test/pytest: resolve each record's verdict where the outcome is known (#937 phase 2) - #968
Conversation
…#937 phase 2) Phase 1 left every record PASS. The plan was to resolve the verdict from the exception in pytest_runtest_call, and @OffgridwithJD refuted that: proving a guard REFUSES means catching the AssertionError, which five tests in this corpus do (test_ordered.py:243, test_failed_query_sentinel.py:236, :326, :357, :382). Measured: count before/mid/after: 0 / 1 / 2 record 0 'this comparison must fail' verdict PASS <- this RAISED record 1 'and the test continues' verdict PASS 1 passed A genuinely failed assertion stayed PASS, in a passing test, with no exception reaching the hook. So the resolution happens INSIDE the assertion call, before any except in the test body can see the error. A WRAPPER, NOT A VERDICT PASSED AT THE CALL SITE. outcomes and refusal delegate to pytest's assert_outcomes, which raises a message this layer never composes -- there is no verdict for the call site to pass. The wrapper covers those without the methods knowing they are wrapped, and keeps one operation at every site. A REFUSAL MARKS NOTHING, with no special case for VacuityError being an AssertionError subclass: every VacuityError is raised BEFORE its record is taken, so no record exists to mark. Scanned, not trusted. AND ONE CLAIM IN THIS CHANGE WAS WRONG UNTIL A MUTATION CAUGHT IT. The comment said the index form was needed to survive nesting. Replacing self._records[taken] with self._records[-1] left all 235 tests green, because one call appends at most one record and the two always name it. The comment now says so, and the invariant it rests on is pinned by an arm instead of assumed -- a method that records twice reddens it. Removal proofs: the verdict is never set 3 red the reason is never recorded 1 red one method loses the wrapper 1 red (the drift arm, derived not listed) [-1] instead of [taken] green, correctly -- see above a method that records twice 2 red Verified: pytest 236 driver-free, docs_style 9/9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
…terals (#937 phase 2) The arm asserted that no VacuityError is raised after its record is taken, which is what keeps refusals out of the record stream without a special case for VacuityError being an AssertionError subclass. If one lands inside the wrapped region it is recorded as a FAILED ASSERTION -- the stream lying in the most misleading direction available. The scan looked for a literal `raise VacuityError(...)` inside each method body. Most of these methods refuse through a HELPER (_refuse_failed_query), so a refusal moved after the record would have been invisible to it. Measured while attacking my own arm: methods that raise VacuityError directly 17 methods that can raise it, following calls 18 One method short of the real population. Both scans agree on today's code -- nothing calls a refusing helper after its record -- so this changes no verdict. It changes what the arm can see. Removal proof, the case the old scan could not catch: num() calling self._refuse_failed_query() after its record. old arm (literals only) would not have fired new arm 1 red, naming num:420 Verified: 242 driver-free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
|
Pushed I asked @OffgridwithJD to look hardest at the refusal boundary, on the grounds that my scan is AST-based and might miss a refusal reaching the wrapped region by a path the walk does not model. Rather than wait, I went after it — and the specific worry was right. What the arm could not see
Most of these methods do not refuse with a literal. They call The scan was one method short of the real population, and would not have fired on a refusal moved after a record if it arrived through the helper — which is how most of them arrive. It changes no verdict, only what the arm can seeBoth scans agree on today's code: nothing calls a refusing helper after its own record, verified by closing over the call graph. So this is not a fix to a defect on this branch. It is a guard that would not have guarded. Removal proof, the case the old scan could not catch
The message names the site, because a boundary violation that says only "a refusal is in the wrong place" sends the reader through fifteen methods. Why it matters more than the verdict it does not changeThe failure this prevents is silent. A refusal recorded as a failed assertion is not a crash, not a red test, and not something any other arm in the corpus looks at — it is a record stream that reports a refusal as a real failure, which is exactly the confusion the record stream exists to remove. A guard against a silent failure that itself fails silently is worth less than no guard, because it stops the next person checking. Third instance today of the same thing: a claim defended by a sentence rather than by an arm. The first two were mine in comments; this one was mine in a test. |
|
Reviewed at The hole I found in phase 1's argument is closedMy exact probe, at this head: A swallowed Attack 1, the boundary — we convergedYou found your scan was literal-only and pushed So the property holds under a stronger analysis than the one that shipped, and your 17-vs-18 is the same observation — most of these refuse through What neither analysis covers, stated because it is the residue: both are static. A refusal reaching the wrapped region by dynamic dispatch, or from outside this module, is invisible to both. I do not think that is reachable today and I have not proved it. Attack 3, the population — 15 is right
My first pass flagged Attack 2, the drift arm — partially verifiedAll 15 recorders carry the decorator and none is missed. I did not test your two worries — a foreign wrapper carrying Gate at this headMatching yours. On shipping a test that could not failYou called that worse than the two comment claims, and I agree, for the reason you gave: it is the specific failure this layer exists to refuse. Worth adding that it was caught the way the layer's own discipline says to catch it — by mutating the thing the arm claims to defend ( Approving once CI reports 13. And noted on your |
…#937 phase 2) @OffgridwithJD attacked the drift arm and named double-wrapping as the gap most likely to be reached: the marker sits on the outer wrapper, so a method wrapped twice is indistinguishable from one wrapped once. True, and it does not matter -- which is the answer rather than an excuse, and it is measured rather than argued: after double-wrapping @_resolving record 0 verdict FAIL 'a claim that is false: got 1 want 2' record 1 verdict PASS count 2 Both wrappers compute the same `taken` and resolve the same record to the same verdict and reason, because the inner call appends nothing before the outer one measures. The arm is NOT extended to catch it. A guard against a change that alters no behaviour is a false red waiting to happen, and this layer's budget forbids those more strictly than it forbids a gap. The gap is now pinned by a test that asserts the behaviour is identical, so if that ever stops being true the arm reddens for a reason that matters. "I think it is harmless" is the sentence that has been wrong three times today, so it is not the sentence shipped. Verified: 243 driver-free, docs_style 9/9. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving at cc77934c, which is not the head I reviewed. I reviewed 3b3469f2; the new commit answers the attack I made on the drift arm, so I re-reviewed the delta rather than letting the earlier read carry. CI 13/13 at cc77934c, re-checked after this review.
You answered the double-wrap gap by measuring it, and I verified the measurement has teeth
The claim is "wrapping twice changes nothing", and my worry was that a test asserting a no-op cannot fail. It can. I mutated _resolving so that only a double wrap behaves differently — the second resolution flips the verdict instead of setting it:
rec.verdict = "PASS" if rec.verdict == "FAIL" else "FAIL"A singly-wrapped method is unaffected by that, so nothing else should notice. Driven at cc77934c, PG18, REFvenv pytest 9.1.1:
the double-wrap arm alone FAILED
a doubly-wrapped method resolves exactly as a single one does:
first difference at position 0: got 'PASS' want 'FAIL'
every other arm in test_check_records 14 passed, 1 deselected
the whole driver-free corpus 2 failed, 339 passed
The second failure is test_harness_deps.py::test_the_guard_half_of_the_corpus_runs_without_a_database_driver, which re-runs the guard half in a driver-free subprocess and therefore reports the same single red a second time. It passes on the unmutated head (1 passed in 7.69s), so it is an echo, not a second detection.
So: the new arm is the only thing in 340 tests that sees a double-wrap-only change. That is a real guard, and your decision not to extend the drift arm to catch it is the right one — the drift arm would have had to assert something about a wrapper count, which is a shape claim, where this asserts the consequence.
It is also driver-free (0.02s, no cluster), so it runs in the half of the corpus that has no database.
What I checked on the delta
TESTS.mdgains the row, so the table still enumerates the file. That is the rule that has caught three of my own omissions.- The test builds its own
Expect("double::wrapped")rather than using theexpectfixture, so its two records do not land in the real test's stream. Expect.numis restored in afinally, and the patch is the statement immediately before thetry, so there is no window where an exception leaves the class mutated for the rest of the session.- The docstring's "both wrappers compute the same
taken" is not what the test asserts — it asserts the verdict sequence and the record count, which are the observable consequences. That is the right choice and the comment is accurate about the mechanism.
One thing I am not asking you to change
expect.num(e.count, 2, ...) pins one record per call for this path, which the adjacent test_a_recording_method_takes_exactly_one_record_per_call already pins for all fifteen methods. The overlap is deliberate here — the count is half of what makes the double-wrap claim falsifiable, not a redundant assertion — so I mention it only so a later reader does not delete it as duplication.
Approved. Merge is yours.
Second phase of #937, on top of #966. Phase 1 left every record
PASS; this resolves the verdict.The design I stated in #966 was wrong, and @OffgridwithJD refuted it
I planned to resolve each verdict from the exception in
pytest_runtest_call, arguing that assertions are sequential and a raise ends the test, so the failing assertion would be the last record.This corpus is what makes that false. Proving a guard refuses means catching the
AssertionError, which five tests do —test_ordered.py:243,test_failed_query_sentinel.py:236,:326,:357,:382. Measured:A genuinely failed assertion stayed
PASS, in a passing test, with no exception reaching the hook to correct it. Worse than "the last record is not the failing one", which at least leaves something to fix.So the resolution happens inside the assertion call, before any
exceptin the test body can see the error.A wrapper, not a verdict passed at the call site
outcomesandrefusaldelegate to pytest's ownassert_outcomes, which raises a message this layer never composes — there is no verdict for the call site to pass. That is the case a call-site verdict could not cover, and it is why_resolvingwraps the comparison rather than describing it. One operation per site is preserved; the methods do not know they are wrapped.@_resolvingis on all 15 recording methods, and the list cannot drift:test_every_recording_method_resolves_its_verdictderives the population from the module — every method that calls_record— rather than from a list here, because a list would have to be updated by whoever adds the sixteenth, who is exactly the person who would forget.A refusal marks nothing, with no special case
VacuityErrorsubclassesAssertionError, so the obvious implementation would record every refusal as a failed assertion. It does not need a special case: everyVacuityErroris raised before its record is taken, so no record exists to mark.That is scanned rather than trusted.
test_every_refusal_precedes_its_recordwalks the module and reddens if anyone adds one after — the failure it prevents is silent, since nothing else would notice a refusal appearing in the stream as a failure.One claim in this change was wrong until a mutation caught it
The comment said the index form (
self._records[taken]) was needed to survive nesting, sincerow_setdelegates torows. Mutating it toself._records[-1]:Nothing distinguishes them.
row_settakes no record of its own, so one call appends at most one record and both expressions always name it. The comment asserted a difference that does not exist, and no arm defended it.I kept
[taken]— it stays correct if that stops being true — corrected the comment to say the two are equivalent today, and pinned the invariant it rests on rather than leaving it assumed:That is the second unsupported claim of mine in this chain, both in comments rather than code. The first was the phase-2 argument above.
Removal proofs
Gate
The corpus and selftest figures were taken at
3c1e40b, before the rebase onto4a21f21; the rebase was a clean replay of one commit and the driver-free run was repeated after it (242). No ledger or budget change — the ledger's rows are shell suites.What is left in #937
Phase 3, the session reconciliation. The issue is explicit that a reconciliation which cannot fail has shipped twice on the shell side, so that one gets attacked before it is believed rather than landing quietly beside this.
Not in scope, and on main today
#967 — a two-line conftest stubbing
Expect.nummakes a false claim report as a pass. @OffgridwithJD measured it on3f0aa0fwithout either #937 phase, so it is neither introduced nor worsened here. It is worth knowing while reading this PR that_resolvingcentralises the assertion path, which reduces that exploit's cost from one stub per method kind to one stub for all of them — recorded on #967 rather than discovered later.🤖 Generated with Claude Code
https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw