From a220d16a4878d4d6b7fa24ec7f2641de275c8d03 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 09:40:28 -0400 Subject: [PATCH 01/11] wai: `wai generate` and `wai score build|render`, with native pins a sink checks against `wai generate` makes a wai.generate.spectral picture with the reference generator at any size and prints its SHA-256; with --instr it makes a listed size of an instruction object of form v0, which it reads as a sink does, and --write-instr writes one. `wai score build` writes a WSCR from integer note fields only; `wai score render` prints samples, pcm_sha256, mixdown_blake3 and score_hash, and --pcm writes the samples. Vectors: tests/vectors/generate/sizes.json (256x128, 640x320) and instr-v0.json (the object, its four listed sizes and 28 objects a sink refuses); score-conformance gains pcm_sha256 for every vector and notes/.json, the authoring form, which `wai score build` turns back into the committed WSCR byte for byte. Every pin was made natively and equals the shared browser binding's output. `wai wrap` refuses a wai.generate.spectral payload that is not a form-v0 object, in every build. No library source changes. Co-Authored-By: Claude Opus 5.5 --- wai/score-conformance/expected_hashes.json | 9 +- wai/score-conformance/notes/arp.json | 12 + wai/score-conformance/notes/chord.json | 11 + wai/score-conformance/notes/scale.json | 16 + wai/wai-rs/Cargo.toml | 10 +- wai/wai-rs/src/bin/wai.rs | 56 ++- wai/wai-rs/src/bin/wai_cli/generate.rs | 148 ++++++++ wai/wai-rs/src/bin/wai_cli/instr.rs | 160 +++++++++ wai/wai-rs/src/bin/wai_cli/score.rs | 233 +++++++++++++ wai/wai-rs/src/bin/wai_score_vectors.rs | 52 ++- wai/wai-rs/tests/cli_generate_score.rs | 324 ++++++++++++++++++ wai/wai-rs/tests/cli_wrap_intent.rs | 41 +++ wai/wai-rs/tests/generate.rs | 100 +++++- .../tests/vectors/generate/instr-v0.json | 146 ++++++++ wai/wai-rs/tests/vectors/generate/sizes.json | 19 + 15 files changed, 1325 insertions(+), 12 deletions(-) create mode 100644 wai/score-conformance/notes/arp.json create mode 100644 wai/score-conformance/notes/chord.json create mode 100644 wai/score-conformance/notes/scale.json create mode 100644 wai/wai-rs/src/bin/wai_cli/generate.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/instr.rs create mode 100644 wai/wai-rs/src/bin/wai_cli/score.rs create mode 100644 wai/wai-rs/tests/cli_generate_score.rs create mode 100644 wai/wai-rs/tests/vectors/generate/instr-v0.json create mode 100644 wai/wai-rs/tests/vectors/generate/sizes.json diff --git a/wai/score-conformance/expected_hashes.json b/wai/score-conformance/expected_hashes.json index 1e2ed5665..c89f733b3 100644 --- a/wai/score-conformance/expected_hashes.json +++ b/wai/score-conformance/expected_hashes.json @@ -3,18 +3,21 @@ "notes": 8, "duration": 34000, "score_hash": "2c8bcab4968ae5e6f0a698ddfc6c4a7bcf2395ec870767ffe09052e1e63dc10e", - "mixdown_hash": "7bf6154fe69a28f40ddda93bbdc6cf5aba597223e6b02233fb2be2771babc3dc" + "mixdown_hash": "7bf6154fe69a28f40ddda93bbdc6cf5aba597223e6b02233fb2be2771babc3dc", + "pcm_sha256": "dc3c7ef82f3a467aaf0cca42c3ab1126bf57ef9ba90a63b55ea723a1185a025f" }, "chord.wscr": { "notes": 3, "duration": 10000, "score_hash": "435a02ba398cd3370b401a9f7e8e39cdecd9e06d015387414f80d4a17e8cee24", - "mixdown_hash": "7472bf7e59de21217645873110193b3db23411b727c08f843070ed4d38e99825" + "mixdown_hash": "7472bf7e59de21217645873110193b3db23411b727c08f843070ed4d38e99825", + "pcm_sha256": "c4e9c4e9f3fe59b76875cd22e16a8251092a921d2ddffefc2c05aa61ef8f79b7" }, "arp.wscr": { "notes": 4, "duration": 12000, "score_hash": "6e6b0652260c535be37fde73fb49d8070f189d62abc6aeebf24ac8a9c4fb1fae", - "mixdown_hash": "559414cf8ddfd1eeb1db4d0accf0e9ae800efec9b888a6708720992572b4dfbc" + "mixdown_hash": "559414cf8ddfd1eeb1db4d0accf0e9ae800efec9b888a6708720992572b4dfbc", + "pcm_sha256": "b8dc096cb7590d30f05dd0e4ea99af4acbd159f3d4f359c509ed07a399d80554" } } diff --git a/wai/score-conformance/notes/arp.json b/wai/score-conformance/notes/arp.json new file mode 100644 index 000000000..ea960e814 --- /dev/null +++ b/wai/score-conformance/notes/arp.json @@ -0,0 +1,12 @@ +{ + "sample_rate": 22050, + "channels": 1, + "duration": 12000, + "numeric": "wai.det.fixed64", + "notes": [ + {"start": 0, "dur": 2200, "phase_inc": 50960238, "gain": 1717986918, "waveform": 1, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 2500, "dur": 2200, "phase_inc": 64205876, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 5000, "dur": 2200, "phase_inc": 76354085, "gain": 1717986918, "waveform": 2, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 7500, "dur": 2200, "phase_inc": 101920476, "gain": 1717986918, "waveform": 3, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500} + ] +} diff --git a/wai/score-conformance/notes/chord.json b/wai/score-conformance/notes/chord.json new file mode 100644 index 000000000..bc7cfe003 --- /dev/null +++ b/wai/score-conformance/notes/chord.json @@ -0,0 +1,11 @@ +{ + "sample_rate": 22050, + "channels": 1, + "duration": 10000, + "numeric": "wai.det.fixed64", + "notes": [ + {"start": 0, "dur": 8000, "phase_inc": 50960238, "gain": 1717986918, "waveform": 2, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 0, "dur": 8000, "phase_inc": 64205876, "gain": 1717986918, "waveform": 2, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 0, "dur": 8000, "phase_inc": 76354085, "gain": 1717986918, "waveform": 2, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500} + ] +} diff --git a/wai/score-conformance/notes/scale.json b/wai/score-conformance/notes/scale.json new file mode 100644 index 000000000..1050d4f64 --- /dev/null +++ b/wai/score-conformance/notes/scale.json @@ -0,0 +1,16 @@ +{ + "sample_rate": 22050, + "channels": 1, + "duration": 34000, + "numeric": "wai.det.fixed64", + "notes": [ + {"start": 0, "dur": 3500, "phase_inc": 50960238, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 4000, "dur": 3500, "phase_inc": 57200933, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 8000, "dur": 3500, "phase_inc": 64205876, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 12000, "dur": 3500, "phase_inc": 68023757, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 16000, "dur": 3500, "phase_inc": 76354085, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 20000, "dur": 3500, "phase_inc": 85704563, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 24000, "dur": 3500, "phase_inc": 96200119, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500}, + {"start": 28000, "dur": 3500, "phase_inc": 101920476, "gain": 1717986918, "waveform": 0, "attack": 220, "decay": 600, "sustain": 2791728742, "release": 1500} + ] +} diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 9668591fe..cd3faaf6e 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -500,7 +500,8 @@ required-features = ["codecs"] # `encode` and `decode` need the `codecs` feature and are compiled out without it; # every other command (wrap, unwrap, inspect, capabilities, registry export) builds -# with --no-default-features, which is how CI runs it. +# with --no-default-features, which is how CI runs it. `generate` needs the feature +# `generate`, and `score` the feature `score`; without them each says so. [[bin]] name = "wai" path = "src/bin/wai.rs" @@ -762,6 +763,13 @@ name = "generate" path = "tests/generate.rs" required-features = ["generate"] +# `wai generate` and `wai score build|render` run as a user runs them, against the +# committed generate vectors and score-conformance/. +[[test]] +name = "cli_generate_score" +path = "tests/cli_generate_score.rs" +required-features = ["generate", "score"] + [[test]] name = "ffi_envelope" path = "tests/ffi_envelope.rs" diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index 7baec4ca8..c69dffc03 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -6,7 +6,10 @@ //! verification; production paths go through the Rust API or the C FFI. //! //! Every command except `encode` and `decode` builds without the `codecs` -//! feature (no system codec libraries), which is how CI runs it. +//! feature (no system codec libraries), which is how CI runs it. `generate` (make a +//! `wai.generate.spectral` picture natively and print its SHA-256) needs the feature +//! `generate`, and `score build|render` (write a `WSCR` from integer note fields, +//! render one and print its digests) the feature `score`; neither needs a codec library. use std::env; use std::fs; @@ -22,6 +25,20 @@ use wai::*; #[path = "wai_cli/stage.rs"] mod stage; +// The form-v0 instruction object of `wai.generate.spectral` (SPEC §5): `wrap` checks a +// payload against it in every build; `generate` makes pictures from one. +#[cfg_attr(not(feature = "generate"), allow(dead_code))] +#[path = "wai_cli/instr.rs"] +mod instr; + +#[cfg(feature = "generate")] +#[path = "wai_cli/generate.rs"] +mod generate; + +#[cfg(feature = "score")] +#[path = "wai_cli/score.rs"] +mod score; + #[cfg(feature = "http")] #[path = "wai_cli/http.rs"] mod http; @@ -53,6 +70,13 @@ fn usage() { eprintln!(" `video_int`, `audio_int` and `splat_int`); `wai stage` alone prints its usage"); eprintln!("wai http publish|codecs|origin|vectors|bench … HTTP delivery (a build with `http`);"); eprintln!(" `wai http` alone prints its usage"); + eprintln!("wai generate --model (--prompt --seed --steps | --instr )"); + eprintln!(" --size WxH -o [--json] make a wai.generate.spectral picture (RGB8) and"); + eprintln!(" print its SHA-256; --sizes WxH,... --write-instr writes the instruction"); + eprintln!(" object of form v0 (SPEC \u{a7}5). A build with `generate`"); + eprintln!("wai score build -o [--json] a WSCR from integer note fields"); + eprintln!("wai score render [--pcm ] [--json] the reference mixdown and its digests"); + eprintln!(" (pcm_sha256, mixdown_blake3, score_hash). A build with `score`"); eprintln!(); eprintln!("encode and decode need a build with the `codecs` feature."); eprintln!("codecs: png jpeg avif jxl jxl-lossless opus flac av1 av1-lossless zstd xz"); @@ -524,7 +548,10 @@ fn license_str(cap: &str) -> &'static str { /// *Candidate capabilities*) is refused in every place — capability, fallback or /// companion — and nothing is written. So is a payload that is not the format SPEC §5 /// pins its integer capability to (*Payloads of the WAI-native integer codecs*): the -/// JSON a tool writes is converted first, never wrapped. +/// JSON a tool writes is converted first, never wrapped. So is a `wai.generate.spectral` +/// payload that is not an instruction object of form v0 (*Payload of +/// `wai.generate.spectral`*); over that generator `replicate` is refused, so its +/// envelope is written with `--intent create`. fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, intent: Option<&str>, companions: &[(String, String)]) -> Result<(), String> { if !wai::codecs::is_known_capability(cap) { @@ -551,6 +578,7 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, (`create` for a generator, `improve` otherwise)"))?; if !companions.is_empty() { let payload = std::fs::read(input).map_err(|e| format!("read {input}: {e}"))?; + check_generate_payload(cap, fallback, &payload, out)?; return wrap_with_companions(cap, payload, out, fallback, intent, companions); } // A sink that lacks `cap` decodes through the fallback (SPEC §4 step 5), so the @@ -563,6 +591,7 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, reconstruction claim"))?; } let payload = std::fs::read(input).map_err(|e| format!("read {input}: {e}"))?; + check_generate_payload(cap, fallback, &payload, out)?; let n_in = payload.len(); let requirement = match fallback { Some(fb) => ModelRequirement::new(cap).with_fallback(fb), @@ -590,6 +619,21 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, Ok(()) } +/// SPEC §5, *Payload of `wai.generate.spectral`*: the payload of that string is an +/// instruction object of form v0, so the reference tool does not write an envelope +/// that names it, as capability or fallback, over any other bytes (its JSON in another +/// order or spacing among them). Checked in every build: reading the object needs no +/// feature. +fn check_generate_payload(cap: &str, fallback: Option<&str>, payload: &[u8], out: &str) -> Result<(), String> { + let g = wai::codecs::CAP_GENERATE_SPECTRAL; + if cap == g || fallback == Some(g) { + instr::Instruction::parse(payload).map_err(|e| format!( + "not writing {out}: the payload of {g} is an instruction object of form v0 (SPEC \u{a7}5), \ + and this one is not: {e}"))?; + } + Ok(()) +} + /// The `WAI2` half of [`wrap`]: the base payload as the one selectable /// rendition, each companion bound to it. fn wrap_with_companions(cap: &str, payload: Vec, out: &str, fallback: Option<&str>, @@ -836,6 +880,14 @@ fn main() -> ExitCode { }, #[cfg(not(feature = "http"))] "http" => Err("built without http: `wai http` needs a build with the feature `http`".into()), + #[cfg(feature = "generate")] + "generate" => generate::main(&args[1..]), + #[cfg(not(feature = "generate"))] + "generate" => Err("built without generate: `wai generate` needs a build with the feature `generate`".into()), + #[cfg(feature = "score")] + "score" => score::main(&args[1..]), + #[cfg(not(feature = "score"))] + "score" => Err("built without score: `wai score` needs a build with the feature `score`".into()), _ => { usage(); return ExitCode::from(2); } }; match r { diff --git a/wai/wai-rs/src/bin/wai_cli/generate.rs b/wai/wai-rs/src/bin/wai_cli/generate.rs new file mode 100644 index 000000000..429ed096b --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/generate.rs @@ -0,0 +1,148 @@ +//! `wai generate` — make a `wai.generate.spectral` picture natively, with the +//! reference generator (`wai::generate`), and print its SHA-256: the native pin a +//! page or another sink checks its own output against (SPEC §5, *Payload of +//! `wai.generate.spectral`*; §7, *Digests*). A build with the feature `generate`. +//! +//! ```text +//! wai generate --model --prompt --seed --steps --size WxH -o [--json] +//! wai generate --model --instr --size WxH -o [--json] +//! wai generate --model --prompt --seed --steps --sizes WxH,WxH,... --write-instr +//! ``` +//! +//! The first form makes one picture from flags; the second makes one listed size of +//! a form-v0 instruction object, which it reads as a sink does (the model's SHA-256 +//! must be the object's `model`, and the size must be listed); the third writes the +//! canonical object for the model and the flags. Every form holds the flags to the +//! bounds of form v0, so whatever it makes, an instruction object can name. +//! +//! The output is RGB8 — rows top to bottom, pixels left to right, R, G, B bytes — +//! `w·h·3` bytes. `--json` prints `{"w","h","bytes","sha256"}`. + +use super::instr::{self, Instruction}; +use super::opt; +use serde_json::Value; +use wai::generate::{generate, SpectralModel}; + +const USAGE: &str = "usage: + wai generate --model --prompt --seed --steps --size WxH -o [--json] + wai generate --model --instr --size WxH -o [--json] + wai generate --model --prompt --seed --steps --sizes WxH,... --write-instr "; + +/// Read the model file and check it before the reference generator reads it: every +/// field `SpectralModel::from_value` unwraps is present with its type, and every value +/// lies within bounds under which no sum or product of `generate` leaves `i64` at any +/// size form v0 allows. The committed model sits well inside them (a 1024-entry LUT of +/// ±16384, 6 amplitude bits, 8 + 8 components, lexicon values within ±470). A file +/// outside them is refused with a reason rather than panicking or wrapping. +fn load_model(path: &str) -> Result<(Vec, SpectralModel), String> { + let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?; + let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: not JSON: {e}"))?; + let within = |k: &str, lo: i64, hi: i64| -> Result { + v.get(k).and_then(Value::as_i64).filter(|n| (lo..=hi).contains(n)) + .ok_or_else(|| format!("{path}: `{k}` is not an integer from {lo} to {hi}")) + }; + let lut_size = within("lut_size", 1, 65536)?; + let lut = v.get("sine_lut").and_then(Value::as_array).ok_or_else(|| format!("{path}: `sine_lut` is not an array"))?; + if lut.len() as i64 != lut_size { + return Err(format!("{path}: `sine_lut` has {} entries and `lut_size` is {lut_size}", lut.len())); + } + if lut.iter().any(|n| n.as_i64().is_none_or(|n| n.unsigned_abs() > 1 << 15)) { + return Err(format!("{path}: a `sine_lut` entry is not an integer within ±2^15")); + } + let co = v.get("color_offsets").and_then(Value::as_array).ok_or_else(|| format!("{path}: no `color_offsets`"))?; + if co.len() != 3 || co.iter().any(|n| n.as_i64().is_none_or(|n| n.unsigned_abs() > 1 << 20)) { + return Err(format!("{path}: `color_offsets` is not three integers within ±2^20")); + } + within("freq_max", 1, 4096)?; + within("amp_bits", 0, 12)?; + if v.get("lut_bits").is_some() { + within("lut_bits", 0, 15)?; + } + within("base_components", 0, 64)?; + within("step_components", 0, 64)?; + if let Some(lex) = v.get("lexicon") { + let lex = lex.as_object().ok_or_else(|| format!("{path}: `lexicon` is not an object"))?; + for (word, e) in lex { + let ok = e.as_array().is_some_and(|a| { + a.len() <= 10 && a.iter().all(|n| n.as_i64().is_some_and(|n| n.unsigned_abs() <= 1 << 16)) + }); + if !ok { + return Err(format!("{path}: lexicon entry `{word}` is not at most 10 integers within ±2^16")); + } + } + } + Ok((bytes, SpectralModel::from_value(&v))) +} + +fn num(args: &[String], flag: &str) -> Result { + let s = opt(args, flag).ok_or_else(|| format!("{flag} is required\n{USAGE}"))?; + s.parse().map_err(|_| format!("{flag} `{s}` is not a number in range")) +} + +/// The flags' `(prompt, seed, steps)`. +fn flags(args: &[String]) -> Result<(String, u32, u32), String> { + let prompt = opt(args, "--prompt").ok_or_else(|| format!("--prompt is required\n{USAGE}"))?.to_string(); + Ok((prompt, num::(args, "--seed")?, num::(args, "--steps")?)) +} + +fn write_and_report(rgb: &[u8], w: u32, h: u32, out: &str, json: bool) -> Result<(), String> { + std::fs::write(out, rgb).map_err(|e| format!("write {out}: {e}"))?; + let sha = wai::pin::sha256_hex(rgb); + if json { + println!("{}", serde_json::json!({ "w": w, "h": h, "bytes": rgb.len(), "sha256": sha })); + } else { + println!("generated → {out} ({w}x{h} RGB8, {} B) sha256 {sha}", rgb.len()); + } + Ok(()) +} + +pub fn main(args: &[String]) -> Result<(), String> { + if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") { + return Err(USAGE.into()); + } + let model_path = opt(args, "--model").ok_or_else(|| format!("--model is required\n{USAGE}"))?; + let json = args.iter().any(|a| a == "--json"); + + if let Some(dest) = opt(args, "--write-instr") { + let (prompt, seed, steps) = flags(args)?; + let list = opt(args, "--sizes").ok_or_else(|| format!("--sizes is required with --write-instr\n{USAGE}"))?; + let sizes = list.split(',').map(instr::parse_size).collect::, _>>()?; + let (bytes, _) = load_model(model_path)?; + let i = Instruction { model_sha256: wai::pin::sha256_hex(&bytes), prompt, seed, steps, sizes }; + i.check()?; + let object = i.canonical(); + // What is written is what a sink reads. + debug_assert_eq!(Instruction::parse(&object).as_ref(), Ok(&i)); + std::fs::write(dest, &object).map_err(|e| format!("write {dest}: {e}"))?; + println!("instruction object → {dest} ({} B)", object.len()); + return Ok(()); + } + + let out = opt(args, "-o").ok_or_else(|| format!("-o is required\n{USAGE}"))?; + let (w, h) = instr::parse_size(opt(args, "--size").ok_or_else(|| format!("--size is required\n{USAGE}"))?)?; + let (bytes, model) = load_model(model_path)?; + + let (prompt, seed, steps) = if let Some(path) = opt(args, "--instr") { + let object = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?; + let i = Instruction::parse(&object).map_err(|e| format!("{path}: refused: {e}"))?; + let held = wai::pin::sha256_hex(&bytes); + if held != i.model_sha256 { + return Err(format!("{path}: refused: the object's model is sha256:{}, and {model_path} is sha256:{held}", + i.model_sha256)); + } + if !i.lists(w, h) { + return Err(format!("{path}: refused: {w}x{h} is not a listed size; a sink makes only listed sizes")); + } + (i.prompt, i.seed, i.steps) + } else { + let (prompt, seed, steps) = flags(args)?; + // The same bounds as an instruction object, so every pin this writes is one + // an object can name. + Instruction { model_sha256: wai::pin::sha256_hex(&bytes), prompt: prompt.clone(), seed, steps, sizes: vec![(w, h)] } + .check()?; + (prompt, seed, steps) + }; + + let rgb = generate(&model, &prompt, seed as u64, steps as usize, h as usize, w as usize); + write_and_report(&rgb, w, h, out, json) +} diff --git a/wai/wai-rs/src/bin/wai_cli/instr.rs b/wai/wai-rs/src/bin/wai_cli/instr.rs new file mode 100644 index 000000000..82d5c1648 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/instr.rs @@ -0,0 +1,160 @@ +//! The payload of `wai.generate.spectral`: the instruction object, form v0 (SPEC §5, +//! *Payload of `wai.generate.spectral`*). One canonical JSON object names the model by +//! its SHA-256 and gives the prompt, the seed, the steps and every size a sink may +//! make. `wai wrap` checks a `wai.generate.spectral` payload against it before it +//! writes an envelope, and `wai generate --instr` makes a listed size from one. +//! +//! Reading the object needs no feature: it is JSON and a comparison of bytes. The +//! object is read by its members and then written again in the canonical form; it is +//! accepted only if that equals the bytes given, so another order, whitespace, an +//! escape, a duplicate member or a trailing line feed is refused. + +use serde_json::Value; + +/// The members of a form-v0 object, in their canonical (ascending byte) order. +pub const MEMBERS: [&str; 5] = ["model", "prompt", "seed", "sizes", "steps"]; +/// The longest prompt, in bytes. +pub const MAX_PROMPT: usize = 256; +/// The largest `steps`. +pub const MAX_STEPS: u64 = 64; +/// The most sizes one object lists. +pub const MAX_SIZES: usize = 16; +/// The longest side of a listed size, in pixels. +pub const MAX_SIDE: u64 = 4096; +/// The most pixels at a listed size (`w·h`), 2^23. +pub const MAX_PIXELS: u64 = 1 << 23; + +/// A form-v0 instruction object. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Instruction { + /// The SHA-256 of the model file, as 64 lowercase hexadecimal digits. + pub model_sha256: String, + pub prompt: String, + pub seed: u32, + pub steps: u32, + /// `(w, h)`, in strictly ascending order of `w` and then `h`. + pub sizes: Vec<(u32, u32)>, +} + +fn is_hex64(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// Whether `prompt` is a form-v0 prompt: at most [`MAX_PROMPT`] bytes, each a +/// printable ASCII character other than `"` and `\`, so its JSON string needs no +/// escape and its bytes are the same in every JSON writer. +pub fn prompt_ok(prompt: &str) -> Result<(), String> { + if prompt.len() > MAX_PROMPT { + return Err(format!("the prompt is {} bytes; form v0 allows at most {MAX_PROMPT}", prompt.len())); + } + if let Some(b) = prompt.bytes().find(|&b| !(0x20..=0x7e).contains(&b) || b == b'"' || b == b'\\') { + return Err(format!("the prompt holds byte 0x{b:02x}; form v0 allows 0x20-0x7e other than '\"' and '\\'")); + } + Ok(()) +} + +impl Instruction { + /// The canonical serialization: the members in ascending byte order, no + /// whitespace, integers in decimal. The prompt is written as it is, which + /// [`prompt_ok`] makes a valid JSON string. + pub fn canonical(&self) -> Vec { + let sizes: Vec = self.sizes.iter().map(|(w, h)| format!("[{w},{h}]")).collect(); + format!( + "{{\"model\":\"sha256:{}\",\"prompt\":\"{}\",\"seed\":{},\"sizes\":[{}],\"steps\":{}}}", + self.model_sha256, self.prompt, self.seed, sizes.join(","), self.steps + ) + .into_bytes() + } + + /// Every rule of form v0 that is not about the bytes: the model digest, the + /// prompt, the ranges and the order of the sizes. + pub fn check(&self) -> Result<(), String> { + if !is_hex64(&self.model_sha256) { + return Err("`model` is not `sha256:` and 64 lowercase hexadecimal digits".into()); + } + prompt_ok(&self.prompt)?; + if !(1..=MAX_STEPS).contains(&(self.steps as u64)) { + return Err(format!("`steps` is {}; form v0 allows 1 to {MAX_STEPS}", self.steps)); + } + if self.sizes.is_empty() || self.sizes.len() > MAX_SIZES { + return Err(format!("`sizes` lists {} sizes; form v0 allows 1 to {MAX_SIZES}", self.sizes.len())); + } + for &(w, h) in &self.sizes { + size_ok(w as u64, h as u64)?; + } + if self.sizes.windows(2).any(|p| p[0] >= p[1]) { + return Err("`sizes` is not in strictly ascending order of w and then h".into()); + } + Ok(()) + } + + /// Read a form-v0 object from the payload bytes. Refuses anything that is not + /// exactly the canonical serialization of an object that passes [`Self::check`]. + pub fn parse(bytes: &[u8]) -> Result { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not JSON: {e}"))?; + let o = v.as_object().ok_or("not a JSON object")?; + for k in o.keys() { + if !MEMBERS.contains(&k.as_str()) { + return Err(format!("member `{k}` is not one of form v0's")); + } + } + for k in MEMBERS { + if !o.contains_key(k) { + return Err(format!("member `{k}` is missing")); + } + } + let model = o["model"].as_str().ok_or("`model` is not a string")?; + let model_sha256 = model.strip_prefix("sha256:").ok_or("`model` does not begin `sha256:`")?.to_string(); + let prompt = o["prompt"].as_str().ok_or("`prompt` is not a string")?.to_string(); + let seed = o["seed"].as_u64().ok_or("`seed` is not a non-negative integer")?; + let seed = u32::try_from(seed).map_err(|_| format!("`seed` is {seed}; form v0 allows 0 to 4294967295"))?; + let steps = o["steps"].as_u64().ok_or("`steps` is not a non-negative integer")?; + if !(1..=MAX_STEPS).contains(&steps) { + return Err(format!("`steps` is {steps}; form v0 allows 1 to {MAX_STEPS}")); + } + let list = o["sizes"].as_array().ok_or("`sizes` is not an array")?; + let mut sizes = Vec::with_capacity(list.len().min(MAX_SIZES)); + for s in list { + let pair = s.as_array().filter(|a| a.len() == 2).ok_or("a size is not a pair [w, h]")?; + let (w, h) = (pair[0].as_u64(), pair[1].as_u64()); + let (Some(w), Some(h)) = (w, h) else { + return Err("a size's w or h is not a non-negative integer".into()); + }; + size_ok(w, h)?; + sizes.push((w as u32, h as u32)); + } + let i = Instruction { model_sha256, prompt, seed, steps: steps as u32, sizes }; + i.check()?; + if i.canonical() != bytes { + return Err("not in canonical form: members in ascending byte order, no whitespace, \ + no escape, nothing after the object" + .into()); + } + Ok(i) + } + + /// Whether `(w, h)` is a listed size. + pub fn lists(&self, w: u32, h: u32) -> bool { + self.sizes.contains(&(w, h)) + } +} + +/// One size within form v0's bounds. +pub fn size_ok(w: u64, h: u64) -> Result<(), String> { + if !(1..=MAX_SIDE).contains(&w) || !(1..=MAX_SIDE).contains(&h) { + return Err(format!("size {w}x{h}: each side is 1 to {MAX_SIDE} pixels in form v0")); + } + if w * h > MAX_PIXELS { + return Err(format!("size {w}x{h} is {} pixels; form v0 allows at most {MAX_PIXELS}", w * h)); + } + Ok(()) +} + +/// `WxH` → `(w, h)`, within form v0's bounds. +pub fn parse_size(s: &str) -> Result<(u32, u32), String> { + let (w, h) = s.split_once(['x', 'X']).ok_or_else(|| format!("size `{s}` is not WxH"))?; + let w: u64 = w.parse().map_err(|_| format!("size `{s}`: width is not an integer"))?; + let h: u64 = h.parse().map_err(|_| format!("size `{s}`: height is not an integer"))?; + size_ok(w, h)?; + Ok((w as u32, h as u32)) +} diff --git a/wai/wai-rs/src/bin/wai_cli/score.rs b/wai/wai-rs/src/bin/wai_cli/score.rs new file mode 100644 index 000000000..11218e8f7 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/score.rs @@ -0,0 +1,233 @@ +//! `wai score` — write a `wai.audio.score` (`WSCR`) from integer note fields, and render +//! one with the reference synth, printing the digests a sink checks its own mixdown +//! against ([score](../../../../extensions/score.md) §4). A build with the feature +//! `score`. +//! +//! ```text +//! wai score build -o [--json] +//! wai score render [--pcm ] [--json] +//! ``` +//! +//! `build` reads the authoring form (score §2.3): the contract and every note as +//! integers, `phase_inc`, `gain` and `sustain` as raw Q32.32 bits. No floating-point +//! value is read: a number with a fraction or an exponent is refused, so a note's +//! pitch is whatever integer the author committed, on every machine. It writes the +//! container with the reference writer (`Score::to_bytes`). +//! +//! `render` reconstructs the canonical mono `i16` mixdown and prints `samples`, +//! `sample_rate`, `channels`, `notes`, `pcm_sha256` (SHA-256 over the samples as +//! signed 16-bit little-endian integers, SPEC §7 *Digests*), `mixdown_blake3` (the +//! extension's mixdown hash) and `score_hash`. `--pcm` writes those samples. + +use super::opt; +use serde_json::{Map, Value}; +use wai::score::{Note, Score, ScoreContract}; +use wai::world::{Fx, NUMERIC_FIXED64}; + +const USAGE: &str = "usage: + wai score build -o [--json] + wai score render [--pcm ] [--json]"; + +/// The longest mixdown this tool renders, in samples (2^26: about 50 minutes at +/// 22 050 Hz). The reference synth holds the whole mix in memory, eight bytes a +/// sample, so a longer contract is refused before anything is allocated. +pub const MAX_SAMPLES: u64 = 1 << 26; + +const NOTE_MEMBERS: [&str; 9] = ["start", "dur", "phase_inc", "gain", "waveform", "attack", "decay", "sustain", "release"]; +const TOP_MEMBERS: [&str; 5] = ["sample_rate", "channels", "duration", "numeric", "notes"]; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn members(o: &Map, allowed: &[&str], what: &str) -> Result<(), String> { + match o.keys().find(|k| !allowed.contains(&k.as_str())) { + Some(k) => Err(format!("{what}: member `{k}` is not one of {allowed:?}")), + None => Ok(()), + } +} + +fn uint(o: &Map, k: &str, what: &str) -> Result { + match o.get(k) { + None => Err(format!("{what}: `{k}` is missing")), + Some(v) => v.as_u64().ok_or_else(|| format!("{what}: `{k}` is not a non-negative integer (got {v})")), + } +} + +/// A raw Q32.32 value in `0 ..= 2^32` (0 to 1.0). +fn unit(o: &Map, k: &str, what: &str, hi_inclusive: bool) -> Result { + let n = uint(o, k, what)?; + let one = 1u64 << 32; + if n > one || (!hi_inclusive && n == one) { + let range = if hi_inclusive { "0 to 4294967296 (1.0)" } else { "0 to 4294967295 (below 1.0)" }; + return Err(format!("{what}: `{k}` is {n}; it is raw Q32.32 bits from {range}")); + } + Ok(Fx::from_bits(n as i64)) +} + +/// Read the authoring form into a score, holding it to rules under which the +/// reference synth renders every note whole: one channel, a duration of at most +/// [`MAX_SAMPLES`], `phase_inc` below one cycle a sample, `gain` and `sustain` from 0 +/// to 1.0, a reference waveform (0 to 3), and every note's release ending by the +/// contract's duration. +pub fn from_notes_json(bytes: &[u8]) -> Result { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not JSON: {e}"))?; + let o = v.as_object().ok_or("not a JSON object")?; + members(o, &TOP_MEMBERS, "score")?; + let sample_rate = uint(o, "sample_rate", "score")?; + if sample_rate == 0 || sample_rate > u32::MAX as u64 { + return Err(format!("score: `sample_rate` is {sample_rate}; it is 1 to {}", u32::MAX)); + } + let channels = uint(o, "channels", "score")?; + if channels != 1 { + return Err(format!("score: `channels` is {channels}; the reference mixdown is one channel")); + } + let duration = uint(o, "duration", "score")?; + if duration == 0 || duration > MAX_SAMPLES { + return Err(format!("score: `duration` is {duration}; this tool takes 1 to {MAX_SAMPLES} samples")); + } + if let Some(n) = o.get("numeric").filter(|n| n.as_str() != Some(NUMERIC_FIXED64)) { + return Err(format!("score: `numeric` is {n}; a score renders in {NUMERIC_FIXED64}")); + } + let list = o.get("notes").and_then(Value::as_array).ok_or("score: `notes` is not an array")?; + if list.len() > u32::MAX as usize { + return Err("score: more notes than the container counts".into()); + } + let mut notes = Vec::with_capacity(list.len()); + for (i, n) in list.iter().enumerate() { + let what = format!("note {i}"); + let n = n.as_object().ok_or_else(|| format!("{what}: not an object"))?; + members(n, &NOTE_MEMBERS, &what)?; + let waveform = uint(n, "waveform", &what)?; + if waveform > 3 { + return Err(format!("{what}: `waveform` is {waveform}; the reference timbres are 0 saw, 1 square, \ + 2 triangle, 3 25%-pulse")); + } + let note = Note { + start: uint(n, "start", &what)?, + dur: uint(n, "dur", &what)?, + phase_inc: unit(n, "phase_inc", &what, false)?, + gain: unit(n, "gain", &what, true)?, + waveform: waveform as u8, + attack: uint(n, "attack", &what)?, + decay: uint(n, "decay", &what)?, + sustain: unit(n, "sustain", &what, true)?, + release: uint(n, "release", &what)?, + }; + for (k, t) in [("attack", note.attack), ("decay", note.decay)] { + if t > duration { + return Err(format!("{what}: `{k}` is {t} samples, longer than the score ({duration})")); + } + } + let end = note.start.checked_add(note.dur).and_then(|e| e.checked_add(note.release)); + match end { + Some(e) if e <= duration => {} + _ => { + return Err(format!( + "{what}: start + dur + release passes the score's duration ({duration}), so the mixdown \ + would cut its release; lengthen `duration` to cover it" + )) + } + } + notes.push(note); + } + let contract = ScoreContract { sample_rate: sample_rate as u32, numeric: NUMERIC_FIXED64.into(), channels: 1, duration }; + Ok(Score { contract_bytes: contract.to_canonical_json(), contract, notes }) +} + +/// The note count a `WSCR` declares, checked against the bytes its notes section +/// holds, before `Score::from_bytes` reserves room for that many notes. A file of a few +/// bytes can declare 2^32 − 1 notes; the reader would try to reserve them all. +fn declared_notes_fit(b: &[u8]) -> Result<(), String> { + let rd32 = |p: usize| b.get(p..p + 4).map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize); + if b.len() < 6 || &b[..4] != b"WSCR" { + return Ok(()); // the reader reports it + } + let n = u16::from_le_bytes([b[4], b[5]]) as usize; + let pay = 6 + n * 9; + for i in 0..n { + let e = 6 + i * 9; + if b.get(e) != Some(&0x02) { + continue; + } + let (Some(off), Some(len)) = (rd32(e + 1), rd32(e + 5)) else { return Ok(()) }; + let Some(count) = pay.checked_add(off).and_then(rd32) else { return Ok(()) }; + if count.checked_mul(65).and_then(|x| x.checked_add(4)).is_none_or(|need| need > len) { + return Err(format!("the notes section declares {count} notes and holds {len} bytes \ + (65 bytes a note, after a 4-byte count)")); + } + } + Ok(()) +} + +/// Read a `WSCR` and check it can be rendered: the reader's checks, then a duration of +/// at most [`MAX_SAMPLES`] and note times whose sums (start + dur + release, dur + +/// release, attack + decay, which the synth forms in `u64`) do not overflow. A note cut +/// by the duration renders as the extension says, cut. +fn read_score(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?; + declared_notes_fit(&bytes).map_err(|e| format!("{path}: {e}"))?; + let s = Score::from_bytes(&bytes).map_err(|e| format!("{path}: {e}"))?; + if s.contract.duration > MAX_SAMPLES { + return Err(format!("{path}: the score is {} samples; this tool renders at most {MAX_SAMPLES}", + s.contract.duration)); + } + for (i, n) in s.notes.iter().enumerate() { + let sums = n.start.checked_add(n.dur).and_then(|e| e.checked_add(n.release)).is_some() + && n.attack.checked_add(n.decay).is_some(); + if !sums { + return Err(format!("{path}: note {i}: a sum of its times overflows 64 bits")); + } + } + Ok(s) +} + +fn build(args: &[String]) -> Result<(), String> { + let input = args.first().ok_or_else(|| USAGE.to_string())?; + let out = opt(args, "-o").ok_or_else(|| format!("-o is required\n{USAGE}"))?; + let bytes = std::fs::read(input).map_err(|e| format!("read {input}: {e}"))?; + let s = from_notes_json(&bytes).map_err(|e| format!("{input}: refused: {e}"))?; + let wscr = s.to_bytes(); + std::fs::write(out, &wscr).map_err(|e| format!("write {out}: {e}"))?; + let score_hash = hex(&s.score_hash()); + if args.iter().any(|a| a == "--json") { + println!("{}", serde_json::json!({ + "bytes": wscr.len(), "notes": s.notes.len(), "duration": s.contract.duration, "score_hash": score_hash, + })); + } else { + println!("built → {out} ({} B, {} notes, {} samples) score_hash {score_hash}", + wscr.len(), s.notes.len(), s.contract.duration); + } + Ok(()) +} + +fn render(args: &[String]) -> Result<(), String> { + let input = args.first().ok_or_else(|| USAGE.to_string())?; + let s = read_score(input)?; + let pcm: Vec = s.mixdown().iter().flat_map(|x| x.to_le_bytes()).collect(); + let pcm_sha256 = wai::pin::sha256_hex(&pcm); + let (mixdown, score_hash) = (hex(&s.mixdown_hash()), hex(&s.score_hash())); + if let Some(p) = opt(args, "--pcm") { + std::fs::write(p, &pcm).map_err(|e| format!("write {p}: {e}"))?; + } + if args.iter().any(|a| a == "--json") { + println!("{}", serde_json::json!({ + "samples": pcm.len() / 2, "sample_rate": s.contract.sample_rate, "channels": s.contract.channels, + "notes": s.notes.len(), "pcm_sha256": pcm_sha256, "mixdown_blake3": mixdown, "score_hash": score_hash, + })); + } else { + println!("rendered {input}: {} samples at {} Hz, {} notes", pcm.len() / 2, s.contract.sample_rate, s.notes.len()); + println!(" pcm_sha256 {pcm_sha256}"); + println!(" mixdown_blake3 {mixdown}"); + println!(" score_hash {score_hash}"); + } + Ok(()) +} + +pub fn main(args: &[String]) -> Result<(), String> { + match args.first().map(String::as_str) { + Some("build") => build(&args[1..]), + Some("render") => render(&args[1..]), + _ => Err(USAGE.into()), + } +} diff --git a/wai/wai-rs/src/bin/wai_score_vectors.rs b/wai/wai-rs/src/bin/wai_score_vectors.rs index 6f688b877..8eaf7d62d 100644 --- a/wai/wai-rs/src/bin/wai_score_vectors.rs +++ b/wai/wai-rs/src/bin/wai_score_vectors.rs @@ -5,6 +5,12 @@ //! The music counterpart of spatial audio's mixdown-equivalence, with //! the recorded objects replaced by note events. //! +//! Each vector also states `pcm_sha256`, the SHA-256 of the same samples as +//! signed 16-bit little-endian integers (SPEC §7 *Digests*), so a verifier that +//! holds only SHA-256 checks the samples; and `notes/.json` holds its notes +//! in the authoring form `wai score build` reads (score §2.3), every field an +//! integer, which `verify` requires to be exactly the vector's notes. +//! //! wai_score_vectors gen | verify use std::path::Path; @@ -28,6 +34,31 @@ fn main() { fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } + +/// SHA-256 of the mixdown as signed 16-bit little-endian samples. +fn pcm_sha256(s: &Score) -> String { + let pcm: Vec = s.mixdown().iter().flat_map(|x| x.to_le_bytes()).collect(); + wai::pin::sha256_hex(&pcm) +} + +/// The authoring form of a score (score §2.3): the contract, then one note a line, +/// `phase_inc`, `gain` and `sustain` as their raw Q32.32 bits. +fn notes_json(s: &Score) -> String { + let c = &s.contract; + let mut out = format!( + "{{\n \"sample_rate\": {},\n \"channels\": {},\n \"duration\": {},\n \"numeric\": \"{}\",\n \"notes\": [\n", + c.sample_rate, c.channels, c.duration, c.numeric + ); + let rows: Vec = s.notes.iter().map(|n| format!( + " {{\"start\": {}, \"dur\": {}, \"phase_inc\": {}, \"gain\": {}, \"waveform\": {}, \ + \"attack\": {}, \"decay\": {}, \"sustain\": {}, \"release\": {}}}", + n.start, n.dur, n.phase_inc.to_bits(), n.gain.to_bits(), n.waveform, + n.attack, n.decay, n.sustain.to_bits(), n.release + )).collect(); + out.push_str(&rows.join(",\n")); + out.push_str("\n ]\n}\n"); + out +} fn f(n: i32, d: i32) -> Fx { Fx::from_int(n) / Fx::from_int(d) } @@ -86,10 +117,13 @@ fn vectors() -> Vec<(&'static str, Score)> { fn generate(dir: &Path) { let v = dir.join("vectors"); + let nd = dir.join("notes"); std::fs::create_dir_all(&v).expect("mkdir"); + std::fs::create_dir_all(&nd).expect("mkdir"); let mut manifest = serde_json::Map::new(); for (name, s) in vectors() { std::fs::write(v.join(format!("{name}.wscr")), s.to_bytes()).expect("write wscr"); + std::fs::write(nd.join(format!("{name}.json")), notes_json(&s)).expect("write notes"); manifest.insert( format!("{name}.wscr"), serde_json::json!({ @@ -97,6 +131,7 @@ fn generate(dir: &Path) { "duration": s.contract.duration, "score_hash": hex(&s.score_hash()), "mixdown_hash": hex(&s.mixdown_hash()), + "pcm_sha256": pcm_sha256(&s), }), ); } @@ -115,9 +150,18 @@ fn verify(dir: &Path) { let mut fails = 0; for (file, spec) in m.as_object().expect("obj") { let s = Score::from_bytes(&std::fs::read(v.join(file)).expect("wscr")).expect("parse"); - let ok = hex(&s.mixdown_hash()) == spec["mixdown_hash"].as_str().unwrap() - && hex(&s.score_hash()) == spec["score_hash"].as_str().unwrap(); - println!("{} {file}", if ok { "PASS" } else { "FAIL" }); + let name = file.strip_suffix(".wscr").expect("a .wscr file"); + let notes = std::fs::read_to_string(dir.join("notes").join(format!("{name}.json"))).unwrap_or_default(); + let checks = [ + ("mixdown_hash", hex(&s.mixdown_hash()) == spec["mixdown_hash"].as_str().unwrap()), + ("score_hash", hex(&s.score_hash()) == spec["score_hash"].as_str().unwrap()), + ("pcm_sha256", spec["pcm_sha256"].as_str() == Some(pcm_sha256(&s).as_str())), + ("notes/*.json", notes == notes_json(&s)), + ]; + let failed: Vec<&str> = checks.iter().filter(|(_, ok)| !ok).map(|(k, _)| *k).collect(); + let ok = failed.is_empty(); + println!("{} {file}{}", if ok { "PASS" } else { "FAIL" }, + if ok { String::new() } else { format!(" ({})", failed.join(", ")) }); if !ok { fails += 1; } @@ -126,5 +170,5 @@ fn verify(dir: &Path) { eprintln!("{fails} failed — NOT conformant"); std::process::exit(1); } - println!("all vectors pass: mixdown-equivalence holds for scores"); + println!("all vectors pass: mixdown-equivalence holds for scores (mixdown BLAKE3, PCM SHA-256, score hash, authoring form)"); } diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs new file mode 100644 index 000000000..bb60839a8 --- /dev/null +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -0,0 +1,324 @@ +//! `wai generate` and `wai score build|render`, run as a user runs them: the native +//! pins a page checks its own output against. Each output is checked against a +//! committed vector — `tests/vectors/generate/` for pictures, `score-conformance/` +//! for scores — and every refusal is checked to write nothing. +//! +//! Cargo.toml declares this target with required-features = ["generate", "score"], so a +//! run without them stops with an error instead of reporting an empty binary as passed. +//! The two larger listed sizes of `instr-v0.json` take seconds in a debug build and run +//! in the release step. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::Value; +use wai::container::Wai; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} +fn gen_dir() -> PathBuf { + root().join("tests/vectors/generate") +} +fn score_dir() -> PathBuf { + root().join("../score-conformance") +} +fn model() -> PathBuf { + gen_dir().join("generate_spectral_model.json") +} +fn jload(p: &Path) -> Value { + serde_json::from_slice(&std::fs::read(p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))).unwrap() +} +fn sha256_hex(b: &[u8]) -> String { + wai::pin::sha256(b).iter().map(|x| format!("{x:02x}")).collect() +} + +fn scratch(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_generate_score"); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join(name); + let _ = std::fs::remove_file(&p); + p +} + +fn wai(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")).args(args).output().expect("run wai") +} + +fn ok(run: &Output) -> String { + assert!(run.status.success(), "wai failed: {}", String::from_utf8_lossy(&run.stderr)); + String::from_utf8(run.stdout.clone()).unwrap() +} + +fn refused(run: &Output, out: &Path, what: &str) -> String { + let stderr = String::from_utf8_lossy(&run.stderr).into_owned(); + assert!(!run.status.success(), "{what}: must be refused; stdout: {}", String::from_utf8_lossy(&run.stdout)); + assert!(!out.exists(), "{what}: a refusal writes nothing"); + stderr +} + +/// The `--json` line `wai generate` prints, checked against the file it wrote. +fn made(stdout: &str, out: &Path, w: u64, h: u64) -> String { + let v: Value = serde_json::from_str(stdout.trim()).unwrap(); + let rgb = std::fs::read(out).unwrap(); + assert_eq!((v["w"].as_u64(), v["h"].as_u64()), (Some(w), Some(h))); + assert_eq!(v["bytes"].as_u64(), Some(w * h * 3)); + assert_eq!(rgb.len() as u64, w * h * 3, "RGB8, w·h·3 bytes"); + let sha = v["sha256"].as_str().unwrap().to_string(); + assert_eq!(sha256_hex(&rgb), sha, "the printed SHA-256 is the file's"); + sha +} + +fn m() -> String { + model().to_string_lossy().into_owned() +} +fn p(path: &Path) -> &str { + path.to_str().unwrap() +} + +#[test] +fn generate_reproduces_the_committed_vectors() { + // 96×96: the committed vector and its SHA-256 sidecar. + let pin = jload(&gen_dir().join("generate.json")); + let side = jload(&gen_dir().join("sha256.json")); + let out = scratch("p96.rgb"); + let seed = pin["seed"].to_string(); + let steps = pin["steps"].to_string(); + let run = wai(&["generate", "--model", &m(), "--prompt", pin["prompt"].as_str().unwrap(), "--seed", &seed, + "--steps", &steps, "--size", "96x96", "-o", p(&out), "--json"]); + assert_eq!(made(&ok(&run), &out, 96, 96), side["generate"]["sha256"].as_str().unwrap()); + // sizes.json: the same prompt, seed and steps at its non-square sizes. + let sizes = jload(&gen_dir().join("sizes.json")); + for s in sizes["sizes"].as_array().unwrap() { + let (w, h) = (s["w"].as_u64().unwrap(), s["h"].as_u64().unwrap()); + let out = scratch(&format!("p{w}x{h}.rgb")); + let run = wai(&["generate", "--model", &m(), "--prompt", sizes["prompt"].as_str().unwrap(), "--seed", &seed, + "--steps", &steps, "--size", &format!("{w}x{h}"), "-o", p(&out), "--json"]); + assert_eq!(made(&ok(&run), &out, w, h), s["sha256"].as_str().unwrap(), "{w}x{h}"); + } +} + +#[test] +fn generate_refuses_flags_outside_form_v0() { + let base = ["generate", "--model", &m(), "--prompt", "calm ocean", "--seed", "3", "--steps", "4"]; + for (extra, what) in [ + (&["--size", "0x128"][..], "a side of 0"), + (&["--size", "4097x1"][..], "a side past 4096"), + (&["--size", "4096x4096"][..], "more than 2^23 pixels"), + (&["--size", "256"][..], "not WxH"), + ] { + let out = scratch("flags.rgb"); + let mut args: Vec<&str> = base.to_vec(); + args.extend_from_slice(extra); + args.extend_from_slice(&["-o", p(&out)]); + refused(&wai(&args), &out, what); + } + for (flag, value, what) in [ + ("--seed", "4294967296", "a seed past 2^32 - 1"), + ("--steps", "0", "steps below 1"), + ("--steps", "65", "steps past 64"), + ("--prompt", "a \"quoted\" prompt", "a quotation mark in the prompt"), + ] { + let out = scratch("flags.rgb"); + let mut args: Vec = base.iter().map(|s| s.to_string()).collect(); + let i = args.iter().position(|a| a == flag).unwrap(); + args[i + 1] = value.into(); + args.extend(["--size", "64x32", "-o", p(&out)].map(String::from)); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + refused(&wai(&args), &out, what); + } +} + +/// The vector's object, written as `--write-instr` writes it from the same flags. +#[test] +fn write_instr_writes_the_vectors_object() { + let v = jload(&gen_dir().join("instr-v0.json")); + let object = v["object"].as_str().unwrap(); + let o: Value = serde_json::from_str(object).unwrap(); + let sizes: Vec = o["sizes"].as_array().unwrap().iter().map(|s| format!("{}x{}", s[0], s[1])).collect(); + let out = scratch("written.instr"); + ok(&wai(&["generate", "--model", &m(), "--prompt", o["prompt"].as_str().unwrap(), "--seed", &o["seed"].to_string(), + "--steps", &o["steps"].to_string(), "--sizes", &sizes.join(","), "--write-instr", p(&out)])); + assert_eq!(std::fs::read(&out).unwrap(), object.as_bytes()); +} + +fn instr_sizes(min_pixels: u64, max_pixels: u64) -> usize { + let v = jload(&gen_dir().join("instr-v0.json")); + let object = scratch("object.instr"); + std::fs::write(&object, v["object"].as_str().unwrap()).unwrap(); + let mut n = 0; + for e in v["outputs"].as_array().unwrap() { + let (w, h) = (e["w"].as_u64().unwrap(), e["h"].as_u64().unwrap()); + if !(min_pixels..=max_pixels).contains(&(w * h)) { + continue; + } + let out = scratch(&format!("instr{w}x{h}.rgb")); + let run = wai(&["generate", "--model", &m(), "--instr", p(&object), "--size", &format!("{w}x{h}"), + "-o", p(&out), "--json"]); + assert_eq!(made(&ok(&run), &out, w, h), e["sha256"].as_str().unwrap(), "{w}x{h}"); + n += 1; + } + n +} + +#[test] +fn instr_makes_the_listed_small_sizes() { + assert_eq!(instr_sizes(0, 640 * 320), 2); +} + +#[test] +#[cfg_attr(debug_assertions, ignore = "1280x720 and 1920x1080 take seconds in a debug build; the release step runs them")] +fn instr_makes_the_listed_large_sizes() { + assert_eq!(instr_sizes(640 * 320 + 1, u64::MAX), 2); +} + +/// Every object the vector lists as refused is refused by `wai generate --instr` and +/// by `wai wrap`, and neither writes anything; the vector's own object is accepted by +/// both, and the envelope carries it byte for byte with intent `create`. +#[test] +fn the_vectors_refused_objects_are_refused() { + let v = jload(&gen_dir().join("instr-v0.json")); + let cases = v["refused"].as_array().unwrap(); + assert!(cases.len() >= 20, "the vector lists its refusals"); + let object = scratch("refused.instr"); + for c in cases { + let why = c["why"].as_str().unwrap(); + std::fs::write(&object, c["object"].as_str().unwrap()).unwrap(); + let out = scratch("refused.rgb"); + let stderr = refused(&wai(&["generate", "--model", &m(), "--instr", p(&object), "--size", "256x128", + "-o", p(&out)]), &out, why); + assert!(stderr.contains("refused"), "{why}: {stderr}"); + let env = scratch("refused.wai"); + let stderr = refused(&wai(&["wrap", "wai.generate.spectral", p(&object), "--intent", "create", "-o", p(&env)]), + &env, why); + assert!(stderr.contains("instruction object of form v0"), "{why}: {stderr}"); + } + std::fs::write(&object, v["object"].as_str().unwrap()).unwrap(); + let env = scratch("accepted.wai"); + ok(&wai(&["wrap", "wai.generate.spectral", p(&object), "--intent", "create", "-o", p(&env)])); + let e = Wai::read(&env).unwrap(); + assert_eq!((e.capability(), e.intent()), ("wai.generate.spectral", "create")); + assert_eq!(e.payload, v["object"].as_str().unwrap().as_bytes()); +} + +#[test] +fn instr_refuses_another_model_and_an_unlisted_size() { + let v = jload(&gen_dir().join("instr-v0.json")); + let object = scratch("model.instr"); + std::fs::write(&object, v["object"].as_str().unwrap()).unwrap(); + // The same model with one more byte (a line feed): the same parameters, another digest. + let other = scratch("other-model.json"); + let mut bytes = std::fs::read(model()).unwrap(); + bytes.push(b'\n'); + std::fs::write(&other, bytes).unwrap(); + let out = scratch("model.rgb"); + let stderr = refused(&wai(&["generate", "--model", p(&other), "--instr", p(&object), "--size", "256x128", + "-o", p(&out)]), &out, "another model file"); + assert!(stderr.contains("the object's model is sha256:"), "{stderr}"); + let stderr = refused(&wai(&["generate", "--model", &m(), "--instr", p(&object), "--size", "96x96", "-o", p(&out)]), + &out, "an unlisted size"); + assert!(stderr.contains("not a listed size"), "{stderr}"); +} + +#[test] +fn wrap_refuses_replicate_over_the_generator() { + let v = jload(&gen_dir().join("instr-v0.json")); + let object = scratch("replicate.instr"); + std::fs::write(&object, v["object"].as_str().unwrap()).unwrap(); + for extra in [&[][..], &["--intent", "replicate"][..]] { + let env = scratch("replicate.wai"); + let mut args = vec!["wrap", "wai.generate.spectral", p(&object), "-o", p(&env)]; + args.extend_from_slice(extra); + let stderr = refused(&wai(&args), &env, "replicate over a generator"); + assert!(stderr.contains("GenerativeDeterministic"), "{stderr}"); + } +} + +/// Every score of the corpus: `build` from its authoring form writes the committed +/// `WSCR` byte for byte, and `render` prints, and `--pcm` writes, the digests +/// `expected_hashes.json` states. +#[test] +fn score_build_and_render_reproduce_the_corpus() { + let expected = jload(&score_dir().join("expected_hashes.json")); + let entries = expected.as_object().unwrap(); + assert_eq!(entries.len(), 3); + for (file, spec) in entries { + let name = file.strip_suffix(".wscr").unwrap(); + let committed = std::fs::read(score_dir().join("vectors").join(file)).unwrap(); + let built = scratch(&format!("{name}.wscr")); + let notes = score_dir().join("notes").join(format!("{name}.json")); + let stdout = ok(&wai(&["score", "build", p(¬es), "-o", p(&built), "--json"])); + assert_eq!(std::fs::read(&built).unwrap(), committed, "{name}: built byte for byte"); + let b: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(b["bytes"].as_u64(), Some(committed.len() as u64)); + assert_eq!(b["score_hash"], spec["score_hash"], "{name}"); + + let pcm = scratch(&format!("{name}.s16")); + let stdout = ok(&wai(&["score", "render", p(&built), "--pcm", p(&pcm), "--json"])); + let r: Value = serde_json::from_str(stdout.trim()).unwrap(); + let duration = spec["duration"].as_u64().unwrap(); + assert_eq!(r["samples"].as_u64(), Some(duration), "{name}: the contract's duration, whole"); + assert_eq!(r["sample_rate"].as_u64(), Some(22050)); + assert_eq!(r["notes"], spec["notes"]); + assert_eq!(r["pcm_sha256"], spec["pcm_sha256"], "{name}"); + assert_eq!(r["mixdown_blake3"], spec["mixdown_hash"], "{name}"); + assert_eq!(r["score_hash"], spec["score_hash"], "{name}"); + let samples = std::fs::read(&pcm).unwrap(); + assert_eq!(samples.len() as u64, duration * 2, "{name}: i16 samples"); + assert_eq!(Some(sha256_hex(&samples).as_str()), spec["pcm_sha256"].as_str(), "{name}: the written PCM"); + + // The score travels under intent `replicate`: its class is StandardDefined. + let env = scratch(&format!("{name}.wai")); + ok(&wai(&["wrap", "wai.audio.score", p(&built), "--intent", "replicate", "-o", p(&env)])); + let e = Wai::read(&env).unwrap(); + assert_eq!((e.capability(), e.intent()), ("wai.audio.score", "replicate")); + assert_eq!(e.payload, committed); + } +} + +#[test] +fn score_build_refuses_what_is_not_integer_notes_rendered_whole() { + let note = r#"{"start":0,"dur":100,"phase_inc":50960238,"gain":1717986918,"waveform":2,"attack":10,"decay":10,"sustain":2147483648,"release":50}"#; + let score = |note: &str, top: &str| format!(r#"{{"sample_rate":22050,"channels":1,"duration":200{top},"notes":[{note}]}}"#); + // The base case builds. + let input = scratch("base.json"); + std::fs::write(&input, score(note, "")).unwrap(); + let out = scratch("base.wscr"); + ok(&wai(&["score", "build", p(&input), "-o", p(&out)])); + for (doc, what) in [ + (score(¬e.replace("1717986918", "0.4"), ""), "a gain with a fraction"), + (score(¬e.replace("1717986918", "4294967297"), ""), "a gain past 1.0"), + (score(¬e.replace("50960238", "4294967296"), ""), "a phase_inc of a whole cycle"), + (score(¬e.replace("\"waveform\":2", "\"waveform\":4"), ""), "a waveform past 3"), + (score(¬e.replace("\"start\":0", "\"start\":-1"), ""), "a negative start"), + (score(¬e.replace("\"release\":50", "\"release\":101"), ""), "a release the duration would cut"), + (score(¬e.replace("\"dur\":100,", "\"dur\":100,\"pitch\":60,"), ""), "a member that is not a note field"), + (score(¬e.replace(",\"release\":50", ""), ""), "a missing note field"), + (score(note, ",\"numeric\":\"wai.det.float\""), "another numeric profile"), + (score(note, "").replace("\"channels\":1", "\"channels\":2"), "two channels"), + (score(note, "").replace("\"duration\":200", "\"duration\":67108865"), "a duration past 2^26 samples"), + ("[]".to_string(), "not an object"), + ] { + std::fs::write(&input, &doc).unwrap(); + let out = scratch("refused.wscr"); + let stderr = refused(&wai(&["score", "build", p(&input), "-o", p(&out)]), &out, what); + assert!(stderr.contains("refused"), "{what}: {stderr}"); + } +} + +/// A `WSCR` that declares more notes than its notes section holds is refused before the +/// reader reserves room for them: a file of a few bytes can declare 2^32 − 1. +#[test] +fn score_render_refuses_a_note_count_its_bytes_cannot_hold() { + let mut b = b"WSCR".to_vec(); + b.extend_from_slice(&1u16.to_le_bytes()); + b.push(0x02); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&4u32.to_le_bytes()); + b.extend_from_slice(&u32::MAX.to_le_bytes()); + let input = scratch("huge.wscr"); + std::fs::write(&input, b).unwrap(); + let pcm = scratch("huge.s16"); + let stderr = refused(&wai(&["score", "render", p(&input), "--pcm", p(&pcm)]), &pcm, "2^32 - 1 notes in 4 bytes"); + assert!(stderr.contains("declares 4294967295 notes"), "{stderr}"); +} diff --git a/wai/wai-rs/tests/cli_wrap_intent.rs b/wai/wai-rs/tests/cli_wrap_intent.rs index bb8834d9c..a8deea94d 100644 --- a/wai/wai-rs/tests/cli_wrap_intent.rs +++ b/wai/wai-rs/tests/cli_wrap_intent.rs @@ -234,3 +234,44 @@ fn wrap_refuses_a_payload_that_is_not_the_pinned_integer_format() { let stdout = String::from_utf8_lossy(&run.stdout); assert!(stdout.contains("integer payload: encrypted"), "{stdout}"); } + +/// SPEC §5, *Payload of `wai.generate.spectral`*: `wrap` reads the instruction object of +/// form v0 in every build, the generator's own feature or not. It carries the committed +/// object under intent `create`, refuses `replicate` over the generator (a generator +/// has no source to replicate), and refuses the same object in another spacing — as +/// capability or as fallback — writing nothing. +#[test] +fn wrap_carries_only_an_instruction_object_under_the_generator() { + let v: serde_json::Value = serde_json::from_slice(&std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/generate/instr-v0.json")).unwrap()).unwrap(); + let object = v["object"].as_str().unwrap(); + let good = scratch("instr-v0.payload"); + std::fs::write(&good, object).unwrap(); + let wrap_file = |cap: &str, payload: &Path, out: &Path, extra: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_wai")).arg("wrap").arg(cap).arg(payload).arg("-o").arg(out).args(extra) + .output().expect("run wai") + }; + let out = scratch("generate-create.wai"); + let run = wrap_file("wai.generate.spectral", &good, &out, &["--intent", "create"]); + assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); + let env = Wai::read(&out).unwrap(); + assert_eq!((env.capability(), env.intent()), ("wai.generate.spectral", "create")); + assert_eq!(env.payload, object.as_bytes(), "the object is carried byte for byte"); + + let out = scratch("generate-replicate.wai"); + let run = wrap_file("wai.generate.spectral", &good, &out, &["--intent", "replicate"]); + assert!(!run.status.success() && !out.exists()); + assert!(String::from_utf8_lossy(&run.stderr).contains("GenerativeDeterministic")); + + let spaced = scratch("instr-v0-spaced.payload"); + std::fs::write(&spaced, object.replace("\"seed\":7", "\"seed\": 7")).unwrap(); + for (cap, extra) in [("wai.generate.spectral", &["--intent", "create"][..]), + ("wai.image.png", &["--fallback", "wai.generate.spectral", "--intent", "create"][..])] { + let out = scratch("generate-spaced.wai"); + let run = wrap_file(cap, &spaced, &out, extra); + let stderr = String::from_utf8_lossy(&run.stderr); + assert!(!run.status.success(), "{cap} {extra:?}: wrap must refuse"); + assert!(stderr.contains("instruction object of form v0") && stderr.contains("canonical"), "{cap} {extra:?}: {stderr}"); + assert!(!out.exists(), "{cap} {extra:?}: a refused wrap must not write an envelope"); + } +} diff --git a/wai/wai-rs/tests/generate.rs b/wai/wai-rs/tests/generate.rs index 8bd2623bf..02a172ebb 100644 --- a/wai/wai-rs/tests/generate.rs +++ b/wai/wai-rs/tests/generate.rs @@ -1,8 +1,12 @@ //! `wai.generate.spectral` — **generation-equivalence**: a deterministic integer //! generator produces byte-identical bytes on every machine from the same //! `(model, prompt, seed, steps)`, so its hash is portable (the create-side of -//! "conformance is a hash"). Committed vector under `tests/vectors/generate/`; -//! never skips. +//! "conformance is a hash"). Committed vectors under `tests/vectors/generate/`: +//! `generate.json` (96×96), `sizes.json` (256×128 and 640×320), and `instr-v0.json`, +//! the instruction object of form v0 (SPEC §5) with the SHA-256 of each of its four +//! listed sizes. Nothing skips except the two larger listed sizes in a debug build, +//! which the release step of the conformance workflow runs; the objects a sink +//! refuses are run through `wai` in `tests/cli_generate_score.rs`. #![cfg(feature = "generate")] use std::path::PathBuf; @@ -52,3 +56,95 @@ fn generation_is_deterministic_and_pinned() { assert_ne!(hash(&generate(&m, prompt, seed ^ 1, steps, h, w)), got, "seed must change the output"); assert_ne!(hash(&generate(&m, prompt, seed, steps + 1, h, w)), got, "steps must change the output"); } + +fn model() -> SpectralModel { + SpectralModel::from_value(&jload("generate_spectral_model.json")) +} + +/// `sizes.json`: the committed vector's prompt, seed and steps at 256×128 and 640×320, +/// non-square, so the `(h, w)` order of `generate` is pinned too. Each SHA-256 was +/// made natively by `wai generate` and equals the shared browser binding's output. +#[test] +fn sizes_reproduce_their_sha256() { + let (m, pin) = (model(), jload("sizes.json")); + let model_bytes = std::fs::read(dir().join("generate_spectral_model.json")).unwrap(); + assert_eq!(sha256_sidecar::sha256_hex(&model_bytes), pin["model_sha256"].as_str().unwrap(), "the committed model"); + let (prompt, seed, steps) = + (pin["prompt"].as_str().unwrap(), pin["seed"].as_u64().unwrap(), pin["steps"].as_u64().unwrap() as usize); + let sizes = pin["sizes"].as_array().unwrap(); + assert_eq!(sizes.len(), 2); + for s in sizes { + let (w, h) = (s["w"].as_u64().unwrap() as usize, s["h"].as_u64().unwrap() as usize); + assert_ne!(w, h, "a non-square size pins the order of w and h"); + let rgb = generate(&m, prompt, seed, steps, h, w); + assert_eq!(rgb.len(), w * h * 3); + assert_eq!(sha256_sidecar::sha256_hex(&rgb), s["sha256"].as_str().unwrap(), "{w}x{h}"); + // h and w swapped make another picture. + assert_ne!(generate(&m, prompt, seed, steps, w, h), rgb, "{w}x{h}: swapping w and h"); + } +} + +/// The members of the vector's instruction object, read with serde_json, and the +/// object written again in form v0's canonical serialization (SPEC §5, *Payload of +/// `wai.generate.spectral`*), stated here independently of `wai`'s reader. +fn instr_v0() -> (serde_json::Value, Vec<(usize, usize, String)>) { + let v = jload("instr-v0.json"); + let object = v["object"].as_str().unwrap(); + let o: serde_json::Value = serde_json::from_str(object).unwrap(); + let sizes: Vec = o["sizes"].as_array().unwrap().iter() + .map(|p| format!("[{},{}]", p[0].as_u64().unwrap(), p[1].as_u64().unwrap())).collect(); + let canonical = format!( + "{{\"model\":\"{}\",\"prompt\":\"{}\",\"seed\":{},\"sizes\":[{}],\"steps\":{}}}", + o["model"].as_str().unwrap(), o["prompt"].as_str().unwrap(), o["seed"].as_u64().unwrap(), + sizes.join(","), o["steps"].as_u64().unwrap()); + assert_eq!(canonical, object, "the vector's object is in canonical form"); + let outputs = v["outputs"].as_array().unwrap().iter().map(|e| ( + e["w"].as_u64().unwrap() as usize, e["h"].as_u64().unwrap() as usize, e["sha256"].as_str().unwrap().to_string(), + )).collect(); + (o, outputs) +} + +fn make_listed(min_pixels: usize, max_pixels: usize) -> usize { + let (o, outputs) = instr_v0(); + let model_bytes = std::fs::read(dir().join(jload("instr-v0.json")["model_file"].as_str().unwrap())).unwrap(); + assert_eq!(format!("sha256:{}", sha256_sidecar::sha256_hex(&model_bytes)), o["model"].as_str().unwrap(), + "the object names the committed model by its SHA-256"); + let listed: Vec<(usize, usize)> = o["sizes"].as_array().unwrap().iter() + .map(|p| (p[0].as_u64().unwrap() as usize, p[1].as_u64().unwrap() as usize)).collect(); + assert_eq!(listed, outputs.iter().map(|(w, h, _)| (*w, *h)).collect::>(), "one output per listed size"); + let m = SpectralModel::from_value(&serde_json::from_slice(&model_bytes).unwrap()); + let (prompt, seed, steps) = + (o["prompt"].as_str().unwrap(), o["seed"].as_u64().unwrap(), o["steps"].as_u64().unwrap() as usize); + let mut made = 0; + for (w, h, sha) in &outputs { + if !(min_pixels..=max_pixels).contains(&(w * h)) { + continue; + } + let rgb = generate(&m, prompt, seed, steps, *h, *w); + assert_eq!(rgb.len(), w * h * 3); + assert_eq!(&sha256_sidecar::sha256_hex(&rgb), sha, "{w}x{h}"); + made += 1; + } + made +} + +/// `instr-v0.json` at its two smaller listed sizes. Its 256×128 and 640×320 outputs are +/// `sizes.json`'s: the object's prompt, seed and steps are the committed vector's. +#[test] +fn instruction_object_v0_small_sizes_reproduce() { + assert_eq!(make_listed(0, 640 * 320), 2); + let (_, outputs) = instr_v0(); + let sizes = jload("sizes.json"); + for (s, (w, h, sha)) in sizes["sizes"].as_array().unwrap().iter().zip(&outputs) { + assert_eq!((s["w"].as_u64().unwrap() as usize, s["h"].as_u64().unwrap() as usize, s["sha256"].as_str().unwrap()), + (*w, *h, sha.as_str())); + } +} + +/// `instr-v0.json` at 1280×720 and 1920×1080: under a second in a release build, many +/// seconds in a debug one, so the release step of the conformance workflow runs it. +#[test] +#[cfg_attr(debug_assertions, ignore = "1280x720 and 1920x1080 take seconds in a debug build; the release step runs them")] +fn instruction_object_v0_large_sizes_reproduce() { + assert_eq!(make_listed(640 * 320 + 1, usize::MAX), 2); +} diff --git a/wai/wai-rs/tests/vectors/generate/instr-v0.json b/wai/wai-rs/tests/vectors/generate/instr-v0.json new file mode 100644 index 000000000..4c54ccb56 --- /dev/null +++ b/wai/wai-rs/tests/vectors/generate/instr-v0.json @@ -0,0 +1,146 @@ +{ + "form": "the instruction object of wai.generate.spectral, form v0 (SPEC \u00a75)", + "model_file": "generate_spectral_model.json", + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "buffer": "RGB8: rows top to bottom, pixels left to right, R, G, B bytes", + "outputs": [ + { + "w": 256, + "h": 128, + "bytes": 98304, + "sha256": "6c7eabbc826affc752394fa67bf74e135331664d53edf13a4f6eabb9b1f80704" + }, + { + "w": 640, + "h": 320, + "bytes": 614400, + "sha256": "54bc40567935a8b264d1836bd94439623b233c3551b535ea54960f35327e5bf2" + }, + { + "w": 1280, + "h": 720, + "bytes": 2764800, + "sha256": "909aa2d54cc1ab3a98912b173e5b031c8634a794b9ad58f0526a543b4cb7acc8" + }, + { + "w": 1920, + "h": 1080, + "bytes": 6220800, + "sha256": "763e9f672810db934d8c492db77230d6d1e7ab1375c6ff1f076222fa4dfcc83a" + } + ], + "refused": [ + { + "object": "{\"prompt\":\"a calm spectral horizon\",\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "members out of ascending byte order" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\": 7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "whitespace" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}\n", + "why": "a line feed after the object" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":4294967296,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "seed past 2^32 - 1" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":-1,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a negative seed" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7.0,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a seed written with a fraction" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7e0,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a seed written with an exponent" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":0}", + "why": "steps below 1" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":65}", + "why": "steps past 64" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[],\"steps\":4}", + "why": "no size" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[640,320],[256,128]],\"steps\":4}", + "why": "sizes out of ascending order" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[256,128]],\"steps\":4}", + "why": "a size listed twice" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[0,128]],\"steps\":4}", + "why": "a side of 0" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[4097,1]],\"steps\":4}", + "why": "a side past 4096" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[4096,4096]],\"steps\":4}", + "why": "more than 2^23 pixels" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128,1]],\"steps\":4}", + "why": "a size that is not a pair" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[1,1],[2,1],[3,1],[4,1],[5,1],[6,1],[7,1],[8,1],[9,1],[10,1],[11,1],[12,1],[13,1],[14,1],[15,1],[16,1],[17,1]],\"steps\":4}", + "why": "more than 16 sizes" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral \\u0068orizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "an escape in the prompt" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a \\\"calm\\\" horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a quotation mark in the prompt" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horiz\u00f3n\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a byte outside printable ASCII in the prompt" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a prompt of 257 bytes" + }, + { + "object": "{\"model\":\"sha256:963EE634DB1A964D14004618B139FD7E32598F627C023FAF9526B14611504F9E\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a model digest in uppercase" + }, + { + "object": "{\"model\":\"963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a model digest without `sha256:`" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a model digest of 63 digits" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4,\"version\":0}", + "why": "a member form v0 does not define" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]]}", + "why": "a member missing" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a member given twice" + }, + { + "object": "[{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}]", + "why": "not an object" + } + ] +} diff --git a/wai/wai-rs/tests/vectors/generate/sizes.json b/wai/wai-rs/tests/vectors/generate/sizes.json new file mode 100644 index 000000000..61adc4dd9 --- /dev/null +++ b/wai/wai-rs/tests/vectors/generate/sizes.json @@ -0,0 +1,19 @@ +{ + "prompt": "a calm spectral horizon", + "seed": 7, + "steps": 4, + "model_sha256": "963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e", + "buffer": "RGB8: rows top to bottom, pixels left to right, R, G, B bytes", + "sizes": [ + { + "w": 256, + "h": 128, + "sha256": "6c7eabbc826affc752394fa67bf74e135331664d53edf13a4f6eabb9b1f80704" + }, + { + "w": 640, + "h": 320, + "sha256": "54bc40567935a8b264d1836bd94439623b233c3551b535ea54960f35327e5bf2" + } + ] +} -- 2.54.0 (Apple Git-157) From 3770d1ab8e5de7870dd622951c9059c5db1c7365 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 09:53:15 -0400 Subject: [PATCH 02/11] =?UTF-8?q?wai=20SPEC=20=C2=A75:=20the=20payload=20o?= =?UTF-8?q?f=20wai.generate.spectral=20is=20an=20instruction=20object=20(f?= =?UTF-8?q?orm=20v0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registration is completed as §8 permits: the row named the generator and no byte layout. The payload is one canonical JSON object naming the model by its SHA-256 and giving the prompt, the seed, the steps and every size a sink makes, each within stated bounds and with one serialization per object, so an envelope's content hash names the instructions. A sink makes only listed sizes, with that model only; conformance is generation-equivalence at each listed size, and a sink holding a size's SHA-256 does not present a picture that differs or accept one on a 64-bit FNV-1a alone. The envelope's intent is `create`: a generator does not back `replicate` (§7), and this keeps that rule. The registry row names the payload; §8 lists the completion. score §2.3 documents the integer authoring form `wai score build` reads, and §4 the pcm_sha256 every vector now states. CI builds the `wai` binary with `generate` and `score` in both profiles of the integer-kernel steps and runs tests/cli_generate_score.rs there; the two larger listed sizes of instr-v0.json run in the release step. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 12 ++++-- wai/CHANGELOG.md | 27 ++++++++++++++ wai/SPEC.md | 54 ++++++++++++++++++++++++++- wai/extensions/score.md | 28 ++++++++++++++ wai/score-conformance/README.md | 18 ++++++++- 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 2cc9cfe29..880534d76 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -235,13 +235,19 @@ jobs: # its sha256.json sidecar (SPEC §7 *Digests*), and # int_kernels_vectors::every_decoded_output_states_a_sha256 requires a sidecar, # or a named exclusion, for every corpus directory. tests/generate.rs checks the - # wai.generate.spectral vector, which `generate` enables. + # wai.generate.spectral vectors, which `generate` enables: the 96x96 vector, sizes.json + # and instr-v0.json, the instruction object of form v0 (SPEC §5), whose two larger + # listed sizes run only in the release step (seconds each in a debug build). + # tests/cli_generate_score.rs runs `wai generate` and `wai score build|render`, which + # the features `generate` and `score` build into the `wai` binary: every pin it prints + # against those vectors and score-conformance/ (pcm_sha256 among them), every object + # instr-v0.json lists as refused, and each refusal writing nothing. - 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,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 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_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,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 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_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/wai/CHANGELOG.md b/wai/CHANGELOG.md index 112c348c5..0352c696e 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,33 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Generated pictures and scores, made natively and checked by SHA-256 + +- **The payload of `wai.generate.spectral`** (SPEC §5, *Payload of + `wai.generate.spectral`*). The registration is completed (§8): the payload is an + instruction object of form v0, one canonical JSON object naming the model by its + SHA-256 and giving the prompt, the seed, the steps and every size a sink makes. One + serialization per object, so an envelope's content hash names the instructions; a + sink makes only listed sizes, with that model only, and checks each output by its + SHA-256, never by a 64-bit FNV-1a alone. The envelope's intent is `create`. Vector: + `wai-rs/tests/vectors/generate/instr-v0.json`, the object, the SHA-256 at each of + its four listed sizes, and 28 objects a sink refuses. +- **`wai generate`** (feature `generate`) makes a picture with the reference generator + at any size form v0 allows and prints its SHA-256; `--instr` makes a listed size of + an object, read as a sink reads it, and `--write-instr` writes one. `sizes.json` pins + the committed prompt at 256×128 and 640×320; every pin equals the shared browser + binding's output. `wai wrap` refuses a `wai.generate.spectral` payload that is not a + form-v0 object, in every build. +- **`wai score build|render`** (feature `score`). `build` writes a `WSCR` from integer + note fields only (score §2.3); `render` prints `samples`, `pcm_sha256`, + `mixdown_blake3` and `score_hash`, and `--pcm` writes the samples. Every + `score-conformance` vector now states `pcm_sha256`, and `notes/.json` holds it + in the authoring form, which `build` turns back into the committed `WSCR` byte for + byte. +- No library source changed: the generator, the score synth, the world floor and the + browser binding are as they were, so pins made with this tool bind the binding pages + already load. + ### Staged measurement and the session claim in the browser - **The measurement core, bound for JS** ([staged-measurement](extensions/staged-measurement.md)). diff --git a/wai/SPEC.md b/wai/SPEC.md index a38e0a598..359dd56a5 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -605,7 +605,7 @@ registration and makes no conforming envelope non-conforming (§8). | `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`) | -| `wai.generate.spectral` | deterministic integer GENERATIVE synthesis (`intent=create`) — prompt+seed → integer-LUT spectral composition, no float/ONNX | byte-identical on every machine, pinned to (model, prompt, seed, steps); the create-side of "conformance is a hash" (generation-equivalence) | `generate.rs` (small pinned model bundle) | +| `wai.generate.spectral` | deterministic integer GENERATIVE synthesis (`intent=create`) — prompt+seed → integer-LUT spectral composition, no float/ONNX | byte-identical on every machine, pinned to (model, prompt, seed, steps); the create-side of "conformance is a hash" (generation-equivalence). Payload: the instruction object of form v0, which names the model by its SHA-256 and lists every size a sink makes, each output checked by its SHA-256 (*Payload of `wai.generate.spectral`*, below) | `generate.rs` (small pinned model bundle) | | `wai.neural.elic` | checkerboard context model (hyperprior + 2-pass spatial context) | the checkerboard-context entropy class (arXiv 2203.10886) | `neural/context.rs` | | `wai.neural.mlicpp` | MLIC++ multi-reference entropy (channel-AR slices + checkerboard + global attention) | the multi-reference entropy model (arXiv 2307.15421) | research code, `neural/mlicpp.rs` | | `wai.neural.mlicv2` | MLICv2 / MLICv2+ (2025), MetaFormer transform | shares the mlicpp route | research code, `neural/mlicpp.rs` | @@ -776,6 +776,55 @@ of these strings whose bytes are not its format. A sink refuses such a payload w decodes it, and does not try the fallback ([integer-payloads](extensions/integer-payloads.md) §11). +### Payload of `wai.generate.spectral`: the instruction object (form v0) + +This revision completes the registration of `wai.generate.spectral` as §8 permits +(*Completing a registration*): its row named the generator, `generate.rs`, and no byte +layout. Its payload is an **instruction object**, form v0: one JSON object (RFC 8259) in +UTF-8 with exactly the five members below, written in ascending byte order of their +names, with no whitespace, and with each integer in decimal, without sign, fraction or +exponent. Nothing precedes or follows the object. + +```json +{"model":"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e","prompt":"a calm spectral horizon","seed":7,"sizes":[[256,128],[640,320],[1280,720],[1920,1080]],"steps":4} +``` + +| member | value | +|---|---| +| `model` | `sha256:` followed by the SHA-256 of the model file the reference generator reads, as 64 lowercase hexadecimal digits | +| `prompt` | 0 to 256 bytes, each from 0x20 to 0x7E other than `"` (0x22) and `\` (0x5C), so the string holds no escape | +| `seed` | an integer from 0 to 2³² − 1 | +| `sizes` | 1 to 16 pairs `[w, h]`, each side from 1 to 4096 and `w·h` at most 2²³, in strictly ascending order of `w` and then `h` | +| `steps` | an integer from 1 to 64 | + +These rules leave one serialization for each object, so two objects that say the same +thing are the same bytes, and an envelope's content hash names the instructions. A sink +MUST refuse a payload that is not exactly that serialization of such an object — a member +added, missing, repeated or out of order, whitespace, an escape, a value out of range — +and make nothing from it. + +A sink makes the picture at a listed size `[w, h]` with the reference generator, +`generate(model, prompt, seed, steps, h, w)`: `h·w·3` bytes of `RGB8` as +[reconstruction-binding](extensions/reconstruction-binding.md) §2 defines it. It MUST +NOT make one with a model file whose SHA-256 is not `model`, or at a size the object does +not list. It MAY decline a listed size it cannot afford to make, and then presents +nothing for that size. +Conformance is generation-equivalence (§7) at each listed size: every conforming sink +makes the same bytes. A publisher MAY state each size's SHA-256 beside the payload; a +sink that holds such a digest MUST NOT present a picture whose SHA-256 differs from it, +and MUST NOT accept one on a 64-bit FNV-1a alone (§7, *Digests*). The envelope's +`intent` is `create`: a generator has no source to replicate, so it does not back +`replicate` (§7). + +The conformance vector is `wai-rs/tests/vectors/generate/instr-v0.json`: the object +above, the SHA-256 of each of its four listed sizes, and objects a sink refuses. +`wai generate` is the reference: `--instr` makes a listed size from an object, and +`--write-instr` writes one. + +*Envelopes this makes non-conforming* (they still parse): a `WAI1` whose `capability` +or `fallback` is `wai.generate.spectral` and whose payload is not an instruction object +of form v0, and a `WAI2` with a component of that string whose bytes are not one. + ### Immersive audio, cinema, volumetric, haptics, HDR (registered, sink-supplied) The same "name the capability, the sink supplies the decoder" pattern extends @@ -2063,7 +2112,8 @@ envelope that uses any string a later revision registers. revision lists them in §5. From that revision on, the string's payload format is fixed as the item above states. §5, *Payloads of the WAI-native integer codecs*, completes the registrations of `wai.video.int_motion`, `wai.video.int_hyper`, - `wai.neural.int_hyper`, `wai.audio.int_codec` and `wai.splat.int_codec` this way. + `wai.neural.int_hyper`, `wai.audio.int_codec` and `wai.splat.int_codec` this way, and + §5, *Payload of `wai.generate.spectral`*, completes that string's. - **Editions.** A registration that names an edition (or version) of its standard carries that edition. A registration that names none carries the edition of its named standard current on the date it was registered, and no diff --git a/wai/extensions/score.md b/wai/extensions/score.md index 660875bb5..9a99f8f22 100644 --- a/wai/extensions/score.md +++ b/wai/extensions/score.md @@ -60,6 +60,29 @@ exponential to turn a note into a frequency (the same authoring discipline as the avatar quaternion). `waveform` selects a reference timbre: `0` saw, `1` square, `2` triangle, `3` 25%-pulse. +### 2.3 Authoring form (informative) + +`wai score build` writes a `WSCR` from a JSON document that holds every +field as an integer, so the committed document is the definition and no +platform's floating-point `pow` can change a pitch: + +```json +{ "sample_rate": 22050, "channels": 1, "duration": 34000, + "numeric": "wai.det.fixed64", + "notes": [ { "start": 0, "dur": 3500, "phase_inc": 50960238, + "gain": 1717986918, "waveform": 0, "attack": 220, + "decay": 600, "sustain": 2791728742, "release": 1500 } ] } +``` + +`phase_inc`, `gain` and `sustain` are the raw Q32.32 bits. `numeric` may +be left out. The tool refuses a number with a fraction or an exponent, a +member it does not name, more than one channel, a `phase_inc` of a whole +cycle or more, a `gain` or `sustain` above 1.0, a waveform above 3, and a +note whose release would end after `duration`, so the mixdown always +holds every note whole. `score-conformance/notes/` holds each vector in +this form, and `wai score build` turns it back into the committed `WSCR` +byte for byte. + ## 3. Reference synthesis (trig-free) For each note, a phase accumulator advances by `phase_inc` each sample @@ -82,6 +105,11 @@ mixdown_hash = BLAKE3("wai:score-mixdown\x01" || i16le PCM) score_hash = BLAKE3("wai:score\x01" || contract || notes) // identity ``` +A conformance vector also states `pcm_sha256`, the SHA-256 of the same +`i16le` PCM (the audio buffer of SPEC §7 *Digests*), so a verifier that +holds only SHA-256 checks the samples. `wai score render` prints both +and, with `--pcm`, writes the samples. + A sink MAY play a soundfont/neural instrument instead; that is presentation and does not affect conformance, exactly as the audio-scene HRTF render or the worlds renderer does not. diff --git a/wai/score-conformance/README.md b/wai/score-conformance/README.md index b880f66c0..fee1bb201 100644 --- a/wai/score-conformance/README.md +++ b/wai/score-conformance/README.md @@ -21,8 +21,12 @@ conformance. ## Layout - `vectors/.wscr` — the `WSCR` container (contract + note events). +- `notes/.json` — the same score in the authoring form (score + §2.3): the contract and every note field as an integer, the input of + `wai score build`. - `expected_hashes.json` — per score: `notes`, `duration`, `score_hash` - (identity), and `mixdown_hash` (the conformance quantity). + (identity), `mixdown_hash` (the conformance quantity), and `pcm_sha256`, + the SHA-256 of the same `i16le` samples (SPEC §7 *Digests*). Vectors: - `scale` — a C-major scale ascending (saw). @@ -37,4 +41,14 @@ cargo run --features score --bin wai_score_vectors -- verify ../score-conformanc cargo run --features score --bin wai_score_vectors -- gen ../score-conformance # regenerate ``` -Pass = mixdown-hash and score-hash equality. +Pass = mixdown-hash, PCM SHA-256 and score-hash equality, and each +`notes/.json` exactly the vector's notes in the authoring form. + +With the `wai` tool (a build with `score`): + +``` +wai score build ../score-conformance/notes/scale.json -o scale.wscr # the committed WSCR, byte for byte +wai score render scale.wscr --pcm scale.s16 --json # samples, pcm_sha256, mixdown_blake3, score_hash +``` + +`tests/cli_generate_score.rs` runs both over every vector. -- 2.54.0 (Apple Git-157) From 35debe1706ed8c97c9fa51e2e792a65163335eb9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 16:52:15 -0400 Subject: [PATCH 03/11] wai cli tests: the two instr_sizes callers each write their own object file instr_makes_the_listed_small_sizes and instr_makes_the_listed_large_sizes run at once in the release step, and both removed and rewrote one scratch file, object.instr, while the other's `wai generate --instr` children read it. A remove or a truncating write landing as a child opened the file would refuse the object and fail the test. Each caller now names its own file. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/cli_generate_score.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index bb60839a8..b50a4a3e6 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -144,7 +144,9 @@ fn write_instr_writes_the_vectors_object() { fn instr_sizes(min_pixels: u64, max_pixels: u64) -> usize { let v = jload(&gen_dir().join("instr-v0.json")); - let object = scratch("object.instr"); + // A file of its own per caller: the two tests that call this run at once, and each + // removes and rewrites its object before its `wai` children read it. + let object = scratch(&format!("object-{min_pixels}.instr")); std::fs::write(&object, v["object"].as_str().unwrap()).unwrap(); let mut n = 0; for e in v["outputs"].as_array().unwrap() { -- 2.54.0 (Apple Git-157) From 0d123ab7f2a60fa9d977f35faf8f63f2e662f66d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 16:53:08 -0400 Subject: [PATCH 04/11] wai cli tests: the note-count guard is what refuses the crafted WSCR score_render_refuses_a_note_count_its_bytes_cannot_hold wrote a file with no contract section, so the reader refused it with MissingSection(0x01) before parse_notes reserved anything, guard or no guard: the test checked only which message came first. The file now carries a well-formed contract (section 0x01) and a notes section of only the count (0x02), and the same file declaring no notes is shown to render, so without the guard the reader reaches Vec::with_capacity. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/cli_generate_score.rs | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index b50a4a3e6..c71d5da12 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -309,15 +309,33 @@ fn score_build_refuses_what_is_not_integer_notes_rendered_whole() { } /// A `WSCR` that declares more notes than its notes section holds is refused before the -/// reader reserves room for them: a file of a few bytes can declare 2^32 − 1. +/// reader reserves room for them: a file of a few bytes can declare 2^32 − 1. The file +/// carries a well-formed contract (section 0x01) and a notes section (0x02) holding only +/// the count, so without the check the reader would pass every other rule and reach the +/// reservation; the check, not a missing section, is what refuses it. #[test] fn score_render_refuses_a_note_count_its_bytes_cannot_hold() { + let contract = wai::score::ScoreContract { + sample_rate: 22050, numeric: wai::world::NUMERIC_FIXED64.into(), channels: 1, duration: 100, + } + .to_canonical_json(); let mut b = b"WSCR".to_vec(); - b.extend_from_slice(&1u16.to_le_bytes()); - b.push(0x02); - b.extend_from_slice(&0u32.to_le_bytes()); - b.extend_from_slice(&4u32.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + for (kind, off, len) in [(0x01u8, 0u32, contract.len() as u32), (0x02, contract.len() as u32, 4)] { + b.push(kind); + b.extend_from_slice(&off.to_le_bytes()); + b.extend_from_slice(&len.to_le_bytes()); + } + b.extend_from_slice(&contract); b.extend_from_slice(&u32::MAX.to_le_bytes()); + // The same file declaring no notes renders: the contract and the layout are good. + let mut empty = b.clone(); + let n = empty.len(); + empty[n - 4..].copy_from_slice(&0u32.to_le_bytes()); + let input = scratch("empty.wscr"); + std::fs::write(&input, empty).unwrap(); + let r: Value = serde_json::from_str(ok(&wai(&["score", "render", p(&input), "--json"])).trim()).unwrap(); + assert_eq!((r["samples"].as_u64(), r["notes"].as_u64()), (Some(100), Some(0)), "{r}"); let input = scratch("huge.wscr"); std::fs::write(&input, b).unwrap(); let pcm = scratch("huge.s16"); -- 2.54.0 (Apple Git-157) From 887c99a61f2ebedcaf2a306d9c42205308b75133 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 16:54:46 -0400 Subject: [PATCH 05/11] wai score render: refuse a contract that is not one channel of wai.det.fixed64 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render printed "channels":2 beside a mono mixdown of `duration` samples when a WSCR's contract said two channels, and rendered a contract whose numeric profile was wai.det.float64; a verifier following SPEC §7 Digests would read that PCM as stereo frames. render now refuses both, as build refuses to write them. score_render_refuses_a_contract_it_does_not_render patches the committed scale.wscr in place for each case. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/bin/wai_cli/score.rs | 19 +++++++++++++++---- wai/wai-rs/tests/cli_generate_score.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/wai/wai-rs/src/bin/wai_cli/score.rs b/wai/wai-rs/src/bin/wai_cli/score.rs index 11218e8f7..e8fde3217 100644 --- a/wai/wai-rs/src/bin/wai_cli/score.rs +++ b/wai/wai-rs/src/bin/wai_cli/score.rs @@ -160,14 +160,25 @@ fn declared_notes_fit(b: &[u8]) -> Result<(), String> { Ok(()) } -/// Read a `WSCR` and check it can be rendered: the reader's checks, then a duration of -/// at most [`MAX_SAMPLES`] and note times whose sums (start + dur + release, dur + -/// release, attack + decay, which the synth forms in `u64`) do not overflow. A note cut -/// by the duration renders as the extension says, cut. +/// Read a `WSCR` and check it can be rendered: the reader's checks, then a contract of +/// one channel in `wai.det.fixed64` (the reference mixdown is one channel of that +/// profile, so a contract stating two channels or another profile would print a +/// `channels` or a sample layout the samples written are not), a duration of at most +/// [`MAX_SAMPLES`], and note times whose sums (start + dur + release, dur + release, +/// attack + decay, which the synth forms in `u64`) do not overflow. `build` refuses the +/// same contracts. A note cut by the duration renders as the extension says, cut. fn read_score(path: &str) -> Result { let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?; declared_notes_fit(&bytes).map_err(|e| format!("{path}: {e}"))?; let s = Score::from_bytes(&bytes).map_err(|e| format!("{path}: {e}"))?; + if s.contract.channels != 1 { + return Err(format!("{path}: refused: the contract states {} channels; the reference mixdown is one \ + channel, so this tool renders only a one-channel contract", s.contract.channels)); + } + if s.contract.numeric != NUMERIC_FIXED64 { + return Err(format!("{path}: refused: the contract's numeric profile is `{}`; a score renders in \ + {NUMERIC_FIXED64}", s.contract.numeric)); + } if s.contract.duration > MAX_SAMPLES { return Err(format!("{path}: the score is {} samples; this tool renders at most {MAX_SAMPLES}", s.contract.duration)); diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index c71d5da12..860a30c82 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -308,6 +308,32 @@ fn score_build_refuses_what_is_not_integer_notes_rendered_whole() { } } +/// `render` refuses a contract it would misreport: the reference mixdown is one channel +/// in `wai.det.fixed64`, so a contract stating two channels (a verifier would read the +/// samples as stereo frames) or another numeric profile is refused, as `build` refuses +/// to write one. Each case is the committed scale.wscr with its contract patched in place. +#[test] +fn score_render_refuses_a_contract_it_does_not_render() { + let committed = std::fs::read(score_dir().join("vectors/scale.wscr")).unwrap(); + let patch = |from: &str, to: &str| { + assert_eq!(from.len(), to.len(), "a patch in place keeps every offset"); + let at = committed.windows(from.len()).position(|w| w == from.as_bytes()).unwrap(); + let mut b = committed.clone(); + b[at..at + to.len()].copy_from_slice(to.as_bytes()); + b + }; + for (bytes, what, said) in [ + (patch("\"channels\":1", "\"channels\":2"), "two channels", "2 channels"), + (patch("wai.det.fixed64", "wai.det.float64"), "another numeric profile", "wai.det.float64"), + ] { + let input = scratch("contract.wscr"); + std::fs::write(&input, bytes).unwrap(); + let pcm = scratch("contract.s16"); + let stderr = refused(&wai(&["score", "render", p(&input), "--pcm", p(&pcm), "--json"]), &pcm, what); + assert!(stderr.contains("refused") && stderr.contains(said), "{what}: {stderr}"); + } +} + /// A `WSCR` that declares more notes than its notes section holds is refused before the /// reader reserves room for them: a file of a few bytes can declare 2^32 − 1. The file /// carries a well-formed contract (section 0x01) and a notes section (0x02) holding only -- 2.54.0 (Apple Git-157) From 95fa7244f64e35cf5cf9cebceb3eb9b1dba9debd Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 16:56:41 -0400 Subject: [PATCH 06/11] wai score build: refuse a member given twice in the authoring form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authoring form (score §2.3) is the definition, yet a note holding both "start":0 and "start":5 built with start 5 (serde_json keeps the last value) and the same hash as a note with start 5 alone; another JSON reader could keep the first. wai_cli/strict_json.rs reads JSON with every object's members distinct and refuses a repeat, and build reads its document through it. Two refused cases are added: a note field and a document member, each given twice. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/score.md | 3 +- wai/wai-rs/src/bin/wai.rs | 6 ++ wai/wai-rs/src/bin/wai_cli/score.rs | 9 ++- wai/wai-rs/src/bin/wai_cli/strict_json.rs | 87 +++++++++++++++++++++++ wai/wai-rs/tests/cli_generate_score.rs | 2 + 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 wai/wai-rs/src/bin/wai_cli/strict_json.rs diff --git a/wai/extensions/score.md b/wai/extensions/score.md index 9a99f8f22..211b70689 100644 --- a/wai/extensions/score.md +++ b/wai/extensions/score.md @@ -76,7 +76,8 @@ platform's floating-point `pow` can change a pitch: `phase_inc`, `gain` and `sustain` are the raw Q32.32 bits. `numeric` may be left out. The tool refuses a number with a fraction or an exponent, a -member it does not name, more than one channel, a `phase_inc` of a whole +member it does not name, a member given twice (readers differ on which of +the two values they keep), more than one channel, a `phase_inc` of a whole cycle or more, a `gain` or `sustain` above 1.0, a waveform above 3, and a note whose release would end after `duration`, so the mixdown always holds every note whole. `score-conformance/notes/` holds each vector in diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index c69dffc03..ffaab8cf3 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -25,6 +25,12 @@ use wai::*; #[path = "wai_cli/stage.rs"] mod stage; +// JSON read with every object's members distinct: the instruction object below and +// `score build`'s authoring form refuse a repeated member. +#[cfg_attr(not(feature = "score"), allow(dead_code))] +#[path = "wai_cli/strict_json.rs"] +mod strict_json; + // The form-v0 instruction object of `wai.generate.spectral` (SPEC §5): `wrap` checks a // payload against it in every build; `generate` makes pictures from one. #[cfg_attr(not(feature = "generate"), allow(dead_code))] diff --git a/wai/wai-rs/src/bin/wai_cli/score.rs b/wai/wai-rs/src/bin/wai_cli/score.rs index e8fde3217..9f2d4237e 100644 --- a/wai/wai-rs/src/bin/wai_cli/score.rs +++ b/wai/wai-rs/src/bin/wai_cli/score.rs @@ -69,9 +69,14 @@ fn unit(o: &Map, k: &str, what: &str, hi_inclusive: bool) -> Resu /// reference synth renders every note whole: one channel, a duration of at most /// [`MAX_SAMPLES`], `phase_inc` below one cycle a sample, `gain` and `sustain` from 0 /// to 1.0, a reference waveform (0 to 3), and every note's release ending by the -/// contract's duration. +/// contract's duration. A member given twice, in the document or in a note, is +/// refused: the committed document is the definition (score §2.3), and readers differ +/// on which of two values they keep. pub fn from_notes_json(bytes: &[u8]) -> Result { - let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not JSON: {e}"))?; + let v: Value = super::strict_json::from_slice(bytes).map_err(|e| match e { + super::strict_json::Refused::Repeated(m) => m, + super::strict_json::Refused::NotJson(m) => format!("not JSON: {m}"), + })?; let o = v.as_object().ok_or("not a JSON object")?; members(o, &TOP_MEMBERS, "score")?; let sample_rate = uint(o, "sample_rate", "score")?; diff --git a/wai/wai-rs/src/bin/wai_cli/strict_json.rs b/wai/wai-rs/src/bin/wai_cli/strict_json.rs new file mode 100644 index 000000000..65788e3b4 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/strict_json.rs @@ -0,0 +1,87 @@ +//! JSON read with every object's members distinct. `serde_json` keeps the last of a +//! repeated member, and another reader may keep the first, so a document that is a +//! definition — the authoring form of a score (score §2.3), an instruction object of +//! `wai.generate.spectral` (SPEC §5) — would mean one thing to one reader and another +//! to the next. These readers refuse a repeated member instead. + +use std::fmt; + +use serde::de::{DeserializeSeed, Deserializer, Error as _, MapAccess, SeqAccess, Visitor}; +use serde_json::{Map, Number, Value}; + +/// A JSON value whose objects name each member once. +struct Distinct; + +impl<'de> DeserializeSeed<'de> for Distinct { + type Value = Value; + fn deserialize>(self, d: D) -> Result { + d.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for Distinct { + type Value = Value; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a JSON value") + } + fn visit_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + Ok(Number::from_f64(v).map_or(Value::Null, Value::Number)) + } + fn visit_str(self, v: &str) -> Result { + Ok(Value::String(v.to_owned())) + } + fn visit_string(self, v: String) -> Result { + Ok(Value::String(v)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut v = Vec::new(); + while let Some(x) = seq.next_element_seed(Distinct)? { + v.push(x); + } + Ok(Value::Array(v)) + } + fn visit_map>(self, mut map: A) -> Result { + let mut m = Map::new(); + while let Some(k) = map.next_key::()? { + if m.contains_key(&k) { + return Err(A::Error::custom(format!("member `{k}` is given twice"))); + } + let v = map.next_value_seed(Distinct)?; + m.insert(k, v); + } + Ok(Value::Object(m)) + } +} + +/// Why `bytes` is not read. +pub enum Refused { + /// An object names a member twice. + Repeated(String), + /// The bytes are not one JSON value. + NotJson(String), +} + +/// One JSON value from `bytes`, with every object's members distinct; nothing but +/// whitespace may follow it, as `serde_json::from_slice` allows. +pub fn from_slice(bytes: &[u8]) -> Result { + let mut de = serde_json::Deserializer::from_slice(bytes); + let v = Distinct.deserialize(&mut de).and_then(|v| de.end().map(|()| v)); + // Every value is accepted by the visitor, so the only data error is the repeated + // member it raises; every other error is the syntax's. + v.map_err(|e| if e.is_data() { Refused::Repeated(e.to_string()) } else { Refused::NotJson(e.to_string()) }) +} diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index 860a30c82..c7a3a2b43 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -296,6 +296,8 @@ fn score_build_refuses_what_is_not_integer_notes_rendered_whole() { (score(¬e.replace("\"release\":50", "\"release\":101"), ""), "a release the duration would cut"), (score(¬e.replace("\"dur\":100,", "\"dur\":100,\"pitch\":60,"), ""), "a member that is not a note field"), (score(¬e.replace(",\"release\":50", ""), ""), "a missing note field"), + (score(¬e.replace("\"start\":0", "\"start\":0,\"start\":5"), ""), "a note field given twice"), + (score(note, ",\"duration\":300"), "a document member given twice"), (score(note, ",\"numeric\":\"wai.det.float\""), "another numeric profile"), (score(note, "").replace("\"channels\":1", "\"channels\":2"), "two channels"), (score(note, "").replace("\"duration\":200", "\"duration\":67108865"), "a duration past 2^26 samples"), -- 2.54.0 (Apple Git-157) From f0107b07b8d53943b87486ac318bf69b81541154 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 17:01:04 -0400 Subject: [PATCH 07/11] wai generate.spectral form v0: accepted objects at every bound, and each refusal's reason instr-v0.json had one accepted object and 28 refused ones, none at an inclusive bound, so a second sink stricter by one passed every vector; and the CLI test held only that each object was refused, not why, so a rule relaxed (a quotation mark let into the prompt) stayed green while the canonical-bytes check refused the object in its place. - The reader (wai_cli/instr.rs) refuses with a reason code, the first rule broken in a stated order; a member given twice is refused as such (strict_json). - The vector gains twelve accepted objects (seed 0 and 2^32 - 1, steps 1 and 64, an empty prompt, 256 bytes, 0x20 and 0x7e, JSON's structural characters, a side of 1, a side of 4096 and 2^23 pixels, 16 sizes, the w-then-h order), each with the SHA-256 of its first listed size made natively; eight more refused objects (types, a fraction in a size, a negative side, an escaped line feed, a backslash, cut-short JSON); and a reason for every refused object. - tests/cli_generate_score.rs asserts each refusal's reason through `wai generate --instr` and `wai wrap`, and makes and wraps every accepted object. - tests/generate.rs states form v0 a second time, independently of the reader, and holds the object, the accepted and the refused lists to it, making each accepted object's output with the library. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 4 +- wai/CHANGELOG.md | 4 +- wai/SPEC.md | 5 +- wai/wai-rs/src/bin/wai.rs | 1 - wai/wai-rs/src/bin/wai_cli/generate.rs | 4 +- wai/wai-rs/src/bin/wai_cli/instr.rs | 158 ++++++---- wai/wai-rs/tests/cli_generate_score.rs | 44 ++- wai/wai-rs/tests/generate.rs | 70 +++++ .../tests/vectors/generate/instr-v0.json | 274 ++++++++++++++++-- 9 files changed, 470 insertions(+), 94 deletions(-) diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 880534d76..3df436146 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -241,7 +241,9 @@ jobs: # tests/cli_generate_score.rs runs `wai generate` and `wai score build|render`, which # the features `generate` and `score` build into the `wai` binary: every pin it prints # against those vectors and score-conformance/ (pcm_sha256 among them), every object - # instr-v0.json lists as refused, and each refusal writing nothing. + # instr-v0.json lists as accepted (one at each bound) made and wrapped, every object + # it lists as refused refused for the reason it states, and each refusal writing + # 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 diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 0352c696e..21048b7b7 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -16,7 +16,9 @@ wire format may change and such changes are recorded here. sink makes only listed sizes, with that model only, and checks each output by its SHA-256, never by a 64-bit FNV-1a alone. The envelope's intent is `create`. Vector: `wai-rs/tests/vectors/generate/instr-v0.json`, the object, the SHA-256 at each of - its four listed sizes, and 28 objects a sink refuses. + its four listed sizes, twelve objects at the inclusive bounds a sink accepts (each + with the SHA-256 of its first listed size), and 36 objects a sink refuses, each with + the rule the reference reader finds broken first, which its tests assert. - **`wai generate`** (feature `generate`) makes a picture with the reference generator at any size form v0 allows and prints its SHA-256; `--instr` makes a listed size of an object, read as a sink reads it, and `--write-instr` writes one. `sizes.json` pins diff --git a/wai/SPEC.md b/wai/SPEC.md index 359dd56a5..2077f2d5d 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -817,7 +817,10 @@ and MUST NOT accept one on a 64-bit FNV-1a alone (§7, *Digests*). The envelope' `replicate` (§7). The conformance vector is `wai-rs/tests/vectors/generate/instr-v0.json`: the object -above, the SHA-256 of each of its four listed sizes, and objects a sink refuses. +above and the SHA-256 of each of its four listed sizes; objects at each inclusive bound +above, which a sink MUST accept, each with the SHA-256 of its first listed size; and +objects a sink MUST refuse, each with the rule the reference reader finds broken first +(informative: a sink refuses each, and says why in its own words). `wai generate` is the reference: `--instr` makes a listed size from an object, and `--write-instr` writes one. diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index ffaab8cf3..3a53f0cc7 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -27,7 +27,6 @@ mod stage; // JSON read with every object's members distinct: the instruction object below and // `score build`'s authoring form refuse a repeated member. -#[cfg_attr(not(feature = "score"), allow(dead_code))] #[path = "wai_cli/strict_json.rs"] mod strict_json; diff --git a/wai/wai-rs/src/bin/wai_cli/generate.rs b/wai/wai-rs/src/bin/wai_cli/generate.rs index 429ed096b..02c434302 100644 --- a/wai/wai-rs/src/bin/wai_cli/generate.rs +++ b/wai/wai-rs/src/bin/wai_cli/generate.rs @@ -109,7 +109,7 @@ pub fn main(args: &[String]) -> Result<(), String> { let sizes = list.split(',').map(instr::parse_size).collect::, _>>()?; let (bytes, _) = load_model(model_path)?; let i = Instruction { model_sha256: wai::pin::sha256_hex(&bytes), prompt, seed, steps, sizes }; - i.check()?; + i.check().map_err(|e| e.to_string())?; let object = i.canonical(); // What is written is what a sink reads. debug_assert_eq!(Instruction::parse(&object).as_ref(), Ok(&i)); @@ -139,7 +139,7 @@ pub fn main(args: &[String]) -> Result<(), String> { // The same bounds as an instruction object, so every pin this writes is one // an object can name. Instruction { model_sha256: wai::pin::sha256_hex(&bytes), prompt: prompt.clone(), seed, steps, sizes: vec![(w, h)] } - .check()?; + .check().map_err(|e| e.to_string())?; (prompt, seed, steps) }; diff --git a/wai/wai-rs/src/bin/wai_cli/instr.rs b/wai/wai-rs/src/bin/wai_cli/instr.rs index 82d5c1648..acab196d4 100644 --- a/wai/wai-rs/src/bin/wai_cli/instr.rs +++ b/wai/wai-rs/src/bin/wai_cli/instr.rs @@ -2,13 +2,23 @@ //! *Payload of `wai.generate.spectral`*). One canonical JSON object names the model by //! its SHA-256 and gives the prompt, the seed, the steps and every size a sink may //! make. `wai wrap` checks a `wai.generate.spectral` payload against it before it -//! writes an envelope, and `wai generate --instr` makes a listed size from one. +//! writes an envelope, `wai inspect` and `wai unwrap` report it, and `wai generate +//! --instr` makes a listed size from one. //! //! Reading the object needs no feature: it is JSON and a comparison of bytes. The -//! object is read by its members and then written again in the canonical form; it is +//! object is read by its members (a member given twice is refused, since readers differ +//! on which value they keep) and then written again in the canonical form; it is //! accepted only if that equals the bytes given, so another order, whitespace, an -//! escape, a duplicate member or a trailing line feed is refused. +//! escape or a trailing line feed is refused. +//! +//! Every refusal carries a reason code ([`Refusal::code`]), the first rule the object +//! breaks in the order [`Instruction::parse`] checks them. The conformance vector +//! (`tests/vectors/generate/instr-v0.json`) states the code for each object it lists as +//! refused, so a test notices when a rule stops refusing and another rule happens to +//! refuse the same object. The codes are this tool's; a sink MUST refuse each object, +//! by any reason. +use super::strict_json; use serde_json::Value; /// The members of a form-v0 object, in their canonical (ascending byte) order. @@ -24,6 +34,28 @@ pub const MAX_SIDE: u64 = 4096; /// The most pixels at a listed size (`w·h`), 2^23. pub const MAX_PIXELS: u64 = 1 << 23; +/// Why bytes are not a form-v0 instruction object. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Refusal { + /// The rule broken: `not_json`, `member_repeated`, `not_object`, `member_unknown`, + /// `member_missing`, `member_type`, `not_integer`, `model_digest`, `prompt_length`, + /// `prompt_byte`, `seed_range`, `steps_range`, `sizes_count`, `size_not_pair`, + /// `size_side`, `size_pixels`, `sizes_order` or `not_canonical`. + pub code: &'static str, + /// The same, in words. + pub detail: String, +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.code, self.detail) + } +} + +fn no(code: &'static str, detail: impl Into) -> Refusal { + Refusal { code, detail: detail.into() } +} + /// A form-v0 instruction object. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Instruction { @@ -43,16 +75,38 @@ fn is_hex64(s: &str) -> bool { /// Whether `prompt` is a form-v0 prompt: at most [`MAX_PROMPT`] bytes, each a /// printable ASCII character other than `"` and `\`, so its JSON string needs no /// escape and its bytes are the same in every JSON writer. -pub fn prompt_ok(prompt: &str) -> Result<(), String> { +pub fn prompt_ok(prompt: &str) -> Result<(), Refusal> { if prompt.len() > MAX_PROMPT { - return Err(format!("the prompt is {} bytes; form v0 allows at most {MAX_PROMPT}", prompt.len())); + return Err(no("prompt_length", format!("the prompt is {} bytes; form v0 allows at most {MAX_PROMPT}", prompt.len()))); } if let Some(b) = prompt.bytes().find(|&b| !(0x20..=0x7e).contains(&b) || b == b'"' || b == b'\\') { - return Err(format!("the prompt holds byte 0x{b:02x}; form v0 allows 0x20-0x7e other than '\"' and '\\'")); + return Err(no("prompt_byte", format!("the prompt holds byte 0x{b:02x}; form v0 allows 0x20-0x7e other than '\"' and '\\'"))); } Ok(()) } +/// One size within form v0's bounds. +pub fn size_ok(w: u64, h: u64) -> Result<(), Refusal> { + if !(1..=MAX_SIDE).contains(&w) || !(1..=MAX_SIDE).contains(&h) { + return Err(no("size_side", format!("size {w}x{h}: each side is 1 to {MAX_SIDE} pixels in form v0"))); + } + if w * h > MAX_PIXELS { + return Err(no("size_pixels", format!("size {w}x{h} is {} pixels; form v0 allows at most {MAX_PIXELS}", w * h))); + } + Ok(()) +} + +/// A member's value as an integer written in decimal: `member_type` when it is not a +/// number, `not_integer` when it is one with a fraction or an exponent (which the +/// canonical form would also refuse, but for a reason that says less). +fn integer(v: &Value, name: &str) -> Result { + match v { + Value::Number(n) => n.as_u64().map(i128::from).or_else(|| n.as_i64().map(i128::from)) + .ok_or_else(|| no("not_integer", format!("`{name}` is {n}, not an integer in decimal"))), + _ => Err(no("member_type", format!("`{name}` is not a number"))), + } +} + impl Instruction { /// The canonical serialization: the members in ascending byte order, no /// whitespace, integers in decimal. The prompt is written as it is, which @@ -68,67 +122,74 @@ impl Instruction { /// Every rule of form v0 that is not about the bytes: the model digest, the /// prompt, the ranges and the order of the sizes. - pub fn check(&self) -> Result<(), String> { + pub fn check(&self) -> Result<(), Refusal> { if !is_hex64(&self.model_sha256) { - return Err("`model` is not `sha256:` and 64 lowercase hexadecimal digits".into()); + return Err(no("model_digest", "`model` is not `sha256:` and 64 lowercase hexadecimal digits")); } prompt_ok(&self.prompt)?; if !(1..=MAX_STEPS).contains(&(self.steps as u64)) { - return Err(format!("`steps` is {}; form v0 allows 1 to {MAX_STEPS}", self.steps)); + return Err(no("steps_range", format!("`steps` is {}; form v0 allows 1 to {MAX_STEPS}", self.steps))); } if self.sizes.is_empty() || self.sizes.len() > MAX_SIZES { - return Err(format!("`sizes` lists {} sizes; form v0 allows 1 to {MAX_SIZES}", self.sizes.len())); + return Err(no("sizes_count", format!("`sizes` lists {} sizes; form v0 allows 1 to {MAX_SIZES}", self.sizes.len()))); } for &(w, h) in &self.sizes { size_ok(w as u64, h as u64)?; } if self.sizes.windows(2).any(|p| p[0] >= p[1]) { - return Err("`sizes` is not in strictly ascending order of w and then h".into()); + return Err(no("sizes_order", "`sizes` is not in strictly ascending order of w and then h")); } Ok(()) } /// Read a form-v0 object from the payload bytes. Refuses anything that is not - /// exactly the canonical serialization of an object that passes [`Self::check`]. - pub fn parse(bytes: &[u8]) -> Result { - let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not JSON: {e}"))?; - let o = v.as_object().ok_or("not a JSON object")?; - for k in o.keys() { - if !MEMBERS.contains(&k.as_str()) { - return Err(format!("member `{k}` is not one of form v0's")); - } + /// exactly the canonical serialization of an object that passes [`Self::check`], + /// with the code of the first rule it breaks, in this order: the JSON (a member + /// given twice among it), an object, its members, `model`, `prompt`, `seed`, + /// `steps`, `sizes` (the count, each pair, the order), and last the bytes. + pub fn parse(bytes: &[u8]) -> Result { + let v = strict_json::from_slice(bytes).map_err(|e| match e { + strict_json::Refused::Repeated(m) => no("member_repeated", m), + strict_json::Refused::NotJson(m) => no("not_json", m), + })?; + let o = v.as_object().ok_or_else(|| no("not_object", "the payload is JSON but not an object"))?; + if let Some(k) = o.keys().find(|k| !MEMBERS.contains(&k.as_str())) { + return Err(no("member_unknown", format!("member `{k}` is not one of form v0's"))); } - for k in MEMBERS { - if !o.contains_key(k) { - return Err(format!("member `{k}` is missing")); - } + if let Some(k) = MEMBERS.iter().find(|k| !o.contains_key(**k)) { + return Err(no("member_missing", format!("member `{k}` is missing"))); } - let model = o["model"].as_str().ok_or("`model` is not a string")?; - let model_sha256 = model.strip_prefix("sha256:").ok_or("`model` does not begin `sha256:`")?.to_string(); - let prompt = o["prompt"].as_str().ok_or("`prompt` is not a string")?.to_string(); - let seed = o["seed"].as_u64().ok_or("`seed` is not a non-negative integer")?; - let seed = u32::try_from(seed).map_err(|_| format!("`seed` is {seed}; form v0 allows 0 to 4294967295"))?; - let steps = o["steps"].as_u64().ok_or("`steps` is not a non-negative integer")?; - if !(1..=MAX_STEPS).contains(&steps) { - return Err(format!("`steps` is {steps}; form v0 allows 1 to {MAX_STEPS}")); + let model = o["model"].as_str().ok_or_else(|| no("member_type", "`model` is not a string"))?; + let model_sha256 = model.strip_prefix("sha256:").filter(|h| is_hex64(h)) + .ok_or_else(|| no("model_digest", "`model` is not `sha256:` and 64 lowercase hexadecimal digits"))? + .to_string(); + let prompt = o["prompt"].as_str().ok_or_else(|| no("member_type", "`prompt` is not a string"))?.to_string(); + prompt_ok(&prompt)?; + let seed = integer(&o["seed"], "seed")?; + let seed = u32::try_from(seed).map_err(|_| no("seed_range", format!("`seed` is {seed}; form v0 allows 0 to {}", u32::MAX)))?; + let steps = integer(&o["steps"], "steps")?; + if !(1..=MAX_STEPS as i128).contains(&steps) { + return Err(no("steps_range", format!("`steps` is {steps}; form v0 allows 1 to {MAX_STEPS}"))); } - let list = o["sizes"].as_array().ok_or("`sizes` is not an array")?; - let mut sizes = Vec::with_capacity(list.len().min(MAX_SIZES)); + let list = o["sizes"].as_array().ok_or_else(|| no("member_type", "`sizes` is not an array"))?; + if list.is_empty() || list.len() > MAX_SIZES { + return Err(no("sizes_count", format!("`sizes` lists {} sizes; form v0 allows 1 to {MAX_SIZES}", list.len()))); + } + let mut sizes = Vec::with_capacity(list.len()); for s in list { - let pair = s.as_array().filter(|a| a.len() == 2).ok_or("a size is not a pair [w, h]")?; - let (w, h) = (pair[0].as_u64(), pair[1].as_u64()); - let (Some(w), Some(h)) = (w, h) else { - return Err("a size's w or h is not a non-negative integer".into()); - }; - size_ok(w, h)?; + let pair = s.as_array().filter(|a| a.len() == 2).ok_or_else(|| no("size_not_pair", "a size is not a pair [w, h]"))?; + let (w, h) = (integer(&pair[0], "a size's w")?, integer(&pair[1], "a size's h")?); + if !(1..=MAX_SIDE as i128).contains(&w) || !(1..=MAX_SIDE as i128).contains(&h) { + return Err(no("size_side", format!("size {w}x{h}: each side is 1 to {MAX_SIDE} pixels in form v0"))); + } + size_ok(w as u64, h as u64)?; sizes.push((w as u32, h as u32)); } let i = Instruction { model_sha256, prompt, seed, steps: steps as u32, sizes }; i.check()?; if i.canonical() != bytes { - return Err("not in canonical form: members in ascending byte order, no whitespace, \ - no escape, nothing after the object" - .into()); + return Err(no("not_canonical", "not in canonical form: members in ascending byte order, no whitespace, \ + no escape, nothing after the object")); } Ok(i) } @@ -139,22 +200,11 @@ impl Instruction { } } -/// One size within form v0's bounds. -pub fn size_ok(w: u64, h: u64) -> Result<(), String> { - if !(1..=MAX_SIDE).contains(&w) || !(1..=MAX_SIDE).contains(&h) { - return Err(format!("size {w}x{h}: each side is 1 to {MAX_SIDE} pixels in form v0")); - } - if w * h > MAX_PIXELS { - return Err(format!("size {w}x{h} is {} pixels; form v0 allows at most {MAX_PIXELS}", w * h)); - } - Ok(()) -} - /// `WxH` → `(w, h)`, within form v0's bounds. pub fn parse_size(s: &str) -> Result<(u32, u32), String> { let (w, h) = s.split_once(['x', 'X']).ok_or_else(|| format!("size `{s}` is not WxH"))?; let w: u64 = w.parse().map_err(|_| format!("size `{s}`: width is not an integer"))?; let h: u64 = h.parse().map_err(|_| format!("size `{s}`: height is not an integer"))?; - size_ok(w, h)?; + size_ok(w, h).map_err(|e| e.to_string())?; Ok((w as u32, h as u32)) } diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index c7a3a2b43..04f5ba7a3 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -175,25 +175,31 @@ fn instr_makes_the_listed_large_sizes() { } /// Every object the vector lists as refused is refused by `wai generate --instr` and -/// by `wai wrap`, and neither writes anything; the vector's own object is accepted by -/// both, and the envelope carries it byte for byte with intent `create`. +/// by `wai wrap`, each for the reason the vector states (the first rule the reader +/// finds broken), and neither writes anything. Holding the reason, not only the +/// refusal, notices a rule that stops refusing while another rule still refuses the +/// same object: a quotation mark in the prompt, say, which the canonical-bytes check +/// would refuse if the prompt rule let it through. The vector's own object is accepted +/// by both, and the envelope carries it byte for byte with intent `create`. #[test] fn the_vectors_refused_objects_are_refused() { let v = jload(&gen_dir().join("instr-v0.json")); let cases = v["refused"].as_array().unwrap(); - assert!(cases.len() >= 20, "the vector lists its refusals"); + assert!(cases.len() >= 30, "the vector lists its refusals"); let object = scratch("refused.instr"); for c in cases { let why = c["why"].as_str().unwrap(); + let reason = c["reason"].as_str().unwrap_or_else(|| panic!("{why}: the vector states no reason")); std::fs::write(&object, c["object"].as_str().unwrap()).unwrap(); let out = scratch("refused.rgb"); let stderr = refused(&wai(&["generate", "--model", &m(), "--instr", p(&object), "--size", "256x128", "-o", p(&out)]), &out, why); - assert!(stderr.contains("refused"), "{why}: {stderr}"); + assert!(stderr.contains(&format!("refused: {reason}: ")), "{why}: refused, but not as {reason}: {stderr}"); let env = scratch("refused.wai"); let stderr = refused(&wai(&["wrap", "wai.generate.spectral", p(&object), "--intent", "create", "-o", p(&env)]), &env, why); - assert!(stderr.contains("instruction object of form v0"), "{why}: {stderr}"); + assert!(stderr.contains("instruction object of form v0") && stderr.contains(&format!(": {reason}: ")), + "{why}: refused, but not as {reason}: {stderr}"); } std::fs::write(&object, v["object"].as_str().unwrap()).unwrap(); let env = scratch("accepted.wai"); @@ -203,6 +209,34 @@ fn the_vectors_refused_objects_are_refused() { assert_eq!(e.payload, v["object"].as_str().unwrap().as_bytes()); } +/// Every object the vector lists as accepted — one at each inclusive bound of form v0 — +/// is accepted by `wai generate --instr`, which makes its first listed size with the +/// SHA-256 the vector states, and by `wai wrap`, whose envelope carries it byte for +/// byte. A reader stricter than form v0 by one refuses one of them. +#[test] +fn the_vectors_accepted_objects_are_accepted() { + let v = jload(&gen_dir().join("instr-v0.json")); + let cases = v["accepted"].as_array().unwrap(); + assert!(cases.len() >= 12, "the vector lists an object at each bound"); + let object = scratch("accepted.instr"); + for c in cases { + let why = c["why"].as_str().unwrap(); + std::fs::write(&object, c["object"].as_str().unwrap()).unwrap(); + for e in c["outputs"].as_array().unwrap() { + let (w, h) = (e["w"].as_u64().unwrap(), e["h"].as_u64().unwrap()); + let out = scratch("accepted.rgb"); + let run = wai(&["generate", "--model", &m(), "--instr", p(&object), "--size", &format!("{w}x{h}"), + "-o", p(&out), "--json"]); + assert!(run.status.success(), "{why}: refused: {}", String::from_utf8_lossy(&run.stderr)); + assert_eq!(made(&ok(&run), &out, w, h), e["sha256"].as_str().unwrap(), "{why}: {w}x{h}"); + } + let env = scratch("accepted-bound.wai"); + let run = wai(&["wrap", "wai.generate.spectral", p(&object), "--intent", "create", "-o", p(&env)]); + assert!(run.status.success(), "{why}: wrap refused it: {}", String::from_utf8_lossy(&run.stderr)); + assert_eq!(Wai::read(&env).unwrap().payload, c["object"].as_str().unwrap().as_bytes(), "{why}"); + } +} + #[test] fn instr_refuses_another_model_and_an_unlisted_size() { let v = jload(&gen_dir().join("instr-v0.json")); diff --git a/wai/wai-rs/tests/generate.rs b/wai/wai-rs/tests/generate.rs index 02a172ebb..f891df790 100644 --- a/wai/wai-rs/tests/generate.rs +++ b/wai/wai-rs/tests/generate.rs @@ -148,3 +148,73 @@ fn instruction_object_v0_small_sizes_reproduce() { fn instruction_object_v0_large_sizes_reproduce() { assert_eq!(make_listed(640 * 320 + 1, usize::MAX), 2); } + +/// SPEC §5, *Payload of `wai.generate.spectral`*, stated here a second time and +/// independently of `wai`'s reader: the bytes are one JSON object with exactly the +/// five members, each value in range, and the bytes are exactly that object written +/// canonically (members in ascending byte order, no whitespace, integers in decimal, +/// the prompt unescaped). A member given twice fails the last test, since the object +/// written again names each member once. `Some(object)` when the bytes are form v0. +fn form_v0(bytes: &[u8]) -> Option { + let o: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let m = o.as_object()?; + let keys: Vec<&str> = m.keys().map(String::as_str).collect(); + let mut sorted = keys.clone(); + sorted.sort(); + if sorted != ["model", "prompt", "seed", "sizes", "steps"] { + return None; + } + let hex = o["model"].as_str()?.strip_prefix("sha256:")?; + let hex_ok = hex.len() == 64 && hex.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + let prompt = o["prompt"].as_str()?; + let prompt_ok = prompt.len() <= 256 && prompt.bytes().all(|b| (0x20..=0x7e).contains(&b) && b != b'"' && b != b'\\'); + let seed = o["seed"].as_u64()?; + let steps = o["steps"].as_u64()?; + let sizes: Vec<(u64, u64)> = o["sizes"].as_array()?.iter() + .map(|p| p.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a[0].as_u64()?, a[1].as_u64()?)))) + .collect::>()?; + let sizes_ok = (1..=16).contains(&sizes.len()) + && sizes.iter().all(|&(w, h)| (1..=4096).contains(&w) && (1..=4096).contains(&h) && w * h <= 1 << 23) + && sizes.windows(2).all(|p| p[0] < p[1]); + if !(hex_ok && prompt_ok && seed <= u32::MAX as u64 && (1..=64).contains(&steps) && sizes_ok) { + return None; + } + let listed: Vec = sizes.iter().map(|(w, h)| format!("[{w},{h}]")).collect(); + let canonical = format!("{{\"model\":\"sha256:{hex}\",\"prompt\":\"{prompt}\",\"seed\":{seed},\"sizes\":[{}],\"steps\":{steps}}}", + listed.join(",")); + (canonical.as_bytes() == bytes).then_some(o) +} + +/// `instr-v0.json`, read by the statement above: its object and every object it lists +/// as accepted (one at each inclusive bound) are form v0, and each accepted object's +/// first listed size, made by the reference generator, has the SHA-256 the vector +/// states; every object it lists as refused is not form v0. `wai`'s own reader is held +/// to the same lists, with the reason for each refusal, in `tests/cli_generate_score.rs`. +#[test] +fn instruction_object_v0_bounds_and_refusals() { + let v = jload("instr-v0.json"); + let model_bytes = std::fs::read(dir().join(v["model_file"].as_str().unwrap())).unwrap(); + let m = SpectralModel::from_value(&serde_json::from_slice(&model_bytes).unwrap()); + let object = form_v0(v["object"].as_str().unwrap().as_bytes()).expect("the vector's object is form v0"); + let accepted = v["accepted"].as_array().unwrap(); + assert!(accepted.len() >= 12); + for c in accepted { + let why = c["why"].as_str().unwrap(); + let o = form_v0(c["object"].as_str().unwrap().as_bytes()).unwrap_or_else(|| panic!("{why}: not form v0")); + assert_eq!(o["model"], object["model"], "{why}: the committed model"); + let first = &o["sizes"][0]; + for e in c["outputs"].as_array().unwrap() { + let (w, h) = (e["w"].as_u64().unwrap() as usize, e["h"].as_u64().unwrap() as usize); + assert_eq!((first[0].as_u64(), first[1].as_u64()), (Some(w as u64), Some(h as u64)), "{why}: the first listed size"); + let rgb = generate(&m, o["prompt"].as_str().unwrap(), o["seed"].as_u64().unwrap(), o["steps"].as_u64().unwrap() as usize, h, w); + assert_eq!(rgb.len(), w * h * 3); + assert_eq!(&sha256_sidecar::sha256_hex(&rgb), e["sha256"].as_str().unwrap(), "{why}: {w}x{h}"); + } + } + let refused = v["refused"].as_array().unwrap(); + assert!(refused.len() >= 30); + for c in refused { + let why = c["why"].as_str().unwrap(); + assert!(form_v0(c["object"].as_str().unwrap().as_bytes()).is_none(), "{why}: the statement of form v0 accepts it"); + } +} diff --git a/wai/wai-rs/tests/vectors/generate/instr-v0.json b/wai/wai-rs/tests/vectors/generate/instr-v0.json index 4c54ccb56..4c9b73de7 100644 --- a/wai/wai-rs/tests/vectors/generate/instr-v0.json +++ b/wai/wai-rs/tests/vectors/generate/instr-v0.json @@ -29,118 +29,334 @@ "sha256": "763e9f672810db934d8c492db77230d6d1e7ab1375c6ff1f076222fa4dfcc83a" } ], + "accepted": [ + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":0,\"sizes\":[[8,4]],\"steps\":4}", + "why": "the lowest seed", + "outputs": [ + { + "w": 8, + "h": 4, + "bytes": 96, + "sha256": "37c9ac2ede8565a90cbc3769c6faf48e86e1bc695df8fb63b363696afcd2acae" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":4294967295,\"sizes\":[[8,4]],\"steps\":4}", + "why": "the highest seed, 2^32 - 1", + "outputs": [ + { + "w": 8, + "h": 4, + "bytes": 96, + "sha256": "d09d427c1d8182f3cf44ca55db6fac364303c509ea16a7eb288a9ca32f629a5c" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[8,4]],\"steps\":1}", + "why": "the fewest steps", + "outputs": [ + { + "w": 8, + "h": 4, + "bytes": 96, + "sha256": "707e58d2a194d59497b9bb4763f97752f40d80eb2c0bf3edf2cb862fa733393d" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[8,4]],\"steps\":64}", + "why": "the most steps", + "outputs": [ + { + "w": 8, + "h": 4, + "bytes": 96, + "sha256": "f145c90692d30290e9da0512f2880aefca5eee93e7992c2dfcaf79fb077ce1aa" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"\",\"seed\":7,\"sizes\":[[8,4]],\"steps\":4}", + "why": "an empty prompt", + "outputs": [ + { + "w": 8, + "h": 4, + "bytes": 96, + "sha256": "f168eff06763c5a1895305aacba2d7802cf163fb18d2c0868851a8cf23a02db4" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocean horizon calm ocea\",\"seed\":7,\"sizes\":[[8,4]],\"steps\":4}", + "why": "a prompt of 256 bytes", + "outputs": [ + { + "w": 8, + "h": 4, + "bytes": 96, + "sha256": "7a9a5293a09a809cd59cea1d7ed469e0180ff7ffe073d30c53917318e10d08db" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\" calm ~ horizon~\",\"seed\":7,\"sizes\":[[8,4]],\"steps\":4}", + "why": "a prompt holding 0x20 and 0x7e, the ends of the printable range, at its ends", + "outputs": [ + { + "w": 8, + "h": 4, + "bytes": 96, + "sha256": "4c7e761f20124363436c74f70183d19e25c825c4a2f2ea921a8efb1bd9d0d879" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"{[calm]:,horizon}\",\"seed\":7,\"sizes\":[[8,4]],\"steps\":4}", + "why": "a prompt holding JSON's structural characters", + "outputs": [ + { + "w": 8, + "h": 4, + "bytes": 96, + "sha256": "a6d9f1d58c870a5a0c9467e1827e8d729239732d9309ecb63b13d43ee5f92936" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[1,1]],\"steps\":4}", + "why": "a side of 1", + "outputs": [ + { + "w": 1, + "h": 1, + "bytes": 3, + "sha256": "9b97f74eae3b8f5f3694abd0b760621410003a832599cb528ff0bc07e294ccc4" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[1,4096],[4096,1],[4096,2048]],\"steps\":4}", + "why": "a side of 4096, and 2^23 pixels", + "outputs": [ + { + "w": 1, + "h": 4096, + "bytes": 12288, + "sha256": "37639a70c24aa12497a4c20082669957f5cc46c9afeef7d076a8e48b060e6d66" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[1,1],[1,2],[1,3],[1,4],[1,5],[1,6],[1,7],[1,8],[1,9],[1,10],[1,11],[1,12],[1,13],[1,14],[1,15],[1,16]],\"steps\":4}", + "why": "16 sizes", + "outputs": [ + { + "w": 1, + "h": 1, + "bytes": 3, + "sha256": "9b97f74eae3b8f5f3694abd0b760621410003a832599cb528ff0bc07e294ccc4" + } + ] + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[2,8],[2,9],[3,1]],\"steps\":4}", + "why": "sizes in ascending order of w and then h", + "outputs": [ + { + "w": 2, + "h": 8, + "bytes": 48, + "sha256": "37da067fc8966309c3000f4f020857b8be04923beb4bdd0ef018d44f8e072efd" + } + ] + } + ], + "accepted_note": "objects at each inclusive bound of form v0, each of which a sink MUST accept; each output is the picture at the object's first listed size, made natively by `wai generate --instr`", "refused": [ { "object": "{\"prompt\":\"a calm spectral horizon\",\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "members out of ascending byte order" + "why": "members out of ascending byte order", + "reason": "not_canonical" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\": 7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "whitespace" + "why": "whitespace", + "reason": "not_canonical" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}\n", - "why": "a line feed after the object" + "why": "a line feed after the object", + "reason": "not_canonical" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":4294967296,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "seed past 2^32 - 1" + "why": "seed past 2^32 - 1", + "reason": "seed_range" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":-1,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a negative seed" + "why": "a negative seed", + "reason": "seed_range" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7.0,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a seed written with a fraction" + "why": "a seed written with a fraction", + "reason": "not_integer" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7e0,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a seed written with an exponent" + "why": "a seed written with an exponent", + "reason": "not_integer" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":0}", - "why": "steps below 1" + "why": "steps below 1", + "reason": "steps_range" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":65}", - "why": "steps past 64" + "why": "steps past 64", + "reason": "steps_range" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[],\"steps\":4}", - "why": "no size" + "why": "no size", + "reason": "sizes_count" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[640,320],[256,128]],\"steps\":4}", - "why": "sizes out of ascending order" + "why": "sizes out of ascending order", + "reason": "sizes_order" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[256,128]],\"steps\":4}", - "why": "a size listed twice" + "why": "a size listed twice", + "reason": "sizes_order" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[0,128]],\"steps\":4}", - "why": "a side of 0" + "why": "a side of 0", + "reason": "size_side" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[4097,1]],\"steps\":4}", - "why": "a side past 4096" + "why": "a side past 4096", + "reason": "size_side" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[4096,4096]],\"steps\":4}", - "why": "more than 2^23 pixels" + "why": "more than 2^23 pixels", + "reason": "size_pixels" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128,1]],\"steps\":4}", - "why": "a size that is not a pair" + "why": "a size that is not a pair", + "reason": "size_not_pair" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[1,1],[2,1],[3,1],[4,1],[5,1],[6,1],[7,1],[8,1],[9,1],[10,1],[11,1],[12,1],[13,1],[14,1],[15,1],[16,1],[17,1]],\"steps\":4}", - "why": "more than 16 sizes" + "why": "more than 16 sizes", + "reason": "sizes_count" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral \\u0068orizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "an escape in the prompt" + "why": "an escape in the prompt", + "reason": "not_canonical" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a \\\"calm\\\" horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a quotation mark in the prompt" + "why": "a quotation mark in the prompt", + "reason": "prompt_byte" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horiz\u00f3n\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a byte outside printable ASCII in the prompt" + "why": "a byte outside printable ASCII in the prompt", + "reason": "prompt_byte" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a prompt of 257 bytes" + "why": "a prompt of 257 bytes", + "reason": "prompt_length" }, { "object": "{\"model\":\"sha256:963EE634DB1A964D14004618B139FD7E32598F627C023FAF9526B14611504F9E\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a model digest in uppercase" + "why": "a model digest in uppercase", + "reason": "model_digest" }, { "object": "{\"model\":\"963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a model digest without `sha256:`" + "why": "a model digest without `sha256:`", + "reason": "model_digest" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a model digest of 63 digits" + "why": "a model digest of 63 digits", + "reason": "model_digest" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4,\"version\":0}", - "why": "a member form v0 does not define" + "why": "a member form v0 does not define", + "reason": "member_unknown" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]]}", - "why": "a member missing" + "why": "a member missing", + "reason": "member_missing" }, { "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", - "why": "a member given twice" + "why": "a member given twice", + "reason": "member_repeated" }, { "object": "[{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}]", - "why": "not an object" + "why": "not an object", + "reason": "not_object" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4", + "why": "not JSON: the object is cut short", + "reason": "not_json" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":7,\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a prompt that is not a string", + "reason": "member_type" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":\"7\",\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a seed written as a string", + "reason": "member_type" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":\"256x128\",\"steps\":4}", + "why": "sizes that are not an array", + "reason": "member_type" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[256.0,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a side written with a fraction", + "reason": "not_integer" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[-256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a negative side", + "reason": "size_side" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm\\nhorizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "an escaped line feed in the prompt", + "reason": "prompt_byte" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm\\\\horizon\",\"seed\":7,\"sizes\":[[256,128],[640,320],[1280,720],[1920,1080]],\"steps\":4}", + "why": "a backslash in the prompt", + "reason": "prompt_byte" } - ] + ], + "reason_note": "the first rule the reference reader (wai generate --instr, wai wrap) finds broken, in the order it checks them; informative: a sink MUST refuse each object, and may say why in its own words" } -- 2.54.0 (Apple Git-157) From da03ef1524a6cf7731296ea63e069db9e50ce011 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 17:02:55 -0400 Subject: [PATCH 08/11] wai generate.spectral: the generator's envelope carries intent create, and wrap writes no other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §5 said the envelope's intent is `create`, but `wai wrap` refused only `replicate` and wrote wai.generate.spectral envelopes with `--intent improve` or `--intent banana`, and the non-conforming list did not mention intent. The sentence is now a rule: an envelope that names the generator, as capability or fallback, carries `create`, one with another intent is non-conforming, and wrap refuses any other. wrap_writes_the_generator_only_with_intent_create covers improve, an open string, a case variant, the empty string and the fallback. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 5 +++-- wai/SPEC.md | 9 +++++---- wai/wai-rs/src/bin/wai.rs | 22 ++++++++++++++-------- wai/wai-rs/tests/cli_generate_score.rs | 21 +++++++++++++++++++++ 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 21048b7b7..cb07ed9fd 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -14,7 +14,8 @@ wire format may change and such changes are recorded here. SHA-256 and giving the prompt, the seed, the steps and every size a sink makes. One serialization per object, so an envelope's content hash names the instructions; a sink makes only listed sizes, with that model only, and checks each output by its - SHA-256, never by a 64-bit FNV-1a alone. The envelope's intent is `create`. Vector: + SHA-256, never by a 64-bit FNV-1a alone. The intent of an envelope that names the + generator is `create`, and an envelope with another intent is non-conforming. Vector: `wai-rs/tests/vectors/generate/instr-v0.json`, the object, the SHA-256 at each of its four listed sizes, twelve objects at the inclusive bounds a sink accepts (each with the SHA-256 of its first listed size), and 36 objects a sink refuses, each with @@ -24,7 +25,7 @@ wire format may change and such changes are recorded here. an object, read as a sink reads it, and `--write-instr` writes one. `sizes.json` pins the committed prompt at 256×128 and 640×320; every pin equals the shared browser binding's output. `wai wrap` refuses a `wai.generate.spectral` payload that is not a - form-v0 object, in every build. + form-v0 object, and any intent but `create` over that generator, in every build. - **`wai score build|render`** (feature `score`). `build` writes a `WSCR` from integer note fields only (score §2.3); `render` prints `samples`, `pcm_sha256`, `mixdown_blake3` and `score_hash`, and `--pcm` writes the samples. Every diff --git a/wai/SPEC.md b/wai/SPEC.md index 2077f2d5d..a555fc73a 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -812,9 +812,9 @@ nothing for that size. Conformance is generation-equivalence (§7) at each listed size: every conforming sink makes the same bytes. A publisher MAY state each size's SHA-256 beside the payload; a sink that holds such a digest MUST NOT present a picture whose SHA-256 differs from it, -and MUST NOT accept one on a 64-bit FNV-1a alone (§7, *Digests*). The envelope's -`intent` is `create`: a generator has no source to replicate, so it does not back -`replicate` (§7). +and MUST NOT accept one on a 64-bit FNV-1a alone (§7, *Digests*). The `intent` of an +envelope that names `wai.generate.spectral` is `create`: a generator has no source to +replicate (it does not back `replicate`, §7) or to improve. The conformance vector is `wai-rs/tests/vectors/generate/instr-v0.json`: the object above and the SHA-256 of each of its four listed sizes; objects at each inclusive bound @@ -826,7 +826,8 @@ objects a sink MUST refuse, each with the rule the reference reader finds broken *Envelopes this makes non-conforming* (they still parse): a `WAI1` whose `capability` or `fallback` is `wai.generate.spectral` and whose payload is not an instruction object -of form v0, and a `WAI2` with a component of that string whose bytes are not one. +of form v0 or whose `intent` is not `create`, and a `WAI2` with a component of that +string whose bytes are not one, or whose `intent` is not `create`. ### Immersive audio, cinema, volumetric, haptics, HDR (registered, sink-supplied) diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index 3a53f0cc7..a662627fc 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -555,7 +555,8 @@ fn license_str(cap: &str) -> &'static str { /// pins its integer capability to (*Payloads of the WAI-native integer codecs*): the /// JSON a tool writes is converted first, never wrapped. So is a `wai.generate.spectral` /// payload that is not an instruction object of form v0 (*Payload of -/// `wai.generate.spectral`*); over that generator `replicate` is refused, so its +/// `wai.generate.spectral`*), and an envelope naming that generator with an intent other +/// than `create`: `replicate` is refused over it (§7), and so is any other, so its /// envelope is written with `--intent create`. fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, intent: Option<&str>, companions: &[(String, String)]) -> Result<(), String> { @@ -583,7 +584,7 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, (`create` for a generator, `improve` otherwise)"))?; if !companions.is_empty() { let payload = std::fs::read(input).map_err(|e| format!("read {input}: {e}"))?; - check_generate_payload(cap, fallback, &payload, out)?; + check_generate_payload(cap, fallback, intent, &payload, out)?; return wrap_with_companions(cap, payload, out, fallback, intent, companions); } // A sink that lacks `cap` decodes through the fallback (SPEC §4 step 5), so the @@ -596,7 +597,7 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, reconstruction claim"))?; } let payload = std::fs::read(input).map_err(|e| format!("read {input}: {e}"))?; - check_generate_payload(cap, fallback, &payload, out)?; + check_generate_payload(cap, fallback, intent, &payload, out)?; let n_in = payload.len(); let requirement = match fallback { Some(fb) => ModelRequirement::new(cap).with_fallback(fb), @@ -625,13 +626,18 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, } /// SPEC §5, *Payload of `wai.generate.spectral`*: the payload of that string is an -/// instruction object of form v0, so the reference tool does not write an envelope -/// that names it, as capability or fallback, over any other bytes (its JSON in another -/// order or spacing among them). Checked in every build: reading the object needs no -/// feature. -fn check_generate_payload(cap: &str, fallback: Option<&str>, payload: &[u8], out: &str) -> Result<(), String> { +/// instruction object of form v0, and the envelope's intent is `create`, so the +/// reference tool does not write an envelope that names it, as capability or fallback, +/// over any other bytes (its JSON in another order or spacing among them) or with any +/// other intent. Checked in every build: reading the object needs no feature. +fn check_generate_payload(cap: &str, fallback: Option<&str>, intent: &str, payload: &[u8], out: &str) + -> Result<(), String> { let g = wai::codecs::CAP_GENERATE_SPECTRAL; if cap == g || fallback == Some(g) { + if intent != "create" { + return Err(format!("not writing {out}: an envelope that names {g} carries the intent `create` \ + (SPEC \u{a7}5), not `{intent}`: a generator has no source to replicate or improve")); + } instr::Instruction::parse(payload).map_err(|e| format!( "not writing {out}: the payload of {g} is an instruction object of form v0 (SPEC \u{a7}5), \ and this one is not: {e}"))?; diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index 04f5ba7a3..6c493c392 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -270,6 +270,27 @@ fn wrap_refuses_replicate_over_the_generator() { } } +/// SPEC §5: an envelope that names the generator carries the intent `create`. `wai wrap` +/// writes no other: not `improve`, not a string the manifest schema leaves open, and not +/// with the generator as the fallback of another capability. +#[test] +fn wrap_writes_the_generator_only_with_intent_create() { + let v = jload(&gen_dir().join("instr-v0.json")); + let object = scratch("intent.instr"); + std::fs::write(&object, v["object"].as_str().unwrap()).unwrap(); + for intent in ["improve", "banana", "Create", ""] { + let env = scratch("intent.wai"); + let stderr = refused(&wai(&["wrap", "wai.generate.spectral", p(&object), "--intent", intent, "-o", p(&env)]), + &env, &format!("intent {intent:?}")); + assert!(stderr.contains("carries the intent `create`"), "{intent:?}: {stderr}"); + } + let env = scratch("intent.wai"); + let stderr = refused(&wai(&["wrap", "wai.image.png", p(&object), "--fallback", "wai.generate.spectral", + "--intent", "improve", "-o", p(&env)]), &env, "the generator as the fallback"); + assert!(stderr.contains("carries the intent `create`"), "{stderr}"); + ok(&wai(&["wrap", "wai.generate.spectral", p(&object), "--intent", "create", "-o", p(&env)])); +} + /// Every score of the corpus: `build` from its authoring form writes the committed /// `WSCR` byte for byte, and `render` prints, and `--pcm` writes, the digests /// `expected_hashes.json` states. -- 2.54.0 (Apple Git-157) From 7e3b8830741ee8498ddfab940c68d74ceb845164 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 17:08:26 -0400 Subject: [PATCH 09/11] wai generate.spectral: the model is the parameter set, so the envelope carries no pin; inspect and unwrap report the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Form v0 names the model inside the payload by the SHA-256 of one file, and SPEC §5 never said how that relates to §3.1's parameter-set pin, which governs every GenerativeDeterministic capability: whether a generate envelope carries a pin, or which wins when a pin and `model` disagree. The library registers the set as several files, so a form-less pin over the very file `model` names would be refused form_required. SPEC §5 now states the relation: the model file is the parameter set and `model` its identity; an envelope that names the generator carries no `prior` and a sink refuses one that does, whatever it names. §3.1 says so beside the pin rules, the non-conforming list names it, and prior-carriage §7's row says the set is never pinned and names the file. No library source changes. `wai inspect` and `wai unwrap` reported a generate envelope with a corrupt payload as conforming ("component roles ✓", no payload line). They now report the payload (form v0, with the reader's reason), the intent and any pin of every envelope or WAI2 component that names the generator, ✓ or ✗; inspect's verdict says the envelope does not conform. inspect_and_unwrap_report_the_generators_envelope covers a prompt byte changed in place, the intent improve, a pin over the model file itself, and a WAI2 component with and without a pin. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 6 ++- wai/SPEC.md | 20 +++++++-- wai/extensions/prior-carriage.md | 2 +- wai/wai-rs/src/bin/wai.rs | 62 +++++++++++++++++++++++++- wai/wai-rs/tests/cli_generate_score.rs | 60 +++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 7 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index cb07ed9fd..ad60a0048 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -15,7 +15,11 @@ wire format may change and such changes are recorded here. serialization per object, so an envelope's content hash names the instructions; a sink makes only listed sizes, with that model only, and checks each output by its SHA-256, never by a 64-bit FNV-1a alone. The intent of an envelope that names the - generator is `create`, and an envelope with another intent is non-conforming. Vector: + generator is `create`, and an envelope with another intent is non-conforming. The + model is the generator's parameter set (§3.1) and `model` its identity, so an + envelope that names the generator carries no `prior` pin, and a sink refuses one that + does (§3.1 says so beside the pin rules). `wai inspect` and `wai unwrap` report the + payload, the intent and any pin of such an envelope, ✓ or ✗ with the reason. Vector: `wai-rs/tests/vectors/generate/instr-v0.json`, the object, the SHA-256 at each of its four listed sizes, twelve objects at the inclusive bounds a sink accepts (each with the SHA-256 of its first listed size), and 36 objects a sink refuses, each with diff --git a/wai/SPEC.md b/wai/SPEC.md index a555fc73a..81c57b154 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -178,7 +178,10 @@ tables, normative constants. The sink supplies it; the envelope never carries it **several files**; a capability registered with neither, and a capability this revision does not register, is treated as several files. -A manifest names the parameter set a payload was coded against with a **pin**: +A manifest names the parameter set a payload was coded against with a **pin**. One +capability is the exception: the payload of `wai.generate.spectral` names its own +parameter set, the model file, by SHA-256, and an envelope that names it carries no pin +(§5, *Payload of `wai.generate.spectral`*). ```json "prior": { @@ -803,6 +806,16 @@ MUST refuse a payload that is not exactly that serialization of such an object added, missing, repeated or out of order, whitespace, an escape, a value out of range — and make nothing from it. +The model file is the generator's parameter set (§3.1), and `model` is its identity: +the object names it, so the envelope does not. An envelope that names +`wai.generate.spectral`, as a `WAI1`'s `capability` or `fallback` or as a `WAI2` +component, carries no `prior` pin, not even one over the file `model` names; a sink MUST +refuse one that does, whatever the pin names, and make nothing from it. A sink checks +the model file it holds against `model` alone. (This revision registers no one-file +shape for this parameter set, so §3.1 would refuse a form-less pin over it with +`form_required` in any case, and a pin in the form `wai.prior.set` would name the same +file twice.) + A sink makes the picture at a listed size `[w, h]` with the reference generator, `generate(model, prompt, seed, steps, h, w)`: `h·w·3` bytes of `RGB8` as [reconstruction-binding](extensions/reconstruction-binding.md) §2 defines it. It MUST @@ -826,8 +839,9 @@ objects a sink MUST refuse, each with the rule the reference reader finds broken *Envelopes this makes non-conforming* (they still parse): a `WAI1` whose `capability` or `fallback` is `wai.generate.spectral` and whose payload is not an instruction object -of form v0 or whose `intent` is not `create`, and a `WAI2` with a component of that -string whose bytes are not one, or whose `intent` is not `create`. +of form v0, whose `intent` is not `create` or which carries a `prior`, and a `WAI2` +with a component of that string whose bytes are not one, which carries a `prior`, or +in an envelope whose `intent` is not `create`. ### Immersive audio, cinema, volumetric, haptics, HDR (registered, sink-supplied) diff --git a/wai/extensions/prior-carriage.md b/wai/extensions/prior-carriage.md index abe3d98d8..426795e20 100644 --- a/wai/extensions/prior-carriage.md +++ b/wai/extensions/prior-carriage.md @@ -325,7 +325,7 @@ file the set lacks is refused with `set_incomplete`. | `wai.neural.glc` | several | `hyper_cdfs_q.json`, `hyper_entropy.onnx`, `context_entropy.onnx`, `synthesis.onnx` | | `wai.neural.dcvc_rt`, `.dcvc_fm` | several | `hyper_cdfs_q.json`, `hyper_synthesis.onnx`, `gauss_cdfs_q.json`, `temporal_decoder.onnx` | | `wai.image.jpegai` | several | exact path: `norm.json`, `weights/{hs,hyp,synth}_{y,uv}.json`, `weights/mcm{0..3}_y.json`, `weights/icci.json`. Float path: `hyper_scale_decoder_int_weights.json`, `model_constants.json`, `jpegai_normative_tables.json`, `me-tANS/bounds.csv`, `jpegai_factorized_cdfs.json`, `jpegai_scaler_vec.json`, `jpegai_dequant_consts.json`, `model/CCS_SGMM/tools_0/model_{y,uv}/common_modules/hyper_decoder.onnx`, `model/CCS_SGMM/tools_0/model_y/common_modules/MCM/stage{0..3}.onnx`, `model16/CCS_SGMM/tools_0/model_{y,uv}/synthesis16.onnx`, `e2e/pfchain/icci_{y_m6,uv_m3}.onnx`. A set holding only the exact path's files decodes through that path; where that path refuses (a non-default floating-point environment), the float path needs files the set lacks, and the decode is refused with `set_incomplete` instead of running on unverified files. | -| `wai.generate.spectral` | several, until an envelope decode path registers its files | — (no envelope decode path in the reference sink) | +| `wai.generate.spectral` | several (no one-file shape registered); never pinned: its payload names its one model file by SHA-256, and its envelope carries no pin (SPEC §5, *Payload of `wai.generate.spectral`*) | the model file the payload's `model` names (`generate_spectral_model.json` in the conformance vector) | ## 8. Sources diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index a662627fc..ee67dc9a2 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -407,13 +407,21 @@ fn inspect(path: &str) -> Result<(), String> { None if fb.is_some() => "INERT here \u{2014} neither capability usable", None => "INERT here \u{2014} capability missing, no fallback declared", }; - let conforms = if roles.is_ok() || wai::codecs::is_companion_only(cap) { "" } - else { " (the envelope does not conform; see component roles)" }; + let generated = generate_findings(&[Some(cap), fb].into_iter().flatten().collect::>(), + w.intent(), w.prior().is_some(), &w.payload); + let conforms = if wai::codecs::is_companion_only(cap) { "" } + else if roles.is_err() { " (the envelope does not conform; see component roles)" } + else if generated.as_ref().is_some_and(|f| !f.is_empty()) { + " (the envelope does not conform; see instruction)" } + else { "" }; println!(" verdict: {verdict}{conforms}"); match roles { Ok(()) => println!(" component roles: \u{2713} conform (SPEC \u{a7}5)"), Err(e) => println!(" component roles: \u{2717} {e}"), } + for line in generated.as_deref().map(generate_lines).unwrap_or_default() { + println!(" instruction: {line}"); + } if let Err(e) = w.check_candidates() { println!(" candidates: \u{2717} {e}"); } @@ -459,6 +467,12 @@ fn inspect(path: &str) -> Result<(), String> { println!(" pin {}", wai::pin::describe_pin(p)); } } + for (i, (meta, payload)) in m.manifest.renditions.iter().zip(m.renditions.iter()).enumerate() { + let found = generate_findings(&[meta.capability.as_str()], m.intent(), meta.prior.is_some(), payload); + for line in found.as_deref().map(generate_lines).unwrap_or_default() { + println!(" [{i}] instruction: {line}"); + } + } println!(); // Companions (and companion-only capabilities) are applied with the chosen // rendition, never picked instead of it (SPEC §7.1). inspect holds no @@ -645,6 +659,41 @@ fn check_generate_payload(cap: &str, fallback: Option<&str>, intent: &str, paylo Ok(()) } +/// SPEC §5, *Payload of `wai.generate.spectral`*: what makes an envelope that names the +/// generator non-conforming beyond the role rules — a payload that is not an instruction +/// object of form v0, an intent other than `create`, and a pin (the object names its +/// model by SHA-256, and an envelope that names the generator carries no `prior`). +/// `None` when no capability in `caps` is the generator; otherwise every finding, none +/// when the envelope conforms on these counts. `inspect` and `unwrap` print them, in +/// every build: reading the object needs no feature. +fn generate_findings(caps: &[&str], intent: &str, pinned: bool, payload: &[u8]) -> Option> { + let g = wai::codecs::CAP_GENERATE_SPECTRAL; + if !caps.contains(&g) { + return None; + } + let mut found = Vec::new(); + if let Err(e) = instr::Instruction::parse(payload) { + found.push(format!("the payload is not an instruction object of form v0: {e}")); + } + if intent != "create" { + found.push(format!("the intent is `{intent}`, not `create`")); + } + if pinned { + found.push("it carries a pin (`prior`): the object names its model, so an envelope that names \ + the generator carries none".into()); + } + Some(found) +} + +/// One line per finding of [`generate_findings`], or one line saying the envelope holds. +fn generate_lines(found: &[String]) -> Vec { + if found.is_empty() { + vec!["\u{2713} an instruction object of form v0, intent create, no pin (SPEC \u{a7}5)".into()] + } else { + found.iter().map(|f| format!("\u{2717} {f}; a sink refuses it (SPEC \u{a7}5)")).collect() + } +} + /// The `WAI2` half of [`wrap`]: the base payload as the one selectable /// rendition, each companion bound to it. fn wrap_with_companions(cap: &str, payload: Vec, out: &str, fallback: Option<&str>, @@ -732,6 +781,11 @@ fn unwrap(input: &str, out: Option<&str>, component: Option<&str>) -> Result<(), if let Err(e) = w.check_candidates() { println!(" conformance \u{2717} {e}"); } + let caps: Vec<&str> = [Some(cap.as_str()), w.fallback()].into_iter().flatten().collect(); + for line in generate_findings(&caps, w.intent(), w.prior().is_some(), &w.payload).as_deref() + .map(generate_lines).unwrap_or_default() { + println!(" conformance {line}"); + } println!(" payload {} bytes", w.payload.len()); if let Some(out) = out { std::fs::write(out, &w.payload).map_err(|e| format!("write {out}: {e}"))?; @@ -762,6 +816,10 @@ fn unwrap_component(input: &str, bytes: &[u8], out: Option<&str>, if let Err(e) = m.check_candidates() { println!(" conformance \u{2717} {e}"); } + for line in generate_findings(&[meta.capability.as_str()], m.intent(), meta.prior.is_some(), payload).as_deref() + .map(generate_lines).unwrap_or_default() { + println!(" conformance [{idx}] {line}"); + } println!(" component [{idx}] {} · {} bytes", meta.capability, payload.len()); if let Some(out) = out { std::fs::write(out, payload).map_err(|e| format!("write {out}: {e}"))?; diff --git a/wai/wai-rs/tests/cli_generate_score.rs b/wai/wai-rs/tests/cli_generate_score.rs index 6c493c392..97c4909d4 100644 --- a/wai/wai-rs/tests/cli_generate_score.rs +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -291,6 +291,66 @@ fn wrap_writes_the_generator_only_with_intent_create() { ok(&wai(&["wrap", "wai.generate.spectral", p(&object), "--intent", "create", "-o", p(&env)])); } +/// SPEC §5: `wai inspect` and `wai unwrap` report an envelope that names the generator — +/// its payload, its intent and any pin — ✓ when it conforms, ✗ with the reason when it +/// does not. Each bad `WAI1` differs from a good one in one respect: one prompt byte +/// changed to 0x01 in place (the envelope still parses; the payload is no longer JSON, +/// since a string holds no raw control character), the intent `improve`, or a pin +/// over the very model file `model` names. A `WAI2` component of the generator is +/// reported the same way, with and without a pin. +#[test] +fn inspect_and_unwrap_report_the_generators_envelope() { + use wai::container::{ManifestV2, Prior, RenditionMeta, WaiMulti}; + let v = jload(&gen_dir().join("instr-v0.json")); + let object = v["object"].as_str().unwrap(); + let input = scratch("report.instr"); + std::fs::write(&input, object).unwrap(); + let good = scratch("report-good.wai"); + ok(&wai(&["wrap", "wai.generate.spectral", p(&input), "--intent", "create", "-o", p(&good)])); + let report = |env: &Path| (ok(&wai(&["unwrap", p(env)])), ok(&wai(&["inspect", p(env)]))); + let (u, i) = report(&good); + assert!(u.contains("conformance \u{2713} an instruction object of form v0, intent create, no pin"), "{u}"); + assert!(i.contains("instruction: \u{2713}") && !i.contains("does not conform"), "{i}"); + + let mut bytes = std::fs::read(&good).unwrap(); + let at = bytes.windows(4).position(|w| w == b"calm").unwrap(); + bytes[at] = 0x01; + let bad_payload = scratch("report-payload.wai"); + std::fs::write(&bad_payload, &bytes).unwrap(); + let mut e = Wai::read(&good).unwrap(); + e.manifest.intent = "improve".into(); + let bad_intent = scratch("report-intent.wai"); + e.write(&bad_intent).unwrap(); + let model_sha = sha256_hex(&std::fs::read(model()).unwrap()); + let pin = Prior::new("the spectral model", model_sha, "integer generator"); + let mut e = Wai::read(&good).unwrap(); + e.manifest.conditioning.prior = Some(pin.clone()); + let bad_pin = scratch("report-pin.wai"); + e.write(&bad_pin).unwrap(); + for (env, said) in [(&bad_payload, "not_json"), (&bad_intent, "the intent is `improve`"), (&bad_pin, "carries a pin")] { + let (u, i) = report(env); + assert!(u.contains("conformance \u{2717}") && u.contains(said), "{said}: {u}"); + assert!(i.contains("instruction: \u{2717}") && i.contains(said) && i.contains("does not conform"), + "{said}: {i}"); + } + + for (pinned, name) in [(false, "report-two.wai"), (true, "report-two-pin.wai")] { + let mut meta = RenditionMeta::new("wai.generate.spectral", "bytes"); + if pinned { + meta = meta.with_prior(pin.clone()); + } + let two = WaiMulti::from_parts(ManifestV2::new("image", "create", vec![meta]), vec![object.as_bytes().to_vec()]).unwrap(); + let path = scratch(name); + two.write(&path).unwrap(); + let u = ok(&wai(&["unwrap", p(&path), "--component", "0"])); + let i = ok(&wai(&["inspect", p(&path)])); + let mark = if pinned { "\u{2717}" } else { "\u{2713}" }; + assert!(u.contains(&format!("conformance [0] {mark}")), "{name}: {u}"); + assert!(i.contains(&format!("[0] instruction: {mark}")), "{name}: {i}"); + assert_eq!(pinned, u.contains("carries a pin") && i.contains("carries a pin"), "{name}"); + } +} + /// Every score of the corpus: `build` from its authoring form writes the committed /// `WSCR` byte for byte, and `render` prints, and `--pcm` writes, the digests /// `expected_hashes.json` states. -- 2.54.0 (Apple Git-157) From cdc351b33c81736cfc40ac80bd06de585cab782b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 17:11:00 -0400 Subject: [PATCH 10/11] wai REGISTRY: the status note names wai.generate.spectral among the registrations v0.4.0 completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written Status note listed the integer codecs whose registrations v0.4.0 completes and left out wai.generate.spectral, which SPEC §8 now says §5 completes. The note sits outside the generated region the library test compares. Co-Authored-By: Claude Opus 5.5 --- wai/REGISTRY.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index 987b33608..77ca4a0c8 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -73,7 +73,10 @@ in the section it names. `wai.splat.int_codec` were registered with a decoder and no byte layout (the first, the fourth and the fifth also with the tool that wrote their bitstreams); v0.4.0 completes their registrations by pinning their formats (SPEC §8, *Completing a - registration*; SPEC §5, *Payloads of the WAI-native integer codecs*). + registration*; SPEC §5, *Payloads of the WAI-native integer codecs*). It completes + `wai.generate.spectral`'s the same way: registered with its generator, `generate.rs`, + and no byte layout, its payload is now the instruction object of form v0 (SPEC §5, + *Payload of `wai.generate.spectral`*). - **candidate** — the string is registered but no revision has pinned its payload format. An encoder MUST NOT emit a candidate, and a sink MUST treat it as unsupported (SPEC §5, *Candidate capabilities*). The reference implementation -- 2.54.0 (Apple Git-157) From 4c09bea76aa35a2c8c08b5c83515f7e14440a1a9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 17:17:06 -0400 Subject: [PATCH 11/11] wai-web wasm32 CI: the browser generator and score synth make every native pin The CHANGELOG said every pin equals the shared browser binding's output, and the browser sink's form-v0 reading has no CI of its own, yet wai-web-wasm.yml ran no generate or score check. demo/verify_generate_wasm.mjs runs generateSpectral at every size the generate vectors pin (sizes.json, instr-v0.json's four listed sizes, and the first listed size of each object at a bound of form v0), checks each object names the committed model, and runs ScoreSink over every score-conformance vector for its pcm_sha256, mixdown hash and score hash. The workflow runs it on the node build and now also triggers on wai/score-conformance/. 35 checks pass locally on a wasm32 build of this tree. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-web-wasm.yml | 16 +++++ wai/CHANGELOG.md | 6 +- wai/wai-web/demo/verify_generate_wasm.mjs | 83 +++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 wai/wai-web/demo/verify_generate_wasm.mjs diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 3f7c8ee7e..156444c7d 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -6,6 +6,11 @@ name: wai-web wasm32 # - demo/verify_int_synth_wasm.mjs against the committed conformance corpus # wai/neural-conformance/ (not the demo's staged copies): the wasm32 # int_synth decode must hash to the corpus's frozen native hashes; +# - demo/verify_generate_wasm.mjs against wai/wai-rs/tests/vectors/generate/ and +# wai/score-conformance/: generateSpectral at every pinned size (sizes.json, +# instr-v0.json's object and every object it accepts) and ScoreSink over every +# score vector must give the SHA-256 the native `wai generate` and `wai score +# render` wrote, so a page checking the browser's output against those pins can; # - demo/verify_menu.mjs: every capability the browser sink advertises is # registered in wai/registry/capabilities.json as a payload or companion # capability, none is a candidate (SPEC §5), and the wasm build's own @@ -60,6 +65,7 @@ on: - 'wai/staged-measure-conformance/**' - 'wai/int-payload-conformance/**' - 'wai/claim-conformance/**' + - 'wai/score-conformance/**' - 'wai/registry/**' - '.github/workflows/wai-web-wasm.yml' pull_request: @@ -72,6 +78,7 @@ on: - 'wai/staged-measure-conformance/**' - 'wai/int-payload-conformance/**' - 'wai/claim-conformance/**' + - 'wai/score-conformance/**' - 'wai/registry/**' - '.github/workflows/wai-web-wasm.yml' @@ -150,6 +157,15 @@ jobs: shell: bash working-directory: wai/wai-web run: node demo/verify_int_payload_wasm.mjs ./pkg-node/wai_web.js ../wai-rs/tests/vectors + # The generator and the score synth in wasm32 against the native pins: every size + # sizes.json and instr-v0.json pin (the object's four listed sizes, and the first + # listed size of each object at a bound of form v0), and every score vector's + # pcm_sha256, mixdown hash and score hash. Pages that make these pieces in the + # browser hold their output to such pins, made natively. + - name: wasm32 generator and score synth equal the native pins + shell: bash + working-directory: wai/wai-web + run: node demo/verify_generate_wasm.mjs ./pkg-node/wai_web.js ../wai-rs/tests/vectors/generate ../score-conformance # Every staged-delivery case replayed through the wasm32 StageSink: each integer # base decoded in wasm32 to its stage0.bin with its pin, and every report, final # unit, stage hash and clip hash equal to what the native sink wrote in case.json. diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index ad60a0048..e0c22d823 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -27,8 +27,10 @@ wire format may change and such changes are recorded here. - **`wai generate`** (feature `generate`) makes a picture with the reference generator at any size form v0 allows and prints its SHA-256; `--instr` makes a listed size of an object, read as a sink reads it, and `--write-instr` writes one. `sizes.json` pins - the committed prompt at 256×128 and 640×320; every pin equals the shared browser - binding's output. `wai wrap` refuses a `wai.generate.spectral` payload that is not a + the committed prompt at 256×128 and 640×320. The wasm32 workflow checks that the + browser binding makes every pinned picture and score with the SHA-256 the native + tool wrote (`wai-web/demo/verify_generate_wasm.mjs`), so a page can hold its own + output to these pins. `wai wrap` refuses a `wai.generate.spectral` payload that is not a form-v0 object, and any intent but `create` over that generator, in every build. - **`wai score build|render`** (feature `score`). `build` writes a `WSCR` from integer note fields only (score §2.3); `render` prints `samples`, `pcm_sha256`, diff --git a/wai/wai-web/demo/verify_generate_wasm.mjs b/wai/wai-web/demo/verify_generate_wasm.mjs new file mode 100644 index 000000000..5151c0c9b --- /dev/null +++ b/wai/wai-web/demo/verify_generate_wasm.mjs @@ -0,0 +1,83 @@ +// The wasm32 generator and score synth equal the native pins. A page that makes a +// generated picture or a score's samples in the browser checks every byte it made +// against the SHA-256 the native reference wrote, so the binding it loads must make +// exactly those bytes: +// +// - generateSpectral at every size tests/vectors/generate/ pins — sizes.json's two +// sizes, the four listed sizes of instr-v0.json's object, and the first listed size +// of every object instr-v0.json lists as accepted (one at each bound of form v0) — +// gives the SHA-256 the vector states, made natively by `wai generate`; each +// object names the committed model by its SHA-256, checked here; +// - ScoreSink over every score-conformance vector gives the SHA-256 of its samples +// (pcm_sha256: the i16 mixdown, little-endian, SPEC §7 Digests), its mixdown hash +// and its score hash as expected_hashes.json states them, and the contract's +// duration in samples. +// +// node verify_generate_wasm.mjs [path/to/wai_web.js] [path/to/wai-rs/tests/vectors/generate] [path/to/score-conformance] +// +// Exit 0 iff every check passes. + +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkg = resolve(here, process.argv[2] ?? './pkg-node/wai_web.js'); +const gen = resolve(process.argv[3] ?? resolve(here, '../../wai-rs/tests/vectors/generate')); +const scores = resolve(process.argv[4] ?? resolve(here, '../../score-conformance')); +const wai = await import(pathToFileURL(pkg).href); + +const sha = (b) => createHash('sha256').update(b).digest('hex'); +const json = (p) => JSON.parse(readFileSync(p, 'utf8')); +const model = new Uint8Array(readFileSync(`${gen}/generate_spectral_model.json`)); +const modelSha = sha(model); + +const checks = []; +const picture = (name, o, w, h, want) => checks.push([`${name} ${w}x${h}`, () => { + const img = wai.generateSpectral(model, o.prompt, o.seed, o.steps, w, h); + const rgb = img.rgb; + img.free(); + return rgb.length === w * h * 3 ? sha(rgb) : `${rgb.length} bytes`; +}, want]); +const named = (name, o) => checks.push([`${name}: names the committed model`, () => o.model, `sha256:${modelSha}`]); + +const sizes = json(`${gen}/sizes.json`); +checks.push(['sizes.json: the committed model', () => sizes.model_sha256, modelSha]); +for (const s of sizes.sizes) picture('sizes.json', sizes, s.w, s.h, s.sha256); + +const v = json(`${gen}/instr-v0.json`); +const object = JSON.parse(v.object); +named('instr-v0.json', object); +for (const e of v.outputs) picture('instr-v0.json', object, e.w, e.h, e.sha256); +if (!(v.accepted?.length >= 12)) checks.push(['instr-v0.json lists an accepted object at each bound', () => v.accepted?.length ?? 0, '>= 12']); +for (const c of v.accepted ?? []) { + const o = JSON.parse(c.object); + named(`accepted (${c.why})`, o); + for (const e of c.outputs) picture(`accepted (${c.why})`, o, e.w, e.h, e.sha256); +} + +const expected = json(`${scores}/expected_hashes.json`); +for (const [file, want] of Object.entries(expected)) { + checks.push([`score ${file}`, () => { + const s = new wai.ScoreSink(new Uint8Array(readFileSync(`${scores}/vectors/${file}`))); + const pcm = s.mixdown(); + const le = new Uint8Array(2 * pcm.length); + const dv = new DataView(le.buffer); + pcm.forEach((x, i) => dv.setInt16(2 * i, x, true)); + const got = [pcm.length, sha(le), s.mixdown_hash_hex(), s.score_hash_hex()].join(' '); + s.free(); + return got; + }, [want.duration, want.pcm_sha256, want.mixdown_hash, want.score_hash].join(' ')]); +} + +let pass = true; +for (const [name, run, want] of checks) { + let got; + try { got = run(); } catch (e) { got = `threw: ${e?.message ?? e}`; } + const ok = got === want; + pass = pass && ok; + console.log(`${ok ? 'PASS' : 'FAIL'} ${name.padEnd(64)} ${String(got).slice(0, 16)}${ok ? '' : ` want ${want}`}`); +} +console.log(`${checks.length} checks`); +process.exit(pass ? 0 : 1); -- 2.54.0 (Apple Git-157)