bantamkit 0.27.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. bantamkit/__init__.py +32 -0
  2. bantamkit/agent.py +458 -0
  3. bantamkit/assets/contracts/default.yaml +90 -0
  4. bantamkit/assets/evals/devteam/manifest.yaml +351 -0
  5. bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. bantamkit/assets/evals/devteam/repo/README.md +12 -0
  7. bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. bantamkit/assets/evals/fixtures/.gitkeep +0 -0
  40. bantamkit/assets/evals/fixtures/catalog.json +6 -0
  41. bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
  42. bantamkit/assets/evals/tasks/.gitkeep +0 -0
  43. bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
  44. bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
  46. bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
  48. bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
  59. bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
  62. bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
  65. bantamkit/assets/profiles/default.yaml +31 -0
  66. bantamkit/assets/profiles/patient.yaml +31 -0
  67. bantamkit/assets/rubrics/.gitkeep +0 -0
  68. bantamkit/assets/rubrics/code-quality.yaml +20 -0
  69. bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
  70. bantamkit/assets/rubrics/task-completion.yaml +28 -0
  71. bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. bantamkit/assets/skills/.gitkeep +0 -0
  73. bantamkit/assets/skills/file-graph.md +7 -0
  74. bantamkit/assets/skills/memory.md +35 -0
  75. bantamkit/assets/tools/.gitkeep +0 -0
  76. bantamkit/assets/tools/bantamkit_read.json +48 -0
  77. bantamkit/assets/tools/bantamkit_status.json +25 -0
  78. bantamkit/assets/tools/build_identity.json +17 -0
  79. bantamkit/assets/tools/document_list.json +12 -0
  80. bantamkit/assets/tools/document_read.json +31 -0
  81. bantamkit/assets/tools/file_graph.json +12 -0
  82. bantamkit/assets/tools/memory_compact.json +31 -0
  83. bantamkit/assets/tools/memory_recall.json +38 -0
  84. bantamkit/assets/tools/memory_save.json +61 -0
  85. bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
  86. bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
  87. bantamkit/assets/tools/shiftwork_status.json +25 -0
  88. bantamkit/assets/tools/skill_audit.json +70 -0
  89. bantamkit/assets/tools/validate_json.json +31 -0
  90. bantamkit/assets.py +67 -0
  91. bantamkit/budget.py +114 -0
  92. bantamkit/client.py +329 -0
  93. bantamkit/contract.py +522 -0
  94. bantamkit/criticreplay.py +3241 -0
  95. bantamkit/critique.py +301 -0
  96. bantamkit/docread.py +1744 -0
  97. bantamkit/evalrun.py +2003 -0
  98. bantamkit/eventlog.py +282 -0
  99. bantamkit/filegraph.py +218 -0
  100. bantamkit/loopguard.py +101 -0
  101. bantamkit/mcpreport.py +763 -0
  102. bantamkit/mcpserver.py +1334 -0
  103. bantamkit/memory/__init__.py +28 -0
  104. bantamkit/memory/__main__.py +291 -0
  105. bantamkit/memory/component.py +569 -0
  106. bantamkit/memory/divergence.py +744 -0
  107. bantamkit/memory/layers.py +257 -0
  108. bantamkit/memory/store.py +940 -0
  109. bantamkit/pdfread.py +1402 -0
  110. bantamkit/profile.py +46 -0
  111. bantamkit/shiftwork.py +212 -0
  112. bantamkit/skillaudit.py +853 -0
  113. bantamkit/statusline.py +313 -0
  114. bantamkit/structured.py +125 -0
  115. bantamkit/textutil.py +30 -0
  116. bantamkit-0.27.0.dist-info/METADATA +207 -0
  117. bantamkit-0.27.0.dist-info/RECORD +119 -0
  118. bantamkit-0.27.0.dist-info/WHEEL +4 -0
  119. bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
