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/pdfclaims.py ADDED
@@ -0,0 +1,129 @@
1
+ """Claims of a paper that is only a PDF: its numbered statements, found by their bold heads.
2
+
3
+ A typeset theorem starts with its head in bold ("Lemma 8.6."), which distinguishes it from a
4
+ reference to it in the text ("by Lemma 8.6"). The PDF is read with poppler's `pdftohtml -xml`,
5
+ which keeps bold runs and positions. Each claim is placed from its head to the next head or
6
+ proof on the same page; the end is an estimate (a PDF does not record where a statement ends),
7
+ so its anchor is `approximate`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import html
13
+ import re
14
+ import subprocess
15
+ import tempfile
16
+ import xml.etree.ElementTree as ET
17
+ from pathlib import Path
18
+
19
+ from .util import norm_ws, sha256_text
20
+
21
+ KINDS = ("Theorem", "Lemma", "Proposition", "Corollary", "Definition", "Remark", "Example",
22
+ "Assumption", "Conjecture", "Claim", "Hypothesis", "Convention")
23
+ HEAD = re.compile(r"^(" + "|".join(KINDS) + r")\s+([A-Z]?\d+(?:\.\d+)*[a-z]?)\s*(\.|\(|$)")
24
+ STOP = re.compile(r"^(Proof\b|\d+(\.\d+)*\s+[A-Z])")
25
+
26
+
27
+ BOLD_FONT = re.compile(r"bold|black|heavy|semibold|demi|cmbx|-bd\b|bx\d", re.I)
28
+
29
+
30
+ def _lines(xml_text: str) -> list[dict]:
31
+ """Every text run, in reading order: page, top and bottom in PDF points, text, bold. A run
32
+ is bold when it is marked so, or when its font's name says so (Computer Modern's bold,
33
+ CMBX, is not marked)."""
34
+ root = ET.fromstring(xml_text)
35
+ bold_fonts = {f.get("id") for f in root.iter("fontspec")
36
+ if BOLD_FONT.search(f.get("family", ""))}
37
+ out = []
38
+ for page in root.iter("page"):
39
+ pno = int(page.get("number"))
40
+ h = float(page.get("height"))
41
+ scale = 1.0
42
+ if page.get("pt_height"):
43
+ scale = float(page.get("pt_height")) / h
44
+ for t in page.iter("text"):
45
+ raw = ET.tostring(t, encoding="unicode", method="xml")
46
+ whole = t.get("font") in bold_fonts
47
+ bold = "<b>" in raw or whole
48
+ txt = html.unescape(re.sub(r"<[^>]+>", "", raw))
49
+ top, height = float(t.get("top")), float(t.get("height"))
50
+ out.append({"page": pno, "y0": top * scale, "y1": (top + height) * scale,
51
+ "text": txt.strip(), "bold": bold,
52
+ "head": txt.strip() if whole else html.unescape(re.sub(
53
+ r"<[^>]+>", "", "".join(ET.tostring(b, encoding="unicode")
54
+ for b in t.iter("b")))).strip()})
55
+ return out
56
+
57
+
58
+ def page_heights(pdf: Path) -> dict[int, float]:
59
+ """Each page's height in points, from `pdfinfo`."""
60
+ r = subprocess.run(["pdfinfo", "-f", "1", "-l", "100000", str(pdf)], capture_output=True,
61
+ text=True)
62
+ out = {}
63
+ for m in re.finditer(r"Page\s+(\d+)\s+size:\s+[\d.]+\s+x\s+([\d.]+)", r.stdout):
64
+ out[int(m.group(1))] = float(m.group(2))
65
+ return out
66
+
67
+
68
+ def claims_from_pdf(pdf: Path) -> tuple[list[dict], tuple[float, float] | None]:
69
+ """The paper's numbered statements as anchors (the shape LaTeX anchors have), and its page
70
+ size in points."""
71
+ with tempfile.TemporaryDirectory(prefix="mc-pdf-") as tmp:
72
+ base = Path(tmp) / "doc"
73
+ r = subprocess.run(["pdftohtml", "-xml", "-i", "-q", "-nodrm", "-noframes", str(pdf),
74
+ str(base)], capture_output=True, text=True, timeout=600)
75
+ x = base.with_suffix(".xml")
76
+ if r.returncode != 0 or not x.exists():
77
+ return [], None
78
+ xml_text = x.read_text(encoding="utf-8", errors="replace")
79
+ heights = page_heights(pdf)
80
+ root = ET.fromstring(xml_text)
81
+ xml_h = {int(p.get("number")): float(p.get("height")) for p in root.iter("page")}
82
+ xml_w = {int(p.get("number")): float(p.get("width")) for p in root.iter("page")}
83
+ lines = _lines(xml_text)
84
+ for l in lines:
85
+ s = heights.get(l["page"], 0) / xml_h[l["page"]] if heights.get(l["page"]) else 1.0
86
+ l["y0"] *= s
87
+ l["y1"] *= s
88
+ size = None
89
+ if heights:
90
+ p1 = min(heights)
91
+ size = (round(xml_w[p1] * heights[p1] / xml_h[p1], 2), round(heights[p1], 2))
92
+
93
+ heads = []
94
+ for i, l in enumerate(lines):
95
+ m = HEAD.match(l["head"]) if l["bold"] else None
96
+ if m:
97
+ heads.append((i, m))
98
+ anchors, seen = [], set()
99
+ for k, (i, m) in enumerate(heads):
100
+ kind, number = m.group(1).lower(), m.group(2)
101
+ if (kind, number) in seen: # a statement restated later keeps its first place
102
+ continue
103
+ seen.add((kind, number))
104
+ nxt = heads[k + 1][0] if k + 1 < len(heads) else len(lines)
105
+ body, end = [], lines[i]
106
+ for j in range(i, nxt):
107
+ l = lines[j]
108
+ if j > i and (re.match(r"Proof\b", l["text"]) or STOP.match(l["text"]) and l["bold"]
109
+ or l["page"] != lines[i]["page"] and len(body) > 3):
110
+ break
111
+ body.append(l["text"])
112
+ if l["page"] == lines[i]["page"]:
113
+ end = l
114
+ text = norm_ws(" ".join(body))
115
+ rest = text[len(m.group(0)):].lstrip() if text.startswith(m.group(0)) else text
116
+ title = None
117
+ tm = re.match(r"\(([^)]{1,120})\)\s*\.?", rest) if m.group(3) == "(" or \
118
+ rest.startswith("(") else None
119
+ if tm:
120
+ title = tm.group(1).strip()
121
+ statement = text[:4000]
122
+ anchors.append({
123
+ "id": len(anchors) + 1, "env": kind, "file": None, "line": None, "number": number,
124
+ "label": None, "labels": [], "title": title,
125
+ "begin": [lines[i]["page"], 0, round(lines[i]["y0"], 2)],
126
+ "head": None,
127
+ "end": [lines[i]["page"], 0, round(end["y1"], 2)],
128
+ "statement": statement, "source_sha256": sha256_text(statement), "kind": "pdf"})
129
+ return anchors, size
prooflens/pipeline.py ADDED
@@ -0,0 +1,331 @@
1
+ """The build: acquire the papers, run the probe, compile with anchors, assess, store the
2
+ evidence, and write the private preview site."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ from importlib import resources
10
+ from importlib.metadata import PackageNotFoundError, version
11
+ from pathlib import Path
12
+
13
+ from . import evidence as ev
14
+ from . import manifest as mf
15
+ from . import review_queue as rq
16
+ from .assess import Assessor, line_location
17
+ from .config import Config
18
+ from .probe import FINGERPRINT_VERSION, requests_from_inventory, run_probe
19
+ from .reviews import load_dismissals, load_reviews
20
+ from .schemas import validate
21
+ from .sources import acquire
22
+ from .tex import DEFAULT_ENVS, TexError, anchors_for, theorem_envs
23
+ from .util import (canonical_json, git_dirty, git_head, norm_ws, read_json, sha256_file,
24
+ sha256_text,
25
+ write_json)
26
+
27
+
28
+ def tool_version() -> str:
29
+ try:
30
+ return version("prooflens")
31
+ except PackageNotFoundError:
32
+ return "0.1.0+src"
33
+
34
+
35
+ def project_provenance(cfg: Config) -> dict:
36
+ lr = cfg.lean_root
37
+ tc = lr / "lean-toolchain"
38
+ lm = lr / "lake-manifest.json"
39
+ prov = {"name": cfg.name, "commit": git_head(cfg.root), "dirty": git_dirty(cfg.root),
40
+ "toolchain": tc.read_text().strip() if tc.exists() else None,
41
+ "lake_manifest_sha256": sha256_file(lm) if lm.exists() else None,
42
+ "dependencies": {}}
43
+ if lm.exists():
44
+ for p in read_json(lm).get("packages", []):
45
+ prov["dependencies"][p.get("name")] = p.get("rev")
46
+ return prov
47
+
48
+
49
+ def build(cfg: Config, release: str | None = None, skip_lean: bool = False,
50
+ skip_tex: bool = False, lake: str = "lake", web_dist: Path | None = None,
51
+ lean_build: bool = True, reuse_lean: bool = False, log=print) -> dict:
52
+ inv = read_json(cfg.inventory)
53
+ validate(inv, "inventory")
54
+ reviews, review_hashes = load_reviews(cfg.reviews, cfg.ai_reviewers, cfg.root)
55
+ dismissals = load_dismissals(cfg.dismissals)
56
+
57
+ lean = None
58
+ lean_digest = None
59
+ probe_used = probe_digest() # the probe this build runs, read before it runs
60
+ if reuse_lean:
61
+ lean = reusable_lean(cfg)
62
+ missing = [r["name"] for r in requests_from_inventory(inv)
63
+ if (r["kind"] == "spec" and r["name"] not in lean["specs"])
64
+ or (r["kind"] == "decl" and r["name"] not in lean["defs"])
65
+ or (r["kind"] in ("accept", "refute", "witness") and
66
+ f"{r['kind']}:{r['name']}:{r.get('spec') or ''}" not in lean["proofs"])]
67
+ if missing:
68
+ raise RuntimeError(f"the stored probe does not cover {missing[:5]}; run the probe")
69
+ lean_digest = lean_inputs_digest(cfg)
70
+ log("probe: reusing the stored probe records (no Lean input changed)")
71
+ elif not skip_lean:
72
+ lean_digest = lean_inputs_digest(cfg)
73
+ log(f"probe: {len(requests_from_inventory(inv))} requests in {cfg.lean_root}")
74
+ lean = run_probe(cfg, requests_from_inventory(inv), lake=lake, build=lean_build)
75
+
76
+ tex, papers_prov, pdfs = {}, {}, {}
77
+ for paper in cfg.papers:
78
+ snap = acquire(cfg, paper, cfg.out_dir / "sources" / paper.id)
79
+ prov = {"title": paper.title, "kind": snap.kind, "revision": snap.revision,
80
+ "mode": snap.mode, "root": snap.root, "note": snap.note,
81
+ "committed_pdf": snap.pdf,
82
+ "committed_pdf_sha256": sha256_file(snap.dir / snap.pdf) if snap.pdf else None}
83
+ t = None
84
+ if snap.mode == "latex" and not skip_tex:
85
+ envs = paper.envs or sorted(set(DEFAULT_ENVS) | set(theorem_envs(snap.dir)))
86
+ log(f"tex: compiling {paper.id} ({snap.root})")
87
+ try:
88
+ t = anchors_for(snap, cfg.out_dir / "build" / paper.id, envs)
89
+ except TexError as e:
90
+ # The sandbox is not weakened to make a document compile: the source's own
91
+ # PDF is used instead, and the paper is marked as not compiled.
92
+ prov["compile_error"] = str(e)[-2000:]
93
+ log(f"tex: {paper.id} did not compile; using its committed PDF if any")
94
+ if t is not None:
95
+ tex[paper.id] = {"mode": "latex", "envs": envs, "page_size": t["page_size"],
96
+ "inputs": t["inputs"],
97
+ "anchors": [a.to_json() for a in t["anchors"]]}
98
+ prov["pdf_sha256"] = t["pdf_sha256"]
99
+ prov["pdf_origin"] = "compiled"
100
+ pdfs[paper.id] = t["pdf"]
101
+ elif snap.pdf:
102
+ tex[paper.id] = {"mode": "pdf", "anchors": [], "page_size": None, "inputs": []}
103
+ prov["mode"] = "pdf"
104
+ prov["pdf_sha256"] = prov["committed_pdf_sha256"]
105
+ prov["pdf_origin"] = "committed"
106
+ pdfs[paper.id] = snap.dir / snap.pdf
107
+ if paper.id in tex:
108
+ tex[paper.id]["lines"] = line_hashes(inv, paper.id, snap.dir)
109
+ if t is not None:
110
+ locs = [c["location"] for c in inv["claims"]
111
+ if c["paper"] == paper.id and line_location(c) is not None]
112
+ tex[paper.id]["line_anchors"] = t["line_anchors"](locs)
113
+ tex[paper.id]["manual_anchors"] = manual_anchors(cfg, paper.id)
114
+ papers_prov[paper.id] = prov
115
+
116
+ comp = cfg.comparator_log
117
+ comp_text = comp.read_text(errors="replace") if comp and comp.exists() else None
118
+ assessment = Assessor(cfg, inv, lean, tex, reviews, dismissals, comp_text,
119
+ git_head(cfg.lean_root)).run()
120
+ body = {
121
+ "provenance": {"tool": {"name": "prooflens", "version": tool_version(),
122
+ "fingerprint": FINGERPRINT_VERSION},
123
+ "probe_sha256": probe_used if lean else None,
124
+ "lean_inputs_sha256": lean_digest,
125
+ "lean_reused": reuse_lean,
126
+ "lean": (lean or {}).get("env"), "project": project_provenance(cfg),
127
+ "papers": papers_prov,
128
+ "comparator_log_sha256": sha256_file(comp) if comp_text else None},
129
+ "inventory": {"file": os.path.relpath(cfg.inventory, cfg.root), "claims": inv["claims"]},
130
+ "reviews": {"files": review_hashes, "records": [r.to_json() for r in reviews]},
131
+ "dismissals": dismissals,
132
+ "lean": lean, "tex": tex, "assessment": assessment}
133
+ eid = ev.store(cfg.out_dir, body)
134
+ rel = release or f"r-{eid[:12]}"
135
+ ev.record_release(cfg.out_dir, rel, eid)
136
+ record = ev.load(cfg.out_dir, eid)
137
+ index, chunks, store = mf.build(record, rel, load_display(cfg))
138
+ index.update(issues=cfg.issues, site_url=cfg.site_url)
139
+ index["project"].update(source_info(cfg))
140
+ site = cfg.out_dir / "site"
141
+ write_preview(site, index, chunks, store, pdfs, web_dist,
142
+ rq.assurance_queue(index, chunks, store, cfg.statement_authors))
143
+ log(f"evidence {eid[:12]} release {rel} site {site}")
144
+ return {"evidence": eid, "release": rel, "site": site, "index": index}
145
+
146
+
147
+ LEAN_INPUTS = ["*.lean", "lakefile.toml", "lakefile.lean", "lake-manifest.json", "lean-toolchain"]
148
+
149
+
150
+ def lean_input_roots(cfg: Config) -> list[Path]:
151
+ """The project and every path dependency of its lake manifest: their sources are inputs of
152
+ the probe; remote dependencies are pinned by the manifest itself."""
153
+ roots = [cfg.lean_root]
154
+ lm = cfg.lean_root / "lake-manifest.json"
155
+ if lm.exists():
156
+ for p in read_json(lm).get("packages", []):
157
+ if p.get("type") == "path":
158
+ roots.append((cfg.lean_root / p["dir"]).resolve())
159
+ return roots
160
+
161
+
162
+ def lean_inputs_digest(cfg: Config) -> str:
163
+ """SHA-256 over the content of every Lean input of the project and its path dependencies
164
+ (sources, lakefiles, manifest, toolchain), committed or not."""
165
+ import hashlib
166
+ h = hashlib.sha256()
167
+ for root in lean_input_roots(cfg):
168
+ files = []
169
+ for pat in LEAN_INPUTS:
170
+ files += [f for f in root.rglob(pat) if ".lake" not in f.parts and ".git" not in
171
+ f.parts and ".prooflens" not in f.parts]
172
+ h.update(f"root {root.name}\n".encode())
173
+ for f in sorted(set(files)):
174
+ h.update(f"{f.relative_to(root)}\n".encode())
175
+ h.update(hashlib.sha256(f.read_bytes()).digest())
176
+ return h.hexdigest()
177
+
178
+
179
+ def probe_digest() -> str:
180
+ return sha256_text(resources.files("prooflens").joinpath("data/ProofLensProbe.lean")
181
+ .read_text())
182
+
183
+
184
+ def source_info(cfg: Config) -> dict:
185
+ """The project's source repository, for the reader's Lean source links."""
186
+ from . import audit
187
+ return audit.source_info(cfg.lean_root, cfg.scope_roots, cfg.repository,
188
+ cfg.repository_private)
189
+
190
+
191
+ def reusable_lean(cfg: Config) -> dict:
192
+ """The probe records of the latest evidence, if they still describe this checkout: the same
193
+ content of every Lean input (here and in every path dependency) and the same probe."""
194
+ rels = sorted((cfg.out_dir / "releases").glob("*.json"), key=lambda p: p.stat().st_mtime)
195
+ if not rels:
196
+ raise RuntimeError("no stored evidence to reuse")
197
+ body = ev.load(cfg.out_dir, read_json(rels[-1])["evidence"])["body"]
198
+ lean, prov = body.get("lean"), body["provenance"]
199
+ if not lean or not prov.get("lean_inputs_sha256") or not prov.get("probe_sha256"):
200
+ raise RuntimeError("the stored evidence does not record its Lean inputs; run the probe")
201
+ if prov["lean_inputs_sha256"] != lean_inputs_digest(cfg):
202
+ raise RuntimeError("Lean inputs changed since the stored probe (here or in a path "
203
+ "dependency); run the probe")
204
+ if prov["probe_sha256"] != probe_digest():
205
+ raise RuntimeError("the probe changed since the stored records; run the probe")
206
+ return lean
207
+
208
+
209
+ def work_dir(cfg: Config, paper) -> Path:
210
+ return cfg.out_dir / "build" / paper.id
211
+
212
+
213
+ def manual_anchors(cfg: Config, pid: str) -> dict:
214
+ """Anchors placed by hand (`anchors.json` beside the configuration, versioned with the
215
+ project): `{claim id: {page, y0, y1, note, by}}` for the claims of paper `pid`."""
216
+ path = cfg.root / "anchors.json"
217
+ if not path.exists():
218
+ return {}
219
+ return {cid: a for cid, a in read_json(path).items() if cid.split(":")[0] == pid}
220
+
221
+
222
+ def line_hashes(inv: dict, pid: str, src: Path) -> dict:
223
+ """The hash of every source line a claim of the paper is recorded at."""
224
+ out = {}
225
+ for c in inv["claims"]:
226
+ if c["paper"] != pid or line_location(c) is None:
227
+ continue
228
+ path, _, n = c["location"].rpartition(":")
229
+ try:
230
+ lines = (src / path).read_text(encoding="utf-8").split("\n")
231
+ out[c["location"]] = sha256_text(norm_ws(lines[int(n) - 1]))
232
+ except (OSError, IndexError):
233
+ pass
234
+ return out
235
+
236
+
237
+ def reassess(cfg: Config, web_dist: Path | None = None, release: str | None = None,
238
+ log=print) -> dict:
239
+ """Assess the latest evidence again with the current rules and the current review files,
240
+ without probing or compiling, and store the result as a new evidence record and release."""
241
+ rels = sorted((cfg.out_dir / "releases").glob("*.json"), key=lambda p: p.stat().st_mtime)
242
+ if not rels:
243
+ raise RuntimeError("no release: run `prooflens build` first")
244
+ old = ev.load(cfg.out_dir, read_json(rels[-1])["evidence"])["body"]
245
+ inv = read_json(cfg.inventory)
246
+ validate(inv, "inventory")
247
+ if canonical_json(inv["claims"]) != canonical_json(old["inventory"]["claims"]):
248
+ raise RuntimeError("the claim inventory changed since the stored evidence: its Lean and "
249
+ "LaTeX results may not cover it; run `prooflens build` (with "
250
+ "--reuse-lean if no Lean input changed)")
251
+ reviews, review_hashes = load_reviews(cfg.reviews, cfg.ai_reviewers, cfg.root)
252
+ dismissals = load_dismissals(cfg.dismissals)
253
+ comp = cfg.comparator_log
254
+ comp_text = comp.read_text(errors="replace") if comp and comp.exists() else None
255
+ assessment = Assessor(cfg, inv, old["lean"], old["tex"], reviews, dismissals, comp_text,
256
+ old["provenance"]["project"].get("commit")).run()
257
+ body = {**old, "inventory": {"file": os.path.relpath(cfg.inventory, cfg.root),
258
+ "claims": inv["claims"]},
259
+ "reviews": {"files": review_hashes, "records": [r.to_json() for r in reviews]},
260
+ "dismissals": dismissals, "assessment": assessment,
261
+ "provenance": {**old["provenance"],
262
+ "tool": {**old["provenance"]["tool"], "version": tool_version()},
263
+ "comparator_log_sha256": sha256_file(comp) if comp_text else None,
264
+ "reassessed_from": read_json(rels[-1])["evidence"]}}
265
+ body.pop("schema", None)
266
+ eid = ev.store(cfg.out_dir, body)
267
+ rel = release or f"r-{eid[:12]}"
268
+ ev.record_release(cfg.out_dir, rel, eid)
269
+ log(f"evidence {eid[:12]} release {rel} (reassessed)")
270
+ return rebuild_site(cfg, web_dist, rel)
271
+
272
+
273
+ def pdf_paths(cfg: Config, body: dict) -> dict:
274
+ """The PDF each paper's evidence was built with: the compiled one, or the committed one."""
275
+ out = {}
276
+ for pid, prov in body["provenance"]["papers"].items():
277
+ if prov.get("pdf_origin") == "compiled" and prov.get("root"):
278
+ p = cfg.out_dir / "build" / pid / Path(prov["root"]).with_suffix(".pdf")
279
+ elif prov.get("committed_pdf"):
280
+ p = cfg.out_dir / "sources" / pid / prov["committed_pdf"]
281
+ else:
282
+ continue
283
+ if p.exists() and sha256_file(p) == prov.get("pdf_sha256"):
284
+ out[pid] = p
285
+ return out
286
+
287
+
288
+ def rebuild_site(cfg: Config, web_dist: Path | None = None, release: str | None = None) -> dict:
289
+ """The preview site again from a stored release, without probing or compiling. A PDF is
290
+ included only if the one on disk is the one the evidence records."""
291
+ rels = sorted((cfg.out_dir / "releases").glob("*.json"), key=lambda p: p.stat().st_mtime)
292
+ if not rels:
293
+ raise RuntimeError("no release: run `prooflens build` first")
294
+ rel = read_json(cfg.out_dir / "releases" / f"{release}.json") if release else \
295
+ read_json(rels[-1])
296
+ record = ev.load(cfg.out_dir, rel["evidence"])
297
+ index, chunks, store = mf.build(record, rel["release"], load_display(cfg))
298
+ index.update(issues=cfg.issues, site_url=cfg.site_url)
299
+ index["project"].update(source_info(cfg))
300
+ site = cfg.out_dir / "site"
301
+ write_preview(site, index, chunks, store, pdf_paths(cfg, record["body"]), web_dist,
302
+ rq.assurance_queue(index, chunks, store, cfg.statement_authors))
303
+ return {"release": rel["release"], "site": site, "index": index}
304
+
305
+
306
+ def load_display(cfg: Config) -> dict | None:
307
+ return read_json(cfg.display) if cfg.display and cfg.display.exists() else None
308
+
309
+
310
+ def write_preview(site: Path, index: dict, chunks: dict, store: dict, pdfs: dict,
311
+ web_dist: Path | None, queue: dict | None = None):
312
+ if queue is not None:
313
+ index = {**index, "review_queue": "data/review-queue.json"}
314
+ if site.exists():
315
+ shutil.rmtree(site)
316
+ if web_dist and web_dist.exists():
317
+ shutil.copytree(web_dist, site)
318
+ data = site / "data"
319
+ validate(index, "manifest-index")
320
+ write_json(data / "index.json", index, indent=None)
321
+ for name, chunk in chunks.items():
322
+ validate(chunk, "manifest-claim")
323
+ write_json(data / "claims" / name, chunk, indent=None)
324
+ for key, record in store.items():
325
+ write_json(data / "decls" / f"{key}.json", record, indent=None)
326
+ write_json(data / "search.json", mf.search_index(index, chunks), indent=None)
327
+ if queue is not None:
328
+ write_json(data / "review-queue.json", queue, indent=None)
329
+ for pid, pdf in pdfs.items():
330
+ (data / "papers").mkdir(parents=True, exist_ok=True)
331
+ shutil.copyfile(pdf, data / "papers" / f"{pid}.pdf")
prooflens/probe.py ADDED
@@ -0,0 +1,126 @@
1
+ """Run the Lean probe inside a project and collect its records.
2
+
3
+ The probe body (`data/ProofLensProbe.lean`) is prefixed with `import Lean` and the project's
4
+ modules and run with `lake env lean` in the project root, with the project's own toolchain. The
5
+ project is not modified and needs no dependency on ProofLens.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import tempfile
13
+ from importlib import resources
14
+ from pathlib import Path
15
+
16
+ from .config import Config
17
+ from .util import run, sha256_text
18
+
19
+ FINGERPRINT_VERSION = "pl-fp-v1"
20
+
21
+
22
+ class ProbeError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def requests_from_inventory(inv: dict) -> list[dict]:
27
+ """One request per specification, proof, refutation, witness and definition named by the
28
+ inventory, without duplicates and in a stable order."""
29
+ reqs, seen = [], set()
30
+
31
+ def add(r):
32
+ key = (r["kind"], r["name"], r.get("spec"))
33
+ if key not in seen:
34
+ seen.add(key)
35
+ reqs.append(r)
36
+
37
+ for c in inv["claims"]:
38
+ for cl in c.get("clauses", []):
39
+ for d in cl.get("defs", []):
40
+ add({"kind": "decl", "name": d})
41
+ if cl.get("spec"):
42
+ add({"kind": "spec", "name": cl["spec"]})
43
+ if cl.get("accept"):
44
+ add({"kind": "accept", "name": cl["accept"], "spec": cl["spec"]})
45
+ if cl.get("refute"):
46
+ add({"kind": "refute", "name": cl["refute"], "spec": cl["spec"]})
47
+ for w in [cl.get("witness")] + [r.get("witness") for r in cl.get("regimes", [])]:
48
+ if w:
49
+ add({"kind": "witness", "name": w})
50
+ return reqs
51
+
52
+
53
+ def fingerprint(canon: str) -> str:
54
+ return f"{FINGERPRINT_VERSION}:{sha256_text(canon)}"
55
+
56
+
57
+ def run_probe(cfg: Config, requests: list[dict], lake: str = "lake",
58
+ timeout: int = 3600, build: bool = True) -> dict:
59
+ """Build the project's modules (unless `build` is false), run the probe, and return its
60
+ records grouped by kind, with every canonical serialisation replaced by its SHA-256
61
+ fingerprint."""
62
+ if build:
63
+ r = run([lake, "build", *cfg.modules], cwd=cfg.lean_root, timeout=timeout)
64
+ if r.returncode != 0:
65
+ raise ProbeError(f"lake build failed:\n{r.stdout[-4000:]}\n{r.stderr[-4000:]}")
66
+ body = resources.files("prooflens").joinpath("data/ProofLensProbe.lean").read_text()
67
+ header = "import Lean\n" + "".join(f"import {m}\n" for m in cfg.modules) + "\n"
68
+ with tempfile.TemporaryDirectory(prefix="prooflens-probe-") as tmp:
69
+ tmp = Path(tmp)
70
+ (tmp / "Probe.lean").write_text(header + body)
71
+ (tmp / "query.json").write_text(json.dumps({
72
+ "scope_roots": cfg.scope_roots, "spec_prefix": cfg.spec_prefix,
73
+ "accept_prefix": cfg.accept_prefix, "allowed_axioms": cfg.allowed_axioms,
74
+ "requests": requests}))
75
+ env = dict(os.environ, PROOFLENS_QUERY=str(tmp / "query.json"),
76
+ PROOFLENS_OUT=str(tmp / "out.jsonl"))
77
+ r = run([lake, "env", "lean", str(tmp / "Probe.lean")], cwd=cfg.lean_root, env=env,
78
+ timeout=timeout)
79
+ out = tmp / "out.jsonl"
80
+ if r.returncode != 0 or not out.exists():
81
+ raise ProbeError(f"probe failed ({r.returncode}):\n{r.stdout[-4000:]}\n{r.stderr[-4000:]}")
82
+ return parse_records(out.read_text(encoding="utf-8").splitlines())
83
+
84
+
85
+ def _strip_decl(rec: dict) -> dict:
86
+ rec = dict(rec)
87
+ rec["fingerprint"] = fingerprint(rec.pop("canon"))
88
+ rec.pop("record", None)
89
+ return rec
90
+
91
+
92
+ def parse_records(lines: list[str]) -> dict:
93
+ res = {"env": None, "layering": [], "specs": {}, "proofs": {}, "decls": {}, "defs": {},
94
+ "complete": False}
95
+ for line in lines:
96
+ if not line.strip():
97
+ continue
98
+ rec = json.loads(line)
99
+ kind = rec.get("record")
100
+ if kind == "env":
101
+ res["env"] = {"lean": rec["lean"], "githash": rec["githash"]}
102
+ elif kind == "layering":
103
+ res["layering"] = rec["violations"]
104
+ elif kind == "decl":
105
+ res["decls"][rec["name"]] = _strip_decl(rec)
106
+ elif kind == "defclosure":
107
+ res["defs"][rec["name"]] = {"closure": rec.get("closure", []),
108
+ "errors": rec.get("errors", [])}
109
+ elif kind == "spec":
110
+ rec = dict(rec)
111
+ rec.pop("record")
112
+ if "decl" in rec:
113
+ d = _strip_decl(rec.pop("decl"))
114
+ res["decls"][d["name"]] = d
115
+ rec["legacy_hash"] = d["legacy_hash"]
116
+ rec["fingerprint"] = d["fingerprint"]
117
+ res["specs"][rec["name"]] = rec
118
+ elif kind in ("accept", "refute", "witness"):
119
+ rec = dict(rec)
120
+ rec["kind"] = rec.pop("record")
121
+ res["proofs"][f"{rec['kind']}:{rec['name']}:{rec.get('spec') or ''}"] = rec
122
+ elif kind == "done":
123
+ res["complete"] = True
124
+ if not res["complete"]:
125
+ raise ProbeError("probe output ends before its `done` record")
126
+ return res