machinechecked 0.1.4__tar.gz → 0.1.5__tar.gz

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 (65) hide show
  1. {machinechecked-0.1.4 → machinechecked-0.1.5}/PKG-INFO +1 -1
  2. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/machinechecked.egg-info/PKG-INFO +1 -1
  3. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/machinechecked.egg-info/SOURCES.txt +2 -1
  4. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/cli.py +53 -8
  5. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/formalization.py +2 -1
  6. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/pdfclaims.py +3 -1
  7. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/pipeline.py +4 -4
  8. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/simple.py +79 -28
  9. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/sources.py +28 -4
  10. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/util.py +32 -0
  11. machinechecked-0.1.4/engine/python/prooflens/web/assets/index-Crg6e6Ow.js → machinechecked-0.1.5/engine/python/prooflens/web/assets/index-D0aKe720.js +6 -6
  12. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/index.html +1 -1
  13. machinechecked-0.1.5/engine/python/prooflens/web/theme.css +82 -0
  14. {machinechecked-0.1.4 → machinechecked-0.1.5}/pyproject.toml +1 -1
  15. {machinechecked-0.1.4 → machinechecked-0.1.5}/LICENSE +0 -0
  16. {machinechecked-0.1.4 → machinechecked-0.1.5}/README.md +0 -0
  17. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/machinechecked.egg-info/dependency_links.txt +0 -0
  18. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/machinechecked.egg-info/entry_points.txt +0 -0
  19. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/machinechecked.egg-info/requires.txt +0 -0
  20. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/machinechecked.egg-info/top_level.txt +0 -0
  21. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/__init__.py +0 -0
  22. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/__main__.py +0 -0
  23. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/assess.py +0 -0
  24. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/audit.py +0 -0
  25. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/bundle.py +0 -0
  26. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/config.py +0 -0
  27. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/data/ProofLensProbe.lean +0 -0
  28. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/data/prooflens.sty +0 -0
  29. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/data/schema/evidence.schema.json +0 -0
  30. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/data/schema/inventory.schema.json +0 -0
  31. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/data/schema/manifest-claim.schema.json +0 -0
  32. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/data/schema/manifest-index.schema.json +0 -0
  33. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/evidence.py +0 -0
  34. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/export.py +0 -0
  35. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/manifest.py +0 -0
  36. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/paper.py +0 -0
  37. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/probe.py +0 -0
  38. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/promote.py +0 -0
  39. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/review_queue.py +0 -0
  40. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/reviews.py +0 -0
  41. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/schemas.py +0 -0
  42. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/server.py +0 -0
  43. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/tex.py +0 -0
  44. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  45. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  46. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  47. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  48. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  49. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  50. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  51. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  52. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  53. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  54. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  55. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  56. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  57. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  58. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  59. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  60. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  61. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  62. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  63. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/index-T9UdK0vf.css +0 -0
  64. {machinechecked-0.1.4 → machinechecked-0.1.5}/engine/python/prooflens/web/assets/pdf.worker.min-yatZIOMy.mjs +0 -0
  65. {machinechecked-0.1.4 → machinechecked-0.1.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: machinechecked
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Read a mathematical paper beside its Lean formalisation, and see claim by claim what Lean establishes.
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://machinechecked.org
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: machinechecked
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Read a mathematical paper beside its Lean formalisation, and see claim by claim what Lean establishes.
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://machinechecked.org
@@ -38,6 +38,7 @@ engine/python/prooflens/data/schema/inventory.schema.json
38
38
  engine/python/prooflens/data/schema/manifest-claim.schema.json
39
39
  engine/python/prooflens/data/schema/manifest-index.schema.json
40
40
  engine/python/prooflens/web/index.html
41
+ engine/python/prooflens/web/theme.css
41
42
  engine/python/prooflens/web/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2
42
43
  engine/python/prooflens/web/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2
43
44
  engine/python/prooflens/web/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2
@@ -57,6 +58,6 @@ engine/python/prooflens/web/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2
57
58
  engine/python/prooflens/web/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2
58
59
  engine/python/prooflens/web/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2
59
60
  engine/python/prooflens/web/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2
60
- engine/python/prooflens/web/assets/index-Crg6e6Ow.js
61
+ engine/python/prooflens/web/assets/index-D0aKe720.js
61
62
  engine/python/prooflens/web/assets/index-T9UdK0vf.css
62
63
  engine/python/prooflens/web/assets/pdf.worker.min-yatZIOMy.mjs
@@ -8,7 +8,8 @@ Everyday commands, run from anywhere inside a Lean project:
8
8
 
9
9
  A project with a `machinechecked.toml` (an acceptance layer: locked statements, reviews) gets
10
10
  the full assessment; any other project gets the zero-configuration view, whose only file in the
11
- repository is `machinechecked.links.json`. The other commands are for that full assessment.
11
+ repository is `machinechecked.links.json` (its working files, a downloaded paper among them, are
12
+ in `.machinechecked/`, which Git ignores). The other commands are for that full assessment.
12
13
  `prooflens` is another name for the same command.
13
14
  """
14
15
 
@@ -149,7 +150,8 @@ def _fetch_paper(root: Path, url: str, title: str | None = None) -> Path:
149
150
  repository), with its address and title beside it."""
150
151
  import hashlib
151
152
  import urllib.request
152
- d = root / cfgmod.work_dir_name(root) / "papers"
153
+ from .util import prepare_work_dir
154
+ d = prepare_work_dir(root / cfgmod.work_dir_name(root)) / "papers"
153
155
  name = re.sub(r"[^\w.-]+", "_", url.rstrip("/").rsplit("/", 1)[-1]) or "paper"
154
156
  if not name.lower().endswith(".pdf"):
155
157
  name += ".pdf"
@@ -357,7 +359,8 @@ def cmd_export(a):
357
359
  from .export import ExportError, export
358
360
  cfg = cfgmod.load(_config_arg(a))
359
361
  try:
360
- res = export(cfg, Path(a.out).resolve(), a.release, _web_dist(a.web))
362
+ res = export(cfg, Path(a.out).resolve(), a.release,
363
+ None if a.data_only else _web_dist(a.web))
361
364
  except ExportError as e:
362
365
  sys.exit(str(e))
363
366
  print(f"exported release {res['release']} to {res['out']}")
@@ -471,7 +474,8 @@ def main(argv=None):
471
474
  b.set_defaults(func=cmd_build)
472
475
 
473
476
  k = sub.add_parser("check", help="report what the Lean side establishes (exit 1 if a "
474
- "linked declaration is missing or unproved)")
477
+ "linked declaration is missing or unproved, 2 if the check "
478
+ "could not run)")
475
479
  k.add_argument("paper", nargs="?")
