machinechecked 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- machinechecked-0.1.0.dist-info/METADATA +108 -0
- machinechecked-0.1.0.dist-info/RECORD +58 -0
- machinechecked-0.1.0.dist-info/WHEEL +5 -0
- machinechecked-0.1.0.dist-info/entry_points.txt +3 -0
- machinechecked-0.1.0.dist-info/licenses/LICENSE +201 -0
- machinechecked-0.1.0.dist-info/top_level.txt +1 -0
- prooflens/__init__.py +1 -0
- prooflens/__main__.py +3 -0
- prooflens/assess.py +504 -0
- prooflens/audit.py +115 -0
- prooflens/bundle.py +70 -0
- prooflens/cli.py +542 -0
- prooflens/config.py +145 -0
- prooflens/data/ProofLensProbe.lean +469 -0
- prooflens/data/prooflens.sty +75 -0
- prooflens/data/schema/evidence.schema.json +24 -0
- prooflens/data/schema/inventory.schema.json +55 -0
- prooflens/data/schema/manifest-claim.schema.json +126 -0
- prooflens/data/schema/manifest-index.schema.json +128 -0
- prooflens/evidence.py +60 -0
- prooflens/export.py +227 -0
- prooflens/formalization.py +106 -0
- prooflens/manifest.py +296 -0
- prooflens/pdfclaims.py +129 -0
- prooflens/pipeline.py +331 -0
- prooflens/probe.py +126 -0
- prooflens/promote.py +204 -0
- prooflens/review_queue.py +138 -0
- prooflens/reviews.py +105 -0
- prooflens/schemas.py +19 -0
- prooflens/server.py +63 -0
- prooflens/simple.py +628 -0
- prooflens/sources.py +164 -0
- prooflens/tex.py +408 -0
- prooflens/util.py +66 -0
- prooflens/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- prooflens/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- prooflens/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- prooflens/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- prooflens/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- prooflens/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- prooflens/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- prooflens/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- prooflens/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- prooflens/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- prooflens/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- prooflens/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- prooflens/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- prooflens/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- prooflens/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- prooflens/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- prooflens/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- prooflens/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- prooflens/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- prooflens/web/assets/index-B2GDDbsW.css +1 -0
- prooflens/web/assets/index-DN7uLe7p.js +285 -0
- prooflens/web/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- prooflens/web/index.html +14 -0
prooflens/promote.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Promotion of review reports: from a reviewer's report to the project's review markers.
|
|
2
|
+
|
|
3
|
+
A report (exported from the review queue) records what a reviewer read, compared and decided.
|
|
4
|
+
Promotion is the maintainer's step. It refuses a report that does not apply to the project as
|
|
5
|
+
it now is, or that the acceptance rules cannot count as an independent review:
|
|
6
|
+
|
|
7
|
+
* the evidence must be current (the Lean inputs unchanged since the last build);
|
|
8
|
+
* the unit's fingerprints (and the 32-digit legacy hash the markers record) and the manuscript
|
|
9
|
+
revisions must be those the report was made at;
|
|
10
|
+
* the verdict must be faithful, faithful with representation choices (listed), or broader (with
|
|
11
|
+
the instantiation that gives the printed claim);
|
|
12
|
+
* the reading must have been locked before the verdict, and the verdict recorded before the
|
|
13
|
+
author's review was shown;
|
|
14
|
+
* the reviewer must not be an author of the statements, and a model of the same family as the
|
|
15
|
+
author (or of unknown family) is refused unless the maintainer accepts it explicitly.
|
|
16
|
+
|
|
17
|
+
For each report it accepts, it archives the report (JSON and the protocol's text block) and
|
|
18
|
+
writes `<!-- spec-review: NAME HASH by=REVIEWER -->` (or `def-review`) after the declaration's
|
|
19
|
+
last marker, in the exact form the acceptance checker reads, then assesses the stored evidence
|
|
20
|
+
again. A promoted marker records the reviewer the report names; who they are is as the report
|
|
21
|
+
states it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from . import evidence as ev
|
|
31
|
+
from .config import Config
|
|
32
|
+
from .reviews import history, load_reviews, review_files
|
|
33
|
+
from .util import read_json
|
|
34
|
+
|
|
35
|
+
ACCEPTABLE = {"faithful", "faithful with representation choices", "broader"}
|
|
36
|
+
NEEDS_DETAIL = {"faithful with representation choices", "broader"}
|
|
37
|
+
HEX32 = re.compile(r"^[0-9a-f]{32}$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PromoteError(RuntimeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def latest_record(cfg: Config) -> dict:
|
|
45
|
+
rels = sorted((cfg.out_dir / "releases").glob("*.json"), key=lambda p: p.stat().st_mtime)
|
|
46
|
+
if not rels:
|
|
47
|
+
raise PromoteError("no release: run `prooflens build` first")
|
|
48
|
+
return ev.load(cfg.out_dir, read_json(rels[-1])["evidence"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_reports(paths: list[Path]) -> list[dict]:
|
|
52
|
+
out = []
|
|
53
|
+
for p in paths:
|
|
54
|
+
d = json.loads(Path(p).read_text(encoding="utf-8"))
|
|
55
|
+
out += d["reports"] if "reports" in d else [d]
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_report(r: dict, body: dict, cfg: Config, accept_same_family: bool) -> list[str]:
|
|
60
|
+
"""Why `r` cannot be promoted against the evidence `body` (empty if it can)."""
|
|
61
|
+
why = []
|
|
62
|
+
if r.get("schema") != "prooflens/review-report@1":
|
|
63
|
+
return [f"not a review report (schema {r.get('schema')!r})"]
|
|
64
|
+
u, pins, rev = r["unit"], r["pins"], r["reviewer"]
|
|
65
|
+
lean = body.get("lean") or {}
|
|
66
|
+
if u["kind"] == "spec":
|
|
67
|
+
cur = lean.get("specs", {}).get(u["name"])
|
|
68
|
+
elif u["kind"] == "def":
|
|
69
|
+
cur = lean.get("decls", {}).get(u["name"])
|
|
70
|
+
else:
|
|
71
|
+
return [f"a {u['kind']} unit has no review marker in this project"]
|
|
72
|
+
if cur is None:
|
|
73
|
+
return [f"{u['name']} is not in the current evidence"]
|
|
74
|
+
if pins.get("fingerprint") != cur.get("fingerprint"):
|
|
75
|
+
why.append("the declaration changed since the review (fingerprint differs)")
|
|
76
|
+
if pins.get("legacy_hash") != cur.get("legacy_hash") or not HEX32.match(
|
|
77
|
+
cur.get("legacy_hash") or ""):
|
|
78
|
+
why.append("the legacy hash differs from the current one")
|
|
79
|
+
papers = body["provenance"]["papers"]
|
|
80
|
+
for pid, revn in (pins.get("manuscripts") or {}).items():
|
|
81
|
+
if pid in papers and papers[pid].get("revision") != revn:
|
|
82
|
+
why.append(f"paper {pid} is at another revision than the one reviewed")
|
|
83
|
+
v = r["verdict"]["value"]
|
|
84
|
+
if v not in ACCEPTABLE:
|
|
85
|
+
why.append(f"verdict {v!r} is not one that is recorded as a review")
|
|
86
|
+
elif v in NEEDS_DETAIL and not r["verdict"].get("detail", "").strip():
|
|
87
|
+
why.append(f"verdict {v!r} needs its detail (representation choices or instantiation)")
|
|
88
|
+
if not r["reading"].get("text", "").strip():
|
|
89
|
+
why.append("the report has no reading of the paper")
|
|
90
|
+
t = [r["reading"].get("locked_at"), r["comparison"].get("decided_at"),
|
|
91
|
+
r["author_review"].get("shown_at"), r.get("submitted_at")]
|
|
92
|
+
if None in t or t != sorted(t):
|
|
93
|
+
why.append("the steps are not recorded in order (reading, verdict, author's review)")
|
|
94
|
+
rid = rev.get("id", "")
|
|
95
|
+
if not rid or rid in cfg.statement_authors or rev.get("name") in cfg.statement_authors:
|
|
96
|
+
why.append(f"reviewer {rid!r} is an author of the statements, or unnamed")
|
|
97
|
+
if rev.get("kind") == "model" and rev.get("same_family_as_author") != "no" \
|
|
98
|
+
and not accept_same_family:
|
|
99
|
+
why.append("a model reviewer of the same family as the author, or of unknown family "
|
|
100
|
+
"(pass --accept-same-family to accept it)")
|
|
101
|
+
if not re.fullmatch(r"\S+", rid):
|
|
102
|
+
why.append(f"reviewer id {rid!r} has spaces")
|
|
103
|
+
return why
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def marker(r: dict, cur: dict) -> str:
|
|
107
|
+
return (f"<!-- {r['unit']['kind']}-review: {r['unit']['name']} {cur['legacy_hash']} "
|
|
108
|
+
f"by={r['reviewer']['id']} -->")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def report_text(r: dict, cur: dict) -> str:
|
|
112
|
+
"""The report as the protocol's text block."""
|
|
113
|
+
u, p, rv = r["unit"], r["pins"], r["reviewer"]
|
|
114
|
+
ms = "; ".join(f"{k} {v}" for k, v in (p.get("manuscripts") or {}).items()) or "—"
|
|
115
|
+
return "\n".join([
|
|
116
|
+
f"### {', '.join(u['claims']) or u['id']}: {u['name']}",
|
|
117
|
+
f"Pins: manuscript {ms}; project {p.get('project_commit')}; "
|
|
118
|
+
f"hash {cur['legacy_hash']} ({p.get('fingerprint')})",
|
|
119
|
+
f"Reviewer: {rv['id']} ({rv['kind']}{', ' + rv['model'] if rv.get('model') else ''}, "
|
|
120
|
+
f"self-declared); {r['submitted_at']}; same model family as the author: "
|
|
121
|
+
f"{rv.get('same_family_as_author')}",
|
|
122
|
+
f"Phase 1 specification (locked {r['reading']['locked_at']}):", r["reading"]["text"],
|
|
123
|
+
f"Comparison (verdict recorded {r['comparison']['decided_at']}):",
|
|
124
|
+
r["comparison"]["text"],
|
|
125
|
+
f"Witness: {r['comparison'].get('witness') or '—'}",
|
|
126
|
+
f"Verdict: {r['verdict']['value']}"
|
|
127
|
+
+ (f" — {r['verdict']['detail']}" if r['verdict'].get('detail') else ""),
|
|
128
|
+
f"Disagreements with the author's review (shown {r['author_review']['shown_at']}): "
|
|
129
|
+
f"{r['author_review'].get('disagreements') or 'none'}", ""])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def target_file(cfg: Config, r: dict, reviews) -> tuple[Path, int | None]:
|
|
133
|
+
"""Where the marker goes: after the declaration's last marker, else at the end of the
|
|
134
|
+
paper's review file (specs) or of the definitions' review file."""
|
|
135
|
+
u = r["unit"]
|
|
136
|
+
hist = history(reviews, u["kind"], u["name"])
|
|
137
|
+
if hist:
|
|
138
|
+
return (cfg.root / hist[-1].file).resolve(), hist[-1].line
|
|
139
|
+
files = review_files(cfg.reviews)
|
|
140
|
+
if not files:
|
|
141
|
+
raise PromoteError("the configuration names no review file")
|
|
142
|
+
want = "definitions.md" if u["kind"] == "def" else f"{(u.get('papers') or ['?'])[0]}.md"
|
|
143
|
+
return next((f for f in files if f.name == want), files[-1]), None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def insert_after(path: Path, line: int | None, lines: list[str]):
|
|
147
|
+
txt = path.read_text(encoding="utf-8").split("\n")
|
|
148
|
+
if line is None:
|
|
149
|
+
while txt and not txt[-1].strip():
|
|
150
|
+
txt.pop()
|
|
151
|
+
txt += [""] + lines + [""]
|
|
152
|
+
else:
|
|
153
|
+
indent = re.match(r"\s*", txt[line - 1]).group(0)
|
|
154
|
+
txt[line:line] = [indent + l for l in lines]
|
|
155
|
+
path.write_text("\n".join(txt), encoding="utf-8")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def reports_dir(cfg: Config) -> Path:
|
|
159
|
+
first = next((p for p in cfg.reviews if p.is_dir()), cfg.reviews[0].parent if cfg.reviews
|
|
160
|
+
else cfg.root)
|
|
161
|
+
return first / "reports"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def plan(cfg: Config, reports: list[dict], accept_same_family: bool = False):
|
|
165
|
+
"""For each report: (report, current record, reasons it is refused)."""
|
|
166
|
+
body = latest_record(cfg)["body"]
|
|
167
|
+
from .pipeline import lean_inputs_digest
|
|
168
|
+
if body["provenance"].get("lean_inputs_sha256") != lean_inputs_digest(cfg):
|
|
169
|
+
raise PromoteError("the Lean project changed since the last build: run `prooflens build` "
|
|
170
|
+
"first, so the reports are checked against the current declarations")
|
|
171
|
+
lean = body.get("lean") or {}
|
|
172
|
+
out = []
|
|
173
|
+
for r in reports:
|
|
174
|
+
u = r.get("unit", {})
|
|
175
|
+
cur = (lean.get("specs" if u.get("kind") == "spec" else "decls", {}) or {}).get(
|
|
176
|
+
u.get("name"))
|
|
177
|
+
out.append((r, cur, check_report(r, body, cfg, accept_same_family)))
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def apply(cfg: Config, accepted: list[tuple[dict, dict]]) -> list[dict]:
|
|
182
|
+
"""Archive each report and write its marker."""
|
|
183
|
+
reviews, _ = load_reviews(cfg.reviews, cfg.ai_reviewers, cfg.root)
|
|
184
|
+
rdir = reports_dir(cfg)
|
|
185
|
+
rdir.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
done = []
|
|
187
|
+
for r, cur in accepted:
|
|
188
|
+
stem = re.sub(r"[^\w.-]+", "_", f"{r['unit']['name']}--{r['reviewer']['id']}--"
|
|
189
|
+
f"{r['submitted_at']}")
|
|
190
|
+
(rdir / f"{stem}.json").write_text(json.dumps(r, indent=1, ensure_ascii=False) + "\n",
|
|
191
|
+
encoding="utf-8")
|
|
192
|
+
(rdir / f"{stem}.md").write_text(report_text(r, cur), encoding="utf-8")
|
|
193
|
+
path, line = target_file(cfg, r, reviews)
|
|
194
|
+
rel = Path(rdir / f"{stem}.md").resolve()
|
|
195
|
+
try:
|
|
196
|
+
rel = rel.relative_to(path.parent.resolve())
|
|
197
|
+
except ValueError:
|
|
198
|
+
pass
|
|
199
|
+
mk = marker(r, cur)
|
|
200
|
+
insert_after(path, line, [f"Review by {r['reviewer']['id']}: {r['verdict']['value']} "
|
|
201
|
+
f"(report `{rel.as_posix()}`).", mk])
|
|
202
|
+
reviews, _ = load_reviews(cfg.reviews, cfg.ai_reviewers, cfg.root)
|
|
203
|
+
done.append({"name": r["unit"]["name"], "file": str(path), "marker": mk})
|
|
204
|
+
return done
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The review queue: what a reviewer can review, in a suggested order.
|
|
2
|
+
|
|
3
|
+
A unit of review is a locked proposition (`spec`), a definition its meaning depends on (`def`), or,
|
|
4
|
+
in the zero-configuration view, a confirmed link between a claim and a declaration (`link`). Each
|
|
5
|
+
unit carries the fingerprints it would be reviewed at, its current review state, the author's
|
|
6
|
+
review (shown to the reviewer only after their own verdict) and why it sits where it does in the
|
|
7
|
+
order. A unit is reviewed once per fingerprint, however many claims depend on it.
|
|
8
|
+
|
|
9
|
+
The order is a suggestion: less effort first, with a preference for units that many claims depend
|
|
10
|
+
on. Reviewing a definition settles that definition only; no claim is approved by it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
SCHEMA = "prooflens/review-queue@1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def section_of(number: str, location: str | None = None) -> str:
|
|
22
|
+
"""The section a claim sits in, from its printed number: `Theorem 3.1`, `(4.2)`, `§8.1.3` and
|
|
23
|
+
`App A.3` give `3`, `4`, `8` and `A`."""
|
|
24
|
+
m = re.search(r"(?:App(?:endix)?\.?\s*|§\s*|\(|\b)([A-Z]|\d+)(?=[.\d)])", number or "")
|
|
25
|
+
if m:
|
|
26
|
+
return m.group(1)
|
|
27
|
+
m = re.search(r"\b([A-Z]|\d+)\b", number or "")
|
|
28
|
+
return m.group(1) if m else "—"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _text(seg) -> str:
|
|
32
|
+
return (seg or {}).get("text", "") if isinstance(seg, dict) else str(seg or "")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _effort_spec(st: dict, unreviewed: int) -> float:
|
|
36
|
+
binders = len(st.get("binders") or [])
|
|
37
|
+
size = len(_text(st.get("conclusion"))) + sum(len(_text(b.get("type")))
|
|
38
|
+
for b in st.get("binders") or [])
|
|
39
|
+
return binders + size / 200 + unreviewed / 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _effort_def(rec: dict) -> float:
|
|
43
|
+
return 1 + len(_text(rec.get("signature"))) / 150 + (2 if rec.get("value") else 0)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _size(effort: float) -> str:
|
|
47
|
+
return "Short" if effort < 4 else "Medium" if effort < 10 else "Long"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _author_text(review: dict | None) -> list[dict]:
|
|
51
|
+
return [{"by": r.get("by"), "text": r.get("text", ""), "file": r.get("file"),
|
|
52
|
+
"line": r.get("line")} for r in (review or {}).get("current", [])]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def assurance_queue(index: dict, chunks: dict, store: dict, authors: list[str]) -> dict:
|
|
56
|
+
"""The queue of a full assessment: every locked proposition with a statement, and every
|
|
57
|
+
definition in their closures."""
|
|
58
|
+
paper_of = {c["id"]: c["paper"] for c in index["claims"]}
|
|
59
|
+
by_name = {r["name"]: r for r in store.values()}
|
|
60
|
+
keys = {r["name"]: k for k, r in store.items()}
|
|
61
|
+
used_by: dict[str, set] = {}
|
|
62
|
+
specs: dict[str, dict] = {}
|
|
63
|
+
unspecified = []
|
|
64
|
+
for c in index["claims"]:
|
|
65
|
+
ch = chunks[c["file"]]
|
|
66
|
+
if c["class"] == "unspecified":
|
|
67
|
+
unspecified.append({"id": c["id"], "paper": c["paper"], "number": c["number"]})
|
|
68
|
+
for n in ch.get("decls", {}):
|
|
69
|
+
used_by.setdefault(n, set()).add(c["id"])
|
|
70
|
+
for cl in ch.get("clauses", []):
|
|
71
|
+
st = cl.get("statement")
|
|
72
|
+
if not cl.get("spec") or not st:
|
|
73
|
+
continue
|
|
74
|
+
u = specs.setdefault(cl["spec"], {
|
|
75
|
+
"id": "spec:" + cl["spec"], "kind": "spec", "name": cl["spec"],
|
|
76
|
+
"paper": c["paper"], "papers": [c["paper"]],
|
|
77
|
+
"section": section_of(c["number"]), "claims": [], "clauses": [],
|
|
78
|
+
"legacy_hash": st.get("legacy_hash"), "fingerprint": st.get("fingerprint"),
|
|
79
|
+
"state": (cl.get("spec_review") or {}).get("state", "pending"),
|
|
80
|
+
"author_review": _author_text(cl.get("spec_review")),
|
|
81
|
+
"witness": cl.get("witness"), "refute": cl.get("refute"),
|
|
82
|
+
"unreviewed": sum(1 for r in (cl.get("def_reviews") or {}).values()
|
|
83
|
+
if r["state"] != "reviewed"),
|
|
84
|
+
"effort": 0.0})
|
|
85
|
+
u["claims"].append({"id": c["id"], "number": c["number"], "title": c["title"],
|
|
86
|
+
"file": c["file"], "status": c["status"]})
|
|
87
|
+
u["clauses"].append(cl["name"])
|
|
88
|
+
u["effort"] = round(_effort_spec(st, u["unreviewed"]), 2)
|
|
89
|
+
units = list(specs.values())
|
|
90
|
+
for u in units:
|
|
91
|
+
u["reason"] = (f"{_size(u['effort'])} statement"
|
|
92
|
+
+ (f"; its meaning uses {u['unreviewed']} definition(s) not yet "
|
|
93
|
+
"independently reviewed" if u["unreviewed"] else ""))
|
|
94
|
+
for name, rec in sorted(by_name.items()):
|
|
95
|
+
if rec.get("review") is None:
|
|
96
|
+
continue
|
|
97
|
+
users = sorted(used_by.get(name, ()))
|
|
98
|
+
effort = round(_effort_def(rec), 2)
|
|
99
|
+
units.append({
|
|
100
|
+
"id": "def:" + name, "kind": "def", "name": name, "key": keys.get(name),
|
|
101
|
+
"paper": None, "papers": sorted({paper_of[i] for i in users}),
|
|
102
|
+
"section": rec.get("module", "").rsplit(".", 1)[0] or "—",
|
|
103
|
+
"claims": [{"id": i} for i in users], "legacy_hash": rec.get("legacy_hash"),
|
|
104
|
+
"fingerprint": rec.get("fingerprint"), "state": rec["review"]["state"],
|
|
105
|
+
"author_review": _author_text(rec["review"]), "effort": effort,
|
|
106
|
+
"reason": f"{_size(effort)} definition; used by {len(users)} claim(s)"})
|
|
107
|
+
for u in units:
|
|
108
|
+
u["order"] = round(u["effort"] - math.log2(1 + len(u["claims"])), 3)
|
|
109
|
+
units.sort(key=lambda u: (u["state"] == "reviewed", u["order"], u["id"]))
|
|
110
|
+
return {"schema": SCHEMA, "release": index["release"], "mode": "assurance",
|
|
111
|
+
"authors": sorted(authors), "units": units, "needs_statement": unspecified}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def simple_queue(index: dict, chunks: dict) -> dict:
|
|
115
|
+
"""The queue of the zero-configuration view: every claim with links, reviewed as one unit
|
|
116
|
+
(do these declarations say what the claim says?)."""
|
|
117
|
+
units = []
|
|
118
|
+
for c in index["claims"]:
|
|
119
|
+
ch = chunks[c["file"]]
|
|
120
|
+
links = ch.get("links") or []
|
|
121
|
+
if not links:
|
|
122
|
+
continue
|
|
123
|
+
size = sum(len(_text((l.get("statement") or {}).get("conclusion"))) +
|
|
124
|
+
sum(len(_text(b.get("type"))) for b in (l.get("statement") or {})
|
|
125
|
+
.get("binders", [])) for l in links)
|
|
126
|
+
effort = round(1 + size / 200, 2)
|
|
127
|
+
units.append({
|
|
128
|
+
"id": "link:" + ch["key"], "kind": "link", "key": ch["key"],
|
|
129
|
+
"name": ", ".join(l["name"] for l in links), "lean": [l["name"] for l in links],
|
|
130
|
+
"fingerprints": {l["name"]: l.get("fingerprint") for l in links},
|
|
131
|
+
"paper": c["paper"], "papers": [c["paper"]], "section": section_of(c["number"]),
|
|
132
|
+
"claims": [{"id": c["id"], "number": c["number"], "title": c["title"],
|
|
133
|
+
"file": c["file"], "status": c["status"]}],
|
|
134
|
+
"state": ch.get("review_state") or "pending", "author_review": [],
|
|
135
|
+
"effort": effort, "order": effort, "reason": f"{_size(effort)} statement"})
|
|
136
|
+
units.sort(key=lambda u: (u["state"] == "reviewed", u["order"], u["id"]))
|
|
137
|
+
return {"schema": SCHEMA, "release": index["release"], "mode": "simple", "authors": [],
|
|
138
|
+
"units": units, "needs_statement": []}
|
prooflens/reviews.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Review records: markers in Markdown files, and dismissals of advisory findings.
|
|
2
|
+
|
|
3
|
+
A marker `<!-- spec-review: <name> <hash> by=<reviewer> [kind=human|ai] -->` records a review of
|
|
4
|
+
the proposition `<name>` at `<hash>`; `def-review` does the same for a definition. The hash is
|
|
5
|
+
either the legacy 128-bit structural hash or a `pl-fp-v1:` fingerprint. The last marker of a
|
|
6
|
+
declaration (files in order, then position) is its record; it counts only while its hash is
|
|
7
|
+
current. The text of the review is the section
|
|
8
|
+
the marker closes.
|
|
9
|
+
|
|
10
|
+
A marker is a self-declared record: nothing in the file authenticates who wrote it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fnmatch
|
|
16
|
+
import os
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .util import sha256_file
|
|
23
|
+
|
|
24
|
+
MARKER = re.compile(r"<!--\s*(spec|def)-review:\s*(\S+)\s+(\S+)((?:\s+[\w-]+=\S+)*)\s*-->")
|
|
25
|
+
HEADING = re.compile(r"^#{1,6} ", re.M)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Review:
|
|
30
|
+
target: str # "spec" | "def"
|
|
31
|
+
name: str
|
|
32
|
+
hash: str
|
|
33
|
+
by: str
|
|
34
|
+
kind: str # "human" | "ai" | "unknown"
|
|
35
|
+
provenance: str # "self-declared"
|
|
36
|
+
file: str
|
|
37
|
+
line: int
|
|
38
|
+
text: str
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> dict:
|
|
41
|
+
return dict(self.__dict__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def reviewer_kind(by: str, explicit: str | None, ai_patterns: list[str]) -> str:
|
|
45
|
+
if explicit in ("human", "ai"):
|
|
46
|
+
return explicit
|
|
47
|
+
if any(fnmatch.fnmatch(by, p) for p in ai_patterns):
|
|
48
|
+
return "ai"
|
|
49
|
+
return "unknown"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_file(path: Path, ai_patterns: list[str], rel_to: Path) -> list[Review]:
|
|
53
|
+
txt = path.read_text(encoding="utf-8")
|
|
54
|
+
out, prev = [], 0
|
|
55
|
+
for m in MARKER.finditer(txt):
|
|
56
|
+
attrs = dict(a.split("=", 1) for a in m.group(4).split())
|
|
57
|
+
start = prev
|
|
58
|
+
heads = [h.start() for h in HEADING.finditer(txt, prev, m.start())]
|
|
59
|
+
if heads:
|
|
60
|
+
start = heads[-1]
|
|
61
|
+
body = MARKER.sub("", txt[start:m.start()]).strip()
|
|
62
|
+
body = re.sub(r"\A#{1,6} [^\n]*\n", "", body).strip()
|
|
63
|
+
by = attrs.get("by", "unknown")
|
|
64
|
+
out.append(Review(
|
|
65
|
+
target=m.group(1), name=m.group(2), hash=m.group(3), by=by,
|
|
66
|
+
kind=reviewer_kind(by, attrs.get("kind"), ai_patterns), provenance="self-declared",
|
|
67
|
+
file=os.path.relpath(path, rel_to),
|
|
68
|
+
line=txt.count("\n", 0, m.start()) + 1, text=body))
|
|
69
|
+
prev = m.end()
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def review_files(entries: list[Path]) -> list[Path]:
|
|
74
|
+
"""The review files named by the configuration: a file, or every `.md` file of a directory
|
|
75
|
+
in name order."""
|
|
76
|
+
out = []
|
|
77
|
+
for e in entries:
|
|
78
|
+
if e.is_dir():
|
|
79
|
+
out += sorted(p for p in e.iterdir() if p.suffix == ".md")
|
|
80
|
+
elif e.exists():
|
|
81
|
+
out.append(e)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_reviews(entries: list[Path], ai_patterns: list[str],
|
|
86
|
+
root: Path) -> tuple[list[Review], dict]:
|
|
87
|
+
"""Every marker, in file order and then position."""
|
|
88
|
+
reviews, hashes = [], {}
|
|
89
|
+
for f in review_files(entries):
|
|
90
|
+
reviews += parse_file(f, ai_patterns, root)
|
|
91
|
+
hashes[os.path.relpath(f, root)] = sha256_file(f)
|
|
92
|
+
return reviews, hashes
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def history(reviews: list[Review], target: str, name: str) -> list[Review]:
|
|
96
|
+
"""The markers of a declaration; the last one is its current record."""
|
|
97
|
+
return [r for r in reviews if r.target == target and r.name == name]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_dismissals(path: Path | None) -> list[dict]:
|
|
101
|
+
"""Dismissals of advisory findings: `[{code, subject, fingerprint, rationale, by}]`. A
|
|
102
|
+
dismissal holds only while `fingerprint` is the subject's current fingerprint."""
|
|
103
|
+
if path is None or not path.exists():
|
|
104
|
+
return []
|
|
105
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
prooflens/schemas.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""JSON Schemas of the inventory, the evidence record and the presentation manifest."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from functools import cache
|
|
7
|
+
from importlib import resources
|
|
8
|
+
|
|
9
|
+
import jsonschema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cache
|
|
13
|
+
def schema(name: str) -> dict:
|
|
14
|
+
return json.loads(resources.files("prooflens").joinpath(f"data/schema/{name}.schema.json")
|
|
15
|
+
.read_text())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def validate(obj, name: str) -> None:
|
|
19
|
+
jsonschema.validate(obj, schema(name))
|
prooflens/server.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The local server of `prooflens view`: the site, and saving links into the repository.
|
|
2
|
+
|
|
3
|
+
It listens on 127.0.0.1 only. `GET /api/status` tells the reader it may edit; `POST /api/link`
|
|
4
|
+
with `{"key", "lean": [names] | null, "reject": [names]}` records a link (or removes it, or
|
|
5
|
+
rejects suggestions) in `prooflens.links.json` and rebuilds the view.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import http.server
|
|
12
|
+
import json
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def serve(site: Path, port: int, on_link=None, open_browser: bool = True):
|
|
18
|
+
lock = threading.Lock()
|
|
19
|
+
|
|
20
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
21
|
+
def log_message(self, *a):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def _json(self, code: int, obj):
|
|
25
|
+
b = json.dumps(obj).encode()
|
|
26
|
+
self.send_response(code)
|
|
27
|
+
self.send_header("Content-Type", "application/json")
|
|
28
|
+
self.send_header("Content-Length", str(len(b)))
|
|
29
|
+
self.end_headers()
|
|
30
|
+
self.wfile.write(b)
|
|
31
|
+
|
|
32
|
+
def do_GET(self):
|
|
33
|
+
if self.path == "/api/status":
|
|
34
|
+
return self._json(200, {"editable": on_link is not None})
|
|
35
|
+
return super().do_GET()
|
|
36
|
+
|
|
37
|
+
def do_POST(self):
|
|
38
|
+
if self.path != "/api/link" or on_link is None:
|
|
39
|
+
return self._json(404, {"error": "not found"})
|
|
40
|
+
try:
|
|
41
|
+
n = int(self.headers.get("Content-Length", "0"))
|
|
42
|
+
req = json.loads(self.rfile.read(n) or b"{}")
|
|
43
|
+
with lock:
|
|
44
|
+
on_link(req)
|
|
45
|
+
return self._json(200, {"ok": True})
|
|
46
|
+
except Exception as e: # the reader shows the message
|
|
47
|
+
return self._json(400, {"error": str(e)})
|
|
48
|
+
|
|
49
|
+
handler = functools.partial(Handler, directory=str(site))
|
|
50
|
+
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
51
|
+
url = f"http://127.0.0.1:{httpd.server_address[1]}/"
|
|
52
|
+
print(f"ProofLens: {url} (Ctrl-C to stop)")
|
|
53
|
+
if open_browser:
|
|
54
|
+
try:
|
|
55
|
+
import webbrowser
|
|
56
|
+
webbrowser.open(url)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
try:
|
|
60
|
+
httpd.serve_forever()
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
pass
|
|
63
|
+
return url
|