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/assess.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""From evidence to statuses, findings and gates.
|
|
2
|
+
|
|
3
|
+
Every status shown by any ProofLens view is computed here, from the probe records, the TeX
|
|
4
|
+
anchors, the review markers and the inventory; the views only display it.
|
|
5
|
+
|
|
6
|
+
Findings come in three tiers:
|
|
7
|
+
|
|
8
|
+
* `violation`: a mechanical fact that contradicts acceptance (a disallowed axiom, a proof whose
|
|
9
|
+
type is not the proposition, a stale source hash, a claim of the paper missing from the
|
|
10
|
+
inventory). A violation fails the consistency check; it does not change a status, which
|
|
11
|
+
records what is proved: a proof that fails its check is not a proof, so its clause is open.
|
|
12
|
+
* `warning`: a pattern that may change the meaning of a statement (a totalised operation, for
|
|
13
|
+
instance). It asks for investigation, may be wrong, and can be dismissed with a recorded
|
|
14
|
+
rationale tied to the subject's fingerprint.
|
|
15
|
+
* `obligation`: a judgement only a reviewer can make (a review of a proposition or definition, a
|
|
16
|
+
witness that the hypotheses can be met, a regime the paper intends to cover).
|
|
17
|
+
|
|
18
|
+
Two gates per paper: the consistency check (no violation) and the release gate (sources checked, consistency clean, every scope decision reviewed, every formal
|
|
19
|
+
claim accepted and every definition claim reviewed, no printed clause refuted, every witness
|
|
20
|
+
valid, and a Comparator replay of every proof).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from .reviews import Review, history
|
|
26
|
+
|
|
27
|
+
OUT_OF_SCOPE = {"expository", "literature", "empirical", "proof-step", "part-of", "duplicate"}
|
|
28
|
+
|
|
29
|
+
# Constants whose value outside the intended domain is a junk value; a statement that uses one
|
|
30
|
+
# may be weaker or different from the printed claim unless the domain is constrained.
|
|
31
|
+
TOTALISED = {
|
|
32
|
+
"instSubNat": "truncated subtraction on ℕ: a - b = 0 when a < b",
|
|
33
|
+
"Nat.sub": "truncated subtraction on ℕ: a - b = 0 when a < b",
|
|
34
|
+
"Nat.instDiv": "division on ℕ rounds down and n / 0 = 0",
|
|
35
|
+
"Int.instDivInt": "integer division rounds and n / 0 = 0",
|
|
36
|
+
"HDiv.hDiv": "division: x / 0 = 0 in Lean's fields and ℕ",
|
|
37
|
+
"Inv.inv": "inverse: 0⁻¹ = 0",
|
|
38
|
+
"MeasureTheory.integral": "the Bochner integral is 0 for a non-integrable function",
|
|
39
|
+
"tsum": "an unconditional sum is 0 when the family is not summable",
|
|
40
|
+
"iSup": "a supremum in ℝ is 0 when the set is unbounded or empty",
|
|
41
|
+
"sSup": "a supremum in ℝ is 0 when the set is unbounded or empty",
|
|
42
|
+
"iInf": "an infimum in ℝ is 0 when the set is unbounded below or empty",
|
|
43
|
+
"sInf": "an infimum in ℝ is 0 when the set is unbounded below or empty",
|
|
44
|
+
"Real.log": "log x = log |x| and log 0 = 0",
|
|
45
|
+
"Real.sqrt": "√x = 0 for x < 0",
|
|
46
|
+
"Real.rpow": "x ^ y for x < 0 is defined through the complex logarithm",
|
|
47
|
+
"deriv": "the derivative is 0 where the function is not differentiable",
|
|
48
|
+
"fderiv": "the derivative is 0 where the function is not differentiable",
|
|
49
|
+
"MeasureTheory.condExp": "the conditional expectation is 0 for a non-integrable function",
|
|
50
|
+
"ENNReal.toReal": "∞.toReal = 0",
|
|
51
|
+
"ENNReal.toNNReal": "∞.toNNReal = 0",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Totalised operations so common in analysis that a warning about them is rarely informative.
|
|
56
|
+
LOW_SIGNAL = {"HDiv.hDiv", "Inv.inv"}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _finding(tier, code, subject, message, **extra):
|
|
60
|
+
f = {"tier": tier, "code": code, "subject": subject, "message": message}
|
|
61
|
+
f.update(extra)
|
|
62
|
+
f["id"] = f"{code}:{subject}" + (f":{extra['trigger']}" if "trigger" in extra else "")
|
|
63
|
+
return f
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_comparator(text: str | None) -> dict | None:
|
|
67
|
+
"""The structured Comparator record, or an error saying why the text is not one."""
|
|
68
|
+
import json
|
|
69
|
+
if not text:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
rec = json.loads(text)
|
|
73
|
+
except ValueError:
|
|
74
|
+
return {"error": "the Comparator log is not a structured record (JSON with commit and "
|
|
75
|
+
"replayed theorems)"}
|
|
76
|
+
if not isinstance(rec, dict) or not isinstance(rec.get("replayed"), list) or \
|
|
77
|
+
not isinstance(rec.get("commit"), str):
|
|
78
|
+
return {"error": "the Comparator record lacks `commit` or `replayed`"}
|
|
79
|
+
return rec
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _hashes(rec):
|
|
83
|
+
return {h for h in (rec.get("legacy_hash"), rec.get("fingerprint")) if h}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Assessor:
|
|
87
|
+
def __init__(self, cfg, inventory, lean, tex, reviews: list[Review], dismissals,
|
|
88
|
+
comparator_log: str | None = None, project_commit: str | None = None):
|
|
89
|
+
self.cfg, self.inv, self.lean, self.tex = cfg, inventory, lean, tex
|
|
90
|
+
self.reviews, self.dismissals = reviews, dismissals
|
|
91
|
+
self.comparator = parse_comparator(comparator_log)
|
|
92
|
+
self.project_commit = project_commit
|
|
93
|
+
self.findings: list[dict] = []
|
|
94
|
+
self.authors = set(cfg.statement_authors)
|
|
95
|
+
|
|
96
|
+
# -- reviews --------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def review_state(self, target: str, name: str, rec: dict | None) -> dict:
|
|
99
|
+
"""The state of the last review of `name`: `reviewed` (current, by a reviewer other than
|
|
100
|
+
the authors of the statements), `author-reviewed`, `stale` (the declaration changed since)
|
|
101
|
+
or `pending` (none)."""
|
|
102
|
+
hist = history(self.reviews, target, name)
|
|
103
|
+
last = hist[-1] if hist else None
|
|
104
|
+
if last is None:
|
|
105
|
+
state = "pending"
|
|
106
|
+
elif rec is None or last.hash not in _hashes(rec):
|
|
107
|
+
state = "stale"
|
|
108
|
+
elif last.by in self.authors:
|
|
109
|
+
state = "author-reviewed"
|
|
110
|
+
else:
|
|
111
|
+
state = "reviewed"
|
|
112
|
+
return {"state": state, "current": [last.to_json()] if state in (
|
|
113
|
+
"reviewed", "author-reviewed") else [],
|
|
114
|
+
"stale": [r.to_json() for r in hist if r is not last or state == "stale"]}
|
|
115
|
+
|
|
116
|
+
def closure_reviews(self, names: list[str]) -> dict:
|
|
117
|
+
"""The review state of every project declaration in the closures of `names`, other than
|
|
118
|
+
the propositions of the inventory (each reviewed by its own spec-review)."""
|
|
119
|
+
lean = self.lean
|
|
120
|
+
out = {}
|
|
121
|
+
for n in names:
|
|
122
|
+
clos = lean["specs"][n]["closure"] if n in lean["specs"] else \
|
|
123
|
+
lean["defs"].get(n, {}).get("closure", [])
|
|
124
|
+
for d in clos:
|
|
125
|
+
if d in lean["specs"] or d in out:
|
|
126
|
+
continue
|
|
127
|
+
out[d] = self.review_state("def", d, lean["decls"].get(d))
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
# -- clauses --------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def replay(self, thm: str) -> dict:
|
|
133
|
+
"""Whether a structured Comparator record shows a kernel replay of exactly `thm` for this
|
|
134
|
+
commit. The record is JSON: `{"commit": <full sha>, "replayed": [<theorem names>],
|
|
135
|
+
"checker": ..., "kernels": [...]}`. Without a record the replay is `not-run`, never
|
|
136
|
+
`fail`; a record of another commit, or a log ProofLens cannot read as such a record,
|
|
137
|
+
is `unverified`."""
|
|
138
|
+
rec = self.comparator
|
|
139
|
+
if rec is None:
|
|
140
|
+
return {"state": "not-run", "detail": "no Comparator record for this release"}
|
|
141
|
+
if "error" in rec:
|
|
142
|
+
return {"state": "unverified", "detail": rec["error"]}
|
|
143
|
+
if not self.project_commit or rec.get("commit") != self.project_commit:
|
|
144
|
+
return {"state": "unverified",
|
|
145
|
+
"detail": f"the Comparator record is for commit {str(rec.get('commit'))[:12]}"
|
|
146
|
+
f", not this one"}
|
|
147
|
+
if thm not in set(rec.get("replayed", [])):
|
|
148
|
+
return {"state": "not-run", "detail": "the Comparator record does not cover this proof"}
|
|
149
|
+
return {"state": "pass", "detail": "Comparator replayed the proof in a fresh kernel ("
|
|
150
|
+
+ ", ".join(rec.get("kernels", ["lean"])) + ")"}
|
|
151
|
+
|
|
152
|
+
def _proof(self, kind, name, spec):
|
|
153
|
+
return self.lean["proofs"].get(f"{kind}:{name}:{spec or ''}")
|
|
154
|
+
|
|
155
|
+
def clause(self, claim: dict, cl: dict, k: int) -> dict:
|
|
156
|
+
subj = f"{claim['id']}#{k}"
|
|
157
|
+
spec = cl.get("spec")
|
|
158
|
+
res = {"name": cl.get("name", "main"), "spec": spec, "accept": cl.get("accept"),
|
|
159
|
+
"refute": cl.get("refute"), "corrects": cl.get("corrects"),
|
|
160
|
+
"witness": cl.get("witness"), "defs": cl.get("defs", []),
|
|
161
|
+
"regimes": cl.get("regimes", []), "checks": {}}
|
|
162
|
+
if self.lean is None:
|
|
163
|
+
res.update(state="unchecked", review="unknown")
|
|
164
|
+
return res
|
|
165
|
+
lean = self.lean
|
|
166
|
+
if spec:
|
|
167
|
+
srec = lean["specs"].get(spec)
|
|
168
|
+
errs = (srec or {}).get("errors", ["missing"])
|
|
169
|
+
res["checks"]["spec"] = {"ok": not errs, "errors": errs}
|
|
170
|
+
for e in errs:
|
|
171
|
+
self.findings.append(_finding("violation", "spec-" + e.split("=")[0], subj,
|
|
172
|
+
f"proposition {spec}: {e}"))
|
|
173
|
+
sr = self.review_state("spec", spec, srec)
|
|
174
|
+
dr = self.closure_reviews([spec]) if srec else {}
|
|
175
|
+
res["spec_review"] = sr
|
|
176
|
+
if sr["state"] in ("pending", "stale"):
|
|
177
|
+
self.findings.append(_finding(
|
|
178
|
+
"obligation", "spec-review", subj,
|
|
179
|
+
f"the proposition {spec} has no current review" +
|
|
180
|
+
(" (its last review is stale)" if sr["state"] == "stale" else "")))
|
|
181
|
+
states = [sr["state"]] + [d["state"] for d in dr.values()]
|
|
182
|
+
else:
|
|
183
|
+
srec = None
|
|
184
|
+
names = list(cl.get("defs", []))
|
|
185
|
+
missing = [d for d in names if d not in lean["decls"]]
|
|
186
|
+
for d in missing:
|
|
187
|
+
self.findings.append(_finding("violation", "def-missing", subj,
|
|
188
|
+
f"definition {d} is not in the environment"))
|
|
189
|
+
dr = {d: self.review_state("def", d, lean["decls"].get(d)) for d in names}
|
|
190
|
+
dr.update({d: r for d, r in self.closure_reviews(names).items() if d not in dr})
|
|
191
|
+
res["spec_review"] = None
|
|
192
|
+
states = [d["state"] for d in dr.values()]
|
|
193
|
+
res["def_reviews"] = dr
|
|
194
|
+
for n, d in dr.items():
|
|
195
|
+
if d["state"] in ("pending", "stale"):
|
|
196
|
+
self.findings.append(_finding(
|
|
197
|
+
"obligation", "def-review", subj,
|
|
198
|
+
f"the definition {n} has no current review" +
|
|
199
|
+
(" (its last review is stale)" if d["state"] == "stale" else ""), trigger=n))
|
|
200
|
+
if any(s in ("pending", "stale") for s in states):
|
|
201
|
+
res["review"] = "pending"
|
|
202
|
+
elif any(s == "author-reviewed" for s in states):
|
|
203
|
+
res["review"] = "author-reviewed"
|
|
204
|
+
else:
|
|
205
|
+
res["review"] = "reviewed"
|
|
206
|
+
|
|
207
|
+
# Proofs, refutations and witnesses: a check that fails is a violation, and the proof
|
|
208
|
+
# does not count.
|
|
209
|
+
for kind in ("accept", "refute", "witness"):
|
|
210
|
+
thm = cl.get(kind)
|
|
211
|
+
if not thm:
|
|
212
|
+
continue
|
|
213
|
+
p = self._proof(kind, thm, None if kind == "witness" else spec)
|
|
214
|
+
perrs = (p or {}).get("errors", ["missing"])
|
|
215
|
+
res["checks"][kind] = {"ok": not perrs, "errors": perrs,
|
|
216
|
+
"axioms": (p or {}).get("axioms", []),
|
|
217
|
+
"sorry": (p or {}).get("sorry", False),
|
|
218
|
+
"type": (p or {}).get("type") if kind == "witness" else None}
|
|
219
|
+
res[kind + "_ok"] = not perrs
|
|
220
|
+
if kind in ("accept", "refute"):
|
|
221
|
+
res["checks"]["replay"] = self.replay(thm)
|
|
222
|
+
for e in perrs:
|
|
223
|
+
self.findings.append(_finding("violation", f"{kind}-" + e.split("=")[0], subj,
|
|
224
|
+
f"{thm}: {e}"))
|
|
225
|
+
res["state"] = ("proved" if res.get("accept_ok") else
|
|
226
|
+
"refuted" if res.get("refute_ok") else
|
|
227
|
+
"open" if spec else
|
|
228
|
+
"stated" if cl.get("defs") else "unspecified")
|
|
229
|
+
|
|
230
|
+
if srec:
|
|
231
|
+
roles = [b["role"] for b in srec.get("binders", [])]
|
|
232
|
+
if ("hypothesis" in roles or "instance" in roles) and not cl.get("witness"):
|
|
233
|
+
self.findings.append(_finding(
|
|
234
|
+
"obligation", "witness", subj,
|
|
235
|
+
f"{spec} has hypotheses but no witness that they can be met"))
|
|
236
|
+
for c in srec.get("consts", []):
|
|
237
|
+
if c in TOTALISED:
|
|
238
|
+
self.add_warning("totalised", subj, spec, srec,
|
|
239
|
+
f"{TOTALISED[c]} ({c} occurs in {spec})", c)
|
|
240
|
+
regs = []
|
|
241
|
+
for r in cl.get("regimes", []):
|
|
242
|
+
w = r.get("witness")
|
|
243
|
+
p = self._proof("witness", w, None) if w else None
|
|
244
|
+
ok = bool(p) and not p.get("errors")
|
|
245
|
+
regs.append({**r, "discharged": ok, "type": (p or {}).get("type")})
|
|
246
|
+
if not ok:
|
|
247
|
+
self.findings.append(_finding(
|
|
248
|
+
"obligation", "regime", subj,
|
|
249
|
+
f"regime not shown to be covered: {r.get('name', '')}",
|
|
250
|
+
trigger=r.get("name", "")))
|
|
251
|
+
res["regimes"] = regs
|
|
252
|
+
return res
|
|
253
|
+
|
|
254
|
+
def add_warning(self, code, subj, name, rec, message, trigger):
|
|
255
|
+
fp = rec.get("fingerprint")
|
|
256
|
+
f = _finding("warning", code, subj, message, trigger=trigger, fingerprint=fp,
|
|
257
|
+
signal="low" if trigger in LOW_SIGNAL else "normal")
|
|
258
|
+
for d in self.dismissals:
|
|
259
|
+
if d.get("code") == code and d.get("subject") == name and d.get("trigger") == trigger:
|
|
260
|
+
if d.get("fingerprint") == fp:
|
|
261
|
+
f["dismissed"] = {"rationale": d.get("rationale", ""), "by": d.get("by", "")}
|
|
262
|
+
else:
|
|
263
|
+
f["dismissal_stale"] = True
|
|
264
|
+
self.findings.append(f)
|
|
265
|
+
|
|
266
|
+
# -- claims ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def claim_status(claim: dict, clauses: list[dict]) -> str:
|
|
270
|
+
"""The status of a claim from its clauses: a refuted original clause never hides another
|
|
271
|
+
original clause."""
|
|
272
|
+
scope = claim["scope"]
|
|
273
|
+
if scope in OUT_OF_SCOPE:
|
|
274
|
+
return "out of scope: " + scope
|
|
275
|
+
if any(c["state"] == "unchecked" for c in clauses):
|
|
276
|
+
return ("definition" if scope == "definition" else "formal") + ": unchecked"
|
|
277
|
+
if scope == "definition":
|
|
278
|
+
if not clauses:
|
|
279
|
+
return "definition: not yet stated"
|
|
280
|
+
if not all(c["review"] in ("reviewed", "author-reviewed") for c in clauses):
|
|
281
|
+
return "definition: review pending"
|
|
282
|
+
return ("definition: author-reviewed"
|
|
283
|
+
if any(c["review"] == "author-reviewed" for c in clauses)
|
|
284
|
+
else "definition: reviewed")
|
|
285
|
+
if not clauses:
|
|
286
|
+
return "formal: unspecified"
|
|
287
|
+
originals = [c for c in clauses if not c.get("corrects")]
|
|
288
|
+
if not originals:
|
|
289
|
+
return "formal: malformed"
|
|
290
|
+
refuted = [c for c in originals if c.get("refute_ok")]
|
|
291
|
+
rest = [c for c in originals if not c.get("refute_ok")]
|
|
292
|
+
rest_proved = all(c.get("accept_ok") for c in rest)
|
|
293
|
+
if refuted:
|
|
294
|
+
fixed = all(any(k.get("accept_ok") for k in clauses if k.get("corrects") == c["name"])
|
|
295
|
+
for c in refuted)
|
|
296
|
+
return ("refuted as printed" + ("; corrected statement proved" if fixed else "")
|
|
297
|
+
+ ("" if rest_proved else "; other clauses open"))
|
|
298
|
+
if rest_proved:
|
|
299
|
+
if not all(c["review"] in ("reviewed", "author-reviewed") for c in rest):
|
|
300
|
+
return "formal: proved, review pending"
|
|
301
|
+
return ("formal: proved, author-reviewed"
|
|
302
|
+
if any(c["review"] == "author-reviewed" for c in rest)
|
|
303
|
+
else "formal: accepted")
|
|
304
|
+
return "formal: open"
|
|
305
|
+
|
|
306
|
+
def blockers(self, claim: dict, status: str, clauses: list[dict]) -> list[str]:
|
|
307
|
+
"""Why the claim is not accepted, in plain words, most basic first: one line per reason,
|
|
308
|
+
saying which clauses it affects. Empty for an accepted claim whose proofs were replayed,
|
|
309
|
+
and for a claim out of scope."""
|
|
310
|
+
if claim["scope"] in OUT_OF_SCOPE:
|
|
311
|
+
return []
|
|
312
|
+
if any(c["state"] == "unchecked" for c in clauses):
|
|
313
|
+
return ["the Lean project was not checked in this build"]
|
|
314
|
+
out = []
|
|
315
|
+
if not claim.get("scope_reviewed", True):
|
|
316
|
+
out.append("its scope was set by the automatic first pass and not checked by reading")
|
|
317
|
+
if not clauses:
|
|
318
|
+
out.append("no Lean proposition has been written for it" if claim["scope"] == "formal"
|
|
319
|
+
else "no Lean definition has been named for it")
|
|
320
|
+
n = len(clauses)
|
|
321
|
+
reasons: dict[str, list[str]] = {}
|
|
322
|
+
|
|
323
|
+
def add(reason, clause):
|
|
324
|
+
reasons.setdefault(reason, []).append(clause["name"])
|
|
325
|
+
|
|
326
|
+
defs: dict[str, str] = {}
|
|
327
|
+
for c in clauses:
|
|
328
|
+
st = c["state"]
|
|
329
|
+
if st == "unspecified":
|
|
330
|
+
add("no Lean proposition has been written", c)
|
|
331
|
+
elif st == "open":
|
|
332
|
+
failed = [e for k in ("accept", "refute") if c["checks"].get(k)
|
|
333
|
+
and not c["checks"][k]["ok"] for e in c["checks"][k]["errors"]]
|
|
334
|
+
add("its proof fails a check: " + ", ".join(failed) if failed
|
|
335
|
+
else "the proposition has no proof yet", c)
|
|
336
|
+
elif st == "refuted":
|
|
337
|
+
add("the printed statement is false: a theorem proves its negation", c)
|
|
338
|
+
if c.get("spec") and c.get("spec_review"):
|
|
339
|
+
srs = c["spec_review"]["state"]
|
|
340
|
+
if srs == "author-reviewed":
|
|
341
|
+
add("the proposition was reviewed only by the author of the statements", c)
|
|
342
|
+
elif srs == "pending":
|
|
343
|
+
add("the proposition has no review", c)
|
|
344
|
+
elif srs == "stale":
|
|
345
|
+
add("the proposition changed after its last review", c)
|
|
346
|
+
for d, r in (c.get("def_reviews") or {}).items():
|
|
347
|
+
defs[d] = r["state"]
|
|
348
|
+
if c.get("witness") and not c.get("witness_ok"):
|
|
349
|
+
add("the witness that the hypotheses can be met fails its check", c)
|
|
350
|
+
if any(not r["discharged"] for r in c.get("regimes", [])):
|
|
351
|
+
add("a regime the paper intends is not shown to be covered", c)
|
|
352
|
+
rp = c["checks"].get("replay")
|
|
353
|
+
if rp and rp["state"] != "pass" and st in ("proved", "refuted"):
|
|
354
|
+
add("no independent kernel replay: " + rp["detail"], c)
|
|
355
|
+
for reason, names in reasons.items():
|
|
356
|
+
if n == 1:
|
|
357
|
+
out.append(reason)
|
|
358
|
+
elif len(names) == n:
|
|
359
|
+
out.append(f"{reason} (all {n} clauses)")
|
|
360
|
+
elif reason.startswith("the printed statement is false") or len(names) <= 2:
|
|
361
|
+
out.append(f"{reason}: " + "; ".join(f"“{x}”" for x in names))
|
|
362
|
+
else:
|
|
363
|
+
out.append(f"{reason} ({len(names)} of {n} clauses)")
|
|
364
|
+
counts: dict[str, int] = {}
|
|
365
|
+
for state in defs.values():
|
|
366
|
+
counts[state] = counts.get(state, 0) + 1
|
|
367
|
+
for state, words in (("pending", "have no review"),
|
|
368
|
+
("stale", "changed after their review"),
|
|
369
|
+
("author-reviewed", "were reviewed only by the author")):
|
|
370
|
+
if counts.get(state):
|
|
371
|
+
out.append(f"{counts[state]} definition{'s' if counts[state] > 1 else ''} its "
|
|
372
|
+
f"meaning depends on {words}")
|
|
373
|
+
if status.startswith("definition") and status != "definition: reviewed" and not out:
|
|
374
|
+
out.append("its definitions are not independently reviewed")
|
|
375
|
+
return list(dict.fromkeys(out))
|
|
376
|
+
|
|
377
|
+
def run(self) -> dict:
|
|
378
|
+
claims = []
|
|
379
|
+
for c in self.inv["claims"]:
|
|
380
|
+
clauses = [self.clause(c, cl, k) for k, cl in enumerate(c.get("clauses", []))]
|
|
381
|
+
status = self.claim_status(c, clauses)
|
|
382
|
+
claims.append({"id": c["id"], "status": status, "clauses": clauses,
|
|
383
|
+
"blockers": self.blockers(c, status, clauses)})
|
|
384
|
+
self.inventory_checks()
|
|
385
|
+
if self.lean:
|
|
386
|
+
for v in self.lean.get("layering", []):
|
|
387
|
+
self.findings.append(_finding(
|
|
388
|
+
"violation", "layering", v["module"],
|
|
389
|
+
f"the specification module {v['module']} imports {v['imports']}"))
|
|
390
|
+
gates = self.gates(claims)
|
|
391
|
+
return {"claims": claims, "findings": self.findings, "gates": gates}
|
|
392
|
+
|
|
393
|
+
# -- gates ----------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def gates(self, claims: list[dict]) -> dict:
|
|
396
|
+
ids = {c["id"]: c for c in self.inv["claims"]}
|
|
397
|
+
violations = [f for f in self.findings if f["tier"] == "violation"]
|
|
398
|
+
out = {}
|
|
399
|
+
for paper in self.cfg.papers:
|
|
400
|
+
pid = paper.id
|
|
401
|
+
rows = [c for c in claims if ids[c["id"]]["paper"] == pid]
|
|
402
|
+
mine = [f for f in violations if f["subject"].split("#")[0] in
|
|
403
|
+
{r["id"] for r in rows} or f["subject"].split(":")[0] == pid
|
|
404
|
+
or ":" not in f["subject"].split("#")[0]]
|
|
405
|
+
consistency = [f["message"] for f in mine]
|
|
406
|
+
reasons = []
|
|
407
|
+
t = (self.tex or {}).get(pid, {})
|
|
408
|
+
if t.get("mode") != "latex":
|
|
409
|
+
reasons.append("sources: manuscript source not compiled, source hashes unchecked")
|
|
410
|
+
if self.lean is None:
|
|
411
|
+
reasons.append("lean: the Lean project was not checked")
|
|
412
|
+
reasons += [f"consistency: {m}" for m in consistency]
|
|
413
|
+
if not rows:
|
|
414
|
+
reasons.append(f"inventory: no claims for paper {pid}")
|
|
415
|
+
for r in rows:
|
|
416
|
+
e = ids[r["id"]]
|
|
417
|
+
if not e.get("scope_reviewed", False):
|
|
418
|
+
reasons.append(f"{r['id']}: scope decision ({e['scope']}) not reviewed")
|
|
419
|
+
if e["scope"] in OUT_OF_SCOPE:
|
|
420
|
+
continue
|
|
421
|
+
if e["scope"] == "definition":
|
|
422
|
+
if r["status"] != "definition: reviewed":
|
|
423
|
+
reasons.append(f"{r['id']}: {r['status']}")
|
|
424
|
+
continue
|
|
425
|
+
if r["status"] != "formal: accepted":
|
|
426
|
+
reasons.append(f"{r['id']}: {r['status']}")
|
|
427
|
+
for c in r["clauses"]:
|
|
428
|
+
if c.get("refute_ok"):
|
|
429
|
+
reasons.append(f"{r['id']}: printed clause refuted, unresolved in the "
|
|
430
|
+
f"manuscript: {c['name']}")
|
|
431
|
+
if c.get("witness") and not c.get("witness_ok"):
|
|
432
|
+
reasons.append(f"{r['id']}: witness fails: {c['name']}")
|
|
433
|
+
names = sorted({c["accept"] for r in rows for c in r["clauses"] if c.get("accept")})
|
|
434
|
+
comparator = bool(names) and all(self.replay(n)["state"] == "pass" for n in names)
|
|
435
|
+
if not comparator:
|
|
436
|
+
reasons.append("comparator: no successful kernel replay covering every proof "
|
|
437
|
+
"of the paper")
|
|
438
|
+
out[pid] = {"consistency": {"pass": not consistency, "failures": consistency},
|
|
439
|
+
"release": {"pass": not reasons, "reasons": reasons},
|
|
440
|
+
"comparator": comparator}
|
|
441
|
+
return out
|
|
442
|
+
|
|
443
|
+
# -- inventory against the compiled paper ---------------------------------------------
|
|
444
|
+
|
|
445
|
+
def inventory_checks(self):
|
|
446
|
+
"""Compare the inventory with the environments the compiled papers contain."""
|
|
447
|
+
for pid, t in (self.tex or {}).items():
|
|
448
|
+
if t.get("mode") != "latex":
|
|
449
|
+
continue
|
|
450
|
+
anchors = t["anchors"]
|
|
451
|
+
matched = set()
|
|
452
|
+
for c in [c for c in self.inv["claims"] if c["paper"] == pid]:
|
|
453
|
+
line = line_location(c)
|
|
454
|
+
if line is not None:
|
|
455
|
+
# A claim recorded by file and line (an equation, a part, a prose claim): its
|
|
456
|
+
# hash is the hash of that source line, as in `acceptance.py`.
|
|
457
|
+
got = (t.get("lines") or {}).get(c["location"])
|
|
458
|
+
if c.get("source_sha256") and got != c["source_sha256"]:
|
|
459
|
+
self.findings.append(_finding(
|
|
460
|
+
"violation", "source-changed", c["id"],
|
|
461
|
+
f"the source line {c['location']} of {c.get('number', c['id'])} "
|
|
462
|
+
+ ("is missing" if got is None else
|
|
463
|
+
"differs from the frozen inventory")))
|
|
464
|
+
continue
|
|
465
|
+
a = match_anchor(c, anchors)
|
|
466
|
+
if a is None:
|
|
467
|
+
if c.get("kind") in t.get("envs", []):
|
|
468
|
+
self.findings.append(_finding(
|
|
469
|
+
"violation", "inventory-unanchored", c["id"],
|
|
470
|
+
f"{c.get('number', c['id'])} is not found in the compiled paper"))
|
|
471
|
+
continue
|
|
472
|
+
matched.add(a["id"])
|
|
473
|
+
if c.get("source_sha256") and a.get("source_sha256") and \
|
|
474
|
+
c["source_sha256"] != a["source_sha256"]:
|
|
475
|
+
self.findings.append(_finding(
|
|
476
|
+
"violation", "source-changed", c["id"],
|
|
477
|
+
f"the source of {c.get('number', c['id'])} differs from the frozen "
|
|
478
|
+
f"inventory"))
|
|
479
|
+
for a in anchors:
|
|
480
|
+
if a["id"] not in matched:
|
|
481
|
+
self.findings.append(_finding(
|
|
482
|
+
"violation", "inventory-gap", f"{pid}:{a.get('label') or a['env']}",
|
|
483
|
+
f"{a['env'].capitalize()} {a.get('number')} ({a['file']}:{a['line']}) "
|
|
484
|
+
f"is in the paper but not in the inventory"))
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def line_location(claim: dict) -> int | None:
|
|
488
|
+
"""The line of a claim recorded as `<file>:<line>`, else None."""
|
|
489
|
+
loc = claim.get("location") or ""
|
|
490
|
+
head, _, tail = loc.rpartition(":")
|
|
491
|
+
return int(tail) if head and tail.isdigit() else None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def match_anchor(claim: dict, anchors: list[dict]) -> dict | None:
|
|
495
|
+
pid = claim["paper"]
|
|
496
|
+
cid = claim["id"]
|
|
497
|
+
for a in anchors:
|
|
498
|
+
lab = a.get("label")
|
|
499
|
+
if lab and (lab == claim.get("label") or lab == cid or f"{pid}:{lab}" == cid):
|
|
500
|
+
return a
|
|
501
|
+
for a in anchors:
|
|
502
|
+
if a.get("number") and claim.get("number") == f"{a['env'].capitalize()} {a['number']}":
|
|
503
|
+
return a
|
|
504
|
+
return None
|
prooflens/audit.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The audit descriptor: `audit.json` at the root of a published reader.
|
|
2
|
+
|
|
3
|
+
It says, in a few fields, what the audit is about and where its data is: the paper (and its
|
|
4
|
+
arXiv identifier and version when it has one), the Lean repository and commit, who published
|
|
5
|
+
the audit, where reports go, and the path of the reader's data. A site that indexes audits reads
|
|
6
|
+
this file and then the data it points to; it never runs anything an audit contains.
|
|
7
|
+
|
|
8
|
+
The descriptor records what its author states; it is not evidence of anything by itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import functools
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
SCHEMA = "machinechecked/audit@1"
|
|
20
|
+
NAME = "audit.json"
|
|
21
|
+
|
|
22
|
+
ARXIV = re.compile(r"arxiv\.org/(?:abs|pdf)/([a-z-]+(?:\.[A-Z]{2})?/\d{7}|\d{4}\.\d{4,5})"
|
|
23
|
+
r"(v\d+)?", re.I)
|
|
24
|
+
GITHUB = re.compile(r"github\.com[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?/?$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def arxiv_id(url: str | None) -> tuple[str, str | None] | None:
|
|
28
|
+
"""The arXiv identifier and version named by a URL (`arxiv:2601.01234`, `v2`), if any."""
|
|
29
|
+
m = ARXIV.search(url or "")
|
|
30
|
+
return (f"arxiv:{m.group(1)}", m.group(2)) if m else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def github_repository(path: Path) -> str | None:
|
|
34
|
+
"""`owner/repo` of the GitHub remote `origin` of the repository at `path`, if there is one."""
|
|
35
|
+
r = subprocess.run(["git", "remote", "get-url", "origin"], cwd=path, capture_output=True,
|
|
36
|
+
text=True)
|
|
37
|
+
m = GITHUB.search(r.stdout.strip()) if r.returncode == 0 else None
|
|
38
|
+
return f"{m.group(1)}/{m.group(2)}" if m else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def toolchain(path: Path) -> str | None:
|
|
42
|
+
p = path / "lean-toolchain"
|
|
43
|
+
return p.read_text(encoding="utf-8").strip() if p.is_file() else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def descriptor(index: dict, lean_root: Path | None, author: str | None = None,
|
|
47
|
+
paper_url: str | None = None) -> dict:
|
|
48
|
+
"""The descriptor of a reader whose data index is `index`."""
|
|
49
|
+
papers = []
|
|
50
|
+
for p in index.get("papers", []):
|
|
51
|
+
ax = arxiv_id(paper_url) if len(index.get("papers", [])) == 1 else None
|
|
52
|
+
papers.append({"title": p.get("title"), "id": ax[0] if ax else None,
|
|
53
|
+
"version": ax[1] if ax else None, "url": paper_url,
|
|
54
|
+
"revision": p.get("revision")})
|
|
55
|
+
project = index.get("project") or {}
|
|
56
|
+
return {
|
|
57
|
+
"schema": SCHEMA,
|
|
58
|
+
"created": _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds"),
|
|
59
|
+
"tool": "machinechecked",
|
|
60
|
+
"author": author,
|
|
61
|
+
"papers": papers,
|
|
62
|
+
"formalisation": {
|
|
63
|
+
"repository": project.get("repository") or (github_repository(lean_root)
|
|
64
|
+
if lean_root else None),
|
|
65
|
+
"repository_private": bool(project.get("repository_private")),
|
|
66
|
+
"commit": project.get("commit"),
|
|
67
|
+
"toolchain": project.get("toolchain") or (toolchain(lean_root) if lean_root
|
|
68
|
+
else None),
|
|
69
|
+
},
|
|
70
|
+
"release": index.get("release"),
|
|
71
|
+
"data": "data/index.json",
|
|
72
|
+
"issues": index.get("issues"),
|
|
73
|
+
"site_url": index.get("site_url"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@functools.lru_cache(maxsize=None)
|
|
78
|
+
def source_root(lean_root: Path) -> tuple[Path, str] | None:
|
|
79
|
+
"""The Git work tree holding the Lean project, and the project's path inside it (`""` at
|
|
80
|
+
its root, else `"sub/dir/"`)."""
|
|
81
|
+
r = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=lean_root,
|
|
82
|
+
capture_output=True, text=True)
|
|
83
|
+
if r.returncode != 0:
|
|
84
|
+
return None
|
|
85
|
+
top = Path(r.stdout.strip()).resolve()
|
|
86
|
+
rel = lean_root.resolve().relative_to(top).as_posix()
|
|
87
|
+
return top, ("" if rel == "." else rel + "/")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def source_path(lean_root: Path, module: str | None) -> str | None:
|
|
91
|
+
"""The path of a module's file in the project's Git repository, if the file is in the
|
|
92
|
+
project rather than in a dependency."""
|
|
93
|
+
if not module or not re.fullmatch(r"[\w.']+", module):
|
|
94
|
+
return None
|
|
95
|
+
sr = source_root(lean_root)
|
|
96
|
+
rel = module.replace(".", "/") + ".lean"
|
|
97
|
+
if sr is None or not (lean_root / rel).is_file():
|
|
98
|
+
return None
|
|
99
|
+
return sr[1] + rel
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def source_info(lean_root: Path | None, roots: list[str], repository: str | None = None,
|
|
103
|
+
private: bool = False) -> dict:
|
|
104
|
+
"""Where the reader's Lean source links point: the GitHub repository (given, else the
|
|
105
|
+
project's `origin` remote), whether it is private (the reader then does not show its name;
|
|
106
|
+
the links still name it), the project's path inside it, and the top-level module names whose
|
|
107
|
+
files are the project's own."""
|
|
108
|
+
if lean_root is None:
|
|
109
|
+
return {}
|
|
110
|
+
sr = source_root(lean_root)
|
|
111
|
+
return {"repository": repository or github_repository(lean_root),
|
|
112
|
+
"repository_private": bool(private),
|
|
113
|
+
"source_prefix": sr[1] if sr else None,
|
|
114
|
+
"source_roots": sorted(r for r in roots if (lean_root / r).is_dir()
|
|
115
|
+
or (lean_root / f"{r}.lean").is_file())}
|