puremethod 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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
writer/lint.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Checks before compiling (06-writer.md §5 step 5, INV-1, INV-2, INV-4, INV-5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
# Commands the LLM may not use: anything that reads or writes files, defines or redefines
|
|
8
|
+
# macros, or builds tables and graphics itself (tables and figures come from code).
|
|
9
|
+
FORBIDDEN = re.compile(
|
|
10
|
+
r"\\(input|include|includeonly|write|immediate|openout|openin|read|catcode|def|edef|"
|
|
11
|
+
r"gdef|xdef|let|newcommand|renewcommand|providecommand|DeclareRobustCommand|csname|"
|
|
12
|
+
r"usepackage|documentclass|begin\{(tabular|table|figure|tikzpicture|verbatim)|"
|
|
13
|
+
r"includegraphics|special|directlua|ShellEscape|bibliography|addbibresource)"
|
|
14
|
+
r"(?![A-Za-z])") # not \b: `\write18` (shell escape) has a digit right after the name
|
|
15
|
+
|
|
16
|
+
# Removed before looking for numbers: the macros, references, labels and citations whose
|
|
17
|
+
# arguments may hold digits (the KB allowlist).
|
|
18
|
+
_ALLOWED = re.compile(
|
|
19
|
+
r"\\pm(?:val|agg|cmp|cfg)\{[^{}]*\}(?:\{[^{}]*\})?"
|
|
20
|
+
r"|\\pm(?:table|figure)\{[^{}]*\}"
|
|
21
|
+
r"|\\(?:cite|citep|citet|ref|eqref|autoref|label)\{[^{}]*\}")
|
|
22
|
+
_DIGIT = re.compile(r"\d")
|
|
23
|
+
_NUMBER_WORDS = (r"zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|"
|
|
24
|
+
r"twenty|thirty|forty|fifty|hundred|thousand|million|billion|half|double|"
|
|
25
|
+
r"twice|triple|thrice|several-fold")
|
|
26
|
+
_UNIT_WORDS = (r"percent|per\s*cent|percentage|points?|times|fold|seeds?|epochs?|ms|"
|
|
27
|
+
r"milliseconds?|seconds?|minutes?|hours?|layers?|parameters|samples|nodes|"
|
|
28
|
+
r"steps|iterations|x|%")
|
|
29
|
+
_SPELLED = re.compile(rf"\b({_NUMBER_WORDS})\b[\s-]*({_UNIT_WORDS})\b", re.IGNORECASE)
|
|
30
|
+
# Multipliers are quantity claims on their own ("twice as fast", "a threefold gain").
|
|
31
|
+
_MULTIPLIER = re.compile(r"\b(twice|thrice|double[sd]?|triple[sd]?|halve[sd]?|"
|
|
32
|
+
r"\w+fold)\b", re.IGNORECASE)
|
|
33
|
+
_CITE = re.compile(r"\\cite[pt]?\{([^{}]*)\}")
|
|
34
|
+
_FINDING = re.compile(r"\b(we\s+find|we\s+found|finding|findings|our\s+results\s+show|"
|
|
35
|
+
r"we\s+demonstrate|demonstrates?\s+that|proves?\s+that|"
|
|
36
|
+
r"establish(?:es)?\s+that|confirm(?:s|ed)?\s+that)\b", re.IGNORECASE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def forbidden(latex: str) -> list[str]:
|
|
40
|
+
return [f"forbidden command \\{m.group(1)}" for m in FORBIDDEN.finditer(latex)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bare_numbers(latex: str) -> list[str]:
|
|
44
|
+
"""Digits outside the allowlist, and spelled-out quantities with a unit (INV-1)."""
|
|
45
|
+
stripped = _ALLOWED.sub(" ", latex)
|
|
46
|
+
problems = []
|
|
47
|
+
for m in _DIGIT.finditer(stripped):
|
|
48
|
+
start = max(m.start() - 20, 0)
|
|
49
|
+
problems.append(f"bare number near: ...{stripped[start:m.end() + 20].strip()}...")
|
|
50
|
+
break # one example per section is enough for a retry prompt
|
|
51
|
+
for m in _SPELLED.finditer(stripped):
|
|
52
|
+
problems.append(f"spelled-out quantity: {m.group(0)!r}")
|
|
53
|
+
for m in _MULTIPLIER.finditer(stripped):
|
|
54
|
+
problems.append(f"multiplier without a macro: {m.group(0)!r}")
|
|
55
|
+
return problems
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cite_keys(latex: str) -> list[str]:
|
|
59
|
+
keys = []
|
|
60
|
+
for m in _CITE.finditer(latex):
|
|
61
|
+
keys += [k.strip() for k in m.group(1).split(",") if k.strip()]
|
|
62
|
+
return keys
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def unknown_citations(latex: str, pool: set[str]) -> list[str]:
|
|
66
|
+
return [f"citation key {k!r} is not in the citation pool" for k in cite_keys(latex)
|
|
67
|
+
if k not in pool]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def finding_wording(latex: str) -> list[str]:
|
|
71
|
+
m = _FINDING.search(latex)
|
|
72
|
+
return [f"findings wording ({m.group(0)!r}) outside a confirmed finding"] if m else []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_section(latex: str, *, pool: set[str], allow_findings: bool,
|
|
76
|
+
required_cites: list[str], macro_problems: list[str]) -> list[str]:
|
|
77
|
+
problems = forbidden(latex) + bare_numbers(latex) + unknown_citations(latex, pool)
|
|
78
|
+
problems += macro_problems
|
|
79
|
+
if not allow_findings:
|
|
80
|
+
problems += finding_wording(latex)
|
|
81
|
+
used = set(cite_keys(latex))
|
|
82
|
+
for key in required_cites:
|
|
83
|
+
if key not in used:
|
|
84
|
+
problems.append(f"must cite the closest prior work {key!r}")
|
|
85
|
+
return problems
|
writer/macros.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Numeric macros, resolved by code from the ledger (06-writer.md §4, INV-1, INV-3).
|
|
2
|
+
|
|
3
|
+
`\\pmval{<experiment>}{<metric>}`, `\\pmagg{<decision>}{<metric>}`, `\\pmcmp{<decision>}`,
|
|
4
|
+
`\\pmcfg{<prereg>}{<field>}`. Values print with three significant figures and their unit;
|
|
5
|
+
`\\pmagg` prints mean ± standard deviation and n (KB-017). `\\pmagg` summarises the
|
|
6
|
+
treatment arm, on the data role its stage decides on (held-out at `confirm`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from .facts import RunFacts
|
|
16
|
+
|
|
17
|
+
MACRO_RE = re.compile(r"\\pm(val|agg|cmp|cfg)\{([^{}]*)\}(?:\{([^{}]*)\})?")
|
|
18
|
+
NO_UNIT = {"", "none", "fraction", "ratio", "count", "1"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MacroError(ValueError):
|
|
22
|
+
"""A macro that does not resolve to ledger data."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sig3(value: float) -> str:
|
|
26
|
+
"""Three significant figures, LaTeX-ready (scientific form outside 1e-3 to 1e4)."""
|
|
27
|
+
if value == 0 or not math.isfinite(value):
|
|
28
|
+
return "0" if value == 0 else str(value)
|
|
29
|
+
exp = math.floor(math.log10(abs(value)))
|
|
30
|
+
if exp >= 4 or exp < -3:
|
|
31
|
+
mant = value / 10 ** exp
|
|
32
|
+
return f"${mant:.2f}\\times10^{{{exp}}}$"
|
|
33
|
+
digits = 2 - exp # round to three significant figures, also above the decimal point
|
|
34
|
+
return f"{round(value, digits):.{max(digits, 0)}f}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def latex_escape(text: str) -> str:
|
|
38
|
+
repl = {"\\": r"\textbackslash{}", "&": r"\&", "%": r"\%", "$": r"\$", "#": r"\#",
|
|
39
|
+
"_": r"\_", "{": r"\{", "}": r"\}", "~": r"\textasciitilde{}",
|
|
40
|
+
"^": r"\textasciicircum{}"}
|
|
41
|
+
return "".join(repl.get(c, c) for c in text)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _with_unit(number: str, unit: str) -> str:
|
|
45
|
+
return number if unit.lower() in NO_UNIT else f"{number}~{latex_escape(unit)}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Resolver:
|
|
50
|
+
facts: RunFacts
|
|
51
|
+
trace_map: dict[str, list[str]] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def resolve(self, latex: str) -> str:
|
|
54
|
+
return MACRO_RE.sub(lambda m: self._one(m.group(1), m.group(2), m.group(3)), latex)
|
|
55
|
+
|
|
56
|
+
def check(self, latex: str) -> list[str]:
|
|
57
|
+
"""Every macro in the text resolves (INV-1); returns the problems."""
|
|
58
|
+
problems = []
|
|
59
|
+
for m in MACRO_RE.finditer(latex):
|
|
60
|
+
try:
|
|
61
|
+
self._one(m.group(1), m.group(2), m.group(3), dry=True)
|
|
62
|
+
except MacroError as exc:
|
|
63
|
+
problems.append(str(exc))
|
|
64
|
+
return problems
|
|
65
|
+
|
|
66
|
+
def _one(self, kind: str, a: str, b: str | None, *, dry: bool = False) -> str:
|
|
67
|
+
f = self.facts
|
|
68
|
+
a = a.strip()
|
|
69
|
+
b = b.strip() if b else None
|
|
70
|
+
if kind == "val":
|
|
71
|
+
started = f.finished_to_started.get(a, a)
|
|
72
|
+
exp = f.experiments.get(started)
|
|
73
|
+
if exp is None or b is None:
|
|
74
|
+
raise MacroError(f"\\pmval{{{a}}}: no such experiment")
|
|
75
|
+
hit = [m for m in exp.metrics if m["name"] == b]
|
|
76
|
+
if not hit:
|
|
77
|
+
raise MacroError(f"\\pmval{{{a}}}{{{b}}}: metric not recorded")
|
|
78
|
+
out = _with_unit(sig3(float(hit[0]["value"])), str(hit[0].get("unit", "")))
|
|
79
|
+
refs = [exp.started_id] + ([exp.finished_id] if exp.finished_id else [])
|
|
80
|
+
elif kind in ("agg", "cmp"):
|
|
81
|
+
if a not in f.decisions:
|
|
82
|
+
raise MacroError(f"\\pm{kind}{{{a}}}: no such decision")
|
|
83
|
+
if kind == "agg":
|
|
84
|
+
if b is None:
|
|
85
|
+
raise MacroError(f"\\pmagg{{{a}}}: metric missing")
|
|
86
|
+
s = f.summary(a, b, "treatment")
|
|
87
|
+
if s is None:
|
|
88
|
+
raise MacroError(f"\\pmagg{{{a}}}{{{b}}}: no treatment values")
|
|
89
|
+
m, sd, n = s
|
|
90
|
+
out = _with_unit(f"{sig3(m)}~$\\pm$~{sig3(sd)}", f.unit(a, b)) + f" ($n={n}$)"
|
|
91
|
+
else:
|
|
92
|
+
cmp_ = f.decisions[a].comparison
|
|
93
|
+
if "value" not in cmp_:
|
|
94
|
+
raise MacroError(f"\\pmcmp{{{a}}}: the decision recorded no comparison")
|
|
95
|
+
out = sig3(float(cmp_["value"]))
|
|
96
|
+
refs = f.trace(a)
|
|
97
|
+
elif kind == "cfg":
|
|
98
|
+
pre = f.preregs.get(a)
|
|
99
|
+
if pre is None or b is None:
|
|
100
|
+
raise MacroError(f"\\pmcfg{{{a}}}: no such pre-registration")
|
|
101
|
+
value = {"threshold": pre.get("comparison", {}).get("threshold"),
|
|
102
|
+
"wall_clock_s": pre.get("budget", {}).get("wall_clock_s"),
|
|
103
|
+
"money_usd": pre.get("budget", {}).get("money_usd_estimate"),
|
|
104
|
+
"seeds": len(pre.get("seeds", {}).get("values", []))}.get(b)
|
|
105
|
+
if value is None:
|
|
106
|
+
raise MacroError(f"\\pmcfg{{{a}}}{{{b}}}: unknown field")
|
|
107
|
+
out = sig3(float(value)) if b != "seeds" else str(int(value))
|
|
108
|
+
refs = [a]
|
|
109
|
+
else: # pragma: no cover - the regex admits only the four kinds
|
|
110
|
+
raise MacroError(kind)
|
|
111
|
+
if not dry:
|
|
112
|
+
key = f"pm{kind}:{a}" + (f":{b}" if b else "")
|
|
113
|
+
self.trace_map[key] = refs
|
|
114
|
+
return out
|
writer/render.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Tables and figures rendered by code from ledger data (06-writer.md §5 step 4).
|
|
2
|
+
|
|
3
|
+
The LLM gives a spec: which decisions, which metric, a caption. Every value comes from the
|
|
4
|
+
ledger; the trace map records the entries behind each table and figure (INV-3).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .facts import RunFacts
|
|
15
|
+
from .macros import NO_UNIT, latex_escape, sig3
|
|
16
|
+
|
|
17
|
+
_ID = re.compile(r"^[A-Za-z0-9_-]{1,40}$")
|
|
18
|
+
TABLE_SCHEMA: dict[str, Any] = {
|
|
19
|
+
"type": "object", "additionalProperties": False,
|
|
20
|
+
"required": ["id", "caption", "metric", "decisions"],
|
|
21
|
+
"properties": {"id": {"type": "string"}, "caption": {"type": "string"},
|
|
22
|
+
"metric": {"type": "string"},
|
|
23
|
+
"decisions": {"type": "array", "items": {"type": "string"},
|
|
24
|
+
"minItems": 1, "maxItems": 12}},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SpecError(ValueError):
|
|
29
|
+
"""A table or figure spec that cannot be rendered from the ledger."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Spec:
|
|
34
|
+
id: str
|
|
35
|
+
caption: str
|
|
36
|
+
metric: str
|
|
37
|
+
decisions: tuple[str, ...]
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def parse(raw: dict[str, Any], facts: RunFacts) -> Spec:
|
|
41
|
+
spec = Spec(id=str(raw["id"]), caption=str(raw["caption"]), metric=str(raw["metric"]),
|
|
42
|
+
decisions=tuple(str(d) for d in raw["decisions"]))
|
|
43
|
+
if not _ID.match(spec.id):
|
|
44
|
+
raise SpecError(f"spec id {spec.id!r} must be letters, digits, _ or -")
|
|
45
|
+
for d in spec.decisions:
|
|
46
|
+
if d not in facts.decisions:
|
|
47
|
+
raise SpecError(f"spec {spec.id}: {d!r} is not a decision of this run")
|
|
48
|
+
if facts.summary(d, spec.metric, "treatment") is None:
|
|
49
|
+
raise SpecError(f"spec {spec.id}: no {spec.metric!r} values for {d!r}")
|
|
50
|
+
return spec
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _label(facts: RunFacts, decision: str) -> str:
|
|
54
|
+
d = facts.decisions[decision]
|
|
55
|
+
statement = facts.hypotheses.get(d.hypothesis, {}).get("statement", d.hypothesis)
|
|
56
|
+
text = str(statement)
|
|
57
|
+
return text if len(text) <= 60 else text[:57] + "..."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cell(s: tuple[float, float, int] | None) -> str:
|
|
61
|
+
return f"{sig3(s[0])} $\\pm$ {sig3(s[1])}" if s else "--"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_table(spec: Spec, facts: RunFacts) -> tuple[str, list[str]]:
|
|
65
|
+
rows, refs = [], []
|
|
66
|
+
for d in spec.decisions:
|
|
67
|
+
t = facts.summary(d, spec.metric, "treatment")
|
|
68
|
+
b = facts.summary(d, spec.metric, "baseline")
|
|
69
|
+
dec = facts.decisions[d]
|
|
70
|
+
rows.append(f"{latex_escape(_label(facts, d))} & {latex_escape(dec.stage)}"
|
|
71
|
+
f" & {_cell(t)} & {_cell(b)} & {latex_escape(dec.outcome)} \\\\")
|
|
72
|
+
refs += facts.trace(d)
|
|
73
|
+
unit = facts.unit(spec.decisions[0], spec.metric)
|
|
74
|
+
shown = unit.lower() not in NO_UNIT
|
|
75
|
+
head = latex_escape(spec.metric) + (f" ({latex_escape(unit)})" if shown else "")
|
|
76
|
+
body = "\n".join(rows)
|
|
77
|
+
tex = (f"\\begin{{table}}[t]\\centering\\small\n\\caption{{{latex_escape(spec.caption)}}}"
|
|
78
|
+
f"\\label{{tab:{spec.id}}}\n\\begin{{tabular}}{{p{{4.2cm}}lllr}}\\toprule\n"
|
|
79
|
+
f"Hypothesis & Stage & Treatment {head} & Baseline & Decision \\\\ \\midrule\n"
|
|
80
|
+
f"{body}\n\\bottomrule\\end{{tabular}}\n\\end{{table}}")
|
|
81
|
+
return tex, refs
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_figure(spec: Spec, facts: RunFacts) -> tuple[str, bytes, list[str]]:
|
|
85
|
+
import matplotlib
|
|
86
|
+
|
|
87
|
+
matplotlib.use("Agg")
|
|
88
|
+
import matplotlib.pyplot as plt
|
|
89
|
+
|
|
90
|
+
labels, t_mean, t_sd, b_mean, b_sd, refs = [], [], [], [], [], []
|
|
91
|
+
for i, d in enumerate(spec.decisions, start=1):
|
|
92
|
+
t = facts.summary(d, spec.metric, "treatment")
|
|
93
|
+
b = facts.summary(d, spec.metric, "baseline")
|
|
94
|
+
labels.append(f"H{i}")
|
|
95
|
+
t_mean.append(t[0] if t else 0.0)
|
|
96
|
+
t_sd.append(t[1] if t else 0.0)
|
|
97
|
+
b_mean.append(b[0] if b else 0.0)
|
|
98
|
+
b_sd.append(b[1] if b else 0.0)
|
|
99
|
+
refs += facts.trace(d)
|
|
100
|
+
fig, ax = plt.subplots(figsize=(4.0, 2.6), dpi=150)
|
|
101
|
+
x = range(len(labels))
|
|
102
|
+
width = 0.38
|
|
103
|
+
ax.bar([i - width / 2 for i in x], t_mean, width, yerr=t_sd, label="treatment",
|
|
104
|
+
color="#4C72B0", capsize=3)
|
|
105
|
+
ax.bar([i + width / 2 for i in x], b_mean, width, yerr=b_sd, label="baseline",
|
|
106
|
+
color="#B0B0B0", capsize=3)
|
|
107
|
+
ax.set_xticks(list(x), labels)
|
|
108
|
+
unit = facts.unit(spec.decisions[0], spec.metric)
|
|
109
|
+
ax.set_ylabel(spec.metric + (f" ({unit})" if unit.lower() not in NO_UNIT else ""))
|
|
110
|
+
ax.legend(frameon=False, fontsize=7)
|
|
111
|
+
fig.tight_layout()
|
|
112
|
+
buf = io.BytesIO()
|
|
113
|
+
fig.savefig(buf, format="pdf", metadata={"CreationDate": None})
|
|
114
|
+
plt.close(fig)
|
|
115
|
+
key = "; ".join(f"H{i}: {latex_escape(_label(facts, d))}"
|
|
116
|
+
for i, d in enumerate(spec.decisions, start=1))
|
|
117
|
+
tex = (f"\\begin{{figure}}[t]\\centering\n\\includegraphics[width=\\linewidth]"
|
|
118
|
+
f"{{fig-{spec.id}.pdf}}\n\\caption{{{latex_escape(spec.caption)} ({key})}}"
|
|
119
|
+
f"\\label{{fig:{spec.id}}}\n\\end{{figure}}")
|
|
120
|
+
return tex, buf.getvalue(), refs
|
writer/tectonic.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tectonic in Docker (06-writer.md §5 step 7, KB-017).
|
|
2
|
+
|
|
3
|
+
The image is built from a pinned Tectonic release whose archive is checked against its
|
|
4
|
+
SHA-256. The TeX bundle is fetched on first use and cached in a Docker volume.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .types import CompilerUnavailable
|
|
15
|
+
|
|
16
|
+
TECTONIC_VERSION = "0.17.0"
|
|
17
|
+
TECTONIC_SHA256 = "8533d07f9ccbd7a65824b9e0459041bca34af1eb33daba48f59215593753a3b7"
|
|
18
|
+
TECTONIC_URL = ("https://github.com/tectonic-typesetting/tectonic/releases/download/"
|
|
19
|
+
f"tectonic%40{TECTONIC_VERSION}/"
|
|
20
|
+
f"tectonic-{TECTONIC_VERSION}-x86_64-unknown-linux-musl.tar.gz")
|
|
21
|
+
DEFAULT_IMAGE = f"pm-tectonic:{TECTONIC_VERSION}"
|
|
22
|
+
CACHE_VOLUME = "pm-tectonic-cache"
|
|
23
|
+
|
|
24
|
+
DOCKERFILE = f"""FROM python:3.11-slim
|
|
25
|
+
RUN python - <<'PY'
|
|
26
|
+
import hashlib, io, tarfile, urllib.request
|
|
27
|
+
data = urllib.request.urlopen("{TECTONIC_URL}", timeout=120).read()
|
|
28
|
+
assert hashlib.sha256(data).hexdigest() == "{TECTONIC_SHA256}", "tectonic checksum mismatch"
|
|
29
|
+
with tarfile.open(fileobj=io.BytesIO(data)) as t:
|
|
30
|
+
t.extract("tectonic", "/usr/local/bin", filter="data")
|
|
31
|
+
PY
|
|
32
|
+
RUN chmod 0755 /usr/local/bin/tectonic
|
|
33
|
+
WORKDIR /doc
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CompileResult:
|
|
39
|
+
ok: bool
|
|
40
|
+
pdf: bytes | None
|
|
41
|
+
log: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _docker(*args: str, timeout: float = 600, stdin: str | None = None
|
|
45
|
+
) -> subprocess.CompletedProcess[str]:
|
|
46
|
+
try:
|
|
47
|
+
return subprocess.run(["docker", *args], capture_output=True, text=True, # noqa: S603, S607
|
|
48
|
+
check=False, timeout=timeout, input=stdin)
|
|
49
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
50
|
+
raise CompilerUnavailable(f"docker: {type(exc).__name__}") from None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_image(image: str = DEFAULT_IMAGE) -> None:
|
|
54
|
+
if _docker("image", "inspect", image, timeout=60).returncode == 0:
|
|
55
|
+
return
|
|
56
|
+
with tempfile.TemporaryDirectory(prefix="pm-tectonic-") as ctx:
|
|
57
|
+
Path(ctx, "Dockerfile").write_text(DOCKERFILE, encoding="utf-8")
|
|
58
|
+
out = _docker("build", "-t", image, ctx, timeout=1800)
|
|
59
|
+
if out.returncode != 0:
|
|
60
|
+
raise CompilerUnavailable(f"building {image} failed: {(out.stdout + out.stderr)[-800:]}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compile_tex(files: dict[str, bytes], image: str = DEFAULT_IMAGE) -> CompileResult:
|
|
64
|
+
"""Compile `main.tex` with the given files; network only for the TeX bundle cache."""
|
|
65
|
+
ensure_image(image)
|
|
66
|
+
with tempfile.TemporaryDirectory(prefix="pm-paper-") as work:
|
|
67
|
+
for name, data in files.items():
|
|
68
|
+
Path(work, name).write_bytes(data)
|
|
69
|
+
out = _docker("run", "--rm", "-v", f"{Path(work).resolve()}:/doc", "-w", "/doc",
|
|
70
|
+
"-v", f"{CACHE_VOLUME}:/root/.cache", image,
|
|
71
|
+
"tectonic", "-X", "compile", "--keep-logs", "main.tex", timeout=900)
|
|
72
|
+
log_path = Path(work, "main.log")
|
|
73
|
+
log = (log_path.read_text(encoding="utf-8", errors="replace")
|
|
74
|
+
if log_path.exists() else "")
|
|
75
|
+
log = (out.stdout + out.stderr + "\n" + log)[-20000:]
|
|
76
|
+
pdf_path = Path(work, "main.pdf")
|
|
77
|
+
if out.returncode == 0 and pdf_path.exists():
|
|
78
|
+
return CompileResult(ok=True, pdf=pdf_path.read_bytes(), log=log)
|
|
79
|
+
return CompileResult(ok=False, pdf=None, log=log)
|
writer/types.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Writer types (06-writer.md §3, §4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Literal, Protocol
|
|
8
|
+
|
|
9
|
+
Template = Literal["article", "ieeetran"]
|
|
10
|
+
Kind = Literal["paper", "report"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RunContext(Protocol):
|
|
14
|
+
"""Provided by `loop` (06-writer.md §3): limits before, records after, every call."""
|
|
15
|
+
|
|
16
|
+
def guard(self, max_cost_usd: float) -> None:
|
|
17
|
+
"""Raise if a call with this maximum cost would break the run's limits."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def record(self, call_record: Any, artifacts: dict[str, bytes], *,
|
|
21
|
+
citation_records: list[dict[str, Any]] | None = None) -> None:
|
|
22
|
+
"""Hand one LLM or PureScholar call to `loop` for the ledger."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PaperOptions:
|
|
28
|
+
title: str
|
|
29
|
+
template: Template = "article"
|
|
30
|
+
disclosure: bool = True
|
|
31
|
+
kind: Kind = "paper"
|
|
32
|
+
findings: tuple[str, ...] = () # hypothesis entry ids with `confirm` decisions
|
|
33
|
+
others: tuple[str, ...] = () # negative and exploratory hypothesis ids
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class BuildResult:
|
|
38
|
+
status: Literal["built", "failed"]
|
|
39
|
+
reasons: list[str]
|
|
40
|
+
files: dict[str, bytes] # main.tex, refs, figures, main.pdf (when built)
|
|
41
|
+
trace_map: dict[str, list[str]]
|
|
42
|
+
citation_keys: list[str]
|
|
43
|
+
lint_log: list[str] = field(default_factory=list)
|
|
44
|
+
compile_log: str = ""
|
|
45
|
+
sections: dict[str, str] = field(default_factory=dict) # LLM LaTeX per section key
|
|
46
|
+
|
|
47
|
+
def digests(self) -> dict[str, str]:
|
|
48
|
+
return {name: "sha256:" + hashlib.sha256(data).hexdigest()
|
|
49
|
+
for name, data in self.files.items()}
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def pdf(self) -> bytes | None:
|
|
53
|
+
return self.files.get("main.pdf")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WriterError(Exception):
|
|
57
|
+
"""Base error: a fault that is not a failed build (a failed build returns `failed`)."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CompilerUnavailable(WriterError):
|
|
61
|
+
"""Docker or the Tectonic image cannot be used."""
|