quirework 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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
"""Forked reader experiments driven by a project-owned procedure.
|
|
2
|
+
|
|
3
|
+
The operator owns assignment, artifacts and interpretation. A reader sees the
|
|
4
|
+
same page transport and frozen read-through prompt on every arm. Runtime homes
|
|
5
|
+
are copied only from an idle parent and never shared between arms.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
from collections import Counter
|
|
13
|
+
import fcntl
|
|
14
|
+
import hashlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
|
|
26
|
+
from quirework.core import usage as usage_records
|
|
27
|
+
from quirework.providers import blind
|
|
28
|
+
from quirework.providers import claude_login
|
|
29
|
+
from quirework.providers import quota
|
|
30
|
+
from quirework.providers import replies
|
|
31
|
+
|
|
32
|
+
# Above every published ceiling on purpose; Codex clamps it down.
|
|
33
|
+
OVER_REQUEST = 1_050_000
|
|
34
|
+
from quirework.project import current_project
|
|
35
|
+
|
|
36
|
+
PROMPT_REVISION = "2026-09-04-reader-understanding"
|
|
37
|
+
PROTOCOL_REVISION = "chapter-experiment-v2"
|
|
38
|
+
VIRTUAL = Path.home() / "tmp" / "reader-workspace"
|
|
39
|
+
|
|
40
|
+
ARMS = ("a", "b", "c")
|
|
41
|
+
CODEX_TRANSPORT_FALLBACK = "Falling back from WebSockets to HTTPS transport."
|
|
42
|
+
SUPPORTED_MODELS = {"codex": "gpt-5.6-sol", "claude": "opus"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _settings():
|
|
46
|
+
"""Resolve paths from the current operation, without import-time state."""
|
|
47
|
+
from types import SimpleNamespace
|
|
48
|
+
|
|
49
|
+
SOURCE = current_project().path("sops.reader_driver")
|
|
50
|
+
return SimpleNamespace(SOURCE=SOURCE)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def digest(path):
|
|
54
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_json(path, value):
|
|
58
|
+
path = Path(path)
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as f:
|
|
61
|
+
json.dump(value, f, indent=2, ensure_ascii=False)
|
|
62
|
+
f.write("\n")
|
|
63
|
+
f.flush()
|
|
64
|
+
os.fsync(f.fileno())
|
|
65
|
+
tmp = f.name
|
|
66
|
+
os.replace(tmp, path)
|
|
67
|
+
fd = os.open(path.parent, os.O_DIRECTORY)
|
|
68
|
+
try:
|
|
69
|
+
os.fsync(fd)
|
|
70
|
+
finally:
|
|
71
|
+
os.close(fd)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_json(path):
|
|
75
|
+
return json.loads(Path(path).read_text())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_runner_model(runner, model):
|
|
79
|
+
expected = SUPPORTED_MODELS[runner]
|
|
80
|
+
if model != expected:
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f"{runner} chapter experiments require model {expected!r}, not {model!r}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def tree_hashes(root):
|
|
87
|
+
root = Path(root)
|
|
88
|
+
if any(p.is_symlink() for p in root.rglob("*")):
|
|
89
|
+
raise RuntimeError(f"symlink in experiment state: {root}")
|
|
90
|
+
return {
|
|
91
|
+
str(p.relative_to(root)): digest(p)
|
|
92
|
+
for p in sorted(root.rglob("*"))
|
|
93
|
+
if p.is_file()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def events(path):
|
|
98
|
+
"""An incomplete final line is a pending writer, never a completed turn."""
|
|
99
|
+
if not Path(path).exists():
|
|
100
|
+
return []
|
|
101
|
+
lines = Path(path).read_text().splitlines()
|
|
102
|
+
return [json.loads(line) for line in lines if line.strip()]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def prompts(seat):
|
|
106
|
+
spec = importlib.util.spec_from_file_location(
|
|
107
|
+
"reader_instrument", _settings().SOURCE
|
|
108
|
+
)
|
|
109
|
+
module = importlib.util.module_from_spec(spec)
|
|
110
|
+
spec.loader.exec_module(module)
|
|
111
|
+
prefix = seat.upper()
|
|
112
|
+
return {
|
|
113
|
+
"first": getattr(module, f"{prefix}_TURN_ONE"),
|
|
114
|
+
"next": getattr(module, f"{prefix}_TURN_NEXT"),
|
|
115
|
+
"headings": getattr(module, f"{prefix}_CHAPTER_HEADINGS"),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def missing_headings(text, headings):
|
|
120
|
+
"""Match heading lines while preserving typographic punctuation in evidence."""
|
|
121
|
+
actual = {
|
|
122
|
+
line.strip().replace("\u2019", "'")
|
|
123
|
+
for line in text.splitlines()
|
|
124
|
+
if line.startswith("## ")
|
|
125
|
+
}
|
|
126
|
+
return [heading for heading in headings if heading not in actual]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_pages(pdf, directory):
|
|
130
|
+
directory.mkdir(parents=True)
|
|
131
|
+
subprocess.run(
|
|
132
|
+
["pdftoppm", "-png", "-scale-to", "1600", str(pdf), str(directory / "page")],
|
|
133
|
+
check=True,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
)
|
|
136
|
+
pages = sorted(
|
|
137
|
+
directory.glob("page-*.png"), key=lambda p: int(p.stem.rsplit("-", 1)[1])
|
|
138
|
+
)
|
|
139
|
+
from pypdf import PdfReader
|
|
140
|
+
|
|
141
|
+
if len(pages) != len(PdfReader(pdf).pages) or not pages:
|
|
142
|
+
raise RuntimeError("incomplete page rasterization")
|
|
143
|
+
return pages
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def snapshot(source, target):
|
|
147
|
+
"""Copy an idle home, detecting concurrent writes rather than copying a WAL mid-write."""
|
|
148
|
+
before = tree_hashes(source)
|
|
149
|
+
shutil.copytree(source, target)
|
|
150
|
+
if tree_hashes(source) != before or tree_hashes(target) != before:
|
|
151
|
+
raise RuntimeError("parent state changed during snapshot; do not fork it")
|
|
152
|
+
return before
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def assert_model(runner, requested, actual, established=None):
|
|
156
|
+
if established is not None:
|
|
157
|
+
valid = actual == established
|
|
158
|
+
elif runner == "claude" and requested in ("opus", "opus[1m]"):
|
|
159
|
+
valid = actual.startswith("claude-opus-")
|
|
160
|
+
else:
|
|
161
|
+
valid = actual == requested
|
|
162
|
+
if not valid:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f"served model {actual!r}, expected {established or requested!r}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def reject_compaction(rows):
|
|
169
|
+
for row in rows:
|
|
170
|
+
payload = row.get("payload") or {}
|
|
171
|
+
item = payload.get("item") or {}
|
|
172
|
+
fields = [
|
|
173
|
+
row.get("type"),
|
|
174
|
+
row.get("subtype"),
|
|
175
|
+
payload.get("type"),
|
|
176
|
+
item.get("type"),
|
|
177
|
+
]
|
|
178
|
+
if any("compact" in str(value).lower() for value in fields if value):
|
|
179
|
+
raise RuntimeError("compaction invalidates this experiment")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def codex_item_diagnostics(rows):
|
|
183
|
+
"""Return recognized transport notices and reject every other item kind."""
|
|
184
|
+
notices = []
|
|
185
|
+
for row in rows:
|
|
186
|
+
if row.get("type") != "item.completed":
|
|
187
|
+
continue
|
|
188
|
+
item = row.get("item", {})
|
|
189
|
+
if item.get("type") in ("agent_message", "reasoning"):
|
|
190
|
+
continue
|
|
191
|
+
if item.get("type") == "error" and item.get("message", "").startswith(
|
|
192
|
+
CODEX_TRANSPORT_FALLBACK
|
|
193
|
+
):
|
|
194
|
+
notices.append(item["message"])
|
|
195
|
+
continue
|
|
196
|
+
raise RuntimeError("Codex used a tool or an unexpected output item")
|
|
197
|
+
return notices
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_codex(rows, home, model, effort, established):
|
|
201
|
+
started = [r["thread_id"] for r in rows if r.get("type") == "thread.started"]
|
|
202
|
+
if len(started) != 1:
|
|
203
|
+
raise RuntimeError("missing or ambiguous Codex thread identity")
|
|
204
|
+
sid = started[0]
|
|
205
|
+
transport_notices = codex_item_diagnostics(rows)
|
|
206
|
+
answers = [
|
|
207
|
+
r["item"]["text"]
|
|
208
|
+
for r in rows
|
|
209
|
+
if r.get("type") == "item.completed"
|
|
210
|
+
and r.get("item", {}).get("type") == "agent_message"
|
|
211
|
+
]
|
|
212
|
+
completed = [r for r in rows if r.get("type") == "turn.completed"]
|
|
213
|
+
if not answers or not completed:
|
|
214
|
+
raise RuntimeError("Codex turn has no completed answer")
|
|
215
|
+
matches = list((home / "sessions").glob(f"**/*{sid}.jsonl"))
|
|
216
|
+
if len(matches) != 1:
|
|
217
|
+
raise RuntimeError("missing or ambiguous Codex rollout for this thread")
|
|
218
|
+
roll = events(matches[0])
|
|
219
|
+
reject_compaction(roll)
|
|
220
|
+
contexts = [r["payload"] for r in roll if r.get("type") == "turn_context"]
|
|
221
|
+
if not contexts:
|
|
222
|
+
raise RuntimeError("missing Codex model receipt")
|
|
223
|
+
ctx = contexts[-1]
|
|
224
|
+
actual = ctx.get("model")
|
|
225
|
+
assert_model("codex", model, actual, established)
|
|
226
|
+
if ctx.get("effort") != effort:
|
|
227
|
+
raise RuntimeError("Codex effort changed")
|
|
228
|
+
windows = [
|
|
229
|
+
r["payload"].get("model_context_window", 0)
|
|
230
|
+
for r in roll
|
|
231
|
+
if r.get("type") == "event_msg"
|
|
232
|
+
and r.get("payload", {}).get("type") == "task_started"
|
|
233
|
+
]
|
|
234
|
+
# STILL A FIXED FLOOR, and deliberately so. Deriving it from the catalog
|
|
235
|
+
# was tried and reverted: this is the receipt verifier, and making it
|
|
236
|
+
# resolve a model would put a live catalog read on the verification path,
|
|
237
|
+
# which the tests rightly refuse by running the whole module on a
|
|
238
|
+
# fictional model. The number is the one hazard the launch side no longer
|
|
239
|
+
# has (a model ceilinged below it would be refused for running at its own
|
|
240
|
+
# maximum), so the fix belongs at prepare time: freeze the resolved
|
|
241
|
+
# ceiling and floor into the plan, and read them here. Every model this
|
|
242
|
+
# experiment has run publishes 872,000 at 95 percent, so nothing is
|
|
243
|
+
# refused today.
|
|
244
|
+
if not windows or windows[-1] < 800_000:
|
|
245
|
+
raise RuntimeError("Codex effective context below the reader requirement")
|
|
246
|
+
meta = next((r["payload"] for r in roll if r.get("type") == "session_meta"), {})
|
|
247
|
+
return answers[-1], {
|
|
248
|
+
"session_id": sid,
|
|
249
|
+
"model": actual,
|
|
250
|
+
"usage": completed[-1]["usage"],
|
|
251
|
+
"effective_context": windows[-1],
|
|
252
|
+
"forked_from": meta.get("forked_from_id"),
|
|
253
|
+
"transport_notices": transport_notices,
|
|
254
|
+
"rollout": str(matches[0]),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def parse_claude(rows, home, model, effort, established):
|
|
259
|
+
reject_compaction(rows)
|
|
260
|
+
results = [r for r in rows if r.get("type") == "result"]
|
|
261
|
+
if len(results) != 1:
|
|
262
|
+
raise RuntimeError("Claude turn has no unique result")
|
|
263
|
+
result = results[0]
|
|
264
|
+
if quota.claude_auth(rows):
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
"the reader's Claude login was revoked or expired mid-turn, because "
|
|
267
|
+
"the host renewed it or it lapsed. This adapter never continues a "
|
|
268
|
+
"turn: once `claude` on the host is signed in, prepare a distinct "
|
|
269
|
+
"experiment rather than rerunning this one"
|
|
270
|
+
)
|
|
271
|
+
if result.get("is_error") or result.get("subtype") != "success":
|
|
272
|
+
raise RuntimeError(
|
|
273
|
+
f"Claude result is {result.get('subtype')}: "
|
|
274
|
+
f"{str(result.get('result', ''))[:180]}"
|
|
275
|
+
)
|
|
276
|
+
for row in rows:
|
|
277
|
+
if row.get("type") == "assistant":
|
|
278
|
+
content = row.get("message", {}).get("content", [])
|
|
279
|
+
if any(b.get("type") in ("tool_use", "server_tool_use") for b in content):
|
|
280
|
+
raise RuntimeError("Claude used a tool in an attached-pages experiment")
|
|
281
|
+
usage = result.get("modelUsage") or {}
|
|
282
|
+
served = {
|
|
283
|
+
r.get("message", {}).get("model") for r in rows if r.get("type") == "assistant"
|
|
284
|
+
}
|
|
285
|
+
if len(served) != 1:
|
|
286
|
+
raise RuntimeError("Claude missing or mixed assistant-model receipt")
|
|
287
|
+
actual = served.pop()
|
|
288
|
+
if actual not in usage or not usage[actual].get("outputTokens"):
|
|
289
|
+
raise RuntimeError("Claude missing served-model usage receipt")
|
|
290
|
+
# Claude's documented first-turn Haiku preflight is billing overhead,
|
|
291
|
+
# not a fallback reader. Verify the actual assistant messages separately.
|
|
292
|
+
auxiliary = {k: v for k, v in usage.items() if k != actual}
|
|
293
|
+
if any(not k.startswith("claude-haiku-") for k in auxiliary):
|
|
294
|
+
raise RuntimeError("unrecognized auxiliary model usage")
|
|
295
|
+
init = [r for r in rows if r.get("type") == "system" and r.get("subtype") == "init"]
|
|
296
|
+
if len(init) != 1 or init[0].get("tools") != ["Read"] or init[0].get("mcp_servers"):
|
|
297
|
+
raise RuntimeError("Claude tool or MCP configuration drifted")
|
|
298
|
+
if init[0].get("output_style") != "default" or init[0].get("model") != actual:
|
|
299
|
+
raise RuntimeError("Claude initial model or output style drifted")
|
|
300
|
+
assert_model("claude", model, actual, established)
|
|
301
|
+
sid = result["session_id"]
|
|
302
|
+
matches = list((home / "projects").glob(f"**/{sid}.jsonl"))
|
|
303
|
+
if len(matches) != 1:
|
|
304
|
+
raise RuntimeError("Claude session was not durably persisted")
|
|
305
|
+
native = events(matches[0])
|
|
306
|
+
reject_compaction(native)
|
|
307
|
+
# The result row holds only the last response, so a reaction the output
|
|
308
|
+
# ceiling cut is rebuilt from every part of it.
|
|
309
|
+
answer, reply = replies.claude(rows, native)
|
|
310
|
+
return answer, {
|
|
311
|
+
"session_id": sid,
|
|
312
|
+
"model": actual,
|
|
313
|
+
"usage": usage[actual],
|
|
314
|
+
"auxiliary_usage": auxiliary,
|
|
315
|
+
"cost_usd": result.get("total_cost_usd"),
|
|
316
|
+
"rollout": str(matches[0]),
|
|
317
|
+
"red_flags": replies.flags(reply),
|
|
318
|
+
**reply,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
from quirework.providers.images import history_image_hashes
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def command(runner, model, effort, mode, sid, images, prompt):
|
|
326
|
+
if runner == "codex":
|
|
327
|
+
args = ["codex", "exec"]
|
|
328
|
+
if mode == "start":
|
|
329
|
+
args += ["-s", "read-only"]
|
|
330
|
+
else:
|
|
331
|
+
args += [mode, sid]
|
|
332
|
+
args += [
|
|
333
|
+
"--ignore-user-config",
|
|
334
|
+
"--ignore-rules",
|
|
335
|
+
"--skip-git-repo-check",
|
|
336
|
+
"-m",
|
|
337
|
+
model,
|
|
338
|
+
"-c",
|
|
339
|
+
f'model_reasoning_effort="{effort}"',
|
|
340
|
+
"-c",
|
|
341
|
+
'model_verbosity="high"',
|
|
342
|
+
"-c",
|
|
343
|
+
'personality="none"',
|
|
344
|
+
"-c",
|
|
345
|
+
'web_search="disabled"',
|
|
346
|
+
*blind.CODEX_SEAT,
|
|
347
|
+
# Over-request and let Codex clamp to the catalog maximum,
|
|
348
|
+
# which is what the operator's own config.toml does and says:
|
|
349
|
+
# "Codex clamps this to the model catalog maximum and reserves
|
|
350
|
+
# the model's configured headroom." Resolving the exact ceiling
|
|
351
|
+
# was tried and reverted: it puts a live catalog read on a path
|
|
352
|
+
# the tests exercise with a fictional model. The scope is the
|
|
353
|
+
# setting that matters either way, without which the limit is
|
|
354
|
+
# clamped to 90% and compaction is not disabled at all.
|
|
355
|
+
"-c",
|
|
356
|
+
f"model_context_window={OVER_REQUEST}",
|
|
357
|
+
"-c",
|
|
358
|
+
f"model_auto_compact_token_limit={OVER_REQUEST}",
|
|
359
|
+
"-c",
|
|
360
|
+
'model_auto_compact_token_limit_scope="body_after_prefix"',
|
|
361
|
+
]
|
|
362
|
+
for path in images:
|
|
363
|
+
args += ["-i", str(path)]
|
|
364
|
+
return args + ["--json", prompt], b""
|
|
365
|
+
args = [
|
|
366
|
+
"claude",
|
|
367
|
+
"-p",
|
|
368
|
+
"--input-format",
|
|
369
|
+
"stream-json",
|
|
370
|
+
"--output-format",
|
|
371
|
+
"stream-json",
|
|
372
|
+
"--verbose",
|
|
373
|
+
"--effort",
|
|
374
|
+
effort,
|
|
375
|
+
"--tools",
|
|
376
|
+
"Read",
|
|
377
|
+
"--allowedTools",
|
|
378
|
+
"Read",
|
|
379
|
+
*blind.BLIND_SETTINGS,
|
|
380
|
+
*blind.NO_SETTING_SOURCES,
|
|
381
|
+
*blind.NO_MCP,
|
|
382
|
+
]
|
|
383
|
+
if mode == "start":
|
|
384
|
+
args += ["--model", model, "--session-id", sid]
|
|
385
|
+
else:
|
|
386
|
+
args += ["--resume", sid]
|
|
387
|
+
if mode == "fork":
|
|
388
|
+
args += ["--fork-session"]
|
|
389
|
+
# The host supplies actual image bytes below; no file-read tool is needed.
|
|
390
|
+
return args, None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def claude_environment():
|
|
394
|
+
"""Supply only an access token; a blind child must not rotate shared OAuth.
|
|
395
|
+
|
|
396
|
+
No token is put in an argument, receipt, plan or snapshot. The documented
|
|
397
|
+
environment-token path takes precedence over persisted Claude credentials
|
|
398
|
+
and has no refresh token. Renew authentication outside the reader.
|
|
399
|
+
"""
|
|
400
|
+
env = os.environ.copy()
|
|
401
|
+
if any(
|
|
402
|
+
env.get(k)
|
|
403
|
+
for k in (
|
|
404
|
+
"CLAUDE_BACKEND",
|
|
405
|
+
"ANTHROPIC_API_KEY",
|
|
406
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
407
|
+
"ANTHROPIC_BASE_URL",
|
|
408
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
409
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
410
|
+
)
|
|
411
|
+
):
|
|
412
|
+
raise RuntimeError(
|
|
413
|
+
"this experiment adapter requires direct claude.ai authentication"
|
|
414
|
+
)
|
|
415
|
+
if not env.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
|
416
|
+
# With LAUNCH_MARGIN of life left, so the host does not renew the
|
|
417
|
+
# login out from under the reader (``claude_login``).
|
|
418
|
+
env["CLAUDE_CODE_OAUTH_TOKEN"], _ = claude_login.host_login()
|
|
419
|
+
return env
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def canary(runner, label):
|
|
423
|
+
"""Prove a reader seat's flags blind on the installed runner (``canary``).
|
|
424
|
+
|
|
425
|
+
The twin is ``command`` with the canary's prompt, model and effort, and
|
|
426
|
+
no page images, which are content rather than flags.
|
|
427
|
+
"""
|
|
428
|
+
from quirework.providers import canary as canaries
|
|
429
|
+
|
|
430
|
+
def build(prompt, model, workdir, session):
|
|
431
|
+
args, payload = command(
|
|
432
|
+
runner, model, canaries.EFFORT, "start", session, [], prompt
|
|
433
|
+
)
|
|
434
|
+
if payload is None:
|
|
435
|
+
payload = image_message(prompt, [])
|
|
436
|
+
return canaries.Twin(args, payload)
|
|
437
|
+
|
|
438
|
+
return canaries.require(label, runner, build)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def image_message(prompt, images):
|
|
442
|
+
content = [{"type": "text", "text": prompt}]
|
|
443
|
+
content += [
|
|
444
|
+
{
|
|
445
|
+
"type": "image",
|
|
446
|
+
"source": {
|
|
447
|
+
"type": "base64",
|
|
448
|
+
"media_type": "image/png",
|
|
449
|
+
"data": base64.b64encode(Path(p).read_bytes()).decode(),
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
for p in images
|
|
453
|
+
]
|
|
454
|
+
return (
|
|
455
|
+
json.dumps({"type": "user", "message": {"role": "user", "content": content}})
|
|
456
|
+
+ "\n"
|
|
457
|
+
).encode()
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class Experiment:
|
|
461
|
+
def __init__(self, record, progress=False):
|
|
462
|
+
self.record = Path(record).resolve()
|
|
463
|
+
self.plan = read_json(self.record / "plan.json")
|
|
464
|
+
self.root = Path(self.plan["scratch"])
|
|
465
|
+
self.runner = self.plan["runner"]
|
|
466
|
+
self.progress = progress
|
|
467
|
+
self.state_file = self.record / "state.json"
|
|
468
|
+
self.state = (
|
|
469
|
+
read_json(self.state_file)
|
|
470
|
+
if self.state_file.exists()
|
|
471
|
+
else {"sessions": {}, "done": {}, "pending": None}
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def save(self):
|
|
475
|
+
write_json(self.state_file, self.state)
|
|
476
|
+
|
|
477
|
+
def emit(self, message, terminal=False):
|
|
478
|
+
if self.progress or terminal:
|
|
479
|
+
print(message, flush=True)
|
|
480
|
+
|
|
481
|
+
def workspace(self, seat):
|
|
482
|
+
return self.root / seat / "seat"
|
|
483
|
+
|
|
484
|
+
def home(self, seat):
|
|
485
|
+
return blind.state_root(self.workspace(seat)) / self.runner
|
|
486
|
+
|
|
487
|
+
def banked_history_pages(self, seat):
|
|
488
|
+
pages = [
|
|
489
|
+
p
|
|
490
|
+
for index, item in enumerate(self.plan.get("prefix", []))
|
|
491
|
+
if f"prefix-{index:02d}-{item['stem']}" in self.state["done"]
|
|
492
|
+
for p in item["pages"]
|
|
493
|
+
]
|
|
494
|
+
if seat in ARMS:
|
|
495
|
+
pages += [
|
|
496
|
+
p
|
|
497
|
+
for index, item in enumerate(self.plan["arms"][seat]["segments"])
|
|
498
|
+
if f"arm-{seat}-{index:02d}-{item['stem']}" in self.state["done"]
|
|
499
|
+
for p in item["pages"]
|
|
500
|
+
]
|
|
501
|
+
return pages
|
|
502
|
+
|
|
503
|
+
def verify(self):
|
|
504
|
+
if (
|
|
505
|
+
digest(self.record / "plan.json")
|
|
506
|
+
!= (self.record / "plan.sha256").read_text().strip()
|
|
507
|
+
):
|
|
508
|
+
raise RuntimeError("frozen plan or assignment changed")
|
|
509
|
+
if self.plan["driver_sha"] != digest(__file__):
|
|
510
|
+
raise RuntimeError(
|
|
511
|
+
"driver changed since preparation; freeze a new experiment"
|
|
512
|
+
)
|
|
513
|
+
if self.plan["blind_sha"] != digest(blind.__file__):
|
|
514
|
+
raise RuntimeError("shared confinement code changed since preparation")
|
|
515
|
+
if blind.runner_version(self.runner) != self.plan["runner_version"]:
|
|
516
|
+
raise RuntimeError("runner version changed since preparation")
|
|
517
|
+
for rel, expected in self.plan["hashes"].items():
|
|
518
|
+
if digest(self.record / rel) != expected:
|
|
519
|
+
raise RuntimeError(f"frozen experiment input changed: {rel}")
|
|
520
|
+
for key, done in self.state["done"].items():
|
|
521
|
+
if digest(self.record / done["answer"]) != done["answer_sha"]:
|
|
522
|
+
raise RuntimeError(f"earlier reaction changed or disappeared: {key}")
|
|
523
|
+
|
|
524
|
+
def turn(self, seat, key, prompt, pages, mode, headings=()):
|
|
525
|
+
self.verify()
|
|
526
|
+
if key in self.state["done"]:
|
|
527
|
+
return self.state["done"][key]
|
|
528
|
+
pending = self.state["pending"]
|
|
529
|
+
if pending and pending["key"] != key:
|
|
530
|
+
raise RuntimeError(
|
|
531
|
+
f"unresolved submitted turn {pending['key']}; cannot skip it"
|
|
532
|
+
)
|
|
533
|
+
sid = self.state["sessions"].get(seat)
|
|
534
|
+
if mode == "fork":
|
|
535
|
+
sid = self.state["sessions"]["parent"]
|
|
536
|
+
elif mode == "start":
|
|
537
|
+
sid = str(uuid.uuid4())
|
|
538
|
+
if mode in ("resume", "fork") and not pending:
|
|
539
|
+
# A fork continues the parent's session inside the arm's own home,
|
|
540
|
+
# cloned from the checkpoint. Checked before the pages are staged,
|
|
541
|
+
# so a home that is gone leaves the seat as it was. A pending
|
|
542
|
+
# submission is reconciled, never launched again, so it is not.
|
|
543
|
+
blind.require_session(self.workspace(seat), self.runner, sid)
|
|
544
|
+
work = self.workspace(seat)
|
|
545
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
546
|
+
staged = work / "pages"
|
|
547
|
+
staged.mkdir(exist_ok=True)
|
|
548
|
+
originals = [self.record / p for p in pages]
|
|
549
|
+
wanted = {f"page-{index:03d}.png" for index in range(len(originals))}
|
|
550
|
+
for old in staged.iterdir():
|
|
551
|
+
if old.is_symlink() or not old.is_file():
|
|
552
|
+
raise RuntimeError("unexpected entry in isolated page staging")
|
|
553
|
+
if old.name not in wanted:
|
|
554
|
+
old.unlink()
|
|
555
|
+
for index, source in enumerate(originals):
|
|
556
|
+
shutil.copyfile(source, staged / f"page-{index:03d}.png")
|
|
557
|
+
physical_images = sorted(staged.glob("*.png"))
|
|
558
|
+
virtual_images = [VIRTUAL / "pages" / p.name for p in physical_images]
|
|
559
|
+
raw = self.record / "raw" / f"events-{key}.jsonl"
|
|
560
|
+
err = self.record / "raw" / f"stderr-{key}.log"
|
|
561
|
+
raw.parent.mkdir(exist_ok=True)
|
|
562
|
+
args, payload = command(
|
|
563
|
+
self.runner,
|
|
564
|
+
self.plan["model"],
|
|
565
|
+
self.plan["effort"],
|
|
566
|
+
mode,
|
|
567
|
+
sid,
|
|
568
|
+
virtual_images,
|
|
569
|
+
prompt,
|
|
570
|
+
)
|
|
571
|
+
if payload is None:
|
|
572
|
+
payload = image_message(prompt, physical_images)
|
|
573
|
+
request_hash = hashlib.sha256(
|
|
574
|
+
prompt.encode()
|
|
575
|
+
+ b"\0"
|
|
576
|
+
+ "".join(digest(p) for p in physical_images).encode()
|
|
577
|
+
).hexdigest()
|
|
578
|
+
if pending:
|
|
579
|
+
if pending["request_sha"] != request_hash:
|
|
580
|
+
raise RuntimeError("pending request differs from frozen request")
|
|
581
|
+
if pending.get("pid"):
|
|
582
|
+
try:
|
|
583
|
+
os.kill(pending["pid"], 0)
|
|
584
|
+
except ProcessLookupError:
|
|
585
|
+
pass
|
|
586
|
+
else:
|
|
587
|
+
raise RuntimeError(
|
|
588
|
+
"submitted process still exists; wait, do not resubmit"
|
|
589
|
+
)
|
|
590
|
+
else:
|
|
591
|
+
if raw.exists():
|
|
592
|
+
raise RuntimeError(
|
|
593
|
+
"unbanked event file exists; reconcile before submitting"
|
|
594
|
+
)
|
|
595
|
+
# Proven before the request is marked pending, so a seat that
|
|
596
|
+
# cannot be shown blind leaves no submission to reconcile.
|
|
597
|
+
proven = canary(self.runner, f"experiment {key}")
|
|
598
|
+
runtime_env = claude_environment() if self.runner == "claude" else None
|
|
599
|
+
self.state["pending"] = {
|
|
600
|
+
"key": key,
|
|
601
|
+
"request_sha": request_hash,
|
|
602
|
+
"mode": mode,
|
|
603
|
+
"parent_id": sid,
|
|
604
|
+
}
|
|
605
|
+
self.save()
|
|
606
|
+
if blind.backend_error():
|
|
607
|
+
raise RuntimeError(blind.backend_error())
|
|
608
|
+
if self.runner == "claude":
|
|
609
|
+
# Claude also keeps runtime configuration beside ~/.claude.
|
|
610
|
+
# Persist a fresh per-seat file; never mount the user's file.
|
|
611
|
+
blind.make_state_root(work)
|
|
612
|
+
config = self.home(seat) / "runtime-config.json"
|
|
613
|
+
config.parent.mkdir(parents=True, exist_ok=True)
|
|
614
|
+
if not config.exists():
|
|
615
|
+
write_json(config, {})
|
|
616
|
+
jail = blind.jail_argv(
|
|
617
|
+
work,
|
|
618
|
+
runners=[self.runner],
|
|
619
|
+
bind_credentials=self.runner != "claude",
|
|
620
|
+
env={"DISABLE_COMPACT": "1", "DISABLE_AUTOUPDATER": "1"},
|
|
621
|
+
canary=proven,
|
|
622
|
+
)
|
|
623
|
+
blind.hold(
|
|
624
|
+
work,
|
|
625
|
+
f"chapter comparison {self.record.name}: seat {seat} continues "
|
|
626
|
+
"this session until the comparison completes",
|
|
627
|
+
)
|
|
628
|
+
# Give every arm the same logical paths without sharing a mount or
|
|
629
|
+
# runtime home. The host's real seat path stays outside the prompt.
|
|
630
|
+
# Replace the existing chdir argument rather than emitting a
|
|
631
|
+
# second one (bwrap warns, and stderr must remain useful).
|
|
632
|
+
jail[jail.index("--chdir") + 1] = str(VIRTUAL)
|
|
633
|
+
jail += ["--bind", str(work), str(VIRTUAL), "--setenv", "PWD", str(VIRTUAL)]
|
|
634
|
+
if self.runner == "claude":
|
|
635
|
+
jail += ["--bind", str(config), str(Path.home() / ".claude.json")]
|
|
636
|
+
binds, missing = blind.runner_binds([self.runner])
|
|
637
|
+
if missing:
|
|
638
|
+
raise RuntimeError(
|
|
639
|
+
"missing blind launcher paths: " + "; ".join(missing)
|
|
640
|
+
)
|
|
641
|
+
usage_records.seat(
|
|
642
|
+
"experiment",
|
|
643
|
+
{
|
|
644
|
+
"harness": self.runner,
|
|
645
|
+
"model": self.plan["model"],
|
|
646
|
+
"effort": self.plan["effort"],
|
|
647
|
+
},
|
|
648
|
+
)
|
|
649
|
+
blind.write_receipt(
|
|
650
|
+
self.record / "raw" / f"blind-{key}.json",
|
|
651
|
+
confined=True,
|
|
652
|
+
cmd=args,
|
|
653
|
+
run_dir=work,
|
|
654
|
+
runners=[self.runner],
|
|
655
|
+
binds=binds,
|
|
656
|
+
reason="",
|
|
657
|
+
canary=proven,
|
|
658
|
+
)
|
|
659
|
+
with raw.open("wb") as out, err.open("wb") as errors:
|
|
660
|
+
proc = subprocess.Popen(
|
|
661
|
+
jail + args,
|
|
662
|
+
stdin=subprocess.PIPE,
|
|
663
|
+
stdout=out,
|
|
664
|
+
stderr=errors,
|
|
665
|
+
cwd=current_project().root,
|
|
666
|
+
start_new_session=True,
|
|
667
|
+
env=runtime_env,
|
|
668
|
+
)
|
|
669
|
+
self.state["pending"]["pid"] = proc.pid
|
|
670
|
+
self.save()
|
|
671
|
+
proc.communicate(payload)
|
|
672
|
+
self.state["pending"]["exit_code"] = proc.returncode
|
|
673
|
+
self.state["pending"].pop("pid", None)
|
|
674
|
+
self.save()
|
|
675
|
+
# Completion can be flushed just after the CLI exits. Reconcile the
|
|
676
|
+
# existing event/rollout files; never generate a second first reaction.
|
|
677
|
+
failure = None
|
|
678
|
+
for attempt in range(30):
|
|
679
|
+
try:
|
|
680
|
+
parser = parse_codex if self.runner == "codex" else parse_claude
|
|
681
|
+
answer, receipt = parser(
|
|
682
|
+
events(raw),
|
|
683
|
+
self.home(seat),
|
|
684
|
+
self.plan["model"],
|
|
685
|
+
self.plan["effort"],
|
|
686
|
+
self.state.get("served_model"),
|
|
687
|
+
)
|
|
688
|
+
break
|
|
689
|
+
except (RuntimeError, json.JSONDecodeError) as exc:
|
|
690
|
+
failure = exc
|
|
691
|
+
if attempt == 29:
|
|
692
|
+
raise RuntimeError(f"{key}: {failure}; inspect {err}") from exc
|
|
693
|
+
time.sleep(1)
|
|
694
|
+
if len(answer.strip()) < 30:
|
|
695
|
+
raise RuntimeError("implausibly short answer")
|
|
696
|
+
missing = missing_headings(answer, headings)
|
|
697
|
+
if missing:
|
|
698
|
+
raise RuntimeError(f"reaction missing headings: {missing}")
|
|
699
|
+
actual_sid = receipt["session_id"]
|
|
700
|
+
previous = self.state["pending"]["parent_id"]
|
|
701
|
+
if mode == "resume" and actual_sid != previous:
|
|
702
|
+
raise RuntimeError("resume silently created a new session")
|
|
703
|
+
if mode == "fork" and (
|
|
704
|
+
actual_sid == previous or actual_sid in self.state["sessions"].values()
|
|
705
|
+
):
|
|
706
|
+
raise RuntimeError("fork did not create a distinct session")
|
|
707
|
+
if (
|
|
708
|
+
mode == "fork"
|
|
709
|
+
and self.runner == "codex"
|
|
710
|
+
and receipt["forked_from"] != previous
|
|
711
|
+
):
|
|
712
|
+
raise RuntimeError("Codex fork names the wrong parent")
|
|
713
|
+
previous_pages = self.banked_history_pages(seat)
|
|
714
|
+
expected_images = Counter(
|
|
715
|
+
digest(self.record / p) for p in previous_pages + list(pages)
|
|
716
|
+
)
|
|
717
|
+
observed_images = Counter(
|
|
718
|
+
history_image_hashes(receipt["rollout"], self.home(seat))
|
|
719
|
+
)
|
|
720
|
+
if expected_images - observed_images:
|
|
721
|
+
raise RuntimeError("persisted reading history lost or changed page images")
|
|
722
|
+
receipt["verified_history_images"] = sum(expected_images.values())
|
|
723
|
+
receipt["exit_code"] = self.state["pending"].get("exit_code")
|
|
724
|
+
self.state["served_model"] = receipt["model"]
|
|
725
|
+
self.state["sessions"][seat] = actual_sid
|
|
726
|
+
dest = self.record / f"{key}.md"
|
|
727
|
+
if dest.exists() and dest.read_text() != answer + "\n":
|
|
728
|
+
raise RuntimeError("refusing to overwrite an existing reaction")
|
|
729
|
+
dest.write_text(answer + "\n")
|
|
730
|
+
receipt.update(
|
|
731
|
+
answer=dest.name, answer_sha=digest(dest), request_sha=request_hash
|
|
732
|
+
)
|
|
733
|
+
self.state["done"][key] = receipt
|
|
734
|
+
self.state["pending"] = None
|
|
735
|
+
self.save()
|
|
736
|
+
for image in physical_images:
|
|
737
|
+
image.unlink()
|
|
738
|
+
self.emit(f"{self.runner}: banked {key} ({receipt['model']})")
|
|
739
|
+
for flag in receipt.get("red_flags", []):
|
|
740
|
+
self.emit(f"{self.runner}: {key}: {flag}")
|
|
741
|
+
if receipt["exit_code"] not in (None, 0):
|
|
742
|
+
raise RuntimeError(
|
|
743
|
+
f"{key}: answer banked, but process exited {receipt['exit_code']}; halt"
|
|
744
|
+
)
|
|
745
|
+
return receipt
|
|
746
|
+
|
|
747
|
+
def checkpoint(self):
|
|
748
|
+
if self.state["pending"]:
|
|
749
|
+
raise RuntimeError("cannot checkpoint a pending turn")
|
|
750
|
+
checkpoint = self.root / "checkpoint"
|
|
751
|
+
if "checkpoint" not in self.state:
|
|
752
|
+
self.require_parent()
|
|
753
|
+
# Provider writers must be quiescent, including delayed writes.
|
|
754
|
+
previous = tree_hashes(self.home("parent"))
|
|
755
|
+
for _ in range(30):
|
|
756
|
+
time.sleep(1)
|
|
757
|
+
now = tree_hashes(self.home("parent"))
|
|
758
|
+
if now == previous:
|
|
759
|
+
break
|
|
760
|
+
previous = now
|
|
761
|
+
else:
|
|
762
|
+
raise RuntimeError("parent runtime home is still changing")
|
|
763
|
+
hashes = snapshot(self.home("parent"), checkpoint)
|
|
764
|
+
self.state["checkpoint"] = hashes
|
|
765
|
+
self.save()
|
|
766
|
+
self.check_parent()
|
|
767
|
+
|
|
768
|
+
def require_parent(self):
|
|
769
|
+
"""Refuse once the parent's home is gone, rather than misname it.
|
|
770
|
+
|
|
771
|
+
A pruned home hashes as one that changed, which would read as a
|
|
772
|
+
parent that moved after the arms branched from it.
|
|
773
|
+
"""
|
|
774
|
+
blind.require_session(
|
|
775
|
+
self.workspace("parent"), self.runner, self.state["sessions"]["parent"]
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
def check_parent(self):
|
|
779
|
+
expected = self.state["checkpoint"]
|
|
780
|
+
if tree_hashes(self.root / "checkpoint") != expected:
|
|
781
|
+
raise RuntimeError("frozen checkpoint changed")
|
|
782
|
+
self.require_parent()
|
|
783
|
+
if tree_hashes(self.home("parent")) != expected:
|
|
784
|
+
raise RuntimeError("parent changed after checkpoint")
|
|
785
|
+
|
|
786
|
+
def fork_home(self, seat):
|
|
787
|
+
self.check_parent()
|
|
788
|
+
home = self.home(seat)
|
|
789
|
+
if not home.exists():
|
|
790
|
+
blind.make_state_root(self.workspace(seat))
|
|
791
|
+
snapshot(self.root / "checkpoint", home)
|
|
792
|
+
elif seat not in self.state["sessions"] and not self.state["pending"]:
|
|
793
|
+
if tree_hashes(home) != self.state["checkpoint"]:
|
|
794
|
+
raise RuntimeError("unsubmitted branch home differs from checkpoint")
|
|
795
|
+
|
|
796
|
+
def run(self):
|
|
797
|
+
self.verify()
|
|
798
|
+
p = self.plan["prompts"]
|
|
799
|
+
for index, item in enumerate(self.plan["prefix"]):
|
|
800
|
+
self.turn(
|
|
801
|
+
"parent",
|
|
802
|
+
f"prefix-{index:02d}-{item['stem']}",
|
|
803
|
+
p["first"] if index == 0 else p["next"],
|
|
804
|
+
item["pages"],
|
|
805
|
+
"start" if index == 0 else "resume",
|
|
806
|
+
p["headings"],
|
|
807
|
+
)
|
|
808
|
+
self.checkpoint()
|
|
809
|
+
for arm in self.plan["order"]:
|
|
810
|
+
self.fork_home(arm)
|
|
811
|
+
for index, item in enumerate(self.plan["arms"][arm]["segments"]):
|
|
812
|
+
self.turn(
|
|
813
|
+
arm,
|
|
814
|
+
f"arm-{arm}-{index:02d}-{item['stem']}",
|
|
815
|
+
p["next"],
|
|
816
|
+
item["pages"],
|
|
817
|
+
"fork" if index == 0 else "resume",
|
|
818
|
+
p["headings"],
|
|
819
|
+
)
|
|
820
|
+
self.check_parent()
|
|
821
|
+
if self.plan.get("canary_markers"):
|
|
822
|
+
for arm in ARMS:
|
|
823
|
+
item = self.plan["arms"][arm]["segments"][0]
|
|
824
|
+
answer = self.record / f"arm-{arm}-00-{item['stem']}.md"
|
|
825
|
+
text = answer.read_text().lower()
|
|
826
|
+
if any(
|
|
827
|
+
marker.lower() not in text for marker in self.plan["canary_markers"]
|
|
828
|
+
):
|
|
829
|
+
raise RuntimeError(
|
|
830
|
+
"fork did not retain the synthetic reading history"
|
|
831
|
+
)
|
|
832
|
+
self.state["canary_passed"] = True
|
|
833
|
+
self.state["complete"] = True
|
|
834
|
+
self.save()
|
|
835
|
+
for arm in self.plan["order"]:
|
|
836
|
+
blind.release(self.workspace(arm))
|
|
837
|
+
blind.hold(
|
|
838
|
+
self.workspace("parent"),
|
|
839
|
+
f"chapter comparison {self.record.name}: inspect checks every arm "
|
|
840
|
+
"against the parent's home",
|
|
841
|
+
)
|
|
842
|
+
self.emit(f"Complete: {self.record}", terminal=True)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def prepare(args):
|
|
846
|
+
record = args.record_dir.expanduser().resolve()
|
|
847
|
+
validate_runner_model(args.runner, args.model)
|
|
848
|
+
record.mkdir(parents=True, exist_ok=False)
|
|
849
|
+
artifact = record / "raw" / "artifact"
|
|
850
|
+
artifact.mkdir(parents=True)
|
|
851
|
+
scratch = Path(
|
|
852
|
+
tempfile.mkdtemp(prefix="quirework-chapter-", dir=Path.home() / "tmp")
|
|
853
|
+
)
|
|
854
|
+
plan = {
|
|
855
|
+
"protocol": PROTOCOL_REVISION,
|
|
856
|
+
"prompt_revision": PROMPT_REVISION,
|
|
857
|
+
"runner": args.runner,
|
|
858
|
+
"model": args.model,
|
|
859
|
+
"effort": args.effort,
|
|
860
|
+
"runner_version": blind.runner_version(args.runner),
|
|
861
|
+
"driver_sha": digest(__file__),
|
|
862
|
+
"blind_sha": digest(blind.__file__),
|
|
863
|
+
"prompt_source_sha": digest(_settings().SOURCE),
|
|
864
|
+
"scratch": str(scratch),
|
|
865
|
+
"seat": args.seat,
|
|
866
|
+
"prompts": prompts(args.seat),
|
|
867
|
+
"prefix": [],
|
|
868
|
+
"arms": {},
|
|
869
|
+
"order": list(ARMS),
|
|
870
|
+
}
|
|
871
|
+
import random
|
|
872
|
+
|
|
873
|
+
entropy = random.SystemRandom()
|
|
874
|
+
entropy.shuffle(plan["order"])
|
|
875
|
+
if args.canary:
|
|
876
|
+
color = entropy.choice(("ultramarine", "vermilion", "turquoise"))
|
|
877
|
+
drink = entropy.choice(("cocoa", "lemonade", "chamomile"))
|
|
878
|
+
plan["source_sha"] = "synthetic"
|
|
879
|
+
plan["target"] = "synthetic"
|
|
880
|
+
plan["canary_markers"] = [color, drink]
|
|
881
|
+
plan["prompts"] = {
|
|
882
|
+
"first": f"Imagine a harmless fictional scene. Its lighthouse is "
|
|
883
|
+
f"painted {color}, and its keeper drinks {drink}. "
|
|
884
|
+
"Acknowledge both details in one sentence. Do not use tools.",
|
|
885
|
+
"next": "Continue the harmless fictional scene from our previous turn. "
|
|
886
|
+
"In one sentence, name the lighthouse's color and the keeper's "
|
|
887
|
+
"drink, then say that the story is continuing. Do not use tools.",
|
|
888
|
+
"headings": [],
|
|
889
|
+
}
|
|
890
|
+
plan["prefix"] = [{"stem": "canary", "pages": []}]
|
|
891
|
+
plan["arms"] = {
|
|
892
|
+
a: {
|
|
893
|
+
"version": "synthetic",
|
|
894
|
+
"segments": [{"stem": "canary", "version": "synthetic", "pages": []}],
|
|
895
|
+
}
|
|
896
|
+
for a in ARMS
|
|
897
|
+
}
|
|
898
|
+
else:
|
|
899
|
+
manifest = read_json(args.original_kit / "manifest.json")
|
|
900
|
+
if manifest["dirty"]:
|
|
901
|
+
raise RuntimeError("baseline kit must be rendered from clean canon sources")
|
|
902
|
+
segments = [i for i in manifest["chapters"] if i["kind"] == "chapter"]
|
|
903
|
+
start_matches = [i for i, s in enumerate(segments) if s["stem"] == args.chapter]
|
|
904
|
+
end_slug = args.through_chapter or args.chapter
|
|
905
|
+
end_matches = [i for i, s in enumerate(segments) if s["stem"] == end_slug]
|
|
906
|
+
if (
|
|
907
|
+
len(start_matches) != 1
|
|
908
|
+
or len(end_matches) != 1
|
|
909
|
+
or start_matches[0] == 0
|
|
910
|
+
or end_matches[0] < start_matches[0]
|
|
911
|
+
):
|
|
912
|
+
raise RuntimeError(
|
|
913
|
+
"target span must resolve in order and have a preceding segment"
|
|
914
|
+
)
|
|
915
|
+
start_index, end_index = start_matches[0], end_matches[0]
|
|
916
|
+
targets = segments[start_index : end_index + 1]
|
|
917
|
+
source_stems = [source.stem for source in args.candidate_source]
|
|
918
|
+
target_stems = [item["stem"] for item in targets]
|
|
919
|
+
if len(targets) > 1 and source_stems != target_stems:
|
|
920
|
+
raise RuntimeError(
|
|
921
|
+
"candidate sources must name every target chapter in reading order"
|
|
922
|
+
)
|
|
923
|
+
if len(targets) == 1 and len(source_stems) != 1:
|
|
924
|
+
raise RuntimeError("a single target requires one candidate source")
|
|
925
|
+
plan["source_sha"] = manifest["source_sha"]
|
|
926
|
+
plan["target"] = args.chapter
|
|
927
|
+
plan["targets"] = target_stems
|
|
928
|
+
plan["hypothesis"] = args.hypothesis.read_text()
|
|
929
|
+
plan["baseline_manifest"] = manifest
|
|
930
|
+
originals = []
|
|
931
|
+
for index, item in enumerate(segments[: end_index + 1]):
|
|
932
|
+
name = item["stem"]
|
|
933
|
+
shutil.copyfile(args.original_kit / item["file"], artifact / f"{name}.pdf")
|
|
934
|
+
pages = render_pages(artifact / f"{name}.pdf", artifact / name)
|
|
935
|
+
entry = {
|
|
936
|
+
"stem": name,
|
|
937
|
+
"title": item["title"],
|
|
938
|
+
"pages": [str(p.relative_to(record)) for p in pages],
|
|
939
|
+
}
|
|
940
|
+
if start_index <= index <= end_index:
|
|
941
|
+
originals.append({**entry, "version": "original"})
|
|
942
|
+
else:
|
|
943
|
+
plan["prefix"].append(entry)
|
|
944
|
+
candidates = []
|
|
945
|
+
if args.candidate_kit:
|
|
946
|
+
candidate_manifest = read_json(args.candidate_kit / "manifest.json")
|
|
947
|
+
if candidate_manifest["source_sha"] != manifest["source_sha"]:
|
|
948
|
+
raise RuntimeError("candidate kit is not based on the baseline commit")
|
|
949
|
+
candidate_segments = {
|
|
950
|
+
item["stem"]: item
|
|
951
|
+
for item in candidate_manifest["chapters"]
|
|
952
|
+
if item["kind"] == "chapter"
|
|
953
|
+
}
|
|
954
|
+
candidate_order = [
|
|
955
|
+
item["stem"]
|
|
956
|
+
for item in candidate_manifest["chapters"]
|
|
957
|
+
if item["kind"] == "chapter"
|
|
958
|
+
]
|
|
959
|
+
if candidate_order != [item["stem"] for item in segments]:
|
|
960
|
+
raise RuntimeError("candidate kit changed the reading order")
|
|
961
|
+
if [item["stem"] for item in targets] != [
|
|
962
|
+
candidate_segments.get(item["stem"], {}).get("stem") for item in targets
|
|
963
|
+
]:
|
|
964
|
+
raise RuntimeError("candidate kit does not contain the target span")
|
|
965
|
+
plan["candidate_manifest"] = candidate_manifest
|
|
966
|
+
for original in originals:
|
|
967
|
+
name = original["stem"]
|
|
968
|
+
candidate_item = candidate_segments[name]
|
|
969
|
+
target_pdf = artifact / f"candidate-{name}.pdf"
|
|
970
|
+
shutil.copyfile(args.candidate_kit / candidate_item["file"], target_pdf)
|
|
971
|
+
pages = render_pages(target_pdf, artifact / f"candidate-{name}")
|
|
972
|
+
candidates.append(
|
|
973
|
+
{
|
|
974
|
+
"stem": name,
|
|
975
|
+
"title": candidate_item["title"],
|
|
976
|
+
"version": "candidate",
|
|
977
|
+
"pages": [str(p.relative_to(record)) for p in pages],
|
|
978
|
+
}
|
|
979
|
+
)
|
|
980
|
+
else:
|
|
981
|
+
if len(originals) != 1:
|
|
982
|
+
raise RuntimeError("a multi-chapter span requires --candidate-kit")
|
|
983
|
+
shutil.copyfile(args.candidate_pdf, artifact / "candidate.pdf")
|
|
984
|
+
pages = render_pages(artifact / "candidate.pdf", artifact / "candidate")
|
|
985
|
+
candidates = [
|
|
986
|
+
{
|
|
987
|
+
"stem": originals[0]["stem"],
|
|
988
|
+
"title": originals[0]["title"],
|
|
989
|
+
"version": "candidate",
|
|
990
|
+
"pages": [str(p.relative_to(record)) for p in pages],
|
|
991
|
+
}
|
|
992
|
+
]
|
|
993
|
+
plan["arms"] = {
|
|
994
|
+
"a": {"version": "original", "segments": originals},
|
|
995
|
+
"b": {"version": "original", "segments": originals},
|
|
996
|
+
"c": {"version": "candidate", "segments": candidates},
|
|
997
|
+
}
|
|
998
|
+
source_dir = artifact / "candidate-sources"
|
|
999
|
+
source_dir.mkdir()
|
|
1000
|
+
for source in args.candidate_source:
|
|
1001
|
+
shutil.copyfile(source, source_dir / source.name)
|
|
1002
|
+
shutil.copyfile(args.candidate_patch, record / "_candidate.patch")
|
|
1003
|
+
shutil.copyfile(__file__, artifact / "driver.py")
|
|
1004
|
+
shutil.copyfile(_settings().SOURCE, artifact / "reader_instrument.py")
|
|
1005
|
+
shutil.copyfile(blind.__file__, artifact / "shared_blind.py")
|
|
1006
|
+
if (
|
|
1007
|
+
digest(artifact / "driver.py") != plan["driver_sha"]
|
|
1008
|
+
or digest(artifact / "reader_instrument.py") != plan["prompt_source_sha"]
|
|
1009
|
+
or digest(artifact / "shared_blind.py") != plan["blind_sha"]
|
|
1010
|
+
):
|
|
1011
|
+
raise RuntimeError("implementation changed during preparation")
|
|
1012
|
+
plan["hashes"] = tree_hashes(artifact)
|
|
1013
|
+
plan["hashes"] = {f"raw/artifact/{k}": v for k, v in plan["hashes"].items()}
|
|
1014
|
+
if (record / "_candidate.patch").exists():
|
|
1015
|
+
plan["hashes"]["_candidate.patch"] = digest(record / "_candidate.patch")
|
|
1016
|
+
write_json(record / "plan.json", plan)
|
|
1017
|
+
(record / "plan.sha256").write_text(digest(record / "plan.json") + "\n")
|
|
1018
|
+
print(f"Prepared: {record}", flush=True)
|
|
1019
|
+
return record
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def main(argv=None):
|
|
1023
|
+
parser = argparse.ArgumentParser(
|
|
1024
|
+
prog="quirework publish compare", description=__doc__
|
|
1025
|
+
)
|
|
1026
|
+
sub = parser.add_subparsers(dest="action", required=True)
|
|
1027
|
+
prep = sub.add_parser("prepare", help="stage a forked reader comparison")
|
|
1028
|
+
prep.add_argument("--record-dir", type=Path, required=True)
|
|
1029
|
+
prep.add_argument("--runner", choices=("codex", "claude"), required=True)
|
|
1030
|
+
prep.add_argument("--model", required=True)
|
|
1031
|
+
prep.add_argument("--effort", default="xhigh")
|
|
1032
|
+
prep.add_argument("--seat", choices=("trade",), default="trade")
|
|
1033
|
+
prep.add_argument("--canary", action="store_true")
|
|
1034
|
+
prep.add_argument("--original-kit", type=Path)
|
|
1035
|
+
prep.add_argument("--chapter")
|
|
1036
|
+
prep.add_argument("--through-chapter")
|
|
1037
|
+
prep.add_argument("--candidate-kit", type=Path)
|
|
1038
|
+
prep.add_argument("--candidate-pdf", type=Path)
|
|
1039
|
+
prep.add_argument("--candidate-source", type=Path, action="append")
|
|
1040
|
+
prep.add_argument("--candidate-patch", type=Path)
|
|
1041
|
+
prep.add_argument("--hypothesis", type=Path)
|
|
1042
|
+
for name, text in (
|
|
1043
|
+
("run", "run the prepared arms"),
|
|
1044
|
+
("inspect", "each arm's progress and receipts"),
|
|
1045
|
+
):
|
|
1046
|
+
action = sub.add_parser(name, help=text)
|
|
1047
|
+
action.add_argument("record_dir", type=Path)
|
|
1048
|
+
if name == "run":
|
|
1049
|
+
action.add_argument(
|
|
1050
|
+
"--progress",
|
|
1051
|
+
action="store_true",
|
|
1052
|
+
help="print each banked turn; default output is terminal status only",
|
|
1053
|
+
)
|
|
1054
|
+
args = parser.parse_args(argv)
|
|
1055
|
+
if args.action == "prepare":
|
|
1056
|
+
if not args.canary and not all(
|
|
1057
|
+
[
|
|
1058
|
+
args.original_kit,
|
|
1059
|
+
args.chapter,
|
|
1060
|
+
args.candidate_source,
|
|
1061
|
+
args.candidate_patch,
|
|
1062
|
+
args.hypothesis,
|
|
1063
|
+
args.candidate_kit or args.candidate_pdf,
|
|
1064
|
+
]
|
|
1065
|
+
):
|
|
1066
|
+
parser.error(
|
|
1067
|
+
"a book experiment needs both artifacts, source, patch and hypothesis"
|
|
1068
|
+
)
|
|
1069
|
+
prepare(args)
|
|
1070
|
+
elif args.action == "run":
|
|
1071
|
+
with (args.record_dir / ".lock").open("w") as lock:
|
|
1072
|
+
try:
|
|
1073
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1074
|
+
except BlockingIOError as exc:
|
|
1075
|
+
raise RuntimeError(
|
|
1076
|
+
"another operator is running this experiment"
|
|
1077
|
+
) from exc
|
|
1078
|
+
experiment = Experiment(args.record_dir, progress=args.progress)
|
|
1079
|
+
experiment.run()
|
|
1080
|
+
if experiment.state.get("canary_passed"):
|
|
1081
|
+
print(
|
|
1082
|
+
"Canary: all three distinct forks retained parent history; parent unchanged"
|
|
1083
|
+
)
|
|
1084
|
+
else:
|
|
1085
|
+
experiment = Experiment(args.record_dir)
|
|
1086
|
+
experiment.verify()
|
|
1087
|
+
if experiment.state.get("checkpoint"):
|
|
1088
|
+
experiment.check_parent()
|
|
1089
|
+
print(json.dumps(experiment.state, indent=2))
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
if __name__ == "__main__":
|
|
1093
|
+
try:
|
|
1094
|
+
main()
|
|
1095
|
+
except (RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as exc:
|
|
1096
|
+
print(f"STOPPED: {exc}", file=sys.stderr)
|
|
1097
|
+
sys.exit(2)
|