From ed10bacbcf9de527da5e6c7f1ce44f638f5a0a08 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 05:16:22 -0400 Subject: [PATCH 01/11] wai: one base decode for sinks, a keyframe-0 base writer, and a JSON stage trace - staged::decode_base: decode a base envelope for a sink through int_payload::decode_int, verifying its pin against the set held in memory; the tier counts the pin only where it resolved. The CLI and the browser binding both call it, so there is one base path. - int_payload::encode_wiv1_zero_motion: the reference keyframe-0 WIV1 encoder (zero motion, one block per frame, closed GOP) moves out of the corpus generator into the library. The corpus regenerates byte for byte through it. - wai stage base: writes a wai.video.int_motion base from raw RGB8 frames, decoded back before it is written. - wai stage apply: a StageSink replay. Envelopes are taken in order; a later base is decoded and releases the layers held for it. --json prints each step's reports in the corpus's form and the units each step changed, with their stage hashes, for a sink elsewhere to check against. Tests: int_payload::tests::encode_wiv1_zero_motion_round_trips, staged::tests::decode_base_reads_the_pin_and_the_corpus_units, cli_stage::{base_refine_apply_json_trace, apply_holds_layers_for_a_later_base}. Co-Authored-By: Claude Opus 5.5 --- wai/staged-conformance/README.md | 5 +- wai/wai-rs/src/bin/wai_cli/stage.rs | 311 +++++++++++++++++------ wai/wai-rs/src/bin/wai_staged_vectors.rs | 60 +---- wai/wai-rs/src/int_payload/mod.rs | 87 +++++++ wai/wai-rs/src/int_payload/tests.rs | 41 +++ wai/wai-rs/src/staged/mod.rs | 86 +++++++ wai/wai-rs/src/staged/tests.rs | 47 ++++ wai/wai-rs/tests/cli_stage.rs | 92 +++++++ 8 files changed, 592 insertions(+), 137 deletions(-) diff --git a/wai/staged-conformance/README.md b/wai/staged-conformance/README.md index 112002b05..29676af69 100644 --- a/wai/staged-conformance/README.md +++ b/wai/staged-conformance/README.md @@ -52,8 +52,9 @@ receipt for it, so that step 5 requires `delivered-verified` of its layers. **The content is synthetic**: pictures, frames and PCM computed by integer functions in the generator. The bases of the integer codecs are the committed vectors under [`../wai-rs/tests/vectors/`](../wai-rs/tests/vectors/), decoded against their -parameter sets with their pins; the `wai.video.int_motion` bases are coded by a -keyframe-0 encoder in the generator (zero motion, one block per frame), and the PNG +parameter sets with their pins; the `wai.video.int_motion` bases are coded by the +library's keyframe-0 reference encoder (`int_payload::encode_wiv1_zero_motion`, which +`wai stage base` also runs: zero motion, one block per frame), and the PNG and FLAC bases are written by small writers in the generator (stored deflate with the five row filters; VERBATIM subframes). diff --git a/wai/wai-rs/src/bin/wai_cli/stage.rs b/wai/wai-rs/src/bin/wai_cli/stage.rs index c8cdd2da2..b4163056c 100644 --- a/wai/wai-rs/src/bin/wai_cli/stage.rs +++ b/wai/wai-rs/src/bin/wai_cli/stage.rs @@ -1,9 +1,10 @@ //! `wai stage`: write, apply, inspect, pack and explode staged-delivery layers -//! (`extensions/staged-delivery.md`; SPEC §7.1). Built with the `staged` feature and -//! the integer codecs' features, which decode the bases. +//! (`extensions/staged-delivery.md`; SPEC §7.1), and write a `wai.video.int_motion` +//! base. Built with the `staged` feature and the integer codecs' features, which decode +//! the bases. //! -//! A base of an integer capability is decoded through `int_payload::decode_int`: with -//! no parameter set for `wai.video.int_motion`, otherwise against the set in the +//! A base of an integer capability is decoded through `staged::decode_base`: with no +//! parameter set for `wai.video.int_motion`, otherwise against the set in the //! directory `--set` names (its files by the names SPEC §5 gives them), with the base's //! pin verified when it carries one. Any other base (PNG, FLAC: this tool has no //! `codecs` decoder for them) is read from `--stage0`, the units' canonical buffers @@ -11,11 +12,10 @@ use std::collections::BTreeMap; +use serde_json::{json, Value}; use wai::container::{Prev, PrevRun, Refines, Span, Wai, WaiMulti}; -use wai::int_payload::{decode_int, resolve}; -use wai::pin::HeldBytes; use wai::staged::wir1::{self, Below, Next, Predictor, RefineParams}; -use wai::staged::{tier, BaseInfo, ChainConfig, HoldBound, ReceiptEvidence, Samples, StageChain, UnitBuf}; +use wai::staged::{tier, BaseInfo, ChainConfig, HoldBound, OfferReport, ReceiptEvidence, Samples, StageChain, StageSink, UnitBuf}; fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() @@ -29,6 +29,9 @@ fn opts<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> { args.iter().enumerate().filter(|(_, a)| *a == flag).filter_map(|(i, _)| args.get(i + 1)).map(String::as_str).collect() } +/// The flags that take no value. +const SWITCHES: &[&str] = &["--json", "--table-per-unit"]; + /// The positional arguments: those that are neither a flag nor a flag's value. fn positional(args: &[String]) -> Vec<&str> { let mut out = Vec::new(); @@ -36,6 +39,7 @@ fn positional(args: &[String]) -> Vec<&str> { for a in args { if skip { skip = false; + } else if SWITCHES.contains(&a.as_str()) { } else if a.starts_with("--") || a == "-o" { skip = true; } else { @@ -57,56 +61,52 @@ fn dims(s: &str) -> Result<(u32, u32, u32), String> { } } +/// The parameter set `--set` names, by file name; empty without `--set`. +fn param_set(args: &[String]) -> Result>, String> { + match opt(args, "--set") { + Some(dir) => wai::pin::read_dir_set(std::path::Path::new(dir)).map_err(|e| format!("--set {dir}: {e}")), + None => Ok(BTreeMap::new()), + } +} + +/// An integer base, decoded through `staged::decode_base` against `--set`. +fn int_base(path: &str, env: &[u8], args: &[String]) -> Result<(BaseInfo, Vec), String> { + let set = param_set(args)?; + let files: BTreeMap = set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + wai::staged::decode_base(env, &files).map_err(|e| format!("{path}: {e}")) +} + /// The base's info and units, decoded as the module doc states. fn base(path: &str, args: &[String]) -> Result<(Vec, BaseInfo, Vec), String> { let env = read(path)?; + let Some(s0) = opt(args, "--stage0") else { + let (info, units) = int_base(path, &env, args)?; + return Ok((env, info, units)); + }; let w = Wai::from_bytes(&env).map_err(|e| format!("{path}: {e}"))?; let cap = w.capability().to_string(); - let hash = wai::jwp_hash::content_hash(&env); - let (units, pin_resolved) = if let Some(s0) = opt(args, "--stage0") { - let bytes = read(s0)?; - let n: usize = opt(args, "--units").unwrap_or("1").parse().map_err(|e| format!("--units: {e}"))?; - let d = dims(opt(args, "--dims").ok_or("--stage0 needs --dims")?)?; - let per = d.0 as usize * d.1 as usize * d.2 as usize; - let i16 = opt(args, "--elem") == Some("i16"); - let need = n * per * if i16 { 2 } else { 1 }; - if bytes.len() != need { - return Err(format!("{s0}: {} bytes, the units need {need}", bytes.len())); - } - let units = (0..n) - .map(|u| { - let samples = if i16 { - Samples::I16(bytes[2 * u * per..2 * (u + 1) * per].chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()) - } else { - Samples::U8(bytes[u * per..(u + 1) * per].to_vec()) - }; - UnitBuf { shape: d, samples } - }) - .collect(); - (units, false) - } else { - let set: BTreeMap> = match opt(args, "--set") { - Some(dir) => wai::pin::read_dir_set(std::path::Path::new(dir)).map_err(|e| format!("--set {dir}: {e}"))?, - None => BTreeMap::new(), - }; - let files: BTreeMap = set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); - let listing = if files.is_empty() { vec![] } else { wai::pin::listing_from(&files).map_err(|e| e.to_string())? }; - let one_file = wai::container::pin_shape(&cap) == wai::codecs::ParameterSetShape::OneFile; - let held: Vec = if files.is_empty() { - vec![] - } else if one_file && w.prior().is_some_and(|p| p.form().is_none()) { - vec![HeldBytes::File(files.values().next().copied().unwrap_or_default())] - } else { - vec![HeldBytes::Set { listing: &listing, files: &files }] - }; - let params = resolve(&cap, w.prior(), &held, None).map_err(|e| format!("{path}: {e}"))?; - let pinned = params.is_pinned(); - let d = decode_int(&cap, &w.payload, ¶ms).map_err(|e| format!("{path}: {e}"))?; - (wai::staged::units_from_decoded(d).ok_or("an output kind with no units")?, pinned) - }; + let bytes = read(s0)?; + let n: usize = opt(args, "--units").unwrap_or("1").parse().map_err(|e| format!("--units: {e}"))?; + let d = dims(opt(args, "--dims").ok_or("--stage0 needs --dims")?)?; + let per = d.0 as usize * d.1 as usize * d.2 as usize; + let i16 = opt(args, "--elem") == Some("i16"); + let need = n * per * if i16 { 2 } else { 1 }; + if bytes.len() != need { + return Err(format!("{s0}: {} bytes, the units need {need}", bytes.len())); + } + let units = (0..n) + .map(|u| { + let samples = if i16 { + Samples::I16(bytes[2 * u * per..2 * (u + 1) * per].chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()) + } else { + Samples::U8(bytes[u * per..(u + 1) * per].to_vec()) + }; + UnitBuf { shape: d, samples } + }) + .collect(); let info = BaseInfo { - content_hash: hash, - tier: tier::base_tier(&cap, if pin_resolved { w.prior() } else { None }), + content_hash: wai::jwp_hash::content_hash(&env), + tier: tier::base_tier(&cap, None), capability: cap, via_fallback: false, attested: false, @@ -139,35 +139,184 @@ fn print_units(c: &StageChain) { } } -/// `wai stage apply …`: offer each layer in order; print every -/// report and the units at the end. -fn apply(args: &[String]) -> Result<(), String> { - let pos = positional(args); - let (_, info, units) = base(pos.first().ok_or("wai stage apply …")?, args)?; - let mut c = StageChain::new(info, units, cfg()); - for (i, l) in pos[1..].iter().enumerate() { - let bytes = read(l)?; - for r in c.offer(&bytes, i as u64, ReceiptEvidence::None).map_err(|e| format!("{l}: {e}"))? { - print!("{l}: {}", &hex(&r.content_hash)[..16]); - if let Some(e) = r.envelope { - print!(" refused {}", e.code()); - if let Some(d) = r.detail { - print!(" ({d})"); - } - } - if !r.applied.is_empty() { - print!(" applied {:?}", r.applied); - } - if !r.held.is_empty() { - print!(" held {:?}", r.held); - } - for (u, w) in &r.refused { - print!(" unit {u}: {}", w.code()); - } - println!(); +/// A report as the corpus states one (`staged-conformance/README.md`). +fn report_json(r: &OfferReport) -> Value { + json!({ + "object": hex(&r.content_hash), + "envelope": r.envelope.map(|x| x.code()), + "detail": r.detail, + "held_for_base": r.held_for_base, + "applied": r.applied, + "held": r.held, + "refused": r.refused.iter().map(|(u, w)| json!([u, w.code()])).collect::>(), + }) +} + +/// Unit `u` of the chain as the corpus states a final unit, with its base. +fn unit_json(c: &StageChain, u: u32) -> Value { + let s = c.unit(u).expect("in range"); + json!({ + "base": hex(&c.base().content_hash), + "unit": u, + "stage": s.stage, + "producer": hex(&s.producer), + "frozen": s.frozen, + "tier": tier::token(s.tier), + "shape": [s.buf.shape.0, s.buf.shape.1, s.buf.shape.2], + "stage_sha256": c.unit_sha256(u).map(|h| hex(&h)), + }) +} + +fn print_report(file: &str, r: &OfferReport) { + print!("{file}: {}", &hex(&r.content_hash)[..16]); + if let Some(e) = r.envelope { + print!(" refused {}", e.code()); + if let Some(d) = r.detail { + print!(" ({d})"); } } - print_units(&c); + if r.held_for_base { + print!(" held for its base"); + } + if !r.applied.is_empty() { + print!(" applied {:?}", r.applied); + } + if !r.held.is_empty() { + print!(" held {:?}", r.held); + } + for (u, w) in &r.refused { + print!(" unit {u}: {}", w.code()); + } + println!(); +} + +/// `wai stage apply … [--json]`: a sink that decodes the base, +/// then takes each envelope in order: one with `refines` is a layer, offered at the +/// next millisecond; any other is another base, decoded against the same `--set` and +/// added, which releases the layers held for it (§4.4). Prints every report and the +/// units at the end; with `--json`, one JSON document instead: every step's reports +/// in the corpus's form and the units whose stage, producer or frozen flag it changed +/// (a base's step lists all its units), then every base's units and clip hash. Every +/// stage hash a delivery in this order reaches is in it, for a sink elsewhere to check +/// its own against. +fn apply(args: &[String]) -> Result<(), String> { + let pos = positional(args); + let as_json = args.iter().any(|a| a == "--json"); + let first = *pos.first().ok_or("wai stage apply … [--json]")?; + let (_, info, units) = base(first, args)?; + let mut sink = StageSink::new(cfg()); + let mut bases: Vec<[u8; 32]> = Vec::new(); + let mut seen: BTreeMap<([u8; 32], u32), (u8, [u8; 32], bool)> = BTreeMap::new(); + let mut steps: Vec = Vec::new(); + let mut pending = Some((info, units)); + for (i, path) in pos.iter().enumerate() { + let bytes = if i == 0 { Vec::new() } else { read(path)? }; + let (op, object, reports) = match pending.take() { + Some((info, units)) => { + let h = info.content_hash; + bases.push(h); + ("base", h, sink.add_base(info, units)) + } + None => { + let w = Wai::from_bytes(&bytes).map_err(|e| format!("{path}: {e}"))?; + if w.manifest.refines.is_some() { + let got = sink.offer(&bytes, i as u64, ReceiptEvidence::None).map_err(|e| format!("{path}: {e}"))?; + ("offer", wai::jwp_hash::content_hash(&bytes), got) + } else { + if opt(args, "--stage0").is_some() { + return Err(format!("{path}: a second base; --stage0 reads the first base only")); + } + let (info, units) = int_base(path, &bytes, args)?; + let h = info.content_hash; + if !bases.contains(&h) { + bases.push(h); + } + ("base", h, sink.add_base(info, units)) + } + } + }; + let mut changed = Vec::new(); + for b in &bases { + let Some(c) = sink.chain(b) else { continue }; + for u in 0..c.n_units() { + let s = c.unit(u).expect("in range"); + let now = (s.stage, s.producer, s.frozen); + if seen.insert((*b, u), now) != Some(now) { + changed.push(unit_json(c, u)); + } + } + } + if as_json { + steps.push(json!({ + "file": path, + "op": op, + "object": hex(&object), + "reports": reports.iter().map(report_json).collect::>(), + "units": changed, + })); + } else { + if op == "base" { + println!("{path}: base {} decoded", &hex(&object)[..16]); + } + for r in &reports { + print_report(path, r); + } + } + } + if as_json { + let finals: Vec = bases + .iter() + .filter_map(|b| sink.chain(b)) + .map(|c| json!({ + "object": hex(&c.base().content_hash), + "capability": c.base().capability, + "units": (0..c.n_units()).map(|u| unit_json(c, u)).collect::>(), + "clip_sha256": c.clip_sha256().map(|h| hex(&h)), + })) + .collect(); + let doc = json!({"format": "wai-stage-apply/1", "steps": steps, "bases": finals}); + println!("{}", serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?); + } else { + for b in &bases { + if let Some(c) = sink.chain(b) { + if bases.len() > 1 { + println!("base {}:", &hex(b)[..16]); + } + print_units(c); + } + } + } + Ok(()) +} + +/// `wai stage base --frames --dims N,H,W --q Q -o base.wai`: a +/// `wai.video.int_motion` base (`WIV1`, keyframe 0) of the `N` frames of `H × W` RGB8 +/// in `--frames` (concatenated, rows top to bottom), coded by the reference encoder +/// `int_payload::encode_wiv1_zero_motion` at step `Q`: zero motion, one block per frame, +/// a closed group of pictures. The envelope is decoded back before it is written, and +/// must give the encoder's reconstruction. +fn base_cmd(args: &[String]) -> Result<(), String> { + let out = opt(args, "-o").ok_or("-o ")?; + let (n, h, w) = dims(opt(args, "--dims").ok_or("--dims N,H,W")?)?; + let q: u32 = opt(args, "--q").ok_or("--q Q")?.parse().map_err(|e| format!("--q: {e}"))?; + let src = read(opt(args, "--frames").ok_or("--frames ")?)?; + let per = h as usize * w as usize * 3; + if n == 0 || per == 0 || src.len() != n as usize * per { + return Err(format!("--frames holds {} bytes; {n} frames of {h}x{w} RGB8 need {}", src.len(), n as usize * per)); + } + let frames: Vec> = src.chunks_exact(per).map(<[u8]>::to_vec).collect(); + let (payload, recon) = wai::int_payload::encode_wiv1_zero_motion(&frames, h, w, q).map_err(|e| e.to_string())?; + let cap = wai::codecs::CAP_VIDEO_INT_MOTION; + let m = wai::Manifest::new("video", "replicate", wai::ModelRequirement::new(cap), wai::Conditioning::new("int_motion")); + let bytes = Wai::new(m, payload).to_emit_bytes().map_err(|e| e.to_string())?; + let (info, units) = wai::staged::decode_base(&bytes, &BTreeMap::new()).map_err(|e| e.to_string())?; + if units.len() != recon.len() || units.iter().zip(&recon).any(|(u, r)| u.samples != Samples::U8(r.clone())) { + return Err("the base does not decode to the encoder's reconstruction".into()); + } + std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?; + let err = frames.iter().flatten().zip(recon.iter().flatten()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + println!("base → {out} ({} B) {cap} {n} frames of {h}x{w} q {q} tier {} largest error {err}", bytes.len(), tier::token(info.tier)); + println!(" content hash {}", hex(&info.content_hash)); Ok(()) } @@ -359,6 +508,7 @@ fn explode(args: &[String]) -> Result<(), String> { pub fn main(args: &[String]) -> Result<(), String> { match args.first().map(String::as_str) { Some("apply") => apply(&args[1..]), + Some("base") => base_cmd(&args[1..]), Some("refine") => refine(&args[1..]), Some("inspect") => inspect(&args[1..]), Some("pack") => pack(&args[1..]), @@ -369,7 +519,10 @@ pub fn main(args: &[String]) -> Result<(), String> { pub const USAGE: &str = "wai stage refine --base [--on ]… --target --q N \ [--predictor identity|up2|rate:NUM/DEN] [--span F:C] [--out-dims D0,D1,D2] [--max-symbols N] [--table-per-unit] -o L.wai -wai stage apply … offer the layers in order; print each report and the units +wai stage apply … [--json] offer the layers in order (another base is decoded and + added); print each report and the units, or a JSON trace +wai stage base --frames --dims N,H,W --q Q -o base.wai a wai.video.int_motion base (WIV1, + keyframe 0, zero motion) of N frames of HxW RGB8 wai stage inspect a layer's refines, WIR1 header and declared cost wai stage pack … -o x.wai pack WAI1 envelopes into a WAI2 (manifests verbatim) wai stage explode [-o out.wai] a WAI2 component as its WAI1 envelope diff --git a/wai/wai-rs/src/bin/wai_staged_vectors.rs b/wai/wai-rs/src/bin/wai_staged_vectors.rs index 1edae28d1..3fc383904 100644 --- a/wai/wai-rs/src/bin/wai_staged_vectors.rs +++ b/wai/wai-rs/src/bin/wai_staged_vectors.rs @@ -24,8 +24,9 @@ //! //! The content is synthetic: pictures, frames and PCM computed by integer functions in //! this file. The base payloads of the integer codecs are the committed vectors; the -//! `wai.video.int_motion` bases are coded by a keyframe-0 encoder confined to this file -//! (zero motion, one block per frame), which the decode checks. +//! `wai.video.int_motion` bases are coded by the library's keyframe-0 reference encoder +//! (`int_payload::encode_wiv1_zero_motion`: zero motion, one block per frame), which +//! the decode checks. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -117,59 +118,6 @@ fn envelope(media: &str, cap: &str, fallback: Option<&str>, kind: &str, prior: O Wai::new(m, payload).to_bytes().expect("serialise") } -// ───────────────────────── a keyframe-0 WIV1 encoder (this generator only) ───────────────────────── - -/// Code `frames` (each `h × w` RGB8) as `wai.video.int_motion` (`WIV1`, keyframe 0) -/// with zero motion and one block per frame: frame 0 predicted from grey, each later -/// frame from the previous decoded frame. Returns the payload and its decode. -fn encode_wiv1(frames: &[Vec], h: u32, w: u32, q: u32) -> (Vec, Vec>) { - let mut recon: Vec> = Vec::new(); - let mut rs: Vec = Vec::new(); - for (f, src) in frames.iter().enumerate() { - let mut cur = Vec::with_capacity(src.len()); - for (i, &t) in src.iter().enumerate() { - let pred = if f == 0 { 128 } else { recon[f - 1][i] as i64 }; - let r = wai::staged::predict::rdiv(t as i64 - pred, q as i64); - rs.push(r); - cur.push((pred + r * q as i64).clamp(0, 255) as u8); - } - recon.push(cur); - } - let lo = *rs.iter().min().unwrap(); - let hi = *rs.iter().max().unwrap(); - let width = ((hi - lo + 1) as u64).min(4095); - let mut hist = vec![0u64; width as usize]; - for &r in &rs { - if r >= lo && r < lo + width as i64 { - hist[(r - lo) as usize] += 1; - } - } - let cdf = wai::int_entropy::hist_to_cdf(&hist, 16).expect("table"); - let mut e = wai::rans::RansEncoder::new(); - for &r in &rs { - assert!(e.encode(r as i32, &cdf, cdf.len(), lo as i32)); - } - let stream = e.flush(); - let n = frames.len() as u32; - let block = h.max(w); - let v = wai::int_wire::Wiv1 { - mv_precision: 1, - keyframe: 0, - n_frames: n, - h, - w, - block: if n == 1 { 1 } else { block }, - q, - offset: lo as i32, - cdf, - mvs: vec![(0, 0); (n as usize - 1) * if n == 1 { 0 } else { 1 }], - rans: &stream, - wih1: None, - }; - let payload = wai::int_wire::write_wiv1(&v); - (payload, recon) -} - // ───────────────────────── PNG and FLAC writers (fixtures) ───────────────────────── fn crc32(data: &[u8]) -> u32 { @@ -526,7 +474,7 @@ fn splat_base(ctx: &Ctx) -> Base { /// `seed`, starting at time `t0`. fn gop_base(ctx: &Ctx, h: u32, w: u32, n: u32, seed: u32, t0: u32, q: u32) -> Base { let frames: Vec> = (0..n).map(|f| picture(h, w, seed, t0 + f)).collect(); - let (payload, recon) = encode_wiv1(&frames, h, w, q); + let (payload, recon) = wai::int_payload::encode_wiv1_zero_motion(&frames, h, w, q).expect("a clip the encoder codes"); let units = decode_with::<&str>(ctx, CAP_VIDEO_INT_MOTION, &payload, &[], None); let mine: Vec = recon.into_iter().map(|f| UnitBuf { shape: (h, w, 3), samples: Samples::U8(f) }).collect(); assert_eq!(units, mine, "the WIV1 decode is the encoder's reconstruction"); diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index d8905d011..c2742c3bf 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -827,3 +827,90 @@ pub fn wis1_from_json(bs: &serde_json::Value) -> Result, IntPayloadError wire::parse_wis1(&out)?; Ok(out) } + +// ───────────────────────── a reference keyframe-0 encoder ───────────────────────── + +/// `n / d` rounded to the nearest integer, halves away from zero, for `d ≥ 1`: the +/// quantizer of [`encode_wiv1_zero_motion`] (the rounding of +/// `staged::predict::rdiv`, which this module cannot reach without `staged`). +#[cfg(feature = "video_int")] +fn round_div(n: i64, d: u64) -> i64 { + let m = n.unsigned_abs(); + let (q, r) = (m / d, m % d); + let mag = (q + (r >= d - r) as u64) as i64; + if n < 0 { -mag } else { mag } +} + +/// Code `frames` (each `h × w` RGB8, rows top to bottom) as a `WIV1` keyframe-0 +/// payload (`wai.video.int_motion`) with zero motion and one block per frame: frame 0 +/// is predicted from grey (128), each later frame from the previous frame as decoded, +/// and every residual is quantized at step `q` rounded half away from zero, so the +/// decode adds `r·q` back and clamps. The residual table is fitted to the clip's +/// residuals (`int_entropy::hist_to_cdf`, at most 4,095 symbols wide; a residual past +/// it is coded through the bypass escape). Returns the payload and its decode, each +/// frame as `decode_int` gives it. +/// +/// A reference encoder for the format, not a rate-distortion encoder: it searches no +/// motion. It writes a closed group of pictures, since frame 0 refers to nothing. +#[cfg(feature = "video_int")] +pub fn encode_wiv1_zero_motion(frames: &[Vec], h: u32, w: u32, q: u32) -> Result<(Vec, Vec>), IntPayloadError> { + let field = |why: &str| IntPayloadError::new(Refusal::FieldRange, why.to_string()); + if !(1..=wire::MAX_Q).contains(&q) { + return Err(field("q is outside 1..=65535")); + } + if frames.is_empty() || h == 0 || w == 0 { + return Err(field("a clip needs a frame of at least one pixel")); + } + let n = u32::try_from(frames.len()).map_err(|_| field("more frames than u32 counts"))?; + let len = (h as u64).checked_mul(w as u64).and_then(|p| p.checked_mul(3)).ok_or_else(|| field("h × w × 3 overflows"))?; + if frames.iter().any(|f| f.len() as u64 != len) { + return Err(field("a frame is not h × w × 3 bytes")); + } + let mut recon: Vec> = Vec::with_capacity(frames.len()); + let mut rs: Vec = Vec::with_capacity(frames.len() * len as usize); + for (f, src) in frames.iter().enumerate() { + let mut cur = Vec::with_capacity(src.len()); + for (i, &t) in src.iter().enumerate() { + let pred = if f == 0 { 128 } else { recon[f - 1][i] as i64 }; + let r = round_div(t as i64 - pred, q as u64); + rs.push(r); + cur.push((pred + r * q as i64).clamp(0, 255) as u8); + } + recon.push(cur); + } + let lo = *rs.iter().min().expect("at least one sample"); + let hi = *rs.iter().max().expect("at least one sample"); + let width = ((hi - lo + 1) as u64).min(4095); + let mut hist = vec![0u64; width as usize]; + for &r in &rs { + if r >= lo && r < lo + width as i64 { + hist[(r - lo) as usize] += 1; + } + } + let cdf = crate::int_entropy::hist_to_cdf(&hist, 16).ok_or_else(|| field("no residual table fits"))?; + let mut e = crate::rans::RansEncoder::new(); + for &r in &rs { + if !e.encode(r as i32, &cdf, cdf.len(), lo as i32) { + return Err(field("a residual the stream cannot code")); + } + } + let stream = e.flush(); + let one = n == 1; + let v = wire::Wiv1 { + mv_precision: 1, + keyframe: 0, + n_frames: n, + h, + w, + block: if one { 1 } else { h.max(w) }, + q, + offset: lo as i32, + cdf, + mvs: vec![(0, 0); if one { 0 } else { n as usize - 1 }], + rans: &stream, + wih1: None, + }; + let out = wire::write_wiv1(&v); + wire::parse_wiv1(&out, Some(0))?; + Ok((out, recon)) +} diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 445557452..301bc1995 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -775,3 +775,44 @@ fn a_frame_entry_that_is_not_a_list_is_refused() { assert_eq!(wiv1_from_json(&js, None).unwrap_err().code(), "field_range", "{bad}"); } } + +/// `encode_wiv1_zero_motion` writes a `WIV1` keyframe-0 payload that `decode_int` +/// decodes to the encoder's reconstruction, frame by frame; at `q = 1` that is the +/// source itself, and at a coarser step every sample is within `q / 2` of it. A +/// one-frame clip carries no motion vector; a step of 0, an empty clip and a frame of +/// the wrong size are refused. +#[cfg(feature = "video_int")] +#[test] +fn encode_wiv1_zero_motion_round_trips() { + use crate::codecs::CAP_VIDEO_INT_MOTION; + let (h, w) = (9u32, 14u32); + let frames: Vec> = (0..4u32) + .map(|t| (0..h * w * 3).map(|i| ((i * 7 + t * 29 + (i % 5) * (i % 3) * 40) % 256) as u8).collect()) + .collect(); + for q in [1u32, 3, 8, 64] { + for n in [1usize, 4] { + let (payload, recon) = encode_wiv1_zero_motion(&frames[..n], h, w, q).unwrap(); + let v = wire::parse_wiv1(&payload, Some(0)).unwrap(); + assert_eq!((v.n_frames as usize, v.h, v.w, v.q), (n, h, w, q)); + assert_eq!(v.mvs.len(), n - 1, "zero motion, one block per frame"); + match decode_int(CAP_VIDEO_INT_MOTION, &payload, &IntParams::none()).unwrap() { + IntDecoded::Video { frames: got, width, height } => { + assert_eq!((width, height), (w, h)); + assert_eq!(got, recon, "q {q}, {n} frames: the decode is the encoder's reconstruction"); + } + other => panic!("{other:?}"), + } + for (src, r) in frames[..n].iter().zip(&recon) { + let worst = src.iter().zip(r).map(|(a, b)| a.abs_diff(*b) as u32).max().unwrap(); + assert!(worst <= q / 2, "q {q}: an error of {worst}"); + } + if q == 1 { + assert_eq!(recon, frames[..n].to_vec(), "q = 1 is lossless"); + } + } + } + let code = |r: Result<(Vec, Vec>), IntPayloadError>| r.unwrap_err().code(); + assert_eq!(code(encode_wiv1_zero_motion(&frames, h, w, 0)), "field_range"); + assert_eq!(code(encode_wiv1_zero_motion(&[], h, w, 4)), "field_range"); + assert_eq!(code(encode_wiv1_zero_motion(&frames, h, w + 1, 4)), "field_range"); +} diff --git a/wai/wai-rs/src/staged/mod.rs b/wai/wai-rs/src/staged/mod.rs index d91fc1857..d6cb1fe83 100644 --- a/wai/wai-rs/src/staged/mod.rs +++ b/wai/wai-rs/src/staged/mod.rs @@ -50,6 +50,92 @@ pub fn units_from_decoded(d: crate::int_payload::IntDecoded) -> Option &'static str { + match self { + BaseError::NotWai1(_) => "not-wai1", + BaseError::Encrypted => "encrypted", + BaseError::Pin(p) => p.code(), + BaseError::Decode(e) => e.code(), + BaseError::NoUnits => "no-units", + } + } +} + +impl std::fmt::Display for BaseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BaseError::NotWai1(e) => write!(f, "not-wai1: {e}"), + BaseError::Encrypted => f.write_str("encrypted: an encrypted envelope this sink cannot open"), + BaseError::Pin(p) => write!(f, "{}: {p}", p.code()), + BaseError::Decode(e) => write!(f, "{e}"), + BaseError::NoUnits => f.write_str("no-units: the decode gave an output with no units"), + } + } +} + +impl std::error::Error for BaseError {} + +/// Decode a base envelope for a sink (§2), through `int_payload::decode_int`: its +/// capability's parameter set is `set` (each file by the name SPEC §5 gives it; empty +/// for a capability that reads none), and the envelope's pin, where it carries one, +/// is verified against those bytes (SPEC §3.1). The base's info states its content +/// hash, the capability it was decoded through, and its tier (§5, +/// [`tier::base_tier`]: the pin counts only where it resolved); it is not attested, +/// not decoded through a fallback, and its unit 0 starts the live clock. The units are +/// [`units_from_decoded`]'s. +/// +/// A sink that decodes a base some other way (a codec this module does not run) +/// builds its [`BaseInfo`] and units itself. +pub fn decode_base(envelope: &[u8], set: &BTreeMap) -> Result<(BaseInfo, Vec), BaseError> { + use crate::pin::HeldBytes; + let w = Wai::from_bytes(envelope).map_err(|e| BaseError::NotWai1(e.to_string()))?; + if w.manifest.security.as_ref().is_some_and(|s| !s.is_plaintext()) { + return Err(BaseError::Encrypted); + } + let cap = w.capability().to_string(); + let listing = if set.is_empty() { vec![] } else { crate::pin::listing_from(set).map_err(BaseError::Pin)? }; + let one_file = crate::container::pin_shape(&cap) == crate::codecs::ParameterSetShape::OneFile; + let held: Vec = if set.is_empty() { + vec![] + } else if one_file && w.prior().is_some_and(|p| p.form().is_none()) { + vec![HeldBytes::File(set.values().next().copied().unwrap_or_default())] + } else { + vec![HeldBytes::Set { listing: &listing, files: set }] + }; + let params = crate::int_payload::resolve(&cap, w.prior(), &held, None).map_err(BaseError::Pin)?; + let pinned = params.is_pinned(); + let decoded = crate::int_payload::decode_int(&cap, &w.payload, ¶ms).map_err(BaseError::Decode)?; + let units = units_from_decoded(decoded).filter(|u| !u.is_empty()).ok_or(BaseError::NoUnits)?; + let info = BaseInfo { + content_hash: crate::jwp_hash::content_hash(envelope), + tier: tier::base_tier(&cap, if pinned { w.prior() } else { None }), + capability: cap, + via_fallback: false, + attested: false, + first_unit: 0, + }; + Ok((info, units)) +} + /// Why a layer, or a layer for one unit, was not applied: the closed list of /// staged-delivery §4.2, in the order of §4.1's steps. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] diff --git a/wai/wai-rs/src/staged/tests.rs b/wai/wai-rs/src/staged/tests.rs index 9e2baf05d..99d2a7984 100644 --- a/wai/wai-rs/src/staged/tests.rs +++ b/wai/wai-rs/src/staged/tests.rs @@ -1534,3 +1534,50 @@ fn refinement_roles() { tampered.renditions[2][50] ^= 1; assert_eq!(tampered.refinements_of(0).iter().map(|(i, ..)| *i).collect::>(), vec![1]); } + +/// `decode_base` decodes a base for a sink through the integer path: a pinned base +/// whose set the sink holds gives its stage-0 units and `decode-equivalence`; the same +/// payload unpinned gives no tier; a pin whose set is absent or altered is refused +/// before anything is decoded; a `wai.video.int_motion` base needs no set; bytes that +/// are not an envelope are `not-wai1`. The corpus states the units and tiers +/// (`staged-conformance/`), and `wai_staged_vectors verify` replays them. +#[cfg(feature = "video_int")] +#[test] +fn decode_base_reads_the_pin_and_the_corpus_units() { + use super::{decode_base, BaseError}; + use std::collections::BTreeMap; + let wai = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let rd = |rel: &str| std::fs::read(wai.join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}")); + let stage0 = |units: &[UnitBuf]| units.iter().flat_map(|u| u.samples.canonical_bytes()).collect::>(); + let set_bytes: Vec<(String, Vec)> = [("entropy.json", "tiny_entropy.json"), ("g_s.whm", "tiny_g_s.bin"), ("h_s.whm", "tiny_h_s.bin")] + .iter() + .map(|(m, f)| (m.to_string(), rd(&format!("wai-rs/tests/vectors/int_hyper/{f}")))) + .collect(); + let set: BTreeMap = set_bytes.iter().map(|(m, b)| (m.clone(), b.as_slice())).collect(); + + let env = rd("staged-conformance/cases/image_int_hyper_2l/base.wai"); + let (info, units) = decode_base(&env, &set).expect("the pinned base decodes"); + assert_eq!(info.tier, Some(DeterminismTier::DecodeEquivalence)); + assert_eq!(info.capability, crate::codecs::CAP_NEURAL_INT_HYPER); + assert_eq!(info.content_hash, crate::jwp_hash::content_hash(&env)); + assert!(!info.via_fallback && !info.attested && info.first_unit == 0); + assert_eq!(stage0(&units), rd("staged-conformance/cases/image_int_hyper_2l/stage0.bin")); + + assert!(matches!(decode_base(&env, &BTreeMap::new()), Err(BaseError::Pin(_))), "no set held"); + let mut altered = set_bytes.clone(); + altered[1].1[0] ^= 1; + let altered: BTreeMap = altered.iter().map(|(m, b)| (m.clone(), b.as_slice())).collect(); + assert!(matches!(decode_base(&env, &altered), Err(BaseError::Pin(_))), "an altered set"); + + let (info, _) = decode_base(&rd("staged-conformance/cases/image_int_hyper_unpinned/base.wai"), &set).expect("unpinned decodes"); + assert_eq!(info.tier, None, "an unpinned base holds no tier"); + + let env = rd("staged-conformance/cases/video_int_motion_gop_perframe/base_0.wai"); + let (info, units) = decode_base(&env, &BTreeMap::new()).expect("int_motion reads no set"); + assert_eq!(info.tier, Some(DeterminismTier::DecodeEquivalence)); + assert_eq!(units.len(), 4); + assert_eq!(stage0(&units), rd("staged-conformance/cases/video_int_motion_gop_perframe/stage0_0.bin")); + + let e = decode_base(b"not an envelope", &BTreeMap::new()).unwrap_err(); + assert_eq!(e.code(), "not-wai1"); +} diff --git a/wai/wai-rs/tests/cli_stage.rs b/wai/wai-rs/tests/cli_stage.rs index a2a2dcc49..a1b8ab21d 100644 --- a/wai/wai-rs/tests/cli_stage.rs +++ b/wai/wai-rs/tests/cli_stage.rs @@ -137,3 +137,95 @@ fn wrap_refuses_a_refinement_capability() { assert!(text(&o).contains("refinement-only"), "{}", text(&o)); assert!(!out.exists()); } + +fn sha256_hex(b: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(b).iter().map(|x| format!("{x:02x}")).collect() +} + +/// `wai stage base` writes a `wai.video.int_motion` base that decodes; `wai stage +/// refine` at q = 1 over it writes a layer per frame; `wai stage apply --json` traces +/// the base's units at stage 0, then each layer's unit at stage 1 with the SHA-256 of +/// the source frame, in the corpus's report form. +#[test] +fn base_refine_apply_json_trace() { + let (n, h, w) = (3usize, 6usize, 10usize); + let per = h * w * 3; + let frames: Vec = (0..n * per).map(|i| ((i * 13 + (i / per) * 50) % 256) as u8).collect(); + let fpath = scratch("frames.rgb"); + std::fs::write(&fpath, &frames).unwrap(); + let bpath = scratch("gop.wai"); + let o = wai(&[os("stage"), os("base"), os("--frames"), fpath.as_os_str(), os("--dims"), os("3,6,10"), os("--q"), os("40"), os("-o"), bpath.as_os_str()]); + let t = text(&o); + assert!(o.status.success(), "{t}"); + assert!(t.contains("wai.video.int_motion") && t.contains("tier decode-equivalence"), "{t}"); + let mut layers = Vec::new(); + for f in 0..n { + let tpath = scratch(&format!("t{f}.rgb")); + std::fs::write(&tpath, &frames[f * per..(f + 1) * per]).unwrap(); + let lpath = scratch(&format!("L1_f{f}.wai")); + let span = format!("{f}:1"); + let o = wai(&[os("stage"), os("refine"), os("--base"), bpath.as_os_str(), os("--span"), os(&span), os("--target"), tpath.as_os_str(), os("--q"), os("1"), os("-o"), lpath.as_os_str()]); + assert!(o.status.success(), "{}", text(&o)); + layers.push(lpath); + } + let mut args = vec![os("stage"), os("apply"), os("--json"), bpath.as_os_str()]; + // Out of order: the last frame's layer first. + args.extend(layers.iter().rev().map(|p| p.as_os_str())); + let o = wai(&args); + assert!(o.status.success(), "{}", text(&o)); + let doc: serde_json::Value = serde_json::from_slice(&o.stdout).expect("one JSON document"); + assert_eq!(doc["format"], "wai-stage-apply/1"); + let steps = doc["steps"].as_array().unwrap(); + assert_eq!(steps.len(), 1 + n); + assert_eq!(steps[0]["op"], "base"); + assert_eq!(steps[0]["units"].as_array().unwrap().len(), n, "a base's step lists every unit"); + for u in steps[0]["units"].as_array().unwrap() { + assert_eq!((u["stage"].as_u64(), u["tier"].as_str()), (Some(0), Some("decode-equivalence"))); + } + for (i, s) in steps[1..].iter().enumerate() { + let f = n - 1 - i; + assert_eq!(s["op"], "offer"); + let r = &s["reports"][0]; + assert_eq!(r["applied"], serde_json::json!([f]), "{s}"); + assert_eq!(r["envelope"], serde_json::Value::Null); + let changed = s["units"].as_array().unwrap(); + assert_eq!(changed.len(), 1); + assert_eq!(changed[0]["unit"].as_u64(), Some(f as u64)); + assert_eq!(changed[0]["stage"].as_u64(), Some(1)); + assert_eq!(changed[0]["producer"], r["object"]); + assert_eq!(changed[0]["stage_sha256"].as_str().unwrap(), sha256_hex(&frames[f * per..(f + 1) * per]), "q = 1 reaches the source"); + } + assert!(doc["bases"][0]["clip_sha256"].is_string()); +} + +/// `wai stage apply` takes several bases: a layer whose base comes later is held for +/// it and released when it arrives, and the corpus's three-GOP clip reaches the clip +/// hash `index.json` states for each GOP. +#[test] +fn apply_holds_layers_for_a_later_base() { + let d = corpus("cases/video_int_motion_gop_perframe"); + let index: serde_json::Value = serde_json::from_slice(&std::fs::read(corpus("index.json")).unwrap()).unwrap(); + let want = index["cases"].as_array().unwrap().iter().find(|c| c["name"] == "video_int_motion_gop_perframe").unwrap()["clip_sha256"].clone(); + let mut files: Vec = vec![d.join("base_0.wai")]; + // Every layer before bases 1 and 2. + for g in 0..3 { + files.extend((0..4).map(|f| d.join(format!("g{g}_L1_f{f}.wai")))); + files.push(d.join(format!("g{g}_L2.wai"))); + } + files.push(d.join("base_1.wai")); + files.push(d.join("base_2.wai")); + let mut args = vec![os("stage"), os("apply"), os("--json")]; + args.extend(files.iter().map(|p| p.as_os_str())); + let o = wai(&args); + assert!(o.status.success(), "{}", text(&o)); + let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let steps = doc["steps"].as_array().unwrap(); + let held: usize = steps.iter().filter(|s| s["reports"].as_array().is_some_and(|r| r.iter().any(|r| r["held_for_base"] == true))).count(); + assert_eq!(held, 10, "GOPs 1 and 2's layers wait for their bases"); + let last = &steps[steps.len() - 1]; + assert_eq!(last["op"], "base"); + assert_eq!(last["reports"].as_array().unwrap().len(), 5, "base 2 releases its five layers"); + let got: Vec = doc["bases"].as_array().unwrap().iter().map(|b| b["clip_sha256"].clone()).collect(); + assert_eq!(serde_json::Value::Array(got), want); +} -- 2.54.0 (Apple Git-157) From d532d9dbe2b9d1c57ce10a36cb176d67715dab18 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 05:24:28 -0400 Subject: [PATCH 02/11] wai-web: the staged-delivery sink in the browser, checked against the corpus - src/stage.rs binds wai::staged::StageSink for JS. StageBase.decode decodes a base through staged::decode_base_via (its pin verified against the set held in memory; the manifest's capability or its fallback); StageBase.fromStage0 takes a base decoded elsewhere. StageSink adds bases, offers layers, expires holds, removes, attests and forgets bases, and reads back every unit's stage, producer, frozen flag, tier, shape, output, stage hash and squared error. stageLayerInfo reads a layer's refines, WIR1 header, declared cost and byte split before it is offered. Reports and units are in the corpus's own form. - staged::decode_base_via: a base decoded through its manifest's fallback is recorded as decoded through a fallback; any other capability is refused (not-offered). - demo/verify_staged_wasm.mjs replays all of staged-conformance/ through the wasm32 build: every integer base decoded in wasm32 to its stage0.bin, and every report, final unit, stage hash and clip hash equal to the native sink's. - wai-web-wasm.yml runs it, and watches staged-conformance/; the native unit test count is 12 (stage::tests adds three, one of which replays the corpus through the binding's pure half). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-web-wasm.yml | 18 +- wai/wai-rs/src/staged/mod.rs | 20 +- wai/wai-rs/src/staged/tests.rs | 9 + wai/wai-web/Cargo.toml | 2 +- wai/wai-web/demo/README.md | 23 + wai/wai-web/demo/verify_staged_wasm.mjs | 134 +++++ wai/wai-web/src/lib.rs | 6 + wai/wai-web/src/stage.rs | 714 ++++++++++++++++++++++++ 8 files changed, 921 insertions(+), 5 deletions(-) create mode 100644 wai/wai-web/demo/verify_staged_wasm.mjs create mode 100644 wai/wai-web/src/stage.rs diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index 6470617c8..ffcc9d007 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -13,6 +13,9 @@ 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, # and a manifest that names a member twice in one object; +# - demo/verify_staged_wasm.mjs against the staged-delivery corpus +# wai/staged-conformance/: every case replayed through the wasm32 StageSink +# must give the reports, final units and stage hashes the native sink wrote; # - a byte comparison of the demo's staged int_synth copies with the corpus; # - the crate's wasm_tests under wasm-bindgen-test-runner, and its native unit # tests on the host. @@ -28,6 +31,7 @@ on: - 'wai/wai-rs/**' - 'wai/wai-quantum/**' - 'wai/neural-conformance/**' + - 'wai/staged-conformance/**' - 'wai/registry/**' - '.github/workflows/wai-web-wasm.yml' pull_request: @@ -36,6 +40,7 @@ on: - 'wai/wai-rs/**' - 'wai/wai-quantum/**' - 'wai/neural-conformance/**' + - 'wai/staged-conformance/**' - 'wai/registry/**' - '.github/workflows/wai-web-wasm.yml' @@ -91,7 +96,9 @@ jobs: # 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, and a manifest that names a member twice (tests). The count check turns a test that + # pins its integer capability to, and a manifest that names a member twice (tests); the + # staged-delivery binding's pure half replays the staged corpus and reads a layer's + # header and cost (stage::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 @@ -99,7 +106,7 @@ jobs: run: | set -euo pipefail cargo test --locked --lib 2>&1 | tee "$RUNNER_TEMP/wai-web-native.log" - grep -q "test result: ok. 9 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" + grep -q "test result: ok. 12 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 @@ -110,6 +117,13 @@ jobs: shell: bash working-directory: wai/wai-web run: node demo/verify_int_payload_wasm.mjs ./pkg-node/wai_web.js ../wai-rs/tests/vectors + # Every staged-delivery case replayed through the wasm32 StageSink: each integer + # base decoded in wasm32 to its stage0.bin with its pin, and every report, final + # unit, stage hash and clip hash equal to what the native sink wrote in case.json. + - name: wasm32 staged-delivery sink equals the native corpus + shell: bash + working-directory: wai/wai-web + run: node demo/verify_staged_wasm.mjs ./pkg-node/wai_web.js ../staged-conformance - name: The browser menu is registered and names no candidate shell: bash working-directory: wai/wai-web diff --git a/wai/wai-rs/src/staged/mod.rs b/wai/wai-rs/src/staged/mod.rs index d6cb1fe83..0862d29c9 100644 --- a/wai/wai-rs/src/staged/mod.rs +++ b/wai/wai-rs/src/staged/mod.rs @@ -64,6 +64,8 @@ pub enum BaseError { Decode(crate::int_payload::IntPayloadError), /// The decode gave an output with no units. NoUnits, + /// The capability asked for is neither the manifest's capability nor its fallback. + NotOffered(String), } impl BaseError { @@ -76,6 +78,7 @@ impl BaseError { BaseError::Pin(p) => p.code(), BaseError::Decode(e) => e.code(), BaseError::NoUnits => "no-units", + BaseError::NotOffered(_) => "not-offered", } } } @@ -88,6 +91,7 @@ impl std::fmt::Display for BaseError { BaseError::Pin(p) => write!(f, "{}: {p}", p.code()), BaseError::Decode(e) => write!(f, "{e}"), BaseError::NoUnits => f.write_str("no-units: the decode gave an output with no units"), + BaseError::NotOffered(c) => write!(f, "not-offered: {c} is neither the manifest's capability nor its fallback"), } } } @@ -106,12 +110,24 @@ impl std::error::Error for BaseError {} /// A sink that decodes a base some other way (a codec this module does not run) /// builds its [`BaseInfo`] and units itself. pub fn decode_base(envelope: &[u8], set: &BTreeMap) -> Result<(BaseInfo, Vec), BaseError> { + decode_base_via(envelope, None, set) +} + +/// [`decode_base`] through the capability the sink chose by SPEC §4: the manifest's +/// capability (`None`, or that string), or its fallback, which the base's info then +/// records as decoded through a fallback (§4.1 step 3 refuses its layers). +pub fn decode_base_via(envelope: &[u8], via: Option<&str>, set: &BTreeMap) -> Result<(BaseInfo, Vec), BaseError> { use crate::pin::HeldBytes; let w = Wai::from_bytes(envelope).map_err(|e| BaseError::NotWai1(e.to_string()))?; if w.manifest.security.as_ref().is_some_and(|s| !s.is_plaintext()) { return Err(BaseError::Encrypted); } - let cap = w.capability().to_string(); + let primary = w.capability().to_string(); + let cap = match via { + None => primary.clone(), + Some(c) if c == primary || w.manifest.model_requirement.fallback.as_deref() == Some(c) => c.to_string(), + Some(c) => return Err(BaseError::NotOffered(c.to_string())), + }; let listing = if set.is_empty() { vec![] } else { crate::pin::listing_from(set).map_err(BaseError::Pin)? }; let one_file = crate::container::pin_shape(&cap) == crate::codecs::ParameterSetShape::OneFile; let held: Vec = if set.is_empty() { @@ -128,8 +144,8 @@ pub fn decode_base(envelope: &[u8], set: &BTreeMap) -> Result<(Ba let info = BaseInfo { content_hash: crate::jwp_hash::content_hash(envelope), tier: tier::base_tier(&cap, if pinned { w.prior() } else { None }), + via_fallback: cap != primary, capability: cap, - via_fallback: false, attested: false, first_unit: 0, }; diff --git a/wai/wai-rs/src/staged/tests.rs b/wai/wai-rs/src/staged/tests.rs index 99d2a7984..56feeed31 100644 --- a/wai/wai-rs/src/staged/tests.rs +++ b/wai/wai-rs/src/staged/tests.rs @@ -1580,4 +1580,13 @@ fn decode_base_reads_the_pin_and_the_corpus_units() { let e = decode_base(b"not an envelope", &BTreeMap::new()).unwrap_err(); assert_eq!(e.code(), "not-wai1"); + + // A base offered with a fallback, decoded through it, is recorded so; a capability + // the manifest does not offer is refused. + let env = rd("staged-conformance/cases/base_via_fallback/base.wai"); + let (info, _) = super::decode_base_via(&env, Some(crate::codecs::CAP_VIDEO_INT_MOTION), &BTreeMap::new()).expect("the fallback decodes"); + assert!(info.via_fallback); + assert_eq!(info.capability, crate::codecs::CAP_VIDEO_INT_MOTION); + let e = super::decode_base_via(&env, Some(crate::codecs::CAP_VIDEO_INT_HYPER), &BTreeMap::new()).unwrap_err(); + assert_eq!(e.code(), "not-offered"); } diff --git a/wai/wai-web/Cargo.toml b/wai/wai-web/Cargo.toml index e85a6b578..bf63cadff 100644 --- a/wai/wai-web/Cargo.toml +++ b/wai/wai-web/Cargo.toml @@ -19,7 +19,7 @@ js-sys = "0.3" # with the codec chain off. No second implementation: replay-equivalence # in the browser is the identical Rust that generated the conformance # corpus hashes. -wai = { path = "../wai-rs", default-features = false, features = ["world", "audio_scene", "haptic", "splat4d", "provenance", "score", "film", "neural_int", "generate", "video_int", "audio_int", "world_model", "world_int", "splat_int", "quantum", "quantum_ops", "quantum_cal", "quantum_mitigate", "quantum_qec", "quantum_control", "quantum_noise", "quantum_compile", "quantum_atom", "quantum_qbom", "quantum_receipt", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_pauli", "quantum_vml", "quantum_phasor", "quantum_qfhrr", "quantum_kernel", "quantum_phasor_meter", "quantum_qdata", "video_workflow"] } +wai = { path = "../wai-rs", default-features = false, features = ["world", "audio_scene", "haptic", "splat4d", "provenance", "score", "film", "neural_int", "generate", "video_int", "audio_int", "world_model", "world_int", "splat_int", "quantum", "quantum_ops", "quantum_cal", "quantum_mitigate", "quantum_qec", "quantum_control", "quantum_noise", "quantum_compile", "quantum_atom", "quantum_qbom", "quantum_receipt", "quantum_toolchain", "quantum_mps", "quantum_stabilizer", "quantum_pauli", "quantum_vml", "quantum_phasor", "quantum_qfhrr", "quantum_kernel", "quantum_phasor_meter", "quantum_qdata", "video_workflow", "staged"] } ed25519-dalek = "2" # actor keys for browser-side op authoring (same version as wai-rs) blake3 = "1" # in-page receipt check: hash a decoded image against a pinned digest diff --git a/wai/wai-web/demo/README.md b/wai/wai-web/demo/README.md index 649261951..f50d9f992 100644 --- a/wai/wai-web/demo/README.md +++ b/wai/wai-web/demo/README.md @@ -50,3 +50,26 @@ nodejs-target pkg and checks all four vectors' hashes): ``` ./demo/verify_int_synth.sh ``` + +## Staged delivery — `StageSink` in the browser + +`src/stage.rs` binds `wai::staged::StageSink`, the staged-delivery sink a native +player runs (`extensions/staged-delivery.md`), for JS: `StageBase.decode` decodes a +base envelope through the integer path with its pin, `StageSink` adds bases, offers +refinement layers, expires holds and removes bases in any order, and every unit's +stage, producer, tier, output and stage hash reads back. `stageLayerInfo` reads a +layer's `refines`, `WIR1` header and declared cost before it is offered. Reports and +units come back in the corpus's own form (`staged-conformance/README.md`). + +`verify_staged_wasm.mjs` replays every case of `staged-conformance/` through the +wasm32 build and requires every report, final unit, stage hash and clip hash the +native sink wrote; each integer base is decoded in wasm32 and must give its +`stage0.bin`: + +``` +# from wai-web/ +cargo build --release --target wasm32-unknown-unknown +wasm-bindgen --target nodejs --out-dir demo/pkg-node --out-name wai_web \ + target/wasm32-unknown-unknown/release/wai_web.wasm +node demo/verify_staged_wasm.mjs ./pkg-node/wai_web.js ../staged-conformance +``` diff --git a/wai/wai-web/demo/verify_staged_wasm.mjs b/wai/wai-web/demo/verify_staged_wasm.mjs new file mode 100644 index 000000000..b3f2045f9 --- /dev/null +++ b/wai/wai-web/demo/verify_staged_wasm.mjs @@ -0,0 +1,134 @@ +// The wasm32 staged-delivery sink equals the native one: every case of the corpus +// wai/staged-conformance/ is replayed through the browser crate's StageSink, built +// for wasm32, and every report of every step and every base's final units (stage, +// producer, frozen flag, tier, shape and stage hash) and clip hash must equal what +// case.json states, which wai_staged_vectors wrote from the native StageSink. +// +// A base whose decode is the integer path (case.json `decode.kind` "int") is decoded +// in wasm32 too, through StageBase.decode with its parameter set and its pin, and must +// give its stage0.bin byte for byte, the case's tier and the case's fallback flag. A +// PNG or FLAC base (no decoder in this build) is read from stage0.bin, whose SHA-256 +// is checked. Each base then takes the case's facts (attested, first unit). +// +// node verify_staged_wasm.mjs [path/to/wai_web.js] [path/to/staged-conformance] +// +// Exit 0 iff every check passes; the number of cases replayed is printed, and a +// corpus with no cases fails. + +import { readFileSync, readdirSync } 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 corpus = resolve(process.argv[3] ?? resolve(here, '../../staged-conformance')); +const waiDir = resolve(corpus, '..'); +const wai = await import(pathToFileURL(pkg).href); + +const sha = (b) => createHash('sha256').update(b).digest('hex'); +const bytes = (p) => new Uint8Array(readFileSync(p)); +// Key order matters: the reports and units are compared as the corpus writes them. +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +let fails = 0; +const fail = (m) => { fails++; console.log(`FAIL ${m}`); }; + +const names = readdirSync(`${corpus}/cases`, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(); +let replayed = 0; +let stageHashes = 0; +for (const name of names) { + const dir = `${corpus}/cases/${name}`; + const c = JSON.parse(readFileSync(`${dir}/case.json`, 'utf8')); + const bases = []; + let ok = true; + for (const b of c.bases) { + const env = bytes(`${dir}/${b.file}`); + const stage0 = bytes(`${dir}/${b.stage0}`); + if (sha(stage0) !== b.stage0_sha256) { fail(`${name}: ${b.stage0} does not have its stated SHA-256`); ok = false; } + if (wai.stageContentHash(env) !== b.object) { fail(`${name}: ${b.file} does not have its stated content hash`); ok = false; } + const elems = new Set(b.units.map((u) => u.elem)); + if (elems.size !== 1) { fail(`${name}: mixed element types`); ok = false; continue; } + const shapes = new Uint32Array(b.units.flatMap((u) => u.shape)); + let base; + if (b.decode.kind === 'int') { + const set = new Map(Object.entries(b.decode.set).map(([m, p]) => [m, bytes(`${waiDir}/${p}`)])); + try { + base = wai.StageBase.decode(env, set.size ? set : undefined, b.decoded_via); + } catch (e) { + fail(`${name}: ${b.file} does not decode: ${e.message ?? e}`); ok = false; continue; + } + if (!Buffer.from(base.stage0()).equals(Buffer.from(stage0))) { fail(`${name}: the wasm decode of ${b.file} is not ${b.stage0}`); ok = false; } + if (base.tier !== b.tier) { fail(`${name}: the base tier is ${base.tier}, the case states ${b.tier}`); ok = false; } + if (base.viaFallback !== b.via_fallback) { fail(`${name}: via fallback ${base.viaFallback}, the case states ${b.via_fallback}`); ok = false; } + if (base.capability !== b.decoded_via) { fail(`${name}: decoded via ${base.capability}`); ok = false; } + } else { + base = wai.StageBase.fromStage0(env, b.decoded_via, b.tier, shapes, [...elems][0], stage0); + base.viaFallback = b.via_fallback; + } + base.attested = b.attested; + base.setFirstUnit(b.first_unit); + bases.push(base); + } + if (!ok) continue; + const files = new Map(c.files.map((f) => [f.file, bytes(`${dir}/${f.file}`)])); + const sink = new wai.StageSink(c.config); + const reports = []; + for (const s of c.steps) { + switch (s.op) { + case 'offer': + try { reports.push(sink.offer(files.get(s.file), s.at_ms, s.evidence)); } + catch (e) { reports.push({ error: String(e.message ?? e).split(':')[0] }); } + break; + case 'base': reports.push(sink.addBase(bases[s.index])); break; + case 'expire': reports.push(sink.expire(s.at_ms)); break; + case 'remove': reports.push(sink.removeBase(bases[s.index].contentHash) ?? []); break; + case 'attest': sink.markAttested(bases[s.index].contentHash); reports.push([]); break; + default: fail(`${name}: unknown op ${s.op}`); + } + } + c.expect.reports.forEach((want, i) => { + if (!same(reports[i], want)) fail(`${name}: step ${i} (${c.steps[i].op}) reports ${JSON.stringify(reports[i])}, the case states ${JSON.stringify(want)}`); + }); + if (reports.length !== c.expect.reports.length) fail(`${name}: ${reports.length} steps replayed, the case states ${c.expect.reports.length}`); + c.expect.bases.forEach((want, i) => { + const got = sink.units(bases[i].contentHash); + if (!same(got, want)) { fail(`${name}: base ${i}'s final units differ: ${JSON.stringify(got)}`); return; } + if (got === null) return; + // The per-unit accessors agree with the summary, unit by unit. + got.units.forEach((u, k) => { + const h = bases[i].contentHash; + const viaAccessor = sink.unitSha256(h, k) ?? null; + if (viaAccessor !== u.stage_sha256) fail(`${name}: base ${i} unit ${k}: unitSha256 ${viaAccessor}`); + if (sink.unitStage(h, k) !== u.stage || sink.unitProducer(h, k) !== u.producer || sink.unitTier(h, k) !== u.tier || sink.unitFrozen(h, k) !== u.frozen) { + fail(`${name}: base ${i} unit ${k}: an accessor disagrees with units()`); + } + if (u.stage_sha256 !== null) { + stageHashes++; + const out = u.shape[2] && sink.unitShape(h, k); + const buf = (() => { try { return sink.unitBytes(h, k); } catch { const p = sink.unitPcm(h, k); return new Uint8Array(p.buffer, p.byteOffset, p.byteLength); } })(); + if (sha(buf) !== u.stage_sha256) fail(`${name}: base ${i} unit ${k}: the output read back does not hash to its stage hash`); + if (!out || out.length !== 3) fail(`${name}: base ${i} unit ${k}: unitShape`); + } + }); + if ((sink.clipSha256(bases[i].contentHash) ?? null) !== want.clip_sha256) fail(`${name}: base ${i}: clipSha256`); + }); + sink.free(); + bases.forEach((b) => b.free()); + replayed++; +} + +// A layer's header and declared cost read before it is offered. +{ + const d = `${corpus}/cases/video_int_motion_gop_perframe`; + const env = bytes(`${d}/g0_L2.wai`); + const info = wai.stageLayerInfo(env); + if (info.framing_bytes + info.manifest_bytes + info.payload_bytes !== env.length) fail('stageLayerInfo: the bytes do not sum to the envelope'); + if (info.refines?.layer !== 2 || !Array.isArray(info.refines?.prev) || info.wir1?.n_units !== 4) fail('stageLayerInfo: refines or WIR1 header'); + if (!(info.declared_cost?.output_bytes > 0)) fail('stageLayerInfo: declared cost'); +} + +console.log(`${replayed} of ${names.length} cases replayed through the wasm32 StageSink; ${stageHashes} unit stage hashes equal the native ones`); +if (names.length === 0 || replayed !== names.length) fail('not every case replayed'); +console.log(fails === 0 ? 'staged-conformance (wasm32): OK' : `staged-conformance (wasm32): ${fails} failure(s)`); +process.exit(fails === 0 ? 0 : 1); diff --git a/wai/wai-web/src/lib.rs b/wai/wai-web/src/lib.rs index 11819025a..2ed3e5e9c 100644 --- a/wai/wai-web/src/lib.rs +++ b/wai/wai-web/src/lib.rs @@ -152,6 +152,12 @@ pub mod quantum_phasor; // a total-energy roll-up, and show a swapped stage break the bill. pub mod quantum_qbom; +// Staged delivery (extensions/staged-delivery.md): wai::staged::StageSink, the sink a +// native player runs, bound for JS — bases added, refinement layers offered, holds +// expired and bases removed in any order, every unit's stage, producer, tier, output +// and stage hash read back, and a layer's declared cost read before it is offered. +pub mod stage; + // ---- Capability menu ---- // The capabilities this browser sink advertises: a subset of the registry diff --git a/wai/wai-web/src/stage.rs b/wai/wai-web/src/stage.rs new file mode 100644 index 000000000..2d256e33f --- /dev/null +++ b/wai/wai-web/src/stage.rs @@ -0,0 +1,714 @@ +//! Staged delivery in the browser (`extensions/staged-delivery.md`): a base first, +//! refinement layers after, each its own `WAI1` object bound to what it refines by +//! content hash. +//! +//! [`StageSink`] is `wai::staged::StageSink`, the sink a native player runs, bound for +//! JS: bases are added, layers offered, holds expired and bases removed in any order, +//! and every outcome is the reference implementation's report (§4.1, §4.2). A page +//! reads each unit's stage, producer, tier, output and stage hash back from it, and a +//! layer's declared cost before offering it ([`stage_layer_info`]). Nothing here is a +//! second implementation: the same Rust decides on every machine, so the stage hashes +//! a page computes are the native ones (`demo/verify_staged_wasm.mjs` replays the +//! corpus `staged-conformance/` and requires every report and hash). +//! +//! Media cross the network as `WAI1` bytes only. Reports, units and layer headers come +//! back as plain objects in the corpus's own form (`staged-conformance/README.md`): +//! snake_case members, content hashes as lowercase hex, reasons as §4.2's tokens. +//! +//! The pure-Rust half (`config_from`, `report_value`, `units_value`, `layer_info`) +//! is what the native tests run; the `#[wasm_bindgen]` wrappers only convert. + +use std::collections::BTreeMap; + +use serde_json::{json, Value}; +use wai::container::{Prev, Refines, Wai}; +use wai::staged::schedule::UnitClock; +use wai::staged::wir1::{self, Wir1Cost}; +use wai::staged::{self, tier, BaseInfo, ChainConfig, HoldBound, Live, OfferReport, ReceiptEvidence, Samples, StageChain, UnitBuf}; +use wasm_bindgen::prelude::*; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn unhex32(s: &str) -> Option<[u8; 32]> { + let b = s.as_bytes(); + if b.len() != 64 { + return None; + } + let nib = |c: u8| match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + _ => None, + }; + let mut out = [0u8; 32]; + for (i, o) in out.iter_mut().enumerate() { + *o = (nib(b[2 * i])? << 4) | nib(b[2 * i + 1])?; + } + Some(out) +} + +// ───────────────────────── the pure-Rust half ───────────────────────── + +/// A count, a byte figure or a time in milliseconds, from a JSON number: a +/// non-negative integer. A figure at or past `2^64` (JavaScript reads +/// 18446744073709551615 as `2^64`) is the largest `u64`. +pub(crate) fn count(v: &Value, what: &str) -> Result { + if let Some(n) = v.as_u64() { + return Ok(n); + } + match v.as_f64() { + Some(f) if f >= 0.0 && f.fract() == 0.0 => Ok(if f >= 18_446_744_073_709_551_616.0 { u64::MAX } else { f as u64 }), + _ => Err(format!("{what}: a non-negative integer")), + } +} + +/// A sink's configuration, in the form a corpus case states it (`case.json`'s +/// `config`): `{bound: {max_envelopes, max_age_ms}, cost_limit: null | {output_bytes, +/// symbols, working_bytes}, budget, record_budget, live: null | {t0_ms, unit_ms, +/// target_latency_ms, lead_ms: [..]}}`. `cost_limit` and `live` may be absent. +pub(crate) fn config_from(v: &Value) -> Result { + let obj = v.as_object().ok_or("the configuration is an object")?; + for k in obj.keys() { + if !["bound", "cost_limit", "budget", "record_budget", "live"].contains(&k.as_str()) { + return Err(format!("the configuration has no member {k}")); + } + } + let n = |v: &Value, what: &str| count(v, what); + let bound = &v["bound"]; + let max_envelopes = n(&bound["max_envelopes"], "bound.max_envelopes")?; + let cost_limit = match &v["cost_limit"] { + Value::Null => None, + c => Some(Wir1Cost { + output_bytes: n(&c["output_bytes"], "cost_limit.output_bytes")?, + symbols: n(&c["symbols"], "cost_limit.symbols")?, + working_bytes: n(&c["working_bytes"], "cost_limit.working_bytes")?, + }), + }; + let live = match &v["live"] { + Value::Null => None, + l => Some(Live { + clock: UnitClock { + t0_ms: n(&l["t0_ms"], "live.t0_ms")?, + unit_ms: n(&l["unit_ms"], "live.unit_ms")?, + target_latency_ms: n(&l["target_latency_ms"], "live.target_latency_ms")?, + }, + lead_ms: l["lead_ms"].as_array().ok_or("live.lead_ms: an array")?.iter().map(|x| n(x, "live.lead_ms")).collect::>()?, + }), + }; + Ok(ChainConfig { + bound: HoldBound { max_envelopes: usize::try_from(max_envelopes).unwrap_or(usize::MAX), max_age_ms: n(&bound["max_age_ms"], "bound.max_age_ms")? }, + cost_limit, + budget: n(&v["budget"], "budget")?, + record_budget: n(&v["record_budget"], "record_budget")?, + live, + }) +} + +/// A report as the corpus states one. +pub(crate) fn report_value(r: &OfferReport) -> Value { + json!({ + "object": hex(&r.content_hash), + "envelope": r.envelope.map(|x| x.code()), + "detail": r.detail, + "held_for_base": r.held_for_base, + "applied": r.applied, + "held": r.held, + "refused": r.refused.iter().map(|(u, w)| json!([u, w.code()])).collect::>(), + }) +} + +pub(crate) fn reports_value(rs: &[OfferReport]) -> Value { + Value::Array(rs.iter().map(report_value).collect()) +} + +/// A chain's units and clip hash as the corpus states a base at the end of a case. +pub(crate) fn units_value(c: &StageChain) -> Value { + json!({ + "units": c.units().iter().enumerate().map(|(u, s)| json!({ + "stage": s.stage, + "producer": hex(&s.producer), + "frozen": s.frozen, + "tier": tier::token(s.tier), + "shape": [s.buf.shape.0, s.buf.shape.1, s.buf.shape.2], + "stage_sha256": c.unit_sha256(u as u32).map(|h| hex(&h)), + })).collect::>(), + "clip_sha256": c.clip_sha256().map(|h| hex(&h)), + "n_units": c.n_units(), + }) +} + +/// Units from their canonical buffers concatenated (a corpus case's `stage0.bin`): +/// `shapes` holds three dimensions per unit, `elem` is `u8` or `i16`. +pub(crate) fn units_from_stage0(shapes: &[u32], elem: &str, stage0: &[u8]) -> Result, String> { + if shapes.is_empty() || shapes.len() % 3 != 0 { + return Err("shapes: three dimensions per unit".into()); + } + let width = match elem { + "u8" => 1usize, + "i16" => 2, + e => return Err(format!("elem {e}: u8 or i16")), + }; + let mut at = 0usize; + let mut out = Vec::with_capacity(shapes.len() / 3); + for s in shapes.chunks_exact(3) { + let n = (s[0] as usize).checked_mul(s[1] as usize).and_then(|x| x.checked_mul(s[2] as usize)).and_then(|x| x.checked_mul(width)).ok_or("a unit's size overflows")?; + let end = at.checked_add(n).filter(|&e| e <= stage0.len()).ok_or("stage0 is shorter than its units")?; + let b = &stage0[at..end]; + let samples = if width == 1 { Samples::U8(b.to_vec()) } else { Samples::I16(b.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()) }; + out.push(UnitBuf { shape: (s[0], s[1], s[2]), samples }); + at = end; + } + if at != stage0.len() { + return Err("stage0 is longer than its units".into()); + } + Ok(out) +} + +fn prev_value(p: &Prev) -> Value { + match p { + Prev::One(h) => json!(h), + Prev::Runs(runs) => Value::Array(runs.iter().map(|r| json!({"producer": r.producer, "count": r.count})).collect()), + } +} + +/// What an envelope is, read before anything is decoded: its content hash, its bytes +/// (the `WAI1` framing, the manifest and the payload, which sum to the whole), its +/// capability; for a layer its `refines`, its `WIR1` header and its declared cost +/// (Appendix A.5: the output bytes, symbols and working bytes a sink applies its cost +/// limit to). A member that does not parse is `null`, with the reason under `error`. +pub(crate) fn layer_info(env: &[u8]) -> Result { + let w = Wai::from_bytes(env).map_err(|e| format!("not-wai1: {e}"))?; + let manifest_bytes = u32::from_le_bytes(env[4..8].try_into().map_err(|_| "not-wai1: no manifest length")?) as usize; + let payload_bytes = w.payload.len(); + let cap = w.capability().to_string(); + let mut out = json!({ + "object": hex(&wai::jwp_hash::content_hash(env)), + "bytes": env.len(), + "framing_bytes": env.len() - manifest_bytes - payload_bytes, + "manifest_bytes": manifest_bytes, + "payload_bytes": payload_bytes, + "capability": cap, + "refinement_only": wai::codecs::is_refinement_only(&cap), + "refines": null, + "wir1": null, + "declared_cost": null, + "error": null, + }); + match w.refines() { + None => {} + Some(Err(e)) => out["error"] = json!(format!("refines-malformed: {e}")), + Some(Ok(r)) => { + let Refines { base, prev, layer, span } = &r; + out["refines"] = json!({ + "base": base, + "prev": prev_value(prev), + "layer": layer, + "span": span.map(|s| json!({"from": s.from, "count": s.count})), + }); + } + } + if let Some(kind) = staged::unit_kind(&cap) { + match wir1::parse(&w.payload, kind) { + Ok(p) => { + let c = p.declared_cost(); + out["wir1"] = json!({ + "n_units": p.n_units, + "shape": [p.d0, p.d1, p.d2], + "predictor": p.predictor.token(), + "q": p.q, + "tables": p.n_tables(), + }); + out["declared_cost"] = json!({"output_bytes": c.output_bytes, "symbols": c.symbols, "working_bytes": c.working_bytes}); + } + Err(e) => out["error"] = json!(format!("payload-malformed: {}", e.code())), + } + } + Ok(out) +} + +/// The sum of squared differences of two equal-length byte buffers, exact. +pub(crate) fn sse_u8(a: &[u8], b: &[u8]) -> Result { + if a.len() != b.len() { + return Err(format!("the buffers differ in length: {} and {}", a.len(), b.len())); + } + Ok(a.iter().zip(b).map(|(&x, &y)| { + let d = x as i64 - y as i64; + (d * d) as u64 + }).sum()) +} + +/// The same for 16-bit samples. +pub(crate) fn sse_i16(a: &[i16], b: &[i16]) -> Result { + if a.len() != b.len() { + return Err(format!("the buffers differ in length: {} and {}", a.len(), b.len())); + } + Ok(a.iter().zip(b).map(|(&x, &y)| { + let d = x as i64 - y as i64; + (d * d) as u64 + }).sum()) +} + +// ───────────────────────── the JS surface ───────────────────────── + +fn js_err(e: impl std::fmt::Display) -> JsError { + JsError::new(&e.to_string()) +} + +fn to_js(v: &Value) -> Result { + use serde::Serialize; + v.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(js_err) +} + +/// A time or figure from JS: a non-negative integer, or `Infinity` for the largest. +fn ms(v: f64, what: &str) -> Result { + if v == f64::INFINITY { + return Ok(u64::MAX); + } + if !(v >= 0.0) || v.fract() != 0.0 { + return Err(JsError::new(&format!("{what}: a non-negative integer"))); + } + Ok(if v >= 18_446_744_073_709_551_616.0 { u64::MAX } else { v as u64 }) +} + +/// A base, decoded and ready for a sink: its info (content hash, the capability it +/// was decoded through, whether through a fallback, its tier, whether the sink verified +/// a delivery receipt for it, the live position of its unit 0) and its units. +#[wasm_bindgen] +#[derive(Clone)] +pub struct StageBase { + info: BaseInfo, + units: Vec, +} + +#[wasm_bindgen] +impl StageBase { + /// Decode a base envelope through the integer path (`wai::staged::decode_base_via`): + /// `set` maps each parameter file's name (as SPEC §5 names it) to its bytes, and is + /// absent for a capability that reads none (`wai.video.int_motion`); the envelope's + /// pin is verified against those bytes. `via` is the capability to decode through: + /// absent for the manifest's own, or its fallback. A refusal's message starts with + /// its code. + pub fn decode(envelope: &[u8], set: Option, via: Option) -> Result { + let mut held: Vec<(String, Vec)> = Vec::new(); + if let Some(m) = set { + for entry in m.entries() { + let pair = js_sys::Array::from(&entry.map_err(|_| JsError::new("set: unreadable entry"))?); + let name = pair.get(0).as_string().ok_or_else(|| JsError::new("set: a file name is a string"))?; + let bytes = js_sys::Uint8Array::new(&pair.get(1)).to_vec(); + held.push((name, bytes)); + } + } + let files: BTreeMap = held.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let (info, units) = staged::decode_base_via(envelope, via.as_deref(), &files).map_err(js_err)?; + Ok(StageBase { info, units }) + } + + /// A base decoded elsewhere (a codec this build does not run, or a fixture): its + /// envelope, the capability it was decoded through, the tier of that step + /// (`decode-equivalence`, `entropy-consistency` or `none`), and its units as their + /// canonical buffers concatenated, `shapes` holding three dimensions per unit and + /// `elem` `u8` or `i16`. + #[wasm_bindgen(js_name = fromStage0)] + pub fn from_stage0(envelope: &[u8], capability: &str, tier_token: &str, shapes: &[u32], elem: &str, stage0: &[u8]) -> Result { + let units = units_from_stage0(shapes, elem, stage0).map_err(js_err)?; + let info = BaseInfo { + content_hash: wai::jwp_hash::content_hash(envelope), + capability: capability.to_string(), + via_fallback: false, + tier: tier::from_token(tier_token), + attested: false, + first_unit: 0, + }; + Ok(StageBase { info, units }) + } + + /// The base envelope's content hash, lowercase hex. + #[wasm_bindgen(getter, js_name = contentHash)] + pub fn content_hash(&self) -> String { + hex(&self.info.content_hash) + } + #[wasm_bindgen(getter)] + pub fn capability(&self) -> String { + self.info.capability.clone() + } + #[wasm_bindgen(getter)] + pub fn tier(&self) -> String { + tier::token(self.info.tier).to_string() + } + #[wasm_bindgen(getter, js_name = nUnits)] + pub fn n_units(&self) -> u32 { + self.units.len() as u32 + } + #[wasm_bindgen(getter, js_name = viaFallback)] + pub fn via_fallback(&self) -> bool { + self.info.via_fallback + } + #[wasm_bindgen(setter, js_name = viaFallback)] + pub fn set_via_fallback(&mut self, v: bool) { + self.info.via_fallback = v; + } + /// Whether the sink verified a delivery receipt for the base (§10). + #[wasm_bindgen(getter)] + pub fn attested(&self) -> bool { + self.info.attested + } + #[wasm_bindgen(setter)] + pub fn set_attested(&mut self, v: bool) { + self.info.attested = v; + } + /// The live clock position of the base's unit 0 (§8). + #[wasm_bindgen(getter, js_name = firstUnit)] + pub fn first_unit(&self) -> f64 { + self.info.first_unit as f64 + } + /// Set the live clock position of the base's unit 0: a non-negative integer. + #[wasm_bindgen(js_name = setFirstUnit)] + pub fn set_first_unit(&mut self, v: f64) -> Result<(), JsError> { + self.info.first_unit = ms(v, "firstUnit")?; + Ok(()) + } + /// The units' canonical buffers concatenated, in unit order (SPEC §7 *Digests*). + #[wasm_bindgen(js_name = stage0)] + pub fn stage0(&self) -> Vec { + self.units.iter().flat_map(|u| u.samples.canonical_bytes()).collect() + } +} + +/// A staged-delivery sink: `wai::staged::StageSink`. Every method that decides returns +/// the reports of that step, an array in the corpus's form; a unit is read back by its +/// base's content hash (hex) and its index. +#[wasm_bindgen] +pub struct StageSink { + sink: staged::StageSink, +} + +impl StageSink { + fn chain(&self, base: &str) -> Result<&StageChain, JsError> { + let h = unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits"))?; + self.sink.chain(&h).ok_or_else(|| JsError::new("base-absent: the sink holds no such base")) + } + fn state(&self, base: &str, u: u32) -> Result<&staged::UnitState, JsError> { + self.chain(base)?.unit(u).ok_or_else(|| JsError::new("unit: out of range")) + } +} + +#[wasm_bindgen] +impl StageSink { + /// A sink with the configuration `config`, in the form a corpus case states it: + /// `{bound: {max_envelopes, max_age_ms}, cost_limit, budget, record_budget, live}` + /// (`cost_limit` and `live` null or absent for none). + #[wasm_bindgen(constructor)] + pub fn new(config: JsValue) -> Result { + let v: Value = serde_wasm_bindgen::from_value(config).map_err(js_err)?; + Ok(StageSink { sink: staged::StageSink::new(config_from(&v).map_err(js_err)?) }) + } + + /// Add a decoded base, which releases the layers held for it (§4.4). A base the + /// sink already holds is kept as it is. + #[wasm_bindgen(js_name = addBase)] + pub fn add_base(&mut self, base: &StageBase) -> Result { + let b = base.clone(); + to_js(&reports_value(&self.sink.add_base(b.info, b.units))) + } + + /// Offer the envelope `envelope` at `now_ms` with the receipt evidence `evidence` + /// (`delivered-verified`, `inclusion-verified` or `none`). Bytes that are not a + /// `WAI1` envelope, or one this sink cannot open, are an error whose message + /// starts with `not-wai1` or `encrypted`. + pub fn offer(&mut self, envelope: &[u8], now_ms: f64, evidence: &str) -> Result { + let e = ReceiptEvidence::from_token(evidence).ok_or_else(|| JsError::new("evidence: delivered-verified, inclusion-verified or none"))?; + match self.sink.offer(envelope, ms(now_ms, "now_ms")?, e) { + Ok(r) => to_js(&reports_value(&r)), + Err(staged::OfferError::NotWai1(m)) => Err(JsError::new(&format!("not-wai1: {m}"))), + Err(e) => Err(JsError::new(&format!("encrypted: {e}"))), + } + } + + /// Discard what has been held past the bound at `now_ms` (§4.3). + pub fn expire(&mut self, now_ms: f64) -> Result { + to_js(&reports_value(&self.sink.expire(ms(now_ms, "now_ms")?))) + } + + /// Drop a base's chain (§4.4); its units' records are kept. `null` when the sink + /// holds no such base. + #[wasm_bindgen(js_name = removeBase)] + pub fn remove_base(&mut self, base: &str) -> Result { + let h = unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits"))?; + match self.sink.remove_base(&h) { + Some(r) => to_js(&reports_value(&r)), + None => Ok(JsValue::NULL), + } + } + + /// Record that the sink verified a delivery receipt for the base (§10). + #[wasm_bindgen(js_name = markAttested)] + pub fn mark_attested(&mut self, base: &str) -> Result<(), JsError> { + let h = unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits"))?; + self.sink.mark_attested(&h); + Ok(()) + } + + /// Discard what the sink keeps of a base it does not hold (§14). + #[wasm_bindgen(js_name = forgetBase)] + pub fn forget_base(&mut self, base: &str) -> Result { + let h = unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits"))?; + Ok(self.sink.forget_base(&h)) + } + + /// Whether the sink holds the base. + #[wasm_bindgen(js_name = hasBase)] + pub fn has_base(&self, base: &str) -> bool { + unhex32(base).is_some_and(|h| self.sink.chain(&h).is_some()) + } + + /// The base's units and clip hash, as the corpus states a base at the end of a case + /// (`{units: [{stage, producer, frozen, tier, shape, stage_sha256}], clip_sha256, + /// n_units}`), or `null` when the sink holds no such base. + pub fn units(&self, base: &str) -> Result { + match unhex32(base).and_then(|h| self.sink.chain(&h)) { + Some(c) => to_js(&units_value(c)), + None => Ok(JsValue::NULL), + } + } + + #[wasm_bindgen(js_name = nUnits)] + pub fn n_units(&self, base: &str) -> Result { + Ok(self.chain(base)?.n_units()) + } + #[wasm_bindgen(js_name = unitStage)] + pub fn unit_stage(&self, base: &str, u: u32) -> Result { + Ok(self.state(base, u)?.stage as u32) + } + /// The content hash of the envelope that produced the unit's stage (the base at + /// stage 0). + #[wasm_bindgen(js_name = unitProducer)] + pub fn unit_producer(&self, base: &str, u: u32) -> Result { + Ok(hex(&self.state(base, u)?.producer)) + } + /// Whether the unit is frozen by a conflict (§4.1 step 7). + #[wasm_bindgen(js_name = unitFrozen)] + pub fn unit_frozen(&self, base: &str, u: u32) -> Result { + Ok(self.state(base, u)?.frozen) + } + #[wasm_bindgen(js_name = unitTier)] + pub fn unit_tier(&self, base: &str, u: u32) -> Result { + Ok(tier::token(self.state(base, u)?.tier).to_string()) + } + /// The unit's shape: rows, columns and channels of a picture. + #[wasm_bindgen(js_name = unitShape)] + pub fn unit_shape(&self, base: &str, u: u32) -> Result, JsError> { + let s = self.state(base, u)?.buf.shape; + Ok(vec![s.0, s.1, s.2]) + } + /// The unit's current output, 8-bit samples (RGB8 for a picture). An error for a + /// unit of 16-bit samples. + #[wasm_bindgen(js_name = unitBytes)] + pub fn unit_bytes(&self, base: &str, u: u32) -> Result, JsError> { + match &self.state(base, u)?.buf.samples { + Samples::U8(v) => Ok(v.clone()), + Samples::I16(_) => Err(JsError::new("the unit holds 16-bit samples: unitPcm")), + } + } + /// The unit's current output, 16-bit samples, channels interleaved. + #[wasm_bindgen(js_name = unitPcm)] + pub fn unit_pcm(&self, base: &str, u: u32) -> Result, JsError> { + match &self.state(base, u)?.buf.samples { + Samples::I16(v) => Ok(v.clone()), + Samples::U8(_) => Err(JsError::new("the unit holds 8-bit samples: unitBytes")), + } + } + /// The unit's stage hash (SHA-256 of its canonical buffer), or `undefined` unless + /// it holds `decode-equivalence` (§5). + #[wasm_bindgen(js_name = unitSha256)] + pub fn unit_sha256(&self, base: &str, u: u32) -> Result, JsError> { + let c = self.chain(base)?; + if u >= c.n_units() { + return Err(JsError::new("unit: out of range")); + } + Ok(c.unit_sha256(u).map(|h| hex(&h))) + } + /// The base's clip hash, or `undefined` unless every unit holds + /// `decode-equivalence`. + #[wasm_bindgen(js_name = clipSha256)] + pub fn clip_sha256(&self, base: &str) -> Result, JsError> { + Ok(self.chain(base)?.clip_sha256().map(|h| hex(&h))) + } + /// The squared error of the unit's current 8-bit output against `reference`, + /// summed exactly (a `BigInt`): the numerator of the page's PSNR. + #[wasm_bindgen(js_name = unitSseU8)] + pub fn unit_sse_u8(&self, base: &str, u: u32, reference: &[u8]) -> Result { + match &self.state(base, u)?.buf.samples { + Samples::U8(v) => sse_u8(v, reference).map_err(js_err), + Samples::I16(_) => Err(JsError::new("the unit holds 16-bit samples")), + } + } + /// The envelopes held for the base's units, `[[object, unit], …]`. + pub fn held(&self, base: &str) -> Result { + let v: Vec = self.chain(base)?.held().iter().map(|(h, u)| json!([hex(h), u])).collect(); + to_js(&Value::Array(v)) + } + /// The chain's state in bytes (§4.5). + #[wasm_bindgen(js_name = stateBytes)] + pub fn state_bytes(&self, base: &str) -> Result { + Ok(self.chain(base)?.state_bytes() as f64) + } + /// The bytes the sink keeps of bases it does not hold (§4.4). + #[wasm_bindgen(js_name = keptBytes)] + pub fn kept_bytes(&self) -> f64 { + self.sink.kept_bytes() as f64 + } +} + +/// What an envelope is, before it is offered: `{object, bytes, framing_bytes, +/// manifest_bytes, payload_bytes, capability, refinement_only, refines, wir1, +/// declared_cost, error}`, where `wir1` is a layer's header (`n_units`, `shape`, +/// `predictor`, `q`, `tables`) and `declared_cost` its output bytes, symbols and +/// working bytes (Appendix A.5). Not a `WAI1` envelope: an error starting `not-wai1`. +#[wasm_bindgen(js_name = stageLayerInfo)] +pub fn stage_layer_info(envelope: &[u8]) -> Result { + to_js(&layer_info(envelope).map_err(js_err)?) +} + +/// The content hash of an envelope (jwp-receipts §2.1), lowercase hex: the identity a +/// layer's `refines` names its base and producers by. +#[wasm_bindgen(js_name = stageContentHash)] +pub fn stage_content_hash(envelope: &[u8]) -> String { + hex(&wai::jwp_hash::content_hash(envelope)) +} + +/// The exact sum of squared differences of two equal-length byte buffers (a `BigInt`). +#[wasm_bindgen(js_name = sseU8)] +pub fn sse_u8_js(a: &[u8], b: &[u8]) -> Result { + sse_u8(a, b).map_err(js_err) +} + +/// The same for 16-bit samples. +#[wasm_bindgen(js_name = sseI16)] +pub fn sse_i16_js(a: &[i16], b: &[i16]) -> Result { + sse_i16(a, b).map_err(js_err) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wai_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..") + } + + /// Every corpus case, replayed through the binding's pure-Rust half the way + /// `demo/verify_staged_wasm.mjs` drives the wasm build: each base decoded through + /// `decode_base_via` where its decode is the integer path (and required to give its + /// stage-0 buffer and tier), from `stage0.bin` otherwise, then given the case's + /// facts; the sink configured from `case.json`'s `config`; every step's reports and + /// every base's final units in the corpus's form, equal to what the case states. + #[test] + fn the_corpus_replays_through_the_binding() { + let root = wai_dir().join("staged-conformance/cases"); + let mut names: Vec<_> = std::fs::read_dir(&root).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert!(names.len() >= 60, "the corpus is present"); + for name in names { + let dir = root.join(&name); + let case: Value = serde_json::from_slice(&std::fs::read(dir.join("case.json")).unwrap()).unwrap(); + let mut bases = Vec::new(); + for b in case["bases"].as_array().unwrap() { + let env = std::fs::read(dir.join(b["file"].as_str().unwrap())).unwrap(); + let stage0 = std::fs::read(dir.join(b["stage0"].as_str().unwrap())).unwrap(); + let shapes: Vec = b["units"].as_array().unwrap().iter().flat_map(|u| u["shape"].as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as u32).collect::>()).collect(); + let elem = b["units"][0]["elem"].as_str().unwrap(); + let via = b["decoded_via"].as_str().unwrap(); + let units = if b["decode"]["kind"] == "int" { + let held: Vec<(String, Vec)> = b["decode"]["set"].as_object().unwrap().iter() + .map(|(m, p)| (m.clone(), std::fs::read(wai_dir().join(p.as_str().unwrap())).unwrap())) + .collect(); + let files: BTreeMap = held.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let (info, units) = staged::decode_base_via(&env, Some(via), &files).unwrap_or_else(|e| panic!("{name}: {e}")); + let got: Vec = units.iter().flat_map(|u| u.samples.canonical_bytes()).collect(); + assert_eq!(got, stage0, "{name}: the base decodes to its stage 0"); + assert_eq!(tier::token(info.tier), b["tier"], "{name}: the base tier"); + assert_eq!(info.via_fallback, b["via_fallback"] == true, "{name}: via fallback"); + units + } else { + units_from_stage0(&shapes, elem, &stage0).unwrap() + }; + let info = BaseInfo { + content_hash: wai::jwp_hash::content_hash(&env), + capability: via.to_string(), + via_fallback: b["via_fallback"] == true, + tier: tier::from_token(b["tier"].as_str().unwrap()), + attested: b["attested"] == true, + first_unit: b["first_unit"].as_u64().unwrap(), + }; + assert_eq!(hex(&info.content_hash), b["object"], "{name}: the base's content hash"); + bases.push((info, units)); + } + let mut sink = staged::StageSink::new(config_from(&case["config"]).unwrap()); + let files: BTreeMap> = case["files"].as_array().unwrap().iter() + .map(|f| { let n = f["file"].as_str().unwrap().to_string(); (n.clone(), std::fs::read(dir.join(&n)).unwrap()) }) + .collect(); + let mut reports = Vec::new(); + for s in case["steps"].as_array().unwrap() { + let at = s["at_ms"].as_u64().unwrap(); + let idx = || s["index"].as_u64().unwrap() as usize; + let got = match s["op"].as_str().unwrap() { + "offer" => match sink.offer(&files[s["file"].as_str().unwrap()], at, ReceiptEvidence::from_token(s["evidence"].as_str().unwrap()).unwrap()) { + Ok(r) => reports_value(&r), + Err(staged::OfferError::NotWai1(_)) => json!({"error": "not-wai1"}), + Err(_) => json!({"error": "encrypted"}), + }, + "base" => { let (i, u) = bases[idx()].clone(); reports_value(&sink.add_base(i, u)) } + "expire" => reports_value(&sink.expire(at)), + "remove" => reports_value(&sink.remove_base(&bases[idx()].0.content_hash).unwrap_or_default()), + "attest" => { sink.mark_attested(&bases[idx()].0.content_hash); json!([]) } + o => panic!("{name}: op {o}"), + }; + reports.push(got); + } + assert_eq!(Value::Array(reports), case["expect"]["reports"], "{name}: the reports"); + let finals: Vec = bases.iter().map(|(i, _)| sink.chain(&i.content_hash).map(units_value).unwrap_or(Value::Null)).collect(); + assert_eq!(Value::Array(finals), case["expect"]["bases"], "{name}: the final units"); + } + } + + /// The configuration reader takes the corpus's form, maps a figure at or past + /// `2^64` (as JavaScript reads `u64::MAX`) to the largest `u64`, and refuses a + /// negative or fractional figure and a member it does not know. + #[test] + fn config_reads_the_corpus_form() { + let c = config_from(&json!({"bound": {"max_envelopes": 4, "max_age_ms": 1000}, "cost_limit": {"output_bytes": 100, "symbols": 18446744073709551616.0_f64, "working_bytes": 18446744073709551615u64}, "budget": 1, "record_budget": 2, "live": {"t0_ms": 1, "unit_ms": 40, "target_latency_ms": 3, "lead_ms": [10, 30]}})).unwrap(); + assert_eq!((c.bound.max_envelopes, c.bound.max_age_ms, c.budget, c.record_budget), (4, 1000, 1, 2)); + let l = c.cost_limit.unwrap(); + assert_eq!((l.output_bytes, l.symbols, l.working_bytes), (100, u64::MAX, u64::MAX)); + assert_eq!(c.live.unwrap().lead_ms, vec![10, 30]); + assert!(config_from(&json!({"bound": {"max_envelopes": 4, "max_age_ms": -1}, "budget": 1, "record_budget": 2})).is_err()); + assert!(config_from(&json!({"bound": {"max_envelopes": 4.5, "max_age_ms": 1}, "budget": 1, "record_budget": 2})).is_err()); + assert!(config_from(&json!({"bound": {"max_envelopes": 4, "max_age_ms": 1}, "budget": 1, "record_budget": 2, "extra": 0})).is_err()); + let ok = config_from(&json!({"bound": {"max_envelopes": 4, "max_age_ms": 1}, "budget": 1, "record_budget": 2})).unwrap(); + assert!(ok.cost_limit.is_none() && ok.live.is_none()); + } + + /// A layer's header and declared cost are read before it is offered, and its bytes + /// are its framing, manifest and payload; a base has no `refines` or `WIR1`. + #[test] + fn layer_info_reads_header_cost_and_bytes() { + let d = wai_dir().join("staged-conformance/cases/video_int_motion_gop_perframe"); + let l2 = std::fs::read(d.join("g0_L2.wai")).unwrap(); + let v = layer_info(&l2).unwrap(); + assert_eq!(v["bytes"].as_u64().unwrap() as usize, l2.len()); + assert_eq!(v["framing_bytes"].as_u64().unwrap() + v["manifest_bytes"].as_u64().unwrap() + v["payload_bytes"].as_u64().unwrap(), l2.len() as u64); + assert_eq!(v["framing_bytes"], 12); + assert_eq!(v["refines"]["layer"], 2); + assert_eq!(v["refines"]["prev"].as_array().map(Vec::len), Some(4), "a run per frame's producer"); + assert_eq!(v["wir1"]["n_units"], 4); + assert!(v["declared_cost"]["output_bytes"].as_u64().unwrap() > 0); + assert_eq!(v["error"], Value::Null); + let b = layer_info(&std::fs::read(d.join("base_0.wai")).unwrap()).unwrap(); + assert_eq!((b["refines"].is_null(), b["wir1"].is_null(), b["refinement_only"].as_bool()), (true, true, Some(false))); + assert!(layer_info(b"nope").unwrap_err().starts_with("not-wai1")); + assert_eq!(sse_u8(&[0, 10, 255], &[1, 7, 0]).unwrap(), 1 + 9 + 255 * 255); + assert!(sse_u8(&[0], &[]).is_err()); + assert_eq!(sse_i16(&[-32768], &[32767]).unwrap(), 65535 * 65535); + } +} -- 2.54.0 (Apple Git-157) From fa40aff3bcab11fa27d63871b66e6a5c16a97715 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 05:30:18 -0400 Subject: [PATCH 03/11] wai: wai_stage_bench, a native measurement of one staged set Stage 0 is every base decoded, stage k every layer k applied, in the order given. Per stage: the envelopes' bytes split into WAI1 framing, manifest and payload, and cumulative; PSNR (8-bit) or SNR (16-bit) per unit and over the clip from the exact integer SSE, with an exact match reported as exact; the microseconds the stage took, median and quartiles over at least 20 runs; the direct comparator, one identity layer per base at the stage's step straight from stage 0; every unit's stage hash. Energy is reported as unmetered and receipts as absent: the tool reads no meter, and a set on disk has none. Test: cli_stage::stage_bench_reports_bytes_quality_and_hashes, which builds a set with wai stage base and refine and checks the report against the files and the source frames' SHA-256. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/Cargo.toml | 5 + wai/wai-rs/src/bin/wai_stage_bench.rs | 372 ++++++++++++++++++++++++++ wai/wai-rs/tests/cli_stage.rs | 47 ++++ 3 files changed, 424 insertions(+) create mode 100644 wai/wai-rs/src/bin/wai_stage_bench.rs diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 6854bb57d..f34d7a72d 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -511,6 +511,11 @@ name = "wai_staged_vectors" path = "src/bin/wai_staged_vectors.rs" required-features = ["staged", "video_int", "audio_int", "splat_int"] +[[bin]] +name = "wai_stage_bench" +path = "src/bin/wai_stage_bench.rs" +required-features = ["staged", "video_int", "audio_int", "splat_int"] + [[bin]] name = "wai_film_vectors" path = "src/bin/wai_film_vectors.rs" diff --git a/wai/wai-rs/src/bin/wai_stage_bench.rs b/wai/wai-rs/src/bin/wai_stage_bench.rs new file mode 100644 index 000000000..64bda3b8c --- /dev/null +++ b/wai/wai-rs/src/bin/wai_stage_bench.rs @@ -0,0 +1,372 @@ +//! `wai_stage_bench` — measure one staged set natively (`extensions/staged-delivery.md`; +//! the measurement method of the staged-delivery design, §9). +//! +//! wai_stage_bench --source [--set ] [--runs N] [--out report.json] … +//! +//! The envelopes are a stage set: bases (no `refines`), decoded through +//! `staged::decode_base` against the parameter set in `--set` (none for +//! `wai.video.int_motion`), and their refinement layers. Stage 0 is every base decoded; +//! stage `k` is every layer whose `refines.layer` is `k` applied, in the order given. +//! `--source` holds what the units approximate: their canonical buffers concatenated, +//! base by base in the order given and unit by unit, as `stage0.bin` is laid out. +//! +//! For each stage it reports: +//! - **bytes**: the envelopes' bytes as delivered, split into `WAI1` framing, manifest +//! and payload, and the cumulative bytes through that stage. JSON and base64 are +//! never counted; there is no receipt, index or transport byte in a set on disk. +//! - **quality**: for 8-bit units, PSNR = 10·log10(255²·N / SSE) per unit and over the +//! whole clip (N samples, SSE the exact integer sum of squared differences); for +//! 16-bit units, SNR = 10·log10(Σx² / Σ(x−y)²) on the samples. A unit equal to its +//! source has no finite figure and is reported as `null` with `"exact": true`. +//! - **time**: the microseconds the stage took (decoding the bases; applying the +//! layers), median and interquartile range over `--runs` repetitions (at least 20), +//! single-threaded, in this process. +//! - **the direct comparator** (§9.3): for stage `k ≥ 1` whose layers share one step +//! `q` and the identity predictor, one layer per base coded straight from the base's +//! stage 0 to the source at that `q` — the cost of reaching stage `k` in one layer +//! instead of `k`. Its bytes and quality are reported beside the stage's. +//! +//! Every unit's stage hash at every stage is reported too, so a run can be checked +//! against `wai stage apply --json`. Energy is reported as `unmetered`: this tool reads +//! no meter. The figures describe this set on this machine and build, nothing wider. + +use std::collections::BTreeMap; +use std::process::ExitCode; +use std::time::Instant; + +use serde_json::{json, Value}; +use wai::container::{Prev, Refines, Wai}; +use wai::staged::wir1::{self, Below, Next, Predictor, RefineParams}; +use wai::staged::{BaseInfo, ChainConfig, HoldBound, ReceiptEvidence, Samples, StageSink, UnitBuf}; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn opt<'a>(args: &'a [String], flag: &str) -> Option<&'a str> { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +fn positional(args: &[String]) -> Vec<&str> { + let mut out = Vec::new(); + let mut skip = false; + for a in args { + if skip { + skip = false; + } else if a.starts_with("--") { + skip = true; + } else { + out.push(a.as_str()); + } + } + out +} + +fn read(path: &str) -> Result, String> { + std::fs::read(path).map_err(|e| format!("read {path}: {e}")) +} + +fn cfg() -> ChainConfig { + ChainConfig { bound: HoldBound { max_envelopes: 4096, max_age_ms: u64::MAX }, cost_limit: None, budget: u64::MAX, record_budget: u64::MAX, live: None } +} + +/// One envelope of the set, read once. +struct Env { + file: String, + bytes: Vec, + /// 0 for a base, `refines.layer` for a layer. + stage: u8, + manifest: usize, + payload: usize, + /// For a layer: its step and predictor. + q: Option<(u32, Predictor)>, +} + +fn read_env(file: &str) -> Result { + let bytes = read(file)?; + let w = Wai::from_bytes(&bytes).map_err(|e| format!("{file}: {e}"))?; + let manifest = u32::from_le_bytes(bytes[4..8].try_into().map_err(|_| format!("{file}: no manifest length"))?) as usize; + let (stage, q) = match w.refines() { + None => (0, None), + Some(Err(e)) => return Err(format!("{file}: {e}")), + Some(Ok(r)) => { + let kind = wai::staged::unit_kind(w.capability()).ok_or_else(|| format!("{file}: not a residual refinement"))?; + let p = wir1::parse(&w.payload, kind).map_err(|e| format!("{file}: {e}"))?; + (r.layer, Some((p.q, p.predictor))) + } + }; + Ok(Env { file: file.to_string(), stage, manifest, payload: w.payload.len(), q, bytes }) +} + +fn quantiles(mut v: Vec) -> Value { + v.sort_by(|a, b| a.partial_cmp(b).expect("finite")); + let at = |p: f64| { + let x = p * (v.len() - 1) as f64; + let (lo, hi) = (x.floor() as usize, x.ceil() as usize); + v[lo] + (v[hi] - v[lo]) * (x - lo as f64) + }; + json!({"median": round2(at(0.5)), "p25": round2(at(0.25)), "p75": round2(at(0.75))}) +} + +fn round2(x: f64) -> f64 { + (x * 100.0).round() / 100.0 +} + +/// The squared error of a unit against its source, and its sample count and signal +/// energy (for SNR). +fn sse(s: &Samples, src: &[u8]) -> (u64, u64, u64) { + match s { + Samples::U8(v) => (v.iter().zip(src).map(|(a, b)| (*a as i64 - *b as i64).pow(2) as u64).sum(), v.len() as u64, 0), + Samples::I16(v) => { + let x: Vec = src.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]]) as i64).collect(); + let e = v.iter().zip(&x).map(|(a, b)| (*a as i64 - b).pow(2) as u64).sum(); + (e, v.len() as u64, x.iter().map(|b| (b * b) as u64).sum()) + } + } +} + +/// PSNR (8-bit) or SNR (16-bit) in dB, `None` for an exact match. +fn quality(err: u64, n: u64, energy: u64, pcm: bool) -> Option { + if err == 0 { + return None; + } + let db = if pcm { 10.0 * (energy as f64 / err as f64).log10() } else { 10.0 * (255.0 * 255.0 * n as f64 / err as f64).log10() }; + Some(round2(db)) +} + +fn quality_json(err: u64, n: u64, energy: u64, pcm: bool) -> Value { + let mut v = json!({"sse": err, "samples": n}); + v[if pcm { "snr_db" } else { "psnr_db" }] = json!(quality(err, n, energy, pcm)); + v["exact"] = json!(err == 0); + v +} + +/// The refinement capability over a base's units (as `wai stage refine` picks it). +fn refine_cap(info: &BaseInfo, units: &[UnitBuf]) -> &'static str { + use wai::codecs::{capability_media, Media}; + match units.first().map(|u| &u.samples) { + Some(Samples::I16(_)) => wai::codecs::CAP_AUDIO_INT_REFINE, + _ if capability_media(&info.capability) == Some(Media::Video) => wai::codecs::CAP_VIDEO_INT_REFINE, + _ if capability_media(&info.capability) == Some(Media::Volumetric) => wai::codecs::CAP_SPLAT_INT_REFINE, + _ => wai::codecs::CAP_IMAGE_INT_REFINE, + } +} + +fn below(s: &Samples) -> Below<'_> { + match s { + Samples::U8(v) => Below::U8(v), + Samples::I16(v) => Below::I16(v), + } +} + +fn run(args: &[String]) -> Result<(), String> { + let source = read(opt(args, "--source").ok_or("--source ")?)?; + let runs: usize = opt(args, "--runs").unwrap_or("20").parse().map_err(|e| format!("--runs: {e}"))?; + if runs < 20 { + return Err("--runs: at least 20, so the quartiles mean something".into()); + } + let set: BTreeMap> = match opt(args, "--set") { + Some(dir) => wai::pin::read_dir_set(std::path::Path::new(dir)).map_err(|e| format!("--set {dir}: {e}"))?, + None => BTreeMap::new(), + }; + let files: BTreeMap = set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + let envs: Vec = positional(args).iter().map(|f| read_env(f)).collect::>()?; + let n_stages = envs.iter().map(|e| e.stage).max().ok_or("no envelopes")?; + if !envs.iter().any(|e| e.stage == 0) { + return Err("no base among the envelopes".into()); + } + + // The bases as decoded, and each base's slice of the source. + let mut bases: Vec<(BaseInfo, Vec)> = Vec::new(); + for e in envs.iter().filter(|e| e.stage == 0) { + bases.push(wai::staged::decode_base(&e.bytes, &files).map_err(|x| format!("{}: {x}", e.file))?); + } + let mut src_of: Vec> = Vec::new(); + let mut at = 0usize; + for (_, units) in &bases { + let mut v = Vec::new(); + for u in units { + let n = u.samples.byte_len() as usize; + v.push(source.get(at..at + n).ok_or("--source is shorter than the bases' units")?); + at += n; + } + src_of.push(v); + } + if at != source.len() { + return Err(format!("--source holds {} bytes; the bases' units hold {at}", source.len())); + } + let pcm = matches!(bases[0].1[0].samples, Samples::I16(_)); + + // Timed replays: the first also records every stage's units. + let mut times: Vec> = vec![Vec::new(); n_stages as usize + 1]; + let mut snapshots: Vec> = vec![Vec::new(); n_stages as usize + 1]; + let mut clip: Vec<(u64, u64, u64)> = vec![(0, 0, 0); n_stages as usize + 1]; + for r in 0..runs { + let mut sink = StageSink::new(cfg()); + let t = Instant::now(); + let mut hashes = Vec::new(); + for e in envs.iter().filter(|e| e.stage == 0) { + let (info, units) = wai::staged::decode_base(&e.bytes, &files).map_err(|x| format!("{}: {x}", e.file))?; + hashes.push(info.content_hash); + sink.add_base(info, units); + } + times[0].push(t.elapsed().as_secs_f64() * 1e6); + let snap = |sink: &StageSink, k: usize, snapshots: &mut Vec>, clip: &mut Vec<(u64, u64, u64)>| { + for (b, h) in hashes.iter().enumerate() { + let c = sink.chain(h).expect("added"); + for u in 0..c.n_units() { + let s = c.unit(u).expect("in range"); + let (err, n, energy) = sse(&s.buf.samples, src_of[b][u as usize]); + clip[k].0 += err; + clip[k].1 += n; + clip[k].2 += energy; + let mut q = quality_json(err, n, energy, pcm); + q["base"] = json!(hex(h)); + q["unit"] = json!(u); + q["stage"] = json!(s.stage); + q["stage_sha256"] = json!(c.unit_sha256(u).map(|x| hex(&x))); + snapshots[k].push(q); + } + } + }; + if r == 0 { + snap(&sink, 0, &mut snapshots, &mut clip); + } + for k in 1..=n_stages { + let t = Instant::now(); + for (i, e) in envs.iter().enumerate().filter(|(_, e)| e.stage == k) { + let reports = sink.offer(&e.bytes, i as u64, ReceiptEvidence::None).map_err(|x| format!("{}: {x}", e.file))?; + if r == 0 { + if let Some(bad) = reports.iter().find(|x| x.envelope.is_some() || !x.refused.is_empty() || !x.held.is_empty() || x.held_for_base) { + return Err(format!("{}: not applied whole ({:?}); the bench measures a set delivered whole and in order", e.file, bad)); + } + } + } + times[k as usize].push(t.elapsed().as_secs_f64() * 1e6); + if r == 0 { + snap(&sink, k as usize, &mut snapshots, &mut clip); + } + } + } + + // The direct comparator for each stage whose layers share one step and identity. + let direct = |k: u8| -> Result { + let qs: Vec<(u32, Predictor)> = envs.iter().filter(|e| e.stage == k).filter_map(|e| e.q).collect(); + let Some(&(q, p)) = qs.first() else { return Ok(Value::Null) }; + if p != Predictor::Identity || qs.iter().any(|x| *x != (q, p)) { + return Ok(json!({"q": null, "why": "the stage's layers do not share one step and the identity predictor"})); + } + let (mut bytes, mut err, mut n, mut energy) = (0u64, 0u64, 0u64, 0u64); + for (b, (info, units)) in bases.iter().enumerate() { + let shape = units[0].shape; + if units.iter().any(|u| u.shape != shape) { + return Ok(json!({"q": q, "why": "a base's units differ in shape"})); + } + let belows: Vec = units.iter().map(|u| below(&u.samples)).collect(); + let targets: Vec = src_of[b] + .iter() + .map(|s| if pcm { Samples::I16(s.chunks_exact(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect()) } else { Samples::U8(s.to_vec()) }) + .collect(); + let tb: Vec = targets.iter().map(below).collect(); + let params = RefineParams { q, predictor: Predictor::Identity, rate: (0, 0), table_per_unit: false, max_symbols: 4095 }; + let cap = refine_cap(info, units); + let kind = wai::staged::unit_kind(cap).expect("a refinement"); + let (payload, recon) = wir1::encode(kind, &belows, shape, &tb, shape, ¶ms).map_err(|e| e.to_string())?; + let refines = Refines { base: hex(&info.content_hash), prev: Prev::One(hex(&info.content_hash)), layer: 1, span: None }; + let media = wai::codecs::capability_media(cap).map(|m| m.label()).unwrap_or("binary"); + let m = wai::Manifest::new(media, "replicate", wai::ModelRequirement::new(cap), wai::Conditioning::new("wir1")).with_refines(&refines); + bytes += Wai::new(m, payload).to_emit_bytes().map_err(|e| e.to_string())?.len() as u64; + for (r, t) in recon.iter().zip(&targets) { + let s = match r { + Next::U8(v) => Samples::U8(v.clone()), + Next::I16(v) => Samples::I16(v.clone()), + }; + let (e, m, x) = sse(&s, &t.canonical_bytes()); + err += e; + n += m; + energy += x; + } + } + let mut v = quality_json(err, n, energy, pcm); + v["q"] = json!(q); + v["bytes"] = json!(bytes); + Ok(v) + }; + + let mut stages = Vec::new(); + let mut cumulative = 0u64; + for k in 0..=n_stages { + let es: Vec<&Env> = envs.iter().filter(|e| e.stage == k).collect(); + let bytes: u64 = es.iter().map(|e| e.bytes.len() as u64).sum(); + cumulative += bytes; + let (err, n, energy) = clip[k as usize]; + stages.push(json!({ + "stage": k, + "envelopes": es.len(), + "bytes": bytes, + "framing_bytes": es.iter().map(|e| (e.bytes.len() - e.manifest - e.payload) as u64).sum::(), + "manifest_bytes": es.iter().map(|e| e.manifest as u64).sum::(), + "payload_bytes": es.iter().map(|e| e.payload as u64).sum::(), + "cumulative_bytes": cumulative, + "clip": quality_json(err, n, energy, pcm), + "time_us": quantiles(times[k as usize].clone()), + "direct": if k == 0 { Value::Null } else { direct(k)? }, + "units": snapshots[k as usize], + })); + } + let report = json!({ + "format": "wai-stage-bench/1", + "conditions": { + "runs": runs, + "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, + "arch": std::env::consts::ARCH, + "os": std::env::consts::OS, + "threads": 1, + "energy": "unmetered", + "receipts": "none: a set on disk carries no delivery receipts", + "quality": if pcm { "SNR = 10·log10(Σx² / Σ(x−y)²) on the 16-bit samples" } else { "PSNR = 10·log10(255²·N / SSE), SSE the exact integer sum over N samples" }, + }, + "envelopes": envs.iter().map(|e| json!({ + "file": e.file, "object": hex(&wai::jwp_hash::content_hash(&e.bytes)), "stage": e.stage, "bytes": e.bytes.len(), + "manifest_bytes": e.manifest, "payload_bytes": e.payload, "q": e.q.map(|x| x.0), "predictor": e.q.map(|x| x.1.token()), + })).collect::>(), + "stages": stages, + }); + + println!("stage envelopes bytes cumulative clip quality time µs (median [p25, p75]) direct: bytes quality"); + for s in report["stages"].as_array().expect("stages") { + let qk = if pcm { "snr_db" } else { "psnr_db" }; + let q = |v: &Value| match v[qk].as_f64() { + Some(x) => format!("{x:.2} dB"), + None if v["exact"] == true => "exact".to_string(), + None => "-".to_string(), + }; + let d = &s["direct"]; + let direct = if d.is_null() || d["bytes"].is_null() { "-".to_string() } else { format!("{:>9} {}", d["bytes"], q(d)) }; + println!("{:>5} {:>9} {:>9} {:>10} {:>16} {:>9} [{}, {}] {}", + s["stage"], s["envelopes"], s["bytes"], s["cumulative_bytes"], q(&s["clip"]), + s["time_us"]["median"], s["time_us"]["p25"], s["time_us"]["p75"], direct); + } + if let Some(out) = opt(args, "--out") { + let mut text = serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?; + text.push('\n'); + std::fs::write(out, text).map_err(|e| format!("write {out}: {e}"))?; + println!("report → {out}"); + } + Ok(()) +} + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + eprintln!("usage: wai_stage_bench --source [--set ] [--runs N≥20] [--out report.json] …"); + return ExitCode::from(2); + } + match run(&args) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::from(1) + } + } +} diff --git a/wai/wai-rs/tests/cli_stage.rs b/wai/wai-rs/tests/cli_stage.rs index a1b8ab21d..a189e197a 100644 --- a/wai/wai-rs/tests/cli_stage.rs +++ b/wai/wai-rs/tests/cli_stage.rs @@ -229,3 +229,50 @@ fn apply_holds_layers_for_a_later_base() { let got: Vec = doc["bases"].as_array().unwrap().iter().map(|b| b["clip_sha256"].clone()).collect(); assert_eq!(serde_json::Value::Array(got), want); } + +/// `wai_stage_bench` measures a set made with `wai stage`: stage 0 is the base, +/// stage 1 its layers; the bytes are the envelopes' and sum to them; a layer at q = 1 +/// reaches the source, which the report states as exact (no finite PSNR) with each +/// unit's stage hash that of its source frame; the direct comparator at the same step +/// is exact too; fewer than 20 runs are refused. +#[test] +fn stage_bench_reports_bytes_quality_and_hashes() { + let (n, h, w) = (2usize, 5usize, 7usize); + let per = h * w * 3; + let frames: Vec = (0..n * per).map(|i| ((i * 29 + (i / per) * 70) % 256) as u8).collect(); + let fpath = scratch("bench_frames.rgb"); + std::fs::write(&fpath, &frames).unwrap(); + let bpath = scratch("bench_base.wai"); + let o = wai(&[os("stage"), os("base"), os("--frames"), fpath.as_os_str(), os("--dims"), os("2,5,7"), os("--q"), os("30"), os("-o"), bpath.as_os_str()]); + assert!(o.status.success(), "{}", text(&o)); + let lpath = scratch("bench_L1.wai"); + let o = wai(&[os("stage"), os("refine"), os("--base"), bpath.as_os_str(), os("--target"), fpath.as_os_str(), os("--q"), os("1"), os("-o"), lpath.as_os_str()]); + assert!(o.status.success(), "{}", text(&o)); + let out = scratch("bench.json"); + let bench = |runs: &str| Command::new(env!("CARGO_BIN_EXE_wai_stage_bench")) + .args([os("--source"), fpath.as_os_str(), os("--runs"), os(runs), os("--out"), out.as_os_str(), bpath.as_os_str(), lpath.as_os_str()]) + .output() + .unwrap(); + assert!(!bench("5").status.success(), "fewer than 20 runs are refused"); + let o = bench("20"); + assert!(o.status.success(), "{}", text(&o)); + let r: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap(); + assert_eq!(r["format"], "wai-stage-bench/1"); + assert_eq!(r["conditions"]["energy"], "unmetered"); + let s = r["stages"].as_array().unwrap(); + assert_eq!(s.len(), 2); + let sizes = [std::fs::metadata(&bpath).unwrap().len(), std::fs::metadata(&lpath).unwrap().len()]; + for (k, st) in s.iter().enumerate() { + assert_eq!(st["bytes"].as_u64(), Some(sizes[k])); + assert_eq!(st["framing_bytes"].as_u64().unwrap() + st["manifest_bytes"].as_u64().unwrap() + st["payload_bytes"].as_u64().unwrap(), sizes[k]); + assert!(st["time_us"]["median"].as_f64().is_some()); + } + assert_eq!(s[1]["cumulative_bytes"].as_u64(), Some(sizes[0] + sizes[1])); + assert!(s[0]["clip"]["psnr_db"].as_f64().is_some_and(|db| db > 0.0), "the base is lossy at q = 30"); + assert_eq!((s[1]["clip"]["exact"].as_bool(), s[1]["clip"]["psnr_db"].is_null()), (Some(true), true)); + for (f, u) in s[1]["units"].as_array().unwrap().iter().enumerate() { + assert_eq!(u["stage"].as_u64(), Some(1)); + assert_eq!(u["stage_sha256"].as_str().unwrap(), sha256_hex(&frames[f * per..(f + 1) * per])); + } + assert_eq!((s[1]["direct"]["q"].as_u64(), s[1]["direct"]["exact"].as_bool()), (Some(1), Some(true))); +} -- 2.54.0 (Apple Git-157) From 1ad89e0d7113ff5c14f73102df3f9910fad94e86 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 05:31:59 -0400 Subject: [PATCH 04/11] =?UTF-8?q?wai:=20CHANGELOG=20=E2=80=94=20staged=20d?= =?UTF-8?q?elivery=20in=20the=20browser,=20a=20base=20writer,=20a=20trace?= =?UTF-8?q?=20and=20a=20bench?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index c7bea8e52..232f2cf58 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,47 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Staged delivery in the browser, a base writer, a trace and a bench + +- **The sink in the browser.** `wai-web` binds `wai::staged::StageSink` for JS + (`src/stage.rs`). `StageBase.decode` decodes a base envelope through the integer path + with its pin verified against the parameter set the page holds in memory, through the + manifest's capability or its fallback; `StageBase.fromStage0` takes a base decoded + elsewhere. `StageSink` adds bases, offers layers, expires holds, removes, attests and + forgets bases, and reads back every unit's stage, producer, frozen flag, tier, shape, + output, stage hash and squared error against a reference. `stageLayerInfo` reads a + layer's `refines`, `WIR1` header, declared cost and byte split (framing, manifest, + payload) before it is offered. Reports and units come back in the corpus's own form. + Media reach the page as `WAI1` bytes only. +- **The browser equals the native sink.** `wai-web/demo/verify_staged_wasm.mjs` + replays every case of `staged-conformance/` through the wasm32 build: each integer + base is decoded in wasm32 and must give its `stage0.bin`, its tier and its fallback + flag, and every report, final unit, stage hash and clip hash must equal what the + native sink wrote. `wai-web-wasm.yml` runs it and watches the corpus; the crate's + native tests replay the corpus through the binding's pure-Rust half as well. +- **One base path.** `staged::decode_base` (and `decode_base_via`, which takes the + capability a sink chose: the manifest's own or its fallback, recorded as decoded + through a fallback) is the base decode the CLI and the browser share. A pin counts + toward the base's tier only where it resolved; a capability the manifest does not + offer is refused (`not-offered`). +- **A keyframe-0 base writer.** `int_payload::encode_wiv1_zero_motion` codes RGB8 + frames as a `WIV1` keyframe-0 payload (zero motion, one block per frame, a closed + group of pictures): the reference encoder the corpus generator used, now in the + library. The corpus regenerates byte for byte through it. `wai stage base` writes a + `wai.video.int_motion` base with it from raw frames and decodes it back before + writing. +- **A trace to check against.** `wai stage apply` is now a sink replay: envelopes are + taken in order, and a base given after its layers is decoded and releases the layers + held for it. `--json` prints each step's reports in the corpus's form and the units + each step changed, with their stage hashes, so a sink elsewhere can check every stage + it reaches. +- **`wai_stage_bench`.** Measures one staged set natively: per stage, the envelopes' + bytes (framing, manifest and payload, and cumulative), PSNR or SNR per unit and over + the clip from the exact integer squared error, the time each stage took (median and + quartiles over at least 20 runs), the direct comparator (one layer per base at the + stage's step, straight from stage 0) and every unit's stage hash. Energy is reported + as unmetered. + ### Staged delivery: refinement layers, base admission, and WAI2 refinement components - **The extension** [staged-delivery](extensions/staged-delivery.md): a stage set is a -- 2.54.0 (Apple Git-157) From 35c53adb9f9bdf73b01adcbc7536f6a46b3cdb9d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 06:27:28 -0400 Subject: [PATCH 05/11] =?UTF-8?q?wai:=20bound=20a=20base=20before=20it=20i?= =?UTF-8?q?s=20decoded,=20and=20decode=20it=20by=20SPEC=20=C2=A74?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings 1-10 of PR #152 (link 18). - staged::decode_base / decode_base_via take a cost limit and a key log. The payload's declared cost is checked (int_payload::check_cost) before any symbol is decoded or output allocated; cost_over_limit, like unsupported_capability and set_incomplete, makes the capability unsupported and dispatch moves on to the fallback. The key log runs SPEC §3.1 check 5 over a pin and over an unpinned set; None skips it (a sink with no key log). - decode_base runs SPEC §4 through Wai::dispatch_verified: the capability, then the fallback, only where is_dispatchable accepts it, so a refinement-only or companion-only capability makes the envelope inert (missing_capability) and its fallback is never reached. decode_base_via asks for one of the two, and refuses any other string (not-offered). - A capability whose set is one file, pinned with no form, offers every held file to the pin, the member it reads first: a README beside model.wam no longer breaks the decode. - StageSink::add_base counts the base's state against the chain budget: a base past it is refused chain-over-limit, keeps no chain, and its layers stay held. staged-delivery §4.5 (and §4.2, §14) say so; the corpus gains base_over_budget, and staged_verify.py implements the rule independently. - StageSink::set_trace / take_trace record every application, so a batch that takes a unit up several stages records each. - wai stage apply routes by content: refines or a refinement-only capability is a layer; any other envelope is a base decoded by SPEC §4. not-wai1 and encrypted are a step's error under --json, and the replay goes on. --json lists every stage reached (from the trace), stage 0 for a base's step, and the units whose frozen flag changed. - wai_stage_bench takes the source at the units' final shape; a stage whose unit has another shape has no quality figure, and the comparator says why it does not apply. - encode_wiv1_zero_motion checks MAX_TENSOR, MAX_FRAMES and MAX_CLIP before allocating (too_large); wai stage base multiplies --dims checked. - wai-web: StageBase.decode takes the page's cost limit; stageLayerInfo returns a base's declared decode cost; addBase consumes the StageBase. The viaFallback setter is gone and attested only rises. fromStage0 parses the envelope, takes only the manifest's capability or its fallback (deriving via_fallback), caps the claimed tier at base_tier(capability, None), and marks the base claimed: every report, units() entry and stage hash (unitSha256 and clipSha256 now return {sha256, claimed}) of its chain says claimed. Tests: staged::tests::{decode_base_runs_spec_4, decode_base_applies_the_cost_limit, decode_base_runs_check_5, decode_base_one_file_pin_ignores_other_files, add_base_counts_its_state_against_the_budget}, the extended encoder test, the new corpus case, and wai-web stage::tests::{a_claimed_base_cannot_raise_its_facts, limit_reads_its_form}. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/staged-delivery.md | 17 +- .../cases/base_over_budget/L1.wai | Bin 0 -> 587 bytes .../cases/base_over_budget/base.wai | Bin 0 -> 555 bytes .../cases/base_over_budget/case.json | 115 ++++++ .../cases/base_over_budget/stage0.bin | 1 + wai/staged-conformance/index.json | 7 + wai/staged-conformance/staged_verify.py | 12 + wai/wai-rs/src/bin/wai_cli/stage.rs | 170 +++++--- wai/wai-rs/src/bin/wai_stage_bench.rs | 132 ++++--- wai/wai-rs/src/bin/wai_staged_vectors.rs | 8 + wai/wai-rs/src/int_payload/mod.rs | 17 +- wai/wai-rs/src/int_payload/tests.rs | 10 + wai/wai-rs/src/staged/mod.rs | 293 +++++++++++--- wai/wai-rs/src/staged/tests.rs | 157 +++++++- wai/wai-web/src/stage.rs | 371 ++++++++++++++---- 15 files changed, 1061 insertions(+), 249 deletions(-) create mode 100644 wai/staged-conformance/cases/base_over_budget/L1.wai create mode 100644 wai/staged-conformance/cases/base_over_budget/base.wai create mode 100644 wai/staged-conformance/cases/base_over_budget/case.json create mode 100644 wai/staged-conformance/cases/base_over_budget/stage0.bin diff --git a/wai/extensions/staged-delivery.md b/wai/extensions/staged-delivery.md index 3b5feda14..da16e7b3a 100644 --- a/wai/extensions/staged-delivery.md +++ b/wai/extensions/staged-delivery.md @@ -140,6 +140,7 @@ unit. | 4 | `base-not-admitted` | envelope | discarded | | 5 | `refinement-unattested` | envelope | discarded | | 6 | `payload-malformed`, `cost-over-limit`, `chain-over-limit` | envelope | discarded | +| §4.5 | `chain-over-limit`, for a base | the base's envelope | the base is not added; its layers stay held | | 7 | `layer-conflict` | unit | not applied; the unit is frozen | | 8 | `prev-mismatch` | unit | not applied to the unit | | 8 | `layer-gap` | unit | held for the unit, then not applied | @@ -246,6 +247,16 @@ leaves the layer's: an application lowers the state by the unit's old output (an state rises only when a layer is admitted, so it never passes the budget, and the bytes a chain holds never pass the state. +A chain starts with its base. A base whose units' output, with 32 bytes for each stage +the records the sink keeps of it hold (§4.4), passes the budget is not added: the sink +reports `chain-over-limit` for the base's envelope, keeps no chain for it, leaves the +records it keeps of it as they were, and holds the layers that name it as for a base it +has not decoded (§4.3). A receipt verified with the base is recorded all the same +(§10). The base's own decode is bounded before it starts: where its capability declares +a cost before decoding (`extensions/integer-payloads.md` §7), a sink applies its cost +limit to that declared cost first, and SPEC §4 treats a base past it as one this sink +does not decode (`cost_over_limit`), so dispatch moves on to the fallback. + ## 5. The tier of a stage, and the stage hash - **The base step**: `decode-equivalence` when the capability the base was decoded @@ -403,7 +414,11 @@ ignores the header. - A hold bound (§4.3), a cost limit (Appendix A.5) and a chain budget (§4.5) are resource limits. A layer's header declares its output, its symbols and its working bytes before any symbol is decoded, so a sink refuses a layer past its limit or its - budget without decoding it. A sink's own work for a layer is linear in the + budget without decoding it. A base of an integer codec declares its cost the same way + (`extensions/integer-payloads.md` §7), so a sink refuses a base past its limit before + decoding it, and one whose output passes the chain budget before keeping it (§4.5): + a few bytes of envelope can declare a large output, and only the declared cost bounds + the work. A sink's own work for a layer is linear in the layer's payload bytes and declared symbols, up to a factor logarithmic in its units: it parses the payload once, at step 6; decides each unit the layer covers once for each offer or release of the layer; finds a unit's producer in `prev` by diff --git a/wai/staged-conformance/cases/base_over_budget/L1.wai b/wai/staged-conformance/cases/base_over_budget/L1.wai new file mode 100644 index 0000000000000000000000000000000000000000..84238136cc640880acd4002218acacb52d4c0ec8 GIT binary patch literal 587 zcmWG{^fYv4WMHUPDo@N*vQjeCGf>h|%1uqlOa$`EGE-9Xf&9$8lGMBspg>V-K~83J zVo54UQGQBlPJB^nVQFSjYA#s5S}8fPATcR3C$ppys2XUb9@q#ypaDRYX_X?+1SeaU+WMv3+X-Q&HdMYqn@=9}ZYX2}WFob&s88QNclL^S?fMRAK4FfDd z#-IQH|MNfvRs#hX#TXdE^cfgd`!O*5Z)IRuv4eqO`4a|)pzjO}!T*6`KnzsH-~%K< z*fU~Ek4qjO!SG;-7FY*e>96?c|D+feX$XNPV+av|nU*=9==#{*%x4=O=nf za$jJYb@yTT>5%lIrMJ5*UVoXpclV>ERVrV)w{|DI{m%z9bJ23<>+!nb_xU>io_z3P z!l7tG#RIyZrpDMF>D9UYJ7LOkOEuB5d9HG0`;S=5&G%zW3%>A9BC1A)J6Ya4Z|$Ym JQ65_-0szQ<#OVM4 literal 0 HcmV?d00001 diff --git a/wai/staged-conformance/cases/base_over_budget/base.wai b/wai/staged-conformance/cases/base_over_budget/base.wai new file mode 100644 index 0000000000000000000000000000000000000000..a5feb5542008809a0486d9a5e0777bc594caa599 GIT binary patch literal 555 zcmWG{^fa8$z`#(gRGyfrWTj-NXP~5`l$)B8nF!>SWu~O&1NoVGC8>EOK!Kvvf}G6c z#FA8yqWqN9ocN;D!qUv5)LgK9wNi3oL1I#7PG(6ZP&Lp1M7#SGCJ;Mwcfix4)xg2Qt zw`Q@l_zc3*7!f#LsGApQ?>0nm{^a0ZB7n4b%}1KrZW^CMVFqIJm&v4b8Pv}>cw`3p?f zXgDy>oA*79UGZ<{t>*IE zu`Ux{*3E1CXp_s4WH4{7>mP%IA$F^Rd)j$lW!#wG#BKb;fn9rzZ^7T7LSdO#>}3<1 i?|E`>*pNO?qGLDzd$yF~^Y2d|d+(I`_J!`|{-pqYlE#ey literal 0 HcmV?d00001 diff --git a/wai/staged-conformance/cases/base_over_budget/case.json b/wai/staged-conformance/cases/base_over_budget/case.json new file mode 100644 index 000000000..2f08ed86a --- /dev/null +++ b/wai/staged-conformance/cases/base_over_budget/case.json @@ -0,0 +1,115 @@ +{ + "format": "wai-staged-case/1", + "name": "base_over_budget", + "about": "a chain budget one byte short of the base's units: the base is not added (chain-over-limit, §4.5), and the layer held for it stays held until the bound (base-absent)", + "bases": [ + { + "file": "base.wai", + "object": "551af715c03c0216f9e79a6b0bafcce5c31c0c5e573cf4cc7bbab34e1dc8d4c7", + "decoded_via": "wai.video.int_motion", + "via_fallback": false, + "attested": false, + "first_unit": 0, + "pin_resolved": false, + "tier": "decode-equivalence", + "units": [ + { + "shape": [ + 8, + 8, + 3 + ], + "elem": "u8" + }, + { + "shape": [ + 8, + 8, + 3 + ], + "elem": "u8" + } + ], + "stage0": "stage0.bin", + "stage0_sha256": "acb4b066fd922bf6b50128457ea7132f123f18eac82b3d98ac49e145b091742c", + "decode": { + "kind": "int", + "set": {} + } + } + ], + "files": [ + { + "file": "L1.wai", + "object": "9f91cd9d50204f57545e42014eef169c50f9167b038037a83705f7a50aee4a4d", + "bytes": 587 + } + ], + "config": { + "bound": { + "max_envelopes": 16, + "max_age_ms": 1000 + }, + "cost_limit": null, + "budget": 383, + "record_budget": 1048576, + "live": null + }, + "steps": [ + { + "op": "offer", + "file": "L1.wai", + "at_ms": 0, + "evidence": "none" + }, + { + "op": "base", + "index": 0, + "at_ms": 1 + }, + { + "op": "expire", + "at_ms": 1001 + } + ], + "expect": { + "reports": [ + [ + { + "object": "9f91cd9d50204f57545e42014eef169c50f9167b038037a83705f7a50aee4a4d", + "envelope": null, + "detail": null, + "held_for_base": true, + "applied": [], + "held": [], + "refused": [] + } + ], + [ + { + "object": "551af715c03c0216f9e79a6b0bafcce5c31c0c5e573cf4cc7bbab34e1dc8d4c7", + "envelope": "chain-over-limit", + "detail": null, + "held_for_base": false, + "applied": [], + "held": [], + "refused": [] + } + ], + [ + { + "object": "9f91cd9d50204f57545e42014eef169c50f9167b038037a83705f7a50aee4a4d", + "envelope": "base-absent", + "detail": null, + "held_for_base": false, + "applied": [], + "held": [], + "refused": [] + } + ] + ], + "bases": [ + null + ] + } +} diff --git a/wai/staged-conformance/cases/base_over_budget/stage0.bin b/wai/staged-conformance/cases/base_over_budget/stage0.bin new file mode 100644 index 000000000..d74be3a35 --- /dev/null +++ b/wai/staged-conformance/cases/base_over_budget/stage0.bin @@ -0,0 +1 @@ + ( (@0@H0@X8PXPXp`px`hx08H8@X8PXPPhPhx`xxpp�p€˜8PXHP`�Àè`pxhx€px˜x�¨� ¨PXp Ðè¨Ðø¸Øÿx�¨� ¨� ¸¨°À¨Øÿ°àÿÈðÿÐøÿàøÿ˜¨À °ȰÈÈx€�ÐðÿÐÿÿØÿÿ¨¸À¸ÀÈÀÐØÀØ萘¨  °èÿÿ¨¸иÈØÐÐàÐèèàè𨰸°ÀȸÐØÀÐèÈØðàèøàðÿðøÿ 0 (@0@H0@`@PXPXp`px`px08H8@X8PXPPhPhx`xxpx�p€˜8X`HX`P`p`pxpx€p€˜€�¨� ¨PXp`pp`p€px—Àðÿ� ¨�˜¸¨°Àhx‡h€—€�—Ðøÿàøÿàÿÿ °ȰÀÈx€�ˆ�§ÐÿÿØÿÿèÿÿÿÿÿÿÿÿÀØ萘¨  °¨§¿ðÿÿÿÿÿÿÿÿÐèèàè𨰸°ÀȸÐØÀÐèÿÿÿàèøàèÿðøÿ \ No newline at end of file diff --git a/wai/staged-conformance/index.json b/wai/staged-conformance/index.json index a73311241..a210b272e 100644 --- a/wai/staged-conformance/index.json +++ b/wai/staged-conformance/index.json @@ -319,6 +319,13 @@ "b139902d21228247e2a7901db2077ca0e346c6f34c398b62be71d4516a1dcdf5" ] }, + { + "name": "base_over_budget", + "about": "a chain budget one byte short of the base's units: the base is not added (chain-over-limit, §4.5), and the layer held for it stays held until the bound (base-absent)", + "clip_sha256": [ + null + ] + }, { "name": "chain_budget_retained", "about": "a budget that holds the base and either layer, not both: layer 2, held, is retained, so layer 1 is chain-over-limit; once layer 2 expires and is dropped, layer 1 applies", diff --git a/wai/staged-conformance/staged_verify.py b/wai/staged-conformance/staged_verify.py index 350903489..f2c0efa8f 100644 --- a/wai/staged-conformance/staged_verify.py +++ b/wai/staged-conformance/staged_verify.py @@ -893,6 +893,18 @@ class Sink: if info["attested"]: self.bases[info["hash"]].info["attested"] = True return [] + # §4.5: a chain starts with its base. Its units' output, and 32 bytes for each + # stage the record kept of it holds, past the budget: the base is not added, the + # record is left as it was, and its layers stay held. A receipt verified with it + # is recorded all the same (§10). + k0 = self.kept.get(info["hash"]) + recorded = sum(32 * reached for u, (_, reached) in k0["units"].items() if u < len(units)) if k0 else 0 + if recorded + sum(len(d) * (2 if e == "i16" else 1) for (_, e, d) in units) > self.cfg["budget"]: + if info["attested"]: + self.mark_attested(info["hash"]) + out = Outcome() + out.refuse(info["hash"], "chain-over-limit") + return out.reports() # Attestation belongs to the base hash: a base attested before stays attested. k = self.kept.pop(info["hash"], None) info = dict(info, attested=info["attested"] or (k is not None and k["attested"])) diff --git a/wai/wai-rs/src/bin/wai_cli/stage.rs b/wai/wai-rs/src/bin/wai_cli/stage.rs index b4163056c..6f89bb333 100644 --- a/wai/wai-rs/src/bin/wai_cli/stage.rs +++ b/wai/wai-rs/src/bin/wai_cli/stage.rs @@ -73,7 +73,8 @@ fn param_set(args: &[String]) -> Result>, String> { fn int_base(path: &str, env: &[u8], args: &[String]) -> Result<(BaseInfo, Vec), String> { let set = param_set(args)?; let files: BTreeMap = set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); - wai::staged::decode_base(env, &files).map_err(|e| format!("{path}: {e}")) + // No cost limit and no key log: a tool over files its user chose (check 5 skipped). + wai::staged::decode_base(env, &files, None, None).map_err(|e| format!("{path}: {e}")) } /// The base's info and units, decoded as the module doc states. @@ -190,76 +191,132 @@ fn print_report(file: &str, r: &OfferReport) { println!(); } -/// `wai stage apply … [--json]`: a sink that decodes the base, -/// then takes each envelope in order: one with `refines` is a layer, offered at the -/// next millisecond; any other is another base, decoded against the same `--set` and -/// added, which releases the layers held for it (§4.4). Prints every report and the -/// units at the end; with `--json`, one JSON document instead: every step's reports -/// in the corpus's form and the units whose stage, producer or frozen flag it changed -/// (a base's step lists all its units), then every base's units and clip hash. Every -/// stage hash a delivery in this order reaches is in it, for a sink elsewhere to check -/// its own against. +/// A traced application as the JSON trace lists a unit: its base, index, stage, +/// producer, frozen flag, tier, shape and stage hash. +fn applied_json(base: &[u8; 32], a: &wai::staged::Applied) -> Value { + json!({ + "base": hex(base), + "unit": a.unit, + "stage": a.stage, + "producer": hex(&a.producer), + "frozen": a.frozen, + "tier": tier::token(a.tier), + "shape": [a.shape.0, a.shape.1, a.shape.2], + "stage_sha256": a.sha256.map(|h| hex(&h)), + }) +} + +/// The base's units at stage 0, as the JSON trace lists them. +fn stage0_json(info: &BaseInfo, units: &[UnitBuf]) -> Vec { + let de = info.tier == Some(wai::codecs::DeterminismTier::DecodeEquivalence); + units + .iter() + .enumerate() + .map(|(u, b)| json!({ + "base": hex(&info.content_hash), + "unit": u, + "stage": 0, + "producer": hex(&info.content_hash), + "frozen": false, + "tier": tier::token(info.tier), + "shape": [b.shape.0, b.shape.1, b.shape.2], + "stage_sha256": de.then(|| hex(&wai::pin::sha256(&b.samples.canonical_bytes()))), + })) + .collect() +} + +/// `wai stage apply … [--json]`: a sink that takes each envelope in order +/// (SPEC §4 and staged-delivery §4). An envelope that carries `refines`, or whose +/// capability is refinement-only, is a layer, offered at the next millisecond; any +/// other is a base, decoded by SPEC §4 (its capability, then its fallback) against +/// `--set` and added, which releases the layers held for it (§4.4). With `--stage0`, +/// the first envelope is a base read from that file. Bytes that are not a `WAI1` this +/// sink reads are that step's error (`not-wai1`, `encrypted`), and the replay goes on. +/// +/// Prints every report and the units at the end. With `--json`, one JSON document +/// instead: for each step its reports in the corpus's form (or its error), then every +/// stage a unit reached in that step, in the order the sink applied the layers (a +/// base's step starts with its units at stage 0, and a batch that takes a unit up +/// several stages lists each), and the units whose frozen flag the step changed; then +/// every base's units and clip hash. Every stage hash a delivery in this order reaches +/// is in it, for a sink elsewhere to check its own against. fn apply(args: &[String]) -> Result<(), String> { let pos = positional(args); let as_json = args.iter().any(|a| a == "--json"); - let first = *pos.first().ok_or("wai stage apply … [--json]")?; - let (_, info, units) = base(first, args)?; + if pos.is_empty() { + return Err("wai stage apply … [--json]".into()); + } let mut sink = StageSink::new(cfg()); + sink.set_trace(true); let mut bases: Vec<[u8; 32]> = Vec::new(); - let mut seen: BTreeMap<([u8; 32], u32), (u8, [u8; 32], bool)> = BTreeMap::new(); + let mut frozen: BTreeMap<([u8; 32], u32), bool> = BTreeMap::new(); let mut steps: Vec = Vec::new(); - let mut pending = Some((info, units)); for (i, path) in pos.iter().enumerate() { - let bytes = if i == 0 { Vec::new() } else { read(path)? }; - let (op, object, reports) = match pending.take() { - Some((info, units)) => { - let h = info.content_hash; - bases.push(h); - ("base", h, sink.add_base(info, units)) - } - None => { - let w = Wai::from_bytes(&bytes).map_err(|e| format!("{path}: {e}"))?; - if w.manifest.refines.is_some() { - let got = sink.offer(&bytes, i as u64, ReceiptEvidence::None).map_err(|e| format!("{path}: {e}"))?; - ("offer", wai::jwp_hash::content_hash(&bytes), got) - } else { - if opt(args, "--stage0").is_some() { + let (op, object, outcome, mut units): (&str, [u8; 32], Result, &str>, Vec) = if i == 0 && opt(args, "--stage0").is_some() { + let (env, info, units) = base(path, args)?; + let first = stage0_json(&info, &units); + bases.push(info.content_hash); + ("base", wai::jwp_hash::content_hash(&env), Ok(sink.add_base(info, units)), first) + } else { + let bytes = read(path)?; + let object = wai::jwp_hash::content_hash(&bytes); + match Wai::from_bytes(&bytes) { + Err(_) => ("offer", object, Err("not-wai1"), vec![]), + Ok(w) if w.manifest.refines.is_some() || wai::codecs::is_refinement_only(w.capability()) => { + match sink.offer(&bytes, i as u64, ReceiptEvidence::None) { + Ok(r) => ("offer", object, Ok(r), vec![]), + Err(wai::staged::OfferError::NotWai1(_)) => ("offer", object, Err("not-wai1"), vec![]), + Err(_) => ("offer", object, Err("encrypted"), vec![]), + } + } + Ok(_) => { + if i > 0 && opt(args, "--stage0").is_some() { return Err(format!("{path}: a second base; --stage0 reads the first base only")); } let (info, units) = int_base(path, &bytes, args)?; - let h = info.content_hash; - if !bases.contains(&h) { - bases.push(h); + let first = if sink.chain(&info.content_hash).is_some() { vec![] } else { stage0_json(&info, &units) }; + if !bases.contains(&info.content_hash) { + bases.push(info.content_hash); } - ("base", h, sink.add_base(info, units)) + let r = sink.add_base(info, units); + // A base refused (chain-over-limit) has no stage 0. + let first = if sink.chain(&object).is_some() { first } else { vec![] }; + ("base", object, Ok(r), first) } } }; - let mut changed = Vec::new(); + units.extend(sink.take_trace().iter().map(|(b, a)| applied_json(b, a))); for b in &bases { let Some(c) = sink.chain(b) else { continue }; for u in 0..c.n_units() { - let s = c.unit(u).expect("in range"); - let now = (s.stage, s.producer, s.frozen); - if seen.insert((*b, u), now) != Some(now) { - changed.push(unit_json(c, u)); + let f = c.unit(u).expect("in range").frozen; + if frozen.insert((*b, u), f).unwrap_or(false) != f { + units.push(unit_json(c, u)); } } } if as_json { - steps.push(json!({ - "file": path, - "op": op, - "object": hex(&object), - "reports": reports.iter().map(report_json).collect::>(), - "units": changed, - })); - } else { - if op == "base" { - println!("{path}: base {} decoded", &hex(&object)[..16]); + let mut step = json!({"file": path, "op": op, "object": hex(&object)}); + match &outcome { + Ok(r) => step["reports"] = json!(r.iter().map(report_json).collect::>()), + Err(e) => { + step["error"] = json!(e); + step["reports"] = json!([]); + } } - for r in &reports { - print_report(path, r); + step["units"] = Value::Array(units); + steps.push(step); + } else { + match &outcome { + Err(e) => println!("{path}: {} {e}", &hex(&object)[..16]), + Ok(r) => { + if op == "base" { + println!("{path}: base {} decoded", &hex(&object)[..16]); + } + for x in r { + print_report(path, x); + } + } } } } @@ -270,6 +327,7 @@ fn apply(args: &[String]) -> Result<(), String> { .map(|c| json!({ "object": hex(&c.base().content_hash), "capability": c.base().capability, + "via_fallback": c.base().via_fallback, "units": (0..c.n_units()).map(|u| unit_json(c, u)).collect::>(), "clip_sha256": c.clip_sha256().map(|h| hex(&h)), })) @@ -300,16 +358,17 @@ fn base_cmd(args: &[String]) -> Result<(), String> { let (n, h, w) = dims(opt(args, "--dims").ok_or("--dims N,H,W")?)?; let q: u32 = opt(args, "--q").ok_or("--q Q")?.parse().map_err(|e| format!("--q: {e}"))?; let src = read(opt(args, "--frames").ok_or("--frames ")?)?; - let per = h as usize * w as usize * 3; - if n == 0 || per == 0 || src.len() != n as usize * per { - return Err(format!("--frames holds {} bytes; {n} frames of {h}x{w} RGB8 need {}", src.len(), n as usize * per)); + let per = (h as usize).checked_mul(w as usize).and_then(|x| x.checked_mul(3)).ok_or("--dims: H × W × 3 overflows")?; + let need = (n as usize).checked_mul(per).ok_or("--dims: N × H × W × 3 overflows")?; + if n == 0 || per == 0 || src.len() != need { + return Err(format!("--frames holds {} bytes; {n} frames of {h}x{w} RGB8 need {need}", src.len())); } let frames: Vec> = src.chunks_exact(per).map(<[u8]>::to_vec).collect(); let (payload, recon) = wai::int_payload::encode_wiv1_zero_motion(&frames, h, w, q).map_err(|e| e.to_string())?; let cap = wai::codecs::CAP_VIDEO_INT_MOTION; let m = wai::Manifest::new("video", "replicate", wai::ModelRequirement::new(cap), wai::Conditioning::new("int_motion")); let bytes = Wai::new(m, payload).to_emit_bytes().map_err(|e| e.to_string())?; - let (info, units) = wai::staged::decode_base(&bytes, &BTreeMap::new()).map_err(|e| e.to_string())?; + let (info, units) = wai::staged::decode_base(&bytes, &BTreeMap::new(), None, None).map_err(|e| e.to_string())?; if units.len() != recon.len() || units.iter().zip(&recon).any(|(u, r)| u.samples != Samples::U8(r.clone())) { return Err("the base does not decode to the encoder's reconstruction".into()); } @@ -519,8 +578,9 @@ pub fn main(args: &[String]) -> Result<(), String> { pub const USAGE: &str = "wai stage refine --base [--on ]… --target --q N \ [--predictor identity|up2|rate:NUM/DEN] [--span F:C] [--out-dims D0,D1,D2] [--max-symbols N] [--table-per-unit] -o L.wai -wai stage apply … [--json] offer the layers in order (another base is decoded and - added); print each report and the units, or a JSON trace +wai stage apply … [--json] take each envelope in order: a layer is offered, a base + decoded (SPEC §4) and added; print each report and the + units, or a JSON trace of every stage reached wai stage base --frames --dims N,H,W --q Q -o base.wai a wai.video.int_motion base (WIV1, keyframe 0, zero motion) of N frames of HxW RGB8 wai stage inspect a layer's refines, WIR1 header and declared cost diff --git a/wai/wai-rs/src/bin/wai_stage_bench.rs b/wai/wai-rs/src/bin/wai_stage_bench.rs index 64bda3b8c..1a6ee8573 100644 --- a/wai/wai-rs/src/bin/wai_stage_bench.rs +++ b/wai/wai-rs/src/bin/wai_stage_bench.rs @@ -7,8 +7,11 @@ //! `staged::decode_base` against the parameter set in `--set` (none for //! `wai.video.int_motion`), and their refinement layers. Stage 0 is every base decoded; //! stage `k` is every layer whose `refines.layer` is `k` applied, in the order given. -//! `--source` holds what the units approximate: their canonical buffers concatenated, -//! base by base in the order given and unit by unit, as `stage0.bin` is laid out. +//! `--source` holds what the units approximate, at their final shape: each unit's +//! canonical buffer as the last stage shapes it, base by base in the order given and +//! unit by unit. A unit whose stage holds another number of samples (below an up2 or a +//! rate layer) has no quality figure at that stage, and the clip's figure counts the +//! units that have one (`units_measured`). //! //! For each stage it reports: //! - **bytes**: the envelopes' bytes as delivered, split into `WAI1` framing, manifest @@ -176,76 +179,98 @@ fn run(args: &[String]) -> Result<(), String> { return Err("no base among the envelopes".into()); } - // The bases as decoded, and each base's slice of the source. + // A replay, untimed: every stage's units, each base's in order. Every layer must + // apply whole: the bench measures a set delivered whole and in order. let mut bases: Vec<(BaseInfo, Vec)> = Vec::new(); for e in envs.iter().filter(|e| e.stage == 0) { - bases.push(wai::staged::decode_base(&e.bytes, &files).map_err(|x| format!("{}: {x}", e.file))?); + bases.push(wai::staged::decode_base(&e.bytes, &files, None, None).map_err(|x| format!("{}: {x}", e.file))?); } + let hashes: Vec<[u8; 32]> = bases.iter().map(|(i, _)| i.content_hash).collect(); + // stages[k][b][u]: the unit's samples, stage and stage hash at stage k. + let mut stages_seen: Vec)>>> = Vec::new(); + { + let mut sink = StageSink::new(cfg()); + for (info, units) in &bases { + sink.add_base(info.clone(), units.clone()); + } + let snap = |sink: &StageSink| -> Vec)>> { + hashes.iter().map(|h| { + let c = sink.chain(h).expect("added"); + c.units().iter().enumerate().map(|(u, s)| (s.buf.samples.clone(), s.stage, c.unit_sha256(u as u32))).collect() + }).collect() + }; + stages_seen.push(snap(&sink)); + for k in 1..=n_stages { + for (i, e) in envs.iter().enumerate().filter(|(_, e)| e.stage == k) { + let reports = sink.offer(&e.bytes, i as u64, ReceiptEvidence::None).map_err(|x| format!("{}: {x}", e.file))?; + if let Some(bad) = reports.iter().find(|x| x.envelope.is_some() || !x.refused.is_empty() || !x.held.is_empty() || x.held_for_base) { + return Err(format!("{}: not applied whole ({:?}); the bench measures a set delivered whole and in order", e.file, bad)); + } + } + stages_seen.push(snap(&sink)); + } + } + + // The source, at the units' final shape: each unit's canonical buffer at the last + // stage, base by base and unit by unit. A stage whose unit holds another number of + // samples (below an up2 or rate layer) has no quality figure for that unit. + let last = stages_seen.last().expect("stage 0"); let mut src_of: Vec> = Vec::new(); let mut at = 0usize; - for (_, units) in &bases { + for units in last { let mut v = Vec::new(); - for u in units { - let n = u.samples.byte_len() as usize; - v.push(source.get(at..at + n).ok_or("--source is shorter than the bases' units")?); + for (samples, ..) in units { + let n = samples.byte_len() as usize; + v.push(source.get(at..at + n).ok_or("--source is shorter than the units at their last stage")?); at += n; } src_of.push(v); } if at != source.len() { - return Err(format!("--source holds {} bytes; the bases' units hold {at}", source.len())); + return Err(format!("--source holds {} bytes; the units at their last stage hold {at}", source.len())); } let pcm = matches!(bases[0].1[0].samples, Samples::I16(_)); - - // Timed replays: the first also records every stage's units. - let mut times: Vec> = vec![Vec::new(); n_stages as usize + 1]; let mut snapshots: Vec> = vec![Vec::new(); n_stages as usize + 1]; - let mut clip: Vec<(u64, u64, u64)> = vec![(0, 0, 0); n_stages as usize + 1]; - for r in 0..runs { - let mut sink = StageSink::new(cfg()); - let t = Instant::now(); - let mut hashes = Vec::new(); - for e in envs.iter().filter(|e| e.stage == 0) { - let (info, units) = wai::staged::decode_base(&e.bytes, &files).map_err(|x| format!("{}: {x}", e.file))?; - hashes.push(info.content_hash); - sink.add_base(info, units); - } - times[0].push(t.elapsed().as_secs_f64() * 1e6); - let snap = |sink: &StageSink, k: usize, snapshots: &mut Vec>, clip: &mut Vec<(u64, u64, u64)>| { - for (b, h) in hashes.iter().enumerate() { - let c = sink.chain(h).expect("added"); - for u in 0..c.n_units() { - let s = c.unit(u).expect("in range"); - let (err, n, energy) = sse(&s.buf.samples, src_of[b][u as usize]); + let mut clip: Vec<(u64, u64, u64, usize)> = vec![(0, 0, 0, 0); n_stages as usize + 1]; + for (k, per_base) in stages_seen.iter().enumerate() { + for (b, units) in per_base.iter().enumerate() { + for (u, (samples, stage, sha)) in units.iter().enumerate() { + let src = src_of[b][u]; + let mut q = if samples.byte_len() as usize == src.len() { + let (err, n, energy) = sse(samples, src); clip[k].0 += err; clip[k].1 += n; clip[k].2 += energy; - let mut q = quality_json(err, n, energy, pcm); - q["base"] = json!(hex(h)); - q["unit"] = json!(u); - q["stage"] = json!(s.stage); - q["stage_sha256"] = json!(c.unit_sha256(u).map(|x| hex(&x))); - snapshots[k].push(q); - } + clip[k].3 += 1; + quality_json(err, n, energy, pcm) + } else { + json!({"sse": null, "samples": null, "exact": null, "why": "the unit's shape at this stage is not the source's"}) + }; + q["base"] = json!(hex(&hashes[b])); + q["unit"] = json!(u); + q["stage"] = json!(stage); + q["stage_sha256"] = json!(sha.map(|x| hex(&x))); + snapshots[k].push(q); } - }; - if r == 0 { - snap(&sink, 0, &mut snapshots, &mut clip); } + } + + // Timed replays. + let mut times: Vec> = vec![Vec::new(); n_stages as usize + 1]; + for _ in 0..runs { + let mut sink = StageSink::new(cfg()); + let t = Instant::now(); + for e in envs.iter().filter(|e| e.stage == 0) { + let (info, units) = wai::staged::decode_base(&e.bytes, &files, None, None).map_err(|x| format!("{}: {x}", e.file))?; + sink.add_base(info, units); + } + times[0].push(t.elapsed().as_secs_f64() * 1e6); for k in 1..=n_stages { let t = Instant::now(); for (i, e) in envs.iter().enumerate().filter(|(_, e)| e.stage == k) { - let reports = sink.offer(&e.bytes, i as u64, ReceiptEvidence::None).map_err(|x| format!("{}: {x}", e.file))?; - if r == 0 { - if let Some(bad) = reports.iter().find(|x| x.envelope.is_some() || !x.refused.is_empty() || !x.held.is_empty() || x.held_for_base) { - return Err(format!("{}: not applied whole ({:?}); the bench measures a set delivered whole and in order", e.file, bad)); - } - } + sink.offer(&e.bytes, i as u64, ReceiptEvidence::None).map_err(|x| format!("{}: {x}", e.file))?; } times[k as usize].push(t.elapsed().as_secs_f64() * 1e6); - if r == 0 { - snap(&sink, k as usize, &mut snapshots, &mut clip); - } } } @@ -262,6 +287,11 @@ fn run(args: &[String]) -> Result<(), String> { if units.iter().any(|u| u.shape != shape) { return Ok(json!({"q": q, "why": "a base's units differ in shape"})); } + // The target is the source at the units' final shape; an identity layer + // from stage 0 reaches it only where stage 0 has that shape. + if units.iter().zip(&src_of[b]).any(|(u, s)| u.samples.byte_len() as usize != s.len()) { + return Ok(json!({"q": q, "why": "the units change shape between stage 0 and the source; an identity layer from stage 0 cannot reach it"})); + } let belows: Vec = units.iter().map(|u| below(&u.samples)).collect(); let targets: Vec = src_of[b] .iter() @@ -299,7 +329,9 @@ fn run(args: &[String]) -> Result<(), String> { let es: Vec<&Env> = envs.iter().filter(|e| e.stage == k).collect(); let bytes: u64 = es.iter().map(|e| e.bytes.len() as u64).sum(); cumulative += bytes; - let (err, n, energy) = clip[k as usize]; + let (err, n, energy, measured) = clip[k as usize]; + let mut quality = if measured == 0 { json!({"sse": null, "samples": null, "exact": null, "why": "no unit has the source's shape at this stage"}) } else { quality_json(err, n, energy, pcm) }; + quality["units_measured"] = json!(measured); stages.push(json!({ "stage": k, "envelopes": es.len(), @@ -308,7 +340,7 @@ fn run(args: &[String]) -> Result<(), String> { "manifest_bytes": es.iter().map(|e| e.manifest as u64).sum::(), "payload_bytes": es.iter().map(|e| e.payload as u64).sum::(), "cumulative_bytes": cumulative, - "clip": quality_json(err, n, energy, pcm), + "clip": quality, "time_us": quantiles(times[k as usize].clone()), "direct": if k == 0 { Value::Null } else { direct(k)? }, "units": snapshots[k as usize], diff --git a/wai/wai-rs/src/bin/wai_staged_vectors.rs b/wai/wai-rs/src/bin/wai_staged_vectors.rs index 3fc383904..0d18cf8a4 100644 --- a/wai/wai-rs/src/bin/wai_staged_vectors.rs +++ b/wai/wai-rs/src/bin/wai_staged_vectors.rs @@ -1091,6 +1091,14 @@ fn cases(ctx: &Ctx) -> Vec { out.push(Case { name: "chain_over_limit", about: "a chain budget one byte short of the base's units and the layer's payload, declared output and working bytes and 32 bytes a unit: chain-over-limit, before any stream is decoded", bases: vec![a], files: vec![("L1.wai".into(), l1.clone())], cfg: ChainConfig { budget: state0 + reserve(&l1) - 1, ..cfg() }, steps: vec![base_at(0, 0), offer("L1.wai", 1)] }); } + { + let a = small(141); + let (l1, _) = l1_of(&a, 142, 4); + let state0: u64 = a.units.iter().map(|u| u.samples.byte_len()).sum(); + out.push(Case { name: "base_over_budget", about: "a chain budget one byte short of the base's units: the base is not added (chain-over-limit, §4.5), and the layer held for it stays held until the bound (base-absent)", bases: vec![a], + files: vec![("L1.wai".into(), l1)], cfg: ChainConfig { budget: state0 - 1, ..cfg() }, + steps: vec![offer("L1.wai", 0), base_at(0, 1), Step::Expire { at_ms: 1001 }] }); + } { let a = small(117); let (l1, mid) = l1_of(&a, 118, 4); diff --git a/wai/wai-rs/src/int_payload/mod.rs b/wai/wai-rs/src/int_payload/mod.rs index c2742c3bf..ffd49ffb3 100644 --- a/wai/wai-rs/src/int_payload/mod.rs +++ b/wai/wai-rs/src/int_payload/mod.rs @@ -851,7 +851,9 @@ fn round_div(n: i64, d: u64) -> i64 { /// frame as `decode_int` gives it. /// /// A reference encoder for the format, not a rate-distortion encoder: it searches no -/// motion. It writes a closed group of pictures, since frame 0 refers to nothing. +/// motion. It writes a closed group of pictures, since frame 0 refers to nothing. A +/// clip past the format's caps (`MAX_TENSOR` elements in a frame, `MAX_FRAMES` frames, +/// `MAX_CLIP` elements) is refused `too_large` before anything is allocated. #[cfg(feature = "video_int")] pub fn encode_wiv1_zero_motion(frames: &[Vec], h: u32, w: u32, q: u32) -> Result<(Vec, Vec>), IntPayloadError> { let field = |why: &str| IntPayloadError::new(Refusal::FieldRange, why.to_string()); @@ -861,8 +863,17 @@ pub fn encode_wiv1_zero_motion(frames: &[Vec], h: u32, w: u32, q: u32) -> Re if frames.is_empty() || h == 0 || w == 0 { return Err(field("a clip needs a frame of at least one pixel")); } - let n = u32::try_from(frames.len()).map_err(|_| field("more frames than u32 counts"))?; - let len = (h as u64).checked_mul(w as u64).and_then(|p| p.checked_mul(3)).ok_or_else(|| field("h × w × 3 overflows"))?; + // The format's caps, checked before anything is allocated: a frame of at most + // MAX_TENSOR elements, at most MAX_FRAMES frames and MAX_CLIP elements in the clip. + let too_large = |why: &str| IntPayloadError::new(Refusal::TooLarge, why.to_string()); + let len = (h as u64).checked_mul(w as u64).and_then(|p| p.checked_mul(3)).filter(|&l| l <= wire::MAX_TENSOR).ok_or_else(|| too_large("h × w × 3 exceeds the tensor cap"))?; + if frames.len() as u64 > wire::MAX_FRAMES { + return Err(too_large("more frames than the frame cap")); + } + if (frames.len() as u64).checked_mul(len).is_none_or(|c| c > wire::MAX_CLIP) { + return Err(too_large("the clip exceeds the clip cap")); + } + let n = frames.len() as u32; if frames.iter().any(|f| f.len() as u64 != len) { return Err(field("a frame is not h × w × 3 bytes")); } diff --git a/wai/wai-rs/src/int_payload/tests.rs b/wai/wai-rs/src/int_payload/tests.rs index 301bc1995..4b124310d 100644 --- a/wai/wai-rs/src/int_payload/tests.rs +++ b/wai/wai-rs/src/int_payload/tests.rs @@ -815,4 +815,14 @@ fn encode_wiv1_zero_motion_round_trips() { assert_eq!(code(encode_wiv1_zero_motion(&frames, h, w, 0)), "field_range"); assert_eq!(code(encode_wiv1_zero_motion(&[], h, w, 4)), "field_range"); assert_eq!(code(encode_wiv1_zero_motion(&frames, h, w + 1, 4)), "field_range"); + // The caps, before anything is read or allocated: a frame past the tensor cap (its + // bytes are never looked at), more frames than the frame cap, a clip past the clip + // cap, and dimensions whose product overflows. + let one = vec![vec![0u8; 3]]; + assert_eq!(code(encode_wiv1_zero_motion(&one, 4097, 5462, 4)), "too_large"); + assert_eq!(code(encode_wiv1_zero_motion(&one, u32::MAX, u32::MAX, 4)), "too_large"); + let many = vec![Vec::new(); (wire::MAX_FRAMES + 1) as usize]; + assert_eq!(code(encode_wiv1_zero_motion(&many, 1, 1, 4)), "too_large"); + let clip = vec![Vec::new(); 17]; + assert_eq!(code(encode_wiv1_zero_motion(&clip, 4096, 5461, 4)), "too_large", "17 frames of a frame within the tensor cap pass the clip cap"); } diff --git a/wai/wai-rs/src/staged/mod.rs b/wai/wai-rs/src/staged/mod.rs index 0862d29c9..248ae66f1 100644 --- a/wai/wai-rs/src/staged/mod.rs +++ b/wai/wai-rs/src/staged/mod.rs @@ -58,19 +58,28 @@ pub enum BaseError { NotWai1(String), /// The manifest declares an encryption this sink cannot open (SPEC §6.4). Encrypted, - /// The envelope's pin, or the parameter set the sink holds, was refused (SPEC §3.1). + /// The envelope's pin, or the parameter set the sink holds, was refused (SPEC §3.1), + /// check 5 included where the sink gave a key log. Pin(crate::container::PinRefusal), - /// The integer decode refused the payload (`extensions/integer-payloads.md`). + /// The integer decode refused the payload (`extensions/integer-payloads.md`), or + /// its declared cost passes the sink's limit (`cost_over_limit`), before any symbol + /// was decoded. Decode(crate::int_payload::IntPayloadError), /// The decode gave an output with no units. NoUnits, /// The capability asked for is neither the manifest's capability nor its fallback. NotOffered(String), + /// SPEC §4 takes no capability for this envelope here (`missing_capability`): its + /// capability is refinement-only or companion-only, so the envelope is inert and + /// its fallback never reached, or neither its capability nor its fallback is one + /// this sink decodes through the integer path. + Inert, } impl BaseError { /// A short token for the error: `not-wai1`, `encrypted`, the pin refusal's code, - /// the payload refusal's code, or `no-units`. + /// the payload refusal's code (`cost_over_limit` among them), `no-units`, + /// `not-offered` or `missing_capability`. pub fn code(&self) -> &'static str { match self { BaseError::NotWai1(_) => "not-wai1", @@ -79,6 +88,7 @@ impl BaseError { BaseError::Decode(e) => e.code(), BaseError::NoUnits => "no-units", BaseError::NotOffered(_) => "not-offered", + BaseError::Inert => "missing_capability", } } } @@ -92,64 +102,178 @@ impl std::fmt::Display for BaseError { BaseError::Decode(e) => write!(f, "{e}"), BaseError::NoUnits => f.write_str("no-units: the decode gave an output with no units"), BaseError::NotOffered(c) => write!(f, "not-offered: {c} is neither the manifest's capability nor its fallback"), + BaseError::Inert => f.write_str( + "missing_capability: SPEC §4 takes no capability for this envelope here (a refinement-only or \ + companion-only capability, or none this sink decodes through the integer path)", + ), } } } impl std::error::Error for BaseError {} -/// Decode a base envelope for a sink (§2), through `int_payload::decode_int`: its -/// capability's parameter set is `set` (each file by the name SPEC §5 gives it; empty -/// for a capability that reads none), and the envelope's pin, where it carries one, -/// is verified against those bytes (SPEC §3.1). The base's info states its content -/// hash, the capability it was decoded through, and its tier (§5, -/// [`tier::base_tier`]: the pin counts only where it resolved); it is not attested, -/// not decoded through a fallback, and its unit 0 starts the live clock. The units are -/// [`units_from_decoded`]'s. -/// -/// A sink that decodes a base some other way (a codec this module does not run) -/// builds its [`BaseInfo`] and units itself. -pub fn decode_base(envelope: &[u8], set: &BTreeMap) -> Result<(BaseInfo, Vec), BaseError> { - decode_base_via(envelope, None, set) +/// Whether `cap` decodes through `int_payload::decode_int`: the capabilities SPEC §5 +/// pins an integer payload format to, and `wai.neural.int_synth` and +/// `wai.neural.int_mlicpp`. What a base decode advertises to SPEC §4. +fn int_path(cap: &str) -> bool { + crate::codecs::pinned_int_payload(cap).is_some() || matches!(cap, crate::codecs::CAP_NEURAL_INT_SYNTH | crate::codecs::CAP_NEURAL_INT_MLICPP) } -/// [`decode_base`] through the capability the sink chose by SPEC §4: the manifest's -/// capability (`None`, or that string), or its fallback, which the base's info then -/// records as decoded through a fallback (§4.1 step 3 refuses its layers). -pub fn decode_base_via(envelope: &[u8], via: Option<&str>, set: &BTreeMap) -> Result<(BaseInfo, Vec), BaseError> { +/// The sets a sink holding `set` offers a decode through `cap` (SPEC §3.1): for a +/// capability whose set is one file, pinned without a form, every file as its own +/// candidate (the pin resolves when any one satisfies it), the member the capability +/// reads first; otherwise the files as one set, which an unpinned decode reads by its +/// members' names. +fn held_for<'a>(cap: &str, prior: Option<&crate::container::Prior>, set: &'a BTreeMap, listing: &'a [u8]) -> Vec> { use crate::pin::HeldBytes; - let w = Wai::from_bytes(envelope).map_err(|e| BaseError::NotWai1(e.to_string()))?; - if w.manifest.security.as_ref().is_some_and(|s| !s.is_plaintext()) { - return Err(BaseError::Encrypted); + if set.is_empty() { + return vec![]; } - let primary = w.capability().to_string(); - let cap = match via { - None => primary.clone(), - Some(c) if c == primary || w.manifest.model_requirement.fallback.as_deref() == Some(c) => c.to_string(), - Some(c) => return Err(BaseError::NotOffered(c.to_string())), - }; - let listing = if set.is_empty() { vec![] } else { crate::pin::listing_from(set).map_err(BaseError::Pin)? }; - let one_file = crate::container::pin_shape(&cap) == crate::codecs::ParameterSetShape::OneFile; - let held: Vec = if set.is_empty() { - vec![] - } else if one_file && w.prior().is_some_and(|p| p.form().is_none()) { - vec![HeldBytes::File(set.values().next().copied().unwrap_or_default())] + let one_file = crate::container::pin_shape(cap) == crate::codecs::ParameterSetShape::OneFile; + if one_file && prior.is_some_and(|p| p.form().is_none()) { + let member = crate::int_payload::set_members(cap).first().copied(); + let mut v: Vec = member.and_then(|m| set.get(m)).map(|b| HeldBytes::File(b)).into_iter().collect(); + v.extend(set.iter().filter(|(k, _)| Some(k.as_str()) != member).map(|(_, b)| HeldBytes::File(b))); + v } else { - vec![HeldBytes::Set { listing: &listing, files: set }] - }; - let params = crate::int_payload::resolve(&cap, w.prior(), &held, None).map_err(BaseError::Pin)?; - let pinned = params.is_pinned(); - let decoded = crate::int_payload::decode_int(&cap, &w.payload, ¶ms).map_err(BaseError::Decode)?; - let units = units_from_decoded(decoded).filter(|u| !u.is_empty()).ok_or(BaseError::NoUnits)?; - let info = BaseInfo { - content_hash: crate::jwp_hash::content_hash(envelope), - tier: tier::base_tier(&cap, if pinned { w.prior() } else { None }), - via_fallback: cap != primary, - capability: cap, - attested: false, - first_unit: 0, - }; - Ok((info, units)) + vec![HeldBytes::Set { listing, files: set }] + } +} + +/// Whether a refusal of the capability §4 picked makes it unsupported for this +/// envelope, so dispatch moves on to the fallback: no decoder in this build +/// (`unsupported_capability`), a set that lacks a member (`set_incomplete`), or a +/// declared cost past the sink's limit (`cost_over_limit`), each refused before any +/// symbol was decoded. Any other refusal is the payload's, and final. +fn routes_on(e: &crate::int_payload::IntPayloadError) -> bool { + use crate::int_payload::Refusal; + matches!(e.refusal(), Refusal::UnsupportedCapability | Refusal::SetIncomplete | Refusal::CostOverLimit) +} + +/// Decode a base envelope for a sink (§2) by SPEC §4: its capability first, then its +/// fallback, each taken only where [`crate::codecs::is_dispatchable`] accepts it, as +/// `Wai::dispatch_verified` takes them (a refinement-only or companion-only capability +/// makes the envelope inert, and its fallback is never reached), through +/// `int_payload::decode_int`. +/// +/// - `set` is the capability's parameter set, each file by the name SPEC §5 gives it +/// (empty for `wai.video.int_motion`, which reads none). The envelope's pin, where +/// it carries one, is verified against those bytes (SPEC §3.1). +/// - `limit` is the sink's cost limit: where given, the payload's declared cost +/// (`int_payload::check_cost`) is checked before any symbol is decoded or any +/// output allocated, and a payload past it is `cost_over_limit`. That refusal, like +/// a capability this build has no decoder for (`unsupported_capability`) or a set +/// that lacks a member (`set_incomplete`), makes the capability unsupported for this +/// envelope, and dispatch moves on to the fallback. With no limit, the decode is +/// bounded by the payload format's caps alone. +/// - `authority` is the sink's key log: where given, SPEC §3.1's check 5 runs over the +/// pin and over an unpinned decode's set (`prior_revoked`, `prior_not_authorised`). +/// `None` skips check 5: a sink that holds no key log (SPEC §4, *Module +/// authorisation*), such as the browser binding. +/// +/// The base's info states its content hash, the capability it was decoded through, +/// whether that was the fallback, and its tier (§5, [`tier::base_tier`]: the pin +/// counts only where it resolved); it is not attested, and its unit 0 starts the live +/// clock. When nothing decodes, the error is the first refusal kept, the capability's +/// before the fallback's, else `missing_capability`. +pub fn decode_base( + envelope: &[u8], + set: &BTreeMap, + limit: Option<&crate::int_payload::DeclaredCost>, + authority: Option<&dyn crate::pin::PinAuthority>, +) -> Result<(BaseInfo, Vec), BaseError> { + decode_base_via(envelope, None, set, limit, authority) +} + +/// [`decode_base`] at a sink that decodes through `via` alone: the manifest's +/// capability or its fallback, which §4 takes only where it would at a sink that +/// advertised nothing else (`missing_capability` otherwise, so a refinement-only or +/// companion-only capability never reaches its fallback). Any other string is +/// `not-offered`. `None` is [`decode_base`]. +pub fn decode_base_via( + envelope: &[u8], + via: Option<&str>, + set: &BTreeMap, + limit: Option<&crate::int_payload::DeclaredCost>, + authority: Option<&dyn crate::pin::PinAuthority>, +) -> Result<(BaseInfo, Vec), BaseError> { + use crate::container::Inert; + use crate::int_payload::{check_cost, decode_int, resolve}; + let w = Wai::from_bytes(envelope).map_err(|e| BaseError::NotWai1(e.to_string()))?; + let primary = w.capability().to_string(); + if let Some(c) = via { + if c != primary && w.fallback() != Some(c) { + return Err(BaseError::NotOffered(c.to_string())); + } + } + let listing = if set.is_empty() { vec![] } else { crate::pin::listing_from(set).map_err(BaseError::Pin)? }; + let mut excluded: Vec = Vec::new(); + // The first refusal that made a capability unsupported, with that capability. + let mut kept: Option<(String, BaseError)> = None; + loop { + let d = w.dispatch_verified( + |c| int_path(c) && via.is_none_or(|v| v == c) && !excluded.iter().any(|x| x == c), + |c, p| resolve(c, Some(p), &held_for(c, Some(p), set, &listing), authority), + ); + let (cap, params) = match d { + Ok(d) => { + let cap = d.capability.to_string(); + match d.verified { + Some(p) => (cap, p), + // Unpinned (no pin, or one that does not apply): the set the sink + // holds for the capability, with check 5 over what it reads. + None => match resolve(&cap, None, &held_for(&cap, None, set, &listing), authority) { + Ok(p) => (cap, p), + Err(r) => { + kept.get_or_insert((cap.clone(), BaseError::Pin(r))); + excluded.push(cap); + continue; + } + }, + } + } + Err(Inert::Encrypted { .. }) => return Err(BaseError::Encrypted), + // Step 6: the reason kept for the capability (step 3) before the one kept for + // the fallback (step 5). + Err(Inert::PinRefused { capability, refusal }) => { + return Err(match kept { + Some((c, e)) if c == primary || capability != primary => e, + _ => BaseError::Pin(refusal), + }) + } + Err(Inert::MissingCapability) => return Err(kept.map(|(_, e)| e).unwrap_or(BaseError::Inert)), + }; + if let Some(limit) = limit { + if let Err(e) = check_cost(&cap, &w.payload, ¶ms, limit) { + if routes_on(&e) { + kept.get_or_insert((cap.clone(), BaseError::Decode(e))); + excluded.push(cap); + continue; + } + return Err(BaseError::Decode(e)); + } + } + let decoded = match decode_int(&cap, &w.payload, ¶ms) { + Ok(d) => d, + Err(e) if routes_on(&e) => { + kept.get_or_insert((cap.clone(), BaseError::Decode(e))); + excluded.push(cap); + continue; + } + Err(e) => return Err(BaseError::Decode(e)), + }; + let units = units_from_decoded(decoded).filter(|u| !u.is_empty()).ok_or(BaseError::NoUnits)?; + let pinned = params.is_pinned(); + let info = BaseInfo { + content_hash: crate::jwp_hash::content_hash(envelope), + tier: tier::base_tier(&cap, if pinned { w.prior() } else { None }), + via_fallback: cap != primary, + capability: cap, + attested: false, + first_unit: 0, + }; + return Ok((info, units)); + } } /// Why a layer, or a layer for one unit, was not applied: the closed list of @@ -552,6 +676,22 @@ pub struct StageChain { /// The chain's state (§4.5), in bytes. state: u64, seq: u64, + /// Every application, when the sink traces them ([`StageSink::set_trace`]). + trace: Option>, +} + +/// One application of a layer to a unit, as a traced sink records it +/// ([`StageSink::set_trace`]): the unit's state just after it, with its stage hash where +/// the unit holds `decode-equivalence` (§5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Applied { + pub unit: u32, + pub stage: u8, + pub producer: [u8; 32], + pub frozen: bool, + pub tier: Option, + pub shape: (u32, u32, u32), + pub sha256: Option<[u8; 32]>, } fn hex(h: &[u8; 32]) -> String { @@ -596,6 +736,7 @@ impl StageChain { held_layers: 0, state, seq: 0, + trace: None, } } @@ -964,6 +1105,11 @@ impl StageChain { if !recorded { self.passed.remove(&u); } + if let Some(t) = self.trace.as_mut() { + let s = &self.units[u as usize]; + let sha256 = (s.tier == Some(DeterminismTier::DecodeEquivalence)).then(|| crate::pin::sha256(&s.buf.samples.canonical_bytes())); + t.push(Applied { unit: u, stage: s.stage, producer: s.producer, frozen: s.frozen, tier: s.tier, shape: s.buf.shape, sha256 }); + } Ok(()) } @@ -1206,6 +1352,8 @@ pub struct StageSink { kept_order: BTreeMap, kept_bytes: u64, tick: u64, + /// Whether every chain records its applications ([`Self::set_trace`]). + trace: bool, } /// What a sink keeps of one base it does not hold. @@ -1227,7 +1375,7 @@ impl StageSink { /// A sink holding no base, with the bound, cost limit, budget and schedule every /// chain it creates uses. pub fn new(cfg: ChainConfig) -> Self { - StageSink { cfg, chains: BTreeMap::new(), pending: vec![], kept: BTreeMap::new(), kept_order: BTreeMap::new(), kept_bytes: 0, tick: 0 } + StageSink { cfg, chains: BTreeMap::new(), pending: vec![], kept: BTreeMap::new(), kept_order: BTreeMap::new(), kept_bytes: 0, tick: 0, trace: false } } /// The chain of the base whose content hash is `base`. @@ -1235,12 +1383,42 @@ impl StageSink { self.chains.get(base) } + /// Record every application of a layer to a unit from now on, in every chain the + /// sink holds or creates, for [`Self::take_trace`]: a batch that takes a unit up + /// several stages in one offer or release records each stage it passes. Off by + /// default; a traced sink hashes each unit it applies a layer to. + pub fn set_trace(&mut self, on: bool) { + self.trace = on; + for c in self.chains.values_mut() { + if on { + c.trace.get_or_insert_with(Vec::new); + } else { + c.trace = None; + } + } + } + + /// The applications recorded since the last call, with their base's content hash: + /// chain by chain in base order, each in the order the sink applied them. + pub fn take_trace(&mut self) -> Vec<([u8; 32], Applied)> { + self.chains + .iter_mut() + .flat_map(|(h, c)| c.trace.as_mut().map(std::mem::take).unwrap_or_default().into_iter().map(move |a| (*h, a))) + .collect() + } + /// Record a decoded base and release the layers held for it, together (§4.4). A /// base the sink already holds is kept as it is: decoding it again does not reset /// its units, and reports nothing; if this decode comes with the base's delivery /// receipt verified, the base is attested from now on (§10), and never the reverse. /// A base the sink attested before, held, dropped or not yet decoded, is attested /// whatever `base.attested` says. + /// + /// A base whose chain would start past the chain budget (§4.5: its units' output and + /// 32 bytes for each stage the records the sink keeps of it hold) is not added: the + /// one report is the base envelope's, refused `chain-over-limit`; the sink keeps no + /// chain for it, the records it keeps of it are untouched, and the layers held for + /// it stay held (§4.3). A receipt verified with it is still recorded (§10). pub fn add_base(&mut self, mut base: BaseInfo, units: Vec) -> Vec { let hash = base.content_hash; if let Some(c) = self.chains.get_mut(&hash) { @@ -1249,10 +1427,23 @@ impl StageSink { } return vec![]; } + let recorded: u64 = self.kept.get(&hash).map_or(0, |k| { + k.records.iter().filter(|(u, _)| (*u as usize) < units.len()).map(|(_, r)| 32 * r.producers.len() as u64).sum() + }); + let state = units.iter().fold(recorded, |s, u| s.saturating_add(u.samples.byte_len())); + if state > self.cfg.budget { + if base.attested { + self.mark_attested(&hash); + } + return vec![OfferReport::refuse(hash, StageRefusal::ChainOverLimit, None)]; + } let kept = self.take_kept(&hash).unwrap_or_default(); base.attested |= kept.attested; let records = kept.records; let mut chain = StageChain::with_records(base, units, self.cfg.clone(), records); + if self.trace { + chain.trace = Some(Vec::new()); + } let hexed = hex(&hash); let (mine, rest): (Vec, Vec) = std::mem::take(&mut self.pending).into_iter().partition(|p| p.refines.base == hexed); self.pending = rest; diff --git a/wai/wai-rs/src/staged/tests.rs b/wai/wai-rs/src/staged/tests.rs index 56feeed31..4ac405ebf 100644 --- a/wai/wai-rs/src/staged/tests.rs +++ b/wai/wai-rs/src/staged/tests.rs @@ -1556,37 +1556,178 @@ fn decode_base_reads_the_pin_and_the_corpus_units() { let set: BTreeMap = set_bytes.iter().map(|(m, b)| (m.clone(), b.as_slice())).collect(); let env = rd("staged-conformance/cases/image_int_hyper_2l/base.wai"); - let (info, units) = decode_base(&env, &set).expect("the pinned base decodes"); + let (info, units) = decode_base(&env, &set, None, None).expect("the pinned base decodes"); assert_eq!(info.tier, Some(DeterminismTier::DecodeEquivalence)); assert_eq!(info.capability, crate::codecs::CAP_NEURAL_INT_HYPER); assert_eq!(info.content_hash, crate::jwp_hash::content_hash(&env)); assert!(!info.via_fallback && !info.attested && info.first_unit == 0); assert_eq!(stage0(&units), rd("staged-conformance/cases/image_int_hyper_2l/stage0.bin")); - assert!(matches!(decode_base(&env, &BTreeMap::new()), Err(BaseError::Pin(_))), "no set held"); + assert!(matches!(decode_base(&env, &BTreeMap::new(), None, None), Err(BaseError::Pin(_))), "no set held"); let mut altered = set_bytes.clone(); altered[1].1[0] ^= 1; let altered: BTreeMap = altered.iter().map(|(m, b)| (m.clone(), b.as_slice())).collect(); - assert!(matches!(decode_base(&env, &altered), Err(BaseError::Pin(_))), "an altered set"); + assert!(matches!(decode_base(&env, &altered, None, None), Err(BaseError::Pin(_))), "an altered set"); - let (info, _) = decode_base(&rd("staged-conformance/cases/image_int_hyper_unpinned/base.wai"), &set).expect("unpinned decodes"); + let (info, _) = decode_base(&rd("staged-conformance/cases/image_int_hyper_unpinned/base.wai"), &set, None, None).expect("unpinned decodes"); assert_eq!(info.tier, None, "an unpinned base holds no tier"); let env = rd("staged-conformance/cases/video_int_motion_gop_perframe/base_0.wai"); - let (info, units) = decode_base(&env, &BTreeMap::new()).expect("int_motion reads no set"); + let (info, units) = decode_base(&env, &BTreeMap::new(), None, None).expect("int_motion reads no set"); assert_eq!(info.tier, Some(DeterminismTier::DecodeEquivalence)); assert_eq!(units.len(), 4); assert_eq!(stage0(&units), rd("staged-conformance/cases/video_int_motion_gop_perframe/stage0_0.bin")); - let e = decode_base(b"not an envelope", &BTreeMap::new()).unwrap_err(); + let e = decode_base(b"not an envelope", &BTreeMap::new(), None, None).unwrap_err(); assert_eq!(e.code(), "not-wai1"); // A base offered with a fallback, decoded through it, is recorded so; a capability // the manifest does not offer is refused. let env = rd("staged-conformance/cases/base_via_fallback/base.wai"); - let (info, _) = super::decode_base_via(&env, Some(crate::codecs::CAP_VIDEO_INT_MOTION), &BTreeMap::new()).expect("the fallback decodes"); + let (info, _) = super::decode_base_via(&env, Some(crate::codecs::CAP_VIDEO_INT_MOTION), &BTreeMap::new(), None, None).expect("the fallback decodes"); assert!(info.via_fallback); assert_eq!(info.capability, crate::codecs::CAP_VIDEO_INT_MOTION); - let e = super::decode_base_via(&env, Some(crate::codecs::CAP_VIDEO_INT_HYPER), &BTreeMap::new()).unwrap_err(); + let e = super::decode_base_via(&env, Some(crate::codecs::CAP_VIDEO_INT_HYPER), &BTreeMap::new(), None, None).unwrap_err(); assert_eq!(e.code(), "not-offered"); } + +/// A small `wai.video.int_motion` payload (two 4 × 4 frames) in an envelope naming +/// `cap`, with `fallback`. +#[cfg(feature = "video_int")] +fn motion_envelope(cap: &str, fallback: Option<&str>) -> Vec { + let frames: Vec> = (0..2u32).map(|t| (0..4 * 4 * 3u32).map(|i| ((i * 7 + t * 13) % 256) as u8).collect()).collect(); + let (payload, _) = crate::int_payload::encode_wiv1_zero_motion(&frames, 4, 4, 4).unwrap(); + let mut mr = ModelRequirement::new(cap); + if let Some(f) = fallback { + mr = mr.with_fallback(f); + } + Wai::new(Manifest::new("video", "replicate", mr, Conditioning::new("int_motion")), payload).to_bytes().unwrap() +} + +/// SPEC §4 for a base, as `Wai::dispatch_verified` runs it: a refinement-only or a +/// companion-only capability makes the envelope inert, so its fallback is never +/// reached, by `decode_base` or by `decode_base_via` asked for that fallback +/// (`missing_capability`); a capability this build does not decode moves dispatch on +/// to the fallback, recorded as decoded through it. +#[cfg(feature = "video_int")] +#[test] +fn decode_base_runs_spec_4() { + use super::{decode_base, decode_base_via}; + use crate::codecs::{CAP_VIDEO_INT_MOTION as MOTION, CAP_VIDEO_LCEVC}; + let none = std::collections::BTreeMap::new(); + for inert_primary in [CAP_VIDEO_INT_REFINE, CAP_VIDEO_LCEVC] { + let env = motion_envelope(inert_primary, Some(MOTION)); + assert_eq!(decode_base(&env, &none, None, None).unwrap_err().code(), "missing_capability", "{inert_primary}"); + assert_eq!(decode_base_via(&env, Some(MOTION), &none, None, None).unwrap_err().code(), "missing_capability", "{inert_primary}"); + } + for (primary, via_fallback) in [(MOTION, false), ("x.unregistered.cap", true), (crate::codecs::CAP_VIDEO_AV1, true)] { + let env = motion_envelope(primary, if via_fallback { Some(MOTION) } else { None }); + let (info, units) = decode_base(&env, &none, None, None).unwrap_or_else(|e| panic!("{primary}: {e}")); + assert_eq!((info.capability.as_str(), info.via_fallback, units.len()), (MOTION, via_fallback, 2), "{primary}"); + } + let env = motion_envelope(crate::codecs::CAP_VIDEO_AV1, None); + assert_eq!(decode_base(&env, &none, None, None).unwrap_err().code(), "missing_capability", "no capability decodes here"); +} + +/// A sink's cost limit applies to a base before it is decoded: a payload whose +/// declared output passes the limit is `cost_over_limit`, at the limit it decodes, and +/// a capability refused for its cost moves dispatch on to the fallback. +#[cfg(feature = "video_int")] +#[test] +fn decode_base_applies_the_cost_limit() { + use super::decode_base; + use crate::int_payload::DeclaredCost; + let none = std::collections::BTreeMap::new(); + let env = motion_envelope(crate::codecs::CAP_VIDEO_INT_MOTION, None); + let out = 2 * 4 * 4 * 3; + let at = DeclaredCost::new(out, u64::MAX, u64::MAX, u128::MAX); + assert!(decode_base(&env, &none, Some(&at), None).is_ok()); + let under = DeclaredCost::new(out - 1, u64::MAX, u64::MAX, u128::MAX); + assert_eq!(decode_base(&env, &none, Some(&under), None).unwrap_err().code(), "cost_over_limit"); + let few_symbols = DeclaredCost::new(u64::MAX, 3, u64::MAX, u128::MAX); + assert_eq!(decode_base(&env, &none, Some(&few_symbols), None).unwrap_err().code(), "cost_over_limit"); +} + +/// SPEC §3.1 check 5 on a base: a key log that revokes the pinned set refuses the base +/// (`prior_revoked`), and one that revokes the set an unpinned base would read refuses +/// that too; with no key log both decode. +#[test] +fn decode_base_runs_check_5() { + use super::decode_base; + use crate::container::{PinRefusal, Prior}; + #[derive(Debug)] + struct Revokes(Vec); + impl crate::pin::PinAuthority for Revokes { + 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.0.contains(d)) { + return Err(PinRefusal::Revoked { capability: capability.into(), revoked_by: None }); + } + Ok(()) + } + } + let wai = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let rd = |rel: &str| std::fs::read(wai.join(rel)).unwrap(); + let set_bytes: Vec<(String, Vec)> = [("entropy.json", "tiny_entropy.json"), ("g_s.whm", "tiny_g_s.bin"), ("h_s.whm", "tiny_h_s.bin")] + .iter() + .map(|(m, f)| (m.to_string(), rd(&format!("wai-rs/tests/vectors/int_hyper/{f}")))) + .collect(); + let set: std::collections::BTreeMap = set_bytes.iter().map(|(m, b)| (m.clone(), b.as_slice())).collect(); + let listing_digest = crate::pin::sha256_hex(&crate::pin::listing_from(&set).unwrap()); + let revoked = Revokes(vec![listing_digest.clone()]); + let pinned = rd("staged-conformance/cases/image_int_hyper_2l/base.wai"); + assert_eq!(Wai::from_bytes(&pinned).unwrap().prior().unwrap().sha256, listing_digest, "the base pins this set"); + assert!(decode_base(&pinned, &set, None, None).is_ok()); + assert_eq!(decode_base(&pinned, &set, None, Some(&revoked)).unwrap_err().code(), "prior_revoked"); + assert!(decode_base(&pinned, &set, None, Some(&Revokes(vec![]))).is_ok()); + let unpinned = rd("staged-conformance/cases/image_int_hyper_unpinned/base.wai"); + assert!(decode_base(&unpinned, &set, None, None).is_ok()); + assert_eq!(decode_base(&unpinned, &set, None, Some(&revoked)).unwrap_err().code(), "prior_revoked"); +} + +/// A capability whose set is one file, pinned without a form, resolves against +/// whichever held file satisfies the pin (SPEC §3.1), not the first by name: a README +/// beside the model does not stop the base decoding, pinned or unpinned. +#[cfg(feature = "audio_int")] +#[test] +fn decode_base_one_file_pin_ignores_other_files() { + use super::decode_base; + let wai = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let rd = |rel: &str| std::fs::read(wai.join(rel)).unwrap(); + let model = rd("wai-rs/tests/vectors/int_audio/audio_model.bin"); + let readme = b"notes about this model".to_vec(); + let env = rd("staged-conformance/cases/audio_int_codec_1l/base.wai"); + let w = Wai::from_bytes(&env).unwrap(); + assert!(w.prior().is_some_and(|p| p.form().is_none()), "the corpus base pins one file, form-less"); + let set: std::collections::BTreeMap = [("README".to_string(), readme.as_slice()), ("model.wam".to_string(), model.as_slice())].into_iter().collect(); + let (info, units) = decode_base(&env, &set, None, None).expect("the model satisfies the pin"); + assert_eq!(info.tier, Some(DeterminismTier::DecodeEquivalence)); + let stage0: Vec = units.iter().flat_map(|u| u.samples.canonical_bytes()).collect(); + assert_eq!(stage0, rd("staged-conformance/cases/audio_int_codec_1l/stage0.bin")); + let mut unpinned = Wai::from_bytes(&env).unwrap(); + unpinned.manifest.conditioning.prior = None; + assert!(decode_base(&unpinned.to_bytes().unwrap(), &set, None, None).is_ok(), "unpinned, the member is read by name"); +} + +/// §4.5 for a base: a base whose units' output passes the chain budget is not added; +/// its envelope is refused `chain-over-limit`, the sink keeps no chain, and a layer held +/// for it stays held, to the hold bound. A base exactly at the budget is added. +#[test] +fn add_base_counts_its_state_against_the_budget() { + let (_env, info, units) = base4(); + let out: u64 = units.iter().map(|u| u.samples.byte_len()).sum(); + let l1 = layer(&info.content_hash, one(&info.content_hash), 1, Some(Span { from: 0, count: 1 }), &[frame(0)], 50); + let mut small = StageSink::new(ChainConfig { budget: out - 1, ..cfg() }); + assert!(small.offer(&l1, 0, ReceiptEvidence::None).unwrap()[0].held_for_base); + let r = small.add_base(info.clone(), units.clone()); + assert_eq!(r.len(), 1); + assert_eq!((r[0].content_hash, r[0].envelope), (info.content_hash, Some(StageRefusal::ChainOverLimit))); + assert!(small.chain(&info.content_hash).is_none()); + let expired = small.expire(2000); + assert_eq!(expired.iter().map(|r| r.envelope).collect::>(), vec![Some(StageRefusal::BaseAbsent)], "the layer stayed held for its base"); + let mut room = StageSink::new(ChainConfig { budget: out, ..cfg() }); + assert!(room.add_base(info.clone(), units).is_empty(), "a base exactly at the budget is added"); + assert_eq!(room.chain(&info.content_hash).map(|c| c.state_bytes()), Some(out)); +} diff --git a/wai/wai-web/src/stage.rs b/wai/wai-web/src/stage.rs index 2d256e33f..09a0ef61f 100644 --- a/wai/wai-web/src/stage.rs +++ b/wai/wai-web/src/stage.rs @@ -176,8 +176,11 @@ fn prev_value(p: &Prev) -> Value { /// (the `WAI1` framing, the manifest and the payload, which sum to the whole), its /// capability; for a layer its `refines`, its `WIR1` header and its declared cost /// (Appendix A.5: the output bytes, symbols and working bytes a sink applies its cost -/// limit to). A member that does not parse is `null`, with the reason under `error`. -pub(crate) fn layer_info(env: &[u8]) -> Result { +/// limit to); for a base of an integer codec, the declared cost of its decode +/// (`extensions/integer-payloads.md` §7; `macs` as a decimal string), against `set` +/// where the codec reads a parameter set. A member that does not parse is `null`, with +/// the reason under `error`. +pub(crate) fn layer_info(env: &[u8], set: &BTreeMap) -> Result { let w = Wai::from_bytes(env).map_err(|e| format!("not-wai1: {e}"))?; let manifest_bytes = u32::from_le_bytes(env[4..8].try_into().map_err(|_| "not-wai1: no manifest length")?) as usize; let payload_bytes = w.payload.len(); @@ -208,6 +211,22 @@ pub(crate) fn layer_info(env: &[u8]) -> Result { }); } } + // A base: the declared cost of the capability SPEC §4 would take first through the + // integer path (its own, else its fallback; none for a refinement-only or + // companion-only capability), read from its header against `set`, before anything + // is decoded (`extensions/integer-payloads.md` §7). + if staged::unit_kind(&cap).is_none() && !wai::codecs::is_companion_only(&cap) && !wai::codecs::is_refinement_only(&cap) { + let via = [Some(cap.as_str()), w.fallback()].into_iter().flatten().find(|c| wai::codecs::is_dispatchable(c) && wai::codecs::pinned_int_payload(c).is_some()); + if let Some(via) = via { + match crate::int_payload::cost(via, &w.payload, &set.iter().map(|(k, v)| (k.as_str(), *v)).collect::>()) { + Ok(c) => { + out["declared_cost"] = json!({"output_bytes": c.output_bytes, "symbols": c.symbols, "working_bytes": c.working_bytes, "macs": c.macs.to_string()}); + out["cost_capability"] = json!(via); + } + Err(e) => out["error"] = json!(e), + } + } + } if let Some(kind) = staged::unit_kind(&cap) { match wir1::parse(&w.payload, kind) { Ok(p) => { @@ -227,6 +246,63 @@ pub(crate) fn layer_info(env: &[u8]) -> Result { Ok(out) } +/// A cost limit, in the form the binding takes it: `{output_bytes, symbols, +/// working_bytes, macs}` (`macs` may be absent, for no bound on it), each figure read +/// as [`count`] reads one; `null` for no limit. +pub(crate) fn limit_from(v: &Value) -> Result, String> { + if v.is_null() { + return Ok(None); + } + let obj = v.as_object().ok_or("the limit is an object, or null")?; + for k in obj.keys() { + if !["output_bytes", "symbols", "working_bytes", "macs"].contains(&k.as_str()) { + return Err(format!("the limit has no member {k}")); + } + } + let macs = match &v["macs"] { + Value::Null => u128::MAX, + m => { + let n = count(m, "limit.macs")?; + if n == u64::MAX { u128::MAX } else { n as u128 } + } + }; + Ok(Some(wai::int_payload::DeclaredCost::new( + count(&v["output_bytes"], "limit.output_bytes")?, + count(&v["symbols"], "limit.symbols")?, + count(&v["working_bytes"], "limit.working_bytes")?, + macs, + ))) +} + +/// A base decoded elsewhere, as the binding takes one ([`StageBase::from_stage0`]): the +/// envelope must parse, `capability` must be its manifest's capability or its fallback +/// (decoded through the fallback where it is not the capability), and the claimed tier +/// is a tier token, lowered to what the capability's registry class gives a base with +/// no resolved pin ([`tier::base_tier`]): a page never raises a base's tier by claiming +/// it. +pub(crate) fn claim_base(envelope: &[u8], capability: &str, tier_token: &str, shapes: &[u32], elem: &str, stage0: &[u8]) -> Result<(BaseInfo, Vec), String> { + let w = Wai::from_bytes(envelope).map_err(|e| format!("not-wai1: {e}"))?; + let primary = w.capability(); + if capability != primary && w.fallback() != Some(capability) { + return Err(format!("not-offered: {capability} is neither the manifest's capability nor its fallback")); + } + let claimed = match tier_token { + "decode-equivalence" | "entropy-consistency" | "none" => tier::from_token(tier_token), + t => return Err(format!("tier: {t} is not decode-equivalence, entropy-consistency or none")), + }; + let ceiling = tier::base_tier(capability, None); + let units = units_from_stage0(shapes, elem, stage0)?; + let info = BaseInfo { + content_hash: wai::jwp_hash::content_hash(envelope), + capability: capability.to_string(), + via_fallback: capability != primary, + tier: claimed.min(ceiling), + attested: false, + first_unit: 0, + }; + Ok((info, units)) +} + /// The sum of squared differences of two equal-length byte buffers, exact. pub(crate) fn sse_u8(a: &[u8], b: &[u8]) -> Result { if a.len() != b.len() { @@ -271,56 +347,71 @@ fn ms(v: f64, what: &str) -> Result { Ok(if v >= 18_446_744_073_709_551_616.0 { u64::MAX } else { v as u64 }) } -/// A base, decoded and ready for a sink: its info (content hash, the capability it -/// was decoded through, whether through a fallback, its tier, whether the sink verified -/// a delivery receipt for it, the live position of its unit 0) and its units. +/// A JS `Map` of file name → bytes, as owned pairs. +fn set_from(set: Option) -> Result)>, JsError> { + let mut held: Vec<(String, Vec)> = Vec::new(); + if let Some(m) = set { + for entry in m.entries() { + let pair = js_sys::Array::from(&entry.map_err(|_| JsError::new("set: unreadable entry"))?); + let name = pair.get(0).as_string().ok_or_else(|| JsError::new("set: a file name is a string"))?; + let bytes = pair.get(1).dyn_into::().map_err(|_| JsError::new("set: a file's bytes are a Uint8Array"))?.to_vec(); + held.push((name, bytes)); + } + } + Ok(held) +} + +/// A base, ready for a sink: its info (content hash, the capability it was decoded +/// through, whether through a fallback, its tier, whether the sink verified a delivery +/// receipt for it, the live position of its unit 0) and its units. +/// +/// A base is **decoded** ([`StageBase::decode`]: the reference decode ran here, so its +/// units and tier are the decode's) or **claimed** ([`StageBase::from_stage0`]: the page +/// supplied its units, and nothing here checked them against the envelope). A claimed +/// base's tier is never above what its capability gives a base with no resolved pin, +/// and every unit, report and stage hash of its chain says `claimed`: a stage hash of a +/// claimed chain is the hash of what the page supplied and the layers applied to it, +/// not evidence that the envelope decodes to it. #[wasm_bindgen] -#[derive(Clone)] pub struct StageBase { info: BaseInfo, units: Vec, + claimed: bool, } #[wasm_bindgen] impl StageBase { - /// Decode a base envelope through the integer path (`wai::staged::decode_base_via`): - /// `set` maps each parameter file's name (as SPEC §5 names it) to its bytes, and is - /// absent for a capability that reads none (`wai.video.int_motion`); the envelope's - /// pin is verified against those bytes. `via` is the capability to decode through: - /// absent for the manifest's own, or its fallback. A refusal's message starts with - /// its code. - pub fn decode(envelope: &[u8], set: Option, via: Option) -> Result { - let mut held: Vec<(String, Vec)> = Vec::new(); - if let Some(m) = set { - for entry in m.entries() { - let pair = js_sys::Array::from(&entry.map_err(|_| JsError::new("set: unreadable entry"))?); - let name = pair.get(0).as_string().ok_or_else(|| JsError::new("set: a file name is a string"))?; - let bytes = js_sys::Uint8Array::new(&pair.get(1)).to_vec(); - held.push((name, bytes)); - } - } + /// Decode a base envelope by SPEC §4 through the integer path + /// (`wai::staged::decode_base_via`). `set` maps each parameter file's name (as SPEC + /// §5 names it) to its bytes, and is absent for a capability that reads none + /// (`wai.video.int_motion`); the envelope's pin is verified against those bytes. + /// `via` is the capability to decode through: absent to run §4 (the manifest's + /// capability, then its fallback), or one of those two. `limit` is the page's cost + /// limit, `{output_bytes, symbols, working_bytes, macs}` (`macs` optional): the + /// payload's declared cost is checked against it before any symbol is decoded or + /// any output allocated (`cost_over_limit`); `null` bounds the decode by the + /// payload format's caps alone. No key log is held here, so SPEC §3.1's check 5 is + /// not run. A refusal's message starts with its code. + pub fn decode(envelope: &[u8], set: Option, via: Option, limit: JsValue) -> Result { + let limit_v: Value = if limit.is_null() || limit.is_undefined() { Value::Null } else { serde_wasm_bindgen::from_value(limit).map_err(js_err)? }; + let limit = limit_from(&limit_v).map_err(js_err)?; + let held = set_from(set)?; let files: BTreeMap = held.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); - let (info, units) = staged::decode_base_via(envelope, via.as_deref(), &files).map_err(js_err)?; - Ok(StageBase { info, units }) + let (info, units) = staged::decode_base_via(envelope, via.as_deref(), &files, limit.as_ref(), None).map_err(js_err)?; + Ok(StageBase { info, units, claimed: false }) } - /// A base decoded elsewhere (a codec this build does not run, or a fixture): its - /// envelope, the capability it was decoded through, the tier of that step - /// (`decode-equivalence`, `entropy-consistency` or `none`), and its units as their - /// canonical buffers concatenated, `shapes` holding three dimensions per unit and - /// `elem` `u8` or `i16`. + /// A base the page decoded elsewhere (a codec this build does not run, or a + /// fixture): its envelope, the capability it was decoded through (the manifest's + /// capability, or its fallback, which makes it decoded through a fallback), the + /// tier the page claims for that step (`decode-equivalence`, `entropy-consistency` + /// or `none`; lowered to what the capability gives a base with no resolved pin), + /// and its units as their canonical buffers concatenated, `shapes` holding three + /// dimensions per unit and `elem` `u8` or `i16`. The base is **claimed**. #[wasm_bindgen(js_name = fromStage0)] pub fn from_stage0(envelope: &[u8], capability: &str, tier_token: &str, shapes: &[u32], elem: &str, stage0: &[u8]) -> Result { - let units = units_from_stage0(shapes, elem, stage0).map_err(js_err)?; - let info = BaseInfo { - content_hash: wai::jwp_hash::content_hash(envelope), - capability: capability.to_string(), - via_fallback: false, - tier: tier::from_token(tier_token), - attested: false, - first_unit: 0, - }; - Ok(StageBase { info, units }) + let (info, units) = claim_base(envelope, capability, tier_token, shapes, elem, stage0).map_err(js_err)?; + Ok(StageBase { info, units, claimed: true }) } /// The base envelope's content hash, lowercase hex. @@ -340,22 +431,29 @@ impl StageBase { pub fn n_units(&self) -> u32 { self.units.len() as u32 } + /// Whether the base was decoded through its manifest's fallback (§4.1 step 3 + /// refuses its layers). Read-only: the decode, or the capability a claimed base + /// names, decides it. #[wasm_bindgen(getter, js_name = viaFallback)] pub fn via_fallback(&self) -> bool { self.info.via_fallback } - #[wasm_bindgen(setter, js_name = viaFallback)] - pub fn set_via_fallback(&mut self, v: bool) { - self.info.via_fallback = v; + /// Whether the page supplied this base's units ([`StageBase::from_stage0`]). + #[wasm_bindgen(getter)] + pub fn claimed(&self) -> bool { + self.claimed } /// Whether the sink verified a delivery receipt for the base (§10). #[wasm_bindgen(getter)] pub fn attested(&self) -> bool { self.info.attested } + /// Record that the sink verified a delivery receipt for the base (§10): from then + /// on its layers need `delivered-verified` evidence. A verified receipt is never + /// unverified, so `false` changes nothing. #[wasm_bindgen(setter)] pub fn set_attested(&mut self, v: bool) { - self.info.attested = v; + self.info.attested |= v; } /// The live clock position of the base's unit 0 (§8). #[wasm_bindgen(getter, js_name = firstUnit)] @@ -377,20 +475,48 @@ impl StageBase { /// A staged-delivery sink: `wai::staged::StageSink`. Every method that decides returns /// the reports of that step, an array in the corpus's form; a unit is read back by its -/// base's content hash (hex) and its index. +/// base's content hash (hex) and its index. A report, a base's units and a stage hash +/// from the chain of a claimed base carry `claimed: true` (see [`StageBase`]). #[wasm_bindgen] pub struct StageSink { sink: staged::StageSink, + /// The bases whose chain started from a claimed base. + claimed: std::collections::BTreeSet<[u8; 32]>, +} + +/// Mark the reports of `rs` whose envelope belongs to a claimed chain: those `of` +/// says so of. +fn marked(rs: &[OfferReport], of: impl Fn(&OfferReport) -> bool) -> Value { + Value::Array(rs.iter().map(|r| { + let mut v = report_value(r); + if of(r) { + v["claimed"] = json!(true); + } + v + }).collect()) } impl StageSink { + fn hash(base: &str) -> Result<[u8; 32], JsError> { + unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits")) + } fn chain(&self, base: &str) -> Result<&StageChain, JsError> { - let h = unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits"))?; - self.sink.chain(&h).ok_or_else(|| JsError::new("base-absent: the sink holds no such base")) + self.sink.chain(&Self::hash(base)?).ok_or_else(|| JsError::new("base-absent: the sink holds no such base")) } fn state(&self, base: &str, u: u32) -> Result<&staged::UnitState, JsError> { self.chain(base)?.unit(u).ok_or_else(|| JsError::new("unit: out of range")) } + fn is_claimed(&self, base: &str) -> bool { + unhex32(base).is_some_and(|h| self.claimed.contains(&h)) + } +} + +/// A stage hash and whether it is a claimed chain's. +fn stage_hash(h: Option<[u8; 32]>, claimed: bool) -> Result { + match h { + None => Ok(JsValue::UNDEFINED), + Some(h) => to_js(&json!({"sha256": hex(&h), "claimed": claimed})), + } } #[wasm_bindgen] @@ -401,15 +527,24 @@ impl StageSink { #[wasm_bindgen(constructor)] pub fn new(config: JsValue) -> Result { let v: Value = serde_wasm_bindgen::from_value(config).map_err(js_err)?; - Ok(StageSink { sink: staged::StageSink::new(config_from(&v).map_err(js_err)?) }) + Ok(StageSink { sink: staged::StageSink::new(config_from(&v).map_err(js_err)?), claimed: Default::default() }) } - /// Add a decoded base, which releases the layers held for it (§4.4). A base the - /// sink already holds is kept as it is. + /// Add a base, which releases the layers held for it (§4.4). The base is moved + /// into the sink: the JS object is spent. A base the sink already holds is kept as + /// it is. A base whose units' output passes the chain budget is not added: its one + /// report refuses its envelope `chain-over-limit` (§4.5). #[wasm_bindgen(js_name = addBase)] - pub fn add_base(&mut self, base: &StageBase) -> Result { - let b = base.clone(); - to_js(&reports_value(&self.sink.add_base(b.info, b.units))) + pub fn add_base(&mut self, base: StageBase) -> Result { + let StageBase { info, units, claimed } = base; + let h = info.content_hash; + let held_before = self.sink.chain(&h).is_some(); + let rs = self.sink.add_base(info, units); + if claimed && !held_before && self.sink.chain(&h).is_some() { + self.claimed.insert(h); + } + let mark = claimed || self.claimed.contains(&h); + to_js(&marked(&rs, |_| mark)) } /// Offer the envelope `envelope` at `now_ms` with the receipt evidence `evidence` @@ -418,8 +553,11 @@ impl StageSink { /// starts with `not-wai1` or `encrypted`. pub fn offer(&mut self, envelope: &[u8], now_ms: f64, evidence: &str) -> Result { let e = ReceiptEvidence::from_token(evidence).ok_or_else(|| JsError::new("evidence: delivered-verified, inclusion-verified or none"))?; + // An offer decides only the chain of the base the layer names. + let base = Wai::from_bytes(envelope).ok().and_then(|w| w.refines().and_then(Result::ok)).and_then(|r| unhex32(&r.base)); + let mark = base.is_some_and(|b| self.claimed.contains(&b)); match self.sink.offer(envelope, ms(now_ms, "now_ms")?, e) { - Ok(r) => to_js(&reports_value(&r)), + Ok(r) => to_js(&marked(&r, |_| mark)), Err(staged::OfferError::NotWai1(m)) => Err(JsError::new(&format!("not-wai1: {m}"))), Err(e) => Err(JsError::new(&format!("encrypted: {e}"))), } @@ -427,16 +565,21 @@ impl StageSink { /// Discard what has been held past the bound at `now_ms` (§4.3). pub fn expire(&mut self, now_ms: f64) -> Result { - to_js(&reports_value(&self.sink.expire(ms(now_ms, "now_ms")?))) + // The envelopes claimed chains hold, before they expire. + let held: std::collections::BTreeSet<[u8; 32]> = + self.claimed.iter().filter_map(|b| self.sink.chain(b)).flat_map(|c| c.held().into_iter().map(|(h, _)| h)).collect(); + let rs = self.sink.expire(ms(now_ms, "now_ms")?); + to_js(&marked(&rs, |r| held.contains(&r.content_hash))) } /// Drop a base's chain (§4.4); its units' records are kept. `null` when the sink /// holds no such base. #[wasm_bindgen(js_name = removeBase)] pub fn remove_base(&mut self, base: &str) -> Result { - let h = unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits"))?; + let h = Self::hash(base)?; + let mark = self.claimed.remove(&h); match self.sink.remove_base(&h) { - Some(r) => to_js(&reports_value(&r)), + Some(r) => to_js(&marked(&r, |_| mark)), None => Ok(JsValue::NULL), } } @@ -444,16 +587,14 @@ impl StageSink { /// Record that the sink verified a delivery receipt for the base (§10). #[wasm_bindgen(js_name = markAttested)] pub fn mark_attested(&mut self, base: &str) -> Result<(), JsError> { - let h = unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits"))?; - self.sink.mark_attested(&h); + self.sink.mark_attested(&Self::hash(base)?); Ok(()) } /// Discard what the sink keeps of a base it does not hold (§14). #[wasm_bindgen(js_name = forgetBase)] pub fn forget_base(&mut self, base: &str) -> Result { - let h = unhex32(base).ok_or_else(|| JsError::new("base: a content hash, 64 lowercase hex digits"))?; - Ok(self.sink.forget_base(&h)) + Ok(self.sink.forget_base(&Self::hash(base)?)) } /// Whether the sink holds the base. @@ -462,12 +603,28 @@ impl StageSink { unhex32(base).is_some_and(|h| self.sink.chain(&h).is_some()) } + /// Whether the base's chain started from a claimed base. + #[wasm_bindgen(js_name = isClaimed)] + pub fn is_claimed_js(&self, base: &str) -> bool { + self.is_claimed(base) + } + /// The base's units and clip hash, as the corpus states a base at the end of a case /// (`{units: [{stage, producer, frozen, tier, shape, stage_sha256}], clip_sha256, - /// n_units}`), or `null` when the sink holds no such base. + /// n_units}`), with `claimed: true` on the base and on each unit where its chain + /// started from a claimed base; `null` when the sink holds no such base. pub fn units(&self, base: &str) -> Result { match unhex32(base).and_then(|h| self.sink.chain(&h)) { - Some(c) => to_js(&units_value(c)), + Some(c) => { + let mut v = units_value(c); + if self.is_claimed(base) { + v["claimed"] = json!(true); + for u in v["units"].as_array_mut().into_iter().flatten() { + u["claimed"] = json!(true); + } + } + to_js(&v) + } None => Ok(JsValue::NULL), } } @@ -518,21 +675,22 @@ impl StageSink { Samples::U8(_) => Err(JsError::new("the unit holds 8-bit samples: unitBytes")), } } - /// The unit's stage hash (SHA-256 of its canonical buffer), or `undefined` unless - /// it holds `decode-equivalence` (§5). + /// The unit's stage hash (SHA-256 of its canonical buffer) as `{sha256, claimed}`, + /// or `undefined` unless the unit holds `decode-equivalence` (§5). `claimed` is true + /// where the chain started from a claimed base. #[wasm_bindgen(js_name = unitSha256)] - pub fn unit_sha256(&self, base: &str, u: u32) -> Result, JsError> { + pub fn unit_sha256(&self, base: &str, u: u32) -> Result { let c = self.chain(base)?; if u >= c.n_units() { return Err(JsError::new("unit: out of range")); } - Ok(c.unit_sha256(u).map(|h| hex(&h))) + stage_hash(c.unit_sha256(u), self.is_claimed(base)) } - /// The base's clip hash, or `undefined` unless every unit holds - /// `decode-equivalence`. + /// The base's clip hash as `{sha256, claimed}`, or `undefined` unless every unit + /// holds `decode-equivalence`. #[wasm_bindgen(js_name = clipSha256)] - pub fn clip_sha256(&self, base: &str) -> Result, JsError> { - Ok(self.chain(base)?.clip_sha256().map(|h| hex(&h))) + pub fn clip_sha256(&self, base: &str) -> Result { + stage_hash(self.chain(base)?.clip_sha256(), self.is_claimed(base)) } /// The squared error of the unit's current 8-bit output against `reference`, /// summed exactly (a `BigInt`): the numerator of the page's PSNR. @@ -560,14 +718,20 @@ impl StageSink { } } -/// What an envelope is, before it is offered: `{object, bytes, framing_bytes, -/// manifest_bytes, payload_bytes, capability, refinement_only, refines, wir1, -/// declared_cost, error}`, where `wir1` is a layer's header (`n_units`, `shape`, -/// `predictor`, `q`, `tables`) and `declared_cost` its output bytes, symbols and -/// working bytes (Appendix A.5). Not a `WAI1` envelope: an error starting `not-wai1`. +/// What an envelope is, before it is offered or decoded: `{object, bytes, +/// framing_bytes, manifest_bytes, payload_bytes, capability, refinement_only, refines, +/// wir1, declared_cost, error}`, where `wir1` is a layer's header (`n_units`, `shape`, +/// `predictor`, `q`, `tables`) and `declared_cost` a layer's output bytes, symbols and +/// working bytes (Appendix A.5), or a base's declared decode cost +/// (`extensions/integer-payloads.md` §7, with `macs` as a decimal string and the +/// capability it was read for as `cost_capability`), against the parameter set `set` +/// where the base's codec reads one. Not a `WAI1` envelope: an error starting +/// `not-wai1`. #[wasm_bindgen(js_name = stageLayerInfo)] -pub fn stage_layer_info(envelope: &[u8]) -> Result { - to_js(&layer_info(envelope).map_err(js_err)?) +pub fn stage_layer_info(envelope: &[u8], set: Option) -> Result { + let held = set_from(set)?; + let files: BTreeMap = held.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); + to_js(&layer_info(envelope, &files).map_err(js_err)?) } /// The content hash of an envelope (jwp-receipts §2.1), lowercase hex: the identity a @@ -624,7 +788,7 @@ mod tests { .map(|(m, p)| (m.clone(), std::fs::read(wai_dir().join(p.as_str().unwrap())).unwrap())) .collect(); let files: BTreeMap = held.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); - let (info, units) = staged::decode_base_via(&env, Some(via), &files).unwrap_or_else(|e| panic!("{name}: {e}")); + let (info, units) = staged::decode_base_via(&env, Some(via), &files, None, None).unwrap_or_else(|e| panic!("{name}: {e}")); let got: Vec = units.iter().flat_map(|u| u.samples.canonical_bytes()).collect(); assert_eq!(got, stage0, "{name}: the base decodes to its stage 0"); assert_eq!(tier::token(info.tier), b["tier"], "{name}: the base tier"); @@ -695,7 +859,8 @@ mod tests { fn layer_info_reads_header_cost_and_bytes() { let d = wai_dir().join("staged-conformance/cases/video_int_motion_gop_perframe"); let l2 = std::fs::read(d.join("g0_L2.wai")).unwrap(); - let v = layer_info(&l2).unwrap(); + let none = BTreeMap::new(); + let v = layer_info(&l2, &none).unwrap(); assert_eq!(v["bytes"].as_u64().unwrap() as usize, l2.len()); assert_eq!(v["framing_bytes"].as_u64().unwrap() + v["manifest_bytes"].as_u64().unwrap() + v["payload_bytes"].as_u64().unwrap(), l2.len() as u64); assert_eq!(v["framing_bytes"], 12); @@ -704,11 +869,55 @@ mod tests { assert_eq!(v["wir1"]["n_units"], 4); assert!(v["declared_cost"]["output_bytes"].as_u64().unwrap() > 0); assert_eq!(v["error"], Value::Null); - let b = layer_info(&std::fs::read(d.join("base_0.wai")).unwrap()).unwrap(); + let b = layer_info(&std::fs::read(d.join("base_0.wai")).unwrap(), &none).unwrap(); assert_eq!((b["refines"].is_null(), b["wir1"].is_null(), b["refinement_only"].as_bool()), (true, true, Some(false))); - assert!(layer_info(b"nope").unwrap_err().starts_with("not-wai1")); + // A base's declared decode cost, read before it is decoded: four 16 × 16 frames. + assert_eq!(b["declared_cost"]["output_bytes"], 4 * 16 * 16 * 3); + assert_eq!((b["cost_capability"].as_str(), b["declared_cost"]["macs"].as_str()), (Some("wai.video.int_motion"), Some("0"))); + assert!(layer_info(b"nope", &none).unwrap_err().starts_with("not-wai1")); assert_eq!(sse_u8(&[0, 10, 255], &[1, 7, 0]).unwrap(), 1 + 9 + 255 * 255); assert!(sse_u8(&[0], &[]).is_err()); assert_eq!(sse_i16(&[-32768], &[32767]).unwrap(), 65535 * 65535); } + + /// A claimed base: the capability must be the manifest's or its fallback (decoded + /// through the fallback where it is not the capability); the claimed tier is a + /// tier token, lowered to what the capability gives a base with no resolved pin. + #[test] + fn a_claimed_base_cannot_raise_its_facts() { + let c = wai_dir().join("staged-conformance/cases"); + let unpinned = std::fs::read(c.join("image_int_hyper_unpinned/base.wai")).unwrap(); + let shapes = [16u32, 16, 3]; + let junk = vec![77u8; 16 * 16 * 3]; + let err = claim_base(&unpinned, "wai.image.png", "decode-equivalence", &shapes, "u8", &junk).unwrap_err(); + assert!(err.starts_with("not-offered"), "{err}"); + let (info, _) = claim_base(&unpinned, "wai.neural.int_hyper", "decode-equivalence", &shapes, "u8", &junk).unwrap(); + assert_eq!(info.tier, None, "an integer neural base claims no tier without a resolved pin"); + assert!(!info.via_fallback); + assert!(claim_base(&unpinned, "wai.neural.int_hyper", "decode-equivalance", &shapes, "u8", &junk).unwrap_err().starts_with("tier")); + let png = std::fs::read(c.join("image_png_up2/base.wai")).unwrap(); + let (info, _) = claim_base(&png, "wai.image.png", "decode-equivalence", &[16, 16, 3], "u8", &junk).unwrap(); + assert_eq!(info.tier, Some(wai::codecs::DeterminismTier::DecodeEquivalence), "a standard-defined capability keeps the tier claimed"); + let (info, _) = claim_base(&png, "wai.image.png", "none", &[16, 16, 3], "u8", &junk).unwrap(); + assert_eq!(info.tier, None, "a page may lower the tier"); + let fb = std::fs::read(c.join("base_via_fallback/base.wai")).unwrap(); + let w = Wai::from_bytes(&fb).unwrap(); + let shape: Vec = vec![8, 8, 3, 8, 8, 3]; + let (info, _) = claim_base(&fb, w.fallback().unwrap(), "decode-equivalence", &shape, "u8", &vec![0u8; 2 * 8 * 8 * 3]).unwrap(); + assert!(info.via_fallback, "named through its fallback, decoded through a fallback"); + assert!(claim_base(b"nope", "x", "none", &shapes, "u8", &junk).unwrap_err().starts_with("not-wai1")); + } + + /// The cost limit's form: four figures, `macs` optional; `null` for none. + #[test] + fn limit_reads_its_form() { + assert_eq!(limit_from(&Value::Null).unwrap(), None); + let l = limit_from(&json!({"output_bytes": 10, "symbols": 20, "working_bytes": 30})).unwrap().unwrap(); + assert_eq!((l.output_bytes, l.symbols, l.working_bytes, l.macs), (10, 20, 30, u128::MAX)); + let l = limit_from(&json!({"output_bytes": 1, "symbols": 2, "working_bytes": 3, "macs": 4})).unwrap().unwrap(); + assert_eq!(l.macs, 4); + assert!(limit_from(&json!({"output_bytes": 1, "symbols": 2, "working_bytes": 3, "extra": 0})).is_err()); + assert!(limit_from(&json!({"output_bytes": -1, "symbols": 2, "working_bytes": 3})).is_err()); + assert!(limit_from(&json!(5)).is_err()); + } } -- 2.54.0 (Apple Git-157) From f90367e1aa5c12923bcbb32c6a22b240d55cc21c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 06:34:47 -0400 Subject: [PATCH 06/11] wai: tests for the bounded base, the CLI as the corpus's sink, the trace and the bench - wai-rs/tests/vectors/staged_bounds: the review's two bases that declare clips just under the 2^30 and 2^26 caps in 3,243 and 415 bytes, and two inert envelopes (a refinement-only and a companion-only capability with an int_motion fallback), which staged::tests::decode_base_runs_spec_4 builds and checks byte for byte. - tests/staged_resources.rs: with a cost limit, decode_base refuses each bounded base (cost_over_limit) holding a few kilobytes at peak (counting allocator); a decoded base past the chain budget leaves nothing in the sink. - wai-web demo/verify_staged_wasm.mjs: the same refusals in wasm32 with linear memory growth checked (wasmMemoryBytes); a base past the chain budget; the viaFallback setter gone and attested raise-only; the inert envelopes; fromStage0's capability, tier and claimed rules; addBase spends its base; claimed marks set aside to compare the PNG and FLAC cases; a seeded fuzz of every corpus envelope that must not trap. - cli_stage::apply_json_replays_the_corpus (the review's cli_vs_corpus.py): every case the tool's sink can replay gives the case's reports and final units; step1_refusals, unreadable_envelopes and base_via_fallback now match. - cli_stage::apply_holds_layers_for_a_later_base lists stages 0, 1 and 2 for each unit a base's arrival takes up in one step. - cli_stage::stage_bench_takes_the_source_at_the_final_shape and stage_base_refuses_overflowing_dims. - wai-web-wasm.yml: 14 native tests. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-web-wasm.yml | 6 +- wai/wai-rs/src/staged/tests.rs | 10 +- wai/wai-rs/tests/cli_stage.rs | 156 +++++++++++++ wai/wai-rs/tests/staged_resources.rs | 32 +++ .../tests/vectors/staged_bounds/README.md | 26 +++ .../tests/vectors/staged_bounds/clip_1gib.wai | Bin 0 -> 3243 bytes .../vectors/staged_bounds/clip_64mib.wai | Bin 0 -> 415 bytes .../staged_bounds/inert_companion_primary.wai | Bin 0 -> 680 bytes .../inert_refinement_primary.wai | Bin 0 -> 685 bytes wai/wai-web/demo/verify_staged_wasm.mjs | 211 ++++++++++++++++-- wai/wai-web/src/stage.rs | 25 ++- 11 files changed, 439 insertions(+), 27 deletions(-) create mode 100644 wai/wai-rs/tests/vectors/staged_bounds/README.md create mode 100644 wai/wai-rs/tests/vectors/staged_bounds/clip_1gib.wai create mode 100644 wai/wai-rs/tests/vectors/staged_bounds/clip_64mib.wai create mode 100644 wai/wai-rs/tests/vectors/staged_bounds/inert_companion_primary.wai create mode 100644 wai/wai-rs/tests/vectors/staged_bounds/inert_refinement_primary.wai diff --git a/.github/workflows/wai-web-wasm.yml b/.github/workflows/wai-web-wasm.yml index ffcc9d007..cdc544145 100644 --- a/.github/workflows/wai-web-wasm.yml +++ b/.github/workflows/wai-web-wasm.yml @@ -106,7 +106,7 @@ jobs: run: | set -euo pipefail cargo test --locked --lib 2>&1 | tee "$RUNNER_TEMP/wai-web-native.log" - grep -q "test result: ok. 12 passed; 0 failed" "$RUNNER_TEMP/wai-web-native.log" + grep -q "test result: ok. 14 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 @@ -120,6 +120,10 @@ jobs: # Every staged-delivery case replayed through the wasm32 StageSink: each integer # base decoded in wasm32 to its stage0.bin with its pin, and every report, final # unit, stage hash and clip hash equal to what the native sink wrote in case.json. + # Then the binding's guards: bases that declare huge clips refused under a cost + # limit with linear memory checked, a base past the chain budget, base facts a + # page cannot raise (fallback, tier, claimed), addBase spending its base, and a + # seeded fuzz that must never trap. - name: wasm32 staged-delivery sink equals the native corpus shell: bash working-directory: wai/wai-web diff --git a/wai/wai-rs/src/staged/tests.rs b/wai/wai-rs/src/staged/tests.rs index 4ac405ebf..0e671dae7 100644 --- a/wai/wai-rs/src/staged/tests.rs +++ b/wai/wai-rs/src/staged/tests.rs @@ -1615,8 +1615,16 @@ fn decode_base_runs_spec_4() { use super::{decode_base, decode_base_via}; use crate::codecs::{CAP_VIDEO_INT_MOTION as MOTION, CAP_VIDEO_LCEVC}; let none = std::collections::BTreeMap::new(); - for inert_primary in [CAP_VIDEO_INT_REFINE, CAP_VIDEO_LCEVC] { + // The same two envelopes are committed for the wasm32 verifier + // (wai-web/demo/verify_staged_wasm.mjs); they must be what this builds. + // WAI_STAGED_BOUNDS_WRITE=1 rewrites them. + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/staged_bounds"); + for (inert_primary, file) in [(CAP_VIDEO_INT_REFINE, "inert_refinement_primary.wai"), (CAP_VIDEO_LCEVC, "inert_companion_primary.wai")] { let env = motion_envelope(inert_primary, Some(MOTION)); + if std::env::var_os("WAI_STAGED_BOUNDS_WRITE").is_some() { + std::fs::write(dir.join(file), &env).unwrap(); + } + assert_eq!(std::fs::read(dir.join(file)).unwrap(), env, "{file} is what this test builds"); assert_eq!(decode_base(&env, &none, None, None).unwrap_err().code(), "missing_capability", "{inert_primary}"); assert_eq!(decode_base_via(&env, Some(MOTION), &none, None, None).unwrap_err().code(), "missing_capability", "{inert_primary}"); } diff --git a/wai/wai-rs/tests/cli_stage.rs b/wai/wai-rs/tests/cli_stage.rs index a189e197a..dec0be3b3 100644 --- a/wai/wai-rs/tests/cli_stage.rs +++ b/wai/wai-rs/tests/cli_stage.rs @@ -226,6 +226,18 @@ fn apply_holds_layers_for_a_later_base() { let last = &steps[steps.len() - 1]; assert_eq!(last["op"], "base"); assert_eq!(last["reports"].as_array().unwrap().len(), 5, "base 2 releases its five layers"); + // Every stage reached in that one step is listed: each unit at stage 0, then at 1, + // then at 2, the last equal to the base's final unit. + let b2 = last["object"].as_str().unwrap(); + let listed = last["units"].as_array().unwrap(); + for u in 0..4u64 { + let stages: Vec = listed.iter().filter(|x| x["base"] == b2 && x["unit"] == u).map(|x| x["stage"].as_u64().unwrap()).collect(); + assert_eq!(stages, vec![0, 1, 2], "unit {u}: {listed:?}"); + let fin = doc["bases"].as_array().unwrap().iter().find(|b| b["object"] == b2).unwrap()["units"][u as usize]["stage_sha256"].clone(); + let at2 = listed.iter().find(|x| x["base"] == b2 && x["unit"] == u && x["stage"] == 2).unwrap()["stage_sha256"].clone(); + assert_eq!(at2, fin); + } + assert!(listed.iter().all(|x| x["stage_sha256"].is_string()), "every stage reached has its hash"); let got: Vec = doc["bases"].as_array().unwrap().iter().map(|b| b["clip_sha256"].clone()).collect(); assert_eq!(serde_json::Value::Array(got), want); } @@ -276,3 +288,147 @@ fn stage_bench_reports_bytes_quality_and_hashes() { } assert_eq!((s[1]["direct"]["q"].as_u64(), s[1]["direct"]["exact"].as_bool()), (Some(1), Some(true))); } + +/// `wai stage apply --json` is the corpus's sink: every case it can replay (bases +/// decoded through the integer path, offers with no receipt, no expiry, removal or +/// attestation, at the corpus's default configuration, since the tool's sink applies no +/// budget or bound) gives, step by step, the reports `case.json` states, with a step +/// whose bytes are not a `WAI1` this sink reads as that step's error, and ends with the +/// final units it states. Ported from the review's cli_vs_corpus.py. +#[test] +fn apply_json_replays_the_corpus() { + let root = corpus("cases"); + let mut names: Vec = std::fs::read_dir(&root).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + let default_cfg = serde_json::json!({"bound": {"max_envelopes": 16, "max_age_ms": 1000}, "cost_limit": null, "budget": 1u64 << 30, "record_budget": 1u64 << 20, "live": null}); + let (mut same, mut skipped) = (0, 0); + let mut differ: Vec = Vec::new(); + for name in names { + let d = root.join(&name); + let c = case(&name); + let steps = c["steps"].as_array().unwrap(); + let bases = c["bases"].as_array().unwrap(); + let sets: std::collections::BTreeSet = bases.iter().map(|b| b["decode"]["set"].to_string()).collect(); + let skip = steps.iter().any(|s| !["base", "offer"].contains(&s["op"].as_str().unwrap())) + || steps.iter().any(|s| s.get("evidence").is_some_and(|e| e != "none")) + || bases.iter().any(|b| b["attested"] == true || b["first_unit"] != 0 || b["decode"]["kind"] != "int") + || c["config"] != default_cfg + || sets.len() > 1; + if skip { + skipped += 1; + continue; + } + // The parameter set, by the names SPEC §5 gives its files. + let set: serde_json::Map = bases[0]["decode"]["set"].as_object().cloned().unwrap_or_default(); + let set_dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_stage/corpus_sets").join(&name); + let mut args: Vec = vec!["stage".into(), "apply".into(), "--json".into()]; + if !set.is_empty() { + std::fs::create_dir_all(&set_dir).unwrap(); + for (m, p) in &set { + std::fs::copy(wai_dir().join(p.as_str().unwrap()), set_dir.join(m)).unwrap(); + } + args.push("--set".into()); + args.push(set_dir.clone().into()); + } + for s in steps { + let f = if s["op"] == "base" { bases[s["index"].as_u64().unwrap() as usize]["file"].as_str().unwrap() } else { s["file"].as_str().unwrap() }; + args.push(d.join(f).into()); + } + let refs: Vec<&std::ffi::OsStr> = args.iter().map(|a| a.as_os_str()).collect(); + let o = wai(&refs); + if !o.status.success() { + differ.push(format!("{name}: the tool fails: {}", text(&o))); + continue; + } + let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let got: Vec = doc["steps"].as_array().unwrap().iter() + .map(|st| if let Some(e) = st.get("error") { serde_json::json!({"error": e}) } else { st["reports"].clone() }) + .collect(); + if serde_json::Value::Array(got.clone()) != c["expect"]["reports"] { + let i = got.iter().zip(c["expect"]["reports"].as_array().unwrap()).position(|(g, w)| g != w).unwrap_or(got.len()); + differ.push(format!("{name}: step {i}: the tool {} / the case {}", got.get(i).map(|v| v.to_string()).unwrap_or_default(), c["expect"]["reports"][i])); + continue; + } + let finals: std::collections::BTreeMap = + doc["bases"].as_array().unwrap().iter().map(|b| (b["object"].as_str().unwrap().to_string(), b)).collect(); + for (i, b) in bases.iter().enumerate() { + let want = &c["expect"]["bases"][i]; + let got = finals.get(b["object"].as_str().unwrap()); + match (want.is_null(), got) { + (true, None) => {} + (false, Some(g)) => { + let units: Vec = g["units"].as_array().unwrap().iter().map(|u| { + serde_json::json!({"stage": u["stage"], "producer": u["producer"], "frozen": u["frozen"], "tier": u["tier"], "shape": u["shape"], "stage_sha256": u["stage_sha256"]}) + }).collect(); + if serde_json::Value::Array(units) != want["units"] || g["clip_sha256"] != want["clip_sha256"] { + differ.push(format!("{name}: base {i}'s final units differ")); + } + } + _ => differ.push(format!("{name}: base {i} held {} by the tool", if got.is_some() { "" } else { "not" })), + } + } + same += 1; + } + println!("apply --json against the corpus: {same} cases agree, {} differ, {skipped} need what the tool's sink does not do", differ.len()); + assert!(differ.is_empty(), "{}", differ.join("\n")); + assert!(same >= 30, "only {same} cases compared"); +} + +/// `wai_stage_bench` on a set whose layer 1 changes the units' shape (up2) and whose +/// layer 2 is identity: the source is taken at the units' final shape, so stage 0 has +/// no quality figure (its units are half the size), stages 1 and 2 are measured against +/// it, and the direct comparator says why it does not apply (an up2 stage; an identity +/// stage whose stage 0 has another shape); a source sized for stage 0 is refused. +#[test] +fn stage_bench_takes_the_source_at_the_final_shape() { + let (n, h, w) = (2usize, 4usize, 6usize); + let small: Vec = (0..n * h * w * 3).map(|i| ((i * 11) % 256) as u8).collect(); + let big: Vec = (0..n * 4 * h * w * 3).map(|i| ((i * 5 + 40) % 256) as u8).collect(); + let (sp, bp) = (scratch("up2_small.rgb"), scratch("up2_big.rgb")); + std::fs::write(&sp, &small).unwrap(); + std::fs::write(&bp, &big).unwrap(); + let base = scratch("up2_base.wai"); + let o = wai(&[os("stage"), os("base"), os("--frames"), sp.as_os_str(), os("--dims"), os("2,4,6"), os("--q"), os("16"), os("-o"), base.as_os_str()]); + assert!(o.status.success(), "{}", text(&o)); + let l1 = scratch("up2_L1.wai"); + let o = wai(&[os("stage"), os("refine"), os("--base"), base.as_os_str(), os("--predictor"), os("up2"), os("--target"), bp.as_os_str(), os("--q"), os("1"), os("-o"), l1.as_os_str()]); + assert!(o.status.success(), "{}", text(&o)); + let l2 = scratch("up2_L2.wai"); + let o = wai(&[os("stage"), os("refine"), os("--base"), base.as_os_str(), os("--on"), l1.as_os_str(), os("--target"), bp.as_os_str(), os("--q"), os("1"), os("-o"), l2.as_os_str()]); + assert!(o.status.success(), "{}", text(&o)); + let out = scratch("up2_bench.json"); + let bench = |src: &Path| Command::new(env!("CARGO_BIN_EXE_wai_stage_bench")) + .args([os("--source"), src.as_os_str(), os("--runs"), os("20"), os("--out"), out.as_os_str(), base.as_os_str(), l1.as_os_str(), l2.as_os_str()]) + .output() + .unwrap(); + let o = bench(&sp); + assert!(!o.status.success() && text(&o).contains("last stage"), "a source at stage 0's shape is refused: {}", text(&o)); + let o = bench(&bp); + assert!(o.status.success(), "{}", text(&o)); + let r: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap(); + let s = r["stages"].as_array().unwrap(); + assert_eq!(s[0]["clip"]["units_measured"], 0); + assert!(s[0]["clip"]["psnr_db"].is_null() && s[0]["units"].as_array().unwrap().iter().all(|u| u["why"].is_string())); + assert_eq!((s[1]["clip"]["units_measured"].as_u64(), s[1]["clip"]["exact"].as_bool()), (Some(2), Some(true))); + assert!(s[1]["direct"]["why"].as_str().is_some_and(|w| w.contains("identity predictor")), "{}", s[1]["direct"]); + // Layer 2 is identity, but stage 0 has another shape than the source: the identity + // comparator from stage 0 cannot reach it. + assert!(s[2]["direct"]["why"].as_str().is_some_and(|w| w.contains("shape")), "{}", s[2]["direct"]); + assert_eq!(s[2]["clip"]["units_measured"], 2); +} + +/// `wai stage base` refuses dimensions whose product overflows, with a message, not a +/// panic. +#[test] +fn stage_base_refuses_overflowing_dims() { + let f = scratch("tiny.rgb"); + std::fs::write(&f, [0u8; 3]).unwrap(); + let out = scratch("never.wai"); + for dims in ["2,4294967295,4294967295", "4294967295,65536,65536"] { + let o = wai(&[os("stage"), os("base"), os("--frames"), f.as_os_str(), os("--dims"), os(dims), os("--q"), os("4"), os("-o"), out.as_os_str()]); + let t = text(&o); + assert_eq!(o.status.code(), Some(2), "{dims}: {t}"); + assert!(t.contains("overflows") && !t.contains("panicked"), "{dims}: {t}"); + assert!(!out.exists()); + } +} diff --git a/wai/wai-rs/tests/staged_resources.rs b/wai/wai-rs/tests/staged_resources.rs index b89c75913..7fa9d2476 100644 --- a/wai/wai-rs/tests/staged_resources.rs +++ b/wai/wai-rs/tests/staged_resources.rs @@ -392,4 +392,36 @@ fn staged_sink_resources() { let c = w.declared_cost(); println!("parse: {} units, one table each: holds {} bytes, declares {} working bytes", n, parse.peak, c.working_bytes); assert!(parse.peak as u64 <= c.working_bytes, "the parse held {} bytes, past the declared {}", parse.peak, c.working_bytes); + + // ── A base is bounded before it is decoded (§4.5; integer-payloads §7) ── + // A few bytes of base envelope declare a clip of about 2^30 or 2^26 bytes. With a cost + // limit, decode_base refuses each (cost_over_limit) holding no more than a few + // times the envelope: nothing of the declared output is allocated. + use wai::int_payload::{declared_cost, DeclaredCost, IntParams}; + let empty = std::collections::BTreeMap::new(); + let limit = DeclaredCost::new(1 << 20, u64::MAX, u64::MAX, u128::MAX); + for (file, out) in [("clip_1gib.wai", 16 * 4096 * 5461 * 3u64), ("clip_64mib.wai", 4096 * 5461 * 3)] { + let env = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/vectors/staged_bounds").join(file)).unwrap(); + let w = Wai::from_bytes(&env).unwrap(); + assert_eq!(declared_cost(CAP_VIDEO_INT_MOTION, &w.payload, &IntParams::none()).unwrap().output_bytes, out, "{file} declares what its README states"); + let (r, win) = measure(|| wai::staged::decode_base(&env, &empty, Some(&limit), None)); + println!("bounded base: {file} ({} bytes, declares {out}): {:?} after {} bytes live at peak", env.len(), r.as_ref().err().map(|e| e.code()), win.peak); + assert_eq!(r.unwrap_err().code(), "cost_over_limit"); + assert!(win.peak < 64 * env.len() + (64 << 10), "{file}: {} bytes live while refusing it", win.peak); + } + // A decoded base past the chain budget is not kept: the sink holds no chain for it, + // and what it allocated for the base is released with the refusal. + let bh = [0x5e; 32]; + let (info, units) = base(16, 256, bh); + let out: u64 = units.iter().map(|u| u.samples.byte_len()).sum(); + let mut sink = StageSink::new(cfg(16, out / 2)); + let before = CUR.load(Relaxed); + let r = sink.add_base(info, units); + assert_eq!(r.len(), 1); + assert_eq!(r[0].envelope, Some(StageRefusal::ChainOverLimit)); + drop(r); + assert!(sink.chain(&bh).is_none()); + let left = CUR.load(Relaxed).saturating_sub(before); + println!("budget: a base of {out} output bytes in a {}-byte budget is refused; the sink holds {left} bytes of it", out / 2); + assert!(left < 4096, "the sink kept {left} bytes of a base it refused"); } diff --git a/wai/wai-rs/tests/vectors/staged_bounds/README.md b/wai/wai-rs/tests/vectors/staged_bounds/README.md new file mode 100644 index 000000000..6a0235530 --- /dev/null +++ b/wai/wai-rs/tests/vectors/staged_bounds/README.md @@ -0,0 +1,26 @@ +# staged_bounds — bases that declare far more than they carry + +Two `wai.video.int_motion` (`WIV1`, keyframe 0) base envelopes whose few bytes declare a +large decoded clip: grey frames from a residual stream of zeros, coded with a table +that makes the zero symbol nearly free (`{0, 65535, 65536}`), so the stream stays short +while its symbol count, and the declared output, are large. + +| file | envelope bytes | declared output | +|---|---|---| +| `clip_1gib.wai` | 3,243 | 16 frames of 4096 × 5461: 1,073,676,288 bytes, just under the clip cap (2^30) | +| `clip_64mib.wai` | 415 | 1 frame of 4096 × 5461: 67,104,768 bytes, just under the tensor cap (2^26 elements) | + +A sink that applies a cost limit to a base's declared cost refuses them before any +symbol is decoded or any output allocated (`cost_over_limit`; +`extensions/staged-delivery.md` §4.5, `extensions/integer-payloads.md` §7). +`tests/staged_resources.rs` checks that natively with a counting allocator, and +`wai-web/demo/verify_staged_wasm.mjs` checks it in wasm32 by the growth of linear memory. +Both also check the declared output stated above, so the files describe themselves. + +Two more envelopes carry a small valid `WIV1` payload under a capability SPEC §4 never +dispatches, with `wai.video.int_motion` as the fallback: `inert_refinement_primary.wai` +(`wai.video.int_refine`, refinement-only) and `inert_companion_primary.wai` +(`wai.video.lcevc`, companion-only). Each is inert: a sink decodes neither the +capability nor, through it, the fallback (`missing_capability`). They are what +`staged::tests::decode_base_runs_spec_4` builds, which checks them byte for byte +(`WAI_STAGED_BOUNDS_WRITE=1` rewrites them). diff --git a/wai/wai-rs/tests/vectors/staged_bounds/clip_1gib.wai b/wai/wai-rs/tests/vectors/staged_bounds/clip_1gib.wai new file mode 100644 index 0000000000000000000000000000000000000000..b398da24bccb8d1d414a16140d193d68ca0c6f80 GIT binary patch literal 3243 zcmWG{^fa8$z`#(gRGyfrWTj-NXP~5`l$)B8nF!>SWu~O&1NoVGC8>EOK!Kvvf}G6c z#FA8yqWqN9ocN;D!qUv5)LgK9wNi3oL1I#7PG(6ZP&Lp1Kco-PMJ;Mwc85tM^fX)RG zp`s8B6aoWgDEFlgL6}&*dqP8ZgK(b{TXT5cfb4JY^)-CVD&=* zv42dW-I`6kQ%`^NcyM8Ba?ZV5&i9V_Eboi>bMkh=T(-`j$!ukI`m;3|%=>@)=5ys_ zp0?*&_|Y`;KN!t~*n4>%Vp5P0cj+``dd`z8sC_Q1#t*Y2Jw&HvcO|{WKa+ zqiJt6KaG|Vqhu44&HWirnI%H-$p6( z->T9c+lpsq+AH8G60d~bZaI8-Vqp8u+T36_|F!==+W+vVzLTzc zzqIvTe`V_o$0olm1uOD)S{@WT&)s@K_C|EtA=8-X)g^~#d*(S6{F2BNFcbm#c#Exw@tdEz3k`4yd26Rrlu3U1|S z{_e4e^|pP}mhb<+@l9x0H}}P@?QILIc{XiZy>y<$p%a0hBN88RR42$Q3P&Hi`fyIv GCnW$c=oQic literal 0 HcmV?d00001 diff --git a/wai/wai-rs/tests/vectors/staged_bounds/clip_64mib.wai b/wai/wai-rs/tests/vectors/staged_bounds/clip_64mib.wai new file mode 100644 index 0000000000000000000000000000000000000000..3ab84b0c563770556cd2a346af1bacd6906e9e0d GIT binary patch literal 415 zcmWG{^fa8$z`#(gRGyfrWTj-NXP~5`l$)B8nF!>SWu~O&1NoVGC8>EOK!Kvvf}G6c z#FA8yqWqN9ocN;D!qUv5)LgK9wNi3oL1I#7PG(6ZP&Lp3G0G$}_8D_}H$iM(Z3=9Gc z456YBHkc0-Vg`u=!T5CR`1Q72L|v{M};_>uvj{E#LotNnEl>EGDPK;l$5_N0L_<5GET@=`r zg3Al}6P`EqIU;3}gYgL07QUtRKTpCi%+i`8y-pgyATk)j8JxvAoW}(WBa4f$k%NOg zT#TTAOE4J47(5i=ql7ZrHb~DlNzu*H(=D{Ev~9GgG`?MG-%0DB?WOIb?Wc7~&%32c z4=q7ENIOL9mtNrV5h=iqqte_l=@qKSrAb&PrCpd$Nwop#3Oa_QJ(#&Gop>uPzmpnx z@LoFgQA%Oulk^wU3(_N`zcBwMg_!i)_oNTBUVT`5dEFVk Leg8wt2S>u|Z z#xz^TRyaStmu;OOH3uHqtiHqSDxAseLQ0gSL~li?*BAB|Yty zsy(y>Z7*#ftzUYE^9Q5=TMkOIhol#nI4n)VIx20$+<;UYlrErSSlWS^o6?cj($X8L zfje)dWACK_EPs%GV|re?htwz5U!)MzUuj5vP@1q#M_+%wIZgzOQ}ovN15*9o^rP+S7XRZteNy N-01b&?^^C}{{w|?wQc|a literal 0 HcmV?d00001 diff --git a/wai/wai-web/demo/verify_staged_wasm.mjs b/wai/wai-web/demo/verify_staged_wasm.mjs index b3f2045f9..53f07b9cc 100644 --- a/wai/wai-web/demo/verify_staged_wasm.mjs +++ b/wai/wai-web/demo/verify_staged_wasm.mjs @@ -5,10 +5,26 @@ // case.json states, which wai_staged_vectors wrote from the native StageSink. // // A base whose decode is the integer path (case.json `decode.kind` "int") is decoded -// in wasm32 too, through StageBase.decode with its parameter set and its pin, and must -// give its stage0.bin byte for byte, the case's tier and the case's fallback flag. A -// PNG or FLAC base (no decoder in this build) is read from stage0.bin, whose SHA-256 -// is checked. Each base then takes the case's facts (attested, first unit). +// in wasm32 too, through StageBase.decode with its parameter set, its pin and a cost +// limit, and must give its stage0.bin byte for byte, the case's tier and the case's +// fallback flag. A PNG or FLAC base (no decoder in this build) is claimed from +// stage0.bin (StageBase.fromStage0), whose SHA-256 is checked; every report, unit and +// stage hash of its chain must then say claimed, and with that mark set aside equal +// the case's. Each base then takes the case's facts (attested, first unit). +// +// Then the binding's guards: +// - bounds: the bases in wai-rs/tests/vectors/staged_bounds/ declare clips of about +// 2^30 and 2^26 bytes in a few bytes; stageLayerInfo reads that cost, and StageBase.decode +// with a cost limit refuses them (cost_over_limit) while linear memory grows by +// less than a few MiB; a sink whose chain budget a base passes refuses the base +// (chain-over-limit); +// - facts: a base's viaFallback cannot be set, so its layers stay refused +// base-via-fallback; fromStage0 takes only the manifest's capability or its +// fallback, lowers a claimed tier to what the capability gives an unpinned base, +// refuses a misspelt tier, and marks the chain claimed; +// - addBase moves the base into the sink: the JS object is spent; +// - a seeded fuzz of every corpus base and layer through stageLayerInfo, +// StageBase.decode (with a limit) and offer never traps. // // node verify_staged_wasm.mjs [path/to/wai_web.js] [path/to/staged-conformance] // @@ -24,6 +40,8 @@ const here = dirname(fileURLToPath(import.meta.url)); const pkg = resolve(here, process.argv[2] ?? './pkg-node/wai_web.js'); const corpus = resolve(process.argv[3] ?? resolve(here, '../../staged-conformance')); const waiDir = resolve(corpus, '..'); +// A page's cost limit for a base decode: far above every corpus base. +const LIMIT = { output_bytes: 64 << 20, symbols: 64 << 20, working_bytes: 256 << 20 }; const wai = await import(pathToFileURL(pkg).href); const sha = (b) => createHash('sha256').update(b).digest('hex'); @@ -54,7 +72,7 @@ for (const name of names) { if (b.decode.kind === 'int') { const set = new Map(Object.entries(b.decode.set).map(([m, p]) => [m, bytes(`${waiDir}/${p}`)])); try { - base = wai.StageBase.decode(env, set.size ? set : undefined, b.decoded_via); + base = wai.StageBase.decode(env, set.size ? set : undefined, b.decoded_via, LIMIT); } catch (e) { fail(`${name}: ${b.file} does not decode: ${e.message ?? e}`); ok = false; continue; } @@ -64,42 +82,77 @@ for (const name of names) { if (base.capability !== b.decoded_via) { fail(`${name}: decoded via ${base.capability}`); ok = false; } } else { base = wai.StageBase.fromStage0(env, b.decoded_via, b.tier, shapes, [...elems][0], stage0); - base.viaFallback = b.via_fallback; + if (!base.claimed || base.viaFallback !== b.via_fallback || base.tier !== b.tier) { fail(`${name}: the claimed base's facts differ from the case's`); ok = false; } } base.attested = b.attested; base.setFirstUnit(b.first_unit); - bases.push(base); + const remake = () => { + const x = b.decode.kind === 'int' + ? wai.StageBase.decode(env, b.decode.set && Object.keys(b.decode.set).length ? new Map(Object.entries(b.decode.set).map(([m, p]) => [m, bytes(`${waiDir}/${p}`)])) : undefined, b.decoded_via, LIMIT) + : wai.StageBase.fromStage0(env, b.decoded_via, b.tier, shapes, [...elems][0], stage0); + x.attested = b.attested; + x.setFirstUnit(b.first_unit); + return x; + }; + bases.push({ base, hash: base.contentHash, claimed: base.claimed, remake, spent: false }); } if (!ok) continue; const files = new Map(c.files.map((f) => [f.file, bytes(`${dir}/${f.file}`)])); const sink = new wai.StageSink(c.config); const reports = []; + // A claimed chain's reports say so; set the mark aside to compare with the case. + const unmark = (r) => { + if (!Array.isArray(r)) return r; + return r.map((x) => { + const { claimed, ...rest } = x; + if (claimed !== undefined && claimed !== true) fail(`${name}: claimed is ${claimed}`); + return rest; + }); + }; + const anyClaimed = bases.some((b) => b.claimed); for (const s of c.steps) { + let got; switch (s.op) { case 'offer': - try { reports.push(sink.offer(files.get(s.file), s.at_ms, s.evidence)); } - catch (e) { reports.push({ error: String(e.message ?? e).split(':')[0] }); } + try { got = sink.offer(files.get(s.file), s.at_ms, s.evidence); } + catch (e) { got = { error: String(e.message ?? e).split(':')[0] }; } break; - case 'base': reports.push(sink.addBase(bases[s.index])); break; - case 'expire': reports.push(sink.expire(s.at_ms)); break; - case 'remove': reports.push(sink.removeBase(bases[s.index].contentHash) ?? []); break; - case 'attest': sink.markAttested(bases[s.index].contentHash); reports.push([]); break; + case 'base': { + // addBase spends the StageBase; a case that adds a base again gets a fresh one. + const b = bases[s.index]; + if (b.spent) b.base = b.remake(); + got = sink.addBase(b.base); + b.spent = true; + break; + } + case 'expire': got = sink.expire(s.at_ms); break; + case 'remove': got = sink.removeBase(bases[s.index].hash) ?? []; break; + case 'attest': sink.markAttested(bases[s.index].hash); got = []; break; default: fail(`${name}: unknown op ${s.op}`); } + if (Array.isArray(got) && !anyClaimed && got.some((x) => 'claimed' in x)) fail(`${name}: a report says claimed with no claimed base`); + reports.push(unmark(got)); } c.expect.reports.forEach((want, i) => { if (!same(reports[i], want)) fail(`${name}: step ${i} (${c.steps[i].op}) reports ${JSON.stringify(reports[i])}, the case states ${JSON.stringify(want)}`); }); if (reports.length !== c.expect.reports.length) fail(`${name}: ${reports.length} steps replayed, the case states ${c.expect.reports.length}`); c.expect.bases.forEach((want, i) => { - const got = sink.units(bases[i].contentHash); + const h = bases[i].hash; + const raw = sink.units(h); + const claimed = sink.isClaimed(h); + if (raw !== null && claimed !== bases[i].claimed) fail(`${name}: base ${i}: isClaimed ${claimed}`); + if (raw !== null && claimed && !(raw.claimed === true && raw.units.every((u) => u.claimed === true))) fail(`${name}: base ${i}: a claimed chain's units do not say so`); + const got = raw === null ? null : { ...raw, units: raw.units.map(({ claimed, ...u }) => u) }; + if (got !== null) delete got.claimed; if (!same(got, want)) { fail(`${name}: base ${i}'s final units differ: ${JSON.stringify(got)}`); return; } if (got === null) return; // The per-unit accessors agree with the summary, unit by unit. got.units.forEach((u, k) => { - const h = bases[i].contentHash; - const viaAccessor = sink.unitSha256(h, k) ?? null; + const sh = sink.unitSha256(h, k); + const viaAccessor = sh?.sha256 ?? null; if (viaAccessor !== u.stage_sha256) fail(`${name}: base ${i} unit ${k}: unitSha256 ${viaAccessor}`); + if (sh && sh.claimed !== claimed) fail(`${name}: base ${i} unit ${k}: unitSha256 claimed ${sh.claimed}`); if (sink.unitStage(h, k) !== u.stage || sink.unitProducer(h, k) !== u.producer || sink.unitTier(h, k) !== u.tier || sink.unitFrozen(h, k) !== u.frozen) { fail(`${name}: base ${i} unit ${k}: an accessor disagrees with units()`); } @@ -111,13 +164,135 @@ for (const name of names) { if (!out || out.length !== 3) fail(`${name}: base ${i} unit ${k}: unitShape`); } }); - if ((sink.clipSha256(bases[i].contentHash) ?? null) !== want.clip_sha256) fail(`${name}: base ${i}: clipSha256`); + if ((sink.clipSha256(h)?.sha256 ?? null) !== want.clip_sha256) fail(`${name}: base ${i}: clipSha256`); }); sink.free(); - bases.forEach((b) => b.free()); + bases.forEach((b) => { if (!b.spent) b.base.free(); }); replayed++; } +const expectThrow = (what, f, code) => { + try { f(); fail(`${what}: no error`); } + catch (e) { + const msg = String(e?.message ?? e); + if (e instanceof WebAssembly.RuntimeError) fail(`${what}: trapped: ${msg}`); + else if (code && !msg.startsWith(code)) fail(`${what}: ${msg.slice(0, 120)}, want ${code}`); + } +}; + +// ── bounds: a base is bounded before it is decoded ── +{ + const B = resolve(waiDir, 'wai-rs/tests/vectors/staged_bounds'); + const small = { output_bytes: 1 << 20, symbols: 1 << 20, working_bytes: 1 << 22 }; + for (const [file, out] of [['clip_1gib.wai', 16 * 4096 * 5461 * 3], ['clip_64mib.wai', 4096 * 5461 * 3]]) { + const env = bytes(`${B}/${file}`); + const info = wai.stageLayerInfo(env); + if (info.declared_cost?.output_bytes !== out) fail(`bounds: ${file} declares ${info.declared_cost?.output_bytes}, want ${out}`); + const before = wai.wasmMemoryBytes(); + expectThrow(`bounds: ${file} under a limit`, () => wai.StageBase.decode(env, undefined, undefined, small), 'cost_over_limit'); + const grew = wai.wasmMemoryBytes() - before; + if (!(before > 0) || grew > 4 * 2 ** 20) fail(`bounds: refusing ${file} grew linear memory by ${grew} bytes`); + } + // A base whose units pass the chain budget: refused, and not held. + const d = `${corpus}/cases/video_int_motion_gop_perframe`; + const env = bytes(`${d}/base_0.wai`); + const base = wai.StageBase.decode(env, undefined, undefined, small); + const s = new wai.StageSink({ bound: { max_envelopes: 4, max_age_ms: 1000 }, cost_limit: null, budget: 100, record_budget: 1000, live: null }); + const r = s.addBase(base); + if (r.length !== 1 || r[0].envelope !== 'chain-over-limit' || s.hasBase(wai.stageContentHash(env))) fail(`bounds: a base past the chain budget: ${JSON.stringify(r)}`); + // addBase spent the StageBase. + expectThrow('addBase spends the base', () => base.nUnits); + s.free(); +} + +// ── facts a page cannot raise ── +{ + const d = `${corpus}/cases/base_via_fallback`; + const c = JSON.parse(readFileSync(`${d}/case.json`, 'utf8')); + const base = wai.StageBase.decode(bytes(`${d}/${c.bases[0].file}`), undefined, c.bases[0].decoded_via, LIMIT); + if (base.viaFallback !== true) fail('facts: the fallback decode is not marked via fallback'); + try { base.viaFallback = false; } catch { /* no setter: a TypeError in a module */ } + if (base.viaFallback !== true) fail('facts: viaFallback was lowered'); + base.attested = true; + base.attested = false; + if (base.attested !== true) fail('facts: attested was lowered'); + const s = new wai.StageSink(c.config); + s.addBase(base); + const r = s.offer(bytes(`${d}/L1.wai`), 1, 'delivered-verified'); + if (r[0]?.envelope !== 'base-via-fallback') fail(`facts: a fallback base's layer: ${JSON.stringify(r)}`); + s.free(); + + // Two inert envelopes: a refinement-only and a companion-only capability with an + // int_motion fallback never reach it. + const fb = bytes(`${corpus}/cases/out_of_order/base.wai`); + for (const inert of ['inert_refinement_primary.wai', 'inert_companion_primary.wai']) { + expectThrow(`facts: ${inert}`, () => wai.StageBase.decode(bytes(`${resolve(waiDir, 'wai-rs/tests/vectors/staged_bounds')}/${inert}`), undefined, undefined, LIMIT), 'missing_capability'); + expectThrow(`facts: ${inert} via its fallback`, () => wai.StageBase.decode(bytes(`${resolve(waiDir, 'wai-rs/tests/vectors/staged_bounds')}/${inert}`), undefined, 'wai.video.int_motion', LIMIT), 'missing_capability'); + } + void fb; + + // A claimed base: only its manifest's capability or fallback; its tier no higher than + // an unpinned base's; a misspelt tier refused; its chain marked claimed. + const u = `${corpus}/cases/image_int_hyper_unpinned`; + const uc = JSON.parse(readFileSync(`${u}/case.json`, 'utf8')); + const uenv = bytes(`${u}/${uc.bases[0].file}`); + const shapes = new Uint32Array(uc.bases[0].units.flatMap((x) => x.shape)); + const junk = new Uint8Array(shapes[0] * shapes[1] * shapes[2]).fill(77); + expectThrow('facts: a capability the manifest does not name', () => wai.StageBase.fromStage0(uenv, 'wai.image.png', 'decode-equivalence', shapes, 'u8', junk), 'not-offered'); + expectThrow('facts: a misspelt tier', () => wai.StageBase.fromStage0(uenv, 'wai.neural.int_hyper', 'decode-equivalance', shapes, 'u8', junk), 'tier'); + const claimed = wai.StageBase.fromStage0(uenv, 'wai.neural.int_hyper', 'decode-equivalence', shapes, 'u8', junk); + if (claimed.tier !== 'none' || !claimed.claimed) fail(`facts: a claimed int_hyper base has tier ${claimed.tier}`); + const ch = claimed.contentHash; + const s2 = new wai.StageSink(uc.config); + s2.addBase(claimed); + const units = s2.units(ch); + if (!s2.isClaimed(ch) || units.claimed !== true || units.units.some((x) => x.claimed !== true)) fail('facts: the claimed chain is not marked'); + if (s2.unitSha256(ch, 0) !== undefined) fail('facts: a claimed base with no tier has a stage hash'); + const r2 = s2.offer(bytes(`${u}/${uc.files[0].file}`), 1, 'none'); + if (!r2.every((x) => x.claimed === true)) fail(`facts: a claimed chain's report is not marked: ${JSON.stringify(r2)}`); + s2.free(); +} + +// ── a seeded fuzz: nothing traps ── +{ + let seed = 99; + const rnd = () => { seed = (seed * 1103515245 + 12345) >>> 0; return seed; }; + let calls = 0; + const guard = (what, f) => { + calls++; + try { f(); } catch (e) { if (e instanceof WebAssembly.RuntimeError) fail(`fuzz: ${what} trapped: ${e}`); } + }; + for (const name of names) { + const d = `${corpus}/cases/${name}`; + const c = JSON.parse(readFileSync(`${d}/case.json`, 'utf8')); + const ints = c.bases.filter((b) => b.decode.kind === 'int'); + for (const b of ints) { + const env = bytes(`${d}/${b.file}`); + const set = new Map(Object.entries(b.decode.set).map(([m, p]) => [m, bytes(`${waiDir}/${p}`)])); + const pl = wai.stageLayerInfo(env, set.size ? set : undefined).payload_bytes; + for (let k = 0; k < 12; k++) { + const m = env.slice(); + for (let j = 0; j < 1 + (rnd() % 3); j++) m[m.length - pl + (rnd() % pl)] ^= 1 << (rnd() % 8); + guard(`${name}/${b.file}`, () => { wai.stageLayerInfo(m, set.size ? set : undefined); wai.StageBase.decode(m, set.size ? set : undefined, b.decoded_via, LIMIT).free(); }); + } + } + for (const f of c.files.map((x) => x.file)) { + const env = bytes(`${d}/${f}`); + for (let k = 0; k < 12; k++) { + const m = env.slice(); + for (let j = 0; j < 1 + (rnd() % 4); j++) m[rnd() % m.length] ^= 1 << (rnd() % 8); + guard(`${name}/${f}`, () => { + wai.stageLayerInfo(m); + wai.stageContentHash(m); + const s = new wai.StageSink(c.config); + try { s.offer(m, 0, 'none'); } finally { s.free(); } + }); + } + } + } + console.log(`fuzz: ${calls} mutated envelopes, no trap`); +} + // A layer's header and declared cost read before it is offered. { const d = `${corpus}/cases/video_int_motion_gop_perframe`; diff --git a/wai/wai-web/src/stage.rs b/wai/wai-web/src/stage.rs index 09a0ef61f..67679f742 100644 --- a/wai/wai-web/src/stage.rs +++ b/wai/wai-web/src/stage.rs @@ -118,9 +118,6 @@ pub(crate) fn report_value(r: &OfferReport) -> Value { }) } -pub(crate) fn reports_value(rs: &[OfferReport]) -> Value { - Value::Array(rs.iter().map(report_value).collect()) -} /// A chain's units and clip hash as the corpus states a base at the end of a case. pub(crate) fn units_value(c: &StageChain) -> Value { @@ -741,6 +738,20 @@ pub fn stage_content_hash(envelope: &[u8]) -> String { hex(&wai::jwp_hash::content_hash(envelope)) } +/// The bytes of linear memory this instance holds: what a page or a test reads to see +/// what a call allocated (linear memory only grows). 0 off wasm32. +#[wasm_bindgen(js_name = wasmMemoryBytes)] +pub fn wasm_memory_bytes() -> f64 { + #[cfg(target_arch = "wasm32")] + { + (core::arch::wasm32::memory_size(0) as f64) * 65536.0 + } + #[cfg(not(target_arch = "wasm32"))] + { + 0.0 + } +} + /// The exact sum of squared differences of two equal-length byte buffers (a `BigInt`). #[wasm_bindgen(js_name = sseU8)] pub fn sse_u8_js(a: &[u8], b: &[u8]) -> Result { @@ -818,13 +829,13 @@ mod tests { let idx = || s["index"].as_u64().unwrap() as usize; let got = match s["op"].as_str().unwrap() { "offer" => match sink.offer(&files[s["file"].as_str().unwrap()], at, ReceiptEvidence::from_token(s["evidence"].as_str().unwrap()).unwrap()) { - Ok(r) => reports_value(&r), + Ok(r) => marked(&r, |_| false), Err(staged::OfferError::NotWai1(_)) => json!({"error": "not-wai1"}), Err(_) => json!({"error": "encrypted"}), }, - "base" => { let (i, u) = bases[idx()].clone(); reports_value(&sink.add_base(i, u)) } - "expire" => reports_value(&sink.expire(at)), - "remove" => reports_value(&sink.remove_base(&bases[idx()].0.content_hash).unwrap_or_default()), + "base" => { let (i, u) = bases[idx()].clone(); marked(&sink.add_base(i, u), |_| false) } + "expire" => marked(&sink.expire(at), |_| false), + "remove" => marked(&sink.remove_base(&bases[idx()].0.content_hash).unwrap_or_default(), |_| false), "attest" => { sink.mark_attested(&bases[idx()].0.content_hash); json!([]) } o => panic!("{name}: op {o}"), }; -- 2.54.0 (Apple Git-157) From dfca621c07c41388609433dc36dd1a92e19d9c84 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 06:42:07 -0400 Subject: [PATCH 07/11] wai: the bounded-base check never prints a decoded clip when it fails Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/staged_resources.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/wai/wai-rs/tests/staged_resources.rs b/wai/wai-rs/tests/staged_resources.rs index 7fa9d2476..4e6606614 100644 --- a/wai/wai-rs/tests/staged_resources.rs +++ b/wai/wai-rs/tests/staged_resources.rs @@ -406,7 +406,11 @@ fn staged_sink_resources() { assert_eq!(declared_cost(CAP_VIDEO_INT_MOTION, &w.payload, &IntParams::none()).unwrap().output_bytes, out, "{file} declares what its README states"); let (r, win) = measure(|| wai::staged::decode_base(&env, &empty, Some(&limit), None)); println!("bounded base: {file} ({} bytes, declares {out}): {:?} after {} bytes live at peak", env.len(), r.as_ref().err().map(|e| e.code()), win.peak); - assert_eq!(r.unwrap_err().code(), "cost_over_limit"); + // Never print a decoded clip: it may be a gigabyte. + match r { + Err(e) => assert_eq!(e.code(), "cost_over_limit"), + Ok((_, units)) => panic!("{file}: decoded {} units instead of refusing", units.len()), + } assert!(win.peak < 64 * env.len() + (64 << 10), "{file}: {} bytes live while refusing it", win.peak); } // A decoded base past the chain budget is not kept: the sink holds no chain for it, -- 2.54.0 (Apple Git-157) From 72c96bc880466b0b2c2ae57b2cf3e08b439b0092 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 06:49:59 -0400 Subject: [PATCH 08/11] wai: CHANGELOG and demo README for the review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.4.0 subsection states the base rule of staged-delivery §4.5 (a base past the chain budget is not added; its decode is bounded by the cost limit first), SPEC §4 for a base with a cost limit and a key log, the claimed base and the facts a page cannot raise, the routed and traced `wai stage apply`, the encoder's caps, and the bench's source at the final shape. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 75 +++++++++++++++++++++++++------------- wai/wai-web/demo/README.md | 18 ++++++--- 2 files changed, 62 insertions(+), 31 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 232f2cf58..8bf076671 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -8,44 +8,69 @@ wire format may change and such changes are recorded here. ### Staged delivery in the browser, a base writer, a trace and a bench +- **A base is bounded before it is decoded, and its chain from the start** + ([staged-delivery](extensions/staged-delivery.md) §4.5, §4.2, §14). A base whose units' + output, with 32 bytes for each stage its kept record holds, passes the chain budget is + not added: its envelope is reported `chain-over-limit`, no chain is kept, its kept + record is untouched, and its layers stay held. A sink applies its cost limit to a + base's declared decode cost first, and SPEC §4 treats a base past it as one it does + not decode, moving on to the fallback. The corpus gains `base_over_budget`; + `staged_verify.py` implements the rule independently. - **The sink in the browser.** `wai-web` binds `wai::staged::StageSink` for JS - (`src/stage.rs`). `StageBase.decode` decodes a base envelope through the integer path - with its pin verified against the parameter set the page holds in memory, through the - manifest's capability or its fallback; `StageBase.fromStage0` takes a base decoded - elsewhere. `StageSink` adds bases, offers layers, expires holds, removes, attests and - forgets bases, and reads back every unit's stage, producer, frozen flag, tier, shape, - output, stage hash and squared error against a reference. `stageLayerInfo` reads a - layer's `refines`, `WIR1` header, declared cost and byte split (framing, manifest, - payload) before it is offered. Reports and units come back in the corpus's own form. - Media reach the page as `WAI1` bytes only. + (`src/stage.rs`). `StageBase.decode` decodes a base envelope by SPEC §4 through the + integer path, with its pin verified against the parameter set the page holds in + memory and under the page's cost limit; `StageBase.fromStage0` takes a base the page + decoded elsewhere, which is **claimed**: its capability must be the manifest's or its + fallback, its tier is never above what the capability gives an unpinned base, and + every report, unit and stage hash of its chain says `claimed`. A base's facts cannot + be raised from JS (no `viaFallback` setter; `attested` only rises), and `addBase` + takes the base. `StageSink` adds bases, offers layers, expires holds, removes, attests + and forgets bases, and reads back every unit's stage, producer, frozen flag, tier, + shape, output, stage hash (with its `claimed` mark) and squared error against a + reference. `stageLayerInfo` reads a layer's `refines`, `WIR1` header, declared cost and + byte split (framing, manifest, payload), and a base's declared decode cost, before + either is offered. Reports and units come back in the corpus's own form. Media reach + the page as `WAI1` bytes only. - **The browser equals the native sink.** `wai-web/demo/verify_staged_wasm.mjs` replays every case of `staged-conformance/` through the wasm32 build: each integer base is decoded in wasm32 and must give its `stage0.bin`, its tier and its fallback flag, and every report, final unit, stage hash and clip hash must equal what the native sink wrote. `wai-web-wasm.yml` runs it and watches the corpus; the crate's native tests replay the corpus through the binding's pure-Rust half as well. -- **One base path.** `staged::decode_base` (and `decode_base_via`, which takes the - capability a sink chose: the manifest's own or its fallback, recorded as decoded - through a fallback) is the base decode the CLI and the browser share. A pin counts - toward the base's tier only where it resolved; a capability the manifest does not - offer is refused (`not-offered`). +- **One base path.** `staged::decode_base` runs SPEC §4 for a base through + `Wai::dispatch_verified`: its capability, then its fallback, each only where it is + dispatchable, so a refinement-only or companion-only capability makes the envelope + inert (`missing_capability`) and never reaches its fallback. It takes the sink's cost + limit (checked before any symbol is decoded or output allocated, `cost_over_limit`) + and its key log (SPEC §3.1 check 5 over a pin and over an unpinned set; none skips + it). `decode_base_via` asks for one of the two capabilities and refuses any other + (`not-offered`). A pin counts toward the base's tier only where it resolved. A + one-file set pinned without a form offers every held file to the pin, the member the + capability reads first. The CLI and the browser share this path. - **A keyframe-0 base writer.** `int_payload::encode_wiv1_zero_motion` codes RGB8 frames as a `WIV1` keyframe-0 payload (zero motion, one block per frame, a closed group of pictures): the reference encoder the corpus generator used, now in the - library. The corpus regenerates byte for byte through it. `wai stage base` writes a - `wai.video.int_motion` base with it from raw frames and decodes it back before - writing. + library. The corpus regenerates byte for byte through it. It refuses a clip past the + format's caps before allocating (`too_large`). `wai stage base` writes a + `wai.video.int_motion` base with it from raw frames, with its dimensions multiplied + checked, and decodes it back before writing. - **A trace to check against.** `wai stage apply` is now a sink replay: envelopes are - taken in order, and a base given after its layers is decoded and releases the layers - held for it. `--json` prints each step's reports in the corpus's form and the units - each step changed, with their stage hashes, so a sink elsewhere can check every stage - it reaches. + taken in order, routed by content (one with `refines`, or a refinement-only + capability, is a layer; any other is a base, decoded by SPEC §4), and a base given + after its layers releases the layers held for it. `--json` prints each step's reports + in the corpus's form (bytes that are not a `WAI1` this sink reads are the step's + error, and the replay goes on), and every stage a unit reached in the step, in the + order the sink applied the layers (`StageSink::set_trace`), so a batch that takes a + unit up several stages lists each; every stage hash a delivery in that order reaches + is in it. A test replays the corpus through it and requires the cases' reports and + final units. - **`wai_stage_bench`.** Measures one staged set natively: per stage, the envelopes' bytes (framing, manifest and payload, and cumulative), PSNR or SNR per unit and over - the clip from the exact integer squared error, the time each stage took (median and - quartiles over at least 20 runs), the direct comparator (one layer per base at the - stage's step, straight from stage 0) and every unit's stage hash. Energy is reported - as unmetered. + the clip from the exact integer squared error against the source at the units' final + shape (a unit of another shape has no figure at that stage), the time each stage took + (median and quartiles over at least 20 runs), the direct comparator (one layer per + base at the stage's step, straight from stage 0, or why it does not apply) and every + unit's stage hash. Energy is reported as unmetered. ### Staged delivery: refinement layers, base admission, and WAI2 refinement components diff --git a/wai/wai-web/demo/README.md b/wai/wai-web/demo/README.md index f50d9f992..f90dc1e47 100644 --- a/wai/wai-web/demo/README.md +++ b/wai/wai-web/demo/README.md @@ -55,16 +55,22 @@ nodejs-target pkg and checks all four vectors' hashes): `src/stage.rs` binds `wai::staged::StageSink`, the staged-delivery sink a native player runs (`extensions/staged-delivery.md`), for JS: `StageBase.decode` decodes a -base envelope through the integer path with its pin, `StageSink` adds bases, offers -refinement layers, expires holds and removes bases in any order, and every unit's -stage, producer, tier, output and stage hash reads back. `stageLayerInfo` reads a -layer's `refines`, `WIR1` header and declared cost before it is offered. Reports and -units come back in the corpus's own form (`staged-conformance/README.md`). +base envelope by SPEC §4 through the integer path, with its pin and under the page's +cost limit; `StageBase.fromStage0` takes a base the page decoded elsewhere, which is +*claimed* (its chain's reports, units and stage hashes say so). `StageSink` adds bases +(taking them), offers refinement layers, expires holds and removes bases in any order, +and every unit's stage, producer, tier, output and stage hash reads back. +`stageLayerInfo` reads a layer's `refines`, `WIR1` header and declared cost, and a +base's declared decode cost, before either is offered. Reports and units come back in +the corpus's own form (`staged-conformance/README.md`). `verify_staged_wasm.mjs` replays every case of `staged-conformance/` through the wasm32 build and requires every report, final unit, stage hash and clip hash the native sink wrote; each integer base is decoded in wasm32 and must give its -`stage0.bin`: +`stage0.bin`. It then checks the binding's guards: bases that declare huge clips +(`wai-rs/tests/vectors/staged_bounds/`) refused under a cost limit with linear memory +watched, a base past the chain budget, base facts a page cannot raise, and a seeded +fuzz that must not trap: ``` # from wai-web/ -- 2.54.0 (Apple Git-157) From e0d85900d9ad1640a82d2417cc0e2bc1a1aeb026 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 06:52:48 -0400 Subject: [PATCH 09/11] wai: the sidecar rule excludes tests/vectors/staged_bounds, which holds nothing to decode Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/int_kernels_vectors.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/wai/wai-rs/tests/int_kernels_vectors.rs b/wai/wai-rs/tests/int_kernels_vectors.rs index fcccbec42..a7da2a40f 100644 --- a/wai/wai-rs/tests/int_kernels_vectors.rs +++ b/wai/wai-rs/tests/int_kernels_vectors.rs @@ -226,6 +226,7 @@ fn every_decoded_output_states_a_sha256() { ("staged-conformance", "each case.json states the SHA-256 of every stage-0 buffer and of every unit's stage (SPEC §7 Digests)"), ("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"), + ("vectors/staged_bounds", "bases that declare clips past a sink's cost limit, refused before any symbol is decoded, and inert envelopes: nothing here is decoded"), ]; let wai = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."); let mut dirs: Vec<(String, PathBuf)> = Vec::new(); -- 2.54.0 (Apple Git-157) From 06f7de0a4dd8bc20d4c9c6d930a6be5b6e2a4132 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 07:23:23 -0400 Subject: [PATCH 10/11] =?UTF-8?q?wai:=20round=203=20of=20the=20link-18=20r?= =?UTF-8?q?eview=20=E2=80=94=20claimed=20stage0=20in=20the=20CLI,=20inert?= =?UTF-8?q?=20claims,=20order=20and=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wai stage apply: with --stage0 the base is claimed; its step, its units, every report of its chain and its final entry say "claimed": true (as the browser binding marks a claimed chain). A base the tool cannot decode (encrypted, inert, refused by its pin or payload) is its step's error by code, and the replay goes on. - staged::decode_base refuses in a stated order: not-wai1, encrypted (SPEC §6.4 before §4), not-offered, then §4; a parameter set's listing is made only for a capability that reads a set, so a stray file name is no refusal for wai.video.int_motion. The doc states each token's vocabulary, and that the cost limit binds a base whose capability declares a cost (int_synth and int_mlicpp decode under their formats' caps, as with_cost_limit does). - wai-web: fromStage0 applies SPEC §4 as decode_base_via does (an inert envelope, a refinement-only or companion-only capability, is claimed neither through its capability nor its fallback; the capability must be dispatchable). StageBase.decode requires its limit argument: left out it is refused; null means the format's caps only. - cli_stage::apply_json_replays_the_corpus also replays the four PNG and FLAC cases through --stage0 (claimed marks set aside, and required) and budget_partly_applied: 43 cases agree. Tests: staged::tests::{decode_base_refuses_in_its_stated_order, decode_base_limit_binds_declared_costs_only}, cli_stage::{apply_stage0_marks_the_base_claimed, apply_records_a_refused_base_and_goes_on}, the inert cases in stage::tests::a_claimed_base_cannot_raise_its_facts and in demo/verify_staged_wasm.mjs, which also requires the limit argument. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 30 ++++-- wai/wai-rs/src/bin/wai_cli/stage.rs | 110 +++++++++++++++++---- wai/wai-rs/src/staged/mod.rs | 75 +++++++++++---- wai/wai-rs/src/staged/tests.rs | 45 +++++++++ wai/wai-rs/tests/cli_stage.rs | 121 ++++++++++++++++++++++-- wai/wai-web/demo/verify_staged_wasm.mjs | 22 +++-- wai/wai-web/src/stage.rs | 44 +++++++-- 7 files changed, 375 insertions(+), 72 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 8bf076671..9c4a4881d 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -12,17 +12,21 @@ wire format may change and such changes are recorded here. ([staged-delivery](extensions/staged-delivery.md) §4.5, §4.2, §14). A base whose units' output, with 32 bytes for each stage its kept record holds, passes the chain budget is not added: its envelope is reported `chain-over-limit`, no chain is kept, its kept - record is untouched, and its layers stay held. A sink applies its cost limit to a - base's declared decode cost first, and SPEC §4 treats a base past it as one it does - not decode, moving on to the fallback. The corpus gains `base_over_budget`; + record is untouched, and its layers stay held. Where a base's capability declares a + cost before decoding (the integer payloads SPEC §5 pins), a sink applies its cost + limit to that declared cost first, and SPEC §4 treats a base past it as one it does + not decode, moving on to the fallback; a capability that declares no cost + (`wai.neural.int_synth`, `wai.neural.int_mlicpp`) decodes under its format's caps. The corpus gains `base_over_budget`; `staged_verify.py` implements the rule independently. - **The sink in the browser.** `wai-web` binds `wai::staged::StageSink` for JS (`src/stage.rs`). `StageBase.decode` decodes a base envelope by SPEC §4 through the integer path, with its pin verified against the parameter set the page holds in - memory and under the page's cost limit; `StageBase.fromStage0` takes a base the page - decoded elsewhere, which is **claimed**: its capability must be the manifest's or its - fallback, its tier is never above what the capability gives an unpinned base, and - every report, unit and stage hash of its chain says `claimed`. A base's facts cannot + memory and under the page's cost limit, which is a required argument (`null` for the + format's caps only; it bounds a base whose capability declares a cost); + `StageBase.fromStage0` takes a base the page decoded elsewhere, which is **claimed**: + its capability must be the manifest's or its fallback and one SPEC §4 would take (an + inert envelope is refused), its tier is never above what the capability gives an + unpinned base, and every report, unit and stage hash of its chain says `claimed`. A base's facts cannot be raised from JS (no `viaFallback` setter; `attested` only rises), and `addBase` takes the base. `StageSink` adds bases, offers layers, expires holds, removes, attests and forgets bases, and reads back every unit's stage, producer, frozen flag, tier, @@ -44,7 +48,11 @@ wire format may change and such changes are recorded here. limit (checked before any symbol is decoded or output allocated, `cost_over_limit`) and its key log (SPEC §3.1 check 5 over a pin and over an unpinned set; none skips it). `decode_base_via` asks for one of the two capabilities and refuses any other - (`not-offered`). A pin counts toward the base's tier only where it resolved. A + (`not-offered`). The refusals come in a stated order: `not-wai1`, then `encrypted` + (SPEC §6.4 before §4), then `not-offered`, then §4's; a parameter set is read only + for a capability that reads one. Its own tokens are staged-delivery's kebab-case sink + vocabulary; SPEC and payload codes pass through in snake_case. A pin counts toward + the base's tier only where it resolved. A one-file set pinned without a form offers every held file to the pin, the member the capability reads first. The CLI and the browser share this path. - **A keyframe-0 base writer.** `int_payload::encode_wiv1_zero_motion` codes RGB8 @@ -58,8 +66,10 @@ wire format may change and such changes are recorded here. taken in order, routed by content (one with `refines`, or a refinement-only capability, is a layer; any other is a base, decoded by SPEC §4), and a base given after its layers releases the layers held for it. `--json` prints each step's reports - in the corpus's form (bytes that are not a `WAI1` this sink reads are the step's - error, and the replay goes on), and every stage a unit reached in the step, in the + in the corpus's form (bytes that are not a `WAI1` this sink reads, and a base it + cannot decode, are the step's error by their code, and the replay goes on; a base + read with `--stage0` is claimed, and its step, units, chain's reports and final entry + say so), and every stage a unit reached in the step, in the order the sink applied the layers (`StageSink::set_trace`), so a batch that takes a unit up several stages lists each; every stage hash a delivery in that order reaches is in it. A test replays the corpus through it and requires the cases' reports and diff --git a/wai/wai-rs/src/bin/wai_cli/stage.rs b/wai/wai-rs/src/bin/wai_cli/stage.rs index 6f89bb333..78f41cf47 100644 --- a/wai/wai-rs/src/bin/wai_cli/stage.rs +++ b/wai/wai-rs/src/bin/wai_cli/stage.rs @@ -21,6 +21,18 @@ fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } +fn unhex32(s: &str) -> Option<[u8; 32]> { + let b = s.as_bytes(); + if b.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (i, o) in out.iter_mut().enumerate() { + *o = u8::from_str_radix(std::str::from_utf8(&b[2 * i..2 * i + 2]).ok()?, 16).ok()?; + } + Some(out) +} + fn opt<'a>(args: &'a [String], flag: &str) -> Option<&'a str> { args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).map(String::as_str) } @@ -71,10 +83,16 @@ fn param_set(args: &[String]) -> Result>, String> { /// An integer base, decoded through `staged::decode_base` against `--set`. fn int_base(path: &str, env: &[u8], args: &[String]) -> Result<(BaseInfo, Vec), String> { + decode_int_base(env, args)?.map_err(|e| format!("{path}: {e}")) +} + +/// A base decoded by SPEC §4 against `--set`: the outer error is the tool's (an +/// unreadable `--set`), the inner one the base's refusal. +fn decode_int_base(env: &[u8], args: &[String]) -> Result), wai::staged::BaseError>, String> { let set = param_set(args)?; let files: BTreeMap = set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); // No cost limit and no key log: a tool over files its user chose (check 5 skipped). - wai::staged::decode_base(env, &files, None, None).map_err(|e| format!("{path}: {e}")) + Ok(wai::staged::decode_base(env, &files, None, None)) } /// The base's info and units, decoded as the module doc states. @@ -233,6 +251,13 @@ fn stage0_json(info: &BaseInfo, units: &[UnitBuf]) -> Vec { /// the first envelope is a base read from that file. Bytes that are not a `WAI1` this /// sink reads are that step's error (`not-wai1`, `encrypted`), and the replay goes on. /// +/// With `--stage0`, the first base's units are that file's, not a decode: the base is +/// **claimed**, and its step, its units, the reports of its chain and its final entry +/// say `"claimed": true` (as the browser binding marks a claimed base's chain); its +/// stage hashes are of what the file holds and the layers applied to it. A base this +/// sink cannot decode (encrypted, inert, refused by its pin or its payload) is its +/// step's error, by its code, and the replay goes on. +/// /// Prints every report and the units at the end. With `--json`, one JSON document /// instead: for each step its reports in the corpus's form (or its error), then every /// stage a unit reached in that step, in the order the sink applied the layers (a @@ -251,11 +276,18 @@ fn apply(args: &[String]) -> Result<(), String> { let mut bases: Vec<[u8; 32]> = Vec::new(); let mut frozen: BTreeMap<([u8; 32], u32), bool> = BTreeMap::new(); let mut steps: Vec = Vec::new(); + // With --stage0 the first base's units are the file's, not a decode: the base is + // claimed, and so is everything its chain reports. + let mut claimed: Option<[u8; 32]> = None; for (i, path) in pos.iter().enumerate() { + // The base whose chain this step concerns, where it is one. + let mut chain_of: Option<[u8; 32]> = None; let (op, object, outcome, mut units): (&str, [u8; 32], Result, &str>, Vec) = if i == 0 && opt(args, "--stage0").is_some() { let (env, info, units) = base(path, args)?; let first = stage0_json(&info, &units); bases.push(info.content_hash); + claimed = Some(info.content_hash); + chain_of = Some(info.content_hash); ("base", wai::jwp_hash::content_hash(&env), Ok(sink.add_base(info, units)), first) } else { let bytes = read(path)?; @@ -263,6 +295,7 @@ fn apply(args: &[String]) -> Result<(), String> { match Wai::from_bytes(&bytes) { Err(_) => ("offer", object, Err("not-wai1"), vec![]), Ok(w) if w.manifest.refines.is_some() || wai::codecs::is_refinement_only(w.capability()) => { + chain_of = w.refines().and_then(Result::ok).and_then(|r| unhex32(&r.base)); match sink.offer(&bytes, i as u64, ReceiptEvidence::None) { Ok(r) => ("offer", object, Ok(r), vec![]), Err(wai::staged::OfferError::NotWai1(_)) => ("offer", object, Err("not-wai1"), vec![]), @@ -273,15 +306,22 @@ fn apply(args: &[String]) -> Result<(), String> { if i > 0 && opt(args, "--stage0").is_some() { return Err(format!("{path}: a second base; --stage0 reads the first base only")); } - let (info, units) = int_base(path, &bytes, args)?; - let first = if sink.chain(&info.content_hash).is_some() { vec![] } else { stage0_json(&info, &units) }; - if !bases.contains(&info.content_hash) { - bases.push(info.content_hash); + match decode_int_base(&bytes, args)? { + // A base this sink cannot decode (encrypted, inert, refused by + // its pin or its payload) is the step's error; the replay goes on. + Err(e) => ("base", object, Err(e.code()), vec![]), + Ok((info, units)) => { + chain_of = Some(info.content_hash); + let first = if sink.chain(&info.content_hash).is_some() { vec![] } else { stage0_json(&info, &units) }; + if !bases.contains(&info.content_hash) { + bases.push(info.content_hash); + } + let r = sink.add_base(info, units); + // A base refused (chain-over-limit) has no stage 0. + let first = if sink.chain(&object).is_some() { first } else { vec![] }; + ("base", object, Ok(r), first) + } } - let r = sink.add_base(info, units); - // A base refused (chain-over-limit) has no stage 0. - let first = if sink.chain(&object).is_some() { first } else { vec![] }; - ("base", object, Ok(r), first) } } }; @@ -295,22 +335,39 @@ fn apply(args: &[String]) -> Result<(), String> { } } } + let on_claimed = claimed.is_some() && chain_of == claimed; if as_json { let mut step = json!({"file": path, "op": op, "object": hex(&object)}); match &outcome { - Ok(r) => step["reports"] = json!(r.iter().map(report_json).collect::>()), + Ok(r) => step["reports"] = json!(r.iter().map(|x| { + let mut v = report_json(x); + if on_claimed { + v["claimed"] = json!(true); + } + v + }).collect::>()), Err(e) => { step["error"] = json!(e); step["reports"] = json!([]); } } + if on_claimed { + step["claimed"] = json!(true); + } + if let Some(c) = claimed { + for u in units.iter_mut().filter(|u| u["base"] == hex(&c)) { + u["claimed"] = json!(true); + } + } step["units"] = Value::Array(units); steps.push(step); } else { match &outcome { Err(e) => println!("{path}: {} {e}", &hex(&object)[..16]), Ok(r) => { - if op == "base" { + if op == "base" && on_claimed { + println!("{path}: base {} claimed: its units are --stage0's, not a decode", &hex(&object)[..16]); + } else if op == "base" { println!("{path}: base {} decoded", &hex(&object)[..16]); } for x in r { @@ -324,21 +381,34 @@ fn apply(args: &[String]) -> Result<(), String> { let finals: Vec = bases .iter() .filter_map(|b| sink.chain(b)) - .map(|c| json!({ - "object": hex(&c.base().content_hash), - "capability": c.base().capability, - "via_fallback": c.base().via_fallback, - "units": (0..c.n_units()).map(|u| unit_json(c, u)).collect::>(), - "clip_sha256": c.clip_sha256().map(|h| hex(&h)), - })) + .map(|c| { + let is_claimed = claimed == Some(c.base().content_hash); + let mut v = json!({ + "object": hex(&c.base().content_hash), + "capability": c.base().capability, + "via_fallback": c.base().via_fallback, + "units": (0..c.n_units()).map(|u| { + let mut x = unit_json(c, u); + if is_claimed { + x["claimed"] = json!(true); + } + x + }).collect::>(), + "clip_sha256": c.clip_sha256().map(|h| hex(&h)), + }); + if is_claimed { + v["claimed"] = json!(true); + } + v + }) .collect(); let doc = json!({"format": "wai-stage-apply/1", "steps": steps, "bases": finals}); println!("{}", serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?); } else { for b in &bases { if let Some(c) = sink.chain(b) { - if bases.len() > 1 { - println!("base {}:", &hex(b)[..16]); + if bases.len() > 1 || claimed == Some(*b) { + println!("base {}:{}", &hex(b)[..16], if claimed == Some(*b) { " claimed (--stage0): these hashes are of the file's units and the layers on them, not of a decode" } else { "" }); } print_units(c); } diff --git a/wai/wai-rs/src/staged/mod.rs b/wai/wai-rs/src/staged/mod.rs index 248ae66f1..298af3a08 100644 --- a/wai/wai-rs/src/staged/mod.rs +++ b/wai/wai-rs/src/staged/mod.rs @@ -119,24 +119,33 @@ fn int_path(cap: &str) -> bool { crate::codecs::pinned_int_payload(cap).is_some() || matches!(cap, crate::codecs::CAP_NEURAL_INT_SYNTH | crate::codecs::CAP_NEURAL_INT_MLICPP) } -/// The sets a sink holding `set` offers a decode through `cap` (SPEC §3.1): for a -/// capability whose set is one file, pinned without a form, every file as its own -/// candidate (the pin resolves when any one satisfies it), the member the capability -/// reads first; otherwise the files as one set, which an unpinned decode reads by its -/// members' names. -fn held_for<'a>(cap: &str, prior: Option<&crate::container::Prior>, set: &'a BTreeMap, listing: &'a [u8]) -> Vec> { +/// The sets a sink holding `set` offers a decode through `cap` (SPEC §3.1): none for a +/// capability that reads no parameter set (`wai.video.int_motion`), whatever `set` +/// holds; for a capability whose set is one file, pinned without a form, every file as +/// its own candidate (the pin resolves when any one satisfies it), the member the +/// capability reads first; otherwise the files as one set, which an unpinned decode +/// reads by its members' names, with their listing (SPEC §3.1), made from `set` the +/// first time a capability needs it. A set whose listing cannot be made (a file name +/// SPEC §3.1 refuses) is that capability's refusal. +fn held_for<'a>( + cap: &str, + prior: Option<&crate::container::Prior>, + set: &'a BTreeMap, + listing: &'a std::cell::OnceCell, crate::container::PinRefusal>>, +) -> Result>, crate::container::PinRefusal> { use crate::pin::HeldBytes; - if set.is_empty() { - return vec![]; + if set.is_empty() || (crate::codecs::is_known_capability(cap) && !crate::codecs::decodes_against_parameter_set(cap)) { + return Ok(vec![]); } let one_file = crate::container::pin_shape(cap) == crate::codecs::ParameterSetShape::OneFile; if one_file && prior.is_some_and(|p| p.form().is_none()) { let member = crate::int_payload::set_members(cap).first().copied(); let mut v: Vec = member.and_then(|m| set.get(m)).map(|b| HeldBytes::File(b)).into_iter().collect(); v.extend(set.iter().filter(|(k, _)| Some(k.as_str()) != member).map(|(_, b)| HeldBytes::File(b))); - v + Ok(v) } else { - vec![HeldBytes::Set { listing, files: set }] + let listing = listing.get_or_init(|| crate::pin::listing_from(set)).as_ref().map_err(Clone::clone)?; + Ok(vec![HeldBytes::Set { listing, files: set }]) } } @@ -156,16 +165,38 @@ fn routes_on(e: &crate::int_payload::IntPayloadError) -> bool { /// makes the envelope inert, and its fallback is never reached), through /// `int_payload::decode_int`. /// +/// **The order of refusals.** Bytes that are not a `WAI1` envelope (`not-wai1`); then +/// an envelope whose manifest declares an encryption this sink cannot open +/// (`encrypted`: SPEC §6.4 step 2 comes before §4); then, for [`decode_base_via`], a +/// capability the manifest does not offer (`not-offered`); then §4's dispatch, whose +/// refusal is the first kept for the capability before the one kept for the fallback +/// (a pin or set refusal, a payload code, or `missing_capability`); then an output with +/// no units (`no-units`). A parameter set is read only for a capability that reads one: +/// a file name in `set` that SPEC §3.1 would refuse is no refusal for +/// `wai.video.int_motion`. +/// +/// **The codes.** This function's own refusals (`not-wai1`, `encrypted`, +/// `not-offered`, `no-units`) are tokens of staged-delivery's sink vocabulary, in +/// kebab-case. Codes from elsewhere pass through verbatim, in snake_case: SPEC §4's +/// `missing_capability`, SPEC §3.1's pin refusals (`prior_revoked`, `set_incomplete`, +/// …) and the integer payloads' codes (`cost_over_limit`, `stream_exhausted`, …, +/// `extensions/integer-payloads.md` §8). +/// /// - `set` is the capability's parameter set, each file by the name SPEC §5 gives it /// (empty for `wai.video.int_motion`, which reads none). The envelope's pin, where /// it carries one, is verified against those bytes (SPEC §3.1). -/// - `limit` is the sink's cost limit: where given, the payload's declared cost -/// (`int_payload::check_cost`) is checked before any symbol is decoded or any -/// output allocated, and a payload past it is `cost_over_limit`. That refusal, like -/// a capability this build has no decoder for (`unsupported_capability`) or a set -/// that lacks a member (`set_incomplete`), makes the capability unsupported for this -/// envelope, and dispatch moves on to the fallback. With no limit, the decode is -/// bounded by the payload format's caps alone. +/// - `limit` is the sink's cost limit, for a base whose capability declares a cost +/// before decoding: the integer payloads SPEC §5 pins a format to (`WIV1`, `WIH1`, +/// `WIA1`, `WIS1`; `int_payload::check_cost`). Their declared cost is checked +/// before any symbol is decoded or any output allocated, and a payload past it is +/// `cost_over_limit`. That refusal, like a capability this build has no decoder +/// for (`unsupported_capability`) or a set that lacks a member (`set_incomplete`), +/// makes the capability unsupported for this envelope, and dispatch moves on to the +/// fallback. A capability that declares no cost (`wai.neural.int_synth`, +/// `wai.neural.int_mlicpp`) is decoded under its format's own caps whatever the +/// limit, as `ModelRegistry::with_cost_limit` does (staged-delivery §4.5: "where its +/// capability declares a cost"). With no limit, every decode is bounded by its +/// format's caps alone. /// - `authority` is the sink's key log: where given, SPEC §3.1's check 5 runs over the /// pin and over an unpinned decode's set (`prior_revoked`, `prior_not_authorised`). /// `None` skips check 5: a sink that holds no key log (SPEC §4, *Module @@ -200,20 +231,24 @@ pub fn decode_base_via( use crate::container::Inert; use crate::int_payload::{check_cost, decode_int, resolve}; let w = Wai::from_bytes(envelope).map_err(|e| BaseError::NotWai1(e.to_string()))?; + // SPEC §6.4 step 2 comes before §4: an encrypted payload reaches no capability. + if w.manifest.security.as_ref().is_some_and(|s| !s.is_plaintext()) { + return Err(BaseError::Encrypted); + } let primary = w.capability().to_string(); if let Some(c) = via { if c != primary && w.fallback() != Some(c) { return Err(BaseError::NotOffered(c.to_string())); } } - let listing = if set.is_empty() { vec![] } else { crate::pin::listing_from(set).map_err(BaseError::Pin)? }; + let listing = std::cell::OnceCell::new(); let mut excluded: Vec = Vec::new(); // The first refusal that made a capability unsupported, with that capability. let mut kept: Option<(String, BaseError)> = None; loop { let d = w.dispatch_verified( |c| int_path(c) && via.is_none_or(|v| v == c) && !excluded.iter().any(|x| x == c), - |c, p| resolve(c, Some(p), &held_for(c, Some(p), set, &listing), authority), + |c, p| held_for(c, Some(p), set, &listing).and_then(|h| resolve(c, Some(p), &h, authority)), ); let (cap, params) = match d { Ok(d) => { @@ -222,7 +257,7 @@ pub fn decode_base_via( Some(p) => (cap, p), // Unpinned (no pin, or one that does not apply): the set the sink // holds for the capability, with check 5 over what it reads. - None => match resolve(&cap, None, &held_for(&cap, None, set, &listing), authority) { + None => match held_for(&cap, None, set, &listing).and_then(|h| resolve(&cap, None, &h, authority)) { Ok(p) => (cap, p), Err(r) => { kept.get_or_insert((cap.clone(), BaseError::Pin(r))); diff --git a/wai/wai-rs/src/staged/tests.rs b/wai/wai-rs/src/staged/tests.rs index 0e671dae7..32e022466 100644 --- a/wai/wai-rs/src/staged/tests.rs +++ b/wai/wai-rs/src/staged/tests.rs @@ -1739,3 +1739,48 @@ fn add_base_counts_its_state_against_the_budget() { assert!(room.add_base(info.clone(), units).is_empty(), "a base exactly at the budget is added"); assert_eq!(room.chain(&info.content_hash).map(|c| c.state_bytes()), Some(out)); } + +/// `decode_base` refuses in its stated order: `not-wai1`, then `encrypted` (SPEC §6.4 +/// before §4), then `not-offered`, then §4; and a parameter set is read only for a +/// capability that reads one, so a stray file name SPEC §3.1 refuses is no refusal for +/// `wai.video.int_motion`. +#[cfg(feature = "video_int")] +#[test] +fn decode_base_refuses_in_its_stated_order() { + use super::{decode_base, decode_base_via}; + use crate::codecs::{CAP_VIDEO_INT_HYPER, CAP_VIDEO_INT_MOTION as MOTION}; + let none = std::collections::BTreeMap::new(); + let plain = motion_envelope(MOTION, None); + let mut w = Wai::from_bytes(&plain).unwrap(); + w.manifest.security = Some(crate::container::Security::new("aes-256-gcm", 1)); + let enc = w.to_bytes().unwrap(); + let bad: std::collections::BTreeMap = [("../x".to_string(), b"a".as_slice())].into_iter().collect(); + assert_eq!(decode_base(b"WAI1", &bad, None, None).unwrap_err().code(), "not-wai1"); + assert_eq!(decode_base(&enc, &none, None, None).unwrap_err().code(), "encrypted"); + assert_eq!(decode_base_via(&enc, Some(CAP_VIDEO_INT_HYPER), &none, None, None).unwrap_err().code(), "encrypted", "encrypted before not-offered"); + assert_eq!(decode_base(&enc, &bad, None, None).unwrap_err().code(), "encrypted", "encrypted before the set is read"); + assert_eq!(decode_base_via(&plain, Some(CAP_VIDEO_INT_HYPER), &bad, None, None).unwrap_err().code(), "not-offered", "not-offered before the set is read"); + let (info, _) = decode_base(&plain, &bad, None, None).expect("int_motion reads no set, so a stray name is no refusal"); + assert_eq!(info.capability, MOTION); + let notes: std::collections::BTreeMap = [("notes.txt".to_string(), b"a".as_slice())].into_iter().collect(); + assert!(decode_base(&plain, ¬es, None, None).is_ok()); +} + +/// The cost limit binds a base whose capability declares a cost before decoding (the +/// integer payloads SPEC §5 pins); `wai.neural.int_synth` declares none, so it decodes +/// under its format's caps whatever the limit, as the neural registry's limit does. +#[test] +fn decode_base_limit_binds_declared_costs_only() { + use super::decode_base; + let v = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../neural-conformance/vectors/gradient"); + let latent = std::fs::read(v.join("latent.bin")).unwrap(); + let spec = std::fs::read(v.join("int_synth.json")).unwrap(); + let m = Manifest::new("image", "replicate", ModelRequirement::new(crate::codecs::CAP_NEURAL_INT_SYNTH), Conditioning::new("int_synth")); + let env = Wai::new(m, latent).to_bytes().unwrap(); + let set: std::collections::BTreeMap = [("int_synth.json".to_string(), spec.as_slice())].into_iter().collect(); + let zero = crate::int_payload::DeclaredCost::new(0, 0, 0, 0); + let (info, units) = decode_base(&env, &set, Some(&zero), None).expect("int_synth declares no cost"); + assert_eq!(info.capability, crate::codecs::CAP_NEURAL_INT_SYNTH); + assert!(units.iter().map(|u| u.samples.byte_len()).sum::() > 0); + assert_eq!(crate::int_payload::check_cost(crate::codecs::CAP_NEURAL_INT_SYNTH, &[], &crate::int_payload::IntParams::none(), &zero).unwrap(), None, "no declared cost to check"); +} diff --git a/wai/wai-rs/tests/cli_stage.rs b/wai/wai-rs/tests/cli_stage.rs index dec0be3b3..13e24f393 100644 --- a/wai/wai-rs/tests/cli_stage.rs +++ b/wai/wai-rs/tests/cli_stage.rs @@ -290,9 +290,10 @@ fn stage_bench_reports_bytes_quality_and_hashes() { } /// `wai stage apply --json` is the corpus's sink: every case it can replay (bases -/// decoded through the integer path, offers with no receipt, no expiry, removal or -/// attestation, at the corpus's default configuration, since the tool's sink applies no -/// budget or bound) gives, step by step, the reports `case.json` states, with a step +/// decoded through the integer path or, one given first, read with `--stage0`, offers +/// with no receipt, no expiry, removal or attestation, at the corpus's default +/// configuration, since the tool's sink applies no budget or bound, or at a budget that +/// never binds) gives, step by step, the reports `case.json` states, with a step /// whose bytes are not a `WAI1` this sink reads as that step's error, and ends with the /// final units it states. Ported from the review's cli_vs_corpus.py. #[test] @@ -301,7 +302,7 @@ fn apply_json_replays_the_corpus() { let mut names: Vec = std::fs::read_dir(&root).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); names.sort(); let default_cfg = serde_json::json!({"bound": {"max_envelopes": 16, "max_age_ms": 1000}, "cost_limit": null, "budget": 1u64 << 30, "record_budget": 1u64 << 20, "live": null}); - let (mut same, mut skipped) = (0, 0); + let (mut same, mut skipped, mut extra) = (0, 0, 0); let mut differ: Vec = Vec::new(); for name in names { let d = root.join(&name); @@ -309,15 +310,28 @@ fn apply_json_replays_the_corpus() { let steps = c["steps"].as_array().unwrap(); let bases = c["bases"].as_array().unwrap(); let sets: std::collections::BTreeSet = bases.iter().map(|b| b["decode"]["set"].to_string()).collect(); + // A base the tool cannot decode (PNG, FLAC) is read from its stage0.bin with + // --stage0, where the case has that one base, given first, in units of one shape. + let stage0_base = bases.len() == 1 + && bases[0]["decode"]["kind"] != "int" + && steps[0]["op"] == "base" + && steps.iter().filter(|s| s["op"] == "base").count() == 1 + && bases[0]["units"].as_array().unwrap().windows(2).all(|w| w[0]["shape"] == w[1]["shape"]); + // budget_partly_applied states a budget with no byte to spare that never refuses + // anything: the tool's sink, with no budget, reports what it reports. + let config_ok = c["config"] == default_cfg || name == "budget_partly_applied"; let skip = steps.iter().any(|s| !["base", "offer"].contains(&s["op"].as_str().unwrap())) || steps.iter().any(|s| s.get("evidence").is_some_and(|e| e != "none")) - || bases.iter().any(|b| b["attested"] == true || b["first_unit"] != 0 || b["decode"]["kind"] != "int") - || c["config"] != default_cfg + || bases.iter().any(|b| b["attested"] == true || b["first_unit"] != 0 || (b["decode"]["kind"] != "int" && !stage0_base)) + || !config_ok || sets.len() > 1; if skip { skipped += 1; continue; } + if stage0_base || name == "budget_partly_applied" { + extra += 1; + } // The parameter set, by the names SPEC §5 gives its files. let set: serde_json::Map = bases[0]["decode"]["set"].as_object().cloned().unwrap_or_default(); let set_dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_stage/corpus_sets").join(&name); @@ -330,6 +344,15 @@ fn apply_json_replays_the_corpus() { args.push("--set".into()); args.push(set_dir.clone().into()); } + if stage0_base { + let b = &bases[0]; + let shape: Vec = b["units"][0]["shape"].as_array().unwrap().iter().map(|x| x.to_string()).collect(); + args.extend(["--stage0".into(), d.join(b["stage0"].as_str().unwrap()).into_os_string(), "--dims".into(), shape.join(",").into(), + "--units".into(), b["units"].as_array().unwrap().len().to_string().into()]); + if b["units"][0]["elem"] == "i16" { + args.extend(["--elem".into(), "i16".into()]); + } + } for s in steps { let f = if s["op"] == "base" { bases[s["index"].as_u64().unwrap() as usize]["file"].as_str().unwrap() } else { s["file"].as_str().unwrap() }; args.push(d.join(f).into()); @@ -341,8 +364,19 @@ fn apply_json_replays_the_corpus() { continue; } let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + // A claimed base's chain says so (--stage0); the mark is set aside to compare, + // and must be on every report of that chain. let got: Vec = doc["steps"].as_array().unwrap().iter() - .map(|st| if let Some(e) = st.get("error") { serde_json::json!({"error": e}) } else { st["reports"].clone() }) + .map(|st| if let Some(e) = st.get("error") { serde_json::json!({"error": e}) } else { + serde_json::Value::Array(st["reports"].as_array().unwrap().iter().map(|r| { + let mut r = r.clone(); + let mark = r.as_object_mut().unwrap().remove("claimed"); + if stage0_base && mark != Some(serde_json::json!(true)) { + differ.push(format!("{name}: a report of the claimed chain is not marked")); + } + r + }).collect()) + }) .collect(); if serde_json::Value::Array(got.clone()) != c["expect"]["reports"] { let i = got.iter().zip(c["expect"]["reports"].as_array().unwrap()).position(|(g, w)| g != w).unwrap_or(got.len()); @@ -369,9 +403,10 @@ fn apply_json_replays_the_corpus() { } same += 1; } - println!("apply --json against the corpus: {same} cases agree, {} differ, {skipped} need what the tool's sink does not do", differ.len()); + println!("apply --json against the corpus: {same} cases agree ({extra} through --stage0 or a budget that never binds), {} differ, {skipped} need what the tool's sink does not do", differ.len()); assert!(differ.is_empty(), "{}", differ.join("\n")); assert!(same >= 30, "only {same} cases compared"); + assert_eq!(extra, 5, "the four PNG and FLAC cases and budget_partly_applied replay"); } /// `wai_stage_bench` on a set whose layer 1 changes the units' shape (up2) and whose @@ -432,3 +467,73 @@ fn stage_base_refuses_overflowing_dims() { assert!(!out.exists()); } } + + +/// `--stage0` publishes a claimed base: the units are the file's bytes, not a decode, +/// so the base's step, each of its units, every report of its chain and its final entry +/// say `"claimed": true`, and the stage-0 hashes are those of the file's bytes. +#[test] +fn apply_stage0_marks_the_base_claimed() { + let d = corpus("cases/out_of_order"); + // 384 bytes that are no decode of anything: two 8 × 8 RGB8 units. + let mut x = 0x9e37_79b9u32; + let junk: Vec = (0..384).map(|_| { x ^= x << 13; x ^= x >> 17; x ^= x << 5; x as u8 }).collect(); + let jpath = scratch("junk384.bin"); + std::fs::write(&jpath, &junk).unwrap(); + let o = wai(&[os("stage"), os("apply"), os("--json"), os("--stage0"), jpath.as_os_str(), os("--units"), os("2"), os("--dims"), os("8,8,3"), + d.join("base.wai").as_os_str(), d.join("L1.wai").as_os_str()]); + assert!(o.status.success(), "{}", text(&o)); + let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let steps = doc["steps"].as_array().unwrap(); + assert_eq!(steps[0]["claimed"], true); + let s0 = steps[0]["units"].as_array().unwrap(); + assert_eq!(s0.len(), 2); + for (u, e) in s0.iter().enumerate() { + assert_eq!(e["claimed"], true); + assert_eq!(e["stage_sha256"].as_str().unwrap(), sha256_hex(&junk[u * 192..(u + 1) * 192]), "the hash is the file's bytes'"); + } + assert_eq!(steps[1]["claimed"], true, "the layer is on the claimed chain"); + assert!(steps[1]["reports"].as_array().unwrap().iter().all(|r| r["claimed"] == true)); + assert!(steps[1]["units"].as_array().unwrap().iter().all(|u| u["claimed"] == true)); + let b = &doc["bases"][0]; + assert_eq!(b["claimed"], true); + assert!(b["units"].as_array().unwrap().iter().all(|u| u["claimed"] == true)); + // Without --stage0 the decoded base claims nothing. + let o = wai(&[os("stage"), os("apply"), os("--json"), d.join("base.wai").as_os_str(), d.join("L1.wai").as_os_str()]); + let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + assert!(!o.stdout.windows(9).any(|w| w == b"\"claimed\""), "{doc}"); +} + +/// A base the tool cannot decode is that step's error, by its code, and the replay goes +/// on: an encrypted copy of a base (`encrypted`) and an inert envelope +/// (`missing_capability`); the layer for the encrypted base's plaintext is held for it. +#[test] +fn apply_records_a_refused_base_and_goes_on() { + let d = corpus("cases/out_of_order"); + let mut w = wai::container::Wai::from_bytes(&std::fs::read(d.join("base.wai")).unwrap()).unwrap(); + w.manifest.security = Some(wai::container::Security::new("aes-256-gcm", 1)); + let enc = scratch("encrypted_base.wai"); + std::fs::write(&enc, w.to_bytes().unwrap()).unwrap(); + let inert = wai_dir().join("wai-rs/tests/vectors/staged_bounds/inert_companion_primary.wai"); + let l1 = d.join("L1.wai"); + for json in [true, false] { + let mut args = vec![os("stage"), os("apply")]; + if json { + args.push(os("--json")); + } + args.extend([enc.as_os_str(), inert.as_os_str(), l1.as_os_str()]); + let o = wai(&args); + let t = text(&o); + assert!(o.status.success(), "{t}"); + if json { + let doc: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + let steps = doc["steps"].as_array().unwrap(); + assert_eq!((steps[0]["op"].as_str(), steps[0]["error"].as_str()), (Some("base"), Some("encrypted"))); + assert_eq!(steps[1]["error"], "missing_capability"); + assert_eq!(steps[2]["reports"][0]["held_for_base"], true); + assert!(doc["bases"].as_array().unwrap().is_empty()); + } else { + assert!(t.contains("encrypted") && t.contains("missing_capability") && t.contains("held for its base"), "{t}"); + } + } +} diff --git a/wai/wai-web/demo/verify_staged_wasm.mjs b/wai/wai-web/demo/verify_staged_wasm.mjs index 53f07b9cc..33b84f09c 100644 --- a/wai/wai-web/demo/verify_staged_wasm.mjs +++ b/wai/wai-web/demo/verify_staged_wasm.mjs @@ -19,9 +19,11 @@ // less than a few MiB; a sink whose chain budget a base passes refuses the base // (chain-over-limit); // - facts: a base's viaFallback cannot be set, so its layers stay refused -// base-via-fallback; fromStage0 takes only the manifest's capability or its -// fallback, lowers a claimed tier to what the capability gives an unpinned base, -// refuses a misspelt tier, and marks the chain claimed; +// base-via-fallback; an inert envelope is neither decoded nor claimed, through its +// capability or its fallback; fromStage0 takes only the manifest's capability or +// its fallback, lowers a claimed tier to what the capability gives an unpinned +// base, refuses a misspelt tier, and marks the chain claimed; StageBase.decode +// refuses a call with no limit argument (null is the format's caps); // - addBase moves the base into the sink: the JS object is spent; // - a seeded fuzz of every corpus base and layer through stageLayerInfo, // StageBase.decode (with a limit) and offer never traps. @@ -226,10 +228,18 @@ const expectThrow = (what, f, code) => { // int_motion fallback never reach it. const fb = bytes(`${corpus}/cases/out_of_order/base.wai`); for (const inert of ['inert_refinement_primary.wai', 'inert_companion_primary.wai']) { - expectThrow(`facts: ${inert}`, () => wai.StageBase.decode(bytes(`${resolve(waiDir, 'wai-rs/tests/vectors/staged_bounds')}/${inert}`), undefined, undefined, LIMIT), 'missing_capability'); - expectThrow(`facts: ${inert} via its fallback`, () => wai.StageBase.decode(bytes(`${resolve(waiDir, 'wai-rs/tests/vectors/staged_bounds')}/${inert}`), undefined, 'wai.video.int_motion', LIMIT), 'missing_capability'); + const ienv = bytes(`${resolve(waiDir, 'wai-rs/tests/vectors/staged_bounds')}/${inert}`); + const icap = wai.stageLayerInfo(ienv).capability; + expectThrow(`facts: ${inert}`, () => wai.StageBase.decode(ienv, undefined, undefined, LIMIT), 'missing_capability'); + expectThrow(`facts: ${inert} via its fallback`, () => wai.StageBase.decode(ienv, undefined, 'wai.video.int_motion', LIMIT), 'missing_capability'); + // Nor can a page claim it: SPEC §4 never takes its capability or its fallback. + for (const cap of [icap, 'wai.video.int_motion']) { + expectThrow(`facts: ${inert} claimed as ${cap}`, () => wai.StageBase.fromStage0(ienv, cap, 'decode-equivalence', new Uint32Array([4, 4, 3, 4, 4, 3]), 'u8', new Uint8Array(96).fill(5)), 'missing_capability'); + } } - void fb; + // The limit is required: left out, the decode is refused; null is the format's caps. + expectThrow('facts: a decode with no limit argument', () => wai.StageBase.decode(fb, undefined, undefined), 'limit'); + wai.StageBase.decode(fb, undefined, undefined, null).free(); // A claimed base: only its manifest's capability or fallback; its tier no higher than // an unpinned base's; a misspelt tier refused; its chain marked claimed. diff --git a/wai/wai-web/src/stage.rs b/wai/wai-web/src/stage.rs index 67679f742..91d9a5f2a 100644 --- a/wai/wai-web/src/stage.rs +++ b/wai/wai-web/src/stage.rs @@ -273,7 +273,10 @@ pub(crate) fn limit_from(v: &Value) -> Result tier::from_token(tier_token), t => return Err(format!("tier: {t} is not decode-equivalence, entropy-consistency or none")), @@ -383,14 +392,21 @@ impl StageBase { /// §5 names it) to its bytes, and is absent for a capability that reads none /// (`wai.video.int_motion`); the envelope's pin is verified against those bytes. /// `via` is the capability to decode through: absent to run §4 (the manifest's - /// capability, then its fallback), or one of those two. `limit` is the page's cost - /// limit, `{output_bytes, symbols, working_bytes, macs}` (`macs` optional): the - /// payload's declared cost is checked against it before any symbol is decoded or - /// any output allocated (`cost_over_limit`); `null` bounds the decode by the - /// payload format's caps alone. No key log is held here, so SPEC §3.1's check 5 is - /// not run. A refusal's message starts with its code. + /// capability, then its fallback), or one of those two. `limit` is required: the + /// page's cost limit, `{output_bytes, symbols, working_bytes, macs}` (`macs` + /// optional), or `null` for the payload format's caps only; leaving it out is an + /// error, so a page never decodes unbounded by accident. The limit binds a base + /// whose capability declares a cost before decoding (the integer payloads SPEC §5 + /// pins: `WIV1`, `WIH1`, `WIA1`, `WIS1`), checked before any symbol is decoded or + /// any output allocated (`cost_over_limit`); a capability that declares no cost + /// (`wai.neural.int_synth`, `wai.neural.int_mlicpp`) decodes under its format's caps + /// whatever the limit. No key log is held here, so SPEC §3.1's check 5 is not run. + /// A refusal's message starts with its code. pub fn decode(envelope: &[u8], set: Option, via: Option, limit: JsValue) -> Result { - let limit_v: Value = if limit.is_null() || limit.is_undefined() { Value::Null } else { serde_wasm_bindgen::from_value(limit).map_err(js_err)? }; + if limit.is_undefined() { + return Err(JsError::new("limit: required; pass the page's cost limit, or null for the payload format's caps only")); + } + let limit_v: Value = if limit.is_null() { Value::Null } else { serde_wasm_bindgen::from_value(limit).map_err(js_err)? }; let limit = limit_from(&limit_v).map_err(js_err)?; let held = set_from(set)?; let files: BTreeMap = held.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect(); @@ -917,6 +933,18 @@ mod tests { let (info, _) = claim_base(&fb, w.fallback().unwrap(), "decode-equivalence", &shape, "u8", &vec![0u8; 2 * 8 * 8 * 3]).unwrap(); assert!(info.via_fallback, "named through its fallback, decoded through a fallback"); assert!(claim_base(b"nope", "x", "none", &shapes, "u8", &junk).unwrap_err().starts_with("not-wai1")); + // SPEC §4: an inert envelope (a refinement-only or companion-only capability) + // is claimed neither through its capability nor through its fallback. + let b = wai_dir().join("wai-rs/tests/vectors/staged_bounds"); + let two = [4u32, 4, 3, 4, 4, 3]; + for f in ["inert_refinement_primary.wai", "inert_companion_primary.wai"] { + let env = std::fs::read(b.join(f)).unwrap(); + let w = Wai::from_bytes(&env).unwrap(); + for cap in [w.capability(), w.fallback().unwrap()] { + let err = claim_base(&env, cap, "decode-equivalence", &two, "u8", &[5u8; 96]).unwrap_err(); + assert!(err.starts_with("missing_capability"), "{f} as {cap}: {err}"); + } + } } /// The cost limit's form: four figures, `macs` optional; `null` for none. -- 2.54.0 (Apple Git-157) From 0fe755f95414204f77c96062173531b330275ce2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 20:59:37 -0400 Subject: [PATCH 11/11] wai: the staged sink's linearity test times the thread's CPU, not the wall clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the shared macOS runner the wall clock counted other jobs' time: a linear offer measured 64.5× and 183× for 16× the units, against a 64× bound, while its bytes, allocations and peak stayed at 16×. The offer runs on the calling thread, so the thread's CPU time is the offer's own work; under a load of 108 on 18 cores it measures 16–17.6× for every shape. Co-Authored-By: Claude Opus 5.5 --- wai/wai-rs/tests/staged_resources.rs | 46 ++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/wai/wai-rs/tests/staged_resources.rs b/wai/wai-rs/tests/staged_resources.rs index 4e6606614..b91bff0e6 100644 --- a/wai/wai-rs/tests/staged_resources.rs +++ b/wai/wai-rs/tests/staged_resources.rs @@ -4,8 +4,10 @@ //! //! - Work per layer is linear in its units: an offer of a layer over 4096, 16384 and //! 65536 units (one layer alone; a layer whose `prev` is a run per unit, held; a -//! layer held and then released by the layer below it) allocates, holds and takes -//! time in proportion to the units, within a margin a quadratic sink is far outside. +//! layer held and then released by the layer below it) allocates, holds and spends +//! CPU time in proportion to the units, within a margin a quadratic sink is far +//! outside. The time is the thread's CPU time, not wall-clock time, so a loaded host +//! does not decide it. //! - The chain budget: a layer that would take the chain's state past it is refused //! (`chain-over-limit`) before any stream is decoded, and the state the chain //! reports is the sum §4.5 defines. @@ -19,7 +21,7 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; -use std::time::{Duration, Instant}; +use std::time::Duration; use wai::codecs::{DeterminismTier, CAP_VIDEO_INT_MOTION, CAP_VIDEO_INT_REFINE}; use wai::container::{Conditioning, Manifest, ModelRequirement, Prev, PrevRun, Refines, Wai}; @@ -66,7 +68,7 @@ unsafe impl GlobalAlloc for Count { static ALLOC: Count = Count; /// What a window measured: bytes allocated, allocations, the most bytes live at once -/// beyond the start, and the time. +/// beyond the start, and the time the calling thread spent on the CPU. #[derive(Debug, Clone, Copy)] struct Window { allocated: usize, @@ -75,13 +77,45 @@ struct Window { time: Duration, } +/// The calling thread's CPU time. Wall-clock time also counts the time other processes +/// held the core, so on a loaded host it grows with the load rather than the work: a +/// CI runner shared with other jobs made a linear offer look 64–183× slower for 16× the +/// units. The thread's own CPU time grows only with the work it did. The offer runs on +/// the calling thread, so its CPU time is the offer's. +#[cfg(unix)] +fn now() -> Duration { + // `time_t` and `long` are 64 bits on every 64-bit Unix target. + #[repr(C)] + struct Timespec { + tv_sec: i64, + tv_nsec: i64, + } + unsafe extern "C" { + fn clock_gettime(clock: i32, tp: *mut Timespec) -> i32; + } + #[cfg(target_vendor = "apple")] + const CLOCK_THREAD_CPUTIME_ID: i32 = 16; + #[cfg(not(target_vendor = "apple"))] + const CLOCK_THREAD_CPUTIME_ID: i32 = 3; + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + assert_eq!(unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }, 0, "clock_gettime"); + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) +} + +/// Elsewhere, wall-clock time from a fixed origin. +#[cfg(not(unix))] +fn now() -> Duration { + static ORIGIN: std::sync::OnceLock = std::sync::OnceLock::new(); + ORIGIN.get_or_init(std::time::Instant::now).elapsed() +} + fn measure(f: impl FnOnce() -> T) -> (T, Window) { let start = CUR.load(Relaxed); PEAK.store(start, Relaxed); let (a0, c0) = (ALLOCATED.load(Relaxed), CALLS.load(Relaxed)); - let t = Instant::now(); + let t = now(); let out = f(); - let time = t.elapsed(); + let time = now() - t; let w = Window { allocated: ALLOCATED.load(Relaxed) - a0, calls: CALLS.load(Relaxed) - c0, peak: PEAK.load(Relaxed) - start, time }; (out, w) } -- 2.54.0 (Apple Git-157)