Some checks failed
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 14s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 36s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 16s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 41s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 37s
wai-audit collector and player integration / does this change touch it (push) Successful in 36s
wai-fleet service / does this change touch it (push) Successful in 50s
wai-sink-android native sink library / does this change touch it (push) Successful in 35s
wai-ledger service / does this change touch it (push) Successful in 58s
wai-quantum studio and desktop / does this change touch it (push) Successful in 59s
WAI operation catalogue / does this change touch it (push) Successful in 1m5s
wai-sink-swift native sink package / does this change touch it (push) Successful in 26s
wai-key-proxy / does this change touch it (push) Successful in 1m1s
wai-quantum for JavaScript / does this change touch it (push) Successful in 59s
wai-app and Create / wai-app · does this change touch it (push) Successful in 1m13s
WAI Studio / does this change touch it (push) Successful in 42s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 1m18s
wai-fleet service / service · macos (push) Successful in 1m2s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 37s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 1m44s
wai-audit collector and player integration / collector · linux (push) Successful in 2m9s
wai-ledger service / service · macos (push) Successful in 2m24s
wai-key-proxy / proxy · linux (push) Successful in 2m8s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 3m21s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 3m30s
WAI Studio / Studio in WebKit · linux (push) Failing after 51s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m38s
wai-ledger service / service · linux (push) Successful in 4m17s
wai-quantum studio and desktop / desktop app · macos (push) Successful in 3m27s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 6m15s
wai-fleet service / service · linux (push) Successful in 4m51s
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 6m33s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 4m53s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 7m8s
wai-quantum for JavaScript / javascript · linux (push) Successful in 6m16s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 9m31s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 9m28s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 9m3s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 9m59s
wai-quantum studio and desktop / studio · browser (push) Successful in 10m26s
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 12m55s
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Successful in 13m18s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 13m10s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 13m21s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 18m47s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 18m25s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 17m24s
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Successful in 22m34s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 12m33s
extensions/sink-predicate.md registers the record `wai.delivery.sink_predicate` (`wai-sink-predicate`): a source's signed predicate travelling beside a programme, the registered form of unidirectional-delivery §2's sink-evaluated predicate. It carries the programme's blackouts (audience-decision §4.2) and its ad slots, each a list of candidate ads in the order the source prefers them, with the regions each is shown in and its frequency caps (a counter, a limit, a window), and declares exactly the state classes it reads (`location`, `exposures`); it is a route-record document signed in its canonical form under `wai:sink-predicate`. A sink evaluates it from its own state alone: the programme's verdict is audience-decision §4.6's at its aggregated location (unresolved, every blackout applied, when it holds none), and when that is `show` it fills each slot itself, taking in order the candidates shown at its location (a country of which a candidate lists only a subdivision is shown neither `only` nor `except`), whose caps its own exposures have not reached in their windows, with those the earlier slots and candidates added, and that fit the avail. A sink that keeps no exposures is shown no capped candidate. The evaluation causes no request and nothing about the device leaves it. A predicate holding, at any depth, a member by which its evaluation would send something away (`report`, `beacon`, `callback`, `uri`) or count across devices (`household`, `account`) is refused `predicate_needs_report`; a sink's log of a slot that presents a candidate whose cap was reached is refused `predicate_cap_breached`, one not shown at the location `predicate_not_targeted`, and any other departure from the fill `predicate_fill_mismatch`. unidirectional-delivery §2 and audience-decision §1 and §6 name it. live-compose §5.10 adds per-session ad tracks: in a squeeze or a double box, a session's sink may show the session's own ad, decided by a stitched-presentation pod-input record of that session, on the session's ad track (owner `ad_` and the session), bound to the composition by an ad-track binding, `WAT1`, signed by the record's signer under `wai:live-compose-ad-track`. A binding that does not match its session's composition is refused `ad_track_mismatch`, naming what differs: a descriptor that is not an ad frame, a record other than the one it names, a shared record or another session's, a record whose frame is not the descriptor's (layout, canvas, corner, window, gap, aspect), or a slot the record does not have; one signed by another key `ad_track_not_permitted`; one out of its bounds `ad_track_invalid`. While a binding is in effect (from `start` for its ad's duration), the session's layout is §5.9's with the track in the ad tile, the program where the room's frame puts it, and the room's ad participant with no tile and no voice. live-compose's §10 table, its descriptor and its receipt are unchanged; its codes for the binding are §5.10.5's. stitched-presentation §4.6 and §6: a composited break whose ad region shows each session its own ad is decided by a record of each session under the room's ad frame, never by a shared record, and the session's claim is that record's one presentation; no record, claim or verification rule changes. The references are the `sink_predicate` module of wai-rs (feature `sink_predicate`) and the `compose_ad_track` module (feature `compose_ad_track`). The corpora are live-conformance/sink-predicate/ (68 predicates, 23 evaluations, 18 logs) and live-conformance/compose-ad-track/ (36 bindings, 17 layouts); sink_predicate_verify.py and compose_ad_track_verify.py are their second implementations, which agree on every case, fail every mutated copy (17 of 17, 13 of 13) and catch every weakened verifier (35 of 35, 21 of 21). Nineteen faults planted in the reference each fail its tests, each matched by a check the second verifier drops: a cap breach (the fill ignoring caps, the log check ignoring them, an exposure a whole window old still counted, a sink that keeps none admitting a capped candidate); a predicate needing a report (the check removed, read only at the top level, the across-device members dropped); a per-session ad track not matching its session's composition (another frame, another session's record, a shared record, a descriptor that is no ad frame, a missing slot, another signer, the track kept at its binding's end); and a signature over the wrong bytes (a predicate's without its domain, under the audience decision's, over the text as written; a binding's without its domain and under the pod-input record's). Co-Authored-By: Claude Opus 5.5 <[email protected]>
600 lines
24 KiB
Python
600 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Independent verifier of the sink-evaluated predicate corpus (extensions/sink-predicate.md).
|
|
|
|
Written from the extension's text. It shares no code with wai-rs, which generates the corpus, and
|
|
never runs it. It takes from the second implementations of the texts this extension builds on what
|
|
they define: documents, their canonical form, digest and tokens from route-record's
|
|
(`route_record_verify.py`, route-record §2); region codes, their country and *holds* from rung
|
|
price's (`rung_price_verify.py`, rung-price §2); a blackout's form, a location aggregated against
|
|
blackouts and what follows from them, and §5.5's territory, from the audience decision corpus's
|
|
(`audience_decision_verify.py`, audience-decision §4.2, §4.3, §4.6 and §5.5); and Ed25519
|
|
verification as SPEC §7 *Signatures* states it from the routine every Python verifier of WAI's
|
|
corpora shares (`../claim-conformance/_ed25519.py`). Everything this extension adds is written here:
|
|
|
|
- §3: a predicate's members and rules, the members by which it would send something away from the
|
|
device, the state classes it reads, its digest and its signature;
|
|
- §4: a sink's state, the programme's verdict, a candidate's regions and caps, a slot's fill, and
|
|
the evaluation;
|
|
- §5: a sink's log of a slot, checked.
|
|
|
|
For every case it requires the outcome the corpus states, member for member; that every reason of
|
|
§6's table appears in the corpus and that the corpus states no other; that no evaluation depends on
|
|
the order of the sink's exposures, on exposures of a counter no cap names, or on the order of a
|
|
document's members; that a candidate is shown exactly where audience-decision §5.5 puts the location
|
|
within its regions; and that each slot's fill, given as a log with the state the sink held before
|
|
that slot, conforms. It checks what no vector carries: the bound of a text. Then it mutates copies
|
|
of the corpus, one defect each, and requires every copy to fail; and it drops each of its own checks
|
|
in turn and requires the corpus to catch the weakened verifier.
|
|
|
|
python3 -I sink_predicate_verify.py <live-conformance/sink-predicate>
|
|
|
|
Exit status 0 only if every case verifies, every mutated copy fails and every weakened verifier is
|
|
caught. Python 3.8 or later; the `cryptography` package, which the audience decision verifier's
|
|
readers import.
|
|
"""
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
for d in ("claim-conformance", "jwp-conformance", "signer-conformance"):
|
|
sys.path.insert(0, os.path.join(HERE, "..", d))
|
|
import _ed25519 as ED # noqa: E402
|
|
import route_record_verify as RR # noqa: E402
|
|
import rung_price_verify as RP # noqa: E402
|
|
import audience_decision_verify as AD # noqa: E402
|
|
|
|
EXTENSION = os.path.join(HERE, "..", "extensions", "sink-predicate.md")
|
|
KIND = "wai-sink-predicate" # §3.1
|
|
DOMAIN = b"wai:sink-predicate\x01" # §3.4
|
|
MAX_TEXT = 1 << 20 # §3.5: 1,048,576 bytes
|
|
MAX_BLACKOUTS = 256 # §3.1
|
|
MAX_SLOTS = 64 # §3.1
|
|
MAX_CANDIDATES = 64 # §3.2
|
|
MAX_CAPS = 8 # §3.3
|
|
MAX_REGIONS = 8192 # §3.3
|
|
MAX_AVAIL = 3600000 # §3.2
|
|
MAX_LIMIT = 1000 # §3.3
|
|
PREDICATE = ["kind", "version", "source", "programme", "start", "end", "state", "blackouts", "slots", "sig"] # §3.1
|
|
SLOT = ["id", "at", "avail_ms", "candidates", "filler"] # §3.2
|
|
CANDIDATE = ["ad", "creative", "duration_ms", "show", "regions", "caps"] # §3.3
|
|
CAP = ["counter", "limit", "window"] # §3.3
|
|
SEND = ["report", "beacon", "callback", "uri"] # §3.5
|
|
ACROSS = ["household", "account"] # §3.5
|
|
SHOWS = ["everywhere", "only", "except"]
|
|
EXPOSURES, LOCATION = "exposures", "location" # §4.1
|
|
|
|
MALFORMED = "predicate_malformed"
|
|
NEEDS_REPORT = "predicate_needs_report"
|
|
SIGNATURE = "predicate_signature_invalid"
|
|
SOURCE_UNKNOWN = "predicate_source_unknown"
|
|
NOT_TARGETED = "predicate_not_targeted"
|
|
CAP_BREACHED = "predicate_cap_breached"
|
|
FILL_MISMATCH = "predicate_fill_mismatch"
|
|
|
|
# A weakened run drops one of these checks (see `WEAKENINGS`); None checks everything.
|
|
DROPPED = None
|
|
|
|
|
|
def drops(name):
|
|
return DROPPED == name
|
|
|
|
|
|
def reasons_table():
|
|
"""§6's codes, in order."""
|
|
text = open(EXTENSION, encoding="utf-8").read()
|
|
at = text.index("\n## 6. Reasons")
|
|
end = text.find("\n## ", at + 1)
|
|
return [r.split("`")[1] for r in text[at:end].splitlines() if r.startswith("| `")]
|
|
|
|
|
|
class Refused(Exception):
|
|
def __init__(self, reason):
|
|
super().__init__(reason)
|
|
self.reason = reason
|
|
|
|
|
|
def need(ok, reason, check=None):
|
|
"""Refuse with `reason` unless `ok`, or unless `check` is the check a weakened run drops."""
|
|
if not ok and not (check is not None and drops(check)):
|
|
raise Refused(reason)
|
|
|
|
|
|
# ------------------------------------------------------------------ §3
|
|
|
|
def names_a_report(v, depth=0):
|
|
"""§3.5: a member, at any depth, by which an evaluation would send something away."""
|
|
names = SEND if drops("report_across") else SEND + ACROSS
|
|
if isinstance(v, dict):
|
|
if any(k in names for k in v) and (depth == 0 or not drops("report_nested")):
|
|
return True
|
|
return any(names_a_report(x, depth + 1) for x in v.values())
|
|
if isinstance(v, list):
|
|
return any(names_a_report(x, depth + 1) for x in v)
|
|
return False
|
|
|
|
|
|
def reads(blackouts, slots):
|
|
"""§3.1: the state classes a predicate of these blackouts and slots reads."""
|
|
cands = [c for s in slots for c in s["candidates"]]
|
|
out = []
|
|
if any(c["caps"] for c in cands):
|
|
out.append(EXPOSURES)
|
|
if blackouts or any(c["show"] != "everywhere" for c in cands):
|
|
out.append(LOCATION)
|
|
return out
|
|
|
|
|
|
def cap_ok(k):
|
|
if not (RR.exactly(k, CAP) and RR.is_token(k["counter"]) and RR.is_int(k["limit"]) and RR.is_int(k["window"])):
|
|
return False
|
|
if not 1 <= k["limit"] <= MAX_LIMIT and not drops("cap_limit"):
|
|
return False
|
|
return k["window"] >= 1 or drops("cap_window")
|
|
|
|
|
|
def candidate_ok(c, avail):
|
|
if not (RR.exactly(c, CANDIDATE) and RR.is_token(c["ad"]) and RR.is_hex(c["creative"], 64)
|
|
and RR.is_int(c["duration_ms"]) and c["duration_ms"] >= 1):
|
|
return False
|
|
if c["duration_ms"] > avail and not drops("duration_in_avail"):
|
|
return False
|
|
if c["show"] not in SHOWS or not AD.regions_ok(c["regions"], 0, MAX_REGIONS):
|
|
return False
|
|
if (len(c["regions"]) == 0) != (c["show"] == "everywhere") and not drops("everywhere_empty"):
|
|
return False
|
|
caps = c["caps"]
|
|
if not (isinstance(caps, list) and len(caps) <= MAX_CAPS and all(cap_ok(k) for k in caps)):
|
|
return False
|
|
return AD.ascending([k["counter"] for k in caps]) or drops("caps_ascending")
|
|
|
|
|
|
def slot_ok(s, start, end):
|
|
if not (RR.exactly(s, SLOT) and RR.is_token(s["id"]) and RR.is_int(s["at"]) and RR.is_int(s["avail_ms"])):
|
|
return False
|
|
if not start <= s["at"] < end and not drops("at_in_span"):
|
|
return False
|
|
if not 1 <= s["avail_ms"] <= MAX_AVAIL:
|
|
return False
|
|
cs = s["candidates"]
|
|
if not (isinstance(cs, list) and 1 <= len(cs) <= MAX_CANDIDATES and all(candidate_ok(c, s["avail_ms"]) for c in cs)):
|
|
return False
|
|
if len({c["ad"] for c in cs}) != len(cs) and not drops("ads_distinct"):
|
|
return False
|
|
return AD.null_or_token(s["filler"])
|
|
|
|
|
|
def predicate_form(v):
|
|
"""Rules 1 to 4."""
|
|
if not RR.exactly(v, PREDICATE) or v["kind"] != KIND or not RR.same(v["version"], 1):
|
|
return False
|
|
if not (RR.is_hex(v["source"], 64) and RR.is_token(v["programme"]) and RR.is_int(v["start"])
|
|
and RR.is_int(v["end"]) and RR.is_hex(v["sig"], 128)):
|
|
return False
|
|
start, end = v["start"], v["end"]
|
|
if start >= end and not drops("start_below_end"):
|
|
return False
|
|
st = v["state"]
|
|
if not (isinstance(st, list) and all(x in (EXPOSURES, LOCATION) for x in st) and AD.ascending(st)):
|
|
return False
|
|
bs = v["blackouts"]
|
|
if not (isinstance(bs, list) and len(bs) <= MAX_BLACKOUTS and all(AD.blackout_ok(b) for b in bs)):
|
|
return False
|
|
if not AD.ascending([b["id"] for b in bs]) and not drops("blackouts_ascending"):
|
|
return False
|
|
ss = v["slots"]
|
|
if not (isinstance(ss, list) and len(ss) <= MAX_SLOTS and all(slot_ok(s, start, end) for s in ss)):
|
|
return False
|
|
if not all(ss[i]["at"] < ss[i + 1]["at"] for i in range(len(ss) - 1)) and not drops("slots_ascending"):
|
|
return False
|
|
if len({s["id"] for s in ss}) != len(ss) and not drops("slot_ids_distinct"):
|
|
return False
|
|
return st == reads(bs, ss) or drops("state_exact")
|
|
|
|
|
|
_VERIFIED = {}
|
|
|
|
|
|
def signed(v):
|
|
"""§3.4: an Ed25519 signature by `source` over the domain and the canonical form of `v` without
|
|
`sig`, verified strictly."""
|
|
if drops("signature"):
|
|
return True
|
|
body = {k: x for k, x in v.items() if k != "sig"}
|
|
form = json.dumps(body, separators=(",", ":"), ensure_ascii=False) if drops("signature_as_written") else RR.canonical(body)
|
|
domain = b"" if drops("signature_domain") else AD.DECISION_DOMAIN if drops("signature_other_domain") else DOMAIN
|
|
msg = domain + form.encode("utf-8")
|
|
k = (v["source"], hashlib.sha256(msg).digest(), v["sig"])
|
|
if k not in _VERIFIED:
|
|
try:
|
|
_VERIFIED[k] = ED.verify(bytes.fromhex(v["source"]), msg, bytes.fromhex(v["sig"])) is None
|
|
except ValueError:
|
|
_VERIFIED[k] = False
|
|
return _VERIFIED[k]
|
|
|
|
|
|
def read_predicate(text):
|
|
"""§3.5: the predicate's value, or Refused."""
|
|
v = AD.read_text(text)
|
|
need(v is not None, MALFORMED)
|
|
need(not names_a_report(v), NEEDS_REPORT, "report")
|
|
need(predicate_form(v), MALFORMED)
|
|
need(signed(v), SIGNATURE)
|
|
return v
|
|
|
|
|
|
def digest(v):
|
|
return RR.digest({k: x for k, x in v.items() if k != "sig"})
|
|
|
|
|
|
def predicate_outcome(text):
|
|
try:
|
|
v = read_predicate(text)
|
|
except Refused as r:
|
|
return {"outcome": "refuse", "reason": r.reason}
|
|
return {"outcome": "read", "digest": digest(v), "source": v["source"], "programme": v["programme"], "state": v["state"]}
|
|
|
|
|
|
# ------------------------------------------------------------------ §4
|
|
|
|
def shown(c, loc):
|
|
"""§4.3."""
|
|
if c["show"] == "everywhere":
|
|
return True
|
|
if loc is None:
|
|
return drops("no_location_shown")
|
|
held = RP.holds(c["regions"], loc)
|
|
if c["show"] == "only":
|
|
return held
|
|
country_of_listed = len(loc) == 2 and any(len(r) > 2 and RP.country(r) == loc for r in c["regions"])
|
|
return (not held or drops("except_held")) and (not country_of_listed or drops("undetermined"))
|
|
|
|
|
|
def reached(k, exposures, t):
|
|
"""§4.4."""
|
|
if drops("cap"):
|
|
return False
|
|
n = 0
|
|
for e in exposures:
|
|
if e["counter"] != k["counter"]:
|
|
continue
|
|
if e["at"] > t and not drops("window_lower"):
|
|
continue
|
|
if not (t < e["at"] + k["window"] or (drops("window_upper") and t == e["at"] + k["window"])):
|
|
continue
|
|
n += 1
|
|
return n >= k["limit"]
|
|
|
|
|
|
def admits(c, exposures, t):
|
|
"""§4.4: with no cap always; otherwise only when the exposures are kept and no cap is reached."""
|
|
if not c["caps"]:
|
|
return True
|
|
if exposures is None:
|
|
if not drops("not_kept"):
|
|
return False
|
|
exposures = []
|
|
return not any(reached(k, exposures, t) for k in c["caps"])
|
|
|
|
|
|
def expose(c, exposures, t):
|
|
if exposures is not None and not drops("expose"):
|
|
exposures.extend({"counter": k["counter"], "at": t} for k in c["caps"])
|
|
|
|
|
|
def fill(s, loc, exposures):
|
|
"""§4.5: (ads, rest) of the slot, the exposures it adds appended to `exposures`."""
|
|
left, ads = s["avail_ms"], []
|
|
for c in s["candidates"]:
|
|
if shown(c, loc) and admits(c, exposures, s["at"]) and (c["duration_ms"] <= left or drops("avail")):
|
|
left -= c["duration_ms"]
|
|
ads.append(c["ad"])
|
|
expose(c, exposures, s["at"])
|
|
return ads, left
|
|
|
|
|
|
def programme(v, loc):
|
|
"""§4.2: audience-decision §4.6 at the location's aggregation (§4.3 there)."""
|
|
e = AD.aggregate(v["blackouts"], loc)
|
|
return AD.follows(v["blackouts"], e["region"], e["resolved"])
|
|
|
|
|
|
def held_exposures(state):
|
|
x = state["exposures"]
|
|
return None if x is None else [dict(e) for e in x]
|
|
|
|
|
|
def read_for(text, state):
|
|
"""§4.6 steps 1 and 2."""
|
|
v = read_predicate(text)
|
|
need(v["source"] in state["sources"], SOURCE_UNKNOWN, "source")
|
|
return v
|
|
|
|
|
|
def evaluate(text, state):
|
|
"""§4.6: the evaluation, or Refused."""
|
|
v = read_for(text, state)
|
|
loc = state["location"]
|
|
applied, verdict, alternate = programme(v, loc)
|
|
fills = []
|
|
if verdict == "show" or drops("verdict_gates"):
|
|
x = held_exposures(state)
|
|
for s in v["slots"]:
|
|
ads, rest = fill(s, loc, held_exposures(state) if drops("across_slots") else x)
|
|
fills.append({"slot": s["id"], "ads": ads, "rest_ms": rest})
|
|
return {"outcome": "evaluated", "verdict": verdict, "applied": applied, "alternate": alternate, "fills": fills}
|
|
|
|
|
|
def evaluation_outcome(case):
|
|
try:
|
|
return evaluate(case["text"], case["state"])
|
|
except Refused as r:
|
|
return {"outcome": "refuse", "reason": r.reason}
|
|
|
|
|
|
# ------------------------------------------------------------------ §5
|
|
|
|
def check_log(text, state, slot_id, log):
|
|
v = read_for(text, state)
|
|
loc = state["location"]
|
|
verdict = programme(v, loc)[1]
|
|
s = next((x for x in v["slots"] if x["id"] == slot_id), None)
|
|
need(s is not None, FILL_MISMATCH)
|
|
want = fill(s, loc, held_exposures(state))[0] if verdict == "show" else []
|
|
x = held_exposures(state)
|
|
seen = set()
|
|
for a in log:
|
|
c = next((k for k in s["candidates"] if k["ad"] == a), None)
|
|
need(c is not None, FILL_MISMATCH)
|
|
need(a not in seen, FILL_MISMATCH, "log_once")
|
|
seen.add(a)
|
|
need(shown(c, loc), NOT_TARGETED, "log_target")
|
|
need(admits(c, x, s["at"]), CAP_BREACHED, "log_cap")
|
|
expose(c, x, s["at"])
|
|
need(list(log) == want, FILL_MISMATCH, "log_fill")
|
|
return {"outcome": "conforms"}
|
|
|
|
|
|
def log_outcome(case):
|
|
try:
|
|
return check_log(case["text"], case["state"], case["slot"], case["log"])
|
|
except Refused as r:
|
|
return {"outcome": "refuse", "reason": r.reason}
|
|
|
|
|
|
RUNS = (("predicates", lambda c: predicate_outcome(c["text"])), ("evaluations", evaluation_outcome),
|
|
("logs", log_outcome))
|
|
KINDS = tuple(k for k, _ in RUNS)
|
|
|
|
|
|
# ------------------------------------------------------------------ the corpus
|
|
|
|
def reordered(text):
|
|
"""`text`'s document with its members in reverse order, or None when it is not a document object."""
|
|
v = AD.read_text(text)
|
|
if not isinstance(v, dict):
|
|
return None
|
|
return json.dumps(dict(reversed(list(v.items()))), indent=1, ensure_ascii=False)
|
|
|
|
|
|
def counters(text):
|
|
v = AD.read_text(text) or {}
|
|
return {k["counter"] for s in v.get("slots") or [] for c in s["candidates"] for k in c["caps"]}
|
|
|
|
|
|
def invariances(doc, fail):
|
|
for c in doc["evaluations"]:
|
|
st, out = c["state"], c["outcome"]
|
|
# The order of the exposures, and exposures of a counter no cap names.
|
|
if st["exposures"] is not None:
|
|
unnamed = "zz/unnamed"
|
|
assert unnamed not in counters(c["text"])
|
|
for x in (list(reversed(st["exposures"])), st["exposures"] + [{"counter": unnamed, "at": 0}]):
|
|
if not RR.same(evaluation_outcome(dict(c, state=dict(st, exposures=x))), out):
|
|
fail(f"evaluations/{c['name']}: other exposures of no consequence give another evaluation")
|
|
# The order of a document's members.
|
|
t = reordered(c["text"])
|
|
if t is not None and not RR.same(evaluation_outcome(dict(c, text=t)), out):
|
|
fail(f"evaluations/{c['name']}: its members in another order give another evaluation")
|
|
if out["outcome"] != "evaluated":
|
|
continue
|
|
v = read_predicate(c["text"])
|
|
# §4.3 is audience-decision §5.5's within.
|
|
for s in v["slots"]:
|
|
for k in s["candidates"]:
|
|
if shown(k, st["location"]) != (AD.territory_word(k, st["location"]) == "within"):
|
|
fail(f"evaluations/{c['name']}: {k['ad']} is shown otherwise than §5.5 words it")
|
|
# Each slot's fill, as a log with the state the sink held before the slot, conforms.
|
|
before = held_exposures(st)
|
|
for s, f in zip(v["slots"], out["fills"]):
|
|
got = log_outcome({"text": c["text"], "state": dict(st, exposures=copy.deepcopy(before)), "slot": s["id"],
|
|
"log": f["ads"]})
|
|
if got != {"outcome": "conforms"}:
|
|
fail(f"evaluations/{c['name']}: the fill of {s['id']} as a log gives {got}")
|
|
fill(s, st["location"], before)
|
|
for c in doc["predicates"]:
|
|
t = reordered(c["text"])
|
|
if t is not None and len(t.encode()) <= MAX_TEXT and not RR.same(predicate_outcome(t), c["outcome"]):
|
|
fail(f"predicates/{c['name']}: its members in another order give another outcome")
|
|
|
|
|
|
def strings(v, name, out):
|
|
if isinstance(v, dict):
|
|
for k, x in v.items():
|
|
if k == name and isinstance(x, str):
|
|
out.append(x)
|
|
strings(x, name, out)
|
|
elif isinstance(v, list):
|
|
for x in v:
|
|
strings(x, name, out)
|
|
|
|
|
|
def check(doc, quiet=False, full=True):
|
|
fails = []
|
|
|
|
def fail(msg):
|
|
fails.append(msg)
|
|
if not quiet:
|
|
print("FAIL", msg)
|
|
|
|
for kind, run in RUNS:
|
|
cases = doc.get(kind) or []
|
|
if not cases:
|
|
fail(f"no {kind} cases")
|
|
for c in cases:
|
|
try:
|
|
got = run(c)
|
|
except (KeyError, TypeError, ValueError, AttributeError) as e:
|
|
fail(f"{kind}/{c.get('name')}: {e!r}")
|
|
continue
|
|
if not RR.same(got, c["outcome"]):
|
|
fail(f"{kind}/{c['name']}: stated {json.dumps(c['outcome'])} got {json.dumps(got)}")
|
|
table = reasons_table()
|
|
reasons = []
|
|
strings([c["outcome"] for k in KINDS for c in doc.get(k) or []], "reason", reasons)
|
|
for r in table:
|
|
if r not in reasons:
|
|
fail(f"no case gives {r}")
|
|
for r in sorted(set(reasons) - set(table)):
|
|
fail(f"a case gives {r}, which §6 does not list")
|
|
if full:
|
|
try:
|
|
invariances(doc, fail)
|
|
except (KeyError, TypeError, ValueError, AttributeError, AssertionError, Refused) as e:
|
|
fail(f"invariances: {e!r}")
|
|
return fails
|
|
|
|
|
|
WEAKENINGS = (
|
|
"report", "report_nested", "report_across", "start_below_end", "state_exact", "blackouts_ascending",
|
|
"slots_ascending", "slot_ids_distinct", "at_in_span", "ads_distinct", "duration_in_avail", "everywhere_empty",
|
|
"caps_ascending", "cap_limit", "cap_window", "signature", "signature_domain", "signature_other_domain",
|
|
"signature_as_written", "source",
|
|
"no_location_shown", "undetermined", "except_held", "cap", "window_lower", "window_upper", "not_kept", "expose",
|
|
"avail", "across_slots", "verdict_gates", "log_once", "log_target", "log_cap", "log_fill",
|
|
)
|
|
|
|
|
|
def mutations(doc):
|
|
"""Copies of the corpus, one defect each, every one of which must fail."""
|
|
def case(d, kind, name):
|
|
return next(c for c in d[kind] if c["name"] == name)
|
|
|
|
def m_fill(d):
|
|
case(d, "evaluations", "show_and_fill")["outcome"]["fills"][0]["ads"] = ["ad/a", "ad/b"]
|
|
|
|
def m_rest(d):
|
|
case(d, "evaluations", "targets_in_a_listed_country")["outcome"]["fills"][0]["rest_ms"] = 0
|
|
|
|
def m_verdict(d):
|
|
case(d, "evaluations", "withhold_fills_no_slot")["outcome"]["verdict"] = "show"
|
|
|
|
def m_location(d):
|
|
case(d, "evaluations", "only_holds")["state"]["location"] = "XB-2"
|
|
|
|
def m_window(d):
|
|
case(d, "evaluations", "an_exposure_a_whole_window_before")["state"]["exposures"][0]["at"] += 1
|
|
|
|
def m_kept(d):
|
|
case(d, "evaluations", "exposures_not_kept")["state"]["exposures"] = []
|
|
|
|
def m_source(d):
|
|
c = case(d, "evaluations", "show_and_fill")
|
|
c["state"]["sources"] = [d["keys"]["other_source"]]
|
|
|
|
def m_log(d):
|
|
case(d, "logs", "the_fill")["log"] = ["ad/a", "ad/b"]
|
|
|
|
def m_log_reason(d):
|
|
case(d, "logs", "two_of_one_counter")["outcome"]["reason"] = FILL_MISMATCH
|
|
|
|
def m_log_state(d):
|
|
case(d, "logs", "cap_reached_by_the_sinks_exposures")["state"]["exposures"] = []
|
|
|
|
def m_report_reason(d):
|
|
case(d, "predicates", "household_in_a_cap")["outcome"]["reason"] = MALFORMED
|
|
|
|
def m_digest(d):
|
|
case(d, "predicates", "targeted_and_capped")["outcome"]["digest"] = "00" * 32
|
|
|
|
def m_state(d):
|
|
case(d, "predicates", "location_alone")["outcome"]["state"] = []
|
|
|
|
def m_sig(d):
|
|
c = case(d, "predicates", "no_state")
|
|
v = json.loads(c["text"])
|
|
v["sig"] = ("01" if v["sig"][:2] != "01" else "02") + v["sig"][2:]
|
|
c["text"] = RR.canonical(v)
|
|
|
|
def m_reason_gone(d):
|
|
d["logs"] = [c for c in d["logs"] if c["outcome"].get("reason") != NOT_TARGETED]
|
|
|
|
def m_reason_unknown(d):
|
|
case(d, "logs", "passes_one_over")["outcome"]["reason"] = "predicate_passed_over"
|
|
|
|
def m_no_kind(d):
|
|
d["logs"] = []
|
|
|
|
return [m_fill, m_rest, m_verdict, m_location, m_window, m_kept, m_source, m_log, m_log_reason, m_log_state,
|
|
m_report_reason, m_digest, m_state, m_sig, m_reason_gone, m_reason_unknown, m_no_kind]
|
|
|
|
|
|
def direct(doc):
|
|
"""§3.5's bound of 1,048,576 bytes: a text of that many bytes, whitespace added, reads as itself,
|
|
and one of a byte more is refused."""
|
|
fails = []
|
|
c = next(x for x in doc["predicates"] if x["name"] == "targeted_and_capped")
|
|
pad = MAX_TEXT - len(c["text"].encode("utf-8"))
|
|
if not RR.same(predicate_outcome(c["text"] + " " * pad), c["outcome"]):
|
|
fails.append("targeted_and_capped of 1,048,576 bytes, whitespace added, did not read as itself")
|
|
if predicate_outcome(c["text"] + " " * (pad + 1)) != {"outcome": "refuse", "reason": MALFORMED}:
|
|
fails.append(f"targeted_and_capped of 1,048,577 bytes was not refused {MALFORMED}")
|
|
return fails
|
|
|
|
|
|
def main(argv):
|
|
global DROPPED
|
|
if len(argv) != 2:
|
|
print(__doc__)
|
|
return 2
|
|
doc = json.load(open(os.path.join(argv[1], "vectors.json"), encoding="utf-8"))
|
|
extra = sorted(set(os.listdir(argv[1])) - {"vectors.json", "README.md"})
|
|
fails = check(doc)
|
|
if extra:
|
|
fails.append(f"files the corpus does not hold: {extra}")
|
|
print(f"{sum(len(doc[k]) for k in KINDS)} cases ({', '.join(f'{len(doc[k])} {k}' for k in KINDS)}): {len(fails)} failure(s)")
|
|
b = direct(doc)
|
|
print(f"what no vector carries: {len(b)} failure(s)")
|
|
fails += b
|
|
caught = 0
|
|
ms = mutations(doc)
|
|
for m in ms:
|
|
d = copy.deepcopy(doc)
|
|
m(d)
|
|
if check(d, quiet=True):
|
|
caught += 1
|
|
else:
|
|
fails.append(f"mutation {m.__name__} was not caught")
|
|
print(f"{caught} of {len(ms)} mutated copies fail")
|
|
weak = 0
|
|
for w in WEAKENINGS:
|
|
DROPPED = w
|
|
try:
|
|
if check(doc, quiet=True, full=False):
|
|
weak += 1
|
|
else:
|
|
fails.append(f"a verifier without its {w} check passes the corpus")
|
|
finally:
|
|
DROPPED = None
|
|
print(f"{weak} of {len(WEAKENINGS)} weakened verifiers are caught")
|
|
for f in fails:
|
|
print("FAIL", f)
|
|
return 1 if fails else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|