Features for the next six months (accessible HTML export, paperpack and
Lode on packages, the personal bundle):
- Zipped packages: the oracle writes and reads `.sct.zip` archives under
the rules of spec/12 §12.2; validate.py and load.py accept them; a
byte-identical zip of the tiny package joins the fixtures, and two
broken archives (traversal, symbolic link) join fixtures/invalid.
- The target set of targeted F3 (spec/08 §8.10) is computed by the
oracle and pinned by vectors/targets.jsonl under each digit rule; the
rule now covers cells as well as sentences and the leaves of anchored
containers, and defines a node's section.
- scienting-checklist/1 (provisional): a conformance check of an export,
covered by a receipt as ["checklist", id, hash] (spec/08 §8.11), with a
WCAG 2.1 AA fixture and its receipt.
- QA reports are signed like receipts (spec/08 §8.9).
- A `description` annotation body with role alt or long, so an
accessible export can find a confirmed figure description; the layer
fixture carries one, and the store's body-kind constraint allows it.
- scienting-spot-check/1, the seed payload, is registered with a schema.
- exercise, submission and grade are provisional until a course has
used them.
Audit fixes, each with a vector or a broken package where one was
missing:
- Folder packages: symbolic links are refused on validate and load,
manifest paths must not collide under case folding, listed files are
matched by exact name, files are hashed in chunks under a size limit.
- Files are compared byte for byte, so CR LF, a byte order mark, NaN,
a lone surrogate or invalid UTF-8 is refused instead of translated;
integers beyond 2^53 are read as doubles, as canonical JSON writes them.
- Receipts: signature.key must equal by and the key must be valid when
signed; a qa receipt's nodes must be the sample at the original's
hashes, its scope must name qa.of, its errors must be sampled nodes;
statuses are computed once, iteratively, so chains of spot-checks
hundreds deep settle; Base64 and manifest shape errors become refusals
instead of tracebacks; counts and the fidelity of roster-less packages
are recomputed.
- The gate enforces rule 11 of spec/11 §11.4: changed nodes are
validated against the node schema and the references between nodes
are checked, so a set to a bad value, an insert with unknown
properties or a delete of a referenced header cell is refused; a
relabel to null drops the label; a reclassify drops foreign
attributes; a split strips whitespace across runs; ids.jsonl order
follows spec/04 §4.4 in every state; a move after itself is refused.
- Anchors: the relabel rule requires that nothing else changed below the
node; an alias chain ending in a deletion no longer crashes resolve.
- Notation: a script after an empty group applies to the previous atom
({}^{14}C); letters are defined by Unicode block in spec/05 §5.4 and
the oracle; \left) is complex.
- Page fingerprints delete whitespace before folding, so "10 -20" and
"10-20" fold alike (spec/07 §7.1).
- Grades are summed with exact decimals; the reviewer error bound is
computed with integer arithmetic.
- receipts and revocations are keyed by (doc_key, id) in SQL, since one
revocation may be carried by several packages.
Checking:
- tools/stress.py: seeded randomized property tests over canonical JSON,
normalization, notation, random correction walks, anchor resolution,
Merkle roots, spot-check samples, error bounds, fingerprints, mutated
archives and packages, rights and spot-check chains; CI runs it.
- check.py verifies every count and version the prose states against
the repository, the zipped fixture and crafted archives, the checklist
coverage, and nine more broken packages.
- CONTRIBUTING.md gains a release checklist.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
250 lines
10 KiB
Python
250 lines
10 KiB
Python
"""Establishing a document from a nested tree, minting IDs, and structural checks.
|
||
|
||
Non-normative; see spec/03-nodes.md and spec/04-identity.md.
|
||
"""
|
||
|
||
import copy
|
||
|
||
from . import hashing
|
||
from .model import (
|
||
AT_MOST_ONCE, CHILDREN, KINDS, LABELLED, LEAVES, MATH_KINDS, NONEMPTY, RUN_KINDS, RUNS, STYLES,
|
||
VERBATIM_KINDS, Doc,
|
||
)
|
||
from .text import NODE_ID_RE, mint_node_id, norm
|
||
|
||
|
||
class Refusal(Exception):
|
||
"""A rule was broken. `rule` names it, as spec/22-conformance.md lists."""
|
||
|
||
def __init__(self, rule, message, node=None):
|
||
super().__init__("%s: %s" % (rule, message))
|
||
self.rule = rule
|
||
self.message = message
|
||
self.node = node
|
||
|
||
|
||
def canonical_runs(runs):
|
||
"""Drop empty text runs and join adjacent text runs with the same styles."""
|
||
out = []
|
||
for r in runs:
|
||
r = dict(r)
|
||
if r["kind"] == "text":
|
||
if "style" in r:
|
||
r["style"] = sorted(set(r["style"]))
|
||
if not r["style"]:
|
||
del r["style"]
|
||
if r["text"] == "":
|
||
continue
|
||
if out and out[-1]["kind"] == "text" and out[-1].get("style") == r.get("style"):
|
||
out[-1] = dict(out[-1], text=out[-1]["text"] + r["text"])
|
||
continue
|
||
out.append(r)
|
||
return out
|
||
|
||
|
||
def _flatten(spec, parent, ord_, out):
|
||
node = {k: v for k, v in spec.items() if k != "children"}
|
||
node["_parent"] = parent
|
||
node["ord"] = ord_
|
||
out.append(node)
|
||
idx = len(out) - 1
|
||
for i, c in enumerate(spec.get("children", [])):
|
||
_flatten(c, idx, i, out)
|
||
return idx
|
||
|
||
|
||
def mint_ids(doc_key, flat, used, preassigned=None):
|
||
"""Mint IDs for flat nodes in document order. `flat` nodes carry `_parent` indices."""
|
||
preassigned = preassigned or {}
|
||
tmp = []
|
||
for i, n in enumerate(flat):
|
||
m = {k: v for k, v in n.items() if not k.startswith("_")}
|
||
m["id"] = "tmp%d" % i
|
||
m["parent"] = None if n["_parent"] is None else "tmp%d" % n["_parent"]
|
||
tmp.append(m)
|
||
_resolve_child_refs(tmp)
|
||
doc = Doc(doc_key, tmp)
|
||
seen = {}
|
||
ids = {}
|
||
for i, n in enumerate(tmp):
|
||
if i in preassigned:
|
||
ids[n["id"]] = preassigned[i]
|
||
used.add(preassigned[i])
|
||
continue
|
||
th = hashing.PLAIN(norm(hashing.plain(doc, n["id"])))
|
||
k = (n["kind"], th)
|
||
occ = seen.get(k, 0)
|
||
seen[k] = occ + 1
|
||
nid = mint_node_id(doc_key, n["kind"], th, occ)
|
||
while nid in used:
|
||
occ += 1
|
||
nid = mint_node_id(doc_key, n["kind"], th, occ)
|
||
used.add(nid)
|
||
ids[n["id"]] = nid
|
||
result = []
|
||
for n in tmp:
|
||
m = dict(n)
|
||
m["id"] = ids[n["id"]]
|
||
m["parent"] = None if n["parent"] is None else ids[n["parent"]]
|
||
if m["kind"] in RUN_KINDS:
|
||
m["content"] = [
|
||
dict(r, node=ids[r["node"]]) if r["kind"] == "equation" else r for r in m["content"]
|
||
]
|
||
result.append(m)
|
||
return result
|
||
|
||
|
||
def _resolve_child_refs(tmp):
|
||
"""Equation runs in a spec name their child by position: {"kind": "equation", "child": i}."""
|
||
by_parent = {}
|
||
for n in tmp:
|
||
by_parent.setdefault(n["parent"], []).append(n)
|
||
for n in tmp:
|
||
if n["kind"] in RUN_KINDS:
|
||
kids = sorted(by_parent.get(n["id"], []), key=lambda c: c["ord"])
|
||
runs = []
|
||
for r in n["content"]:
|
||
if r["kind"] == "equation" and "child" in r:
|
||
r = {"kind": "equation", "node": kids[r["child"]]["id"]}
|
||
runs.append(r)
|
||
n["content"] = canonical_runs(runs)
|
||
|
||
|
||
def establish(doc_key, spec, used=None):
|
||
"""Turn a nested spec into flat node records with minted IDs, in document order."""
|
||
flat = []
|
||
_flatten(copy.deepcopy(spec), None, 0, flat)
|
||
return mint_ids(doc_key, flat, used if used is not None else set())
|
||
|
||
|
||
def with_values(doc_key, nodes, hasher=hashing.PLAIN):
|
||
doc = Doc(doc_key, nodes)
|
||
vals = hashing.compute(doc, hasher)
|
||
out = []
|
||
for nid in doc.preorder():
|
||
n = dict(doc.nodes[nid])
|
||
for k in ("text_hash", "match_hash", "align_key", "own_hash", "node_hash"):
|
||
n.pop(k, None)
|
||
n.update(vals[nid])
|
||
out.append(n)
|
||
return out
|
||
|
||
|
||
def validate_structure(doc):
|
||
"""Check the tree against the child rules of spec/03-nodes.md."""
|
||
if doc.root is None:
|
||
raise Refusal("doc.tree.root", "no root node")
|
||
roots = [x for x in doc.order if doc.nodes[x].get("parent") is None]
|
||
if len(roots) != 1 or doc.nodes[doc.root]["kind"] != "document":
|
||
raise Refusal("doc.tree.root", "exactly one root, of kind document, is required")
|
||
if doc.preorder() != doc.order:
|
||
raise Refusal("doc.tree.order", "nodes are not in document order")
|
||
seen = set()
|
||
for nid in doc.order:
|
||
n = doc.nodes[nid]
|
||
if nid in seen:
|
||
raise Refusal("doc.id.unique", "duplicate ID " + nid, nid)
|
||
seen.add(nid)
|
||
if not NODE_ID_RE.match(nid):
|
||
raise Refusal("doc.id.form", "ID is not seven letter-first base32 characters", nid)
|
||
k = n["kind"]
|
||
if k not in KINDS:
|
||
raise Refusal("doc.node.kind", "unknown kind " + k, nid)
|
||
kids = doc.kids(nid)
|
||
if [doc.nodes[c]["ord"] for c in kids] != list(range(len(kids))):
|
||
raise Refusal("doc.tree.ord", "child order is not 0..n-1", nid)
|
||
counts = {}
|
||
for i, c in enumerate(kids):
|
||
ck = doc.nodes[c]["kind"]
|
||
if ck not in CHILDREN[k]:
|
||
raise Refusal("doc.tree.child", "%s may not contain %s" % (k, ck), c)
|
||
counts[ck] = counts.get(ck, 0) + 1
|
||
if ck == "heading" and i != 0:
|
||
raise Refusal("doc.tree.child", "a heading must be a section's first child", c)
|
||
for ck, cnt in counts.items():
|
||
if cnt > 1 and (ck in AT_MOST_ONCE or ck == "heading"):
|
||
raise Refusal("doc.tree.child", "%s may occur once in %s" % (ck, k), nid)
|
||
if k == "table" and counts.get("table-body", 0) != 1:
|
||
raise Refusal("doc.tree.child", "a table has exactly one table-body", nid)
|
||
if k in NONEMPTY and not kids:
|
||
raise Refusal("doc.tree.empty", "%s must have children" % k, nid)
|
||
if n.get("label") is not None and k not in LABELLED:
|
||
raise Refusal("doc.node.label", "%s does not take a label" % k, nid)
|
||
_check_content(doc, nid)
|
||
|
||
|
||
def _check_content(doc, nid):
|
||
n = doc.nodes[nid]
|
||
k = n["kind"]
|
||
if n.get("withheld"):
|
||
if "content" in n:
|
||
raise Refusal("doc.policy.withheld", "a withheld node keeps no content", nid)
|
||
return
|
||
if k in RUN_KINDS:
|
||
runs = n.get("content")
|
||
if not isinstance(runs, list) or not runs:
|
||
raise Refusal("doc.node.content", "runs are required", nid)
|
||
if canonical_runs(runs) != runs:
|
||
raise Refusal("doc.run.canonical", "runs are not in canonical form", nid)
|
||
eq_refs = []
|
||
for r in runs:
|
||
if r["kind"] not in RUNS:
|
||
raise Refusal("doc.run.kind", "unknown run kind " + r["kind"], nid)
|
||
st = r.get("style", [])
|
||
if any(s not in STYLES for s in st) or ("sup" in st and "sub" in st):
|
||
raise Refusal("doc.run.style", "bad style set", nid)
|
||
if r["kind"] == "equation":
|
||
eq_refs.append(r["node"])
|
||
kids = doc.kids(nid)
|
||
if eq_refs != kids:
|
||
raise Refusal("doc.run.equation", "equation runs must name the sentence's children in order", nid)
|
||
elif k in MATH_KINDS:
|
||
c = n.get("content") or {}
|
||
if c.get("notation") not in ("tex", "typst") or not isinstance(c.get("source"), str):
|
||
raise Refusal("doc.node.content", "equations need a notation and a source", nid)
|
||
elif k in VERBATIM_KINDS:
|
||
c = n.get("content") or {}
|
||
if not isinstance(c.get("text"), str):
|
||
raise Refusal("doc.node.content", "verbatim nodes need text", nid)
|
||
elif "content" in n:
|
||
raise Refusal("doc.node.content", "%s holds no content" % k, nid)
|
||
if k == "section" and not isinstance(n.get("attrs", {}).get("level"), int):
|
||
raise Refusal("doc.node.attrs", "a section needs a level", nid)
|
||
|
||
|
||
def validate_references(doc):
|
||
"""Check the references between nodes: cite, fnmark and xref runs, and the header paths of tables and rows
|
||
(spec/03 §3.4–3.5, spec/09 §9.2). Raises Refusal."""
|
||
kinds = {nid: doc.nodes[nid]["kind"] for nid in doc.order}
|
||
for nid in doc.order:
|
||
n = doc.nodes[nid]
|
||
if n["kind"] in RUN_KINDS and not n.get("withheld"):
|
||
for r in n.get("content") or []:
|
||
if r["kind"] == "cite":
|
||
for ref in r["refs"]:
|
||
if kinds.get(ref) != "reference":
|
||
raise Refusal("doc.run.cite", "cite names %s, which is not a reference" % ref, nid)
|
||
if r["kind"] == "fnmark" and r.get("footnote") is not None and kinds.get(r["footnote"]) != "footnote":
|
||
raise Refusal("doc.run.fnmark", "fnmark names a node that is not a footnote", nid)
|
||
if r["kind"] == "xref" and r.get("target") and "#" not in r["target"] and r["target"] not in kinds:
|
||
raise Refusal("doc.run.xref", "xref names no node in this document", nid)
|
||
if n["kind"] == "table":
|
||
for col in n.get("attrs", {}).get("columns", []):
|
||
for h in col["header_path"]:
|
||
if kinds.get(h) != "cell":
|
||
raise Refusal("doc.table.header", "a header path names %s, not a cell" % h, nid)
|
||
if n["kind"] == "row":
|
||
for h in n.get("attrs", {}).get("header_path", []):
|
||
if kinds.get(h) != "cell":
|
||
raise Refusal("doc.table.header", "a row header path names %s, not a cell" % h, nid)
|
||
if n["kind"] == "cell":
|
||
col = n.get("attrs", {}).get("column")
|
||
table = next((a for a in reversed(doc.ancestors(nid)) if kinds[a] == "table"), None)
|
||
columns = {c["id"] for c in doc.nodes[table].get("attrs", {}).get("columns", [])} if table else set()
|
||
if col is not None and columns and col not in columns:
|
||
raise Refusal("doc.table.header", "a cell names a column its table does not define", nid)
|
||
|
||
|
||
def leaf_kinds():
|
||
return LEAVES
|