open-standards/wai/oer-conformance/fuzz_differential.py
Transaction Science 82473f6bcf oer: minor version 1, the unmetered marker and the acquisition class
A receipt whose oer is exactly "2.1" may state a null energy.joules_total, the
unmetered marker, reported as unmetered and never as a figure, and energy.class,
the figure's acquisition class with its evidence, held to the grade it supports.
Minor version 0 keeps its members, so no 2.0 receipt reads differently. Three
reasons (unmetered_invalid, class_invalid, class_grade_mismatch), the oer.job.*
namespace for job receipts, a third canonical fixture, job_vectors.json written by
hand-declared generate_job_vectors.py, and the differential fuzzer over both forms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:39:58 -04:00

255 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Differential fuzzing of the two OER verifiers.
Conformance is reason-set-equivalence, and the corpus pins it vector by vector. This
checks the rest of the input space: it mutates real corpus receipts, chains, records,
ledgers, published keys and signatures -- type swaps at any member, deletions, unknown
members, respelled decimals, repeated member names, literals no JSON library writes
(`-0`, `1e2`, lone surrogates) -- and requires the reference verifier here and wai-rs
(`wai_oer_diff`) to report the same thing for every document: the same reasons, the
same refusal, and no crash in either. Minor version 1's job receipts (extension
section 3.1: the unmetered marker and the acquisition class) are mutated too, under
their own key.
python3 fuzz_differential.py [--seed N] [--cases N]
python3 fuzz_differential.py --write TEXTS --cases N # the documents and the
reference's verdicts, one JSON line each, for a wai_oer_diff run elsewhere
python3 fuzz_differential.py --compare TEXTS RUST # those verdicts against
wai_oer_diff's output
Exit status 0 when every document agrees. Deterministic for a given seed. Needs
`cryptography`; receipts hashed with blake3 are included only when the `blake3` module
is installed -- without it the reference rightly reports `missing_capability` for them
(extension section 5) where wai-rs, which has blake3, goes on to check the hash.
"""
import argparse, copy, hashlib, importlib.util, io, json, os, random, subprocess, sys, tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import oer2_verify as V # noqa: E402
from cryptography.hazmat.primitives.asymmetric import ed25519 # noqa: E402
HAVE_BLAKE3 = importlib.util.find_spec("blake3") is not None
class Lit:
"""Raw JSON text, written verbatim."""
def __init__(self, text):
self.text = text
class Pairs:
"""An object that may repeat a member name."""
def __init__(self, pairs):
self.pairs = pairs
def enc(v):
if isinstance(v, Lit):
return v.text
if isinstance(v, Pairs):
return "{" + ",".join(json.dumps(k) + ":" + enc(x) for k, x in v.pairs) + "}"
if isinstance(v, dict):
return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + enc(x) for k, x in v.items()) + "}"
if isinstance(v, list):
return "[" + ",".join(enc(x) for x in v) + "]"
return json.dumps(v, ensure_ascii=False)
BASE = json.load(open(os.path.join(HERE, "vectors.json")))
KEYS = BASE["keys"]
V01 = BASE["vectors"][0]
R = V01["receipts"][0]
BUNDLE = next(r for v in BASE["vectors"] for r in v["receipts"] if r.get("kind") == "bundle")
PRIV = ed25519.Ed25519PrivateKey.from_private_bytes(
hashlib.sha256(b"settlement world node ledger vector key").digest())
JOB = json.load(open(os.path.join(HERE, "job_vectors.json")))
JOB_KEYS = JOB["keys"]
JOB_PRIV = ed25519.Ed25519PrivateKey.from_private_bytes(hashlib.sha256(b"oer job vectors: the hop key").digest())
JOB_METERED = JOB["vectors"][0]["receipts"][0]
JOB_UNMETERED = JOB["vectors"][1]["receipts"][0]
JOB_CALIBRATED = JOB["vectors"][5]["receipts"][0]
VALUES = [None, True, False, 0, 1, -1, 5, 2**53 - 1, 2**53, -(2**53), 2**64, "", "x", "0", "1.5", "+1",
" 1", "1.", "01", "1e1000", "١", "NaN", "abstain", "escalate", "2.0", "3.0", "sha-256",
"sha3-512", "ed25519", [], {}, [0.5], {"a": 1}, [1, [2, [3]]], 0.5, 1e300,
Lit("-0"), Lit("1e2"), Lit("1.0"), Lit("-0.0"), Lit("2.99999999999999977"), Lit("9007199254740993"),
Lit('"\\u0000"'), Lit('"\\ud83d\\ude00"'), Lit('"\\ud800"'), Lit("1.7976931348623157e308"),
Lit("123456789012345678901234567890"), "2.1", "2.10", "projected", "measured", "derived",
"OnChipCounter", "CalibratedInstrument", "ModelBased", "Estimator", "HwShunt", "Off", "Unknown",
{"label": "Estimator"}, {"label": "OnChipCounter", "relative_ppm": 1, "window_us": 1, "filtering": "On"},
2**32 - 1, 2**32]
if HAVE_BLAKE3:
VALUES.append("blake3")
def reseal(r, priv=None):
"""Re-seal after an edit where the body still canonicalises, so the checks past the
seal are reached; otherwise leave the stale seal, which is itself a case."""
try:
body = V.canonical_bytes(r)
r["receipt_id"] = V.h(r["hash_alg"], body)
r["chain"]["receipt_hash"] = V.h(r["hash_alg"], r["chain"]["prev_hash"].encode() + body)
r["chain"]["signature"] = (priv or PRIV).sign(r["chain"]["receipt_hash"].encode()).hex()
except Exception:
pass
return r
def paths(v, p=()):
yield p
if isinstance(v, dict):
for k, x in v.items():
yield from paths(x, p + (k,))
elif isinstance(v, list):
for i, x in enumerate(v):
yield from paths(x, p + (i,))
def at(v, p):
for k in p:
v = v[k]
return v
def mutate(rng, r, n):
for _ in range(n):
choices = [p for p in paths(r) if p]
if not choices:
break
p = rng.choice(choices)
parent, k = at(r, p[:-1]), p[-1]
op = rng.random()
if op < 0.55:
parent[k] = copy.deepcopy(rng.choice(VALUES))
elif op < 0.7 and isinstance(parent, dict):
del parent[k]
elif op < 0.8 and isinstance(parent, dict):
parent[rng.choice(["extra", "zz", str(k) + "_x"])] = copy.deepcopy(rng.choice(VALUES))
elif op < 0.9 and isinstance(parent[k], str):
s = parent[k]
parent[k] = rng.choice([s + "0", "+" + s, s.upper(), s[:-1], s + " ", s.replace(".", ","), s * 2])
elif isinstance(parent, dict): # a repeated member name
items = list(parent.items())
items.insert(rng.randrange(len(items) + 1), (k, copy.deepcopy(rng.choice(VALUES))))
if len(p) == 1:
return Pairs(items)
at(r, p[:-2])[p[-2]] = Pairs(items)
break # the tree below is no longer plain dicts
return r
def document(receipts, extra=None, keys=None):
vec = {"name": "c", "receipts": receipts, "expected": []}
vec.update(extra or {})
return enc({"keys": KEYS if keys is None else keys, "vectors": [vec]})
def case(rng):
if rng.random() < 0.25: # a job receipt of minor version 1, mutated, re-sealed half the time
r = mutate(rng, copy.deepcopy(rng.choice([JOB_METERED, JOB_UNMETERED, JOB_CALIBRATED])), rng.randint(1, 3))
if rng.random() < 0.5 and not isinstance(r, Pairs):
r = reseal(r, JOB_PRIV)
return document([r], keys=JOB_KEYS)
kind = rng.random()
if kind < 0.6: # one receipt or bundle, mutated, re-sealed half the time
r = mutate(rng, copy.deepcopy(rng.choice([R, BUNDLE])), rng.randint(1, 3))
if rng.random() < 0.5 and not isinstance(r, Pairs):
r = reseal(r)
return document([r])
if kind < 0.7: # a two-receipt chain with a mutated link
a, b = copy.deepcopy(R), copy.deepcopy(R)
b["chain"]["prev_hash"] = a["chain"]["receipt_hash"]
reseal(b)
mutate(rng, rng.choice([a, b])["chain"], 1)
return document([a, b])
if kind < 0.8: # records
recs = copy.deepcopy(V01["records"])
rid = R["settlement"]["record_id"]
if rng.random() < 0.5:
recs = rng.choice([[], None, "x", {rid: copy.deepcopy(rng.choice(VALUES))}])
else:
mutate(rng, recs[rid], 1)
return document([copy.deepcopy(R)], {"records": recs})
if kind < 0.85: # ledgers
ledger = copy.deepcopy(V01["ledger"])
mutate(rng, ledger, rng.randint(1, 2))
return document(copy.deepcopy(V01["receipts"][:20]), {"ledger": ledger})
if kind < 0.93: # published keys
keys = copy.deepcopy(KEYS)
name = next(iter(keys))
keys[name] = rng.choice([None, 5, "", "zz", keys[name][:-2], keys[name] + "00", keys[name].upper(),
" " + keys[name], "01" + "00" * 31, "00" * 32, "ec" + "ff" * 30 + "7f"])
return document([copy.deepcopy(R)], keys=keys)
r = copy.deepcopy(R) # signature bytes
sig = bytearray(bytes.fromhex(r["chain"]["signature"]))
sig[rng.randrange(64)] ^= 1 << rng.randrange(8)
r["chain"]["signature"] = rng.choice([sig.hex(), sig.hex().upper(), sig.hex()[:-2], "", " " + sig.hex()])
return document([r])
def reference(text):
try:
doc = V.load(io.StringIO(text))
except ValueError:
return "REFUSED"
except Exception as e: # any other exception is a crash the fuzzer must report
return "CRASH:load:" + type(e).__name__
try:
results = V.run(doc)
except ValueError:
return "NOFILE"
except Exception as e:
return "CRASH:run:" + type(e).__name__
return " | ".join("%s=%s" % (x["vector"], ",".join(x["got"])) for x in results)
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--seed", type=int, default=1)
ap.add_argument("--cases", type=int, default=20000)
ap.add_argument("--write", metavar="TEXTS")
ap.add_argument("--compare", nargs=2, metavar=("TEXTS", "RUST"))
args = ap.parse_args()
if args.compare:
texts = [json.loads(l) for l in open(args.compare[0], encoding="utf-8")]
ours = [reference(t) for t in texts]
rust = open(args.compare[1], encoding="utf-8").read().splitlines()
return report(args.seed, texts, ours, rust)
rng = random.Random(args.seed)
texts = [case(rng) for _ in range(args.cases)]
if args.write:
with open(args.write, "w", encoding="utf-8") as f:
for t in texts:
f.write(json.dumps(t) + "\n")
print("wrote %d documents to %s" % (len(texts), args.write))
return
ours = [reference(t) for t in texts]
with tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False) as f:
for t in texts:
f.write(json.dumps(t) + "\n")
rust = subprocess.run(
["cargo", "run", "-q", "--release", "--no-default-features", "--features", "oer",
"--bin", "wai_oer_diff", "--", f.name],
cwd=os.path.join(HERE, "..", "wai-rs"), capture_output=True, text=True, check=True).stdout.splitlines()
os.unlink(f.name)
report(args.seed, texts, ours, rust)
def report(seed, texts, ours, rust):
assert len(rust) == len(ours), (len(rust), len(ours))
diverged = [i for i in range(len(ours)) if ours[i] != rust[i]]
crashes = sum(o.startswith("CRASH") for o in ours) + rust.count("CRASH")
shape = {k: sum(1 for o in ours if f(o)) for k, f in [
("refused", lambda o: o == "REFUSED"), ("valid", lambda o: o.endswith("=")),
("with reasons", lambda o: o not in ("REFUSED", "NOFILE") and not o.endswith("=") and "CRASH" not in o)]}
print("seed %d: %d documents (%s)%s" % (seed, len(ours), ", ".join("%d %s" % (n, k) for k, n in shape.items()),
"" if HAVE_BLAKE3 else "; blake3 receipts left out (no blake3 module)"))
for i in diverged[:20]:
print("DIVERGED %d\n reference: %s\n wai-rs: %s\n document: %s" % (i, ours[i], rust[i], texts[i][:400]))
print("%d diverged, %d crashed" % (len(diverged), crashes))
sys.exit(1 if diverged or crashes else 0)
if __name__ == "__main__":
main()