feat: validator, bundle builder and release checks for 0.1.0
- Add tools/validate.py: validates packages, and with --correction runs proposed corrections through the gate against a package without writing anything (spec/11 §11.4). The gate now refuses a proposal that fails the correction schema, rule correction.schema. - tools/load.py validates every package before loading, and with --bundle --built-from builds a scienting-bundle/1 with the FTS5 search index and bundle_meta (spec/13 §13.5). Search rows are defined per unit (sentence, cell, equation outside a sentence, figure-text) with the parent kind as context. - Roster keys carry a role, and a key with the role owner may revoke any receipt (spec/08 §8.7, spec/20); roster schema, store table and the fixture roster updated. The checker signs a trial revocation to prove the rule both ways. - A qa: receipt raises the nodes it sampled except those in its errors (spec/08 §8.2); the example paper's fidelity counts become 27 F3, 13 F2, 4 F1 and 1 F0. - The destination chooses the keyed scheme, not the text policy: hmac-team between the team's machines, hkdf-pages outside the team (spec/05 §5.8, spec/12 §12.5). - A TextQuoteSelector must repeat the anchor's plain quote and is absent for hashed or keyed anchors (spec/18 §18.3). - Add fixtures/records/suspect.json, a scienting-suspect/1 record. - Oracle: hashes are kept incrementally and checked after every correction against a full recomputation; load and write refuse unsafe manifest paths; Ed25519, HKDF and schema validation come from the cryptography and jsonschema packages; canonical JSON uses the built-in encoder with an exact RFC 8785 fallback. - The tools need Python 3.14 with cryptography and jsonschema; the py310 CI environment is dropped and the workflow runs the default only. - Resolve every [CHECK] note in the docs, READMEs and context; add CONTRIBUTING.md; every tool answers --help and refuses unknown options. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
89 files changed
+1141
-513
No files matched your search
+72
-4
@@ -8,9 +8,14 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from . import jcs
|
||||
from .package import read_json, read_jsonl, write
|
||||
from .text import sha256_hex
|
||||
from . import hashing, jcs
|
||||
from .model import Doc
|
||||
from .package import SHA256_RE, read_json, read_jsonl, safe_path, write
|
||||
from .text import norm, sha256_hex
|
||||
from .tree import Refusal
|
||||
|
||||
SPEC_VERSION = "0.1.0"
|
||||
BUNDLE_FORMAT = "scienting-bundle/1"
|
||||
|
||||
STRUCTURED = {"MANIFEST.json", "document.json", "witnesses.json", "provenance.json", "roster.json", "nodes.jsonl",
|
||||
"pages.jsonl", "locations.jsonl", "references.jsonl", "corrections.jsonl", "receipts.jsonl",
|
||||
@@ -44,10 +49,71 @@ def create(conn, sql_dir):
|
||||
conn.executescript(f.read())
|
||||
|
||||
|
||||
def create_bundle(conn, sql_dir):
|
||||
"""The tables of a bundle: those of create(), then sql/bundle-sqlite.sql (spec/13 §13.5)."""
|
||||
create(conn, sql_dir)
|
||||
with open(os.path.join(sql_dir, "bundle-sqlite.sql"), encoding="utf-8") as f:
|
||||
conn.executescript(f.read())
|
||||
|
||||
|
||||
def search_rows(key, nodes):
|
||||
"""The bundle's search rows for one document, in document order (spec/13 §13.5).
|
||||
|
||||
One row per sentence, cell, equation outside a sentence and figure-text item, as
|
||||
(doc_key, node, unit, context, text). For a node, `context` is the kind of its parent; a figure-text item
|
||||
takes its panel as `node` and its `role` as `context`. Withheld nodes are skipped.
|
||||
"""
|
||||
doc = Doc(key, nodes)
|
||||
out = []
|
||||
for nid in doc.order:
|
||||
n = doc.nodes[nid]
|
||||
kind = n["kind"]
|
||||
parent = doc.nodes[n["parent"]]["kind"] if n.get("parent") else None
|
||||
if n.get("withheld"):
|
||||
continue
|
||||
if kind in ("sentence", "cell") or (kind == "equation" and parent != "sentence"):
|
||||
out.append((key, nid, kind, parent, norm(hashing.plain(doc, nid))))
|
||||
elif kind == "panel":
|
||||
for item in (n.get("attrs") or {}).get("figure_text", []):
|
||||
out.append((key, nid, "figure-text", item["role"], norm(item["text"])))
|
||||
return out
|
||||
|
||||
|
||||
def index(conn, key):
|
||||
"""Fill the bundle's `search` table for the loaded document `key`."""
|
||||
nodes = []
|
||||
for n in _q(conn, "SELECT id, kind, parent, ord, attrs, content, withheld FROM nodes WHERE doc_key = ? ORDER BY seq",
|
||||
(key,)):
|
||||
rec = {"id": n["id"], "kind": n["kind"], "parent": n["parent"], "ord": n["ord"]}
|
||||
_opt(rec, "attrs", P(n["attrs"]))
|
||||
_opt(rec, "content", P(n["content"]))
|
||||
if n["withheld"]:
|
||||
rec["withheld"] = True
|
||||
nodes.append(rec)
|
||||
rows = search_rows(key, nodes)
|
||||
conn.executemany("INSERT INTO search VALUES (?, ?, ?, ?, ?)", rows)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def write_meta(conn, store, built_from, extra=None):
|
||||
"""Write `bundle_meta`: format, store, built_from and spec, then any `extra` pairs."""
|
||||
pairs = {"format": BUNDLE_FORMAT, "store": store, "built_from": built_from, "spec": SPEC_VERSION}
|
||||
pairs.update(extra or {})
|
||||
conn.executemany("INSERT INTO bundle_meta VALUES (?, ?)", sorted(pairs.items()))
|
||||
|
||||
|
||||
def load(conn, root, asset_store):
|
||||
"""Load the package at `root`. Files without tables go to `asset_store`, by SHA-256."""
|
||||
"""Load the package at `root`. Files without tables go to `asset_store`, by SHA-256.
|
||||
|
||||
Validate the package first (package.validate); this only refuses paths and hashes that are unsafe to use.
|
||||
"""
|
||||
manifest = read_json(os.path.join(root, "MANIFEST.json"))
|
||||
listed = {f["path"]: f for f in manifest["files"]}
|
||||
for path, f in listed.items():
|
||||
if not safe_path(path):
|
||||
raise Refusal("package.path.unsafe", "unsafe path %r" % path)
|
||||
if not isinstance(f.get("sha256"), str) or not SHA256_RE.fullmatch(f["sha256"]):
|
||||
raise Refusal("package.manifest.schema", "%s: sha256 is not 64 lowercase hex digits" % path)
|
||||
|
||||
def jl(name):
|
||||
return read_jsonl(os.path.join(root, name)) if name in listed else []
|
||||
@@ -241,6 +307,8 @@ def unload(conn, key, out, asset_store):
|
||||
absent = []
|
||||
for f in _q(conn, "SELECT * FROM package_files WHERE doc_key = ?", (key,)):
|
||||
if f["present"]:
|
||||
if not SHA256_RE.fullmatch(f["sha256"]):
|
||||
raise Refusal("package.manifest.schema", "%s: sha256 is not 64 lowercase hex digits" % f["path"])
|
||||
with open(os.path.join(asset_store, f["sha256"]), "rb") as fh:
|
||||
files[f["path"]] = fh.read()
|
||||
else:
|
||||
|
||||
Reference in new issue
Block a user