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.
Files changed (58) hide show
  1. machinechecked-0.1.0.dist-info/METADATA +108 -0
  2. machinechecked-0.1.0.dist-info/RECORD +58 -0
  3. machinechecked-0.1.0.dist-info/WHEEL +5 -0
  4. machinechecked-0.1.0.dist-info/entry_points.txt +3 -0
  5. machinechecked-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. machinechecked-0.1.0.dist-info/top_level.txt +1 -0
  7. prooflens/__init__.py +1 -0
  8. prooflens/__main__.py +3 -0
  9. prooflens/assess.py +504 -0
  10. prooflens/audit.py +115 -0
  11. prooflens/bundle.py +70 -0
  12. prooflens/cli.py +542 -0
  13. prooflens/config.py +145 -0
  14. prooflens/data/ProofLensProbe.lean +469 -0
  15. prooflens/data/prooflens.sty +75 -0
  16. prooflens/data/schema/evidence.schema.json +24 -0
  17. prooflens/data/schema/inventory.schema.json +55 -0
  18. prooflens/data/schema/manifest-claim.schema.json +126 -0
  19. prooflens/data/schema/manifest-index.schema.json +128 -0
  20. prooflens/evidence.py +60 -0
  21. prooflens/export.py +227 -0
  22. prooflens/formalization.py +106 -0
  23. prooflens/manifest.py +296 -0
  24. prooflens/pdfclaims.py +129 -0
  25. prooflens/pipeline.py +331 -0
  26. prooflens/probe.py +126 -0
  27. prooflens/promote.py +204 -0
  28. prooflens/review_queue.py +138 -0
  29. prooflens/reviews.py +105 -0
  30. prooflens/schemas.py +19 -0
  31. prooflens/server.py +63 -0
  32. prooflens/simple.py +628 -0
  33. prooflens/sources.py +164 -0
  34. prooflens/tex.py +408 -0
  35. prooflens/util.py +66 -0
  36. prooflens/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  37. prooflens/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  38. prooflens/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  39. prooflens/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  40. prooflens/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  41. prooflens/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  42. prooflens/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  43. prooflens/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  44. prooflens/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  45. prooflens/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  46. prooflens/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  47. prooflens/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  48. prooflens/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  49. prooflens/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  50. prooflens/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  51. prooflens/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. prooflens/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. prooflens/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  54. prooflens/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  55. prooflens/web/assets/index-B2GDDbsW.css +1 -0
  56. prooflens/web/assets/index-DN7uLe7p.js +285 -0
  57. prooflens/web/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  58. 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")}