Skip to content

feat: verify a probe's answer key before it reaches a developer - #17

Merged
imkp1 merged 6 commits into
mainfrom
verify-stage
Jul 28, 2026
Merged

feat: verify a probe's answer key before it reaches a developer#17
imkp1 merged 6 commits into
mainfrom
verify-stage

Conversation

@imkp1

@imkp1 imkp1 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Adds stage 4. Stage 3 writes the question, every option, the key, and the explanation in one call, in sequence, and never re-reads an early option against a later one. Every failure that produces is already forbidden in its prompt, so more instruction is the approach that has been tried. This is the check instead.

verify.py reads the options back cold — no key, no explanation, no seed, no transcript, no repository — and judges each one true or false with a stated reason. The probe survives only if exactly one is true and it is the stored key. It takes a Probe and nothing else on purpose: a judge that has seen the reasoning behind the answer is the model agreeing with itself, and keeping the transcript unavailable at the type level is what stops that eroding.

Backtested before anything was wired in

All 47 stored probes, against the real CLI:

verified 44
discarded 3 (6%)
two true options 0
cost $0.041/probe vs a $0.226 stage 3 baseline (~+10% per kept session)

One discard is a genuinely false key: a PEP 503 question whose answer claims fault-line normalises onto faultline. It does not — fault-line normalises to fault-line. The real collision is Faultline.

Sensitivity was measured separately, by hand-planting a known-true option into five clean probes: caught 5/5, each at the exact planted index.

Two designed pieces are deliberately absent

  • Repointing correct_idx at the option the judge preferred. That judgment landed 3 times in 47 and would have been right once; the other two were local-file questions where the judge could only reason from the stem's premises. A verifier that installs a false key two times in three is the bug it exists to catch. It discards instead.
  • The stage 3a/3b split, and check A with its repair loop. Both existed to fix two-true option sets. Stage 3 does not measurably produce them, so the repair path would be code that never runs.

The original gate for this work required probe 48 to fail verification — key "kernel serialises dirent creation" vs distractor "mkdir is a single syscall, two callers can't both succeed", believed to be two true options. It does not fail, batched or in isolation with the key not even visible, and the judge is right: read/write are single syscalls and are not atomic against concurrent callers, so that distractor asserts a false causal chain. Probe 48 is a good probe.

Behaviour

  • A call failure keeps the probe; only a judgment discards. A CLI that cannot run has said nothing about the question, and treating silence as rejection would empty the queue every time the model was unreachable.
  • New unverified session verdict and a sixth EmptyReason, with a line on both /grask surfaces. A discarded probe is not silent, not error, not ask.
  • CAPTURE_STALE_MINUTES 20 → 30: the pipeline's worst case is now 24 minutes of stage timeouts, and a marker that expires early promises a probe that is still coming. That is a ceiling for crash detection, not a runtime — measured end to end over the real store, capture runs a median of 25s and has never exceeded 166s.

The suite was making real calls

Adding stage 4 gave eight existing tests a real claude -p call each, and they kept passing — the only symptom was test_capture.py going from 0.11s to 49s. The cause is structural: a test injects the stages that existed when it was written and inherits the real default for anything added later.

conftest now refuses any subprocess whose argv[0] ends in claude. It raises through pytest.fail rather than AssertionError, because capture_session catches Exception on purpose and would swallow the guard into an error row — the author of stage 5 would see that instead of a message naming what they did.

Reviewing the branch against itself

Five defects, then two more, all found after the stage above was written:

  • A discard spoke for sessions it wasn't. unverified outranks caught_up and was scoped only by age, so one discard suppressed "you are caught up" for seven days — including for a developer who had since earned a probe and answered it. It now also requires that nothing has minted a probe since.
  • A discarded question read as free. No probes row exists on that path, so stage 3 and stage 4's spend — the expensive half of the session — was recorded nowhere, in the very report used to decide whether stage 4 earns its price. New sessions.discarded_usd, kept apart from cost_usd so SUM(discarded_usd) stays answerable. outcomes had the mirror-image blind spot: it summed seeds through the probes join, losing stage 2 for any session with a seed and no probe. On a two-session fixture the reported total went from $0.46 to the true $0.83.
  • Cost and duration covered different stages. verify billed stage 4 into cost_usd but not duration_ms.
  • A judgment's indices were trusted one at a time. Every element in range and every element "index": 0 is individually valid and collectively meaningless; read literally it moved the true verdict onto option 0 and discarded a good probe for disagreeing with a key it never disagreed with. Indices are now accepted only as a permutation, or as position throughout.
  • A stage that gave up recorded nothing. seed and probe raise LLMError and capture caught neither, so both fell to the catch-all: an error row with no cost and no seed. Probe exhausting its retries is the pipeline's most ordinary failure and it reported three billed calls as $0.00 while discarding a stage 2 result that was never at fault. LLMError now carries its spend, and capture keeps the seed whenever stage 2 produced one.
  • Nothing could use a kept seed. add_probe was reachable only from capture, so "the seed is still stored" was a consolation, not a recovery — a control priced against a redemption nobody had built. grask.reprobe re-runs stages 3 and 4 over unprobed seeds. Both stages, not a shortcut past the check; explicit and --go-gated, bounded to PROBE_TTL_DAYS, and skipped once the transcript has rotated.
  • doctor called a stale skill healthy. grask install copies SKILL.md into ~/.claude/skills and upgrading the package does not re-copy it. Stage 4 is where that first bites: a skill enumerating five empty_reason codes invites the model to flatten an unfamiliar sixth into one it recognises, and unverified softened into "you're caught up" is exactly what the new reason exists to prevent. Plugin installs are unaffected.

