Initial commit
This commit is contained in:
373 files changed
+16704
No files matched your search
Whitespace-only changes.
@@ -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}
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)))
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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 + "}"
|
||||
@@ -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"]
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
|
||||
Reference in new issue
Block a user