feat: verify a probe's answer key before it reaches a developer - #17
Merged
Conversation
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>
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.
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.pyreads 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 aProbeand 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:
One discard is a genuinely false key: a PEP 503 question whose answer claims
fault-linenormalises ontofaultline. It does not —fault-linenormalises tofault-line. The real collision isFaultline.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
correct_idxat 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 original gate for this work required probe 48 to fail verification — key "kernel serialises dirent creation" vs distractor "
mkdiris 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/writeare single syscalls and are not atomic against concurrent callers, so that distractor asserts a false causal chain. Probe 48 is a good probe.Behaviour
unverifiedsession verdict and a sixthEmptyReason, with a line on both/grasksurfaces. A discarded probe is notsilent, noterror, notask.CAPTURE_STALE_MINUTES20 → 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 -pcall each, and they kept passing — the only symptom wastest_capture.pygoing 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 inclaude. It raises throughpytest.failrather thanAssertionError, becausecapture_sessioncatchesExceptionon purpose and would swallow the guard into anerrorrow — 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:
unverifiedoutrankscaught_upand 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.sessions.discarded_usd, kept apart fromcost_usdsoSUM(discarded_usd)stays answerable.outcomeshad 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.verifybilled stage 4 intocost_usdbut notduration_ms."index": 0is 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.seedandproberaiseLLMErrorand capture caught neither, so both fell to the catch-all: anerrorrow 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.LLMErrornow carries its spend, and capture keeps the seed whenever stage 2 produced one.add_probewas 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.reprobere-runs stages 3 and 4 over unprobed seeds. Both stages, not a shortcut past the check; explicit and--go-gated, bounded toPROBE_TTL_DAYS, and skipped once the transcript has rotated.grask installcopiesSKILL.mdinto~/.claude/skillsand upgrading the package does not re-copy it. Stage 4 is where that first bites: a skill enumerating fiveempty_reasoncodes invites the model to flatten an unfamiliar sixth into one it recognises, andunverifiedsoftened into "you're caught up" is exactly what the new reason exists to prevent. Plugin installs are unaffected.Stale text went with them:
SKILL.mdlisted five empty-queue reasons in both the packaged copy and the plugin mirror, and stage counts and timings were wrong instorage.py,capture.py,llm.pyanddesign.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 holds0.1.0rc5permanently. 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