fix: the matrix summary printed a negative count of suites (#999, #1006) - #1110
Conversation
16e108f to
bf26baa
Compare
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving bf26baa. Green on all 14, and the set-difference framing is the right fix — a residual that cannot be negative beats one that is checked for being negative.
One finding, not blocking, and half of it predates you: the three comm helpers do not pin their collation, and the guard that exists to catch exactly that cannot see them.
What I verified
- The 29 new ledger rows carry all five majors. You did not hit #1071, which #1107 did an hour earlier with
15;16;17;18. - The census reconciles.
awk -F'\t' '$5=="never"' | wc -lgives 1306, matching the budget.suites_not_coveredstays 249 on both sides. expected_tests.txt346 → 355, re-derived by collection rather than by adding nine, and the new file is declared intest_harness_deps.pyin both theNO_CLUSTERlist and the reach map.- The partition is now a measurement.
ran ∩ accountedandran − accountedare both counted from the names, and they sum tosuites_ranby construction rather than by arithmetic. A negative is unrepresentable. - Recording the name beside all four increments, and truncating the per-major files rather than appending, is the right shape — the
suites_incomplete=0precedent you cite is exactly the failure that would otherwise make PG16 report PG15's suites.
The "2 of 14 arms passed against an absent reader" finding in your body is the best part of this PR. command not found writing nothing and grep -c . reporting 0 is the same shape as the empty-log hole I just fixed in #1106 — an absence that satisfies the assertion.
The finding: comm without a pinned collation
pgc_own_mechanism_suites() { comm -13 <(sort "$1") <(sort "$2"); } # pre-existing
pgc_ran_without_accounting() { comm -23 <(sort "$1") <(sort "$2"); } # new
pgc_accounted_among() { comm -12 <(sort "$1") <(sort "$2"); } # newThis same file already does it correctly, 500 lines away:
LC_ALL=C sort -u "$_reg" >"$_rf"
LC_ALL=C comm -23 "$_rf" "$_af" >"$_t1"Real suite names do reorder. Ten actual names, two collations:
LC_ALL=C ... pg_dump_roundtrip pgc_setup ... projection_update projections
LC_ALL=en_US ... pgc_setup pg_dump_roundtrip ... projections projection_update
Two pairs swap. And comm says so itself when fed en_US-sorted input:
comm: file 1 is not in sorted order
comm: file 2 is not in sorted order
comm: input is not in sorted order
Not live on the gate, and I checked rather than assumed: the container and the workflows set no locale, the container is C.UTF-8, and C.UTF-8 orders these identically to C. It bites a developer whose box is en_US.UTF-8, which is the default on plenty of them.
And selftest 070 cannot see it
The guard from #552 requires every | sort in a file using comm to carry LC_ALL=C. Its pattern is \|[[:space:]]*sort — a pipe. Measured on this file:
matches for '| sort' : 0
occurrences of '<(sort ...)': 3
selftest 070 verdict : run_all_versions.sh passes the guard
So a file that uses comm three times reads as compliant because it never pipes into sort. The guard's own comment notices the distinction — "The first reads a FILE and is not a pipeline at all" — but the regex only covers the pipeline half.
I hit the other half of this yesterday: adding a comm to docs_style.sh made selftest 070 flag five pre-existing piped sorts, correctly. The process-substitution form gets a free pass.
What I would do, and it is your call
Pinning the two new helpers is three characters each and matches the convention already in the file. pgc_own_mechanism_suites has the same gap and is not yours, so widening selftest 070 to reach <(sort is arguably its own change with its own removal proof.
I am approving rather than blocking because it is latent on every environment the gate runs in, and because the pre-existing helper means this is not a defect this PR introduced so much as one it joins. If you would rather take the three-line pin here and leave the guard for a follow-up, that reads well to me too.
|
Taking the pin. Thank you — I verified the reordering myself before acting on it, and it reproduces here: Two pairs swap, exactly the ones you named. I pinned all three, not just my two
LC_ALL=C comm -23 <(LC_ALL=C sort "$1") <(LC_ALL=C sort "$2")Both halves, because the prefix does not reach the substitutionsWorth stating since it is the trap inside the trap: This also matches the convention already in this file — lines 1154 through 1243 all use Arms, and the scope call you offeredThree arms in part 510, over The premise arm is there because the other two are "count is zero" checks, which a file containing no I took your second option for the guard itself: #1112 filed to widen selftest 070 to reach Tree-wide state after this change:
CostPart 510 gaining three checks means |
3a3da25 to
e3da397
Compare
Every PG 17 matrix report on main printed a number that cannot exist:
suites that ran: 243 of 252 (skipped: 9, incomplete: 0)
of those, 248 accounted for their checks and -5 did not
PG 18 printed -2 the same day, from 246 ran. Both sessions filed this
independently off the same runs.
THE TWO TERMS COUNTED DIFFERENT POPULATIONS. `suites_ran` excludes a
skipped suite. `_acc_any` counts every registered suite whose log shows an
accounting line, and a skipped suite still prints one, because
`pgc_summary` emits it on every exit path before it decides the status.
NOTHING CAUGHT IT BECAUSE THE RESIDUAL WAS DERIVED. 248 + (-5) = 243, so
an `inputs == sum(buckets)` arm passes on that line whatever the numbers
are. That is the error `pgc_summary` warns about eight lines below its own
counter, committed one level up.
The residual is now a set difference over the names the run recorded, and
the other bucket is the intersection, so neither is the other's leftover
and a negative is unrepresentable rather than merely detected.
`pgc_tally_suite` records the name of every suite beside the counter it
increments, and the skipped-but-accounted suites get their own named
category rather than being folded into a number phrased as a problem.
Guarded twice, independently: selftest part 510 and
test/pytest/test_residual_is_counted.py. Both drive the readers AND the
summary block extracted from the runner rather than grepping its text --
proving a reader correct says nothing about what the summary prints with
it. Removal proof: four mutations, each reddening named checks, each
mutant asserted to still parse, control restored to 1005/0.
Ledger rows for the 29 new checks derived from five majors, all uniform
15;16;17;18;19. The five logs were verified to carry 1005 records each,
an identical check-name hash, and each its own major.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
`test_harness_deps.py` keeps an inventory of the pytest files that reach
into the shell harness, and asserts it is EXACTLY what the corpus does, in
both directions. The new twin reads `run_all_versions.sh` to extract its
two set readers and the summary block, then executes them, so it belongs
in that inventory and the arm named it:
the files that reach into the shell harness are exactly the declared
ones: got [... 'test_residual_is_counted.py' ...] want [...]
The entry names the MECHANISM rather than a path, per the note above the
table: an earlier version spelled a path in a description and the detector
then flagged the declaring file for its own inventory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
Found by @OffgridwithJD in review. `comm` needs both inputs sorted in ITS collation, and `sort` orders by locale. Real suite names reorder: measured here, `pgc_setup`/`pg_dump_roundtrip` and `projections`/`projection_update` both swap between `C` and `en_US.UTF-8`. Fed a mismatch, `comm` writes `input is not in sorted order` to stderr and prints a result anyway -- so in a harness whose stderr lands in a log nobody reads, a wrong set arrives looking like an answer. THE PREFIX ON `comm` IS NOT ENOUGH. Process substitutions run in subshells of the PARENT and inherit its locale, not comm's, so `LC_ALL=C comm <(sort ...)` still sorts on the caller's locale. Both halves are pinned. All three readers, not the two this branch added. `pgc_own_mechanism_suites` has the same gap and predates this work, and leaving one unpinned helper beside two pinned ones is a worse state than either -- the next reader cannot tell whether the odd one out is deliberate. Lines 1154 to 1243 of the same file already use `LC_ALL=C comm`, so the three were the outliers rather than the precedent. Latent, not live: the container and CI run C.UTF-8, which orders these as C does, and pinned and unpinned produce identical output there. The guard that should have caught it reads only the piped form and cannot see process substitution; filed as #1112. Three arms here cover this file. Ledger rows for the three new checks derived from five majors on ONE frozen snapshot, all uniform 15;16;17;18;19. The previous run copied the host tree per major, so an edit made mid-loop reached the later legs only and PG19 went red alone while the check-name hashes matched -- which read exactly like a major-specific defect and was not. The runner now snapshots once and the verification compares a verdict hash as well as a name hash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
e3da397 to
2c5095d
Compare
…1112) `test/selftest/070` required every `sort` feeding a `comm` to carry `LC_ALL=C`, and its pattern was a PIPE. A process-substituted sort is not a pipeline, so `run_all_versions.sh` used `comm` three times through substitutions, matched zero times, and read as compliant. One of the three had been there since #928. Found by @OffgridwithJD reviewing #1110. PINNING THE COMM IS NOT ENOUGH, and that is the half that changes the fix rather than its description. `LC_ALL=C comm <(sort a) <(sort b)` pins only comm's own comparison: the substitutions run in subshells of the PARENT and inherit its locale. A guard accepting `LC_ALL=C` anywhere on the line would bless exactly the form a reader writes after reading the guard's name, so the two halves are separate checks. AND IT READS CODE ONLY. The guard scanned every line, prose included, so a comment explaining the rule violated it: a note reading "reads only the piped form" contained the literal string it grepped for and flagged its own file. A rule that cannot be written down is a rule people stop writing down. Both corpus arms report ZERO on this tree, measured before the change, so the detector is proved by PLANTING: the substituted form, the piped form, a half-pinned line, a pinned comm over unpinned sorts, and the two forms that must not be flagged. The fifth matters as much as the first four, or the detector could be "flag everything" and every other arm still passes. Removal proof: reader reverts to the pipe-only pattern 3 arms red reader stops skipping comments 1 arm red, naming the file comm reader stops requiring a pin 2 arms red, naming three files control 1033 passed + 0 failed A FOURTH MUTATION DELETED A LINE OF MY OWN AND NOTHING CHANGED. The draft substituted the pinned form away before matching; removing that left nine arms green, so it was dead -- both patterns require `sort` immediately after the `|` or the `<(`, which makes a mixed line fail without it. Removed, with the reasoning recorded where the line was. Both harnesses, independent: part 070 and test/pytest/test_collation_pinned.py, which walks the same corpus in Python and additionally pins that `command` is not a `comm`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
…1112) `test/selftest/070` required every `sort` feeding a `comm` to carry `LC_ALL=C`, and its pattern was a PIPE. A process-substituted sort is not a pipeline, so `run_all_versions.sh` used `comm` three times through substitutions, matched zero times, and read as compliant. One of the three had been there since #928. Found by @OffgridwithJD reviewing #1110. PINNING THE COMM IS NOT ENOUGH, and that is the half that changes the fix rather than its description. `LC_ALL=C comm <(sort a) <(sort b)` pins only comm's own comparison: the substitutions run in subshells of the PARENT and inherit its locale. A guard accepting `LC_ALL=C` anywhere on the line would bless exactly the form a reader writes after reading the guard's name, so the two halves are separate checks. AND IT READS CODE ONLY. The guard scanned every line, prose included, so a comment explaining the rule violated it: a note reading "reads only the piped form" contained the literal string it grepped for and flagged its own file. A rule that cannot be written down is a rule people stop writing down. Both corpus arms report ZERO on this tree, measured before the change, so the detector is proved by PLANTING: the substituted form, the piped form, a half-pinned line, a pinned comm over unpinned sorts, and the two forms that must not be flagged. The fifth matters as much as the first four, or the detector could be "flag everything" and every other arm still passes. Removal proof: reader reverts to the pipe-only pattern 3 arms red reader stops skipping comments 1 arm red, naming the file comm reader stops requiring a pin 2 arms red, naming three files control 1033 passed + 0 failed A FOURTH MUTATION DELETED A LINE OF MY OWN AND NOTHING CHANGED. The draft substituted the pinned form away before matching; removing that left nine arms green, so it was dead -- both patterns require `sort` immediately after the `|` or the `<(`, which makes a mixed line fail without it. Removed, with the reasoning recorded where the line was. Both harnesses, independent: part 070 and test/pytest/test_collation_pinned.py, which walks the same corpus in Python and additionally pins that `command` is not a `comm`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
`test_harness_deps.py` keeps an inventory of the pytest files that read the shell harness and asserts it is EXACTLY what the corpus does, in both directions. The new twin reads `run_all_versions.sh` to check the guard is called and acts on its answer, so it belongs in that inventory and the arm named it. Second time this has caught a file of mine on the same day (#1110 was the first), which is the arm doing its job rather than a nuisance: a twin that reaches into the other harness and does not say so is the thing the inventory exists to surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
`test_harness_deps.py` keeps an inventory of the pytest files that read the shell harness and asserts it is EXACTLY what the corpus does, in both directions. The new twin reads `run_all_versions.sh` to check the guard is called and acts on its answer, so it belongs in that inventory and the arm named it. Second time this has caught a file of mine on the same day (#1110 was the first), which is the arm doing its job rather than a nuisance: a twin that reaches into the other harness and does not say so is the thing the inventory exists to surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
Closes #999. Closes #1006.
Both sessions filed this independently off the same runs, which is the strongest evidence it was worth fixing.
The defect
Every PG 17 matrix report on
mainprinted a number that cannot exist:PG 18 printed
-2the same day, from 246 ran.The two terms count different populations.
suites_ranexcludes a skipped suite._acc_anycounts every registered suite whose log shows an accounting line — and a skipped suite still prints one, becausepgc_summaryemits it on every exit path before it decides the status.Nothing caught it because the residual was derived.
248 + (-5) = 243, so aninputs == sum(buckets)arm passes on that line whatever the numbers are. That is the errorpgc_summarywarns about eight lines below its own counter:The suite-level breakdown committed exactly the error the check-level accounting was written to prevent.
The fix
The residual is a set difference over the names the run recorded, and the other bucket is the intersection — so neither is the other's leftover,
inputs == sum(buckets)becomes a measurement, and a negative is unrepresentable rather than merely detected.pgc_tally_suitenow records the name of every suite beside the counter it increments, at all four sites. The skipped-but-accounted suites are printed as their own named category, which is what was being folded into a number phrased as a problem.Proof
Red first, and the first draft of the test was wrong. 14 arms written against absent readers: 12 went red, 2 passed.
command not foundwrites nothing to stdout andgrep -c .reports 0, exactly as a clean residual does — so the two arms that were the point of the fix were satisfied by the absence of their subject. They are now asserted as a pair,0/2, which a missing reader fails.The wiring, not only the readers. The summary block is extracted from the runner and run against a fixture shaped like the live defect. Proving a reader correct says nothing about what the summary prints with it, which is the half a previous removal proof in this tree missed entirely. The extraction is asserted non-empty and asserted to end at its own
fibefore it is used for anything.That end-to-end arm found a second defect in my own fix:
${_acc_debt% }has no leading space, unlike the neighbouringskipped_names, so the colon abutted the first name. A grep-based arm would not have seen it.Removal proof — four mutations, each mutant asserted to still parse:
Mutation 3 is the one that matters: restoring the subtraction reddens a behavioural arm, not a grep.
Both harnesses, independent
test/selftest/510-a-residual-must-be-counted.shandtest/pytest/test_residual_is_counted.pyassert the same properties through their own harnesses, with their own fixtures, and name each other nowhere.Two existing guards moved, and neither was weakened
pgc_tally_suitedirectly underset -u, so the new globals aborted it —_acc_ranfile: unbound variable. The fixture already suppliessuites_ran,verfailand four more; it now supplies these two the same way. It also gained two arms asserting the name actually lands, so the coupling became coverage rather than a patch to get green._acc_accounted, never from_acc_observed— plus two arms that the reworded line is what a reader sees.The ledger
29 new checks in a covered suite, so they need rows, and a row is a matrix-wide claim. Derived from five separate runs, not copied from neighbours:
Per-major build trees, because one shared tree previously let five runs clobber each other's objects while four loaded the wrong major's
.so.checks_never_observed_redre-derived 1277 -> 1306 by two independent instruments: the budget file's ownawkrecipe and the gate's census, which agreed.suites_not_covereddoes not move: no suite was added.The ledger and budget auto-merged silently on the rebase. Both were re-derived on the rebased tree afterwards rather than trusted, because a silent auto-merge of those two files has been wrong here before.
What this does not fix
#1071is adjacent and still open:pgc_ledgermerge writes a single-major row and never warns. This change does not touch that path — it only avoids the trap by deriving from five runs by hand.🤖 Generated with Claude Code
https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK