open-standards/wai/jwp-conformance/chain_gap_differential.py
Transaction Science 86634ff252 wai: receipt chains fill gaps in any order, under bounds that hold in all (NEW 1, NEW 2)
jwp-receipts §3 step 3. Each anchor leaves a gap between the last group admitted
before it and the earliest at or after it; a group inside the gap that links to
either end is history and moves that end, so a gap fills ascending (as MOQT
delivers a FETCH or a fill), descending, or from both ends, and closes when one
group links to both. A group linking into a gap is never chain_break.

Holding states three counts: live per chain (past it the chain re-anchors),
history per chain for groups inside a gap (past it the lowest held below is
refused, alone), and a total across chains; MAX_HELD_GROUPS caps every hold.
Held groups are indexed by link and by parent; a held group is offered again
only when a group it links to is admitted, so re-offering the head changes and
verifies nothing; each signature is verified once (a group admitted or held is
known by its link hash). ReceiptChains::for_moq refuses a count above the
maximum; HoldBound::from_json refuses a statement without counts; admit_on and
ObjectReceipt::check_moqt refuse a receipt off its track, group or object
(moqt_mismatch) before verifying or holding it.

The reviewer's two sequences are corpus vectors (gap fetched ascending after a
re-anchor; a joiner's ascending fill under a live bound of 4), with the history
bound passed and the total passed across two tracks; jwp_verify.py implements
the same rules; chain_gap_differential.py states the new counts and refetches
ranges ascending.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 11:48:58 -04:00

