diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 95ed53aa3..693f2c9a5 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -239,13 +239,21 @@ 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 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,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/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index d9a3ae81d..0df22bd86 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 @@ -62,6 +67,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: @@ -76,6 +82,7 @@ on: - 'wai/staged-measure-conformance/**' - 'wai/int-payload-conformance/**' - 'wai/claim-conformance/**' + - 'wai/score-conformance/**' - 'wai/registry/**' - '.github/workflows/wai-web-wasm.yml' @@ -159,6 +166,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 30165cf9d..f5ea7ee7d 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,42 @@ 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 intent of an envelope that names the + 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 + 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 + 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`, + `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. + ### Quantum circuits: `wai.quantum.circuit2`, and `wai.quantum.circuit` stated exactly - **A new capability, `wai.quantum.circuit2`** ([quantum-sim](extensions/quantum-sim.md), diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index 13509daef..6bdc0011a 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -74,7 +74,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 diff --git a/wai/SPEC.md b/wai/SPEC.md index fb69a7615..8ae19fb7c 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": { @@ -605,7 +608,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` | @@ -796,6 +799,70 @@ 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. + +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 +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 `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 +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. + +*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, 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) The same "name the capability, the sink supplies the decoder" pattern extends @@ -2083,7 +2150,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/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/extensions/score.md b/wai/extensions/score.md index 660875bb5..211b70689 100644 --- a/wai/extensions/score.md +++ b/wai/extensions/score.md @@ -60,6 +60,30 @@ 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, 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 +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 +106,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. 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 935114d98..61189574c 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -515,7 +515,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" @@ -778,6 +779,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..ee67dc9a2 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,25 @@ 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. +#[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))] +#[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 +75,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"); @@ -378,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}"); } @@ -430,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 @@ -524,7 +567,11 @@ 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`*), 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> { if !wai::codecs::is_known_capability(cap) { @@ -551,6 +598,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, 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 @@ -563,6 +611,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, intent, &payload, out)?; let n_in = payload.len(); let requirement = match fallback { Some(fb) => ModelRequirement::new(cap).with_fallback(fb), @@ -590,6 +639,61 @@ 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, 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}"))?; + } + 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>, @@ -677,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}"))?; @@ -707,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}"))?; @@ -836,6 +949,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..02c434302 --- /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().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)); + 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().map_err(|e| e.to_string())?; + (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..acab196d4 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/instr.rs @@ -0,0 +1,210 @@ +//! 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, `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 (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 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. +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; + +/// 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 { + /// 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<(), Refusal> { + if prompt.len() > MAX_PROMPT { + 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(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 + /// [`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<(), Refusal> { + if !is_hex64(&self.model_sha256) { + 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(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(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(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`], + /// 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"))); + } + 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_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_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_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(no("not_canonical", "not in canonical form: members in ascending byte order, no whitespace, \ + no escape, nothing after the object")); + } + Ok(i) + } + + /// Whether `(w, h)` is a listed size. + pub fn lists(&self, w: u32, h: u32) -> bool { + self.sizes.contains(&(w, h)) + } +} + +/// `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).map_err(|e| e.to_string())?; + 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..9f2d4237e --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/score.rs @@ -0,0 +1,249 @@ +//! `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. 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 = 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")?; + 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 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)); + } + 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_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/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..97c4909d4 --- /dev/null +++ b/wai/wai-rs/tests/cli_generate_score.rs @@ -0,0 +1,487 @@ +//! `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")); + // 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() { + 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`, 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() >= 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(&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") && 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"); + 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()); +} + +/// 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")); + 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}"); + } +} + +/// 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)])); +} + +/// 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. +#[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(¬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"), + ("[]".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}"); + } +} + +/// `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 +/// 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(&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"); + 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..f891df790 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,165 @@ 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); +} + +/// 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 new file mode 100644 index 000000000..4c9b73de7 --- /dev/null +++ b/wai/wai-rs/tests/vectors/generate/instr-v0.json @@ -0,0 +1,362 @@ +{ + "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" + } + ], + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "reason": "steps_range" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[],\"steps\":4}", + "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", + "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", + "reason": "sizes_order" + }, + { + "object": "{\"model\":\"sha256:963ee634db1a964d14004618b139fd7e32598f627c023faf9526b14611504f9e\",\"prompt\":\"a calm spectral horizon\",\"seed\":7,\"sizes\":[[0,128]],\"steps\":4}", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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:`", + "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", + "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", + "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", + "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", + "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", + "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" +} 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" + } + ] +} 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);