wai: the opportunity report and the statement check (wai.delivery.opportunity) #541

Merged
dcharlot merged 8 commits from wai/fast-statement-check into main 2026-10-29 22:16:45 -04:00
28 changed files with 11744 additions and 19 deletions

View file

@ -123,8 +123,10 @@ jobs:
# --confirm, its deprecated spelling),
# first in the archive's build (quickstart), then with every command-line feature that
# needs no system library, so --dry-run is held on generate, score and stage too, with
# Create's (app), so it is held on wai app export, and with the revenue share's
# (revenue_share), so it is held on wai contract create|accept and wai statement create.
# Create's (app), so it is held on wai app export, with the revenue share's
# (revenue_share), so it is held on wai contract create|accept and wai statement create, and
# with the opportunity report's (opportunity_report), so it is held on wai opportunity
# create and wai statement check.
- name: The catalogue's rules, and the command line through it
shell: bash
working-directory: wai/wai-rs
@ -141,3 +143,5 @@ jobs:
ran "$RUNNER_TEMP/cli-app.log"
cargo test --locked --no-default-features --features quickstart,revenue_share --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-share.log"
ran "$RUNNER_TEMP/cli-share.log"
cargo test --locked --no-default-features --features quickstart,opportunity_report --test cli_ops 2>&1 | tee "$RUNNER_TEMP/cli-opportunity.log"
ran "$RUNNER_TEMP/cli-opportunity.log"

View file

@ -200,6 +200,10 @@ on:
# test reads its sections. Its corpus is generated from the ledger corpus, under the paths
# above.
- 'wai/extensions/revenue-share.md'
# tests/opportunity_report.rs requires this extension's §13 to list the codes the reference
# reports, opportunity_report_verify.py reads that table, and the registry's wire identifiers
# test reads its sections. Its corpus is generated from fixed seeds, under the paths above.
- 'wai/extensions/opportunity-report.md'
# tests/stream_audit.rs requires this extension's §14 to list the codes the reference
# reports, stream_audit_verify.py reads that table, and the registry's wire identifiers test
# reads its sections.
@ -1160,7 +1164,7 @@ jobs:
name: independent verifiers · claims, JWP, key discovery and integer payloads (Python)
runs-on: linux-arm64
env:
WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging,conform_result,revenue_share
WAI_FEATURES: key_discovery,http,video_manifest,stage_measure,live_events,live_room,capture_take,live_compose,live_record,timed_text,access_services,live_metering,energy_steering,live_moderation,dred_exact,live_ads,live_provenance,synthesis,synthesis_mark,neural_int,exact_upscale,robustness,ai_usage,contribution,settlement,browser_sink,receipt_claims,takedown,influence,direction,co_performance,age_assurance,bearer_receipts,exploration,co_watch,telemetry_aggregate,exact_tonemap,splat_exact,live_watermark,derive_edge,sink_energy,key_release,stitched_presentation,prior_wire,scene_map,avatar_face,receipt_ledger,stream_audit,fleet,native_sink,platform_encode,remote_desktop,stream_termination,live_world,format_probe,live_messaging,conform_result,revenue_share,opportunity_report
WAI_PQ_FEATURES: synthesis,pq_signatures
WAI_RELEASE_FEATURES: dred_exact,synthesis_mark,robustness,avatar_face
steps:
@ -1255,7 +1259,7 @@ jobs:
--test stitched_presentation --test prior_wire --test scene_map --test receipt_ledger \
--test stream_audit --test fleet --test native_sink --test platform_encode --test remote_desktop \
--test stream_termination --test live_world --test format_probe --test live_messaging \
--test conform_result --test revenue_share
--test conform_result --test revenue_share --test opportunity_report
cargo test --release --no-run --no-default-features --features "$WAI_RELEASE_FEATURES" \
--test dred_exact --test dred_exact_measure --test synthesis_mark --test robustness \
--test avatar_face
@ -2014,6 +2018,29 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib wai_video_channel::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test revenue_share)
python3 -I live-conformance/revenue_share_verify.py live-conformance/share
# wai.delivery.opportunity (extensions/opportunity-report.md): the reference module's unit
# tests (the SCTE 35 subset reader against the standard's sample and damaged sections, the
# reported statement's JSON and CSV forms and dates, the report's and the check's reading and
# rules, the ratios); tests/opportunity_report.rs (vectors.json regenerates byte for byte
# from its fixed seeds and no other file is there; wai-rs gives every vector its stated
# outcome; every reason of §13 and every reused code refuses a vector; §13 lists exactly the
# reference's codes; the worked example, a channel's day whose reported statement
# under-reports one owner's programme, and the check that shows it, recomputed from the
# public records alone); then the corpus's second implementation, opportunity_report_verify.py,
# written from the extension's text and sharing no code with wai-rs, which verifies rooms
# with live_ads_verify.py and contracts, runs and signed statements with
# revenue_share_verify.py, reads every report, check, section and reported statement itself,
# recomputes every break, attribution, line, citation and flag, requires every mutated copy
# of the corpus to fail, and drops each of its own checks in turn and requires the corpus to
# catch the weakened verifier; standard library only, run isolated (-I).
- name: opportunity report · wai-rs and opportunity_report_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --lib opportunity_report::)
(cd wai-rs && cargo test --no-default-features --features "$WAI_FEATURES" --test opportunity_report)
python3 -I live-conformance/opportunity_report_verify.py live-conformance/opportunity
# wai.delivery.audit (extensions/stream-audit.md): the reference module's unit tests (the
# acknowledgement windows, chains and repeats of §4.2, the origin rule, the report's bytes,
# channel names) and tests/stream_audit.rs (vectors.json regenerates byte for byte from its
@ -2692,7 +2719,7 @@ jobs:
synthesis,pq_signatures sink_energy key_release stitched_presentation prior_wire \
prior_wire,neural_int scene_map avatar_face receipt_ledger stream_audit fleet native_sink \
platform_encode remote_desktop stream_termination live_world format_probe live_messaging \
conform_result revenue_share; do
conform_result revenue_share opportunity_report; do
echo "::group::$f"
cargo check --no-default-features --features "$f" --tests
echo "::endgroup::"

View file

