On a bearer with no return path a receiver cannot ask for what it missed: it holds an object only if the passes it heard carried enough encoding symbols. bearer-receipts declares the transmission and reconciles it afterwards; nothing stated the plan in between. extensions/carousel.md registers `wai.bearer.carousel` and the jwp-receipts claim class `schedule` (§2.4.16): a publisher's signed plan, beside the group receipt and the instance's transmission claim, naming for each object its forward error correction (RaptorQ, RFC 6330 with its verified erratum 5548; Compact No-Code, RFC 5445) and that code's object transmission information, the repair symbols each pass adds, whether later passes repeat their ESIs or send new ones, and the passes' times and count. Step 4 holds each code's parameters to its bounds and division into source blocks; step 6 holds the entries to the group, its objects' content hashes and, where the bytes are held, their lengths. From it a verifier derives, for a loss bound in parts per million, the pass after which a receiver whose losses stay within it holds each object, for every pattern of loss within the bound: a RaptorQ block at K' + h distinct symbols, the count RFC 6330 §5.8 states its decoding failure bound at, and a symbol a repeating pass missed counted as missed again. It checks a statement that an object or a group is held by an instant, sets aside schedules that contradict each other or whose instance is undeclared or two-way, and a receiver derives the passes it must listen to and how long its radio must be on. The reference is the `carousel` module of wai-rs with the class's body in `claim_receipt::schedule`; the corpus is live-conformance/carousel/ (33 verdicts, 50 claims, 16 plans) with carousel_verify.py as its second implementation and claim_verify.py's new `carousel` mode for steps 1-4. tests/carousel.rs checks K' and Partition[] against an independent RFC 6330 implementation (a dev-dependency) for every block size, and decodes each RaptorQ object a case holds available from what a receiver within the bound holds. A receipt index served over HTTP now checks a schedule claim against its group and listed objects. Co-Authored-By: Claude Opus 5.5 <[email protected]>
491 lines
18 KiB
Python
491 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""Independent verifier of the carousel corpus (extensions/carousel.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: group and object receipts from the JWP corpus's
|
||
(`../jwp-conformance/jwp_verify.py`, jwp-receipts §2.2, §3), and a claim's steps 1 to 5 from the
|
||
claim corpus's (`../claim-conformance/claim_verify.py`, jwp-receipts §3.1), whose step 4 holds
|
||
the schedule class's rules with §5.1 and §5.2's division of an object into source blocks.
|
||
Everything else this extension adds is written here:
|
||
|
||
- §4.3: a schedule against the group, its object receipts and their envelopes;
|
||
- §5.3: what a receiver needs of a block, K′ read from Appendix A of the extension's text;
|
||
- §6: the declarations and schedules that stand, each entry's availability within a loss bound,
|
||
and statements offered;
|
||
- §7: the passes a receiver listens to.
|
||
|
||
For every case it requires the outcome the corpus states, field for field; that every reason
|
||
of §9's table appears in the corpus; 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 carousel_verify.py <live-conformance/carousel>
|
||
|
||
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 claim verifier imports.
|
||
"""
|
||
|
||
import copy
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
for d in ("claim-conformance", "jwp-conformance"):
|
||
sys.path.insert(0, os.path.join(HERE, "..", d))
|
||
import claim_verify as CV # noqa: E402
|
||
import jwp_verify as JV # noqa: E402
|
||
|
||
EXTENSION = os.path.join(HERE, "..", "extensions", "carousel.md")
|
||
|
||
# A weakened run drops one of these checks (see `WEAKENINGS`); None checks everything.
|
||
DROPPED = None
|
||
|
||
|
||
def drops(name):
|
||
return DROPPED == name
|
||
|
||
|
||
def extension():
|
||
return open(EXTENSION, encoding="utf-8").read()
|
||
|
||
|
||
def reasons_table():
|
||
"""§9's codes, in order."""
|
||
text = extension()
|
||
at = text.index("\n## 9. Reasons")
|
||
end = text.find("\n## ", at + 1)
|
||
rows = text[at:end if end >= 0 else len(text)].splitlines()
|
||
return [r.split("`")[1] for r in rows if r.startswith("| `")]
|
||
|
||
|
||
def k_primes():
|
||
"""Appendix A: the column K′ of RFC 6330 Table 2."""
|
||
text = extension()
|
||
a, b = text.index("<!-- k-prime:begin -->"), text.index("<!-- k-prime:end -->")
|
||
return [int(x) for x in re.findall(r"[0-9]+", text[a + len("<!-- k-prime:begin -->"):b])]
|
||
|
||
|
||
K_PRIME = k_primes()
|
||
|
||
|
||
def k_prime(k):
|
||
"""§5.3: the least K′ at least k."""
|
||
return next(v for v in K_PRIME if v >= k)
|
||
|
||
|
||
def need(ok, reason):
|
||
if not ok:
|
||
raise CV.Reject(6, reason)
|
||
|
||
|
||
def included(o, g):
|
||
try:
|
||
JV.inclusion(o, g)
|
||
return True
|
||
except JV.Refuse:
|
||
return False
|
||
|
||
|
||
def accepted(o, g, envelope):
|
||
try:
|
||
JV.accept(o, g, envelope)
|
||
return True
|
||
except JV.Refuse:
|
||
return False
|
||
|
||
|
||
# ------------------------------------------------------------------ §4.3: step 6
|
||
|
||
def check_transmission(c, g):
|
||
"""bearer-receipts §4.3."""
|
||
need(JV.link(g).hex() == c["subject"], "transmission_subject_mismatch")
|
||
need(g["signer_pubkey"] == c["signer_pubkey"], "transmission_signer_mismatch")
|
||
|
||
|
||
def check_schedule(c, g, objects):
|
||
"""§4.3 against a group that passed jwp-receipts §3 steps 1 and 2, and the object receipts
|
||
held under it with their envelopes where held: [(object, envelope or None)]."""
|
||
start = JV.seconds(g["group_start"])
|
||
need(start is not None, "context_invalid")
|
||
entries = c["body"]["objects"]
|
||
need(drops("subject") or JV.link(g).hex() == c["subject"], "schedule_subject_mismatch")
|
||
need(drops("signer") or g["signer_pubkey"] == c["signer_pubkey"], "schedule_signer_mismatch")
|
||
need(drops("range") or all(e["leaf_index"] < g["leaf_count"] for e in entries), "schedule_leaf_out_of_range")
|
||
need(drops("before") or all(e["first_ms"] // 1000 >= start for e in entries), "schedule_before_group")
|
||
for e in entries:
|
||
for o, envelope in objects:
|
||
if o["object_id"] != e["leaf_index"] or not included(o, g):
|
||
continue
|
||
need(drops("content") or o["content_hash"].hex() == e["content_hash"], "schedule_content_mismatch")
|
||
if envelope is not None and accepted(o, g, envelope):
|
||
need(drops("length") or len(envelope) == e["oti"]["transfer_length"], "schedule_length_mismatch")
|
||
|
||
|
||
# ------------------------------------------------------------------ §5.3, §6.3
|
||
|
||
def needed(e, k, h):
|
||
"""§5.3: every source symbol for no-code, K′ + h for RaptorQ."""
|
||
return k if e["fec"] == "no-code" else k_prime(k) + h
|
||
|
||
|
||
def missed(loss, m):
|
||
"""§2: ⌊L · m / 1,000,000⌋."""
|
||
return loss * m // 1_000_000
|
||
|
||
|
||
def pass_end(e, j):
|
||
return e["first_ms"] + (j - 1) * e["period_ms"] + e["duration_ms"]
|
||
|
||
|
||
def block_pass(e, k, loss, h):
|
||
"""§6.3: the first pass after which a receiver within the bound holds a block of k."""
|
||
m = k + e["repair"]
|
||
x = missed(loss, m)
|
||
need_ = needed(e, k, h)
|
||
if e["fec"] == "raptorq" and e["esi"] == "advance":
|
||
j = CV.ceil_div(need_, m - x)
|
||
elif e["fec"] == "raptorq":
|
||
j = 1 if m - x >= need_ else None
|
||
else:
|
||
j = 1 if x == 0 or drops("no_code_loss") else None
|
||
return j if j is not None and j <= e["count"] else None
|
||
|
||
|
||
def availability(e, loss, h):
|
||
blocks = CV.schedule_blocks(e)
|
||
passes = [block_pass(e, k, loss, h) for k, _ in blocks]
|
||
j = None if None in passes else max(passes)
|
||
return {
|
||
"pass": j,
|
||
"available_ms": None if j is None else pass_end(e, j),
|
||
"bytes_sent": CV.schedule_bytes(e, blocks),
|
||
"failure_one_in": [100, 10_000, 1_000_000][h] if e["fec"] == "raptorq" else None,
|
||
}
|
||
|
||
|
||
# ------------------------------------------------------------------ §6: the verdict
|
||
|
||
def bounds(loss, h):
|
||
if loss > 999_999:
|
||
return "loss_bound_invalid"
|
||
if h not in (0, 1, 2):
|
||
return "overhead_invalid"
|
||
return None
|
||
|
||
|
||
def verdict(case):
|
||
bad = bounds(case["loss_ppm"], case["overhead"])
|
||
if bad:
|
||
return {"outcome": "refuse", "reason": bad}
|
||
try:
|
||
g = JV.read_group(case["group"])
|
||
JV.verify_group(g)
|
||
except JV.Refuse as r:
|
||
return {"outcome": "refuse", "reason": r.reason}
|
||
|
||
# Step 1: the objects, the same on every bearer.
|
||
objs, overdicts = [], []
|
||
for o in case["objects"]:
|
||
try:
|
||
ob = JV.read_object(o["receipt"])
|
||
except JV.Refuse as r:
|
||
overdicts.append("reject reason=" + r.reason)
|
||
continue
|
||
env = None if o["envelope"] is None else bytes.fromhex(o["envelope"])
|
||
objs.append((ob, env))
|
||
try:
|
||
if env is None:
|
||
JV.inclusion(ob, g)
|
||
overdicts.append("inclusion-verified")
|
||
else:
|
||
JV.accept(ob, g, env)
|
||
overdicts.append("delivered-verified")
|
||
except JV.Refuse as r:
|
||
overdicts.append("reject reason=" + r.reason)
|
||
|
||
# Step 2: each claim, §3.1 steps 1-5, then step 6.
|
||
held, claims, standing_claims = {}, [], []
|
||
for wire in case["claims"]:
|
||
try:
|
||
c = CV.verify_claim(wire)
|
||
except CV.Reject as r:
|
||
claims.append(f"reject step={r.step} reason={r.reason}")
|
||
standing_claims.append(None)
|
||
continue
|
||
previous = held.get(c["signer_pubkey"])
|
||
held[c["signer_pubkey"]] = c
|
||
try:
|
||
CV.check_chain(c, previous)
|
||
if c["class"] == "transmission":
|
||
check_transmission(c, g)
|
||
elif c["class"] == "schedule":
|
||
check_schedule(c, g, objs)
|
||
else:
|
||
raise CV.Reject(6, "context_mismatch")
|
||
except CV.Reject as r:
|
||
claims.append(f"reject step={r.step} reason={r.reason}")
|
||
standing_claims.append(None)
|
||
continue
|
||
claims.append("stands" if c["class"] == "schedule" else "declared")
|
||
standing_claims.append(c)
|
||
|
||
def by_instance(cls):
|
||
out = {}
|
||
for i, c in enumerate(standing_claims):
|
||
if c is not None and c["class"] == cls:
|
||
out.setdefault(c["body"]["bearer_id"], []).append(i)
|
||
return out
|
||
|
||
# Step 3: the declarations.
|
||
declared, contradicted = {}, set()
|
||
for bid, idx in by_instance("transmission").items():
|
||
bodies = [standing_claims[i]["body"] for i in idx]
|
||
if all(b == bodies[0] for b in bodies):
|
||
declared[bid] = bodies[0]["bearer"]
|
||
else:
|
||
contradicted.add(bid)
|
||
for i in idx:
|
||
claims[i] = "set-aside reason=bearer_contradicted"
|
||
|
||
# Step 4: the schedules.
|
||
schedules = {}
|
||
for bid, idx in by_instance("schedule").items():
|
||
bodies = [standing_claims[i]["body"] for i in idx]
|
||
if not drops("schedule_contradiction") and any(b != bodies[0] for b in bodies):
|
||
aside = "schedule_contradicted"
|
||
elif bid in contradicted:
|
||
aside = "bearer_contradicted"
|
||
elif bid not in declared and not drops("undeclared"):
|
||
aside = "bearer_not_declared"
|
||
elif declared.get(bid) == "bidirectional" and not drops("bidirectional"):
|
||
aside = "schedule_on_bidirectional"
|
||
else:
|
||
aside = None
|
||
if aside:
|
||
for i in idx:
|
||
claims[i] = "set-aside reason=" + aside
|
||
else:
|
||
schedules[bid] = bodies[0]
|
||
|
||
# Step 5.
|
||
loss, h = case["loss_ppm"], case["overhead"]
|
||
lines = []
|
||
for bid in sorted(schedules):
|
||
for e in schedules[bid]["objects"]:
|
||
lines.append({"bearer_id": bid, "leaf_index": e["leaf_index"], **availability(e, loss, h)})
|
||
|
||
# §6.4.
|
||
def held_by(bid, i, by):
|
||
line = next((x for x in lines if x["bearer_id"] == bid and x["leaf_index"] == i), None)
|
||
if line is None:
|
||
return "object_not_scheduled"
|
||
if line["available_ms"] is None:
|
||
return "availability_not_within_schedule"
|
||
if line["available_ms"] > by and not drops("deadline"):
|
||
return "availability_after_deadline"
|
||
return None
|
||
|
||
statement = None
|
||
s = case["statement"]
|
||
if s is not None:
|
||
if s["kind"] == "object":
|
||
r = held_by(s["bearer_id"], s["index"], s["by_ms"])
|
||
else:
|
||
r = next((r for r in (held_by(s["bearer_id"], i, s["by_ms"]) for i in range(g["leaf_count"])) if r), None)
|
||
statement = r or "holds"
|
||
return {"outcome": "verdict", "objects": overdicts, "claims": claims, "lines": lines, "statement": statement}
|
||
|
||
|
||
# ------------------------------------------------------------------ §7: a plan
|
||
|
||
def entry_of(v):
|
||
"""A plan's entry, read as a schedule's entry is at jwp-receipts §3.1 steps 2 and 4."""
|
||
body = {"bearer_id": "urn:example:plan", "objects": [v], "attests": "scheduled"}
|
||
CV.check_body("schedule", body)
|
||
CV.schedule_entry_rules(v)
|
||
return v
|
||
|
||
|
||
def plan(case):
|
||
e = entry_of(case["entry"])
|
||
loss, h, t, held = case["loss_ppm"], case["overhead"], case["at_ms"], case["held"]
|
||
bad = bounds(loss, h)
|
||
if bad:
|
||
return {"outcome": "refuse", "reason": bad}
|
||
ks = [k for k, c in CV.schedule_blocks(e) for _ in range(c)]
|
||
if len(held) != len(ks) or (e["fec"] == "no-code" and any(n > k for n, k in zip(held, ks))
|
||
and not drops("holding")):
|
||
return {"outcome": "refuse", "reason": "holding_invalid"}
|
||
n = list(held)
|
||
open_ = lambda: [b for b, k in enumerate(ks) if n[b] < needed(e, k, h)] # noqa: E731
|
||
first = 1 if t <= e["first_ms"] else (2 if e["count"] == 1 else CV.ceil_div(t - e["first_ms"], e["period_ms"]) + 1)
|
||
if drops("late_pass"):
|
||
first = 1 if t <= e["first_ms"] else (t - e["first_ms"]) // max(1, e["period_ms"]) + 1
|
||
if not open_():
|
||
return {"outcome": "plan", "passes": [], "complete_ms": t, "listen_ms": 0}
|
||
passes = []
|
||
for j in range(first, e["count"] + 1):
|
||
blocks = open_()
|
||
if not blocks:
|
||
break
|
||
gains = {}
|
||
for b in blocks:
|
||
m = ks[b] + e["repair"]
|
||
x = missed(loss, m)
|
||
if e["fec"] == "raptorq" and e["esi"] == "advance":
|
||
gains[b] = m - x
|
||
else:
|
||
gains[b] = max(0, m - n[b] - (0 if drops("again") else x))
|
||
if not any(gains.values()):
|
||
break
|
||
for b, gain in gains.items():
|
||
n[b] += gain
|
||
passes.append(j)
|
||
complete = None if open_() else pass_end(e, passes[-1])
|
||
return {
|
||
"outcome": "plan",
|
||
"passes": [{"pass": j, "start_ms": e["first_ms"] + (j - 1) * e["period_ms"], "end_ms": pass_end(e, j)} for j in passes],
|
||
"complete_ms": complete,
|
||
"listen_ms": len(passes) * e["duration_ms"],
|
||
}
|
||
|
||
|
||
# ------------------------------------------------------------------ the corpus
|
||
|
||
def strings(v, out):
|
||
if isinstance(v, str):
|
||
out.append(v)
|
||
elif isinstance(v, list):
|
||
for x in v:
|
||
strings(x, out)
|
||
elif isinstance(v, dict):
|
||
for x in v.values():
|
||
strings(x, out)
|
||
|
||
|
||
def claim_outcome(wire):
|
||
try:
|
||
CV.verify_claim(wire)
|
||
return "accept"
|
||
except CV.Reject as r:
|
||
return f"reject step={r.step} reason={r.reason}"
|
||
|
||
|
||
def check(doc, quiet=False):
|
||
"""The failures of a corpus document: each case's outcome, and every reason of §9."""
|
||
fails = []
|
||
for kind, run in (("verdicts", verdict), ("claims", lambda c: claim_outcome(c["claim"])), ("plans", plan)):
|
||
for c in doc[kind]:
|
||
try:
|
||
got = run(c)
|
||
except CV.Reject as r:
|
||
got = {"error": f"reject step={r.step} reason={r.reason}"}
|
||
except (KeyError, TypeError, ValueError, IndexError, AttributeError, StopIteration, ZeroDivisionError) as e:
|
||
got = {"error": repr(e)}
|
||
if got != c["outcome"]:
|
||
fails.append(c["name"])
|
||
if not quiet:
|
||
print(f"FAIL {c['name']}\n stated {json.dumps(c['outcome'])}\n got {json.dumps(got)}")
|
||
stated = []
|
||
for kind in ("verdicts", "claims", "plans"):
|
||
for c in doc[kind]:
|
||
strings(c["outcome"], stated)
|
||
for code in reasons_table():
|
||
if not any(s == code or s.endswith("reason=" + code) for s in stated):
|
||
fails.append("no case gives " + code)
|
||
return fails
|
||
|
||
|
||
def mutations(doc):
|
||
"""Copies of the corpus, one defect each, every one of which must fail."""
|
||
def case(d, name):
|
||
return next(c for k in ("verdicts", "claims", "plans") for c in d[k] if c["name"] == name)
|
||
|
||
def entry(c, i):
|
||
w = json.loads(c["claims"][i])
|
||
return w, w["body"]["objects"]
|
||
|
||
def m_pass(d):
|
||
case(d, "loss_30_percent")["outcome"]["lines"][0]["pass"] = 1
|
||
|
||
def m_bytes(d):
|
||
case(d, "lossless")["outcome"]["lines"][0]["bytes_sent"] += 64
|
||
|
||
def m_failure(d):
|
||
case(d, "overhead_1")["outcome"]["lines"][0]["failure_one_in"] = 100
|
||
|
||
def m_loss(d):
|
||
# 88%: object 0 gains 3 a pass, and needs 7.
|
||
case(d, "loss_85_percent_last_pass")["loss_ppm"] = 880_000
|
||
|
||
def m_repair(d):
|
||
c = case(d, "lossless")
|
||
w, es = entry(c, 1)
|
||
es[0]["repair"] = 7
|
||
c["claims"][1] = json.dumps(w, separators=(",", ":"), sort_keys=True)
|
||
|
||
def m_statement(d):
|
||
case(d, "object_after_deadline")["statement"]["by_ms"] += 1
|
||
|
||
def m_plan(d):
|
||
case(d, "too_late")["outcome"]["complete_ms"] = case(d, "too_late")["outcome"]["passes"][0]["end_ms"]
|
||
|
||
def m_held(d):
|
||
# 19 short of a block whose pass adds 18: two passes.
|
||
case(d, "holding_part")["held"][0] = 1
|
||
|
||
def m_accept(d):
|
||
case(d, "bytes_greatest")["outcome"] = "reject step=4 reason=schedule_passes_invalid"
|
||
|
||
def m_reason_gone(d):
|
||
d["verdicts"].remove(case(d, "schedule_on_bidirectional"))
|
||
|
||
return [m_pass, m_bytes, m_failure, m_loss, m_repair, m_statement, m_plan, m_held, m_accept, m_reason_gone]
|
||
|
||
|
||
WEAKENINGS = ["subject", "signer", "range", "before", "content", "length", "no_code_loss", "schedule_contradiction",
|
||
"undeclared", "bidirectional", "deadline", "holding", "late_pass", "again"]
|
||
|
||
|
||
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}")
|
||
if len(K_PRIME) != 477 or any(a >= b for a, b in zip(K_PRIME, K_PRIME[1:])) or K_PRIME[-1] != CV.K_PRIME_MAX:
|
||
fails.append("Appendix A is not 477 increasing values ending at K'max")
|
||
print(f"{len(doc['verdicts'])} verdict cases, {len(doc['claims'])} claim cases, {len(doc['plans'])} plans: "
|
||
f"{len(fails)} failure(s)")
|
||
caught = 0
|
||
for m in mutations(doc):
|
||
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(mutations(doc))} mutated copies fail")
|
||
weak = 0
|
||
for w in WEAKENINGS:
|
||
DROPPED = w
|
||
if check(doc, quiet=True):
|
||
weak += 1
|
||
else:
|
||
fails.append(f"a verifier without its {w} check passes the corpus")
|
||
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))
|