- Rename manuspec to paperspec everywhere outside pixi.toml: prose, the tools, provenance tool names and the fixture key derivations, so every fixture and vector is rebuilt. - Move the namespace addresses under the site, mirroring the repository: schema $id https://paperspec.scient.ing/schemas/<file>, the JSON-LD context https://paperspec.scient.ing/context/scienting-1.jsonld, and term IRIs https://paperspec.scient.ing/vocab#<term>. Format identifiers (scienting-*) and URNs (urn:scienting:) are unchanged. - tools/site.py (standard library only) builds _site/: every file of the repository at its own path, each Markdown file rendered as a page with sidebar navigation, heading anchors matching the forge's, folder listings, a vocabulary page for the context's IRIs, a heading search and a 404 page; light and dark themes. - check.py builds the site and checks that every Markdown file has a page, every internal link and fragment resolves, no Markdown is left unrendered, and every $id, the context address and the vocabulary IRIs are files of the site. - deploy/: a Caddyfile with the media types and CORS the data needs, and a guide; .gitea/workflows/site.yml builds on main and publishes with rsync when the SITE_* secrets are set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1685 lines
95 KiB
Python
1685 lines
95 KiB
Python
#!/usr/bin/env python3
|
||
"""Build every fixture and test vector from fixtures/src and the cases below.
|
||
|
||
Non-normative tooling.
|
||
The build is deterministic: run it twice and the output is byte-identical; `tools/check.py` verifies that.
|
||
Usage: python3 tools/build.py [--out DIR] (default: the repository itself)
|
||
"""
|
||
|
||
import base64
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import shutil
|
||
import struct
|
||
import sys
|
||
import zipfile
|
||
import zlib
|
||
from fractions import Fraction
|
||
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
sys.path.insert(0, ROOT)
|
||
|
||
from tools.oracle import align as AL # noqa: E402
|
||
from tools.oracle import anchors as AN # noqa: E402
|
||
from tools.oracle import corrections as CO # noqa: E402
|
||
from tools.oracle import crypto, fingerprint as FP, grade as GR, hashing, jcs, receipts as RC, rights as RI # noqa: E402
|
||
from tools.oracle import targets as TG # noqa: E402
|
||
from tools.oracle.model import LEAVES, MATH_KINDS, RUN_KINDS, VERBATIM_KINDS, Doc # noqa: E402
|
||
from tools.oracle.notation import fold_math # noqa: E402
|
||
from tools.oracle.package import ZIP_EXTENSION, jsonl, line, witness_id, write, write_zip # noqa: E402
|
||
from tools.oracle.schema import Store # noqa: E402
|
||
from tools.oracle.text import letter_first, mint_node_id, norm, record_id, sha256_hex # noqa: E402
|
||
from tools.oracle.tree import Refusal, _flatten, mint_ids, validate_structure # noqa: E402
|
||
|
||
VERSION = "0.1.0"
|
||
STORE = "smithlab"
|
||
SCHEMAS = Store(os.path.join(ROOT, "schemas"))
|
||
|
||
|
||
def H(s):
|
||
return sha256_hex(s)
|
||
|
||
|
||
def secret(name):
|
||
return hashlib.sha256(("paperspec fixture key: " + name).encode()).digest()
|
||
|
||
|
||
KEYS = {
|
||
"ana": {"name": "Ana Example", "role": "owner", "purposes": ["sign"]},
|
||
"ben": {"name": "Ben Example", "role": "editor", "purposes": ["sign"]},
|
||
"ben-spot": {"name": "Ben Example", "role": "editor", "purposes": ["spot-check"]},
|
||
"cara": {"name": "Cara Example", "role": "reader", "purposes": ["sign"]},
|
||
}
|
||
TEAM_SECRET = hashlib.sha256(b"paperspec fixture team secret: smithlab").digest()
|
||
|
||
|
||
def roster():
|
||
keys = []
|
||
for kid, k in KEYS.items():
|
||
keys.append({"id": kid, "person": {"name": k["name"]}, "role": k["role"], "scheme": "ed25519",
|
||
"public": base64.b64encode(crypto.ed25519_public(secret(kid))).decode(),
|
||
"purposes": k["purposes"], "registered_at": "2026-09-01T00:00:00Z", "revoked_at": None})
|
||
return {"format": "scienting-roster/1", "store": STORE, "keys": keys}
|
||
|
||
|
||
ROSTER = roster()
|
||
ROSTER_BY_ID = {k["id"]: k for k in ROSTER["keys"]}
|
||
|
||
|
||
def png(w, h, rgb):
|
||
"""A tiny valid PNG, so a fixture can carry a real image asset."""
|
||
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
|
||
|
||
def chunk(t, d):
|
||
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
|
||
|
||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
|
||
|
||
|
||
# --- the shorthand of fixtures/src ------------------------------------------------
|
||
|
||
|
||
def runs_of(parts, kids):
|
||
out = []
|
||
for p in parts:
|
||
if isinstance(p, str):
|
||
out.append({"kind": "text", "text": p})
|
||
elif "sup" in p or "sub" in p or "em" in p:
|
||
st = next(k for k in ("sup", "sub", "em") if k in p)
|
||
out.append({"kind": "text", "text": p[st], "style": [st]})
|
||
elif "m" in p:
|
||
out.append({"kind": "math", "notation": "tex", "source": p["m"]})
|
||
elif "t" in p:
|
||
out.append({"kind": "math", "notation": "typst", "source": p["t"]})
|
||
elif "cite" in p:
|
||
out.append({"kind": "cite", "refs": p["cite"], "text": p["text"]})
|
||
elif "xref" in p:
|
||
out.append({"kind": "xref", "target": p["xref"], "text": p["text"]})
|
||
elif "fn" in p:
|
||
out.append({"kind": "fnmark", "footnote": p["fn"], "text": p["text"]})
|
||
elif "link" in p:
|
||
out.append({"kind": "link", "url": p["link"], "text": p["text"]})
|
||
elif "eq" in p:
|
||
e = p["eq"]
|
||
kids.append(_math_node("equation", e))
|
||
out.append({"kind": "equation", "child": len(kids) - 1})
|
||
else:
|
||
raise ValueError(p)
|
||
return out
|
||
|
||
|
||
def _math_node(kind, e):
|
||
n = {"kind": kind, "content": {"notation": "typst" if "typst" in e else "tex", "source": e.get("tex", e.get("typst"))}}
|
||
if "label" in e:
|
||
n["label"] = e["label"]
|
||
for k in ("name", "fidelity"):
|
||
if k in e:
|
||
n["_" + k if k == "name" else k] = e[k]
|
||
return n
|
||
|
||
|
||
def expand(s, page=None):
|
||
if isinstance(s, str):
|
||
s = {"s": [s]}
|
||
page = s.get("page", page)
|
||
if "s" in s:
|
||
kids = []
|
||
n = {"kind": "sentence", "content": runs_of(s["s"], kids), "children": kids}
|
||
elif "p" in s:
|
||
n = {"kind": "paragraph", "children": [expand(x, page) for x in s["p"]]}
|
||
elif "row" in s:
|
||
n = {"kind": "row", "children": [expand(x, page) for x in s["row"]]}
|
||
if "header_path" in s:
|
||
n["attrs"] = {"header_path": s["header_path"]}
|
||
elif "c" in s:
|
||
n = {"kind": "cell", "content": runs_of(s["c"], []),
|
||
"attrs": {"column": s["col"], "rowspan": s.get("rowspan", 1), "colspan": s.get("colspan", 1),
|
||
"role": s.get("role", "data")}}
|
||
n["fidelity"] = s.get("fidelity", "F1")
|
||
else:
|
||
n = {"kind": s["kind"]}
|
||
if "label" in s:
|
||
n["label"] = s["label"]
|
||
kids = []
|
||
if s["kind"] == "section":
|
||
n["attrs"] = {"level": s["level"]}
|
||
if "title" in s:
|
||
kids.append({"kind": "heading", "children": [expand({"s": [s["title"]], "fidelity": "F2"}, page)]})
|
||
if s["kind"] == "table":
|
||
n["attrs"] = {"columns": [{"id": c["name"], "header_path": c["header_path"]} for c in s["columns"]]}
|
||
if s["kind"] == "panel":
|
||
n["attrs"] = {}
|
||
if "figure_text" in s:
|
||
n["attrs"]["figure_text"] = [dict(t) for t in s["figure_text"]]
|
||
if s["kind"] in ("reference", "authors", "keywords"):
|
||
n["content"] = runs_of(s["runs"], [])
|
||
if s["kind"] in MATH_KINDS:
|
||
n = _math_node(s["kind"], s)
|
||
for k in ("csl", "resolution"):
|
||
if k in s:
|
||
n["_" + k] = s[k]
|
||
kids += [expand(x, page) for x in s.get("children", [])]
|
||
n["children"] = kids
|
||
if "name" in s:
|
||
n["_name"] = s["name"]
|
||
if "fidelity" in s and n["kind"] in LEAVES:
|
||
n["fidelity"] = s["fidelity"]
|
||
n["_page"] = page
|
||
for c in n.get("children", []):
|
||
if c.get("_page") is None:
|
||
c["_page"] = page
|
||
return n
|
||
|
||
|
||
class Names:
|
||
"""Local names (`@x`) and the IDs minted for them, per document."""
|
||
|
||
registry = {}
|
||
|
||
def __init__(self, key):
|
||
self.key = key
|
||
self.ids = {}
|
||
Names.registry[key] = self
|
||
|
||
def resolve(self, v):
|
||
if isinstance(v, str) and v.startswith("ext:"):
|
||
_, doc, name = v.split(":", 2)
|
||
return doc + "#" + Names.registry[doc].ids[name]
|
||
if isinstance(v, str) and v.startswith("@"):
|
||
return self.ids[v]
|
||
if isinstance(v, list):
|
||
return [self.resolve(x) for x in v]
|
||
return v
|
||
|
||
|
||
def resolve_refs(nodes, names):
|
||
for n in nodes:
|
||
if n["kind"] in RUN_KINDS:
|
||
out = []
|
||
for r in n["content"]:
|
||
r = dict(r)
|
||
if r["kind"] == "cite":
|
||
r["refs"] = names.resolve(r["refs"])
|
||
elif r["kind"] == "xref":
|
||
r["target"] = names.resolve(r["target"])
|
||
elif r["kind"] == "fnmark":
|
||
r["footnote"] = names.resolve(r["footnote"])
|
||
out.append(r)
|
||
n["content"] = out
|
||
a = n.get("attrs")
|
||
if a:
|
||
if "header_path" in a:
|
||
a["header_path"] = names.resolve(a["header_path"])
|
||
if "column" in a:
|
||
a["column"] = names.resolve(a["column"])
|
||
if "columns" in a:
|
||
a["columns"] = [{"id": names.resolve(c["id"]), "header_path": names.resolve(c["header_path"])}
|
||
for c in a["columns"]]
|
||
|
||
|
||
def mint_extras(key, nodes, names, used):
|
||
"""Columns and figure-text items get IDs from the same minting rule, kinds `column` and `figure-text`."""
|
||
doc = Doc(key, nodes)
|
||
seen = {}
|
||
|
||
def mint(kind, th):
|
||
occ = seen.get((kind, th), 0)
|
||
seen[(kind, th)] = occ + 1
|
||
nid = mint_node_id(key, kind, th, occ)
|
||
while nid in used:
|
||
occ += 1
|
||
nid = mint_node_id(key, kind, th, occ)
|
||
used.add(nid)
|
||
return nid
|
||
|
||
for n in nodes:
|
||
a = n.get("attrs", {})
|
||
for c in a.get("columns", []):
|
||
if c["id"].startswith("@"):
|
||
th = H(norm(" ".join(hashing.plain(doc, names.ids[h]) for h in c["header_path"])))
|
||
names.ids[c["id"]] = mint("column", th)
|
||
for t in a.get("figure_text", []):
|
||
t["id"] = mint("figure-text", H(norm(t["text"])))
|
||
if "name" in t:
|
||
names.ids[t.pop("name")] = t["id"]
|
||
|
||
|
||
def flatten_spec(spec):
|
||
flat = []
|
||
_flatten(copy.deepcopy(spec), None, 0, flat)
|
||
return flat
|
||
|
||
|
||
def establish(key, spec, names, used):
|
||
flat = flatten_spec(spec)
|
||
nodes = mint_ids(key, flat, used)
|
||
side = {}
|
||
for f, n in zip(flat, nodes):
|
||
if "_name" in f:
|
||
names.ids[f["_name"]] = n["id"]
|
||
side[n["id"]] = {k: v for k, v in f.items() if k.startswith("_")}
|
||
mint_extras(key, nodes, names, used)
|
||
resolve_refs(nodes, names)
|
||
return nodes, side
|
||
|
||
|
||
# --- geometry -----------------------------------------------------------------
|
||
|
||
|
||
def page_layout(doc, side, src, wit_id, fps_override=None):
|
||
"""Synthetic but consistent geometry: lines per page, fingerprints and boxes."""
|
||
P = src["pages"]
|
||
size = P["size"]
|
||
npages = len(P["labels"])
|
||
stamps = json.load(open(os.path.join(ROOT, "registry", "stamps", "scienting-stamps-1.json")))["rules"]
|
||
lines = {p: [] for p in range(1, npages + 1)}
|
||
boxes = {}
|
||
y = {p: 72.0 for p in lines}
|
||
for p, rh in P.get("running_head", {}).items():
|
||
lines[int(p)].append({"text": rh, "box": [72.0, 40.0, 400.0, 50.0]})
|
||
for nid in doc.preorder():
|
||
n = doc.nodes[nid]
|
||
pg = side.get(nid, {}).get("_page") or 1
|
||
if n["kind"] in ("sentence", "cell", "reference", "authors", "keywords") or (
|
||
n["kind"] == "equation" and doc.nodes[n["parent"]]["kind"] != "sentence"):
|
||
text = norm(hashing.plain(doc, nid))
|
||
lines[pg].append({"text": text})
|
||
bx = []
|
||
rest = len(text)
|
||
while True:
|
||
w = min(rest, 80)
|
||
bx.append([72.0, y[pg], round(72.0 + 5.85 * w, 2), round(y[pg] + 11.8, 2)])
|
||
y[pg] = round(y[pg] + 13.95, 2)
|
||
rest -= 80
|
||
if rest <= 0:
|
||
break
|
||
boxes[nid] = (pg, bx)
|
||
elif n["kind"] == "panel":
|
||
boxes[nid] = (pg, [[72.0, y[pg], 300.0, round(y[pg] + 150.0, 2)]])
|
||
y[pg] = round(y[pg] + 160.0, 2)
|
||
pages = []
|
||
images = {}
|
||
for nid, s in side.items():
|
||
if "_image" in s:
|
||
images.setdefault(boxes[nid][0], []).append(s["_image"])
|
||
for p in range(1, npages + 1):
|
||
ls = list(lines[p])
|
||
if "stamp" in P:
|
||
ls.append({"text": P["stamp"], "box": [8.0, 200.0, 20.0, 600.0]})
|
||
ls.append({"text": P["stamp2"], "box": [580.0, 200.0, 592.0, 600.0]})
|
||
page_in = {"size": size, "lines": ls, "images": images.get(p, [])}
|
||
fp, removed = FP.fingerprint(page_in, stamps)
|
||
rec = {"witness": wit_id, "page": p, "page_fp": fp, "size": size, "page_label": P["labels"][p - 1],
|
||
"text_layer": True}
|
||
if removed:
|
||
rec["stamps_removed"] = removed
|
||
pages.append((rec, page_in))
|
||
return pages, boxes
|
||
|
||
|
||
# --- packages -------------------------------------------------------------------
|
||
|
||
|
||
def rights_record(kind):
|
||
base = {"format": "scienting-rights/1", "at": "2026-09-15T12:00:00Z"}
|
||
if kind == "open":
|
||
base.update({"license": {"spdx": "CC-BY-4.0", "url": "https://creativecommons.org/licenses/by/4.0/"},
|
||
"basis": "open-license",
|
||
"uses": {"read": True, "tdm": True, "redistribute": True, "quote-public": True,
|
||
"accessible-copy": True, "ai": {p: True for p in RI.AI_PAIRS}},
|
||
"asserted_by": {"by": "crossref", "evidence": [
|
||
{"kind": "crossref-license", "doi": "10.5555/example.okafor.2025",
|
||
"url": "https://creativecommons.org/licenses/by/4.0/"}]},
|
||
"valid_until": None})
|
||
elif kind == "own":
|
||
base.update({"license": {"spdx": "CC-BY-4.0"}, "basis": "own-work",
|
||
"uses": {"read": True, "tdm": True, "redistribute": True, "quote-public": True,
|
||
"accessible-copy": True, "ai": {p: True for p in RI.AI_PAIRS}},
|
||
"asserted_by": {"by": "ana", "evidence": [{"kind": "statement", "note": "The team's own draft."}]},
|
||
"valid_until": None})
|
||
else:
|
||
base.update({"license": {"text": "Publisher's subscription terms (fictional)."}, "basis": "subscription",
|
||
"uses": {"read": True, "tdm": True, "redistribute": False, "quote-public": False,
|
||
"accessible-copy": "unknown",
|
||
"ai": {"internal/local": True, "internal/tenancy": "unknown",
|
||
"internal/outside-no-retention": "unknown", "internal/outside": False,
|
||
"external/local": False, "external/tenancy": False,
|
||
"external/outside-no-retention": False, "external/outside": False}},
|
||
"asserted_by": {"by": "ana", "evidence": [
|
||
{"kind": "statement", "note": "The library's subscription terms, as the team read them (fictional)."}]},
|
||
"valid_until": "2027-08-31T23:59:59Z"})
|
||
return base
|
||
|
||
|
||
def crop_sha(name):
|
||
return H("crop of " + name + " at 300 dpi (synthetic)")
|
||
|
||
|
||
def node_record(n, values):
|
||
rec = {k: n[k] for k in ("id", "kind", "parent", "ord")}
|
||
for k in ("label", "attrs", "content", "withheld", "fidelity", "prov"):
|
||
if k in n and n[k] not in (None, {}, []):
|
||
rec[k] = n[k]
|
||
rec.update(values[n["id"]])
|
||
return rec
|
||
|
||
|
||
def seal_receipts(spec, names, state_by_rev, final_state, key, wit_sha):
|
||
"""Build and sign the receipts and revocations a source lists."""
|
||
out = []
|
||
by_name = {}
|
||
final_doc = final_state.doc()
|
||
final_vals = hashing.compute(final_doc)
|
||
for r in spec:
|
||
if "revoke" in r:
|
||
rec = {"format": "scienting-revocation/1", "receipt": by_name[r["revoke"]]["id"], "reason": r["reason"],
|
||
"by": r["by"], "at": r["at"]}
|
||
rec = RC.seal(rec, secret(r["by"]), r["by"])
|
||
elif "qa_of" in r:
|
||
orig = by_name[r["qa_of"]]
|
||
seed = RC.make_seed(orig, secret(r["key"]))
|
||
sample = RC.sample(orig, seed)
|
||
hashes = dict((nid, h) for nid, h in orig["nodes"])
|
||
errs = [{"node": names.ids[e["node"]], **{k: v for k, v in e.items() if k != "node"}} for e in r["errors"]]
|
||
rec = {"format": "scienting-receipt/1", "doc": key, "rev": orig["rev"], "level": orig["level"],
|
||
"scope": "qa:" + orig["id"], "nodes": [[nid, hashes[nid]] for nid in sample],
|
||
"evidence": [{"kind": "crop", "node": nid, "sha256": crop_sha(nid)} for nid in sample],
|
||
"by": r["by"], "at": r["at"],
|
||
"qa": {"of": orig["id"], "key": r["key"], "seed": base64.b64encode(seed).decode(), "rate": "0.05",
|
||
"minimum": 10, "sample": sample, "errors": errs}}
|
||
rec = RC.seal(rec, secret(r["by"]), r["by"])
|
||
else:
|
||
rev = r.get("rev", final_state.rev)
|
||
st = state_by_rev[rev]
|
||
vals = hashing.compute(st.doc()) if rev != final_state.rev else final_vals
|
||
ids = sorted(names.ids[x] for x in r["nodes"])
|
||
scope = r["scope"]
|
||
if scope.startswith("section:@"):
|
||
scope = "section:" + names.ids[scope[8:]]
|
||
rec = {"format": "scienting-receipt/1", "doc": key, "rev": rev, "level": r["level"], "scope": scope,
|
||
"nodes": [[i, vals[i]["node_hash"]] for i in ids],
|
||
"evidence": [{"kind": "crop", "node": i, "sha256": crop_sha(i)} for i in ids]
|
||
+ [{"kind": "witness", "sha256": wit_sha}],
|
||
"by": r["by"], "at": r["at"]}
|
||
rec = RC.seal(rec, secret(r["by"]), r["by"])
|
||
by_name[r["name"]] = rec
|
||
out.append(rec)
|
||
out.sort(key=lambda x: (x["at"], x["id"]))
|
||
return out, by_name
|
||
|
||
|
||
def build_document(src, used=None, extra_files=None):
|
||
key = src["document"]["key"]
|
||
names = Names(key)
|
||
used = used if used is not None else set()
|
||
spec = expand(src["tree"])
|
||
nodes, side = establish(key, spec, names, used)
|
||
aliases = [{"id": a, "kind": "alias", "successor": names.ids[t]}
|
||
for a, t in sorted(src.get("legacy_aliases", {}).items())]
|
||
state = CO.State(key, nodes, aliases)
|
||
states = {0: state}
|
||
records = []
|
||
for c in src.get("corrections", []):
|
||
c = copy.deepcopy(c)
|
||
crop = c.pop("crop", None)
|
||
new_name = c.pop("new_name", None)
|
||
before = c.pop("before", None)
|
||
c["evidence"] = [{"kind": "crop", "sha256": crop_sha(names.ids.get(crop, crop))}] if crop else [
|
||
{"kind": "person", "by": "ana", "note": "Read against the page."}]
|
||
c["author"] = {"key": "ana"}
|
||
for k in ("target", "parent", "place_after", "with"):
|
||
if k in c:
|
||
c[k] = names.resolve(c[k])
|
||
if "node" in c:
|
||
nspec = expand(c["node"])
|
||
flat = flatten_spec(nspec)
|
||
pre = mint_ids(key, flat, state.used_ids())
|
||
for f, n in zip(flat, pre):
|
||
if "_name" in f:
|
||
names.ids[f["_name"]] = n["id"]
|
||
c["node"] = _strip_private(nspec, names)
|
||
if before is not None:
|
||
d = state.doc()
|
||
c["offset"] = hashing.plain(d, c["target"]).index(before)
|
||
state, rec = CO.apply(state, c, store=SCHEMAS)
|
||
if new_name:
|
||
names.ids[new_name] = rec["created"][0]
|
||
records.append(rec)
|
||
states[state.rev] = state
|
||
# A corrected leaf was checked against the page image: it is F1 at most until corroborated again.
|
||
final = state
|
||
touched = set()
|
||
for rec in records:
|
||
if any(e["kind"] == "crop" for e in rec["evidence"]):
|
||
touched |= set(rec["touched"]) | set(rec.get("created", []))
|
||
for n in final.nodes:
|
||
if n["id"] in touched and n["kind"] in LEAVES:
|
||
n["fidelity"] = "F1"
|
||
for n in final.nodes:
|
||
if n["kind"] in LEAVES and "fidelity" not in n:
|
||
n["fidelity"] = "F1"
|
||
return key, names, side, final, states, records
|
||
|
||
|
||
def _strip_private(spec, names):
|
||
out = {k: v for k, v in spec.items() if not k.startswith("_") and k != "children"}
|
||
if out["kind"] == "row" and "attrs" in out:
|
||
out["attrs"] = {"header_path": names.resolve(out["attrs"]["header_path"])}
|
||
if out["kind"] == "cell":
|
||
out["attrs"] = dict(out["attrs"], column=names.resolve(out["attrs"]["column"]))
|
||
if spec.get("children"):
|
||
out["children"] = [_strip_private(c, names) for c in spec["children"]]
|
||
return out
|
||
|
||
|
||
def package_files(src, key, names, side, final, records, receipts=None, roster_rec=None, witnesses=None,
|
||
pages=None, boxes=None, extra=None, absent=()):
|
||
doc = final.doc()
|
||
vals = hashing.compute(doc)
|
||
nodes = [node_record(doc.nodes[nid], vals) for nid in doc.preorder()]
|
||
files = {}
|
||
files["nodes.jsonl"] = jsonl(nodes)
|
||
if pages:
|
||
files["pages.jsonl"] = jsonl([p for p, _ in sorted(pages, key=lambda x: (x[0]["witness"], x[0]["page"]))])
|
||
locs = []
|
||
fps = {p["page"]: p for p, _ in pages}
|
||
pos = {nid: i for i, nid in enumerate(doc.preorder())}
|
||
for nid, (pg, bx) in boxes.items():
|
||
if nid in doc.nodes:
|
||
locs.append({"node": nid, "page_fp": fps[pg]["page_fp"], "page": pg,
|
||
"page_label": fps[pg]["page_label"], "boxes": bx})
|
||
locs.sort(key=lambda l: (pos[l["node"]], l["page"], l["page_fp"]))
|
||
if locs:
|
||
files["locations.jsonl"] = jsonl(locs)
|
||
refs = []
|
||
for nid in doc.preorder():
|
||
s = side.get(nid, {})
|
||
if doc.nodes[nid]["kind"] == "reference":
|
||
r = {"node": nid, "label": doc.nodes[nid].get("label")}
|
||
if "_csl" in s:
|
||
r["csl"] = s["_csl"]
|
||
res = s.get("_resolution", {"type": "none"})
|
||
r["resolution"] = res
|
||
refs.append({k: v for k, v in r.items() if v is not None})
|
||
if refs:
|
||
files["references.jsonl"] = jsonl(refs)
|
||
if records:
|
||
files["corrections.jsonl"] = jsonl(records)
|
||
ids = sorted(final.ids, key=lambda e: (0 if e["kind"] == "alias" else 1, e.get("rev", 0), e["id"]))
|
||
if ids:
|
||
files["ids.jsonl"] = jsonl(ids)
|
||
if receipts:
|
||
files["receipts.jsonl"] = jsonl(receipts)
|
||
if roster_rec:
|
||
files["roster.json"] = (jcs.dumps(roster_rec) + "\n").encode()
|
||
files["witnesses.json"] = (jcs.dumps({"witnesses": witnesses}) + "\n").encode()
|
||
files["provenance.json"] = (jcs.dumps({
|
||
"tools": [{"name": "paperspec tools/build.py", "version": VERSION}],
|
||
"stages": [{"stage": "hand", "processor": "local", "tool": "paperspec tools/build.py"}],
|
||
"note": "Built by hand for the conformance suite; every reading is synthetic."}) + "\n").encode()
|
||
levels = RC.effective_levels(nodes, vals, receipts or [], ROSTER_BY_ID if roster_rec else {})
|
||
d = dict(src["document"])
|
||
d = {"format": "scienting-doc/1", **d}
|
||
d.update({"hashing": {"scheme": "plain"}, "root": vals[doc.root]["node_hash"], "rev": final.rev,
|
||
"counts": {"nodes": len(nodes), "leaves": sum(1 for n in nodes if n["kind"] in LEAVES)},
|
||
"fidelity": RC.distribution(levels)})
|
||
d["meta_hash"] = hashing.meta_hash(d)
|
||
files["document.json"] = (jcs.dumps(d) + "\n").encode()
|
||
if extra:
|
||
files.update(extra)
|
||
return files, nodes, vals, d
|
||
|
||
|
||
def build_hale(out):
|
||
src = json.load(open(os.path.join(ROOT, "fixtures", "src", "hale2026harmonic.json")))
|
||
image = png(4, 3, (40, 90, 160))
|
||
image_sha = sha256_hex(image)
|
||
key, names, side, final, states, records = build_document(src)
|
||
side[names.ids["@pa"]]["_image"] = image_sha
|
||
doc = final.doc()
|
||
pa = doc.nodes[names.ids["@pa"]]
|
||
pa["attrs"]["image"] = {"sha256": image_sha, "media_type": "image/png", "native": True}
|
||
pa["attrs"]["crop"] = {"sha256": crop_sha("panel a"), "media_type": "image/png", "dpi": 300}
|
||
P = src["pages"]
|
||
wsha = H(P["witness"]["sha_seed"])
|
||
wid = witness_id(wsha)
|
||
for n in final.nodes:
|
||
if n["kind"] in LEAVES:
|
||
n["prov"] = {"witnesses": [wid], "backend": "docling (illustrative)"}
|
||
if n.get("fidelity") == "F2":
|
||
n["prov"]["corroborated_by"] = [wid]
|
||
pages, boxes = page_layout(final.doc(), side, src, wid)
|
||
p2 = [p for p, _ in pages if p["page"] == 2][0]
|
||
for t in pa["attrs"]["figure_text"]:
|
||
t["page_fp"] = p2["page_fp"]
|
||
t["boxes"] = [[90.0, 300.0, 140.0, 310.0]]
|
||
witnesses = [{"id": wid, "sha256": wsha, "size": P["witness"]["size"], "kind": "pdf", "media_type": "application/pdf",
|
||
"pages": len(P["labels"]), "text_layer": True, "version_label": "version of record",
|
||
"origin": {"source": "publisher", "doi": "10.5555/example.hale.2026", "retrieved_at": "2026-10-01T08:00:00Z"},
|
||
"rights": rights_record("subscription")}]
|
||
receipts, by_name = seal_receipts(src["receipts"], names, states, final, key, wsha)
|
||
# The dataset reported from Table 1.
|
||
final_doc = final.doc()
|
||
vals = hashing.compute(final_doc)
|
||
rows = [("ct-ct", "CT–CT", "@cc"), ("ct-hc", "CT–HC", "@ch2"), ("ow-hw", "OW–HW", "@ow")]
|
||
csv = ["key,bond,set,k_r,r_eq"]
|
||
anchors = []
|
||
for rk, bond, pre in rows:
|
||
for st, (kc, rc) in (("A", ("_ak", "_ar")), ("B", ("_bk", "_br"))):
|
||
kid, rid = names.ids[pre + kc], names.ids[pre + rc]
|
||
kv, rv = norm(hashing.plain(final_doc, kid)), norm(hashing.plain(final_doc, rid))
|
||
row_key = rk + "-" + st.lower()
|
||
csv.append("%s,%s,%s,%s,%s" % (row_key, bond, st, kv, rv))
|
||
for field, nid in (("k_r", kid), ("r_eq", rid)):
|
||
anchors.append({"resource": "bond-parameters", "row": row_key, "field": field,
|
||
"anchor": AN.make_anchor(final_doc, vals, nid, final.rev, store=STORE)})
|
||
ds_id = "ds-hale-bond-params"
|
||
datapackage = {"format": "scienting-dataset/1", "id": ds_id, "name": "hale-bond-params",
|
||
"title": "Harmonic bond parameters reported in Table 1",
|
||
"resources": [{"name": "bond-parameters", "path": "data.csv", "format": "csv", "encoding": "utf-8",
|
||
"schema": {"fields": [
|
||
{"name": "key", "type": "string"}, {"name": "bond", "type": "string"},
|
||
{"name": "set", "type": "string"},
|
||
{"name": "k_r", "type": "number", "unit": "kcal / mol / angstrom ** 2"},
|
||
{"name": "r_eq", "type": "number", "unit": "angstrom",
|
||
"uncertainty": {"kind": "unknown"}}],
|
||
"primaryKey": "key"},
|
||
"origin": {"kind": "reported", "anchors": "anchors.jsonl"}}]}
|
||
eq = names.ids["@eq1"]
|
||
mathml = ('<math display="block"><msub><mi>E</mi><mtext>bond</mtext></msub><mo>=</mo><mfrac><mn>1</mn><mn>2'
|
||
'</mn></mfrac><msub><mi>K</mi><mi>r</mi></msub><msup><mrow><mo>(</mo><mi>r</mi><mo>−</mo><msub><mi>r'
|
||
'</mi><mtext>eq</mtext></msub><mo>)</mo></mrow><mn>2</mn></msup></math>')
|
||
extra = {
|
||
"assets/%s.png" % image_sha: image,
|
||
"datasets/%s/datapackage.json" % ds_id: (jcs.dumps(datapackage) + "\n").encode(),
|
||
"datasets/%s/data.csv" % ds_id: ("\n".join(csv) + "\n").encode(),
|
||
"datasets/%s/anchors.jsonl" % ds_id: jsonl(anchors),
|
||
"derived/mathml.jsonl": jsonl([{"node": eq, "run": None, "tool": {"name": "hand", "version": "0"},
|
||
"mathml": mathml}]),
|
||
}
|
||
absent = [{"path": "assets/%s.png" % crop_sha("panel a"), "sha256": crop_sha("panel a"), "size": 18213,
|
||
"present": False}]
|
||
files, nodes, vals, d = package_files(src, key, names, side, final, records, receipts, ROSTER, witnesses, pages,
|
||
boxes, extra, absent)
|
||
write(os.path.join(out, "fixtures", "packages", key), files, absent)
|
||
return {"src": src, "key": key, "names": names, "states": states, "final": final, "records": records,
|
||
"receipts": receipts, "by_name": by_name, "pages": pages, "files": files, "nodes": nodes, "values": vals,
|
||
"document": d, "witness": witnesses[0], "absent": absent}
|
||
|
||
|
||
def build_checks_only(out, hale):
|
||
"""The same document exported to another machine of the team: text withheld, hashes keyed."""
|
||
final = hale["final"]
|
||
doc = final.doc()
|
||
k = crypto.team_key(TEAM_SECRET)
|
||
keyed = hashing.compute(doc, hashing.Hasher(k))
|
||
nodes = []
|
||
for nid in doc.preorder():
|
||
n = copy.deepcopy(doc.nodes[nid])
|
||
if n["kind"] in LEAVES:
|
||
n.pop("content", None)
|
||
n["withheld"] = True
|
||
if n["kind"] == "panel":
|
||
n["attrs"].pop("figure_text", None)
|
||
nodes.append(node_record(n, keyed))
|
||
f = dict(hale["files"])
|
||
for p in ("corrections.jsonl", "receipts.jsonl", "roster.json", "derived/mathml.jsonl"):
|
||
f.pop(p, None)
|
||
for p in [p for p in f if p.startswith("datasets/")]:
|
||
f.pop(p)
|
||
f["nodes.jsonl"] = jsonl(nodes)
|
||
refs = [{"node": r["node"], "label": r["label"], "resolution": r["resolution"]}
|
||
for r in map(json.loads, f["references.jsonl"].decode().splitlines())]
|
||
f["references.jsonl"] = jsonl(refs)
|
||
d = dict(hale["document"])
|
||
d.update({"policy": "checks-only", "hashing": {"scheme": "hmac-team", "key_id": crypto.key_id(k)},
|
||
"root": keyed[doc.root]["node_hash"]})
|
||
d["meta_hash"] = hashing.meta_hash(d, hashing.Hasher(k))
|
||
f["document.json"] = (jcs.dumps(d) + "\n").encode()
|
||
write(os.path.join(out, "fixtures", "packages", "hale2026harmonic.checks-only"), f, hale["absent"])
|
||
|
||
|
||
def build_simple(out, name, used=None):
|
||
src = json.load(open(os.path.join(ROOT, "fixtures", "src", name + ".json")))
|
||
key, names, side, final, states, records = build_document(src)
|
||
witnesses = []
|
||
pages = boxes = None
|
||
if "pages" in src:
|
||
P = src["pages"]
|
||
wsha = H(P["witness"]["sha_seed"])
|
||
wid = witness_id(wsha)
|
||
jsha = H(P["jats"]["sha_seed"])
|
||
jid = witness_id(jsha)
|
||
for n in final.nodes:
|
||
if n["kind"] in LEAVES:
|
||
n["prov"] = {"witnesses": [jid, wid], "backend": "jats reader (illustrative)", "corroborated_by": [wid]}
|
||
pages, boxes = page_layout(final.doc(), side, src, wid)
|
||
witnesses = [
|
||
{"id": jid, "sha256": jsha, "size": P["jats"]["size"], "kind": "jats", "media_type": "application/xml",
|
||
"version_label": "version of record",
|
||
"origin": {"source": "repository", "doi": "10.5555/example.okafor.2025"}, "rights": rights_record("open")},
|
||
{"id": wid, "sha256": wsha, "size": P["witness"]["size"], "kind": "pdf", "media_type": "application/pdf",
|
||
"pages": len(P["labels"]), "text_layer": True, "version_label": "version of record",
|
||
"origin": {"source": "publisher", "doi": "10.5555/example.okafor.2025"}, "rights": rights_record("open")}]
|
||
else:
|
||
T = src["typst"]
|
||
tsha = H(T["sha_seed"])
|
||
tid = witness_id(tsha)
|
||
for n in final.nodes:
|
||
if n["kind"] in LEAVES:
|
||
n["prov"] = {"witnesses": [tid], "backend": "typst reader (illustrative)"}
|
||
witnesses = [{"id": tid, "sha256": tsha, "size": T["size"], "kind": "typst", "media_type": "application/zip",
|
||
"version_label": "draft 3", "origin": {"source": "team"}, "rights": rights_record("own")}]
|
||
files, nodes, vals, d = package_files(src, key, names, side, final, records, None, None, witnesses, pages, boxes)
|
||
write(os.path.join(out, "fixtures", "packages", key), files)
|
||
return {"key": key, "names": names, "final": final, "files": files, "values": vals, "src": src}
|
||
|
||
|
||
# --- the tiny document used for corrections vectors and invalid packages -----------------
|
||
|
||
TINY = {"document": {"key": "tiny2026example", "kind": "article", "work": {"key": "tiny2026example", "same_as": []},
|
||
"version": {"type": "version-of-record"}, "relations": [], "meta": {"title": "A tiny example"},
|
||
"language": "en", "license": {"spdx": "CC0-1.0"}, "policy": "full"},
|
||
"tree": {"kind": "document", "children": [
|
||
{"kind": "section", "name": "@sec", "label": "1", "level": 1, "title": "Results", "children": [
|
||
{"name": "@p1", "p": [
|
||
{"name": "@a", "s": ["The value is 42.0 at 300 K."], "fidelity": "F2"},
|
||
{"name": "@b", "s": ["See ", {"xref": "@t", "text": "Table 1"}, " and the note.", {"cite": ["@r"], "text": "1"}],
|
||
"fidelity": "F2"}]},
|
||
{"name": "@p2", "p": [{"name": "@c", "s": ["A second paragraph."], "fidelity": "F2"},
|
||
{"name": "@d", "s": ["It ends here."], "fidelity": "F2"}]},
|
||
{"kind": "table", "name": "@t", "label": "Table 1",
|
||
"columns": [{"name": "@cx", "header_path": ["@hx"]}, {"name": "@cy", "header_path": ["@hy"]}],
|
||
"children": [
|
||
{"kind": "table-head", "children": [{"row": [
|
||
{"name": "@hx", "c": ["x"], "role": "column-header", "col": "@cx"},
|
||
{"name": "@hy", "c": ["y"], "role": "column-header", "col": "@cy"}]}]},
|
||
{"kind": "table-body", "name": "@tb", "children": [
|
||
{"name": "@row1", "header_path": ["@x1"], "row": [
|
||
{"name": "@x1", "c": ["1"], "role": "row-header", "col": "@cx"},
|
||
{"name": "@y1", "c": ["2.5"], "col": "@cy"}]}]}]}]},
|
||
{"kind": "back", "children": [{"kind": "references", "children": [
|
||
{"kind": "reference", "name": "@r", "label": "1", "runs": ["1. An Example, 2020."],
|
||
"resolution": {"type": "none"}, "fidelity": "F2"}]}]}]}}
|
||
|
||
|
||
def tiny_package(out_dir, receipts=True):
|
||
key, names, side, final, states, records = build_document(copy.deepcopy(TINY))
|
||
wsha = H("tiny2026example: a file (synthetic)")
|
||
wit = [{"id": witness_id(wsha), "sha256": wsha, "size": 1000, "kind": "pdf", "media_type": "application/pdf",
|
||
"pages": 1, "origin": {"source": "author"}, "rights": rights_record("own")}]
|
||
recs = None
|
||
if receipts:
|
||
recs, _ = seal_receipts([{"name": "t1", "by": "ana", "level": "F3", "scope": "all", "at": "2026-10-11T12:00:00Z",
|
||
"nodes": ["@a", "@b", "@c", "@d", "@x1", "@y1", "@hx", "@hy", "@r"]}],
|
||
names, states, final, key, wsha)
|
||
files, nodes, vals, d = package_files(TINY, key, names, side, final, records, recs, ROSTER if receipts else None,
|
||
wit)
|
||
if out_dir:
|
||
write(out_dir, files)
|
||
return {"key": key, "names": names, "final": final, "files": files, "values": vals}
|
||
|
||
|
||
# --- vectors --------------------------------------------------------------------
|
||
|
||
|
||
def wv(out, name, cases):
|
||
path = os.path.join(out, "vectors", name)
|
||
with open(path, "wb") as f:
|
||
f.write(jsonl(cases))
|
||
|
||
|
||
def sentence_doc(runs, key="vectors"):
|
||
"""A one-sentence document, so projections of runs can be computed with the node functions."""
|
||
nodes = [{"id": "aaaaaaa", "kind": "document", "parent": None, "ord": 0},
|
||
{"id": "bbbbbbb", "kind": "section", "parent": "aaaaaaa", "ord": 0, "attrs": {"level": 1}},
|
||
{"id": "ccccccc", "kind": "paragraph", "parent": "bbbbbbb", "ord": 0},
|
||
{"id": "ddddddd", "kind": "sentence", "parent": "ccccccc", "ord": 0, "content": runs}]
|
||
return Doc(key, nodes), "ddddddd"
|
||
|
||
|
||
def projection_case(cid, runs, note=None):
|
||
doc, s = sentence_doc(runs)
|
||
strict, cx = hashing.strict(doc, s)
|
||
lossy = hashing.lossy_from_strict(strict)
|
||
plain = hashing.plain(doc, s)
|
||
c = {"id": cid, "runs": runs, "plain": plain, "normalized": norm(plain), "strict": strict, "lossy": lossy,
|
||
"complex": cx, "text_hash": H(norm(plain)), "match_hash": H(strict), "align_key": H(lossy)}
|
||
if note:
|
||
c["note"] = note
|
||
return c
|
||
|
||
|
||
T = lambda t, **kw: dict({"kind": "text", "text": t}, **({"style": kw["style"]} if "style" in kw else {})) # noqa: E731
|
||
M = lambda s, n="tex": {"kind": "math", "notation": n, "source": s} # noqa: E731
|
||
|
||
|
||
def parse_doubles(text):
|
||
"""Parse JSON as an ECMAScript engine does: every number is an IEEE 754 double."""
|
||
return json.loads(text, parse_int=lambda x: int(x) if abs(int(x)) <= 2**53 else float(x))
|
||
|
||
|
||
def vectors(out, hale, tiny):
|
||
# Canonical JSON.
|
||
cj = [
|
||
("sort-utf16", '{"\\u20ac": 1, "\\ud83d\\ude00": 2, "a": 3, "\\ufb01": 4}'),
|
||
("numbers", '[0, -0.0, 1.0, 100.0, 72.10, 1E21, 1e-7, 0.000001, 123456789012345678901, -2.5e-5, 4.50e2]'),
|
||
("escapes", '{"s": "tab\\tnl\\ncr\\rq\\"bs\\\\ctl\\u0001del\\u007fnbsp\\u00a0slash\\/"}'),
|
||
("nested", '{ "b" : [ true , false , null ] , "a" : { "z" : "" , "y" : [ ] } }'),
|
||
("strings-stay-unnormalized", '{"cafe\\u0301": "e\\u0301", "caf\\u00e9": "\\u00e9"}'),
|
||
]
|
||
wv(out, "canonical-json.jsonl", [{"id": i, "input_json": t, "output": jcs.dumps(parse_doubles(t))} for i, t in cj])
|
||
# Normalization for hashing.
|
||
ns = [("nfc", "cafe\u0301"), ("collapse", "a \t\n b"), ("trim", "\u00a0 a b \u3000"),
|
||
("angstrom-sign", "1.5 \u212b"), ("nbsp-thin", "300\u2009K and 10\u00a0mol"),
|
||
("zero-width-kept", "a\u200bb"), ("ligature-kept", "\ufb01t"), ("micro-kept", "5 \u00b5m"),
|
||
("line-separator", "one\u2028two")]
|
||
wv(out, "normalize.jsonl", [{"id": i, "input": s, "output": norm(s), "sha256": H(norm(s))} for i, s in ns])
|
||
# IDs.
|
||
idc = []
|
||
for doc_key, kind, th, occ in [("cornell1995second", "sentence", H("A sentence."), 0),
|
||
("cornell1995second", "sentence", H("A sentence."), 1),
|
||
("cornell1995second.si", "sentence", H("A sentence."), 0),
|
||
("hale2026harmonic", "cell", H("310.0"), 0),
|
||
("hale2026harmonic", "column", H("Set A $K_r$ (kcal mol⁻¹ Å⁻²)"), 0)]:
|
||
payload = jcs.dumps(["scienting-id/1", doc_key, kind, th, occ])
|
||
idc.append({"id": "mint-%s-%s-%d" % (doc_key, kind, occ), "doc": doc_key, "kind": kind, "text_hash": th,
|
||
"occurrence": occ, "payload": payload, "sha256": H(payload),
|
||
"node_id": mint_node_id(doc_key, kind, th, occ)})
|
||
for hx in ("00" * 32, "ff" * 32, H("x")):
|
||
idc.append({"id": "letter-first-" + hx[:8], "digest": hx, "node_id": letter_first(bytes.fromhex(hx), 7),
|
||
"record_id": record_id(hx)})
|
||
wv(out, "ids.jsonl", idc)
|
||
# Notation.
|
||
notes = [("tex", "H_2O"), ("tex", "_2"), ("tex", "\\mathrm{H}_{2}\\mathrm{O}"), ("typst", "H_2 O"),
|
||
("typst", "upright(H)_2 upright(O)"), ("tex", "\\mathrm{cm}^{-1}"), ("typst", "\"cm\"^(-1)"),
|
||
("tex", "10^{-3}"), ("typst", "10^(-3)"), ("tex", "K_r"), ("tex", "K_{\\mathrm{r}}"), ("typst", "K_r"),
|
||
("tex", "\\alpha_i"), ("tex", "\\alpha_{i}"), ("tex", "x'_1"), ("tex", "x^{\\prime}"),
|
||
("tex", "T = 300\\,\\mathrm{K}"), ("tex", "-1.5"), ("tex", "\\epsilon"), ("typst", "epsilon.alt"),
|
||
("tex", "a \\cdot b"), ("typst", "a dot b"), ("tex", "a \\times b \\pm c"), ("tex", "\\left(x\\right)^2"),
|
||
("typst", "(a+b)^2"), ("tex", "\\frac{a}{b}"), ("typst", "a/b"), ("tex", "\\sqrt{2}"),
|
||
("tex", "\\mathbf{F}"), ("tex", "{ab}_2"), ("tex", "x_1_2"), ("tex", "\\Delta G^\\circ"),
|
||
("tex", "E_{\\mathrm{bond}} = \\frac{1}{2} K_r (r - r_{\\mathrm{eq}})^2"), ("typst", "E = K_r (r - r_\"eq\")^2"),
|
||
# A script after an empty group applies to the atom before it, or to an empty base (the isotope idiom).
|
||
("tex", "{}^{14}C"), ("tex", "x{}^2"), ("tex", "{}_2"), ("tex", "\\left)x\\right("),
|
||
# Letters are the Latin and Greek blocks: a fullwidth or modifier letter is complex, a Coptic letter too.
|
||
("tex", "A"), ("tex", "ʰ"), ("tex", "Ϣ"), ("tex", "ἀω")]
|
||
wv(out, "notation.jsonl", [{"id": "%s:%s" % (n, s), "notation": n, "source": s, "canonical": fold_math(n, s)}
|
||
for n, s in notes])
|
||
# Projections.
|
||
pc = [
|
||
projection_case("h2o-math", [T("The solvent is "), M("H_2O"), T(".")]),
|
||
projection_case("h2o-split-math", [T("The solvent is H"), M("_2"), T("O.")]),
|
||
projection_case("h2o-mathrm", [T("The solvent is "), M("\\mathrm{H}_{2}\\mathrm{O}"), T(".")]),
|
||
projection_case("h2o-sub", [T("The solvent is H"), T("2", style=["sub"]), T("O.")]),
|
||
projection_case("h2o-unicode", [T("The solvent is H\u2082O.")]),
|
||
projection_case("h2o-typst", [T("The solvent is "), M("H_2 O", "typst"), T(".")]),
|
||
projection_case("wavenumber-unicode", [T("A band near 1595 cm\u207b\u00b9.")]),
|
||
projection_case("wavenumber-sup", [T("A band near 1595 cm"), T("-1", style=["sup"]), T(".")]),
|
||
projection_case("wavenumber-math", [T("A band near 1595 "), M("\\mathrm{cm}^{-1}"), T(".")]),
|
||
projection_case("wavenumber-typst", [T("A band near 1595 "), M("\"cm\"^(-1)", "typst"), T(".")]),
|
||
projection_case("milli-unicode", [T("Values below 10\u207b\u00b3 were dropped.")]),
|
||
projection_case("milli-sup", [T("Values below 10"), T("\u22123", style=["sup"]), T(" were dropped.")]),
|
||
projection_case("milli-math", [T("Values below "), M("10^{-3}"), T(" were dropped.")]),
|
||
projection_case("kr-math", [T("The constant "), M("K_r"), T(" is large.")]),
|
||
projection_case("kr-mathrm", [T("The constant "), M("K_{\\mathrm{r}}"), T(" is large.")]),
|
||
projection_case("kr-sub", [T("The constant K"), T("r", style=["sub"]), T(" is large.")]),
|
||
projection_case("kr-unicode", [T("The constant K\u1d63 is large.")]),
|
||
projection_case("ligature", [T("The \ufb01t is good.")]),
|
||
projection_case("no-ligature", [T("The fit is good.")]),
|
||
projection_case("micro-sign", [T("A 5 \u00b5m film.")]),
|
||
projection_case("greek-mu", [T("A 5 \u03bcm film.")]),
|
||
projection_case("lost-superscript", [T("A rate of 103 per second.")], "OCR lost the superscript."),
|
||
projection_case("superscript", [T("A rate of 10\u00b3 per second.")]),
|
||
projection_case("cite-mark", [T("The energy"), {"kind": "cite", "refs": [], "text": "3"}, T(" is low.")]),
|
||
projection_case("cite-as-text", [T("The energy3 is low.")]),
|
||
projection_case("complex", [T("The energy is "), M("\\frac{1}{2} k x^2"), T(" exactly.")]),
|
||
projection_case("hyphen-before-digit", [T("pages 10-20 and CT\u2013CT with 6-31G*")]),
|
||
projection_case("en-dash-before-digit", [T("pages 10\u201320 and CT-CT with 6\u201331G*")]),
|
||
projection_case("astral", [T("Let "), M("\U0001d465"), T(" vary.")]),
|
||
]
|
||
groups = [
|
||
{"id": "h2o", "same": "match_hash",
|
||
"cases": ["h2o-math", "h2o-split-math", "h2o-mathrm", "h2o-sub", "h2o-unicode", "h2o-typst"]},
|
||
{"id": "wavenumber", "same": "match_hash",
|
||
"cases": ["wavenumber-unicode", "wavenumber-sup", "wavenumber-math", "wavenumber-typst"]},
|
||
{"id": "milli", "same": "match_hash", "cases": ["milli-unicode", "milli-sup", "milli-math"]},
|
||
{"id": "kr", "same": "match_hash", "cases": ["kr-math", "kr-mathrm", "kr-sub", "kr-unicode"]},
|
||
{"id": "ligature", "same": "match_hash", "cases": ["ligature", "no-ligature"]},
|
||
{"id": "micro", "same": "match_hash", "cases": ["micro-sign", "greek-mu"]},
|
||
{"id": "103-align", "same": "align_key", "cases": ["lost-superscript", "superscript"]},
|
||
{"id": "103-match", "differ": "match_hash", "cases": ["lost-superscript", "superscript"]},
|
||
{"id": "cite-structure", "differ": "match_hash", "cases": ["cite-mark", "cite-as-text"]},
|
||
{"id": "dashes-align", "same": "align_key", "cases": ["hyphen-before-digit", "en-dash-before-digit"]},
|
||
{"id": "dashes-text", "differ": "text_hash", "cases": ["hyphen-before-digit", "en-dash-before-digit"]},
|
||
]
|
||
byid = {c["id"]: c for c in pc}
|
||
for g in groups:
|
||
vals = {byid[c][g.get("same") or g.get("differ")] for c in g["cases"]}
|
||
assert (len(vals) == 1) if "same" in g else (len(vals) == len(g["cases"])), g
|
||
wv(out, "projections.jsonl", pc)
|
||
wv(out, "projection-groups.jsonl", groups)
|
||
# Offsets.
|
||
off = []
|
||
for cid, runs, span in [("bmp", [T("A sentence about H"), M("_2"), T("O.")], [18, 24]),
|
||
("astral", [T("Let "), M("\U0001d465"), T(" vary with "), M("\U0001d466"), T(".")], [4, 7])]:
|
||
doc, s = sentence_doc(runs)
|
||
p = hashing.plain(doc, s)
|
||
from tools.oracle.text import cp_to_utf16
|
||
off.append({"id": cid, "runs": runs, "plain": p, "span": span, "exact": p[span[0]:span[1]],
|
||
"utf16_span": [cp_to_utf16(p, span[0]), cp_to_utf16(p, span[1])], "code_points": len(p),
|
||
"utf16_units": cp_to_utf16(p, len(p))})
|
||
wv(out, "offsets.jsonl", off)
|
||
# Five values on a small tree: the tiny document.
|
||
tnodes = [{k: v for k, v in n.items() if k not in ("text_hash", "match_hash", "align_key", "own_hash", "node_hash")}
|
||
for n in map(json.loads, tiny["files"]["nodes.jsonl"].decode().splitlines())]
|
||
tdoc = Doc("tiny2026example", tnodes)
|
||
tv = hashing.compute(tdoc)
|
||
own = {nid: hashing.own_content(tdoc, nid, lambda x: tv[x]["text_hash"]) for nid in tdoc.order}
|
||
wv(out, "hashes.jsonl", [{"id": "tiny2026example", "doc": "tiny2026example", "nodes": tnodes,
|
||
"own_content": own, "values": tv}])
|
||
# Addresses.
|
||
ad = [("plain", "cornell1995second#k3m9q2a"), ("pinned", "cornell1995second#k3m9q2a@4f1a0c"),
|
||
("span", "cornell1995second#k3m9q2a@4f1a0c[12:57]"), ("store", "smithlab/cornell1995second#k3m9q2a"),
|
||
("span-no-pin", "dong2026machine.v2#k3m9q2a[0:0]"), ("si", "cornell1995second.si#m2x8p4d"),
|
||
("alias", "cornell1995second#b-p03-text-004"), ("bad-reversed", "cornell1995second#k3m9q2a[57:12]"),
|
||
("bad-pin", "cornell1995second#k3m9q2a@4F1A0C"), ("bad-leading-zero", "k#k3m9q2a[01:2]"),
|
||
("bad-no-node", "cornell1995second#"), ("bad-upper-key", "Cornell1995#k3m9q2a"),
|
||
("bad-two-stores", "a/b/c#k3m9q2a"), ("bad-pin-length", "k#k3m9q2a@4f1a")]
|
||
wv(out, "addresses.jsonl", [{"id": i, "address": a, "parsed": AN.parse_address(a)} for i, a in ad])
|
||
# Keyed hashes.
|
||
k = crypto.team_key(TEAM_SECRET)
|
||
fps = [p["page_fp"] for p, _ in hale["pages"]]
|
||
pk = crypto.page_key(fps, "layer:smithlab/kp4m2x7q")
|
||
text = "310.0"
|
||
keyed = [
|
||
{"id": "team-key", "secret": TEAM_SECRET.hex(), "key": k.hex(), "key_id": crypto.key_id(k)},
|
||
{"id": "team-hmac-text", "key": k.hex(), "text": text, "text_hash_plain": H(text),
|
||
"text_hash_keyed": hashing.Hasher(k)(text)},
|
||
{"id": "page-hashes", "page_fps": fps, "page_hashes": [crypto.page_hash(f) for f in fps]},
|
||
{"id": "page-key", "page_fps": fps, "context": "layer:smithlab/kp4m2x7q", "key": pk.hex(),
|
||
"quote": "310.0", "quote_mac": hashing.Hasher(pk)(norm(text))},
|
||
{"id": "non-holder", "note": "Without the pages, a guess hashes to the plain value, which the export never "
|
||
"carries; with one page changed, the key differs.",
|
||
"guess": text, "guess_sha256": H(text),
|
||
"wrong_page_fps": [fps[0], H("another page")],
|
||
"wrong_key": crypto.page_key([fps[0], H("another page")], "layer:smithlab/kp4m2x7q").hex(),
|
||
"quote_mac": hashing.Hasher(pk)(norm(text))},
|
||
]
|
||
assert keyed[4]["wrong_key"] != pk.hex() and keyed[4]["guess_sha256"] != keyed[4]["quote_mac"]
|
||
wv(out, "keyed.jsonl", keyed)
|
||
# Fingerprints: two downloads of one page, stamped differently.
|
||
stamps = json.load(open(os.path.join(ROOT, "registry", "stamps", "scienting-stamps-1.json")))["rules"]
|
||
_, p1 = hale["pages"][0]
|
||
body = [l for l in p1["lines"] if "box" not in l]
|
||
acs1 = dict(p1)
|
||
acs2 = dict(p1, lines=body + [
|
||
{"text": "Downloaded via EXAMPLE PHARMA INC on March 2, 2027 at 09:14:55 (UTC).", "box": [8.0, 210.0, 20.0, 590.0]},
|
||
{"text": "See https://pubs.acs.org/sharingguidelines for options on how to legitimately share published articles.",
|
||
"box": [580.0, 200.0, 592.0, 600.0]}])
|
||
wiley = {"size": [595.276, 841.89], "images": [H("figure 2 image stream (synthetic)")], "lines": [
|
||
{"text": "Results"}, {"text": "The fitted slope is 0.42 \u00b1 0.03 per kelvin."},
|
||
{"text": "Downloaded from https://onlinelibrary.wiley.com/doi/10.5555/example.wiley by Example University, "
|
||
"Wiley Online Library on [11/10/2026]. See the Terms and Conditions (https://onlinelibrary.wiley.com/"
|
||
"terms-and-conditions) on Wiley Online Library for rules of use.", "box": [580.0, 100.0, 592.0, 740.0]}]}
|
||
wiley2 = dict(wiley, lines=wiley["lines"][:2] + [
|
||
{"text": "Downloaded from https://onlinelibrary.wiley.com/doi/10.5555/example.wiley by Example Pharma Inc, "
|
||
"Wiley Online Library on [02/03/2027].", "box": [580.0, 100.0, 592.0, 740.0]},
|
||
{"text": "See the Terms and Conditions (https://onlinelibrary.wiley.com/terms-and-conditions) on Wiley Online "
|
||
"Library for rules of use.", "box": [580.0, 60.0, 592.0, 99.0]}])
|
||
changed = dict(acs1, lines=[dict(l, text=l["text"].replace("1595", "1594")) for l in acs1["lines"]])
|
||
scan = {"size": [612.4, 791.6], "lines": [], "images": [H("scanned page image (synthetic)")]}
|
||
fpc = []
|
||
for cid, pg in [("acs-download-1", acs1), ("acs-download-2", acs2), ("acs-changed-text", changed),
|
||
("wiley-download-1", wiley), ("wiley-download-2", wiley2), ("scanned", scan)]:
|
||
fp, removed = FP.fingerprint(pg, stamps)
|
||
kept = [l["text"] for l in pg["lines"] if not FP.is_stamp(l, pg["size"], stamps)]
|
||
fpc.append({"id": cid, "page": pg, "rules": "scienting-stamps-1", "removed": removed,
|
||
"text": FP.fp_text(" ".join(kept)), "page_fp": fp})
|
||
assert fpc[0]["page_fp"] == fpc[1]["page_fp"] != fpc[2]["page_fp"] and fpc[3]["page_fp"] == fpc[4]["page_fp"]
|
||
wv(out, "fingerprints.jsonl", fpc)
|
||
# Merkle roots.
|
||
mk = []
|
||
for n in (1, 2, 3, 4, 5, 7):
|
||
items = [["node", mint_node_id("m", "sentence", H(str(i)), 0), H("h%d" % i)] for i in range(n)]
|
||
mk.append({"id": "items-%d" % n, "items": items, "root": crypto.merkle_root(items)})
|
||
wv(out, "merkle.jsonl", mk)
|
||
# Signatures.
|
||
r1 = hale["by_name"]["r1"]
|
||
sk = bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
|
||
wv(out, "signatures.jsonl", [
|
||
{"id": "rfc8032-test-1", "secret": sk.hex(), "public": crypto.ed25519_public(sk).hex(), "message": "",
|
||
"signature": crypto.ed25519_sign(sk, b"").hex()},
|
||
{"id": "receipt-r1", "receipt": r1, "record_hash": RC.record_hash(r1),
|
||
"payload": RC.signing_payload(r1).decode(), "public": ROSTER_BY_ID["ana"]["public"],
|
||
"record_id": r1["id"]}])
|
||
# Spot-checks.
|
||
q1 = hale["by_name"]["q1"]
|
||
wv(out, "spotcheck.jsonl", [{"id": "q1", "receipt": r1, "seed_payload": RC.seed_payload(r1).decode(),
|
||
"checker_public": ROSTER_BY_ID["ben-spot"]["public"], "seed": q1["qa"]["seed"],
|
||
"n": len(r1["nodes"]), "rate": "0.05", "minimum": 10,
|
||
"size": RC.sample_size(len(r1["nodes"])), "sample": q1["qa"]["sample"]}]
|
||
+ [{"id": "size-%d" % n, "n": n, "rate": "0.05", "minimum": 10, "size": RC.sample_size(n)}
|
||
for n in (3, 10, 199, 200, 201, 3000)])
|
||
# Bounds.
|
||
wv(out, "bounds.jsonl", [{"id": "k%d-n%d" % (kk, nn), "errors": kk, "sampled": nn, "confidence": "0.95",
|
||
"bound": RC.bound_string(kk, nn), "approx": round(RC.upper_bound(kk, nn), 9)}
|
||
for kk, nn in [(0, 10), (0, 300), (1, 10), (1, 300), (2, 50), (5, 100), (0, 1), (10, 10)]])
|
||
# Alignment.
|
||
def item(i, runs, container, context="paragraph"):
|
||
doc, s = sentence_doc(runs)
|
||
st, cx = hashing.strict(doc, s)
|
||
return {"id": i, "runs": runs, "strict": st, "lossy": hashing.lossy_from_strict(st), "complex": cx,
|
||
"container": container, "context": context}
|
||
A = [item("a1", [T("Methods")], "h", "heading"),
|
||
item("a2", [T("Every bond was scanned in steps of 0.01 \u00c5 around its minimum.")], "p1"),
|
||
item("a3", [T("where "), M("\\frac{a}{b}"), T(" is the bond length.")], "p1"),
|
||
item("a4", [T("The fitted rate is 10\u00b3 per second at room temperature.")], "p1"),
|
||
item("a5", [T("where "), M("\\frac{c}{d}"), T(" is the bond length.")], "p2"),
|
||
item("a6", [T("This sentence was dropped by the second reading entirely.")], "p2"),
|
||
item("a7", [T("The last sentence of the section is unique and long enough.")], "p2")]
|
||
B = [item("b1", [T("Methods")], "x", "paragraph"),
|
||
item("b2", [T("Every bond was scanned in steps of 0.01 \u00c5 around its minimum.")], "q1"),
|
||
item("b3", [T("where "), M("\\frac{a}{b}"), T(" is the bond length.")], "q1"),
|
||
item("b4", [T("The fitted rate is 103 per second at room temperature.")], "q2"),
|
||
item("b5", [T("where "), M("\\frac{c}{e}"), T(" is the bond length.")], "q2"),
|
||
item("b6", [T("A sentence only the second reading has, also long.")], "q2"),
|
||
item("b7", [T("The last sentence of the section is unique and long enough.")], "q2")]
|
||
wv(out, "alignment.jsonl", [{"id": "two-readings", "a": A, "b": B, "expect": AL.align(A, B)}])
|
||
# Corrections, op by op, on the tiny document.
|
||
wv(out, "corrections.jsonl", corrections_vectors())
|
||
# Anchor resolution against the hale package.
|
||
wv(out, "resolve.jsonl", resolve_vectors(hale))
|
||
# Rights.
|
||
wv(out, "rights.jsonl", rights_vectors(hale))
|
||
|
||
|
||
def corrections_vectors():
|
||
tiny = tiny_package(None, receipts=False)
|
||
st0 = tiny["final"]
|
||
nm = tiny["names"].ids
|
||
crop = [{"kind": "crop", "sha256": crop_sha("tiny")}]
|
||
person = [{"kind": "person", "by": "ana"}]
|
||
base = {"author": {"key": "ana"}, "reason": "Vector.", "at": "2026-10-11T12:00:00Z"}
|
||
cases = [
|
||
("replace-digit", {"op": "replace", "target": nm["@a"], "find": "42.0", "replace": "42.5", "evidence": crop}),
|
||
("replace-digit-no-crop", {"op": "replace", "target": nm["@a"], "find": "42.0", "replace": "42.5", "evidence": person}),
|
||
("replace-word", {"op": "replace", "target": nm["@c"], "find": "second", "replace": "later", "evidence": person}),
|
||
("replace-not-unique", {"op": "replace", "target": nm["@a"], "find": "0", "replace": "1", "evidence": crop}),
|
||
("replace-runs", {"op": "replace", "target": nm["@a"], "find": "300 K", "replace": [
|
||
{"kind": "math", "notation": "tex", "source": "300\\,\\mathrm{K}"}], "evidence": crop}),
|
||
("insert-text", {"op": "insert", "target": nm["@d"], "run": 0, "offset": 8, "text": "only ", "evidence": crop}),
|
||
("insert-text-no-crop", {"op": "insert", "target": nm["@d"], "run": 0, "offset": 8, "text": "only ",
|
||
"evidence": person}),
|
||
("insert-text-bad-run", {"op": "insert", "target": nm["@d"], "run": 7, "offset": 0, "text": "x",
|
||
"evidence": crop}),
|
||
("insert-node", {"op": "insert", "parent": nm["@p2"], "place_after": nm["@c"],
|
||
"node": {"kind": "sentence", "content": [{"kind": "text", "text": "A dropped sentence."}]},
|
||
"evidence": crop}),
|
||
("delete", {"op": "delete", "target": nm["@p2"], "evidence": person}),
|
||
("delete-digits-no-crop", {"op": "delete", "target": nm["@p1"], "evidence": person}),
|
||
("split", {"op": "split", "target": nm["@a"], "offset": 13, "evidence": person}),
|
||
("split-inside-xref", {"op": "split", "target": nm["@b"], "offset": 6, "evidence": person}),
|
||
("merge", {"op": "merge", "target": nm["@c"], "with": nm["@d"], "joiner": " ", "evidence": person}),
|
||
("merge-not-adjacent", {"op": "merge", "target": nm["@a"], "with": nm["@c"], "evidence": person}),
|
||
("move", {"op": "move", "target": nm["@d"], "parent": nm["@p1"], "place_after": None, "evidence": person}),
|
||
("move-after-itself", {"op": "move", "target": nm["@d"], "parent": nm["@p2"], "place_after": nm["@d"],
|
||
"evidence": person}),
|
||
("reclassify", {"op": "reclassify", "target": nm["@p2"], "kind": "footnote", "evidence": person}),
|
||
("reclassify-blocks", {"op": "reclassify", "target": nm["@p2"], "kind": "quote", "evidence": person}),
|
||
("reclassify-bad", {"op": "reclassify", "target": nm["@a"], "kind": "paragraph", "evidence": person}),
|
||
("relabel-section", {"op": "relabel", "target": nm["@sec"], "label": "2", "evidence": crop}),
|
||
("relabel-remove", {"op": "relabel", "target": nm["@sec"], "label": None, "evidence": crop}),
|
||
("relabel-digit-no-crop", {"op": "relabel", "target": nm["@t"], "label": "Table 2", "evidence": person}),
|
||
("set-colspan", {"op": "set", "target": nm["@y1"], "attr": "colspan", "value": 2, "evidence": person}),
|
||
("set-bad-value", {"op": "set", "target": nm["@y1"], "attr": "colspan", "value": "2", "evidence": person}),
|
||
("set-not-settable", {"op": "set", "target": nm["@y1"], "attr": "level", "value": 2, "evidence": person}),
|
||
("insert-node-junk", {"op": "insert", "parent": nm["@p2"], "place_after": nm["@c"],
|
||
"node": {"kind": "sentence", "content": [{"kind": "text", "text": "Junk."}], "foo": 1},
|
||
"evidence": crop}),
|
||
("delete-header-cell", {"op": "delete", "target": nm["@hx"], "evidence": person}),
|
||
]
|
||
out = []
|
||
vals0 = hashing.compute(st0.doc())
|
||
before = [dict(n, **vals0[n["id"]]) for n in st0.nodes]
|
||
for cid, c in cases:
|
||
c = dict(base, **c)
|
||
case = {"id": cid, "doc": st0.key, "before": {"nodes": before, "ids": [], "rev": 0}, "correction": c}
|
||
try:
|
||
st1, rec = CO.apply(st0, c, store=SCHEMAS)
|
||
v1 = hashing.compute(st1.doc())
|
||
case["expect"] = {"record": rec, "nodes": [dict(n, **v1[n["id"]]) for n in st1.nodes], "ids": st1.ids}
|
||
except Refusal as e:
|
||
case["expect"] = {"refusal": e.rule}
|
||
out.append(case)
|
||
return out
|
||
|
||
|
||
def resolve_vectors(hale):
|
||
src, names, states, final = hale["src"], hale["names"], hale["states"], hale["final"]
|
||
s0 = states[0]
|
||
d0 = s0.doc()
|
||
v0 = hashing.compute(d0)
|
||
out = []
|
||
for cid, a in src["anchors_at_rev0"].items():
|
||
nid = names.ids[a["node"]]
|
||
if a.get("container_of"):
|
||
nid = d0.nodes[nid]["parent"]
|
||
span = None
|
||
if "quote" in a:
|
||
t = hashing.plain(d0, nid)
|
||
i = t.index(a["quote"])
|
||
span = [i, i + len(a["quote"])]
|
||
anchor = AN.make_anchor(d0, v0, nid, 0, span=span, store=STORE)
|
||
if "alias" in a:
|
||
anchor["node"] = a["alias"]
|
||
res = AN.resolve(final, anchor)
|
||
out.append({"id": cid, "package": "fixtures/packages/hale2026harmonic", "anchor": anchor, "expect": res})
|
||
return out
|
||
|
||
|
||
def rights_vectors(hale):
|
||
out = OUT
|
||
w = hale["witness"]
|
||
stages = json.load(open(os.path.join(ROOT, "registry", "stages", "scienting-stages-default.json")))["stages"]
|
||
policy = json.load(open(os.path.join(out, "fixtures", "records", "policy.json")))
|
||
cases = []
|
||
for cid, stage, proc, now, pols in [
|
||
("convert-local", "convert-model", "local", "2026-10-11T00:00:00Z", []),
|
||
("convert-outside", "convert-model", "outside", "2026-10-11T00:00:00Z", []),
|
||
("review-tenancy-unknown", "review-model", "tenancy", "2026-10-11T00:00:00Z", []),
|
||
("review-tenancy-policy", "review-model", "tenancy", "2026-10-11T00:00:00Z", [policy]),
|
||
("index", "index", "local", "2026-10-11T00:00:00Z", []),
|
||
("embed-outside-refused", "embed", "outside-no-retention", "2026-10-11T00:00:00Z", [policy]),
|
||
("publish-quotes", "publish-quotes", "local", "2026-10-11T00:00:00Z", []),
|
||
("read-expired", "read", "local", "2027-09-01T00:00:00Z", []),
|
||
("accessible-copy-unknown", "accessible-copy", "local", "2026-10-11T00:00:00Z", []),
|
||
]:
|
||
uses, expired = RI.effective(w, pols, now)
|
||
ok, lacking = RI.check(stages[stage], proc, uses)
|
||
cases.append({"id": cid, "rights": w["rights"], "origin": w["origin"], "witness_sha256": w["sha256"],
|
||
"policies": pols, "stage": stage, "requires": stages[stage], "processor": proc, "now": now,
|
||
"expect": {"allowed": ok, "lacking": lacking, "expired": expired}})
|
||
oa = rights_record("open")
|
||
sub = w["rights"]
|
||
cases.append({"id": "combine", "rights": [oa, sub],
|
||
"expect": RI.combine([RI.flat(oa), RI.flat(sub)])})
|
||
backends = [json.load(open(os.path.join(out, "fixtures", "records", "backend-%s.json" % b))) for b in ("docling", "examplevendor")]
|
||
profiles = [json.load(open(os.path.join(out, "fixtures", "records", "profile-%s.json" % p))) for p in ("smithlab", "acme")]
|
||
for b in backends:
|
||
for p in profiles:
|
||
ok, failed = RI.backend_allowed(b, p)
|
||
cases.append({"id": "backend-%s-%s" % (b["name"], p["team"]), "backend": b, "profile": p,
|
||
"expect": {"allowed": ok, "failed": failed}})
|
||
return cases
|
||
|
||
|
||
# --- records ------------------------------------------------------------------------
|
||
|
||
|
||
def wr(out, rel, rec):
|
||
path = os.path.join(out, "fixtures", "records", rel)
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
with open(path, "wb") as f:
|
||
f.write((jcs.dumps(rec) + "\n").encode() if not isinstance(rec, bytes) else rec)
|
||
|
||
|
||
CTX = ["http://www.w3.org/ns/anno.jsonld", "https://paperspec.scient.ing/context/scienting-1.jsonld"]
|
||
|
||
|
||
def ann_hash(a):
|
||
return H(jcs.dumpb({k: v for k, v in a.items() if k not in ("status", "receipt", "modified")}))
|
||
|
||
|
||
def records(out, hale):
|
||
names, final = hale["names"], hale["final"]
|
||
doc = final.doc()
|
||
vals = hashing.compute(doc)
|
||
rev = final.rev
|
||
I = names.ids
|
||
|
||
def anchor(name, quote=None):
|
||
nid = I[name]
|
||
span = None
|
||
if quote:
|
||
t = hashing.plain(doc, nid)
|
||
i = t.index(quote)
|
||
span = [i, i + len(quote)]
|
||
return AN.make_anchor(doc, vals, nid, rev, span=span, store=STORE)
|
||
|
||
wr(out, "roster.json", ROSTER)
|
||
# The doubtful reading behind the first correction, as the converter raised it before anyone answered it.
|
||
sus = {"format": "scienting-suspect/1", "kind": "number", "node": I["@s4"], "a": "10-3", "b": "10\u22123",
|
||
"crop": crop_sha(I["@s4"]),
|
||
"note": "The text layer reads a hyphen on the line; the page image shows a raised minus three."}
|
||
sus = dict(sus, id=record_id(sha256_hex(jcs.dumpb(sus))))
|
||
wr(out, "suspect.json", sus)
|
||
wr(out, "anchor-span.json", anchor("@s3", "first test case"))
|
||
wr(out, "anchor-cell.json", anchor("@cc_ak"))
|
||
wr(out, "anchor-paragraph.json", AN.make_anchor(doc, vals, doc.nodes[I["@s3"]]["parent"], rev, store=STORE))
|
||
long_anchor = AN.make_anchor(doc, vals, I["@s2"], rev, store=STORE)
|
||
wr(out, "anchor-leaf.json", long_anchor)
|
||
# A public layer's anchor on a paywalled paper: keyed by the pages, no text.
|
||
fps = [p["page_fp"] for p, _ in hale["pages"]]
|
||
layer_id = "urn:scienting:layer:smithlab/kp4m2x7q"
|
||
pk = crypto.page_key(fps, "layer:smithlab/kp4m2x7q")
|
||
hk = hashing.Hasher(pk)
|
||
ka = anchor("@cc_ak")
|
||
keyed_vals = hashing.compute(doc, hk)
|
||
ka = {"format": "scienting-anchor/1", "store": STORE, "doc": ka["doc"], "node": ka["node"], "kind": "cell",
|
||
"own_hash": keyed_vals[ka["node"]]["own_hash"], "node_hash": keyed_vals[ka["node"]]["node_hash"],
|
||
"quote": {"mac": hk(norm(ka["quote"]["exact"])), "length": len(ka["quote"]["exact"])},
|
||
"context": {"table_label_mac": hk(norm(ka["context"]["table_label"])),
|
||
"row_path_mac": [hk(norm(x)) for x in ka["context"]["row_path"]],
|
||
"column_path_mac": [hk(norm(x)) for x in ka["context"]["column_path"]]},
|
||
"rev": rev, "keyed": {"scheme": "hkdf-pages", "context": "layer:smithlab/kp4m2x7q",
|
||
"pages": [crypto.page_hash(f) for f in fps]}}
|
||
wr(out, "anchor-keyed.json", ka)
|
||
# Rights, policy, backends, profiles.
|
||
wr(out, "rights-open.json", rights_record("open"))
|
||
wr(out, "rights-subscription.json", rights_record("subscription"))
|
||
ccc = rights_record("subscription")
|
||
ccc.update({"license": {"text": "Annual collective license with internal AI re-use (fictional)."},
|
||
"basis": "collective-license", "valid_until": "2027-06-30T23:59:59Z"})
|
||
wr(out, "rights-collective.json", ccc)
|
||
policy = RC.seal({"format": "scienting-policy/1", "project": "smithlab-charges",
|
||
"applies_to": {"basis": ["subscription"], "doi_prefixes": ["10.5555/"]},
|
||
"uses": {"ai": {"internal/tenancy": True}},
|
||
"statement": "Our institutional agreement covers internal AI use in our own cloud account for "
|
||
"these papers (fictional).",
|
||
"by": "ana", "at": "2026-10-05T09:00:00Z", "valid_until": "2027-08-31T23:59:59Z"},
|
||
secret("ana"), "ana")
|
||
wr(out, "policy.json", policy)
|
||
wr(out, "backend-docling.json", {
|
||
"format": "scienting-backend/1", "name": "docling", "version": "illustrative", "stage": "read",
|
||
"processor": "local", "code_license": {"spdx": "MIT"},
|
||
"weights": [{"name": "layout model", "license": {"spdx": "Apache-2.0"}}], "conditions": []})
|
||
wr(out, "backend-examplevendor.json", {
|
||
"format": "scienting-backend/1", "name": "examplevendor", "version": "1.0", "stage": "read",
|
||
"processor": "local", "code_license": {"spdx": "Apache-2.0"},
|
||
"weights": [{"name": "example weights", "license": {"text": "A fictional weight license with conditions."}}],
|
||
"conditions": [{"id": "revenue-cap", "kind": "revenue-cap", "usd": 5000000},
|
||
{"id": "no-competition", "kind": "no-competition", "vendor": "examplevendor"},
|
||
{"id": "purpose", "kind": "purpose", "allowed": ["research", "personal", "education", "startup"]}]})
|
||
wr(out, "profile-smithlab.json", {"format": "scienting-profile/1", "team": "smithlab", "purpose": "research",
|
||
"revenue_or_funding_usd": {"at_most": 2000000}, "competes_with": [],
|
||
"recorded_by": "ana", "at": "2026-10-01T00:00:00Z"})
|
||
wr(out, "profile-acme.json", {"format": "scienting-profile/1", "team": "acme", "purpose": "commercial",
|
||
"revenue_or_funding_usd": {"at_most": None}, "competes_with": None,
|
||
"recorded_by": "acme-admin", "at": "2026-10-01T00:00:00Z"})
|
||
# A layer with one annotation of each common kind.
|
||
creator = {"id": "urn:scienting:key:ana", "type": "Person", "name": "Ana Example"}
|
||
model = {"id": "urn:scienting:software:example-model", "type": "Software", "name": "A model (illustrative)"}
|
||
|
||
def target(a):
|
||
return {"type": "SpecificResource", "source": "urn:scienting:doc:smithlab/hale2026harmonic",
|
||
"selector": [{"type": "AnchorSelector", "anchor": a}]
|
||
+ ([{"type": "TextQuoteSelector", "exact": a["quote"]["exact"],
|
||
"prefix": a["quote"].get("prefix", ""), "suffix": a["quote"].get("suffix", "")}]
|
||
if "quote" in a and "exact" in a["quote"] else [])}
|
||
|
||
def ann(body, tgt, motivation, status="proposed", who=creator, created="2026-10-11T19:00:00Z"):
|
||
a = {"@context": CTX, "type": "Annotation", "layer": layer_id, "motivation": motivation, "creator": who,
|
||
"created": created, "body": body, "target": tgt}
|
||
a["id"] = "urn:scienting:annotation:smithlab/" + record_id(H(jcs.dumpb(a)))
|
||
a["status"] = status
|
||
return a
|
||
|
||
ex_body = {"format": "scienting-exercise/1", "exercise": "anchor",
|
||
"prompt": "Find the table cell that gives the Set A bond constant for the CT–CT bond.",
|
||
"key": {"elements": [{"anchors": [anchor("@cc_ak")], "points": "1",
|
||
"feedback": {"hit": "That is the cell.", "miss": "Look in Table 1, row CT–CT."}}]},
|
||
"rule": {"match": "same-node"}}
|
||
check_body = {"format": "scienting-exercise/1", "exercise": "check",
|
||
"prompt": "Does the cited passage support the claim that harmonic terms are the most common choice?",
|
||
"key": {"verdict": {"value": "supports", "points": "1"},
|
||
"elements": [{"anchors": [anchor("@s1", "the most common description")], "points": "1"}]},
|
||
"rule": {"match": "overlap", "min_overlap": "0.5"}}
|
||
anns = [
|
||
ann({"type": "TextualBody", "kind": "gloss", "audience": "novice", "format": "text/markdown", "language": "en",
|
||
"value": "A *harmonic* term treats a bond like a spring: the energy grows with the square of the stretch."},
|
||
target(anchor("@s2", "bond force constant")), "describing", "confirmed"),
|
||
ann({"type": "SpecificResource", "kind": "claim-link", "source": "lode:amber/k-bond", "pin": "illustrative-pin",
|
||
"role": "states"}, target(anchor("@s2")), "linking", "confirmed"),
|
||
ann({"type": "TextualBody", "kind": "comment", "value": "The step size matters more than the text admits."},
|
||
target(anchor("@s6a", "steps of 0.01")), "commenting"),
|
||
ann({"type": "TextualBody", "kind": "question", "for": "group",
|
||
"value": "Why fit to a scan first and to frequencies second?"},
|
||
target(AN.make_anchor(doc, vals, I["@sec2"], rev, store=STORE)), "questioning"),
|
||
ann({"type": "TextualBody", "kind": "citation-context", "reference": I["@r2"], "verdict": "cannot-tell",
|
||
"reason": "value", "value": "Cited for the band position; the reference is not in the library."},
|
||
target(anchor("@s3", "1595")), "assessing"),
|
||
ann({"type": "TextualBody", "kind": "generated", "value": "Two panels: an energy scan and its fit residual.",
|
||
"generator": {"model": "example-model", "run": "illustrative-run-1"}},
|
||
target(AN.make_anchor(doc, vals, I["@fig1"], rev, store=STORE)), "describing", "proposed", model),
|
||
ann({"type": "TextualBody", "kind": "exercise", "value": ex_body["prompt"], "exercise": ex_body},
|
||
target(AN.make_anchor(doc, vals, I["@tab1"], rev, store=STORE)), "assessing", "confirmed"),
|
||
ann({"type": "TextualBody", "kind": "exercise", "value": check_body["prompt"], "exercise": check_body},
|
||
target(anchor("@s1")), "assessing", "confirmed"),
|
||
# A person's description of the figure, which an accessible export may use because it is confirmed.
|
||
ann({"type": "TextualBody", "kind": "description", "role": "alt", "language": "en",
|
||
"value": "Energy along the C–C bond stretch (panel a) and the fit residual (panel b)."},
|
||
target(AN.make_anchor(doc, vals, I["@fig1"], rev, store=STORE)), "describing", "confirmed"),
|
||
]
|
||
anns.append(ann({"type": "TextualBody", "kind": "comment", "value": "Agreed; see the residual panel."},
|
||
{"type": "SpecificResource", "source": anns[2]["id"]}, "replying", created="2026-10-11T19:05:00Z"))
|
||
confirmed = [a for a in anns if a["status"] == "confirmed"]
|
||
lr = RC.seal({"format": "scienting-receipt/1", "doc": "hale2026harmonic", "rev": rev, "level": "F3",
|
||
"scope": "targeted:layer", "nodes": [],
|
||
"records": sorted([["annotation", a["id"].rsplit("/", 1)[1], ann_hash(a)] for a in confirmed]),
|
||
"evidence": [], "by": "ana", "at": "2026-10-11T19:30:00Z"}, secret("ana"), "ana")
|
||
for a in confirmed:
|
||
a["receipt"] = lr["id"]
|
||
wr(out, "layer/layer.json", {"@context": CTX, "format": "scienting-layer/1", "id": layer_id,
|
||
"type": "AnnotationCollection", "label": "Smith lab reading of Hale and Osei",
|
||
"purpose": "Glosses, claim links and exercises for new students.",
|
||
"audience": "student", "visibility": "team", "license": {"spdx": "CC-BY-4.0"},
|
||
"creator": creator, "created": "2026-10-11T19:00:00Z", "project": "smithlab-charges",
|
||
"docs": ["smithlab/hale2026harmonic"], "total": len(anns)})
|
||
wr(out, "layer/annotations.jsonl", jsonl(anns))
|
||
wr(out, "layer/receipt.json", lr)
|
||
# Submissions and grades.
|
||
ex_ann = anns[6]
|
||
sub_ok = {"format": "scienting-submission/1", "exercise": ex_ann["id"], "doc": "smithlab/hale2026harmonic",
|
||
"rev": rev, "student": "s-7k2m", "answers": {"anchors": [anchor("@cc_ak")]},
|
||
"submitted_at": "2026-10-20T10:00:00Z"}
|
||
sub_ok["id"] = record_id(H(jcs.dumpb(sub_ok)))
|
||
sub_miss = dict(sub_ok, student="s-9q4p", answers={"anchors": [anchor("@cc_bk")]})
|
||
sub_miss.pop("id")
|
||
sub_miss["id"] = record_id(H(jcs.dumpb(sub_miss)))
|
||
ch_ann = anns[7]
|
||
sub_check = {"format": "scienting-submission/1", "exercise": ch_ann["id"], "doc": "smithlab/hale2026harmonic",
|
||
"rev": rev, "student": "s-7k2m",
|
||
"answers": {"verdict": "supports", "anchors": [anchor("@s1", "most common description of covalent")]},
|
||
"submitted_at": "2026-10-20T10:05:00Z"}
|
||
sub_check["id"] = record_id(H(jcs.dumpb(sub_check)))
|
||
grades = []
|
||
for name, sub, body in (("anchor-hit", sub_ok, ex_body), ("anchor-miss", sub_miss, ex_body),
|
||
("check-overlap", sub_check, check_body)):
|
||
g = GR.grade(body, sub, final)
|
||
wr(out, "submissions/%s.json" % name, sub)
|
||
wr(out, "grades/%s.json" % name, g)
|
||
grades.append({"id": name, "package": "fixtures/packages/hale2026harmonic", "exercise": body,
|
||
"submission": sub, "expect": g})
|
||
wv(out, "grading.jsonl", grades)
|
||
# Project.
|
||
wr(out, "project.json", {
|
||
"format": "scienting-project/1", "key": "smithlab-charges", "store": STORE, "name": "Charges and bonds",
|
||
"members": [{"person": "Ana Example", "role": "owner", "keys": ["ana"]},
|
||
{"person": "Ben Example", "role": "editor", "keys": ["ben", "ben-spot"]},
|
||
{"person": "Cara Example", "role": "reader", "keys": ["cara"]}],
|
||
"documents": [{"doc": "smithlab/hale2026harmonic", "reason": "The bond constants we start from.",
|
||
"tags": ["parameters"], "status": {"Ben Example": "read", "Cara Example": "reading"}},
|
||
{"doc": "okafor2025open", "reason": "Solvent shifts for the validation set.", "tags": ["spectra"]}],
|
||
"paths": [{"id": "onboarding", "title": "First week", "steps": [
|
||
{"type": "document", "ref": "smithlab/hale2026harmonic", "note": "Read the introduction."},
|
||
{"type": "node", "ref": "smithlab/hale2026harmonic#" + I["@tab1"], "note": "Then the table."},
|
||
{"type": "claim", "ref": "lode:amber/k-bond"}]}],
|
||
"layers": [layer_id], "datasets": ["ds-hale-bond-params", "ds-hale-ctct-scan"], "views": ["ctct-scan"],
|
||
"drafts": ["smithlab/smithlab2027charges"], "claims": {"lode": "amber"},
|
||
"settings": {"digit_rule": "anchored-sections", "drift_threshold": "0.25",
|
||
"spot_check": {"rate": "0.05", "minimum": 10}, "rights_unknown": "refuse",
|
||
"stages": "scienting-stages-default"}})
|
||
# A digitization of panel a, and the dataset it yields.
|
||
p2 = hale["pages"][1][0]
|
||
pa = I["@pa"]
|
||
ft = {t["text"]: t["id"] for t in doc.nodes[pa]["attrs"]["figure_text"]}
|
||
pts = [((110.0, 360.0), ("1.45", "3.10")), ((150.0, 330.0), ("1.50", "0.40")), ((190.0, 300.0), ("1.55", "2.80"))]
|
||
dg = {"format": "scienting-digitization/1", "doc": "smithlab/hale2026harmonic",
|
||
"target": {"anchor": AN.make_anchor(doc, vals, pa, rev, store=STORE), "page_fp": p2["page_fp"],
|
||
"region": [72.0, 260.0, 300.0, 410.0], "witness": hale["witness"]["sha256"]},
|
||
"axes": [{"id": "x", "scale": "linear", "unit": "angstrom",
|
||
"calibration": [{"at": [90.0, 400.0], "value": "1.4"}, {"at": [210.0, 400.0], "value": "1.6"}],
|
||
"check": {"at": [150.0, 400.0], "value": "1.5"}, "label": {"panel": pa, "figure_text": ft["r (Å)"]}},
|
||
{"id": "y", "scale": "linear", "unit": "kcal / mol",
|
||
"calibration": [{"at": [80.0, 400.0], "value": "0"}, {"at": [80.0, 280.0], "value": "4"}],
|
||
"label": {"panel": pa, "figure_text": ft["E (kcal mol⁻¹)"]}}],
|
||
"series": [{"id": "scan", "legend": "scan", "mark": {"color": "#285aa0", "marker": "circle"}, "kind": "points",
|
||
"values": [{"at": list(at), "value": {"x": x, "y": y},
|
||
"uncertainty": {"x": "0.01", "y": "0.05", "resolution": "0.01", "marker": "0.02",
|
||
"calibration": "0.01"}} for at, (x, y) in pts]}],
|
||
"method": {"kind": "manual", "tool": {"name": "hand", "version": "0"}},
|
||
"verification": {"image": "", "residual": "0.4", "max_residual": "0.9"},
|
||
"confirmation": None, "dataset": "ds-hale-ctct-scan", "rev": rev}
|
||
dg["verification"]["image"] = crop_sha("overlay of panel a")
|
||
dg["id"] = record_id(H(jcs.dumpb(dg)))
|
||
wr(out, "digitization.json", dg)
|
||
body = {k: v for k, v in dg.items() if k != "confirmation"}
|
||
conf = RC.seal({"format": "scienting-receipt/1", "doc": "hale2026harmonic", "rev": rev, "level": "F3",
|
||
"scope": "targeted:digitization", "nodes": [],
|
||
"records": [["digitization", dg["id"], H(jcs.dumpb(body))]],
|
||
"evidence": [{"kind": "crop", "node": pa, "sha256": crop_sha("overlay of panel a")}],
|
||
"by": "ana", "at": "2026-10-11T20:00:00Z"}, secret("ana"), "ana")
|
||
wr(out, "digitization-confirmation.json", conf)
|
||
ds = {"format": "scienting-dataset/1", "id": "ds-hale-ctct-scan", "name": "hale-ctct-scan",
|
||
"title": "Energy along the C–C stretch, read from Figure 1a",
|
||
"resources": [{"name": "scan", "path": "data.csv", "format": "csv", "encoding": "utf-8",
|
||
"schema": {"fields": [{"name": "point", "type": "string"},
|
||
{"name": "r", "type": "number", "unit": "angstrom",
|
||
"uncertainty": {"field": "r_u", "kind": "resolution"}},
|
||
{"name": "r_u", "type": "number", "unit": "angstrom"},
|
||
{"name": "e", "type": "number", "unit": "kcal / mol",
|
||
"uncertainty": {"field": "e_u", "kind": "resolution"}},
|
||
{"name": "e_u", "type": "number", "unit": "kcal / mol"}],
|
||
"primaryKey": "point"},
|
||
"origin": {"kind": "digitized", "digitization": dg["id"], "anchors": "anchors.jsonl"}}]}
|
||
csvd = "point,r,r_u,e,e_u\n" + "".join("p%d,%s,0.01,%s,0.05\n" % (i, x, y) for i, (_, (x, y)) in enumerate(pts))
|
||
va = [{"resource": "scan", "row": "p%d" % i, "field": f, "digitization": dg["id"], "series": "scan", "point": i}
|
||
for i in range(len(pts)) for f in ("r", "e")]
|
||
wr(out, "datasets/ds-hale-ctct-scan/datapackage.json", ds)
|
||
wr(out, "datasets/ds-hale-ctct-scan/data.csv", csvd.encode())
|
||
wr(out, "datasets/ds-hale-ctct-scan/anchors.jsonl", jsonl(va))
|
||
ds_files = sorted([["anchors.jsonl", H(jsonl(va)), len(jsonl(va))], ["data.csv", H(csvd), len(csvd.encode())],
|
||
["datapackage.json", H(jcs.dumps(ds) + "\n"), len((jcs.dumps(ds) + "\n").encode())]])
|
||
ds_hash = H(jcs.dumpb({"format": "scienting-dataset/1", "files": ds_files}))
|
||
wr(out, "view.vl.json", {
|
||
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
|
||
"usermeta": {"scienting": {"format": "scienting-view/1", "id": "ctct-scan",
|
||
"data": [{"name": "ds-hale-ctct-scan", "dataset": "ds-hale-ctct-scan",
|
||
"hash": ds_hash, "resource": "scan"}]}},
|
||
"data": {"name": "ds-hale-ctct-scan"}, "mark": {"type": "point"},
|
||
"encoding": {"x": {"field": "r", "type": "quantitative", "title": "r (Å)"},
|
||
"y": {"field": "e", "type": "quantitative", "title": "E (kcal/mol)"}}})
|
||
# QA report.
|
||
q1 = hale["by_name"]["q1"]
|
||
q2 = hale["by_name"]["q2"]
|
||
n = len(q1["qa"]["sample"]) + len(q2["qa"]["sample"])
|
||
k = len(q1["qa"]["errors"]) + len(q2["qa"]["errors"])
|
||
wr(out, "qa-report.json", RC.seal({
|
||
"format": "scienting-qa-report/1", "delivery": "smithlab evidence pilot (fictional)",
|
||
"docs": ["smithlab/hale2026harmonic"], "receipts": [hale["by_name"]["r1"]["id"], hale["by_name"]["r4"]["id"]],
|
||
"reviewers": [{"key": "ana", "sampled": n, "errors": k, "confidence": "0.95", "method": "clopper-pearson",
|
||
"bound": RC.bound_string(k, n)}],
|
||
"by": "ana", "at": "2026-10-12T10:00:00Z"}, secret("ana"), "ana"))
|
||
# A checklist for an accessible HTML export of the paper, and the receipt that covers it (spec/08 §8.11).
|
||
html_sha = H("accessible HTML export of hale2026harmonic (synthetic)")
|
||
checker = {"name": "example-checker", "version": "1.0"}
|
||
checklist = {"format": "scienting-checklist/1", "doc": "smithlab/hale2026harmonic", "rev": rev,
|
||
"standard": {"name": "WCAG 2.1", "level": "AA", "url": "https://www.w3.org/TR/WCAG21/"},
|
||
"export": {"kind": "html", "sha256": html_sha, "size": 48213},
|
||
"items": [
|
||
{"criterion": "1.1.1", "result": "pass", "method": "manual",
|
||
"note": "Every figure has a confirmed description in the layer."},
|
||
{"criterion": "1.2.1", "result": "not-applicable", "method": "manual", "note": "No audio or video."},
|
||
{"criterion": "1.3.1", "result": "pass", "method": "manual",
|
||
"note": "Headings, lists and table headers are marked up; each cell names its header path."},
|
||
{"criterion": "1.3.2", "result": "pass", "method": "automated", "tool": checker},
|
||
{"criterion": "1.4.3", "result": "pass", "method": "automated", "tool": checker},
|
||
{"criterion": "1.4.5", "result": "pass", "method": "manual",
|
||
"note": "Equations are MathML, never images of text."},
|
||
{"criterion": "2.4.2", "result": "pass", "method": "automated", "tool": checker},
|
||
{"criterion": "3.1.1", "result": "pass", "method": "automated", "tool": checker}],
|
||
"by": "ana", "at": "2026-10-12T11:00:00Z"}
|
||
checklist_hash = H(jcs.dumpb(checklist))
|
||
checklist["id"] = record_id(checklist_hash)
|
||
wr(out, "checklist/checklist.json", checklist)
|
||
wr(out, "checklist/receipt.json", RC.seal({
|
||
"format": "scienting-receipt/1", "doc": "hale2026harmonic", "rev": rev, "level": "F3",
|
||
"scope": "targeted:checklist", "nodes": [], "records": [["checklist", checklist["id"], checklist_hash]],
|
||
"evidence": [{"kind": "source-file", "sha256": html_sha, "note": "The exported HTML."}],
|
||
"by": "ana", "at": "2026-10-12T11:30:00Z"}, secret("ana"), "ana"))
|
||
# The target set of targeted F3 (spec/08 §8.10) under each digit rule: the dataset's cells, the sentence a
|
||
# confirmed claim link anchors, and the panel a digitization reads from are the anchored nodes.
|
||
dataset_anchors = [json.loads(x) for x in
|
||
hale["files"]["datasets/ds-hale-bond-params/anchors.jsonl"].decode().splitlines()]
|
||
anchored = sorted({a["anchor"]["node"] for a in dataset_anchors} | {I["@s2"], pa})
|
||
wv(out, "targets.jsonl", [{"id": "hale-" + rule, "package": "fixtures/packages/hale2026harmonic",
|
||
"anchored": anchored, "digit_rule": rule,
|
||
"expect": {"nodes": TG.target_set(doc, anchored, rule)}}
|
||
for rule in TG.DIGIT_RULES])
|
||
# Draft snapshot and sentence IDs.
|
||
s5 = I["@s5"]
|
||
snap_anchor = AN.make_anchor(doc, vals, s5, rev, store=STORE)
|
||
wr(out, "snapshot.json", {"format": "scienting-snapshot/1", "store": STORE, "made_at": "2026-10-11T21:00:00Z",
|
||
"quotes": [{"address": AN.format_address(STORE, "hale2026harmonic", s5, vals[s5]["node_hash"]),
|
||
"anchor": snap_anchor, "text": norm(hashing.plain(doc, s5)),
|
||
"fidelity": "F3", "result": "current"}],
|
||
"claims": [{"ref": "amber/k-bond", "pin": "illustrative-pin", "level": "illustrative",
|
||
"typst": "K_r"}],
|
||
"datasets": [{"id": "ds-hale-ctct-scan", "hash": ds_hash}]})
|
||
draft = Names.registry["smithlab2027charges"]
|
||
dd = Doc("smithlab2027charges", [json.loads(x) for x in open(os.path.join(
|
||
out, "fixtures", "packages", "smithlab2027charges", "nodes.jsonl"))])
|
||
lines = []
|
||
for nid in dd.preorder():
|
||
if dd.nodes[nid]["kind"] == "sentence":
|
||
sec = next((a for a in reversed(dd.ancestors(nid)) if dd.nodes[a]["kind"] == "section"), None)
|
||
lines.append({"id": nid, "text_hash": dd.nodes[nid]["text_hash"], "section": sec,
|
||
"ord": dd.nodes[nid]["ord"]})
|
||
lines[-1]["pinned"] = "energy-sentence"
|
||
wr(out, "draft-ids.jsonl", jsonl(lines))
|
||
|
||
|
||
# --- invalid packages ---------------------------------------------------------------
|
||
|
||
|
||
def invalid(out, tiny):
|
||
base = os.path.join(out, "fixtures", "invalid")
|
||
files = tiny["files"]
|
||
nodes = [json.loads(x) for x in files["nodes.jsonl"].decode().splitlines()]
|
||
nm = tiny["names"].ids
|
||
|
||
def edit_nodes(fn):
|
||
ns = copy.deepcopy(nodes)
|
||
fn(ns)
|
||
return jsonl(ns)
|
||
|
||
def by(ns, name):
|
||
return next(n for n in ns if n["id"] == nm[name])
|
||
|
||
def case(name, rule, change, note, raw=None):
|
||
f = dict(files)
|
||
f.update(change)
|
||
d = os.path.join(base, name, "package")
|
||
write(d, f)
|
||
if raw:
|
||
raw(d)
|
||
with open(os.path.join(base, name, "expect.json"), "wb") as fh:
|
||
fh.write((jcs.dumps({"rule": rule, "note": note}) + "\n").encode())
|
||
|
||
case("hash-mismatch", "doc.hash.node-hash", {"nodes.jsonl": edit_nodes(lambda ns: by(ns, "@a").update(node_hash="0" * 64))},
|
||
"A node_hash that does not recompute.")
|
||
case("text-edited", "doc.hash.text-hash", {"nodes.jsonl": edit_nodes(
|
||
lambda ns: by(ns, "@a").update(content=[{"kind": "text", "text": "The value is 42.5 at 300 K."}]))},
|
||
"Text changed without its hashes.")
|
||
case("unknown-kind", "package.schema", {"nodes.jsonl": edit_nodes(lambda ns: by(ns, "@p2").update(kind="aside"))},
|
||
"Kinds outside the list are refused.")
|
||
case("heading-not-first", "doc.tree.child", {"nodes.jsonl": edit_nodes(lambda ns: _swap_first(ns, nm["@sec"]))},
|
||
"A heading must be a section's first child.")
|
||
case("cite-not-reference", "doc.run.cite", {"nodes.jsonl": edit_nodes(
|
||
lambda ns: by(ns, "@b").update(content=[r if r["kind"] != "cite" else dict(r, refs=[nm["@a"]])
|
||
for r in by(ns, "@b")["content"]]))},
|
||
"A cite run names a node that is not a reference.")
|
||
case("unlisted-file", "package.manifest.unlisted", {}, "A file the manifest does not list.",
|
||
raw=lambda d: open(os.path.join(d, "notes.txt"), "w").write("stray\n"))
|
||
case("manifest-hash", "package.manifest.hash", {}, "A file changed after the manifest was written.",
|
||
raw=lambda d: open(os.path.join(d, "provenance.json"), "a").write(" "))
|
||
case("not-canonical", "package.file.canonical", {}, "A line that is valid JSON but not canonical JSON.",
|
||
raw=lambda d: _rewrite_noncanonical(d))
|
||
case("root-mismatch", "doc.hash.root", {"document.json": _doc_edit(files, root="1" * 64)},
|
||
"document.json's root is not the root node's node_hash.")
|
||
case("receipt-root", "doc.receipt.root", {"receipts.jsonl": _receipt_edit(files, root="2" * 64)},
|
||
"A receipt whose Merkle root does not recompute.")
|
||
case("receipt-signature", "doc.receipt.signature", {"receipts.jsonl": _receipt_edit(files, at="2026-10-11T12:00:01Z")},
|
||
"A receipt changed after it was signed.")
|
||
case("checks-only-plain", "doc.policy.withheld", {"document.json": _doc_edit(files, policy="checks-only")},
|
||
"A checks-only package that still carries text and plain hashes.")
|
||
case("bad-id", "package.schema", {"nodes.jsonl": edit_nodes(lambda ns: _rename(ns, nm["@d"], "i1lo0uu"))},
|
||
"IDs use Crockford base32, which has no i, l, o or u.")
|
||
case("empty-optional-file", "package.file.empty", {"suspects.jsonl": b""}, "An optional JSONL file with no records.")
|
||
case("alias-dangling", "doc.id.successor", {"ids.jsonl": jsonl([{"id": "b-p01-text-001", "kind": "alias",
|
||
"successor": "zzzzzzz"}])},
|
||
"An alias whose node does not exist.")
|
||
case("location-unknown-page", "doc.location.page", {"locations.jsonl": jsonl([
|
||
{"node": nm["@a"], "page_fp": "3" * 64, "page": 1, "boxes": [[72, 72, 200, 84]]}])},
|
||
"A location on a page no witness has.")
|
||
case("style-sup-sub", "doc.run.style", {"nodes.jsonl": edit_nodes(lambda ns: by(ns, "@c").update(
|
||
content=[{"kind": "text", "text": "A second paragraph.", "style": ["sub", "sup"]}]))},
|
||
"A run cannot be both a subscript and a superscript.")
|
||
# Zipped packages that break spec/12 §12.2: the tiny package's files plus one bad entry.
|
||
tiny_dir = os.path.join(out, "fixtures", "packages", "tiny2026example")
|
||
entries = []
|
||
for rel in sorted(os.path.relpath(os.path.join(d, f), tiny_dir).replace(os.sep, "/")
|
||
for d, _, fs in os.walk(tiny_dir) for f in fs):
|
||
with open(os.path.join(tiny_dir, rel), "rb") as fh:
|
||
entries.append(("tiny2026example/" + rel, fh.read(), 0o644))
|
||
|
||
def zip_case(name, rule, note, extra):
|
||
d = os.path.join(base, name)
|
||
os.makedirs(d)
|
||
with zipfile.ZipFile(os.path.join(d, "package" + ZIP_EXTENSION), "w") as zf:
|
||
for ename, data, mode in entries + extra:
|
||
zi = zipfile.ZipInfo(ename, date_time=(1980, 1, 1, 0, 0, 0))
|
||
zi.compress_type = zipfile.ZIP_STORED
|
||
zi.create_system = 3
|
||
zi.external_attr = mode << 16
|
||
zf.writestr(zi, data)
|
||
with open(os.path.join(d, "expect.json"), "wb") as fh:
|
||
fh.write((jcs.dumps({"rule": rule, "note": note}) + "\n").encode())
|
||
|
||
zip_case("zip-traversal", "package.zip.entry", "An archive entry whose path leaves the package folder.",
|
||
[("tiny2026example/../escape.txt", b"outside\n", 0o644)])
|
||
zip_case("zip-symlink", "package.zip.entry", "An archive entry that is a symbolic link.",
|
||
[("tiny2026example/assets/link", b"document.json", 0o120777)])
|
||
# Rules the audit found no broken package for.
|
||
case("missing-required-file", "package.file.required", {"provenance.json": None}, "A required file is missing.")
|
||
case("counts-wrong", "doc.counts", {"document.json": _doc_edit(files, counts={"nodes": 1, "leaves": 1})},
|
||
"document.json's counts are not the number of nodes and leaves.")
|
||
case("rev-without-corrections", "doc.correction.rev", {"document.json": _doc_edit(files, rev=3)},
|
||
"document.json's rev is not the number of corrections.")
|
||
case("receipt-unsorted-nodes", "doc.receipt.nodes", {"receipts.jsonl": _receipt_edit(
|
||
files, nodes=list(reversed([json.loads(x) for x in files["receipts.jsonl"].decode().splitlines()][0]["nodes"])))},
|
||
"A receipt whose nodes are not sorted by ID.")
|
||
case("retired-live", "doc.id.retired-live", {"ids.jsonl": jsonl([
|
||
{"id": nm["@a"], "kind": "retired", "successor": None, "node_kind": "sentence", "rev": 1}])},
|
||
"An ID that is both live and retired.")
|
||
case("manifest-key", "package.manifest.key", {}, "The manifest names another key than document.json.",
|
||
raw=lambda d: _manifest_edit(d, key="other2026example"))
|
||
case("unsafe-path", "package.path.unsafe", {}, "The manifest lists a path that leaves the package.",
|
||
raw=lambda d: _manifest_edit(d, files=[{"path": "../escape.txt", "sha256": "0" * 64, "size": 1}]
|
||
+ json.load(open(os.path.join(d, "MANIFEST.json")))["files"]))
|
||
case("crlf", "package.file.canonical", {}, "A file whose lines end in CR LF, which is not the canonical form.",
|
||
raw=lambda d: _crlf(d))
|
||
signer = [json.loads(x) for x in files["receipts.jsonl"].decode().splitlines()][0]
|
||
case("signer-mismatch", "doc.receipt.signature", {"receipts.jsonl": jsonl([
|
||
RC.seal(dict(signer, by="cara"), secret("ana"), "ana")])},
|
||
"A receipt whose signature was made with another key than the signer it names.")
|
||
|
||
|
||
def _manifest_edit(d, **kw):
|
||
p = os.path.join(d, "MANIFEST.json")
|
||
m = json.load(open(p, encoding="utf-8"))
|
||
m.update(kw)
|
||
open(p, "w", encoding="utf-8").write(jcs.dumps(m) + "\n")
|
||
|
||
|
||
def _crlf(d):
|
||
p = os.path.join(d, "provenance.json")
|
||
data = open(p, "rb").read().replace(b"\n", b"\r\n")
|
||
open(p, "wb").write(data)
|
||
m = json.load(open(os.path.join(d, "MANIFEST.json"), encoding="utf-8"))
|
||
for f in m["files"]:
|
||
if f["path"] == "provenance.json":
|
||
f["sha256"], f["size"] = H(data), len(data)
|
||
open(os.path.join(d, "MANIFEST.json"), "w", encoding="utf-8").write(jcs.dumps(m) + "\n")
|
||
|
||
|
||
def _swap_first(ns, sec):
|
||
kids = sorted([n for n in ns if n["parent"] == sec], key=lambda n: n["ord"])
|
||
kids[0]["ord"], kids[1]["ord"] = kids[1]["ord"], kids[0]["ord"]
|
||
d = Doc("x", ns)
|
||
order = d.preorder()
|
||
ns.sort(key=lambda n: order.index(n["id"]))
|
||
|
||
|
||
def _rename(ns, old, new):
|
||
for n in ns:
|
||
if n["id"] == old:
|
||
n["id"] = new
|
||
|
||
|
||
def _doc_edit(files, **kw):
|
||
d = json.loads(files["document.json"])
|
||
d.update(kw)
|
||
return (jcs.dumps(d) + "\n").encode()
|
||
|
||
|
||
def _receipt_edit(files, **kw):
|
||
rs = [json.loads(x) for x in files["receipts.jsonl"].decode().splitlines()]
|
||
rs[0].update(kw)
|
||
return jsonl(rs)
|
||
|
||
|
||
def _rewrite_noncanonical(d):
|
||
p = os.path.join(d, "nodes.jsonl")
|
||
lines = open(p).read().splitlines()
|
||
lines[0] = json.dumps(json.loads(lines[0]), indent=None, separators=(", ", ": "))
|
||
data = ("\n".join(lines) + "\n").encode()
|
||
open(p, "wb").write(data)
|
||
m = json.load(open(os.path.join(d, "MANIFEST.json")))
|
||
for f in m["files"]:
|
||
if f["path"] == "nodes.jsonl":
|
||
f["sha256"], f["size"] = H(data), len(data)
|
||
open(os.path.join(d, "MANIFEST.json"), "w").write(jcs.dumps(m) + "\n")
|
||
|
||
|
||
# --- main -----------------------------------------------------------------------------
|
||
|
||
|
||
OUT = ROOT
|
||
|
||
|
||
def main(out):
|
||
global OUT
|
||
OUT = out
|
||
for sub in ("fixtures/packages", "fixtures/records", "fixtures/invalid", "fixtures/keys"):
|
||
p = os.path.join(out, sub)
|
||
if os.path.exists(p):
|
||
shutil.rmtree(p)
|
||
os.makedirs(p)
|
||
vec = os.path.join(out, "vectors")
|
||
os.makedirs(vec, exist_ok=True)
|
||
for f in os.listdir(vec):
|
||
if f.endswith(".jsonl"):
|
||
os.remove(os.path.join(vec, f))
|
||
for kid in KEYS:
|
||
with open(os.path.join(out, "fixtures", "keys", kid + ".ed25519.hex"), "w") as f:
|
||
f.write(secret(kid).hex() + "\n")
|
||
with open(os.path.join(out, "fixtures", "keys", "smithlab-team-secret.hex"), "w") as f:
|
||
f.write(TEAM_SECRET.hex() + "\n")
|
||
Names.registry.clear()
|
||
hale = build_hale(out)
|
||
build_checks_only(out, hale)
|
||
build_simple(out, "okafor2025open")
|
||
build_simple(out, "smithlab2027charges")
|
||
tiny = tiny_package(os.path.join(out, "fixtures", "packages", "tiny2026example"))
|
||
# The same package zipped as spec/12 §12.2 says a producer writes a byte-identical archive.
|
||
write_zip(os.path.join(out, "fixtures", "packages", "tiny2026example"),
|
||
os.path.join(out, "fixtures", "packages", "tiny2026example" + ZIP_EXTENSION))
|
||
records(out, hale)
|
||
vectors(out, hale, tiny)
|
||
invalid(out, tiny)
|
||
|
||
|
||
def cli():
|
||
import argparse
|
||
ap = argparse.ArgumentParser(
|
||
description="Build every fixture and test vector from fixtures/src and the oracle. The build is "
|
||
"deterministic, and tools/check.py rebuilds into a temporary folder to prove it.")
|
||
ap.add_argument("--out", metavar="DIR", default=ROOT,
|
||
help="build into DIR instead of rewriting fixtures/ and vectors/ in this repository")
|
||
out = os.path.abspath(ap.parse_args().out)
|
||
if out != ROOT:
|
||
for sub in ("registry", "fixtures/src"):
|
||
dst = os.path.join(out, sub)
|
||
if not os.path.exists(dst):
|
||
shutil.copytree(os.path.join(ROOT, sub), dst)
|
||
main(out)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
cli()
|