bench: SF10 perf validation, Tier-B token harness, and the finalized token-efficiency doc scope - #291
Conversation
…y, checkpointing Running the perf harness at TPC-H SF10 surfaced three real gaps: - ServerSideRunner had no way to raise the query timeout past MCPg's interactive-workload default (30s), and heavy TPC-H queries at SF10 run close to that on modest hardware. Add a --timeout CLI flag, threaded into ServerSideRunner and into the harness's own MCPG_STATEMENT_TIMEOUT_MS (the separate Postgres-level GUC) so one flag controls both timeout mechanisms consistently. - The e2e paths call the real, unmodified server, whose 30s timeout has no override — a heavy query legitimately exceeding it should not discard every other path/query already measured. Catch e2e-prefixed path failures per (path, query), log + record them in run metadata, and continue; native/server_side failures still raise, since those would be a real regression rather than an expected product ceiling. - The run only wrote results at the very end, so an interruption lost everything measured so far. Checkpoint after every query (and after the concurrency sweep) to the same output path, with a metadata.complete flag distinguishing a finished run from a partial one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
…n1 CVEs asyncio.WindowsSelectorEventLoopPolicy is renamed to the private _WindowsSelectorEventLoopPolicy under Python 3.14 (per typeshed); a static attribute reference only resolves under one target Python version, so mypy --strict (pinned to python_version = "3.14") flagged it as missing on this Windows dev machine even though the code runs correctly on the installed 3.13 interpreter. Resolve it via getattr with a fallback to the private name instead, in both call sites that set the policy (__main__.py, http_runtime.py) — both need to set it globally since the actual asyncio.run() call happens inside a third-party library (FastMCP, uvicorn) they don't control directly. Also, while getting a clean local pre-commit run on Windows: - test_run_http_builds_app_and_serves_via_uvicorn's mock uvicorn.run had a strict (app, *, host, port) signature that broke once run_http legitimately started passing loop="none" on win32 — widen it to **kwargs, matching the sibling Windows-specific test already in this file. - test_config.py's subprocess-allowlist tests hardcoded Unix-style "/usr/bin" as an example absolute path; os.path.isabs requires a drive letter on Windows (ntpath), so it isn't one there. Use platform-appropriate absolute paths instead of assuming POSIX. - pyasn1 0.6.3 (pulled in transitively via the gcp extra's google-auth) carries 5 known CVEs; bump to the patched 0.6.4. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
Path.write_text/read_text default to the platform locale encoding, which is cp1252 on Windows — writing the SF10 dashboard crashed with UnicodeEncodeError on the "Δ" delta character the report renders. Encode explicitly as UTF-8 on both the read and write side. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
A real SF10 run — 60M lineitem rows, all 11 t_db_matches_native assertions passing, decomposition and concurrency-sweep numbers that match the committed SF1 run's shape. Added as a distinct section rather than replacing the SF1 result: this environment (a tuned Docker Desktop container on Windows, sharing host resources) is explicitly not the dedicated hardware the "reproduce it yourself" section describes for the eventual headline SF10 run, so the section states that plainly and every methodology deviation (raised timeouts, Postgres tuning, --e2e omitted after repeated unexplained process terminations) up front rather than presenting the absolute numbers as directly comparable. Raw run JSON and rendered dashboard committed alongside for reproducibility. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
Each Tier-B trial is a real, costed model conversation. The perf harness lost hours of measurement to unexplained process interruptions in this environment; a Tier-B interruption loses money instead, which is worse. Write the run to --output after every single trial (not just at the end), and on startup, load an existing incomplete checkpoint and skip any (task, arm, trial) already recorded there rather than paying for it again — but only when the checkpoint's model / trials-per-arm / max-turns / tool-set match this invocation exactly, so a differently-configured rerun can't silently mix incompatible results into one aggregate. Verified end-to-end against a real (cheap, Haiku, capped-turns) run: trimming a completed checkpoint to a partial one and rerunning with identical args skipped exactly the trials already present and made no new API calls for them; rerunning with a changed --max-turns correctly refused to resume and started clean instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
…calls claude-sonnet-5 (the default --model) rejects temperature as a deprecated parameter — HTTP 400 invalid_request_error, "\`temperature\` is deprecated for this model." — which made every trial in the first real Tier-B run fail immediately (0 tokens billed, since it errors before generating anything; confirmed via the checkpoint's per-trial error field). Drop the kwarg entirely rather than special-case it per model. Verified with a cheap 1-trial rerun against the real target model: all 6 (task, arm) trials now complete with real token counts and correct grading. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
…already promised The seed script's own comment says "order_items and reviews get proper FK indexes — contrast that makes the missing one on orders.customer_id a *finding*, not a theme," but the DDL only ever indexed reviews.product_id, leaving reviews.customer_id unindexed too. That's a second real unindexed FK the dataset never intended to plant, discovered when the Tier-B benchmark's missing_index task graded a model's confident, correctly-reasoned identification of reviews.customer_id as wrong (the grader only accepts orders + customer_id) — the model wasn't mistaken, the dataset just had an extra, unplanted gap. Add the index the comment already claimed existed; the intended orders.customer_id finding is untouched and still asserted by test_planted_findings_are_present_in_the_ddl. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
translate_nl_to_sql makes its own internal LLM call — a separate HTTP request to whichever provider is configured, independent of and invisible to whatever's driving MCPg over MCP. TranslationResult reported no token usage at all, so that call's real cost was a total blind spot to any external cost accounting, including an agent's own token budget and (what surfaced this) the Tier-B benchmark, where adding this tool without fixing that would have silently made MCPg look cheaper than it actually is. Add ProviderCompletion (text + tokens_in/tokens_out) as the Protocol's return type in place of a bare str; parse each vendor's usage block instead of discarding it (Anthropic: usage.input_tokens/output_tokens; OpenAI-compatible: usage.prompt_tokens/completion_tokens; Gemini: usageMetadata.promptTokenCount/candidatesTokenCount) — missing/ malformed usage defaults to 0 rather than raising, since it's a nice-to-have, not load-bearing for a translation succeeding. Thread tokens_in/tokens_out through all five TranslationResult construction sites (success, refused, preflight-rejected, no-execute, and exec-error branches all share the one provider call). Also wires prompt_tokens/completion_tokens into audit_nl2sql.py's record_nl2sql_event() — it already accepted these params and the audit table already had the columns, but the call site never had a token count to pass, so this was dead plumbing until now. Regenerated the tool-return-shapes contract snapshot (MCPG_REGENERATE_TOOL_RETURN_SHAPES=1) — a mechanical two-field diff. tool_surface.snapshot.json is unaffected (only pins name/description/ inputSchema). Updated the test_tool_output_schemas.py manifest to match (its test only checks a subset, so this wasn't required, just kept accurate). Added the first real HTTP-response-shape tests this module has ever had for the three provider classes (test_nl2sql.py's existing tests all bypass real HTTP/JSON parsing via a stub) — mocking pattern borrowed from test_oidc.py's fake-AsyncClient approach. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
…tical tasks
Two changes so Tier-B can fairly measure MCPg's actual NL->SQL
workflow rather than only the audit-lookup tools it tested before:
- Add translate_nl_to_sql to the MCPg arm's tool list. Without the
companion nl2sql.py fix (previous commit), this alone would have
biased the study toward MCPg — the tool's internal LLM call is
invisible to the outer agent loop this harness measures.
- TrialResult gains hidden_tokens_in/out (defaulting to 0, so old
checkpoint JSON without these keys still resumes fine via
TrialResult(**t)); total_tokens now sums all four fields.
run_trial's tool-call loop reads translate_nl_to_sql's reported
usage straight out of the MCP tool result's structuredContent and
folds it in — baseline arm never sees this tool, so its hidden
totals stay 0 and no asymmetry is introduced there.
Two new tasks, both graded against ground truth computed directly
against the live demo dataset (not asserted from memory): top revenue
category ("Home", a clear margin over the runner-up) and top
lifetime-spend customer ("Liam Okafor", >2x the runner-up). Unlike the
three existing planted-flaw audit tasks — simple lookups a model
rarely gets wrong — these need a real multi-table join + aggregation,
closer to the plain-English business questions an agent driving MCPg
actually gets asked, and more likely to provoke a wrong first SQL
attempt worth measuring the retry cost of.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
The default Tier-B "mcpg" arm never once chose to call translate_nl_to_sql on either claude-haiku-4-5 or claude-sonnet-5, even on the two tasks built for it — it always explored the schema and wrote its own SQL via run_select instead. That means the normal arm can't tell us whether MCPg's dedicated NL2SQL tool actually helps. Added a standalone, reusable diagnostic (never wired into runner.py, never run in CI) that forces the comparison: for the two analytical tasks, MCPg's arm gets ONLY translate_nl_to_sql (no run_select, no schema tools — it cannot answer without going through it), baseline stays run_select-only. First attempt failed silently in an unhelpful way — the script only configured MCPG_DATABASE_URL, not the separate MCPG_NL2SQL_PROVIDER/MCPG_NL2SQL_API_KEY translate_nl_to_sql's internal LLM call actually needs (distinct from the outer agent's own key) — so every call errored with "no provider configured" and the resulting "baseline wins" number was the tool failing outright, not losing a fair comparison. Fixed, and added a fail-fast check so a missing key errors immediately instead of spending on trials that can't succeed. Committed result is a real, verified 1-trial-per-arm run confirming the fix works end-to-end (non-zero hidden tokens now show up in real trials, ~7k tokens per translate_nl_to_sql call) and a genuine finding: even guaranteed to use it, MCPg's NL2SQL tool cost ~4.6x more tokens and was less reliable (50% vs 100%) than the model just writing SQL itself. One trial made 17 tool calls before failing — an open question the current TrialResult schema can't explain (it stores the final answer, not a turn-by-turn transcript). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
Tier-B's synthetic Python agent loop never actually tested "Claude Code with MCPg available" - it's a from-scratch harness with a one-line system prompt calling the raw Anthropic API against MCP tools directly. That gap explains a real discrepancy: translate_nl_to_sql never fired once across Tier-B's free-choice runs, but the same model uses it readily in a real Claude Code project with MCPg configured. Adds a new diagnostic that drives real `claude -p` headless invocations twice per task - once with MCPg's MCP server available, once without - and compares Claude Code's own reported token/cost numbers directly, instead of extending the synthetic harness further. Two new analytical tasks with verified ground truth against the live demo dataset, designed to need genuine multi-table joins/aggregation rather than lookups. Never wired into runner.py or CI - costed, spawns real subprocesses. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
…ison Switches real_harness_comparison.py from --output-format json to stream-json so each trial's actual tool-call sequence is captured (which tools fired, in order) instead of only aggregate token/turn counts - the thing needed to actually answer "did translate_nl_to_sql fire," which token/turn counts alone can't. Adds cost_usd and tool_names to the shared TrialResult schema (both default to their zero-value for the synthetic harness, which has neither). Verified with a live dry run before wiring in: confirmed the event shape, confirmed --bare mode isn't usable here (needs a plain ANTHROPIC_API_KEY for the top-level session's own auth; this project authenticates via OAuth), and confirmed the model calling a tool name (list_tables) that doesn't exist in MCPg, recovering from the error - real signal the instrumentation is now able to surface. Re-ran the smoke test end to end through the fixed script (4 real trials, all correct): translate_nl_to_sql fired 0/2 times in the mcpg arm, same as the first smoke test - 0/4 total across both runs now, with real tool-call evidence rather than inference from token counts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
…gers translate_nl_to_sql
Adds --mcpg-system-prompt-hint, applied only to the mcpg arm via
--append-system-prompt, to test a concrete hypothesis for why
translate_nl_to_sql fires in a real project but not in this comparison:
that project injects a standing instruction every session via a
SessionStart hook ("Prefer mcpg for DB inspection, schema/design review,
query plans, and audits over ad-hoc psql.").
Result: appending that exact text did not change the outcome -
translate_nl_to_sql still fired 0/2 times in the mcpg arm, same as
without it (0/6 total across all three real-harness runs now). The
model still reached for run_select/get_compact_schema/list_schemas
instead. Consistent with the hint text itself, which names inspection/
audit/plan work but never mentions NL-to-SQL translation - so this
specific standing instruction isn't sufficient on its own to explain the
other project's behavior; something else (task phrasing, conversation
history, or run-to-run variability) is still unaccounted for.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
The token-efficiency writeup and its cross-references (performance writeup, roadmap rows 19.4-19.6, the original benchmark-suite plan) promised a future agent-loop (Tier B) round-trip savings result. That objective is descoped from the published claim; the writeup now states only the deterministic per-call compactness result (Tier A) and its break-even accounting, paired with the performance result.
…t task-set Two permission-scoping bugs in real_harness_comparison.py's mcpg arm caused spurious tool-approval stalls in headless mode: Bash wasn't in --allowedTools at all, and the curated 9-tool mcpg allowlist missed real tools the model reached for (e.g. lint_naming_conventions). Replaced the curated list with a wildcard (mcp__mcpg__*) plus Bash, so the mcpg arm is genuine free choice rather than an artificial subset. Also adds a --task-set flag (analytical | audit) so the harness can run against tasks.py's planted-flaw DBA tasks, not just the two analytical ones — internal diagnostic tooling, not part of the published token-efficiency claim.
# Conflicts: # src/mcpg/__main__.py # src/mcpg/http_runtime.py # tests/unit/test_config.py # tests/unit/test_http_runtime.py
There was a problem hiding this comment.
Code Review
This pull request introduces several enhancements to the benchmark suite and token accounting, most notably capturing and reporting the internal LLM token usage for the translate_nl_to_sql tool across Anthropic, OpenAI, and Gemini providers. It also adds checkpointing and resumability to the performance and Tier-B token runners to prevent data loss during long runs, introduces a --timeout option for the performance runner, and indexes reviews.customer_id in the demo seed script. Documentation and feature plans have been updated accordingly. Feedback on the changes points out a potential UnicodeEncodeError on Windows when writing the performance checkpoint file without an explicit UTF-8 encoding, and a code suggestion has been provided to resolve this.
| args.output.parent.mkdir(parents=True, exist_ok=True) | ||
| args.output.write_text(json.dumps(run.to_dict(), indent=2) + "\n") |
There was a problem hiding this comment.
Writing the JSON checkpoint file without specifying an explicit encoding (e.g., encoding="utf-8") defaults to the platform-dependent locale encoding. On Windows environments, this often defaults to CP1252, which can cause a UnicodeEncodeError if any non-ASCII characters are present in the metadata (such as the PostgreSQL version string or platform information). Specifying encoding="utf-8" ensures consistent and robust behavior across all platforms, matching the fixes applied to other benchmark scripts in this PR.
| args.output.parent.mkdir(parents=True, exist_ok=True) | |
| args.output.write_text(json.dumps(run.to_dict(), indent=2) + "\n") | |
| args.output.parent.mkdir(parents=True, exist_ok=True) | |
| args.output.write_text(json.dumps(run.to_dict(), indent=2) + "\n", encoding="utf-8") |
There was a problem hiding this comment.
Hey - I've found 5 issues, and left some high level feedback:
- Consider keeping the generated benchmark artifacts (perf-sf10.html/json, real-harness comparison JSONs, forced-nl2sql-comparison.json) out of the tracked tree or moving them under a dedicated
artifacts/orsamples/area so they don’t grow the repo and get confused with source-of-truth inputs. - The checkpoint/resume guards for Tier-B and the real-harness comparison only key off model/trials/config flags; it would be safer to include
database_url(and for real_harness,worktree_dir) in_RESUME_KEYSso runs against different databases or code trees can’t be silently merged into one aggregate.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- Consider keeping the generated benchmark artifacts (perf-sf10.html/json, real-harness comparison JSONs, forced-nl2sql-comparison.json) out of the tracked tree or moving them under a dedicated `artifacts/` or `samples/` area so they don’t grow the repo and get confused with source-of-truth inputs.
- The checkpoint/resume guards for Tier-B and the real-harness comparison only key off model/trials/config flags; it would be safer to include `database_url` (and for real_harness, `worktree_dir`) in `_RESUME_KEYS` so runs against different databases or code trees can’t be silently merged into one aggregate.
## Individual Comments
### Comment 1
<location path="benchmarks/tokens/tier_b/tasks.py" line_range="123-132" />
<code_context>
+ ]
+
+
+def audit_tasks() -> list[Task]:
+ """The three planted-flaw DBA tasks from ``default_tasks()``, isolated for the real-harness diagnostic.
+
+ The synthetic Tier-B harness measured these three costing MCPg's
+ advisor arm 2.66x more tokens than bare SQL — the opposite of what the
+ tool descriptions promise (one advisor call vs. many exploratory
+ queries) — and that number was never explained, only recorded. This
+ subset lets ``real_harness_comparison`` re-run just these three through
+ real Claude Code with tool-trace capture, to see *why*: whether the
+ advisor tool gets called and then the agent explores anyway, whether
+ its output isn't trusted, or something else.
+ """
+ return default_tasks()[:3]
+
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Selecting audit tasks by list slice is brittle and will silently change if default_tasks() ordering ever changes.
audit_tasks() assumes the first three entries in default_tasks() are always the planted-flaw audit tasks. Any future reordering or prepending in default_tasks() will change this subset without obvious breakage. Please select these tasks by explicit identifiers (e.g., missing_index, pii_columns, naming_violation) instead of positional slicing to keep the behavior stable as default_tasks() evolves.
Suggested implementation:
```python
]
def audit_tasks() -> list[Task]:
"""The three planted-flaw DBA tasks from ``default_tasks()``, isolated for the real-harness diagnostic.
The synthetic Tier-B harness measured these three costing MCPg's
advisor arm 2.66x more tokens than bare SQL — the opposite of what the
tool descriptions promise (one advisor call vs. many exploratory
queries) — and that number was never explained, only recorded. This
subset lets ``real_harness_comparison`` re-run just these three through
real Claude Code with tool-trace capture, to see *why*: whether the
advisor tool gets called and then the agent explores anyway, whether
its output isn't trusted, or something else.
Tasks are selected by explicit identifiers (``missing_index``,
``pii_columns``, ``naming_violation``) rather than positional slicing,
so changes to ``default_tasks()`` ordering do not silently alter this
subset.
"""
audit_ids = ("missing_index", "pii_columns", "naming_violation")
# Build an index of tasks by identifier to avoid relying on list order.
tasks_by_id: dict[str, Task] = {task.id: task for task in default_tasks()}
try:
return [tasks_by_id[audit_id] for audit_id in audit_ids]
except KeyError as exc:
# Fail loudly if any expected planted-flaw task is missing, rather
# than silently changing the audit subset.
missing = ", ".join(audit_id for audit_id in audit_ids if audit_id not in tasks_by_id)
raise RuntimeError(
f"Expected planted-flaw audit tasks missing from default_tasks(): {missing}"
) from exc
```
I assumed the task identifier attribute is named `id`. If your `Task` model uses a different field (e.g. `slug`, `name`, or `key`), update `task.id` and the `audit_ids` values to match the actual identifiers for the planted-flaw tasks (`missing_index`, `pii_columns`, `naming_violation`). Also ensure `Task` is hashable and that `default_tasks()` returns a list containing these three tasks with those identifiers.
</issue_to_address>
### Comment 2
<location path="benchmarks/tokens/tier_b/agent.py" line_range="70-74" />
<code_context>
+_TOOLS_WITH_HIDDEN_LLM_COST = {"translate_nl_to_sql"}
+
+
+def _hidden_tokens(tool_name: str, result: Any) -> tuple[int, int]:
+ """The tokens ``tool_name``'s own internal LLM call spent, or (0, 0)."""
+ if tool_name not in _TOOLS_WITH_HIDDEN_LLM_COST:
+ return 0, 0
+ structured = getattr(result, "structuredContent", None) or {}
+ return int(structured.get("tokens_in") or 0), int(structured.get("tokens_out") or 0)
+
</code_context>
<issue_to_address>
**issue (bug_risk):** Assuming structuredContent is always a dict can raise at runtime if the SDK payload shape changes.
`structured = getattr(result, "structuredContent", None) or {}` assumes `structuredContent` is a mapping. If a future SDK/tool returns a list or other non-mapping type, `structured.get(...)` will raise `AttributeError` and break trial accounting. Please guard with `isinstance(structured, dict)` (or `Mapping`) before using `.get`, and return `(0, 0)` for unsupported types.
</issue_to_address>
### Comment 3
<location path="benchmarks/perf/runner.py" line_range="215-224" />
<code_context>
+def _checkpoint(
</code_context>
<issue_to_address>
**nitpick (bug_risk):** Checkpoint writing omits an explicit encoding, unlike other new write_text calls that standardize on UTF-8.
In `_checkpoint()`, `args.output.write_text(...)` still uses the platform default encoding, unlike the other updated paths that explicitly set `encoding="utf-8"`. Please pass `encoding="utf-8"` here as well to keep checkpoint files consistent and avoid cross-platform issues (e.g., non-UTF-8 defaults on Windows).
</issue_to_address>
### Comment 4
<location path="benchmarks/tokens/tier_b/experiments/real_harness_comparison.py" line_range="178" />
<code_context>
+def _invoke_claude(
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The claude subprocess invocation does not enforce any overall timeout, so a stuck CLI could hang the experiment.
`_invoke_claude()` starts `claude -p` and calls `proc.communicate()` without a timeout. If the CLI or its network call hangs, the experiment can block indefinitely and never checkpoint the current trial. Please consider wrapping `communicate()` in `asyncio.wait_for` with a reasonable upper bound and, on timeout, recording a `TrialResult` error so one bad invocation cannot stall the entire run.
</issue_to_address>
### Comment 5
<location path="benchmarks/tokens/tier_b/runner.py" line_range="131" />
<code_context>
+ return trials
+
+
+async def _run(args: argparse.Namespace) -> TierBReport:
+ nl2sql_key = os.environ.get("MCPG_NL2SQL_API_KEY") or os.environ.get("ANTHROPIC_API_KEY", "")
+ if not nl2sql_key:
</code_context>
<issue_to_address>
**issue (complexity):** Consider encapsulating the resume/checkpoint behavior into a dedicated helper that owns configuration, state, and persistence so `_run` can focus solely on orchestrating task execution.
The resume/checkpoint logic is useful, but it now cross‑cuts `_run` and introduces several implicit contracts. You can keep all functionality while isolating this concern and making the main loop easier to follow.
### 1. Extract a small helper to own resume/checkpoint state
Right now `_load_resumable`, `_checkpoint`, `_RESUME_KEYS`, and the `done` set are spread across the module. Pull them into a tiny helper that owns:
- loading existing trials
- deciding whether a `(task_id, arm, trial)` is done
- checkpointing after each new trial
- writing the final report
This keeps `_run` focused on task execution.
```python
@dataclass(frozen=True)
class RunConfig:
model: str
trials_per_arm: int
max_turns: int
mcpg_tools: list[str]
@classmethod
def from_args(cls, args: argparse.Namespace, resolved_mcpg_tools: set[str] | None = None) -> "RunConfig":
tools = sorted(resolved_mcpg_tools or args.mcpg_tools)
return cls(
model=args.model,
trials_per_arm=args.trials,
max_turns=args.max_turns,
mcpg_tools=tools,
)
class ResumableRun:
def __init__(self, args: argparse.Namespace, resolved_mcpg_tools: set[str]):
self.args = args
self.config = RunConfig.from_args(args, resolved_mcpg_tools)
self.trials: list[TrialResult] = self._load_resumable()
self._done = {(t.task_id, t.arm, t.trial) for t in self.trials}
def is_done(self, task_id: str, arm: str, trial: int) -> bool:
return (task_id, arm, trial) in self._done
def record(self, result: TrialResult, *, complete: bool = False) -> TierBReport:
self.trials.append(result)
self._done.add((result.task_id, result.arm, result.trial))
return self._checkpoint(complete=complete)
def finalize(self) -> TierBReport:
return self._checkpoint(complete=True)
# existing _load_resumable / _checkpoint logic moved in here, using self.config
def _load_resumable(self) -> list[TrialResult]:
...
def _checkpoint(self, *, complete: bool) -> TierBReport:
...
```
Then `_run` becomes much clearer:
```python
async def _run(args: argparse.Namespace) -> TierBReport:
settings = load_settings({"MCPG_DATABASE_URL": args.database_url})
model = AnthropicClient(args.model)
tasks = default_tasks()
async with AsyncExitStack() as stack:
server = create_server(settings)
session = await stack.enter_async_context(
create_connected_server_and_client_session(server, read_timeout_seconds=_READ_TIMEOUT)
)
listed = await session.list_tools()
server_names = {t.name for t in listed.tools}
mcpg_tools = {n for n in args.mcpg_tools if n in server_names}
missing = set(args.mcpg_tools) - server_names
if missing:
print(f"warning: requested mcpg tools not on the server, skipped: {sorted(missing)}")
runner = ResumableRun(args, resolved_mcpg_tools=mcpg_tools)
arms = ((ARM_BASELINE, _BASELINE_TOOLS & server_names), (ARM_MCPG, mcpg_tools))
for task in tasks:
for trial in range(args.trials):
for arm, allowed in arms:
if runner.is_done(task.id, arm, trial):
print(f" {task.id:16} {arm:9} #{trial}: skipped (already recorded)")
continue
result = await run_trial(
task,
session=session,
model=model,
allowed_tools=allowed,
arm=arm,
trial=trial,
max_turns=args.max_turns,
)
flag = "PASS" if result.passed else ("ERR " if result.error else "FAIL")
print(
f" {task.id:16} {arm:9} #{trial}: "
f"tok={result.total_tokens:6} tools={result.tool_calls:2} turns={result.turns:2} {flag}"
)
runner.record(result, complete=False)
return runner.finalize()
```
This keeps the nested loops “for task, for trial, for arm, run_trial” visually intact, with resume/checkpoint concerns pushed behind `ResumableRun`.
### 2. Make the metadata/config contract explicit
You currently duplicate the config fields in `_checkpoint` and `_load_resumable` and rely on `_RESUME_KEYS`. The `RunConfig` above can be used to:
- serialize/deserialize the run configuration to/from metadata
- compare the on‑disk config with the current invocation
For example inside `ResumableRun._load_resumable`:
```python
def _load_resumable(self) -> list[TrialResult]:
if not self.args.output.exists():
return []
try:
data = json.loads(self.args.output.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return []
meta = data.get("metadata", {})
if meta.get("complete"):
print(f"note: {self.args.output} already holds a complete run; overwriting with a fresh one.")
return []
disk_config = RunConfig(
model=meta.get("model"),
trials_per_arm=meta.get("trials_per_arm"),
max_turns=meta.get("max_turns"),
mcpg_tools=meta.get("mcpg_tools") or [],
)
if disk_config != self.config:
print(f"note: {self.args.output} exists but its config differs from this run; starting fresh, not resuming.")
return []
trials = [TrialResult(**t) for t in data.get("trials", [])]
if trials:
print(f"resuming from {self.args.output}: {len(trials)} trial(s) already recorded, will not be re-run.")
return trials
```
And `_checkpoint` can build metadata from `self.config`, reducing repetition:
```python
def _checkpoint(self, *, complete: bool) -> TierBReport:
metadata: dict[str, Any] = {
"timestamp": self.args.timestamp,
"git_sha": self.args.git_sha,
"mcpg_version": __version__,
"model": self.config.model,
"trials_per_arm": self.config.trials_per_arm,
"max_turns": self.config.max_turns,
"mcpg_tools": self.config.mcpg_tools,
"host": {"python": platform.python_version(), "os": platform.platform()},
"complete": complete,
}
report = TierBReport(metadata=metadata, trials=self.trials, aggregate=aggregate(self.trials))
self.args.output.parent.mkdir(parents=True, exist_ok=True)
self.args.output.write_text(json.dumps(report.to_dict(), indent=2) + "\n", encoding="utf-8")
return report
```
This makes the matching criteria explicit and central, avoiding implicit contracts between multiple helpers.
### 3. Optional: decouple checkpoint frequency from loop structure
If you later want to reduce I/O without changing semantics, you can let `ResumableRun` control checkpoint cadence (e.g. every N trials, or per task) without touching the loop:
```python
class ResumableRun:
def __init__(..., checkpoint_interval: int = 1):
...
self._checkpoint_interval = checkpoint_interval
def record(self, result: TrialResult, *, complete: bool = False) -> TierBReport | None:
self.trials.append(result)
self._done.add((result.task_id, result.arm, result.trial))
if complete or len(self.trials) % self._checkpoint_interval == 0:
return self._checkpoint(complete=complete)
return None
```
You can keep the default `checkpoint_interval=1` to preserve the “after every trial” behavior while making it easier to tune later.
These changes should retain all existing behavior (resume safety, per‑trial checkpointing, config checks) but significantly reduce the cognitive load in `_run` and make the resume logic easier to maintain.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
- perf/runner.py: both checkpoint write_text() calls now pass encoding="utf-8", matching the other benchmark scripts (Windows defaults to a non-UTF-8 locale encoding, risking UnicodeEncodeError). - tasks.py: audit_tasks() selects by explicit task id instead of a positional slice of default_tasks(), so a future reorder can't silently change which tasks it returns. - agent.py: _hidden_tokens() guards structuredContent with an isinstance check before calling .get() on it. - real_harness_comparison.py: _invoke_claude()'s subprocess call now has a timeout (default 600s, --invocation-timeout-seconds), so a hung claude -p process can't block a run indefinitely. - runner.py + real_harness_comparison.py: _RESUME_KEYS now includes database_url (and worktree_dir for real_harness_comparison), so a checkpoint file can't be silently resumed against a different database or code tree than it was started with. Declined: moving committed benchmark result JSONs out of the tracked tree (matches this repo's existing, repeated convention for benchmark results — out of scope for this PR to change) and the suggested ResumableRun/RunConfig extraction in runner.py (working, tested code; the suggested abstraction isn't earned by a problem this module actually has).
Summary
docs/performance-benchmark.md) — the published perf writeup, generated from a real TPC-H SF10 run.benchmarks/tokens/tier_b/) — built out with checkpoint/resume, a real-Claude-Code (claude -p) on/off comparison mode, and real tool-trace + cost instrumentation. Fixed two--allowedToolsscoping bugs found while using it (missingBash, an incomplete curated tool list) that were causing spurious permission-stall "failures." Internal diagnostic tooling — not part of the published claim (see below).translate_nl_to_sqltoken-usage fix — the tool's internal LLM call was a total blind spot in cost accounting; it now reportstokens_in/tokens_outfrom the actual provider response and wires them into the existing (previously dead)audit_nl2sqlcolumns.mcpg --demofix —reviews.customer_idwas an unplanted, unindexed FK; now indexed as the seed script's own comment already claimed.docs/token-efficiency.mdno longer promises a future agent-loop/session-level savings result; roadmap rows 19.4–19.6 and the original benchmark-suite plan updated to match. The agent-loop objective is descoped from the published claim; its harness stays in-tree as internal tooling only.main(this branch was 9 commits behind) — resolved 4 conflicts, all between independently-written fixes for the same underlying issues (Windows event-loop policy on Python 3.14; Windows-only test path bugs); kept the more robust side in each case, verified no functional loss.Roadmap linkage
Advances roadmap row: 19.1
Advances roadmap row: 19.2
Advances roadmap row: 19.3
Advances roadmap row: 19.4
Advances roadmap row: 19.5
Advances roadmap row: 19.6
Checklist
ruff,ruff format, andmypy src/mcpgpassCHANGELOG.mdupdated under[Unreleased]src/mcpg/_vendor/🤖 Generated with Claude Code
https://claude.ai/code/session_015CgKWtcUQYcfuQv3mpyGQ9
Summary by Sourcery
Add SF10 performance benchmark artifacts, enhance Tier-B token study harness and diagnostics, and finalize token-efficiency documentation scope to deterministic per-call accounting only.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: