Repository navigation
test(escalation): hosted Kaggle arm of the escalation benchmark — tasks, converter, smoke-tested on five models - #44
Merged
Conversation
… and dataset gate
Everything the desk needs to run the benchmark on a hosted runner and score
it with the unchanged aggregate.py, built and tested offline.
- kaggle_export.py writes one JSON line per fixture item (id, shape, messages,
schema). messages come from runner.build_chat_payload, so the hosted prompt
and the local one cannot drift; the answer key is never exported.
- kaggle/{route,classify,judge,ground}_task.py: one kaggle-benchmarks 0.6.1
task per shape. A per-item sub-task (store_task=False) sends system + user at
seed 0 and temperature 0 and records the raw reply; the top-level task
evaluates it over a DataFrame with on_failure="continue" and returns
completed/errored counts. Nothing is parsed on the host.
- kaggle_rows.py reads a `kaggle b t download` tree and writes runner-format
rows, reusing runner.make_row so parsing is identical; errors and missing
items become error rows.
- kaggle_stage.py builds the dataset upload directory and refuses, deleting it,
unless every gate passes: file set and line counts against manifest.json, the
answer key, everything privacy_gate.py checks, store ids, Unicode lookalikes,
compressed payloads. The deny-list and the staging directory must both be
outside every git tree.
- privacy_gate.py ALLOWED_OTHER gains the new files; README documents the flow.
Tests: export parity for every item, converter round-trip into aggregate.py on
a hand-built tree in the download layout, and the stage gate against a planted
email, file URI, store id, deny-list term, lookalike name, count mismatch and
answer key. Four mutants (dropped user message, unfolded confusables, skipped
line counts, unflagged store ids) each turn the suite red.
Persona: hanuman
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vcntd3qgAxd6gnPEvVukVm
…, output cap, unknown done_reason The hosted task files called respond() with its default str schema, never sent MAX_OUTPUT_TOKENS, and left done_reason null, so a hosted model was scored on easier terms than a local one (Loki D4B02BB2 parity finding). - Each task file passes an Answer type (runner.ANSWER_SCHEMA as a pydantic model) to respond(). The parsed reply is written back out as the JSON text aggregate.py parses, with the SDK's raw text kept as raw_text. A reply that breaks the schema is kept as unparseable text, like a local one. ESCALATION_SCHEMA=none sends none; each item records which. - The 512-token cap is sent as max_tokens, or max_output_tokens for Google GenAI models; ESCALATION_CAP_PARAM overrides the name or turns it off. - kaggle-benchmarks 0.6.1 reports no finish reason, so kaggle_rows writes done_reason "unknown" and aggregate.py counts it apart from "length" and shows Truncated as unknown. Local rows and their output are unchanged. Persona: hanuman Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vcntd3qgAxd6gnPEvVukVm
…ape> task names The tasks were renamed to escalation-bench-<shape> when pushed, so RunDir.shape stripped "escalation-", saw "bench-<shape>", and skipped every task: the 204-file smoke download converted 0 rows (gap 2c4c46da429d). shape now tries escalation-bench- then escalation-; any other name is still skipped. Persona: hanuman Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD
…a; tasks take the live names
The hosted route and classify tasks declared answer as str | dict[str, Any], an open
object with no properties. Gemini's structured output drops additionalProperties, so
the model could only emit {}: every route and classify row in the smoke run parsed to
answer {} and scored 0/33 and 0/38 (gap a84645278b60).
- route_task.py: answer is one explicit object per catalogue tool (tool fixed to its
name, args limited to that tool's arguments, copied from catalog/tools.json and pinned
to it by a test) or the string ESCALATE. An optional catalogue argument is a required
nullable key, which OpenAI strict output needs; a null is dropped from the recorded
reply, so it scores like a local reply that omits it.
- classify_task.py: answer is {status, severity, needs_human} with the values
prompts/classify.txt allows, or the string ESCALATE.
- Both models are inlined (no $defs), because kaggle_benchmarks 0.6.1 treats a schema
with $defs as nested and for an OpenAI-style model drops response_format and only
pastes the schema into the prompt.
- Each row records schema "answer-typed"; ESCALATION_SCHEMA=none still records "none".
The reply text is the JSON aggregate.py parses for a local reply; a test feeds typed
replies through the task, kaggle_rows and aggregate and gets the same cell as the
equivalent runner rows. A reply that breaks the typed schema stays raw text and is
scored like a local one.
- The load_frame guard no longer compares the exported schema to the model field for
field. It requires the same envelope fields, the same required set, the same
confidence type, and that the typed answer admits exactly the JSON types the export's
answer admits (object and string); a dropped ESCALATE arm, a dropped object arm or a
new type is refused.
- Hidden reasoning shares the 512-token cap. route and classify send respond(reasoning=)
by model-name prefix: "low" for google/ and openai/, nothing for other vendors, which
keep their default. ESCALATION_REASONING overrides it. The cap is not raised.
- Task names are now escalation-bench-<shape> and escalation-bench-<shape>-item, as live
on Kaggle. judge and ground differ from kaggle-escalation/step3/push2 in nothing; route
and classify differ from it only by the changes above.
Hosted/local difference, deliberate (Loki 4917FD62): the local runner and the exported
dataset keep runner.ANSWER_SCHEMA (answer is a string or an open object); only the two
hosted task files type it. The module header of both says so.
Tests: the typed schema has no open object; the route schema matches the catalogue; the
classify schema matches the prompt; every fixture answer is expressible; typed replies
score like local ones; the guard accepts the runner schema and refuses six mismatches.
Restoring the open dict[str, Any] in a scratch copy turns 5 route tests and 2 classify
tests red; the unmutated copy is green.
Persona: hanuman
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD
… cap per slug, send one reasoning table on all four shapes Closes the four open items Loki raised on 230a695 (4915E060, gap 5f1d65e364a2) so every slug in the ratified set can be run. deepseek-r1 <think>. kaggle_benchmarks 0.6.1 strips a leading <think> block only inside OpenAI._call_api and only when a reasoning level was sent. A probe through the real SDK over a canned transport (deepseek-ai/deepseek-r1-0528, both parse paths) showed: with no level the reply fails the schema; with level "low" the text path parses, but the structured-output path (beta.chat.completions.parse) still raises a pydantic ValidationError before the strip runs, and an inlined schema takes that path on all four shapes. deepseek is built with support_structured_outputs=False (model_proxy.py), so its live path is the text one, but whether the proxy accepts reasoning_effort for it is unverifiable offline and a refusal would cost every row. So the task strips it instead: when the SDK's parse has already failed, a closed leading <think> block is taken off, the rest is validated as the shape's Answer, and the text is the answer JSON (or the stripped text when it does not validate). The reply as sent is kept as raw_text. A reply cut off inside its think block has nothing to strip and stays unparseable text. A ValidationError with no think block is still an error row. Cap parameter. The SDK passes the name through to the proxy unchanged; it has no per-model mapping. The openai/ prefix (gpt-5.5, gpt-5.4-nano, gpt-oss-20b) now sends max_completion_tokens (the OpenAI API deprecates max_tokens and refuses it for reasoning models); everything else keeps max_tokens, and a Google GenAI client keeps max_output_tokens. The value stays 512. ESCALATION_CAP_PARAM still overrides it. Uniform reasoning. One settings table (reasoning level and cap name by model-name prefix, plus the think helpers) now sits in all four task files, copied identically because each file is standalone on Kaggle, and judge and ground now send and record the same reasoning level as route and classify. A test fails if the four copies drift. google/ is narrowed to google/gemini-: gemma does not think and was being sent a thinking level. CI. pydantic is added to the test extra: the Linux matrix and Windows legs install .[test], so the typed kaggle tests run there instead of skipping at importorskip. A separate CI leg would add a job to the required aggregate for one dependency; the no-extras leg stays dependency-free and still skips them. A test holds the extra and the two .[test] installs. The real-SDK tests importorskip kaggle_benchmarks, so they run only where the SDK is installed (not in CI). grok-4.6: nothing changed. Tests: every ratified slug gets its cap name and reasoning level on every shape; the think block is stripped on the stub path, the pydantic path, the schema-off path, and through the real SDK (16 cases); a cut-off think block and a plain ValidationError behave as before; the four settings blocks are identical; the test extra lists pydantic. Each new test was made red in a scratch copy that keeps .git (strip_think a no-op, cap table emptied, one copy drifted, pydantic removed from the extra, google/ prefix restored, ValidationError no longer caught); the unmutated copy is green. Persona: hanuman Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD
… a 429, drop grok-4.6 from the batch Smoke2 blockers (gap 2b2d3c896251). Temperature: openai/gpt-5 (nano confirmed by smoke2 400, gpt-5.5 inferred, SDK has no per-model rule) sends no temperature; every other slug sends 0; each row records temperature 0 or "default". 429 retry: only an exception class named RateLimitError, up to 6 calls, waits 4/8/16/32/60 s (120 s max), only llm.respond is repeated so the chat holds one user turn (checked through the real SDK), row records attempts; any other error is recorded once. grok-4.6 leaves the ratified set for this batch (operator: "For this batch, retire and drop Grok"). The four task files keep one identical settings block; runner.py untouched. Persona: hanuman Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD
…onverted rows Sealed cf803012 says each row records how many calls a 429 took. 6284cdc put attempts and temperature in the hosted task's result, but make_rows dropped them, so rows.jsonl never showed either (Loki 23A827B1 F1). Copy both from the run-file result when it recorded them; rows without them are unchanged. Persona: willow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD
… strict mode accepts smoke3: every gpt-5.4-nano judge and ground call (90 of 90) was a 400, "Invalid schema for response_format 'Answer': ... 'additionalProperties' is required to be supplied and to be false", because both still declared answer: str | dict[str, Any]. A judge answer is one of four strings (SUPPORTS, CONTRADICTS, UNRELATED, ESCALATE) and a ground answer is a string (a phrase from the passage, or ESCALATE); aggregate.answers_match scores a string for both and never credits an object. So judge types answer as the four labels and ground as str, both with extra=forbid, and rows now record the answer-typed schema label like route and classify. check_exported_schema is one rule in all four task files. The export keeps the runner's open schema (string or object), so a typed answer narrower than it is allowed: the export's arms must stay within (object, string), and the typed answer must keep the string arm (ESCALATE) and add no arm the export lacks. A typed object arm the export lacks, a lost string arm, a new export type and changed envelope fields are still refused. Equality would refuse the string-only shapes; route and classify behave as before. Tests: openai_strict_problems() (no open object, every object closed and fully required, no $ref) runs on every shape's generated schema and on the request body the real kaggle_benchmarks SDK sends for each shape; a control proves it fails on the old str | dict[str, Any], on the schema and on the real SDK request. The four task files are held identical outside the typed block. The three gpt-5.4-nano ValidationErrors were route items whose reply matched no catalogue arm (a mis-shaped object); they are unrelated to this schema. Persona: hanuman Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD
…le, not a dropped error row smoke3: nano route-017, route-038 and route-060 raised a pydantic ValidationError that is not json_invalid. reply_in_error returned None for it, the task recorded an error row, and aggregate leaves error rows out of every denominator, so hosted accuracy was inflated against local, where a schema-violating reply stays unparseable text and counts wrong on an answerable item. The reply text is not recoverable for these: the SDK's structured-output parse raises before it appends the reply to the chat, and a non-json_invalid error carries only the offending sub-values. So the task now records text empty plus a bounded schema_violation (str(exc), 2000 characters), which converts to parse_ok False and error None. A json_invalid reply with no think block is kept as text and raw_text instead of an error row. A RateLimitError, BadRequestError or any other failure stays an error row. kaggle_rows passes schema_violation through like attempts and temperature. Same change in all four task files (the settings block stays identical). Re-converting kaggle-smoke3 with the old run files still gives error rows for the three items, since those results were written by the old task code. Under the new code nano route would go from 40/45 (0.889, 3 errors) to 40/48 (0.833, 3 unparseable). Persona: hanuman Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD
Author
|
CI red: #44 @ 1aef4cc — 3 job(s):
windows (3.11)windows (3.13)test |
Author
|
willow-bot status for
|
…ws passes The stage test built paths with str(relative_to), which is catalog\tools.json on Windows against EXPECTED_FILES' catalog/tools.json; Forge#44 windows (3.11/3.13) went red on that one assertion. Use as_posix(). Persona: willow Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD
rudi193-cmd
approved these changes
Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bite
The hosted half of the escalation benchmark: four Kaggle Benchmarks tasks (
escalation-bench-{route,classify,judge,ground}) that collect raw replies from hosted models under the same prompts, answer envelope and 512-token cap as the local runner, pluskaggle_rows.py, which turns akaggle b t downloadtree into runner-format rows that the unchangedaggregate.pyscores.What was done
benchmarks/escalation/kaggle/*_task.py): one item, one call, at temperature 0 / seed 0. A failed call is a result, not a retry. The exception is a 429 rate limit, which is retried up to 6 calls with 4/8/16/32/60 s waits and recordsattempts.{status, severity, needs_human}or ESCALATE. Judge is a literal verdict set. Ground is a string. Every object is closed and inlined, with no$defs, so OpenAI strict mode and Gemini structured output both accept them. The old opendict[str, Any]made Gemini return{}and OpenAI return 400.lowforgoogle/gemini-andopenai/slugs, no temperature foropenai/gpt-5*(they reject 0), andmax_completion_tokensforopenai/.<think>block is stripped after a failed parse, and the raw reply is kept. A schema-violating reply scores as unparseable (text=""plusschema_violation) instead of being dropped as an error row.kaggle_rows.py): accepts bothescalation-bench-<shape>andescalation-<shape>. Passesattempts,temperatureandschema_violationthrough.pydanticadded to thetestextra, so the typed-schema tests run on the.[test]legs.Evidence
391 passed, 26 skipped in 15.83s; ruff 0.16.7All checks passed!.401 passed..git:assert 0 == 60.dict[str, Any]): 7 failed.~/.forge/escalation-runs/kaggle-smoke{2,3}):Out of scope
runner.py's local schema and scoring, and the 512 cap value.Next bite
Merge, then run the paid 11-model set in two days under the $10/day cap (cheap models first). Then group the four tasks into a public Kaggle benchmark for the DEV.to #kagglechallenge entry, due 2026-10-11.
Decision
Sealed Nestor decisions this PR implements:
The push and PR ride envelopes env-git.push-d3291939c70d and env-pr.open-b2cd03b02854, both ratified by the operator.
Ratified-by: sean-campbell — "ratify"
🤖 Generated with Claude Code
https://claude.ai/code/session_016jPFpbxqZYKT1gTfw8pKkD