Stale text went with them: SKILL.md listed five empty-queue reasons in both the packaged copy and the plugin mirror, and stage counts and timings were wrong in storage.py, capture.py, llm.py and design.md.

Release

Bumped to 0.1.0-rc6 (pyproject.toml, .claude-plugin/plugin.json, uv.lock). The plugin cache is keyed by version, so shipping this under rc5 would no-op for anyone already on rc5, and PyPI holds 0.1.0rc5 permanently. The CHANGELOG gained the stage 4 entry it never had.

Verification

408 passed, ruff and mypy clean, green on the 3.8 CI floor. The shipped module reproduces the backtest against the real store: probe 42 rejected, 47 and 48 verified.

🤖 Generated with Claude Code

imkp1 and others added 6 commits July 28, 2026 08:25
Stage 3 writes the question, every option, the key, and the explanation in
one call, in sequence, and never re-reads an early option against a later
one. Every failure that produces is already forbidden in its prompt, so more
instruction is the approach that has been tried. This adds the check instead.

`verify.py` reads the options back cold — no key, no explanation, no seed, no
transcript, no repository — and judges each one true or false with a stated
reason. The probe survives only if exactly one is true and it is the stored
key. It takes a `Probe` and nothing else on purpose: a judge that has seen the
reasoning behind the answer is the model agreeing with itself, and keeping the
transcript unavailable at the type level is what stops that eroding.

Backtested over all 47 stored probes before any of this was wired in: 44
verified, 3 discarded, 0 with two true options, $0.041/probe against a $0.226
stage 3 baseline (~+10% per kept session). One discard is a genuinely false
key — a PEP 503 question whose answer claims `fault-line` normalises onto
`faultline`, which it does not.

Two pieces of the original design are deliberately absent, on that evidence:

- Repointing `correct_idx` at the option the judge preferred. It landed three
  times in 47 and would have been right once; the other two were local-file
  questions where the judge could only reason from the stem's premises. A
  verifier that installs a false key two times in three is the bug it exists
  to catch. It discards instead.
- The stage 3a/3b split, and check A with its repair loop. Both existed to fix
  two-true option sets. Stage 3 does not measurably produce them.

A call failure keeps the probe; only a judgment discards. Verification checks
a question that already exists, so a CLI that cannot run has said nothing
about it, and treating silence as rejection would empty the queue every time
the model was unreachable.

Also widens CAPTURE_STALE_MINUTES to 30: the pipeline's worst case is now 24
minutes of stage timeouts, and a marker that expires early promises a probe
that is still coming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding stage 4 gave eight existing tests a real `claude -p` call each. They
kept passing. The only symptom was test_capture.py going from 0.11s to 49s,
which is not a signal anyone reads.

The cause is structural rather than a mistake in any one test: a test injects
the stages that existed when it was written and inherits the real default for
anything added later. Every test in this suite predates stage 5.

So conftest refuses any subprocess whose argv[0] ends in `claude`, autouse,
on the same principle as the GRASK_HOME fixture beside it — it works by
default rather than by remembering. It is deliberately narrow: install.py's
python probe and capture_run.py's worker spawn are legitimate launches, and a
test that wants a real call can still patch subprocess.run underneath it.

It raises through `pytest.fail` rather than AssertionError. `capture_session`
catches Exception on purpose — it runs detached and turns every failure into a
row — so an AssertionError would be swallowed into an `error` verdict, and the
author of the next stage would see that instead of a message naming what they
did. `Failed` derives from BaseException and passes straight through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five defects found reviewing the stage 4 branch against itself.

**A discard spoke for sessions it wasn't.** `unverified` outranks `caught_up`
and was scoped only by age, so one discard suppressed "you are caught up" for
the full seven days: a developer who then earned a probe and answered it was
still told the queue was empty because a question had been thrown away — about
a session that was no longer the last one. It now also requires that nothing
has minted a probe since. Both CLI notes drop "your last session", which the
reason has never actually known.

**A discarded question read as free.** There is no probes row on that path, so
stage 3 and stage 4's spend — the most expensive half of the session — was
recorded nowhere, in the very report used to decide whether stage 4 earns its
price. `ProbeUnverified` now carries the spend out (the only scope that knows
it) into a new `sessions.discarded_usd`, migrated through `ADDED_COLUMNS`.

Separate from `cost_usd` for one reason: `SUM(discarded_usd)` is what the stage
cost to produce nothing, and merged it could not be recovered. The tidier
argument — that `cost_usd` must stay summable as triage spend — is not the
reason and was not true: nothing reads that column as triage-only.