476
480
  k.add_argument("--json", action="store_true",
477
481
  help="print every claim's status and links as JSON (for scripts and agents)")
@@ -504,6 +508,9 @@ def main(argv=None):
504
508
  e.add_argument("--release")
505
509
  e.add_argument("--out", required=True)
506
510
  e.add_argument("--web")
511
+ e.add_argument("--data-only", action="store_true",
512
+ help="write the audit's data only, without the reader (for a site that "
513
+ "reads it with its own reader, such as machinechecked.org)")
507
514
  e.set_defaults(func=cmd_export)
508
515
 
509
516
  s = sub.add_parser("serve")
@@ -531,11 +538,49 @@ def main(argv=None):
531
538
  a = p.parse_args(argv)
532
539
  try:
533
540
  a.func(a)
541
+ except SystemExit as e:
542
+ # `sys.exit("message")` is a command that could not do its job; an integer is its result.
543
+ if isinstance(e.code, str):
544
+ _could_not_run(a, "error", e.code.removeprefix("machinechecked: "))
545
+ raise
534
546
  except FileNotFoundError as e:
535
- hint = MISSING.get(Path(str(e.filename or "")).name)
536
- if not hint:
537
- raise
538
- sys.exit(f"machinechecked: `{Path(e.filename).name}` not found: {hint}")
547
+ tool = Path(str(e.filename or "")).name
548
+ if tool in MISSING:
549
+ _could_not_run(a, "missing-tool", f"`{tool}` not found: {MISSING[tool]}", tool=tool)
550
+ _could_not_run(a, "internal", _crash(e))
551
+ except (RuntimeError, ValueError, OSError) as e:
552
+ # The engine's own errors (the Lean probe, TeX, the paper's sources, the configuration,
553
+ # stored evidence) say what went wrong in their message.
554
+ if type(e).__module__.startswith("prooflens"):
555
+ _could_not_run(a, "error", str(e))
556
+ _could_not_run(a, "internal", _crash(e))
557
+ except Exception as e:
558
+ _could_not_run(a, "internal", _crash(e))
559
+
560
+
561
+ # Exit codes: 0 the command did its job (and `check` found nothing wrong), 1 `check` (or
562
+ # `build --fail-on-violation`) found a problem, 2 the command could not run.
563
+ COULD_NOT_RUN = 2
564
+
565
+
566
+ def _crash(e: Exception) -> str:
567
+ """An unexpected error: its traceback on stderr, for a bug report, and a one-line message."""
568
+ import traceback
569
+ traceback.print_exc()
570
+ return f"unexpected error ({type(e).__name__}: {e}); please report it with the traceback above"
571
+
572
+
573
+ def _could_not_run(a, kind: str, message: str, **extra):
574
+ """Stop with exit 2: the message on stderr and, for `check --json`, one JSON document on
575
+ stdout saying the check did not run."""
576
+ print(f"machinechecked: {message}", file=sys.stderr)
577
+ if getattr(a, "func", None) is cmd_check and getattr(a, "json", False):
578
+ import json
579
+ print(json.dumps({"schema": "machinechecked/check@1", "ok": False,
580
+ "error": {"kind": kind, "message": message, **extra},
581
+ "note": "The check did not run; nothing is known about the claims."},
582
+ indent=1))
583
+ raise SystemExit(COULD_NOT_RUN)
539
584
 
