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/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())}