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]>
124 lines
6.5 KiB
Python
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))
|