540
585
 
541
586
  if __name__ == "__main__":
@@ -18,7 +18,8 @@ from pathlib import Path
18
18
  NAMES = ("formalization.yaml", "formalization.yml")
19
19
  KINDS = ("theorem", "lemma", "proposition", "corollary", "definition", "remark", "example",
20
20
  "conjecture", "claim", "assumption", "hypothesis", "convention")
21
- REF = re.compile(r"\b(" + "|".join(KINDS) + r")\s+([A-Z]?\d+(?:\.\d+)*[a-z]?)", re.I)
21
+ REF = re.compile(r"\b(" + "|".join(KINDS) + r")\s+((?:[A-Z](?:\.\d+)+|[A-Z]?\d+(?:\.\d+)*)[a-z]?)",
22
+ re.I)
22
23
 
23
24
 
24
25
  def find(root: Path) -> Path | None:
@@ -20,7 +20,9 @@ from .util import norm_ws, sha256_text
20
20
 
21
21
  KINDS = ("Theorem", "Lemma", "Proposition", "Corollary", "Definition", "Remark", "Example",
22
22
  "Assumption", "Conjecture", "Claim", "Hypothesis", "Convention")
23
- HEAD = re.compile(r"^(" + "|".join(KINDS) + r")\s+([A-Z]?\d+(?:\.\d+)*[a-z]?)\s*(\.|\(|$)")
23
+ # A statement's number: "3", "8.6", "A1", "4.2a", or an appendix's "A.1", "B.10".
24
+ NUMBER = r"(?:[A-Z](?:\.\d+)+|[A-Z]?\d+(?:\.\d+)*)[a-z]?"
25
+ HEAD = re.compile(r"^(" + "|".join(KINDS) + r")\s+(" + NUMBER + r")\s*(\.|\(|$)")
24
26
  STOP = re.compile(r"^(Proof\b|\d+(\.\d+)*\s+[A-Z])")
25
27
 
26
28
 
@@ -20,9 +20,8 @@ from .reviews import load_dismissals, load_reviews
20
20
  from .schemas import validate