bantamkit/evalrun.py ADDED
@@ -0,0 +1,2003 @@
1
+ """Eval harness: bare vs +toolkit on the same suite, with token accounting (spec §4.6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import hashlib
7
+ import io
8
+ import json
9
+ import re
10
+ import sys
11
+ import tempfile
12
+ import zipfile
13
+ from collections import Counter
14
+ from collections.abc import Callable
15
+ from dataclasses import asdict, dataclass
16
+ from pathlib import Path, PurePosixPath
17
+
18
+ import yaml
19
+
20
+ from bantamkit.agent import Agent, MaxTurnsExceeded, ToolDef, response_format_for
21
+ from bantamkit.assets import assets_root, load_tool
22
+ from bantamkit.budget import TokenBudget
23
+ from bantamkit.client import BantamError, Message, ModelClient, OpenAICompatible, Tool, Usage
24
+ from bantamkit.contract import (
25
+ document_error,
26
+ document_manifest,
27
+ document_offset_past_end,
28
+ document_page,
29
+ document_paste,
30
+ document_unknown,
31
+ schema_error,
32
+ schema_instruction,
33
+ schema_retry_feedback,
34
+ )
35
+ from bantamkit.critique import CritiqueExhausted, CritiqueGate, GroundedCritiqueGate
36
+ from bantamkit.docread import (
37
+ DEFAULT_ROW_LIMIT,
38
+ PAGE_MAX_BYTES,
39
+ PAGE_MAX_ROWS,
40
+ Document,
41
+ DocumentReadError,
42
+ extract,
43
+ page,
44
+ )
45
+ from bantamkit.filegraph import FileAccessGraph, ReadAccounting
46
+ from bantamkit.loopguard import LoopGuard
47
+ from bantamkit.memory import Memory, MemoryStore
48
+ from bantamkit.profile import default as profile_default
49
+ from bantamkit.profile import load_profile
50
+ from bantamkit.structured import (
51
+ JsonAnswerGate,
52
+ StructuredOutputError,
53
+ extract_json,
54
+ structured,
55
+ )
56
+
57
+ CONFIGS = ["bare", "structured", "critique", "grounded", "graph", "memory", "lean", "full"]
58
+
59
+
60
+ # ---- deterministic eval fixture tools (fixture data lives in assets) ----
61
+
62
+
63
+ def _catalog() -> dict:
64
+ path = assets_root() / "evals" / "fixtures" / "catalog.json"
65
+ return json.loads(path.read_text(encoding="utf-8"))
66
+
67
+
68
+ def _lookup(field: str):
69
+ def handler(item: str) -> str:
70
+ entry = _catalog().get(item.lower())
71
+ if entry is None:
72
+ return f"error: unknown item '{item}'. known items: {sorted(_catalog())}"
73
+ return f"{item.lower()} {field}: {entry[field]}"
74
+
75
+ return handler
76
+
77
+
78
+ _ITEM_SCHEMA = {
79
+ "type": "object",
80
+ "required": ["item"],
81
+ "properties": {"item": {"type": "string"}},
82
+ }
83
+
84
+ _PRICE_TOOL = ToolDef(
85
+ tool=Tool(
86
+ name="price_lookup",
87
+ description="Get the unit price of an item",
88
+ parameters=_ITEM_SCHEMA,
89
+ ),
90
+ handler=_lookup("price"),
91
+ )
92
+ _STOCK_TOOL = ToolDef(
93
+ tool=Tool(
94
+ name="stock_lookup",
95
+ description="Get the stock count of an item",
96
+ parameters=_ITEM_SCHEMA,
97
+ ),
98
+ handler=_lookup("stock"),
99
+ )
100
+
101
+ BUILTIN_TOOLS = {
102
+ "price_lookup": _PRICE_TOOL,
103
+ "stock_lookup": _STOCK_TOOL,
104
+ }
105
+
106
+ WORKSPACE_TOOLS = ("read_file", "list_files")
107
+
108
+ _PATH_SCHEMA = {
109
+ "type": "object",
110
+ "required": ["path"],
111
+ "properties": {"path": {"type": "string"}},
112
+ }
113
+
114
+
115
+ def _workspace_tools(workspace: dict) -> dict[str, ToolDef]:
116
+ """Per-task file tools over the task's `workspace:` mapping (path -> content)."""
117
+
118
+ def read_file(path: str) -> str:
119
+ content = workspace.get(path)
120
+ if content is None:
121
+ return f"error: unknown file '{path}'. available: {sorted(workspace)}"
122
+ return content
123
+
124
+ def list_files() -> str:
125
+ return "\n".join(sorted(workspace))
126
+
127
+ return {
128
+ "read_file": ToolDef(
129
+ tool=Tool(
130
+ name="read_file",
131
+ description="Read the full content of one file by its exact path",
132
+ parameters=_PATH_SCHEMA,
133
+ ),
134
+ handler=read_file,
135
+ ),
136
+ "list_files": ToolDef(
137
+ tool=Tool(
138
+ name="list_files",
139
+ description="List all file paths in the workspace",
140
+ parameters={"type": "object", "properties": {}},
141
+ ),
142
+ handler=list_files,
143
+ ),
144
+ }
145
+
146
+
147
+ GRAPH_CONFIGS = {
148
+ "graph": {"annotate": True, "cache": True, "query": True},
149
+ "graph-annotate": {"annotate": True, "cache": False, "query": False},
150
+ "graph-cache": {"annotate": True, "cache": True, "query": False},
151
+ # Rung zero WITH the ledger. `bare` is already a clean rung zero for the
152
+ # ladder's token deltas — `graph-annotate`'s `effective` resolves to itself and
153
+ # picks up no other component, so `bare -> graph-annotate -> graph-cache ->
154
+ # graph` isolates one flag per step. What `bare` cannot do is COUNT: it attaches
155
+ # no FileAccessGraph, so nothing records how many reads a run made or how many
156
+ # of them were repeats. All three flags off records the ledger and changes
157
+ # nothing the model sees — the wrapped reader returns the observation unchanged on
158
+ # every path when `cache` and `annotate` are both False (`filegraph.py:133-195`,
159
+ # the three returns at `143`, `153` and `195`), and `setup` registers no tool and
160
+ # adds no skill when `query` is False (`filegraph.py:118-122`). Both pins were
161
+ # re-read at HEAD, not shifted by arithmetic: the previous `83-92` / `51-53` were
162
+ # written against a pre-`d522e93` file and named 1 of the 3 return paths (bar
163
+ # §9/A3, re-pinned in §9/A4). That is what makes it the meaning-preserving null
164
+ # control: a mechanism-off arm that removes no information, and the only arm
165
+ # that can tell "the collapse did not help" from "the collapse never fired".
166
+ # Pinned by test_evalrun.py::test_graph_off_observations_are_identical_to_bare.
167
+ "graph-off": {"annotate": False, "cache": False, "query": False},
168
+ }
169
+
170
+ # Calibration-only too (same precedent as GRAPH_CONFIGS): each name maps to the headline
171
+ # config it mirrors exactly, plus a TokenBudget. `budgeted` is `full` under a ceiling —
172
+ # it earns a place in CONFIGS only once the calibration bars say the ceiling holds.
173
+ BUDGET_CONFIGS = {"budgeted": "full"}
174
+
175
+ # Same precedent once more: each guarded name mirrors its headline config exactly, plus
176
+ # a LoopGuard — attached last in run_task so it wraps every tool (v1 wraps only what is
177
+ # registered at setup). Calibration-only until the conversion bars say otherwise.
178
+ GUARD_CONFIGS = {"graph-guarded": "graph", "memory-guarded": "memory"}
179
+
180
+ # Same precedent a fourth time. `reader` mirrors `bare` exactly, plus the document tool pair
181
+ # — and `bare` is the right thing to mirror, because it is the only arm that says what an
182
+ # over-window corpus costs with NO reader at all. Resolving `effective` to `bare` means the
183
+ # arm picks up no schema gate, no critic, no graph and no memory, so `bare -> reader`
184
+ # isolates one component per step exactly as `bare -> graph-annotate` does.
185
+ #
186
+ # Calibration-only, in CONFIG_CHOICES and NOT in CONFIGS, for the reason the other three are:
187
+ # a component earns a place in the permanent matrix when a bar says it did, and this job's
188
+ # bar has not been run yet. Keeping it out also means the default suite is byte-identical to
189
+ # the one before this commit for every task that declares no `document_setup:`.
190
+ READER_CONFIGS = {"reader": "bare"}
191
+
192
+ # Same precedent a fifth time, and this one is the reader's REAL comparison rather than its
193
+ # floor. `paste` mirrors `bare` exactly — no schema gate, no critic, no graph, no memory and
194
+ # NO reader tools — plus one system message carrying as much of the corpus as fits. It is what
195
+ # a practitioner with no reader does, and J4 died for assuming that paste was always available:
196
+ # `inventory.xlsx` extracts to 258,129 B, which is 7.88x the 8,192-token window the declared
197
+ # tiers are actually served at, so the paste J4 imagined is unconstructible and the arm that
198
+ # replaces it is incomplete BY CONSTRUCTION on the large corpus and complete on the small one.
199
+ # Pre-registered at `docs/eval-data/2026-08-20-document-read-bar.md` §10.2 before any arm ran;
200
+ # this is a transcription of that contract, and the constant below is one of its five clauses.
201
+ PASTE_CONFIGS = {"paste": "bare"}
202
+
203
+ # ONE constant over BOTH corpora, and it is not tuned per corpus. AMENDMENT 1 to the bar
204
+ # (2026-08-20, §12), dated and reasoned there, BEFORE any graded run: 12,288 was sized on
205
+ # `bytes // 4`, and G-3 measured that estimator to be 2.83-2.91x wrong on this content
206
+ # (1.372-1.415 B/token). At 12,288 the large paste's prompt measured >= 8,192 `prompt_eval_count`
207
+ # on BOTH counters at all three compared tiers — clamped, i.e. the true prompt EXCEEDED the
208
+ # served window and the arm was already being truncated server-side (RB-P53). V-1 fired and the
209
+ # whole comparison was uncomputable.
210
+ #
211
+ # 8,621 is the size of the COMPLETE small corpus, which is the design's binding constraint: the
212
+ # small cell is only a level-ground comparison if the paste is whole there, so the constant
213
+ # cannot go below it, and every byte above it buys the large paste rows at the cost of the
214
+ # margin V-1 exists to protect (measured: 9,088 reads 6,963/6,984 tokens, exactly at and over
215
+ # the threshold). Measured at this commit, cap 8,621:
216
+ # large keeps rendered rows 0-400 (401 of 12,001 = 3.3414%, 8,621 B), row 401 first outside
217
+ # small needs 8,621 B and is kept WHOLE -> COMPLETE
218
+ # Both corpora carry the identical 401 rows, so §1.4's `doc-small-137` / `doc-large-in-137` pair
219
+ # now differs in nothing but the corpus behind it. §7.10 still holds: a larger window would give
220
+ # a larger paste and a smaller effect. Shrinking this again is another amendment, never a
221
+ # silent adjustment.
222
+ PASTE_MAX_BYTES = 8621
223
+
224
+ # The four MIRROR families above each map a calibration name to the headline config it copies,
225
+ # and every one of them exists to make a ladder step isolate ONE component. Resolving that here
226
+ # rather than inline in `run_task` is what makes the mapping checkable: the property that every
227
+ # mirrored name lands on a real config in CONFIGS is the whole reason `bare -> reader` and
228
+ # `bare -> paste` are one-component steps, and an arm that quietly resolved to itself would
229
+ # still behave identically today and stop being a mirror the moment a component keyed off a new
230
+ # name. GRAPH_CONFIGS is deliberately absent: its names resolve to THEMSELVES because the flag
231
+ # dict is looked up by the resolved name (`effective in GRAPH_CONFIGS`), which is a different
232
+ # mechanism wearing the same variable.
233
+ MIRROR_CONFIGS = (GUARD_CONFIGS, BUDGET_CONFIGS, READER_CONFIGS, PASTE_CONFIGS)
234
+
235
+
236
+ def effective_config(config: str) -> str:
237
+ """The headline config a calibration-only name mirrors, or the name itself."""
238
+ for family in MIRROR_CONFIGS:
239
+ if config in family:
240
+ return family[config]
241
+ return config
242
+
243
+
244
+ # Every config name run_task accepts: the permanent matrix plus calibration-only ablations.
245
+ CONFIG_CHOICES = CONFIGS + sorted(
246
+ (
247
+ set(GRAPH_CONFIGS)
248
+ | set(BUDGET_CONFIGS)
249
+ | set(GUARD_CONFIGS)
250
+ | set(READER_CONFIGS)
251
+ | set(PASTE_CONFIGS)
252
+ )
253
+ - set(CONFIGS)
254
+ )
255
+
256
+
257
+ # ---- suite ----
258
+
259
+
260
+ @dataclass
261
+ class TaskResult:
262
+ task: str
263
+ config: str
264
+ family: str
265
+ passed: bool
266
+ tokens: int
267
+ outcome: str
268
+ model_calls: int
269
+ tool_calls: int
270
+ schema_retries: int
271
+ critique_rounds: int
272
+ error: str | None
273
+ # Trailing field: new JSONL columns are additive, old rows simply lack them.
274
+ # None means no seed was applied (a client that has no `seed` attribute to pin).
275
+ seed: int | None = None
276
+ # Bar §8's accounting grain, added by M3.5 under the same trailing-field
277
+ # convention: additive, defaulted, and nothing above is renumbered, reordered or
278
+ # repurposed. `tokens` still means exactly what it meant.
279
+ #
280
+ # Columns 1-6 are read off `FileAccessGraph.accounting` — the ledger the harness
281
+ # used to discard (bar §7.2) — and read 0 on every config that attaches no graph,
282
+ # which is what "no graph was attached" looks like rather than a measurement.
283
+ # Column 7 is counted in `TrackingClient.chat` below.
284
+ #
285
+ # `unrecorded_reader_calls` is NOT one of bar §8's seven. It is the carve-out that
286
+ # makes column 1 honest: reads that returned the harness's `error:` convention are
287
+ # counted as attempts and named here rather than silently missing from the
288
+ # denominator (null-control §7.3). Ledger-sourced rates use
289
+ # `reader_calls - unrecorded_reader_calls`. Recorded per bar §9/A4.
290
+ reader_calls: int = 0
291
+ unrecorded_reader_calls: int = 0
292
+ repeat_reader_calls: int = 0
293
+ collapsed_calls: int = 0
294
+ collapsed_bytes: int = 0
295
+ annotate_marker_bytes: int = 0
296
+ query_bytes: int = 0
297
+ context_bytes_sent: int = 0
298
+ # RB-P38's fix, under the same trailing-field convention as `seed` above and the
299
+ # eight accounting columns: additive, defaulted, nothing above renumbered. The
300
+ # committed rows that predate it simply lack the key — backfilling them would be a
301
+ # retro-edit of evidence.
302
+ #
303
+ # The value is read off the client the harness already holds, by duck typing and not
304
+ # by signature, exactly as `seed` is. `None` means the client carries no model name,
305
+ # which is what a fake or an in-process stub looks like; recording the CLI's
306
+ # `--model` string instead would attribute a row to a model that never answered it.
307
+ model: str | None = None
308
+ # The repeat index, under the same trailing-field convention as `seed` above, the
309
+ # eight accounting columns and RB-P38's `model`: additive, defaulted, nothing above
310
+ # renumbered, reordered or repurposed. Rows that predate it simply lack the key —
311
+ # backfilling them would be a retro-edit of evidence.
312
+ #
313
+ # `repeat` is the finest grain a row can be matched on across arms, and until now it
314
+ # was only *recoverable*: `run_seed` is injective over the (task, repeat) domain, so
315
+ # an analyst could invert the seed by brute force, which is exactly what
316
+ # `docs/eval-data/2026-08-17-devteam-ladder-field-measurement.py`'s
317
+ # `slots_are_repeat_indexed` does. That recovery costs the reader the model string and
318
+ # a 66-hash loop, and it stops working the moment anything about the seed changes.
319
+ # `run_task` is handed the number already; this records it instead of re-deriving it.
320
+ # `run_seed` is untouched — the seed a row carries still means exactly what it meant,
321
+ # and the recorded `repeat` is a witness that can be checked against it.
322
+ repeat: int = 0
323
+
324
+
325
+ def request_wire_bytes(messages: list[Message], tools: list[Tool] | None) -> int:
326
+ """Bytes of the transcript-and-roster half of one request, as the adapter serializes it.
327
+
328
+ Mirrors `OpenAICompatible.chat`'s payload construction (`client.py:155-157`) key for
329
+ key, minus the two fields a wrapper cannot see: `model` and `seed` live on the inner
330
+ client and are per-run constants, so excluding them keeps this a function of the
331
+ transcript — which is the quantity bar §8 column 7 exists to be a denominator for.
332
+ """
333
+ payload: dict = {"messages": [m.to_wire() for m in messages]}
334
+ if tools:
335
+ payload["tools"] = [t.to_wire() for t in tools]
336
+ return len(json.dumps(payload).encode())
337
+
338
+
339
+ class TrackingClient:
340
+ """Wraps any ModelClient and accumulates token usage and call count across all calls."""
341
+
342
+ def __init__(self, inner: ModelClient):
343
+ self.inner = inner
344
+ self.usage = Usage()
345
+ self.calls = 0
346
+ # Bar §8 column 7. Accumulated here rather than at the adapter because the
347
+ # transcript is re-sent whole on every call, so a byte that entered the context
348
+ # once is paid for on every later request — and that re-send weighting is the
349
+ # only thing that converts a byte share into a token share.
350
+ self.context_bytes_sent = 0
351
+
352
+ @property
353
+ def _response_format_unsupported(self) -> bool:
354
+ """Mirror the inner client's capability memo, live.
355
+
356
+ Callers duck-type on this attribute, and they see the wrapper, not the
357
+ wrapped client. Raising AttributeError when the inner has no memo is the
358
+ point: `hasattr` then reads False and the wrapper is as transparent to the
359
+ constrained-decoding tier as it already is to `seed`.
360
+ """
361
+ return self.inner._response_format_unsupported
362
+
363
+ def chat(self, messages, tools=None, response_format=None):
364
+ # Before the call, so a request that raises still counts the bytes it sent.
365
+ self.context_bytes_sent += request_wire_bytes(messages, tools)
366
+ # Forwarded only when asked for AND understood: fake clients whose chat()
367
+ # takes two arguments must keep working, and they never carry the memo.
368
+ if response_format is not None and hasattr(self.inner, "_response_format_unsupported"):
369
+ resp = self.inner.chat(messages, tools, response_format=response_format)
370
+ else:
371
+ resp = self.inner.chat(messages, tools)
372
+ self.usage = self.usage + resp.usage
373
+ self.calls += 1
374
+ return resp
375
+
376
+
377
+ def load_tasks(tasks_dir: Path | None = None) -> list[dict]:
378
+ tasks_dir = tasks_dir or assets_root() / "evals" / "tasks"
379
+ files = sorted(Path(tasks_dir).glob("*.yaml"))
380
+ if not files:
381
+ raise EvalConfigError(f"no task files found in {tasks_dir}")
382
+ return [yaml.safe_load(f.read_text(encoding="utf-8")) for f in files]
383
+
384
+
385
+ def contains_term(output: str, term: str) -> bool:
386
+ """Case-insensitive match on word boundaries.
387
+
388
+ A plain substring test scores wrong answers as passes: `100` is inside `1000`, `atlas`
389
+ is inside `atlassian`. The guards are "no word character either side" rather than `\\b`,
390
+ so terms that start or end with punctuation still anchor the way you would expect.
391
+ Digit-comma adjacency is also blocked, so `200` does not match inside `1,200`.
392
+ """
393
+ pattern = rf"(?<!\d,)(?<!\w){re.escape(term)}(?!\w)(?!,\d)"
394
+ return re.search(pattern, output, re.IGNORECASE) is not None
395
+
396
+
397
+ def score_output(task: dict, output: str, messages: list[Message]) -> bool:
398
+ kind = task["scoring"]["kind"]
399
+ expected = task["scoring"]["expected"]
400
+ if kind == "json_equal":
401
+ try:
402
+ return extract_json(output) == expected
403
+ except ValueError:
404
+ return False
405
+ if kind == "contains":
406
+ return all(contains_term(output, str(s)) for s in expected)
407
+ if kind == "tool_trace":
408
+ trace = [tc.name for m in messages for tc in m.tool_calls]
409
+ it = iter(trace)
410
+ return all(name in it for name in expected) # ordered subsequence
411
+ raise ValueError(f"unknown scoring kind '{kind}'")
412
+
413
+
414
+ class EvalConfigError(BantamError):
415
+ """A task and a config combine into something the harness cannot score."""
416
+
417
+
418
+ # ---- generated document fixtures (`document_setup:`) ----------------------------------
419
+ #
420
+ # `memory_setup:` is the precedent: a task declares facts, and `run_task` materialises them
421
+ # into a real per-run `workdir` before the agent starts. A document works the same way and
422
+ # for the same reason. It cannot use `workspace:` — that mapping holds `path -> str` and its
423
+ # `read_file` returns the string; an `.xlsx` is a zip archive, so it has no representation in
424
+ # that mapping and no reader can open it from there. Widening `workspace:` to carry bytes
425
+ # would give every file-nav task a second, silently different storage mode; putting the
426
+ # document on the filesystem where a reader expects it does not.
427
+ #
428
+ # The task declares a GENERATOR, not a file. The repository therefore gains no binary blob,
429
+ # and a corpus larger than the worker context window costs nothing to ship. The price is that
430
+ # a reader of the task YAML sees the SHAPE (how many rows, which columns, what value range,
431
+ # which cell holds the answer) but not the VALUES: those come out of the seed. That trade is
432
+ # taken deliberately — the alternative is a committed blob whose contents no diff can review
433
+ # either, plus a hand-written expected answer that drifts from it the first time the blob is
434
+ # regenerated. Here the expected answer is resolved by extracting the file that was just
435
+ # built, so the answer cannot disagree with the document by construction.
436
+
437
+
438
+ class DocumentSetupError(EvalConfigError):
439
+ """A `document_setup:` declaration the harness cannot build.
440
+
441
+ Raised at setup, BEFORE the agent runs, and left to ESCAPE `run_task` rather than caught
442
+ into the `config-error` outcome that a missing `family` produces. A run that starts with no
443
+ document and scores `wrong-answer` measures nothing at all and reports a defect in the
444
+ declaration as a defect in the model; a `config-error` row is only marginally better,
445
+ because `main()` prints its report and returns whatever the outcomes were — no outcome
446
+ class moves the process exit status, so a suite whose corpus never got built still exits 0.
447
+ A typo here is a hard failure, never a recorded non-measurement.
448
+ """
449
+
450
+
451
+ DOCUMENT_SUFFIXES = ("xlsx", "docx")
452
+ DOCUMENT_COLUMN_KINDS = ("key", "choice", "int")
453
+
454
+ _ENTRY_KEYS = frozenset({"path", "seed", "sheets", "rows", "columns", "answers"})
455
+ _SHEET_KEYS = frozenset({"name", "rows", "columns"})
456
+ _COLUMN_KEYS = frozenset({"name", "kind", "prefix", "width", "values", "low", "high"})
457
+
458
+ # Every archive member is written with this timestamp and no compression. Both are
459
+ # byte-determinism requirements, not cosmetics. A zip member records an mtime, so the default
460
+ # `writestr(str, ...)` stamps *now* and the same declaration hashes differently on every run;
461
+ # `ZipInfo.create_system` additionally defaults to 3 on POSIX and 0 on Windows, which moves a
462
+ # byte across machines. Compression is left off because DEFLATE output is a function of the
463
+ # linked zlib, so a compressed fixture's hash would be pinned to a build of a C library rather
464
+ # than to the declaration. STORED costs disk in a temp dir and buys a hash that is a function
465
+ # of the declaration alone.
466
+ _ZIP_EPOCH = (1980, 1, 1, 0, 0, 0)
467
+
468
+ _CONTENT_TYPES = (
469
+ '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/'
470
+ 'content-types"><Default Extension="xml" ContentType="application/xml"/>'
471
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.'
472
+ 'relationships+xml"/></Types>'
473
+ )
474
+ _ROOT_RELS_XLSX = (
475
+ '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/'
476
+ '2006/relationships"><Relationship Id="rIdWb" Type="http://schemas.openxmlformats.org/'
477
+ 'officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
478
+ "</Relationships>"
479
+ )
480
+ _ROOT_RELS_DOCX = (
481
+ '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/'
482
+ '2006/relationships"><Relationship Id="rIdDoc" Type="http://schemas.openxmlformats.org/'
483
+ 'officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
484
+ "</Relationships>"
485
+ )
486
+ _SHEET_NS = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"'
487
+ _REL_NS = 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'
488
+ _WORD_NS = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
489
+
490
+
491
+ @dataclass(frozen=True)
492
+ class DocumentFixture:
493
+ """One materialised document. Sizes are MEASURED off the file that was just written."""
494
+
495
+ name: str
496
+ path: Path
497
+ file_bytes: int
498
+ sha256: str
499
+ text_bytes: int
500
+ row_counts: tuple[int, ...]
501
+ answers: dict[str, str]
502
+
503
+ @property
504
+ def est_tokens(self) -> int:
505
+ """Extracted bytes / 4, the estimator every other measurement in this repo uses.
506
+
507
+ Off the EXTRACTED text, never `file_bytes`: `docread` measured the ratio between the
508
+ two spanning 1660x across real files, so a budget taken from the file size is wrong
509
+ by up to three orders of magnitude.
510
+ """
511
+ return self.text_bytes // 4
512
+
513
+
514
+ def _doc_field(seed: int, part: str, column: dict, index: int) -> str:
515
+ """One cell, as a pure function of (seed, part, column, 1-based data row).
516
+
517
+ SHA-256 rather than `random.Random`: the Mersenne stream is a CPython implementation
518
+ detail, while the digest of a byte string is specified. A fixture whose values could move
519
+ under an interpreter upgrade is not a fixture.
520
+ """
521
+ kind = column["kind"]
522
+ if kind == "key":
523
+ return f"{column.get('prefix', '')}{index:0{int(column.get('width', 6))}d}"
524
+ digest = hashlib.sha256(f"{seed}|{part}|{column['name']}|{index}".encode()).digest()
525
+ draw = int.from_bytes(digest[:8], "big")
526
+ if kind == "choice":
527
+ return str(column["values"][draw % len(column["values"])])
528
+ return str(int(column["low"]) + draw % (int(column["high"]) - int(column["low"]) + 1))
529
+
530
+
531
+ def _xml_text(value: str) -> str:
532
+ return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
533
+
534
+
535
+ def _column_letter(index: int) -> str:
536
+ letters = ""
537
+ index += 1
538
+ while index:
539
+ index, rem = divmod(index - 1, 26)
540
+ letters = chr(65 + rem) + letters
541
+ return letters
542
+
543
+
544
+ def _sheet_xml(seed: int, name: str, columns: list[dict], rows: int) -> str:
545
+ """Row 1 is the header, so declared data row `i` is spreadsheet row `i + 1`.
546
+
547
+ Every row from 1 to `rows + 1` is declared, with no gaps. That matters because `docread`
548
+ does not materialise undeclared rows: only because this generator leaves none does a
549
+ spreadsheet row number equal its rendered index + 1, which is what makes the `sheet!C4138`
550
+ answer addresses below mean what a reader of the YAML assumes they mean.
551
+ """
552
+ out = []
553
+ header = "".join(
554
+ f'<c r="{_column_letter(c)}1" t="inlineStr"><is><t>{_xml_text(str(col["name"]))}'
555
+ "</t></is></c>"
556
+ for c, col in enumerate(columns)
557
+ )
558
+ out.append(f'<row r="1">{header}</row>')
559
+ for i in range(1, rows + 1):
560
+ r = i + 1
561
+ cells = []
562
+ for c, col in enumerate(columns):
563
+ ref = f"{_column_letter(c)}{r}"
564
+ value = _doc_field(seed, name, col, i)
565
+ if col["kind"] == "int":
566
+ cells.append(f'<c r="{ref}"><v>{value}</v></c>')
567
+ else:
568
+ # inlineStr, not a shared-string table: a shared table would make every cell's
569
+ # bytes depend on a global dedup ORDER, so byte-determinism would rest on the
570
+ # stability of that ordering as well as on the seed. Inline keeps a row's bytes
571
+ # a function of that row. `docread` reads both spellings (its trap 1 and 2).
572
+ cells.append(
573
+ f'<c r="{ref}" t="inlineStr"><is><t>{_xml_text(value)}</t></is></c>'
574
+ )
575
+ out.append(f'<row r="{r}">{"".join(cells)}</row>')
576
+ return "".join(out)
577
+
578
+
579
+ def _xlsx_members(entry: dict) -> list[tuple[str, str]]:
580
+ seed = int(entry["seed"])
581
+ members = [("[Content_Types].xml", _CONTENT_TYPES), ("_rels/.rels", _ROOT_RELS_XLSX)]
582
+ decls, rels = [], []
583
+ for i, sheet in enumerate(entry["sheets"]):
584
+ rid = f"rId{i + 1}"
585
+ decls.append(f'<sheet name="{_xml_text(sheet["name"])}" sheetId="{i + 1}" r:id="{rid}"/>')
586
+ rels.append(
587
+ f'<Relationship Id="{rid}" Type="http://schemas.openxmlformats.org/'
588
+ f'officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i + 1}.xml"/>'
589
+ )
590
+ body = _sheet_xml(seed, sheet["name"], sheet["columns"], int(sheet["rows"]))
591
+ members.append(
592
+ (
593
+ f"xl/worksheets/sheet{i + 1}.xml",
594
+ f"<worksheet {_SHEET_NS}><sheetData>{body}</sheetData></worksheet>",
595
+ )
596
+ )
597
+ members.append(
598
+ (
599
+ "xl/workbook.xml",
600
+ f"<workbook {_SHEET_NS} {_REL_NS}><sheets>{''.join(decls)}</sheets></workbook>",
601
+ )
602
+ )
603
+ members.append(
604
+ (
605
+ "xl/_rels/workbook.xml.rels",
606
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/'
607
+ f"relationships\">{''.join(rels)}</Relationships>",
608
+ )
609
+ )
610
+ return members
611
+
612
+
613
+ def _docx_members(entry: dict) -> list[tuple[str, str]]:
614
+ """One paragraph per row, fields joined by ", ".
615
+
616
+ `docread` renders a `.docx` paragraph as a single field, so a `.docx` answer address can
617
+ only be column A and resolves to the whole line. That is stated rather than worked around:
618
+ a Word document has no columns and pretending otherwise would invent structure the reader
619
+ cannot see.
620
+ """
621
+ seed = int(entry["seed"])
622
+ columns = entry["columns"]
623
+ lines = [", ".join(str(col["name"]) for col in columns)]
624
+ for i in range(1, int(entry["rows"]) + 1):
625
+ lines.append(", ".join(_doc_field(seed, "document", col, i) for col in columns))
626
+ body = "".join(f"<w:p><w:r><w:t>{_xml_text(line)}</w:t></w:r></w:p>" for line in lines)
627
+ return [
628
+ ("[Content_Types].xml", _CONTENT_TYPES),
629
+ ("_rels/.rels", _ROOT_RELS_DOCX),
630
+ ("word/document.xml", f"<w:document {_WORD_NS}><w:body>{body}</w:body></w:document>"),
631
+ ]
632
+
633
+
634
+ def build_document(entry: dict) -> bytes:
635
+ """The file's bytes, as a pure function of the (already validated) declaration."""
636
+ members = (
637
+ _xlsx_members(entry) if str(entry["path"]).endswith(".xlsx") else _docx_members(entry)
638
+ )
639
+ buf = io.BytesIO()
640
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as archive:
641
+ for name, payload in members:
642
+ info = zipfile.ZipInfo(name, date_time=_ZIP_EPOCH)
643
+ info.compress_type = zipfile.ZIP_STORED
644
+ info.create_system = 0
645
+ info.external_attr = 0
646
+ archive.writestr(info, payload)
647
+ return buf.getvalue()
648
+
649
+
650
+ def _fail(entry: object, message: str) -> None:
651
+ where = entry.get("path", "<no path>") if isinstance(entry, dict) else entry
652
+ raise DocumentSetupError(f"document_setup entry {where!r}: {message}")
653
+
654
+
655
+ def _check_column(entry: dict, where: str, column: object) -> None:
656
+ if not isinstance(column, dict):
657
+ _fail(entry, f"{where} column must be a mapping, got {type(column).__name__}")
658
+ unknown = sorted(set(column) - _COLUMN_KEYS)
659
+ if unknown:
660
+ _fail(entry, f"{where} column has unknown keys {unknown}; known: {sorted(_COLUMN_KEYS)}")
661
+ if not isinstance(column.get("name"), str) or not column["name"]:
662
+ _fail(entry, f"{where} column needs a non-empty string 'name'")
663
+ kind = column.get("kind")
664
+ if kind not in DOCUMENT_COLUMN_KINDS:
665
+ _fail(entry, f"{where} column {column['name']!r} has kind {kind!r}, "
666
+ f"supported are {', '.join(DOCUMENT_COLUMN_KINDS)}")
667
+ if kind == "choice":
668
+ values = column.get("values")
669
+ if not isinstance(values, list) or not values:
670
+ _fail(
671
+ entry,
672
+ f"{where} column {column['name']!r} kind 'choice' needs a non-empty 'values'",
673
+ )
674
+ if kind == "int":
675
+ try:
676
+ low, high = int(column["low"]), int(column["high"])
677
+ except (KeyError, TypeError, ValueError):
678
+ _fail(entry, f"{where} column {column['name']!r} kind 'int' needs integer low/high")
679
+ if low > high:
680
+ _fail(entry, f"{where} column {column['name']!r} has low {low} > high {high}")
681
+
682
+
683
+ def _check_rows_and_columns(entry: dict, where: str, holder: dict) -> None:
684
+ rows = holder.get("rows")
685
+ if not isinstance(rows, int) or isinstance(rows, bool) or rows < 1:
686
+ _fail(entry, f"{where} needs an integer 'rows' >= 1, got {rows!r}")
687
+ columns = holder.get("columns")
688
+ if not isinstance(columns, list) or not columns:
689
+ _fail(entry, f"{where} needs a non-empty 'columns' list")
690
+ for column in columns:
691
+ _check_column(entry, where, column)
692
+
693
+
694
+ def validate_document_entry(entry: object) -> None:
695
+ """Reject a declaration the generator cannot build, naming the key that is wrong.
696
+
697
+ Unknown keys are an error rather than an ignored extra. A silently ignored `row:` for
698
+ `rows:` builds a document of the wrong size and the run still scores, which is the exact
699
+ failure mode this program has already been bitten by: a measurement that skipped what it
700
+ was measuring and exited 0.
701
+ """
702
+ if not isinstance(entry, dict):
703
+ _fail(entry, f"must be a mapping, got {type(entry).__name__}")
704
+ unknown = sorted(set(entry) - _ENTRY_KEYS)
705
+ if unknown:
706
+ _fail(entry, f"unknown keys {unknown}; known: {sorted(_ENTRY_KEYS)}")
707
+ path = entry.get("path")
708
+ if not isinstance(path, str) or not path:
709
+ _fail(entry, "needs a non-empty string 'path'")
710
+ pure = PurePosixPath(path)
711
+ if pure.is_absolute() or ".." in pure.parts:
712
+ _fail(entry, "'path' must be relative and must not escape the workdir")
713
+ suffix = pure.suffix.lower().lstrip(".")
714
+ if suffix not in DOCUMENT_SUFFIXES:
715
+ _fail(entry, f"suffix {suffix or 'none'!r} is not readable; "
716
+ f"supported are {', '.join(DOCUMENT_SUFFIXES)}")
717
+ seed = entry.get("seed")
718
+ if not isinstance(seed, int) or isinstance(seed, bool):
719
+ _fail(entry, f"needs an integer 'seed', got {seed!r}")
720
+ if suffix == "xlsx":
721
+ if "rows" in entry or "columns" in entry:
722
+ _fail(entry, "an .xlsx declares 'sheets:', not top-level 'rows'/'columns'")
723
+ sheets = entry.get("sheets")
724
+ if not isinstance(sheets, list) or not sheets:
725
+ _fail(entry, "needs a non-empty 'sheets' list")
726
+ seen = set()
727
+ for sheet in sheets:
728
+ if not isinstance(sheet, dict):
729
+ _fail(entry, f"sheet must be a mapping, got {type(sheet).__name__}")
730
+ extra = sorted(set(sheet) - _SHEET_KEYS)
731
+ if extra:
732
+ _fail(entry, f"sheet has unknown keys {extra}; known: {sorted(_SHEET_KEYS)}")
733
+ name = sheet.get("name")
734
+ if not isinstance(name, str) or not name:
735
+ _fail(entry, "every sheet needs a non-empty string 'name'")
736
+ if name in seen:
737
+ _fail(entry, f"duplicate sheet name {name!r}; `Document.part` resolves by name")
738
+ seen.add(name)
739
+ _check_rows_and_columns(entry, f"sheet {name!r}", sheet)
740
+ else:
741
+ if "sheets" in entry:
742
+ _fail(entry, "a .docx declares top-level 'rows'/'columns', not 'sheets:'")
743
+ _check_rows_and_columns(entry, "document", entry)
744
+ answers = entry.get("answers") or {}
745
+ if not isinstance(answers, dict):
746
+ _fail(entry, f"'answers' must be a mapping of label -> 'part!CELL', got {answers!r}")
747
+ for label, address in answers.items():
748
+ if not isinstance(address, str) or "!" not in address:
749
+ _fail(entry, f"answer {label!r} must be 'part!CELL', got {address!r}")
750
+
751
+
752
+ def _resolve_answer(doc: Document, entry: dict, label: str, address: str) -> str:
753
+ """Read the answer OUT OF the document that was just built, never off the declaration.
754
+
755
+ This is what stops the expected value and the corpus drifting apart: they cannot disagree,
756
+ because one is a slice of the other. It also means an answer address that points past the
757
+ end of the sheet is a setup failure rather than a silent empty string.
758
+ """
759
+ part_key, _, cell = address.partition("!")
760
+ letters = "".join(c for c in cell if c.isalpha())
761
+ digits = "".join(c for c in cell if c.isdigit())
762
+ if not letters or not digits:
763
+ _fail(entry, f"answer {label!r} address {address!r} is not a cell reference")
764
+ try:
765
+ part = doc.part(part_key)
766
+ except DocumentReadError as exc:
767
+ _fail(entry, f"answer {label!r}: {exc}")
768
+ index = int(digits) - 1
769
+ if not 0 <= index < part.row_count:
770
+ _fail(entry, f"answer {label!r} names row {digits} but part {part.name!r} "
771
+ f"renders {part.row_count} rows")
772
+ column = 0
773
+ for char in letters:
774
+ column = column * 26 + (ord(char.upper()) - 64)
775
+ fields = part.rows[index].split("\t")
776
+ if not 0 < column <= len(fields):
777
+ _fail(entry, f"answer {label!r} names column {letters} but row {digits} of "
778
+ f"{part.name!r} has {len(fields)} fields")
779
+ return fields[column - 1]
780
+
781
+
782
+ def document_dir(workdir: Path, task: dict, config: str) -> Path:
783
+ """Per-task, per-config, under the run's `workdir` — the `memory_setup:` convention.
784
+
785
+ Per config and not shared, for the same reason the memory store is per config: an arm that
786
+ let the agent write to the document would otherwise hand the next arm a different corpus,
787
+ and the comparison would stop being between configs. Nothing is deleted afterwards; the
788
+ caller owns `workdir` (`run_suite` mkdtemps one), and a failed run whose document was
789
+ cleaned up cannot be diagnosed.
790
+ """
791
+ return workdir / f"{task['name']}-{config}-docs"
792
+
793
+
794
+ def materialise_documents(task: dict, workdir: Path, config: str) -> list[DocumentFixture]:
795
+ """Validate, build and write every `document_setup:` entry. Raises before the agent runs."""
796
+ entries = task.get("document_setup") or []
797
+ if not isinstance(entries, list):
798
+ raise DocumentSetupError(
799
+ f"task {task.get('name')!r}: document_setup must be a list, "
800
+ f"got {type(entries).__name__}"
801
+ )
802
+ target = document_dir(workdir, task, config)
803
+ fixtures = []
804
+ for entry in entries:
805
+ validate_document_entry(entry)
806
+ path = target / entry["path"]
807
+ path.parent.mkdir(parents=True, exist_ok=True)
808
+ payload = build_document(entry)
809
+ path.write_bytes(payload)
810
+ doc = extract(path)
811
+ fixtures.append(
812
+ DocumentFixture(
813
+ name=entry["path"],
814
+ path=path,
815
+ file_bytes=len(payload),
816
+ sha256=hashlib.sha256(payload).hexdigest(),
817
+ text_bytes=doc.text_bytes,
818
+ row_counts=tuple(p.row_count for p in doc.parts),
819
+ answers={
820
+ label: _resolve_answer(doc, entry, label, address)
821
+ for label, address in (entry.get("answers") or {}).items()
822
+ },
823
+ )
824
+ )
825
+ return fixtures
826
+
827
+
828
+ # ---- the scored answer and the corpus it came from -------------------------------------
829
+ #
830
+ # `materialise_documents` above resolves every `answers:` address OUT OF the file it has just
831
+ # written, which is what makes an answer and its corpus incapable of disagreeing. Nothing
832
+ # consumed that. `run_task` touched `document_fixtures` only to decide whether to register the
833
+ # reader pair or the paste, and `score_output` reads `task["scoring"]["expected"]` — a literal
834
+ # typed into the YAML by hand. The drift `document_setup:` was built to make impossible was
835
+ # therefore reintroduced one layer up, and the nine committed `document-read` tasks were safe
836
+ # only because a checker in the test suite rebuilds them (`tests/test_document_tasks.py`); the
837
+ # tenth task anybody writes was not. Filed as §11.4 of the document-read bar and confirmed
838
+ # again after it; this is the harness half neither filing was allowed to touch.
839
+ #
840
+ # The property, and the whole of it:
841
+ #
842
+ # A suite must not score an answer that no one checked against the corpus it came from.
843
+ #
844
+ # Enforced BEFORE the agent runs, at the same point and for the same reason a malformed
845
+ # declaration is: a run that starts against a corpus whose answer is wrong has already spent
846
+ # the expensive part, and it would then report a defect in the task file as a `wrong-answer`
847
+ # row about the model.
848
+ #
849
+ # THE TWO SHAPES, AND THE BRIDGE. `answers:` is `label -> "part!CELL"` and resolves to STRINGS
850
+ # sliced out of a rendered row. `scoring.expected` is a scoring payload whose TYPE is chosen by
851
+ # `scoring.kind`. They are not the same type, this check does not pretend they are, and it
852
+ # bridges them per kind — refusing outright for any kind it has no bridge for:
853
+ #
854
+ # json_equal a mapping. KEYED bridge: a label spelled `expected_<key>` claims the payload
855
+ # key `<key>`. Every key the payload scores must carry such a label, and the two
856
+ # must be equal AS STRINGS — `7726` the int scored against `"7726"` the cell text
857
+ # is one claim, and the cell is text because a rendered row is.
858
+ # contains a list of terms and NO keys at all, so key-matching is not available and
859
+ # pretending otherwise would be the fiction. UNKEYED bridge: every term the
860
+ # payload demands must equal some `expected_*` value this task's corpora
861
+ # resolved. Membership, stated as membership, and exact — `contains_term`
862
+ # matches case-insensitively at scoring time, but a task file whose literal is
863
+ # spelled differently from the cell is a task file that misreports the corpus to
864
+ # its reviewer, so it is refused rather than quietly accepted.
865
+ # tool_trace a list of TOOL NAMES. It scores the transcript, not an answer, so no cell of a
866
+ # spreadsheet could ever be one of its terms and the property has no subject
867
+ # here. Named explicitly rather than reached by falling off the end of a branch,
868
+ # so a kind nobody anticipated REFUSES instead of passing. A task that declares
869
+ # `expected_*` labels next to it is still refused: those answers would be scored
870
+ # by nothing, which is the hole under a different spelling.
871
+ #
872
+ # The direction is one-way and that is deliberate: every SCORED literal must come from the
873
+ # corpus; not every corpus answer must be scored. The property's subject is the scored answer.
874
+ # A task may address a cell for the prompt's sake (`question_sku:` in all nine committed tasks)
875
+ # or ship a second corpus whose answer is not the one under test, and neither is drift.
876
+
877
+ ANSWER_CLAIM_PREFIX = "expected_"
878
+
879
+ # Which bridge a scoring kind gets. Membership in one of these three is what decides whether
880
+ # the comparison can be made at all; anything outside them is an unknown kind and refuses.
881
+ KEYED_SCORING_KINDS = ("json_equal",)
882
+ UNKEYED_SCORING_KINDS = ("contains",)
883
+ TRACE_SCORING_KINDS = ("tool_trace",)
884
+
885
+
886
+ class UncheckedAnswerError(EvalConfigError):
887
+ """A task scores an expected answer that its own corpus was never asked about.
888
+
889
+ An `EvalConfigError` like `DocumentSetupError`, raised at the same place — before the agent
890
+ is built — and left to ESCAPE `run_task` for the same reason: `main()` prints its report and
891
+ returns whatever the outcomes were, so no outcome class moves the process exit status and a
892
+ `config-error` row would be a suite that measured nothing and still exited 0.
893
+ """
894
+
895
+
896
+ def _answer_claims(task: dict, fixtures: list[DocumentFixture]) -> dict[str, dict[str, str]]:
897
+ """`<key> -> {resolved value -> where it was read}`, over every entry of the task.
898
+
899
+ Only labels spelled `expected_<key>` are claims about `scoring.expected`. A label without
900
+ the prefix addresses a cell for some other purpose — the nine committed tasks use
901
+ `question_sku:` to put the lookup key into the prompt — and asserts nothing about the
902
+ scoring payload, so it is not compared. That silence is bounded and visible: the prefix is
903
+ right there in the task file, and a label that means to be checked is one rename away.
904
+
905
+ Values are collected per key rather than overwritten, so a task carrying two corpora that
906
+ both answer `expected_units` is DETECTABLE as ambiguous instead of resolving to whichever
907
+ entry happened to be last.
908
+ """
909
+ entries = [e for e in (task.get("document_setup") or []) if isinstance(e, dict)]
910
+ claims: dict[str, dict[str, str]] = {}
911
+ for entry, fixture in zip(entries, fixtures, strict=False):
912
+ declared = entry.get("answers") or {}
913
+ for label, value in fixture.answers.items():
914
+ if not label.startswith(ANSWER_CLAIM_PREFIX):
915
+ continue
916
+ key = label[len(ANSWER_CLAIM_PREFIX) :]
917
+ where = f"{fixture.name} {declared.get(label, label)}"
918
+ claims.setdefault(key, {}).setdefault(value, where)
919
+ return claims
920
+
921
+
922
+ def _check_keyed_expected(name: object, expected: object, claims: dict[str, dict[str, str]]):
923
+ """`json_equal`: every scored key names a cell, and the cell holds what the key scores."""
924
+ if not isinstance(expected, dict) or not expected:
925
+ raise UncheckedAnswerError(
926
+ f"task {name!r}: json_equal scoring takes a non-empty mapping, got "
927
+ f"{expected!r}; there is nothing for a corpus answer to be keyed against"
928
+ )
929
+ for key in expected:
930
+ found = claims.get(str(key))
931
+ if not found:
932
+ raise UncheckedAnswerError(
933
+ f"task {name!r}: scoring.expected[{key!r}] is scored, but no "
934
+ f"{ANSWER_CLAIM_PREFIX}{key} address reads it out of the corpus. The corpus "
935
+ f"answers this task declares are {sorted(claims)}."
936
+ )
937
+ if len(found) > 1:
938
+ raise UncheckedAnswerError(
939
+ f"task {name!r}: {ANSWER_CLAIM_PREFIX}{key} resolves to {sorted(found)} across "
940
+ f"{sorted(found.values())}; a keyed payload cannot say which corpus it meant"
941
+ )
942
+ value, where = next(iter(found.items()))
943
+ if str(expected[key]) != value:
944
+ raise UncheckedAnswerError(
945
+ f"task {name!r}: scoring.expected[{key!r}] is {expected[key]!r}, but the "
946
+ f"corpus holds {value!r} at {where}"
947
+ )
948
+
949
+
950
+ def _check_unkeyed_expected(name: object, expected: object, claims: dict[str, dict[str, str]]):
951
+ """`contains`: no keys exist, so the honest comparison is exact membership."""
952
+ if not isinstance(expected, list) or not expected:
953
+ raise UncheckedAnswerError(
954
+ f"task {name!r}: contains scoring takes a non-empty list, got {expected!r}; "
955
+ f"there is nothing for a corpus answer to be compared against"
956
+ )
957
+ resolved = {v: w for by_value in claims.values() for v, w in by_value.items()}
958
+ for term in expected:
959
+ if str(term) not in resolved:
960
+ raise UncheckedAnswerError(
961
+ f"task {name!r}: scoring.expected demands {str(term)!r}, which no "
962
+ f"{ANSWER_CLAIM_PREFIX}* address reads out of this task's corpus. It holds "
963
+ f"{sorted(resolved)} at {sorted(resolved.values())}. `contains` has no keys, "
964
+ f"so the comparison is membership and it is exact: spell the term the way the "
965
+ f"cell does."
966
+ )
967
+
968
+
969
+ def check_expected_against_corpus(task: dict, fixtures: list[DocumentFixture]) -> None:
970
+ """Refuse a document task whose `scoring.expected` was never read out of its own corpus.
971
+
972
+ A no-op for a task that materialised no document: it has no corpus, so the property has
973
+ nothing to say about it, and the twenty-two frozen suite tasks are untouched.
974
+ """
975
+ if not fixtures:
976
+ return
977
+ name = task.get("name")
978
+ scoring = task.get("scoring") or {}
979
+ kind = scoring.get("kind")
980
+ expected = scoring.get("expected")
981
+ claims = _answer_claims(task, fixtures)
982
+
983
+ if kind in TRACE_SCORING_KINDS:
984
+ if claims:
985
+ raise UncheckedAnswerError(
986
+ f"task {name!r}: scoring kind {kind!r} scores the tool trace and not an "
987
+ f"answer, so the corpus answer(s) {sorted(claims)} would be scored by nothing. "
988
+ f"Drop the {ANSWER_CLAIM_PREFIX!r} prefix from those labels, or score an answer."
989
+ )
990
+ return
991
+ if kind not in KEYED_SCORING_KINDS + UNKEYED_SCORING_KINDS:
992
+ raise UncheckedAnswerError(
993
+ f"task {name!r}: there is no bridge from a corpus answer to scoring kind {kind!r}, "
994
+ f"so this task's expected answer cannot be checked against the document it came "
995
+ f"from. Bridged kinds are "
996
+ f"{sorted(KEYED_SCORING_KINDS + UNKEYED_SCORING_KINDS + TRACE_SCORING_KINDS)}."
997
+ )
998
+ if not claims:
999
+ raise UncheckedAnswerError(
1000
+ f"task {name!r} declares document_setup: but no {ANSWER_CLAIM_PREFIX}<key> answer "
1001
+ f"address, so scoring.expected is a literal nothing checked against the corpus. "
1002
+ f"Add answers: {{{ANSWER_CLAIM_PREFIX}<key>: '<part>!<CELL>'}} naming the cell the "
1003
+ f"answer is read out of."
1004
+ )
1005
+ if kind in KEYED_SCORING_KINDS:
1006
+ _check_keyed_expected(name, expected, claims)
1007
+ else:
1008
+ _check_unkeyed_expected(name, expected, claims)
1009
+
1010
+
1011
+ # THE OTHER HALF OF THE SAME SENTENCE (RB-P90). `check_expected_against_corpus` above refuses a
1012
+ # task whose SCORED answer nobody read out of its own corpus. It says nothing about the half of
1013
+ # the task that does the ASKING, and by construction: `_answer_claims` skips every label without
1014
+ # the `expected_` prefix, and the nine committed tasks put the lookup key into the prompt through
1015
+ # `question_sku:`, which has none. So a task could ask about `SKU-999999`, score the row its
1016
+ # corpus actually holds, and report the model wrong — the closed drift, one field over, and again
1017
+ # attributed to the model.
1018
+ #
1019
+ # The property, both clauses:
1020
+ #
1021
+ # A cell a task resolves FOR THE PROMPT'S SAKE must be one the prompt actually names, and a
1022
+ # task that resolves a cell for NEITHER the prompt NOR the scoring must say so.
1023
+ #
1024
+ # The spelling is the label's, because that is where this file already keeps a label's role:
1025
+ # `expected_<key>` says "scored", so `unasked_<label>` says "asked by nothing", and the two
1026
+ # prefixes are read at the same place by the same rule. Every other label is a question address
1027
+ # and its resolved value must occur in `task["prompt"]`.
1028
+ #
1029
+ # The declaration is CHECKED, not merely honoured: `unasked_` on a cell the prompt does name is
1030
+ # refused too. Without that, renaming `question_sku:` to `unasked_question_sku:` across the nine
1031
+ # committed tasks would silence this check on all of them and nothing would say a word — an
1032
+ # escape hatch nobody can falsify is the hole with a longer name.
1033
+
1034
+ QUESTION_EXEMPT_PREFIX = "unasked_"
1035
+
1036
+
1037
+ class UnaskedAnswerError(EvalConfigError):
1038
+ """A task resolves a corpus cell for the prompt's sake that its own prompt never names.
1039
+
1040
+ Sibling to `UncheckedAnswerError`, not a subclass: they are two different sentences about
1041
+ one task file, and a node that means to catch the question half must not go green on the
1042
+ scored half's refusal. Raised at the same place — before the agent is built — and left to
1043
+ ESCAPE `run_task` for the same reason: a `config-error` row is a suite that measured
1044
+ nothing and still exited 0.
1045
+ """
1046
+
1047
+
1048
+ def check_question_against_prompt(task: dict, fixtures: list[DocumentFixture]) -> None:
1049
+ """Refuse a document task whose question addresses a row its prompt does not name.
1050
+
1051
+ A no-op for a task that materialised no document, for the same reason the scored half is:
1052
+ no corpus, no cell, no subject. `expected_*` labels are not subjects either — they are the
1053
+ scored half's, and a scored answer has no business being spelled out in the prompt.
1054
+ """
1055
+ if not fixtures:
1056
+ return
1057
+ name = task.get("name")
1058
+ prompt = task.get("prompt")
1059
+ prompt = prompt if isinstance(prompt, str) else ""
1060
+ entries = [e for e in (task.get("document_setup") or []) if isinstance(e, dict)]
1061
+ # Collected across every entry, because the prompt is one: an address declared on the
1062
+ # second corpus answers a row identity the prompt names just as well as one on the first.
1063
+ asked: dict[str, str] = {}
1064
+ exempt: dict[str, str] = {}
1065
+ labels: list[str] = []
1066
+ identities: list[_RowIdentity] = []
1067
+ for entry, fixture in zip(entries, fixtures, strict=False):
1068
+ declared = entry.get("answers") or {}
1069
+ identities.extend(_row_identity_patterns(entry))
1070
+ for label, value in fixture.answers.items():
1071
+ labels.append(label)
1072
+ if label.startswith(ANSWER_CLAIM_PREFIX):
1073
+ continue
1074
+ where = f"{fixture.name} {declared.get(label, label)}"
1075
+ named = value in prompt
1076
+ if label.startswith(QUESTION_EXEMPT_PREFIX):
1077
+ exempt.setdefault(value, where)
1078
+ if named:
1079
+ raise UnaskedAnswerError(
1080
+ f"task {name!r}: answers[{label!r}] declares the cell at {where} to be "
1081
+ f"asked by nothing, but the prompt names the value {value!r} it "
1082
+ f"resolves to. Drop the {QUESTION_EXEMPT_PREFIX!r} prefix so the "
1083
+ f"address is checked against the prompt that uses it."
1084
+ )
1085
+ continue
1086
+ asked.setdefault(value, where)
1087
+ if not named:
1088
+ raise UnaskedAnswerError(
1089
+ f"task {name!r}: answers[{label!r}] resolves to {value!r} at {where}, which "
1090
+ f"this task's prompt does not name. The prompt asks about some other row, so "
1091
+ f"a run would score the model on a question its corpus was never asked. Fix "
1092
+ f"the prompt or the address, or rename the label "
1093
+ f"{QUESTION_EXEMPT_PREFIX}{label} if this cell is deliberately asked by "
1094
+ f"nothing."
1095
+ )
1096
+ _check_question_floor(name, prompt, labels, asked, exempt, identities)
1097
+
1098
+
1099
+ # THE FLOOR UNDER BOTH OF THOSE DIRECTIONS (RB-P91). Every clause above is a check on a DECLARED
1100
+ # question address: one refuses an address the prompt does not name, the other refuses an
1101
+ # `unasked_` declaration the prompt does name. NEITHER HAS A SUBJECT WHEN NO QUESTION ADDRESS
1102
+ # EXISTS. Omit `question_sku:`, or rename every question label to `unasked_`, and the loop above
1103
+ # runs to completion over nothing while the prompt asks for whatever it likes — RB-P90's own
1104
+ # shape surviving its closure one step out, measured ACCEPTED both ways before this landed.
1105
+ #
1106
+ # The property, and the whole of it:
1107
+ #
1108
+ # A task that materialises a corpus must SAY WHAT ITS PROMPT ASKS ABOUT, and "nothing" must
1109
+ # be a thing it can say and be HELD TO.
1110
+ #
1111
+ # Two clauses, because "say" and "held to" are two different failures, and one rule trying to be
1112
+ # both is a rule nobody can falsify:
1113
+ #
1114
+ # THE FLOOR A task with a corpus must carry at least one label that is either a question
1115
+ # address or an `unasked_` declaration. SILENCE IS NOT A DECLARATION. No new
1116
+ # field and no new vocabulary is invented for this: `unasked_<label>` already
1117
+ # means "this cell is asked by nothing" and is already refused in the other
1118
+ # direction, so the deliberate case — an aggregation, a `tool_trace` task, a
1119
+ # prompt that addresses no single row — stays one line away and stays
1120
+ # falsifiable. What is refused is the task that says NOTHING AT ALL, which is
1121
+ # the shape the two evasions above share.
1122
+ #
1123
+ # THE FALSIFIER Every ROW IDENTITY the prompt names must be the value of a question address.
1124
+ # This is what holds a declaration to account, and it is what makes the floor
1125
+ # more than a counting rule — counting rules are cheap to satisfy. Point one
1126
+ # question address at `stock!B138`, write `east` into a prompt that goes on to
1127
+ # ask about SKU-999999, and a floor that only counted labels passes it; this
1128
+ # clause does not, because the prompt still names a row identity no address
1129
+ # resolves to.
1130
+ #
1131
+ # The falsifier is the load-bearing half and it is stated ON THE PROMPT rather than on the label
1132
+ # set, because the defect is a property of the prompt: the task asks about a row, so it must say
1133
+ # which cell that row came from. Its two limits are on `_row_identity_patterns` rather than left
1134
+ # to be discovered.
1135
+
1136
+
1137
+ @dataclass(frozen=True)
1138
+ class _RowIdentity:
1139
+ """The shape a row identity takes in one corpus, and the range of them that corpus holds."""
1140
+
1141
+ where: str
1142
+ pattern: re.Pattern[str]
1143
+ prefix: str
1144
+ width: int
1145
+ rows: int
1146
+
1147
+ def spelling(self, index: int) -> str:
1148
+ return f"{self.prefix}{index:0{self.width}d}"
1149
+
1150
+ def holds(self, token: str) -> bool:
1151
+ return 1 <= int(token[len(self.prefix) :]) <= self.rows
1152
+
1153
+
1154
+ def _row_identity_patterns(entry: dict) -> list[_RowIdentity]:
1155
+ """Every `kind: key` column of one entry, as the token shape it can put in a prompt.
1156
+
1157
+ Not guessed and not a convention: `_doc_field` builds every key in the corpus out of the
1158
+ declared `prefix:` and `width:` and nothing else, so those same two give the shape a token
1159
+ in a prompt must have to be one of them. The trailing/leading digit guards keep `SKU-000137`
1160
+ out of `SKU-0001370`.
1161
+
1162
+ TWO EXPOSURES, recorded here rather than left to be discovered. (1) A corpus with NO `key`
1163
+ column has no row identity to match, so this clause has no subject there and the floor is
1164
+ the only guard left — a declaration held to by nothing. (2) A key column with no `prefix:`
1165
+ is bare digits, so any number of that width anywhere in the prompt reads as a row identity.
1166
+ Both err toward REFUSING: (2) costs a task file one extra address, and (1) is the residual.
1167
+ """
1168
+ holders = entry.get("sheets") or [entry]
1169
+ out: list[_RowIdentity] = []
1170
+ for holder in holders:
1171
+ if not isinstance(holder, dict):
1172
+ continue
1173
+ for column in holder.get("columns") or []:
1174
+ if not isinstance(column, dict) or column.get("kind") != "key":
1175
+ continue
1176
+ prefix, width = str(column.get("prefix", "")), int(column.get("width", 6))
1177
+ out.append(
1178
+ _RowIdentity(
1179
+ where=f"{entry.get('path')} {holder.get('name', 'document')}."
1180
+ f"{column.get('name')}",
1181
+ pattern=re.compile(rf"(?<!\d){re.escape(prefix)}\d{{{width}}}(?!\d)"),
1182
+ prefix=prefix,
1183
+ width=width,
1184
+ rows=int(holder.get("rows", 0)),
1185
+ )
1186
+ )
1187
+ return out
1188
+
1189
+
1190
+ def _check_question_floor(
1191
+ name: object,
1192
+ prompt: str,
1193
+ labels: list[str],
1194
+ asked: dict[str, str],
1195
+ exempt: dict[str, str],
1196
+ identities: list[_RowIdentity],
1197
+ ) -> None:
1198
+ """The two clauses of RB-P91, in the order a task file should read them."""
1199
+ if not asked and not exempt:
1200
+ raise UnaskedAnswerError(
1201
+ f"task {name!r} materialises a corpus but declares neither a question address nor "
1202
+ f"an {QUESTION_EXEMPT_PREFIX!r} label, so it says nothing about what its prompt "
1203
+ f"asks and its prompt may ask for any row at all. Its corpus labels are "
1204
+ f"{sorted(labels)}, which are the scored half's and say nothing about the question. "
1205
+ f"Name the cell the prompt asks about, or — if this prompt deliberately asks about "
1206
+ f"no single cell — say so with {QUESTION_EXEMPT_PREFIX}<label> on the cell it does "
1207
+ f"not ask about, which is then checked against the prompt in the other direction."
1208
+ )
1209
+ for identity in identities:
1210
+ for token in identity.pattern.findall(prompt):
1211
+ if token in asked:
1212
+ continue
1213
+ span = f"{identity.spelling(1)}..{identity.spelling(identity.rows)}"
1214
+ held = "holds" if identity.holds(token) else "does not hold"
1215
+ raise UnaskedAnswerError(
1216
+ f"task {name!r}: the prompt names the row identity {token!r}, which no question "
1217
+ f"address in this task resolves to, so the task does not say where that row "
1218
+ f"came from. The corpus column {identity.where} {held} it ({span}). Question "
1219
+ f"addresses in this task resolve to {sorted(asked)}"
1220
+ + (
1221
+ f"; {sorted(exempt)} are declared asked by nothing."
1222
+ if exempt
1223
+ else ", and no cell is declared asked by nothing."
1224
+ )
1225
+ + " Add answers: {question_<label>: '<part>!<CELL>'} for the row the prompt"
1226
+ " names, or fix the prompt."
1227
+ )
1228
+
1229
+
1230
+ # ---- the reader pair the model actually sees (`document_list`, `document_read`) ---------
1231
+ #
1232
+ # TWO polymorphic tools, not five typed ones. Both `.xlsx` and `.docx` reduce to the same
1233
+ # shape upstream — a `Document` of named `Part`s of rendered rows — so a `sheet_*` roster and
1234
+ # a `docx_*` roster would be two spellings of one mechanism, and every request would carry
1235
+ # both whichever kind the task actually holds. The roster is re-sent on every request, so
1236
+ # that duplication is not paid once; `test_document_tools.py` prices the pair against a
1237
+ # five-tool typed roster written out in full and asserts the gap in bytes.
1238
+ #
1239
+ # The pair is a DESCRIBER and a PAGER, and the split is what makes an over-window corpus
1240
+ # readable at all. `document_list` answers "what exists" once — part names, row counts, row
1241
+ # numbering, and the header / first / last row of each part. `document_read` answers "what is
1242
+ # at offset N". Neither searches: no argument names a value to look for. That is deliberate
1243
+ # and it is the boundary of what this job measures — a `find`-shaped argument is a FINDER
1244
+ # primitive, a different axis, and adding one would make the result unable to say whether the
1245
+ # reader bought anything. See `contract.document_manifest` for why the three sample rows are
1246
+ # the load-bearing part.
1247
+
1248
+ DOCUMENT_PAGE_ROW_LIMIT = DEFAULT_ROW_LIMIT
1249
+ DOCUMENT_PAGE_MAX_ROWS = PAGE_MAX_ROWS
1250
+ # Under `Agent.observation_budget` (4096 by default) with room for the header line, the
1251
+ # continuation line and the per-row number prefixes this module adds after `page()` has
1252
+ # sliced. The ceiling has to be the READER's, not the loop's: the loop cuts an over-budget
1253
+ # observation IN BAND and the model then reads a page that lies about where it stopped,
1254
+ # whereas `page()` stops on a row boundary and reports the shortfall out of band. The
1255
+ # three numbers are `docread`'s since job43, so that `bantamkit_read` pages with the same
1256
+ # ceilings without importing this module.
1257
+ DOCUMENT_PAGE_MAX_BYTES = PAGE_MAX_BYTES
1258
+
1259
+
1260
+ def _document_int(value: object, fallback: int) -> int:
1261
+ """A model's spelling of a number, bent to the argument the reader takes.
1262
+
1263
+ `Agent.coerce_arguments` already turns `"50"` into `50` for a declared integer; what it
1264
+ cannot do is decide what an omitted or null argument means. Anything unconvertible is
1265
+ passed through as-is so the reader's own validation is what speaks.
1266
+ """
1267
+ if value is None:
1268
+ return fallback
1269
+ try:
1270
+ return int(value)
1271
+ except (TypeError, ValueError):
1272
+ return value # type: ignore[return-value]
1273
+
1274
+
1275
+ def _document_tools(fixtures: list[DocumentFixture]) -> list[ToolDef]:
1276
+ """The pair, bound to the documents this run materialised.
1277
+
1278
+ Extraction happens once here rather than per call: `page()` slices an already-rendered
1279
+ `Document`, so a per-call `extract()` would re-parse 12,000 rows of XML on every read and
1280
+ price the tool by the corpus size. It re-extracts rather than reusing what
1281
+ `materialise_documents` parsed because `DocumentFixture` is a MEASUREMENT record — sizes
1282
+ and hashes of what was written — and hanging a live handle off it would make a row's
1283
+ provenance depend on whether a reader was attached.
1284
+ """
1285
+ docs: dict[str, Document] = {f.name: extract(f.path) for f in fixtures}
1286
+ default_document = next(iter(docs), "")
1287
+
1288
+ def list_documents() -> str:
1289
+ """What exists — and, since J25, what the rendering left out of it.
1290
+
1291
+ The omissions are passed straight through as primitives. Nothing is filtered here:
1292
+ deciding at the harness which counts are worth the model's attention would put back
1293
+ exactly the silence `docread` was changed to end, and it would do it in the layer
1294
+ least able to know. `docread` counts, `contract` words it, this only carries it.
1295
+ """
1296
+ return document_manifest(
1297
+ [
1298
+ {
1299
+ "document": name,
1300
+ "kind": doc.kind,
1301
+ "index": part.index,
1302
+ "part": part.name,
1303
+ "row_count": part.row_count,
1304
+ "rows": part.rows,
1305
+ "omissions": [o.as_dict() for o in part.omissions],
1306
+ }
1307
+ for name, doc in docs.items()
1308
+ for part in doc.parts
1309
+ ],
1310
+ [
1311
+ {"document": name, "omissions": [o.as_dict() for o in doc.omissions]}
1312
+ for name, doc in docs.items()
1313
+ if doc.omissions
1314
+ ],
1315
+ )
1316
+
1317
+ def read_document(
1318
+ document: str | None = None,
1319
+ part: str | None = None,
1320
+ offset: object = 0,
1321
+ limit: object = DOCUMENT_PAGE_ROW_LIMIT,
1322
+ ) -> str:
1323
+ name = document or default_document
1324
+ doc = docs.get(name)
1325
+ if doc is None:
1326
+ return document_unknown(name, sorted(docs))
1327
+ key: str | int = part if part else 0
1328
+ try:
1329
+ target = doc.part(key)
1330
+ except DocumentReadError as e:
1331
+ return document_error(e) # names every part it does have
1332
+ start = _document_int(offset, 0)
1333
+ rows = _document_int(limit, DOCUMENT_PAGE_ROW_LIMIT)
1334
+ if isinstance(start, int) and start >= target.row_count:
1335
+ # `page()` would return an empty page with `next_offset=None`, which reads as
1336
+ # "the part ended here" — a dead end the model cannot tell from a real one.
1337
+ return document_offset_past_end(target.name, start, target.row_count)
1338
+ if isinstance(rows, int):
1339
+ rows = min(rows, DOCUMENT_PAGE_MAX_ROWS)
1340
+ try:
1341
+ got = page(doc, key, start, rows, DOCUMENT_PAGE_MAX_BYTES)
1342
+ except (DocumentReadError, TypeError) as e:
1343
+ return document_error(e)
1344
+ return document_page(
1345
+ document=name,
1346
+ part=got.part,
1347
+ offset=got.offset,
1348
+ rows=list(got.rows),
1349
+ row_count=got.total_rows,
1350
+ next_offset=got.next_offset,
1351
+ truncated_bytes=got.truncated_bytes,
1352
+ )
1353
+
1354
+ return [
1355
+ ToolDef(tool=load_tool("document_list"), handler=list_documents),
1356
+ ToolDef(tool=load_tool("document_read"), handler=read_document),
1357
+ ]
1358
+
1359
+
1360
+ def _paste_head(fixtures: list[DocumentFixture]) -> str:
1361
+ """The `paste` arm's system message: the head of the corpus, cut on a row boundary.
1362
+
1363
+ The bar's §10.2, clause by clause. (1) The fixtures are the ones `run_task` materialised
1364
+ unconditionally, so this arm reads the same bytes every other arm does. (2) Each part is
1365
+ re-extracted and rendered as `docread` renders it — `Part.rows` is already header-then-rows
1366
+ and nothing here reformats a row. (3) Whole rows are added in declaration order while the
1367
+ running total, counting each row PLUS its newline, stays <= PASTE_MAX_BYTES; a row that
1368
+ would cross the ceiling stops the fill rather than being cut, because half a row is a value
1369
+ the model can misread as a whole one. (4) `document_paste` states the part name, the total
1370
+ and the shown count. (5) The caller registers no tools on this arm.
1371
+
1372
+ The budget is one running total across every part in order, not a fresh ceiling per part,
1373
+ because PASTE_MAX_BYTES is a ceiling on what the SYSTEM PROMPT carries and a per-part
1374
+ ceiling would make a two-part task quietly pay twice. Every task in this bar's set declares
1375
+ one part, so the two readings agree on the committed corpus; the global one is the reading
1376
+ that stays honest if a later task does not. A part reached with the budget already spent is
1377
+ still announced with its true row count and zero rows shown — the model is told the part
1378
+ exists and is unreadable, which is the same honesty clause 4 asks for.
1379
+ """
1380
+ remaining = PASTE_MAX_BYTES
1381
+ entries = []
1382
+ for fixture in fixtures:
1383
+ doc = extract(fixture.path)
1384
+ for part in doc.parts:
1385
+ kept: list[str] = []
1386
+ for row in part.rows:
1387
+ cost = len(row.encode()) + 1
1388
+ if cost > remaining:
1389
+ break
1390
+ remaining -= cost
1391
+ kept.append(row)
1392
+ entries.append(
1393
+ {
1394
+ "document": fixture.name,
1395
+ "kind": doc.kind,
1396
+ "index": part.index,
1397
+ "part": part.name,
1398
+ "row_count": part.row_count,
1399
+ "rows": kept,
1400
+ }
1401
+ )
1402
+ return document_paste(entries)
1403
+
1404
+
1405
+ class SchemaGate:
1406
+ """Enforce a task schema on the agent's final answer, on structured()'s retry budget.
1407
+
1408
+ Mirrors structured(): a violation is fed back as a pointed revision message rather than
1409
+ scored as a loss, so `full` and `structured` get the same number of shots at schema
1410
+ compliance and the config comparison measures the components, not the retry budget.
1411
+ """
1412
+
1413
+ def __init__(self, schema: dict, max_attempts: int | None = None):
1414
+ self.schema = schema
1415
+ self.max_attempts = (
1416
+ max_attempts
1417
+ if max_attempts is not None
1418
+ else profile_default("schema_gate", "max_attempts")
1419
+ )
1420
+ self.retries_used = 0
1421
+ self._attempts = 0
1422
+
1423
+ def setup(self, agent: Agent) -> None:
1424
+ self.retries_used = 0
1425
+ agent.add_post_hook(self)
1426
+
1427
+ def __call__(self, task: str, output: str) -> str | None:
1428
+ error = schema_error(output, self.schema)
1429
+ if error is None:
1430
+ self._attempts = 0
1431
+ return None
1432
+ self._attempts += 1
1433
+ if self._attempts >= self.max_attempts:
1434
+ self._attempts = 0
1435
+ raise StructuredOutputError(
1436
+ f"no valid output after {self.max_attempts} attempts; last error: {error}"
1437
+ )
1438
+ self.retries_used += 1
1439
+ return schema_retry_feedback(error)
1440
+
1441
+
1442
+ OUTCOMES = [
1443
+ "pass",
1444
+ "wrong-answer",
1445
+ "malformed-output",
1446
+ "schema-exhausted",
1447
+ "critique-exhausted",
1448
+ "turns-exhausted",
1449
+ "budget-exhausted",
1450
+ "config-error",
1451
+ "transport-error",
1452
+ ]
1453
+
1454
+
1455
+ def classify_outcome(
1456
+ task: dict,
1457
+ passed: bool,
1458
+ output: str | None,
1459
+ error: BantamError | None,
1460
+ budget: TokenBudget | None = None,
1461
+ ) -> str:
1462
+ """One deterministic failure class per run (suite-hardening spec §3.2).
1463
+
1464
+ Splits "failed" into content-wrong vs format-broken vs gate-gave-up vs
1465
+ infrastructure — each has a different remedy.
1466
+ """
1467
+ if passed:
1468
+ return "pass"
1469
+ if isinstance(error, EvalConfigError):
1470
+ return "config-error"
1471
+ if isinstance(error, StructuredOutputError):
1472
+ return "schema-exhausted"
1473
+ if isinstance(error, CritiqueExhausted):
1474
+ return "critique-exhausted"
1475
+ if isinstance(error, MaxTurnsExceeded):
1476
+ # Agent behaviour, not infrastructure. Before the generic branch below, which
1477
+ # would otherwise file turn exhaustion under `transport-error` and point the
1478
+ # diagnosis at the server (P7 — all 10 of the 3b sweep's "transport errors").
1479
+ return "turns-exhausted"
1480
+ if error is not None:
1481
+ return "transport-error"
1482
+ if budget is not None and budget.exhausted:
1483
+ # Reached only on a failed run, because `passed` is decided above by scoring the
1484
+ # answer the ceiling left behind — a budget-truncated but correct answer counts
1485
+ # `pass` (the P7 lesson: nothing swallows a scorable answer). Below the raised
1486
+ # classes on purpose: an exception explains itself better than the ceiling does.
1487
+ return "budget-exhausted"
1488
+ if task["scoring"]["kind"] == "json_equal":
1489
+ try:
1490
+ extract_json(output or "")
1491
+ except ValueError:
1492
+ return "malformed-output"
1493
+ return "wrong-answer"
1494
+
1495
+
1496
+ def run_seed(model: str, task_name: str, repeat: int) -> int:
1497
+ """One deterministic sampling seed per (model, task, repeat).
1498
+
1499
+ SHA-256 rather than Python's `hash()`, which is salted per process — a seed that
1500
+ changes between sweeps records nothing. Truncated to 32 bits, which every server
1501
+ accepts.
1502
+
1503
+ **Config is deliberately excluded**: every config of a (task, repeat) shares one
1504
+ seed, so `bare` vs `graph` off-family is exact-equality-falsifiable again instead
1505
+ of paying a sampling-noise tax on the first call of each run (P9).
1506
+ """
1507
+ digest = hashlib.sha256(f"{model}\x1f{task_name}\x1f{repeat}".encode()).digest()
1508
+ return int.from_bytes(digest[:4], "big")
1509
+
1510
+
1511
+ def _message_dict(message: Message) -> dict:
1512
+ """Exactly the `Message` fields, JSON-ready — the transcript is a post-hoc read, not a wire."""
1513
+ return {
1514
+ "role": message.role,
1515
+ "content": message.content,
1516
+ "tool_calls": [
1517
+ {"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in message.tool_calls
1518
+ ],
1519
+ "tool_call_id": message.tool_call_id,
1520
+ }
1521
+
1522
+
1523
+ def _write_transcript(
1524
+ transcripts_dir: Path,
1525
+ result: TaskResult,
1526
+ repeat: int,
1527
+ output: str | None,
1528
+ messages: list[Message],
1529
+ ) -> None:
1530
+ """Dump one run's messages beside its scoring verdict.
1531
+
1532
+ One file per run, `<config>--<task>--r<repeat>.json`, written on pass and on
1533
+ failure alike (the `structured` path has no agent transcript, so `messages` is
1534
+ `[]` there — the file still appears). Any write failure warns and returns:
1535
+ measurement must never change what it measures.
1536
+ """
1537
+ path = transcripts_dir / f"{result.config}--{result.task}--r{repeat}.json"
1538
+ payload = {
1539
+ "task": result.task,
1540
+ "config": result.config,
1541
+ "repeat": repeat,
1542
+ "passed": result.passed,
1543
+ "outcome": result.outcome,
1544
+ "seed": result.seed,
1545
+ # RB-P38 again: the transcript is the other thing a run writes, and its filename
1546
+ # is `<config>--<task>--r<repeat>.json` with no model in it, so two models writing
1547
+ # into one --transcripts directory collide. Carrying the key makes the file it
1548
+ # wrote self-describing; the collision itself is reported, not papered over.
1549
+ "model": result.model,
1550
+ "output": output,
1551
+ "messages": [_message_dict(m) for m in messages],
1552
+ }
1553
+ try:
1554
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
1555
+ except (OSError, TypeError, ValueError) as e:
1556
+ print(f"warning: could not write transcript {path}: {e}", file=sys.stderr)
1557
+
1558
+
1559
+ def run_task(
1560
+ client: ModelClient,
1561
+ task: dict,
1562
+ config: str,
1563
+ workdir: Path,
1564
+ transcripts_dir: Path | None = None,
1565
+ repeat: int = 0,
1566
+ profile: dict | None = None,
1567
+ ) -> TaskResult:
1568
+ # Applied by duck typing, not by signature: a client that carries a `seed` attribute
1569
+ # (OpenAICompatible does) gets this run's pinned seed; anything else is left alone and
1570
+ # records `seed: None`, because a seed the client ignored would be provenance fiction.
1571
+ # chat() is untouched either way.
1572
+ applied_seed: int | None = None
1573
+ if hasattr(client, "seed"):
1574
+ applied_seed = run_seed(getattr(client, "model", ""), task["name"], repeat)
1575
+ client.seed = applied_seed
1576
+ # RB-P38. Read, never written: the harness already holds the client that will answer,
1577
+ # so no call site gains an argument. Read here rather than at the row so it is the
1578
+ # model in force when the run started, and a client mutated mid-run cannot relabel it.
1579
+ answering_model: str | None = getattr(client, "model", None)
1580
+
1581
+ def policy(section: str, key: str):
1582
+ """Explicit-wins profile threading (P6).
1583
+
1584
+ `None` keeps every component resolving the `default` profile itself, so a run
1585
+ without `--eval-profile` is byte-identical to the one before the flag existed.
1586
+ """
1587
+ return None if profile is None else profile[section][key]
1588
+
1589
+ # Calibration-only configs mirror a headline config exactly, plus one component.
1590
+ # Resolving the name here keeps every membership test below reading as it did;
1591
+ # `config` itself stays the label the TaskResult records.
1592
+ effective = effective_config(config)
1593
+
1594
+ # Before the agent exists, and unconditional on `config`: a document is the task's
1595
+ # corpus, not a component under test, so every arm reads the same bytes — which the
1596
+ # generator makes literally true. A malformed declaration raises here rather than
1597
+ # letting the run start with no document and record a `wrong-answer` row, which would
1598
+ # report a defect in the task file as a defect in the model.
1599
+ document_fixtures = materialise_documents(task, workdir, config)
1600
+ # And immediately: a corpus that was built is a corpus the task's expected answer can be
1601
+ # checked against, and this is the last moment before anything expensive happens. Placed
1602
+ # here rather than beside `score_output` because a run that scores at the end has already
1603
+ # paid for the model; and outside the `try` below, so it ESCAPES rather than becoming a
1604
+ # `config-error` row in a report that still exits 0.
1605
+ check_expected_against_corpus(task, document_fixtures)
1606
+ # And the same moment for the other half of that sentence: the answer the task SCORES is
1607
+ # checked above, the row the task ASKS ABOUT here. Second rather than first so an existing
1608
+ # refusal keeps its existing message when a task file is wrong in both halves at once.
1609
+ check_question_against_prompt(task, document_fixtures)
1610
+ tracking = TrackingClient(client)
1611
+ workspace_tools = _workspace_tools(task.get("workspace") or {})
1612
+ tools = [
1613
+ workspace_tools[name] if name in workspace_tools else BUILTIN_TOOLS[name]
1614
+ for name in task.get("tools", [])
1615
+ ]
1616
+ agent = Agent(
1617
+ client=tracking,
1618
+ tools=tools,
1619
+ max_turns=policy("agent", "max_turns"),
1620
+ observation_budget=policy("agent", "observation_budget"),
1621
+ )
1622
+
1623
+ schema_gate: SchemaGate | None = None
1624
+ critique_gate: CritiqueGate | None = None
1625
+ budget: TokenBudget | None = None
1626
+ if config in BUDGET_CONFIGS:
1627
+ # Before every gate: `CritiqueGate.setup` reads `agent.budget` and `agent.client`
1628
+ # once, so a budget attached after it would be a governor nothing ever asks —
1629
+ # and would leave the gate holding the unwrapped client, invisible again.
1630
+ budget = TokenBudget(
1631
+ ceiling=policy("token_budget", "ceiling"),
1632
+ optional_cutoff=policy("token_budget", "optional_cutoff"),
1633
+ )
1634
+ agent.use(budget)
1635
+ memory_attached = False
1636
+ if effective in ("memory", "lean", "full") and task.get("memory_setup"):
1637
+ store_dir = workdir / f"{task['name']}-{config}-mem"
1638
+ store = MemoryStore(store_dir)
1639
+ for fact in task["memory_setup"]:
1640
+ store.save(fact["type"], fact["name"], fact["description"], fact["body"])
1641
+ agent.use(Memory(store=store_dir))
1642
+ memory_attached = True
1643
+ # `memory_setup:` gates the memory tools; `document_setup:` gates these, for the same
1644
+ # reason — a task with no corpus handed a reader would put two tools on the wire that
1645
+ # can only answer "no documents are attached to this task". The config side follows the
1646
+ # memory recipe too: the component's own arm, plus the two composed configs. `bare` gets
1647
+ # nothing and stays the floor.
1648
+ if document_fixtures and (config in READER_CONFIGS or effective in ("lean", "full")):
1649
+ for tooldef in _document_tools(document_fixtures):
1650
+ agent.register_tool(tooldef)
1651
+ # The `paste` arm, gated on `document_setup:` for the same reason: a paste of no corpus is
1652
+ # a system message that says nothing. It registers NO tool — the branch above cannot fire
1653
+ # for it, because `paste` is not in READER_CONFIGS and its `effective` is `bare` — so the
1654
+ # only difference between a `bare` row and a `paste` row is these bytes in the system
1655
+ # prompt, which is exactly the comparison the bar's §5 asks for.
1656
+ if document_fixtures and config in PASTE_CONFIGS:
1657
+ agent.add_system(_paste_head(document_fixtures))
1658
+ if effective in ("lean", "full") and "schema" in task:
1659
+ # The agent owns the loop here, so it needs the same instruction structured() gives.
1660
+ # Gate registered before the critique gate: a malformed answer is fixed for free
1661
+ # rather than spending a critique call on it.
1662
+ agent.add_system(schema_instruction(task["schema"]))
1663
+ # Tier 1 on the loop's own call, the same tier `structured` has always had.
1664
+ # It belongs here and not inside `SchemaGate`: a gate only ever sees a violation
1665
+ # that already happened, so a gate-owned decision could never constrain the first
1666
+ # decode — and on 3b that first decode is where the run was lost (RP1 wire capture:
1667
+ # all three POSTs of a schema-exhausted cell carried only `{model, messages, seed}`).
1668
+ agent.response_format = response_format_for(task["schema"])
1669
+ schema_gate = SchemaGate(task["schema"], max_attempts=policy("schema_gate", "max_attempts"))
1670
+ agent.use(schema_gate)
1671
+ if effective in ("memory", "lean", "full") and task["scoring"]["kind"] == "json_equal":
1672
+ # Registered after the schema gate on purpose: where a task has a schema, the
1673
+ # schema-aware error is strictly more informative, so this is the fallback for
1674
+ # json_equal tasks that carry no schema (every memory-recall task). `bare` does
1675
+ # not get it — it stays the floor.
1676
+ agent.use(JsonAnswerGate(max_attempts=policy("json_answer", "max_attempts")))
1677
+ # The gates take no explicit client: `CritiqueGate.setup` inherits `agent.client`,
1678
+ # which is the tracking client — wrapped by the budget in the `budgeted` config,
1679
+ # bare tracking everywhere else (identical to the `client=tracking` they used to be
1680
+ # handed). Inheriting is what puts critic spend in front of the governor.
1681
+ # `deterministic_sampling=True` on both gates below is this harness affirming what
1682
+ # the library will not assume for a consumer: that the endpoint reproduces a
1683
+ # verdict's *decision* — its score — for a fixed request under the seed pinned
1684
+ # above. Narrowed to the score and no wider, because that is what is measured
1685
+ # (RB-P15): the verdict text moved at an identical request payload sha, the score
1686
+ # never did. It is the harness's claim to make and it costs nothing new — every bar
1687
+ # here is already stated as a seeded number, and a run against a backend that
1688
+ # resamples has an unreproducible bar with or without the critic memo. A consumer
1689
+ # whose backend batches gets the safe default instead (RP5b;
1690
+ # `CritiqueGate._verdict`).
1691
+ if effective == "critique":
1692
+ critique_gate = CritiqueGate(
1693
+ "task-completion",
1694
+ max_rounds=policy("critique", "max_rounds"),
1695
+ deterministic_sampling=True,
1696
+ )
1697
+ agent.use(critique_gate)
1698
+ # RB-P8. A `memory_setup` task keeps its answer in a store; a config that attaches
1699
+ # no store hands the agent a question whose only source it withheld. That is the
1700
+ # deliberate control arm — `bare`, `critique` and `grounded` all run those tasks
1701
+ # storeless, which is how the memory component's uplift gets measured. What is not
1702
+ # deliberate is then asking a critic that verifies answers *against sources* to
1703
+ # bless one, because there is no source for it to check and refusing is the only
1704
+ # honest verdict it can reach. On 4b `grounded` that spent ten `critique-exhausted`
1705
+ # runs, three rounds each, on answers no round could ever have fixed.
1706
+ #
1707
+ # Composition, not library: `GroundedCritiqueGate` is behaving correctly, and a
1708
+ # library-side degrade ("pass when the evidence set is empty") would make every
1709
+ # consumer's grounded gate defeatable by calling no tools — the one thing it is
1710
+ # attached to prevent. The defect is that this recipe pairs a source-checking
1711
+ # critic with a task whose source it removed, so the recipe is what changes.
1712
+ #
1713
+ # Scoped to the measured cell on purpose. Toolless `structured-extraction` tasks
1714
+ # also reach the critic with an empty evidence set, but their source is the task
1715
+ # prompt itself, they burn no exhausted runs, and there is no finding to act on —
1716
+ # so they keep the gate. `full` never trips this, because a `memory_setup` task
1717
+ # under `full` always gets its store.
1718
+ source_withheld = bool(task.get("memory_setup")) and not memory_attached
1719
+ if effective in ("grounded", "full") and not source_withheld:
1720
+ critique_gate = GroundedCritiqueGate(
1721
+ max_rounds=policy("critique", "max_rounds"),
1722
+ evidence_budget=policy("critique", "evidence_budget"),
1723
+ deterministic_sampling=True,
1724
+ )
1725
+ agent.use(critique_gate)
1726
+ graph: FileAccessGraph | None = None
1727
+ if effective in GRAPH_CONFIGS and any(n in WORKSPACE_TOOLS for n in task.get("tools", [])):
1728
+ # `effective`, not `config`: `graph-guarded` gets exactly the headline graph.
1729
+ # Held in a local now: `run_task` used to construct the graph inside the `use(...)`
1730
+ # call and drop the only reference to it, which is why the realised repeat-read
1731
+ # count could not reach a JSONL row (bar §7.2) and a field program had to
1732
+ # monkey-patch the constructor to see it at all.
1733
+ graph = FileAccessGraph(readers={"read_file": "path"}, **GRAPH_CONFIGS[effective])
1734
+ agent.use(graph)
1735
+ if config in GUARD_CONFIGS:
1736
+ # Attached LAST on purpose: LoopGuard wraps only the tools registered by the
1737
+ # time its setup runs, and last means all of them — the memory tools, the
1738
+ # graph-wrapped readers, and the file_graph query tool alike.
1739
+ agent.use(
1740
+ LoopGuard(
1741
+ inject_at=policy("loop_guard", "inject_at"),
1742
+ warn_at=policy("loop_guard", "warn_at"),
1743
+ )
1744
+ )
1745
+
1746
+ output: str | None = None
1747
+ messages: list[Message] = []
1748
+ caught: BantamError | None = None
1749
+ try:
1750
+ if "family" not in task:
1751
+ raise EvalConfigError(f"task '{task['name']}' is missing required key 'family'")
1752
+ if effective == "structured" and "schema" in task:
1753
+ # structured() drives its own loop, so no agent transcript exists to score against.
1754
+ if task["scoring"]["kind"] == "tool_trace":
1755
+ raise EvalConfigError(
1756
+ f"task '{task['name']}' uses schema + tool_trace scoring, which the "
1757
+ "structured config cannot score: it has no agent transcript"
1758
+ )
1759
+ data = structured(
1760
+ tracking,
1761
+ task["prompt"],
1762
+ task["schema"],
1763
+ max_retries=policy("structured", "max_retries"),
1764
+ )
1765
+ output = json.dumps(data)
1766
+ else:
1767
+ result = agent.run(task["prompt"])
1768
+ output, messages = result.output, result.messages
1769
+ passed = score_output(task, output, messages)
1770
+ except BantamError as e:
1771
+ passed, caught = False, e
1772
+
1773
+ if effective == "structured" and "schema" in task:
1774
+ # No gate object on this path; structured() makes exactly one call per attempt.
1775
+ schema_retries = max(0, tracking.calls - 1)
1776
+ else:
1777
+ schema_retries = schema_gate.retries_used if schema_gate else 0
1778
+ # An all-zero accounting stands in for "no graph was attached", which is what a
1779
+ # `bare`/`lean`/`full` row means. It is not a measured zero and the bar's §5 R3 test
1780
+ # is only readable on a row whose config is in GRAPH_CONFIGS.
1781
+ accounting = graph.accounting if graph is not None else ReadAccounting()
1782
+ result = TaskResult(
1783
+ task=task["name"],
1784
+ config=config,
1785
+ family=task.get("family", "unknown"),
1786
+ passed=passed,
1787
+ tokens=tracking.usage.total,
1788
+ outcome=classify_outcome(task, passed, output, caught, budget),
1789
+ model_calls=tracking.calls,
1790
+ # messages stays [] when agent.run raises, so tool_calls reads 0 on gate-exhausted runs.
1791
+ tool_calls=sum(len(m.tool_calls) for m in messages),
1792
+ schema_retries=schema_retries,
1793
+ critique_rounds=critique_gate.rounds_used if critique_gate else 0,
1794
+ error=f"{type(caught).__name__}: {caught}" if caught else None,
1795
+ seed=applied_seed,
1796
+ reader_calls=accounting.reader_calls,
1797
+ unrecorded_reader_calls=accounting.unrecorded_reader_calls,
1798
+ repeat_reader_calls=accounting.repeat_reader_calls,
1799
+ collapsed_calls=accounting.collapsed_calls,
1800
+ collapsed_bytes=accounting.collapsed_bytes,
1801
+ annotate_marker_bytes=accounting.annotate_marker_bytes,
1802
+ query_bytes=accounting.query_bytes,
1803
+ context_bytes_sent=tracking.context_bytes_sent,
1804
+ model=answering_model,
1805
+ repeat=repeat,
1806
+ )
1807
+ if transcripts_dir is not None:
1808
+ # The run most worth reading used to record nothing: `agent.run` raising left
1809
+ # `messages` empty, so every turns-exhausted transcript was `messages: []`.
1810
+ # TaskResult.tool_calls above still reads the loop's own list, so the JSONL
1811
+ # columns keep their documented semantics this cycle — only the dump improves.
1812
+ _write_transcript(
1813
+ transcripts_dir, result, repeat, output, messages or getattr(caught, "messages", [])
1814
+ )
1815
+ return result
1816
+
1817
+
1818
+ def run_suite(
1819
+ client: ModelClient,
1820
+ configs: list[str] | None = None,
1821
+ workdir: Path | None = None,
1822
+ tasks_dir: Path | None = None,
1823
+ repeats: int = 1,
1824
+ on_result: Callable[[TaskResult], None] | None = None,
1825
+ transcripts_dir: Path | None = None,
1826
+ profile: dict | None = None,
1827
+ ) -> list[TaskResult]:
1828
+ configs = configs or CONFIGS
1829
+ workdir = workdir or Path(tempfile.mkdtemp(prefix="bantamkit-eval-"))
1830
+ tasks = load_tasks(tasks_dir)
1831
+ results: list[TaskResult] = []
1832
+ for config in configs:
1833
+ for task in tasks:
1834
+ for i in range(repeats):
1835
+ # Fresh subdir per repeat: memory stores must not leak between repeats.
1836
+ result = run_task(
1837
+ client,
1838
+ task,
1839
+ config,
1840
+ workdir / f"repeat-{i}",
1841
+ transcripts_dir=transcripts_dir,
1842
+ repeat=i,
1843
+ profile=profile,
1844
+ )
1845
+ results.append(result)
1846
+ if on_result is not None:
1847
+ on_result(result)
1848
+ return results
1849
+
1850
+
1851
+ def _score_cell(rows: list[TaskResult]) -> str:
1852
+ return f"{sum(r.passed for r in rows)}/{len(rows)}"
1853
+
1854
+
1855
+ def format_report(results: list[TaskResult]) -> str:
1856
+ seen = {r.config for r in results}
1857
+ configs = [c for c in CONFIG_CHOICES if c in seen]
1858
+ configs += sorted(seen - set(CONFIG_CHOICES)) # never silently drop a result row
1859
+ lines = ["| config | score | tokens | score/1k tok |", "|---|---|---|---|"]
1860
+ for config in configs:
1861
+ rows = [r for r in results if r.config == config]
1862
+ passed, tokens = sum(r.passed for r in rows), sum(r.tokens for r in rows)
1863
+ per_1k = passed / (tokens / 1000) if tokens else 0.0
1864
+ lines.append(f"| {config} | {_score_cell(rows)} | {tokens} | {per_1k:.2f} |")
1865
+
1866
+ families = sorted({r.family for r in results})
1867
+ if len(families) > 1:
1868
+ lines += [
1869
+ "",
1870
+ "Per family (score · tokens):",
1871
+ "| config | " + " | ".join(families) + " |",
1872
+ "|---" * (len(families) + 1) + "|",
1873
+ ]
1874
+ for config in configs:
1875
+ cells = []
1876
+ for family in families:
1877
+ rows = [r for r in results if r.config == config and r.family == family]
1878
+ cells.append(f"{_score_cell(rows)} · {sum(r.tokens for r in rows)} tok")
1879
+ lines.append(f"| {config} | " + " | ".join(cells) + " |")
1880
+
1881
+ failed = [r for r in results if not r.passed]
1882
+ if failed:
1883
+ lines += ["", "Failure outcomes:"]
1884
+ for config in configs:
1885
+ counts = Counter(r.outcome for r in failed if r.config == config)
1886
+ if counts:
1887
+ summary = ", ".join(f"{o} ×{n}" for o, n in sorted(counts.items()))
1888
+ lines.append(f"- {config}: {summary}")
1889
+
1890
+ if len(configs) > 1:
1891
+ lines += _rescue_matrix(results, configs)
1892
+
1893
+ errors = [r for r in results if r.error]
1894
+ if errors:
1895
+ lines += ["", "Explicit failures:"]
1896
+ lines.extend(f"- {r.config}/{r.task}: {r.error}" for r in errors)
1897
+ return "\n".join(lines)
1898
+
1899
+
1900
+ def _rescue_matrix(results: list[TaskResult], configs: list[str]) -> list[str]:
1901
+ """Pass-fraction grid over tasks some run failed.
1902
+
1903
+ "Discriminating" = fully passed under at least one config AND fully failed
1904
+ under at least one — the tasks that actually separate configs. The count is
1905
+ the suite-quality headline the hardening cycle exists to move.
1906
+ """
1907
+ task_names = list(dict.fromkeys(r.task for r in results))
1908
+ grid: dict[str, dict[str, tuple[int, int]]] = {}
1909
+ for name in task_names:
1910
+ per_config = {}
1911
+ for config in configs:
1912
+ rows = [r for r in results if r.task == name and r.config == config]
1913
+ per_config[config] = (sum(r.passed for r in rows), len(rows))
1914
+ if any(p < n for p, n in per_config.values()):
1915
+ grid[name] = per_config
1916
+ if not grid:
1917
+ return ["", f"Discriminating tasks: 0/{len(task_names)}"]
1918
+ discriminating = sum(
1919
+ 1
1920
+ for per_config in grid.values()
1921
+ if any(n > 0 and p == n for p, n in per_config.values())
1922
+ and any(n > 0 and p == 0 for p, n in per_config.values())
1923
+ )
1924
+ lines = [
1925
+ "",
1926
+ f"Discriminating tasks: {discriminating}/{len(task_names)}",
1927
+ "| task | " + " | ".join(configs) + " |",
1928
+ "|---" * (len(configs) + 1) + "|",
1929
+ ]
1930
+ for name, per_config in grid.items():
1931
+ cells = [f"{p}/{n}" for p, n in (per_config[c] for c in configs)]
1932
+ lines.append(f"| {name} | " + " | ".join(cells) + " |")
1933
+ return lines
1934
+
1935
+
1936
+ def main(argv: list[str] | None = None) -> None:
1937
+ parser = argparse.ArgumentParser(description="Run the bantamkit eval suite.")
1938
+ parser.add_argument("--base-url", required=True)
1939
+ parser.add_argument("--model", required=True)
1940
+ parser.add_argument(
1941
+ "--config", action="append", choices=CONFIG_CHOICES, help="repeatable; default: all configs"
1942
+ )
1943
+ parser.add_argument(
1944
+ "--timeout", type=float, default=60.0, help="per-request timeout in seconds (default 60)"
1945
+ )
1946
+ parser.add_argument("--repeats", type=int, default=1, help="runs per (config, task); default 1")
1947
+ parser.add_argument(
1948
+ "--tasks", type=Path, help="load tasks from this directory instead of the builtin suite"
1949
+ )
1950
+ parser.add_argument(
1951
+ "--json", type=Path, help="append one JSON line per finished run to this file"
1952
+ )
1953
+ parser.add_argument(
1954
+ "--transcripts",
1955
+ type=Path,
1956
+ help="dump one JSON transcript per finished run into this directory",
1957
+ )
1958
+ parser.add_argument(
1959
+ "--eval-profile",
1960
+ help="run under this named profile asset instead of `default` (e.g. `patient`)",
1961
+ )
1962
+ args = parser.parse_args(argv)
1963
+ if args.repeats < 1:
1964
+ parser.error("--repeats must be >= 1")
1965
+ client = OpenAICompatible(base_url=args.base_url, model=args.model, timeout=args.timeout)
1966
+
1967
+ sink: Callable[[TaskResult], None] | None = None
1968
+ jsonl = None
1969
+ if args.json:
1970
+ jsonl = args.json.open("a", encoding="utf-8")
1971
+
1972
+ def sink(result: TaskResult) -> None:
1973
+ jsonl.write(json.dumps(asdict(result)) + "\n")
1974
+ jsonl.flush()
1975
+
1976
+ # Passed only when the flag is given, so callers (and fakes) that predate it keep working.
1977
+ suite_kwargs: dict = {}
1978
+ if args.transcripts:
1979
+ args.transcripts.mkdir(parents=True, exist_ok=True)
1980
+ suite_kwargs["transcripts_dir"] = args.transcripts
1981
+ if args.eval_profile:
1982
+ # Loaded (and validated) once here, then passed down as explicit constructor
1983
+ # args: profile *selection* is the harness's business, never global state that
1984
+ # the library reads. Core keeps resolving `default` for everyone else.
1985
+ suite_kwargs["profile"] = load_profile(args.eval_profile)
1986
+
1987
+ try:
1988
+ results = run_suite(
1989
+ client,
1990
+ configs=args.config,
1991
+ tasks_dir=args.tasks,
1992
+ repeats=args.repeats,
1993
+ on_result=sink,
1994
+ **suite_kwargs,
1995
+ )
1996
+ finally:
1997
+ if jsonl is not None:
1998
+ jsonl.close()
1999
+ print(format_report(results))
2000
+
2001
+
2002
+ if __name__ == "__main__":
2003
+ main()