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/core.py ADDED
@@ -0,0 +1,430 @@
1
+ """The Writer (06-writer.md): the LLM writes prose; code supplies every number, table,
2
+ figure and citation, and a paper that fails a check is not built (INV-8)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ledger import Ledger
13
+ from llm import LLM, Message
14
+ from purescholar import PureScholar
15
+
16
+ from .facts import RunFacts, read_facts
17
+ from .latex import assemble, bibliography, disclosure
18
+ from .lint import check_section
19
+ from .macros import Resolver
20
+ from .render import TABLE_SCHEMA, Spec, SpecError, render_figure, render_table
21
+ from .tectonic import DEFAULT_IMAGE, compile_tex
22
+ from .types import BuildResult, PaperOptions, RunContext
23
+
24
+ ATTEMPTS = 3 # per section, and for compile fixes (§5 steps 5 and 7)
25
+ MAX_SEARCHES = 2 # citation searches the LLM may request per section
26
+ SECTION_TOKENS = 8000 # thinking counts against it on models that always think
27
+ OUTLINE_TOKENS = 2000
28
+
29
+ SECTION_SCHEMA: dict[str, Any] = {
30
+ "type": "object", "additionalProperties": False, "required": ["latex"],
31
+ "properties": {
32
+ "latex": {"type": "string"},
33
+ "tables": {"type": "array", "items": TABLE_SCHEMA, "maxItems": 3},
34
+ "figures": {"type": "array", "items": TABLE_SCHEMA, "maxItems": 3},
35
+ "searches": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_SEARCHES},
36
+ },
37
+ }
38
+ OUTLINE_SCHEMA: dict[str, Any] = {
39
+ "type": "object", "additionalProperties": False, "required": ["title", "sections"],
40
+ "properties": {
41
+ "title": {"type": "string"},
42
+ "sections": {"type": "array", "items": {
43
+ "type": "object", "additionalProperties": False, "required": ["key", "title"],
44
+ "properties": {"key": {"type": "string"}, "title": {"type": "string"}}}},
45
+ },
46
+ }
47
+ FIX_SCHEMA: dict[str, Any] = {
48
+ "type": "object", "additionalProperties": False, "required": ["sections"],
49
+ "properties": {"sections": {"type": "object", "additionalProperties": {"type": "string"}}},
50
+ }
51
+
52
+ RULES = (
53
+ "Write LaTeX body text only. Rules, checked by code: (1) never write a digit or a "
54
+ "spelled-out quantity; every number comes from a macro listed in the facts: "
55
+ "\\pmval{experiment}{metric}, \\pmagg{decision}{metric}, \\pmcmp{decision}, "
56
+ "\\pmcfg{prereg}{threshold|wall_clock_s|money_usd|seeds}; (2) cite only keys from the "
57
+ "citation pool, with \\cite{key}; ask for a search if you need another source; (3) put "
58
+ "tables and figures in only through specs, placed with \\pmtable{id} or \\pmfigure{id}; "
59
+ "(4) no \\begin{table}, \\includegraphics, \\input, macro definitions or packages; "
60
+ "(5) use the words 'finding' or 'we find' only where the section is a confirmed finding; "
61
+ "(6) the digit rule covers names too: write metric names without digits (F-score, not "
62
+ "F1) and never put run, entry or experiment ids in the text; refer to a hypothesis by "
63
+ "what it tests; (7) a table or figure spec id uses only letters, digits, _ or -, and "
64
+ "its metric must be recorded by every decision it lists; a decision with no recorded "
65
+ "comparison (discarded before it ran) cannot be in a table: describe it in prose."
66
+ )
67
+
68
+
69
+ def _last(log: list[str], n: int = 3) -> str:
70
+ """The last problems, for the reason the user sees (the full log is in `lint_log`)."""
71
+ return (": " + "; ".join(p[:200] for p in log[-n:])) if log else ""
72
+
73
+
74
+ @dataclass
75
+ class _Section:
76
+ key: str
77
+ title: str
78
+ purpose: str
79
+ allow_findings: bool
80
+ required_cites: list[str]
81
+ hypotheses: list[str]
82
+
83
+
84
+ class Writer:
85
+ """Built from a read-only Ledger, an LLM, a PureScholar client and a Tectonic image
86
+ name (KB-017)."""
87
+
88
+ def __init__(self, ledger: Ledger, llm: LLM, purescholar: PureScholar | None,
89
+ tectonic_image: str = DEFAULT_IMAGE) -> None:
90
+ self._ledger = ledger
91
+ self._llm = llm
92
+ self._ps = purescholar
93
+ self._image = tectonic_image
94
+
95
+ # ---- interface ---------------------------------------------------------------------
96
+
97
+ def build(self, run_id: str, options: PaperOptions, ctx: RunContext) -> BuildResult:
98
+ facts = read_facts(self._ledger, run_id)
99
+ pool = dict(facts.citations)
100
+ sections = self._plan(facts, options)
101
+ title = self._outline(facts, options, sections, ctx)
102
+ drafted: dict[str, dict[str, Any]] = {}
103
+ log: list[str] = []
104
+ for sec in sections:
105
+ out, problems = self._draft(facts, options, sec, pool, ctx)
106
+ log += [f"{sec.key}: {p}" for p in problems]
107
+ if out is None:
108
+ return self._failed(f"section {sec.key!r} failed its checks{_last(log)}", log)
109
+ drafted[sec.key] = out
110
+ return self._finish(facts, options, title, sections, drafted, pool, ctx, log)
111
+
112
+ def revise(self, run_id: str, previous: BuildResult, feedback: list[str],
113
+ ctx: RunContext) -> BuildResult:
114
+ """One pass that applies feedback as required changes, then rechecks and compiles."""
115
+ if not previous.sections:
116
+ return self._failed("the previous build has no sections to revise", [])
117
+ facts = read_facts(self._ledger, run_id)
118
+ saved_opts = json.loads(previous.files["options.json"])
119
+ options = PaperOptions(**{**saved_opts, "findings": tuple(saved_opts["findings"]),
120
+ "others": tuple(saved_opts["others"])})
121
+ specs_saved: dict[str, dict[str, Any]] = json.loads(previous.files["specs.json"])
122
+ pool = dict(facts.citations)
123
+ sections = self._plan(facts, options)
124
+ title = str(saved_opts["title"])
125
+ request = {"feedback": feedback, "sections": previous.sections}
126
+ reply = self._call(ctx, "Apply every item of the feedback as a required change. "
127
+ "Return every section's LaTeX, revised. " + RULES,
128
+ LLM.quote(json.dumps(request), "previous paper and feedback"),
129
+ FIX_SCHEMA)
130
+ drafted: dict[str, dict[str, Any]] = {}
131
+ log: list[str] = []
132
+ for sec in sections:
133
+ latex = str(reply["sections"].get(sec.key, previous.sections.get(sec.key, "")))
134
+ saved = specs_saved.get(sec.key, {})
135
+ problems = self._check(facts, sec, latex, pool, _specs(saved, facts))
136
+ if problems:
137
+ log += [f"{sec.key}: {p}" for p in problems]
138
+ return self._failed(f"revised section {sec.key!r} failed its checks"
139
+ f"{_last(log)}", log)
140
+ drafted[sec.key] = {"latex": latex, "tables": list(saved.get("tables", [])),
141
+ "figures": list(saved.get("figures", []))}
142
+ return self._finish(facts, options, title, sections, drafted, pool, ctx, log)
143
+
144
+ def bundle(self, run_id: str, build: BuildResult, dest: Path) -> Path:
145
+ """The run bundle (§4): paper, full ledger export, code, env specs, data sources,
146
+ citation evidence, README. The ledger export is always included (INV-7)."""
147
+ dest = Path(dest)
148
+ if dest.exists() and any(dest.iterdir()):
149
+ raise FileExistsError(f"{dest} is not empty")
150
+ facts = read_facts(self._ledger, run_id)
151
+ paper = dest / "paper"
152
+ paper.mkdir(parents=True)
153
+ for name, data in build.files.items():
154
+ (paper / name).write_bytes(data)
155
+ self._ledger.export(run_id, dest / "ledger")
156
+ (dest / "data_sources.json").write_text(json.dumps(facts.data_sources, indent=2),
157
+ encoding="utf-8")
158
+ used = [facts.citations[k] for k in build.citation_keys if k in facts.citations]
159
+ (dest / "citations.json").write_text(json.dumps(used, indent=2), encoding="utf-8")
160
+ (dest / "trace_map.json").write_text(json.dumps(build.trace_map, indent=2),
161
+ encoding="utf-8")
162
+ (dest / "README.md").write_text(_README.format(status=build.status, run=run_id),
163
+ encoding="utf-8")
164
+ return dest
165
+
166
+ # ---- planning ----------------------------------------------------------------------
167
+
168
+ def _plan(self, facts: RunFacts, options: PaperOptions) -> list[_Section]:
169
+ """The section list is code's, never the LLM's (§5 step 1)."""
170
+ others = list(options.others)
171
+ abstract = _Section("abstract", "Abstract", "A short abstract of the whole document.",
172
+ options.kind == "paper", [], list(options.findings) + others)
173
+ if options.kind == "report":
174
+ return [abstract,
175
+ _Section("question", "Question", "The research question and why it matters.",
176
+ False, [], []),
177
+ _Section("tried", "What was tried", "Each hypothesis, its baseline and what "
178
+ "was run, with a table only for decisions that recorded a "
179
+ "comparison.", False, [], others),
180
+ _Section("stopped", "Why each attempt stopped", "The reason each hypothesis "
181
+ "was discarded or left inconclusive.", False, [], others),
182
+ _Section("conclusion", "Conclusion", "State plainly that no hypothesis was "
183
+ "confirmed, and what a next attempt would need.", False, [], []),
184
+ ]
185
+ sections = [
186
+ abstract,
187
+ _Section("introduction", "Introduction", "The question and the contribution.",
188
+ True, [], list(options.findings)),
189
+ _Section("related", "Related work", "The closest prior work.", False, [], []),
190
+ _Section("method", "Method", "How hypotheses were tested: pre-registration, staged "
191
+ "budgets, baselines run by the system itself.", False, [], []),
192
+ ]
193
+ for i, h in enumerate(options.findings, start=1):
194
+ sections.append(_Section(f"finding{i}", f"Finding {i}", "This confirmed hypothesis, "
195
+ "its evidence and its comparison.", True,
196
+ facts.closest.get(h, [])[:1], [h]))
197
+ if others:
198
+ sections.append(_Section("negative", "Negative and exploratory results",
199
+ "Every refuted, inconclusive or discarded hypothesis, "
200
+ "with its reason.", False, [], others))
201
+ sections.append(_Section("conclusion", "Conclusion", "What was confirmed and its "
202
+ "limits.", True, [], list(options.findings)))
203
+ return sections
204
+
205
+ def _outline(self, facts: RunFacts, options: PaperOptions, sections: list[_Section],
206
+ ctx: RunContext) -> str:
207
+ listing = [{"key": s.key, "title": s.title, "purpose": s.purpose} for s in sections]
208
+ reply = self._call(ctx, "Propose a paper title and a title for each section. Keep "
209
+ "every key, in order; add or remove none. No digits.",
210
+ LLM.quote(json.dumps({"idea": facts.idea, "sections": listing}),
211
+ "outline"), OUTLINE_SCHEMA, max_tokens=OUTLINE_TOKENS)
212
+ titles = {str(s["key"]): str(s["title"]) for s in reply.get("sections", [])}
213
+ if [s["key"] for s in reply.get("sections", [])] == [s.key for s in sections]:
214
+ for s in sections:
215
+ if titles[s.key].strip() and not any(c.isdigit() for c in titles[s.key]):
216
+ s.title = titles[s.key].strip()
217
+ title = str(reply.get("title") or options.title).strip()
218
+ return title if title and not any(c.isdigit() for c in title) else options.title
219
+
220
+ # ---- drafting and checks -----------------------------------------------------------
221
+
222
+ def _facts_for(self, facts: RunFacts, sec: _Section, pool: dict[str, Any]
223
+ ) -> dict[str, Any]:
224
+ decisions = [d for d in facts.decisions.values()
225
+ if not sec.hypotheses or d.hypothesis in sec.hypotheses]
226
+ return {
227
+ "idea": facts.idea,
228
+ "hypotheses": {h: facts.hypotheses.get(h) for h in sec.hypotheses}
229
+ or facts.hypotheses,
230
+ "decisions": [{"decision": d.entry_id, "hypothesis": d.hypothesis,
231
+ "stage": d.stage, "outcome": d.outcome,
232
+ "reason_code": d.reason_code, "reason": d.reason_text,
233
+ "prereg": d.prereg, "experiments": d.experiments,
234
+ "metrics": sorted({m["name"] for s in d.experiments
235
+ for m in (facts.experiments[s].metrics
236
+ if s in facts.experiments else [])})}
237
+ for d in decisions],
238
+ "citation_pool": {k: {"title": r.get("title"), "year": r.get("year"),
239
+ "excerpts": (r.get("excerpts") or [])[:1]}
240
+ for k, r in pool.items()},
241
+ "must_cite": sec.required_cites,
242
+ }
243
+
244
+ def _check(self, facts: RunFacts, sec: _Section, latex: str, pool: dict[str, Any],
245
+ specs: dict[str, Spec]) -> list[str]:
246
+ resolver = Resolver(facts)
247
+ problems = check_section(latex, pool=set(pool), allow_findings=sec.allow_findings,
248
+ required_cites=sec.required_cites,
249
+ macro_problems=resolver.check(latex))
250
+ for kind, ref in (("table", "\\pmtable{"), ("figure", "\\pmfigure{")):
251
+ for part in latex.split(ref)[1:]:
252
+ spec_id = part.split("}", 1)[0]
253
+ if spec_id not in specs:
254
+ problems.append(f"\\pm{kind}{{{spec_id}}} has no spec in this section")
255
+ return problems
256
+
257
+ def _draft(self, facts: RunFacts, options: PaperOptions, sec: _Section,
258
+ pool: dict[str, Any], ctx: RunContext
259
+ ) -> tuple[dict[str, Any] | None, list[str]]:
260
+ log: list[str] = []
261
+ problems: list[str] = []
262
+ searched = False
263
+ for _ in range(ATTEMPTS):
264
+ data = LLM.quote(json.dumps(self._facts_for(facts, sec, pool)), "run facts")
265
+ instruction = (f"Write the section '{sec.title}': {sec.purpose} " + RULES +
266
+ (" Fix these problems from your previous attempt: "
267
+ + "; ".join(problems) if problems else ""))
268
+ reply = self._call(ctx, instruction, data, SECTION_SCHEMA)
269
+ if reply.get("searches") and not searched and self._ps is not None:
270
+ searched = True
271
+ self._search(list(reply["searches"])[:MAX_SEARCHES], pool, ctx)
272
+ continue # redraft with the larger pool
273
+ specs: dict[str, Spec] = {}
274
+ problems = []
275
+ for raw in list(reply.get("tables", [])) + list(reply.get("figures", [])):
276
+ try:
277
+ spec = Spec.parse(raw, facts)
278
+ specs[spec.id] = spec
279
+ except (SpecError, KeyError) as exc:
280
+ problems.append(str(exc))
281
+ latex = str(reply.get("latex", ""))
282
+ problems += self._check(facts, sec, latex, pool, specs)
283
+ if not problems:
284
+ return reply, log
285
+ log += problems
286
+ return None, log
287
+
288
+ def _search(self, queries: list[str], pool: dict[str, Any], ctx: RunContext) -> None:
289
+ """§5 step 2: the LLM asks; `writer` searches PureScholar; what resolves joins."""
290
+ if self._ps is None:
291
+ return
292
+ for q in queries:
293
+ ctx.guard(0.0) # PureScholar's cost is unmetered (01 §9); time still counts
294
+ papers, records = asyncio.run(self._ps.search_papers(q, limit=5))
295
+ cited = []
296
+ for p in papers:
297
+ rec = PureScholar.citation_for(p)
298
+ if not hasattr(rec, "key"):
299
+ continue
300
+ d = dict(rec.__dict__)
301
+ cited.append(d)
302
+ pool.setdefault(d["key"], d)
303
+ for r in records:
304
+ ctx.record(r, {"request": r.request_body.encode(),
305
+ "response": (r.response_body or "").encode()},
306
+ citation_records=cited)
307
+
308
+ # ---- assembly ----------------------------------------------------------------------
309
+
310
+ def _finish(self, facts: RunFacts, options: PaperOptions, title: str,
311
+ sections: list[_Section], drafted: dict[str, dict[str, Any]],
312
+ pool: dict[str, Any], ctx: RunContext, log: list[str],
313
+ fixes_left: int = ATTEMPTS) -> BuildResult:
314
+ resolver = Resolver(facts)
315
+ files: dict[str, bytes] = {}
316
+ body: list[tuple[str, str]] = []
317
+ abstract = ""
318
+ for sec in sections:
319
+ out = drafted[sec.key]
320
+ latex = str(out["latex"])
321
+ for raw in list(out.get("tables", [])):
322
+ spec = Spec.parse(raw, facts)
323
+ tex, refs = render_table(spec, facts)
324
+ latex = latex.replace(f"\\pmtable{{{spec.id}}}", tex)
325
+ resolver.trace_map[f"table:{spec.id}"] = refs
326
+ for raw in list(out.get("figures", [])):
327
+ spec = Spec.parse(raw, facts)
328
+ tex, pdf, refs = render_figure(spec, facts)
329
+ latex = latex.replace(f"\\pmfigure{{{spec.id}}}", tex)
330
+ files[f"fig-{spec.id}.pdf"] = pdf
331
+ resolver.trace_map[f"figure:{spec.id}"] = refs
332
+ latex = resolver.resolve(latex)
333
+ if sec.key == "abstract":
334
+ abstract = latex
335
+ else:
336
+ body.append((sec.title, latex))
337
+ if options.kind == "report":
338
+ body.append(("Status", "No hypothesis in this run was confirmed. The sections "
339
+ "above report what was tried and why each attempt stopped."))
340
+ from .lint import cite_keys
341
+
342
+ used = sorted({k for _, t in body for k in cite_keys(t)})
343
+ bib = bibliography([pool[k] for k in used if k in pool])
344
+ tex = assemble(template=options.template, title=title, abstract=abstract,
345
+ sections=body, bib=bib,
346
+ disclosure_text=disclosure(facts) if options.disclosure else None)
347
+ files["main.tex"] = tex.encode()
348
+ files["options.json"] = json.dumps({**options.__dict__, "title": title,
349
+ "findings": list(options.findings),
350
+ "others": list(options.others)}).encode()
351
+ sections_src = {s.key: str(drafted[s.key]["latex"]) for s in sections}
352
+ files["specs.json"] = json.dumps(
353
+ {k: {"tables": list(v.get("tables", [])), "figures": list(v.get("figures", []))}
354
+ for k, v in drafted.items()}).encode()
355
+ result = compile_tex(files, self._image)
356
+ if not result.ok or result.pdf is None:
357
+ if fixes_left <= 0:
358
+ failed = self._failed("LaTeX did not compile", log, result.log)
359
+ failed.files = files
360
+ return failed
361
+ fixed = self._call(ctx, "The LaTeX failed to compile. Return every section's "
362
+ "LaTeX with the error fixed. " + RULES,
363
+ LLM.quote(json.dumps({"error": result.log[-3000:],
364
+ "sections": sections_src}),
365
+ "compile error and sections"), FIX_SCHEMA)
366
+ for sec in sections:
367
+ latex = str(fixed["sections"].get(sec.key, sections_src[sec.key]))
368
+ specs = _specs(drafted[sec.key], facts)
369
+ problems = self._check(facts, sec, latex, pool, specs)
370
+ if problems: # a fix is checked exactly like a draft (INV-8)
371
+ log += [f"{sec.key} (compile fix): {p}" for p in problems]
372
+ return self._failed("a compile fix failed the checks", log, result.log)
373
+ drafted[sec.key] = {**drafted[sec.key], "latex": latex}
374
+ return self._finish(facts, options, title, sections, drafted, pool, ctx, log,
375
+ fixes_left - 1)
376
+ files["main.pdf"] = result.pdf
377
+ return BuildResult(status="built", reasons=[], files=files,
378
+ trace_map=dict(resolver.trace_map), citation_keys=used,
379
+ lint_log=log, compile_log=result.log, sections=sections_src)
380
+
381
+ @staticmethod
382
+ def _failed(reason: str, log: list[str], compile_log: str = "") -> BuildResult:
383
+ return BuildResult(status="failed", reasons=[reason], files={}, trace_map={},
384
+ citation_keys=[], lint_log=log, compile_log=compile_log)
385
+
386
+ # ---- LLM calls (INV-6: guard before, record after) -----------------------------------
387
+
388
+ def _call(self, ctx: RunContext, instruction: str, data: str, schema: dict[str, Any],
389
+ max_tokens: int = SECTION_TOKENS) -> dict[str, Any]:
390
+ messages: list[Message] = [{"role": "system", "content": instruction},
391
+ {"role": "user", "content": data}]
392
+ ctx.guard(self._llm.max_cost("writer", messages, max_tokens, schema))
393
+ from llm import LLMError
394
+
395
+ try:
396
+ result = self._llm.complete("writer", messages, max_tokens=max_tokens, schema=schema)
397
+ except LLMError as exc:
398
+ for r in exc.records:
399
+ ctx.record(r, _artifacts(r))
400
+ raise
401
+ for r in result.records:
402
+ ctx.record(r, _artifacts(r))
403
+ return dict(result.parsed)
404
+
405
+
406
+ def _specs(drafted: dict[str, Any], facts: RunFacts) -> dict[str, Spec]:
407
+ raws = list(drafted.get("tables", [])) + list(drafted.get("figures", []))
408
+ return {s.id: s for s in (Spec.parse(r, facts) for r in raws)}
409
+
410
+
411
+ def _artifacts(record: Any) -> dict[str, bytes]:
412
+ return {"request": json.dumps(record.request).encode(),
413
+ "response": (record.response or "").encode()}
414
+
415
+
416
+ _README = """# Run bundle
417
+
418
+ Run: `{run}`. Paper build status: {status}.
419
+
420
+ | Path | Contents |
421
+ |---|---|
422
+ | `paper/` | LaTeX sources, figures, `main.pdf` when built, `options.json` |
423
+ | `ledger/` | The full run ledger, its referenced artifacts, rejections, chain report |
424
+ | `data_sources.json` | Every pre-registered data set: source, licence, role, digest |
425
+ | `citations.json` | The citation records behind the references, with their evidence |
426
+ | `trace_map.json` | For each number, table and figure, the ledger entries behind it |
427
+
428
+ Every number in the paper is computed from `ledger/ledger.jsonl`. Code bundles and
429
+ environment specs are artifacts under `ledger/artifacts/`, named by their digests.
430
+ """
writer/facts.py ADDED
@@ -0,0 +1,162 @@
1
+ """What the ledger says, in the shape the writer needs (06-writer.md §5 step 1).
2
+
3
+ Everything here is read from ledger entries; nothing comes from an LLM. Experiments carry
4
+ an `arm` field (`treatment` or `baseline`) set by `loop` when it starts them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass, field
11
+ from statistics import mean, stdev
12
+ from typing import Any
13
+
14
+ from ledger import Entry, Ledger
15
+
16
+
17
+ @dataclass
18
+ class Experiment:
19
+ started_id: str
20
+ finished_id: str | None
21
+ arm: str
22
+ seed: int | None
23
+ status: str | None
24
+ metrics: list[dict[str, Any]]
25
+ artifacts: dict[str, str]
26
+
27
+
28
+ @dataclass
29
+ class Decision:
30
+ entry_id: str
31
+ hypothesis: str
32
+ stage: str
33
+ outcome: str
34
+ reason_code: str | None
35
+ reason_text: str
36
+ prereg: str | None
37
+ experiments: list[str]
38
+ comparison: dict[str, Any]
39
+
40
+
41
+ @dataclass
42
+ class RunFacts:
43
+ run_id: str
44
+ idea: str
45
+ entries: dict[str, Entry]
46
+ hypotheses: dict[str, dict[str, Any]] = field(default_factory=dict)
47
+ preregs: dict[str, dict[str, Any]] = field(default_factory=dict)
48
+ experiments: dict[str, Experiment] = field(default_factory=dict)
49
+ finished_to_started: dict[str, str] = field(default_factory=dict)
50
+ decisions: dict[str, Decision] = field(default_factory=dict)
51
+ citations: dict[str, dict[str, Any]] = field(default_factory=dict) # key -> record
52
+ closest: dict[str, list[str]] = field(default_factory=dict) # hypothesis -> cite keys
53
+ models: dict[str, set[str]] = field(default_factory=dict) # "provider/model" -> roles
54
+ data_sources: list[dict[str, Any]] = field(default_factory=list)
55
+
56
+ # ---- queries -----------------------------------------------------------------------
57
+
58
+ def decision_for(self, hypothesis: str) -> Decision | None:
59
+ """The last decision recorded for a hypothesis."""
60
+ found = [d for d in self.decisions.values() if d.hypothesis == hypothesis]
61
+ return found[-1] if found else None
62
+
63
+ def values(self, decision_id: str, metric: str, arm: str,
64
+ role: str | None = None) -> list[float]:
65
+ d = self.decisions[decision_id]
66
+ out: list[float] = []
67
+ for sid in d.experiments:
68
+ exp = self.experiments.get(sid)
69
+ if exp is None or exp.arm != arm:
70
+ continue
71
+ for m in exp.metrics:
72
+ if m["name"] == metric and (role is None or m["data_role"] == role):
73
+ out.append(float(m["value"]))
74
+ return out
75
+
76
+ def role_for(self, decision_id: str) -> str:
77
+ """Confirmation decisions report held-out metrics; earlier ones selection metrics."""
78
+ return "held_out" if self.decisions[decision_id].stage == "confirm" else "selection"
79
+
80
+ def summary(self, decision_id: str, metric: str, arm: str
81
+ ) -> tuple[float, float, int] | None:
82
+ vals = self.values(decision_id, metric, arm, self.role_for(decision_id))
83
+ if not vals:
84
+ return None
85
+ return mean(vals), (stdev(vals) if len(vals) > 1 else 0.0), len(vals)
86
+
87
+ def unit(self, decision_id: str, metric: str) -> str:
88
+ for sid in self.decisions[decision_id].experiments:
89
+ exp = self.experiments.get(sid)
90
+ for m in exp.metrics if exp else []:
91
+ if m["name"] == metric:
92
+ return str(m.get("unit") or "")
93
+ return ""
94
+
95
+ def trace(self, decision_id: str) -> list[str]:
96
+ """The entries behind a decision: the decision, its pre-registration, and its
97
+ experiments (started and finished)."""
98
+ d = self.decisions[decision_id]
99
+ ids = [d.entry_id] + ([d.prereg] if d.prereg else [])
100
+ for sid in d.experiments:
101
+ ids.append(sid)
102
+ exp = self.experiments.get(sid)
103
+ if exp and exp.finished_id:
104
+ ids.append(exp.finished_id)
105
+ return ids
106
+
107
+
108
+ def _records(payload: dict[str, Any], key: str) -> Iterable[dict[str, Any]]:
109
+ value = payload.get(key) or []
110
+ return [r for r in value if isinstance(r, dict) and r.get("key")]
111
+
112
+
113
+ def read_facts(ledger: Ledger, run_id: str) -> RunFacts:
114
+ entries = list(ledger.entries(run_id))
115
+ by_id = {e.entry_id: e for e in entries}
116
+ created = next((e for e in entries if e.type == "run.created"), None)
117
+ facts = RunFacts(run_id=run_id, idea=str(created.payload["idea"]) if created else "",
118
+ entries=by_id)
119
+ for e in entries:
120
+ p = e.payload
121
+ if e.type == "hypothesis.proposed":
122
+ facts.hypotheses[e.entry_id] = {"statement": p["statement"], "kind": p["kind"]}
123
+ elif e.type in ("prereg.registered", "prereg.amended"):
124
+ base = dict(facts.preregs.get(p.get("supersedes", ""), {}))
125
+ base.update(p.get("changes", {}) if e.type == "prereg.amended" else p)
126
+ facts.preregs[e.entry_id] = base
127
+ if e.type == "prereg.registered":
128
+ for ds in p.get("evaluation_data", []):
129
+ if ds not in facts.data_sources:
130
+ facts.data_sources.append(ds)
131
+ elif e.type == "experiment.started":
132
+ facts.experiments[e.entry_id] = Experiment(
133
+ started_id=e.entry_id, finished_id=None, arm=str(p.get("arm", "treatment")),
134
+ seed=p.get("seed"), status=None, metrics=[], artifacts={})
135
+ elif e.type == "experiment.finished":
136
+ exp = facts.experiments.get(p["experiment"])
137
+ if exp is not None:
138
+ exp.finished_id, exp.status = e.entry_id, p["status"]
139
+ exp.metrics, exp.artifacts = list(p.get("metrics", [])), dict(p["artifacts"])
140
+ facts.finished_to_started[e.entry_id] = exp.started_id
141
+ elif e.type == "decision.made":
142
+ facts.decisions[e.entry_id] = Decision(
143
+ entry_id=e.entry_id, hypothesis=p["hypothesis"], stage=p["stage"],
144
+ outcome=p["outcome"], reason_code=p.get("reason_code"),
145
+ reason_text=p["reason_text"], prereg=p.get("prereg"),
146
+ experiments=list(p.get("experiments", [])),
147
+ comparison=dict(p.get("comparison", {})))
148
+ elif e.type == "call.recorded":
149
+ if p["source"] == "purescholar":
150
+ for rec in _records(p, "citation_records"):
151
+ facts.citations.setdefault(rec["key"], rec)
152
+ elif p["source"] == "llm":
153
+ rec = p.get("record", {})
154
+ name = f"{rec.get('provider')}/{rec.get('model')}"
155
+ facts.models.setdefault(name, set()).add(str(rec.get("role")))
156
+ elif e.type == "hypothesis.novelty":
157
+ keys = []
158
+ for rec in _records(p, "closest"):
159
+ facts.citations.setdefault(rec["key"], rec)
160
+ keys.append(rec["key"])
161
+ facts.closest[p["hypothesis"]] = keys
162
+ return facts
writer/latex.py ADDED
@@ -0,0 +1,62 @@
1
+ """Document assembly: templates, bibliography and the disclosure statement (§5 step 6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .facts import RunFacts
8
+ from .macros import latex_escape
9
+
10
+ PREAMBLE = {
11
+ "article": "\\documentclass[11pt]{article}\n\\usepackage[margin=1in]{geometry}\n",
12
+ "ieeetran": "\\documentclass[conference]{IEEEtran}\n",
13
+ }
14
+ PACKAGES = ("\\usepackage{graphicx}\n\\usepackage{booktabs}\n\\usepackage{amsmath}\n"
15
+ "\\usepackage[hidelinks]{hyperref}\n")
16
+
17
+
18
+ def bibliography(records: list[dict[str, Any]]) -> str:
19
+ """`thebibliography` from citation records only (INV-2): no record, no entry."""
20
+ items = []
21
+ for r in records:
22
+ authors = ", ".join(r.get("authors") or []) or "Anonymous"
23
+ parts = [latex_escape(authors), f"\\emph{{{latex_escape(str(r.get('title') or ''))}}}"]
24
+ if r.get("venue"):
25
+ parts.append(latex_escape(str(r["venue"])))
26
+ if r.get("year"):
27
+ parts.append(str(r["year"]))
28
+ ident = str(r.get("id") or "")
29
+ kind = r.get("id_kind")
30
+ if kind == "doi":
31
+ parts.append(f"doi:\\nolinkurl{{{ident}}}")
32
+ elif kind == "openalex":
33
+ parts.append(f"OpenAlex {latex_escape(ident)}")
34
+ elif r.get("url"):
35
+ parts.append(f"\\url{{{r['url']}}}")
36
+ items.append(f"\\bibitem{{{r['key']}}} " + ". ".join(parts) + ".")
37
+ if not items:
38
+ return ""
39
+ return "\\begin{thebibliography}{99}\n" + "\n".join(items) + "\n\\end{thebibliography}\n"
40
+
41
+
42
+ def disclosure(facts: RunFacts) -> str:
43
+ """A fixed statement built from the ledger's LLM call records, not written by an LLM."""
44
+ if not facts.models:
45
+ return ("\\paragraph{Disclosure} This document was produced with puremethod. No "
46
+ "language model calls are recorded in its run ledger.\n")
47
+ uses = "; ".join(f"\\texttt{{{latex_escape(m)}}} ({latex_escape(', '.join(sorted(r)))})"
48
+ for m, r in sorted(facts.models.items()))
49
+ return ("\\paragraph{Disclosure} This document was produced with puremethod, an automated "
50
+ "research system. Language models used, with their roles: " + uses + ". Every "
51
+ "reported number is computed from the run ledger, and the full ledger is in the "
52
+ "run bundle.\n")
53
+
54
+
55
+ def assemble(*, template: str, title: str, abstract: str, sections: list[tuple[str, str]],
56
+ bib: str, disclosure_text: str | None) -> str:
57
+ body = "\n\n".join(f"\\section{{{latex_escape(name)}}}\n{text}" for name, text in sections)
58
+ return (PREAMBLE[template] + PACKAGES +
59
+ f"\\title{{{latex_escape(title)}}}\n\\author{{puremethod}}\n\\date{{}}\n"
60
+ "\\begin{document}\n\\maketitle\n"
61
+ f"\\begin{{abstract}}\n{abstract}\n\\end{{abstract}}\n\n{body}\n\n"
62
+ + (disclosure_text or "") + "\n" + bib + "\\end{document}\n")