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/export.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""The public export.
|
|
2
|
+
|
|
3
|
+
The export is assembled from fields each paper's `publish` settings allow; it is never a copy of
|
|
4
|
+
the private preview with things removed. After assembly every output file is scanned against the
|
|
5
|
+
private inputs (manuscript sources, Lean sources, PDFs not approved), and any match blocks the
|
|
6
|
+
export.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import evidence as ev
|
|
16
|
+
from . import manifest as mf
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .util import read_json, sha256_bytes, sha256_file, write_json
|
|
19
|
+
|
|
20
|
+
MIN_LINE = 30 # a private source line at least this long must not occur in the export
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExportError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pick(d: dict | None, keys: list[str]) -> dict | None:
|
|
28
|
+
if d is None:
|
|
29
|
+
return None
|
|
30
|
+
return {k: d[k] for k in keys if k in d}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SEG_KEYS = ["text", "segments"]
|
|
34
|
+
DECL_KEYS_LEAN = ["name", "kind", "module", "range", "doc", "signature", "value", "uses",
|
|
35
|
+
"fingerprint", "legacy_hash"]
|
|
36
|
+
DECL_KEYS_MIN = ["name", "kind", "module", "fingerprint", "legacy_hash"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def public_claim(chunk: dict, pub) -> dict:
|
|
40
|
+
"""The fields of a claim passport that `pub` allows."""
|
|
41
|
+
out = _pick(chunk, ["schema", "release", "id", "paper", "number", "title", "kind", "scope",
|
|
42
|
+
"scope_note", "scope_reviewed", "status", "class", "source_matches",
|
|
43
|
+
"source_sha256", "blockers"])
|
|
44
|
+
out["location"] = chunk.get("location") if pub.source_text else None
|
|
45
|
+
out["statement_tex"] = chunk.get("statement_tex") if pub.source_text else None
|
|
46
|
+
a = chunk.get("anchor")
|
|
47
|
+
out["anchor"] = _pick(a, ["id", "env", "number", "label", "begin", "end", "source_sha256"] +
|
|
48
|
+
(["file", "line"] if pub.source_text else [])) if a else None
|
|
49
|
+
clauses = []
|
|
50
|
+
for cl in chunk["clauses"]:
|
|
51
|
+
c = _pick(cl, ["name", "spec", "accept", "refute", "corrects", "witness", "defs", "state",
|
|
52
|
+
"review", "checks", "ladder"])
|
|
53
|
+
c["regimes"] = [_pick(r, ["name", "description", "witness", "discharged"] +
|
|
54
|
+
(["type"] if pub.lean else [])) for r in cl.get("regimes", [])]
|
|
55
|
+
if pub.lean and cl.get("statement"):
|
|
56
|
+
c["statement"] = cl["statement"]
|
|
57
|
+
c["proofs"] = cl.get("proofs")
|
|
58
|
+
c["witness_record"] = cl.get("witness_record")
|
|
59
|
+
else:
|
|
60
|
+
c["statement"] = None
|
|
61
|
+
if "spec_review" in cl:
|
|
62
|
+
c["spec_review"] = _reviews(cl["spec_review"], pub.reviews)
|
|
63
|
+
if "def_reviews" in cl:
|
|
64
|
+
c["def_reviews"] = {n: _pick(r, ["state", "by", "kind"])
|
|
65
|
+
for n, r in cl["def_reviews"].items()}
|
|
66
|
+
if not pub.source_text:
|
|
67
|
+
for rung in c["ladder"]:
|
|
68
|
+
if rung["rung"] == "source" and rung["state"] == "pass":
|
|
69
|
+
rung["detail"] = "located in the compiled paper"
|
|
70
|
+
clauses.append(c)
|
|
71
|
+
out["clauses"] = clauses
|
|
72
|
+
out["decls"] = dict(chunk.get("decls", {}))
|
|
73
|
+
out["findings"] = [_pick(f, ["id", "tier", "code", "subject", "message", "trigger",
|
|
74
|
+
"signal", "dismissed", "dismissal_stale"])
|
|
75
|
+
for f in chunk["findings"]]
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _reviews(r: dict | None, with_text: bool) -> dict | None:
|
|
80
|
+
if r is None:
|
|
81
|
+
return None
|
|
82
|
+
keys = ["target", "name", "hash", "by", "kind", "provenance"] + (["text"] if with_text else [])
|
|
83
|
+
return {"state": r["state"], "current": [_pick(x, keys) for x in r.get("current", [])],
|
|
84
|
+
"stale": [_pick(x, keys) for x in r.get("stale", [])]}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def private_inputs(cfg: Config) -> tuple[list[Path], set[str]]:
|
|
88
|
+
"""Text files whose lines must not appear, and file hashes that must not appear."""
|
|
89
|
+
texts, hashes = [], set()
|
|
90
|
+
for paper in cfg.papers:
|
|
91
|
+
src = cfg.out_dir / "sources" / paper.id
|
|
92
|
+
if not paper.publish.source_text:
|
|
93
|
+
texts += [p for p in src.rglob("*") if p.suffix in (".tex", ".bib", ".sty", ".cls",
|
|
94
|
+
".bbl", ".md")]
|
|
95
|
+
if not paper.publish.pdf:
|
|
96
|
+
hashes |= {sha256_file(p) for p in src.rglob("*.pdf")}
|
|
97
|
+
built = cfg.out_dir / "site" / "data" / "papers" / f"{paper.id}.pdf"
|
|
98
|
+
if built.exists():
|
|
99
|
+
hashes.add(sha256_file(built))
|
|
100
|
+
if not any(p.publish.lean for p in cfg.papers):
|
|
101
|
+
texts += [p for p in cfg.lean_root.rglob("*.lean") if ".lake" not in p.parts]
|
|
102
|
+
return texts, hashes
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def scan(out: Path, texts: list[Path], hashes: set[str]) -> list[str]:
|
|
106
|
+
"""Every leak of a private input into the export."""
|
|
107
|
+
lines = {}
|
|
108
|
+
for t in texts:
|
|
109
|
+
for ln in t.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
110
|
+
s = ln.strip()
|
|
111
|
+
if len(s) >= MIN_LINE and not s.startswith(("%", "--", "/-", "import ")):
|
|
112
|
+
lines.setdefault(s, t)
|
|
113
|
+
leaks = []
|
|
114
|
+
for f in sorted(out.rglob("*")):
|
|
115
|
+
if not f.is_file():
|
|
116
|
+
continue
|
|
117
|
+
b = f.read_bytes()
|
|
118
|
+
if sha256_bytes(b) in hashes:
|
|
119
|
+
leaks.append(f"{f.relative_to(out)}: is a private PDF")
|
|
120
|
+
continue
|
|
121
|
+
if f.suffix in (".json", ".html", ".js", ".css", ".txt", ".md", ".svg"):
|
|
122
|
+
text = b.decode("utf-8", errors="replace")
|
|
123
|
+
# JSON escapes; compare against the decoded strings as well.
|
|
124
|
+
hay = text
|
|
125
|
+
if f.suffix == ".json":
|
|
126
|
+
try:
|
|
127
|
+
hay = text + "\n" + "\n".join(_strings(json.loads(text)))
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
pass
|
|
130
|
+
for s, src in lines.items():
|
|
131
|
+
if s in hay:
|
|
132
|
+
leaks.append(f"{f.relative_to(out)}: contains a line of {src.name}")
|
|
133
|
+
break
|
|
134
|
+
return leaks
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _strings(obj):
|
|
138
|
+
if isinstance(obj, str):
|
|
139
|
+
yield obj
|
|
140
|
+
elif isinstance(obj, dict):
|
|
141
|
+
for v in obj.values():
|
|
142
|
+
yield from _strings(v)
|
|
143
|
+
elif isinstance(obj, list):
|
|
144
|
+
for v in obj:
|
|
145
|
+
yield from _strings(v)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def export(cfg: Config, out: Path, release: str | None = None,
|
|
149
|
+
web_dist: Path | None = None) -> dict:
|
|
150
|
+
rels = sorted((cfg.out_dir / "releases").glob("*.json"), key=lambda p: p.stat().st_mtime)
|
|
151
|
+
if not rels:
|
|
152
|
+
raise ExportError("no release: run `prooflens build` first")
|
|
153
|
+
relrec = read_json(cfg.out_dir / "releases" / f"{release}.json") if release \
|
|
154
|
+
else read_json(rels[-1])
|
|
155
|
+
record = ev.load(cfg.out_dir, relrec["evidence"])
|
|
156
|
+
index, chunks, store = mf.build(record, relrec["release"])
|
|
157
|
+
pubs = {p.id: p.publish for p in cfg.papers}
|
|
158
|
+
|
|
159
|
+
if out.exists():
|
|
160
|
+
shutil.rmtree(out)
|
|
161
|
+
if web_dist and web_dist.exists():
|
|
162
|
+
shutil.copytree(web_dist, out)
|
|
163
|
+
data = out / "data"
|
|
164
|
+
pidx = _pick(index, ["schema", "release", "evidence", "created", "tool", "lean", "summary",
|
|
165
|
+
"classes", "gates", "search"])
|
|
166
|
+
pidx["issues"], pidx["site_url"] = cfg.issues, cfg.site_url
|
|
167
|
+
pidx["project"] = _pick(index["project"], ["name", "commit", "dirty", "toolchain",
|
|
168
|
+
"repository", "repository_private",
|
|
169
|
+
"source_prefix", "source_roots",
|
|
170
|
+
"lake_manifest_sha256", "dependencies"])
|
|
171
|
+
from .pipeline import source_info
|
|
172
|
+
pidx["project"].update(source_info(cfg))
|
|
173
|
+
pidx["papers"] = []
|
|
174
|
+
for p in index["papers"]:
|
|
175
|
+
pub = pubs[p["id"]]
|
|
176
|
+
q = _pick(p, ["id", "title", "mode", "revision", "pdf_sha256", "page_size", "note"])
|
|
177
|
+
q["root"] = p.get("root") if pub.source_text else None
|
|
178
|
+
q["pdf"] = p["pdf"] if pub.pdf else None
|
|
179
|
+
pidx["papers"].append(q)
|
|
180
|
+
if pub.pdf and p["pdf"]:
|
|
181
|
+
src = cfg.out_dir / "site" / "data" / p["pdf"]
|
|
182
|
+
(data / "papers").mkdir(parents=True, exist_ok=True)
|
|
183
|
+
shutil.copyfile(src, data / p["pdf"])
|
|
184
|
+
pidx["findings"] = {"total": index["findings"]["total"],
|
|
185
|
+
"general": [_pick(f, ["id", "tier", "code", "subject", "message"])
|
|
186
|
+
for f in index["findings"]["general"]]}
|
|
187
|
+
pidx["claims"] = [_pick(c, ["id", "paper", "number", "title", "kind", "scope", "status",
|
|
188
|
+
"class", "anchor", "counts", "file"]) for c in index["claims"]]
|
|
189
|
+
write_json(data / "index.json", pidx, indent=None)
|
|
190
|
+
for name, chunk in chunks.items():
|
|
191
|
+
write_json(data / "claims" / name, public_claim(chunk, pubs[chunk["paper"]]),
|
|
192
|
+
indent=None)
|
|
193
|
+
# Declarations are shared between papers: their Lean text and review texts are exported only
|
|
194
|
+
# when every paper allows it. A redacted record is a different record, so it gets its own key.
|
|
195
|
+
lean_ok = all(p.publish.lean for p in cfg.papers)
|
|
196
|
+
rev_ok = all(p.publish.reviews for p in cfg.papers)
|
|
197
|
+
rekey = {}
|
|
198
|
+
for key, d in store.items():
|
|
199
|
+
q = _pick(d, DECL_KEYS_LEAN if lean_ok else DECL_KEYS_MIN)
|
|
200
|
+
q["review"] = _reviews(d.get("review"), rev_ok)
|
|
201
|
+
new = mf.record_key(q)
|
|
202
|
+
rekey[key] = new
|
|
203
|
+
write_json(data / "decls" / f"{new}.json", q, indent=None)
|
|
204
|
+
for name in chunks:
|
|
205
|
+
path = data / "claims" / name
|
|
206
|
+
c = json.loads(path.read_text(encoding="utf-8"))
|
|
207
|
+
c["decls"] = {n: rekey[k] for n, k in c["decls"].items()}
|
|
208
|
+
write_json(path, c, indent=None)
|
|
209
|
+
# The search index, with only what each paper allows.
|
|
210
|
+
srch = mf.search_index(index, chunks)
|
|
211
|
+
pub_search = {"claims": [], "decls": srch["decls"] if lean_ok else []}
|
|
212
|
+
for c in srch["claims"]:
|
|
213
|
+
pub = pubs[c["paper"]]
|
|
214
|
+
pub_search["claims"].append({**c, "statement": c["statement"] if pub.source_text else "",
|
|
215
|
+
"lean": c["lean"] if pub.lean else []})
|
|
216
|
+
write_json(data / "search.json", pub_search, indent=None)
|
|
217
|
+
from . import audit
|
|
218
|
+
arx = [p.source["arxiv"] for p in cfg.papers if p.source.get("arxiv")]
|
|
219
|
+
write_json(out / audit.NAME, audit.descriptor(
|
|
220
|
+
pidx, cfg.lean_root, author=cfg.author,
|
|
221
|
+
paper_url=f"https://arxiv.org/abs/{arx[0]}" if len(cfg.papers) == 1 and arx else None))
|
|
222
|
+
texts, hashes = private_inputs(cfg)
|
|
223
|
+
leaks = scan(out, texts, hashes)
|
|
224
|
+
if leaks:
|
|
225
|
+
shutil.rmtree(out)
|
|
226
|
+
raise ExportError("export blocked; private material found:\n " + "\n ".join(leaks))
|
|
227
|
+
return {"release": relrec["release"], "evidence": relrec["evidence"], "out": out}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Reading a project's `formalization.yaml` (the mathlib-initiative standard).
|
|
2
|
+
|
|
3
|
+
The file is the project's own account of itself: its sources, its main results, how it was made,
|
|
4
|
+
its review status, and an `alignment` between the source's statements and Lean declarations. The
|
|
5
|
+
alignment is free-form in the standard; the shape read here is its suggested one,
|
|
6
|
+
`statements: [{source, lean, module, status}]`, with `lean` a name or a list of names.
|
|
7
|
+
|
|
8
|
+
A mapping read from the file is a link the project's authors declared, shown as such; the
|
|
9
|
+
file's review status is the project's own description of its review, shown as such. Neither
|
|
10
|
+
changes what Lean is checked to establish.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
NAMES = ("formalization.yaml", "formalization.yml")
|
|
19
|
+
KINDS = ("theorem", "lemma", "proposition", "corollary", "definition", "remark", "example",
|
|
20
|
+
"conjecture", "claim", "assumption", "hypothesis", "convention")
|
|
21
|
+
REF = re.compile(r"\b(" + "|".join(KINDS) + r")\s+([A-Z]?\d+(?:\.\d+)*[a-z]?)", re.I)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find(root: Path) -> Path | None:
|
|
25
|
+
return next((root / n for n in NAMES if (root / n).is_file()), None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(root: Path) -> dict | None:
|
|
29
|
+
"""The project's formalization.yaml, parsed, or None. A file that cannot be read is
|
|
30
|
+
reported as `{"error": ...}`."""
|
|
31
|
+
p = find(root)
|
|
32
|
+
if p is None:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
import yaml
|
|
36
|
+
except ImportError:
|
|
37
|
+
return {"error": "formalization.yaml found, but PyYAML is not installed"}
|
|
38
|
+
try:
|
|
39
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
40
|
+
except Exception as e: # malformed YAML is the file's problem, not a crash
|
|
41
|
+
return {"error": f"formalization.yaml could not be read: {e}"}
|
|
42
|
+
return data if isinstance(data, dict) else {"error": "formalization.yaml is not a mapping"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _tokens(s: str) -> set[str]:
|
|
46
|
+
return {t for t in re.split(r"[^\w]+", (s or "").lower()) if len(t) > 2}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def statements(fy: dict) -> list[dict]:
|
|
50
|
+
"""The alignment's statements in the suggested shape: each with the claim it names
|
|
51
|
+
(`kind`, `number`), the words that say which source it is in, and its Lean names."""
|
|
52
|
+
al = fy.get("alignment") if isinstance(fy, dict) else None
|
|
53
|
+
rows = al.get("statements") if isinstance(al, dict) else al if isinstance(al, list) else None
|
|
54
|
+
out = []
|
|
55
|
+
for r in rows or []:
|
|
56
|
+
if not isinstance(r, dict):
|
|
57
|
+
continue
|
|
58
|
+
lean = r.get("lean") or r.get("declaration") or r.get("declarations")
|
|
59
|
+
names = [lean] if isinstance(lean, str) else [n for n in lean or [] if isinstance(n, str)]
|
|
60
|
+
src = str(r.get("source") or "")
|
|
61
|
+
m = REF.search(src)
|
|
62
|
+
if not names or not m:
|
|
63
|
+
continue
|
|
64
|
+
rest = src[m.end():]
|
|
65
|
+
out.append({"source": src, "kind": m.group(1).lower(), "number": m.group(2),
|
|
66
|
+
"hint": _tokens(rest), "lean": names, "status": r.get("status")})
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def formalized_sources(fy: dict) -> list[dict]:
|
|
71
|
+
return [s for s in fy.get("sources") or [] if isinstance(s, dict)
|
|
72
|
+
and s.get("relationship", "formalizes") == "formalizes"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def links_for(fy: dict, claim_kind: str, claim_number: str, paper_title: str) -> list[dict]:
|
|
76
|
+
"""The alignment's statements that name this claim of this paper. When the file formalizes
|
|
77
|
+
several sources, the paper is the source whose title it shares most words with, and a
|
|
78
|
+
statement must name it by a word only that source's title has ("(Euler)")."""
|
|
79
|
+
num = claim_number.split()[-1] if claim_number else ""
|
|
80
|
+
rows = [st for st in statements(fy) if st["kind"] == claim_kind.lower()
|
|
81
|
+
and st["number"] == num]
|
|
82
|
+
srcs = [_tokens(s.get("title", "")) for s in formalized_sources(fy)]
|
|
83
|
+
if len(srcs) <= 1 or not rows:
|
|
84
|
+
return rows
|
|
85
|
+
title = _tokens(paper_title)
|
|
86
|
+
best = max(range(len(srcs)), key=lambda i: len(srcs[i] & title))
|
|
87
|
+
if not srcs[best] & title:
|
|
88
|
+
return []
|
|
89
|
+
own = srcs[best] - set().union(*(t for i, t in enumerate(srcs) if i != best))
|
|
90
|
+
return [st for st in rows if st["hint"] & own]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def summary(fy: dict | None) -> dict | None:
|
|
94
|
+
"""What the reader shows about the file: the project's own review status and methods."""
|
|
95
|
+
if not fy:
|
|
96
|
+
return None
|
|
97
|
+
if "error" in fy:
|
|
98
|
+
return {"error": fy["error"]}
|
|
99
|
+
rv = fy.get("review") if isinstance(fy.get("review"), dict) else {}
|
|
100
|
+
auto = fy.get("automation") if isinstance(fy.get("automation"), dict) else {}
|
|
101
|
+
return {"review_status": rv.get("status"), "reviewers": rv.get("reviewers") or [],
|
|
102
|
+
"methods": [m.get("method") for m in auto.get("methods") or []
|
|
103
|
+
if isinstance(m, dict) and m.get("method")],
|
|
104
|
+
"sources": [{"title": s.get("title"), "id": s.get("id")}
|
|
105
|
+
for s in formalized_sources(fy)],
|
|
106
|
+
"version": fy.get("version")}
|
prooflens/manifest.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""The presentation manifest, derived from one evidence record.
|
|
2
|
+
|
|
3
|
+
`index.json` lists the papers and claims; `claims/<file>.json` holds one claim with everything
|
|
4
|
+
its passport shows. Nothing here decides a status: statuses and findings are copied from the
|
|
5
|
+
assessment in the evidence, and the evidence ladder is a fixed reading of the clause checks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections import Counter
|
|
12
|
+
|
|
13
|
+
from .assess import line_location, match_anchor
|
|
14
|
+
from .util import content_id, sha256_text
|
|
15
|
+
|
|
16
|
+
SCHEMA = "prooflens/manifest@0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def claim_file(cid: str) -> str:
|
|
20
|
+
return re.sub(r"[^A-Za-z0-9._-]", "_", cid) + "-" + sha256_text(cid)[:8] + ".json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def status_class(status: str) -> str:
|
|
24
|
+
"""A coarse class for colouring; the status text is always shown verbatim beside it."""
|
|
25
|
+
if status in ("formal: accepted", "definition: reviewed"):
|
|
26
|
+
return "accepted"
|
|
27
|
+
if status.startswith("formal: proved") or status == "definition: author-reviewed":
|
|
28
|
+
return "proved"
|
|
29
|
+
if status.startswith("refuted"):
|
|
30
|
+
return "refuted"
|
|
31
|
+
if status in ("formal: open", "definition: review pending"):
|
|
32
|
+
return "open"
|
|
33
|
+
if status.endswith("malformed"):
|
|
34
|
+
return "malformed"
|
|
35
|
+
if status.startswith("out of scope"):
|
|
36
|
+
return "out-of-scope"
|
|
37
|
+
return "unspecified"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def top_of(anchor: dict) -> tuple[int, float]:
|
|
41
|
+
"""Page and top of a located claim: a line height above the baseline of its first line when
|
|
42
|
+
that is recorded, else the position where the environment opened."""
|
|
43
|
+
if anchor.get("head"):
|
|
44
|
+
return anchor["head"][0], round(anchor["head"][2] - 11, 2)
|
|
45
|
+
return anchor["begin"][0], anchor["begin"][2]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ladder(cl: dict, anchor: dict | None, mode: str | None, src_ok: bool | None) -> list[dict]:
|
|
49
|
+
"""The rungs of the evidence ladder of one clause, each with its own state."""
|
|
50
|
+
ch = cl.get("checks", {})
|
|
51
|
+
proof = ch.get("accept") or ch.get("refute")
|
|
52
|
+
rungs = []
|
|
53
|
+
if cl["state"] == "unchecked":
|
|
54
|
+
rungs.append({"rung": "Lean", "state": "none",
|
|
55
|
+
"detail": "the Lean project was not checked in this build"})
|
|
56
|
+
elif cl.get("defs") and not cl.get("spec"):
|
|
57
|
+
rungs.append({"rung": "stated", "state": "pass" if cl["state"] == "stated" else "fail",
|
|
58
|
+
"detail": ", ".join(cl["defs"])})
|
|
59
|
+
else:
|
|
60
|
+
spec = ch.get("spec")
|
|
61
|
+
rungs.append({"rung": "locked statement",
|
|
62
|
+
"state": "none" if not cl.get("spec") else
|
|
63
|
+
("pass" if spec and spec["ok"] else "fail"),
|
|
64
|
+
"detail": cl.get("spec") or "no proposition written"})
|
|
65
|
+
if proof is None:
|
|
66
|
+
rungs.append({"rung": "exact statement", "state": "none", "detail": "no proof"})
|
|
67
|
+
else:
|
|
68
|
+
type_errs = [e for e in proof["errors"] if not e.startswith("axioms=")]
|
|
69
|
+
rungs.append({"rung": "exact statement",
|
|
70
|
+
"state": "pass" if not type_errs and not proof["sorry"] else "fail",
|
|
71
|
+
"detail": ("a theorem has exactly the negation as its type"
|
|
72
|
+
if ch.get("refute") else
|
|
73
|
+
"a theorem has exactly this proposition as its type"),
|
|
74
|
+
"errors": proof["errors"]})
|
|
75
|
+
rungs.append({"rung": "axioms", "state": "pass" if not any(
|
|
76
|
+
e.startswith("axioms=") for e in proof["errors"]) and not proof["sorry"]
|
|
77
|
+
else "fail", "detail": ", ".join(proof["axioms"]) or "none"})
|
|
78
|
+
rp = ch.get("replay") or {"state": "not-run", "detail": "no Comparator log"}
|
|
79
|
+
rungs.append({"rung": "kernel replay", "state": rp["state"], "detail": rp["detail"]})
|
|
80
|
+
w = ch.get("witness")
|
|
81
|
+
rungs.append({"rung": "witness",
|
|
82
|
+
"state": "n/a" if w is None else ("pass" if w["ok"] else "open"),
|
|
83
|
+
"detail": cl.get("witness") or ("not needed" if w is None else "missing")})
|
|
84
|
+
regs = cl.get("regimes", [])
|
|
85
|
+
rungs.append({"rung": "regimes",
|
|
86
|
+
"state": "n/a" if not regs else
|
|
87
|
+
("pass" if all(r["discharged"] for r in regs) else "open"),
|
|
88
|
+
"detail": f"{sum(r['discharged'] for r in regs)}/{len(regs)} covered"
|
|
89
|
+
if regs else "none declared"})
|
|
90
|
+
rev = cl.get("review", "unknown")
|
|
91
|
+
kinds = sorted({r["kind"] for r in (cl.get("spec_review") or {}).get("current", [])})
|
|
92
|
+
rungs.append({"rung": "review",
|
|
93
|
+
"state": {"reviewed": "pass", "author-reviewed": "author",
|
|
94
|
+
"pending": "open"}.get(rev, "none"),
|
|
95
|
+
"detail": rev + (f" ({', '.join(kinds)})" if kinds else "")})
|
|
96
|
+
if anchor and anchor.get("kind") == "ambiguous":
|
|
97
|
+
rungs.append({"rung": "source", "state": "open" if src_ok is not False else "fail",
|
|
98
|
+
"detail": f"{anchor['file']}:{anchor['line']} is typeset on pages "
|
|
99
|
+
f"{', '.join(map(str, anchor['pages']))}: ambiguous, not placed "
|
|
100
|
+
"(place it by hand in anchors.json)"})
|
|
101
|
+
return rungs
|
|
102
|
+
rungs.append({"rung": "source",
|
|
103
|
+
"state": "pdf-only" if mode == "pdf" else
|
|
104
|
+
("none" if anchor is None else ("pass" if src_ok is not False else "fail")),
|
|
105
|
+
"detail": "PDF only: statement not hashed from source" if mode == "pdf" else
|
|
106
|
+
("not located in the compiled paper" if anchor is None else
|
|
107
|
+
f"{anchor.get('file', '')}:{anchor.get('line', '')}" +
|
|
108
|
+
{"approximate": " (located approximately, by SyncTeX)",
|
|
109
|
+
"manual": " (placed by hand)"}.get(anchor.get("kind"), ""))})
|
|
110
|
+
return rungs
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build(evidence: dict, release: str, display: dict | None = None
|
|
114
|
+
) -> tuple[dict, dict[str, dict]]:
|
|
115
|
+
"""The presentation manifest of `evidence`. `display` (the project's display overlay) gives
|
|
116
|
+
per claim a TeX `title_tex` and a printed `number` to show in place of the inventory's; it
|
|
117
|
+
changes what is shown, not what is assessed."""
|
|
118
|
+
body = evidence["body"]
|
|
119
|
+
shown = (display or {}).get("claims", {})
|
|
120
|
+
clause_tex = {n: v["title_tex"] for n, v in (display or {}).get("clauses", {}).items()
|
|
121
|
+
if v.get("title_tex")}
|
|
122
|
+
inv = body["inventory"]["claims"]
|
|
123
|
+
lean = body.get("lean") or {}
|
|
124
|
+
tex = body.get("tex") or {}
|
|
125
|
+
assessed = {c["id"]: c for c in body["assessment"]["claims"]}
|
|
126
|
+
findings = body["assessment"]["findings"]
|
|
127
|
+
by_subject: dict[str, list] = {}
|
|
128
|
+
for f in findings:
|
|
129
|
+
by_subject.setdefault(f["subject"].split("#")[0], []).append(f)
|
|
130
|
+
|
|
131
|
+
papers = []
|
|
132
|
+
for pid, prov in body["provenance"]["papers"].items():
|
|
133
|
+
t = tex.get(pid, {})
|
|
134
|
+
papers.append({"id": pid, "title": prov.get("title", pid), "mode": prov["mode"],
|
|
135
|
+
"revision": prov.get("revision"), "root": prov.get("root"),
|
|
136
|
+
"pdf": f"papers/{pid}.pdf" if prov.get("pdf_sha256") else None,
|
|
137
|
+
"pdf_sha256": prov.get("pdf_sha256"), "page_size": t.get("page_size"),
|
|
138
|
+
"note": prov.get("note", "")})
|
|
139
|
+
|
|
140
|
+
store = decl_records(body, lean)
|
|
141
|
+
keys = {n: record_key(r) for n, r in store.items()}
|
|
142
|
+
index_claims, chunks, summary = [], {}, {}
|
|
143
|
+
for c in inv:
|
|
144
|
+
a = assessed[c["id"]]
|
|
145
|
+
t = tex.get(c["paper"], {})
|
|
146
|
+
anchor = match_anchor(c, t.get("anchors", [])) if t.get("mode") == "latex" else None
|
|
147
|
+
src_ok = None
|
|
148
|
+
if anchor:
|
|
149
|
+
anchor = {**anchor, "kind": "exact"}
|
|
150
|
+
if c.get("source_sha256") and anchor.get("source_sha256"):
|
|
151
|
+
src_ok = anchor["source_sha256"] == c["source_sha256"]
|
|
152
|
+
elif line_location(c) is not None and t.get("mode") == "latex":
|
|
153
|
+
got = (t.get("lines") or {}).get(c["location"])
|
|
154
|
+
if c.get("source_sha256") and got:
|
|
155
|
+
src_ok = got == c["source_sha256"]
|
|
156
|
+
la = (t.get("line_anchors") or {}).get(c["location"])
|
|
157
|
+
if la and la.get("kind") == "ambiguous":
|
|
158
|
+
path, _, line = c["location"].rpartition(":")
|
|
159
|
+
anchor = {"kind": "ambiguous", "file": path, "line": int(line),
|
|
160
|
+
"pages": la["pages"]}
|
|
161
|
+
elif la and la.get("kind", "approximate") == "approximate":
|
|
162
|
+
path, _, line = c["location"].rpartition(":")
|
|
163
|
+
anchor = {"kind": "approximate", "file": path, "line": int(line),
|
|
164
|
+
"begin": [la["page"], 0, la["y0"]], "end": [la["page"], 0, la["y1"]]}
|
|
165
|
+
manual = (t.get("manual_anchors") or {}).get(c["id"])
|
|
166
|
+
if manual:
|
|
167
|
+
anchor = {**(anchor or {}), "kind": "manual", "note": manual.get("note", ""),
|
|
168
|
+
"by": manual.get("by", ""), "head": None,
|
|
169
|
+
"begin": [manual["page"], 0, manual["y0"]],
|
|
170
|
+
"end": [manual["page"], 0, manual["y1"]]}
|
|
171
|
+
# The title the paper prints (`\\begin{theorem}[Efficiency]`), as TeX; the inventory's
|
|
172
|
+
# own description is kept as `title` and shown only as such.
|
|
173
|
+
heading = (anchor or {}).get("title") if (anchor or {}).get("kind") == "exact" else None
|
|
174
|
+
over = shown.get(c["id"], {})
|
|
175
|
+
number = over.get("number") or c.get("number", "")
|
|
176
|
+
description = over["title_tex"] if "title_tex" in over else c.get("title", "")
|
|
177
|
+
fs = by_subject.get(c["id"], [])
|
|
178
|
+
counts = Counter(f["tier"] for f in fs if not f.get("dismissed"))
|
|
179
|
+
summary.setdefault(c["paper"], Counter())[a["status"]] += 1
|
|
180
|
+
fname = claim_file(c["id"])
|
|
181
|
+
index_claims.append({
|
|
182
|
+
"id": c["id"], "paper": c["paper"], "number": number,
|
|
183
|
+
"title": description, "heading": heading, "kind": c.get("kind", ""),
|
|
184
|
+
"scope": c["scope"], "status": a["status"], "class": status_class(a["status"]),
|
|
185
|
+
"anchor": {"kind": anchor["kind"], "page": top_of(anchor)[0], "y0": top_of(anchor)[1],
|
|
186
|
+
"y1": anchor["end"][2] if anchor.get("end") and
|
|
187
|
+
anchor["end"][0] == anchor["begin"][0] else None,
|
|
188
|
+
"page_end": anchor["end"][0] if anchor.get("end") else None}
|
|
189
|
+
if anchor and anchor.get("begin") else None,
|
|
190
|
+
"unplaced": anchor.get("kind") if anchor and not anchor.get("begin") else None,
|
|
191
|
+
"counts": dict(counts), "file": fname})
|
|
192
|
+
clauses = []
|
|
193
|
+
for cl in a["clauses"]:
|
|
194
|
+
spec = lean.get("specs", {}).get(cl.get("spec") or "")
|
|
195
|
+
proofs = {}
|
|
196
|
+
for kind in ("accept", "refute"):
|
|
197
|
+
if cl.get(kind):
|
|
198
|
+
proofs[kind] = lean.get("proofs", {}).get(f"{kind}:{cl[kind]}:{cl['spec']}")
|
|
199
|
+
wit = lean.get("proofs", {}).get(f"witness:{cl.get('witness')}:") \
|
|
200
|
+
if cl.get("witness") else None
|
|
201
|
+
clauses.append({**cl, "label": clause_tex.get(cl["name"], cl["name"]),
|
|
202
|
+
"corrects_label": clause_tex.get(cl.get("corrects") or "",
|
|
203
|
+
cl.get("corrects")),
|
|
204
|
+
"statement": {
|
|
205
|
+
"binders": spec.get("binders", []), "conclusion": spec.get("conclusion"),
|
|
206
|
+
"axioms": spec.get("axioms", []), "closure": spec.get("closure", []),
|
|
207
|
+
"direct": spec.get("direct", []), "legacy_hash": spec.get("legacy_hash"),
|
|
208
|
+
"fingerprint": spec.get("fingerprint"),
|
|
209
|
+
"decl": lean.get("decls", {}).get(cl["spec"])} if spec else None,
|
|
210
|
+
"proofs": proofs, "witness_record": wit,
|
|
211
|
+
"ladder": ladder(cl, anchor, t.get("mode"), src_ok)})
|
|
212
|
+
names = set()
|
|
213
|
+
for cl in clauses:
|
|
214
|
+
st = cl.get("statement") or {}
|
|
215
|
+
names.update(st.get("closure", []))
|
|
216
|
+
names.update(cl.get("defs", []))
|
|
217
|
+
names.update((cl.get("def_reviews") or {}).keys())
|
|
218
|
+
# A definition's review belongs to the definition (shared store); the clause keeps
|
|
219
|
+
# its state and reviewer.
|
|
220
|
+
cl["def_reviews"] = {n: {"state": r["state"],
|
|
221
|
+
"by": [x["by"] for x in r.get("current", [])],
|
|
222
|
+
"kind": [x["kind"] for x in r.get("current", [])]}
|
|
223
|
+
for n, r in (cl.get("def_reviews") or {}).items()}
|
|
224
|
+
if st:
|
|
225
|
+
st.pop("decl", None)
|
|
226
|
+
decls = {n: keys[n] for n in sorted(names) if n in keys}
|
|
227
|
+
chunks[fname] = {
|
|
228
|
+
"schema": SCHEMA, "release": release, "id": c["id"], "paper": c["paper"],
|
|
229
|
+
"number": number, "number_note": over.get("number_note"), "title": description,
|
|
230
|
+
"heading": heading,
|
|
231
|
+
"kind": c.get("kind", ""), "scope": c["scope"], "scope_note": c.get("scope_note", ""),
|
|
232
|
+
"scope_reviewed": c.get("scope_reviewed"), "location": c.get("location"),
|
|
233
|
+
"source_sha256": c.get("source_sha256"), "status": a["status"],
|
|
234
|
+
"class": status_class(a["status"]), "anchor": anchor, "source_matches": src_ok,
|
|
235
|
+
"statement_tex": (anchor or {}).get("statement"),
|
|
236
|
+
"blockers": [shown_names(b, clause_tex) for b in a.get("blockers", [])],
|
|
237
|
+
"clauses": clauses, "decls": decls, "findings": fs}
|
|
238
|
+
|
|
239
|
+
prov = body["provenance"]
|
|
240
|
+
index = {"schema": SCHEMA, "release": release, "evidence": evidence["id"],
|
|
241
|
+
"created": evidence["created"], "project": prov["project"],
|
|
242
|
+
"lean": prov.get("lean"), "tool": prov["tool"], "papers": papers,
|
|
243
|
+
"summary": {p: dict(s) for p, s in summary.items()},
|
|
244
|
+
"classes": {st: status_class(st) for s in summary.values() for st in s},
|
|
245
|
+
"gates": body["assessment"].get("gates", {}),
|
|
246
|
+
"findings": {"total": dict(Counter(f["tier"] for f in findings
|
|
247
|
+
if not f.get("dismissed"))),
|
|
248
|
+
"general": [f for f in findings
|
|
249
|
+
if f["subject"].split("#")[0] not in
|
|
250
|
+
{c["id"] for c in inv}]},
|
|
251
|
+
"claims": index_claims}
|
|
252
|
+
index["search"] = "data/search.json"
|
|
253
|
+
return index, chunks, {keys[n]: r for n, r in store.items()}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def shown_names(text: str, clause_tex: dict[str, str]) -> str:
|
|
257
|
+
"""`text` with the clause names it quotes in their displayed (TeX) form."""
|
|
258
|
+
for n in sorted(clause_tex, key=len, reverse=True):
|
|
259
|
+
if n in text:
|
|
260
|
+
text = text.replace(n, clause_tex[n])
|
|
261
|
+
return text
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def search_index(index: dict, chunks: dict) -> dict:
|
|
265
|
+
"""What the reader searches: every claim (number, title, id, status, printed statement, Lean
|
|
266
|
+
names of its clauses) and every declaration (with the claims whose closure holds it)."""
|
|
267
|
+
claims, decls = [], {}
|
|
268
|
+
for c in index["claims"]:
|
|
269
|
+
ch = chunks[c["file"]]
|
|
270
|
+
lean = [n for cl in ch["clauses"] for n in (cl.get("spec"), cl.get("accept"),
|
|
271
|
+
cl.get("refute"), *cl.get("defs", [])) if n]
|
|
272
|
+
claims.append({"id": c["id"], "paper": c["paper"], "number": c.get("number", ""),
|
|
273
|
+
"title": c.get("title", ""), "heading": c.get("heading"),
|
|
274
|
+
"status": c["status"],
|
|
275
|
+
"statement": ch.get("statement_tex") or "", "lean": lean})
|
|
276
|
+
for n in ch.get("decls", {}):
|
|
277
|
+
decls.setdefault(n, []).append(c["id"])
|
|
278
|
+
return {"claims": claims, "decls": [{"name": n, "claims": ids} for n, ids in
|
|
279
|
+
sorted(decls.items())]}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def record_key(record: dict) -> str:
|
|
283
|
+
"""The key of a declaration record: the first 24 hex digits of the SHA-256 of its canonical
|
|
284
|
+
JSON. The record holds the declaration's name, kind and fingerprint, so different
|
|
285
|
+
declarations never share a key, and a record never changes under its key."""
|
|
286
|
+
return content_id(record)[:24]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def decl_records(body: dict, lean: dict) -> dict[str, dict]:
|
|
290
|
+
"""Every declaration once, with its current review."""
|
|
291
|
+
reviews: dict[str, dict] = {}
|
|
292
|
+
for c in body["assessment"]["claims"]:
|
|
293
|
+
for cl in c["clauses"]:
|
|
294
|
+
for n, r in (cl.get("def_reviews") or {}).items():
|
|
295
|
+
reviews.setdefault(n, r)
|
|
296
|
+
return {n: {**d, "review": reviews.get(n)} for n, d in lean.get("decls", {}).items()}
|