wai: staged delivery in the browser — one base decode, the wasm binding, and its check #152
31 changed files with 3574 additions and 141 deletions
22
.github/workflows/wai-web-wasm.yml
vendored
22
.github/workflows/wai-web-wasm.yml
vendored
|
|
@ -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. 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
|
||||
|
|
@ -110,6 +117,17 @@ 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.
|
||||
# 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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,82 @@ 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
|
||||
|
||||
- **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. 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, 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,
|
||||
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` 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`). 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
|
||||
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. 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, 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, 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
|
||||
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 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
|
||||
|
||||
- **The extension** [staged-delivery](extensions/staged-delivery.md): a stage set is a
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
BIN
wai/staged-conformance/cases/base_over_budget/L1.wai
Normal file
BIN
wai/staged-conformance/cases/base_over_budget/L1.wai
Normal file
Binary file not shown.
BIN
wai/staged-conformance/cases/base_over_budget/base.wai
Normal file
BIN
wai/staged-conformance/cases/base_over_budget/base.wai
Normal file
Binary file not shown.
115
wai/staged-conformance/cases/base_over_budget/case.json
Normal file
115
wai/staged-conformance/cases/base_over_budget/case.json
Normal file
|
|
@ -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
|
||||
]
|
||||
}
|
||||
}
|
||||
1
wai/staged-conformance/cases/base_over_budget/stage0.bin
Normal file
1
wai/staged-conformance/cases/base_over_budget/stage0.bin
Normal file
|
|
@ -0,0 +1 @@
|
|||
( (@0@H0@X8PXPXp`px`hx08H8@X8PXPPhPhx`xxpp<70>p€˜8PXHP`<60>Àè`pxhx€px˜x<CB9C>¨<EFBFBD> ¨PXp Ðè¨Ðø¸Øÿx<C3BF>¨<EFBFBD> ¨<C2A0> ¸¨°À¨Øÿ°àÿÈðÿÐøÿàøÿ˜¨À °È°ÈÈx€<78>ÐðÿÐÿÿØÿÿ¨¸À¸ÀÈÀÐØÀØè<C398>˜¨ °èÿÿ¨¸Ð¸ÈØÐÐàÐèèàè𨰸°ÀÈ¸ÐØÀÐèÈØðàèøàðÿðøÿ 0 (@0@H0@`@PXPXp`px`px08H8@X8PXPPhPhx`xxpx<70>p€˜8X`HX`P`p`pxpx€p€˜€<CB9C>¨<EFBFBD> ¨PXp`pp`p€px—Àðÿ<C3B0> ¨<C2A0>˜¸¨°Àhx‡h€—€<E28094>—Ðøÿàøÿàÿÿ °È°ÀÈx€<78>ˆ<EFBFBD>§ÐÿÿØÿÿèÿÿÿÿÿÿÿÿÀØè<C398>˜¨ °¨§¿ðÿÿÿÿÿÿÿÿÐèèàè𨰸°ÀÈ¸ÐØÀÐèÿÿÿàèøàèÿðøÿ
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,16 +12,27 @@
|
|||
|
||||
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()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -29,6 +41,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 +51,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 +73,59 @@ 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<BTreeMap<String, Vec<u8>>, 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<UnitBuf>), 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<Result<(BaseInfo, Vec<UnitBuf>), wai::staged::BaseError>, String> {
|
||||
let set = param_set(args)?;
|
||||
let files: BTreeMap<String, &[u8]> = 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).
|
||||
Ok(wai::staged::decode_base(env, &files, None, None))
|
||||
}
|
||||
|
||||
/// The base's info and units, decoded as the module doc states.
|
||||
fn base(path: &str, args: &[String]) -> Result<(Vec<u8>, BaseInfo, Vec<UnitBuf>), 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<String, Vec<u8>> = 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<String, &[u8]> = 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<HeldBytes> = 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 +158,294 @@ fn print_units(c: &StageChain) {
|
|||
}
|
||||
}
|
||||
|
||||
/// `wai stage apply <base.wai> <L.wai>…`: 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 <base.wai> <layer.wai>…")?, 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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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!();
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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 <envelope>… [--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.
|
||||
///
|
||||
/// 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
|
||||
/// 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");
|
||||
if pos.is_empty() {
|
||||
return Err("wai stage apply <envelope>… [--json]".into());
|
||||
}
|
||||
let mut sink = StageSink::new(cfg());
|
||||
sink.set_trace(true);
|
||||
let mut bases: Vec<[u8; 32]> = Vec::new();
|
||||
let mut frozen: BTreeMap<([u8; 32], u32), bool> = BTreeMap::new();
|
||||
let mut steps: Vec<Value> = 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<Vec<OfferReport>, &str>, Vec<Value>) = 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)?;
|
||||
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()) => {
|
||||
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![]),
|
||||
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"));
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
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 f = c.unit(u).expect("in range").frozen;
|
||||
if frozen.insert((*b, u), f).unwrap_or(false) != f {
|
||||
units.push(unit_json(c, u));
|
||||
}
|
||||
}
|
||||
}
|
||||
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(|x| {
|
||||
let mut v = report_json(x);
|
||||
if on_claimed {
|
||||
v["claimed"] = json!(true);
|
||||
}
|
||||
v
|
||||
}).collect::<Vec<_>>()),
|
||||
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" && 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 {
|
||||
print_report(path, x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if as_json {
|
||||
let finals: Vec<Value> = bases
|
||||
.iter()
|
||||
.filter_map(|b| sink.chain(b))
|
||||
.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::<Vec<_>>(),
|
||||
"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 || 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `wai stage base --frames <file> --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 <base.wai>")?;
|
||||
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 <file>")?)?;
|
||||
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<Vec<u8>> = 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(), 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());
|
||||
}
|
||||
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 +637,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 +648,11 @@ pub fn main(args: &[String]) -> Result<(), String> {
|
|||
|
||||
pub const USAGE: &str = "wai stage refine --base <b.wai> [--on <L.wai>]… --target <file> --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 <base.wai> <L.wai>… offer the layers in order; print each report and the units
|
||||
wai stage apply <envelope>… [--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 <file> --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 <L.wai> a layer's refines, WIR1 header and declared cost
|
||||
wai stage pack <base.wai> <L.wai>… -o x.wai pack WAI1 envelopes into a WAI2 (manifests verbatim)
|
||||
wai stage explode <x.wai> <i> [-o out.wai] a WAI2 component as its WAI1 envelope
|
||||
|
|
|
|||
404
wai/wai-rs/src/bin/wai_stage_bench.rs
Normal file
404
wai/wai-rs/src/bin/wai_stage_bench.rs
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
//! `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 <file> [--set <dir>] [--runs N] [--out report.json] <envelope>…
|
||||
//!
|
||||
//! 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, 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
|
||||
//! 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<Vec<u8>, 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<u8>,
|
||||
/// 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<Env, String> {
|
||||
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<f64>) -> 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<i64> = 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<f64> {
|
||||
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 <file>")?)?;
|
||||
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<String, Vec<u8>> = 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<String, &[u8]> = set.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect();
|
||||
let envs: Vec<Env> = positional(args).iter().map(|f| read_env(f)).collect::<Result<_, _>>()?;
|
||||
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());
|
||||
}
|
||||
|
||||
// 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<UnitBuf>)> = Vec::new();
|
||||
for e in envs.iter().filter(|e| e.stage == 0) {
|
||||
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<Vec<(Samples, u8, Option<[u8; 32]>)>>> = 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<Vec<(Samples, u8, Option<[u8; 32]>)>> {
|
||||
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<&[u8]>> = Vec::new();
|
||||
let mut at = 0usize;
|
||||
for units in last {
|
||||
let mut v = Vec::new();
|
||||
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 units at their last stage hold {at}", source.len()));
|
||||
}
|
||||
let pcm = matches!(bases[0].1[0].samples, Samples::I16(_));
|
||||
let mut snapshots: Vec<Vec<Value>> = vec![Vec::new(); n_stages as usize + 1];
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Timed replays.
|
||||
let mut times: Vec<Vec<f64>> = 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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// The direct comparator for each stage whose layers share one step and identity.
|
||||
let direct = |k: u8| -> Result<Value, String> {
|
||||
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"}));
|
||||
}
|
||||
// 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<Below> = units.iter().map(|u| below(&u.samples)).collect();
|
||||
let targets: Vec<Samples> = 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<Below> = 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, 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(),
|
||||
"bytes": bytes,
|
||||
"framing_bytes": es.iter().map(|e| (e.bytes.len() - e.manifest - e.payload) as u64).sum::<u64>(),
|
||||
"manifest_bytes": es.iter().map(|e| e.manifest as u64).sum::<u64>(),
|
||||
"payload_bytes": es.iter().map(|e| e.payload as u64).sum::<u64>(),
|
||||
"cumulative_bytes": cumulative,
|
||||
"clip": quality,
|
||||
"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::<Vec<_>>(),
|
||||
"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<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
eprintln!("usage: wai_stage_bench --source <file> [--set <dir>] [--runs N≥20] [--out report.json] <envelope>…");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
match run(&args) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u8>], h: u32, w: u32, q: u32) -> (Vec<u8>, Vec<Vec<u8>>) {
|
||||
let mut recon: Vec<Vec<u8>> = Vec::new();
|
||||
let mut rs: Vec<i64> = 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<Vec<u8>> = (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<UnitBuf> = 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");
|
||||
|
|
@ -1143,6 +1091,14 @@ fn cases(ctx: &Ctx) -> Vec<Case> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -827,3 +827,101 @@ pub fn wis1_from_json(bs: &serde_json::Value) -> Result<Vec<u8>, 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. 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<u8>], h: u32, w: u32, q: u32) -> Result<(Vec<u8>, Vec<Vec<u8>>), 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"));
|
||||
}
|
||||
// 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"));
|
||||
}
|
||||
let mut recon: Vec<Vec<u8>> = Vec::with_capacity(frames.len());
|
||||
let mut rs: Vec<i64> = 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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -775,3 +775,54 @@ 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<Vec<u8>> = (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<u8>, Vec<Vec<u8>>), 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");
|
||||
// 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,267 @@ pub fn units_from_decoded(d: crate::int_payload::IntDecoded) -> Option<Vec<UnitB
|
|||
})
|
||||
}
|
||||
|
||||
/// Why a base envelope could not be decoded for a sink ([`decode_base`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum BaseError {
|
||||
/// The bytes are not a `WAI1` envelope.
|
||||
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),
|
||||
/// check 5 included where the sink gave a key log.
|
||||
Pin(crate::container::PinRefusal),
|
||||
/// 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 (`cost_over_limit` among them), `no-units`,
|
||||
/// `not-offered` or `missing_capability`.
|
||||
pub fn code(&self) -> &'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",
|
||||
BaseError::NotOffered(_) => "not-offered",
|
||||
BaseError::Inert => "missing_capability",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
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 {}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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<String, &'a [u8]>,
|
||||
listing: &'a std::cell::OnceCell<Result<Vec<u8>, crate::container::PinRefusal>>,
|
||||
) -> Result<Vec<crate::pin::HeldBytes<'a>>, crate::container::PinRefusal> {
|
||||
use crate::pin::HeldBytes;
|
||||
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<HeldBytes> = 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)));
|
||||
Ok(v)
|
||||
} else {
|
||||
let listing = listing.get_or_init(|| crate::pin::listing_from(set)).as_ref().map_err(Clone::clone)?;
|
||||
Ok(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`.
|
||||
///
|
||||
/// **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, 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
|
||||
/// 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<String, &[u8]>,
|
||||
limit: Option<&crate::int_payload::DeclaredCost>,
|
||||
authority: Option<&dyn crate::pin::PinAuthority>,
|
||||
) -> Result<(BaseInfo, Vec<UnitBuf>), 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<String, &[u8]>,
|
||||
limit: Option<&crate::int_payload::DeclaredCost>,
|
||||
authority: Option<&dyn crate::pin::PinAuthority>,
|
||||
) -> Result<(BaseInfo, Vec<UnitBuf>), 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()))?;
|
||||
// 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 = std::cell::OnceCell::new();
|
||||
let mut excluded: Vec<String> = 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| held_for(c, Some(p), set, &listing).and_then(|h| resolve(c, Some(p), &h, 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 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)));
|
||||
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
|
||||
/// staged-delivery §4.2, in the order of §4.1's steps.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
|
|
@ -450,6 +711,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<Vec<Applied>>,
|
||||
}
|
||||
|
||||
/// 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<DeterminismTier>,
|
||||
pub shape: (u32, u32, u32),
|
||||
pub sha256: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
fn hex(h: &[u8; 32]) -> String {
|
||||
|
|
@ -494,6 +771,7 @@ impl StageChain {
|
|||
held_layers: 0,
|
||||
state,
|
||||
seq: 0,
|
||||
trace: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -862,6 +1140,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(())
|
||||
}
|
||||
|
||||
|
|
@ -1104,6 +1387,8 @@ pub struct StageSink {
|
|||
kept_order: BTreeMap<u64, [u8; 32]>,
|
||||
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.
|
||||
|
|
@ -1125,7 +1410,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`.
|
||||
|
|
@ -1133,12 +1418,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<UnitBuf>) -> Vec<OfferReport> {
|
||||
let hash = base.content_hash;
|
||||
if let Some(c) = self.chains.get_mut(&hash) {
|
||||
|
|
@ -1147,10 +1462,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<Pending>, Vec<Pending>) = std::mem::take(&mut self.pending).into_iter().partition(|p| p.refines.base == hexed);
|
||||
self.pending = rest;
|
||||
|
|
|
|||
|
|
@ -1534,3 +1534,253 @@ fn refinement_roles() {
|
|||
tampered.renditions[2][50] ^= 1;
|
||||
assert_eq!(tampered.refinements_of(0).iter().map(|(i, ..)| *i).collect::<Vec<_>>(), 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::<Vec<u8>>();
|
||||
let set_bytes: Vec<(String, Vec<u8>)> = [("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<String, &[u8]> = 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, 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(), None, None), Err(BaseError::Pin(_))), "no set held");
|
||||
let mut altered = set_bytes.clone();
|
||||
altered[1].1[0] ^= 1;
|
||||
let altered: BTreeMap<String, &[u8]> = altered.iter().map(|(m, b)| (m.clone(), b.as_slice())).collect();
|
||||
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, 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(), 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(), 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(), 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(), 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<u8> {
|
||||
let frames: Vec<Vec<u8>> = (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();
|
||||
// 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}");
|
||||
}
|
||||
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<String>);
|
||||
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<u8>)> = [("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<String, &[u8]> = 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<String, &[u8]> = [("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<u8> = 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<_>>(), 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));
|
||||
}
|
||||
|
||||
/// `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<String, &[u8]> = [("../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<String, &[u8]> = [("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<String, &[u8]> = [("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::<u64>() > 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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,3 +137,403 @@ 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<u8> = (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<PathBuf> = 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");
|
||||
// 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<u64> = 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<serde_json::Value> = 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<u8> = (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)));
|
||||
}
|
||||
|
||||
/// `wai stage apply --json` is the corpus's sink: every case it can replay (bases
|
||||
/// 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]
|
||||
fn apply_json_replays_the_corpus() {
|
||||
let root = corpus("cases");
|
||||
let mut names: Vec<String> = 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, mut extra) = (0, 0, 0);
|
||||
let mut differ: Vec<String> = 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<String> = 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" && !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<String, serde_json::Value> = 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<std::ffi::OsString> = 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());
|
||||
}
|
||||
if stage0_base {
|
||||
let b = &bases[0];
|
||||
let shape: Vec<String> = 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());
|
||||
}
|
||||
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();
|
||||
// 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<serde_json::Value> = doc["steps"].as_array().unwrap().iter()
|
||||
.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());
|
||||
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<String, &serde_json::Value> =
|
||||
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<serde_json::Value> = 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 ({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
|
||||
/// 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<u8> = (0..n * h * w * 3).map(|i| ((i * 11) % 256) as u8).collect();
|
||||
let big: Vec<u8> = (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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// `--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<u8> = (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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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::time::Instant> = std::sync::OnceLock::new();
|
||||
ORIGIN.get_or_init(std::time::Instant::now).elapsed()
|
||||
}
|
||||
|
||||
fn measure<T>(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)
|
||||
}
|
||||
|
|
@ -392,4 +426,40 @@ 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);
|
||||
// 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,
|
||||
// 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");
|
||||
}
|
||||
|
|
|
|||
26
wai/wai-rs/tests/vectors/staged_bounds/README.md
Normal file
26
wai/wai-rs/tests/vectors/staged_bounds/README.md
Normal file
|
|
@ -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).
|
||||
BIN
wai/wai-rs/tests/vectors/staged_bounds/clip_1gib.wai
Normal file
BIN
wai/wai-rs/tests/vectors/staged_bounds/clip_1gib.wai
Normal file
Binary file not shown.
BIN
wai/wai-rs/tests/vectors/staged_bounds/clip_64mib.wai
Normal file
BIN
wai/wai-rs/tests/vectors/staged_bounds/clip_64mib.wai
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -50,3 +50,32 @@ 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 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`. 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/
|
||||
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
|
||||
```
|
||||
|
|
|
|||
319
wai/wai-web/demo/verify_staged_wasm.mjs
Normal file
319
wai/wai-web/demo/verify_staged_wasm.mjs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
// 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, 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; 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.
|
||||
//
|
||||
// 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, '..');
|
||||
// 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');
|
||||
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, LIMIT);
|
||||
} 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);
|
||||
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);
|
||||
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 { 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': {
|
||||
// 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 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 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()`);
|
||||
}
|
||||
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(h)?.sha256 ?? null) !== want.clip_sha256) fail(`${name}: base ${i}: clipSha256`);
|
||||
});
|
||||
sink.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']) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
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`;
|
||||
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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
962
wai/wai-web/src/stage.rs
Normal file
962
wai/wai-web/src/stage.rs
Normal file
|
|
@ -0,0 +1,962 @@
|
|||
//! 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<u64, String> {
|
||||
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<ChainConfig, String> {
|
||||
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::<Result<_, _>>()?,
|
||||
}),
|
||||
};
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// 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::<Vec<_>>(),
|
||||
"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<Vec<UnitBuf>, 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); 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<String, &[u8]>) -> Result<Value, String> {
|
||||
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})),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 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::<Vec<_>>()) {
|
||||
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) => {
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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<Option<wai::int_payload::DeclaredCost>, 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) that SPEC §4 would
|
||||
/// take (`missing_capability` for an inert envelope, whose capability is
|
||||
/// refinement-only or companion-only, or a capability that is not dispatchable), 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<UnitBuf>), 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"));
|
||||
}
|
||||
// SPEC §4, as decode_base_via applies it: a refinement-only or companion-only
|
||||
// capability makes the envelope inert, so neither it nor its fallback is ever
|
||||
// taken, and only a dispatchable capability decodes a payload.
|
||||
if wai::codecs::is_refinement_only(primary) || wai::codecs::is_companion_only(primary) || !wai::codecs::is_dispatchable(capability) {
|
||||
return Err(format!("missing_capability: SPEC §4 takes no capability for this envelope through {capability}"));
|
||||
}
|
||||
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<u64, String> {
|
||||
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<u64, String> {
|
||||
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<JsValue, JsError> {
|
||||
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<u64, JsError> {
|
||||
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 JS `Map` of file name → bytes, as owned pairs.
|
||||
fn set_from(set: Option<js_sys::Map>) -> Result<Vec<(String, Vec<u8>)>, JsError> {
|
||||
let mut held: Vec<(String, Vec<u8>)> = 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::<js_sys::Uint8Array>().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]
|
||||
pub struct StageBase {
|
||||
info: BaseInfo,
|
||||
units: Vec<UnitBuf>,
|
||||
claimed: bool,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl StageBase {
|
||||
/// 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 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<js_sys::Map>, via: Option<String>, limit: JsValue) -> Result<StageBase, JsError> {
|
||||
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<String, &[u8]> = held.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect();
|
||||
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 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<StageBase, JsError> {
|
||||
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.
|
||||
#[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
|
||||
}
|
||||
/// 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
|
||||
}
|
||||
/// 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;
|
||||
}
|
||||
/// 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<u8> {
|
||||
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. 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> {
|
||||
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<JsValue, JsError> {
|
||||
match h {
|
||||
None => Ok(JsValue::UNDEFINED),
|
||||
Some(h) => to_js(&json!({"sha256": hex(&h), "claimed": claimed})),
|
||||
}
|
||||
}
|
||||
|
||||
#[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<StageSink, JsError> {
|
||||
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)?), claimed: Default::default() })
|
||||
}
|
||||
|
||||
/// 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<JsValue, JsError> {
|
||||
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`
|
||||
/// (`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<JsValue, JsError> {
|
||||
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(&marked(&r, |_| mark)),
|
||||
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<JsValue, JsError> {
|
||||
// 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<JsValue, JsError> {
|
||||
let h = Self::hash(base)?;
|
||||
let mark = self.claimed.remove(&h);
|
||||
match self.sink.remove_base(&h) {
|
||||
Some(r) => to_js(&marked(&r, |_| mark)),
|
||||
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> {
|
||||
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<bool, JsError> {
|
||||
Ok(self.sink.forget_base(&Self::hash(base)?))
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// 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}`), 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<JsValue, JsError> {
|
||||
match unhex32(base).and_then(|h| self.sink.chain(&h)) {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = nUnits)]
|
||||
pub fn n_units(&self, base: &str) -> Result<u32, JsError> {
|
||||
Ok(self.chain(base)?.n_units())
|
||||
}
|
||||
#[wasm_bindgen(js_name = unitStage)]
|
||||
pub fn unit_stage(&self, base: &str, u: u32) -> Result<u32, JsError> {
|
||||
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<String, JsError> {
|
||||
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<bool, JsError> {
|
||||
Ok(self.state(base, u)?.frozen)
|
||||
}
|
||||
#[wasm_bindgen(js_name = unitTier)]
|
||||
pub fn unit_tier(&self, base: &str, u: u32) -> Result<String, JsError> {
|
||||
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<Vec<u32>, 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<Vec<u8>, 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<Vec<i16>, 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) 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<JsValue, JsError> {
|
||||
let c = self.chain(base)?;
|
||||
if u >= c.n_units() {
|
||||
return Err(JsError::new("unit: out of range"));
|
||||
}
|
||||
stage_hash(c.unit_sha256(u), self.is_claimed(base))
|
||||
}
|
||||
/// 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<JsValue, JsError> {
|
||||
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.
|
||||
#[wasm_bindgen(js_name = unitSseU8)]
|
||||
pub fn unit_sse_u8(&self, base: &str, u: u32, reference: &[u8]) -> Result<u64, JsError> {
|
||||
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<JsValue, JsError> {
|
||||
let v: Vec<Value> = 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<f64, JsError> {
|
||||
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 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], set: Option<js_sys::Map>) -> Result<JsValue, JsError> {
|
||||
let held = set_from(set)?;
|
||||
let files: BTreeMap<String, &[u8]> = 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
|
||||
/// 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 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<u64, JsError> {
|
||||
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<u64, JsError> {
|
||||
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<u32> = b["units"].as_array().unwrap().iter().flat_map(|u| u["shape"].as_array().unwrap().iter().map(|x| x.as_u64().unwrap() as u32).collect::<Vec<_>>()).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<u8>)> = 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<String, &[u8]> = held.iter().map(|(k, v)| (k.clone(), v.as_slice())).collect();
|
||||
let (info, units) = staged::decode_base_via(&env, Some(via), &files, None, None).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let got: Vec<u8> = 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<String, Vec<u8>> = 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) => marked(&r, |_| false),
|
||||
Err(staged::OfferError::NotWai1(_)) => json!({"error": "not-wai1"}),
|
||||
Err(_) => json!({"error": "encrypted"}),
|
||||
},
|
||||
"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}"),
|
||||
};
|
||||
reports.push(got);
|
||||
}
|
||||
assert_eq!(Value::Array(reports), case["expect"]["reports"], "{name}: the reports");
|
||||
let finals: Vec<Value> = 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 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);
|
||||
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(), &none).unwrap();
|
||||
assert_eq!((b["refines"].is_null(), b["wir1"].is_null(), b["refinement_only"].as_bool()), (true, true, Some(false)));
|
||||
// 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<u32> = 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"));
|
||||
// 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.
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue