From 91936a474701774e92f1a46506ae959dd8fed38a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 10:26:09 -0400 Subject: [PATCH 01/34] wai-rs: hoist the JWP object content hash into jwp_hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jwp-receipts §2.1's content hash, BLAKE3 over "moq-jwp:object\x01" and the envelope, moves out of claim_receipt into its own module, jwp_hash, gated on a new feature `jwp_hash = ["dep:blake3"]` that `receipt_claims` enables. claim_receipt re-exports it under its old names (object_content_hash, DOMAIN_OBJECT), so its public API and every caller stay valid; jwp_delivery, key_log and their corpora and tests call jwp_hash::content_hash directly. Behaviour does not change. A known-answer test pins the hash of the claim corpus's canonical fixture object to the value its independent verifier computes. Regenerating the jwp, claim and signer corpora into empty directories gives files byte-identical to the committed ones; every corpus verifies, as do jwp_verify.py, claim_verify.py and signer_verify.py; the corpora and include/wai.h have no diff after a build. joule-moq keeps its own copy of the domain and the Python verifiers keep their constants; they are separate implementations and are not touched. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/Cargo.toml | 5 +++- wai/wai-rs/src/claim_receipt.rs | 13 +++------- wai/wai-rs/src/energy_figure/tests.rs | 4 ++-- wai/wai-rs/src/jwp_delivery.rs | 4 ++-- wai/wai-rs/src/jwp_delivery/corpus.rs | 2 +- wai/wai-rs/src/jwp_delivery/tests.rs | 10 ++++---- wai/wai-rs/src/jwp_hash.rs | 34 +++++++++++++++++++++++++++ wai/wai-rs/src/key_log/corpus.rs | 6 ++--- wai/wai-rs/src/key_log/tests.rs | 4 ++-- wai/wai-rs/src/lib.rs | 5 ++++ 10 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 wai/wai-rs/src/jwp_hash.rs diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 3cd82e8cf..a23244d34 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -176,13 +176,16 @@ provenance = ["world"] # content-address and chain it, and sign it. Conformance is reason-set equality with # the reference verifier and the portable C kernel over oer-conformance/. oer = ["dep:blake3", "dep:ed25519-dalek", "dep:num-bigint", "serde_json/float_roundtrip"] +# jwp-receipts §2.1's object content hash (src/jwp_hash.rs), on its own so any +# receipt family that names an object can compute it with BLAKE3 alone. +jwp_hash = ["dep:blake3"] # Typed receipt classes on the JWP chain (extensions/jwp-receipts.md §2.4.3–§2.4.7, # §2.6, §3.1–§3.2): `claim` receipts of class energy / lawful-basis / log-inclusion, # the soft-binding class, and recovery under the ancestor rule. Needs `provenance` # for the coverage vocabulary and `oer` for the evidential grades and the OER receipt # an energy claim may name, which it reads and verifies through `crate::oer`. Its one # direct dependency, curve25519-dalek, is already in the graph under ed25519-dalek. -receipt_claims = ["provenance", "oer", "dep:curve25519-dalek"] +receipt_claims = ["provenance", "oer", "jwp_hash", "dep:curve25519-dalek"] # Key discovery and signing authority (extensions/jwp-receipts.md §7): the origin key # log a `did:web` signer_id names, anchoring, the confirmation reported beside a receipt's # or claim's verdict, and the authorisation and revocation of decoder and reconstruct diff --git a/wai/wai-rs/src/claim_receipt.rs b/wai/wai-rs/src/claim_receipt.rs index d4c414f40..54ad62508 100644 --- a/wai/wai-rs/src/claim_receipt.rs +++ b/wai/wai-rs/src/claim_receipt.rs @@ -113,22 +113,15 @@ use std::fmt::Write as _; pub const DOMAIN_CLAIM: &[u8] = b"moq-jwp:claim\x01"; /// Domain of a claim receipt's link hash (its identity for `parent_receipt_hash`). pub const DOMAIN_CLAIM_LINK: &[u8] = b"moq-jwp:claim-link\x01"; -/// jwp-receipts §2.1: the per-object content hash domain. -pub const DOMAIN_OBJECT: &[u8] = b"moq-jwp:object\x01"; +// jwp-receipts §2.1: the per-object content hash and its domain, kept under their +// names here so every existing caller of this module's API stays valid. +pub use crate::jwp_hash::{content_hash as object_content_hash, DOMAIN_OBJECT}; /// The largest integer a claim may carry: I-JSON's (RFC 7493) exact-integer bound, /// which is what keeps RFC 8785 number serialisation trivial. Crate-private so the /// C header (cbindgen exports public integer constants) does not gain a generic name. pub(crate) const MAX_INTEGER: u64 = (1 << 53) - 1; -/// jwp-receipts §2.1 content hash of a WAI envelope — the hard binding. -pub fn object_content_hash(envelope_bytes: &[u8]) -> [u8; 32] { - let mut h = blake3::Hasher::new(); - h.update(DOMAIN_OBJECT); - h.update(envelope_bytes); - *h.finalize().as_bytes() -} - // ───────────────────────────── reasons ───────────────────────────── /// Why a claim, or a recovery, was rejected. The label is the conformance name. diff --git a/wai/wai-rs/src/energy_figure/tests.rs b/wai/wai-rs/src/energy_figure/tests.rs index 0c2f2b2a2..673805fec 100644 --- a/wai/wai-rs/src/energy_figure/tests.rs +++ b/wai/wai-rs/src/energy_figure/tests.rs @@ -96,8 +96,8 @@ fn a_delivered_object_is_viewed_at_its_index_within_its_group() { use crate::jwp_delivery::{GroupBuilder, ObjectLabel}; let env = |i: u8| vec![i; 9]; let mut b = GroupBuilder::default(); - b.add(crate::claim_receipt::object_content_hash(&env(0)), 300, "o", Some(ObjectLabel::Class(chip(20_000)))); - b.add(crate::claim_receipt::object_content_hash(&env(1)), 200, "o", None); + b.add(crate::jwp_hash::content_hash(&env(0)), 300, "o", Some(ObjectLabel::Class(chip(20_000)))); + b.add(crate::jwp_hash::content_hash(&env(1)), 200, "o", None); let (g, objs) = b.finish(&key(0x11), "p", 1, 2, "2026-09-30T12:00:00Z", "2026-09-30T12:00:01Z", None, false).unwrap(); let v = DeliveredObject { object: &objs[1], group: &g, envelope: &env(1) }.figure_view().unwrap(); assert_eq!(v.profile, "moq-jwp:object"); diff --git a/wai/wai-rs/src/jwp_delivery.rs b/wai/wai-rs/src/jwp_delivery.rs index 51ecd8064..f71858b7e 100644 --- a/wai/wai-rs/src/jwp_delivery.rs +++ b/wai/wai-rs/src/jwp_delivery.rs @@ -43,7 +43,7 @@ //! This crate seals group receipts only to generate its conformance corpus; //! [`GroupBuilder`] is not a publishing path. -use crate::claim_receipt::{object_content_hash, verify_ed25519_strict, ClaimBody, ClaimReceipt, Reason as ClaimReason}; +use crate::claim_receipt::{verify_ed25519_strict, ClaimBody, ClaimReceipt, Reason as ClaimReason}; use crate::energy_binding::Coverage; use crate::energy_class::{ check_label, json_fields, label_from_json, AcquisitionError, AcquisitionKind, CounterFiltering, EnergyProvenance, @@ -663,7 +663,7 @@ impl ObjectReceipt { /// the class's evidence and the origin. pub fn accept(&self, group: &GroupReceipt, envelope: &[u8]) -> Result<(), DeliveryError> { group.verify()?; - if object_content_hash(envelope) != self.content_hash { + if crate::jwp_hash::content_hash(envelope) != self.content_hash { return Err(DeliveryError::at(4, Reason::ContentHashMismatch)); } self.verify_inclusion(group) diff --git a/wai/wai-rs/src/jwp_delivery/corpus.rs b/wai/wai-rs/src/jwp_delivery/corpus.rs index 915f401aa..3513f264b 100644 --- a/wai/wai-rs/src/jwp_delivery/corpus.rs +++ b/wai/wai-rs/src/jwp_delivery/corpus.rs @@ -63,7 +63,7 @@ fn build( let mut b = GroupBuilder::default(); for (i, (j, l)) in parts.iter().enumerate() { let n = first + i as u8; - b.add(object_content_hash(&envelope(n)), *j, &origin(n), l.clone()); + b.add(crate::jwp_hash::content_hash(&envelope(n)), *j, &origin(n), l.clone()); } b.finish(&key(0x11), PUBLISHER_ID, 7, group_id, T0, T1, parent, labelled).expect("the corpus's groups compose") } diff --git a/wai/wai-rs/src/jwp_delivery/tests.rs b/wai/wai-rs/src/jwp_delivery/tests.rs index 158b6bc6a..cf9874fb5 100644 --- a/wai/wai-rs/src/jwp_delivery/tests.rs +++ b/wai/wai-rs/src/jwp_delivery/tests.rs @@ -28,7 +28,7 @@ const T1: &str = "2026-09-30T12:00:01Z"; fn group(parts: &[(u64, Option)], labelled: bool) -> (GroupReceipt, Vec) { let mut b = GroupBuilder::default(); for (i, (j, l)) in parts.iter().enumerate() { - b.add(object_content_hash(&env(i as u8)), *j, "origin.example", l.clone()); + b.add(crate::jwp_hash::content_hash(&env(i as u8)), *j, "origin.example", l.clone()); } b.finish(&key(0x11), "did:example:publisher", 7, 3, T0, T1, None, labelled).expect("composes") } @@ -48,9 +48,9 @@ fn lab(p: EnergyProvenance) -> Option { #[test] fn the_legacy_leaf_and_tree_are_the_ones_every_profile_already_builds() { - let h = object_content_hash(b"one"); + let h = crate::jwp_hash::content_hash(b"one"); assert_eq!(root(&[leaf_legacy(&h)]), crate::world::receipt::merkle_root(&[h])); - let hs: Vec<[u8; 32]> = (0..5u8).map(|i| object_content_hash(&[i])).collect(); + let hs: Vec<[u8; 32]> = (0..5u8).map(|i| crate::jwp_hash::content_hash(&[i])).collect(); let leaves: Vec<[u8; 32]> = hs.iter().map(leaf_legacy).collect(); assert_eq!(root(&leaves), crate::world::receipt::merkle_root(&hs)); assert_eq!(root(&[]), [0u8; 32]); @@ -58,7 +58,7 @@ fn the_legacy_leaf_and_tree_are_the_ones_every_profile_already_builds() { #[test] fn the_labelled_leaf_binds_the_figure_the_class_and_the_origin() { - let h = object_content_hash(b"x"); + let h = crate::jwp_hash::content_hash(b"x"); let base = leaf_labelled(&h, 1_000, &ObjectLabel::Class(chip(20_000, 1_000_000)), "o"); assert_ne!(base, leaf_legacy(&h)); for other in [ @@ -429,7 +429,7 @@ fn a_non_canonical_key_encoding_is_refused() { fn the_parent_link() { let (g0, _) = group(&[(1, lab(EnergyProvenance::ModelBased))], true); let mut b = GroupBuilder::default(); - b.add(object_content_hash(&env(0)), 1, "o", lab(EnergyProvenance::ModelBased)); + b.add(crate::jwp_hash::content_hash(&env(0)), 1, "o", lab(EnergyProvenance::ModelBased)); let (g1, _) = b.finish(&key(0x11), "did:example:publisher", 7, 4, T0, T1, Some(g0.link_hash()), true).unwrap(); assert_eq!(g1.check_parent(Some(&g0)), Ok(())); assert_eq!(g1.check_parent(None), Err(DeliveryError::at(3, Reason::ParentNotHeld))); diff --git a/wai/wai-rs/src/jwp_hash.rs b/wai/wai-rs/src/jwp_hash.rs new file mode 100644 index 000000000..d20bc358c --- /dev/null +++ b/wai/wai-rs/src/jwp_hash.rs @@ -0,0 +1,34 @@ +//! jwp-receipts §2.1: the content hash of a delivered object. +//! +//! `content_hash = BLAKE3("moq-jwp:object\x01" ‖ envelope)`, over the envelope bytes +//! exactly as they travel. It is an object's identity in a JWP object receipt, the +//! subject of a claim about that object, and the leaf a labelled delivery receipt +//! commits to. This module holds it alone, gated on nothing but BLAKE3, so every +//! receipt family that names an object computes it in one place. + +/// jwp-receipts §2.1: the per-object content hash domain. +pub const DOMAIN_OBJECT: &[u8] = b"moq-jwp:object\x01"; + +/// jwp-receipts §2.1 content hash of a WAI envelope: BLAKE3 over [`DOMAIN_OBJECT`] +/// followed by the envelope bytes. +pub fn content_hash(envelope_bytes: &[u8]) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(DOMAIN_OBJECT); + h.update(envelope_bytes); + *h.finalize().as_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The known answer claim-conformance's independent verifier computes for its + /// canonical fixture object (`claim_verify.py`, BLAKE3 over the domain and the + /// object), so the hoisted function is the one the corpus froze. + #[test] + fn content_hash_known_answer() { + let h = content_hash(b"claim-conformance: canonical fixture object"); + let hex: String = h.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, "af904358ba08fb542a845ce37edb3e5ee7f56eb362dfb1ee10b209e167d420de"); + } +} diff --git a/wai/wai-rs/src/key_log/corpus.rs b/wai/wai-rs/src/key_log/corpus.rs index e6991a33d..29d2affe3 100644 --- a/wai/wai-rs/src/key_log/corpus.rs +++ b/wai/wai-rs/src/key_log/corpus.rs @@ -6,7 +6,7 @@ use super::*; use crate::claim_receipt::{ - object_content_hash, verify_claim_json, BindingType, ClaimBody, ClaimReceipt, DeclaredUncertainty, EnergyClaim, + verify_claim_json, BindingType, ClaimBody, ClaimReceipt, DeclaredUncertainty, EnergyClaim, FigureHeader, LawfulBasisClaim, MeasurementClaim, OperatingPointClaim, Purpose, SoftBindingClaim, SubjectType, VectorResult, }; @@ -196,7 +196,7 @@ fn logs() -> Logs { // ───────────────────────────── items and claims ───────────────────────────── fn subject() -> [u8; 32] { - object_content_hash(b"signer-conformance: the object a claim is about") + crate::jwp_hash::content_hash(b"signer-conformance: the object a claim is about") } fn energy_body(class: &str) -> ClaimBody { @@ -221,7 +221,7 @@ fn energy_body(class: &str) -> ClaimBody { fn figure_header(class: &str) -> FigureHeader { let ClaimBody::Energy(e) = energy_body(class) else { unreachable!("energy_body is an energy claim") }; let provenance = e.acquisition_class().expect("energy_body's class is well formed"); - let group = object_content_hash(b"signer-conformance: the group a figure was delivered in"); + let group = crate::jwp_hash::content_hash(b"signer-conformance: the group a figure was delivered in"); FigureHeader::new("moq-jwp:object", Some(0), Some(group), e.joules_micro, &provenance) } diff --git a/wai/wai-rs/src/key_log/tests.rs b/wai/wai-rs/src/key_log/tests.rs index c91a2132c..c714519d9 100644 --- a/wai/wai-rs/src/key_log/tests.rs +++ b/wai/wai-rs/src/key_log/tests.rs @@ -310,7 +310,7 @@ fn from_group_reads_the_classes_a_verified_group_carries() { let build = |labels: &[Option], labelled: bool| { let mut b = GroupBuilder::default(); for (i, l) in labels.iter().enumerate() { - b.add(crate::claim_receipt::object_content_hash(&[i as u8]), 12_000, "origin.example", l.clone()); + b.add(crate::jwp_hash::content_hash(&[i as u8]), 12_000, "origin.example", l.clone()); } b.finish(&key(0x66), sid, 7, 3, "2026-09-18T11:00:00Z", "2026-09-18T12:00:00Z", None, labelled).expect("composes") }; @@ -383,7 +383,7 @@ fn from_family_reads_a_verified_receipt() { &key(0x64), "did:web:publisher.example#m1", crate::claim_receipt::SubjectType::Object, - crate::claim_receipt::object_content_hash(b"subject"), + crate::jwp_hash::content_hash(b"subject"), 0, "2026-09-18T12:00:00Z", crate::claim_receipt::ClaimBody::SoftBinding(crate::claim_receipt::SoftBindingClaim { diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index 3922b7d44..ddbfd5a7d 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -277,6 +277,11 @@ pub mod energy_binding; #[cfg(feature = "c2pa_emit")] pub mod c2pa_emit; +// jwp-receipts §2.1: an object's content hash, BLAKE3 over a domain and the envelope. +// One module, gated only on BLAKE3, so every receipt family computes it in one place. +#[cfg(feature = "jwp_hash")] +pub mod jwp_hash; + // Typed receipt classes (extensions/jwp-receipts.md §2.4.3–§2.4.7, §2.6, §3.1–§3.2): // energy, lawful-basis, log-inclusion, soft-binding and binding-recovery statements // about delivered objects, on one `claim` envelope and one signature rule beside the -- 2.54.0 (Apple Git-157) From f2c3cbe3e5bbe49fb30dab6b436cb69a0d8027b8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 12:17:44 -0400 Subject: [PATCH 02/34] wai-rs: typed, checked integer decoders that the JSON forms are read into int_video, int_audio and int_splat gain a typed stream (VideoStream, AudioStream, SplatStream) and decode_typed. Before allocating, it checks every field, size and CDF table and caps every tensor at 2^26 elements; it rebuilds each latent with dequantize, runs each network only after checking its arithmetic on the values present, and refuses a stream that runs out before its last symbol. The same input is refused in a build with overflow checks and one without. The JSON decoders read their input into the typed form (VideoJson, AudioJson, SplatJson) and call the same function, with what the old readers left implicit stated: an absent mv_precision is 1 and only 1 and 2 are read; the first frame carries no motion vectors; a short motion-vector list is padded with (0, 0), the vector the old decoder read for a missing block; the JSON afrac must be the model's; every audio channel's table has one length; t_out may not exceed the synthesised length. int_hyper_synth gains decode_conditional_checked, CondStream and CondEntropy::try_from_value; decode_conditional is unchanged. AudioModel::from_bytes forms its sizes in checked arithmetic and reserves no more than its bytes allow. int_synth refuses a side of 2^64 or more instead of shifting by it, and int_synth::decode_bytes and int_mlicpp::decode_bytes name the byte-input entries. Every committed integer vector decodes to its committed hashes through the new paths, under every kernel policy. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/int_audio.rs | 323 +++++++++++++++++++++-------- wai/wai-rs/src/int_hyper_synth.rs | 276 ++++++++++++++++++++++--- wai/wai-rs/src/int_mlicpp.rs | 8 + wai/wai-rs/src/int_splat.rs | 184 +++++++++++++---- wai/wai-rs/src/int_synth.rs | 22 +- wai/wai-rs/src/int_video.rs | 324 +++++++++++++++++++++--------- 6 files changed, 890 insertions(+), 247 deletions(-) diff --git a/wai/wai-rs/src/int_audio.rs b/wai/wai-rs/src/int_audio.rs index 64b2ac9ed..480894769 100644 --- a/wai/wai-rs/src/int_audio.rs +++ b/wai/wai-rs/src/int_audio.rs @@ -35,43 +35,43 @@ pub struct AudioModel { pub layers: Vec, } -/// Little-endian, bounds-checked cursor over the binary model. +/// Little-endian cursor over the binary model, bounds-checked: every read is of bytes +/// that are present, with its end formed in checked arithmetic, so a damaged model is +/// refused and never makes the parser allocate more than the bytes it holds. struct Cur<'a> { b: &'a [u8], p: usize, } -impl Cur<'_> { - fn u32(&mut self) -> Result { - let e = self.p + 4; +impl<'a> Cur<'a> { + /// The next `n` elements of `size` bytes each. + fn take(&mut self, n: usize, size: usize) -> Result<&'a [u8], &'static str> { + let e = n.checked_mul(size).and_then(|len| self.p.checked_add(len)).ok_or("model truncated")?; let s = self.b.get(self.p..e).ok_or("model truncated")?; self.p = e; - Ok(u32::from_le_bytes(s.try_into().unwrap()) as usize) + Ok(s) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes(self.take(1, 4)?.try_into().unwrap()) as usize) } fn u8(&mut self) -> Result { - let v = *self.b.get(self.p).ok_or("model truncated")?; - self.p += 1; - Ok(v) + Ok(self.take(1, 1)?[0]) } fn i16v(&mut self, n: usize) -> Result, &'static str> { - let e = self.p + 2 * n; - let s = self.b.get(self.p..e).ok_or("model truncated")?; - self.p = e; - Ok(s.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]]) as i64).collect()) + Ok(self.take(n, 2)?.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]]) as i64).collect()) } fn i32v(&mut self, n: usize) -> Result, &'static str> { - let e = self.p + 4 * n; - let s = self.b.get(self.p..e).ok_or("model truncated")?; - self.p = e; - Ok(s.chunks_exact(4).map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]]) as i64).collect()) + Ok(self.take(n, 4)?.chunks_exact(4).map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]]) as i64).collect()) } fn i64v(&mut self, n: usize) -> Result, &'static str> { - let e = self.p + 8 * n; - let s = self.b.get(self.p..e).ok_or("model truncated")?; - self.p = e; - Ok(s.chunks_exact(8).map(|c| i64::from_le_bytes(c.try_into().unwrap())).collect()) + Ok(self.take(n, 8)?.chunks_exact(8).map(|c| i64::from_le_bytes(c.try_into().unwrap())).collect()) } } +/// The product of a layer's dimensions, refused where it overflows `usize`. +fn count(dims: &[usize]) -> Result { + dims.iter().try_fold(1usize, |a, &d| a.checked_mul(d)).ok_or("layer size overflows") +} + impl AudioModel { /// Parse the export tool's compact binary model: magic `WAM1`, then /// `afrac/gf/shift/n_layers` (u32) and per layer a `u8` tag — 0 convT1d, 2 igdn @@ -83,7 +83,10 @@ impl AudioModel { let mut c = Cur { b, p: 4 }; let (afrac, gf, shift) = (c.u32()? as u32, c.u32()? as u32, c.u32()? as u32); let n = c.u32()?; - let mut layers = Vec::with_capacity(n); + // Each layer is at least its tag byte, so a count beyond the bytes left is a + // truncated model, and reserving for it would let four damaged bytes ask for a + // large allocation. + let mut layers = Vec::with_capacity(n.min(b.len() - c.p)); for _ in 0..n { layers.push(match c.u8()? { 0 => { @@ -91,11 +94,11 @@ impl AudioModel { let out = c.u32()?; let (k, stride, pad, opad) = (c.u32()?, c.u32()?, c.u32()?, c.u32()?); ALayer::ConvT1d { out_ch: out, k, stride, pad, opad, - weight: c.i16v(inc * out * k)?, mult: c.i64v(out)?, bias: c.i64v(out)? } + weight: c.i16v(count(&[inc, out, k])?)?, mult: c.i64v(out)?, bias: c.i64v(out)? } } 2 => { let cc = c.u32()?; - ALayer::Igdn { c: cc, gamma: c.i32v(cc * cc)?, beta: c.i64v(cc)? } + ALayer::Igdn { c: cc, gamma: c.i32v(count(&[cc, cc])?)?, beta: c.i64v(cc)? } } _ => return Err("bad layer tag"), }); @@ -167,79 +170,233 @@ pub mod reference { } } -/// Run the integer synthesis `g_s` on the fixed-point latent `x` (`[c, t]` at -/// `2^afrac`) → `([1, to], to)` waveform at `2^afrac`. Pure i64. -fn run_g_s(model: &AudioModel, x: &[i64], mut c: usize, mut t: usize) -> (Vec, usize) { - let (afrac, gf, shift) = (model.afrac, model.gf, model.shift); - let mut x = x.to_vec(); - for layer in &model.layers { - match layer { - ALayer::ConvT1d { out_ch, k, stride, pad, opad, weight, mult, bias } => { - let (nx, to) = conv_t_1d(&x, c, t, weight, *out_ch, *k, *stride, *pad, *opad, mult, bias, shift); - x = nx; - c = *out_ch; - t = to; - } - ALayer::Igdn { c: _, gamma, beta } => { - x = igdn(&x, c, t, afrac, gamma, beta, gf); - } - } - } - (x, t) -} - fn i64s(v: &Value) -> Vec { v.as_array().map(|a| a.iter().filter_map(|n| n.as_i64()).collect()).unwrap_or_default() } -/// Decode a full audio **bitstream → int16 PCM**, end to end and fully integer: +use crate::int_hyper_synth::{refused, Refused, RefusalKind, MAX_AFRAC, MAX_TENSOR}; + +/// A clip's coded form, typed: what a `WIA1` payload carries +/// (`extensions/integer-payloads.md` §5), and what the tool's JSON form carries, read +/// by [`AudioJson`]. `cdfs` holds one table per latent channel, all of one length. +#[derive(Debug, Clone, Copy)] +pub struct AudioStream<'a> { + /// The fixed-point scale the stream was coded at; it must equal the model's. + pub afrac: u32, + pub n_ch: usize, + pub lat_len: usize, + /// Samples presented: the first `t_out` of the synthesis. + pub t_out: usize, + /// The symbol offset every channel's table is coded at (the JSON form's `lo`). + pub offset: i32, + pub median_q: &'a [i64], + pub cdfs: &'a [Vec], + pub rans: &'a [u8], +} + +/// The tool's JSON bitstream (`tools/wai_int_audio_export.py`'s +/// `audio_bitstream.json`), read into the typed form. A converter input and a +/// debugging aid; the payload is `WIA1`. What the old JSON decoder left implicit is +/// explicit here: its `afrac` is read (the old decoder ignored it) and must be the +/// model's; every channel's table must have the first table's length (the old decoder +/// read every table at that length). +#[derive(Debug, Clone)] +pub struct AudioJson { + pub afrac: u32, + pub n_ch: usize, + pub lat_len: usize, + pub t_out: usize, + pub offset: i32, + pub median_q: Vec, + pub cdfs: Vec>, + pub rans: Vec, +} + +impl AudioJson { + /// Read the tool's JSON bitstream. + pub fn from_value(bs: &Value) -> Result { + let uz = |k: &'static str| bs[k].as_u64().and_then(|v| usize::try_from(v).ok()).ok_or(k); + let (n_ch, lat_len, t_out) = (uz("n_ch")?, uz("lat_len")?, uz("t_out")?); + let afrac = bs["afrac"].as_u64().and_then(|v| u32::try_from(v).ok()).ok_or("afrac")?; + let offset = bs["lo"].as_i64().and_then(|v| i32::try_from(v).ok()).ok_or("lo")?; + let median_q = i64s(&bs["median_q"]); + let cdfs = bs["cdfs"].as_array().ok_or("cdfs")?.iter() + .map(|row| row.as_array().and_then(|r| r.iter().map(|n| n.as_i64().and_then(|v| i32::try_from(v).ok())).collect::>>())) + .collect::>>().ok_or("cdfs holds a value outside i32")?; + let first = cdfs.first().ok_or("empty cdfs")?.len(); + if cdfs.iter().any(|c| c.len() != first) { + return Err("the channels' tables are not of one length"); + } + let rans = b64_decode(bs["rans_b64"].as_str().ok_or("rans_b64")?)?; + Ok(AudioJson { afrac, n_ch, lat_len, t_out, offset, median_q, cdfs, rans }) + } + + /// The typed form, borrowing this one's tables. + pub fn stream(&self) -> AudioStream<'_> { + AudioStream { + afrac: self.afrac, n_ch: self.n_ch, lat_len: self.lat_len, t_out: self.t_out, offset: self.offset, + median_q: &self.median_q, cdfs: &self.cdfs, rans: &self.rans, + } + } +} + +/// One 1-D transposed convolution's geometry on `[c, t]`: its output length, and the +/// uncropped length the reference accumulates into, in checked arithmetic. +fn conv_t_1d_len(t: usize, k: usize, stride: usize, pad: usize, opad: usize) -> Option<(usize, usize)> { + let full = (t - 1).checked_mul(stride)?.checked_add(k)?.checked_add(opad)?; + let to = full.checked_sub(pad.checked_mul(2)?).filter(|&n| n > 0)?; + Some((to, full)) +} + +/// From the model's layer geometry alone, its output `[c, t]` on a `[c, t]` latent, +/// refused unless every layer's parameters fit the running channel count and no +/// buffer exceeds [`MAX_TENSOR`] elements. Allocates nothing. +fn plan(model: &AudioModel, mut c: usize, mut t: usize) -> Result<(usize, usize), Refused> { + use RefusalKind::*; + for layer in &model.layers { + match layer { + ALayer::ConvT1d { out_ch, k, stride, pad, opad, weight, mult, bias } => { + let (cout, k) = (*out_ch, *k); + if cout == 0 || k == 0 || *stride == 0 { + return Err(refused(ModelMismatch, "a layer has zero output channels, kernel or stride")); + } + if c.checked_mul(cout).and_then(|v| v.checked_mul(k)) != Some(weight.len()) || mult.len() != cout || bias.len() != cout { + return Err(refused(ModelMismatch, "a layer's parameters do not match the running channel count")); + } + let (to, full) = conv_t_1d_len(t, k, *stride, *pad, *opad) + .ok_or(refused(ModelMismatch, "a layer's geometry leaves no output, or overflows"))?; + if cout.checked_mul(full).is_none_or(|n| n > MAX_TENSOR) { + return Err(refused(TooLarge, "a layer would form a buffer over 2^26 elements")); + } + (c, t) = (cout, to); + } + ALayer::Igdn { c: cc, gamma, beta } => { + if *cc != c || c.checked_mul(c) != Some(gamma.len()) || beta.len() != c { + return Err(refused(ModelMismatch, "an IGDN layer does not match the running channel count")); + } + } + } + } + Ok((c, t)) +} + +/// One layer's arithmetic on the values `x` holds, checked before it runs, as +/// [`crate::int_hyper_synth::run_net_checked`] checks a 2-D layer: every product and +/// partial sum and the requantised value of a convolution fit `i64`; IGDN's terms, +/// norm and root fit their types. +fn layer_in_range(model: &AudioModel, layer: &ALayer, x: &[i64], c: usize) -> Result<(), Refused> { + use crate::int_kernels::max_abs; + const I64: u128 = i64::MAX as u128; + match layer { + ALayer::ConvT1d { k, weight, mult, bias, .. } => { + if model.shift > 127 { + return Err(refused(RefusalKind::ModelMismatch, "requantisation shift of 128 or more")); + } + let terms = (c as u128).saturating_mul(*k as u128); + let acc = terms.saturating_mul(max_abs(weight) as u128).saturating_mul(max_abs(x) as u128); + if acc > I64 { + return Err(refused(RefusalKind::FieldRange, "a latent could take an accumulation outside i64")); + } + let q = ((acc * max_abs(mult) as u128) >> model.shift) + 1; + if q.saturating_add(max_abs(bias) as u128) > I64 { + return Err(refused(RefusalKind::FieldRange, "a latent could take a requantised value outside i64")); + } + Ok(()) + } + ALayer::Igdn { gamma, beta, .. } => crate::int_hyper_synth::igdn_check(x, c, gamma, beta, model.gf, model.afrac) + .map_err(|(why, values)| refused(if values { RefusalKind::FieldRange } else { RefusalKind::ModelMismatch }, why)), + } +} + +/// Decode a typed clip → int16 PCM, mono, byte-exact on every machine, refusing +/// rather than panicking or overflowing on any input: /// -/// 1. per channel, factorized rANS-decode the symbol `s`; rebuild the fixed-point -/// latent `y_q = s·2^afrac + median_q` (no float); -/// 2. integer synthesis `g_s` (transposed convs + IGDN) → waveform at `2^afrac`; -/// 3. quantize → int16 PCM (round-half-up, clamp). -/// -/// `bs` is the export tool's `audio_bitstream.json`. Byte-identical on every machine. -pub fn decode(model: &AudioModel, bs: &Value) -> Result, &'static str> { - let c = bs["n_ch"].as_u64().ok_or("n_ch")? as usize; - let l = bs["lat_len"].as_u64().ok_or("lat_len")? as usize; - let t_out = bs["t_out"].as_u64().ok_or("t_out")? as usize; +/// 1. the stream's `afrac` is the model's, in `1..=30`; every table is well formed; +/// the model's layers fit the latent's shape, give one channel, and give at least +/// `t_out` samples, with no buffer over [`MAX_TENSOR`] elements — all before +/// anything is allocated; +/// 2. per channel, rANS-decode the symbol `s`; the latent is `s·2^afrac + median_q` +/// ([`crate::int_entropy::dequantize`]), and a stream that runs out before its last +/// symbol is refused; +/// 3. integer synthesis `g_s`, each layer's arithmetic checked before it runs; +/// 4. the first `t_out` samples, `(x·32767 + 2^(afrac−1)) >> afrac` clamped to `i16`. +pub fn decode_typed(model: &AudioModel, s: &AudioStream<'_>) -> Result, Refused> { + use crate::int_entropy::dequantize; + use RefusalKind::*; let afrac = model.afrac; - let one = 1i64 << afrac; - let lo = bs["lo"].as_i64().ok_or("lo")? as i32; - let median_q = i64s(&bs["median_q"]); - if median_q.len() != c { - return Err("median_q length disagrees with n_ch"); + if !(1..=MAX_AFRAC).contains(&afrac) { + return Err(refused(ModelMismatch, "the model's fixed-point scale is outside 1..=30")); + } + if s.afrac != afrac { + return Err(refused(ModelMismatch, "the stream's afrac is not the model's")); + } + let (c, l) = (s.n_ch, s.lat_len); + if c == 0 || l == 0 || s.t_out == 0 || s.median_q.len() != c || s.cdfs.len() != c { + return Err(refused(FieldRange, "a dimension is zero, or the medians or tables are not one per channel")); + } + if s.median_q.iter().any(|m| m.unsigned_abs() > 1 << 47) { + return Err(refused(FieldRange, "a median's magnitude exceeds 2^47")); + } + let len = s.cdfs[0].len(); + let ok = |t: &Vec| t.len() == len && (3..=4097).contains(&len) && t[0] == 0 && t[len - 1] == 1 << 16 + && t.windows(2).all(|p| p[0] < p[1]); + if !s.cdfs.iter().all(ok) { + return Err(refused(CdfMalformed, "a table is not a CDF from 0 to 65536 of the channels' one length")); + } + if c.checked_mul(l).is_none_or(|n| n > MAX_TENSOR) || s.t_out > MAX_TENSOR { + return Err(refused(TooLarge, "the latent or the clip exceeds 2^26 elements")); + } + let (oc, t) = plan(model, c, l)?; + if oc != 1 { + return Err(refused(ModelMismatch, "the model does not give one channel")); + } + if t < s.t_out { + return Err(refused(GeometryMismatch, "the synthesis gives fewer samples than t_out")); } - let cdfs: Vec> = bs["cdfs"] - .as_array() - .ok_or("cdfs")? - .iter() - .map(|row| row.as_array().unwrap().iter().map(|n| n.as_i64().unwrap() as i32).collect()) - .collect(); - let cdf_len = cdfs.first().ok_or("empty cdfs")?.len(); - let rans = b64_decode(bs["rans_b64"].as_str().ok_or("rans_b64")?)?; // 1. factorized rANS-decode the per-channel latent (channel-major), rebuild y_q. let mut y = vec![0i64; c * l]; - let mut dec = RansDecoder::new(&rans)?; - for ch in 0..c { - for p in 0..l { - let s = dec.decode(&cdfs[ch], cdf_len, lo); - y[ch * l + p] = s as i64 * one + median_q[ch]; + let mut dec = RansDecoder::new(s.rans).map_err(|_| refused(FieldRange, "the latent stream is not a rANS stream"))?; + for (ch, yc) in y.chunks_mut(l).enumerate() { + for v in yc { + let sym = dec.decode(&s.cdfs[ch], len, s.offset); + *v = dequantize(sym, afrac, s.median_q[ch]).ok_or(refused(FieldRange, "a latent value leaves i64"))?; } } - // 2. integer synthesis → waveform. - let (x, t) = run_g_s(model, &y, c, l); - // 3. waveform → int16 PCM (round-half-up, clamp). Take t_out samples. - let half = 1i64 << (afrac - 1); - let n = t_out.min(t); - let mut pcm = vec![0i16; n]; - for (i, s) in pcm.iter_mut().enumerate() { - let num = x[i] * 32767 + half; - *s = (num >> afrac).clamp(-32768, 32767) as i16; + if dec.is_exhausted() { + return Err(refused(StreamExhausted, "the latent stream ended before its last symbol")); } - Ok(pcm) + // 2. integer synthesis → waveform, each layer checked on the values it is given. + let (mut x, mut cc, mut tt) = (y, c, l); + for layer in &model.layers { + layer_in_range(model, layer, &x, cc)?; + (x, cc, tt) = run_layer(model, layer, x, cc, tt); + } + // 3. waveform → int16 PCM (round-half-up, clamp): the first t_out samples. + let half = 1i64 << (afrac - 1); + x[..s.t_out].iter().map(|&v| { + let num = v.checked_mul(32767).and_then(|n| n.checked_add(half)) + .ok_or(refused(FieldRange, "a synthesised sample leaves i64 when scaled"))?; + Ok((num >> afrac).clamp(-32768, 32767) as i16) + }).collect() +} + +/// One layer of the synthesis on `x` (`[c, t]`): the new activations and shape. +fn run_layer(model: &AudioModel, layer: &ALayer, x: Vec, c: usize, t: usize) -> (Vec, usize, usize) { + match layer { + ALayer::ConvT1d { out_ch, k, stride, pad, opad, weight, mult, bias } => { + let (nx, to) = conv_t_1d(&x, c, t, weight, *out_ch, *k, *stride, *pad, *opad, mult, bias, model.shift); + (nx, *out_ch, to) + } + ALayer::Igdn { gamma, beta, .. } => (igdn(&x, c, t, model.afrac, gamma, beta, model.gf), c, t), + } +} + +/// Decode the tool's JSON bitstream (`audio_bitstream.json`) → int16 PCM: +/// [`AudioJson`] then [`decode_typed`]. A debugging and converter path; the payload +/// is `WIA1`. Byte-identical on every machine. +pub fn decode(model: &AudioModel, bs: &Value) -> Result, &'static str> { + decode_typed(model, &AudioJson::from_value(bs)?.stream()).map_err(|r| r.detail) } /// FNV-1a 64-bit over the little-endian int16 PCM bytes — the dependency-free diff --git a/wai/wai-rs/src/int_hyper_synth.rs b/wai/wai-rs/src/int_hyper_synth.rs index 0112cc7a3..ddaa01044 100644 --- a/wai/wai-rs/src/int_hyper_synth.rs +++ b/wai/wai-rs/src/int_hyper_synth.rs @@ -449,7 +449,7 @@ pub fn run_net_checked( /// A check [`check_layer`] failed: its reason, and whether it concerned the values /// presented (`true`) or the layer itself (`false`). -type Failed = (&'static str, bool); +pub(crate) type Failed = (&'static str, bool); const fn shape(reason: &'static str) -> Failed { (reason, false) } @@ -548,7 +548,6 @@ fn check_layer(model: &IntModel, layer: &IntLayer, x: &[i64], c: usize, h: usize use crate::int_kernels::max_abs; let (out, _) = layer_geometry(layer, c, h, w)?; const I64: u128 = i64::MAX as u128; - const I128: u128 = i128::MAX as u128; let xm = max_abs(x) as u128; // One (transposed) convolution's arithmetic: every product and partial sum, then // the requantised value, must fit i64 (the reference's accumulator and result). @@ -579,28 +578,7 @@ fn check_layer(model: &IntModel, layer: &IntLayer, x: &[i64], c: usize, h: usize Ok(out) } IntLayer::Igdn { gamma, beta } => { - if c.checked_mul(c) != Some(gamma.len()) || beta.len() != c { - return Err(shape("IGDN parameter lengths do not match the channel count")); - } - if model.gf > 127 || model.afrac > 127 { - return Err(shape("IGDN shift of 128 or more")); - } - // x² ≤ 2^126 always fits; each term gamma·x², and the norm, must fit i128. - let x2 = xm * xm; - let term = (max_abs(gamma) as u128).checked_mul(x2).filter(|&t| t <= I128).ok_or(range("IGDN term could leave i128"))?; - let norm = (max_abs(beta) as u128).saturating_add((c as u128).saturating_mul((term >> model.gf) + 1)); - if norm > I128 { - return Err(range("IGDN norm could leave i128")); - } - // The reference narrows the root to i64 (`isqrt(norm) as i64`), so it must - // fit; then |x · root| < 2^63 · 2^63 fits u128, and the result must fit i64. - let root = norm.isqrt(); - if root > I64 { - return Err(range("IGDN root could leave i64")); - } - if ((xm * root) >> model.afrac) + 1 > I64 { - return Err(range("IGDN output could leave i64")); - } + igdn_check(x, c, gamma, beta, model.gf, model.afrac)?; Ok((c, h, w)) } IntLayer::Relu => Ok((c, h, w)), @@ -616,6 +594,39 @@ fn check_layer(model: &IntModel, layer: &IntLayer, x: &[i64], c: usize, h: usize } } +/// The IGDN checks [`run_net_checked`] documents, for activations `x` of `c` channels +/// at `2^afrac` against `gamma` (`c × c`, at `2^gf`) and `beta` (`c`). Shared with the +/// 1-D synthesis of `int_audio`, whose IGDN is the same [`igdn`]. +pub(crate) fn igdn_check(x: &[i64], c: usize, gamma: &[i64], beta: &[i64], gf: u32, afrac: u32) -> Result<(), Failed> { + use crate::int_kernels::max_abs; + const I64: u128 = i64::MAX as u128; + const I128: u128 = i128::MAX as u128; + if c.checked_mul(c) != Some(gamma.len()) || beta.len() != c { + return Err(shape("IGDN parameter lengths do not match the channel count")); + } + if gf > 127 || afrac > 127 { + return Err(shape("IGDN shift of 128 or more")); + } + let xm = max_abs(x) as u128; + // x² ≤ 2^126 always fits; each term gamma·x², and the norm, must fit i128. + let x2 = xm * xm; + let term = (max_abs(gamma) as u128).checked_mul(x2).filter(|&t| t <= I128).ok_or(range("IGDN term could leave i128"))?; + let norm = (max_abs(beta) as u128).saturating_add((c as u128).saturating_mul((term >> gf) + 1)); + if norm > I128 { + return Err(range("IGDN norm could leave i128")); + } + // The reference narrows the root to i64 (`isqrt(norm) as i64`), so it must + // fit; then |x · root| < 2^63 · 2^63 fits u128, and the result must fit i64. + let root = norm.isqrt(); + if root > I64 { + return Err(range("IGDN root could leave i64")); + } + if ((xm * root) >> afrac) + 1 > I64 { + return Err(range("IGDN output could leave i64")); + } + Ok(()) +} + /// Decode the integer synthesis `model` on the latent `input` (`[c, h, w]` at /// `2^afrac`) → RGB, row-major **HWC** `u8`. Byte-identical on every machine. pub fn decode(model: &IntModel, input: &[i64], c: usize, h: usize, w: usize) -> (Vec, usize, usize) { @@ -780,6 +791,223 @@ pub fn decode_conditional( Ok(decode(g_s, &y_hat, bs.m, sh, sw)) } +/// The kind of rule a checked decode refused on. The integer-payload decoders +/// (`int_payload`, and `int_audio` and `int_splat`'s typed decoders) report these as +/// the refusal codes of `extensions/integer-payloads.md` §8. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefusalKind { + /// A value the payload carries, or a latent its stream decodes, is outside the + /// range the decode is exact over. + FieldRange, + /// A tensor or an activation would exceed [`MAX_TENSOR`] elements. + TooLarge, + /// A CDF table breaks the table rules. + CdfMalformed, + /// A rANS stream ran out before its last symbol. + StreamExhausted, + /// A shape the payload declares disagrees with the one the decode produces. + GeometryMismatch, + /// The parameter set does not fit the payload. + ModelMismatch, +} + +/// A checked decode's refusal: the kind of rule and which. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Refused { + pub kind: RefusalKind, + pub detail: &'static str, +} + +impl std::fmt::Display for Refused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.kind, self.detail) + } +} + +pub(crate) const fn refused(kind: RefusalKind, detail: &'static str) -> Refused { + Refused { kind, detail } +} + +/// The most elements any one buffer a checked decode forms may hold, the same on +/// every target (`extensions/integer-payloads.md` §7). +/// +/// cbindgen:ignore +pub const MAX_TENSOR: usize = 1 << 26; +/// The largest fixed-point scale a checked decode accepts. +/// +/// cbindgen:ignore +pub const MAX_AFRAC: u32 = 30; + +/// A quantised CDF the rANS decoder is total over: `2 ≤ len ≤ row`, `cdf[0] = 0`, +/// `cdf[len − 1] = 2^16`, strictly increasing over `len`. +fn table_ok(cdf: &[i32], len: usize) -> bool { + len >= 2 && len <= cdf.len() && cdf[0] == 0 && cdf[len - 1] == 1 << 16 && cdf[..len].windows(2).all(|p| p[0] < p[1]) +} + +/// The entropy model's consistency: a non-empty scale table with one Gaussian table, +/// length and offset per edge; one `z` table, length, offset and median per channel; +/// every table one the rANS decoder is total over. +fn check_entropy(t: &CondEntropy) -> Result<(), &'static str> { + let nb = t.edges.len(); + if nb == 0 || t.cdfs.len() != nb || t.cdf_lengths.len() != nb || t.offsets.len() != nb { + return Err("one Gaussian table, length and offset per scale edge, and at least one edge"); + } + let nz = t.z_cdfs.len(); + if nz == 0 || t.z_cdf_lengths.len() != nz || t.z_offsets.len() != nz || t.z_median_q.len() != nz { + return Err("one z table, length, offset and median per channel"); + } + let all = t.cdfs.iter().zip(&t.cdf_lengths).chain(t.z_cdfs.iter().zip(&t.z_cdf_lengths)); + if all.into_iter().any(|(c, &l)| !table_ok(c, l)) { + return Err("a table is not a CDF from 0 to 65536, strictly increasing over its length"); + } + Ok(()) +} + +impl CondEntropy { + /// Parse and check the entropy tables (`cond_entropy.json`, read from a set as + /// `entropy.json`): every member present with integers in range, the lengths + /// consistent and every table well formed. Refuses rather than panics. + pub fn try_from_value(v: &Value) -> Result { + fn arr<'a>(v: &'a Value, k: &str) -> Result<&'a Vec, &'static str> { + v.get(k).and_then(Value::as_array).ok_or("an entropy member is missing or not an array") + } + fn ints>(v: &Value, k: &str) -> Result, &'static str> { + arr(v, k)?.iter().map(|x| x.as_i64().and_then(|i| T::try_from(i).ok())).collect::>() + .ok_or("an entropy member holds a value out of range") + } + fn rows(v: &Value, k: &str) -> Result>, &'static str> { + arr(v, k)?.iter().map(|r| r.as_array().ok_or("an entropy table is not an array") + .and_then(|r| r.iter().map(|x| x.as_i64().and_then(|i| i32::try_from(i).ok())).collect::>() + .ok_or("an entropy table holds a value out of range"))).collect() + } + let t = CondEntropy { + edges: ints(v, "scale_edges")?, + cdfs: rows(v, "cdfs")?, + cdf_lengths: ints(v, "cdf_lengths")?, + offsets: ints(v, "offsets")?, + z_cdfs: rows(v, "z_cdfs")?, + z_cdf_lengths: ints(v, "z_cdf_lengths")?, + z_offsets: ints(v, "z_offsets")?, + z_median_q: ints(v, "z_median_q")?, + }; + check_entropy(&t)?; + Ok(t) + } +} + +/// A conditional bitstream's fields, borrowed: what a `WIH1` payload carries. +#[derive(Debug, Clone, Copy)] +pub struct CondStream<'a> { + pub m: usize, + pub sh: usize, + pub sw: usize, + pub n_z: usize, + pub zh: usize, + pub zw: usize, + pub z_rans: &'a [u8], + pub rans: &'a [u8], +} + +impl CondBitstream { + /// The borrowed form [`decode_conditional_checked`] reads. + pub fn stream(&self) -> CondStream<'_> { + CondStream { m: self.m, sh: self.sh, sw: self.sw, n_z: self.n_z, zh: self.zh, zw: self.zw, z_rans: &self.z_rans, rans: &self.rans } + } +} + +/// From a network's layer geometry alone, its output on a `[c, h, w]` input, refused +/// unless every buffer it forms is within [`MAX_TENSOR`]. +fn plan(net: &IntModel, c: usize, h: usize, w: usize) -> Result<(usize, usize, usize), Refused> { + let p = net_shape(net, c, h, w).map_err(|_| refused(RefusalKind::ModelMismatch, "a network's layers do not fit the payload's shapes"))?; + if p.peak > MAX_TENSOR { + return Err(refused(RefusalKind::TooLarge, "a network would form a buffer over 2^26 elements")); + } + Ok(p.out) +} + +/// [`run_net_checked`] with its refusal as a [`Refused`]: values that could overflow a +/// layer are the stream's (`FieldRange`); a layer that does not fit is the model's. +fn run_checked(net: &IntModel, x: &[i64], c: usize, h: usize, w: usize) -> Result, Refused> { + run_net_checked(net, x, c, h, w).map(|(o, ..)| o).map_err(|r| { + if r.out_of_range { + refused(RefusalKind::FieldRange, "a decoded latent could take a layer outside the exact integer range") + } else { + refused(RefusalKind::ModelMismatch, "a network's parameters do not fit the running shapes") + } + }) +} + +/// [`decode_conditional`], refusing instead of panicking or overflowing on any input: +/// the decode the `WIH1` payload (`wai.neural.int_hyper`, and the keyframe of +/// `wai.video.int_hyper`) runs. On a stream [`decode_conditional`] decodes, both give +/// the same image. +/// +/// Before anything is allocated: the networks share one fixed-point scale in +/// `1..=`[`MAX_AFRAC`]; the entropy model is consistent and has a `z` table for each +/// of the payload's channels; `h_s` maps the hyper-latent's shape to `[2·m, sh, sw]` +/// and `g_s` maps `[m, sh, sw]` to three channels, with no buffer over [`MAX_TENSOR`] +/// elements. Then every latent is rebuilt with [`dequantize`], every network runs +/// through [`run_net_checked`], and a stream that runs out before its last symbol is +/// refused. Returns RGB HWC with its height and width. +pub fn decode_conditional_checked( + g_s: &IntModel, h_s: &IntModel, ent: &CondEntropy, bs: &CondStream<'_>, +) -> Result<(Vec, usize, usize), Refused> { + use crate::int_entropy::dequantize; + use RefusalKind::*; + let afrac = g_s.afrac; + if h_s.afrac != afrac || !(1..=MAX_AFRAC).contains(&afrac) { + return Err(refused(ModelMismatch, "g_s and h_s do not share one fixed-point scale in 1..=30")); + } + check_entropy(ent).map_err(|e| refused(ModelMismatch, e))?; + let (n_z, zh, zw, m, sh, sw) = (bs.n_z, bs.zh, bs.zw, bs.m, bs.sh, bs.sw); + if [n_z, zh, zw, m, sh, sw].contains(&0) { + return Err(refused(FieldRange, "a latent dimension is zero")); + } + if n_z > ent.z_cdfs.len() { + return Err(refused(ModelMismatch, "the hyper-latent has more channels than the entropy model has z tables")); + } + let size = |d: &[usize]| d.iter().try_fold(1usize, |a, &x| a.checked_mul(x)).filter(|&n| n <= MAX_TENSOR); + let (zplane, n) = match (size(&[n_z, zh, zw]), size(&[2, m, sh, sw])) { + (Some(_), Some(_)) => (zh * zw, m * sh * sw), + _ => return Err(refused(TooLarge, "a latent exceeds 2^26 elements")), + }; + if plan(h_s, n_z, zh, zw)? != (2 * m, sh, sw) { + return Err(refused(GeometryMismatch, "h_s does not give the latent shape the payload declares")); + } + let (oc, oh, ow) = plan(g_s, m, sh, sw)?; + if oc != 3 { + return Err(refused(ModelMismatch, "g_s does not give three channels")); + } + // 1. factorized-decode z, rebuild z_q = s·2^afrac + median_q. + let mut z = vec![0i64; n_z * zplane]; + let mut dz = RansDecoder::new(bs.z_rans).map_err(|_| refused(FieldRange, "the z stream is not a rANS stream"))?; + for (ch, zc) in z.chunks_mut(zplane).enumerate() { + for v in zc { + let s = dz.decode(&ent.z_cdfs[ch], ent.z_cdf_lengths[ch], ent.z_offsets[ch]); + *v = dequantize(s, afrac, ent.z_median_q[ch]).ok_or(refused(FieldRange, "a hyper-latent value leaves i64"))?; + } + } + if dz.is_exhausted() { + return Err(refused(StreamExhausted, "the z stream ended before its last symbol")); + } + let sm = run_checked(h_s, &z, n_z, zh, zw)?; + let (sigma, mu) = sm.split_at(n); + // 2. y, conditioned on the integer σ: y_hat = r·2^afrac + μ. + let mut dec = RansDecoder::new(bs.rans).map_err(|_| refused(FieldRange, "the y stream is not a rANS stream"))?; + let mut y_hat = vec![0i64; n]; + for p in 0..n { + let b = det_build_index(sigma[p], &ent.edges); + let r = dec.decode(&ent.cdfs[b], ent.cdf_lengths[b], ent.offsets[b]); + y_hat[p] = dequantize(r, afrac, mu[p]).ok_or(refused(FieldRange, "a latent value leaves i64"))?; + } + if dec.is_exhausted() { + return Err(refused(StreamExhausted, "the y stream ended before its last symbol")); + } + // 3. integer synthesis g_s → pixels. + let x = run_checked(g_s, &y_hat, m, sh, sw)?; + Ok((to_rgb8(&x, oc, oh, ow, afrac), oh, ow)) +} + /// The fast kernels against the scalar reference, element by element. Operand /// ranges cover the `i32` fast path, the `i64` path, and sparse (zero-heavy) /// inputs; geometries cover strides, paddings, output paddings and kernels smaller diff --git a/wai/wai-rs/src/int_mlicpp.rs b/wai/wai-rs/src/int_mlicpp.rs index a6a457e0d..dacc63a9b 100644 --- a/wai/wai-rs/src/int_mlicpp.rs +++ b/wai/wai-rs/src/int_mlicpp.rs @@ -361,6 +361,14 @@ pub struct Trace { pub symbols: Vec, } +/// Parse the `WMB1` bundle `bundle` and decode the `WIM1` payload with it: the +/// byte-input entry of the integer decoders (`int_payload`, the browser crate). A pin +/// over the bundle is verified before this is called (`pin::verify_bytes`, +/// `int_payload::resolve`), so it takes no pin. +pub fn decode_bytes(payload: &[u8], bundle: &[u8]) -> Result { + decode(&IntMlicModel::from_bundle(bundle)?, payload) +} + /// Decode a `WIM1` payload with `model` → RGB. Byte-identical on every machine. /// /// Before it allocates anything, the decode derives from the model's layer geometry diff --git a/wai/wai-rs/src/int_splat.rs b/wai/wai-rs/src/int_splat.rs index a6cbbe99c..df63278a5 100644 --- a/wai/wai-rs/src/int_splat.rs +++ b/wai/wai-rs/src/int_splat.rs @@ -11,13 +11,9 @@ use serde_json::Value; -use crate::int_hyper_synth::{fnv1a, run_net, IntModel}; +use crate::int_hyper_synth::{fnv1a, net_shape, refused, run_net_checked, IntModel, Refused, RefusalKind, MAX_AFRAC, MAX_TENSOR}; use crate::rans::RansDecoder; -fn i64s(v: &Value) -> Vec { - v.as_array().map(|a| a.iter().filter_map(|n| n.as_i64()).collect()).unwrap_or_default() -} - /// A decoded splat: the `[attr · s · s]` `u8` attribute image (channel-major), to be /// un-normalized + un-sorted into Gaussians by the presentation layer. pub struct DecodedSplat { @@ -33,58 +29,158 @@ impl DecodedSplat { } } -/// Decode a learned-splat **bitstream → integer attribute tensor**, fully integer: -/// per channel, factorized rANS-decode the symbol `s` and rebuild the fixed-point -/// latent `y_q = s·2^afrac + median_q`; integer synthesis `g_s` (transposed convs + -/// IGDN) → `[attr, S, S]` at `2^afrac`; round-half-up → `u8`. `gs` is the WHM1 model -/// (`splat_gs.bin`); `bs` the export's `splat.json`. Byte-identical on every machine. -pub fn decode(gs: &IntModel, bs: &Value) -> Result { - let c = bs["c"].as_u64().ok_or("c")? as usize; - let sh = bs["sh"].as_u64().ok_or("sh")? as usize; - let sw = bs["sw"].as_u64().ok_or("sw")? as usize; - let s = bs["s"].as_u64().ok_or("s")? as usize; - let n_attr = bs["attr"].as_u64().ok_or("attr")? as usize; +/// A cloud's coded form, typed: what a `WIS1` payload carries +/// (`extensions/integer-payloads.md` §6), and what the tool's JSON form carries, read +/// by [`SplatJson`]. Per latent channel: a table, a symbol offset and a median. +#[derive(Debug, Clone, Copy)] +pub struct SplatStream<'a> { + /// The fixed-point scale the stream was coded at; it must equal the model's. + pub afrac: u32, + pub c: usize, + pub sh: usize, + pub sw: usize, + pub s: usize, + pub n_attr: usize, + pub cdfs: &'a [Vec], + pub offsets: &'a [i32], + pub median_q: &'a [i64], + pub rans: &'a [u8], +} + +/// The tool's JSON bitstream (`tools/wai_int_splat_export.py`'s `splat.json`), read +/// into the typed form, with the members only presentation reads: `n_gaussians` and +/// the per-attribute ranges `lo`, `hi`. A converter input and a debugging aid; the +/// payload is `WIS1`. Its `afrac` is read (the old JSON decoder ignored it) and must +/// be the model's. +#[derive(Debug, Clone)] +pub struct SplatJson { + pub afrac: u32, + pub c: usize, + pub sh: usize, + pub sw: usize, + pub s: usize, + pub n_attr: usize, + pub n_gaussians: usize, + pub cdfs: Vec>, + pub offsets: Vec, + pub median_q: Vec, + /// Per attribute, `(lo, hi)` as the JSON carries them. + pub ranges: Vec<(f64, f64)>, + pub rans: Vec, +} + +impl SplatJson { + /// Read the tool's JSON bitstream. + pub fn from_value(bs: &Value) -> Result { + let uz = |k: &'static str| bs[k].as_u64().and_then(|v| usize::try_from(v).ok()).ok_or(k); + let (c, sh, sw, s, n_attr) = (uz("c")?, uz("sh")?, uz("sw")?, uz("s")?, uz("attr")?); + let n_gaussians = uz("n_gaussians")?; + let afrac = bs["afrac"].as_u64().and_then(|v| u32::try_from(v).ok()).ok_or("afrac")?; + let offsets = bs["offsets"].as_array().ok_or("offsets")?.iter() + .map(|n| n.as_i64().and_then(|v| i32::try_from(v).ok())).collect::>>().ok_or("offsets")?; + let median_q = bs["median_q"].as_array().ok_or("median_q")?.iter() + .map(Value::as_i64).collect::>>().ok_or("median_q")?; + let cdfs = bs["cdfs"].as_array().ok_or("cdfs")?.iter() + .map(|row| row.as_array().and_then(|r| r.iter().map(|n| n.as_i64().and_then(|v| i32::try_from(v).ok())).collect::>>())) + .collect::>>().ok_or("cdfs holds a value outside i32")?; + let f = |k: &'static str| bs[k].as_array().ok_or(k)?.iter().map(Value::as_f64).collect::>>().ok_or(k); + let (lo, hi) = (f("lo")?, f("hi")?); + if lo.len() != n_attr || hi.len() != n_attr { + return Err("lo and hi are not one per attribute"); + } + let rans = b64_decode(bs["rans_b64"].as_str().ok_or("rans_b64")?)?; + Ok(SplatJson { afrac, c, sh, sw, s, n_attr, n_gaussians, cdfs, offsets, median_q, ranges: lo.into_iter().zip(hi).collect(), rans }) + } + + /// The typed form, borrowing this one's tables. + pub fn stream(&self) -> SplatStream<'_> { + SplatStream { + afrac: self.afrac, c: self.c, sh: self.sh, sw: self.sw, s: self.s, n_attr: self.n_attr, + cdfs: &self.cdfs, offsets: &self.offsets, median_q: &self.median_q, rans: &self.rans, + } + } +} + +/// Decode a typed cloud → the integer attribute tensor, byte-exact on every machine, +/// refusing rather than panicking or overflowing on any input: +/// +/// 1. the stream's `afrac` is the model's, in `1..=30`; the medians, offsets and +/// tables are one per channel and every table is well formed; `g_s` maps the +/// latent's shape to `[n_attr, s, s]` with no buffer over +/// [`crate::int_hyper_synth::MAX_TENSOR`] elements — all before anything is +/// allocated; +/// 2. per channel, rANS-decode the symbol and rebuild `s·2^afrac + median_q` +/// ([`crate::int_entropy::dequantize`]); a stream that runs out before its last +/// symbol is refused; +/// 3. integer synthesis `g_s` through [`crate::int_hyper_synth::run_net_checked`]; +/// 4. round-half-up → `u8`, channel-major `[attr, s, s]`. +pub fn decode_typed(gs: &IntModel, st: &SplatStream<'_>) -> Result { + use crate::int_entropy::dequantize; + use RefusalKind::*; let afrac = gs.afrac; - let one = 1i64 << afrac; - let median_q = i64s(&bs["median_q"]); - let offsets: Vec = bs["offsets"].as_array().ok_or("offsets")?.iter().map(|n| n.as_i64().unwrap() as i32).collect(); - if median_q.len() != c || offsets.len() != c { - return Err("median_q / offsets length disagrees with channel count"); + if !(1..=MAX_AFRAC).contains(&afrac) { + return Err(refused(ModelMismatch, "the model's fixed-point scale is outside 1..=30")); + } + if st.afrac != afrac { + return Err(refused(ModelMismatch, "the stream's afrac is not the model's")); + } + let (c, sh, sw, s, n_attr) = (st.c, st.sh, st.sw, st.s, st.n_attr); + if [c, sh, sw, s, n_attr].contains(&0) || st.median_q.len() != c || st.offsets.len() != c || st.cdfs.len() != c { + return Err(refused(FieldRange, "a dimension is zero, or the medians, offsets or tables are not one per channel")); + } + if st.median_q.iter().any(|m| m.unsigned_abs() > 1 << 47) { + return Err(refused(FieldRange, "a median's magnitude exceeds 2^47")); + } + let ok = |t: &Vec| (3..=4097).contains(&t.len()) && t[0] == 0 && t[t.len() - 1] == 1 << 16 && t.windows(2).all(|p| p[0] < p[1]); + if !st.cdfs.iter().all(ok) { + return Err(refused(CdfMalformed, "a table is not a CDF from 0 to 65536, strictly increasing")); + } + let size = |d: &[usize]| d.iter().try_fold(1usize, |a, &x| a.checked_mul(x)).filter(|&n| n <= MAX_TENSOR); + let (Some(_), Some(_)) = (size(&[c, sh, sw]), size(&[n_attr, s, s])) else { + return Err(refused(TooLarge, "the latent or the attribute tensor exceeds 2^26 elements")); + }; + let plan = net_shape(gs, c, sh, sw).map_err(|_| refused(ModelMismatch, "g_s's layers do not fit the latent's shape"))?; + if plan.peak > MAX_TENSOR { + return Err(refused(TooLarge, "g_s would form a buffer over 2^26 elements")); + } + if plan.out != (n_attr, s, s) { + return Err(refused(GeometryMismatch, "g_s does not give the attribute shape the payload declares")); } - let cdfs: Vec> = bs["cdfs"] - .as_array() - .ok_or("cdfs")? - .iter() - .map(|row| row.as_array().unwrap().iter().map(|n| n.as_i64().unwrap() as i32).collect()) - .collect(); - let rans = b64_decode(bs["rans_b64"].as_str().ok_or("rans_b64")?)?; // 1. per-channel factorized rANS-decode the latent, rebuild y_q. let plane = sh * sw; let mut y = vec![0i64; c * plane]; - let mut dec = RansDecoder::new(&rans)?; - for ch in 0..c { - let cdf_len = cdfs[ch].len(); - for p in 0..plane { - let sym = dec.decode(&cdfs[ch], cdf_len, offsets[ch]); - y[ch * plane + p] = sym as i64 * one + median_q[ch]; + let mut dec = RansDecoder::new(st.rans).map_err(|_| refused(FieldRange, "the latent stream is not a rANS stream"))?; + for (ch, yc) in y.chunks_mut(plane).enumerate() { + for v in yc { + let sym = dec.decode(&st.cdfs[ch], st.cdfs[ch].len(), st.offsets[ch]); + *v = dequantize(sym, afrac, st.median_q[ch]).ok_or(refused(FieldRange, "a latent value leaves i64"))?; } } + if dec.is_exhausted() { + return Err(refused(StreamExhausted, "the latent stream ended before its last symbol")); + } // 2. integer synthesis → attribute tensor at 2^afrac. - let (x, oc, oh, ow) = run_net(gs, &y, c, sh, sw); - if oc != n_attr || oh != s || ow != s { - return Err("g_s output shape disagrees with the splat header"); - } + let (x, ..) = run_net_checked(gs, &y, c, sh, sw).map_err(|r| { + if r.out_of_range { + refused(FieldRange, "a decoded latent could take a layer outside the exact integer range") + } else { + refused(ModelMismatch, "g_s's parameters do not fit the running shapes") + } + })?; // 3. round-half-up → u8 (channel-major [attr, s, s]). - let half = 1i64 << (afrac - 1); - let mut attr = vec![0u8; n_attr * s * s]; - for (i, v) in x.iter().enumerate() { - let q = (*v).clamp(0, one); - attr[i] = ((q * 255 + half) >> afrac).clamp(0, 255) as u8; - } + let (one, half) = (1i64 << afrac, 1i64 << (afrac - 1)); + let attr = x.iter().map(|&v| ((v.clamp(0, one) * 255 + half) >> afrac).clamp(0, 255) as u8).collect(); Ok(DecodedSplat { attr, n_attr, s }) } +/// Decode the tool's JSON bitstream (`splat.json`) → the integer attribute tensor: +/// [`SplatJson`] then [`decode_typed`]. A debugging and converter path; the payload is +/// `WIS1`. `gs` is the WHM1 model (`splat_gs.bin`). Byte-identical on every machine. +pub fn decode(gs: &IntModel, bs: &Value) -> Result { + decode_typed(gs, &SplatJson::from_value(bs)?.stream()).map_err(|r| r.detail) +} + /// Dependency-free base64 decode (standard alphabet) — the rANS bytes ride in JSON as /// base64, same as `int_audio` / `int_video`. fn b64_decode(s: &str) -> Result, &'static str> { diff --git a/wai/wai-rs/src/int_synth.rs b/wai/wai-rs/src/int_synth.rs index 0c5dcd329..916f2fb3b 100644 --- a/wai/wai-rs/src/int_synth.rs +++ b/wai/wai-rs/src/int_synth.rs @@ -244,6 +244,14 @@ pub fn decode(payload: &[u8], model_path: &Path) -> Result Result { + decode_from_spec(payload, spec_json) +} + /// Decode an int_synth payload against the model spec supplied as bytes (the /// `int_synth.json` contents). No filesystem, no float, no ONNX — the /// integer-exact decode a browser / WASM sink runs directly, host-supplied @@ -256,12 +264,16 @@ pub fn decode_from_spec(payload: &[u8], spec_json: &[u8]) -> Result usize { v[k].as_u64().unwrap_or(0) as usize } +/// The most elements one decoded frame may hold (`h·w·3`), the same on every target. +/// +/// cbindgen:ignore +pub const MAX_FRAME: usize = 1 << 26; +/// The most bytes a whole decoded clip may hold (`n_frames·h·w·3`). +/// +/// cbindgen:ignore +pub const MAX_CLIP: usize = 1 << 30; +/// The refusal [`decode_typed`] and [`decode_pframes_typed`] give when the residual +/// stream runs out before the clip's last symbol. +/// +/// cbindgen:ignore +pub const STREAM_EXHAUSTED: &str = "the residual stream ended before its last symbol"; + +/// A clip's coded form, typed: what the `WIV1` payload carries (and what the tool's +/// JSON form carries, read by [`VideoJson`]). Both decode entry points take it. +/// +/// `mvs` holds frames `1..n_frames` in order, each its block grid row-major +/// (`⌈h/block⌉·⌈w/block⌉` entries), as `(dy, dx)` in units of `1/mv_precision` pel. +#[derive(Debug, Clone, Copy)] +pub struct VideoStream<'a> { + pub n_frames: usize, + pub h: usize, + pub w: usize, + pub block: usize, + pub q: i64, + pub offset: i32, + /// `1` whole-pel, `2` half-pel. + pub mv_precision: u8, + pub cdf: &'a [i32], + pub mvs: &'a [(i16, i16)], + pub rans: &'a [u8], +} + +/// The tool's JSON bitstream (`tools/wai_int_video_*encode.py`), read into the typed +/// form. The JSON form is a converter input and a debugging aid; the payload a +/// capability carries is `WIV1` (`extensions/integer-payloads.md`). What the old JSON +/// decoder left implicit is made explicit here, so a JSON bitstream and the `WIV1` +/// converted from it decode alike: an absent `mv_precision` is 1 (only 1 and 2 are +/// read); `mvs` has one list per frame and the first is empty; a frame's list shorter +/// than its block grid is padded with `(0, 0)`, the vector the old decoder read for a +/// missing block; a vector outside `i16`, or a list longer than the grid, is refused. +/// `rmax` is not read. +#[derive(Debug, Clone)] +pub struct VideoJson { + pub n_frames: usize, + pub h: usize, + pub w: usize, + pub block: usize, + pub q: i64, + pub offset: i32, + pub mv_precision: u8, + pub cdf: Vec, + pub mvs: Vec<(i16, i16)>, + pub rans: Vec, +} + +impl VideoJson { + /// Read the tool's JSON bitstream. + pub fn from_value(bs: &Value) -> Result { + Self::read(bs, None) + } + + /// [`Self::from_value`] at the geometry `(h, w)` instead of the bitstream's own + /// (the P-frames of a clip whose I-frame came from elsewhere). + fn read(bs: &Value, geometry: Option<(usize, usize)>) -> Result { + let (block, nf) = (u(bs, "block"), u(bs, "n_frames")); + let (h, w) = geometry.unwrap_or((u(bs, "h"), u(bs, "w"))); + if block == 0 { + return Err("block is zero"); + } + let q = bs["q"].as_i64().ok_or("q")?; + let offset = bs["offset"].as_i64().and_then(|o| i32::try_from(o).ok()).ok_or("offset")?; + let cdf = bs["cdf"].as_array().ok_or("cdf")?.iter() + .map(|n| n.as_i64().and_then(|v| i32::try_from(v).ok())) + .collect::>>().ok_or("cdf holds a value outside i32")?; + let mv_precision = match bs.get("mv_precision") { + None => 1, + Some(p) => match p.as_u64() { + Some(1) => 1, + Some(2) => 2, + _ => return Err("mv_precision is neither 1 nor 2"), + }, + }; + let lists = bs["mvs"].as_array().ok_or("mvs")?; + if lists.len() != nf { + return Err("mvs has not one list per frame"); + } + if lists.first().and_then(Value::as_array).is_some_and(|l| !l.is_empty()) { + return Err("the first frame carries motion vectors"); + } + let nb = h.div_ceil(block) * w.div_ceil(block); + let mut mvs = Vec::new(); + for fr in lists.iter().skip(1) { + let list = fr.as_array().map(Vec::as_slice).unwrap_or_default(); + if list.len() > nb { + return Err("a frame carries more motion vectors than blocks"); + } + for m in list { + let comp = |i: usize| m.get(i).and_then(Value::as_i64).and_then(|v| i16::try_from(v).ok()); + mvs.push((comp(0).ok_or("a motion vector is outside i16")?, comp(1).ok_or("a motion vector is outside i16")?)); + } + mvs.resize(mvs.len() + nb - list.len(), (0, 0)); + } + let rans = b64_decode(bs["rans_b64"].as_str().ok_or("rans_b64")?)?; + Ok(VideoJson { n_frames: nf, h, w, block, q, offset, mv_precision, cdf, mvs, rans }) + } + + /// The typed form, borrowing this one's tables. + pub fn stream(&self) -> VideoStream<'_> { + VideoStream { + n_frames: self.n_frames, h: self.h, w: self.w, block: self.block, q: self.q, offset: self.offset, + mv_precision: self.mv_precision, cdf: &self.cdf, mvs: &self.mvs, rans: &self.rans, + } + } +} + /// Predict one channel of one pixel from the reference with a motion vector /// `(dy, dx)` in `prec`-th-pixel units (1 = whole-pel, 2 = half-pel). Half-pel /// positions are bilinearly interpolated with integer rounding — the byte-exact @@ -110,100 +230,122 @@ fn predict(rf: &[i64], y: usize, x: usize, dy: i64, dx: i64, c: usize, prec: i64 } } -/// Decode an integer inter-frame bitstream (`int_video_bitstream.json`) → frames, -/// byte-exact on every machine. Pure i64. -pub fn decode(bs: &Value) -> Result { - let (block, q, h, w, nf) = (u(bs, "block"), bs["q"].as_i64().ok_or("q")?, u(bs, "h"), u(bs, "w"), u(bs, "n_frames")); - let offset = bs["offset"].as_i64().ok_or("offset")? as i32; - let cdf: Vec = bs["cdf"].as_array().ok_or("cdf")?.iter().map(|n| n.as_i64().unwrap() as i32).collect(); - let cdf_len = cdf.len(); - let rans = b64_decode(bs["rans_b64"].as_str().ok_or("rans_b64")?)?; - // mvs[f][blk] = [dy, dx] - let mvs: Vec> = bs["mvs"].as_array().ok_or("mvs")?.iter().map(|fr| { - fr.as_array().map(|blks| { - blks.iter().map(|m| { - let a = m.as_array().unwrap(); - (a[0].as_i64().unwrap(), a[1].as_i64().unwrap()) - }).collect() - }).unwrap_or_default() - }).collect(); - - let nbx = w.div_ceil(block); - let prec = bs["mv_precision"].as_i64().unwrap_or(1); // 1 whole-pel, 2 half-pel - let mut dec = RansDecoder::new(&rans)?; - let mut frames: Vec> = Vec::with_capacity(nf); - let mut prev = vec![0i64; h * w * 3]; // previous reconstructed frame (HWC, 0..255) - for f in 0..nf { - let mut frame = vec![0i64; h * w * 3]; - let mvs_f = mvs.get(f); - for y in 0..h { - for x in 0..w { - // motion vector for this block (P-frames only) - let (dy, dx) = if f == 0 { - (0, 0) - } else { - mvs_f.and_then(|m| m.get((y / block) * nbx + (x / block)).copied()).unwrap_or((0, 0)) - }; - let base = (y * w + x) * 3; - for c in 0..3 { - let pred = if f == 0 { 128 } else { predict(&prev, y, x, dy, dx, c, prec, h, w) }; - let r = dec.decode(&cdf, cdf_len, offset) as i64; // residual symbol - frame[base + c] = (pred + r * q).clamp(0, 255); - } - } - } - frames.push(frame.iter().map(|&v| v as u8).collect()); - prev = frame; +/// The checks both decode entry points make before allocating a frame: every field +/// in range, the CDF a table the rANS decoder is total over, the motion-vector count +/// the block grid's, and the frame and clip sizes under their caps, all formed with +/// checked arithmetic. Returns the frame's element count. +fn check(s: &VideoStream<'_>) -> Result { + if s.n_frames == 0 || s.h == 0 || s.w == 0 || s.block == 0 { + return Err("n_frames, h, w or block is zero"); } - Ok(DecodedClip { frames, h, w }) + if !(1..=65_535).contains(&s.q) { + return Err("q is outside 1..=65535"); + } + if !matches!(s.mv_precision, 1 | 2) { + return Err("mv_precision is neither 1 nor 2"); + } + let c = s.cdf; + if c.len() < 3 || c.len() > 4097 || c[0] != 0 || c[c.len() - 1] != 1 << 16 || c.windows(2).any(|p| p[0] >= p[1]) { + return Err("the CDF is not a table from 0 to 65536, strictly increasing, of 3..=4097 entries"); + } + let frame = s.h.checked_mul(s.w).and_then(|v| v.checked_mul(3)).filter(|&n| n <= MAX_FRAME) + .ok_or("a frame exceeds 2^26 elements")?; + frame.checked_mul(s.n_frames).filter(|&n| n <= MAX_CLIP).ok_or("the clip exceeds 2^30 bytes")?; + let nb = s.h.div_ceil(s.block) * s.w.div_ceil(s.block); + if (s.n_frames - 1).checked_mul(nb) != Some(s.mvs.len()) { + return Err("the motion vectors are not one per block for frames 1..n_frames"); + } + Ok(frame) } -/// Decode the **P-frames** of a clip on top of an already-decoded I-frame -/// (`i_frame`, RGB HWC `u8`) — the integer block-MC + quantized residual loop, -/// seeded with frame 0 from any source. The NEURAL-KEYFRAME video composes this +/// One P-frame (or, from a grey `prev` of `None`, the I-frame): read its residual +/// symbols (raster `y, x, c`) and add them to the prediction. `r·q` is under `2^47` +/// in magnitude (the decoder saturates a symbol to `i32`, `q ≤ 65535`), so the sum +/// fits `i64`. +fn frame(s: &VideoStream<'_>, dec: &mut RansDecoder<'_>, prev: Option<&[i64]>, mvs: &[(i16, i16)]) -> Vec { + let (h, w, block) = (s.h, s.w, s.block); + let nbx = w.div_ceil(block); + let prec = s.mv_precision as i64; + let mut out = vec![0i64; h * w * 3]; + for y in 0..h { + for x in 0..w { + let (dy, dx) = mvs.get((y / block) * nbx + (x / block)).map_or((0, 0), |&(a, b)| (a as i64, b as i64)); + let base = (y * w + x) * 3; + for c in 0..3 { + let pred = match prev { + None => 128, + Some(rf) => predict(rf, y, x, dy, dx, c, prec, h, w), + }; + let r = dec.decode(s.cdf, s.cdf.len(), s.offset) as i64; // residual symbol + out[base + c] = (pred + r * s.q).clamp(0, 255); + } + } + } + out +} + +/// Decode a typed clip whose I-frame is predicted from grey (`WIV1` keyframe 0, +/// `wai.video.int_motion`) → frames, byte-exact on every machine. Refuses, rather +/// than panicking on or misreading, a stream that breaks [`check`]'s rules or runs +/// out before its last symbol ([`STREAM_EXHAUSTED`]). Pure i64. +pub fn decode_typed(s: &VideoStream<'_>) -> Result { + check(s)?; + let nb = s.h.div_ceil(s.block) * s.w.div_ceil(s.block); + let mut dec = RansDecoder::new(s.rans)?; + let mut frames: Vec> = Vec::with_capacity(s.n_frames); + let mut prev = frame(s, &mut dec, None, &[]); + frames.push(prev.iter().map(|&v| v as u8).collect()); + for f in 1..s.n_frames { + let cur = frame(s, &mut dec, Some(&prev), &s.mvs[(f - 1) * nb..f * nb]); + frames.push(cur.iter().map(|&v| v as u8).collect()); + prev = cur; + } + if dec.is_exhausted() { + return Err(STREAM_EXHAUSTED); + } + Ok(DecodedClip { frames, h: s.h, w: s.w }) +} + +/// Decode the **P-frames** of a typed clip on top of an already-decoded I-frame +/// (`i_frame`, RGB HWC `u8`, `h·w·3` bytes at the stream's `h × w`) — `WIV1` +/// keyframe 1 (`wai.video.int_hyper`), whose I-frame is the nested `WIH1`'s decode. +/// The residual stream holds frames `1..n_frames` only. Pure i64. +pub fn decode_pframes_typed(i_frame: &[u8], s: &VideoStream<'_>) -> Result { + let n = check(s)?; + if i_frame.len() != n { + return Err("i_frame size disagrees with h·w·3"); + } + let nb = s.h.div_ceil(s.block) * s.w.div_ceil(s.block); + let mut dec = RansDecoder::new(s.rans)?; + let mut frames: Vec> = Vec::with_capacity(s.n_frames); + frames.push(i_frame.to_vec()); + let mut prev: Vec = i_frame.iter().map(|&b| b as i64).collect(); + for f in 1..s.n_frames { + let cur = frame(s, &mut dec, Some(&prev), &s.mvs[(f - 1) * nb..f * nb]); + frames.push(cur.iter().map(|&v| v as u8).collect()); + prev = cur; + } + if dec.is_exhausted() { + return Err(STREAM_EXHAUSTED); + } + Ok(DecodedClip { frames, h: s.h, w: s.w }) +} + +/// Decode the tool's JSON bitstream (`int_video_bitstream.json`) → frames, byte-exact +/// on every machine: [`VideoJson`] then [`decode_typed`]. A debugging and converter +/// path; the payload is `WIV1`. Pure i64. +pub fn decode(bs: &Value) -> Result { + decode_typed(&VideoJson::from_value(bs)?.stream()) +} + +/// Decode the **P-frames** of a JSON bitstream on top of an already-decoded I-frame +/// (`i_frame`, RGB HWC `u8`) at `h × w` — the integer block-MC + quantized residual +/// loop, seeded with frame 0 from any source. The NEURAL-KEYFRAME video composes this /// with the learned `int_hyper` I-frame decode (`int_hyper_synth::decode_conditional` /// → frame 0): a learned, byte-exact keyframe + integer inter-frames, every frame /// reproducible by hash. Pure i64. `bs`'s `n_frames` counts the I-frame; `mvs[0]` -/// is empty; the rANS stream holds only frames `1..n_frames`'s residuals. +/// is empty; the rANS stream holds only frames `1..n_frames`'s residuals. A debugging +/// and converter path; the payload is `WIV1` with keyframe 1. pub fn decode_pframes(i_frame: &[u8], h: usize, w: usize, bs: &Value) -> Result { - let (block, q, nf) = (u(bs, "block"), bs["q"].as_i64().ok_or("q")?, u(bs, "n_frames")); - if i_frame.len() != h * w * 3 { - return Err("i_frame size disagrees with h·w·3"); - } - let offset = bs["offset"].as_i64().ok_or("offset")? as i32; - let cdf: Vec = bs["cdf"].as_array().ok_or("cdf")?.iter().map(|n| n.as_i64().unwrap() as i32).collect(); - let cdf_len = cdf.len(); - let rans = b64_decode(bs["rans_b64"].as_str().ok_or("rans_b64")?)?; - let mvs: Vec> = bs["mvs"].as_array().ok_or("mvs")?.iter().map(|fr| { - fr.as_array().map(|blks| { - blks.iter().map(|m| { - let a = m.as_array().unwrap(); - (a[0].as_i64().unwrap(), a[1].as_i64().unwrap()) - }).collect() - }).unwrap_or_default() - }).collect(); - - let nbx = w.div_ceil(block); - let prec = bs["mv_precision"].as_i64().unwrap_or(1); - let mut dec = RansDecoder::new(&rans)?; - let mut frames: Vec> = Vec::with_capacity(nf); - frames.push(i_frame.to_vec()); - let mut prev: Vec = i_frame.iter().map(|&b| b as i64).collect(); - for mvs_f in mvs.iter().take(nf).skip(1) { - let mut frame = vec![0i64; h * w * 3]; - for y in 0..h { - for x in 0..w { - let (dy, dx) = mvs_f.get((y / block) * nbx + (x / block)).copied().unwrap_or((0, 0)); - let base = (y * w + x) * 3; - for c in 0..3 { - let pred = predict(&prev, y, x, dy, dx, c, prec, h, w); - let r = dec.decode(&cdf, cdf_len, offset) as i64; - frame[base + c] = (pred + r * q).clamp(0, 255); - } - } - } - frames.push(frame.iter().map(|&v| v as u8).collect()); - prev = frame; - } - Ok(DecodedClip { frames, h, w }) + decode_pframes_typed(i_frame, &VideoJson::read(bs, Some((h, w)))?.stream()) } -- 2.54.0 (Apple Git-157) From b4d10ea2f329befa25b6c126322539be791221ac Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 12:20:06 -0400 Subject: [PATCH 03/34] =?UTF-8?q?wai-rs:=20int=5Fpayload=20=E2=80=94=20the?= =?UTF-8?q?=20WIV1,=20WIH1,=20WIA1=20and=20WIS1=20payloads,=20and=20decode?= =?UTF-8?q?=5Fint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payloads of the WAI-native integer codecs get binary formats: WIV1 for wai.video.int_motion (keyframe 0) and wai.video.int_hyper (keyframe 1, a nested WIH1), WIH1 for wai.neural.int_hyper, WIA1 for wai.audio.int_codec (with its sample rate) and WIS1 for wai.splat.int_codec (with each attribute's presentation range). codecs::PINNED_INT_PAYLOADS maps each string to its format's magic; it is the one table int_payload and the framing check read. int_payload::wire parses and writes the four formats. It is self-contained, so the cross-architecture crate can include it. Every size is formed in u64 and capped alike on every target. A refusal is a code from a closed list (Refusal::code): bad_magic, which a JSON form gets, format_capability_mismatch, keyframe_capability_mismatch, version, reserved_nonzero, field_range, too_large, cdf_malformed, stream_length, nested_length, trailing, stream_exhausted, geometry_mismatch, model_mismatch, set_incomplete, not_dispatchable, unsupported_capability and payload_malformed. int_payload::decode_int decodes every integer capability, these five and wai.neural.int_synth and wai.neural.int_mlicpp, from parameter files held in memory (IntParams), with no ONNX runtime and no filesystem, and only through a dispatchable capability. int_payload::resolve verifies a pin over sets held in memory with pin::verify_bytes_with, and runs SPEC §3.1's check 5 over the set an unpinned decode reads, under every digest a pin over it could name. The converters wiv1_from_json, wih1_from_json, wia1_from_json and wis1_from_json turn the tools' JSON into payloads and parse what they write. wai.audio.int_codec and wai.splat.int_codec are registered with sets of one file (model.wam, g_s.whm): each decoder reads one file that holds every parameter. container::check_int_payload_framing and the Wai and WaiMulti methods check a payload's magic and version without neural_int. wai_int_payload_vectors writes each committed JSON vector's payload beside it, refusal fixtures derived from those payloads, and the index int-payload-conformance/vectors.json; `verify` regenerates them byte for byte, decodes each payload to its sidecar's SHA-256 and checks each fixture's code. int-payload-conformance/int_payload_verify.py checks the same corpus independently, in Python's standard library. Co-Authored-By: Claude Opus 5.5 --- wai/.gitattributes | 6 + wai/int-payload-conformance/.gitattributes | 2 + wai/int-payload-conformance/README.md | 67 ++ .../int_payload_verify.py | 498 ++++++++++++ .../refusals/wia1_afrac_model.bin | Bin 0 -> 1444 bytes .../refusals/wia1_afrac_zero.bin | Bin 0 -> 1444 bytes .../refusals/wia1_cdf_malformed.bin | Bin 0 -> 1444 bytes .../refusals/wia1_exhausted.bin | Bin 0 -> 1068 bytes .../refusals/wia1_median_range.bin | Bin 0 -> 1444 bytes .../refusals/wia1_sr_zero.bin | Bin 0 -> 1444 bytes .../refusals/wia1_t_out.bin | Bin 0 -> 1444 bytes .../refusals/wia1_too_large.bin | Bin 0 -> 1444 bytes .../refusals/wia1_trailing.bin | Bin 0 -> 1445 bytes .../refusals/wia1_under_splat.bin | Bin 0 -> 1444 bytes .../refusals/wih1_exhausted.bin | Bin 0 -> 56 bytes .../refusals/wih1_geometry.bin | Bin 0 -> 88 bytes .../refusals/wih1_model_mismatch.bin | Bin 0 -> 88 bytes .../refusals/wih1_reserved.bin | Bin 0 -> 88 bytes .../refusals/wih1_stream_length.bin | Bin 0 -> 88 bytes .../refusals/wih1_too_large.bin | Bin 0 -> 88 bytes .../refusals/wih1_trailing.bin | Bin 0 -> 89 bytes .../refusals/wih1_under_video.bin | Bin 0 -> 88 bytes .../refusals/wih1_zero_dim.bin | Bin 0 -> 88 bytes .../refusals/wis1_afrac_model.bin | Bin 0 -> 2964 bytes .../refusals/wis1_cdf_len.bin | Bin 0 -> 2964 bytes .../refusals/wis1_exhausted.bin | Bin 0 -> 2236 bytes .../refusals/wis1_geometry.bin | Bin 0 -> 2956 bytes .../refusals/wis1_n_gaussians.bin | Bin 0 -> 2964 bytes .../refusals/wis1_range_nan.bin | Bin 0 -> 2964 bytes .../refusals/wis1_range_order.bin | Bin 0 -> 2964 bytes .../refusals/wis1_trailing.bin | Bin 0 -> 2965 bytes .../refusals/wiv1_cdf_malformed.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_exhausted.bin | Bin 0 -> 980 bytes .../refusals/wiv1_geometry.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_keyframe_mismatch.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_mv_precision.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_nested_length.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_nested_version.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_q_zero.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_reserved.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_stream_length.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_too_large.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_trailing.bin | Bin 0 -> 17985 bytes .../refusals/wiv1_truncated.bin | Bin 0 -> 2058 bytes .../refusals/wiv1_under_audio.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_version.bin | Bin 0 -> 2148 bytes wai/int-payload-conformance/vectors.json | 578 ++++++++++++++ wai/wai-rs/Cargo.toml | 18 + wai/wai-rs/src/bin/wai_int_payload_vectors.rs | 413 ++++++++++ wai/wai-rs/src/codecs/mod.rs | 50 +- wai/wai-rs/src/container.rs | 65 ++ wai/wai-rs/src/int_payload/mod.rs | 521 ++++++++++++ wai/wai-rs/src/int_payload/tests.rs | 482 +++++++++++ wai/wai-rs/src/int_payload/wire.rs | 752 ++++++++++++++++++ wai/wai-rs/src/lib.rs | 9 + wai/wai-rs/tests/int_kernels_vectors.rs | 1 + wai/wai-rs/tests/int_payload.rs | 138 ++++ wai/wai-rs/tests/int_payload_preflight.rs | 116 +++ .../vectors/int_audio/audio_bitstream.wia1 | Bin 0 -> 1444 bytes .../vectors/int_hyper/tiny_bitstream.wih1 | Bin 0 -> 88 bytes wai/wai-rs/tests/vectors/int_splat/splat.wis1 | Bin 0 -> 2964 bytes .../int_video/int_video_bitstream.wiv1 | Bin 0 -> 17984 bytes .../vectors/int_video/video_nk_bitstream.wiv1 | Bin 0 -> 2148 bytes .../int_video/video_real_bitstream.wiv1 | Bin 0 -> 29428 bytes 64 files changed, 3703 insertions(+), 13 deletions(-) create mode 100644 wai/int-payload-conformance/.gitattributes create mode 100644 wai/int-payload-conformance/README.md create mode 100644 wai/int-payload-conformance/int_payload_verify.py create mode 100644 wai/int-payload-conformance/refusals/wia1_afrac_model.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_afrac_zero.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_cdf_malformed.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_exhausted.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_median_range.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_sr_zero.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_t_out.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_too_large.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_trailing.bin create mode 100644 wai/int-payload-conformance/refusals/wia1_under_splat.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_exhausted.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_geometry.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_model_mismatch.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_reserved.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_stream_length.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_too_large.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_trailing.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_under_video.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_zero_dim.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_afrac_model.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_cdf_len.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_exhausted.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_geometry.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_n_gaussians.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_range_nan.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_range_order.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_trailing.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_cdf_malformed.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_exhausted.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_geometry.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_keyframe_mismatch.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_mv_precision.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_nested_length.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_nested_version.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_q_zero.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_reserved.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_stream_length.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_too_large.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_trailing.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_truncated.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_under_audio.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_version.bin create mode 100644 wai/int-payload-conformance/vectors.json create mode 100644 wai/wai-rs/src/bin/wai_int_payload_vectors.rs create mode 100644 wai/wai-rs/src/int_payload/mod.rs create mode 100644 wai/wai-rs/src/int_payload/tests.rs create mode 100644 wai/wai-rs/src/int_payload/wire.rs create mode 100644 wai/wai-rs/tests/int_payload.rs create mode 100644 wai/wai-rs/tests/int_payload_preflight.rs create mode 100644 wai/wai-rs/tests/vectors/int_audio/audio_bitstream.wia1 create mode 100644 wai/wai-rs/tests/vectors/int_hyper/tiny_bitstream.wih1 create mode 100644 wai/wai-rs/tests/vectors/int_splat/splat.wis1 create mode 100644 wai/wai-rs/tests/vectors/int_video/int_video_bitstream.wiv1 create mode 100644 wai/wai-rs/tests/vectors/int_video/video_nk_bitstream.wiv1 create mode 100644 wai/wai-rs/tests/vectors/int_video/video_real_bitstream.wiv1 diff --git a/wai/.gitattributes b/wai/.gitattributes index 9eb1db2f4..6cb0347ff 100644 --- a/wai/.gitattributes +++ b/wai/.gitattributes @@ -1,3 +1,9 @@ # REGISTRY.md carries a table `wai registry export` generates, which a library test # compares byte for byte (as registry/ and vocab/v2.* are): no line-ending conversion. REGISTRY.md -text +# The binary payloads of the integer codecs (extensions/integer-payloads.md) beside +# their JSON sources: pinned by SHA-256, so never converted. +*.wiv1 -text +*.wih1 -text +*.wia1 -text +*.wis1 -text diff --git a/wai/int-payload-conformance/.gitattributes b/wai/int-payload-conformance/.gitattributes new file mode 100644 index 000000000..eff1df73a --- /dev/null +++ b/wai/int-payload-conformance/.gitattributes @@ -0,0 +1,2 @@ +# Hashed files: a line-ending conversion on checkout would change every digest. +* -text diff --git a/wai/int-payload-conformance/README.md b/wai/int-payload-conformance/README.md new file mode 100644 index 000000000..299f89cb9 --- /dev/null +++ b/wai/int-payload-conformance/README.md @@ -0,0 +1,67 @@ +# int-payload-conformance — the integer payloads + +Vectors for the payload formats SPEC §5 pins to the WAI-native integer codecs +([`extensions/integer-payloads.md`](../extensions/integer-payloads.md)): `WIV1` for +`wai.video.int_motion` (keyframe 0) and `wai.video.int_hyper` (keyframe 1), `WIH1` +for `wai.neural.int_hyper`, `WIA1` for `wai.audio.int_codec` and `WIS1` for +`wai.splat.int_codec`. + +**Code-equivalence.** A conforming sink decodes every payload to the output digests +its vector states, and refuses every fixture with the code it states, from the +extension's closed list (§8). + +- `vectors.json` — the index. For each payload: its capability, its path, its + SHA-256, the JSON it is converted from, the parameter set it decodes against (each + member's name in the set, and the committed file that holds it), and the + `sha256.json` sidecar that states its decoded output's SHA-256. For each refusal + fixture: its capability, its path, its code, and whether parsing alone refuses it + (`parse`) or the decode does (`decode`). Paths are relative to `wai/`. +- The payloads sit beside their JSON sources under + [`../wai-rs/tests/vectors/`](../wai-rs/tests/vectors/): `int_video/*.wiv1`, + `int_hyper/tiny_bitstream.wih1`, `int_audio/audio_bitstream.wia1` and + `int_splat/splat.wis1`. Their sidecars are the ones the JSON forms' decodes are + checked against: a payload and its JSON source decode to the same bytes. +- `refusals/*.bin` — each a committed payload changed in one place (a byte, a + field, a truncation), so that it is refused with one code. The fixtures for + `bad_magic` are the JSON sources themselves: a tool's JSON output is a converter + input, never a payload. + +Files are compared as bytes; `.gitattributes` turns off line-ending conversion. + +**Generation.** Every file here, and every payload under `tests/vectors/`, is +written by one generator from the committed JSON alone, so it can be rebuilt and +compared: + + cd wai/wai-rs + cargo run --no-default-features --features video_int,audio_int,splat_int \ + --bin wai_int_payload_vectors -- gen ../int-payload-conformance + +**Verifiers.** + +- `wai_int_payload_vectors verify ../int-payload-conformance` (wai-rs) regenerates + every file and requires it to equal the committed one byte for byte, decodes every + payload through `int_payload::decode_int` to its sidecar's SHA-256, and requires + every fixture to be refused with its code at its layer. `tests/int_payload.rs` runs + the fixtures again in both build profiles, refuses every strict prefix of every + payload, and runs a sweep of single-byte changes in which nothing may panic; + `tests/int_payload_preflight.rs` checks that a header past the caps or past what the + parameter set can fit is refused before anything sized from it is allocated. +- `int_payload_verify.py`, Python standard library only and sharing no code with + wai-rs, written from the extension: it parses every payload with the extension's + rules and check order, converts every JSON source independently and requires the + committed bytes, decodes the `WIV1` payloads with keyframe 0 in full (the rANS + decoder with its bypass escape, whole-pel and half-pel motion compensation) to + their sidecars' SHA-256, and checks every fixture: a `parse` fixture's code, and + that a `decode` fixture parses (a `WIV1` keyframe-0 one is decoded and must run out + of its stream). + + python3 int-payload-conformance/int_payload_verify.py int-payload-conformance + +**Not decoded independently.** The Python verifier does not decode `WIH1`, `WIV1` +with keyframe 1 (its keyframe is a `WIH1`), `WIA1` or `WIS1`: their decodes run +integer networks it does not implement. Their outputs are checked by wai-rs against +the sidecars above, and the `WIV1` and `WIH1` payloads also by the +cross-architecture goldens in +[`../byte-exact-conformance/`](../byte-exact-conformance/). For their `decode` +fixtures the Python verifier checks that they parse; their codes are checked by +wai-rs. diff --git a/wai/int-payload-conformance/int_payload_verify.py b/wai/int-payload-conformance/int_payload_verify.py new file mode 100644 index 000000000..d8ca3f8df --- /dev/null +++ b/wai/int-payload-conformance/int_payload_verify.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +"""Independent verifier for the integer payload corpus (extensions/integer-payloads.md). + +Python standard library only; it shares no code with wai-rs. Written from the +extension's text, it: + + - parses every committed payload (WIV1, WIH1, WIA1, WIS1) with the rules and in the + check order of the extension's §2-§6, and requires each to parse; + - converts each payload's JSON source independently (§10) and requires the result to + equal the committed payload byte for byte, and the payload's SHA-256 to be the one + the index states; + - decodes the WIV1 payloads with keyframe 0 in full - the rANS decoder with its + bypass escape, whole-pel and half-pel motion compensation - and requires every + frame's SHA-256 and the clip's to be the ones the vector's sha256.json states; + - parses every refusal fixture and requires a parse-layer fixture to be refused with + its code, and a decode-layer fixture to parse; a decode-layer fixture of WIV1 + keyframe 0 is decoded and must run out of its stream (stream_exhausted). + +Not decoded independently: WIH1, WIV1 with keyframe 1 (its keyframe is a WIH1), WIA1 +and WIS1, whose decodes run integer networks this script does not implement. Their +decoded outputs are checked by wai-rs against the same sidecars, on every leg of the +cross-architecture matrix for WIV1 and WIH1. The decode-layer fixtures of those +formats are checked here to parse, and by wai-rs for their codes. + + python3 int-payload-conformance/int_payload_verify.py int-payload-conformance + +Exit status 1 on any failure. +""" +import base64 +import hashlib +import json +import math +import os +import struct +import sys + +MAGICS = (b"WIV1", b"WIH1", b"WIA1", b"WIS1") +MAX_TENSOR = 1 << 26 +MAX_CLIP = 1 << 30 +MAX_MEDIAN_Q = 1 << 47 +CAP_MAGIC = { + "wai.video.int_motion": b"WIV1", + "wai.video.int_hyper": b"WIV1", + "wai.neural.int_hyper": b"WIH1", + "wai.audio.int_codec": b"WIA1", + "wai.splat.int_codec": b"WIS1", +} + + +class Refused(Exception): + def __init__(self, code, why): + super().__init__(f"{code}: {why}") + self.code = code + + +class Reader: + def __init__(self, b, p): + self.b, self.p = b, p + + def left(self): + return len(self.b) - self.p + + def take(self, n): + if n > self.left(): + raise Refused("stream_length", "the payload ends inside a section") + s = self.b[self.p:self.p + n] + self.p += n + return s + + def u8(self): + return self.take(1)[0] + + def u16(self): + return struct.unpack("= b for a, b in zip(t, t[1:])): + raise Refused("cdf_malformed", "not strictly increasing") + + +def check_magic(p, want): + m = p[:4] + if len(m) == 4 and m == want: + return + if len(m) == 4 and m in MAGICS: + raise Refused("format_capability_mismatch", "another integer format") + raise Refused("bad_magic", "not the capability's magic") + + +def opening(p, magic, header): + check_magic(p, magic) + if len(p) < 5: + raise Refused("stream_length", "no version byte") + if p[4] != 1: + raise Refused("version", "a version this revision does not define") + if len(p) < header: + raise Refused("stream_length", "the payload ends inside its fixed header") + return Reader(p, 5) + + +def too_large(dims, cap): + n = 1 + for d in dims: + n *= d + if n > cap: + raise Refused("too_large", "over the cap") + + +def parse_wiv1(p, want_keyframe=None): + r = opening(p, b"WIV1", 36) + mvp, kf, res0 = r.u8(), r.u8(), r.u8() + n_frames, h, w, block, q = r.u32(), r.u32(), r.u32(), r.u32(), r.u32() + offset, cdf_len, res1 = r.i32(), r.u16(), r.u16() + if res0 or res1: + raise Refused("reserved_nonzero", "reserved") + if mvp not in (1, 2): + raise Refused("field_range", "mv_precision") + if kf > 1: + raise Refused("field_range", "keyframe") + if 0 in (n_frames, h, w): + raise Refused("field_range", "n_frames, h or w") + if block == 0: + raise Refused("field_range", "block") + if not 1 <= q <= 65535: + raise Refused("field_range", "q") + if not 3 <= cdf_len <= 4097: + raise Refused("field_range", "cdf_len") + if want_keyframe is not None and want_keyframe != kf: + raise Refused("keyframe_capability_mismatch", "keyframe") + too_large([h, w, 3], MAX_TENSOR) + too_large([n_frames, h, w, 3], MAX_CLIP) + cdf = r.cdf(cdf_len) + nb = -(-h // block) * -(-w // block) + raw = r.take((n_frames - 1) * nb * 4) + mvs = [struct.unpack_from(" MAX_MEDIAN_Q for x in med): + raise Refused("field_range", "median") + cdfs = [r.cdf(cdf_len) for _ in range(n_ch)] + rans = r.stream() + r.end() + return dict(afrac=afrac, sr=sr, n_ch=n_ch, lat_len=lat_len, t_out=t_out, offset=offset, + median_q=med, cdfs=cdfs, rans=rans) + + +def parse_wis1(p): + r = opening(p, b"WIS1", 32) + afrac, res = r.u8(), r.u16() + c, sh, sw, s, attr, n_g = (r.u32() for _ in range(6)) + if res: + raise Refused("reserved_nonzero", "reserved") + if not 1 <= afrac <= 30: + raise Refused("field_range", "afrac") + if 0 in (c, sh, sw, s, attr, n_g): + raise Refused("field_range", "a dimension") + if n_g > s * s: + raise Refused("field_range", "n_gaussians") + too_large([c, sh, sw], MAX_TENSOR) + too_large([attr, s, s], MAX_TENSOR) + raw = r.take(8 * attr) + ranges = [struct.unpack_from(" hi: + raise Refused("field_range", "a range") + lens = list(struct.unpack(f"<{c}H", r.take(2 * c))) + if any(not 3 <= n <= 4097 for n in lens): + raise Refused("field_range", "a CDF length") + offs = list(struct.unpack(f"<{c}i", r.take(4 * c))) + med = list(struct.unpack(f"<{c}q", r.take(8 * c))) + if any(abs(x) > MAX_MEDIAN_Q for x in med): + raise Refused("field_range", "median") + cdfs = [r.cdf(n) for n in lens] + rans = r.stream() + r.end() + return dict(afrac=afrac, c=c, sh=sh, sw=sw, s=s, attr=attr, n_gaussians=n_g, ranges=ranges, + cdfs=cdfs, offsets=offs, median_q=med, rans=rans) + + +def parse(cap, p): + m = CAP_MAGIC[cap] + if m == b"WIV1": + return parse_wiv1(p, 1 if cap == "wai.video.int_hyper" else 0) + return {b"WIH1": parse_wih1, b"WIA1": parse_wia1, b"WIS1": parse_wis1}[m](p) + + +# ---- writers: the JSON sources converted independently (extensions/integer-payloads.md §10) ---- + +def put_stream(b): + return struct.pack("= len(self.w): + self.exhausted = True + return + self.x = (self.x << 32) | self.w[self.i] + self.i += 1 + + def bits(self, n): + v = self.x & ((1 << n) - 1) + self.x >>= n + self.renorm() + return v + + def decode(self, cdf, offset): + n = len(cdf) + max_value = n - 2 + cum = self.x & 0xFFFF + lo, hi = 1, n - 1 + while lo < hi: + mid = (lo + hi) // 2 + if cdf[mid] > cum: + hi = mid + else: + lo = mid + 1 + s = lo - 1 + self.x = (cdf[s + 1] - cdf[s]) * (self.x >> 16) + (self.x & 0xFFFF) - cdf[s] + self.renorm() + value = s + if value == max_value: + val = self.bits(4) + count, groups = val, 0 + while val == 15 and groups < 8 and not self.exhausted: + val = self.bits(4) + count += val + groups += 1 + raw = 0 + for j in range(min(count, 8)): + raw |= self.bits(4) << (4 * j) + value = raw >> 1 + value = sat(-value - 1) if raw & 1 else sat(value + max_value) + return sat(value + offset) + + +def decode_wiv1_k0(v): + h, w, block, q, prec = v["h"], v["w"], v["block"], v["q"], v["mv_precision"] + nbx = -(-w // block) + nb = nbx * -(-h // block) + d = Rans(v["rans"]) + frames, prev = [], None + for f in range(v["n_frames"]): + mvs = v["mvs"][(f - 1) * nb:f * nb] if f else None + cur = [0] * (h * w * 3) + for y in range(h): + for x in range(w): + dy, dx = mvs[(y // block) * nbx + x // block] if f else (0, 0) + base = (y * w + x) * 3 + for c in range(3): + if f == 0: + pred = 128 + else: + def p(yy, xx): + return prev[(min(max(yy, 0), h - 1) * w + min(max(xx, 0), w - 1)) * 3 + c] + if prec == 1: + pred = p(y + dy, x + dx) + else: + iy, fy, ix, fx = y + (dy >> 1), dy & 1, x + (dx >> 1), dx & 1 + if not fy and not fx: + pred = p(iy, ix) + elif not fy: + pred = (p(iy, ix) + p(iy, ix + 1) + 1) >> 1 + elif not fx: + pred = (p(iy, ix) + p(iy + 1, ix) + 1) >> 1 + else: + pred = (p(iy, ix) + p(iy, ix + 1) + p(iy + 1, ix) + p(iy + 1, ix + 1) + 2) >> 2 + r = d.decode(v["cdf"], v["offset"]) + cur[base + c] = min(max(pred + r * q, 0), 255) + frames.append(bytes(cur)) + prev = cur + if d.exhausted: + raise Refused("stream_exhausted", "the residual stream ran out") + return frames + + +def main(cdir): + wai = os.path.normpath(os.path.join(cdir, "..")) + + def rd(rel): + with open(os.path.join(wai, rel), "rb") as f: + return f.read() + + def jl(rel): + return json.loads(rd(rel)) + + idx = jl("int-payload-conformance/vectors.json") + fails, decoded, parsed_only = [], 0, [] + for v in idx["vectors"]: + name, cap = v["name"], v["capability"] + p = rd(v["payload"]) + try: + fields = parse(cap, p) + except Refused as e: + fails.append(f"{name}: does not parse: {e}") + continue + if hashlib.sha256(p).hexdigest() != v["payload_sha256"]: + fails.append(f"{name}: payload SHA-256 differs from the index") + src = v["converted_from"] + j = jl(src["json"]) + m = CAP_MAGIC[cap] + if m == b"WIV1": + conv = wiv1_from_json(j, rd(src["keyframe"]) if "keyframe" in src else None) + elif m == b"WIH1": + conv = wih1_from_json(j) + elif m == b"WIA1": + conv = wia1_from_json(j, jl(src["sample_rate"])["sample_rate"]) + else: + conv = wis1_from_json(j) + if conv != p: + fails.append(f"{name}: the independent conversion of {src['json']} differs from the payload") + if m == b"WIV1" and fields["keyframe"] == 0: + frames = decode_wiv1_k0(fields) + side = jl(v["sidecar"])[name] + digests = [hashlib.sha256(f).digest() for f in frames] + if [d.hex() for d in digests] != side["frames_sha256"] or hashlib.sha256(b"".join(digests)).hexdigest() != side["clip_sha256"]: + fails.append(f"{name}: the independent decode differs from {v['sidecar']}") + else: + decoded += 1 + print(f"ok {name}: parsed, converted, decoded to its sidecar's SHA-256 ({len(frames)} frames)") + else: + parsed_only.append(name) + print(f"ok {name}: parsed and converted (not decoded independently)") + checked, decode_layer = 0, [] + for f in idx["refusals"]: + name, cap, code, layer = f["name"], f["capability"], f["code"], f["layer"] + p = rd(f["payload"]) + try: + fields = parse(cap, p) + got = None + except Refused as e: + got = e.code + if layer == "parse": + if got != code: + fails.append(f"{name}: parsed with {got}, want {code}") + continue + else: + if got is not None: + fails.append(f"{name}: refused at parse with {got}, want a decode-layer {code}") + continue + if CAP_MAGIC[cap] == b"WIV1" and fields["keyframe"] == 0: + try: + decode_wiv1_k0(fields) + fails.append(f"{name}: decoded, want {code}") + continue + except Refused as e: + if e.code != code: + fails.append(f"{name}: decode refused with {e.code}, want {code}") + continue + else: + decode_layer.append(name) + checked += 1 + print(f"ok {name}: {code}" + ("" if layer == "parse" or name not in decode_layer else " (parses; the code is the decode's)")) + print(f"vectors: {decoded} decoded independently; parsed and converted only: {', '.join(parsed_only)}") + print(f"refusal fixtures checked: {checked}; decode-layer codes left to wai-rs: {', '.join(decode_layer)}") + for x in fails: + print("FAIL", x) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__)))) diff --git a/wai/int-payload-conformance/refusals/wia1_afrac_model.bin b/wai/int-payload-conformance/refusals/wia1_afrac_model.bin new file mode 100644 index 0000000000000000000000000000000000000000..3c5391d10ae84d2fd4e0d6b6d4ea144f7b9a434c GIT binary patch literal 1444 zcmWIdbTnk-W?*QrV_*QDgAv!@i7#MKr`vp`tlJx<-&+rczfVG0aFqCItsMcd(r~<}+ z%TZt$p9hBZ9bnkK1Ez^z*u%4t6DTLiz)-IZG{=I0q4gk8-(3cVrq>J%jX!~MqwxSv zpY%>=42!6rt{Z@vsOlQqS5RJ(y!3lgw1vWlL`n8J*Y34%7F5kkY7mS_uRHnXUhIX~ z&YP0iTB~l=#^3p`WSXd;Q#j$B>e2^SHZs}seJh{C6YZMtj6tja@YzMrtb7i=y8h03 z?WgaV=7NU|T!qi(9q@3T?`%hIU zp1ml!_Rx{_J|(7^rzSt%rF-x|frKi9w5R&pchd9T%g_7p``3l)&$T}txiwRFh-XYL zf2zIhnla;2k7CZ&*wu$D!+Exru?XwFKX{DqT-c9B_FpHG_9dRa=ezQaLs^%i<}bcO z$7Abm+>-ftZ8!VR9T#0U&a6EUy;5yqdU4>)i~SZZRLZY!y2DkG$ihj@qlv>`VXnatVAtYj@hrsjguK E03S1mmH+?% literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wia1_afrac_zero.bin b/wai/int-payload-conformance/refusals/wia1_afrac_zero.bin new file mode 100644 index 0000000000000000000000000000000000000000..d06bc299057cb7a35563124f08770574e20cf12c GIT binary patch literal 1444 zcmWIdbTnjSU|?vlV_*;f(hN*MtiZtV>;M1%d>}r(!3UtlK!9v5$bBGZF#|CR5VHa? z8xYq3Rs9CyKS2B!i2ngGL?=iG0|PF7zkupSvOb{q8U6tSuvQQlhVl#y)p`sJRlxXf zISLHp^T4pa0}Q)&z%=m-dw4c-0_7wb80xiw=2$Q=v>pWNyUW1P^qPU8@h4DjG#4%qFoc7F=+K4KD+3dmCvD9*WX#M z{q#N4T=0;At1x@-6=f#fI_(M0UEg2cSJ!J>KlefPEPK^f$%J3aJO9ZY+O^aF@9sHe z$}-aov@Nw=v*O>ynjLM439RtSm-utKH`TQVQ7?PmYEQDgK{{c&aq0U7RD(?)0|Q70gv4gwaM1_!KEpp?0M-fu!%&`q zp<0iDp$ZuPEk}W2d>$CqcYtB=4wxo>!NL+XTpBrna*_-T_1ZvlEEpJC4+8bwWngG} z&A`z36DS9d15|zJ@i$z;6XeUmkX{%TQ9oTb06kZAjqNMQkCK;uPl~os_>d^cKIhuK z_RWH-c}Wd|5$Sa&-`tD65ZifEGFxlat=jlI|CLM=6?6(Gyi;BJ;L1iOd%kbwb9ka% z6P__>^&dXF=$Vzzp;y=6S+D){J=0w9kb$c(d+!xxCfz#i3C>;LU*1>OYg<3}LG>(q z)mF)bU&}lH$sO9Y)Bo@8Ic3T+(+spNwOzB~-^H38ZHWo2@XD9?bGqd5Z;484+wy{r zeGwPKcc#3&lAo0GyL$hr3dOS*CD$H0vfih}H1pKt$Gdb79w?AdWsvq%fBQ~)-h26Z zAAbM3Q2n{~rz5v!>JIUY$>mSAw_P)4T&BV22clQ1Ele*CJUX@LmZf8S zx!ftMiS_b|kF`t>vIJ~;x;Mr2%RE1~4M{f+Zs!wQDPp~wVbd+p1rtN;MD?242C literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wia1_exhausted.bin b/wai/int-payload-conformance/refusals/wia1_exhausted.bin new file mode 100644 index 0000000000000000000000000000000000000000..758a5fa3234c811651733b7d3da467247989c98e GIT binary patch literal 1068 zcmWIdbTnk-VPI&mV_*QDgAv!@i7#MKr`vp`tlJx<-&+rczfVG0aFqCItsMcd(r~<}+ z%TZt$p9hBZ9bnkK1Ez^z*u%4t6DTLiz)-IZG{=I0q4gk8-(3cVrq>J%jX!~MqwxSv YpY%>=42!6rt~r2drRo~nS5RI802E=&w*UYD literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wia1_median_range.bin b/wai/int-payload-conformance/refusals/wia1_median_range.bin new file mode 100644 index 0000000000000000000000000000000000000000..d932c5d597789c7aff0130e0f7be413714373c77 GIT binary patch literal 1444 zcmWIdbTnk-VPI&mV_*P8rxS;j*`6eds4K8!iPjj_Bq$?wQm+w%}Z(!j7YCL z`Q~2ih1kxUlG$3TZq>%$`LAS}sGw6g;hpNz2Uj*S+4FrXpTiUFn(&N4tN-xXMbE5! z4!yem&U)>q@0sR;hYVbW*?X@jGwIf8PjK$~{_?)MUfcS)52|O`tF}re{94}mPwvpJ zo&JA!&nZ)unP#AEsqLB-|1Q?-XiH3Bg;&1BpVK9ee@j$a+m;t}?2EVKOMO> zQ+J4GOfG+_z3rMY<5G`e&eqt~hb+T+wwAF7>%KpDjPG37k4E-iCzAFhp1$Y1@{L1T zm!jq`zC*`j>u%hV`FL$N`_CO0T{q6GJrKQ8ZDD$G;L)i?w=5mw%jHg4O{|w!e5_@9 zkR@Q#)4eICU*`F_ZAiLta66yaN)hYb44ZB-AGvH5I{S~j$CqcYtB{j)9@!7xwUMNJY&%6KYVu4Gb^7%udctdUi;~L zrn%rD16N`8-Yd#Xx^>zUoV&iiysxg;wtnt|>RI-xt&$19mUsS>JG5)3|KHtn%9LfM z8E9K-yJp3|i#0pi5))YAl`rw8if1oM zu03>Qy-$g0=Bde#cj+EHP#~eoAnmFC_MP;+_ww^T{Qh;J`g841M{dp39pV|2%b#j* zyJpO|)T5ZQHFotO%W$5pWh}zF?++g1I~Vq&k^R?+qjX>*W<6YndKo3E1>>Z;I)c zd46sil5QN_&L_4~#CkWwrd!NME?b4p{v+>rsiXF)GyBp%zFY#|&)S_fbE<1t0RZ~_ BhuHuC literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wia1_t_out.bin b/wai/int-payload-conformance/refusals/wia1_t_out.bin new file mode 100644 index 0000000000000000000000000000000000000000..4e839490218f76be8b5c4b0ec173b79b9cfc571b GIT binary patch literal 1444 zcmWIdbTnk-VPI&mV_*sEd~N)dl`WoptBg5ftUq| zS%H`hh--kVegp9zApQ%)|9}{x6Ql#^4-?-x+rNY)4RKEpp?0M-fu!%&`qp<0iD zp$ZuPEk}W2d>$BPcYtB{4wxo>VGqwnPN1A514F$w&>RZ}hSq~XeRmlcnqD(7H2wt2 zjm85webPIfF)X5fx^4hwqN;0bUqN|E^3v}~(H06H5+&K^T)Wr4Sx_}EsX;Izz3$|j zd$AW{J8w#6YpuFf8-M4&l4+uXPT_=is!JbS*~nzi_pN*mPqb^oGX|~x!)F&gv+_Cg z>iRqDwV%FcnhPE>a200ny`s#dTc*qeGo@KAvDw*(WdFMa5L%VkR z|J^;OOj%}{fwrZ#YgYWbShJ%oF@Y6c`4WFlmpuM0QE6>kUeK{G;$ryDl$TfXlX8An z?>|+cc=n>?+CxXy`;?ewo|^o4m+rv>1rn+Z(w^#X-$~DVFF)_Y?_U?HKiB?rBWIZrxx9^bc`>TJ7qPoUS9FBmgzy3 zfK5;LrkH-2=jXN|>BhnBd}1p_tamePy2X6tvQ_BpKk|;3I%=;vvoHPQ%O&vrtleod Ir@Dp}04idLm;e9( literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wia1_too_large.bin b/wai/int-payload-conformance/refusals/wia1_too_large.bin new file mode 100644 index 0000000000000000000000000000000000000000..7556093a48da364ff33c30603d981ced711355d9 GIT binary patch literal 1444 zcmWIdbTnk-VPI&mV*mn11_llwrNF@O>;M1%d>|ougAYK9fdJWBpsN^|fS4JGS%8=o zh}nR+2B_*c5dQ(5fcs_z$2-AL94^ghEsU;x$%0>eVSFAK)^~tm_YRmQeqj&KMoyrdBm+aeHqaak28PyyKz(-^7@A%)Ff{%I%8kYY zIDOJPoiQw;e!6Y|W~HiYY+pe+O7hb0NzoPx9}*?m=UltjzFAN;FR4K=BE9b9n|rYr zVmoh2W^1jwRU3cjzmjR9f==OtcdAPtT-nHE&-bl-4o|de!ZQY~{=;V%J+tyT^y>OM z>$RW0XPOHhGH?}U@4ceTq+6#w!MW@E%lqnjZR_VgsGeo7+A5jwYkB8CxkI~l`v2WM zr%YL9nt`^Zwrf`WyI8ZMEir)=UilJ#PM19XEm3K0TVBwyFXCeO&Xku|@{@9YSMNVn zp?LP9_763mpW>%IidN literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wia1_trailing.bin b/wai/int-payload-conformance/refusals/wia1_trailing.bin new file mode 100644 index 0000000000000000000000000000000000000000..343085bba85843f5833d371c27a0696dfac6fd69 GIT binary patch literal 1445 zcmWIdbTnk-VPI&mV_*QDgAv!@i7#MKr`vp`tlJx<-&+rczfVG0aFqCItsMcd(r~<}+ z%TZt$p9hBZ9bnkK1Ez^z*u%4t6DTLiz)-IZG{=I0q4gk8-(3cVrq>J%jX!~MqwxSv zpY%>=42!6rt{Z@vsOlQqS5RJ(y!3lgw1vWlL`n8J*Y34%7F5kkY7mS_uRHnXUhIX~ z&YP0iTB~l=#^3p`WSXd;Q#j$B>e2^SHZs}seJh{C6YZMtj6tja@YzMrtb7i=y8h03 z?WgaV=7NU|T!qi(9q@3T?`%hIU zp1ml!_Rx{_J|(7^rzSt%rF-x|frKi9w5R&pchd9T%g_7p``3l)&$T}txiwRFh-XYL zf2zIhnla;2k7CZ&*wu$D!+Exru?XwFKX{DqT-c9B_FpHG_9dRa=ezQaLs^%i<}bcO z$7Abm+>-ftZ8!VR9T#0U&a6EUy;5yqdU4>)i~SZZRLZY!y2DkG$ihj@qlv>`VXnatVAtYj@hrsjguK F0|2dQh?f8W literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wia1_under_splat.bin b/wai/int-payload-conformance/refusals/wia1_under_splat.bin new file mode 100644 index 0000000000000000000000000000000000000000..05d5af267979cb7b144e22f71b9c1a7798a47f22 GIT binary patch literal 1444 zcmWIdbTnk-VPI&mV_*QDgAv!@i7#MKr`vp`tlJx<-&+rczfVG0aFqCItsMcd(r~<}+ z%TZt$p9hBZ9bnkK1Ez^z*u%4t6DTLiz)-IZG{=I0q4gk8-(3cVrq>J%jX!~MqwxSv zpY%>=42!6rt{Z@vsOlQqS5RJ(y!3lgw1vWlL`n8J*Y34%7F5kkY7mS_uRHnXUhIX~ z&YP0iTB~l=#^3p`WSXd;Q#j$B>e2^SHZs}seJh{C6YZMtj6tja@YzMrtb7i=y8h03 z?WgaV=7NU|T!qi(9q@3T?`%hIU zp1ml!_Rx{_J|(7^rzSt%rF-x|frKi9w5R&pchd9T%g_7p``3l)&$T}txiwRFh-XYL zf2zIhnla;2k7CZ&*wu$D!+Exru?XwFKX{DqT-c9B_FpHG_9dRa=ezQaLs^%i<}bcO z$7Abm+>-ftZ8!VR9T#0U&a6EUy;5yqdU4>)i~SZZRLZY!y2DkG$ihj@qlv>`VXnatVAtYj@hrsjguK E03_0gmjD0& literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wih1_exhausted.bin b/wai/int-payload-conformance/refusals/wih1_exhausted.bin new file mode 100644 index 0000000000000000000000000000000000000000..524fef59dd8d216ad26a888b13b8d023c78ae106 GIT binary patch literal 56 qcmWId^e|*(U|?WD!c0Im408b49dGR)^Jo__fY=H}kzz3`1Q`H>%?6VI literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wih1_geometry.bin b/wai/int-payload-conformance/refusals/wih1_geometry.bin new file mode 100644 index 0000000000000000000000000000000000000000..b26b407cabb5e93af4bb8efcc1861a2eeb6e20e7 GIT binary patch literal 88 zcmWId^e|*(U|?VYVh$)~0@5%H66<(t|CmR+h(QC$Rw#-Ti&-Jau!NySH|d5jMdk9o9<7&L%vg`!BYm=%HyOBh;olg^2XeP-Ac aBFQMYrIEq??DrjYip)iDaW->l-^Ny8UQE66jA^H literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wih1_too_large.bin b/wai/int-payload-conformance/refusals/wih1_too_large.bin new file mode 100644 index 0000000000000000000000000000000000000000..bfd420f753fa41ea2e75defbf7af07657bccca6f GIT binary patch literal 88 zcmWId^e|*(U|?WD!c0Im0|Q8uk%0q9cD%KJ%%fezpaEnn6h(@~tPo^a!qB3dbWT+4 dGsC73Nk+jfjSTK*zwfA9q#V~iQF=$+X#gj*6jA^H literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wih1_trailing.bin b/wai/int-payload-conformance/refusals/wih1_trailing.bin new file mode 100644 index 0000000000000000000000000000000000000000..78c6285abdcd80b2c968623ded376a090de89542 GIT binary patch literal 89 zcmWId^e|*(U|?WD!c0Im408b49dGR)^Jo__XaLy?MUi4LD+C#qFtq3p)iDaW->l-^NyngIYjh!j)+ literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wih1_under_video.bin b/wai/int-payload-conformance/refusals/wih1_under_video.bin new file mode 100644 index 0000000000000000000000000000000000000000..f9004d33731652b534afcea327cf9722f862de11 GIT binary patch literal 88 zcmWId^e|*(U|?WD!c0Im408b49dGR)^Jo__XaLy?MUi4LD+C#qFtq3p)iDaW->l-^Ny8UQFX6jT5J literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wih1_zero_dim.bin b/wai/int-payload-conformance/refusals/wih1_zero_dim.bin new file mode 100644 index 0000000000000000000000000000000000000000..bbe93f49ef42fa6e2a003d00cce80cc3329c6ac0 GIT binary patch literal 88 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 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wis1_exhausted.bin b/wai/int-payload-conformance/refusals/wis1_exhausted.bin new file mode 100644 index 0000000000000000000000000000000000000000..8810add87dcabddec9321668a61feac1f8510556 GIT binary patch literal 2236 zcmb7^O-NK>6o${tsFOc4hDIeVDqOgjg+z#psjkpMLLtaNszri@L4u3g1mOrR6ckE| zXb}kIqD4fMiy&d7MMTXa!i7N#7Zoi8BgUVZJ2SWEd^02SbH^WtZ|3^l_q^vl?>XPs zf3EL@r$OYHi0sQPeGamuO60=v>FAr#v~}s>A&@8J)T_Y0Z3CJM}i!d%E=Z zxf6+gb0U_87yexwZ3+Ko|Kr`y9U^1atD)iW#`TExe(qq$R3>75kM*AEIxuPlq+aS+ zL22MyKaJAFPC#nSchEewup8u)MFe;gWGBG4?KqCERBq{WGWV>%X_oFKa(jx?z5ISY zR{xK)G$zlXzlBRxpxS5?J z-#pkKi>48^!;D&=sFGdHQ>638wQRf6P?}))G zGaIFv;nw_S?#_$Uj**9+R4))m9`6cqXm>pW+LO-g{QF-J1KtPT3qC)GMSaH~%~gF3 z963x4%xV#)px@?PQj5;2-9{cfNRV$GopJp7HzKk+E>b^>ZhBG^gO@tDG&6UuHRpWF zs87s;&h{q#TE9ol^uYK_&s_V3UuKpx)*|n4Zk9A|iFefQ8b|G0vv`u9Jks;$TY#6IxZX?P)9NqyB(q|8(NV^WhK!z_Rd!pzp5tn|h!%X+Heg#DkhgSr5z{$IJW9 OQIyLQF{kOvFZmC`QZp0) literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wis1_geometry.bin b/wai/int-payload-conformance/refusals/wis1_geometry.bin new file mode 100644 index 0000000000000000000000000000000000000000..cf6c9bb4b0d8f3b2c878888bfaf3a52ae4c816e4 GIT binary patch literal 2956 zcmb7E3sj7082+YdifW2-x*+Sav~~^=m6dMdq$A4`5p8r?b!bx}8!5RIqofkKwcBKJ zmxPGYI?Aq!R<+2Xw6z?yO?01T{(Zi`a=J_|&H4WS@B5$kd7t-r-~V%VSTdEPLC9!A z$e^x8pZQosnUKX}4(6l_4qC5>OUOx%46{yB73S>!MQEM$d(*5d1&)?|?;<8N=PxGI znuhoMbJ}iTQG@-wZGV~)5@vmI?RtwAuTbl(3|-Ux@=$9&Q739xnnVLv`qLuX*x{4G z>{XL}GQh4Tew_$D-e_WnkE@hn7`i&T7JY8-dZu?77Ts&>+M_tS*S+5zOaE`gqA}eZ z^xkvP*uL+3*7`-yL%s<8<>1fd9|&jj`6(GyLgYGx$i5;(YEDSoN z0uD9y(*!@{;Fam&6*Nh~-3D%@`@lj|FaNZFvjD~ooaZ|^m0ovg?^|m(FM8Ag58JxE zfnGxO)+4VJcdUMzxY$z$FBU#g!a$o$K#1CELR4e$Dxn$ZI#+5<&6aM1&i zflk>pa4tcek%-Ae47_4zqpxOg)BNn*EhJ=c81kSeygP_P9;I5uLA%0lfcC_^bN*FN zBL;Xb@JhgU&tb2=!ylT9=LsBgur**-dtrL?oBb|P3!PP|5%R!;HsouD&PMq4&X17# z2tw3Tpc_3=DF83({Gb_gr?BQOpXAgh%mbb6R`jcRCu&9ySbxzoh5dqG>@3k(Yvf(u zHA^(^3ErW0g>k6;!z}6}7CEKJ`Cef0Zow*dEAoM#7M*B$U<&J~_z=plRF=%7|(^ga;S4Dcg|yaigYK6Ngz zXFuwa+IM=ToCXf`UE%pgJ)kwweDJFk9;n23dce+cWBo=n)>YQW&ynxJQ-W?Gf?0g)YT$o`=h)jJaXUO&D*Q zy`yZIPG04d{AtGy=?c=~W95MhH10lNic1sgFPg>fsBF}}A)Y>b`^*=w=WH5(-0t{y zek)%`MDwd19NTY4$rGPS^6CRUT#aCms|*W`g%c;C5KsPAu4@4 z?0uGhnaA0xNevk_S9JBy?cs>UQ{8Xs8KwPr^W42A!xP2V50_~hX*Dc(Y$JHWi$CO- z=IUQ+vaN7N^dQxfdyh<*7NkF_$c5vZDdyQu^tzUw?d11sWl;32SMy9-b@a|$*PFo6 zdtvIbCdIhkZ28y-QEDN7r~a-Rg?8eRv(sOW_03(VlU`uea5g#3nCE@f&@E0=*T5-i z)3<_Fk?jjvUH&1r(us_6z{ama(%s+OzqDwS|GR%L{pMpAWB7yEM>5+$!+vLNywrS5 zxHuuDV1tp(h#CihL3)u@#+EH9rIW`t2+!=k`q0ryc3E`V{aN*{5TBw80r3uMRX&ne zCwR*qPYRe)?6AW8Z_Ry`mrhDISiBS*u8K~&SO0d7lZlz{n54y)wTw`sIHcj5wJT~{ zTxJH>W*1f6eLi?bV8OPs%b}vwh&87o7eskkc1Xg8y5~qZ6OV12>>c6!&QEK4l2wi6 zzUn~R3gQ0L?98Q!i-J^!>ASyKlgmWCIhFa=%;rH={hs_=-ckO+%kQr+%xD`~^Hi#KYCQ9m#Xj$!8;2e+AvXWHdITh`T=hLCHaF{;N^;@D_YXyn zHphhTXwp%4T<4$llciRm)?(***F#P_RyP;AhGk61`OG-=hFKP~)YslYSx9+@M`P>>&UF+W2!Tk7!o4)xM ZT4Qc&e4XpzEL`j`B-L}*!TOTA{{asJq#^(S literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wis1_n_gaussians.bin b/wai/int-payload-conformance/refusals/wis1_n_gaussians.bin new file mode 100644 index 0000000000000000000000000000000000000000..89da80d066b36c474fc81bb350496eae2749a60f GIT binary patch literal 2964 zcmbVM3pA8z82-i>Du$xtQmNFIZReD3XcI%~$cfFCQe-Q#amtF6R*9}eB&2k+Yc#qi zDY}vEq;^Buut}%LvO2LYxsUPh^ZiCAj4nHKzWM+8{^xz(=Y8Jy{~T?WOyj5$GL{fB zNVe#`8Wu;9kj46kvQhsrmgNl!dq!tMhCirgeAGQ<`%C>X48c+(s`#Z} zb)tbCJ{ipZs{6i#nHW<{hnC* zzX^-R^l;GYhl|Gcecr3+FZna{OThmF`?>rB;VeEsC!g`wvs5fe*xpEQn_W*gQ zH&)Pe6gi#%hZ_57f*)Prm1^S=G)chS4sL~qz(P|m|I~pq1!fgEFLZM%RLivYt+j_2 zJ?eyq?LFQ=FQIztkXM2$RzFQ#>?wj53$G|)piL?uWXNhll%wz{t-Eeg_Guor-y`7} zG;=b(SwnJPU#GAE`yWDqx z_Qa%n{*})l26!&;3c&ZwVXwZ!ADWA|7C7W!Yrw4b!hFzg_Ps$O8}Bk*@_h zo8VWYHz9Q)gs3DzH+rI!3trUuUNh!SZp|{Ex~NZ>2Rhqr=vT{b)Qldm{-S4c`vt$) zS)#E*O1M?YR@=%Vk=cwjMC^n9XcBo}_OXB6d!*S-2k zxqd2s(IZabM|7Y*`hItqf_oj#vxBhfF8DF$a?djKkUeiSQL7<(?+0ue_>rTl z6R9FHw#(l+JaF>}w+in&BV)7koC;#r9x0uw zf5Va+H^C@#XYn%4obss;W*k4PB}j>l?((x!z4wSID2lJUWE{P-yh-DRc;<*5vtL%v z+dScf)roJtovTA4`IR=d9d{zS;!EmtYU75b*FUunzBD3s)|NE)RZCtJUJNX$w#zr; zFpEq@MQ?|{yX8~tcCKP_Lt52UEuHiGIAZZM*PGe~DL>pif4^DpWI_6oVhscJ2D_&g zf@i$g!`>+_K1I{F=go>7qfY$N<3Z@^H*^4w&bIlvhC8ikiJg(`j zic!8j!VkjXkan2%0{4@ns1)Ab!$@5lyME=XZK!vZ0jJsB0A&xymC*VXZ}Us zSeqdgp7pO!@|HcF>^rr(vEdF(I^G$GG`5h-Z`_^-% z#JtDv9*drAiQ2WZSyRP!z0a*5&D8zW7dtLU4?JUA*^=iHoHj9ox9xbLcyB7%W$k~) zJ?BYGy;H>ag2Zbl8cpM`8~T=>ym!n#Ij7VrAtl}?zr}C99)EA``U_U2^Q%O)ZTsg2 g@M9ZpdOf(<7IjDUt86#N@WnPmlh^J!R99I0AALioWB>pF literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wis1_range_nan.bin b/wai/int-payload-conformance/refusals/wis1_range_nan.bin new file mode 100644 index 0000000000000000000000000000000000000000..06b4da320882f141effa8e9e409d928255497d2c GIT binary patch literal 2964 zcmb7E3p7<(7~a?Q$m3G9JSt6(Dbw6lN})+yQcG5vOerCwJSJ8dk}~F&@e5y3Tz2pyhG(aMwQP*ysDd|NFoHKil4V#Vn2rArlB8 zBYGEoR>dN+^l~>ijek_&5FMM7cpyZOpb(aG_?Iw2!td|rU(2^K9efuu!8w01!2%lD z|Icoxj!6yn^LG4cNJxm_%KD8aZ63jbtaMGo!`;DxvV0pe^U*s6JfcjLuvCc(uJory z)Um@OitJUDebT|MDtau$3>Ptolc*8W#V4fGjSy}U-U3e=hrv!JL~bo`@xT=ikL%I{ zpFObPlc@j}xk=YBoX9WMhh{56-iLu3yqp%`5%bYFWl*Bt&Lx2QpjMhIA2I(7lZSd^ z2~8)E;|Xx6afl}P(G6aS7G6P<7~CD;mU##)G!5`i4LB2ET)=s$hf}7uSNq^v`*_i# zE_m3{=MD4{s<#1o#kgY)(Zt1`EO@c-i4q3dBz!`Y))Jx+iC5`8b(4xu^RWFM3(ugL zi#T>J25Ut=c8=gx$A+Fc;@$@K1@hCmkfxE=2S3Da;H6mjFr*IZt3Dy}p3saQ$j}~0 z!-0z)j0|*2W`lDD>O6p$OvJz|b~XlU1~<*m&fPLX6hn{)J(0VMIOLJ3LmaeA{RU`H zjCSeDV|$7i);rWW9;c(Sj-hYpXeFMh2QKMMfu_NfId=g zV7rz8zZqEch*SI#9jK3f-<>AlUXSza2<*BGe$2VlvkX0C&l?TYs*m3L1Dg(hW_l4B>6Fukm-=9pDrev}KH2mCv2AF}VefMbYlZ%V$o! zX~B)1Vwko$&6svc|mn*ijjcPi;f4jER}MHQmEy#f#$0L1nc|3r#u9 zG819R+tKf`d`sOfR84P6ueqkFeQ`fWB%0-VOG_{H$6FWgzt=rolySUNT~DoP=~Hw5 zGr5>!KB>;WB?dbR=0=QAICJpC)Y$>r;|m=*-kBmfi)kJ=(z5M*eyt3MnE!gQLA!?5 z`3$Y89Icmzj_Z>28;n*@3Kb?7@b+l$y;)!>8n+U(m-!8OxDXD*O%%x-+BkQ;MtDrR3HxE;6$NM(_oAR5NWu)#8BCo~;Ix1Fs z>SDyk6L*VZlkzv|nUAfp=If*tnx${smQ*rhQd8LZeb*n`*h#Jm&$&LY-W%jqc-b$; zTB*vb@%3rBRZpk;%`CEZF#cQhP-V&)@g|d3{Nq&-3HKY`F0?Z+@}8Km+_a7fQz;5+ z`ewaDU7O>)z`E?hs(UXK=lbXGD7_jiOb%Ul_Q29`57Vy3kWsEVjhtzxHqY=3wQu%O zo0DKxV|u9C-=ZSyaB_C$$~fBq`O(_0Z`S29;cw1nzBMv`RMoKm!EMiQ-@w%m9dy$> z#?_Q5wU{fa2Tm|vb<%yi=ulM3nq>WXuT2hl{@gO^hygMG&)Ll{!D-F+oT%Ka=kkdK zkKa8OKG_<%d)IpnWt$DYSwESo`Kv9rUz`zi&ZfGxz&Rv+YNp)wQ^lfvX=Jxmz&Ve+ zC((_L;ggFJug5o=#NE*MD?fejq-}Cuxn)9XoNr;P{~}%9zWNQ9EXx^}$ZuViztw$!i?>*X3T>n3cB&Od0 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wis1_range_order.bin b/wai/int-payload-conformance/refusals/wis1_range_order.bin new file mode 100644 index 0000000000000000000000000000000000000000..bb9f866de8269dc8e8f6df5c9e3b7918070d4e98 GIT binary patch literal 2964 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 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_cdf_malformed.bin b/wai/int-payload-conformance/refusals/wiv1_cdf_malformed.bin new file mode 100644 index 0000000000000000000000000000000000000000..418fc2a89e692b19a435c2774d3a2e2453ede2dd GIT binary patch literal 2148 zcmbu>iBl9;9tZFr$PC~F6%2v{sEZOYY!($kbTNUTD+ZO-fCg7VkcbN|grjGAdS-gM zr>Exv6yk-7A|Qwd9)O5ZVFiOC2nb%xE8=B7$9U9z;+BEPY-Y|3*hy6w1;wlyVU@e4*=SK0-%3Jd+$K= zE`Y9HfDNw!+y?kHoWCMXb3EF}HBdZgsL!M>P4v@k;Rbf9Pn+C- zwM40+OsRnjrx}mrLoyG=G|91&e95L-7n)sWAs=gaP#R_-7l^d6asUNofP5mNu8mYJR zWV;$yN*wa0Gvih}r0)z{q*rzbWfs@-nU;td_;ke;nvH2>QcCmLo%a}hV#G`OTS2s{ zE+tM+?P$Uh(o6_NlBa&lI#5O8tF)gR9Oz>HIC&&uj5~Jp3N`*HgW6CnA-nH1%YXdm zV~tm31rx%X&E_o?WAjZLRLjqXk>oZzO^G+ou8qD(Ms1agCgU>Hlo*p+sfN^ zBY5T!gVxFKfynbpib~znkF_1K02ljUSweLV^QE~;Ob1qpYQ(LSH(>^@-JRMqgLg!J z4RtcN+;Ad1`jqa*M3>5|^P>8FYnjyXm8ZD&WQa0i-)r4ac#B5pVg|MfGawC)#GcC~ zij8H9RLOPL(u!m8tk;53bnP|L56`Zk%T<}LolmqB4dH#|C3r+*I=kJdq{lqhu}!Z$ zR5hKwqQaI+Z2MA1eIe7RBn~arM!Ia)MrKXQpL;ozi@)DX*I1sw%Ekg^b8oCt?aJ^| zmDRWOdouT7q2ho^HlESQgP`uaO5VVIJS%PldzzQ8z}bLB;-0X3}MI?$G0d zj?$(l156NWEnV`BfLw^`6u| zZ9v&CiW;3K?b4Ge88i)|6T*B68=F>9M&naDrZ!1_6vgQJjA5-W_Hjl#(F+VOt-R_!h3q2phOHs|o6#}LBa=Dm>Q-27T~ zb=fm)Uh1GY-LMll+$G2x0mqH?0vcp(Ij?sp@bZ|$4TDh9l>%3Ff!D2 zgyU~GN$Oln@l0EquKstZ?w6c$)vx{$l=F{`YBQ;&yvL#kcYHD|tx5=>{Bzya*^wk+ z^R!!2z<);GnC7JRUTCj;zp_~RE^-g8ZD`lx4vX3L$7!0Kx8kMdZ?B3cPMpVEw&jqC z+itSC=>nNH&&Zvp>ga8IrPzPoh0$qILF(lEG%%ya^HQtKtvb!Ztoi?C)y2c!= z0&fw^lcJR1@DyLR^)8;VDT=V%EF>b&G>Hw-ZIq-jgjgOyD?1a@**#Ue#O2A0*m-}+ zl7<$vhIIwP15fG literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_exhausted.bin b/wai/int-payload-conformance/refusals/wiv1_exhausted.bin new file mode 100644 index 0000000000000000000000000000000000000000..d569a055adc9c9555301825e27e8bc9a37e3dc2a GIT binary patch literal 980 zcmbW0O^A(A7{`D2V?>i^G#0avq-IL;mC0f@N=c!VkEWCbJ1a383uR$5CM!D@%8pXl zC}lGXDM}(r(p}7AWHL!tyzjlo@45G$aSMy_o__B+_dL)0{Qvhk?|b6F@g0>43;^4E ztaM%>Z&FH=&T-Xcm8_BVvO&gVm+X}zaz-x7b$KZ7q-O1LIWLc8Z51xaCz-0jh=o7M ztOi~wx=!(H3LPGVQ>)>nf)9qwQ*`49G!(wH+4UA^?u0!PFuEUxj>56iaB>Q6Ps5#? zuy9uokKpO7-e17S*Kqm0-W#sxU~V1`Ex@)#xb+q8e}i*P`1~EV{(y;}u&FRlrb`%Z z!BzPp``R!g&!m*m1PA4;T#*}cPoBtgc`FT>mqlqxAxXf-c(qf--`#a*!&{YBDjcQ6 zD(}~N`#F|+v8d2}A|k)@^K)e7h)%RDD;j9vab^9P0O)dl#{qr|_@-wTwiaT;qS|LZ8vU{<99< hZWl^B)?U(x^9+A^^lL`%yN-$!-|X1n?nyrfegQ5_DzE?m literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_geometry.bin b/wai/int-payload-conformance/refusals/wiv1_geometry.bin new file mode 100644 index 0000000000000000000000000000000000000000..14e73bf8a41d9fe0627b04f5efe075e69091fe45 GIT binary patch literal 2148 zcmbu>jXPBN9suw!nL(3^Ry3$C*|hFnl}KH+EZta@TT88O5!rOR`xo5b^L(D~?|FW|=X}5Ccg}OZ zXItRbWkyCuzyW|6|7C_E7inWzHVnm&!vrx$W+8Kt`G^bRj`$**k!S=*4k4w;6J(kZ zfG3iS+(c#@1Ee7Rh+rB3A6@u4QiBR;K}qMJo&QLl${?a)4@&&@H`q1{ssOWox&#m7qv#GC7!DQ1O%?I=yBNy9<$yBiPHmcF&h;<*kK`;T$< zK?7gr^!Z9{OGdj^dOlD5#!yJ!S#gs&bZd?*$GJ?h{z$S~FDMbrKk-K?uXU~T{9a0W zk>5)h{5!NsYnm1GXVr3Jj8F4U&ZEMQ9KkIeA~Xj^Z;0#r4%0rnwuny6x7FxAxQbI& z-4GflUeP9R1#IGr%i0D(g3k0|i^Sdet=z1~9S`sdCxS*^6LFteh7yHV5iL@O1jtf`HB8->R5g`9NGvMv z)p}OF5FE&L(~NuPh&EN2fK7J*7W3hnrtb$_*I9RlxPIvv_CPkqnvj(ezuF%uFYBce z$GB?jgoR3=zrz99CQo%SZ?!zV<*&pKwrlZ5|DV)MqJbR>IwfAmisVV2?*xu1W)QPy zoQ>I2FHiWJv(U|WfO<5xl=gO<$85?lgQ>FVx*qH_!{75xW>_Asdert8)yuS*gwEW9 z>26vrw#%PIPgv}fyw`7)T;0i+n%yX%TO(&G}{B}?x@vvAH?jveE zM+n`Ye=QQdcquyTH=wmWMHSQ6}G{1IN z)^_y?i(0D$JC@U`i&+K*et5Yi%6W?>Dtk)y!qbsh`r|&T*8HTPd^||H;N}|T?o3~0 zd40Svd7lZi+D?&Ie)(3(GcUv|!-A-T@|i>|*A%!VPc)TBUMW`>-KXyz9mXOZ_u!=q z#@Q^=Rk*T$w!DL}CGs8>$qyRkV3|!E2=2M3;0!*%vg1bu&+-ctT+>*sc^C&y>4(W< z7gMNK*I`vwnhjymR7AL_%pgi`A}^1yl-XVC5lzM)5~cq-jDJx)BtP)^9IAZ&U20;; zUef$@kPc=nCCk3!QA>E4%HUyFB4qal@%85|ik2=jSmwS+v}$?)!F^;Ryj^Xh+MCv| z2`K+fUaR$>oO`pxL&m{WVz>`(Wz{CkYIhYF`Nlt$!ullFNl&eJFE>^HQ99>s#fu z70(3=(}qL^J}fPrx$ZQ-WiQ#l-M^R{TEys331pK!mxB zvfPcQ2(3#QmSs&*HUA9L{+3&z{M|2-bo{wVWg;<`^_ul!_D@G7)rkS5U!I#PCyKzW zp7p2;xzCB4GaXc3i)|GjR+mWLNA0CFjU5`yZYk68BwfAhc7nw8-8IqV$qQKP_FN)q z`zT~@r|53&LuH`0>VE@ZQJi`S<<688Hh zXsw&aSUq))HooCQ1eZDJw&w}eao;3U-{{8J=slG*eK`YbOR!+NZElOwW)3Q!tuqCS zpxgM$gTm@bRd#%_4nFJ1K4o#aBjBims##W^eUwQANrUX5pW* zC1Hhe^b@ZvxqZ%es0(ci3|NL*^rv2rTJooe+BE#n$NvJ&o3FZNxF&t-{R@-A=I$u6 kgfpQ0GOE#f?mH+6nP+5KM#1`rqZLG+Q@A?Qu7arl4^*|%>;M1& literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_keyframe_mismatch.bin b/wai/int-payload-conformance/refusals/wiv1_keyframe_mismatch.bin new file mode 100644 index 0000000000000000000000000000000000000000..f948456f97775dc4a14e0f7807fd6c4824febae8 GIT binary patch literal 2148 zcmbu>i&qm@8UXMQBmqoO!6FzyU6hJtyQm1Fi!Bsgv8b#Tw73d_R9tYOJd&AAlF3Xa zlNX>+U#KVog80A(AYxTm!J-HPLRponNC^mt6nsE8Z|=;Q zZ?>-6vc$y11b6^2MhQ-EZo4-riP5TXk|L#j~$%_!+ilzbmbv{3*MZUxYU5*FK_eJJTXAwUO8 zOfEokH-O=#0Ny?T3)ceJ1OtTZ0@yAB$kPLy%K~_P9@S6|aJL%O{s5r;7XbQKRC@=S zcL8+u0&I8<;64a&>J7k^cK`{)0R1BXUyK6yi~%^0qkV{e0>E|>AO-myS^aN-RHPP} zKo)WU>ycQ5MUEpEkqV?9d4zNzy~rRkjEo~30$_sT|2KTl*?DU+gN12DCKhnNelgWR z409vJ{$lNuYPS263|V{S6s27OA2N>)bN-4n&GBd>*Ff>0p+1wkG|^ADg&WwZK5cUU z)e@zOGNlGCoMt?d56L_f(G}eAKs-js=H204& z?tZ;cKL4}jy5{sYo$Oq$k_ zKPl%fiXkduJ6ZOY8yoTGaD8}Os+`}+gK{dD6x0_hb;>AA6C5B<9@4V4n^QD&-T=9v ztVicn`CPO=$6Y(-lZ|aGHv_w_06hBrRc-GNgubKpG$Z(qn@{L}aBEd>UTJvAYAMC#*8dm?LVH1qpVBkr~8V*w=dc74nCtE=D z?lCTUcby{sZ!ThY(|-El=wimlc{aN--2x^{r|P@$Q><|JTe)#*nEGMsU(_#Br;&O~ zPqwRZrNkj`Ix}vyL;B9JMS5k2P-byGpJ|DhflpUlq1l*5CZ#l=-Fc7ECq}%azZXQS z>QdtL)Q%=mLYfJoNb=NgSqG{}e3kY~g9Ba6A19AQjB&?~UZKVxWl$TcC1m%VX8F&5 zf2{GUtYAWTv)R0*m}tIfgKGKNFp}J6rz!Eq*|pIZ$*8Sz@nl?vdfb0IjY)q3EyRnJ5gILfA2ACk$TDs&r0li3&p$ZyuBZGIXmt1?+tZeSIfF&LauoY7SNd7}J@vSO5^`6u| zZ9v&?iW;3K?b4Ge88i)|6T*B68=F>fM&naDrZ!1_6vgQJjA5-W_Hjl#(F+VMVqtM(T2(E3;6%{hGNF@&(Uc`qb6H@{U~ zUG_{gFLe;h_vIMbnNH&&Zvp>ga8IrJ{em3!~Gbg4D_RY4W1=e~{<MGIHZhmAOR_fQp}5a4 zUT51h${FahjOle>GN{x^zb#j+iTx&t{zgB_MeVL&m`mw+YrGZPWp@ipoi?C)y2c!= z0&fw^lcJR1@DyLR^)8;VDT=V%EF>b&G+~D5HcHYMLM)GfVowdEt l8cu`ii^vAsS#O~@c(#dkDGh5MjFgkP^TRY54&`Lse*l+d()0iT literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_mv_precision.bin b/wai/int-payload-conformance/refusals/wiv1_mv_precision.bin new file mode 100644 index 0000000000000000000000000000000000000000..c9f40646823028d82192ec8deb31407d7b61e4c0 GIT binary patch literal 2148 zcmbu>i#JvI9suw!IR~9l(TEPJt4tbqu1ch?W*BwTsN5QAbc@KOOPBFD=j^l3Is0+; z+2@5~da0BU>7fUbrs77UM2K-KQ^`1lWaz>1y8HfW-TN2Z-&&vb{jIfs>-YWc$J*cD z)^%H!m`pJN9stbvFAEg;NGr#2p(uVBW{4#+9hrs9L0l0J#1GkoL?JkG5Gg?(BWP>z zLXwah$P80}WTX!fO#u+1jh`XasDNgabS6r^4<*_t00_4NXhI2#ZO}TDbe<5P10^OF zpt&2s@KOM8AAp5x0c?T+LUsXc7Xjqy0nTLsygrX=C)oX62RL_YywI|-12{En>tH$W;&35j}CMGWSZu9w2^C|c+gOvNnM)gr`y5}>{OpNh5u@) zQbn0k0~by+9?6H~9^^FXv66i0rdk)8U1sFkOVSw1ctHC0`U8!%UyZ70mJ`kWV~o3B zFI3F`Y`LyEy-g=Smn(g3ETC>LyTKm3Ia87CQYu?}I7wp=6^rH^|D#;c@}>OT9$J1u z*h3pvx9bvDH7S`-t4LE!$Oul%ro#>&#;u*gwfje|OY3?MF}^!DOHR(Q*XrN9Nt0Jx z7n>$r)+KELZ2a>}x_VK(-uywc%){jkY0>S02Y81Ofy1we*iUWobsdMOeSCLpiTo$! z+(j{XMQkU_-g09j{v56kk4shXJ9$t}+QCD8;2D3XV?Z0+V04V^bYEGX;I zc~w3a?ay)7j`?IuHkO-#T~`1W{r;-9_Xk|xQG1%WcJU~7Uopy=Q57=(njdLz+r={H z*edL}m0D!D%>%_oFHMnPC7IUzSK~7jmKO9}m_&CpIH>O*_Wa(6WH+G5@?tZH=4Q=uIDo?5i_vqIOZt03w5l#8 zPEYM<5+$UW;AE1ge#<&gMPjS8Um6_fV*WUBBw~y^cJvB0{wRamP%S08?=&lZ{`+H% zS7ikg!kf+JEtQDon>MJHpA933ZFZUxZ;V|VeUXUTst`}cWvIvfx6=~okW>-oD`_}O zh&`TtEs;EbAvxpUue+9UQ}m`WN8wh}PmEM9)8z%ViHs2;%1}Wi+x%X~IOp6}-nJXT zGLIOvPJR!>o>x*->Yjd4+Yt+Ju@9CfROc{Xo2w*rV3oK=(n@*bX5iZ0sXa4zN9@;7 zCwI#Y$HSvf>26GPsk}Nbs^7Pk$sAvKN@`DrC?od0)(wTXXoN0iU`v_-VQ|FvTrMFu zmMu~x*ICOdj>WTH3r5ks*T_D+yMiuPWxjSk-cmG#^_7=k5sm5WcB7IW^IXR^z4B1i zboPo1TPj7{mon-LnMNgkXsI^RWwSOiYf|yt%b8gG<6gSP@`R{tEKol8#wyjW3_n#_ zU7R>+uNk!1Pg0kE`Bo*cEWj&51L*_gG$Mv?4*cTB+KR(3RVxecF?Wv)VG+)|@shb? zTqflvUfwr@Y-jC>+=qqa0h4Sjqmc(e-FKC|f%{li+=%FDUcQoV9;34i<-tkA5OwrI zGTq`fq|QvWBdi(=30JiRM3QD?S-7>r;bOOBBJQ9h?bjjv^P)j=|7SDlvN?C?@j*vf z(~|)vh_#k2`A$GD5@e`?hTMqYUF)USo;53*J1t;|#{$WUsR0E4p_%wrm7RJ|YM(Zs z>^HJT=SjQtWJ(83gXn}XU);u~Rh-fIl#c05qQyHc84vO$eR+pgKiFmtF{P}2b=Df@ zWvw~-_8nM1Cv$KVQc8SkvN>*l zH=HDNuBBL}ElpSdGgS9mPPyuL{|L(Y=SH=e%u>-~(StcY8J1Ng1W^9D?&|DF0=IeE zttsF?BW_G{QhP77SH53aEPEHZht@Wtw!JdZzutw>X;DGyZIS6E7rt*lSF@`ALXKUS1`<_bgVVritVzyB}tt&pnAH-9IOIw z;mebvl;H3bTekHsma!=cx7;kmBhNHR4AE_rv@rx<9ziQR6Vur}Rl6kR$&1)|f69`D z7Q`}-y)#M2?C($)+PW@a32MjXPBN9suw!nL)EETG61oWYfBPRU&oOvTA8nwwAiOBC_eyWxdSInKLtI&Y3f3 z-cW3BDkVgE(F>Bc;zp}Ph;=Jl$r?hk^ul<%XMgqF`xo5b^L(D~?|FW|=X}5Ccg}OZ zXWRO%OO1?-fCB)t{>uzSF4D%bY$%E!h6!Sh%tYoO^AH!r9q~mrBhd(s97IZy$H+7z z08bkDAw$RrGJ&uNfDwxS-|$9f=cUdH=BF1MnZf;rC1fK$ z!j6{s2{lh@n4V8Er5#n%6gGui$T~X0`pMJP$72j^BguirhAi^Zj%tns!e)Bz2e15FaZo5O1z?rkLdhwxcwiCJhI~Z?8X4Tl&^2i|05{>_5lZ z`we`V(`PHREg9`v>A5`dYeOM76stnJVyt!`G(pH|C_F+R;ZF_#KId>FTMh|ugGy)LfrJ4E~J+#)(T&sL**?L1JNf zuhz5bxnO^;n`YcQN3^NJ1Z=tku$cE(HGMzey3V@O#I=h@vHP+y)`YB-_|^VMd08)! zIL1|D$1PL>{cR4&HhHRxd8_2`${Bw{z7!dZ$Nu3^QPcUQ?AUlc7PbITCU9xY8MzHLlmLBDyHSVmUhg&t+;J7 zie(+qYaDzZ2tBT(DpkD$g7zb3;A|T#O{~eKzcN*dsK9Drt*DLk!cD-Xr%Q8Y=#J30 zv0mz$7lB8_oYLNy>{fbqT~xhqE0@^6@(|UX3{gbxd#xRgXjSu_O~6`i0)*Zk-*dTC zzNvh%GNs;9Qh6+a@mx5D?!8v>;n@{-yC@1YPIznaFxFpDibXbMFkuD-HSV#VX@2Fd ztnKO(7PVFh!j{ph3t0vQerTB{%6W?>Dtk)y+|!X*^5b5r*8GH^d^}J(|Hf+Nu1sHL zd40Svd9Mky+D?&|fB8-J|aw8O9Rk)&mmb`*sc_;@?>W9gr z7gDHJ*I`vwnhjymR7AL_%pgi`A}^1yl-XVE5lzM)6s7+~ zmp>CMNE;Fr_^`Be>N4R}`K5);GsSn<33BZ9m^0TJpl z%5pcJB(yGNSe7+K)%+_|`&({>@^`;T((&gem5Ib$)@#;_**_VPR3`?IetB-HoG1df zdfKBd`o2=LaZUQz`xhjI&Iv2B kgwvq>BC64P&RZx6o@-=TMuGo>(F!8ZDO{atS3%VO0hra&_y7O^ literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_nested_version.bin b/wai/int-payload-conformance/refusals/wiv1_nested_version.bin new file mode 100644 index 0000000000000000000000000000000000000000..af56afc0baca1ee99a3ff66a4eb42120283b3bf4 GIT binary patch literal 2148 zcmbu>i#t{68UXN@?2Y!QXha*;Q6`NuMwIz?pCp~JZBz1LoQ?{(R0 zuYE%?-Be15bkPM#Q*okEBE&eAsbp+IGIU|Popru?&iM<@_dLJn{noR-XT9&bJnQ|o zuG_N2#KZ)605IdfEKuYltsKXNqWEE$A(qH=WEL_9aYZ~3KV%aUh2Y3Rqy%}4OfdoQ zLXwah$P80}WTX!fO#u+13qM1uQ31^;=}eS-A4;@Q01$2k(1a2e+n{wQ={zAo2TDvV zKyx>M;iUlHJ^%~X0@wrtgzN&?E&|BY1DwkOczqt#P!4dn8rA*)p#2vB`d3tY2by;Q zboBykcn#n_2yp5Rz?F9Z3Bv&WBLH8F0{DypIFF-sh<*aVb`l^3`5jsPZ-7*!7MVa6 zasca*ScFB6BNvehq#k*MbRfORATo@MBOC%?g5v)-e9*J=)?@|?(~3+i;C}sLssSJ7 zMvDE#+9%a)_a_atJdzK|J;-U&VpGyUfV7m!vV2@qqO0^#>YjzZzB1EGL@##~63N zUZ|M=*>YWTdYevuE?4^6SU}xgc7r{5bEYEOrBt@|aFWI#Di+N-{zti>_m)7+jVtjXQmYkepuhqYIlP0gY zE;dcLtV`Mg*!bs{boHWmz4?P?nTN|8(xTe~5AY5n0*7A_v7g%F>pBim`}pqI68TTc zxr<`(ir7w;z2(M6{5f179+#@%ck-Z|$|VH##Y&wrN}>r4P$Um&+1kx18ai))SWwoZ z^QwF<+MnaD9rMYSY%DheyRHB%`u$aH?+>`XqxLj$?c!1FzG9R!qbg+nH9ykcwu@!X zu~pb{E49dQn+J-GUYa7oN;0kaufz}bU*Zj`f6}lCMs6_hq;w4@Arrmciky=zAbR&0 z7rncVjQ^X9*xj_BemJ_A@o}EbZcMj;$U-?C2G0{80wAp;}6G-)UC-{P)Ki zugVH0gg2YbTPhLFH*HWYKO066+w3$Y-WaJ^FA*mwFSJH5n z5PLlPS|WM=LUP8xUw19zrsz#$j>4^`pBSlJrppUz6B#2yl%ax3w)wq|an8A|ylppv zWganTo%|k%J+Gvw)II&8wj&ncVjnC|sLo-&HdjgLz$$T#q?Pi<&A_$0Q+sCcj@YlE zPVSZ)j)zB|(%qQoQh9Y=RKIU6lR3Wfl+>OKQAX^0ts4q&(Fk44z?L)v!r+MSxm-eS zEL)^XuCta^9E)eY7L1~MuaSLtcLiOp%6#p7yrpOe>nkt8A{x`#?M5X%=DCh-dgY<2 z>FgC3wp5C?FJ;shGL1_7&{A!r%Vuq4)}-ROmou^W$GvooS%6oB2GR$}X+#X)9QehLwH1e7s#X@>W9}Xq!Xlh^<0W&) zxJ=4Tyu5D)+0NP%xep7;118y6Mk5b`y6-A^1NX74xDnCQynH3!JVs|3%7c@JA?oOb zWV*#|NS&E#M_4r$60T|sh$PL(vT$pK!^LjNMBG71+OI?S=S73${?BI8WpnP(7Zx&XacO$&?P72GI#&zPOD|t2m?aDIL?BM2mM?G9Khh`tlB~ez46PVoF*4>Z~=) z%UW~v?K`l3PUhe!q?GsyBWFVzxn4Wo$A8t{VjepGm3VUwA9@TS>}}o)Y0k}WRacii z6U|E7Mi_J*CC!19bG6ThVF6FcX>xdxdS31)OCd8 zZ#YTlTuZS`Tbi!^XQ=MCoO0Fg{t=Y(&y8v`nWdt~q6c$)GAye~2%!9P-PPHV1a9-R zTT{S)M%wruNNEMrp?Zn;^AN1kbt7^2%KX=4b!Jc3qsCZ@A{s&+}rlNYh`{*)yP zEr?|vduNi4+25fqv~^v;64aJI_IlKkKgJKo|MOvApnd&S=O=E7AAA42#L!vW3$5Wa isJ@77u$}c5ii2mHSeMeU_Q6Owkvl(3li^TK)cpsSxzhCj literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_q_zero.bin b/wai/int-payload-conformance/refusals/wiv1_q_zero.bin new file mode 100644 index 0000000000000000000000000000000000000000..3a8161b052c432b16316104115de9b868cc1ffb1 GIT binary patch literal 2148 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 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_stream_length.bin b/wai/int-payload-conformance/refusals/wiv1_stream_length.bin new file mode 100644 index 0000000000000000000000000000000000000000..754ef4b31170322cd79602d2d66e26a93a9de765 GIT binary patch literal 2148 zcmbu>i&qm@8UXMQBmqoO!6FzyU6hKYT~q|oRSQK|EGpH47FR)#iVH53$0U( z&DM2W7MYls01p7B{g(xbe58frxDXUS3^T+MnTpInW+ASK2jYutLLw0YIfxV^kC90x z0A5HUas!!W3Xp{KBG@DVA-eE0qzV<#gpy82$@if|8wCJiRsfACVUZ2mhmy__0<@#V ztYpGfvE;PHu$h8%xGL-RvvxoCL&TVh@NA!;Ar9bY8- zSvh+_G*KSY!Lqm9*zmuE>BC}ElalW?n-M-WC2mT zN4coowTigExrp3N`{;)w3z-$pGue%47BEpVS>J`9Vuick%8iRd)el?#s(z6&h16Sm zvYidfB@Ve$nK7#!(sza}(knZJGK=eZOmp}&e5&FK%|tHsw(_>! zFrIP5pmp+nAo9GDtWtOPVXa3jz{NgD7GIUkd~L20(*c#DYHLQYv+Poso#-Fvn4!?VlpbXDeQ=Mv3@gLrRQF&^HK#%?z%=~2&hY~w2r zRdq*?sGzw5+rF4lU&t^ji9?IE5iXmx5t$S6=U&d_!k_li)s`o)($N6f>>DdpyV8AC zrM0o5#Jy(FY(GI={`EVRz%rjG4+)?TD5j9nd~@IzJ=T^VeyLhsaF4lrWDpN`-c1zG z9_2D9H__7GX^J-1p3Hezpg3TXg{L?0Ah7GMlGlG9&x{?$p62E$`R36&%Mc!%Gz?Nl zFC@{;ZiDKK6g$$Yp@4K%TR?=uOi>zUEqA!sB_5AGC{F!tkodfCK(YU`>2&F=JM`Fq zqqOl!KNHAWOBa1Fpce?zRe^(UWYDhll55YJluaELu*hScc-drslK;?5bgR-%y(gtt z>tFi2qFU!kyL4wr222C#_)s6h#->G--td%;?n$IYJ1rRx#Y_6~4y}Hm)f}QrSpDkE zHO$KzbM)>zu)a>_;3%YmiqcCzkcD#?@s=Y-#wEmT7b2cBk4Z-Yf-U~_g&F@rK zmpsGfqzs7jd^konahaT3;Kn{mK27%ia0w5|+NWz5O<+goOUS`{oa9~Z5iF-4BSTz= zIsS%|q|UVj&#OlC{YXMZLap zI@`t(&Oo1KOt1TpfhA7*Z8;)M%r}YjH~JASa(6kyTuQ@R;;h(CyIbOvDgCOaYs|qa z;1;noF;WQ*Pw^#N@8ap3A_>dQLL%Z!qu3DDN=X`miKXGRvLhjl-BY;h6P3>Ox!B`7c5(`eUz0E%{?aZ5saP!+(MH^;aFAxFvk-{c{pRW^6C8 khSQ+>GNRsg##<-~nrUKPLc`hz!)0X7+)z!rLm657ABru~@&Et; literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_too_large.bin b/wai/int-payload-conformance/refusals/wiv1_too_large.bin new file mode 100644 index 0000000000000000000000000000000000000000..f83b0c107f8c4975180e28c89a3117b3a3b4f7cb GIT binary patch literal 2148 zcmbu>i#JvI9suw!IR~9l(TEPJt4tbqu1ch?W*BwTsN5QAbc@KOOPBFD=j^l3Is0+; z+2@5~da0BU>7fUbrs77UM2K-KQ^`1lWaz>1y8Hg>uKO3Vr{jIfsYk$A{vG#Xw zUAJY4iHQmD0L8rjvOtNCv~nC5it>jrLoAW$$ShIHM^slJ*4z%t9 z=;{U7@EX8<5a84sfGh6+5{3c#M*zMU1@IXIa2`kd5d8#z?Ib`7@;kEn-vFsdEi!>D zqt)1m*uPe9*n~)?@|?(~3+i;C}sLssSJ7 zMvDE#+9%a)_a_atJdzK|J;-U&VpGyUfV7m!vV2@qqO0^#>YjzZzB1EGL@##~63N zUZ|M=*>YWTdYevuE?4^6SU}xgc7r{5bEYEOrBt@|aFWI#Di+N-{zti>_m)7+jVtjXQmYkepuhqYIlP0gY zE;dcLtV`Mg*!bs{boHWmz4?P?nTN|8(xTe~5AY5n0*7A_v7g%F>pBim`}pqI68TTc zxr<`(ir7w;z2(M6{5f179+#@%ck-Z|$|VH##Y&wrN}>r4P$Um&+1kx18ai))SWwoZ z^QwF<+MnaD9rMYSY%DheyRHB%`u$aH?+>`XqxLj$?c!1FzG9R!qbg+nH9ykcwu@!X zu~pb{E49dQn+J-GUYa7oN;0kaufz}bU*Zj`f6}lCMs6_hq;w4@Arrmciky=zAbR&0 z7rncVjQ^X9*xj_BemJ_A@o}EbZcMj;$U-?C2G0{80wAp;}6G-)UC-{P)Ki zugVH0gg2YbTPhLFH*HWYKO066+w3$Y-WaJ^FA*mwFSJH5n z5PLlPS|WM=LUP8xUw19zrsz#$j>4^`pBSlJrppUz6B#2yl%ax3w)wq|an8A|ylppv zWganTo%|k%J+Gvw)II&8wj&ncVjnC|sLo-&HdjgLz$$T#q?Pi<&A_$0Q+sCcj@YlE zPVSZ)j)zB|(%qQoQh9Y=RKIU6lR3Wfl+>OKQAX^0ts4q&(Fk44z?L)v!r+MSxm-eS zEL)^XuCta^9E)eY7L1~6uaSLtb_HFo%6#p7yrpOe>nkt8A{x`#?M5X%=DCh-dgY<2 z>FgC3wp5C?FJ;shGL1_7&{A!r%Vuq4)}-ROmou^W$GvooS%6oB2GR$}X+#X)9QehLwH1e7s#X@>W9}Xq!Xlh^<0W&) zxJ=4Tyu5D)+0NP%xep7;118y6Mk5b`y6-A^1NX74xDnCQynH3!JVs|3%7c@JA?oOb zWV*#|NS&E#M_4r$60T|sh$PL(vT$pK!^LjNMBG71+OI?S=S73${?BI8WpnP(7Zx&XacO$&?P72GI#&zPOD|t2m?aDIL?BM2mM?G9Khh`tlB~ez46PVoF*4>Z~=) z%UW~v?mMu4PUhe!q?GsyBWFVzxn4Wo$8Xi%VjepFm3VUwA9@TS>}}o)Y0k}WRacii z6U|E7Mi_J*CC!19bG6ThVF6FcX>xdxdS31)OCd8 zZ#YTlTuZS`Tbi!^XQ=MCoO0Fg{t=Y(&y8v`nWdt~q6c$)GAye~2%!9P-PPHV1a9-R zTT{S)M%wruNNEMrp?Zn;^AN1kbt7^2%KX=4b!Jc3qsCZ@A{s&+}rlNYh`{*)yP zEr?|vduNi4+25flv~^v;5;USe&U!SGKV~$h;eS5-7ieF9)%l5A;>X!PFEMo1_Cjko j4XQ698*FF2h2r4ZCf21itbH(2PUOxH(_}c56LtRqq4d)f literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_trailing.bin b/wai/int-payload-conformance/refusals/wiv1_trailing.bin new file mode 100644 index 0000000000000000000000000000000000000000..695bc1d557cbd0d90175c5a89781f7d652d88b08 GIT binary patch literal 17985 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 LkWk&Wl-3mhvN9TC literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_truncated.bin b/wai/int-payload-conformance/refusals/wiv1_truncated.bin new file mode 100644 index 0000000000000000000000000000000000000000..c85f9cc307203237fd94d2f3997c5074c5299a02 GIT binary patch literal 2058 zcmbuM|=XpJ!@AG`W&v}2o-*cX` zb?uhLW@cs}0Du|)V}+^^Y2|r79M#{31!9d%M?OVnBkqV75`b((Vh{p3gp?ppktt>X zK1ech3z=aKkb?9fk|_XU^xzjrHEN(4h0a9b`%%ya5kRC3KobfqwnO_+=v*;C2MQ+V zqqzsb@Dc!DKY#^m0PHpcgzW;@E&<3l09?ohczY4`P!4dv8uk7Np#4Vx<|ov92by;Q zboBzPe+%F_2yprxz_kwmNy7mBBLIIK1@IdKa2-ec5W@t3{Ukst@(U95AAmHZ7MVa6 z@Br(Oc!WbvAeWH}q#k*KbRfORATo@MBRm3NhU)(}{LtO`YO^+r(~Hck;9>nDx`7zx zM~VZbx@Xl~_wOMjkRtJx75V9m!z|_>7eZW%|}|>fEsntr!EZt&oTaC zy;wQ#vt|0`j5fXELZ0lcsgSJE(Dl-2bfX8m_=!cNV0)EPc{$WoTy zl$s}9)hBNOeEjn(`g%#C!SYeF+{^79W!3FP1p7viA;WLT_)qMKwH=4){X$QCvEqBx zoP}{jMSLg6-SOa}{v2tDOh{7-I|Wcq=aHiNVwGMMgK0MhD^rGaTMUs69j8xO@zMs2t@j=n8pY&3BBi{UW(* zd=-AeMk6ua6@YSskG4p(f=X}x8~Lr{Dxx9ido7n_;s--c$yW0iwa@3h#5KhVVt0@6 zvAgT2#J{^qJ$D@l_Ki65@h72p1ES+lT#!qwN-S3sAB@vp(t$)?LN}EO+ti8Fe z#uYNB{ORnt%?|ko;}-d~9b$#m%>uS1Y6d=?y2fyEjcjUZ0k`u3YuFd{n)yZ)tFB8; zFwi@iBuVLJ1eNS<*s>PX(fCT;Dx(uqEF33~Mvd{uk6ok3pJdYOt7T;Oy=LVP|9GnP zsjOhb1hcsOC75KMd4qb{`3RES=AbR{#kn=Hm&ur|O6g=mre-{FJA=uFWXcGCtl>N< z^?LCIhP`}+oek{Q-^jczdDob$^r-14M=F=<^F!Mtrl>GgxTunA{-|eNbMLC|I*j01 zM~ym{fJajAYpH5YPrsz?s1>+5ZdN2!=dxc|sxc;{N?L=p(!PWRxOaEz&JNy_1~k+u zJn|xm$k@~RTN7PspU%sgkF8~L=QrM1?Wr(T)c&{np~x1k*v$g$DGMNt&cvRpCDewp zh3b?#TY1ItM9ydaC^~zM{I_>k*yXM&(9I)SiiYsM@)A6%F@xJ~QZZxRYq_R3Uh108 zUg^1(O3C&mtmaadNktr9qKkIhq>Ij;RKE0aB^Q0Sm#MKnDJdHZQOvouQoSoPKwVas zAWhzD0WFS`^wl4~Qj4q$iHh(L<{&kVj1yXdu=uI2;>c_DigORx`$vcHDA(OY$(%7h zi}sK%>zhHfbB<)*<8#zOvm89LQ2?Rc_f>*{hj@0vh~#;Gfl6o@r?(Clz$xPpee6;S z)8a9t$x3q|Z5q#!?iwqIrYxwkNL!`Tu;SiSo(-84_(I9o;vzbiU?0d}kptHQ` z*#H~L*~%AxEn*gmGS#6&9^~d->tr`xG^?6Btzfa&d~EsDU{d(lLVBmlL9-{VPZwPF zGgYJaX54zRWP|3ROj3kDVQ1GW&1`(m#PudK(w)|<7xkLCxmc~=ziMx>0ImN;x+zx(J;pHZuHcm{_x4xn>q}oq z=B5o|1^zs%n7m5PJLkbYNj*dMeRBm5&)Kio=!x%!vDDBAUhMvaBsTG?aOgF8PPmRBVO(}8)Onw)5ouzTLE zEfl^WZ%uR2_%3i%eOys2{}8>0(KWQ|aHmCF`_pvo&O3>6%lFr@iIW%cmTkG@zHPU; zybO^-mv7=P(sj(Xy>iLFKSVI;F`=52f^=oky5B1c{5@$~QJa*{-X&X;`dHc*kf^tB z8s&}5dDi@vFh*rA-@9KVNMLHX(P2 zWyvuraC(j}-FhF-+!#YxZxR#HXPYo%Y#S|W3?r6BF{;je8Qh+#U08X_LT>J#vgP50 O@$6IIEXq0OYxoss0=}UD literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_under_audio.bin b/wai/int-payload-conformance/refusals/wiv1_under_audio.bin new file mode 100644 index 0000000000000000000000000000000000000000..f948456f97775dc4a14e0f7807fd6c4824febae8 GIT binary patch literal 2148 zcmbu>i&qm@8UXMQBmqoO!6FzyU6hJtyQm1Fi!Bsgv8b#Tw73d_R9tYOJd&AAlF3Xa zlNX>+U#KVog80A(AYxTm!J-HPLRponNC^mt6nsE8Z|=;Q zZ?>-6vc$y11b6^2MhQ-EZo4-riP5TXk|L#j~$%_!+ilzbmbv{3*MZUxYU5*FK_eJJTXAwUO8 zOfEokH-O=#0Ny?T3)ceJ1OtTZ0@yAB$kPLy%K~_P9@S6|aJL%O{s5r;7XbQKRC@=S zcL8+u0&I8<;64a&>J7k^cK`{)0R1BXUyK6yi~%^0qkV{e0>E|>AO-myS^aN-RHPP} zKo)WU>ycQ5MUEpEkqV?9d4zNzy~rRkjEo~30$_sT|2KTl*?DU+gN12DCKhnNelgWR z409vJ{$lNuYPS263|V{S6s27OA2N>)bN-4n&GBd>*Ff>0p+1wkG|^ADg&WwZK5cUU z)e@zOGNlGCoMt?d56L_f(G}eAKs-js=H204& z?tZ;cKL4}jy5{sYo$Oq$k_ zKPl%fiXkduJ6ZOY8yoTGaD8}Os+`}+gK{dD6x0_hb;>AA6C5B<9@4V4n^QD&-T=9v ztVicn`CPO=$6Y(-lZ|aGHv_w_06hBrRc-GNgubKpG$Z(qn@{L}aBEd>UTJvAYAMC#*8dm?LVH1qpVBkr~8V*w=dc74nCtE=D z?lCTUcby{sZ!ThY(|-El=wimlc{aN--2x^{r|P@$Q><|JTe)#*nEGMsU(_#Br;&O~ zPqwRZrNkj`Ix}vyL;B9JMS5k2P-byGpJ|DhflpUlq1l*5CZ#l=-Fc7ECq}%azZXQS z>QdtL)Q%=mLYfJoNb=NgSqG{}e3kY~g9Ba6A19AQjB&?~UZKVxWl$TcC1m%VX8F&5 zf2{GUtYAWTv)R0*m}tIfgKGKNFp}J6rz!Eq*|pIZ$*8Sz@nl?vdfb0IjY)q3EyRnJ5gILfA2ACk$TDs&r0li3&p$ZyuBZGIXmt1?+tZeSIfF&LauoY7SNd7}J@vSO5^`6u| zZ9v&?iW;3K?b4Ge88i)|6T*B68=F>fM&naDrZ!1_6vgQJjA5-W_Hjl#(F+VMVqtM(T2(E3;6%{hGNF@&(Uc`qb6H@{U~ zUG_{gFLe;h_vIMbnNH&&Zvp>ga8IrJ{em3!~Gbg4D_RY4W1=e~{<MGIHZhmAOR_fQp}5a4 zUT51h${FahjOle>GN{x^zb#j+iTx&t{zgB_MeVL&m`mw+YrGZPWp@ipoi?C)y2c!= z0&fw^lcJR1@DyLR^)8;VDT=V%EF>b&G+~D5HcHYMLM)GfVowdEt l8cu`ii^vAsS#O~@c(#dkDGh5MjFgkP^TRY54&`Lse*l+d()0iT literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_version.bin b/wai/int-payload-conformance/refusals/wiv1_version.bin new file mode 100644 index 0000000000000000000000000000000000000000..55f11d8828024c9ad5a0ad1d89d12d3238e451bb GIT binary patch literal 2148 zcmbu>i&qm@8UXMQBmqoO!6FzyU6hJtyQm1Fi!Bsgv8b#Tw73d_R9tYOJd&AAlF3Xa zlNX>+U#KVog80A(AYxTm!J-HPLRponNC^mt6nsE8Z|=;Q zZ?>-6vc%NH1b6^2MhQ-EZo4-riP5TXk|L#j~$%_!+ilzbmbv{3*MZUxYU5*FK_eJJTXAwUO8 zOfEokH-O=#0Ny?T3)ceJ1OtTZ0@yAB$kPLy%K~_P9@S6|aJL%O{s5r;7XbQKRC@=S zcL8+u0&I8<;64a&>J7k^cK`{)0R1BXUyK6yi~%^0qkV{e0>E|>AO-myS^aN-RHPP} zKo)WU>ycQ5MUEpEkqV?9d4zNzy~rRkjEo~30$_sT|2KTl*?DU+gN12DCKhnNelgWR z409vJ{$lNuYPS263|V{S6s27OA2N>)bN-4n&GBd>*Ff>0p+1wkG|^ADg&WwZK5cUU z)e@zOGNlGCoMt?d56L_f(G}eAKs-js=H204& z?tZ;cKL4}jy5{sYo$Oq$k_ zKPl%fiXkduJ6ZOY8yoTGaD8}Os+`}+gK{dD6x0_hb;>AA6C5B<9@4V4n^QD&-T=9v ztVicn`CPO=$6Y(-lZ|aGHv_w_06hBrRc-GNgubKpG$Z(qn@{L}aBEd>UTJvAYAMC#*8dm?LVH1qpVBkr~8V*w=dc74nCtE=D z?lCTUcby{sZ!ThY(|-El=wimlc{aN--2x^{r|P@$Q><|JTe)#*nEGMsU(_#Br;&O~ zPqwRZrNkj`Ix}vyL;B9JMS5k2P-byGpJ|DhflpUlq1l*5CZ#l=-Fc7ECq}%azZXQS z>QdtL)Q%=mLYfJoNb=NgSqG{}e3kY~g9Ba6A19AQjB&?~UZKVxWl$TcC1m%VX8F&5 zf2{GUtYAWTv)R0*m}tIfgKGKNFp}J6rz!Eq*|pIZ$*8Sz@nl?vdfb0IjY)q3EyRnJ5gILfA2ACk$TDs&r0li3&p$ZyuBZGIXmt1?+tZeSIfF&LauoY7SNd7}J@vSO5^`6u| zZ9v&?iW;3K?b4Ge88i)|6T*B68=F>fM&naDrZ!1_6vgQJjA5-W_Hjl#(F+VMVqtM(T2(E3;6%{hGNF@&(Uc`qb6H@{U~ zUG_{gFLe;h_vIMbnNH&&Zvp>ga8IrJ{em3!~Gbg4D_RY4W1=e~{<MGIHZhmAOR_fQp}5a4 zUT51h${FahjOle>GN{x^zb#j+iTx&t{zgB_MeVL&m`mw+YrGZPWp@ipoi?C)y2c!= z0&fw^lcJR1@DyLR^)8;VDT=V%EF>b&G+~D5HcHYMLM)GfVowdEt l8cu`ii^vAsS#O~@c(#dkDGh5MjFgkP^TRY54&`Lse*mAw()9oU literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/vectors.json b/wai/int-payload-conformance/vectors.json new file mode 100644 index 000000000..7c9abb29f --- /dev/null +++ b/wai/int-payload-conformance/vectors.json @@ -0,0 +1,578 @@ +{ + "format": "wai-int-payload-vectors/1", + "about": "Binary payload vectors of the WAI-native integer codecs and their refusal fixtures (extensions/integer-payloads.md §12). Paths are relative to wai/. Written by wai_int_payload_vectors gen.", + "vectors": [ + { + "name": "int_video", + "capability": "wai.video.int_motion", + "payload": "wai-rs/tests/vectors/int_video/int_video_bitstream.wiv1", + "payload_sha256": "3bbf0a864d9528e73aff036d1a636893f839d3f6c5f31dde7824a59e6c116565", + "converted_from": { + "json": "wai-rs/tests/vectors/int_video/int_video_bitstream.json" + }, + "set": {}, + "sidecar": "wai-rs/tests/vectors/int_video/sha256.json" + }, + { + "name": "video_real", + "capability": "wai.video.int_motion", + "payload": "wai-rs/tests/vectors/int_video/video_real_bitstream.wiv1", + "payload_sha256": "efce5ada256cb3443a673ee0c10aa4e0a8ce4a372102cd918627e5eec746880f", + "converted_from": { + "json": "wai-rs/tests/vectors/int_video/video_real_bitstream.json" + }, + "set": {}, + "sidecar": "wai-rs/tests/vectors/int_video/sha256.json" + }, + { + "name": "tiny", + "capability": "wai.neural.int_hyper", + "payload": "wai-rs/tests/vectors/int_hyper/tiny_bitstream.wih1", + "payload_sha256": "6d8a195a5a138452292b55d70a575d1fbb4372fd8614b3b821b676e5090c3c17", + "converted_from": { + "json": "wai-rs/tests/vectors/int_hyper/tiny_bitstream.json" + }, + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + }, + "sidecar": "wai-rs/tests/vectors/int_hyper/sha256.json" + }, + { + "name": "video_nk", + "capability": "wai.video.int_hyper", + "payload": "wai-rs/tests/vectors/int_video/video_nk_bitstream.wiv1", + "payload_sha256": "d2c62da643328bfbdf9eab129283322e9077a491c5ddd269464577f51a958bc8", + "converted_from": { + "json": "wai-rs/tests/vectors/int_video/video_nk_bitstream.json", + "keyframe": "wai-rs/tests/vectors/int_hyper/tiny_bitstream.wih1" + }, + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + }, + "sidecar": "wai-rs/tests/vectors/int_video/sha256.json" + }, + { + "name": "audio", + "capability": "wai.audio.int_codec", + "payload": "wai-rs/tests/vectors/int_audio/audio_bitstream.wia1", + "payload_sha256": "b45e727525f06ac1ac5a5a7121886c85a7c33613451acbb482c20e37f3c79055", + "converted_from": { + "json": "wai-rs/tests/vectors/int_audio/audio_bitstream.json", + "sample_rate": "wai-rs/tests/vectors/int_audio/audio.json" + }, + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + }, + "sidecar": "wai-rs/tests/vectors/int_audio/sha256.json" + }, + { + "name": "splat", + "capability": "wai.splat.int_codec", + "payload": "wai-rs/tests/vectors/int_splat/splat.wis1", + "payload_sha256": "473ea0223840758b26ff7d540d861e3b2c1565dbc2bec68b0235b3f50f19d422", + "converted_from": { + "json": "wai-rs/tests/vectors/int_splat/splat.json" + }, + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + }, + "sidecar": "wai-rs/tests/vectors/int_splat/sha256.json" + } + ], + "refusals": [ + { + "name": "wiv1_json_form", + "capability": "wai.video.int_motion", + "payload": "wai-rs/tests/vectors/int_video/int_video_bitstream.json", + "code": "bad_magic", + "layer": "parse", + "set": {} + }, + { + "name": "wiv1_under_audio", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wiv1_under_audio.bin", + "code": "format_capability_mismatch", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wiv1_keyframe_mismatch", + "capability": "wai.video.int_motion", + "payload": "int-payload-conformance/refusals/wiv1_keyframe_mismatch.bin", + "code": "keyframe_capability_mismatch", + "layer": "parse", + "set": {} + }, + { + "name": "wiv1_version", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_version.bin", + "code": "version", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_reserved", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_reserved.bin", + "code": "reserved_nonzero", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_mv_precision", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_mv_precision.bin", + "code": "field_range", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_q_zero", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_q_zero.bin", + "code": "field_range", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_too_large", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_too_large.bin", + "code": "too_large", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_cdf_malformed", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_cdf_malformed.bin", + "code": "cdf_malformed", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_stream_length", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_stream_length.bin", + "code": "stream_length", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_truncated", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_truncated.bin", + "code": "stream_length", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_nested_length", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_nested_length.bin", + "code": "nested_length", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_nested_version", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_nested_version.bin", + "code": "version", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_trailing", + "capability": "wai.video.int_motion", + "payload": "int-payload-conformance/refusals/wiv1_trailing.bin", + "code": "trailing", + "layer": "parse", + "set": {} + }, + { + "name": "wiv1_exhausted", + "capability": "wai.video.int_motion", + "payload": "int-payload-conformance/refusals/wiv1_exhausted.bin", + "code": "stream_exhausted", + "layer": "decode", + "set": {} + }, + { + "name": "wiv1_geometry", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_geometry.bin", + "code": "geometry_mismatch", + "layer": "decode", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_json_form", + "capability": "wai.neural.int_hyper", + "payload": "wai-rs/tests/vectors/int_hyper/tiny_bitstream.json", + "code": "bad_magic", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_under_video", + "capability": "wai.video.int_motion", + "payload": "int-payload-conformance/refusals/wih1_under_video.bin", + "code": "format_capability_mismatch", + "layer": "parse", + "set": {} + }, + { + "name": "wih1_reserved", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_reserved.bin", + "code": "reserved_nonzero", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_zero_dim", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_zero_dim.bin", + "code": "field_range", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_too_large", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_too_large.bin", + "code": "too_large", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_stream_length", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_stream_length.bin", + "code": "stream_length", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_trailing", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_trailing.bin", + "code": "trailing", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_model_mismatch", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_model_mismatch.bin", + "code": "model_mismatch", + "layer": "decode", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_geometry", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_geometry.bin", + "code": "geometry_mismatch", + "layer": "decode", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_exhausted", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_exhausted.bin", + "code": "stream_exhausted", + "layer": "decode", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wia1_json_form", + "capability": "wai.audio.int_codec", + "payload": "wai-rs/tests/vectors/int_audio/audio_bitstream.json", + "code": "bad_magic", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_under_splat", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wia1_under_splat.bin", + "code": "format_capability_mismatch", + "layer": "parse", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wia1_afrac_zero", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_afrac_zero.bin", + "code": "field_range", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_sr_zero", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_sr_zero.bin", + "code": "field_range", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_median_range", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_median_range.bin", + "code": "field_range", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_cdf_malformed", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_cdf_malformed.bin", + "code": "cdf_malformed", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_too_large", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_too_large.bin", + "code": "too_large", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_trailing", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_trailing.bin", + "code": "trailing", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_afrac_model", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_afrac_model.bin", + "code": "model_mismatch", + "layer": "decode", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_t_out", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_t_out.bin", + "code": "geometry_mismatch", + "layer": "decode", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wia1_exhausted", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_exhausted.bin", + "code": "stream_exhausted", + "layer": "decode", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, + { + "name": "wis1_json_form", + "capability": "wai.splat.int_codec", + "payload": "wai-rs/tests/vectors/int_splat/splat.json", + "code": "bad_magic", + "layer": "parse", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wis1_range_order", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wis1_range_order.bin", + "code": "field_range", + "layer": "parse", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wis1_range_nan", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wis1_range_nan.bin", + "code": "field_range", + "layer": "parse", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wis1_n_gaussians", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wis1_n_gaussians.bin", + "code": "field_range", + "layer": "parse", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wis1_cdf_len", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wis1_cdf_len.bin", + "code": "field_range", + "layer": "parse", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wis1_trailing", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wis1_trailing.bin", + "code": "trailing", + "layer": "parse", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wis1_geometry", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wis1_geometry.bin", + "code": "geometry_mismatch", + "layer": "decode", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wis1_afrac_model", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wis1_afrac_model.bin", + "code": "model_mismatch", + "layer": "decode", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + }, + { + "name": "wis1_exhausted", + "capability": "wai.splat.int_codec", + "payload": "int-payload-conformance/refusals/wis1_exhausted.bin", + "code": "stream_exhausted", + "layer": "decode", + "set": { + "g_s.whm": "wai-rs/tests/vectors/int_splat/splat_gs.bin" + } + } + ] +} diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index a23244d34..ee89aae83 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -470,6 +470,14 @@ name = "wai_int_synth_vectors" path = "src/bin/wai_int_synth_vectors.rs" required-features = ["neural_int"] +# The binary payload vectors of the integer codecs and their refusal fixtures +# (extensions/integer-payloads.md §12): `gen` converts the committed JSON, `verify` +# requires byte-identical regeneration, the sidecars' SHA-256 and every fixture's code. +[[bin]] +name = "wai_int_payload_vectors" +path = "src/bin/wai_int_payload_vectors.rs" +required-features = ["video_int", "audio_int", "splat_int"] + [[bin]] name = "wai_film_vectors" path = "src/bin/wai_film_vectors.rs" @@ -630,6 +638,16 @@ name = "int_mlicpp_preflight" path = "tests/int_mlicpp_preflight.rs" required-features = ["neural_int"] +[[test]] +name = "int_payload" +path = "tests/int_payload.rs" +required-features = ["video_int", "audio_int", "splat_int"] + +[[test]] +name = "int_payload_preflight" +path = "tests/int_payload_preflight.rs" +required-features = ["video_int", "audio_int", "splat_int"] + [[test]] name = "generate" path = "tests/generate.rs" diff --git a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs new file mode 100644 index 000000000..8b7e694a5 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs @@ -0,0 +1,413 @@ +//! `wai_int_payload_vectors` — writes and verifies the binary payload vectors of the +//! WAI-native integer codecs (`extensions/integer-payloads.md` §12). +//! +//! wai_int_payload_vectors gen +//! wai_int_payload_vectors verify +//! +//! `gen` converts each committed JSON bitstream under `wai-rs/tests/vectors/` to its +//! pinned payload, beside the JSON (`*.wiv1`, `*.wih1`, `*.wia1`, `*.wis1`), derives the +//! refusal fixtures from those payloads (`refusals/*.bin`), and writes the index +//! `vectors.json`. Every file it writes is a function of the committed JSON alone, so +//! a second run writes the same bytes. +//! +//! `verify` regenerates every file in memory and requires it to equal the committed +//! one byte for byte; decodes each payload through `int_payload::decode_int` against +//! the parameter set the index names, and requires the output's SHA-256 to be the one +//! the vector's `sha256.json` sidecar states; and requires each refusal fixture to be +//! refused with the code the index states. Exit status 1 on any failure. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use wai::int_payload::{decode_int, wire, IntDecoded, IntParams}; +use wai::pin::HeldBytes; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn sha256(b: &[u8]) -> String { + hex(&Sha256::digest(b)) +} + +/// The tree below `wai/`, read from and written to by path relative to it. +struct Tree { + wai: PathBuf, + out: BTreeMap>, +} + +impl Tree { + fn read(&self, rel: &str) -> Vec { + if let Some(b) = self.out.get(rel) { + return b.clone(); + } + let p = self.wai.join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) + } + fn json(&self, rel: &str) -> Value { + serde_json::from_slice(&self.read(rel)).unwrap_or_else(|e| panic!("parse {rel}: {e}")) + } + fn put(&mut self, rel: &str, bytes: Vec) { + self.out.insert(rel.to_string(), bytes); + } +} + +const V: &str = "wai-rs/tests/vectors"; +const R: &str = "int-payload-conformance/refusals"; + +/// One decodable vector: its name, capability, payload path, the JSON it is converted +/// from, the set members it decodes against (member name → path) and its sidecar entry. +struct Vector { + name: &'static str, + cap: &'static str, + payload: String, + from: Value, + set: Vec<(&'static str, String)>, + sidecar: String, +} + +fn int_hyper_set() -> Vec<(&'static str, String)> { + vec![ + ("entropy.json", format!("{V}/int_hyper/tiny_entropy.json")), + ("g_s.whm", format!("{V}/int_hyper/tiny_g_s.bin")), + ("h_s.whm", format!("{V}/int_hyper/tiny_h_s.bin")), + ] +} + +/// Convert every committed JSON bitstream to its payload. +fn vectors(t: &mut Tree) -> Vec { + use wai::int_payload::{wia1_from_json, wih1_from_json, wis1_from_json, wiv1_from_json}; + let mut out = Vec::new(); + for (name, json_rel, wiv1) in [ + ("int_video", "int_video/int_video_bitstream.json", "int_video/int_video_bitstream.wiv1"), + ("video_real", "int_video/video_real_bitstream.json", "int_video/video_real_bitstream.wiv1"), + ] { + let p = wiv1_from_json(&t.json(&format!("{V}/{json_rel}")), None).unwrap_or_else(|e| panic!("{name}: {e}")); + t.put(&format!("{V}/{wiv1}"), p); + out.push(Vector { + name, + cap: wai::codecs::CAP_VIDEO_INT_MOTION, + payload: format!("{V}/{wiv1}"), + from: json!({ "json": format!("{V}/{json_rel}") }), + set: vec![], + sidecar: format!("{V}/int_video/sha256.json"), + }); + } + let wih1 = wih1_from_json(&t.json(&format!("{V}/int_hyper/tiny_bitstream.json"))).expect("tiny WIH1"); + t.put(&format!("{V}/int_hyper/tiny_bitstream.wih1"), wih1.clone()); + out.push(Vector { + name: "tiny", + cap: wai::codecs::CAP_NEURAL_INT_HYPER, + payload: format!("{V}/int_hyper/tiny_bitstream.wih1"), + from: json!({ "json": format!("{V}/int_hyper/tiny_bitstream.json") }), + set: int_hyper_set(), + sidecar: format!("{V}/int_hyper/sha256.json"), + }); + let nk = wiv1_from_json(&t.json(&format!("{V}/int_video/video_nk_bitstream.json")), Some(&wih1)).expect("video_nk WIV1"); + t.put(&format!("{V}/int_video/video_nk_bitstream.wiv1"), nk); + out.push(Vector { + name: "video_nk", + cap: wai::codecs::CAP_VIDEO_INT_HYPER, + payload: format!("{V}/int_video/video_nk_bitstream.wiv1"), + from: json!({ "json": format!("{V}/int_video/video_nk_bitstream.json"), "keyframe": format!("{V}/int_hyper/tiny_bitstream.wih1") }), + set: int_hyper_set(), + sidecar: format!("{V}/int_video/sha256.json"), + }); + let sr = t.json(&format!("{V}/int_audio/audio.json"))["sample_rate"].as_u64().expect("sample_rate") as u32; + let wia1 = wia1_from_json(&t.json(&format!("{V}/int_audio/audio_bitstream.json")), sr).expect("audio WIA1"); + t.put(&format!("{V}/int_audio/audio_bitstream.wia1"), wia1); + out.push(Vector { + name: "audio", + cap: wai::codecs::CAP_AUDIO_INT_CODEC, + payload: format!("{V}/int_audio/audio_bitstream.wia1"), + from: json!({ "json": format!("{V}/int_audio/audio_bitstream.json"), "sample_rate": format!("{V}/int_audio/audio.json") }), + set: vec![("model.wam", format!("{V}/int_audio/audio_model.bin"))], + sidecar: format!("{V}/int_audio/sha256.json"), + }); + let wis1 = wis1_from_json(&t.json(&format!("{V}/int_splat/splat.json"))).expect("splat WIS1"); + t.put(&format!("{V}/int_splat/splat.wis1"), wis1); + out.push(Vector { + name: "splat", + cap: wai::codecs::CAP_SPLAT_INT_CODEC, + payload: format!("{V}/int_splat/splat.wis1"), + from: json!({ "json": format!("{V}/int_splat/splat.json") }), + set: vec![("g_s.whm", format!("{V}/int_splat/splat_gs.bin"))], + sidecar: format!("{V}/int_splat/sha256.json"), + }); + out +} + +/// One refusal fixture: its name, capability, payload path, the code it is refused +/// with, whether parsing alone refuses it (`parse`) or the decode does (`decode`), and +/// the set its decode reads. +struct Fixture { + name: &'static str, + cap: &'static str, + payload: String, + code: &'static str, + layer: &'static str, + set: Vec<(&'static str, String)>, +} + +fn set_u32(b: &mut [u8], off: usize, v: u32) { + b[off..off + 4].copy_from_slice(&v.to_le_bytes()); +} +fn get_u32(b: &[u8], off: usize) -> u32 { + u32::from_le_bytes(b[off..off + 4].try_into().unwrap()) +} + +/// The refusal fixtures, each the committed payload changed in one place. +fn fixtures(t: &mut Tree) -> Vec { + use wai::codecs::{CAP_AUDIO_INT_CODEC as AUD, CAP_NEURAL_INT_HYPER as HYP, CAP_SPLAT_INT_CODEC as SPL, + CAP_VIDEO_INT_HYPER as VNK, CAP_VIDEO_INT_MOTION as MOT}; + let iv = t.read(&format!("{V}/int_video/int_video_bitstream.wiv1")); + let nk = t.read(&format!("{V}/int_video/video_nk_bitstream.wiv1")); + let ih = t.read(&format!("{V}/int_hyper/tiny_bitstream.wih1")); + let au = t.read(&format!("{V}/int_audio/audio_bitstream.wia1")); + let sp = t.read(&format!("{V}/int_splat/splat.wis1")); + let mut out = Vec::new(); + let mut fx = |t: &mut Tree, name: &'static str, cap: &'static str, bytes: Option>, payload: Option, code: &'static str, layer: &'static str| { + let payload = match bytes { + Some(b) => { + let rel = format!("{R}/{name}.bin"); + t.put(&rel, b); + rel + } + None => payload.expect("a fixture names its payload"), + }; + let set = match cap { + c if c == HYP || c == VNK => int_hyper_set(), + c if c == AUD => vec![("model.wam", format!("{V}/int_audio/audio_model.bin"))], + c if c == SPL => vec![("g_s.whm", format!("{V}/int_splat/splat_gs.bin"))], + _ => vec![], + }; + out.push(Fixture { name, cap, payload, code, layer, set }); + }; + let edit = |base: &[u8], f: &dyn Fn(&mut Vec)| { + let mut b = base.to_vec(); + f(&mut b); + Some(b) + }; + + // ── WIV1 ── (keyframe 1 is video_nk: 16×16, the nested tiny WIH1) + let parsed = wire::parse_wiv1(&nk, Some(1)).expect("video_nk parses"); + let nk_l = at(&nk, parsed.rans) - 4; // offset of L + let nk_k = at(&nk, parsed.wih1.expect("keyframe 1")) - 4; // offset of K + fx(t, "wiv1_json_form", MOT, None, Some(format!("{V}/int_video/int_video_bitstream.json")), "bad_magic", "parse"); + fx(t, "wiv1_under_audio", AUD, Some(nk.clone()), None, "format_capability_mismatch", "parse"); + fx(t, "wiv1_keyframe_mismatch", MOT, Some(nk.clone()), None, "keyframe_capability_mismatch", "parse"); + fx(t, "wiv1_version", VNK, edit(&nk, &|b| b[4] = 2), None, "version", "parse"); + fx(t, "wiv1_reserved", VNK, edit(&nk, &|b| b[7] = 1), None, "reserved_nonzero", "parse"); + fx(t, "wiv1_mv_precision", VNK, edit(&nk, &|b| b[5] = 3), None, "field_range", "parse"); + fx(t, "wiv1_q_zero", VNK, edit(&nk, &|b| set_u32(b, 24, 0)), None, "field_range", "parse"); + fx(t, "wiv1_too_large", VNK, edit(&nk, &|b| { set_u32(b, 12, 8192); set_u32(b, 16, 8192) }), None, "too_large", "parse"); + fx(t, "wiv1_cdf_malformed", VNK, edit(&nk, &|b| { let v = get_u32(b, 40); set_u32(b, 44, v) }), None, "cdf_malformed", "parse"); + fx(t, "wiv1_stream_length", VNK, edit(&nk, &|b| { let l = get_u32(b, nk_l); set_u32(b, nk_l, l - 2) }), None, "stream_length", "parse"); + fx(t, "wiv1_truncated", VNK, edit(&nk, &|b| b.truncate(nk_k + 2)), None, "stream_length", "parse"); + fx(t, "wiv1_nested_length", VNK, edit(&nk, &|b| { let k = get_u32(b, nk_k); set_u32(b, nk_k, k + 4) }), None, "nested_length", "parse"); + fx(t, "wiv1_nested_version", VNK, edit(&nk, &|b| b[nk_k + 4 + 4] = 2), None, "version", "parse"); + fx(t, "wiv1_trailing", MOT, edit(&iv, &|b| b.push(0)), None, "trailing", "parse"); + let iv_l = at(&iv, wire::parse_wiv1(&iv, Some(0)).expect("int_video parses").rans) - 4; + fx(t, "wiv1_exhausted", MOT, edit(&iv, &|b| { set_u32(b, iv_l, 8); b.truncate(iv_l + 4 + 8) }), None, "stream_exhausted", "decode"); + let geo = { + let mut v = wire::parse_wiv1(&nk, Some(1)).expect("video_nk parses"); + (v.h, v.w) = (12, 12); // the same 2 × 2 block grid; the keyframe decodes 16 × 16 + wire::write_wiv1(&v) + }; + fx(t, "wiv1_geometry", VNK, Some(geo), None, "geometry_mismatch", "decode"); + + // ── WIH1 ── (the tiny conditional codec: m 4, 4×4; n_z 2, 2×2) + fx(t, "wih1_json_form", HYP, None, Some(format!("{V}/int_hyper/tiny_bitstream.json")), "bad_magic", "parse"); + fx(t, "wih1_under_video", MOT, Some(ih.clone()), None, "format_capability_mismatch", "parse"); + fx(t, "wih1_reserved", HYP, edit(&ih, &|b| b[6] = 1), None, "reserved_nonzero", "parse"); + fx(t, "wih1_zero_dim", HYP, edit(&ih, &|b| set_u32(b, 8, 0)), None, "field_range", "parse"); + fx(t, "wih1_too_large", HYP, edit(&ih, &|b| { set_u32(b, 24, 65_536); set_u32(b, 28, 65_536) }), None, "too_large", "parse"); + fx(t, "wih1_stream_length", HYP, edit(&ih, &|b| set_u32(b, 32, 6)), None, "stream_length", "parse"); + fx(t, "wih1_trailing", HYP, edit(&ih, &|b| b.push(0)), None, "trailing", "parse"); + fx(t, "wih1_model_mismatch", HYP, edit(&ih, &|b| set_u32(b, 20, 3)), None, "model_mismatch", "decode"); + fx(t, "wih1_geometry", HYP, edit(&ih, &|b| { set_u32(b, 12, 8); set_u32(b, 16, 8) }), None, "geometry_mismatch", "decode"); + let ih_ly = at(&ih, wire::parse_wih1(&ih).expect("tiny parses").rans) - 4; + fx(t, "wih1_exhausted", HYP, edit(&ih, &|b| { set_u32(b, ih_ly, 8); b.truncate(ih_ly + 4 + 8) }), None, "stream_exhausted", "decode"); + + // ── WIA1 ── (16 channels × 512, tables of 14) + let au_n = at(&au, wire::parse_wia1(&au).expect("audio parses").rans) - 4; // offset of L + fx(t, "wia1_json_form", AUD, None, Some(format!("{V}/int_audio/audio_bitstream.json")), "bad_magic", "parse"); + fx(t, "wia1_under_splat", SPL, Some(au.clone()), None, "format_capability_mismatch", "parse"); + fx(t, "wia1_afrac_zero", AUD, edit(&au, &|b| b[5] = 0), None, "field_range", "parse"); + fx(t, "wia1_sr_zero", AUD, edit(&au, &|b| set_u32(b, 8, 0)), None, "field_range", "parse"); + fx(t, "wia1_median_range", AUD, edit(&au, &|b| b[32..40].copy_from_slice(&((1i64 << 47) + 1).to_le_bytes())), None, "field_range", "parse"); + fx(t, "wia1_cdf_malformed", AUD, edit(&au, &|b| set_u32(b, 160 + 13 * 4, 65_535)), None, "cdf_malformed", "parse"); + fx(t, "wia1_too_large", AUD, edit(&au, &|b| { set_u32(b, 12, 65_536); set_u32(b, 16, 2_048) }), None, "too_large", "parse"); + fx(t, "wia1_trailing", AUD, edit(&au, &|b| b.push(0)), None, "trailing", "parse"); + fx(t, "wia1_afrac_model", AUD, edit(&au, &|b| b[5] = 11), None, "model_mismatch", "decode"); + fx(t, "wia1_t_out", AUD, edit(&au, &|b| { let n = get_u32(b, 20); set_u32(b, 20, n + 1) }), None, "geometry_mismatch", "decode"); + fx(t, "wia1_exhausted", AUD, edit(&au, &|b| { set_u32(b, au_n, 8); b.truncate(au_n + 4 + 8) }), None, "stream_exhausted", "decode"); + + // ── WIS1 ── (32 channels of 8×8 → 14 planes of 32×32) + let wis = |f: &dyn Fn(&mut wire::Wis1<'_>)| { + let mut v = wire::parse_wis1(&sp).expect("splat parses"); + f(&mut v); + Some(wire::write_wis1(&v)) + }; + fx(t, "wis1_json_form", SPL, None, Some(format!("{V}/int_splat/splat.json")), "bad_magic", "parse"); + fx(t, "wis1_range_order", SPL, wis(&|v| v.ranges[0] = (1.0, -1.0)), None, "field_range", "parse"); + fx(t, "wis1_range_nan", SPL, wis(&|v| v.ranges[0].0 = f32::NAN), None, "field_range", "parse"); + fx(t, "wis1_n_gaussians", SPL, wis(&|v| v.n_gaussians = v.s * v.s + 1), None, "field_range", "parse"); + fx(t, "wis1_cdf_len", SPL, edit(&sp, &|b| b[32 + 14 * 8..32 + 14 * 8 + 2].copy_from_slice(&2u16.to_le_bytes())), None, "field_range", "parse"); + fx(t, "wis1_trailing", SPL, edit(&sp, &|b| b.push(0)), None, "trailing", "parse"); + fx(t, "wis1_geometry", SPL, wis(&|v| { v.attr = 13; v.ranges.truncate(13) }), None, "geometry_mismatch", "decode"); + fx(t, "wis1_afrac_model", SPL, edit(&sp, &|b| b[5] = 11), None, "model_mismatch", "decode"); + let sp_l = at(&sp, wire::parse_wis1(&sp).expect("splat parses").rans) - 4; + fx(t, "wis1_exhausted", SPL, edit(&sp, &|b| { set_u32(b, sp_l, 8); b.truncate(sp_l + 4 + 8) }), None, "stream_exhausted", "decode"); + out +} + +/// The offset in `base` at which `part`, a slice of it, starts. +fn at(base: &[u8], part: &[u8]) -> usize { + part.as_ptr() as usize - base.as_ptr() as usize +} + +/// Build every file: the payloads, the fixtures, the index. +fn build(wai: &Path) -> Tree { + let mut t = Tree { wai: wai.to_path_buf(), out: BTreeMap::new() }; + let vs = vectors(&mut t); + let fs = fixtures(&mut t); + let set = |s: &[(&str, String)]| -> Value { s.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::>().into() }; + let index = json!({ + "format": "wai-int-payload-vectors/1", + "about": "Binary payload vectors of the WAI-native integer codecs and their refusal fixtures (extensions/integer-payloads.md §12). Paths are relative to wai/. Written by wai_int_payload_vectors gen.", + "vectors": vs.iter().map(|v| json!({ + "name": v.name, + "capability": v.cap, + "payload": v.payload, + "payload_sha256": sha256(&t.read(&v.payload)), + "converted_from": v.from, + "set": set(&v.set), + "sidecar": v.sidecar, + })).collect::>(), + "refusals": fs.iter().map(|f| json!({ + "name": f.name, + "capability": f.cap, + "payload": f.payload, + "code": f.code, + "layer": f.layer, + "set": set(&f.set), + })).collect::>(), + }); + let mut text = serde_json::to_string_pretty(&index).expect("index"); + text.push('\n'); + t.put("int-payload-conformance/vectors.json", text.into_bytes()); + t +} + +/// The SHA-256 digests of a decode's output, in the form its sidecar entry states. +fn digests(d: &IntDecoded) -> Value { + match d { + IntDecoded::Video { frames, .. } => { + let per: Vec<[u8; 32]> = frames.iter().map(|f| Sha256::digest(f).into()).collect(); + let clip = Sha256::digest(per.concat()); + json!({ "frames_sha256": per.iter().map(|d| hex(d)).collect::>(), "clip_sha256": hex(&clip) }) + } + IntDecoded::Image { rgb, .. } => json!({ "sha256": sha256(rgb) }), + IntDecoded::Pcm { samples, .. } => { + let le: Vec = samples.iter().flat_map(|s| s.to_le_bytes()).collect(); + json!({ "sha256": sha256(&le) }) + } + IntDecoded::Attr { bytes, .. } => json!({ "sha256": sha256(bytes) }), + _ => json!(null), + } +} + +/// Decode `payload` through `cap` against the set `set` names (member → path). +fn decode(t: &Tree, cap: &str, payload: &[u8], set: &Value) -> Result { + let owned: BTreeMap> = set.as_object().into_iter().flatten() + .map(|(k, v)| (k.clone(), t.read(v.as_str().expect("a set path")))).collect(); + let view: BTreeMap = owned.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let params = if view.is_empty() { IntParams::None } else { IntParams::Unverified(HeldBytes::Set { listing: b"", files: &view }) }; + decode_int(cap, payload, ¶ms) +} + +fn verify(wai: &Path) -> bool { + let t = build(wai); + let mut ok = true; + for (rel, bytes) in &t.out { + match std::fs::read(wai.join(rel)) { + Ok(have) if &have == bytes => {} + Ok(_) => { println!("FAIL {rel}: differs from its regeneration"); ok = false } + Err(e) => { println!("FAIL {rel}: {e}"); ok = false } + } + } + let index: Value = serde_json::from_slice(&t.out["int-payload-conformance/vectors.json"]).expect("index"); + for v in index["vectors"].as_array().expect("vectors") { + let (name, cap) = (v["name"].as_str().unwrap(), v["capability"].as_str().unwrap()); + let payload = t.read(v["payload"].as_str().unwrap()); + match decode(&t, cap, &payload, &v["set"]) { + Ok(d) => { + let want = &t.json(v["sidecar"].as_str().unwrap())[name]; + let got = digests(&d); + let same = got.as_object().unwrap().iter().all(|(k, g)| &want[k] == g); + if same { + println!("ok {name} ({cap}): decodes to its sidecar's SHA-256"); + } else { + println!("FAIL {name} ({cap}): decoded {got} ≠ sidecar"); + ok = false; + } + } + Err(e) => { println!("FAIL {name} ({cap}): {e}"); ok = false } + } + } + let mut seen = std::collections::BTreeSet::new(); + for f in index["refusals"].as_array().expect("refusals") { + let (name, cap, code) = (f["name"].as_str().unwrap(), f["capability"].as_str().unwrap(), f["code"].as_str().unwrap()); + let payload = t.read(f["payload"].as_str().unwrap()); + let parsed = match wai::codecs::pinned_int_payload(cap) { + Some(wire::MAGIC_WIV1) => wire::parse_wiv1(&payload, Some((cap == wai::codecs::CAP_VIDEO_INT_HYPER) as u8)).err(), + Some(wire::MAGIC_WIH1) => wire::parse_wih1(&payload).err(), + Some(wire::MAGIC_WIA1) => wire::parse_wia1(&payload).err(), + Some(wire::MAGIC_WIS1) => wire::parse_wis1(&payload).err(), + _ => None, + }; + let layer_ok = (f["layer"] == "parse") == parsed.is_some(); + match decode(&t, cap, &payload, &f["set"]) { + Err(e) if e.code() == code && layer_ok => { seen.insert(code); println!("ok {name}: {code}") } + Err(e) => { println!("FAIL {name}: refused with {e}, want {code} at {}", f["layer"]); ok = false } + Ok(_) => { println!("FAIL {name}: decoded, want {code}"); ok = false } + } + } + println!("{} refusal codes covered by fixtures", seen.len()); + ok +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let (Some(cmd), Some(dir)) = (args.get(1), args.get(2)) else { + eprintln!("usage: wai_int_payload_vectors gen|verify "); + return ExitCode::from(2); + }; + let wai = Path::new(dir).join(".."); + match cmd.as_str() { + "gen" => { + let t = build(&wai); + for (rel, bytes) in &t.out { + let p = wai.join(rel); + if let Some(d) = p.parent() { + std::fs::create_dir_all(d).expect("create directory"); + } + std::fs::write(&p, bytes).unwrap_or_else(|e| panic!("write {}: {e}", p.display())); + } + println!("wrote {} files", t.out.len()); + ExitCode::SUCCESS + } + "verify" => { + if verify(&wai) { ExitCode::SUCCESS } else { ExitCode::FAILURE } + } + _ => { + eprintln!("usage: wai_int_payload_vectors gen|verify "); + ExitCode::from(2) + } + } +} diff --git a/wai/wai-rs/src/codecs/mod.rs b/wai/wai-rs/src/codecs/mod.rs index 09c794592..506fb5211 100644 --- a/wai/wai-rs/src/codecs/mod.rs +++ b/wai/wai-rs/src/codecs/mod.rs @@ -47,8 +47,8 @@ pub const CAP_AUDIO_FLAC: &str = "wai.audio.flac"; // lossless // Video pub const CAP_VIDEO_AV1: &str = "wai.video.av1"; // lossy AV1 pub const CAP_VIDEO_AV1_LOSSLESS: &str = "wai.video.av1.lossless"; // lossless AV1 -pub const CAP_VIDEO_INT_MOTION: &str = "wai.video.int_motion"; // DETERMINISTIC INTEGER inter-frame codec (int_video.rs): I-frame + integer block motion-compensated P-frames + integer-quantized residual over rANS. Decoder IMPLEMENTED, pure i64, no float/ONNX — every frame is byte-identical on every machine (frame-state-equivalence; per-frame portable hash). The temporal extension of the integer contract. Bitstream from tools/wai_int_video_encode.py -pub const CAP_VIDEO_INT_HYPER: &str = "wai.video.int_hyper"; // NEURAL-KEYFRAME inter-frame codec: the I-frame is the LEARNED integer codec int_hyper (wai.neural.int_hyper) decoded byte-exact, the P-frames are integer block-MC residuals on top (int_video::decode_pframes). A learned keyframe you can put in a receipt + integer inter-frames — the whole decode integer, byte-identical everywhere. Composes int_hyper_synth + int_video; pure i64 +pub const CAP_VIDEO_INT_MOTION: &str = "wai.video.int_motion"; // DETERMINISTIC INTEGER inter-frame codec (int_video.rs): I-frame + integer block motion-compensated P-frames + integer-quantized residual over rANS. Decoder IMPLEMENTED, pure i64, no float/ONNX — every frame is byte-identical on every machine (frame-state-equivalence; per-frame portable hash). The temporal extension of the integer contract. Payload WIV1, keyframe 0 (extensions/integer-payloads.md); the encode tools write a JSON form a converter reads +pub const CAP_VIDEO_INT_HYPER: &str = "wai.video.int_hyper"; // NEURAL-KEYFRAME inter-frame codec: the I-frame is the LEARNED integer codec int_hyper (wai.neural.int_hyper) decoded byte-exact, the P-frames are integer block-MC residuals on top (int_video::decode_pframes_typed). A learned keyframe you can put in a receipt + integer inter-frames — the whole decode integer, byte-identical everywhere. Composes int_hyper_synth + int_video; pure i64. Payload WIV1, keyframe 1, a nested WIH1 (extensions/integer-payloads.md) // Text pub const CAP_TEXT_ZSTD: &str = "wai.text.zstd"; // general-purpose pub const CAP_TEXT_XZ: &str = "wai.text.xz"; // maximum classical ratio @@ -61,7 +61,7 @@ pub const CAP_NEURAL_WAVTOKENIZER: &str = "wai.neural.wavtokenizer"; // ultra-lo pub const CAP_NEURAL_BMSHJ2018: &str = "wai.neural.bmshj2018"; // bmshj2018-factorized image codec (2018 baseline) pub const CAP_NEURAL_MBT2018_MEAN: &str = "wai.neural.mbt2018_mean"; // mean-scale HYPERPRIOR image codec (Gaussian-conditional entropy) — decoder IMPLEMENTED in this crate (neural/hyperprior.rs); the conditional-entropy family: a mean-scale hyperprior conditions each latent's Gaussian; sink-supplied ONNX (hyper_synthesis + synthesis) pub const CAP_NEURAL_INT_SYNTH: &str = "wai.neural.int_synth"; // INTEGER fixed-point synthesis (neural/int_synth.rs): all-i64 transposed-conv decode, no float/ONNX — bit-identical on every machine BY CONSTRUCTION. The first capability that can soundly back intent=replicate cross-hardware (determinism IS integer-exact). Weights are sink-supplied (int_synth.json from a QAT model) -pub const CAP_NEURAL_INT_HYPER: &str = "wai.neural.int_hyper"; // INTEGER fixed-point CONDITIONAL codec (int_hyper_synth.rs): the full Gaussian-conditional decode integer end-to-end — factorized rANS-decode z (z_q = s·2^afrac + median_q) → integer hyper-decoder h_s → σ,μ → integer-σ bucket (int_entropy) → integer rANS-decode residual → y_hat = r·2^afrac + μ → integer synthesis g_s. Both bitstreams entropy-coded, no raw latents. No float/ONNX anywhere, so the WHOLE bitstream→image decode is bit-identical on every machine BY CONSTRUCTION — int_synth's sibling that adds the conditional entropy. Quality: PTQ of the pretrained mbt2018_mean (quality 6); CI (wai-sota-vectors.yml) requires the integer decode within 1 dB of the same model's float decode on a pinned test photograph — measured cost there 0.19 dB (35.985 vs 36.175 dB, arm64 macOS); image-dependent, see int_hyper_synth.rs. Tables sink-supplied +pub const CAP_NEURAL_INT_HYPER: &str = "wai.neural.int_hyper"; // INTEGER fixed-point CONDITIONAL codec (int_hyper_synth.rs): the full Gaussian-conditional decode integer end-to-end — factorized rANS-decode z (z_q = s·2^afrac + median_q) → integer hyper-decoder h_s → σ,μ → integer-σ bucket (int_entropy) → integer rANS-decode residual → y_hat = r·2^afrac + μ → integer synthesis g_s. Both bitstreams entropy-coded, no raw latents; payload WIH1 (extensions/integer-payloads.md). No float/ONNX anywhere, so the WHOLE bitstream→image decode is bit-identical on every machine BY CONSTRUCTION — int_synth's sibling that adds the conditional entropy. Quality: PTQ of the pretrained mbt2018_mean (quality 6); CI (wai-sota-vectors.yml) requires the integer decode within 1 dB of the same model's float decode on a pinned test photograph — measured cost there 0.19 dB (35.985 vs 36.175 dB, arm64 macOS); image-dependent, see int_hyper_synth.rs. Tables sink-supplied pub const CAP_NEURAL_INT_MLICPP: &str = "wai.neural.int_mlicpp"; // INTEGER MLIC++-topology codec (int_mlicpp.rs): the channel-autoregressive × checkerboard entropy model — K channel slices, each decoded as an anchor pass then a non-anchor pass, with an entropy network over concat[y decoded so far, hyper] giving σ‖μ before every pass — with every decode-side network integer (int_hyper_synth networks, int_entropy bucketing, integer rANS). The encoder and every decoder compute the same σ, so the same bucket, at any thread count: no float/ONNX anywhere, byte-identical on every machine BY CONSTRUCTION. Refuses any stream value outside the exact integer range. Payload (WIM1) and one-file model bundle (WMB1, so a prior pins every network and table) specified in int_mlicpp.rs. Conformance vector tests/vectors/int_mlicpp/ has SYNTHETIC weights (seeded, quantised like a trained model): it shows decode-equivalence, not compression quality. Model sink-supplied pub const CAP_NEURAL_ELIC: &str = "wai.neural.elic"; // checkerboard CONTEXT-MODEL image codec (neural/context.rs) — the JPEG-AI/MLIC++ entropy class: hyperprior + 2-pass spatial context (anchor → context net → non-anchor). Decoder IMPLEMENTED; sink-supplied ONNX (hyper_synthesis + context + synthesis). WAI-native, NOT the ISO JPEG AI bitstream pub const CAP_NEURAL_MLICPP: &str = "wai.neural.mlicpp"; // MLIC++: the multi-reference entropy model — channel-autoregressive slices + checkerboard + global attention. Decoder IMPLEMENTED (neural/mlicpp.rs): the slice/checkerboard decode orchestration; the attention nets are sink-supplied ONNX (hyper_synthesis + entropy + synthesis) @@ -136,8 +136,10 @@ pub const CAP_SPLAT_SPZ: &str = "wai.splat.spz"; // synthesis (int_hyper machinery, 14 channels) over a factorized-rANS latent → // byte-exact attribute tensor on every machine (splat-equivalence; a portable cloud // hash). The int_hyper of 3D — the decoded attributes you can put in a receipt. -// Decoder IMPLEMENTED, pure i64, no float/ONNX. Model + bitstream from -// tools/wai_int_splat_export.py. The float rasterizer stays sink-supplied. +// Decoder IMPLEMENTED, pure i64, no float/ONNX. Payload WIS1, with each attribute's +// presentation range (extensions/integer-payloads.md); its parameter set is one file, +// the WHM1 synthesis. tools/wai_int_splat_export.py writes the model and a JSON form a +// converter reads. The float rasterizer stays sink-supplied. pub const CAP_SPLAT_INT_CODEC: &str = "wai.splat.int_codec"; // Object-based spatial audio (extensions/spatial-audio, Phase 1). Ship @@ -161,10 +163,29 @@ pub const CAP_AUDIO_SCORE: &str = "wai.audio.score"; // decoding the per-channel factorized-rANS latent to int16 PCM byte-identically on // every machine (sample-equivalence; a portable PCM hash). The audio analog of // int_hyper — a neural audio codec whose reconstruction you can put in a receipt. -// Decoder IMPLEMENTED, pure i64, no float/ONNX. Model + bitstream from -// tools/wai_int_audio_export.py +// Decoder IMPLEMENTED, pure i64, no float/ONNX. Payload WIA1, with its sample rate +// (extensions/integer-payloads.md); its parameter set is one file, the WAM1 synthesis. +// tools/wai_int_audio_export.py writes the model and a JSON form a converter reads. pub const CAP_AUDIO_INT_CODEC: &str = "wai.audio.int_codec"; +/// SPEC §5, *Payloads of the WAI-native integer codecs*: each capability whose payload +/// this revision pins to a format of `extensions/integer-payloads.md`, with the +/// format's magic. The one table `int_payload`, `container::check_int_payload_framing` +/// (and so `wai wrap` and `wai inspect`) and the SPEC test read. `WIV1` is pinned twice: +/// keyframe 0 for `wai.video.int_motion`, keyframe 1 for `wai.video.int_hyper`. +pub const PINNED_INT_PAYLOADS: &[(&str, [u8; 4])] = &[ + (CAP_VIDEO_INT_MOTION, *b"WIV1"), + (CAP_VIDEO_INT_HYPER, *b"WIV1"), + (CAP_NEURAL_INT_HYPER, *b"WIH1"), + (CAP_AUDIO_INT_CODEC, *b"WIA1"), + (CAP_SPLAT_INT_CODEC, *b"WIS1"), +]; + +/// The magic of the format SPEC §5 pins `cap`'s payload to, or `None`. +pub fn pinned_int_payload(cap: &str) -> Option<[u8; 4]> { + PINNED_INT_PAYLOADS.iter().find(|(c, _)| *c == cap).map(|&(_, m)| m) +} + // Haptics (extensions/haptics): per-channel intensity/sharpness envelope, // reconstructed to an actuator signal (sample-equivalence). Engine // behind the `haptic` feature. @@ -1265,9 +1286,11 @@ pub fn parameter_set_shape(cap: &str) -> Option { } Some(match cap { // int_synth: its model specification, int_synth.json. int_mlicpp: its WMB1 - // bundle. The audio codecs and the per-frame video codec: one self-contained - // ONNX decoder (a model that keeps tensors in external files is several files). - CAP_NEURAL_INT_SYNTH | CAP_NEURAL_INT_MLICPP + // bundle. audio.int_codec: its WAM1 synthesis (model.wam). splat.int_codec: its + // WHM1 synthesis (g_s.whm). The audio codecs and the per-frame video codec: one + // self-contained ONNX decoder (a model that keeps tensors in external files is + // several files). + CAP_NEURAL_INT_SYNTH | CAP_NEURAL_INT_MLICPP | CAP_AUDIO_INT_CODEC | CAP_SPLAT_INT_CODEC | CAP_NEURAL_ENCODEC32 | CAP_NEURAL_DAC | CAP_NEURAL_MIMI | CAP_NEURAL_WAVTOKENIZER | CAP_NEURAL_SNAC | CAP_NEURAL_VIDEO_BMSHJ2018 => ParameterSetShape::OneFile, _ => ParameterSetShape::SeveralFiles, @@ -1993,14 +2016,15 @@ mod tests { } } one_file.sort_unstable(); - let mut want = vec![CAP_NEURAL_INT_SYNTH, CAP_NEURAL_INT_MLICPP, CAP_NEURAL_ENCODEC32, CAP_NEURAL_DAC, - CAP_NEURAL_MIMI, CAP_NEURAL_WAVTOKENIZER, CAP_NEURAL_SNAC, CAP_NEURAL_VIDEO_BMSHJ2018]; + let mut want = vec![CAP_NEURAL_INT_SYNTH, CAP_NEURAL_INT_MLICPP, CAP_AUDIO_INT_CODEC, CAP_SPLAT_INT_CODEC, + CAP_NEURAL_ENCODEC32, CAP_NEURAL_DAC, CAP_NEURAL_MIMI, CAP_NEURAL_WAVTOKENIZER, + CAP_NEURAL_SNAC, CAP_NEURAL_VIDEO_BMSHJ2018]; want.sort_unstable(); assert_eq!(one_file, want); for c in [CAP_IMAGE_PNG, CAP_SEMANTIC_CLIP, CAP_PRIOR_SET, CAP_WORLD_MODEL] { assert!(!decodes_against_parameter_set(c), "{c}"); } - for c in [CAP_IMAGE_JPEGAI, CAP_NEURAL_GLC, CAP_GENERATE_SPECTRAL, CAP_AUDIO_INT_CODEC] { + for c in [CAP_IMAGE_JPEGAI, CAP_NEURAL_GLC, CAP_GENERATE_SPECTRAL, CAP_NEURAL_INT_HYPER, CAP_VIDEO_INT_HYPER] { assert_eq!(parameter_set_shape(c), Some(ParameterSetShape::SeveralFiles), "{c}"); } // The listing form: lossless, royalty-free, StandardDefined. diff --git a/wai/wai-rs/src/container.rs b/wai/wai-rs/src/container.rs index 581a03fb5..678d42551 100644 --- a/wai/wai-rs/src/container.rs +++ b/wai/wai-rs/src/container.rs @@ -1036,6 +1036,17 @@ impl Wai { check_candidates_v1(self.capability(), self.fallback()) } + /// Check that the payload has the framing SPEC §5 pins for the `capability`, and + /// for the `fallback` (step 5 decodes the same payload through it), where either + /// is an integer capability with a pinned format ([`check_int_payload_framing`]). + pub fn check_int_payload_framing(&self) -> Result<(), FramingError> { + check_int_payload_framing(self.capability(), &self.payload)?; + match self.fallback() { + Some(fb) => check_int_payload_framing(fb, &self.payload), + None => Ok(()), + } + } + /// The envelope's bytes, for an encoder to emit: [`Self::check_candidates`], /// then [`Self::to_bytes`]. The C packer `wai_envelope_pack` emits through /// this, so an encoder that uses it cannot emit a candidate (SPEC §5). @@ -1330,6 +1341,50 @@ pub fn check_component_candidates<'a>(capabilities: impl IntoIterator) -> std::fmt::Result { + let want = crate::codecs::pinned_int_payload(&self.capability).map(|m| String::from_utf8_lossy(&m).into_owned()); + write!(f, "the payload is not the {} payload SPEC \u{a7}5 pins {} to ({}; extensions/integer-payloads.md)", + want.as_deref().unwrap_or("?"), self.capability, self.code) + } +} + +impl std::error::Error for FramingError {} + +/// Check that `payload` has the framing of the format SPEC §5 pins `capability`'s +/// payload to ([`crate::codecs::PINNED_INT_PAYLOADS`]): its magic, then version 1. +/// `Ok` for a capability with no pinned integer format. The same first checks, in the +/// same order and with the same codes, as `int_payload::wire`'s parsers make. +pub fn check_int_payload_framing(capability: &str, payload: &[u8]) -> Result<(), FramingError> { + let Some(want) = crate::codecs::pinned_int_payload(capability) else { + return Ok(()); + }; + let fail = |code| Err(FramingError { capability: capability.to_string(), code }); + match payload.get(0..4) { + Some(m) if m == want => {} + Some(m) if crate::codecs::PINNED_INT_PAYLOADS.iter().any(|(_, k)| k[..] == *m) => return fail("format_capability_mismatch"), + _ => return fail("bad_magic"), + } + match payload.get(4) { + None => fail("stream_length"), + Some(1) => Ok(()), + Some(_) => fail("version"), + } +} + /// Why [`Wai::to_emit_bytes`] or [`WaiMulti::to_emit_bytes`] wrote nothing. #[derive(Debug)] #[non_exhaustive] @@ -1708,6 +1763,16 @@ impl WaiMulti { check_component_candidates(self.manifest.renditions.iter().map(|m| m.capability.as_str())) } + /// [`check_int_payload_framing`] over every component, in table order: the + /// first component whose payload is not its capability's pinned format, with its + /// index. + pub fn check_int_payload_framing(&self) -> Result<(), (usize, FramingError)> { + for (i, (m, p)) in self.manifest.renditions.iter().zip(&self.renditions).enumerate() { + check_int_payload_framing(&m.capability, p).map_err(|e| (i, e))?; + } + Ok(()) + } + /// The envelope's bytes, for an encoder to emit: [`Self::check_candidates`], /// then [`Self::to_bytes`]. The C packer `wai_envelope_pack_multi` emits /// through this, so an encoder that uses it cannot emit a candidate (SPEC §5). diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs new file mode 100644 index 000000000..b0cfb8fdf --- /dev/null +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -0,0 +1,521 @@ +//! Payloads of the WAI-native integer codecs +//! ([`extensions/integer-payloads.md`](../../../extensions/integer-payloads.md)) and +//! the one integer decode path every sink shares. +//! +//! SPEC §5 pins each of these capabilities' payload to a binary format +//! ([`crate::codecs::PINNED_INT_PAYLOADS`]): `WIV1` for `wai.video.int_motion` +//! (keyframe 0) and `wai.video.int_hyper` (keyframe 1, a nested `WIH1`), `WIH1` for +//! `wai.neural.int_hyper`, `WIA1` for `wai.audio.int_codec` and `WIS1` for +//! `wai.splat.int_codec`. [`wire`] parses and writes them. The JSON the export and +//! encode tools write is a converter input ([`wiv1_from_json`], [`wih1_from_json`], +//! [`wia1_from_json`], [`wis1_from_json`]) and a debugging aid, never a payload: a +//! payload that is not the pinned format is refused (`bad_magic`). +//! +//! [`decode_int`] decodes every integer capability, these five and +//! `wai.neural.int_synth` and `wai.neural.int_mlicpp`, from the parameter files a +//! [`IntParams`] holds. It needs no ONNX runtime and no filesystem, so it builds under +//! `neural_int` and its media features alone, natively and for `wasm32`; the neural +//! sink (`neural::decode_payload`, feature `neural`) delegates these capabilities to +//! it. [`resolve`] verifies a pin over sets held in memory, running SPEC §3.1's check +//! 5 when the sink holds a key log, and runs the same check over the set an unpinned +//! decode reads (SPEC §4, *Module authorisation*). +//! +//! Every refusal is a code from the closed list of the extension's §8 +//! ([`Refusal`]); a refusal is final, and nothing is decoded in its place. + +pub mod wire; + +#[cfg(test)] +mod tests; + +pub use wire::{Refusal, WireError}; + +use crate::codecs::{ + CAP_AUDIO_INT_CODEC, CAP_NEURAL_INT_HYPER, CAP_NEURAL_INT_MLICPP, CAP_NEURAL_INT_SYNTH, CAP_SPLAT_INT_CODEC, + CAP_VIDEO_INT_HYPER, +}; +use crate::container::{PinRefusal, Prior}; +use crate::pin::{HeldBytes, PinAuthority, Verified, VerifiedRef}; + +/// Why [`decode_int`] or a converter refused: a code of the extension's closed list, +/// and what failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntPayloadError { + refusal: Refusal, + detail: String, +} + +impl IntPayloadError { + pub fn new(refusal: Refusal, detail: impl Into) -> Self { + IntPayloadError { refusal, detail: detail.into() } + } + /// The refusal. + pub fn refusal(&self) -> Refusal { + self.refusal + } + /// The code the extension's §8 names. + pub fn code(&self) -> &'static str { + self.refusal.code() + } + /// What failed. For [`Refusal::SetIncomplete`], the path of the file the set lacks. + pub fn detail(&self) -> &str { + &self.detail + } +} + +impl std::fmt::Display for IntPayloadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.refusal.code(), self.detail) + } +} + +impl std::error::Error for IntPayloadError {} + +impl From for IntPayloadError { + fn from(e: WireError) -> Self { + IntPayloadError::new(e.refusal, e.detail) + } +} + +impl From for IntPayloadError { + fn from(r: crate::int_hyper_synth::Refused) -> Self { + use crate::int_hyper_synth::RefusalKind as K; + let refusal = match r.kind { + K::FieldRange => Refusal::FieldRange, + K::TooLarge => Refusal::TooLarge, + K::CdfMalformed => Refusal::CdfMalformed, + K::StreamExhausted => Refusal::StreamExhausted, + K::GeometryMismatch => Refusal::GeometryMismatch, + K::ModelMismatch => Refusal::ModelMismatch, + }; + IntPayloadError::new(refusal, r.detail) + } +} + +fn model(detail: impl Into) -> IntPayloadError { + IntPayloadError::new(Refusal::ModelMismatch, detail) +} + +/// The files a capability's decode reads from its parameter set, by the names a set +/// lists them under (SPEC §5; `extensions/prior-carriage.md` §7). Empty for a +/// capability that decodes against no set, or that this module does not decode. +pub fn set_members(cap: &str) -> &'static [&'static str] { + match cap { + CAP_NEURAL_INT_HYPER | CAP_VIDEO_INT_HYPER => &["entropy.json", "g_s.whm", "h_s.whm"], + CAP_AUDIO_INT_CODEC => &["model.wam"], + CAP_SPLAT_INT_CODEC => &["g_s.whm"], + CAP_NEURAL_INT_SYNTH => &["int_synth.json"], + CAP_NEURAL_INT_MLICPP => &["model.wmb"], + _ => &[], + } +} + +/// The parameter set one integer decode reads, in memory. +/// +/// A pinned decode reads only bytes its pin verified: [`IntParams::Verified`] holds +/// what `pin::verify` returned (a native sink), [`IntParams::Held`] the held set +/// `pin::verify_bytes` matched and, for the form `wai.prior.set`, the paths its +/// listing names (a sink with no filesystem). An unpinned decode reads the set the +/// sink registers for the capability, unverified ([`IntParams::Unverified`]). +/// [`resolve`] builds the right one from a pin and the sets a sink holds. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum IntParams<'a> { + /// The capability decodes against no parameter set (`wai.video.int_motion`). + None, + /// A set a pin verified, as the bytes that were hashed. + Verified(&'a Verified), + /// A held set a pin verified in memory; `paths` are the files its listing names + /// (`None` for a form-less pin over one file). + Held { set: HeldBytes<'a>, paths: Option> }, + /// An unpinned decode's set: the one the sink registers for the capability. + Unverified(HeldBytes<'a>), +} + +impl<'a> IntParams<'a> { + /// The parameters a pin verified with `pin::verify_bytes` over `held`. + pub fn from_verified_ref(held: &[HeldBytes<'a>], r: &VerifiedRef) -> Self { + match r { + VerifiedRef::File { index } => IntParams::Held { set: held[*index], paths: None }, + VerifiedRef::Set { index, paths } => IntParams::Held { set: held[*index], paths: Some(paths.clone()) }, + } + } + + /// Whether a pin verified these parameters. + pub fn is_pinned(&self) -> bool { + matches!(self, IntParams::Verified(_) | IntParams::Held { .. }) + } + + /// The set member `name` that `cap`'s decode reads. A one-file set (a form-less + /// pin's, or a one-file capability's unpinned file) answers for the capability's + /// one member; a listed set answers for a path it lists. A file the set does not + /// hold is `set_incomplete`, naming it. + pub fn member(&self, cap: &str, name: &str) -> Result<&'a [u8], IntPayloadError> { + let one = set_members(cap) == [name]; + let found = match self { + IntParams::None => None, + IntParams::Verified(Verified::File(b)) => one.then_some(b.as_slice()), + IntParams::Verified(v @ Verified::Set { .. }) => v.file(name).ok(), + IntParams::Held { set: HeldBytes::File(b), .. } | IntParams::Unverified(HeldBytes::File(b)) => one.then_some(*b), + IntParams::Held { set: HeldBytes::Set { files, .. }, paths } => { + if paths.as_ref().is_none_or(|p| p.iter().any(|x| x == name)) { files.get(name).copied() } else { None } + } + IntParams::Unverified(HeldBytes::Set { files, .. }) => files.get(name).copied(), + }; + found.ok_or_else(|| IntPayloadError::new(Refusal::SetIncomplete, name)) + } +} + +/// The digests a pin over `set` could name, for check 5 over an unpinned decode: the +/// listing's, and, for a one-file capability, the one file's. +fn unpinned_digests(cap: &str, set: &HeldBytes<'_>) -> Vec { + let one_file = crate::container::pin_shape(cap) == crate::codecs::ParameterSetShape::OneFile; + let mut out = Vec::new(); + match set { + HeldBytes::File(b) if one_file => out.push(crate::pin::sha256_hex(b)), + HeldBytes::File(_) => {} + HeldBytes::Set { listing, files } => { + if one_file { + if let Some(b) = set_members(cap).first().and_then(|m| files.get(*m)) { + out.push(crate::pin::sha256_hex(b)); + } + } + out.push(crate::pin::sha256_hex(listing)); + } + } + out +} + +/// The parameters `cap`'s decode reads, at a sink that holds the sets `held` in memory +/// and, where it has one, the key log `authority` (SPEC §3.1, §4). +/// +/// - A capability that decodes against no parameter set reads none. +/// - Where a pin applies and the envelope carries one, `pin::verify_bytes_with` runs +/// SPEC §3.1's checks in order, check 5 included where `authority` is given, and the +/// decode reads the bytes it verified. +/// - Otherwise the decode is unpinned and reads `held[0]`, the set the sink registers +/// for the capability; where `authority` is given, check 5 runs first over that set, +/// under every digest a pin over it could name (its listing's, and a one-file +/// capability's file's), and refuses a revoked or unauthorised set. +pub fn resolve<'a>( + cap: &str, + prior: Option<&Prior>, + held: &[HeldBytes<'a>], + authority: Option<&dyn PinAuthority>, +) -> Result, PinRefusal> { + if crate::codecs::is_known_capability(cap) && !crate::codecs::decodes_against_parameter_set(cap) { + return Ok(IntParams::None); + } + match prior { + Some(p) if crate::container::pin_applies_to(cap) => { + let r = crate::pin::verify_bytes_with(cap, p, held, crate::pin::READABLE_FORMS, authority)?; + Ok(IntParams::from_verified_ref(held, &r)) + } + _ => { + let set = *held.first().ok_or_else(|| PinRefusal::NotHeld { capability: cap.to_string() })?; + if let Some(a) = authority { + a.check_unpinned(cap, &unpinned_digests(cap, &set))?; + } + Ok(IntParams::Unverified(set)) + } + } +} + +/// What an integer decode gives. Pixels are RGB8, rows top to bottom, pixels left to +/// right; PCM is `i16`, channels interleaved. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum IntDecoded { + /// `wai.neural.int_hyper`, `wai.neural.int_synth`, `wai.neural.int_mlicpp`. + Image { rgb: Vec, width: u32, height: u32 }, + /// `wai.video.int_motion`, `wai.video.int_hyper`: every frame, in order. + Video { frames: Vec>, width: u32, height: u32 }, + /// `wai.audio.int_codec`: the PCM, its sample rate and channel count. + Pcm { samples: Vec, sample_rate: u32, channels: u32 }, + /// `wai.splat.int_codec`: the `[n_attr, s, s]` attribute tensor (the + /// splat-equivalence digest's buffer), the cloud's Gaussian count, and each + /// attribute's presentation range `(lo, hi)`, which the digest does not cover. + Attr { bytes: Vec, n_attr: u32, s: u32, n_gaussians: u32, ranges: Vec<(f32, f32)> }, +} + +/// Decode `payload` through the integer capability `cap` against `params`. +/// +/// The capability must be dispatchable (SPEC §4, `codecs::is_dispatchable`). For the +/// five capabilities SPEC §5 pins a format to, the payload must be that format +/// (`bad_magic` otherwise; a JSON form is refused here, before any parameter is +/// read) and is checked as the extension's §2–§7 give, in that order; the decode then +/// reads its set members, checks that they fit the payload, and runs the checked +/// integer decoder. `wai.neural.int_synth` and `wai.neural.int_mlicpp` decode through +/// their own decoders. A capability this build has no decoder for is +/// `unsupported_capability`. +pub fn decode_int(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { + if !crate::codecs::is_dispatchable(cap) { + return Err(IntPayloadError::new(Refusal::NotDispatchable, format!("{cap} is not dispatchable (SPEC \u{a7}4)"))); + } + match crate::codecs::pinned_int_payload(cap) { + Some(wire::MAGIC_WIV1) => decode_wiv1(cap, payload, params), + Some(wire::MAGIC_WIH1) => decode_wih1(cap, payload, params), + Some(wire::MAGIC_WIA1) => decode_wia1(cap, payload, params), + Some(wire::MAGIC_WIS1) => decode_wis1(cap, payload, params), + _ => match cap { + CAP_NEURAL_INT_SYNTH => { + let spec = params.member(cap, "int_synth.json")?; + let img = crate::int_synth::decode_from_spec(payload, spec).map_err(|e| { + let crate::int_synth::IntSynthError::InvalidPayload(why) = e; + IntPayloadError::new(Refusal::PayloadMalformed, why) + })?; + Ok(IntDecoded::Image { rgb: img.rgb, width: img.width, height: img.height }) + } + CAP_NEURAL_INT_MLICPP => { + use crate::int_mlicpp::IntMlicError; + let mlic = |e: IntMlicError| match e { + IntMlicError::Payload(why) => IntPayloadError::new(Refusal::PayloadMalformed, why), + IntMlicError::Model(why) => model(why), + }; + let m = crate::int_mlicpp::IntMlicModel::from_bundle(params.member(cap, "model.wmb")?).map_err(mlic)?; + let img = crate::int_mlicpp::decode(&m, payload).map_err(mlic)?; + Ok(IntDecoded::Image { rgb: img.rgb, width: img.width as u32, height: img.height as u32 }) + } + _ => Err(unsupported(cap, "no integer decoder for this capability")), + }, + } +} + +fn unsupported(cap: &str, why: &str) -> IntPayloadError { + IntPayloadError::new(Refusal::UnsupportedCapability, format!("{cap}: {why}")) +} + +/// The conditional codec's three set members, parsed and checked. +fn int_hyper_models( + cap: &str, + params: &IntParams<'_>, +) -> Result<(crate::int_hyper_synth::IntModel, crate::int_hyper_synth::IntModel, crate::int_hyper_synth::CondEntropy), IntPayloadError> { + use crate::int_hyper_synth::{CondEntropy, IntModel}; + let net = |name: &str| -> Result { + IntModel::from_bytes(params.member(cap, name)?).map_err(|e| model(format!("{name}: {e}"))) + }; + let g_s = net("g_s.whm")?; + let h_s = net("h_s.whm")?; + let ent: serde_json::Value = serde_json::from_slice(params.member(cap, "entropy.json")?) + .map_err(|e| model(format!("entropy.json: {e}")))?; + let ent = CondEntropy::try_from_value(&ent).map_err(|e| model(format!("entropy.json: {e}")))?; + Ok((g_s, h_s, ent)) +} + +/// A parsed `WIH1` decoded to RGB with the capability's set. +fn wih1_image(cap: &str, k: &wire::Wih1<'_>, params: &IntParams<'_>) -> Result<(Vec, u32, u32), IntPayloadError> { + let (g_s, h_s, ent) = int_hyper_models(cap, params)?; + let s = crate::int_hyper_synth::CondStream { + m: k.m as usize, sh: k.sh as usize, sw: k.sw as usize, + n_z: k.n_z as usize, zh: k.zh as usize, zw: k.zw as usize, + z_rans: k.z_rans, rans: k.rans, + }; + let (rgb, h, w) = crate::int_hyper_synth::decode_conditional_checked(&g_s, &h_s, &ent, &s)?; + Ok((rgb, h as u32, w as u32)) +} + +fn decode_wih1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { + let k = wire::parse_wih1(payload)?; + let (rgb, height, width) = wih1_image(cap, &k, params)?; + Ok(IntDecoded::Image { rgb, width, height }) +} + +#[cfg(feature = "video_int")] +fn decode_wiv1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { + use crate::int_video::{decode_pframes_typed, decode_typed, VideoStream, STREAM_EXHAUSTED}; + let keyframe = if cap == crate::codecs::CAP_VIDEO_INT_MOTION { 0 } else { 1 }; + let v = wire::parse_wiv1(payload, Some(keyframe))?; + let s = VideoStream { + n_frames: v.n_frames as usize, h: v.h as usize, w: v.w as usize, block: v.block as usize, + q: v.q as i64, offset: v.offset, mv_precision: v.mv_precision, cdf: &v.cdf, mvs: &v.mvs, rans: v.rans, + }; + let video = |e: &'static str| { + IntPayloadError::new(if e == STREAM_EXHAUSTED { Refusal::StreamExhausted } else { Refusal::FieldRange }, e) + }; + let clip = match v.wih1 { + None => decode_typed(&s).map_err(video)?, + Some(nested) => { + let k = wire::parse_wih1(nested)?; + let (frame0, h, w) = wih1_image(cap, &k, params)?; + if (h, w) != (v.h, v.w) { + return Err(IntPayloadError::new(Refusal::GeometryMismatch, "the keyframe's size is not the clip's h × w")); + } + decode_pframes_typed(&frame0, &s).map_err(video)? + } + }; + Ok(IntDecoded::Video { frames: clip.frames, width: v.w, height: v.h }) +} + +#[cfg(not(feature = "video_int"))] +fn decode_wiv1(cap: &str, _: &[u8], _: &IntParams<'_>) -> Result { + Err(unsupported(cap, "this build has no integer video decoder (feature video_int)")) +} + +#[cfg(feature = "audio_int")] +fn decode_wia1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { + use crate::int_audio::{decode_typed, AudioModel, AudioStream}; + let a = wire::parse_wia1(payload)?; + let m = AudioModel::from_bytes(params.member(cap, "model.wam")?).map_err(|e| model(format!("model.wam: {e}")))?; + let s = AudioStream { + afrac: a.afrac as u32, n_ch: a.n_ch as usize, lat_len: a.lat_len as usize, t_out: a.t_out as usize, + offset: a.offset, median_q: &a.median_q, cdfs: &a.cdfs, rans: a.rans, + }; + let samples = decode_typed(&m, &s)?; + Ok(IntDecoded::Pcm { samples, sample_rate: a.sr, channels: 1 }) +} + +#[cfg(not(feature = "audio_int"))] +fn decode_wia1(cap: &str, _: &[u8], _: &IntParams<'_>) -> Result { + Err(unsupported(cap, "this build has no integer audio decoder (feature audio_int)")) +} + +#[cfg(feature = "splat_int")] +fn decode_wis1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { + use crate::int_hyper_synth::IntModel; + use crate::int_splat::{decode_typed, SplatStream}; + let v = wire::parse_wis1(payload)?; + let gs = IntModel::from_bytes(params.member(cap, "g_s.whm")?).map_err(|e| model(format!("g_s.whm: {e}")))?; + let s = SplatStream { + afrac: v.afrac as u32, c: v.c as usize, sh: v.sh as usize, sw: v.sw as usize, s: v.s as usize, + n_attr: v.attr as usize, cdfs: &v.cdfs, offsets: &v.offsets, median_q: &v.median_q, rans: v.rans, + }; + let d = decode_typed(&gs, &s)?; + Ok(IntDecoded::Attr { bytes: d.attr, n_attr: v.attr, s: v.s, n_gaussians: v.n_gaussians, ranges: v.ranges }) +} + +#[cfg(not(feature = "splat_int"))] +fn decode_wis1(cap: &str, _: &[u8], _: &IntParams<'_>) -> Result { + Err(unsupported(cap, "this build has no integer splat decoder (feature splat_int)")) +} + +// ───────────────────────── converters (JSON tool output → payload) ───────────────────────── + +/// A converter's refusal of its JSON input. +fn json(why: &str) -> IntPayloadError { + IntPayloadError::new(Refusal::FieldRange, format!("the JSON form: {why}")) +} + +#[cfg(any(feature = "video_int", feature = "audio_int", feature = "splat_int"))] +fn to_u32(v: usize, what: &str) -> Result { + u32::try_from(v).map_err(|_| json(&format!("{what} exceeds u32"))) +} + +#[cfg(any(feature = "video_int", feature = "audio_int", feature = "splat_int"))] +fn table_len(len: usize) -> Result<(), IntPayloadError> { + if !(wire::CDF_LEN_MIN..=wire::CDF_LEN_MAX).contains(&len) { + return Err(json("a CDF table's length is outside 3..=4097")); + } + Ok(()) +} + +/// Convert the video encoders' JSON bitstream to `WIV1`: keyframe 0, or keyframe 1 +/// with `keyframe_wih1` (a `WIH1` payload) as its nested I-frame. The JSON's implicit +/// rules are made explicit as [`crate::int_video::VideoJson`] states; `q` must be in +/// `1..=65535`. The result is parsed before it is returned, so a converter never +/// emits a payload a sink would refuse at parse. +#[cfg(feature = "video_int")] +pub fn wiv1_from_json(bs: &serde_json::Value, keyframe_wih1: Option<&[u8]>) -> Result, IntPayloadError> { + let j = crate::int_video::VideoJson::from_value(bs).map_err(json)?; + let q = u32::try_from(j.q).ok().filter(|q| (1..=wire::MAX_Q).contains(q)).ok_or_else(|| json("q is outside 1..=65535"))?; + table_len(j.cdf.len())?; + let v = wire::Wiv1 { + mv_precision: j.mv_precision, + keyframe: keyframe_wih1.is_some() as u8, + n_frames: to_u32(j.n_frames, "n_frames")?, + h: to_u32(j.h, "h")?, + w: to_u32(j.w, "w")?, + block: to_u32(j.block, "block")?, + q, + offset: j.offset, + cdf: j.cdf.clone(), + mvs: j.mvs.clone(), + rans: &j.rans, + wih1: keyframe_wih1, + }; + let out = wire::write_wiv1(&v); + wire::parse_wiv1(&out, None)?; + Ok(out) +} + +/// Convert the conditional codec's JSON bitstream (`cond_bitstream.json`) to `WIH1`. +pub fn wih1_from_json(bs: &serde_json::Value) -> Result, IntPayloadError> { + let dim = |k: &str| bs[k].as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| json(&format!("`{k}` is missing or out of range"))); + let bytes = |k: &str| -> Result, IntPayloadError> { + bs[k].as_array().and_then(|a| a.iter().map(|b| b.as_u64().and_then(|b| u8::try_from(b).ok())).collect::>>()) + .ok_or_else(|| json(&format!("`{k}` is not an array of bytes"))) + }; + let (z_rans, rans) = (bytes("z_rans")?, bytes("rans")?); + let v = wire::Wih1 { + m: dim("m")?, sh: dim("sh")?, sw: dim("sw")?, n_z: dim("n_z")?, zh: dim("zh")?, zw: dim("zw")?, + z_rans: &z_rans, rans: &rans, + }; + let out = wire::write_wih1(&v); + wire::parse_wih1(&out)?; + Ok(out) +} + +/// Convert the audio export's JSON bitstream (`audio_bitstream.json`) to `WIA1` at +/// the sample rate `sr` (the export's `audio.json` carries it as `sample_rate`; the +/// bitstream does not). The JSON's implicit rules are made explicit as +/// [`crate::int_audio::AudioJson`] states. +#[cfg(feature = "audio_int")] +pub fn wia1_from_json(bs: &serde_json::Value, sr: u32) -> Result, IntPayloadError> { + let j = crate::int_audio::AudioJson::from_value(bs).map_err(json)?; + table_len(j.cdfs[0].len())?; + let v = wire::Wia1 { + afrac: u8::try_from(j.afrac).map_err(|_| json("afrac exceeds a byte"))?, + sr, + n_ch: to_u32(j.n_ch, "n_ch")?, + lat_len: to_u32(j.lat_len, "lat_len")?, + t_out: to_u32(j.t_out, "t_out")?, + offset: j.offset, + median_q: j.median_q.clone(), + cdfs: j.cdfs.clone(), + rans: &j.rans, + }; + let out = wire::write_wia1(&v); + wire::parse_wia1(&out)?; + Ok(out) +} + +/// Convert the splat export's JSON bitstream (`splat.json`) to `WIS1`, with its +/// per-attribute presentation ranges. The export writes each range as a binary32 +/// value; a range that is not one is refused, so the payload carries what the JSON +/// states. `serde_json`'s default number reader is not correctly rounded and can land +/// one f64 unit in the last place from the decimal's value, so a value within one unit +/// of a binary32 value is read as that value; a decimal that does not denote a binary32 +/// value is further away than that by many orders of magnitude. +#[cfg(feature = "splat_int")] +pub fn wis1_from_json(bs: &serde_json::Value) -> Result, IntPayloadError> { + let j = crate::int_splat::SplatJson::from_value(bs).map_err(json)?; + for t in &j.cdfs { + table_len(t.len())?; + } + let exact = |x: f64| -> Result { + let f = x as f32; + let back = f as f64; + if back == x || (back.is_sign_negative() == x.is_sign_negative() && back.to_bits().abs_diff(x.to_bits()) <= 1) { + Ok(f) + } else { + Err(json("a range is not a binary32 value")) + } + }; + let ranges = j.ranges.iter().map(|&(lo, hi)| Ok((exact(lo)?, exact(hi)?))).collect::, IntPayloadError>>()?; + let v = wire::Wis1 { + afrac: u8::try_from(j.afrac).map_err(|_| json("afrac exceeds a byte"))?, + c: to_u32(j.c, "c")?, + sh: to_u32(j.sh, "sh")?, + sw: to_u32(j.sw, "sw")?, + s: to_u32(j.s, "s")?, + attr: to_u32(j.n_attr, "attr")?, + n_gaussians: to_u32(j.n_gaussians, "n_gaussians")?, + ranges, + cdfs: j.cdfs.clone(), + offsets: j.offsets.clone(), + median_q: j.median_q.clone(), + rans: &j.rans, + }; + let out = wire::write_wis1(&v); + wire::parse_wis1(&out)?; + Ok(out) +} diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs new file mode 100644 index 000000000..97949e1af --- /dev/null +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -0,0 +1,482 @@ +//! `int_payload`: the binary forms against the JSON they are converted from, the +//! integer decode path without the neural sink, the closed refusal list, and SPEC +//! §3.1's check 5 through [`super::resolve`]. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use sha2::{Digest, Sha256}; + +use super::*; +use crate::int_kernels::{with_policy, Policy}; + +const POLICIES: [Policy; 5] = [Policy::reference(), Policy::fast(1), Policy::fast(2), Policy::fast(5), Policy::fast(0)]; + +fn vroot() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors") +} +fn read(rel: &str) -> Vec { + let p = vroot().join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} +fn jload(rel: &str) -> serde_json::Value { + serde_json::from_slice(&read(rel)).unwrap_or_else(|e| panic!("parse {rel}: {e}")) +} +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} +fn sha(b: &[u8]) -> String { + hex(&Sha256::digest(b)) +} + +/// Decode under every kernel policy; all outputs must be identical. +fn same_under_every_policy(what: &str, f: impl Fn() -> T) -> T { + let want = with_policy(POLICIES[0], &f); + for p in &POLICIES[1..] { + assert!(with_policy(*p, &f) == want, "{what}: output under {p:?} differs from the reference kernels"); + } + want +} + +/// The tiny conditional codec's set, by member name. +fn int_hyper_set() -> BTreeMap> { + [("entropy.json", "int_hyper/tiny_entropy.json"), ("g_s.whm", "int_hyper/tiny_g_s.bin"), ("h_s.whm", "int_hyper/tiny_h_s.bin")] + .into_iter().map(|(k, v)| (k.to_string(), read(v))).collect() +} +fn view(m: &BTreeMap>) -> BTreeMap { + m.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect() +} + +/// Every frame's SHA-256 and the clip's, against the sidecar entry `key`. +#[cfg(feature = "video_int")] +fn assert_frames_sidecar(frames: &[Vec], key: &str) { + let side = jload("int_video/sha256.json"); + let want = side[key]["frames_sha256"].as_array().unwrap(); + assert_eq!(want.len(), frames.len(), "{key}: frame count"); + let per: Vec<[u8; 32]> = frames.iter().map(|f| Sha256::digest(f).into()).collect(); + for (i, d) in per.iter().enumerate() { + assert_eq!(hex(d), want[i].as_str().unwrap(), "{key}: frame {i}"); + } + assert_eq!(sha(&per.concat()), side[key]["clip_sha256"].as_str().unwrap(), "{key}: clip"); +} + +/// §6.1 test 17, `WIV1`: the converter writes the committed payload; the payload's +/// fields are the JSON's; the payload decodes to the frames the JSON decodes to, +/// under every kernel policy, and to the sidecar's SHA-256 and the committed FNV-1a. +#[cfg(feature = "video_int")] +#[test] +fn wiv1_json_equivalence() { + use crate::int_video::{decode, decode_pframes, fnv1a, VideoJson}; + for (name, json, bin, conf) in [ + ("int_video", "int_video/int_video_bitstream.json", "int_video/int_video_bitstream.wiv1", "int_video/int_video.json"), + ("video_real", "int_video/video_real_bitstream.json", "int_video/video_real_bitstream.wiv1", "int_video/video_real.json"), + ] { + let js = jload(json); + let payload = read(bin); + assert_eq!(wiv1_from_json(&js, None).unwrap(), payload, "{name}: the converter writes the committed payload"); + let (v, j) = (wire::parse_wiv1(&payload, Some(0)).unwrap(), VideoJson::from_value(&js).unwrap()); + assert_eq!((v.n_frames as usize, v.h as usize, v.w as usize, v.block as usize, v.q as i64, v.offset, v.mv_precision), + (j.n_frames, j.h, j.w, j.block, j.q, j.offset, j.mv_precision), "{name}: header"); + assert_eq!((&v.cdf, &v.mvs, v.rans), (&j.cdf, &j.mvs, j.rans.as_slice()), "{name}: tables and stream"); + let frames = same_under_every_policy(name, || match decode_int(crate::codecs::CAP_VIDEO_INT_MOTION, &payload, &IntParams::None) { + Ok(IntDecoded::Video { frames, .. }) => frames, + other => panic!("{name}: {other:?}"), + }); + assert_eq!(frames, decode(&js).unwrap().frames, "{name}: the binary and JSON decodes agree"); + assert_frames_sidecar(&frames, name); + let want = jload(conf); + for (f, w) in frames.iter().zip(want["frame_hashes"].as_array().unwrap()) { + assert_eq!(format!("{:016x}", fnv1a(f)), w.as_str().unwrap(), "{name}: FNV-1a"); + } + } + // Keyframe 1: the nested WIH1 is the tiny conditional codec's payload. + let (set, wih1) = (int_hyper_set(), read("int_hyper/tiny_bitstream.wih1")); + let js = jload("int_video/video_nk_bitstream.json"); + let payload = read("int_video/video_nk_bitstream.wiv1"); + assert_eq!(wiv1_from_json(&js, Some(&wih1)).unwrap(), payload, "video_nk: the converter writes the committed payload"); + assert_eq!(wire::parse_wiv1(&payload, Some(1)).unwrap().wih1, Some(wih1.as_slice())); + let files = view(&set); + let params = IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }); + let frames = same_under_every_policy("video_nk", || match decode_int(crate::codecs::CAP_VIDEO_INT_HYPER, &payload, ¶ms) { + Ok(IntDecoded::Video { frames, .. }) => frames, + other => panic!("video_nk: {other:?}"), + }); + let legacy = { + use crate::int_hyper_synth::{decode_conditional, CondBitstream, CondEntropy, IntModel}; + let g_s = IntModel::from_json(&jload("int_hyper/tiny_g_s.json")); + let h_s = IntModel::from_json(&jload("int_hyper/tiny_h_s.json")); + let ent = CondEntropy::from_value(&jload("int_hyper/tiny_entropy.json")); + let (f0, h, w) = decode_conditional(&g_s, &h_s, &ent, &CondBitstream::from_value(&jload("int_hyper/tiny_bitstream.json"))).unwrap(); + decode_pframes(&f0, h, w, &js).unwrap().frames + }; + assert_eq!(frames, legacy, "video_nk: the binary and JSON decodes agree"); + assert_frames_sidecar(&frames, "video_nk"); +} + +/// §6.1 test 17, `WIH1`. +#[test] +fn wih1_json_equivalence() { + use crate::int_hyper_synth::{decode_conditional, fnv1a, CondBitstream, CondEntropy, IntModel}; + let js = jload("int_hyper/tiny_bitstream.json"); + let payload = read("int_hyper/tiny_bitstream.wih1"); + assert_eq!(wih1_from_json(&js).unwrap(), payload, "the converter writes the committed payload"); + let (k, b) = (wire::parse_wih1(&payload).unwrap(), CondBitstream::from_value(&js)); + assert_eq!((k.m as usize, k.sh as usize, k.sw as usize, k.n_z as usize, k.zh as usize, k.zw as usize), + (b.m, b.sh, b.sw, b.n_z, b.zh, b.zw)); + assert_eq!((k.z_rans, k.rans), (b.z_rans.as_slice(), b.rans.as_slice())); + let set = int_hyper_set(); + let files = view(&set); + let params = IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }); + let rgb = same_under_every_policy("tiny", || match decode_int(crate::codecs::CAP_NEURAL_INT_HYPER, &payload, ¶ms) { + Ok(IntDecoded::Image { rgb, width: 16, height: 16 }) => rgb, + other => panic!("tiny: {other:?}"), + }); + let g_s = IntModel::from_json(&jload("int_hyper/tiny_g_s.json")); + let h_s = IntModel::from_json(&jload("int_hyper/tiny_h_s.json")); + let ent = CondEntropy::from_value(&jload("int_hyper/tiny_entropy.json")); + assert_eq!(rgb, decode_conditional(&g_s, &h_s, &ent, &b).unwrap().0, "the binary and JSON decodes agree"); + assert_eq!(sha(&rgb), jload("int_hyper/sha256.json")["tiny"]["sha256"].as_str().unwrap()); + assert_eq!(format!("{:016x}", fnv1a(&rgb)), jload("int_hyper/tiny.json")["rgb_hash"].as_str().unwrap()); + // The checked entropy reader accepts the committed tables and gives what the + // unchecked one gives. + let checked = CondEntropy::try_from_value(&jload("int_hyper/tiny_entropy.json")).unwrap(); + assert_eq!((checked.edges, checked.cdfs, checked.z_median_q), (ent.edges, ent.cdfs, ent.z_median_q)); +} + +/// §6.1 test 17, `WIA1`. +#[cfg(feature = "audio_int")] +#[test] +fn wia1_json_equivalence() { + use crate::int_audio::{decode, pcm_fnv1a, AudioJson, AudioModel}; + let (js, conf) = (jload("int_audio/audio_bitstream.json"), jload("int_audio/audio.json")); + let sr = conf["sample_rate"].as_u64().unwrap() as u32; + let payload = read("int_audio/audio_bitstream.wia1"); + assert_eq!(wia1_from_json(&js, sr).unwrap(), payload, "the converter writes the committed payload"); + let (a, j) = (wire::parse_wia1(&payload).unwrap(), AudioJson::from_value(&js).unwrap()); + assert_eq!((a.afrac as u32, a.sr, a.n_ch as usize, a.lat_len as usize, a.t_out as usize, a.offset), + (j.afrac, sr, j.n_ch, j.lat_len, j.t_out, j.offset)); + assert_eq!((&a.median_q, &a.cdfs, a.rans), (&j.median_q, &j.cdfs, j.rans.as_slice())); + let model = read("int_audio/audio_model.bin"); + let params = IntParams::Unverified(HeldBytes::File(&model)); + let pcm = same_under_every_policy("audio", || match decode_int(crate::codecs::CAP_AUDIO_INT_CODEC, &payload, ¶ms) { + Ok(IntDecoded::Pcm { samples, sample_rate: 16_000, channels: 1 }) => samples, + other => panic!("audio: {other:?}"), + }); + assert_eq!(pcm, decode(&AudioModel::from_bytes(&model).unwrap(), &js).unwrap(), "the binary and JSON decodes agree"); + let s16le: Vec = pcm.iter().flat_map(|s| s.to_le_bytes()).collect(); + assert_eq!(sha(&s16le), jload("int_audio/sha256.json")["audio"]["sha256"].as_str().unwrap()); + assert_eq!(format!("{:016x}", pcm_fnv1a(&pcm)), conf["pcm_fnv1a"].as_str().unwrap()); +} + +/// §6.1 test 17, `WIS1`, with the presentation ranges the JSON states. +#[cfg(feature = "splat_int")] +#[test] +fn wis1_json_equivalence() { + use crate::int_hyper_synth::{fnv1a, IntModel}; + use crate::int_splat::{decode, SplatJson}; + let js = jload("int_splat/splat.json"); + let payload = read("int_splat/splat.wis1"); + assert_eq!(wis1_from_json(&js).unwrap(), payload, "the converter writes the committed payload"); + let (v, j) = (wire::parse_wis1(&payload).unwrap(), SplatJson::from_value(&js).unwrap()); + assert_eq!((v.afrac as u32, v.c as usize, v.sh as usize, v.sw as usize, v.s as usize, v.attr as usize, v.n_gaussians as usize), + (j.afrac, j.c, j.sh, j.sw, j.s, j.n_attr, j.n_gaussians)); + assert_eq!((&v.cdfs, &v.offsets, &v.median_q, v.rans), (&j.cdfs, &j.offsets, &j.median_q, j.rans.as_slice())); + for (&(lo, hi), &(jl, jh)) in v.ranges.iter().zip(&j.ranges) { + assert_eq!((lo, hi), (jl as f32, jh as f32), "the ranges are the JSON's binary32 values"); + } + let gs = read("int_splat/splat_gs.bin"); + let params = IntParams::Unverified(HeldBytes::File(&gs)); + let (bytes, ranges) = same_under_every_policy("splat", || match decode_int(crate::codecs::CAP_SPLAT_INT_CODEC, &payload, ¶ms) { + Ok(IntDecoded::Attr { bytes, n_attr: 14, s: 32, n_gaussians: 1024, ranges }) => (bytes, ranges), + other => panic!("splat: {other:?}"), + }); + assert_eq!(ranges, v.ranges); + assert_eq!(bytes, decode(&IntModel::from_bytes(&gs).unwrap(), &js).unwrap().attr, "the binary and JSON decodes agree"); + assert_eq!(sha(&bytes), jload("int_splat/sha256.json")["splat"]["sha256"].as_str().unwrap()); + assert_eq!(format!("{:016x}", fnv1a(&bytes)), js["attr_hash"].as_str().unwrap()); +} + +/// §6.1 test 18: every integer capability decodes through `decode_int` in a build +/// without the neural sink (`neural`, which pulls the ONNX runtime), to its pinned +/// hashes: the five pinned formats, `wai.neural.int_mlicpp`'s committed envelopes (the +/// form-less pin and the listing pin, verified in memory) and the `wai.neural.int_synth` +/// corpus. +#[cfg(all(feature = "video_int", feature = "audio_int", feature = "splat_int", not(feature = "neural")))] +#[test] +fn decode_int_without_neural() { + use crate::container::Wai; + use crate::codecs as c; + let set = int_hyper_set(); + let files = view(&set); + let hyp = IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }); + let (model, gs) = (read("int_audio/audio_model.bin"), read("int_splat/splat_gs.bin")); + let cases: [(&str, &str, IntParams<'_>); 6] = [ + (c::CAP_VIDEO_INT_MOTION, "int_video/int_video_bitstream.wiv1", IntParams::None), + (c::CAP_VIDEO_INT_MOTION, "int_video/video_real_bitstream.wiv1", IntParams::None), + (c::CAP_VIDEO_INT_HYPER, "int_video/video_nk_bitstream.wiv1", hyp.clone()), + (c::CAP_NEURAL_INT_HYPER, "int_hyper/tiny_bitstream.wih1", hyp.clone()), + (c::CAP_AUDIO_INT_CODEC, "int_audio/audio_bitstream.wia1", IntParams::Unverified(HeldBytes::File(&model))), + (c::CAP_SPLAT_INT_CODEC, "int_splat/splat.wis1", IntParams::Unverified(HeldBytes::File(&gs))), + ]; + for (cap, rel, params) in cases { + assert!(decode_int(cap, &read(rel), ¶ms).is_ok(), "{cap} {rel}"); + } + + // wai.neural.int_mlicpp: the form-less pin over the bundle, and the listing pin, + // each verified in memory by resolve, decode to the committed image. + let d = vroot().join("int_mlicpp"); + let rd = |n: &str| std::fs::read(d.join(n)).unwrap(); + let want: serde_json::Value = serde_json::from_slice(&rd("expected.json")).unwrap(); + let (bundle, listing) = (rd("model.wmb"), rd("model.sha256")); + let set_files: BTreeMap = [("model.wmb".to_string(), bundle.as_slice())].into_iter().collect(); + for (env, held) in [("stream.wai", HeldBytes::File(&bundle)), ("stream_set.wai", HeldBytes::Set { listing: &listing, files: &set_files })] { + let env = Wai::from_bytes(&rd(env)).unwrap(); + let held = [held]; + let params = resolve(c::CAP_NEURAL_INT_MLICPP, env.prior(), &held, None).expect("the pin verifies"); + assert!(params.is_pinned()); + match decode_int(c::CAP_NEURAL_INT_MLICPP, &env.payload, ¶ms) { + Ok(IntDecoded::Image { rgb, .. }) => assert_eq!(sha(&rgb), want["rgb_sha256"].as_str().unwrap()), + other => panic!("int_mlicpp: {other:?}"), + } + } + + // wai.neural.int_synth: the neural-conformance corpus, by its blake3 RGB hash. + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../neural-conformance"); + let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(dir.join("expected_hashes.json")).unwrap()).unwrap(); + let mut n = 0; + for (name, spec) in manifest.as_object().unwrap() { + let vd = dir.join("vectors").join(name); + let (payload, model) = (std::fs::read(vd.join("latent.bin")).unwrap(), std::fs::read(vd.join("int_synth.json")).unwrap()); + let rgb = match decode_int(c::CAP_NEURAL_INT_SYNTH, &payload, &IntParams::Unverified(HeldBytes::File(&model))) { + Ok(IntDecoded::Image { rgb, .. }) => rgb, + other => panic!("{name}: {other:?}"), + }; + let mut h = blake3::Hasher::new(); + h.update(b"wai:int-synth-rgb\x01"); + h.update(&rgb); + assert_eq!(h.finalize().to_hex().as_str(), spec["rgb_hash"].as_str().unwrap(), "{name}"); + n += 1; + } + assert!(n >= 4, "the four committed int_synth vectors, found {n}"); +} + +/// The refusal list is closed: every code distinct, snake_case, and read back. +#[test] +fn refusal_codes_are_closed_and_distinct() { + let mut codes: Vec<&str> = Refusal::ALL.iter().map(|r| r.code()).collect(); + for c in &codes { + assert!(c.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'), "{c} is snake_case"); + assert_eq!(Refusal::from_code(c).map(Refusal::code), Some(*c)); + } + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), Refusal::ALL.len(), "codes are distinct"); + assert_eq!(Refusal::from_code("prior_not_held"), None); +} + +/// A JSON form under each pinned string is refused as `bad_magic`, before any +/// parameter is read (the parameters here are absent, and would be `set_incomplete`). +#[test] +fn json_form_is_refused_before_any_parameter_is_read() { + for (cap, json) in [ + (crate::codecs::CAP_VIDEO_INT_MOTION, "int_video/int_video_bitstream.json"), + (crate::codecs::CAP_VIDEO_INT_HYPER, "int_video/video_nk_bitstream.json"), + (crate::codecs::CAP_NEURAL_INT_HYPER, "int_hyper/tiny_bitstream.json"), + (crate::codecs::CAP_AUDIO_INT_CODEC, "int_audio/audio_bitstream.json"), + (crate::codecs::CAP_SPLAT_INT_CODEC, "int_splat/splat.json"), + ] { + let built = match cap { + crate::codecs::CAP_NEURAL_INT_HYPER => true, + crate::codecs::CAP_AUDIO_INT_CODEC => cfg!(feature = "audio_int"), + crate::codecs::CAP_SPLAT_INT_CODEC => cfg!(feature = "splat_int"), + _ => cfg!(feature = "video_int"), + }; + let want = if built { "bad_magic" } else { "unsupported_capability" }; + assert_eq!(decode_int(cap, &read(json), &IntParams::None).map(|_| ()).unwrap_err().code(), want, "{cap}"); + } +} + +/// The refusals that concern the capability or the set rather than the payload's bytes. +#[test] +fn capability_and_set_refusals() { + let code = |r: Result| r.map(|_| "decoded").unwrap_or_else(|e| e.code()); + // A candidate is not dispatchable (SPEC §4); a dispatchable non-integer capability + // has no integer decoder. + assert_eq!(code(decode_int(crate::codecs::CAP_SPLAT_SOG, b"WIS1", &IntParams::None)), "not_dispatchable"); + assert_eq!(code(decode_int(crate::codecs::CAP_IMAGE_PNG, b"\x89PNG", &IntParams::None)), "unsupported_capability"); + // The conditional codec reads three files: a set without h_s.whm is incomplete, + // and the refusal names the file. + let mut set = int_hyper_set(); + set.remove("h_s.whm"); + let files = view(&set); + let r = decode_int(crate::codecs::CAP_NEURAL_INT_HYPER, &read("int_hyper/tiny_bitstream.wih1"), + &IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files })); + assert_eq!(r.as_ref().map_err(|e| (e.code(), e.detail())).err(), Some(("set_incomplete", "h_s.whm"))); + // One file cannot hold a several-file set. + let one = read("int_hyper/tiny_g_s.bin"); + assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_HYPER, &read("int_hyper/tiny_bitstream.wih1"), + &IntParams::Unverified(HeldBytes::File(&one)))), "set_incomplete"); + // Entropy tables that break the table rules are the model's fault. + let mut bad = int_hyper_set(); + bad.insert("entropy.json".into(), br#"{"scale_edges":[],"cdfs":[],"cdf_lengths":[],"offsets":[],"z_cdfs":[],"z_cdf_lengths":[],"z_offsets":[],"z_median_q":[]}"#.to_vec()); + let files = view(&bad); + assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_HYPER, &read("int_hyper/tiny_bitstream.wih1"), + &IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }))), "model_mismatch"); + // int_mlicpp: a payload its decoder refuses, and a bundle that is not one. + let bundle = std::fs::read(vroot().join("int_mlicpp/model.wmb")).unwrap(); + assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_MLICPP, b"WIM1", &IntParams::Unverified(HeldBytes::File(&bundle)))), "payload_malformed"); + assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_MLICPP, b"WIM1", &IntParams::Unverified(HeldBytes::File(b"WHM1")))), "model_mismatch"); + // int_synth: a side of 2^64 is refused, not shifted. + let mut p = vec![0u8; 12]; + p[8] = 1; + p[10] = 64; + let spec = std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../neural-conformance/vectors/gradient/int_synth.json")).unwrap(); + assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_SYNTH, &p, &IntParams::Unverified(HeldBytes::File(&spec)))), "payload_malformed"); +} + +/// `container::check_int_payload_framing` (what `wai wrap` and `wai inspect` run, with +/// no `neural_int`) gives the parsers' code for every framing refusal, and accepts what +/// the parsers take past the version byte. +#[test] +fn framing_check_agrees_with_the_parsers() { + let framing = ["bad_magic", "format_capability_mismatch", "version", "stream_length"]; + let payloads: Vec> = vec![ + read("int_video/video_nk_bitstream.wiv1"), read("int_hyper/tiny_bitstream.wih1"), + read("int_audio/audio_bitstream.wia1"), read("int_splat/splat.wis1"), + read("int_splat/splat.json"), b"WIV1".to_vec(), b"WIH1\x02".to_vec(), b"WI".to_vec(), Vec::new(), + ]; + for &(cap, magic) in crate::codecs::PINNED_INT_PAYLOADS { + for p in &payloads { + let parsed = match &magic { + b"WIV1" => wire::parse_wiv1(p, None).err(), + b"WIH1" => wire::parse_wih1(p).err(), + b"WIA1" => wire::parse_wia1(p).err(), + _ => wire::parse_wis1(p).err(), + }; + let framed = crate::container::check_int_payload_framing(cap, p).err().map(|e| e.code); + match parsed.map(|e| e.code()) { + Some(c) if framing.contains(&c) && !(c == "stream_length" && p.len() > 4) => assert_eq!(framed, Some(c), "{cap}, {} bytes", p.len()), + _ => assert_eq!(framed, None, "{cap}, {} bytes: the framing is right", p.len()), + } + } + } + assert_eq!(crate::container::check_int_payload_framing(crate::codecs::CAP_IMAGE_PNG, b"{}"), Ok(())); +} + +/// A key log stand-in: revokes the digests in `revoked`; where `held_to`, a set it +/// does not grant (`granted`) is not authorised. +#[derive(Debug)] +struct Authority { + revoked: Vec, + granted: Vec, + held_to: bool, +} + +impl crate::pin::PinAuthority for Authority { + fn check(&self, capability: &str, prior: &Prior) -> Result<(), PinRefusal> { + self.check_unpinned(capability, std::slice::from_ref(&prior.sha256)) + } + fn check_unpinned(&self, capability: &str, digests: &[String]) -> Result<(), PinRefusal> { + if digests.iter().any(|d| self.revoked.contains(d)) { + return Err(PinRefusal::Revoked { capability: capability.into(), revoked_by: None }); + } + if self.held_to && !digests.iter().any(|d| self.granted.contains(d)) { + return Err(PinRefusal::NotAuthorised { capability: capability.into(), check: None, reason: None }); + } + Ok(()) + } +} + +/// SPEC §3.1 check 5 through the ungated path: a revoked set and an unauthorised set +/// are refused, pinned and unpinned; with no key log, or with one that grants the set, +/// the same set resolves. +#[test] +fn check_5_through_the_ungated_path() { + use crate::codecs::{CAP_AUDIO_INT_CODEC as AUD, CAP_NEURAL_INT_HYPER as HYP, CAP_PRIOR_SET}; + let set = int_hyper_set(); + let files = view(&set); + let listing = crate::pin::listing_from(&files).unwrap(); + let digest = crate::pin::sha256_hex(&listing); + let held = [HeldBytes::Set { listing: &listing, files: &files }]; + let pin = Prior::new("tiny", digest.clone(), "decode-equivalence").with_form(CAP_PRIOR_SET); + let code = |r: Result, PinRefusal>| r.map(|p| if p.is_pinned() { "pinned" } else { "unpinned" }).unwrap_or_else(|e| e.code()); + let revoking = Authority { revoked: vec![digest.clone()], granted: vec![], held_to: false }; + let ungranting = Authority { revoked: vec![], granted: vec![], held_to: true }; + let granting = Authority { revoked: vec![], granted: vec![digest.clone()], held_to: true }; + for (prior, ok) in [(Some(&pin), "pinned"), (None, "unpinned")] { + assert_eq!(code(resolve(HYP, prior, &held, None)), ok); + assert_eq!(code(resolve(HYP, prior, &held, Some(&revoking))), "prior_revoked"); + assert_eq!(code(resolve(HYP, prior, &held, Some(&ungranting))), "prior_not_authorised"); + assert_eq!(code(resolve(HYP, prior, &held, Some(&granting))), ok); + } + // The pinned decode reads the files its listing names, and decodes. + let p = resolve(HYP, Some(&pin), &held, Some(&granting)).unwrap(); + assert!(decode_int(HYP, &read("int_hyper/tiny_bitstream.wih1"), &p).is_ok()); + // A one-file set, unpinned: checked under its file's digest. + let model = read("int_audio/audio_model.bin"); + let one = [HeldBytes::File(&model)]; + let revoke_model = Authority { revoked: vec![crate::pin::sha256_hex(&model)], granted: vec![], held_to: false }; + assert_eq!(code(resolve(AUD, None, &one, Some(&revoke_model))), "prior_revoked"); + assert_eq!(code(resolve(AUD, None, &one, None)), "unpinned"); + // A capability that reads no set needs none, and no key log is consulted. + assert!(matches!(resolve(crate::codecs::CAP_VIDEO_INT_MOTION, None, &[], Some(&revoking)), Ok(IntParams::None))); + // An unpinned decode with no set held. + assert_eq!(code(resolve(HYP, None, &[], None)), "prior_not_held"); +} + +/// The same refusals from the key log a sink holds (`key_log::SinkKeyLog`), as the +/// neural sink's `int_mlicpp_revoked_set_is_refused_through_the_key_log` checks them. +#[cfg(feature = "key_discovery")] +#[test] +fn check_5_with_a_sink_key_log() { + use crate::key_log::{commitment, Anchor, KeyEntry, KeyStatus, ModuleForm, ModuleGrant, Revision, SinkKeyLog}; + use ed25519_dalek::SigningKey; + const P: &str = "https://publisher.example"; + let hyp = crate::codecs::CAP_NEURAL_INT_HYPER; + let key = |s: u8| SigningKey::from_bytes(&[s; 32]); + let set = int_hyper_set(); + let files = view(&set); + let listing = crate::pin::listing_from(&files).unwrap(); + let digest = crate::pin::sha256(&listing); + let held = [HeldBytes::Set { listing: &listing, files: &files }]; + let pin = Prior::new("tiny", hex(&digest), "decode-equivalence").with_form(crate::codecs::CAP_PRIOR_SET); + let sink = |revoked: Vec<[u8; 32]>, statuses: bool| { + let unsigned = Revision { + origin: P.into(), seq: 0, prev: None, issued_at: "2026-09-01T00:00:00Z".into(), root: [0; 32], + next_root: commitment(&key(0x62).verifying_key().to_bytes()), + keys: vec![KeyEntry::active("modd", key(0x6b).verifying_key().to_bytes(), &["module/decoder"])], + revoked_modules: vec![], grant_max_s: 3_600, status_max_age_s: 600, sig: [0; 64], + }; + let r0 = Revision::seal(&key(0x61), unsigned.clone()).unwrap(); + let r1 = Revision::seal(&key(0x61), Revision { seq: 1, prev: Some(r0.revision_hash()), + issued_at: "2026-09-10T00:00:00Z".into(), revoked_modules: revoked, ..unsigned }).unwrap(); + let grant = ModuleGrant::seal(&key(0x6b), "did:web:publisher.example#modd", P, digest, ModuleForm::Decoder, &[hyp], + "2026-09-20T05:30:00Z", "2026-09-20T06:30:00Z").unwrap().to_json(); + let st = if statuses { vec![KeyStatus::seal(&key(0x61), &r1, "2026-09-20T05:55:00Z").to_json()] } else { vec![] }; + SinkKeyLog::new(vec![r0.clone(), r1], vec![Anchor::pinned(P, r0.revision_hash())]) + .with_statuses(st).with_grants(vec![grant]).at("2026-09-20T06:00:00Z").hold_pinned_sets(hyp, &[P]) + }; + let code = |r: Result, PinRefusal>| r.map(|_| "ok").unwrap_or_else(|e| e.code()); + for prior in [Some(&pin), None] { + assert_eq!(code(resolve(hyp, prior, &held, Some(&sink(vec![digest], true)))), "prior_revoked"); + assert_eq!(code(resolve(hyp, prior, &held, Some(&sink(vec![], false)))), "prior_not_authorised"); + assert_eq!(code(resolve(hyp, prior, &held, Some(&sink(vec![], true)))), "ok"); + } +} + +/// A pinned set answers only for the paths its listing names, and a form-less pin's +/// one file only for the capability's one member. +#[test] +fn a_verified_set_answers_only_for_what_it_verified() { + let mut set = int_hyper_set(); + let listing = crate::pin::listing_from(&view(&set)).unwrap(); + set.insert("extra.bin".into(), b"unverified".to_vec()); + let files = view(&set); + let p = IntParams::Held { set: HeldBytes::Set { listing: &listing, files: &files }, paths: Some(vec!["entropy.json".into(), "g_s.whm".into(), "h_s.whm".into()]) }; + let hyp = crate::codecs::CAP_NEURAL_INT_HYPER; + assert!(p.member(hyp, "g_s.whm").is_ok()); + assert_eq!(p.member(hyp, "extra.bin").unwrap_err().code(), "set_incomplete"); + let one = IntParams::Held { set: HeldBytes::File(b"WAM1"), paths: None }; + assert!(one.member(crate::codecs::CAP_AUDIO_INT_CODEC, "model.wam").is_ok()); + assert!(one.member(hyp, "g_s.whm").is_err(), "one file is not a several-file set"); +} diff --git a/wai/wai-rs/src/int_payload/wire.rs b/wai/wai-rs/src/int_payload/wire.rs new file mode 100644 index 000000000..9efa1ea04 --- /dev/null +++ b/wai/wai-rs/src/int_payload/wire.rs @@ -0,0 +1,752 @@ +//! The payload formats of the WAI-native integer codecs ([integer-payloads] §2–§6): +//! `WIV1` (`wai.video.int_motion`, `wai.video.int_hyper`), `WIH1` +//! (`wai.neural.int_hyper`), `WIA1` (`wai.audio.int_codec`) and `WIS1` +//! (`wai.splat.int_codec`). A parser and a writer for each, and the closed list of +//! refusal codes ([`Refusal`]). +//! +//! Every multi-byte field is little-endian. A parser checks the fields in the order +//! the extension lists them and reports the first rule that fails, so two +//! conforming parsers refuse the same bytes with the same code. Every size is +//! formed in `u64` with checked arithmetic and capped by the same constants on +//! every target, `wasm32` included, so a payload is accepted or refused alike +//! everywhere and no parser allocates more than the bytes it holds justify. +//! +//! This file is self-contained: it uses `std`'s `Vec` and nothing from the crate, so +//! the cross-architecture byte-equality crate (`wai/byte-exact-conformance`) +//! `#[path]`-includes it and parses the committed binary vectors with the same code +//! a sink runs. +//! +//! [integer-payloads]: ../../../extensions/integer-payloads.md + +/// `WIV1`: integer inter-frame video. +/// +/// cbindgen:ignore +pub const MAGIC_WIV1: [u8; 4] = *b"WIV1"; +/// `WIH1`: the integer conditional image codec. +/// +/// cbindgen:ignore +pub const MAGIC_WIH1: [u8; 4] = *b"WIH1"; +/// `WIA1`: the integer learned audio codec. +/// +/// cbindgen:ignore +pub const MAGIC_WIA1: [u8; 4] = *b"WIA1"; +/// `WIS1`: the integer learned Gaussian-splat attribute codec. +/// +/// cbindgen:ignore +pub const MAGIC_WIS1: [u8; 4] = *b"WIS1"; +/// The four magics, for telling a payload of another of these formats from one of +/// none ([`Refusal::FormatCapabilityMismatch`] against [`Refusal::BadMagic`]). +/// +/// cbindgen:ignore +pub const MAGICS: [[u8; 4]; 4] = [MAGIC_WIV1, MAGIC_WIH1, MAGIC_WIA1, MAGIC_WIS1]; +/// The one version this revision defines, for every format. +/// +/// cbindgen:ignore +pub const VERSION: u8 = 1; + +/// The most elements any one tensor a payload sizes may hold: a frame (`h·w·3`), a +/// latent, an attribute tensor, a decoded clip's samples. Also the cap on every +/// activation a decode forms on the way ([integer-payloads] §7). +/// +/// cbindgen:ignore +pub const MAX_TENSOR: u64 = 1 << 26; +/// The most decoded bytes a whole `WIV1` clip may hold (`n_frames·h·w·3`). +/// +/// cbindgen:ignore +pub const MAX_CLIP: u64 = 1 << 30; +/// The largest fixed-point scale a payload may declare (`afrac` in `1..=30`). +/// +/// cbindgen:ignore +pub const MAX_AFRAC: u8 = 30; +/// The largest magnitude of a per-channel median (`|median_q| ≤ 2^47`). +/// +/// cbindgen:ignore +pub const MAX_MEDIAN_Q: i64 = 1 << 47; +/// The bounds on a CDF table's length. +/// +/// cbindgen:ignore +pub const CDF_LEN_MIN: usize = 3; +/// cbindgen:ignore +pub const CDF_LEN_MAX: usize = 4097; +/// The total every CDF table ends at (rANS precision 16). +/// +/// cbindgen:ignore +pub const CDF_TOTAL: u32 = 1 << 16; +/// The largest residual quantisation step. +/// +/// cbindgen:ignore +pub const MAX_Q: u32 = 65_535; + +/// Why a payload was refused: the closed list of [integer-payloads] §8. The first +/// rule that fails, in the order the extension gives, is the reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// cbindgen:ignore +pub enum Refusal { + /// The payload does not start with any of the four magics (a JSON form does not). + BadMagic, + /// The payload is one of the four formats, but not the one the capability pins. + FormatCapabilityMismatch, + /// A `WIV1` keyframe kind that does not belong to the capability (0 is + /// `wai.video.int_motion`'s, 1 is `wai.video.int_hyper`'s). + KeyframeCapabilityMismatch, + /// A version this revision does not define. + Version, + /// A reserved field that is not zero. + ReservedNonzero, + /// A field outside its range. + FieldRange, + /// A tensor, a clip or an activation larger than the caps allow. + TooLarge, + /// A CDF table that breaks the table rules. + CdfMalformed, + /// The payload ends before a field or section it declares, or a rANS stream's + /// length is not a multiple of 4 or is under 8. + StreamLength, + /// A nested `WIH1` whose declared length is not the rest of the payload. + NestedLength, + /// Bytes after the last section. + Trailing, + /// A rANS stream that ran out before its last symbol. + StreamExhausted, + /// A shape the payload declares disagrees with the shape the decode produces. + GeometryMismatch, + /// The parameter set does not fit the payload. + ModelMismatch, + /// The parameter set does not contain a file the decode reads. + SetIncomplete, + /// The capability is not dispatchable (SPEC §4). + NotDispatchable, + /// The capability has no integer decode in this build. + UnsupportedCapability, + /// A payload of `wai.neural.int_synth` or `wai.neural.int_mlicpp`, whose formats + /// are defined elsewhere, refused by its decoder. + PayloadMalformed, +} + +impl Refusal { + /// Every refusal, in the order of [integer-payloads] §8. + /// + /// cbindgen:ignore + pub const ALL: [Refusal; 18] = [ + Refusal::BadMagic, + Refusal::FormatCapabilityMismatch, + Refusal::KeyframeCapabilityMismatch, + Refusal::Version, + Refusal::ReservedNonzero, + Refusal::FieldRange, + Refusal::TooLarge, + Refusal::CdfMalformed, + Refusal::StreamLength, + Refusal::NestedLength, + Refusal::Trailing, + Refusal::StreamExhausted, + Refusal::GeometryMismatch, + Refusal::ModelMismatch, + Refusal::SetIncomplete, + Refusal::NotDispatchable, + Refusal::UnsupportedCapability, + Refusal::PayloadMalformed, + ]; + + /// The code [integer-payloads] §8 names. + pub fn code(self) -> &'static str { + match self { + Refusal::BadMagic => "bad_magic", + Refusal::FormatCapabilityMismatch => "format_capability_mismatch", + Refusal::KeyframeCapabilityMismatch => "keyframe_capability_mismatch", + Refusal::Version => "version", + Refusal::ReservedNonzero => "reserved_nonzero", + Refusal::FieldRange => "field_range", + Refusal::TooLarge => "too_large", + Refusal::CdfMalformed => "cdf_malformed", + Refusal::StreamLength => "stream_length", + Refusal::NestedLength => "nested_length", + Refusal::Trailing => "trailing", + Refusal::StreamExhausted => "stream_exhausted", + Refusal::GeometryMismatch => "geometry_mismatch", + Refusal::ModelMismatch => "model_mismatch", + Refusal::SetIncomplete => "set_incomplete", + Refusal::NotDispatchable => "not_dispatchable", + Refusal::UnsupportedCapability => "unsupported_capability", + Refusal::PayloadMalformed => "payload_malformed", + } + } + + /// The refusal whose code is `code`. + pub fn from_code(code: &str) -> Option { + Refusal::ALL.into_iter().find(|r| r.code() == code) + } +} + +/// A parser's refusal: the code and what failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WireError { + pub refusal: Refusal, + pub detail: &'static str, +} + +impl WireError { + /// cbindgen:ignore + pub const fn new(refusal: Refusal, detail: &'static str) -> Self { + WireError { refusal, detail } + } + pub fn code(&self) -> &'static str { + self.refusal.code() + } +} + +impl std::fmt::Display for WireError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.refusal.code(), self.detail) + } +} + +impl std::error::Error for WireError {} + +const fn err(refusal: Refusal, detail: &'static str) -> WireError { + WireError { refusal, detail } +} + +/// The magic check every parser starts with: `want` passes; another of the four +/// magics is the wrong format for the capability; anything else, a JSON form +/// included, is not one of these formats. +pub fn check_magic(p: &[u8], want: [u8; 4]) -> Result<(), WireError> { + match p.get(0..4) { + Some(m) if m == want => Ok(()), + Some(m) if MAGICS.iter().any(|k| k[..] == *m) => { + Err(err(Refusal::FormatCapabilityMismatch, "the payload is another integer format than the capability's")) + } + _ => Err(err(Refusal::BadMagic, "the payload does not start with the capability's magic")), + } +} + +/// The table rules every CDF obeys ([integer-payloads] §7): `3 ≤ len ≤ 4097`, +/// `cdf[0] = 0`, `cdf[len−1] = 2^16`, strictly increasing. They are the +/// precondition under which the rANS decoder's symbol search and state update stay +/// in range. +pub fn check_cdf(cdf: &[i32]) -> Result<(), WireError> { + if cdf.len() < CDF_LEN_MIN || cdf.len() > CDF_LEN_MAX { + return Err(err(Refusal::CdfMalformed, "a CDF table's length is outside 3..=4097")); + } + if cdf[0] != 0 || cdf[cdf.len() - 1] != CDF_TOTAL as i32 { + return Err(err(Refusal::CdfMalformed, "a CDF table does not run from 0 to 65536")); + } + if cdf.windows(2).any(|w| w[0] >= w[1]) { + return Err(err(Refusal::CdfMalformed, "a CDF table is not strictly increasing")); + } + Ok(()) +} + +/// The product of `dims` in `u64`, or `None` past `u64`. +fn product(dims: &[u64]) -> Option { + dims.iter().try_fold(1u64, |a, &d| a.checked_mul(d)) +} + +/// A product checked against `cap`: [`Refusal::TooLarge`] past it (or past `u64`). +fn capped(dims: &[u64], cap: u64, detail: &'static str) -> Result { + product(dims).filter(|&n| n <= cap).ok_or(err(Refusal::TooLarge, detail)) +} + +/// A bounds-checked little-endian reader. Every read past the end is +/// [`Refusal::StreamLength`]. +struct Cur<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Cur<'a> { + fn new(b: &'a [u8], p: usize) -> Self { + Cur { b, p } + } + fn left(&self) -> usize { + self.b.len() - self.p + } + /// The next `n` bytes; `n` is a `u64` the caller formed, so no allocation or + /// slice is ever sized from more than the payload holds. + fn take(&mut self, n: u64, what: &'static str) -> Result<&'a [u8], WireError> { + let n = usize::try_from(n).ok().filter(|&n| n <= self.left()).ok_or(err(Refusal::StreamLength, what))?; + let s = &self.b[self.p..self.p + n]; + self.p += n; + Ok(s) + } + fn u8(&mut self, what: &'static str) -> Result { + Ok(self.take(1, what)?[0]) + } + fn u16(&mut self, what: &'static str) -> Result { + let s = self.take(2, what)?; + Ok(u16::from_le_bytes([s[0], s[1]])) + } + fn u32(&mut self, what: &'static str) -> Result { + let s = self.take(4, what)?; + Ok(u32::from_le_bytes([s[0], s[1], s[2], s[3]])) + } + fn i32(&mut self, what: &'static str) -> Result { + Ok(self.u32(what)? as i32) + } + /// A CDF table of `len` `u32` entries, read and checked. + fn cdf(&mut self, len: u64, what: &'static str) -> Result, WireError> { + let s = self.take(len.checked_mul(4).unwrap_or(u64::MAX), what)?; + let cdf: Vec = s + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .map(|v| i32::try_from(v).unwrap_or(i32::MAX)) + .collect(); + check_cdf(&cdf)?; + Ok(cdf) + } + /// A length-prefixed rANS stream: `u32 L` then `L` bytes, `L` a multiple of 4 + /// and at least 8. + fn stream(&mut self, what: &'static str) -> Result<&'a [u8], WireError> { + let len = self.u32(what)?; + if len % 4 != 0 || len < 8 { + return Err(err(Refusal::StreamLength, "a rANS stream's length is not a multiple of 4, or is under 8")); + } + self.take(len as u64, what) + } + /// Nothing may follow the last section. + fn end(&self) -> Result<(), WireError> { + if self.left() != 0 { + return Err(err(Refusal::Trailing, "bytes follow the last section")); + } + Ok(()) + } +} + +/// The checks every format opens with: the magic, then the version byte at offset +/// 4, then a fixed header of `header` bytes. +fn open<'a>(p: &'a [u8], magic: [u8; 4], header: usize) -> Result, WireError> { + check_magic(p, magic)?; + let version = *p.get(4).ok_or(err(Refusal::StreamLength, "the payload ends before its version"))?; + if version != VERSION { + return Err(err(Refusal::Version, "a version this revision does not define")); + } + if p.len() < header { + return Err(err(Refusal::StreamLength, "the payload ends inside its fixed header")); + } + Ok(Cur::new(p, 5)) +} + +fn put_u16(o: &mut Vec, v: u16) { + o.extend_from_slice(&v.to_le_bytes()); +} +fn put_u32(o: &mut Vec, v: u32) { + o.extend_from_slice(&v.to_le_bytes()); +} +fn put_i32(o: &mut Vec, v: i32) { + o.extend_from_slice(&v.to_le_bytes()); +} +fn put_i64(o: &mut Vec, v: i64) { + o.extend_from_slice(&v.to_le_bytes()); +} +fn put_cdf(o: &mut Vec, cdf: &[i32]) { + for &v in cdf { + put_u32(o, v as u32); + } +} +fn put_stream(o: &mut Vec, s: &[u8]) { + put_u32(o, s.len() as u32); + o.extend_from_slice(s); +} + +// ───────────────────────────── WIV1 ───────────────────────────── + +/// A parsed `WIV1` payload ([integer-payloads] §3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Wiv1<'a> { + /// `1` whole-pel, `2` half-pel: a motion vector is in units of `1/mv_precision` pel. + pub mv_precision: u8, + /// `0`: the first frame is predicted from grey and coded in the residual stream + /// (`wai.video.int_motion`). `1`: the first frame is the nested `WIH1`'s decode + /// (`wai.video.int_hyper`). + pub keyframe: u8, + pub n_frames: u32, + pub h: u32, + pub w: u32, + pub block: u32, + pub q: u32, + pub offset: i32, + pub cdf: Vec, + /// Frames `1..n_frames`, each its block grid row-major: `(dy, dx)` per block, + /// `nb = ⌈h/block⌉·⌈w/block⌉` per frame. + pub mvs: Vec<(i16, i16)>, + /// The residual rANS stream. + pub rans: &'a [u8], + /// The nested `WIH1` payload, when `keyframe` is 1. + pub wih1: Option<&'a [u8]>, +} + +/// `WIV1`'s fixed header, in bytes. +/// +/// cbindgen:ignore +pub const WIV1_HEADER: usize = 36; + +impl Wiv1<'_> { + /// Blocks per frame: `⌈h/block⌉·⌈w/block⌉`. + pub fn blocks_per_frame(&self) -> u64 { + blocks(self.h, self.w, self.block) + } +} + +fn blocks(h: u32, w: u32, block: u32) -> u64 { + (h as u64).div_ceil(block.max(1) as u64) * (w as u64).div_ceil(block.max(1) as u64) +} + +/// Parse a `WIV1` payload. `want_keyframe` is the keyframe kind the capability +/// pins (`Some(0)` for `wai.video.int_motion`, `Some(1)` for `wai.video.int_hyper`); +/// `None` accepts either. +pub fn parse_wiv1(p: &[u8], want_keyframe: Option) -> Result, WireError> { + let mut c = open(p, MAGIC_WIV1, WIV1_HEADER)?; + let mv_precision = c.u8("mv_precision")?; + let keyframe = c.u8("keyframe")?; + let reserved0 = c.u8("reserved")?; + let n_frames = c.u32("n_frames")?; + let h = c.u32("h")?; + let w = c.u32("w")?; + let block = c.u32("block")?; + let q = c.u32("q")?; + let offset = c.i32("offset")?; + let cdf_len = c.u16("cdf_len")?; + let reserved1 = c.u16("reserved")?; + if reserved0 != 0 || reserved1 != 0 { + return Err(err(Refusal::ReservedNonzero, "a reserved field is not zero")); + } + if !matches!(mv_precision, 1 | 2) { + return Err(err(Refusal::FieldRange, "mv_precision is neither 1 nor 2")); + } + if keyframe > 1 { + return Err(err(Refusal::FieldRange, "keyframe is neither 0 nor 1")); + } + if n_frames == 0 || h == 0 || w == 0 { + return Err(err(Refusal::FieldRange, "n_frames, h or w is zero")); + } + if block == 0 { + return Err(err(Refusal::FieldRange, "block is zero")); + } + if q == 0 || q > MAX_Q { + return Err(err(Refusal::FieldRange, "q is outside 1..=65535")); + } + if (cdf_len as usize) < CDF_LEN_MIN || cdf_len as usize > CDF_LEN_MAX { + return Err(err(Refusal::FieldRange, "cdf_len is outside 3..=4097")); + } + if let Some(k) = want_keyframe { + if k != keyframe { + return Err(err(Refusal::KeyframeCapabilityMismatch, "the keyframe kind is not the capability's")); + } + } + capped(&[h as u64, w as u64, 3], MAX_TENSOR, "a frame exceeds 2^26 elements")?; + capped(&[n_frames as u64, h as u64, w as u64, 3], MAX_CLIP, "the clip exceeds 2^30 bytes")?; + let cdf = c.cdf(cdf_len as u64, "the CDF table")?; + let n_mv = product(&[n_frames as u64 - 1, blocks(h, w, block)]).ok_or(err(Refusal::StreamLength, "the motion vectors"))?; + let raw = c.take(n_mv.checked_mul(4).unwrap_or(u64::MAX), "the motion vectors")?; + let mvs = raw + .chunks_exact(4) + .map(|m| (i16::from_le_bytes([m[0], m[1]]), i16::from_le_bytes([m[2], m[3]]))) + .collect(); + let rans = c.stream("the residual stream")?; + let wih1 = if keyframe == 1 { + let k = c.u32("the keyframe length")?; + if k as usize != c.left() { + return Err(err(Refusal::NestedLength, "the keyframe length is not the rest of the payload")); + } + let nested = c.take(k as u64, "the keyframe")?; + parse_wih1(nested)?; + Some(nested) + } else { + c.end()?; + None + }; + Ok(Wiv1 { mv_precision, keyframe, n_frames, h, w, block, q, offset, cdf, mvs, rans, wih1 }) +} + +/// Write a `WIV1` payload. The caller supplies conforming fields; the converters +/// parse what they write before returning it. +pub fn write_wiv1(v: &Wiv1<'_>) -> Vec { + let mut o = Vec::with_capacity(WIV1_HEADER + v.cdf.len() * 4 + v.mvs.len() * 4 + v.rans.len() + 8); + o.extend_from_slice(&MAGIC_WIV1); + o.extend_from_slice(&[VERSION, v.mv_precision, v.keyframe, 0]); + put_u32(&mut o, v.n_frames); + put_u32(&mut o, v.h); + put_u32(&mut o, v.w); + put_u32(&mut o, v.block); + put_u32(&mut o, v.q); + put_i32(&mut o, v.offset); + put_u16(&mut o, v.cdf.len() as u16); + put_u16(&mut o, 0); + put_cdf(&mut o, &v.cdf); + for &(dy, dx) in &v.mvs { + o.extend_from_slice(&dy.to_le_bytes()); + o.extend_from_slice(&dx.to_le_bytes()); + } + put_stream(&mut o, v.rans); + if let Some(k) = v.wih1 { + put_stream(&mut o, k); + } + o +} + +// ───────────────────────────── WIH1 ───────────────────────────── + +/// A parsed `WIH1` payload ([integer-payloads] §4): the conditional codec's two +/// streams and the shapes of its two latents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Wih1<'a> { + /// Latent `y`: `m` channels of `sh × sw`. + pub m: u32, + pub sh: u32, + pub sw: u32, + /// Hyper-latent `z`: `n_z` channels of `zh × zw`. + pub n_z: u32, + pub zh: u32, + pub zw: u32, + /// The factorized-prior stream of `z`. + pub z_rans: &'a [u8], + /// The Gaussian-conditional stream of `y`. + pub rans: &'a [u8], +} + +/// `WIH1`'s fixed header, in bytes. +/// +/// cbindgen:ignore +pub const WIH1_HEADER: usize = 32; + +/// Parse a `WIH1` payload. +pub fn parse_wih1(p: &[u8]) -> Result, WireError> { + let mut c = open(p, MAGIC_WIH1, WIH1_HEADER)?; + let reserved = c.take(3, "reserved")?; + let m = c.u32("m")?; + let sh = c.u32("sh")?; + let sw = c.u32("sw")?; + let n_z = c.u32("n_z")?; + let zh = c.u32("zh")?; + let zw = c.u32("zw")?; + if reserved.iter().any(|&b| b != 0) { + return Err(err(Refusal::ReservedNonzero, "a reserved field is not zero")); + } + if [m, sh, sw, n_z, zh, zw].contains(&0) { + return Err(err(Refusal::FieldRange, "a latent dimension is zero")); + } + capped(&[n_z as u64, zh as u64, zw as u64], MAX_TENSOR, "the hyper-latent exceeds 2^26 elements")?; + capped(&[2, m as u64, sh as u64, sw as u64], MAX_TENSOR, "the latent's mean and scale exceed 2^26 elements")?; + let z_rans = c.stream("the z stream")?; + let rans = c.stream("the y stream")?; + c.end()?; + Ok(Wih1 { m, sh, sw, n_z, zh, zw, z_rans, rans }) +} + +/// Write a `WIH1` payload. +pub fn write_wih1(v: &Wih1<'_>) -> Vec { + let mut o = Vec::with_capacity(WIH1_HEADER + v.z_rans.len() + v.rans.len() + 8); + o.extend_from_slice(&MAGIC_WIH1); + o.extend_from_slice(&[VERSION, 0, 0, 0]); + for d in [v.m, v.sh, v.sw, v.n_z, v.zh, v.zw] { + put_u32(&mut o, d); + } + put_stream(&mut o, v.z_rans); + put_stream(&mut o, v.rans); + o +} + +// ───────────────────────────── WIA1 ───────────────────────────── + +/// A parsed `WIA1` payload ([integer-payloads] §5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Wia1<'a> { + /// The fixed-point scale; it must equal the model's. + pub afrac: u8, + /// The sample rate the PCM is presented at, in hertz. + pub sr: u32, + /// Latent channels and length. + pub n_ch: u32, + pub lat_len: u32, + /// Samples presented: the first `t_out` of the synthesis. + pub t_out: u32, + /// The symbol offset every channel's table is coded at. + pub offset: i32, + pub median_q: Vec, + /// One table per channel, all of one length. + pub cdfs: Vec>, + pub rans: &'a [u8], +} + +/// `WIA1`'s fixed header, in bytes. +/// +/// cbindgen:ignore +pub const WIA1_HEADER: usize = 32; + +/// Parse a `WIA1` payload. +pub fn parse_wia1(p: &[u8]) -> Result, WireError> { + let mut c = open(p, MAGIC_WIA1, WIA1_HEADER)?; + let afrac = c.u8("afrac")?; + let reserved0 = c.u16("reserved")?; + let sr = c.u32("sr")?; + let n_ch = c.u32("n_ch")?; + let lat_len = c.u32("lat_len")?; + let t_out = c.u32("t_out")?; + let offset = c.i32("offset")?; + let cdf_len = c.u16("cdf_len")?; + let reserved1 = c.u16("reserved")?; + if reserved0 != 0 || reserved1 != 0 { + return Err(err(Refusal::ReservedNonzero, "a reserved field is not zero")); + } + if afrac == 0 || afrac > MAX_AFRAC { + return Err(err(Refusal::FieldRange, "afrac is outside 1..=30")); + } + if sr == 0 || n_ch == 0 || lat_len == 0 || t_out == 0 { + return Err(err(Refusal::FieldRange, "sr, n_ch, lat_len or t_out is zero")); + } + if (cdf_len as usize) < CDF_LEN_MIN || cdf_len as usize > CDF_LEN_MAX { + return Err(err(Refusal::FieldRange, "cdf_len is outside 3..=4097")); + } + capped(&[n_ch as u64, lat_len as u64], MAX_TENSOR, "the latent exceeds 2^26 elements")?; + capped(&[t_out as u64], MAX_TENSOR, "t_out exceeds 2^26 samples")?; + let raw = c.take(n_ch as u64 * 8, "the medians")?; + let median_q: Vec = raw.chunks_exact(8).map(|b| i64::from_le_bytes(b.try_into().unwrap())).collect(); + if median_q.iter().any(|m| m.unsigned_abs() > MAX_MEDIAN_Q as u64) { + return Err(err(Refusal::FieldRange, "a median's magnitude exceeds 2^47")); + } + let mut cdfs = Vec::with_capacity(n_ch as usize); + for _ in 0..n_ch { + cdfs.push(c.cdf(cdf_len as u64, "the CDF tables")?); + } + let rans = c.stream("the latent stream")?; + c.end()?; + Ok(Wia1 { afrac, sr, n_ch, lat_len, t_out, offset, median_q, cdfs, rans }) +} + +/// Write a `WIA1` payload. +pub fn write_wia1(v: &Wia1<'_>) -> Vec { + let cdf_len = v.cdfs.first().map_or(0, Vec::len); + let mut o = Vec::with_capacity(WIA1_HEADER + v.median_q.len() * 8 + v.cdfs.len() * cdf_len * 4 + v.rans.len() + 4); + o.extend_from_slice(&MAGIC_WIA1); + o.extend_from_slice(&[VERSION, v.afrac, 0, 0]); + put_u32(&mut o, v.sr); + put_u32(&mut o, v.n_ch); + put_u32(&mut o, v.lat_len); + put_u32(&mut o, v.t_out); + put_i32(&mut o, v.offset); + put_u16(&mut o, cdf_len as u16); + put_u16(&mut o, 0); + for &m in &v.median_q { + put_i64(&mut o, m); + } + for cdf in &v.cdfs { + put_cdf(&mut o, cdf); + } + put_stream(&mut o, v.rans); + o +} + +// ───────────────────────────── WIS1 ───────────────────────────── + +/// A parsed `WIS1` payload ([integer-payloads] §6). +#[derive(Debug, Clone, PartialEq)] +pub struct Wis1<'a> { + /// The fixed-point scale; it must equal the model's. + pub afrac: u8, + /// Latent: `c` channels of `sh × sw`. + pub c: u32, + pub sh: u32, + pub sw: u32, + /// The attribute tensor: `attr` planes of `s × s`. + pub s: u32, + pub attr: u32, + /// Gaussians in the cloud: the first `n_gaussians` cells of each plane. + pub n_gaussians: u32, + /// Per attribute, the range `(lo, hi)` its `u8` plane is presented over. + /// Presentation only: outside the splat-equivalence digest. + pub ranges: Vec<(f32, f32)>, + /// Per channel, its table, its symbol offset and its median. + pub cdfs: Vec>, + pub offsets: Vec, + pub median_q: Vec, + pub rans: &'a [u8], +} + +/// `WIS1`'s fixed header, in bytes. +/// +/// cbindgen:ignore +pub const WIS1_HEADER: usize = 32; + +/// Parse a `WIS1` payload. +pub fn parse_wis1(p: &[u8]) -> Result, WireError> { + let mut cur = open(p, MAGIC_WIS1, WIS1_HEADER)?; + let afrac = cur.u8("afrac")?; + let reserved = cur.u16("reserved")?; + let c = cur.u32("c")?; + let sh = cur.u32("sh")?; + let sw = cur.u32("sw")?; + let s = cur.u32("s")?; + let attr = cur.u32("attr")?; + let n_gaussians = cur.u32("n_gaussians")?; + if reserved != 0 { + return Err(err(Refusal::ReservedNonzero, "a reserved field is not zero")); + } + if afrac == 0 || afrac > MAX_AFRAC { + return Err(err(Refusal::FieldRange, "afrac is outside 1..=30")); + } + if [c, sh, sw, s, attr, n_gaussians].contains(&0) { + return Err(err(Refusal::FieldRange, "a dimension is zero")); + } + if n_gaussians as u64 > s as u64 * s as u64 { + return Err(err(Refusal::FieldRange, "n_gaussians exceeds s·s")); + } + capped(&[c as u64, sh as u64, sw as u64], MAX_TENSOR, "the latent exceeds 2^26 elements")?; + capped(&[attr as u64, s as u64, s as u64], MAX_TENSOR, "the attribute tensor exceeds 2^26 elements")?; + let raw = cur.take(attr as u64 * 8, "the attribute ranges")?; + let mut ranges = Vec::with_capacity(attr as usize); + for r in raw.chunks_exact(8) { + let lo = f32::from_bits(u32::from_le_bytes([r[0], r[1], r[2], r[3]])); + let hi = f32::from_bits(u32::from_le_bytes([r[4], r[5], r[6], r[7]])); + if !lo.is_finite() || !hi.is_finite() || lo > hi { + return Err(err(Refusal::FieldRange, "an attribute range is not finite, or its lo exceeds its hi")); + } + ranges.push((lo, hi)); + } + let raw = cur.take(c as u64 * 2, "the CDF lengths")?; + let lens: Vec = raw.chunks_exact(2).map(|b| u16::from_le_bytes([b[0], b[1]])).collect(); + if lens.iter().any(|&l| (l as usize) < CDF_LEN_MIN || l as usize > CDF_LEN_MAX) { + return Err(err(Refusal::FieldRange, "a CDF length is outside 3..=4097")); + } + let raw = cur.take(c as u64 * 4, "the symbol offsets")?; + let offsets = raw.chunks_exact(4).map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect(); + let raw = cur.take(c as u64 * 8, "the medians")?; + let median_q: Vec = raw.chunks_exact(8).map(|b| i64::from_le_bytes(b.try_into().unwrap())).collect(); + if median_q.iter().any(|m| m.unsigned_abs() > MAX_MEDIAN_Q as u64) { + return Err(err(Refusal::FieldRange, "a median's magnitude exceeds 2^47")); + } + let mut cdfs = Vec::with_capacity(c as usize); + for &l in &lens { + cdfs.push(cur.cdf(l as u64, "the CDF tables")?); + } + let rans = cur.stream("the latent stream")?; + cur.end()?; + Ok(Wis1 { afrac, c, sh, sw, s, attr, n_gaussians, ranges, cdfs, offsets, median_q, rans }) +} + +/// Write a `WIS1` payload. +pub fn write_wis1(v: &Wis1<'_>) -> Vec { + let mut o = Vec::with_capacity(WIS1_HEADER + v.ranges.len() * 8 + v.rans.len() + 64); + o.extend_from_slice(&MAGIC_WIS1); + o.extend_from_slice(&[VERSION, v.afrac, 0, 0]); + for d in [v.c, v.sh, v.sw, v.s, v.attr, v.n_gaussians] { + put_u32(&mut o, d); + } + for &(lo, hi) in &v.ranges { + put_u32(&mut o, lo.to_bits()); + put_u32(&mut o, hi.to_bits()); + } + for cdf in &v.cdfs { + put_u16(&mut o, cdf.len() as u16); + } + for &off in &v.offsets { + put_i32(&mut o, off); + } + for &m in &v.median_q { + put_i64(&mut o, m); + } + for cdf in &v.cdfs { + put_cdf(&mut o, cdf); + } + put_stream(&mut o, v.rans); + o +} diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index ddbfd5a7d..c84ae6af6 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -164,6 +164,15 @@ pub mod int_world; #[cfg(feature = "splat_int")] pub mod int_splat; +// The payloads of the WAI-native integer codecs (`extensions/integer-payloads.md`): +// the WIV1, WIH1, WIA1 and WIS1 parsers and writers, the JSON converters, and +// `decode_int`, the one integer decode path, with the pin and key-log checks over +// parameter sets held in memory. No ONNX runtime and no filesystem: the neural sink +// delegates the integer capabilities to it, and it builds for wasm32. Its video, audio +// and splat arms sit behind their own features. +#[cfg(feature = "neural_int")] +pub mod int_payload; + #[cfg(feature = "neural")] pub mod neural; // The neural C FFI uses the `ffi` feature's shared buffer and error types, so it diff --git a/wai/wai-rs/tests/int_kernels_vectors.rs b/wai/wai-rs/tests/int_kernels_vectors.rs index 797852c1d..586c4ba4a 100644 --- a/wai/wai-rs/tests/int_kernels_vectors.rs +++ b/wai/wai-rs/tests/int_kernels_vectors.rs @@ -222,6 +222,7 @@ fn every_decoded_output_states_a_sha256() { ("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"), ("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"), ("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"), + ("int-payload-conformance", "refusal fixtures and the index of the payloads under tests/vectors, whose sidecars state the SHA-256"), ("vectors/int_mlicpp", "expected.json states rgb_sha256 (RGB8)"), ("vectors/jpegai", "inputs only; the decoded pixels' SHA-256 is pinned by the JPEG AI e2e job"), ]; diff --git a/wai/wai-rs/tests/int_payload.rs b/wai/wai-rs/tests/int_payload.rs new file mode 100644 index 000000000..bd52523a2 --- /dev/null +++ b/wai/wai-rs/tests/int_payload.rs @@ -0,0 +1,138 @@ +//! The payloads of the WAI-native integer codecs (`extensions/integer-payloads.md`) +//! against the committed corpus `wai/int-payload-conformance/`: +//! +//! - every refusal fixture is refused with the code its index states, at the layer it +//! states (parsing alone, or the decode); +//! - every strict prefix of every committed payload is refused, and every changed byte +//! in a sweep over each payload is refused or decoded — never a panic, in either +//! profile (CI runs this file with overflow checks on and off); +//! - a tool's JSON output carried as an envelope's payload under each pinned string is +//! refused (`bad_magic`) by the framing check `wai wrap` runs and by `decode_int`. +#![cfg(all(feature = "video_int", feature = "audio_int", feature = "splat_int"))] + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde_json::Value; + +use wai::container::{Conditioning, Manifest, ModelRequirement, Rendition, Wai, WaiMulti}; +use wai::int_payload::{decode_int, wire, IntParams, Refusal}; +use wai::pin::HeldBytes; + +fn wai_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") +} +fn read(rel: &str) -> Vec { + let p = wai_dir().join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} +fn index() -> Value { + serde_json::from_slice(&read("int-payload-conformance/vectors.json")).expect("the index parses") +} + +/// The set an index entry names, read: member name → bytes. +fn set_of(entry: &Value) -> BTreeMap> { + entry["set"].as_object().into_iter().flatten().map(|(k, v)| (k.clone(), read(v.as_str().unwrap()))).collect() +} + +fn decode(cap: &str, payload: &[u8], set: &BTreeMap>) -> Result { + let view: BTreeMap = set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let params = if view.is_empty() { IntParams::None } else { IntParams::Unverified(HeldBytes::Set { listing: b"", files: &view }) }; + decode_int(cap, payload, ¶ms) +} + +fn parse_err(cap: &str, p: &[u8]) -> Option { + match wai::codecs::pinned_int_payload(cap)? { + wire::MAGIC_WIV1 => wire::parse_wiv1(p, Some((cap == wai::codecs::CAP_VIDEO_INT_HYPER) as u8)).err(), + wire::MAGIC_WIH1 => wire::parse_wih1(p).err(), + wire::MAGIC_WIA1 => wire::parse_wia1(p).err(), + _ => wire::parse_wis1(p).err(), + } +} + +/// Every fixture is refused with its code, at its layer; and every code a payload's +/// bytes can cause has a fixture. +#[test] +fn every_refusal_fixture_is_refused_with_its_code() { + let idx = index(); + let mut seen = std::collections::BTreeSet::new(); + for f in idx["refusals"].as_array().unwrap() { + let (name, cap, code) = (f["name"].as_str().unwrap(), f["capability"].as_str().unwrap(), f["code"].as_str().unwrap()); + let payload = read(f["payload"].as_str().unwrap()); + let parsed = parse_err(cap, &payload); + match f["layer"].as_str().unwrap() { + "parse" => assert_eq!(parsed.map(|e| e.code()), Some(code), "{name}: refused by parsing alone"), + _ => assert_eq!(parsed, None, "{name}: parses, and is refused by the decode"), + } + let r = decode(cap, &payload, &set_of(f)); + assert_eq!(r.as_ref().map(|_| ()).map_err(|e| e.code()), Err(code), "{name}"); + seen.insert(code); + } + for code in ["bad_magic", "format_capability_mismatch", "keyframe_capability_mismatch", "version", "reserved_nonzero", + "field_range", "too_large", "cdf_malformed", "stream_length", "nested_length", "trailing", + "stream_exhausted", "geometry_mismatch", "model_mismatch"] { + assert!(Refusal::from_code(code).is_some(), "{code} is a code"); + assert!(seen.contains(code), "no fixture is refused with {code}"); + } +} + +/// Every strict prefix of every committed payload is refused; a sweep of single-byte +/// changes over each is refused or decoded, and nothing panics. The sweep changes +/// every byte of the fixed header and 96 bytes spread evenly over the rest (low bit +/// and high bit by turns), so each payload's tables, length fields and stream are +/// all reached while the decodes stay few enough for a build with overflow checks. +#[test] +fn every_prefix_and_byte_change_is_refused_or_decoded() { + let idx = index(); + for v in idx["vectors"].as_array().unwrap() { + let (name, cap) = (v["name"].as_str().unwrap(), v["capability"].as_str().unwrap()); + let payload = read(v["payload"].as_str().unwrap()); + let set = set_of(v); + assert!(decode(cap, &payload, &set).is_ok(), "{name}: the committed payload decodes"); + for n in 0..payload.len() { + assert!(parse_err(cap, &payload[..n]).is_some(), "{name}: the {n}-byte prefix parses"); + } + let (mut refused, mut decoded) = (0, 0); + const HEADER: usize = 48; + let stride = (payload.len() - HEADER).div_ceil(96).max(1); + for (n, at) in (0..payload.len()).filter(|&i| i < HEADER || (i - HEADER) % stride == 0).enumerate() { + let mut p = payload.clone(); + p[at] ^= if n % 2 == 0 { 0x01 } else { 0x80 }; + match decode(cap, &p, &set) { + Ok(_) => decoded += 1, + Err(_) => refused += 1, + } + } + assert!(refused > 0, "{name}: no change was refused"); + println!("{name}: {refused} changes refused, {decoded} decoded, no panic"); + } +} + +/// A tool's JSON output wrapped as the payload under each pinned string: the framing +/// check (`Wai::check_int_payload_framing`, `WaiMulti::check_int_payload_framing`, +/// which `wai wrap` and `wai inspect` run) and `decode_int` both refuse it as +/// `bad_magic`; its binary conversion passes both. +#[test] +fn a_json_form_in_an_envelope_is_refused() { + use wai::codecs as c; + for (cap, json, bin) in [ + (c::CAP_VIDEO_INT_MOTION, "wai-rs/tests/vectors/int_video/int_video_bitstream.json", "wai-rs/tests/vectors/int_video/int_video_bitstream.wiv1"), + (c::CAP_VIDEO_INT_HYPER, "wai-rs/tests/vectors/int_video/video_nk_bitstream.json", "wai-rs/tests/vectors/int_video/video_nk_bitstream.wiv1"), + (c::CAP_NEURAL_INT_HYPER, "wai-rs/tests/vectors/int_hyper/tiny_bitstream.json", "wai-rs/tests/vectors/int_hyper/tiny_bitstream.wih1"), + (c::CAP_AUDIO_INT_CODEC, "wai-rs/tests/vectors/int_audio/audio_bitstream.json", "wai-rs/tests/vectors/int_audio/audio_bitstream.wia1"), + (c::CAP_SPLAT_INT_CODEC, "wai-rs/tests/vectors/int_splat/splat.json", "wai-rs/tests/vectors/int_splat/splat.wis1"), + ] { + let wrap = |payload: Vec| Wai::new(Manifest::new("binary", "improve", ModelRequirement::new(cap), Conditioning::new("bytes")), payload); + let env = Wai::from_bytes(&wrap(read(json)).to_bytes().unwrap()).unwrap(); + assert_eq!(env.check_int_payload_framing().map_err(|e| e.code), Err("bad_magic"), "{cap}"); + assert_eq!(decode_int(cap, &env.payload, &IntParams::None).map(|_| ()).map_err(|e| e.code()), Err("bad_magic"), "{cap}"); + assert_eq!(wrap(read(bin)).check_int_payload_framing(), Ok(()), "{cap}: the binary form"); + let multi = WaiMulti::new("binary", "improve", Value::Null, vec![Rendition::media(cap, "bytes", read(json))]); + assert_eq!(multi.check_int_payload_framing().map_err(|(i, e)| (i, e.code)), Err((0, "bad_magic")), "{cap}"); + } + // The fallback decodes the same payload (SPEC §4 step 5), so its framing is checked too. + let env = Wai::new( + Manifest::new("video", "improve", ModelRequirement::new(c::CAP_VIDEO_AV1).with_fallback(c::CAP_VIDEO_INT_MOTION), Conditioning::new("bytes")), + read("wai-rs/tests/vectors/int_video/int_video_bitstream.json")); + assert_eq!(env.check_int_payload_framing().map_err(|e| e.code), Err("bad_magic")); +} diff --git a/wai/wai-rs/tests/int_payload_preflight.rs b/wai/wai-rs/tests/int_payload_preflight.rs new file mode 100644 index 000000000..f57fa832b --- /dev/null +++ b/wai/wai-rs/tests/int_payload_preflight.rs @@ -0,0 +1,116 @@ +//! An integer payload whose header asks for more than the caps allow, or more than its +//! parameter set can fit, is refused before the decode allocates anything sized from +//! it (`extensions/integer-payloads.md` §7). +//! +//! Each case below is a committed payload with header fields changed. The parsers cap +//! every tensor a header sizes (`too_large`) before reading a section, and the decode +//! derives each network's shapes from its layer geometry before it allocates a latent; +//! this file checks both directly with a global allocator that records the largest +//! single allocation, and asserts that refusing each payload allocates at once no more +//! than parsing its parameter set needs (each parsed weight is held as an `i64`, so at +//! most four times the set's bytes) or a few kilobytes, whichever is more: nothing +//! sized from the header. +//! +//! Its own test binary with a single test, so that no other test's allocations land in +//! the window it measures. +#![cfg(all(feature = "video_int", feature = "audio_int", feature = "splat_int"))] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + +use wai::int_payload::{decode_int, IntParams}; +use wai::pin::HeldBytes; + +/// The system allocator, recording the largest single request. +struct Largest; +static LARGEST: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Largest { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + LARGEST.fetch_max(l.size(), Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + LARGEST.fetch_max(l.size(), Relaxed); + unsafe { System.alloc_zeroed(l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + LARGEST.fetch_max(new, Relaxed); + unsafe { System.realloc(p, l, new) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static ALLOC: Largest = Largest; + +/// The largest single allocation `f` makes, and its result. +fn largest_in(f: impl FnOnce() -> R) -> (R, usize) { + LARGEST.store(0, Relaxed); + let r = f(); + (r, LARGEST.load(Relaxed)) +} + +fn read(rel: &str) -> Vec { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors").join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display())) +} + +/// `p` with the u32 fields at the given offsets replaced. +fn with(p: &[u8], fields: &[(usize, u32)]) -> Vec { + let mut p = p.to_vec(); + for &(off, v) in fields { + p[off..off + 4].copy_from_slice(&v.to_le_bytes()); + } + p +} + +const FEW_KB: usize = 16 << 10; + +#[test] +fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() { + use wai::codecs as c; + let hyp: BTreeMap> = [("entropy.json", "int_hyper/tiny_entropy.json"), ("g_s.whm", "int_hyper/tiny_g_s.bin"), ("h_s.whm", "int_hyper/tiny_h_s.bin")] + .into_iter().map(|(k, v)| (k.to_string(), read(v))).collect(); + let hyp_view: BTreeMap = hyp.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let hyp = IntParams::Unverified(HeldBytes::Set { listing: b"", files: &hyp_view }); + let (model, gs) = (read("int_audio/audio_model.bin"), read("int_splat/splat_gs.bin")); + let (aud, spl) = (IntParams::Unverified(HeldBytes::File(&model)), IntParams::Unverified(HeldBytes::File(&gs))); + let (iv, nk, ih) = (read("int_video/int_video_bitstream.wiv1"), read("int_video/video_nk_bitstream.wiv1"), read("int_hyper/tiny_bitstream.wih1")); + let (au, sp) = (read("int_audio/audio_bitstream.wia1"), read("int_splat/splat.wis1")); + + // The counter sees a decode's allocations: the committed clip's largest is at least + // one 48 × 48 frame of i64. + let (r, largest) = largest_in(|| decode_int(c::CAP_VIDEO_INT_MOTION, &iv, &IntParams::None)); + r.expect("the committed clip decodes"); + assert!(largest >= 48 * 48 * 3 * 8, "the counter saw a {largest}-byte allocation at most"); + + let hyp_bytes: usize = hyp_view.values().map(|v| v.len()).sum(); + let cases: Vec<(&str, &str, Vec, &IntParams<'_>, &str, usize)> = vec![ + // A 2^20-frame clip of 48 × 48: each frame is within the cap, the clip is not. + ("a 2^20-frame clip", c::CAP_VIDEO_INT_MOTION, with(&iv, &[(8, 1 << 20)]), &IntParams::None, "too_large", 0), + // An 8192 × 8192 frame. + ("an 8192 × 8192 frame", c::CAP_VIDEO_INT_HYPER, with(&nk, &[(12, 8192), (16, 8192)]), &hyp, "too_large", hyp_bytes), + // A hyper-latent of 2 × 4000 × 4000, past the cap. + ("a 32-million-element hyper-latent", c::CAP_NEURAL_INT_HYPER, with(&ih, &[(24, 4000), (28, 4000)]), &hyp, "too_large", hyp_bytes), + // A hyper-latent of 2 × 2000 × 2000, within the cap, that h_s would expand to + // 8 × 4000 × 4000, past it. + ("a hyper-latent h_s expands past the cap", c::CAP_NEURAL_INT_HYPER, with(&ih, &[(24, 2000), (28, 2000)]), &hyp, "too_large", hyp_bytes), + // An audio latent of 16 × 2^20, within the cap, that the synthesis would + // upsample past it. + ("an audio latent the synthesis expands past the cap", c::CAP_AUDIO_INT_CODEC, with(&au, &[(16, 1 << 20)]), &aud, "too_large", model.len()), + // A splat latent of 32 × 1024 × 1024, within the cap, that g_s would expand to + // 14 × 4096 × 4096, past it. + ("a splat latent g_s expands past the cap", c::CAP_SPLAT_INT_CODEC, with(&sp, &[(12, 1024), (16, 1024)]), &spl, "too_large", gs.len()), + ]; + for (what, cap, bad, params, code, set_bytes) in cases { + let (r, largest) = largest_in(|| decode_int(cap, &bad, params)); + let bound = FEW_KB.max(4 * set_bytes); + assert!(largest <= bound, "{what}: refusing it allocated {largest} bytes at once, over {bound} ({r:?})"); + assert_eq!(r.map(|_| ()).map_err(|e| e.code()), Err(code), "{what}"); + } +} diff --git a/wai/wai-rs/tests/vectors/int_audio/audio_bitstream.wia1 b/wai/wai-rs/tests/vectors/int_audio/audio_bitstream.wia1 new file mode 100644 index 0000000000000000000000000000000000000000..05d5af267979cb7b144e22f71b9c1a7798a47f22 GIT binary patch literal 1444 zcmWIdbTnk-VPI&mV_*QDgAv!@i7#MKr`vp`tlJx<-&+rczfVG0aFqCItsMcd(r~<}+ z%TZt$p9hBZ9bnkK1Ez^z*u%4t6DTLiz)-IZG{=I0q4gk8-(3cVrq>J%jX!~MqwxSv zpY%>=42!6rt{Z@vsOlQqS5RJ(y!3lgw1vWlL`n8J*Y34%7F5kkY7mS_uRHnXUhIX~ z&YP0iTB~l=#^3p`WSXd;Q#j$B>e2^SHZs}seJh{C6YZMtj6tja@YzMrtb7i=y8h03 z?WgaV=7NU|T!qi(9q@3T?`%hIU zp1ml!_Rx{_J|(7^rzSt%rF-x|frKi9w5R&pchd9T%g_7p``3l)&$T}txiwRFh-XYL zf2zIhnla;2k7CZ&*wu$D!+Exru?XwFKX{DqT-c9B_FpHG_9dRa=ezQaLs^%i<}bcO z$7Abm+>-ftZ8!VR9T#0U&a6EUy;5yqdU4>)i~SZZRLZY!y2DkG$ihj@qlv>`VXnatVAtYj@hrsjguK E03_0gmjD0& literal 0 HcmV?d00001 diff --git a/wai/wai-rs/tests/vectors/int_hyper/tiny_bitstream.wih1 b/wai/wai-rs/tests/vectors/int_hyper/tiny_bitstream.wih1 new file mode 100644 index 0000000000000000000000000000000000000000..f9004d33731652b534afcea327cf9722f862de11 GIT binary patch literal 88 zcmWId^e|*(U|?WD!c0Im408b49dGR)^Jo__XaLy?MUi4LD+C#qFtq3p)iDaW->l-^Ny8UQFX6jT5J literal 0 HcmV?d00001 diff --git a/wai/wai-rs/tests/vectors/int_splat/splat.wis1 b/wai/wai-rs/tests/vectors/int_splat/splat.wis1 new file mode 100644 index 0000000000000000000000000000000000000000..72a429a3d401320f5f87a7715207a1d7fec8b017 GIT binary patch literal 2964 zcmb7E4K!3~82-liNg0YxsI;jqYv+_wXqy;PM^0>3N>p2sjZ;>nv`X}=OeCa4*)Qvcc1T~BSt^weD_}8ectDN-sgSq=W4%jDo2Bmv4oI8 z-HSeJVi9FRoDC1=XYmgT-D2bOQ}%`llhs7|`+pG$lYf6Z>q3!(W#7A)3C;hD2^G@t z-hVFJ^aZupFWma486jc9b8FTK-g$)zbF)X7?e7c~Runl{Sr6YPm+tUo^`qee)l4k7Zd2$5M3(zcY4ci|YP zYlJ90B7{>1u2w=AhH`RPVup(t#L2Y?>EIL6?nwwY6>otj(gCpJ2~k-IToQ0)1LHc3 zz~>4q_>`)EMQ+kD04MUxOrY70khdamgO}3`JYqf?Hw2WZw|zdKeyEk^DniUZ1LUFJ z*h140=WdtbD>Bhtq*?4Ji$w`@S$HF)K?QiRK1}YJy4=O zP=o^)Js272lurZaLe#kzF*%5VSL|%`)eLT$pPjn}gs6uh4|<|<198ZsREIcdSNILk zo>+9vzgjwCfae0Q1bojNe$aRLLvyLD1`avc8ZfILV0!hN{Vq`pomH6;^1y>O zX86_QM@U0BAwyE38$D4i0x#-(uNiZvu;y-`I;l^X2RhrW=vT{5)Qldm{-S3J`vt$) zS)#E*v?~i)}yy%I-a|w7k^%wYd%nHj39sPKrp_6_)7z^UVqR> z$_;GCeBd_#iym>xKB5El(eJxm0PZz7&kn$@8{o&BD?H24L-xGUL9Hg}eIT&e;75+m zchG|MsdIrn`%#z7uFEUsOmLv@3ePv{0j-JVgI}%iKsBz*19py^`}e&{i4ga_MU3{{ z8e*Kc$a21&Lt?$vAF;v9ZTM!oY2q;6X4l$4&mBQaMIjqUdRF^g9~D|PqX`f%kG z!z(u2_z7lt+aE60DSSTV_O!%9BlwwdF`a>q8aMAUrR50?=gecaKX2B)BAGsN+svmg z=WLviWSjJz-_n=iQM?*^hxY4{oe34v!ut4Om!%J!!p@D1o3Sa|YsJDRWoJVwUOJXo za+n1Iarx`vZ*u(~dY-DD)RfAbqs$+#p1$?g;8^M9!wF{B zSo0sL#2xa>T<%|Py0v&l)F8Fv`;JVU7Nj?(#GT`tBT=z2_PUgn=i>M4^Ps3%FXoxH z>gb-ltUHmT`_#;RRjNsY`I2$r; z0de+&t9_&|j;SnuFezY4slA)U-0S4Sn^YIr@z#njx_FxlC%juB~; zhBSV&#;xw1`^@0FyprmhPt<1w7HxfaK2)3*zUstY$4D>B4r$mhk9;Y|IB~;d?{L>9 zKdtG>R<)K9HGwu&qWx)kIg1jUf>ej=dAwRxz(l?}k@MQz`fhc@p4->FBmIMy+;KC= zZW~=&F}T@UT|0QJ#p0u@w@4ylGM1&8%zPn;@cy}Z*a1^w{oiuWfaImizURagI`vM>!16RrBrfAwQRwJ3Bi8-0+`mHy0r;qFki7C~!H#PSf@@TqYdoE6$Xyr$2@!!n#<&LC#kl3TMimc2 zAfLI00ab2X3j$d)fIv=|K_Kdm5Qvx$1mb%E0ttjcAk|C=q_GqN`QF6Ua0>z%8sKUl zg+M;OfIz^vT`;`woHV{)9j(Rw0l!HUyHm27!G01A*vlKpa;04S}R`dCX;#i~0@(;>snC3x!JwmliI)Tt>J|arw+;k;_jm zYg{;7cDO(wT*q*??VVlR-~WmKR|5CI1KHZ)aQ96v|HS@H=l$>WfAe(eVFQc8jZFbBEih{>S!j?f=;L$20$C{ZFm``tF}RUT!P=@BFvL z&H1OEf9v?a4LMw$|M>5Jw)H=D|Ko*CuE)0jmyLhg^B;Zx?*G%~KfeEuBF;{-4R<^W z8wPNP1Efl#x4~X=*X`?j@7*ib%L)gzfoG>>4vafO2j1S5S4S#C3$G?guZoDtV_rNt zWYMv5C{bKO`RO5$0OEqRiugHB zy0{-DLu4y{Qa{?RMfdG)N$DF=x25nxDzfqka#GzN^%d&uMHH&azblLw?A1!feNv1& zKCF3}dHC>6RFtFn>3Q2n9tpZO^M+>|w<3-=-uW2v`C6n^LbIB_N9?rN3gnW=#6Y>2 z-O1m2adn?gCPeH=sjnauAc?E8g6S;^TYGTIFHPPkdfNQuim6lo_+iA%(d5eM`wI28 zXA zLRI4rnuYOSIwA1Q*Q`q@*#h}R{_tLzV@Fy0%ym3+E7XJbU3U-ovtwmx*LQTh&Hjw3 zz&YQ(Z(jn+ukkXyAAU`u)d=fJ1_VRNd_SR5sHi}2g#3wBb*=lnrD5# zYs)2#A5PSJrS(<*jaNkj!YNur-B4P7*iUIP+cHf?mzKNvIBv0USv>d*SrpXC(^7k< zt4lF{3*Gf0=U}-oC|493Cy!3Lb!Z+HsnR}UrySYzRqg&)IkiMne~rsK`A%XT^+HOXO}R%E*$PT~;tF0;~VUvgY0@fK1reLM5Z>c?elKj<74>2;QVCVIMUuHGm` z+bn^-lW7-e?gQ@2^@<*78hXK&JKAe%O9_ptGwc(mL z_yaFvf6_v=1S4@h>FS1rLH^P6(#lSG(4e(nvU)zTEcf+8(pE1zS8X!?$}EalvrxMC zpdA7$tCy7+QimkH*&1z|(vl8DgI%HFI*(LtPoWHwm~5GoJ-f$9;9!}R3v1)Ws?1f1=PclbBedr_jO}zj zO{Vp@5<50UR7Tn^jJ1M!08N8jIQ88Byld3q=CC65U%vLvqDK2fZy^Rj*SPYk4et?F zb<_2%H~UvEuq0eWRgUwh>==TMLhMPec&WKNFjlpnqfEc|5q4LFg_O}-GP~Fx2|d4{ z#PYMLl9uiN!zx(Omonya(7SFs;TdjXf6_av>s->=xg^u~vgen+*4W>R(7!4(Xc#?s z@SGTbhnwkl`B%o&54+gK&aE8H(q)!c`fCoJUzW|YdXFP~yN_kJRKt<&N@H>M*Kp#V z-eTRcui$XT71#(xcTU1@(3AEm`h2nG-XQ3rN^DFTBy8&HTL)oXwT6C=W8UR68V5QW zB_(MWn~WP|Nhg0YIwL-6*e8P%LF_JcG{00e~=k8Q1@EN`gkt;Q%J3+yT_eBm;Y`miFyoG zFTM+%TaMwZN~}sNN_29rZd-8u@R?I(z73s?tm5`ltkk;)37p2dHK_~P7MyITf%Ngf zI*yk*FEk`Jk7J_+lR2++!GE*(dUS=OSit#Fnk^rKT>O6ZKys_jBj-|&=QQLkjbn(} z@1|msuB_Jk4xHGVJ*;=uH#n}nSuAbglboYJS*vQAHk>cg;;d0`J&t4SI*TTn%~@GG z&qkxOIZ4MyR}01u1*MDenEBq1OmJU=1g81l&2!E(zrtU%<*}~j(Qfv$S(?gsP|l5y zaghDJpYtcXXVv<`JZPZgpnCa;~Qy9ix0>KT_FlXW~F17$SS zaLD%I(#w0RIW7&IGCw{Raj0jVq)GKwoRJP5s6w1R=iS3<>DSWL9QC9YXt`tyC+N8> zw1Z~JfnQ-um(~Ra2@1cn{q?yWxp?QN^}+8K%Gy!g7dvvV1YDfRk#gJQg>JoV;!qQ` z*;jv@^FBR>cOUI6szNaDCXoVQEyd|Lz~* zw9HZ2hoZ7Lvo^+Oo3c3;_xDcFY~9N)o%6D-(v7i>6opRv51ucTep+uP1v7f4kdC}2 zV>cenmg!KEwgylvV5UYYz2ZD8+GAZx6ehu%JQgf9_L$6`_3)E=3r%7Dn)QH2%)exn z_~$}*UZ|P1-4s4^CgqQ{aFKpNh_OeaXdvFBM&V3axWHyzkESEo4#yK32^>AD=;kULw-rJs{`2FYrvY~px{g|mt0ra0Pf zf3HUCX9U_E*oAn1MIsKZy5g4S*Vddk+)N2P%@Z24-+_5Rd`!(-T&iWP}Tx0`IwHvaQHqYLGlNVx7o*4@DQ1k z$wOile2V0BU5I0MwVH7I+~VqY_i9e#9V}aW(~FbrY{lL*AL7_q?Po2s<2hEluCjaV zqy41^lFc3$$<>sapT89U_M*>|Bd^eH)?Wg0G-wK&4MVcwc3%|zX1v)!)3>49joxgn z%>y#Y`bw-Ak3#9U@kOkuQU~e6A}Cw!t$qA#41)5OddgZ*Xy$jo*5&VSdnJ- zu2mChotq$gR)17VA#RQ(Z^8@3@SJ2B`#PCZ9#5Qi3ZJly>wZ*M3MhrYSpOatHMMn* zuiq+Ul`1eEvooNQG2){#l$zr(DA8c)Xrcv$@sl_|URFTa{NbDf%85|FVq4Ci=+`oS zD|Q^`+yJSQ0lM6N{+0?DhM#|=an$B=k6yJzV3FJ4321*8{|;&|of48vuAGcByP@1! z)_3S612Dg-{?*M+`<~st;GmRzxYyF=J;z}ewZ`_H zhsVjUYFkGSG%IuJykn%aL{OZO_n)A4*gWpF;}x{dwK(*u+D&V|=*vZu3mcc-UrKZ` zsL%sNLXVuUIsfUg;Gz%J0q}@GA3o!-y(w!RDfZ0aXP~sno~n6$0>F(B{=AEbj_>h>A7b=>UQ>v#wd}7EZ|XG7h{VJzd}| zhkAVfWmVi6^CbVLx60Eo{5Hi2Z|`_z%J`*euX_vW(C>+AA}PhXhqjfy%BG)s;(LD$q9H@AkO_hpL$7eg>-oc;fT|F!%oFzKOesHD}5Au`Q_K$WjW)Yv;rw&xMyO7I^HlZy!E69%R#2ig}y~f2J)1!gF-3O`C$Q<;#RDW!IhS`ou(+ zyh`_L|7>Tw|Mtq&5?O(ymI~V-(qSb0V9$uZn<0Jl8{bTl_146P{Pvr`4S~+NjTbAL zG@Dj$!x}HgfhXMtmXmg8%CmB={L*^q>rP2CGga!(tDGuz@o2B_zG~75;y+o39q)pV z%tg$^Oe6Na?$7o}`Sh{jeYEIT^iTeW&L!Gxn}KV?2IMU73ya8#<0e~~zC3)P4)4%6 z5oY45(O>;CT(>^uB?+e^&nE3V^^oVtANq||c#OVFNRF(-JrwUQeENQd=oiyt!n*lh z0;e4&4|rh4&X|NXYWeKhbMN>Igfg;sIQX(znUb4Xdw0|zb;F%}?0K(cQ!L`Ow_n8m zMy)A^!ukBCgPwYVg!Sc0=*HSG6y9e|p(!xA80&azU8$yLFV3iX!=Zr$j<9=Ad zq(7I1%bklbuP6_=McBs}M>_d^bGjHSba-Wfrwslyj)0=pka~t}pR@bE?3zhQShn`N{N_5W%>W8a6*-5OythqVH zFBJVsRrO6kDk^G~SUzHZML+6Im7SPzRwIU=W^{H0_@#dguf3+?Cnw;8)R43YhoK%i ziT{P&%E7)wB{{$M=l#J;-FVU-nT24yzB2Hb@$vVk4+kSaoC>|D7ASXGjv5#xfon-7&x8Drr}g=6 zezJV6k$tXuZC2#w#|&Qo;cCNNf9LQrq^b7}NSNw#E1Q8|u@tu#^-;?FVeU!+N>}{$ zkDph{5qpsM<`mqSuAa0U`+%5(5@p7YEi(%XX^it42kO*V(F=!Pgk;+(#5muTweV|T zDj9vR(n~Zw6(0s`6w8qekGXQ}lG5qmJGXAtI!EK`(WRlrUQOeUDsQ5$KMb1ww$p*i zYrH!JE z^zSI&dxMC3wHR!U;55ZZW{H-!kwn1EI-w^AdqBhk2L4k0WyI)y9A@vO+X&C-d&I6H zXOd5B3bM3j6VTf>V5+o!U~CHA#)&+l1^2)nCNmLr8T0V+_80UCU=jIlEe59Ky@q=E!VXZGgrfz*qLD6nDX7o$PXVY) zFCgT70}UWdflQrk5<@-*6Ka`Da71Mz`{u$x8>2AL?cH_2Gg=!naXFoqb?*RX@_G>5 z<@O>)Axw&HuJM-2C&o(*IROWr3(5g+UO5s*9zf96SFeF*o^t4jpZrXt_+RL5=O}{D z{&7U$zCQ9Q=?q$OXE&j~r;XxR)=jMV#Dk^Yo+HzoGZ;@NrD)jIa|CFV53@hf30Z$l zj`5^&n$mZ{kWq1@1bDk*LF{x{pvo+~Mh(qGP(C-~z?lzq;Nf&7GE``d>=H>K_^18B zG<++7$Jr_|%CH&apEpm@1+CbW%G2f)XjCoE#mAL&BjGP@_=Yac@k1c~{)jkjHRLQa zf83jqsQZInSZkHGy^3$!K8DWke0`u?d6`zEN7haWn@ot^vRZtkIE7`PGa+D z(H38Lu=F|7W_AurfB!h`-Lrk%h8)D|z%P_+D6JDVdw=F7L>;&qb=$Yt@Fk}Nt1wc8 zPQBTk7RXA~kY%Ri^;A(+9{fS~Js2lJzBvG;mvX5kJu*0fPFbSl_eAWmYA>c{j6iyP z*4lL`Ti%>EzWlXkYsYx6NdDclSyI`Zb0Kniuu8v1yw01R<>G|g`qqum?!Qi2tTQ3a zlOU*?e zfk5A#CWjrgKF=(iR{^G7j$&Ms*RVf&BXK9dq=K`>3$1nJK4i#az3d*~GyIOgTUeV_ z5ytaW11z#co9dUvN#U(Bz}Wmaf@(ENC4BF0CZd@#M8)pgNK+01Wt@p3rjJ?N+>8F6 zlg@T2+>!D{stWNC#{Kl+nx~H-c&oed^QIHD-l^kYafTwH@nI_Q^hhjB@RT|#cHk=yBgeKapE=Z;g$@=THX`>z$+F${Xvpga&3Wn=$$CN5ow7jZVtsh z>{3O(zL|mJ9jZkoD;lGgazDY{`$lYhrA025p2NMl{ju_6LsDH;w;2ASsR44RY90L9 zT1d1vJWcfMFJeHRm(Y)-sUv4>-7d1w zv3c&77KuJ%84(0iqhZmKzZgo(n(2sO?<|SJ&5IzMQLIqTdo^aJ zd<_fx-sBxZXU}1{V1q~O>bGr1=CF0G({)i^-+RK~@Te#0%=;Ot`b7q9cDNE~Q4PR0 zYv)rMONR(wnH%+DDTeinvzG8hzsm%BD>lP0^Fp+EA(_ylD_(kaukUkGj|^47?gitd zbT)nOHZT3BWhHf?a1Qf+_YCUkwOfC?DUCQi6qqabQ=FlobrLflJA_=}U|@2e#2GG6 zEy!W{KX^YVl`(+pQ1EktFGHXJjxb2X;1)k=fQiD3h&!DZHrKS(@?IS`DM$?cfLLbW zVS01xq$Y92M0tNpip`(_y1|l+Ra}& zvlquwsuht_`q$7FK8gt8pO%yZ8^P!z_#Uc2Lo(jCsO@j(;#;r~y^mJHa}Jx95Q7}Q zi^2(U$NeDW1Fc_NmR#8RhW9TYKN(rmPIFdx0JH*o8JF!BfI2pfI3Xs8by-l87foQ& z`(U2r%U5GiP0SBS>B%ja*3wh*LE~X$+{h4CBIf`ZLn1I=YusX(o{%A9Ci*cl$3bv9 zn@DT^)C*QQ$;qaKI}@HGMG&kt(@{oZhlx?6CjpAaX}agUHC(Go92G152$&*w*Xq6$tld$3MJ@k(ANJy72y>sY z0JGTkFl9XVA@X%cJMw~Y1T(c3M@ublM$0@m26nN?#O;O0D7D)J3MzJ?Jh${w=DRBk zI4R9PSe^wz8ufN8fpUC;ns3yD)ra;`YZCm?LmvCklksDONJJZHg&a&1mT)5Kx>V5K zPV}Oms7Qb!{y1u(sjO-eurq7QnB)6Ul6T~4&hd^W zvT&3r?oa#$x|`Sy@;g6!)NCidU8**9jFL$ioJ*iFo zOM$NK7J8&v{$!bz`(1f=rE?h(NR;vbQ&ZhMpS+NY{R?{WsbLXSPCzV%F zCwE9FUT5uc$u>R`_Gx_nWcag6W9i-|`;iMZZ`-42M;Chtw&tRY+e8`$U15ubraZzv ze(MY#UA#yt$^VAeqcxx!wmld&zY*7NH9FKn3RH6UvG!%WS3TP5)4@|%d+Q}?C4ZfK z>f9e(aK>v!|9f9%Et^g<+kXymywRUw6pu&ZHslGHp3*4gNL(grN4(B7PBUc}MJ^R0 zSy}BzN@h;WN&t^*;~2KQz4YrFG}tZeCB!)oBzK+whjr+i18!+^^t zp!2<)aTfSVby0Z6h&JCwIndpzO@5tf&O^~teI`Q-p3a?Tlw8^d)6L#s6U0jhBC&hP z+6mLx^C6q4^9r8iZMO!3$y6p`;GhaTx9cMOFl`$KAM>J>wNsF9Vt}Yd5AjYl|Xem<@hH-zCqajAD+mp@7uXEHNL^g^4wjqwcgU z!F*~4z!Row-gD*PZFwnN)#)X8KQxnA1rz{3dL@V>1U-5z=Q4KYQ7kpZ!yb9L z5rMc=ACK6QjX?74ZbXXRPow?5EQ)urkwWVf*H9LgtI$048#K?8=`dHk934~r3%{$P z9rv_H6`NLjm=YINjedlYA{+lwCR?m{G61I_+{}mr={2?XA!5~E|%m}Q4SYRuZKH}6!s<_z{ zbHc^_0IdCS1&jid0gn4|$PYBu!JHNhVHt=dWhn>J7IT-Wb()4aMcz50c2Eps+1#8l zJI5i~p;Lh-<{GLzu^Xn2UqC;R#u1eQ;0#N=4dU({bM(@!qr{pUP)3CCF4RP>4I|py z6T70Xj3r1uL@V4s!;~z>;ZRsl!h@m^OyI}o0K#???2`q7i`zF4`xJao9lkA?x#v0z zzUv<`X+i0v-xx!Zd!{Agd(a%N=42Pj{fRT8JT?u_n`}z&ds9S2wFls?$*N!*9Kw!E{GmKmcI`GszAm18!8o7Z6Yqz-ABrLV zmxf4%1HE$;<9)x-FCV`m6FK#C(XC%Zl7ukRCw2?( zBoqb& zu*@nqpe*C)fhtd>TQqAiN#y3wU$mx+Wz_9FD+151r-U~lqJ*S(3)DoS6?Ol>Jgo~V zMCQxgg{U6XrB%v*t+X2SNDZz;`zGZF{sA{7THw};%au8m#qx~6xVS@<%aa>~;LpSW=n^{eO> z#_uAL5@m3cpa_13iC;NHZA|=tX(|=QV!9N&v*Sk4=G{G}?!t<@I@eV8YHEuse_dr*L0;`@?zw4?gE znqgl?d0<=9`lu73^Ta)(z@{`|h@wdh`DuzO2#!;_W%{<<#WI$B_3#CYO&UHCmsu~LdeSTcubab=;{88bOR zroES@`url6cXpFO=+DKRP>)1b9L&R?Q+$o$blfJ`mES~E|2~MP(1Ht+g|4?v2T-!j z&9f=hSHc<5-A3r5{`&~DTX4#^m}8ygcZ{ePO*JW)EDM6fNo6YkeJ50_fHu;>aX<0l zvL{Njqk)>QenHWO3BdzSg`(*QP~@e6=Y@~^&R>AM`hM-3MC(%Yvc5t6x5D7X5{0l|uCXn;P?Ke~{?U7DG zE5QAc8vJnTQTXMoqr_7e>7W@QjM*n}GHY8(nj*O8KHO0n z&|iJoBvCY`-$YRh0q4)L)Fh)4%2G)a+(2(Pc~i)RWFJ096j$V+w?JsNI4L><@Mh=nQWwo`QsJExmYMgiDlQKC-qDI)dnMLOq1 zB+a`cg#k7jP@gO^`Luff9fv{VWD#U=c=Tx4FCgM283O@WX4&GM&4cD&n6#cu9 zLze3BrQ-LUrnN2wlZ?i-QPY2jxc$#=QvPU_z;i7IVdq2QG3jGC%+EP7Mu0Ga;idU? z!{!=J#&n>Yx_3JT7OOIdN{?$s%s%LbZ`A4$V~!Fi*qD1fxfS!+8>zhX^@KWViLEKO7LYpjQWHNdYLh8hqTAlF)!Aer3lAFU!Dl?K zQ>TX5yy}jS+cE&V9GYQjuY_q%+lLCxP z7#RE>tt&kZsps_);b~w_r573#B7(*7{0J*T0dO5*ieex_TMwbT#b09$b{(%JXa2?y zWPL-(CDV{UzMGJy=SDGmW%c2nCjpp(j%}XAVNrSv`x0QV>kuZ}^aWl*asm59a~|F_ zp-2CeV@zcki?(KN2BHsKh9GX(-ND60ouudVIm7S7eGFXkJs{o&oyed5m;S#{E`()e;D zj0T}N*~b0+?Id$3A35Q*8C>wiC6deGkA&Lh`zY70dcfG=B2(5P7B;>znWMAM^Xl8P zQ6(R`LP&X8f3qd-{OAzK>`Ggl6Do39P{cTE)X|gsc@Z&3wZL{WebI%~eW338T`$eR-zTLUbVf*98T% ze$jsHw>tq$Sx*QpS85P#Ypy`RH;f@ufH+*-p*RBDvjge!>M4LXM=)-b^C2zQNMz6M zI_&-}A+Wsu9NFo(20aTphFTWeL5Dx10o;2bVAnqfe>gkD$bPRx?v&k6x}G)Gon=GO$^mE6;pou2WTx^fGhc6hIx7jM86odA=TU7WUxiE87I6a zi1y)Pj0;^y$W){g;QAWMj6)p*5fXKz&YE$Qea;T)X5I^6*TcW~=B`NMN>l~)s~|tf zi8_xjd!B_QpEyRM+zZFHgBF4dGX#F)$^q23uxqeMnO20& z^_Sc=#1!PA-!u}jvKMy!YCpACqyo0ocAv0|4^LFM?}e(wJj3S=z$q$|_6&=aB77ll zHL1%V2h;_S!Q)4au}^Iguzd=*h*vtP)Mc4+oTIHQ2{@YyuY&9&LjN={x-_b2@{m4y zX;TRy`|nBOEovuR(7*~KV6Y!0J?Td^>P!Q#Uwwv8@rYo0HAgdcznnq#W{e{IsYg+v zUOMzIp=#uxmnPBEE>pCS6JAuMW^?kR4G+=-z6ZeOB|4r?^&mO3NFc=WC|XW5lMrya z5YRdB5e@6^hYzFW;m0OSx$`azUj1MZ%#O0fXpxD*KX-lzdux*cGYpoZogG}o7bbKg zcH8d+`tcFp_F~-WI^UzhYUnAq^K+34H3Ea-EoA~m3 z4moG-FWnh?hhFPwL>Bl+M0h(o681b&MGdUvpt@Hs;P&9`m~(s_$^=9hTra0GCBA=0 z8oDyzZZWUuCQ(c9LsvwwQlXE4Q(1fQHW5?kkE)rd+}De^g3Je~Q-@zLkT;(Y&Q*QE zJf06E207Bu5Lh#H_Rk94JG7SJE-*l(S>Vy|05~G(Z3MO~HjtX6dXtt{r@%}xxJz-{ z7y`5gcViq6#na#Xg~8jsd#E2f6A<3LTl9T>J&fA}^O(F~Ywq){1ATev2Z*av1n!*u zOsr#95R|NZm{qDMCXSd-OxtP4s*wt)@vvrw#q2L~U$!q&suzlj|C2&y3>-wKWU2sr z*wLiwJN9(m*N5==r#s;Snf~;>v17oL{sP69b&!6=PKCRybCM~s{+cMdl|fg`S;6{l zx8t=XBbb1II=v=G7K8q~3a`HZ6h9x5LQdHSVi~JgMvGPg{B)8$U_w?$yz$2Xj@R07 zlyA#`s#iTFKmMXH#$q&xG9Ma*zrccf&e1nz zy-1of`q*f@&y2!79@I*cB>dU4G}Q2wJE&vl;_?36b<}K9Kk0Yw6xP028tY;Hg#M<_ zf#HmO3rqZjfsgQvghtM%w~9YC08&60pG(<)kBJ9BPT< zL$Bd6y}FvB=xW3TLMZWI&)ikZ(Vw&OU6q|OYK||xm#)cOy-qfQYyY- zGgG`Ito&Ze9eQwpA5Ai-zDVU=0mb6*8Wk&5%ougUfxhh=V3kyVv*OUB%#$pUl$q7d zM&69nEce1Y4g1Va?uI@8tYma4zBKQMPO9pA4t#TRp;CKJmswt_g`3h}r_FulN1|d` z$hUfqxQUYBtXC^tg>!@Ubpz(A1$^@-@=c6htAEE>VjMC);OC(^G^ON$RI@X~gotCa zxzU~vaf`tfba_=<;CW;n8a<_t`6CYW)`ql_^6*@H7F*{XMX({S2Z|_@mSZch07Xghe;u z7cbJ-7Uk#$ax^83C;K#b%Dao$XQBEz$KJS`vyfgyBU8!NdzBMoDA#e z*xl8_V^M0Gs$JjZA=MH+yG1|I{RJrDAc4P{b0j811n^u}CnUdLqTQ<2VUj|B!+MMT zv18hM@i)+|_^-%Jc(b-pnv=M2_rc7p(p*9J)T%EQ#6=T1?z%o7JeI8wXNdjfCX;*@l%NbN2=j8G?^ar?XHx4Bvumx1Td5@;Nn1Ete55N&WJu%gF z8hk<;d)}v4RWfeu*-MHk9;1C6eMP@#I7YNPbsD6)86mDz@ux)`^G8j{|G<_`s)AVi zdnl9MVwy*EZr%{yskqFqgmyQ`A2b`jjxPtQ$sL-&K6mEULkMmTWo7`S*rfbFyc2R7U|y=ODh%$qh2`30}kGgrOCJjG7M(( zOa7)l-C%^MfP2h-;vlA$SMfTiGb((9&->fuim_$E&UGi4NxTYcq0 zX$_Mg0>A>55VRXUU!g!UBwB5HR|+sLmHs5#8kE6K{Q85yth^;BzI=naKCpu6Iz9<~ zRpeavz!O5$DtM0UiD*Fyx87rHxm}@8>4(u15n3cE{T_U;k_k@b#1Y1xlvaY6)=4Up zAt83RnMbj#T)8I*UzZtF=M|zdkZ>lMGt=Z^D3-&I$n%+131$(=# z0WV!+0n|wP(0)Bc;akq0#9b-yLz=y*!Z_a>yYWX?jl5iCNGb5}C4Rayif3$yVB~{W z@K{m*xQA>2g&!|JSfl$KhY_qKX^xv?1%JIEtchjgF4Zp)&oxLAKbnW$LfE9y^)>96 zb(Po130WHmt*}wBnkiGj1J6&T$VJmgf3Uh4Gy_!Mrg8LMrmr5q; z*lZR|?0P(~4V`CrlkI4X+$P+Fm=v{I(Lj1*gwu6$y^KUA_|Q!_OPD2MFA0sbVoccv z5RDA_u=qh$Mo)x_I`tgShHS1-4E3#x{HC--JkYW*J_+b?{L3|P!Jnt zj0>yNKJC#)BVGEqbBouclq)|#cB5ti`^*D+qXG_j)p-U`@nxW9L?&@Y<51+7c44F3 zlO!(1BAWXg9^--tuK}sy{dD`UpS()7n&Oq*YAxxiIpHyzTEY&+-841dHQ7ml4kgsV}dYD@% zbyQ=FD5unbWxo4{y-1UQCqA>nG>iHYqCvHc+@#PdQ*nLa;ExlPeM9TH!f8mvts!B= z+1!`-nv+t9y|Rjo@L)q+fQb~v`FR26Gw6gxjEGYg>?>e~t2a{zHq0{&cD11&xZGu? z9{}1y{&?i@mG@!C8l8aPu@?+C;Z2I*TN2^ZsVL;G<23w6Z8w~6072c^rAIzE;SJa4 zTc{shg`qTt&QQ9G>Y3IzGwYo5_ZQdLbt4swQn8Y967UY&W5{}6EN)OW2fX7U0_<8C zAg{i%r(;F?u+)%()Kkd!j92bX*VsNUac@~V`4xJ}sV6}fYV)U41j*i0z^$#X2o8=- zsV=YtKhqB}>U#3&n62-q+i97&qMTpoz8b!~u4pUd^SlQQh2G=Mm)TcqILEg$uhb}0 z;?!<%zmYJ3i!bfRlSV?hYh?bo2eGaMYu6t7>8LfJK%j=fFEdTq8+C%PcKLeBqAN9D zSDV}r`GS~rZpMkUplyY;I^PBaFJ6NGER&^r-Wb6+k|$~C!}+*WJ9*rnu($Yv20a4f z@?qk6k|O4-F_n8A-U1X8Y|$5-(`k!WNwCg8UX1%GDVQ0}4JxgVjOuP#r>v*O;WEex zn2yCa_&NVPJVfXn)wUrXD3PF}-u5wxk~=4ujW+M`E+Id`L%)85>HM?k;ZYar+K(T^ zM!j3;d+Sfh20mIe_@N*wtBXx-HzOc^6!#-=u9dKZ?G;GqAwh~cpE`}?`;F$8Wr2jM z)svS>r&b-hq{@B9sVo)D zK?O64y5~FM50!UFD|=la^#&j2;_*6q{m&}c&y=sYjXh7`d{_OjR|>qbxZGL#A3F{S zY5ASos6 zRD)cRc7*Kw97j2*mkK5^prqR3ZU#~R2S)y~8<{Hp2w2mN1K(|V5Y%o@z>k%N;)J7O zNk1p2$e)`xQKypz@wrMmG@t!dp!jGt`rxZFO8w*u^v#9+aP?ifbQP2$rTx)O#yMtT56q5qsMM_A-Z(ZfuUn11LiBIf%S)TWvb+RBegDRXlI%6*zi&7X*H zL(ETX8LgL?ofyNkEg!yFrKF#q@n|SlxzE+qia&7!K3=g-1jI?z|P_Om|AZFMVxXUtuj(d z|9n7|X^Gz;MMQL9j!>%^*NFtscKQ`Yv`-WF-P@QfvA#r!-h(CbD6A63O}c>*wTDRS zU<52rNegbwy|#R$EaIxhUm_9L&Svm=Oe3KqSCGNYyhc!8`z${)> z;)nsKn6TbR@?x$vFecc>AWqeBzqg2odE=e{u3JQGc1RmjjZ=yv<(Cs)K0c3BP*o#j zlu6^|Fmd2Ll`A-->zUZV(Gq&U_+7N3-BVokz78@me*;ZPe+NYVen4=05?C;=-%KJ+ zE@r!3cE@~;gyQ>2Gk}m)1^9cQ7O}z+MUEeN$9+DjCSP4^hNpE;QLU|e=~N=qh< zG@S4ey(C`*@4M6te|a(xvx9^o`xhz+6;yYO^yF?lNKd9IckDvGzq^WjmEk~Do2Uap z_$|2fuq5}}m?2zrs$bDvgeijmRxDgMV+?g}S`)4wd4dvdZ$^o~pn@*Sp<<5|ihwgo zTf|oZD_D8?84$k@z)i;g0i5Pq35t7ouv$P3!ApG&W;Oc^u_XjSTDCYNcOqR0a(+({ ze!C9i#C}JB1L8X*+N>IUba)TIXAQ?aU+KUqmVZOH<5X#Jcs2-no!z+4Yf`|SyM$ici_soJin#?oLGS>4$SAw`Tm0J` zIisMeHQ@;Kg;`yOd)OBKFTV}q>)!o@c^JEDuSPG7in?G2WseN>LFu(XV{67@#CXrRFh75j09FNwG_NF2`a>llgo z14Lrh*pv>`PliKJIfZrcF#W*wyFqJa{W*GKqo zrD1VxCS}Rzr!w9TfuPCMHClFk4ykjy3YOOYn6VtDg&eK9O5Y2!Mod+Dlhz{C{@Bbjd9wVet~+X;hOt_Fa1hd#J`WUNsr}rA?_ON1s%+oq`E0fa>g|l z5%~Rxy1lSK?wN@qJyl!B9ogb#05gBcnaYKeCHwbYIQQTD?o z^JWsMHUM?_WGG!Kzmbwv5lQOq`bAamI!_-M`hNn919SYMg%|K2iH*M4cPaVPf7sKn znl|`Sd!LHQf2bRYc~IXocieRUeHSK%h)XP9eB(~1hM^1Thx>~tfkp;cg?AJefzZyi zcmROhdo{QSn)L<+rZTQ$fUy&~gdaqUd{eKEf?}MGdu>K6gtaO7g+HS{f%8-veO;w! zh%|cuirDvWd3temcb&F%b_$`Ii01l{hp|};sQ4c{nnSgBhnSej4blhA9%sj<(TQrB1g5h;kCV zcHkfag#t;mck=VvfC3Cuf2krQh{Lrkd|Q6qfnEm0iGSFQdob*;dGh+Mf7 zl_(wUk?qtJ9?;dfc=C?VfRU?Gcqgvud~QNOg11}IhxZv>e42gsdcrWygc+cfiAerY ze^JRecgxrQf9wPOfn_hoh%Ow5nQw`>9D4g)dS5Xoeo?n3g;orje}LxCdWOr)e6~#& zc=E*acf{)pgY&Lti4Fm&crs0WfLU+=h)7uSh?L-KZhtrYb-9h$hw9@vd$VT^g}v=V z7^MBFf^r$VhiPkXdGK7lg^GS{i2>+KcK4#meA-{pf4q7gf<)BpfPo`Jhqp;Yc*<)9 zg_Z}li6*Y%iIp8ag66WJcAcxAf@OmGb?U=1cHTO_cfTKcf=kkde(owIev^?XhuWt( zgHQb9eYi7Zg$YNthg{S*iF_T6f2j<_g|fjFg>d90W_p>>}(k@PgLlk7*g6=;dN?XYfYjFw9ipZQZldOhI} zc^9v=h%CuAg3iPYeW55biZdF!cKjw`iAKy7h~-A6hkx-HrlXvZnr@ACnB+C{t*!N+ KkWk&Wl-3oO&>CU@ literal 0 HcmV?d00001 diff --git a/wai/wai-rs/tests/vectors/int_video/video_nk_bitstream.wiv1 b/wai/wai-rs/tests/vectors/int_video/video_nk_bitstream.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..f948456f97775dc4a14e0f7807fd6c4824febae8 GIT binary patch literal 2148 zcmbu>i&qm@8UXMQBmqoO!6FzyU6hJtyQm1Fi!Bsgv8b#Tw73d_R9tYOJd&AAlF3Xa zlNX>+U#KVog80A(AYxTm!J-HPLRponNC^mt6nsE8Z|=;Q zZ?>-6vc$y11b6^2MhQ-EZo4-riP5TXk|L#j~$%_!+ilzbmbv{3*MZUxYU5*FK_eJJTXAwUO8 zOfEokH-O=#0Ny?T3)ceJ1OtTZ0@yAB$kPLy%K~_P9@S6|aJL%O{s5r;7XbQKRC@=S zcL8+u0&I8<;64a&>J7k^cK`{)0R1BXUyK6yi~%^0qkV{e0>E|>AO-myS^aN-RHPP} zKo)WU>ycQ5MUEpEkqV?9d4zNzy~rRkjEo~30$_sT|2KTl*?DU+gN12DCKhnNelgWR z409vJ{$lNuYPS263|V{S6s27OA2N>)bN-4n&GBd>*Ff>0p+1wkG|^ADg&WwZK5cUU z)e@zOGNlGCoMt?d56L_f(G}eAKs-js=H204& z?tZ;cKL4}jy5{sYo$Oq$k_ zKPl%fiXkduJ6ZOY8yoTGaD8}Os+`}+gK{dD6x0_hb;>AA6C5B<9@4V4n^QD&-T=9v ztVicn`CPO=$6Y(-lZ|aGHv_w_06hBrRc-GNgubKpG$Z(qn@{L}aBEd>UTJvAYAMC#*8dm?LVH1qpVBkr~8V*w=dc74nCtE=D z?lCTUcby{sZ!ThY(|-El=wimlc{aN--2x^{r|P@$Q><|JTe)#*nEGMsU(_#Br;&O~ zPqwRZrNkj`Ix}vyL;B9JMS5k2P-byGpJ|DhflpUlq1l*5CZ#l=-Fc7ECq}%azZXQS z>QdtL)Q%=mLYfJoNb=NgSqG{}e3kY~g9Ba6A19AQjB&?~UZKVxWl$TcC1m%VX8F&5 zf2{GUtYAWTv)R0*m}tIfgKGKNFp}J6rz!Eq*|pIZ$*8Sz@nl?vdfb0IjY)q3EyRnJ5gILfA2ACk$TDs&r0li3&p$ZyuBZGIXmt1?+tZeSIfF&LauoY7SNd7}J@vSO5^`6u| zZ9v&?iW;3K?b4Ge88i)|6T*B68=F>fM&naDrZ!1_6vgQJjA5-W_Hjl#(F+VMVqtM(T2(E3;6%{hGNF@&(Uc`qb6H@{U~ zUG_{gFLe;h_vIMbnNH&&Zvp>ga8IrJ{em3!~Gbg4D_RY4W1=e~{<MGIHZhmAOR_fQp}5a4 zUT51h${FahjOle>GN{x^zb#j+iTx&t{zgB_MeVL&m`mw+YrGZPWp@ipoi?C)y2c!= z0&fw^lcJR1@DyLR^)8;VDT=V%EF>b&G+~D5HcHYMLM)GfVowdEt l8cu`ii^vAsS#O~@c(#dkDGh5MjFgkP^TRY54&`Lse*l+d()0iT literal 0 HcmV?d00001 diff --git a/wai/wai-rs/tests/vectors/int_video/video_real_bitstream.wiv1 b/wai/wai-rs/tests/vectors/int_video/video_real_bitstream.wiv1 new file mode 100644 index 0000000000000000000000000000000000000000..7775f68bee8e54fe97c20070754fda2b23bd853b GIT binary patch literal 29428 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 Date: Thu, 1 Oct 2026 12:24:33 -0400 Subject: [PATCH 04/34] wai-rs: the neural sink delegates the integer capabilities to decode_int neural::decode_payload sends wai.neural.int_synth, wai.neural.int_mlicpp, wai.neural.int_hyper, wai.video.int_motion, wai.video.int_hyper, wai.audio.int_codec and wai.splat.int_codec to int_payload::decode_int, so integer decoding has one path and that path needs no ONNX runtime. A pinned decode hands it the verified bytes. An unpinned decode reads the set's members beside the registered path, as int_synth and int_mlicpp read theirs before: the int_synth.json beside the path, and, for the other one-file sets, the registered file itself. A member that a pinned set lacks is set_incomplete, so dispatch moves on as it does for any decoder. A payload refusal is final, so the fallback is not tried. The neural/int_synth.rs and neural/int_mlicpp.rs shims are removed. Decoded gains Int(IntDecoded): integer video, PCM and splat attributes come back in the integer form their digests are taken over, never converted. Integer images still decode to Decoded::Image. The C ABI's decode_ex refuses an Int output, which no wai_decoded_t kind carries. ModelRegistry::builtin advertises wai.video.int_motion, which reads no parameter set and so has no path to register. tests/neural_native_decode.rs decodes wai.neural.int_hyper's WIH1 from its three-file set, pinned through a listing under the strict gate and unpinned. A listing without h_s.whm gives set_incomplete. The JSON form is refused as bad_magic and the fallback is not tried. The integer video, audio and splat capabilities decode to Decoded::Int with their committed SHA-256, unpinned and under a form-less pin over their one file. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/ffi_neural.rs | 6 ++ wai/wai-rs/src/neural/int_mlicpp.rs | 21 ---- wai/wai-rs/src/neural/int_synth.rs | 23 ---- wai/wai-rs/src/neural/mod.rs | 83 ++++++++++++-- wai/wai-rs/tests/neural_native_decode.rs | 131 +++++++++++++++++++++++ 5 files changed, 209 insertions(+), 55 deletions(-) delete mode 100644 wai/wai-rs/src/neural/int_mlicpp.rs delete mode 100644 wai/wai-rs/src/neural/int_synth.rs diff --git a/wai/wai-rs/src/ffi_neural.rs b/wai/wai-rs/src/ffi_neural.rs index 6b363fb69..574ddf1e3 100644 --- a/wai/wai-rs/src/ffi_neural.rs +++ b/wai/wai-rs/src/ffi_neural.rs @@ -283,6 +283,12 @@ pub unsafe extern "C" fn wai_neural_decode_ex( o.fps_x_1000 = (v.fps * 1000.0).round() as u32; o.data = WaiBuffer::from_vec(v.frames_rgb.concat()); } + // Integer video, PCM and splat attributes have no `wai_decoded_t` kind; the + // Rust API returns them (`int_payload::decode_int`). Refused here rather + // than converted, so no caller reads them as another kind. + Decoded::Int(_) => { + return Err((WAI_ERR_CODEC, "this integer output (video, PCM or splat attributes) has no wai_decoded_t kind; decode it with the Rust API".to_string())); + } } unsafe { out.write(o) }; Ok(()) diff --git a/wai/wai-rs/src/neural/int_mlicpp.rs b/wai/wai-rs/src/neural/int_mlicpp.rs deleted file mode 100644 index 3ead0610a..000000000 --- a/wai/wai-rs/src/neural/int_mlicpp.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Neural-dispatch shim for `wai.neural.int_mlicpp`. -//! -//! The decoder lives at the crate root in [`crate::int_mlicpp`] behind the light -//! `neural_int` feature (no ONNX runtime). This shim reads the model bundle and maps -//! the result onto the neural module's types. The parameter set is one file, the -//! `WMB1` bundle (`model.wmb` within a set): a pinned decode parses the very bytes -//! its pin verified, so nothing is hashed or read twice. - -use super::{image::DecodedImage, params::ParamSource, DecodeError}; - -pub(crate) fn decode(payload: &[u8], params: &ParamSource) -> Result { - let bytes = params.model("model.wmb").map_err(|e| match e { - // An unreadable unpinned model stays an invalid payload, as before. - DecodeError::InvalidPayload(why) => DecodeError::InvalidPayload(format!("int_mlicpp model {why}")), - other => other, - })?; - let model = crate::int_mlicpp::IntMlicModel::from_bundle(&bytes) - .map_err(|e| DecodeError::InvalidPayload(e.to_string()))?; - let img = crate::int_mlicpp::decode(&model, payload).map_err(|e| DecodeError::InvalidPayload(e.to_string()))?; - Ok(DecodedImage { rgb: img.rgb, width: img.width as u32, height: img.height as u32 }) -} diff --git a/wai/wai-rs/src/neural/int_synth.rs b/wai/wai-rs/src/neural/int_synth.rs deleted file mode 100644 index 01dfaa623..000000000 --- a/wai/wai-rs/src/neural/int_synth.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Neural-dispatch shim for the integer synthesis decoder. -//! -//! The actual integer-exact decode lives at the crate root in -//! [`crate::int_synth`] behind the light `neural_int` feature — it needs no -//! ONNX runtime, so it stays buildable (and cross-arch-checkable) without -//! `ort`. This shim adapts its image and error types onto the neural module's. -//! -//! The parameter set is one file, the model specification `int_synth.json` -//! (SPEC §5). A pinned decode reads it from the bytes the pin verified; an unpinned -//! one reads `int_synth.json` beside the registered path, as before. - -use super::{image::DecodedImage, params::ParamSource, DecodeError}; - -pub(crate) fn decode(payload: &[u8], params: &ParamSource) -> Result { - let r = match params { - ParamSource::Unpinned(path) => crate::int_synth::decode(payload, path), - ParamSource::Pinned(_) => crate::int_synth::decode_from_spec(payload, ¶ms.model("int_synth.json")?), - }; - match r { - Ok(img) => Ok(DecodedImage { rgb: img.rgb, width: img.width, height: img.height }), - Err(crate::int_synth::IntSynthError::InvalidPayload(e)) => Err(DecodeError::InvalidPayload(e)), - } -} diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index c797c21a3..7369563f1 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -35,8 +35,6 @@ pub mod jpegai_header; pub mod jpegai_hyper; pub mod jpegai_mans; pub mod jpegai_bitstream; -mod int_synth; -mod int_mlicpp; mod video; mod video_dcvc; pub(crate) use crate::rans; @@ -65,11 +63,15 @@ pub use video::DecodedVideo; /// over the set an unpinned decode reads too (SPEC §4, *Module authorisation*). A /// registry without one does not run check 5. /// +/// A capability whose decode reads no parameter set (`wai.video.int_motion`) is +/// advertised with [`ModelRegistry::builtin`] instead, and holds no path. +/// /// The browser equivalent is the `NEURAL_MODELS` map in `wai-web/demo/index.html`. #[derive(Default, Debug, Clone)] pub struct ModelRegistry { pub paths: HashMap, pub held: HashMap>, + builtin: Vec, key_log: Option>, } @@ -103,9 +105,21 @@ impl ModelRegistry { self.paths.get(capability) } - /// Whether this sink advertises `capability` (it is registered). + /// Advertise `capability`, whose decode reads no parameter set and so has no path + /// to register: `wai.video.int_motion`, decoded by `int_payload::decode_int`. A + /// capability that decodes against a parameter set is advertised only through + /// [`Self::register`]; given here, it is ignored. + pub fn builtin(mut self, capability: impl Into) -> Self { + let c = capability.into(); + if crate::codecs::is_known_capability(&c) && !crate::codecs::decodes_against_parameter_set(&c) { + self.builtin.push(c); + } + self + } + + /// Whether this sink advertises `capability` (it is registered, or built in). pub fn advertises(&self, capability: &str) -> bool { - self.paths.contains_key(capability) + self.paths.contains_key(capability) || self.builtin.iter().any(|c| c == capability) } /// The artifacts a pin over `capability` is checked against (see the type's doc). @@ -122,7 +136,10 @@ impl ModelRegistry { /// form `wai.prior.set`. The digests are read when the decode starts; an unpinned /// decode then reads the path, as it always does. `capability` is registered. fn unpinned(&self, capability: &str) -> Result { - let path = &self.paths[capability]; + let Some(path) = self.paths.get(capability) else { + // A built-in capability reads no parameter set: there is nothing to check. + return Ok(ParamSource::Unpinned(PathBuf::new())); + }; if let Some(k) = self.key_log.as_deref().filter(|_| crate::container::pin_applies_to(capability)) { let one_file = crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile; let mut digests = Vec::new(); @@ -179,7 +196,7 @@ impl ModelRegistry { /// otherwise the registered artifact, unverified — the honest meaning of an /// unpinned decode — once a key log the registry holds has not refused it. pub fn resolve_verified(&self, capability: &str, prior: Option<&Prior>) -> Result { - if !self.paths.contains_key(capability) { + if !self.advertises(capability) { return Err(DecodeError::ModelNotRegistered(capability.to_string())); } match prior { @@ -215,12 +232,16 @@ pub fn model_sha256(path: &Path) -> Result { /// Output of a neural-capability decode. The variant is determined by /// the capability's media class — audio caps produce `Audio`, image caps -/// produce `Image`, video caps produce `Video`. +/// produce `Image`, video caps produce `Video`. The integer capabilities decode +/// through `int_payload::decode_int`: an image gives `Image`, and integer video, +/// PCM and splat attributes give `Int`, in the integer form their digests are +/// taken over (RGB8 frames, `i16` PCM, the attribute tensor), never converted. #[derive(Debug, Clone)] pub enum Decoded { Audio(DecodedAudio), Image(DecodedImage), Video(DecodedVideo), + Int(crate::int_payload::IntDecoded), } #[derive(Debug)] @@ -283,10 +304,13 @@ pub fn decode_payload(capability: &str, payload: &[u8], params: &ParamSource) -> "wai.neural.snac" => snac::decode(payload, params).map(Decoded::Audio), "wai.neural.bmshj2018" => image::decode(payload, params).map(Decoded::Image), "wai.neural.mbt2018_mean" => hyperprior::decode(payload, params).map(Decoded::Image), - "wai.neural.int_synth" => int_synth::decode(payload, params).map(Decoded::Image), - // The MLIC++ entropy topology, integer end to end (crate::int_mlicpp), over - // its one-file WMB1 bundle. - "wai.neural.int_mlicpp" => int_mlicpp::decode(payload, params).map(Decoded::Image), + // The integer capabilities: the one integer decode path (crate::int_payload), + // which needs no ONNX runtime. int_synth reads its one-file spec, int_mlicpp its + // WMB1 bundle, int_hyper (and the int_hyper keyframe) three files, the audio and + // splat codecs their one model file; int_motion reads none. + "wai.neural.int_synth" | "wai.neural.int_mlicpp" | "wai.neural.int_hyper" + | "wai.video.int_motion" | "wai.video.int_hyper" | "wai.audio.int_codec" + | "wai.splat.int_codec" => int_decode(capability, payload, params), "wai.neural.elic" => context::decode(payload, params).map(Decoded::Image), "wai.neural.mlicpp" => mlicpp::decode(payload, params).map(Decoded::Image), // MLICv2 is the SAME multi-reference channel-autoregressive decode; its gains @@ -338,6 +362,43 @@ pub fn decode_payload(capability: &str, payload: &[u8], params: &ParamSource) -> } } +/// An integer capability through `int_payload::decode_int`, against the parameters +/// `params` names. A pinned decode hands it the verified bytes; an unpinned one reads +/// the set's members beside the registered path (for `wai.neural.int_synth`, the +/// `int_synth.json` beside it; for the other one-file sets, the registered file +/// itself), as each decode read them before it was delegated. A member a pinned set +/// lacks is `set_incomplete`, so dispatch moves on as it does for any decoder. +fn int_decode(cap: &str, payload: &[u8], params: &ParamSource) -> Result { + use crate::int_payload::{decode_int, set_members, IntDecoded, IntParams, Refusal}; + let one_file = crate::container::pin_shape(cap) == crate::codecs::ParameterSetShape::OneFile; + let mut owned: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + if let ParamSource::Unpinned(_) = params { + for &m in set_members(cap) { + let read = if one_file && cap != crate::codecs::CAP_NEURAL_INT_SYNTH { params.model(m) } else { params.sibling(m) }; + let bytes = read.map_err(|e| match e { + DecodeError::InvalidPayload(why) => DecodeError::InvalidPayload(format!("{cap} parameter set: {why}")), + other => other, + })?; + owned.insert(m.to_string(), bytes.into_owned()); + } + } + let view: std::collections::BTreeMap = owned.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let p = match params { + ParamSource::Pinned(v) => IntParams::Verified(v), + ParamSource::Unpinned(_) if set_members(cap).is_empty() => IntParams::None, + ParamSource::Unpinned(_) => IntParams::Unverified(crate::pin::HeldBytes::Set { listing: b"", files: &view }), + }; + match decode_int(cap, payload, &p) { + Ok(IntDecoded::Image { rgb, width, height }) => Ok(Decoded::Image(DecodedImage { rgb, width, height })), + Ok(other) => Ok(Decoded::Int(other)), + Err(e) if e.refusal() == Refusal::SetIncomplete => { + Err(DecodeError::PriorMismatch(PinRefusal::SetIncomplete { path: e.detail().to_string() })) + } + Err(e) if e.refusal() == Refusal::UnsupportedCapability => Err(DecodeError::UnknownCapability(cap.to_string())), + Err(e) => Err(DecodeError::InvalidPayload(format!("{cap}: {e}"))), + } +} + /// SPEC §4 for a parsed `WAI1` envelope at a sink whose parameter sets `registry` /// holds, through the capabilities `gate` admits. A refused pin makes its capability /// unsupported for this envelope and dispatch moves on to the fallback; a pinned diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index 425c33d8e..af21b8ffc 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -1082,3 +1082,134 @@ fn int_synth_trained_model_matches_integer_forward() { println!(" trained int_synth: Rust vs numpy integer-forward max|Δ|={maxd} (must be 0 — integer-exact)"); assert_eq!(maxd, 0, "Rust integer decode must be bit-identical to the reference"); } + +/// A temporary directory holding the tiny conditional codec's set under its member +/// names (`g_s.whm`, `h_s.whm`, `entropy.json`) and a listing, `set.sha256`, of the +/// members `listed`. Returns the directory and the listing's SHA-256. +fn int_hyper_set_dir(tag: &str, listed: &[&str]) -> (PathBuf, String) { + let v = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_hyper"); + let dir = std::env::temp_dir().join(format!("wai-int-hyper-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut files = std::collections::BTreeMap::new(); + for (member, src) in [("entropy.json", "tiny_entropy.json"), ("g_s.whm", "tiny_g_s.bin"), ("h_s.whm", "tiny_h_s.bin")] { + let b = std::fs::read(v.join(src)).unwrap(); + std::fs::write(dir.join(member), &b).unwrap(); + if listed.contains(&member) { + files.insert(member.to_string(), b); + } + } + let view: std::collections::BTreeMap = files.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let listing = wai::pin::listing_from(&view).unwrap(); + std::fs::write(dir.join("set.sha256"), &listing).unwrap(); + (dir, wai::pin::sha256_hex(&listing)) +} + +/// `wai.neural.int_hyper` through the neural dispatch, which delegates it to +/// `int_payload::decode_int`: its `WIH1` payload decodes from the three-file set, pinned +/// through the set's listing (under the strict `replicate` gate) and unpinned, to the +/// committed image. A pinned set whose listing leaves out `h_s.whm` is refused with +/// `set_incomplete` naming it. The tool's JSON output carried as the payload is refused +/// (`bad_magic`), and the refusal is final: the fallback the envelope names is not tried. +#[test] +fn int_hyper_decodes_through_the_neural_dispatch() { + use wai::codecs::{DeclaredTier, CAP_NEURAL_INT_HYPER as HYP, CAP_NEURAL_INT_SYNTH, CAP_PRIOR_SET}; + let v = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_hyper"); + let payload = std::fs::read(v.join("tiny_bitstream.wih1")).unwrap(); + let rgb_sha = "dce7282d46f88f03d5dc0fd42f325756d310fe1fc982b6e1792a1fbfbeb482cf"; + let env = |intent: &str, prior: Option, fallback: Option<&str>, payload: Vec| { + let mut req = ModelRequirement::new(HYP); + if let Some(f) = fallback { + req = req.with_fallback(f); + } + let mut cond = Conditioning::new("int_hyper"); + if let Some(p) = prior { + cond = cond.with_prior(p); + } + Wai::new(Manifest::new("image", intent, req, cond), payload) + }; + let sha = |r: Result| match r { + Ok(Decoded::Image(img)) => wai::pin::sha256_hex(&img.rgb), + other => refusal(other), + }; + let (dir, digest) = int_hyper_set_dir("all", &["entropy.json", "g_s.whm", "h_s.whm"]); + let reg = ModelRegistry::new().register(HYP, dir.join("g_s.whm")); + let pin = Prior::new("tiny", digest, "decode-equivalence").with_form(CAP_PRIOR_SET).with_tier(DeclaredTier::DecodeEquivalence); + assert_eq!(sha(decode_envelope_strict(&env("replicate", Some(pin), None, payload.clone()), ®)), rgb_sha); + assert_eq!(sha(decode_envelope(&env("improve", None, None, payload.clone()), ®)), rgb_sha); + + let (part, digest) = int_hyper_set_dir("part", &["entropy.json", "g_s.whm"]); + let pin = Prior::new("tiny", digest, "decode-equivalence").with_form(CAP_PRIOR_SET); + let r = decode_envelope(&env("improve", Some(pin), None, payload.clone()), &ModelRegistry::new().register(HYP, part.join("g_s.whm"))); + assert!(matches!(r, Err(DecodeError::PriorMismatch(wai::container::PinRefusal::SetIncomplete { ref path })) if path == "h_s.whm"), "{}", refusal(r)); + + let json = std::fs::read(v.join("tiny_bitstream.json")).unwrap(); + let spec = Path::new(env!("CARGO_MANIFEST_DIR")).join("../neural-conformance/vectors/gradient/model"); + let both = ModelRegistry::new().register(HYP, dir.join("g_s.whm")).register(CAP_NEURAL_INT_SYNTH, spec); + for fallback in [None, Some(CAP_NEURAL_INT_SYNTH)] { + match decode_envelope(&env("improve", None, fallback, json.clone()), &both) { + Err(DecodeError::InvalidPayload(why)) => assert!(why.contains("bad_magic") && why.contains(HYP), "{why}"), + other => panic!("a JSON form must be refused as bad_magic, got {}", refusal(other)), + } + } + std::fs::remove_dir_all(&dir).ok(); + std::fs::remove_dir_all(&part).ok(); +} + +/// The integer video, audio and splat capabilities through the neural dispatch: +/// `wai.video.int_motion`, which reads no parameter set, advertised with +/// `ModelRegistry::builtin`; `wai.audio.int_codec` and `wai.splat.int_codec`, each +/// registered as its one file, unpinned and under a form-less pin over that file. Each +/// decodes to `Decoded::Int` in the integer form its digest is taken over, with the +/// committed SHA-256. `builtin` does not advertise a capability that reads a set. +#[cfg(all(feature = "video_int", feature = "audio_int", feature = "splat_int"))] +#[test] +fn integer_media_decode_through_the_neural_dispatch() { + use wai::codecs::{CAP_AUDIO_INT_CODEC as AUD, CAP_NEURAL_INT_HYPER, CAP_SPLAT_INT_CODEC as SPL, CAP_VIDEO_INT_MOTION as MOT}; + use wai::int_payload::IntDecoded; + let v = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors"); + let rd = |rel: &str| std::fs::read(v.join(rel)).unwrap(); + let side = |rel: &str, key: &str, field: &str| -> String { + let j: serde_json::Value = serde_json::from_slice(&rd(rel)).unwrap(); + j[key][field].as_str().unwrap().to_string() + }; + let env = |cap: &str, prior: Option, payload: Vec| { + let mut cond = Conditioning::new("bytes"); + if let Some(p) = prior { + cond = cond.with_prior(p); + } + Wai::new(Manifest::new("binary", "improve", ModelRequirement::new(cap), cond), payload) + }; + let reg = ModelRegistry::new() + .builtin(MOT) + .register(AUD, v.join("int_audio/audio_model.bin")) + .register(SPL, v.join("int_splat/splat_gs.bin")); + assert!(reg.advertises(MOT)); + assert!(!ModelRegistry::new().builtin(CAP_NEURAL_INT_HYPER).advertises(CAP_NEURAL_INT_HYPER)); + + match decode_envelope(&env(MOT, None, rd("int_video/int_video_bitstream.wiv1")), ®) { + Ok(Decoded::Int(IntDecoded::Video { frames, width: 48, height: 48 })) => { + let per: Vec = frames.iter().flat_map(|f| wai::pin::sha256(f)).collect(); + assert_eq!(wai::pin::sha256_hex(&per), side("int_video/sha256.json", "int_video", "clip_sha256")); + } + other => panic!("int_motion: {}", refusal(other)), + } + let model = rd("int_audio/audio_model.bin"); + for prior in [None, Some(Prior::new("audio", wai::pin::sha256_hex(&model), "decode-equivalence"))] { + match decode_envelope(&env(AUD, prior, rd("int_audio/audio_bitstream.wia1")), ®) { + Ok(Decoded::Int(IntDecoded::Pcm { samples, sample_rate: 16_000, channels: 1 })) => { + let le: Vec = samples.iter().flat_map(|s| s.to_le_bytes()).collect(); + assert_eq!(wai::pin::sha256_hex(&le), side("int_audio/sha256.json", "audio", "sha256")); + } + other => panic!("int_codec: {}", refusal(other)), + } + } + let gs = rd("int_splat/splat_gs.bin"); + for prior in [None, Some(Prior::new("splat", wai::pin::sha256_hex(&gs), "decode-equivalence"))] { + match decode_envelope(&env(SPL, prior, rd("int_splat/splat.wis1")), ®) { + Ok(Decoded::Int(IntDecoded::Attr { bytes, n_attr: 14, s: 32, .. })) => { + assert_eq!(wai::pin::sha256_hex(&bytes), side("int_splat/sha256.json", "splat", "sha256")); + } + other => panic!("splat: {}", refusal(other)), + } + } +} -- 2.54.0 (Apple Git-157) From 594879bc66eb98bdb15f1edc87d73bd1958446d3 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 12:26:04 -0400 Subject: [PATCH 05/34] wai-rs: wai wrap refuses, and wai inspect reports, a payload that is not its integer format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wai wrap` writes nothing when the payload for an integer capability is not the format SPEC §5 pins to it, as `container::check_int_payload_framing` reads it (magic, then version). This covers the WAI1 capability, a WAI1 fallback whose decode reads the same payload, and every WAI2 component. A tool's JSON output is converted first, never wrapped. `wai inspect` prints the framing check for an envelope that names an integer capability, and its code when the check fails. tests/cli_wrap_intent.rs wraps each committed JSON form and is refused with bad_magic. It wraps a WIV1 under wai.audio.int_codec and is refused with format_capability_mismatch. It wraps every binary payload. It inspects an envelope that carries a JSON form. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/bin/wai.rs | 20 ++++++++++-- wai/wai-rs/tests/cli_wrap_intent.rs | 49 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index fbaf3b9b4..5e491f6c7 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -364,6 +364,12 @@ fn inspect(path: &str) -> Result<(), String> { if let Err(e) = w.check_candidates() { println!(" candidates: \u{2717} {e}"); } + if [Some(cap), fb].into_iter().flatten().any(|c| wai::codecs::pinned_int_payload(c).is_some()) { + match w.check_int_payload_framing() { + Ok(()) => println!(" integer payload: \u{2713} the format SPEC \u{a7}5 pins (magic and version)"), + Err(e) => println!(" integer payload: \u{2717} {e}; a sink refuses it at decode"), + } + } } else { // WAI2 inspection let m = wai::container::WaiMulti::from_bytes(&bytes).map_err(|e| e.to_string())?; @@ -422,6 +428,9 @@ fn inspect(path: &str) -> Result<(), String> { if let Err(e) = m.check_candidates() { println!(" candidates: \u{2717} {e}"); } + if let Err((i, e)) = m.check_int_payload_framing() { + println!(" integer payload: \u{2717} [{i}] {e}; a sink refuses it at decode"); + } } println!(); @@ -486,7 +495,9 @@ fn license_str(cap: &str) -> &'static str { /// capability is refused as a `WAI1` capability or fallback, and a `WAI2` table /// that breaks the binding rules is refused. A candidate capability (SPEC §5, /// *Candidate capabilities*) is refused in every place — capability, fallback or -/// companion — and nothing is written. +/// 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. 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) { @@ -526,9 +537,11 @@ fn wrap(cap: &str, input: &str, out: &str, fallback: Option<&str>, }; let manifest = Manifest::new(media_for(cap), intent, requirement, Conditioning::new("bytes")); let envelope = Wai::new(manifest, payload); - // The reference tool does not write an envelope that names a candidate, or - // one that breaks the role rules. + // The reference tool does not write an envelope that names a candidate, one that + // breaks the role rules, or one whose payload is not the format SPEC §5 pins its + // integer capability to (a tool's JSON output, say). envelope.check_candidates().map_err(|e| e.to_string())?; + envelope.check_int_payload_framing().map_err(|e| format!("not writing {out}: {e}"))?; envelope.check_component_roles().map_err(|e| match e { wai::container::RoleError::CompanionOnlyAsCapability { .. } => format!( "{e} (SPEC \u{a7}5): `wai wrap --companion {cap} `"), @@ -573,6 +586,7 @@ fn wrap_with_companions(cap: &str, payload: Vec, out: &str, fallback: Option let envelope = wai::container::WaiMulti::new( media_for(cap), intent, serde_json::Value::Null, components); envelope.check_candidates().map_err(|e| e.to_string())?; + envelope.check_int_payload_framing().map_err(|(i, e)| format!("not writing {out}: component {i}: {e}"))?; envelope.check_component_roles() .map_err(|e| format!("{e} (SPEC \u{a7}5, \u{a7}7.1)"))?; let n = envelope.write(out).map_err(|e| e.to_string())?; diff --git a/wai/wai-rs/tests/cli_wrap_intent.rs b/wai/wai-rs/tests/cli_wrap_intent.rs index 908b16551..9897026e0 100644 --- a/wai/wai-rs/tests/cli_wrap_intent.rs +++ b/wai/wai-rs/tests/cli_wrap_intent.rs @@ -156,3 +156,52 @@ fn wrap_holds_a_companion_envelope_to_the_same_intent_rule() { assert!(stderr.contains("--fallback names a WAI1 alternative"), "{stderr}"); assert!(!out.exists(), "a refused wrap must not write an envelope"); } + +/// SPEC §5 pins the payload of each WAI-native integer codec to a binary format +/// (`extensions/integer-payloads.md`). `wai wrap` checks the framing (`bad_magic`, +/// `format_capability_mismatch`, `version`) and writes nothing when a payload is not +/// its capability's format: a tool's JSON output is converted first, never wrapped. +/// The binary payloads wrap; `wai inspect` reports the framing of an envelope another +/// writer made. +#[test] +fn wrap_refuses_a_payload_that_is_not_the_pinned_integer_format() { + let v = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors"); + let wrap_file = |cap: &str, input: &Path, out: &Path, extra: &[&str]| -> Output { + Command::new(env!("CARGO_BIN_EXE_wai")) + .arg("wrap").arg(cap).arg(input).arg("-o").arg(out).args(extra) + .output() + .expect("run wai") + }; + for (cap, json, bin, code) in [ + ("wai.video.int_motion", "int_video/int_video_bitstream.json", "int_video/int_video_bitstream.wiv1", "bad_magic"), + ("wai.neural.int_hyper", "int_hyper/tiny_bitstream.json", "int_hyper/tiny_bitstream.wih1", "bad_magic"), + ("wai.audio.int_codec", "int_video/video_nk_bitstream.wiv1", "int_audio/audio_bitstream.wia1", "format_capability_mismatch"), + ("wai.splat.int_codec", "int_splat/splat.json", "int_splat/splat.wis1", "bad_magic"), + ] { + let out = scratch("int-payload.wai"); + let run = wrap_file(cap, &v.join(json), &out, &["--intent", "improve"]); + let stderr = String::from_utf8_lossy(&run.stderr); + assert!(!run.status.success(), "{cap} {json}: wrap must refuse; stderr: {stderr}"); + assert!(stderr.contains(code) && stderr.contains("integer-payloads"), "{cap}: {stderr}"); + assert!(!out.exists(), "{cap}: a refused wrap must not write an envelope"); + let run = wrap_file(cap, &v.join(bin), &out, &["--intent", "improve"]); + assert!(run.status.success(), "{cap} {bin}: {}", String::from_utf8_lossy(&run.stderr)); + assert_eq!(Wai::read(&out).unwrap().payload, std::fs::read(v.join(bin)).unwrap()); + } + // The same rule for a WAI2 base. + let out = scratch("int-payload-hdr.wai"); + let run = wrap_file("wai.video.int_motion", &v.join("int_video/int_video_bitstream.json"), &out, + &["--companion", "wai.meta.hdr10plus", payload().to_str().unwrap()]); + assert!(!run.status.success() && String::from_utf8_lossy(&run.stderr).contains("bad_magic")); + assert!(!out.exists()); + // inspect reports an envelope that carries the JSON form. + let out = scratch("int-payload-json.wai"); + wai::container::Wai::new( + wai::container::Manifest::new("video", "improve", wai::container::ModelRequirement::new("wai.video.int_motion"), + wai::container::Conditioning::new("bytes")), + std::fs::read(v.join("int_video/int_video_bitstream.json")).unwrap(), + ).write(&out).unwrap(); + let run = Command::new(env!("CARGO_BIN_EXE_wai")).arg("inspect").arg(&out).output().expect("run wai"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!(stdout.contains("integer payload:") && stdout.contains("bad_magic"), "{stdout}"); +} -- 2.54.0 (Apple Git-157) From 51a47166fec81c88c40098c7e9cc00b67d035cd8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 12:35:37 -0400 Subject: [PATCH 06/34] wai-rs: a wai.video.int_motion receipt identifies its WIV1 payload VideoReceipt::seal_decode_wiv1 decodes a WIV1 payload (keyframe 0) and seals a receipt whose codestream hash is BLAKE3 over the payload, the bytes an envelope carries. verify_reconstruction_wiv1 re-derives it from the payload alone. video_receipt::decode_wiv1 parses with int_payload::wire and decodes through int_video::decode_typed. seal_decode and verify_reconstruction, which read the tool's JSON form, are deprecated and unchanged. Every receipt sealed over the JSON form, and every pinned digest of one, still verifies byte for byte. wai_video_meter meters and seals the WIV1 payload. Given a JSON bitstream, it converts it first, so its receipt identifies the converted payload. tests/video_receipt.rs: a WIV1 receipt carries the same frames, clip commitment, geometry and work as the JSON form's receipt. Neither receipt vouches for the other's codestream. A one-byte change is refused. The signed bytes of metered, unmetered and chained WIV1 receipts are pinned as introduced. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/src/bin/wai_video_meter.rs | 44 ++++++----- wai/wai-rs/src/energy_figure/tests.rs | 1 + wai/wai-rs/src/video_receipt.rs | 101 ++++++++++++++++++++------ wai/wai-rs/tests/video_receipt.rs | 59 +++++++++++++++ 4 files changed, 163 insertions(+), 42 deletions(-) diff --git a/wai/wai-rs/src/bin/wai_video_meter.rs b/wai/wai-rs/src/bin/wai_video_meter.rs index 41a99d950..46ae5ff34 100644 --- a/wai/wai-rs/src/bin/wai_video_meter.rs +++ b/wai/wai-rs/src/bin/wai_video_meter.rs @@ -8,9 +8,14 @@ //! decode burst against an idle baseline, and reports the **marginal** CPU //! energy attributable to the decode. //! -//! wai_video_meter [receipt_out.json [report_out.json]] +//! wai_video_meter [receipt_out.json [report_out.json]] //! [--no-report] [--previous-report ] //! +//! The codestream is the `WIV1` payload `wai.video.int_motion` carries +//! (`extensions/integer-payloads.md` §3), and the receipt identifies it. Given a +//! tool's JSON bitstream instead, the meter converts it to `WIV1` first +//! (`int_payload::wiv1_from_json`) and meters and seals the converted payload. +//! //! Video was the last cargo class with no meter: haptics, 4D splats, scores and //! film are metered by `wai_meter`, worlds/feeds/avatars by `wai_world_meter`, //! quantum circuits by `wai_quantum_meter`. It also has the strongest claim of @@ -42,21 +47,28 @@ use ed25519_dalek::SigningKey; use wai::energy_burst::{measure_with_macmon, power_reader}; use wai::energy_figure::meter_report; use wai::energy_class::CounterFiltering; -use wai::int_video::decode; -use wai::video_receipt::{work_pixel_ops, Provenance, VideoReceipt}; +use wai::video_receipt::{decode_wiv1, work_pixel_ops, Provenance, VideoReceipt}; fn main() { let report_args = meter_report::parse_args_or_exit(&std::env::args().collect::>()); let args = &report_args.positional; let Some(path) = args.get(1) else { - eprintln!("usage: wai_video_meter [receipt_out.json [report_out.json]] [--no-report] [--previous-report ]"); + eprintln!("usage: wai_video_meter [receipt_out.json [report_out.json]] [--no-report] [--previous-report ]"); std::process::exit(2); }; - let bytes = std::fs::read(path).expect("read bitstream"); - let bs: serde_json::Value = serde_json::from_slice(&bytes).expect("parse bitstream JSON"); + let bytes = std::fs::read(path).expect("read the codestream"); + // The payload is WIV1; a JSON bitstream is the tools' form, converted first. + let payload = if bytes.starts_with(b"WIV1") { + bytes + } else { + let bs: serde_json::Value = serde_json::from_slice(&bytes).expect("neither a WIV1 payload nor a JSON bitstream"); + let p = wai::int_payload::wiv1_from_json(&bs, None).expect("the JSON bitstream converts to WIV1"); + eprintln!("{path} is a JSON bitstream: metering and sealing the WIV1 payload converted from it"); + p + }; // A decode that does not even run is not meterable. - let clip = decode(&bs).expect("codestream decodes"); + let clip = decode_wiv1(&payload).expect("codestream decodes"); let (n, h, w) = (clip.frames.len() as u64, clip.h as u64, clip.w as u64); let work = work_pixel_ops(n, h, w); @@ -78,8 +90,8 @@ fn main() { // as one. let mut values = None; let measured: Option<(u64, Provenance)> = - match measure_with_macmon("decode", &bs, |b| { - let _ = decode(b); + match measure_with_macmon("decode", &payload, |p| { + let _ = decode_wiv1(p); }) { None => { eprintln!( @@ -144,19 +156,11 @@ fn main() { // label it also did not earn. None => (0, None), }; - let receipt = VideoReceipt::seal_decode( - &signer, - signer_id, - &bytes, - &bs, - uj, - class, - None, - ) - .expect("seal receipt"); + let receipt = VideoReceipt::seal_decode_wiv1(&signer, signer_id, &payload, uj, class, None) + .expect("seal receipt"); assert!(receipt.verify(), "emitted receipt must verify"); assert!( - receipt.verify_reconstruction(&bytes, &bs), + receipt.verify_reconstruction_wiv1(&payload), "emitted receipt must re-decode to the same frames" ); let tag = if measured.is_some() { "MEASURED" } else { "UNMETERED" }; diff --git a/wai/wai-rs/src/energy_figure/tests.rs b/wai/wai-rs/src/energy_figure/tests.rs index 673805fec..901c53838 100644 --- a/wai/wai-rs/src/energy_figure/tests.rs +++ b/wai/wai-rs/src/energy_figure/tests.rs @@ -143,6 +143,7 @@ fn a_provenance_receipt_is_viewed_step_by_step() { #[cfg(feature = "video_receipt")] #[test] +#[allow(deprecated)] // the receipt sealed over the JSON form, as its legacy pins are fn the_video_receipt_is_viewed_by_its_receipt_hash() { let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_video/int_video_bitstream.json"); let bytes = std::fs::read(p).unwrap(); diff --git a/wai/wai-rs/src/video_receipt.rs b/wai/wai-rs/src/video_receipt.rs index 0560e2d33..c88cb19a9 100644 --- a/wai/wai-rs/src/video_receipt.rs +++ b/wai/wai-rs/src/video_receipt.rs @@ -1,18 +1,25 @@ //! Video receipt — a JWP profile for `wai.video.int_motion`, the integer -//! inter-frame codec. The **codestream hash** is the identity; the group's -//! objects are the per-frame decoded-pixel hashes; one Ed25519 signature covers -//! codestream hash + frame hashes + exact reconstruction work + measured joules. -//! Chains via `parent_receipt_hash` exactly as the other classes — reuse, not -//! reinvention. +//! inter-frame codec. The **codestream hash** is the identity: BLAKE3 over the +//! `WIV1` payload an envelope carries (`extensions/integer-payloads.md` §3); the +//! group's objects are the per-frame decoded-pixel hashes; one Ed25519 signature +//! covers codestream hash + frame hashes + exact reconstruction work + measured +//! joules. Chains via `parent_receipt_hash` exactly as the other classes — reuse, +//! not reinvention. [`VideoReceipt::seal_decode_wiv1`] seals one and +//! [`VideoReceipt::verify_reconstruction_wiv1`] re-derives it. A receipt sealed +//! before the payload was pinned identifies the tool's JSON form instead; the +//! deprecated [`VideoReceipt::seal_decode`] and +//! [`VideoReceipt::verify_reconstruction`] still read it, so such a receipt keeps +//! verifying byte for byte. //! //! **What makes this profile different from its siblings.** In every other //! cargo class the reconstruction is deterministic *by construction* and taken -//! on trust. Here it is deterministic *and proven on real silicon*: the -//! cross-architecture byte-equality matrix decodes these same vectors on -//! linux-x86_64, linux-aarch64, macos-arm64 and windows-x86_64 and asserts -//! identical bytes. So a verifier holding only the codestream can independently -//! recompute both `clip_hash` and `work_pixel_ops` — see -//! [`VideoReceipt::verify_reconstruction`] — and every field except the +//! on trust. Here it is deterministic *and checked on real silicon*: the +//! cross-architecture byte-equality matrix (`wai-byte-equality.yml`) parses and +//! decodes the committed `WIV1` payloads, and the JSON forms they were converted +//! from, on every platform in its matrix and asserts identical bytes. So a +//! verifier holding only the codestream can independently recompute both +//! `clip_hash` and `work_pixel_ops` — see +//! [`VideoReceipt::verify_reconstruction_wiv1`] — and every field except the //! measurement stops being a claim and becomes a checkable fact. //! //! That leaves the measurement attested-not-verifiable — `joules_micro` and the @@ -99,9 +106,22 @@ pub fn clip_hash(h: u32, w: u32, frames: &[FrameHash]) -> [u8; 32] { *hs.finalize().as_bytes() } +/// Decode a `WIV1` payload of `wai.video.int_motion` (keyframe 0): parsed with +/// `int_payload::wire` and decoded through [`crate::int_video::decode_typed`], each +/// refusal reported by its code (`extensions/integer-payloads.md` §8). +pub fn decode_wiv1(payload: &[u8]) -> Result { + let v = crate::int_payload::wire::parse_wiv1(payload, Some(0)).map_err(|e| e.refusal.code())?; + let s = crate::int_video::VideoStream { + n_frames: v.n_frames as usize, h: v.h as usize, w: v.w as usize, block: v.block as usize, + q: v.q as i64, offset: v.offset, mv_precision: v.mv_precision, cdf: &v.cdf, mvs: &v.mvs, rans: v.rans, + }; + crate::int_video::decode_typed(&s) +} + #[derive(Clone, Debug, PartialEq)] pub struct VideoReceipt { - /// Identity: BLAKE3 over the canonical codestream bytes as they were read. + /// Identity: BLAKE3 over the codestream bytes exactly as they were read — the + /// `WIV1` payload, or, in a receipt sealed before it was pinned, the JSON form. pub codestream_hash: [u8; 32], /// The reconstruction result: the portable whole-clip commitment. pub clip_hash: [u8; 32], @@ -200,12 +220,31 @@ impl VideoReceipt { r } - /// Decode `codestream` and seal a receipt over what it actually produces. + /// Decode the `WIV1` payload `payload` (keyframe 0) and seal a receipt over what + /// it actually produces. The identity is BLAKE3 over `payload` exactly as an + /// envelope carries it, so it identifies the artifact a verifier will be handed. + pub fn seal_decode_wiv1( + signer: &SigningKey, + signer_id: impl Into, + payload: &[u8], + joules_micro: u64, + energy_provenance: Option, + parent_receipt_hash: Option<[u8; 32]>, + ) -> Result { + let clip = decode_wiv1(payload)?; + let codestream_hash = *blake3::hash(payload).as_bytes(); + Ok(Self::build(signer, signer_id, codestream_hash, &clip, joules_micro, energy_provenance, parent_receipt_hash)) + } + + /// Decode `codestream`, the tool's JSON form, and seal a receipt over what it + /// actually produces, identified by BLAKE3 over `raw`, the JSON bytes as read. /// - /// The identity is hashed from `raw` — the codestream bytes exactly as they - /// arrived — rather than from a re-serialisation of the parsed value, so the - /// hash identifies the artifact a verifier will be handed rather than this - /// process's idea of it. + /// The JSON form is no longer a payload (`extensions/integer-payloads.md` §10): + /// a new receipt is sealed over the `WIV1` payload with + /// [`Self::seal_decode_wiv1`]. This stays so that a receipt sealed over the JSON + /// form can still be re-derived and its pinned bytes reproduced. + #[deprecated(note = "the payload of wai.video.int_motion is WIV1 (extensions/integer-payloads.md); \ + seal with `seal_decode_wiv1`. Kept so receipts sealed over the JSON form still verify")] pub fn seal_decode( signer: &SigningKey, signer_id: impl Into, @@ -264,20 +303,38 @@ impl VideoReceipt { k.verify(&self.signing_payload(), &Signature::from_bytes(&self.sig)).is_ok() } - /// The independent check: re-decode the codestream and confirm this receipt - /// describes it — identity, geometry, every frame, the clip commitment and - /// the work figure. + /// The independent check: re-decode the `WIV1` payload and confirm this receipt + /// describes it — identity, geometry, every frame, the clip commitment and the + /// work figure. /// /// Byte-equality across architectures is what makes this meaningful rather /// than circular: the caller need not trust the machine that sealed the - /// receipt, because the same bitstream reconstructs the same bytes on any of - /// the proven architectures. `joules_micro` is untouched here — it is not + /// receipt, because the same payload reconstructs the same bytes on every + /// platform the matrix checks. `joules_micro` is untouched here — it is not /// reproducible and is not meant to be. + pub fn verify_reconstruction_wiv1(&self, payload: &[u8]) -> bool { + if *blake3::hash(payload).as_bytes() != self.codestream_hash { + return false; + } + let Ok(clip) = decode_wiv1(payload) else { return false }; + self.describes(&clip) + } + + /// [`Self::verify_reconstruction_wiv1`] for a receipt sealed over the tool's + /// JSON form: `raw` is the JSON bytes its identity was hashed from. + #[deprecated(note = "the payload of wai.video.int_motion is WIV1 (extensions/integer-payloads.md); \ + verify with `verify_reconstruction_wiv1`. Kept so receipts sealed over the JSON form still verify")] pub fn verify_reconstruction(&self, raw: &[u8], codestream: &Value) -> bool { if *blake3::hash(raw).as_bytes() != self.codestream_hash { return false; } let Ok(clip) = decode(codestream) else { return false }; + self.describes(&clip) + } + + /// Geometry, every frame and then [`Self::verify`]: what a re-decoded clip must + /// match. + fn describes(&self, clip: &DecodedClip) -> bool { if clip.h as u32 != self.h || clip.w as u32 != self.w { return false; } diff --git a/wai/wai-rs/tests/video_receipt.rs b/wai/wai-rs/tests/video_receipt.rs index c8ce13d98..f1eaa45b5 100644 --- a/wai/wai-rs/tests/video_receipt.rs +++ b/wai/wai-rs/tests/video_receipt.rs @@ -5,6 +5,10 @@ //! attested by the signature alone. Each half is tested for what it actually //! promises, and the tamper cases confirm the checkable half cannot be bent. #![cfg(feature = "video_receipt")] +// Most tests here seal over the tool's JSON form through the deprecated entry points: +// they are the legacy receipts, whose pinned bytes must keep verifying. The tests of +// the WIV1 payload, the identity a receipt has now, are at the end. +#![allow(deprecated)] use std::path::PathBuf; @@ -277,3 +281,58 @@ fn refined_labels_keep_their_signed_bytes() { ] ); } + +/// A receipt sealed over the `WIV1` payload (`extensions/integer-payloads.md` §3): +/// its identity is BLAKE3 over the payload, it re-derives from the payload alone, +/// and every reconstruction field — frames, clip commitment, geometry, work — is the +/// one the JSON form's receipt carries, since both decode to the same frames. +#[test] +fn a_wiv1_receipt_identifies_the_payload_and_reconstructs_the_same_clip() { + for (bin, json) in [("int_video_bitstream.wiv1", "int_video_bitstream.json"), ("video_real_bitstream.wiv1", "video_real_bitstream.json")] { + let payload = raw(bin); + let r = VideoReceipt::seal_decode_wiv1(&key(7), "did:example:meter", &payload, 4_200, Some(chip()), None).expect("seal"); + assert!(r.verify(), "{bin}"); + assert!(r.verify_reconstruction_wiv1(&payload), "{bin}: re-decode must reproduce every frame"); + assert_eq!(r.codestream_hash, *blake3::hash(&payload).as_bytes(), "{bin}: the identity is the payload's"); + let (legacy, json_bytes, _) = seal(json, 4_200); + assert_eq!((&r.frames, r.clip_hash, r.h, r.w, r.work_pixel_ops), (&legacy.frames, legacy.clip_hash, legacy.h, legacy.w, legacy.work_pixel_ops), + "{bin}: the same reconstruction as the JSON form's receipt"); + assert_ne!(r.codestream_hash, legacy.codestream_hash); + // Neither receipt vouches for the other's codestream. + assert!(!r.verify_reconstruction_wiv1(&json_bytes), "{bin}: a JSON form is not a WIV1 payload"); + assert!(!legacy.verify_reconstruction_wiv1(&payload), "{bin}: the legacy receipt names the JSON bytes"); + let mut other = payload.clone(); + let at = other.len() - 5; + other[at] ^= 1; + assert!(!r.verify_reconstruction_wiv1(&other), "{bin}: one changed byte"); + } +} + +/// The signed bytes of receipts sealed over the `WIV1` payload, captured from the +/// code as this identity was introduced: a metered one with the on-chip class, an +/// unmetered one, and one chained to the first. Never edited to make a test pass. +#[test] +fn wiv1_receipts_keep_their_signed_bytes() { + let payload = raw("int_video_bitstream.wiv1"); + let metered = VideoReceipt::seal_decode_wiv1(&key(7), "did:example:meter", &payload, 4_200, Some(chip()), None).unwrap(); + let unmetered = VideoReceipt::seal_decode_wiv1(&key(7), "did:example:meter", &payload, 0, None, None).unwrap(); + let chained = VideoReceipt::seal_decode_wiv1(&key(7), "did:example:meter", &payload, 0, None, Some(metered.receipt_hash())).unwrap(); + assert!(metered.verify() && unmetered.verify() && chained.verify()); + let got = [ + hex(&metered.codestream_hash), + hex(&metered.receipt_hash()), + hex(blake3::hash(metered.to_json().as_bytes()).as_bytes()), + hex(&unmetered.receipt_hash()), + hex(&chained.receipt_hash()), + ]; + assert_eq!( + got, + [ + "277865b574029f700f4fac023de3ab06ae598ee6f8ed726444e91442a6bd59e9", + "52c0ca1c9b383b9ad83226d423b36ecafbef92bf431432c7426a484c0406a742", + "97a7cb3b2598f609d205159e2ef1b2d7fb2d35e429ee161a6a8fbbd8ffdcae52", + "f19f86c9106981096bdbb5ea06926e3543112c26a5f737732938feb51c2b6119", + "58ca49ab785116aec8d2f3b990722e962967cbca6b4d6fe41087343872bd1628", + ] + ); +} -- 2.54.0 (Apple Git-157) From 33131f6f6b67aa13056910a669be929a420b628c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 12:36:19 -0400 Subject: [PATCH 07/34] byte-exact-conformance: WIV1 and WIH1 goldens over the committed payloads The crate #[path]-includes int_payload/wire.rs, which uses nothing from wai-rs, and parses the committed binary payloads with the parser a sink runs. It decodes them through the checked typed decoders: - the WIV1 keyframe-0 clips, synthetic and half-pel real footage; - the WIV1 keyframe-1 clip, whose nested WIH1 keyframe decodes from the tiny conditional codec's compact networks and entropy tables; - the WIH1 payload alone. Each payload is pinned by its SHA-256. Each decode must give, on every leg of the matrix, the per-frame, clip and image SHA-256 that the vectors' sidecars state and that the JSON forms' goldens decode to. Co-Authored-By: Claude Opus 5.5 --- .../src/int_payload_golden.rs | 118 ++++++++++++++++++ wai/byte-exact-conformance/src/lib.rs | 7 ++ 2 files changed, 125 insertions(+) create mode 100644 wai/byte-exact-conformance/src/int_payload_golden.rs diff --git a/wai/byte-exact-conformance/src/int_payload_golden.rs b/wai/byte-exact-conformance/src/int_payload_golden.rs new file mode 100644 index 000000000..817684bce --- /dev/null +++ b/wai/byte-exact-conformance/src/int_payload_golden.rs @@ -0,0 +1,118 @@ +//! Cross-architecture byte-equality for the binary payloads SPEC §5 pins +//! (`extensions/integer-payloads.md`): `WIV1` (`wai.video.int_motion` with keyframe 0, +//! `wai.video.int_hyper` with keyframe 1) and `WIH1` (`wai.neural.int_hyper`). +//! +//! The goldens next door decode the JSON forms the encode tools write. These parse the +//! committed binary payloads with the wire parser a sink runs (`int_payload/wire.rs`, +//! `#[path]`-included and self-contained) and decode them through the checked typed +//! decoders, so every leg of the matrix checks the normative parse as well as the +//! decode. Each must reproduce the same SHA-256 digests the JSON forms do, from the +//! vectors' `sha256.json` sidecars, and the payloads themselves are pinned by their +//! SHA-256. + +#![cfg(test)] + +use std::path::PathBuf; + +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::int_hyper_synth::{decode_conditional_checked, CondEntropy, CondStream, IntModel}; +use crate::int_payload_wire::{parse_wih1, parse_wiv1, Wih1, Wiv1}; +use crate::int_video::{decode_pframes_typed, decode_typed, VideoStream}; + +const INT_VIDEO_WIV1_SHA256: &str = "3bbf0a864d9528e73aff036d1a636893f839d3f6c5f31dde7824a59e6c116565"; +const VIDEO_REAL_WIV1_SHA256: &str = "efce5ada256cb3443a673ee0c10aa4e0a8ce4a372102cd918627e5eec746880f"; +const VIDEO_NK_WIV1_SHA256: &str = "d2c62da643328bfbdf9eab129283322e9077a491c5ddd269464577f51a958bc8"; +const TINY_WIH1_SHA256: &str = "6d8a195a5a138452292b55d70a575d1fbb4372fd8614b3b821b676e5090c3c17"; + +fn vectors() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../wai-rs/tests/vectors") +} +fn read(rel: &str) -> Vec { + let p = vectors().join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())) +} +fn jload(rel: &str) -> Value { + serde_json::from_slice(&read(rel)).unwrap_or_else(|e| panic!("parse {rel}: {e}")) +} +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn stream<'a>(v: &'a Wiv1<'a>) -> VideoStream<'a> { + VideoStream { + n_frames: v.n_frames as usize, h: v.h as usize, w: v.w as usize, block: v.block as usize, + q: v.q as i64, offset: v.offset, mv_precision: v.mv_precision, cdf: &v.cdf, mvs: &v.mvs, rans: v.rans, + } +} + +/// Every frame's SHA-256 and the clip's against the sidecar entry `key`. +fn assert_clip(frames: &[Vec], key: &str) -> String { + let side = jload("int_video/sha256.json"); + let want = side[key]["frames_sha256"].as_array().expect("frames_sha256"); + assert_eq!(want.len(), frames.len(), "{key}: frame count"); + let mut all = Vec::new(); + for (i, f) in frames.iter().enumerate() { + let d = Sha256::digest(f); + assert_eq!(hex(&d), want[i].as_str().unwrap(), "{key}: frame {i} diverged"); + all.extend_from_slice(&d); + } + let clip = hex(&Sha256::digest(&all)); + assert_eq!(clip, side[key]["clip_sha256"].as_str().unwrap(), "{key}: clip"); + clip +} + +/// The tiny conditional codec's keyframe, from its committed set (the compact `WHM1` +/// networks and the entropy tables). +fn keyframe(k: &Wih1<'_>) -> (Vec, usize, usize) { + let g_s = IntModel::from_bytes(&read("int_hyper/tiny_g_s.bin")).expect("g_s"); + let h_s = IntModel::from_bytes(&read("int_hyper/tiny_h_s.bin")).expect("h_s"); + let ent = CondEntropy::try_from_value(&jload("int_hyper/tiny_entropy.json")).expect("entropy tables"); + let s = CondStream { + m: k.m as usize, sh: k.sh as usize, sw: k.sw as usize, n_z: k.n_z as usize, zh: k.zh as usize, zw: k.zw as usize, + z_rans: k.z_rans, rans: k.rans, + }; + decode_conditional_checked(&g_s, &h_s, &ent, &s).expect("the keyframe decodes") +} + +/// `WIV1`, keyframe 0: the synthetic whole-pel clip and the half-pel real footage. +#[test] +fn wiv1_keyframe_0_payloads_are_byte_identical() { + for (bin, key, payload_sha, clip_sha) in [ + ("int_video/int_video_bitstream.wiv1", "int_video", INT_VIDEO_WIV1_SHA256, + "5afd6477d09525b7db4485027588ba3bc43de3d29c996eb57f2d3274bf8309f9"), + ("int_video/video_real_bitstream.wiv1", "video_real", VIDEO_REAL_WIV1_SHA256, + "e6724ca214694e012c3e2b49df4e116a0a7e150435a12d7b144560ce2d5c7bdf"), + ] { + let p = read(bin); + assert_eq!(hex(&Sha256::digest(&p)), payload_sha, "{key}: the committed payload"); + let v = parse_wiv1(&p, Some(0)).expect("the payload parses"); + let clip = decode_typed(&stream(&v)).expect("decode must succeed on every architecture"); + assert_eq!(assert_clip(&clip.frames, key), clip_sha); + } +} + +/// `WIV1`, keyframe 1: the nested `WIH1` keyframe, then integer P-frames on top. +#[test] +fn wiv1_keyframe_1_payload_is_byte_identical() { + let p = read("int_video/video_nk_bitstream.wiv1"); + assert_eq!(hex(&Sha256::digest(&p)), VIDEO_NK_WIV1_SHA256); + let v = parse_wiv1(&p, Some(1)).expect("the payload parses"); + let k = parse_wih1(v.wih1.expect("keyframe 1 nests a WIH1")).expect("the keyframe parses"); + let (frame0, h, w) = keyframe(&k); + assert_eq!((h, w), (v.h as usize, v.w as usize), "the keyframe is the clip's size"); + let clip = decode_pframes_typed(&frame0, &stream(&v)).expect("integer P-frames"); + assert_eq!(assert_clip(&clip.frames, "video_nk"), "4a81f0c94ccd938a7ca80df203303d5735d9dfcdfa150da155d9f2e4ca283e8f"); +} + +/// `WIH1` alone: the learned keyframe's image. +#[test] +fn wih1_payload_is_byte_identical() { + let p = read("int_hyper/tiny_bitstream.wih1"); + assert_eq!(hex(&Sha256::digest(&p)), TINY_WIH1_SHA256); + let (rgb, h, w) = keyframe(&parse_wih1(&p).expect("the payload parses")); + assert_eq!((h, w), (16, 16)); + assert_eq!(hex(&Sha256::digest(&rgb)), "dce7282d46f88f03d5dc0fd42f325756d310fe1fc982b6e1792a1fbfbeb482cf"); + assert_eq!(hex(&Sha256::digest(&rgb)), jload("int_hyper/sha256.json")["tiny"]["sha256"].as_str().unwrap()); +} diff --git a/wai/byte-exact-conformance/src/lib.rs b/wai/byte-exact-conformance/src/lib.rs index 4088a4701..b442bccc9 100644 --- a/wai/byte-exact-conformance/src/lib.rs +++ b/wai/byte-exact-conformance/src/lib.rs @@ -69,7 +69,14 @@ pub mod int_hyper_synth; #[path = "../../wai-rs/src/int_mlicpp.rs"] pub mod int_mlicpp; +// The wire parser of the integer payloads SPEC §5 pins (WIV1, WIH1, WIA1, WIS1). +// Self-contained — std's Vec only, nothing from the crate — so the matrix parses the +// committed binary payloads with the parser a sink runs. +#[path = "../../wai-rs/src/int_payload/wire.rs"] +pub mod int_payload_wire; + mod golden; +mod int_payload_golden; mod int_video_golden; mod neural_keyframe_golden; mod jpegai_golden; -- 2.54.0 (Apple Git-157) From a031b9dee80a7163a31c6712f977ddedcda244fe Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 13:12:44 -0400 Subject: [PATCH 08/34] =?UTF-8?q?wai:=20SPEC=20=C2=A75=20pins=20the=20inte?= =?UTF-8?q?ger=20codecs'=20payloads;=20extensions/integer-payloads.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §5 gains *Payloads of the WAI-native integer codecs*. It pins WIV1 to wai.video.int_motion (keyframe 0) and to wai.video.int_hyper (keyframe 1), WIH1 to wai.neural.int_hyper, WIA1 to wai.audio.int_codec and WIS1 to wai.splat.int_codec, each with its parameter set. These rows named a decoder and the tool that wrote a bitstream, and no byte layout, so the pin completes their registration and redefines nothing. The JSON the tools write is a converter input; an encoder must not carry it under these strings. The subsection lists the envelopes the pin makes non-conforming; they still parse. The passages the pin contradicted now say what holds. The §5 introduction no longer says that every payload is a codec's own bytes. The neural introduction no longer says that the standard specifies no neural payload layout. The integer rows name their formats, and name the tools only as writers of converter input. The parameter-set paragraph names the members the integer decoders read and registers wai.audio.int_codec (model.wam) and wai.splat.int_codec (g_s.whm) as one file; that is the widening the code made. The prior-carriage §7 table gives the same members. REGISTRY.md's definition of "registered" says these strings got their formats in v0.4.0. The README says the payloads are binary and the JSON is converted. extensions/integer-payloads.md defines each layout field by field, the rANS stream and CDF table rules, the order of the checks, each decode, the caps and the checks a decode makes, the closed list of refusal codes, the parameter sets, the converter rules for the JSON forms, and the reference implementation. codecs::registry's spec_pins_the_integer_payloads_the_registry_lists holds SPEC's table to PINNED_INT_PAYLOADS and to each set's registered shape and members, and the extension to one section per format. `wai registry export --check` passes with nothing written: no exported field records a set's shape. Co-Authored-By: Claude Opus 5.5 --- wai/README.md | 5 + wai/REGISTRY.md | 6 +- wai/SPEC.md | 62 ++++- wai/extensions/integer-payloads.md | 407 +++++++++++++++++++++++++++++ wai/extensions/prior-carriage.md | 10 +- wai/wai-rs/src/codecs/registry.rs | 48 ++++ wai/wai-rs/src/int_payload/wire.rs | 2 +- 7 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 wai/extensions/integer-payloads.md diff --git a/wai/README.md b/wai/README.md index 667dfdd2c..aa0d59a36 100644 --- a/wai/README.md +++ b/wai/README.md @@ -111,6 +111,11 @@ hash: a deployer can require `intent=replicate` to dispatch only to these. | `wai.audio.int_codec` | audio — learned 1-D transform, integer synthesis → int16 PCM | byte-exact (hash-portable) | | `wai.splat.int_codec` | 3D — learned Gaussian-splat-attribute transform, integer synthesis | byte-exact (hash-portable) | +Their payloads are binary formats SPEC §5 pins — `WIV1` for the integer video +codecs, `WIH1`, `WIA1` and `WIS1` +([`extensions/integer-payloads.md`](extensions/integer-payloads.md)). The JSON the +encode and export tools write is converted to them, never carried. + ## Interactive & temporal content (extensions) Beyond the codec menu, WAI carries content that consumes input or moves diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index 20695f0e2..cf24fd51c 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -49,7 +49,11 @@ Two further tables hold strings that look like capabilities and are not: ## Status - **registered** — the payload format (for a record or derivation, the - definition) is pinned. + definition) is pinned. The WAI-native integer codecs `wai.video.int_motion`, + `wai.video.int_hyper`, `wai.neural.int_hyper`, `wai.audio.int_codec` and + `wai.splat.int_codec` were registered with a decoder and the tool that wrote their + bitstreams, and no byte layout; v0.4.0 pins their formats (SPEC §5, *Payloads of the + WAI-native integer codecs*). - **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 04ce24cb2..73737c19a 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -437,9 +437,12 @@ codec or a WAI-native class. The mandatory floor and the recommended set are royalty-free; every payload and companion capability carries a licence class (see the end of this section), and one that is not royalty-free is never a default. The "payload format" column is the -on-the-wire payload bytes; in every case it is the canonical bytes -that codec normally emits (a JXL file, an Opus stream, etc.) — WAI -does not wrap or re-frame them. +on-the-wire payload bytes. For a standard codec it is the canonical +bytes that codec normally emits (a JXL file, an Opus stream, etc.) — +WAI does not wrap or re-frame them. For a WAI-native class it is the +format the class's registration or extension defines; for the +WAI-native integer codecs, the format *Payloads of the WAI-native +integer codecs* (below) pins. The registry is also published in machine-readable form, generated from the reference implementation: [`registry/capabilities.json`](registry/capabilities.json) @@ -502,8 +505,10 @@ Every conforming sink SHOULD implement: Each names a model the sink MAY have installed. Payload is a compact conditioning representation specific to the capability (latent tokens, -prompt embeddings, etc.). The standard does NOT specify the byte -layout of these payloads — the capability owner does. +prompt embeddings, etc.). The standard does not specify the byte +layout of these payloads — the capability owner does — except where this +section pins one: the integer codecs' payloads are pinned below +(*Payloads of the WAI-native integer codecs*). Each row names the model and the operating point its own publication reports (bitrate, sample rate) or the property WAI's integer construction @@ -515,11 +520,15 @@ sink-supplied. A capability that decodes against a parameter set (§3.1) is registered with that set's shape. **One file:** `wai.neural.int_synth` (its model specification, `int_synth.json`); `wai.neural.int_mlicpp` (its `WMB1` bundle, read from a set as -`model.wmb`); and `wai.neural.encodec32`, `wai.neural.dac`, `wai.neural.mimi`, +`model.wmb`); `wai.audio.int_codec` (its `WAM1` synthesis, read from a set as +`model.wam`); `wai.splat.int_codec` (its `WHM1` synthesis, read from a set as +`g_s.whm`); and `wai.neural.encodec32`, `wai.neural.dac`, `wai.neural.mimi`, `wai.neural.wavtokenizer`, `wai.neural.snac` and `wai.neural.video_bmshj2018` (a self-contained ONNX model; one that keeps tensors in external files is several files). **Several files:** every other capability that decodes against a parameter -set. A later revision MAY register a capability of several files as one file when +set; `wai.neural.int_hyper` and `wai.video.int_hyper` read `g_s.whm`, `h_s.whm` and +`entropy.json` from theirs. `wai.audio.int_codec` and `wai.splat.int_codec` were +registered as several files until this revision named their one-file formats. A later revision MAY register a capability of several files as one file when it defines a one-file format that holds the whole set; that widens the registration and makes no conforming envelope non-conforming (§8). @@ -540,7 +549,7 @@ registration and makes no conforming envelope non-conforming (§8). | `wai.neural.bmshj2018` | bmshj2018-factorized + rANS (byte-exact CompressAI port) | about 0.26 bpp at 29 dB PSNR (quality 3) | CompressAI | | `wai.neural.mbt2018_mean` | mean-scale hyperprior (Gaussian-conditional entropy) | the conditional-entropy family (arXiv 1809.02736) | `neural/hyperprior.rs` | | `wai.neural.int_synth` | integer fixed-point synthesis (all-i64 transposed-conv, no float/ONNX) | bit-identical on every machine — soundly backs `intent = replicate` | `neural/int_synth.rs` (QAT weights sink-supplied) | -| `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 | `int_hyper_synth.rs` (tables sink-supplied) | +| `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) | @@ -559,8 +568,8 @@ registration and makes no conforming envelope non-conforming (§8). | `wai.neural.video_bmshj2018` | per-frame bmshj2018 | per-frame image coding; decodable in a browser on WebGPU | CompressAI | | `wai.neural.dcvc_rt` | DCVC-RT (2025), inter-frame with reference-feature propagation | inter-frame neural video; its paper reports real-time 1080p and 4K decode on a GPU | research code, `neural/video_dcvc.rs` | | `wai.neural.dcvc_fm` | DCVC-FM (feature modulation), wide single-model quality range (RGB + YUV) | a wide single-model quality range; shares the inter-frame route | research code, `neural/video_dcvc.rs` | -| `wai.video.int_motion` | DETERMINISTIC INTEGER inter-frame — I-frame + integer block motion-compensated P-frames + integer-quantized residual over rANS, no float/ONNX; motion is whole-pel or **half-pel** (`mv_precision`), the latter predicting at the half-grid by integer bilinear interpolation (`(a+b+1)>>1`, `(a+b+c+d+2)>>2`) so real footage — which pans a non-integer number of pixels per frame — costs fewer residual bits while staying byte-exact | byte-identical frame-by-frame on every machine (*frame-state-equivalence*, a per-frame portable hash); the temporal extension of the integer contract, where a float inter-frame decode desyncs down the GOP | `int_video.rs` (whole-pel `tools/wai_int_video_encode.py`; half-pel from real footage `tools/wai_int_video_real_encode.py`) | -| `wai.video.int_hyper` | NEURAL-KEYFRAME inter-frame — the I-frame is the LEARNED integer codec `int_hyper` decoded byte-exact, the P-frames are integer block-MC residuals on top | a learned keyframe you can put in a receipt + integer inter-frames; whole decode integer, byte-identical everywhere | `int_video::decode_pframes` ∘ `int_hyper_synth::decode_conditional` | +| `wai.video.int_motion` | DETERMINISTIC INTEGER inter-frame — I-frame + integer block motion-compensated P-frames + integer-quantized residual over rANS, no float/ONNX; motion is whole-pel or **half-pel** (`mv_precision`), the latter predicting at the half-grid by integer bilinear interpolation (`(a+b+1)>>1`, `(a+b+c+d+2)>>2`) so real footage — which pans a non-integer number of pixels per frame — costs fewer residual bits while staying byte-exact | byte-identical frame-by-frame on every machine (*frame-state-equivalence*, a per-frame portable hash); the temporal extension of the integer contract, where a float inter-frame decode desyncs down the GOP | payload `WIV1`, keyframe 0; `int_video.rs` (the encode tools write a JSON form a converter reads) | +| `wai.video.int_hyper` | NEURAL-KEYFRAME inter-frame — the I-frame is the LEARNED integer codec `int_hyper` decoded byte-exact, the P-frames are integer block-MC residuals on top | a learned keyframe you can put in a receipt + integer inter-frames; whole decode integer, byte-identical everywhere | payload `WIV1`, keyframe 1 (a nested `WIH1`); `int_video::decode_pframes_typed` ∘ `int_hyper_synth::decode_conditional_checked` | | `wai.video.manifest` | ATTESTED PER-SESSION MANIFEST — the OTT delivery layer, not a codec: a personalised HLS manifest (ads stitched at `EXT-X-DISCONTINUITY`, blackouts + geo enforced) rendered as a **pure function of the session *class*** `(content, entitlement, geo, blackout set, ad pod, window)` rather than the viewer, and sealed in an Ed25519 receipt that binds the JCP grant (capability, joule ceiling, `funds_ceiling` = ad budget), the composition, the stitched ads and the policy applied | **manifest-equivalence**: `manifest_hash == hash(render(segments))` and `session_class_hash` are recomputable by anyone holding the bytes, so the ads, blackouts and compose-work stop being dashboard claims; class-determinism means one generation serves every viewer of the class, and `uj_per_viewer` is the amortised energy that a 1:1-unique generator cannot report below its per-viewer cost. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_manifest.rs` | | `wai.video.ssai` | ATTESTED SERVER-SIDE AD INSERTION — a **deterministic** ad decision (greedy by price within the avail duration and the funds remaining) plus a per-impression Ed25519 receipt binding the candidate pod, the manifest it belongs to, the served `creative_hash`es and the VAST quartile each reached | **spend-equivalence**: `budget_spent_micros` is recomputed from the *billable* impressions and rejected if it exceeds the grant's `funds_ceiling` — an overspent receipt fails verification; every creative and quartile is checkable, so a buyer and seller reconcile impressions from the same bytes instead of two logs that only a dashboard joins. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_ssai.rs` | | `wai.video.keys` | ATTESTED CONTENT-KEY RELEASE — the DRM licensing decision as a record: every key release, and every refusal, is an Ed25519 receipt binding the KID, the DRM system, the entitlement demanded and the window enforced, with a BLAKE3 `key_commit` proving *which* key was released without carrying it | **decision-equivalence**: `verify()` re-runs the release policy over the receipt's own `(grant capability, entitlement, issued, expiry)` and rejects any misreported outcome, and enforces granted-iff-committed — so "keys went only to entitled, unexpired sessions" is checkable against the signed log, not asserted. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_keys.rs` | @@ -657,12 +666,12 @@ standard. See the linked extension for each. | `wai.splat.spz` | SPZ (an open-source Gaussian-splat format) | asset, content-addressed; presentation only | | `wai.audio.scene` | WSCN scene (objects + trajectories) | mixdown-equivalence · [spatial-audio](extensions/spatial-audio.md) | | `wai.audio.score` | WSCR container (note events) | music as instructions; sink synthesizes, mixdown-equivalence · [score](extensions/score.md) | -| `wai.audio.int_codec` | LEARNED 1-D transform codec — integer transposed-conv synthesis + integer IGDN over a factorized-rANS latent (model + bitstream from `tools/wai_int_audio_export.py`) | **sample-equivalence**: int16 PCM byte-identical on every machine (a portable PCM hash); the audio analog of `wai.neural.int_hyper`, where a float learned-audio decode desyncs on ~1e-5 round-off. Pure i64, no float/ONNX (`int_audio.rs`) | +| `wai.audio.int_codec` | `WIA1` — a LEARNED 1-D transform codec: integer transposed-conv synthesis + integer IGDN over a factorized-rANS latent (`tools/wai_int_audio_export.py` writes the model and a JSON form a converter reads) | **sample-equivalence**: int16 PCM byte-identical on every machine (a portable PCM hash); the audio analog of `wai.neural.int_hyper`, where a float learned-audio decode desyncs on ~1e-5 round-off. Pure i64, no float/ONNX (`int_audio.rs`) | | `wai.haptic.signal` | WHAP envelope (intensity/sharpness keyframes) | sample-equivalence · [haptics](extensions/haptics.md) | | `wai.feed.state` | WWLD, sim `wai.world.sim.feed0` | replay-equivalence · [state-feeds](extensions/state-feeds.md) | | `wai.avatar.pose` | WWLD, sim `wai.world.sim.skeletal0` | pose-equivalence · [avatars](extensions/avatars.md) | | `wai.splat.4d` | W4DS container (per-Gaussian keyframed tracks) | frame-state-equivalence · [volumetric](extensions/volumetric.md) | -| `wai.splat.int_codec` | LEARNED Gaussian-splat ATTRIBUTE codec — a learned transform over the `[14,S,S]` attribute image (pos/scale/rot/color/opacity), integer synthesis (int_hyper machinery, 14 channels) over a factorized-rANS latent (`int_splat.rs`; model + bitstream from `tools/wai_int_splat_export.py`) | **splat-equivalence**: the decoded attribute tensor is byte-identical on every machine (a portable cloud hash) — the int_hyper of 3D, where a float splat-attribute decode desyncs. The float rasterizer stays sink-supplied presentation; the *attributes* are the contract. Pure i64, no float/ONNX | +| `wai.splat.int_codec` | `WIS1` — a LEARNED Gaussian-splat ATTRIBUTE codec: a learned transform over the `[14,S,S]` attribute image (pos/scale/rot/color/opacity), integer synthesis (int_hyper machinery, 14 channels) over a factorized-rANS latent (`int_splat.rs`; `tools/wai_int_splat_export.py` writes the model and a JSON form a converter reads) | **splat-equivalence**: the decoded attribute tensor is byte-identical on every machine (a portable cloud hash) — the int_hyper of 3D, where a float splat-attribute decode desyncs. The float rasterizer stays sink-supplied presentation; the *attributes* are the contract. Pure i64, no float/ONNX | | `wai.film.linear` | WFLM container (sealed world + camera track + frame schedule) | film-equivalence · [linear-film](extensions/linear-film.md) | | `wai.film.reel` | WREL container (sealed world + a sequence of shots = camera track + schedule each) | reel-equivalence (film-equivalence with cuts) · [linear-film](extensions/linear-film.md) | | `wai.ternary.tt` | WQTT container (balanced-ternary MPS cores — packed trits + integer per-column scales) | a **quantum-inspired** (tensor-network) transform ported from the ternary/MPS fabric with ONE change: the per-column scale is an **integer**, so the whole decode is `i64`/`i128` and the reconstruction is byte-identical on every machine — **reconstruction-equivalence** (a portable recon hash), receiptable. Scope: a **provenance** transform, measured at 13.3 dB SNR for 3.8× with six residual layers (`Lossy` tier; the extension's §5). Encoder-side float SVD only · [ternary-tt](extensions/ternary-tt.md) | @@ -681,6 +690,35 @@ grant's joule ceiling (gate ⊗ meter ⊗ receipt in one object). WAI owns the receipt; the sink's control system runs the hardware: [`extensions/quantum-ops.md`](extensions/quantum-ops.md). +### Payloads of the WAI-native integer codecs + +This revision pins a payload format to each WAI-native integer codec whose row named +a decoder and the tool that wrote its bitstream, and no byte layout. +[integer-payloads](extensions/integer-payloads.md) §3–§6 defines each layout and its +decode, §7 the checks a sink makes, and §8 the closed list of codes it refuses with. +The rows above keep their headings. + +| capability | payload format | parameter set | +|---|---|---| +| `wai.video.int_motion` | `WIV1`, keyframe 0 | none | +| `wai.video.int_hyper` | `WIV1`, keyframe 1 (a nested `WIH1`) | several files: `g_s.whm`, `h_s.whm`, `entropy.json` | +| `wai.neural.int_hyper` | `WIH1` | several files: `g_s.whm`, `h_s.whm`, `entropy.json` | +| `wai.audio.int_codec` | `WIA1` | one file: `model.wam` | +| `wai.splat.int_codec` | `WIS1` | one file: `g_s.whm` | + +These rows fixed no payload format before this revision, so the pin completes their +registration and redefines nothing (§8); from this revision on, the format of each is +fixed as §8 states. The JSON the encode and export tools write is a converter input +([integer-payloads](extensions/integer-payloads.md) §10), never a payload: an encoder +MUST NOT carry it under these strings, and a sink refuses it (`bad_magic`). + +*Envelopes the pin makes non-conforming* (they still parse): a `WAI1` whose +`capability` or `fallback` is one of these strings and whose payload is not that +string's format, a tool's JSON form among them; and a `WAI2` with a component of one +of these strings whose bytes are not its format. A sink refuses such a payload when it +decodes it, and does not try the fallback +([integer-payloads](extensions/integer-payloads.md) §11). + ### Immersive audio, cinema, volumetric, haptics, HDR (registered, sink-supplied) The same "name the capability, the sink supplies the decoder" pattern extends diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md new file mode 100644 index 000000000..13c1c0b3c --- /dev/null +++ b/wai/extensions/integer-payloads.md @@ -0,0 +1,407 @@ +# WAI Extension: Integer Payloads + +> Status: Draft. Pins the payload formats of the WAI-native integer codecs that +> [SPEC.md §5](../SPEC.md) registers without one (*Payloads of the WAI-native integer +> codecs*): `WIV1` for `wai.video.int_motion` and `wai.video.int_hyper`, `WIH1` for +> `wai.neural.int_hyper`, `WIA1` for `wai.audio.int_codec` and `WIS1` for +> `wai.splat.int_codec`. Defines each layout, its decode, the checks a sink makes and +> the closed list of refusal codes; names each capability's parameter-set members; +> and makes the JSON the encode and export tools write a converter input, never a +> payload. Reference impl: `wai-rs` — `int_payload::{wire, decode_int, resolve}`, +> `int_payload::{wiv1_from_json, wih1_from_json, wia1_from_json, wis1_from_json}`, +> `codecs::PINNED_INT_PAYLOADS`, `container::check_int_payload_framing`. +> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. + +## 1. Scope + +| capability | payload | parameter set (§9) | +|---|---|---| +| `wai.video.int_motion` | `WIV1` with keyframe 0 (§3) | none | +| `wai.video.int_hyper` | `WIV1` with keyframe 1, a nested `WIH1` (§3) | several files: `entropy.json`, `g_s.whm`, `h_s.whm` | +| `wai.neural.int_hyper` | `WIH1` (§4) | several files: `entropy.json`, `g_s.whm`, `h_s.whm` | +| `wai.audio.int_codec` | `WIA1` (§5) | one file: `model.wam` | +| `wai.splat.int_codec` | `WIS1` (§6) | one file: `g_s.whm` | + +**Why this is a pin and not a redefinition.** Until this revision, these +capabilities' SPEC §5 rows named the tool that wrote a bitstream (an encode or export +script) and the decoder that read it, and no byte layout. Their payload format was +never fixed, so fixing it now completes their registration; it changes no format a +revision had fixed. SPEC §8's rule that a registered string's payload format is fixed +forever applies to these strings from this revision on. The encode and export tools +write JSON that carries the same fields; it is a converter input (§10). + +`wai.neural.int_synth` and `wai.neural.int_mlicpp` keep the payload formats SPEC §5 +gives them. A sink decodes all seven integer capabilities through one path +(`int_payload::decode_int`), and §8 names the codes it reports for those two. + +## 2. Common rules + +**Encoding.** Every multi-byte field is little-endian. Offsets are in bytes. + +**Framing.** A payload starts with its four-byte magic and a version byte. This +revision defines version 1 for every format. Every reserved field is zero. Nothing +follows the last section. + +**rANS streams.** A stream is a `u32` length `L` followed by `L` bytes. `L` is a +multiple of 4 and at least 8. It is decoded as follows (precision 16, with a bypass +escape of 4-bit groups): + +- The state `x` starts as `(w1 << 32) | w0`, from the stream's first two `u32` + words. *Renormalising*: if `x < 2^31`, then `x = (x << 32) | w`, where `w` is the + next unread word; if no word is left, the stream has **run out** and `x` is left + as it is. +- `bits(n)`: `v = x mod 2^n`, `x = x >> n`, renormalise, return `v`. +- Decoding a symbol against a table `cdf` of length `n` with offset `o`: let + `cum = x mod 2^16` and `s` be the largest index with `cdf[s] ≤ cum` + (`0 ≤ s ≤ n − 2`); then `x = (cdf[s+1] − cdf[s]) · (x >> 16) + cum − cdf[s]`, and + renormalise. If `s < n − 2`, the value is `s`. If `s = n − 2` (the escape): let + `v = bits(4)` and `count = v`; then, at most 8 times, while `v = 15` and the stream + has not run out, `v = bits(4)` and `count = count + v`; then `count = min(count, 8)` + and `raw = Σ_{j < count} bits(4) << 4j`. The value is `−(raw >> 1) − 1` if `raw` + is odd and `(raw >> 1) + (n − 2)` if it is even, saturated to `i32`. The symbol + is `value + o`, saturated to `i32`. +- A decode that has run out of its stream by the end of its last symbol is refused + (`stream_exhausted`). + +**CDF tables.** A table is `n` entries of `u32` with `3 ≤ n ≤ 4097`, `cdf[0] = 0`, +`cdf[n − 1] = 65536` and every entry greater than the one before. These rules are +what keep the symbol search and the state update above in range. + +**Order of the checks.** A parser checks, in this order, and refuses with the first +that fails: + +1. the magic: the capability's (§1), or else `format_capability_mismatch` if it is + another of the four magics, or `bad_magic`; +2. the version byte: present (`stream_length`), and 1 (`version`); +3. the fixed header: present (`stream_length`); +4. the reserved fields (`reserved_nonzero`); +5. the header's fields, in the order the format's section lists their rules + (`field_range`), then, for `WIV1`, the keyframe kind (`keyframe_capability_mismatch`); +6. the caps of §7 on the sizes the header declares (`too_large`); +7. each later section in order: present (`stream_length`), then its rules; +8. nothing after the last section (`trailing`, or `nested_length` for `WIV1`'s + nested keyframe). + +A decode then makes the checks of §7, and refuses a stream that runs out. Two +conforming sinks refuse the same bytes with the same code. + +## 3. `WIV1` — integer inter-frame video + +| off | size | field | rule | +|---|---|---|---| +| 0 | 4 | magic `WIV1` | | +| 4 | 1 | version | `1` | +| 5 | 1 | `mv_precision` | `1` (whole-pel) or `2` (half-pel) | +| 6 | 1 | `keyframe` | `0` or `1` | +| 7 | 1 | reserved | `0` | +| 8 | 4 | `n_frames` | ≥ 1 | +| 12 | 4 | `h` | ≥ 1 | +| 16 | 4 | `w` | ≥ 1 | +| 20 | 4 | `block` | ≥ 1 | +| 24 | 4 | `q` | 1..=65535 | +| 28 | 4 | `offset` | `i32` | +| 32 | 2 | `cdf_len` | 3..=4097 | +| 34 | 2 | reserved | `0` | +| 36 | `cdf_len`·4 | the residual table | §2 | +| … | (`n_frames` − 1)·`nb`·4 | motion vectors | `i16 dy, i16 dx` | +| … | 4 + `L` | the residual stream | §2 | +| … | 4 + `K` | keyframe 1 only: `u32 K`, then a `WIH1` payload | `K` is exactly the bytes that remain | + +The header's field rules are checked in the order `mv_precision`, `keyframe`, +`n_frames`, `h` and `w`, `block`, `q`, `cdf_len`. `keyframe` 0 is +`wai.video.int_motion`'s and 1 is `wai.video.int_hyper`'s; the other is +`keyframe_capability_mismatch`. A frame `h·w·3` and a clip `n_frames·h·w·3` are capped +(§7). + +**Motion vectors.** `nb = ⌈h/block⌉·⌈w/block⌉` and `nbx = ⌈w/block⌉`. The vectors +are frames 1 to `n_frames − 1` in order, each its block grid row-major: the vector of +pixel `(y, x)` in frame `f` is entry `(f − 1)·nb + (y div block)·nbx + (x div block)`, +in units of `1/mv_precision` pel. + +**Decode.** One decoder reads the residual stream for the whole clip, against the +residual table at `offset`. For each frame in order, for each pixel in raster order +`(y, x)`, for each channel `c` of R, G, B: decode the symbol `r`, and the sample is +`clamp(pred + r·q, 0, 255)`. Since `|r| < 2^31` and `q < 2^16`, every step fits `i64`. + +- Frame 0 with keyframe 0: `pred = 128`. +- Frame 0 with keyframe 1: the frame is the nested `WIH1`'s decode (§4), which reads + no symbol of the residual stream. It must be `h × w` (`geometry_mismatch`). +- Frame `f ≥ 1`: `pred` reads the previous **decoded** frame `R` with the pixel's + vector `(dy, dx)`, where `p(Y, X)` is `R` at row `clamp(Y, 0, h − 1)`, column + `clamp(X, 0, w − 1)`, channel `c`: + - whole-pel: `pred = p(y + dy, x + dx)`; + - half-pel: `iy = y + (dy >> 1)`, `fy = dy & 1`, `ix = x + (dx >> 1)`, + `fx = dx & 1` (arithmetic shift); then `p(iy, ix)` when both are 0, + `(p(iy, ix) + p(iy, ix + 1) + 1) >> 1` when only `fx` is 1, + `(p(iy, ix) + p(iy + 1, ix) + 1) >> 1` when only `fy` is 1, and + `(p(iy, ix) + p(iy, ix + 1) + p(iy + 1, ix) + p(iy + 1, ix + 1) + 2) >> 2` + when both are. + +The output is `n_frames` RGB8 frames of `h × w` (rows top to bottom, pixels left to +right, R, G, B bytes): the buffers of the capability's frame-state-equivalence digests +(SPEC §7 *Digests*). + +## 4. `WIH1` — the integer conditional image codec + +| off | size | field | rule | +|---|---|---|---| +| 0 | 4 | magic `WIH1` | | +| 4 | 1 | version | `1` | +| 5 | 3 | reserved | `0` | +| 8 | 4 | `m` | ≥ 1: latent channels | +| 12 | 4 | `sh` | ≥ 1: latent height | +| 16 | 4 | `sw` | ≥ 1: latent width | +| 20 | 4 | `n_z` | ≥ 1: hyper-latent channels | +| 24 | 4 | `zh` | ≥ 1: hyper-latent height | +| 28 | 4 | `zw` | ≥ 1: hyper-latent width | +| 32 | 4 + `Lz` | the hyper-latent stream | §2 | +| … | 4 + `Ly` | the latent stream | §2 | + +The caps (§7) apply to `n_z·zh·zw` and to `2·m·sh·sw`. + +**Decode,** against the set of §9 (`g_s.whm` and `h_s.whm`, two integer networks at +one fixed-point scale `2^afrac`; `entropy.json`, the tables): + +1. For each hyper-latent channel `k` and each of its `zh·zw` elements, decode a + symbol `s` from the hyper-latent stream against channel `k`'s table and offset; + the element is `s·2^afrac + median_k`. +2. `h_s` on the hyper-latent `[n_z, zh, zw]` gives `[2·m, sh, sw]`: its first `m` + channels are the scales `σ`, the rest the means `μ`. +3. For each latent element `p` in order (channel, then row, then column), its table + is `b`, where, with the scale edges `e_0 … e_{N−1}` and `σ' = max(σ_p, e_0)`, + `b = N − 1 − #{ i < N − 1 : σ' ≤ e_i }`. Decode a symbol `r` from the latent + stream against table `b` and its offset; the element is `r·2^afrac + μ_p`. +4. `g_s` on the latent `[m, sh, sw]` gives three channels; each value `v` is clamped + to `[0, 2^afrac]` and becomes `(v·255 + 2^(afrac−1)) >> afrac`, as RGB8. + +Every value is formed in `i64` and must fit it (§7). + +## 5. `WIA1` — the integer learned audio codec + +| off | size | field | rule | +|---|---|---|---| +| 0 | 4 | magic `WIA1` | | +| 4 | 1 | version | `1` | +| 5 | 1 | `afrac` | 1..=30; equal to the model's (§7) | +| 6 | 2 | reserved | `0` | +| 8 | 4 | `sr` | ≥ 1: the sample rate the PCM is presented at, in hertz | +| 12 | 4 | `n_ch` | ≥ 1: latent channels | +| 16 | 4 | `lat_len` | ≥ 1: latent length | +| 20 | 4 | `t_out` | ≥ 1: samples presented | +| 24 | 4 | `offset` | `i32`: the symbol offset of every channel's table | +| 28 | 2 | `cdf_len` | 3..=4097 | +| 30 | 2 | reserved | `0` | +| 32 | `n_ch`·8 | medians | `i64` each, `|median| ≤ 2^47` | +| … | `n_ch`·`cdf_len`·4 | one table per channel | §2 | +| … | 4 + `L` | the latent stream | §2 | + +The header's field rules are checked in the order `afrac`, then `sr`, `n_ch`, +`lat_len` and `t_out`, then `cdf_len`. The caps (§7) apply to `n_ch·lat_len` and to +`t_out`. + +**Decode,** against the set of §9 (`model.wam`, the integer synthesis): + +1. For each channel `k` and each of its `lat_len` elements, decode a symbol `s` + against channel `k`'s table at `offset`; the element is `s·2^afrac + median_k`. +2. The synthesis (one-dimensional integer transposed convolutions and integer + inverse GDN) on `[n_ch, lat_len]` gives one channel of `t ≥ t_out` samples. +3. The first `t_out` samples `v` become `(v·32767 + 2^(afrac−1)) >> afrac`, clamped + to `[−32768, 32767]`. + +The output is `t_out` samples of `i16` PCM, one channel, at `sr` hertz: the buffer of +the capability's sample-equivalence digest (signed 16-bit little-endian samples). + +## 6. `WIS1` — the integer learned Gaussian-splat attribute codec + +| off | size | field | rule | +|---|---|---|---| +| 0 | 4 | magic `WIS1` | | +| 4 | 1 | version | `1` | +| 5 | 1 | `afrac` | 1..=30; equal to the model's (§7) | +| 6 | 2 | reserved | `0` | +| 8 | 4 | `c` | ≥ 1: latent channels | +| 12 | 4 | `sh` | ≥ 1: latent height | +| 16 | 4 | `sw` | ≥ 1: latent width | +| 20 | 4 | `s` | ≥ 1: the attribute planes' side | +| 24 | 4 | `attr` | ≥ 1: attribute planes | +| 28 | 4 | `n_gaussians` | 1..=`s·s` | +| 32 | `attr`·8 | ranges | per plane, `f32 lo` then `f32 hi` (IEEE 754 binary32): finite, `lo ≤ hi` | +| … | `c`·2 | table lengths | `u16` each, 3..=4097 | +| … | `c`·4 | symbol offsets | `i32` each | +| … | `c`·8 | medians | `i64` each, `|median| ≤ 2^47` | +| … | the lengths' sum ·4 | one table per channel | §2 | +| … | 4 + `L` | the latent stream | §2 | + +The header's field rules are checked in the order `afrac`, the dimensions, +`n_gaussians`. The caps (§7) apply to `c·sh·sw` and to `attr·s·s`. + +**Decode,** against the set of §9 (`g_s.whm`, the integer synthesis): + +1. For each channel `k` and each of its `sh·sw` elements, decode a symbol `s` + against channel `k`'s table and offset; the element is `s·2^afrac + median_k`. +2. `g_s` on `[c, sh, sw]` gives `[attr, s, s]` (`geometry_mismatch` otherwise). +3. Each value `v` is clamped to `[0, 2^afrac]` and becomes + `(v·255 + 2^(afrac−1)) >> afrac`, a byte. + +The output, plane after plane, is the buffer of the capability's splat-equivalence +digest. The first `n_gaussians` cells of the planes, in raster order, hold one +Gaussian each. The ranges are presentation data: a sink presents a plane's byte `u` +at about `lo + (hi − lo)·u/255`. They are outside the digest, and this extension does +not fix presentation. + +## 7. Caps and the checks a decode makes + +**Caps, the same on every target.** No tensor a payload sizes may exceed `2^26` +elements: a frame `h·w·3`, a latent, a hyper-latent, a network's output, an attribute +tensor, a clip's `t_out`. A `WIV1` clip may not exceed `2^30` bytes. Before a decode +allocates a latent, it derives every network's shapes from its layer geometry on the +payload's shapes, and refuses (`too_large`) a payload on which any network would form +a buffer, its input, a layer's output or a transposed convolution's uncropped plane, +over `2^26` elements. Every size is formed in checked arithmetic, so a 32-bit sink +accepts and refuses exactly what a 64-bit one does. + +**The parameter set must fit the payload** (`model_mismatch`): + +- every network parses (the `WHM1` and `WAM1` forms, §9), and the networks of one set + share one fixed-point scale `afrac` in 1..=30; +- a `WIA1` or `WIS1` payload's `afrac` is the model's; +- the entropy tables of `entropy.json` are consistent (a table, length and offset per + scale edge, at least one edge; a table, length, offset and median per hyper-latent + channel) and every table is a CDF of §2 over its length; a payload has no more + hyper-latent channels than the tables; +- each layer's parameters have the lengths the running channel count needs, and its + geometry leaves an output; +- the audio synthesis gives one channel; the conditional codec's `g_s` gives three. + +**The shapes the payload declares must be the ones the decode gives** +(`geometry_mismatch`): `h_s`'s output and `[2·m, sh, sw]`; a `WIV1` keyframe and +`h × w`; the audio synthesis's length and `t_out` (it may be longer, not shorter); +`g_s`'s output and `[attr, s, s]`. + +**Exactness.** A latent rebuilt as `s·2^afrac + base` must fit `i64`; before each +network layer runs, the decode checks on the values present that no product, partial +sum, requantised value or inverse-GDN term can leave the type its arithmetic is +formed in. A stream whose values fail either check is refused (`field_range`). So a +decode with overflow checks and one without compute the same integers, and neither +panics. + +## 8. Refusal codes + +The list is closed: a sink reports exactly one of these, the first that applies in the +order of §2 and §7, and a refusal is final. A sink does not decode the payload by +another path, and SPEC §4's fallback is not tried. + +| code | when | +|---|---| +| `bad_magic` | the payload does not start with any of the four magics: a tool's JSON form lands here | +| `format_capability_mismatch` | the payload is another of the four formats than the capability's | +| `keyframe_capability_mismatch` | a `WIV1` keyframe kind that is not the capability's | +| `version` | a version this revision does not define | +| `reserved_nonzero` | a reserved field that is not zero | +| `field_range` | a header field outside its range; or a value the stream decodes that leaves the exact integer range (§7) | +| `too_large` | a size past the caps of §7 | +| `cdf_malformed` | a table that breaks the rules of §2 | +| `stream_length` | the payload ends before a field or section it declares; or a stream length that is not a multiple of 4, or is under 8 | +| `nested_length` | a `WIV1` keyframe length `K` that is not the rest of the payload | +| `trailing` | bytes after the last section | +| `stream_exhausted` | a stream that ran out before its last symbol | +| `geometry_mismatch` | a shape the payload declares that the decode does not give (§7) | +| `model_mismatch` | a parameter set that does not fit the payload (§7) | +| `set_incomplete` | a parameter set without a member the decode reads (§9); a pinned decode reports SPEC §3.1's `set_incomplete` | +| `not_dispatchable` | a capability SPEC §4 does not dispatch | +| `unsupported_capability` | a capability the sink has no integer decoder for | +| `payload_malformed` | a `wai.neural.int_synth` or `wai.neural.int_mlicpp` payload its decoder refuses | + +## 9. Parameter sets + +A capability that decodes against a parameter set reads these members, by the names a +set's listing gives them ([prior-carriage](prior-carriage.md) §7): + +| capability | shape | members | +|---|---|---| +| `wai.neural.int_hyper`, `wai.video.int_hyper` | several files | `g_s.whm` and `h_s.whm` (networks in `WHM1`), `entropy.json` (the tables) | +| `wai.audio.int_codec` | one file | `model.wam` (the synthesis in `WAM1`) | +| `wai.splat.int_codec` | one file | `g_s.whm` (the synthesis in `WHM1`) | +| `wai.video.int_motion` | none | — | + +A set of several files is pinned through its listing (`wai.prior.set`); a one-file set +MAY be pinned form-less, by its file's SHA-256 (SPEC §3.1). `wai.audio.int_codec` and +`wai.splat.int_codec` were registered with sets of several files, because no one-file +format had been named for them; this revision registers each as one file, the file its +decoder reads, which holds every parameter its output depends on. That widens their +registrations, as SPEC §8 allows a MINOR revision to; a set pinned through its listing +still verifies. + +**`WHM1`** (`int_hyper_synth::IntModel::from_bytes`): magic, then `afrac`, `gf`, +`shift` and the layer count (`u32` each), then per layer a tag byte: 0 a transposed +convolution and 1 a convolution (input and output channels, kernel, stride, padding and, +for 0, output padding as `u32`; weights as `i16`; per-output-channel multipliers and +biases as `i64`), 2 an inverse GDN (channels as `u32`; `gamma` as `i32`, `beta` as +`i64`), 3 a ReLU, 4 a leaky ReLU (slope `i64`, its shift `u32`). **`WAM1`** +(`int_audio::AudioModel::from_bytes`): the same header, then layers of tag 0, a +one-dimensional transposed convolution, and tag 2, an inverse GDN. The layers' integer +arithmetic is the reference decoders' (`int_hyper_synth.rs`, `int_audio.rs`, +`int_transform.rs`), which the conformance vectors pin. + +## 10. The tools' JSON forms + +The encode and export tools under `tools/` write a JSON bitstream. It is a converter input and a +debugging aid, never a payload. An encoder MUST NOT carry it as the payload of any +capability of §1, and a sink refuses it (`bad_magic`). The converters +(`int_payload::{wiv1,wih1,wia1,wis1}_from_json`, and the independent +`int-payload-conformance/int_payload_verify.py`) make explicit what the JSON leaves +implicit: + +- `WIV1`: an absent `mv_precision` is 1, and only 1 and 2 are converted; `mvs` has one + list per frame, and the first is empty; a frame's list shorter than its block grid is + padded with `(0, 0)`, the vector the JSON decoder read for a missing block; a list + longer than the grid, or a vector outside `i16`, is refused; `rmax` is not carried. + The keyframe of `wai.video.int_hyper` is the `WIH1` converted from its own JSON. +- `WIH1`: the fields of `cond_bitstream.json`, the streams as they are. +- `WIA1`: `sr` is the export's `sample_rate` (in `audio.json`, which the bitstream + does not carry); `lo` is the symbol offset; every channel's table must have the first + table's length; `afrac` is carried and must be the model's. +- `WIS1`: the ranges `lo` and `hi` must each be a binary32 value, carried exactly; + `afrac` is carried and must be the model's. + +A payload and the JSON it is converted from decode to the same bytes; the conformance +vectors check that for every committed JSON bitstream. + +## 11. Envelopes this revision makes non-conforming + +This revision fixes a payload format these strings' registrations did not have (SPEC +§8, *Registering a capability*). It makes non-conforming, and they still parse: + +- a `WAI1` envelope whose `capability` or `fallback` is a string of §1 and whose + payload is not that string's format: a tool's JSON form among them; +- a `WAI2` envelope with a component of a string of §1 whose bytes are not that + string's format. + +A sink refuses such a payload when it decodes it (§8), with no fallback attempt; a +tool that writes envelopes refuses to write one (`wai wrap`). No committed envelope or +conformance corpus carried such a payload when this revision was made. + +## 12. Reference implementation and conformance + +- `int_payload::wire` parses and writes the four formats, with no dependency on the + rest of the crate; `int_payload::decode_int` decodes every integer capability from + parameter files held in memory, with no ONNX runtime and no filesystem, for native + and `wasm32` sinks; `int_payload::resolve` verifies a pin over sets held in memory + (`pin::verify_bytes_with`) and runs SPEC §3.1's check 5, for a pinned decode and over + the set an unpinned decode reads. The neural sink (`neural::decode_payload`) + delegates every integer capability to `decode_int`. +- `codecs::PINNED_INT_PAYLOADS` is the one table of strings and magics; + `container::check_int_payload_framing` checks the magic and version without the + integer decoders, and `wai wrap` and `wai inspect` run it. +- `video_receipt::VideoReceipt::seal_decode_wiv1` seals a `wai.video.int_motion` + receipt whose identity is BLAKE3 over the `WIV1` payload. +- `wai-web` decodes the four formats in the browser (`decodeWiv1`, `decodeWih1`, + `decodeWia1`, `decodeWis1`). +- Conformance: the payloads beside their JSON sources in `wai-rs/tests/vectors/`, + and the refusal fixtures and index in + [`int-payload-conformance/`](../int-payload-conformance/), written by + `wai_int_payload_vectors gen` and checked by `wai_int_payload_vectors verify`; the + same corpus checked by `int_payload_verify.py`, which shares no code with wai-rs and + decodes the `WIV1` keyframe-0 payloads in full; and the cross-architecture goldens of + `byte-exact-conformance`, which parse and decode the `WIV1` and `WIH1` payloads on + every platform of its matrix. diff --git a/wai/extensions/prior-carriage.md b/wai/extensions/prior-carriage.md index a89f0d009..abe3d98d8 100644 --- a/wai/extensions/prior-carriage.md +++ b/wai/extensions/prior-carriage.md @@ -287,7 +287,10 @@ WAI's binding sits beside the standard's, never in place of it. decode through. - The decoders read parameters only through `neural::ParamSource`, which hands a pinned decode the verified bytes; ONNX sessions are built from those bytes, and - a file the verified set lacks is refused with `set_incomplete`. + a file the verified set lacks is refused with `set_incomplete`. The integer + capabilities decode through `int_payload::decode_int`, which reads its set members + from memory; `int_payload::resolve` verifies a pin over sets held in memory and + runs check 5, pinned or unpinned ([integer-payloads](integer-payloads.md) §12). `wai.neural.bmshj2018` caches its CDF tables by content digest. - The C ABI (`ffi`) returns each manifest as its original bytes, carries a `WAI2` rendition's identity and pin in the rendition table, and reports a refused pin @@ -311,6 +314,9 @@ file the set lacks is refused with `set_incomplete`. |---|---|---| | `wai.neural.int_mlicpp` | one file | `model.wmb` | | `wai.neural.int_synth` | one file | `int_synth.json` | +| `wai.audio.int_codec` | one file | `model.wam` | +| `wai.splat.int_codec` | one file | `g_s.whm` | +| `wai.neural.int_hyper`, `wai.video.int_hyper` | several | `g_s.whm`, `h_s.whm`, `entropy.json` | | `wai.neural.encodec32`, `.dac`, `.mimi`, `.wavtokenizer`, `.snac`, `.video_bmshj2018` | one file | `decoder.onnx` | | `wai.neural.bmshj2018` | several | `decoder.onnx`, `cdfs_q.json` | | `wai.neural.mbt2018_mean` | several | `hyper_cdfs_q.json`, `hyper_synthesis.onnx`, `gauss_cdfs_q.json`, `synthesis.onnx` | @@ -319,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.neural.int_hyper`, `wai.video.int_hyper`, `wai.audio.int_codec`, `wai.splat.int_codec`, `wai.generate.spectral` | several, until an envelope decode path registers its files | — (no envelope decode path in the reference sink) | +| `wai.generate.spectral` | several, until an envelope decode path registers its files | — (no envelope decode path in the reference sink) | ## 8. Sources diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index be4ce30d5..29381fcff 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -1212,6 +1212,54 @@ mod tests { assert!(problems.is_empty(), "SPEC and the registry disagree:\n{}", problems.join("\n")); } + /// SPEC §5, *Payloads of the WAI-native integer codecs*, pins exactly the strings + /// and formats of `PINNED_INT_PAYLOADS`, and names each string's parameter-set + /// members as `int_payload` reads them; the extension defines each format in its + /// own section. + #[test] + fn spec_pins_the_integer_payloads_the_registry_lists() { + let spec = read("SPEC.md"); + let at = spec.find("### Payloads of the WAI-native integer codecs").expect("the SPEC §5 subsection"); + let section = &spec[at..]; + let section = §ion[..section[4..].find("\n### ").map_or(section.len(), |e| e + 4)]; + let mut rows: Vec<(String, String, String)> = Vec::new(); + for line in section.lines().filter(|l| l.starts_with("| `wai.")) { + let cells: Vec<&str> = line.split('|').map(str::trim).collect(); + let tick = |c: &str| c.split('`').nth(1).unwrap_or_default().to_string(); + rows.push((tick(cells[1]), tick(cells[2]), cells[3].to_string())); + } + let mut want: Vec<(String, String)> = super::super::PINNED_INT_PAYLOADS.iter() + .map(|(c, m)| (c.to_string(), String::from_utf8_lossy(m).into_owned())).collect(); + let mut got: Vec<(String, String)> = rows.iter().map(|(c, m, _)| (c.clone(), m.clone())).collect(); + want.sort(); + got.sort(); + assert_eq!(got, want, "SPEC's table and PINNED_INT_PAYLOADS"); + // Each row's set: its shape is the registry's, and its members are named. + let members: &[(&str, &[&str])] = &[ + (CAP_VIDEO_INT_MOTION, &[]), + (CAP_VIDEO_INT_HYPER, &["g_s.whm", "h_s.whm", "entropy.json"]), + (CAP_NEURAL_INT_HYPER, &["g_s.whm", "h_s.whm", "entropy.json"]), + (CAP_AUDIO_INT_CODEC, &["model.wam"]), + (CAP_SPLAT_INT_CODEC, &["g_s.whm"]), + ]; + for (cap, _, set) in &rows { + let (_, names) = members.iter().find(|(c, _)| c == cap).expect("a pinned string"); + let shape = match crate::codecs::parameter_set_shape(cap) { + None => "none", + Some(crate::codecs::ParameterSetShape::OneFile) => "one file", + Some(crate::codecs::ParameterSetShape::SeveralFiles) => "several files", + }; + assert!(set.starts_with(shape), "{cap}: SPEC says `{set}`, the registry {shape}"); + for n in *names { + assert!(set.contains(&format!("`{n}`")), "{cap}: SPEC names no `{n}`"); + } + } + let ext = read("extensions/integer-payloads.md"); + for (heading, magic) in [("## 3.", "WIV1"), ("## 4.", "WIH1"), ("## 5.", "WIA1"), ("## 6.", "WIS1")] { + assert!(ext.lines().any(|l| l.starts_with(heading) && l.contains(&format!("`{magic}`"))), "the extension's {heading} is {magic}"); + } + } + #[test] fn vocab_v2_names_every_registered_string_and_no_candidate_as_emittable() { let shapes = vocab_v2_shapes(); diff --git a/wai/wai-rs/src/int_payload/wire.rs b/wai/wai-rs/src/int_payload/wire.rs index 9efa1ea04..602152f9f 100644 --- a/wai/wai-rs/src/int_payload/wire.rs +++ b/wai/wai-rs/src/int_payload/wire.rs @@ -1,4 +1,4 @@ -//! The payload formats of the WAI-native integer codecs ([integer-payloads] §2–§6): +//! The payload formats of the WAI-native integer codecs ([integer-payloads] §3–§6): //! `WIV1` (`wai.video.int_motion`, `wai.video.int_hyper`), `WIH1` //! (`wai.neural.int_hyper`), `WIA1` (`wai.audio.int_codec`) and `WIS1` //! (`wai.splat.int_codec`). A parser and a writer for each, and the closed list of -- 2.54.0 (Apple Git-157) From b2296c6171d35c68dd171bb78ec25a42ceaaa181 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 13:13:31 -0400 Subject: [PATCH 09/34] wai-web: decodeWiv1, decodeWih1, decodeWia1 and decodeWis1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser crate decodes the payloads SPEC §5 pins to the integer codecs through wai::int_payload::decode_int, the Rust a native sink runs, over the parameter files the host passes in, named as a set lists them. decodeWiv1 takes the envelope's capability, so the keyframe kind is checked against it. decodeWia1's output gains sampleRate. decodeWis1's output gains nGaussians and the per-attribute ranges, which are presentation data outside the digest. A refusal's message starts with its code (bad_magic for a JSON form). The entry points that read the tools' JSON forms (decodeIntVideo, decodeIntVideoPframes, decodeIntHyper, decodeIntHyperBin, decodeIntAudio, decodeIntSplat) stay, and are documented as for debugging and conversion. A native test decodes every committed payload through the crate's path to its sidecar's SHA-256. demo/verify_int_payload_wasm.mjs does the same in the wasm32 build under Node and checks that a JSON form is refused. Co-Authored-By: Claude Opus 5.5 --- wai/wai-web/demo/verify_int_payload_wasm.mjs | 65 ++++++++++++++++ wai/wai-web/src/audio_int.rs | 28 ++++++- wai/wai-web/src/int_payload.rs | 81 ++++++++++++++++++++ wai/wai-web/src/int_splat.rs | 45 +++++++++-- wai/wai-web/src/lib.rs | 5 ++ wai/wai-web/src/neural_int.rs | 26 +++++-- wai/wai-web/src/video.rs | 36 +++++++-- 7 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 wai/wai-web/demo/verify_int_payload_wasm.mjs create mode 100644 wai/wai-web/src/int_payload.rs diff --git a/wai/wai-web/demo/verify_int_payload_wasm.mjs b/wai/wai-web/demo/verify_int_payload_wasm.mjs new file mode 100644 index 000000000..556b58300 --- /dev/null +++ b/wai/wai-web/demo/verify_int_payload_wasm.mjs @@ -0,0 +1,65 @@ +// The wasm32 decode of the integer payloads (extensions/integer-payloads.md) equals +// the native decode: each committed WIV1, WIH1, WIA1 and WIS1 payload, decoded by +// decodeWiv1 / decodeWih1 / decodeWia1 / decodeWis1 in the browser crate built for +// wasm32, must give the SHA-256 its vector's sha256.json sidecar states, and a +// tool's JSON form must be refused with bad_magic. +// +// node verify_int_payload_wasm.mjs [path/to/wai_web.js] [path/to/wai-rs/tests/vectors] +// +// 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 vectors = resolve(process.argv[3] ?? resolve(here, '../../wai-rs/tests/vectors')); +const wai = await import(pathToFileURL(pkg).href); + +const rd = (rel) => new Uint8Array(readFileSync(`${vectors}/${rel}`)); +const side = (rel, key) => JSON.parse(readFileSync(`${vectors}/${rel}`, 'utf8'))[key]; +const sha = (b) => createHash('sha256').update(b).digest(); +const hex = (b) => Buffer.from(b).toString('hex'); +const clip = (v) => { + const n = v.width * v.height * 3, rgb = v.rgb, digests = []; + for (let f = 0; f < v.nFrames; f++) digests.push(sha(rgb.subarray(f * n, (f + 1) * n))); + return hex(sha(Buffer.concat(digests))); +}; +const [g, h, e] = [rd('int_hyper/tiny_g_s.bin'), rd('int_hyper/tiny_h_s.bin'), rd('int_hyper/tiny_entropy.json')]; + +const checks = [ + ['int_video (WIV1, keyframe 0)', () => clip(wai.decodeWiv1('wai.video.int_motion', rd('int_video/int_video_bitstream.wiv1'))), + side('int_video/sha256.json', 'int_video').clip_sha256], + ['video_real (WIV1, half-pel)', () => clip(wai.decodeWiv1('wai.video.int_motion', rd('int_video/video_real_bitstream.wiv1'))), + side('int_video/sha256.json', 'video_real').clip_sha256], + ['video_nk (WIV1, keyframe 1)', () => clip(wai.decodeWiv1('wai.video.int_hyper', rd('int_video/video_nk_bitstream.wiv1'), g, h, e)), + side('int_video/sha256.json', 'video_nk').clip_sha256], + ['tiny (WIH1)', () => hex(sha(wai.decodeWih1(rd('int_hyper/tiny_bitstream.wih1'), g, h, e).rgb)), + side('int_hyper/sha256.json', 'tiny').sha256], + ['audio (WIA1)', () => { + const a = wai.decodeWia1(rd('int_audio/audio_bitstream.wia1'), rd('int_audio/audio_model.bin')); + if (a.sampleRate !== 16000) return `sample rate ${a.sampleRate}`; + const pcm = a.pcm; + return hex(sha(Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength))); + }, side('int_audio/sha256.json', 'audio').sha256], + ['splat (WIS1)', () => { + const s = wai.decodeWis1(rd('int_splat/splat.wis1'), rd('int_splat/splat_gs.bin')); + if (s.ranges.length !== 2 * s.nAttr || s.nGaussians !== 1024) return 'ranges or Gaussian count'; + return hex(sha(s.attr)); + }, side('int_splat/sha256.json', 'splat').sha256], + ['a JSON form is refused', () => { + try { wai.decodeWiv1('wai.video.int_motion', rd('int_video/int_video_bitstream.json')); return 'decoded'; } + catch (err) { return String(err.message ?? err).split(':')[0]; } + }, 'bad_magic'], +]; + +let pass = true; +for (const [name, run, want] of checks) { + const got = run(); + const ok = got === want; + pass = pass && ok; + console.log(`${ok ? 'PASS' : 'FAIL'} ${name.padEnd(30)} ${String(got).slice(0, 16)}${ok ? '' : ` want ${want}`}`); +} +process.exit(pass ? 0 : 1); diff --git a/wai/wai-web/src/audio_int.rs b/wai/wai-web/src/audio_int.rs index c149738f4..fc846daad 100644 --- a/wai/wai-web/src/audio_int.rs +++ b/wai/wai-web/src/audio_int.rs @@ -15,6 +15,7 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] pub struct DecodedAudio { pcm: Vec, + sample_rate: u32, } #[wasm_bindgen] @@ -28,16 +29,35 @@ impl DecodedAudio { pub fn n_samples(&self) -> u32 { self.pcm.len() as u32 } + /// The sample rate the `WIA1` payload states, in hertz; `0` from the JSON form, + /// which carries none. + #[wasm_bindgen(getter, js_name = sampleRate)] + pub fn sample_rate(&self) -> u32 { + self.sample_rate + } } -/// Decode a learned-audio model (`audio_model.bin`, WAM1) + bitstream -/// (`audio_bitstream.json`) to int16 PCM in the browser, byte-identical to the -/// native reference. No float, no ML runtime. +/// Decode a `WIA1` payload (`wai.audio.int_codec`, `extensions/integer-payloads.md` +/// §5) to int16 PCM in the browser, byte-identical to the native reference, with its +/// one-file parameter set (`model.wam`, the `WAM1` synthesis). A refusal's message +/// starts with its code (`bad_magic` for a JSON form). No float, no ML runtime. +#[wasm_bindgen(js_name = decodeWia1)] +pub fn decode_wia1(payload: &[u8], model_wam: &[u8]) -> Result { + match crate::int_payload::decode("wai.audio.int_codec", payload, &[("model.wam", model_wam)]).map_err(|e| JsError::new(&e))? { + wai::int_payload::IntDecoded::Pcm { samples, sample_rate, .. } => Ok(DecodedAudio { pcm: samples, sample_rate }), + _ => Err(JsError::new("unsupported_capability: not PCM")), + } +} + +/// Decode a learned-audio model (`audio_model.bin`, WAM1) + the tool's JSON +/// bitstream (`audio_bitstream.json`) to int16 PCM in the browser. For debugging and +/// conversion only: the payload `wai.audio.int_codec` carries is `WIA1` +/// ([`decode_wia1`]). Byte-identical to the native reference. #[wasm_bindgen(js_name = decodeIntAudio)] pub fn decode_int_audio(model_bin: &[u8], bitstream_json: &[u8]) -> Result { let model = wai::int_audio::AudioModel::from_bytes(model_bin).map_err(JsError::new)?; let bs: serde_json::Value = serde_json::from_slice(bitstream_json).map_err(|e| JsError::new(&e.to_string()))?; let pcm = wai::int_audio::decode(&model, &bs).map_err(JsError::new)?; - Ok(DecodedAudio { pcm }) + Ok(DecodedAudio { pcm, sample_rate: 0 }) } diff --git a/wai/wai-web/src/int_payload.rs b/wai/wai-web/src/int_payload.rs new file mode 100644 index 000000000..dfd71c808 --- /dev/null +++ b/wai/wai-web/src/int_payload.rs @@ -0,0 +1,81 @@ +//! The payloads SPEC §5 pins to the WAI-native integer codecs +//! (`extensions/integer-payloads.md`), decoded in the browser: `WIV1` +//! (`wai.video.int_motion`, `wai.video.int_hyper`), `WIH1` (`wai.neural.int_hyper`), +//! `WIA1` (`wai.audio.int_codec`) and `WIS1` (`wai.splat.int_codec`). +//! +//! Every entry point (`decodeWiv1`, `decodeWih1`, `decodeWia1`, `decodeWis1`) runs +//! `wai::int_payload::decode_int`, the same Rust a native sink runs, over the parameter +//! files the host passes in, named as a set lists them. A refusal comes back as an +//! error whose message starts with its code from the extension's closed list +//! (`bad_magic`, `stream_exhausted`, …). The tools' JSON forms are not payloads: the +//! `decodeInt*` entry points that read them are for debugging and conversion only. + +use std::collections::BTreeMap; + +use wai::int_payload::{decode_int, IntDecoded, IntParams}; +use wai::pin::HeldBytes; + +/// Decode `payload` through `cap` against the set members `set` (name, bytes): the +/// pure-Rust half of the bindings, which the native tests run. +pub(crate) fn decode(cap: &str, payload: &[u8], set: &[(&str, &[u8])]) -> Result { + let files: BTreeMap = set.iter().map(|(k, v)| (k.to_string(), *v)).collect(); + let params = if files.is_empty() { IntParams::None } else { IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }) }; + decode_int(cap, payload, ¶ms).map_err(|e| e.to_string()) +} + +/// The conditional codec's three set members, from the host's bytes. +pub(crate) fn int_hyper_set<'a>(g_s: &'a [u8], h_s: &'a [u8], entropy: &'a [u8]) -> [(&'static str, &'a [u8]); 3] { + [("entropy.json", entropy), ("g_s.whm", g_s), ("h_s.whm", h_s)] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(rel: &str) -> Vec { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../wai-rs/tests/vectors").join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display())) + } + fn side(rel: &str, key: &str, field: &str) -> String { + let j: serde_json::Value = serde_json::from_slice(&read(rel)).unwrap(); + j[key][field].as_str().unwrap().to_string() + } + + /// Each committed payload decodes, through the browser crate's path, to the + /// SHA-256 its sidecar states; a JSON form is refused with `bad_magic`. + #[test] + fn the_committed_payloads_decode_to_their_sidecars() { + let (g, h, e) = (read("int_hyper/tiny_g_s.bin"), read("int_hyper/tiny_h_s.bin"), read("int_hyper/tiny_entropy.json")); + let set = int_hyper_set(&g, &h, &e); + let clip = |d: IntDecoded| match d { + IntDecoded::Video { frames, .. } => wai::pin::sha256_hex(&frames.iter().flat_map(|f| wai::pin::sha256(f)).collect::>()), + other => panic!("{other:?}"), + }; + assert_eq!(clip(decode("wai.video.int_motion", &read("int_video/int_video_bitstream.wiv1"), &[]).unwrap()), + side("int_video/sha256.json", "int_video", "clip_sha256")); + assert_eq!(clip(decode("wai.video.int_hyper", &read("int_video/video_nk_bitstream.wiv1"), &set).unwrap()), + side("int_video/sha256.json", "video_nk", "clip_sha256")); + match decode("wai.neural.int_hyper", &read("int_hyper/tiny_bitstream.wih1"), &set).unwrap() { + IntDecoded::Image { rgb, .. } => assert_eq!(wai::pin::sha256_hex(&rgb), side("int_hyper/sha256.json", "tiny", "sha256")), + other => panic!("{other:?}"), + } + let model = read("int_audio/audio_model.bin"); + match decode("wai.audio.int_codec", &read("int_audio/audio_bitstream.wia1"), &[("model.wam", &model)]).unwrap() { + IntDecoded::Pcm { samples, sample_rate: 16_000, .. } => { + let le: Vec = samples.iter().flat_map(|s| s.to_le_bytes()).collect(); + assert_eq!(wai::pin::sha256_hex(&le), side("int_audio/sha256.json", "audio", "sha256")); + } + other => panic!("{other:?}"), + } + let gs = read("int_splat/splat_gs.bin"); + match decode("wai.splat.int_codec", &read("int_splat/splat.wis1"), &[("g_s.whm", &gs)]).unwrap() { + IntDecoded::Attr { bytes, ranges, .. } => { + assert_eq!(wai::pin::sha256_hex(&bytes), side("int_splat/sha256.json", "splat", "sha256")); + assert_eq!(ranges.len(), 14); + } + other => panic!("{other:?}"), + } + let refused = decode("wai.video.int_motion", &read("int_video/int_video_bitstream.json"), &[]).unwrap_err(); + assert!(refused.starts_with("bad_magic"), "{refused}"); + } +} diff --git a/wai/wai-web/src/int_splat.rs b/wai/wai-web/src/int_splat.rs index 9e557b7cf..a1e6b462a 100644 --- a/wai/wai-web/src/int_splat.rs +++ b/wai/wai-web/src/int_splat.rs @@ -14,12 +14,14 @@ use wai::int_hyper_synth::IntModel; /// A decoded splat: the `[n_attr · s · s]` `u8` attribute tensor (channel-major) — /// un-normalized to Gaussians (pos/scale/rot/color/opacity) on the JS side using the -/// per-attribute ranges in the bitstream. +/// per-attribute ranges the payload carries (`ranges`). #[wasm_bindgen] pub struct DecodedSplat { attr: Vec, n_attr: u32, s: u32, + n_gaussians: u32, + ranges: Vec, } #[wasm_bindgen] @@ -36,16 +38,47 @@ impl DecodedSplat { pub fn s(&self) -> u32 { self.s } + /// Gaussians in the cloud: the first `nGaussians` cells of each `s × s` plane. + #[wasm_bindgen(getter, js_name = nGaussians)] + pub fn n_gaussians(&self) -> u32 { + self.n_gaussians + } + /// Per attribute, `lo` then `hi`: the range its `u8` plane is presented over. + /// Presentation only: the splat-equivalence digest covers `attr`, not these. + #[wasm_bindgen(getter)] + pub fn ranges(&self) -> Box<[f32]> { + self.ranges.clone().into_boxed_slice() + } } -/// Decode a learned-splat model (`splat_gs.bin`, WHM1) + bitstream (`splat.json`) to -/// the integer attribute tensor in the browser, byte-identical to the native -/// reference. No float, no ML runtime. +/// Decode a `WIS1` payload (`wai.splat.int_codec`, `extensions/integer-payloads.md` +/// §6) to the integer attribute tensor in the browser, byte-identical to the native +/// reference, with its one-file parameter set (`g_s.whm`, the `WHM1` synthesis), and +/// the per-attribute ranges and Gaussian count the payload carries. A refusal's +/// message starts with its code (`bad_magic` for a JSON form). No float, no ML runtime. +#[wasm_bindgen(js_name = decodeWis1)] +pub fn decode_wis1(payload: &[u8], g_s: &[u8]) -> Result { + match crate::int_payload::decode("wai.splat.int_codec", payload, &[("g_s.whm", g_s)]).map_err(|e| JsError::new(&e))? { + wai::int_payload::IntDecoded::Attr { bytes, n_attr, s, n_gaussians, ranges } => Ok(DecodedSplat { + attr: bytes, n_attr, s, n_gaussians, ranges: ranges.iter().flat_map(|&(lo, hi)| [lo, hi]).collect(), + }), + _ => Err(JsError::new("unsupported_capability: not an attribute tensor")), + } +} + +/// Decode a learned-splat model (`splat_gs.bin`, WHM1) + the tool's JSON bitstream +/// (`splat.json`) to the integer attribute tensor in the browser. For debugging and +/// conversion only: the payload `wai.splat.int_codec` carries is `WIS1` +/// ([`decode_wis1`]). Byte-identical to the native reference. #[wasm_bindgen(js_name = decodeIntSplat)] pub fn decode_int_splat(gs_bin: &[u8], bitstream_json: &[u8]) -> Result { let gs = IntModel::from_bytes(gs_bin).map_err(JsError::new)?; let bs: serde_json::Value = serde_json::from_slice(bitstream_json).map_err(|e| JsError::new(&e.to_string()))?; - let d = wai::int_splat::decode(&gs, &bs).map_err(JsError::new)?; - Ok(DecodedSplat { attr: d.attr, n_attr: d.n_attr as u32, s: d.s as u32 }) + let j = wai::int_splat::SplatJson::from_value(&bs).map_err(JsError::new)?; + let d = wai::int_splat::decode_typed(&gs, &j.stream()).map_err(|r| JsError::new(r.detail))?; + Ok(DecodedSplat { + attr: d.attr, n_attr: d.n_attr as u32, s: d.s as u32, n_gaussians: j.n_gaussians as u32, + ranges: j.ranges.iter().flat_map(|&(lo, hi)| [lo as f32, hi as f32]).collect(), + }) } diff --git a/wai/wai-web/src/lib.rs b/wai/wai-web/src/lib.rs index 7d3768260..b63a00791 100644 --- a/wai/wai-web/src/lib.rs +++ b/wai/wai-web/src/lib.rs @@ -37,6 +37,11 @@ pub mod neural_int; // portable hash. pub mod generate; +// The payloads SPEC §5 pins to the integer codecs (WIV1, WIH1, WIA1, WIS1), decoded +// through wai::int_payload::decode_int: the shared half of decodeWiv1, decodeWih1, +// decodeWia1 and decodeWis1. +mod int_payload; + // Byte-exact integer inter-frame VIDEO (wai.video.int_motion / int_hyper) — pure // i64, no ML runtime; frame-state-equivalence, decoded + per-frame-verified in-page. pub mod video; diff --git a/wai/wai-web/src/neural_int.rs b/wai/wai-web/src/neural_int.rs index 7194af16c..a2bc5dffa 100644 --- a/wai/wai-web/src/neural_int.rs +++ b/wai/wai-web/src/neural_int.rs @@ -54,7 +54,23 @@ pub fn decode_int_synth(payload: &[u8], spec_json: &[u8]) -> Result Result { + let set = crate::int_payload::int_hyper_set(g_s, h_s, entropy); + match crate::int_payload::decode("wai.neural.int_hyper", payload, &set).map_err(|e| JsError::new(&e))? { + wai::int_payload::IntDecoded::Image { rgb, width, height } => Ok(DecodedImage { rgb, width, height }), + _ => Err(JsError::new("unsupported_capability: not an image")), + } +} + +/// Decode the tool's JSON conditional bitstream → RGB in the browser. For debugging +/// and conversion only: the payload `wai.neural.int_hyper` carries is `WIH1` +/// ([`decode_wih1`]). The rest of this note describes the decode both share: /// integer-exact (`wai.neural.int_hyper`). The host installs the model /// (`int_synth_sota_model.json` = g_s, `int_hs_model.json` = h_s, /// `cond_entropy.json` = the entropy tables); WAI carries the bitstream @@ -73,10 +89,10 @@ pub fn decode_int_hyper( cond_decode(IntModel::from_json(&parse(g_s_json)?), IntModel::from_json(&parse(h_s_json)?), entropy_json, bitstream_json) } -/// Same decode from the **compact binary** model (`*_model.bin`, ~½ the JSON -/// size — int12 weights as `i16`). This is the path the in-browser demo uses: -/// the model download is the bottleneck, and the binary roughly halves it while -/// the decode stays byte-identical to the JSON model and to the native reference. +/// Same decode from the **compact binary** model (`*_model.bin`, int12 weights as +/// `i16`), of the tool's JSON bitstream. For debugging and conversion only: the +/// payload is `WIH1` ([`decode_wih1`]). Byte-identical to the JSON model and to the +/// native reference. #[wasm_bindgen(js_name = decodeIntHyperBin)] pub fn decode_int_hyper_bin( g_s_bin: &[u8], h_s_bin: &[u8], entropy_json: &[u8], bitstream_json: &[u8], diff --git a/wai/wai-web/src/video.rs b/wai/wai-web/src/video.rs index 1e6f70d7d..8733c4f8c 100644 --- a/wai/wai-web/src/video.rs +++ b/wai/wai-web/src/video.rs @@ -40,10 +40,32 @@ impl DecodedVideo { } } -/// Decode a deterministic-integer inter-frame bitstream (`int_video_bitstream.json`) -/// to RGB frames in the browser, byte-identical to the native reference. No float, -/// no ML runtime. `fnv1aHex` (the `generate` binding) hashes each frame for the -/// in-page receipt check. +/// Decode a `WIV1` payload (`extensions/integer-payloads.md` §3) to RGB frames in +/// the browser, byte-identical to the native reference: `capability` is the +/// envelope's, `wai.video.int_motion` (keyframe 0, no parameters) or +/// `wai.video.int_hyper` (keyframe 1, whose nested `WIH1` keyframe decodes with the +/// conditional codec's `g_s.whm`, `h_s.whm` and `entropy.json`). A refusal's message +/// starts with its code (`bad_magic` for a JSON form). No float, no ML runtime. +#[wasm_bindgen(js_name = decodeWiv1)] +pub fn decode_wiv1( + capability: &str, payload: &[u8], g_s: Option>, h_s: Option>, entropy: Option>, +) -> Result { + let (g, h, e) = (g_s.unwrap_or_default(), h_s.unwrap_or_default(), entropy.unwrap_or_default()); + let set = crate::int_payload::int_hyper_set(&g, &h, &e); + let set: &[(&str, &[u8])] = if capability == "wai.video.int_hyper" { &set } else { &[] }; + match crate::int_payload::decode(capability, payload, set).map_err(|e| JsError::new(&e))? { + wai::int_payload::IntDecoded::Video { frames, width, height } => { + let n_frames = frames.len() as u32; + Ok(DecodedVideo { rgb: frames.concat(), n_frames, width, height }) + } + _ => Err(JsError::new("unsupported_capability: not an integer video capability")), + } +} + +/// Decode the tool's JSON bitstream (`int_video_bitstream.json`) to RGB frames. For +/// debugging and conversion only: the payload `wai.video.int_motion` carries is +/// `WIV1` ([`decode_wiv1`]), and a JSON form is not one. Byte-identical to the +/// native reference; `fnv1aHex` (the `generate` binding) hashes each frame. #[wasm_bindgen(js_name = decodeIntVideo)] pub fn decode_int_video(bitstream_json: &[u8]) -> Result { let bs: serde_json::Value = @@ -52,10 +74,12 @@ pub fn decode_int_video(bitstream_json: &[u8]) -> Result Ok(flatten(clip)) } -/// Decode the **P-frames** of a clip on top of an already-decoded I-frame +/// Decode the **P-frames** of a JSON bitstream on top of an already-decoded I-frame /// (`i_frame`, RGB HWC `u8`, e.g. from `decodeIntHyperBin`) — the NEURAL-KEYFRAME /// path (`wai.video.int_hyper`): a learned `int_hyper` keyframe + integer -/// inter-frames, the whole clip byte-exact. No float, no ML runtime. +/// inter-frames, the whole clip byte-exact. For debugging and conversion only: the +/// payload is `WIV1` with its keyframe nested ([`decode_wiv1`]). No float, no ML +/// runtime. #[wasm_bindgen(js_name = decodeIntVideoPframes)] pub fn decode_int_video_pframes(i_frame: &[u8], w: u32, h: u32, bitstream_json: &[u8]) -> Result { let bs: serde_json::Value = -- 2.54.0 (Apple Git-157) From 94089c356191e61a8bbcf66c6338bd40feb08cb4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 13:14:28 -0400 Subject: [PATCH 10/34] ci: run the integer payload tests, generator, verifier and per-feature checks wai-conformance.yml: - Both fast-kernel steps, with overflow checks on and off, run tests/int_payload.rs and tests/int_payload_preflight.rs beside --lib. --lib carries int_payload's unit tests, among them decode_int_without_neural, since the step's features include no `neural`. - A new unix step runs `cargo check --lib` for neural_int, video_int, audio_int and splat_int, each alone, and for wasm32 with the three media features. - The corpus loops, unix and Windows, add video_int, audio_int and splat_int to F and verify ../int-payload-conformance with wai_int_payload_vectors. - The independent-verifiers job runs int_payload_verify.py. wai-byte-equality.yml fires on int_payload/wire.rs, which the crate now includes. wai-neural-gates.yml fires on the integer decoders, int_payload and the vectors its tests read. It runs int_hyper_decodes_through_the_neural_dispatch in the model-free list, and integer_media_decode_through_the_neural_dispatch in a step with the media features. wai-web-wasm.yml checks the wasm32 decodeWiv1, decodeWih1, decodeWia1 and decodeWis1 against the committed payloads' sidecars. wai-energy-receipts.yml already fires on every change under wai/wai-rs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-byte-equality.yml | 5 ++- .github/workflows/wai-conformance.yml | 47 +++++++++++++++++++++---- .github/workflows/wai-neural-gates.yml | 37 ++++++++++++++++++- .github/workflows/wai-web-wasm.yml | 6 ++++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/.github/workflows/wai-byte-equality.yml b/.github/workflows/wai-byte-equality.yml index a4f938924..6f0f8ec05 100644 --- a/.github/workflows/wai-byte-equality.yml +++ b/.github/workflows/wai-byte-equality.yml @@ -14,7 +14,8 @@ name: wai cross-architecture byte-equality # (`wai.video.int_hyper`) whose I-frame comes out of a learned conditional codec, # and the MLIC++-topology codec (`wai.neural.int_mlicpp`), whose entropy network runs # before every checkerboard pass and must give every platform the encoder's scale -# bucket for every element. Video is the strictest leg — inter-frame decode is a +# bucket for every element. The binary payloads SPEC §5 pins (WIV1, WIH1) are parsed +# with the wire parser a sink runs and decoded to the same digests. Video is the strictest leg — inter-frame decode is a # feedback loop, so one divergent half-pel rounding does not stay one byte, it # predicts the next frame and compounds for the rest of the clip. # @@ -38,6 +39,7 @@ on: - 'wai/wai-rs/src/int_transform.rs' - 'wai/wai-rs/src/int_hyper_synth.rs' - 'wai/wai-rs/src/int_mlicpp.rs' + - 'wai/wai-rs/src/int_payload/wire.rs' # The fast kernels int_synth, int_transform and int_hyper_synth dispatch to # by default: the goldens here decode through them. - 'wai/wai-rs/src/int_kernels.rs' @@ -62,6 +64,7 @@ on: - 'wai/wai-rs/src/int_transform.rs' - 'wai/wai-rs/src/int_hyper_synth.rs' - 'wai/wai-rs/src/int_mlicpp.rs' + - 'wai/wai-rs/src/int_payload/wire.rs' # The fast kernels int_synth, int_transform and int_hyper_synth dispatch to # by default: the goldens here decode through them. - 'wai/wai-rs/src/int_kernels.rs' diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 4862d0178..b49e29d5a 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -153,7 +153,14 @@ jobs: # decode allocates anything sized from it. # tests/int_audio.rs and tests/int_splat.rs decode the committed wai.audio.int_codec and # wai.splat.int_codec vectors against the hashes the Python integer reference computed at - # export, and check each vector's metadata. Cargo.toml declares them, and the other + # export, and check each vector's metadata. `--lib` runs int_payload's unit tests: each + # binary payload (WIV1, WIH1, WIA1, WIS1) against the JSON it is converted from, under + # every kernel policy, and decode_int for every integer capability in this build, which + # has no `neural`. tests/int_payload.rs refuses every fixture of int-payload-conformance + # with its code, every strict prefix of every payload, and runs a byte-change sweep in + # which nothing may panic; tests/int_payload_preflight.rs (its own binary, a counting + # allocator) checks that a header past the caps is refused before anything sized from it + # is allocated. Cargo.toml declares them, and the other # feature-gated targets named here, with required-features, so if this step ever loses a # feature, cargo stops with an error instead of compiling an empty binary that reports ok. # Each committed vector's decoded output is also checked against the SHA-256 in @@ -163,10 +170,24 @@ jobs: # wai.generate.spectral vector, which `generate` enables. - 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 --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate + run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate --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 - 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 --lib --test int_kernels_vectors --test int_kernels_geometry --test int_mlicpp --test int_mlicpp_preflight --test int_audio --test int_splat --test generate + run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate --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 + # 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`. + - name: Each integer feature alone, and wasm32 (unix) + if: matrix.os != 'windows' + shell: bash + working-directory: wai/wai-rs + run: | + set -euo pipefail + for f in neural_int video_int audio_int splat_int; do + cargo check --lib --no-default-features --features "$f" + done + rustup target add --toolchain 1.98.0 wasm32-unknown-unknown + cargo check --lib --target wasm32-unknown-unknown --no-default-features --features video_int,audio_int,splat_int # carriage: every registered capability round-trips byte-exact. cli_wrap_intent and # cli_registry run the `wai` binary, which builds without the codecs feature: the # intent rule of `wai wrap`, its refusal of candidate capabilities (SPEC §5), its media @@ -237,7 +258,7 @@ jobs: working-directory: wai/wai-rs run: | set -euo pipefail - F=world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer,receipt_claims,key_discovery + F=world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer,receipt_claims,key_discovery,video_int,audio_int,splat_int v() { echo "::group::$1"; cargo run -q --no-default-features --features "$F" --bin "$2" -- verify "$3"; echo "::endgroup::"; } v worlds wai_world_vectors ../world-conformance v spatial-audio wai_audio_vectors ../audio-conformance @@ -255,6 +276,7 @@ jobs: v claims wai_claim_vectors ../claim-conformance v pin wai_pin_vectors ../pin-conformance v signers wai_signer_vectors ../signer-conformance + v int-payload wai_int_payload_vectors ../int-payload-conformance # The pin corpus's second implementation: Python standard library only, sharing no # code with wai-rs. One leg is enough — it checks digests and listing rules, which # do not depend on the architecture — and the macOS host has python3. @@ -283,7 +305,7 @@ jobs: shell: pwsh working-directory: wai/wai-rs run: | - $F = "world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer,receipt_claims,key_discovery" + $F = "world,audio_scene,haptic,splat4d,score,provenance,neural_int,film,quantum,quantum_ops,oer,receipt_claims,key_discovery,video_int,audio_int,splat_int" function v($grp, $bin, $path) { Write-Output "::group::$grp" cargo run -q --no-default-features --features $F --bin $bin -- verify $path @@ -306,6 +328,7 @@ jobs: v claims wai_claim_vectors ../claim-conformance v pin wai_pin_vectors ../pin-conformance v signers wai_signer_vectors ../signer-conformance + v int-payload wai_int_payload_vectors ../int-payload-conformance # The generated v2 vocabulary (wai/vocab/v2.*) is checked as a consumer uses it: # sample manifests, typed and untyped, WAI1 and WAI2, are expanded through the @@ -335,11 +358,12 @@ jobs: # differential.py (both verifiers' verdicts on generated inputs the corpus does not hold, # under a fixed seed, so a run is reproducible), jwp_verify.py (every JWP vector), and # signer_verify.py (every key-discovery vector and the canonical fixture, with its - # verdicts required to equal wai_signer_vectors' line for line). + # verdicts required to equal wai_signer_vectors' line for line), and + # int_payload_verify.py (the integer payloads and their refusal fixtures). # Each exits non-zero on any failure or any difference, and the step fails with it. # One platform: what they check is the reading of the text, not the architecture. independent-verifiers: - name: independent verifiers · claims, JWP and key discovery (Python) + name: independent verifiers · claims, JWP, key discovery and integer payloads (Python) runs-on: linux steps: - uses: actions/checkout@v5 @@ -371,6 +395,15 @@ jobs: shell: bash run: cargo build --no-default-features --features key_discovery --bin wai_claim_vectors --bin wai_jwp_vectors --bin wai_signer_vectors + # The integer payload corpus's second implementation (int-payload-conformance/README): + # it parses every payload with the extension's rules and check order, converts every + # JSON source independently and requires the committed bytes, decodes the WIV1 + # keyframe-0 payloads in full to their sidecars' SHA-256, and checks every refusal + # fixture. Standard library only, so it needs nothing installed. + - name: int_payload_verify.py + working-directory: wai + shell: bash + run: python3 int-payload-conformance/int_payload_verify.py int-payload-conformance - name: claim_verify.py, differential.py, jwp_verify.py, signer_verify.py working-directory: wai shell: bash diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index 4952426eb..4c6d06571 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -50,6 +50,17 @@ on: - 'wai/wai-rs/tests/neural_native_decode.rs' - 'wai/wai-rs/src/int_mlicpp.rs' - 'wai/wai-rs/tests/vectors/int_mlicpp/**' + # The integer capabilities the neural sink delegates to int_payload::decode_int, + # and the vectors its tests decode through it. + - 'wai/wai-rs/src/int_payload/**' + - 'wai/wai-rs/src/int_video.rs' + - 'wai/wai-rs/src/int_hyper_synth.rs' + - 'wai/wai-rs/src/int_audio.rs' + - 'wai/wai-rs/src/int_splat.rs' + - 'wai/wai-rs/tests/vectors/int_hyper/**' + - 'wai/wai-rs/tests/vectors/int_video/**' + - 'wai/wai-rs/tests/vectors/int_audio/**' + - 'wai/wai-rs/tests/vectors/int_splat/**' - 'wai/wai-rs/Cargo.toml' - 'wai/wai-rs/Cargo.lock' - '.github/workflows/wai-neural-gates.yml' @@ -75,6 +86,17 @@ on: - 'wai/wai-rs/tests/neural_native_decode.rs' - 'wai/wai-rs/src/int_mlicpp.rs' - 'wai/wai-rs/tests/vectors/int_mlicpp/**' + # The integer capabilities the neural sink delegates to int_payload::decode_int, + # and the vectors its tests decode through it. + - 'wai/wai-rs/src/int_payload/**' + - 'wai/wai-rs/src/int_video.rs' + - 'wai/wai-rs/src/int_hyper_synth.rs' + - 'wai/wai-rs/src/int_audio.rs' + - 'wai/wai-rs/src/int_splat.rs' + - 'wai/wai-rs/tests/vectors/int_hyper/**' + - 'wai/wai-rs/tests/vectors/int_video/**' + - 'wai/wai-rs/tests/vectors/int_audio/**' + - 'wai/wai-rs/tests/vectors/int_splat/**' - 'wai/wai-rs/Cargo.toml' - 'wai/wai-rs/Cargo.lock' - '.github/workflows/wai-neural-gates.yml' @@ -115,8 +137,21 @@ jobs: pinned_decode_reads_only_verified_bytes wai2_decode_multi_picks_the_verified_rendition \ strict_gate_reads_the_capability_it_decodes_through set_incomplete_at_step_3_outranks_the_fallback_refusal \ decode_multi_strict_reports_replicate_reasons strict_gate_ignores_a_held_capability_it_cannot_dispatch \ + int_hyper_decodes_through_the_neural_dispatch \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 12 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 13 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + # The integer video, audio and splat capabilities through the neural dispatch, which + # delegates them to int_payload::decode_int: each decodes to Decoded::Int with its + # committed SHA-256, and wai.video.int_motion is advertised with ModelRegistry::builtin. + - name: The neural sink decodes the integer video, audio and splat payloads + working-directory: wai/wai-rs + shell: bash + run: | + set -euo pipefail + cargo test --no-default-features --features neural,video_int,audio_int,splat_int --test neural_native_decode -- \ + --exact integer_media_decode_through_the_neural_dispatch \ + 2>&1 | tee "$RUNNER_TEMP/neural-int.log" + grep -q "test result: ok. 1 passed; 0 failed" "$RUNNER_TEMP/neural-int.log" - name: A key log refuses a revoked pinned set at the neural sink (SPEC §3.1 check 5) working-directory: wai/wai-rs shell: bash diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 23a141843..2ef7b5ac9 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -89,6 +89,12 @@ jobs: shell: bash working-directory: wai/wai-web run: node demo/verify_int_synth_wasm.mjs ./pkg-node/wai_web.js ../neural-conformance + # decodeWiv1, decodeWih1, decodeWia1 and decodeWis1 on the committed integer + # payloads give the SHA-256 their sidecars state, and a JSON form is refused. + - name: wasm32 integer payload decode equals the native sidecars + shell: bash + working-directory: wai/wai-web + run: node demo/verify_int_payload_wasm.mjs ./pkg-node/wai_web.js ../wai-rs/tests/vectors - name: The browser menu is registered and names no candidate shell: bash working-directory: wai/wai-web -- 2.54.0 (Apple Git-157) From adffd6489c10b718548aa121f5f84cdb3dcc711e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 13:14:29 -0400 Subject: [PATCH 11/34] wai/CHANGELOG: the integer codecs' payloads pinned in place A new subsection at the top of v0.4.0. It records the WIV1, WIH1, WIA1 and WIS1 payloads pinned under the existing strings, and why that is a pin and not a redefinition. It records that the JSON forms are converter inputs, lists the envelopes this makes non-conforming, and records the one integer decode path and its closed refusal codes. It also records the checked decoders, the parameter-set members and the audio and splat widenings to one file, the WIV1 identity of video receipts with legacy receipts unchanged, the jwp_hash module, the browser entry points, and the conformance corpus and CI. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index e9b1ef611..0d3c73f76 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,72 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### The integer codecs' payloads pinned in place: `WIV1`, `WIH1`, `WIA1`, `WIS1` + +- **Pinned in place** (SPEC §5, *Payloads of the WAI-native integer codecs*, and the + new extension [integer-payloads](extensions/integer-payloads.md)): the payload of + `wai.video.int_motion` is `WIV1` with keyframe 0; of `wai.video.int_hyper`, `WIV1` + with keyframe 1, a nested `WIH1`; of `wai.neural.int_hyper`, `WIH1`; of + `wai.audio.int_codec`, `WIA1`, which carries the sample rate; of + `wai.splat.int_codec`, `WIS1`, which carries each attribute's presentation range. + These strings' rows named a decoder and the tool that wrote a bitstream, and no byte + layout, so the pin completes their registration and redefines nothing; from this + revision their formats are fixed as SPEC §8 states. No new string is registered. + `codecs::PINNED_INT_PAYLOADS` is the one table of strings and magics. +- **The JSON is a converter input, never a payload.** The encode and export tools + still write JSON; `int_payload::{wiv1,wih1,wia1,wis1}_from_json` convert it and + state what it left implicit (an absent `mv_precision` is 1; the first frame carries + no motion vectors; a short motion-vector list is padded with `(0, 0)`; the audio + sample rate comes from the export's `audio.json`; a splat range must be a binary32 + value; the JSON `afrac` must be the model's). *Envelopes this makes + non-conforming:* a `WAI1` or `WAI2` that carries anything but the pinned format + under these strings, a tool's JSON output among them. It still parses; a sink + refuses it at decode with `bad_magic` and does not try the fallback, and `wai wrap` + refuses to write it (`container::check_int_payload_framing`, also reported by + `wai inspect`). No committed envelope or conformance corpus carried one. +- **One integer decode path.** `int_payload::decode_int` decodes the strings above + and `wai.neural.int_synth` and `wai.neural.int_mlicpp` from parameter files held in + memory, with no ONNX runtime and no filesystem, natively and for `wasm32`, through a + dispatchable capability only. A refusal is one code from a closed list + ([integer-payloads](extensions/integer-payloads.md) §8). `int_payload::resolve` + verifies a pin over sets held in memory and runs SPEC §3.1's check 5 for a pinned + decode and over the set an unpinned decode reads. The neural sink delegates every + integer capability to it: integer video, PCM and splat attributes come back as + `neural::Decoded::Int`, never converted; `ModelRegistry::builtin` advertises + `wai.video.int_motion`, which reads no set; the C ABI refuses an `Int` output. +- **Checked decoders.** `int_video`, `int_audio` and `int_splat` decode a typed + stream that the binary and JSON forms both read into. Every size is capped at + `2^26` elements (a `WIV1` clip at `2^30` bytes) the same on every target, every + network's shapes are planned before anything is allocated, every latent and layer + is checked for exactness, and a stream that runs out is refused, so a build with + overflow checks and one without accept the same bytes. `int_hyper_synth` gains + `decode_conditional_checked`; `int_synth` refuses a side of `2^64` or more instead of + shifting by it. +- **Parameter sets.** SPEC §5 names the members the integer decoders read: + `g_s.whm`, `h_s.whm` and `entropy.json` for `wai.neural.int_hyper` and + `wai.video.int_hyper`. `wai.audio.int_codec` (`model.wam`) and + `wai.splat.int_codec` (`g_s.whm`) are registered as one file, which widens their + registrations (SPEC §8). `wai registry export` writes nothing new: no exported field + records a set's shape. +- **Receipts.** A `wai.video.int_motion` receipt identifies its `WIV1` payload + (`VideoReceipt::seal_decode_wiv1`, `verify_reconstruction_wiv1`), and + `wai_video_meter` seals over it. The entry points that read the JSON form are + deprecated and unchanged, so every receipt sealed over it, and every pinned digest + of one, still verifies. jwp-receipts §2.1's object content hash moves to its own + module and feature (`jwp_hash`), which `receipt_claims` enables; + `claim_receipt::object_content_hash` re-exports it, and no corpus changed. +- **Browser.** `wai-web` decodes the four formats (`decodeWiv1`, `decodeWih1`, + `decodeWia1`, `decodeWis1`); the entry points that read the JSON forms are for + debugging and conversion. +- **Conformance.** The payloads sit beside their JSON sources in + `wai-rs/tests/vectors/`, written by `wai_int_payload_vectors`, with refusal fixtures + and an index in `int-payload-conformance/`; `int_payload_verify.py` checks them + independently in Python's standard library and decodes the `WIV1` keyframe-0 + payloads in full; `byte-exact-conformance` parses and decodes the `WIV1` and `WIH1` + payloads on every leg of its matrix. CI runs the new tests in both build profiles, + the generator in the corpus loop, the verifier, a `cargo check` of each integer + feature alone, and a `wasm32` check. + ### The registry as one table, candidate capabilities, the codec glossary under test, and the public text retrofit - **Candidate capabilities** (SPEC §5): a registered string whose payload format no -- 2.54.0 (Apple Git-157) From 3fbff62e2022deb699771a09af90a89ee950f954 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 18:45:06 -0400 Subject: [PATCH 12/34] wai: check 5 runs over the bytes an integer decode reads An unpinned integer decode now reads exactly the files check 5 identified. - int_payload::resolve, unpinned: a listing the held set carries must be well formed and match its files (set_malformed, part_mismatch), and the decode reads only the capability's members it lists. Check 5 runs under the listing's digest, the digest of the listing of the members read, and a one-file set's file digest, so a set held with an empty listing is no longer decoded under a revoked digest. - IntParams is opaque: only resolve builds one that holds a set, and IntParams::none() holds none. The pinned constructors are crate-private, and the verified-reference lookup is bounds-checked. - The neural registry reads an integer capability's set once, from the paths one function gives (inside a registered model directory included), runs check 5 over those bytes and hands the decode the same bytes (ParamSource::UnpinnedRead; ParamSource is now non_exhaustive). A form-less pin over wai.neural.int_synth resolves against a directory registration too. - A member the unpinned set lacks is refused set_incomplete after the payload's framing is checked, so dispatch moves on. Tests: int_payload::tests::unpinned_check_5_covers_the_files_the_decode_reads and neural_native_decode::int_synth_check_5_covers_the_spec_a_model_directory_holds (added to the neural gates step). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-neural-gates.yml | 4 +- wai/wai-rs/src/bin/wai_int_payload_vectors.rs | 3 +- wai/wai-rs/src/int_payload/mod.rs | 166 +++++++++++++----- wai/wai-rs/src/int_payload/tests.rs | 105 ++++++++--- wai/wai-rs/src/neural/mod.rs | 130 ++++++++++---- wai/wai-rs/src/neural/params.rs | 17 +- wai/wai-rs/tests/int_payload.rs | 5 +- wai/wai-rs/tests/int_payload_preflight.rs | 11 +- wai/wai-rs/tests/neural_native_decode.rs | 64 +++++++ wai/wai-web/src/int_payload.rs | 5 +- 10 files changed, 384 insertions(+), 126 deletions(-) diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index 4c6d06571..2f77edeef 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -137,9 +137,9 @@ jobs: pinned_decode_reads_only_verified_bytes wai2_decode_multi_picks_the_verified_rendition \ strict_gate_reads_the_capability_it_decodes_through set_incomplete_at_step_3_outranks_the_fallback_refusal \ decode_multi_strict_reports_replicate_reasons strict_gate_ignores_a_held_capability_it_cannot_dispatch \ - int_hyper_decodes_through_the_neural_dispatch \ + int_hyper_decodes_through_the_neural_dispatch int_synth_check_5_covers_the_spec_a_model_directory_holds \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 13 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 14 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" # The integer video, audio and splat capabilities through the neural dispatch, which # delegates them to int_payload::decode_int: each decodes to Decoded::Int with its # committed SHA-256, and wai.video.int_motion is advertised with ModelRegistry::builtin. diff --git a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs index 8b7e694a5..8a77d9ee6 100644 --- a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs +++ b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs @@ -327,7 +327,8 @@ fn decode(t: &Tree, cap: &str, payload: &[u8], set: &Value) -> Result> = set.as_object().into_iter().flatten() .map(|(k, v)| (k.clone(), t.read(v.as_str().expect("a set path")))).collect(); let view: BTreeMap = owned.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); - let params = if view.is_empty() { IntParams::None } else { IntParams::Unverified(HeldBytes::Set { listing: b"", files: &view }) }; + let params = wai::int_payload::resolve(cap, None, &[HeldBytes::Set { listing: b"", files: &view }], None) + .unwrap_or_else(|_| IntParams::none()); decode_int(cap, payload, ¶ms) } diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index b0cfb8fdf..9838aa303 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -112,38 +112,63 @@ pub fn set_members(cap: &str) -> &'static [&'static str] { /// The parameter set one integer decode reads, in memory. /// -/// A pinned decode reads only bytes its pin verified: [`IntParams::Verified`] holds -/// what `pin::verify` returned (a native sink), [`IntParams::Held`] the held set -/// `pin::verify_bytes` matched and, for the form `wai.prior.set`, the paths its -/// listing names (a sink with no filesystem). An unpinned decode reads the set the -/// sink registers for the capability, unverified ([`IntParams::Unverified`]). -/// [`resolve`] builds the right one from a pin and the sets a sink holds. +/// A pinned decode reads only bytes its pin verified: what `pin::verify` returned (a +/// native sink), or the held set `pin::verify_bytes` matched and, for the form +/// `wai.prior.set`, the paths its listing names (a sink with no filesystem). An +/// unpinned decode reads the set the sink registers for the capability: only the +/// files check 5 was run over ([`resolve`]). +/// +/// Only [`resolve`] builds one that holds parameters, so a decode never reads a set +/// that no pin verified while reporting it pinned, nor an unpinned set other than the +/// one check 5 identified. [`IntParams::none`] holds none. #[derive(Debug, Clone)] -#[non_exhaustive] -pub enum IntParams<'a> { - /// The capability decodes against no parameter set (`wai.video.int_motion`). +pub struct IntParams<'a>(Params<'a>); + +#[derive(Debug, Clone)] +enum Params<'a> { + /// No parameter set: a capability that decodes against none (`wai.video.int_motion`). None, - /// A set a pin verified, as the bytes that were hashed. + /// A set `pin::verify` verified, as the bytes that were hashed. + #[cfg_attr(not(feature = "neural"), allow(dead_code))] Verified(&'a Verified), - /// A held set a pin verified in memory; `paths` are the files its listing names - /// (`None` for a form-less pin over one file). - Held { set: HeldBytes<'a>, paths: Option> }, - /// An unpinned decode's set: the one the sink registers for the capability. - Unverified(HeldBytes<'a>), + /// A held set: one file, or a set whose readable files are `paths` (`None`: a + /// form-less pin's one file). `pinned` when a pin verified it. + Held { set: HeldBytes<'a>, paths: Option>, pinned: bool }, } impl<'a> IntParams<'a> { + /// No parameter set. A decode through a capability that reads one refuses with + /// `set_incomplete`. + pub fn none() -> IntParams<'static> { + IntParams(Params::None) + } + + /// The set `pin::verify` returned for a pin over `cap`: the neural sink's pinned + /// decode, whose registry ran SPEC §3.1's checks. + #[cfg(feature = "neural")] + pub(crate) fn verified(v: &'a Verified) -> Self { + IntParams(Params::Verified(v)) + } + /// The parameters a pin verified with `pin::verify_bytes` over `held`. - pub fn from_verified_ref(held: &[HeldBytes<'a>], r: &VerifiedRef) -> Self { - match r { - VerifiedRef::File { index } => IntParams::Held { set: held[*index], paths: None }, - VerifiedRef::Set { index, paths } => IntParams::Held { set: held[*index], paths: Some(paths.clone()) }, - } + fn from_verified_ref(cap: &str, held: &[HeldBytes<'a>], r: &VerifiedRef) -> Result { + let (index, paths) = match r { + VerifiedRef::File { index } => (*index, None), + VerifiedRef::Set { index, paths } => (*index, Some(paths.clone())), + }; + let set = *held.get(index).ok_or_else(|| PinRefusal::NotHeld { capability: cap.to_string() })?; + Ok(IntParams(Params::Held { set, paths, pinned: true })) } /// Whether a pin verified these parameters. pub fn is_pinned(&self) -> bool { - matches!(self, IntParams::Verified(_) | IntParams::Held { .. }) + matches!(self.0, Params::Verified(_) | Params::Held { pinned: true, .. }) + } + + /// Whether these parameters hold no set ([`IntParams::none`], or [`resolve`] for a + /// capability that decodes against none). + pub fn is_none(&self) -> bool { + matches!(self.0, Params::None) } /// The set member `name` that `cap`'s decode reads. A one-file set (a form-less @@ -152,51 +177,95 @@ impl<'a> IntParams<'a> { /// hold is `set_incomplete`, naming it. pub fn member(&self, cap: &str, name: &str) -> Result<&'a [u8], IntPayloadError> { let one = set_members(cap) == [name]; - let found = match self { - IntParams::None => None, - IntParams::Verified(Verified::File(b)) => one.then_some(b.as_slice()), - IntParams::Verified(v @ Verified::Set { .. }) => v.file(name).ok(), - IntParams::Held { set: HeldBytes::File(b), .. } | IntParams::Unverified(HeldBytes::File(b)) => one.then_some(*b), - IntParams::Held { set: HeldBytes::Set { files, .. }, paths } => { + let found = match &self.0 { + Params::None => None, + Params::Verified(Verified::File(b)) => one.then_some(b.as_slice()), + Params::Verified(v @ Verified::Set { .. }) => v.file(name).ok(), + Params::Held { set: HeldBytes::File(b), .. } => one.then_some(*b), + Params::Held { set: HeldBytes::Set { files, .. }, paths, .. } => { if paths.as_ref().is_none_or(|p| p.iter().any(|x| x == name)) { files.get(name).copied() } else { None } } - IntParams::Unverified(HeldBytes::Set { files, .. }) => files.get(name).copied(), }; found.ok_or_else(|| IntPayloadError::new(Refusal::SetIncomplete, name)) } } -/// The digests a pin over `set` could name, for check 5 over an unpinned decode: the -/// listing's, and, for a one-file capability, the one file's. -fn unpinned_digests(cap: &str, set: &HeldBytes<'_>) -> Vec { +/// What an unpinned decode of `cap` reads from `set`, and every digest a pin over +/// those bytes could name, for check 5 (SPEC §4, *Module authorisation*). +/// +/// The decode reads the capability's members (`set_members`) and nothing else. Where +/// the set carries a listing, it must be well formed (`set_malformed`) and every file +/// it names must be held with its digest (`part_mismatch`), as for a pinned set (SPEC +/// §3.1 checks 7 and 8), and the decode reads only the members it lists. The digests: +/// the listing's, where there is one; the listing of the members the decode reads +/// (`pin::listing_from`), which is what a pin in the form `wai.prior.set` over exactly +/// those files names; and, for a one-file capability, its one file's. So an empty +/// listing does not hide the set: its digest is the one derived from the files read. +/// A set whose listing is malformed or does not match its files has no one identity, +/// so it is refused before check 5 runs. +pub(crate) fn unpinned_read(cap: &str, set: &HeldBytes<'_>) -> Result<(Vec, Option>), PinRefusal> { + use crate::pin::{listing_from, parse_listing, sha256, sha256_hex}; let one_file = crate::container::pin_shape(cap) == crate::codecs::ParameterSetShape::OneFile; - let mut out = Vec::new(); - match set { - HeldBytes::File(b) if one_file => out.push(crate::pin::sha256_hex(b)), - HeldBytes::File(_) => {} - HeldBytes::Set { listing, files } => { + let members = set_members(cap); + let mut digests = Vec::new(); + let mut read: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let paths = match set { + HeldBytes::File(b) => { if one_file { - if let Some(b) = set_members(cap).first().and_then(|m| files.get(*m)) { - out.push(crate::pin::sha256_hex(b)); + if let Some(m) = members.first() { + read.insert(m.to_string(), *b); } } - out.push(crate::pin::sha256_hex(listing)); + None + } + HeldBytes::Set { listing, files } => { + let listed: Option> = if listing.is_empty() { + None + } else { + digests.push(sha256_hex(listing)); + let lines = parse_listing(listing)?; + if let Some((path, _)) = lines.iter().find(|(path, d)| files.get(path).is_none_or(|b| sha256(b) != *d)) { + return Err(PinRefusal::PartMismatch { path: path.clone() }); + } + Some(lines.into_iter().map(|(p, _)| p).collect()) + }; + for m in members { + if listed.as_ref().is_none_or(|l| l.iter().any(|p| p == m)) { + if let Some(b) = files.get(*m) { + read.insert(m.to_string(), *b); + } + } + } + Some(read.keys().cloned().collect()) + } + }; + if one_file { + if let Some(b) = members.first().and_then(|m| read.get(*m)) { + digests.push(sha256_hex(b)); } } - out + if let Ok(l) = listing_from(&read) { + digests.push(sha256_hex(&l)); + } + digests.sort_unstable(); + digests.dedup(); + Ok((digests, paths)) } /// The parameters `cap`'s decode reads, at a sink that holds the sets `held` in memory -/// and, where it has one, the key log `authority` (SPEC §3.1, §4). +/// and, where it has one, the key log `authority` (SPEC §3.1, §4). The one way to build +/// [`IntParams`] that hold a set. /// /// - A capability that decodes against no parameter set reads none. /// - Where a pin applies and the envelope carries one, `pin::verify_bytes_with` runs /// SPEC §3.1's checks in order, check 5 included where `authority` is given, and the /// decode reads the bytes it verified. /// - Otherwise the decode is unpinned and reads `held[0]`, the set the sink registers -/// for the capability; where `authority` is given, check 5 runs first over that set, -/// under every digest a pin over it could name (its listing's, and a one-file -/// capability's file's), and refuses a revoked or unauthorised set. +/// for the capability, as [`unpinned_read`] states: a listing the set carries must +/// match its files, and the decode reads only the capability's members (only those +/// the listing names, where there is one). Where `authority` is given, check 5 runs +/// over every digest a pin over the bytes read could name, and refuses a revoked or +/// unauthorised set. pub fn resolve<'a>( cap: &str, prior: Option<&Prior>, @@ -204,19 +273,20 @@ pub fn resolve<'a>( authority: Option<&dyn PinAuthority>, ) -> Result, PinRefusal> { if crate::codecs::is_known_capability(cap) && !crate::codecs::decodes_against_parameter_set(cap) { - return Ok(IntParams::None); + return Ok(IntParams(Params::None)); } match prior { Some(p) if crate::container::pin_applies_to(cap) => { let r = crate::pin::verify_bytes_with(cap, p, held, crate::pin::READABLE_FORMS, authority)?; - Ok(IntParams::from_verified_ref(held, &r)) + IntParams::from_verified_ref(cap, held, &r) } _ => { let set = *held.first().ok_or_else(|| PinRefusal::NotHeld { capability: cap.to_string() })?; + let (digests, paths) = unpinned_read(cap, &set)?; if let Some(a) = authority { - a.check_unpinned(cap, &unpinned_digests(cap, &set))?; + a.check_unpinned(cap, &digests)?; } - Ok(IntParams::Unverified(set)) + Ok(IntParams(Params::Held { set, paths, pinned: false })) } } } diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 97949e1af..efe207bde 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -38,6 +38,11 @@ fn same_under_every_policy(what: &str, f: impl F want } +/// An unpinned decode's parameters over `held`, at a sink with no key log. +fn unpinned<'a>(cap: &str, held: HeldBytes<'a>) -> IntParams<'a> { + resolve(cap, None, &[held], None).expect("an unpinned set resolves") +} + /// The tiny conditional codec's set, by member name. fn int_hyper_set() -> BTreeMap> { [("entropy.json", "int_hyper/tiny_entropy.json"), ("g_s.whm", "int_hyper/tiny_g_s.bin"), ("h_s.whm", "int_hyper/tiny_h_s.bin")] @@ -78,7 +83,7 @@ fn wiv1_json_equivalence() { assert_eq!((v.n_frames as usize, v.h as usize, v.w as usize, v.block as usize, v.q as i64, v.offset, v.mv_precision), (j.n_frames, j.h, j.w, j.block, j.q, j.offset, j.mv_precision), "{name}: header"); assert_eq!((&v.cdf, &v.mvs, v.rans), (&j.cdf, &j.mvs, j.rans.as_slice()), "{name}: tables and stream"); - let frames = same_under_every_policy(name, || match decode_int(crate::codecs::CAP_VIDEO_INT_MOTION, &payload, &IntParams::None) { + let frames = same_under_every_policy(name, || match decode_int(crate::codecs::CAP_VIDEO_INT_MOTION, &payload, &IntParams::none()) { Ok(IntDecoded::Video { frames, .. }) => frames, other => panic!("{name}: {other:?}"), }); @@ -96,7 +101,7 @@ fn wiv1_json_equivalence() { assert_eq!(wiv1_from_json(&js, Some(&wih1)).unwrap(), payload, "video_nk: the converter writes the committed payload"); assert_eq!(wire::parse_wiv1(&payload, Some(1)).unwrap().wih1, Some(wih1.as_slice())); let files = view(&set); - let params = IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }); + let params = unpinned(crate::codecs::CAP_VIDEO_INT_HYPER, HeldBytes::Set { listing: b"", files: &files }); let frames = same_under_every_policy("video_nk", || match decode_int(crate::codecs::CAP_VIDEO_INT_HYPER, &payload, ¶ms) { Ok(IntDecoded::Video { frames, .. }) => frames, other => panic!("video_nk: {other:?}"), @@ -126,7 +131,7 @@ fn wih1_json_equivalence() { assert_eq!((k.z_rans, k.rans), (b.z_rans.as_slice(), b.rans.as_slice())); let set = int_hyper_set(); let files = view(&set); - let params = IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }); + let params = unpinned(crate::codecs::CAP_NEURAL_INT_HYPER, HeldBytes::Set { listing: b"", files: &files }); let rgb = same_under_every_policy("tiny", || match decode_int(crate::codecs::CAP_NEURAL_INT_HYPER, &payload, ¶ms) { Ok(IntDecoded::Image { rgb, width: 16, height: 16 }) => rgb, other => panic!("tiny: {other:?}"), @@ -157,7 +162,7 @@ fn wia1_json_equivalence() { (j.afrac, sr, j.n_ch, j.lat_len, j.t_out, j.offset)); assert_eq!((&a.median_q, &a.cdfs, a.rans), (&j.median_q, &j.cdfs, j.rans.as_slice())); let model = read("int_audio/audio_model.bin"); - let params = IntParams::Unverified(HeldBytes::File(&model)); + let params = unpinned(crate::codecs::CAP_AUDIO_INT_CODEC, HeldBytes::File(&model)); let pcm = same_under_every_policy("audio", || match decode_int(crate::codecs::CAP_AUDIO_INT_CODEC, &payload, ¶ms) { Ok(IntDecoded::Pcm { samples, sample_rate: 16_000, channels: 1 }) => samples, other => panic!("audio: {other:?}"), @@ -185,7 +190,7 @@ fn wis1_json_equivalence() { assert_eq!((lo, hi), (jl as f32, jh as f32), "the ranges are the JSON's binary32 values"); } let gs = read("int_splat/splat_gs.bin"); - let params = IntParams::Unverified(HeldBytes::File(&gs)); + let params = unpinned(crate::codecs::CAP_SPLAT_INT_CODEC, HeldBytes::File(&gs)); let (bytes, ranges) = same_under_every_policy("splat", || match decode_int(crate::codecs::CAP_SPLAT_INT_CODEC, &payload, ¶ms) { Ok(IntDecoded::Attr { bytes, n_attr: 14, s: 32, n_gaussians: 1024, ranges }) => (bytes, ranges), other => panic!("splat: {other:?}"), @@ -208,15 +213,15 @@ fn decode_int_without_neural() { use crate::codecs as c; let set = int_hyper_set(); let files = view(&set); - let hyp = IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }); + let hyp = unpinned(c::CAP_NEURAL_INT_HYPER, HeldBytes::Set { listing: b"", files: &files }); let (model, gs) = (read("int_audio/audio_model.bin"), read("int_splat/splat_gs.bin")); let cases: [(&str, &str, IntParams<'_>); 6] = [ - (c::CAP_VIDEO_INT_MOTION, "int_video/int_video_bitstream.wiv1", IntParams::None), - (c::CAP_VIDEO_INT_MOTION, "int_video/video_real_bitstream.wiv1", IntParams::None), + (c::CAP_VIDEO_INT_MOTION, "int_video/int_video_bitstream.wiv1", IntParams::none()), + (c::CAP_VIDEO_INT_MOTION, "int_video/video_real_bitstream.wiv1", IntParams::none()), (c::CAP_VIDEO_INT_HYPER, "int_video/video_nk_bitstream.wiv1", hyp.clone()), (c::CAP_NEURAL_INT_HYPER, "int_hyper/tiny_bitstream.wih1", hyp.clone()), - (c::CAP_AUDIO_INT_CODEC, "int_audio/audio_bitstream.wia1", IntParams::Unverified(HeldBytes::File(&model))), - (c::CAP_SPLAT_INT_CODEC, "int_splat/splat.wis1", IntParams::Unverified(HeldBytes::File(&gs))), + (c::CAP_AUDIO_INT_CODEC, "int_audio/audio_bitstream.wia1", unpinned(c::CAP_AUDIO_INT_CODEC, HeldBytes::File(&model))), + (c::CAP_SPLAT_INT_CODEC, "int_splat/splat.wis1", unpinned(c::CAP_SPLAT_INT_CODEC, HeldBytes::File(&gs))), ]; for (cap, rel, params) in cases { assert!(decode_int(cap, &read(rel), ¶ms).is_ok(), "{cap} {rel}"); @@ -247,7 +252,7 @@ fn decode_int_without_neural() { for (name, spec) in manifest.as_object().unwrap() { let vd = dir.join("vectors").join(name); let (payload, model) = (std::fs::read(vd.join("latent.bin")).unwrap(), std::fs::read(vd.join("int_synth.json")).unwrap()); - let rgb = match decode_int(c::CAP_NEURAL_INT_SYNTH, &payload, &IntParams::Unverified(HeldBytes::File(&model))) { + let rgb = match decode_int(c::CAP_NEURAL_INT_SYNTH, &payload, &unpinned(c::CAP_NEURAL_INT_SYNTH, HeldBytes::File(&model))) { Ok(IntDecoded::Image { rgb, .. }) => rgb, other => panic!("{name}: {other:?}"), }; @@ -292,7 +297,7 @@ fn json_form_is_refused_before_any_parameter_is_read() { _ => cfg!(feature = "video_int"), }; let want = if built { "bad_magic" } else { "unsupported_capability" }; - assert_eq!(decode_int(cap, &read(json), &IntParams::None).map(|_| ()).unwrap_err().code(), want, "{cap}"); + assert_eq!(decode_int(cap, &read(json), &IntParams::none()).map(|_| ()).unwrap_err().code(), want, "{cap}"); } } @@ -302,36 +307,36 @@ fn capability_and_set_refusals() { let code = |r: Result| r.map(|_| "decoded").unwrap_or_else(|e| e.code()); // A candidate is not dispatchable (SPEC §4); a dispatchable non-integer capability // has no integer decoder. - assert_eq!(code(decode_int(crate::codecs::CAP_SPLAT_SOG, b"WIS1", &IntParams::None)), "not_dispatchable"); - assert_eq!(code(decode_int(crate::codecs::CAP_IMAGE_PNG, b"\x89PNG", &IntParams::None)), "unsupported_capability"); + assert_eq!(code(decode_int(crate::codecs::CAP_SPLAT_SOG, b"WIS1", &IntParams::none())), "not_dispatchable"); + assert_eq!(code(decode_int(crate::codecs::CAP_IMAGE_PNG, b"\x89PNG", &IntParams::none())), "unsupported_capability"); // The conditional codec reads three files: a set without h_s.whm is incomplete, // and the refusal names the file. let mut set = int_hyper_set(); set.remove("h_s.whm"); let files = view(&set); let r = decode_int(crate::codecs::CAP_NEURAL_INT_HYPER, &read("int_hyper/tiny_bitstream.wih1"), - &IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files })); + &unpinned(crate::codecs::CAP_NEURAL_INT_HYPER, HeldBytes::Set { listing: b"", files: &files })); assert_eq!(r.as_ref().map_err(|e| (e.code(), e.detail())).err(), Some(("set_incomplete", "h_s.whm"))); // One file cannot hold a several-file set. let one = read("int_hyper/tiny_g_s.bin"); assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_HYPER, &read("int_hyper/tiny_bitstream.wih1"), - &IntParams::Unverified(HeldBytes::File(&one)))), "set_incomplete"); + &unpinned(crate::codecs::CAP_NEURAL_INT_HYPER, HeldBytes::File(&one)))), "set_incomplete"); // Entropy tables that break the table rules are the model's fault. let mut bad = int_hyper_set(); bad.insert("entropy.json".into(), br#"{"scale_edges":[],"cdfs":[],"cdf_lengths":[],"offsets":[],"z_cdfs":[],"z_cdf_lengths":[],"z_offsets":[],"z_median_q":[]}"#.to_vec()); let files = view(&bad); assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_HYPER, &read("int_hyper/tiny_bitstream.wih1"), - &IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }))), "model_mismatch"); + &unpinned(crate::codecs::CAP_NEURAL_INT_HYPER, HeldBytes::Set { listing: b"", files: &files }))), "model_mismatch"); // int_mlicpp: a payload its decoder refuses, and a bundle that is not one. let bundle = std::fs::read(vroot().join("int_mlicpp/model.wmb")).unwrap(); - assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_MLICPP, b"WIM1", &IntParams::Unverified(HeldBytes::File(&bundle)))), "payload_malformed"); - assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_MLICPP, b"WIM1", &IntParams::Unverified(HeldBytes::File(b"WHM1")))), "model_mismatch"); + assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_MLICPP, b"WIM1", &unpinned(crate::codecs::CAP_NEURAL_INT_MLICPP, HeldBytes::File(&bundle)))), "payload_malformed"); + assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_MLICPP, b"WIM1", &unpinned(crate::codecs::CAP_NEURAL_INT_MLICPP, HeldBytes::File(b"WHM1")))), "model_mismatch"); // int_synth: a side of 2^64 is refused, not shifted. let mut p = vec![0u8; 12]; p[8] = 1; p[10] = 64; let spec = std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../neural-conformance/vectors/gradient/int_synth.json")).unwrap(); - assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_SYNTH, &p, &IntParams::Unverified(HeldBytes::File(&spec)))), "payload_malformed"); + assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_SYNTH, &p, &unpinned(crate::codecs::CAP_NEURAL_INT_SYNTH, HeldBytes::File(&spec)))), "payload_malformed"); } /// `container::check_int_payload_framing` (what `wai wrap` and `wai inspect` run, with @@ -419,11 +424,55 @@ fn check_5_through_the_ungated_path() { assert_eq!(code(resolve(AUD, None, &one, Some(&revoke_model))), "prior_revoked"); assert_eq!(code(resolve(AUD, None, &one, None)), "unpinned"); // A capability that reads no set needs none, and no key log is consulted. - assert!(matches!(resolve(crate::codecs::CAP_VIDEO_INT_MOTION, None, &[], Some(&revoking)), Ok(IntParams::None))); + assert!(resolve(crate::codecs::CAP_VIDEO_INT_MOTION, None, &[], Some(&revoking)).is_ok_and(|p| p.is_none())); // An unpinned decode with no set held. assert_eq!(code(resolve(HYP, None, &[], None)), "prior_not_held"); } +/// An unpinned decode is checked under the bytes it reads (SPEC §4, *Module +/// authorisation*): the tiny set is refused under its own listing, and the same files +/// held with an empty listing, or beside a file the decode does not read, are refused +/// too, under the listing of the members read. A listing that does not match its files, +/// or is malformed, is refused before check 5; the decode reads only the members a +/// listing names. +#[test] +fn unpinned_check_5_covers_the_files_the_decode_reads() { + use crate::pin::{listing_from, sha256_hex}; + let hyp = crate::codecs::CAP_NEURAL_INT_HYPER; + let set = int_hyper_set(); + let files = view(&set); + let listing = listing_from(&files).unwrap(); + let revoking = Authority { revoked: vec![sha256_hex(&listing)], granted: vec![], held_to: false }; + let code = |r: Result, PinRefusal>| r.map(|_| "ok").unwrap_or_else(|e| e.code()); + fn held<'a>(listing: &'a [u8], files: &'a BTreeMap) -> HeldBytes<'a> { + HeldBytes::Set { listing, files } + } + let tiny = read("int_hyper/tiny_bitstream.wih1"); + let own = listing.as_slice(); + assert_eq!(code(resolve(hyp, None, &[held(own, &files)], Some(&revoking))), "prior_revoked", "its own listing"); + assert_eq!(code(resolve(hyp, None, &[held(b"", &files)], Some(&revoking))), "prior_revoked", "no listing"); + let mut extra = set.clone(); + extra.insert("notes.txt".into(), b"not a member".to_vec()); + let extra = view(&extra); + assert_eq!(code(resolve(hyp, None, &[held(b"", &extra)], Some(&revoking))), "prior_revoked", "an unread file beside it"); + // With no key log, the same set decodes. + let p = resolve(hyp, None, &[held(b"", &files)], None).unwrap(); + assert!(!p.is_pinned()); + assert!(decode_int(hyp, &tiny, &p).is_ok()); + // A listing whose file is not the one held, and a malformed listing. + let mut tampered = set.clone(); + tampered.get_mut("g_s.whm").unwrap().push(0); + let tampered = view(&tampered); + assert_eq!(code(resolve(hyp, None, &[held(own, &tampered)], None)), "part_mismatch"); + assert_eq!(code(resolve(hyp, None, &[held(b"not a listing\n", &files)], None)), "set_malformed"); + // A listing of two of the three members: the decode reads those two only. + let two: BTreeMap = files.iter().filter(|(k, _)| k.as_str() != "h_s.whm").map(|(k, v)| (k.clone(), *v)).collect(); + let two = listing_from(&two).unwrap(); + let p = resolve(hyp, None, &[held(&two, &files)], None).unwrap(); + let r = decode_int(hyp, &tiny, &p); + assert_eq!(r.as_ref().map_err(|e| (e.code(), e.detail())).err(), Some(("set_incomplete", "h_s.whm"))); +} + /// The same refusals from the key log a sink holds (`key_log::SinkKeyLog`), as the /// neural sink's `int_mlicpp_revoked_set_is_refused_through_the_key_log` checks them. #[cfg(feature = "key_discovery")] @@ -468,15 +517,21 @@ fn check_5_with_a_sink_key_log() { /// one file only for the capability's one member. #[test] fn a_verified_set_answers_only_for_what_it_verified() { + use crate::codecs::CAP_PRIOR_SET; let mut set = int_hyper_set(); let listing = crate::pin::listing_from(&view(&set)).unwrap(); set.insert("extra.bin".into(), b"unverified".to_vec()); let files = view(&set); - let p = IntParams::Held { set: HeldBytes::Set { listing: &listing, files: &files }, paths: Some(vec!["entropy.json".into(), "g_s.whm".into(), "h_s.whm".into()]) }; let hyp = crate::codecs::CAP_NEURAL_INT_HYPER; + let pin = Prior::new("tiny", crate::pin::sha256_hex(&listing), "decode-equivalence").with_form(CAP_PRIOR_SET); + let held = [HeldBytes::Set { listing: &listing, files: &files }]; + let p = resolve(hyp, Some(&pin), &held, None).unwrap(); + assert!(p.is_pinned()); assert!(p.member(hyp, "g_s.whm").is_ok()); assert_eq!(p.member(hyp, "extra.bin").unwrap_err().code(), "set_incomplete"); - let one = IntParams::Held { set: HeldBytes::File(b"WAM1"), paths: None }; - assert!(one.member(crate::codecs::CAP_AUDIO_INT_CODEC, "model.wam").is_ok()); - assert!(one.member(hyp, "g_s.whm").is_err(), "one file is not a several-file set"); + let aud = crate::codecs::CAP_AUDIO_INT_CODEC; + let one = [HeldBytes::File(b"WAM1")]; + let p = resolve(aud, Some(&Prior::new("m", crate::pin::sha256_hex(b"WAM1"), "decode-equivalence")), &one, None).unwrap(); + assert!(p.member(aud, "model.wam").is_ok()); + assert!(p.member(hyp, "g_s.whm").is_err(), "one file is not a several-file set"); } diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index 7369563f1..09fcaaab9 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -52,16 +52,18 @@ pub use video::DecodedVideo; /// capability (a set and its successor, SPEC §3.1). For a pin the registry offers, /// for each registered and held path: /// -/// - form-less: the file itself — for `wai.neural.int_synth`, the `int_synth.json` -/// beside it, the one file its decode reads; +/// - form-less: the file itself; for an integer capability whose set is one file, +/// the one file its decode reads (inside the path when the path is a directory; for +/// `wai.neural.int_synth`, the `int_synth.json` beside a registered file); /// - `wai.prior.set`: every `*.sha256` file directly inside the path's directory /// (the path itself if it is a directory), each a listing rooted there. /// /// A key log, where set ([`ModelRegistry::with_key_log`]), runs SPEC §3.1's check 5: /// a set whose digest it revokes is refused, and the sets of the capabilities the /// sink's relying party holds to jwp-receipts §7.9 must be held under §7.9. It runs -/// over the set an unpinned decode reads too (SPEC §4, *Module authorisation*). A -/// registry without one does not run check 5. +/// over the set an unpinned decode reads too (SPEC §4, *Module authorisation*): for an +/// integer capability, over the very bytes the decode then reads, which the registry +/// reads once. A registry without one does not run check 5. /// /// A capability whose decode reads no parameter set (`wai.video.int_motion`) is /// advertised with [`ModelRegistry::builtin`] instead, and holds no path. @@ -131,31 +133,85 @@ impl ModelRegistry { /// The parameters an unpinned decode through `capability` reads: its registered /// artifact. At a registry with a key log, for a capability a pin applies to, check /// 5 runs first over that artifact (SPEC §4, *Module authorisation*), identified by - /// each digest a pin over it could name: its file's SHA-256 where the capability's - /// set is one file, and the SHA-256 of each listing the registry finds for it in the - /// form `wai.prior.set`. The digests are read when the decode starts; an unpinned - /// decode then reads the path, as it always does. `capability` is registered. + /// each digest a pin over it could name. + /// + /// For an integer capability (one `int_payload::decode_int` decodes), the members + /// its decode reads are read here, once, from the paths [`int_member_path`] gives, + /// and returned as [`ParamSource::UnpinnedRead`]: check 5 runs over exactly those + /// bytes (`int_payload::unpinned_read`: a one-file set's file, and the listing of + /// the members read) and over each listing beside them, and the decode reads + /// nothing else. For any other capability the digests are read when the decode + /// starts: its file's SHA-256 where the capability's set is one file, and the + /// SHA-256 of each listing the registry finds for it in the form `wai.prior.set`; + /// the decode then reads the path, as it always does. `capability` is registered. fn unpinned(&self, capability: &str) -> Result { let Some(path) = self.paths.get(capability) else { // A built-in capability reads no parameter set: there is nothing to check. return Ok(ParamSource::Unpinned(PathBuf::new())); }; - if let Some(k) = self.key_log.as_deref().filter(|_| crate::container::pin_applies_to(capability)) { - let one_file = crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile; - let mut digests = Vec::new(); - let files = if one_file { artifacts_at(capability, [path], false) } else { Vec::new() }; - for h in files.iter().chain(artifacts_at(capability, [path], true).iter()) { + let key_log = self.key_log.as_deref().filter(|_| crate::container::pin_applies_to(capability)); + let listings = |digests: &mut Vec| { + for h in artifacts_at(capability, [path], true) { let (crate::pin::Held::File(f) | crate::pin::Held::Listing(f)) = h; if let Ok(bytes) = std::fs::read(f) { digests.push(crate::pin::sha256_hex(&bytes)); } } + }; + if !crate::int_payload::set_members(capability).is_empty() { + let files = read_int_members(capability, path); + if let Some(k) = key_log { + let view: std::collections::BTreeMap = files.iter().map(|(n, b)| (n.clone(), b.as_slice())).collect(); + let (mut digests, _) = crate::int_payload::unpinned_read(capability, &crate::pin::HeldBytes::Set { listing: b"", files: &view })?; + listings(&mut digests); + k.check_unpinned(capability, &digests)?; + } + return Ok(ParamSource::UnpinnedRead(files)); + } + if let Some(k) = key_log { + let one_file = crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile; + let mut digests = Vec::new(); + if one_file { + for h in artifacts_at(capability, [path], false) { + let (crate::pin::Held::File(f) | crate::pin::Held::Listing(f)) = h; + if let Ok(bytes) = std::fs::read(f) { + digests.push(crate::pin::sha256_hex(&bytes)); + } + } + } + listings(&mut digests); k.check_unpinned(capability, &digests)?; } Ok(ParamSource::Unpinned(path.clone())) } } +/// The file an integer capability's decode reads for its set member `member`, at the +/// registered path `registered`: inside it when it is a directory; the registered file +/// itself for a one-file set (except `wai.neural.int_synth`, whose registered path +/// names the model and whose set is the `int_synth.json` beside it); otherwise the +/// file named `member` beside it. A pin over the set, check 5 and the decode all read +/// this one path, so they cover the same bytes. +fn int_member_path(capability: &str, registered: &Path, member: &str) -> PathBuf { + let one_file = crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile; + if registered.is_dir() { + registered.join(member) + } else if one_file && capability != crate::codecs::CAP_NEURAL_INT_SYNTH { + registered.to_path_buf() + } else { + registered.parent().map(|d| d.join(member)).unwrap_or_else(|| PathBuf::from(member)) + } +} + +/// The members of `capability`'s set read from beside `registered`, once +/// ([`int_member_path`]); a member that cannot be read is absent, and the decode then +/// refuses with `set_incomplete` after it has checked the payload's framing. +fn read_int_members(capability: &str, registered: &Path) -> std::collections::BTreeMap> { + crate::int_payload::set_members(capability).iter() + .filter_map(|m| std::fs::read(int_member_path(capability, registered, m)).ok().map(|b| (m.to_string(), b))) + .collect() +} + /// The artifacts at `paths` a pin over `capability` is checked against: see /// [`ModelRegistry`]. fn artifacts_at<'a>(capability: &str, paths: impl IntoIterator, set_form: bool) -> Vec { @@ -170,10 +226,9 @@ fn artifacts_at<'a>(capability: &str, paths: impl IntoIterator } /// An integer capability through `int_payload::decode_int`, against the parameters -/// `params` names. A pinned decode hands it the verified bytes; an unpinned one reads -/// the set's members beside the registered path (for `wai.neural.int_synth`, the -/// `int_synth.json` beside it; for the other one-file sets, the registered file -/// itself), as each decode read them before it was delegated. A member a pinned set -/// lacks is `set_incomplete`, so dispatch moves on as it does for any decoder. +/// `params` names. A pinned decode hands it the verified bytes; an unpinned one the +/// set the registry read when the decode started ([`ParamSource::UnpinnedRead`]), or, +/// given a path directly, the set's members read from beside it now +/// ([`int_member_path`]). The payload's framing is checked before any member is read, +/// and a member the set lacks is `set_incomplete`, so dispatch moves on as it does for +/// any decoder. fn int_decode(cap: &str, payload: &[u8], params: &ParamSource) -> Result { - use crate::int_payload::{decode_int, set_members, IntDecoded, IntParams, Refusal}; - let one_file = crate::container::pin_shape(cap) == crate::codecs::ParameterSetShape::OneFile; - let mut owned: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - if let ParamSource::Unpinned(_) = params { - for &m in set_members(cap) { - let read = if one_file && cap != crate::codecs::CAP_NEURAL_INT_SYNTH { params.model(m) } else { params.sibling(m) }; - let bytes = read.map_err(|e| match e { - DecodeError::InvalidPayload(why) => DecodeError::InvalidPayload(format!("{cap} parameter set: {why}")), - other => other, - })?; - owned.insert(m.to_string(), bytes.into_owned()); + use crate::int_payload::{decode_int, resolve, IntDecoded, IntParams, Refusal}; + let read_now; + let unpinned = match params { + ParamSource::UnpinnedRead(files) => Some(files), + ParamSource::Unpinned(path) => { + read_now = read_int_members(cap, path); + Some(&read_now) } - } - let view: std::collections::BTreeMap = owned.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + _ => None, + }; + let view: std::collections::BTreeMap = + unpinned.into_iter().flatten().map(|(k, v)| (k.clone(), v.as_slice())).collect(); let p = match params { - ParamSource::Pinned(v) => IntParams::Verified(v), - ParamSource::Unpinned(_) if set_members(cap).is_empty() => IntParams::None, - ParamSource::Unpinned(_) => IntParams::Unverified(crate::pin::HeldBytes::Set { listing: b"", files: &view }), + ParamSource::Pinned(v) => IntParams::verified(v), + _ => resolve(cap, None, &[crate::pin::HeldBytes::Set { listing: b"", files: &view }], None) + .map_err(DecodeError::PriorMismatch)?, }; match decode_int(cap, payload, &p) { Ok(IntDecoded::Image { rgb, width, height }) => Ok(Decoded::Image(DecodedImage { rgb, width, height })), diff --git a/wai/wai-rs/src/neural/params.rs b/wai/wai-rs/src/neural/params.rs index 504d58888..bd0dd420c 100644 --- a/wai/wai-rs/src/neural/params.rs +++ b/wai/wai-rs/src/neural/params.rs @@ -14,6 +14,7 @@ //! hardware, which only an integer decode is (SPEC §7). use std::borrow::Cow; +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use ort::session::Session; @@ -25,12 +26,19 @@ use super::DecodeError; /// The parameters one decode reads. #[derive(Debug, Clone)] +#[non_exhaustive] pub enum ParamSource { /// No pin applied: the registered path, and the files beside it (the path /// itself when it is a directory). Unpinned(PathBuf), /// A pin verified this set; the decode reads nothing else. Pinned(Verified), + /// No pin applied, and the set an integer capability's decode reads was read into + /// memory once, when the decode started: its members (`int_payload::set_members`) + /// by the names a set lists them under, a member the sink could not read being + /// absent. A key log the registry holds ran check 5 over exactly these bytes, and + /// the decode reads nothing else ([`super::ModelRegistry`]). + UnpinnedRead(BTreeMap>), } fn incomplete(name: &str) -> DecodeError { @@ -39,7 +47,7 @@ fn incomplete(name: &str) -> DecodeError { impl ParamSource { /// The directory an unpinned decode reads siblings from. - fn dir(path: &Path) -> PathBuf { + pub(crate) fn dir(path: &Path) -> PathBuf { if path.is_dir() { path.to_path_buf() } else { @@ -61,6 +69,7 @@ impl ParamSource { .map_err(|e| DecodeError::InvalidPayload(format!("{}: {e}", p.display()))), Self::Pinned(Verified::File(b)) => Ok(Cow::Borrowed(b)), Self::Pinned(v) => v.file(set_name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), + Self::UnpinnedRead(files) => files.get(set_name).map(|b| Cow::Borrowed(b.as_slice())).ok_or_else(|| incomplete(set_name)), } } @@ -75,6 +84,7 @@ impl ParamSource { } Self::Pinned(Verified::File(_)) => Err(incomplete(name)), Self::Pinned(v) => v.file(name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), + Self::UnpinnedRead(files) => files.get(name).map(|b| Cow::Borrowed(b.as_slice())).ok_or_else(|| incomplete(name)), } } @@ -85,6 +95,7 @@ impl ParamSource { Self::Unpinned(p) => std::fs::read(Self::dir(p).join(name)).ok().map(Cow::Owned), Self::Pinned(Verified::File(_)) => None, Self::Pinned(v) => v.file(name).ok().map(Cow::Borrowed), + Self::UnpinnedRead(files) => files.get(name).map(|b| Cow::Borrowed(b.as_slice())), } } @@ -102,7 +113,7 @@ impl ParamSource { pub(crate) fn session_model(&self, set_name: &str) -> Result { match self { Self::Unpinned(p) => crate::ort_session::load_deterministic(p).map_err(DecodeError::Ort), - Self::Pinned(_) => session_from_verified(&self.model(set_name)?), + Self::Pinned(_) | Self::UnpinnedRead(_) => session_from_verified(&self.model(set_name)?), } } @@ -110,7 +121,7 @@ impl ParamSource { pub(crate) fn session_sibling(&self, name: &str) -> Result { match self { Self::Unpinned(p) => crate::ort_session::load_deterministic(&Self::dir(p).join(name)).map_err(DecodeError::Ort), - Self::Pinned(_) => session_from_verified(&self.sibling(name)?), + Self::Pinned(_) | Self::UnpinnedRead(_) => session_from_verified(&self.sibling(name)?), } } } diff --git a/wai/wai-rs/tests/int_payload.rs b/wai/wai-rs/tests/int_payload.rs index bd52523a2..44fbe6224 100644 --- a/wai/wai-rs/tests/int_payload.rs +++ b/wai/wai-rs/tests/int_payload.rs @@ -37,7 +37,8 @@ fn set_of(entry: &Value) -> BTreeMap> { fn decode(cap: &str, payload: &[u8], set: &BTreeMap>) -> Result { let view: BTreeMap = set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); - let params = if view.is_empty() { IntParams::None } else { IntParams::Unverified(HeldBytes::Set { listing: b"", files: &view }) }; + let params = wai::int_payload::resolve(cap, None, &[HeldBytes::Set { listing: b"", files: &view }], None) + .unwrap_or_else(|_| IntParams::none()); decode_int(cap, payload, ¶ms) } @@ -125,7 +126,7 @@ fn a_json_form_in_an_envelope_is_refused() { let wrap = |payload: Vec| Wai::new(Manifest::new("binary", "improve", ModelRequirement::new(cap), Conditioning::new("bytes")), payload); let env = Wai::from_bytes(&wrap(read(json)).to_bytes().unwrap()).unwrap(); assert_eq!(env.check_int_payload_framing().map_err(|e| e.code), Err("bad_magic"), "{cap}"); - assert_eq!(decode_int(cap, &env.payload, &IntParams::None).map(|_| ()).map_err(|e| e.code()), Err("bad_magic"), "{cap}"); + assert_eq!(decode_int(cap, &env.payload, &IntParams::none()).map(|_| ()).map_err(|e| e.code()), Err("bad_magic"), "{cap}"); assert_eq!(wrap(read(bin)).check_int_payload_framing(), Ok(()), "{cap}: the binary form"); let multi = WaiMulti::new("binary", "improve", Value::Null, vec![Rendition::media(cap, "bytes", read(json))]); assert_eq!(multi.check_int_payload_framing().map_err(|(i, e)| (i, e.code)), Err((0, "bad_magic")), "{cap}"); diff --git a/wai/wai-rs/tests/int_payload_preflight.rs b/wai/wai-rs/tests/int_payload_preflight.rs index f57fa832b..78f1e21fc 100644 --- a/wai/wai-rs/tests/int_payload_preflight.rs +++ b/wai/wai-rs/tests/int_payload_preflight.rs @@ -20,7 +20,7 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; -use wai::int_payload::{decode_int, IntParams}; +use wai::int_payload::{decode_int, resolve, IntParams}; use wai::pin::HeldBytes; /// The system allocator, recording the largest single request. @@ -77,22 +77,23 @@ fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() let hyp: BTreeMap> = [("entropy.json", "int_hyper/tiny_entropy.json"), ("g_s.whm", "int_hyper/tiny_g_s.bin"), ("h_s.whm", "int_hyper/tiny_h_s.bin")] .into_iter().map(|(k, v)| (k.to_string(), read(v))).collect(); let hyp_view: BTreeMap = hyp.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); - let hyp = IntParams::Unverified(HeldBytes::Set { listing: b"", files: &hyp_view }); + let unpinned = |cap, held| resolve(cap, None, &[held], None).expect("an unpinned set resolves"); + let hyp = unpinned(c::CAP_NEURAL_INT_HYPER, HeldBytes::Set { listing: b"", files: &hyp_view }); let (model, gs) = (read("int_audio/audio_model.bin"), read("int_splat/splat_gs.bin")); - let (aud, spl) = (IntParams::Unverified(HeldBytes::File(&model)), IntParams::Unverified(HeldBytes::File(&gs))); + let (aud, spl) = (unpinned(c::CAP_AUDIO_INT_CODEC, HeldBytes::File(&model)), unpinned(c::CAP_SPLAT_INT_CODEC, HeldBytes::File(&gs))); let (iv, nk, ih) = (read("int_video/int_video_bitstream.wiv1"), read("int_video/video_nk_bitstream.wiv1"), read("int_hyper/tiny_bitstream.wih1")); let (au, sp) = (read("int_audio/audio_bitstream.wia1"), read("int_splat/splat.wis1")); // The counter sees a decode's allocations: the committed clip's largest is at least // one 48 × 48 frame of i64. - let (r, largest) = largest_in(|| decode_int(c::CAP_VIDEO_INT_MOTION, &iv, &IntParams::None)); + let (r, largest) = largest_in(|| decode_int(c::CAP_VIDEO_INT_MOTION, &iv, &IntParams::none())); r.expect("the committed clip decodes"); assert!(largest >= 48 * 48 * 3 * 8, "the counter saw a {largest}-byte allocation at most"); let hyp_bytes: usize = hyp_view.values().map(|v| v.len()).sum(); let cases: Vec<(&str, &str, Vec, &IntParams<'_>, &str, usize)> = vec![ // A 2^20-frame clip of 48 × 48: each frame is within the cap, the clip is not. - ("a 2^20-frame clip", c::CAP_VIDEO_INT_MOTION, with(&iv, &[(8, 1 << 20)]), &IntParams::None, "too_large", 0), + ("a 2^20-frame clip", c::CAP_VIDEO_INT_MOTION, with(&iv, &[(8, 1 << 20)]), &IntParams::none(), "too_large", 0), // An 8192 × 8192 frame. ("an 8192 × 8192 frame", c::CAP_VIDEO_INT_HYPER, with(&nk, &[(12, 8192), (16, 8192)]), &hyp, "too_large", hyp_bytes), // A hyper-latent of 2 × 4000 × 4000, past the cap. diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index af21b8ffc..44d94fdb1 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -550,6 +550,70 @@ fn int_synth_pin_covers_the_spec_it_reads() { assert_eq!(refusal(decode_envelope(&env(&"ab".repeat(32)), ®)), "prior_not_held"); } +/// A key log stand-in that revokes the digests it holds. +#[derive(Debug)] +struct Revokes(Vec); + +impl wai::pin::PinAuthority for Revokes { + fn check(&self, cap: &str, prior: &Prior) -> Result<(), wai::container::PinRefusal> { + self.check_unpinned(cap, std::slice::from_ref(&prior.sha256)) + } + fn check_unpinned(&self, cap: &str, digests: &[String]) -> Result<(), wai::container::PinRefusal> { + if digests.iter().any(|d| self.0.contains(d)) { + return Err(wai::container::PinRefusal::Revoked { capability: cap.into(), revoked_by: None }); + } + Ok(()) + } +} + +/// `wai.neural.int_synth` registered as a model directory, or as the `int_synth.json` +/// inside it: the decode reads that one file, a form-less pin covers it, and check 5 +/// runs over it, so a key log that revokes the spec refuses the unpinned decode either +/// way (`prior_revoked`). Without the key log both registrations decode to the corpus +/// hash. The unpinned decode reads the bytes check 5 ran over: the spec replaced on +/// disk after the registry read it does not change the image. +#[test] +fn int_synth_check_5_covers_the_spec_a_model_directory_holds() { + let cap = "wai.neural.int_synth"; + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../neural-conformance/vectors/gradient"); + let spec = std::fs::read(src.join("int_synth.json")).unwrap(); + let latent = std::fs::read(src.join("latent.bin")).unwrap(); + let digest = wai::pin::sha256_hex(&spec); + let dir = std::env::temp_dir().join(format!("wai-int-synth-dir-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("int_synth.json"), &spec).unwrap(); + let env = |prior: Option| { + let mut cond = Conditioning::new("int_synth"); + if let Some(p) = prior { + cond = cond.with_prior(p); + } + Wai::new(Manifest::new("image", "improve", ModelRequirement::new(cap), cond), latent.clone()) + }; + let rgb_hash = |r: Result| match r { + Ok(Decoded::Image(img)) => { + let mut h = blake3::Hasher::new(); + h.update(b"wai:int-synth-rgb\x01"); + h.update(&img.rgb); + h.finalize().to_hex().to_string() + } + other => refusal(other), + }; + let want = "1282839b699ad3e79fc810a92ef4220fec7a57a573bb6c844709af4c1fb3f638"; + for path in [dir.clone(), dir.join("int_synth.json")] { + let revoking = ModelRegistry::new().register(cap, path.clone()).with_key_log(Revokes(vec![digest.clone()])); + assert_eq!(refusal(decode_envelope(&env(None), &revoking)), "prior_revoked", "{}", path.display()); + let plain = ModelRegistry::new().register(cap, path.clone()); + assert_eq!(rgb_hash(decode_envelope(&env(None), &plain)), want, "{}", path.display()); + let pin = Prior::new("gradient", digest.clone(), "int-synth/i64"); + assert_eq!(rgb_hash(decode_envelope(&env(Some(pin)), &plain)), want, "{}: a form-less pin", path.display()); + } + let reg = ModelRegistry::new().register(cap, dir.clone()).with_key_log(Revokes(vec![])); + let params = reg.resolve_verified(cap, None).expect("the unrevoked spec resolves"); + std::fs::copy(src.join("../bloom/int_synth.json"), dir.join("int_synth.json")).unwrap(); + assert_eq!(rgb_hash(wai::neural::decode_payload(cap, &latent, ¶ms)), want, "the decode read what check 5 ran over"); + std::fs::remove_dir_all(&dir).ok(); +} + /// For every several-file capability the reference sink dispatches, a form-less pin /// is refused with `form_required` before any file is read — even when its digest is /// the stand-in's own, and even for a capability registered as a directory. diff --git a/wai/wai-web/src/int_payload.rs b/wai/wai-web/src/int_payload.rs index dfd71c808..258aff37e 100644 --- a/wai/wai-web/src/int_payload.rs +++ b/wai/wai-web/src/int_payload.rs @@ -12,14 +12,15 @@ use std::collections::BTreeMap; -use wai::int_payload::{decode_int, IntDecoded, IntParams}; +use wai::int_payload::{decode_int, IntDecoded}; use wai::pin::HeldBytes; /// Decode `payload` through `cap` against the set members `set` (name, bytes): the /// pure-Rust half of the bindings, which the native tests run. pub(crate) fn decode(cap: &str, payload: &[u8], set: &[(&str, &[u8])]) -> Result { let files: BTreeMap = set.iter().map(|(k, v)| (k.to_string(), *v)).collect(); - let params = if files.is_empty() { IntParams::None } else { IntParams::Unverified(HeldBytes::Set { listing: b"", files: &files }) }; + let params = wai::int_payload::resolve(cap, None, &[HeldBytes::Set { listing: b"", files: &files }], None) + .map_err(|e| format!("{}: {e}", e.code()))?; decode_int(cap, payload, ¶ms).map_err(|e| e.to_string()) } -- 2.54.0 (Apple Git-157) From 65c63ee50dd88d254bf3c31fdd79d84ad0c2a8fe Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 18:52:25 -0400 Subject: [PATCH 13/34] wai: every emit path parses an integer payload as a sink does The pinned-format check read only a payload's magic and version, and only `wai wrap` ran it, so a keyframe-0 WIV1 wrapped under wai.video.int_hyper, or a WIV1 cut short, was written. - container::check_int_payload_framing now parses the payload with the wire parser a sink runs, with the capability's keyframe kind for WIV1, and reports the first parse rule that fails (FramingError gains the failed rule). The wire module is compiled with no feature (`int_wire`, re-exported as int_payload::wire), so the container can run it. - Wai::to_emit_bytes and WaiMulti::to_emit_bytes run it, so the C packers refuse such a payload (WAI_ERR_INVALID_ARG, new EmitError::IntPayload); so do wai-web's pack and packMulti. - `wai inspect` says what it checked: every parse rule, with the decode checking the rest. The CHANGELOG and the extension say the same. - CI runs wai-web's native unit tests. Tests: container::tests::the_emit_path_refuses_an_integer_payload_that_is_not_its_format, int_payload::tests::framing_check_is_the_parser, ffi_envelope::envelope_pack_refuses_an_integer_payload_that_is_not_its_format, cli_wrap_intent::wrap_refuses_a_payload_that_is_not_the_pinned_integer_format (extended), wai-web tests::the_writers_refuse_a_payload_that_is_not_the_pinned_format. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-web-wasm.yml | 16 +++- wai/CHANGELOG.md | 13 +++- wai/extensions/integer-payloads.md | 12 ++- wai/wai-rs/include/wai.h | 7 +- wai/wai-rs/src/bin/wai.rs | 2 +- wai/wai-rs/src/container.rs | 116 ++++++++++++++++++++-------- wai/wai-rs/src/ffi.rs | 9 ++- wai/wai-rs/src/int_payload/mod.rs | 2 +- wai/wai-rs/src/int_payload/tests.rs | 31 +++++--- wai/wai-rs/src/lib.rs | 8 ++ wai/wai-rs/tests/cli_wrap_intent.rs | 28 ++++++- wai/wai-rs/tests/ffi_envelope.rs | 46 +++++++++++ wai/wai-web/src/lib.rs | 69 +++++++++++++++-- 13 files changed, 289 insertions(+), 70 deletions(-) diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 2ef7b5ac9..4892c9867 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -13,7 +13,8 @@ name: wai-web wasm32 # - demo/verify_pack.mjs: the browser writers `pack` and `packMulti` refuse # every candidate the export lists, in every place an envelope can name one; # - a byte comparison of the demo's staged int_synth copies with the corpus; -# - the crate's wasm_tests under wasm-bindgen-test-runner. +# - the crate's wasm_tests under wasm-bindgen-test-runner, and its native unit +# tests on the host. # # The full-scale int_hyper wasm decode needs vectors regenerated from a # pretrained model; wai-sota-vectors.yml runs it after regenerating them. @@ -85,6 +86,19 @@ jobs: CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner \ cargo test --locked --target wasm32-unknown-unknown 2>&1 | tee "$RUNNER_TEMP/wai-web-wasm.log" grep -q "test result: ok. 4 passed; 0 failed" "$RUNNER_TEMP/wai-web-wasm.log" + # The crate's native unit tests, run on the host: the pure-Rust halves of the + # bindings, which wasm_tests does not run. They decode the committed integer + # payloads to their sidecars' SHA-256 (int_payload::tests) and check that the + # writers `pack` and `packMulti` refuse a payload that is not the format SPEC §5 + # pins its integer capability to (tests). The count check turns a test that + # stopped being compiled into a failure instead of a silent "0 passed". + - name: wai-web native unit tests + shell: bash + working-directory: wai/wai-web + run: | + set -euo pipefail + cargo test --locked --lib 2>&1 | tee "$RUNNER_TEMP/wai-web-native.log" + grep -q "test result: ok. 6 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" - name: wasm32 int_synth decode equals the frozen native corpus shell: bash working-directory: wai/wai-web diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 0d3c73f76..801742eb3 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -26,9 +26,16 @@ wire format may change and such changes are recorded here. value; the JSON `afrac` must be the model's). *Envelopes this makes non-conforming:* a `WAI1` or `WAI2` that carries anything but the pinned format under these strings, a tool's JSON output among them. It still parses; a sink - refuses it at decode with `bad_magic` and does not try the fallback, and `wai wrap` - refuses to write it (`container::check_int_payload_framing`, also reported by - `wai inspect`). No committed envelope or conformance corpus carried one. + refuses it at decode (a JSON form with `bad_magic`) and does not try the fallback. + Every emit path refuses to write it: `Wai::to_emit_bytes` and + `WaiMulti::to_emit_bytes` (so the C packers `wai_envelope_pack` and + `wai_envelope_pack_multi`, `WAI_ERR_INVALID_ARG`), `wai-web`'s `pack` and + `packMulti`, and `wai wrap`. They run `container::check_int_payload_framing`, which + parses the payload with the wire parser a sink runs and the capability's keyframe + kind, so it checks every parse rule of the extension's §2–§6 and leaves to the + decode only what needs a parameter set or the streams (§7); `wai inspect` reports + what it finds. A refused emit is the new `EmitError::IntPayload`. No committed + envelope or conformance corpus carried such a payload. - **One integer decode path.** `int_payload::decode_int` decodes the strings above and `wai.neural.int_synth` and `wai.neural.int_mlicpp` from parameter files held in memory, with no ONNX runtime and no filesystem, natively and for `wasm32`, through a diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 13c1c0b3c..bb152df04 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -377,8 +377,9 @@ This revision fixes a payload format these strings' registrations did not have ( - a `WAI2` envelope with a component of a string of §1 whose bytes are not that string's format. -A sink refuses such a payload when it decodes it (§8), with no fallback attempt; a -tool that writes envelopes refuses to write one (`wai wrap`). No committed envelope or +A sink refuses such a payload when it decodes it (§8), with no fallback attempt. An +encoder MUST NOT write one; the reference implementation's emit paths (`wai wrap`, the +C packers and the browser writers) parse the payload as a sink does and refuse to. No committed envelope or conformance corpus carried such a payload when this revision was made. ## 12. Reference implementation and conformance @@ -391,8 +392,11 @@ conformance corpus carried such a payload when this revision was made. the set an unpinned decode reads. The neural sink (`neural::decode_payload`) delegates every integer capability to `decode_int`. - `codecs::PINNED_INT_PAYLOADS` is the one table of strings and magics; - `container::check_int_payload_framing` checks the magic and version without the - integer decoders, and `wai wrap` and `wai inspect` run it. + `container::check_int_payload_framing` parses a payload with `int_payload::wire` and + the capability's keyframe kind, which needs no integer decoder and no feature, so + every emit path runs it: `Wai::to_emit_bytes` and `WaiMulti::to_emit_bytes` (and so + the C packers), `wai-web`'s `pack` and `packMulti`, and `wai wrap`; `wai inspect` + reports it. - `video_receipt::VideoReceipt::seal_decode_wiv1` seals a `wai.video.int_motion` receipt whose identity is BLAKE3 over the `WIV1` payload. - `wai-web` decodes the four formats in the browser (`decodeWiv1`, `decodeWih1`, diff --git a/wai/wai-rs/include/wai.h b/wai/wai-rs/include/wai.h index 52b10caff..890afb605 100644 --- a/wai/wai-rs/include/wai.h +++ b/wai/wai-rs/include/wai.h @@ -403,7 +403,9 @@ extern "C" { * any fields; `payload` is the bytes from one of the codec encoders. * Refuses (`WAI_ERR_INVALID_ARG`) a manifest that names a candidate capability * as its capability or fallback, which an encoder MUST NOT emit (SPEC §5, - * *Candidate capabilities*): it emits through `Wai::to_emit_bytes`. + * *Candidate capabilities*), and a payload that is not the format SPEC §5 pins its + * integer capability or fallback to (*Payloads of the WAI-native integer codecs*): + * it emits through `Wai::to_emit_bytes`. */ int wai_envelope_pack(const char *manifest_json, @@ -426,7 +428,8 @@ int wai_envelope_pack(const char *manifest_json, * - The i-th payload pairs with `manifest.renditions[i]`. * * Refuses (`WAI_ERR_INVALID_ARG`) a table with a component, in either role, - * whose capability is a candidate (SPEC §5, *Candidate capabilities*): it emits + * whose capability is a candidate (SPEC §5, *Candidate capabilities*), or whose + * payload is not the format SPEC §5 pins its integer capability to: it emits * through `WaiMulti::to_emit_bytes`. * * # Safety diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index 5e491f6c7..574c0a2a2 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -366,7 +366,7 @@ fn inspect(path: &str) -> Result<(), String> { } if [Some(cap), fb].into_iter().flatten().any(|c| wai::codecs::pinned_int_payload(c).is_some()) { match w.check_int_payload_framing() { - Ok(()) => println!(" integer payload: \u{2713} the format SPEC \u{a7}5 pins (magic and version)"), + Ok(()) => println!(" integer payload: \u{2713} parses as the format SPEC \u{a7}5 pins (every parse rule; the decode checks the rest)"), Err(e) => println!(" integer payload: \u{2717} {e}; a sink refuses it at decode"), } } diff --git a/wai/wai-rs/src/container.rs b/wai/wai-rs/src/container.rs index 678d42551..7c7cce3c1 100644 --- a/wai/wai-rs/src/container.rs +++ b/wai/wai-rs/src/container.rs @@ -1036,9 +1036,9 @@ impl Wai { check_candidates_v1(self.capability(), self.fallback()) } - /// Check that the payload has the framing SPEC §5 pins for the `capability`, and - /// for the `fallback` (step 5 decodes the same payload through it), where either - /// is an integer capability with a pinned format ([`check_int_payload_framing`]). + /// Check that the payload is the format SPEC §5 pins for the `capability`, and for + /// the `fallback` (step 5 decodes the same payload through it), where either is an + /// integer capability with a pinned format ([`check_int_payload_framing`]). pub fn check_int_payload_framing(&self) -> Result<(), FramingError> { check_int_payload_framing(self.capability(), &self.payload)?; match self.fallback() { @@ -1047,11 +1047,14 @@ impl Wai { } } - /// The envelope's bytes, for an encoder to emit: [`Self::check_candidates`], - /// then [`Self::to_bytes`]. The C packer `wai_envelope_pack` emits through - /// this, so an encoder that uses it cannot emit a candidate (SPEC §5). + /// The envelope's bytes, for an encoder to emit: [`Self::check_candidates`], then + /// [`Self::check_int_payload_framing`], then [`Self::to_bytes`]. The C packer + /// `wai_envelope_pack` emits through this, so an encoder that uses it cannot emit a + /// candidate, or an integer payload that is not its capability's pinned format + /// (SPEC §5). pub fn to_emit_bytes(&self) -> Result, EmitError> { self.check_candidates().map_err(EmitError::Candidate)?; + self.check_int_payload_framing().map_err(|error| EmitError::IntPayload { component: None, error })?; self.to_bytes().map_err(EmitError::Serialize) } @@ -1342,47 +1345,48 @@ pub fn check_component_candidates<'a>(capabilities: impl IntoIterator) -> std::fmt::Result { let want = crate::codecs::pinned_int_payload(&self.capability).map(|m| String::from_utf8_lossy(&m).into_owned()); - write!(f, "the payload is not the {} payload SPEC \u{a7}5 pins {} to ({}; extensions/integer-payloads.md)", - want.as_deref().unwrap_or("?"), self.capability, self.code) + write!(f, "the payload is not the {} payload SPEC \u{a7}5 pins {} to ({}: {}; extensions/integer-payloads.md)", + want.as_deref().unwrap_or("?"), self.capability, self.code, self.detail) } } impl std::error::Error for FramingError {} -/// Check that `payload` has the framing of the format SPEC §5 pins `capability`'s -/// payload to ([`crate::codecs::PINNED_INT_PAYLOADS`]): its magic, then version 1. -/// `Ok` for a capability with no pinned integer format. The same first checks, in the -/// same order and with the same codes, as `int_payload::wire`'s parsers make. +/// Check that `payload` is the format SPEC §5 pins `capability`'s payload to +/// ([`crate::codecs::PINNED_INT_PAYLOADS`]): it is parsed with the wire parser a sink +/// runs (`int_payload::wire`, with the capability's keyframe kind for `WIV1`), which +/// checks every rule of the extension's §2–§6 in its order and reports the first that +/// fails ([`FramingError`]). `Ok` for a capability with no pinned integer format. It +/// needs no parameter set and no integer decoder, so every emit path runs it +/// ([`Wai::to_emit_bytes`], [`WaiMulti::to_emit_bytes`], `wai wrap`, the browser's +/// writers). pub fn check_int_payload_framing(capability: &str, payload: &[u8]) -> Result<(), FramingError> { - let Some(want) = crate::codecs::pinned_int_payload(capability) else { - return Ok(()); + use crate::int_wire as w; + let parsed = match crate::codecs::pinned_int_payload(capability) { + None => return Ok(()), + Some(w::MAGIC_WIV1) => w::parse_wiv1(payload, Some((capability == crate::codecs::CAP_VIDEO_INT_HYPER) as u8)).map(|_| ()), + Some(w::MAGIC_WIH1) => w::parse_wih1(payload).map(|_| ()), + Some(w::MAGIC_WIA1) => w::parse_wia1(payload).map(|_| ()), + Some(_) => w::parse_wis1(payload).map(|_| ()), }; - let fail = |code| Err(FramingError { capability: capability.to_string(), code }); - match payload.get(0..4) { - Some(m) if m == want => {} - Some(m) if crate::codecs::PINNED_INT_PAYLOADS.iter().any(|(_, k)| k[..] == *m) => return fail("format_capability_mismatch"), - _ => return fail("bad_magic"), - } - match payload.get(4) { - None => fail("stream_length"), - Some(1) => Ok(()), - Some(_) => fail("version"), - } + parsed.map_err(|e| FramingError { capability: capability.to_string(), code: e.code(), detail: e.detail }) } /// Why [`Wai::to_emit_bytes`] or [`WaiMulti::to_emit_bytes`] wrote nothing. @@ -1393,6 +1397,10 @@ pub enum EmitError { Candidate(CandidateError), /// The envelope did not serialise. Serialize(std::io::Error), + /// A payload is not the format SPEC §5 pins its integer capability to + /// ([`check_int_payload_framing`]): the `WAI2` component's index, or `None` for a + /// `WAI1`'s payload. + IntPayload { component: Option, error: FramingError }, } impl std::fmt::Display for EmitError { @@ -1400,6 +1408,8 @@ impl std::fmt::Display for EmitError { match self { EmitError::Candidate(e) => e.fmt(f), EmitError::Serialize(e) => write!(f, "envelope serialize: {e}"), + EmitError::IntPayload { component: Some(i), error } => write!(f, "component {i}: {error}"), + EmitError::IntPayload { component: None, error } => error.fmt(f), } } } @@ -1773,11 +1783,14 @@ impl WaiMulti { Ok(()) } - /// The envelope's bytes, for an encoder to emit: [`Self::check_candidates`], - /// then [`Self::to_bytes`]. The C packer `wai_envelope_pack_multi` emits - /// through this, so an encoder that uses it cannot emit a candidate (SPEC §5). + /// The envelope's bytes, for an encoder to emit: [`Self::check_candidates`], then + /// [`Self::check_int_payload_framing`], then [`Self::to_bytes`]. The C packer + /// `wai_envelope_pack_multi` emits through this, so an encoder that uses it cannot + /// emit a candidate, or a component whose integer payload is not its capability's + /// pinned format (SPEC §5). pub fn to_emit_bytes(&self) -> Result, EmitError> { self.check_candidates().map_err(EmitError::Candidate)?; + self.check_int_payload_framing().map_err(|(i, error)| EmitError::IntPayload { component: Some(i), error })?; self.to_bytes().map_err(EmitError::Serialize) } @@ -2741,6 +2754,41 @@ mod tests { assert_eq!(m.pick(|c| c == "wai.image.nope", no_parameter_sets()).unwrap().meta.capability, "wai.image.nope"); } + /// SPEC §5, *Payloads of the WAI-native integer codecs*: `to_emit_bytes` (the C + /// packers' emit path) parses an integer payload as a sink does and writes nothing + /// when it is not its capability's pinned format, as the capability, the fallback + /// or a `WAI2` component: a keyframe-0 `WIV1` under `wai.video.int_hyper`, a `WIV1` + /// cut inside its tables, a tool's JSON form. The committed payloads are written. + #[test] + fn the_emit_path_refuses_an_integer_payload_that_is_not_its_format() { + let read = |rel: &str| std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors").join(rel)).unwrap(); + let (k0, k1) = (read("int_video/int_video_bitstream.wiv1"), read("int_video/video_nk_bitstream.wiv1")); + let (mot, hyp) = (crate::codecs::CAP_VIDEO_INT_MOTION, crate::codecs::CAP_VIDEO_INT_HYPER); + let v1 = |cap: &str, fb: Option<&str>, p: &[u8]| { + let mut req = ModelRequirement::new(cap); + if let Some(f) = fb { + req = req.with_fallback(f); + } + Wai::new(Manifest::new("video", "improve", req, Conditioning::new("bytes")), p.to_vec()).to_emit_bytes() + }; + let code = |r: Result, EmitError>| match r { + Err(EmitError::IntPayload { component, error }) => Some((component, error.code)), + Err(e) => panic!("{e}"), + Ok(_) => None, + }; + assert_eq!(code(v1(hyp, None, &k0)), Some((None, "keyframe_capability_mismatch"))); + assert_eq!(code(v1(mot, None, &k0[..40])), Some((None, "stream_length"))); + assert_eq!(code(v1(mot, None, &read("int_video/int_video_bitstream.json"))), Some((None, "bad_magic"))); + assert_eq!(code(v1(crate::codecs::CAP_VIDEO_AV1, Some(mot), &k1)), Some((None, "keyframe_capability_mismatch"))); + assert_eq!(code(v1(mot, None, &k0)), None); + assert_eq!(code(v1(hyp, None, &k1)), None); + let v2 = |a: &[u8], b: &[u8]| WaiMulti::new("video", "improve", serde_json::Value::Null, vec![ + Rendition::media(mot, "bytes", a.to_vec()), Rendition::media(hyp, "bytes", b.to_vec()), + ]).to_emit_bytes(); + assert_eq!(code(v2(&k0, &k0)), Some((Some(1), "keyframe_capability_mismatch"))); + assert_eq!(code(v2(&k0, &k1)), None); + } + /// The emit path of the C packers (`wai_envelope_pack`, /// `wai_envelope_pack_multi`), driven exactly as they drive it: the manifest /// JSON parsed into the manifest type, the envelope built, and the bytes taken diff --git a/wai/wai-rs/src/ffi.rs b/wai/wai-rs/src/ffi.rs index 0a8699a98..bf5ee65a3 100644 --- a/wai/wai-rs/src/ffi.rs +++ b/wai/wai-rs/src/ffi.rs @@ -162,7 +162,7 @@ where F: FnOnce() -> std::result::Result, (c_int, String)> + panic::Unwi /// container's (`WAI_ERR_CONTAINER`). fn emit_err(e: crate::container::EmitError) -> (c_int, String) { match e { - crate::container::EmitError::Candidate(_) => (WAI_ERR_INVALID_ARG, e.to_string()), + crate::container::EmitError::Candidate(_) | crate::container::EmitError::IntPayload { .. } => (WAI_ERR_INVALID_ARG, e.to_string()), _ => (WAI_ERR_CONTAINER, e.to_string()), } } @@ -172,7 +172,9 @@ fn emit_err(e: crate::container::EmitError) -> (c_int, String) { /// any fields; `payload` is the bytes from one of the codec encoders. /// Refuses (`WAI_ERR_INVALID_ARG`) a manifest that names a candidate capability /// as its capability or fallback, which an encoder MUST NOT emit (SPEC §5, -/// *Candidate capabilities*): it emits through `Wai::to_emit_bytes`. +/// *Candidate capabilities*), and a payload that is not the format SPEC §5 pins its +/// integer capability or fallback to (*Payloads of the WAI-native integer codecs*): +/// it emits through `Wai::to_emit_bytes`. #[unsafe(no_mangle)] pub unsafe extern "C" fn wai_envelope_pack( manifest_json: *const c_char, @@ -226,7 +228,8 @@ pub unsafe extern "C" fn wai_envelope_detect_version( /// - The i-th payload pairs with `manifest.renditions[i]`. /// /// Refuses (`WAI_ERR_INVALID_ARG`) a table with a component, in either role, -/// whose capability is a candidate (SPEC §5, *Candidate capabilities*): it emits +/// whose capability is a candidate (SPEC §5, *Candidate capabilities*), or whose +/// payload is not the format SPEC §5 pins its integer capability to: it emits /// through `WaiMulti::to_emit_bytes`. /// /// # Safety diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index 9838aa303..28b450573 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -23,7 +23,7 @@ //! Every refusal is a code from the closed list of the extension's §8 //! ([`Refusal`]); a refusal is final, and nothing is decoded in its place. -pub mod wire; +pub use crate::int_wire as wire; #[cfg(test)] mod tests; diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index efe207bde..614162f47 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -339,32 +339,39 @@ fn capability_and_set_refusals() { assert_eq!(code(decode_int(crate::codecs::CAP_NEURAL_INT_SYNTH, &p, &unpinned(crate::codecs::CAP_NEURAL_INT_SYNTH, HeldBytes::File(&spec)))), "payload_malformed"); } -/// `container::check_int_payload_framing` (what `wai wrap` and `wai inspect` run, with -/// no `neural_int`) gives the parsers' code for every framing refusal, and accepts what -/// the parsers take past the version byte. +/// `container::check_int_payload_framing` (what every emit path, `wai wrap` and `wai +/// inspect` run, with no `neural_int`) is the wire parser with the capability's +/// keyframe kind: it refuses every payload with the parser's code, a keyframe-0 `WIV1` +/// under `wai.video.int_hyper` and a `WIV1` cut inside its header among them, and +/// accepts what the parser accepts. #[test] -fn framing_check_agrees_with_the_parsers() { - let framing = ["bad_magic", "format_capability_mismatch", "version", "stream_length"]; +fn framing_check_is_the_parser() { + let k0 = read("int_video/int_video_bitstream.wiv1"); let payloads: Vec> = vec![ - read("int_video/video_nk_bitstream.wiv1"), read("int_hyper/tiny_bitstream.wih1"), + k0.clone(), k0[..40].to_vec(), read("int_video/video_nk_bitstream.wiv1"), read("int_hyper/tiny_bitstream.wih1"), read("int_audio/audio_bitstream.wia1"), read("int_splat/splat.wis1"), read("int_splat/splat.json"), b"WIV1".to_vec(), b"WIH1\x02".to_vec(), b"WI".to_vec(), Vec::new(), ]; + let mut refused = std::collections::BTreeSet::new(); for &(cap, magic) in crate::codecs::PINNED_INT_PAYLOADS { for p in &payloads { let parsed = match &magic { - b"WIV1" => wire::parse_wiv1(p, None).err(), + b"WIV1" => wire::parse_wiv1(p, Some((cap == crate::codecs::CAP_VIDEO_INT_HYPER) as u8)).err(), b"WIH1" => wire::parse_wih1(p).err(), b"WIA1" => wire::parse_wia1(p).err(), _ => wire::parse_wis1(p).err(), }; - let framed = crate::container::check_int_payload_framing(cap, p).err().map(|e| e.code); - match parsed.map(|e| e.code()) { - Some(c) if framing.contains(&c) && !(c == "stream_length" && p.len() > 4) => assert_eq!(framed, Some(c), "{cap}, {} bytes", p.len()), - _ => assert_eq!(framed, None, "{cap}, {} bytes: the framing is right", p.len()), - } + let framed = crate::container::check_int_payload_framing(cap, p).err(); + assert_eq!(framed.as_ref().map(|e| (e.code, e.detail)), parsed.map(|e| (e.code(), e.detail)), "{cap}, {} bytes", p.len()); + refused.extend(framed.map(|e| e.code)); } } + for code in ["bad_magic", "format_capability_mismatch", "keyframe_capability_mismatch", "version", "stream_length"] { + assert!(refused.contains(code), "no case gave {code}"); + } + let hyp = crate::codecs::CAP_VIDEO_INT_HYPER; + assert_eq!(crate::container::check_int_payload_framing(hyp, &k0).unwrap_err().code, "keyframe_capability_mismatch"); + assert_eq!(crate::container::check_int_payload_framing(crate::codecs::CAP_VIDEO_INT_MOTION, &k0[..40]).unwrap_err().code, "stream_length"); assert_eq!(crate::container::check_int_payload_framing(crate::codecs::CAP_IMAGE_PNG, b"{}"), Ok(())); } diff --git a/wai/wai-rs/src/lib.rs b/wai/wai-rs/src/lib.rs index c84ae6af6..450522584 100644 --- a/wai/wai-rs/src/lib.rs +++ b/wai/wai-rs/src/lib.rs @@ -164,6 +164,14 @@ pub mod int_world; #[cfg(feature = "splat_int")] pub mod int_splat; +// The parsers and writers of the integer payload formats (`WIV1`, `WIH1`, `WIA1`, +// `WIS1`), with no dependency on the rest of the crate and no feature: the container's +// emit paths check a payload with them (`container::check_int_payload_framing`), and +// `int_payload` re-exports them as `int_payload::wire`. +#[doc(hidden)] +#[path = "int_payload/wire.rs"] +pub mod int_wire; + // The payloads of the WAI-native integer codecs (`extensions/integer-payloads.md`): // the WIV1, WIH1, WIA1 and WIS1 parsers and writers, the JSON converters, and // `decode_int`, the one integer decode path, with the pin and key-log checks over diff --git a/wai/wai-rs/tests/cli_wrap_intent.rs b/wai/wai-rs/tests/cli_wrap_intent.rs index 9897026e0..82fa31d46 100644 --- a/wai/wai-rs/tests/cli_wrap_intent.rs +++ b/wai/wai-rs/tests/cli_wrap_intent.rs @@ -158,10 +158,12 @@ fn wrap_holds_a_companion_envelope_to_the_same_intent_rule() { } /// SPEC §5 pins the payload of each WAI-native integer codec to a binary format -/// (`extensions/integer-payloads.md`). `wai wrap` checks the framing (`bad_magic`, -/// `format_capability_mismatch`, `version`) and writes nothing when a payload is not -/// its capability's format: a tool's JSON output is converted first, never wrapped. -/// The binary payloads wrap; `wai inspect` reports the framing of an envelope another +/// (`extensions/integer-payloads.md`). `wai wrap` parses the payload as a sink does +/// and writes nothing when it is not its capability's format: a tool's JSON output +/// (`bad_magic`, converted first, never wrapped), another format, a `WIV1` cut inside +/// its header (`stream_length`), and a keyframe-0 `WIV1` under `wai.video.int_hyper` +/// (`keyframe_capability_mismatch`), whose magic and version are right. The binary +/// payloads wrap; `wai inspect` reports what a parse finds in an envelope another /// writer made. #[test] fn wrap_refuses_a_payload_that_is_not_the_pinned_integer_format() { @@ -177,6 +179,7 @@ fn wrap_refuses_a_payload_that_is_not_the_pinned_integer_format() { ("wai.neural.int_hyper", "int_hyper/tiny_bitstream.json", "int_hyper/tiny_bitstream.wih1", "bad_magic"), ("wai.audio.int_codec", "int_video/video_nk_bitstream.wiv1", "int_audio/audio_bitstream.wia1", "format_capability_mismatch"), ("wai.splat.int_codec", "int_splat/splat.json", "int_splat/splat.wis1", "bad_magic"), + ("wai.video.int_hyper", "int_video/int_video_bitstream.wiv1", "int_video/video_nk_bitstream.wiv1", "keyframe_capability_mismatch"), ] { let out = scratch("int-payload.wai"); let run = wrap_file(cap, &v.join(json), &out, &["--intent", "improve"]); @@ -188,6 +191,13 @@ fn wrap_refuses_a_payload_that_is_not_the_pinned_integer_format() { assert!(run.status.success(), "{cap} {bin}: {}", String::from_utf8_lossy(&run.stderr)); assert_eq!(Wai::read(&out).unwrap().payload, std::fs::read(v.join(bin)).unwrap()); } + // A WIV1 cut inside its fixed header. + let cut = scratch("int-payload-cut.wiv1"); + std::fs::write(&cut, &std::fs::read(v.join("int_video/int_video_bitstream.wiv1")).unwrap()[..30]).unwrap(); + let out = scratch("int-payload-cut.wai"); + let run = wrap_file("wai.video.int_motion", &cut, &out, &["--intent", "improve"]); + assert!(!run.status.success() && String::from_utf8_lossy(&run.stderr).contains("stream_length"), "{}", String::from_utf8_lossy(&run.stderr)); + assert!(!out.exists()); // The same rule for a WAI2 base. let out = scratch("int-payload-hdr.wai"); let run = wrap_file("wai.video.int_motion", &v.join("int_video/int_video_bitstream.json"), &out, @@ -204,4 +214,14 @@ fn wrap_refuses_a_payload_that_is_not_the_pinned_integer_format() { let run = Command::new(env!("CARGO_BIN_EXE_wai")).arg("inspect").arg(&out).output().expect("run wai"); let stdout = String::from_utf8_lossy(&run.stdout); assert!(stdout.contains("integer payload:") && stdout.contains("bad_magic"), "{stdout}"); + // ...and one whose keyframe-0 WIV1 sits under wai.video.int_hyper. + let out = scratch("int-payload-k0.wai"); + wai::container::Wai::new( + wai::container::Manifest::new("video", "improve", wai::container::ModelRequirement::new("wai.video.int_hyper"), + wai::container::Conditioning::new("bytes")), + std::fs::read(v.join("int_video/int_video_bitstream.wiv1")).unwrap(), + ).write(&out).unwrap(); + let run = Command::new(env!("CARGO_BIN_EXE_wai")).arg("inspect").arg(&out).output().expect("run wai"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!(stdout.contains("integer payload:") && stdout.contains("keyframe_capability_mismatch"), "{stdout}"); } diff --git a/wai/wai-rs/tests/ffi_envelope.rs b/wai/wai-rs/tests/ffi_envelope.rs index ad4c1642c..4d2b569b6 100644 --- a/wai/wai-rs/tests/ffi_envelope.rs +++ b/wai/wai-rs/tests/ffi_envelope.rs @@ -180,3 +180,49 @@ fn envelope_pack_refuses_a_candidate() { assert!(out.data.is_null()); } } + +/// SPEC §5, *Payloads of the WAI-native integer codecs*: the C packers refuse a payload +/// that is not the format SPEC §5 pins its integer capability (or fallback, or `WAI2` +/// component) to, parsed as a sink parses it: a tool's JSON form, a `WIV1` cut inside +/// its header, and a keyframe-0 `WIV1` under `wai.video.int_hyper`, whose magic and +/// version are right. The committed payloads are written. +#[test] +fn envelope_pack_refuses_an_integer_payload_that_is_not_its_format() { + let vec = |rel: &str| std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors").join(rel)).unwrap(); + let (k0, k1, json) = (vec("int_video/int_video_bitstream.wiv1"), vec("int_video/video_nk_bitstream.wiv1"), vec("int_video/int_video_bitstream.json")); + let man = |cap: &str, fb: &str| CString::new(format!( + r#"{{"wai":"1.0","media":"video","intent":"improve","model_requirement":{{"capability":"{cap}","fallback":{fb}}},"conditioning":{{"kind":"bytes"}},"target":null}}"#)).unwrap(); + let pack = |m: &CString, p: &[u8]| { + let mut out = empty(); + let rc = unsafe { wai_envelope_pack(m.as_ptr(), p.as_ptr(), p.len(), &mut out) }; + if rc == WAI_OK { take(out); } else { assert!(out.data.is_null()); } + (rc, if rc == WAI_OK { String::new() } else { last_error() }) + }; + let (mot, hyp) = ("wai.video.int_motion", "wai.video.int_hyper"); + for (m, p, code) in [ + (man(mot, "null"), &json[..], "bad_magic"), + (man(mot, "null"), &k0[..40], "stream_length"), + (man(hyp, "null"), &k0[..], "keyframe_capability_mismatch"), + (man("wai.video.av1", &format!("\"{mot}\"")), &k1[..], "keyframe_capability_mismatch"), + ] { + let (rc, why) = pack(&m, p); + assert_eq!(rc, WAI_ERR_INVALID_ARG, "{code}"); + assert!(why.contains(&format!("({code}:")), "{why}"); + } + assert_eq!(pack(&man(mot, "null"), &k0).0, WAI_OK); + assert_eq!(pack(&man(hyp, "null"), &k1).0, WAI_OK); + + let multi = CString::new(format!( + r#"{{"wai":"1.1","media":"video","intent":"improve","renditions":[{{"capability":"{mot}","kind":"bytes"}},{{"capability":"{hyp}","kind":"bytes"}}],"target":null}}"#)).unwrap(); + let pack2 = |a: &[u8], b: &[u8]| { + let (ptrs, lens) = ([a.as_ptr(), b.as_ptr()], [a.len(), b.len()]); + let mut out = empty(); + let rc = unsafe { wai_envelope_pack_multi(multi.as_ptr(), ptrs.as_ptr(), lens.as_ptr(), 2, &mut out) }; + if rc == WAI_OK { take(out); } else { assert!(out.data.is_null()); } + (rc, if rc == WAI_OK { String::new() } else { last_error() }) + }; + assert_eq!(pack2(&k0, &k1).0, WAI_OK); + let (rc, why) = pack2(&k0, &k0); + assert_eq!(rc, WAI_ERR_INVALID_ARG); + assert!(why.contains("component 1:") && why.contains("(keyframe_capability_mismatch:"), "{why}"); +} diff --git a/wai/wai-web/src/lib.rs b/wai/wai-web/src/lib.rs index b63a00791..8ceec6613 100644 --- a/wai/wai-web/src/lib.rs +++ b/wai/wai-web/src/lib.rs @@ -460,19 +460,41 @@ pub fn parse(bytes: &[u8]) -> Result { Ok(WaiEnvelope { inner, manifest_json: String::from_utf8_lossy(m).into_owned() }) } +/// What [`pack`] refuses before it writes: a manifest that names a candidate as its +/// capability or fallback (SPEC §5, *Candidate capabilities*), and a payload that is +/// not the format SPEC §5 pins its integer capability or fallback to (*Payloads of the +/// WAI-native integer codecs*), checked by the wire parser a sink runs. The pure-Rust +/// half of the binding, which the native tests run. +fn pack_refusal(m: &wai::container::Manifest, payload: &[u8]) -> Result<(), String> { + let req = &m.model_requirement; + wai::container::check_candidates_v1(&req.capability, req.fallback.as_deref()).map_err(|e| e.to_string())?; + for cap in std::iter::once(req.capability.as_str()).chain(req.fallback.as_deref()) { + wai::container::check_int_payload_framing(cap, payload).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// What [`pack_multi`] refuses before it writes, once it holds the payloads: a +/// component whose payload is not the format SPEC §5 pins its integer capability to. +fn pack_multi_refusal(manifest: &wai::container::ManifestV2, payloads: &[Vec]) -> Result<(), String> { + for (i, (r, p)) in manifest.renditions.iter().zip(payloads).enumerate() { + wai::container::check_int_payload_framing(&r.capability, p).map_err(|e| format!("component {i}: {e}"))?; + } + Ok(()) +} + /// Build a WAI envelope from a manifest JSON string + a payload byte /// array. Mirrors `wai_envelope_pack` from the native SDK so JS code /// can produce .wai files in the browser too: like it, refuses a manifest /// that names a candidate capability as its capability or fallback, which an -/// encoder MUST NOT emit (SPEC §5, *Candidate capabilities*). The manifest is +/// encoder MUST NOT emit (SPEC §5, *Candidate capabilities*), and a payload that is +/// not the format SPEC §5 pins its integer capability or fallback to. The manifest is /// written as given. #[wasm_bindgen(js_name = pack)] pub fn pack(manifest_json: &str, payload: &[u8]) -> Result, JsError> { let m: wai::container::Manifest = serde_json::from_str(manifest_json) .map_err(|e| JsError::new(&format!("manifest parse: {e}")))?; - wai::container::check_candidates_v1(&m.model_requirement.capability, - m.model_requirement.fallback.as_deref()) - .map_err(|e| JsError::new(&e.to_string()))?; + pack_refusal(&m, payload).map_err(|e| JsError::new(&e))?; let mb = manifest_json.as_bytes(); let mut out = Vec::with_capacity(12 + mb.len() + payload.len()); out.extend_from_slice(wai::container::MAGIC); @@ -617,7 +639,8 @@ pub fn parse_multi(bytes: &[u8]) -> Result { /// The manifest's `renditions` array MUST be the same length and order as the /// payloads. The manifest is written as given. Like `wai_envelope_pack_multi`, /// refuses a table with a component, in either role, whose capability is a -/// candidate (SPEC §5). +/// candidate, or whose payload is not the format SPEC §5 pins its integer capability +/// to (SPEC §5). #[wasm_bindgen(js_name = packMulti)] pub fn pack_multi(manifest_json: &str, payloads: js_sys::Array) -> Result, JsError> { let manifest: wai::container::ManifestV2 = serde_json::from_str(manifest_json) @@ -641,6 +664,7 @@ pub fn pack_multi(manifest_json: &str, payloads: js_sys::Array) -> Result| { + let mut req = ModelRequirement::new(cap); + if let Some(f) = fallback { + req = req.with_fallback(f); + } + Manifest::new("video", "improve", req, Conditioning::new("bytes")) + }; + // The refusal names the code: "... (: ; ...)". + let refused = |r: Result<(), String>| r.err().and_then(|e| e.split_once(" to (").map(|(_, t)| t.split(':').next().unwrap_or_default().to_string())); + let (mot, hyp) = ("wai.video.int_motion", "wai.video.int_hyper"); + assert_eq!(super::pack_refusal(&v1(mot, None), &k0), Ok(())); + assert_eq!(super::pack_refusal(&v1(hyp, None), &k1), Ok(())); + assert_eq!(refused(super::pack_refusal(&v1(mot, None), &read("int_video/int_video_bitstream.json"))).as_deref(), Some("bad_magic")); + assert_eq!(refused(super::pack_refusal(&v1(mot, None), &k0[..40])).as_deref(), Some("stream_length")); + assert_eq!(refused(super::pack_refusal(&v1(hyp, None), &k0)).as_deref(), Some("keyframe_capability_mismatch")); + assert_eq!(refused(super::pack_refusal(&v1("wai.video.av1", Some(mot)), &k1)).as_deref(), Some("keyframe_capability_mismatch")); + let m2: ManifestV2 = serde_json::from_value(serde_json::json!({ + "wai": "1.1", "media": "video", "intent": "improve", + "renditions": [{"capability": mot, "kind": "bytes"}, {"capability": hyp, "kind": "bytes"}] + })).unwrap(); + assert_eq!(super::pack_multi_refusal(&m2, &[k0.clone(), k1.clone()]), Ok(())); + let e = super::pack_multi_refusal(&m2, &[k0.clone(), k0.clone()]).unwrap_err(); + assert!(e.starts_with("component 1:") && e.contains("keyframe_capability_mismatch"), "{e}"); + } + /// A companion-only component is never presented as media: not picked, not /// returned by `rendition`, and flagged `selectable: false` in the metadata. #[test] -- 2.54.0 (Apple Git-157) From 10c1597b7f874dc3342b8f8848d51503f4924a06 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 19:06:57 -0400 Subject: [PATCH 14/34] wai: integer decodes do only the work their streams back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 120-byte WIV1 (16 frames of 4729 x 4729 and an 8-byte stream) ran for about a minute and reached gigabytes before it was refused at its last symbol, and the JSON reader panicked on a side of 2^31. - Every integer decode (int_video, int_hyper_synth's checked conditional decode, int_audio, int_splat) stops at the first symbol during whose decode its stream runs out (stream_exhausted), before the value is used. The exhaustion flag is sticky, so a stream that never runs out decodes as before. - A frame, a clip or a latent is allocated only as its symbols are decoded; int_video holds frames as RGB8 and predicts from the last decoded frame, and reserves nothing from n_frames. - WIV1 caps the frame count at 2^20 and the motion-vector field at 2^26 elements, after the frame and clip caps (too_large); refusal fixtures wiv1_too_many_frames and wiv1_mv_field, checked by the generator and the Python verifier. - int_video::VideoJson reads each size as a usize it fits (absent otherwise), and caps every size before it pads a motion-vector list. - wai-web: decodeWiv1 keeps the decoder's frames (DecodedVideo.frame(i)) rather than concatenating the clip. - The Python verifier refuses at the first exhausted symbol, applies the new caps, and refuses a non-convertible JSON source explicitly instead of through assert. - integer-payloads.md §2, §3 and §7 state the early stop, the caps and the memory bound. Test: tests/int_payload_preflight.rs (counting allocator) refuses five starved payloads, the reviewer's two WIV1 among them, at the first exhausted symbol with no allocation sized from the header, and the two JSON headers without a panic or a header-sized allocation. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 16 +- wai/extensions/integer-payloads.md | 25 ++- .../int_payload_verify.py | 57 +++++-- .../refusals/wiv1_mv_field.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_too_many_frames.bin | Bin 0 -> 2148 bytes wai/int-payload-conformance/vectors.json | 24 +++ wai/wai-rs/src/bin/wai_int_payload_vectors.rs | 4 + wai/wai-rs/src/int_audio.rs | 16 +- wai/wai-rs/src/int_hyper_synth.rs | 28 ++-- wai/wai-rs/src/int_payload/wire.rs | 12 +- wai/wai-rs/src/int_splat.rs | 18 ++- wai/wai-rs/src/int_video.rs | 147 +++++++++++------- wai/wai-rs/tests/int_payload_preflight.rs | 76 ++++++++- wai/wai-web/demo/verify_int_payload_wasm.mjs | 6 +- wai/wai-web/src/video.rs | 31 ++-- 15 files changed, 325 insertions(+), 135 deletions(-) create mode 100644 wai/int-payload-conformance/refusals/wiv1_mv_field.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_too_many_frames.bin diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 801742eb3..37a7b985a 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -48,10 +48,18 @@ wire format may change and such changes are recorded here. `wai.video.int_motion`, which reads no set; the C ABI refuses an `Int` output. - **Checked decoders.** `int_video`, `int_audio` and `int_splat` decode a typed stream that the binary and JSON forms both read into. Every size is capped at - `2^26` elements (a `WIV1` clip at `2^30` bytes) the same on every target, every - network's shapes are planned before anything is allocated, every latent and layer - is checked for exactness, and a stream that runs out is refused, so a build with - overflow checks and one without accept the same bytes. `int_hyper_synth` gains + `2^26` elements (a `WIV1` clip at `2^30` bytes and `2^20` frames, its motion-vector + field at `2^26` elements) the same on every target, every network's shapes are + planned before anything is allocated, every latent and layer is checked for + exactness, and a stream that runs out is refused at the first symbol it cannot + give, so a build with overflow checks and one without accept the same bytes. A + frame, a clip or a latent is allocated only as its symbols are decoded, so the + memory and work a payload causes grow with what its streams carry rather than with + what its header declares; `int_video` holds frames as RGB8. The JSON reader + (`int_video::VideoJson`) caps every size before it pads a motion-vector list, and + reads a field that does not fit the target's `usize` as absent rather than + truncating it. In the browser, `decodeWiv1` keeps the decoder's frames + (`DecodedVideo.frame(i)`) instead of copying the clip into one buffer. `int_hyper_synth` gains `decode_conditional_checked`; `int_synth` refuses a side of `2^64` or more instead of shifting by it. - **Parameter sets.** SPEC §5 names the members the integer decoders read: diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index bb152df04..fe487d28c 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -60,8 +60,10 @@ escape of 4-bit groups): and `raw = Σ_{j < count} bits(4) << 4j`. The value is `−(raw >> 1) − 1` if `raw` is odd and `(raw >> 1) + (n − 2)` if it is even, saturated to `i32`. The symbol is `value + o`, saturated to `i32`. -- A decode that has run out of its stream by the end of its last symbol is refused - (`stream_exhausted`). +- A decode stops at the first symbol during whose decode its stream runs out, and + refuses the payload (`stream_exhausted`): the value that symbol would give is not + used, and nothing after it is decoded. A stream that never runs out decodes every + symbol, so a conforming payload is unaffected. **CDF tables.** A table is `n` entries of `u32` with `3 ≤ n ≤ 4097`, `cdf[0] = 0`, `cdf[n − 1] = 65536` and every entry greater than the one before. These rules are @@ -110,8 +112,9 @@ conforming sinks refuse the same bytes with the same code. The header's field rules are checked in the order `mv_precision`, `keyframe`, `n_frames`, `h` and `w`, `block`, `q`, `cdf_len`. `keyframe` 0 is `wai.video.int_motion`'s and 1 is `wai.video.int_hyper`'s; the other is -`keyframe_capability_mismatch`. A frame `h·w·3` and a clip `n_frames·h·w·3` are capped -(§7). +`keyframe_capability_mismatch`. The frame `h·w·3`, the clip `n_frames·h·w·3`, the frame +count `n_frames` and the motion-vector field `2·nb·(n_frames − 1)` are capped (§7), in +that order. **Motion vectors.** `nb = ⌈h/block⌉·⌈w/block⌉` and `nbx = ⌈w/block⌉`. The vectors are frames 1 to `n_frames − 1` in order, each its block grid row-major: the vector of @@ -252,14 +255,24 @@ not fix presentation. ## 7. Caps and the checks a decode makes **Caps, the same on every target.** No tensor a payload sizes may exceed `2^26` -elements: a frame `h·w·3`, a latent, a hyper-latent, a network's output, an attribute -tensor, a clip's `t_out`. A `WIV1` clip may not exceed `2^30` bytes. Before a decode +elements: a frame `h·w·3`, a `WIV1` motion-vector field `2·nb·(n_frames − 1)`, a +latent, a hyper-latent, a network's output, an attribute tensor, a clip's `t_out`. A +`WIV1` clip may not exceed `2^30` bytes, nor `2^20` frames. Before a decode allocates a latent, it derives every network's shapes from its layer geometry on the payload's shapes, and refuses (`too_large`) a payload on which any network would form a buffer, its input, a layer's output or a transposed convolution's uncropped plane, over `2^26` elements. Every size is formed in checked arithmetic, so a 32-bit sink accepts and refuses exactly what a 64-bit one does. +**Memory and work.** The caps bound what a payload can declare; the streams bound +what it can make a decode do. A decode stops at the first symbol that runs its stream +out (§2), and the reference decoders allocate a frame, a clip or a latent only as its +symbols are decoded, and a network's buffers only once its input has been decoded. So +the memory and the work a payload causes before it is refused grow with the symbols +its streams actually carry, not with what its header declares. A decode that +completes holds its output, at most `2^30` bytes for a clip, and, while a network +runs, that network's input and the buffers it forms, each at most `2^26` elements. + **The parameter set must fit the payload** (`model_mismatch`): - every network parses (the `WHM1` and `WAM1` forms, §9), and the networks of one set diff --git a/wai/int-payload-conformance/int_payload_verify.py b/wai/int-payload-conformance/int_payload_verify.py index d8ca3f8df..2c5ca6a6c 100644 --- a/wai/int-payload-conformance/int_payload_verify.py +++ b/wai/int-payload-conformance/int_payload_verify.py @@ -37,6 +37,7 @@ import sys MAGICS = (b"WIV1", b"WIH1", b"WIA1", b"WIS1") MAX_TENSOR = 1 << 26 MAX_CLIP = 1 << 30 +MAX_FRAMES = 1 << 20 MAX_MEDIAN_Q = 1 << 47 CAP_MAGIC = { "wai.video.int_motion": b"WIV1", @@ -155,8 +156,10 @@ def parse_wiv1(p, want_keyframe=None): raise Refused("keyframe_capability_mismatch", "keyframe") too_large([h, w, 3], MAX_TENSOR) too_large([n_frames, h, w, 3], MAX_CLIP) - cdf = r.cdf(cdf_len) + too_large([n_frames], MAX_FRAMES) nb = -(-h // block) * -(-w // block) + too_large([2, n_frames - 1, nb], MAX_TENSOR) + cdf = r.cdf(cdf_len) raw = r.take((n_frames - 1) * nb * 4) mvs = [struct.unpack_from("> 2 r = d.decode(v["cdf"], v["offset"]) + if d.exhausted: + # §2: the first symbol during whose decode the stream runs out. + raise Refused("stream_exhausted", "the residual stream ran out") cur[base + c] = min(max(pred + r * q, 0), 255) frames.append(bytes(cur)) prev = cur - if d.exhausted: - raise Refused("stream_exhausted", "the residual stream ran out") return frames @@ -435,14 +454,18 @@ def main(cdir): src = v["converted_from"] j = jl(src["json"]) m = CAP_MAGIC[cap] - if m == b"WIV1": - conv = wiv1_from_json(j, rd(src["keyframe"]) if "keyframe" in src else None) - elif m == b"WIH1": - conv = wih1_from_json(j) - elif m == b"WIA1": - conv = wia1_from_json(j, jl(src["sample_rate"])["sample_rate"]) - else: - conv = wis1_from_json(j) + try: + if m == b"WIV1": + conv = wiv1_from_json(j, rd(src["keyframe"]) if "keyframe" in src else None) + elif m == b"WIH1": + conv = wih1_from_json(j) + elif m == b"WIA1": + conv = wia1_from_json(j, jl(src["sample_rate"])["sample_rate"]) + else: + conv = wis1_from_json(j) + except NotConvertible as e: + fails.append(f"{name}: {src['json']} is not convertible: {e}") + continue if conv != p: fails.append(f"{name}: the independent conversion of {src['json']} differs from the payload") if m == b"WIV1" and fields["keyframe"] == 0: diff --git a/wai/int-payload-conformance/refusals/wiv1_mv_field.bin b/wai/int-payload-conformance/refusals/wiv1_mv_field.bin new file mode 100644 index 0000000000000000000000000000000000000000..8350fedaa698d0c084f644c2bab56323f724f881 GIT binary patch literal 2148 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 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/vectors.json b/wai/int-payload-conformance/vectors.json index 7c9abb29f..b2e5c4ba7 100644 --- a/wai/int-payload-conformance/vectors.json +++ b/wai/int-payload-conformance/vectors.json @@ -170,6 +170,30 @@ "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" } }, + { + "name": "wiv1_too_many_frames", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_too_many_frames.bin", + "code": "too_large", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wiv1_mv_field", + "capability": "wai.video.int_hyper", + "payload": "int-payload-conformance/refusals/wiv1_mv_field.bin", + "code": "too_large", + "layer": "parse", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, { "name": "wiv1_cdf_malformed", "capability": "wai.video.int_hyper", diff --git a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs index 8a77d9ee6..212eea888 100644 --- a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs +++ b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs @@ -205,6 +205,10 @@ fn fixtures(t: &mut Tree) -> Vec { fx(t, "wiv1_mv_precision", VNK, edit(&nk, &|b| b[5] = 3), None, "field_range", "parse"); fx(t, "wiv1_q_zero", VNK, edit(&nk, &|b| set_u32(b, 24, 0)), None, "field_range", "parse"); fx(t, "wiv1_too_large", VNK, edit(&nk, &|b| { set_u32(b, 12, 8192); set_u32(b, 16, 8192) }), None, "too_large", "parse"); + // 2^20 + 1 frames of 16 × 16: within the clip's 2^30 bytes, past the frame count. + fx(t, "wiv1_too_many_frames", VNK, edit(&nk, &|b| set_u32(b, 8, (1 << 20) + 1)), None, "too_large", "parse"); + // Block 1 over 131074 frames: 2·256·131073 motion-vector elements, past 2^26. + fx(t, "wiv1_mv_field", VNK, edit(&nk, &|b| { set_u32(b, 8, 131_074); set_u32(b, 20, 1) }), None, "too_large", "parse"); fx(t, "wiv1_cdf_malformed", VNK, edit(&nk, &|b| { let v = get_u32(b, 40); set_u32(b, 44, v) }), None, "cdf_malformed", "parse"); fx(t, "wiv1_stream_length", VNK, edit(&nk, &|b| { let l = get_u32(b, nk_l); set_u32(b, nk_l, l - 2) }), None, "stream_length", "parse"); fx(t, "wiv1_truncated", VNK, edit(&nk, &|b| b.truncate(nk_k + 2)), None, "stream_length", "parse"); diff --git a/wai/wai-rs/src/int_audio.rs b/wai/wai-rs/src/int_audio.rs index 480894769..5782fa191 100644 --- a/wai/wai-rs/src/int_audio.rs +++ b/wai/wai-rs/src/int_audio.rs @@ -355,17 +355,19 @@ pub fn decode_typed(model: &AudioModel, s: &AudioStream<'_>) -> Result, } // 1. factorized rANS-decode the per-channel latent (channel-major), rebuild y_q. - let mut y = vec![0i64; c * l]; + // The latent grows a symbol at a time, and the first symbol that runs the stream + // out refuses the payload before its value is used. + let mut y = Vec::new(); let mut dec = RansDecoder::new(s.rans).map_err(|_| refused(FieldRange, "the latent stream is not a rANS stream"))?; - for (ch, yc) in y.chunks_mut(l).enumerate() { - for v in yc { + for ch in 0..c { + for _ in 0..l { let sym = dec.decode(&s.cdfs[ch], len, s.offset); - *v = dequantize(sym, afrac, s.median_q[ch]).ok_or(refused(FieldRange, "a latent value leaves i64"))?; + if dec.is_exhausted() { + return Err(refused(StreamExhausted, "the latent stream ended before its last symbol")); + } + y.push(dequantize(sym, afrac, s.median_q[ch]).ok_or(refused(FieldRange, "a latent value leaves i64"))?); } } - if dec.is_exhausted() { - return Err(refused(StreamExhausted, "the latent stream ended before its last symbol")); - } // 2. integer synthesis → waveform, each layer checked on the values it is given. let (mut x, mut cc, mut tt) = (y, c, l); for layer in &model.layers { diff --git a/wai/wai-rs/src/int_hyper_synth.rs b/wai/wai-rs/src/int_hyper_synth.rs index ddaa01044..b47bededc 100644 --- a/wai/wai-rs/src/int_hyper_synth.rs +++ b/wai/wai-rs/src/int_hyper_synth.rs @@ -978,30 +978,32 @@ pub fn decode_conditional_checked( if oc != 3 { return Err(refused(ModelMismatch, "g_s does not give three channels")); } - // 1. factorized-decode z, rebuild z_q = s·2^afrac + median_q. - let mut z = vec![0i64; n_z * zplane]; + // 1. factorized-decode z, rebuild z_q = s·2^afrac + median_q. The latent grows a + // symbol at a time, and the first symbol that runs its stream out refuses the + // payload before its value is used. + let mut z = Vec::new(); let mut dz = RansDecoder::new(bs.z_rans).map_err(|_| refused(FieldRange, "the z stream is not a rANS stream"))?; - for (ch, zc) in z.chunks_mut(zplane).enumerate() { - for v in zc { + for ch in 0..n_z { + for _ in 0..zplane { let s = dz.decode(&ent.z_cdfs[ch], ent.z_cdf_lengths[ch], ent.z_offsets[ch]); - *v = dequantize(s, afrac, ent.z_median_q[ch]).ok_or(refused(FieldRange, "a hyper-latent value leaves i64"))?; + if dz.is_exhausted() { + return Err(refused(StreamExhausted, "the z stream ended before its last symbol")); + } + z.push(dequantize(s, afrac, ent.z_median_q[ch]).ok_or(refused(FieldRange, "a hyper-latent value leaves i64"))?); } } - if dz.is_exhausted() { - return Err(refused(StreamExhausted, "the z stream ended before its last symbol")); - } let sm = run_checked(h_s, &z, n_z, zh, zw)?; let (sigma, mu) = sm.split_at(n); // 2. y, conditioned on the integer σ: y_hat = r·2^afrac + μ. let mut dec = RansDecoder::new(bs.rans).map_err(|_| refused(FieldRange, "the y stream is not a rANS stream"))?; - let mut y_hat = vec![0i64; n]; + let mut y_hat = Vec::new(); for p in 0..n { let b = det_build_index(sigma[p], &ent.edges); let r = dec.decode(&ent.cdfs[b], ent.cdf_lengths[b], ent.offsets[b]); - y_hat[p] = dequantize(r, afrac, mu[p]).ok_or(refused(FieldRange, "a latent value leaves i64"))?; - } - if dec.is_exhausted() { - return Err(refused(StreamExhausted, "the y stream ended before its last symbol")); + if dec.is_exhausted() { + return Err(refused(StreamExhausted, "the y stream ended before its last symbol")); + } + y_hat.push(dequantize(r, afrac, mu[p]).ok_or(refused(FieldRange, "a latent value leaves i64"))?); } // 3. integer synthesis g_s → pixels. let x = run_checked(g_s, &y_hat, m, sh, sw)?; diff --git a/wai/wai-rs/src/int_payload/wire.rs b/wai/wai-rs/src/int_payload/wire.rs index 602152f9f..8e615ec8c 100644 --- a/wai/wai-rs/src/int_payload/wire.rs +++ b/wai/wai-rs/src/int_payload/wire.rs @@ -45,8 +45,9 @@ pub const MAGICS: [[u8; 4]; 4] = [MAGIC_WIV1, MAGIC_WIH1, MAGIC_WIA1, MAGIC_WIS1 pub const VERSION: u8 = 1; /// The most elements any one tensor a payload sizes may hold: a frame (`h·w·3`), a -/// latent, an attribute tensor, a decoded clip's samples. Also the cap on every -/// activation a decode forms on the way ([integer-payloads] §7). +/// `WIV1` motion-vector field (`2·nb·(n_frames − 1)`), a latent, an attribute tensor, a +/// decoded clip's samples. Also the cap on every activation a decode forms on the way +/// ([integer-payloads] §7). /// /// cbindgen:ignore pub const MAX_TENSOR: u64 = 1 << 26; @@ -54,6 +55,10 @@ pub const MAX_TENSOR: u64 = 1 << 26; /// /// cbindgen:ignore pub const MAX_CLIP: u64 = 1 << 30; +/// The most frames a `WIV1` clip may hold. +/// +/// cbindgen:ignore +pub const MAX_FRAMES: u64 = 1 << 20; /// The largest fixed-point scale a payload may declare (`afrac` in `1..=30`). /// /// cbindgen:ignore @@ -435,8 +440,9 @@ pub fn parse_wiv1(p: &[u8], want_keyframe: Option) -> Result, WireE } capped(&[h as u64, w as u64, 3], MAX_TENSOR, "a frame exceeds 2^26 elements")?; capped(&[n_frames as u64, h as u64, w as u64, 3], MAX_CLIP, "the clip exceeds 2^30 bytes")?; + capped(&[n_frames as u64], MAX_FRAMES, "the clip exceeds 2^20 frames")?; + let n_mv = capped(&[2, n_frames as u64 - 1, blocks(h, w, block)], MAX_TENSOR, "the motion vectors exceed 2^26 elements")? / 2; let cdf = c.cdf(cdf_len as u64, "the CDF table")?; - let n_mv = product(&[n_frames as u64 - 1, blocks(h, w, block)]).ok_or(err(Refusal::StreamLength, "the motion vectors"))?; let raw = c.take(n_mv.checked_mul(4).unwrap_or(u64::MAX), "the motion vectors")?; let mvs = raw .chunks_exact(4) diff --git a/wai/wai-rs/src/int_splat.rs b/wai/wai-rs/src/int_splat.rs index df63278a5..93f9542ed 100644 --- a/wai/wai-rs/src/int_splat.rs +++ b/wai/wai-rs/src/int_splat.rs @@ -147,19 +147,21 @@ pub fn decode_typed(gs: &IntModel, st: &SplatStream<'_>) -> Result usize { - v[k].as_u64().unwrap_or(0) as usize +/// A JSON member as a `usize`, refused when it is absent, not an integer, or does not +/// fit the target's `usize` (so a 32-bit sink refuses what it cannot index rather than +/// truncating it). +fn u(v: &Value, k: &'static str) -> Result { + v[k].as_u64().and_then(|n| usize::try_from(n).ok()).ok_or(k) } /// The most elements one decoded frame may hold (`h·w·3`), the same on every target. @@ -94,6 +97,32 @@ pub const MAX_FRAME: usize = 1 << 26; /// /// cbindgen:ignore pub const MAX_CLIP: usize = 1 << 30; +/// The most frames a clip may hold. +/// +/// cbindgen:ignore +pub const MAX_FRAMES: usize = 1 << 20; +/// The most elements the motion-vector field may hold (`2·nb·(n_frames − 1)`). +/// +/// cbindgen:ignore +pub const MAX_MV: usize = 1 << 26; + +/// The sizes a clip's header declares, checked in the order the extension's §3 caps +/// them, with checked arithmetic: the frame `h·w·3`, the clip `n_frames·h·w·3`, the +/// frame count, and the motion-vector field `2·nb·(n_frames − 1)`. Returns the frame's +/// element count and `nb`. +fn caps(n_frames: usize, h: usize, w: usize, block: usize) -> Result<(usize, usize), &'static str> { + let frame = h.checked_mul(w).and_then(|v| v.checked_mul(3)).filter(|&n| n <= MAX_FRAME) + .ok_or("a frame exceeds 2^26 elements")?; + frame.checked_mul(n_frames).filter(|&n| n <= MAX_CLIP).ok_or("the clip exceeds 2^30 bytes")?; + if n_frames > MAX_FRAMES { + return Err("the clip exceeds 2^20 frames"); + } + // h·w ≤ 2^26, so nb fits. + let nb = h.div_ceil(block) * w.div_ceil(block); + nb.checked_mul(n_frames.saturating_sub(1)).and_then(|v| v.checked_mul(2)).filter(|&n| n <= MAX_MV) + .ok_or("the motion vectors exceed 2^26 elements")?; + Ok((frame, nb)) +} /// The refusal [`decode_typed`] and [`decode_pframes_typed`] give when the residual /// stream runs out before the clip's last symbol. /// @@ -152,11 +181,18 @@ impl VideoJson { /// [`Self::from_value`] at the geometry `(h, w)` instead of the bitstream's own /// (the P-frames of a clip whose I-frame came from elsewhere). fn read(bs: &Value, geometry: Option<(usize, usize)>) -> Result { - let (block, nf) = (u(bs, "block"), u(bs, "n_frames")); - let (h, w) = geometry.unwrap_or((u(bs, "h"), u(bs, "w"))); - if block == 0 { - return Err("block is zero"); + let (block, nf) = (u(bs, "block")?, u(bs, "n_frames")?); + let (h, w) = match geometry { + Some(g) => g, + None => (u(bs, "h")?, u(bs, "w")?), + }; + if nf == 0 || h == 0 || w == 0 || block == 0 { + return Err("n_frames, h, w or block is zero"); } + // Every size is capped before anything is sized from it: a short list of + // motion vectors is padded below, so the field's size is the header's, not the + // JSON's. + let (_, nb) = caps(nf, h, w, block)?; let q = bs["q"].as_i64().ok_or("q")?; let offset = bs["offset"].as_i64().and_then(|o| i32::try_from(o).ok()).ok_or("offset")?; let cdf = bs["cdf"].as_array().ok_or("cdf")?.iter() @@ -177,8 +213,7 @@ impl VideoJson { if lists.first().and_then(Value::as_array).is_some_and(|l| !l.is_empty()) { return Err("the first frame carries motion vectors"); } - let nb = h.div_ceil(block) * w.div_ceil(block); - let mut mvs = Vec::new(); + let mut mvs = Vec::with_capacity(nb * (nf - 1)); for fr in lists.iter().skip(1) { let list = fr.as_array().map(Vec::as_slice).unwrap_or_default(); if list.len() > nb { @@ -210,11 +245,11 @@ impl VideoJson { /// sub-pixel motion (real footage) predicts well while the decode stays exact. #[inline] #[allow(clippy::too_many_arguments)] -fn predict(rf: &[i64], y: usize, x: usize, dy: i64, dx: i64, c: usize, prec: i64, h: usize, w: usize) -> i64 { +fn predict(rf: &[u8], y: usize, x: usize, dy: i64, dx: i64, c: usize, prec: i64, h: usize, w: usize) -> i64 { let p = |yy: i64, xx: i64| -> i64 { let cy = yy.clamp(0, h as i64 - 1) as usize; let cx = xx.clamp(0, w as i64 - 1) as usize; - rf[(cy * w + cx) * 3 + c] + rf[(cy * w + cx) * 3 + c] as i64 }; if prec == 1 { return p(y as i64 + dy, x as i64 + dx); @@ -231,10 +266,11 @@ fn predict(rf: &[i64], y: usize, x: usize, dy: i64, dx: i64, c: usize, prec: i64 } /// The checks both decode entry points make before allocating a frame: every field -/// in range, the CDF a table the rANS decoder is total over, the motion-vector count -/// the block grid's, and the frame and clip sizes under their caps, all formed with -/// checked arithmetic. Returns the frame's element count. -fn check(s: &VideoStream<'_>) -> Result { +/// in range, the CDF a table the rANS decoder is total over, the frame, clip, frame +/// count and motion-vector field under their caps ([`caps`]), and the motion-vector +/// count the block grid's, all formed with checked arithmetic. Returns the frame's +/// element count and `nb`. +fn check(s: &VideoStream<'_>) -> Result<(usize, usize), &'static str> { if s.n_frames == 0 || s.h == 0 || s.w == 0 || s.block == 0 { return Err("n_frames, h, w or block is zero"); } @@ -248,61 +284,69 @@ fn check(s: &VideoStream<'_>) -> Result { if c.len() < 3 || c.len() > 4097 || c[0] != 0 || c[c.len() - 1] != 1 << 16 || c.windows(2).any(|p| p[0] >= p[1]) { return Err("the CDF is not a table from 0 to 65536, strictly increasing, of 3..=4097 entries"); } - let frame = s.h.checked_mul(s.w).and_then(|v| v.checked_mul(3)).filter(|&n| n <= MAX_FRAME) - .ok_or("a frame exceeds 2^26 elements")?; - frame.checked_mul(s.n_frames).filter(|&n| n <= MAX_CLIP).ok_or("the clip exceeds 2^30 bytes")?; - let nb = s.h.div_ceil(s.block) * s.w.div_ceil(s.block); + let (frame, nb) = caps(s.n_frames, s.h, s.w, s.block)?; if (s.n_frames - 1).checked_mul(nb) != Some(s.mvs.len()) { return Err("the motion vectors are not one per block for frames 1..n_frames"); } - Ok(frame) + Ok((frame, nb)) } -/// One P-frame (or, from a grey `prev` of `None`, the I-frame): read its residual -/// symbols (raster `y, x, c`) and add them to the prediction. `r·q` is under `2^47` -/// in magnitude (the decoder saturates a symbol to `i32`, `q ≤ 65535`), so the sum -/// fits `i64`. -fn frame(s: &VideoStream<'_>, dec: &mut RansDecoder<'_>, prev: Option<&[i64]>, mvs: &[(i16, i16)]) -> Vec { +/// One frame (from a grey `prev` of `None`, the first frame of keyframe 0), appended +/// to `out` sample by sample as its residual symbols are read (raster `y, x, c`) and +/// added to the prediction. The frame is never allocated ahead of the symbols that +/// fill it, and the first symbol during whose decode the stream runs out refuses the +/// clip ([`STREAM_EXHAUSTED`]); its value is not used. `r·q` is under `2^47` in +/// magnitude (the decoder saturates a symbol to `i32`, `q ≤ 65535`), so the sum fits +/// `i64`. +fn frame(s: &VideoStream<'_>, dec: &mut RansDecoder<'_>, prev: Option<&[u8]>, mvs: &[(i16, i16)], out: &mut Vec) -> Result<(), &'static str> { let (h, w, block) = (s.h, s.w, s.block); let nbx = w.div_ceil(block); let prec = s.mv_precision as i64; - let mut out = vec![0i64; h * w * 3]; for y in 0..h { for x in 0..w { let (dy, dx) = mvs.get((y / block) * nbx + (x / block)).map_or((0, 0), |&(a, b)| (a as i64, b as i64)); - let base = (y * w + x) * 3; for c in 0..3 { let pred = match prev { None => 128, Some(rf) => predict(rf, y, x, dy, dx, c, prec, h, w), }; let r = dec.decode(s.cdf, s.cdf.len(), s.offset) as i64; // residual symbol - out[base + c] = (pred + r * s.q).clamp(0, 255); + if dec.is_exhausted() { + return Err(STREAM_EXHAUSTED); + } + out.push((pred + r * s.q).clamp(0, 255) as u8); } } } - out + Ok(()) +} + +/// Frames `first..n_frames` of a clip, each predicted from the last frame of `frames` +/// (which holds the frames before `first`), appended to `frames`. A clip holds no +/// buffer it has not decoded into: the frame list grows a frame at a time and each +/// frame a sample at a time. +fn decode_frames(s: &VideoStream<'_>, nb: usize, first: usize, frames: &mut Vec>) -> Result<(), &'static str> { + let mut dec = RansDecoder::new(s.rans)?; + for f in first..s.n_frames { + let mut cur = Vec::new(); + match f { + 0 => frame(s, &mut dec, None, &[], &mut cur)?, + _ => frame(s, &mut dec, frames.last().map(Vec::as_slice), &s.mvs[(f - 1) * nb..f * nb], &mut cur)?, + } + frames.push(cur); + } + Ok(()) } /// Decode a typed clip whose I-frame is predicted from grey (`WIV1` keyframe 0, /// `wai.video.int_motion`) → frames, byte-exact on every machine. Refuses, rather /// than panicking on or misreading, a stream that breaks [`check`]'s rules or runs -/// out before its last symbol ([`STREAM_EXHAUSTED`]). Pure i64. +/// out before its last symbol ([`STREAM_EXHAUSTED`], at the first symbol that runs it +/// out). Pure i64. pub fn decode_typed(s: &VideoStream<'_>) -> Result { - check(s)?; - let nb = s.h.div_ceil(s.block) * s.w.div_ceil(s.block); - let mut dec = RansDecoder::new(s.rans)?; - let mut frames: Vec> = Vec::with_capacity(s.n_frames); - let mut prev = frame(s, &mut dec, None, &[]); - frames.push(prev.iter().map(|&v| v as u8).collect()); - for f in 1..s.n_frames { - let cur = frame(s, &mut dec, Some(&prev), &s.mvs[(f - 1) * nb..f * nb]); - frames.push(cur.iter().map(|&v| v as u8).collect()); - prev = cur; - } - if dec.is_exhausted() { - return Err(STREAM_EXHAUSTED); - } + let (_, nb) = check(s)?; + let mut frames = Vec::new(); + decode_frames(s, nb, 0, &mut frames)?; Ok(DecodedClip { frames, h: s.h, w: s.w }) } @@ -311,23 +355,12 @@ pub fn decode_typed(s: &VideoStream<'_>) -> Result { /// keyframe 1 (`wai.video.int_hyper`), whose I-frame is the nested `WIH1`'s decode. /// The residual stream holds frames `1..n_frames` only. Pure i64. pub fn decode_pframes_typed(i_frame: &[u8], s: &VideoStream<'_>) -> Result { - let n = check(s)?; + let (n, nb) = check(s)?; if i_frame.len() != n { return Err("i_frame size disagrees with h·w·3"); } - let nb = s.h.div_ceil(s.block) * s.w.div_ceil(s.block); - let mut dec = RansDecoder::new(s.rans)?; - let mut frames: Vec> = Vec::with_capacity(s.n_frames); - frames.push(i_frame.to_vec()); - let mut prev: Vec = i_frame.iter().map(|&b| b as i64).collect(); - for f in 1..s.n_frames { - let cur = frame(s, &mut dec, Some(&prev), &s.mvs[(f - 1) * nb..f * nb]); - frames.push(cur.iter().map(|&v| v as u8).collect()); - prev = cur; - } - if dec.is_exhausted() { - return Err(STREAM_EXHAUSTED); - } + let mut frames = vec![i_frame.to_vec()]; + decode_frames(s, nb, 1, &mut frames)?; Ok(DecodedClip { frames, h: s.h, w: s.w }) } diff --git a/wai/wai-rs/tests/int_payload_preflight.rs b/wai/wai-rs/tests/int_payload_preflight.rs index 78f1e21fc..274cffd98 100644 --- a/wai/wai-rs/tests/int_payload_preflight.rs +++ b/wai/wai-rs/tests/int_payload_preflight.rs @@ -80,20 +80,21 @@ fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() let unpinned = |cap, held| resolve(cap, None, &[held], None).expect("an unpinned set resolves"); let hyp = unpinned(c::CAP_NEURAL_INT_HYPER, HeldBytes::Set { listing: b"", files: &hyp_view }); let (model, gs) = (read("int_audio/audio_model.bin"), read("int_splat/splat_gs.bin")); + let none = IntParams::none(); let (aud, spl) = (unpinned(c::CAP_AUDIO_INT_CODEC, HeldBytes::File(&model)), unpinned(c::CAP_SPLAT_INT_CODEC, HeldBytes::File(&gs))); let (iv, nk, ih) = (read("int_video/int_video_bitstream.wiv1"), read("int_video/video_nk_bitstream.wiv1"), read("int_hyper/tiny_bitstream.wih1")); let (au, sp) = (read("int_audio/audio_bitstream.wia1"), read("int_splat/splat.wis1")); // The counter sees a decode's allocations: the committed clip's largest is at least - // one 48 × 48 frame of i64. + // one 48 × 48 frame of RGB8. let (r, largest) = largest_in(|| decode_int(c::CAP_VIDEO_INT_MOTION, &iv, &IntParams::none())); r.expect("the committed clip decodes"); - assert!(largest >= 48 * 48 * 3 * 8, "the counter saw a {largest}-byte allocation at most"); + assert!(largest >= 48 * 48 * 3, "the counter saw a {largest}-byte allocation at most"); let hyp_bytes: usize = hyp_view.values().map(|v| v.len()).sum(); let cases: Vec<(&str, &str, Vec, &IntParams<'_>, &str, usize)> = vec![ // A 2^20-frame clip of 48 × 48: each frame is within the cap, the clip is not. - ("a 2^20-frame clip", c::CAP_VIDEO_INT_MOTION, with(&iv, &[(8, 1 << 20)]), &IntParams::none(), "too_large", 0), + ("a 2^20-frame clip", c::CAP_VIDEO_INT_MOTION, with(&iv, &[(8, 1 << 20)]), &none, "too_large", 0), // An 8192 × 8192 frame. ("an 8192 × 8192 frame", c::CAP_VIDEO_INT_HYPER, with(&nk, &[(12, 8192), (16, 8192)]), &hyp, "too_large", hyp_bytes), // A hyper-latent of 2 × 4000 × 4000, past the cap. @@ -114,4 +115,73 @@ fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() assert!(largest <= bound, "{what}: refusing it allocated {largest} bytes at once, over {bound} ({r:?})"); assert_eq!(r.map(|_| ()).map_err(|e| e.code()), Err(code), "{what}"); } + + // Headers every cap admits, whose streams are 8 bytes and cannot carry what the + // header declares (§2, §7): each is refused at the first symbol that runs its + // stream out, having allocated only what it decoded. Before this, the 16-frame + // clip of 4729 × 4729 ran for about a minute and reached gigabytes before it was + // refused at its last symbol. + use wai::int_payload::wire; + let (zeros, ones) = ([0u8; 8], [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f]); + let clip = |n_frames: u32, h: u32, w: u32, block: u32, cdf: Vec, rans: &[u8]| wire::write_wiv1(&wire::Wiv1 { + mv_precision: 1, keyframe: 0, n_frames, h, w, block, q: 1, offset: 0, cdf, + mvs: vec![(0, 0); n_frames as usize - 1], rans, wih1: None, + }); + let wih1 = { + // The tiny h_s doubles the hyper-latent's sides. + let mut k = wire::parse_wih1(&ih).unwrap(); + (k.zh, k.zw, k.sh, k.sw) = (64, 64, 128, 128); + k.z_rans = &ones[..]; + wire::write_wih1(&k) + }; + let wia1 = { + let mut a = wire::parse_wia1(&au).unwrap(); + a.lat_len = 1 << 12; + a.rans = &ones[..]; + wire::write_wia1(&a) + }; + let wis1 = { + // The splat g_s quadruples the latent's sides. + let mut v = wire::parse_wis1(&sp).unwrap(); + (v.sh, v.sw, v.s) = (64, 64, 256); + v.rans = &ones[..]; + wire::write_wis1(&v) + }; + let starved: Vec<(&str, &str, Vec, &IntParams<'_>, usize)> = vec![ + // Small enough that a decode which ran on past the first exhausted symbol would + // finish here quickly, and allocate a 256 × 256 frame. + ("16 frames of 256 × 256", c::CAP_VIDEO_INT_MOTION, clip(16, 256, 256, 1 << 31, vec![0, 1, 65_536], &zeros), &none, 0), + ("16 frames of 4729 × 4729", c::CAP_VIDEO_INT_MOTION, clip(16, 4729, 4729, 1 << 31, vec![0, 1, 65_536], &zeros), &none, 0), + ("one frame of 4096 × 5461", c::CAP_VIDEO_INT_MOTION, clip(1, 4096, 5461, u32::MAX, vec![0, 65_534, 65_535, 65_536], &ones), &none, 0), + ("a hyper-latent of 2 × 64 × 64", c::CAP_NEURAL_INT_HYPER, wih1, &hyp, hyp_bytes), + ("an audio latent of 16 × 4096", c::CAP_AUDIO_INT_CODEC, wia1, &aud, model.len()), + ("a splat latent of 32 × 64 × 64", c::CAP_SPLAT_INT_CODEC, wis1, &spl, gs.len()), + ]; + for (what, cap, bad, params, set_bytes) in starved { + assert!(wai::container::check_int_payload_framing(cap, &bad).is_ok(), "{what}: the payload parses"); + let (r, largest) = largest_in(|| decode_int(cap, &bad, params)); + let bound = FEW_KB.max(4 * set_bytes); + assert!(largest <= bound, "{what}: refusing it allocated {largest} bytes at once, over {bound} ({r:?})"); + assert_eq!(r.map(|_| ()).map_err(|e| e.code()), Err("stream_exhausted"), "{what}"); + } + + // The tools' JSON form, read by int_video::VideoJson (the converter's and the JSON + // decode's reader): a header past the caps is refused before anything is sized + // from it, on every target. A side of 2^31 made the frame size overflow and the + // reader panic; sixteen frames of 4096 × 4096 at block 1, each with an empty list, + // were padded to 2^28 motion vectors before any cap was checked. + use serde_json::json; + let js = |side: u64, nf: usize| json!({ + "block": 1, "q": 1, "offset": 0, "cdf": [0, 1, 65_536], "n_frames": nf, "h": side, "w": side, + "mvs": vec![json!([]); nf], "rans_b64": "AAAAAAAAAAA=" + }); + for (what, j) in [("h = w = 2^31", js(1 << 31, 2)), ("2^28 padded motion vectors", js(4096, 16))] { + let (r, largest) = largest_in(|| std::panic::catch_unwind(|| ( + wai::int_video::VideoJson::from_value(&j).is_err(), + wai::int_payload::wiv1_from_json(&j, None).map(|_| ()).map_err(|e| e.code()), + wai::int_video::decode(&j).is_err(), + ))); + assert_eq!(r.expect("the JSON reader must not panic"), (true, Err("field_range"), true), "{what}"); + assert!(largest <= FEW_KB, "{what}: refusing it allocated {largest} bytes at once"); + } } diff --git a/wai/wai-web/demo/verify_int_payload_wasm.mjs b/wai/wai-web/demo/verify_int_payload_wasm.mjs index 556b58300..8f161f85e 100644 --- a/wai/wai-web/demo/verify_int_payload_wasm.mjs +++ b/wai/wai-web/demo/verify_int_payload_wasm.mjs @@ -24,7 +24,11 @@ const sha = (b) => createHash('sha256').update(b).digest(); const hex = (b) => Buffer.from(b).toString('hex'); const clip = (v) => { const n = v.width * v.height * 3, rgb = v.rgb, digests = []; - for (let f = 0; f < v.nFrames; f++) digests.push(sha(rgb.subarray(f * n, (f + 1) * n))); + for (let f = 0; f < v.nFrames; f++) { + const one = v.frame(f); + if (one.length !== n || !one.every((b, i) => b === rgb[f * n + i])) throw new Error(`frame ${f} differs from rgb`); + digests.push(sha(one)); + } return hex(sha(Buffer.concat(digests))); }; const [g, h, e] = [rd('int_hyper/tiny_g_s.bin'), rd('int_hyper/tiny_h_s.bin'), rd('int_hyper/tiny_entropy.json')]; diff --git a/wai/wai-web/src/video.rs b/wai/wai-web/src/video.rs index 8733c4f8c..8fe707d52 100644 --- a/wai/wai-web/src/video.rs +++ b/wai/wai-web/src/video.rs @@ -9,26 +9,32 @@ use wasm_bindgen::prelude::*; -/// A decoded clip: all frames concatenated (`n_frames · width · height · 3` -/// bytes), to be sliced per frame on the JS side and painted to a canvas. +/// A decoded clip: its frames, each `width · height · 3` bytes of RGB, as the +/// decoder produced them. `frame(i)` hands JS one frame; `rgb` concatenates them all +/// into one new buffer on each read, so a page that paints frame by frame holds the +/// clip once. #[wasm_bindgen] pub struct DecodedVideo { - rgb: Vec, - n_frames: u32, + frames: Vec>, width: u32, height: u32, } #[wasm_bindgen] impl DecodedVideo { - /// All frames, concatenated: frame `f` is `rgb[f·w·h·3 .. (f+1)·w·h·3]`. + /// All frames, concatenated into a new buffer: frame `f` is + /// `rgb[f·w·h·3 .. (f+1)·w·h·3]`. #[wasm_bindgen(getter)] pub fn rgb(&self) -> Box<[u8]> { - self.rgb.clone().into_boxed_slice() + self.frames.concat().into_boxed_slice() + } + /// Frame `index` (`width · height · 3` bytes), or an empty buffer past the last. + pub fn frame(&self, index: u32) -> Box<[u8]> { + self.frames.get(index as usize).cloned().unwrap_or_default().into_boxed_slice() } #[wasm_bindgen(getter, js_name = nFrames)] pub fn n_frames(&self) -> u32 { - self.n_frames + self.frames.len() as u32 } #[wasm_bindgen(getter)] pub fn width(&self) -> u32 { @@ -54,10 +60,7 @@ pub fn decode_wiv1( let set = crate::int_payload::int_hyper_set(&g, &h, &e); let set: &[(&str, &[u8])] = if capability == "wai.video.int_hyper" { &set } else { &[] }; match crate::int_payload::decode(capability, payload, set).map_err(|e| JsError::new(&e))? { - wai::int_payload::IntDecoded::Video { frames, width, height } => { - let n_frames = frames.len() as u32; - Ok(DecodedVideo { rgb: frames.concat(), n_frames, width, height }) - } + wai::int_payload::IntDecoded::Video { frames, width, height } => Ok(DecodedVideo { frames, width, height }), _ => Err(JsError::new("unsupported_capability: not an integer video capability")), } } @@ -89,9 +92,5 @@ pub fn decode_int_video_pframes(i_frame: &[u8], w: u32, h: u32, bitstream_json: } fn flatten(clip: wai::int_video::DecodedClip) -> DecodedVideo { - let mut rgb = Vec::with_capacity(clip.frames.len() * clip.h * clip.w * 3); - for f in &clip.frames { - rgb.extend_from_slice(f); - } - DecodedVideo { rgb, n_frames: clip.frames.len() as u32, width: clip.w as u32, height: clip.h as u32 } + DecodedVideo { width: clip.w as u32, height: clip.h as u32, frames: clip.frames } } -- 2.54.0 (Apple Git-157) From ec9fc1c85b1fd075ae8c829d20d414be8c7f4654 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 19:18:08 -0400 Subject: [PATCH 15/34] wai: integer payload streams must end canonically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unread word after a rANS stream's last symbol, or a final state other than 2^31, coded the same symbols in other bytes, so one decode could have many content hashes. - rans::RansDecoder::is_canonical_end: never ran out, every word read, and the state 2^31 an encoder starts from. - Each stream of WIV1, WIH1 (both streams, nested ones included), WIA1 and WIS1 is checked after its last symbol; otherwise the payload is refused with the new code stream_noncanonical (Refusal::ALL is now 19 codes). - Fixtures: an appended word under each format, a one-pixel WIV1 coded from the state 2^31 + 1 (its canonical twin, coded from 2^31, decodes to a grey pixel in both verifiers), and a one-frame keyframe-1 WIV1 whose empty residual stream is not the state 2^31 alone. The Python verifier checks the end of every stream it decodes. - Every committed stream ends canonically; integer-payloads.md §2 and §8 state the rule. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 8 ++- wai/extensions/integer-payloads.md | 13 +++- .../int_payload_verify.py | 22 ++++++- .../refusals/wia1_unread_word.bin | Bin 0 -> 1448 bytes .../refusals/wih1_unread_word.bin | Bin 0 -> 92 bytes .../refusals/wis1_unread_word.bin | Bin 0 -> 2968 bytes .../refusals/wiv1_end_state.bin | Bin 0 -> 64 bytes .../refusals/wiv1_one_frame_end_state.bin | Bin 0 -> 496 bytes .../refusals/wiv1_unread_word.bin | Bin 0 -> 17988 bytes wai/int-payload-conformance/vectors.json | 60 ++++++++++++++++++ wai/wai-rs/src/bin/wai_int_payload_vectors.rs | 39 ++++++++++++ wai/wai-rs/src/int_audio.rs | 3 + wai/wai-rs/src/int_hyper_synth.rs | 8 +++ wai/wai-rs/src/int_payload/mod.rs | 10 ++- wai/wai-rs/src/int_payload/wire.rs | 7 +- wai/wai-rs/src/int_splat.rs | 3 + wai/wai-rs/src/int_video.rs | 9 +++ wai/wai-rs/src/rans.rs | 10 +++ wai/wai-rs/tests/int_payload.rs | 2 +- 19 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 wai/int-payload-conformance/refusals/wia1_unread_word.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_unread_word.bin create mode 100644 wai/int-payload-conformance/refusals/wis1_unread_word.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_end_state.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_one_frame_end_state.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_unread_word.bin diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 37a7b985a..53bcd5b6a 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -59,7 +59,13 @@ wire format may change and such changes are recorded here. (`int_video::VideoJson`) caps every size before it pads a motion-vector list, and reads a field that does not fit the target's `usize` as absent rather than truncating it. In the browser, `decodeWiv1` keeps the decoder's frames - (`DecodedVideo.frame(i)`) instead of copying the clip into one buffer. `int_hyper_synth` gains + (`DecodedVideo.frame(i)`) instead of copying the clip into one buffer. +- **Canonical streams.** After its last symbol, each rANS stream of these payloads + must have been read to its last word and end in the state `2^31`, where an encoder + starts (`rans::RansDecoder::is_canonical_end`); otherwise the payload is refused + with the new code `stream_noncanonical`. An unread word or another starting state + coded the same symbols in other bytes. Every committed stream, the JSON forms' + included, ends canonically. `int_hyper_synth` gains `decode_conditional_checked`; `int_synth` refuses a side of `2^64` or more instead of shifting by it. - **Parameter sets.** SPEC §5 names the members the integer decoders read: diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index fe487d28c..e38784eda 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -64,6 +64,14 @@ escape of 4-bit groups): refuses the payload (`stream_exhausted`): the value that symbol would give is not used, and nothing after it is decoded. A stream that never runs out decodes every symbol, so a conforming payload is unaffected. +- After the last symbol a decode reads from a stream, the stream ends **canonically**: + every word of it has been read, and the state is `2^31`. Otherwise the payload is + refused (`stream_noncanonical`). An encoder starts its state at `2^31` and writes + the words it flushes and no others, so its streams end this way. A word after the + last symbol's, or a state the encoder did not start from, codes the same symbols in + other bytes; the rule closes both, so neither can give one decode several content + hashes. A stream that codes no symbol (the residual stream of a one-frame `WIV1` + with keyframe 1) is the state `2^31` alone: the words `2^31` and `0`. **CDF tables.** A table is `n` entries of `u32` with `3 ≤ n ≤ 4097`, `cdf[0] = 0`, `cdf[n − 1] = 65536` and every entry greater than the one before. These rules are @@ -84,8 +92,8 @@ that fails: 8. nothing after the last section (`trailing`, or `nested_length` for `WIV1`'s nested keyframe). -A decode then makes the checks of §7, and refuses a stream that runs out. Two -conforming sinks refuse the same bytes with the same code. +A decode then makes the checks of §7, and refuses a stream that runs out or does not +end canonically (§2). Two conforming sinks refuse the same bytes with the same code. ## 3. `WIV1` — integer inter-frame video @@ -318,6 +326,7 @@ another path, and SPEC §4's fallback is not tried. | `nested_length` | a `WIV1` keyframe length `K` that is not the rest of the payload | | `trailing` | bytes after the last section | | `stream_exhausted` | a stream that ran out before its last symbol | +| `stream_noncanonical` | a stream that does not end canonically after its last symbol: a word left unread, or a state other than `2^31` (§2) | | `geometry_mismatch` | a shape the payload declares that the decode does not give (§7) | | `model_mismatch` | a parameter set that does not fit the payload (§7) | | `set_incomplete` | a parameter set without a member the decode reads (§9); a pinned decode reports SPEC §3.1's `set_incomplete` | diff --git a/wai/int-payload-conformance/int_payload_verify.py b/wai/int-payload-conformance/int_payload_verify.py index 2c5ca6a6c..92d3912a9 100644 --- a/wai/int-payload-conformance/int_payload_verify.py +++ b/wai/int-payload-conformance/int_payload_verify.py @@ -10,11 +10,13 @@ extension's text, it: equal the committed payload byte for byte, and the payload's SHA-256 to be the one the index states; - decodes the WIV1 payloads with keyframe 0 in full - the rANS decoder with its - bypass escape, whole-pel and half-pel motion compensation - and requires every - frame's SHA-256 and the clip's to be the ones the vector's sha256.json states; + bypass escape, its stop at the first exhausted symbol and its canonical end, + whole-pel and half-pel motion compensation - and requires every frame's SHA-256 + and the clip's to be the ones the vector's sha256.json states; - parses every refusal fixture and requires a parse-layer fixture to be refused with its code, and a decode-layer fixture to parse; a decode-layer fixture of WIV1 - keyframe 0 is decoded and must run out of its stream (stream_exhausted). + keyframe 0 is decoded and must be refused with its code (stream_exhausted, + stream_noncanonical). Not decoded independently: WIH1, WIV1 with keyframe 1 (its keyframe is a WIH1), WIA1 and WIS1, whose decodes run integer networks this script does not implement. Their @@ -426,6 +428,9 @@ def decode_wiv1_k0(v): cur[base + c] = min(max(pred + r * q, 0), 255) frames.append(bytes(cur)) prev = cur + if d.i != len(d.w) or d.x != 1 << 31: + # §2: after the last symbol every word has been read and the state is 2^31. + raise Refused("stream_noncanonical", "the residual stream does not end canonically") return frames @@ -510,6 +515,17 @@ def main(cdir): decode_layer.append(name) checked += 1 print(f"ok {name}: {code}" + ("" if layer == "parse" or name not in decode_layer else " (parses; the code is the decode's)")) + # wiv1_end_state codes three symbols from the state 2^31 + 1, as the words [1, 4]; its + # canonical twin, the words [0, 4], decodes to one grey pixel. + twin = bytearray(rd("int-payload-conformance/refusals/wiv1_end_state.bin")) + twin[-8:-4] = struct.pack("QDgAv!@i7#MKr`vp`tlJx<-&+rczfVG0aFqCItsMcd(r~<}+ z%TZt$p9hBZ9bnkK1Ez^z*u%4t6DTLiz)-IZG{=I0q4gk8-(3cVrq>J%jX!~MqwxSv zpY%>=42!6ru3LbasOlQqS5RJ(y!3lgw1vWlL`n8J*Y34%7F5kkY7mS_uRHnXUhIX~ z&YP0iTB~l=#^3p`WSXd;Q#j$B>e2^SHZs}seJh{C6YZMtj6tja@YzMrtb7i=y8h03 z?WgaV=7NU|T!qi(9q@3T?`%hIU zp1ml!_Rx{_J|(7^rzSt%rF-x|frKi9w5R&pchd9T%g_7p``3l)&$T}txiwRFh-XYL zf2zIhnla;2k7CZ&*wu$D!+Exru?XwFKX{DqT-c9B_FpHG_9dRa=ezQaLs^%i<}bcO z$7Abm+>-ftZ8!VR9T#0U&a6EUy;5yqdU4>)i~SZZRLZY!y2DkG$ihj@qlv>`VXnatVAtYj@hrsjguK Hs4N2jO>Bsp literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wih1_unread_word.bin b/wai/int-payload-conformance/refusals/wih1_unread_word.bin new file mode 100644 index 0000000000000000000000000000000000000000..35debc43cb8be3360da5e5a30e11f5290c68fb14 GIT binary patch literal 92 zcmWId^e|*(U|?WD!c0Im408b49dGR)^Jo__=m6OYMUi4LD+C#qFtq3p)iDaW->l-^Ny8mN>30D43eSO5S3 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wis1_unread_word.bin b/wai/int-payload-conformance/refusals/wis1_unread_word.bin new file mode 100644 index 0000000000000000000000000000000000000000..3d36170dd2220129706323edbb6af7aa82254dbb GIT binary patch literal 2968 zcmb7E3sj7082)CaDXJ;TQ6a?A+BxKwtWi_qq$A4`Q7lTU4mKsSk#dbjNsUWx?V3#P zlA+v6>xf-bw92|2x72dfHj!@A%)ig~H#w@wr8(cs-}gW7^FHtMzTfBLIei$%l#srJ zkWRWqpUtp{0U^`ucI0LXcZg>v@5(*2B}SZTB+1?Ot3;go$NRBom0r$me`QQ`?%zzb zn1+A+=e^cORF3`pHGer05+gpfXo;vUFj{;`(cN*IHd_2Z>E+_uZLL^9Oo%a-88O8- z{g@LA>*+qUl43+KG?`KSJta zF;15W;oc{NQvt4eLKuc}a#&)9hZw|Z%n4}{64Dqz2rmt9fhVdCu>A?)&j)TlaK#n11$Ks&wxd4($oPb@~a)7*@KYx5^#f;Qv*C=J{e~MO4Qpp3D6MKN^>a@^Rk0H z)Ejqb+JhW-fJ2S#G{Fxocr{jd1x;#jH-MXa6If_!<)1llB4Fl$^F%Wz_mxh2+ge+A z(W54K*wEq)^b)GK3VGG|#cHRChdl%EV&RAq2HG@2LX77VVw8wiisrh>v(NLe{q70R zpqYm_b}rg#MLu?p;8nxo<~ibB2KF)X)49;6k=6%4)B)h7SoqMc4(h7|A%;QFj2>`l z5A@-{MK4AMIyED}IURLwK}JJhZ>4z+)nMQg+&ry4mw%>T0vwCds*_{R1WF|2o}v)#FZHr6vC zxZx2!f4r;UMNjnJOTcTWzrZ(QR#;x>XvYf;T6%W+1B&@k+cl<$)@{a#z|Du(B#!nZIUw?0VpeSqiJqvsD>dALq zh4=Z=ogo=>LraFQDHxs5$>`wLJp)HXSobdUx~x*+~vK-Wj^hWDsKzw!BYNrvK}kr>FJ~ef#g}--F!~ZGV&ptH#-w zdTgkWs-5iC%Xg(Im)X1aEcX=JWEQ$8R;@}a8Pcy>a&*(V+g{$9GqNN856d=21s9$S zlX@DL2CH5i;Lp4}IBaN<=WM5c%;KJ(KB!(MdMeyqnvidkoX;iHz?rA~9MU?iqR zQPtlrnq5)nJ0`Lsr?B+e zL(E5}x|BP|m4&-KlWa@R$)1riHNvo)wg2k{c})E4!`W{}x!x+R+I-`3P<&|QtedlK z6%D=09~jrTcD9J@>ojxk!qxJ)q|w#)_X)8 z3CzEftn!WTUv%i){X8-^I literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_end_state.bin b/wai/int-payload-conformance/refusals/wiv1_end_state.bin new file mode 100644 index 0000000000000000000000000000000000000000..f9f4357083203a9f795d3586b35c23693b044568 GIT binary patch literal 64 kcmWId3^Qb8WMBXy3=9-vU;z;z&;Y~-fEcKX1585X0A!^CfdBvi literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_one_frame_end_state.bin b/wai/int-payload-conformance/refusals/wiv1_one_frame_end_state.bin new file mode 100644 index 0000000000000000000000000000000000000000..100217ac5eda50a222768915921fefcf0e1a50ad GIT binary patch literal 496 zcmXxhODIHP7{>AcF^G^`Glh~+lq8farlb@KrCe*Gu(NX8SWFbrkWzLmgoP;OvY1jf z7LrR6QIaNEkcrTU!SS4lQ@`HttGDlcdrot8Q=wrP7(l|GDAh5?nOQZDt(LFrqWE1n_i)*{W-RfW2Ta#uW|s>t|f^^g0@Ze#O$L;oS8x7FcV jPGpIZ{ElP0pu7e_gQjc literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_unread_word.bin b/wai/int-payload-conformance/refusals/wiv1_unread_word.bin new file mode 100644 index 0000000000000000000000000000000000000000..8b4a1849935ac017e1df461b15f98f3cca744e3b GIT binary patch literal 17988 zcmbTccUTk6+xHzonusVZAVr!WO^O0a?@f^|pj4%UB1n3&$)+dt-jUt~X`-lzpnwWW zQNdm)QdE>g1Vedq-M{;K-upP-_xa;_c8|}_WM+2GZ_b(7ot<4@=d=2|c0nL~5C}x? zzY*ky{9In{?Ckh+!+*BjT!grYa*^Vqz~u;+V_dAccyI~hg65LLrGv{f7hZ1NMqI9P zxx-~Y4+N6LWr+)n7XmrRt@u5c0j_`vu2FHW@ieZXGr|x^um}V)#x>Y3#x2J+s(26r z`OGy8sB+_45XhPV1aiU*0#SE_K*YQu5TA<>NB|51sbWGPjU^Ds_a?4}TM)?509X4c z1oH6(1OmS0YX8iQ&p{w_ix7zGcL+rHCj?Ty3W2n-A&`VM2;|!z2t;QC0x|mwfhcph z<+w06ArQ$e2qc-y3KzR=2qcBeV=kLq)OR3|(_CV?P`DIxY2nh#WrWKVm(N@lx%}j^ z#)ZRWhYJM4bqsgg-r2?d{h#=MC2$WskgXjKci-glPwd}x-v3VjH*e>^^Zu#fKWF@t z^Z%Cr_q@&j%H8~5_57Rue-d(bc5}7whWw-HU$xsiJlsC?ADjOi|JC}>|Ka17-2N{$ z|6fi2l>4WifA;?q4gY^-x40TMcetJEe{BEO{*R4+Jo9hX|J3@g@BYc-<+j5A&VO6n zoPX;1w~qhYki*sakN^H>TmNJCKVI16dTjfD+4!eD|IzpF{y%O0Pz1{IJWZ;C6uVm!hIjhG4h>O-L;^#SO z;=Yu0k*&B%{V2N@-M70XrEf&umck3E$jZmdNp*kJSE#iYQK&5Yt}tS-S1SqkNip{L zu;vx!;lnpkk&fnG^R|!N;&pB24bL@hg&%Lc^D+2yeS}qfvzoqJ%(U1FCP96;8-8Ct^FK_jz`a^Rmjr>aKUcq#+ccns-AK7oA zs&NO+LisPB5cuX})+Ll=f&3zWc(2T{qpW@AI&L}T>Vf;NpYi{*V`XXAcXYhX{;a9M zd7r*-U;NAJd6}LMzb4YEh4m!;gP>%-pHL~(H8FSENy(I%T;o9_yOaHc;)X*>M9bxU zE)GtoG#soQge*LJE+4fx$FDCYTc~r+q26iOG0h_d6-Sr{+V3{x|R~Qm2k50UGXdV@z(mrFS9MSYu?fzFewFFZ?jVn8OPGTK(9

t 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 Vec { fx(t, "wiv1_trailing", MOT, edit(&iv, &|b| b.push(0)), None, "trailing", "parse"); let iv_l = at(&iv, wire::parse_wiv1(&iv, Some(0)).expect("int_video parses").rans) - 4; fx(t, "wiv1_exhausted", MOT, edit(&iv, &|b| { set_u32(b, iv_l, 8); b.truncate(iv_l + 4 + 8) }), None, "stream_exhausted", "decode"); + // A word appended to the residual stream (the last section), which the decode + // never reads. + fx(t, "wiv1_unread_word", MOT, edit(&iv, &|b| { let l = get_u32(b, iv_l); set_u32(b, iv_l, l + 4); b.extend_from_slice(&[0; 4]) }), None, "stream_noncanonical", "decode"); + // A one-pixel clip whose three symbols are 0 (frequency 2^15 of 2^16), encoded + // from the state 2^31 + 1: the stream is the words [1, 4], and the decode ends at + // 2^31 + 1. Encoded from 2^31, the canonical stream is [0, 4] (`grey_pixel`). + fx(t, "wiv1_end_state", MOT, Some(grey_pixel(1)), None, "stream_noncanonical", "decode"); + // One frame, the nested keyframe: the residual stream codes no symbol, so it must be + // the state 2^31 alone, and 2^31 + 1 is refused. + let one = |state: u32| { + let mut v = wire::parse_wiv1(&nk, Some(1)).expect("video_nk parses"); + let rans: Vec = [state, 0].iter().flat_map(|w| w.to_le_bytes()).collect(); + v.n_frames = 1; + v.mvs.clear(); + wire::write_wiv1(&wire::Wiv1 { rans: &rans, ..v }) + }; + fx(t, "wiv1_one_frame_end_state", VNK, Some(one((1 << 31) + 1)), None, "stream_noncanonical", "decode"); let geo = { let mut v = wire::parse_wiv1(&nk, Some(1)).expect("video_nk parses"); (v.h, v.w) = (12, 12); // the same 2 × 2 block grid; the keyframe decodes 16 × 16 @@ -236,6 +253,7 @@ fn fixtures(t: &mut Tree) -> Vec { fx(t, "wih1_geometry", HYP, edit(&ih, &|b| { set_u32(b, 12, 8); set_u32(b, 16, 8) }), None, "geometry_mismatch", "decode"); let ih_ly = at(&ih, wire::parse_wih1(&ih).expect("tiny parses").rans) - 4; fx(t, "wih1_exhausted", HYP, edit(&ih, &|b| { set_u32(b, ih_ly, 8); b.truncate(ih_ly + 4 + 8) }), None, "stream_exhausted", "decode"); + fx(t, "wih1_unread_word", HYP, edit(&ih, &|b| { let l = get_u32(b, ih_ly); set_u32(b, ih_ly, l + 4); b.extend_from_slice(&[0; 4]) }), None, "stream_noncanonical", "decode"); // ── WIA1 ── (16 channels × 512, tables of 14) let au_n = at(&au, wire::parse_wia1(&au).expect("audio parses").rans) - 4; // offset of L @@ -250,6 +268,7 @@ fn fixtures(t: &mut Tree) -> Vec { fx(t, "wia1_afrac_model", AUD, edit(&au, &|b| b[5] = 11), None, "model_mismatch", "decode"); fx(t, "wia1_t_out", AUD, edit(&au, &|b| { let n = get_u32(b, 20); set_u32(b, 20, n + 1) }), None, "geometry_mismatch", "decode"); fx(t, "wia1_exhausted", AUD, edit(&au, &|b| { set_u32(b, au_n, 8); b.truncate(au_n + 4 + 8) }), None, "stream_exhausted", "decode"); + fx(t, "wia1_unread_word", AUD, edit(&au, &|b| { let l = get_u32(b, au_n); set_u32(b, au_n, l + 4); b.extend_from_slice(&[0; 4]) }), None, "stream_noncanonical", "decode"); // ── WIS1 ── (32 channels of 8×8 → 14 planes of 32×32) let wis = |f: &dyn Fn(&mut wire::Wis1<'_>)| { @@ -267,9 +286,22 @@ fn fixtures(t: &mut Tree) -> Vec { fx(t, "wis1_afrac_model", SPL, edit(&sp, &|b| b[5] = 11), None, "model_mismatch", "decode"); let sp_l = at(&sp, wire::parse_wis1(&sp).expect("splat parses").rans) - 4; fx(t, "wis1_exhausted", SPL, edit(&sp, &|b| { set_u32(b, sp_l, 8); b.truncate(sp_l + 4 + 8) }), None, "stream_exhausted", "decode"); + fx(t, "wis1_unread_word", SPL, edit(&sp, &|b| { let l = get_u32(b, sp_l); set_u32(b, sp_l, l + 4); b.extend_from_slice(&[0; 4]) }), None, "stream_noncanonical", "decode"); out } +/// A `WIV1` keyframe-0 clip of one 1 × 1 frame whose three residual symbols are 0 +/// against the table `[0, 32768, 49152, 65536]`, with the stream `[w0, 4]`: `w0 = 0` is +/// the canonical coding of those symbols, which decodes to a grey pixel; `w0 = 1` +/// codes the same symbols from the state `2^31 + 1`. +fn grey_pixel(w0: u32) -> Vec { + let rans: Vec = [w0, 4].iter().flat_map(|w| w.to_le_bytes()).collect(); + wire::write_wiv1(&wire::Wiv1 { + mv_precision: 1, keyframe: 0, n_frames: 1, h: 1, w: 1, block: 1, q: 1, offset: 0, + cdf: vec![0, 32_768, 49_152, 65_536], mvs: vec![], rans: &rans, wih1: None, + }) +} + /// The offset in `base` at which `part`, a slice of it, starts. fn at(base: &[u8], part: &[u8]) -> usize { part.as_ptr() as usize - base.as_ptr() as usize @@ -384,6 +416,13 @@ fn verify(wai: &Path) -> bool { } } println!("{} refusal codes covered by fixtures", seen.len()); + // The end-state fixture's canonical twin, the same symbols coded from 2^31, decodes. + match decode(&t, wai::codecs::CAP_VIDEO_INT_MOTION, &grey_pixel(0), &Value::Null) { + Ok(IntDecoded::Video { frames, .. }) if frames == [vec![128u8; 3]] => { + println!("ok grey_pixel: wiv1_end_state's canonical twin decodes to one grey pixel") + } + other => { println!("FAIL grey_pixel: {other:?}"); ok = false } + } ok } diff --git a/wai/wai-rs/src/int_audio.rs b/wai/wai-rs/src/int_audio.rs index 5782fa191..598441cca 100644 --- a/wai/wai-rs/src/int_audio.rs +++ b/wai/wai-rs/src/int_audio.rs @@ -368,6 +368,9 @@ pub fn decode_typed(model: &AudioModel, s: &AudioStream<'_>) -> Result, y.push(dequantize(sym, afrac, s.median_q[ch]).ok_or(refused(FieldRange, "a latent value leaves i64"))?); } } + if !dec.is_canonical_end() { + return Err(refused(StreamNoncanonical, "the latent stream does not end canonically after its last symbol")); + } // 2. integer synthesis → waveform, each layer checked on the values it is given. let (mut x, mut cc, mut tt) = (y, c, l); for layer in &model.layers { diff --git a/wai/wai-rs/src/int_hyper_synth.rs b/wai/wai-rs/src/int_hyper_synth.rs index b47bededc..56e473712 100644 --- a/wai/wai-rs/src/int_hyper_synth.rs +++ b/wai/wai-rs/src/int_hyper_synth.rs @@ -805,6 +805,8 @@ pub enum RefusalKind { CdfMalformed, /// A rANS stream ran out before its last symbol. StreamExhausted, + /// A rANS stream does not end canonically after its last symbol. + StreamNoncanonical, /// A shape the payload declares disagrees with the one the decode produces. GeometryMismatch, /// The parameter set does not fit the payload. @@ -992,6 +994,9 @@ pub fn decode_conditional_checked( z.push(dequantize(s, afrac, ent.z_median_q[ch]).ok_or(refused(FieldRange, "a hyper-latent value leaves i64"))?); } } + if !dz.is_canonical_end() { + return Err(refused(StreamNoncanonical, "the z stream does not end canonically after its last symbol")); + } let sm = run_checked(h_s, &z, n_z, zh, zw)?; let (sigma, mu) = sm.split_at(n); // 2. y, conditioned on the integer σ: y_hat = r·2^afrac + μ. @@ -1005,6 +1010,9 @@ pub fn decode_conditional_checked( } y_hat.push(dequantize(r, afrac, mu[p]).ok_or(refused(FieldRange, "a latent value leaves i64"))?); } + if !dec.is_canonical_end() { + return Err(refused(StreamNoncanonical, "the y stream does not end canonically after its last symbol")); + } // 3. integer synthesis g_s → pixels. let x = run_checked(g_s, &y_hat, m, sh, sw)?; Ok((to_rgb8(&x, oc, oh, ow, afrac), oh, ow)) diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index 28b450573..c7f1a2057 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -85,6 +85,7 @@ impl From for IntPayloadError { K::TooLarge => Refusal::TooLarge, K::CdfMalformed => Refusal::CdfMalformed, K::StreamExhausted => Refusal::StreamExhausted, + K::StreamNoncanonical => Refusal::StreamNoncanonical, K::GeometryMismatch => Refusal::GeometryMismatch, K::ModelMismatch => Refusal::ModelMismatch, }; @@ -392,7 +393,7 @@ fn decode_wih1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result) -> Result { - use crate::int_video::{decode_pframes_typed, decode_typed, VideoStream, STREAM_EXHAUSTED}; + use crate::int_video::{decode_pframes_typed, decode_typed, VideoStream, STREAM_EXHAUSTED, STREAM_NONCANONICAL}; let keyframe = if cap == crate::codecs::CAP_VIDEO_INT_MOTION { 0 } else { 1 }; let v = wire::parse_wiv1(payload, Some(keyframe))?; let s = VideoStream { @@ -400,7 +401,12 @@ fn decode_wiv1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result Refusal::StreamExhausted, + STREAM_NONCANONICAL => Refusal::StreamNoncanonical, + _ => Refusal::FieldRange, + }; + IntPayloadError::new(refusal, e) }; let clip = match v.wih1 { None => decode_typed(&s).map_err(video)?, diff --git a/wai/wai-rs/src/int_payload/wire.rs b/wai/wai-rs/src/int_payload/wire.rs index 8e615ec8c..022221a2f 100644 --- a/wai/wai-rs/src/int_payload/wire.rs +++ b/wai/wai-rs/src/int_payload/wire.rs @@ -113,6 +113,9 @@ pub enum Refusal { Trailing, /// A rANS stream that ran out before its last symbol. StreamExhausted, + /// A rANS stream that does not end canonically after its last symbol: a word is + /// left unread, or the state is not `2^31` ([integer-payloads] §2). + StreamNoncanonical, /// A shape the payload declares disagrees with the shape the decode produces. GeometryMismatch, /// The parameter set does not fit the payload. @@ -132,7 +135,7 @@ impl Refusal { /// Every refusal, in the order of [integer-payloads] §8. /// /// cbindgen:ignore - pub const ALL: [Refusal; 18] = [ + pub const ALL: [Refusal; 19] = [ Refusal::BadMagic, Refusal::FormatCapabilityMismatch, Refusal::KeyframeCapabilityMismatch, @@ -145,6 +148,7 @@ impl Refusal { Refusal::NestedLength, Refusal::Trailing, Refusal::StreamExhausted, + Refusal::StreamNoncanonical, Refusal::GeometryMismatch, Refusal::ModelMismatch, Refusal::SetIncomplete, @@ -168,6 +172,7 @@ impl Refusal { Refusal::NestedLength => "nested_length", Refusal::Trailing => "trailing", Refusal::StreamExhausted => "stream_exhausted", + Refusal::StreamNoncanonical => "stream_noncanonical", Refusal::GeometryMismatch => "geometry_mismatch", Refusal::ModelMismatch => "model_mismatch", Refusal::SetIncomplete => "set_incomplete", diff --git a/wai/wai-rs/src/int_splat.rs b/wai/wai-rs/src/int_splat.rs index 93f9542ed..9a44fa37f 100644 --- a/wai/wai-rs/src/int_splat.rs +++ b/wai/wai-rs/src/int_splat.rs @@ -162,6 +162,9 @@ pub fn decode_typed(gs: &IntModel, st: &SplatStream<'_>) -> Result Result<(usize, usi /// /// cbindgen:ignore pub const STREAM_EXHAUSTED: &str = "the residual stream ended before its last symbol"; +/// The refusal [`decode_typed`] and [`decode_pframes_typed`] give when the residual +/// stream does not end canonically after the clip's last symbol: a word left unread, +/// or a state other than `2^31` (`extensions/integer-payloads.md` §2). +/// +/// cbindgen:ignore +pub const STREAM_NONCANONICAL: &str = "the residual stream does not end canonically after its last symbol"; /// A clip's coded form, typed: what the `WIV1` payload carries (and what the tool's /// JSON form carries, read by [`VideoJson`]). Both decode entry points take it. @@ -335,6 +341,9 @@ fn decode_frames(s: &VideoStream<'_>, nb: usize, first: usize, frames: &mut Vec< } frames.push(cur); } + if !dec.is_canonical_end() { + return Err(STREAM_NONCANONICAL); + } Ok(()) } diff --git a/wai/wai-rs/src/rans.rs b/wai/wai-rs/src/rans.rs index 06ace9bce..5303e1f16 100644 --- a/wai/wai-rs/src/rans.rs +++ b/wai/wai-rs/src/rans.rs @@ -39,6 +39,16 @@ impl<'a> RansDecoder<'a> { self.exhausted } + /// Whether the stream has ended canonically: it never ran out, every word has been + /// read, and the state is `2^31`, the state an encoder starts from. Called after a + /// decode's last symbol. A stream that ends otherwise (an unread word after the + /// last symbol, or a state the encoder did not start from) codes the same symbols + /// as the canonical stream, so a decoder that requires this accepts one stream per + /// sequence of symbols. + pub fn is_canonical_end(&self) -> bool { + !self.exhausted && self.state == RANS64_L && self.idx * 4 == self.words.len() + } + #[inline] fn renorm(&mut self) { if self.state < RANS64_L { let off = self.idx * 4; diff --git a/wai/wai-rs/tests/int_payload.rs b/wai/wai-rs/tests/int_payload.rs index 44fbe6224..beda78f74 100644 --- a/wai/wai-rs/tests/int_payload.rs +++ b/wai/wai-rs/tests/int_payload.rs @@ -71,7 +71,7 @@ fn every_refusal_fixture_is_refused_with_its_code() { } for code in ["bad_magic", "format_capability_mismatch", "keyframe_capability_mismatch", "version", "reserved_nonzero", "field_range", "too_large", "cdf_malformed", "stream_length", "nested_length", "trailing", - "stream_exhausted", "geometry_mismatch", "model_mismatch"] { + "stream_exhausted", "stream_noncanonical", "geometry_mismatch", "model_mismatch"] { assert!(Refusal::from_code(code).is_some(), "{code} is a code"); assert!(seen.contains(code), "no fixture is refused with {code}"); } -- 2.54.0 (Apple Git-157) From 13d42fc99839741524f6f69117504989ee6cce13 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 19:25:37 -0400 Subject: [PATCH 16/34] wai: one refusal order for integer decodes, planned alike on every target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - integer-payloads.md §7 states the order of a decode's checks: the set's members, the set against the payload, every network planned with its caps, what the networks give against the payload (per format), then each stream symbol by symbol, a run-out before the value's range. §2 says a section of one table per channel is read table by table, and field_range covers a field of any section. - The conditional decode plans h_s and g_s before it compares h_s's shape, so a 2100 x 2100 latent whose g_s would pass the cap is too_large, not geometry_mismatch. - int_hyper_synth::plan (used by the conditional and splat decodes) and the audio synthesis's plan work layer by layer in u64: parameter lengths and shifts (model_mismatch), every side and buffer within 2^26 (too_large; a size past u64 is past the cap), then an output left (model_mismatch). A 32-bit sink formed these sizes in usize and refused an overflow as a malformed model where a 64-bit one said too_large. A convolution's padded side is now capped too. - The entropy tables of a set must have 3 to 4097 entries, as §2 says (the reader took 2, and any length past 4097). - Fixtures: wia1_order_table_before_length (parse), wih1_order_caps_before_geometry and wih1_order_exhausted_before_value (decode, against sets/entropy_median_max.json, the tiny tables with a median of i64::MAX). Tests: int_payload::tests::network_plans_refuse_alike_on_every_target, int_payload::tests::entropy_tables_obey_the_table_length_rule, and the fixtures through tests/int_payload.rs and both corpus verifiers. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 10 ++ wai/extensions/integer-payloads.md | 61 +++++++-- .../wia1_order_table_before_length.bin | Bin 0 -> 226 bytes .../wih1_order_caps_before_geometry.bin | Bin 0 -> 88 bytes .../wih1_order_exhausted_before_value.bin | Bin 0 -> 88 bytes .../sets/entropy_median_max.json | 1 + wai/int-payload-conformance/vectors.json | 34 +++++ wai/wai-rs/src/bin/wai_int_payload_vectors.rs | 40 ++++++ wai/wai-rs/src/int_audio.rs | 53 ++++---- wai/wai-rs/src/int_hyper_synth.rs | 117 +++++++++++++++--- wai/wai-rs/src/int_payload/tests.rs | 51 ++++++++ wai/wai-rs/src/int_splat.rs | 8 +- 12 files changed, 319 insertions(+), 56 deletions(-) create mode 100644 wai/int-payload-conformance/refusals/wia1_order_table_before_length.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_order_caps_before_geometry.bin create mode 100644 wai/int-payload-conformance/refusals/wih1_order_exhausted_before_value.bin create mode 100644 wai/int-payload-conformance/sets/entropy_median_max.json diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 53bcd5b6a..9114dfac3 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -60,6 +60,16 @@ wire format may change and such changes are recorded here. reads a field that does not fit the target's `usize` as absent rather than truncating it. In the browser, `decodeWiv1` keeps the decoder's frames (`DecodedVideo.frame(i)`) instead of copying the clip into one buffer. +- **One refusal order.** The extension states the order of a decode's checks + exactly (§7): the set's members, the set against the payload, every network + planned with its caps, the shapes, then each stream symbol by symbol, a run-out + before the value it would give. Networks are planned layer by layer in 64-bit + arithmetic (`int_hyper_synth::plan`, the audio synthesis's plan), a size past 64 + bits being past the cap, so a 32-bit and a 64-bit sink refuse a malformed network + with the same code; a planned network's parameter lengths are checked before any + symbol is decoded, and a convolution's padded side is capped. A set's entropy + tables obey §2's length rule (3 to 4097 entries). The parser reads a table of one + per channel table by table. Fixtures pin each case. - **Canonical streams.** After its last symbol, each rANS stream of these payloads must have been read to its last word and end in the state `2^31`, where an encoder starts (`rans::RansDecoder::is_canonical_end`); otherwise the payload is refused diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index e38784eda..b0f45766e 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -87,8 +87,11 @@ that fails: 4. the reserved fields (`reserved_nonzero`); 5. the header's fields, in the order the format's section lists their rules (`field_range`), then, for `WIV1`, the keyframe kind (`keyframe_capability_mismatch`); -6. the caps of §7 on the sizes the header declares (`too_large`); -7. each later section in order: present (`stream_length`), then its rules; +6. the caps of §7 on the sizes the header declares (`too_large`), in the order the + format's section lists them; +7. each later section in order: present (`stream_length`), then its rules + (`field_range`, `cdf_malformed`). A section of one table per channel (`WIA1`, + `WIS1`) is read table by table: each table present, then checked, before the next; 8. nothing after the last section (`trailing`, or `nested_length` for `WIV1`'s nested keyframe). @@ -265,12 +268,26 @@ not fix presentation. **Caps, the same on every target.** No tensor a payload sizes may exceed `2^26` elements: a frame `h·w·3`, a `WIV1` motion-vector field `2·nb·(n_frames − 1)`, a latent, a hyper-latent, a network's output, an attribute tensor, a clip's `t_out`. A -`WIV1` clip may not exceed `2^30` bytes, nor `2^20` frames. Before a decode -allocates a latent, it derives every network's shapes from its layer geometry on the -payload's shapes, and refuses (`too_large`) a payload on which any network would form -a buffer, its input, a layer's output or a transposed convolution's uncropped plane, -over `2^26` elements. Every size is formed in checked arithmetic, so a 32-bit sink -accepts and refuses exactly what a 64-bit one does. +`WIV1` clip may not exceed `2^30` bytes, nor `2^20` frames. + +**Planning the networks.** Before a decode allocates a latent, it plans every network +on the payload's shapes from the layers' geometry and parameter lengths alone. The +network's input must be within the cap (`too_large`); then, layer by layer in order: + +1. the layer fits the running channel count (`model_mismatch`): a convolution's output + channels, kernel and stride are non-zero, it has `c·out·k·k` weights (`c·out·k` for + the audio synthesis) and `out` multipliers and biases, and its requantisation shift + is under 128; an inverse GDN has `c·c` and `c` parameters and shifts under 128; a + leaky ReLU's shift is under 64; +2. every size the layer forms is within `2^26` (`too_large`): a convolution's padded + input side `n + 2·pad`, a transposed convolution's uncropped side + `(n − 1)·stride + k + opad` and uncropped plane, and the output; +3. the layer leaves an output (`model_mismatch`): a kernel no larger than the padded + input, a crop `2·pad` smaller than the uncropped side. + +Every size is formed in 64-bit arithmetic with overflow checked, and a size past 64 +bits is past the cap, so a 32-bit sink refuses exactly what a 64-bit one does, with +the same code; once a network is planned, every size it forms fits a 32-bit index. **Memory and work.** The caps bound what a payload can declare; the streams bound what it can make a decode do. A decode stops at the first symbol that runs its stream @@ -291,7 +308,7 @@ runs, that network's input and the buffers it forms, each at most `2^26` element channel) and every table is a CDF of §2 over its length; a payload has no more hyper-latent channels than the tables; - each layer's parameters have the lengths the running channel count needs, and its - geometry leaves an output; + geometry leaves an output (*Planning the networks*); - the audio synthesis gives one channel; the conditional codec's `g_s` gives three. **The shapes the payload declares must be the ones the decode gives** @@ -299,6 +316,30 @@ runs, that network's input and the buffers it forms, each at most `2^26` element `h × w`; the audio synthesis's length and `t_out` (it may be longer, not shorter); `g_s`'s output and `[attr, s, s]`. +**The order of a decode's checks.** After the parse (§2), a decode checks in this +order and refuses with the first that fails: + +1. the set's members, in the order of §9's list, each present (`set_incomplete`) and + parsed (`model_mismatch`); +2. the parameter set against the payload (`model_mismatch`): one fixed-point scale, + the payload's `afrac`, the entropy tables, the hyper-latent channels; +3. every network planned, in decode order (`h_s` then `g_s`; the audio synthesis; + the splat `g_s`), each layer by the three rules above (`model_mismatch`, + `too_large`): the caps come before any shape is compared; +4. what the networks give against the payload: for `WIH1`, `h_s`'s shape + (`geometry_mismatch`), then `g_s`'s three channels (`model_mismatch`); for `WIA1`, + one channel (`model_mismatch`), then at least `t_out` samples + (`geometry_mismatch`); for `WIS1`, `g_s`'s shape (`geometry_mismatch`); +5. each stream in decode order (`WIH1`: the hyper-latent's, then, after `h_s` runs, + the latent's; `WIV1` with keyframe 1: the nested keyframe's decode first, then its + `h × w` (`geometry_mismatch`), then the residual stream), symbol by symbol: a + symbol that runs its stream out (`stream_exhausted`) before its value's range + (`field_range`); after its last symbol, its canonical end (`stream_noncanonical`); +6. each network as it runs, layer by layer (`field_range`, below). + +So a run-out is reported before the value it would have given, and a size past the +caps before a shape that disagrees. + **Exactness.** A latent rebuilt as `s·2^afrac + base` must fit `i64`; before each network layer runs, the decode checks on the values present that no product, partial sum, requantised value or inverse-GDN term can leave the type its arithmetic is @@ -319,7 +360,7 @@ another path, and SPEC §4's fallback is not tried. | `keyframe_capability_mismatch` | a `WIV1` keyframe kind that is not the capability's | | `version` | a version this revision does not define | | `reserved_nonzero` | a reserved field that is not zero | -| `field_range` | a header field outside its range; or a value the stream decodes that leaves the exact integer range (§7) | +| `field_range` | a field of any section outside its range (a header field, a `WIS1` range or table length, a median); or a value the stream decodes that leaves the exact integer range (§7) | | `too_large` | a size past the caps of §7 | | `cdf_malformed` | a table that breaks the rules of §2 | | `stream_length` | the payload ends before a field or section it declares; or a stream length that is not a multiple of 4, or is under 8 | diff --git a/wai/int-payload-conformance/refusals/wia1_order_table_before_length.bin b/wai/int-payload-conformance/refusals/wia1_order_table_before_length.bin new file mode 100644 index 0000000000000000000000000000000000000000..df51fa5f35c991d8eb12a5b205be9806068dc409 GIT binary patch literal 226 zcmWIdbTnk-VPI&mV_*QDgK{{c&A^HGYE*g9Q literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wih1_order_caps_before_geometry.bin b/wai/int-payload-conformance/refusals/wih1_order_caps_before_geometry.bin new file mode 100644 index 0000000000000000000000000000000000000000..96694b5c98779f858f5395e84148193acd32326c GIT binary patch literal 88 zcmWId^e|*(U|?VYViOJsW&*Nd7%0Th@z(w^k9HA*29T{#6e$+7LXcqzLyK@&lr5J^VCEsYHB SXTR^LTcjM=6_I literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/sets/entropy_median_max.json b/wai/int-payload-conformance/sets/entropy_median_max.json new file mode 100644 index 000000000..ffb4e9fe9 --- /dev/null +++ b/wai/int-payload-conformance/sets/entropy_median_max.json @@ -0,0 +1 @@ +{"afrac":12,"frac":12,"scale_edges":[4096,8192,16384,32768],"cdfs":[[0,1,2,3,4,6,296,3834,19692,45837,61695,65233,65523,65532,65533,65534,65535,65536],[0,4,33,178,752,2521,6765,14695,26232,39306,50843,58773,63017,64786,65360,65505,65534,65536],[0,927,2410,4636,7775,11933,17108,23158,29803,36659,43304,49354,54529,58687,61826,64052,65535,65536],[0,2905,6173,9790,13731,17960,22426,27070,31824,36616,41370,46014,50480,54709,58650,62267,65535,65536]],"cdf_lengths":[18,18,18,18],"offsets":[-8,-8,-8,-8],"z_cdfs":[[0,4,33,178,752,2521,6765,14695,26232,39306,50843,58773,63017,64786,65360,65505,65534,65536],[0,4,33,178,752,2521,6765,14695,26232,39306,50843,58773,63017,64786,65360,65505,65534,65536]],"z_cdf_lengths":[18,18],"z_offsets":[1,-8],"z_median_q":[9223372036854775807,0]} diff --git a/wai/int-payload-conformance/vectors.json b/wai/int-payload-conformance/vectors.json index 763256aaf..10c787155 100644 --- a/wai/int-payload-conformance/vectors.json +++ b/wai/int-payload-conformance/vectors.json @@ -426,6 +426,30 @@ "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" } }, + { + "name": "wih1_order_caps_before_geometry", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_order_caps_before_geometry.bin", + "code": "too_large", + "layer": "decode", + "set": { + "entropy.json": "wai-rs/tests/vectors/int_hyper/tiny_entropy.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, + { + "name": "wih1_order_exhausted_before_value", + "capability": "wai.neural.int_hyper", + "payload": "int-payload-conformance/refusals/wih1_order_exhausted_before_value.bin", + "code": "stream_exhausted", + "layer": "decode", + "set": { + "entropy.json": "int-payload-conformance/sets/entropy_median_max.json", + "g_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_g_s.bin", + "h_s.whm": "wai-rs/tests/vectors/int_hyper/tiny_h_s.bin" + } + }, { "name": "wih1_unread_word", "capability": "wai.neural.int_hyper", @@ -498,6 +522,16 @@ "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" } }, + { + "name": "wia1_order_table_before_length", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_order_table_before_length.bin", + "code": "cdf_malformed", + "layer": "parse", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, { "name": "wia1_too_large", "capability": "wai.audio.int_codec", diff --git a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs index fcc4e3abb..b0a5f8c81 100644 --- a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs +++ b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs @@ -253,6 +253,20 @@ fn fixtures(t: &mut Tree) -> Vec { fx(t, "wih1_geometry", HYP, edit(&ih, &|b| { set_u32(b, 12, 8); set_u32(b, 16, 8) }), None, "geometry_mismatch", "decode"); let ih_ly = at(&ih, wire::parse_wih1(&ih).expect("tiny parses").rans) - 4; fx(t, "wih1_exhausted", HYP, edit(&ih, &|b| { set_u32(b, ih_ly, 8); b.truncate(ih_ly + 4 + 8) }), None, "stream_exhausted", "decode"); + // The order of the decode's checks (§7): every network is planned, caps included, + // before a shape is compared, so a 2100 × 2100 latent, whose g_s would form a + // buffer past the cap, is too_large rather than h_s's geometry_mismatch; and a + // symbol that runs its stream out is refused before its value is rebuilt, so a z + // stream of 8 zero bytes, whose first symbol rebuilt against a median of i64::MAX + // would leave i64, is stream_exhausted (the set's entropy.json is + // sets/entropy_median_max.json). + fx(t, "wih1_order_caps_before_geometry", HYP, edit(&ih, &|b| { set_u32(b, 12, 2100); set_u32(b, 16, 2100) }), None, "too_large", "decode"); + let short_z = { + let mut k = wire::parse_wih1(&ih).expect("tiny parses"); + k.z_rans = &[0; 8]; + wire::write_wih1(&k) + }; + fx(t, "wih1_order_exhausted_before_value", HYP, Some(short_z), None, "stream_exhausted", "decode"); fx(t, "wih1_unread_word", HYP, edit(&ih, &|b| { let l = get_u32(b, ih_ly); set_u32(b, ih_ly, l + 4); b.extend_from_slice(&[0; 4]) }), None, "stream_noncanonical", "decode"); // ── WIA1 ── (16 channels × 512, tables of 14) @@ -263,6 +277,9 @@ fn fixtures(t: &mut Tree) -> Vec { fx(t, "wia1_sr_zero", AUD, edit(&au, &|b| set_u32(b, 8, 0)), None, "field_range", "parse"); fx(t, "wia1_median_range", AUD, edit(&au, &|b| b[32..40].copy_from_slice(&((1i64 << 47) + 1).to_le_bytes())), None, "field_range", "parse"); fx(t, "wia1_cdf_malformed", AUD, edit(&au, &|b| set_u32(b, 160 + 13 * 4, 65_535)), None, "cdf_malformed", "parse"); + // §2's order: the tables are read one at a time, each present and then checked, so + // a malformed first table is cdf_malformed though the payload ends inside the second. + fx(t, "wia1_order_table_before_length", AUD, edit(&au, &|b| { set_u32(b, 160 + 13 * 4, 65_535); b.truncate(160 + 14 * 4 + 10) }), None, "cdf_malformed", "parse"); fx(t, "wia1_too_large", AUD, edit(&au, &|b| { set_u32(b, 12, 65_536); set_u32(b, 16, 2_048) }), None, "too_large", "parse"); fx(t, "wia1_trailing", AUD, edit(&au, &|b| b.push(0)), None, "trailing", "parse"); fx(t, "wia1_afrac_model", AUD, edit(&au, &|b| b[5] = 11), None, "model_mismatch", "decode"); @@ -287,6 +304,17 @@ fn fixtures(t: &mut Tree) -> Vec { let sp_l = at(&sp, wire::parse_wis1(&sp).expect("splat parses").rans) - 4; fx(t, "wis1_exhausted", SPL, edit(&sp, &|b| { set_u32(b, sp_l, 8); b.truncate(sp_l + 4 + 8) }), None, "stream_exhausted", "decode"); fx(t, "wis1_unread_word", SPL, edit(&sp, &|b| { let l = get_u32(b, sp_l); set_u32(b, sp_l, l + 4); b.extend_from_slice(&[0; 4]) }), None, "stream_noncanonical", "decode"); + let sets = "int-payload-conformance/sets"; + t.put(&format!("{sets}/entropy_median_max.json"), entropy_median_max(t)); + for f in &mut out { + if f.name == "wih1_order_exhausted_before_value" { + f.set = vec![ + ("entropy.json", format!("{sets}/entropy_median_max.json")), + ("g_s.whm", format!("{V}/int_hyper/tiny_g_s.bin")), + ("h_s.whm", format!("{V}/int_hyper/tiny_h_s.bin")), + ]; + } + } out } @@ -302,6 +330,18 @@ fn grey_pixel(w0: u32) -> Vec { }) } +/// The tiny conditional codec's entropy tables with the first hyper-latent channel's +/// median at `i64::MAX` and its symbol offset at 1, so that channel's first symbol, if +/// its value were used, would rebuild past `i64`. +fn entropy_median_max(t: &Tree) -> Vec { + let mut e = t.json(&format!("{V}/int_hyper/tiny_entropy.json")); + e["z_median_q"][0] = json!(i64::MAX); + e["z_offsets"][0] = json!(1); + let mut text = serde_json::to_string(&e).expect("entropy"); + text.push('\n'); + text.into_bytes() +} + /// The offset in `base` at which `part`, a slice of it, starts. fn at(base: &[u8], part: &[u8]) -> usize { part.as_ptr() as usize - base.as_ptr() as usize diff --git a/wai/wai-rs/src/int_audio.rs b/wai/wai-rs/src/int_audio.rs index 598441cca..17080b744 100644 --- a/wai/wai-rs/src/int_audio.rs +++ b/wai/wai-rs/src/int_audio.rs @@ -240,44 +240,55 @@ impl AudioJson { } } -/// One 1-D transposed convolution's geometry on `[c, t]`: its output length, and the -/// uncropped length the reference accumulates into, in checked arithmetic. -fn conv_t_1d_len(t: usize, k: usize, stride: usize, pad: usize, opad: usize) -> Option<(usize, usize)> { - let full = (t - 1).checked_mul(stride)?.checked_add(k)?.checked_add(opad)?; - let to = full.checked_sub(pad.checked_mul(2)?).filter(|&n| n > 0)?; - Some((to, full)) -} - -/// From the model's layer geometry alone, its output `[c, t]` on a `[c, t]` latent, -/// refused unless every layer's parameters fit the running channel count and no -/// buffer exceeds [`MAX_TENSOR`] elements. Allocates nothing. -fn plan(model: &AudioModel, mut c: usize, mut t: usize) -> Result<(usize, usize), Refused> { +/// The model's output `[c, t]` on a `[c, t]` latent, planned from its layers' geometry +/// and parameter lengths alone, before anything is allocated +/// (`extensions/integer-payloads.md` §7). Layer by layer in order: the layer fits the +/// running channel count (`ModelMismatch`: output channels, kernel and stride +/// non-zero, `c·out·k` weights, `out` multipliers and biases, a requantisation shift +/// under 128; an inverse GDN of the running channel count, with `c·c` and `c` +/// parameters and shifts under 128); the uncropped length `(t − 1)·stride + k + opad` +/// and the buffer of `out` channels of it are within [`MAX_TENSOR`] (`TooLarge`); and +/// the crop `2·pad` leaves an output (`ModelMismatch`). Every size is formed in `u64` +/// with checked arithmetic, a size past `u64` being over the cap, so every target +/// refuses alike. +fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize), Refused> { use RefusalKind::*; + const CAP: u64 = MAX_TENSOR as u64; + let (mut c, mut t) = (c as u64, t as u64); for layer in &model.layers { match layer { ALayer::ConvT1d { out_ch, k, stride, pad, opad, weight, mult, bias } => { - let (cout, k) = (*out_ch, *k); - if cout == 0 || k == 0 || *stride == 0 { + let (cout, k, s, p, op) = (*out_ch as u64, *k as u64, *stride as u64, *pad as u64, *opad as u64); + if cout == 0 || k == 0 || s == 0 { return Err(refused(ModelMismatch, "a layer has zero output channels, kernel or stride")); } - if c.checked_mul(cout).and_then(|v| v.checked_mul(k)) != Some(weight.len()) || mult.len() != cout || bias.len() != cout { + if c.checked_mul(cout).and_then(|v| v.checked_mul(k)) != Some(weight.len() as u64) + || mult.len() as u64 != cout || bias.len() as u64 != cout || model.shift > 127 + { return Err(refused(ModelMismatch, "a layer's parameters do not match the running channel count")); } - let (to, full) = conv_t_1d_len(t, k, *stride, *pad, *opad) - .ok_or(refused(ModelMismatch, "a layer's geometry leaves no output, or overflows"))?; - if cout.checked_mul(full).is_none_or(|n| n > MAX_TENSOR) { + let full = (t - 1).checked_mul(s).and_then(|v| v.checked_add(k)).and_then(|v| v.checked_add(op)); + let full = full.filter(|&n| n <= CAP).ok_or(refused(TooLarge, "a layer's uncropped length exceeds 2^26"))?; + if cout.checked_mul(full).is_none_or(|n| n > CAP) { return Err(refused(TooLarge, "a layer would form a buffer over 2^26 elements")); } - (c, t) = (cout, to); + let crop = p.checked_mul(2).unwrap_or(u64::MAX); + if full <= crop { + return Err(refused(ModelMismatch, "a layer's padding leaves no output")); + } + (c, t) = (cout, full - crop); } ALayer::Igdn { c: cc, gamma, beta } => { - if *cc != c || c.checked_mul(c) != Some(gamma.len()) || beta.len() != c { + if *cc as u64 != c || c.checked_mul(c) != Some(gamma.len() as u64) || beta.len() as u64 != c + || model.gf > 127 || model.afrac > 127 + { return Err(refused(ModelMismatch, "an IGDN layer does not match the running channel count")); } } } } - Ok((c, t)) + // Every running size is at most 2^26 here, so it fits usize on every target. + Ok((c as usize, t as usize)) } /// One layer's arithmetic on the values `x` holds, checked before it runs, as diff --git a/wai/wai-rs/src/int_hyper_synth.rs b/wai/wai-rs/src/int_hyper_synth.rs index 56e473712..f702d6c3b 100644 --- a/wai/wai-rs/src/int_hyper_synth.rs +++ b/wai/wai-rs/src/int_hyper_synth.rs @@ -840,10 +840,12 @@ pub const MAX_TENSOR: usize = 1 << 26; /// cbindgen:ignore pub const MAX_AFRAC: u32 = 30; -/// A quantised CDF the rANS decoder is total over: `2 ≤ len ≤ row`, `cdf[0] = 0`, -/// `cdf[len − 1] = 2^16`, strictly increasing over `len`. +/// A table of `extensions/integer-payloads.md` §2 over its first `len` entries: +/// `3 ≤ len ≤ 4097` and `len` within the row, `cdf[0] = 0`, `cdf[len − 1] = 2^16`, +/// strictly increasing over `len`. fn table_ok(cdf: &[i32], len: usize) -> bool { - len >= 2 && len <= cdf.len() && cdf[0] == 0 && cdf[len - 1] == 1 << 16 && cdf[..len].windows(2).all(|p| p[0] < p[1]) + (3..=4097).contains(&len) && len <= cdf.len() && cdf[0] == 0 && cdf[len - 1] == 1 << 16 + && cdf[..len].windows(2).all(|p| p[0] < p[1]) } /// The entropy model's consistency: a non-empty scale table with one Gaussian table, @@ -917,14 +919,85 @@ impl CondBitstream { } } -/// From a network's layer geometry alone, its output on a `[c, h, w]` input, refused -/// unless every buffer it forms is within [`MAX_TENSOR`]. -fn plan(net: &IntModel, c: usize, h: usize, w: usize) -> Result<(usize, usize, usize), Refused> { - let p = net_shape(net, c, h, w).map_err(|_| refused(RefusalKind::ModelMismatch, "a network's layers do not fit the payload's shapes"))?; - if p.peak > MAX_TENSOR { - return Err(refused(RefusalKind::TooLarge, "a network would form a buffer over 2^26 elements")); +/// A network's output on a `[c, h, w]` input, planned from its layers' geometry and +/// parameter lengths alone, before anything is allocated or any value read +/// (`extensions/integer-payloads.md` §7). The input must be within [`MAX_TENSOR`] +/// (`TooLarge`). Then, layer by layer in order: +/// +/// 1. the layer fits the running channel count (`ModelMismatch`): a convolution's +/// output channels, kernel and stride are non-zero, its weights number +/// `c·out·k·k` and its multipliers and biases `out`, and its requantisation shift is +/// under 128; an inverse GDN's `gamma` numbers `c·c` and its `beta` `c`, and its +/// shifts are under 128; a leaky ReLU's shift is under 64; +/// 2. every size the layer forms is within [`MAX_TENSOR`] (`TooLarge`): a +/// convolution's padded input side `n + 2·pad`, a transposed convolution's uncropped +/// side `(n − 1)·stride + k + opad` and uncropped plane, and the output; +/// 3. the layer leaves an output (`ModelMismatch`): a kernel no larger than the padded +/// input, a crop `2·pad` smaller than the uncropped side. +/// +/// Every size is formed in `u64` with checked arithmetic, a size past `u64` being over +/// the cap, so a 32-bit sink refuses exactly what a 64-bit one does, with the same +/// kind; and once a network is planned, every size [`run_net_checked`] forms fits +/// `usize` on every target. The first rule that fails is the refusal. +pub(crate) fn plan(net: &IntModel, c: usize, h: usize, w: usize) -> Result<(usize, usize, usize), Refused> { + use RefusalKind::{ModelMismatch, TooLarge}; + const CAP: u64 = MAX_TENSOR as u64; + let big = |what: &'static str| refused(TooLarge, what); + let within = |v: Option, what: &'static str| v.filter(|&n| n <= CAP).ok_or(big(what)); + let (mut c, mut h, mut w) = (c as u64, h as u64, w as u64); + if c == 0 || h == 0 || w == 0 { + return Err(refused(ModelMismatch, "a network's input is empty")); } - Ok(p.out) + within(c.checked_mul(h).and_then(|v| v.checked_mul(w)), "a network's input exceeds 2^26 elements")?; + let params = |cout: u64, k: u64, weight: usize, mult: usize, bias: usize, c: u64| { + let n = c.checked_mul(cout).and_then(|v| v.checked_mul(k)).and_then(|v| v.checked_mul(k)); + n == Some(weight as u64) && mult as u64 == cout && bias as u64 == cout + }; + for layer in &net.layers { + match layer { + IntLayer::ConvT { out_ch, k, stride, pad, opad, weight, mult, bias } => { + let (cout, k, s, p, op) = (*out_ch as u64, *k as u64, *stride as u64, *pad as u64, *opad as u64); + if cout == 0 || k == 0 || s == 0 || !params(cout, k, weight.len(), mult.len(), bias.len(), c) || net.shift > 127 { + return Err(refused(ModelMismatch, "a transposed convolution does not fit the running channel count")); + } + let full = |n: u64| (n - 1).checked_mul(s).and_then(|v| v.checked_add(k)).and_then(|v| v.checked_add(op)); + let (fh, fw) = (within(full(h), "a layer's uncropped side exceeds 2^26")?, within(full(w), "a layer's uncropped side exceeds 2^26")?); + within(cout.checked_mul(fh).and_then(|v| v.checked_mul(fw)), "a layer's uncropped plane exceeds 2^26 elements")?; + let crop = p.checked_mul(2).unwrap_or(u64::MAX); + if fh <= crop || fw <= crop { + return Err(refused(ModelMismatch, "a transposed convolution's padding leaves no output")); + } + (c, h, w) = (cout, fh - crop, fw - crop); + } + IntLayer::Conv { out_ch, k, stride, pad, weight, mult, bias } => { + let (cout, k, s, p) = (*out_ch as u64, *k as u64, *stride as u64, *pad as u64); + if cout == 0 || k == 0 || s == 0 || !params(cout, k, weight.len(), mult.len(), bias.len(), c) || net.shift > 127 { + return Err(refused(ModelMismatch, "a convolution does not fit the running channel count")); + } + let padded = |n: u64| p.checked_mul(2).and_then(|v| v.checked_add(n)); + let (hp, wp) = (within(padded(h), "a layer's padded side exceeds 2^26")?, within(padded(w), "a layer's padded side exceeds 2^26")?); + if hp < k || wp < k { + return Err(refused(ModelMismatch, "a convolution's kernel is larger than its padded input")); + } + let (ho, wo) = ((hp - k) / s + 1, (wp - k) / s + 1); + within(cout.checked_mul(ho).and_then(|v| v.checked_mul(wo)), "a layer's output exceeds 2^26 elements")?; + (c, h, w) = (cout, ho, wo); + } + IntLayer::Igdn { gamma, beta } => { + if c.checked_mul(c) != Some(gamma.len() as u64) || beta.len() as u64 != c || net.gf > 127 || net.afrac > 127 { + return Err(refused(ModelMismatch, "an inverse GDN does not fit the running channel count")); + } + } + IntLayer::Relu => {} + IntLayer::Leaky { slope_frac, .. } => { + if *slope_frac > 63 { + return Err(refused(ModelMismatch, "a leaky ReLU's shift is 64 or more")); + } + } + } + } + // Every running size is at most 2^26 here, so it fits usize on every target. + Ok((c as usize, h as usize, w as usize)) } /// [`run_net_checked`] with its refusal as a [`Refused`]: values that could overflow a @@ -944,13 +1017,16 @@ fn run_checked(net: &IntModel, x: &[i64], c: usize, h: usize, w: usize) -> Resul /// `wai.video.int_hyper`) runs. On a stream [`decode_conditional`] decodes, both give /// the same image. /// -/// Before anything is allocated: the networks share one fixed-point scale in -/// `1..=`[`MAX_AFRAC`]; the entropy model is consistent and has a `z` table for each -/// of the payload's channels; `h_s` maps the hyper-latent's shape to `[2·m, sh, sw]` -/// and `g_s` maps `[m, sh, sw]` to three channels, with no buffer over [`MAX_TENSOR`] -/// elements. Then every latent is rebuilt with [`dequantize`], every network runs -/// through [`run_net_checked`], and a stream that runs out before its last symbol is -/// refused. Returns RGB HWC with its height and width. +/// Before anything is allocated, in this order: the networks share one fixed-point +/// scale in `1..=`[`MAX_AFRAC`]; the entropy model is consistent and has a `z` table +/// for each of the payload's channels; the latents are within [`MAX_TENSOR`]; `h_s` on +/// the hyper-latent's shape, then `g_s` on `[m, sh, sw]`, are planned ([`plan`]: each +/// layer fits, forms nothing over the cap, and leaves an output); then `h_s` must give +/// `[2·m, sh, sw]` (`GeometryMismatch`) and `g_s` three channels. Then the hyper-latent +/// is decoded symbol by symbol (a symbol that runs the stream out is refused before +/// its value is rebuilt with [`dequantize`]), its stream must end canonically, `h_s` +/// runs through [`run_net_checked`], the latent is decoded the same way, and `g_s` +/// runs. Returns RGB HWC with its height and width. pub fn decode_conditional_checked( g_s: &IntModel, h_s: &IntModel, ent: &CondEntropy, bs: &CondStream<'_>, ) -> Result<(Vec, usize, usize), Refused> { @@ -973,10 +1049,13 @@ pub fn decode_conditional_checked( (Some(_), Some(_)) => (zh * zw, m * sh * sw), _ => return Err(refused(TooLarge, "a latent exceeds 2^26 elements")), }; - if plan(h_s, n_z, zh, zw)? != (2 * m, sh, sw) { + // Both networks are planned, caps included, before either output shape is + // compared with the payload's. + let hs_out = plan(h_s, n_z, zh, zw)?; + let (oc, oh, ow) = plan(g_s, m, sh, sw)?; + if hs_out != (2 * m, sh, sw) { return Err(refused(GeometryMismatch, "h_s does not give the latent shape the payload declares")); } - let (oc, oh, ow) = plan(g_s, m, sh, sw)?; if oc != 3 { return Err(refused(ModelMismatch, "g_s does not give three channels")); } diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 614162f47..9ca1633de 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -542,3 +542,54 @@ fn a_verified_set_answers_only_for_what_it_verified() { assert!(p.member(aud, "model.wam").is_ok()); assert!(p.member(hyp, "g_s.whm").is_err(), "one file is not a several-file set"); } + +/// The entropy tables of a set obey the table rules of the extension's §2 (§7): a +/// length of 2 or 4098 is refused, as a payload's own table is, and 3 and 4097 are read. +#[test] +fn entropy_tables_obey_the_table_length_rule() { + use crate::int_hyper_synth::CondEntropy; + let table = |n: usize| -> Vec { + let mut t: Vec = (0..n as i64).collect(); + t[n - 1] = 65_536; + t + }; + let ent = |n: usize| serde_json::json!({ + "scale_edges": [1], "cdfs": [table(n)], "cdf_lengths": [n], "offsets": [0], + "z_cdfs": [table(n)], "z_cdf_lengths": [n], "z_offsets": [0], "z_median_q": [0] + }); + for n in [2, 4098] { + assert!(CondEntropy::try_from_value(&ent(n)).is_err(), "a table of {n} entries is refused"); + } + for n in [3, 4097] { + assert!(CondEntropy::try_from_value(&ent(n)).is_ok(), "a table of {n} entries is read"); + } +} + +/// A network is planned in 64-bit arithmetic, layer by layer, so a size past the cap is +/// `too_large` however far past it is, on every target. Planning in `usize` refused a +/// transposed convolution of stride 2^31 on a side of 3 as malformed on a 32-bit sink +/// and as too large on a 64-bit one; the stride `usize::MAX / 2 + 1` (2^63 here) is the +/// same split on this target. A convolution padded past the cap is too large though +/// its output would be one element. Parameters that do not fit, or a padding that +/// leaves no output, are the model's. On the committed networks the plan is the shape +/// they run at. +#[test] +fn network_plans_refuse_alike_on_every_target() { + use crate::int_hyper_synth::{net_shape, plan, IntLayer, IntModel, RefusalKind as K}; + let net = |layer: IntLayer| IntModel { afrac: 8, gf: 8, shift: 8, layers: vec![layer] }; + let convt = |stride: usize, pad: usize| net(IntLayer::ConvT { out_ch: 1, k: 1, stride, pad, opad: 0, weight: vec![1], mult: vec![1], bias: vec![0] }); + let kind = |r: Result<(usize, usize, usize), crate::int_hyper_synth::Refused>| r.map_err(|e| e.kind); + for stride in [1usize << 31, usize::MAX / 2 + 1, usize::MAX] { + assert_eq!(kind(plan(&convt(stride, 0), 1, 3, 3)), Err(K::TooLarge), "stride {stride}"); + } + let conv = net(IntLayer::Conv { out_ch: 1, k: 1, stride: usize::MAX, pad: usize::MAX / 4, weight: vec![1], mult: vec![1], bias: vec![0] }); + assert_eq!(kind(plan(&conv, 1, 3, 3)), Err(K::TooLarge)); + assert_eq!(kind(plan(&convt(1, 2), 1, 3, 3)), Err(K::ModelMismatch), "padding that leaves no output"); + let short = net(IntLayer::ConvT { out_ch: 2, k: 1, stride: 1, pad: 0, opad: 0, weight: vec![1], mult: vec![1, 1], bias: vec![0, 0] }); + assert_eq!(kind(plan(&short, 1, 3, 3)), Err(K::ModelMismatch), "weights that do not fit"); + assert_eq!(kind(plan(&convt(2, 0), 1, 3, 3)), Ok((1, 5, 5))); + for (file, c, h, w) in [("int_hyper/tiny_g_s.bin", 4, 4, 4), ("int_hyper/tiny_h_s.bin", 2, 2, 2), ("int_splat/splat_gs.bin", 32, 8, 8)] { + let m = IntModel::from_bytes(&read(file)).unwrap(); + assert_eq!(plan(&m, c, h, w).unwrap(), net_shape(&m, c, h, w).unwrap().out, "{file}"); + } +} diff --git a/wai/wai-rs/src/int_splat.rs b/wai/wai-rs/src/int_splat.rs index 9a44fa37f..b9abe4179 100644 --- a/wai/wai-rs/src/int_splat.rs +++ b/wai/wai-rs/src/int_splat.rs @@ -11,7 +11,7 @@ use serde_json::Value; -use crate::int_hyper_synth::{fnv1a, net_shape, refused, run_net_checked, IntModel, Refused, RefusalKind, MAX_AFRAC, MAX_TENSOR}; +use crate::int_hyper_synth::{fnv1a, refused, run_net_checked, IntModel, Refused, RefusalKind, MAX_AFRAC, MAX_TENSOR}; use crate::rans::RansDecoder; /// A decoded splat: the `[attr · s · s]` `u8` attribute image (channel-major), to be @@ -139,11 +139,7 @@ pub fn decode_typed(gs: &IntModel, st: &SplatStream<'_>) -> Result MAX_TENSOR { - return Err(refused(TooLarge, "g_s would form a buffer over 2^26 elements")); - } - if plan.out != (n_attr, s, s) { + if crate::int_hyper_synth::plan(gs, c, sh, sw)? != (n_attr, s, s) { return Err(refused(GeometryMismatch, "g_s does not give the attribute shape the payload declares")); } -- 2.54.0 (Apple Git-157) From 8d5f32feac80a4da08a60ce611ec23951c1e7959 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 19:29:40 -0400 Subject: [PATCH 17/34] wai: the splat converter carries each range exactly wis1_from_json read a range within one f64 unit of a binary32 value as that value, standing in for a JSON reader that is not correctly rounded. splat_int now reads JSON numbers correctly rounded (serde_json/float_roundtrip, which `oer` already enables), and a range must be exactly a binary32 value. The Python verifier's converter already refuses one explicitly rather than through assert (earlier commit). Test: int_payload::tests::splat_ranges_are_carried_exactly (a value one f64 unit either side of the committed range is refused; the range itself converts). Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/Cargo.toml | 2 +- wai/wai-rs/src/int_payload/mod.rs | 16 +++++----------- wai/wai-rs/src/int_payload/tests.rs | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index ee89aae83..79d401782 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -142,7 +142,7 @@ world_int = ["neural_int"] # Integer-exact LEARNED Gaussian-splat ATTRIBUTE codec ("the int_hyper of 3D"): # learned transform over the [14,S,S] attribute image → byte-exact attribute tensor # (splat-equivalence, a portable cloud hash). Reuses int_hyper_synth (neural_int). -splat_int = ["neural_int"] +splat_int = ["neural_int", "serde_json/float_roundtrip"] neural = ["neural_int", "dep:ort", "dep:ndarray", "dep:zstd-safe"] # The perceptual / generative-semantic path: CLIP-similarity conformance, # regenerate-at-sink. Reuses the ONNX runtime for the reference CLIP embedder; diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index c7f1a2057..90ee89c2f 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -556,11 +556,10 @@ pub fn wia1_from_json(bs: &serde_json::Value, sr: u32) -> Result, IntPay /// Convert the splat export's JSON bitstream (`splat.json`) to `WIS1`, with its /// per-attribute presentation ranges. The export writes each range as a binary32 -/// value; a range that is not one is refused, so the payload carries what the JSON -/// states. `serde_json`'s default number reader is not correctly rounded and can land -/// one f64 unit in the last place from the decimal's value, so a value within one unit -/// of a binary32 value is read as that value; a decimal that does not denote a binary32 -/// value is further away than that by many orders of magnitude. +/// value; a range that is not exactly one is refused, so the payload carries what the +/// JSON states. `splat_int` reads JSON numbers correctly rounded +/// (`serde_json/float_roundtrip`), so a decimal that denotes a binary32 value reads as +/// exactly that value, and no tolerance is needed. #[cfg(feature = "splat_int")] pub fn wis1_from_json(bs: &serde_json::Value) -> Result, IntPayloadError> { let j = crate::int_splat::SplatJson::from_value(bs).map_err(json)?; @@ -569,12 +568,7 @@ pub fn wis1_from_json(bs: &serde_json::Value) -> Result, IntPayloadError } let exact = |x: f64| -> Result { let f = x as f32; - let back = f as f64; - if back == x || (back.is_sign_negative() == x.is_sign_negative() && back.to_bits().abs_diff(x.to_bits()) <= 1) { - Ok(f) - } else { - Err(json("a range is not a binary32 value")) - } + if f as f64 == x { Ok(f) } else { Err(json("a range is not exactly a binary32 value")) } }; let ranges = j.ranges.iter().map(|&(lo, hi)| Ok((exact(lo)?, exact(hi)?))).collect::, IntPayloadError>>()?; let v = wire::Wis1 { diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 9ca1633de..6dc3353c3 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -593,3 +593,24 @@ fn network_plans_refuse_alike_on_every_target() { assert_eq!(plan(&m, c, h, w).unwrap(), net_shape(&m, c, h, w).unwrap().out, "{file}"); } } + +/// The splat converter carries each presentation range exactly: a JSON decimal that +/// denotes a binary32 value converts to it, and one a single f64 unit away from it, +/// written as its own shortest decimal, is refused. `splat_int` reads numbers +/// correctly rounded, so no tolerance stands in for the reader. +#[cfg(feature = "splat_int")] +#[test] +fn splat_ranges_are_carried_exactly() { + let text = String::from_utf8(read("int_splat/splat.json")).unwrap(); + let js: serde_json::Value = serde_json::from_str(&text).unwrap(); + let lo0 = js["lo"][0].as_f64().unwrap(); + assert_eq!((lo0 as f32) as f64, lo0, "the committed range is a binary32 value, read exactly"); + assert!(wis1_from_json(&js).is_ok()); + for step in [1i64, -1] { + let off = f64::from_bits(((lo0 as f32) as f64).to_bits().wrapping_add_signed(step)); + let mut bad: serde_json::Value = serde_json::from_str(&text).unwrap(); + bad["lo"][0] = serde_json::from_str(&format!("{off:?}")).unwrap(); + assert_eq!(bad["lo"][0].as_f64(), Some(off), "the reader is correctly rounded"); + assert_eq!(wis1_from_json(&bad).unwrap_err().code(), "field_range", "{off:?}"); + } +} -- 2.54.0 (Apple Git-157) From f1271f0591d9eef7401d8c46a99290b178ede94a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 19:33:45 -0400 Subject: [PATCH 18/34] wai: a network file ends at its last layer; converters check channel counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IntModel::from_bytes (WHM1) and AudioModel::from_bytes (WAM1) refuse bytes after the last layer, so one network has one file and one digest; integer-payloads.md §9 says so. Every committed network ends there. - wia1_from_json and wis1_from_json refuse a JSON whose medians, tables or offsets are not one per channel, instead of writing a payload out of step with its header. - The neural sink's unpinned integer decode is checked against the payload first: a JSON form beside a set that lacks a member is bad_magic, and a WIH1 is set_incomplete naming the member (the set is read once, in the first commit of this series). - ffi_neural: one string literal held a run of spaces. Tests: int_payload::tests::a_model_file_with_bytes_after_its_last_layer_is_refused, int_payload::tests::converters_check_the_channel_count, and neural_native_decode::int_hyper_decodes_through_the_neural_dispatch (extended). Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 9 +++-- wai/extensions/integer-payloads.md | 4 ++- wai/wai-rs/src/ffi_neural.rs | 2 +- wai/wai-rs/src/int_audio.rs | 7 +++- wai/wai-rs/src/int_hyper_synth.rs | 8 ++++- wai/wai-rs/src/int_payload/mod.rs | 6 ++++ wai/wai-rs/src/int_payload/tests.rs | 46 ++++++++++++++++++++++++ wai/wai-rs/tests/neural_native_decode.rs | 15 ++++++++ 8 files changed, 90 insertions(+), 7 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 9114dfac3..6e8a04078 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -22,8 +22,9 @@ wire format may change and such changes are recorded here. still write JSON; `int_payload::{wiv1,wih1,wia1,wis1}_from_json` convert it and state what it left implicit (an absent `mv_precision` is 1; the first frame carries no motion vectors; a short motion-vector list is padded with `(0, 0)`; the audio - sample rate comes from the export's `audio.json`; a splat range must be a binary32 - value; the JSON `afrac` must be the model's). *Envelopes this makes + sample rate comes from the export's `audio.json`; a splat range must be exactly a + binary32 value; the JSON `afrac` must be the model's; the channel count must match + the medians, tables and offsets). *Envelopes this makes non-conforming:* a `WAI1` or `WAI2` that carries anything but the pinned format under these strings, a tool's JSON output among them. It still parses; a sink refuses it at decode (a JSON form with `bad_magic`) and does not try the fallback. @@ -83,7 +84,9 @@ wire format may change and such changes are recorded here. `wai.video.int_hyper`. `wai.audio.int_codec` (`model.wam`) and `wai.splat.int_codec` (`g_s.whm`) are registered as one file, which widens their registrations (SPEC §8). `wai registry export` writes nothing new: no exported field - records a set's shape. + records a set's shape. A `WHM1` or `WAM1` network holds nothing after its last layer + (`IntModel::from_bytes` and `AudioModel::from_bytes` refuse trailing bytes; every + committed network has none), so one network has one file and one digest. - **Receipts.** A `wai.video.int_motion` receipt identifies its `WIV1` payload (`VideoReceipt::seal_decode_wiv1`, `verify_reconstruction_wiv1`), and `wai_video_meter` seals over it. The entry points that read the JSON form are diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index b0f45766e..7d9c98e76 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -402,7 +402,9 @@ for 0, output padding as `u32`; weights as `i16`; per-output-channel multipliers biases as `i64`), 2 an inverse GDN (channels as `u32`; `gamma` as `i32`, `beta` as `i64`), 3 a ReLU, 4 a leaky ReLU (slope `i64`, its shift `u32`). **`WAM1`** (`int_audio::AudioModel::from_bytes`): the same header, then layers of tag 0, a -one-dimensional transposed convolution, and tag 2, an inverse GDN. The layers' integer +one-dimensional transposed convolution, and tag 2, an inverse GDN. In both forms +nothing follows the last layer: a file with bytes after it is refused +(`model_mismatch`), so one network has one file and one digest. The layers' integer arithmetic is the reference decoders' (`int_hyper_synth.rs`, `int_audio.rs`, `int_transform.rs`), which the conformance vectors pin. diff --git a/wai/wai-rs/src/ffi_neural.rs b/wai/wai-rs/src/ffi_neural.rs index 574ddf1e3..fa183ebec 100644 --- a/wai/wai-rs/src/ffi_neural.rs +++ b/wai/wai-rs/src/ffi_neural.rs @@ -287,7 +287,7 @@ pub unsafe extern "C" fn wai_neural_decode_ex( // Rust API returns them (`int_payload::decode_int`). Refused here rather // than converted, so no caller reads them as another kind. Decoded::Int(_) => { - return Err((WAI_ERR_CODEC, "this integer output (video, PCM or splat attributes) has no wai_decoded_t kind; decode it with the Rust API".to_string())); + return Err((WAI_ERR_CODEC, "this integer output (video, PCM or splat attributes) has no wai_decoded_t kind; decode it with the Rust API".to_string())); } } unsafe { out.write(o) }; diff --git a/wai/wai-rs/src/int_audio.rs b/wai/wai-rs/src/int_audio.rs index 17080b744..31e912137 100644 --- a/wai/wai-rs/src/int_audio.rs +++ b/wai/wai-rs/src/int_audio.rs @@ -75,7 +75,8 @@ fn count(dims: &[usize]) -> Result { impl AudioModel { /// Parse the export tool's compact binary model: magic `WAM1`, then /// `afrac/gf/shift/n_layers` (u32) and per layer a `u8` tag — 0 convT1d, 2 igdn - /// — with int12 weights as `i16`, IGDN `gamma` as `i32`, the rest `i64`. + /// — with int12 weights as `i16`, IGDN `gamma` as `i32`, the rest `i64`, and + /// nothing after the last layer. pub fn from_bytes(b: &[u8]) -> Result { if b.get(0..4) != Some(b"WAM1") { return Err("bad model magic (want WAM1)"); @@ -103,6 +104,10 @@ impl AudioModel { _ => return Err("bad layer tag"), }); } + // Nothing follows the last layer (`extensions/integer-payloads.md` §9). + if c.p != b.len() { + return Err("bytes follow the last layer"); + } Ok(Self { afrac, gf, shift, layers }) } } diff --git a/wai/wai-rs/src/int_hyper_synth.rs b/wai/wai-rs/src/int_hyper_synth.rs index f702d6c3b..24169e1c5 100644 --- a/wai/wai-rs/src/int_hyper_synth.rs +++ b/wai/wai-rs/src/int_hyper_synth.rs @@ -291,7 +291,8 @@ impl IntModel { /// Parse the export tool's **compact binary** model (`*_model.bin`): magic /// `WHM1`, then `afrac/gf/shift/n_layers` (u32) and per layer a `u8` tag — /// 0 convT, 1 conv, 2 igdn, 3 relu, 4 leaky — with int12 weights as `i16`, - /// IGDN `gamma` as `i32`, the rest `i64`. Decodes identically to [`from_json`]. + /// IGDN `gamma` as `i32`, the rest `i64`, and nothing after the last layer. + /// Decodes identically to [`from_json`]. pub fn from_bytes(b: &[u8]) -> Result { if b.get(0..4) != Some(b"WHM1") { return Err("bad model magic (want WHM1)"); @@ -328,6 +329,11 @@ impl IntModel { _ => return Err("bad layer tag"), }); } + // Nothing follows the last layer (`extensions/integer-payloads.md` §9), so one + // network has one file. + if c.p != b.len() { + return Err("bytes follow the last layer"); + } Ok(Self { afrac, gf, shift, layers }) } } diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index 90ee89c2f..096594fa9 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -537,6 +537,9 @@ pub fn wih1_from_json(bs: &serde_json::Value) -> Result, IntPayloadError #[cfg(feature = "audio_int")] pub fn wia1_from_json(bs: &serde_json::Value, sr: u32) -> Result, IntPayloadError> { let j = crate::int_audio::AudioJson::from_value(bs).map_err(json)?; + if j.median_q.len() != j.n_ch || j.cdfs.len() != j.n_ch { + return Err(json("the medians and tables are not one per channel (n_ch)")); + } table_len(j.cdfs[0].len())?; let v = wire::Wia1 { afrac: u8::try_from(j.afrac).map_err(|_| json("afrac exceeds a byte"))?, @@ -563,6 +566,9 @@ pub fn wia1_from_json(bs: &serde_json::Value, sr: u32) -> Result, IntPay #[cfg(feature = "splat_int")] pub fn wis1_from_json(bs: &serde_json::Value) -> Result, IntPayloadError> { let j = crate::int_splat::SplatJson::from_value(bs).map_err(json)?; + if j.cdfs.len() != j.c || j.offsets.len() != j.c || j.median_q.len() != j.c { + return Err(json("the tables, offsets and medians are not one per channel (c)")); + } for t in &j.cdfs { table_len(t.len())?; } diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 6dc3353c3..1cee03223 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -614,3 +614,49 @@ fn splat_ranges_are_carried_exactly() { assert_eq!(wis1_from_json(&bad).unwrap_err().code(), "field_range", "{off:?}"); } } + +/// The converters check the JSON's channel count against its tables: a medians list, +/// a table list or an offsets list one short is refused (`field_range`), never written +/// out of step with the header. +#[cfg(all(feature = "audio_int", feature = "splat_int"))] +#[test] +fn converters_check_the_channel_count() { + let audio = jload("int_audio/audio_bitstream.json"); + for key in ["median_q", "cdfs"] { + let mut j = audio.clone(); + j[key].as_array_mut().unwrap().pop(); + let e = wia1_from_json(&j, 16_000).unwrap_err(); + assert_eq!((e.code(), e.detail().contains("one per channel")), ("field_range", true), "audio {key}: {e}"); + } + let splat = jload("int_splat/splat.json"); + for key in ["median_q", "cdfs", "offsets"] { + let mut j = splat.clone(); + j[key].as_array_mut().unwrap().pop(); + let e = wis1_from_json(&j).unwrap_err(); + assert_eq!((e.code(), e.detail().contains("one per channel")), ("field_range", true), "splat {key}: {e}"); + } +} + +/// `WHM1` and `WAM1` hold nothing after their last layer (§9): a committed network with +/// one byte appended is refused, and a decode against it is `model_mismatch`. +#[test] +fn a_model_file_with_bytes_after_its_last_layer_is_refused() { + use crate::int_hyper_synth::IntModel; + let mut g_s = read("int_hyper/tiny_g_s.bin"); + assert!(IntModel::from_bytes(&g_s).is_ok()); + g_s.push(0); + assert_eq!(IntModel::from_bytes(&g_s).err(), Some("bytes follow the last layer")); + let mut set = int_hyper_set(); + set.insert("g_s.whm".into(), g_s); + let files = view(&set); + let hyp = crate::codecs::CAP_NEURAL_INT_HYPER; + let r = decode_int(hyp, &read("int_hyper/tiny_bitstream.wih1"), &unpinned(hyp, HeldBytes::Set { listing: b"", files: &files })); + assert_eq!(r.map(|_| ()).unwrap_err().code(), "model_mismatch"); + #[cfg(feature = "audio_int")] + { + let mut wam = read("int_audio/audio_model.bin"); + assert!(crate::int_audio::AudioModel::from_bytes(&wam).is_ok()); + wam.push(0); + assert_eq!(crate::int_audio::AudioModel::from_bytes(&wam).err(), Some("bytes follow the last layer")); + } +} diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index 44d94fdb1..33f5ddf95 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -1174,6 +1174,8 @@ fn int_hyper_set_dir(tag: &str, listed: &[&str]) -> (PathBuf, String) { /// committed image. A pinned set whose listing leaves out `h_s.whm` is refused with /// `set_incomplete` naming it. The tool's JSON output carried as the payload is refused /// (`bad_magic`), and the refusal is final: the fallback the envelope names is not tried. +/// An unpinned set without `h_s.whm` refuses a JSON form as `bad_magic` too, the payload +/// being checked before any member is read, and a `WIH1` as `set_incomplete`. #[test] fn int_hyper_decodes_through_the_neural_dispatch() { use wai::codecs::{DeclaredTier, CAP_NEURAL_INT_HYPER as HYP, CAP_NEURAL_INT_SYNTH, CAP_PRIOR_SET}; @@ -1215,8 +1217,21 @@ fn int_hyper_decodes_through_the_neural_dispatch() { other => panic!("a JSON form must be refused as bad_magic, got {}", refusal(other)), } } + // An unpinned set that lacks a member: the payload is checked first, so a JSON form + // is still bad_magic, and a WIH1 is set_incomplete, naming the member, so dispatch + // moves on as it does for any decoder. + let (lack, _) = int_hyper_set_dir("lack", &["entropy.json", "g_s.whm", "h_s.whm"]); + std::fs::remove_file(lack.join("h_s.whm")).unwrap(); + let reg = ModelRegistry::new().register(HYP, lack.join("g_s.whm")); + match decode_envelope(&env("improve", None, None, json.clone()), ®) { + Err(DecodeError::InvalidPayload(why)) => assert!(why.contains("bad_magic"), "{why}"), + other => panic!("a JSON form must be refused as bad_magic, got {}", refusal(other)), + } + let r = decode_envelope(&env("improve", None, None, payload.clone()), ®); + assert!(matches!(r, Err(DecodeError::PriorMismatch(wai::container::PinRefusal::SetIncomplete { ref path })) if path == "h_s.whm"), "{}", refusal(r)); std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&part).ok(); + std::fs::remove_dir_all(&lack).ok(); } /// The integer video, audio and splat capabilities through the neural dispatch: -- 2.54.0 (Apple Git-157) From 48e4f106ab22e99a141c979e25a9fc2ddfa3a28f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 19:43:03 -0400 Subject: [PATCH 19/34] =?UTF-8?q?wai:=20the=20integer-payload=20pin,=20as?= =?UTF-8?q?=20SPEC=20=C2=A78=20now=20permits=20it,=20and=20what=20is=20fin?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPEC §8 gains *Completing a registration*: a revision may complete the registration of a WAI-native string whose row named a decoder and fixed no byte layout by pinning one; envelopes carrying another form become non-conforming, still parse, and are listed in §5; "fixed forever" starts at the pin. SPEC §5, integer-payloads.md §1 and §11, REGISTRY and the CHANGELOG cite it, no longer say the pin redefines nothing, and say that wai.neural.int_hyper's and wai.video.int_hyper's rows named their decoders only. - integer-payloads.md §8: a refusal of the payload or of the set against it is final; set_incomplete, not_dispatchable and unsupported_capability route as SPEC §4 routes a capability the sink cannot decode. The neural sink now moves on to the fallback (WAI1) or the next entry (WAI2) when the build has no decoder for the capability, as it does for set_incomplete. - SPEC §4 and jwp-receipts §7.9 name int_payload::resolve as a place check 5 runs. The CHANGELOG says what JSON the deprecated JSON entry points now refuse, flags the breaking API changes (neural::Decoded gains Int and is non_exhaustive; ParamSource and IntParams), and names the integer capabilities instead of counting them. - The int_hyper demo says it decodes the export tool's debug JSON. - Tests: the WIV1/WIA1/WIS1 equivalence tests say what they compare, and video_nk is anchored to the FNV-1a hashes recorded before this revision; the preflight's hyper-latent case now exceeds the parser's cap and says so. CI runs the conformance workflow on an edit of integer-payloads.md. Test: neural_native_decode::an_integer_capability_without_a_decoder_moves_on_to_the_fallback (added to the neural gates step). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 6 +++ .github/workflows/wai-neural-gates.yml | 3 +- wai/CHANGELOG.md | 27 +++++++++-- wai/REGISTRY.md | 7 +-- wai/SPEC.md | 41 +++++++++++----- wai/extensions/integer-payloads.md | 59 ++++++++++++++++------- wai/extensions/jwp-receipts.md | 11 +++-- wai/int-payload-conformance/README.md | 25 +++++++--- wai/wai-rs/src/int_payload/mod.rs | 6 ++- wai/wai-rs/src/int_payload/tests.rs | 35 ++++++++++---- wai/wai-rs/src/neural/mod.rs | 24 +++++++-- wai/wai-rs/tests/int_payload_preflight.rs | 7 ++- wai/wai-rs/tests/neural_native_decode.rs | 26 ++++++++++ wai/wai-web/demo/int_hyper.html | 6 ++- 14 files changed, 213 insertions(+), 70 deletions(-) diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index b49e29d5a..3fc34b1ba 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -30,6 +30,9 @@ on: - 'wai/glossary/**' - 'wai/SPEC.md' - 'wai/REGISTRY.md' + # A library test checks SPEC §5's pinned integer payloads against this + # extension's sections, so an edit of it is checked here too. + - 'wai/extensions/integer-payloads.md' - 'wai/wai-web/**' - '.github/workflows/wai-conformance.yml' pull_request: @@ -49,6 +52,9 @@ on: - 'wai/glossary/**' - 'wai/SPEC.md' - 'wai/REGISTRY.md' + # A library test checks SPEC §5's pinned integer payloads against this + # extension's sections, so an edit of it is checked here too. + - 'wai/extensions/integer-payloads.md' - 'wai/wai-web/**' - '.github/workflows/wai-conformance.yml' diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index 2f77edeef..c360f3109 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -138,8 +138,9 @@ jobs: strict_gate_reads_the_capability_it_decodes_through set_incomplete_at_step_3_outranks_the_fallback_refusal \ decode_multi_strict_reports_replicate_reasons strict_gate_ignores_a_held_capability_it_cannot_dispatch \ int_hyper_decodes_through_the_neural_dispatch int_synth_check_5_covers_the_spec_a_model_directory_holds \ + an_integer_capability_without_a_decoder_moves_on_to_the_fallback \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 14 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 15 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" # The integer video, audio and splat capabilities through the neural dispatch, which # delegates them to int_payload::decode_int: each decodes to Decoded::Int with its # committed SHA-256, and wai.video.int_motion is advertised with ModelRegistry::builtin. diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 6e8a04078..3e68b68d5 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -14,8 +14,12 @@ wire format may change and such changes are recorded here. with keyframe 1, a nested `WIH1`; of `wai.neural.int_hyper`, `WIH1`; of `wai.audio.int_codec`, `WIA1`, which carries the sample rate; of `wai.splat.int_codec`, `WIS1`, which carries each attribute's presentation range. - These strings' rows named a decoder and the tool that wrote a bitstream, and no byte - layout, so the pin completes their registration and redefines nothing; from this + These strings' rows named a decoder and no byte layout; three of them + (`wai.video.int_motion`, `wai.audio.int_codec`, `wai.splat.int_codec`) also named + the tool that wrote a JSON bitstream, which SPEC §5 called the bytes the codec + normally emits. So the pin changes what these strings carry. SPEC §8 gains + *Completing a registration*, which permits it for a WAI-native string whose row + fixed no byte layout and lists the envelopes it makes non-conforming; from this revision their formats are fixed as SPEC §8 states. No new string is registered. `codecs::PINNED_INT_PAYLOADS` is the one table of strings and magics. - **The JSON is a converter input, never a payload.** The encode and export tools @@ -46,7 +50,14 @@ wire format may change and such changes are recorded here. decode and over the set an unpinned decode reads. The neural sink delegates every integer capability to it: integer video, PCM and splat attributes come back as `neural::Decoded::Int`, never converted; `ModelRegistry::builtin` advertises - `wai.video.int_motion`, which reads no set; the C ABI refuses an `Int` output. + `wai.video.int_motion`, which reads no set; the C ABI refuses an `Int` output. A + capability this build has no integer decoder for (`unsupported_capability`) moves + dispatch on to the fallback, as SPEC §4 treats a capability a sink cannot decode. + *Breaking:* `neural::Decoded` gains the variant `Int` and is now `#[non_exhaustive]`, + so a `match` on it outside this crate needs a wildcard arm; `neural::ParamSource` + gains `UnpinnedRead` (an unpinned integer set read into memory once) and is + `#[non_exhaustive]` too; `int_payload::IntParams` is opaque, built by + `int_payload::resolve` (or `IntParams::none()`). - **Checked decoders.** `int_video`, `int_audio` and `int_splat` decode a typed stream that the binary and JSON forms both read into. Every size is capped at `2^26` elements (a `WIV1` clip at `2^30` bytes and `2^20` frames, its motion-vector @@ -90,8 +101,14 @@ wire format may change and such changes are recorded here. - **Receipts.** A `wai.video.int_motion` receipt identifies its `WIV1` payload (`VideoReceipt::seal_decode_wiv1`, `verify_reconstruction_wiv1`), and `wai_video_meter` seals over it. The entry points that read the JSON form are - deprecated and unchanged, so every receipt sealed over it, and every pinned digest - of one, still verifies. jwp-receipts §2.1's object content hash moves to its own + deprecated; on a conforming JSON bitstream their output is unchanged, so every + receipt sealed over a committed one, and every pinned digest of one, still + verifies. They now read the JSON through `int_video::VideoJson` and the checked + decoder, so they refuse JSON the old decoder took: a stream that runs out (it + decoded on from the exhausted state) or does not end canonically, a table that + breaks the table rules, `q` outside 1..=65535, an `mv_precision` other than 1 or 2, + other than one motion-vector list per frame, vectors on the first frame or more + vectors than blocks, a vector outside `i16`, a zero size, and a size past the caps. jwp-receipts §2.1's object content hash moves to its own module and feature (`jwp_hash`), which `receipt_claims` enables; `claim_receipt::object_content_hash` re-exports it, and no corpus changed. - **Browser.** `wai-web` decodes the four formats (`decodeWiv1`, `decodeWih1`, diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index cf24fd51c..3f7f47bbd 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -51,9 +51,10 @@ Two further tables hold strings that look like capabilities and are not: - **registered** — the payload format (for a record or derivation, the definition) is pinned. The WAI-native integer codecs `wai.video.int_motion`, `wai.video.int_hyper`, `wai.neural.int_hyper`, `wai.audio.int_codec` and - `wai.splat.int_codec` were registered with a decoder and the tool that wrote their - bitstreams, and no byte layout; v0.4.0 pins their formats (SPEC §5, *Payloads of the - WAI-native integer codecs*). + `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*). - **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 73737c19a..4b8a0fe63 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -422,11 +422,12 @@ fallback (§3). A sink configured with no key log holds no module under §7.9, s runs no module obtained apart from an envelope, and it does not run check 5. The reference dispatcher in `wai-rs` runs no module obtained apart from an envelope; it runs check 5 when the sink is configured with a key log (`key_log::SinkKeyLog`, -feature `key_discovery`, given to `pin::verify_with` or to -`neural::ModelRegistry::with_key_log`, which also runs it before an unpinned -decode): the revocation part for every capability a pin applies to, and §7.9's -authorisation for the capabilities the sink holds to it. `wai-web` and the C ABI -take no key log. +feature `key_discovery`, given to `pin::verify_with`, to `int_payload::resolve` +(which runs it for a pinned decode and over the files an unpinned integer decode +reads) or to `neural::ModelRegistry::with_key_log`, which also runs it before an +unpinned decode, over the bytes that decode then reads): the revocation part for +every capability a pin applies to, and §7.9's authorisation for the capabilities the +sink holds to it. `wai-web` and the C ABI take no key log. --- @@ -693,10 +694,14 @@ receipt; the sink's control system runs the hardware: ### Payloads of the WAI-native integer codecs This revision pins a payload format to each WAI-native integer codec whose row named -a decoder and the tool that wrote its bitstream, and no byte layout. -[integer-payloads](extensions/integer-payloads.md) §3–§6 defines each layout and its -decode, §7 the checks a sink makes, and §8 the closed list of codes it refuses with. -The rows above keep their headings. +its decoder and no byte layout. The rows of `wai.video.int_motion`, +`wai.audio.int_codec` and `wai.splat.int_codec` also named the tool that wrote a +bitstream, a JSON document, which this section's opening called the bytes the codec +normally emits; those of `wai.neural.int_hyper` and `wai.video.int_hyper` named their +decoders only. The pin completes these registrations as §8 permits (*Completing a +registration*). [integer-payloads](extensions/integer-payloads.md) §3–§6 defines +each layout and its decode, §7 the checks a sink makes, and §8 the closed list of +codes it refuses with. The rows above keep their headings. | capability | payload format | parameter set | |---|---|---| @@ -706,9 +711,8 @@ The rows above keep their headings. | `wai.audio.int_codec` | `WIA1` | one file: `model.wam` | | `wai.splat.int_codec` | `WIS1` | one file: `g_s.whm` | -These rows fixed no payload format before this revision, so the pin completes their -registration and redefines nothing (§8); from this revision on, the format of each is -fixed as §8 states. The JSON the encode and export tools write is a converter input +From this revision on, the format of each is fixed as §8 states. The JSON the encode +and export tools write is a converter input ([integer-payloads](extensions/integer-payloads.md) §10), never a payload: an encoder MUST NOT carry it under these strings, and a sink refuses it (`bad_magic`). @@ -1803,7 +1807,18 @@ envelope that uses any string a later revision registers. whole envelope where an earlier reader ignored the member. - New capabilities (§5) MAY be added in any MINOR revision. Existing capabilities MUST NOT be redefined — once a capability string is - registered for a codec, its payload format is fixed forever. + registered for a codec, its payload format is fixed forever; for a registration + completed as the next item says, from the revision that completes it. +- **Completing a registration.** A WAI-native string whose §5 row named the decoder + that reads its payload and fixed no byte layout (naming, at most, a tool that wrote + it) has a registration with no payload format to hold. A MINOR revision MAY + complete such a registration by pinning a byte layout, which is not a + redefinition. Envelopes that carried another form under the string, a tool's + output among them, become non-conforming; they still parse, and the completing + 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. - **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/integer-payloads.md b/wai/extensions/integer-payloads.md index 7d9c98e76..049713664 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -22,17 +22,25 @@ | `wai.audio.int_codec` | `WIA1` (§5) | one file: `model.wam` | | `wai.splat.int_codec` | `WIS1` (§6) | one file: `g_s.whm` | -**Why this is a pin and not a redefinition.** Until this revision, these -capabilities' SPEC §5 rows named the tool that wrote a bitstream (an encode or export -script) and the decoder that read it, and no byte layout. Their payload format was -never fixed, so fixing it now completes their registration; it changes no format a -revision had fixed. SPEC §8's rule that a registered string's payload format is fixed -forever applies to these strings from this revision on. The encode and export tools -write JSON that carries the same fields; it is a converter input (§10). +**Why this pin is permitted.** Until this revision, these capabilities' SPEC §5 rows +named the decoder that read their payload, and no byte layout. The rows of +`wai.video.int_motion`, `wai.audio.int_codec` and `wai.splat.int_codec` also named the +encode or export tool that wrote a bitstream, a JSON document; those of +`wai.neural.int_hyper` and `wai.video.int_hyper` named their decoders only. SPEC §5 +called every row's payload the bytes its codec normally emits, which for these rows +was the tools' JSON. So this revision changes what these strings carry. SPEC §8 +permits it under *Completing a registration*: a revision may complete the +registration of a WAI-native string whose row named a decoder and fixed no byte layout +by pinning one, and it lists the envelopes that become non-conforming (§11; SPEC §5). +SPEC §8's rule that a registered string's payload format is fixed forever applies to +these strings from this revision on. The encode and export tools still write JSON +that carries the same fields; it is a converter input (§10). `wai.neural.int_synth` and `wai.neural.int_mlicpp` keep the payload formats SPEC §5 -gives them. A sink decodes all seven integer capabilities through one path -(`int_payload::decode_int`), and §8 names the codes it reports for those two. +gives them. A sink decodes them and the five strings above (`wai.video.int_motion`, +`wai.video.int_hyper`, `wai.neural.int_hyper`, `wai.audio.int_codec`, +`wai.splat.int_codec`) through one path (`int_payload::decode_int`), and §8 names the +codes it reports for those two. ## 2. Common rules @@ -350,8 +358,20 @@ panics. ## 8. Refusal codes The list is closed: a sink reports exactly one of these, the first that applies in the -order of §2 and §7, and a refusal is final. A sink does not decode the payload by -another path, and SPEC §4's fallback is not tried. +order of §2 and §7. + +A refusal of the payload or of the parameter set against it, `bad_magic` through +`model_mismatch` in the table below (and `payload_malformed`), is final: the sink does +not decode the payload by another path, and SPEC §4's fallback is not tried, since it +would decode the same bytes. The other codes say the sink cannot decode through the +capability at all, and route as SPEC §4 routes such a capability: + +- `set_incomplete` is a pin that did not resolve (SPEC §3.1): a `WAI1` continues at + SPEC §4 step 4 (or reports it at step 6), and a `WAI2` moves on to its next entry + (SPEC §7.1); +- `not_dispatchable` and `unsupported_capability` name a capability the sink does not + decode: SPEC §4 does not take step 3 (or 5) with it, so a `WAI1` continues at step 4 + and a `WAI2` at its next entry, as for a capability the sink does not advertise. | code | when | |---|---| @@ -434,8 +454,9 @@ vectors check that for every committed JSON bitstream. ## 11. Envelopes this revision makes non-conforming -This revision fixes a payload format these strings' registrations did not have (SPEC -§8, *Registering a capability*). It makes non-conforming, and they still parse: +This revision completes these strings' registrations with a payload format they did +not have (SPEC §8, *Completing a registration*). It makes non-conforming, and they +still parse: - a `WAI1` envelope whose `capability` or `fallback` is a string of §1 and whose payload is not that string's format: a tool's JSON form among them; @@ -444,8 +465,9 @@ This revision fixes a payload format these strings' registrations did not have ( A sink refuses such a payload when it decodes it (§8), with no fallback attempt. An encoder MUST NOT write one; the reference implementation's emit paths (`wai wrap`, the -C packers and the browser writers) parse the payload as a sink does and refuse to. No committed envelope or -conformance corpus carried such a payload when this revision was made. +C packers and the browser writers) parse the payload as a sink does and refuse to. +No committed envelope or conformance corpus carried such a payload when this revision +was made. ## 12. Reference implementation and conformance @@ -454,8 +476,11 @@ conformance corpus carried such a payload when this revision was made. parameter files held in memory, with no ONNX runtime and no filesystem, for native and `wasm32` sinks; `int_payload::resolve` verifies a pin over sets held in memory (`pin::verify_bytes_with`) and runs SPEC §3.1's check 5, for a pinned decode and over - the set an unpinned decode reads. The neural sink (`neural::decode_payload`) - delegates every integer capability to `decode_int`. + the files an unpinned decode reads (a listing the set carries must match them, and + the digests include the listing of the files read); it is the only way to build the + parameters a decode reads (`IntParams`). The neural sink (`neural::decode_payload`) + delegates every integer capability to `decode_int`, and its registry reads an + unpinned integer set once and runs check 5 over those bytes. - `codecs::PINNED_INT_PAYLOADS` is the one table of strings and magics; `container::check_int_payload_framing` parses a payload with `int_payload::wire` and the capability's keyframe kind, which needs no integer decoder and no feature, so diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index 76e9d2c97..cbb6b8f82 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -2602,10 +2602,13 @@ left the file inert because no fallback was declared. `key_log::module_check` in `wai-rs` implements these checks. The reference dispatcher runs SPEC §3.1's check 5 when the sink is configured with a key log (`key_log::SinkKeyLog`, given to -`pin::verify_with` or to `neural::ModelRegistry::with_key_log`, which -also runs it before an unpinned decode): the revocation lists for every -capability a pin applies to, and these checks for the capabilities a sink -holds to this section. A sink configured with no key log runs neither. The reference dispatcher runs no module obtained +`pin::verify_with`, to `int_payload::resolve`, which runs it for a pinned +decode and over the files an unpinned integer decode reads, or to +`neural::ModelRegistry::with_key_log`, which also runs it before an +unpinned decode, over the bytes that decode then reads): the revocation +lists for every capability a pin applies to, and these checks for the +capabilities a sink holds to this section. A sink configured with no key +log runs neither. The reference dispatcher runs no module obtained apart from an envelope, and `wai-web` and the C ABI take no key log ([SPEC](../SPEC.md) §4). diff --git a/wai/int-payload-conformance/README.md b/wai/int-payload-conformance/README.md index 299f89cb9..0758719f7 100644 --- a/wai/int-payload-conformance/README.md +++ b/wai/int-payload-conformance/README.md @@ -22,9 +22,15 @@ extension's closed list (§8). `int_splat/splat.wis1`. Their sidecars are the ones the JSON forms' decodes are checked against: a payload and its JSON source decode to the same bytes. - `refusals/*.bin` — each a committed payload changed in one place (a byte, a - field, a truncation), so that it is refused with one code. The fixtures for - `bad_magic` are the JSON sources themselves: a tool's JSON output is a converter - input, never a payload. + field, a truncation, a word appended to a stream), so that it is refused with one + code, or, for `wiv1_end_state` and `wiv1_one_frame_end_state`, a small clip written + to end its stream in a state other than `2^31`. The fixtures named `*_order_*` + break two rules at once and pin which one a sink reports (the extension's §2 and + §7). The fixtures for `bad_magic` are the JSON sources themselves: a tool's JSON + output is a converter input, never a payload. +- `sets/` — a parameter-set member a fixture decodes against in place of a committed + one: `entropy_median_max.json` is the tiny conditional codec's tables with one + median at `i64::MAX`. Files are compared as bytes; `.gitattributes` turns off line-ending conversion. @@ -45,15 +51,18 @@ compared: the fixtures again in both build profiles, refuses every strict prefix of every payload, and runs a sweep of single-byte changes in which nothing may panic; `tests/int_payload_preflight.rs` checks that a header past the caps or past what the - parameter set can fit is refused before anything sized from it is allocated. + parameter set can fit is refused before anything sized from it is allocated, and + that a payload whose streams cannot carry what its header declares is refused at + its first exhausted symbol, having allocated only what it decoded. - `int_payload_verify.py`, Python standard library only and sharing no code with wai-rs, written from the extension: it parses every payload with the extension's rules and check order, converts every JSON source independently and requires the committed bytes, decodes the `WIV1` payloads with keyframe 0 in full (the rANS - decoder with its bypass escape, whole-pel and half-pel motion compensation) to - their sidecars' SHA-256, and checks every fixture: a `parse` fixture's code, and - that a `decode` fixture parses (a `WIV1` keyframe-0 one is decoded and must run out - of its stream). + decoder with its bypass escape, its stop at the first exhausted symbol and its + canonical end, whole-pel and half-pel motion compensation) to their sidecars' + SHA-256, and checks every fixture: a `parse` fixture's code, and that a `decode` + fixture parses (a `WIV1` keyframe-0 one is decoded and must be refused with its + code). python3 int-payload-conformance/int_payload_verify.py int-payload-conformance diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index 096594fa9..fa9d1bef9 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -21,7 +21,11 @@ //! decode reads (SPEC §4, *Module authorisation*). //! //! Every refusal is a code from the closed list of the extension's §8 -//! ([`Refusal`]); a refusal is final, and nothing is decoded in its place. +//! ([`Refusal`]). A refusal of the payload or of the parameter set against it is final: +//! nothing is decoded in its place, and SPEC §4's fallback, which would decode the same +//! bytes, is not tried. `set_incomplete`, `not_dispatchable` and +//! `unsupported_capability` say the sink cannot decode through the capability, and +//! dispatch moves on as SPEC §4 says (the extension's §8). pub use crate::int_wire as wire; diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 1cee03223..8dd1fe1e8 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -66,8 +66,12 @@ fn assert_frames_sidecar(frames: &[Vec], key: &str) { } /// §6.1 test 17, `WIV1`: the converter writes the committed payload; the payload's -/// fields are the JSON's; the payload decodes to the frames the JSON decodes to, -/// under every kernel policy, and to the sidecar's SHA-256 and the committed FNV-1a. +/// fields are the JSON's; the payload decodes, under every kernel policy, to the +/// frames the JSON form decodes to through the same typed decoder, and those frames +/// are anchored to output recorded before this revision: the sidecar's SHA-256 and the +/// FNV-1a hashes the encode tools recorded (`int_video.json`, `video_real.json`, +/// `video_nk.json`). The keyframe-1 clip is also checked against the unchecked +/// conditional decode of its keyframe. #[cfg(feature = "video_int")] #[test] fn wiv1_json_equivalence() { @@ -87,7 +91,7 @@ fn wiv1_json_equivalence() { Ok(IntDecoded::Video { frames, .. }) => frames, other => panic!("{name}: {other:?}"), }); - assert_eq!(frames, decode(&js).unwrap().frames, "{name}: the binary and JSON decodes agree"); + assert_eq!(frames, decode(&js).unwrap().frames, "{name}: the binary and JSON forms decode alike"); assert_frames_sidecar(&frames, name); let want = jload(conf); for (f, w) in frames.iter().zip(want["frame_hashes"].as_array().unwrap()) { @@ -114,8 +118,14 @@ fn wiv1_json_equivalence() { let (f0, h, w) = decode_conditional(&g_s, &h_s, &ent, &CondBitstream::from_value(&jload("int_hyper/tiny_bitstream.json"))).unwrap(); decode_pframes(&f0, h, w, &js).unwrap().frames }; - assert_eq!(frames, legacy, "video_nk: the binary and JSON decodes agree"); + assert_eq!(frames, legacy, "video_nk: the binary and JSON forms decode alike"); assert_frames_sidecar(&frames, "video_nk"); + let want = jload("int_video/video_nk.json"); + let want = want["frame_hashes"].as_array().unwrap(); + assert_eq!(frames.len(), want.len(), "video_nk: frame count"); + for (f, w) in frames.iter().zip(want) { + assert_eq!(format!("{:016x}", crate::int_video::fnv1a(f)), w.as_str().unwrap(), "video_nk: the recorded FNV-1a"); + } } /// §6.1 test 17, `WIH1`. @@ -139,7 +149,7 @@ fn wih1_json_equivalence() { let g_s = IntModel::from_json(&jload("int_hyper/tiny_g_s.json")); let h_s = IntModel::from_json(&jload("int_hyper/tiny_h_s.json")); let ent = CondEntropy::from_value(&jload("int_hyper/tiny_entropy.json")); - assert_eq!(rgb, decode_conditional(&g_s, &h_s, &ent, &b).unwrap().0, "the binary and JSON decodes agree"); + assert_eq!(rgb, decode_conditional(&g_s, &h_s, &ent, &b).unwrap().0, "the payload and the unchecked JSON decode agree"); assert_eq!(sha(&rgb), jload("int_hyper/sha256.json")["tiny"]["sha256"].as_str().unwrap()); assert_eq!(format!("{:016x}", fnv1a(&rgb)), jload("int_hyper/tiny.json")["rgb_hash"].as_str().unwrap()); // The checked entropy reader accepts the committed tables and gives what the @@ -148,7 +158,8 @@ fn wih1_json_equivalence() { assert_eq!((checked.edges, checked.cdfs, checked.z_median_q), (ent.edges, ent.cdfs, ent.z_median_q)); } -/// §6.1 test 17, `WIA1`. +/// §6.1 test 17, `WIA1`: the payload and its JSON form decode alike through the typed +/// decoder, to the PCM recorded before this revision. #[cfg(feature = "audio_int")] #[test] fn wia1_json_equivalence() { @@ -167,13 +178,17 @@ fn wia1_json_equivalence() { Ok(IntDecoded::Pcm { samples, sample_rate: 16_000, channels: 1 }) => samples, other => panic!("audio: {other:?}"), }); - assert_eq!(pcm, decode(&AudioModel::from_bytes(&model).unwrap(), &js).unwrap(), "the binary and JSON decodes agree"); + assert_eq!(pcm, decode(&AudioModel::from_bytes(&model).unwrap(), &js).unwrap(), "the binary and JSON forms decode alike"); + // Anchored below to output recorded before this revision: the sidecar and the + // export's FNV-1a (`audio.json`). let s16le: Vec = pcm.iter().flat_map(|s| s.to_le_bytes()).collect(); assert_eq!(sha(&s16le), jload("int_audio/sha256.json")["audio"]["sha256"].as_str().unwrap()); assert_eq!(format!("{:016x}", pcm_fnv1a(&pcm)), conf["pcm_fnv1a"].as_str().unwrap()); } -/// §6.1 test 17, `WIS1`, with the presentation ranges the JSON states. +/// §6.1 test 17, `WIS1`, with the presentation ranges the JSON states: the payload and +/// its JSON form decode alike through the typed decoder, to the attributes recorded +/// before this revision. #[cfg(feature = "splat_int")] #[test] fn wis1_json_equivalence() { @@ -196,7 +211,9 @@ fn wis1_json_equivalence() { other => panic!("splat: {other:?}"), }); assert_eq!(ranges, v.ranges); - assert_eq!(bytes, decode(&IntModel::from_bytes(&gs).unwrap(), &js).unwrap().attr, "the binary and JSON decodes agree"); + assert_eq!(bytes, decode(&IntModel::from_bytes(&gs).unwrap(), &js).unwrap().attr, "the binary and JSON forms decode alike"); + // Anchored below to output recorded before this revision: the sidecar and the + // export's FNV-1a (`attr_hash`). assert_eq!(sha(&bytes), jload("int_splat/sha256.json")["splat"]["sha256"].as_str().unwrap()); assert_eq!(format!("{:016x}", fnv1a(&bytes)), js["attr_hash"].as_str().unwrap()); } diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index 09fcaaab9..62e3ca086 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -292,6 +292,7 @@ pub fn model_sha256(path: &Path) -> Result { /// PCM and splat attributes give `Int`, in the integer form their digests are /// taken over (RGB8 frames, `i16` PCM, the attribute tensor), never converted. #[derive(Debug, Clone)] +#[non_exhaustive] pub enum Decoded { Audio(DecodedAudio), Image(DecodedImage), @@ -347,6 +348,14 @@ fn is_set_incomplete(e: &DecodeError) -> bool { matches!(e, DecodeError::PriorMismatch(PinRefusal::SetIncomplete { .. })) } +/// Whether a decode through `cap` failed because this build has no decoder for it +/// (`unsupported_capability`): the sink cannot decode `cap` at all, so SPEC §4 does not +/// take step 3 (or 5) with it, and dispatch moves on as for a capability the sink does +/// not advertise. +fn is_unsupported(e: &DecodeError, cap: &str) -> bool { + matches!(e, DecodeError::UnknownCapability(c) if c == cap) +} + /// Decode `payload` through `capability` against `params` — the per-capability /// dispatch, shared by the `WAI1` and `WAI2` paths. pub fn decode_payload(capability: &str, payload: &[u8], params: &ParamSource) -> Result { @@ -456,10 +465,12 @@ fn int_decode(cap: &str, payload: &[u8], params: &ParamSource) -> Result { + Err(e) if is_set_incomplete(&e) || is_unsupported(&e, d.capability) => { excluded.push(d.capability.to_string()); if incomplete.is_none() { incomplete = Some((d.capability.to_string(), e)); @@ -634,6 +645,9 @@ fn decode_multi_where( let DecodeError::PriorMismatch(r) = &e else { unreachable!() }; incomplete.push((p.index, format!("{}: {r}", r.code()))); } + Err(e) if is_unsupported(&e, &p.meta.capability) => { + incomplete.push((p.index, format!("unsupported_capability: {e}"))); + } Err(e) => return Err(e), Ok(d) => return Ok((p.index, d)), } diff --git a/wai/wai-rs/tests/int_payload_preflight.rs b/wai/wai-rs/tests/int_payload_preflight.rs index 274cffd98..8986bd3af 100644 --- a/wai/wai-rs/tests/int_payload_preflight.rs +++ b/wai/wai-rs/tests/int_payload_preflight.rs @@ -97,8 +97,9 @@ fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() ("a 2^20-frame clip", c::CAP_VIDEO_INT_MOTION, with(&iv, &[(8, 1 << 20)]), &none, "too_large", 0), // An 8192 × 8192 frame. ("an 8192 × 8192 frame", c::CAP_VIDEO_INT_HYPER, with(&nk, &[(12, 8192), (16, 8192)]), &hyp, "too_large", hyp_bytes), - // A hyper-latent of 2 × 4000 × 4000, past the cap. - ("a 32-million-element hyper-latent", c::CAP_NEURAL_INT_HYPER, with(&ih, &[(24, 4000), (28, 4000)]), &hyp, "too_large", hyp_bytes), + // A hyper-latent of 2 × 6000 × 6000 = 72 million elements, past the parser's + // 2^26 cap. + ("a 72-million-element hyper-latent", c::CAP_NEURAL_INT_HYPER, with(&ih, &[(24, 6000), (28, 6000)]), &hyp, "too_large", hyp_bytes), // A hyper-latent of 2 × 2000 × 2000, within the cap, that h_s would expand to // 8 × 4000 × 4000, past it. ("a hyper-latent h_s expands past the cap", c::CAP_NEURAL_INT_HYPER, with(&ih, &[(24, 2000), (28, 2000)]), &hyp, "too_large", hyp_bytes), @@ -109,6 +110,8 @@ fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() // 14 × 4096 × 4096, past it. ("a splat latent g_s expands past the cap", c::CAP_SPLAT_INT_CODEC, with(&sp, &[(12, 1024), (16, 1024)]), &spl, "too_large", gs.len()), ]; + let parsed = wai::int_payload::wire::parse_wih1(&with(&ih, &[(24, 6000), (28, 6000)])).map(|_| ()).map_err(|e| e.code()); + assert_eq!(parsed, Err("too_large"), "the parser's cap refuses the 72-million-element hyper-latent"); for (what, cap, bad, params, code, set_bytes) in cases { let (r, largest) = largest_in(|| decode_int(cap, &bad, params)); let bound = FEW_KB.max(4 * set_bytes); diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index 33f5ddf95..1760cb013 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -1292,3 +1292,29 @@ fn integer_media_decode_through_the_neural_dispatch() { } } } + +/// An integer capability this build has no decoder for (here `wai.video.int_motion`, +/// without `video_int`) is refused with `unsupported_capability`, which is not final: +/// SPEC §4 does not take step 3 with a capability the sink cannot decode, so dispatch +/// moves on to the fallback, which decodes the payload. Without a fallback the +/// capability is reported as unknown. +#[cfg(not(feature = "video_int"))] +#[test] +fn an_integer_capability_without_a_decoder_moves_on_to_the_fallback() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../neural-conformance/vectors/gradient"); + let latent = std::fs::read(src.join("latent.bin")).unwrap(); + let (mot, synth) = ("wai.video.int_motion", "wai.neural.int_synth"); + let reg = ModelRegistry::new().builtin(mot).register(synth, src.join("model")); + let env = |fallback: Option<&str>| { + let mut req = ModelRequirement::new(mot); + if let Some(f) = fallback { + req = req.with_fallback(f); + } + Wai::new(Manifest::new("image", "improve", req, Conditioning::new("int_synth")), latent.clone()) + }; + match decode_envelope(&env(Some(synth)), ®) { + Ok(Decoded::Image(img)) => assert_eq!((img.width, img.height), (38, 38)), + other => panic!("the fallback must decode, got {}", refusal(other)), + } + assert!(matches!(decode_envelope(&env(None), ®), Err(DecodeError::UnknownCapability(ref c)) if c == mot)); +} diff --git a/wai/wai-web/demo/int_hyper.html b/wai/wai-web/demo/int_hyper.html index 8350ca207..fcbf75333 100644 --- a/wai/wai-web/demo/int_hyper.html +++ b/wai/wai-web/demo/int_hyper.html @@ -24,8 +24,10 @@

WAI integer-exact conditional codec — in-browser, no ML runtime

- A wai.neural.int_hyper bitstream arrived: a factorized side-stream z and the - Gaussian-conditional main stream y, both rANS-coded. This tab decodes it to RGB with the + This tab decodes the export tool's debug form of a wai.neural.int_hyper bitstream + (cond_bitstream.json; the payload an envelope carries is WIH1, which + decodeWih1 reads): a factorized side-stream z and the + Gaussian-conditional main stream y, both rANS-coded. It decodes them to RGB with the same pure-i64 Rust that decodes natively, compiled to wasm32 — no onnxruntime-web, no WebGPU, no float anywhere. The whole path is integer: factorized z → integer hyper-decoder → integer-σ entropy bucketing → integer synthesis. Because of that (the integer-entropy -- 2.54.0 (Apple Git-157) From 865c75c20acf5989e2d1ebc180b19bd5b8c92626 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 20:42:47 -0400 Subject: [PATCH 20/34] wai: a listing beside an unpinned set counts only when it matches the files read The neural sink's unpinned check 5 hashed every *.sha256 beside the registered path without reading it, so a copy of a granted listing beside other files authorised them. Each listing is now read as a pinned set's would be: for an integer capability through int_payload::unpinned_read over the members the decode reads (and any other file it names, from beside it), counting only when it names every member read with its digest; for another capability, every file it names must match on disk and it must name the registered file. A listing that does not match is refused (set_malformed, part_mismatch) and contributes no digest. Test: neural_native_decode::a_listing_that_does_not_match_the_files_read_authorises_nothing (added to the neural gates step): a granted listing beside other files is not authorised, for a several-file and a one-file integer set and for a capability outside the integer path; the granted files beside it decode. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-neural-gates.yml | 3 +- wai/wai-rs/src/neural/mod.rs | 73 +++++++++++++++---- wai/wai-rs/tests/neural_native_decode.rs | 93 ++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 16 deletions(-) diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index c360f3109..3b58394df 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -139,8 +139,9 @@ jobs: decode_multi_strict_reports_replicate_reasons strict_gate_ignores_a_held_capability_it_cannot_dispatch \ int_hyper_decodes_through_the_neural_dispatch int_synth_check_5_covers_the_spec_a_model_directory_holds \ an_integer_capability_without_a_decoder_moves_on_to_the_fallback \ + a_listing_that_does_not_match_the_files_read_authorises_nothing \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 15 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 16 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" # The integer video, audio and splat capabilities through the neural dispatch, which # delegates them to int_payload::decode_int: each decodes to Decoded::Int with its # committed SHA-256, and wai.video.int_motion is advertised with ModelRegistry::builtin. diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index 62e3ca086..d96ade2d3 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -139,31 +139,26 @@ impl ModelRegistry { /// its decode reads are read here, once, from the paths [`int_member_path`] gives, /// and returned as [`ParamSource::UnpinnedRead`]: check 5 runs over exactly those /// bytes (`int_payload::unpinned_read`: a one-file set's file, and the listing of - /// the members read) and over each listing beside them, and the decode reads - /// nothing else. For any other capability the digests are read when the decode - /// starts: its file's SHA-256 where the capability's set is one file, and the - /// SHA-256 of each listing the registry finds for it in the form `wai.prior.set`; - /// the decode then reads the path, as it always does. `capability` is registered. + /// the members read), and the decode reads nothing else. For any other capability + /// the digests are read when the decode starts: its file's SHA-256 where the + /// capability's set is one file; the decode then reads the path, as it always does. + /// Either way a listing beside the path (the form `wai.prior.set`) adds its digest + /// only when it identifies the files the decode reads ([`listing_digests`]): a + /// listing that does not match them is refused and contributes no digest, so a + /// granted listing beside other files authorises nothing. `capability` is + /// registered. fn unpinned(&self, capability: &str) -> Result { let Some(path) = self.paths.get(capability) else { // A built-in capability reads no parameter set: there is nothing to check. return Ok(ParamSource::Unpinned(PathBuf::new())); }; let key_log = self.key_log.as_deref().filter(|_| crate::container::pin_applies_to(capability)); - let listings = |digests: &mut Vec| { - for h in artifacts_at(capability, [path], true) { - let (crate::pin::Held::File(f) | crate::pin::Held::Listing(f)) = h; - if let Ok(bytes) = std::fs::read(f) { - digests.push(crate::pin::sha256_hex(&bytes)); - } - } - }; if !crate::int_payload::set_members(capability).is_empty() { let files = read_int_members(capability, path); if let Some(k) = key_log { let view: std::collections::BTreeMap = files.iter().map(|(n, b)| (n.clone(), b.as_slice())).collect(); let (mut digests, _) = crate::int_payload::unpinned_read(capability, &crate::pin::HeldBytes::Set { listing: b"", files: &view })?; - listings(&mut digests); + digests.extend(listing_digests(capability, path, Some(&view))); k.check_unpinned(capability, &digests)?; } return Ok(ParamSource::UnpinnedRead(files)); @@ -179,13 +174,61 @@ impl ModelRegistry { } } } - listings(&mut digests); + digests.extend(listing_digests(capability, path, None)); k.check_unpinned(capability, &digests)?; } Ok(ParamSource::Unpinned(path.clone())) } } +/// The digests of the listings beside `registered` (each `*.sha256` in its directory, +/// the form `wai.prior.set`) that identify the files an unpinned decode reads, for +/// check 5. A listing is read as a pinned set's would be (SPEC §3.1 checks 7 and 8): +/// malformed (`set_malformed`), or naming a file whose bytes are not its digest +/// (`part_mismatch`), it is refused and contributes no digest. +/// +/// - An integer capability's members, `read`, are the bytes the decode reads, by member +/// name; a listed file that is not a member is read from beside the listing. The +/// listing goes through `int_payload::unpinned_read` and counts only when it names, +/// with their digests, every member the decode reads. +/// - For another capability, every file the listing names is read from beside it, and +/// it counts only when each matches and, where `registered` is a file, it names that +/// file. +fn listing_digests(capability: &str, registered: &Path, read: Option<&std::collections::BTreeMap>) -> Vec { + use crate::pin::{parse_listing, sha256, sha256_hex, Held, HeldBytes}; + let mut out = Vec::new(); + for h in artifacts_at(capability, [®istered.to_path_buf()], true) { + let Held::Listing(f) = h else { continue }; + let Ok(listing) = std::fs::read(&f) else { continue }; + let Ok(lines) = parse_listing(&listing) else { continue }; + let root = f.parent().map(Path::to_path_buf).unwrap_or_default(); + let others: std::collections::BTreeMap> = lines.iter() + .filter(|(p, _)| read.is_none_or(|r| !r.contains_key(p))) + .filter_map(|(p, _)| std::fs::read(root.join(p)).ok().map(|b| (p.clone(), b))) + .collect(); + let mut view: std::collections::BTreeMap = others.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + match read { + Some(members) => { + view.extend(members.iter().map(|(k, v)| (k.clone(), *v))); + if let Ok((digests, Some(paths))) = crate::int_payload::unpinned_read(capability, &HeldBytes::Set { listing: &listing, files: &view }) { + if paths.len() == members.len() { + out.extend(digests); + } + } + } + None => { + let matches = lines.iter().all(|(p, d)| view.get(p).is_some_and(|b| sha256(b) == *d)); + let names_it = !registered.is_file() + || registered.file_name().and_then(|n| n.to_str()).is_some_and(|n| lines.iter().any(|(p, _)| p == n)); + if matches && names_it { + out.push(sha256_hex(&listing)); + } + } + } + } + out +} + /// The file an integer capability's decode reads for its set member `member`, at the /// registered path `registered`: inside it when it is a directory; the registered file /// itself for a one-file set (except `wai.neural.int_synth`, whose registered path diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index 1760cb013..fe835ce84 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -1318,3 +1318,96 @@ fn an_integer_capability_without_a_decoder_moves_on_to_the_fallback() { } assert!(matches!(decode_envelope(&env(None), ®), Err(DecodeError::UnknownCapability(ref c)) if c == mot)); } + +/// A key log stand-in that holds the capability's sets to §7.9 and grants only the +/// digests it holds: an unpinned set is authorised only under one of them. +#[derive(Debug)] +struct Grants(Vec); + +impl wai::pin::PinAuthority for Grants { + fn check(&self, cap: &str, prior: &Prior) -> Result<(), wai::container::PinRefusal> { + self.check_unpinned(cap, std::slice::from_ref(&prior.sha256)) + } + fn check_unpinned(&self, cap: &str, digests: &[String]) -> Result<(), wai::container::PinRefusal> { + if digests.iter().any(|d| self.0.contains(d)) { + return Ok(()); + } + Err(wai::container::PinRefusal::NotAuthorised { capability: cap.into(), check: None, reason: None }) + } +} + +/// A listing beside the registered path identifies a set only when it matches the +/// files the decode reads. A key log that grants only the publisher's listing refuses +/// an unpinned decode of other files (`prior_not_authorised`), with a copy of that +/// listing beside them or not; the publisher's own files beside the same listing +/// decode. The same holds for a one-file capability registered as its file, and for a +/// capability outside the integer path (resolved, not decoded: no model is needed). +#[test] +fn a_listing_that_does_not_match_the_files_read_authorises_nothing() { + use wai::codecs::{CAP_AUDIO_INT_CODEC as AUD, CAP_NEURAL_INT_HYPER as HYP}; + let code = |r: Result| match r { + Ok(_) => "decoded".to_string(), + Err(e) => refusal(Err(e)), + }; + let v = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors"); + let root = std::env::temp_dir().join(format!("wai-stale-listing-{}", std::process::id())); + let set: std::collections::BTreeMap> = [("entropy.json", "int_hyper/tiny_entropy.json"), ("g_s.whm", "int_hyper/tiny_g_s.bin"), ("h_s.whm", "int_hyper/tiny_h_s.bin")] + .into_iter().map(|(k, f)| (k.to_string(), std::fs::read(v.join(f)).unwrap())).collect(); + let view: std::collections::BTreeMap = set.iter().map(|(k, b)| (k.clone(), b.as_slice())).collect(); + let granted = wai::pin::listing_from(&view).unwrap(); + let write = |dir: &Path, files: &std::collections::BTreeMap>, listing: bool| { + std::fs::create_dir_all(dir).unwrap(); + for (k, b) in files { + std::fs::write(dir.join(k), b).unwrap(); + } + if listing { + std::fs::write(dir.join("publisher.sha256"), &granted).unwrap(); + } + }; + let mut other = set.clone(); + other.get_mut("entropy.json").unwrap().push(b' '); + let env = Wai::new(Manifest::new("image", "improve", ModelRequirement::new(HYP), Conditioning::new("bytes")), + std::fs::read(v.join("int_hyper/tiny_bitstream.wih1")).unwrap()); + let reg = |dir: &Path| ModelRegistry::new().register(HYP, dir.to_path_buf()).with_key_log(Grants(vec![wai::pin::sha256_hex(&granted)])); + for (name, files, listing, want) in [ + ("other files", &other, false, "prior_not_authorised"), + ("other files beside the granted listing", &other, true, "prior_not_authorised"), + ("the granted files beside their listing", &set, true, "decoded"), + ] { + let dir = root.join(name.replace(' ', "-")); + write(&dir, files, listing); + assert_eq!(code(decode_envelope(&env, ®(&dir))), want, "{name}"); + } + + // One file: the publisher's model.wam is granted under its listing; the sink's file + // differs in its last byte. + let model = std::fs::read(v.join("int_audio/audio_model.bin")).unwrap(); + let one: std::collections::BTreeMap = [("model.wam".to_string(), model.as_slice())].into_iter().collect(); + let pub_listing = wai::pin::listing_from(&one).unwrap(); + let mut tampered = model.clone(); + *tampered.last_mut().unwrap() ^= 1; + let dir = root.join("audio"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("model.wam"), &tampered).unwrap(); + std::fs::write(dir.join("publisher.sha256"), &pub_listing).unwrap(); + let envw = Wai::new(Manifest::new("audio", "improve", ModelRequirement::new(AUD), Conditioning::new("bytes")), + std::fs::read(v.join("int_audio/audio_bitstream.wia1")).unwrap()); + let reg = ModelRegistry::new().register(AUD, dir.join("model.wam")).with_key_log(Grants(vec![wai::pin::sha256_hex(&pub_listing)])); + assert_eq!(code(decode_envelope(&envw, ®)), "prior_not_authorised", "a one-file set beside a stale listing"); + + // A capability outside the integer path: the listing must match the files beside it + // and name the registered file. + let cap = "wai.neural.bmshj2018"; + let dir = root.join("onnx"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("decoder.onnx"), b"publisher").unwrap(); + let onnx: std::collections::BTreeMap = [("decoder.onnx".to_string(), &b"publisher"[..])].into_iter().collect(); + let l = wai::pin::listing_from(&onnx).unwrap(); + std::fs::write(dir.join("publisher.sha256"), &l).unwrap(); + let reg = || ModelRegistry::new().register(cap, dir.join("decoder.onnx")).with_key_log(Grants(vec![wai::pin::sha256_hex(&l)])); + assert!(reg().resolve_verified(cap, None).is_ok(), "the granted file beside its listing resolves"); + std::fs::write(dir.join("decoder.onnx"), b"other").unwrap(); + assert!(matches!(reg().resolve_verified(cap, None), Err(DecodeError::PriorMismatch(ref r)) if r.code() == "prior_not_authorised"), + "another file beside the granted listing is not authorised"); + std::fs::remove_dir_all(&root).ok(); +} -- 2.54.0 (Apple Git-157) From 8cc9e369e2d2088559094be949a81e7c898a26fc Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 20:47:09 -0400 Subject: [PATCH 21/34] wai: check 5 covers every identity of the bytes a pinned decode reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check 5 ran over the pin's digest only, so a revocation of model.wam's file digest was bypassed by a set-form pin over a listing of that one file, and a revocation of a set's listing by a pin over its members plus a README. After a pin verifies, int_payload::resolve and the neural registry's verify run check 5 again over every identity of the bytes the decode reads (int_payload::read_identities: the listing of the members read and a one-file set's file digest) together with the pin's digest, which keeps a granted pin authorised. SPEC §3.1 check 5 says so. Tests: int_payload::tests::a_pin_by_another_identity_does_not_reach_a_revoked_set (both probes) and neural_native_decode::a_pinned_set_revoked_under_its_file_digest_is_refused_through_a_listing_pin (added to the neural gates step). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-neural-gates.yml | 3 +- wai/CHANGELOG.md | 5 +++ wai/SPEC.md | 8 ++++ wai/wai-rs/src/int_payload/mod.rs | 53 +++++++++++++++++++----- wai/wai-rs/src/int_payload/tests.rs | 34 +++++++++++++++ wai/wai-rs/src/neural/mod.rs | 30 +++++++++++++- wai/wai-rs/tests/neural_native_decode.rs | 22 ++++++++++ 7 files changed, 142 insertions(+), 13 deletions(-) diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index 3b58394df..25f1cf818 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -140,8 +140,9 @@ jobs: int_hyper_decodes_through_the_neural_dispatch int_synth_check_5_covers_the_spec_a_model_directory_holds \ an_integer_capability_without_a_decoder_moves_on_to_the_fallback \ a_listing_that_does_not_match_the_files_read_authorises_nothing \ + a_pinned_set_revoked_under_its_file_digest_is_refused_through_a_listing_pin \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 16 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 17 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" # The integer video, audio and splat capabilities through the neural dispatch, which # delegates them to int_payload::decode_int: each decodes to Decoded::Int with its # committed SHA-256, and wai.video.int_motion is advertised with ModelRegistry::builtin. diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 3e68b68d5..637d8c3b1 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -53,6 +53,11 @@ wire format may change and such changes are recorded here. `wai.video.int_motion`, which reads no set; the C ABI refuses an `Int` output. A capability this build has no integer decoder for (`unsupported_capability`) moves dispatch on to the fallback, as SPEC §4 treats a capability a sink cannot decode. + Check 5 covers every identity of the bytes a decode reads (SPEC §3.1): after a pin + verifies, it runs again over the listing of the members read and a one-file set's + file digest (`int_payload::read_identities`), so a set revoked under one identity + is not reached through a pin naming it by another; and a listing beside an + unpinned set counts only when it matches the files read. *Breaking:* `neural::Decoded` gains the variant `Int` and is now `#[non_exhaustive]`, so a `match` on it outside this crate needs a wildcard arm; `neural::ParamSource` gains `UnpinnedRead` (an unpinned integer set read into memory once) and is diff --git a/wai/SPEC.md b/wai/SPEC.md index 4b8a0fe63..171495319 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -249,6 +249,14 @@ order, and the first that fails is the reason reported: is offline or has no clock it trusts, a grant outside its window or for another capability — or the sink holds no grant for the set. The sink reports §7.9's check and reason with it. A sink that holds no key log skips this check. + Check 5 covers every identity of the bytes the decode reads, not only the + pin's: once checks 6 to 8 have found the set, the sink runs this check again + over each digest a pin over those bytes could name — the listing of the files + the decode reads, and, for a capability registered with a one-file set, that + file's SHA-256 — so a set refused under one identity is not reached through a + pin that names it by another (a set-form pin over a listing of one revoked file, + or a listing that adds a file the decode does not read). Authorisation needs + one of them to be held; the pin's own digest, which passed above, is one. 6. `prior_not_held`: the sink holds no parameter set for the capability whose digest, in the pin's form, equals `sha256`; a file the sink cannot read is not held. A sink that holds the set a well-formed `supersedes` names — a file when diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index fa9d1bef9..9d2d33b83 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -170,6 +170,12 @@ impl<'a> IntParams<'a> { matches!(self.0, Params::Verified(_) | Params::Held { pinned: true, .. }) } + /// The members of `cap`'s set these parameters hold, by name: the bytes a decode + /// reads. + pub fn read(&self, cap: &str) -> std::collections::BTreeMap { + set_members(cap).iter().filter_map(|m| self.member(cap, m).ok().map(|b| (m.to_string(), b))).collect() + } + /// Whether these parameters hold no set ([`IntParams::none`], or [`resolve`] for a /// capability that decodes against none). pub fn is_none(&self) -> bool { @@ -209,7 +215,7 @@ impl<'a> IntParams<'a> { /// A set whose listing is malformed or does not match its files has no one identity, /// so it is refused before check 5 runs. pub(crate) fn unpinned_read(cap: &str, set: &HeldBytes<'_>) -> Result<(Vec, Option>), PinRefusal> { - use crate::pin::{listing_from, parse_listing, sha256, sha256_hex}; + use crate::pin::{parse_listing, sha256, sha256_hex}; let one_file = crate::container::pin_shape(cap) == crate::codecs::ParameterSetShape::OneFile; let members = set_members(cap); let mut digests = Vec::new(); @@ -244,19 +250,32 @@ pub(crate) fn unpinned_read(cap: &str, set: &HeldBytes<'_>) -> Result<(Vec) -> Vec { + use crate::pin::{listing_from, sha256_hex}; + let mut out = Vec::new(); + if crate::container::pin_shape(cap) == crate::codecs::ParameterSetShape::OneFile { + if let Some(b) = set_members(cap).first().and_then(|m| read.get(*m)) { + out.push(sha256_hex(b)); + } + } + if let Ok(l) = listing_from(read) { + out.push(sha256_hex(&l)); + } + out +} + /// The parameters `cap`'s decode reads, at a sink that holds the sets `held` in memory /// and, where it has one, the key log `authority` (SPEC §3.1, §4). The one way to build /// [`IntParams`] that hold a set. @@ -264,7 +283,9 @@ pub(crate) fn unpinned_read(cap: &str, set: &HeldBytes<'_>) -> Result<(Vec( match prior { Some(p) if crate::container::pin_applies_to(cap) => { let r = crate::pin::verify_bytes_with(cap, p, held, crate::pin::READABLE_FORMS, authority)?; - IntParams::from_verified_ref(cap, held, &r) + let params = IntParams::from_verified_ref(cap, held, &r)?; + // Check 5 ran over the pin's digest; it runs again over every identity of the + // bytes the decode will read, so a set revoked (or not granted) under its + // file's digest or under the listing of its members is not reached through + // a pin that names it another way. + if let Some(a) = authority { + let mut ids = read_identities(cap, ¶ms.read(cap)); + ids.push(p.sha256.clone()); + a.check_unpinned(cap, &ids)?; + } + Ok(params) } _ => { let set = *held.first().ok_or_else(|| PinRefusal::NotHeld { capability: cap.to_string() })?; diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 8dd1fe1e8..af06efc26 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -677,3 +677,37 @@ fn a_model_file_with_bytes_after_its_last_layer_is_refused() { assert_eq!(crate::int_audio::AudioModel::from_bytes(&wam).err(), Some("bytes follow the last layer")); } } + +/// Check 5 covers every identity of the bytes a pinned decode reads, not only the pin's +/// digest: a one-file set revoked under its file's digest is refused through a set-form +/// pin over a listing of that file, and a set revoked under the listing of its members +/// is refused through a pin over a listing that adds a file the decode does not read. +#[test] +fn a_pin_by_another_identity_does_not_reach_a_revoked_set() { + use crate::codecs::{CAP_AUDIO_INT_CODEC as AUD, CAP_NEURAL_INT_HYPER as HYP, CAP_PRIOR_SET}; + use crate::pin::{listing_from, sha256_hex}; + let code = |r: Result, PinRefusal>| r.map(|p| if p.is_pinned() { "pinned" } else { "unpinned" }).unwrap_or_else(|e| e.code()); + let model = read("int_audio/audio_model.bin"); + let one: BTreeMap = [("model.wam".to_string(), model.as_slice())].into_iter().collect(); + let listing = listing_from(&one).unwrap(); + let revoke_file = Authority { revoked: vec![sha256_hex(&model)], granted: vec![], held_to: false }; + let set_pin = Prior::new("m", sha256_hex(&listing), "decode-equivalence").with_form(CAP_PRIOR_SET); + let held = [HeldBytes::Set { listing: &listing, files: &one }]; + assert_eq!(code(resolve(AUD, Some(&set_pin), &held, None)), "pinned"); + assert_eq!(code(resolve(AUD, Some(&set_pin), &held, Some(&revoke_file))), "prior_revoked", "a set-form pin over the revoked file"); + + let set = int_hyper_set(); + let three = listing_from(&view(&set)).unwrap(); + let mut extra = set.clone(); + extra.insert("README".into(), b"not a member".to_vec()); + let extra_v = view(&extra); + let four = listing_from(&extra_v).unwrap(); + let revoke_three = Authority { revoked: vec![sha256_hex(&three)], granted: vec![], held_to: false }; + let pin4 = Prior::new("t", sha256_hex(&four), "decode-equivalence").with_form(CAP_PRIOR_SET); + let held4 = [HeldBytes::Set { listing: &four, files: &extra_v }]; + assert_eq!(code(resolve(HYP, Some(&pin4), &held4, None)), "pinned"); + assert_eq!(code(resolve(HYP, Some(&pin4), &held4, Some(&revoke_three))), "prior_revoked", "a pin over the members and a README"); + // A key log that grants the pin's digest still authorises it. + let granting = Authority { revoked: vec![], granted: vec![sha256_hex(&four)], held_to: true }; + assert_eq!(code(resolve(HYP, Some(&pin4), &held4, Some(&granting))), "pinned"); +} diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index d96ade2d3..70d391746 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -229,6 +229,25 @@ fn listing_digests(capability: &str, registered: &Path, read: Option<&std::colle out } +/// Every identity of the bytes a pin verified, besides the pin's own digest: for an +/// integer capability, those of the members its decode reads +/// (`int_payload::read_identities`); for another, the one file's digest, or the +/// listing of every file the verified set holds. +fn verified_identities(capability: &str, v: &crate::pin::Verified) -> Vec { + use crate::pin::{listing_from, sha256_hex, Verified}; + if !crate::int_payload::set_members(capability).is_empty() { + let read = crate::int_payload::IntParams::verified(v).read(capability); + return crate::int_payload::read_identities(capability, &read); + } + match v { + Verified::File(b) => vec![sha256_hex(b)], + Verified::Set { files, .. } => { + let view: std::collections::BTreeMap = files.iter().map(|(k, b)| (k.clone(), b.as_slice())).collect(); + listing_from(&view).map(|l| vec![sha256_hex(&l)]).unwrap_or_default() + } + } +} + /// The file an integer capability's decode reads for its set member `member`, at the /// registered path `registered`: inside it when it is a directory; the registered file /// itself for a one-file set (except `wai.neural.int_synth`, whose registered path @@ -286,7 +305,16 @@ impl ModelRegistry { pub fn verify(&self, capability: &str, prior: &Prior) -> Result { let set_form = prior.form() == Some(crate::codecs::CAP_PRIOR_SET); let key_log = self.key_log.as_deref().map(|k| k as &dyn crate::pin::PinAuthority); - crate::pin::verify_with(capability, prior, &self.held_artifacts(capability, set_form), READABLE_PRIOR_FORMS, key_log) + let v = crate::pin::verify_with(capability, prior, &self.held_artifacts(capability, set_form), READABLE_PRIOR_FORMS, key_log)?; + // Check 5 ran over the pin's digest; it runs again over every identity of the + // verified bytes (SPEC §3.1 check 5), so a set revoked under another identity + // is not reached through this pin. + if let Some(k) = key_log { + let mut ids = verified_identities(capability, &v); + ids.push(prior.sha256.clone()); + k.check_unpinned(capability, &ids)?; + } + Ok(v) } /// The parameters a decode through `capability` reads: verified against `prior` diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index fe835ce84..6d61856c4 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -1411,3 +1411,25 @@ fn a_listing_that_does_not_match_the_files_read_authorises_nothing() { "another file beside the granted listing is not authorised"); std::fs::remove_dir_all(&root).ok(); } + +/// The neural registry's pinned path runs check 5 over every identity of the bytes it +/// verified: a one-file set whose file digest a key log revokes is refused through a +/// set-form pin over a listing of that file. +#[test] +fn a_pinned_set_revoked_under_its_file_digest_is_refused_through_a_listing_pin() { + use wai::codecs::{CAP_AUDIO_INT_CODEC as AUD, CAP_PRIOR_SET}; + let v = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors"); + let model = std::fs::read(v.join("int_audio/audio_model.bin")).unwrap(); + let dir = std::env::temp_dir().join(format!("wai-listing-pin-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("model.wam"), &model).unwrap(); + let one: std::collections::BTreeMap = [("model.wam".to_string(), model.as_slice())].into_iter().collect(); + let listing = wai::pin::listing_from(&one).unwrap(); + std::fs::write(dir.join("model.sha256"), &listing).unwrap(); + let pin = Prior::new("m", wai::pin::sha256_hex(&listing), "decode-equivalence").with_form(CAP_PRIOR_SET); + let plain = ModelRegistry::new().register(AUD, dir.join("model.wam")); + assert!(plain.verify(AUD, &pin).is_ok(), "the pin verifies"); + let revoking = ModelRegistry::new().register(AUD, dir.join("model.wam")).with_key_log(Revokes(vec![wai::pin::sha256_hex(&model)])); + assert_eq!(revoking.verify(AUD, &pin).map(|_| ()).map_err(|r| r.code()), Err("prior_revoked")); + std::fs::remove_dir_all(&dir).ok(); +} -- 2.54.0 (Apple Git-157) From cd37a71d20743651d13363f0b944f76b11b04da7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 21:04:22 -0400 Subject: [PATCH 22/34] wai: the header bounds an integer decode's work, and a sink reads it first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A payload carries its own tables, so a fully valid 3 KB WIV1 decodes a 2^30-byte clip, and a 384-byte WIA1 held gigabytes to present one sample. The stream's length does not bound the work; the header does, within the caps, and a sink applies its own size policy before decoding. - int_payload::declared_cost(cap, payload, params): the output bytes, the symbols, and the most bytes the reference decode holds at once beside the payload and the parsed set, from the header and the set's layer geometry, with the refusals a decode gives before its first symbol. container::declared_size reads the header alone (no feature, no set); the C ABI has wai_int_payload_declared (wai_int_declared_t) and wai-web intPayloadCost. - Each decoder's pre-decode checks are one plan function the decode and the cost share (cond_plan, audio_plan, splat_plan, int_video::check); networks are planned with their live buffer sizes (plan_net). Latents and frames grow to their declared size and no further, and spent buffers are dropped, so the declared figure is what the decode holds. - WIA1: the latent must be the one t_out needs: the synthesis of lat_len gives at least t_out samples and that of lat_len - 1 fewer (geometry_mismatch). Fixture wia1_latent_too_long. - integer-payloads.md §7 states the bound: output, symbols and buffers with their bytes per element, read before decoding; a short stream stops at its first missing symbol; a sink's policy refuses past what it will spend. The claim that work grows with what a stream carries is gone, here and in the CHANGELOG. Tests: tests/int_payload_cost.rs (own binary, live-bytes allocator; CI fast-kernel steps): the declared working bytes bound what each committed decode holds and equal it for keyframe-0 WIV1, including, in release, the 3 KB clip at the cap (declared and held both 1073445624 bytes); tests/int_payload_preflight.rs refuses the reviewer's 384-byte WIA1 (geometry_mismatch, before a symbol, and by declared_cost); ffi_envelope::int_payload_declared_reads_the_header; wai-web the_declared_cost_is_read_before_decoding. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 8 +- .github/workflows/wai-web-wasm.yml | 2 +- wai/CHANGELOG.md | 11 +- wai/extensions/integer-payloads.md | 49 +++-- .../refusals/wia1_latent_too_long.bin | Bin 0 -> 1444 bytes wai/int-payload-conformance/vectors.json | 10 + wai/wai-rs/Cargo.toml | 5 + wai/wai-rs/cbindgen.toml | 1 + wai/wai-rs/include/wai.h | 31 +++ wai/wai-rs/src/bin/wai_int_payload_vectors.rs | 2 + wai/wai-rs/src/container.rs | 47 +++++ wai/wai-rs/src/ffi.rs | 54 ++++++ wai/wai-rs/src/int_audio.rs | 107 +++++++---- wai/wai-rs/src/int_hyper_synth.rs | 179 +++++++++++++----- wai/wai-rs/src/int_payload/mod.rs | 140 ++++++++++++++ wai/wai-rs/src/int_payload/tests.rs | 3 +- wai/wai-rs/src/int_splat.rs | 80 ++++---- wai/wai-rs/src/int_video.rs | 24 ++- wai/wai-rs/tests/ffi_envelope.rs | 28 +++ wai/wai-rs/tests/int_payload_cost.rs | 170 +++++++++++++++++ wai/wai-rs/tests/int_payload_preflight.rs | 53 ++++++ wai/wai-web/src/int_payload.rs | 81 +++++++- 22 files changed, 938 insertions(+), 147 deletions(-) create mode 100644 wai/int-payload-conformance/refusals/wia1_latent_too_long.bin create mode 100644 wai/wai-rs/tests/int_payload_cost.rs diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 3fc34b1ba..4dbad796a 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -166,7 +166,9 @@ jobs: # with its code, every strict prefix of every payload, and runs a byte-change sweep in # which nothing may panic; tests/int_payload_preflight.rs (its own binary, a counting # allocator) checks that a header past the caps is refused before anything sized from it - # is allocated. Cargo.toml declares them, and the other + # is allocated; tests/int_payload_cost.rs (its own binary, a live-bytes allocator) checks + # that int_payload::declared_cost bounds what each decode holds, and, in the release + # step only, decodes a 2^30-byte clip a 3 KB payload codes. Cargo.toml declares them, and the other # feature-gated targets named here, with required-features, so if this step ever loses a # feature, cargo stops with an error instead of compiling an empty binary that reports ok. # Each committed vector's decoded output is also checked against the SHA-256 in @@ -176,10 +178,10 @@ jobs: # wai.generate.spectral vector, which `generate` enables. - 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 --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 + run: cargo test --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate --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 - 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 --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 + run: cargo test --release --no-default-features --features neural_int,video_int,audio_int,world_int,splat_int,generate --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 # 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`. diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 4892c9867..592d0d125 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -98,7 +98,7 @@ jobs: run: | set -euo pipefail cargo test --locked --lib 2>&1 | tee "$RUNNER_TEMP/wai-web-native.log" - grep -q "test result: ok. 6 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" + grep -q "test result: ok. 7 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" - name: wasm32 int_synth decode equals the frozen native corpus shell: bash working-directory: wai/wai-web diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 637d8c3b1..d1197878c 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -70,9 +70,14 @@ wire format may change and such changes are recorded here. planned before anything is allocated, every latent and layer is checked for exactness, and a stream that runs out is refused at the first symbol it cannot give, so a build with overflow checks and one without accept the same bytes. A - frame, a clip or a latent is allocated only as its symbols are decoded, so the - memory and work a payload causes grow with what its streams carry rather than with - what its header declares; `int_video` holds frames as RGB8. The JSON reader + frame, a clip or a latent grows only as its symbols are decoded, to its declared + size and no further, so a short stream stops early; `int_video` holds frames as + RGB8. A stream that carries its symbols is decoded in full, and a few kilobytes can + code a clip at the cap, so the header bounds the work: a sink reads the declared + cost before decoding (`int_payload::declared_cost`, `container::declared_size`, the + C ABI's `wai_int_payload_declared`, `wai-web`'s `intPayloadCost`) and applies its own + size policy (integer-payloads §7, *Memory and work*). A `WIA1`'s latent may be no + longer than its `t_out` needs (`geometry_mismatch`). The JSON reader (`int_video::VideoJson`) caps every size before it pads a motion-vector list, and reads a field that does not fit the target's `usize` as absent rather than truncating it. In the browser, `decodeWiv1` keeps the decoder's frames diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 049713664..553dbaae6 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -297,14 +297,37 @@ Every size is formed in 64-bit arithmetic with overflow checked, and a size past bits is past the cap, so a 32-bit sink refuses exactly what a 64-bit one does, with the same code; once a network is planned, every size it forms fits a 32-bit index. -**Memory and work.** The caps bound what a payload can declare; the streams bound -what it can make a decode do. A decode stops at the first symbol that runs its stream -out (§2), and the reference decoders allocate a frame, a clip or a latent only as its -symbols are decoded, and a network's buffers only once its input has been decoded. So -the memory and the work a payload causes before it is refused grow with the symbols -its streams actually carry, not with what its header declares. A decode that -completes holds its output, at most `2^30` bytes for a clip, and, while a network -runs, that network's input and the buffers it forms, each at most `2^26` elements. +**Memory and work.** A payload's header declares its decode's output and the work +that output takes, and a sink can read both before it decodes anything. A stream does +not bound them: a table can make one symbol cost a small fraction of a bit, so a few +kilobytes of stream can code a clip at the `2^30`-byte cap, as any codec can describe +a large output in a small file. The bound is the header's, within the caps: + +- the output: a `WIV1` clip's `n_frames·h·w·3` bytes (one per sample), a `WIH1` + image's `3·h·w` bytes at its synthesis network's size, a `WIA1` clip's `2·t_out` + bytes (`i16` samples), a `WIS1` attribute tensor's `attr·s·s` bytes; +- the entropy decode: one step per symbol, `n_frames·h·w·3` for a keyframe-0 `WIV1` + (`h·w·3` fewer, plus its keyframe's, for keyframe 1), `n_z·zh·zw + m·sh·sw` for a + `WIH1`, `n_ch·lat_len` for a `WIA1`, `c·sh·sw` for a `WIS1`; +- the buffers: latents and network activations of 8 bytes per element (`i64`); while + a network layer runs, its input, its output and, for a transposed convolution, the + uncropped plane it accumulates into, each at most `2^26` elements (*Planning the + networks*); a `WIV1` decode's frames (one byte per sample) and its motion vectors + (4 bytes each); +- each network's work: its layers' outputs times the multiply-accumulates per output. + +A `WIA1`'s latent is the one its `t_out` needs and no longer: the synthesis of +`lat_len` samples gives at least `t_out` samples and that of `lat_len − 1` fewer +(`geometry_mismatch`), so a payload cannot declare a large latent to present a few +samples. + +A decode refuses a stream that runs out at its first missing symbol (§2), so a short +stream stops early, but a stream that carries its symbols is decoded in full: a sink +that will not spend what a header declares refuses before decoding, by its own size +policy, applied to the declared cost (`int_payload::declared_cost`, with the set; the +header alone through `container::declared_size`, the C ABI's +`wai_int_payload_declared` and `wai-web`'s `intPayloadCost`). This extension fixes the +caps; a sink's policy may be stricter. **The parameter set must fit the payload** (`model_mismatch`): @@ -321,8 +344,9 @@ runs, that network's input and the buffers it forms, each at most `2^26` element **The shapes the payload declares must be the ones the decode gives** (`geometry_mismatch`): `h_s`'s output and `[2·m, sh, sw]`; a `WIV1` keyframe and -`h × w`; the audio synthesis's length and `t_out` (it may be longer, not shorter); -`g_s`'s output and `[attr, s, s]`. +`h × w`; the audio synthesis's length and `t_out` (it may be longer, by less than one +latent sample's worth: the synthesis of `lat_len − 1` samples gives fewer than +`t_out`); `g_s`'s output and `[attr, s, s]`. **The order of a decode's checks.** After the parse (§2), a decode checks in this order and refuses with the first that fails: @@ -336,8 +360,9 @@ order and refuses with the first that fails: `too_large`): the caps come before any shape is compared; 4. what the networks give against the payload: for `WIH1`, `h_s`'s shape (`geometry_mismatch`), then `g_s`'s three channels (`model_mismatch`); for `WIA1`, - one channel (`model_mismatch`), then at least `t_out` samples - (`geometry_mismatch`); for `WIS1`, `g_s`'s shape (`geometry_mismatch`); + one channel (`model_mismatch`), then at least `t_out` samples, then a latent no + longer than that needs (both `geometry_mismatch`); for `WIS1`, `g_s`'s shape + (`geometry_mismatch`); 5. each stream in decode order (`WIH1`: the hyper-latent's, then, after `h_s` runs, the latent's; `WIV1` with keyframe 1: the nested keyframe's decode first, then its `h × w` (`geometry_mismatch`), then the residual stream), symbol by symbol: a diff --git a/wai/int-payload-conformance/refusals/wia1_latent_too_long.bin b/wai/int-payload-conformance/refusals/wia1_latent_too_long.bin new file mode 100644 index 0000000000000000000000000000000000000000..a136487880092c86e681cb3654aa053096a3b387 GIT binary patch literal 1444 zcmWIdbTnk-VPI&mV_*QDgAv!@i7#MKr`vp`tlJx<-&+rczfVG0aFqCItsMcd(r~<}+ z%TZt$p9hBZ9bnkK1Ez^z*u%4t6DTLiz)-IZG{=I0q4gk8-(3cVrq>J%jX!~MqwxSv zpY%>=42!6rt{Z@vsOlQqS5RJ(y!3lgw1vWlL`n8J*Y34%7F5kkY7mS_uRHnXUhIX~ z&YP0iTB~l=#^3p`WSXd;Q#j$B>e2^SHZs}seJh{C6YZMtj6tja@YzMrtb7i=y8h03 z?WgaV=7NU|T!qi(9q@3T?`%hIU zp1ml!_Rx{_J|(7^rzSt%rF-x|frKi9w5R&pchd9T%g_7p``3l)&$T}txiwRFh-XYL zf2zIhnla;2k7CZ&*wu$D!+Exru?XwFKX{DqT-c9B_FpHG_9dRa=ezQaLs^%i<}bcO z$7Abm+>-ftZ8!VR9T#0U&a6EUy;5yqdU4>)i~SZZRLZY!y2DkG$ihj@qlv>`VXnatVAtYj@hrsjguK E05AB6nE(I) literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/vectors.json b/wai/int-payload-conformance/vectors.json index 10c787155..3c33218cd 100644 --- a/wai/int-payload-conformance/vectors.json +++ b/wai/int-payload-conformance/vectors.json @@ -572,6 +572,16 @@ "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" } }, + { + "name": "wia1_latent_too_long", + "capability": "wai.audio.int_codec", + "payload": "int-payload-conformance/refusals/wia1_latent_too_long.bin", + "code": "geometry_mismatch", + "layer": "decode", + "set": { + "model.wam": "wai-rs/tests/vectors/int_audio/audio_model.bin" + } + }, { "name": "wia1_exhausted", "capability": "wai.audio.int_codec", diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 79d401782..cebff6519 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -648,6 +648,11 @@ name = "int_payload_preflight" path = "tests/int_payload_preflight.rs" required-features = ["video_int", "audio_int", "splat_int"] +[[test]] +name = "int_payload_cost" +path = "tests/int_payload_cost.rs" +required-features = ["video_int", "audio_int", "splat_int"] + [[test]] name = "generate" path = "tests/generate.rs" diff --git a/wai/wai-rs/cbindgen.toml b/wai/wai-rs/cbindgen.toml index c44d98c9e..f54688798 100644 --- a/wai/wai-rs/cbindgen.toml +++ b/wai/wai-rs/cbindgen.toml @@ -41,6 +41,7 @@ prefix = "" [export.rename] "WaiBuffer" = "wai_buffer_t" "WaiDecoded" = "wai_decoded_t" +"WaiIntDeclared" = "wai_int_declared_t" [fn] prefix = "" diff --git a/wai/wai-rs/include/wai.h b/wai/wai-rs/include/wai.h index 890afb605..4fd3ed5f2 100644 --- a/wai/wai-rs/include/wai.h +++ b/wai/wai-rs/include/wai.h @@ -345,6 +345,17 @@ typedef struct wai_buffer_t { uintptr_t cap; } wai_buffer_t; +/** + * What an integer payload's header declares (`wai_int_payload_declared`): the + * decoded output's bytes, where the header alone fixes them (`output_known` 1), and + * the symbols its streams code. + */ +typedef struct wai_int_declared_t { + uint64_t output_bytes; + uint8_t output_known; + uint64_t symbols; +} wai_int_declared_t; + /** * What `wai_neural_decode_ex` decoded. * @@ -397,6 +408,26 @@ extern "C" { */ const char *wai_last_error(void); +/** + * Read what the payload of an integer capability declares before decoding it, so a + * sink can refuse a payload past its own size policy first + * (`extensions/integer-payloads.md` §7, *Memory and work*). `capability` is the + * envelope's capability (or the `WAI2` component's). The payload is parsed as a sink + * parses it: one that is not the format SPEC §5 pins `capability` to is + * `WAI_ERR_INVALID_ARG`, with its code in `wai_last_error`, and so is a capability + * with no pinned integer format. A `wai.neural.int_hyper` image's size is its + * synthesis network's, so `output_known` is 0 for it. + * + * # Safety + * `capability` NUL-terminated; `payload` valid for `payload_len` bytes; `out` + * writable. + */ + +int wai_int_payload_declared(const char *capability, + const uint8_t *payload, + uintptr_t payload_len, + struct wai_int_declared_t *out); + /** * Builds a complete WAI file from a codec-produced payload + manifest * fields. `manifest_json` is a UTF-8 JSON object overriding/supplying diff --git a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs index b0a5f8c81..35a68d52c 100644 --- a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs +++ b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs @@ -284,6 +284,8 @@ fn fixtures(t: &mut Tree) -> Vec { fx(t, "wia1_trailing", AUD, edit(&au, &|b| b.push(0)), None, "trailing", "parse"); fx(t, "wia1_afrac_model", AUD, edit(&au, &|b| b[5] = 11), None, "model_mismatch", "decode"); fx(t, "wia1_t_out", AUD, edit(&au, &|b| { let n = get_u32(b, 20); set_u32(b, 20, n + 1) }), None, "geometry_mismatch", "decode"); + // A latent twice as long as t_out needs: one sample fewer still gives t_out (§7). + fx(t, "wia1_latent_too_long", AUD, edit(&au, &|b| { let n = get_u32(b, 16); set_u32(b, 16, 2 * n) }), None, "geometry_mismatch", "decode"); fx(t, "wia1_exhausted", AUD, edit(&au, &|b| { set_u32(b, au_n, 8); b.truncate(au_n + 4 + 8) }), None, "stream_exhausted", "decode"); fx(t, "wia1_unread_word", AUD, edit(&au, &|b| { let l = get_u32(b, au_n); set_u32(b, au_n, l + 4); b.extend_from_slice(&[0; 4]) }), None, "stream_noncanonical", "decode"); diff --git a/wai/wai-rs/src/container.rs b/wai/wai-rs/src/container.rs index 7c7cce3c1..f64f8808e 100644 --- a/wai/wai-rs/src/container.rs +++ b/wai/wai-rs/src/container.rs @@ -1389,6 +1389,53 @@ pub fn check_int_payload_framing(capability: &str, payload: &[u8]) -> Result<(), parsed.map_err(|e| FramingError { capability: capability.to_string(), code: e.code(), detail: e.detail }) } +/// What an integer payload's header declares about its decode, read before any +/// symbol is decoded and with no parameter set (`extensions/integer-payloads.md` §7). +/// A sink applies its own size policy to it and refuses a payload past that policy +/// before decoding; `int_payload::declared_cost` adds what the parameter set fixes +/// (the networks' buffers, a `WIH1` image's size). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct DeclaredSize { + /// The decoded output's bytes, where the header alone fixes it: the frames of a + /// `WIV1` (`n_frames·h·w·3`), the PCM of a `WIA1` (`2·t_out`), the attribute tensor + /// of a `WIS1` (`attr·s·s`). `None` for a `WIH1`, whose image size is its + /// synthesis network's. + pub output_bytes: Option, + /// The symbols the payload's streams code: one entropy-decode step each. + pub symbols: u64, +} + +/// [`DeclaredSize`] for `payload` under `capability`, after the parse +/// [`check_int_payload_framing`] makes. `Ok(None)` for a capability with no pinned +/// integer format. +pub fn declared_size(capability: &str, payload: &[u8]) -> Result, FramingError> { + use crate::int_wire as w; + let fail = |e: w::WireError| FramingError { capability: capability.to_string(), code: e.code(), detail: e.detail }; + let wih1 = |k: &w::Wih1<'_>| k.n_z as u64 * k.zh as u64 * k.zw as u64 + k.m as u64 * k.sh as u64 * k.sw as u64; + Ok(Some(match crate::codecs::pinned_int_payload(capability) { + None => return Ok(None), + Some(w::MAGIC_WIV1) => { + let v = w::parse_wiv1(payload, Some((capability == crate::codecs::CAP_VIDEO_INT_HYPER) as u8)).map_err(fail)?; + let frame = v.h as u64 * v.w as u64 * 3; + let symbols = match v.wih1 { + None => frame * v.n_frames as u64, + Some(k) => frame * (v.n_frames as u64 - 1) + wih1(&w::parse_wih1(k).map_err(fail)?), + }; + DeclaredSize { output_bytes: Some(frame * v.n_frames as u64), symbols } + } + Some(w::MAGIC_WIH1) => DeclaredSize { output_bytes: None, symbols: wih1(&w::parse_wih1(payload).map_err(fail)?) }, + Some(w::MAGIC_WIA1) => { + let a = w::parse_wia1(payload).map_err(fail)?; + DeclaredSize { output_bytes: Some(2 * a.t_out as u64), symbols: a.n_ch as u64 * a.lat_len as u64 } + } + Some(_) => { + let v = w::parse_wis1(payload).map_err(fail)?; + DeclaredSize { output_bytes: Some(v.attr as u64 * v.s as u64 * v.s as u64), symbols: v.c as u64 * v.sh as u64 * v.sw as u64 } + } + })) +} + /// Why [`Wai::to_emit_bytes`] or [`WaiMulti::to_emit_bytes`] wrote nothing. #[derive(Debug)] #[non_exhaustive] diff --git a/wai/wai-rs/src/ffi.rs b/wai/wai-rs/src/ffi.rs index bf5ee65a3..7e890b8c5 100644 --- a/wai/wai-rs/src/ffi.rs +++ b/wai/wai-rs/src/ffi.rs @@ -167,6 +167,60 @@ fn emit_err(e: crate::container::EmitError) -> (c_int, String) { } } +/// What an integer payload's header declares (`wai_int_payload_declared`): the +/// decoded output's bytes, where the header alone fixes them (`output_known` 1), and +/// the symbols its streams code. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default)] +pub struct WaiIntDeclared { + pub output_bytes: u64, + pub output_known: u8, + pub symbols: u64, +} + +/// Read what the payload of an integer capability declares before decoding it, so a +/// sink can refuse a payload past its own size policy first +/// (`extensions/integer-payloads.md` §7, *Memory and work*). `capability` is the +/// envelope's capability (or the `WAI2` component's). The payload is parsed as a sink +/// parses it: one that is not the format SPEC §5 pins `capability` to is +/// `WAI_ERR_INVALID_ARG`, with its code in `wai_last_error`, and so is a capability +/// with no pinned integer format. A `wai.neural.int_hyper` image's size is its +/// synthesis network's, so `output_known` is 0 for it. +/// +/// # Safety +/// `capability` NUL-terminated; `payload` valid for `payload_len` bytes; `out` +/// writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn wai_int_payload_declared( + capability: *const c_char, + payload: *const u8, payload_len: usize, + out: *mut WaiIntDeclared, +) -> c_int { + if capability.is_null() || out.is_null() { + set_err("int_payload_declared: capability/out null"); + return WAI_ERR_INVALID_ARG; + } + let r = panic::catch_unwind(|| -> std::result::Result { + let cap = unsafe { std::ffi::CStr::from_ptr(capability) }.to_string_lossy().to_string(); + let p = unsafe { input_slice(payload, payload_len) } + .ok_or((WAI_ERR_INVALID_ARG, "payload null + len>0".into()))?; + match crate::container::declared_size(&cap, p) { + Ok(Some(d)) => Ok(WaiIntDeclared { + output_bytes: d.output_bytes.unwrap_or(0), + output_known: d.output_bytes.is_some() as u8, + symbols: d.symbols, + }), + Ok(None) => Err((WAI_ERR_INVALID_ARG, format!("{cap} has no pinned integer payload format"))), + Err(e) => Err((WAI_ERR_INVALID_ARG, e.to_string())), + } + }); + match r { + Ok(Ok(d)) => { unsafe { *out = d; } WAI_OK } + Ok(Err((code, msg))) => { set_err(msg); code } + Err(_) => { set_err("internal panic in int_payload_declared"); WAI_ERR_PANIC } + } +} + /// Builds a complete WAI file from a codec-produced payload + manifest /// fields. `manifest_json` is a UTF-8 JSON object overriding/supplying /// any fields; `payload` is the bytes from one of the codec encoders. diff --git a/wai/wai-rs/src/int_audio.rs b/wai/wai-rs/src/int_audio.rs index 31e912137..4fa266598 100644 --- a/wai/wai-rs/src/int_audio.rs +++ b/wai/wai-rs/src/int_audio.rs @@ -256,10 +256,14 @@ impl AudioJson { /// the crop `2·pad` leaves an output (`ModelMismatch`). Every size is formed in `u64` /// with checked arithmetic, a size past `u64` being over the cap, so every target /// refuses alike. -fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize), Refused> { +fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize, u64), Refused> { use RefusalKind::*; const CAP: u64 = MAX_TENSOR as u64; let (mut c, mut t) = (c as u64, t as u64); + // The most elements the reference kernels hold while one layer runs, its input + // included: a transposed convolution's input, uncropped buffer and output; an + // inverse GDN's input, output and one position's squares (`c` values of 16 bytes). + let mut live = c * t; for layer in &model.layers { match layer { ALayer::ConvT1d { out_ch, k, stride, pad, opad, weight, mult, bias } => { @@ -281,6 +285,7 @@ fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize), Refuse if full <= crop { return Err(refused(ModelMismatch, "a layer's padding leaves no output")); } + live = live.max(c * t + cout * full + cout * (full - crop)); (c, t) = (cout, full - crop); } ALayer::Igdn { c: cc, gamma, beta } => { @@ -289,11 +294,63 @@ fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize), Refuse { return Err(refused(ModelMismatch, "an IGDN layer does not match the running channel count")); } + live = live.max(2 * c * t + 2 * c); } } } // Every running size is at most 2^26 here, so it fits usize on every target. - Ok((c as usize, t as usize)) + Ok((c as usize, t as usize, live)) +} + +/// What [`decode_typed`] checks before it decodes a symbol, and the buffers it then +/// forms. Returns the synthesis's length and the most bytes the decode holds at once +/// on the reference kernels beyond the payload and the parsed model: the latent and +/// each layer's buffers (`i64` each), then the synthesis's output (`i64`) with the +/// PCM (`i16`). +/// +/// The latent's length is the one `t_out` needs and no longer: the synthesis of +/// `lat_len` latent samples gives at least `t_out` samples (`GeometryMismatch` +/// otherwise), and that of `lat_len − 1` gives fewer (`GeometryMismatch`), so a +/// payload cannot declare a large latent to present a short clip. A length for which +/// the model leaves no output gives none. +pub(crate) fn audio_plan(model: &AudioModel, s: &AudioStream<'_>) -> Result<(usize, u64), Refused> { + use RefusalKind::*; + let afrac = model.afrac; + if !(1..=MAX_AFRAC).contains(&afrac) { + return Err(refused(ModelMismatch, "the model's fixed-point scale is outside 1..=30")); + } + if s.afrac != afrac { + return Err(refused(ModelMismatch, "the stream's afrac is not the model's")); + } + let (c, l) = (s.n_ch, s.lat_len); + if c == 0 || l == 0 || s.t_out == 0 || s.median_q.len() != c || s.cdfs.len() != c { + return Err(refused(FieldRange, "a dimension is zero, or the medians or tables are not one per channel")); + } + if s.median_q.iter().any(|m| m.unsigned_abs() > 1 << 47) { + return Err(refused(FieldRange, "a median's magnitude exceeds 2^47")); + } + let len = s.cdfs[0].len(); + let ok = |t: &Vec| t.len() == len && (3..=4097).contains(&len) && t[0] == 0 && t[len - 1] == 1 << 16 + && t.windows(2).all(|p| p[0] < p[1]); + if !s.cdfs.iter().all(ok) { + return Err(refused(CdfMalformed, "a table is not a CDF from 0 to 65536 of the channels' one length")); + } + if c.checked_mul(l).is_none_or(|n| n > MAX_TENSOR) || s.t_out > MAX_TENSOR { + return Err(refused(TooLarge, "the latent or the clip exceeds 2^26 elements")); + } + let (oc, t, live) = plan(model, c, l)?; + if oc != 1 { + return Err(refused(ModelMismatch, "the model does not give one channel")); + } + if t < s.t_out { + return Err(refused(GeometryMismatch, "the synthesis gives fewer samples than t_out")); + } + let shorter = if l > 1 { plan(model, c, l - 1).map_or(0, |(_, t, _)| t) } else { 0 }; + if shorter >= s.t_out { + return Err(refused(GeometryMismatch, "the latent is longer than t_out needs: one sample fewer gives t_out samples")); + } + let working = (8 * live).max(8 * t as u64 + 2 * s.t_out as u64); + Ok((t, working)) } /// One layer's arithmetic on the values `x` holds, checked before it runs, as @@ -329,8 +386,8 @@ fn layer_in_range(model: &AudioModel, layer: &ALayer, x: &[i64], c: usize) -> Re /// /// 1. the stream's `afrac` is the model's, in `1..=30`; every table is well formed; /// the model's layers fit the latent's shape, give one channel, and give at least -/// `t_out` samples, with no buffer over [`MAX_TENSOR`] elements — all before -/// anything is allocated; +/// `t_out` samples from a latent no longer than that needs, with no buffer over +/// [`MAX_TENSOR`] elements — all before anything is allocated ([`audio_plan`]); /// 2. per channel, rANS-decode the symbol `s`; the latent is `s·2^afrac + median_q` /// ([`crate::int_entropy::dequantize`]), and a stream that runs out before its last /// symbol is refused; @@ -339,36 +396,8 @@ fn layer_in_range(model: &AudioModel, layer: &ALayer, x: &[i64], c: usize) -> Re pub fn decode_typed(model: &AudioModel, s: &AudioStream<'_>) -> Result, Refused> { use crate::int_entropy::dequantize; use RefusalKind::*; - let afrac = model.afrac; - if !(1..=MAX_AFRAC).contains(&afrac) { - return Err(refused(ModelMismatch, "the model's fixed-point scale is outside 1..=30")); - } - if s.afrac != afrac { - return Err(refused(ModelMismatch, "the stream's afrac is not the model's")); - } - let (c, l) = (s.n_ch, s.lat_len); - if c == 0 || l == 0 || s.t_out == 0 || s.median_q.len() != c || s.cdfs.len() != c { - return Err(refused(FieldRange, "a dimension is zero, or the medians or tables are not one per channel")); - } - if s.median_q.iter().any(|m| m.unsigned_abs() > 1 << 47) { - return Err(refused(FieldRange, "a median's magnitude exceeds 2^47")); - } - let len = s.cdfs[0].len(); - let ok = |t: &Vec| t.len() == len && (3..=4097).contains(&len) && t[0] == 0 && t[len - 1] == 1 << 16 - && t.windows(2).all(|p| p[0] < p[1]); - if !s.cdfs.iter().all(ok) { - return Err(refused(CdfMalformed, "a table is not a CDF from 0 to 65536 of the channels' one length")); - } - if c.checked_mul(l).is_none_or(|n| n > MAX_TENSOR) || s.t_out > MAX_TENSOR { - return Err(refused(TooLarge, "the latent or the clip exceeds 2^26 elements")); - } - let (oc, t) = plan(model, c, l)?; - if oc != 1 { - return Err(refused(ModelMismatch, "the model does not give one channel")); - } - if t < s.t_out { - return Err(refused(GeometryMismatch, "the synthesis gives fewer samples than t_out")); - } + audio_plan(model, s)?; + let (afrac, c, l, len) = (model.afrac, s.n_ch, s.lat_len, s.cdfs[0].len()); // 1. factorized rANS-decode the per-channel latent (channel-major), rebuild y_q. // The latent grows a symbol at a time, and the first symbol that runs the stream @@ -381,7 +410,7 @@ pub fn decode_typed(model: &AudioModel, s: &AudioStream<'_>) -> Result, if dec.is_exhausted() { return Err(refused(StreamExhausted, "the latent stream ended before its last symbol")); } - y.push(dequantize(sym, afrac, s.median_q[ch]).ok_or(refused(FieldRange, "a latent value leaves i64"))?); + crate::int_hyper_synth::push_capped(&mut y, dequantize(sym, afrac, s.median_q[ch]).ok_or(refused(FieldRange, "a latent value leaves i64"))?, c * l); } } if !dec.is_canonical_end() { @@ -395,11 +424,13 @@ pub fn decode_typed(model: &AudioModel, s: &AudioStream<'_>) -> Result, } // 3. waveform → int16 PCM (round-half-up, clamp): the first t_out samples. let half = 1i64 << (afrac - 1); - x[..s.t_out].iter().map(|&v| { + let mut pcm = Vec::with_capacity(s.t_out); + for &v in &x[..s.t_out] { let num = v.checked_mul(32767).and_then(|n| n.checked_add(half)) .ok_or(refused(FieldRange, "a synthesised sample leaves i64 when scaled"))?; - Ok((num >> afrac).clamp(-32768, 32767) as i16) - }).collect() + pcm.push((num >> afrac).clamp(-32768, 32767) as i16); + } + Ok(pcm) } /// One layer of the synthesis on `x` (`[c, t]`): the new activations and shape. diff --git a/wai/wai-rs/src/int_hyper_synth.rs b/wai/wai-rs/src/int_hyper_synth.rs index 24169e1c5..ea57ebeb3 100644 --- a/wai/wai-rs/src/int_hyper_synth.rs +++ b/wai/wai-rs/src/int_hyper_synth.rs @@ -945,7 +945,27 @@ impl CondBitstream { /// the cap, so a 32-bit sink refuses exactly what a 64-bit one does, with the same /// kind; and once a network is planned, every size [`run_net_checked`] forms fits /// `usize` on every target. The first rule that fails is the refusal. -pub(crate) fn plan(net: &IntModel, c: usize, h: usize, w: usize) -> Result<(usize, usize, usize), Refused> { +pub(crate) fn plan_net(net: &IntModel, c: usize, h: usize, w: usize) -> Result { + plan_layers(net, c, h, w) +} + +/// What [`plan_net`] finds about a network on a `[c, h, w]` input, in elements. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NetPlan { + /// The output `[c, h, w]`. + pub out: (usize, usize, usize), + /// The largest single buffer the network forms: its input, a layer's output, a + /// transposed convolution's uncropped plane. + pub largest: u64, + /// The most elements the reference kernels hold at once while one layer runs, its + /// input included: a transposed convolution's input, uncropped plane and output; a + /// convolution's input and output; an inverse GDN's input, output and one + /// position's squares (`c` values of 16 bytes, counted as `2·c`); a ReLU's input, + /// rewritten in place. Each element is an `i64`. + pub live: u64, +} + +fn plan_layers(net: &IntModel, c: usize, h: usize, w: usize) -> Result { use RefusalKind::{ModelMismatch, TooLarge}; const CAP: u64 = MAX_TENSOR as u64; let big = |what: &'static str| refused(TooLarge, what); @@ -954,7 +974,8 @@ pub(crate) fn plan(net: &IntModel, c: usize, h: usize, w: usize) -> Result<(usiz if c == 0 || h == 0 || w == 0 { return Err(refused(ModelMismatch, "a network's input is empty")); } - within(c.checked_mul(h).and_then(|v| v.checked_mul(w)), "a network's input exceeds 2^26 elements")?; + let input = within(c.checked_mul(h).and_then(|v| v.checked_mul(w)), "a network's input exceeds 2^26 elements")?; + let (mut largest, mut live) = (input, input); let params = |cout: u64, k: u64, weight: usize, mult: usize, bias: usize, c: u64| { let n = c.checked_mul(cout).and_then(|v| v.checked_mul(k)).and_then(|v| v.checked_mul(k)); n == Some(weight as u64) && mult as u64 == cout && bias as u64 == cout @@ -968,12 +989,16 @@ pub(crate) fn plan(net: &IntModel, c: usize, h: usize, w: usize) -> Result<(usiz } let full = |n: u64| (n - 1).checked_mul(s).and_then(|v| v.checked_add(k)).and_then(|v| v.checked_add(op)); let (fh, fw) = (within(full(h), "a layer's uncropped side exceeds 2^26")?, within(full(w), "a layer's uncropped side exceeds 2^26")?); - within(cout.checked_mul(fh).and_then(|v| v.checked_mul(fw)), "a layer's uncropped plane exceeds 2^26 elements")?; + let plane = within(cout.checked_mul(fh).and_then(|v| v.checked_mul(fw)), "a layer's uncropped plane exceeds 2^26 elements")?; let crop = p.checked_mul(2).unwrap_or(u64::MAX); if fh <= crop || fw <= crop { return Err(refused(ModelMismatch, "a transposed convolution's padding leaves no output")); } + let a = c * h * w; (c, h, w) = (cout, fh - crop, fw - crop); + let b = c * h * w; + largest = largest.max(plane).max(b); + live = live.max(a + plane + b); } IntLayer::Conv { out_ch, k, stride, pad, weight, mult, bias } => { let (cout, k, s, p) = (*out_ch as u64, *k as u64, *stride as u64, *pad as u64); @@ -986,13 +1011,16 @@ pub(crate) fn plan(net: &IntModel, c: usize, h: usize, w: usize) -> Result<(usiz return Err(refused(ModelMismatch, "a convolution's kernel is larger than its padded input")); } let (ho, wo) = ((hp - k) / s + 1, (wp - k) / s + 1); - within(cout.checked_mul(ho).and_then(|v| v.checked_mul(wo)), "a layer's output exceeds 2^26 elements")?; + let b = within(cout.checked_mul(ho).and_then(|v| v.checked_mul(wo)), "a layer's output exceeds 2^26 elements")?; + live = live.max(c * h * w + b); + largest = largest.max(b); (c, h, w) = (cout, ho, wo); } IntLayer::Igdn { gamma, beta } => { if c.checked_mul(c) != Some(gamma.len() as u64) || beta.len() as u64 != c || net.gf > 127 || net.afrac > 127 { return Err(refused(ModelMismatch, "an inverse GDN does not fit the running channel count")); } + live = live.max(2 * c * h * w + 2 * c); } IntLayer::Relu => {} IntLayer::Leaky { slope_frac, .. } => { @@ -1003,7 +1031,7 @@ pub(crate) fn plan(net: &IntModel, c: usize, h: usize, w: usize) -> Result<(usiz } } // Every running size is at most 2^26 here, so it fits usize on every target. - Ok((c as usize, h as usize, w as usize)) + Ok(NetPlan { out: (c as usize, h as usize, w as usize), largest, live }) } /// [`run_net_checked`] with its refusal as a [`Refused`]: values that could overflow a @@ -1026,7 +1054,7 @@ fn run_checked(net: &IntModel, x: &[i64], c: usize, h: usize, w: usize) -> Resul /// Before anything is allocated, in this order: the networks share one fixed-point /// scale in `1..=`[`MAX_AFRAC`]; the entropy model is consistent and has a `z` table /// for each of the payload's channels; the latents are within [`MAX_TENSOR`]; `h_s` on -/// the hyper-latent's shape, then `g_s` on `[m, sh, sw]`, are planned ([`plan`]: each +/// the hyper-latent's shape, then `g_s` on `[m, sh, sw]`, are planned ([`plan_net`]: each /// layer fits, forms nothing over the cap, and leaves an output); then `h_s` must give /// `[2·m, sh, sw]` (`GeometryMismatch`) and `g_s` three channels. Then the hyper-latent /// is decoded symbol by symbol (a symbol that runs the stream out is refused before @@ -1037,6 +1065,69 @@ pub fn decode_conditional_checked( g_s: &IntModel, h_s: &IntModel, ent: &CondEntropy, bs: &CondStream<'_>, ) -> Result<(Vec, usize, usize), Refused> { use crate::int_entropy::dequantize; + use RefusalKind::*; + let p = cond_plan(g_s, h_s, ent, bs)?; + let (afrac, n_z, zh, zw, m, sh, sw) = (g_s.afrac, bs.n_z, bs.zh, bs.zw, bs.m, bs.sh, bs.sw); + let (zplane, n, (oc, oh, ow)) = (zh * zw, m * sh * sw, p.gs.out); + // 1. factorized-decode z, rebuild z_q = s·2^afrac + median_q. The latent grows a + // symbol at a time, to its declared length and no further, and the first symbol + // that runs its stream out refuses the payload before its value is used. + let mut z = Vec::new(); + let mut dz = RansDecoder::new(bs.z_rans).map_err(|_| refused(FieldRange, "the z stream is not a rANS stream"))?; + for ch in 0..n_z { + for _ in 0..zplane { + let s = dz.decode(&ent.z_cdfs[ch], ent.z_cdf_lengths[ch], ent.z_offsets[ch]); + if dz.is_exhausted() { + return Err(refused(StreamExhausted, "the z stream ended before its last symbol")); + } + push_capped(&mut z, dequantize(s, afrac, ent.z_median_q[ch]).ok_or(refused(FieldRange, "a hyper-latent value leaves i64"))?, n_z * zplane); + } + } + if !dz.is_canonical_end() { + return Err(refused(StreamNoncanonical, "the z stream does not end canonically after its last symbol")); + } + let sm = run_checked(h_s, &z, n_z, zh, zw)?; + drop(z); + let (sigma, mu) = sm.split_at(n); + // 2. y, conditioned on the integer σ: y_hat = r·2^afrac + μ. + let mut dec = RansDecoder::new(bs.rans).map_err(|_| refused(FieldRange, "the y stream is not a rANS stream"))?; + let mut y_hat = Vec::new(); + for p in 0..n { + let b = det_build_index(sigma[p], &ent.edges); + let r = dec.decode(&ent.cdfs[b], ent.cdf_lengths[b], ent.offsets[b]); + if dec.is_exhausted() { + return Err(refused(StreamExhausted, "the y stream ended before its last symbol")); + } + push_capped(&mut y_hat, dequantize(r, afrac, mu[p]).ok_or(refused(FieldRange, "a latent value leaves i64"))?, n); + } + if !dec.is_canonical_end() { + return Err(refused(StreamNoncanonical, "the y stream does not end canonically after its last symbol")); + } + drop(sm); + // 3. integer synthesis g_s → pixels. + let x = run_checked(g_s, &y_hat, m, sh, sw)?; + drop(y_hat); + Ok((to_rgb8(&x, oc, oh, ow, afrac), oh, ow)) +} + +/// Append `x` to `v`, a buffer that will hold `total` elements, growing it by doubling +/// but never past `total`: a decode holds no more than it declared, and no buffer is +/// reserved ahead of the symbols that fill it. +pub(crate) fn push_capped(v: &mut Vec, x: T, total: usize) { + if v.len() == v.capacity() { + let want = v.capacity().saturating_mul(2).max(64).min(total).max(v.len() + 1); + v.reserve_exact(want - v.len()); + } + v.push(x); +} + +/// What [`decode_conditional_checked`] checks before it decodes a symbol, in its +/// order, and the buffers it then forms: the networks share one fixed-point scale in +/// `1..=`[`MAX_AFRAC`]; the entropy model is consistent and has a `z` table for each +/// of the payload's channels; the latents are within [`MAX_TENSOR`]; `h_s` on the +/// hyper-latent's shape, then `g_s` on `[m, sh, sw]`, are planned ([`plan_net`]); then `h_s` +/// must give `[2·m, sh, sw]` (`GeometryMismatch`) and `g_s` three channels. +pub(crate) fn cond_plan(g_s: &IntModel, h_s: &IntModel, ent: &CondEntropy, bs: &CondStream<'_>) -> Result { use RefusalKind::*; let afrac = g_s.afrac; if h_s.afrac != afrac || !(1..=MAX_AFRAC).contains(&afrac) { @@ -1051,56 +1142,48 @@ pub fn decode_conditional_checked( return Err(refused(ModelMismatch, "the hyper-latent has more channels than the entropy model has z tables")); } let size = |d: &[usize]| d.iter().try_fold(1usize, |a, &x| a.checked_mul(x)).filter(|&n| n <= MAX_TENSOR); - let (zplane, n) = match (size(&[n_z, zh, zw]), size(&[2, m, sh, sw])) { - (Some(_), Some(_)) => (zh * zw, m * sh * sw), - _ => return Err(refused(TooLarge, "a latent exceeds 2^26 elements")), - }; + if size(&[n_z, zh, zw]).is_none() || size(&[2, m, sh, sw]).is_none() { + return Err(refused(TooLarge, "a latent exceeds 2^26 elements")); + } // Both networks are planned, caps included, before either output shape is // compared with the payload's. - let hs_out = plan(h_s, n_z, zh, zw)?; - let (oc, oh, ow) = plan(g_s, m, sh, sw)?; - if hs_out != (2 * m, sh, sw) { + let hs = plan_net(h_s, n_z, zh, zw)?; + let gs = plan_net(g_s, m, sh, sw)?; + if hs.out != (2 * m, sh, sw) { return Err(refused(GeometryMismatch, "h_s does not give the latent shape the payload declares")); } - if oc != 3 { + if gs.out.0 != 3 { return Err(refused(ModelMismatch, "g_s does not give three channels")); } - // 1. factorized-decode z, rebuild z_q = s·2^afrac + median_q. The latent grows a - // symbol at a time, and the first symbol that runs its stream out refuses the - // payload before its value is used. - let mut z = Vec::new(); - let mut dz = RansDecoder::new(bs.z_rans).map_err(|_| refused(FieldRange, "the z stream is not a rANS stream"))?; - for ch in 0..n_z { - for _ in 0..zplane { - let s = dz.decode(&ent.z_cdfs[ch], ent.z_cdf_lengths[ch], ent.z_offsets[ch]); - if dz.is_exhausted() { - return Err(refused(StreamExhausted, "the z stream ended before its last symbol")); - } - z.push(dequantize(s, afrac, ent.z_median_q[ch]).ok_or(refused(FieldRange, "a hyper-latent value leaves i64"))?); - } + Ok(CondPlan { hs, gs, z: (n_z * zh * zw) as u64, y: (m * sh * sw) as u64 }) +} + +/// The buffers [`decode_conditional_checked`] forms on a payload [`cond_plan`] admits. +#[derive(Debug, Clone, Copy)] +pub(crate) struct CondPlan { + pub hs: NetPlan, + pub gs: NetPlan, + /// The hyper-latent's and the latent's elements: the symbols each stream codes. + pub z: u64, + pub y: u64, +} + +impl CondPlan { + /// The image's bytes (RGB8). + pub fn output_bytes(&self) -> u64 { + let (c, h, w) = self.gs.out; + (c * h * w) as u64 } - if !dz.is_canonical_end() { - return Err(refused(StreamNoncanonical, "the z stream does not end canonically after its last symbol")); + + /// The most bytes the decode holds at once on the reference kernels, beyond the + /// payload and the parsed set: the hyper-latent while `h_s` runs; `h_s`'s output + /// (`[2·m, sh, sw]`) while the latent is decoded; the latent while `g_s` runs; and + /// `g_s`'s output while it becomes RGB8. Every value is an `i64`. + pub fn working_bytes(&self) -> u64 { + let gs_out = self.output_bytes(); + [8 * (self.z + self.hs.live), 8 * (2 * self.y + self.y), 8 * (self.y + self.gs.live), 8 * gs_out + gs_out] + .into_iter().max().unwrap_or(0) } - let sm = run_checked(h_s, &z, n_z, zh, zw)?; - let (sigma, mu) = sm.split_at(n); - // 2. y, conditioned on the integer σ: y_hat = r·2^afrac + μ. - let mut dec = RansDecoder::new(bs.rans).map_err(|_| refused(FieldRange, "the y stream is not a rANS stream"))?; - let mut y_hat = Vec::new(); - for p in 0..n { - let b = det_build_index(sigma[p], &ent.edges); - let r = dec.decode(&ent.cdfs[b], ent.cdf_lengths[b], ent.offsets[b]); - if dec.is_exhausted() { - return Err(refused(StreamExhausted, "the y stream ended before its last symbol")); - } - y_hat.push(dequantize(r, afrac, mu[p]).ok_or(refused(FieldRange, "a latent value leaves i64"))?); - } - if !dec.is_canonical_end() { - return Err(refused(StreamNoncanonical, "the y stream does not end canonically after its last symbol")); - } - // 3. integer synthesis g_s → pixels. - let x = run_checked(g_s, &y_hat, m, sh, sw)?; - Ok((to_rgb8(&x, oc, oh, ow, afrac), oh, ow)) } /// The fast kernels against the scalar reference, element by element. Operand diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index 9d2d33b83..f763ea9d5 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -387,6 +387,146 @@ pub fn decode_int(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result) -> Result { + if !crate::codecs::is_dispatchable(cap) { + return Err(IntPayloadError::new(Refusal::NotDispatchable, format!("{cap} is not dispatchable (SPEC \u{a7}4)"))); + } + match crate::codecs::pinned_int_payload(cap) { + Some(wire::MAGIC_WIV1) => cost_wiv1(cap, payload, params), + Some(wire::MAGIC_WIH1) => { + let k = wire::parse_wih1(payload)?; + let p = cond_cost(cap, &k, params)?; + Ok(DeclaredCost { output_bytes: p.output_bytes(), symbols: p.z + p.y, working_bytes: p.working_bytes() }) + } + Some(wire::MAGIC_WIA1) => cost_wia1(cap, payload, params), + Some(wire::MAGIC_WIS1) => cost_wis1(cap, payload, params), + _ => Err(unsupported(cap, "no declared cost: its payload format is not one SPEC \u{a7}5 pins here")), + } +} + +/// The bytes of one `Vec`'s header on this target, for a table of tables. +#[cfg(any(feature = "audio_int", feature = "splat_int"))] +const VEC: u64 = std::mem::size_of::>() as u64; + +/// The conditional codec's plan for a parsed `WIH1` against the capability's set. +fn cond_cost(cap: &str, k: &wire::Wih1<'_>, params: &IntParams<'_>) -> Result { + let (g_s, h_s, ent) = int_hyper_models(cap, params)?; + let s = crate::int_hyper_synth::CondStream { + m: k.m as usize, sh: k.sh as usize, sw: k.sw as usize, + n_z: k.n_z as usize, zh: k.zh as usize, zw: k.zw as usize, + z_rans: k.z_rans, rans: k.rans, + }; + Ok(crate::int_hyper_synth::cond_plan(&g_s, &h_s, &ent, &s)?) +} + +#[cfg(feature = "video_int")] +fn cost_wiv1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { + use crate::int_video::{check, working_bytes, VideoStream}; + let keyframe = if cap == crate::codecs::CAP_VIDEO_INT_MOTION { 0 } else { 1 }; + let v = wire::parse_wiv1(payload, Some(keyframe))?; + let s = VideoStream { + n_frames: v.n_frames as usize, h: v.h as usize, w: v.w as usize, block: v.block as usize, + q: v.q as i64, offset: v.offset, mv_precision: v.mv_precision, cdf: &v.cdf, mvs: &v.mvs, rans: v.rans, + }; + check(&s).map_err(|e| IntPayloadError::new(Refusal::FieldRange, e))?; + // The parsed table and motion vectors, held for the whole decode. + let tables = 4 * v.cdf.len() as u64 + 4 * v.mvs.len() as u64; + let frame = v.h as u64 * v.w as u64 * 3; + let output_bytes = frame * v.n_frames as u64; + match v.wih1 { + None => Ok(DeclaredCost { output_bytes, symbols: output_bytes, working_bytes: tables + working_bytes(&s) }), + Some(nested) => { + let p = cond_cost(cap, &wire::parse_wih1(nested)?, params)?; + if p.gs.out != (3, v.h as usize, v.w as usize) { + return Err(IntPayloadError::new(Refusal::GeometryMismatch, "the keyframe's size is not the clip's h × w")); + } + // The keyframe's decode, then the clip with the keyframe's image beside it. + let working = p.working_bytes().max(working_bytes(&s) + frame); + Ok(DeclaredCost { output_bytes, symbols: frame * (v.n_frames as u64 - 1) + p.z + p.y, working_bytes: tables + working }) + } + } +} + +#[cfg(not(feature = "video_int"))] +fn cost_wiv1(cap: &str, _: &[u8], _: &IntParams<'_>) -> Result { + Err(unsupported(cap, "this build has no integer video decoder (feature video_int)")) +} + +#[cfg(feature = "audio_int")] +fn cost_wia1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { + use crate::int_audio::{audio_plan, AudioModel, AudioStream}; + let a = wire::parse_wia1(payload)?; + let m = AudioModel::from_bytes(params.member(cap, "model.wam")?).map_err(|e| model(format!("model.wam: {e}")))?; + let s = AudioStream { + afrac: a.afrac as u32, n_ch: a.n_ch as usize, lat_len: a.lat_len as usize, t_out: a.t_out as usize, + offset: a.offset, median_q: &a.median_q, cdfs: &a.cdfs, rans: a.rans, + }; + let (_, working) = audio_plan(&m, &s)?; + // The parsed medians and tables, held for the whole decode. + let tables = a.n_ch as u64 * (8 + VEC + 4 * a.cdfs[0].len() as u64); + Ok(DeclaredCost { output_bytes: 2 * a.t_out as u64, symbols: a.n_ch as u64 * a.lat_len as u64, working_bytes: tables + working }) +} + +#[cfg(not(feature = "audio_int"))] +fn cost_wia1(cap: &str, _: &[u8], _: &IntParams<'_>) -> Result { + Err(unsupported(cap, "this build has no integer audio decoder (feature audio_int)")) +} + +#[cfg(feature = "splat_int")] +fn cost_wis1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { + use crate::int_hyper_synth::IntModel; + use crate::int_splat::{splat_plan, SplatStream}; + let v = wire::parse_wis1(payload)?; + let gs = IntModel::from_bytes(params.member(cap, "g_s.whm")?).map_err(|e| model(format!("g_s.whm: {e}")))?; + let s = SplatStream { + afrac: v.afrac as u32, c: v.c as usize, sh: v.sh as usize, sw: v.sw as usize, s: v.s as usize, + n_attr: v.attr as usize, cdfs: &v.cdfs, offsets: &v.offsets, median_q: &v.median_q, rans: v.rans, + }; + let working = splat_plan(&gs, &s)?; + // The parsed ranges, offsets, medians and tables, held for the whole decode. + let cdfs: u64 = v.cdfs.iter().map(|t| 4 * t.len() as u64).sum(); + let tables = 8 * v.attr as u64 + v.c as u64 * (4 + 8 + VEC) + cdfs; + Ok(DeclaredCost { output_bytes: v.attr as u64 * v.s as u64 * v.s as u64, symbols: v.c as u64 * v.sh as u64 * v.sw as u64, working_bytes: tables + working }) +} + +#[cfg(not(feature = "splat_int"))] +fn cost_wis1(cap: &str, _: &[u8], _: &IntParams<'_>) -> Result { + Err(unsupported(cap, "this build has no integer splat decoder (feature splat_int)")) +} + fn unsupported(cap: &str, why: &str) -> IntPayloadError { IntPayloadError::new(Refusal::UnsupportedCapability, format!("{cap}: {why}")) } diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index af06efc26..863bec111 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -592,7 +592,8 @@ fn entropy_tables_obey_the_table_length_rule() { /// they run at. #[test] fn network_plans_refuse_alike_on_every_target() { - use crate::int_hyper_synth::{net_shape, plan, IntLayer, IntModel, RefusalKind as K}; + use crate::int_hyper_synth::{net_shape, plan_net, IntLayer, IntModel, RefusalKind as K}; + let plan = |m: &IntModel, c, h, w| plan_net(m, c, h, w).map(|p| p.out); let net = |layer: IntLayer| IntModel { afrac: 8, gf: 8, shift: 8, layers: vec![layer] }; let convt = |stride: usize, pad: usize| net(IntLayer::ConvT { out_ch: 1, k: 1, stride, pad, opad: 0, weight: vec![1], mult: vec![1], bias: vec![0] }); let kind = |r: Result<(usize, usize, usize), crate::int_hyper_synth::Refused>| r.map_err(|e| e.kind); diff --git a/wai/wai-rs/src/int_splat.rs b/wai/wai-rs/src/int_splat.rs index b9abe4179..1be9d5e4b 100644 --- a/wai/wai-rs/src/int_splat.rs +++ b/wai/wai-rs/src/int_splat.rs @@ -116,6 +116,48 @@ impl SplatJson { /// 4. round-half-up → `u8`, channel-major `[attr, s, s]`. pub fn decode_typed(gs: &IntModel, st: &SplatStream<'_>) -> Result { use crate::int_entropy::dequantize; + use RefusalKind::*; + splat_plan(gs, st)?; + let (afrac, c, sh, sw, s, n_attr) = (gs.afrac, st.c, st.sh, st.sw, st.s, st.n_attr); + + // 1. per-channel factorized rANS-decode the latent, rebuild y_q. The latent grows + // a symbol at a time, and the first symbol that runs the stream out refuses the + // payload before its value is used. + let plane = sh * sw; + let mut y = Vec::new(); + let mut dec = RansDecoder::new(st.rans).map_err(|_| refused(FieldRange, "the latent stream is not a rANS stream"))?; + for ch in 0..c { + for _ in 0..plane { + let sym = dec.decode(&st.cdfs[ch], st.cdfs[ch].len(), st.offsets[ch]); + if dec.is_exhausted() { + return Err(refused(StreamExhausted, "the latent stream ended before its last symbol")); + } + crate::int_hyper_synth::push_capped(&mut y, dequantize(sym, afrac, st.median_q[ch]).ok_or(refused(FieldRange, "a latent value leaves i64"))?, c * plane); + } + } + if !dec.is_canonical_end() { + return Err(refused(StreamNoncanonical, "the latent stream does not end canonically after its last symbol")); + } + // 2. integer synthesis → attribute tensor at 2^afrac. + let (x, ..) = run_net_checked(gs, &y, c, sh, sw).map_err(|r| { + if r.out_of_range { + refused(FieldRange, "a decoded latent could take a layer outside the exact integer range") + } else { + refused(ModelMismatch, "g_s's parameters do not fit the running shapes") + } + })?; + drop(y); + // 3. round-half-up → u8 (channel-major [attr, s, s]). + let (one, half) = (1i64 << afrac, 1i64 << (afrac - 1)); + let attr = x.iter().map(|&v| ((v.clamp(0, one) * 255 + half) >> afrac).clamp(0, 255) as u8).collect(); + Ok(DecodedSplat { attr, n_attr, s }) +} + +/// What [`decode_typed`] checks before it decodes a symbol, and the most bytes the +/// decode then holds at once on the reference kernels beyond the payload and the +/// parsed model: the latent (`i64`) with `g_s`'s buffers while it runs, then `g_s`'s +/// output (`i64`) with the attribute tensor (`u8`). +pub(crate) fn splat_plan(gs: &IntModel, st: &SplatStream<'_>) -> Result { use RefusalKind::*; let afrac = gs.afrac; if !(1..=MAX_AFRAC).contains(&afrac) { @@ -136,43 +178,15 @@ pub fn decode_typed(gs: &IntModel, st: &SplatStream<'_>) -> Result> afrac).clamp(0, 255) as u8).collect(); - Ok(DecodedSplat { attr, n_attr, s }) + let (latent, out) = (latent as u64, out as u64); + Ok((8 * (latent + p.live)).max(8 * out + out)) } /// Decode the tool's JSON bitstream (`splat.json`) → the integer attribute tensor: diff --git a/wai/wai-rs/src/int_video.rs b/wai/wai-rs/src/int_video.rs index 0c63a8de3..bb6f3cf45 100644 --- a/wai/wai-rs/src/int_video.rs +++ b/wai/wai-rs/src/int_video.rs @@ -276,7 +276,7 @@ fn predict(rf: &[u8], y: usize, x: usize, dy: i64, dx: i64, c: usize, prec: i64, /// count and motion-vector field under their caps ([`caps`]), and the motion-vector /// count the block grid's, all formed with checked arithmetic. Returns the frame's /// element count and `nb`. -fn check(s: &VideoStream<'_>) -> Result<(usize, usize), &'static str> { +pub(crate) fn check(s: &VideoStream<'_>) -> Result<(usize, usize), &'static str> { if s.n_frames == 0 || s.h == 0 || s.w == 0 || s.block == 0 { return Err("n_frames, h, w or block is zero"); } @@ -306,6 +306,7 @@ fn check(s: &VideoStream<'_>) -> Result<(usize, usize), &'static str> { /// `i64`. fn frame(s: &VideoStream<'_>, dec: &mut RansDecoder<'_>, prev: Option<&[u8]>, mvs: &[(i16, i16)], out: &mut Vec) -> Result<(), &'static str> { let (h, w, block) = (s.h, s.w, s.block); + let n = h * w * 3; let nbx = w.div_ceil(block); let prec = s.mv_precision as i64; for y in 0..h { @@ -320,7 +321,7 @@ fn frame(s: &VideoStream<'_>, dec: &mut RansDecoder<'_>, prev: Option<&[u8]>, mv if dec.is_exhausted() { return Err(STREAM_EXHAUSTED); } - out.push((pred + r * s.q).clamp(0, 255) as u8); + crate::int_hyper_synth::push_capped(out, (pred + r * s.q).clamp(0, 255) as u8, n); } } } @@ -328,9 +329,9 @@ fn frame(s: &VideoStream<'_>, dec: &mut RansDecoder<'_>, prev: Option<&[u8]>, mv } /// Frames `first..n_frames` of a clip, each predicted from the last frame of `frames` -/// (which holds the frames before `first`), appended to `frames`. A clip holds no -/// buffer it has not decoded into: the frame list grows a frame at a time and each -/// frame a sample at a time. +/// (which holds the frames before `first`), appended to `frames`. Each frame grows a +/// sample at a time to its `h·w·3` bytes and no further, so the clip holds its +/// declared bytes and the frame list ([`working_bytes`]). fn decode_frames(s: &VideoStream<'_>, nb: usize, first: usize, frames: &mut Vec>) -> Result<(), &'static str> { let mut dec = RansDecoder::new(s.rans)?; for f in first..s.n_frames { @@ -347,6 +348,14 @@ fn decode_frames(s: &VideoStream<'_>, nb: usize, first: usize, frames: &mut Vec< Ok(()) } +/// The most bytes [`decode_typed`] holds at once, beyond the payload and its parsed +/// tables, on a stream [`check`] admits: the clip (`n_frames·h·w·3`) and the frame list +/// (one `Vec` header of 24 bytes per frame on a 64-bit target). +pub fn working_bytes(s: &VideoStream<'_>) -> u64 { + let frame = (s.h * s.w * 3) as u64; + frame * s.n_frames as u64 + std::mem::size_of::>() as u64 * s.n_frames as u64 +} + /// Decode a typed clip whose I-frame is predicted from grey (`WIV1` keyframe 0, /// `wai.video.int_motion`) → frames, byte-exact on every machine. Refuses, rather /// than panicking on or misreading, a stream that breaks [`check`]'s rules or runs @@ -354,7 +363,7 @@ fn decode_frames(s: &VideoStream<'_>, nb: usize, first: usize, frames: &mut Vec< /// out). Pure i64. pub fn decode_typed(s: &VideoStream<'_>) -> Result { let (_, nb) = check(s)?; - let mut frames = Vec::new(); + let mut frames = Vec::with_capacity(s.n_frames); decode_frames(s, nb, 0, &mut frames)?; Ok(DecodedClip { frames, h: s.h, w: s.w }) } @@ -368,7 +377,8 @@ pub fn decode_pframes_typed(i_frame: &[u8], s: &VideoStream<'_>) -> Result *mut u8 { + PEAK.fetch_max(CUR.fetch_add(l.size(), Relaxed) + l.size(), Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + PEAK.fetch_max(CUR.fetch_add(l.size(), Relaxed) + l.size(), Relaxed); + unsafe { System.alloc_zeroed(l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + if new >= l.size() { + PEAK.fetch_max(CUR.fetch_add(new - l.size(), Relaxed) + (new - l.size()), Relaxed); + } else { + CUR.fetch_sub(l.size() - new, Relaxed); + } + unsafe { System.realloc(p, l, new) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + CUR.fetch_sub(l.size(), Relaxed); + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static ALLOC: Live = Live; + +/// The most bytes `f` holds at once beyond what was live when it started, and its result +/// (still held when the measure is taken). +fn peak_during(f: impl FnOnce() -> R) -> (R, u64) { + let base = CUR.load(Relaxed); + PEAK.store(base, Relaxed); + let r = f(); + (r, (PEAK.load(Relaxed) - base) as u64) +} + +fn read(rel: &str) -> Vec { + let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors").join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display())) +} + +/// A rANS stream of `n` symbols, symbol `i` coded with `(start, freq)` from `sym(i)`, +/// from the state 2^31, so it ends canonically: what an encoder writes. +fn encode(n: u64, sym: impl Fn(u64) -> (u64, u64)) -> Vec { + const L: u64 = 1 << 31; + let mut state = L; + let mut renorm: Vec = Vec::new(); + let mut i = n; + while i > 0 { + i -= 1; + let (start, freq) = sym(i); + let x_max = ((L >> 16) << 32) * freq; + while state >= x_max { + renorm.push(state as u32); + state >>= 32; + } + state = ((state / freq) << 16) + (state % freq) + start; + } + renorm.reverse(); + let mut words = vec![state as u32, (state >> 32) as u32]; + words.extend(renorm); + words.iter().flat_map(|w| w.to_le_bytes()).collect() +} + +/// A keyframe-0 clip of `n_frames` frames of `h × w`, one block per frame, whose every +/// residual is 0 at frequency 65535/65536: a valid stream of a few bytes per 2^20 +/// symbols. +fn constant_clip(n_frames: u32, h: u32, w: u32) -> Vec { + let rans = encode(n_frames as u64 * h as u64 * w as u64 * 3, |_| (0, 65_535)); + wire::write_wiv1(&wire::Wiv1 { + mv_precision: 1, keyframe: 0, n_frames, h, w, block: h.max(w), q: 1, offset: 0, cdf: vec![0, 65_535, 65_536], + mvs: vec![(0, 0); n_frames as usize - 1], rans: &rans, wih1: None, + }) +} + +#[test] +fn the_declared_cost_bounds_what_the_decode_holds() { + use wai::codecs as c; + let hyp: BTreeMap> = [("entropy.json", "int_hyper/tiny_entropy.json"), ("g_s.whm", "int_hyper/tiny_g_s.bin"), ("h_s.whm", "int_hyper/tiny_h_s.bin")] + .into_iter().map(|(k, v)| (k.to_string(), read(v))).collect(); + let hyp_view: BTreeMap = hyp.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let (model, gs) = (read("int_audio/audio_model.bin"), read("int_splat/splat_gs.bin")); + let unpinned = |cap, held| resolve(cap, None, &[held], None).expect("an unpinned set resolves"); + let none = IntParams::none(); + let hyp_p = unpinned(c::CAP_NEURAL_INT_HYPER, HeldBytes::Set { listing: b"", files: &hyp_view }); + let (aud, spl) = (unpinned(c::CAP_AUDIO_INT_CODEC, HeldBytes::File(&model)), unpinned(c::CAP_SPLAT_INT_CODEC, HeldBytes::File(&gs))); + // The parameter set as a decode parses it, which the declared cost leaves out. + let set_bytes = |cap: &str| -> u64 { + let (r, peak) = peak_during(|| match cap { + c::CAP_NEURAL_INT_HYPER | c::CAP_VIDEO_INT_HYPER => { + let e: serde_json::Value = serde_json::from_slice(&hyp["entropy.json"]).unwrap(); + let t = wai::int_hyper_synth::CondEntropy::try_from_value(&e).unwrap(); + drop(e); + Box::new((wai::int_hyper_synth::IntModel::from_bytes(&hyp["g_s.whm"]).unwrap(), + wai::int_hyper_synth::IntModel::from_bytes(&hyp["h_s.whm"]).unwrap(), t)) as Box + } + c::CAP_AUDIO_INT_CODEC => Box::new(wai::int_audio::AudioModel::from_bytes(&model).unwrap()) as Box, + c::CAP_SPLAT_INT_CODEC => Box::new(wai::int_hyper_synth::IntModel::from_bytes(&gs).unwrap()) as Box, + _ => Box::new(()) as Box, + }); + drop(r); + peak + }; + let small_clip = constant_clip(4, 300, 200); + let mut cases: Vec<(&str, &str, Vec, &IntParams<'_>)> = vec![ + ("int_video", c::CAP_VIDEO_INT_MOTION, read("int_video/int_video_bitstream.wiv1"), &none), + ("video_real", c::CAP_VIDEO_INT_MOTION, read("int_video/video_real_bitstream.wiv1"), &none), + ("4 frames of 300 × 200, a constant stream", c::CAP_VIDEO_INT_MOTION, small_clip, &none), + ("video_nk", c::CAP_VIDEO_INT_HYPER, read("int_video/video_nk_bitstream.wiv1"), &hyp_p), + ("tiny", c::CAP_NEURAL_INT_HYPER, read("int_hyper/tiny_bitstream.wih1"), &hyp_p), + ("audio", c::CAP_AUDIO_INT_CODEC, read("int_audio/audio_bitstream.wia1"), &aud), + ("splat", c::CAP_SPLAT_INT_CODEC, read("int_splat/splat.wis1"), &spl), + ]; + if !cfg!(debug_assertions) { + let big = constant_clip(16, 4729, 4729); + assert!(big.len() < 4096, "the clip at the cap is coded in {} bytes", big.len()); + cases.push(("16 frames of 4729 × 4729 in about 3 KB", c::CAP_VIDEO_INT_MOTION, big, &none)); + } + for (what, cap, payload, params) in cases { + let cost = declared_cost(cap, &payload, params).unwrap_or_else(|e| panic!("{what}: {e}")); + // The header-only reading agrees where the header fixes the output. + let size = wai::container::declared_size(cap, &payload).unwrap().unwrap(); + assert_eq!(size.symbols, cost.symbols, "{what}: symbols"); + if let Some(o) = size.output_bytes { + assert_eq!(o, cost.output_bytes, "{what}: output bytes"); + } + let set = set_bytes(cap); + let (r, peak) = with_policy(Policy::reference(), || peak_during(|| decode_int(cap, &payload, params))); + r.unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(peak >= cost.output_bytes, "{what}: the decode held {peak} bytes, under its {} output bytes", cost.output_bytes); + assert!(peak <= cost.working_bytes + set, "{what}: the decode held {peak} bytes, over the declared {} and the set's {set}", + cost.working_bytes); + if cap == c::CAP_VIDEO_INT_MOTION { + assert!(cost.working_bytes - peak <= 4096, "{what}: declared {}, held {peak}", cost.working_bytes); + } + println!("{what}: declared {} working bytes ({} output, {} symbols); held {peak}", cost.working_bytes, cost.output_bytes, cost.symbols); + } +} diff --git a/wai/wai-rs/tests/int_payload_preflight.rs b/wai/wai-rs/tests/int_payload_preflight.rs index 8986bd3af..7e0feabf3 100644 --- a/wai/wai-rs/tests/int_payload_preflight.rs +++ b/wai/wai-rs/tests/int_payload_preflight.rs @@ -71,6 +71,29 @@ fn with(p: &[u8], fields: &[(usize, u32)]) -> Vec { const FEW_KB: usize = 16 << 10; +/// A rANS stream of `n` symbols, symbol `i` coded with `(start, freq)` from `sym(i)`, +/// from the state 2^31, so it ends canonically: what an encoder writes. +fn encode(n: u64, sym: impl Fn(u64) -> (u64, u64)) -> Vec { + const L: u64 = 1 << 31; + let mut state = L; + let mut renorm: Vec = Vec::new(); + let mut i = n; + while i > 0 { + i -= 1; + let (start, freq) = sym(i); + let x_max = ((L >> 16) << 32) * freq; + while state >= x_max { + renorm.push(state as u32); + state >>= 32; + } + state = ((state / freq) << 16) + (state % freq) + start; + } + renorm.reverse(); + let mut words = vec![state as u32, (state >> 32) as u32]; + words.extend(renorm); + words.iter().flat_map(|w| w.to_le_bytes()).collect() +} + #[test] fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() { use wai::codecs as c; @@ -138,8 +161,11 @@ fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() wire::write_wih1(&k) }; let wia1 = { + // The committed synthesis gives 16 samples per latent sample (512 → 8192), so + // t_out is what a latent of 4096 presents. let mut a = wire::parse_wia1(&au).unwrap(); a.lat_len = 1 << 12; + a.t_out = 16 << 12; a.rans = &ones[..]; wire::write_wia1(&a) }; @@ -168,6 +194,33 @@ fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() assert_eq!(r.map(|_| ()).map_err(|e| e.code()), Err("stream_exhausted"), "{what}"); } + // A WIA1 of 384 bytes whose stream is fully valid (8.4 million symbols of frequency + // 65535/65536) for a latent of 16 × 524287 that presents one sample: its synthesis + // would hold gigabytes for that one sample. The latent is longer than t_out needs, + // so the plan refuses it (geometry_mismatch) before a symbol is decoded, and the + // declared cost refuses it alike. + let wia1 = { + let mut a = wire::parse_wia1(&au).unwrap(); + let n = a.n_ch as usize; + a.lat_len = 524_287; + a.t_out = 1; + a.offset = 0; + a.median_q = vec![0; n]; + a.cdfs = vec![vec![0, 65_535, 65_536]; n]; + let rans = encode(n as u64 * 524_287, |_| (0, 65_535)); + let mut b = a.clone(); + b.rans = &rans; + wire::write_wia1(&b) + }; + assert!(wia1.len() <= 400, "the payload is {} bytes", wia1.len()); + assert!(wai::container::check_int_payload_framing(c::CAP_AUDIO_INT_CODEC, &wia1).is_ok(), "it parses"); + let (r, largest) = largest_in(|| decode_int(c::CAP_AUDIO_INT_CODEC, &wia1, &aud)); + let bound = FEW_KB.max(4 * model.len()); + assert!(largest <= bound, "the 384-byte WIA1: refusing it allocated {largest} bytes at once, over {bound}"); + assert_eq!(r.map(|_| ()).map_err(|e| e.code()), Err("geometry_mismatch"), "the 384-byte WIA1"); + assert_eq!(wai::int_payload::declared_cost(c::CAP_AUDIO_INT_CODEC, &wia1, &aud).map(|_| ()).map_err(|e| e.code()), + Err("geometry_mismatch"), "the declared cost refuses it too"); + // The tools' JSON form, read by int_video::VideoJson (the converter's and the JSON // decode's reader): a header past the caps is refused before anything is sized // from it, on every target. A side of 2^31 made the frame size overflow and the diff --git a/wai/wai-web/src/int_payload.rs b/wai/wai-web/src/int_payload.rs index 258aff37e..63d1964c8 100644 --- a/wai/wai-web/src/int_payload.rs +++ b/wai/wai-web/src/int_payload.rs @@ -12,8 +12,9 @@ use std::collections::BTreeMap; -use wai::int_payload::{decode_int, IntDecoded}; +use wai::int_payload::{decode_int, DeclaredCost, IntDecoded}; use wai::pin::HeldBytes; +use wasm_bindgen::prelude::*; /// Decode `payload` through `cap` against the set members `set` (name, bytes): the /// pure-Rust half of the bindings, which the native tests run. @@ -24,6 +25,65 @@ pub(crate) fn decode(cap: &str, payload: &[u8], set: &[(&str, &[u8])]) -> Result decode_int(cap, payload, ¶ms).map_err(|e| e.to_string()) } +/// What decoding `payload` through `cap` against the set members `set` will cost, +/// before any symbol is decoded (`int_payload::declared_cost`): the pure-Rust half of +/// `intPayloadCost`. +pub(crate) fn cost(cap: &str, payload: &[u8], set: &[(&str, &[u8])]) -> Result { + let files: BTreeMap = set.iter().map(|(k, v)| (k.to_string(), *v)).collect(); + let params = wai::int_payload::resolve(cap, None, &[HeldBytes::Set { listing: b"", files: &files }], None) + .map_err(|e| format!("{}: {e}", e.code()))?; + wai::int_payload::declared_cost(cap, payload, ¶ms).map_err(|e| e.to_string()) +} + +/// What a decode will cost, read before it starts (`intPayloadCost`): the output's +/// bytes, the symbols the streams code, and the most bytes the reference decode holds +/// at once (`extensions/integer-payloads.md` §7, *Memory and work*). A page applies its +/// own size policy to these and does not decode a payload past it. +#[wasm_bindgen] +pub struct IntPayloadCost { + output_bytes: f64, + symbols: f64, + working_bytes: f64, +} + +#[wasm_bindgen] +impl IntPayloadCost { + #[wasm_bindgen(getter, js_name = outputBytes)] + pub fn output_bytes(&self) -> f64 { + self.output_bytes + } + #[wasm_bindgen(getter)] + pub fn symbols(&self) -> f64 { + self.symbols + } + #[wasm_bindgen(getter, js_name = workingBytes)] + pub fn working_bytes(&self) -> f64 { + self.working_bytes + } +} + +/// The declared cost of decoding `payload` through `capability`, before decoding it. +/// The set members are given as the decoders take them: `g_s`, `h_s` and `entropy` +/// for `wai.neural.int_hyper` and `wai.video.int_hyper`, `model` for +/// `wai.audio.int_codec`, `g_s` for `wai.splat.int_codec`, none for +/// `wai.video.int_motion`. A refusal's message starts with its code. +#[wasm_bindgen(js_name = intPayloadCost)] +pub fn int_payload_cost( + capability: &str, payload: &[u8], g_s: Option>, h_s: Option>, entropy: Option>, + model: Option>, +) -> Result { + let (g, h, e, m) = (g_s.unwrap_or_default(), h_s.unwrap_or_default(), entropy.unwrap_or_default(), model.unwrap_or_default()); + let hyper = int_hyper_set(&g, &h, &e); + let set: Vec<(&str, &[u8])> = match capability { + "wai.neural.int_hyper" | "wai.video.int_hyper" => hyper.to_vec(), + "wai.audio.int_codec" => vec![("model.wam", &m)], + "wai.splat.int_codec" => vec![("g_s.whm", &g)], + _ => vec![], + }; + let c = cost(capability, payload, &set).map_err(|e| JsError::new(&e))?; + Ok(IntPayloadCost { output_bytes: c.output_bytes as f64, symbols: c.symbols as f64, working_bytes: c.working_bytes as f64 }) +} + /// The conditional codec's three set members, from the host's bytes. pub(crate) fn int_hyper_set<'a>(g_s: &'a [u8], h_s: &'a [u8], entropy: &'a [u8]) -> [(&'static str, &'a [u8]); 3] { [("entropy.json", entropy), ("g_s.whm", g_s), ("h_s.whm", h_s)] @@ -79,4 +139,23 @@ mod tests { let refused = decode("wai.video.int_motion", &read("int_video/int_video_bitstream.json"), &[]).unwrap_err(); assert!(refused.starts_with("bad_magic"), "{refused}"); } + + /// The browser's declared cost is the library's: the committed clip's output is its + /// frames' bytes, its symbols one per byte, and its working set the clip and the + /// frame list beside the parsed table and motion vectors; a JSON form is refused. + #[test] + fn the_declared_cost_is_read_before_decoding() { + let k0 = read("int_video/int_video_bitstream.wiv1"); + let c = cost("wai.video.int_motion", &k0, &[]).unwrap(); + let clip: usize = match decode("wai.video.int_motion", &k0, &[]).unwrap() { + IntDecoded::Video { frames, .. } => frames.iter().map(Vec::len).sum(), + other => panic!("{other:?}"), + }; + assert_eq!((c.output_bytes, c.symbols), (clip as u64, clip as u64)); + assert!(c.working_bytes > c.output_bytes); + let (g, h, e) = (read("int_hyper/tiny_g_s.bin"), read("int_hyper/tiny_h_s.bin"), read("int_hyper/tiny_entropy.json")); + let img = cost("wai.neural.int_hyper", &read("int_hyper/tiny_bitstream.wih1"), &int_hyper_set(&g, &h, &e)).unwrap(); + assert_eq!(img.output_bytes, 16 * 16 * 3); + assert!(cost("wai.video.int_motion", &read("int_video/int_video_bitstream.json"), &[]).unwrap_err().starts_with("bad_magic")); + } } -- 2.54.0 (Apple Git-157) From 583915507c22fbd124ef1203f4faf94e6e4c7a76 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 21:07:11 -0400 Subject: [PATCH 23/34] wai: a one-frame WIV1 has one header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-frame clip codes no motion vector, so its mv_precision and block changed nothing and let one clip have many headers. The parser (and the Python verifier) now requires mv_precision 1 and block 1 for a one-frame clip (field_range), after the other header fields; the converters write them so. The one-frame end-state fixture is regenerated with that header, and two fixtures refuse block 4 and half-pel. integer-payloads.md §2 now says that a payload's hash identifies the payload, not its output: table entries the stream never selects code the same clip in other bytes, so one output is identified by its decoded digest (a clip's clip_hash). Test: int_payload::tests::a_one_frame_clip_has_one_header (the reviewer's six headers; the converter's canonical header), and the fixtures wiv1_one_frame_block and wiv1_one_frame_mv_precision in both verifiers. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 6 ++++ wai/extensions/integer-payloads.md | 20 +++++++++--- .../int_payload_verify.py | 5 +++ .../refusals/wiv1_one_frame_block.bin | Bin 0 -> 64 bytes .../refusals/wiv1_one_frame_end_state.bin | Bin 496 -> 496 bytes .../refusals/wiv1_one_frame_mv_precision.bin | Bin 0 -> 64 bytes wai/int-payload-conformance/vectors.json | 16 ++++++++++ wai/wai-rs/src/bin/wai_int_payload_vectors.rs | 6 +++- wai/wai-rs/src/int_payload/mod.rs | 7 +++-- wai/wai-rs/src/int_payload/tests.rs | 29 ++++++++++++++++++ wai/wai-rs/src/int_payload/wire.rs | 5 +++ 11 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 wai/int-payload-conformance/refusals/wiv1_one_frame_block.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_one_frame_mv_precision.bin diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index d1197878c..264c898a5 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -92,6 +92,12 @@ wire format may change and such changes are recorded here. symbol is decoded, and a convolution's padded side is capped. A set's entropy tables obey §2's length rule (3 to 4097 entries). The parser reads a table of one per channel table by table. Fixtures pin each case. +- **One header for a one-frame clip.** A `WIV1` of one frame codes no motion + vector, so its `mv_precision` and `block` must be 1 (`field_range` otherwise); the + converters write them so. The extension's §2 says a payload's hash identifies the + payload, not its output (unused table entries, say, code the same clip in other + bytes), and that one output is identified by its decoded digest (a clip's + `clip_hash`). - **Canonical streams.** After its last symbol, each rANS stream of these payloads must have been read to its last word and end in the state `2^31`, where an encoder starts (`rans::RansDecoder::is_canonical_end`); otherwise the payload is refused diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 553dbaae6..e97d01128 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -81,6 +81,15 @@ escape of 4-bit groups): hashes. A stream that codes no symbol (the residual stream of a one-frame `WIV1` with keyframe 1) is the state `2^31` alone: the words `2^31` and `0`. +**One clip, several payloads.** These rules fix how a payload is read, not the +only payload of a decoded output: tables that differ in entries the stream never +selects (a symbol the clip does not use, say), or a different escape layout, code +the same output in other bytes. A payload's hash therefore identifies the payload, +not what it decodes to. Where one output must have one identity, it is identified by +the digest of its decoded output: for a clip, its frame-state-equivalence digests +(SPEC §7, a video receipt's `clip_hash`); for the other formats, their capabilities' +equivalence digests. + **CDF tables.** A table is `n` entries of `u32` with `3 ≤ n ≤ 4097`, `cdf[0] = 0`, `cdf[n − 1] = 65536` and every entry greater than the one before. These rules are what keep the symbol search and the state update above in range. @@ -112,13 +121,13 @@ end canonically (§2). Two conforming sinks refuse the same bytes with the same |---|---|---|---| | 0 | 4 | magic `WIV1` | | | 4 | 1 | version | `1` | -| 5 | 1 | `mv_precision` | `1` (whole-pel) or `2` (half-pel) | +| 5 | 1 | `mv_precision` | `1` (whole-pel) or `2` (half-pel); `1` when `n_frames` is 1 | | 6 | 1 | `keyframe` | `0` or `1` | | 7 | 1 | reserved | `0` | | 8 | 4 | `n_frames` | ≥ 1 | | 12 | 4 | `h` | ≥ 1 | | 16 | 4 | `w` | ≥ 1 | -| 20 | 4 | `block` | ≥ 1 | +| 20 | 4 | `block` | ≥ 1; `1` when `n_frames` is 1 | | 24 | 4 | `q` | 1..=65535 | | 28 | 4 | `offset` | `i32` | | 32 | 2 | `cdf_len` | 3..=4097 | @@ -129,7 +138,9 @@ end canonically (§2). Two conforming sinks refuse the same bytes with the same | … | 4 + `K` | keyframe 1 only: `u32 K`, then a `WIH1` payload | `K` is exactly the bytes that remain | The header's field rules are checked in the order `mv_precision`, `keyframe`, -`n_frames`, `h` and `w`, `block`, `q`, `cdf_len`. `keyframe` 0 is +`n_frames`, `h` and `w`, `block`, `q`, `cdf_len`, then, for a one-frame clip, +`mv_precision` 1 and `block` 1: such a clip codes no motion vector, so neither field +would change its decode, and they are fixed so that it has one header. `keyframe` 0 is `wai.video.int_motion`'s and 1 is `wai.video.int_hyper`'s; the other is `keyframe_capability_mismatch`. The frame `h·w·3`, the clip `n_frames·h·w·3`, the frame count `n_frames` and the motion-vector field `2·nb·(n_frames − 1)` are capped (§7), in @@ -462,7 +473,8 @@ capability of §1, and a sink refuses it (`bad_magic`). The converters `int-payload-conformance/int_payload_verify.py`) make explicit what the JSON leaves implicit: -- `WIV1`: an absent `mv_precision` is 1, and only 1 and 2 are converted; `mvs` has one +- `WIV1`: an absent `mv_precision` is 1, and only 1 and 2 are converted; a one-frame + clip is written with `mv_precision` 1 and `block` 1, whatever the JSON states; `mvs` has one list per frame, and the first is empty; a frame's list shorter than its block grid is padded with `(0, 0)`, the vector the JSON decoder read for a missing block; a list longer than the grid, or a vector outside `i16`, is refused; `rmax` is not carried. diff --git a/wai/int-payload-conformance/int_payload_verify.py b/wai/int-payload-conformance/int_payload_verify.py index 92d3912a9..d796b1f01 100644 --- a/wai/int-payload-conformance/int_payload_verify.py +++ b/wai/int-payload-conformance/int_payload_verify.py @@ -154,6 +154,8 @@ def parse_wiv1(p, want_keyframe=None): raise Refused("field_range", "q") if not 3 <= cdf_len <= 4097: raise Refused("field_range", "cdf_len") + if n_frames == 1 and (mvp != 1 or block != 1): + raise Refused("field_range", "a one-frame clip has mv_precision 1 and block 1") if want_keyframe is not None and want_keyframe != kf: raise Refused("keyframe_capability_mismatch", "keyframe") too_large([h, w, 3], MAX_TENSOR) @@ -278,6 +280,9 @@ def wiv1_from_json(j, keyframe=None): nf, h, w, block = j["n_frames"], j["h"], j["w"], j["block"] mvp = j.get("mv_precision", 1) require(mvp in (1, 2), "mv_precision is neither 1 nor 2") + if nf == 1: + # A one-frame clip's header fixes mv_precision 1 and block 1 (§3, §10). + mvp, block = 1, 1 nb = -(-h // block) * -(-w // block) mvs = j["mvs"] require(len(mvs) == nf and not mvs[0], "mvs is not one list per frame, the first empty") diff --git a/wai/int-payload-conformance/refusals/wiv1_one_frame_block.bin b/wai/int-payload-conformance/refusals/wiv1_one_frame_block.bin new file mode 100644 index 0000000000000000000000000000000000000000..ae614adc49c2f85e1b8c12cb3680180d4edeb587 GIT binary patch literal 64 ocmWId3^Qb8WMBXyIA(#effNIX4F(NB@&Jg%$iM-jfB+;10A(Qpg8%>k literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_one_frame_end_state.bin b/wai/int-payload-conformance/refusals/wiv1_one_frame_end_state.bin index 100217ac5eda50a222768915921fefcf0e1a50ad..6ba80e96b726c9c8be9913ea2b83f05efa9fd42d 100644 GIT binary patch delta 12 Tcmeys{DFCb2qWW0(c6pw9=QZB delta 12 Tcmeys{DFCb2qVWv(c6pw9^3>r diff --git a/wai/int-payload-conformance/refusals/wiv1_one_frame_mv_precision.bin b/wai/int-payload-conformance/refusals/wiv1_one_frame_mv_precision.bin new file mode 100644 index 0000000000000000000000000000000000000000..f8260e7fb3ed6707bc3c81ce5587be39593a9624 GIT binary patch literal 64 mcmWId3^Qb8Vqjooz{Efy1{M$j0u4ZX0EmGqIY1N;faCyV*aCq7 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/vectors.json b/wai/int-payload-conformance/vectors.json index 3c33218cd..6b96f1ec2 100644 --- a/wai/int-payload-conformance/vectors.json +++ b/wai/int-payload-conformance/vectors.json @@ -286,6 +286,22 @@ "layer": "decode", "set": {} }, + { + "name": "wiv1_one_frame_block", + "capability": "wai.video.int_motion", + "payload": "int-payload-conformance/refusals/wiv1_one_frame_block.bin", + "code": "field_range", + "layer": "parse", + "set": {} + }, + { + "name": "wiv1_one_frame_mv_precision", + "capability": "wai.video.int_motion", + "payload": "int-payload-conformance/refusals/wiv1_one_frame_mv_precision.bin", + "code": "field_range", + "layer": "parse", + "set": {} + }, { "name": "wiv1_one_frame_end_state", "capability": "wai.video.int_hyper", diff --git a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs index 35a68d52c..52d67829e 100644 --- a/wai/wai-rs/src/bin/wai_int_payload_vectors.rs +++ b/wai/wai-rs/src/bin/wai_int_payload_vectors.rs @@ -224,12 +224,16 @@ fn fixtures(t: &mut Tree) -> Vec { // from the state 2^31 + 1: the stream is the words [1, 4], and the decode ends at // 2^31 + 1. Encoded from 2^31, the canonical stream is [0, 4] (`grey_pixel`). fx(t, "wiv1_end_state", MOT, Some(grey_pixel(1)), None, "stream_noncanonical", "decode"); + // A one-frame clip's header fixes mv_precision 1 and block 1 (§3): the one-pixel + // clip with block 4, or half-pel, is refused at parse. + fx(t, "wiv1_one_frame_block", MOT, edit(&grey_pixel(0), &|b| set_u32(b, 20, 4)), None, "field_range", "parse"); + fx(t, "wiv1_one_frame_mv_precision", MOT, edit(&grey_pixel(0), &|b| b[5] = 2), None, "field_range", "parse"); // One frame, the nested keyframe: the residual stream codes no symbol, so it must be // the state 2^31 alone, and 2^31 + 1 is refused. let one = |state: u32| { let mut v = wire::parse_wiv1(&nk, Some(1)).expect("video_nk parses"); let rans: Vec = [state, 0].iter().flat_map(|w| w.to_le_bytes()).collect(); - v.n_frames = 1; + (v.n_frames, v.mv_precision, v.block) = (1, 1, 1); v.mvs.clear(); wire::write_wiv1(&wire::Wiv1 { rans: &rans, ..v }) }; diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index f763ea9d5..7d71c711d 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -669,13 +669,16 @@ pub fn wiv1_from_json(bs: &serde_json::Value, keyframe_wih1: Option<&[u8]>) -> R let j = crate::int_video::VideoJson::from_value(bs).map_err(json)?; let q = u32::try_from(j.q).ok().filter(|q| (1..=wire::MAX_Q).contains(q)).ok_or_else(|| json("q is outside 1..=65535"))?; table_len(j.cdf.len())?; + // A one-frame clip carries no motion vector: its header is written with + // mv_precision 1 and block 1, the values the format fixes for it. + let one = j.n_frames == 1; let v = wire::Wiv1 { - mv_precision: j.mv_precision, + mv_precision: if one { 1 } else { j.mv_precision }, keyframe: keyframe_wih1.is_some() as u8, n_frames: to_u32(j.n_frames, "n_frames")?, h: to_u32(j.h, "h")?, w: to_u32(j.w, "w")?, - block: to_u32(j.block, "block")?, + block: if one { 1 } else { to_u32(j.block, "block")? }, q, offset: j.offset, cdf: j.cdf.clone(), diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 863bec111..9e770d972 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -712,3 +712,32 @@ fn a_pin_by_another_identity_does_not_reach_a_revoked_set() { let granting = Authority { revoked: vec![], granted: vec![sha256_hex(&four)], held_to: true }; assert_eq!(code(resolve(HYP, Some(&pin4), &held4, Some(&granting))), "pinned"); } + +/// A one-frame clip has one header (§3): of the same one-pixel clip under six +/// `(mv_precision, block)` pairs, only `(1, 1)` parses; the others are `field_range`. +/// The converter writes a one-frame JSON clip with that header whatever the JSON +/// states, and the result decodes as the JSON does. +#[cfg(feature = "video_int")] +#[test] +fn a_one_frame_clip_has_one_header() { + let rans: Vec = [0u32, 4].iter().flat_map(|w| w.to_le_bytes()).collect(); + let clip = |mv_precision: u8, block: u32| wire::write_wiv1(&wire::Wiv1 { + mv_precision, keyframe: 0, n_frames: 1, h: 1, w: 1, block, q: 1, offset: 0, + cdf: vec![0, 32_768, 49_152, 65_536], mvs: vec![], rans: &rans, wih1: None, + }); + for (mvp, block) in [(1u8, 1u32), (1, 4), (1, 7), (1, u32::MAX), (2, 1), (2, 1 << 31)] { + let got = wire::parse_wiv1(&clip(mvp, block), Some(0)).map(|_| ()).map_err(|e| e.code()); + assert_eq!(got, if (mvp, block) == (1, 1) { Ok(()) } else { Err("field_range") }, "mv_precision {mvp}, block {block}"); + } + let js = serde_json::json!({ + "n_frames": 1, "h": 1, "w": 1, "block": 8, "mv_precision": 2, "q": 1, "offset": 0, + "cdf": [0, 32_768, 49_152, 65_536], "mvs": [[]], "rans_b64": "AAAAAAQAAAA=" + }); + let p = wiv1_from_json(&js, None).unwrap(); + let v = wire::parse_wiv1(&p, Some(0)).unwrap(); + assert_eq!((v.mv_precision, v.block), (1, 1)); + match decode_int(crate::codecs::CAP_VIDEO_INT_MOTION, &p, &IntParams::none()) { + Ok(IntDecoded::Video { frames, .. }) => assert_eq!(frames, crate::int_video::decode(&js).unwrap().frames), + other => panic!("{other:?}"), + } +} diff --git a/wai/wai-rs/src/int_payload/wire.rs b/wai/wai-rs/src/int_payload/wire.rs index 022221a2f..ba24a184d 100644 --- a/wai/wai-rs/src/int_payload/wire.rs +++ b/wai/wai-rs/src/int_payload/wire.rs @@ -438,6 +438,11 @@ pub fn parse_wiv1(p: &[u8], want_keyframe: Option) -> Result, WireE if (cdf_len as usize) < CDF_LEN_MIN || cdf_len as usize > CDF_LEN_MAX { return Err(err(Refusal::FieldRange, "cdf_len is outside 3..=4097")); } + // A one-frame clip codes no motion vector, so neither field would change its + // decode; they are fixed so that such a clip has one header. + if n_frames == 1 && (mv_precision != 1 || block != 1) { + return Err(err(Refusal::FieldRange, "a one-frame clip has mv_precision 1 and block 1")); + } if let Some(k) = want_keyframe { if k != keyframe { return Err(err(Refusal::KeyframeCapabilityMismatch, "the keyframe kind is not the capability's")); -- 2.54.0 (Apple Git-157) From 13936959b2e59f7de8d4081b108685ef5740b565 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 21:08:21 -0400 Subject: [PATCH 24/34] wai: the splat converter carries integer ranges exactly A JSON integer range past 2^53 was read through f64 and rounded (2^60 + 1 became 2^60), so the converter wrote a range the JSON did not state. An integer Number is now read only where an f64 holds it exactly, and then must be a binary32 value like any range; otherwise it is refused (field_range), as the Python verifier refuses it. Test: int_payload::tests::splat_integer_ranges_are_carried_exactly (2^60 converts exactly; 2^60 + 1 and its negation are refused). Co-Authored-By: Claude Opus 5.5 --- wai/extensions/integer-payloads.md | 3 ++- wai/wai-rs/src/int_payload/tests.rs | 20 ++++++++++++++++++++ wai/wai-rs/src/int_splat.rs | 14 ++++++++++++-- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index e97d01128..35495a4db 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -483,7 +483,8 @@ implicit: - `WIA1`: `sr` is the export's `sample_rate` (in `audio.json`, which the bitstream does not carry); `lo` is the symbol offset; every channel's table must have the first table's length; `afrac` is carried and must be the model's. -- `WIS1`: the ranges `lo` and `hi` must each be a binary32 value, carried exactly; +- `WIS1`: the ranges `lo` and `hi` must each be a binary32 value, carried exactly (a + JSON integer included: `2^60 + 1` is refused, not rounded to `2^60`); `afrac` is carried and must be the model's. A payload and the JSON it is converted from decode to the same bytes; the conformance diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 9e770d972..e13c914ee 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -741,3 +741,23 @@ fn a_one_frame_clip_has_one_header() { other => panic!("{other:?}"), } } + +/// An integer range is carried only where it is exactly a binary32 value: `2^60` is, +/// and converts to exactly that; `2^60 + 1` is not (an f64 would round it to `2^60`), +/// and is refused, as the Python verifier refuses it. +#[cfg(feature = "splat_int")] +#[test] +fn splat_integer_ranges_are_carried_exactly() { + let text = String::from_utf8(read("int_splat/splat.json")).unwrap(); + let with = |n: &str| { + let mut j: serde_json::Value = serde_json::from_str(&text).unwrap(); + let big: serde_json::Value = serde_json::from_str(n).unwrap(); + j["lo"][0] = big.clone(); + j["hi"][0] = big; + wis1_from_json(&j) + }; + let p = with("1152921504606846976").unwrap(); + assert_eq!(wire::parse_wis1(&p).unwrap().ranges[0], ((1u64 << 60) as f32, (1u64 << 60) as f32)); + assert_eq!(with("1152921504606846977").unwrap_err().code(), "field_range"); + assert_eq!(with("-1152921504606846977").unwrap_err().code(), "field_range"); +} diff --git a/wai/wai-rs/src/int_splat.rs b/wai/wai-rs/src/int_splat.rs index 1be9d5e4b..1518b5454 100644 --- a/wai/wai-rs/src/int_splat.rs +++ b/wai/wai-rs/src/int_splat.rs @@ -64,7 +64,8 @@ pub struct SplatJson { pub cdfs: Vec>, pub offsets: Vec, pub median_q: Vec, - /// Per attribute, `(lo, hi)` as the JSON carries them. + /// Per attribute, `(lo, hi)` as the JSON carries them; an integer an `f64` does not + /// hold exactly is refused. pub ranges: Vec<(f64, f64)>, pub rans: Vec, } @@ -83,7 +84,16 @@ impl SplatJson { let cdfs = bs["cdfs"].as_array().ok_or("cdfs")?.iter() .map(|row| row.as_array().and_then(|r| r.iter().map(|n| n.as_i64().and_then(|v| i32::try_from(v).ok())).collect::>>())) .collect::>>().ok_or("cdfs holds a value outside i32")?; - let f = |k: &'static str| bs[k].as_array().ok_or(k)?.iter().map(Value::as_f64).collect::>>().ok_or(k); + // A range as the JSON states it: an integer only where an f64 holds it exactly + // (so 2^60 + 1 is refused rather than rounded to 2^60), a decimal as read. + let exact = |v: &Value| -> Option { + let int = v.as_i64().map(i128::from).or(v.as_u64().map(i128::from)); + match int { + Some(i) => Some(i as f64).filter(|&f| f as i128 == i), + None => v.as_f64(), + } + }; + let f = |k: &'static str| bs[k].as_array().ok_or(k)?.iter().map(exact).collect::>>().ok_or(k); let (lo, hi) = (f("lo")?, f("hi")?); if lo.len() != n_attr || hi.len() != n_attr { return Err("lo and hi are not one per attribute"); -- 2.54.0 (Apple Git-157) From 02e5326c2ef69b01b96d232d6916ff29c825b928 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 21:10:41 -0400 Subject: [PATCH 25/34] wai: the debug JSON readers refuse instead of panicking wai-web's decodeIntHyper and decodeIntHyperBin read the conditional codec's JSON through panicking readers and the unchecked decode, so {} or a hyper-latent of 2^61 elements panicked the page. - IntModel::try_from_json and CondBitstream::try_from_value read what from_json and from_value read (an absent number as 0, as before) and refuse a member of the wrong type, a size that does not fit, or an unknown layer type. - The browser's JSON decode goes through them, CondEntropy::try_from_value and decode_conditional_checked. - int_video::VideoJson refuses a frame whose motion vectors are not a list instead of reading it as empty; the Python converter refuses it too. Tests: wai-web neural_int::tests::the_debug_json_decode_refuses_instead_of_panicking (the reviewer's {} and 2^61 cases, too many z channels, and the committed JSON models decoding alike) and int_payload::tests::a_frame_entry_that_is_not_a_list_is_refused. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-web-wasm.yml | 2 +- wai/CHANGELOG.md | 6 ++ .../int_payload_verify.py | 1 + wai/wai-rs/src/int_hyper_synth.rs | 64 ++++++++++++++++++- wai/wai-rs/src/int_payload/tests.rs | 14 ++++ wai/wai-rs/src/int_video.rs | 2 +- wai/wai-web/src/neural_int.rs | 62 +++++++++++++++--- 7 files changed, 140 insertions(+), 11 deletions(-) diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 592d0d125..cd648b6c9 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -98,7 +98,7 @@ jobs: run: | set -euo pipefail cargo test --locked --lib 2>&1 | tee "$RUNNER_TEMP/wai-web-native.log" - grep -q "test result: ok. 7 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" + grep -q "test result: ok. 8 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" - name: wasm32 int_synth decode equals the frozen native corpus shell: bash working-directory: wai/wai-web diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 264c898a5..a2d9671f2 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -127,6 +127,12 @@ wire format may change and such changes are recorded here. vectors than blocks, a vector outside `i16`, a zero size, and a size past the caps. jwp-receipts §2.1's object content hash moves to its own module and feature (`jwp_hash`), which `receipt_claims` enables; `claim_receipt::object_content_hash` re-exports it, and no corpus changed. +- **JSON readers that refuse.** `IntModel::try_from_json` and + `CondBitstream::try_from_value` read the conditional codec's debug JSON without + panicking; `wai-web`'s `decodeIntHyper` and `decodeIntHyperBin` read it through + them, `CondEntropy::try_from_value` and `decode_conditional_checked`, so an empty + model or bitstream, or sizes past the caps, are refused. `int_video::VideoJson` + refuses a frame whose motion vectors are not a list. - **Browser.** `wai-web` decodes the four formats (`decodeWiv1`, `decodeWih1`, `decodeWia1`, `decodeWis1`); the entry points that read the JSON forms are for debugging and conversion. diff --git a/wai/int-payload-conformance/int_payload_verify.py b/wai/int-payload-conformance/int_payload_verify.py index d796b1f01..abeb19432 100644 --- a/wai/int-payload-conformance/int_payload_verify.py +++ b/wai/int-payload-conformance/int_payload_verify.py @@ -290,6 +290,7 @@ def wiv1_from_json(j, keyframe=None): struct.pack(" Result { } impl IntModel { + /// [`Self::from_json`], refusing rather than panicking on any JSON. It reads what + /// `from_json` reads, an absent number as 0 and an absent list as empty, as that + /// does, but refuses a member of the wrong type, a size that does not fit (`u32` + /// for `afrac`, `gf`, `shift` and a shift, `usize` for the rest), and a layer type + /// it does not know. A layer's parameters are checked against the running shapes + /// when the network is planned ([`decode_conditional_checked`]). + pub fn try_from_json(v: &Value) -> Result { + let word = |o: &Value, k: &'static str| match o.get(k) { + None => Ok(0), + Some(n) => n.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or(k), + }; + let size = |o: &Value, k: &'static str| match o.get(k) { + None => Ok(0), + Some(n) => n.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or(k), + }; + let ints = |o: &Value, k: &'static str| -> Result, &'static str> { + match o.get(k) { + None => Ok(Vec::new()), + Some(a) => a.as_array().ok_or(k)?.iter().map(Value::as_i64).collect::>().ok_or(k), + } + }; + let layers = v.get("layers").and_then(Value::as_array).ok_or("layers")?.iter().map(|l| { + Ok(match l.get("type").and_then(Value::as_str).ok_or("a layer's type")? { + "convT" => IntLayer::ConvT { + out_ch: size(l, "out_ch")?, k: size(l, "k")?, stride: size(l, "stride")?, pad: size(l, "pad")?, + opad: size(l, "opad")?, weight: ints(l, "weight")?, mult: ints(l, "mult")?, bias: ints(l, "bias")?, + }, + "conv" => IntLayer::Conv { + out_ch: size(l, "out_ch")?, k: size(l, "k")?, stride: size(l, "stride")?, pad: size(l, "pad")?, + weight: ints(l, "weight")?, mult: ints(l, "mult")?, bias: ints(l, "bias")?, + }, + "igdn" => IntLayer::Igdn { gamma: ints(l, "gamma")?, beta: ints(l, "beta")? }, + "relu" => IntLayer::Relu, + "leaky" => IntLayer::Leaky { + slope_q: match l.get("slope_q") { None => 655, Some(q) => q.as_i64().ok_or("slope_q")? }, + slope_frac: word(l, "slope_frac")?, + }, + _ => return Err("an unknown layer type"), + }) + }).collect::, &'static str>>()?; + Ok(Self { afrac: word(v, "afrac")?, gf: word(v, "gf")?, shift: word(v, "shift")?, layers }) + } + /// Parse the export tool's JSON model (`int_synth_sota_model.json` / `int_hs_model.json`). + /// Panics on a JSON the tool would not write; [`Self::try_from_json`] refuses + /// instead. pub fn from_json(v: &Value) -> Self { let layers = v["layers"] .as_array() @@ -736,7 +781,24 @@ fn bytes(v: &Value) -> Vec { } impl CondBitstream { - /// Parse the bitstream the export tool emits (`cond_bitstream.json`). + /// [`Self::from_value`], refusing rather than panicking on any JSON: every member + /// present, a size an integer that fits `usize`, a stream an array of bytes. A + /// reader of JSON it did not write (the browser's debug decode) uses this, and + /// decodes with [`decode_conditional_checked`]. + pub fn try_from_value(v: &Value) -> Result { + let dim = |k: &'static str| v.get(k).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or(k); + let bytes = |k: &'static str| -> Result, &'static str> { + v.get(k).and_then(Value::as_array).ok_or(k)?.iter() + .map(|b| b.as_u64().and_then(|b| u8::try_from(b).ok())).collect::>().ok_or(k) + }; + Ok(Self { + z_rans: bytes("z_rans")?, n_z: dim("n_z")?, zh: dim("zh")?, zw: dim("zw")?, + m: dim("m")?, sh: dim("sh")?, sw: dim("sw")?, rans: bytes("rans")?, + }) + } + + /// Parse the bitstream the export tool emits (`cond_bitstream.json`). Panics on a + /// JSON the tool would not write; [`Self::try_from_value`] refuses instead. pub fn from_value(v: &Value) -> Self { Self { z_rans: bytes(&v["z_rans"]), diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index e13c914ee..445557452 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -761,3 +761,17 @@ fn splat_integer_ranges_are_carried_exactly() { assert_eq!(with("1152921504606846977").unwrap_err().code(), "field_range"); assert_eq!(with("-1152921504606846977").unwrap_err().code(), "field_range"); } + +/// The JSON reader refuses a frame whose motion vectors are not a list (a number, an +/// object), as the Python converter does, instead of reading it as an empty list. +#[cfg(feature = "video_int")] +#[test] +fn a_frame_entry_that_is_not_a_list_is_refused() { + let mut js = jload("int_video/int_video_bitstream.json"); + assert!(crate::int_video::VideoJson::from_value(&js).is_ok()); + for bad in [serde_json::json!(5), serde_json::json!({"dy": 0}), serde_json::json!(null)] { + js["mvs"][1] = bad.clone(); + assert!(crate::int_video::VideoJson::from_value(&js).is_err(), "{bad}"); + assert_eq!(wiv1_from_json(&js, None).unwrap_err().code(), "field_range", "{bad}"); + } +} diff --git a/wai/wai-rs/src/int_video.rs b/wai/wai-rs/src/int_video.rs index bb6f3cf45..92773bec7 100644 --- a/wai/wai-rs/src/int_video.rs +++ b/wai/wai-rs/src/int_video.rs @@ -221,7 +221,7 @@ impl VideoJson { } let mut mvs = Vec::with_capacity(nb * (nf - 1)); for fr in lists.iter().skip(1) { - let list = fr.as_array().map(Vec::as_slice).unwrap_or_default(); + let list = fr.as_array().ok_or("a frame's motion vectors are not a list")?; if list.len() > nb { return Err("a frame carries more motion vectors than blocks"); } diff --git a/wai/wai-web/src/neural_int.rs b/wai/wai-web/src/neural_int.rs index a2bc5dffa..46524534b 100644 --- a/wai/wai-web/src/neural_int.rs +++ b/wai/wai-web/src/neural_int.rs @@ -86,7 +86,8 @@ pub fn decode_int_hyper( let parse = |b: &[u8]| -> Result { serde_json::from_slice(b).map_err(|e| JsError::new(&e.to_string())) }; - cond_decode(IntModel::from_json(&parse(g_s_json)?), IntModel::from_json(&parse(h_s_json)?), entropy_json, bitstream_json) + let net = |b: &[u8]| IntModel::try_from_json(&parse(b)?).map_err(|k| JsError::new(&format!("model_mismatch: a model's `{k}`"))); + cond_decode(net(g_s_json)?, net(h_s_json)?, entropy_json, bitstream_json) } /// Same decode from the **compact binary** model (`*_model.bin`, int12 weights as @@ -107,16 +108,61 @@ fn cond_decode( g_s: wai::int_hyper_synth::IntModel, h_s: wai::int_hyper_synth::IntModel, entropy_json: &[u8], bitstream_json: &[u8], ) -> Result { - use wai::int_hyper_synth::{decode_conditional, CondBitstream, CondEntropy}; - let parse = |b: &[u8]| -> Result { - serde_json::from_slice(b).map_err(|e| JsError::new(&e.to_string())) - }; - let ent = CondEntropy::from_value(&parse(entropy_json)?); - let bs = CondBitstream::from_value(&parse(bitstream_json)?); - let (rgb, height, width) = decode_conditional(&g_s, &h_s, &ent, &bs).map_err(JsError::new)?; + let (rgb, height, width) = cond_decode_checked(&g_s, &h_s, entropy_json, bitstream_json).map_err(|e| JsError::new(&e))?; Ok(DecodedImage { rgb, width: width as u32, height: height as u32 }) } +/// The debug JSON decode, through the checked readers (`CondEntropy::try_from_value`, +/// `CondBitstream::try_from_value`) and `decode_conditional_checked`, so a JSON the +/// export tool did not write is refused, never a panic: the pure-Rust half the native +/// tests run. +pub(crate) fn cond_decode_checked( + g_s: &wai::int_hyper_synth::IntModel, h_s: &wai::int_hyper_synth::IntModel, entropy_json: &[u8], bitstream_json: &[u8], +) -> Result<(Vec, usize, usize), String> { + use wai::int_hyper_synth::{decode_conditional_checked, CondBitstream, CondEntropy}; + let parse = |b: &[u8]| serde_json::from_slice::(b).map_err(|e| e.to_string()); + let ent = CondEntropy::try_from_value(&parse(entropy_json)?).map_err(|e| format!("model_mismatch: {e}"))?; + let bs = CondBitstream::try_from_value(&parse(bitstream_json)?).map_err(|k| format!("field_range: `{k}`"))?; + decode_conditional_checked(g_s, h_s, &ent, &bs.stream()).map_err(|r| r.to_string()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wai::int_hyper_synth::IntModel; + + fn read(rel: &str) -> Vec { + let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../wai-rs/tests/vectors").join(rel); + std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}", p.display())) + } + + /// The debug JSON decode refuses, rather than panics on, a JSON the export tool + /// did not write: an empty model or bitstream, a hyper-latent of 2^61 elements, + /// more hyper-latent channels than tables. The committed tiny codec decodes. + #[test] + fn the_debug_json_decode_refuses_instead_of_panicking() { + assert!(IntModel::try_from_json(&json!({})).is_err()); + let (g, h) = (IntModel::from_bytes(&read("int_hyper/tiny_g_s.bin")).unwrap(), IntModel::from_bytes(&read("int_hyper/tiny_h_s.bin")).unwrap()); + let ent = read("int_hyper/tiny_entropy.json"); + let got = super::cond_decode_checked(&g, &h, &ent, &read("int_hyper/tiny_bitstream.json")).unwrap(); + assert_eq!((got.1, got.2), (16, 16)); + // The JSON models read through the checked reader decode alike. + let net = |rel: &str| IntModel::try_from_json(&serde_json::from_slice(&read(rel)).unwrap()).unwrap(); + let from_json = super::cond_decode_checked(&net("int_hyper/tiny_g_s.json"), &net("int_hyper/tiny_h_s.json"), &ent, &read("int_hyper/tiny_bitstream.json")).unwrap(); + assert_eq!(from_json, got); + let bs = |v: serde_json::Value| serde_json::to_vec(&v).unwrap(); + let zeros = vec![0u8; 8]; + for (what, b) in [ + ("{}", bs(json!({}))), + ("n_z·zh·zw = 2^61", bs(json!({"z_rans": zeros, "rans": zeros, "n_z": 1u64 << 20, "zh": 1u64 << 20, "zw": 1u64 << 21, "m": 1, "sh": 1, "sw": 1}))), + ("more z channels than tables", bs(json!({"z_rans": zeros, "rans": zeros, "n_z": 4096, "zh": 1, "zw": 1, "m": 1, "sh": 1, "sw": 1}))), + ] { + let r = std::panic::catch_unwind(|| super::cond_decode_checked(&g, &h, &ent, &b)); + assert!(matches!(r, Ok(Err(_))), "{what}: {r:?}"); + } + } +} + // ---- int_entropy: the deterministic scale→bucket primitive, in the browser ---- /// Precompute a CompressAI-style ascending `scale_table` as fixed-point integer -- 2.54.0 (Apple Git-157) From bdb366fd64ff8bdf632c7c5c0840150073de0da3 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 21:13:36 -0400 Subject: [PATCH 26/34] wai: an encrypted integer payload is checked after decryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emit paths parsed an encrypted envelope's payload as the integer format, so a ciphertext WIV1 could not be written. Under SPEC §6 the payload is ciphertext and a sink decrypts it before dispatch (§6.4). Wai::check_int_payload_framing and WaiMulti::check_int_payload_framing (and so to_emit_bytes and the C packers), and wai-web's pack and packMulti, skip the check when the manifest's security.encryption is neither absent nor "none" (container::manifest_v2_plaintext reads a WAI2's security member); the decode checks the plaintext. wai-rs has no decrypting path, so no check runs after decryption here. `wai inspect` reports such a payload as encrypted rather than as parsed. The extension and the CHANGELOG say so. Tests: container::tests::an_encrypted_integer_payload_is_emitted (WAI1 and WAI2; the same bytes in plaintext are refused), the wai-web writers' test (extended), cli_wrap_intent (inspect, extended). Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 6 ++-- wai/extensions/integer-payloads.md | 5 ++- wai/wai-rs/src/bin/wai.rs | 5 ++- wai/wai-rs/src/container.rs | 51 +++++++++++++++++++++++++++-- wai/wai-rs/tests/cli_wrap_intent.rs | 9 +++++ wai/wai-web/src/lib.rs | 15 +++++++++ 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index a2d9671f2..738b4e0bd 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -39,8 +39,10 @@ wire format may change and such changes are recorded here. parses the payload with the wire parser a sink runs and the capability's keyframe kind, so it checks every parse rule of the extension's §2–§6 and leaves to the decode only what needs a parameter set or the streams (§7); `wai inspect` reports - what it finds. A refused emit is the new `EmitError::IntPayload`. No committed - envelope or conformance corpus carried such a payload. + what it finds. A refused emit is the new `EmitError::IntPayload`. An encrypted + envelope's payload is ciphertext (SPEC §6), so the emit paths skip it and a sink + checks the plaintext after decrypting (SPEC §6.4); `wai inspect` says so. No + committed envelope or conformance corpus carried such a payload. - **One integer decode path.** `int_payload::decode_int` decodes the strings above and `wai.neural.int_synth` and `wai.neural.int_mlicpp` from parameter files held in memory, with no ONNX runtime and no filesystem, natively and for `wasm32`, through a diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 35495a4db..52372cc29 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -503,7 +503,10 @@ still parse: A sink refuses such a payload when it decodes it (§8), with no fallback attempt. An encoder MUST NOT write one; the reference implementation's emit paths (`wai wrap`, the -C packers and the browser writers) parse the payload as a sink does and refuse to. +C packers and the browser writers) parse the payload as a sink does and refuse to. An +encrypted envelope's payload (SPEC §6) is ciphertext until a sink decrypts it, so the +emit paths do not check it; the sink checks the plaintext when it decodes it (SPEC +§6.4). No committed envelope or conformance corpus carried such a payload when this revision was made. diff --git a/wai/wai-rs/src/bin/wai.rs b/wai/wai-rs/src/bin/wai.rs index 574c0a2a2..ec7ce19e5 100644 --- a/wai/wai-rs/src/bin/wai.rs +++ b/wai/wai-rs/src/bin/wai.rs @@ -364,7 +364,10 @@ fn inspect(path: &str) -> Result<(), String> { if let Err(e) = w.check_candidates() { println!(" candidates: \u{2717} {e}"); } - if [Some(cap), fb].into_iter().flatten().any(|c| wai::codecs::pinned_int_payload(c).is_some()) { + let encrypted = !w.manifest.security.as_ref().is_none_or(wai::container::Security::is_plaintext); + if [Some(cap), fb].into_iter().flatten().any(|c| wai::codecs::pinned_int_payload(c).is_some()) && encrypted { + println!(" integer payload: encrypted; a sink checks it after decrypting (SPEC \u{a7}6.4)"); + } else if [Some(cap), fb].into_iter().flatten().any(|c| wai::codecs::pinned_int_payload(c).is_some()) { match w.check_int_payload_framing() { Ok(()) => println!(" integer payload: \u{2713} parses as the format SPEC \u{a7}5 pins (every parse rule; the decode checks the rest)"), Err(e) => println!(" integer payload: \u{2717} {e}; a sink refuses it at decode"), diff --git a/wai/wai-rs/src/container.rs b/wai/wai-rs/src/container.rs index f64f8808e..fc1a6a652 100644 --- a/wai/wai-rs/src/container.rs +++ b/wai/wai-rs/src/container.rs @@ -1038,8 +1038,14 @@ impl Wai { /// Check that the payload is the format SPEC §5 pins for the `capability`, and for /// the `fallback` (step 5 decodes the same payload through it), where either is an - /// integer capability with a pinned format ([`check_int_payload_framing`]). + /// integer capability with a pinned format ([`check_int_payload_framing`]). An + /// encrypted payload (`security.encryption` other than `"none"`) is ciphertext, so + /// it is not checked here: a sink decrypts it first (SPEC §6.4) and checks the + /// plaintext when it decodes it. pub fn check_int_payload_framing(&self) -> Result<(), FramingError> { + if !self.manifest.security.as_ref().is_none_or(Security::is_plaintext) { + return Ok(()); + } check_int_payload_framing(self.capability(), &self.payload)?; match self.fallback() { Some(fb) => check_int_payload_framing(fb, &self.payload), @@ -1344,6 +1350,15 @@ pub fn check_component_candidates<'a>(capabilities: impl IntoIterator bool { + match m.extra.get("security") { + None | Some(Value::Null) => true, + Some(v) => serde_json::from_value::(v.clone()).is_ok_and(|s| s.is_plaintext()), + } +} + /// Why a payload is not the format SPEC §5 pins its capability to (*Payloads of the /// WAI-native integer codecs*, `extensions/integer-payloads.md`): the capability, the /// code the extension's §8 names and what failed. The payload is parsed as a sink parses @@ -1376,7 +1391,8 @@ impl std::error::Error for FramingError {} /// fails ([`FramingError`]). `Ok` for a capability with no pinned integer format. It /// needs no parameter set and no integer decoder, so every emit path runs it /// ([`Wai::to_emit_bytes`], [`WaiMulti::to_emit_bytes`], `wai wrap`, the browser's -/// writers). +/// writers). `payload` is plaintext: an encrypted envelope's payload is checked after +/// it is decrypted (SPEC §6.4), and its emit paths skip the check. pub fn check_int_payload_framing(capability: &str, payload: &[u8]) -> Result<(), FramingError> { use crate::int_wire as w; let parsed = match crate::codecs::pinned_int_payload(capability) { @@ -1822,8 +1838,13 @@ impl WaiMulti { /// [`check_int_payload_framing`] over every component, in table order: the /// first component whose payload is not its capability's pinned format, with its - /// index. + /// index. Skipped for an encrypted envelope (a `security` member whose + /// `encryption` is not `"none"`), whose payloads are ciphertext until a sink + /// decrypts them (SPEC §6.4). pub fn check_int_payload_framing(&self) -> Result<(), (usize, FramingError)> { + if !manifest_v2_plaintext(&self.manifest) { + return Ok(()); + } for (i, (m, p)) in self.manifest.renditions.iter().zip(&self.renditions).enumerate() { check_int_payload_framing(&m.capability, p).map_err(|e| (i, e))?; } @@ -2836,6 +2857,30 @@ mod tests { assert_eq!(code(v2(&k0, &k1)), None); } + /// An encrypted envelope's payload is ciphertext (SPEC §6): the emit paths do not + /// check it against the integer format, since a sink decrypts it first (§6.4) and + /// checks the plaintext when it decodes it. A `WAI1` and a `WAI2` that declare an + /// encryption are written; the same bytes in plaintext are refused. + #[test] + fn an_encrypted_integer_payload_is_emitted() { + let read = |rel: &str| std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors").join(rel)).unwrap(); + let plain = read("int_video/int_video_bitstream.wiv1"); + let cipher: Vec = plain.iter().enumerate().map(|(i, b)| b ^ (i as u8).wrapping_mul(151).wrapping_add(7)).collect(); + let mot = crate::codecs::CAP_VIDEO_INT_MOTION; + let v1 = |security: Option| { + let mut m = Manifest::new("video", "improve", ModelRequirement::new(mot), Conditioning::new("bytes")); + m.security = security; + Wai::new(m, cipher.clone()).to_emit_bytes() + }; + assert!(v1(Some(Security::new("aes-256-gcm", 7))).is_ok(), "an encrypted WAI1 is written"); + assert!(matches!(v1(None), Err(EmitError::IntPayload { .. })), "the ciphertext in plaintext is refused"); + assert!(matches!(v1(Some(Security::new("none", 0))), Err(EmitError::IntPayload { .. })), "`none` is plaintext"); + let mut multi = WaiMulti::new("video", "improve", serde_json::Value::Null, vec![Rendition::media(mot, "bytes", cipher.clone())]); + assert!(matches!(multi.to_emit_bytes(), Err(EmitError::IntPayload { .. }))); + multi.manifest.extra.insert("security".into(), serde_json::json!({"encryption": "mls-sframe", "kid": 0})); + assert!(multi.to_emit_bytes().is_ok(), "an encrypted WAI2 is written"); + } + /// The emit path of the C packers (`wai_envelope_pack`, /// `wai_envelope_pack_multi`), driven exactly as they drive it: the manifest /// JSON parsed into the manifest type, the envelope built, and the bytes taken diff --git a/wai/wai-rs/tests/cli_wrap_intent.rs b/wai/wai-rs/tests/cli_wrap_intent.rs index 82fa31d46..bb8834d9c 100644 --- a/wai/wai-rs/tests/cli_wrap_intent.rs +++ b/wai/wai-rs/tests/cli_wrap_intent.rs @@ -224,4 +224,13 @@ fn wrap_refuses_a_payload_that_is_not_the_pinned_integer_format() { let run = Command::new(env!("CARGO_BIN_EXE_wai")).arg("inspect").arg(&out).output().expect("run wai"); let stdout = String::from_utf8_lossy(&run.stdout); assert!(stdout.contains("integer payload:") && stdout.contains("keyframe_capability_mismatch"), "{stdout}"); + // ...and an encrypted one, whose ciphertext is checked only after decryption. + let out = scratch("int-payload-encrypted.wai"); + let mut m = wai::container::Manifest::new("video", "improve", wai::container::ModelRequirement::new("wai.video.int_motion"), + wai::container::Conditioning::new("bytes")); + m.security = Some(wai::container::Security::new("aes-256-gcm", 7)); + wai::container::Wai::new(m, vec![0x5a; 64]).write(&out).unwrap(); + let run = Command::new(env!("CARGO_BIN_EXE_wai")).arg("inspect").arg(&out).output().expect("run wai"); + let stdout = String::from_utf8_lossy(&run.stdout); + assert!(stdout.contains("integer payload: encrypted"), "{stdout}"); } diff --git a/wai/wai-web/src/lib.rs b/wai/wai-web/src/lib.rs index 8ceec6613..fc467181a 100644 --- a/wai/wai-web/src/lib.rs +++ b/wai/wai-web/src/lib.rs @@ -468,6 +468,10 @@ pub fn parse(bytes: &[u8]) -> Result { fn pack_refusal(m: &wai::container::Manifest, payload: &[u8]) -> Result<(), String> { let req = &m.model_requirement; wai::container::check_candidates_v1(&req.capability, req.fallback.as_deref()).map_err(|e| e.to_string())?; + // An encrypted payload is ciphertext; a sink checks it after decrypting (SPEC §6.4). + if !m.security.as_ref().is_none_or(wai::container::Security::is_plaintext) { + return Ok(()); + } for cap in std::iter::once(req.capability.as_str()).chain(req.fallback.as_deref()) { wai::container::check_int_payload_framing(cap, payload).map_err(|e| e.to_string())?; } @@ -477,6 +481,9 @@ fn pack_refusal(m: &wai::container::Manifest, payload: &[u8]) -> Result<(), Stri /// What [`pack_multi`] refuses before it writes, once it holds the payloads: a /// component whose payload is not the format SPEC §5 pins its integer capability to. fn pack_multi_refusal(manifest: &wai::container::ManifestV2, payloads: &[Vec]) -> Result<(), String> { + if !wai::container::manifest_v2_plaintext(manifest) { + return Ok(()); + } for (i, (r, p)) in manifest.renditions.iter().zip(payloads).enumerate() { wai::container::check_int_payload_framing(&r.capability, p).map_err(|e| format!("component {i}: {e}"))?; } @@ -732,6 +739,14 @@ mod tests { assert_eq!(super::pack_multi_refusal(&m2, &[k0.clone(), k1.clone()]), Ok(())); let e = super::pack_multi_refusal(&m2, &[k0.clone(), k0.clone()]).unwrap_err(); assert!(e.starts_with("component 1:") && e.contains("keyframe_capability_mismatch"), "{e}"); + // An encrypted envelope's payload is ciphertext, checked after decryption + // (SPEC §6.4): it is written. + let cipher: Vec = k0.iter().enumerate().map(|(i, b)| b ^ (i as u8).wrapping_mul(151).wrapping_add(7)).collect(); + let enc = v1(mot, None).with_security(wai::container::Security::new("aes-256-gcm", 7)); + assert_eq!(super::pack_refusal(&enc, &cipher), Ok(())); + let mut m2e = m2.clone(); + m2e.extra.insert("security".into(), serde_json::json!({"encryption": "mls-sframe", "kid": 0})); + assert_eq!(super::pack_multi_refusal(&m2e, &[cipher.clone(), cipher]), Ok(())); } /// A companion-only component is never presented as media: not picked, not -- 2.54.0 (Apple Git-157) From 030d4aab853d6c576d50b98ba1a6a856bc2c49f7 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 21:15:43 -0400 Subject: [PATCH 27/34] wai: receipt decode refusals are codes; the Python decode allocates as it goes - video_receipt::decode_wiv1 reports a decode refusal by its code (stream_exhausted, stream_noncanonical, field_range), as it already did for a parse refusal, not by a sentence. - int_payload_verify.py grows each frame as its symbols decode instead of allocating it before the stream is read. - The receipts workflow runs when the refusal fixtures its test reads change. Test: video_receipt::decode_wiv1_reports_refusal_codes (the run-out and unread-word fixtures, and a JSON form). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-energy-receipts.yml | 4 ++++ wai/int-payload-conformance/int_payload_verify.py | 7 ++++--- wai/wai-rs/src/video_receipt.rs | 11 +++++++++-- wai/wai-rs/tests/video_receipt.rs | 12 ++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wai-energy-receipts.yml b/.github/workflows/wai-energy-receipts.yml index c994a08a6..f9ef8ac30 100644 --- a/.github/workflows/wai-energy-receipts.yml +++ b/.github/workflows/wai-energy-receipts.yml @@ -70,6 +70,8 @@ on: - 'wai/extensions/**' - 'wai/SPEC.md' - 'wai/CHANGELOG.md' + # tests/video_receipt.rs reads the WIV1 refusal fixtures. + - 'wai/int-payload-conformance/**' - '.github/workflows/wai-energy-receipts.yml' pull_request: paths: @@ -79,6 +81,8 @@ on: - 'wai/extensions/**' - 'wai/SPEC.md' - 'wai/CHANGELOG.md' + # tests/video_receipt.rs reads the WIV1 refusal fixtures. + - 'wai/int-payload-conformance/**' - '.github/workflows/wai-energy-receipts.yml' jobs: diff --git a/wai/int-payload-conformance/int_payload_verify.py b/wai/int-payload-conformance/int_payload_verify.py index abeb19432..8705e6316 100644 --- a/wai/int-payload-conformance/int_payload_verify.py +++ b/wai/int-payload-conformance/int_payload_verify.py @@ -404,11 +404,12 @@ def decode_wiv1_k0(v): frames, prev = [], None for f in range(v["n_frames"]): mvs = v["mvs"][(f - 1) * nb:f * nb] if f else None - cur = [0] * (h * w * 3) + # The frame grows a sample at a time as its symbols decode: nothing is + # allocated ahead of the stream that fills it. + cur = bytearray() for y in range(h): for x in range(w): dy, dx = mvs[(y // block) * nbx + x // block] if f else (0, 0) - base = (y * w + x) * 3 for c in range(3): if f == 0: pred = 128 @@ -431,7 +432,7 @@ def decode_wiv1_k0(v): if d.exhausted: # §2: the first symbol during whose decode the stream runs out. raise Refused("stream_exhausted", "the residual stream ran out") - cur[base + c] = min(max(pred + r * q, 0), 255) + cur.append(min(max(pred + r * q, 0), 255)) frames.append(bytes(cur)) prev = cur if d.i != len(d.w) or d.x != 1 << 31: diff --git a/wai/wai-rs/src/video_receipt.rs b/wai/wai-rs/src/video_receipt.rs index c88cb19a9..a35364324 100644 --- a/wai/wai-rs/src/video_receipt.rs +++ b/wai/wai-rs/src/video_receipt.rs @@ -108,14 +108,21 @@ pub fn clip_hash(h: u32, w: u32, frames: &[FrameHash]) -> [u8; 32] { /// Decode a `WIV1` payload of `wai.video.int_motion` (keyframe 0): parsed with /// `int_payload::wire` and decoded through [`crate::int_video::decode_typed`], each -/// refusal reported by its code (`extensions/integer-payloads.md` §8). +/// refusal reported by its code (`extensions/integer-payloads.md` §8): the parse's, +/// or the decode's (`stream_exhausted`, `stream_noncanonical`, and `field_range` for +/// a rule of the typed stream). pub fn decode_wiv1(payload: &[u8]) -> Result { + use crate::int_video::{STREAM_EXHAUSTED, STREAM_NONCANONICAL}; let v = crate::int_payload::wire::parse_wiv1(payload, Some(0)).map_err(|e| e.refusal.code())?; let s = crate::int_video::VideoStream { n_frames: v.n_frames as usize, h: v.h as usize, w: v.w as usize, block: v.block as usize, q: v.q as i64, offset: v.offset, mv_precision: v.mv_precision, cdf: &v.cdf, mvs: &v.mvs, rans: v.rans, }; - crate::int_video::decode_typed(&s) + crate::int_video::decode_typed(&s).map_err(|e| match e { + STREAM_EXHAUSTED => crate::int_payload::Refusal::StreamExhausted.code(), + STREAM_NONCANONICAL => crate::int_payload::Refusal::StreamNoncanonical.code(), + _ => crate::int_payload::Refusal::FieldRange.code(), + }) } #[derive(Clone, Debug, PartialEq)] diff --git a/wai/wai-rs/tests/video_receipt.rs b/wai/wai-rs/tests/video_receipt.rs index f1eaa45b5..b697e6eab 100644 --- a/wai/wai-rs/tests/video_receipt.rs +++ b/wai/wai-rs/tests/video_receipt.rs @@ -336,3 +336,15 @@ fn wiv1_receipts_keep_their_signed_bytes() { ] ); } + +/// `video_receipt::decode_wiv1` reports a refusal by its code +/// (`extensions/integer-payloads.md` §8), the decode's as well as the parse's: the +/// refusal fixtures of a run-out stream and an unread word, and a JSON form. +#[test] +fn decode_wiv1_reports_refusal_codes() { + let fixture = |name: &str| std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../int-payload-conformance/refusals").join(name)).unwrap(); + let code = |p: &[u8]| wai::video_receipt::decode_wiv1(p).map(|_| ()).unwrap_err(); + assert_eq!(code(&fixture("wiv1_exhausted.bin")), "stream_exhausted"); + assert_eq!(code(&fixture("wiv1_unread_word.bin")), "stream_noncanonical"); + assert_eq!(code(&raw("int_video_bitstream.json")), "bad_magic"); +} -- 2.54.0 (Apple Git-157) From dcae455cec1039646e8a66b925893034809e9028 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 21:22:53 -0400 Subject: [PATCH 28/34] wai: the one-frame preflight clip has the canonical header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-frame WIV1 carries block 1 and mv_precision 1 (integer-payloads §3). The starved one-frame clip in tests/int_payload_preflight.rs had block 2^32 - 1, so it was refused as field_range at parse, before the decode it exists to measure. With block 1 it parses and is refused at its first exhausted symbol, as the test asserts. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/int_payload_preflight.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wai/wai-rs/tests/int_payload_preflight.rs b/wai/wai-rs/tests/int_payload_preflight.rs index 7e0feabf3..68c0d4951 100644 --- a/wai/wai-rs/tests/int_payload_preflight.rs +++ b/wai/wai-rs/tests/int_payload_preflight.rs @@ -181,7 +181,7 @@ fn a_header_past_the_caps_or_the_model_is_refused_before_anything_is_allocated() // finish here quickly, and allocate a 256 × 256 frame. ("16 frames of 256 × 256", c::CAP_VIDEO_INT_MOTION, clip(16, 256, 256, 1 << 31, vec![0, 1, 65_536], &zeros), &none, 0), ("16 frames of 4729 × 4729", c::CAP_VIDEO_INT_MOTION, clip(16, 4729, 4729, 1 << 31, vec![0, 1, 65_536], &zeros), &none, 0), - ("one frame of 4096 × 5461", c::CAP_VIDEO_INT_MOTION, clip(1, 4096, 5461, u32::MAX, vec![0, 65_534, 65_535, 65_536], &ones), &none, 0), + ("one frame of 4096 × 5461", c::CAP_VIDEO_INT_MOTION, clip(1, 4096, 5461, 1, vec![0, 65_534, 65_535, 65_536], &ones), &none, 0), ("a hyper-latent of 2 × 64 × 64", c::CAP_NEURAL_INT_HYPER, wih1, &hyp, hyp_bytes), ("an audio latent of 16 × 4096", c::CAP_AUDIO_INT_CODEC, wia1, &aud, model.len()), ("a splat latent of 32 × 64 × 64", c::CAP_SPLAT_INT_CODEC, wis1, &spl, gs.len()), -- 2.54.0 (Apple Git-157) From 1ed652b0e77b7267e4f81d9d1cd3b367baeb9bb2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 22:14:48 -0400 Subject: [PATCH 29/34] wai: a granted listing authorises only the files it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside the integer path, an unpinned decode at a sink whose key log holds the capability's sets to jwp-receipts §7.9 read the registered path and any file beside it once check 5 had passed, so a granted listing naming one file authorised every other file in its directory. The registry now reads, once each, the sets that could identify what the decode reads (the registered file for a one-file capability, and each listing beside it whose files all match), runs check 5 over their digests and, for a capability held to §7.9, hands the decode only the set §7.9 authorised (neural::ParamSource::Authorised). The decode reads nothing else, as a pinned decode does: a file outside the set is set_incomplete. A key log that only revokes leaves the decode reading the path, as before. pin::PinAuthority gains requires_grant, with a default; SinkKeyLog answers it from its relying party. SPEC §4 (Module authorisation) states the rule. Test: neural_native_decode::a_granted_listing_authorises_only_the_files_it_names (the decoder's listing beside unauthorised tables, a directory's listing of another file, a listing of both, a one-file grant, a revoking log). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-neural-gates.yml | 6 +- wai/CHANGELOG.md | 11 +- wai/SPEC.md | 5 +- wai/wai-rs/src/key_log.rs | 4 + wai/wai-rs/src/neural/mod.rs | 143 ++++++++++++++--------- wai/wai-rs/src/neural/params.rs | 34 ++++-- wai/wai-rs/src/pin.rs | 8 ++ wai/wai-rs/tests/neural_native_decode.rs | 77 ++++++++++++ 8 files changed, 214 insertions(+), 74 deletions(-) diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index 25f1cf818..e576e37df 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -16,7 +16,8 @@ name: WAI neural sink gates # committed int_mlicpp vector and the int_synth corpus: the form-less and set-form # pins through the strict gate, the int_synth pin over the spec it reads, the # several-file capabilities refusing a form-less pin, a pinned decode reading only -# the bytes it verified, a WAI2 envelope whose pins pick the held model, the strict +# the bytes it verified, an unpinned decode at a sink holding the capability's sets +# to §7.9 reading only the set §7.9 authorised, a WAI2 envelope whose pins pick the held model, the strict # rule read for the capability decoded through (a sound fallback behind an unsound # primary), SPEC §4 step 6's order of reasons after a set_incomplete decode, and the # replicate_* reason the strict WAI2 policy reports for an entry it leaves out, @@ -141,8 +142,9 @@ jobs: an_integer_capability_without_a_decoder_moves_on_to_the_fallback \ a_listing_that_does_not_match_the_files_read_authorises_nothing \ a_pinned_set_revoked_under_its_file_digest_is_refused_through_a_listing_pin \ + a_granted_listing_authorises_only_the_files_it_names \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 17 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 18 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" # The integer video, audio and splat capabilities through the neural dispatch, which # delegates them to int_payload::decode_int: each decodes to Decoded::Int with its # committed SHA-256, and wai.video.int_motion is advertised with ModelRegistry::builtin. diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 738b4e0bd..cfb5ab9c1 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -59,10 +59,17 @@ wire format may change and such changes are recorded here. verifies, it runs again over the listing of the members read and a one-file set's file digest (`int_payload::read_identities`), so a set revoked under one identity is not reached through a pin naming it by another; and a listing beside an - unpinned set counts only when it matches the files read. + unpinned set counts only when it matches the files read. Outside the integer path, + at a sink whose key log holds the capability's sets to jwp-receipts §7.9, an + unpinned decode reads only the set §7.9 authorised, read once (the registered + file, or the files a listing beside it names), as a pinned decode does: a granted + listing authorises the files it names and nothing else, and a file the decode + needs outside the set is `set_incomplete` (SPEC §4, *Module authorisation*). + `pin::PinAuthority` gains `requires_grant`, with a default. *Breaking:* `neural::Decoded` gains the variant `Int` and is now `#[non_exhaustive]`, so a `match` on it outside this crate needs a wildcard arm; `neural::ParamSource` - gains `UnpinnedRead` (an unpinned integer set read into memory once) and is + gains `UnpinnedRead` (an unpinned integer set read into memory once) and + `Authorised` (a set §7.9 authorised for an unpinned decode) and is `#[non_exhaustive]` too; `int_payload::IntParams` is opaque, built by `int_payload::resolve` (or `IntParams::none()`). - **Checked decoders.** `int_video`, `int_audio` and `int_splat` decode a typed diff --git a/wai/SPEC.md b/wai/SPEC.md index 171495319..ecffd3c81 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -422,7 +422,10 @@ pin does not resolve. An envelope that pins no set is decoded against the set th sink holds for `cap`, and a sink that holds a key log runs check 5 over that set too, identified by each digest a pin over it could name: it does not decode `cap` for that envelope when any of them is revoked, or, for a capability it holds to -§7.9, when §7.9 holds the set under none of them. Either way +§7.9, when §7.9 holds the set under none of them; for such a capability it then +decodes from the set §7.9 holds alone — that one file, or the files the held listing +names, as a pinned decode reads only what its pin verified (§3.1) — and a file the +decode needs outside it is `set_incomplete`. Either way the algorithm above runs unchanged, so a sink MUST NOT report a file inert because of module authorisation while a fallback it can decode without that module remains, and an encoder emitting a capability whose decode needs a module MUST declare such a diff --git a/wai/wai-rs/src/key_log.rs b/wai/wai-rs/src/key_log.rs index 6e470c643..ab6768e97 100644 --- a/wai/wai-rs/src/key_log.rs +++ b/wai/wai-rs/src/key_log.rs @@ -1888,6 +1888,10 @@ impl crate::pin::PinAuthority for SinkKeyLog { let digests: Vec<[u8; 32]> = digests.iter().filter_map(|d| hash32(d)).collect(); self.check_unpinned_set(capability, &digests) } + + fn requires_grant(&self, capability: &str) -> bool { + self.holds_to_section_7_9(capability) + } } // ───────────────────────────── published keys ───────────────────────────── diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index 70d391746..d6c74edf0 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -61,9 +61,11 @@ pub use video::DecodedVideo; /// A key log, where set ([`ModelRegistry::with_key_log`]), runs SPEC §3.1's check 5: /// a set whose digest it revokes is refused, and the sets of the capabilities the /// sink's relying party holds to jwp-receipts §7.9 must be held under §7.9. It runs -/// over the set an unpinned decode reads too (SPEC §4, *Module authorisation*): for an -/// integer capability, over the very bytes the decode then reads, which the registry -/// reads once. A registry without one does not run check 5. +/// over the set an unpinned decode reads too (SPEC §4, *Module authorisation*), and the +/// decode then reads only bytes check 5 ran over: for an integer capability, the +/// members the registry read once; for another capability held to §7.9, the set §7.9 +/// authorised, read once ([`ParamSource::Authorised`]). A registry without one does +/// not run check 5. /// /// A capability whose decode reads no parameter set (`wai.video.int_motion`) is /// advertised with [`ModelRegistry::builtin`] instead, and holds no path. @@ -139,14 +141,20 @@ impl ModelRegistry { /// its decode reads are read here, once, from the paths [`int_member_path`] gives, /// and returned as [`ParamSource::UnpinnedRead`]: check 5 runs over exactly those /// bytes (`int_payload::unpinned_read`: a one-file set's file, and the listing of - /// the members read), and the decode reads nothing else. For any other capability - /// the digests are read when the decode starts: its file's SHA-256 where the - /// capability's set is one file; the decode then reads the path, as it always does. - /// Either way a listing beside the path (the form `wai.prior.set`) adds its digest - /// only when it identifies the files the decode reads ([`listing_digests`]): a - /// listing that does not match them is refused and contributes no digest, so a - /// granted listing beside other files authorises nothing. `capability` is - /// registered. + /// the members read), and the decode reads nothing else. A listing beside the path + /// (the form `wai.prior.set`) adds its digest only when it names, with their + /// digests, every member the decode reads ([`listing_digests`]). + /// + /// For any other capability the sets that could identify what the decode reads are + /// read here, once each: the registered file, where the capability's set is one + /// file, and each listing beside the path whose every file matches its digest (and + /// that names the registered file, where that is a file) ([`listed_sets`]). Check + /// 5 runs over their digests. Where the key log holds the capability's sets to + /// §7.9, the decode then reads only the set §7.9 authorised, from the bytes read + /// here ([`ParamSource::Authorised`]), so a file the set does not name is + /// `set_incomplete`, never read from disk; a granted listing authorises the files + /// it names and nothing else. Where it does not, check 5 is revocation alone and + /// the decode reads the path, as it always has. `capability` is registered. fn unpinned(&self, capability: &str) -> Result { let Some(path) = self.paths.get(capability) else { // A built-in capability reads no parameter set: there is nothing to check. @@ -158,44 +166,45 @@ impl ModelRegistry { if let Some(k) = key_log { let view: std::collections::BTreeMap = files.iter().map(|(n, b)| (n.clone(), b.as_slice())).collect(); let (mut digests, _) = crate::int_payload::unpinned_read(capability, &crate::pin::HeldBytes::Set { listing: b"", files: &view })?; - digests.extend(listing_digests(capability, path, Some(&view))); + digests.extend(listing_digests(capability, path, &view)); k.check_unpinned(capability, &digests)?; } return Ok(ParamSource::UnpinnedRead(files)); } - if let Some(k) = key_log { - let one_file = crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile; - let mut digests = Vec::new(); - if one_file { - for h in artifacts_at(capability, [path], false) { - let (crate::pin::Held::File(f) | crate::pin::Held::Listing(f)) = h; - if let Ok(bytes) = std::fs::read(f) { - digests.push(crate::pin::sha256_hex(&bytes)); - } + let Some(k) = key_log else { return Ok(ParamSource::Unpinned(path.clone())) }; + let mut sets: Vec<(String, crate::pin::Verified)> = Vec::new(); + if crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile { + for h in artifacts_at(capability, [path], false) { + let (crate::pin::Held::File(f) | crate::pin::Held::Listing(f)) = h; + if let Ok(bytes) = std::fs::read(f) { + sets.push((crate::pin::sha256_hex(&bytes), crate::pin::Verified::File(bytes))); } } - digests.extend(listing_digests(capability, path, None)); - k.check_unpinned(capability, &digests)?; } - Ok(ParamSource::Unpinned(path.clone())) + sets.extend(listed_sets(capability, path)); + let digests: Vec = sets.iter().map(|(d, _)| d.clone()).collect(); + k.check_unpinned(capability, &digests)?; + if !k.requires_grant(capability) { + return Ok(ParamSource::Unpinned(path.clone())); + } + // Check 5 held the sets under one of these digests: the decode reads that set. + sets.into_iter() + .find(|(d, _)| k.check_unpinned(capability, std::slice::from_ref(d)).is_ok()) + .map(|(_, set)| ParamSource::Authorised(set)) + .ok_or_else(|| PinRefusal::NotAuthorised { capability: capability.to_string(), check: None, reason: None }) } } /// The digests of the listings beside `registered` (each `*.sha256` in its directory, -/// the form `wai.prior.set`) that identify the files an unpinned decode reads, for -/// check 5. A listing is read as a pinned set's would be (SPEC §3.1 checks 7 and 8): -/// malformed (`set_malformed`), or naming a file whose bytes are not its digest -/// (`part_mismatch`), it is refused and contributes no digest. -/// -/// - An integer capability's members, `read`, are the bytes the decode reads, by member -/// name; a listed file that is not a member is read from beside the listing. The -/// listing goes through `int_payload::unpinned_read` and counts only when it names, -/// with their digests, every member the decode reads. -/// - For another capability, every file the listing names is read from beside it, and -/// it counts only when each matches and, where `registered` is a file, it names that -/// file. -fn listing_digests(capability: &str, registered: &Path, read: Option<&std::collections::BTreeMap>) -> Vec { - use crate::pin::{parse_listing, sha256, sha256_hex, Held, HeldBytes}; +/// the form `wai.prior.set`) that identify the members an integer capability's +/// unpinned decode reads, `read`, for check 5. A listing is read as a pinned set's +/// would be (SPEC §3.1 checks 7 and 8): malformed (`set_malformed`), or naming a file +/// whose bytes are not its digest (`part_mismatch`), it is refused and contributes no +/// digest. A listed file that is not a member is read from beside the listing; the +/// listing goes through `int_payload::unpinned_read` and counts only when it names, +/// with their digests, every member the decode reads. +fn listing_digests(capability: &str, registered: &Path, read: &std::collections::BTreeMap) -> Vec { + use crate::pin::{parse_listing, Held, HeldBytes}; let mut out = Vec::new(); for h in artifacts_at(capability, [®istered.to_path_buf()], true) { let Held::Listing(f) = h else { continue }; @@ -203,32 +212,50 @@ fn listing_digests(capability: &str, registered: &Path, read: Option<&std::colle let Ok(lines) = parse_listing(&listing) else { continue }; let root = f.parent().map(Path::to_path_buf).unwrap_or_default(); let others: std::collections::BTreeMap> = lines.iter() - .filter(|(p, _)| read.is_none_or(|r| !r.contains_key(p))) + .filter(|(p, _)| !read.contains_key(p)) .filter_map(|(p, _)| std::fs::read(root.join(p)).ok().map(|b| (p.clone(), b))) .collect(); let mut view: std::collections::BTreeMap = others.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); - match read { - Some(members) => { - view.extend(members.iter().map(|(k, v)| (k.clone(), *v))); - if let Ok((digests, Some(paths))) = crate::int_payload::unpinned_read(capability, &HeldBytes::Set { listing: &listing, files: &view }) { - if paths.len() == members.len() { - out.extend(digests); - } - } - } - None => { - let matches = lines.iter().all(|(p, d)| view.get(p).is_some_and(|b| sha256(b) == *d)); - let names_it = !registered.is_file() - || registered.file_name().and_then(|n| n.to_str()).is_some_and(|n| lines.iter().any(|(p, _)| p == n)); - if matches && names_it { - out.push(sha256_hex(&listing)); - } + view.extend(read.iter().map(|(k, v)| (k.clone(), *v))); + if let Ok((digests, Some(paths))) = crate::int_payload::unpinned_read(capability, &HeldBytes::Set { listing: &listing, files: &view }) { + if paths.len() == read.len() { + out.extend(digests); } } } out } +/// The sets the listings beside `registered` identify for a capability outside the +/// integer path (each `*.sha256` in its directory, the form `wai.prior.set`): each +/// listing's digest with the files it names, read once from beside it. A listing is +/// read as a pinned set's would be (SPEC §3.1 checks 7 and 8): malformed, or naming a +/// file that is absent or whose bytes are not its digest, it is refused and identifies +/// nothing; where `registered` is a file, a listing that does not name it identifies +/// nothing either, since the decode reads that file. Only a listing check 5 then +/// authorises is a set the decode reads, and it reads nothing outside it. +fn listed_sets(capability: &str, registered: &Path) -> Vec<(String, crate::pin::Verified)> { + use crate::pin::{parse_listing, sha256, sha256_hex, Held, Verified}; + let mut out = Vec::new(); + let file_name = registered.is_file().then(|| registered.file_name().and_then(|n| n.to_str())).flatten(); + for h in artifacts_at(capability, [®istered.to_path_buf()], true) { + let Held::Listing(f) = h else { continue }; + let Ok(listing) = std::fs::read(&f) else { continue }; + let Ok(lines) = parse_listing(&listing) else { continue }; + if registered.is_file() && file_name.is_none_or(|n| !lines.iter().any(|(p, _)| p == n)) { + continue; + } + let root = f.parent().map(Path::to_path_buf).unwrap_or_default(); + let files: Option>> = lines.iter() + .map(|(p, d)| std::fs::read(root.join(p)).ok().filter(|b| sha256(b) == *d).map(|b| (p.clone(), b))) + .collect(); + if let Some(files) = files { + out.push((sha256_hex(&listing), Verified::Set { root, files })); + } + } + out +} + /// Every identity of the bytes a pin verified, besides the pin's own digest: for an /// integer capability, those of the members its decode reads /// (`int_payload::read_identities`); for another, the one file's digest, or the @@ -320,7 +347,9 @@ impl ModelRegistry { /// The parameters a decode through `capability` reads: verified against `prior` /// when a pin applies to the capability ([`crate::container::pin_applies_to`]), /// otherwise the registered artifact, unverified — the honest meaning of an - /// unpinned decode — once a key log the registry holds has not refused it. + /// unpinned decode — once a key log the registry holds has not refused it; where + /// that key log holds the capability's sets to jwp-receipts §7.9, only the set + /// §7.9 authorised ([`ParamSource::Authorised`]). pub fn resolve_verified(&self, capability: &str, prior: Option<&Prior>) -> Result { if !self.advertises(capability) { return Err(DecodeError::ModelNotRegistered(capability.to_string())); @@ -518,7 +547,7 @@ fn int_decode(cap: &str, payload: &[u8], params: &ParamSource) -> Result = unpinned.into_iter().flatten().map(|(k, v)| (k.clone(), v.as_slice())).collect(); let p = match params { - ParamSource::Pinned(v) => IntParams::verified(v), + ParamSource::Pinned(v) | ParamSource::Authorised(v) => IntParams::verified(v), _ => resolve(cap, None, &[crate::pin::HeldBytes::Set { listing: b"", files: &view }], None) .map_err(DecodeError::PriorMismatch)?, }; diff --git a/wai/wai-rs/src/neural/params.rs b/wai/wai-rs/src/neural/params.rs index bd0dd420c..facad1b96 100644 --- a/wai/wai-rs/src/neural/params.rs +++ b/wai/wai-rs/src/neural/params.rs @@ -5,7 +5,9 @@ //! sink always has. A pinned decode reads only the bytes the pin verified //! ([`crate::pin::Verified`]) — the very bytes that were hashed, never a second read //! of the same path — and a file the verified set does not contain is refused with -//! `set_incomplete` instead of being read from disk unverified. +//! `set_incomplete` instead of being read from disk unverified. So does an unpinned +//! decode at a sink that holds the capability's sets to jwp-receipts §7.9: it reads +//! only the set §7.9 authorised ([`ParamSource::Authorised`]). //! //! Sessions are built for determinism (`crate::ort_session`): one intra-op and one //! inter-op thread and a fixed graph-optimization level, because multi-threaded @@ -33,6 +35,13 @@ pub enum ParamSource { Unpinned(PathBuf), /// A pin verified this set; the decode reads nothing else. Pinned(Verified), + /// No pin applied, and the sink's key log holds the capability's sets to + /// jwp-receipts §7.9 (SPEC §4, *Module authorisation*): the set §7.9 authorised, + /// read once when the decode started — the registered file, or every file a + /// listing beside it names. The decode reads nothing else, as a pinned decode + /// does: a file the set does not contain is `set_incomplete` + /// ([`super::ModelRegistry`]). + Authorised(Verified), /// No pin applied, and the set an integer capability's decode reads was read into /// memory once, when the decode started: its members (`int_payload::set_members`) /// by the names a set lists them under, a member the sink could not read being @@ -61,20 +70,23 @@ impl ParamSource { } /// The artifact the capability registers, named `set_name` within a set: for an - /// unpinned decode the registered file itself; for a form-less pin the one - /// verified file; for a set, the file it lists as `set_name`. + /// unpinned decode the registered file itself; for one verified file, that file; + /// for a set, the file it lists as `set_name`. pub(crate) fn model(&self, set_name: &str) -> Result, DecodeError> { match self { Self::Unpinned(p) => std::fs::read(p).map(Cow::Owned) .map_err(|e| DecodeError::InvalidPayload(format!("{}: {e}", p.display()))), - Self::Pinned(Verified::File(b)) => Ok(Cow::Borrowed(b)), - Self::Pinned(v) => v.file(set_name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), Self::UnpinnedRead(files) => files.get(set_name).map(|b| Cow::Borrowed(b.as_slice())).ok_or_else(|| incomplete(set_name)), + Self::Pinned(v) | Self::Authorised(v) => match v { + Verified::File(b) => Ok(Cow::Borrowed(b)), + v => v.file(set_name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), + }, } } /// The file `name` beside the registered artifact (unpinned), or in the verified - /// set. A form-less pin verified one file, so it holds no sibling. + /// set. One verified file (a form-less pin, or a one-file set §7.9 authorised) + /// holds no sibling. pub(crate) fn sibling(&self, name: &str) -> Result, DecodeError> { match self { Self::Unpinned(p) => { @@ -82,9 +94,8 @@ impl ParamSource { std::fs::read(&f).map(Cow::Owned) .map_err(|e| DecodeError::InvalidPayload(format!("{}: {e}", f.display()))) } - Self::Pinned(Verified::File(_)) => Err(incomplete(name)), - Self::Pinned(v) => v.file(name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), Self::UnpinnedRead(files) => files.get(name).map(|b| Cow::Borrowed(b.as_slice())).ok_or_else(|| incomplete(name)), + Self::Pinned(v) | Self::Authorised(v) => v.file(name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), } } @@ -93,9 +104,8 @@ impl ParamSource { pub(crate) fn try_sibling(&self, name: &str) -> Option> { match self { Self::Unpinned(p) => std::fs::read(Self::dir(p).join(name)).ok().map(Cow::Owned), - Self::Pinned(Verified::File(_)) => None, - Self::Pinned(v) => v.file(name).ok().map(Cow::Borrowed), Self::UnpinnedRead(files) => files.get(name).map(|b| Cow::Borrowed(b.as_slice())), + Self::Pinned(v) | Self::Authorised(v) => v.file(name).ok().map(Cow::Borrowed), } } @@ -113,7 +123,7 @@ impl ParamSource { pub(crate) fn session_model(&self, set_name: &str) -> Result { match self { Self::Unpinned(p) => crate::ort_session::load_deterministic(p).map_err(DecodeError::Ort), - Self::Pinned(_) | Self::UnpinnedRead(_) => session_from_verified(&self.model(set_name)?), + Self::Pinned(_) | Self::Authorised(_) | Self::UnpinnedRead(_) => session_from_verified(&self.model(set_name)?), } } @@ -121,7 +131,7 @@ impl ParamSource { pub(crate) fn session_sibling(&self, name: &str) -> Result { match self { Self::Unpinned(p) => crate::ort_session::load_deterministic(&Self::dir(p).join(name)).map_err(DecodeError::Ort), - Self::Pinned(_) | Self::UnpinnedRead(_) => session_from_verified(&self.sibling(name)?), + Self::Pinned(_) | Self::Authorised(_) | Self::UnpinnedRead(_) => session_from_verified(&self.sibling(name)?), } } } diff --git a/wai/wai-rs/src/pin.rs b/wai/wai-rs/src/pin.rs index ad41b48a3..d02199d59 100644 --- a/wai/wai-rs/src/pin.rs +++ b/wai/wai-rs/src/pin.rs @@ -246,6 +246,14 @@ pub trait PinAuthority: std::fmt::Debug { /// `prior_revoked` when any of them is revoked, and, for a capability held to /// §7.9, with `prior_not_authorised` unless §7.9 holds the set under one of them. fn check_unpinned(&self, capability: &str, digests: &[String]) -> Result<(), PinRefusal>; + + /// Whether the sink holds `capability`'s sets to §7.9, so that an unpinned decode + /// through it reads a set only under a digest §7.9 holds. The default asks + /// [`Self::check_unpinned`] over no digest, which such a sink refuses and any other + /// accepts (no digest is revoked). + fn requires_grant(&self, capability: &str) -> bool { + self.check_unpinned(capability, &[]).is_err() + } } /// Which held artifact satisfied a pin: its index in the `held` slice given to diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index 6d61856c4..c69bfe41b 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -1412,6 +1412,83 @@ fn a_listing_that_does_not_match_the_files_read_authorises_nothing() { std::fs::remove_dir_all(&root).ok(); } +/// Outside the integer path, a granted listing authorises the files it names and +/// nothing else: at a sink whose key log holds the capability's sets to §7.9, the +/// unpinned decode reads only the set §7.9 authorised, read once +/// (`ParamSource::Authorised`), and a file the decode needs that the set does not name +/// is `set_incomplete`, never read from disk. A `wai.neural.bmshj2018` payload reads +/// its CDF tables (`cdfs_q3.json`) before it loads the decoder, so a decode that read +/// the unauthorised tables beside the decoder would fail parsing them instead. +/// +/// - the decoder registered as its file, beside a granted listing that names only +/// `decoder.onnx` and an unauthorised `cdfs_q3.json`: `set_incomplete`; +/// - the directory registered, beside a granted listing that names only `notes.txt`: +/// `set_incomplete`; +/// - a granted listing of both files: the decode reads the tables from the set; +/// - a one-file capability granted under its file's digest: that file alone; +/// - a key log that only revokes: the path, as before. +#[test] +fn a_granted_listing_authorises_only_the_files_it_names() { + use wai::neural::ParamSource; + use wai::pin::Verified; + let bm = "wai.neural.bmshj2018"; + let dir = std::env::temp_dir().join(format!("wai-granted-listing-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let files: [(&str, &[u8]); 3] = [("decoder.onnx", b"the publisher's decoder"), ("cdfs_q3.json", b"{\"unauthorised\": true}"), ("notes.txt", b"an unrelated file")]; + for (n, b) in files { + std::fs::write(dir.join(n), b).unwrap(); + } + let listing = |names: &[&str]| { + let set: std::collections::BTreeMap = + files.iter().filter(|(n, _)| names.contains(n)).map(|(n, b)| (n.to_string(), *b)).collect(); + let l = wai::pin::listing_from(&set).unwrap(); + std::fs::write(dir.join("granted.sha256"), &l).unwrap(); + wai::pin::sha256_hex(&l) + }; + // h 1, w 1, an empty stream, quality 3, one channel, a 1 × 1 latent. + let mut payload = Vec::new(); + for v in [1u32, 1, 0, 3] { + payload.extend_from_slice(&v.to_le_bytes()); + } + payload.extend_from_slice(&[1, 0]); + let env = Wai::new(Manifest::new("image", "improve", ModelRequirement::new(bm), Conditioning::new("bytes")), payload); + let names = |p: &ParamSource| match p { + ParamSource::Authorised(Verified::Set { files, .. }) => files.keys().cloned().collect::>().join(","), + ParamSource::Authorised(Verified::File(_)) => "one file".into(), + ParamSource::Unpinned(_) => "the path".into(), + other => format!("{other:?}"), + }; + let case = |path: PathBuf, granted: &[&str]| { + let reg = ModelRegistry::new().register(bm, path).with_key_log(Grants(vec![listing(granted)])); + (names(®.resolve_verified(bm, None).unwrap()), refusal(decode_envelope(&env, ®))) + }; + let incomplete = |r: &str| r == "set_incomplete"; + + let (set, r) = case(dir.join("decoder.onnx"), &["decoder.onnx"]); + assert_eq!(set, "decoder.onnx"); + assert!(incomplete(&r), "a listing of the decoder alone does not authorise the tables beside it: {r}"); + let (set, r) = case(dir.clone(), &["notes.txt"]); + assert_eq!(set, "notes.txt"); + assert!(incomplete(&r), "a listing of another file does not authorise the directory: {r}"); + let (set, r) = case(dir.join("decoder.onnx"), &["cdfs_q3.json", "decoder.onnx"]); + assert_eq!(set, "cdfs_q3.json,decoder.onnx"); + assert!(r.contains("cdf parse cdfs_q3.json"), "the listed tables are read from the set: {r}"); + + // A one-file capability granted under its file's digest decodes from that file alone. + std::fs::remove_file(dir.join("granted.sha256")).unwrap(); + let enc = "wai.neural.encodec32"; + let digest = wai::pin::sha256_hex(files[0].1); + let reg = ModelRegistry::new().register(enc, dir.join("decoder.onnx")).with_key_log(Grants(vec![digest.clone()])); + match reg.resolve_verified(enc, None).unwrap() { + ParamSource::Authorised(Verified::File(b)) => assert_eq!(b, files[0].1), + other => panic!("expected the authorised file, got {other:?}"), + } + // A key log that only revokes holds nothing to §7.9: the decode reads the path. + let reg = ModelRegistry::new().register(bm, dir.join("decoder.onnx")).with_key_log(Revokes(vec!["00".repeat(32)])); + assert_eq!(names(®.resolve_verified(bm, None).unwrap()), "the path"); + std::fs::remove_dir_all(&dir).ok(); +} + /// The neural registry's pinned path runs check 5 over every identity of the bytes it /// verified: a one-file set whose file digest a key log revokes is refused through a /// set-form pin over a listing of that file. -- 2.54.0 (Apple Git-157) From 9bcc70cff16ad57d2a601797e9c1ad81d8317d6e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 22:18:32 -0400 Subject: [PATCH 30/34] wai: a WIV1 keyframe's size is checked before the keyframe is decoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit int_payload::declared_cost refused a keyframe-1 WIV1 whose keyframe does not give the clip's h × w as geometry_mismatch, but the decode compared the size only after decoding the keyframe, so the same bytes could be refused there for the keyframe's stream (stream_noncanonical) instead. The decode now plans the keyframe and compares its size with h × w before it decodes a symbol of it, as declared_cost does; integer-payloads §7 lists the check at step 4. The Rust verifier (wai_int_payload_vectors verify) also requires declared_cost to refuse each decode-layer fixture with the decode's code, unless that code comes from a stream or a network run. Fixtures: wiv1_geometry_flip_* (seven), wiv1_geometry with one byte of its keyframe's last stream words changed, each geometry_mismatch, regenerated through the generator; the Python verifier checks that they parse. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 5 +- wai/extensions/integer-payloads.md | 8 +- wai/int-payload-conformance/README.md | 8 +- .../refusals/wiv1_geometry_flip_2133_fe.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_geometry_flip_2136_96.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_geometry_flip_2136_9b.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_geometry_flip_2137_8c.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_geometry_flip_2138_be.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_geometry_flip_2139_03.bin | Bin 0 -> 2148 bytes .../refusals/wiv1_geometry_flip_2146_4a.bin | Bin 0 -> 2148 bytes wai/int-payload-conformance/vectors.json | 84 ++++++++++++++++++ wai/wai-rs/src/bin/wai_int_payload_vectors.rs | 41 +++++++-- wai/wai-rs/src/int_payload/mod.rs | 23 +++-- 13 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 wai/int-payload-conformance/refusals/wiv1_geometry_flip_2133_fe.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_geometry_flip_2136_96.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_geometry_flip_2136_9b.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_geometry_flip_2137_8c.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_geometry_flip_2138_be.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_geometry_flip_2139_03.bin create mode 100644 wai/int-payload-conformance/refusals/wiv1_geometry_flip_2146_4a.bin diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index cfb5ab9c1..b3063da5c 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -100,7 +100,10 @@ wire format may change and such changes are recorded here. with the same code; a planned network's parameter lengths are checked before any symbol is decoded, and a convolution's padded side is capped. A set's entropy tables obey §2's length rule (3 to 4097 entries). The parser reads a table of one - per channel table by table. Fixtures pin each case. + per channel table by table. A `WIV1` keyframe's planned size is checked against the + clip's `h × w` before any of the keyframe's symbols is decoded, so + `int_payload::declared_cost` and the decode refuse the same bytes with the same + code; the Rust verifier checks that for every fixture. Fixtures pin each case. - **One header for a one-frame clip.** A `WIV1` of one frame codes no motion vector, so its `mv_precision` and `block` must be 1 (`field_range` otherwise); the converters write them so. The extension's §2 says a payload's hash identifies the diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 52372cc29..6c058a7ab 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -370,13 +370,15 @@ order and refuses with the first that fails: the splat `g_s`), each layer by the three rules above (`model_mismatch`, `too_large`): the caps come before any shape is compared; 4. what the networks give against the payload: for `WIH1`, `h_s`'s shape - (`geometry_mismatch`), then `g_s`'s three channels (`model_mismatch`); for `WIA1`, + (`geometry_mismatch`), then `g_s`'s three channels (`model_mismatch`); for `WIV1` + with keyframe 1, its keyframe's as for `WIH1`, then the keyframe's size against + `h × w` (`geometry_mismatch`); for `WIA1`, one channel (`model_mismatch`), then at least `t_out` samples, then a latent no longer than that needs (both `geometry_mismatch`); for `WIS1`, `g_s`'s shape (`geometry_mismatch`); 5. each stream in decode order (`WIH1`: the hyper-latent's, then, after `h_s` runs, - the latent's; `WIV1` with keyframe 1: the nested keyframe's decode first, then its - `h × w` (`geometry_mismatch`), then the residual stream), symbol by symbol: a + the latent's; `WIV1` with keyframe 1: the keyframe's streams, then the residual + stream), symbol by symbol: a symbol that runs its stream out (`stream_exhausted`) before its value's range (`field_range`); after its last symbol, its canonical end (`stream_noncanonical`); 6. each network as it runs, layer by layer (`field_range`, below). diff --git a/wai/int-payload-conformance/README.md b/wai/int-payload-conformance/README.md index 0758719f7..868405d07 100644 --- a/wai/int-payload-conformance/README.md +++ b/wai/int-payload-conformance/README.md @@ -26,7 +26,9 @@ extension's closed list (§8). code, or, for `wiv1_end_state` and `wiv1_one_frame_end_state`, a small clip written to end its stream in a state other than `2^31`. The fixtures named `*_order_*` break two rules at once and pin which one a sink reports (the extension's §2 and - §7). The fixtures for `bad_magic` are the JSON sources themselves: a tool's JSON + §7), and so do the `wiv1_geometry_flip_*` fixtures: `wiv1_geometry` with one byte + of its keyframe's last stream words changed, refused for the keyframe's size before + that stream is read. The fixtures for `bad_magic` are the JSON sources themselves: a tool's JSON output is a converter input, never a payload. - `sets/` — a parameter-set member a fixture decodes against in place of a committed one: `entropy_median_max.json` is the tiny conditional codec's tables with one @@ -47,7 +49,9 @@ compared: - `wai_int_payload_vectors verify ../int-payload-conformance` (wai-rs) regenerates every file and requires it to equal the committed one byte for byte, decodes every payload through `int_payload::decode_int` to its sidecar's SHA-256, and requires - every fixture to be refused with its code at its layer. `tests/int_payload.rs` runs + every fixture to be refused with its code at its layer, and by + `int_payload::declared_cost` with the same code unless the decode's refusal comes + from a stream or a network run. `tests/int_payload.rs` runs the fixtures again in both build profiles, refuses every strict prefix of every payload, and runs a sweep of single-byte changes in which nothing may panic; `tests/int_payload_preflight.rs` checks that a header past the caps or past what the diff --git a/wai/int-payload-conformance/refusals/wiv1_geometry_flip_2133_fe.bin b/wai/int-payload-conformance/refusals/wiv1_geometry_flip_2133_fe.bin new file mode 100644 index 0000000000000000000000000000000000000000..e398c3de482d26100f9cad4521028786f064b329 GIT binary patch literal 2148 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 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_geometry_flip_2136_9b.bin b/wai/int-payload-conformance/refusals/wiv1_geometry_flip_2136_9b.bin new file mode 100644 index 0000000000000000000000000000000000000000..64e335c0cd879cbbb908afb9cc59a4be13b4b97b GIT binary patch literal 2148 zcmbu>jXPBN9suw!nL(3^Ry3$C*|hFnl}KH+EZta@TT88O5!rOR`xo5b^L(D~?|FW|=X}5Ccg}OZ zXItRbWkyCuzyW|6|7C_E7inWzHVnm&!vrx$W+8Kt`G^bRj`$**k!S=*4k4w;6J(kZ zfG3iS+(c#@1Ee7Rh+rB3A6@u4QiBR;K}qMJo&QLl${?a)4@&&@H`q1{ssOWox&#m7qv#GC7!DQ1O%?I=yBNy9<$yBiPHmcF&h;<*kK`;T$< zK?7gr^!Z9{OGdj^dOlD5#!yJ!S#gs&bZd?*$GJ?h{z$S~FDMbrKk-K?uXU~T{9a0W zk>5)h{5!NsYnm1GXVr3Jj8F4U&ZEMQ9KkIeA~Xj^Z;0#r4%0rnwuny6x7FxAxQbI& z-4GflUeP9R1#IGr%i0D(g3k0|i^Sdet=z1~9S`sdCxS*^6LFteh7yHV5iL@O1jtf`HB8->R5g`9NGvMv z)p}OF5FE&L(~NuPh&EN2fK7J*7W3hnrtb$_*I9RlxPIvv_CPkqnvj(ezuF%uFYBce z$GB?jgoR3=zrz99CQo%SZ?!zV<*&pKwrlZ5|DV)MqJbR>IwfAmisVV2?*xu1W)QPy zoQ>I2FHiWJv(U|WfO<5xl=gO<$85?lgQ>FVx*qH_!{75xW>_Asdert8)yuS*gwEW9 z>26vrw#%PIPgv}fyw`7)T;0i+n%yX%TO(&G}{B}?x@vvAH?jveE zM+n`Ye=QQdcquyTH=wmWMHSQ6}G{1IN z)^_y?i(0D$JC@U`i&+K*et5Yi%6W?>Dtk)y!qbsh`r|&T*8HTPd^||H;N}|T?o3~0 zd40Svd7lZi+D?&Ie)(3(GcUv|!-A-T@|i>|*A%!VPc)TBUMW`>-KXyz9mXOZ_u!=q z#@Q^=Rk*T$w!DL}CGs8>$qyRkV3|!E2=2M3;0!*%vg1bu&+-ctT+>*sc^C&y>4(W< z7gMNK*I`vwnhjymR7AL_%pgi`A}^1yl-XVC5lzM)5~cq-jDJx)BtP)^9IAZ&U20;; zUef$@kPc=nCCk3!QA>E4%HUyFB4qal@%85|ik2=jSmwS+v}$?)!F^;Ryj^Xh+MCv| z2`K+fUaR$>oO`pxL&m{WVz>`(Wz{CkYIhYF`Nlt$!ullFNl&eJFE>^HQ99>s#fu z70(3=(}qL^J}fPrx$ZQ-WiQ#l-M^R{TEys331pK!mxB zvfPcQ2(3#QmSs&*HUA9L{+3&z{M|2-bo{wVWg;<`^_ul!_D@G7)rkS5U!I#PCyKzW zp7p2;xzCB4GaXc3i)|GjR+mWLNA0CFjU5`yZYk68BwfAhc7nw8-8IqV$qQKP_FN)q z`zT~@r|53&LuH`0>VE@ZQJi`S<<688Hh zXsw&aSUq))HooCQ1eZDJw&w}eao;3U-{{8J=slG*eK`YbOR!+NZElOwW)3Q!tuqCS zpxgM$gTm@bRd#%_4nFJ1K4o#aBjBims##W^eUwQANrUX5pW* zC1Hhe^b@ZvxqZ%es0(ci3|NL*^rv2rTJooe+BE#n$NvJ&o3FZNxF&t-{R@-A=I$u6 kgfpQ0GOE#f?mH+6nP+5KM#1`r`znY$r*L(qT?JA9A5JmSVE_OC literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_geometry_flip_2137_8c.bin b/wai/int-payload-conformance/refusals/wiv1_geometry_flip_2137_8c.bin new file mode 100644 index 0000000000000000000000000000000000000000..f8727cadb3fc4f09a173e3f6c30c45d1e59e5b78 GIT binary patch literal 2148 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 literal 0 HcmV?d00001 diff --git a/wai/int-payload-conformance/refusals/wiv1_geometry_flip_2146_4a.bin b/wai/int-payload-conformance/refusals/wiv1_geometry_flip_2146_4a.bin new file mode 100644 index 0000000000000000000000000000000000000000..f713f62dcb02500b022f4d2247b7eb638b5e561a GIT binary patch literal 2148 zcmbu>jXPBN9suw!nL(3^Ry3%tvT5DDDle(4mQ^=a<%D5n|oSRA` z-?Ken+j1i#Bj5nQjQ=u2k&CpkEE|gA$6wT_0C;%=EZG2H6$}uv2VkcFAYTV?J{#c81yn-?z`Yt&`$K?^UjV3IQSF^* z-VM;*2e9c4fa?&z>9+t^-vcC!01S)*d^rZ-Jr3YFf%YM~NdW69fE46+WZl03Qjt1j z5?R6mY(!!a204LTLMo94J!lhwvpsOV?!2sd2)bi57#r(z1wAe z>%G~EyyJe8Iecr5EXS!#vf*fwS}!OO%s=r*DX(>{^!z?b zdXe8t8P;`Z6W25==+CO<#u%UGot#I79X*O$I)rNuj@}U0_Z^{qc5fA(ns2MoeQ*&c zueu>LPPn2?+6LIf7nijSf_Rpn}XJcnu{Ats+XK4)&KN4{MmZtto0Me~?&Q z-mCSjdLcNN>#7;|&Jk^{Faeuxe=PdLHBH|SxURGA3~~L^G3%V?Uut%vfNqVLjm?r@rI?r|I;E_D*?phZB}TlWzUM_N z>r>)%NO6&4}~6AQ90+dA}X+2SSxBHy>Jt7?&;E;9l9&@ zZLF8Nv%_6+Dco^%iD8(Y0(wUtG1vTywz%;*h zQ`UC%35!~*1UpyIs*70$1%70OCemrECNg_U_QKPVSoY(7s@D9ZpnN<~y5Qy-<(>>* zWqEy^FloODwAxOQSAO|c$ulp+D?!3a)94);yF0r}V?* zv5U!6tIMz|E7gXuXeuI{Rb~(=H<6czTgvP%^@t|p4vW%$9mc;X9+DsYd=6DU|1LE# zWG`ucI!FgGmXhV)@u;P|3}w);3lY3$qxkys7DY>!87y~OELt_)pWr?+5#Fx0QSD3Z z*Z7zJCa=|cP)@yB;vwT8Dk01Vx3X#zW;8vcV)~LO;cj!0K|222q%x71%X-avG5e<@lIjG1(l5_dl@m$e zR?m9Wh1}=F&6y4=uO+sM535Tg?<4n7n#K+dX19##c#@{xeLG%a`tF)&^5g}qbw@6d zxZ@U+m(G)F@(t_-vYy(pUn2O|`!FgkDoB-FkR~hM_y<{mk1J`(YZtQFd&C=39trz> zez3RsBd&*Y}DRLn!cQlwZ&U7-8Q#HsWS(a&(@oQ zMc{3GWm1#^?4Dun+wWl+TcU9Dt$aN4Y_muo-A;;|LhzLll%gv!o!M8tM^ur#lv((v zY)NQgEd9hQOKzX@9qK~c1N@hx7X7K$qn7+BqBafx^YOnx^X99r87_&RdjG=2(78K{ lEa40&zl?0Op8F0;g6A1omQk?b;b;Ysw String { @@ -243,7 +243,19 @@ fn fixtures(t: &mut Tree) -> Vec { (v.h, v.w) = (12, 12); // the same 2 × 2 block grid; the keyframe decodes 16 × 16 wire::write_wiv1(&v) }; - fx(t, "wiv1_geometry", VNK, Some(geo), None, "geometry_mismatch", "decode"); + fx(t, "wiv1_geometry", VNK, Some(geo.clone()), None, "geometry_mismatch", "decode"); + // The keyframe's size is checked against the clip's h × w before any symbol of the + // keyframe is decoded (§7): with one byte of the keyframe's last stream words + // changed, so that its stream no longer ends canonically, each is still + // geometry_mismatch. Named by the byte changed and the value XORed into it. + for (name, back, x) in [ + ("wiv1_geometry_flip_2146_4a", 2, 0x4a), ("wiv1_geometry_flip_2138_be", 10, 0xbe), + ("wiv1_geometry_flip_2136_96", 12, 0x96), ("wiv1_geometry_flip_2139_03", 9, 0x03), + ("wiv1_geometry_flip_2133_fe", 15, 0xfe), ("wiv1_geometry_flip_2136_9b", 12, 0x9b), + ("wiv1_geometry_flip_2137_8c", 11, 0x8c), + ] { + fx(t, name, VNK, edit(&geo, &|b| { let at = b.len() - back; b[at] ^= x }), None, "geometry_mismatch", "decode"); + } // ── WIH1 ── (the tiny conditional codec: m 4, 4×4; n_z 2, 2×2) fx(t, "wih1_json_form", HYP, None, Some(format!("{V}/int_hyper/tiny_bitstream.json")), "bad_magic", "parse"); @@ -404,14 +416,19 @@ fn digests(d: &IntDecoded) -> Value { } } -/// Decode `payload` through `cap` against the set `set` names (member → path). -fn decode(t: &Tree, cap: &str, payload: &[u8], set: &Value) -> Result { +/// `f` with the parameters of the set `set` names (member → path) for `cap`. +fn with_set(t: &Tree, cap: &str, set: &Value, f: impl FnOnce(&IntParams<'_>) -> R) -> R { let owned: BTreeMap> = set.as_object().into_iter().flatten() .map(|(k, v)| (k.clone(), t.read(v.as_str().expect("a set path")))).collect(); let view: BTreeMap = owned.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); let params = wai::int_payload::resolve(cap, None, &[HeldBytes::Set { listing: b"", files: &view }], None) .unwrap_or_else(|_| IntParams::none()); - decode_int(cap, payload, ¶ms) + f(¶ms) +} + +/// Decode `payload` through `cap` against the set `set` names (member → path). +fn decode(t: &Tree, cap: &str, payload: &[u8], set: &Value) -> Result { + with_set(t, cap, set, |p| decode_int(cap, payload, p)) } fn verify(wai: &Path) -> bool { @@ -455,8 +472,20 @@ fn verify(wai: &Path) -> bool { _ => None, }; let layer_ok = (f["layer"] == "parse") == parsed.is_some(); + // The declared cost reads what a decode reads before its first symbol, so it + // refuses with the decode's code, or admits the payload when the decode's + // refusal comes from a stream or a network run (§7, steps 5 and 6). + let declared = with_set(&t, cap, &f["set"], |p| declared_cost(cap, &payload, p).map(|_| ()).map_err(|e| e.code())); + let declared_ok = match declared { + Err(c) => c == code, + Ok(()) => matches!(code, "stream_exhausted" | "stream_noncanonical" | "field_range"), + }; match decode(&t, cap, &payload, &f["set"]) { - Err(e) if e.code() == code && layer_ok => { seen.insert(code); println!("ok {name}: {code}") } + Err(e) if e.code() == code && layer_ok && declared_ok => { seen.insert(code); println!("ok {name}: {code}") } + Err(e) if e.code() == code && layer_ok => { + println!("FAIL {name}: the decode gives {code}, the declared cost {declared:?}"); + ok = false + } Err(e) => { println!("FAIL {name}: refused with {e}, want {code} at {}", f["layer"]); ok = false } Ok(_) => { println!("FAIL {name}: decoded, want {code}"); ok = false } } diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index 7d71c711d..fc77da9cf 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -417,7 +417,9 @@ pub struct DeclaredCost { /// check a decode makes before its first symbol (the set's members and networks /// planned), with the refusal a decode would give, then the sizes those checks fix. /// A `WIV1` with keyframe 1 is also refused here when its keyframe's size is not the -/// clip's (`geometry_mismatch`), which a decode finds after the keyframe. Only the five +/// clip's (`geometry_mismatch`), as a decode refuses it before it decodes the keyframe. +/// So a payload the decode would refuse before its first symbol is refused here with +/// the same code. Only the five /// capabilities SPEC §5 pins a format to have a declared cost /// (`unsupported_capability` otherwise). pub fn declared_cost(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { @@ -548,21 +550,29 @@ fn int_hyper_models( Ok((g_s, h_s, ent)) } -/// A parsed `WIH1` decoded to RGB with the capability's set. -fn wih1_image(cap: &str, k: &wire::Wih1<'_>, params: &IntParams<'_>) -> Result<(Vec, u32, u32), IntPayloadError> { +/// A parsed `WIH1` decoded to RGB with the capability's set. A `WIV1` keyframe passes +/// the clip's `h × w` as `size`: the keyframe's planned image is checked against it +/// (`geometry_mismatch`) before any of its symbols is decoded (§7). +fn wih1_image(cap: &str, k: &wire::Wih1<'_>, params: &IntParams<'_>, size: Option<(u32, u32)>) -> Result<(Vec, u32, u32), IntPayloadError> { let (g_s, h_s, ent) = int_hyper_models(cap, params)?; let s = crate::int_hyper_synth::CondStream { m: k.m as usize, sh: k.sh as usize, sw: k.sw as usize, n_z: k.n_z as usize, zh: k.zh as usize, zw: k.zw as usize, z_rans: k.z_rans, rans: k.rans, }; + if let Some((h, w)) = size { + let p = crate::int_hyper_synth::cond_plan(&g_s, &h_s, &ent, &s)?; + if p.gs.out != (3, h as usize, w as usize) { + return Err(IntPayloadError::new(Refusal::GeometryMismatch, "the keyframe's size is not the clip's h × w")); + } + } let (rgb, h, w) = crate::int_hyper_synth::decode_conditional_checked(&g_s, &h_s, &ent, &s)?; Ok((rgb, h as u32, w as u32)) } fn decode_wih1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result { let k = wire::parse_wih1(payload)?; - let (rgb, height, width) = wih1_image(cap, &k, params)?; + let (rgb, height, width) = wih1_image(cap, &k, params, None)?; Ok(IntDecoded::Image { rgb, width, height }) } @@ -587,10 +597,7 @@ fn decode_wiv1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result decode_typed(&s).map_err(video)?, Some(nested) => { let k = wire::parse_wih1(nested)?; - let (frame0, h, w) = wih1_image(cap, &k, params)?; - if (h, w) != (v.h, v.w) { - return Err(IntPayloadError::new(Refusal::GeometryMismatch, "the keyframe's size is not the clip's h × w")); - } + let (frame0, ..) = wih1_image(cap, &k, params, Some((v.h, v.w)))?; decode_pframes_typed(&frame0, &s).map_err(video)? } }; -- 2.54.0 (Apple Git-157) From c7daf867871196ce7d651d2addfa86a510b0764c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 22:22:38 -0400 Subject: [PATCH 31/34] wai: the declared cost counts the networks' multiply-accumulates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit int_payload::DeclaredCost gains macs: the multiply-accumulates the decode's networks comprise, layer by layer, from the same plan that sizes their buffers (int_hyper_synth::plan_net, the audio synthesis's plan): a convolution's every output times c·k·k, a transposed convolution's every input times out·k·k (out·k in the audio synthesis), an inverse GDN's every element times c. It is exact, summed in u128, and 0 for a WIV1 with keyframe 0. A kernel may skip a zero term, so it performs at most this many. wai-web's intPayloadCost gives it as a BigInt (macs). integer-payloads §7 states the definition. Tests: int_hyper_synth's the_planned_macs_are_every_layers_terms (200 random networks, each term counted at the shapes apply_layer forms); int_audio's the_planned_macs_are_every_layers_terms (the committed synthesis, at the lengths the reference kernel gives); int_payload_cost (0 for keyframe 0, more for every network); wai-web's the_declared_cost_is_read_before_decoding. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 4 +- wai/extensions/integer-payloads.md | 6 ++- wai/wai-rs/src/int_audio.rs | 60 ++++++++++++++++++----- wai/wai-rs/src/int_hyper_synth.rs | 72 +++++++++++++++++++++++++++- wai/wai-rs/src/int_payload/mod.rs | 20 +++++--- wai/wai-rs/src/int_splat.rs | 7 +-- wai/wai-rs/tests/int_payload_cost.rs | 6 ++- wai/wai-web/src/int_payload.rs | 17 +++++-- 8 files changed, 163 insertions(+), 29 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index b3063da5c..db3725657 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -85,7 +85,9 @@ wire format may change and such changes are recorded here. code a clip at the cap, so the header bounds the work: a sink reads the declared cost before decoding (`int_payload::declared_cost`, `container::declared_size`, the C ABI's `wai_int_payload_declared`, `wai-web`'s `intPayloadCost`) and applies its own - size policy (integer-payloads §7, *Memory and work*). A `WIA1`'s latent may be no + size policy (integer-payloads §7, *Memory and work*). The cost counts the networks' + multiply-accumulates layer by layer, exactly (`DeclaredCost::macs`, a `u128`; + `macs` on `intPayloadCost`, a `BigInt`). A `WIA1`'s latent may be no longer than its `t_out` needs (`geometry_mismatch`). The JSON reader (`int_video::VideoJson`) caps every size before it pads a motion-vector list, and reads a field that does not fit the target's `usize` as absent rather than diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 6c058a7ab..8df173952 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -325,7 +325,11 @@ a large output in a small file. The bound is the header's, within the caps: uncropped plane it accumulates into, each at most `2^26` elements (*Planning the networks*); a `WIV1` decode's frames (one byte per sample) and its motion vectors (4 bytes each); -- each network's work: its layers' outputs times the multiply-accumulates per output. +- each network's work, its multiply-accumulates layer by layer: a convolution's + every output times `c·k·k` (every tap of its padded input), a transposed + convolution's every input times `out·k·k` (`out·k` for the audio synthesis), an + inverse GDN's every element times `c`; a `WIV1` with keyframe 0 runs no network. A + kernel may skip a zero term, so it performs at most this many. A `WIA1`'s latent is the one its `t_out` needs and no longer: the synthesis of `lat_len` samples gives at least `t_out` samples and that of `lat_len − 1` fewer diff --git a/wai/wai-rs/src/int_audio.rs b/wai/wai-rs/src/int_audio.rs index 4fa266598..3b4a118f6 100644 --- a/wai/wai-rs/src/int_audio.rs +++ b/wai/wai-rs/src/int_audio.rs @@ -255,8 +255,11 @@ impl AudioJson { /// and the buffer of `out` channels of it are within [`MAX_TENSOR`] (`TooLarge`); and /// the crop `2·pad` leaves an output (`ModelMismatch`). Every size is formed in `u64` /// with checked arithmetic, a size past `u64` being over the cap, so every target -/// refuses alike. -fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize, u64), Refused> { +/// refuses alike. Returns the output `[c, t]`, the most elements one layer holds, and +/// the multiply-accumulates the layers comprise (`extensions/integer-payloads.md` §7): +/// a transposed convolution's every input times `out·k`, an inverse GDN's every +/// element times `c`, summed in `u128`. +fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize, u64, u128), Refused> { use RefusalKind::*; const CAP: u64 = MAX_TENSOR as u64; let (mut c, mut t) = (c as u64, t as u64); @@ -264,6 +267,7 @@ fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize, u64), R // included: a transposed convolution's input, uncropped buffer and output; an // inverse GDN's input, output and one position's squares (`c` values of 16 bytes). let mut live = c * t; + let mut macs: u128 = 0; for layer in &model.layers { match layer { ALayer::ConvT1d { out_ch, k, stride, pad, opad, weight, mult, bias } => { @@ -286,6 +290,7 @@ fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize, u64), R return Err(refused(ModelMismatch, "a layer's padding leaves no output")); } live = live.max(c * t + cout * full + cout * (full - crop)); + macs = macs.saturating_add((c * t) as u128 * (cout * k) as u128); (c, t) = (cout, full - crop); } ALayer::Igdn { c: cc, gamma, beta } => { @@ -295,25 +300,26 @@ fn plan(model: &AudioModel, c: usize, t: usize) -> Result<(usize, usize, u64), R return Err(refused(ModelMismatch, "an IGDN layer does not match the running channel count")); } live = live.max(2 * c * t + 2 * c); + macs = macs.saturating_add((c * t) as u128 * c as u128); } } } // Every running size is at most 2^26 here, so it fits usize on every target. - Ok((c as usize, t as usize, live)) + Ok((c as usize, t as usize, live, macs)) } /// What [`decode_typed`] checks before it decodes a symbol, and the buffers it then -/// forms. Returns the synthesis's length and the most bytes the decode holds at once -/// on the reference kernels beyond the payload and the parsed model: the latent and -/// each layer's buffers (`i64` each), then the synthesis's output (`i64`) with the -/// PCM (`i16`). +/// forms. Returns the synthesis's length, the most bytes the decode holds at once +/// on the reference kernels beyond the payload and the parsed model (the latent and +/// each layer's buffers, `i64` each, then the synthesis's output, `i64`, with the +/// PCM, `i16`), and the synthesis's multiply-accumulates on the latent. /// /// The latent's length is the one `t_out` needs and no longer: the synthesis of /// `lat_len` latent samples gives at least `t_out` samples (`GeometryMismatch` /// otherwise), and that of `lat_len − 1` gives fewer (`GeometryMismatch`), so a /// payload cannot declare a large latent to present a short clip. A length for which /// the model leaves no output gives none. -pub(crate) fn audio_plan(model: &AudioModel, s: &AudioStream<'_>) -> Result<(usize, u64), Refused> { +pub(crate) fn audio_plan(model: &AudioModel, s: &AudioStream<'_>) -> Result<(usize, u64, u128), Refused> { use RefusalKind::*; let afrac = model.afrac; if !(1..=MAX_AFRAC).contains(&afrac) { @@ -338,19 +344,19 @@ pub(crate) fn audio_plan(model: &AudioModel, s: &AudioStream<'_>) -> Result<(usi if c.checked_mul(l).is_none_or(|n| n > MAX_TENSOR) || s.t_out > MAX_TENSOR { return Err(refused(TooLarge, "the latent or the clip exceeds 2^26 elements")); } - let (oc, t, live) = plan(model, c, l)?; + let (oc, t, live, macs) = plan(model, c, l)?; if oc != 1 { return Err(refused(ModelMismatch, "the model does not give one channel")); } if t < s.t_out { return Err(refused(GeometryMismatch, "the synthesis gives fewer samples than t_out")); } - let shorter = if l > 1 { plan(model, c, l - 1).map_or(0, |(_, t, _)| t) } else { 0 }; + let shorter = if l > 1 { plan(model, c, l - 1).map_or(0, |(_, t, ..)| t) } else { 0 }; if shorter >= s.t_out { return Err(refused(GeometryMismatch, "the latent is longer than t_out needs: one sample fewer gives t_out samples")); } let working = (8 * live).max(8 * t as u64 + 2 * s.t_out as u64); - Ok((t, working)) + Ok((t, working, macs)) } /// One layer's arithmetic on the values `x` holds, checked before it runs, as @@ -495,6 +501,38 @@ fn b64_decode(s: &str) -> Result, &'static str> { } /// The fast 1-D transposed conv against the scalar reference, element by element. +#[cfg(test)] +mod plan_tests { + use super::*; + + /// The committed synthesis's planned multiply-accumulates on the committed latent are + /// its layers' terms at the lengths the reference kernel gives (run here on a zero + /// latent): a transposed convolution's every input times `out·k`, an inverse GDN's + /// every element times `c`. + #[test] + fn the_planned_macs_are_every_layers_terms() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/int_audio"); + let model = AudioModel::from_bytes(&std::fs::read(dir.join("audio_model.bin")).unwrap()).unwrap(); + let wia1 = std::fs::read(dir.join("audio_bitstream.wia1")).unwrap(); + let a = crate::int_wire::parse_wia1(&wia1).unwrap(); + let (c0, l) = (a.n_ch as usize, a.lat_len as usize); + let (.., macs) = plan(&model, c0, l).unwrap(); + let (mut c, mut t, mut terms) = (c0, l, 0u128); + for layer in &model.layers { + match layer { + ALayer::ConvT1d { out_ch, k, stride, pad, opad, weight, mult, bias } => { + let (_, to) = reference::conv_t_1d(&vec![0; c * t], c, t, weight, *out_ch, *k, *stride, *pad, *opad, mult, bias, model.shift); + terms += (c * t) as u128 * (out_ch * k) as u128; + (c, t) = (*out_ch, to); + } + ALayer::Igdn { .. } => terms += (c * t) as u128 * c as u128, + } + } + assert!(terms > 0); + assert_eq!(macs, terms); + } +} + #[cfg(test)] mod kernel_equivalence { use super::*; diff --git a/wai/wai-rs/src/int_hyper_synth.rs b/wai/wai-rs/src/int_hyper_synth.rs index a2c14cd95..c1acd61c7 100644 --- a/wai/wai-rs/src/int_hyper_synth.rs +++ b/wai/wai-rs/src/int_hyper_synth.rs @@ -1025,6 +1025,13 @@ pub(crate) struct NetPlan { /// position's squares (`c` values of 16 bytes, counted as `2·c`); a ReLU's input, /// rewritten in place. Each element is an `i64`. pub live: u64, + /// The multiply-accumulates the layers comprise (`extensions/integer-payloads.md` + /// §7): a convolution's every output times `c·k·k` (every tap of its padded + /// input), a transposed convolution's every input times `out·k·k`, an inverse + /// GDN's every element times `c`. A kernel may skip a zero term, so it performs + /// at most this many. Summed in `u128`: a layer's terms are its weights times at + /// most `2^26` positions, so no set a sink can hold reaches the type's limit. + pub macs: u128, } fn plan_layers(net: &IntModel, c: usize, h: usize, w: usize) -> Result { @@ -1038,6 +1045,7 @@ fn plan_layers(net: &IntModel, c: usize, h: usize, w: usize) -> Result Result Result { @@ -1083,6 +1093,7 @@ fn plan_layers(net: &IntModel, c: usize, h: usize, w: usize) -> Result {} IntLayer::Leaky { slope_frac, .. } => { @@ -1093,7 +1104,7 @@ fn plan_layers(net: &IntModel, c: usize, h: usize, w: usize) -> Result u128 { + self.hs.macs.saturating_add(self.gs.macs) + } } /// The fast kernels against the scalar reference, element by element. Operand @@ -1586,6 +1602,60 @@ mod checked { assert!(ran > 150, "too few nets passed the checks ({ran})"); } + /// `NetPlan::macs` is every layer's terms at the shapes the network runs at: + /// counted here one term at a time — a convolution's every output and every tap of + /// every input channel, a transposed convolution's every input and every tap of + /// every output channel, an inverse GDN's every element and every channel — over + /// the shapes `apply_layer` forms, not the plan's. + #[test] + fn the_planned_macs_are_every_layers_terms() { + let mut g = Rng(0x5EED_0003); + let mut ran = 0; + for _ in 0..200 { + let cin = 1 + g.below(4); + let (model, _) = random_net(&mut g, cin); + let (h, w) = (1 + g.below(6), 1 + g.below(6)); + let x = g.vec(cin * h * w, 1 << 16, 20); + if run_net_checked(&model, &x, cin, h, w).is_err() { + continue; + } + let plan = plan_net(&model, cin, h, w).expect("the checked run ran, so the plan admits it"); + let (mut a, mut c, mut hh, mut ww) = (x, cin, h, w); + let mut terms: u128 = 0; + for layer in &model.layers { + let (na, nc, nh, nw) = apply_layer(&model, layer, a, c, hh, ww); + match layer { + IntLayer::Conv { k, .. } => { + for _output in 0..nc * nh * nw { + for _tap in 0..c * k * k { + terms += 1; + } + } + } + IntLayer::ConvT { out_ch, k, .. } => { + for _input in 0..c * hh * ww { + for _tap in 0..out_ch * k * k { + terms += 1; + } + } + } + IntLayer::Igdn { .. } => { + for _element in 0..c * hh * ww { + for _channel in 0..c { + terms += 1; + } + } + } + IntLayer::Relu | IntLayer::Leaky { .. } => {} + } + (a, c, hh, ww) = (na, nc, nh, nw); + } + assert_eq!(plan.macs, terms); + ran += 1; + } + assert!(ran > 150, "too few nets passed the checks ({ran})"); + } + #[test] fn a_convolution_runs_up_to_the_accumulation_bound_and_no_further() { // One term: the bound is |w|·|x|. Exactly i64::MAX runs; one past refuses. diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index fc77da9cf..07475f331 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -411,6 +411,12 @@ pub struct DeclaredCost { /// trade the uncropped plane for a padded copy of the layer's input, at most 16 /// times the elements the layer reads and writes, and per-worker rows. pub working_bytes: u64, + /// The multiply-accumulates the decode's networks comprise, layer by layer: a + /// convolution's every output times `c·k·k`, a transposed convolution's every + /// input times `out·k·k` (`out·k` in the audio synthesis), an inverse GDN's every + /// element times `c` (§7). Exact, and 0 for a `WIV1` with keyframe 0, which runs + /// no network. A kernel may skip a zero term, so it performs at most this many. + pub macs: u128, } /// [`DeclaredCost`] for `payload` under `cap` against `params`: the parse and every @@ -431,7 +437,7 @@ pub fn declared_cost(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Resul Some(wire::MAGIC_WIH1) => { let k = wire::parse_wih1(payload)?; let p = cond_cost(cap, &k, params)?; - Ok(DeclaredCost { output_bytes: p.output_bytes(), symbols: p.z + p.y, working_bytes: p.working_bytes() }) + Ok(DeclaredCost { output_bytes: p.output_bytes(), symbols: p.z + p.y, working_bytes: p.working_bytes(), macs: p.macs() }) } Some(wire::MAGIC_WIA1) => cost_wia1(cap, payload, params), Some(wire::MAGIC_WIS1) => cost_wis1(cap, payload, params), @@ -469,7 +475,7 @@ fn cost_wiv1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result Ok(DeclaredCost { output_bytes, symbols: output_bytes, working_bytes: tables + working_bytes(&s) }), + None => Ok(DeclaredCost { output_bytes, symbols: output_bytes, working_bytes: tables + working_bytes(&s), macs: 0 }), Some(nested) => { let p = cond_cost(cap, &wire::parse_wih1(nested)?, params)?; if p.gs.out != (3, v.h as usize, v.w as usize) { @@ -477,7 +483,7 @@ fn cost_wiv1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result) -> Result) -> Result) -> Result) -> Result { +/// output (`i64`) with the attribute tensor (`u8`); and `g_s`'s multiply-accumulates +/// ([`crate::int_hyper_synth::NetPlan`]). +pub(crate) fn splat_plan(gs: &IntModel, st: &SplatStream<'_>) -> Result<(u64, u128), Refused> { use RefusalKind::*; let afrac = gs.afrac; if !(1..=MAX_AFRAC).contains(&afrac) { @@ -196,7 +197,7 @@ pub(crate) fn splat_plan(gs: &IntModel, st: &SplatStream<'_>) -> Result 0, "{what}: its networks run"); } - println!("{what}: declared {} working bytes ({} output, {} symbols); held {peak}", cost.working_bytes, cost.output_bytes, cost.symbols); + println!("{what}: declared {} working bytes ({} output, {} symbols, {} multiply-accumulates); held {peak}", + cost.working_bytes, cost.output_bytes, cost.symbols, cost.macs); } } diff --git a/wai/wai-web/src/int_payload.rs b/wai/wai-web/src/int_payload.rs index 63d1964c8..716c95981 100644 --- a/wai/wai-web/src/int_payload.rs +++ b/wai/wai-web/src/int_payload.rs @@ -36,14 +36,17 @@ pub(crate) fn cost(cap: &str, payload: &[u8], set: &[(&str, &[u8])]) -> Result f64 { self.working_bytes } + #[wasm_bindgen(getter)] + pub fn macs(&self) -> u128 { + self.macs + } } /// The declared cost of decoding `payload` through `capability`, before decoding it. @@ -81,7 +88,7 @@ pub fn int_payload_cost( _ => vec![], }; let c = cost(capability, payload, &set).map_err(|e| JsError::new(&e))?; - Ok(IntPayloadCost { output_bytes: c.output_bytes as f64, symbols: c.symbols as f64, working_bytes: c.working_bytes as f64 }) + Ok(IntPayloadCost { output_bytes: c.output_bytes as f64, symbols: c.symbols as f64, working_bytes: c.working_bytes as f64, macs: c.macs }) } /// The conditional codec's three set members, from the host's bytes. @@ -153,9 +160,11 @@ mod tests { }; assert_eq!((c.output_bytes, c.symbols), (clip as u64, clip as u64)); assert!(c.working_bytes > c.output_bytes); + assert_eq!(c.macs, 0, "a keyframe-0 clip runs no network"); let (g, h, e) = (read("int_hyper/tiny_g_s.bin"), read("int_hyper/tiny_h_s.bin"), read("int_hyper/tiny_entropy.json")); let img = cost("wai.neural.int_hyper", &read("int_hyper/tiny_bitstream.wih1"), &int_hyper_set(&g, &h, &e)).unwrap(); assert_eq!(img.output_bytes, 16 * 16 * 3); + assert!(img.macs > 0, "the image's networks run"); assert!(cost("wai.video.int_motion", &read("int_video/int_video_bitstream.json"), &[]).unwrap_err().starts_with("bad_magic")); } } -- 2.54.0 (Apple Git-157) From 2888206f7b778652b470ed8ae504bf2a2bbba50a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 22:31:18 -0400 Subject: [PATCH 32/34] wai: the reference sink applies the declared cost before decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No reference sink read the declared cost: a few kilobytes of valid stream could make the neural sink, or its C ABI, decode a clip at the 2^30-byte cap. - neural::ModelRegistry::with_cost_limit takes a limit on each figure of int_payload::DeclaredCost (DeclaredCost::new, a figure left unbounded at its type's maximum). An integer payload over it is refused before any symbol is decoded, with the new code cost_over_limit (int_payload::check_cost, neural::DecodeError::OverCostLimit); it is the sink's policy, so dispatch moves on as for a capability the sink cannot decode: a WAI1 to its fallback, a WAI2 to its next entry. integer-payloads §8 lists the code and its routing, and §7 places the check after the networks are planned. - neural::declared_cost reads the cost over the parameters resolve_verified returns, so a registry user reads it over the bytes a pin verified without building int_payload::IntParams. - The C ABI's neural entry points do not advertise a capability that decodes to integer video, PCM or splat attributes, which have no wai_decoded_t kind (neural::decodes_to_int): such a payload is refused before it is decoded, not after. - wai_int_payload_declared takes the parameter set's path, or null for the header alone, and wai_int_declared_t gains working_bytes, the multiply-accumulates (macs_lo, macs_hi) and cost_known. The header alone fixes them for a keyframe-0 WIV1 (int_wire::Wiv1::keyframe0_working_bytes, which declared_cost now uses too; container::DeclaredSize gains working_bytes and macs). Tests: neural_cost_limit (its own binary, a live-bytes allocator): a clip over the limit refused having held under 64 KiB, the next WAI2 entry decoded; the C ABI refusing the clip under 64 KiB through wai_neural_decode_ex and wai_neural_decode_video; the cost over a registry's set and through the C ABI equal to the library's, and set_incomplete without a member. ffi_envelope and int_payload_cost check the header-only figures. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-neural-gates.yml | 15 ++ wai/CHANGELOG.md | 12 +- wai/extensions/integer-payloads.md | 21 ++- wai/wai-rs/include/wai.h | 40 +++-- wai/wai-rs/src/container.rs | 27 ++- wai/wai-rs/src/ffi.rs | 110 ++++++++++-- wai/wai-rs/src/ffi_neural.rs | 50 +++++- wai/wai-rs/src/int_payload/mod.rs | 43 ++++- wai/wai-rs/src/int_payload/wire.rs | 19 +- wai/wai-rs/src/neural/mod.rs | 138 ++++++++++++--- wai/wai-rs/tests/ffi_envelope.rs | 12 +- wai/wai-rs/tests/int_payload_cost.rs | 3 + wai/wai-rs/tests/neural_cost_limit.rs | 230 +++++++++++++++++++++++++ 13 files changed, 638 insertions(+), 82 deletions(-) create mode 100644 wai/wai-rs/tests/neural_cost_limit.rs diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index e576e37df..121c8c58a 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -44,6 +44,7 @@ on: - 'wai/wai-rs/src/ffi.rs' - 'wai/wai-rs/src/ffi_neural.rs' - 'wai/wai-rs/tests/ffi_neural_pin.rs' + - 'wai/wai-rs/tests/neural_cost_limit.rs' - 'wai/wai-rs/src/int_synth.rs' - 'wai/wai-rs/src/ort_session.rs' - 'wai/neural-conformance/vectors/**' @@ -80,6 +81,7 @@ on: - 'wai/wai-rs/src/ffi.rs' - 'wai/wai-rs/src/ffi_neural.rs' - 'wai/wai-rs/tests/ffi_neural_pin.rs' + - 'wai/wai-rs/tests/neural_cost_limit.rs' - 'wai/wai-rs/src/int_synth.rs' - 'wai/wai-rs/src/ort_session.rs' - 'wai/neural-conformance/vectors/**' @@ -157,6 +159,19 @@ jobs: --exact integer_media_decode_through_the_neural_dispatch \ 2>&1 | tee "$RUNNER_TEMP/neural-int.log" grep -q "test result: ok. 1 passed; 0 failed" "$RUNNER_TEMP/neural-int.log" + # The registry's cost limit refuses an integer payload over it before any symbol is + # decoded (cost_over_limit) and moves on to the next entry; the C ABI refuses an + # integer video it has no output kind for before decoding it; and the declared + # cost over a registry's set, and through the C ABI given the set's path, is the + # library's. Its own binary: a live-bytes allocator measures each refusal. + - name: The neural sink applies the declared cost before decoding + working-directory: wai/wai-rs + shell: bash + run: | + set -euo pipefail + cargo test --no-default-features --features neural,ffi,video_int,audio_int,splat_int --test neural_cost_limit \ + 2>&1 | tee "$RUNNER_TEMP/neural-cost.log" + grep -q "test result: ok. 3 passed; 0 failed" "$RUNNER_TEMP/neural-cost.log" - name: A key log refuses a revoked pinned set at the neural sink (SPEC §3.1 check 5) working-directory: wai/wai-rs shell: bash diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index db3725657..c6434edf3 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -87,7 +87,17 @@ wire format may change and such changes are recorded here. C ABI's `wai_int_payload_declared`, `wai-web`'s `intPayloadCost`) and applies its own size policy (integer-payloads §7, *Memory and work*). The cost counts the networks' multiply-accumulates layer by layer, exactly (`DeclaredCost::macs`, a `u128`; - `macs` on `intPayloadCost`, a `BigInt`). A `WIA1`'s latent may be no + `macs` on `intPayloadCost`, a `BigInt`). The reference sink applies one: + `neural::ModelRegistry::with_cost_limit` refuses a payload whose declared cost + exceeds the limit before any symbol is decoded, with the new code `cost_over_limit` + (`int_payload::check_cost`, `neural::DecodeError::OverCostLimit`), and dispatch moves + on as for a capability the sink cannot decode (§8). `neural::declared_cost` reads the + cost over the set a registry resolved, pinned or not. The C ABI's + `wai_int_payload_declared` takes the set's path (or null for the header alone) and + gives the working bytes and the multiply-accumulates too (`wai_int_declared_t` + gains `working_bytes`, `macs_lo`, `macs_hi` and `cost_known`); its neural decode + entry points no longer decode integer video, PCM or splat attributes, which they + have no output kind for, and refuse them before decoding. A `WIA1`'s latent may be no longer than its `t_out` needs (`geometry_mismatch`). The JSON reader (`int_video::VideoJson`) caps every size before it pads a motion-vector list, and reads a field that does not fit the target's `usize` as absent rather than diff --git a/wai/extensions/integer-payloads.md b/wai/extensions/integer-payloads.md index 8df173952..b3ebb8d63 100644 --- a/wai/extensions/integer-payloads.md +++ b/wai/extensions/integer-payloads.md @@ -339,9 +339,13 @@ samples. A decode refuses a stream that runs out at its first missing symbol (§2), so a short stream stops early, but a stream that carries its symbols is decoded in full: a sink that will not spend what a header declares refuses before decoding, by its own size -policy, applied to the declared cost (`int_payload::declared_cost`, with the set; the -header alone through `container::declared_size`, the C ABI's -`wai_int_payload_declared` and `wai-web`'s `intPayloadCost`). This extension fixes the +policy, applied to the declared cost (`int_payload::declared_cost` and +`int_payload::check_cost`, with the set; `neural::declared_cost` over the set a +registry resolved; the C ABI's `wai_int_payload_declared`, given the set's path; +`wai-web`'s `intPayloadCost`; the header alone through `container::declared_size`), +`cost_over_limit` (§8). The reference neural sink applies one when it is given one +(`neural::ModelRegistry::with_cost_limit`); its C ABI does not decode integer video, +PCM or splat attributes, which it has no output kind for. This extension fixes the caps; a sink's policy may be stricter. **The parameter set must fit the payload** (`model_mismatch`): @@ -388,7 +392,9 @@ order and refuses with the first that fails: 6. each network as it runs, layer by layer (`field_range`, below). So a run-out is reported before the value it would have given, and a size past the -caps before a shape that disagrees. +caps before a shape that disagrees. A sink that applies a cost limit (*Memory and +work*) compares the declared cost with it after step 4, once everything the cost reads +has been checked, and before any symbol is decoded (`cost_over_limit`, §8). **Exactness.** A latent rebuilt as `s·2^afrac + base` must fit `i64`; before each network layer runs, the decode checks on the values present that no product, partial @@ -413,7 +419,11 @@ capability at all, and route as SPEC §4 routes such a capability: (SPEC §7.1); - `not_dispatchable` and `unsupported_capability` name a capability the sink does not decode: SPEC §4 does not take step 3 (or 5) with it, so a `WAI1` continues at step 4 - and a `WAI2` at its next entry, as for a capability the sink does not advertise. + and a `WAI2` at its next entry, as for a capability the sink does not advertise; +- `cost_over_limit` is the sink's own policy, not a fault of the payload: a payload + whose declared cost exceeds the limit the sink applies (§7, *Memory and work*) is + refused before any symbol is decoded, and routes as `unsupported_capability` does, + so a `WAI1` continues at step 4 and a `WAI2` at its next entry. | code | when | |---|---| @@ -436,6 +446,7 @@ capability at all, and route as SPEC §4 routes such a capability: | `not_dispatchable` | a capability SPEC §4 does not dispatch | | `unsupported_capability` | a capability the sink has no integer decoder for | | `payload_malformed` | a `wai.neural.int_synth` or `wai.neural.int_mlicpp` payload its decoder refuses | +| `cost_over_limit` | a payload whose declared cost (§7) exceeds the limit the sink applies before decoding | ## 9. Parameter sets diff --git a/wai/wai-rs/include/wai.h b/wai/wai-rs/include/wai.h index 4fd3ed5f2..e80552da8 100644 --- a/wai/wai-rs/include/wai.h +++ b/wai/wai-rs/include/wai.h @@ -346,14 +346,21 @@ typedef struct wai_buffer_t { } wai_buffer_t; /** - * What an integer payload's header declares (`wai_int_payload_declared`): the - * decoded output's bytes, where the header alone fixes them (`output_known` 1), and - * the symbols its streams code. + * What decoding an integer payload will cost (`wai_int_payload_declared`, + * `extensions/integer-payloads.md` §7, *Memory and work*): the decoded output's + * bytes (`output_known` 1 where they are filled), the symbols its streams code, and, + * where `cost_known` is 1, the most bytes the reference decode holds at once beyond + * the payload and the parsed set (`working_bytes`) and the networks' + * multiply-accumulates, `macs_hi·2^64 + macs_lo`. */ typedef struct wai_int_declared_t { uint64_t output_bytes; uint8_t output_known; uint64_t symbols; + uint64_t working_bytes; + uint64_t macs_lo; + uint64_t macs_hi; + uint8_t cost_known; } wai_int_declared_t; /** @@ -409,23 +416,33 @@ extern "C" { const char *wai_last_error(void); /** - * Read what the payload of an integer capability declares before decoding it, so a - * sink can refuse a payload past its own size policy first + * Read what decoding the payload of an integer capability will cost before decoding + * it, so a sink can refuse a payload past its own size policy first * (`extensions/integer-payloads.md` §7, *Memory and work*). `capability` is the * envelope's capability (or the `WAI2` component's). The payload is parsed as a sink * parses it: one that is not the format SPEC §5 pins `capability` to is * `WAI_ERR_INVALID_ARG`, with its code in `wai_last_error`, and so is a capability - * with no pinned integer format. A `wai.neural.int_hyper` image's size is its - * synthesis network's, so `output_known` is 0 for it. + * with no pinned integer format. + * + * With `set_path` null, only the header is read: a `wai.neural.int_hyper` image's + * size is its synthesis network's, so `output_known` is 0 for it, and `cost_known` is + * 1 only for a `WIV1` with keyframe 0, which runs no network. With `set_path` naming + * the capability's parameter set — a directory holding its members by the names §9 + * gives, or the file of a one-file set — every figure is filled + * (`int_payload::declared_cost`), and the set is checked against the payload as a + * decode checks it first (`set_incomplete`, `model_mismatch`, `geometry_mismatch`, + * …). The set is read as given, unverified: a pin is verified when the envelope is + * decoded. A build without `neural_int` reads no set. * * # Safety - * `capability` NUL-terminated; `payload` valid for `payload_len` bytes; `out` - * writable. + * `capability` NUL-terminated; `payload` valid for `payload_len` bytes; `set_path` + * null or NUL-terminated UTF-8; `out` writable. */ int wai_int_payload_declared(const char *capability, const uint8_t *payload, uintptr_t payload_len, + const char *set_path, struct wai_int_declared_t *out); /** @@ -714,7 +731,10 @@ int wai_neural_decode_video(const uint8_t *envelope, * array of paths. The first path is the registered artifact; the others are * further sets a pin may resolve against (a set and its successor). Every pin is * verified first (SPEC §3.1); a `WAI2` rendition is picked by SPEC §7.1. With - * `flags & WAI_DECODE_STRICT`, SPEC §7's replicate rule applies too. + * `flags & WAI_DECODE_STRICT`, SPEC §7's replicate rule applies too. A capability + * whose output has no `wai_decoded_t` kind (integer video, PCM, splat attributes) is + * not advertised, whatever `registry_json` says: dispatch moves on past it before + * decoding, and when nothing else decodes the error names it. * * `*out` is written on every call with a non-null `out`, before anything else is * read: on failure it holds `kind` 0 and an empty `data`, so the caller may free diff --git a/wai/wai-rs/src/container.rs b/wai/wai-rs/src/container.rs index fc1a6a652..c97f2c41d 100644 --- a/wai/wai-rs/src/container.rs +++ b/wai/wai-rs/src/container.rs @@ -1420,6 +1420,14 @@ pub struct DeclaredSize { pub output_bytes: Option, /// The symbols the payload's streams code: one entropy-decode step each. pub symbols: u64, + /// The most bytes the reference decode holds at once beyond the payload, where the + /// header alone fixes it: a `WIV1` with keyframe 0 + /// ([`crate::int_wire::Wiv1::keyframe0_working_bytes`]). `None` where a network + /// runs, whose buffers the parameter set sizes. + pub working_bytes: Option, + /// The networks' multiply-accumulates, where the header alone fixes them: 0 for a + /// `WIV1` with keyframe 0, which runs no network; `None` otherwise. + pub macs: Option, } /// [`DeclaredSize`] for `payload` under `capability`, after the parse @@ -1434,20 +1442,23 @@ pub fn declared_size(capability: &str, payload: &[u8]) -> Result { let v = w::parse_wiv1(payload, Some((capability == crate::codecs::CAP_VIDEO_INT_HYPER) as u8)).map_err(fail)?; let frame = v.h as u64 * v.w as u64 * 3; - let symbols = match v.wih1 { - None => frame * v.n_frames as u64, - Some(k) => frame * (v.n_frames as u64 - 1) + wih1(&w::parse_wih1(k).map_err(fail)?), - }; - DeclaredSize { output_bytes: Some(frame * v.n_frames as u64), symbols } + let output_bytes = Some(frame * v.n_frames as u64); + match v.wih1 { + None => DeclaredSize { output_bytes, symbols: frame * v.n_frames as u64, working_bytes: Some(v.keyframe0_working_bytes()), macs: Some(0) }, + Some(k) => { + let symbols = frame * (v.n_frames as u64 - 1) + wih1(&w::parse_wih1(k).map_err(fail)?); + DeclaredSize { output_bytes, symbols, working_bytes: None, macs: None } + } + } } - Some(w::MAGIC_WIH1) => DeclaredSize { output_bytes: None, symbols: wih1(&w::parse_wih1(payload).map_err(fail)?) }, + Some(w::MAGIC_WIH1) => DeclaredSize { output_bytes: None, symbols: wih1(&w::parse_wih1(payload).map_err(fail)?), working_bytes: None, macs: None }, Some(w::MAGIC_WIA1) => { let a = w::parse_wia1(payload).map_err(fail)?; - DeclaredSize { output_bytes: Some(2 * a.t_out as u64), symbols: a.n_ch as u64 * a.lat_len as u64 } + DeclaredSize { output_bytes: Some(2 * a.t_out as u64), symbols: a.n_ch as u64 * a.lat_len as u64, working_bytes: None, macs: None } } Some(_) => { let v = w::parse_wis1(payload).map_err(fail)?; - DeclaredSize { output_bytes: Some(v.attr as u64 * v.s as u64 * v.s as u64), symbols: v.c as u64 * v.sh as u64 * v.sw as u64 } + DeclaredSize { output_bytes: Some(v.attr as u64 * v.s as u64 * v.s as u64), symbols: v.c as u64 * v.sh as u64 * v.sw as u64, working_bytes: None, macs: None } } })) } diff --git a/wai/wai-rs/src/ffi.rs b/wai/wai-rs/src/ffi.rs index 7e890b8c5..dcc85210c 100644 --- a/wai/wai-rs/src/ffi.rs +++ b/wai/wai-rs/src/ffi.rs @@ -167,33 +167,107 @@ fn emit_err(e: crate::container::EmitError) -> (c_int, String) { } } -/// What an integer payload's header declares (`wai_int_payload_declared`): the -/// decoded output's bytes, where the header alone fixes them (`output_known` 1), and -/// the symbols its streams code. +/// What decoding an integer payload will cost (`wai_int_payload_declared`, +/// `extensions/integer-payloads.md` §7, *Memory and work*): the decoded output's +/// bytes (`output_known` 1 where they are filled), the symbols its streams code, and, +/// where `cost_known` is 1, the most bytes the reference decode holds at once beyond +/// the payload and the parsed set (`working_bytes`) and the networks' +/// multiply-accumulates, `macs_hi·2^64 + macs_lo`. #[repr(C)] #[derive(Debug, Clone, Copy, Default)] pub struct WaiIntDeclared { pub output_bytes: u64, pub output_known: u8, pub symbols: u64, + pub working_bytes: u64, + pub macs_lo: u64, + pub macs_hi: u64, + pub cost_known: u8, } -/// Read what the payload of an integer capability declares before decoding it, so a -/// sink can refuse a payload past its own size policy first +impl WaiIntDeclared { + /// The figures `container::declared_size` reads from the header alone. + fn from_size(d: crate::container::DeclaredSize) -> Self { + let macs = d.macs.unwrap_or(0); + WaiIntDeclared { + output_bytes: d.output_bytes.unwrap_or(0), + output_known: d.output_bytes.is_some() as u8, + symbols: d.symbols, + working_bytes: d.working_bytes.unwrap_or(0), + macs_lo: macs as u64, + macs_hi: (macs >> 64) as u64, + cost_known: (d.working_bytes.is_some() && d.macs.is_some()) as u8, + } + } +} + +/// The members of `capability`'s parameter set read from `set_path`: inside it where +/// it is a directory, by the names the set lists them under; the file itself for a set +/// of one file; else the file named for the member beside it. A member that cannot be +/// read is absent, and the cost then refuses with `set_incomplete`. +#[cfg(feature = "neural_int")] +fn read_set(capability: &str, set_path: &std::path::Path) -> std::collections::BTreeMap> { + let members = crate::int_payload::set_members(capability); + members.iter() + .filter_map(|m| { + let f = if set_path.is_dir() { + set_path.join(m) + } else if members.len() == 1 { + set_path.to_path_buf() + } else { + set_path.parent().map(|d| d.join(m)).unwrap_or_else(|| std::path::PathBuf::from(m)) + }; + std::fs::read(f).ok().map(|b| (m.to_string(), b)) + }) + .collect() +} + +/// Every figure of the declared cost, over the set read from `set_path`. +#[cfg(feature = "neural_int")] +fn cost_with_set(capability: &str, payload: &[u8], set_path: &str) -> std::result::Result { + use crate::int_payload::{declared_cost, resolve}; + let files = read_set(capability, std::path::Path::new(set_path)); + let view: std::collections::BTreeMap = files.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let params = resolve(capability, None, &[crate::pin::HeldBytes::Set { listing: b"", files: &view }], None) + .map_err(|r| (WAI_ERR_INVALID_ARG, format!("{}: {r}", r.code())))?; + let c = declared_cost(capability, payload, ¶ms).map_err(|e| (WAI_ERR_INVALID_ARG, e.to_string()))?; + Ok(WaiIntDeclared { + output_bytes: c.output_bytes, output_known: 1, symbols: c.symbols, working_bytes: c.working_bytes, + macs_lo: c.macs as u64, macs_hi: (c.macs >> 64) as u64, cost_known: 1, + }) +} + +#[cfg(not(feature = "neural_int"))] +fn cost_with_set(capability: &str, _: &[u8], _: &str) -> std::result::Result { + Err((WAI_ERR_INVALID_ARG, format!("{capability}: this build reads no parameter set (feature neural_int)"))) +} + +/// Read what decoding the payload of an integer capability will cost before decoding +/// it, so a sink can refuse a payload past its own size policy first /// (`extensions/integer-payloads.md` §7, *Memory and work*). `capability` is the /// envelope's capability (or the `WAI2` component's). The payload is parsed as a sink /// parses it: one that is not the format SPEC §5 pins `capability` to is /// `WAI_ERR_INVALID_ARG`, with its code in `wai_last_error`, and so is a capability -/// with no pinned integer format. A `wai.neural.int_hyper` image's size is its -/// synthesis network's, so `output_known` is 0 for it. +/// with no pinned integer format. +/// +/// With `set_path` null, only the header is read: a `wai.neural.int_hyper` image's +/// size is its synthesis network's, so `output_known` is 0 for it, and `cost_known` is +/// 1 only for a `WIV1` with keyframe 0, which runs no network. With `set_path` naming +/// the capability's parameter set — a directory holding its members by the names §9 +/// gives, or the file of a one-file set — every figure is filled +/// (`int_payload::declared_cost`), and the set is checked against the payload as a +/// decode checks it first (`set_incomplete`, `model_mismatch`, `geometry_mismatch`, +/// …). The set is read as given, unverified: a pin is verified when the envelope is +/// decoded. A build without `neural_int` reads no set. /// /// # Safety -/// `capability` NUL-terminated; `payload` valid for `payload_len` bytes; `out` -/// writable. +/// `capability` NUL-terminated; `payload` valid for `payload_len` bytes; `set_path` +/// null or NUL-terminated UTF-8; `out` writable. #[unsafe(no_mangle)] pub unsafe extern "C" fn wai_int_payload_declared( capability: *const c_char, payload: *const u8, payload_len: usize, + set_path: *const c_char, out: *mut WaiIntDeclared, ) -> c_int { if capability.is_null() || out.is_null() { @@ -204,15 +278,17 @@ pub unsafe extern "C" fn wai_int_payload_declared( let cap = unsafe { std::ffi::CStr::from_ptr(capability) }.to_string_lossy().to_string(); let p = unsafe { input_slice(payload, payload_len) } .ok_or((WAI_ERR_INVALID_ARG, "payload null + len>0".into()))?; - match crate::container::declared_size(&cap, p) { - Ok(Some(d)) => Ok(WaiIntDeclared { - output_bytes: d.output_bytes.unwrap_or(0), - output_known: d.output_bytes.is_some() as u8, - symbols: d.symbols, - }), - Ok(None) => Err((WAI_ERR_INVALID_ARG, format!("{cap} has no pinned integer payload format"))), - Err(e) => Err((WAI_ERR_INVALID_ARG, e.to_string())), + let header = match crate::container::declared_size(&cap, p) { + Ok(Some(d)) => WaiIntDeclared::from_size(d), + Ok(None) => return Err((WAI_ERR_INVALID_ARG, format!("{cap} has no pinned integer payload format"))), + Err(e) => return Err((WAI_ERR_INVALID_ARG, e.to_string())), + }; + if set_path.is_null() { + return Ok(header); } + let set = unsafe { std::ffi::CStr::from_ptr(set_path) }.to_str() + .map_err(|_| (WAI_ERR_INVALID_ARG, "set_path is not UTF-8".to_string()))?; + cost_with_set(&cap, p, set) }); match r { Ok(Ok(d)) => { unsafe { *out = d; } WAI_OK } diff --git a/wai/wai-rs/src/ffi_neural.rs b/wai/wai-rs/src/ffi_neural.rs index fa183ebec..b3c2fbfa6 100644 --- a/wai/wai-rs/src/ffi_neural.rs +++ b/wai/wai-rs/src/ffi_neural.rs @@ -19,6 +19,12 @@ //! `WAI_ERR_UNSOUND_REPLICATE`, and a `WAI2` envelope with nothing to pick is //! `WAI_ERR_NO_RENDITION`. Output buffers are heap-allocated here; free them with //! `wai_buffer_free`. +//! +//! Integer video, PCM and splat attributes (`neural::decodes_to_int`) have no +//! `wai_decoded_t` kind; the Rust API returns them (`int_payload::decode_int`). These +//! entry points do not advertise those capabilities, so such a payload is refused +//! before anything is decoded, and dispatch moves on to a fallback this ABI can +//! return. use std::ffi::{c_char, c_int, CStr}; use std::panic; @@ -28,8 +34,8 @@ use std::slice; use crate::container::{Wai, WaiMulti}; use crate::ffi::{set_err, WaiBuffer, WAI_ERR_CODEC, WAI_ERR_CONTAINER, WAI_ERR_INVALID_ARG, WAI_ERR_NO_RENDITION, WAI_ERR_PANIC, WAI_ERR_PRIOR, WAI_ERR_UNSOUND_REPLICATE, WAI_OK}; -use crate::neural::{decode_envelope, decode_envelope_strict, decode_multi, decode_multi_strict, DecodeError, - Decoded, ModelRegistry}; +use crate::neural::{decode_envelope, decode_envelope_strict, decode_multi, decode_multi_strict, decodes_to_int, + DecodeError, Decoded, ModelRegistry}; unsafe fn input_envelope<'a>(bytes: *const u8, len: usize) -> Option<&'a [u8]> { if bytes.is_null() && len > 0 { return None; } @@ -66,14 +72,25 @@ fn run Result<(), (c_int, String)> + panic::UnwindSafe>(f: F) -> } } +/// The refusal of a capability whose output this ABI has no kind for. +fn no_kind(caps: &[&str]) -> (c_int, String) { + (WAI_ERR_CODEC, format!( + "{} decodes to integer video, PCM or splat attributes, which have no wai_decoded_t kind: \ + not decoded here; decode it with the Rust API (int_payload::decode_int)", caps.join(", "))) +} + /// Parse a `WAI1` envelope and decode it with `model_path` registered for its -/// capability. +/// capability. A capability whose output has no `wai_decoded_t` kind is refused before +/// anything is decoded. fn decode_one(envelope: *const u8, envelope_len: usize, model_path: *const c_char) -> Result { let bytes = unsafe { input_envelope(envelope, envelope_len) } .ok_or((WAI_ERR_INVALID_ARG, "envelope null with len>0".to_string()))?; let path = unsafe { input_cstr(model_path) } .ok_or((WAI_ERR_INVALID_ARG, "model_path null or not UTF-8".to_string()))?; let env = Wai::from_bytes(bytes).map_err(|e| (WAI_ERR_CONTAINER, format!("envelope parse: {e}")))?; + if decodes_to_int(env.capability()) { + return Err(no_kind(&[env.capability()])); + } let reg = ModelRegistry::new().register(env.capability().to_string(), Path::new(path)); decode_envelope(&env, ®).map_err(decode_status) } @@ -211,7 +228,10 @@ impl WaiDecoded { /// array of paths. The first path is the registered artifact; the others are /// further sets a pin may resolve against (a set and its successor). Every pin is /// verified first (SPEC §3.1); a `WAI2` rendition is picked by SPEC §7.1. With -/// `flags & WAI_DECODE_STRICT`, SPEC §7's replicate rule applies too. +/// `flags & WAI_DECODE_STRICT`, SPEC §7's replicate rule applies too. A capability +/// whose output has no `wai_decoded_t` kind (integer video, PCM, splat attributes) is +/// not advertised, whatever `registry_json` says: dispatch moves on past it before +/// decoding, and when nothing else decodes the error names it. /// /// `*out` is written on every call with a non-null `out`, before anything else is /// read: on failure it holds `kind` 0 and an empty `data`, so the caller may free @@ -241,7 +261,12 @@ pub unsafe extern "C" fn wai_neural_decode_ex( let map: std::collections::BTreeMap> = serde_json::from_str(rj) .map_err(|e| (WAI_ERR_INVALID_ARG, format!("registry_json: {e}")))?; let mut reg = ModelRegistry::new(); + let mut kindless: Vec = Vec::new(); for (cap, paths) in map { + if decodes_to_int(&cap) { + kindless.push(cap); + continue; + } let mut it = paths.into_iter(); if let Some(first) = it.next() { reg = reg.register(cap.clone(), first); @@ -253,14 +278,22 @@ pub unsafe extern "C" fn wai_neural_decode_ex( let strict = flags & WAI_DECODE_STRICT != 0; let version = crate::container::detect_version(bytes) .map_err(|e| (WAI_ERR_CONTAINER, e.to_string()))?; + // A refusal at an envelope that names a capability left unadvertised above says so. + let explain = |named: Vec<&str>, e: DecodeError| { + let left: Vec<&str> = named.into_iter().filter(|c| kindless.iter().any(|k| k == c)).collect(); + let (code, msg) = decode_status(e); + if left.is_empty() { (code, msg) } else { (code, format!("{msg}; {}", no_kind(&left).1)) } + }; let (index, decoded) = if version == 1 { let env = Wai::from_bytes(bytes).map_err(|e| (WAI_ERR_CONTAINER, format!("envelope parse: {e}")))?; let d = if strict { decode_envelope_strict(&env, ®) } else { decode_envelope(&env, ®) }; - (0, d.map_err(decode_status)?) + let named = [Some(env.capability()), env.fallback()].into_iter().flatten().collect(); + (0, d.map_err(|e| explain(named, e))?) } else { let m = WaiMulti::from_bytes(bytes).map_err(|e| (WAI_ERR_CONTAINER, format!("envelope parse: {e}")))?; let r = if strict { decode_multi_strict(&m, ®) } else { decode_multi(&m, ®) }; - r.map_err(decode_status)? + let named = m.manifest.renditions.iter().map(|r| r.capability.as_str()).collect(); + r.map_err(|e| explain(named, e))? }; let mut o = WaiDecoded { index: index as u32, ..WaiDecoded::empty() }; match decoded { @@ -283,9 +316,8 @@ pub unsafe extern "C" fn wai_neural_decode_ex( o.fps_x_1000 = (v.fps * 1000.0).round() as u32; o.data = WaiBuffer::from_vec(v.frames_rgb.concat()); } - // Integer video, PCM and splat attributes have no `wai_decoded_t` kind; the - // Rust API returns them (`int_payload::decode_int`). Refused here rather - // than converted, so no caller reads them as another kind. + // Not reached: such a capability is not advertised above. Refused rather + // than converted, so no caller could read one as another kind. Decoded::Int(_) => { return Err((WAI_ERR_CODEC, "this integer output (video, PCM or splat attributes) has no wai_decoded_t kind; decode it with the Rust API".to_string())); } diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index 07475f331..d8905d011 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -419,6 +419,47 @@ pub struct DeclaredCost { pub macs: u128, } +impl DeclaredCost { + /// A cost from its four figures: what a sink gives [`check_cost`] (and the neural + /// registry's `with_cost_limit`) as its limit. A figure the sink does not bound is + /// its type's maximum. + pub const fn new(output_bytes: u64, symbols: u64, working_bytes: u64, macs: u128) -> Self { + Self { output_bytes, symbols, working_bytes, macs } + } + + /// The first figure of this cost over `limit`'s, by name, or `None` where every + /// figure is within it. + pub fn exceeds(&self, limit: &DeclaredCost) -> Option<&'static str> { + if self.output_bytes > limit.output_bytes { + Some("output bytes") + } else if self.symbols > limit.symbols { + Some("symbols") + } else if self.working_bytes > limit.working_bytes { + Some("working bytes") + } else if self.macs > limit.macs { + Some("multiply-accumulates") + } else { + None + } + } +} + +/// A sink's cost limit (§7, *Memory and work*), applied before any symbol is decoded: +/// [`declared_cost`], with its refusal where the payload or the set fails a check it +/// reads, and `cost_over_limit` where the cost exceeds `limit` in any figure. A +/// capability with no declared cost (`wai.neural.int_synth`, `wai.neural.int_mlicpp`) +/// has nothing to check, `Ok(None)`; it is decoded as before. +pub fn check_cost(cap: &str, payload: &[u8], params: &IntParams<'_>, limit: &DeclaredCost) -> Result, IntPayloadError> { + if crate::codecs::pinned_int_payload(cap).is_none() { + return Ok(None); + } + let cost = declared_cost(cap, payload, params)?; + match cost.exceeds(limit) { + None => Ok(Some(cost)), + Some(what) => Err(IntPayloadError::new(Refusal::CostOverLimit, format!("{cap}: the declared {what} exceed the sink's limit"))), + } +} + /// [`DeclaredCost`] for `payload` under `cap` against `params`: the parse and every /// check a decode makes before its first symbol (the set's members and networks /// planned), with the refusal a decode would give, then the sizes those checks fix. @@ -475,7 +516,7 @@ fn cost_wiv1(cap: &str, payload: &[u8], params: &IntParams<'_>) -> Result Ok(DeclaredCost { output_bytes, symbols: output_bytes, working_bytes: tables + working_bytes(&s), macs: 0 }), + None => Ok(DeclaredCost { output_bytes, symbols: output_bytes, working_bytes: v.keyframe0_working_bytes(), macs: 0 }), Some(nested) => { let p = cond_cost(cap, &wire::parse_wih1(nested)?, params)?; if p.gs.out != (3, v.h as usize, v.w as usize) { diff --git a/wai/wai-rs/src/int_payload/wire.rs b/wai/wai-rs/src/int_payload/wire.rs index ba24a184d..099af499a 100644 --- a/wai/wai-rs/src/int_payload/wire.rs +++ b/wai/wai-rs/src/int_payload/wire.rs @@ -129,13 +129,17 @@ pub enum Refusal { /// A payload of `wai.neural.int_synth` or `wai.neural.int_mlicpp`, whose formats /// are defined elsewhere, refused by its decoder. PayloadMalformed, + /// The payload's declared cost exceeds the limit the sink applies before decoding + /// ([integer-payloads] §7, *Memory and work*): the sink's own policy, so dispatch + /// moves on. + CostOverLimit, } impl Refusal { /// Every refusal, in the order of [integer-payloads] §8. /// /// cbindgen:ignore - pub const ALL: [Refusal; 19] = [ + pub const ALL: [Refusal; 20] = [ Refusal::BadMagic, Refusal::FormatCapabilityMismatch, Refusal::KeyframeCapabilityMismatch, @@ -155,6 +159,7 @@ impl Refusal { Refusal::NotDispatchable, Refusal::UnsupportedCapability, Refusal::PayloadMalformed, + Refusal::CostOverLimit, ]; /// The code [integer-payloads] §8 names. @@ -179,6 +184,7 @@ impl Refusal { Refusal::NotDispatchable => "not_dispatchable", Refusal::UnsupportedCapability => "unsupported_capability", Refusal::PayloadMalformed => "payload_malformed", + Refusal::CostOverLimit => "cost_over_limit", } } @@ -395,6 +401,17 @@ impl Wiv1<'_> { pub fn blocks_per_frame(&self) -> u64 { blocks(self.h, self.w, self.block) } + + /// The most bytes the reference decode of a keyframe-0 clip holds at once beyond + /// the payload ([integer-payloads] §7, *Memory and work*): its table and motion + /// vectors as parsed (4 bytes an entry), the clip (one byte per sample) and its + /// frame list (one `Vec` header per frame). The header alone fixes it; a keyframe-1 + /// clip also runs its keyframe's networks, which the parameter set sizes. + pub fn keyframe0_working_bytes(&self) -> u64 { + let clip = self.h as u64 * self.w as u64 * 3 * self.n_frames as u64; + 4 * self.cdf.len() as u64 + 4 * self.mvs.len() as u64 + clip + + std::mem::size_of::>() as u64 * self.n_frames as u64 + } } fn blocks(h: u32, w: u32, block: u32) -> u64 { diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index d6c74edf0..7ef654a30 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -70,6 +70,9 @@ pub use video::DecodedVideo; /// A capability whose decode reads no parameter set (`wai.video.int_motion`) is /// advertised with [`ModelRegistry::builtin`] instead, and holds no path. /// +/// A cost limit, where set ([`ModelRegistry::with_cost_limit`]), refuses an integer +/// payload whose declared cost exceeds it before any of its symbols is decoded. +/// /// The browser equivalent is the `NEURAL_MODELS` map in `wai-web/demo/index.html`. #[derive(Default, Debug, Clone)] pub struct ModelRegistry { @@ -77,6 +80,7 @@ pub struct ModelRegistry { pub held: HashMap>, builtin: Vec, key_log: Option>, + cost_limit: Option, } impl ModelRegistry { @@ -99,6 +103,21 @@ impl ModelRegistry { self } + /// Refuse, before any of its symbols is decoded, an integer payload whose declared + /// cost (`int_payload::declared_cost`, `extensions/integer-payloads.md` §7, *Memory + /// and work*) exceeds `limit` in any figure: output bytes, symbols, working bytes or + /// multiply-accumulates (`DeclaredCost::new`, a figure left unbounded at its type's + /// maximum). The refusal is `cost_over_limit` ([`DecodeError::OverCostLimit`]), the + /// sink's own policy, so dispatch moves on as for a capability the sink cannot + /// decode (§8): a `WAI1` to its fallback, a `WAI2` to its next entry. A capability + /// with no declared cost — `wai.neural.int_synth`, `wai.neural.int_mlicpp`, and + /// every capability outside the integer path — is not checked. A registry without + /// a limit refuses nothing for its cost. + pub fn with_cost_limit(mut self, limit: crate::int_payload::DeclaredCost) -> Self { + self.cost_limit = Some(limit); + self + } + /// Hold a further artifact a pin over `capability` may resolve against. pub fn hold(mut self, capability: impl Into, path: impl Into) -> Self { self.held.entry(capability.into()).or_default().push(path.into()); @@ -420,6 +439,10 @@ pub enum DecodeError { Ort(String), InvalidPayload(String), Zstd(String), + /// An integer payload whose declared cost exceeds the registry's limit + /// ([`ModelRegistry::with_cost_limit`]), refused before any symbol was decoded + /// (`cost_over_limit`). The sink's own policy: dispatch moves on. + OverCostLimit(String), } impl std::fmt::Display for DecodeError { @@ -439,6 +462,7 @@ impl std::fmt::Display for DecodeError { Self::Ort(e) => write!(f, "onnxruntime error: {e}"), Self::InvalidPayload(e) => write!(f, "invalid payload: {e}"), Self::Zstd(e) => write!(f, "zstd error: {e}"), + Self::OverCostLimit(e) => write!(f, "cost_over_limit: {e}"), } } } @@ -448,6 +472,20 @@ fn is_set_incomplete(e: &DecodeError) -> bool { matches!(e, DecodeError::PriorMismatch(PinRefusal::SetIncomplete { .. })) } +/// Whether a decode was refused for its declared cost, before it decoded anything +/// (`cost_over_limit`): the sink's policy, so dispatch moves on. +fn is_over_cost(e: &DecodeError) -> bool { + matches!(e, DecodeError::OverCostLimit(_)) +} + +/// Whether `capability` decodes to an integer output other than an image: integer +/// video (`WIV1`), PCM (`WIA1`) or splat attributes (`WIS1`), which +/// [`decode_payload`] returns as [`Decoded::Int`]. +pub fn decodes_to_int(capability: &str) -> bool { + use crate::int_wire::{MAGIC_WIA1, MAGIC_WIS1, MAGIC_WIV1}; + matches!(crate::codecs::pinned_int_payload(capability), Some(m) if [MAGIC_WIV1, MAGIC_WIA1, MAGIC_WIS1].contains(&m)) +} + /// Whether a decode through `cap` failed because this build has no decoder for it /// (`unsupported_capability`): the sink cannot decode `cap` at all, so SPEC §4 does not /// take step 3 (or 5) with it, and dispatch moves on as for a capability the sink does @@ -459,6 +497,27 @@ fn is_unsupported(e: &DecodeError, cap: &str) -> bool { /// Decode `payload` through `capability` against `params` — the per-capability /// dispatch, shared by the `WAI1` and `WAI2` paths. pub fn decode_payload(capability: &str, payload: &[u8], params: &ParamSource) -> Result { + decode_payload_within(capability, payload, params, None) +} + +/// What decoding `payload` through the integer capability `capability` against +/// `params` will cost, read before any symbol is decoded +/// (`int_payload::declared_cost`, `extensions/integer-payloads.md` §7): over the +/// bytes [`ModelRegistry::resolve_verified`] returns, so a pinned decode's cost is +/// read from the set the pin verified, as its decode would read it. A capability +/// with no declared cost is [`DecodeError::UnknownCapability`]; a payload or set the +/// cost's checks refuse gives the decode's refusal. +pub fn declared_cost(capability: &str, payload: &[u8], params: &ParamSource) -> Result { + with_int_params(capability, params, |p| { + crate::int_payload::declared_cost(capability, payload, p).map_err(|e| int_error(capability, e)) + }) +} + +/// [`decode_payload`] with a cost limit applied to the integer capabilities first +/// ([`ModelRegistry::with_cost_limit`]). +fn decode_payload_within( + capability: &str, payload: &[u8], params: &ParamSource, limit: Option<&crate::int_payload::DeclaredCost>, +) -> Result { match capability { "wai.neural.encodec32" => audio::decode(payload, params, 32_000).map(Decoded::Audio), "wai.neural.dac" => audio::decode(payload, params, 44_100).map(Decoded::Audio), @@ -474,7 +533,7 @@ pub fn decode_payload(capability: &str, payload: &[u8], params: &ParamSource) -> // splat codecs their one model file; int_motion reads none. "wai.neural.int_synth" | "wai.neural.int_mlicpp" | "wai.neural.int_hyper" | "wai.video.int_motion" | "wai.video.int_hyper" | "wai.audio.int_codec" - | "wai.splat.int_codec" => int_decode(capability, payload, params), + | "wai.splat.int_codec" => int_decode(capability, payload, params, limit), "wai.neural.elic" => context::decode(payload, params).map(Decoded::Image), "wai.neural.mlicpp" => mlicpp::decode(payload, params).map(Decoded::Image), // MLICv2 is the SAME multi-reference channel-autoregressive decode; its gains @@ -527,14 +586,46 @@ pub fn decode_payload(capability: &str, payload: &[u8], params: &ParamSource) -> } /// An integer capability through `int_payload::decode_int`, against the parameters -/// `params` names. A pinned decode hands it the verified bytes; an unpinned one the -/// set the registry read when the decode started ([`ParamSource::UnpinnedRead`]), or, -/// given a path directly, the set's members read from beside it now -/// ([`int_member_path`]). The payload's framing is checked before any member is read, -/// and a member the set lacks is `set_incomplete`, so dispatch moves on as it does for -/// any decoder. -fn int_decode(cap: &str, payload: &[u8], params: &ParamSource) -> Result { - use crate::int_payload::{decode_int, resolve, IntDecoded, IntParams, Refusal}; +/// `params` names ([`with_int_params`]). The payload's framing is checked before any +/// member is read, and a member the set lacks is `set_incomplete`, so dispatch moves +/// on as it does for any decoder. With a cost limit, the payload's declared cost is +/// checked against it first (`int_payload::check_cost`): one over it is refused +/// before any symbol is decoded (`cost_over_limit`). +fn int_decode(cap: &str, payload: &[u8], params: &ParamSource, limit: Option<&crate::int_payload::DeclaredCost>) -> Result { + use crate::int_payload::{check_cost, decode_int, IntDecoded}; + with_int_params(cap, params, |p| { + if let Some(limit) = limit { + check_cost(cap, payload, p, limit).map_err(|e| int_error(cap, e))?; + } + match decode_int(cap, payload, p) { + Ok(IntDecoded::Image { rgb, width, height }) => Ok(Decoded::Image(DecodedImage { rgb, width, height })), + Ok(other) => Ok(Decoded::Int(other)), + Err(e) => Err(int_error(cap, e)), + } + }) +} + +/// An integer decode's refusal as a [`DecodeError`]: `set_incomplete` as SPEC §3.1's, +/// `unsupported_capability` as a capability with no decoder, `cost_over_limit` as the +/// sink's policy, and every other code as the payload's. +fn int_error(cap: &str, e: crate::int_payload::IntPayloadError) -> DecodeError { + use crate::int_payload::Refusal; + match e.refusal() { + Refusal::SetIncomplete => DecodeError::PriorMismatch(PinRefusal::SetIncomplete { path: e.detail().to_string() }), + Refusal::UnsupportedCapability => DecodeError::UnknownCapability(cap.to_string()), + Refusal::CostOverLimit => DecodeError::OverCostLimit(e.detail().to_string()), + _ => DecodeError::InvalidPayload(format!("{cap}: {e}")), + } +} + +/// `f` with the integer parameters `params` names: for a pin, or a set §7.9 +/// authorised, the verified bytes; for an unpinned decode, the set the registry read +/// when the decode started ([`ParamSource::UnpinnedRead`]), or, given a path directly, +/// the set's members read from beside it now ([`int_member_path`]). +fn with_int_params( + cap: &str, params: &ParamSource, f: impl FnOnce(&crate::int_payload::IntParams<'_>) -> Result, +) -> Result { + use crate::int_payload::{resolve, IntParams}; let read_now; let unpinned = match params { ParamSource::UnpinnedRead(files) => Some(files), @@ -551,26 +642,19 @@ fn int_decode(cap: &str, payload: &[u8], params: &ParamSource) -> Result resolve(cap, None, &[crate::pin::HeldBytes::Set { listing: b"", files: &view }], None) .map_err(DecodeError::PriorMismatch)?, }; - match decode_int(cap, payload, &p) { - Ok(IntDecoded::Image { rgb, width, height }) => Ok(Decoded::Image(DecodedImage { rgb, width, height })), - Ok(other) => Ok(Decoded::Int(other)), - Err(e) if e.refusal() == Refusal::SetIncomplete => { - Err(DecodeError::PriorMismatch(PinRefusal::SetIncomplete { path: e.detail().to_string() })) - } - Err(e) if e.refusal() == Refusal::UnsupportedCapability => Err(DecodeError::UnknownCapability(cap.to_string())), - Err(e) => Err(DecodeError::InvalidPayload(format!("{cap}: {e}"))), - } + f(&p) } /// SPEC §4 for a parsed `WAI1` envelope at a sink whose parameter sets `registry` /// holds, through the capabilities `gate` admits. A refused pin makes its capability /// unsupported for this envelope and dispatch moves on to the fallback; a pinned /// decode refused with `set_incomplete` does too, and so do an unpinned decode whose -/// set the registry's key log refuses and a capability this build has no decoder for -/// (`DecodeError::UnknownCapability`, an integer decode's `unsupported_capability`). +/// set the registry's key log refuses, a capability this build has no decoder for +/// (`DecodeError::UnknownCapability`, an integer decode's `unsupported_capability`) and +/// an integer payload over the registry's cost limit (`cost_over_limit`). /// When nothing decodes, the error is step 6's: the reason kept for the capability /// (step 3) before the one kept for the fallback (step 5), whether that reason is a -/// refused pin, `set_incomplete`, the key log's or a missing decoder. +/// refused pin, `set_incomplete`, the key log's, a missing decoder or the cost limit. fn decode_envelope_with( env: &Wai, registry: &ModelRegistry, @@ -615,8 +699,8 @@ fn decode_envelope_with( } }, }; - match decode_payload(d.capability, &env.payload, ¶ms) { - Err(e) if is_set_incomplete(&e) || is_unsupported(&e, d.capability) => { + match decode_payload_within(d.capability, &env.payload, ¶ms, registry.cost_limit.as_ref()) { + Err(e) if is_set_incomplete(&e) || is_unsupported(&e, d.capability) || is_over_cost(&e) => { excluded.push(d.capability.to_string()); if incomplete.is_none() { incomplete = Some((d.capability.to_string(), e)); @@ -700,8 +784,9 @@ pub fn decode_envelope_strict(env: &Wai, registry: &ModelRegistry) -> Result Result<(usize, Decoded), DecodeError> { @@ -740,11 +825,12 @@ fn decode_multi_where( } }, }; - match decode_payload(&p.meta.capability, p.payload, ¶ms) { + match decode_payload_within(&p.meta.capability, p.payload, ¶ms, registry.cost_limit.as_ref()) { Err(e) if is_set_incomplete(&e) => { let DecodeError::PriorMismatch(r) = &e else { unreachable!() }; incomplete.push((p.index, format!("{}: {r}", r.code()))); } + Err(e) if is_over_cost(&e) => incomplete.push((p.index, e.to_string())), Err(e) if is_unsupported(&e, &p.meta.capability) => { incomplete.push((p.index, format!("unsupported_capability: {e}"))); } diff --git a/wai/wai-rs/tests/ffi_envelope.rs b/wai/wai-rs/tests/ffi_envelope.rs index 54ade724e..6696b4e09 100644 --- a/wai/wai-rs/tests/ffi_envelope.rs +++ b/wai/wai-rs/tests/ffi_envelope.rs @@ -228,16 +228,17 @@ fn envelope_pack_refuses_an_integer_payload_that_is_not_its_format() { } /// `wai_int_payload_declared` reads what an integer payload's header declares, before -/// any decode: a `WIV1`'s clip bytes and symbols; a `WIH1`'s symbols, its image size -/// being its network's; and refuses a payload that is not the capability's format, or -/// a capability with none. +/// any decode: a `WIV1`'s clip bytes and symbols, and, for keyframe 0, which runs no +/// network, its working bytes and no multiply-accumulates; a `WIH1`'s symbols, its +/// image size and its cost being its networks'; and refuses a payload that is not the +/// capability's format, or a capability with none. #[test] fn int_payload_declared_reads_the_header() { let vec = |rel: &str| std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors").join(rel)).unwrap(); let declared = |cap: &str, p: &[u8]| { let c = CString::new(cap).unwrap(); let mut out = WaiIntDeclared::default(); - let rc = unsafe { wai_int_payload_declared(c.as_ptr(), p.as_ptr(), p.len(), &mut out) }; + let rc = unsafe { wai_int_payload_declared(c.as_ptr(), p.as_ptr(), p.len(), std::ptr::null(), &mut out) }; (rc, out, if rc == WAI_OK { String::new() } else { last_error() }) }; let k0 = vec("int_video/int_video_bitstream.wiv1"); @@ -245,10 +246,13 @@ fn int_payload_declared_reads_the_header() { let clip = v.n_frames as u64 * v.h as u64 * v.w as u64 * 3; let (rc, out, _) = declared("wai.video.int_motion", &k0); assert_eq!((rc, out.output_bytes, out.output_known, out.symbols), (WAI_OK, clip, 1, clip)); + assert_eq!((out.cost_known, out.working_bytes, out.macs_lo, out.macs_hi), (1, v.keyframe0_working_bytes(), 0, 0)); + assert!(out.working_bytes > clip, "the clip, its table, its motion vectors and its frame list"); let wih1 = vec("int_hyper/tiny_bitstream.wih1"); let k = wai::int_wire::parse_wih1(&wih1).unwrap(); let (rc, out, _) = declared("wai.neural.int_hyper", &wih1); assert_eq!((rc, out.output_known, out.symbols), (WAI_OK, 0, (k.n_z * k.zh * k.zw + k.m * k.sh * k.sw) as u64)); + assert_eq!(out.cost_known, 0, "a WIH1's cost is its networks'"); let (rc, _, why) = declared("wai.video.int_motion", &vec("int_video/int_video_bitstream.json")); assert!(rc == WAI_ERR_INVALID_ARG && why.contains("(bad_magic:"), "{why}"); let (rc, _, _) = declared("wai.image.png", &k0); diff --git a/wai/wai-rs/tests/int_payload_cost.rs b/wai/wai-rs/tests/int_payload_cost.rs index 69dbba0cd..27494e371 100644 --- a/wai/wai-rs/tests/int_payload_cost.rs +++ b/wai/wai-rs/tests/int_payload_cost.rs @@ -156,6 +156,9 @@ fn the_declared_cost_bounds_what_the_decode_holds() { if let Some(o) = size.output_bytes { assert_eq!(o, cost.output_bytes, "{what}: output bytes"); } + if let (Some(w), Some(m)) = (size.working_bytes, size.macs) { + assert_eq!((w, m), (cost.working_bytes, cost.macs), "{what}: working bytes and multiply-accumulates"); + } let set = set_bytes(cap); let (r, peak) = with_policy(Policy::reference(), || peak_during(|| decode_int(cap, &payload, params))); r.unwrap_or_else(|e| panic!("{what}: {e}")); diff --git a/wai/wai-rs/tests/neural_cost_limit.rs b/wai/wai-rs/tests/neural_cost_limit.rs new file mode 100644 index 000000000..4450594bd --- /dev/null +++ b/wai/wai-rs/tests/neural_cost_limit.rs @@ -0,0 +1,230 @@ +//! The reference neural sink applies the declared cost of an integer payload before it +//! decodes a symbol of it (`extensions/integer-payloads.md` §7, *Memory and work*; +//! `cost_over_limit`, §8), and its C ABI does not decode an integer output it has no +//! kind for. Measured with a global allocator that tracks the bytes live and their +//! peak, so "before decoding" is what the allocator sees: nothing sized from the +//! header is allocated. +//! +//! The payload is a clip a few kilobytes can code: sixteen frames whose stream is one +//! symbol of frequency 65535/65536 over and over. At 4729 × 4729 it decodes to a clip at +//! the 2^30-byte cap (release builds); a debug build uses 1024 × 1024 frames, 48 MiB. +//! +//! The tests share the allocator's counters, so they run one at a time. +#![cfg(all(feature = "neural", feature = "ffi", feature = "video_int"))] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::collections::BTreeMap; +use std::ffi::{CStr, CString}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; +use std::sync::Mutex; + +use wai::codecs::{CAP_NEURAL_INT_HYPER, CAP_VIDEO_INT_MOTION}; +use wai::container::{Conditioning, Manifest, ModelRequirement, Rendition, Wai, WaiMulti}; +use wai::ffi::{wai_int_payload_declared, wai_last_error, WaiBuffer, WaiIntDeclared, WAI_OK}; +use wai::ffi_neural::{wai_neural_decode_ex, wai_neural_decode_video, WaiDecoded}; +use wai::int_payload::{wire, DeclaredCost, IntDecoded}; +use wai::neural::{decode_envelope, decode_multi, DecodeError, Decoded, ModelRegistry}; + +/// The system allocator, tracking the bytes live and the most live at once. +struct Live; +static CUR: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Live { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + PEAK.fetch_max(CUR.fetch_add(l.size(), Relaxed) + l.size(), Relaxed); + unsafe { System.alloc(l) } + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + PEAK.fetch_max(CUR.fetch_add(l.size(), Relaxed) + l.size(), Relaxed); + unsafe { System.alloc_zeroed(l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + if new >= l.size() { + PEAK.fetch_max(CUR.fetch_add(new - l.size(), Relaxed) + (new - l.size()), Relaxed); + } else { + CUR.fetch_sub(l.size() - new, Relaxed); + } + unsafe { System.realloc(p, l, new) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + CUR.fetch_sub(l.size(), Relaxed); + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static ALLOC: Live = Live; + +static SERIAL: Mutex<()> = Mutex::new(()); + +/// The most bytes `f` holds at once beyond what was live when it started. +fn peak_during(f: impl FnOnce() -> R) -> (R, usize) { + let base = CUR.load(Relaxed); + PEAK.store(base, Relaxed); + let r = f(); + (r, PEAK.load(Relaxed) - base) +} + +/// Refusing before decoding allocates no more than this: the parsed table, a few +/// motion vectors, the refusal's message. +const SMALL: usize = 64 << 10; + +fn vectors() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors") +} + +fn read(rel: &str) -> Vec { + std::fs::read(vectors().join(rel)).unwrap() +} + +fn last_error() -> String { + let p = wai_last_error(); + if p.is_null() { String::new() } else { unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned() } +} + +/// A rANS stream of `n` symbols, each coded with `(start, freq)`, from the state 2^31, +/// so it ends canonically: what an encoder writes. +fn encode(n: u64, (start, freq): (u64, u64)) -> Vec { + const L: u64 = 1 << 31; + let mut state = L; + let mut renorm: Vec = Vec::new(); + for _ in 0..n { + let x_max = ((L >> 16) << 32) * freq; + while state >= x_max { + renorm.push(state as u32); + state >>= 32; + } + state = ((state / freq) << 16) + (state % freq) + start; + } + renorm.reverse(); + let mut words = vec![state as u32, (state >> 32) as u32]; + words.extend(renorm); + words.iter().flat_map(|w| w.to_le_bytes()).collect() +} + +/// A keyframe-0 clip of sixteen frames whose every residual is 0 at frequency +/// 65535/65536: a valid stream of a few bytes per 2^20 symbols. +fn bomb() -> Vec { + let side = if cfg!(debug_assertions) { 1024 } else { 4729 }; + let n = 16u32; + let rans = encode(n as u64 * side as u64 * side as u64 * 3, (0, 65_535)); + wire::write_wiv1(&wire::Wiv1 { + mv_precision: 1, keyframe: 0, n_frames: n, h: side, w: side, block: side, q: 1, offset: 0, cdf: vec![0, 65_535, 65_536], + mvs: vec![(0, 0); n as usize - 1], rans: &rans, wih1: None, + }) +} + +fn video(cap: &str, payload: Vec) -> Wai { + Wai::new(Manifest::new("video", "improve", ModelRequirement::new(cap), Conditioning::new("bytes")), payload) +} + +/// A registry with a cost limit refuses a payload over it before decoding anything +/// (`cost_over_limit`), and dispatch moves on: a `WAI2` decodes its next entry. The +/// cost it applied is `neural::declared_cost` over the parameters it resolves. +#[test] +fn a_payload_over_the_cost_limit_is_refused_before_any_symbol_is_decoded() { + let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let bomb = bomb(); + assert!(bomb.len() < 4096, "the clip is coded in {} bytes", bomb.len()); + let limit = DeclaredCost::new(16 << 20, u64::MAX, u64::MAX, u128::MAX); + let reg = ModelRegistry::new().builtin(CAP_VIDEO_INT_MOTION).with_cost_limit(limit); + + let env = video(CAP_VIDEO_INT_MOTION, bomb.clone()); + let (r, peak) = peak_during(|| decode_envelope(&env, ®)); + match r { + Err(e @ DecodeError::OverCostLimit(_)) => assert!(e.to_string().starts_with("cost_over_limit: ") && e.to_string().contains("output bytes"), "{e}"), + Err(e) => panic!("expected cost_over_limit, got {e}"), + Ok(_) => panic!("expected cost_over_limit, the clip decoded"), + } + assert!(peak < SMALL, "refusing it held {peak} bytes"); + + let small = read("int_video/int_video_bitstream.wiv1"); + let n_frames = wire::parse_wiv1(&small, Some(0)).unwrap().n_frames as usize; + let m = WaiMulti::new("video", "improve", serde_json::Value::Null, + vec![Rendition::media(CAP_VIDEO_INT_MOTION, "bytes", bomb.clone()), Rendition::media(CAP_VIDEO_INT_MOTION, "bytes", small)]); + let (r, peak) = peak_during(|| decode_multi(&m, ®)); + match r { + Ok((1, Decoded::Int(IntDecoded::Video { frames, .. }))) => assert_eq!(frames.len(), n_frames), + Ok((i, _)) => panic!("decoded entry {i}"), + Err(e) => panic!("the entry within the limit must decode: {e}"), + } + assert!(peak < 1 << 20, "skipping the first entry and decoding the second held {peak} bytes"); + + let params = reg.resolve_verified(CAP_VIDEO_INT_MOTION, None).unwrap(); + let cost = wai::neural::declared_cost(CAP_VIDEO_INT_MOTION, &bomb, ¶ms).unwrap(); + assert_eq!(cost.exceeds(&limit), Some("output bytes")); + assert_eq!(cost.output_bytes, 16 * 3 * if cfg!(debug_assertions) { 1024 * 1024 } else { 4729 * 4729 }); +} + +/// The C ABI has no output kind for integer video, so it does not decode it: the +/// clip is refused before anything is allocated for it, by the registry entry point +/// and by the one-model video entry point. +#[test] +fn the_c_abi_refuses_an_integer_video_before_decoding_it() { + let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let env = video(CAP_VIDEO_INT_MOTION, bomb()).to_emit_bytes().unwrap(); + let reg = CString::new(r#"{"wai.video.int_motion": ["/nonexistent"]}"#).unwrap(); + let mut out = std::mem::MaybeUninit::::uninit(); + let (rc, peak) = peak_during(|| unsafe { wai_neural_decode_ex(env.as_ptr(), env.len(), reg.as_ptr(), 0, out.as_mut_ptr()) }); + let why = last_error(); + assert_ne!(rc, WAI_OK); + assert!(why.contains("no wai_decoded_t kind"), "{why}"); + assert!(peak < SMALL, "decode_ex held {peak} bytes refusing it"); + unsafe { wai::ffi::wai_buffer_free(out.assume_init().data) }; + + let model = CString::new("/nonexistent").unwrap(); + let mut frames = WaiBuffer { data: std::ptr::null_mut(), len: 0, cap: 0 }; + let null = std::ptr::null_mut(); + let (rc, peak) = peak_during(|| unsafe { wai_neural_decode_video(env.as_ptr(), env.len(), model.as_ptr(), &mut frames, null, null, null, null) }); + let why = last_error(); + assert_ne!(rc, WAI_OK); + assert!(why.contains("no wai_decoded_t kind"), "{why}"); + assert!(peak < SMALL, "decode_video held {peak} bytes refusing it"); +} + +/// `neural::declared_cost` over the set a registry resolves is the library's over the +/// same bytes, and so is the C ABI's given the set's directory: every figure, the +/// multiply-accumulates in two halves. A set without a member is `set_incomplete`. +#[test] +fn the_declared_cost_over_a_registrys_set_and_through_the_c_abi() { + let _serial = SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("wai-cost-set-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let set: BTreeMap> = [("entropy.json", "int_hyper/tiny_entropy.json"), ("g_s.whm", "int_hyper/tiny_g_s.bin"), ("h_s.whm", "int_hyper/tiny_h_s.bin")] + .into_iter().map(|(k, f)| (k.to_string(), read(f))).collect(); + for (k, b) in &set { + std::fs::write(dir.join(k), b).unwrap(); + } + let payload = read("int_hyper/tiny_bitstream.wih1"); + let view: BTreeMap = set.iter().map(|(k, b)| (k.clone(), b.as_slice())).collect(); + let held = wai::int_payload::resolve(CAP_NEURAL_INT_HYPER, None, &[wai::pin::HeldBytes::Set { listing: b"", files: &view }], None).unwrap(); + let want = wai::int_payload::declared_cost(CAP_NEURAL_INT_HYPER, &payload, &held).unwrap(); + assert!(want.macs > 0); + + let reg = ModelRegistry::new().register(CAP_NEURAL_INT_HYPER, dir.clone()); + let params = reg.resolve_verified(CAP_NEURAL_INT_HYPER, None).unwrap(); + assert_eq!(wai::neural::declared_cost(CAP_NEURAL_INT_HYPER, &payload, ¶ms).unwrap(), want); + + let cap = CString::new(CAP_NEURAL_INT_HYPER).unwrap(); + let path = CString::new(dir.to_str().unwrap()).unwrap(); + let declared = |set: *const std::ffi::c_char| { + let mut out = WaiIntDeclared::default(); + let rc = unsafe { wai_int_payload_declared(cap.as_ptr(), payload.as_ptr(), payload.len(), set, &mut out) }; + (rc, out) + }; + let (rc, out) = declared(path.as_ptr()); + assert_eq!(rc, WAI_OK, "{}", last_error()); + assert_eq!((out.output_known, out.output_bytes, out.symbols, out.cost_known, out.working_bytes), + (1, want.output_bytes, want.symbols, 1, want.working_bytes)); + assert_eq!(((out.macs_hi as u128) << 64) | out.macs_lo as u128, want.macs); + let (rc, out) = declared(std::ptr::null()); + assert_eq!((rc, out.output_known, out.cost_known), (WAI_OK, 0, 0), "the header alone"); + + std::fs::remove_file(dir.join("h_s.whm")).unwrap(); + let (rc, _) = declared(path.as_ptr()); + assert_ne!(rc, WAI_OK); + assert!(last_error().contains("set_incomplete"), "{}", last_error()); + std::fs::remove_dir_all(&dir).ok(); +} -- 2.54.0 (Apple Git-157) From d10a9541fad2b335e7057fc10effc2332b4571fa Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 22:37:05 -0400 Subject: [PATCH 33/34] wai: an encrypted envelope is inert at the reference dispatchers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emit paths skip the integer framing check for an envelope whose manifest declares an encryption, since its payload is ciphertext; but the reference dispatchers did not apply SPEC §6.4 step 2, so a payload sent in the clear under a false claim of encryption was decoded as plaintext, and a WAI2 security member that is not a security object (true, "aes", [], {"encryption": 5}) was written. - Wai::dispatch_verified and WaiMulti::pick open no scheme, so an envelope whose manifest declares an encryption other than "none" is inert at them before any capability is considered: unsupported_encryption (container::Inert::Encrypted, and container::Skip::Encrypted for every WAI2 entry). The neural sink reports neural::DecodeError::Encrypted, its C ABI WAI_ERR_CONTAINER; wai-web's dispatchCapability returns undefined. - WaiMulti::to_emit_bytes, and so the C packer and wai-web's packMulti, refuse a security member that is not a security object (EmitError::Security, container::manifest_v2_security); read, such a member is treated as encrypted. SPEC §6.4 step 2 names the reference's report, and §7.1 states the WAI2 security member and the reason it gives every entry. Tests: container's an_encrypted_envelope_is_inert_at_the_reference_dispatchers (WAI1 and WAI2, "none", null, an absent and an empty member, the four malformed members refused at emit and inert when read); neural_native_decode's an_encrypted_envelope_is_inert_at_the_reference_sink (a valid WIH1 and its JSON form in the clear under the claim, a WAI2 encrypted or with security true); wai-web's writers test. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-neural-gates.yml | 6 +- wai/CHANGELOG.md | 11 +- wai/SPEC.md | 12 ++- wai/wai-rs/src/container.rs | 127 +++++++++++++++++++++-- wai/wai-rs/src/ffi_neural.rs | 1 + wai/wai-rs/src/neural/mod.rs | 13 ++- wai/wai-rs/tests/neural_native_decode.rs | 49 +++++++++ wai/wai-web/src/lib.rs | 18 +++- 8 files changed, 214 insertions(+), 23 deletions(-) diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index 121c8c58a..0d0fda438 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -17,7 +17,8 @@ name: WAI neural sink gates # pins through the strict gate, the int_synth pin over the spec it reads, the # several-file capabilities refusing a form-less pin, a pinned decode reading only # the bytes it verified, an unpinned decode at a sink holding the capability's sets -# to §7.9 reading only the set §7.9 authorised, a WAI2 envelope whose pins pick the held model, the strict +# to §7.9 reading only the set §7.9 authorised, an encrypted envelope inert at the +# sink (SPEC §6.4 step 2), a WAI2 envelope whose pins pick the held model, the strict # rule read for the capability decoded through (a sound fallback behind an unsound # primary), SPEC §4 step 6's order of reasons after a set_incomplete decode, and the # replicate_* reason the strict WAI2 policy reports for an entry it leaves out, @@ -145,8 +146,9 @@ jobs: a_listing_that_does_not_match_the_files_read_authorises_nothing \ a_pinned_set_revoked_under_its_file_digest_is_refused_through_a_listing_pin \ a_granted_listing_authorises_only_the_files_it_names \ + an_encrypted_envelope_is_inert_at_the_reference_sink \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 18 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 19 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" # The integer video, audio and splat capabilities through the neural dispatch, which # delegates them to int_payload::decode_int: each decodes to Decoded::Int with its # committed SHA-256, and wai.video.int_motion is advertised with ModelRegistry::builtin. diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index c6434edf3..8c91231db 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -42,7 +42,16 @@ wire format may change and such changes are recorded here. what it finds. A refused emit is the new `EmitError::IntPayload`. An encrypted envelope's payload is ciphertext (SPEC §6), so the emit paths skip it and a sink checks the plaintext after decrypting (SPEC §6.4); `wai inspect` says so. No - committed envelope or conformance corpus carried such a payload. + committed envelope or conformance corpus carried such a payload. The reference + dispatchers (`Wai::dispatch_verified`, `WaiMulti::pick`, and so the neural sink, + its C ABI and `wai-web`'s `dispatchCapability`) implement SPEC §6.4 step 2: they + open no scheme, so an envelope whose manifest declares an encryption other than + `"none"` is inert at them (`unsupported_encryption`; `container::Inert::Encrypted`, + `container::Skip::Encrypted`, `neural::DecodeError::Encrypted`), and a payload sent + in the clear under a claim of encryption is not decoded as plaintext. A `WAI2` + manifest's `security` member (now stated in SPEC §7.1) must be a security object to + be written (`EmitError::Security`, `container::manifest_v2_security`); one that is + not is read as encrypted. - **One integer decode path.** `int_payload::decode_int` decodes the strings above and `wai.neural.int_synth` and `wai.neural.int_mlicpp` from parameter files held in memory, with no ONNX runtime and no filesystem, natively and for `wasm32`, through a diff --git a/wai/SPEC.md b/wai/SPEC.md index ecffd3c81..9c275f3d8 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -1052,7 +1052,9 @@ A confidentiality-aware sink MUST, before running §4: 2. Else, if the sink does not implement the named scheme, or holds no key for `security.kid`, the file is **inert at this sink** — return a clear "no key / unsupported encryption" error. It MUST NOT feed - ciphertext to a codec. + ciphertext to a codec. (The reference dispatchers in `wai-rs` implement + no scheme and report `unsupported_encryption`, for a `WAI2` envelope + against every entry.) 3. Else recover the plaintext payload via the named scheme (for `mls-sframe`: SFrame `open` under the member's base key), then run §4 on the recovered plaintext. @@ -1582,7 +1584,10 @@ by which capability happens to be installed. } ``` Each entry has its own `capability` (the dispatch key) and `kind` - (the codec id matching the bytes in the corresponding payload). An entry MAY + (the codec id matching the bytes in the corresponding payload). The manifest + MAY carry `security` (§6.1), which covers every payload: a sink applies §6.4 + before step 2 of the selection below, and an encoder writes only a security + object there. An entry MAY carry `prior`, the pin (§3.1) of the parameter set its payload was coded against. Two entries MAY name one capability with different pins: the same content coded against two parameter sets, such as a set and its successor: @@ -1645,7 +1650,8 @@ A v1.1-aware sink MUST: which step 2 never picks), `missing_capability`, `component_digest_mismatch`, a pin reason (§3.1), or, where a deployer policy restricts step 2 to the entries §7's `replicate` rule admits, the `replicate_*` reason the rule gives for an entry - it leaves out. + it leaves out; an envelope inert under §6.4 step 2 names that reason for every + entry. A sink MUST NOT decode one entry's payload against the parameter set another entry pins. The envelope's `intent` applies to the rendition the sink picks, and diff --git a/wai/wai-rs/src/container.rs b/wai/wai-rs/src/container.rs index c97f2c41d..1d5223d6d 100644 --- a/wai/wai-rs/src/container.rs +++ b/wai/wai-rs/src/container.rs @@ -733,6 +733,15 @@ impl Security { } } +/// SPEC §6.4 step 2 at a sink that implements no encryption scheme, as the reference +/// dispatchers are: a manifest whose `security` declares an encryption other than +/// `"none"` makes the envelope inert here, and no ciphertext reaches a codec. A sink +/// that opens a scheme decrypts first and dispatches the plaintext envelope (§6.4 +/// step 3), whose `security` is plaintext. `declared` is the encryption as written. +fn encryption_inert(declared: impl Into) -> Inert { + Inert::Encrypted { encryption: declared.into() } +} + /// A `WAI1` manifest (SPEC §3). Build one with [`Manifest::new`]. #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] @@ -809,14 +818,19 @@ pub enum Inert { /// The pin was refused for `capability` (the first refusal kept, step 3 before /// step 5). PinRefused { capability: String, refusal: PinRefusal }, + /// The manifest declares an encryption this sink cannot open (SPEC §6.4 step 2, + /// `unsupported_encryption`): the reference dispatchers implement no scheme, so an + /// encrypted envelope is inert at them before any capability is considered. + Encrypted { encryption: String }, } impl Inert { - /// `missing_capability`, or the pin's reason. + /// `missing_capability`, `unsupported_encryption`, or the pin's reason. pub fn code(&self) -> &'static str { match self { Self::MissingCapability => "missing_capability", Self::PinRefused { refusal, .. } => refusal.code(), + Self::Encrypted { .. } => "unsupported_encryption", } } } @@ -826,6 +840,11 @@ impl std::fmt::Display for Inert { match self { Self::MissingCapability => write!(f, "missing_capability: the sink advertises neither the capability nor the fallback"), Self::PinRefused { capability, refusal } => write!(f, "{}: {capability}: {refusal}", refusal.code()), + Self::Encrypted { encryption } => write!( + f, + "unsupported_encryption: the manifest declares the encryption {encryption}, which this sink cannot \ + open (SPEC §6.4): the file is inert here, and its payload is not decoded" + ), } } } @@ -904,7 +923,9 @@ impl Wai { } /// SPEC §4 dispatch at a sink that advertises the capabilities `advertises` - /// accepts and verifies pins with `verify` (SPEC §3.1). Step 3 takes the + /// accepts and verifies pins with `verify` (SPEC §3.1). First, SPEC §6.4 step 2: + /// a manifest that declares an encryption other than `"none"` is inert here + /// ([`Inert::Encrypted`]), since this dispatcher opens no scheme. Step 3 takes the /// capability when the sink advertises it and, where the pin applies to it, /// `verify` accepts the pin; step 5 does the same for the fallback. A refused /// pin makes that capability unsupported for this envelope: dispatch moves on, @@ -935,6 +956,11 @@ impl Wai { advertises: impl Fn(&str) -> bool, mut verify: impl FnMut(&str, &Prior) -> Result, ) -> Result, Inert> { + // SPEC §6.4 step 2, before §4: an encrypted payload is inert at a sink that + // opens no scheme. + if let Some(sec) = self.manifest.security.as_ref().filter(|s| !s.is_plaintext()) { + return Err(encryption_inert(format!("{:?}", sec.encryption.as_deref().unwrap_or_default()))); + } let primary = self.capability(); if crate::codecs::is_companion_only(primary) { return Err(Inert::MissingCapability); @@ -1351,11 +1377,23 @@ pub fn check_component_candidates<'a>(capabilities: impl IntoIterator bool { + matches!(manifest_v2_security(m), Ok(s) if s.as_ref().is_none_or(Security::is_plaintext)) +} + +/// A `WAI2` manifest's `security` member (SPEC §6.1, §7.1): `None` when absent or +/// `null`; the object; or why the member is not a security object (`true`, a string, +/// an array, an `encryption` that is not a string, …), which an encoder does not write +/// ([`WaiMulti::to_emit_bytes`]). +pub fn manifest_v2_security(m: &ManifestV2) -> Result, String> { match m.extra.get("security") { - None | Some(Value::Null) => true, - Some(v) => serde_json::from_value::(v.clone()).is_ok_and(|s| s.is_plaintext()), + None | Some(Value::Null) => Ok(None), + Some(v) => serde_json::from_value::(v.clone()).map(Some) + .map_err(|e| format!("the manifest's `security` member {v} is not a security object (SPEC §6.1): {e}")), } } @@ -1475,6 +1513,10 @@ pub enum EmitError { /// ([`check_int_payload_framing`]): the `WAI2` component's index, or `None` for a /// `WAI1`'s payload. IntPayload { component: Option, error: FramingError }, + /// A `WAI2` manifest's `security` member is not a security object + /// ([`manifest_v2_security`]): an encoder that writes one could claim an + /// encryption no sink can read, or none. + Security(String), } impl std::fmt::Display for EmitError { @@ -1484,6 +1526,7 @@ impl std::fmt::Display for EmitError { EmitError::Serialize(e) => write!(f, "envelope serialize: {e}"), EmitError::IntPayload { component: Some(i), error } => write!(f, "component {i}: {error}"), EmitError::IntPayload { component: None, error } => error.fmt(f), + EmitError::Security(why) => f.write_str(why), } } } @@ -1595,11 +1638,15 @@ pub enum Skip { /// A policy that serves only sound `replicate` claims left the entry out: /// SPEC §7's rule refuses `replicate` through it (`replicate_*`). Replicate(crate::codecs::ReplicateRefusal), + /// The manifest declares an encryption the sink cannot open (SPEC §6.4 step 2, + /// `unsupported_encryption`): every entry's payload is ciphertext here. + Encrypted, } impl Skip { /// The reason: `not_selectable`, `missing_capability`, - /// `component_digest_mismatch`, the pin's reason, or the `replicate_*` reason. + /// `component_digest_mismatch`, the pin's reason, the `replicate_*` reason, or + /// `unsupported_encryption`. pub fn code(&self) -> &'static str { match self { Self::NotSelectable => "not_selectable", @@ -1607,6 +1654,7 @@ impl Skip { Self::ComponentDigest => "component_digest_mismatch", Self::Pin(r) => r.code(), Self::Replicate(r) => r.code(), + Self::Encrypted => "unsupported_encryption", } } } @@ -1784,7 +1832,10 @@ impl WaiMulti { /// pin applies ([`pin_applies_to`]), `verify` accepts. An entry whose pin is /// refused is skipped as if its capability were not advertised, and its reason /// kept. When nothing is picked the envelope is inert at this sink, and the - /// error gives every entry's index and reason. + /// error gives every entry's index and reason. An envelope whose manifest declares + /// an encryption other than `"none"` is inert before any entry is considered (SPEC + /// §6.4 step 2; this dispatcher opens no scheme): every entry is + /// [`Skip::Encrypted`]. pub fn pick( &self, advertises: impl Fn(&str) -> bool, @@ -1811,6 +1862,11 @@ impl WaiMulti { eligible: impl Fn(usize, &RenditionMeta) -> Result<(), Skip>, mut verify: impl FnMut(&str, &Prior) -> Result, ) -> Result, Vec<(usize, Skip)>> { + // SPEC §6.4 step 2, before selection: an encrypted envelope is inert at a sink + // that opens no scheme, and every entry is reported so. + if !manifest_v2_plaintext(&self.manifest) { + return Err((0..self.manifest.renditions.len()).map(|i| (i, Skip::Encrypted)).collect()); + } let mut skipped = Vec::new(); for (index, (meta, payload)) in self.manifest.renditions.iter().zip(self.renditions.iter()).enumerate() { if !meta.is_selectable() { @@ -1863,12 +1919,14 @@ impl WaiMulti { } /// The envelope's bytes, for an encoder to emit: [`Self::check_candidates`], then - /// [`Self::check_int_payload_framing`], then [`Self::to_bytes`]. The C packer - /// `wai_envelope_pack_multi` emits through this, so an encoder that uses it cannot - /// emit a candidate, or a component whose integer payload is not its capability's - /// pinned format (SPEC §5). + /// [`manifest_v2_security`], then [`Self::check_int_payload_framing`], then + /// [`Self::to_bytes`]. The C packer `wai_envelope_pack_multi` emits through this, so + /// an encoder that uses it cannot emit a candidate, a `security` member that is not + /// a security object (SPEC §6.1), or a component whose integer payload is not its + /// capability's pinned format (SPEC §5). pub fn to_emit_bytes(&self) -> Result, EmitError> { self.check_candidates().map_err(EmitError::Candidate)?; + manifest_v2_security(&self.manifest).map_err(EmitError::Security)?; self.check_int_payload_framing().map_err(|(i, error)| EmitError::IntPayload { component: Some(i), error })?; self.to_bytes().map_err(EmitError::Serialize) } @@ -2892,6 +2950,53 @@ mod tests { assert!(multi.to_emit_bytes().is_ok(), "an encrypted WAI2 is written"); } + /// SPEC §6.4 step 2 at the reference dispatchers, which open no scheme: a manifest + /// that declares an encryption other than `"none"` is inert before any capability + /// is considered (`unsupported_encryption`), so a payload sent in the clear under a + /// false claim of encryption is not decoded as plaintext; `"none"`, `null` and an + /// absent member are plaintext. A `WAI2` `security` member that is not a security + /// object is inert too, and is never written. + #[test] + fn an_encrypted_envelope_is_inert_at_the_reference_dispatchers() { + let png = "wai.image.png"; + let all = |_: &str| true; + let v1 = |security: Option| { + let mut m = Manifest::new("image", "improve", ModelRequirement::new(png), Conditioning::new("bytes")); + m.security = security; + Wai::new(m, b"bytes".to_vec()) + }; + assert_eq!(v1(None).dispatch_verified(all, no_parameter_sets()).unwrap().capability, png); + assert_eq!(v1(Some(Security::new("none", 0))).dispatch_verified(all, no_parameter_sets()).unwrap().capability, png); + let e = v1(Some(Security::new("aes-256-gcm", 7))).dispatch_verified(all, no_parameter_sets()).unwrap_err(); + assert_eq!(e.code(), "unsupported_encryption"); + assert!(e.to_string().contains("\"aes-256-gcm\""), "{e}"); + + let multi = |security: Option| { + let mut m = WaiMulti::new("image", "improve", Value::Null, + vec![Rendition::media(png, "bytes", b"a".to_vec()), Rendition::media(png, "bytes", b"b".to_vec())]); + if let Some(v) = security { + m.manifest.extra.insert("security".into(), v); + } + m + }; + for plain in [None, Some(Value::Null), Some(serde_json::json!({"encryption": "none"})), Some(serde_json::json!({}))] { + let m = multi(plain.clone()); + assert_eq!(m.pick(all, no_parameter_sets()).unwrap().index, 0, "{plain:?}"); + assert!(m.to_emit_bytes().is_ok(), "{plain:?}"); + } + let encrypted = multi(Some(serde_json::json!({"encryption": "mls-sframe", "kid": 0}))); + assert!(encrypted.to_emit_bytes().is_ok(), "an encrypted WAI2 is written"); + let skips = encrypted.pick(all, no_parameter_sets()).unwrap_err(); + assert_eq!(skips, vec![(0, Skip::Encrypted), (1, Skip::Encrypted)]); + assert_eq!(skips[0].1.code(), "unsupported_encryption"); + for bad in [serde_json::json!(true), serde_json::json!("aes"), serde_json::json!([]), serde_json::json!({"encryption": 5})] { + let m = multi(Some(bad.clone())); + assert!(matches!(m.to_emit_bytes(), Err(EmitError::Security(ref why)) if why.contains("is not a security object")), "{bad}"); + let back = WaiMulti::from_bytes(&m.to_bytes().unwrap()).unwrap(); + assert_eq!(back.pick(all, no_parameter_sets()).unwrap_err()[0].1, Skip::Encrypted, "{bad}: read, it is inert"); + } + } + /// The emit path of the C packers (`wai_envelope_pack`, /// `wai_envelope_pack_multi`), driven exactly as they drive it: the manifest /// JSON parsed into the manifest type, the envelope built, and the bytes taken diff --git a/wai/wai-rs/src/ffi_neural.rs b/wai/wai-rs/src/ffi_neural.rs index b3c2fbfa6..2a181733b 100644 --- a/wai/wai-rs/src/ffi_neural.rs +++ b/wai/wai-rs/src/ffi_neural.rs @@ -54,6 +54,7 @@ fn decode_status(e: DecodeError) -> (c_int, String) { DecodeError::PriorMismatch(r) => (WAI_ERR_PRIOR, format!("{}: {r}", r.code())), DecodeError::UnsoundReplicate(why) => (WAI_ERR_UNSOUND_REPLICATE, why), DecodeError::NoRendition(_) => (WAI_ERR_NO_RENDITION, e.to_string()), + DecodeError::Encrypted(why) => (WAI_ERR_CONTAINER, why), other => (WAI_ERR_CODEC, other.to_string()), } } diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index 7ef654a30..c0e7f3d79 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -443,6 +443,11 @@ pub enum DecodeError { /// ([`ModelRegistry::with_cost_limit`]), refused before any symbol was decoded /// (`cost_over_limit`). The sink's own policy: dispatch moves on. OverCostLimit(String), + /// The manifest declares an encryption this sink cannot open (SPEC §6.4 step 2, + /// `unsupported_encryption`): the envelope is inert here, and its ciphertext reaches + /// no codec. The reference sink opens no scheme; a sink that does decrypts first + /// and decodes the plaintext envelope. + Encrypted(String), } impl std::fmt::Display for DecodeError { @@ -463,6 +468,7 @@ impl std::fmt::Display for DecodeError { Self::InvalidPayload(e) => write!(f, "invalid payload: {e}"), Self::Zstd(e) => write!(f, "zstd error: {e}"), Self::OverCostLimit(e) => write!(f, "cost_over_limit: {e}"), + Self::Encrypted(e) => f.write_str(e), } } } @@ -680,6 +686,7 @@ fn decode_envelope_with( _ => DecodeError::PriorMismatch(refusal), }); } + Err(e @ Inert::Encrypted { .. }) => return Err(DecodeError::Encrypted(e.to_string())), Err(_) => { return Err(incomplete.map(|(_, e)| e) .unwrap_or_else(|| DecodeError::ModelNotRegistered(env.capability().to_string()))); @@ -714,7 +721,8 @@ fn decode_envelope_with( /// Decode a parsed `WAI1` envelope (SPEC §4): the capability if the registry holds /// it and, where a pin applies, a parameter set that satisfies the pin (SPEC §3.1); /// else the fallback on the same terms. A pinned decode reads only the bytes it -/// verified. Returns [`DecodeError::PriorMismatch`] with the first refusal when no +/// verified. An envelope whose manifest declares an encryption is inert here +/// ([`DecodeError::Encrypted`], SPEC §6.4 step 2): this sink opens no scheme. Returns [`DecodeError::PriorMismatch`] with the first refusal when no /// capability's pin resolves, and [`DecodeError::ModelNotRegistered`] when the /// registry holds neither capability. pub fn decode_envelope(env: &Wai, registry: &ModelRegistry) -> Result { @@ -788,7 +796,8 @@ pub fn decode_envelope_strict(env: &Wai, registry: &ModelRegistry) -> Result Result<(usize, Decoded), DecodeError> { decode_multi_where(m, registry, |_| Ok(())) } diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index c69bfe41b..039a5a766 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -1489,6 +1489,55 @@ fn a_granted_listing_authorises_only_the_files_it_names() { std::fs::remove_dir_all(&dir).ok(); } +/// The reference sink opens no encryption scheme, so an envelope whose manifest +/// declares one is inert at it (SPEC §6.4 step 2, `unsupported_encryption`): a +/// payload sent in the clear under a claim of encryption is not decoded as plaintext, +/// and a tool's JSON form under that claim is not read as a payload either. The same +/// payload without the claim decodes; a `WAI2` gives every entry the reason, also when +/// its `security` member is not a security object. +#[test] +fn an_encrypted_envelope_is_inert_at_the_reference_sink() { + use wai::codecs::CAP_NEURAL_INT_HYPER as HYP; + use wai::container::{Rendition, Security, WaiMulti}; + use wai::neural::decode_multi; + let v = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors"); + let dir = std::env::temp_dir().join(format!("wai-encrypted-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (k, f) in [("entropy.json", "int_hyper/tiny_entropy.json"), ("g_s.whm", "int_hyper/tiny_g_s.bin"), ("h_s.whm", "int_hyper/tiny_h_s.bin")] { + std::fs::copy(v.join(f), dir.join(k)).unwrap(); + } + let reg = ModelRegistry::new().register(HYP, dir.clone()); + let (wih1, json) = (std::fs::read(v.join("int_hyper/tiny_bitstream.wih1")).unwrap(), std::fs::read(v.join("int_hyper/tiny_bitstream.json")).unwrap()); + let env = |payload: &[u8], security: Option| { + let mut m = Manifest::new("image", "improve", ModelRequirement::new(HYP), Conditioning::new("bytes")); + m.security = security; + Wai::from_bytes(&Wai::new(m, payload.to_vec()).to_emit_bytes().unwrap()).unwrap() + }; + assert!(matches!(decode_envelope(&env(&wih1, None), ®), Ok(Decoded::Image(_))), "in the clear, it decodes"); + for payload in [&wih1, &json] { + match decode_envelope(&env(payload, Some(Security::new("aes-256-gcm", 7))), ®) { + Err(DecodeError::Encrypted(why)) => assert!(why.starts_with("unsupported_encryption: "), "{why}"), + other => panic!("an encrypted envelope must be inert, got {}", refusal(other)), + } + } + + let multi = |security: Option| { + let mut m = WaiMulti::new("image", "improve", serde_json::Value::Null, vec![Rendition::media(HYP, "bytes", wih1.clone())]); + if let Some(s) = security { + m.manifest.extra.insert("security".into(), s); + } + WaiMulti::from_bytes(&m.to_bytes().unwrap()).unwrap() + }; + assert!(matches!(decode_multi(&multi(None), ®), Ok((0, Decoded::Image(_))))); + for s in [serde_json::json!({"encryption": "mls-sframe", "kid": 0}), serde_json::json!(true)] { + match decode_multi(&multi(Some(s.clone())), ®) { + Err(DecodeError::NoRendition(skips)) => assert_eq!(skips, vec![(0, "unsupported_encryption".to_string())], "{s}"), + other => panic!("{s}: expected every entry inert, got {:?}", other.map(|(i, _)| i)), + } + } + std::fs::remove_dir_all(&dir).ok(); +} + /// The neural registry's pinned path runs check 5 over every identity of the bytes it /// verified: a one-file set whose file digest a key log revokes is refused through a /// set-form pin over a listing of that file. diff --git a/wai/wai-web/src/lib.rs b/wai/wai-web/src/lib.rs index fc467181a..c7c5f5a5e 100644 --- a/wai/wai-web/src/lib.rs +++ b/wai/wai-web/src/lib.rs @@ -435,8 +435,9 @@ impl WaiEnvelope { /// SPEC §4: the capability that decodes this envelope at a page that /// advertises `advertised` and holds the parameter sets `held` (see /// `verifyPin`); `undefined` when it is inert here — including when its - /// capability is companion-only, or when its pin is refused and no fallback - /// remains. + /// capability is companion-only, when its pin is refused and no fallback + /// remains, or when its manifest declares an encryption, which this page does not + /// open (SPEC §6.4 step 2). #[wasm_bindgen(js_name = dispatchCapability)] pub fn dispatch_capability(&self, advertised: Vec, held: JsValue) -> Result, JsError> { let held = held_from_js(&held)?; @@ -479,8 +480,10 @@ fn pack_refusal(m: &wai::container::Manifest, payload: &[u8]) -> Result<(), Stri } /// What [`pack_multi`] refuses before it writes, once it holds the payloads: a -/// component whose payload is not the format SPEC §5 pins its integer capability to. +/// `security` member that is not a security object (SPEC §6.1), and a component whose +/// payload is not the format SPEC §5 pins its integer capability to. fn pack_multi_refusal(manifest: &wai::container::ManifestV2, payloads: &[Vec]) -> Result<(), String> { + wai::container::manifest_v2_security(manifest)?; if !wai::container::manifest_v2_plaintext(manifest) { return Ok(()); } @@ -746,7 +749,14 @@ mod tests { assert_eq!(super::pack_refusal(&enc, &cipher), Ok(())); let mut m2e = m2.clone(); m2e.extra.insert("security".into(), serde_json::json!({"encryption": "mls-sframe", "kid": 0})); - assert_eq!(super::pack_multi_refusal(&m2e, &[cipher.clone(), cipher]), Ok(())); + assert_eq!(super::pack_multi_refusal(&m2e, &[cipher.clone(), cipher.clone()]), Ok(())); + // A `security` member that is not a security object is not written: it would + // claim an encryption no sink can read. + for bad in [serde_json::json!(true), serde_json::json!("aes"), serde_json::json!([]), serde_json::json!({"encryption": 5})] { + m2e.extra.insert("security".into(), bad.clone()); + let e = super::pack_multi_refusal(&m2e, &[cipher.clone(), cipher.clone()]).unwrap_err(); + assert!(e.contains("is not a security object"), "{bad}: {e}"); + } } /// A companion-only component is never presented as media: not picked, not -- 2.54.0 (Apple Git-157) From fbde9b2131aad90dc78b6e3d777bd86b1f93d497 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 23:08:03 -0400 Subject: [PATCH 34/34] wai: an unpinned decode reads its set once and is checked by every identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside the integer path, a sink whose key log only revokes ran check 5 over the registered file's digest and any listing found beside it, then decoded from the path. With no listing beside them, a revoked several- file set decoded, and a file changed after the check was what the decode read. At a registry with a key log, the neural sink now reads once what the decode can read: the registered file of a one-file set; otherwise every file below the directory the decoder reads from (the registered directory, or the registered file's), as pin::listing_of_dir lists it (pin::read_dir_set, which listing_of_dir now uses). Check 5 runs over every digest a pin over it could name: the file's, or the directory's derived listing and each member's, and each listing beside them that matches what was read. The decode then reads only those bytes (neural::ParamSource::Checked, which replaces Authorised and records the registered file within a set), never the path; where the key log holds the capability's sets to jwp-receipts §7.9, only the part §7.9 authorised. The decoders outside the integer path read files by name from that directory, some chosen by the payload, so the set is the directory rather than a fixed list. With no key log the decode reads the path, as before. SPEC §4 (Module authorisation) and jwp-receipts §7.9 state the reference behaviour. Test: neural_native_decode::a_revoke_only_key_log_checks_and_reads_the_set_once (a revoked set beside its listing; with no listing, the decoder and the directory registered; a member's own digest revoked; the tables swapped on disk after the check; no key log). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-neural-gates.yml | 8 +- wai/CHANGELOG.md | 18 ++- wai/SPEC.md | 16 +- wai/extensions/jwp-receipts.md | 3 +- wai/wai-rs/src/neural/mod.rs | 194 ++++++++++++++++++----- wai/wai-rs/src/neural/params.rs | 43 ++--- wai/wai-rs/src/pin.rs | 24 ++- wai/wai-rs/tests/neural_native_decode.rs | 85 +++++++++- 8 files changed, 301 insertions(+), 90 deletions(-) diff --git a/.github/workflows/wai-neural-gates.yml b/.github/workflows/wai-neural-gates.yml index 0d0fda438..07fccb2c1 100644 --- a/.github/workflows/wai-neural-gates.yml +++ b/.github/workflows/wai-neural-gates.yml @@ -16,8 +16,9 @@ name: WAI neural sink gates # committed int_mlicpp vector and the int_synth corpus: the form-less and set-form # pins through the strict gate, the int_synth pin over the spec it reads, the # several-file capabilities refusing a form-less pin, a pinned decode reading only -# the bytes it verified, an unpinned decode at a sink holding the capability's sets -# to §7.9 reading only the set §7.9 authorised, an encrypted envelope inert at the +# the bytes it verified, an unpinned decode at a sink with a key log checked over +# every identity of what it read once and reading only that (or, holding the +# capability's sets to §7.9, the part §7.9 authorised), an encrypted envelope inert at the # sink (SPEC §6.4 step 2), a WAI2 envelope whose pins pick the held model, the strict # rule read for the capability decoded through (a sound fallback behind an unsound # primary), SPEC §4 step 6's order of reasons after a set_incomplete decode, and the @@ -147,8 +148,9 @@ jobs: a_pinned_set_revoked_under_its_file_digest_is_refused_through_a_listing_pin \ a_granted_listing_authorises_only_the_files_it_names \ an_encrypted_envelope_is_inert_at_the_reference_sink \ + a_revoke_only_key_log_checks_and_reads_the_set_once \ 2>&1 | tee "$RUNNER_TEMP/neural-gates.log" - grep -q "test result: ok. 19 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" + grep -q "test result: ok. 20 passed; 0 failed" "$RUNNER_TEMP/neural-gates.log" # The integer video, audio and splat capabilities through the neural dispatch, which # delegates them to int_payload::decode_int: each decodes to Decoded::Int with its # committed SHA-256, and wai.video.int_motion is advertised with ModelRegistry::builtin. diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 8c91231db..bd77e07a8 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -69,16 +69,22 @@ wire format may change and such changes are recorded here. file digest (`int_payload::read_identities`), so a set revoked under one identity is not reached through a pin naming it by another; and a listing beside an unpinned set counts only when it matches the files read. Outside the integer path, - at a sink whose key log holds the capability's sets to jwp-receipts §7.9, an - unpinned decode reads only the set §7.9 authorised, read once (the registered - file, or the files a listing beside it names), as a pinned decode does: a granted - listing authorises the files it names and nothing else, and a file the decode - needs outside the set is `set_incomplete` (SPEC §4, *Module authorisation*). + at a sink that holds a key log, an unpinned decode reads what it can read once — + the registered file of a one-file set, or every file below the directory its + decoder reads from (`pin::read_dir_set`) — and check 5 runs over every digest a pin + over it could name: the file's, or the directory's derived listing + (`pin::listing_of_dir`) and each file's, and each listing beside them that matches. + The decode reads only those bytes, never the path again; where the key log holds + the capability's sets to jwp-receipts §7.9, only the part §7.9 authorised (the + whole, the files a granted listing names, or one granted file). So a revoked set + is refused with no listing beside it, a granted listing authorises the files it + names and nothing else, a file changed after the check is not read, and a file the + decode needs outside the set is `set_incomplete` (SPEC §4, *Module authorisation*). `pin::PinAuthority` gains `requires_grant`, with a default. *Breaking:* `neural::Decoded` gains the variant `Int` and is now `#[non_exhaustive]`, so a `match` on it outside this crate needs a wildcard arm; `neural::ParamSource` gains `UnpinnedRead` (an unpinned integer set read into memory once) and - `Authorised` (a set §7.9 authorised for an unpinned decode) and is + `Checked` (what an unpinned decode at a sink with a key log reads, read once) and is `#[non_exhaustive]` too; `int_payload::IntParams` is opaque, built by `int_payload::resolve` (or `IntParams::none()`). - **Checked decoders.** `int_video`, `int_audio` and `int_splat` decode a typed diff --git a/wai/SPEC.md b/wai/SPEC.md index 9c275f3d8..582086957 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -422,10 +422,12 @@ pin does not resolve. An envelope that pins no set is decoded against the set th sink holds for `cap`, and a sink that holds a key log runs check 5 over that set too, identified by each digest a pin over it could name: it does not decode `cap` for that envelope when any of them is revoked, or, for a capability it holds to -§7.9, when §7.9 holds the set under none of them; for such a capability it then -decodes from the set §7.9 holds alone — that one file, or the files the held listing -names, as a pinned decode reads only what its pin verified (§3.1) — and a file the -decode needs outside it is `set_incomplete`. Either way +§7.9, when §7.9 holds the set under none of them. It then decodes from the bytes +check 5 ran over alone, as a pinned decode reads only what its pin verified (§3.1): +read once, so a file changed after the check is not what the decode reads, and, for +a capability it holds to §7.9, only the part §7.9 holds (the set, the files a held +listing names, or one held file); a file the decode needs outside them is +`set_incomplete`. Either way the algorithm above runs unchanged, so a sink MUST NOT report a file inert because of module authorisation while a fallback it can decode without that module remains, and an encoder emitting a capability whose decode needs a module MUST declare such a @@ -436,7 +438,11 @@ runs check 5 when the sink is configured with a key log (`key_log::SinkKeyLog`, feature `key_discovery`, given to `pin::verify_with`, to `int_payload::resolve` (which runs it for a pinned decode and over the files an unpinned integer decode reads) or to `neural::ModelRegistry::with_key_log`, which also runs it before an -unpinned decode, over the bytes that decode then reads): the revocation part for +unpinned decode, over the bytes that decode then reads, read once: an integer +decode's members, and otherwise the registered file of a one-file set, or every +file below the directory the decoder reads from, identified by the listing +`pin::listing_of_dir` derives, each file's digest and each listing beside them that +matches): the revocation part for every capability a pin applies to, and §7.9's authorisation for the capabilities the sink holds to it. `wai-web` and the C ABI take no key log. diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index cbb6b8f82..5dd2439e5 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -2605,7 +2605,8 @@ configured with a key log (`key_log::SinkKeyLog`, given to `pin::verify_with`, to `int_payload::resolve`, which runs it for a pinned decode and over the files an unpinned integer decode reads, or to `neural::ModelRegistry::with_key_log`, which also runs it before an -unpinned decode, over the bytes that decode then reads): the revocation +unpinned decode, over the bytes that decode then reads, read once, by +every digest a pin over them could name): the revocation lists for every capability a pin applies to, and these checks for the capabilities a sink holds to this section. A sink configured with no key log runs neither. The reference dispatcher runs no module obtained diff --git a/wai/wai-rs/src/neural/mod.rs b/wai/wai-rs/src/neural/mod.rs index c0e7f3d79..254548871 100644 --- a/wai/wai-rs/src/neural/mod.rs +++ b/wai/wai-rs/src/neural/mod.rs @@ -62,10 +62,12 @@ pub use video::DecodedVideo; /// a set whose digest it revokes is refused, and the sets of the capabilities the /// sink's relying party holds to jwp-receipts §7.9 must be held under §7.9. It runs /// over the set an unpinned decode reads too (SPEC §4, *Module authorisation*), and the -/// decode then reads only bytes check 5 ran over: for an integer capability, the -/// members the registry read once; for another capability held to §7.9, the set §7.9 -/// authorised, read once ([`ParamSource::Authorised`]). A registry without one does -/// not run check 5. +/// decode then reads only bytes check 5 ran over, read once: for an integer capability, +/// the members its decode reads ([`ParamSource::UnpinnedRead`]); for another, the +/// registered file of a one-file set or every file below the directory its decoder +/// reads from, and, for a capability held to §7.9, the part of that §7.9 authorised +/// ([`ParamSource::Checked`]). A registry without one does not run check 5, and an +/// unpinned decode reads the registered path as it always has. /// /// A capability whose decode reads no parameter set (`wai.video.int_motion`) is /// advertised with [`ModelRegistry::builtin`] instead, and holds no path. @@ -164,16 +166,17 @@ impl ModelRegistry { /// (the form `wai.prior.set`) adds its digest only when it names, with their /// digests, every member the decode reads ([`listing_digests`]). /// - /// For any other capability the sets that could identify what the decode reads are - /// read here, once each: the registered file, where the capability's set is one - /// file, and each listing beside the path whose every file matches its digest (and - /// that names the registered file, where that is a file) ([`listed_sets`]). Check - /// 5 runs over their digests. Where the key log holds the capability's sets to - /// §7.9, the decode then reads only the set §7.9 authorised, from the bytes read - /// here ([`ParamSource::Authorised`]), so a file the set does not name is - /// `set_incomplete`, never read from disk; a granted listing authorises the files - /// it names and nothing else. Where it does not, check 5 is revocation alone and - /// the decode reads the path, as it always has. `capability` is registered. + /// For any other capability what the decode can read is read here, once + /// ([`read_once`]): the registered file of a one-file set; otherwise every file + /// below the directory the decoder reads its files from (the registered directory, + /// or the registered file's), as `pin::listing_of_dir` lists it. Check 5 runs over + /// every digest a pin over it could name: the file's, or the directory's derived + /// listing and each member's; and each listing beside it that matches what was read. + /// The decode then reads only those bytes ([`ParamSource::Checked`]), never the path + /// again, so a file changed or added after the check is not read, and a file + /// outside the set is `set_incomplete`. Where the key log holds the capability's sets + /// to §7.9, the decode reads only the part §7.9 authorised: the whole, the files a + /// granted listing names, or one granted file. `capability` is registered. fn unpinned(&self, capability: &str) -> Result { let Some(path) = self.paths.get(capability) else { // A built-in capability reads no parameter set: there is nothing to check. @@ -191,27 +194,129 @@ impl ModelRegistry { return Ok(ParamSource::UnpinnedRead(files)); } let Some(k) = key_log else { return Ok(ParamSource::Unpinned(path.clone())) }; - let mut sets: Vec<(String, crate::pin::Verified)> = Vec::new(); - if crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile { - for h in artifacts_at(capability, [path], false) { - let (crate::pin::Held::File(f) | crate::pin::Held::Listing(f)) = h; - if let Ok(bytes) = std::fs::read(f) { - sets.push((crate::pin::sha256_hex(&bytes), crate::pin::Verified::File(bytes))); - } + let read = read_once(capability, path)?; + k.check_unpinned(capability, &read.identities)?; + if !k.requires_grant(capability) { + return Ok(read.source(Part::Whole)); + } + // Check 5 held the set under one of these digests: the decode reads that part. + let i = read.parts.iter().position(|(d, _)| k.check_unpinned(capability, std::slice::from_ref(d)).is_ok()) + .ok_or_else(|| PinRefusal::NotAuthorised { capability: capability.to_string(), check: None, reason: None })?; + let mut read = read; + let (_, part) = read.parts.swap_remove(i); + Ok(read.source(part)) + } +} + +/// What an unpinned decode through a capability outside the integer path can read, at +/// a registry with a key log, read once ([`read_once`]). +struct ReadOnce { + /// The bytes read: the one file, or every file below the decoder's directory. + set: crate::pin::Verified, + /// The registered file's name within `set`, where a file was registered. + model: Option, + /// Every digest a pin over what was read could name, for check 5. + identities: Vec, + /// The parts §7.9 can hold the decode under, each with its digest, in order: the + /// whole; each matching listing (naming the registered file, where a file was + /// registered); each member file (the registered one, where a file was registered). + parts: Vec<(String, Part)>, +} + +/// A part of what [`read_once`] read. +enum Part { + Whole, + /// These files of the set read. + Names(Vec), + /// A set read apart: a listing beside a one-file set's file, with its files. + Read(crate::pin::Verified), +} + +impl ReadOnce { + /// The parameters a decode of `part` reads. + fn source(self, part: Part) -> ParamSource { + use crate::pin::Verified; + let set = match (part, self.set) { + (Part::Whole, set) => set, + (Part::Names(names), Verified::Set { root, mut files }) => Verified::Set { + root, + files: names.into_iter().filter_map(|n| files.remove(&n).map(|b| (n, b))).collect(), + }, + (Part::Names(_), file) => file, + (Part::Read(v), _) => v, + }; + let model = match set { + Verified::File(_) => None, + Verified::Set { .. } => self.model, + }; + ParamSource::Checked { set, model } + } +} + +/// Read, once, what an unpinned decode through `capability` (outside the integer path) +/// at `registered` can read, with every digest a pin over it could name (SPEC §4, +/// *Module authorisation*): +/// +/// - a one-file capability registered as its file: that file, identified by its +/// SHA-256 and by each listing beside it that names it and matches the files it +/// names (each such listing's files read with it, [`listed_sets`]); +/// - otherwise every file below the directory the decoder reads its files from — the +/// registered directory, or the registered file's — as `pin::listing_of_dir` lists +/// it (`pin::read_dir_set`: a symbolic link is `set_malformed`), identified by that +/// derived listing, by each member's SHA-256, and by each listing beside the files +/// that matches them. The decoders outside the integer path read files by name from +/// that directory, some chosen by the payload (`wai.neural.bmshj2018` reads the +/// tables of the payload's quality), so the set is the directory, not a fixed list. +/// +/// A registered path that cannot be read is `set_incomplete`. +fn read_once(capability: &str, registered: &Path) -> Result { + use crate::pin::{listing_from, parse_listing, read_dir_set, sha256, sha256_hex, Held, Verified}; + let missing = |p: &Path| PinRefusal::SetIncomplete { path: p.display().to_string() }; + let file_name = registered.is_file().then(|| registered.file_name().and_then(|n| n.to_str()).map(str::to_string)).flatten(); + if crate::container::pin_shape(capability) == crate::codecs::ParameterSetShape::OneFile && registered.is_file() { + let bytes = std::fs::read(registered).map_err(|_| missing(registered))?; + let d = sha256_hex(&bytes); + let mut out = ReadOnce { set: Verified::File(bytes), model: file_name, identities: vec![d.clone()], parts: vec![(d, Part::Whole)] }; + for (d, v) in listed_sets(capability, registered) { + out.identities.push(d.clone()); + out.parts.push((d, Part::Read(v))); + } + return Ok(out); + } + if !registered.is_dir() && file_name.is_none() { + return Err(missing(registered)); + } + let root = if registered.is_dir() { registered.to_path_buf() } else { ParamSource::dir(registered) }; + let files = read_dir_set(&root)?; + if let Some(m) = &file_name { + if !files.contains_key(m) { + return Err(missing(registered)); + } + } + let view: std::collections::BTreeMap = files.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let derived = sha256_hex(&listing_from(&view)?); + let mut identities = vec![derived.clone()]; + let mut parts = vec![(derived, Part::Whole)]; + for h in artifacts_at(capability, [®istered.to_path_buf()], true) { + let Held::Listing(f) = h else { continue }; + let Ok(listing) = std::fs::read(&f) else { continue }; + let Ok(lines) = parse_listing(&listing) else { continue }; + if lines.iter().all(|(p, d)| files.get(p).is_some_and(|b| sha256(b) == *d)) { + let d = sha256_hex(&listing); + identities.push(d.clone()); + if file_name.as_ref().is_none_or(|m| lines.iter().any(|(p, _)| p == m)) { + parts.push((d, Part::Names(lines.into_iter().map(|(p, _)| p).collect()))); } } - sets.extend(listed_sets(capability, path)); - let digests: Vec = sets.iter().map(|(d, _)| d.clone()).collect(); - k.check_unpinned(capability, &digests)?; - if !k.requires_grant(capability) { - return Ok(ParamSource::Unpinned(path.clone())); - } - // Check 5 held the sets under one of these digests: the decode reads that set. - sets.into_iter() - .find(|(d, _)| k.check_unpinned(capability, std::slice::from_ref(d)).is_ok()) - .map(|(_, set)| ParamSource::Authorised(set)) - .ok_or_else(|| PinRefusal::NotAuthorised { capability: capability.to_string(), check: None, reason: None }) } + for (name, b) in &files { + let d = sha256_hex(b); + identities.push(d.clone()); + if file_name.as_ref().is_none_or(|m| m == name) { + parts.push((d, Part::Names(vec![name.clone()]))); + } + } + Ok(ReadOnce { set: Verified::Set { root, files }, model: file_name, identities, parts }) } /// The digests of the listings beside `registered` (each `*.sha256` in its directory, @@ -245,14 +350,14 @@ fn listing_digests(capability: &str, registered: &Path, read: &std::collections: out } -/// The sets the listings beside `registered` identify for a capability outside the -/// integer path (each `*.sha256` in its directory, the form `wai.prior.set`): each -/// listing's digest with the files it names, read once from beside it. A listing is -/// read as a pinned set's would be (SPEC §3.1 checks 7 and 8): malformed, or naming a -/// file that is absent or whose bytes are not its digest, it is refused and identifies -/// nothing; where `registered` is a file, a listing that does not name it identifies -/// nothing either, since the decode reads that file. Only a listing check 5 then -/// authorises is a set the decode reads, and it reads nothing outside it. +/// The sets the listings beside the registered file of a one-file capability outside +/// the integer path identify (each `*.sha256` in its directory, the form +/// `wai.prior.set`): each listing's digest with the files it names, read once from +/// beside it. A listing is read as a pinned set's would be (SPEC §3.1 checks 7 and 8): +/// malformed, or naming a file that is absent or whose bytes are not its digest, it is +/// refused and identifies nothing; a listing that does not name the registered file +/// identifies nothing either, since the decode reads that file. Only a listing check 5 +/// then authorises is a set the decode reads, and it reads nothing outside it. fn listed_sets(capability: &str, registered: &Path) -> Vec<(String, crate::pin::Verified)> { use crate::pin::{parse_listing, sha256, sha256_hex, Held, Verified}; let mut out = Vec::new(); @@ -366,9 +471,10 @@ impl ModelRegistry { /// The parameters a decode through `capability` reads: verified against `prior` /// when a pin applies to the capability ([`crate::container::pin_applies_to`]), /// otherwise the registered artifact, unverified — the honest meaning of an - /// unpinned decode — once a key log the registry holds has not refused it; where - /// that key log holds the capability's sets to jwp-receipts §7.9, only the set - /// §7.9 authorised ([`ParamSource::Authorised`]). + /// unpinned decode — once a key log the registry holds has not refused it; with a + /// key log, the bytes check 5 ran over, read once, and where that key log holds + /// the capability's sets to jwp-receipts §7.9 only the part §7.9 authorised + /// ([`ParamSource::Checked`]). pub fn resolve_verified(&self, capability: &str, prior: Option<&Prior>) -> Result { if !self.advertises(capability) { return Err(DecodeError::ModelNotRegistered(capability.to_string())); @@ -644,7 +750,7 @@ fn with_int_params( let view: std::collections::BTreeMap = unpinned.into_iter().flatten().map(|(k, v)| (k.clone(), v.as_slice())).collect(); let p = match params { - ParamSource::Pinned(v) | ParamSource::Authorised(v) => IntParams::verified(v), + ParamSource::Pinned(v) | ParamSource::Checked { set: v, .. } => IntParams::verified(v), _ => resolve(cap, None, &[crate::pin::HeldBytes::Set { listing: b"", files: &view }], None) .map_err(DecodeError::PriorMismatch)?, }; diff --git a/wai/wai-rs/src/neural/params.rs b/wai/wai-rs/src/neural/params.rs index facad1b96..7e6c78695 100644 --- a/wai/wai-rs/src/neural/params.rs +++ b/wai/wai-rs/src/neural/params.rs @@ -6,8 +6,8 @@ //! ([`crate::pin::Verified`]) — the very bytes that were hashed, never a second read //! of the same path — and a file the verified set does not contain is refused with //! `set_incomplete` instead of being read from disk unverified. So does an unpinned -//! decode at a sink that holds the capability's sets to jwp-receipts §7.9: it reads -//! only the set §7.9 authorised ([`ParamSource::Authorised`]). +//! decode at a sink that holds a key log: it reads only the bytes check 5 ran over, +//! read once ([`ParamSource::Checked`], [`ParamSource::UnpinnedRead`]). //! //! Sessions are built for determinism (`crate::ort_session`): one intra-op and one //! inter-op thread and a fixed graph-optimization level, because multi-threaded @@ -35,13 +35,15 @@ pub enum ParamSource { Unpinned(PathBuf), /// A pin verified this set; the decode reads nothing else. Pinned(Verified), - /// No pin applied, and the sink's key log holds the capability's sets to - /// jwp-receipts §7.9 (SPEC §4, *Module authorisation*): the set §7.9 authorised, - /// read once when the decode started — the registered file, or every file a - /// listing beside it names. The decode reads nothing else, as a pinned decode - /// does: a file the set does not contain is `set_incomplete` - /// ([`super::ModelRegistry`]). - Authorised(Verified), + /// No pin applied, and the sink holds a key log (SPEC §4, *Module authorisation*): + /// the set the decode reads, read once when the decode started, over whose every + /// identity check 5 ran ([`super::ModelRegistry`]) — the registered file of a + /// one-file set, or every file below the directory the decoder reads from; where + /// the key log holds the capability's sets to jwp-receipts §7.9, the set §7.9 + /// authorised. `model` is the registered file's name within a set, where a file + /// was registered. The decode reads nothing else, as a pinned decode does: a file + /// the set does not contain is `set_incomplete`. + Checked { set: Verified, model: Option }, /// No pin applied, and the set an integer capability's decode reads was read into /// memory once, when the decode started: its members (`int_payload::set_members`) /// by the names a set lists them under, a member the sink could not read being @@ -71,16 +73,21 @@ impl ParamSource { /// The artifact the capability registers, named `set_name` within a set: for an /// unpinned decode the registered file itself; for one verified file, that file; - /// for a set, the file it lists as `set_name`. + /// for a set read where a file was registered, that file; for another set, the + /// file it lists as `set_name`. pub(crate) fn model(&self, set_name: &str) -> Result, DecodeError> { + fn from<'a>(v: &'a Verified, name: &str) -> Result, DecodeError> { + match v { + Verified::File(b) => Ok(Cow::Borrowed(b.as_slice())), + v => v.file(name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), + } + } match self { Self::Unpinned(p) => std::fs::read(p).map(Cow::Owned) .map_err(|e| DecodeError::InvalidPayload(format!("{}: {e}", p.display()))), Self::UnpinnedRead(files) => files.get(set_name).map(|b| Cow::Borrowed(b.as_slice())).ok_or_else(|| incomplete(set_name)), - Self::Pinned(v) | Self::Authorised(v) => match v { - Verified::File(b) => Ok(Cow::Borrowed(b)), - v => v.file(set_name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), - }, + Self::Pinned(v) => from(v, set_name), + Self::Checked { set, model } => from(set, model.as_deref().unwrap_or(set_name)), } } @@ -95,7 +102,7 @@ impl ParamSource { .map_err(|e| DecodeError::InvalidPayload(format!("{}: {e}", f.display()))) } Self::UnpinnedRead(files) => files.get(name).map(|b| Cow::Borrowed(b.as_slice())).ok_or_else(|| incomplete(name)), - Self::Pinned(v) | Self::Authorised(v) => v.file(name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), + Self::Pinned(v) | Self::Checked { set: v, .. } => v.file(name).map(Cow::Borrowed).map_err(DecodeError::PriorMismatch), } } @@ -105,7 +112,7 @@ impl ParamSource { match self { Self::Unpinned(p) => std::fs::read(Self::dir(p).join(name)).ok().map(Cow::Owned), Self::UnpinnedRead(files) => files.get(name).map(|b| Cow::Borrowed(b.as_slice())), - Self::Pinned(v) | Self::Authorised(v) => v.file(name).ok().map(Cow::Borrowed), + Self::Pinned(v) | Self::Checked { set: v, .. } => v.file(name).ok().map(Cow::Borrowed), } } @@ -123,7 +130,7 @@ impl ParamSource { pub(crate) fn session_model(&self, set_name: &str) -> Result { match self { Self::Unpinned(p) => crate::ort_session::load_deterministic(p).map_err(DecodeError::Ort), - Self::Pinned(_) | Self::Authorised(_) | Self::UnpinnedRead(_) => session_from_verified(&self.model(set_name)?), + Self::Pinned(_) | Self::Checked { .. } | Self::UnpinnedRead(_) => session_from_verified(&self.model(set_name)?), } } @@ -131,7 +138,7 @@ impl ParamSource { pub(crate) fn session_sibling(&self, name: &str) -> Result { match self { Self::Unpinned(p) => crate::ort_session::load_deterministic(&Self::dir(p).join(name)).map_err(DecodeError::Ort), - Self::Pinned(_) | Self::Authorised(_) | Self::UnpinnedRead(_) => session_from_verified(&self.sibling(name)?), + Self::Pinned(_) | Self::Checked { .. } | Self::UnpinnedRead(_) => session_from_verified(&self.sibling(name)?), } } } diff --git a/wai/wai-rs/src/pin.rs b/wai/wai-rs/src/pin.rs index d02199d59..dfbba4eb7 100644 --- a/wai/wai-rs/src/pin.rs +++ b/wai/wai-rs/src/pin.rs @@ -188,7 +188,17 @@ pub fn write_listing(root: &Path, paths: &[String]) -> Result, PinRefusa /// `.` and the `*.sha256` files directly in `root` (the listings themselves). A /// symbolic link, or a name a listing path cannot carry, is refused. pub fn listing_of_dir(root: &Path) -> Result, PinRefusal> { - fn walk(root: &Path, rel: &str, out: &mut Vec) -> Result<(), PinRefusal> { + let files = read_dir_set(root)?; + let view: BTreeMap = files.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + listing_from(&view) +} + +/// Every file [`listing_of_dir`] lists below `root`, read once, by its listing path: +/// the set a sink holds as that directory, with the bytes its listing is derived +/// from. A symbolic link, or a name a listing path cannot carry, is refused +/// (`set_malformed`). +pub fn read_dir_set(root: &Path) -> Result>, PinRefusal> { + fn walk(root: &Path, rel: &str, out: &mut BTreeMap>) -> Result<(), PinRefusal> { let dir = if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }; let rd = std::fs::read_dir(&dir).map_err(|e| malformed(format!("{}: {e}", dir.display())))?; for entry in rd { @@ -207,14 +217,18 @@ pub fn listing_of_dir(root: &Path) -> Result, PinRefusal> { } else if rel.is_empty() && name.ends_with(".sha256") { continue; } else { - out.push(path); + if !is_listing_path(&path) { + return Err(malformed(format!("'{path}' is not a listing path"))); + } + let b = std::fs::read(root.join(&path)).map_err(|e| malformed(format!("{path}: {e}")))?; + out.insert(path, b); } } Ok(()) } - let mut paths = Vec::new(); - walk(root, "", &mut paths)?; - write_listing(root, &paths) + let mut files = BTreeMap::new(); + walk(root, "", &mut files)?; + Ok(files) } /// A parameter set a sink holds for a capability, in memory: one file, or a diff --git a/wai/wai-rs/tests/neural_native_decode.rs b/wai/wai-rs/tests/neural_native_decode.rs index 039a5a766..5ec8433c7 100644 --- a/wai/wai-rs/tests/neural_native_decode.rs +++ b/wai/wai-rs/tests/neural_native_decode.rs @@ -1414,8 +1414,8 @@ fn a_listing_that_does_not_match_the_files_read_authorises_nothing() { /// Outside the integer path, a granted listing authorises the files it names and /// nothing else: at a sink whose key log holds the capability's sets to §7.9, the -/// unpinned decode reads only the set §7.9 authorised, read once -/// (`ParamSource::Authorised`), and a file the decode needs that the set does not name +/// unpinned decode reads only the part §7.9 authorised of what it read once +/// (`ParamSource::Checked`), and a file the decode needs that the part does not name /// is `set_incomplete`, never read from disk. A `wai.neural.bmshj2018` payload reads /// its CDF tables (`cdfs_q3.json`) before it loads the decoder, so a decode that read /// the unauthorised tables beside the decoder would fail parsing them instead. @@ -1426,7 +1426,7 @@ fn a_listing_that_does_not_match_the_files_read_authorises_nothing() { /// `set_incomplete`; /// - a granted listing of both files: the decode reads the tables from the set; /// - a one-file capability granted under its file's digest: that file alone; -/// - a key log that only revokes: the path, as before. +/// - a key log that only revokes: every file of the decoder's directory, read once. #[test] fn a_granted_listing_authorises_only_the_files_it_names() { use wai::neural::ParamSource; @@ -1453,8 +1453,8 @@ fn a_granted_listing_authorises_only_the_files_it_names() { payload.extend_from_slice(&[1, 0]); let env = Wai::new(Manifest::new("image", "improve", ModelRequirement::new(bm), Conditioning::new("bytes")), payload); let names = |p: &ParamSource| match p { - ParamSource::Authorised(Verified::Set { files, .. }) => files.keys().cloned().collect::>().join(","), - ParamSource::Authorised(Verified::File(_)) => "one file".into(), + ParamSource::Checked { set: Verified::Set { files, .. }, .. } => files.keys().cloned().collect::>().join(","), + ParamSource::Checked { set: Verified::File(_), .. } => "one file".into(), ParamSource::Unpinned(_) => "the path".into(), other => format!("{other:?}"), }; @@ -1480,15 +1480,84 @@ fn a_granted_listing_authorises_only_the_files_it_names() { let digest = wai::pin::sha256_hex(files[0].1); let reg = ModelRegistry::new().register(enc, dir.join("decoder.onnx")).with_key_log(Grants(vec![digest.clone()])); match reg.resolve_verified(enc, None).unwrap() { - ParamSource::Authorised(Verified::File(b)) => assert_eq!(b, files[0].1), + ParamSource::Checked { set: Verified::File(b), .. } => assert_eq!(b, files[0].1), other => panic!("expected the authorised file, got {other:?}"), } - // A key log that only revokes holds nothing to §7.9: the decode reads the path. + // A key log that only revokes holds nothing to §7.9: the decode reads every file of + // the decoder's directory, read once. let reg = ModelRegistry::new().register(bm, dir.join("decoder.onnx")).with_key_log(Revokes(vec!["00".repeat(32)])); - assert_eq!(names(®.resolve_verified(bm, None).unwrap()), "the path"); + assert_eq!(names(®.resolve_verified(bm, None).unwrap()), "cdfs_q3.json,decoder.onnx,notes.txt"); std::fs::remove_dir_all(&dir).ok(); } +/// At a sink whose key log only revokes, an unpinned decode outside the integer path +/// is checked over every digest a pin over what it reads could name, and reads those +/// bytes alone (SPEC §4, *Module authorisation*). With no listing beside the files, a +/// key log that revokes the listing of `{decoder.onnx, cdfs_q3.json}` still refuses +/// them (`prior_revoked`) through the listing derived from the directory, whether the +/// decoder or the directory is registered; one that revokes a member's own digest +/// refuses them too. The bytes were read once: tables swapped on disk after the check +/// are not the tables the decode reads. With no key log, the decode reads the path. +#[test] +fn a_revoke_only_key_log_checks_and_reads_the_set_once() { + use wai::neural::{decode_payload, ParamSource}; + use wai::pin::Verified; + let bm = "wai.neural.bmshj2018"; + let root = std::env::temp_dir().join(format!("wai-revoke-only-{}", std::process::id())); + let (decoder, cdfs_a, cdfs_b): (&[u8], &[u8], &[u8]) = (b"the publisher's decoder bytes", b"cdfs A: not JSON", b"{\"channels\": 1}"); + let mk = |name: &str| { + let d = root.join(name); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("decoder.onnx"), decoder).unwrap(); + std::fs::write(d.join("cdfs_q3.json"), cdfs_a).unwrap(); + d + }; + let set: std::collections::BTreeMap = + [("decoder.onnx".to_string(), decoder), ("cdfs_q3.json".to_string(), cdfs_a)].into_iter().collect(); + let listing = wai::pin::listing_from(&set).unwrap(); + let revoked = wai::pin::sha256_hex(&listing); + let mut payload = Vec::new(); + for v in [1u32, 1, 0, 3] { + payload.extend_from_slice(&v.to_le_bytes()); + } + payload.extend_from_slice(&[1, 0]); + let env = Wai::new(Manifest::new("image", "improve", ModelRequirement::new(bm), Conditioning::new("bytes")), payload.clone()); + + let r = mk("revoked"); + std::fs::write(r.join("set.sha256"), &listing).unwrap(); + let file_reg = || ModelRegistry::new().register(bm, r.join("decoder.onnx")); + assert_eq!(refusal(decode_envelope(&env, &file_reg().with_key_log(Revokes(vec![revoked.clone()])))), "prior_revoked", + "the revoked set beside its listing"); + std::fs::remove_file(r.join("set.sha256")).unwrap(); + assert_eq!(refusal(decode_envelope(&env, &file_reg().with_key_log(Revokes(vec![revoked.clone()])))), "prior_revoked", + "the decoder registered, no listing beside it"); + let dir_reg = ModelRegistry::new().register(bm, r.clone()).with_key_log(Revokes(vec![revoked.clone()])); + assert_eq!(refusal(decode_envelope(&env, &dir_reg)), "prior_revoked", "the directory registered, no listing beside it"); + let member = Revokes(vec![wai::pin::sha256_hex(cdfs_a)]); + assert_eq!(refusal(decode_envelope(&env, &file_reg().with_key_log(member))), "prior_revoked", "a member's own digest revoked"); + + // Read once: the tables swapped after the check are not read. The decode reads A + // (not JSON: "expected value"); B would parse and lack a field. + let t = mk("toctou"); + let reg = ModelRegistry::new().register(bm, t.join("decoder.onnx")).with_key_log(Revokes(vec![])); + let params = reg.resolve_verified(bm, None).unwrap(); + std::fs::write(t.join("cdfs_q3.json"), cdfs_b).unwrap(); + std::fs::write(t.join("cdfs_q4.json"), cdfs_b).unwrap(); + let got = refusal(decode_payload(bm, &payload, ¶ms)); + assert!(got.contains("cdf parse cdfs_q3.json: expected value"), "the decode read the tables checked: {got}"); + match ¶ms { + ParamSource::Checked { set: Verified::Set { files, .. }, model } => { + assert_eq!(files.keys().collect::>(), ["cdfs_q3.json", "decoder.onnx"]); + assert_eq!(model.as_deref(), Some("decoder.onnx")); + } + other => panic!("expected the files read once, got {other:?}"), + } + + // No key log: no check 5, and the decode reads the path, as it always has. + assert!(matches!(ModelRegistry::new().register(bm, t.join("decoder.onnx")).resolve_verified(bm, None), Ok(ParamSource::Unpinned(_)))); + std::fs::remove_dir_all(&root).ok(); +} + /// The reference sink opens no encryption scheme, so an envelope whose manifest /// declares one is inert at it (SPEC §6.4 step 2, `unsupported_encryption`): a /// payload sent in the clear under a claim of encryption is not decoded as plaintext, -- 2.54.0 (Apple Git-157)