@ -6,6 +6,35 @@ wire format may change and such changes are recorded here.
## Unreleased
### The opportunity report and the statement check: what a channel's records prove, beside what a platform reported
- **`wai.delivery.opportunity`** ([`extensions/opportunity-report.md`](extensions/opportunity-report.md)), a new
record. An **opportunity report**, `WOR1`, states for one party of a revenue-share contract and one period the ad
breaks that aired in the programmes of the splits it shares in: the breaks a room the contract names makes of its
signed cues (`wai.live.ads` §4.3), each cue's SCTE 35 section read for its id, duration and slots (a placement
opportunity's `sub_segments_expected`, or the room policy's slots) and held to the cue's summary, and the ad entries
of the channel's signed program-as-run that no cue covers; each placed at its start on the window that aired it and
attributed to the programme on air by the contract's map and rule. Each line, per split and class (cued,
scheduled), states the breaks, their seconds, their slots and those with none, with a citation digest of the cues,
policies and runs it came from. The report is signed by its issuer, and every figure recomputes from the records.
- **A reported statement**: a platform's figures for the period, per day or per programme, in a documented JSON or
CSV (RFC 4180) form; read, labelled reported, and never evidence.
- **A statement check**, `WCK1`: per period, day and programme, the opportunities, the reported impressions and
revenue, the implied fill (impressions per slot, in millionths) and revenue per slot; flags for a figure no break
stands behind and for a line far from the period's own rate at bounds the check states; and, where the operator's
signed revenue-share statement of the same party and period is given, its impressions and revenue (gross, or the
party's part) beside the reported ones.
- **Operations** `opportunity.create` (`wai opportunity create`), `opportunity.verify` (`wai opportunity verify`, a
report's or a check's document) and `statement.check` (`wai statement check`), in a build with the feature
`opportunity_report`, under the agent rules.
- **Conformance**: `live-conformance/opportunity/`, written by `wai_opportunity_vectors` from fixed seeds: a channel's
day of two owners, a room cueing breaks with every kind of section the text reads, the operator's signed
statements of the day, the reports of both owners and the operator, checks in JSON and CSV (one under-reporting an
owner's programme), and a refused vector for each reason. Its second implementation,
`live-conformance/opportunity_report_verify.py`, verifies rooms with `live_ads_verify.py` and contracts, runs and
statements with `revenue_share_verify.py`, and recomputes every break, line and flag itself.
- `revenue_share`'s run reader and asset rule are shared with the new module (`pub(crate)`); no record's bytes change.
### The revenue share: what each owner of a channel is owed, recomputable from the public records
- **`wai.delivery.revenue_share`** ([`extensions/revenue-share.md`](extensions/revenue-share.md)), a new record. An

View file

@ -187,6 +187,7 @@ and a role are fixed once registered.
| `wai.delivery.audit` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.fleet` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.ledger` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.opportunity` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.revenue_share` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.delivery.settlement` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.derive.crop` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
@ -381,6 +382,7 @@ and a role are fixed once registered.
| `WCA1` | magic | defined | live-messaging §10.3 |
| `WCD1` | magic | defined | live-compose §3.1 |
| `WCI1` | magic | defined | live-messaging §10.2 |
| `WCK1` | magic | defined | opportunity-report §10.1 |
| `WCL1` | magic | defined | live-recording §5.1 |
| `WCN1` | magic | defined | capture-binding §8.1 |
| `WCR1` | magic | defined | live-compose §8.1 |
@ -413,6 +415,7 @@ and a role are fixed once registered.
| `WMP1` | magic | defined | live-moderation §3.1 |
| `WMR1` | magic | defined | live-metering §4.1 |
| `WMS1` | magic | defined | live-moderation §4.2 |
| `WOR1` | magic | defined | opportunity-report §7.1 |
| `WPA1` | magic | defined | co-performance §7.3 |
| `WPC1` | magic | defined | ad-presentation §5.1 |
| `WPF1` | magic | defined | co-performance §5.6 |

View file

@ -1544,6 +1544,24 @@ not map, a contract an owner did not countersign, two contracts that overlap, an
invoices bill, a split whose shares do not sum, and a line no record covers. No payment and no
currency conversion: [`extensions/revenue-share.md`](extensions/revenue-share.md).
And for **checking a platform's reported figures against the opportunities a channel's own records
prove** — `wai.delivery.opportunity`: an opportunity report, `WOR1`, states for one party of a
revenue-share contract and one period the ad breaks that aired in the programmes of the splits it
shares in, each from the signed cues of a room the contract names (the schedule `wai.live.ads` makes
of them, its slots those an SCTE 35 placement opportunity in the cue's section states, or the room
policy's) or from an ad entry of the channel's signed program-as-run that no cue covers, placed at its
start on the window that aired it and attributed to the programme on air; per split and class it
states the breaks, their seconds and their slots, with a citation digest of the cues, policy and runs
they came from. A reported statement, the platform's own figures in a documented JSON or CSV form, is
read and never treated as evidence. A statement check, `WCK1`, sets them side by side for the period,
each day and each programme: the opportunities, the reported impressions and revenue, impressions and
revenue per slot, flags for a figure no break stands behind and for one far from the period's own
rate, and, where the operator's signed revenue-share statement of the same party and period is given,
its impressions and revenue beside the reported ones. A verifier refuses a cue whose section its
summary contradicts, a break no window places, two rooms' breaks at once, and a report or a check that
does not recompute. No parser of any platform's own format:
[`extensions/opportunity-report.md`](extensions/opportunity-report.md).
And for **auditing a stream another service delivers** — `wai.delivery.audit`: a content owner's
players, with nothing asked of the service that packages and delivers the stream over HTTP. For each
playback session a player makes a fresh key and signs with it settlement acknowledgements of every

View file

@ -6428,6 +6428,289 @@
"sdk": "wai.statement.verify"
}
},
{
"id": "opportunity.create",
"summary": "A party's opportunity report for a period: the ad breaks a channel's signed cues and program-as-run prove in the programmes it shares in, signed with the issuer key",
"cli": "wai opportunity create",
"aliases": [],
"usage": [
"wai opportunity create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F] [--yes] the report",
" (WOR1) of the party for [start, end), computed from a verification document without its report (the",
" contract, other contracts, runs and rooms), signed with the issuer key and written with its document to F",
" (<digest>.wai-opportunity.json) for `wai opportunity verify`; replacing a file waits for --yes"
],
"placement": "local",
"effect": "writes-project",
"dry_run": true,
"confirm": "when-replacing-a-file",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"inputs": {
"type": "string",
"format": "path",
"description": "A verification document without its report: contract, contracts, runs and rooms.",
"x-cli": {
"positional": true
}
},
"party": {
"type": "string",
"description": "The party's key, 64 hexadecimal digits: the operator or an owner.",
"x-cli": {
"flag": "--party"
}
},
"start": {
"type": "integer",
"description": "The period's start, microseconds since 1970.",
"x-cli": {
"flag": "--start"
}
},
"end": {
"type": "integer",
"description": "The period's end, after its start.",
"x-cli": {
"flag": "--end"
}
},
"key": {
"type": "string",
"format": "path",
"description": "The issuer key's file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
},
"out": {
"type": "string",
"format": "path",
"description": "The verification document's file (<digest>.wai-opportunity.json).",
"x-cli": {
"flag": "--out"
}
}
},
"required": [
"inputs",
"party",
"start",
"end",
"key"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"file",
"report",
"outcome"
],
"description": "The opportunity report (WOR1, hexadecimal), the file its verification document went to, and its outcome, recomputed here"
},
"stream": null,
"since": "0.3.0",
"deprecated": null,
"receipt": true,
"needs": {
"all": [
"opportunity_report"
]
},
"names": {
"cli": "wai opportunity create",
"mcp": null,
"c": "wai_opportunity_create",
"rust": "wai::opportunity::create",
"sdk": "wai.opportunity.create"
}
},
{
"id": "opportunity.verify",
"summary": "An opportunity report's or a statement check's verification document checked: every break, line and flag recomputed from the records, or the reason it is refused",
"cli": "wai opportunity verify",
"aliases": [],
"usage": [
"wai opportunity verify <document.json> the outcome of a report's or a check's verification document: each break",
" attributed, each line and flag recomputed; exit 1 with the reason on a refusal"
],
"placement": "local",
"effect": "reads",
"dry_run": false,
"confirm": "never",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"document": {
"type": "string",
"format": "path",
"description": "The verification document: a report's (contract, contracts, report, runs, rooms) or a check's (report, reported, statement, check).",
"x-cli": {
"positional": true
}
}
},
"required": [
"document"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"outcome"
],
"description": "An accepted report's outcome (opportunity-report §8.3: every break attributed, each line and the totals) or an accepted check's (§11.3: each line's opportunities, reported and signed figures and flags)"
},
"stream": null,
"since": "0.3.0",
"deprecated": null,
"receipt": false,
"needs": {
"all": [
"opportunity_report"
]
},
"names": {
"cli": "wai opportunity verify",
"mcp": null,
"c": "wai_opportunity_verify",
"rust": "wai::opportunity::verify",
"sdk": "wai.opportunity.verify"
}
},
{
"id": "statement.check",
"summary": "A platform's reported statement checked against an opportunity report and, when given, the operator's signed statement, signed with the issuer key",
"cli": "wai statement check",
"aliases": [],
"usage": [
"wai statement check <report.json> <reported> [--statement <document.json>] [--floor ppm] [--ceiling ppm] --key <file> [--out F] [--yes]",
" the check (WCK1) of a reported statement (JSON, or CSV for a file named .csv) beside a report's verification",
" document and a signed statement's, at the bounds given (500000 and 2000000 when not), signed with the issuer",
" key and written with its document to F (<digest>.wai-check.json); replacing a file waits for --yes"
],
"placement": "local",
"effect": "writes-project",
"dry_run": true,
"confirm": "when-replacing-a-file",
"route": null,
"doors": {
"cli": true,
"mcp": false,
"http": false
},
"toolset": "core",
"stability": "preview",
"input": {
"type": "object",
"properties": {
"report": {
"type": "string",
"format": "path",
"description": "A report's verification document, with its report.",
"x-cli": {
"positional": true
}
},
"reported": {
"type": "string",
"format": "path",
"description": "The reported statement: JSON, or CSV for a file named .csv.",
"x-cli": {
"positional": true
}
},
"statement": {
"type": "string",
"format": "path",
"description": "A signed revenue-share statement's verification document of the same party and period.",
"x-cli": {
"flag": "--statement"
}
},
"floor": {
"type": "integer",
"description": "The lower bound, in millionths of the period's rate (500000).",
"x-cli": {
"flag": "--floor"
}
},
"ceiling": {
"type": "integer",
"description": "The upper bound, in millionths of the period's rate (2000000).",
"x-cli": {
"flag": "--ceiling"
}
},
"key": {
"type": "string",
"format": "path",
"description": "The issuer key's file: 32 bytes, or 64 hexadecimal digits.",
"x-cli": {
"flag": "--key"
}
},
"out": {
"type": "string",
"format": "path",
"description": "The verification document's file (<digest>.wai-check.json).",
"x-cli": {
"flag": "--out"
}
}
},
"required": [
"report",
"reported",
"key"
],
"additionalProperties": false
},
"output": {
"type": "object",
"required": [
"file",
"check",
"outcome"
],
"description": "The statement check (WCK1, hexadecimal), the file its verification document went to, and its outcome, recomputed here"
},
"stream": null,
"since": "0.3.0",
"deprecated": null,
"receipt": true,
"needs": {
"all": [
"opportunity_report"
]
},
"names": {
"cli": "wai statement check",
"mcp": null,
"c": "wai_statement_check",
"rust": "wai::statement::check",
"sdk": "wai.statement.check"
}
},
{
"id": "ops.list",
"summary": "Every operation of this catalogue: its id, command line, effect and whether this build holds it",

View file

@ -0,0 +1,643 @@
# WAI Extension: Opportunity Report
> Status: Draft. Registers the record `wai.delivery.opportunity`: the ad opportunities a linear
> channel's own signed records prove, per content owner and per period, and the check of a
> platform's reported figures against them. An **opportunity report**, `WOR1`, states for one
> party of a [revenue-share](revenue-share.md) contract and one period the ad breaks that aired in
> the programmes of the splits it shares in: each break from the cues of a room the contract
> names ([live-ads](live-ads.md) §4.3, the SCTE 35 sections they carry read for their slots) or
> from an ad entry of the channel's signed program-as-run (`wai.video.channel`), placed at its
> start on a window that aired it and attributed to the programme on air; and, per split and class,
> the breaks, their seconds and their slots, with the records each line came from. A **reported
> statement** is what a platform says it counted: a documented JSON or CSV form of impressions and
> revenue for the period, per day or per programme, which this text reads and never treats as
> evidence. A **statement check**, `WCK1`, sets the report beside the reported figures: per line,
> the opportunities, the reported impressions, the implied fill and the revenue per slot, the lines
> where a reported figure has no break behind it or lies far from the period's own rate, and, where
> the operator's signed revenue-share statement of the same party and period is given, the signed
> impressions and revenue beside the reported ones. Integer arithmetic throughout. No new
> transport, no payment, and no reading of any platform's own report format. The reference
> implementation is the `opportunity_report` module of `wai-rs` (feature `opportunity_report`);
> the corpus is [`live-conformance/opportunity/`](../live-conformance/opportunity/), and its second
> implementation is `live-conformance/opportunity_report_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: the WAI texts this one cites; ANSI/SCTE 35-1 2023r2 through the
> fields cue-injection §5 writes (its §9.6 to §9.9 and §10.2 and §10.3, as that text read them);
> ISO/IEC 13818-1 Annex A (the CRC); RFC 4180 (the CSV form of §9.2); RFC 8259; RFC 8032;
> FIPS 180-4.
>
> Why a text of its own, beside revenue-share: a report rests on records the statement does not
> read (a room's cues and the SCTE 35 sections they carry), needs no invoice, and is useful to an
> owner whose platform signs no statement; and revenue-share's records keep their bytes. Why the
> name: the record states the opportunities a channel's records prove, and names both of its
> parts, the report and the check, under one string.
## 1. Scope
In scope: the evidence a report rests on, the contract, the runs and the rooms (§3); breaks, from
a room's cues and from the program-as-run, their start, duration and slots (§4); the subset of an
SCTE 35 section this text reads, and its agreement with the cue that carries it (§5); attribution,
from a break's start to a split (§6); the report, its lines and citations (§7) and its
verification (§8); the reported statement, in JSON and in CSV (§9); the check, its lines, figures
and flags, and the signed statement beside it (§10), and its verification (§11); and the
operations (§12).
Out of scope: a transport; a parser of any platform's own report format, which turns that format
into §9's; paying an owner, which revenue-share leaves out too; an audience count, which no record
of this text states (§10.3); and the records this text reads, which their own extensions define,
verify and leave as they are. No existing record's signed bytes change.
Relationship to the core spec: `wai.delivery.opportunity` 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.
## 2. Terms
- **Contract**, **operator**, **owner**, **party**, **split**, **run**, **window**, **entry**,
**programme**, **carrier**, **ppm**, **time**, **period**: as revenue-share §2 and §3 define
them. Time is microseconds since 1970-01-01T00:00:00Z; a period is `[start, end)`.
- **Room**, **cue**, **break**, **open**, **closed**, **avail**: as live-ads §2 and §4.3.
- **Issuer**: the Ed25519 key (RFC 8032) that signs a report or a check: the owner itself, the
operator, or an auditor either chooses. A report's figures are recomputed from the records, so
an issuer's signature binds the issuer to what it states, and adds nothing a verifier trusts.
- **Section**: an SCTE 35-1 `splice_info_section()` a cue of form 1 carries (live-events §4.3).
- **Slots**: the ads one break holds (§4.4).
- **Day**: a UTC calendar date, written `YYYY-MM-DD`; its span is from its 00:00:00Z (included)
to the next date's (excluded).
- *u64*, *u32*, *u16*, *u8*: little-endian unsigned integers in this text's records; the bit fields
of a section are big-endian, as SCTE 35 writes them. A **digest** is SHA-256 (FIPS 180-4); zero
is 32 zero bytes. A *text* field is a u16 length and that many bytes of UTF-8.
## 3. The evidence
### 3.1 The contract
The contract is a revenue-share contract (revenue-share §3), in force, and verified as
revenue-share §8.2 steps 1 and 2 verify one, with the other contracts given for the overlap check.
It names the channel, the key that signs its runs, its carriers, its owners, its splits and the
map from each asset to a split, and the rule for an entry that is not a programme.
### 3.2 The runs
Each run is a `wai.video.channel` receipt in its JSON carriage (revenue-share §4.1), verified as
revenue-share §8.2 step 4 verifies one: it reads, it verifies, it is of the contract's channel and
signer, and its window shares no instant with the window of a run before it.
### 3.3 The rooms
Each room is given as a live-ads verifier is given one (live-ads §9.1), without receipts: its
`descriptor`, `grants`, `key_log` (the key-log head, or `null`), `chains` and `policy`. It is
verified as live-ads §9.2 steps 1 to 3 verify one: the room (live-rooms §9), the policy, and the
schedule its accepted cues make. A room with a gap has no schedule: a schedule read from part of
the cues is not the room's, and the room is refused `delivery_gap`. The room's id is a carrier of
kind 1 of the contract, or the room is refused `room_foreign`; a room whose id is that of a room
given before it is refused `room_repeated`.
So the cues are the room's own: each signed by a principal the room's grants permit `cue`, in its
chain, and held to live-ads' schedule rules. A cue an injector signed ([cue-injection](cue-injection.md))
is such a cue; its injection record rides in the same chain, and this text does not translate it
again (cue-injection §8 does).
## 4. Breaks
### 4.1 Cued breaks
Each break of a room's schedule (live-ads §4.3) is a **cued break**:
- its **start** is the room descriptor's `t0` plus the `t` of the cue that opens it, in
microseconds since 1970; a start that does not fit in a u64 lies outside every period;
- its **duration**, in milliseconds, is a closed break's duration (live-ads §4.3: an out's, from
its in, or a break's of type 4), and an open break's avail;
- its **opening cue** is the cue whose digest names it (live-ads §4.4), and its **closing cue**
the in that closed it, when one did;
- its **section** is the opening cue's, when the cue is of form 1 (§5).
### 4.2 Scheduled breaks
The runs are taken in ascending order of `window_start_ms`, and in each the entries of its `aired`
list in ascending order. An entry of kind `ad` is a **scheduled break**, once: an entry whose leaf
(revenue-share §4.2) is the leaf of an entry taken in an earlier run is the same break and is
passed over. Its start is the entry's `start_ms` × 1000, its duration the entry's `duration_ms`,
and its run and entry those it was taken in. An entry that airs in two windows is one break.
A scheduled break is **cued** when the start of a cued break of any room given lies within its
span, from its start (included) to its start plus its duration × 1000 (excluded). A cued scheduled
break is counted by its cued break, and is not a break of its own.
### 4.3 Classes, and the period
A break is in a report when its start lies within the report's period; a break outside it is
another period's, and is passed over. Every break of a report is of one **class**:
| class | name | the break |
|---|---|---|
| 1 | **cued** | a cued break (§4.1) |
| 2 | **scheduled** | a scheduled break no cued break counts (§4.2) |
A class is never summed into another: each has its own lines (§7.2).
### 4.4 Slots
A break's **slots**, and where they come from:
1. a cued break whose section states slots (§5.2): those;
2. another cued break: its room policy's `slots` (live-ads §3.1), the most ads one break holds;
3. a scheduled break: none. Its record states no slots, and it is **unslotted**.
A section that states slots is the content's own signal of the break's placements, and is taken
over the policy, which states the most a decision fills.
## 5. The section a cue carries
### 5.1 Reading
A section is read as SCTE 35-1 Table 5 lays it out, every field in its order and its width, and
refused `cue_section_invalid` at the first of these that holds:
1. it is shorter than 20 bytes, or its `CRC_32`, the last four bytes, is not the MPEG-2 CRC
(ISO/IEC 13818-1 Annex A: polynomial 0x04C11DB7, register preset to all ones, no reflection,
no final inversion) over every byte before them;
2. its `protocol_version` is not 0;
3. its `encrypted_packet` is 1: the section is read no further, and states nothing (§5.2);
4. its command, the `splice_command_length` bytes from byte 14 (after `splice_command_type`, byte
13), runs past the CRC; a `splice_command_length` of 0xFFF states no length, and the command's
length is then what step 5 reads of it, or, for a command step 5 does not read, the section
states nothing past its CRC check;
5. the command does not read as its table lays it out, filling exactly its length:
`splice_insert()` (type 0x05): `splice_event_id`, `splice_event_cancel_indicator` and, when it
is 0, the flags `out_of_network_indicator`, `program_splice_flag`, `duration_flag` and
`splice_immediate_flag`, then `splice_time()` when the program flag is 1 and the immediate flag
0, or `component_count` and each component with its `splice_time()` when the immediate flag is
0, when the program flag is 0; then `break_duration()` (`auto_return`, `duration`) when the
duration flag is 1; then `unique_program_id`, `avail_num` and `avails_expected`;
`time_signal()` (type 0x06): one `splice_time()`; `splice_null()` (type 0x00): nothing; a
`splice_time()` is one byte when its `time_specified_flag` is 0, and five, with `pts_time`,
when it is 1. Another command is not read;
6. `descriptor_loop_length` runs past the CRC, or the loop's last descriptor does not end where
the CRC begins; a descriptor (a tag, a length, that many bytes) runs past the loop;
7. a descriptor of tag 0x02 whose first four bytes are `CUEI` does not read as a
`segmentation_descriptor()` (SCTE 35-1 Table 20) fills its length: `segmentation_event_id`,
`segmentation_event_cancel_indicator`, and, when that is 0, `program_segmentation_flag`,
`segmentation_duration_flag`, `delivery_not_restricted_flag` and the restriction bits, the
components when the program flag is 0 (six bytes each), `segmentation_duration` (40 bits) when
flagged, `segmentation_upid_type`, `segmentation_upid_length` and the UPID,
`segmentation_type_id`, `segment_num` and `segments_expected`, then, for the types 0x30, 0x32,
0x34, 0x36, 0x38, 0x3A, 0x44 and 0x46, the two bytes `sub_segment_num` and
`sub_segments_expected` when two bytes remain. Any other count of bytes remaining refuses it.
A section is read only as far as this. Another descriptor is passed over whole.
### 5.2 What a section states
- **Its id**: a `splice_insert()`'s `splice_event_id`; a `time_signal()`'s opening descriptor's
`segmentation_event_id`. The **opening descriptor** is the first segmentation descriptor of the
section not cancelled whose `segmentation_type_id` makes an out (cue-injection §6.2): 0x22, 0x30,
0x32, 0x34 or 0x36.
- **Its duration**, in 90 kHz ticks: a `splice_insert()`'s `break_duration()` `duration`, when its
duration flag is 1; a `time_signal()`'s opening descriptor's `segmentation_duration`, when
flagged.
- **Its slots**: the `sub_segments_expected` of its first segmentation descriptor not cancelled
whose `segmentation_type_id` is a placement opportunity start, 0x34 or 0x36, when that
descriptor carries the sub-segment fields and `sub_segments_expected` is above 0.
A section states nothing more. An encrypted section, a command this text does not read, and a
`time_signal()` with no opening descriptor state no id and no duration; a form 2 message and a cue
of form 0 have no section.
### 5.3 The section and the cue's summary
A producer **MUST NOT** write a summary its message contradicts (live-events §4.3). An opening
cue's section is refused `cue_section_mismatch` when:
- it is a `splice_insert()` whose cancel indicator is 1, or whose `out_of_network_indicator` is 0:
it states a cancellation or a return, and the cue opens a break;
- it states an id that is not the cue's `cue_id`;
- it states a duration *d* ticks and ⌊*d* / 90⌋ is not the cue's `duration_ms`.
## 6. Attribution
A break is attributed as revenue-share §6 attributes an impression, by its start:
- a cued break: its **run** is the run whose window holds its start, and its **entry** the entry of
that run's schedule whose span holds it. One with no run, or a run and no entry, aired in no
window the verifier holds, and is refused `outside_window`;
- a scheduled break: its run and entry are those of §4.2.
The entry gives the **asset**: a programme's own `asset_id`, or, for an entry that is not a
programme, the asset the contract's rule gives (revenue-share §6.2), or none. A break with an asset
is attributed to the asset's split; an asset the contract does not map is refused
`asset_unmapped`. A break with no asset is attributed to the contract's between split.
## 7. The opportunity report
### 7.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WOR1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `issuer` | 32 | the key that signs the report |
| `contract` | 32 | the contract's digest (revenue-share §3.3) |
| `party` | 32 | the party it is for: the operator or an owner |
| `start`, `end` | 8 each | u64: the period it states |
| `rooms` | 2 + 32 × *m* | a u16 count, then each room's id, in strictly ascending order |
| `runs` | 2 + 32 × *r* | a u16 count, then each run's identity (revenue-share §4.2), in strictly ascending order |
| `lines` | 2 + 75 × *l* | a u16 count, then each line (§7.2), in strictly ascending order of (`split`, `class`) |
| `breaks` | 8 | u64: the sum of the lines' breaks |
| `break_ms` | 8 | u64: the sum of the lines' `break_ms` |
| `slots` | 8 | u64: the sum of the lines' slots |
| `sig` | 64 | §7.4 |
### 7.2 Lines
A line is 75 bytes:
| field | size | meaning |
|---|---|---|
| `split` | 2 | u16: the split's index |
| `class` | 1 | 1 cued, 2 scheduled (§4.3) |
| `breaks` | 8 | u64: the breaks of the class attributed to the split |
| `break_ms` | 8 | u64: the sum of their durations, in milliseconds |
| `slots` | 8 | u64: the sum of their slots |
| `unslotted` | 8 | u64: those of them with no slots (§4.4) |
| `ppm` | 4 | u32: the party's share of the split |
| `cited` | 4 | u32: the records the line cites (§7.3) |
| `cites` | 32 | their citation digest (§7.3) |
There is one line for each split of which the party holds a share (revenue-share §3.2) and each
class with at least one break attributed to that split, and none other. A report so states every
opportunity of every split the party shares in, and the share by which it shares it.
### 7.3 Citations
Each line cites the records its breaks came from: for a cued break, its opening cue and its
closing cue (kind 12, each by its event digest, live-events §3.2), its room's policy when its slots
are the policy's (kind 13, the policy's digest, live-ads §3.2), and its run (kind 10, its identity);
for a scheduled break, its run (kind 10). `cited` is their number, each record counted once, and
`cites` is SHA-256 over the bytes `wai:opportunity-cites` (ASCII) and 0x01, followed by each cited
record's `kind` (u8) and identity (32), in ascending order of (`kind`, identity). Kind 10 is
revenue-share §7.4's; 12 and 13 are this text's.
### 7.4 Digest and signature
The **digest** of a report is SHA-256 over its bytes before `sig`. Its signature is Ed25519 by
`issuer` over the bytes `wai:opportunity-report` (ASCII) and 0x01, followed by the bytes the digest
covers, 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`.
### 7.5 Reading and rules
A reader reads exactly the bytes it is given as one report, in the order of §7.1, and refuses at
the first of these that holds: fewer than 4 bytes, `truncated`; a magic other than `WOR1`,
`malformed`; no fifth byte, `truncated`; a version other than 1, `unknown_version`; the bytes end
before a field the layout requires, `truncated`; bytes after `sig`, `malformed`. It then refuses
as `opportunity_report_invalid` a report that breaks any of these:
- `start` is below `end`; `issuer` and `party` are not zero;
- `rooms`, `runs` and `lines` are each in the strictly ascending order §7.1 gives it;
- each line's `class` is 1 or 2, its `breaks` and `cited` above 0, its `unslotted` at most its
`breaks`, and its `ppm` 1 to 10^6.
## 8. Verifying a report
### 8.1 Inputs
A verifier is given the **contract**, the **other contracts** it holds of the operator or the
channel, the **report**, the **runs** and the **rooms**. Its verification document holds them as
one JSON object (RFC 8259) with exactly the members `contract` and `report` (hexadecimal),
`contracts` (a list of hexadecimal strings), `runs` (a list of runs in their carriage,
revenue-share §4.1) and `rooms` (a list of rooms, each an object with exactly the members
`descriptor` and `policy` (hexadecimal), `grants` (a list of hexadecimal strings), `key_log` (a
string or `null`) and `chains` (a list of lists of hexadecimal strings, each a principal's events
in order)). A document that is not such an object is refused `malformed`.
### 8.2 Steps
It runs these steps and stops at the first refusal, which it reports with the reason and where:
1. **The contract**, at `contract`, as revenue-share §8.2 step 1.
2. **The other contracts**, each at `contracts` and its index, as revenue-share §8.2 step 2
(`contract_overlap` among its reasons).
3. **The report**, at `report`: it reads (§7.5: `truncated`, `malformed`, `unknown_version`,
`opportunity_report_invalid`); its `contract` is the contract's digest and its `party` the
operator or an owner of the contract: otherwise `contract_mismatch`; its period lies within the
contract's: otherwise `outside_contract`; its signature verifies under its issuer (§7.4):
`bad_key`, `bad_signature`.
4. **The runs**, each at `run` and its index, as revenue-share §8.2 step 4 (`malformed`,
`run_invalid`, `run_foreign`, `run_overlap`).
5. **The rooms**, each at `room` and its index: it reads (`malformed`); it verifies (§3.3), a
refusal being live-ads' reason with live-ads' place after the index, and a gap
`delivery_gap`; its id is a carrier (`room_foreign`) and not that of a room before it
(`room_repeated`).
6. **The cued breaks** in the period (§4.3), the rooms in order and each room's breaks in its
schedule's order, each at `room`, the room's index, `break` and the break's index in its
room's schedule: its section, when it has one, reads (§5.1: `cue_section_invalid`) and agrees
with its opening cue (§5.3: `cue_section_mismatch`); its span shares an instant with the span
of a cued break in the period of a room before it: `break_overlap` (two rooms that carry the
channel at once would count one break twice); it is placed and attributed (§6:
`outside_window`, `asset_unmapped`).
7. **The scheduled breaks** in the period, in the order of §4.2, each at `run`, the run's index in
the document, `entry` and the entry's index: attributed (§6: `asset_unmapped`).
8. **The figures**, at `report`, recomputed as §7.2 and §7.3 compute them for the report's party,
and compared in this order:
1. a line whose split and class give the party no break, or that states more breaks than they
give: `line_unsourced`;
2. a line whose `cited` or `cites` is not the records it came from: `citation_mismatch`;
3. any other difference (the rooms, the runs, a line missing, a figure of a line, a total):
`opportunity_report_mismatch`.
A figure that cannot be held in its field is a difference.
### 8.3 Outcome
- **refuse**: the reason and where.
- **accept**: the report's digest; the contract's digest; the issuer; the party and whether it is
the operator or an owner; the period; the rooms and the runs; each break in the period, in the
order of steps 6 and 7, with its class, its room and its index there (a cued break) or its run
and entry (a scheduled break), its opening cue's digest or `null`, its start, its duration,
whether it is open, its slots and where they came from (`section`, `policy` or `null`), what its
section states (`null` without one), its run, entry and asset, its split, and the records it
cites; each line, with its cited records; and the totals.
A break of a split the party holds no share of is stated with the others, and counted in no line
of the party's: the outcome shows each party the whole channel's breaks in the period, and its
report states those it shares in.
## 9. The reported statement
A **reported statement** is what a platform reports it counted for a channel and a period. It is
an input, never a signed WAI record and never evidence: a check (§10) names it by the digest of its
bytes, and states it beside what the records prove. A platform's own report is turned into one of
these two forms outside this text.
### 9.1 JSON
One JSON object (RFC 8259) with exactly these members, and optionally `days` and `programmes`:
| member | value |
|---|---|
| `channel` | a string: the channel's id, 1 to 1024 bytes of UTF-8 |
| `start`, `end` | integers: the period, in microseconds since 1970 |
| `currency` | a string of three capitals `A`–`Z` (ISO 4217) |
| `basis` | `gross`, for revenue before the contract's split, or `share`, for the party's part |
| `impressions` | an integer: the impressions reported for the period |
| `revenue` | an integer: the revenue reported, an amount (receipt-ledger §2: millionths of a minor unit) |
| `days` | a list of objects, each with exactly `day` (a day, §2), `impressions` and `revenue` |
| `programmes` | a list of objects, each with exactly `asset` (an `asset_id`, 1 to 1024 bytes of UTF-8), `impressions` and `revenue` |
An integer is a JSON integer from 0 to 2^64 − 1. An empty list is the same as its member left out.
### 9.2 CSV
A text of RFC 4180 records, each ended by CRLF or LF (the last line break optional), each of
exactly four fields. A field is unquoted, holding no comma, double quote, CR or LF, or quoted: a
double quote, any bytes with each double quote doubled, and a double quote. The first record is
`scope,key,impressions,revenue`. Then, in this order:
- `channel`, `start`, `end`, `currency` and `basis`, each once, its value in `key` and the last two
fields empty;
- `period`, once, `key` empty, and the period's `impressions` and `revenue`;
- each `day`, its day in `key`, then each `programme`, its asset in `key`, each with its
`impressions` and `revenue`.
An integer is `0`, or a digit 1 to 9 followed by digits, at most 2^64 − 1. The text read is the
JSON form with the same values.
### 9.3 Rules
A reported statement is refused `reported_invalid` when its text is not one of these forms, or:
- its `start` is not below its `end`;
- a day is not a date of the Gregorian calendar from 1970 to 9999, or its span does not lie within
the period, or the days are not in strictly ascending order;
- the programmes are not in strictly ascending order of `asset`'s bytes.
It is refused `reported_foreign` when its `channel` is not the contract's, its `currency` not the
contract's, or its period not the report's.
## 10. The statement check
### 10.1 Layout
| field | size | meaning |
|---|---|---|
| `magic` | 4 | `WCK1` (ASCII) |
| `version` | 1 | 1 in this revision |
| `issuer` | 32 | the key that signs the check |
| `report` | 32 | the report's digest (§7.4) |
| `reported` | 32 | SHA-256 over the reported statement's bytes, as given |
| `form` | 1 | 1 JSON (§9.1), 2 CSV (§9.2) |
| `statement` | 32 | the digest of the signed revenue-share statement set beside it (revenue-share §7.5), or zero |
| `floor_ppm`, `ceiling_ppm` | 4 each | u32: the bounds of §10.3, in millionths of the period's rate |
| `lines` | 2 + … | a u16 count, then each line (§10.2), in strictly ascending order of (`scope`, `key`'s bytes) |
| `sig` | 64 | §10.5 |
### 10.2 Lines
| field | size | meaning |
|---|---|---|
| `scope` | 1 | 1 the period, 2 a day, 3 a programme |
| `key` | text | empty for the period; the day (`YYYY-MM-DD`); the programme's `asset_id` |
| `breaks`, `break_ms`, `slots`, `unslotted` | 8 each | u64: the opportunities of the scope (§10.3) |
| `impressions`, `revenue` | 8 each | u64: the reported figures of the scope |
| `fill_ppm` | 8 | u64: the implied fill (§10.3) |
| `revenue_per_slot` | 8 | u64: an amount (§10.3) |
| `signed` | 1 | 1 when signed figures follow, 0 otherwise |
| `signed_impressions`, `signed_revenue` | 8 each | only when `signed` is 1: §10.4 |
| `flags` | 1 | §10.3 and §10.4 |
There is one line of the period, and one line for each day and each programme the reported
statement lists, and none other.
### 10.3 Figures and flags
The **opportunities** of a line are the report's breaks in the period that are counted in a line
of the report's (§7.2), restricted to its scope: every one for the period; those whose start lies
within the day's span for a day; those whose asset (§6) is the programme's for a programme. A break
with no asset is in no programme's line. `breaks`, `break_ms`, `slots` and `unslotted` are their
count and sums, as §7.2 sums them.
- `fill_ppm` is ⌊`impressions` × 10^6 / `slots`⌋ and `revenue_per_slot` ⌊`revenue` / `slots`⌋,
computed in integers wide enough not to overflow, a quotient above 2^64 − 1 held as 2^64 − 1;
both are 0 when `slots` is 0. The implied fill is impressions per slot: a break's slots are
filled once per viewer, so it is the audience times the share of slots filled, and no record of
this text states an audience.
- The **rate** is the period line's `fill_ppm`.
A line's `flags` are these bits:
| bit | name | set when |
|---|---|---|
| 0 | `unproved` | `impressions` is above 0 and `breaks` is 0: a figure no break the records prove stands behind |
| 1 | `above` | not the period's line, its `slots` and the period's above 0, and `fill_ppm` × 10^6 > rate × `ceiling_ppm` |
| 2 | `below` | not the period's line, its `slots` and the period's above 0, and `fill_ppm` × 10^6 < rate × `floor_ppm` |
| 3 | `impressions_above_signed` | `signed` is 1 and `impressions` > `signed_impressions` |
| 4 | `impressions_below_signed` | `signed` is 1 and `impressions` < `signed_impressions` |
| 5 | `revenue_above_signed` | `signed` is 1 and `revenue` > `signed_revenue` |
| 6 | `revenue_below_signed` | `signed` is 1 and `revenue` < `signed_revenue` |
Every other bit is 0. `above` and `below` are relative to the period's own rate, at bounds the
issuer chooses and the check states: a day or programme far from the rest of the period. A
reported statement low in every line is low against nothing but the signed statement (§10.4).
### 10.4 The signed statement beside it
Where the operator's signed revenue-share statement of the report's contract, party and period is
given, verified as revenue-share §8 verifies it, the period's line is `signed`, and states:
- `signed_impressions`: the sum of the statement's lines' `impressions`;
- `signed_revenue`: for the basis `gross`, the sum of its lines' `revenue`; for `share`, its
`total`, the party's part.
So the three stand side by side: the opportunities the cues and runs prove, the impressions and
revenue the invoices' signed records prove, and the figures the platform reported. A day's or a
programme's line is never `signed`: the statement's lines are per split, and the party's part is
rounded once per line (revenue-share §7.3).
### 10.5 Digest and signature
The **digest** of a check is SHA-256 over its bytes before `sig`. Its signature is Ed25519 by
`issuer` over the bytes `wai:statement-check` (ASCII) and 0x01, followed by the bytes the digest
covers, verified strictly (SPEC §7 *Signatures*).
### 10.6 Reading and rules
A reader reads exactly the bytes it is given as one check, as §7.5 reads a report with `WCK1` for
`WOR1` (`truncated`, `malformed`, `unknown_version`), and refuses as `statement_check_invalid` one
that breaks any of these:
- `issuer` is not zero; `form` is 1 or 2; `floor_ppm` is at most 10^6 and `ceiling_ppm` at least
10^6;
- its first line is of scope 1 with an empty key, and no other line is of scope 1; the lines are in
the strictly ascending order §10.1 gives them; each `scope` is 1, 2 or 3; a day's key is a day
(§9.3) and a programme's 1 to 1024 bytes of UTF-8;
- `signed` is 0 or 1, and 1 only on the period's line; no bit of `flags` above bit 6 is set.
## 11. Verifying a check
### 11.1 Inputs
A check's verification document is one JSON object with exactly the members `report` (a report's
verification document, §8.1), `reported` (an object with exactly `form`, `json` or `csv`, and
`text`, the reported statement's text), `statement` (a revenue-share verification document,
revenue-share §8.1, or `null`) and `check` (hexadecimal). A document that is not such an object is
refused `malformed`. The reported statement's bytes are `text` in UTF-8.
### 11.2 Steps
It runs these steps and stops at the first refusal:
1. **The report**, at `report` and the place §8.2 gives: every reason of §8.
2. **The reported statement**, at `reported`: it reads in its form and keeps §9.3's rules
(`reported_invalid`), and is of the contract's channel and currency and the report's period
(`reported_foreign`).
3. **The signed statement**, when given, at `statement` and the place revenue-share §8.2 gives:
every reason of revenue-share §8; it is of the report's contract, party and period, or it is
refused `statement_foreign`.
4. **The check**, at `check`: it reads (§10.6: `truncated`, `malformed`, `unknown_version`,
`statement_check_invalid`); its signature verifies under its issuer (§10.5: `bad_key`,
`bad_signature`); and its `report`, `reported`, `form` and `statement` are those given and its
lines those §10.2 to §10.4 compute at its own bounds: otherwise `statement_check_mismatch`.
### 11.3 Outcome
- **refuse**: the reason and where.
- **accept**: the check's digest; the issuer; the report's digest, its party and period; the
reported statement's digest, form and basis; the signed statement's digest or `null`; the
bounds; and each line with its figures and the names of its flags.
## 12. Operations
`wai` runs the operations of this extension, under the agent rules of the operation catalogue (the
`--json` envelope, the exit codes, `--dry-run` on every operation that writes, `--yes` before a file
is replaced). A refusal by this text has its reason as the operation's code.
| operation | command line | does |
|---|---|---|
| `opportunity.create` | `wai opportunity create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F]` | the report of a party and a period, computed from a verification document without its report (§8.1), signed by the issuer key, written with its document |
| `opportunity.verify` | `wai opportunity verify <document.json>` | a report's (§8.1) or a check's (§11.1) verification document: its outcome; exit 1 with the reason on a refusal |
| `statement.check` | `wai statement check <report.json> <reported> [--statement <document.json>] [--floor ppm] [--ceiling ppm] --key <file> [--out F]` | the check of a reported statement (JSON, or CSV for a file named `.csv`) against a report's verification document and, when given, a signed statement's, at the bounds given (500000 and 2000000 when not), signed by the issuer key, written with its document |
## 13. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `room_foreign` | §3.3, §8.2 step 5 |
| `room_repeated` | §3.3, §8.2 step 5 |
| `cue_section_invalid` | §5.1, §8.2 step 6 |
| `cue_section_mismatch` | §5.3, §8.2 step 6 |
| `opportunity_report_invalid` | §7.5, §8.2 step 3 |
| `opportunity_report_mismatch` | §8.2 step 8 |
| `reported_invalid` | §9.3, §11.2 step 2 |
| `reported_foreign` | §9.3, §11.2 step 2 |
| `statement_foreign` | §11.2 step 3 |
| `statement_check_invalid` | §10.6, §11.2 step 4 |
| `statement_check_mismatch` | §11.2 step 4 |
A verifier of this extension also raises, with the meaning this text gives them: `truncated`,
`malformed` and `unknown_version` for a report or a check that does not read, and `malformed` for a
document or a room that does not read; `bad_key` and `bad_signature` for their signatures;
`contract_mismatch` and `outside_contract` (revenue-share §11) for a report of another contract or
past its period; `delivery_gap` (live-events §6.4) for a room with a gap; `break_overlap` (live-ads
§4.3) for the breaks of two rooms at once; `outside_window` and `asset_unmapped` (revenue-share §11)
for a break no window places and an asset the contract does not map; `line_unsourced` and
`citation_mismatch` for a line, as revenue-share raises them; and, at a contract, a run, a room or
a signed statement, every code revenue-share and live-ads raise there.
## 14. Conformance
[`live-conformance/opportunity/`](../live-conformance/opportunity/) holds the corpus, in one file,
`vectors.json`, written by `wai_opportunity_vectors gen` from fixed seeds. The contracts, runs,
rooms, ledger documents and statements are held once, by name, and each vector is a report's or a
check's verification document naming them, with the outcome §8.3 or §11.3 gives it:
- **Accepted**: a channel's day under a contract of two owners: two windows airing each owner's
programmes, a room whose producer cues breaks inside them, with SCTE 35 sections of every kind
§5 reads (a `splice_insert()` with a break duration, a `time_signal()` with a placement
opportunity stating its slots, one whose type states none, a `splice_insert()` carrying a
segmentation descriptor) and cues of form 0, a break inside an ad entry, and an ad entry no cue
covers; the reports of both owners and of the operator, and one made from the runs alone; and
checks of reported statements in JSON and in CSV, with and without the operator's signed
statement of the day, one under-reporting an owner's programme, one reporting impressions in a
programme with no break.
- **Refused**: each reason of §13 at least once, each vector a control of one defect; each code
§13 reuses; and a refusal at a contract, a run, a room and a signed statement, at its place.
`wai_opportunity_vectors verify` and the test `tests/opportunity_report.rs` check that the file
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that every reason of §13
refuses a vector, and that §13 lists exactly the reference's codes; the test also holds the worked
example, a channel's day of two owners whose reported statement under-reports one owner's breaks,
with the check that shows it, computed and then recomputed from the public records alone.
`live-conformance/opportunity_report_verify.py` is the second implementation, written from this
text: it shares no code with `wai-rs`, reads every report, check, section and reported statement
itself, verifies contracts, runs and signed statements with `revenue_share_verify.py` and rooms with
`live_ads_verify.py`, recomputes every break, attribution, line, citation and flag, gives every
vector the same outcome, requires every mutated copy of the corpus to fail, and drops each of its
own checks in turn and requires the corpus to catch the weakened verifier.
## 15. Security and privacy considerations
- **What a report proves.** A break in a report is one the room's own cues opened, signed by a
principal the room's grants permit to cue, or an ad entry the channel signer signed as aired,
placed in a window the channel signer signed. Nothing of it rests on the platform's figures or
on the issuer's word: anyone holding the records computes the same report.
- **What it does not.** Opportunities are per break, not per viewer: a report says what was on air
to be sold, not how many saw it. The fill of a check is therefore impressions per slot, and its
`above` and `below` flags are relative to the period's own rate. Only a signed statement states
signed impressions; where none is given, a reported statement uniformly low or high is not
flagged by the report alone.
- **What the operator still controls.** A room the contract does not name, or a channel carried
without cues or runs, proves no opportunity; the contract's carriers and its channel signer are
what the owners agreed to. Two rooms carrying the channel at once are refused rather than
counted twice (`break_overlap`), and so are a cue whose message its summary contradicts and a
break no window places.
- **The reported statement.** It is labelled and kept apart: a check names it by the digest of its
bytes, and no figure of it changes a report. A platform that disputes a check recomputes it from
the same records and its own reported bytes.
- **What a document reveals.** A report's document holds the room's chains, whose events name the
principals of the room and its other events (chat among them, live-events §4.4); a check's holds
a signed statement's, with the ledger's sources (revenue-share §13). Each travels between the
owner, the operator, the platform and an auditor they choose.

View file

@ -0,0 +1,87 @@
# live-conformance/opportunity — the opportunity report and the statement check
The corpus of [`extensions/opportunity-report.md`](../../extensions/opportunity-report.md) §14, in
one file, `vectors.json`:
- `contracts`: share contracts (revenue-share §3), by name, each hexadecimal;
- `runs`: the channel's program-as-run windows, by name, each in its carriage (revenue-share §4.1);
- `rooms`: rooms, by name, each as a report's document holds one (§8.1): its `descriptor`, `grants`,
`key_log`, `chains` and `policy`;
- `ledgers`: ledger documents (receipt-ledger §7.1), by name, each whole, for the signed
statements;
- `reported`: reported statements (§9), by name, each its `form` and `text`;
- `vectors`: verification documents, each of `kind` `report` (§8.1: the names of its `contract`,
other `contracts`, `runs` (or `null`) and `rooms`, and its `report` in hexadecimal) or `check`
(§11.1: such a `report`, the name of its `reported` statement, its signed `statement` (the name
of its contract, the statement in hexadecimal, the names of its runs and ledgers) or `null`, and
its `check` in hexadecimal), with the `outcome` §8.3 or §11.3 gives it;
- `keys`: the operator's, the two owners', the channel signer's, the auditor's, the room
producer's and the ad decider's public keys.
**Equivalence.** For each vector, a conforming verifier given its parts, resolved by name, reaches
the outcome the vector states:
- `refuse`: the `reason` and where, `at`: `[]` for the document; `["contract"]` and the places
revenue-share §8.2 gives; `["report"]`; `["run", k]`; `["room", k]`, followed by live-ads' own
place for a room live-ads refuses; `["room", k, "break", i]` for a cued break (its index in its
room's schedule); `["run", k, "entry", e]` for a scheduled break; for a check, `["report", …]`,
`["reported"]`, `["statement", …]` and `["check"]`;
- `accept`, for a report: its digest, the contract's, the `issuer`, the `party` and its `role`,
`start` and `end`, the `rooms` and `runs`, each break in the period (its class, its room and
index or the run and entry it was taken in, its opening `cue`, its `start`, `duration_ms`,
whether it is `open`, its `slots` and where they came from, what its `section` states, its run,
entry, asset and split, and the records it `cites`), each line (split, class, breaks,
`break_ms`, slots, unslotted, `ppm`, `cites`), and the `totals`;
- `accept`, for a check: its digest, the `issuer`, the report's digest, `party`, `start` and
`end`, the reported statement's digest, `form` and `basis`, the signed `statement`'s digest or
`null`, the bounds, and each line with its figures, its `signed` figures or `null`, and the names
of its `flags`.
The channel, `fast-9`, airs two windows of 2026-10-16: the first hour (owner A's `a/ep-1`, the ad
slot `slate-90`, owner B's `b/doc-1`) and the second (A's `a/ep-2`, the ad slot `mid-60`, B's
`b/doc-2`). Its contract maps each owner's programmes to a split of its own (a third to A and two
thirds to the operator; three fifths to B and two fifths to the operator), names a between split
of halves to the two owners, and the rule preceding. The room's producer cues eight breaks: an out
and its in carrying a `splice_insert()` with a break duration (slots from the room's policy, two),
breaks of type 4 carrying `time_signal()`s with provider and distributor placement opportunities
stating three or four slots, one of them inside `slate-90`, an out and its in with such a section,
a `splice_insert()` carrying a placement opportunity of three slots, a provider advertisement
start (a type that states no slots), and a cue of form 0. No cue covers `mid-60`, which is a
scheduled break, unslotted. The ledger document bills three viewers' receipts for every break,
two impressions each; the signed statements are the operator's, of the same day.
The accepted vectors:
- `owner_a_day`, `owner_b_day`, `operator_day`: the day's reports, each signed by the party it is
for;
- `owner_a_runs_alone`: A's day from the runs alone, both ad slots scheduled;
- `owner_b_two_rooms`: B's day under a contract naming a second room, whose break overlaps none;
- `check_owner_b_under_reported`: B's platform reports twelve impressions for `b/doc-1` and two for
`b/doc-2`, which holds as many slots: `b/doc-2` is `below` the day's rate, and the day is below
the signed statement's twenty-four impressions and B's signed part;
- `check_owner_a_csv`: A's reported statement in CSV, per day, as the signed statement states it:
nothing flagged;
- `check_owner_b_without_statement`, `check_owner_b_reports_a_programme`: B's report alone beside
the same figures, and beside figures listing A's episode, which is `unproved` (no break of B's
aired in it).
Every other vector is refused, a control of one defect: each reason of §13, each code it reuses,
and a refusal at a contract, a run, a room and a signed statement, at its place.
**Provenance.** Generated here: `wai_opportunity_vectors gen` writes `vectors.json` from fixed
seeds (the operator, the owners, the channel signer, the auditor, the buyer, and the room's
authority, clock, producer and decider). Nothing here was fetched from outside the repository.
Regenerate and check:
cd wai/wai-rs
cargo run -q --no-default-features --features opportunity_report --bin wai_opportunity_vectors -- gen ../live-conformance/opportunity
cargo run -q --no-default-features --features opportunity_report --bin wai_opportunity_vectors -- verify ../live-conformance/opportunity
cargo test -q --no-default-features --features opportunity_report --test opportunity_report
python3 -I ../live-conformance/opportunity_report_verify.py ../live-conformance/opportunity
The second implementation, `opportunity_report_verify.py`, shares no code with `wai-rs`. It reads
every report, check, section and reported statement itself, verifies contracts, runs and signed
statements with `revenue_share_verify.py` and rooms with `live_ads_verify.py`, recomputes every
break, attribution, line, citation and flag, requires every mutated copy of the corpus to fail, and
requires the corpus to catch it with each of its own checks dropped.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -404,6 +404,20 @@
"edition": null,
"registered": "2026-10-25"
},
{
"capability": "wai.delivery.opportunity",
"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-29"
},
{
"capability": "wai.delivery.revenue_share",
"kind": "record",
@ -2765,6 +2779,13 @@
"section": "§10.2",
"status": "defined"
},
{
"name": "WCK1",
"kind": "magic",
"document": "extensions/opportunity-report.md",
"section": "§10.1",
"status": "defined"
},
{
"name": "WCL1",
"kind": "magic",
@ -2989,6 +3010,13 @@
"section": "§4.2",
"status": "defined"
},
{
"name": "WOR1",
"kind": "magic",
"document": "extensions/opportunity-report.md",
"section": "§7.1",
"status": "defined"
},
{
"name": "WPA1",
"kind": "magic",

View file

@ -373,6 +373,13 @@ wai:wai.delivery.ledger a wai:Capability ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-25"^^xsd:date .
wai:wai.delivery.opportunity a wai:Capability ;
rdfs:label "wai.delivery.opportunity" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-29"^^xsd:date .
wai:wai.delivery.revenue_share a wai:Capability ;
rdfs:label "wai.delivery.revenue_share" ;
wai:kind wai:kind-record ;

View file

@ -513,6 +513,14 @@ receipt_ledger = ["live_metering", "settlement", "live_ads", "video_package", "s
# revenue divided by the largest-remainder rule. SHA-256, Ed25519 and the sources' own readers; no
# new dependency.
revenue_share = ["receipt_ledger", "video_channel"]
# Opportunity report (extensions/opportunity-report): `wai.delivery.opportunity` — the ad breaks a
# channel's own signed records prove, per owner and period (WOR1): the cues of a room a share
# contract names (live_ads, the SCTE 35 sections they carry read for their slots) and the ad
# entries of the channel's program-as-run, each attributed to the programme on air; and the check
# (WCK1) of a platform's reported figures, in a documented JSON or CSV form, against the report and
# the operator's signed revenue-share statement. SHA-256, Ed25519 and the sources' own readers; no
# new dependency.
opportunity_report = ["revenue_share"]
# Stream audit (extensions/stream-audit): `wai.delivery.audit` — what a content owner gathers from the
# players of a stream another service delivers over HTTP: settlement acknowledgements of every object
# by plain SHA-256 (settlement §14), one chain per origin, under a fresh session key; presentation
@ -1313,6 +1321,11 @@ name = "wai_revenue_share_vectors"
path = "src/bin/wai_revenue_share_vectors.rs"
required-features = ["revenue_share"]
[[bin]]
name = "wai_opportunity_vectors"
path = "src/bin/wai_opportunity_vectors.rs"
required-features = ["opportunity_report"]
[[bin]]
name = "wai_fleet_vectors"
path = "src/bin/wai_fleet_vectors.rs"
@ -2329,6 +2342,11 @@ name = "revenue_share"
path = "tests/revenue_share.rs"
required-features = ["revenue_share"]
[[test]]
name = "opportunity_report"
path = "tests/opportunity_report.rs"
required-features = ["opportunity_report"]
[[test]]
name = "fleet"
path = "tests/fleet.rs"

View file

@ -24,7 +24,11 @@
//! - `contract.create`, `contract.update` (`wai contract create|accept`) and `statement.create`,
//! `statement.verify` (`wai statement create|verify`): a channel's share contract and the
//! revenue-share statements recomputed from the ledger's invoices and the channel's
//! program-as-run (extensions/revenue-share.md §10; `share.rs`).
//! program-as-run (extensions/revenue-share.md §10; `share.rs`);
//! - `opportunity.create`, `opportunity.verify` (`wai opportunity create|verify`) and
//! `statement.check` (`wai statement check`): the ad opportunities a channel's signed records
//! prove for an owner, and a platform's reported figures checked against them
//! (extensions/opportunity-report.md §12; `opportunity.rs`).
//!
//! [`envelope`] holds the agent rules every command keeps: the exit codes, the JSON envelope,
//! `--dry-run`, `--yes`.
@ -45,6 +49,7 @@ mod dev;
pub(crate) mod envelope;
mod fleet;
mod hello;
mod opportunity;
mod share;
mod verify;
@ -83,6 +88,10 @@ pub const VERBS: &[Impl] = &[
Impl { id: "contract.update", run: share::contract_update, json: Json::Native },
Impl { id: "statement.create", run: share::statement_create, json: Json::Native },
Impl { id: "statement.verify", run: share::statement_verify, json: Json::Native },
// The opportunity report and the statement check (extensions/opportunity-report.md §12).
Impl { id: "opportunity.create", run: opportunity::opportunity_create, json: Json::Native },
Impl { id: "opportunity.verify", run: opportunity::opportunity_verify, json: Json::Native },
Impl { id: "statement.check", run: opportunity::statement_check, json: Json::Native },
];
/// An operation's synopsis, from the catalogue, for a usage error.

View file

@ -0,0 +1,187 @@
//! The opportunity report's operations (extensions/opportunity-report.md §12): `wai opportunity
//! create` (`opportunity.create`), `wai opportunity verify` (`opportunity.verify`) and `wai
//! statement check` (`statement.check`), on files on this machine.
//!
//! They keep the agent rules of [`super::envelope`], as every operation of the catalogue does:
//! the exit codes, the JSON envelope with `--json`, `--dry-run` on the two that write (every file
//! through `out.rs`, nothing written), and `--yes` before a file that exists is replaced. A
//! refusal by the extension's text has its reason as the code (`cue_section_mismatch`,
//! `reported_foreign`, ...), at exit 1.
#[cfg(feature = "opportunity_report")]
use super::envelope as agent;
use super::envelope::{EXIT_FAILED, OpError};
#[cfg(feature = "opportunity_report")]
use super::share::{done, hex, io_fail, number, read_json, read_key, required, write};
#[cfg(feature = "opportunity_report")]
use crate::out;
#[cfg(feature = "opportunity_report")]
use serde_json::{Value, json};
type R = Result<bool, OpError>;
/// A refusal by the extension's text, its reason as the code.
#[cfg_attr(not(feature = "opportunity_report"), allow(dead_code))]
fn refused(what: &str, reason: &'static str, at: &serde_json::Value) -> OpError {
OpError::new(EXIT_FAILED, reason, format!("{what} is refused: {reason} at {at}"), "extensions/opportunity-report.md §13 names each reason")
}
/// A key of 64 hexadecimal digits.
#[cfg(feature = "opportunity_report")]
fn key_arg(v: &str, flag: &str, usage: &str) -> Result<[u8; 32], OpError> {
(v.len() == 64 && v.bytes().all(|c| c.is_ascii_hexdigit()))
.then(|| (0..32).map(|i| u8::from_str_radix(&v[2 * i..2 * i + 2], 16).expect("hex")).collect::<Vec<u8>>())
.and_then(|b| b.try_into().ok())
.ok_or_else(|| OpError::usage(format!("{flag}: a key, 64 hexadecimal digits"), usage))
}
/// A report's outcome, for a person: its party, its lines and its totals.
#[cfg(feature = "opportunity_report")]
fn summarise(o: &Value) -> String {
let d = o["report"].as_str().unwrap_or("");
let party = o["party"].as_str().unwrap_or("");
let mut t = format!(
"opportunity report {}… of the {} {}… for [{}, {}): {} break(s), {} ms, {} slot(s)",
&d[..16.min(d.len())],
o["role"].as_str().unwrap_or("?"),
&party[..16.min(party.len())],
o["start"],
o["end"],
o["totals"]["breaks"],
o["totals"]["break_ms"],
o["totals"]["slots"],
);
for l in o["lines"].as_array().into_iter().flatten() {
t.push_str(&format!("\n split {} {:<9} {} break(s), {} ms, {} slot(s), {} unslotted, share {} ppm", l["split"], l["class"].as_str().unwrap_or(""), l["breaks"], l["break_ms"], l["slots"], l["unslotted"], l["ppm"]));
}
t
}
/// A check's outcome, for a person: each line with its figures and flags.
#[cfg(feature = "opportunity_report")]
fn summarise_check(o: &Value) -> String {
let d = o["check"].as_str().unwrap_or("");
let r = o["report"].as_str().unwrap_or("");
let mut t = format!(
"statement check {}… of report {}… beside a reported statement ({}, {}){}",
&d[..16.min(d.len())],
&r[..16.min(r.len())],
o["form"].as_str().unwrap_or("?"),
o["basis"].as_str().unwrap_or("?"),
if o["statement"].is_null() { "" } else { " and the signed statement" },
);
for l in o["lines"].as_array().into_iter().flatten() {
let flags: Vec<&str> = l["flags"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
t.push_str(&format!(
"\n {:<9} {:<12} {} slot(s), {} impression(s) reported, fill {} ppm{}{}",
l["scope"].as_str().unwrap_or(""),
l["key"].as_str().unwrap_or(""),
l["slots"],
l["impressions"],
l["fill_ppm"],
l["signed"].get("impressions").map(|s| format!(", {s} signed")).unwrap_or_default(),
if flags.is_empty() { String::new() } else { format!(" [{}]", flags.join(", ")) },
));
}
t
}
#[cfg(not(feature = "opportunity_report"))]
pub fn opportunity_create(_: &[String]) -> R {
Err(crate::door::not_built(wai::catalogue::op("opportunity.create").expect("listed")))
}
/// `wai opportunity create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F]`:
/// the report of a party and a period from a document without its report (§8.1), signed by the
/// issuer key and written with its document.
#[cfg(feature = "opportunity_report")]
pub fn opportunity_create(args: &[String]) -> R {
use wai::opportunity_report as opp;
let usage = super::usage("opportunity.create");
let with_value = ["--party", "--start", "--end", "--key", "--out"];
let pos = agent::check_args(args, &with_value, &with_value, 1, &usage)?;
let path = *pos.first().ok_or_else(|| OpError::usage("the inputs: a report's verification document without its report", &usage))?;
let party = key_arg(required(args, "--party", "the party's key: 64 hexadecimal digits", &usage)?, "--party", &usage)?;
let (start, end) = (number(args, "--start", &usage)?, number(args, "--end", &usage)?);
let key = read_key(required(args, "--key", "the issuer key's file", &usage)?, "the issuer key's file (32 bytes, or 64 hexadecimal digits)")?;
let inputs = read_json(path, "the report's inputs")?;
let (r, doc) = opp::create(&inputs, &key, party, start, end).map_err(|(reason, at)| refused("the report", reason, &at))?;
let o = opp::outcome_json(&opp::verify(&doc));
let file = super::opt(args, "--out").map(str::to_string).unwrap_or_else(|| format!("{}.wai-opportunity.json", &hex(&r.digest())[..16]));
write(&file, (serde_json::to_string_pretty(&doc).expect("JSON") + "\n").as_bytes(), "the report's document")?;
let text = format!("{}{}", summarise(&o), if out::dry_run() { String::new() } else { format!("\nwritten to {file}; check it anywhere with `wai opportunity verify {file}`") });
done("opportunity.create", true, json!({"file": file, "report": hex(&r.to_bytes()), "outcome": o}), &text)
}
#[cfg(not(feature = "opportunity_report"))]
pub fn opportunity_verify(_: &[String]) -> R {
Err(crate::door::not_built(wai::catalogue::op("opportunity.verify").expect("listed")))
}
/// `wai opportunity verify <document.json>`: a report's (§8.1) or a check's (§11.1) document,
/// verified; a refusal is exit 1 with its reason as the code.
#[cfg(feature = "opportunity_report")]
pub fn opportunity_verify(args: &[String]) -> R {
use wai::opportunity_report as opp;
let usage = super::usage("opportunity.verify");
let pos = agent::check_args(args, &[], &[], 1, &usage)?;
let path = *pos.first().ok_or_else(|| OpError::usage("the verification document", &usage))?;
let doc = read_json(path, "the verification document")?;
if opp::is_check_document(&doc) {
let a = opp::verify_check(&doc).map_err(|(r, at)| refused("the check", r, &at))?;
let o = opp::check_json(&a);
let text = summarise_check(&o);
return done("opportunity.verify", false, o, &text);
}
let a = opp::verify(&doc).map_err(|(r, at)| refused("the report", r, &at))?;
let o = opp::accepted_json(&a);
let text = summarise(&o);
done("opportunity.verify", false, o, &text)
}
#[cfg(not(feature = "opportunity_report"))]
pub fn statement_check(_: &[String]) -> R {
Err(crate::door::not_built(wai::catalogue::op("statement.check").expect("listed")))
}
/// A bound in millionths.
#[cfg(feature = "opportunity_report")]
fn ppm(args: &[String], flag: &str, default: u32, usage: &str) -> Result<u32, OpError> {
match super::opt(args, flag) {
None => Ok(default),
Some(v) => v.parse().map_err(|_| OpError::usage(format!("{flag} {v}: millionths, an integer"), usage)),
}
}
/// `wai statement check <report.json> <reported> [--statement <document.json>] [--floor ppm]
/// [--ceiling ppm] --key <file> [--out F]`: the check of a reported statement (§9; CSV for a file
/// named `.csv`) against a report's document and, when given, a signed statement's (§10), signed
/// by the issuer key and written with its document.
#[cfg(feature = "opportunity_report")]
pub fn statement_check(args: &[String]) -> R {
use wai::opportunity_report as opp;
let usage = super::usage("statement.check");
let with_value = ["--statement", "--floor", "--ceiling", "--key", "--out"];
let pos = agent::check_args(args, &with_value, &with_value, 2, &usage)?;
let (report_path, reported_path) = match pos.as_slice() {
[a, b] => (*a, *b),
_ => return Err(OpError::usage("the report's document and the reported statement", &usage)),
};
let key = read_key(required(args, "--key", "the issuer key's file", &usage)?, "the issuer key's file (32 bytes, or 64 hexadecimal digits)")?;
let (floor, ceiling) = (ppm(args, "--floor", opp::FLOOR_PPM, &usage)?, ppm(args, "--ceiling", opp::CEILING_PPM, &usage)?);
let report = read_json(report_path, "the report's verification document")?;
let raw = std::fs::read(reported_path).map_err(|e| io_fail(reported_path, &e, "the reported statement"))?;
let text = String::from_utf8(raw).map_err(|_| OpError::usage(format!("{reported_path}: not UTF-8 text"), &usage))?;
let form = if reported_path.ends_with(".csv") { "csv" } else { "json" };
let statement = match super::opt(args, "--statement") {
Some(p) => read_json(p, "the signed statement's verification document")?,
None => Value::Null,
};
let inputs = json!({"report": report, "reported": {"form": form, "text": text}, "statement": statement});
let (c, doc) = opp::create_check(&inputs, &key, floor, ceiling).map_err(|(reason, at)| refused("the check", reason, &at))?;
let o = opp::check_outcome_json(&opp::verify_check(&doc));
let file = super::opt(args, "--out").map(str::to_string).unwrap_or_else(|| format!("{}.wai-check.json", &hex(&c.digest())[..16]));
write(&file, (serde_json::to_string_pretty(&doc).expect("JSON") + "\n").as_bytes(), "the check's document")?;
let text = format!("{}{}", summarise_check(&o), if out::dry_run() { String::new() } else { format!("\nwritten to {file}; check it anywhere with `wai opportunity verify {file}`") });
done("statement.check", true, json!({"file": file, "check": hex(&c.to_bytes()), "outcome": o}), &text)
}

View file

@ -22,7 +22,7 @@ type R = Result<bool, OpError>;
/// Print what an operation did: its envelope with `--json`, its text otherwise; in a dry run of
/// an operation that writes, with the plan of what it would have written.
#[cfg_attr(not(feature = "revenue_share"), allow(dead_code))]
fn done(op: &str, writes: bool, data: Value, text: &str) -> R {
pub(super) fn done(op: &str, writes: bool, data: Value, text: &str) -> R {
let plan = (writes && out::dry_run()).then(out::plan);
if agent::ctx().json {
agent::print(&agent::envelope_with(op, true, data, plan));
@ -40,7 +40,7 @@ fn done(op: &str, writes: bool, data: Value, text: &str) -> R {
/// A file that could not be read or written.
#[cfg_attr(not(feature = "revenue_share"), allow(dead_code))]
fn io_fail(path: &str, e: &std::io::Error, hint: &str) -> OpError {
pub(super) fn io_fail(path: &str, e: &std::io::Error, hint: &str) -> OpError {
match e.kind() {
std::io::ErrorKind::NotFound => OpError::new(EXIT_NOT_FOUND, "not-found", format!("{path}: {e}"), hint),
std::io::ErrorKind::PermissionDenied => OpError::new(EXIT_PERMISSION, "permission-denied", format!("{path}: {e}"), "the file's owner runs this"),
@ -55,13 +55,13 @@ fn refused(what: &str, reason: &'static str, at: &Value) -> OpError {
}
#[cfg_attr(not(feature = "revenue_share"), allow(dead_code))]
fn hex(b: &[u8]) -> String {
pub(super) fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// A key file: 32 bytes, or 64 hexadecimal digits.
#[cfg(feature = "revenue_share")]
fn read_key(path: &str, what: &str) -> Result<ed25519_dalek::SigningKey, OpError> {
pub(super) fn read_key(path: &str, what: &str) -> Result<ed25519_dalek::SigningKey, OpError> {
let raw = std::fs::read(path).map_err(|e| io_fail(path, &e, what))?;
let seed: [u8; 32] = if raw.len() == 32 {
raw.try_into().expect("32")
@ -78,14 +78,14 @@ fn read_key(path: &str, what: &str) -> Result<ed25519_dalek::SigningKey, OpError
}
#[cfg(feature = "revenue_share")]
fn read_json(path: &str, what: &str) -> Result<Value, OpError> {
pub(super) fn read_json(path: &str, what: &str) -> Result<Value, OpError> {
let b = std::fs::read(path).map_err(|e| io_fail(path, &e, what))?;
serde_json::from_slice(&b).map_err(|e| OpError::usage(format!("{path}: not JSON: {e}"), what))
}
/// Write `bytes` to `file`, asking first when it exists; in a dry run, plan it.
#[cfg(feature = "revenue_share")]
fn write(file: &str, bytes: &[u8], what: &str) -> Result<(), OpError> {
pub(super) fn write(file: &str, bytes: &[u8], what: &str) -> Result<(), OpError> {
if !out::dry_run() && std::path::Path::new(file).exists() {
agent::confirm(&format!("{file} exists, and {what} would replace it"), vec![json!({"write": file, "bytes": bytes.len(), "replaces": true})])?;
}
@ -93,12 +93,12 @@ fn write(file: &str, bytes: &[u8], what: &str) -> Result<(), OpError> {
}
#[cfg(feature = "revenue_share")]
fn required<'a>(args: &'a [String], flag: &str, what: &str, usage: &str) -> Result<&'a str, OpError> {
pub(super) fn required<'a>(args: &'a [String], flag: &str, what: &str, usage: &str) -> Result<&'a str, OpError> {
super::opt(args, flag).ok_or_else(|| OpError::usage(format!("{flag}: {what}"), usage))
}
#[cfg(feature = "revenue_share")]
fn number(args: &[String], flag: &str, usage: &str) -> Result<u64, OpError> {
pub(super) fn number(args: &[String], flag: &str, usage: &str) -> Result<u64, OpError> {
let v = required(args, flag, "microseconds since 1970", usage)?;
v.parse().map_err(|_| OpError::usage(format!("{flag} {v}: microseconds since 1970, an integer"), usage))
}

View file

@ -0,0 +1,61 @@
//! Conformance corpus for the opportunity report (`live-conformance/opportunity/`,
//! extensions/opportunity-report.md §14).
//!
//! wai_opportunity_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_opportunity_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! every vector gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second implementation is
//! `live-conformance/opportunity_report_verify.py`, written from the extension's text and sharing
//! no code with this binary.
use std::path::Path;
use wai::opportunity_report::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)) => {
let p = Path::new(d).join("vectors.json");
let text = match corpus::generate() {
Ok(t) => t,
Err(e) => {
eprintln!("generate: {e}");
std::process::exit(1);
}
};
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, text)) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_opportunity_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let p = dir.join("vectors.json");
let doc = match std::fs::read_to_string(&p) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", p.display());
return 1;
}
};
let mut fail = 0;
let same = corpus::generate().is_ok_and(|g| g == doc);
println!("{} vectors.json regenerates byte for byte from its seeds", if same { "PASS" } else { "FAIL" });
fail += (!same) as i32;
let fails = corpus::verify(&doc);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
(fail != 0) as i32
}

View file

@ -301,6 +301,10 @@ const S_APP_CHECK: &str = r#"{"type":"object","required":["program","errors","fa
const S_SHARE_CONTRACT: &str = r#"{"type":"object","required":["digest","operator","channel","owners","in_force","contract","file"],"description":"The share contract (WRC1, hexadecimal), its digest, each owner and whether it has countersigned, and the file it went to"}"#;
const S_SHARE_STATEMENT: &str = r#"{"type":"object","required":["file","statement","outcome"],"description":"The statement (WRS1, hexadecimal), the file its verification document went to, and its outcome, recomputed here"}"#;
const S_SHARE_OUTCOME: &str = r#"{"type":"object","required":["outcome","statement","contract","party","role","lines","total"],"description":"An accepted statement's outcome (revenue-share §8.3): its lines, each split's division, every impression attributed, and the total"}"#;
// The opportunity report's outputs (extensions/opportunity-report.md §12).
const S_OPPORTUNITY: &str = r#"{"type":"object","required":["file","report","outcome"],"description":"The opportunity report (WOR1, hexadecimal), the file its verification document went to, and its outcome, recomputed here"}"#;
const S_OPPORTUNITY_OUTCOME: &str = r#"{"type":"object","required":["outcome"],"description":"An accepted report's outcome (opportunity-report §8.3: every break attributed, each line and the totals) or an accepted check's (§11.3: each line's opportunities, reported and signed figures and flags)"}"#;
const S_STATEMENT_CHECK: &str = r#"{"type":"object","required":["file","check","outcome"],"description":"The statement check (WCK1, hexadecimal), the file its verification document went to, and its outcome, recomputed here"}"#;
const S_ENROLMENT: &str = r#"{"type":"object","required":["node","role","organisation","digest","enrolment"],"description":"The enrolment (WFE1, hexadecimal), its digest, and the file and service it went to"}"#;
const S_ENROLMENTS: &str = r#"{"type":"object","required":["enrolments"],"description":"Each node the fleet service holds enrolled: its key, role, label and records"}"#;
const S_REPORT: &str = r#"{"type":"object","required":["report","outcome"],"description":"The report's digest and its outcome as extensions/fleet.md §7.3 states it"}"#;
@ -1559,6 +1563,68 @@ http vectors diff|fuzz <dir> --seed S --count N --out F",
needs: Needs::All(&["revenue_share"]),
..OP
},
// ─── the opportunity report and the statement check (extensions/opportunity-report.md §12) ───
Op {
id: "opportunity.create",
summary: "A party's opportunity report for a period: the ad breaks a channel's signed cues and program-as-run prove in the programmes it shares in, signed with the issuer key",
cli: Some("opportunity create"),
usage: "opportunity create <inputs.json> --party <key> --start µs --end µs --key <file> [--out F] [--yes] the report
(WOR1) of the party for [start, end), computed from a verification document without its report (the
contract, other contracts, runs and rooms), signed with the issuer key and written with its document to F
(<digest>.wai-opportunity.json) for `wai opportunity verify`; replacing a file waits for --yes",
effect: Effect::WritesProject,
stability: Stability::Preview,
input: &[
pos("inputs", Ty::Path, true, "A verification document without its report: contract, contracts, runs and rooms."),
flag("party", "--party", Ty::String, true, "The party's key, 64 hexadecimal digits: the operator or an owner."),
flag("start", "--start", Ty::Integer, true, "The period's start, microseconds since 1970."),
flag("end", "--end", Ty::Integer, true, "The period's end, after its start."),
flag("key", "--key", Ty::Path, true, "The issuer key's file: 32 bytes, or 64 hexadecimal digits."),
flag("out", "--out", Ty::Path, false, "The verification document's file (<digest>.wai-opportunity.json)."),
],
output: Output::Json(S_OPPORTUNITY),
receipt: true,
needs: Needs::All(&["opportunity_report"]),
confirm: Some(Confirm::AnyFile),
..OP
},
Op {
id: "opportunity.verify",
summary: "An opportunity report's or a statement check's verification document checked: every break, line and flag recomputed from the records, or the reason it is refused",
cli: Some("opportunity verify"),
usage: "opportunity verify <document.json> the outcome of a report's or a check's verification document: each break
attributed, each line and flag recomputed; exit 1 with the reason on a refusal",
stability: Stability::Preview,
input: &[pos("document", Ty::Path, true, "The verification document: a report's (contract, contracts, report, runs, rooms) or a check's (report, reported, statement, check).")],
output: Output::Json(S_OPPORTUNITY_OUTCOME),
needs: Needs::All(&["opportunity_report"]),
..OP
},
Op {
id: "statement.check",
summary: "A platform's reported statement checked against an opportunity report and, when given, the operator's signed statement, signed with the issuer key",
cli: Some("statement check"),
usage: "statement check <report.json> <reported> [--statement <document.json>] [--floor ppm] [--ceiling ppm] --key <file> [--out F] [--yes]
the check (WCK1) of a reported statement (JSON, or CSV for a file named .csv) beside a report's verification
document and a signed statement's, at the bounds given (500000 and 2000000 when not), signed with the issuer
key and written with its document to F (<digest>.wai-check.json); replacing a file waits for --yes",
effect: Effect::WritesProject,
stability: Stability::Preview,
input: &[
pos("report", Ty::Path, true, "A report's verification document, with its report."),
pos("reported", Ty::Path, true, "The reported statement: JSON, or CSV for a file named .csv."),
flag("statement", "--statement", Ty::Path, false, "A signed revenue-share statement's verification document of the same party and period."),
flag("floor", "--floor", Ty::Integer, false, "The lower bound, in millionths of the period's rate (500000)."),
flag("ceiling", "--ceiling", Ty::Integer, false, "The upper bound, in millionths of the period's rate (2000000)."),
flag("key", "--key", Ty::Path, true, "The issuer key's file: 32 bytes, or 64 hexadecimal digits."),
flag("out", "--out", Ty::Path, false, "The verification document's file (<digest>.wai-check.json)."),
],
output: Output::Json(S_STATEMENT_CHECK),
receipt: true,
needs: Needs::All(&["opportunity_report"]),
confirm: Some(Confirm::AnyFile),
..OP
},
// ─── the catalogue itself ───
Op {
id: "ops.list",
@ -1629,6 +1695,7 @@ pub fn feature_on(feature: &str) -> Option<bool> {
"app" => cfg!(feature = "app"),
"fleet" => cfg!(feature = "fleet"),
"revenue_share" => cfg!(feature = "revenue_share"),
"opportunity_report" => cfg!(feature = "opportunity_report"),
_ => return None,
})
}

View file

@ -240,6 +240,7 @@ pub const CAP_DELIVERY_LEDGER: &str = "wai.delivery.ledger";
pub const CAP_DELIVERY_AUDIT: &str = "wai.delivery.audit";
pub const CAP_DELIVERY_FLEET: &str = "wai.delivery.fleet";
pub const CAP_DELIVERY_REVENUE_SHARE: &str = "wai.delivery.revenue_share";
pub const CAP_DELIVERY_OPPORTUNITY: &str = "wai.delivery.opportunity";
pub const CAP_MEDIA_TAKEDOWN: &str = "wai.media.takedown";
pub const CAP_LIVE_COPERFORMANCE: &str = "wai.live.coperformance";
pub const CAP_MEDIA_INFLUENCE: &str = "wai.media.influence";
@ -367,6 +368,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_DELIVERY_AUDIT, S_INTERACTIVE, None, "2026-10-25"),
(CAP_DELIVERY_FLEET, S_INTERACTIVE, None, "2026-10-27"),
(CAP_DELIVERY_REVENUE_SHARE, S_INTERACTIVE, None, "2026-10-29"),
(CAP_DELIVERY_OPPORTUNITY, S_INTERACTIVE, None, "2026-10-29"),
(CAP_MEDIA_TAKEDOWN, S_INTERACTIVE, None, "2026-10-21"),
(CAP_LIVE_COPERFORMANCE, S_INTERACTIVE, None, "2026-10-21"),
(CAP_MEDIA_INFLUENCE, S_INTERACTIVE, None, "2026-10-21"),
@ -461,6 +463,7 @@ const RECORDS: &[&str] = &[
CAP_DELIVERY_AUDIT,
CAP_DELIVERY_FLEET,
CAP_DELIVERY_REVENUE_SHARE,
CAP_DELIVERY_OPPORTUNITY,
CAP_MEDIA_TAKEDOWN,
CAP_LIVE_COPERFORMANCE,
CAP_MEDIA_INFLUENCE,
@ -660,6 +663,7 @@ const LEDGER: &str = "extensions/receipt-ledger.md";
const STREAM_AUDIT: &str = "extensions/stream-audit.md";
const FLEET: &str = "extensions/fleet.md";
const REVENUE_SHARE: &str = "extensions/revenue-share.md";
const OPPORTUNITY: &str = "extensions/opportunity-report.md";
const BROWSER_SINK: &str = "extensions/browser-sink.md";
const TAKEDOWN: &str = "extensions/takedown.md";
const INFLUENCE: &str = "extensions/influence.md";
@ -837,6 +841,8 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire(".wai-fleet.json", WireKind::Path, FLEET, "§7.1", WireStatus::Defined),
wire("WRC1", WireKind::Magic, REVENUE_SHARE, "§3.1", WireStatus::Defined),
wire("WRS1", WireKind::Magic, REVENUE_SHARE, "§7.1", WireStatus::Defined),
wire("WOR1", WireKind::Magic, OPPORTUNITY, "§7.1", WireStatus::Defined),
wire("WCK1", WireKind::Magic, OPPORTUNITY, "§10.1", WireStatus::Defined),
wire("contentBinding", WireKind::FrameMetadata, BROWSER_SINK, "§7.1", WireStatus::Proposed),
wire("wai-takedown-clocks", WireKind::DocumentKind, TAKEDOWN, "§4", WireStatus::Defined),
wire("wai-takedown-policy", WireKind::DocumentKind, TAKEDOWN, "§5.1", WireStatus::Defined),

View file

@ -741,6 +741,14 @@ pub mod receipt_ledger;
/// cbindgen:ignore
#[cfg(feature = "revenue_share")]
pub mod revenue_share;
// Opportunity report (extensions/opportunity-report.md): `wai.delivery.opportunity` — the ad breaks
// a channel's own signed records prove for one party and one period (a room's cues and their SCTE 35
// sections, the program-as-run's ad entries), attributed by the share contract, and the check of a
// platform's reported figures against them and the signed revenue-share statement. Rust-only, like
// the other record modules: its constants must not become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "opportunity_report")]
pub mod opportunity_report;
// Stream audit (extensions/stream-audit.md): `wai.delivery.audit` — a player's signed evidence of a
// stream another service delivers over HTTP (acknowledgements by plain SHA-256, presentation claims for
// marked ads, the session record), the bundle a collector verifies, and the channel report it signs

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,853 @@
//! The opportunity-report corpus (`live-conformance/opportunity/`,
//! extensions/opportunity-report.md §14).
//!
//! Everything is written here from fixed seeds: every key is SHA-256 of this corpus's label, every
//! time a constant, and Ed25519 signing is deterministic (RFC 8032), so the file is the same bytes
//! on every machine. The world is a channel's day, 2026-10-16, under a contract of two owners: two
//! windows of its program-as-run, a room whose producer cues breaks with SCTE 35 sections inside
//! the owners' programmes, a ledger document billing the breaks' impressions and the operator's
//! signed statements of the day. [`generate`] writes the corpus; [`verify`] checks every vector
//! against the outcome it states.
use std::collections::BTreeMap;
use ed25519_dalek::{Signer, SigningKey};
use serde_json::{Map, Value, json};
use super::section::{Command, SegmentationSpec, write as section};
use super::*;
use crate::live_ads::{AdPolicy, NO_SKIP_CONTROL};
use crate::live_events::{Cue, Payload, Policy as EventPolicy, cue_type, scte_form};
use crate::live_room::{Caps, Descriptor, Detail, Grant, Limits, NO_CEILING, Recording, Review, Role};
use crate::quantum_ops::GrantRef;
use crate::receipt_ledger::{self as ledger, Tariff};
use crate::revenue_share::{Split, Statement, rule};
use crate::wai_video_channel::ScheduleEntry;
use crate::wai_video_ssai::{AdCandidate, BreakBinding, SsaiBreakReceipt, asset_format, decide, pod_hash};
/// 2026-10-01 and 2026-11-01 (00:00:00Z): the contract's period.
const OCT: u64 = 1_790_812_800_000_000;
const NOV: u64 = 1_793_491_200_000_000;
/// 2026-10-16T00:00:00Z: the room's `t0` and the day every report states.
pub const DAY16: u64 = 1_792_108_800_000_000;
pub const DAY_US: u64 = 86_400_000_000;
pub const DAY17: u64 = DAY16 + DAY_US;
const T0_S: u64 = DAY16 / 1_000_000;
const S: u64 = 1_000_000;
pub const CHANNEL: &str = "fast-9";
/// Per presented and per billable impression: 1.5 and 1 minor unit, in millionths.
const PRICES: [u64; 2] = [1_500_000, 1_000_000];
/// The decider grant's ceiling, in minor units of EUR.
const BUDGET_MINOR: u64 = 10;
/// Sessions per break.
const SESSIONS: u8 = 3;
/// A key of the corpus, from its fixed seed: `operator`, `owner-a`, `owner-b`, `channel`, `buyer`,
/// `auditor`, `stranger`, and the room's `authority`, `clock`, `producer` and `decider`. Test
/// keys, never a deployment's.
pub fn key(label: &str) -> SigningKey {
SigningKey::from_bytes(&sha256(&[b"wai:opportunity-corpus\x01", label.as_bytes()]))
}
pub fn pk(label: &str) -> [u8; 32] {
key(label).verifying_key().to_bytes()
}
// ---- the room ----
fn bits(ks: &[u8]) -> [u8; 32] {
let mut b = [0u8; 32];
for &k in ks {
b[(k / 8) as usize] |= 1 << (k % 8);
}
b
}
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 principal's chain being written.
#[derive(Clone)]
struct Chain {
label: &'static str,
seq: u64,
prev: [u8; 32],
records: Vec<Vec<u8>>,
}
/// A room being written: the clock's chain, then the producer's.
#[derive(Clone)]
pub struct RoomBuild {
pub descriptor: Vec<u8>,
pub id: [u8; 32],
pub grants: Vec<Vec<u8>>,
decider: Grant,
chains: Vec<Chain>,
pub policy: AdPolicy,
}
impl RoomBuild {
/// A room whose descriptor admits `max_present` (so that two rooms have two ids): a producer
/// that cues breaks, and a decider with the room's ad budget that never joins.
fn new(max_present: u16) -> RoomBuild {
let d = Descriptor {
authority: pk("authority"),
clock: pk("clock"),
t0_us: DAY16,
tick_us: S,
origin: String::new(),
limits: Limits { max_present: max_present.into(), max_publishers: 4, reval_s: 0, ceiling: EventPolicy::default() },
roles: vec![role("producer", true, &[kind::META, kind::CUE, 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: &str, r: usize, 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: T0_S + 86_400,
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, None);
let decider = grant("decider", 1, Some(BUDGET_MINOR));
let policy = AdPolicy {
room: id,
decider_grant: decider.digest(),
slots: 2,
open_avail_ms: 30_000,
preload_ms: 0,
restrict: 0,
skip_after_s: NO_SKIP_CONTROL,
snap: 0,
asset_base: "https://ads.channel.example/sgai/".into(),
creative_base: "https://cdn.channel.example/creatives/".into(),
presentation: None,
sig: [0; 64],
}
.sign(&key("authority"));
let mut room = RoomBuild {
descriptor: d.to_bytes(),
id,
grants: vec![producer.to_bytes(), decider.to_bytes()],
decider,
chains: vec![Chain { label: "clock", seq: 0, prev: [0; 32], records: vec![] }, Chain { label: "producer", seq: 0, prev: [0; 32], records: vec![] }],
policy,
};
let j = Detail::Join { grant: producer.digest() };
room.raw(0, 0, j.kind(), Payload::Lifecycle { kind: j.kind(), subject: pk("producer"), detail: j.encode() }.encode());
room
}
fn raw(&mut self, chain: usize, t: u64, kind_: u8, payload: Vec<u8>) -> [u8; 32] {
let c = &mut self.chains[chain];
let e = Event::sign(&key(c.label), self.id, c.seq, c.prev, t, None, kind_, payload);
c.seq += 1;
c.prev = e.digest();
c.records.push(e.to_bytes());
c.prev
}
/// The producer's cue at `t` seconds of the room clock, with its section when it has one.
fn cue(&mut self, t_s: u64, ctype: u8, cue_id: u32, duration_ms: u32, scte: Option<Vec<u8>>) -> [u8; 32] {
let c = Payload::Cue(Cue { cue_type: ctype, cue_id, duration_ms, scte: scte.map(|s| (scte_form::SPLICE_INFO_SECTION, s)) });
self.raw(1, t_s * S, kind::CUE, c.encode())
}
pub fn chains(&self) -> Vec<Vec<Vec<u8>>> {
self.chains.iter().map(|c| c.records.clone()).collect()
}
pub fn input(&self) -> RoomInput {
RoomInput { descriptor: self.descriptor.clone(), grants: self.grants.clone(), key_log: None, chains: self.chains(), policy: self.policy.to_bytes() }
}
}
/// The program clock's `pts_time` of an instant of `t` seconds (cue-injection §5.2).
fn pts(t_s: u64) -> u64 {
(t_s * S * 9 / 100) % (1 << 33)
}
/// A placement opportunity, or another segmentation type, of `cue_id`, `seconds` long.
fn seg(cue_id: u32, seconds: u64, type_id: u8, sub: Option<(u8, u8)>) -> SegmentationSpec {
SegmentationSpec { event_id: cue_id, cancelled: false, duration: Some(seconds * 90_000), type_id, sub }
}
/// How the main room's cues are written.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Cues {
Main,
/// Cue 2's section with its CRC's last byte flipped.
BadCrc,
/// Cue 4's section naming segmentation event 40.
OtherId,
}
/// The main room's breaks: (cue id, start s, type, duration ms, the in's start s), in order.
pub const BREAKS: [(u32, u64, u8, u32, Option<u64>); 8] = [
(1, 600, cue_type::OUT, 60_000, Some(660)),
(2, 1_200, cue_type::BREAK, 90_000, None),
(3, 1_800, cue_type::BREAK, 90_000, None),
(4, 2_400, cue_type::OUT, 120_000, Some(2_520)),
(5, 3_000, cue_type::BREAK, 60_000, None),
(6, 4_200, cue_type::BREAK, 90_000, None),
(7, 6_000, cue_type::BREAK, 120_000, None),
(8, 6_600, cue_type::BREAK, 60_000, None),
];
/// The main room: the producer cues eight breaks of the day, each kind of section §5 reads
/// among them, and returns the room with each break's opening cue and avail.
fn main_room(cues: Cues) -> (RoomBuild, Vec<([u8; 32], u64)>) {
let mut r = RoomBuild::new(16);
let mut opened = Vec::new();
for (id, t, ctype, dur, in_at) in BREAKS {
let secs = dur as u64 / 1000;
let s = match id {
// A splice_insert() with its break duration: slots from the policy.
1 => Some(section(&Command::Insert { id, cancel: false, out: true, duration: Some(secs * 90_000) }, &[])),
// Provider and distributor placement opportunities stating three and four slots.
2 | 3 | 4 | 7 => {
let ty = if id == 3 { 0x36 } else { 0x34 };
let n = if dur >= 120_000 { 4 } else { 3 };
let event = if id == 4 && cues == Cues::OtherId { 40 } else { id };
let mut s = section(&Command::TimeSignal { pts: pts(t) }, &[seg(event, secs, ty, Some((1, n)))]);
if id == 2 && cues == Cues::BadCrc {
let n = s.len();
s[n - 1] ^= 0x01;
}
Some(s)
}
// A splice_insert() carrying a placement opportunity of three slots.
6 => Some(section(&Command::Insert { id, cancel: false, out: true, duration: Some(secs * 90_000) }, &[SegmentationSpec { duration: None, ..seg(id, 0, 0x34, Some((1, 3))) }])),
// A provider advertisement start, a type that states no slots.
8 => Some(section(&Command::TimeSignal { pts: pts(t) }, &[seg(id, secs, 0x30, None)])),
// A cue of form 0.
_ => None,
};
opened.push((r.cue(t, ctype, id, dur, s), dur as u64));
if let Some(t_in) = in_at {
r.cue(t_in, cue_type::IN, id, dur, None);
}
}
(r, opened)
}
/// A second room of the channel's: one break, at 55 minutes, or at 20½ minutes, inside cue 2's.
fn backup_room(overlapping: bool) -> RoomBuild {
let mut r = RoomBuild::new(15);
let t = if overlapping { 1_230 } else { 3_300 };
r.cue(t, cue_type::BREAK, 9, 60_000, None);
r
}
// ---- the ledger ----
fn ad(id: &str, creative: u8, duration_ms: u64, price_micros: u64) -> AdCandidate {
AdCandidate { ad_id: id.into(), creative_hash: sha256(&[b"wai:opportunity-corpus/creative\x01", &[creative]]), duration_ms, price_micros }
}
/// Three ads: two of 15 s and one of 20 s.
fn pod() -> 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)]
}
fn session(i: u8) -> [u8; 16] {
sha256(&[b"wai:opportunity-corpus/session\x01", &[i]])[..16].try_into().expect("16 bytes")
}
/// A viewer's receipt for a break: the fill of the pod, every ad played to its end.
fn receipt(r: &RoomBuild, cue: [u8; 32], avail_ms: u64, s: u8) -> SsaiBreakReceipt {
let funds = BUDGET_MINOR * 1_000_000;
let fill = decide(&pod(), r.policy.slots as u64, avail_ms, funds);
let mut imps = fill.clone();
for i in &mut imps {
i.quartiles_reached = 4;
}
let list = crate::live_ads::asset_list(&r.policy, asset_format::HLS, &fill);
let binding = BreakBinding {
room: r.id,
cue,
session: session(s),
start_offset_ms: 0,
format: asset_format::HLS,
asset_list_sha256: sha256(&[&list]),
avail_ms,
slots: r.policy.slots as u64,
funds_remaining_micros: funds,
};
let (grant_hash, capability, joule_ceiling_micro, funds_ceiling) = r.decider.grant_ref();
let grant = GrantRef { grant_hash, capability: capability.into(), joule_ceiling_micro, funds_ceiling };
SsaiBreakReceipt::seal(&key("decider"), crate::signer_id::did_key(&pk("decider")), binding, pod_hash(&pod()), imps, pod().len() as u64, 0, None, grant)
}
/// The room as a source of the receipt ledger, with every viewer's receipt for every break.
fn ads_source(r: &RoomBuild, opened: &[([u8; 32], u64)]) -> Value {
let mut receipts = Vec::new();
let mut pods = Vec::new();
let pod_json: Vec<Value> = 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();
for (cue, avail) in opened {
for s in 1..=SESSIONS {
receipts.push(receipt(r, *cue, *avail, s).to_json());
pods.push(Value::Array(pod_json.clone()));
}
}
let i = r.input();
json!({
"kind": "ads", "descriptor": hex(&i.descriptor), "grants": i.grants.iter().map(|g| hex(g)).collect::<Vec<_>>(), "key_log": null,
"chains": i.chains.iter().map(|c| c.iter().map(|e| hex(e)).collect::<Vec<_>>()).collect::<Vec<_>>(), "policy": hex(&i.policy), "pt0_ms": 0,
"receipts": receipts, "pods": pods, "claims": [], "creatives": [], "issuers": [],
})
}
// ---- the channel ----
fn entry(asset: &str, start_s: u64, duration_s: u64, kind_: EntryKind) -> ScheduleEntry {
ScheduleEntry { asset_id: asset.into(), start_ms: DAY16 / 1000 + start_s * 1000, duration_ms: duration_s * 1000, kind: kind_ }
}
fn run(schedule: Vec<ScheduleEntry>, window_s: (u64, u64), parent: Option<[u8; 32]>) -> ChannelReceipt {
let ms = |s: u64| DAY16 / 1000 + s * 1000;
ChannelReceipt::seal(&key("channel"), "did:key:channel-signer", CHANNEL, schedule, ms(window_s.0), ms(window_s.1), 0, None, GrantRef::unbounded("video.channel"), parent)
}
/// The day's first two hours, one window each: owner A's episode, an ad slot, owner B's
/// documentary; then A's next episode, an ad slot no cue covers, and B's next documentary.
fn runs(last: &str) -> (ChannelReceipt, ChannelReceipt) {
let r1 = run(vec![entry("a/ep-1", 0, 1_800, EntryKind::Program), entry("slate-90", 1_800, 90, EntryKind::Ad), entry("b/doc-1", 1_890, 1_710, EntryKind::Program)], (0, 3_600), None);
let r2 = run(vec![entry("a/ep-2", 3_600, 1_740, EntryKind::Program), entry("mid-60", 5_340, 60, EntryKind::Ad), entry(last, 5_400, 1_800, EntryKind::Program)], (3_600, 7_200), Some(r1.receipt_hash()));
(r1, r2)
}
/// The contract: two owners, a split each and a between split, under the rule preceding.
fn contract(rooms: &[[u8; 32]]) -> Contract {
let mut owners = vec![pk("owner-a"), pk("owner-b")];
owners.sort();
let ia = owners.iter().position(|k| *k == pk("owner-a")).expect("listed") as u8;
let ib = 1 - ia;
let mut carriers: Vec<(u8, [u8; 32])> = rooms.iter().map(|r| (share::carrier::ROOM, *r)).collect();
carriers.sort();
let mut halves = vec![(ia, 500_000), (ib, 500_000)];
halves.sort();
Contract {
operator: pk("operator"),
start: OCT,
end: NOV,
currency: *b"EUR",
channel: CHANNEL.as_bytes().to_vec(),
channel_signer: pk("channel"),
rule: rule::PRECEDING,
between: 2,
carriers,
owners,
splits: vec![
Split { operator_ppm: 666_667, shares: vec![(ia, 333_333)] },
Split { operator_ppm: 400_000, shares: vec![(ib, 600_000)] },
Split { operator_ppm: 0, shares: halves },
],
assets: ["a/ep-1", "a/ep-2", "b/doc-1", "b/doc-2"].iter().map(|a| (a.as_bytes().to_vec(), if a.starts_with('a') { 0 } else { 1 })).collect(),
operator_sig: [0; 64],
countersigs: Vec::new(),
}
}
fn in_force(c: Contract) -> Contract {
c.offer(&key("operator")).countersign(&key("owner-a")).expect("an owner").countersign(&key("owner-b")).expect("an owner")
}
// ---- the corpus ----
/// The named parts every vector draws on.
pub struct Pools {
pub contracts: BTreeMap<String, Value>,
pub runs: BTreeMap<String, Value>,
pub rooms: BTreeMap<String, Value>,
pub ledgers: BTreeMap<String, Value>,
pub reported: BTreeMap<String, Value>,
}
/// A report's verification document by names (§8.1).
#[derive(Clone)]
pub struct ReportParts {
pub contract: &'static str,
pub contracts: Vec<&'static str>,
pub report: Vec<u8>,
pub runs: Option<Vec<&'static str>>,
pub rooms: Vec<&'static str>,
}
/// A revenue-share verification document by names.
#[derive(Clone)]
pub struct StatementParts {
pub contract: &'static str,
pub statement: Vec<u8>,
pub runs: Vec<&'static str>,
pub ledgers: Vec<&'static str>,
}
impl Pools {
fn named(&self, pool: &BTreeMap<String, Value>, n: &str) -> Value {
pool.get(n).unwrap_or_else(|| panic!("no part {n}")).clone()
}
/// A report's document, with its report or without one.
pub fn report_document(&self, p: &ReportParts, with_report: bool) -> Value {
let mut d = Map::new();
d.insert("contract".into(), self.named(&self.contracts, p.contract));
d.insert("contracts".into(), Value::Array(p.contracts.iter().map(|n| self.named(&self.contracts, n)).collect()));
if with_report {
d.insert("report".into(), json!(hex(&p.report)));
}
d.insert("runs".into(), p.runs.as_ref().map_or(Value::Null, |r| Value::Array(r.iter().map(|n| self.named(&self.runs, n)).collect())));
d.insert("rooms".into(), Value::Array(p.rooms.iter().map(|n| self.named(&self.rooms, n)).collect()));
Value::Object(d)
}
pub fn statement_document(&self, p: &StatementParts, with_statement: bool) -> Value {
let mut d = Map::new();
d.insert("contract".into(), self.named(&self.contracts, p.contract));
d.insert("contracts".into(), json!([]));
if with_statement {
d.insert("statement".into(), json!(hex(&p.statement)));
}
d.insert("runs".into(), Value::Array(p.runs.iter().map(|n| self.named(&self.runs, n)).collect()));
d.insert("ledgers".into(), Value::Array(p.ledgers.iter().map(|n| self.named(&self.ledgers, n)).collect()));
Value::Object(d)
}
/// A check's document, with its check or without one.
pub fn check_document(&self, report: &ReportParts, reported: &str, statement: Option<&StatementParts>, check: Option<&[u8]>) -> Value {
let mut d = Map::new();
d.insert("report".into(), self.report_document(report, true));
d.insert("reported".into(), self.named(&self.reported, reported));
d.insert("statement".into(), statement.map_or(Value::Null, |s| self.statement_document(s, true)));
if let Some(c) = check {
d.insert("check".into(), json!(hex(c)));
}
Value::Object(d)
}
}
/// The world of the corpus: its pools, and the parts its accepted vectors name.
pub struct World {
pub pools: Pools,
pub reports: BTreeMap<&'static str, ReportParts>,
pub statements: BTreeMap<&'static str, StatementParts>,
}
fn reported_json(r: Value) -> Value {
json!({"form": "json", "text": serde_json::to_string(&r).expect("JSON")})
}
/// B's reported statement of the day, per programme: its second documentary's impressions all
/// but missing.
fn b_programmes(channel: &str, extra: Option<Value>) -> Value {
let mut programmes = vec![json!({"asset": "b/doc-1", "impressions": 12, "revenue": 7_200_000}), json!({"asset": "b/doc-2", "impressions": 2, "revenue": 1_200_000})];
if let Some(e) = extra {
programmes.insert(0, e);
}
let (imps, rev) = programmes.iter().fold((0u64, 0u64), |(i, r), p| (i + p["impressions"].as_u64().unwrap(), r + p["revenue"].as_u64().unwrap()));
json!({"channel": channel, "start": DAY16, "end": DAY17, "currency": "EUR", "basis": "share", "impressions": imps, "revenue": rev, "programmes": programmes})
}
/// A's reported statement of the day in CSV, per day: what the signed statement states.
fn a_days(header: &str) -> String {
format!("{header}\r\nchannel,{CHANNEL},,\r\nstart,{DAY16},,\r\nend,{DAY17},,\r\ncurrency,EUR,,\r\nbasis,share,,\r\nperiod,,24,7999992\r\nday,2026-10-16,24,7999992\r\n")
}
/// Build the world: pools, the day's reports and statements.
pub fn world() -> Result<World, String> {
let (main, opened) = main_room(Cues::Main);
let backup = backup_room(false);
let mut rooms = BTreeMap::new();
let mut put_room = |n: &str, r: &RoomInput| {
rooms.insert(n.to_string(), r.to_json());
};
put_room("main", &main.input());
put_room("main_crc_flipped", &main_room(Cues::BadCrc).0.input());
put_room("main_other_segmentation_id", &main_room(Cues::OtherId).0.input());
put_room("backup", &backup.input());
put_room("backup_overlapping", &backup_room(true).input());
let mut gap = main.input();
gap.chains[1].remove(4);
put_room("main_with_a_gap", &gap);
let mut flipped = main.input();
let last = flipped.chains[1][2].len() - 1;
flipped.chains[1][2][last] ^= 0x01;
put_room("main_signature_flipped", &flipped);
let (r1, r2) = runs("b/doc-2");
let (_, r2_unmapped) = runs("c/film-9");
let mut runs_ = BTreeMap::new();
runs_.insert("hour_1".to_string(), r1.to_json());
runs_.insert("hour_2".to_string(), r2.to_json());
runs_.insert("hour_2_unmapped".to_string(), r2_unmapped.to_json());
let mut tampered = r1.clone();
tampered.aired = vec![0];
tampered.sig = key("channel").sign(&tampered.payload()).to_bytes();
runs_.insert("hour_1_aired_misstated".to_string(), tampered.to_json());
let part_1 = run(r1.schedule.clone(), (0, 1_850), None);
let part_2 = run(r1.schedule.clone(), (1_850, 3_600), Some(part_1.receipt_hash()));
runs_.insert("hour_1_to_1850_s".to_string(), part_1.to_json());
runs_.insert("hour_1_from_1850_s".to_string(), part_2.to_json());
let other = ChannelReceipt::seal(&key("channel"), "did:key:channel-signer", "fast-8", r1.schedule.clone(), r1.window_start_ms, r1.window_end_ms, 0, None, GrantRef::unbounded("video.channel"), None);
runs_.insert("fast_8".to_string(), other.to_json());
runs_.insert("hour_1_overlapping".to_string(), run(r1.schedule.clone(), (3_000, 4_000), None).to_json());
let c = in_force(contract(&[main.id]));
let two = in_force(contract(&[main.id, backup.id]));
let offered = contract(&[main.id]).offer(&key("operator")).countersign(&key("owner-a")).expect("an owner");
let mut contracts = BTreeMap::new();
contracts.insert("main".to_string(), json!(hex(&c.to_bytes())));
contracts.insert("two_rooms".to_string(), json!(hex(&two.to_bytes())));
contracts.insert("offered".to_string(), json!(hex(&offered.to_bytes())));
let mut late = contract(&[main.id]);
(late.start, late.end) = (OCT + 20 * DAY_US, NOV + 20 * DAY_US);
contracts.insert("overlapping".to_string(), json!(hex(&in_force(late).to_bytes())));
// The ledger: the room's receipts for every break, billed for the day.
let source = ads_source(&main, &opened);
let t = Tariff {
seller: pk("operator"),
buyer: pk("buyer"),
start: OCT,
end: NOV,
currency: *b"EUR",
room: [0; ledger::ROOM_PRICES],
delivery: [0; 4],
package: [0; 3],
ads: PRICES,
scope: vec![(4, pk("decider"))],
seller_sig: [0; 64],
buyer_sig: [0; 64],
}
.sign(&key("operator"), &key("buyer"));
let invoice = ledger::make_invoice(&key("operator"), &t, None, DAY16, DAY17, std::slice::from_ref(&source)).map_err(|e| format!("the day's invoice: {e:?}"))?;
let mut ledgers = BTreeMap::new();
ledgers.insert("day_16".to_string(), json!({"tariff": hex(&t.to_bytes()), "invoice": hex(&invoice.to_bytes()), "previous": null, "sources": [source]}));
let mut reported = BTreeMap::new();
reported.insert("b_programmes".to_string(), reported_json(b_programmes(CHANNEL, None)));
reported.insert("b_programmes_and_a_programme".to_string(), reported_json(b_programmes(CHANNEL, Some(json!({"asset": "a/ep-1", "impressions": 5, "revenue": 3_000_000})))));
reported.insert("b_programmes_of_another_channel".to_string(), reported_json(b_programmes("fast-8", None)));
let mut outside = b_programmes(CHANNEL, None);
outside["days"] = json!([{"day": "2026-10-17", "impressions": 14, "revenue": 8_400_000}]);
reported.insert("b_a_day_after_the_period".to_string(), reported_json(outside));
reported.insert("a_days".to_string(), json!({"form": "csv", "text": a_days("scope,key,impressions,revenue")}));
reported.insert("a_days_header_misspelt".to_string(), json!({"form": "csv", "text": a_days("scope,key,impression,revenue")}));
reported.insert("of_another_form".to_string(), json!({"form": "xml", "text": "<reported/>"}));
let pools = Pools { contracts, runs: runs_, rooms, ledgers, reported };
// The operator's signed statements of the day.
let mut statements = BTreeMap::new();
for (name, who) in [("owner_a", "owner-a"), ("owner_b", "owner-b"), ("operator", "operator")] {
let mut sp = StatementParts { contract: "main", statement: Vec::new(), runs: vec!["hour_1", "hour_2"], ledgers: vec!["day_16"] };
let (s, _): (Statement, Value) = share::create(&pools.statement_document(&sp, false), &key("operator"), pk(who), DAY16, DAY17).map_err(|e| format!("the statement of {who}: {e:?}"))?;
sp.statement = s.to_bytes();
statements.insert(name, sp);
}
// The reports of the day.
let mut reports = BTreeMap::new();
let both = || Some(vec!["hour_1", "hour_2"]);
let split = || Some(vec!["hour_1_to_1850_s", "hour_1_from_1850_s", "hour_2"]);
for (name, contract_, runs__, rooms_, who, issuer, end) in [
("owner_a", "main", both(), vec!["main"], "owner-a", "owner-a", DAY17),
("owner_b", "main", both(), vec!["main"], "owner-b", "owner-b", DAY17),
("operator", "main", both(), vec!["main"], "operator", "operator", DAY17),
("owner_a_runs_alone", "main", both(), vec![], "owner-a", "auditor", DAY17),
("owner_a_runs_split", "main", split(), vec![], "owner-a", "auditor", DAY17),
("owner_b_first_hour", "main", both(), vec!["main"], "owner-b", "owner-b", DAY16 + 3_600 * S),
("owner_b_two_rooms", "two_rooms", both(), vec!["main", "backup"], "owner-b", "auditor", DAY17),
] {
let mut rp = ReportParts { contract: contract_, contracts: vec![], report: Vec::new(), runs: runs__, rooms: rooms_ };
let (r, _) = super::create(&pools.report_document(&rp, false), &key(issuer), pk(who), DAY16, end).map_err(|e| format!("the report {name}: {e:?}"))?;
rp.report = r.to_bytes();
reports.insert(name, rp);
}
Ok(World { pools, reports, statements })
}
/// A report changed and signed again by its issuer.
fn resealed(r: &[u8], issuer: &str, f: impl FnOnce(&mut Report)) -> Vec<u8> {
let mut r = Report::read(r).expect("a corpus report reads");
f(&mut r);
r.sign(&key(issuer)).to_bytes()
}
/// A check changed and signed again by its issuer.
fn check_resealed(c: &[u8], issuer: &str, f: impl FnOnce(&mut Check)) -> Vec<u8> {
let mut c = Check::read(c).expect("a corpus check reads");
f(&mut c);
c.sign(&key(issuer)).to_bytes()
}
fn flip(b: &[u8], at: usize) -> Vec<u8> {
let mut b = b.to_vec();
b[at] ^= 0x01;
b
}
/// One vector: a report's document or a check's, by names.
enum Vector {
Report { name: &'static str, about: &'static str, parts: ReportParts },
Check { name: &'static str, about: &'static str, report: ReportParts, reported: &'static str, statement: Option<StatementParts>, check: Vec<u8> },
}
fn report_parts_json(p: &ReportParts) -> Value {
json!({"contract": p.contract, "contracts": p.contracts, "report": hex(&p.report), "runs": p.runs, "rooms": p.rooms})
}
fn statement_parts_json(p: &StatementParts) -> Value {
json!({"contract": p.contract, "statement": hex(&p.statement), "runs": p.runs, "ledgers": p.ledgers})
}
/// Write the corpus, from fixed seeds.
pub fn generate() -> Result<String, String> {
let w = world()?;
let p = &w.pools;
let rep = |n: &str| w.reports[n].clone();
let st = |n: &str| w.statements[n].clone();
let a = rep("owner_a");
let ra = a.report.clone();
let b_lines = Report::read(&rep("owner_b").report).expect("reads").lines;
let with = |name: &'static str, about: &'static str, f: &dyn Fn(&mut ReportParts)| {
let mut parts = a.clone();
f(&mut parts);
Vector::Report { name, about, parts }
};
let report = |name: &'static str, about: &'static str, report: Vec<u8>| with(name, about, &|x: &mut ReportParts| x.report = report.clone());
// The checks of the day, signed by the owner each is for.
let check_of = |report: &str, reported: &str, statement: Option<&str>, issuer: &str| -> Result<Vec<u8>, String> {
let s = statement.map(|n| w.statements[n].clone());
let inputs = p.check_document(&w.reports[report], reported, s.as_ref(), None);
create_check(&inputs, &key(issuer), FLOOR_PPM, CEILING_PPM).map(|(c, _)| c.to_bytes()).map_err(|e| format!("the check of {report} and {reported}: {e:?}"))
};
let cb = check_of("owner_b", "b_programmes", Some("owner_b"), "owner-b")?;
let ca = check_of("owner_a", "a_days", Some("owner_a"), "owner-a")?;
let cb_alone = check_of("owner_b", "b_programmes", None, "owner-b")?;
let cb_unproved = check_of("owner_b", "b_programmes_and_a_programme", None, "owner-b")?;
let check = |name: &'static str, about: &'static str, report: ReportParts, reported: &'static str, statement: Option<StatementParts>, check: Vec<u8>| Vector::Check { name, about, report, reported, statement, check };
let b = rep("owner_b");
let b_check = |name: &'static str, about: &'static str, check_: Vec<u8>| check(name, about, b.clone(), "b_programmes", Some(st("owner_b")), check_);
let mut b_report_flipped = b.clone();
let n = b_report_flipped.report.len();
b_report_flipped.report = flip(&b_report_flipped.report, n - 10);
let mut b_statement_flipped = st("owner_b");
let n = b_statement_flipped.statement.len();
b_statement_flipped.statement = flip(&b_statement_flipped.statement, n - 10);
let vectors = vec![
// Accepted reports.
Vector::Report { name: "owner_a_day", about: "owner A's day: four cued breaks in its episodes, one of them inside the ad slot after its first episode, and the ad slot no cue covers, scheduled and unslotted", parts: rep("owner_a") },
Vector::Report { name: "owner_b_day", about: "owner B's day: four cued breaks in its documentaries, slots from placement opportunities and from the room's policy", parts: rep("owner_b") },
Vector::Report { name: "operator_day", about: "the operator's day: its share of both owners' splits; no line of the between split, in which it holds no share", parts: rep("operator") },
Vector::Report { name: "owner_a_runs_alone", about: "owner A's day from the runs alone: both ad slots scheduled, no cued break", parts: rep("owner_a_runs_alone") },
Vector::Report { name: "owner_a_runs_split", about: "owner A's day from the runs alone, the first hour in two windows that both air its ad slot: one break, taken in the earlier window", parts: rep("owner_a_runs_split") },
Vector::Report { name: "owner_b_first_hour", about: "owner B's first hour: the breaks of the second hour are another period's", parts: rep("owner_b_first_hour") },
Vector::Report { name: "owner_b_two_rooms", about: "owner B's day under a contract naming two rooms: a break of the second room in its first documentary, overlapping none", parts: rep("owner_b_two_rooms") },
// Accepted checks.
check("check_owner_b_under_reported", "owner B's reported statement per programme beside its report and the signed statement: its second documentary far below the day's rate, and the day below the signed impressions and revenue", b.clone(), "b_programmes", Some(st("owner_b")), cb.clone()),
check("check_owner_a_csv", "owner A's reported statement in CSV, per day, beside its report and the signed statement: nothing flagged", a.clone(), "a_days", Some(st("owner_a")), ca.clone()),
check("check_owner_b_without_statement", "owner B's reported statement beside its report alone", b.clone(), "b_programmes", None, cb_alone),
check("check_owner_b_reports_a_programme", "owner B's reported statement listing owner A's episode: impressions where no break of B's aired", b.clone(), "b_programmes_and_a_programme", None, cb_unproved),
// The report's document and contract.
with("document_malformed", "a report's document whose runs are not a list", &|x| x.runs = None),
with("contract_offered", "a contract owner B has not countersigned", &|x| x.contract = "offered"),
with("contract_overlap", "another contract of the channel whose period overlaps the contract's", &|x| x.contracts = vec!["overlapping"]),
// The report.
report("report_truncated", "a report of ten bytes", ra[..10].to_vec()),
report("report_magic", "a report whose magic is WOR2", [b"WOR2".as_slice(), &ra[4..]].concat()),
report("report_version_2", "a report of version 2", [&ra[..4], &[2u8][..], &ra[5..]].concat()),
report("report_lines_unordered", "a report whose lines are not in ascending order", resealed(&ra, "owner-a", |r| r.lines.reverse())),
report("report_of_another_contract", "a report naming the contract of two rooms", resealed(&ra, "owner-a", |r| r.contract = Contract::read(&unhex(p.contracts["two_rooms"].as_str().unwrap()).unwrap()).unwrap().digest())),
report("report_past_the_contract", "a report whose period ends a day after the contract's", resealed(&ra, "owner-a", |r| r.end = NOV + DAY_US)),
report("report_signature", "a report whose signature has a byte flipped", flip(&ra, ra.len() - 40)),
report("report_issuer_small_order", "a report whose issuer key is of small order", {
let mut b = ra.clone();
b[5..37].copy_from_slice(&crate::sig::IDENTITY);
b
}),
// The runs and the rooms.
with("run_aired_misstated", "a run whose aired set the window does not give, signed again", &|x| x.runs = Some(vec!["hour_1_aired_misstated", "hour_2"])),
with("run_of_another_channel", "a run of another channel, by the same signer", &|x| x.runs = Some(vec!["hour_1", "fast_8"])),
with("runs_overlapping", "a third run whose window overlaps the first's", &|x| x.runs = Some(vec!["hour_1", "hour_2", "hour_1_overlapping"])),
with("room_event_signature", "a room whose producer's third event has a byte of its signature flipped", &|x| x.rooms = vec!["main_signature_flipped"]),
with("room_with_a_gap", "a room whose producer's chain lacks its fifth event", &|x| x.rooms = vec!["main_with_a_gap"]),
with("room_foreign", "a room the contract does not name", &|x| x.rooms = vec!["backup"]),
with("room_repeated", "the contract's room given twice", &|x| x.rooms = vec!["main", "main"]),
// The breaks.
with("cue_section_crc", "a room whose second break's section has its CRC damaged", &|x| x.rooms = vec!["main_crc_flipped"]),
with("cue_section_other_id", "a room whose fourth break's section names segmentation event 40 for cue 4", &|x| x.rooms = vec!["main_other_segmentation_id"]),
Vector::Report {
name: "two_rooms_overlapping",
about: "a second room of the contract's whose break falls inside the first room's second break",
parts: ReportParts { rooms: vec!["main", "backup_overlapping"], ..rep("owner_b_two_rooms") },
},
with("break_outside_every_window", "the run of the first hour alone: the breaks of the second aired in no window held", &|x| x.runs = Some(vec!["hour_1"])),
with("asset_unmapped", "a run of the second hour whose last programme the contract does not map", &|x| x.runs = Some(vec!["hour_1", "hour_2_unmapped"])),
// The figures.
report("line_of_another_split", "owner A's report with a line of owner B's split", resealed(&ra, "owner-a", |r| r.lines.push(Line { ppm: 1, ..b_lines[0] }))),
report("line_citations_changed", "a line whose citation digest has a byte changed", resealed(&ra, "owner-a", |r| r.lines[0].cites[0] ^= 0x01)),
report("line_seconds_raised", "a line stating a second more of breaks, and the total with it", resealed(&ra, "owner-a", |r| {
r.lines[0].break_ms += 1_000;
r.break_ms += 1_000;
})),
report("runs_listed_short", "a report listing one of the two runs it is given", resealed(&ra, "owner-a", |r| r.runs.truncate(1))),
// The check's document, the report and the reported statement.
check("check_document_malformed", "a check's document whose reported statement is of a form this text does not define", b.clone(), "of_another_form", None, cb.clone()),
check("check_report_refused", "a check whose report has a byte of its signature flipped, refused at the report's place", b_report_flipped, "b_programmes", Some(st("owner_b")), cb.clone()),
check("reported_day_outside_the_period", "a reported statement listing a day after its period", b.clone(), "b_a_day_after_the_period", None, cb.clone()),
check("reported_csv_header", "a reported statement in CSV whose header misspells a column", a.clone(), "a_days_header_misspelt", None, ca.clone()),
check("reported_of_another_channel", "a reported statement of another channel", b.clone(), "b_programmes_of_another_channel", None, cb.clone()),
// The signed statement.
check("check_statement_refused", "a signed statement with a byte of its signature flipped, refused at the statement's place", b.clone(), "b_programmes", Some(b_statement_flipped), cb.clone()),
check("statement_of_another_owner", "owner B's report beside owner A's signed statement", b.clone(), "b_programmes", Some(st("owner_a")), cb.clone()),
// The check.
b_check("check_truncated", "a check of ten bytes", cb[..10].to_vec()),
b_check("check_floor_above_the_rate", "a check whose floor is above the whole", check_resealed(&cb, "owner-b", |c| c.floor_ppm = 1_500_000)),
b_check("check_flags_changed", "a check whose flags leave out the programme's below", check_resealed(&cb, "owner-b", |c| {
for l in &mut c.lines {
l.flags &= !(1 << 2);
}
})),
b_check("check_of_another_reported_statement", "a check naming the bytes of another reported statement", check_resealed(&cb, "owner-b", |c| c.reported = sha256(&[b"another"]))),
b_check("check_signature", "a check whose signature has a byte flipped", flip(&cb, cb.len() - 40)),
];
let mut out = Vec::with_capacity(vectors.len());
for v in &vectors {
out.push(match v {
Vector::Report { name, about, parts } => {
let outcome = outcome_json(&super::verify(&p.report_document(parts, true)));
json!({"name": name, "about": about, "kind": "report", "report": report_parts_json(parts), "outcome": outcome})
}
Vector::Check { name, about, report, reported, statement, check } => {
let outcome = check_outcome_json(&verify_check(&p.check_document(report, reported, statement.as_ref(), Some(check))));
json!({
"name": name, "about": about, "kind": "check", "report": report_parts_json(report), "reported": reported,
"statement": statement.as_ref().map(statement_parts_json), "check": hex(check), "outcome": outcome,
})
}
});
}
let keys = json!({
"operator": hex(&pk("operator")), "owner_a": hex(&pk("owner-a")), "owner_b": hex(&pk("owner-b")), "channel_signer": hex(&pk("channel")),
"auditor": hex(&pk("auditor")), "producer": hex(&pk("producer")), "decider": hex(&pk("decider")),
});
let to_map = |m: &BTreeMap<String, Value>| Value::Object(m.clone().into_iter().collect());
let doc = json!({
"corpus": CAPABILITY,
"extension": "extensions/opportunity-report.md",
"keys": keys,
"contracts": to_map(&p.contracts),
"runs": to_map(&p.runs),
"rooms": to_map(&p.rooms),
"ledgers": to_map(&p.ledgers),
"reported": to_map(&p.reported),
"vectors": out,
});
Ok(serde_json::to_string_pretty(&doc).expect("JSON") + "\n")
}
/// The parts of the committed corpus, read back into pools.
fn pools_of(doc: &Value) -> Option<Pools> {
let map = |k: &str| -> Option<BTreeMap<String, Value>> { Some(doc[k].as_object()?.iter().map(|(n, v)| (n.clone(), v.clone())).collect()) };
Some(Pools { contracts: map("contracts")?, runs: map("runs")?, rooms: map("rooms")?, ledgers: map("ledgers")?, reported: map("reported")? })
}
/// A name read back from the corpus, held for the life of the process (the corpus's parts name
/// their pools by `&'static str`).
fn leak(s: &str) -> &'static str {
Box::leak(s.to_string().into_boxed_str())
}
fn names(v: &Value) -> Option<Vec<&'static str>> {
v.as_array()?.iter().map(|n| n.as_str().map(leak)).collect()
}
fn report_parts(v: &Value) -> Option<ReportParts> {
Some(ReportParts {
contract: leak(v["contract"].as_str()?),
contracts: names(&v["contracts"])?,
report: unhex(v["report"].as_str()?)?,
runs: if v["runs"].is_null() { None } else { Some(names(&v["runs"])?) },
rooms: names(&v["rooms"])?,
})
}
fn statement_parts(v: &Value) -> Option<StatementParts> {
Some(StatementParts { contract: leak(v["contract"].as_str()?), statement: unhex(v["statement"].as_str()?)?, runs: names(&v["runs"])?, ledgers: names(&v["ledgers"])? })
}
/// A vector of the corpus as its verification document, its parts resolved by name.
pub fn document(doc: &Value, v: &Value) -> Option<Value> {
let p = pools_of(doc)?;
let has = |pool: &BTreeMap<String, Value>, ns: &[&str]| ns.iter().all(|n| pool.contains_key(*n));
let rp = report_parts(&v["report"])?;
if !has(&p.contracts, &[rp.contract]) || !has(&p.contracts, &rp.contracts) || !has(&p.runs, rp.runs.as_deref().unwrap_or(&[])) || !has(&p.rooms, &rp.rooms) {
return None;
}
match v["kind"].as_str()? {
"report" => Some(p.report_document(&rp, true)),
"check" => {
let reported = v["reported"].as_str()?;
let sp = if v["statement"].is_null() { None } else { Some(statement_parts(&v["statement"])?) };
if !has(&p.reported, &[reported]) || sp.as_ref().is_some_and(|s| !has(&p.contracts, &[s.contract]) || !has(&p.runs, &s.runs) || !has(&p.ledgers, &s.ledgers)) {
return None;
}
Some(p.check_document(&rp, reported, sp.as_ref(), Some(&unhex(v["check"].as_str()?)?)))
}
_ => None,
}
}
/// The outcome a vector's document gets.
pub fn outcome_of(v: &Value, d: &Value) -> Value {
if v["kind"] == "check" { check_outcome_json(&verify_check(d)) } else { outcome_json(&super::verify(d)) }
}
/// The failures: every vector whose stated outcome is not the one this module gives.
pub fn verify(text: &str) -> Vec<String> {
let doc: Value = match serde_json::from_str(text) {
Ok(d) => d,
Err(e) => return vec![format!("vectors.json: {e}")],
};
let mut fails = Vec::new();
for v in doc["vectors"].as_array().into_iter().flatten() {
let name = v["name"].as_str().unwrap_or("?");
let Some(d) = document(&doc, v) else {
fails.push(format!("{name}: the vector does not read"));
continue;
};
let got = outcome_of(v, &d);
if got != v["outcome"] {
fails.push(format!("{name}: the outcome is {got}, the corpus states {}", v["outcome"]));
}
}
fails
}

View file

@ -0,0 +1,341 @@
//! The reported statement (extensions/opportunity-report.md §9): what a platform reports it
//! counted, in a documented JSON form (§9.1) or CSV form (§9.2), held to §9.3's rules. It is an
//! input and never evidence: a check names it by the digest of its bytes.
use serde_json::{Map, Value};
/// The forms (§10.1 `form`).
pub mod form {
pub const JSON: u8 = 1;
pub const CSV: u8 = 2;
pub fn name(f: u8) -> &'static str {
match f {
JSON => "json",
CSV => "csv",
_ => "?",
}
}
pub fn code(s: &str) -> Option<u8> {
match s {
"json" => Some(JSON),
"csv" => Some(CSV),
_ => None,
}
}
}
/// The bases of the reported revenue (§9.1).
pub mod basis {
pub const GROSS: u8 = 1;
pub const SHARE: u8 = 2;
pub fn name(b: u8) -> &'static str {
match b {
GROSS => "gross",
SHARE => "share",
_ => "?",
}
}
pub fn code(s: &str) -> Option<u8> {
match s {
"gross" => Some(GROSS),
"share" => Some(SHARE),
_ => None,
}
}
}
/// A day's figures: the day as written, the start of its span, and its figures.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Day {
pub day: String,
pub start: u64,
pub impressions: u64,
pub revenue: u64,
}
/// A programme's figures.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Programme {
pub asset: Vec<u8>,
pub impressions: u64,
pub revenue: u64,
}
/// A reported statement, read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Reported {
pub channel: Vec<u8>,
pub start: u64,
pub end: u64,
pub currency: [u8; 3],
pub basis: u8,
pub impressions: u64,
pub revenue: u64,
pub days: Vec<Day>,
pub programmes: Vec<Programme>,
}
/// A day's span, in microseconds.
pub const DAY_US: u64 = 86_400_000_000;
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// The start of a day written `YYYY-MM-DD` (§2), a date of the Gregorian calendar from 1970 to
/// 9999, in microseconds since 1970.
pub fn day_start(s: &str) -> Option<u64> {
let b = s.as_bytes();
let digits = |r: std::ops::Range<usize>| -> Option<i64> { b[r].iter().try_fold(0i64, |a, c| c.is_ascii_digit().then(|| a * 10 + (c - b'0') as i64)) };
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
return None;
}
let (y, m, d) = (digits(0..4)?, digits(5..7)?, digits(8..10)?);
let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let month_days = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
if !(1970..=9999).contains(&y) || !(1..=12).contains(&m) || d < 1 || d > month_days[(m - 1) as usize] {
return None;
}
Some(days_from_civil(y, m, d) as u64 * DAY_US)
}
/// A JSON integer from 0 to 2^64 − 1.
fn int(v: &Value) -> Option<u64> {
v.as_u64()
}
fn members(o: &Map<String, Value>, required: &[&str], optional: &[&str]) -> bool {
required.iter().all(|k| o.contains_key(*k)) && o.keys().all(|k| required.contains(&k.as_str()) || optional.contains(&k.as_str()))
}
fn from_json(v: &Value) -> Option<Reported> {
let o = v.as_object()?;
if !members(o, &["channel", "start", "end", "currency", "basis", "impressions", "revenue"], &["days", "programmes"]) {
return None;
}
let mut days = Vec::new();
let list = |k: &str| -> Option<&[Value]> { o.get(k).map_or(Some(&[][..]), |v| v.as_array().map(Vec::as_slice)) };
for d in list("days")? {
let d = d.as_object()?;
if !members(d, &["day", "impressions", "revenue"], &[]) {
return None;
}
let day = d["day"].as_str()?.to_string();
days.push(Day { start: day_start(&day)?, day, impressions: int(&d["impressions"])?, revenue: int(&d["revenue"])? });
}
let mut programmes = Vec::new();
for p in list("programmes")? {
let p = p.as_object()?;
if !members(p, &["asset", "impressions", "revenue"], &[]) {
return None;
}
programmes.push(Programme { asset: p["asset"].as_str()?.as_bytes().to_vec(), impressions: int(&p["impressions"])?, revenue: int(&p["revenue"])? });
}
Some(Reported {
channel: o["channel"].as_str()?.as_bytes().to_vec(),
start: int(&o["start"])?,
end: int(&o["end"])?,
currency: o["currency"].as_str()?.as_bytes().try_into().ok()?,
basis: basis::code(o["basis"].as_str()?)?,
impressions: int(&o["impressions"])?,
revenue: int(&o["revenue"])?,
days,
programmes,
})
}
/// RFC 4180 records (§9.2): each ended by CRLF or LF, the last line break optional.
fn records(text: &str) -> Option<Vec<Vec<String>>> {
let b = text.as_bytes();
let n = b.len();
let mut out = Vec::new();
let mut p = 0;
if n == 0 {
return None;
}
loop {
let mut rec = Vec::new();
loop {
let mut f = Vec::new();
if p < n && b[p] == b'"' {
p += 1;
loop {
if p >= n {
return None;
}
if b[p] == b'"' {
if p + 1 < n && b[p + 1] == b'"' {
f.push(b'"');
p += 2;
} else {
p += 1;
break;
}
} else {
f.push(b[p]);
p += 1;
}
}
} else {
while p < n && !matches!(b[p], b',' | b'\r' | b'\n') {
if b[p] == b'"' {
return None;
}
f.push(b[p]);
p += 1;
}
}
rec.push(String::from_utf8(f).ok()?);
if p < n && b[p] == b',' {
p += 1;
continue;
}
break;
}
out.push(rec);
if p == n {
break;
}
match b[p] {
b'\r' if p + 1 < n && b[p + 1] == b'\n' => p += 2,
b'\n' => p += 1,
_ => return None,
}
if p == n {
break;
}
}
Some(out)
}
/// An integer of §9.2: `0`, or a digit 1 to 9 and digits, at most 2^64 − 1.
fn csv_int(s: &str) -> Option<u64> {
let b = s.as_bytes();
if b.is_empty() || !b.iter().all(u8::is_ascii_digit) || (b.len() > 1 && b[0] == b'0') {
return None;
}
s.parse().ok()
}
fn from_csv(text: &str) -> Option<Reported> {
let recs = records(text)?;
if recs.iter().any(|r| r.len() != 4) {
return None;
}
let mut it = recs.iter();
if it.next()? != &["scope", "key", "impressions", "revenue"] {
return None;
}
let mut meta = |name: &str| -> Option<String> {
let r = it.next()?;
(r[0] == name && r[2].is_empty() && r[3].is_empty()).then(|| r[1].clone())
};
let channel = meta("channel")?;
let start = csv_int(&meta("start")?)?;
let end = csv_int(&meta("end")?)?;
let currency = meta("currency")?;
let basis_ = meta("basis")?;
let rest: Vec<&Vec<String>> = it.collect();
let (period, rows) = rest.split_first()?;
if period[0] != "period" || !period[1].is_empty() {
return None;
}
let mut days = Vec::new();
let mut programmes = Vec::new();
let mut in_programmes = false;
for r in rows {
let (impressions, revenue) = (csv_int(&r[2])?, csv_int(&r[3])?);
match r[0].as_str() {
"day" if !in_programmes => days.push(Day { start: day_start(&r[1])?, day: r[1].clone(), impressions, revenue }),
"programme" => {
in_programmes = true;
programmes.push(Programme { asset: r[1].as_bytes().to_vec(), impressions, revenue });
}
_ => return None,
}
}
Some(Reported {
channel: channel.into_bytes(),
start,
end,
currency: currency.as_bytes().try_into().ok()?,
basis: basis::code(&basis_)?,
impressions: csv_int(&period[2])?,
revenue: csv_int(&period[3])?,
days,
programmes,
})
}
/// §9.3's rules, short of the binding to the contract and the report.
fn rules(r: &Reported) -> bool {
let text = |t: &[u8]| (1..=1024).contains(&t.len());
text(&r.channel)
&& r.currency.iter().all(u8::is_ascii_uppercase)
&& r.start < r.end
&& r.days.iter().all(|d| r.start <= d.start && d.start.checked_add(DAY_US).is_some_and(|e| e <= r.end))
&& r.days.windows(2).all(|w| w[0].start < w[1].start)
&& r.programmes.iter().all(|p| text(&p.asset))
&& r.programmes.windows(2).all(|w| w[0].asset < w[1].asset)
}
/// Read a reported statement of a form (§9); `None` is `reported_invalid`.
pub fn read(form_: u8, text: &str) -> Option<Reported> {
let r = match form_ {
form::JSON => from_json(&serde_json::from_str(text).ok()?)?,
form::CSV => from_csv(text)?,
_ => return None,
};
rules(&r).then_some(r)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days() {
assert_eq!(day_start("1970-01-01"), Some(0));
assert_eq!(day_start("2026-10-16"), Some(1_792_108_800_000_000));
assert_eq!(day_start("2024-02-29"), Some(19_782 * DAY_US));
assert_eq!(day_start("2026-02-29"), None);
assert_eq!(day_start("1969-12-31"), None);
assert_eq!(day_start("2026-1-016"), None);
assert_eq!(day_start("2026-13-01"), None);
}
#[test]
fn the_two_forms_read_alike() {
let json = r#"{"channel":"fast-9","start":0,"end":172800000000,"currency":"EUR","basis":"share","impressions":5,"revenue":7,
"days":[{"day":"1970-01-01","impressions":2,"revenue":3},{"day":"1970-01-02","impressions":3,"revenue":4}],
"programmes":[{"asset":"a,\"b\"","impressions":5,"revenue":7}]}"#;
let csv = "scope,key,impressions,revenue\r\nchannel,fast-9,,\r\nstart,0,,\r\nend,172800000000,,\r\ncurrency,EUR,,\r\nbasis,share,,\r\nperiod,,5,7\r\nday,1970-01-01,2,3\nday,1970-01-02,3,4\nprogramme,\"a,\"\"b\"\"\",5,7";
let a = read(form::JSON, json).expect("the JSON form reads");
assert_eq!(read(form::CSV, csv), Some(a));
}
#[test]
fn what_does_not_read() {
let base = "scope,key,impressions,revenue\nchannel,fast-9,,\nstart,0,,\nend,86400000000,,\ncurrency,EUR,,\nbasis,gross,,\nperiod,,5,7\n";
assert!(read(form::CSV, base).is_some());
assert!(read(form::CSV, &base.replace("period,,5", "period,,05")).is_none());
assert!(read(form::CSV, &base.replace("basis,gross", "basis,net")).is_none());
assert!(read(form::CSV, &format!("{base}day,1970-01-02,1,1\n")).is_none(), "a day past the period");
assert!(read(form::CSV, &format!("{base}programme,b,1,1\nprogramme,a,1,1\n")).is_none(), "programmes out of order");
assert!(read(form::CSV, &format!("{base}programme,a,1,1\nday,1970-01-01,1,1\n")).is_none(), "a day after a programme");
assert!(read(form::CSV, &base.replace("fast-9", "fa\"st")).is_none(), "a bare quote");
assert!(read(form::CSV, &format!("{base}\n")).is_none(), "an empty record");
assert!(read(form::JSON, r#"{"channel":"x","start":0,"end":1,"currency":"EUR","basis":"gross","impressions":1.0,"revenue":1}"#).is_none());
assert!(read(form::JSON, r#"{"channel":"x","start":0,"end":1,"currency":"EUR","basis":"gross","impressions":1,"revenue":1,"note":""}"#).is_none());
}
}

View file

@ -0,0 +1,476 @@
//! The subset of an SCTE 35-1 `splice_info_section()` the opportunity report reads
//! (extensions/opportunity-report.md §5): the CRC, the header, `splice_insert()`,
//! `time_signal()` and `splice_null()`, and the segmentation descriptors of the descriptor loop,
//! for what a section states (§5.2): its id, its duration and its slots. A section is read only as
//! far as this; another command or descriptor is passed over whole.
//!
//! [`write`] builds sections from their fields for the corpus.
/// The MPEG-2 CRC (ISO/IEC 13818-1 Annex A): polynomial 0x04C11DB7, register preset to all ones,
/// no reflection, no final inversion.
pub fn crc32(b: &[u8]) -> u32 {
let mut c: u32 = 0xFFFF_FFFF;
for &x in b {
c ^= (x as u32) << 24;
for _ in 0..8 {
c = if c & 0x8000_0000 != 0 { (c << 1) ^ 0x04C1_1DB7 } else { c << 1 };
}
}
c
}
/// The command types this text reads.
pub mod command {
pub const SPLICE_NULL: u8 = 0x00;
pub const SPLICE_INSERT: u8 = 0x05;
pub const TIME_SIGNAL: u8 = 0x06;
}
/// The segmentation types whose start makes an out (cue-injection §6.2).
pub const OPENING: [u8; 5] = [0x22, 0x30, 0x32, 0x34, 0x36];
/// The placement opportunity starts.
pub const PLACEMENT: [u8; 2] = [0x34, 0x36];
/// The types that may carry the sub-segment fields (SCTE 35-1 Table 20).
pub const SUB_SEGMENTED: [u8; 8] = [0x30, 0x32, 0x34, 0x36, 0x38, 0x3A, 0x44, 0x46];
/// A segmentation descriptor, as far as §5.1 reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Segmentation {
pub event_id: u32,
pub cancelled: bool,
pub duration: Option<u64>,
pub type_id: u8,
/// (`sub_segment_num`, `sub_segments_expected`), when carried.
pub sub: Option<(u8, u8)>,
}
/// What a section states (§5.2), and what §5.3 holds it to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct States {
/// `splice_command_type`, or `None` for an encrypted section.
pub command: Option<u8>,
/// A `splice_insert()`: (its cancel indicator, its `out_of_network_indicator`).
pub insert: Option<(bool, bool)>,
pub id: Option<u32>,
pub duration: Option<u64>,
pub slots: Option<u64>,
}
impl States {
fn nothing(command: Option<u8>) -> States {
States { command, insert: None, id: None, duration: None, slots: None }
}
/// The command's name, as the outcome states it.
pub fn command_name(&self) -> &'static str {
match self.command {
None => "encrypted",
Some(command::SPLICE_INSERT) => "splice_insert",
Some(command::TIME_SIGNAL) => "time_signal",
Some(command::SPLICE_NULL) => "splice_null",
Some(_) => "other",
}
}
}
/// A big-endian bit cursor over `b[..end]`; running out is a refusal.
struct Bits<'a> {
b: &'a [u8],
/// The position, in bits.
at: usize,
/// The end, in bytes.
end: usize,
}
impl Bits<'_> {
fn take(&mut self, n: usize) -> Result<u64, ()> {
if self.at + n > self.end * 8 {
return Err(());
}
let mut v: u64 = 0;
for _ in 0..n {
let bit = (self.b[self.at / 8] >> (7 - self.at % 8)) & 1;
v = (v << 1) | bit as u64;
self.at += 1;
}
Ok(v)
}
fn skip(&mut self, n_bytes: usize) -> Result<(), ()> {
if self.at / 8 + n_bytes > self.end {
return Err(());
}
self.at += 8 * n_bytes;
Ok(())
}
fn byte_pos(&self) -> usize {
self.at / 8
}
}
/// A `splice_time()`: one byte, or five with `pts_time`.
fn splice_time(r: &mut Bits) -> Result<(), ()> {
if r.take(1)? == 1 {
r.take(6)?;
r.take(33)?;
} else {
r.take(7)?;
}
Ok(())
}
/// `splice_insert()`: (cancel, out_of_network, splice_event_id, break duration).
fn splice_insert(r: &mut Bits) -> Result<(bool, bool, u32, Option<u64>), ()> {
let id = r.take(32)? as u32;
let cancel = r.take(1)? == 1;
r.take(7)?;
if cancel {
return Ok((true, false, id, None));
}
let out = r.take(1)? == 1;
let program = r.take(1)? == 1;
let duration_flag = r.take(1)? == 1;
let immediate = r.take(1)? == 1;
r.take(4)?;
if program && !immediate {
splice_time(r)?;
}
if !program {
let n = r.take(8)?;
for _ in 0..n {
r.take(8)?;
if !immediate {
splice_time(r)?;
}
}
}
let duration = if duration_flag {
r.take(1)?;
r.take(6)?;
Some(r.take(33)?)
} else {
None
};
r.take(16)?;
r.take(8)?;
r.take(8)?;
Ok((false, out, id, duration))
}
/// A segmentation descriptor's body after its identifier, filling `[from, to)` exactly.
fn segmentation(b: &[u8], from: usize, to: usize) -> Result<Segmentation, ()> {
let mut r = Bits { b, at: from * 8, end: to };
let event_id = r.take(32)? as u32;
let cancelled = r.take(1)? == 1;
r.take(7)?;
if cancelled {
return if r.byte_pos() == to { Ok(Segmentation { event_id, cancelled, duration: None, type_id: 0, sub: None }) } else { Err(()) };
}
let program = r.take(1)? == 1;
let duration_flag = r.take(1)? == 1;
r.take(6)?;
if !program {
let n = r.take(8)?;
r.skip(6 * n as usize)?;
}
let duration = if duration_flag { Some(r.take(40)?) } else { None };
r.take(8)?;
let upid_len = r.take(8)?;
r.skip(upid_len as usize)?;
let type_id = r.take(8)? as u8;
r.take(8)?;
r.take(8)?;
let left = to - r.byte_pos();
let sub = match left {
0 => None,
2 if SUB_SEGMENTED.contains(&type_id) => Some((r.take(8)? as u8, r.take(8)? as u8)),
_ => return Err(()),
};
Ok(Segmentation { event_id, cancelled, duration, type_id, sub })
}
/// A section that does not read (§5.1): `cue_section_invalid`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Invalid;
/// Read a section (§5.1) for what it states (§5.2).
pub fn read(s: &[u8]) -> Result<States, Invalid> {
read_section(s).map_err(|()| Invalid)
}
fn read_section(s: &[u8]) -> Result<States, ()> {
let n = s.len();
if n < 20 {
return Err(());
}
let crc_at = n - 4;
if crc32(&s[..crc_at]) != u32::from_be_bytes(s[crc_at..].try_into().expect("4 bytes")) {
return Err(());
}
if s[3] != 0 {
return Err(());
}
if s[4] >> 7 == 1 {
return Ok(States::nothing(None));
}
let cmd_len = (((s[11] & 0x0F) as usize) << 8) | s[12] as usize;
let cmd = s[13];
let readable = matches!(cmd, command::SPLICE_INSERT | command::TIME_SIGNAL | command::SPLICE_NULL);
if cmd_len == 0xFFF && !readable {
return Ok(States::nothing(Some(cmd)));
}
let end = if cmd_len == 0xFFF { crc_at } else { 14 + cmd_len };
if end > crc_at {
return Err(());
}
let mut r = Bits { b: s, at: 14 * 8, end };
let mut st = States::nothing(Some(cmd));
match cmd {
command::SPLICE_INSERT => {
let (cancel, out, id, duration) = splice_insert(&mut r)?;
st.insert = Some((cancel, out));
st.id = Some(id);
st.duration = duration;
}
command::TIME_SIGNAL => splice_time(&mut r)?,
command::SPLICE_NULL => {}
_ => r.at = end * 8,
}
let cmd_end = r.byte_pos();
if cmd_len != 0xFFF && cmd_end != end {
return Err(());
}
if cmd_end + 2 > crc_at {
return Err(());
}
let loop_len = u16::from_be_bytes([s[cmd_end], s[cmd_end + 1]]) as usize;
let mut p = cmd_end + 2;
if p + loop_len != crc_at {
return Err(());
}
let mut segs = Vec::new();
while p < crc_at {
if p + 2 > crc_at {
return Err(());
}
let (tag, len) = (s[p], s[p + 1] as usize);
let body = p + 2;
if body + len > crc_at {
return Err(());
}
if tag == 0x02 && len >= 4 && &s[body..body + 4] == b"CUEI" {
segs.push(segmentation(s, body + 4, body + len)?);
}
p = body + len;
}
let live = |x: &&Segmentation| !x.cancelled;
if cmd == command::TIME_SIGNAL
&& let Some(o) = segs.iter().filter(live).find(|x| OPENING.contains(&x.type_id))
{
st.id = Some(o.event_id);
st.duration = o.duration;
}
st.slots = segs.iter().filter(live).find(|x| PLACEMENT.contains(&x.type_id)).and_then(|x| x.sub).map(|(_, e)| e as u64).filter(|e| *e > 0);
Ok(st)
}
// ---------------------------------------------------------------------------------------
// Writing, for the corpus
// ---------------------------------------------------------------------------------------
/// A big-endian bit writer.
#[derive(Default)]
struct Out {
b: Vec<u8>,
bits: usize,
}
impl Out {
fn put(&mut self, n: usize, v: u64) {
for i in (0..n).rev() {
if self.bits.is_multiple_of(8) {
self.b.push(0);
}
let bit = ((v >> i) & 1) as u8;
let last = self.b.len() - 1;
self.b[last] |= bit << (7 - self.bits % 8);
self.bits += 1;
}
}
}
/// A segmentation descriptor to write.
#[derive(Clone, Debug)]
pub struct SegmentationSpec {
pub event_id: u32,
pub cancelled: bool,
/// In 90 kHz ticks.
pub duration: Option<u64>,
pub type_id: u8,
pub sub: Option<(u8, u8)>,
}
/// A command to write.
#[derive(Clone, Debug)]
pub enum Command {
/// `splice_insert()`: id, cancel, out_of_network, and the break duration in ticks.
Insert { id: u32, cancel: bool, out: bool, duration: Option<u64> },
/// `time_signal()` at a `pts_time`.
TimeSignal { pts: u64 },
}
fn segmentation_bytes(d: &SegmentationSpec) -> Vec<u8> {
let mut o = Out::default();
o.put(32, u32::from_be_bytes(*b"CUEI") as u64);
o.put(32, d.event_id as u64);
o.put(1, d.cancelled as u64);
o.put(1, 1);
o.put(6, 0x3F);
if !d.cancelled {
o.put(1, 1); // program_segmentation_flag
o.put(1, d.duration.is_some() as u64);
o.put(1, 1); // delivery_not_restricted_flag
o.put(5, 0x1F);
if let Some(t) = d.duration {
o.put(40, t);
}
o.put(8, 0); // segmentation_upid_type: not used
o.put(8, 0); // segmentation_upid_length
o.put(8, d.type_id as u64);
o.put(8, 1); // segment_num
o.put(8, 1); // segments_expected
if let Some((n, e)) = d.sub {
o.put(8, n as u64);
o.put(8, e as u64);
}
}
let mut v = vec![0x02, o.b.len() as u8];
v.extend(o.b);
v
}
/// A section of one command and its segmentation descriptors, with its CRC.
pub fn write(cmd: &Command, descriptors: &[SegmentationSpec]) -> Vec<u8> {
let mut c = Out::default();
let ty = match cmd {
Command::Insert { id, cancel, out, duration } => {
c.put(32, *id as u64);
c.put(1, *cancel as u64);
c.put(7, 0x7F);
if !cancel {
c.put(1, *out as u64);
c.put(1, 1); // program_splice_flag
c.put(1, duration.is_some() as u64);
c.put(1, 1); // splice_immediate_flag
c.put(1, 1); // event_id_compliance_flag
c.put(3, 0x7);
if let Some(t) = duration {
c.put(1, 1);
c.put(6, 0x3F);
c.put(33, *t);
}
c.put(16, 1); // unique_program_id
c.put(8, 0); // avail_num
c.put(8, 0); // avails_expected
}
command::SPLICE_INSERT
}
Command::TimeSignal { pts } => {
c.put(1, 1);
c.put(6, 0x3F);
c.put(33, *pts);
command::TIME_SIGNAL
}
};
let mut loop_ = Vec::new();
for d in descriptors {
loop_.extend(segmentation_bytes(d));
}
let mut body = Out::default();
body.put(8, 0); // protocol_version
body.put(1, 0); // encrypted_packet
body.put(6, 0); // encryption_algorithm
body.put(33, 0); // pts_adjustment
body.put(8, 0xFF); // cw_index
body.put(12, 0xFFF); // tier
body.put(12, c.b.len() as u64);
body.put(8, ty as u64);
let mut rest = body.b;
rest.extend(&c.b);
rest.extend((loop_.len() as u16).to_be_bytes());
rest.extend(loop_);
let section_length = rest.len() + 4;
let mut s = vec![0xFC, 0x30 | ((section_length >> 8) as u8 & 0x0F), section_length as u8];
s.extend(rest);
let crc = crc32(&s);
s.extend(crc.to_be_bytes());
s
}
#[cfg(test)]
mod tests {
use super::*;
/// ANSI/SCTE 35-1 §14.1's sample, as cue-injection §5.7 holds it: a `time_signal()` with a
/// Provider Placement Opportunity Start of 307 s.
const SAMPLE: &str = "fc3034000000000000fffff00506fe72bd0050001e021c435545494800008e7fcf0001a599b00808000000002ca0a18a3402009ac9d17e";
fn unhex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
#[test]
fn the_crc_of_the_standards_sample() {
let s = unhex(SAMPLE);
let n = s.len();
assert_eq!(crc32(&s[..n - 4]), u32::from_be_bytes(s[n - 4..].try_into().unwrap()));
}
#[test]
fn the_standards_sample_states_its_opportunity() {
let st = read(&unhex(SAMPLE)).expect("the sample reads");
assert_eq!(st.command, Some(command::TIME_SIGNAL));
assert_eq!(st.id, Some(0x4800_008e));
// 307 s at 90 kHz.
assert_eq!(st.duration, Some(307 * 90_000));
// Its descriptor (28 bytes) ends at `segments_expected`: it carries no sub-segment
// fields, so it states no slots.
assert_eq!(st.slots, None);
}
#[test]
fn written_sections_read_back() {
let s = write(&Command::Insert { id: 7, cancel: false, out: true, duration: Some(5_400_000) }, &[]);
let st = read(&s).unwrap();
assert_eq!((st.id, st.duration, st.slots, st.insert), (Some(7), Some(5_400_000), None, Some((false, true))));
let d = SegmentationSpec { event_id: 9, cancelled: false, duration: Some(8_100_000), type_id: 0x34, sub: Some((1, 3)) };
let s = write(&Command::TimeSignal { pts: 12_345 }, &[d]);
let st = read(&s).unwrap();
assert_eq!((st.id, st.duration, st.slots), (Some(9), Some(8_100_000), Some(3)));
}
#[test]
fn a_cancelled_descriptor_states_nothing() {
let c = SegmentationSpec { event_id: 3, cancelled: true, duration: None, type_id: 0, sub: None };
let d = SegmentationSpec { event_id: 4, cancelled: false, duration: None, type_id: 0x36, sub: Some((1, 2)) };
let st = read(&write(&Command::TimeSignal { pts: 0 }, &[c, d])).unwrap();
assert_eq!((st.id, st.slots), (Some(4), Some(2)));
}
#[test]
fn a_damaged_section_is_refused() {
let s = write(&Command::TimeSignal { pts: 1 }, &[]);
let mut crc = s.clone();
let n = crc.len();
crc[n - 1] ^= 1;
assert!(read(&crc).is_err());
assert!(read(&s[..19]).is_err());
// A descriptor loop that runs past the CRC, the CRC made good again.
let mut long = s.clone();
let at = long.len() - 6;
long[at + 1] = 9;
let n = long.len();
let c = crc32(&long[..n - 4]);
long[n - 4..].copy_from_slice(&c.to_be_bytes());
assert!(read(&long).is_err());
}
}