`outcomes` had the same blind spot from the other side. It summed seeds through
the probes join, so a session with a seed and no probe lost stage 2 as well. On
a two-session fixture the reported total went from $0.46 to the true $0.83.

**Cost and duration covered different stages.** `verify` billed stage 4 into
`cost_usd` and not `duration_ms`, leaving two columns on one row that nobody
could put beside each other.

**A judgment's indices were trusted one at a time.** Every element in range and
every element `"index": 0` is individually valid and collectively meaningless;
read literally it moved the true verdict onto option 0 and discarded a good
probe for disagreeing with a key it never disagreed with. Indices are now
accepted only as a permutation, or position throughout.

**Stale text.** `SKILL.md` still listed five empty-queue reasons without
`unverified` — a third `/grask` surface, in both the packaged copy and the
plugin mirror. Plus stage counts and ~30s/~45s in `storage.py`, `capture.py`,
`llm.py` and `design.md`.

Minor: the model-call guard wrapped `run` but not `Popen`, which the stage-5
author it exists for could have reached for; `unverified` is 10 characters and
broke `outcomes`' column; its "No probes" hint gained the third cause; dropped a
duplicated capture test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two gaps left open by the stage 4 review.

**A stage that gave up recorded nothing.** `seed` and `probe` raise `LLMError`
and `capture_session` caught neither, so both fell to the catch-all: an `error`
row with no cost and no seed. Probe exhausting its three attempts is the most
ordinary failure the pipeline has, and it reported three billed calls as $0.00
while discarding a stage 2 result that was never the problem.

`LLMError` now carries the spend behind it, and stages attach it on the way out
— `ProbeUnverified` inherits that rather than defining its own. In `probe` the
whole retry loop is wrapped, not just its exit: an unparseable response raises a
plain `LLMError` from `parse_probe`, which is not a `ProbeRejected` and escaped
without passing the bottom of the function. That was the common path, so
attaching cost only at the exit fixed almost nothing.

Capture now catches stages 2 and 3, records the spend as `discarded_usd`, and
keeps the seed whenever stage 2 produced one. A kept probe also absorbs what a
failed verification cost — keeping the question does not make the attempts free.

**Nothing could use a kept seed.** `add_probe` was reachable only from capture,
so "the seed is still stored" was a consolation rather than a recovery: a
control priced against a redemption nobody had built. `grask.reprobe` re-runs
stages 3 and 4 over seeds with no probe, via `Store.unprobed_seeds`.

Both stages, not a shortcut past the check — these are the seeds most likely to
fail it again, and a second discard is a fact about the seed rather than a bad
roll. Explicit and `--go`-gated for the reason `capture_run` is: a retry folded
into the next capture would bill a decision nobody made, and a seed that fails
twice would do it on a schedule. Bounded to `PROBE_TTL_DAYS` and skipped when
the transcript has rotated, since stage 3 needs the dialogue and a probe born
expired is a call spent on something `next_probe` will never serve.

A recovered probe needs nothing to rewrite history: the session row stays
`unverified`, and the reason retires itself because it is scoped to "nothing
minted since".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`grask install` copies SKILL.md into ~/.claude/skills; upgrading the package
does not re-copy it. Nothing noticed, so a new grask could run against a skill
written for an older one and doctor called it healthy.

Stage 4 is where that first bites. The note text comes from the installed code
and stays correct, but a skill enumerating five `empty_reason` codes is a skill
inviting the model to flatten an unfamiliar sixth into one it recognises —
`unverified` softened into "you're caught up" is precisely the outcome the new
reason exists to prevent.

Reported as a failure rather than a note: doctor's contract is one exit code and
there is no severity between them. It reads correctly for a hand-edited skill
too, since those edits are not what is under test and the next `grask install`
overwrites them silently. An unreadable file on either side is not drift —
"cannot check" and "is wrong" are different answers, and only one should stop a
developer.

Plugin installs are unaffected: the bundled skill ships with the package and is
already guarded against drift by test_plugin.

The check now outgrows its label, so "delivery skill present" becomes "delivery
skill current", and a missing skill says so rather than printing a bare path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carries stage 4: a probe's answer key is read back cold by a judge that
has never seen the seed, the transcript, or the key itself, and the probe
is discarded unless exactly one option is true and it is the stored one.
Also the capture marker, the judgment-shaped stage 3 frames, `record`
returning the next probe, `--tools ""`, and the hook dropping payloads
whose transcript is gone.

The bump is load-bearing, not ceremony: the plugin cache is keyed by
version (~/.claude/plugins/cache/grask/grask/<version>/), so shipping
under rc5 would no-op for everyone already on rc5, and PyPI holds
0.1.0rc5 permanently and refuses a re-upload.

The CHANGELOG gained a stage 4 entry it never had, and its `capturing`
paragraph now says 30 minutes rather than 20 — the window moved with the
pipeline's worst case when stage 4 added a fourth call's worth of
timeouts, and the changelog still described the old one.

uv.lock moves with the version because CI runs `uv sync --locked`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@imkp1
imkp1 merged commit b062484 into main Jul 28, 2026
10 checks passed
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