21
21
  from .sources import acquire
22
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)
23
+ from .util import (canonical_json, git_dirty, git_head, link_or_copy, norm_ws, prepare_work_dir,
24
+ read_json, sha256_file, sha256_text, write_json)
26
25
 
27
26
 
28
27
  def tool_version() -> str:
@@ -49,6 +48,7 @@ def project_provenance(cfg: Config) -> dict:
49
48
  def build(cfg: Config, release: str | None = None, skip_lean: bool = False,
50
49
  skip_tex: bool = False, lake: str = "lake", web_dist: Path | None = None,
51
50
  lean_build: bool = True, reuse_lean: bool = False, log=print) -> dict:
51
+ prepare_work_dir(cfg.out_dir)
52
52
  inv = read_json(cfg.inventory)
53
53
  validate(inv, "inventory")
54
54
  reviews, review_hashes = load_reviews(cfg.reviews, cfg.ai_reviewers, cfg.root)
@@ -328,4 +328,4 @@ def write_preview(site: Path, index: dict, chunks: dict, store: dict, pdfs: dict
328
328
  write_json(data / "review-queue.json", queue, indent=None)
329
329
  for pid, pdf in pdfs.items():
330
330
  (data / "papers").mkdir(parents=True, exist_ok=True)
331
- shutil.copyfile(pdf, data / "papers" / f"{pid}.pdf")
331
+ link_or_copy(pdf, data / "papers" / f"{pid}.pdf")
@@ -29,7 +29,7 @@ from .probe import fingerprint
29
29
  from .sources import SourceError, acquire
30
30
  from .tex import DEFAULT_ENVS, TexError, anchors_for, theorem_envs
31
31
  from . import formalization as fz
32
- from .util import content_id, git_head, read_json, sha256_file, write_json
32
+ from .util import content_id, git_head, prepare_work_dir, read_json, sha256_file, write_json
33
33
 
34
34
  LINKS = "machinechecked.links.json"
35
35
  OLD_LINKS = "prooflens.links.json"
@@ -275,11 +275,36 @@ def save_settings(root: Path, issues: str | None = None, site_url: str | None =
275
275
 
276
276
  # -- the Lean side ---------------------------------------------------------------------------
277
277
 
278
+ # What an index record keeps: the reader lists a declaration's statement only to this length,
279
+ # and suggestions read a docstring only for the statement numbers it cites.
280
+ INDEX_TYPE_CHARS, INDEX_DOC_CHARS = 300, 4000
281
+
282
+
283
+ def _record(line: str) -> dict:
284
+ """One probe record, as kept in memory: an index record trimmed to what is used, any other
285
+ with its canonical form replaced by its fingerprint."""
286
+ rec = json.loads(line)
287
+ if rec.get("record") == "index":
288
+ return {"record": "index", "name": rec["name"], "kind": rec["kind"],
289
+ "module": rec.get("module"), "type": (rec.get("type") or "")[:INDEX_TYPE_CHARS],
290
+ "doc": (rec.get("doc") or "")[:INDEX_DOC_CHARS] or None}
291
+ for d in [rec, rec.get("decl")]:
292
+ if isinstance(d, dict) and "canon" in d:
293
+ d["fingerprint"] = fingerprint(d.pop("canon"))
294
+ return rec
295
+
296
+
278
297
  def run_simple_probe(root: Path, modules: list[str], libs: list[str], requests: list[dict],
279
- lake: str = "lake", timeout: int = 3600) -> list[dict]:
298
+ lake: str = "lake", timeout: int = 3600,
299
+ work: Path | None = None) -> list[dict]:
300
+ """Run the Lean probe on `modules` in one session. Its output is read one record at a time
301
+ (it can be gigabytes for a large project) from a file in `work` (the working directory's
302
+ build folder, rather than a system temporary folder that may be in memory)."""
280
303
  body = resources.files("prooflens").joinpath("data/ProofLensProbe.lean").read_text()
281
304
  header = "import Lean\n" + "".join(f"import {m}\n" for m in modules) + "\n"
282
- with tempfile.TemporaryDirectory(prefix="prooflens-view-") as tmp:
305
+ if work is not None:
306
+ work.mkdir(parents=True, exist_ok=True)
307
+ with tempfile.TemporaryDirectory(prefix="prooflens-view-", dir=work) as tmp:
283
308
  tmp = Path(tmp)
284
309
  (tmp / "Probe.lean").write_text(header + body)
285
310
  (tmp / "query.json").write_text(json.dumps({
@@ -292,12 +317,8 @@ def run_simple_probe(root: Path, modules: list[str], libs: list[str], requests:
292
317
  out = tmp / "out.jsonl"
293
318
  if r.returncode != 0 or not out.exists():
294
319
  raise ViewError(f"the Lean probe failed:\n{r.stdout[-3000:]}\n{r.stderr[-3000:]}")
295
- recs = [json.loads(l) for l in out.read_text(encoding="utf-8").splitlines() if l.strip()]
296
- for rec in recs:
297
- for d in [rec, rec.get("decl")]:
298
- if isinstance(d, dict) and "canon" in d:
299
- d["fingerprint"] = fingerprint(d.pop("canon"))
300
- return recs
320
+ with out.open(encoding="utf-8") as f:
321
+ return [_record(l) for l in f if l.strip()]
301
322
 
302
323
 
303
324
  def library_of(module: str, libs: list[str]) -> str:
@@ -307,7 +328,8 @@ def library_of(module: str, libs: list[str]) -> str:
307
328
 
308
329
 
309
330
  def probe_modules(root: Path, modules: list[str], libs: list[str], requests: list[dict],
310
- lake: str = "lake", log=print) -> tuple[list[dict], dict[str, str]]:
331
+ lake: str = "lake", log=print,
332
+ work: Path | None = None) -> tuple[list[dict], dict[str, str]]:
311
333
  """Probe the built modules without assuming they can all be imported into one Lean session.
312
334
 
313
335
  A project may build modules that do not belong together: two libraries declaring the same
@@ -320,7 +342,7 @@ def probe_modules(root: Path, modules: list[str], libs: list[str], requests: lis
320
342
 
321
343
  def run(group: list[str], label: str):
322
344
  try:
323
- out = run_simple_probe(root, group, libs, requests, lake)
345
+ out = run_simple_probe(root, group, libs, requests, lake, work=work)
324
346
  except ViewError as e:
325
347
  if len(group) == 1:
326
348
  failed[group[0]] = str(e)[-600:]
@@ -497,6 +519,7 @@ def paper_title(p: Path) -> str:
497
519
  def build_view(root: Path, paper_path: Path, out_dir: Path, lake: str = "lake",
498
520
  log=print) -> dict:
499
521
  """Everything the reader shows for `paper_path` in the Lean project at `root`."""
522
+ prepare_work_dir(out_dir)
500
523
  libs = lib_names(root)
501
524
  built, unbuilt = built_modules(root, libs) if libs else ([], [])
502
525
  cfg = view_config(root, paper_path, out_dir, libs)
@@ -569,41 +592,69 @@ def build_view(root: Path, paper_path: Path, out_dir: Path, lake: str = "lake",
569
592
  if unbuilt:
570
593
  notes.append(f"{len(unbuilt)} module(s) of the project are not built and are not "
571
594
  f"indexed")
572
- # The index is reused while no Lean input of the project changed.
573
- icache = out_dir / "build" / "index-cache.json"
595
+ # The names linked before any suggestion (confirmed links, \lean tags, formalization.yaml),
596
+ # less those whose module is known not to be built: Lean cannot answer for those.
597
+ for c in claims:
598
+ c["link_names"] = linked_names(c, links.get(c["key"], {}))
599
+ unbuilt_set = set(unbuilt)
600
+ linked = {n for c in claims for n in c["link_names"]
601
+ if c.get("declared_modules", {}).get(n) not in unbuilt_set}
602
+ work = out_dir / "build"
603
+
604
+ def take(recs):
605
+ for r in recs:
606
+ if r["record"] == "link":
607
+ linkrecs.setdefault(r["name"], []).append(r)
608
+ elif r["record"] == "decl":
609
+ decls.setdefault(r["name"], r)
610
+
611
+ # The index is reused while no Lean input of the project changed. Built afresh, it is
612
+ # made in the same sessions as the linked names' records, so the modules load once.
613
+ icache = work / "index-cache.json"
574
614
  idigest = lean_digest(root)
575
615
  cached = read_json(icache) if icache.exists() else {}
576
616
  failed = {}
577
617
  if cached.get("digest") == idigest:
578
618
  index, failed = cached["index"], cached.get("failed", {})
579
619
  else:
580
- log(f"lean: indexing {len(built)} built module(s)")
581
- recs, failed = probe_modules(root, built, libs, [{"kind": "index", "name": ""}],
582
- lake, log)
620
+ log(f"lean: indexing {len(built)} built module(s)"
621
+ + (f" and checking {len(linked)} linked declaration(s)" if linked else ""))
622
+ recs, failed = probe_modules(root, built, libs, [{"kind": "index", "name": ""}]
623
+ + [{"kind": "link", "name": n} for n in sorted(linked)],
624
+ lake, log, work)
583
625
  index = [r for r in recs if r["record"] == "index"]
626
+ take(recs)
584
627
  write_json(icache, {"digest": idigest, "index": index, "failed": failed},
585
628
  indent=None)
586
- wanted = set()
629
+ wanted = set(linked)
587
630
  for c in claims:
588
631
  entry = links.get(c["key"], {})
589
- c["link_names"] = linked_names(c, entry)
590
632
  c["suggestions"] = [s for s in suggest(c, index)
591
633
  if s["name"] not in entry.get("rejected", [])
592
634
  and s["name"] not in c["link_names"]]
593
- wanted |= set(c["link_names"])
594
635
  wanted |= {s["name"] for s in c["suggestions"][:1] if s.get("kind")}
636
+ wanted -= set(linkrecs)
595
637
  if wanted:
638
+ # Only the modules that declare the names are loaded (with what they import). A name
639
+ # the index does not hold (from a dependency, say) is looked for in every loadable
640
+ # module, unless its source shows it in a module that is not built.
596
641
  log(f"lean: checking {len(wanted)} linked or suggested declaration(s)")
597
642
  loadable = [m for m in built if m not in failed]
598
- recs, more = probe_modules(root, loadable, libs,
599
- [{"kind": "link", "name": n} for n in sorted(wanted)],
600
- lake, log)
601
- failed.update(more)
602
- for r in recs:
603
- if r["record"] == "link":
604
- linkrecs.setdefault(r["name"], []).append(r)
605
- elif r["record"] == "decl":
606
- decls.setdefault(r["name"], r)
643
+ where: dict[str, set[str]] = {}
644
+ for r in index:
645
+ if r["name"] in wanted and r.get("module") in loadable:
646
+ where.setdefault(r["name"], set()).add(r["module"])
647
+ elsewhere = sorted(n for n in wanted if n not in where
648
+ and not declaring_modules(root, unbuilt, n))
649
+ groups = [(sorted(set().union(*where.values())), sorted(where))] if where else []
650
+ if elsewhere:
651
+ groups.append((loadable, elsewhere))
652
+ for mods, names in groups:
653
+ recs, more = probe_modules(root, mods, libs,
654
+ [{"kind": "link", "name": n} for n in names],
655
+ lake, log, work)
656
+ failed.update(more)
657
+ take(recs)
607
658
  if failed:
608
659
  notes.append(f"{len(failed)} built module(s) could not be loaded by MachineChecked "
609
660
  f"and are not indexed: {', '.join(sorted(failed)[:5])}"
@@ -16,6 +16,7 @@ from dataclasses import dataclass
16
16
  from pathlib import Path
17
17
 
18
18
  from .config import Config, Paper
19
+ from .util import link_or_copy
19
20
 
20
21
 
21
22
  class SourceError(RuntimeError):
@@ -49,6 +50,24 @@ def _latex_roots(d: Path) -> list[str]:
49
50
  return roots
50
51
 
51
52
 
53
+ SKIP = (".git", ".lake", ".machinechecked", ".prooflens")
54
+
55
+
56
+ def _has_latex_root(d: Path) -> bool:
57
+ """Whether a copy of `d` (without the directories a snapshot leaves out) has a LaTeX root."""
58
+ for top, dirs, files in os.walk(d):
59
+ dirs[:] = [x for x in dirs if x not in SKIP]
60
+ for f in files:
61
+ if f.endswith(".tex"):
62
+ try:
63
+ if DOCCLASS.search((Path(top) / f).read_text(encoding="utf-8",
64
+ errors="replace")):
65
+ return True
66
+ except OSError:
67
+ continue
68
+ return False
69
+
70
+
52
71
  def _pdfs(d: Path) -> list[str]:
53
72
  return sorted(str(p.relative_to(d)) for p in d.rglob("*.pdf"))
54
73
 
@@ -105,10 +124,15 @@ def acquire(cfg: Config, paper: Paper, dest: Path) -> Snapshot:
105
124
  base = (cfg.root / src["path"]).resolve()
106
125
  if not base.is_dir():
107
126
  raise SourceError(f"paper {paper.id}: {base} is not a directory")
108
- shutil.copytree(base, dest, dirs_exist_ok=True, copy_function=shutil.copy2,
109
- ignore_dangling_symlinks=True,
110
- ignore=shutil.ignore_patterns(".git", ".lake", ".machinechecked", ".prooflens",
111
- "*.aux", "*.log", "*.fls"))
127
+ if paper.pdf and not paper.root and (base / paper.pdf).is_file() \
128
+ and not _has_latex_root(base):
129
+ # A paper that is only a PDF needs only that file, not the directory it is in (the
130
+ # source would be read as the same PDF either way).
131
+ link_or_copy(base / paper.pdf, dest / paper.pdf)
132
+ else:
133
+ shutil.copytree(base, dest, dirs_exist_ok=True, copy_function=shutil.copy2,
134
+ ignore_dangling_symlinks=True,
135
+ ignore=shutil.ignore_patterns(*SKIP, "*.aux", "*.log", "*.fls"))
112
136
  kind, rev, commit_time = "path", None, None
113
137
  elif "git" in src:
114
138
  rev = src.get("rev")
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import json
7
+ import os
7
8
  import re
9
+ import shutil
8
10
  import subprocess
9
11
  from pathlib import Path
10
12
 
@@ -64,3 +66,33 @@ def write_json(path: str | Path, obj, indent: int | None = 1) -> None:
64
66
 
65
67
  def read_json(path: str | Path):
66
68
  return json.loads(Path(path).read_text(encoding="utf-8"))
69
+
70
+
71
+ def link_or_copy(src: str | Path, dst: str | Path) -> None:
72
+ """Put `src` at `dst` as a hard link (the file is then stored once), else as a copy."""
73
+ dst = Path(dst)
74
+ dst.parent.mkdir(parents=True, exist_ok=True)
75
+ dst.unlink(missing_ok=True)
76
+ try:
77
+ os.link(src, dst)
78
+ except OSError:
79
+ shutil.copy2(src, dst)
80
+
81
+
82
+ def prepare_work_dir(d: str | Path) -> Path:
83
+ """Create the working directory (`.machinechecked/`) and have Git ignore it, as Lake does
84
+ `.lake/`: a `.gitignore` of `*` inside it, unless it has one or Git already tracks files in
85
+ it."""
86
+ d = Path(d)
87
+ d.mkdir(parents=True, exist_ok=True)
88
+ ignore = d / ".gitignore"
89
+ if not ignore.exists():
90
+ try:
91
+ tracked = subprocess.run(["git", "ls-files", "--", "."], cwd=d, capture_output=True,
92
+ text=True).stdout.strip()
93
+ except OSError: # no Git: nothing can be tracked
94
+ tracked = ""
95
+ if not tracked:
96
+ ignore.write_text("# MachineChecked's working files, never committed.\n*\n")
97
+ return d
98
+