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/simple.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""Zero-configuration mode: a paper and the Lean project it sits in, nothing else.
|
|
2
|
+
|
|
3
|
+
`prooflens view` finds the Lake project and the paper, takes the claims from the compiled paper,
|
|
4
|
+
indexes the declarations of the project's own built modules, suggests links, and shows what is
|
|
5
|
+
linked. Links the reader confirms are kept in `prooflens.links.json` at the project root, the
|
|
6
|
+
only file this mode writes into the repository.
|
|
7
|
+
|
|
8
|
+
A link says that someone associated a printed claim with a Lean declaration; it is never shown as
|
|
9
|
+
a check that the two say the same thing. A suggestion is shown as a suggestion.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime as _dt
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
import tomllib
|
|
23
|
+
from importlib import resources
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import evidence as ev
|
|
27
|
+
from .config import STANDARD_AXIOMS, Config, Paper, Publish
|
|
28
|
+
from .probe import fingerprint
|
|
29
|
+
from .sources import SourceError, acquire
|
|
30
|
+
from .tex import DEFAULT_ENVS, TexError, anchors_for, theorem_envs
|
|
31
|
+
from . import formalization as fz
|
|
32
|
+
from .util import content_id, git_head, read_json, sha256_file, write_json
|
|
33
|
+
|
|
34
|
+
LINKS = "machinechecked.links.json"
|
|
35
|
+
OLD_LINKS = "prooflens.links.json"
|
|
36
|
+
SKIP_DIRS = {".lake", ".git", ".prooflens", "node_modules", "build", "_build", ".venv"}
|
|
37
|
+
DEFINITION_ENVS = {"definition", "assumption", "convention", "hypothesis", "notation"}
|
|
38
|
+
ENV_WORDS = {"thm", "theorem", "lem", "lemma", "prop", "proposition", "cor", "corollary",
|
|
39
|
+
"def", "definition", "rem", "remark", "ex", "example", "eq", "ass", "assumption"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ViewError(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# -- discovery -------------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def find_lake_root(start: Path) -> Path | None:
|
|
49
|
+
for d in [start, *start.parents]:
|
|
50
|
+
if (d / "lakefile.toml").exists() or (d / "lakefile.lean").exists():
|
|
51
|
+
return d
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def lib_names(root: Path) -> list[str]:
|
|
56
|
+
"""The `lean_lib` names of the project's lakefile."""
|
|
57
|
+
names = []
|
|
58
|
+
t = root / "lakefile.toml"
|
|
59
|
+
if t.exists():
|
|
60
|
+
cfg = tomllib.loads(t.read_text(encoding="utf-8"))
|
|
61
|
+
names = [lib["name"] for lib in cfg.get("lean_lib", [])]
|
|
62
|
+
if not names and cfg.get("name"):
|
|
63
|
+
names = [str(cfg["name"]).capitalize()]
|
|
64
|
+
l = root / "lakefile.lean"
|
|
65
|
+
if l.exists():
|
|
66
|
+
names += re.findall(r"^\s*lean_lib\s+«?([\w.]+)»?", l.read_text(encoding="utf-8"), re.M)
|
|
67
|
+
return list(dict.fromkeys(names))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def built_modules(root: Path, libs: list[str]) -> tuple[list[str], list[str]]:
|
|
71
|
+
"""(built, unbuilt) modules of the project's libraries: a module is built when Lake has
|
|
72
|
+
written its `.olean`. Modules are found from the source tree, so modules outside the import
|
|
73
|
+
graph of a library's root are found too."""
|
|
74
|
+
olean = root / ".lake" / "build" / "lib" / "lean"
|
|
75
|
+
if not olean.exists():
|
|
76
|
+
olean = root / ".lake" / "build" / "lib"
|
|
77
|
+
built, unbuilt = [], []
|
|
78
|
+
for lib in libs:
|
|
79
|
+
base = lib.split(".")[0]
|
|
80
|
+
sources = [root / f"{base}.lean"] + sorted((root / base).rglob("*.lean")) \
|
|
81
|
+
if (root / base).is_dir() else [root / f"{base}.lean"]
|
|
82
|
+
for src in sources:
|
|
83
|
+
if not src.exists():
|
|
84
|
+
continue
|
|
85
|
+
mod = ".".join(src.relative_to(root).with_suffix("").parts)
|
|
86
|
+
(built if (olean / src.relative_to(root).with_suffix(".olean")).exists()
|
|
87
|
+
else unbuilt).append(mod)
|
|
88
|
+
return built, unbuilt
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def find_papers(root: Path) -> list[Path]:
|
|
92
|
+
"""LaTeX roots (files with \\documentclass) of the repository, else its PDFs."""
|
|
93
|
+
tex, pdf = [], []
|
|
94
|
+
for dirpath, dirnames, files in os.walk(root):
|
|
95
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
|
|
96
|
+
for f in files:
|
|
97
|
+
p = Path(dirpath) / f
|
|
98
|
+
if f.endswith(".tex"):
|
|
99
|
+
try:
|
|
100
|
+
if re.search(r"^[^%\n]*\\documentclass", p.read_text(errors="replace"), re.M):
|
|
101
|
+
tex.append(p)
|
|
102
|
+
except OSError:
|
|
103
|
+
pass
|
|
104
|
+
elif f.endswith(".pdf"):
|
|
105
|
+
pdf.append(p)
|
|
106
|
+
# A leanblueprint keeps the plasTeX input `web.tex` beside the LaTeX root `print.tex`.
|
|
107
|
+
tex = [p for p in tex if not (p.name == "web.tex" and (p.parent / "print.tex") in tex)]
|
|
108
|
+
return sorted(tex) or sorted(pdf)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -- claims from the paper -------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def claims_from_anchors(pid: str, anchors: list[dict], snap: Path) -> list[dict]:
|
|
114
|
+
claims = []
|
|
115
|
+
for a in anchors:
|
|
116
|
+
env = a["env"]
|
|
117
|
+
num = a.get("number")
|
|
118
|
+
key = a.get("label") or (f"{env}-{num}" if num else f"{env}@{a['file']}:{a['line']}")
|
|
119
|
+
title, tags = a.get("title") or "", []
|
|
120
|
+
if a.get("file"):
|
|
121
|
+
try:
|
|
122
|
+
lines = (snap / a["file"]).read_text(encoding="utf-8",
|
|
123
|
+
errors="replace").split("\n")
|
|
124
|
+
tags = source_tags(lines, a["line"], env)
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
claims.append({
|
|
128
|
+
"id": f"{pid}:{key}", "key": key, "paper": pid, "kind": env,
|
|
129
|
+
"number": f"{env.capitalize()} {num}" if num else env.capitalize(), "title": title,
|
|
130
|
+
"label": a.get("label"), "location": a["file"], "source_sha256": a["source_sha256"],
|
|
131
|
+
"scope": "definition" if env in DEFINITION_ENVS else "formal",
|
|
132
|
+
"scope_note": "", "scope_reviewed": True, "clauses": [], "tags": tags})
|
|
133
|
+
return claims
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def source_tags(lines: list[str], line: int, env: str) -> list[str]:
|
|
137
|
+
"""The arguments of the `\\lean{…}` tags in the environment `env` opening on `line`, with
|
|
138
|
+
TeX comments removed (a commented-out tag is no tag)."""
|
|
139
|
+
out, depth = [], 0
|
|
140
|
+
pat = re.compile(r"\\(begin|end)\{" + re.escape(env) + r"\}")
|
|
141
|
+
for l in lines[line - 1:]:
|
|
142
|
+
l = re.sub(r"(?<!\\)%.*", "", l)
|
|
143
|
+
out += re.findall(r"\\lean\{([^}]*)\}", l)
|
|
144
|
+
for m in pat.finditer(l):
|
|
145
|
+
depth += 1 if m.group(1) == "begin" else -1
|
|
146
|
+
if depth <= 0:
|
|
147
|
+
break
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# -- suggestions -----------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _tokens(s: str) -> set[str]:
|
|
154
|
+
s = re.sub(r"([a-z])([A-Z])", r"\1 \2", s)
|
|
155
|
+
return {t for t in re.split(r"[^A-Za-z0-9]+", s.lower()) if len(t) > 1}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def suggest(claim: dict, index: list[dict], limit: int = 3) -> list[dict]:
|
|
159
|
+
"""Candidate declarations for a claim, best first, each with the reason it is suggested."""
|
|
160
|
+
out: dict[str, dict] = {}
|
|
161
|
+
names = {d["name"]: d for d in index}
|
|
162
|
+
|
|
163
|
+
def add(name, score, why):
|
|
164
|
+
if name in names and (name not in out or out[name]["score"] < score):
|
|
165
|
+
out[name] = {"name": name, "score": score, "why": why, "kind": names[name]["kind"]}
|
|
166
|
+
|
|
167
|
+
# Name and title resemblance proposes declarations of the claim's own sort: theorems for a
|
|
168
|
+
# theorem, definitions for a definition.
|
|
169
|
+
wants_def = claim["kind"] in DEFINITION_ENVS
|
|
170
|
+
|
|
171
|
+
def sort_ok(d):
|
|
172
|
+
return d["kind"] != "theorem" if wants_def else d["kind"] == "theorem"
|
|
173
|
+
|
|
174
|
+
number = claim["number"]
|
|
175
|
+
num_re = re.compile(r"\b" + re.escape(number) + r"\b", re.I)
|
|
176
|
+
label = set(_tokens(claim.get("label") or "")) - ENV_WORDS
|
|
177
|
+
words = _tokens(claim.get("title") or "")
|
|
178
|
+
for d in index:
|
|
179
|
+
doc = d.get("doc") or ""
|
|
180
|
+
if doc and num_re.search(doc):
|
|
181
|
+
add(d["name"], 0.9, f"its docstring cites {number}")
|
|
182
|
+
if not sort_ok(d):
|
|
183
|
+
continue
|
|
184
|
+
short = d["name"].rsplit(".", 1)[-1]
|
|
185
|
+
nt = _tokens(short)
|
|
186
|
+
if label and nt:
|
|
187
|
+
j = len(label & nt) / len(label | nt)
|
|
188
|
+
if j >= 0.5:
|
|
189
|
+
add(d["name"], 0.4 + 0.4 * j, "its name resembles the label")
|
|
190
|
+
if words and nt and len(words & nt) >= 2:
|
|
191
|
+
add(d["name"], 0.35, "its name shares words with the title")
|
|
192
|
+
return sorted(out.values(), key=lambda x: -x["score"])[:limit]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def tagged_names(claim: dict) -> list[str]:
|
|
196
|
+
"""The declarations a claim's `\\lean{…}` tags name, in order."""
|
|
197
|
+
out = []
|
|
198
|
+
for tag in claim.get("tags", []):
|
|
199
|
+
for n in (t.strip() for t in tag.split(",")):
|
|
200
|
+
if n and n not in out:
|
|
201
|
+
out.append(n)
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def linked_names(claim: dict, entry: dict) -> list[str]:
|
|
206
|
+
"""The declarations a claim is linked to: those confirmed in the reader (the links file
|
|
207
|
+
holds the whole list, possibly empty), else those its `\\lean{…}` tags and the project's
|
|
208
|
+
formalization.yaml name, less the ones rejected in the reader. A tag is a link its author
|
|
209
|
+
wrote into the paper's source; a formalization.yaml mapping, one the project declared."""
|
|
210
|
+
if "lean" in entry:
|
|
211
|
+
return list(entry["lean"])
|
|
212
|
+
names = tagged_names(claim) + [n for n in claim.get("declared", [])
|
|
213
|
+
if n not in tagged_names(claim)]
|
|
214
|
+
return [n for n in names if n not in entry.get("rejected", [])]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def link_source(claim: dict, entry: dict, name: str) -> str:
|
|
218
|
+
if "lean" in entry:
|
|
219
|
+
return "confirmed"
|
|
220
|
+
return "paper" if name in tagged_names(claim) else "formalization.yaml"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# -- links file ------------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def links_file(root: Path) -> Path:
|
|
226
|
+
"""The project's links file: `machinechecked.links.json`, or the older
|
|
227
|
+
`prooflens.links.json` where only that one exists."""
|
|
228
|
+
new, old = root / LINKS, root / OLD_LINKS
|
|
229
|
+
return old if old.exists() and not new.exists() else new
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_links(root: Path) -> dict:
|
|
233
|
+
p = links_file(root)
|
|
234
|
+
if not p.exists():
|
|
235
|
+
return {"version": 1, "papers": {}}
|
|
236
|
+
return read_json(p)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def save_link(root: Path, paper: str, key: str, lean: list[str] | None, rejected: list[str]
|
|
240
|
+
| None = None, by: str = "") -> dict:
|
|
241
|
+
"""Record (or with `lean=None`, remove) the link of claim `key` of `paper`."""
|
|
242
|
+
data = load_links(root)
|
|
243
|
+
links = data.setdefault("papers", {}).setdefault(paper, {})
|
|
244
|
+
if lean is None and not rejected:
|
|
245
|
+
links.pop(key, None)
|
|
246
|
+
else:
|
|
247
|
+
entry = links.setdefault(key, {})
|
|
248
|
+
if lean is not None:
|
|
249
|
+
entry["lean"] = lean
|
|
250
|
+
entry["at"] = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
|
|
251
|
+
if by:
|
|
252
|
+
entry["by"] = by
|
|
253
|
+
if rejected is not None:
|
|
254
|
+
entry["rejected"] = sorted(set(entry.get("rejected", [])) | set(rejected))
|
|
255
|
+
write_json(links_file(root), data)
|
|
256
|
+
return data
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def save_settings(root: Path, issues: str | None = None, site_url: str | None = None,
|
|
260
|
+
author: str | None = None, repository_private: bool | None = None) -> dict:
|
|
261
|
+
"""Record where the reader's Raise issue opens reports (`owner/repo`), the public address
|
|
262
|
+
of the published reader, and the name the audit is published under, in the links file."""
|
|
263
|
+
data = load_links(root)
|
|
264
|
+
if issues:
|
|
265
|
+
data["issues"] = issues
|
|
266
|
+
if site_url:
|
|
267
|
+
data["site_url"] = site_url
|
|
268
|
+
if author:
|
|
269
|
+
data["author"] = author
|
|
270
|
+
if repository_private is not None:
|
|
271
|
+
data["repository_private"] = repository_private
|
|
272
|
+
write_json(links_file(root), data)
|
|
273
|
+
return data
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# -- the Lean side ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def run_simple_probe(root: Path, modules: list[str], libs: list[str], requests: list[dict],
|
|
279
|
+
lake: str = "lake", timeout: int = 3600) -> list[dict]:
|
|
280
|
+
body = resources.files("prooflens").joinpath("data/ProofLensProbe.lean").read_text()
|
|
281
|
+
header = "import Lean\n" + "".join(f"import {m}\n" for m in modules) + "\n"
|
|
282
|
+
with tempfile.TemporaryDirectory(prefix="prooflens-view-") as tmp:
|
|
283
|
+
tmp = Path(tmp)
|
|
284
|
+
(tmp / "Probe.lean").write_text(header + body)
|
|
285
|
+
(tmp / "query.json").write_text(json.dumps({
|
|
286
|
+
"scope_roots": [l.split(".")[0] for l in libs], "spec_prefix": "",
|
|
287
|
+
"accept_prefix": "", "allowed_axioms": STANDARD_AXIOMS, "requests": requests}))
|
|
288
|
+
env = dict(os.environ, PROOFLENS_QUERY=str(tmp / "query.json"),
|
|
289
|
+
PROOFLENS_OUT=str(tmp / "out.jsonl"))
|
|
290
|
+
r = subprocess.run([lake, "env", "lean", str(tmp / "Probe.lean")], cwd=root, env=env,
|
|
291
|
+
capture_output=True, text=True, timeout=timeout)
|
|
292
|
+
out = tmp / "out.jsonl"
|
|
293
|
+
if r.returncode != 0 or not out.exists():
|
|
294
|
+
raise ViewError(f"the Lean probe failed:\n{r.stdout[-3000:]}\n{r.stderr[-3000:]}")
|
|
295
|
+
recs = [json.loads(l) for l in out.read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
296
|
+
for rec in recs:
|
|
297
|
+
for d in [rec, rec.get("decl")]:
|
|
298
|
+
if isinstance(d, dict) and "canon" in d:
|
|
299
|
+
d["fingerprint"] = fingerprint(d.pop("canon"))
|
|
300
|
+
return recs
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def link_state(rec: dict | None) -> tuple[str, str]:
|
|
304
|
+
"""The status of a confirmed link from its probe record, and its class."""
|
|
305
|
+
if rec is None or "missing" in rec.get("errors", []):
|
|
306
|
+
return "linked: Lean declaration not found", "malformed"
|
|
307
|
+
if rec.get("kind") != "theorem":
|
|
308
|
+
return "linked: Lean definition", "proved"
|
|
309
|
+
if rec.get("sorry"):
|
|
310
|
+
return "linked: Lean proof incomplete (sorry)", "open"
|
|
311
|
+
if rec.get("errors"):
|
|
312
|
+
return "linked: Lean proof uses further axioms", "open"
|
|
313
|
+
return "linked: Lean proof checked", "proved"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# -- the whole view --------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
def tex_machinery_digest() -> str:
|
|
319
|
+
"""The digest of what turns LaTeX into anchors: a change to it invalidates cached anchors."""
|
|
320
|
+
h = hashlib.sha256()
|
|
321
|
+
for f in ("data/prooflens.sty", "tex.py"):
|
|
322
|
+
h.update(resources.files("prooflens").joinpath(f).read_bytes())
|
|
323
|
+
return h.hexdigest()[:16]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def tree_digest(d: Path) -> str:
|
|
327
|
+
import hashlib
|
|
328
|
+
h = hashlib.sha256()
|
|
329
|
+
for f in sorted(p for p in d.rglob("*") if p.is_file()):
|
|
330
|
+
h.update(str(f.relative_to(d)).encode())
|
|
331
|
+
h.update(hashlib.sha256(f.read_bytes()).digest())
|
|
332
|
+
return h.hexdigest()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def lean_digest(root: Path) -> str:
|
|
336
|
+
"""The content of every Lean input of the project and its path dependencies, and whether
|
|
337
|
+
each module is built."""
|
|
338
|
+
import hashlib
|
|
339
|
+
from .pipeline import lean_inputs_digest
|
|
340
|
+
h = hashlib.sha256()
|
|
341
|
+
for f in sorted((root / ".lake" / "build").rglob("*.olean")) if (root / ".lake").exists() \
|
|
342
|
+
else []:
|
|
343
|
+
h.update(str(f.relative_to(root)).encode())
|
|
344
|
+
h.update(str(f.stat().st_mtime_ns).encode())
|
|
345
|
+
fake = type("C", (), {"lean_root": root})()
|
|
346
|
+
h.update(lean_inputs_digest(fake).encode())
|
|
347
|
+
return h.hexdigest()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def view_config(root: Path, paper_path: Path, out_dir: Path, libs: list[str]) -> Config:
|
|
351
|
+
rel = paper_path.relative_to(root)
|
|
352
|
+
source = {"path": str(rel.parent)}
|
|
353
|
+
paper = Paper(id="P", title=paper_title(paper_path), source=source,
|
|
354
|
+
root=rel.name if paper_path.suffix == ".tex" else None,
|
|
355
|
+
pdf=rel.name if paper_path.suffix == ".pdf" else None,
|
|
356
|
+
publish=Publish(pdf=True, source_text=True, lean=True, reviews=True))
|
|
357
|
+
return Config(path=root / "prooflens.toml", name=root.name, lean_root=root,
|
|
358
|
+
modules=[], scope_roots=[l.split(".")[0] for l in libs], spec_prefix="",
|
|
359
|
+
accept_prefix="", allowed_axioms=STANDARD_AXIOMS, inventory=links_file(root),
|
|
360
|
+
reviews=[], statement_authors=[], ai_reviewers=[], dismissals=None,
|
|
361
|
+
comparator_log=None, papers=[paper], out_dir=out_dir)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def paper_title(p: Path) -> str:
|
|
365
|
+
side = p.with_suffix(".json")
|
|
366
|
+
if p.suffix == ".pdf" and side.exists():
|
|
367
|
+
try:
|
|
368
|
+
t = json.loads(side.read_text()).get("title")
|
|
369
|
+
if t:
|
|
370
|
+
return t
|
|
371
|
+
except ValueError:
|
|
372
|
+
pass
|
|
373
|
+
if p.suffix == ".tex":
|
|
374
|
+
m = re.search(r"\\title\{([^}]*)\}", p.read_text(errors="replace"))
|
|
375
|
+
if m:
|
|
376
|
+
return re.sub(r"\\\\|\s+", " ", m.group(1)).strip()
|
|
377
|
+
return p.stem
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def build_view(root: Path, paper_path: Path, out_dir: Path, lake: str = "lake",
|
|
381
|
+
log=print) -> dict:
|
|
382
|
+
"""Everything the reader shows for `paper_path` in the Lean project at `root`."""
|
|
383
|
+
libs = lib_names(root)
|
|
384
|
+
built, unbuilt = built_modules(root, libs) if libs else ([], [])
|
|
385
|
+
cfg = view_config(root, paper_path, out_dir, libs)
|
|
386
|
+
paper = cfg.papers[0]
|
|
387
|
+
notes = []
|
|
388
|
+
|
|
389
|
+
# The paper.
|
|
390
|
+
snap = acquire(cfg, paper, out_dir / "sources" / paper.id)
|
|
391
|
+
tex, pdf, claims = {"mode": snap.mode}, None, []
|
|
392
|
+
if snap.mode == "latex":
|
|
393
|
+
envs = sorted(set(DEFAULT_ENVS) | set(theorem_envs(snap.dir)))
|
|
394
|
+
# The compiled paper is reused while its sources are unchanged (a link changes nothing
|
|
395
|
+
# in the paper).
|
|
396
|
+
cache = out_dir / "build" / "tex-cache.json"
|
|
397
|
+
digest = tree_digest(snap.dir) + ":" + tex_machinery_digest()
|
|
398
|
+
cached = read_json(cache) if cache.exists() else {}
|
|
399
|
+
try:
|
|
400
|
+
if cached.get("digest") == digest and Path(cached.get("pdf", "")).exists():
|
|
401
|
+
t = {"page_size": cached["page_size"], "anchors": cached["anchors"],
|
|
402
|
+
"pdf": Path(cached["pdf"])}
|
|
403
|
+
else:
|
|
404
|
+
r = anchors_for(snap, out_dir / "build" / paper.id, envs)
|
|
405
|
+
t = {"page_size": r["page_size"], "anchors": [a.to_json() for a in r["anchors"]],
|
|
406
|
+
"pdf": r["pdf"]}
|
|
407
|
+
write_json(cache, {"digest": digest, "page_size": t["page_size"],
|
|
408
|
+
"anchors": t["anchors"], "pdf": str(t["pdf"])}, indent=None)
|
|
409
|
+
anchors = t["anchors"]
|
|
410
|
+
tex.update(page_size=t["page_size"], anchors=anchors, envs=envs)
|
|
411
|
+
pdf = t["pdf"]
|
|
412
|
+
claims = claims_from_anchors(paper.id, anchors, snap.dir)
|
|
413
|
+
except TexError as e:
|
|
414
|
+
notes.append("the paper did not compile, so its claims are not listed: "
|
|
415
|
+
+ str(e).splitlines()[0])
|
|
416
|
+
tex["mode"] = "pdf"
|
|
417
|
+
if pdf is None and snap.pdf:
|
|
418
|
+
pdf = snap.dir / snap.pdf
|
|
419
|
+
if not claims and pdf is not None:
|
|
420
|
+
# A paper that is only a PDF: its numbered statements are found by their bold heads.
|
|
421
|
+
from .pdfclaims import claims_from_pdf
|
|
422
|
+
anchors, size = claims_from_pdf(pdf)
|
|
423
|
+
tex.update(mode="pdf", anchors=anchors, page_size=size)
|
|
424
|
+
claims = claims_from_anchors(paper.id, anchors, snap.dir)
|
|
425
|
+
if anchors:
|
|
426
|
+
notes.append("the paper is a PDF: its statements were found by their bold headings, "
|
|
427
|
+
"and where each one ends is estimated")
|
|
428
|
+
paper_key = str(paper_path.relative_to(root))
|
|
429
|
+
side = paper_path.with_suffix(".json")
|
|
430
|
+
if paper_path.suffix == ".pdf" and side.exists():
|
|
431
|
+
# A paper fetched by URL is known by its URL, the same in every clone.
|
|
432
|
+
paper_key = json.loads(side.read_text()).get("url") or paper_key
|
|
433
|
+
|
|
434
|
+
# The Lean project.
|
|
435
|
+
fy = fz.load(root)
|
|
436
|
+
if fy and "error" in fy:
|
|
437
|
+
notes.append(fy["error"])
|
|
438
|
+
index, linkrecs, decls = [], {}, {}
|
|
439
|
+
if fy and "error" not in fy:
|
|
440
|
+
for c in claims:
|
|
441
|
+
c["declared"] = [n for st in fz.links_for(fy, c["kind"], c["number"], paper.title)
|
|
442
|
+
for n in st["lean"]]
|
|
443
|
+
links = load_links(root).get("papers", {}).get(paper_key, {})
|
|
444
|
+
if not libs:
|
|
445
|
+
notes.append("no Lean library found in the lakefile")
|
|
446
|
+
elif not built:
|
|
447
|
+
notes.append("the Lean project is not built (run `lake build`); Lean information is "
|
|
448
|
+
"not shown")
|
|
449
|
+
else:
|
|
450
|
+
if unbuilt:
|
|
451
|
+
notes.append(f"{len(unbuilt)} module(s) of the project are not built and are not "
|
|
452
|
+
f"indexed")
|
|
453
|
+
# The index is reused while no Lean input of the project changed.
|
|
454
|
+
icache = out_dir / "build" / "index-cache.json"
|
|
455
|
+
idigest = lean_digest(root)
|
|
456
|
+
cached = read_json(icache) if icache.exists() else {}
|
|
457
|
+
if cached.get("digest") == idigest:
|
|
458
|
+
index = cached["index"]
|
|
459
|
+
else:
|
|
460
|
+
log(f"lean: indexing {len(built)} built module(s)")
|
|
461
|
+
recs = run_simple_probe(root, built, libs, [{"kind": "index", "name": ""}], lake)
|
|
462
|
+
index = [r for r in recs if r["record"] == "index"]
|
|
463
|
+
write_json(icache, {"digest": idigest, "index": index}, indent=None)
|
|
464
|
+
wanted = set()
|
|
465
|
+
for c in claims:
|
|
466
|
+
entry = links.get(c["key"], {})
|
|
467
|
+
c["link_names"] = linked_names(c, entry)
|
|
468
|
+
c["suggestions"] = [s for s in suggest(c, index)
|
|
469
|
+
if s["name"] not in entry.get("rejected", [])
|
|
470
|
+
and s["name"] not in c["link_names"]]
|
|
471
|
+
wanted |= set(c["link_names"])
|
|
472
|
+
wanted |= {s["name"] for s in c["suggestions"][:1] if s.get("kind")}
|
|
473
|
+
if wanted:
|
|
474
|
+
log(f"lean: checking {len(wanted)} linked or suggested declaration(s)")
|
|
475
|
+
recs = run_simple_probe(root, built, libs,
|
|
476
|
+
[{"kind": "link", "name": n} for n in sorted(wanted)], lake)
|
|
477
|
+
linkrecs = {r["name"]: r for r in recs if r["record"] == "link"}
|
|
478
|
+
decls = {r["name"]: r for r in recs if r["record"] == "decl"}
|
|
479
|
+
|
|
480
|
+
# Statuses.
|
|
481
|
+
for c in claims:
|
|
482
|
+
entry = links.get(c["key"], {})
|
|
483
|
+
c["links"] = []
|
|
484
|
+
for n in c.pop("link_names", None) or linked_names(c, entry):
|
|
485
|
+
status, cls = link_state(linkrecs.get(n)) if built else \
|
|
486
|
+
("linked: Lean not checked (the project is not built)", "unspecified")
|
|
487
|
+
c["links"].append({"name": n, "status": status, "class": cls,
|
|
488
|
+
"source": link_source(c, entry, n),
|
|
489
|
+
"record": linkrecs.get(n)})
|
|
490
|
+
for s in c.get("suggestions", []):
|
|
491
|
+
s["record"] = linkrecs.get(s["name"])
|
|
492
|
+
if c["links"]:
|
|
493
|
+
worst = max(c["links"], key=lambda l: ["proved", "unspecified", "open", "malformed"]
|
|
494
|
+
.index(l["class"]))
|
|
495
|
+
c["status"], c["class"] = worst["status"], worst["class"]
|
|
496
|
+
elif c.get("suggestions"):
|
|
497
|
+
c["status"], c["class"] = "suggested link, not confirmed", "unspecified"
|
|
498
|
+
else:
|
|
499
|
+
c["status"], c["class"] = "not linked", "unspecified"
|
|
500
|
+
|
|
501
|
+
body = {"mode": "simple", "paper": paper_key, "title": paper.title, "notes": notes,
|
|
502
|
+
"project": {"name": root.name, "libs": libs, "built": len(built),
|
|
503
|
+
"unbuilt": len(unbuilt)},
|
|
504
|
+
"links_sha256": sha256_file(links_file(root)) if links_file(root).exists()
|
|
505
|
+
else None,
|
|
506
|
+
"commit": git_head(root), "issues": load_links(root).get("issues"),
|
|
507
|
+
"formalization": fz.summary(fy),
|
|
508
|
+
"site_url": load_links(root).get("site_url"),
|
|
509
|
+
"author": load_links(root).get("author"),
|
|
510
|
+
"pdf_sha256": sha256_file(pdf) if pdf else None, "tex": tex,
|
|
511
|
+
"claims": claims, "index": index, "decls": decls}
|
|
512
|
+
return {"body": body, "pdf": pdf, "cfg": cfg}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def source_info(root: Path | None, libs: list[str]) -> dict:
|
|
516
|
+
"""The project's source repository, for the reader's Lean source links."""
|
|
517
|
+
from . import audit
|
|
518
|
+
if root is None:
|
|
519
|
+
return {}
|
|
520
|
+
links = load_links(root)
|
|
521
|
+
return audit.source_info(root, libs, links.get("repository"),
|
|
522
|
+
links.get("repository_private", False))
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def write_view_site(view: dict, site: Path, web_dist: Path | None,
|
|
526
|
+
root: Path | None = None) -> dict:
|
|
527
|
+
"""The site of a view: the same app as the full reader, in its simple mode, with its audit
|
|
528
|
+
descriptor (`audit.json`). `root` is the Lean project, for the descriptor's repository."""
|
|
529
|
+
from . import manifest as mf
|
|
530
|
+
body = view["body"]
|
|
531
|
+
eid = content_id(body)
|
|
532
|
+
release = f"v-{eid[:12]}"
|
|
533
|
+
store = {}
|
|
534
|
+
keys = {}
|
|
535
|
+
for n, d in body["decls"].items():
|
|
536
|
+
rec = {k: v for k, v in d.items() if k != "record"}
|
|
537
|
+
k = mf.record_key(rec)
|
|
538
|
+
keys[n], store[k] = k, rec
|
|
539
|
+
index_claims, chunks = [], {}
|
|
540
|
+
for c in body["claims"]:
|
|
541
|
+
anchor = next((a for a in body["tex"].get("anchors", [])
|
|
542
|
+
if (a.get("label") or f"{a['env']}-{a.get('number')}") == c["key"]), None)
|
|
543
|
+
fname = mf.claim_file(c["id"])
|
|
544
|
+
akind = "approximate" if anchor and anchor.get("kind") == "pdf" else "exact"
|
|
545
|
+
top = mf.top_of({**anchor, "kind": akind}) if anchor and anchor.get("begin") else None
|
|
546
|
+
index_claims.append({
|
|
547
|
+
"id": c["id"], "paper": c["paper"], "number": c["number"], "title": c["title"],
|
|
548
|
+
"heading": c["title"] or None, "kind": c["kind"], "scope": c["scope"],
|
|
549
|
+
"status": c["status"], "class": c["class"],
|
|
550
|
+
"anchor": {"kind": akind, "page": top[0], "y0": top[1],
|
|
551
|
+
"y1": anchor["end"][2] if anchor.get("end") and
|
|
552
|
+
anchor["end"][0] == anchor["begin"][0] else None,
|
|
553
|
+
"page_end": anchor["end"][0] if anchor.get("end") else None}
|
|
554
|
+
if top else None,
|
|
555
|
+
"counts": {}, "file": fname, "linked": len(c["links"]),
|
|
556
|
+
"suggested": len(c.get("suggestions", []))})
|
|
557
|
+
names = set()
|
|
558
|
+
for l in c["links"] + c.get("suggestions", []):
|
|
559
|
+
rec = l.get("record") or {}
|
|
560
|
+
names |= set(rec.get("closure", []))
|
|
561
|
+
chunks[fname] = {
|
|
562
|
+
"schema": "prooflens/manifest@0", "mode": "simple", "release": release,
|
|
563
|
+
"id": c["id"], "key": c["key"], "paper": c["paper"], "number": c["number"],
|
|
564
|
+
"title": c["title"], "heading": c["title"] or None, "kind": c["kind"], "scope": c["scope"], "scope_note": "",
|
|
565
|
+
"scope_reviewed": True, "location": c["location"],
|
|
566
|
+
"source_sha256": c["source_sha256"], "status": c["status"], "class": c["class"],
|
|
567
|
+
"anchor": {**anchor, "kind": akind} if anchor else None, "source_matches": None,
|
|
568
|
+
"statement_tex": (anchor or {}).get("statement") if akind == "exact" else None,
|
|
569
|
+
"blockers": [],
|
|
570
|
+
"links": [_link_view(l, root) for l in c["links"]],
|
|
571
|
+
"suggestions": [_link_view(s, root) for s in c.get("suggestions", [])],
|
|
572
|
+
"clauses": [], "decls": {n: keys[n] for n in sorted(names) if n in keys},
|
|
573
|
+
"findings": []}
|
|
574
|
+
summary = {}
|
|
575
|
+
for c in index_claims:
|
|
576
|
+
summary[c["status"]] = summary.get(c["status"], 0) + 1
|
|
577
|
+
classes = {c["status"]: c["class"] for c in index_claims}
|
|
578
|
+
index = {"schema": "prooflens/manifest@0", "mode": "simple", "release": release,
|
|
579
|
+
"evidence": eid, "created": _dt.datetime.now(_dt.timezone.utc)
|
|
580
|
+
.isoformat(timespec="seconds"),
|
|
581
|
+
"project": {"name": body["project"]["name"], "commit": body.get("commit"),
|
|
582
|
+
**source_info(root, body["project"]["libs"])},
|
|
583
|
+
"tool": {"name": "machinechecked"},
|
|
584
|
+
"issues": body.get("issues"), "site_url": body.get("site_url"),
|
|
585
|
+
"formalization": body.get("formalization"),
|
|
586
|
+
"lean": None, "notes": body["notes"],
|
|
587
|
+
"papers": [{"id": "P", "title": body["title"], "mode": body["tex"]["mode"],
|
|
588
|
+
"revision": body.get("commit"),
|
|
589
|
+
"pdf": "papers/P.pdf" if view["pdf"] else None,
|
|
590
|
+
"page_size": body["tex"].get("page_size"), "note": ""}],
|
|
591
|
+
"summary": {"P": summary}, "classes": classes,
|
|
592
|
+
"findings": {"total": {}, "general": []}, "gates": {}, "claims": index_claims,
|
|
593
|
+
"search": "data/search.json", "lean_index": "data/lean-index.json",
|
|
594
|
+
"editable": False}
|
|
595
|
+
from .pipeline import write_preview
|
|
596
|
+
from .review_queue import simple_queue
|
|
597
|
+
write_preview(site, index, chunks, store, {"P": view["pdf"]} if view["pdf"] else {},
|
|
598
|
+
web_dist, simple_queue(index, chunks))
|
|
599
|
+
index["review_queue"] = "data/review-queue.json"
|
|
600
|
+
from . import audit
|
|
601
|
+
key = body.get("paper") or ""
|
|
602
|
+
write_json(site / audit.NAME, audit.descriptor(
|
|
603
|
+
index, root, author=body.get("author"),
|
|
604
|
+
paper_url=key if key.startswith(("http://", "https://")) else None))
|
|
605
|
+
write_json(site / "data" / "lean-index.json",
|
|
606
|
+
[{"name": d["name"], "kind": d["kind"], "module": d["module"],
|
|
607
|
+
"type": d["type"][:300], "doc": (d.get("doc") or "")[:300]}
|
|
608
|
+
for d in body["index"]], indent=None)
|
|
609
|
+
return index
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _link_view(l: dict, root: Path | None = None) -> dict:
|
|
613
|
+
"""A link or suggestion as the reader shows it; `path` is its file in the project's
|
|
614
|
+
repository, for a link to its source, when the project is known."""
|
|
615
|
+
from . import audit
|
|
616
|
+
rec = l.get("record") or {}
|
|
617
|
+
module = (rec.get("decl") or {}).get("module")
|
|
618
|
+
return {"path": audit.source_path(root, module) if root else None,"name": l["name"], "status": l.get("status"), "class": l.get("class"),
|
|
619
|
+
"why": l.get("why"), "source": l.get("source"),
|
|
620
|
+
"fingerprint": rec.get("fingerprint") or (rec.get("decl") or {}).get("fingerprint"),
|
|
621
|
+
"kind": rec.get("kind") or l.get("kind"),
|
|
622
|
+
"axioms": rec.get("axioms"), "sorry": rec.get("sorry"),
|
|
623
|
+
"errors": rec.get("errors"),
|
|
624
|
+
"statement": {"binders": rec.get("binders", []), "conclusion": rec.get("conclusion"),
|
|
625
|
+
"closure": rec.get("closure", []), "direct": []} if rec else None,
|
|
626
|
+
"range": (rec.get("decl") or {}).get("range"),
|
|
627
|
+
"module": (rec.get("decl") or {}).get("module"),
|
|
628
|
+
"doc": (rec.get("decl") or {}).get("doc")}
|