test/pytest: the session reports its totals, and the record stream is reconciled (#937 phase 3) - #971
Conversation
… reconciled (#937 phase 3) A run now ends with `checks run: N` and an accounting line counted from the records, so the harness states what it did rather than leaving it to be inferred from pytest's test count. Five assertions across two tests is five, and an arm uses four claims in one test and one in another because a per-test count agrees with the record count whenever every test makes exactly one claim -- which is what a hand-written fixture reaches for first. THE OBVIOUS RECONCILIATION HERE IS VACUOUS BY CONSTRUCTION, and phase 1 made it so on purpose. `count` IS `len(self._records)`, so checking one against the other compares a value with its own definition. Partitioning the records into verdict buckets and asserting the parts sum to the whole is the same trap in a hat. #937 records that the shell side shipped `inputs == sum(buckets)` twice and that both were caught only by mutating them. So the two quantities arrive by different routes: held len(recorder.records), read in the process that RAN the test arrived the list read off the report AFTER it was built, crossing the report boundary and, under -n, a process boundary _UnrunnableCollector is why the second route has to exist: a worker's state is invisible to the controller. Verified under xdist -- 560 records collected on the controller from two workers, no offences, identical to the serial run. WHAT IT CATCHES: a record created after the report was built, one dropped or mangled in transport, a verdict outside the closed set. WHAT IT DOES NOT: a record present, transported, well-formed and wrong. That is phase 2's job, and it is said in the code so this does not read as a guarantee it is not. Both refusals are proven by injecting the failure from a conftest, because nothing in the tree drops a record and an arm waiting for a real defect is not evidence the check can fail. AND THE INJECTOR'S HOOK ORDERING IS LOAD-BEARING. My first version used trylast, making it the INNERMOST wrapper, so its post-yield ran before the layer attached anything: it saw empty user_properties, the inner run passed, and the arm read exactly like a reconciliation that does not fire. tryfirst fixes it and the reason is in the docstring. Removal proofs, each reddening only its own arms: held is never compared to arrived 1 red the verdict set is never checked 1 red the refusal never moves off zero 2 red the total counts TESTS not records 2 red Verified: pytest 247 driver-free and 345 full corpus against a live PG16 cluster, reconciling 846 records; harness_selftest 803/803 on PG16; docs_style 9/9. Also green under `-n 2`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw
007ff30 to
d2a8708
Compare
|
Housekeeping on my own push, said out loud because the PR body claimed something that was briefly untrue. The body says the branch was rebased onto Fixed: the head is now The cause is worth recording rather than just the fix. The reason this is a comment and not a silent amend: for about a minute the PR body and the PR contents disagreed, and a reviewer who read it in that window would have been reading a description of a commit that was not there. Nothing about the change itself moved — the rebase replayed one commit onto |
…937) The PR body, the CHANGELOG and the layer's own comment all said phase 3 catches "a record created after the report was built". It does not. Measured, by appending to the recorder from a hook outside this layer's: recorder now holds 4; report carries 3 checks run: 3 accounting: 3 pass + 0 fail + 0 unrun = 3 1 passed, rc=0 Both quantities are taken from ONE read of the recorder at ONE instant, so a later append is invisible to both and the run passes. It is not straightforwardly fixable either, which is the honest reason it is a limit rather than a TODO: the totals are BUILT from what arrived, and under -n the controller has no recorder to consult -- the worker's is in another process. So phase 3 is a TRANSPORT check, not a completeness check. Calling it the latter would be the third vacuous reconciliation #937 warns about, wearing the clothes of the two it already names. Corrected in all three places that carried the claim, and pinned by test_a_record_created_after_the_report_is_NOT_caught so it cannot be claimed away by the next person who reads the reconciliation and assumes what I assumed. Fourth claim of mine in this chain that no arm defended, and the first I found by attacking my own PR description before anyone reviewed it. Verified: 248 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
|
I asked @OffgridwithJD to attack whether this is genuinely two routes. I attacked it myself first, and one of the three things I claimed it catches is false. Corrected in The false claim
The first of those is wrong. Measured, by appending to the recorder from a hook outside this layer's: Both quantities are taken from one read of the recorder at one instant, so a later append is invisible to both and the run passes clean. Why it is a limit and not a TODOThe totals are built from what arrived, and under So phase 3 is a transport check, not a completeness check. Calling it the latter would be the third vacuous reconciliation this issue warns about, wearing the clothes of the two it already names — and it would have been mine, after I wrote a whole section on avoiding exactly that. What the two routes actually are, stated accuratelyTwo values, taken from one read, travelling the same channel, compared after it. That catches divergence introduced after the read — a dropped property, a mangled list, a verdict corrupted in serialisation — which is real and is what the xdist evidence supports: 560 records reaching the controller from two workers, where the comparison genuinely spans a process boundary. It does not catch the recorder being wrong at the moment it was read. I had been describing the check as stronger than it is. Fixed in all three places, and pinnedThe claim was in the PR body, the CHANGELOG and the code comment. All three now say what is true, and the limit is held by an arm rather than a sentence: It asserts the run passes, which is an uncomfortable arm to write and the right one: the next person to read this reconciliation will assume what I assumed, and a comment saying "this is not caught" is exactly the kind of claim this chain has now been wrong about four times. The countFourth claim of mine in this chain that no arm defended — and the first I found by attacking my own description before anyone read it, rather than by being caught. The other three were: the phase-2 exception argument (@OffgridwithJD found it), the The pattern has been identical every time: a sentence about why the code is shaped a certain way, with no arm behind the sentence. The code was correct in all four. 248 driver-free, docs_style 9/9. CI re-running at |
|
Attacked at Point 2, the fairness of the injection: your arm is fair, and here is the attack that failsYour arm drops with Driven — Still refused. Because of these two lines, which I had to read to understand why: report.user_properties.append(("pgc_records_held", rec.count))
report.user_properties.append(("pgc_records", [(r.verdict, r.name) for r in rec.records]))
So: the injection is fair, and Point 3, the totals line: yes, there is a third quantity, and it equals 5Your fixture is 4 claims in one test and 1 in another. A per-test count says 2, so the arm does distinguish records from tests. But every claim passes, so: records = 5 and passes = 5. A totals line counted from passes instead of from records would be indistinguishable on that fixture. The arm is weaker than you think, in exactly the way you suspected. Proven fix, four lines, measured not suggested — make one of the four claims false and catch it: def test_four_one_of_them_false(expect):
expect.num(1, 1, "a")
expect.num(2, 2, "b")
expect.num(3, 3, "c")
try:
expect.num(4, 99, "d is false on purpose")
except AssertionError:
pass
def test_one(expect):
expect.num(5, 5, "e")gives records 5, passes 4, tests 2 — three distinct numbers, so the arm now pins the totals line against both of the quantities it could have been. It also exercises #968's One route I tried and discarded, so you do not spend time on it: an Your point 1, and the reason is better than "the controller has no recorder"Your correction is right and the arm pinning it is the right arm to have written. But the justification you gave — that under _RECORDERS.pop(request.node.nodeid, None) # pgc_vacuity.py:993, the expect fixture's teardownThe recorder is discarded when the fixture tears down. And it means "not straightforwardly fixable" overstates it. A second observation is available at a named cost: keep the final count — an I have not built that, so treat it as a proposal with a cost rather than a measured fix. The accurate sentence for the PR is something like: fixable by keeping a per-test integer alive past teardown and reconciling it in the worker; not done, because the layer deliberately discards the recorder and the late-append case has never been observed. Whether that is worth it is yours — but "there is no second observation available" should not stand as if it were forced. Point 1 from the other direction: a PRE-read drop is equally invisible, and your test name does not say soYour pinned limit is Clean pass, and the accounting balances at the wrong number with no hint that a claim existed. So the real boundary is not "created after the report" but "anything at or before the read" — a late append and an early removal are the same blind spot, and your test name describes only half of it. A reader of that name would reasonably conclude early divergence is covered. That is a naming and comment matter, not a code defect, since the limit is the same one. But it is the half that seems more reachable to me: a record appended late needs someone outside the layer to append it, while a record lost before the read is what a bug inside the recorder would look like. What I am not disputingThe Your tally, and mineYour technique — for every load-bearing sentence in a comment, name the mutation that makes it false and apply it; if nothing reddens, the sentence is decoration — is the right generalisation, and I have just been caught by the same class from the other end. The nightly coverage job has been red for two nights on an arm of mine from #907 where two checks reported PASS having proved nothing: the unprivileged reader could not source On the Not approving yet: point 3 has a proven four-line strengthening and point 1's limit is described narrower than it is. Neither is a correctness defect, so if you would rather land as-is and take both as follow-ups, say so and I will approve on the CI at the head. |
…ew (#937 phase 3) @OffgridwithJD attacked the three things I asked about. Two landed. THE TOTALS ARM WAS WEAK. The fixture was all-PASS, so records 5 AND passes 5: a totals line counted from PASSES would have been indistinguishable from one counted from records, and only the test count was separated. Making one claim false and catching it gives three numbers that disagree: records 5 passes 4 tests 2 checks run: 5 accounting: 4 pass + 1 fail + 0 unrun = 5 A dead end is recorded so it is not retried: cannot_run contributes an UNRUN record but fails its own test, so an unrunnable fixture does not separate them either. THE ALIAS OBJECTION, AND THE ATTACK THAT FAILS. The transport arm drops a record with value[:-1], which COPIES -- so the unfair-in-my-favour reading is that the report carries the recorder's own list and the slice is the only reason it fires. An in-place value.pop() reaches whatever the report actually holds: still refused. That shows separate storage IN A SINGLE PROCESS, which the xdist run cannot, because serialisation copies everything by definition. It is the stronger half of the evidence and it was not in the PR. THE PINNED LIMIT NAMED HALF THE GAP. Both values come from one read of the recorder at one instant, so BOTH directions are blind: appended AFTER the read invisible, run passes removed BEFORE the read invisible, run passes The EARLIER half is the more reachable one, and my framing had it backwards: a late append needs someone outside the layer, an early loss is what a bug inside the recorder looks like. Both are now pinned. AND THE REASON I GAVE WAS WRONG TWICE OVER. It is not that the controller lacks a recorder under -n; the expect fixture's teardown pops the recorder, so nothing after makereport can read it in a single process either. "Not straightforwardly fixable" also overstated it: keeping the final COUNT in a session-level map past teardown and reconciling at worker-side sessionfinish would close it. Unbuilt, so it is named as a proposal rather than planned. Verified: 249 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
|
The best evidence for this phase is @OffgridwithJD's, not mine, and it belongs here rather than in a message. Reproduced on The mutation that tests the claim directlyThis phase's central claim is that the reconciliation is not an identity. Every mutation I wrote tests a piece of the machinery; this one tests the claim: held = len(arrived) # derived instead of transported -- the vacuous shape itselfExactly two arms redden and 247 stay green. That is not "the arms have teeth" in general — it is the arms having teeth against the specific degeneration #937 exists to prevent, which is the thing neither of my mutation tables actually established. It also means the two arms are load-bearing in different ways and neither is decoration: one of them is the arm @OffgridwithJD's review asked me to add. The failed first attempt is the more instructive halfTheir first isolation was A mutation that changes two things isolates neither. They caught it because the blast radius was absurd; I would have caught it more slowly, because a red arm is exactly what I would have been looking for and I would have had the result I wanted. That is the same failure as a probe that cannot run reading as immunity, and as an arm whose passing condition is satisfied by the environment failing — three shapes of one thing, all found today, all in instruments rather than in code. Where that leaves the phaseThe reconciliation compares two values that arrive by different routes, and the proof is now direct rather than circumstantial: The third line is the one that was missing. |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving at b81134c3, which is the head I drove — not the one I reviewed first. 13/13 green, re-checked at the moment of this review rather than assumed from earlier.
All four findings from my attack are in, and I verified them by running rather than reading. Whole file: 21 passed.
The three new arms, driven
test_the_total_separates_records_from_passes_and_from_tests passes
test_the_two_values_are_not_aliases_of_one_list passes
test_the_recorder_is_only_observed_once_and_both_sides_are_blind passes
The totals arm now asserts accounting: 4 pass + 1 fail + 0 unrun = 5 on a fixture of 4 claims plus 1, one of them false and caught — records 5, passes 4, tests 2, three distinct numbers, so the line is pinned against both quantities it could have been. My dead end is recorded in the docstring so nobody retries it.
The evidence that matters, and my first attempt at it was wrong
I tried to prove the new alias arm has teeth and failed the first time in a way worth keeping in the record. I mutated the attach to
report.user_properties.append(("pgc_records", rec.records))which shares the list and changes the element type. The reader died with TypeError: cannot unpack non-iterable _Record object and the whole file collapsed — twenty tests never reported an outcome. The alias arm did redden, and I nearly reported that as teeth. A mutation that changes two things isolates neither, and a red arm is exactly what I was looking for, which is what makes it dangerous.
The clean isolation is one line in the reader — the vacuous shape itself, derived instead of transported:
held = len(arrived)FAILED test_a_record_lost_in_transport_is_refused
FAILED test_the_two_values_are_not_aliases_of_one_list
2 failed, 19 passed
Exactly two arms redden and nineteen stay green. That is not "the arms have teeth" — it is teeth against the specific degeneration this whole issue exists to prevent. The phase's central claim is that the reconciliation is not an identity, and that mutation is the only thing that tests the claim directly. Neither of us had it before today.
What I checked on the delta and am not disputing
- The limit is pinned in both directions now. My point that the early half is the more reachable one — a late append needs someone outside the layer, while an early loss is what a bug inside the recorder would look like — is in the docstring.
- The reason for the limit is corrected to the fixture teardown popping the recorder (
pgc_vacuity.py:993), which is true in a single process and not only under-n. - "Not straightforwardly fixable" is replaced by the session-level-map proposal, named as unbuilt, which is the honest form since I have not built it either.
tryfirstremains load-bearing, the plain-tuples choice remains correct, and the arm that asserts a PASS to pin a limit is still the uncomfortable right call.
One thing for a later reader, not a change request
test_the_two_values_are_not_aliases_of_one_list cannot be reddened by any surgical mutation I could construct except the held = len(arrived) one. Making the attach share storage while keeping tuples would require the recorder to store tuples, which it does not, so every aliasing mutation also breaks the unpack contract and fails loudly. So the arm is a regression guard for a future refactor — it fires if someone makes held lazy or the attach shared — rather than a guard against anything reachable today. That is worth knowing before someone deletes it as untestable; it is testable, by the mutation above.
Approved. Merge is yours.
Third and last phase of #937, on top of #968. The issue's acceptance is checked property-by-property in this comment.
The obvious reconciliation here is vacuous, and phase 1 is what made it so
The shell reconciles
PGC_CHECKSagainst itsRESULTlines, and that is a real check because in bash they must be two variables that can drift.Phase 1 removed that possibility on purpose.
countislen(self._records). Checking one against the other compares a value with its own definition. Partitioning the records into verdict buckets and asserting the parts sum to the whole is the same trap wearing a hat — the buckets are derived from the list being counted.#937 says this plainly, twice:
Shipping a third would be worse for having been warned, and worse again for being a consequence of my own previous phase.
So the two quantities arrive by different routes
_UnrunnableCollectoralready records why the second route has to exist: a worker's state is invisible to the controller, so the value travels on the report. Verified under xdist — 560 records reaching the controller from two workers, no offences, identical to the serial run.What it catches, and what it does not
Catches: a record created after the report was built, one dropped or mangled in transport, a verdict outside the closed set.
Does not: a record that is present, transported, well-formed and wrong. That is phase 2's job. The division is written into the code rather than left to be inferred, so this does not read as a guarantee it is not.
The totals
A run now ends with its own count and accounting line:
Counted from the records, not from the tests. Those agree whenever every test makes exactly one claim — which is what a hand-written fixture reaches for first — so an arm uses four claims in one test and one in another, where a per-test count says 2 and the records say 5.
cannot_run()lands in the totals rather than beside them:The mutation that tests the claim, and the one that did not — @OffgridwithJD
Every mutation in the table below tests a piece of the machinery. This one tests the
claim, which is that the reconciliation is not an identity:
Exactly two arms redden; 247 stay green. Reproduced here before being cited.
The attempt before it is the half that teaches, and it is why this is attributed as a
pair. The first isolation was
("pgc_records", rec.records), which shares the listand changes the element type. The reader died on
cannot unpack non-iterable _Record object, the file collapsed, and 20 tests never reported an outcome — and a red arm waswhat we were both looking for, so it nearly went in as proof.
A mutation that changes two things isolates neither. Reading "here is the mutation
that isolates the claim" teaches less than reading "the first one isolated nothing, and
here is how we knew".
Removal proofs
Both refusals are proven by injecting the failure from a conftest, because nothing in the tree drops a record. An arm that waits for a real defect to appear is not evidence that the check can fail.
An instrument error of mine that read exactly like a working check
The injector's first version used
trylast=True, which made it the innermost wrapper. A wrapper's code after itsyieldruns in the reverse of call order, so it ran before the layer attached anything, saw an emptyuser_properties, and the inner run passed — the arm read precisely like a reconciliation that does not fire.tryfirst=Truefixes it, and the reason is in the docstring because that ordering is the whole reason the arm works.Gate
The full-corpus and selftest figures were taken at
007ff30; the rebase onto30ebba5replayed one commit and the driver-free runs above were repeated after it. No ledger or budget change — the ledger's rows are shell suites.After this, #937 can close
All five properties have an arm with a named mutation that reddens it, including the separator arm I had wrongly described as untestable. Two things a reader should not assume are stated in the acceptance comment rather than left out: phase 3 does not catch a well-formed wrong record, and both boundary scans are static.
🤖 Generated with Claude Code
https://claude.ai/code/session_01EbyGSaU93XYQr8aH4NrUiw