#!/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 [--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))