Skip to content

Make the eval harness runnable, then run it - #97

Open
Defaultuser361 wants to merge 4 commits into
ayghri:mainfrom
Defaultuser361:eval-harness-judge-and-fidelity-fixes
Open

Make the eval harness runnable, then run it#97
Defaultuser361 wants to merge 4 commits into
ayghri:mainfrom
Defaultuser361:eval-harness-judge-and-fidelity-fixes

Conversation

@Defaultuser361

Copy link
Copy Markdown

The harness in scripts/ is carefully built — paired conditions, blind judging, a weighted rubric, a release gate, resumable runs, and deliberate isolation from operator config — and as far as I can tell it has never been run: no results committed, no numbers in the README, no workflow invoking it.

I wanted to know whether the skill actually works, so I tried to run it. This is what that took, and what it found.

Why it couldn't be run as-is

score aggregates rows a human has already written. There is no judge, so a 3-trial pass means hand-scoring 84 responses across five dimensions — 420 numbers, blind. That is the step standing between the harness and an answer.

scripts/judge.py fills it in. It grades a whole (case, trial) group in one call so conditions are compared rather than scored in isolation, and blinding is structural instead of a convention the grader is asked to respect: conditions are relabelled A/B/C with the order permuted per group from a digest of the group key, so position carries no signal and a resumed run reproduces its earlier labels. Stdlib only, matching the existing scripts.

Four defects, three of them only visible once it executes

The runner adopted this repo as its working directory. The subprocess inherited cwd=ROOT, so the agent CLI answered prompts by inspecting the harness. One baseline response opened by identifying scripts/run_evals.py; another degenerated into narrating tool calls for tools it did not have. Over a 14-case pass it hit 8 baseline responses and 2 candidate ones. The asymmetry is the problem — it favours whichever condition discourages exploration, so it lands on the number under test. The runners already pass --setting-sources "" for precisely this reason; the working directory was an unguarded channel for the same class of leak.

The eval graded text that never ships. _condition_prompt injected SKILL.md verbatim, frontmatter included, so the candidate prompt carried disable-model-invocation: true and the hermes metadata. hooks/always-on.sh strips that before injecting. The new _strip_frontmatter mirrors the hook's awk — byte-identical output on the current SKILL.md.

rubric.md leaked the blinding vocabulary. It ends with "Release the candidate only when… higher than baseline". Feeding the whole document to a blind grader hands over the exact words the blinding exists to hide, plus release-gate rules the grader has no business seeing. Now delimited with <!-- judge:begin --> / <!-- judge:end -->. Worth noting this got through every unit test and was caught only by the end-to-end one.

--tools "" ate the judge prompt. Appending the prompt as a trailing argument let the empty-valued flag consume it. Generation only escapes this because --max-budget-usd happens to sit in between — remove the budget flag from a runner and it breaks the same way. The judge passes the prompt on stdin.

Each fix ships with a test that fails against the previous behaviour; the working-directory one uses a stub runner that just reports its own pwd. CI now runs the suite (stdlib unittest, no new dependency) — tests/ was not referenced by any workflow, so the scoring maths and release gate could regress with CI green.

Result

14 cases, 3 trials, claude-opus-4-8, Claude Code 2.1.220. Full numbers and caveats in evals/RESULTS.md.

Dimension Weight Baseline Candidate Δ
Correctness 35% 4.333 4.524 +0.190
Autonomy 25% 3.762 4.167 +0.405
Actionability 20% 3.905 4.619 +0.714
Safety 10% 4.643 4.667 +0.024
Concision 10% 3.429 4.571 +1.143
Weighted 4.045 4.473 +0.427

Blocking findings 7 → 3. The candidate wins 10 of 14 cases, ties 2, loses 2.

Every dimension moves in the candidate's favour, including the two that would expose a style rule buying brevity with substance. The gains concentrate in cases about reporting state — multi-step-progress +2.53 and error-report +2.40 — while cases with an explicit output contract (code-answer, long-form-request) are unchanged, which is what the escape-hatch clauses are supposed to do.

It still fails your release gate, on rule 1. That rule is absolute where its neighbours are comparative, so more than halving the blocker count is not enough to pass. Two of the three remaining come from a case nothing can pass (see the companion issue). Whether that asymmetry is intended seems worth deciding deliberately rather than discovering at a release.

