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.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- 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("&", "&").replace("<", "<").replace(">", ">")
|
|
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()
|