Initial commit
check / check (macos-latest) (push) Canceled after 0s
check / check (ubuntu-latest) (push) Canceled after 0s

This commit is contained in:
alexm committed 2026-10-11 23:45:29 -04:00
commit 62f54ed869
373 files changed
+16704

No files matched your search

View File
Whitespace-only changes.
+127
View File
@@ -0,0 +1,127 @@
"""Whole-document sentence alignment, in the manner of a patience diff.
Non-normative; see spec/05-hashes.md, "Alignment".
Each reading is a list of items: {"id", "strict", "lossy", "complex", "container", "context"}.
"""
import unicodedata
from .hashing import prose_length
MIN_PROSE = 20
def _numeric_skeleton(strict):
keep = []
i, n = 0, len(strict)
while i < n:
ch = strict[i]
if ch in "_^" and i + 1 < n and strict[i + 1] == "{":
keep.append(strict[i:i + 2])
i += 2
continue
if unicodedata.category(ch) == "Nd" or ch in "\u2212.,+}":
keep.append(ch)
i += 1
return "".join(keep)
def classify(a, b):
"""`None` when two paired sentences agree; else the suspect kind."""
if a["strict"] == b["strict"] and a["complex"] == b["complex"]:
return None
if _numeric_skeleton(a["strict"]) != _numeric_skeleton(b["strict"]):
return "number"
if a["complex"] != b["complex"]:
return "equation"
return "word"
def _lis(pairs):
"""Longest subsequence increasing in B among pairs sorted by A; lexicographically smallest A on ties."""
n = len(pairs)
if not n:
return []
best = [1] * n
nxt = [-1] * n
for i in range(n - 1, -1, -1):
for j in range(i + 1, n):
if pairs[j][1] > pairs[i][1] and best[j] + 1 > best[i]:
best[i] = best[j] + 1
nxt[i] = j
top = max(best)
i = next(k for k in range(n) if best[k] == top)
out = []
while i != -1:
out.append(pairs[i])
i = nxt[i]
return out
def _lcs(A, B, a0, a1, b0, b1):
"""LCS over align keys of A[a0:a1] and B[b0:b1], as index pairs, with the spec's tie rule."""
n, m = a1 - a0, b1 - b0
L = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n - 1, -1, -1):
for j in range(m - 1, -1, -1):
if A[a0 + i]["lossy"] == B[b0 + j]["lossy"]:
L[i][j] = L[i + 1][j + 1] + 1
else:
L[i][j] = max(L[i + 1][j], L[i][j + 1])
out = []
i = j = 0
while i < n and j < m:
if A[a0 + i]["lossy"] == B[b0 + j]["lossy"]:
out.append((a0 + i, b0 + j))
i += 1
j += 1
elif L[i + 1][j] >= L[i][j + 1]:
i += 1
else:
j += 1
return out
def align(A, B):
"""Align reading B to reading A. Returns {"pairs": [...], "suspects": [...]}."""
count_a, count_b = {}, {}
for x in A:
count_a[x["lossy"]] = count_a.get(x["lossy"], 0) + 1
for x in B:
count_b[x["lossy"]] = count_b.get(x["lossy"], 0) + 1
pos_b = {x["lossy"]: j for j, x in enumerate(B)}
cands = [
(i, pos_b[x["lossy"]]) for i, x in enumerate(A)
if count_a[x["lossy"]] == 1 and count_b.get(x["lossy"]) == 1 and prose_length(x["lossy"]) >= MIN_PROSE
]
anchors = _lis(cands)
pairs = []
prev_a, prev_b = 0, 0
for ai, bi in anchors + [(len(A), len(B))]:
pairs += _lcs(A, B, prev_a, ai, prev_b, bi)
if ai < len(A):
pairs.append((ai, bi))
prev_a, prev_b = ai + 1, bi + 1
out_pairs, suspects = [], []
for ai, bi in pairs:
kind = classify(A[ai], B[bi])
out_pairs.append({"a": A[ai]["id"], "b": B[bi]["id"], "agree": kind is None})
if kind:
suspects.append({"kind": kind, "a": A[ai]["id"], "b": B[bi]["id"]})
if A[ai]["context"] != B[bi]["context"]:
suspects.append({"kind": "structure", "a": A[ai]["id"], "b": B[bi]["id"]})
for (a1, b1), (a2, b2) in zip(pairs, pairs[1:]):
if a2 == a1 + 1 and b2 == b1 + 1:
same_a = A[a1]["container"] == A[a2]["container"]
same_b = B[b1]["container"] == B[b2]["container"]
if same_a != same_b:
suspects.append({"kind": "join", "a": A[a2]["id"], "b": B[b2]["id"]})
paired_a = {a for a, _ in pairs}
paired_b = {b for _, b in pairs}
for i, x in enumerate(A):
if i not in paired_a:
suspects.append({"kind": "dropped", "a": x["id"], "b": None})
for j, x in enumerate(B):
if j not in paired_b:
suspects.append({"kind": "added", "a": None, "b": x["id"]})
return {"pairs": out_pairs, "suspects": suspects}
+264
View File
@@ -0,0 +1,264 @@
"""Addresses, anchors and their resolution.
Non-normative; see spec/06-anchors.md.
"""
import re
from fractions import Fraction
from . import hashing, jcs
from .model import CONTAINERS_WITH_CONTENT, LEAVES, RUN_KINDS, Doc, anchor_class
from .text import norm
ADDRESS_RE = re.compile(
r"^(?:(?P<store>[a-z0-9][a-z0-9._-]*)/)?"
r"(?P<key>[a-z0-9][a-z0-9._-]*)"
r"#(?P<node>[a-z0-9][a-z0-9._-]*)"
r"(?:@(?P<pin>[0-9a-f]{6}))?"
r"(?:\[(?P<start>0|[1-9][0-9]*):(?P<end>0|[1-9][0-9]*)\])?$"
)
QUOTE_CAP = 200
CONTEXT_CAP = 32
def parse_address(s):
m = ADDRESS_RE.match(s)
if not m:
return None
d = m.groupdict()
out = {"store": d["store"], "key": d["key"], "node": d["node"], "pin": d["pin"], "span": None}
if d["start"] is not None:
a, b = int(d["start"]), int(d["end"])
if a > b:
return None
out["span"] = [a, b]
return out
def format_address(store, key, node, pin=None, span=None):
s = (store + "/" if store else "") + key + "#" + node
if pin:
s += "@" + pin[:6]
if span is not None:
s += "[%d:%d]" % tuple(span)
return s
def cell_context(doc, cell_id):
"""Table label and the text of the row and column header paths of a cell."""
cell = doc.nodes[cell_id]
row = doc.nodes[cell["parent"]]
table_id = doc.ancestors(cell_id)[-3]
table = doc.nodes[table_id]
cols = {c["id"]: c for c in table.get("attrs", {}).get("columns", [])}
col = cols.get(cell.get("attrs", {}).get("column"), {"header_path": []})
def txt(nid):
return norm(hashing.plain(doc, nid))
return {
"table_label": table.get("label"),
"row_path": [txt(x) for x in row.get("attrs", {}).get("header_path", [])],
"column_path": [txt(x) for x in col["header_path"]],
}
def make_anchor(doc, values, node, rev, span=None, store=None):
n = doc.nodes[node]
a = {"format": "scienting-anchor/1"}
if store:
a["store"] = store
a.update({"doc": doc.key, "node": node, "kind": n["kind"], "own_hash": values[node]["own_hash"],
"node_hash": values[node]["node_hash"]})
if n["kind"] in LEAVES:
text = hashing.plain(doc, node)
if span is not None:
a["span"] = list(span)
exact = text[span[0]:span[1]]
pre, suf = text[max(0, span[0] - CONTEXT_CAP):span[0]], text[span[1]:span[1] + CONTEXT_CAP]
else:
exact, pre, suf = text, "", ""
q = {}
if len(exact) <= QUOTE_CAP:
q["exact"] = exact
else:
q["exact_hash"] = hashing.PLAIN(norm(exact))
q["length"] = len(exact)
if pre:
q["prefix"] = pre
if suf:
q["suffix"] = suf
a["quote"] = q
if n["kind"] == "cell":
a["context"] = cell_context(doc, node)
elif anchor_class(n["kind"]) == "container-without-content":
a["leaves"] = [[x, values[x]["match_hash"]] for x in doc.leaves(node)]
a["rev"] = rev
return a
def _quote_hits(text, q):
"""Start positions where the quote matches `text`."""
if "exact" in q:
ex = q["exact"]
hits = [i for i in range(len(text) - len(ex) + 1) if text.startswith(ex, i)]
length = len(ex)
else:
length = q["length"]
hits = [i for i in range(len(text) - length + 1)
if hashing.PLAIN(norm(text[i:i + length])) == q["exact_hash"]]
if len(hits) > 1 and ("prefix" in q or "suffix" in q):
pre, suf = q.get("prefix", ""), q.get("suffix", "")
hits = [i for i in hits if text[:i].endswith(pre) and text[i + length:].startswith(suf)]
return hits, length
def _drift(state, doc, anchor, nid, threshold):
leaves = set(doc.leaves(nid))
touched = set()
count = 0
for c in state.corrections:
if c["index"] <= anchor["rev"]:
continue
inside = nid in (c.get("path") or []) or nid in (c.get("to_path") or [])
t = set(c.get("touched", []))
if inside and t:
count += 1
touched |= t
denom = len(leaves | touched)
share = Fraction(len(touched), denom) if denom else Fraction(0)
level = "none" if count == 0 else ("high" if share > threshold else "low")
return {"count": count, "share": _decimal(share), "level": level}
def _decimal(fr):
"""A share as a decimal string with four places, rounded half up."""
v = (fr * 10000 + Fraction(1, 2)).__floor__()
return "%d.%04d" % (v // 10000, v % 10000)
def _follow(state, nid):
"""Follow aliases and retirements. Returns (live id or None, chain, via)."""
live = {n["id"] for n in state.nodes}
table = {e["id"]: e for e in state.ids}
chain, via = [], None
seen = set()
while nid not in live:
if nid in seen or nid not in table:
return None, chain, via
seen.add(nid)
e = table[nid]
if e["kind"] == "retired" or via is None:
via = e["kind"]
chain.append(nid)
nid = e.get("successor")
if nid is None:
return None, chain, via
return nid, chain, via
def _relabel_reverted_own(state, doc, nid, anchor):
"""The own_hash the node had before the relabel corrections since the anchor, or None."""
rel = [c for c in state.corrections if c["index"] > anchor["rev"] and c.get("target") == nid]
if not rel or any(c["op"] != "relabel" for c in rel):
return None
n = dict(doc.nodes[nid], label=rel[0]["from"])
tmp = Doc(doc.key, [n if x["id"] == nid else x for x in [doc.nodes[i] for i in doc.order]])
return hashing.PLAIN(jcs.dumpb(hashing.own_content(tmp, nid, lambda h: hashing.PLAIN(
norm(hashing.plain(tmp, h))))))
def resolve(state, anchor, threshold=Fraction(1, 4)):
doc = state.doc()
values = hashing.compute(doc)
kind = anchor.get("kind")
original = anchor["node"]
nid, chain, via = _follow(state, original)
live_kind = doc.nodes[nid]["kind"] if nid else None
kind = kind or live_kind or next((e["node_kind"] for e in state.ids if e["id"] == original), None)
cls = anchor_class(kind) if kind else "leaf"
is_span = "span" in anchor
result = {"node": nid}
retired = via == "retired"
def out(res, **kw):
r = {"result": res, "node": kw.pop("node", nid)}
if r["node"]:
r["node_hash"] = values[r["node"]]["node_hash"]
if chain:
r["via"] = chain
r.update(kw)
return r
# The cell rule: a retired cell is found again by its table label and header paths.
def cell_rule():
ctx = anchor.get("context")
q = anchor.get("quote")
if kind != "cell" or not ctx or not q:
return None
found = []
for x in doc.order:
if doc.nodes[x]["kind"] != "cell":
continue
if cell_context(doc, x) != ctx:
continue
hits, ln = _quote_hits(hashing.plain(doc, x), q)
if len(hits) == 1:
found.append((x, hits[0], ln))
if len(found) == 1:
x, i, ln = found[0]
kw = {"node": x}
if is_span:
kw["span"] = [i, i + ln]
return out("reattached", **kw)
return None
if nid is None:
return cell_rule() or out("stale", node=None, reason="retired without a successor" if chain
else "unknown node")
if cls == "leaf":
text = hashing.plain(doc, nid)
if not retired and values[nid]["node_hash"] == anchor["node_hash"]:
return out("current", **({"span": anchor["span"]} if is_span else {}))
q = anchor.get("quote")
if retired:
if q:
hits, ln = _quote_hits(text, q)
if (is_span and len(hits) == 1) or (not is_span and hits):
return out("moved", **({"span": [hits[0], hits[0] + ln]} if is_span else {}))
return cell_rule() or out("stale", reason="the successor does not contain the quote")
if q:
hits, ln = _quote_hits(text, q)
if len(hits) == 1:
return out("reattached", **({"span": [hits[0], hits[0] + ln]} if is_span else {}))
own = _relabel_reverted_own(state, doc, nid, anchor)
if own is not None and own == anchor["own_hash"]:
return out("reattached", reason="relabel")
return out("stale", reason="the node changed and the quote does not occur exactly once")
if cls == "container-with-content":
if values[nid]["own_hash"] == anchor["own_hash"]:
if retired:
return out("moved")
if values[nid]["node_hash"] == anchor["node_hash"]:
return out("current")
return out("changed-below", drift=_drift(state, doc, anchor, nid, threshold))
if not retired:
own = _relabel_reverted_own(state, doc, nid, anchor)
if own is not None and own == anchor["own_hash"]:
return out("reattached", reason="relabel")
return out("stale", reason="the node's own content changed")
# Container without content.
if retired:
orig = anchor.get("leaves", [])
now = set(doc.leaves(nid))
now_match = {values[x]["match_hash"] for x in now}
held = sum(1 for i, m in orig if i in now or m in now_match)
if orig and 2 * held >= len(orig):
return out("moved")
return out("stale", reason="the successor holds fewer than half of the original leaves")
if values[nid]["node_hash"] == anchor["node_hash"]:
return out("current")
return out("changed-below", drift=_drift(state, doc, anchor, nid, threshold))
+387
View File
@@ -0,0 +1,387 @@
"""Corrections on an established document.
Non-normative; see spec/11-corrections.md.
"""
import copy
from . import hashing
from .model import LABELLED, LEAVES, MATH_KINDS, RUN_KINDS, VERBATIM_KINDS, Doc, CHILDREN
from .tree import Refusal, canonical_runs, mint_ids, validate_structure, _flatten
from . import jcs
from .text import digits, record_id, sha256_hex
MERGEABLE = RUN_KINDS | {"paragraph", "list", "item", "quote", "caption", "figure-note", "table-note"}
SETTABLE = {
"section": {"level"},
"table": {"columns"},
"cell": {"column", "rowspan", "colspan", "role"},
"row": {"header_path"},
"list": {"ordered"},
"item": {"marker"},
"theorem": {"variant"},
}
EDITABLE_RUN_FIELD = {"text": "text", "link": "text", "cite": "text", "xref": "text", "fnmark": "text", "math": "source"}
class State:
"""The current state of a document: nodes, the ID table and the corrections applied."""
def __init__(self, key, nodes, ids=None, corrections=None):
self.key = key
self.nodes = [dict(n) for n in nodes]
self.ids = [dict(e) for e in (ids or [])]
self.corrections = [dict(c) for c in (corrections or [])]
@property
def rev(self):
return len(self.corrections)
def doc(self):
return Doc(self.key, self.nodes)
def used_ids(self):
return {n["id"] for n in self.nodes} | {e["id"] for e in self.ids}
def _strings(node):
"""Editable strings of a leaf: (path, value) pairs."""
k = node["kind"]
if k in RUN_KINDS:
out = []
for i, r in enumerate(node["content"]):
f = EDITABLE_RUN_FIELD.get(r["kind"])
if f:
out.append(((i, f), r[f]))
return out
if k in MATH_KINDS:
return [(("source",), node["content"]["source"])]
if k in VERBATIM_KINDS:
return [(("text",), node["content"]["text"])]
return []
def _occurrences(s, find):
return [i for i in range(len(s) - len(find) + 1) if s.startswith(find, i)]
def _set_string(node, path, value):
if len(path) == 2:
i, f = path
node["content"][i] = dict(node["content"][i], **{f: value})
else:
node["content"] = dict(node["content"], **{path[0]: value})
def _has_crop(c):
return any(e.get("kind") == "crop" for e in c.get("evidence", []))
def _rebuild(nodes):
"""Recompute document order and `ord` from parent links, keeping sibling order as listed."""
by_id = {n["id"]: n for n in nodes}
kids = {}
root = None
for n in nodes:
if n.get("parent") is None:
root = n["id"]
else:
kids.setdefault(n["parent"], []).append(n["id"])
for p in kids:
kids[p].sort(key=lambda c: by_id[c]["ord"])
out = []
stack = [root]
while stack:
x = stack.pop()
out.append(by_id[x])
for i, c in enumerate(kids.get(x, [])):
by_id[c]["ord"] = i
stack.extend(reversed(kids.get(x, [])))
return out
def _subtree(doc, nid):
return doc.preorder(nid)
def _place(nodes, nid, parent, after):
"""Give `nid` a fractional position under `parent` after sibling `after` (None: first)."""
by_id = {n["id"]: n for n in nodes}
sibs = sorted([n for n in nodes if n.get("parent") == parent and n["id"] != nid], key=lambda n: n["ord"])
for i, s in enumerate(sibs):
s["ord"] = 2 * i + 2
if after is None:
pos = 0
else:
if after not in by_id or by_id[after].get("parent") != parent:
raise Refusal("correction.target", "`place_after` is not a child of the parent")
pos = by_id[after]["ord"] + 1
by_id[nid]["parent"] = parent
by_id[nid]["ord"] = pos
def apply(state, c, hasher=hashing.PLAIN):
"""Apply correction `c` to `state` and return the recorded correction. Raises Refusal."""
c = copy.deepcopy(c)
doc = state.doc()
before_vals = hashing.compute(doc, hasher)
nodes = [copy.deepcopy(n) for n in state.nodes]
by_id = {n["id"]: n for n in nodes}
op = c["op"]
target = c.get("target")
if op != "insert" or "target" in c:
if target not in by_id:
raise Refusal("correction.target", "no live node %s" % target)
if target == doc.root and op in ("delete", "move", "merge", "reclassify"):
raise Refusal("correction.target", "the root cannot be " + op + "d")
record = {"format": "scienting-correction/1", "index": state.rev + 1}
retired, created = [], []
path = doc.ancestors(target) if target in by_id else None
touched = []
if op == "replace":
node = by_id[target]
find, repl = c.get("find", ""), c["replace"]
if not find:
raise Refusal("correction.find", "find must not be empty")
hits = []
for pth, s in _strings(node):
if "run" in c and (len(pth) != 2 or pth[0] != c["run"]):
continue
hits += [(pth, s, i) for i in _occurrences(s, find)]
if len(hits) != 1:
raise Refusal("correction.find", "find occurs %d times, not exactly once" % len(hits), target)
pth, s, i = hits[0]
if isinstance(repl, str):
_set_string(node, pth, s[:i] + repl + s[i + len(find):])
else:
if len(pth) != 2 or node["content"][pth[0]]["kind"] != "text":
raise Refusal("correction.replace", "runs may replace text only inside a text run", target)
if any(r["kind"] == "equation" for r in repl):
raise Refusal("correction.replace", "equation runs are added by insert", target)
run = node["content"][pth[0]]
style = {"style": run["style"]} if "style" in run else {}
mid = [dict({"kind": "text", "text": s[:i]}, **style)] + list(repl) + [
dict({"kind": "text", "text": s[i + len(find):]}, **style)
]
node["content"] = node["content"][: pth[0]] + mid + node["content"][pth[0] + 1:]
if node["kind"] in RUN_KINDS:
node["content"] = canonical_runs(node["content"])
touched = [target]
elif op == "insert" and "node" in c:
if not _has_crop(c):
raise Refusal("correction.evidence", "an insert needs crop evidence")
parent = c["parent"]
if parent not in by_id:
raise Refusal("correction.target", "no live parent %s" % parent)
flat = []
_flatten(copy.deepcopy(c["node"]), None, 0, flat)
new = mint_ids(state.key, flat, state.used_ids())
new[0]["parent"] = parent
nodes.extend(new)
_place(nodes, new[0]["id"], parent, c.get("place_after"))
nodes = _rebuild(nodes)
created = [n["id"] for n in new]
tmp = Doc(state.key, nodes)
touched = [x for x in tmp.leaves(new[0]["id"])]
path = tmp.ancestors(new[0]["id"])
target = new[0]["id"]
elif op == "insert":
if not _has_crop(c):
raise Refusal("correction.evidence", "an insert needs crop evidence")
node = by_id[target]
if node["kind"] not in RUN_KINDS:
raise Refusal("correction.insert", "text is inserted into runs", target)
r = node["content"][c["run"]]
f = EDITABLE_RUN_FIELD.get(r["kind"])
if not f or not (0 <= c["offset"] <= len(r[f])):
raise Refusal("correction.insert", "bad run or offset", target)
node["content"][c["run"]] = dict(r, **{f: r[f][: c["offset"]] + c["text"] + r[f][c["offset"]:]})
node["content"] = canonical_runs(node["content"])
touched = [target]
elif op == "delete":
gone = _subtree(doc, target)
touched = [x for x in gone if doc.nodes[x]["kind"] in LEAVES]
nodes = _rebuild([n for n in nodes if n["id"] not in set(gone)])
retired = [(x, None) for x in gone]
elif op == "split":
node = by_id[target]
if node["kind"] != "sentence":
raise Refusal("correction.split", "only sentences are split", target)
k = c["offset"]
first, second, pos = [], [], 0
total = len(hashing.plain_runs(node["content"], doc))
if not 0 < k < total:
raise Refusal("correction.split", "offset must fall inside the sentence", target)
for r in node["content"]:
ln = len(hashing.plain_runs([r], doc))
if pos + ln <= k:
first.append(r)
elif pos >= k:
second.append(r)
elif r["kind"] == "text":
cut = k - pos
first.append(dict(r, text=r["text"][:cut]))
second.append(dict(r, text=r["text"][cut:]))
else:
raise Refusal("correction.split", "the offset falls inside a %s run" % r["kind"], target)
pos += ln
_strip_edge(first, end=True)
_strip_edge(second, end=False)
first, second = canonical_runs(first), canonical_runs(second)
if not first or not second:
raise Refusal("correction.split", "both parts must keep text", target)
new_spec = {"kind": "sentence", "content": second}
eq_second = [r["node"] for r in second if r["kind"] == "equation"]
flat = [dict(new_spec, _parent=None, ord=0)]
tmp_nodes = mint_ids(state.key, [dict(flat[0], content=[
{"kind": "text", "text": hashing.plain_runs(second, doc)}])], state.used_ids())
new_id = tmp_nodes[0]["id"]
node["content"] = first
new_node = {"id": new_id, "kind": "sentence", "parent": node["parent"], "ord": 0, "content": second}
if "fidelity" in node:
new_node["fidelity"] = node["fidelity"]
nodes.append(new_node)
_place(nodes, new_id, node["parent"], target)
for i, e in enumerate(eq_second):
by_id[e]["parent"] = new_id
by_id[e]["ord"] = i
nodes = _rebuild(nodes)
created = [new_id]
touched = [target, new_id]
elif op == "merge":
other = c["with"]
node = by_id[target]
if other not in by_id:
raise Refusal("correction.target", "no live node %s" % other)
o = by_id[other]
if node["kind"] != o["kind"] or node["kind"] not in MERGEABLE:
raise Refusal("correction.merge", "merge joins two nodes of one mergeable kind", target)
if o.get("parent") != node.get("parent") or o["ord"] != node["ord"] + 1:
raise Refusal("correction.merge", "merge joins a node with its next sibling", target)
if node["kind"] in RUN_KINDS:
joiner = c.get("joiner", " ")
mid = [{"kind": "text", "text": joiner}] if joiner else []
node["content"] = canonical_runs(node["content"] + mid + o["content"])
touched = [target, other]
else:
touched = [x for x in doc.leaves(other) if x != other]
base = len(doc.kids(target))
for i, kid in enumerate(doc.kids(other)):
by_id[kid]["parent"] = target
by_id[kid]["ord"] = base + i
nodes = _rebuild([n for n in nodes if n["id"] != other])
retired = [(other, target)]
elif op == "move":
parent = c["parent"]
if parent not in by_id or parent in set(_subtree(doc, target)):
raise Refusal("correction.move", "bad destination", target)
_place(nodes, target, parent, c.get("place_after"))
nodes = _rebuild(nodes)
touched = [x for x in doc.leaves(target)]
record["to_path"] = Doc(state.key, nodes).ancestors(target)
elif op == "reclassify":
node = by_id[target]
new_kind = c["kind"]
old = node["kind"]
same_shape = (old in RUN_KINDS) == (new_kind in RUN_KINDS) and (old in MATH_KINDS) == (
new_kind in MATH_KINDS) and (old in VERBATIM_KINDS) == (new_kind in VERBATIM_KINDS)
if new_kind not in CHILDREN or not same_shape:
raise Refusal("correction.reclassify", "%s cannot become %s" % (old, new_kind), target)
node["kind"] = new_kind
if new_kind not in LABELLED:
node.pop("label", None)
record["from"] = old
touched = [x for x in doc.leaves(target)]
elif op == "relabel":
node = by_id[target]
if node["kind"] not in LABELLED:
raise Refusal("correction.relabel", "%s takes no label" % node["kind"], target)
record["from"] = node.get("label")
node["label"] = c["label"]
touched = [target] if node["kind"] in LEAVES else []
elif op == "set":
node = by_id[target]
attr = c["attr"]
if attr not in SETTABLE.get(node["kind"], ()):
raise Refusal("correction.set", "%s cannot be set on %s" % (attr, node["kind"]), target)
attrs = dict(node.get("attrs", {}))
record["from"] = attrs.get(attr)
attrs[attr] = c["value"]
node["attrs"] = attrs
touched = [target] if node["kind"] in LEAVES else []
else:
raise Refusal("correction.op", "unknown operation " + op)
after_doc = Doc(state.key, nodes)
validate_structure(after_doc)
after_vals = hashing.compute(after_doc, hasher)
# Digits: a change to a digit needs crop evidence, and no leaf outside `touched` may change its digits.
needs_crop = False
if op == "replace":
rep = c["replace"] if isinstance(c["replace"], str) else hashing.plain_runs(c["replace"], doc)
needs_crop = digits(c["find"]) != digits(rep)
elif op == "delete":
needs_crop = any(digits(hashing.plain(doc, x)) for x in touched)
elif op == "relabel":
needs_crop = digits(record.get("from") or "") != digits(c.get("label") or "")
if needs_crop and not _has_crop(c):
raise Refusal("correction.digits", "a digit changed without crop evidence", target)
for nid in doc.leaves(doc.root):
if nid in after_doc.nodes and nid not in touched:
if digits(hashing.plain(doc, nid)) != digits(hashing.plain(after_doc, nid)):
raise Refusal("correction.digits", "an undeclared digit change", nid)
for k in ("op", "target", "find", "replace", "run", "offset", "text", "parent", "place_after", "node", "with",
"joiner", "kind", "label", "attr", "value", "evidence", "author", "reason", "at"):
if k in c:
record[k] = c[k]
if op == "insert" and "node" in c:
record["target"] = target
record["before"] = before_vals[target]["node_hash"] if target in before_vals else None
record["after"] = after_vals[target]["node_hash"] if target in after_vals else None
record["path"] = path
record["touched"] = sorted(set(touched))
if created:
record["created"] = created
if retired:
record["retired"] = [r[0] for r in retired]
record["id"] = record_id(sha256_hex(jcs.dumpb({k: v for k, v in record.items() if k != "id"})))
old_kinds = {n["id"]: n["kind"] for n in state.nodes}
new_ids = list(state.ids)
for rid, succ in retired:
new_ids.append({"id": rid, "kind": "retired", "successor": succ, "node_kind": old_kinds[rid],
"rev": record["index"]})
new_state = State(state.key, [after_doc.nodes[x] for x in after_doc.preorder()], new_ids,
state.corrections + [record])
return new_state, record
def _strip_edge(runs, end):
from .text import is_ws
if not runs:
return
i = len(runs) - 1 if end else 0
r = runs[i]
if r["kind"] != "text":
return
t = r["text"]
if end:
while t and is_ws(t[-1]):
t = t[:-1]
else:
while t and is_ws(t[0]):
t = t[1:]
runs[i] = dict(r, text=t)
+194
View File
@@ -0,0 +1,194 @@
"""Keyed hashing, key derivation, Merkle roots and Ed25519.
Non-normative; see spec/05-hashes.md and spec/08-fidelity.md.
Ed25519 follows the reference code of RFC 8032, section 6; it is slow and is meant only for test vectors.
"""
import hashlib
import hmac
from . import jcs
def hkdf(ikm, salt, info, length=32):
"""HKDF-SHA-256, RFC 5869."""
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
out, t, i = b"", b"", 1
while len(out) < length:
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
out += t
i += 1
return out[:length]
def team_key(secret):
return hkdf(secret, b"scienting-team-key/1", b"scienting-hash/1")
def key_id(key):
return hashlib.sha256(b"scienting-key-id/1" + key).hexdigest()[:16]
def page_hash(page_fp_hex):
return hashlib.sha256(b"scienting-page-hash/1" + bytes.fromhex(page_fp_hex)).hexdigest()
def page_key(page_fps_hex, context):
ikm = b"".join(bytes.fromhex(h) for h in page_fps_hex)
return hkdf(ikm, b"scienting-page-key/1", context.encode("utf-8"))
# --- Merkle trees (RFC 6962 hashing) ------------------------------------------
def _leaf(data):
return hashlib.sha256(b"\x00" + data).digest()
def _node(left, right):
return hashlib.sha256(b"\x01" + left + right).digest()
def _mth(leaves):
n = len(leaves)
if n == 1:
return leaves[0]
k = 1
while k * 2 < n:
k *= 2
return _node(_mth(leaves[:k]), _mth(leaves[k:]))
def merkle_root(items):
"""Root over receipt items: each item is a list such as ["node", id, hash]."""
if not items:
raise ValueError("a Merkle root needs at least one item")
return _mth([_leaf(jcs.dumpb(list(it))) for it in items]).hex()
def receipt_items(receipt):
items = [["node", nid, h] for nid, h in receipt.get("nodes", [])]
items += [list(r) for r in receipt.get("records", [])]
return items
# --- Ed25519 (RFC 8032) -------------------------------------------------------
_p = 2**255 - 19
_d = -121665 * pow(121666, _p - 2, _p) % _p
_q = 2**252 + 27742317777372353535851937790883648493
def _sha512(b):
return hashlib.sha512(b).digest()
def _inv(x):
return pow(x, _p - 2, _p)
def _add(P, Q):
A = (P[1] - P[0]) * (Q[1] - Q[0]) % _p
B = (P[1] + P[0]) * (Q[1] + Q[0]) % _p
C = 2 * P[3] * Q[3] * _d % _p
D = 2 * P[2] * Q[2] % _p
E, F, G, H = B - A, D - C, D + C, B + A
return (E * F % _p, G * H % _p, F * G % _p, E * H % _p)
def _mul(s, P):
Q = (0, 1, 1, 0)
while s > 0:
if s & 1:
Q = _add(Q, P)
P = _add(P, P)
s >>= 1
return Q
def _equal(P, Q):
if (P[0] * Q[2] - Q[0] * P[2]) % _p != 0:
return False
return (P[1] * Q[2] - Q[1] * P[2]) % _p == 0
_modp_sqrt_m1 = pow(2, (_p - 1) // 4, _p)
def _recover_x(y, sign):
if y >= _p:
return None
x2 = (y * y - 1) * _inv(_d * y * y + 1)
if x2 == 0:
return None if sign else 0
x = pow(x2, (_p + 3) // 8, _p)
if (x * x - x2) % _p != 0:
x = x * _modp_sqrt_m1 % _p
if (x * x - x2) % _p != 0:
return None
if (x & 1) != sign:
x = _p - x
return x
_gy = 4 * _inv(5) % _p
_gx = _recover_x(_gy, 0)
_G = (_gx, _gy, 1, _gx * _gy % _p)
def _compress(P):
zinv = _inv(P[2])
x = P[0] * zinv % _p
y = P[1] * zinv % _p
return int.to_bytes(y | ((x & 1) << 255), 32, "little")
def _decompress(s):
if len(s) != 32:
return None
y = int.from_bytes(s, "little")
sign = y >> 255
y &= (1 << 255) - 1
x = _recover_x(y, sign)
if x is None:
return None
return (x, y, 1, x * y % _p)
def _secret_expand(secret):
h = _sha512(secret)
a = int.from_bytes(h[:32], "little")
a &= (1 << 254) - 8
a |= 1 << 254
return a, h[32:]
def ed25519_public(secret):
a, _ = _secret_expand(secret)
return _compress(_mul(a, _G))
def ed25519_sign(secret, msg):
a, prefix = _secret_expand(secret)
A = _compress(_mul(a, _G))
r = int.from_bytes(_sha512(prefix + msg), "little") % _q
R = _compress(_mul(r, _G))
h = int.from_bytes(_sha512(R + A + msg), "little") % _q
s = (r + h * a) % _q
return R + int.to_bytes(s, 32, "little")
def ed25519_verify(public, msg, signature):
if len(public) != 32 or len(signature) != 64:
return False
A = _decompress(public)
if not A:
return False
R = _decompress(signature[:32])
if not R:
return False
s = int.from_bytes(signature[32:], "little")
if s >= _q:
return False
h = int.from_bytes(_sha512(signature[:32] + public + msg), "little") % _q
return _equal(_mul(s, _G), _add(R, _mul(h, A)))
+248
View File
@@ -0,0 +1,248 @@
"""The database form: loading a package into tables and unloading it again.
Non-normative; see spec/13-database.md.
Loading is a pure function of the package, and unloading gives back the same bytes.
"""
import json
import os
import shutil
from . import jcs
from .package import read_json, read_jsonl, write
from .text import sha256_hex
STRUCTURED = {"MANIFEST.json", "document.json", "witnesses.json", "provenance.json", "roster.json", "nodes.jsonl",
"pages.jsonl", "locations.jsonl", "references.jsonl", "corrections.jsonl", "receipts.jsonl",
"ids.jsonl", "suspects.jsonl"}
def J(v):
return None if v is None else jcs.dumps(v)
def P(s):
return None if s is None else json.loads(s)
def B(v):
return None if v is None else (1 if v else 0)
def _opt(d, k, v):
if v is not None:
d[k] = v
def ins(conn, into, values):
conn.execute("INSERT %s VALUES (%s)" % (into, ",".join("?" * len(values))), values)
def create(conn, sql_dir):
for name in ("doc.sql", "store.sql"):
with open(os.path.join(sql_dir, name), encoding="utf-8") as f:
conn.executescript(f.read())
def load(conn, root, asset_store):
"""Load the package at `root`. Files without tables go to `asset_store`, by SHA-256."""
manifest = read_json(os.path.join(root, "MANIFEST.json"))
listed = {f["path"]: f for f in manifest["files"]}
def jl(name):
return read_jsonl(os.path.join(root, name)) if name in listed else []
d = read_json(os.path.join(root, "document.json"))
key = d["key"]
with open(os.path.join(root, "MANIFEST.json"), "rb") as f:
package_hash = sha256_hex(f.read())
prov = read_json(os.path.join(root, "provenance.json"))
roster = read_json(os.path.join(root, "roster.json")) if "roster.json" in listed else None
conn.execute("INSERT OR IGNORE INTO works VALUES (?, ?)", (d["work"]["key"], J(d["work"]["same_as"])))
ins(conn, "INTO documents", (key, d.get("store"), J(d.get("aliases")), d["kind"], J(d["work"]), J(d["version"]), J(d["relations"]),
J(d["meta"]), d["language"], J(d.get("license")), d["policy"], J(d["hashing"]), d["root"], d["meta_hash"],
d["rev"], J(d["counts"]), J(d["fidelity"]), J(prov), J(roster), package_hash))
for i, w in enumerate(read_json(os.path.join(root, "witnesses.json"))["witnesses"]):
ins(conn, "INTO witnesses", (key, w["id"], i, w["sha256"], w["size"], w["kind"], w["media_type"], w.get("pages"),
B(w.get("text_layer")), w.get("version_label"), J(w["origin"]), J(w["rights"])))
for seq, n in enumerate(jl("nodes.jsonl")):
ins(conn, "INTO nodes", (key, n["id"], seq, n["kind"], n["parent"], n["ord"], n.get("label"), J(n.get("attrs")),
J(n.get("content")), B(n.get("withheld")), n.get("fidelity"), J(n.get("prov")),
n["text_hash"], n["match_hash"], n["align_key"], n["own_hash"], n["node_hash"]))
if isinstance(n.get("content"), list):
ords = 0
for r in n["content"]:
if r["kind"] == "cite":
for ref in r["refs"]:
ins(conn, "OR IGNORE INTO citations", (key, n["id"], ref, ords))
ords += 1
for p in jl("pages.jsonl"):
ins(conn, "INTO pages", (key, p["witness"], p["page"], p["page_fp"], J(p["size"]), p.get("page_label"),
B(p["text_layer"]), J(p.get("stamps_removed"))))
for l in jl("locations.jsonl"):
ins(conn, "INTO locations", (key, l["node"], l["page_fp"], l["page"], l.get("page_label"), J(l["boxes"]), B(l.get("approx"))))
for r in jl("references.jsonl"):
ins(conn, "INTO refs", (key, r["node"], r.get("label"), J(r.get("csl")), J(r["resolution"])))
main = ("format", "index", "id", "op", "target", "evidence", "author", "reason", "at", "before", "after", "path",
"touched")
for c in jl("corrections.jsonl"):
details = {k: v for k, v in c.items() if k not in main}
ins(conn, "INTO corrections", (key, c["index"], c["id"], c["op"], c.get("target"), J(c["evidence"]), J(c["author"]),
c["reason"], c["at"], c["before"], c["after"], J(c["path"]), J(c["touched"]), J(details)))
for e in jl("ids.jsonl"):
ins(conn, "INTO node_ids", (key, e["id"], e["kind"], e["successor"], e.get("node_kind"), e.get("rev")))
for r in jl("receipts.jsonl"):
if r["format"] == "scienting-receipt/1":
ins(conn, "INTO receipts", (key, r["id"], r["level"], r["scope"], r["rev"], r["root"], J(r["nodes"]),
J(r.get("records")), J(r["evidence"]), r["by"], r["at"], J(r.get("qa")),
r.get("export_of"), J(r.get("keyed")), J(r["signature"])))
for nid, h in r["nodes"]:
ins(conn, "INTO receipt_nodes", (r["id"], key, nid, h))
else:
per = r.get("period") or {}
ins(conn, "INTO revocations", (key, r["id"], r.get("receipt"), r.get("signer"), per.get("from"), per.get("to"),
r["reason"], r["by"], r["at"], J(r["signature"])))
for s in jl("suspects.jsonl"):
ins(conn, "INTO suspects", (key, s["id"], s["kind"], s.get("node"), J(s)))
os.makedirs(asset_store, exist_ok=True)
for path, f in listed.items():
if path in STRUCTURED:
continue
present = f.get("present", True)
ins(conn, "INTO package_files", (key, path, f["sha256"], f["size"], 1 if present else 0))
if present:
shutil.copyfile(os.path.join(root, path), os.path.join(asset_store, f["sha256"]))
return key
def _q(conn, sql, args):
cur = conn.execute(sql, args)
cols = [c[0] for c in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def unload(conn, key, out, asset_store):
"""Write the package for `key` from the tables into `out`. Returns the package hash."""
files = {}
d = _q(conn, "SELECT * FROM documents WHERE doc_key = ?", (key,))[0]
doc = {"format": "scienting-doc/1", "key": key}
_opt(doc, "store", d["store"])
_opt(doc, "aliases", P(d["aliases"]))
doc.update({"kind": d["kind"], "work": P(d["work"]), "version": P(d["version"]), "relations": P(d["relations"]),
"meta": P(d["meta"]), "language": d["language"]})
_opt(doc, "license", P(d["license"]))
doc.update({"policy": d["policy"], "hashing": P(d["hashing"]), "root": d["root_hash"],
"meta_hash": d["meta_hash"], "rev": d["rev"], "counts": P(d["counts"]), "fidelity": P(d["fidelity"])})
def put(name, rec):
files[name] = (jcs.dumps(rec) + "\n").encode("utf-8")
def putl(name, recs):
if recs:
files[name] = "".join(jcs.dumps(r) + "\n" for r in recs).encode("utf-8")
put("document.json", doc)
put("provenance.json", P(d["provenance"]))
if d["roster"] is not None:
put("roster.json", P(d["roster"]))
ws = []
for w in _q(conn, "SELECT * FROM witnesses WHERE doc_key = ? ORDER BY ord", (key,)):
rec = {"id": w["id"], "sha256": w["sha256"], "size": w["size"], "kind": w["kind"], "media_type": w["media_type"]}
_opt(rec, "pages", w["pages"])
_opt(rec, "text_layer", None if w["text_layer"] is None else bool(w["text_layer"]))
_opt(rec, "version_label", w["version_label"])
rec.update({"origin": P(w["origin"]), "rights": P(w["rights"])})
ws.append(rec)
put("witnesses.json", {"witnesses": ws})
nodes = []
for n in _q(conn, "SELECT * FROM nodes WHERE doc_key = ? ORDER BY seq", (key,)):
rec = {"id": n["id"], "kind": n["kind"], "parent": n["parent"], "ord": n["ord"]}
_opt(rec, "label", n["label"])
_opt(rec, "attrs", P(n["attrs"]))
_opt(rec, "content", P(n["content"]))
if n["withheld"]:
rec["withheld"] = True
_opt(rec, "fidelity", n["fidelity"])
_opt(rec, "prov", P(n["prov"]))
for h in ("text_hash", "match_hash", "align_key", "own_hash", "node_hash"):
rec[h] = n[h]
nodes.append(rec)
putl("nodes.jsonl", nodes)
seq = {n["id"]: i for i, n in enumerate(nodes)}
pages = []
for p in _q(conn, "SELECT * FROM pages WHERE doc_key = ? ORDER BY witness, page", (key,)):
rec = {"witness": p["witness"], "page": p["page"], "page_fp": p["page_fp"], "size": P(p["size"])}
_opt(rec, "page_label", p["page_label"])
rec["text_layer"] = bool(p["text_layer"])
_opt(rec, "stamps_removed", P(p["stamps_removed"]))
pages.append(rec)
putl("pages.jsonl", pages)
locs = []
for l in _q(conn, "SELECT * FROM locations WHERE doc_key = ?", (key,)):
rec = {"node": l["node"], "page_fp": l["page_fp"], "page": l["page"]}
_opt(rec, "page_label", l["page_label"])
rec["boxes"] = P(l["boxes"])
if l["approx"]:
rec["approx"] = True
locs.append(rec)
locs.sort(key=lambda r: (seq[r["node"]], r["page"], r["page_fp"]))
putl("locations.jsonl", locs)
refs = []
for r in _q(conn, "SELECT * FROM refs WHERE doc_key = ?", (key,)):
rec = {"node": r["node"]}
_opt(rec, "label", r["label"])
_opt(rec, "csl", P(r["csl"]))
rec["resolution"] = P(r["resolution"])
refs.append(rec)
refs.sort(key=lambda r: seq[r["node"]])
putl("references.jsonl", refs)
cors = []
for c in _q(conn, "SELECT * FROM corrections WHERE doc_key = ? ORDER BY idx", (key,)):
rec = {"format": "scienting-correction/1", "index": c["idx"], "id": c["id"], "op": c["op"]}
_opt(rec, "target", c["target"])
rec.update({"evidence": P(c["evidence"]), "author": P(c["author"]), "reason": c["reason"], "at": c["made_at"],
"before": c["before_hash"], "after": c["after_hash"], "path": P(c["path"]),
"touched": P(c["touched"])})
rec.update(P(c["details"]))
cors.append(rec)
putl("corrections.jsonl", cors)
ids = []
for e in _q(conn, "SELECT * FROM node_ids WHERE doc_key = ?", (key,)):
rec = {"id": e["id"], "kind": e["kind"], "successor": e["successor"]}
_opt(rec, "node_kind", e["node_kind"])
_opt(rec, "rev", e["rev"])
ids.append(rec)
ids.sort(key=lambda e: (0 if e["kind"] == "alias" else 1, e.get("rev", 0), e["id"]))
putl("ids.jsonl", ids)
recs = []
for r in _q(conn, "SELECT * FROM receipts WHERE doc_key = ?", (key,)):
rec = {"format": "scienting-receipt/1", "id": r["id"], "doc": key, "rev": r["rev"], "level": r["level"],
"scope": r["scope"], "nodes": P(r["nodes"])}
_opt(rec, "records", P(r["records"]))
rec.update({"root": r["root"], "evidence": P(r["evidence"]), "by": r["signer"], "at": r["made_at"]})
_opt(rec, "qa", P(r["qa"]))
_opt(rec, "export_of", r["export_of"])
_opt(rec, "keyed", P(r["keyed"]))
rec["signature"] = P(r["signature"])
recs.append(rec)
for r in _q(conn, "SELECT * FROM revocations WHERE doc_key = ?", (key,)):
rec = {"format": "scienting-revocation/1", "id": r["id"]}
_opt(rec, "receipt", r["receipt"])
_opt(rec, "signer", r["revoked_signer"])
if r["period_from"] is not None:
rec["period"] = {"from": r["period_from"], "to": r["period_to"]}
rec.update({"reason": r["reason"], "by": r["signer"], "at": r["made_at"], "signature": P(r["signature"])})
recs.append(rec)
recs.sort(key=lambda r: (r["at"], r["id"]))
putl("receipts.jsonl", recs)
sus = [P(s["record"]) for s in _q(conn, "SELECT * FROM suspects WHERE doc_key = ?", (key,))]
sus.sort(key=lambda s: s["id"])
putl("suspects.jsonl", sus)
absent = []
for f in _q(conn, "SELECT * FROM package_files WHERE doc_key = ?", (key,)):
if f["present"]:
with open(os.path.join(asset_store, f["sha256"]), "rb") as fh:
files[f["path"]] = fh.read()
else:
absent.append({"path": f["path"], "sha256": f["sha256"], "size": f["size"], "present": False})
return write(out, files, absent)
+61
View File
@@ -0,0 +1,61 @@
"""Page fingerprints, scienting-page/1, and download-stamp rules, scienting-stamps/1.
Non-normative; see spec/07-geometry.md.
"""
import math
import re
import unicodedata
from . import jcs
from .notation import fold_plain_text
from .text import is_ws, norm, sha256_hex
def round_point(x):
"""Round half up to a whole point."""
return int(math.floor(x + 0.5))
def _in_margin(box, size, margin):
x0, y0, x1, y1 = box
w, h = size
return x1 <= margin or x0 >= w - margin or y1 <= margin or y0 >= h - margin
def is_stamp(line, size, rules):
text = norm(line["text"])
for r in rules:
if r.get("where", "any") == "margin":
if "box" not in line or not _in_margin(line["box"], size, r.get("margin", 36)):
continue
if re.search(r["pattern"], text, re.IGNORECASE | re.ASCII):
return r["id"]
return None
def fp_text(text):
s = unicodedata.normalize("NFKC", fold_plain_text(text))
return "".join(c for c in s if not is_ws(c) and unicodedata.category(c) not in ("Cc", "Cf"))
def fingerprint(page, rules):
"""`page`: {"size": [w, h], "lines": [{"text", "box"?}], "images": [sha256...]}.
Returns (fingerprint, removed stamp rule IDs in line order).
"""
size = page["size"]
kept, removed = [], []
for line in page.get("lines", []):
rid = is_stamp(line, size, rules)
if rid:
removed.append(rid)
else:
kept.append(line["text"])
payload = {
"format": "scienting-page/1",
"size": [round_point(size[0]), round_point(size[1])],
"text": fp_text(" ".join(kept)),
"images": sorted(page.get("images", [])),
}
return sha256_hex(jcs.dumpb(payload)), removed
+90
View File
@@ -0,0 +1,90 @@
"""Grading a scienting-submission/1 against a scienting-exercise/1 key.
Non-normative; see spec/19-exercises.md.
"""
from fractions import Fraction
from . import anchors as A
from .tree import Refusal
def dec(fr):
fr = Fraction(fr)
if fr.denominator == 1:
return str(fr.numerator)
s = "%.4f" % float(fr)
return s.rstrip("0").rstrip(".")
def _resolved(state, anchor):
r = A.resolve(state, anchor)
if r["result"] == "stale":
return None
return r["node"], r.get("span")
def _match(state, rule, student, key):
s, k = _resolved(state, student), _resolved(state, key)
if not s or not k or s[0] != k[0]:
return False
if rule.get("match") == "overlap" and k[1] is not None:
if s[1] is None:
return True
lo, hi = max(s[1][0], k[1][0]), min(s[1][1], k[1][1])
size = k[1][1] - k[1][0]
return size > 0 and Fraction(max(0, hi - lo), size) >= Fraction(rule.get("min_overlap", "0.5"))
return True
def _any(state, rule, students, keys):
return any(_match(state, rule, s, k) for s in students for k in keys)
def key_anchors(body):
key = body["key"]
out = []
for el in key.get("elements", []) + key.get("chain", []) + key.get("errors", []):
out += el["anchors"]
return out
def grade(body, submission, state):
for a in key_anchors(body):
if A.resolve(state, a)["result"] == "stale":
raise Refusal("grade.key-stale", "an anchor in the key is stale")
kind, key, rule, ans = body["exercise"], body["key"], body.get("rule", {}), submission["answers"]
items = []
def item(name, pts, got, fb):
items.append({"element": name, "points": dec(pts if got else 0), "max": dec(pts), "feedback": fb})
if kind in ("anchor", "check"):
if kind == "check":
v = key["verdict"]
ok = ans.get("verdict") == v["value"]
item("verdict", Fraction(v["points"]), ok, v.get("feedback", {}).get("hit" if ok else "miss", ""))
for i, el in enumerate(key["elements"]):
ok = _any(state, rule, ans.get("anchors", []), el["anchors"])
item("element:%d" % i, Fraction(el["points"]), ok, el.get("feedback", {}).get("hit" if ok else "miss", ""))
elif kind == "trace":
chain = ans.get("chain", [])
for i, link in enumerate(key["chain"]):
ok = i < len(chain) and _any(state, rule, chain[i], link["anchors"])
item("link:%d" % i, Fraction(link["points"]), ok, link.get("feedback", {}).get("hit" if ok else "miss", ""))
elif kind == "reproduce":
ok = abs(Fraction(ans["value"]) - Fraction(key["value"])) <= Fraction(key["tolerance"])
item("value", Fraction(key["points"]), ok, key.get("feedback", {}).get("hit" if ok else "miss", ""))
elif kind == "verify":
flags = ans.get("flags", [])
for i, err in enumerate(key["errors"]):
hit = [f for f in flags if _any(state, rule, [f["anchor"]], err["anchors"])]
ok = any(f["verdict"] == err["verdict"] for f in hit)
fb = err.get("feedback", {}).get("hit" if ok else "miss", "")
item("error:%d" % i, Fraction(err["points"]), ok, fb)
else:
raise Refusal("grade.kind", "unknown exercise kind " + kind)
score = sum(Fraction(x["points"]) for x in items)
total = sum(Fraction(x["max"]) for x in items)
return {"format": "scienting-grade/1", "submission": submission["id"], "exercise": submission["exercise"],
"score": dec(score), "max": dec(total), "items": items}
+244
View File
@@ -0,0 +1,244 @@
"""The plain, strict and lossy projections, and the five content values.
Non-normative; see spec/05-hashes.md.
"""
import hashlib
import hmac
import unicodedata
from . import jcs
from .model import LABELLED, MATH_KINDS, RUN_KINDS, VERBATIM_KINDS
from .notation import complex_key, fold_math, fold_plain_text, fold_styled_script
from .text import collapse_ws, is_ws, norm, remove_ws, trim
OBJ = "\ufffc"
MARK_OPEN = "\ufff9"
MARK_CLOSE = "\ufffb"
class Hasher:
"""SHA-256, or HMAC-SHA-256 under a key: every content value uses the same function."""
def __init__(self, key=None):
self.key = key
def __call__(self, data):
if isinstance(data, str):
data = data.encode("utf-8")
if self.key is None:
return hashlib.sha256(data).hexdigest()
return hmac.new(self.key, data, hashlib.sha256).hexdigest()
PLAIN = Hasher()
# --- runs --------------------------------------------------------------------
def _eq_source(doc, run):
eq = doc.nodes[run["node"]]
return eq["content"]["source"], eq["content"]["notation"]
def plain_runs(runs, doc=None):
out = []
for r in runs:
k = r["kind"]
if k in ("text", "link", "cite", "xref", "fnmark"):
out.append(r["text"])
elif k == "math":
out.append("$" + r["source"] + "$")
elif k == "equation":
src, _ = _eq_source(doc, r)
out.append("$$" + src + "$$")
else:
raise ValueError("run kind " + k)
return "".join(out)
def _strict_math(notation, source, complexes):
folded = fold_math(notation, source)
if folded is None:
complexes.append(complex_key(notation, source))
return OBJ
return folded
def _finish_strict(s):
s = unicodedata.normalize("NFKC", s)
return trim(collapse_ws(s))
def strict_runs_raw(runs, doc, complexes):
out = []
for r in runs:
k = r["kind"]
if k == "text":
st = r.get("style", [])
if "sup" in st:
out.append(fold_styled_script("^", r["text"]))
elif "sub" in st:
out.append(fold_styled_script("_", r["text"]))
else:
out.append(fold_plain_text(r["text"]))
elif k == "link":
out.append(fold_plain_text(r["text"]))
elif k == "math":
out.append(_strict_math(r["notation"], r["source"], complexes))
elif k == "equation":
src, notation = _eq_source(doc, r)
out.append(_strict_math(notation, src, complexes))
elif k in ("cite", "xref", "fnmark"):
out.append(MARK_OPEN + remove_ws(unicodedata.normalize("NFC", r["text"])) + MARK_CLOSE)
else:
raise ValueError("run kind " + k)
return "".join(out)
def lossy_from_strict(s):
out = []
stack = []
i, n = 0, len(s)
while i < n:
ch = s[i]
if ch in "_^" and i + 1 < n and s[i + 1] == "{":
stack.append("s")
i += 2
continue
if ch == "{":
stack.append("t")
out.append(ch)
elif ch == "}":
if stack:
if stack.pop() == "t":
out.append(ch)
else:
out.append(ch)
elif ch == MARK_OPEN:
j = s.find(MARK_CLOSE, i)
i = n if j < 0 else j + 1
continue
else:
out.append(ch)
i += 1
t = unicodedata.normalize("NFKC", "".join(out).casefold())
return "".join(
c for c in t if not is_ws(c) and unicodedata.category(c) != "Pd" and c != "\u00ad"
)
def prose_length(lossy):
return sum(1 for c in lossy if c != OBJ)
# --- nodes -------------------------------------------------------------------
def plain(doc, nid):
n = doc.nodes[nid]
k = n["kind"]
if k in RUN_KINDS:
return plain_runs(n["content"], doc)
if k in MATH_KINDS:
return "$$" + n["content"]["source"] + "$$"
if k in VERBATIM_KINDS:
return n["content"]["text"]
return " ".join(plain(doc, x) for x in doc.text_leaves(nid))
def strict(doc, nid):
"""The strict canonical projection and the node's complex math keys, in order."""
n = doc.nodes[nid]
k = n["kind"]
complexes = []
if k in RUN_KINDS:
s = strict_runs_raw(n["content"], doc, complexes)
elif k in MATH_KINDS:
s = _strict_math(n["content"]["notation"], n["content"]["source"], complexes)
elif k in VERBATIM_KINDS:
s = n["content"]["text"]
else:
parts = []
for x in doc.text_leaves(nid):
p, c = strict(doc, x)
parts.append(p)
complexes.extend(c)
return _finish_strict(" ".join(parts)), complexes
return _finish_strict(s), complexes
def lossy(doc, nid):
return lossy_from_strict(strict(doc, nid)[0])
def _runs_for_own(runs):
out = []
for r in runs:
if r["kind"] == "equation":
out.append({"kind": "equation"})
else:
out.append(r)
return out
def own_content(doc, nid, text_hash_of=None):
n = doc.nodes[nid]
k = n["kind"]
o = {"kind": k}
attrs = n.get("attrs", {})
if k in LABELLED:
o["label"] = n.get("label")
if k in RUN_KINDS:
o["runs"] = _runs_for_own(n["content"])
if k in MATH_KINDS:
o["notation"] = n["content"]["notation"]
o["source"] = n["content"]["source"]
if k in VERBATIM_KINDS:
o["language"] = n["content"].get("language")
o["text"] = n["content"]["text"]
if k == "section":
o["level"] = attrs["level"]
heads = [c for c in doc.kids(nid) if doc.nodes[c]["kind"] == "heading"]
o["heading"] = text_hash_of(heads[0]) if heads else None
if k == "table":
o["columns"] = attrs.get("columns", [])
if k == "cell":
o["column"] = attrs.get("column")
o["rowspan"] = attrs.get("rowspan", 1)
o["colspan"] = attrs.get("colspan", 1)
o["role"] = attrs.get("role", "data")
if k == "theorem":
o["variant"] = attrs.get("variant")
return o
def compute(doc, hasher=PLAIN):
"""Compute the five content values of every node, bottom up. Returns {id: {...}}."""
values = {}
def text_hash(nid):
return values[nid]["text_hash"] if nid in values else hasher(norm(plain(doc, nid)))
for nid in reversed(doc.preorder()):
s, _ = strict(doc, nid)
th = hasher(norm(plain(doc, nid)))
own = hasher(jcs.dumpb(own_content(doc, nid, text_hash)))
node = hasher(jcs.dumpb({"own": own, "children": [values[c]["node_hash"] for c in doc.kids(nid)]}))
values[nid] = {
"text_hash": th,
"match_hash": hasher(s),
"align_key": hasher(lossy_from_strict(s)),
"own_hash": own,
"node_hash": node,
}
return values
def meta_object(document):
return {k: document.get(k) for k in ("work", "version", "relations", "meta", "language", "license")}
def meta_hash(document, hasher=PLAIN):
return hasher(jcs.dumpb(meta_object(document)))
+110
View File
@@ -0,0 +1,110 @@
"""Canonical JSON, RFC 8785 (JSON Canonicalization Scheme).
Non-normative.
The normative rule is spec/01-conventions.md; this module exists so the vectors can be generated and checked.
"""
import math
_ESC = {
0x08: "\\b",
0x09: "\\t",
0x0A: "\\n",
0x0C: "\\f",
0x0D: "\\r",
0x22: '\\"',
0x5C: "\\\\",
}
def _string(s):
out = ['"']
for ch in s:
cp = ord(ch)
if 0xD800 <= cp <= 0xDFFF:
raise ValueError("lone surrogate in string")
if cp in _ESC:
out.append(_ESC[cp])
elif cp < 0x20:
out.append("\\u%04x" % cp)
else:
out.append(ch)
out.append('"')
return "".join(out)
def _number(x):
"""ECMAScript Number.prototype.toString, as RFC 8785 requires."""
if isinstance(x, bool):
raise TypeError("bool is not a number")
if isinstance(x, int):
if abs(x) > 2**53:
raise ValueError("integer outside the IEEE 754 exact range")
return str(x)
if not math.isfinite(x):
raise ValueError("NaN and Infinity are not JSON")
if x == 0:
return "0"
if x < 0:
return "-" + _number(-x)
# Shortest round-trip digits, as ECMAScript requires.
r = repr(x)
if "e" in r or "E" in r:
mant, exp = r.lower().split("e")
exp = int(exp)
else:
mant, exp = r, 0
if "." in mant:
ip, fp = mant.split(".")
else:
ip, fp = mant, ""
digits = (ip + fp).lstrip("0")
# Position of the decimal point relative to the start of `digits`.
lead_zeros = len(ip + fp) - len((ip + fp).lstrip("0"))
n = len(ip) - lead_zeros + exp
digits = digits.rstrip("0") or "0"
k = len(digits)
if k <= n <= 21:
return digits + "0" * (n - k)
if 0 < n <= 21:
return digits[:n] + "." + digits[n:]
if -6 < n <= 0:
return "0." + "0" * (-n) + digits
e = n - 1
sign = "+" if e >= 0 else "-"
if k == 1:
return digits + "e" + sign + str(abs(e))
return digits[0] + "." + digits[1:] + "e" + sign + str(abs(e))
def _key(s):
# RFC 8785 sorts member names by their UTF-16 code units.
return s.encode("utf-16-be")
def dumps(value):
"""Serialize `value` to canonical JSON text."""
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if isinstance(value, str):
return _string(value)
if isinstance(value, (int, float)):
return _number(value)
if isinstance(value, (list, tuple)):
return "[" + ",".join(dumps(v) for v in value) + "]"
if isinstance(value, dict):
for k in value:
if not isinstance(k, str):
raise TypeError("object keys must be strings")
items = sorted(value.items(), key=lambda kv: _key(kv[0]))
return "{" + ",".join(_string(k) + ":" + dumps(v) for k, v in items) + "}"
raise TypeError("not JSON: %r" % type(value))
def dumpb(value):
"""Canonical JSON as UTF-8 bytes."""
return dumps(value).encode("utf-8")
+150
View File
@@ -0,0 +1,150 @@
"""The node tree of scienting-doc/1.
Non-normative; see spec/03-nodes.md.
"""
BLOCKS = frozenset(
["paragraph", "equation", "figure", "table", "list", "quote", "code", "algorithm", "theorem", "footnote"]
)
# Allowed child kinds for each node kind.
CHILDREN = {
"document": frozenset(["front", "section", "back"]),
"front": frozenset(["title", "authors", "abstract", "keywords", "notes"]),
"title": frozenset(["sentence"]),
"authors": frozenset(),
"abstract": BLOCKS | {"section"},
"keywords": frozenset(),
"notes": frozenset(["footnote", "paragraph"]),
"section": BLOCKS | {"heading", "section"},
"heading": frozenset(["sentence"]),
"paragraph": frozenset(["sentence"]),
"sentence": frozenset(["equation"]),
"equation": frozenset(["equation-line"]),
"equation-line": frozenset(),
"figure": frozenset(["panel", "caption", "figure-note"]),
"panel": frozenset(),
"caption": frozenset(["sentence"]),
"figure-note": frozenset(["sentence"]),
"table": frozenset(["caption", "table-head", "table-body", "table-note"]),
"table-head": frozenset(["row"]),
"table-body": frozenset(["row"]),
"table-note": frozenset(["sentence"]),
"row": frozenset(["cell"]),
"cell": frozenset(),
"list": frozenset(["item"]),
"item": BLOCKS,
"quote": BLOCKS,
"code": frozenset(),
"algorithm": frozenset(),
"theorem": BLOCKS,
"footnote": frozenset(["sentence"]),
"back": frozenset(["acknowledgments", "references", "appendix", "notes"]),
"acknowledgments": BLOCKS,
"references": frozenset(["reference"]),
"reference": frozenset(),
"appendix": frozenset(["section"]),
}
KINDS = frozenset(CHILDREN)
# Kinds that must have at least one child.
NONEMPTY = frozenset(
["title", "heading", "paragraph", "caption", "figure-note", "table-note", "table-head", "table-body",
"row", "list", "item", "quote", "theorem", "footnote", "appendix"]
)
# Kinds that occur at most once among their siblings.
AT_MOST_ONCE = frozenset(
["front", "back", "title", "authors", "abstract", "keywords", "caption", "table-head", "table-body",
"acknowledgments", "references"]
)
RUN_KINDS = frozenset(["sentence", "cell", "reference", "authors", "keywords"])
MATH_KINDS = frozenset(["equation", "equation-line"])
VERBATIM_KINDS = frozenset(["code", "algorithm"])
LEAVES = RUN_KINDS | MATH_KINDS | VERBATIM_KINDS
CONTAINERS_WITH_CONTENT = frozenset(["section", "figure", "panel", "table", "theorem", "footnote"])
CONTAINERS_WITHOUT_CONTENT = KINDS - LEAVES - CONTAINERS_WITH_CONTENT
LABELLED = frozenset(
["section", "figure", "panel", "table", "theorem", "footnote", "equation", "equation-line", "reference",
"code", "algorithm"]
)
RUNS = frozenset(["text", "math", "cite", "xref", "fnmark", "link", "equation"])
STYLES = frozenset(["em", "strong", "sup", "sub", "sc", "mono"])
LEVELS = ["F0", "F1", "F2", "F3"]
def anchor_class(kind):
if kind in LEAVES:
return "leaf"
if kind in CONTAINERS_WITH_CONTENT:
return "container-with-content"
return "container-without-content"
class Doc:
"""A document: nodes by ID with parent and order, plus document order."""
def __init__(self, key, nodes):
self.key = key
self.nodes = {}
self.order = []
self.children = {}
for n in nodes:
self.nodes[n["id"]] = n
self.order.append(n["id"])
self.children.setdefault(n["id"], [])
for nid in self.order:
p = self.nodes[nid].get("parent")
if p is not None:
self.children.setdefault(p, []).append(nid)
for p, kids in self.children.items():
kids.sort(key=lambda c: self.nodes[c]["ord"])
roots = [nid for nid in self.order if self.nodes[nid].get("parent") is None]
self.root = roots[0] if roots else None
def kids(self, nid):
return self.children.get(nid, [])
def preorder(self, nid=None):
nid = self.root if nid is None else nid
out = []
stack = [nid]
while stack:
x = stack.pop()
out.append(x)
stack.extend(reversed(self.kids(x)))
return out
def ancestors(self, nid):
out = []
p = self.nodes[nid].get("parent")
while p is not None:
out.append(p)
p = self.nodes[p].get("parent")
return list(reversed(out))
def text_leaves(self, nid):
"""The text-bearing nodes a container's projections are made of, in document order."""
out = []
stack = [nid]
while stack:
x = stack.pop()
n = self.nodes[x]
k = n["kind"]
if k in RUN_KINDS or k in VERBATIM_KINDS or (k == "equation" and self._parent_kind(x) != "sentence"):
out.append(x)
continue
if k == "equation-line" or k == "equation":
continue
stack.extend(reversed(self.kids(x)))
return out
def leaves(self, nid):
"""Every leaf below or at `nid`, including equations inside sentences and equation lines."""
return [x for x in self.preorder(nid) if self.nodes[x]["kind"] in LEAVES]
def _parent_kind(self, nid):
p = self.nodes[nid].get("parent")
return None if p is None else self.nodes[p]["kind"]
+476
View File
@@ -0,0 +1,476 @@
"""Simple notation and its canonical linear form.
Non-normative; the rules are spec/05-hashes.md, "The strict projection".
A math source or a styled script either folds to one canonical linear form, or is complex.
"""
import unicodedata
from .text import collapse_ws, is_ws, remove_ws, trim
MINUS = "\u2212"
DOT = "\u22c5"
PRIME = "\u2032"
# Characters that fold to one canonical character inside notation.
CHAR_FOLD = {
"-": MINUS,
"\u2013": MINUS,
"\u2212": MINUS,
"\u00b7": DOT,
"\u2219": DOT,
"\u22c5": DOT,
"'": PRIME,
"\u2032": PRIME,
}
OPERATORS = set("+=/<>.,()") | {MINUS, DOT, PRIME, "\u00d7", "\u2264", "\u2265", "\u00b1"}
TEX_GREEK = {
"alpha": "\u03b1", "beta": "\u03b2", "gamma": "\u03b3", "delta": "\u03b4",
"epsilon": "\u03f5", "varepsilon": "\u03b5", "zeta": "\u03b6", "eta": "\u03b7",
"theta": "\u03b8", "vartheta": "\u03d1", "iota": "\u03b9", "kappa": "\u03ba",
"varkappa": "\u03f0", "lambda": "\u03bb", "mu": "\u03bc", "nu": "\u03bd",
"xi": "\u03be", "pi": "\u03c0", "varpi": "\u03d6", "rho": "\u03c1",
"varrho": "\u03f1", "sigma": "\u03c3", "varsigma": "\u03c2", "tau": "\u03c4",
"upsilon": "\u03c5", "phi": "\u03d5", "varphi": "\u03c6", "chi": "\u03c7",
"psi": "\u03c8", "omega": "\u03c9",
"Gamma": "\u0393", "Delta": "\u0394", "Theta": "\u0398", "Lambda": "\u039b",
"Xi": "\u039e", "Pi": "\u03a0", "Sigma": "\u03a3", "Upsilon": "\u03a5",
"Phi": "\u03a6", "Psi": "\u03a8", "Omega": "\u03a9",
}
TEX_SYMBOLS = {
"times": "\u00d7", "cdot": DOT, "pm": "\u00b1", "le": "\u2264", "leq": "\u2264",
"ge": "\u2265", "geq": "\u2265", "lt": "<", "gt": ">", "prime": PRIME,
"AA": "\u00c5",
}
TEX_WRAPPERS = {"mathrm", "text", "textrm", "mathup"}
TEX_DECLARATIONS = {"rm"}
TEX_SPACES = {"quad", "qquad", "enspace", "thinspace", "medspace", "thickspace"}
TEX_SPACE_SYMBOLS = {",", ";", ":", "!", " "}
TYPST_GREEK = {
"alpha": "\u03b1", "beta": "\u03b2", "gamma": "\u03b3", "delta": "\u03b4",
"epsilon": "\u03b5", "zeta": "\u03b6", "eta": "\u03b7", "theta": "\u03b8",
"iota": "\u03b9", "kappa": "\u03ba", "lambda": "\u03bb", "mu": "\u03bc",
"nu": "\u03bd", "xi": "\u03be", "omicron": "\u03bf", "pi": "\u03c0",
"rho": "\u03c1", "sigma": "\u03c3", "tau": "\u03c4", "upsilon": "\u03c5",
"phi": "\u03c6", "chi": "\u03c7", "psi": "\u03c8", "omega": "\u03c9",
"Alpha": "\u0391", "Beta": "\u0392", "Gamma": "\u0393", "Delta": "\u0394",
"Epsilon": "\u0395", "Zeta": "\u0396", "Eta": "\u0397", "Theta": "\u0398",
"Iota": "\u0399", "Kappa": "\u039a", "Lambda": "\u039b", "Mu": "\u039c",
"Nu": "\u039d", "Xi": "\u039e", "Omicron": "\u039f", "Pi": "\u03a0",
"Rho": "\u03a1", "Sigma": "\u03a3", "Tau": "\u03a4", "Upsilon": "\u03a5",
"Phi": "\u03a6", "Chi": "\u03a7", "Psi": "\u03a8", "Omega": "\u03a9",
"epsilon.alt": "\u03f5", "theta.alt": "\u03d1", "phi.alt": "\u03d5",
"pi.alt": "\u03d6", "rho.alt": "\u03f1", "sigma.alt": "\u03c2",
"kappa.alt": "\u03f0", "beta.alt": "\u03d0",
}
TYPST_SYMBOLS = {
"plus": "+", "minus": MINUS, "times": "\u00d7", "dot": DOT, "dot.op": DOT,
"dot.c": DOT, "plus.minus": "\u00b1", "lt": "<", "gt": ">", "lt.eq": "\u2264",
"gt.eq": "\u2265", "eq": "=", "prime": PRIME, "slash": "/",
}
TYPST_WRAPPERS = {"upright"}
TYPST_SPACES = {"thin", "med", "thick", "quad", "wide", "space"}
class NotSimple(Exception):
pass
def is_letter(ch):
"""A Latin or Greek letter, or the micro sign."""
if ch == "\u00b5":
return True
if not unicodedata.category(ch).startswith("L"):
return False
name = unicodedata.name(ch, "")
return name.startswith("LATIN ") or name.startswith("GREEK ")
def fold_char(ch):
return CHAR_FOLD.get(ch, ch)
def is_simple_char(ch):
ch = fold_char(ch)
return is_letter(ch) or unicodedata.category(ch) == "Nd" or ch in OPERATORS
def script_type(ch):
d = unicodedata.decomposition(ch)
if d.startswith("<super>"):
return "^", "".join(chr(int(h, 16)) for h in d.split()[1:])
if d.startswith("<sub>"):
return "_", "".join(chr(int(h, 16)) for h in d.split()[1:])
return None, None
class Atom:
__slots__ = ("base", "primes", "sub", "sup")
def __init__(self, base=""):
self.base = base
self.primes = 0
self.sub = None
self.sup = None
def _canon(atoms):
out = []
for a in atoms:
if a.sup is not None and a.sup and all(
x.base == PRIME and x.sub is None and x.sup is None and x.primes == 0 for x in a.sup
):
a.primes += len(a.sup)
a.sup = None
s = a.base + PRIME * a.primes
if a.sub is not None:
s += "_{" + _canon(a.sub) + "}"
if a.sup is not None:
s += "^{" + _canon(a.sup) + "}"
out.append(s)
return "".join(out)
def _attach(atoms, op, arg):
if not atoms:
atoms.append(Atom(""))
a = atoms[-1]
if op == "_":
if a.sub is not None:
raise NotSimple("double subscript")
a.sub = arg
else:
if a.sup is not None:
raise NotSimple("double superscript")
a.sup = arg
def _char_atom(ch):
if not is_simple_char(ch):
raise NotSimple("character %r" % ch)
return Atom(fold_char(ch))
# --- TeX ---------------------------------------------------------------------
def _tex_tokens(src):
toks = []
i, n = 0, len(src)
while i < n:
ch = src[i]
if ch == "\\":
j = i + 1
if j < n and src[j].isascii() and src[j].isalpha():
while j < n and src[j].isascii() and src[j].isalpha():
j += 1
toks.append(("cw", src[i + 1:j]))
while j < n and is_ws(src[j]):
j += 1
i = j
continue
if j < n:
toks.append(("cs", src[j]))
i = j + 1
continue
raise NotSimple("trailing backslash")
if is_ws(ch) or ch == "~":
i += 1
continue
toks.append(("ch", ch))
i += 1
return toks
class _TexParser:
def __init__(self, toks):
self.t = toks
self.i = 0
def peek(self):
return self.t[self.i] if self.i < len(self.t) else None
def take(self):
tok = self.peek()
self.i += 1
return tok
def seq(self, closing):
atoms = []
while True:
tok = self.peek()
if tok is None:
if closing:
raise NotSimple("unclosed group")
return atoms
if tok == ("ch", "}"):
if not closing:
raise NotSimple("unbalanced brace")
self.take()
return atoms
self.take()
kind, val = tok
if kind == "ch" and val in "_^":
_attach(atoms, val, self.arg())
elif kind == "ch" and val == "'":
if not atoms:
atoms.append(Atom(""))
atoms[-1].primes += 1
elif kind == "ch" and val == "{":
inner = self.seq(True)
nxt = self.peek()
if nxt in (("ch", "_"), ("ch", "^")) and len(inner) != 1:
raise NotSimple("script on a multi-atom group")
atoms.extend(inner)
else:
atoms.extend(self.command_or_char(kind, val))
def command_or_char(self, kind, val):
if kind == "ch":
return [_char_atom(val)]
if kind == "cs":
if val in TEX_SPACE_SYMBOLS:
return []
raise NotSimple("control symbol \\" + val)
if val in TEX_GREEK:
return [Atom(TEX_GREEK[val])]
if val in TEX_SYMBOLS:
return [Atom(TEX_SYMBOLS[val])]
if val in TEX_SPACES or val in TEX_DECLARATIONS:
return []
if val in TEX_WRAPPERS:
return self.arg()
if val in ("left", "right"):
tok = self.take()
if tok in (("ch", "("), ("ch", ")")):
return [Atom(tok[1])]
raise NotSimple("\\left or \\right with another delimiter")
raise NotSimple("command \\" + val)
def arg(self):
tok = self.take()
if tok is None:
raise NotSimple("missing argument")
kind, val = tok
if tok == ("ch", "{"):
return self.seq(True)
if kind == "ch" and val in "}_^'":
raise NotSimple("bad argument")
return self.command_or_char(kind, val)
def tex_atoms(src):
return _TexParser(_tex_tokens(src)).seq(False)
# --- Typst -------------------------------------------------------------------
def _typst_tokens(src):
toks = []
i, n = 0, len(src)
while i < n:
ch = src[i]
if is_ws(ch):
i += 1
continue
if ch == '"':
j = i + 1
buf = []
while j < n and src[j] != '"':
if src[j] == "\\":
raise NotSimple("escape in string")
buf.append(src[j])
j += 1
if j >= n:
raise NotSimple("unclosed string")
toks.append(("str", "".join(buf)))
i = j + 1
continue
if unicodedata.category(ch).startswith("L"):
j = i
while j < n and (
unicodedata.category(src[j]).startswith("L")
or (src[j] == "." and j + 1 < n and src[j + 1].isascii() and src[j + 1].isalpha() and j > i)
):
j += 1
toks.append(("id", src[i:j]))
i = j
continue
if unicodedata.category(ch) == "Nd":
j = i
while j < n and unicodedata.category(src[j]) == "Nd":
j += 1
if j + 1 < n and src[j] == "." and unicodedata.category(src[j + 1]) == "Nd":
j += 1
while j < n and unicodedata.category(src[j]) == "Nd":
j += 1
toks.append(("num", src[i:j]))
i = j
continue
two = src[i:i + 2]
if two in ("<=", ">="):
toks.append(("ch", "\u2264" if two == "<=" else "\u2265"))
i += 2
continue
if two in ("->", "=>", "!=", "<-", "==", ":="):
raise NotSimple("operator " + two)
toks.append(("ch", ch))
i += 1
return toks
class _TypstParser:
def __init__(self, toks):
self.t = toks
self.i = 0
def peek(self):
return self.t[self.i] if self.i < len(self.t) else None
def take(self):
tok = self.peek()
self.i += 1
return tok
def seq(self, closing):
atoms = []
while True:
tok = self.peek()
if tok is None:
if closing:
raise NotSimple("unclosed parenthesis")
return atoms
if closing and tok == ("ch", ")"):
self.take()
return atoms
self.take()
kind, val = tok
if kind == "ch" and val in "_^":
_attach(atoms, val, self.arg())
elif kind == "ch" and val == "'":
if not atoms:
atoms.append(Atom(""))
atoms[-1].primes += 1
elif kind == "ch" and val == "(":
atoms.append(Atom("("))
atoms.extend(self.seq(True))
atoms.append(Atom(")"))
else:
atoms.extend(self.single(kind, val))
def single(self, kind, val):
if kind == "num":
return [Atom(c) for c in val]
if kind == "str":
return [_char_atom(c) for c in remove_ws(val)]
if kind == "id":
if len(val) == 1:
return [_char_atom(val)]
if val in TYPST_GREEK:
return [Atom(TYPST_GREEK[val])]
if val in TYPST_SYMBOLS:
return [Atom(TYPST_SYMBOLS[val])]
if val in TYPST_SPACES:
return []
if val in TYPST_WRAPPERS and self.peek() == ("ch", "("):
self.take()
return self.seq(True)
raise NotSimple("identifier " + val)
if val in "/*#&[]{}\\$!:;|~@":
raise NotSimple("character %r" % val)
return [_char_atom(val)]
def arg(self):
tok = self.take()
if tok is None:
raise NotSimple("missing argument")
kind, val = tok
if tok == ("ch", "("):
return self.seq(True)
if kind == "ch" and val in ")_^'":
raise NotSimple("bad argument")
return self.single(kind, val)
def typst_atoms(src):
return _TypstParser(_typst_tokens(src)).seq(False)
# --- public ------------------------------------------------------------------
def fold_math(notation, source):
"""Return the canonical linear form of a math source, or None when it is complex."""
src = unicodedata.normalize("NFC", source)
try:
atoms = tex_atoms(src) if notation == "tex" else typst_atoms(src)
return _canon(atoms)
except NotSimple:
return None
def complex_key(notation, source):
return notation + ":" + remove_ws(unicodedata.normalize("NFC", source))
def _fold_script_chars(chars):
"""Fold a run of Unicode script characters of one type, or None if any base is not simple."""
op = None
bases = []
for ch in chars:
t, b = script_type(ch)
if t is None or (op is not None and t != op):
return None
op = t
bases.append(b)
base = "".join(bases)
if not all(is_simple_char(c) for c in base):
return None
return op + "{" + "".join(fold_char(c) for c in base) + "}"
def _fold_chars(s, in_notation):
"""Fold the characters of text: script characters always, operators only inside notation."""
out = []
i, n = 0, len(s)
while i < n:
ch = s[i]
t, b = script_type(ch)
if t is not None:
j = i
while j < n and script_type(s[j])[0] == t:
j += 1
folded = _fold_script_chars(s[i:j])
out.append(folded if folded is not None else s[i:j])
i = j
continue
if in_notation:
out.append(fold_char(ch))
elif ch in ("-", "\u2013") and i + 1 < n and unicodedata.category(s[i + 1]) == "Nd":
out.append(MINUS)
else:
out.append(ch)
i += 1
return "".join(out)
def fold_plain_text(text):
return _fold_chars(unicodedata.normalize("NFC", text), in_notation=False)
def fold_styled_script(op, text):
"""The canonical form of text styled as a subscript (`_`) or superscript (`^`)."""
t = unicodedata.normalize("NFC", text)
s = remove_ws(t)
simple = all(is_simple_char(c) or script_type(c)[0] is not None for c in s)
if simple:
inner = _fold_chars(s, in_notation=True)
if op == "^" and inner and all(c == PRIME for c in inner):
return inner
else:
inner = trim(collapse_ws(t))
return op + "{" + inner + "}"
+383
View File
@@ -0,0 +1,383 @@
"""Packages: writing, reading and validating a scienting-doc/1 package folder.
Non-normative; see spec/12-package.md and spec/22-conformance.md.
"""
import base64
import json
import os
import posixpath
from . import crypto, hashing, jcs, receipts as R
from .anchors import ADDRESS_RE
from .model import LEAVES, MATH_KINDS, RUN_KINDS, VERBATIM_KINDS, Doc
from .schema import validate as schema_validate
from .text import letter_first, record_id, sha256_hex
from .tree import Refusal, validate_structure
JSONL = {
"nodes.jsonl": "node.schema.json",
"pages.jsonl": "page.schema.json",
"locations.jsonl": "location.schema.json",
"references.jsonl": "reference.schema.json",
"corrections.jsonl": "correction.schema.json",
"receipts.jsonl": None,
"ids.jsonl": "id-entry.schema.json",
"suspects.jsonl": "suspect.schema.json",
}
JSON = {
"document.json": "document.schema.json",
"witnesses.json": "witnesses.schema.json",
"provenance.json": "provenance.schema.json",
"roster.json": "roster.schema.json",
}
REQUIRED = ["document.json", "nodes.jsonl", "witnesses.json", "provenance.json"]
def line(rec):
return jcs.dumps(rec) + "\n"
def jsonl(recs):
return "".join(line(r) for r in recs).encode("utf-8")
def witness_id(sha):
return letter_first(bytes.fromhex(sha), 7)
def safe_path(p):
if not p or p.startswith("/") or "\\" in p or "\x00" in p:
return False
parts = p.split("/")
return all(x not in ("", ".", "..") for x in parts) and posixpath.normpath(p) == p
def write(root, files, absent=()):
"""Write `files` ({path: bytes}) and a MANIFEST.json listing them and the `absent` entries.
Returns the package hash.
"""
os.makedirs(root, exist_ok=True)
entries = [dict(a) for a in absent]
for path in sorted(files, key=lambda p: p.encode("utf-8")):
data = files[path]
if data is None:
continue
full = os.path.join(root, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "wb") as f:
f.write(data)
entries.append({"path": path, "sha256": sha256_hex(data), "size": len(data)})
entries.sort(key=lambda e: e["path"].encode("utf-8"))
doc = json.loads(files["document.json"])
manifest = {"format": "scienting-package/1", "doc": "scienting-doc/1", "key": doc["key"], "files": entries}
mb = (jcs.dumps(manifest) + "\n").encode("utf-8")
with open(os.path.join(root, "MANIFEST.json"), "wb") as f:
f.write(mb)
return sha256_hex(mb)
def read_jsonl(path):
out = []
with open(path, encoding="utf-8") as f:
for i, ln in enumerate(f, 1):
if not ln.endswith("\n"):
raise Refusal("package.file.newline", "%s:%d: every line ends with a newline" % (path, i))
rec = json.loads(ln)
if line(rec) != ln:
raise Refusal("package.file.canonical", "%s:%d: line is not canonical JSON" % (path, i))
out.append(rec)
return out
def read_json(path):
with open(path, encoding="utf-8") as f:
text = f.read()
rec = json.loads(text)
if line(rec) != text:
raise Refusal("package.file.canonical", "%s: file is not canonical JSON" % path)
return rec
class Report:
def __init__(self):
self.errors = []
def add(self, rule, where, message):
self.errors.append({"rule": rule, "where": where, "message": message})
def __bool__(self):
return not self.errors
def _all_files(root):
out = []
for d, _, fs in os.walk(root):
for f in fs:
out.append(os.path.relpath(os.path.join(d, f), root).replace(os.sep, "/"))
return sorted(out)
def validate(root, store, key=None):
"""Validate the package at `root`. `store` is a schema store; `key` a hashing key for keyed packages."""
rep = Report()
try:
_validate(root, store, key, rep)
except Refusal as e:
rep.add(e.rule, e.node or "", e.message)
return rep
def _validate(root, store, key, rep):
mpath = os.path.join(root, "MANIFEST.json")
if not os.path.exists(mpath):
raise Refusal("package.manifest.missing", "MANIFEST.json is missing")
manifest = read_json(mpath)
for e in schema_validate(store, "manifest.schema.json", manifest):
rep.add("package.manifest.schema", "MANIFEST.json", e)
listed = {}
paths = [f["path"] for f in manifest.get("files", [])]
if paths != sorted(paths, key=lambda p: p.encode("utf-8")) or len(set(paths)) != len(paths):
rep.add("package.manifest.order", "MANIFEST.json", "files must be sorted by path and unique")
for f in manifest.get("files", []):
p = f["path"]
listed[p] = f
if not safe_path(p):
rep.add("package.path.unsafe", p, "unsafe path")
continue
full = os.path.join(root, p)
if f.get("present", True) is False:
if not p.startswith("assets/"):
rep.add("package.manifest.absent", p, "only assets may be listed without their bytes")
if os.path.exists(full):
rep.add("package.manifest.absent", p, "a file listed as absent is present")
continue
if not os.path.exists(full):
rep.add("package.manifest.missing-file", p, "listed file is missing")
continue
with open(full, "rb") as fh:
data = fh.read()
if sha256_hex(data) != f["sha256"] or len(data) != f["size"]:
rep.add("package.manifest.hash", p, "SHA-256 or size does not match the manifest")
for p in _all_files(root):
if p != "MANIFEST.json" and p not in listed:
rep.add("package.manifest.unlisted", p, "file is not listed in the manifest")
for p in REQUIRED:
if p not in listed:
rep.add("package.file.required", p, "required file is missing")
if not rep:
return
data = {}
for p, sch in JSON.items():
if p in listed:
data[p] = read_json(os.path.join(root, p))
for e in schema_validate(store, sch, data[p]):
rep.add("package.schema", p, e)
for p, sch in JSONL.items():
if p in listed:
data[p] = read_jsonl(os.path.join(root, p))
for i, rec in enumerate(data[p], 1):
s = sch
if p == "receipts.jsonl":
s = "receipt.schema.json" if rec.get("format") == "scienting-receipt/1" else "revocation.schema.json"
for e in schema_validate(store, s, rec):
rep.add("package.schema", "%s:%d" % (p, i), e)
if p != "nodes.jsonl" and not data[p]:
rep.add("package.file.empty", p, "an optional JSONL file with no records is omitted")
else:
data[p] = []
if not rep:
return
document = data["document.json"]
if document["key"] != manifest["key"]:
rep.add("package.manifest.key", "MANIFEST.json", "key differs from document.json")
nodes = data["nodes.jsonl"]
doc = Doc(document["key"], nodes)
try:
validate_structure(doc)
except Refusal as e:
rep.add(e.rule, "nodes.jsonl " + (e.node or ""), e.message)
return
# Policy and hashing.
withheld = [n["id"] for n in nodes if n.get("withheld")]
scheme = document["hashing"]["scheme"]
if document["policy"] == "full" and withheld:
rep.add("doc.policy.withheld", "nodes.jsonl", "a full package withholds no text")
if document["policy"] == "checks-only":
bearing = [n["id"] for n in nodes if n["kind"] in LEAVES]
if set(bearing) != set(withheld):
rep.add("doc.policy.withheld", "nodes.jsonl", "a checks-only package withholds every leaf's content")
if scheme == "plain":
rep.add("doc.policy.keyed", "document.json", "a checks-only package carries keyed hashes")
if scheme == "hmac-team" and key is not None and document["hashing"]["key_id"] != crypto.key_id(key):
rep.add("doc.hash.key", "document.json", "key_id does not match the key supplied")
# References inside runs.
kinds = {n["id"]: n["kind"] for n in nodes}
for n in nodes:
for r in n.get("content", []) if n["kind"] in RUN_KINDS else []:
if r["kind"] == "cite":
for ref in r["refs"]:
if kinds.get(ref) != "reference":
rep.add("doc.run.cite", n["id"], "cite names %s, which is not a reference" % ref)
if r["kind"] == "fnmark" and r.get("footnote") is not None and kinds.get(r["footnote"]) != "footnote":
rep.add("doc.run.fnmark", n["id"], "fnmark names a node that is not a footnote")
if r["kind"] == "xref" and r.get("target") and "#" not in r["target"]:
if r["target"] not in kinds:
rep.add("doc.run.xref", n["id"], "xref names no node in this document")
if n["kind"] == "table":
for col in n.get("attrs", {}).get("columns", []):
for h in col["header_path"]:
if kinds.get(h) != "cell":
rep.add("doc.table.header", n["id"], "a header path names %s, not a cell" % h)
if n["kind"] == "row":
for h in n.get("attrs", {}).get("header_path", []):
if kinds.get(h) != "cell":
rep.add("doc.table.header", n["id"], "a row header path names %s, not a cell" % h)
# Hashes.
hasher = hashing.PLAIN if scheme == "plain" else (hashing.Hasher(key) if key else None)
if hasher is not None and not withheld:
vals = hashing.compute(doc, hasher)
for n in nodes:
for k, v in vals[n["id"]].items():
if n[k] != v:
rep.add("doc.hash." + k.replace("_", "-"), n["id"], "%s does not recompute" % k)
if vals[doc.root]["node_hash"] != document["root"]:
rep.add("doc.hash.root", "document.json", "root does not equal the root node's node_hash")
if hashing.meta_hash(document, hasher) != document["meta_hash"]:
rep.add("doc.hash.meta", "document.json", "meta_hash does not recompute")
else:
vals = {n["id"]: {k: n[k] for k in ("text_hash", "match_hash", "align_key", "own_hash", "node_hash")}
for n in nodes}
if n_root_hash(nodes, doc) != document["root"]:
rep.add("doc.hash.root", "document.json", "root does not equal the root node's node_hash")
# IDs.
live = set(kinds)
ids = data["ids.jsonl"]
known = set(live)
for i, e in enumerate(ids, 1):
if e["id"] in live:
rep.add("doc.id.retired-live", "ids.jsonl:%d" % i, "%s is both live and in the ID table" % e["id"])
known.add(e["id"])
for i, e in enumerate(ids, 1):
s = e.get("successor")
if s is not None and s not in known:
rep.add("doc.id.successor", "ids.jsonl:%d" % i, "successor %s is unknown" % s)
if e["kind"] == "alias" and s not in live:
rep.add("doc.id.alias", "ids.jsonl:%d" % i, "an alias names a live node")
order = sorted(ids, key=lambda e: (0 if e["kind"] == "alias" else 1, e.get("rev", 0), e["id"]))
if order != ids:
rep.add("doc.order.ids", "ids.jsonl", "aliases first by ID, then retirements by revision and ID")
# Corrections.
cors = data["corrections.jsonl"]
for i, c in enumerate(cors, 1):
if c["index"] != i:
rep.add("doc.correction.index", "corrections.jsonl:%d" % i, "indexes count from 1 without gaps")
if c["id"] != record_id(sha256_hex(jcs.dumpb({k: v for k, v in c.items() if k != "id"}))):
rep.add("doc.correction.id", "corrections.jsonl:%d" % i, "id does not derive from the record")
if document["policy"] == "checks-only" and (cors or data["suspects.jsonl"]):
rep.add("doc.policy.withheld", "corrections.jsonl", "a checks-only package carries no corrections or suspects")
if document["policy"] == "full" and document["rev"] != len(cors):
rep.add("doc.correction.rev", "document.json", "rev must equal the number of corrections")
retired_by = {e["id"]: e for e in ids if e["kind"] == "retired"}
for c in cors:
for r in c.get("retired", []):
if r not in retired_by:
rep.add("doc.id.retired-missing", c["id"], "%s was retired but is not in ids.jsonl" % r)
if not withheld and hasher is not None:
last = {}
for c in cors:
if c.get("target") in live and kinds[c["target"]] in LEAVES:
last[c["target"]] = c
for x in c.get("touched", []):
if x != c.get("target"):
last.pop(x, None)
for nid, c in last.items():
if c.get("after") and c["after"] != vals[nid]["node_hash"] and not doc.kids(nid):
rep.add("doc.correction.after", c["id"], "the last correction of %s does not end at its hash" % nid)
# Geometry.
wits = {w["id"]: w for w in data["witnesses.json"]["witnesses"]}
for w in wits.values():
if w["id"] != witness_id(w["sha256"]):
rep.add("doc.witness.id", w["id"], "a witness ID derives from its SHA-256")
fps = set()
for i, p in enumerate(data["pages.jsonl"], 1):
if p["witness"] not in wits:
rep.add("doc.page.witness", "pages.jsonl:%d" % i, "unknown witness")
fps.add(p["page_fp"])
porder = sorted(data["pages.jsonl"], key=lambda p: (p["witness"], p["page"]))
if porder != data["pages.jsonl"]:
rep.add("doc.order.pages", "pages.jsonl", "pages sort by witness and page")
pos = {nid: i for i, nid in enumerate(doc.order)}
for i, loc in enumerate(data["locations.jsonl"], 1):
if loc["node"] not in live:
rep.add("doc.location.node", "locations.jsonl:%d" % i, "unknown node")
if loc["page_fp"] not in fps:
rep.add("doc.location.page", "locations.jsonl:%d" % i, "page_fp is not a page of any witness")
lorder = sorted(data["locations.jsonl"], key=lambda l: (pos.get(l["node"], -1), l["page"], l["page_fp"]))
if lorder != data["locations.jsonl"]:
rep.add("doc.order.locations", "locations.jsonl", "locations sort by node order, then page")
for i, ref in enumerate(data["references.jsonl"], 1):
if kinds.get(ref["node"]) != "reference":
rep.add("doc.reference.node", "references.jsonl:%d" % i, "names no reference node")
rorder = sorted(data["references.jsonl"], key=lambda r: pos.get(r["node"], -1))
if rorder != data["references.jsonl"]:
rep.add("doc.order.references", "references.jsonl", "references follow document order")
sorder = sorted(data["suspects.jsonl"], key=lambda x: x["id"])
if sorder != data["suspects.jsonl"]:
rep.add("doc.order.suspects", "suspects.jsonl", "suspects sort by ID")
# Receipts and fidelity.
roster = {k["id"]: k for k in data.get("roster.json", {}).get("keys", [])}
recs = data["receipts.jsonl"]
rorder = sorted(recs, key=lambda r: (r["at"], r["id"]))
if rorder != recs:
rep.add("doc.order.receipts", "receipts.jsonl", "receipts and revocations sort by time, then ID")
by_id = {r["id"]: r for r in recs}
for r in recs:
if r["id"] != record_id(R.record_hash(r)):
rep.add("doc.receipt.id", r["id"], "id does not derive from the record")
if r["format"] == "scienting-receipt/1":
if r["doc"] != document["key"]:
rep.add("doc.receipt.doc", r["id"], "receipt names another document")
if r["root"] != crypto.merkle_root(crypto.receipt_items(r)):
rep.add("doc.receipt.root", r["id"], "Merkle root does not recompute")
ids_listed = [x[0] for x in r["nodes"]]
if ids_listed != sorted(ids_listed) or len(set(ids_listed)) != len(ids_listed):
rep.add("doc.receipt.nodes", r["id"], "nodes sort by ID without repeats")
if roster and not R.verify_signature(r, roster):
rep.add("doc.receipt.signature", r["id"], "signature does not verify")
qa = r.get("qa")
if qa:
orig = by_id.get(qa["of"])
if not orig:
rep.add("doc.qa.of", r["id"], "the spot-checked receipt is not in this package")
continue
ck = roster.get(qa["key"])
seed = base64.b64decode(qa["seed"])
if roster:
if not ck or ck["scheme"] != "ed25519" or "spot-check" not in ck["purposes"]:
rep.add("doc.qa.key", r["id"], "the seed key is not a registered Ed25519 spot-check key")
elif not ck["registered_at"] <= orig["at"]:
rep.add("doc.qa.key", r["id"], "the spot-check key was registered after the receipt")
elif not crypto.ed25519_verify(base64.b64decode(ck["public"]), R.seed_payload(orig), seed):
rep.add("doc.qa.seed", r["id"], "the seed is not the checker's signature over the receipt")
if qa["sample"] != R.sample(orig, seed, qa["rate"], qa["minimum"]):
rep.add("doc.qa.sample", r["id"], "the sample does not follow from the seed")
if roster and not withheld:
lv = R.effective_levels(nodes, vals, recs, roster)
if R.distribution(lv) != document["fidelity"]:
rep.add("doc.fidelity.report", "document.json", "the fidelity distribution does not recompute")
def n_root_hash(nodes, doc):
return {n["id"]: n for n in nodes}[doc.root]["node_hash"]
+184
View File
@@ -0,0 +1,184 @@
"""Receipts, revocations, levels, spot-check sampling and reviewer error bounds.
Non-normative; see spec/08-fidelity.md.
"""
import base64
import hashlib
import math
from fractions import Fraction
from . import crypto, jcs
from .model import LEVELS
from .text import record_id, sha256_hex
SIGNED_FORMATS = ("scienting-receipt/1", "scienting-revocation/1", "scienting-policy/1")
def record_hash(rec):
"""SHA-256 of the canonical JSON of a signed record without its `id` and `signature`."""
return sha256_hex(jcs.dumpb({k: v for k, v in rec.items() if k not in ("id", "signature")}))
def signing_payload(rec):
return jcs.dumpb({"format": rec["format"], "hash": record_hash(rec)})
def seal(rec, secret, key_id):
"""Fill in a record's root (for receipts), ID and Ed25519 signature."""
rec = dict(rec)
if rec["format"] == "scienting-receipt/1":
rec["root"] = crypto.merkle_root(crypto.receipt_items(rec))
rec.pop("id", None)
rec.pop("signature", None)
sig = crypto.ed25519_sign(secret, signing_payload(rec))
rec["id"] = record_id(record_hash(rec))
rec["signature"] = {"scheme": "ed25519", "key": key_id, "value": base64.b64encode(sig).decode("ascii")}
return rec
def verify_signature(rec, roster):
sig = rec.get("signature") or {}
key = roster.get(sig.get("key"))
if not key or sig.get("scheme") != "ed25519" or key["scheme"] != "ed25519":
return False
pub = base64.b64decode(key["public"])
return crypto.ed25519_verify(pub, signing_payload(rec), base64.b64decode(sig["value"]))
def key_valid_at(key, at):
return key["registered_at"] <= at and (key.get("revoked_at") is None or at < key["revoked_at"])
# --- spot-checks --------------------------------------------------------------
def seed_payload(receipt):
return jcs.dumpb({"format": "scienting-spot-check/1", "receipt": record_hash(receipt)})
def make_seed(receipt, checker_secret):
return crypto.ed25519_sign(checker_secret, seed_payload(receipt))
def sample_size(n, rate="0.05", minimum=10):
return min(n, max(minimum, math.ceil(Fraction(rate) * n)))
def sample(receipt, seed, rate="0.05", minimum=10):
ids = [nid for nid, _ in receipt["nodes"]]
m = sample_size(len(ids), rate, minimum)
ranked = sorted(ids, key=lambda nid: (hashlib.sha256(seed + nid.encode("utf-8")).hexdigest(), nid))
return sorted(ranked[:m])
def upper_bound(k, n, confidence="0.95"):
"""One-sided Clopper-Pearson upper bound on an error rate, as a float."""
if n <= 0:
raise ValueError("n must be positive")
if k >= n:
return 1.0
alpha = 1.0 - float(Fraction(confidence))
def cdf(p):
if p <= 0:
return 1.0
if p >= 1:
return 0.0
lp, lq = math.log(p), math.log1p(-p)
total = 0.0
for i in range(k + 1):
total += math.exp(math.lgamma(n + 1) - math.lgamma(i + 1) - math.lgamma(n - i + 1) + i * lp
+ (n - i) * lq)
return total
lo, hi = 0.0, 1.0
for _ in range(200):
mid = (lo + hi) / 2
if cdf(mid) > alpha:
lo = mid
else:
hi = mid
return hi
def bound_string(k, n, confidence="0.95"):
"""The bound as the spec reports it: six decimal places, rounded up."""
x = upper_bound(k, n, confidence)
v = math.ceil(x * 10**6 - 1e-9)
return "%d.%06d" % (v // 10**6, v % 10**6)
# --- levels -------------------------------------------------------------------
def revoked(receipt, revocations):
for r in revocations:
if r.get("receipt") == receipt["id"]:
return True
if r.get("signer") == receipt["by"] and r.get("period"):
p = r["period"]
if p["from"] <= receipt["at"] <= p["to"]:
return True
return False
def valid_revocations(revocations, receipts_by_id, roster, owners):
out = []
for r in revocations:
if not verify_signature(r, roster):
continue
signer = r["signature"]["key"]
allowed = set(owners)
if r.get("receipt") in receipts_by_id:
allowed.add(receipts_by_id[r["receipt"]]["by"])
if r.get("signer"):
allowed.add(r["signer"])
if signer in allowed:
out.append(r)
return out
def receipt_status(rec, records, roster, owners=()):
"""`valid`, `revoked`, `suspended`, or `invalid` for one receipt."""
receipts = [x for x in records if x["format"] == "scienting-receipt/1"]
revs = [x for x in records if x["format"] == "scienting-revocation/1"]
by_id = {x["id"]: x for x in receipts}
if rec.get("root") != crypto.merkle_root(crypto.receipt_items(rec)):
return "invalid"
if rec["id"] != record_id(record_hash(rec)) or not verify_signature(rec, roster):
return "invalid"
key = roster.get(rec["by"])
if not key or not key_valid_at(key, rec["at"]) or rec["signature"]["key"] != rec["by"]:
return "invalid"
if revoked(rec, valid_revocations(revs, by_id, roster, owners)):
return "revoked"
for q in receipts:
qa = q.get("qa")
if qa and qa.get("of") == rec["id"] and qa.get("errors"):
if receipt_status(q, records, roster, owners) == "valid":
return "suspended"
return "valid"
def effective_levels(nodes, values, records, roster, owners=()):
"""Every leaf's level: its recorded level, raised by valid receipts that list it at its current hash."""
level = {n["id"]: n.get("fidelity") for n in nodes if "fidelity" in n}
for rec in records:
if rec["format"] != "scienting-receipt/1":
continue
if receipt_status(rec, records, roster, owners) != "valid":
continue
for nid, h in rec.get("nodes", []):
if nid in level and values[nid]["node_hash"] == h:
if LEVELS.index(rec["level"]) > LEVELS.index(level[nid]):
level[nid] = rec["level"]
return level
def distribution(levels):
counts = {lv: 0 for lv in LEVELS}
for lv in levels.values():
counts[lv] += 1
lowest = next((lv for lv in LEVELS if counts[lv]), None)
return {"lowest": lowest, "counts": counts}
+106
View File
@@ -0,0 +1,106 @@
"""Rights records, policies, stage checks and backend conditions.
Non-normative; see spec/14-rights.md.
"""
USES = ("read", "tdm", "redistribute", "quote-public", "accessible-copy")
PURPOSES = ("internal", "external")
PROCESSORS = ("local", "tenancy", "outside-no-retention", "outside")
AI_PAIRS = tuple(p + "/" + q for p in PURPOSES for q in PROCESSORS)
def _value(v):
return v if v in (True, False) else "unknown"
def flat(rights):
"""A rights record's uses as one flat map: the five uses plus ai/<purpose>/<processor>."""
uses = rights.get("uses", {})
out = {u: _value(uses.get(u, "unknown")) for u in USES}
ai = uses.get("ai", {})
for pair in AI_PAIRS:
out["ai/" + pair] = _value(ai.get(pair, "unknown"))
return out
def _policy_applies(policy, witness, now):
if policy.get("valid_until") and now >= policy["valid_until"]:
return False
if policy.get("at") and now < policy["at"]:
return False
a = policy.get("applies_to", {})
r = witness["rights"]
if "basis" in a and r.get("basis") not in a["basis"]:
return False
if "license" in a and (r.get("license") or {}).get("spdx") not in a["license"]:
return False
if "doi_prefixes" in a:
doi = (witness.get("origin") or {}).get("doi") or ""
if not any(doi.startswith(p) for p in a["doi_prefixes"]):
return False
if "witnesses" in a and witness["sha256"] not in a["witnesses"]:
return False
return True
def effective(witness, policies, now):
"""The witness's uses at `now`, with unknowns resolved by applicable policies, or all false if expired."""
r = witness["rights"]
f = flat(r)
if r.get("valid_until") and now >= r["valid_until"]:
return {k: False for k in f}, True
for p in policies:
if not _policy_applies(p, witness, now):
continue
pf = flat({"uses": p.get("uses", {})})
for k, v in pf.items():
if f[k] == "unknown" and v != "unknown":
f[k] = v
return f, False
def combine(flats):
"""The most restrictive of several witnesses' uses: false over unknown over true."""
out = {}
for k in flats[0]:
vals = [f[k] for f in flats]
out[k] = False if False in vals else ("unknown" if "unknown" in vals else True)
return out
def check(stage, processor, uses):
"""Whether a stage may run on a processor under `uses`. Returns (allowed, lacking)."""
lacking = []
for u in stage.get("uses", []):
if uses[u] is not True:
lacking.append(u if uses[u] is False else u + "?")
ai = stage.get("ai")
if ai:
allowed_here = ai.get("processors")
if allowed_here and processor not in allowed_here:
lacking.append("processor:" + processor)
k = "ai/" + ai["purpose"] + "/" + processor
if uses[k] is not True:
lacking.append(k if uses[k] is False else k + "?")
return (not lacking), lacking
def backend_allowed(backend, profile):
"""Whether a team profile meets every condition a backend's licenses impose. Returns (ok, failed)."""
failed = []
for cond in backend.get("conditions", []):
k = cond["kind"]
if k == "revenue-cap":
r = profile.get("revenue_or_funding_usd")
if r is None or r.get("at_most") is None or r["at_most"] > cond["usd"]:
failed.append(cond["id"])
elif k == "no-competition":
comp = profile.get("competes_with")
if comp is None or cond["vendor"] in comp:
failed.append(cond["id"])
elif k == "purpose":
if profile.get("purpose") not in cond["allowed"]:
failed.append(cond["id"])
else:
failed.append(cond["id"])
return (not failed), failed
+210
View File
@@ -0,0 +1,210 @@
"""A JSON Schema (draft 2020-12) validator for the subset of keywords the spec's schemas use.
Non-normative.
The spec's schemas are restricted to these keywords (spec/22-conformance.md) so that any 2020-12 validator, and
this one, agree; CI also runs the `jsonschema` package when it is installed.
"""
import json
import os
import re
KEYWORDS = {
"$schema", "$id", "$ref", "$defs", "$comment", "title", "description", "examples", "default", "deprecated",
"type", "enum", "const", "properties", "required", "additionalProperties", "patternProperties",
"propertyNames", "minProperties", "maxProperties", "dependentRequired", "items", "prefixItems", "minItems",
"maxItems", "uniqueItems", "contains", "minLength", "maxLength", "pattern", "format", "minimum", "maximum",
"exclusiveMinimum", "exclusiveMaximum", "oneOf", "anyOf", "allOf", "not", "if", "then", "else",
}
FORBIDDEN_PATTERN_TOKENS = ("\\d", "\\w", "\\s", "\\b", "\\D", "\\W", "\\S", "\\B", "(?<", "(?P")
class SchemaError(Exception):
pass
class Store:
def __init__(self, root):
self.root = root
self.docs = {}
def load(self, name):
if name not in self.docs:
with open(os.path.join(self.root, name), encoding="utf-8") as f:
self.docs[name] = json.load(f)
lint(self.docs[name], name)
return self.docs[name]
def deref(self, base, ref):
name, _, frag = ref.partition("#")
name = name or base
node = self.load(name)
if frag:
for part in frag.lstrip("/").split("/"):
node = node[part.replace("~1", "/").replace("~0", "~")]
return name, node
def lint(schema, where):
"""Refuse keywords outside the subset, and regex syntax that differs between dialects."""
if isinstance(schema, dict):
for k, v in schema.items():
if k not in KEYWORDS:
raise SchemaError("%s: keyword %s is outside the subset" % (where, k))
if k == "pattern" and any(t in v for t in FORBIDDEN_PATTERN_TOKENS):
raise SchemaError("%s: pattern %r uses dialect-dependent syntax" % (where, v))
if k in ("properties", "patternProperties", "$defs"):
for kk, vv in v.items():
if k == "patternProperties" and any(t in kk for t in FORBIDDEN_PATTERN_TOKENS):
raise SchemaError("%s: pattern %r uses dialect-dependent syntax" % (where, kk))
lint(vv, where + "/" + kk)
elif k in ("items", "additionalProperties", "propertyNames", "not", "if", "then", "else", "contains"):
lint(v, where + "/" + k)
elif k in ("oneOf", "anyOf", "allOf", "prefixItems"):
for i, s in enumerate(v):
lint(s, "%s/%s/%d" % (where, k, i))
def _type_ok(t, x):
if t == "null":
return x is None
if t == "boolean":
return isinstance(x, bool)
if t == "integer":
return isinstance(x, int) and not isinstance(x, bool)
if t == "number":
return isinstance(x, (int, float)) and not isinstance(x, bool)
if t == "string":
return isinstance(x, str)
if t == "array":
return isinstance(x, list)
if t == "object":
return isinstance(x, dict)
raise SchemaError("unknown type " + t)
def _equal(a, b):
if isinstance(a, bool) or isinstance(b, bool):
return type(a) is type(b) and a == b
return a == b
def validate(store, name, instance):
"""Return a list of error strings; empty when the instance is valid."""
schema = store.load(name)
errors = []
_v(store, name, schema, instance, "", errors)
return errors
def _v(store, base, s, x, path, errors):
if s is True:
return
if s is False:
errors.append(path + ": not allowed")
return
if "$ref" in s:
b2, target = store.deref(base, s["$ref"])
_v(store, b2, target, x, path, errors)
if "type" in s:
ts = s["type"] if isinstance(s["type"], list) else [s["type"]]
if not any(_type_ok(t, x) for t in ts):
errors.append("%s: expected %s" % (path or "/", "/".join(ts)))
return
if "const" in s and not _equal(s["const"], x):
errors.append("%s: expected %r" % (path or "/", s["const"]))
if "enum" in s and not any(_equal(e, x) for e in s["enum"]):
errors.append("%s: %r is not one of %r" % (path or "/", x, s["enum"]))
if isinstance(x, str):
if "minLength" in s and len(x) < s["minLength"]:
errors.append("%s: shorter than %d" % (path, s["minLength"]))
if "maxLength" in s and len(x) > s["maxLength"]:
errors.append("%s: longer than %d" % (path, s["maxLength"]))
if "pattern" in s and not re.search(s["pattern"], x):
errors.append("%s: %r does not match %s" % (path, x, s["pattern"]))
if isinstance(x, (int, float)) and not isinstance(x, bool):
if "minimum" in s and x < s["minimum"]:
errors.append("%s: below %s" % (path, s["minimum"]))
if "maximum" in s and x > s["maximum"]:
errors.append("%s: above %s" % (path, s["maximum"]))
if "exclusiveMinimum" in s and x <= s["exclusiveMinimum"]:
errors.append("%s: not above %s" % (path, s["exclusiveMinimum"]))
if "exclusiveMaximum" in s and x >= s["exclusiveMaximum"]:
errors.append("%s: not below %s" % (path, s["exclusiveMaximum"]))
if isinstance(x, list):
if "minItems" in s and len(x) < s["minItems"]:
errors.append("%s: fewer than %d items" % (path, s["minItems"]))
if "maxItems" in s and len(x) > s["maxItems"]:
errors.append("%s: more than %d items" % (path, s["maxItems"]))
if s.get("uniqueItems"):
seen = [json.dumps(i, sort_keys=True) for i in x]
if len(seen) != len(set(seen)):
errors.append("%s: items are not unique" % path)
pre = s.get("prefixItems", [])
for i, item in enumerate(x):
if i < len(pre):
_v(store, base, pre[i], item, "%s/%d" % (path, i), errors)
elif "items" in s:
_v(store, base, s["items"], item, "%s/%d" % (path, i), errors)
if "contains" in s:
if not any(not _sub(store, base, s["contains"], i) for i in x):
errors.append("%s: no item matches `contains`" % path)
if isinstance(x, dict):
if "minProperties" in s and len(x) < s["minProperties"]:
errors.append("%s: fewer than %d properties" % (path, s["minProperties"]))
if "maxProperties" in s and len(x) > s["maxProperties"]:
errors.append("%s: more than %d properties" % (path, s["maxProperties"]))
for r in s.get("required", []):
if r not in x:
errors.append("%s: missing %s" % (path or "/", r))
for k, deps in s.get("dependentRequired", {}).items():
if k in x:
for d in deps:
if d not in x:
errors.append("%s: %s requires %s" % (path or "/", k, d))
props = s.get("properties", {})
pprops = s.get("patternProperties", {})
for k, v in x.items():
matched = False
if k in props:
matched = True
_v(store, base, props[k], v, path + "/" + k, errors)
for pat, ps in pprops.items():
if re.search(pat, k):
matched = True
_v(store, base, ps, v, path + "/" + k, errors)
if not matched and "additionalProperties" in s:
ap = s["additionalProperties"]
if ap is False:
errors.append("%s: unexpected property %s" % (path or "/", k))
else:
_v(store, base, ap, v, path + "/" + k, errors)
if "propertyNames" in s:
_v(store, base, s["propertyNames"], k, path + "/" + k, errors)
for sub in s.get("allOf", []):
_v(store, base, sub, x, path, errors)
if "anyOf" in s:
if all(_sub(store, base, sub, x) for sub in s["anyOf"]):
errors.append("%s: matches none of anyOf" % (path or "/"))
if "oneOf" in s:
ok = [sub for sub in s["oneOf"] if not _sub(store, base, sub, x)]
if len(ok) != 1:
detail = ""
if not ok:
best = min((_sub(store, base, sub, x) for sub in s["oneOf"]), key=len)
detail = " (closest: %s)" % "; ".join(best[:3])
errors.append("%s: matches %d of oneOf%s" % (path or "/", len(ok), detail))
if "not" in s and not _sub(store, base, s["not"], x):
errors.append("%s: matches `not`" % (path or "/"))
if "if" in s:
if not _sub(store, base, s["if"], x):
if "then" in s:
_v(store, base, s["then"], x, path, errors)
elif "else" in s:
_v(store, base, s["else"], x, path, errors)
def _sub(store, base, s, x):
e = []
_v(store, base, s, x, "", e)
return e
+105
View File
@@ -0,0 +1,105 @@
"""Text normalization, IDs, keys and small shared helpers.
Non-normative; see spec/01-conventions.md and spec/04-identity.md.
"""
import hashlib
import re
import unicodedata
from . import jcs
# Unicode White_Space=yes (PropList.txt), the set every rule in the spec means by "whitespace".
WHITE_SPACE = frozenset(
[0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20, 0x85, 0xA0, 0x1680]
+ list(range(0x2000, 0x200B))
+ [0x2028, 0x2029, 0x202F, 0x205F, 0x3000]
)
def is_ws(ch):
return ord(ch) in WHITE_SPACE
def collapse_ws(s):
out = []
prev_ws = False
for ch in s:
if is_ws(ch):
if not prev_ws:
out.append(" ")
prev_ws = True
else:
out.append(ch)
prev_ws = False
return "".join(out)
def trim(s):
a, b = 0, len(s)
while a < b and is_ws(s[a]):
a += 1
while b > a and is_ws(s[b - 1]):
b -= 1
return s[a:b]
def remove_ws(s):
return "".join(ch for ch in s if not is_ws(ch))
def norm(s):
"""The hashing normalization: NFC, whitespace runs to one U+0020, trimmed."""
return trim(collapse_ws(unicodedata.normalize("NFC", s)))
def sha256_hex(b):
if isinstance(b, str):
b = b.encode("utf-8")
return hashlib.sha256(b).hexdigest()
def is_digit(ch):
return unicodedata.category(ch) == "Nd"
def digits(s):
return "".join(ch for ch in s if is_digit(ch))
# Crockford base32, lowercase.
ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
LETTERS = "abcdefghjkmnpqrstvwxyz"
assert len(ALPHABET) == 32 and len(LETTERS) == 22
NODE_ID_RE = re.compile(r"^[a-hjkmnp-tv-z][0-9a-hjkmnp-tv-z]{6}$")
RECORD_ID_RE = re.compile(r"^[a-hjkmnp-tv-z][0-9a-hjkmnp-tv-z]{6,25}$")
KEY_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
HASH_RE = re.compile(r"^[0-9a-f]{64}$")
def letter_first(digest, length):
"""Encode a digest as a letter-first Crockford base32 ID of `length` characters."""
first = LETTERS[digest[0] % 22]
bits = int.from_bytes(digest[1:], "big")
nbits = 8 * (len(digest) - 1)
out = [first]
for i in range(length - 1):
shift = nbits - 5 * (i + 1)
out.append(ALPHABET[(bits >> shift) & 31])
return "".join(out)
def mint_node_id(doc_key, kind, text_hash, occurrence):
d = hashlib.sha256(jcs.dumpb(["scienting-id/1", doc_key, kind, text_hash, occurrence])).digest()
return letter_first(d, 7)
def record_id(record_hash_hex):
"""A record's ID: 13 letter-first base32 characters of its record hash."""
return letter_first(bytes.fromhex(record_hash_hex), 13)
def cp_to_utf16(s, offset):
"""Convert a code point offset in `s` to a UTF-16 code unit offset."""
return sum(2 if ord(ch) > 0xFFFF else 1 for ch in s[:offset])
+216
View File
@@ -0,0 +1,216 @@
"""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 leaf_kinds():
return LEAVES