124 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""Gap and fork differential for receipt chains (jwp-receipts §3 step 3, §6.1): wai-rs
(`wai_jwp_vectors verdicts`) against jwp_verify.py (`verdicts`). A genuine chain per
(key, track), delivered shuffled, with re-sends, walk-backs, groups withheld and then
released stale, legacy parents rewritten (to the group itself, to a random hash, to a
held group), forks the key signs from a held group, and chains long enough (up to 90
groups) that no count could bound a gap; freshness bound and guard on and off. On half
the chains the verifier also recovers (§3 step 3): it joins or not, holds a bound of
groups or none, and receipts are lost outright or the chain is joined mid-stream. Keys are
the corpus's published seeds, so a run is reproducible from its seed. Holding states a
history bound and a total beside the live bound, and on some chains a range is fetched
again in ascending order, which fills the gaps anchors left. Exit status 0
only if the two verifiers give the same verdict on every chain.
usage: chain_gap_differential.py <wai_jwp_vectors binary> [--n N] [--seed S]"""
import json, os, random, subprocess, sys, tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import chain_differential as D # noqa: E402
NOW = "2026-09-30T12:00:01Z"
STALE = "2026-09-30T11:00:00Z"
def gen(rng, i):
labelled = rng.random() < 0.5
track, seed = rng.choice([7, 8]), rng.choice([0x11, 0x22])
n = rng.choice([3, 5, 8, 12, 20]) if rng.random() < 0.85 else rng.choice([66, 70, 90])
stale = set(rng.sample(range(n), k=rng.randint(0, n)))
chain = [D.make(seed, track, 0, 10_000 * i, None, STALE if 0 in stale else NOW, labelled)]
for k in range(1, n):
chain.append(D.make(seed, track, k, 10_000 * i + k, D.link(chain[-1]), STALE if k in stale else NOW, labelled))
order = []
mode = rng.choice(["shuffle", "walkback", "inorder_gaps", "mixed"])
if mode == "shuffle":
order = chain[:] + rng.sample(chain, k=min(len(chain), rng.randint(0, 10)))
rng.shuffle(order)
elif mode == "walkback":
cut = rng.randint(0, n - 1)
order = chain[:cut + 1] + list(reversed(chain[cut + 1:])) + chain[cut + 1:]
elif mode == "inorder_gaps":
withheld = [g for g in chain if rng.random() < 0.3]
order = [g for g in chain if g not in withheld] + withheld + chain
else:
order = chain[:]
for _ in range(rng.randint(1, 15)):
order.insert(rng.randint(0, len(order)), rng.choice(chain))
# adversarial insertions
out = []
for g in order:
out.append(g)
r = rng.random()
if r < 0.05:
out.append(dict(g, parent_receipt_hash=D.link(g).hex())) # self-referential rewrite
elif r < 0.08:
out.append(dict(g, parent_receipt_hash=D.J.blake3(b"r%d" % rng.randint(0, 1 << 30)).hex()))
elif r < 0.10:
out.append(dict(g, parent_receipt_hash=D.link(rng.choice(chain)).hex()))
elif r < 0.12:
src = rng.choice(chain)
out.append(D.make(seed, track, rng.randint(0, n + 2), 10_000 * i + 9000 + len(out), D.link(src), NOW, labelled)) # fork
fresh = {"now": NOW, "max_age_s": 60, "max_skew_s": 5} if rng.random() < 0.7 else None
inp = {"groups": out, "guard": rng.random() < 0.5, "freshness": fresh}
# Recovery (§3 step 3: holding, re-anchoring, joining), from a generator of its own so
# the chains above are the ones every earlier run drew: on half the chains, a verifier
# that joins or not, holds a bound or nothing; a receipt lost outright; a join after
# the chain's first groups.
rr = random.Random(f"recovery {i} {D.link(chain[0]).hex()}")
if rr.random() < 0.5:
inp["recovery"] = {"join": rr.random() < 0.5, "max_held": rr.choice([0, 1, 2, 3, 5, 8, None])}
if rr.random() < 0.4:
lost = rr.choice(chain)
inp["groups"] = [g for g in inp["groups"] if g is not lost] or inp["groups"]
if inp["recovery"]["join"] and rr.random() < 0.5:
first = rr.randint(1, max(1, n - 1))
inp["groups"] = [g for g in inp["groups"] if g not in chain[:first]] or inp["groups"]
# Holding states every count: a history bound of its own, and a total. Then, on
# some chains, a range of the chain fetched again in ascending order, as MOQT
# delivers a FETCH or a fill: gap fill after a re-anchor or a join.
if inp["recovery"]["max_held"] is not None:
inp["recovery"]["max_history"] = rr.choice([0, 1, 2, 4, 16, 64])
inp["recovery"]["max_total"] = rr.choice([1, 3, 8, 64])
if rr.random() < 0.4:
a = rr.randint(0, n - 1)
inp["groups"] = inp["groups"] + chain[a:rr.randint(a + 1, n)]
return {"name": f"gap_{i}", "procedure": "chain", "input": inp, "expected": {"outcome": "chain", "results": []}}
def main(argv):
rust_bin = argv[1]
n = int(argv[argv.index("--n") + 1]) if "--n" in argv else 400
seed = argv[argv.index("--seed") + 1] if "--seed" in argv else "1"
rng = random.Random("gaps " + seed)
vs = [gen(rng, i) for i in range(n)]
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
json.dump({"vectors": vs}, f)
path = f.name
try:
rs = subprocess.run([rust_bin, "verdicts", path], capture_output=True, text=True, check=True).stdout
py = subprocess.run([sys.executable, os.path.join(HERE, "jwp_verify.py"), "verdicts", path],
capture_output=True, text=True, check=True).stdout
finally:
os.unlink(path)
r = dict(l.split("\t", 1) for l in rs.splitlines())
p = dict(l.split("\t", 1) for l in py.splitlines())
if len(r) != n or len(p) != n:
print(f"verdict counts differ: {n} chains, wai-rs {len(r)}, python {len(p)}")
return 1
differ = [k for k in r if r[k] != p.get(k)]
errors = [k for k in r if r[k] == "error" or p.get(k) == "error"]
tally = {}
for v in r.values():
for one in v.split(": ", 1)[-1].split(" / "):
tally[one] = tally.get(one, 0) + 1
print(f"{len(vs)} chains, {sum(len(v['input']['groups']) for v in vs)} groups (seed {seed}); per group: "
+ ", ".join(f"{k} x{c}" for k, c in sorted(tally.items())))
print(f"{len(differ)} differ, {len(errors)} error")
for k in differ[:3]:
print(" ", k, "\n rust", r[k][:300], "\n py ", p.get(k, "")[:300])
return 1 if differ or errors else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))