View file

@ -906,8 +906,9 @@ pub struct Evidence {
pub impressions: Vec<Impression>,
}
/// §8.2 step 4: the runs.
fn the_runs(c: &Contract, runs: &[Value]) -> Result<Vec<ChannelReceipt>, Refusal> {
/// §8.2 step 4: the runs. The opportunity report (extensions/opportunity-report.md §3.2) reads
/// runs as this step does.
pub(crate) fn the_runs(c: &Contract, runs: &[Value]) -> Result<Vec<ChannelReceipt>, Refusal> {
let mut out: Vec<ChannelReceipt> = Vec::with_capacity(runs.len());
for (k, v) in runs.iter().enumerate() {
let at = || json!(["run", k]);
@ -958,8 +959,9 @@ fn the_ledgers(c: &Contract, start: u64, end: u64, docs: &[Value]) -> Result<Vec
Ok(out)
}
/// The asset an instant's entry gives (§6.2): a programme's own, or by the rule.
fn asset_at(schedule: &[crate::wai_video_channel::ScheduleEntry], e: usize, rule_: u8) -> Option<&str> {
/// The asset an instant's entry gives (§6.2): a programme's own, or by the rule. The opportunity
/// report attributes a break by it (extensions/opportunity-report.md §6).
pub(crate) fn asset_at(schedule: &[crate::wai_video_channel::ScheduleEntry], e: usize, rule_: u8) -> Option<&str> {
let programme = |x: &&crate::wai_video_channel::ScheduleEntry| x.kind == EntryKind::Program;
if schedule[e].kind == EntryKind::Program {
return Some(&schedule[e].asset_id);

View file

@ -449,6 +449,23 @@ fn samples(d: &Path, origin_port: u16) -> Vec<(&'static str, Vec<String>, bool)>
true,
));
}
#[cfg(feature = "opportunity_report")]
{
// The opportunity report: owner B's report of the opportunity corpus's day, recomputed
// here from its runs and room, and the check of B's reported statement beside it.
use wai::opportunity_report::corpus;
let hexs = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
let w = corpus::world().unwrap();
let b = &w.reports["owner_b"];
std::fs::write(d.join("owner-b.key"), hexs(&corpus::key("owner-b").to_bytes())).unwrap();
std::fs::write(d.join("opp-inputs.json"), w.pools.report_document(b, false).to_string()).unwrap();
std::fs::write(d.join("opp-report.json"), w.pools.report_document(b, true).to_string()).unwrap();
std::fs::write(d.join("reported.json"), w.pools.reported["b_programmes"]["text"].as_str().unwrap()).unwrap();
let party = hexs(&corpus::pk("owner-b"));
let (start, end) = (corpus::DAY16.to_string(), corpus::DAY17.to_string());
more.push(("opportunity.create", s(&["opportunity", "create", "opp-inputs.json", "--party", &party, "--start", &start, "--end", &end, "--key", "owner-b.key", "--out", "b.wai-opportunity.json"]), true));
more.push(("statement.check", s(&["statement", "check", "opp-report.json", "reported.json", "--key", "owner-b.key", "--out", "b.wai-check.json"]), true));
}
#[cfg(feature = "generate")]
{
let model = wai_dir().join("wai-rs/tests/vectors/generate/generate_spectral_model.json");

View file

@ -0,0 +1,173 @@
//! `wai.delivery.opportunity` (extensions/opportunity-report.md): the committed corpus
//! `live-conformance/opportunity/vectors.json` regenerates byte for byte from its seeds and is the
//! only file there besides its README; every vector gets the outcome it states; every reason the
//! extension adds and every code it reuses refuses a vector; §13 lists exactly the reasons the
//! reference reports; and the worked example of §14: a channel's day of two owners, signed cues and
//! program-as-run, a reported statement that under-reports one owner's breaks, and the check that
//! shows it, computed and then recomputed from the public records alone.
#![cfg(feature = "opportunity_report")]
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use serde_json::{Value, json};
use wai::opportunity_report::{self as opp, Reason, corpus, scope};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn read(p: &str) -> String {
std::fs::read_to_string(wai_dir().join(p)).unwrap_or_else(|e| panic!("{p}: {e}"))
}
fn committed() -> String {
read("live-conformance/opportunity/vectors.json")
}
fn doc() -> Value {
serde_json::from_str(&committed()).expect("the corpus is JSON")
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
let generated = corpus::generate().expect("the corpus generates");
assert!(committed() == generated, "live-conformance/opportunity/vectors.json differs from `wai_opportunity_vectors gen`");
let mut files: Vec<String> = std::fs::read_dir(wai_dir().join("live-conformance/opportunity")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
files.sort();
assert_eq!(files, ["README.md", "vectors.json"]);
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// Every reason this extension adds and every code it reuses refuses a vector, and the texts it
/// delegates to refuse one each at their own places.
#[test]
fn the_corpus_covers_every_reason_and_case() {
let d = doc();
let vectors = d["vectors"].as_array().unwrap();
let seen: BTreeSet<&str> = vectors.iter().filter_map(|v| v["outcome"]["reason"].as_str()).collect();
for r in Reason::ALL {
assert!(seen.contains(r.code()), "nothing in the corpus is refused {r}");
}
for r in Reason::REUSED {
assert!(seen.contains(r), "nothing in the corpus is refused {r}");
}
let get = |n: &str| vectors.iter().find(|v| v["name"] == n).unwrap_or_else(|| panic!("no vector {n}"))["outcome"].clone();
assert_eq!(get("contract_offered")["reason"], "not_countersigned");
assert_eq!(get("run_aired_misstated")["at"], json!(["run", 0]));
assert_eq!(get("room_event_signature")["at"].as_array().unwrap()[..3], [json!("room"), json!(0), json!("room")]);
assert_eq!(get("check_report_refused")["at"], json!(["report", "report"]));
assert_eq!(get("check_statement_refused")["at"], json!(["statement", "statement"]));
for n in ["owner_a_day", "owner_b_day", "operator_day", "owner_a_runs_alone", "owner_b_two_rooms", "check_owner_b_under_reported", "check_owner_a_csv", "check_owner_b_without_statement", "check_owner_b_reports_a_programme"] {
assert_eq!(get(n)["outcome"], "accept", "{n}");
}
}
/// §13's table names exactly the codes the reference adds, in its order.
#[test]
fn section_13_lists_exactly_the_reference_codes() {
let text = read("extensions/opportunity-report.md");
let at = text.find("\n## 13. Reasons").expect("§13");
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
let listed: Vec<&str> = text[at..end].lines().filter_map(|l| l.strip_prefix("| `")).filter_map(|l| l.split('`').next()).collect();
let codes: Vec<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
assert_eq!(listed, codes);
}
fn line<'a>(o: &'a Value, scope_: &str, key: &str) -> &'a Value {
o["lines"].as_array().unwrap().iter().find(|l| l["scope"] == scope_ && l["key"] == key).unwrap_or_else(|| panic!("no line {scope_} {key}"))
}
/// The worked example (§14). Owner B computes and signs its report of the day from the channel's
/// two windows and the room's cues; its platform reports B's impressions per programme, and all
/// but leaves out B's second documentary; B checks the report against it and against the
/// operator's signed statement of the day. A party holding only the public records receives the
/// check's document as text, verifies it, and recomputes the opportunities of every line from
/// the attributed breaks and the contract alone.
#[test]
fn the_worked_example_shows_the_under_report_from_the_public_records() {
let w = corpus::world().expect("the world");
let b = &w.reports["owner_b"];
// B's report, computed from the documents without it, is the corpus's, byte for byte.
let (rep, report_doc) = opp::create(&w.pools.report_document(b, false), &corpus::key("owner-b"), corpus::pk("owner-b"), corpus::DAY16, corpus::DAY17).expect("B's report");
assert_eq!(rep.to_bytes(), b.report);
// Four cued breaks in B's documentaries: 360 s, twelve slots (four, two, four, two).
assert_eq!((rep.breaks, rep.break_ms, rep.slots), (4, 360_000, 12));
// The check of the platform's figures, beside the signed statement.
let inputs = w.pools.check_document(b, "b_programmes", Some(&w.statements["owner_b"]), None);
let (_, document) = opp::create_check(&inputs, &corpus::key("owner-b"), opp::FLOOR_PPM, opp::CEILING_PPM).expect("the check");
// A party holding only the public records: the document, as text.
let text = serde_json::to_string(&document).unwrap();
let got: Value = serde_json::from_str(&text).unwrap();
let a = opp::verify_check(&got).expect("the check verifies");
let o = opp::check_json(&a);
let period = line(&o, "period", "");
// Fourteen impressions reported against twenty-four the signed records prove, and revenue
// below B's signed part.
assert_eq!((period["impressions"].as_u64(), period["signed"]["impressions"].as_u64()), (Some(14), Some(24)));
assert_eq!(period["fill_ppm"], 1_166_666);
assert_eq!(period["flags"], json!(["impressions_below_signed", "revenue_below_signed"]));
// The first documentary is in step with the day; the second, as many slots and two
// impressions, falls far below it.
let doc_1 = line(&o, "programme", "b/doc-1");
let doc_2 = line(&o, "programme", "b/doc-2");
assert_eq!((doc_1["slots"].as_u64(), doc_1["flags"].clone()), (Some(6), json!([])));
assert_eq!((doc_2["slots"].as_u64(), doc_2["fill_ppm"].as_u64(), doc_2["flags"].clone()), (Some(6), Some(333_333), json!(["below"])));
// Recomputed from the report's attributed breaks and the contract: each programme's breaks,
// seconds and slots, over the breaks of the splits B shares in.
let report = opp::verify(&report_doc).expect("the report verifies");
let r = opp::accepted_json(&report);
let b_splits: BTreeSet<u64> = r["lines"].as_array().unwrap().iter().map(|l| l["split"].as_u64().unwrap()).collect();
let mut per: BTreeMap<String, (u64, u64, u64)> = BTreeMap::new();
for x in r["breaks"].as_array().unwrap() {
if !b_splits.contains(&x["split"].as_u64().unwrap()) {
continue;
}
let e = per.entry(x["asset"].as_str().unwrap_or("").to_string()).or_default();
e.0 += 1;
e.1 += x["duration_ms"].as_u64().unwrap();
e.2 += x["slots"].as_u64().unwrap_or(0);
}
for p in ["b/doc-1", "b/doc-2"] {
let l = line(&o, "programme", p);
assert_eq!(per[p], (l["breaks"].as_u64().unwrap(), l["break_ms"].as_u64().unwrap(), l["slots"].as_u64().unwrap()), "{p}");
}
// The signed figures, from the statement's own lines.
let st = &a.statement.as_ref().expect("the statement").statement;
assert_eq!(st.lines.iter().map(|l| l.impressions).sum::<u64>(), 24);
assert_eq!(period["signed"]["revenue"].as_u64(), Some(st.total));
// The check's lines are the only figures that change with the reported statement: the same
// report and statement beside owner A's fair CSV flag nothing.
let d = doc();
let v = d["vectors"].as_array().unwrap().iter().find(|v| v["name"] == "check_owner_a_csv").expect("A's check vector");
let fair = opp::verify_check(&corpus::document(&d, v).expect("it reads")).expect("A's check");
assert!(fair.check.lines.iter().all(|l| l.flags == 0), "A's reported statement is what its records prove");
assert_eq!(fair.check.lines[0].scope, scope::PERIOD);
}
/// Owner A's day holds every kind of break §4 makes: a break inside the ad slot after A's first
/// episode counts as cued and the slot is not counted again; the slot no cue covers is scheduled
/// and unslotted; slots come from a section or from the policy.
#[test]
fn owner_a_holds_every_kind_of_break() {
let w = corpus::world().expect("the world");
let a = opp::verify(&w.pools.report_document(&w.reports["owner_a"], true)).expect("A's report");
let mine: Vec<&opp::Break> = a.counted().collect();
let cued: Vec<&&opp::Break> = mine.iter().filter(|b| b.class == opp::class::CUED).collect();
let scheduled: Vec<&&opp::Break> = mine.iter().filter(|b| b.class == opp::class::SCHEDULED).collect();
assert_eq!(cued.len(), 4);
assert_eq!(scheduled.len(), 1);
assert_eq!((scheduled[0].slots, scheduled[0].asset.as_deref()), (None, Some("a/ep-2")), "the uncued ad slot, by the rule preceding");
let sources: Vec<Option<&str>> = cued.iter().map(|b| b.slot_source).collect();
assert_eq!(sources, [Some("policy"), Some("section"), Some("section"), Some("section")]);
assert_eq!(cued[2].asset.as_deref(), Some("a/ep-1"), "the break inside the ad slot, by the rule preceding");
let runs_alone = opp::verify(&w.pools.report_document(&w.reports["owner_a_runs_alone"], true)).expect("from the runs alone");
assert_eq!(runs_alone.counted().filter(|b| b.class == opp::class::SCHEDULED).count(), 2, "with no room, both ad slots are scheduled");
}

View file

@ -1,7 +1,7 @@
// GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the
// catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run
// `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs.
export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"0ef1aab4f7b44a109dc7a0168c9394b0338458fe5fd23c75feb8892818304e1a"};
export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.4.0","sha256":"ab16d7ceb6b7dfd8f50d469bc6efbb7451fee513da067a4408aeec078c4ffa10"};
export const ACTIONS = [
{"id":"toolchain.test","noun":"toolchain","verb":"test","menu":"Toolchain","item":"Test","palette":"toolchain test","summary":"WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai hello","mcp":null,"route":null,"call":"toolchain_test"},
{"id":"project.create","noun":"project","verb":"create","menu":"Project","item":"Create","palette":"project create","summary":"A new project from one of the examples, written into a new or empty directory; --list names the templates","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai create","mcp":null,"route":null,"call":"project_create"},
@ -99,6 +99,9 @@ export const ACTIONS = [
{"id":"contract.update","noun":"contract","verb":"update","menu":"Contract","item":"Update","palette":"contract update","summary":"An owner's countersignature added to a share contract, after its rules and the operator's signature are checked","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai contract accept","mcp":null,"route":null,"call":"contract_update"},
{"id":"statement.create","noun":"statement","verb":"create","menu":"Statement","item":"Create","palette":"statement create","summary":"A party's revenue-share statement for a period, recomputed from the ledger's invoices and the channel's program-as-run, signed with the operator key","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai statement create","mcp":null,"route":null,"call":"statement_create"},
{"id":"statement.verify","noun":"statement","verb":"verify","menu":"Statement","item":"Verify","palette":"statement verify","summary":"A revenue-share statement's verification document checked: every line recomputed from the public records, or the reason it is refused","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai statement verify","mcp":null,"route":null,"call":"statement_verify"},
{"id":"opportunity.create","noun":"opportunity","verb":"create","menu":"Opportunity","item":"Create","palette":"opportunity create","summary":"A party's opportunity report for a period: the ad breaks a channel's signed cues and program-as-run prove in the programmes it shares in, signed with the issuer key","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai opportunity create","mcp":null,"route":null,"call":"opportunity_create"},
{"id":"opportunity.verify","noun":"opportunity","verb":"verify","menu":"Opportunity","item":"Verify","palette":"opportunity verify","summary":"An opportunity report's or a statement check's verification document checked: every break, line and flag recomputed from the records, or the reason it is refused","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai opportunity verify","mcp":null,"route":null,"call":"opportunity_verify"},
{"id":"statement.check","noun":"statement","verb":"check","menu":"Statement","item":"Check","palette":"statement check","summary":"A platform's reported statement checked against an opportunity report and, when given, the operator's signed statement, signed with the issuer key","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai statement check","mcp":null,"route":null,"call":"statement_check"},
{"id":"ops.list","noun":"ops","verb":"list","menu":"Ops","item":"List","palette":"ops list","summary":"Every operation of this catalogue: its id, command line, effect and whether this build holds it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops list","mcp":null,"route":null,"call":"ops_list"},
{"id":"ops.search","noun":"ops","verb":"search","menu":"Ops","item":"Search","palette":"ops search","summary":"The operations that match a task, ranked, offline: up to five","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops search","mcp":null,"route":null,"call":"ops_search"},
{"id":"ops.show","noun":"ops","verb":"show","menu":"Ops","item":"Show","palette":"ops show","summary":"One operation's entry: its input and output schemas, effect, placement and command line","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops show","mcp":null,"route":null,"call":"ops_show"},