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/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
|