wai.live.ads: server-guided ad insertion for rooms — cues to HLS interstitials and DASH alternative presentations, asset lists, and the break form of wai.video.ssai (L11) #360
27 changed files with 7615 additions and 19 deletions
31
.github/workflows/wai-conformance.yml
vendored
31
.github/workflows/wai-conformance.yml
vendored
|
|
@ -102,6 +102,10 @@ on:
|
|||
# e2ee_verify.py reads this extension's §13 table, and the registry's wire identifiers
|
||||
# test reads its sections.
|
||||
- 'wai/extensions/confidentiality.md'
|
||||
# tests/live_ads.rs requires this extension's §10 to list the codes the reference
|
||||
# reports, live_ads_verify.py reads that table, and the registry's wire identifiers test
|
||||
# reads its sections.
|
||||
- 'wai/extensions/live-ads.md'
|
||||
- 'wai/wai-web/**'
|
||||
# The audio provenance check below reads the corpora above with this tool.
|
||||
- 'wai/tools/wai_audio_provenance_check.py'
|
||||
|
|
@ -771,8 +775,8 @@ jobs:
|
|||
# live-recording corpus), timed_text_verify.py (the timed-text corpus),
|
||||
# live_metering_verify.py (the live-metering corpus), energy_steering_verify.py (the
|
||||
# energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus), and
|
||||
# e2ee_verify.py (the end-to-end encrypted rooms corpus), and live_watermark_verify.py (the
|
||||
# forensic-watermark corpus).
|
||||
# e2ee_verify.py (the end-to-end encrypted rooms corpus), live_watermark_verify.py (the
|
||||
# forensic-watermark corpus) and live_ads_verify.py (the live-ads corpus).
|
||||
# Each exits non-zero on any failure or any difference, and the step fails with it.
|
||||
# One platform: what they check is the reading of the text, not the architecture.
|
||||
independent-verifiers:
|
||||
|
|
@ -1064,6 +1068,29 @@ jobs:
|
|||
(cd wai-rs && cargo test --no-default-features --features live_moderation --lib live_moderation::)
|
||||
(cd wai-rs && cargo test --no-default-features --features live_moderation --test live_moderation)
|
||||
python3 -I live-conformance/live_moderation_verify.py live-conformance/moderation
|
||||
# wai.live.ads (extensions/live-ads.md): the reference module's unit tests (policies and
|
||||
# annotations round-trip and their rules refuse, a break's identity, tags, events and
|
||||
# asset lists written exactly), the break form of wai.video.ssai's receipt (its JSON
|
||||
# carriage, each of its rules, its strict signature, the stitched form's legacy pin
|
||||
# unchanged), and tests/live_ads.rs (every file of the committed corpus, vectors.json and
|
||||
# each room's HLS tags, regenerates byte for byte from its fixed seeds and no other file
|
||||
# is there, wai-rs gives every room its stated outcome, and §10 lists exactly the
|
||||
# reference's codes, each of which the corpus gives); then the corpus's second
|
||||
# implementation, live_ads_verify.py, written from the extension's text and sharing no
|
||||
# code with wai-rs, which verifies each room with live_rooms_verify.py, writes every tag,
|
||||
# event and asset list byte for byte, verifies every receipt with WAI's strict Ed25519
|
||||
# rule and BLAKE3 from claim-conformance, reads the extension's reason table, and
|
||||
# requires every mutated copy of the corpus to fail; standard library only, run isolated
|
||||
# (-I).
|
||||
- name: live ads · wai-rs and live_ads_verify.py
|
||||
working-directory: wai
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd wai-rs && cargo test --no-default-features --features live_ads --lib live_ads::)
|
||||
(cd wai-rs && cargo test --no-default-features --features live_ads --lib wai_video_ssai::)
|
||||
(cd wai-rs && cargo test --no-default-features --features live_ads --test live_ads)
|
||||
python3 -I live-conformance/live_ads_verify.py live-conformance/ads
|
||||
# wai.live.e2ee (extensions/confidentiality.md): the corpus's second implementation,
|
||||
# e2ee_verify.py, written from the extension's text and the documents it reads and
|
||||
# sharing no code with the reference (joule-moq's secure_objects and joule-moq-mls,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,59 @@ wire format may change and such changes are recorded here.
|
|||
|
||||
## v0.4.0 — current
|
||||
|
||||
### `wai.live.ads`: a room's cues as server-guided ad breaks, and the break form of `wai.video.ssai`
|
||||
|
||||
- **The extension** ([live-ads](extensions/live-ads.md)) registers the record `wai.live.ads`.
|
||||
The room authority signs an **ad policy**, `WAP1`: the grant the ad decider runs under, whose
|
||||
funds ceiling is the room's ad budget; the slots of a break; the avail of a break opened with
|
||||
no planned duration; a preload window; skip and jump restrictions, a skip control and
|
||||
snapping; and the bases of the asset-list and creative URIs.
|
||||
- **Breaks from cues.** A room's breaks are its signed `cue` events of `wai.live.events`,
|
||||
unchanged: an out and its in, or a break of known duration; a chapter opens nothing. A
|
||||
post-roll is a break its author annotates with a `WAB1` `meta` event right after it, and ends
|
||||
the program. A verifier refuses an out or a break inside another break, an in with no open
|
||||
out of its `cue_id` and author, a break of no duration, an out with no planned duration when
|
||||
the policy has no open avail, an in whose stated duration is not the one that passed, an
|
||||
annotation that does not read or that follows anything but a break, and any cue after the
|
||||
post-roll; the room refuses a cue by a principal whose grant does not permit `cue`.
|
||||
- **Shared manifests.** Each break is one Interstitial `EXT-X-DATERANGE`
|
||||
(`draft-pantos-hls-rfc8216bis-22` Appendix D) with `CUE`, its duration or planned duration,
|
||||
`X-ASSET-LIST`, `X-RESUME-OFFSET`, `X-PLAYOUT-LIMIT`, `X-SNAP`, `X-RESTRICT` and
|
||||
`X-SKIP-CONTROL-OFFSET`, a closing tag at its in, and a Preload date range (Appendix F); and
|
||||
one DASH alternative-presentation event of ISO/IEC 23009-1:2026, a replacement for a mid-roll
|
||||
and an insertion for a post-roll, read from the sixth edition's schema and its examples, with
|
||||
the attributes whose meaning that revision could not state left unwritten. Every value is a
|
||||
function of the signed cue, its annotation, the policy and `t0`, and the `ID` is derived from
|
||||
the cue's digest, so every edge writes the same bytes.
|
||||
- **Asset lists.** The one URI a break names is where each viewer is decided: an HLS asset list
|
||||
or a DASH list MPD, written exactly, each creative's URI naming it by its hash, filled by
|
||||
`wai.video.ssai`'s decision over the viewer's pod; a viewer who joins a break running is
|
||||
filled from what remains of it. An empty fill is `{"ASSETS":[]}` in HLS and no list MPD in
|
||||
DASH.
|
||||
- **`wai.video.ssai` gains form 2, the break form.** A receipt names the room, the break by its
|
||||
cue's digest, the session, the viewer's offset, the asset list's SHA-256 and format, the
|
||||
avail, the slots and the funds the decision ran with, beside the stitched form's pod hash,
|
||||
impressions, Merkle root, billable spend and energy figure. It is carried as JSON, signs
|
||||
under its own domain (`wai:video-ssai-break-receipt`) and shares the identity domain, so the
|
||||
figure profile `wai:video-ssai-receipt-id` names either form. Every stitched receipt keeps
|
||||
its bytes, and its legacy pin is unchanged.
|
||||
- **Verification end to end**: the room, the policy, the schedule, the renderings, then each
|
||||
receipt: its own rules, the decider's key and grant reference, the break, the decider grant's
|
||||
window and revocation, the pod hash, the decision recomputed, the asset list's digest, one
|
||||
receipt per session and break, and the spend of every receipt together within the decider
|
||||
grant's ceiling (prices in millionths of a minor unit of its currency).
|
||||
- **Twenty refusal reasons**, from `ad_policy_malformed` to `funds_exceeded`.
|
||||
- **Reference and corpus.** `wai-rs` feature `live_ads` (`src/live_ads.rs`), and
|
||||
`SsaiBreakReceipt` in `src/wai_video_ssai.rs`. `live-conformance/ads/`, written from fixed
|
||||
seeds by `wai_live_ads_vectors`: a live break with a viewer at its start and one who joins it
|
||||
running, a mid-roll under restrictions with an empty fill, a program closed by a post-roll,
|
||||
chapters with a planned out, a gap, and one refused room per reason; each accepted room's
|
||||
HLS tags a file of its own. Checked by `tests/live_ads.rs` and by
|
||||
`live-conformance/live_ads_verify.py`, a second implementation that shares no code with
|
||||
`wai-rs`, verifies each room with `live_rooms_verify.py`, writes every tag, event and asset
|
||||
list itself, and requires every mutated copy of the corpus to fail. The registry lists the
|
||||
record and the magics `WAP1` and `WAB1`.
|
||||
|
||||
### `wai.live.watermark`: forensic watermark sessions, matched and disclosed one at a time
|
||||
|
||||
- **The extension** ([forensic-watermark](extensions/forensic-watermark.md)) registers the
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ Every registered string has one kind.
|
|||
`wai.asset.provenance`, the quantum-operations receipts, the delivery records
|
||||
`wai.video.manifest` and its siblings; the live records `wai.live.events`,
|
||||
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
|
||||
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee` and
|
||||
`wai.live.watermark`; timed text, `wai.text.timed`).
|
||||
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee`,
|
||||
`wai.live.watermark` and `wai.live.ads`; timed text, `wai.text.timed`).
|
||||
It names no payload format.
|
||||
|
||||
A derivation or record string is not a payload capability, so it is not
|
||||
|
|
@ -196,6 +196,7 @@ and a role are fixed once registered.
|
|||
| `wai.image.jpegai` | payload | registered | image | standalone | royalty-free | neural-entropy-exact | — | §5 Neural capabilities |
|
||||
| `wai.image.jxl` | payload | registered | image | standalone | royalty-free | standard-tolerance | recommended | §5 Recommended modern set |
|
||||
| `wai.image.png` | payload | registered | image | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor |
|
||||
| `wai.live.ads` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.e2ee` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
|
|
@ -301,12 +302,14 @@ and a role are fixed once registered.
|
|||
| `GRANT_CHALLENGE` | setup-option | proposed | live-rooms §10.5.2 |
|
||||
| `GRANT_PROOF` | setup-option | proposed | live-rooms §10.5.2 |
|
||||
| `SECURE_OBJECT_KEY_ID` | catalog | defined | confidentiality §4.3 |
|
||||
| `WAB1` | magic | defined | live-ads §4.2 |
|
||||
| `WAI-Room-Challenge` | http-header | proposed | live-rooms §10.5.3 |
|
||||
| `WAI-Room-Proof` | http-header | proposed | live-rooms §10.5.3 |
|
||||
| `WAI1` | magic | defined | SPEC §2 |
|
||||
| `WAI2` | magic | defined | SPEC §7.1 |
|
||||
| `WAI_INNER_DIGEST` | catalog | defined | confidentiality §4.4 |
|
||||
| `WAI_RECEIPT` | catalog | defined | moq-streaming-format §5 |
|
||||
| `WAP1` | magic | defined | live-ads §3.1 |
|
||||
| `WCD1` | magic | defined | live-compose §3.1 |
|
||||
| `WCL1` | magic | defined | live-recording §5.1 |
|
||||
| `WCN1` | magic | defined | capture-binding §8.1 |
|
||||
|
|
|
|||
31
wai/SPEC.md
31
wai/SPEC.md
|
|
@ -593,9 +593,9 @@ kind:
|
|||
delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`,
|
||||
`wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`,
|
||||
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
|
||||
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee` and
|
||||
`wai.live.watermark`, and
|
||||
timed text, `wai.text.timed`), which names no payload format.
|
||||
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee`,
|
||||
`wai.live.watermark` and `wai.live.ads`, and timed text, `wai.text.timed`), which names no
|
||||
payload format.
|
||||
|
||||
Only payload, companion and refinement capabilities have a media class, a
|
||||
fidelity tier, a replicate class and a role. A derivation or record string is not a payload
|
||||
|
|
@ -719,7 +719,7 @@ registration and makes no conforming envelope non-conforming (§8).
|
|||
| `wai.video.int_motion` | DETERMINISTIC INTEGER inter-frame — I-frame + integer block motion-compensated P-frames + integer-quantized residual over rANS, no float/ONNX; motion is whole-pel or **half-pel** (`mv_precision`), the latter predicting at the half-grid by integer bilinear interpolation (`(a+b+1)>>1`, `(a+b+c+d+2)>>2`) so real footage — which pans a non-integer number of pixels per frame — costs fewer residual bits while staying byte-exact | byte-identical frame-by-frame on every machine (*frame-state-equivalence*, a per-frame portable hash); the temporal extension of the integer contract, where a float inter-frame decode desyncs down the GOP | payload `WIV1`, keyframe 0; `int_video.rs` (the encode tools write a JSON form a converter reads). Payload: [integer-payloads](extensions/integer-payloads.md) §3; decoded through the integer operator set's stage operators, [integer-operators](extensions/integer-operators.md) §13.5 (`rans64_decode`, `mc_predict`, `residual_step`), with no graph |
|
||||
| `wai.video.int_hyper` | NEURAL-KEYFRAME inter-frame — the I-frame is the LEARNED integer codec `int_hyper` decoded byte-exact, the P-frames are integer block-MC residuals on top | a learned keyframe you can put in a receipt + integer inter-frames; whole decode integer, byte-identical everywhere | payload `WIV1`, keyframe 1 (a nested `WIH1`); `int_video::decode_pframes_typed` ∘ `int_hyper_synth::decode_conditional_checked`. Payload: [integer-payloads](extensions/integer-payloads.md) §3, its keyframe §4; the keyframe's networks decoded through their lowering, [integer-operators](extensions/integer-operators.md) §13.2 (`whm1/1`), and the P-frames through the operator set's stage operators, §13.5 |
|
||||
| `wai.video.manifest` | ATTESTED PER-SESSION MANIFEST — the OTT delivery layer, not a codec: a personalised HLS manifest (ads stitched at `EXT-X-DISCONTINUITY`, blackouts + geo enforced) rendered as a **pure function of the session *class*** `(content, entitlement, geo, blackout set, ad pod, window)` rather than the viewer, and sealed in an Ed25519 receipt that binds the JCP grant (capability, joule ceiling, `funds_ceiling` = ad budget), the composition, the stitched ads and the policy applied. A receipt over a WAI playlist MAY carry `render` and `extras`, the rendering parameters and per-segment lines that playlist adds, both bound through `manifest_hash` and through a render digest the signature covers ([http-delivery](extensions/http-delivery.md) §8.6) | **manifest-equivalence**: `manifest_hash == hash(render(segments))` and `session_class_hash` are recomputable by anyone holding the bytes, so the ads, blackouts and compose-work stop being dashboard claims; class-determinism means one generation serves every viewer of the class, and `uj_per_viewer` is the amortised energy that a 1:1-unique generator cannot report below its per-viewer cost. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_manifest.rs` |
|
||||
| `wai.video.ssai` | ATTESTED SERVER-SIDE AD INSERTION — a **deterministic** ad decision (greedy by price within the avail duration and the funds remaining) plus a per-impression Ed25519 receipt binding the candidate pod, the manifest it belongs to, the served `creative_hash`es and the VAST quartile each reached | **spend-equivalence**: `budget_spent_micros` is recomputed from the *billable* impressions and rejected if it exceeds the grant's `funds_ceiling` — an overspent receipt fails verification; every creative and quartile is checkable, so a buyer and seller reconcile impressions from the same bytes instead of two logs that only a dashboard joins. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_ssai.rs` |
|
||||
| `wai.video.ssai` | ATTESTED SERVER-SIDE AD INSERTION — a **deterministic** ad decision (greedy by price within the avail duration and the funds remaining) plus a per-impression Ed25519 receipt binding the candidate pod, the manifest it belongs to, the served `creative_hash`es and the VAST quartile each reached. The receipt has two forms: form 1, stitched, names an avail of an attested manifest; form 2, break ([live-ads](extensions/live-ads.md) §8), names a break of a room's schedule, the viewer's session and offset into it, and the asset list served, signed under its own domain so that every form-1 receipt keeps its bytes | **spend-equivalence**: `budget_spent_micros` is recomputed from the *billable* impressions and rejected if it exceeds the grant's `funds_ceiling` — an overspent receipt fails verification; every creative and quartile is checkable, so a buyer and seller reconcile impressions from the same bytes instead of two logs that only a dashboard joins. In form 2 the decision is recomputed from the bound pod, avail, slots and funds, and the spend of every receipt of a room together is held to the decider grant's ceiling. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_ssai.rs` |
|
||||
| `wai.video.keys` | ATTESTED CONTENT-KEY RELEASE — the DRM licensing decision as a record: every key release, and every refusal, is an Ed25519 receipt binding the KID, the DRM system, the entitlement demanded and the window enforced, with a BLAKE3 `key_commit` proving *which* key was released without carrying it. It is one of Lane A's records (§6.5): it covers the release decision, never a frame the key opens | **decision-equivalence**: `verify()` re-runs the release policy over the receipt's own `(grant capability, entitlement, issued, expiry)` and rejects any misreported outcome, and enforces granted-iff-committed — so "keys went only to entitled, unexpired sessions" is checkable against the signed log, not asserted. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_keys.rs` |
|
||||
| `wai.video.package` | DETERMINISTIC PACKAGING + ATTESTED SEGMENTMAP — a source sliced into content-hashed segments on GOP boundaries; the ordered `(index → content_hash)` map is a signed Merkle commitment, and identical content (ads, bumpers, replays, simulcast overlap) dedups to one stored/encoded copy | **map-equivalence + verifiable dedup**: `segment_map_root` lets a `wai.video.manifest` reference be checked against the package that produced it, and `verify()` recomputes `unique_segments`/`stored_bytes` from the segments so the storage/encode saving cannot be overstated; `uj_per_unique_segment` is the encode-once energy. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable. Version 2 ([packaging](extensions/packaging.md) §9) binds the policy digest and one stage index per component, names each object by its jwp-receipts §2.1 content hash, and covers every media class a packager packs; the string keeps `video` for history | `wai_video_package.rs`, `package.rs` |
|
||||
| `wai.video.channel` | VIRTUAL LINEAR / FAST ASSEMBLY — a continuous schedule of assets over wall-clock time, sliced into attested playback windows; windows chain via `parent_receipt_hash`, so a channel's history is a hash chain of program-as-run records | **schedule-continuity + recomputable program-as-run**: `verify()` rejects a schedule with a gap or overlap (no dead air, no double-booking) and recomputes `aired` from the schedule and the window, so what actually aired is checkable by a licensor, advertiser or regulator against the signed chain rather than an operator-held play-out log. Only the measurement — `joules_micro`, its acquisition class and the class's evidence — is measured-attested: signed, and not re-measurable | `wai_video_channel.rs` |
|
||||
|
|
@ -1044,6 +1044,29 @@ record and the grant without learning other sessions. A room that is end-to-end
|
|||
no edge that can mark it, and a record for one is refused:
|
||||
[`extensions/forensic-watermark.md`](extensions/forensic-watermark.md).
|
||||
|
||||
And for the **ads** of a room — `wai.live.ads`: server-guided ad insertion that keeps the
|
||||
manifests shared. The room authority signs an ad policy, `WAP1`, naming the grant its ad
|
||||
decider runs under, whose funds ceiling is the room's ad budget, and how breaks are presented
|
||||
(slots, an avail for a break opened with no planned duration, a preload window, skip and jump
|
||||
restrictions, a skip control, snapping, and the base URIs of asset lists and creatives). A
|
||||
room's breaks are its signed `cue` events of `wai.live.events`, unchanged: an out and its in,
|
||||
or a break of known duration, and a post-roll that a `WAB1` annotation in the cue author's
|
||||
chain marks; a verifier refuses overlapping breaks, an in with no out, a break of no duration,
|
||||
an in that misstates its duration and any cue after the post-roll, and the room refuses a cue
|
||||
by a principal whose grant does not permit one. Each break maps, as a pure function of the
|
||||
signed records, onto one interstitial date range of HLS (`draft-pantos-hls-rfc8216bis-22`
|
||||
Appendix D, its closing tag at the in, and a preload range of Appendix F) and one
|
||||
alternative-presentation event of DASH (ISO/IEC 23009-1:2026, replace for a mid-roll and
|
||||
insert for a post-roll), with an `ID` derived from the opening cue's digest, so every edge
|
||||
writes the same bytes. Each viewer's decision is made behind the one URI a break names: the
|
||||
asset list (an HLS asset list, or a DASH list MPD importing each ad's MPD), filled by
|
||||
`wai.video.ssai`'s deterministic decision over the viewer's pod and what remains of the break
|
||||
for a viewer who joins it running, and sealed in that capability's receipt of form 2, which
|
||||
binds the break, the session, the decision's inputs and the asset list served. A verifier
|
||||
recomputes the fill and the asset list, refuses a second receipt of one session for one break,
|
||||
and holds the spend of every receipt together to the decider grant's ceiling:
|
||||
[`extensions/live-ads.md`](extensions/live-ads.md).
|
||||
|
||||
### Payloads of the WAI-native integer codecs
|
||||
|
||||
This revision pins a payload format to each WAI-native integer codec whose row named
|
||||
|
|
|
|||
715
wai/extensions/live-ads.md
Normal file
715
wai/extensions/live-ads.md
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
# WAI Extension: Live Ads
|
||||
|
||||
> Status: Draft. Registers the record `wai.live.ads`: server-guided ad insertion for rooms.
|
||||
> The room authority signs an **ad policy**, `WAP1`, naming the grant its ad decider runs
|
||||
> under and how the room's breaks are presented. The breaks themselves are the room's signed
|
||||
> `cue` events ([live-events](live-events.md) §4.3), an out and its in, or a break of known
|
||||
> duration, with a post-roll marked by a **break annotation**, `WAB1`, in the cue author's
|
||||
> chain. Each break maps, deterministically, onto one interstitial date range of HLS and one
|
||||
> alternative-presentation event of DASH, so every edge writes the same playlist and MPD bytes
|
||||
> and the manifests stay shared and cacheable. Per-viewer decisioning happens behind the one
|
||||
> URI each break names: the **asset list** a player fetches when it reaches the break, filled
|
||||
> by `wai.video.ssai`'s deterministic decision. Each viewer's impressions are sealed in a
|
||||
> `wai.video.ssai` receipt of its **break form**, which names the break, the decision's
|
||||
> inputs and the asset list served, and a verifier recomputes the decision, the asset list and
|
||||
> the spend against the decider grant's funds ceiling. No new transport. The reference
|
||||
> implementation is the `live_ads` module of `wai-rs` (feature `live_ads`) with the break form
|
||||
> in `wai_video_ssai`; the corpus is [`live-conformance/ads/`](../live-conformance/ads/), and
|
||||
> its second implementation is `live-conformance/live_ads_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> Texts read for this revision: `draft-pantos-hls-rfc8216bis-22` (1 May 2026), its §4.4.5.1
|
||||
> (`EXT-X-DATERANGE`), §6.2.1, §6.2.4, Appendix D (Interstitials) and Appendix F (Preloading
|
||||
> HLS Date Range Resources). Its Appendix H is a glossary and defines no date-range schedule,
|
||||
> so nothing here maps onto it. ISO/IEC 23009-1:2026, the sixth edition, through the XML
|
||||
> schema that accompanies it (its types `EventType`, `AlternativeMPDEventType`,
|
||||
> `AlternativeMPDReplaceEventType`, `ImportedMpdType` and `PresentationType`) and the schema's
|
||||
> example MPDs G23, G29-1 and G29-2, at the schema's revision of 2026-06-13; the clause text of
|
||||
> its §5.16 was not available to this revision (§6.1). A section number of one of these texts
|
||||
> is that revision's.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope: the ad policy, its signature and rules (§3); the room's breaks, derived from its
|
||||
`cue` events and break annotations, with their identity and refusals (§4); their HLS
|
||||
interstitial date ranges (§5) and their DASH alternative-presentation events (§6); the asset
|
||||
list, the decision that fills it, and a viewer joining a break already running (§7); the break
|
||||
form of the `wai.video.ssai` receipt (§8); and verification end to end, from the room's events
|
||||
to the spend (§9).
|
||||
|
||||
Out of scope: a transport; how a decider assembles each viewer's candidate pod (targeting);
|
||||
how beacons reach it; the SCTE-35 messages a cue may carry, which WAI carries verbatim and
|
||||
never reads (live-events §4.3); the stitched form of `wai.video.ssai`, which is unchanged
|
||||
(§8.1); and a pre-roll, which a room does not have: a viewer who joins a running break is
|
||||
given what remains of it (§7.4), and one who joins between breaks starts with the program.
|
||||
|
||||
Relationship to the core spec: `wai.live.ads` is a **record** ([SPEC](../SPEC.md) §5): a
|
||||
signed record a WAI tool emits and verifies. It names no payload format and is not
|
||||
dispatchable.
|
||||
|
||||
Relationship to [live-events](live-events.md): this extension changes nothing in it. A break
|
||||
is opened and closed by `cue` events as they are; the break annotation rides in the `meta`
|
||||
kind (§4.2 there), bound to its cue by the chain, as [timed-text](timed-text.md)'s
|
||||
annotation is bound to its caption. Live-events' own date range of each cue (§8.3 there) is
|
||||
written beside the interstitial date range of this extension, under another `ID` and another
|
||||
`CLASS`.
|
||||
|
||||
Relationship to [live-rooms](live-rooms.md): a cue's author is held to its grant: a principal
|
||||
whose grant in effect does not permit `cue` is refused by the room verifier,
|
||||
`kind_not_permitted`, before this extension reads its cue (live-rooms §9.2 step 4). The
|
||||
decider runs under a room grant, whose funds ceiling bounds the room's ad spend (§3.4), and
|
||||
names it in its receipts as live-rooms §4.5 has every receipt name a grant.
|
||||
|
||||
Why a record of its own, beside `wai.video.ssai`: the policy, the breaks and their renderings
|
||||
are verified against a room and its events track, which the stitched form never reads; the
|
||||
impressions are `wai.video.ssai`'s, in a second form of its receipt (§8), so that one decision
|
||||
rule and one spend rule serve stitched and server-guided insertion alike, and every receipt of
|
||||
the stitched form keeps its bytes.
|
||||
|
||||
## 2. Terms
|
||||
|
||||
- **Room**, **room clock**, **principal**, **event**, **chain**, **grant**, **room key**,
|
||||
**merged order**: as in live-events §2 and live-rooms §2 and §9.2.
|
||||
- **Cue**: a `cue` event (live-events §4.3). Its **type** is out (1), in (2), chapter (3) or
|
||||
break (4).
|
||||
- **Break**: a span of the room's program that ads replace, opened by a cue of type out or
|
||||
break (§4.3). A break is **open** from an out until its in, and **closed** otherwise.
|
||||
- **Post-roll**: a break played after the program ends (§4.2).
|
||||
- **Avail**: the duration, in milliseconds, a break's decision fills (§4.3).
|
||||
- **Decider**: the principal that decides each viewer's fill and signs the receipts, under
|
||||
the grant the policy names (§3.4).
|
||||
- **Pod**: the candidate ads a decision runs over for one viewer and one break, each an
|
||||
`ad_id`, a `creative_hash`, a `duration_ms` and a `price_micros` (§7.1).
|
||||
- **Fill**: the ads a decision selects, in slot order.
|
||||
- **Asset list**: the response to the request a player makes for a break: an HLS asset list
|
||||
(§7.2) or a DASH list MPD (§7.3).
|
||||
- **Session**: one viewer's playback session, named in a receipt by 16 bytes the decider
|
||||
assigns (§8.2).
|
||||
|
||||
Integers in the records of §3 and §4 are little-endian, and a digest there is SHA-256 (FIPS
|
||||
180-4), 32 bytes. Integers of the receipt's signed bytes (§8) are big-endian and its hashes
|
||||
BLAKE3, as the stitched form's are. Ed25519 is RFC 8032's, verified strictly ([SPEC](../SPEC.md)
|
||||
§7 *Signatures*): a key that fails step 1 or 2 is `bad_key`, and a signature that fails a
|
||||
later step is `bad_signature`. A *long* field is a u16 length and that many bytes.
|
||||
|
||||
## 3. The ad policy
|
||||
|
||||
### 3.1 Layout
|
||||
|
||||
| offset | size | field | meaning |
|
||||
|---|---|---|---|
|
||||
| 0 | 4 | `magic` | `WAP1` (ASCII) |
|
||||
| 4 | 1 | `version` | 1 in this revision |
|
||||
| 5 | 32 | `room` | the room id (live-rooms §3.2) |
|
||||
| 37 | 32 | `decider_grant` | the digest of the room grant the decider runs under (§3.4) |
|
||||
| 69 | 1 | `slots` | the most ads one break holds, 1 to 16 |
|
||||
| 70 | 4 | `open_avail_ms` | u32: the avail of a break opened with no planned duration (§4.3); 0: such a break is refused |
|
||||
| 74 | 4 | `preload_ms` | u32: how long before a break a player may fetch its asset list (§5.3, §6.2); 0: no preload |
|
||||
| 78 | 1 | `restrict` | bit 0 **skip**: no seeking forward in a break; bit 1 **jump**: no seeking past a break unplayed |
|
||||
| 79 | 2 | `skip_after_s` | u16: seconds of a break played before a viewer may skip the rest; 65535: no skip control |
|
||||
| 81 | 1 | `snap` | bit 0 **out**: leave the program at the segment boundary nearest the break's start; bit 1 **in**: return at the one nearest its end |
|
||||
| 82 | long | `asset_base` | the base of every asset-list URI (§7) |
|
||||
| … | long | `creative_base` | the base of every creative's URI (§7) |
|
||||
| … | 64 | `sig` | §3.2 |
|
||||
|
||||
A **base** is `https://` followed by 1 to 492 bytes, each of `!` to `~` (0x21–0x7E) other
|
||||
than `"`, `#`, `&`, `'`, `<`, `>`, `?` and `\`, the last of them `/`. A base so holds no
|
||||
character that HLS's quoted strings, JSON strings or XML attribute values would have to escape,
|
||||
and a URI built on it (§7) is the base followed by a name of the break or of the creative.
|
||||
|
||||
### 3.2 Digest and signature
|
||||
|
||||
The **digest** of a policy is SHA-256 over its record from `magic` through `creative_base`.
|
||||
The **signature** is Ed25519 by the room descriptor's `authority` over the 20 bytes
|
||||
`wai:live-ads-policy` (ASCII) and 0x01, followed by the bytes the digest covers.
|
||||
|
||||
### 3.3 Reading and rules
|
||||
|
||||
A reader reads exactly the bytes it is given as one policy, in the order of §3.1, and refuses
|
||||
at the first of these that holds:
|
||||
|
||||
1. Fewer than 4 bytes: `truncated`. A magic other than `WAP1`: `malformed`. No byte after the
|
||||
magic: `truncated`. A version other than 1: `unknown_version`.
|
||||
2. The bytes end before a field the layout requires: `truncated`.
|
||||
3. Bytes after `sig`: `malformed`.
|
||||
|
||||
It then refuses as `ad_policy_malformed` a policy whose `slots` is not 1 to 16, whose
|
||||
`restrict` or `snap` sets a bit above bit 1, or whose `asset_base` or `creative_base` is not a
|
||||
base (§3.1).
|
||||
|
||||
### 3.4 The decider
|
||||
|
||||
The decider is the `principal` of the grant whose digest is `decider_grant`. That grant is one
|
||||
of the room's grants, which the room verifier checks (live-rooms §4.3); it need not be used in
|
||||
a `join`, since a decider sends no event. Its `funds_ceiling` and `currency` are the room's ad
|
||||
budget: every price and every spend of §7 and §8 is in millionths of a minor unit of
|
||||
`currency`, as [live-metering](live-metering.md)'s tariff prices are, so a spend of *s* is
|
||||
within a ceiling of *c* minor units when *s* ≤ *c* × 10^6. A `funds_ceiling` of 2^64 − 1
|
||||
states no ceiling. The decider's receipts name the grant as live-rooms §4.5 has a receipt name
|
||||
one (§9.3).
|
||||
|
||||
A grant is good for a break when it is good at the break's `t` (live-rooms §4.3), and it is
|
||||
not revoked through the authority's key log (live-rooms §5).
|
||||
|
||||
## 4. Breaks
|
||||
|
||||
### 4.1 Cues
|
||||
|
||||
A cue (live-events §4.3) carries a `type`, a `cue_id`, a `duration_ms` and, optionally, an
|
||||
SCTE-35 message this extension never reads. Its `t` is the instant of the splice on the room
|
||||
clock: a publisher **MAY** send a cue before that instant, and a cue anchored to a media object
|
||||
(live-events §3.1) splices at that object. Every cue the room accepted, of any principal
|
||||
permitted `cue`, the room keys among them, is read here, in the merged order (live-rooms §9.2),
|
||||
each once.
|
||||
|
||||
### 4.2 The break annotation
|
||||
|
||||
A break is mid-roll unless its author annotates it as a post-roll. The **annotation** is the
|
||||
payload of a `meta` event whose first four bytes are `WAB1`, by the same principal, at the
|
||||
cue's `seq` + 1 and at the cue's `t`:
|
||||
|
||||
| offset | size | field | meaning |
|
||||
|---|---|---|---|
|
||||
| 0 | 4 | `magic` | `WAB1` (ASCII) |
|
||||
| 4 | 1 | `version` | 1 in this revision |
|
||||
| 5 | 32 | `cue` | the digest of the cue it annotates |
|
||||
| 37 | 1 | `placement` | 1 mid-roll, 2 post-roll |
|
||||
|
||||
An annotation is refused `annotation_malformed` when it is not 38 bytes, its version is not 1,
|
||||
or its `placement` is not 1 or 2. It is refused `annotation_misplaced` unless the event before
|
||||
it in its author's chain, at `seq` − 1, is a cue of type break, at the same `t`, whose digest
|
||||
is `cue`.
|
||||
|
||||
A **post-roll** is the break of an annotation of placement 2. It ends the program: after it,
|
||||
the room has no break and no cue (§4.3, `cue_after_close`), and a room that closes ends with
|
||||
it. A player plays it after the program has ended (§5.1, §6.2).
|
||||
|
||||
### 4.3 The schedule
|
||||
|
||||
The **schedule** is the room's breaks and chapters, in the order of the cues that open them.
|
||||
Over the cues and annotations in the merged order, holding the open break (at most one) and
|
||||
the end of the last closed break, a verifier refuses, at the record:
|
||||
|
||||
- any cue after a post-roll's annotation: `cue_after_close`;
|
||||
- **out** (1): when a break is open, or `t` is before the end of the last closed break,
|
||||
`break_overlap`. Otherwise it opens a break: its **planned** duration is `duration_ms`, and
|
||||
its avail is `duration_ms`, or the policy's `open_avail_ms` when `duration_ms` is 0; with
|
||||
both 0, `break_duration_unknown`;
|
||||
- **in** (2): unless the open break was opened by an out of this `cue_id` and of the in's
|
||||
author, `cue_in_unmatched`. The break's **duration** is ⌊(`t` − the out's `t`) / 1000⌋
|
||||
milliseconds: when it is 0, `break_zero_duration`; when the in's `duration_ms` is not 0 and
|
||||
is not the duration, `cue_duration_mismatch`. The break is then closed, and its end is the
|
||||
in's `t`;
|
||||
- **chapter** (3): a chapter of the schedule, its `t` and its `duration_ms` (0 when not known).
|
||||
A chapter opens and closes nothing, and may fall inside a break;
|
||||
- **break** (4): when `duration_ms` is 0, `break_zero_duration`; when a break is open, or `t`
|
||||
is before the end of the last closed break, `break_overlap`. Otherwise it is a closed break
|
||||
whose duration and avail are `duration_ms`, ending at `t` + `duration_ms` × 1000;
|
||||
- an **annotation** (§4.2): read and placed, or refused; of placement 2, its break is the
|
||||
post-roll.
|
||||
|
||||
A break open at the end is stated open: the room may still be live. A cue of the room that
|
||||
does not read is the room's refusal, and an event with a gap before it is a delivery gap the
|
||||
room states (§9.2 step 1).
|
||||
|
||||
### 4.4 A break's identity
|
||||
|
||||
A break is named by the digest of the cue that opens it. From that digest:
|
||||
|
||||
- its HLS **ID** is `wai-ad-` followed by the digest's 64 lowercase hexadecimal digits;
|
||||
- its DASH **event id** is the digest's first eight bytes read as a big-endian u64, of which
|
||||
the low 53 bits are kept, so that a player that holds an id as a double holds it exactly. A
|
||||
receiver identifies a break by its digest; the id lets a player tell one event from another
|
||||
in an EventStream, where a repeat of 53 bits among a room's breaks is possible but not
|
||||
expected.
|
||||
|
||||
Every edge holding the room's events derives the same identity, and no other event of the room
|
||||
has it: live-events' own date range of the cue has the digest alone as its `ID`.
|
||||
|
||||
## 5. HLS: interstitial date ranges
|
||||
|
||||
### 5.1 The interstitial tag
|
||||
|
||||
Each break is one Interstitial `EXT-X-DATERANGE` tag (`draft-pantos-hls-rfc8216bis-22`
|
||||
§4.4.5.1, Appendix D.2), written into every Media Playlist of the room when its cue is known.
|
||||
Its attributes are written in this order, separated by commas with no spaces, each only where
|
||||
its row says:
|
||||
|
||||
| attribute | value |
|
||||
|---|---|
|
||||
| `ID` | quoted: the break's ID (§4.4) |
|
||||
| `CLASS` | quoted: `com.apple.hls.interstitial`, the class Appendix D.2 defines <!-- allow-name: the interstitial date-range class, a protocol identifier --> |
|
||||
| `START-DATE` | quoted: `t0` + `t` of the opening cue, as `YYYY-MM-DDTHH:MM:SS.mmmZ` in UTC, the milliseconds floored (live-events §8.3) |
|
||||
| `CUE` | quoted: `ONCE` for a mid-roll, `POST,ONCE` for a post-roll (§4.4.5.1) |
|
||||
| `DURATION` | a break of type 4: its duration as decimal seconds with three digits after the point (`30.000`) |
|
||||
| `PLANNED-DURATION` | an out with a planned duration: that duration, as `DURATION` |
|
||||
| `X-ASSET-LIST` | quoted: the break's HLS asset-list URI (§7.2) |
|
||||
| `X-RESUME-OFFSET` | a mid-roll of type 4: its duration, as `DURATION` |
|
||||
| `X-PLAYOUT-LIMIT` | a break of type 4: its duration, as `DURATION` |
|
||||
| `X-SNAP` | when `snap` is not 0: quoted, `OUT`, `IN` or `OUT,IN` by its bits |
|
||||
| `X-RESTRICT` | when `restrict` is not 0: quoted, `SKIP`, `JUMP` or `SKIP,JUMP` by its bits |
|
||||
| `X-SKIP-CONTROL-OFFSET` | when `skip_after_s` is not 65535: `skip_after_s` in decimal (Appendix D.3) |
|
||||
|
||||
- `ONCE` keeps a viewer who seeks back over a break in a window of the room from being shown
|
||||
it again; `POST` plays the post-roll once the program has played to its end, which for a
|
||||
room is its playlist's `EXT-X-ENDLIST`.
|
||||
- `X-RESUME-OFFSET` equal to the break's duration returns the viewer to the program where the
|
||||
break ends on the room's timeline, whatever the fill's length, so the program a viewer
|
||||
returns to is the program after the in. A post-roll has nothing to resume, and no
|
||||
`X-RESUME-OFFSET`.
|
||||
- An out whose end is not yet known has no `DURATION`, `X-RESUME-OFFSET` or
|
||||
`X-PLAYOUT-LIMIT`: the closing tag adds them (§5.2).
|
||||
|
||||
### 5.2 The closing tag
|
||||
|
||||
When an out's in closes it, the break gains a second tag with the same `ID`, as §6.2.1 has a
|
||||
server signal the actual end of a date range with a planned duration, and as §4.4.5.1 lets a
|
||||
later tag add attributes to a date range:
|
||||
|
||||
| attribute | value |
|
||||
|---|---|
|
||||
| `ID` | quoted: the break's ID |
|
||||
| `CLASS` | quoted: as §5.1 <!-- allow-name: the interstitial date-range class, a protocol identifier --> |
|
||||
| `DURATION` | the break's duration (§4.3), as §5.1 writes durations |
|
||||
| `X-RESUME-OFFSET` | the same value |
|
||||
| `X-PLAYOUT-LIMIT` | the same value |
|
||||
|
||||
No attribute of the first tag changes: the closing tag adds three it did not have, and repeats
|
||||
`CLASS` with its value. A player inside the break when the closing tag arrives ends the break
|
||||
at the playout limit, and returns to the program where the in placed its end.
|
||||
|
||||
### 5.3 The preload tag
|
||||
|
||||
When the policy's `preload_ms` is not 0, each break also has a Preload `EXT-X-DATERANGE` tag
|
||||
(Appendix F, Appendix D.2), written just before its interstitial tag:
|
||||
|
||||
| attribute | value |
|
||||
|---|---|
|
||||
| `ID` | quoted: the break's ID followed by `-preload` |
|
||||
| `CLASS` | quoted: `com.apple.hls.preload`, the class Appendix F defines <!-- allow-name: the preload date-range class, a protocol identifier --> |
|
||||
| `START-DATE` | quoted: the interstitial's `START-DATE` less `preload_ms`, in its form |
|
||||
| `DURATION` | `preload_ms`, as §5.1 writes durations |
|
||||
| `X-URI` | quoted: the break's HLS asset-list URI (§7.2) |
|
||||
| `X-TARGET-ID` | quoted: the break's ID |
|
||||
| `X-TARGET-CLASS` | quoted: the interstitial class <!-- allow-name: the interstitial date-range class, a protocol identifier --> |
|
||||
|
||||
A player picks a random instant in the preload range and fetches the asset list there
|
||||
(Appendix D.2), so the requests of a room's audience for one break are spread over
|
||||
`preload_ms` rather than arriving together at its start.
|
||||
|
||||
### 5.4 What stays shared
|
||||
|
||||
Every attribute of §5.1 to §5.3 is a function of the signed cue, its annotation, the signed
|
||||
policy and the room's `t0`, and of nothing a viewer supplies. Every Media Playlist of the room
|
||||
therefore carries the same date ranges with the same attributes, as §6.2.4 requires, and every
|
||||
edge that holds the room's events writes the same bytes, so playlists stay cacheable. The one
|
||||
URI a break names is where each viewer's decision is made (§7), and the request for it carries
|
||||
what makes it a viewer's: the `_HLS_primary_id` and `_HLS_start_offset` query parameters
|
||||
Appendix D.4 has a player add.
|
||||
|
||||
A playlist that carries these tags carries `EXT-X-PROGRAM-DATE-TIME` on the same UTC timeline
|
||||
as `t0` (§4.4.5.1). The corpus states the tags of each room in the order a packager writes
|
||||
them: a break's preload and interstitial tags at its cue, its closing tag at its in.
|
||||
|
||||
## 6. DASH: alternative presentation events
|
||||
|
||||
### 6.1 What this revision read
|
||||
|
||||
ISO/IEC 23009-1:2026 defines alternative presentation events in its §5.16, whose text this
|
||||
revision could not read. It read the sixth edition's MPD schema and its example MPDs. From
|
||||
them it takes: the event schemes `urn:mpeg:dash:event:alternativeMPD:replace:2025` and
|
||||
`urn:mpeg:dash:event:alternativeMPD:insert:2025` (example G23 and G29-1 use the first); an
|
||||
`Event`'s `presentationTime`, `duration` and `id`; its child `ReplacePresentation` or
|
||||
`InsertPresentation`, with the attributes `uri` (required), `earliestResolutionTimeOffset`,
|
||||
`maxDuration`, `executeOnce`, `skipAfter` and, for a replacement, `returnOffset`, with their
|
||||
types; and the **list MPD**, an MPD of `type` `list` whose Periods each import one MPD
|
||||
(`ImportedMPD`). The values below are written from those types and examples. Where the
|
||||
schema has an attribute whose meaning this revision could not state, it does not write it:
|
||||
`noJump`, `serviceDescriptionId`, `startWithOffset` and `clip`, whose defaults stand. A later
|
||||
revision checks every value here against §5.16.
|
||||
|
||||
### 6.2 The event
|
||||
|
||||
A mid-roll is an `Event` of the replace scheme; a post-roll is one of the insert scheme, since
|
||||
after the program there is nothing to replace. With `timescale` 1000 on its EventStream:
|
||||
|
||||
| attribute | value |
|
||||
|---|---|
|
||||
| `presentationTime` | `pt0` + ⌊`t` / 1000⌋, `t` the opening cue's, where `pt0` is the presentation time of `t0` in milliseconds on the Period's timeline (live-events §8.4) |
|
||||
| `duration` | when the break's duration is known (a break of type 4, or an out its in has closed): that duration in milliseconds |
|
||||
| `id` | the break's event id (§4.4) |
|
||||
|
||||
and its one child, `ReplacePresentation` for a mid-roll and `InsertPresentation` for a
|
||||
post-roll:
|
||||
|
||||
| attribute | value |
|
||||
|---|---|
|
||||
| `uri` | the break's DASH asset-list URI (§7.3) |
|
||||
| `earliestResolutionTimeOffset` | when `preload_ms` is not 0: `preload_ms` |
|
||||
| `maxDuration` | when the duration is known: the duration in milliseconds |
|
||||
| `executeOnce` | `true`, as HLS's `ONCE` |
|
||||
| `skipAfter` | `PT`, then `skip_after_s` in decimal, then `S`; with no skip control, the avail in whole seconds rounded up (`PT30S` for 30 000 ms), so that no viewer skips before the avail has played: the schema's examples read a missing `skipAfter` as a break a viewer may skip at once |
|
||||
| `returnOffset` | a `ReplacePresentation` whose duration is known: the duration in milliseconds, as HLS's `X-RESUME-OFFSET` |
|
||||
|
||||
Attributes are written in the order of the two tables, each as `name="value"`, the element as
|
||||
`<Event …><ReplacePresentation …/></Event>`, with no whitespace other than one space before
|
||||
each attribute. An out's event is written first without `duration`, `maxDuration` and
|
||||
`returnOffset`, and the MPD that follows its in writes the same event with them: an MPD update
|
||||
gives the event its end as HLS's closing tag does (§5.2). A planned duration has no DASH
|
||||
attribute.
|
||||
|
||||
### 6.3 The event streams
|
||||
|
||||
The Period that holds the room's program carries, after every break of the schedule is
|
||||
written:
|
||||
|
||||
1. when the schedule has a mid-roll, `<EventStream schemeIdUri="urn:mpeg:dash:event:alternativeMPD:replace:2025" timescale="1000">`,
|
||||
the mid-rolls' events in schedule order, and `</EventStream>`;
|
||||
2. when it has a post-roll, the same with the insert scheme and the post-roll's event.
|
||||
|
||||
Every value is a function of the signed records, the policy and `pt0`, so the MPD stays one
|
||||
for every viewer, as the playlists do (§5.4).
|
||||
|
||||
## 7. The asset list
|
||||
|
||||
### 7.1 The decision
|
||||
|
||||
A player that reaches a break fetches its asset list. The decider fills it for that viewer with
|
||||
the decision of `wai.video.ssai` (SPEC §5), unchanged:
|
||||
|
||||
- its inputs are the viewer's pod, the policy's `slots`, an **avail** and the **funds
|
||||
remaining**: the break's avail (§4.3) less the viewer's start offset (§7.4), or 0 when the
|
||||
offset reaches it; and the funds the decider states it had left of the decider grant's
|
||||
ceiling, at most that ceiling (§3.4);
|
||||
- the candidates are taken in order of `price_micros` descending, ties by `ad_id` ascending
|
||||
(as bytes); each is taken when fewer than `slots` are taken, its `duration_ms` is at most the
|
||||
avail not yet taken and its `price_micros` at most the funds not yet taken; the fill is the
|
||||
taken candidates in that order, slot 0 first.
|
||||
|
||||
The fill is a pure function of those inputs, so anyone holding them recomputes it, and the
|
||||
receipt binds each of them (§8).
|
||||
|
||||
### 7.2 HLS asset list
|
||||
|
||||
The break's **HLS asset-list URI** is `asset_base`, the break's ID, `.json?_HLS_interstitial_id=`
|
||||
and the ID again: the query parameter Appendix D.4 has a packager add. The response is the
|
||||
JSON object Appendix D.2 defines, written exactly:
|
||||
|
||||
```
|
||||
{"ASSETS":[{"URI":"<creative_base><creative_hash>.m3u8","DURATION":<s>},…]}
|
||||
```
|
||||
|
||||
one Asset-Description for each ad of the fill in slot order, `<creative_hash>` its 64
|
||||
lowercase hexadecimal digits and `<s>` its `duration_ms` as decimal seconds with three digits
|
||||
after the point, with no whitespace and no byte after the closing brace. An empty fill is
|
||||
`{"ASSETS":[]}`, which has a player resume the program without playing an ad (Appendix D.5).
|
||||
The asset list carries no `SKIP-CONTROL`: the tag's attributes stand.
|
||||
|
||||
A creative's URI names it by its hash, so the asset list a viewer is served binds the creative
|
||||
bytes the receipt names.
|
||||
|
||||
### 7.3 DASH list MPD
|
||||
|
||||
The break's **DASH asset-list URI** is `asset_base`, the break's ID and `.mpd`. The response is
|
||||
a list MPD, written exactly as the XML declaration `<?xml version="1.0" encoding="UTF-8"?>`, a
|
||||
line feed, and:
|
||||
|
||||
```
|
||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" profiles="urn:mpeg:dash:profile:list:2024" type="list" minBufferTime="PT1S"><Period id="0" duration="PT<s>S"><ImportedMPD><creative_base><creative_hash>.mpd</ImportedMPD></Period>…</MPD>
|
||||
```
|
||||
|
||||
one Period for each ad of the fill, its `id` the slot in decimal and `<s>` as §7.2, with no
|
||||
whitespace other than that shown and no byte after `</MPD>`. The profile and `minBufferTime`
|
||||
are those of the schema's list-MPD example (G29-2). An MPD has at least one Period, so an empty
|
||||
fill has no list MPD: the decider answers the request with no body, and the receipt binds the
|
||||
empty asset list, zero bytes (§8.2).
|
||||
|
||||
### 7.4 Joining a break
|
||||
|
||||
A viewer who reaches a break after its start, by joining the room inside it, has its player
|
||||
add the offset into the break at which it starts (`_HLS_start_offset`, Appendix D.4). The
|
||||
decider fills the remaining avail only (§7.1), and the receipt states the offset
|
||||
(`start_offset_ms`). A viewer who joins between breaks is given no break: a room has no
|
||||
pre-roll. The asset list a joining viewer is served is a fill like any other, and every check
|
||||
of §9.3 applies to it.
|
||||
|
||||
## 8. The break form of the `wai.video.ssai` receipt
|
||||
|
||||
### 8.1 Forms
|
||||
|
||||
A `wai.video.ssai` receipt has two forms:
|
||||
|
||||
- **form 1, stitched**: an avail of an attested manifest, named by `avail_id` and
|
||||
`manifest_hash`, as SPEC §5 registers it. This revision changes nothing in it: its signed
|
||||
bytes, its identity and every receipt of it stay as they were;
|
||||
- **form 2, break**: a break of a room's schedule and the asset list one viewer was served for
|
||||
it, defined here.
|
||||
|
||||
The two forms sign under different domains (§8.4), so no signature of one is a signature of
|
||||
the other, and they share the impression, the pod hash, the Merkle root, the billable spend and
|
||||
the identity domain, so a figure profile of energy-measurement §6.1,
|
||||
`wai:video-ssai-receipt-id`, names either.
|
||||
|
||||
### 8.2 Fields and their JSON carriage
|
||||
|
||||
A form-2 receipt is carried as one JSON object (RFC 8259) with exactly these members; a byte
|
||||
field is lowercase hexadecimal of its exact length, and an integer a JSON integer from 0 to
|
||||
2^64 − 1:
|
||||
|
||||
| member | value |
|
||||
|---|---|
|
||||
| `form` | 2 |
|
||||
| `room` | 32 bytes: the room id |
|
||||
| `cue` | 32 bytes: the break's digest (§4.4) |
|
||||
| `session` | 16 bytes: the session (§2), one value for every break of one playback session, and no other's |
|
||||
| `start_offset_ms` | the viewer's offset into the break (§7.4); 0 for a viewer present at its start |
|
||||
| `format` | from 0 to 255: 1, the HLS asset list (§7.2); 2, the DASH list MPD (§7.3) |
|
||||
| `asset_list_sha256` | 32 bytes: SHA-256 of the asset list served, its exact bytes |
|
||||
| `avail_ms` | the break's avail (§4.3) |
|
||||
| `slots` | the policy's `slots` |
|
||||
| `funds_remaining_micros` | the funds the decision ran with (§7.1) |
|
||||
| `pod_hash` | 32 bytes: §8.3 |
|
||||
| `impressions` | an array: each filled slot, `{"slot", "ad_id", "creative_hash", "duration_ms", "price_micros", "quartiles_reached"}`, in slot order; `ad_id` a string, `creative_hash` 32 bytes, `quartiles_reached` 0 to 255 |
|
||||
| `merkle_root` | 32 bytes: §8.3 |
|
||||
| `budget_spent_micros` | the billable spend (§8.3) |
|
||||
| `work_decide_ops` | the candidates the decision considered |
|
||||
| `joules_micro` | the decision's energy, or 0 unmetered |
|
||||
| `energy_provenance` | its class's label (energy-measurement §2.7: `ModelBased`, `Estimator`, `OnChipCounter`, `CalibratedInstrument`), or `null` unmetered |
|
||||
| `energy_uncertainty`, `energy_counter_filtering`, `energy_calibration_ref` | only for a class that carries them, as energy-measurement §2.7 names and writes them: `{"relative_ppm", "window_us"}`, `relative_ppm` from 0 to 2^32 − 1; `Unknown`, `Off` or `On`; a string |
|
||||
| `grant` | `{"grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"}`: the grant reference (live-rooms §4.5), `funds_ceiling` `null` for none |
|
||||
| `signer_pubkey` | 32 bytes: the decider's Ed25519 key |
|
||||
| `signer_id` | a string: the decider's `did:key` or origin-bound id |
|
||||
| `sig` | 64 bytes: §8.4 |
|
||||
|
||||
`quartiles_reached` is the stitched form's: 0 never rendered, 1 started (the billable
|
||||
impression), 2 to 5 a quarter, a half, three quarters and all of it. A receipt that does not
|
||||
read so, or that carries the legacy class label `HwShunt` (energy-measurement §2.6), is
|
||||
refused `receipt_malformed`.
|
||||
|
||||
### 8.3 Hashes
|
||||
|
||||
BLAKE3, with integers big-endian, as the stitched form computes them:
|
||||
|
||||
- the **pod hash**: over `wai:video-ssai-pod` 0x01, the candidates' count (u64), and for each
|
||||
candidate in the pod's order, `ad_id`'s length (u32) and bytes, `creative_hash`,
|
||||
`duration_ms` (u64) and `price_micros` (u64);
|
||||
- an impression's **leaf**: over `wai:video-ssai-imp` 0x01, `slot` (u64), `ad_id`'s length
|
||||
(u32) and bytes, `creative_hash`, `duration_ms` (u64), `price_micros` (u64) and
|
||||
`quartiles_reached` (u8);
|
||||
- the **Merkle root** over the leaves in slot order: [jwp-receipts](jwp-receipts.md) §2.2's
|
||||
tree, each leaf taken as a content hash (its first leaf form) and the final one duplicated
|
||||
on an odd level; all zero for no impression;
|
||||
- the **billable spend**: the sum of `price_micros` over the impressions whose
|
||||
`quartiles_reached` is at least 1.
|
||||
|
||||
### 8.4 Signing payload and identity
|
||||
|
||||
The **signing payload** is the concatenation of:
|
||||
|
||||
1. `wai:video-ssai-break-receipt` (ASCII) and 0x01;
|
||||
2. `room`, `cue`, `session`, `start_offset_ms` (u64), `format` (u8), `asset_list_sha256`,
|
||||
`avail_ms` (u64), `slots` (u64), `funds_remaining_micros` (u64);
|
||||
3. `pod_hash`, `merkle_root`, `budget_spent_micros` (u64), `work_decide_ops` (u64),
|
||||
`joules_micro` (u64), then the class in its signed encoding (energy-measurement §2.7), the
|
||||
tag byte 0 for none;
|
||||
4. the grant reference: `grant_hash`, the length (u32) and bytes of `capability`,
|
||||
`joule_ceiling_micro` (u64), then 1 and `funds_ceiling` (u64), or 0 for none;
|
||||
5. the impressions' count (u64), then each one's leaf;
|
||||
6. `signer_pubkey`, then the length (u32) and bytes of `signer_id`.
|
||||
|
||||
`sig` is Ed25519 by `signer_pubkey` over the signing payload. The receipt's **identity**, its
|
||||
receipt hash, is BLAKE3 over `wai:video-ssai-receipt-id` 0x01, the signing payload and `sig`:
|
||||
the stitched form's identity domain, over a payload whose first bytes no stitched payload has.
|
||||
|
||||
### 8.5 Its own rules
|
||||
|
||||
A form-2 receipt holds, on its own:
|
||||
|
||||
1. `format` is 1 or 2;
|
||||
2. its signature verifies (`bad_key`, `bad_signature`);
|
||||
3. `merkle_root` is the root of its impressions, `budget_spent_micros` their billable spend,
|
||||
and when `grant`'s `funds_ceiling` is not `null`, the spend is within it (§3.4);
|
||||
4. the impressions' slots are 0, 1, 2, … in order, each `quartiles_reached` is at most 5, and
|
||||
`work_decide_ops` is at least their count;
|
||||
5. the figure and its class cohere as energy-measurement §2 has them: `joules_micro` 0 with no
|
||||
class, or above 0 with a class, a hardware class's declared uncertainty with
|
||||
`relative_ppm` and `window_us` above 0 and at the resolution floor (`joules_micro` ×
|
||||
`relative_ppm` at least 288 676), and a calibrated instrument's reference holding a
|
||||
character that is not Unicode White_Space.
|
||||
|
||||
A rule of 1, 3, 4 or 5 that fails is `receipt_invalid`. These rules are what a buyer holding
|
||||
one receipt checks alone; §9.3 adds what the room, the policy and the pod let a verifier check.
|
||||
|
||||
## 9. Verification
|
||||
|
||||
### 9.1 Inputs
|
||||
|
||||
An ads verifier is given what a room verifier is given (live-rooms §9.1: a descriptor, grants,
|
||||
a key-log head or none, and the chains), and:
|
||||
|
||||
- **policy**: the room's ad policy;
|
||||
- **pt0**: the presentation time of `t0`, in milliseconds on the Period's timeline (§6.2);
|
||||
- **receipts**: form-2 receipts in JSON (§8.2), in a list, each with the **pod** its decision
|
||||
ran over, the candidates in the order the decider gave them.
|
||||
|
||||
### 9.2 Steps
|
||||
|
||||
It runs these steps and stops at the first refusal, which it reports with the reason and
|
||||
where:
|
||||
|
||||
1. **The room** (live-rooms §9): a refusal is the ads verifier's, at `room` with the room
|
||||
verifier's own place. A gap is the outcome **gap**: a schedule read from part of the cues
|
||||
is not the room's, so the verifier states the gaps and nothing more.
|
||||
2. **The policy**, at `policy`: it reads (§3.3: `truncated`, `malformed`, `unknown_version`,
|
||||
`ad_policy_malformed`); its `room` is the room id (`wrong_room`); its signature verifies
|
||||
under the descriptor's `authority` (`bad_key`, `bad_signature`); `decider_grant` is the
|
||||
digest of a grant the room verifier holds (`decider_grant_unknown`); and that grant is not
|
||||
revoked through the key log the room verifier read (live-rooms §5, `grant_revoked`).
|
||||
3. **The schedule** (§4.2, §4.3), over the events the room accepted in the merged order, each
|
||||
once: the refusal, at the record's chain and its index in that chain.
|
||||
4. **The renderings** (§5, §6): the tags, the events and the asset-list URIs of every break.
|
||||
No refusal.
|
||||
5. **The receipts**, in the list's order, each by §9.3: the refusal, at `receipt` and its
|
||||
index.
|
||||
6. **The outcome** (§9.4).
|
||||
|
||||
### 9.3 Each receipt
|
||||
|
||||
For receipt *k*, with its pod, in this order:
|
||||
|
||||
1. It reads as §8.2 has it: `receipt_malformed`.
|
||||
2. Its `room` is the room id: `wrong_room`.
|
||||
3. Its own rules (§8.5): `bad_key`, `bad_signature` or `receipt_invalid`.
|
||||
4. `signer_pubkey` is the decider (§3.4): `receipt_not_decider`.
|
||||
5. `grant` is the decider grant's reference: `grant_hash` its digest, `capability`
|
||||
`wai.live.room`, `joule_ceiling_micro` its `joules_ceiling`, and `funds_ceiling` its
|
||||
`funds_ceiling`, or `null` when that is 2^64 − 1 (live-rooms §4.5):
|
||||
`receipt_grant_mismatch`.
|
||||
6. `cue` names a break of the schedule: `receipt_break_unknown`.
|
||||
7. The decider grant is good at the break's `t` (§3.4): `outside_grant_window`.
|
||||
8. `pod_hash` is the pod hash (§8.3) of the pod given: `pod_mismatch`.
|
||||
9. `avail_ms` is the break's avail, `slots` the policy's, and the impressions, less
|
||||
`quartiles_reached`, are the fill §7.1 gives for the pod, the slots, the avail less
|
||||
`start_offset_ms` and `funds_remaining_micros`: `decision_mismatch`.
|
||||
10. `asset_list_sha256` is SHA-256 of the asset list of the fill in the receipt's `format`
|
||||
(§7.2, §7.3): `asset_list_mismatch`.
|
||||
11. No receipt before it in the list names this `cue` and this `session`: `receipt_duplicate`.
|
||||
A viewer is filled once for a break.
|
||||
12. **Spend-equivalence.** `funds_remaining_micros` is within the decider grant's ceiling, and
|
||||
so is the billable spend of this receipt and every receipt before it, together (§3.4):
|
||||
otherwise `funds_exceeded`.
|
||||
|
||||
Step 12 holds the room's ad spend to one ceiling across every viewer and break, in the order the
|
||||
receipts are given. A decision's stated funds are the decider's statement; that the spend of
|
||||
every receipt together stays within the ceiling is checked, whatever was stated.
|
||||
|
||||
### 9.4 Outcomes and what a verification states
|
||||
|
||||
- **refuse**: the reason and where (§9.2).
|
||||
- **gap**: the room has a gap: its gaps, under live-events' code `delivery_gap`.
|
||||
- **accept**: no refusal and no gap.
|
||||
|
||||
With accept, the verifier states:
|
||||
|
||||
- each break, in schedule order: its digest, ID and event id, its author, `cue_id`, `t` and
|
||||
type (out or break), its placement, its planned duration and avail, and, once closed by an
|
||||
in, the in's digest, its end and its duration; or that it is open;
|
||||
- each chapter: its digest, `t` and `duration_ms`;
|
||||
- the HLS tags, in the order of §5.4; each break's HLS and DASH asset-list URIs; the DASH event
|
||||
streams (§6.3), and each out's event as it was written before its in;
|
||||
- each receipt: its identity (§8.4), the asset list it binds, written by the verifier, and its
|
||||
billable spend; and the spend of every receipt together.
|
||||
|
||||
## 10. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `ad_policy_malformed` | §3.3, §9.2 step 2 |
|
||||
| `decider_grant_unknown` | §9.2 step 2 |
|
||||
| `annotation_malformed` | §4.2 |
|
||||
| `annotation_misplaced` | §4.2 |
|
||||
| `cue_after_close` | §4.2, §4.3 |
|
||||
| `break_overlap` | §4.3 |
|
||||
| `break_duration_unknown` | §4.3 |
|
||||
| `break_zero_duration` | §4.3 |
|
||||
| `cue_in_unmatched` | §4.3 |
|
||||
| `cue_duration_mismatch` | §4.3 |
|
||||
| `receipt_malformed` | §8.2, §9.3 step 1 |
|
||||
| `receipt_invalid` | §8.5, §9.3 step 3 |
|
||||
| `receipt_not_decider` | §9.3 step 4 |
|
||||
| `receipt_grant_mismatch` | §9.3 step 5 |
|
||||
| `receipt_break_unknown` | §9.3 step 6 |
|
||||
| `pod_mismatch` | §9.3 step 8 |
|
||||
| `decision_mismatch` | §9.3 step 9 |
|
||||
| `asset_list_mismatch` | §9.3 step 10 |
|
||||
| `receipt_duplicate` | §9.3 step 11 |
|
||||
| `funds_exceeded` | §9.3 step 12 |
|
||||
|
||||
An ads verifier also raises every code a room verifier raises (live-rooms §11 and the
|
||||
live-events codes it lists), at the room: `kind_not_permitted` among them for a cue or an
|
||||
annotation by a principal whose grant does not permit its kind; `truncated`, `malformed` and
|
||||
`unknown_version` for a policy that does not read; `wrong_room`, `bad_key` and `bad_signature`
|
||||
for a policy or a receipt of another room or whose signature fails; `grant_revoked` and
|
||||
`outside_grant_window` for the decider grant (§9.2 step 2, §9.3 step 7); and `delivery_gap` for
|
||||
a gap, which is not a refusal.
|
||||
|
||||
## 11. Conformance
|
||||
|
||||
[`live-conformance/ads/`](../live-conformance/ads/) holds the corpus, written from fixed seeds
|
||||
by `wai_live_ads_vectors gen`: `vectors.json`, and each accepted room's HLS tags as a file of
|
||||
its own, `NAME.m3u8`, one tag a line in the order of §5.4. It has:
|
||||
|
||||
- **Rooms**, each a room (descriptor, grants, key-log head or none, chains), its policy, `pt0`
|
||||
and its receipts with their pods, and the outcome §9.4 gives them with everything it states:
|
||||
- a live break, an out with no planned duration and its in, a viewer present at its start
|
||||
filled from an HLS asset list, and a viewer who joins it running, filled from a DASH list
|
||||
MPD with what remains of it;
|
||||
- a mid-roll of known duration under a policy that restricts skipping and jumping, snaps
|
||||
both ways and offers a skip control, with a viewer whose pod nothing fits;
|
||||
- a program with chapters, a mid-roll, and a post-roll at its close;
|
||||
- chapters, and an out with a planned duration closed by its in;
|
||||
- one refused room for each reason of §10, each a control of one defect; one for each
|
||||
other rule of §8.5, each refused `receipt_invalid`; a cue by a principal whose grant does
|
||||
not permit `cue`, which the room refuses `kind_not_permitted`; and a decider grant revoked
|
||||
through the key log, and one whose window ends before the break;
|
||||
- a room with a gap.
|
||||
|
||||
A conforming verifier gives every room the outcome, the reason, the place and everything else
|
||||
the corpus states, and every tag file byte for byte. `wai_live_ads_vectors verify` and the test
|
||||
`tests/live_ads.rs` check that every file regenerates byte for byte, that `wai-rs` gives every
|
||||
case its outcome, that every reason of §10 appears, and that §10 lists exactly the reference's
|
||||
codes. `live-conformance/live_ads_verify.py` is the second implementation, written from this
|
||||
text: it shares no code with `wai-rs`; it verifies each room with the live-rooms corpus's
|
||||
second implementation (`live_rooms_verify.py`), every signature with WAI's strict rule
|
||||
(`claim-conformance/_ed25519.py`) and every BLAKE3 hash with `claim-conformance/_blake3.py`; it
|
||||
writes every tag, event and asset list from §5 to §7 and requires their bytes; and it mutates
|
||||
copies of the corpus and requires every copy to fail.
|
||||
|
||||
## 12. Security and privacy considerations
|
||||
|
||||
- Every break is a cue its author signed, under a grant that permits `cue`; a relay or an edge
|
||||
can withhold a cue, which is a gap in its author's chain, but cannot add or move a break. The
|
||||
date ranges and events an edge writes are recomputable from the signed records, and a
|
||||
playlist whose interstitials differ from them is one the edge altered.
|
||||
- The decision is a pure function of its inputs, and every input is bound: the pod by its
|
||||
hash, the avail by the break, the slots by the policy, the funds by the receipt, and the
|
||||
asset list served by its digest, whose URIs name each creative by its hash. A decider that
|
||||
serves an ad the decision did not select, or a creative other than the one it bills, signs a
|
||||
receipt that fails §9.3.
|
||||
- The funds a decision states are the decider's statement. What is checked is the bound: no
|
||||
receipt spends past the ceiling, and no receipts together do, so an overspent room fails
|
||||
verification at the receipt that crosses the ceiling.
|
||||
- `quartiles_reached` is the decider's statement of the beacons it received; a receipt binds it
|
||||
and its billing, not the beacons. The impressions a buyer and a seller reconcile are the same
|
||||
signed bytes.
|
||||
- A receipt names a session by 16 bytes the decider assigns. A decider **SHOULD** assign a
|
||||
value with no meaning outside its own records, and **MUST NOT** derive it from a viewer's key
|
||||
or another identifier a third party holds, so that receipts do not link a viewer across
|
||||
rooms; within a room, one session's receipts are linkable to one another by design, so that a
|
||||
viewer is filled once for a break (§9.3 step 11).
|
||||
- The asset-list request carries the viewer's playback-session parameter (Appendix D.4) to the
|
||||
decider, and nowhere else: the manifests every viewer shares carry nothing of any viewer.
|
||||
- The policy is signed by the room authority and the receipts by the decider. A decider whose
|
||||
grant is revoked or out of its window has its receipts refused (§9.2, §9.3).
|
||||
|
|
@ -34,8 +34,9 @@ Out of scope: a transport; the room descriptor, which fixes a room's id and its
|
|||
the room grant, which carries a principal's policy (both belong to a later extension);
|
||||
the contents of a `take_commit` (§4.12), which [capture binding](capture-binding.md)
|
||||
§5 defines; moderation
|
||||
workflow, which [live-moderation](live-moderation.md) defines; and how a sink renders any
|
||||
event.
|
||||
workflow, which [live-moderation](live-moderation.md) defines; the ad breaks a room's cues
|
||||
open, and their interstitials and asset lists, which [live-ads](live-ads.md) defines; and how a
|
||||
sink renders any event.
|
||||
|
||||
Relationship to the core spec: `wai.live.events` is a **record** ([SPEC](../SPEC.md) §5):
|
||||
a signed record a WAI tool emits and verifies. It names no payload format and is not
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ and the independent verifier of each, beside it.
|
|||
| [`metering/`](metering/) | [live-metering](../extensions/live-metering.md) (`wai.live.metering`) | `live_metering_verify.py` |
|
||||
| [`steering/`](steering/) | [energy-steering](../extensions/energy-steering.md) (`wai.live.steering`) | `energy_steering_verify.py` |
|
||||
| [`moderation/`](moderation/) | [live-moderation](../extensions/live-moderation.md) (`wai.live.moderation`) | `live_moderation_verify.py` |
|
||||
| [`ads/`](ads/) | [live-ads](../extensions/live-ads.md) (`wai.live.ads`) | `live_ads_verify.py` |
|
||||
| [`e2ee/`](e2ee/) | [confidentiality](../extensions/confidentiality.md) (`wai.live.e2ee`) | `e2ee_verify.py` |
|
||||
| [`watermark/`](watermark/) | [forensic-watermark](../extensions/forensic-watermark.md) (`wai.live.watermark`) | `live_watermark_verify.py` |
|
||||
|
||||
|
|
@ -22,11 +23,12 @@ and the independent verifier of each, beside it.
|
|||
`takes/`, `wai_live_compose_vectors gen` writes `compose/`, `wai_live_record_vectors gen`
|
||||
writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and
|
||||
`wai_live_metering_vectors gen` writes `metering/`, `wai_energy_steering_vectors gen` writes
|
||||
`steering/`, `wai_live_moderation_vectors gen` writes `moderation/` and
|
||||
`wai_live_watermark_vectors gen` writes `watermark/`, each from records authored in code, from
|
||||
fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`, `compose/README.md`,
|
||||
`recordings/README.md`, `captions/README.md`, `metering/README.md`, `steering/README.md`,
|
||||
`moderation/README.md`, `watermark/README.md`). `joule_moq_e2ee_vectors gen` (in
|
||||
`steering/`, `wai_live_moderation_vectors gen` writes `moderation/`,
|
||||
`wai_live_watermark_vectors gen` writes `watermark/` and `wai_live_ads_vectors gen` writes
|
||||
`ads/`, each from records authored in code, from fixed seeds (`events/README.md`,
|
||||
`rooms/README.md`, `takes/README.md`, `compose/README.md`, `recordings/README.md`,
|
||||
`captions/README.md`, `metering/README.md`, `steering/README.md`, `moderation/README.md`,
|
||||
`watermark/README.md`, `ads/README.md`). `joule_moq_e2ee_vectors gen` (in
|
||||
`joule-code/joule-code-rs/crates/joule-moq-mls`) writes `e2ee/vectors.json` from fixed seeds,
|
||||
and `e2ee/` also holds the test vectors RFC 9605 and `draft-ietf-moq-secure-objects-01`
|
||||
publish, as printed (`e2ee/README.md`). One recorded record: `metering/relay-record.wmh` and
|
||||
|
|
@ -46,6 +48,7 @@ python3 timed_text_verify.py captions
|
|||
python3 live_metering_verify.py metering
|
||||
python3 energy_steering_verify.py steering
|
||||
python3 live_moderation_verify.py moderation
|
||||
python3 live_ads_verify.py ads
|
||||
python3 e2ee_verify.py e2ee
|
||||
python3 live_watermark_verify.py watermark
|
||||
```
|
||||
|
|
|
|||
106
wai/live-conformance/ads/README.md
Normal file
106
wai/live-conformance/ads/README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# live-conformance/ads — live ads
|
||||
|
||||
The corpus of [`extensions/live-ads.md`](../../extensions/live-ads.md) §11: rooms whose `cue`
|
||||
events open ad breaks, each a room (its `WRD1` descriptor, `WRG1` grants, key-log head or none,
|
||||
and each principal's `WEV1` chain, the break annotations `WAB1` among them), its `WAP1` ad
|
||||
policy, the presentation time of `t0` on the DASH Period (`pt0_ms`), and the break-form
|
||||
`wai.video.ssai` receipts of its viewers, each with the pod its decision ran over; what a
|
||||
verifier states of them; and each accepted room's HLS tags, byte for byte.
|
||||
|
||||
**Ads equivalence.** For each vector, a conforming ads verifier given its `descriptor`,
|
||||
`grants`, `key_log`, `chains`, `policy`, `pt0_ms`, `receipts` and `pods` (§9.1) reaches the
|
||||
outcome the vector states (§9.4):
|
||||
|
||||
- `refuse`: the `reason` and where, `at`: `["room", …]` with the room verifier's own place
|
||||
(live-rooms §9.1), `["policy"]`, `["record", c, i]` for record `i` of chain `c`, or
|
||||
`["receipt", k]`;
|
||||
- `gap`: the room's `gaps`, each `principal`, `first` and `last`;
|
||||
- `accept`: the `room` id; the `breaks` (§4.3), each its `digest`, `id` and `event_id` (§4.4),
|
||||
`author`, `cue_id`, `t`, `type` (`out` or `break`), `placement` (`mid` or `post`),
|
||||
`planned_ms`, `avail_ms`, `duration_ms`, `closed_by` and `end` (`null` while it is open),
|
||||
and its `hls_list_uri` and `dash_list_uri` (§7.2, §7.3); the `chapters`, each `digest`, `t`
|
||||
and `duration_ms`; `hls`, the file holding the room's HLS tags; `dash`, the Period's event
|
||||
streams (§6.3); `dash_open`, each out's event as written before its in, by the out's index
|
||||
among the `breaks`; `receipt_facts`, each receipt's `identity` (§8.4), the `asset_list` it
|
||||
binds as the verifier writes it, and its `spend_micros`; and `spend_micros`, the spend of
|
||||
every receipt together.
|
||||
|
||||
Each tag file, `NAME.m3u8`, is the HLS tags of vector `NAME` (§5.1 to §5.3), one tag a line in
|
||||
the order §5.4 gives, each line ended by a line feed. It is the date ranges of the room's Media
|
||||
Playlists, not a whole playlist: the segments are the room's tracks.
|
||||
|
||||
A verifier that reaches another outcome, reason, place, break, tag byte, event byte, asset-list
|
||||
byte, identity or spend does not conform.
|
||||
|
||||
**Provenance.** Generated here: `wai_live_ads_vectors gen` writes every file from rooms
|
||||
authored in code, from fixed seeds. Each principal's key is SHA-256 of a fixed label and the
|
||||
principal's index; each creative's hash and each session id likewise; every time is a
|
||||
constant (the rooms' `t0` is 2026-10-16T00:00:00Z); every origin and base URI is an
|
||||
`.example` name (RFC 2606); and Ed25519 signing is deterministic (RFC 8032), so every file is
|
||||
the same bytes on every machine. The generator asserts that each vector reaches the outcome it
|
||||
was written to have, and that the corpus gives every reason of §10, before it writes. No
|
||||
recorded data.
|
||||
|
||||
## Layout
|
||||
|
||||
- `vectors.json`: `principals` (the keys, by name, informative) and `vectors`.
|
||||
- `NAME.m3u8`: the HLS tags of each accepted room.
|
||||
|
||||
Every room has a producer, whose grant permits `cue`, `meta` and `chat`; a viewer, whose grant
|
||||
permits `chat`; and the decider, whose grant permits no event and carries the room's ad budget,
|
||||
2 minor units of EUR (2 000 000 millionths), and which never joins. The clock admits the
|
||||
producer and the viewer at the room's start.
|
||||
|
||||
The rooms:
|
||||
|
||||
- `live_break`: an out at 60 s with no planned duration and its in at 90 s, under a policy
|
||||
whose open avail is 30 s and whose preload is 4 s. One viewer, present at the break's start,
|
||||
is filled with two 15 s ads from an HLS asset list (its decision energy model-based); a
|
||||
second joins 12 s into the break and is filled with what remains, one ad, from a DASH list
|
||||
MPD (its decision energy an on-chip counter's reading). No pre-roll.
|
||||
- `mid_roll`: a 30 s break, annotated mid-roll, under a policy that restricts skipping and
|
||||
jumping, snaps both ways and offers a skip control after 5 s, with `pt0_ms` 5000. One
|
||||
viewer's pod has two ads at one price, ordered by `ad_id`, and three fill the avail; another
|
||||
viewer's only ad costs more than its funds, so its fill is empty and its DASH list MPD is no
|
||||
bytes.
|
||||
- `post_roll`: two chapters, a 15 s mid-roll at 30 s, and a 20 s break annotated post-roll at
|
||||
120 s; one viewer filled in both breaks, another in the post-roll only.
|
||||
- `chapters`: a chapter of a stated length and one of none, then an out with a planned
|
||||
duration of 15 s and its in, which states no duration.
|
||||
- `gap`: `live_break`'s room as a subscriber holds it when the out was not delivered.
|
||||
- refused, one defect each: `cue_kind_not_permitted` (refused by the room,
|
||||
`kind_not_permitted`), `policy_slots_zero`, `policy_wrong_room`, `policy_bad_signature`,
|
||||
`decider_grant_unknown`, `decider_grant_revoked` (a room whose key log revokes the decider
|
||||
grant, `grant_revoked`), `annotation_placement`, `annotation_on_out`, `cue_after_post_roll`,
|
||||
`overlapping_breaks`, `open_break_no_avail`, `break_of_no_duration`, `in_without_out`,
|
||||
`in_duration_mismatch`, `receipt_extra_member`, `receipt_wrong_room`,
|
||||
`receipt_bad_signature`, `receipt_quartile_six` and, for the other rules of §8.5 each
|
||||
broken alone, `receipt_format_three`, `receipt_work_below_fill`,
|
||||
`receipt_figure_without_class`, `receipt_below_resolution` and `receipt_over_its_ceiling`
|
||||
(all `receipt_invalid`), `receipt_not_decider`,
|
||||
`receipt_grant_mismatch`, `receipt_names_the_in`, `decider_grant_expired`
|
||||
(`outside_grant_window`), `pod_mismatch`, `decision_mismatch`, `asset_list_mismatch`,
|
||||
`receipt_duplicate` and `funds_exceeded` (a budget of 1 minor unit, and two viewers each
|
||||
billed 600 000 millionths).
|
||||
|
||||
## Running
|
||||
|
||||
```
|
||||
cd ../../wai-rs
|
||||
cargo run --no-default-features --features live_ads --bin wai_live_ads_vectors -- verify ../live-conformance/ads
|
||||
cargo run --no-default-features --features live_ads --bin wai_live_ads_vectors -- gen ../live-conformance/ads # regenerate
|
||||
cargo test --no-default-features --features live_ads --test live_ads
|
||||
cd ../live-conformance
|
||||
python3 -I live_ads_verify.py ads
|
||||
```
|
||||
|
||||
`wai_live_ads_vectors verify` and `tests/live_ads.rs` require every file to regenerate byte for
|
||||
byte, no other file but this one to be there, and `wai-rs` to give every case its outcome; the
|
||||
test also requires §10 to list exactly the reasons the reference reports, and the generator
|
||||
that the corpus gives each of them. `live_ads_verify.py` is the second implementation, written
|
||||
from the extension's text and sharing no code with `wai-rs`: it verifies each room with
|
||||
`live_rooms_verify.py`, writes every tag, event and asset list itself, verifies every receipt's
|
||||
signature with WAI's strict Ed25519 rule and computes its BLAKE3 hashes from the specification,
|
||||
reads §10 from the extension, gives every case the same outcome, requires every reason of §10
|
||||
to appear, and then mutates copies of the corpus, one defect each, each of which must fail.
|
||||
Pass = an exit status of 0 from each.
|
||||
2
wai/live-conformance/ads/chapters.m3u8
Normal file
2
wai/live-conformance/ads/chapters.m3u8
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#EXT-X-DATERANGE:ID="wai-ad-02a6994e4d0a2bd745d9d31786abb0f4571978aa9360ed75707b375cbdcbcae2",CLASS="com.apple.hls.interstitial",START-DATE="2026-10-16T00:01:10.000Z",CUE="ONCE",PLANNED-DURATION=15.000,X-ASSET-LIST="https://ads.rooms.example/sgai/wai-ad-02a6994e4d0a2bd745d9d31786abb0f4571978aa9360ed75707b375cbdcbcae2.json?_HLS_interstitial_id=wai-ad-02a6994e4d0a2bd745d9d31786abb0f4571978aa9360ed75707b375cbdcbcae2"
|
||||
#EXT-X-DATERANGE:ID="wai-ad-02a6994e4d0a2bd745d9d31786abb0f4571978aa9360ed75707b375cbdcbcae2",CLASS="com.apple.hls.interstitial",DURATION=15.000,X-RESUME-OFFSET=15.000,X-PLAYOUT-LIMIT=15.000
|
||||
3
wai/live-conformance/ads/live_break.m3u8
Normal file
3
wai/live-conformance/ads/live_break.m3u8
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#EXT-X-DATERANGE:ID="wai-ad-d0f201740eb1313f519b4d9feb3be4291f37663b62ab970b719bf2e012641bc0-preload",CLASS="com.apple.hls.preload",START-DATE="2026-10-16T00:00:56.000Z",DURATION=4.000,X-URI="https://ads.rooms.example/sgai/wai-ad-d0f201740eb1313f519b4d9feb3be4291f37663b62ab970b719bf2e012641bc0.json?_HLS_interstitial_id=wai-ad-d0f201740eb1313f519b4d9feb3be4291f37663b62ab970b719bf2e012641bc0",X-TARGET-ID="wai-ad-d0f201740eb1313f519b4d9feb3be4291f37663b62ab970b719bf2e012641bc0",X-TARGET-CLASS="com.apple.hls.interstitial"
|
||||
#EXT-X-DATERANGE:ID="wai-ad-d0f201740eb1313f519b4d9feb3be4291f37663b62ab970b719bf2e012641bc0",CLASS="com.apple.hls.interstitial",START-DATE="2026-10-16T00:01:00.000Z",CUE="ONCE",X-ASSET-LIST="https://ads.rooms.example/sgai/wai-ad-d0f201740eb1313f519b4d9feb3be4291f37663b62ab970b719bf2e012641bc0.json?_HLS_interstitial_id=wai-ad-d0f201740eb1313f519b4d9feb3be4291f37663b62ab970b719bf2e012641bc0"
|
||||
#EXT-X-DATERANGE:ID="wai-ad-d0f201740eb1313f519b4d9feb3be4291f37663b62ab970b719bf2e012641bc0",CLASS="com.apple.hls.interstitial",DURATION=30.000,X-RESUME-OFFSET=30.000,X-PLAYOUT-LIMIT=30.000
|
||||
1
wai/live-conformance/ads/mid_roll.m3u8
Normal file
1
wai/live-conformance/ads/mid_roll.m3u8
Normal file
|
|
@ -0,0 +1 @@
|
|||
#EXT-X-DATERANGE:ID="wai-ad-b4d071f6cf7db304a33e1ceeb94785cf65b30e2413c021a57c24070686e89895",CLASS="com.apple.hls.interstitial",START-DATE="2026-10-16T00:02:00.000Z",CUE="ONCE",DURATION=30.000,X-ASSET-LIST="https://ads.rooms.example/sgai/wai-ad-b4d071f6cf7db304a33e1ceeb94785cf65b30e2413c021a57c24070686e89895.json?_HLS_interstitial_id=wai-ad-b4d071f6cf7db304a33e1ceeb94785cf65b30e2413c021a57c24070686e89895",X-RESUME-OFFSET=30.000,X-PLAYOUT-LIMIT=30.000,X-SNAP="OUT,IN",X-RESTRICT="SKIP,JUMP",X-SKIP-CONTROL-OFFSET=5
|
||||
4
wai/live-conformance/ads/post_roll.m3u8
Normal file
4
wai/live-conformance/ads/post_roll.m3u8
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#EXT-X-DATERANGE:ID="wai-ad-7b3aa9f4a5567d4b5874a57115d6b3b7656f857fbdf73f4c8cb86b1c647ed836-preload",CLASS="com.apple.hls.preload",START-DATE="2026-10-16T00:00:28.000Z",DURATION=2.000,X-URI="https://ads.rooms.example/sgai/wai-ad-7b3aa9f4a5567d4b5874a57115d6b3b7656f857fbdf73f4c8cb86b1c647ed836.json?_HLS_interstitial_id=wai-ad-7b3aa9f4a5567d4b5874a57115d6b3b7656f857fbdf73f4c8cb86b1c647ed836",X-TARGET-ID="wai-ad-7b3aa9f4a5567d4b5874a57115d6b3b7656f857fbdf73f4c8cb86b1c647ed836",X-TARGET-CLASS="com.apple.hls.interstitial"
|
||||
#EXT-X-DATERANGE:ID="wai-ad-7b3aa9f4a5567d4b5874a57115d6b3b7656f857fbdf73f4c8cb86b1c647ed836",CLASS="com.apple.hls.interstitial",START-DATE="2026-10-16T00:00:30.000Z",CUE="ONCE",DURATION=15.000,X-ASSET-LIST="https://ads.rooms.example/sgai/wai-ad-7b3aa9f4a5567d4b5874a57115d6b3b7656f857fbdf73f4c8cb86b1c647ed836.json?_HLS_interstitial_id=wai-ad-7b3aa9f4a5567d4b5874a57115d6b3b7656f857fbdf73f4c8cb86b1c647ed836",X-RESUME-OFFSET=15.000,X-PLAYOUT-LIMIT=15.000,X-RESTRICT="JUMP",X-SKIP-CONTROL-OFFSET=10
|
||||
#EXT-X-DATERANGE:ID="wai-ad-9900aa0486ae09264795cd65d94f8b4931b671d035856b9746b16f7f3390e61d-preload",CLASS="com.apple.hls.preload",START-DATE="2026-10-16T00:01:58.000Z",DURATION=2.000,X-URI="https://ads.rooms.example/sgai/wai-ad-9900aa0486ae09264795cd65d94f8b4931b671d035856b9746b16f7f3390e61d.json?_HLS_interstitial_id=wai-ad-9900aa0486ae09264795cd65d94f8b4931b671d035856b9746b16f7f3390e61d",X-TARGET-ID="wai-ad-9900aa0486ae09264795cd65d94f8b4931b671d035856b9746b16f7f3390e61d",X-TARGET-CLASS="com.apple.hls.interstitial"
|
||||
#EXT-X-DATERANGE:ID="wai-ad-9900aa0486ae09264795cd65d94f8b4931b671d035856b9746b16f7f3390e61d",CLASS="com.apple.hls.interstitial",START-DATE="2026-10-16T00:02:00.000Z",CUE="POST,ONCE",DURATION=20.000,X-ASSET-LIST="https://ads.rooms.example/sgai/wai-ad-9900aa0486ae09264795cd65d94f8b4931b671d035856b9746b16f7f3390e61d.json?_HLS_interstitial_id=wai-ad-9900aa0486ae09264795cd65d94f8b4931b671d035856b9746b16f7f3390e61d",X-PLAYOUT-LIMIT=20.000,X-RESTRICT="JUMP",X-SKIP-CONTROL-OFFSET=10
|
||||
3138
wai/live-conformance/ads/vectors.json
Normal file
3138
wai/live-conformance/ads/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
762
wai/live-conformance/live_ads_verify.py
Normal file
762
wai/live-conformance/live_ads_verify.py
Normal file
|
|
@ -0,0 +1,762 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Independent verifier of the live-ads corpus (extensions/live-ads.md).
|
||||
|
||||
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
|
||||
and never runs it. It checks Ed25519 signatures with ../claim-conformance/_ed25519.py, WAI's
|
||||
strict rule (SPEC §7 *Signatures*) written from RFC 8032 with the standard library, and
|
||||
computes BLAKE3 with ../claim-conformance/_blake3.py, written from its specification. Each room
|
||||
is verified by the live-rooms corpus's own second implementation, live_rooms_verify.py
|
||||
(live-rooms §9), written from its text alone. Everything else is here:
|
||||
|
||||
- the ad policy (§3): its layout, rules and signature, and the decider grant;
|
||||
- the break annotation and the schedule (§4), with its refusals;
|
||||
- the HLS interstitial, closing and preload tags (§5) and the DASH events (§6), byte for byte;
|
||||
- the decision and the asset lists (§7), byte for byte;
|
||||
- the break form of the wai.video.ssai receipt (§8): its JSON carriage, hashes, signing
|
||||
payload, identity and rules;
|
||||
- verification, from the room to the spend (§9).
|
||||
|
||||
For every room it requires the outcome, reason and place, and everything else the corpus
|
||||
states; every HLS tag file, byte for byte; of the corpus, that every reason of §10 appears in
|
||||
it and that no other file is there. Then it mutates copies of the corpus, one defect each, and
|
||||
requires every copy to fail.
|
||||
|
||||
python3 live_ads_verify.py <live-conformance/ads>
|
||||
|
||||
Exit status 0 only if every case verifies and every mutated copy fails. Python 3.8 or later;
|
||||
standard library only.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
|
||||
import live_events_verify as LEV # noqa: E402
|
||||
import live_rooms_verify as LRV # noqa: E402
|
||||
import _ed25519 as ED # noqa: E402
|
||||
import _blake3 as B3 # noqa: E402
|
||||
|
||||
Refuse = LEV.Refuse
|
||||
EXTENSION = os.path.join(HERE, "..", "extensions", "live-ads.md")
|
||||
|
||||
POLICY_MAGIC, ANNOTATION_MAGIC = b"WAP1", b"WAB1"
|
||||
POLICY_DOMAIN = b"wai:live-ads-policy\x01" # §3.2
|
||||
BREAK_DOMAIN = b"wai:video-ssai-break-receipt\x01" # §8.4
|
||||
ID_DOMAIN = b"wai:video-ssai-receipt-id\x01"
|
||||
POD_DOMAIN, IMP_DOMAIN = b"wai:video-ssai-pod\x01", b"wai:video-ssai-imp\x01" # §8.3
|
||||
LEAF, NODE = b"moq-jwp:merkle-leaf\x01", b"moq-jwp:merkle-node\x01" # jwp-receipts §2.2
|
||||
INTERSTITIAL = "com.apple.hls.interstitial" # allow-name: the interstitial date-range class, a protocol identifier
|
||||
PRELOAD = "com.apple.hls.preload" # allow-name: the preload date-range class, a protocol identifier
|
||||
REPLACE = "urn:mpeg:dash:event:alternativeMPD:replace:2025" # §6.1
|
||||
INSERT = "urn:mpeg:dash:event:alternativeMPD:insert:2025"
|
||||
OUT, IN, CHAPTER, BREAK = 1, 2, 3, 4
|
||||
META, CUE = 0x01, 0x02
|
||||
MID, POST = 1, 2
|
||||
HLS, DASH = 1, 2
|
||||
NO_SKIP = 65535
|
||||
NO_CEILING = 2 ** 64 - 1
|
||||
U64 = 2 ** 64 - 1
|
||||
FLOOR = 288676 # §8.5 rule 5
|
||||
# Unicode White_Space, the code points a calibration reference must hold more than (§8.5).
|
||||
WHITE_SPACE = set(map(chr, [0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0x85, 0xA0, 0x1680] + list(range(0x2000, 0x200B))
|
||||
+ [0x2028, 0x2029, 0x202F, 0x205F, 0x3000]))
|
||||
|
||||
|
||||
def sha(*parts):
|
||||
h = hashlib.sha256()
|
||||
for p in parts:
|
||||
h.update(p)
|
||||
return h.digest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the extension's own table
|
||||
|
||||
def reasons():
|
||||
"""§10's codes, from the text."""
|
||||
with open(EXTENSION, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
at = text.index("\n## 10. Reasons")
|
||||
end = text.index("\n## ", at + 1)
|
||||
return [line.split("`")[1] for line in text[at:end].splitlines() if line.startswith("| `")]
|
||||
|
||||
|
||||
REASONS = reasons()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §3 the ad policy
|
||||
|
||||
def is_base(b):
|
||||
"""§3.1."""
|
||||
if not b.startswith(b"https://"):
|
||||
return False
|
||||
rest = b[8:]
|
||||
return (1 <= len(rest) <= 492 and rest[-1:] == b"/"
|
||||
and all(0x21 <= c <= 0x7E and c not in b"\"#&'<>?\\" for c in rest))
|
||||
|
||||
|
||||
def read_policy(b):
|
||||
"""§3.3: the policy, or Refuse."""
|
||||
if len(b) < 4:
|
||||
raise Refuse("truncated")
|
||||
if b[:4] != POLICY_MAGIC:
|
||||
raise Refuse("malformed")
|
||||
if len(b) < 5:
|
||||
raise Refuse("truncated")
|
||||
if b[4] != 1:
|
||||
raise Refuse("unknown_version")
|
||||
r = LEV.Reader(b, "truncated")
|
||||
r.take(5)
|
||||
p = {"room": r.take(32), "decider_grant": r.take(32), "slots": r.u8(), "open_avail_ms": r.u32(),
|
||||
"preload_ms": r.u32(), "restrict": r.u8(), "skip_after_s": r.u16(), "snap": r.u8()}
|
||||
p["asset_base"] = r.take(r.u16())
|
||||
p["creative_base"] = r.take(r.u16())
|
||||
p["body"] = b[:r.p]
|
||||
p["sig"] = r.take(64)
|
||||
if not r.done():
|
||||
raise Refuse("malformed")
|
||||
if (not 1 <= p["slots"] <= 16 or p["restrict"] > 3 or p["snap"] > 3
|
||||
or not is_base(p["asset_base"]) or not is_base(p["creative_base"])):
|
||||
raise Refuse("ad_policy_malformed")
|
||||
p["asset_base"], p["creative_base"] = p["asset_base"].decode(), p["creative_base"].decode()
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §4 breaks
|
||||
|
||||
def read_annotation(b):
|
||||
"""§4.2."""
|
||||
if len(b) != 38 or b[:4] != ANNOTATION_MAGIC or b[4] != 1 or b[37] not in (MID, POST):
|
||||
raise Refuse("annotation_malformed")
|
||||
return {"cue": b[5:37], "placement": b[37]}
|
||||
|
||||
|
||||
def cue_of(ev):
|
||||
return LEV.read_payload(CUE, ev["payload"], 65535)
|
||||
|
||||
|
||||
def schedule(held, p):
|
||||
"""§4.3 over the held events in the merged order: (breaks, chapters, written) or Stop."""
|
||||
breaks, chapters, written = [], [], []
|
||||
open_, last_end, closed = None, None, False
|
||||
for k, (c, i, ev) in enumerate(held):
|
||||
where = ["record", c, i]
|
||||
if ev["kind"] == CUE:
|
||||
q = cue_of(ev)
|
||||
if closed:
|
||||
raise Stop("cue_after_close", where)
|
||||
t = ev["t"]
|
||||
overlaps = open_ is not None or (last_end is not None and t < last_end)
|
||||
if q["type"] == OUT:
|
||||
if overlaps:
|
||||
raise Stop("break_overlap", where)
|
||||
avail = q["duration_ms"] or p["open_avail_ms"]
|
||||
if avail == 0:
|
||||
raise Stop("break_duration_unknown", where)
|
||||
open_ = len(breaks)
|
||||
written.append(("open", len(breaks)))
|
||||
breaks.append({"digest": ev["digest"], "author": ev["principal"], "cue_id": q["cue_id"], "t": t,
|
||||
"type": OUT, "placement": MID, "planned_ms": q["duration_ms"], "avail_ms": avail,
|
||||
"duration_ms": None, "closed_by": None, "end": None})
|
||||
elif q["type"] == IN:
|
||||
b = breaks[open_] if open_ is not None else None
|
||||
if b is None or b["cue_id"] != q["cue_id"] or b["author"] != ev["principal"]:
|
||||
raise Stop("cue_in_unmatched", where)
|
||||
duration = (t - b["t"]) // 1000
|
||||
if duration == 0:
|
||||
raise Stop("break_zero_duration", where)
|
||||
if q["duration_ms"] != 0 and q["duration_ms"] != duration:
|
||||
raise Stop("cue_duration_mismatch", where)
|
||||
b.update(duration_ms=duration, closed_by=ev["digest"], end=t)
|
||||
written.append(("close", open_))
|
||||
open_, last_end = None, t
|
||||
elif q["type"] == CHAPTER:
|
||||
chapters.append({"digest": ev["digest"], "t": t, "duration_ms": q["duration_ms"]})
|
||||
else:
|
||||
if q["duration_ms"] == 0:
|
||||
raise Stop("break_zero_duration", where)
|
||||
if overlaps:
|
||||
raise Stop("break_overlap", where)
|
||||
last_end = t + q["duration_ms"] * 1000
|
||||
written.append(("open", len(breaks)))
|
||||
breaks.append({"digest": ev["digest"], "author": ev["principal"], "cue_id": q["cue_id"], "t": t,
|
||||
"type": BREAK, "placement": MID, "planned_ms": q["duration_ms"],
|
||||
"avail_ms": q["duration_ms"], "duration_ms": q["duration_ms"], "closed_by": None,
|
||||
"end": last_end})
|
||||
elif ev["kind"] == META and ev["payload"][:4] == ANNOTATION_MAGIC:
|
||||
try:
|
||||
a = read_annotation(ev["payload"])
|
||||
except Refuse as e:
|
||||
raise Stop(e.reason, where)
|
||||
before = next((x for _, _, x in reversed(held[:k])
|
||||
if x["principal"] == ev["principal"] and x["seq"] == ev["seq"] - 1), None)
|
||||
if (before is None or before["digest"] != a["cue"] or before["kind"] != CUE or before["t"] != ev["t"]
|
||||
or cue_of(before)["type"] != BREAK):
|
||||
raise Stop("annotation_misplaced", where)
|
||||
if a["placement"] == POST:
|
||||
for b in breaks:
|
||||
if b["digest"] == a["cue"]:
|
||||
b["placement"] = POST
|
||||
closed = True
|
||||
return breaks, chapters, written
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §4.4, §5, §6, §7
|
||||
|
||||
def break_id(d):
|
||||
return "wai-ad-" + d.hex()
|
||||
|
||||
|
||||
def event_id(d):
|
||||
return int.from_bytes(d[:8], "big") & ((1 << 53) - 1)
|
||||
|
||||
|
||||
def secs(ms):
|
||||
return "%d.%03d" % (ms // 1000, ms % 1000)
|
||||
|
||||
|
||||
def hls_uri(p, d):
|
||||
i = break_id(d)
|
||||
return "%s%s.json?_HLS_interstitial_id=%s" % (p["asset_base"], i, i)
|
||||
|
||||
|
||||
def dash_uri(p, d):
|
||||
return "%s%s.mpd" % (p["asset_base"], break_id(d))
|
||||
|
||||
|
||||
def date(t0, t, less=0):
|
||||
return LEV.iso((t0 + t) // 1000 - less)
|
||||
|
||||
|
||||
def bits(v, names):
|
||||
return ",".join(n for k, n in enumerate(names) if v & (1 << k))
|
||||
|
||||
|
||||
def interstitial(p, t0, b):
|
||||
"""§5.1."""
|
||||
post = b["placement"] == POST
|
||||
a = ['ID="%s"' % break_id(b["digest"]), 'CLASS="%s"' % INTERSTITIAL, 'START-DATE="%s"' % date(t0, b["t"]),
|
||||
'CUE="%s"' % ("POST,ONCE" if post else "ONCE")]
|
||||
known = b["planned_ms"] if b["type"] == BREAK else None
|
||||
if known is not None:
|
||||
a.append("DURATION=" + secs(known))
|
||||
elif b["planned_ms"]:
|
||||
a.append("PLANNED-DURATION=" + secs(b["planned_ms"]))
|
||||
a.append('X-ASSET-LIST="%s"' % hls_uri(p, b["digest"]))
|
||||
if known is not None:
|
||||
if not post:
|
||||
a.append("X-RESUME-OFFSET=" + secs(known))
|
||||
a.append("X-PLAYOUT-LIMIT=" + secs(known))
|
||||
if p["snap"]:
|
||||
a.append('X-SNAP="%s"' % bits(p["snap"], ["OUT", "IN"]))
|
||||
if p["restrict"]:
|
||||
a.append('X-RESTRICT="%s"' % bits(p["restrict"], ["SKIP", "JUMP"]))
|
||||
if p["skip_after_s"] != NO_SKIP:
|
||||
a.append("X-SKIP-CONTROL-OFFSET=%d" % p["skip_after_s"])
|
||||
return "#EXT-X-DATERANGE:" + ",".join(a)
|
||||
|
||||
|
||||
def closing(b):
|
||||
"""§5.2."""
|
||||
s = secs(b["duration_ms"])
|
||||
return '#EXT-X-DATERANGE:ID="%s",CLASS="%s",DURATION=%s,X-RESUME-OFFSET=%s,X-PLAYOUT-LIMIT=%s' % (
|
||||
break_id(b["digest"]), INTERSTITIAL, s, s, s)
|
||||
|
||||
|
||||
def preload(p, t0, b):
|
||||
"""§5.3."""
|
||||
i = break_id(b["digest"])
|
||||
return ('#EXT-X-DATERANGE:ID="%s-preload",CLASS="%s",START-DATE="%s",DURATION=%s,X-URI="%s",X-TARGET-ID="%s",'
|
||||
'X-TARGET-CLASS="%s"' % (i, PRELOAD, date(t0, b["t"], p["preload_ms"]), secs(p["preload_ms"]),
|
||||
hls_uri(p, b["digest"]), i, INTERSTITIAL))
|
||||
|
||||
|
||||
def hls_tags(p, t0, breaks, written):
|
||||
"""§5.4: in the order a packager writes them."""
|
||||
out = []
|
||||
for what, i in written:
|
||||
if what == "open":
|
||||
if p["preload_ms"]:
|
||||
out.append(preload(p, t0, breaks[i]))
|
||||
out.append(interstitial(p, t0, breaks[i]))
|
||||
else:
|
||||
out.append(closing(breaks[i]))
|
||||
return out
|
||||
|
||||
|
||||
def dash_event(p, pt0, b, closed):
|
||||
"""§6.2."""
|
||||
known = b["duration_ms"] if closed else None
|
||||
post = b["placement"] == POST
|
||||
e = '<Event presentationTime="%d"' % (pt0 + b["t"] // 1000)
|
||||
if known is not None:
|
||||
e += ' duration="%d"' % known
|
||||
e += ' id="%d">' % event_id(b["digest"])
|
||||
child = "InsertPresentation" if post else "ReplacePresentation"
|
||||
e += '<%s uri="%s"' % (child, dash_uri(p, b["digest"]))
|
||||
if p["preload_ms"]:
|
||||
e += ' earliestResolutionTimeOffset="%d"' % p["preload_ms"]
|
||||
if known is not None:
|
||||
e += ' maxDuration="%d"' % known
|
||||
e += ' executeOnce="true"'
|
||||
skip = p["skip_after_s"] if p["skip_after_s"] != NO_SKIP else -(-b["avail_ms"] // 1000)
|
||||
e += ' skipAfter="PT%dS"' % skip
|
||||
if known is not None and not post:
|
||||
e += ' returnOffset="%d"' % known
|
||||
return e + "/></Event>"
|
||||
|
||||
|
||||
def dash_streams(p, pt0, breaks):
|
||||
"""§6.3."""
|
||||
out = ""
|
||||
for scheme, post in ((REPLACE, False), (INSERT, True)):
|
||||
evs = [dash_event(p, pt0, b, True) for b in breaks if (b["placement"] == POST) == post]
|
||||
if evs:
|
||||
out += '<EventStream schemeIdUri="%s" timescale="1000">%s</EventStream>' % (scheme, "".join(evs))
|
||||
return out
|
||||
|
||||
|
||||
def decide(pod, slots, avail, funds):
|
||||
"""§7.1: the fill, each (slot, ad_id, creative_hash, duration_ms, price_micros)."""
|
||||
fill = []
|
||||
for c in sorted(pod, key=lambda c: (-c["price_micros"], c["ad_id"].encode())):
|
||||
if len(fill) >= slots:
|
||||
break
|
||||
if c["duration_ms"] <= avail and c["price_micros"] <= funds:
|
||||
fill.append((len(fill), c["ad_id"], c["creative_hash"], c["duration_ms"], c["price_micros"]))
|
||||
avail -= c["duration_ms"]
|
||||
funds -= c["price_micros"]
|
||||
return fill
|
||||
|
||||
|
||||
def asset_list(p, fmt, fill):
|
||||
"""§7.2, §7.3: the exact bytes."""
|
||||
if fmt == HLS:
|
||||
assets = ['{"URI":"%s%s.m3u8","DURATION":%s}' % (p["creative_base"], h.hex(), secs(d)) for _, _, h, d, _ in fill]
|
||||
return ('{"ASSETS":[%s]}' % ",".join(assets)).encode()
|
||||
if not fill:
|
||||
return b""
|
||||
periods = "".join('<Period id="%d" duration="PT%sS"><ImportedMPD>%s%s.mpd</ImportedMPD></Period>'
|
||||
% (s, secs(d), p["creative_base"], h.hex()) for s, _, h, d, _ in fill)
|
||||
return ('<?xml version="1.0" encoding="UTF-8"?>\n<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" '
|
||||
'profiles="urn:mpeg:dash:profile:list:2024" type="list" minBufferTime="PT1S">%s</MPD>' % periods).encode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §8 the break form
|
||||
|
||||
MEMBERS = ["form", "room", "cue", "session", "start_offset_ms", "format", "asset_list_sha256", "avail_ms", "slots",
|
||||
"funds_remaining_micros", "pod_hash", "impressions", "merkle_root", "budget_spent_micros",
|
||||
"work_decide_ops", "joules_micro", "energy_provenance", "grant", "signer_pubkey", "signer_id", "sig"]
|
||||
EVIDENCE = {"OnChipCounter": ["energy_uncertainty", "energy_counter_filtering"],
|
||||
"CalibratedInstrument": ["energy_uncertainty", "energy_calibration_ref"],
|
||||
"ModelBased": [], "Estimator": []}
|
||||
CLASS_TAG = {"ModelBased": 2, "Estimator": 3, "OnChipCounter": 4, "CalibratedInstrument": 5}
|
||||
FILTERING = {"Unknown": 0, "Off": 1, "On": 2}
|
||||
|
||||
|
||||
def u(v, top=U64):
|
||||
if not isinstance(v, int) or isinstance(v, bool) or not 0 <= v <= top:
|
||||
raise Refuse("receipt_malformed")
|
||||
return v
|
||||
|
||||
|
||||
def hexn(v, n):
|
||||
if not isinstance(v, str) or len(v) != 2 * n or any(c not in "0123456789abcdef" for c in v):
|
||||
raise Refuse("receipt_malformed")
|
||||
return bytes.fromhex(v)
|
||||
|
||||
|
||||
def text(v):
|
||||
if not isinstance(v, str):
|
||||
raise Refuse("receipt_malformed")
|
||||
return v
|
||||
|
||||
|
||||
def read_receipt(o):
|
||||
"""§8.2: the receipt, or Refuse receipt_malformed."""
|
||||
if not isinstance(o, dict):
|
||||
raise Refuse("receipt_malformed")
|
||||
label = o.get("energy_provenance")
|
||||
if label is not None and label not in EVIDENCE:
|
||||
raise Refuse("receipt_malformed")
|
||||
want = set(MEMBERS) | set(EVIDENCE.get(label, []))
|
||||
if set(o) != want or u(o["form"]) != 2:
|
||||
raise Refuse("receipt_malformed")
|
||||
r = {"room": hexn(o["room"], 32), "cue": hexn(o["cue"], 32), "session": hexn(o["session"], 16),
|
||||
"start_offset_ms": u(o["start_offset_ms"]), "format": u(o["format"], 255),
|
||||
"asset_list_sha256": hexn(o["asset_list_sha256"], 32), "avail_ms": u(o["avail_ms"]), "slots": u(o["slots"]),
|
||||
"funds_remaining_micros": u(o["funds_remaining_micros"]), "pod_hash": hexn(o["pod_hash"], 32),
|
||||
"merkle_root": hexn(o["merkle_root"], 32), "budget_spent_micros": u(o["budget_spent_micros"]),
|
||||
"work_decide_ops": u(o["work_decide_ops"]), "joules_micro": u(o["joules_micro"]),
|
||||
"signer_pubkey": hexn(o["signer_pubkey"], 32), "signer_id": text(o["signer_id"]), "sig": hexn(o["sig"], 64)}
|
||||
if not isinstance(o["impressions"], list):
|
||||
raise Refuse("receipt_malformed")
|
||||
r["impressions"] = []
|
||||
for i in o["impressions"]:
|
||||
if not isinstance(i, dict) or set(i) != {"slot", "ad_id", "creative_hash", "duration_ms", "price_micros",
|
||||
"quartiles_reached"}:
|
||||
raise Refuse("receipt_malformed")
|
||||
r["impressions"].append({"slot": u(i["slot"]), "ad_id": text(i["ad_id"]),
|
||||
"creative_hash": hexn(i["creative_hash"], 32), "duration_ms": u(i["duration_ms"]),
|
||||
"price_micros": u(i["price_micros"]), "quartiles_reached": u(i["quartiles_reached"], 255)})
|
||||
g = o["grant"]
|
||||
if not isinstance(g, dict) or set(g) != {"grant_hash", "capability", "joule_ceiling_micro", "funds_ceiling"}:
|
||||
raise Refuse("receipt_malformed")
|
||||
r["grant"] = {"grant_hash": hexn(g["grant_hash"], 32), "capability": text(g["capability"]),
|
||||
"joule_ceiling_micro": u(g["joule_ceiling_micro"]),
|
||||
"funds_ceiling": None if g["funds_ceiling"] is None else u(g["funds_ceiling"])}
|
||||
r["class"] = None
|
||||
if label is not None:
|
||||
c = {"label": label}
|
||||
if label in ("OnChipCounter", "CalibratedInstrument"):
|
||||
unc = o["energy_uncertainty"]
|
||||
if not isinstance(unc, dict) or set(unc) != {"relative_ppm", "window_us"}:
|
||||
raise Refuse("receipt_malformed")
|
||||
c["ppm"], c["window"] = u(unc["relative_ppm"], 2 ** 32 - 1), u(unc["window_us"])
|
||||
if label == "OnChipCounter":
|
||||
if o["energy_counter_filtering"] not in FILTERING:
|
||||
raise Refuse("receipt_malformed")
|
||||
c["filtering"] = FILTERING[o["energy_counter_filtering"]]
|
||||
if label == "CalibratedInstrument":
|
||||
c["ref"] = text(o["energy_calibration_ref"])
|
||||
r["class"] = c
|
||||
return r
|
||||
|
||||
|
||||
def b3(*parts):
|
||||
return B3.blake3(b"".join(parts))
|
||||
|
||||
|
||||
def be64(v):
|
||||
return struct.pack(">Q", v)
|
||||
|
||||
|
||||
def pod_hash(pod):
|
||||
"""§8.3."""
|
||||
out = [POD_DOMAIN, be64(len(pod))]
|
||||
for c in pod:
|
||||
a = c["ad_id"].encode()
|
||||
out += [struct.pack(">I", len(a)), a, c["creative_hash"], be64(c["duration_ms"]), be64(c["price_micros"])]
|
||||
return b3(*out)
|
||||
|
||||
|
||||
def leaf(i):
|
||||
a = i["ad_id"].encode()
|
||||
return b3(IMP_DOMAIN, be64(i["slot"]), struct.pack(">I", len(a)), a, i["creative_hash"], be64(i["duration_ms"]),
|
||||
be64(i["price_micros"]), bytes([i["quartiles_reached"]]))
|
||||
|
||||
|
||||
def merkle(hashes):
|
||||
"""jwp-receipts §2.2, each leaf of its first form."""
|
||||
if not hashes:
|
||||
return bytes(32)
|
||||
level = [b3(LEAF, h) for h in hashes]
|
||||
while len(level) > 1:
|
||||
if len(level) % 2:
|
||||
level.append(level[-1])
|
||||
level = [b3(NODE, level[k], level[k + 1]) for k in range(0, len(level), 2)]
|
||||
return level[0]
|
||||
|
||||
|
||||
def class_bytes(c):
|
||||
"""energy-measurement §2.7's signed encoding."""
|
||||
if c is None:
|
||||
return b"\x00"
|
||||
out = bytes([CLASS_TAG[c["label"]]])
|
||||
if c["label"] == "OnChipCounter":
|
||||
out += struct.pack(">IQ", c["ppm"], c["window"]) + bytes([c["filtering"]])
|
||||
elif c["label"] == "CalibratedInstrument":
|
||||
ref = c["ref"].encode()
|
||||
out += struct.pack(">IQ", c["ppm"], c["window"]) + struct.pack(">I", len(ref)) + ref
|
||||
return out
|
||||
|
||||
|
||||
def payload(r):
|
||||
"""§8.4."""
|
||||
g = r["grant"]
|
||||
cap, sid = g["capability"].encode(), r["signer_id"].encode()
|
||||
out = [BREAK_DOMAIN, r["room"], r["cue"], r["session"], be64(r["start_offset_ms"]), bytes([r["format"]]),
|
||||
r["asset_list_sha256"], be64(r["avail_ms"]), be64(r["slots"]), be64(r["funds_remaining_micros"]),
|
||||
r["pod_hash"], r["merkle_root"], be64(r["budget_spent_micros"]), be64(r["work_decide_ops"]),
|
||||
be64(r["joules_micro"]), class_bytes(r["class"]), g["grant_hash"], struct.pack(">I", len(cap)), cap,
|
||||
be64(g["joule_ceiling_micro"])]
|
||||
out.append(b"\x00" if g["funds_ceiling"] is None else b"\x01" + be64(g["funds_ceiling"]))
|
||||
out.append(be64(len(r["impressions"])))
|
||||
out += [leaf(i) for i in r["impressions"]]
|
||||
out += [r["signer_pubkey"], struct.pack(">I", len(sid)), sid]
|
||||
return b"".join(out)
|
||||
|
||||
|
||||
def own_rules(r):
|
||||
"""§8.5: None, or the refusal."""
|
||||
if r["format"] not in (HLS, DASH):
|
||||
return "receipt_invalid"
|
||||
bad = ED.verify(r["signer_pubkey"], payload(r), r["sig"])
|
||||
if bad:
|
||||
return bad
|
||||
imps = r["impressions"]
|
||||
spend = sum(i["price_micros"] for i in imps if i["quartiles_reached"] >= 1)
|
||||
if merkle([leaf(i) for i in imps]) != r["merkle_root"] or spend != r["budget_spent_micros"]:
|
||||
return "receipt_invalid"
|
||||
f = r["grant"]["funds_ceiling"]
|
||||
if f is not None and spend > f * 10 ** 6:
|
||||
return "receipt_invalid"
|
||||
if any(i["slot"] != k or i["quartiles_reached"] > 5 for k, i in enumerate(imps)) or r["work_decide_ops"] < len(imps):
|
||||
return "receipt_invalid"
|
||||
c, j = r["class"], r["joules_micro"]
|
||||
if c is None:
|
||||
return None if j == 0 else "receipt_invalid"
|
||||
if j == 0:
|
||||
return "receipt_invalid"
|
||||
if c["label"] in ("OnChipCounter", "CalibratedInstrument"):
|
||||
if c["ppm"] == 0 or c["window"] == 0 or j * c["ppm"] < FLOOR:
|
||||
return "receipt_invalid"
|
||||
if c["label"] == "CalibratedInstrument" and all(ch in WHITE_SPACE for ch in c["ref"]):
|
||||
return "receipt_invalid"
|
||||
return None
|
||||
|
||||
|
||||
def identity(r):
|
||||
return b3(ID_DOMAIN, payload(r), r["sig"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §9 verification
|
||||
|
||||
class Stop(Exception):
|
||||
def __init__(self, reason, at):
|
||||
super().__init__(reason)
|
||||
self.reason, self.at = reason, at
|
||||
|
||||
|
||||
def ads(v):
|
||||
"""§9: a dict of what the corpus states for vector v, and the run's HLS tags."""
|
||||
descriptor = bytes.fromhex(v["descriptor"])
|
||||
grants = [bytes.fromhex(g) for g in v["grants"]]
|
||||
chains = [[bytes.fromhex(h) for h in c] for c in v["chains"]]
|
||||
room = LRV.verify_room(descriptor, grants, v["key_log"], chains) # step 1
|
||||
if room["outcome"] == "refuse":
|
||||
return {"outcome": "refuse", "reason": room["reason"], "at": ["room"] + room["at"]}, None
|
||||
if room["outcome"] == "gap":
|
||||
return {"outcome": "gap", "gaps": room["gaps"]}, None
|
||||
try:
|
||||
return accept(v, descriptor, grants, chains, room)
|
||||
except Stop as e:
|
||||
return {"outcome": "refuse", "reason": e.reason, "at": e.at}, None
|
||||
|
||||
|
||||
def accept(v, descriptor, grants, chains, room):
|
||||
d = LRV.read_descriptor(descriptor)
|
||||
held_grants, _ = LRV.check_grants(d, grants)
|
||||
try: # step 2
|
||||
p = read_policy(bytes.fromhex(v["policy"]))
|
||||
if p["room"] != d["room"]:
|
||||
raise Refuse("wrong_room")
|
||||
bad = ED.verify(d["authority"], POLICY_DOMAIN + p["body"], p["sig"])
|
||||
if bad:
|
||||
raise Refuse(bad)
|
||||
decider = next((g for g in held_grants if g["digest"] == p["decider_grant"]), None)
|
||||
if decider is None:
|
||||
raise Refuse("decider_grant_unknown")
|
||||
if LRV.revoked(decider, LRV.check_key_log(d, v["key_log"])):
|
||||
raise Refuse("grant_revoked")
|
||||
except Refuse as e:
|
||||
raise Stop(e.reason, ["policy"])
|
||||
held, seen = [], set() # step 3
|
||||
for c, i, _ in room["order"]:
|
||||
ev = LEV.read_record(chains[c][i])
|
||||
if ev["digest"] not in seen:
|
||||
seen.add(ev["digest"])
|
||||
held.append((c, i, ev))
|
||||
breaks, chapters, written = schedule(held, p)
|
||||
t0, pt0 = d["t0"], v["pt0_ms"] # step 4
|
||||
tags = hls_tags(p, t0, breaks, written)
|
||||
funds = None if decider["funds"] == NO_CEILING else decider["funds"]
|
||||
stated, sessions, total = [], set(), 0 # step 5
|
||||
for k, (o, pod_json) in enumerate(zip(v["receipts"], v["pods"] + [None] * len(v["receipts"]))):
|
||||
at = ["receipt", k]
|
||||
try:
|
||||
r = read_receipt(o)
|
||||
except Refuse as e:
|
||||
raise Stop(e.reason, at)
|
||||
if r["room"] != d["room"]:
|
||||
raise Stop("wrong_room", at)
|
||||
bad = own_rules(r)
|
||||
if bad:
|
||||
raise Stop(bad, at)
|
||||
if r["signer_pubkey"] != decider["principal"]:
|
||||
raise Stop("receipt_not_decider", at)
|
||||
g = r["grant"]
|
||||
if (g["grant_hash"] != decider["digest"] or g["capability"] != "wai.live.room"
|
||||
or g["joule_ceiling_micro"] != decider["joules"] or g["funds_ceiling"] != funds):
|
||||
raise Stop("receipt_grant_mismatch", at)
|
||||
b = next((x for x in breaks if x["digest"] == r["cue"]), None)
|
||||
if b is None:
|
||||
raise Stop("receipt_break_unknown", at)
|
||||
if not LRV.good_at(decider, d, b["t"]):
|
||||
raise Stop("outside_grant_window", at)
|
||||
pod = [{"ad_id": c["ad_id"], "creative_hash": bytes.fromhex(c["creative_hash"]), "duration_ms": c["duration_ms"],
|
||||
"price_micros": c["price_micros"]} for c in (pod_json or [])]
|
||||
if r["pod_hash"] != pod_hash(pod):
|
||||
raise Stop("pod_mismatch", at)
|
||||
fill = decide(pod, p["slots"], max(b["avail_ms"] - r["start_offset_ms"], 0), r["funds_remaining_micros"])
|
||||
got = [(i["slot"], i["ad_id"], i["creative_hash"], i["duration_ms"], i["price_micros"]) for i in r["impressions"]]
|
||||
if r["avail_ms"] != b["avail_ms"] or r["slots"] != p["slots"] or got != fill:
|
||||
raise Stop("decision_mismatch", at)
|
||||
listed = asset_list(p, r["format"], fill)
|
||||
if r["asset_list_sha256"] != sha(listed):
|
||||
raise Stop("asset_list_mismatch", at)
|
||||
if (r["cue"], r["session"]) in sessions:
|
||||
raise Stop("receipt_duplicate", at)
|
||||
sessions.add((r["cue"], r["session"]))
|
||||
total += r["budget_spent_micros"]
|
||||
if funds is not None and (r["funds_remaining_micros"] > funds * 10 ** 6 or total > funds * 10 ** 6):
|
||||
raise Stop("funds_exceeded", at)
|
||||
stated.append({"identity": identity(r).hex(), "asset_list": listed.decode(),
|
||||
"spend_micros": r["budget_spent_micros"]})
|
||||
out = {
|
||||
"outcome": "accept",
|
||||
"room": d["room"].hex(),
|
||||
"breaks": [{"digest": b["digest"].hex(), "id": break_id(b["digest"]), "event_id": event_id(b["digest"]),
|
||||
"author": b["author"].hex(), "cue_id": b["cue_id"], "t": b["t"],
|
||||
"type": "out" if b["type"] == OUT else "break", "placement": "post" if b["placement"] == POST else "mid",
|
||||
"planned_ms": b["planned_ms"], "avail_ms": b["avail_ms"], "duration_ms": b["duration_ms"],
|
||||
"closed_by": b["closed_by"].hex() if b["closed_by"] else None, "end": b["end"],
|
||||
"hls_list_uri": hls_uri(p, b["digest"]), "dash_list_uri": dash_uri(p, b["digest"])} for b in breaks],
|
||||
"chapters": [{"digest": c["digest"].hex(), "t": c["t"], "duration_ms": c["duration_ms"]} for c in chapters],
|
||||
"hls": v["name"] + ".m3u8",
|
||||
"dash": dash_streams(p, pt0, breaks),
|
||||
"dash_open": [{"break": k, "event": dash_event(p, pt0, b, False)} for k, b in enumerate(breaks) if b["type"] == OUT],
|
||||
"receipt_facts": stated,
|
||||
"spend_micros": min(total, U64),
|
||||
}
|
||||
return out, "".join(t + "\n" for t in tags).encode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the corpus
|
||||
|
||||
def check(doc, files, quiet=False):
|
||||
"""Every room against its statement, and every tag file: the failures, and the files written."""
|
||||
fails, written = [], {}
|
||||
for v in doc.get("vectors", []):
|
||||
try:
|
||||
got, tags = ads(v)
|
||||
except Exception as e: # a crash on a mutated copy is a failure to read it
|
||||
got, tags = None, None
|
||||
bad = ["the vector does not verify: %s: %s" % (type(e).__name__, e)]
|
||||
else:
|
||||
bad = [k for k in got if v.get(k) != got[k]]
|
||||
bad += [k for k in ("outcome", "reason", "at", "gaps", "breaks", "hls", "dash", "receipt_facts")
|
||||
if k in v and k not in got]
|
||||
if tags is not None:
|
||||
name = v["name"] + ".m3u8"
|
||||
written[name] = tags
|
||||
if files.get(name) != tags:
|
||||
bad.append("its tag file %s" % name)
|
||||
if not quiet:
|
||||
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
|
||||
print(("PASS" if not bad else "FAIL") + " room %s: %s" % (v.get("name"), what))
|
||||
fails += ["room %s: %s differs from the corpus" % (v.get("name"), k) for k in bad]
|
||||
return fails, written
|
||||
|
||||
|
||||
def flip(h, at):
|
||||
b = bytearray(bytes.fromhex(h))
|
||||
b[at] ^= 0x01
|
||||
return b.hex()
|
||||
|
||||
|
||||
def mutations(doc, files):
|
||||
"""Copies of the corpus, each with one defect a conforming verifier must catch."""
|
||||
vn = {v["name"]: k for k, v in enumerate(doc["vectors"])}
|
||||
live, mid, post, chap, dup = vn["live_break"], vn["mid_roll"], vn["post_roll"], vn["chapters"], vn["receipt_duplicate"]
|
||||
|
||||
def mutate(label, f):
|
||||
d, fs = copy.deepcopy(doc), dict(files)
|
||||
f(d["vectors"], fs)
|
||||
return label, d, fs
|
||||
|
||||
def drop_last(vs, chain_of):
|
||||
"""The last record of the chain holding a producer's cues."""
|
||||
ch = vs[chain_of]["chains"]
|
||||
longest = max(range(len(ch)), key=lambda c: len(ch[c]))
|
||||
ch[longest].pop()
|
||||
|
||||
def swap_pod(vs, fs):
|
||||
pod = vs[mid]["pods"][0]
|
||||
pod[1], pod[3] = pod[3], pod[1]
|
||||
|
||||
def edit_file(name, a, b):
|
||||
return lambda vs, fs: fs.update({name: fs[name].replace(a, b, 1)})
|
||||
|
||||
return [
|
||||
mutate("a byte of the live break's policy signature", lambda vs, fs: vs[live].update(policy=flip(vs[live]["policy"], -2))),
|
||||
mutate("the live break's in withheld from the end of its chain", lambda vs, fs: drop_last(vs, live)),
|
||||
mutate("the post-roll's annotation withheld", lambda vs, fs: drop_last(vs, post)),
|
||||
mutate("a stated asset list with one ad's duration changed",
|
||||
lambda vs, fs: vs[live]["receipt_facts"][0].update(asset_list=vs[live]["receipt_facts"][0]["asset_list"].replace("15.000", "14.000", 1))),
|
||||
mutate("the DASH events stated with executeOnce false",
|
||||
lambda vs, fs: vs[live].update(dash=vs[live]["dash"].replace('executeOnce="true"', 'executeOnce="false"'))),
|
||||
mutate("the open out's DASH event stated with its duration",
|
||||
lambda vs, fs: vs[live]["dash_open"][0].update(event=vs[live]["dash"].split(">", 1)[1].rsplit("</EventStream>", 1)[0])),
|
||||
mutate("a byte of the mid-roll's tag file", edit_file("mid_roll.m3u8", b"X-RESUME-OFFSET=30.000", b"X-RESUME-OFFSET=31.000")),
|
||||
mutate("the post-roll's tag stated without POST", edit_file("post_roll.m3u8", b'CUE="POST,ONCE"', b'CUE="ONCE"')),
|
||||
mutate("the mid-roll pod given in another order", swap_pod),
|
||||
mutate("the mid-roll's presentation time of t0 changed", lambda vs, fs: vs[mid].update(pt0_ms=vs[mid]["pt0_ms"] + 1)),
|
||||
mutate("a live break receipt's quartile raised",
|
||||
lambda vs, fs: vs[live]["receipts"][0]["impressions"][1].update(quartiles_reached=4)),
|
||||
mutate("a live break receipt's session changed",
|
||||
lambda vs, fs: vs[live]["receipts"][1].update(session="00" * 16)),
|
||||
mutate("the stated spend", lambda vs, fs: vs[live].update(spend_micros=vs[live]["spend_micros"] + 1)),
|
||||
mutate("a chapter's stated length", lambda vs, fs: vs[chap]["chapters"][0].update(duration_ms=59999)),
|
||||
mutate("the duplicated receipt given once", lambda vs, fs: vs[dup]["receipts"].pop()),
|
||||
mutate("the funds control's second receipt given once",
|
||||
lambda vs, fs: vs[vn["funds_exceeded"]]["receipts"].pop()),
|
||||
mutate("a tag file removed", lambda vs, fs: fs.pop("chapters.m3u8")),
|
||||
]
|
||||
|
||||
|
||||
def main(corpus_dir):
|
||||
fails = []
|
||||
st = ED._self_test()
|
||||
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
|
||||
fails += ["ed25519 self-test"] if st else []
|
||||
ok = len(REASONS) == 20
|
||||
print(("PASS" if ok else "FAIL") + " §10 read from the extension: %d reasons" % len(REASONS))
|
||||
fails += [] if ok else ["§10 does not read"]
|
||||
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
|
||||
doc = json.load(f)
|
||||
files = {}
|
||||
for name in sorted(os.listdir(corpus_dir)):
|
||||
if name.endswith(".m3u8"):
|
||||
with open(os.path.join(corpus_dir, name), "rb") as f:
|
||||
files[name] = f.read()
|
||||
got, written = check(doc, files)
|
||||
fails += got
|
||||
extra = sorted(set(os.listdir(corpus_dir)) - set(written) - {"vectors.json", "README.md"})
|
||||
print(("PASS" if not extra else "FAIL") + " no file but vectors.json, README.md and the tag files the rooms write")
|
||||
fails += ["an extra file: %s" % n for n in extra]
|
||||
seen = {v.get("reason") for v in doc["vectors"]}
|
||||
missing = [r for r in REASONS if r not in seen]
|
||||
print(("PASS" if not missing else "FAIL") + " every reason of §10 appears in the corpus")
|
||||
fails += ["no case gives %s" % r for r in missing]
|
||||
for label, d, fs in mutations(doc, files):
|
||||
caught, _ = check(d, fs, quiet=True)
|
||||
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:100] if caught else ""))
|
||||
if not caught:
|
||||
fails.append("a mutated copy verifies: %s" % label)
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print(("PASS" if not fails else "FAIL") + " live-ads corpus: %d rooms, %d tag files" % (len(doc["vectors"]), len(files)))
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: live_ads_verify.py <live-conformance/ads>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
sys.exit(main(sys.argv[1]))
|
||||
|
|
@ -602,6 +602,20 @@
|
|||
"edition": null,
|
||||
"registered": "2026-06-12"
|
||||
},
|
||||
{
|
||||
"capability": "wai.live.ads",
|
||||
"kind": "record",
|
||||
"status": "registered",
|
||||
"media": null,
|
||||
"role": null,
|
||||
"tier": null,
|
||||
"licence": null,
|
||||
"replicate": null,
|
||||
"sets": [],
|
||||
"spec_section": "§5 Interactive, temporal & computed content",
|
||||
"edition": null,
|
||||
"registered": "2026-10-20"
|
||||
},
|
||||
{
|
||||
"capability": "wai.live.compose",
|
||||
"kind": "record",
|
||||
|
|
@ -1885,6 +1899,13 @@
|
|||
"section": "§4.3",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WAB1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/live-ads.md",
|
||||
"section": "§4.2",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WAI-Room-Challenge",
|
||||
"kind": "http-header",
|
||||
|
|
@ -1927,6 +1948,13 @@
|
|||
"section": "§5",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WAP1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/live-ads.md",
|
||||
"section": "§3.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WCD1",
|
||||
"kind": "magic",
|
||||
|
|
|
|||
|
|
@ -534,6 +534,13 @@ wai:wai.image.png a wai:Capability ;
|
|||
wai:specSection "§5 Mandatory floor" ;
|
||||
wai:registeredOn "2026-06-12"^^xsd:date .
|
||||
|
||||
wai:wai.live.ads a wai:Capability ;
|
||||
rdfs:label "wai.live.ads" ;
|
||||
wai:kind wai:kind-record ;
|
||||
wai:status wai:status-registered ;
|
||||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-20"^^xsd:date .
|
||||
|
||||
wai:wai.live.compose a wai:Capability ;
|
||||
rdfs:label "wai.live.compose" ;
|
||||
wai:kind wai:kind-record ;
|
||||
|
|
|
|||
|
|
@ -247,6 +247,14 @@ live_moderation = ["live_events", "live_room", "live_record"]
|
|||
# deterministic-CBOR reader of its own and RFC 9052's COSE_Mac0 / COSE_Sign1 structure. SHA-256,
|
||||
# Ed25519 and the key log only; no new dependency.
|
||||
live_watermark = ["live_room"]
|
||||
# Live ads (extensions/live-ads): `wai.live.ads` — server-guided ad insertion for rooms: the
|
||||
# room authority's signed ad policy (WAP1), the schedule of breaks the room's `cue` events open
|
||||
# and close with the post-roll annotation (WAB1), each break's HLS interstitial and preload date
|
||||
# ranges and DASH alternative-presentation event written exactly, the HLS asset lists and DASH
|
||||
# list MPDs each viewer is served, and the room verifier extended to the break form of
|
||||
# wai.video.ssai's receipt: the decision recomputed, the asset list bound, the spend held to the
|
||||
# decider grant's ceiling. SHA-256, BLAKE3, Ed25519 and the key log only; no new dependency.
|
||||
live_ads = ["live_room", "video_ssai"]
|
||||
# Timed text and live captions (extensions/timed-text): `wai.text.timed` — the cue model,
|
||||
# live caption revisions (`caption` events of wai.live.events with the WTX1 annotation),
|
||||
# and their exact mappings onto WebVTT documents and segments, the IMSC1 Text Profile,
|
||||
|
|
@ -726,6 +734,11 @@ name = "wai_live_watermark_vectors"
|
|||
path = "src/bin/wai_live_watermark_vectors.rs"
|
||||
required-features = ["live_watermark"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_live_ads_vectors"
|
||||
path = "src/bin/wai_live_ads_vectors.rs"
|
||||
required-features = ["live_ads"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_timed_text_vectors"
|
||||
path = "src/bin/wai_timed_text_vectors.rs"
|
||||
|
|
@ -1412,6 +1425,11 @@ name = "live_watermark"
|
|||
path = "tests/live_watermark.rs"
|
||||
required-features = ["live_watermark"]
|
||||
|
||||
[[test]]
|
||||
name = "live_ads"
|
||||
path = "tests/live_ads.rs"
|
||||
required-features = ["live_ads"]
|
||||
|
||||
[[test]]
|
||||
name = "timed_text"
|
||||
path = "tests/timed_text.rs"
|
||||
|
|
|
|||
75
wai/wai-rs/src/bin/wai_live_ads_vectors.rs
Normal file
75
wai/wai-rs/src/bin/wai_live_ads_vectors.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
//! Conformance corpus for live ads (`live-conformance/ads/`, extensions/live-ads.md §11).
|
||||
//!
|
||||
//! wai_live_ads_vectors gen <corpus-dir> write vectors.json and each accepted room's HLS
|
||||
//! tags (NAME.m3u8), from fixed seeds
|
||||
//! wai_live_ads_vectors verify <corpus-dir> every file regenerates byte for byte, no other
|
||||
//! file but README.md is there, and every case gets
|
||||
//! the outcome it states
|
||||
//!
|
||||
//! The generator and this verifier are one implementation. The corpus's second
|
||||
//! implementation is `live-conformance/live_ads_verify.py`, written from the extension's
|
||||
//! text and sharing no code with this binary.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use wai::live_ads::corpus;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
match (a.get(1).map(String::as_str), a.get(2)) {
|
||||
(Some("gen"), Some(d)) => {
|
||||
if let Err(e) = std::fs::create_dir_all(d) {
|
||||
eprintln!("create {d}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
for (name, body) in corpus::generate() {
|
||||
let p = Path::new(d).join(&name);
|
||||
if let Err(e) = std::fs::write(&p, body) {
|
||||
eprintln!("write {}: {e}", p.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
println!("wrote {d}");
|
||||
}
|
||||
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
|
||||
_ => {
|
||||
eprintln!("usage: wai_live_ads_vectors gen|verify <corpus-dir>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(dir: &Path) -> i32 {
|
||||
let mut files = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("read {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name == "README.md" {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(e.path()) {
|
||||
Ok(s) => {
|
||||
files.insert(name, s);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("read {name}: {err}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let same = files == generated;
|
||||
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
|
||||
let fails = corpus::verify(&files);
|
||||
for f in &fails {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
|
||||
(!same || !fails.is_empty()) as i32
|
||||
}
|
||||
|
|
@ -220,6 +220,7 @@ pub const CAP_TEXT_TIMED: &str = "wai.text.timed";
|
|||
pub const CAP_LIVE_MODERATION: &str = "wai.live.moderation";
|
||||
pub const CAP_LIVE_E2EE: &str = "wai.live.e2ee";
|
||||
pub const CAP_LIVE_WATERMARK: &str = "wai.live.watermark";
|
||||
pub const CAP_LIVE_ADS: &str = "wai.live.ads";
|
||||
|
||||
/// One row per string SPEC §5 registers that is a payload, companion or record:
|
||||
/// `(string, SPEC section, edition, registered)`.
|
||||
|
|
@ -308,6 +309,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
(CAP_LIVE_MODERATION, S_INTERACTIVE, None, "2026-10-19"),
|
||||
(CAP_LIVE_E2EE, S_INTERACTIVE, None, "2026-10-19"),
|
||||
(CAP_LIVE_WATERMARK, S_INTERACTIVE, None, "2026-10-20"),
|
||||
(CAP_LIVE_ADS, S_INTERACTIVE, None, "2026-10-20"),
|
||||
(CAP_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"),
|
||||
(CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"),
|
||||
(CAP_AUDIO_IVAS, S_IMMERSIVE, None, "2026-06-17"),
|
||||
|
|
@ -359,6 +361,7 @@ const RECORDS: &[&str] = &[
|
|||
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE, CAP_LIVE_RECORDING,
|
||||
CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED, CAP_LIVE_MODERATION, CAP_LIVE_E2EE,
|
||||
CAP_LIVE_WATERMARK,
|
||||
CAP_LIVE_ADS,
|
||||
];
|
||||
|
||||
/// A named identifier SPEC uses that has the shape of a capability string but is
|
||||
|
|
@ -514,6 +517,7 @@ const ENERGY_STEERING: &str = "extensions/energy-steering.md";
|
|||
const LIVE_MODERATION: &str = "extensions/live-moderation.md";
|
||||
const CONFIDENTIALITY: &str = "extensions/confidentiality.md";
|
||||
const FORENSIC_WATERMARK: &str = "extensions/forensic-watermark.md";
|
||||
const LIVE_ADS: &str = "extensions/live-ads.md";
|
||||
|
||||
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
|
||||
status: WireStatus) -> WireIdentifier {
|
||||
|
|
@ -524,8 +528,8 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
|
|||
/// (http-delivery, moq-streaming-format), of the key discovery those bindings serve
|
||||
/// (jwp-receipts §7), of packaging, of live events, of live rooms, of capture binding, of
|
||||
/// live composition, of live recording, of timed text, of live metering, of energy steering,
|
||||
/// of live moderation, of confidentiality and of forensic watermark sessions, in document
|
||||
/// order. The export lists them sorted by name and kind.
|
||||
/// of live moderation, of confidentiality, of forensic watermark sessions and of live ads, in
|
||||
/// document order. The export lists them sorted by name and kind.
|
||||
pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
||||
wire("WAI1", WireKind::Magic, "SPEC.md", "§2", WireStatus::Defined),
|
||||
wire("WAI2", WireKind::Magic, "SPEC.md", "§7.1", WireStatus::Defined),
|
||||
|
|
@ -609,6 +613,8 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
|||
wire("WWS1", WireKind::Magic, FORENSIC_WATERMARK, "§5.1", WireStatus::Defined),
|
||||
wire("WWQ1", WireKind::Magic, FORENSIC_WATERMARK, "§7.1", WireStatus::Defined),
|
||||
wire("WWD1", WireKind::Magic, FORENSIC_WATERMARK, "§8.1", WireStatus::Defined),
|
||||
wire("WAP1", WireKind::Magic, LIVE_ADS, "§3.1", WireStatus::Defined),
|
||||
wire("WAB1", WireKind::Magic, LIVE_ADS, "§4.2", WireStatus::Defined),
|
||||
];
|
||||
|
||||
/// A string with the shape of a capability that the reference implementation uses
|
||||
|
|
|
|||
|
|
@ -267,6 +267,8 @@ macro_rules! single_figure {
|
|||
single_figure!("video_receipt", crate::video_receipt::VideoReceipt, "wai:video-receipt-id");
|
||||
single_figure!("video_manifest", crate::wai_video_manifest::ManifestReceipt, "wai:video-manifest-receipt-id");
|
||||
single_figure!("video_ssai", crate::wai_video_ssai::SsaiReceipt, "wai:video-ssai-receipt-id");
|
||||
// The break form (live-ads §8) shares the stitched form's identity domain, so its profile.
|
||||
single_figure!("video_ssai", crate::wai_video_ssai::SsaiBreakReceipt, "wai:video-ssai-receipt-id");
|
||||
single_figure!("video_keys", crate::wai_video_keys::KeyReleaseReceipt, "wai:video-keys-receipt-id");
|
||||
single_figure!("video_package", crate::wai_video_package::PackageReceipt, "wai:video-package-receipt-id");
|
||||
single_figure!("video_channel", crate::wai_video_channel::ChannelReceipt, "wai:video-channel-receipt-id");
|
||||
|
|
|
|||
|
|
@ -188,6 +188,36 @@ fn the_ott_receipts_are_viewed_under_their_profiles() {
|
|||
}
|
||||
}
|
||||
|
||||
/// A `wai.video.ssai` receipt of the break form (live-ads §8) is viewed under the stitched
|
||||
/// form's profile: the two share the identity domain.
|
||||
#[cfg(feature = "video_ssai")]
|
||||
#[test]
|
||||
fn a_break_form_ssai_receipt_is_viewed_under_the_ssai_profile() {
|
||||
use crate::wai_video_ssai::{asset_format, decide, pod_hash, AdCandidate, BreakBinding, SsaiBreakReceipt};
|
||||
let pod = vec![AdCandidate { ad_id: "a".into(), creative_hash: [1; 32], duration_ms: 15_000, price_micros: 20_000 }];
|
||||
let mut imps = decide(&pod, 1, 30_000, 50_000);
|
||||
imps[0].quartiles_reached = 5;
|
||||
let binding = BreakBinding {
|
||||
room: [2; 32],
|
||||
cue: [3; 32],
|
||||
session: [4; 16],
|
||||
start_offset_ms: 0,
|
||||
format: asset_format::HLS,
|
||||
asset_list_sha256: [5; 32],
|
||||
avail_ms: 30_000,
|
||||
slots: 1,
|
||||
funds_remaining_micros: 50_000,
|
||||
};
|
||||
let grant = wai_quantum::quantum_ops::GrantRef { grant_hash: [6; 32], capability: "wai.live.room".into(), joule_ceiling_micro: u64::MAX, funds_ceiling: None };
|
||||
let r = SsaiBreakReceipt::seal(&key(4), "d", binding, pod_hash(&pod), imps, 1, 700, Some(EnergyProvenance::ModelBased), grant);
|
||||
let v = r.figure_view().unwrap();
|
||||
assert_eq!((v.profile.as_str(), v.identity), ("wai:video-ssai-receipt-id", r.receipt_hash()));
|
||||
assert_eq!(v.figures[0].class, Some(EnergyProvenance::ModelBased));
|
||||
let mut bad = r;
|
||||
bad.sig[0] ^= 1;
|
||||
assert!(bad.figure_view().is_none(), "a receipt that does not verify has no view");
|
||||
}
|
||||
|
||||
#[cfg(feature = "quantum_receipt")]
|
||||
#[test]
|
||||
fn a_quantum_receipt_is_viewed_in_either_form() {
|
||||
|
|
|
|||
|
|
@ -468,6 +468,15 @@ pub mod live_moderation;
|
|||
/// cbindgen:ignore
|
||||
#[cfg(feature = "live_watermark")]
|
||||
pub mod live_watermark;
|
||||
// Live ads (extensions/live-ads.md): `wai.live.ads` — server-guided ad insertion for rooms:
|
||||
// the room authority's signed ad policy (WAP1), the room's breaks from its signed `cue` events
|
||||
// with the post-roll annotation (WAB1), each break's HLS interstitial date ranges and DASH
|
||||
// alternative-presentation event written exactly, the asset lists each viewer is served, and
|
||||
// verification from the events to the spend through the break form of wai.video.ssai's receipt.
|
||||
// Rust-only, like the other live modules: its constants must not become macros in include/wai.h.
|
||||
/// cbindgen:ignore
|
||||
#[cfg(feature = "live_ads")]
|
||||
pub mod live_ads;
|
||||
// Timed text and live captions (extensions/timed-text.md): `wai.text.timed` — the cue model
|
||||
// both WebVTT and the IMSC1 Text Profile express, live caption revisions as `caption`
|
||||
// events of `wai.live.events` with the WTX1 annotation, and the exact mappings onto WebVTT
|
||||
|
|
|
|||
1060
wai/wai-rs/src/live_ads.rs
Normal file
1060
wai/wai-rs/src/live_ads.rs
Normal file
File diff suppressed because it is too large
Load diff
935
wai/wai-rs/src/live_ads/corpus.rs
Normal file
935
wai/wai-rs/src/live_ads/corpus.rs
Normal file
|
|
@ -0,0 +1,935 @@
|
|||
//! The conformance corpus of extensions/live-ads.md §11: `live-conformance/ads/`.
|
||||
//!
|
||||
//! [`generate`] writes it from fixed seeds: every key is SHA-256 of this corpus's label and an
|
||||
//! index, every creative hash SHA-256 of a label and a number, every time a constant, and
|
||||
//! Ed25519 signing is deterministic (RFC 8032), so every file is the same bytes on every
|
||||
//! machine. Each vector states the outcome it was written to have, and the generator asserts
|
||||
//! that this module reaches it before writing it, and that the corpus gives every reason of
|
||||
//! §10.
|
||||
//!
|
||||
//! [`verify`] checks a corpus directory's files against this module.
|
||||
//! `live-conformance/live_ads_verify.py` checks the same files from the extension's text,
|
||||
//! sharing no code with this module.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
use crate::energy_class::{CounterFiltering, EnergyProvenance, Uncertainty};
|
||||
use crate::key_log::{commitment, KeyEntry, Revision};
|
||||
use crate::live_events::Policy as EventPolicy;
|
||||
use crate::live_room::{Caps, Descriptor, Detail, Grant, Limits, Recording, Review, Role, KEY_LOG_AUTHORITY, NO_CEILING};
|
||||
use crate::quantum_ops::GrantRef;
|
||||
use crate::wai_video_ssai::BreakBinding;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
const T0_US: u64 = 1_792_108_800_000_000;
|
||||
const T0_S: u64 = T0_US / 1_000_000;
|
||||
const S: u64 = 1_000_000;
|
||||
|
||||
const AUTHORITY: u8 = 0;
|
||||
const CLOCK: u8 = 1;
|
||||
const PRODUCER: u8 = 2;
|
||||
const VIEWER: u8 = 3;
|
||||
const DECIDER: u8 = 4;
|
||||
const LOG_ROOT: u8 = 5;
|
||||
const LOG_NEXT: u8 = 6;
|
||||
const STRANGER: u8 = 7;
|
||||
const PRINCIPALS: [(&str, u8); 6] =
|
||||
[("authority", AUTHORITY), ("clock", CLOCK), ("producer", PRODUCER), ("viewer", VIEWER), ("decider", DECIDER), ("stranger", STRANGER)];
|
||||
|
||||
const ORIGIN: &str = "https://rooms.example";
|
||||
const ASSET_BASE: &str = "https://ads.rooms.example/sgai/";
|
||||
const CREATIVE_BASE: &str = "https://cdn.rooms.example/creatives/";
|
||||
/// The decider grant's ceiling, in minor units of EUR: 2 000 000 millionths.
|
||||
const BUDGET_MINOR: u64 = 2;
|
||||
|
||||
fn sha(parts: &[&[u8]]) -> [u8; 32] {
|
||||
sha256(parts)
|
||||
}
|
||||
|
||||
fn key(i: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&sha(&[b"wai:live-ads/corpus/key\x00", &[i]]))
|
||||
}
|
||||
|
||||
fn pk(i: u8) -> [u8; 32] {
|
||||
key(i).verifying_key().to_bytes()
|
||||
}
|
||||
|
||||
/// A digest naming nothing of any room.
|
||||
fn nothing(label: &str) -> [u8; 32] {
|
||||
sha(&[b"wai:live-ads/corpus/nothing\x00", label.as_bytes()])
|
||||
}
|
||||
|
||||
fn session(i: u8) -> [u8; 16] {
|
||||
sha(&[b"wai:live-ads/corpus/session\x00", &[i]])[..16].try_into().expect("16 bytes")
|
||||
}
|
||||
|
||||
fn bits(ks: &[u8]) -> [u8; 32] {
|
||||
let mut b = [0u8; 32];
|
||||
for &k in ks {
|
||||
b[(k / 8) as usize] |= 1 << (k % 8);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
// ---- rooms ----
|
||||
|
||||
/// A principal's chain being written.
|
||||
#[derive(Clone)]
|
||||
struct Author {
|
||||
pk: [u8; 32],
|
||||
seq: u64,
|
||||
prev: [u8; 32],
|
||||
records: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// How a room is set up.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Spec {
|
||||
/// The decider grant's `exp`, seconds since 1970.
|
||||
decider_exp: u64,
|
||||
/// The decider grant's ceiling, in minor units.
|
||||
budget_minor: u64,
|
||||
/// The descriptor names an origin and the key log's head revokes the decider grant.
|
||||
revoked: bool,
|
||||
}
|
||||
|
||||
const SPEC: Spec = Spec { decider_exp: T0_S + 86_400, budget_minor: BUDGET_MINOR, revoked: false };
|
||||
|
||||
/// A room being written.
|
||||
#[derive(Clone)]
|
||||
struct Room {
|
||||
descriptor: Vec<u8>,
|
||||
id: [u8; 32],
|
||||
grants: Vec<Vec<u8>>,
|
||||
head: Option<String>,
|
||||
authors: Vec<Author>,
|
||||
decider: Grant,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
fn raw(&mut self, k: &SigningKey, t: u64, kind: u8, payload: Vec<u8>) -> [u8; 32] {
|
||||
let me = k.verifying_key().to_bytes();
|
||||
let i = match self.authors.iter().position(|a| a.pk == me) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.authors.push(Author { pk: me, seq: 0, prev: [0; 32], records: vec![] });
|
||||
self.authors.len() - 1
|
||||
}
|
||||
};
|
||||
let a = &mut self.authors[i];
|
||||
let e = Event::sign(k, self.id, a.seq, a.prev, t, None, kind, payload);
|
||||
a.seq += 1;
|
||||
a.prev = e.digest();
|
||||
a.records.push(e.to_bytes());
|
||||
a.prev
|
||||
}
|
||||
|
||||
fn cue(&mut self, who: u8, t: u64, ctype: u8, cue_id: u32, duration_ms: u32) -> [u8; 32] {
|
||||
let c = Payload::Cue(events::Cue { cue_type: ctype, cue_id, duration_ms, scte: None });
|
||||
self.raw(&key(who), t, kind::CUE, c.encode())
|
||||
}
|
||||
|
||||
fn annotate(&mut self, who: u8, t: u64, cue: [u8; 32], placement: u8) -> [u8; 32] {
|
||||
self.raw(&key(who), t, kind::META, Annotation { cue, placement }.encode())
|
||||
}
|
||||
|
||||
fn annotate_raw(&mut self, who: u8, t: u64, bytes: Vec<u8>) -> [u8; 32] {
|
||||
self.raw(&key(who), t, kind::META, bytes)
|
||||
}
|
||||
|
||||
fn chains(&self) -> Vec<Vec<Vec<u8>>> {
|
||||
self.authors.iter().map(|a| a.records.clone()).collect()
|
||||
}
|
||||
|
||||
/// The room as a subscriber holds it when these records are not delivered to it.
|
||||
fn without(&self, digests: &[[u8; 32]]) -> Room {
|
||||
let mut r = self.clone();
|
||||
for a in r.authors.iter_mut() {
|
||||
a.records.retain(|b| !digests.contains(&Event::parse(b).expect("a corpus record").digest()));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// The decider grant's reference (live-rooms §4.5).
|
||||
fn decider_ref(&self) -> GrantRef {
|
||||
let (grant_hash, capability, joule_ceiling_micro, funds_ceiling) = self.decider.grant_ref();
|
||||
GrantRef { grant_hash, capability: capability.into(), joule_ceiling_micro, funds_ceiling }
|
||||
}
|
||||
}
|
||||
|
||||
fn role(name: &str, subscribe: bool, events: &[u8]) -> Role {
|
||||
Role { name: name.into(), caps: Caps { publish: 0, subscribe, max_layer: subscribe as u8, powers: 0 }, events: bits(events) }
|
||||
}
|
||||
|
||||
/// A room: a producer that cues breaks, a viewer that chats, and the decider, which holds a
|
||||
/// grant with the room's ad budget and never joins. The clock admits the producer and the
|
||||
/// viewer at the room's start.
|
||||
fn room(spec: Spec) -> Room {
|
||||
let d = Descriptor {
|
||||
authority: pk(AUTHORITY),
|
||||
clock: pk(CLOCK),
|
||||
t0_us: T0_US,
|
||||
tick_us: S,
|
||||
origin: if spec.revoked { ORIGIN.into() } else { String::new() },
|
||||
limits: Limits { max_present: 16, max_publishers: 4, reval_s: 0, ceiling: EventPolicy::default() },
|
||||
roles: vec![role("producer", true, &[kind::META, kind::CUE, kind::CHAT]), role("audience", true, &[kind::CHAT]), role("decider", false, &[])],
|
||||
slots: vec![],
|
||||
profiles: vec![],
|
||||
review: Review { kinds: [0; 32], deadline_ms: 0, fallback: 0 },
|
||||
recording: Recording { mode: 0, retention_days: 0, commit_interval_ms: 0, jitter_ms: 0, regions: vec![] },
|
||||
sig: [0; 64],
|
||||
}
|
||||
.sign(&key(AUTHORITY));
|
||||
let id = d.room_id();
|
||||
let grant = |who: u8, r: usize, exp: u64, funds: Option<u64>| {
|
||||
let role = &d.roles[r];
|
||||
let kinds: Vec<u8> = (0..=255u8).filter(|&k| role.events[(k / 8) as usize] & (1 << (k % 8)) != 0).collect();
|
||||
Grant {
|
||||
room: id,
|
||||
principal: pk(who),
|
||||
prev: [0; 32],
|
||||
effective: 0,
|
||||
nbf: T0_S - 3_600,
|
||||
exp,
|
||||
role: r as u8,
|
||||
caps: role.caps,
|
||||
precedence: 0,
|
||||
once: [0; 16],
|
||||
viewer: [0; 16],
|
||||
policy: EventPolicy::with_kinds(&kinds),
|
||||
joules_ceiling: NO_CEILING,
|
||||
funds_ceiling: funds.unwrap_or(NO_CEILING),
|
||||
currency: if funds.is_some() { *b"EUR" } else { [0; 3] },
|
||||
profiles: vec![],
|
||||
origins: vec![],
|
||||
attrs: vec![],
|
||||
sig: [0; 64],
|
||||
}
|
||||
.sign(&key(AUTHORITY))
|
||||
};
|
||||
let producer = grant(PRODUCER, 0, T0_S + 86_400, None);
|
||||
let viewer = grant(VIEWER, 1, T0_S + 86_400, None);
|
||||
let decider = grant(DECIDER, 2, spec.decider_exp, Some(spec.budget_minor));
|
||||
let head = spec.revoked.then(|| {
|
||||
let rev = Revision {
|
||||
origin: ORIGIN.into(),
|
||||
seq: 0,
|
||||
prev: None,
|
||||
issued_at: "2026-10-15T00:00:00Z".into(),
|
||||
root: [0; 32],
|
||||
next_root: commitment(&pk(LOG_NEXT)),
|
||||
keys: vec![KeyEntry::active("room-authority", pk(AUTHORITY), &[KEY_LOG_AUTHORITY])],
|
||||
revoked_modules: vec![decider.digest()],
|
||||
grant_max_s: 86_400,
|
||||
status_max_age_s: 3_600,
|
||||
sig: [0; 64],
|
||||
};
|
||||
Revision::seal(&key(LOG_ROOT), rev).expect("a well-formed revision").to_json()
|
||||
});
|
||||
let mut room = Room {
|
||||
descriptor: d.to_bytes(),
|
||||
id,
|
||||
grants: [&producer, &viewer, &decider].iter().map(|g| g.to_bytes()).collect(),
|
||||
head,
|
||||
authors: vec![],
|
||||
decider,
|
||||
};
|
||||
for (who, g) in [(PRODUCER, &producer), (VIEWER, &viewer)] {
|
||||
let j = Detail::Join { grant: g.digest() };
|
||||
room.raw(&key(CLOCK), 0, j.kind(), Payload::Lifecycle { kind: j.kind(), subject: pk(who), detail: j.encode() }.encode());
|
||||
}
|
||||
room
|
||||
}
|
||||
|
||||
/// The room's ad policy: the defaults, changed by `f`, then signed.
|
||||
fn policy(room: &Room, f: impl FnOnce(&mut AdPolicy)) -> AdPolicy {
|
||||
let mut p = AdPolicy {
|
||||
room: room.id,
|
||||
decider_grant: room.decider.digest(),
|
||||
slots: 2,
|
||||
open_avail_ms: 30_000,
|
||||
preload_ms: 4_000,
|
||||
restrict: 0,
|
||||
skip_after_s: NO_SKIP_CONTROL,
|
||||
snap: 0,
|
||||
asset_base: ASSET_BASE.into(),
|
||||
creative_base: CREATIVE_BASE.into(),
|
||||
sig: [0; 64],
|
||||
};
|
||||
f(&mut p);
|
||||
p.sign(&key(AUTHORITY))
|
||||
}
|
||||
|
||||
// ---- pods and receipts ----
|
||||
|
||||
fn ad(id: &str, creative: u8, duration_ms: u64, price_micros: u64) -> AdCandidate {
|
||||
AdCandidate { ad_id: id.into(), creative_hash: sha(&[b"wai:live-ads/corpus/creative\x00", &[creative]]), duration_ms, price_micros }
|
||||
}
|
||||
|
||||
/// Three ads: two of 15 s and one of 20 s.
|
||||
fn pod_one() -> Vec<AdCandidate> {
|
||||
vec![ad("ad-a", 1, 15_000, 40_000), ad("ad-b", 2, 15_000, 30_000), ad("ad-c", 3, 20_000, 25_000)]
|
||||
}
|
||||
|
||||
/// Four ads, two at one price, so the tie is broken by `ad_id`.
|
||||
fn pod_two() -> Vec<AdCandidate> {
|
||||
vec![ad("ad-g", 7, 5_000, 10_000), ad("ad-e", 5, 10_000, 50_000), ad("ad-f", 6, 15_000, 45_000), ad("ad-d", 4, 10_000, 50_000)]
|
||||
}
|
||||
|
||||
fn chip() -> EnergyProvenance {
|
||||
EnergyProvenance::OnChipCounter { uncertainty: Uncertainty { relative_ppm: 30_000, window_us: 2_000_000 }, filtering: CounterFiltering::Unknown }
|
||||
}
|
||||
|
||||
/// What one receipt is sealed from, with the defects a control writes in.
|
||||
#[derive(Clone)]
|
||||
struct Seal {
|
||||
cue: [u8; 32],
|
||||
avail_ms: u64,
|
||||
session: u8,
|
||||
start_offset_ms: u64,
|
||||
format: u8,
|
||||
pod: Vec<AdCandidate>,
|
||||
funds: u64,
|
||||
quartiles: Vec<u8>,
|
||||
energy: Option<(u64, EnergyProvenance)>,
|
||||
signer: u8,
|
||||
// Defects.
|
||||
stated_funds: Option<u64>,
|
||||
stated_room: Option<[u8; 32]>,
|
||||
capability: Option<&'static str>,
|
||||
list_format: Option<u8>,
|
||||
work_ops: Option<u64>,
|
||||
/// A figure sealed with no class.
|
||||
joules_only: Option<u64>,
|
||||
}
|
||||
|
||||
impl Seal {
|
||||
fn new(cue: [u8; 32], avail_ms: u64, session: u8, format: u8, pod: Vec<AdCandidate>, quartiles: &[u8]) -> Seal {
|
||||
Seal {
|
||||
cue,
|
||||
avail_ms,
|
||||
session,
|
||||
start_offset_ms: 0,
|
||||
format,
|
||||
pod,
|
||||
funds: BUDGET_MINOR * 1_000_000,
|
||||
quartiles: quartiles.to_vec(),
|
||||
energy: None,
|
||||
signer: DECIDER,
|
||||
stated_funds: None,
|
||||
stated_room: None,
|
||||
capability: None,
|
||||
list_format: None,
|
||||
work_ops: None,
|
||||
joules_only: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn seal(room: &Room, p: &AdPolicy, s: &Seal) -> SsaiBreakReceipt {
|
||||
let fill = decide(&s.pod, p.slots as u64, s.avail_ms.saturating_sub(s.start_offset_ms), s.funds);
|
||||
assert_eq!(fill.len(), s.quartiles.len(), "a corpus receipt states a quartile for each ad of its fill");
|
||||
let mut imps = fill.clone();
|
||||
for (i, q) in imps.iter_mut().zip(&s.quartiles) {
|
||||
i.quartiles_reached = *q;
|
||||
}
|
||||
let list = asset_list(p, s.list_format.unwrap_or(s.format), &fill);
|
||||
let binding = BreakBinding {
|
||||
room: s.stated_room.unwrap_or(room.id),
|
||||
cue: s.cue,
|
||||
session: session(s.session),
|
||||
start_offset_ms: s.start_offset_ms,
|
||||
format: s.format,
|
||||
asset_list_sha256: sha(&[&list]),
|
||||
avail_ms: s.avail_ms,
|
||||
slots: p.slots as u64,
|
||||
funds_remaining_micros: s.stated_funds.unwrap_or(s.funds),
|
||||
};
|
||||
let mut grant = room.decider_ref();
|
||||
if let Some(c) = s.capability {
|
||||
grant.capability = c.into();
|
||||
}
|
||||
let (joules, class) = match (&s.energy, s.joules_only) {
|
||||
(Some((j, c)), _) => (*j, Some(c.clone())),
|
||||
(None, j) => (j.unwrap_or(0), None),
|
||||
};
|
||||
let signer = key(s.signer);
|
||||
let id = crate::signer_id::did_key(&signer.verifying_key().to_bytes());
|
||||
SsaiBreakReceipt::seal(&signer, id, binding, pod_hash(&s.pod), imps, s.work_ops.unwrap_or(s.pod.len() as u64), joules, class, grant)
|
||||
}
|
||||
|
||||
// ---- vectors ----
|
||||
|
||||
/// What a vector was written to do.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Intent {
|
||||
Accept,
|
||||
Gap,
|
||||
/// Refused, at a place of this kind (`room`, `policy`, `record`, `receipt`), with this reason.
|
||||
Refuse(&'static str, Reason),
|
||||
}
|
||||
|
||||
struct Vector {
|
||||
name: &'static str,
|
||||
about: &'static str,
|
||||
room: Room,
|
||||
policy: Vec<u8>,
|
||||
pt0_ms: u64,
|
||||
receipts: Vec<Value>,
|
||||
pods: Vec<Vec<AdCandidate>>,
|
||||
intent: Intent,
|
||||
}
|
||||
|
||||
impl Vector {
|
||||
fn run(&self) -> Run {
|
||||
let chains = self.room.chains();
|
||||
super::verify(&Input {
|
||||
descriptor: &self.room.descriptor,
|
||||
grants: &self.room.grants,
|
||||
head: self.room.head.as_deref(),
|
||||
chains: &chains,
|
||||
policy: &self.policy,
|
||||
pt0_ms: self.pt0_ms,
|
||||
receipts: &self.receipts,
|
||||
pods: &self.pods,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A live room's break: an out at 60 s with no planned duration and its in at 90 s.
|
||||
struct LiveBreak {
|
||||
room: Room,
|
||||
policy: AdPolicy,
|
||||
out: [u8; 32],
|
||||
in_: [u8; 32],
|
||||
first: Seal,
|
||||
joined: Seal,
|
||||
}
|
||||
|
||||
fn live_break(spec: Spec) -> LiveBreak {
|
||||
let mut room = room(spec);
|
||||
room.raw(&key(PRODUCER), 10 * S, kind::CHAT, Payload::Chat { text: "kick-off".into() }.encode());
|
||||
let out = room.cue(PRODUCER, 60 * S, cue_type::OUT, 101, 0);
|
||||
let in_ = room.cue(PRODUCER, 90 * S, cue_type::IN, 101, 30_000);
|
||||
let policy = policy(&room, |_| {});
|
||||
let mut first = Seal::new(out, 30_000, 1, asset_format::HLS, pod_one(), &[5, 3]);
|
||||
first.energy = Some((1_200, EnergyProvenance::ModelBased));
|
||||
let mut joined = Seal::new(out, 30_000, 2, asset_format::DASH, pod_one(), &[5]);
|
||||
joined.start_offset_ms = 12_000;
|
||||
joined.funds = 1_930_000;
|
||||
joined.energy = Some((900, chip()));
|
||||
LiveBreak { room, policy, out, in_, first, joined }
|
||||
}
|
||||
|
||||
fn vector(name: &'static str, about: &'static str, room: &Room, policy: &AdPolicy, receipts: Vec<SsaiBreakReceipt>, pods: Vec<Vec<AdCandidate>>, intent: Intent) -> Vector {
|
||||
Vector { name, about, room: room.clone(), policy: policy.to_bytes(), pt0_ms: 0, receipts: receipts.iter().map(SsaiBreakReceipt::to_json).collect(), pods, intent }
|
||||
}
|
||||
|
||||
fn accepted() -> Vec<Vector> {
|
||||
let mut out = Vec::new();
|
||||
// A live break: one viewer from its start, one who joins it running.
|
||||
let lb = live_break(SPEC);
|
||||
out.push(vector(
|
||||
"live_break",
|
||||
"an out with no planned duration and its in 30 s later: the policy's open avail is decided; a viewer at the break's start is filled from an HLS asset list, and one who joins 12 s into it from a DASH list MPD with what remains; no pre-roll",
|
||||
&lb.room,
|
||||
&lb.policy,
|
||||
vec![seal(&lb.room, &lb.policy, &lb.first), seal(&lb.room, &lb.policy, &lb.joined)],
|
||||
vec![pod_one(), pod_one()],
|
||||
Intent::Accept,
|
||||
));
|
||||
// A mid-roll of known duration under restrictions and a skip control.
|
||||
let mut r = room(SPEC);
|
||||
let b = r.cue(PRODUCER, 120 * S, cue_type::BREAK, 7, 30_000);
|
||||
r.annotate(PRODUCER, 120 * S, b, placement::MID);
|
||||
let p = policy(&r, |p| {
|
||||
p.slots = 3;
|
||||
p.open_avail_ms = 0;
|
||||
p.preload_ms = 0;
|
||||
p.restrict = restrict::SKIP | restrict::JUMP;
|
||||
p.skip_after_s = 5;
|
||||
p.snap = snap::OUT | snap::IN;
|
||||
});
|
||||
let full = Seal::new(b, 30_000, 3, asset_format::HLS, pod_two(), &[5, 5, 2]);
|
||||
let mut none = Seal::new(b, 30_000, 4, asset_format::DASH, vec![ad("ad-h", 8, 10_000, 60_000)], &[]);
|
||||
none.funds = 50_000;
|
||||
let mut v = vector(
|
||||
"mid_roll",
|
||||
"a 30 s break, annotated mid-roll, under a policy that restricts skipping and jumping, snaps both ways and offers a skip control after 5 s: three ads fill one viewer's avail (two at one price, ordered by ad_id); another viewer's only candidate costs more than its funds, so its fill is empty and its DASH list MPD is no bytes",
|
||||
&r,
|
||||
&p,
|
||||
vec![seal(&r, &p, &full), seal(&r, &p, &none)],
|
||||
vec![pod_two(), vec![ad("ad-h", 8, 10_000, 60_000)]],
|
||||
Intent::Accept,
|
||||
);
|
||||
v.pt0_ms = 5_000;
|
||||
out.push(v);
|
||||
// A program with chapters, a mid-roll, and a post-roll at its close.
|
||||
let mut r = room(SPEC);
|
||||
r.cue(PRODUCER, S, cue_type::CHAPTER, 1, 0);
|
||||
let mid = r.cue(PRODUCER, 30 * S, cue_type::BREAK, 2, 15_000);
|
||||
r.cue(PRODUCER, 50 * S, cue_type::CHAPTER, 3, 0);
|
||||
let post = r.cue(PRODUCER, 120 * S, cue_type::BREAK, 4, 20_000);
|
||||
r.annotate(PRODUCER, 120 * S, post, placement::POST);
|
||||
let p = policy(&r, |p| {
|
||||
p.preload_ms = 2_000;
|
||||
p.restrict = restrict::JUMP;
|
||||
p.skip_after_s = 10;
|
||||
});
|
||||
out.push(vector(
|
||||
"post_roll",
|
||||
"a program of two chapters with a 15 s mid-roll, closed by a 20 s post-roll at 120 s: the post-roll is an insertion played after the program ends; one viewer is filled in both breaks, another in the post-roll only",
|
||||
&r,
|
||||
&p,
|
||||
vec![
|
||||
seal(&r, &p, &Seal::new(mid, 15_000, 5, asset_format::HLS, pod_one(), &[5])),
|
||||
seal(&r, &p, &Seal::new(post, 20_000, 5, asset_format::HLS, pod_one(), &[4])),
|
||||
seal(&r, &p, &Seal::new(post, 20_000, 6, asset_format::DASH, pod_two(), &[1, 0])),
|
||||
],
|
||||
vec![pod_one(), pod_one(), pod_two()],
|
||||
Intent::Accept,
|
||||
));
|
||||
// Chapters, and an out with a planned duration closed by its in.
|
||||
let mut r = room(SPEC);
|
||||
r.cue(PRODUCER, S, cue_type::CHAPTER, 10, 60_000);
|
||||
r.cue(PRODUCER, 61 * S, cue_type::CHAPTER, 11, 0);
|
||||
let out_ = r.cue(PRODUCER, 70 * S, cue_type::OUT, 12, 15_000);
|
||||
r.cue(PRODUCER, 85 * S, cue_type::IN, 12, 0);
|
||||
let p = policy(&r, |p| {
|
||||
p.preload_ms = 0;
|
||||
p.open_avail_ms = 0;
|
||||
});
|
||||
out.push(vector(
|
||||
"chapters",
|
||||
"two chapters, the first of a stated length, then an out with a planned duration of 15 s and its in, which states no duration: the interstitial carries the planned duration and its closing tag the actual one",
|
||||
&r,
|
||||
&p,
|
||||
vec![seal(&r, &p, &Seal::new(out_, 15_000, 7, asset_format::HLS, pod_one(), &[2]))],
|
||||
vec![pod_one()],
|
||||
Intent::Accept,
|
||||
));
|
||||
// A subscriber that missed the out: a gap, and no schedule.
|
||||
let withheld = lb.room.without(&[lb.out]);
|
||||
out.push(vector(
|
||||
"gap",
|
||||
"the live break's room as a subscriber holds it when the out was not delivered to it: the in follows a gap in the producer's chain, so no schedule is read",
|
||||
&withheld,
|
||||
&lb.policy,
|
||||
vec![],
|
||||
vec![],
|
||||
Intent::Gap,
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
/// A room of cues for a schedule control: `f` writes them after the joins.
|
||||
fn cue_room(f: impl FnOnce(&mut Room)) -> Room {
|
||||
let mut r = room(SPEC);
|
||||
f(&mut r);
|
||||
r
|
||||
}
|
||||
|
||||
fn refused() -> Vec<Vector> {
|
||||
let mut out = Vec::new();
|
||||
let lb = live_break(SPEC);
|
||||
let (r, p) = (&lb.room, &lb.policy);
|
||||
let one = |s: &Seal| seal(r, p, s);
|
||||
let refuse = |name, about, room: &Room, policy: &AdPolicy, receipts: Vec<SsaiBreakReceipt>, pods: Vec<Vec<AdCandidate>>, at, reason| {
|
||||
vector(name, about, room, policy, receipts, pods, Intent::Refuse(at, reason))
|
||||
};
|
||||
// The room refuses a cue by a principal its grant does not let send one.
|
||||
let rv = cue_room(|r| {
|
||||
r.cue(VIEWER, 60 * S, cue_type::OUT, 1, 30_000);
|
||||
});
|
||||
out.push(refuse("cue_kind_not_permitted", "the viewer, whose grant permits chat alone, sends an out: the room refuses it", &rv, &policy(&rv, |_| {}), vec![], vec![], "room", Reason::Room(room::Reason::Events(events::Reason::KindNotPermitted))));
|
||||
// The policy.
|
||||
out.push(refuse("policy_slots_zero", "a policy whose slots is 0", r, &policy(r, |p| p.slots = 0), vec![], vec![], "policy", Reason::AdPolicyMalformed));
|
||||
out.push(refuse("policy_wrong_room", "a policy naming another room", r, &policy(r, |p| p.room = nothing("room")), vec![], vec![], "policy", ev(events::Reason::WrongRoom)));
|
||||
let forged = policy(r, |_| {}).sign(&key(PRODUCER));
|
||||
out.push(refuse("policy_bad_signature", "a policy signed by the producer, not the room authority", r, &forged, vec![], vec![], "policy", ev(events::Reason::BadSignature)));
|
||||
out.push(refuse("decider_grant_unknown", "a policy naming a decider grant the room does not have", r, &policy(r, |p| p.decider_grant = nothing("grant")), vec![], vec![], "policy", Reason::DeciderGrantUnknown));
|
||||
let rk = live_break(Spec { revoked: true, ..SPEC });
|
||||
out.push(refuse("decider_grant_revoked", "a room whose key log's head revokes the decider grant", &rk.room, &rk.policy, vec![], vec![], "policy", Reason::Room(room::Reason::GrantRevoked)));
|
||||
// The schedule.
|
||||
let rv = cue_room(|r| {
|
||||
let b = r.cue(PRODUCER, 60 * S, cue_type::BREAK, 1, 30_000);
|
||||
let mut bytes = Annotation { cue: b, placement: placement::POST }.encode();
|
||||
bytes[37] = 3;
|
||||
r.annotate_raw(PRODUCER, 60 * S, bytes);
|
||||
});
|
||||
out.push(refuse("annotation_placement", "a break annotation of placement 3", &rv, &policy(&rv, |_| {}), vec![], vec![], "record", Reason::AnnotationMalformed));
|
||||
let rv = cue_room(|r| {
|
||||
let o = r.cue(PRODUCER, 60 * S, cue_type::OUT, 1, 30_000);
|
||||
r.annotate(PRODUCER, 60 * S, o, placement::POST);
|
||||
});
|
||||
out.push(refuse("annotation_on_out", "a post-roll annotation after an out: only a break of known duration is a post-roll", &rv, &policy(&rv, |_| {}), vec![], vec![], "record", Reason::AnnotationMisplaced));
|
||||
let rv = cue_room(|r| {
|
||||
let b = r.cue(PRODUCER, 120 * S, cue_type::BREAK, 1, 20_000);
|
||||
r.annotate(PRODUCER, 120 * S, b, placement::POST);
|
||||
r.cue(PRODUCER, 150 * S, cue_type::CHAPTER, 2, 0);
|
||||
});
|
||||
out.push(refuse("cue_after_post_roll", "a chapter after the post-roll", &rv, &policy(&rv, |_| {}), vec![], vec![], "record", Reason::CueAfterClose));
|
||||
let rv = cue_room(|r| {
|
||||
r.cue(PRODUCER, 30 * S, cue_type::BREAK, 1, 30_000);
|
||||
r.cue(PRODUCER, 45 * S, cue_type::OUT, 2, 15_000);
|
||||
});
|
||||
out.push(refuse("overlapping_breaks", "an out 15 s into a 30 s break", &rv, &policy(&rv, |_| {}), vec![], vec![], "record", Reason::BreakOverlap));
|
||||
let rv = cue_room(|r| {
|
||||
r.cue(PRODUCER, 60 * S, cue_type::OUT, 1, 0);
|
||||
});
|
||||
out.push(refuse("open_break_no_avail", "an out with no planned duration under a policy with no open avail", &rv, &policy(&rv, |p| p.open_avail_ms = 0), vec![], vec![], "record", Reason::BreakDurationUnknown));
|
||||
let rv = cue_room(|r| {
|
||||
r.cue(PRODUCER, 60 * S, cue_type::BREAK, 1, 0);
|
||||
});
|
||||
out.push(refuse("break_of_no_duration", "a break of type 4 with a duration of 0", &rv, &policy(&rv, |_| {}), vec![], vec![], "record", Reason::BreakZeroDuration));
|
||||
let rv = cue_room(|r| {
|
||||
r.cue(PRODUCER, 60 * S, cue_type::IN, 5, 0);
|
||||
});
|
||||
out.push(refuse("in_without_out", "an in with no open out", &rv, &policy(&rv, |_| {}), vec![], vec![], "record", Reason::CueInUnmatched));
|
||||
let rv = cue_room(|r| {
|
||||
r.cue(PRODUCER, 60 * S, cue_type::OUT, 1, 0);
|
||||
r.cue(PRODUCER, 90 * S, cue_type::IN, 1, 25_000);
|
||||
});
|
||||
out.push(refuse("in_duration_mismatch", "an in stating 25 s when 30 s passed since its out", &rv, &policy(&rv, |_| {}), vec![], vec![], "record", Reason::CueDurationMismatch));
|
||||
// The receipts, each over the live break.
|
||||
let good = one(&lb.first);
|
||||
let mut extra = good.to_json();
|
||||
extra.as_object_mut().expect("an object").insert("x_note".into(), json!("an extra member"));
|
||||
let mut v = refuse("receipt_extra_member", "a receipt carrying a member §8.2 does not define", r, p, vec![], vec![pod_one()], "receipt", Reason::ReceiptMalformed);
|
||||
v.receipts = vec![extra];
|
||||
out.push(v);
|
||||
let mut s = lb.first.clone();
|
||||
s.stated_room = Some(nothing("room"));
|
||||
out.push(refuse("receipt_wrong_room", "a receipt naming another room", r, p, vec![one(&s)], vec![pod_one()], "receipt", ev(events::Reason::WrongRoom)));
|
||||
let mut bad = good.clone();
|
||||
bad.sig[10] ^= 0x01;
|
||||
out.push(refuse("receipt_bad_signature", "a receipt with one bit of its signature flipped", r, p, vec![bad], vec![pod_one()], "receipt", ev(events::Reason::BadSignature)));
|
||||
let mut s = lb.first.clone();
|
||||
s.quartiles = vec![6, 3];
|
||||
out.push(refuse("receipt_quartile_six", "a receipt stating an ad reached quartile 6", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::ReceiptInvalid));
|
||||
// The other rules of §8.5, each broken alone.
|
||||
let mut s = lb.first.clone();
|
||||
s.format = 3;
|
||||
s.list_format = Some(asset_format::HLS);
|
||||
out.push(refuse("receipt_format_three", "a receipt of format 3, which names no asset list", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::ReceiptInvalid));
|
||||
let mut s = lb.first.clone();
|
||||
s.work_ops = Some(1);
|
||||
out.push(refuse("receipt_work_below_fill", "a receipt stating it considered one candidate for a fill of two", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::ReceiptInvalid));
|
||||
let mut s = lb.first.clone();
|
||||
s.energy = None;
|
||||
s.joules_only = Some(1_200);
|
||||
out.push(refuse("receipt_figure_without_class", "a receipt stating 1 200 µJ of decision energy with no acquisition class", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::ReceiptInvalid));
|
||||
let mut s = lb.first.clone();
|
||||
s.energy = Some((9, chip()));
|
||||
out.push(refuse(
|
||||
"receipt_below_resolution",
|
||||
"a receipt stating 9 µJ read from an on-chip counter with a declared uncertainty of 30 000 ppm, below the resolution floor",
|
||||
r,
|
||||
p,
|
||||
vec![one(&s)],
|
||||
vec![pod_one()],
|
||||
"receipt",
|
||||
Reason::ReceiptInvalid,
|
||||
));
|
||||
let dear = vec![ad("ad-q", 10, 15_000, 2_500_000)];
|
||||
let mut s = Seal::new(lb.out, 30_000, 1, asset_format::HLS, dear.clone(), &[1]);
|
||||
s.funds = 3_000_000;
|
||||
out.push(refuse("receipt_over_its_ceiling", "a receipt billing 2 500 000 µ-units under a grant reference whose ceiling is 2 minor units", r, p, vec![one(&s)], vec![dear], "receipt", Reason::ReceiptInvalid));
|
||||
let mut s = lb.first.clone();
|
||||
s.signer = STRANGER;
|
||||
out.push(refuse("receipt_not_decider", "a receipt signed by a key other than the decider's", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::ReceiptNotDecider));
|
||||
let mut s = lb.first.clone();
|
||||
s.capability = Some("video.ssai");
|
||||
out.push(refuse("receipt_grant_mismatch", "a receipt naming the decider grant with another capability", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::ReceiptGrantMismatch));
|
||||
let mut s = lb.first.clone();
|
||||
s.cue = lb.in_;
|
||||
out.push(refuse("receipt_names_the_in", "a receipt naming the in, which opens no break", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::ReceiptBreakUnknown));
|
||||
let lx = live_break(Spec { decider_exp: T0_S + 30, ..SPEC });
|
||||
out.push(refuse(
|
||||
"decider_grant_expired",
|
||||
"a decider grant that ends 30 s into the room, and a receipt for the break at 60 s",
|
||||
&lx.room,
|
||||
&lx.policy,
|
||||
vec![seal(&lx.room, &lx.policy, &lx.first)],
|
||||
vec![pod_one()],
|
||||
"receipt",
|
||||
Reason::Room(room::Reason::OutsideGrantWindow),
|
||||
));
|
||||
out.push(refuse("pod_mismatch", "a receipt given with a pod short of its last candidate", r, p, vec![good.clone()], vec![pod_one()[..2].to_vec()], "receipt", Reason::PodMismatch));
|
||||
let mut s = lb.first.clone();
|
||||
s.stated_funds = Some(50_000);
|
||||
out.push(refuse("decision_mismatch", "a receipt whose fill is the decision over 2 000 000 µ-units of funds, stating 50 000: over 50 000 the decision takes ad-a alone", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::DecisionMismatch));
|
||||
let mut s = lb.first.clone();
|
||||
s.format = asset_format::DASH;
|
||||
s.list_format = Some(asset_format::HLS);
|
||||
out.push(refuse("asset_list_mismatch", "a receipt of the DASH format binding the HLS asset list", r, p, vec![one(&s)], vec![pod_one()], "receipt", Reason::AssetListMismatch));
|
||||
out.push(refuse("receipt_duplicate", "one viewer's receipt for the break given twice", r, p, vec![good.clone(), good], vec![pod_one(), pod_one()], "receipt", Reason::ReceiptDuplicate));
|
||||
let lf = live_break(Spec { budget_minor: 1, ..SPEC });
|
||||
let costly = vec![ad("ad-p", 9, 15_000, 600_000)];
|
||||
let mut a = Seal::new(lf.out, 30_000, 8, asset_format::HLS, costly.clone(), &[1]);
|
||||
a.funds = 1_000_000;
|
||||
let mut b = a.clone();
|
||||
b.session = 9;
|
||||
out.push(refuse(
|
||||
"funds_exceeded",
|
||||
"a decider grant of 1 minor unit, and two viewers each billed 600 000 µ-units: the second receipt takes the room's spend past the ceiling",
|
||||
&lf.room,
|
||||
&lf.policy,
|
||||
vec![seal(&lf.room, &lf.policy, &a), seal(&lf.room, &lf.policy, &b)],
|
||||
vec![costly.clone(), costly],
|
||||
"receipt",
|
||||
Reason::FundsExceeded,
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
fn build() -> Vec<(Vector, Run)> {
|
||||
let mut out = Vec::new();
|
||||
for v in accepted().into_iter().chain(refused()) {
|
||||
let run = v.run();
|
||||
let ok = match (v.intent, run.outcome()) {
|
||||
(Intent::Accept, Outcome::Accept) => true,
|
||||
(Intent::Gap, Outcome::Gap(_)) => true,
|
||||
(Intent::Refuse(place, reason), Outcome::Refuse { at, reason: got }) => {
|
||||
*got == reason
|
||||
&& place
|
||||
== match at {
|
||||
At::Room(_) => "room",
|
||||
At::Policy => "policy",
|
||||
At::Record { .. } => "record",
|
||||
At::Receipt(_) => "receipt",
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
assert!(ok, "vector {} was written to {:?} and gives {:?}", v.name, v.intent, run.outcome());
|
||||
out.push((v, run));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- JSON ----
|
||||
|
||||
fn hexes(v: &[Vec<u8>]) -> Vec<String> {
|
||||
v.iter().map(|b| hex(b)).collect()
|
||||
}
|
||||
|
||||
fn opt_hex(d: &Option<[u8; 32]>) -> Value {
|
||||
d.map_or(Value::Null, |d| json!(hex(&d)))
|
||||
}
|
||||
|
||||
fn place_json(at: &At) -> Value {
|
||||
match at {
|
||||
At::Room(room::At::Descriptor) => json!(["room", "descriptor"]),
|
||||
At::Room(room::At::KeyLog) => json!(["room", "key_log"]),
|
||||
At::Room(room::At::Grant(i)) => json!(["room", "grant", i]),
|
||||
At::Room(room::At::Record { chain, record }) => json!(["room", "record", chain, record]),
|
||||
At::Policy => json!(["policy"]),
|
||||
At::Record { chain, record } => json!(["record", chain, record]),
|
||||
At::Receipt(k) => json!(["receipt", k]),
|
||||
}
|
||||
}
|
||||
|
||||
fn pod_json(pod: &[AdCandidate]) -> Value {
|
||||
Value::Array(pod.iter().map(|c| json!({"ad_id": c.ad_id, "creative_hash": hex(&c.creative_hash), "duration_ms": c.duration_ms, "price_micros": c.price_micros})).collect())
|
||||
}
|
||||
|
||||
fn pod_of(v: &Value) -> Option<Vec<AdCandidate>> {
|
||||
v.as_array()?
|
||||
.iter()
|
||||
.map(|c| {
|
||||
Some(AdCandidate {
|
||||
ad_id: c["ad_id"].as_str()?.into(),
|
||||
creative_hash: unhex(c["creative_hash"].as_str()?)?.try_into().ok()?,
|
||||
duration_ms: c["duration_ms"].as_u64()?,
|
||||
price_micros: c["price_micros"].as_u64()?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hls_file(name: &str) -> String {
|
||||
format!("{name}.m3u8")
|
||||
}
|
||||
|
||||
/// The HLS file of an accepted run: one tag a line.
|
||||
fn hls_body(run: &Run) -> String {
|
||||
run.hls.iter().map(|t| format!("{t}\n")).collect()
|
||||
}
|
||||
|
||||
/// What a vector states of its run (§9.4).
|
||||
fn run_json(name: &str, run: &Run) -> Map<String, Value> {
|
||||
let mut o = Map::new();
|
||||
match run.outcome() {
|
||||
Outcome::Refuse { at, reason } => {
|
||||
o.insert("outcome".into(), json!("refuse"));
|
||||
o.insert("reason".into(), json!(reason.code()));
|
||||
o.insert("at".into(), place_json(at));
|
||||
return o;
|
||||
}
|
||||
Outcome::Gap(g) => {
|
||||
o.insert("outcome".into(), json!("gap"));
|
||||
o.insert("gaps".into(), Value::Array(g.iter().map(|g| json!({"principal": hex(&g.principal), "first": g.first, "last": g.last})).collect()));
|
||||
return o;
|
||||
}
|
||||
Outcome::Accept => {
|
||||
o.insert("outcome".into(), json!("accept"));
|
||||
}
|
||||
}
|
||||
let p = run.policy.as_ref().expect("an accepted run has its policy");
|
||||
o.insert("room".into(), json!(hex(&run.room.expect("an accepted run has its room"))));
|
||||
o.insert(
|
||||
"breaks".into(),
|
||||
Value::Array(
|
||||
run.schedule
|
||||
.breaks
|
||||
.iter()
|
||||
.map(|b| {
|
||||
json!({
|
||||
"digest": hex(&b.digest),
|
||||
"id": break_id(&b.digest),
|
||||
"event_id": event_id(&b.digest),
|
||||
"author": hex(&b.author),
|
||||
"cue_id": b.cue_id,
|
||||
"t": b.t,
|
||||
"type": if b.cue_type == cue_type::OUT { "out" } else { "break" },
|
||||
"placement": if b.placement == placement::POST { "post" } else { "mid" },
|
||||
"planned_ms": b.planned_ms,
|
||||
"avail_ms": b.avail_ms,
|
||||
"duration_ms": b.duration_ms,
|
||||
"closed_by": opt_hex(&b.closed_by),
|
||||
"end": b.end,
|
||||
"hls_list_uri": hls_list_uri(p, &b.digest),
|
||||
"dash_list_uri": dash_list_uri(p, &b.digest),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
o.insert(
|
||||
"chapters".into(),
|
||||
Value::Array(run.schedule.chapters.iter().map(|c| json!({"digest": hex(&c.digest), "t": c.t, "duration_ms": c.duration_ms})).collect()),
|
||||
);
|
||||
o.insert("hls".into(), json!(hls_file(name)));
|
||||
o.insert("dash".into(), json!(run.dash));
|
||||
o.insert("dash_open".into(), Value::Array(run.dash_open.iter().map(|(i, e)| json!({"break": i, "event": e})).collect()));
|
||||
o.insert(
|
||||
"receipt_facts".into(),
|
||||
Value::Array(
|
||||
run.receipts
|
||||
.iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"identity": hex(&r.identity),
|
||||
"asset_list": String::from_utf8(r.asset_list.clone()).expect("an asset list is UTF-8"),
|
||||
"spend_micros": r.spend_micros,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
o.insert("spend_micros".into(), json!(run.spend_micros));
|
||||
o
|
||||
}
|
||||
|
||||
fn vector_json(v: &Vector, run: &Run) -> Value {
|
||||
let chains: Vec<Vec<String>> = v.room.chains().iter().map(|c| hexes(c)).collect();
|
||||
let mut o = Map::new();
|
||||
o.insert("name".into(), json!(v.name));
|
||||
o.insert("about".into(), json!(v.about));
|
||||
o.insert("descriptor".into(), json!(hex(&v.room.descriptor)));
|
||||
o.insert("grants".into(), json!(hexes(&v.room.grants)));
|
||||
o.insert("key_log".into(), json!(v.room.head));
|
||||
o.insert("chains".into(), json!(chains));
|
||||
o.insert("policy".into(), json!(hex(&v.policy)));
|
||||
o.insert("pt0_ms".into(), json!(v.pt0_ms));
|
||||
o.insert("receipts".into(), Value::Array(v.receipts.clone()));
|
||||
o.insert("pods".into(), Value::Array(v.pods.iter().map(|p| pod_json(p)).collect()));
|
||||
o.extend(run_json(v.name, run));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Write the corpus: `vectors.json` and each accepted room's HLS tags.
|
||||
pub fn generate() -> Vec<(String, String)> {
|
||||
let rooms = build();
|
||||
let seen: BTreeSet<&str> = rooms
|
||||
.iter()
|
||||
.filter_map(|(_, run)| match run.outcome() {
|
||||
Outcome::Refuse { reason, .. } => Some(reason.code()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for r in Reason::ALL {
|
||||
assert!(seen.contains(r.code()), "no vector gives {}", r.code());
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
for (v, run) in &rooms {
|
||||
if matches!(run.outcome(), Outcome::Accept) {
|
||||
files.push((hls_file(v.name), hls_body(run)));
|
||||
}
|
||||
}
|
||||
let principals: Map<String, Value> = PRINCIPALS.iter().map(|(n, i)| (n.to_string(), json!(hex(&pk(*i))))).collect();
|
||||
let doc = json!({
|
||||
"about": "extensions/live-ads.md §11: rooms whose cues open ad breaks, their ad policies, the HLS interstitials and DASH alternative-presentation events each break maps onto, each viewer's asset list and break-form wai.video.ssai receipt, and the spend against the decider grant's ceiling",
|
||||
"principals": principals,
|
||||
"vectors": rooms.iter().map(|(v, run)| vector_json(v, run)).collect::<Vec<_>>(),
|
||||
});
|
||||
let mut body = serde_json::to_string_pretty(&doc).expect("JSON");
|
||||
body.push('\n');
|
||||
files.push(("vectors.json".into(), body));
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Option<Vec<u8>> {
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
|
||||
}
|
||||
|
||||
fn hex_list(v: &Value) -> Option<Vec<Vec<u8>>> {
|
||||
v.as_array()?.iter().map(|h| unhex(h.as_str()?)).collect()
|
||||
}
|
||||
|
||||
/// Check a corpus's files against this module: every room gets the outcome and everything
|
||||
/// else it states, and every tag file is the run's. Returns the failures.
|
||||
pub fn verify(files: &BTreeMap<String, String>) -> Vec<String> {
|
||||
let mut fails = Vec::new();
|
||||
let Some(doc) = files.get("vectors.json").and_then(|s| serde_json::from_str::<Value>(s).ok()) else {
|
||||
return vec!["vectors.json does not read".into()];
|
||||
};
|
||||
for v in doc["vectors"].as_array().into_iter().flatten() {
|
||||
let name = v["name"].as_str().unwrap_or("?").to_string();
|
||||
let parsed = (|| {
|
||||
let chains: Vec<Vec<Vec<u8>>> = v["chains"].as_array()?.iter().map(hex_list).collect::<Option<_>>()?;
|
||||
let pods: Vec<Vec<AdCandidate>> = v["pods"].as_array()?.iter().map(pod_of).collect::<Option<_>>()?;
|
||||
Some((
|
||||
unhex(v["descriptor"].as_str()?)?,
|
||||
hex_list(&v["grants"])?,
|
||||
v["key_log"].as_str().map(String::from),
|
||||
chains,
|
||||
unhex(v["policy"].as_str()?)?,
|
||||
v["pt0_ms"].as_u64()?,
|
||||
v["receipts"].as_array()?.clone(),
|
||||
pods,
|
||||
))
|
||||
})();
|
||||
let Some((descriptor, grants, head, chains, policy, pt0_ms, receipts, pods)) = parsed else {
|
||||
fails.push(format!("vector {name}: its inputs do not read"));
|
||||
continue;
|
||||
};
|
||||
let run = super::verify(&Input { descriptor: &descriptor, grants: &grants, head: head.as_deref(), chains: &chains, policy: &policy, pt0_ms, receipts: &receipts, pods: &pods });
|
||||
let want = run_json(&name, &run);
|
||||
for (k, w) in &want {
|
||||
if v.get(k) != Some(w) {
|
||||
fails.push(format!("vector {name}: {k} is {w}, the corpus states {}", v.get(k).unwrap_or(&Value::Null)));
|
||||
}
|
||||
}
|
||||
if matches!(run.outcome(), Outcome::Accept) && files.get(&hls_file(&name)) != Some(&hls_body(&run)) {
|
||||
fails.push(format!("vector {name}: {} differs from the tags the room writes", hls_file(&name)));
|
||||
}
|
||||
}
|
||||
fails
|
||||
}
|
||||
|
|
@ -25,6 +25,15 @@
|
|||
//! Links to [`crate::wai_video_manifest`] by `manifest_hash` and chains from the
|
||||
//! manifest receipt via `parent_receipt_hash`: the ad break belongs to a
|
||||
//! specific attested manifest. Reuse, not reinvention.
|
||||
//!
|
||||
//! That is the receipt's first form, stitched. Its second form,
|
||||
//! [`SsaiBreakReceipt`] (extensions/live-ads.md §8), names a break of a room's
|
||||
//! schedule instead of an avail of a manifest, with the asset list one viewer
|
||||
//! was served for it: server-guided insertion, where the manifest stays shared
|
||||
//! and the decision is made behind the asset list. The two forms share the
|
||||
//! decision, the impression, the pod hash, the Merkle root, the billable spend
|
||||
//! and the identity domain, and sign under different domains. A stitched
|
||||
//! receipt's bytes are unchanged.
|
||||
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
|
|
@ -34,7 +43,7 @@ use crate::quantum_ops::GrantRef;
|
|||
// capability's public surface is unchanged.
|
||||
pub use crate::wai_video_policy::{decide, AdCandidate, Impression};
|
||||
use crate::wai_video_policy::billable_spend;
|
||||
use crate::energy_class::{figure_and_class_cohere, write_optional};
|
||||
use crate::energy_class::{figure_and_class_cohere, write_optional, Uncertainty};
|
||||
use crate::wai_video_manifest::EnergyProvenance;
|
||||
use crate::world::receipt::merkle_root;
|
||||
|
||||
|
|
@ -250,6 +259,412 @@ impl SsaiReceipt {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Form 2: a break of a room's schedule (extensions/live-ads.md §8)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
/// The signing domain of a form-2 receipt (live-ads §8.4). Its first bytes are no
|
||||
/// stitched payload's, so no signature of one form is a signature of the other.
|
||||
const DOMAIN_BREAK_RECEIPT: &[u8] = b"wai:video-ssai-break-receipt\x01";
|
||||
|
||||
/// The asset list a form-2 receipt binds (live-ads §7.2, §7.3). Rust-only: its constants
|
||||
/// must not become macros in include/wai.h.
|
||||
/// cbindgen:ignore
|
||||
pub mod asset_format {
|
||||
/// The HLS asset list of `draft-pantos-hls-rfc8216bis-22` Appendix D.2.
|
||||
pub const HLS: u8 = 1;
|
||||
/// The DASH list MPD of ISO/IEC 23009-1:2026.
|
||||
pub const DASH: u8 = 2;
|
||||
}
|
||||
|
||||
/// What a form-2 receipt names of its break and of the decision it sealed
|
||||
/// (live-ads §8.2): everything but the impressions and the signer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BreakBinding {
|
||||
/// The room id.
|
||||
pub room: [u8; 32],
|
||||
/// The break's digest: the digest of the cue that opened it (live-ads §4.4).
|
||||
pub cue: [u8; 32],
|
||||
/// The playback session, as the decider names it.
|
||||
pub session: [u8; 16],
|
||||
/// The viewer's offset into the break, 0 for a viewer present at its start.
|
||||
pub start_offset_ms: u64,
|
||||
/// [`asset_format`].
|
||||
pub format: u8,
|
||||
/// SHA-256 of the asset list served, its exact bytes.
|
||||
pub asset_list_sha256: [u8; 32],
|
||||
/// The break's avail.
|
||||
pub avail_ms: u64,
|
||||
/// The policy's slots.
|
||||
pub slots: u64,
|
||||
/// The funds the decision ran with, in millionths of a minor unit.
|
||||
pub funds_remaining_micros: u64,
|
||||
}
|
||||
|
||||
/// Why a form-2 receipt fails its own rules (live-ads §8.5).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BreakReceiptFault {
|
||||
BadKey,
|
||||
BadSignature,
|
||||
/// `receipt_invalid`: a rule of §8.5 other than the signature.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// A `wai.video.ssai` receipt of form 2: one viewer's impressions in one break of a
|
||||
/// room, the decision's inputs and the asset list served (live-ads §8).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SsaiBreakReceipt {
|
||||
pub binding: BreakBinding,
|
||||
pub pod_hash: [u8; 32],
|
||||
pub impressions: Vec<Impression>,
|
||||
pub merkle_root: [u8; 32],
|
||||
/// Σ price over billable impressions, in millionths of a minor unit.
|
||||
pub budget_spent_micros: u64,
|
||||
pub work_decide_ops: u64,
|
||||
pub joules_micro: u64,
|
||||
pub energy_provenance: Option<EnergyProvenance>,
|
||||
/// The decider grant, named as live-rooms §4.5 names a grant.
|
||||
pub grant: GrantRef,
|
||||
pub signer_pubkey: [u8; 32],
|
||||
pub signer_id: String,
|
||||
pub sig: [u8; 64],
|
||||
}
|
||||
|
||||
/// Σ price over billable impressions, or `None` past `u64`.
|
||||
fn checked_billable_spend(impressions: &[Impression]) -> Option<u64> {
|
||||
impressions.iter().filter(|i| i.is_billable()).try_fold(0u64, |s, i| s.checked_add(i.price_micros))
|
||||
}
|
||||
|
||||
/// A spend in millionths of a minor unit within a ceiling in minor units (live-ads §3.4).
|
||||
pub fn within_ceiling(spend_micros: u64, ceiling_minor: u64) -> bool {
|
||||
spend_micros as u128 <= ceiling_minor as u128 * 1_000_000
|
||||
}
|
||||
|
||||
impl SsaiBreakReceipt {
|
||||
/// The signing payload (live-ads §8.4).
|
||||
pub fn signing_payload(&self) -> Vec<u8> {
|
||||
let b = &self.binding;
|
||||
let mut o = DOMAIN_BREAK_RECEIPT.to_vec();
|
||||
o.extend_from_slice(&b.room);
|
||||
o.extend_from_slice(&b.cue);
|
||||
o.extend_from_slice(&b.session);
|
||||
o.extend_from_slice(&b.start_offset_ms.to_be_bytes());
|
||||
o.push(b.format);
|
||||
o.extend_from_slice(&b.asset_list_sha256);
|
||||
o.extend_from_slice(&b.avail_ms.to_be_bytes());
|
||||
o.extend_from_slice(&b.slots.to_be_bytes());
|
||||
o.extend_from_slice(&b.funds_remaining_micros.to_be_bytes());
|
||||
o.extend_from_slice(&self.pod_hash);
|
||||
o.extend_from_slice(&self.merkle_root);
|
||||
o.extend_from_slice(&self.budget_spent_micros.to_be_bytes());
|
||||
o.extend_from_slice(&self.work_decide_ops.to_be_bytes());
|
||||
o.extend_from_slice(&self.joules_micro.to_be_bytes());
|
||||
write_optional(self.energy_provenance.as_ref(), &mut o);
|
||||
o.extend_from_slice(&self.grant.grant_hash);
|
||||
o.extend_from_slice(&(self.grant.capability.len() as u32).to_be_bytes());
|
||||
o.extend_from_slice(self.grant.capability.as_bytes());
|
||||
o.extend_from_slice(&self.grant.joule_ceiling_micro.to_be_bytes());
|
||||
match self.grant.funds_ceiling {
|
||||
Some(f) => {
|
||||
o.push(1);
|
||||
o.extend_from_slice(&f.to_be_bytes());
|
||||
}
|
||||
None => o.push(0),
|
||||
}
|
||||
o.extend_from_slice(&(self.impressions.len() as u64).to_be_bytes());
|
||||
for i in &self.impressions {
|
||||
o.extend_from_slice(&i.leaf());
|
||||
}
|
||||
o.extend_from_slice(&self.signer_pubkey);
|
||||
let id = self.signer_id.as_bytes();
|
||||
o.extend_from_slice(&(id.len() as u32).to_be_bytes());
|
||||
o.extend_from_slice(id);
|
||||
o
|
||||
}
|
||||
|
||||
/// Seal one viewer's impressions in one break. The Merkle root and the billable
|
||||
/// spend are computed here from the impressions.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn seal(
|
||||
signer: &SigningKey,
|
||||
signer_id: impl Into<String>,
|
||||
binding: BreakBinding,
|
||||
pod_hash: [u8; 32],
|
||||
impressions: Vec<Impression>,
|
||||
work_decide_ops: u64,
|
||||
joules_micro: u64,
|
||||
energy_provenance: Option<EnergyProvenance>,
|
||||
grant: GrantRef,
|
||||
) -> SsaiBreakReceipt {
|
||||
let leaves: Vec<[u8; 32]> = impressions.iter().map(Impression::leaf).collect();
|
||||
let mut r = SsaiBreakReceipt {
|
||||
binding,
|
||||
pod_hash,
|
||||
budget_spent_micros: checked_billable_spend(&impressions).expect("a spend within u64"),
|
||||
merkle_root: merkle_root(&leaves),
|
||||
impressions,
|
||||
work_decide_ops,
|
||||
joules_micro,
|
||||
energy_provenance,
|
||||
grant,
|
||||
signer_pubkey: signer.verifying_key().to_bytes(),
|
||||
signer_id: signer_id.into(),
|
||||
sig: [0u8; 64],
|
||||
};
|
||||
r.sig = signer.sign(&r.signing_payload()).to_bytes();
|
||||
r
|
||||
}
|
||||
|
||||
/// The receipt's own rules (live-ads §8.5), in their order.
|
||||
pub fn check(&self) -> Result<(), BreakReceiptFault> {
|
||||
if !matches!(self.binding.format, asset_format::HLS | asset_format::DASH) {
|
||||
return Err(BreakReceiptFault::Invalid);
|
||||
}
|
||||
crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).map_err(|e| match e {
|
||||
crate::sig::SigError::BadKey => BreakReceiptFault::BadKey,
|
||||
crate::sig::SigError::BadSignature => BreakReceiptFault::BadSignature,
|
||||
})?;
|
||||
let leaves: Vec<[u8; 32]> = self.impressions.iter().map(Impression::leaf).collect();
|
||||
if merkle_root(&leaves) != self.merkle_root || checked_billable_spend(&self.impressions) != Some(self.budget_spent_micros) {
|
||||
return Err(BreakReceiptFault::Invalid);
|
||||
}
|
||||
if let Some(ceiling) = self.grant.funds_ceiling
|
||||
&& !within_ceiling(self.budget_spent_micros, ceiling)
|
||||
{
|
||||
return Err(BreakReceiptFault::Invalid);
|
||||
}
|
||||
let slots_in_order = self.impressions.iter().enumerate().all(|(i, imp)| imp.slot == i as u64 && imp.quartiles_reached <= 5);
|
||||
if !slots_in_order || self.work_decide_ops < self.impressions.len() as u64 {
|
||||
return Err(BreakReceiptFault::Invalid);
|
||||
}
|
||||
#[allow(deprecated)] // the legacy label is refused in this form, so it is named here
|
||||
let legacy = matches!(self.energy_provenance, Some(EnergyProvenance::HwShunt));
|
||||
if legacy || !figure_and_class_cohere(self.joules_micro, self.energy_provenance.as_ref()) {
|
||||
return Err(BreakReceiptFault::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every rule of §8.5 holds.
|
||||
pub fn verify(&self) -> bool {
|
||||
self.check().is_ok()
|
||||
}
|
||||
|
||||
/// The receipt's identity (live-ads §8.4): the stitched form's identity domain, over
|
||||
/// this form's signing payload and signature.
|
||||
pub fn receipt_hash(&self) -> [u8; 32] {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(DOMAIN_RECEIPT_ID);
|
||||
h.update(&self.signing_payload());
|
||||
h.update(&self.sig);
|
||||
*h.finalize().as_bytes()
|
||||
}
|
||||
|
||||
/// The JSON carriage (live-ads §8.2), members in the order of its table.
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
use serde_json::{json, Map, Value};
|
||||
let b = &self.binding;
|
||||
let mut m = Map::new();
|
||||
m.insert("form".into(), json!(2));
|
||||
m.insert("room".into(), json!(hex(&b.room)));
|
||||
m.insert("cue".into(), json!(hex(&b.cue)));
|
||||
m.insert("session".into(), json!(hex(&b.session)));
|
||||
m.insert("start_offset_ms".into(), json!(b.start_offset_ms));
|
||||
m.insert("format".into(), json!(b.format));
|
||||
m.insert("asset_list_sha256".into(), json!(hex(&b.asset_list_sha256)));
|
||||
m.insert("avail_ms".into(), json!(b.avail_ms));
|
||||
m.insert("slots".into(), json!(b.slots));
|
||||
m.insert("funds_remaining_micros".into(), json!(b.funds_remaining_micros));
|
||||
m.insert("pod_hash".into(), json!(hex(&self.pod_hash)));
|
||||
let imps: Vec<Value> = self
|
||||
.impressions
|
||||
.iter()
|
||||
.map(|i| {
|
||||
json!({
|
||||
"slot": i.slot,
|
||||
"ad_id": i.ad_id,
|
||||
"creative_hash": hex(&i.creative_hash),
|
||||
"duration_ms": i.duration_ms,
|
||||
"price_micros": i.price_micros,
|
||||
"quartiles_reached": i.quartiles_reached,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
m.insert("impressions".into(), Value::Array(imps));
|
||||
m.insert("merkle_root".into(), json!(hex(&self.merkle_root)));
|
||||
m.insert("budget_spent_micros".into(), json!(self.budget_spent_micros));
|
||||
m.insert("work_decide_ops".into(), json!(self.work_decide_ops));
|
||||
m.insert("joules_micro".into(), json!(self.joules_micro));
|
||||
m.insert("energy_provenance".into(), self.energy_provenance.as_ref().map_or(Value::Null, |p| json!(p.label())));
|
||||
match &self.energy_provenance {
|
||||
Some(EnergyProvenance::OnChipCounter { uncertainty, filtering }) => {
|
||||
m.insert("energy_uncertainty".into(), json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us}));
|
||||
m.insert("energy_counter_filtering".into(), json!(filtering.label()));
|
||||
}
|
||||
Some(EnergyProvenance::CalibratedInstrument { uncertainty, calibration_ref }) => {
|
||||
m.insert("energy_uncertainty".into(), json!({"relative_ppm": uncertainty.relative_ppm, "window_us": uncertainty.window_us}));
|
||||
m.insert("energy_calibration_ref".into(), json!(calibration_ref));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
m.insert(
|
||||
"grant".into(),
|
||||
json!({
|
||||
"grant_hash": hex(&self.grant.grant_hash),
|
||||
"capability": self.grant.capability,
|
||||
"joule_ceiling_micro": self.grant.joule_ceiling_micro,
|
||||
"funds_ceiling": self.grant.funds_ceiling,
|
||||
}),
|
||||
);
|
||||
m.insert("signer_pubkey".into(), json!(hex(&self.signer_pubkey)));
|
||||
m.insert("signer_id".into(), json!(self.signer_id));
|
||||
m.insert("sig".into(), json!(hex(&self.sig)));
|
||||
Value::Object(m)
|
||||
}
|
||||
|
||||
/// Read the JSON carriage (live-ads §8.2): exactly its members, each of its type.
|
||||
/// `None` is `receipt_malformed`. The rules of §8.5 are [`SsaiBreakReceipt::check`]'s.
|
||||
pub fn from_json(v: &serde_json::Value) -> Option<SsaiBreakReceipt> {
|
||||
use serde_json::Value;
|
||||
let o = v.as_object()?;
|
||||
let label = match o.get("energy_provenance")? {
|
||||
Value::Null => None,
|
||||
Value::String(s) => Some(s.as_str()),
|
||||
_ => return None,
|
||||
};
|
||||
let mut want: Vec<&str> = BREAK_RECEIPT_MEMBERS.to_vec();
|
||||
match label {
|
||||
Some("OnChipCounter") => want.extend(["energy_uncertainty", "energy_counter_filtering"]),
|
||||
Some("CalibratedInstrument") => want.extend(["energy_uncertainty", "energy_calibration_ref"]),
|
||||
_ => {}
|
||||
}
|
||||
if o.len() != want.len() || !want.iter().all(|k| o.contains_key(*k)) {
|
||||
return None;
|
||||
}
|
||||
if o.get("form")?.as_u64()? != 2 {
|
||||
return None;
|
||||
}
|
||||
let uncertainty = || -> Option<Uncertainty> {
|
||||
let u = o.get("energy_uncertainty")?.as_object()?;
|
||||
if u.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
Some(Uncertainty { relative_ppm: u32::try_from(u.get("relative_ppm")?.as_u64()?).ok()?, window_us: u.get("window_us")?.as_u64()? })
|
||||
};
|
||||
let energy_provenance = match label {
|
||||
None => None,
|
||||
Some("ModelBased") => Some(EnergyProvenance::ModelBased),
|
||||
Some("Estimator") => Some(EnergyProvenance::Estimator),
|
||||
Some("OnChipCounter") => Some(EnergyProvenance::OnChipCounter {
|
||||
uncertainty: uncertainty()?,
|
||||
filtering: crate::energy_class::CounterFiltering::from_label(o.get("energy_counter_filtering")?.as_str()?)?,
|
||||
}),
|
||||
Some("CalibratedInstrument") => Some(EnergyProvenance::CalibratedInstrument {
|
||||
uncertainty: uncertainty()?,
|
||||
calibration_ref: o.get("energy_calibration_ref")?.as_str()?.to_string(),
|
||||
}),
|
||||
// The legacy label, and anything else, does not read in this form.
|
||||
Some(_) => return None,
|
||||
};
|
||||
let g = o.get("grant")?.as_object()?;
|
||||
if g.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let grant = GrantRef {
|
||||
grant_hash: unhex(g.get("grant_hash")?)?,
|
||||
capability: g.get("capability")?.as_str()?.to_string(),
|
||||
joule_ceiling_micro: g.get("joule_ceiling_micro")?.as_u64()?,
|
||||
funds_ceiling: match g.get("funds_ceiling")? {
|
||||
Value::Null => None,
|
||||
f => Some(f.as_u64()?),
|
||||
},
|
||||
};
|
||||
let mut impressions = Vec::new();
|
||||
for i in o.get("impressions")?.as_array()? {
|
||||
let i = i.as_object()?;
|
||||
if i.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
impressions.push(Impression {
|
||||
slot: i.get("slot")?.as_u64()?,
|
||||
ad_id: i.get("ad_id")?.as_str()?.to_string(),
|
||||
creative_hash: unhex(i.get("creative_hash")?)?,
|
||||
duration_ms: i.get("duration_ms")?.as_u64()?,
|
||||
price_micros: i.get("price_micros")?.as_u64()?,
|
||||
quartiles_reached: u8::try_from(i.get("quartiles_reached")?.as_u64()?).ok()?,
|
||||
});
|
||||
}
|
||||
let u = |k: &str| o.get(k)?.as_u64();
|
||||
Some(SsaiBreakReceipt {
|
||||
binding: BreakBinding {
|
||||
room: unhex(o.get("room")?)?,
|
||||
cue: unhex(o.get("cue")?)?,
|
||||
session: unhex(o.get("session")?)?,
|
||||
start_offset_ms: u("start_offset_ms")?,
|
||||
format: u8::try_from(u("format")?).ok()?,
|
||||
asset_list_sha256: unhex(o.get("asset_list_sha256")?)?,
|
||||
avail_ms: u("avail_ms")?,
|
||||
slots: u("slots")?,
|
||||
funds_remaining_micros: u("funds_remaining_micros")?,
|
||||
},
|
||||
pod_hash: unhex(o.get("pod_hash")?)?,
|
||||
impressions,
|
||||
merkle_root: unhex(o.get("merkle_root")?)?,
|
||||
budget_spent_micros: u("budget_spent_micros")?,
|
||||
work_decide_ops: u("work_decide_ops")?,
|
||||
joules_micro: u("joules_micro")?,
|
||||
energy_provenance,
|
||||
grant,
|
||||
signer_pubkey: unhex(o.get("signer_pubkey")?)?,
|
||||
signer_id: o.get("signer_id")?.as_str()?.to_string(),
|
||||
sig: unhex(o.get("sig")?)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The members every form-2 receipt carries (live-ads §8.2); a class with evidence adds
|
||||
/// its own.
|
||||
const BREAK_RECEIPT_MEMBERS: [&str; 21] = [
|
||||
"form",
|
||||
"room",
|
||||
"cue",
|
||||
"session",
|
||||
"start_offset_ms",
|
||||
"format",
|
||||
"asset_list_sha256",
|
||||
"avail_ms",
|
||||
"slots",
|
||||
"funds_remaining_micros",
|
||||
"pod_hash",
|
||||
"impressions",
|
||||
"merkle_root",
|
||||
"budget_spent_micros",
|
||||
"work_decide_ops",
|
||||
"joules_micro",
|
||||
"energy_provenance",
|
||||
"grant",
|
||||
"signer_pubkey",
|
||||
"signer_id",
|
||||
"sig",
|
||||
];
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Lowercase hexadecimal of exactly `N` bytes.
|
||||
fn unhex<const N: usize>(v: &serde_json::Value) -> Option<[u8; N]> {
|
||||
let s = v.as_str()?;
|
||||
if s.len() != 2 * N || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; N];
|
||||
for (i, o) in out.iter_mut().enumerate() {
|
||||
*o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -352,4 +767,122 @@ mod tests {
|
|||
let r = SsaiReceipt::seal(&key(1), "did:key:ssai", "avail-1", [9u8; 32], pod_hash(&pod()), imps, 3, 500, Some(crate::energy_class::test_on_chip()), g, None);
|
||||
crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify());
|
||||
}
|
||||
|
||||
// ---- form 2 (live-ads §8) ----
|
||||
|
||||
fn binding(funds: u64) -> BreakBinding {
|
||||
BreakBinding {
|
||||
room: [5u8; 32],
|
||||
cue: [6u8; 32],
|
||||
session: [7u8; 16],
|
||||
start_offset_ms: 0,
|
||||
format: asset_format::HLS,
|
||||
asset_list_sha256: [8u8; 32],
|
||||
avail_ms: 30_000,
|
||||
slots: 2,
|
||||
funds_remaining_micros: funds,
|
||||
}
|
||||
}
|
||||
|
||||
fn room_grant(funds_minor: Option<u64>) -> GrantRef {
|
||||
GrantRef { grant_hash: [3u8; 32], capability: "wai.live.room".into(), joule_ceiling_micro: u64::MAX, funds_ceiling: funds_minor }
|
||||
}
|
||||
|
||||
fn break_receipt(q: [u8; 2], funds_minor: Option<u64>, prov: Option<EnergyProvenance>, joules: u64) -> SsaiBreakReceipt {
|
||||
let mut imps = decide(&pod(), 2, 30_000, 80_000);
|
||||
imps[0].quartiles_reached = q[0];
|
||||
imps[1].quartiles_reached = q[1];
|
||||
SsaiBreakReceipt::seal(&key(9), "did:key:decider", binding(80_000), pod_hash(&pod()), imps, 3, joules, prov, room_grant(funds_minor))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_break_receipt_seals_verifies_and_reads_back_from_its_json() {
|
||||
for prov in [None, Some(EnergyProvenance::ModelBased), Some(crate::energy_class::test_on_chip())] {
|
||||
let joules = if prov.is_some() { 4_000_000 } else { 0 };
|
||||
let r = break_receipt([5, 0], Some(1), prov, joules);
|
||||
assert_eq!(r.check(), Ok(()));
|
||||
assert_eq!(r.budget_spent_micros, 40_000, "only the started ad bills");
|
||||
let back = SsaiBreakReceipt::from_json(&r.to_json()).expect("its own JSON reads");
|
||||
assert_eq!(back, r);
|
||||
assert_eq!(back.receipt_hash(), r.receipt_hash());
|
||||
}
|
||||
}
|
||||
|
||||
/// The forms sign under different domains: the same impressions sealed as a stitched
|
||||
/// receipt and as a break receipt have different payloads from their first bytes, and
|
||||
/// the stitched receipt's pinned identity is unchanged by the second form.
|
||||
#[test]
|
||||
fn the_two_forms_sign_under_different_domains() {
|
||||
let r = break_receipt([5, 5], None, None, 0);
|
||||
assert!(r.signing_payload().starts_with(b"wai:video-ssai-break-receipt\x01"));
|
||||
assert!(!r.signing_payload().starts_with(DOMAIN_RECEIPT));
|
||||
legacy_hwshunt_receipt_keeps_its_signed_bytes();
|
||||
}
|
||||
|
||||
/// The ceiling is in minor units and the spend in millionths of one (live-ads §3.4):
|
||||
/// 70 000 µ-units is within a ceiling of 1 minor unit and not within one of 0.
|
||||
#[test]
|
||||
fn a_break_receipt_over_its_grant_ceiling_is_invalid() {
|
||||
assert_eq!(break_receipt([1, 1], Some(1), None, 0).check(), Ok(()));
|
||||
assert_eq!(break_receipt([1, 1], Some(0), None, 0).check(), Err(BreakReceiptFault::Invalid));
|
||||
assert!(within_ceiling(1_000_000, 1) && !within_ceiling(1_000_001, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_rule_of_a_break_receipt_refuses() {
|
||||
let k = key(9);
|
||||
let reseal = |f: &dyn Fn(&mut SsaiBreakReceipt)| {
|
||||
let mut r = break_receipt([5, 1], None, None, 0);
|
||||
f(&mut r);
|
||||
r.sig = k.sign(&r.signing_payload()).to_bytes();
|
||||
r.check()
|
||||
};
|
||||
assert_eq!(reseal(&|_| {}), Ok(()));
|
||||
assert_eq!(reseal(&|r| r.binding.format = 3), Err(BreakReceiptFault::Invalid));
|
||||
assert_eq!(reseal(&|r| r.merkle_root[0] ^= 1), Err(BreakReceiptFault::Invalid));
|
||||
assert_eq!(reseal(&|r| r.budget_spent_micros += 1), Err(BreakReceiptFault::Invalid));
|
||||
assert_eq!(reseal(&|r| r.impressions.swap(0, 1)), Err(BreakReceiptFault::Invalid), "slots out of order");
|
||||
assert_eq!(reseal(&|r| r.work_decide_ops = 1), Err(BreakReceiptFault::Invalid));
|
||||
assert_eq!(
|
||||
reseal(&|r| {
|
||||
r.impressions[1].quartiles_reached = 6;
|
||||
r.merkle_root = merkle_root(&r.impressions.iter().map(Impression::leaf).collect::<Vec<_>>());
|
||||
}),
|
||||
Err(BreakReceiptFault::Invalid)
|
||||
);
|
||||
assert_eq!(reseal(&|r| r.joules_micro = 5), Err(BreakReceiptFault::Invalid), "a figure with no class");
|
||||
#[allow(deprecated)]
|
||||
let legacy = reseal(&|r| {
|
||||
r.joules_micro = 5;
|
||||
r.energy_provenance = Some(EnergyProvenance::HwShunt);
|
||||
});
|
||||
assert_eq!(legacy, Err(BreakReceiptFault::Invalid), "the legacy label");
|
||||
let mut r = break_receipt([5, 1], None, None, 0);
|
||||
r.sig[0] ^= 1;
|
||||
assert_eq!(r.check(), Err(BreakReceiptFault::BadSignature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_break_receipt_json_with_another_member_does_not_read() {
|
||||
let r = break_receipt([5, 0], None, None, 0);
|
||||
let good = r.to_json();
|
||||
let mut extra = good.clone();
|
||||
extra.as_object_mut().unwrap().insert("x".into(), serde_json::json!(1));
|
||||
assert!(SsaiBreakReceipt::from_json(&extra).is_none());
|
||||
let mut upper = good.clone();
|
||||
upper["room"] = serde_json::json!(hex(&[0xAB; 32]).to_uppercase());
|
||||
assert!(SsaiBreakReceipt::from_json(&upper).is_none(), "hexadecimal is lowercase");
|
||||
let mut legacy = good.clone();
|
||||
legacy["energy_provenance"] = serde_json::json!("HwShunt");
|
||||
assert!(SsaiBreakReceipt::from_json(&legacy).is_none());
|
||||
let mut form1 = good;
|
||||
form1["form"] = serde_json::json!(1);
|
||||
assert!(SsaiBreakReceipt::from_json(&form1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_break_receipt_signature_is_verified_strictly() {
|
||||
let r = break_receipt([5, 0], None, None, 0);
|
||||
crate::sig::assert_refuses_forgeries(&r, |r, k, s| (r.signer_pubkey, r.sig) = (k, s), |r| r.signing_payload(), |r| r.verify());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ fn every_decoded_output_states_a_sha256() {
|
|||
("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"),
|
||||
("ops-conformance", "integer operators: every operator, stage and graph vector, the lower_* decodes of the sets under sets/ among them, states its output integers in full or its refusal code, never a digest of them; certificates/ states each lowered set's SHA-256 (a parameter-set identity) and holds the lowered graphs as bytes, compared byte for byte; the output digests a capability decoded through its lowering reproduces (integer-operators §14.4) are stated by that capability's own corpus"),
|
||||
("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"),
|
||||
("live-conformance", "live events, rooms, takes, compositions, recordings, captions, metering, steering, moderation and watermark sessions: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, review policies, decisions, notices and the items' timelines with each statement-of-reasons export written byte for byte, watermark tokens, session records, recovered sequences and disclosures, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
|
||||
("live-conformance", "live events, rooms, takes, compositions, recordings, captions, metering, steering, moderation, watermark sessions and ads: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, review policies, decisions, notices and the items' timelines with each statement-of-reasons export written byte for byte, watermark tokens, session records, recovered sequences and disclosures, ad policies, breaks with their interstitial tags, events and asset lists written byte for byte and their impression receipts, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
|
||||
("http-conformance", "objects and head proofs for transport: no case decodes a payload, and the component a fetch case picks is stated by its SHA-256"),
|
||||
("cache-conformance", "cache rules: a driver, its controls and their reports, with no objects of its own"),
|
||||
("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"),
|
||||
|
|
|
|||
52
wai/wai-rs/tests/live_ads.rs
Normal file
52
wai/wai-rs/tests/live_ads.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! `wai.live.ads` (extensions/live-ads.md): the committed corpus `live-conformance/ads/`
|
||||
//! regenerates byte for byte from its fixed seeds, holds no other file, every case in it gets
|
||||
//! the outcome it states, and the extension's §10 lists exactly the reasons the reference
|
||||
//! reports. The generator itself asserts that the corpus gives every one of them.
|
||||
#![cfg(feature = "live_ads")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use wai::live_ads::{corpus, Reason};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn committed() -> BTreeMap<String, String> {
|
||||
let dir = wai_dir().join("live-conformance/ads");
|
||||
std::fs::read_dir(&dir)
|
||||
.expect("the committed corpus")
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||||
.filter(|(n, _)| n != "README.md")
|
||||
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_corpus_regenerates_from_its_seeds() {
|
||||
let files = committed();
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
|
||||
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
|
||||
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
|
||||
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_live_ads_vectors gen`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_case_gets_its_stated_outcome() {
|
||||
let fails = corpus::verify(&committed());
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// §10 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_reasons() {
|
||||
let text = std::fs::read_to_string(wai_dir().join("extensions/live-ads.md")).expect("the extension");
|
||||
let at = text.find("\n## 10. Reasons").expect("a §10");
|
||||
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
||||
let listed: Vec<String> =
|
||||
text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
|
||||
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
|
||||
assert_eq!(listed, want, "§10's table and Reason::ALL differ");
|
||||
}
|
||||
Loading…
Reference in a new issue