wai: the opportunity report and the statement check (wai.delivery.opportunity) #541
28 changed files with 11744 additions and 19 deletions
8
.github/workflows/wai-catalogue.yml
vendored
8
.github/workflows/wai-catalogue.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
33
.github/workflows/wai-conformance.yml
vendored
33
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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::"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
18
wai/SPEC.md
18
wai/SPEC.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
643
wai/extensions/opportunity-report.md
Normal file
643
wai/extensions/opportunity-report.md
Normal 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.
|
||||
87
wai/live-conformance/opportunity/README.md
Normal file
87
wai/live-conformance/opportunity/README.md
Normal 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.
|
||||
5714
wai/live-conformance/opportunity/vectors.json
Normal file
5714
wai/live-conformance/opportunity/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
1110
wai/live-conformance/opportunity_report_verify.py
Normal file
1110
wai/live-conformance/opportunity_report_verify.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
187
wai/wai-rs/src/bin/wai_cli/dx/opportunity.rs
Normal file
187
wai/wai-rs/src/bin/wai_cli/dx/opportunity.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
61
wai/wai-rs/src/bin/wai_opportunity_vectors.rs
Normal file
61
wai/wai-rs/src/bin/wai_opportunity_vectors.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1551
wai/wai-rs/src/opportunity_report.rs
Normal file
1551
wai/wai-rs/src/opportunity_report.rs
Normal file
File diff suppressed because it is too large
Load diff
853
wai/wai-rs/src/opportunity_report/corpus.rs
Normal file
853
wai/wai-rs/src/opportunity_report/corpus.rs
Normal 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
|
||||
}
|
||||
341
wai/wai-rs/src/opportunity_report/reported.rs
Normal file
341
wai/wai-rs/src/opportunity_report/reported.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
476
wai/wai-rs/src/opportunity_report/section.rs
Normal file
476
wai/wai-rs/src/opportunity_report/section.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
173
wai/wai-rs/tests/opportunity_report.rs
Normal file
173
wai/wai-rs/tests/opportunity_report.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Reference in a new issue