Some checks failed
no-vanity-metrics / Scan committed Markdown for vanity metrics (push) Successful in 4s
standards workspaces build + test / jouleclaw-rs (push) Successful in 1m50s
standards workspaces build + test / eoc-rs (push) Successful in 1m54s
standards workspaces build + test / arl-rs (push) Successful in 2m10s
standards workspaces build + test / aac-rs (push) Successful in 40s
standards workspaces build + test / joule-code-rs (push) Successful in 38s
standards workspaces build + test / jcp-rs (push) Successful in 1m18s
standards workspaces build + test / joulecontract-rs (push) Successful in 30s
standards workspaces build + test / map-rs (push) Successful in 30s
standards workspaces build + test / proof-rs (push) Successful in 1m11s
standards workspaces build + test / mesh-rs (push) Successful in 26s
standards workspaces build + test / joule-ui-rs (push) Successful in 2m32s
standards workspaces build + test / openpay (push) Successful in 1m21s
standards workspaces build + test / sandbox-rs (push) Successful in 2m12s
WAI conformance / cargo-class corpora · linux-arm64 (push) Successful in 51s
WAI conformance / cross-arch byte-equality · linux-arm64 (push) Successful in 18s
standards workspaces build + test / jouledesk-rs (push) Successful in 2m39s
standards workspaces build + test / smart-byte-rs (push) Successful in 2m30s
WAI conformance / cargo-class corpora · linux (push) Successful in 1m11s
WAI conformance / cross-arch byte-equality · linux (push) Successful in 15s
standards workspaces build + test / joulehook-rs (push) Successful in 3m48s
WAI conformance / C2PA manifest emission · signed, read back, validated (push) Successful in 40s
standards workspaces build + test / sense-rs (push) Successful in 2m22s
standards workspaces build + test / macOS energy backend (jcp-energy) (push) Successful in 2m32s
WAI conformance / cargo-class corpora · macos (push) Successful in 55s
WAI conformance / cross-arch byte-equality · macos (push) Successful in 14s
WAI conformance / cargo-class corpora · windows (push) Has been cancelled
WAI conformance / cross-arch byte-equality · windows (push) Has been cancelled
Departures from OER/2 draft 0.1, each in extensions/oer-receipts.md §9: - decisions are named oer.decision.*, not wai.settle.* (WAI's namespace is for payload formats) - the stage list behind bundle.provenance_root is serialised exactly as the body is, pinned by a non-ASCII fixture - the number profile is normative: I-JSON integers, decimal strings in JSON's number grammar (<=34 digits), anything else malformed - a document outside the document profile is refused whole: a number whose nearest binary64 is infinite, NaN/Infinity, an unpaired surrogate, a repeated member name, nesting past 64 - every composition check is exact (num-bigint / exact decimal context), ln 2 pinned as a decimal, every tolerance scaled by the figure's magnitude - the order of the reasons reported alone, and every member judged on its value, are stated, so each mistyped member has one named reason - published Ed25519 keys are decoded strictly: canonical encoding, not small order - abstention is its own outcome (abstain, no answer, no record), held by a new reason, abstention_settled; escalate is a verdict returned from upstream Four rounds of adversarial review with differential fuzzing drove this; the last fuzz found one remaining divergence (repeated member names), now refused by both. The edge corpus grows to 99 hand-declared vectors plus unreadable/ (11 documents every verifier must refuse); each rule was falsified in both verifiers. serde_json gains float_roundtrip under the oer feature so Rust rounds as Python does. Co-Authored-By: Claude Opus 5.5 <[email protected]>
868 lines
38 KiB
Python
868 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""Edge vectors for OER/2 as extensions/oer-receipts.md profiles draft 0.1: every
|
|
behaviour the upstream nine never exercise.
|
|
|
|
The nine published vectors cover eight reason names. This file adds one vector per
|
|
remaining behaviour a verifier must get right -- each reason, each number shape and
|
|
decimal-string defect, each bound and tolerance edge, the order of the reasons
|
|
reported alone, and abstention. Each carries at most ONE defect. Where the edit
|
|
leaves the seal intact it is re-sealed (fresh receipt id, receipt hash and Ed25519
|
|
signature, using the published test seed from generate_vectors.py), so the declared
|
|
reason is the only one it can honestly produce; a vector whose reason is reported
|
|
alone before any hash is checked is left unsealed.
|
|
|
|
Every `expected` below is written by hand from the rules in
|
|
extensions/oer-receipts.md. None is copied from a verifier's output: a vector whose
|
|
expectation came from the code under test would only prove the code agrees with
|
|
itself.
|
|
|
|
Run: python3 generate_edge_vectors.py (writes edge_vectors.json beside it)
|
|
"""
|
|
import copy, hashlib, json, os, sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import oer2_verify as V # canonical form + hashing only; the fixture pins those independently
|
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
|
|
SEED = hashlib.sha256(b"settlement world node ledger vector key").digest()
|
|
PRIV = ed25519.Ed25519PrivateKey.from_private_bytes(SEED)
|
|
|
|
base = json.load(open(os.path.join(HERE, "vectors.json")))
|
|
KEYS = base["keys"]
|
|
v01 = next(v for v in base["vectors"] if v["name"].startswith("v01"))
|
|
v03 = next(v for v in base["vectors"] if v["name"].startswith("v03"))
|
|
R = v01["receipts"][0] # a valid, signed decision receipt
|
|
BUNDLE = next(r for r in v03["receipts"] if r.get("kind") == "bundle")
|
|
|
|
|
|
def reseal(r):
|
|
"""Recompute id, chained hash and signature so the receipt is otherwise valid."""
|
|
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.sign(r["chain"]["receipt_hash"].encode()).hex()
|
|
return r
|
|
|
|
|
|
def edit(src, fn, seal=True):
|
|
r = copy.deepcopy(src)
|
|
fn(r)
|
|
return reseal(r) if seal else r
|
|
|
|
|
|
def stage_root(stages, alg):
|
|
return V.h(alg, V.stage_list_bytes(stages))
|
|
|
|
|
|
vectors = []
|
|
|
|
# A hash algorithm the verifier lacks is a capability gap, spelled exactly
|
|
# `missing_capability` -- the reference once reported "'missing_capability" here.
|
|
vectors.append({"name": "e01 unsupported hash algorithm",
|
|
"receipts": [edit(R, lambda r: r.update(hash_alg="sha3-512"), seal=False)],
|
|
"expected": ["missing_capability"]})
|
|
|
|
vectors.append({"name": "e02 unsupported signature algorithm",
|
|
"receipts": [edit(R, lambda r: r.update(sig_alg="ecdsa-p256"))],
|
|
"expected": ["missing_capability"]})
|
|
|
|
# A receipt too broken to check is reported, not raised.
|
|
vectors.append({"name": "e03 energy member absent",
|
|
"receipts": [edit(R, lambda r: r.pop("energy"))],
|
|
"expected": ["malformed"]})
|
|
|
|
vectors.append({"name": "e04 grade outside the vocabulary",
|
|
"receipts": [edit(R, lambda r: r["provenance"].update(how="guessed"))],
|
|
"expected": ["grade_unknown"]})
|
|
|
|
vectors.append({"name": "e05 kind outside the vocabulary",
|
|
"receipts": [edit(R, lambda r: r.update(kind="teleport"))],
|
|
"expected": ["unknown_kind"]})
|
|
|
|
vectors.append({"name": "e06 measurement boundary not stated",
|
|
"receipts": [edit(R, lambda r: r["energy"].update(boundary=""))],
|
|
"expected": ["boundary_missing"]})
|
|
|
|
vectors.append({"name": "e07 Landauer floor misstated",
|
|
"receipts": [edit(R, lambda r: r["floor"].update(floor_j="1"))],
|
|
"expected": ["floor_mismatch"]})
|
|
|
|
vectors.append({"name": "e08 signer not in the key set",
|
|
"receipts": [edit(R, lambda r: r["chain"].update(signer="ed25519:nobody"), seal=False)],
|
|
"expected": ["signer_unknown"]})
|
|
|
|
|
|
# A receipt id that recurs must not hide the reasons of the earlier receipt with that id.
|
|
def repeated_id():
|
|
a = edit(R, lambda r: r["provenance"].update(how="guessed")) # grade_unknown
|
|
b = copy.deepcopy(R)
|
|
b["energy"]["boundary"] = ""
|
|
b["chain"]["prev_hash"] = a["chain"]["receipt_hash"] # chains onto a
|
|
b = reseal(b) # boundary_missing
|
|
b["receipt_id"] = a["receipt_id"] # + receipt_id_mismatch
|
|
return [a, b]
|
|
|
|
|
|
vectors.append({"name": "e09 a repeated receipt id hides nothing",
|
|
"receipts": repeated_id(),
|
|
"expected": ["boundary_missing", "grade_unknown", "receipt_id_mismatch"]})
|
|
|
|
|
|
def bundle_sum():
|
|
def f(r):
|
|
r["bundle"]["stages"][0]["joules"] = "99999"
|
|
r["bundle"]["provenance_root"] = stage_root(r["bundle"]["stages"], r["hash_alg"])
|
|
return edit(BUNDLE, f)
|
|
|
|
|
|
vectors.append({"name": "e10 bundle stages do not sum to the total",
|
|
"receipts": [bundle_sum()],
|
|
"expected": ["bundle_sum_mismatch"]})
|
|
|
|
vectors.append({"name": "e11 bundle without its efficiency",
|
|
"receipts": [edit(BUNDLE, lambda r: r.pop("efficiency"))],
|
|
"expected": ["efficiency_missing"]})
|
|
|
|
|
|
# A record store or ledger too broken to check fails its check rather than crashing.
|
|
def broken_record():
|
|
v = copy.deepcopy(v01)
|
|
served = next(r for r in v["receipts"]
|
|
if (r.get("settlement") or {}).get("served_from_record"))
|
|
del v["records"][served["settlement"]["record_id"]]["created"]
|
|
return v
|
|
|
|
|
|
e12 = broken_record()
|
|
vectors.append({"name": "e12 a cited record cannot be rehashed",
|
|
"receipts": e12["receipts"], "records": e12["records"],
|
|
"expected": ["record_mismatch"]})
|
|
|
|
e13 = copy.deepcopy(v01)
|
|
del e13["ledger"]["meter_joules"]
|
|
vectors.append({"name": "e13 ledger without a meter figure",
|
|
"receipts": e13["receipts"], "ledger": e13["ledger"],
|
|
"expected": ["ledger_mismatch"]})
|
|
|
|
# Literals json.dump cannot write (-0, an exponent on an integer, a float that
|
|
# parsers round differently) go in as sentinels and are swapped in after
|
|
# serialisation, each exactly once.
|
|
LITERALS = {}
|
|
|
|
|
|
def lit(text):
|
|
key = "__oer_literal_%d__" % len(LITERALS)
|
|
LITERALS[key] = text
|
|
return key
|
|
|
|
|
|
def resealed_over(value, literal, path=("settlement", "reuse_count")):
|
|
"""Seal R with `value` at `path` -- what a parser that reads `literal` leniently
|
|
would hash -- then write `literal` there instead."""
|
|
r = edit(R, lambda r: r[path[0]].update({path[1]: value}))
|
|
r[path[0]][path[1]] = lit(literal)
|
|
return r
|
|
|
|
|
|
def with_number(n):
|
|
return edit(R, lambda r: r["settlement"].update(reuse_count=n))
|
|
|
|
|
|
# The number profile (extension section 4): a receipt body carries integers within
|
|
# I-JSON's +-(2**53 - 1) and decimal strings, nothing else. Each forbidden shape is
|
|
# read differently by different JSON parsers, so it is reported as malformed rather
|
|
# than hashed two ways -- and the bounds themselves are pinned from both sides.
|
|
vectors.append({"name": "e14 a figure written as a JSON float",
|
|
"receipts": [edit(R, lambda r: r["work"].update(latency_s=0.0017))],
|
|
"expected": ["malformed"]})
|
|
|
|
vectors.append({"name": "e15 an integer wider than 64 bits",
|
|
"receipts": [with_number(2**64)],
|
|
"expected": ["malformed"]})
|
|
|
|
# A parser that reads -0 as the integer zero finds this valid; one that reads it as
|
|
# a float (serde_json) computes a different receipt id. Neither is the declared reason.
|
|
vectors.append({"name": "e16 negative zero",
|
|
"receipts": [resealed_over(0, "-0")],
|
|
"expected": ["malformed"]})
|
|
|
|
vectors.append({"name": "e17 an integer at the upper bound",
|
|
"receipts": [with_number(2**53 - 1)], "expected": []})
|
|
|
|
vectors.append({"name": "e18 an integer at the lower bound",
|
|
"receipts": [with_number(-(2**53 - 1))], "expected": []})
|
|
|
|
vectors.append({"name": "e19 an integer one past the upper bound",
|
|
"receipts": [with_number(2**53)], "expected": ["malformed"]})
|
|
|
|
vectors.append({"name": "e20 an integer one past the lower bound",
|
|
"receipts": [with_number(-(2**53))], "expected": ["malformed"]})
|
|
|
|
vectors.append({"name": "e21 an integer written with an exponent",
|
|
"receipts": [resealed_over(100, "1e2")], "expected": ["malformed"]})
|
|
|
|
vectors.append({"name": "e22 a float inside an array",
|
|
"receipts": [edit(R, lambda r: r["subject"]["inputs"].append(0.5))],
|
|
"expected": ["malformed"]})
|
|
|
|
# Of the reasons reported alone, unknown_major comes first, then canonical_failure,
|
|
# then malformed (section 7). Unsealed: the one reason reported is all there is.
|
|
vectors.append({"name": "e23 a member not listed for its path",
|
|
"receipts": [edit(R, lambda r: r.update(extra="x"), seal=False)],
|
|
"expected": ["canonical_failure"]})
|
|
|
|
vectors.append({"name": "e24 canonical_failure comes before a forbidden number",
|
|
"receipts": [edit(R, lambda r: (r.update(extra="x"), r["work"].update(latency_s=0.5)),
|
|
seal=False)],
|
|
"expected": ["canonical_failure"]})
|
|
|
|
|
|
def float_oer():
|
|
r = copy.deepcopy(R)
|
|
r["oer"] = lit("2.99999999999999977") # 3.0 to one parser, 2.9999999999999996 to another
|
|
return r
|
|
|
|
|
|
vectors.append({"name": "e25 a version written as a number",
|
|
"receipts": [float_oer()], "expected": ["unknown_major"]})
|
|
|
|
vectors.append({"name": "e26 unknown_major comes before a forbidden number",
|
|
"receipts": [edit(R, lambda r: (r.update(oer="3.0"), r["work"].update(latency_s=0.5)),
|
|
seal=False)],
|
|
"expected": ["unknown_major"]})
|
|
|
|
vectors.append({"name": "e27 kind absent",
|
|
"receipts": [edit(R, lambda r: r.pop("kind"))], "expected": ["unknown_kind"]})
|
|
|
|
|
|
# A decimal string is JSON's number grammar, in ASCII, at most 34 significand digits
|
|
# with a one-to-three-digit exponent. Each variant below means the same number to a
|
|
# lenient decimal parser -- so a verifier without the grammar finds a valid receipt --
|
|
# and is not a decimal string.
|
|
def respelled(fn):
|
|
def f(r):
|
|
old = r["energy"]["breakdown"]["compute_j"]
|
|
new = fn(old)
|
|
assert new != old and Decimal(new) == Decimal(old), (old, new) # same number, leniently
|
|
r["energy"]["breakdown"]["compute_j"] = new
|
|
return edit(R, f)
|
|
|
|
|
|
def arabic_indic(s):
|
|
i = next(i for i, c in enumerate(s) if c.isdigit())
|
|
return s[:i] + chr(0x660 + int(s[i])) + s[i + 1:]
|
|
|
|
|
|
def widened(s):
|
|
mant, _, exp = s.partition("e")
|
|
mant = mant if "." in mant else mant + "."
|
|
digits = sum(c.isdigit() for c in mant)
|
|
return mant + "0" * (35 - digits) + ("e" + exp if exp else "")
|
|
|
|
|
|
def four_digit_exponent(s):
|
|
mant, _, exp = s.partition("e")
|
|
if not exp:
|
|
return mant + "e0000"
|
|
sign, digits = (exp[0], exp[1:]) if exp[0] in "+-" else ("", exp)
|
|
return mant + "e" + sign + digits.zfill(4)
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
for name, fn in [("e28 a decimal string with a leading plus", lambda s: "+" + s),
|
|
("e29 a decimal string with surrounding space", lambda s: " " + s + " "),
|
|
("e30 a decimal string with a non-ASCII digit", arabic_indic),
|
|
("e31 a decimal string with 35 significand digits", widened),
|
|
("e32 a decimal string with a four-digit exponent", four_digit_exponent)]:
|
|
vectors.append({"name": name, "receipts": [respelled(fn)], "expected": ["malformed"]})
|
|
|
|
|
|
# An abstention makes no verdict claim: no answer, and no record anything could later
|
|
# be served from (section 3). The first is the valid shape; each of the others adds
|
|
# exactly one claim an abstention cannot make.
|
|
def abstain(r):
|
|
r["intent"] = "create"
|
|
r["subject"]["output"] = ""
|
|
r["work"]["outcome"] = "abstain"
|
|
r["settlement"].update(served_from_record=False, record_id="", record_hash="", reuse_count=0)
|
|
|
|
|
|
vectors.append({"name": "e33 an abstention",
|
|
"receipts": [edit(R, abstain)], "expected": []})
|
|
|
|
vectors.append({"name": "e34 an abstention that carries an answer",
|
|
"receipts": [edit(R, lambda r: (abstain(r), r["subject"].update(output="authorize")))],
|
|
"expected": ["abstention_settled"]})
|
|
|
|
vectors.append({"name": "e35 an abstention that names a record",
|
|
"receipts": [edit(R, lambda r: (abstain(r), r["settlement"].update(
|
|
record_id=R["settlement"]["record_id"])))],
|
|
"expected": ["abstention_settled"]})
|
|
|
|
e36 = copy.deepcopy(v01)
|
|
e36["ledger"]["meter_joules"] = "+" + e36["ledger"]["meter_joules"]
|
|
vectors.append({"name": "e36 a ledger figure that is not a decimal string",
|
|
"receipts": e36["receipts"], "ledger": e36["ledger"],
|
|
"expected": ["ledger_mismatch"]})
|
|
|
|
vectors.append({"name": "e37 a receipt that is not an object",
|
|
"receipts": ["not a receipt"], "expected": ["malformed"]})
|
|
|
|
|
|
# Every composition check is exact (section 6). The breakdown sums to 0.5 + 1e-33
|
|
# against a total of 0.499999999: off by 1e-9 + 1e-33, which exceeds the tolerance
|
|
# of 1e-9 by 1e-33. Rounded to 28 digits the sum is 0.5, the difference exactly 1e-9,
|
|
# and the receipt passes -- the check has been decided by rounding noise.
|
|
def past_28_digits(r):
|
|
b = r["energy"]["breakdown"]
|
|
for k in b:
|
|
b[k] = "0"
|
|
b["compute_j"], b["movement_j"] = "0.5", "1e-33"
|
|
r["energy"]["joules_total"] = "0.499999999"
|
|
|
|
|
|
vectors.append({"name": "e38 a sum that misses its tolerance only past 28 digits",
|
|
"receipts": [edit(R, past_28_digits)], "expected": ["sum_mismatch"]})
|
|
|
|
|
|
# ...and one exactly at it: off by 1e-9 against a tolerance of 1e-9 is within it.
|
|
def at_tolerance(r):
|
|
b = r["energy"]["breakdown"]
|
|
for k in b:
|
|
b[k] = "0"
|
|
b["compute_j"] = "0.5"
|
|
r["energy"]["joules_total"] = "0.499999999"
|
|
|
|
|
|
vectors.append({"name": "e39 a sum exactly at its tolerance",
|
|
"receipts": [edit(R, at_tolerance)], "expected": []})
|
|
|
|
|
|
# The Landauer floor is held to 1% (section 6), exactly: ln 2 is the decimal
|
|
# 0.6931471805599453. A floor 0.9% off passes and one 2% off does not, so a verifier
|
|
# with a tolerance outside that band fails one of the pair.
|
|
def floor_off(factor):
|
|
def f(r):
|
|
with V.localcontext(V.EXACT):
|
|
floor = (V.dec(r["floor"]["bits_committed"]) * V.KBOLTZ
|
|
* V.dec(r["energy"]["temperature_k"]) * V.LN2)
|
|
r["floor"]["floor_j"] = "%.15e" % (floor * Decimal(factor))
|
|
return f
|
|
|
|
|
|
vectors.append({"name": "e40 a floor 0.9% off",
|
|
"receipts": [edit(R, floor_off("1.009"))], "expected": []})
|
|
|
|
vectors.append({"name": "e41 a floor 2% off",
|
|
"receipts": [edit(R, floor_off("1.02"))], "expected": ["floor_mismatch"]})
|
|
|
|
|
|
# Every tolerance, exactly at its edge and just past it, on both sides (section 6:
|
|
# "within t" is |a - b| <= t, and t scales by the figure's magnitude). Each written
|
|
# figure is checked to be a decimal string the grammar accepts, so the only thing a
|
|
# vector can be judged on is where the verifier draws the edge.
|
|
def exact(fn):
|
|
with V.localcontext(V.EXACT):
|
|
return fn()
|
|
|
|
|
|
def decimal_string(d):
|
|
# normalize() rounds to the context's precision, so it runs in the exact one: in
|
|
# the default 28-digit context it would silently drop a nudge in the 34th digit.
|
|
s = format(exact(d.normalize), "E") if d != 0 else "0"
|
|
assert Decimal(s) == d, (s, d)
|
|
V.dec(s) # raises if the grammar (at most 34 digits) would refuse it
|
|
return s
|
|
|
|
|
|
def only_compute(r, compute, total):
|
|
b = r["energy"]["breakdown"]
|
|
for k in b:
|
|
b[k] = "0"
|
|
b["compute_j"] = compute
|
|
r["energy"]["joules_total"] = total
|
|
|
|
|
|
vectors.append({"name": "e42 a sum past its tolerance on the other side",
|
|
"receipts": [edit(R, lambda r: only_compute(r, "0.5", "0.500000001000000000000000000000001"))],
|
|
"expected": ["sum_mismatch"]})
|
|
|
|
# -5 against -4.999999997 is off by 3e-9, within 1e-9 x |-5|; a verifier that scales
|
|
# by the signed figure (max(1, -5) = 1) rejects it.
|
|
vectors.append({"name": "e43 a negative sum within its tolerance",
|
|
"receipts": [edit(R, lambda r: only_compute(r, "-5", "-4.999999997"))], "expected": []})
|
|
|
|
|
|
def floor_at(factor, nudge="0"):
|
|
def f(r):
|
|
floor = exact(lambda: V.dec(r["floor"]["bits_committed"]) * V.KBOLTZ
|
|
* V.dec(r["energy"]["temperature_k"]) * V.LN2)
|
|
r["floor"]["floor_j"] = decimal_string(exact(lambda: floor * Decimal(factor) + Decimal(nudge)))
|
|
return f
|
|
|
|
|
|
vectors.append({"name": "e44 a floor exactly 1% high",
|
|
"receipts": [edit(R, floor_at("1.01"))], "expected": []})
|
|
|
|
vectors.append({"name": "e45 a floor 1% high and one unit in the 34th digit",
|
|
"receipts": [edit(R, floor_at("1.01", "1e-51"))], "expected": ["floor_mismatch"]})
|
|
|
|
vectors.append({"name": "e46 a floor exactly 1% low",
|
|
"receipts": [edit(R, floor_at("0.99"))], "expected": []})
|
|
|
|
vectors.append({"name": "e47 a floor 2% low",
|
|
"receipts": [edit(R, floor_at("0.98"))], "expected": ["floor_mismatch"]})
|
|
|
|
|
|
def stage_sum(r):
|
|
return exact(lambda: sum(V.dec(s["joules"]) for s in r["bundle"]["stages"]))
|
|
|
|
|
|
def bundle_total(offset):
|
|
"""A bundle whose total sits `offset(st)` from its stage sum; the breakdown moves
|
|
with the total, so the bundle sum is the only check that can object."""
|
|
def f(r):
|
|
st = stage_sum(r)
|
|
total = decimal_string(exact(lambda: st + offset(st)))
|
|
only_compute(r, total, total)
|
|
return f
|
|
|
|
|
|
vectors.append({"name": "e48 a bundle exactly at its tolerance",
|
|
"receipts": [edit(BUNDLE, bundle_total(lambda st: -st * Decimal("1e-9")))], "expected": []})
|
|
|
|
vectors.append({"name": "e49 a bundle just past its tolerance",
|
|
"receipts": [edit(BUNDLE, bundle_total(lambda st: -st * Decimal("1e-9") - Decimal("1e-30")))],
|
|
"expected": ["bundle_sum_mismatch"]})
|
|
|
|
|
|
def negative_bundle(r):
|
|
st = stage_sum(r)
|
|
first = r["bundle"]["stages"][0]
|
|
first["joules"] = decimal_string(exact(lambda: V.dec(first["joules"]) - st - 5)) # stages now sum to -5
|
|
r["bundle"]["provenance_root"] = stage_root(r["bundle"]["stages"], r["hash_alg"])
|
|
only_compute(r, "-4.999999997", "-4.999999997")
|
|
|
|
|
|
vectors.append({"name": "e50 a negative bundle within its tolerance",
|
|
"receipts": [edit(BUNDLE, negative_bundle)], "expected": []})
|
|
|
|
|
|
def ledger_with(unallocated_for):
|
|
"""v01's receipts with `unallocated_joules` set so the identity lands where asked."""
|
|
v = copy.deepcopy(v01)
|
|
L = v["ledger"]
|
|
rest = exact(lambda: sum(V.dec(r["energy"]["joules_total"]) for r in v["receipts"])
|
|
+ V.dec(L["carried_forward_joules"]))
|
|
L["unallocated_joules"] = decimal_string(exact(lambda: unallocated_for(rest, V.dec(L["meter_joules"]))))
|
|
return v
|
|
|
|
|
|
def ledger_vector(name, v, expected):
|
|
vectors.append({"name": name, "receipts": v["receipts"], "ledger": v["ledger"], "expected": expected})
|
|
|
|
|
|
ledger_vector("e51 a ledger exactly at its tolerance",
|
|
ledger_with(lambda rest, m: m - m * Decimal("1e-9") - rest), [])
|
|
ledger_vector("e52 a ledger just past its tolerance on the other side",
|
|
ledger_with(lambda rest, m: m + m * Decimal("1e-9") + Decimal("1e-25") - rest), ["ledger_mismatch"])
|
|
|
|
|
|
def negative_ledger():
|
|
v = ledger_with(lambda rest, m: Decimal(-5) - rest) # everything now sums to -5
|
|
v["ledger"]["meter_joules"] = "-4.999999997"
|
|
return v
|
|
|
|
|
|
ledger_vector("e53 a negative ledger within its tolerance", negative_ledger(), [])
|
|
|
|
vectors.append({"name": "e54 an abstention that cites a record hash",
|
|
"receipts": [edit(R, lambda r: (abstain(r), r["settlement"].update(
|
|
record_hash=R["settlement"]["record_hash"])))],
|
|
"expected": ["abstention_settled"]})
|
|
|
|
vectors.append({"name": "e55 an abstention served from a record",
|
|
"receipts": [edit(R, lambda r: (abstain(r), r["settlement"].update(served_from_record=True)))],
|
|
"expected": ["abstention_settled"]})
|
|
|
|
|
|
# The decimal-string limits from the accepting side: exactly 34 digits, and a one-
|
|
# and a three-digit exponent, are all decimal strings. Every digit before the
|
|
# exponent counts toward the 34, leading zeros included.
|
|
def one_digit_exponent(s):
|
|
mant, _, exp = s.partition("e")
|
|
return mant + "e" + ("-" if exp.startswith("-") else "") + exp.lstrip("+-").lstrip("0")
|
|
|
|
|
|
def three_digit_exponent(s):
|
|
mant, _, exp = s.partition("e")
|
|
sign, digits = (exp[0], exp[1:]) if exp[0] in "+-" else ("", exp)
|
|
return mant + "e" + sign + digits.zfill(3)
|
|
|
|
|
|
def digits_34(s):
|
|
mant, _, exp = s.partition("e")
|
|
mant = mant if "." in mant else mant + "."
|
|
return mant + "0" * (34 - sum(c.isdigit() for c in mant)) + ("e" + exp if exp else "")
|
|
|
|
|
|
for name, fn in [("e56 a decimal string with exactly 34 significand digits", digits_34),
|
|
("e57 a decimal string with a one-digit exponent", one_digit_exponent),
|
|
("e58 a decimal string with a three-digit exponent", three_digit_exponent)]:
|
|
vectors.append({"name": name, "receipts": [respelled(fn)], "expected": []})
|
|
|
|
|
|
def leading_zeros(s):
|
|
plain = format(Decimal(s), "f") # 0.00000169412 for 1.694120e-06
|
|
return plain + "0" * (35 - sum(c.isdigit() for c in plain))
|
|
|
|
|
|
vectors.append({"name": "e59 leading zeros count toward the 34 digits",
|
|
"receipts": [respelled(leading_zeros)], "expected": ["malformed"]})
|
|
|
|
# --- the rest of each tolerance: its base, its other side, and exactness past 34 digits
|
|
|
|
# 10 against 9.99999999 is off by 1e-8, exactly 1e-9 x |sum|: within. Scaled by the
|
|
# total instead (1e-9 x 9.99999999) it would not be -- the base is the sum (section 6).
|
|
vectors.append({"name": "e60 a sum exactly at its tolerance, scaled by the sum",
|
|
"receipts": [edit(R, lambda r: only_compute(r, "10", "9.99999999"))], "expected": []})
|
|
|
|
# 0.5 + 1e-60 against 0.499999999 misses by 1e-60; rounded to 34 digits (decimal128)
|
|
# the sum is 0.5 and the miss vanishes.
|
|
vectors.append({"name": "e61 a sum that misses its tolerance only past 34 digits",
|
|
"receipts": [edit(R, lambda r: (only_compute(r, "0.5", "0.499999999"),
|
|
r["energy"]["breakdown"].update(movement_j="1e-60")))],
|
|
"expected": ["sum_mismatch"]})
|
|
|
|
vectors.append({"name": "e62 a bundle just past its tolerance on the other side",
|
|
"receipts": [edit(BUNDLE, bundle_total(lambda st: st * Decimal("1e-9") + Decimal("1e-30")))],
|
|
"expected": ["bundle_sum_mismatch"]})
|
|
|
|
|
|
def small_bundle(r):
|
|
st = stage_sum(r)
|
|
first = r["bundle"]["stages"][0]
|
|
first["joules"] = decimal_string(exact(lambda: V.dec(first["joules"]) - st + Decimal("0.5")))
|
|
r["bundle"]["provenance_root"] = stage_root(r["bundle"]["stages"], r["hash_alg"])
|
|
only_compute(r, "0.499999999", "0.499999999")
|
|
|
|
|
|
# Stages summing to 0.5 against 0.499999999: off by 1e-9, within 1e-9 x max(1, 0.5).
|
|
vectors.append({"name": "e63 a small bundle exactly at its tolerance",
|
|
"receipts": [edit(BUNDLE, small_bundle)], "expected": []})
|
|
|
|
ledger_vector("e64 a ledger just past its tolerance on the low side",
|
|
ledger_with(lambda rest, m: m - m * Decimal("1e-9") - Decimal("1e-25") - rest), ["ledger_mismatch"])
|
|
|
|
|
|
def small_ledger():
|
|
v = ledger_with(lambda rest, m: Decimal("0.499999999") - rest)
|
|
v["ledger"]["meter_joules"] = "0.5"
|
|
return v
|
|
|
|
|
|
ledger_vector("e65 a small ledger exactly at its tolerance", small_ledger(), [])
|
|
|
|
|
|
# A negative temperature makes the floor negative; 1% of its magnitude is still the
|
|
# tolerance, so a floor_j 1% further from zero is within it.
|
|
def negative_floor(r):
|
|
r["energy"]["temperature_k"] = "-3.000000e+02"
|
|
floor_at("1.01")(r)
|
|
|
|
|
|
vectors.append({"name": "e66 a negative floor exactly 1% off",
|
|
"receipts": [edit(R, negative_floor)], "expected": []})
|
|
|
|
# --- members and shapes: each is reported the same way by every verifier (sections 3, 7)
|
|
vectors.append({"name": "e67 a member not listed for chain",
|
|
"receipts": [edit(R, lambda r: r["chain"].update(extra="x"), seal=False)],
|
|
"expected": ["canonical_failure"]})
|
|
|
|
vectors.append({"name": "e68 a member not listed for chain.anchor",
|
|
"receipts": [edit(R, lambda r: r["chain"].update(
|
|
anchor={"path": "iridium", "delivered_at": 1, "root": "sha256:00", "extra": "x"}), seal=False)],
|
|
"expected": ["canonical_failure"]})
|
|
|
|
|
|
# The first receipt's own hash is not a string: its hash and signature fail, and
|
|
# the second links to nothing, however its prev_hash reads.
|
|
def unlinked():
|
|
a = copy.deepcopy(R)
|
|
b = edit(R, lambda r: None)
|
|
b["chain"]["prev_hash"] = a["chain"]["receipt_hash"]
|
|
b = reseal(b)
|
|
a["chain"]["receipt_hash"] = 5
|
|
return [a, b]
|
|
|
|
|
|
vectors.append({"name": "e69 a chain link to a hash that is not a string",
|
|
"receipts": unlinked(),
|
|
"expected": ["chain_break", "receipt_hash_mismatch", "signature_invalid"]})
|
|
|
|
vectors.append({"name": "e70 a kind that is not a string",
|
|
"receipts": [edit(R, lambda r: r.update(kind=["decision"]))], "expected": ["unknown_kind"]})
|
|
|
|
vectors.append({"name": "e71 a signer that is not a string",
|
|
"receipts": [edit(R, lambda r: r["chain"].update(signer=["x"]), seal=False)],
|
|
"expected": ["signer_unknown"]})
|
|
|
|
vectors.append({"name": "e72 a bundle whose efficiency is not an object",
|
|
"receipts": [edit(BUNDLE, lambda r: r.update(efficiency=[]))], "expected": ["efficiency_missing"]})
|
|
|
|
vectors.append({"name": "e73 a bundle whose stages are not a list",
|
|
"receipts": [edit(BUNDLE, lambda r: r["bundle"].update(stages={}))], "expected": ["malformed"]})
|
|
|
|
|
|
def id_not_a_string():
|
|
r = copy.deepcopy(R)
|
|
r["receipt_id"] = []
|
|
return r
|
|
|
|
|
|
vectors.append({"name": "e74 a receipt id that is not a string",
|
|
"receipts": [id_not_a_string()], "expected": ["receipt_id_mismatch"]})
|
|
|
|
vectors.append({"name": "e75 records that are not an object",
|
|
"receipts": v01["receipts"], "records": [], "expected": ["record_mismatch"]})
|
|
|
|
vectors.append({"name": "e76 a record id that is not a string",
|
|
"receipts": [edit(R, lambda r: r["settlement"].update(record_id=[R["settlement"]["record_id"]]))],
|
|
"records": v01["records"], "expected": ["record_mismatch"]})
|
|
|
|
|
|
# A record whose answer is not a string, or whose created is a boolean, cannot be
|
|
# rehashed -- even when the receipt cites exactly what str() and %.1f would make of it.
|
|
def cited_record(field, value):
|
|
recs = copy.deepcopy(v01["records"])
|
|
rid = R["settlement"]["record_id"]
|
|
recs[rid][field] = value
|
|
rec = recs[rid]
|
|
line = "%s|%s|%.1f" % (rec["key"], rec["answer"], rec["created"])
|
|
r = edit(R, lambda r: r["settlement"].update(record_hash="sha256:" + hashlib.sha256(line.encode()).hexdigest()))
|
|
return {"receipts": [r], "records": recs}
|
|
|
|
|
|
for name, field, value in [("e77 a record whose answer is not a string", "answer", True),
|
|
("e78 a record whose created is a boolean", "created", True)]:
|
|
vectors.append(dict(name=name, expected=["record_mismatch"], **cited_record(field, value)))
|
|
|
|
|
|
# The extremes a document may hold (section 4): the largest finite binary64 written
|
|
# as a float and as an integer, a float just below the rounding midpoint above it, a
|
|
# surrogate pair, and nesting exactly 64 deep. A verifier that refuses the document
|
|
# fails every vector in this file.
|
|
def nested(k):
|
|
x = []
|
|
for _ in range(k):
|
|
x = [x]
|
|
return x
|
|
|
|
|
|
DBL_MAX = 1.7976931348623157e308
|
|
vectors.append({"name": "e79 the extremes a document may hold",
|
|
"note": [DBL_MAX, int(DBL_MAX), lit("1.7976931348623158e308"), lit('"\\ud83d\\ude00"'),
|
|
nested(59)],
|
|
"receipts": [], "expected": []})
|
|
|
|
# --- the order of the reasons reported alone, and each member judged on its value (section 7)
|
|
vectors.append({"name": "e80 unknown_major comes before canonical_failure",
|
|
"receipts": [edit(R, lambda r: r.update(oer="3.0", extra="x"), seal=False)],
|
|
"expected": ["unknown_major"]})
|
|
|
|
vectors.append({"name": "e81 a forbidden number comes before an unsupported hash",
|
|
"receipts": [edit(R, lambda r: (r.update(hash_alg="sha3-512"), r["work"].update(latency_s=0.5)),
|
|
seal=False)],
|
|
"expected": ["malformed"]})
|
|
|
|
vectors.append({"name": "e82 an unsupported hash comes before a missing member",
|
|
"receipts": [edit(R, lambda r: (r.update(hash_alg="sha3-512"), r.pop("energy")), seal=False)],
|
|
"expected": ["missing_capability"]})
|
|
|
|
vectors.append({"name": "e83 an unsupported signature algorithm stops nothing",
|
|
"receipts": [edit(R, lambda r: (r.update(sig_alg="ecdsa-p256"), r["provenance"].update(how="guessed")))],
|
|
"expected": ["grade_unknown", "missing_capability"]})
|
|
|
|
vectors.append({"name": "e84 boundary absent",
|
|
"receipts": [edit(R, lambda r: r["energy"].pop("boundary"))], "expected": ["boundary_missing"]})
|
|
|
|
vectors.append({"name": "e85 a boundary that is not a string",
|
|
"receipts": [edit(R, lambda r: r["energy"].update(boundary=5))], "expected": ["boundary_missing"]})
|
|
|
|
# Extra published keys (see `keys` below): a key that is not a string, and three
|
|
# encodings of the identity point, which verifies a signature over any message.
|
|
NOT_A_STRING = "ed25519:not-a-string"
|
|
vectors.append({"name": "e86 a signer whose published key is not a string",
|
|
"receipts": [edit(R, lambda r: r["chain"].update(signer=NOT_A_STRING), seal=False)],
|
|
"expected": ["signer_unknown"]})
|
|
|
|
vectors.append({"name": "e87 receipt id absent",
|
|
"receipts": [edit(R, lambda r: r.pop("receipt_id"), seal=False)], "expected": ["malformed"]})
|
|
|
|
vectors.append({"name": "e88 signer absent",
|
|
"receipts": [edit(R, lambda r: r["chain"].pop("signer"), seal=False)], "expected": ["malformed"]})
|
|
|
|
# --- the remaining tolerance edges: exactly at each edge on its other side
|
|
vectors.append({"name": "e89 a sum exactly at its tolerance on the other side",
|
|
"receipts": [edit(R, lambda r: only_compute(r, "0.5", "0.500000001"))], "expected": []})
|
|
|
|
vectors.append({"name": "e90 a bundle exactly at its tolerance on the other side",
|
|
"receipts": [edit(BUNDLE, bundle_total(lambda st: st * Decimal("1e-9")))], "expected": []})
|
|
|
|
ledger_vector("e91 a ledger exactly at its tolerance on the other side",
|
|
ledger_with(lambda rest, m: m + m * Decimal("1e-9") - rest), [])
|
|
|
|
vectors.append({"name": "e92 a floor 1% low and one unit in the 34th digit",
|
|
"receipts": [edit(R, floor_at("0.99", "-1e-51"))], "expected": ["floor_mismatch"]})
|
|
|
|
|
|
# --- exactness past 34 digits for the floor, the bundle and the ledger. Each verdict
|
|
# flips for a verifier that rounds to 34 digits (IEEE 754 decimal128); the generator
|
|
# checks that it does, so the claim cannot rot.
|
|
from decimal import Context, ROUND_CEILING, localcontext
|
|
|
|
|
|
def flips_at_34(fn):
|
|
"""fn() under exact arithmetic is True (a miss) and under 34 digits False."""
|
|
with localcontext(V.EXACT):
|
|
exact_miss = fn()
|
|
with localcontext(Context(prec=34)):
|
|
rounded_miss = fn()
|
|
assert exact_miss and not rounded_miss, (exact_miss, rounded_miss)
|
|
|
|
|
|
def floor_past_34(r):
|
|
bits, kb, ln2 = V.dec(r["floor"]["bits_committed"]), V.KBOLTZ, V.LN2
|
|
for i in range(1, 10000): # a temperature whose floor needs more than 34 digits
|
|
t = Decimal("300.%031d" % (i * 7919 % 10**31)) # 34 significand digits
|
|
floor = exact(lambda: bits * kb * t * ln2)
|
|
with localcontext(Context(prec=34, rounding=ROUND_CEILING)):
|
|
fj = +(floor * Decimal("1.01")) # 1% high, rounded away: a miss, exactly
|
|
miss = lambda: abs(bits * kb * t * ln2 - fj) > abs(bits * kb * t * ln2) * Decimal("0.01")
|
|
with localcontext(Context(prec=34)):
|
|
if miss():
|
|
continue
|
|
flips_at_34(miss)
|
|
r["energy"]["temperature_k"] = decimal_string(t)
|
|
r["floor"]["floor_j"] = decimal_string(fj)
|
|
return
|
|
raise AssertionError("no temperature found")
|
|
|
|
|
|
vectors.append({"name": "e93 a floor that misses its tolerance only past 34 digits",
|
|
"receipts": [edit(R, floor_past_34)], "expected": ["floor_mismatch"]})
|
|
|
|
|
|
def bundle_past_34(r):
|
|
stages = r["bundle"]["stages"]
|
|
st = stage_sum(r)
|
|
stages.append({"kind": "decision", "receipt_hash": stages[0]["receipt_hash"], "joules": "1e-40"})
|
|
r["bundle"]["provenance_root"] = stage_root(stages, r["hash_alg"])
|
|
total = decimal_string(exact(lambda: st - st * Decimal("1e-9")))
|
|
only_compute(r, total, total)
|
|
flips_at_34(lambda: abs(sum(V.dec(s["joules"]) for s in stages) - Decimal(total))
|
|
> Decimal("1e-9") * max(Decimal(1), abs(sum(V.dec(s["joules"]) for s in stages))))
|
|
|
|
|
|
vectors.append({"name": "e94 a bundle that misses its tolerance only past 34 digits",
|
|
"receipts": [edit(BUNDLE, bundle_past_34)], "expected": ["bundle_sum_mismatch"]})
|
|
|
|
|
|
def ledger_past_34():
|
|
v = copy.deepcopy(v01)
|
|
L = v["ledger"]
|
|
receipts = exact(lambda: sum(V.dec(r["energy"]["joules_total"]) for r in v["receipts"]))
|
|
m = V.dec(L["meter_joules"])
|
|
L["unallocated_joules"] = "1e-40"
|
|
L["carried_forward_joules"] = decimal_string(exact(lambda: m + m * Decimal("1e-9") - receipts))
|
|
flips_at_34(lambda: not V._verify_ledger(v["receipts"], L))
|
|
return v
|
|
|
|
|
|
ledger_vector("e95 a ledger that misses its tolerance only past 34 digits", ledger_past_34(), ["ledger_mismatch"])
|
|
|
|
vectors.append({"name": "e96 a receipt served from a record that names none",
|
|
"receipts": [edit(R, lambda r: r["settlement"].pop("record_id"))],
|
|
"records": v01["records"], "expected": ["record_mismatch"]})
|
|
|
|
|
|
# --- keys a signature cannot rest on (section 5). Each signature is (R = [s]B, S = s),
|
|
# which verifies over any message under the identity point -- so a verifier that
|
|
# decodes keys leniently accepts it, and the key is the only defect.
|
|
def compress(pt):
|
|
zi = pow(pt[2], V._P - 2, V._P)
|
|
x, y = pt[0] * zi % V._P, pt[1] * zi % V._P
|
|
return (y | ((x & 1) << 255)).to_bytes(32, "little")
|
|
|
|
|
|
_GY = 4 * pow(5, V._P - 2, V._P) % V._P
|
|
_B = V._decompress(_GY.to_bytes(32, "little"))
|
|
S_FORGE = 12345
|
|
FORGED = (compress(V._point_mul(S_FORGE, _B)) + S_FORGE.to_bytes(32, "little")).hex()
|
|
IDENTITY_KEYS = {
|
|
"ed25519:identity": (1).to_bytes(32, "little"),
|
|
"ed25519:identity-sign-bit": (1 | (1 << 255)).to_bytes(32, "little"),
|
|
"ed25519:identity-y-p-plus-1": (V._P + 1).to_bytes(32, "little"),
|
|
}
|
|
for name, key in IDENTITY_KEYS.items():
|
|
from cryptography.hazmat.primitives.asymmetric import ed25519 as _ed
|
|
_ed.Ed25519PublicKey.from_public_bytes(key).verify(bytes.fromhex(FORGED), b"any message") # lenient: accepted
|
|
assert not V.strict_key(key), name
|
|
|
|
for n, (name, why) in enumerate([("ed25519:identity", "of small order"),
|
|
("ed25519:identity-sign-bit", "with the sign bit set on x = 0"),
|
|
("ed25519:identity-y-p-plus-1", "encoding y = p + 1")]):
|
|
vectors.append({"name": "e%d a signer key %s" % (97 + n, why),
|
|
"receipts": [edit(R, lambda r, name=name: r["chain"].update(signer=name, signature=FORGED),
|
|
seal=False)],
|
|
"expected": ["signature_invalid"]})
|
|
|
|
KEYS = dict(KEYS, **{NOT_A_STRING: 5}, **{k: v.hex() for k, v in IDENTITY_KEYS.items()})
|
|
|
|
out = {"oer": base["oer"], "draft": base["draft"],
|
|
"source": "edge cases for the reasons and number shapes the upstream vectors do not exercise",
|
|
"keys": KEYS, "vectors": vectors}
|
|
text = json.dumps(out, indent=0, ensure_ascii=False)
|
|
for key, literal in LITERALS.items():
|
|
assert text.count('"%s"' % key) == 1, key
|
|
text = text.replace('"%s"' % key, literal)
|
|
open(os.path.join(HERE, "edge_vectors.json"), "w", encoding="utf-8").write(text)
|
|
assert V._depth(V.load(open(os.path.join(HERE, "edge_vectors.json"), encoding="utf-8"))) == V.MAX_DEPTH
|
|
print("wrote %d edge vectors" % len(vectors))
|
|
|
|
# Documents every verifier must refuse whole (section 4). The midpoint above the
|
|
# largest binary64 rounds to infinity under round-half-even, in either spelling.
|
|
MIDPOINT = str(2**1024 - 2**970)
|
|
def receipt_with_duplicate_total():
|
|
"""One receipt whose energy carries joules_total twice: last-wins readers see the
|
|
sealed figure, first-wins readers see 999999.0."""
|
|
text = json.dumps(R)
|
|
i = text.index('"joules_total": ')
|
|
return text[:i] + '"joules_total": "999999.0", ' + text[i:]
|
|
|
|
|
|
UNREADABLE = {
|
|
"u01-midpoint-float": MIDPOINT + "e0",
|
|
"u02-midpoint-integer": MIDPOINT,
|
|
"u03-beyond-binary64": "1e400",
|
|
"u04-nan": "NaN",
|
|
"u05-infinity": "Infinity",
|
|
"u06-negative-infinity": "-Infinity",
|
|
"u07-unpaired-surrogate": '"\\ud800"',
|
|
"u08-nested-65": json.dumps(nested(63)),
|
|
"u09-duplicate-member": '1, "note": 2',
|
|
"u10-duplicate-figure-in-a-receipt": None,
|
|
"u11-nested-5000": "[" * 4999 + "]" * 4999,
|
|
}
|
|
UD = os.path.join(HERE, "unreadable")
|
|
os.makedirs(UD, exist_ok=True)
|
|
for name, note in UNREADABLE.items():
|
|
if note is None:
|
|
doc = '{"keys": %s, "vectors": [{"name": "x", "receipts": [%s], "expected": []}]}\n' % (
|
|
json.dumps(base["keys"]), receipt_with_duplicate_total())
|
|
else:
|
|
doc = '{"keys": {}, "vectors": [], "note": %s}\n' % note
|
|
open(os.path.join(UD, name + ".json"), "w", encoding="utf-8").write(doc)
|
|
print("wrote %d unreadable documents" % len(UNREADABLE))
|