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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. 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."""