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/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")
|