Caveats are in RESULTS.md and I would not want them skipped: three trials is few (per-case SD reaches 0.95), the judge shares a model family with the responses it grades, and 3 of 84 responses still carry tool-call syntax as plain text because the CLI primes tool use even with --tools "".

Notes

  • Happy to drop the RESULTS.md commit if you would rather generate the numbers yourself — the other three stand on their own, and the harness is what matters.
  • Also happy to split this into separate PRs. I kept it together because the fixes are what running it found, and they read better as one story, but it is your call.
  • Two case-design issues came out of this that change what the eval measures rather than how it runs, so they are filed separately rather than patched here.

🤖 Generated with Claude Code

Defaultuser361 and others added 4 commits August 2, 2026 05:23
Two ways the harness graded something other than what it claimed to. Both
surfaced on the first real run; neither is visible without executing it.

Working directory: the runner subprocess inherited `cwd=ROOT`, so an agent CLI
adopted this checkout as project context and answered prompts by inspecting the
harness instead of the task. One baseline response opened by identifying
`scripts/run_evals.py` and emitted tool-call syntax for tools it did not have.
Over a 14-case pass it hit 8 baseline responses and 2 candidate ones -- the
asymmetry favours whichever condition discourages exploration, so it lands
directly on the number under test. The runners already pass `--setting-sources ""`
for exactly this reason; the working directory was an unguarded channel for the
same class of leak. Both conditions now run in an empty scratch directory.

Frontmatter: `_condition_prompt` injected SKILL.md verbatim, so the candidate
prompt carried `disable-model-invocation: true` and the `hermes` metadata block.
hooks/always-on.sh strips that frontmatter before injecting the ruleset, so the
eval was grading text that never reaches a user. `_strip_frontmatter` mirrors
the hook's awk -- byte-identical output on the current SKILL.md -- and leaves a
file alone if it opens a block without closing it, rather than emptying it.

Tests cover both: the working-directory one uses a stub runner that reports its
own `pwd`, and fails against the previous behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`score` aggregates rows a human already wrote, so running the harness end to end
meant hand-scoring every response across five dimensions -- 420 numbers for the
documented 3-trial pass. That is the step that kept the harness unrun.

scripts/judge.py grades a whole `(case, trial)` group in one call, so conditions
are compared against each other rather than scored in isolation. Blinding is
structural rather than a convention the grader is asked to honour: conditions
are relabelled A/B/C and the label order is permuted per group from a digest of
the group key, so position carries no signal and a resumed run reproduces the
labels it used before. Over the 42 groups of a 3-trial pass the candidate landed
in each position 19/23 times.

Only the region of rubric.md between the new judge:begin/judge:end markers
reaches the grader. The release-gate rules below them name both conditions, so
sending the whole document to a blind grader would hand over the exact
vocabulary the blinding exists to hide -- caught by the end-to-end test, after
every unit test had passed on a prompt that was still leaking.

The prompt goes in on stdin. Appending it as a trailing argument let the claude
runner's `--tools ""` consume it as that option's value; generation only escapes
this because `--max-budget-usd` happens to sit in between. Stdin also removes an
argv ceiling that matters when prompts embed whole responses.

Graders occasionally drop a field or wrap JSON in prose. Verdicts are validated
with the offending case named, retried on the same budget as a failed process,
and if still unusable the group is skipped, reported on stderr, and the exit
code is non-zero -- one bad reply should not discard the groups already written
or the ones queued behind it. Groups missing a condition are reported the same
way rather than dropped, since conditions are only comparable when judged on
identical rows.

Stdlib only, matching the existing scripts. 18 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tests/ was not referenced by any workflow, so the scoring maths, the release
gate, and the pairing checks could regress with CI green. Uses stdlib unittest
so no dependency is added; matrix covers 3.11 and 3.12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 cases, 3 trials, claude-opus-4-8, Claude Code 2.1.220. Weighted 4.045 ->
4.473; every dimension improves, including correctness (+0.190) and safety
(+0.024). Blocking findings 7 -> 3.

The release gate still fails, on "no blocking findings" -- absolute where the
neighbouring rules are comparative, so more than halving the count is not
enough. Two of the three remaining come from `agent-owned-edit`, which requires
acting on a repository while every runner passes `--tools ""` and so cannot be
passed by any run.

Includes the caveats: three trials is few (per-case SD reaches 0.95), the judge
shares a model family with the responses it grades, and 3 of 84 responses carry
tool-call syntax as plain text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant