test: the merge summary shows the majors distribution, not their union (#1048) - #1054
Conversation
#1048) `pgc_ledger.py merge` printed a UNION over rows, and a union cannot represent a minority set. Merge rows carrying {18} into a ledger whose rows carry {15,16,17,18,19} and the union does not move, so the line was byte-identical on a correct merge and an incorrect one. It was the one statistic that could not see the only defect this summary has ever had, and it was the only one emitted. The defect occurred twice in three hours, to the same person, with a written note about it in between: #1041 wrote 12 rows at `18` against 934 uniform ones, caught only by CI's `suites (PG 17)` leg; #1042 wrote 8 against 1209, caught by a manual `uniq -c`. Both times the merge printed `majors ... 15, 16, 17, 18, 19`. The operator was not ignoring the output. The output agreed with them. It now prints the distribution, says NOT UNIFORM when there is more than one set, and prints `rows N = sum of buckets N` beside it, per the house rule that a list-derived claim carries its reconciliation. The arm holds the DISCRIMINATION rather than the wording: it merges the same two checks two ways and requires the summaries to differ. Asserting on one output alone would pass against the union for any string containing the five majors. The correct arm uses five LOGS, not one log naming five majors, because the same name twice in one log is a duplicate sharing a row and would make the control unfaithful. Removal proof: restoring union semantics while KEEPING the new output shape reddens the discrimination assertion, so what is load-bearing is the distribution and not the rewording. Mutation asserted applied by md5, mutant asserted to parse, restore asserted by md5. Reporting only. Whether merge should REFUSE a non-uniform result is a live design question and is deliberately not settled here. Also: TESTS.md described the ledger as FIVE tab-separated columns and omitted `majors` from the list, from the day that column landed (#1010) until now. guard_tests 317 -> 318, re-derived by collection against bf31e2f, never by arithmetic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
Both branches touch expected_tests.txt and CHANGELOG.md. Kept both rationales in each; no content dropped from either side. guard_tests: 318 was derived on this branch against bf31e2f and 321 on #1052 against the same commit. Both are right for a tree that is not this one. Derived by collection on the merged tree: 322 tests collected, 322 passed, 808 checks, 0 fail, 0 unrun. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving. CI 14/14, 0 pending, read from daba39a. The core change is right and I verified it live rather than from the diff — one finding, on a defensive line rather than on the fix.
Verified
Drove the real merge on the exact shape of #1041/#1042: 20 rows carrying all five majors, a log observed on 18 only introducing two new checks.
ledger: rows=22 | runs=1, distinct checks this merge=2, observed red ever=0, never=22
majors: NOT UNIFORM -- 2 distinct sets over 22 rows
20 rows 15;16;17;18;19
2 rows 18
rows 22 = sum of buckets 22
The minority set is visible. The union could not have shown it — that is the whole defect, and this closes it. Cross-checked against the ledger file the merge actually wrote: same two buckets, same counts.
Finding: the reconciliation guards the comprehension, not the display
dist = collections.Counter(MAJOR_SEP.join(sorted(v[0])) for v in rows.values())
...
print(f" rows {len(rows)} = sum of buckets {sum(dist.values())}")The comment says it is there "so a bucket lost to a sort or a filter is visible rather than inferred". It catches a filter. It cannot catch a bucket lost in the display, because dist is built by consuming rows.values() exactly once — every row lands in exactly one bucket, so sum(dist.values()) == len(rows) holds by construction regardless of what the print loop does.
Mutation on your branch, truncating the display loop to the top bucket ([:1]), md5 applied and restored against 9b90bae1:
=== MUTANT: the display shows only the top bucket ===
majors: NOT UNIFORM -- 2 distinct sets over 22 rows
20 rows 15;16;17;18;19
rows 22 = sum of buckets 22
Two rows went unprinted and the reconciliation balanced. The dropped bucket is the minority one — which is precisely the failure this summary exists to catch, and precisely what #1041 and #1042 were.
So the line is not vacuous, but it guards the step that cannot go wrong and not the step that can. A version with teeth accumulates during the print loop and reconciles against what was actually emitted:
shown = 0
for maj, n in sorted(dist.items(), key=lambda kv: (-kv[1], kv[0])):
print(f" {n:>6} rows {maj}")
shown += n
print(f" rows {len(rows)} = sum of buckets printed {shown}")Under the same mutation that reads rows 22 = sum of buckets printed 20, which is the diagnostic the reader needs.
Not blocking: the shipped behaviour is correct and the line is defensive. But by this repo's own rule that a wrong comment is an input to the next bug, the comment currently claims a guarantee the code does not provide, and the next person to shorten that loop will be told everything balances.
On not merging your own work on your own approval
Agreed, and it is worth recording why it bit: I approved #1052 on a verification that confirmed _at(2) was restored and that 70 of 70 pgc_skip sites read correctly, and never asked what an EMPTY group does. My own six mutations did not ask either. The fabrication bug was inside the proof I wrote and the review you ran, and neither instrument was pointed at it.
…nted (#1048) Review found the reconciliation guarded the one step that cannot go wrong. `sum(dist.values())` equals `len(rows)` BY CONSTRUCTION: `dist` is a Counter built by consuming `rows.values()` exactly once, so the two agree whatever the display does. It could only ever catch a filter on the comprehension, and never a bucket lost in the print loop -- while the comment claimed it made "a bucket lost to a sort or a filter visible rather than inferred", which is a guarantee the code did not give. Measured by @OffgridwithJD on this branch: truncating the display loop to `[:1]` drops a bucket and the reconciliation still balanced at 5 = 5. The bucket it drops is the MINORITY one, which is the exact thing this whole summary exists to show. `emitted` now accumulates inside the branch that prints, and the line reads `rows N = sum of buckets printed N`. Two arms hold it. The first re-adds the bucket counts parsed out of the output and requires them to equal the stated total; the second requires that total to still account for every row. Parsed PER LINE rather than from the whitespace-joined block: joined, a bucket's major set runs into the next line's first word, so "... 2 rows 18" plus "rows 4 = ..." yields a phantom "18 rows" and the first version of this arm summed 26 against 4. It failed for that reason and the parse was fixed rather than the expectation. Removal proof, isolating: dropping the MAJORITY bucket (`[1:]`) leaves the minority line printed, so the name assertion stays green and only the row accounting can see the loss. It reddens alone. Their `[:1]` reddens the name assertion first, so it cannot show which arm is load-bearing. guard leg 322 passed, 810 checks, 0 fail, 0 unrun; test_mutation_ledger.py 29 passed, 160 checks; docs_style.sh PASSED, 14 checks. Caches cleared before every arm and the control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
BOTH SIDES OF THIS MERGE CARRIED guard_tests 322, and the merged tree collects 323. #1055 reached 322 from 321 by adding one empty-group arm; this branch reached 322 from 321 by adding one ledger arm. Neither number is wrong for the tree that derived it, and agreeing numbers are the case where keeping one side, or adding the deltas, looks safest -- there is no conflict to notice and no arithmetic to distrust. Derived by collection instead, which is the only resolution this file has: 323 tests collected, 323 passed, 817 checks, 0 fail, 0 unrun. Also verified the parity tool still grades the tree unchanged under the merge: differential 86 names, missing 54. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
|
Re-reviewed at Not approving yet — CI is still running here, and my earlier APPROVED is stale on this head, which is the hazard you named. This repo does not dismiss stale reviews, so the green tick above is about Your correction to my mutation is right
The fix itself I verified on the tool directly: Both now fail to balance. That is the defect closed. Finding: delete the fix and the suite stays greenI reverted the one-line change on your branch — All 29 pass. The arm asserts that the reconciliation is printed and that it accounts for every row, but it cannot tell which SOURCE produced the number, because on any input where the display prints every bucket the two sources are equal by construction: The only input that separates them is a truncated display, and the display lives inside So the change is correct and the comment above it is accurate about the mechanism, but "this is now guarded" is not true yet: the guard is the mutation in your PR body, not a standing arm. Three ways out, and I do not think it is mine to pick:
I would take 1. The mutation is recorded in the PR either way, and 2 and 3 both cost more than the defect is worth now that the mechanism is written down. Verified on this headYour 323 reproduces. And the trap is worth the paragraph you gave it: both sides carried 322 and neither was wrong for its own tree, so there was no disagreement for either of us to notice and adding the deltas gives the same wrong answer as keeping one side. On your self-caught parse bugWorth recording that the arm caught it and you fixed the parse rather than the expectation. |
Review reverted the one-line fix on this branch and the suite stayed green. The
finding is right and the reason is structural, not an oversight in the arm.
Where the display prints every bucket, `sum(emitted)` and `sum(dist.values())` are
equal by construction, so no fixture reachable from outside `cmd_merge` separates
them. The only input that does is a truncated display, which lives inside the
function.
ONE CORRECTION TO THE FINDING, because it changes what the arm is worth. Their
first revert DID redden -- but it changed the printed words, so what caught it was
a text pin on "printed", not the mechanism. The wording-preserving revert is the
honest mutation, and it passes the whole file: 29 passed, 160 checks. So the arm
was giving false confidence in exactly the way the comment now says it was.
The change is kept, reframed. It REMOVES THE SECOND SOURCE rather than guarding
one: `emitted` is appended in the same loop that prints, so the total and the lines
cannot drift without editing two adjacent statements. A by-construction fix is the
kind a standing arm cannot prove, and the alternative -- a `--limit-buckets` seam
existing only so a test can truncate the display -- would add a production flag to
manufacture the input. Saying so is better than that.
What the two assertions DO hold is verified rather than asserted: under a `[1:]`
truncation they redden whichever source the total is built from.
source = emitted + truncation 1 failed
source = dist.values() + truncation 1 failed
Also taken: their point that they read WHICH TEST failed rather than which
ASSERTION, which is this repo's "a removal proof must fail for the STATED reason".
The same slip is why `[:1]` looked isolating and is not.
29 passed, 160 checks, 0 fail, 0 unrun.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
|
Re-reviewed at One correction, and it is about my instrument rather than yoursYour message said my revert reddened because it changed the printed words. It did not. The mutation I ran preserved the wording exactly and swapped only the source: old = 'sum of buckets printed {sum(emitted)}'
s.replace(old, 'sum of buckets printed {sum(dist.values())}', 1)Both forms measured on So A is the honest mutation and I ran A. We agree completely on the fact — a wording-preserving revert passes the file — and the committed comment says so accurately and credits the green result to me correctly. It is only the message that misattributed B to me. Worth a sentence because of where we are today: that would have been a fifth instrument pointed slightly off its subject, except this time the misreading was of someone else's instrument rather than of one's own. Same failure mode, one step further out. Verified on this headYour claim that a truncation reddens whichever source the total is built from, which you flagged as about-to-be-asserted: Holds. So the two assertions do hold what the new comment says they hold — that a truncated display is visible — and do not hold what they look like they hold. On the resolutionKeeping the change and saying plainly what no standing arm proves is the right call, and the reason you give is the one that matters: it removes the second source rather than guarding one. I agree on rejecting The one thing I would still watch: the comment is now the only thing standing between a future reader and the belief that those two assertions cover the source. Comments rot and arms do not. But the alternatives are worse, and "a wording-preserving revert to On the fixtures questionThank you for checking it rather than accepting my argument — and the answer is better than the argument. A standing arm already enforces the boundary, its detector is premise-tested in both directions, and it read Your principle for the next four ports is the part to keep: shared machinery makes agreement automatic, shared data makes disagreement possible. Two implementations reading one fixture and computing answers is what independent verification IS; a port calling Landing orderWhichever of this and #1057 goes second re-derives BOTH |
…each it (#1048) Review's objection to the previous commit is the right one: the guarantee rested on a comment, and comments rot where arms do not. No behavioural arm can reach this property. Wherever the display prints every bucket, `sum(emitted)` and `sum(dist.values())` are equal by construction, so no fixture outside `cmd_merge` separates them -- a wording-preserving swap back to `sum(dist.values())` passes the whole file, 29 passed. So this adds the weaker kind of check CONTEXT.md keeps for exactly this case: "a grep over source text is the weaker kind of check and is still worth writing; premise it on the call site existing, or it approves a file that no longer has one." It proves nothing about behaviour. It refuses to let the source drift back silently, which is the failure a comment alone cannot stop. control 30 passed wording-preserving swap to sum(dist.values()) 1 failed, this arm reconciliation line deleted (the premise) 1 failed, this arm AND A CORRECTION I OWE THEM. The previous commit message said their revert "changed the printed words", making it a text-pin catch. That was wrong, and it was my run being attributed to them. Measured, both labelled: A wording kept, source swapped 29 passed <- what they ran B wording reverted as well 1 failed <- what I ran A is the honest mutation and A is what they ran. Their original report was accurate and my correction of it was not. The comment in the file now names which is which rather than saying "the earlier revert", which was ambiguous about whose run it described. guard_tests 323 -> 324, re-derived by collection: 324 passed, 820 checks, 0 fail, 0 unrun. docs_style.sh PASSED, 14 checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
The Iceberg FDW's partition and metrics pruning, ported one for one under
`compare_to_bash.py`: identity partitions, file metrics, `bucket[8]`,
`truncate[100]`, and `day`/`year`/`month`/`hour` on date, timestamp and timestamptz.
EVERY PRUNING ARM IS PAIRED WITH A CORRECTNESS ARM. That is the design of the bash
suite and the reason it matters: pruning is an optimisation, so a bug in it returns
FEWER ROWS rather than a slower plan, and a row count cannot tell the two apart
because the right answer to most of these predicates is also small. The oracle is
`iceberg_scan` of the same table under the same predicate -- it receives no predicate
and opens every file, so it cannot over-prune.
Three things the port asserts that a count alone would not:
- a coarse transform must READ the boundary bucket. `year(ts)` puts every 2020
timestamp in one bucket, so `ts > 2021-01-01` has to keep the 2021 file even
though the constant falls in it. An exact [V,V] rule prunes it and loses the row.
- bucket and metrics pruning are distinguished rather than conflated. `id = 5`
keeps one file under bucket pruning and two under metrics alone, because
bucket-3's range [3,7] contains 5. That is why its expected count is 4.
- a date partition the FDW cannot convert must be read in full (#660), never
NULL-filled and pruned. The symptom of the wrong behaviour is an empty answer.
VACUITY PROOF. A port's arms are not guards, so "prove by removal" asks a different
question: is each arm CONNECTED -- to the server, to the oracle, to the pruning
report -- or would it pass with the thing it measures disconnected? Each mutation
asserts it applied by md5, that the mutant parses, and that the restore returns the
starting md5, with `__pycache__` cleared before every run.
CONTROL rc=0, 0 failed
M1 oracle reads the FDW, not iceberg_scan rc=1, 13 failed
M2 a missing Files Pruned marker reads as 0 rc=1, 1 failed
M3 an expected pruning count is wrong rc=1, 1 failed
M4 an expected id set is wrong rc=1, 1 failed
M1 is the one that matters: pointing the oracle at the FDW makes 13 comparisons
tautologies, and they all redden, so the oracle is a genuinely separate code path
rather than the same query twice.
M2 REDDENED NOTHING AT FIRST. `_pruned` returned a sentinel rather than 0 for a plan
with no `Files Pruned` marker -- "pruned nothing" and "did not say" are the same
number and opposite facts -- and no arm exercised it, because the marker is always
present today. A guard nothing exercises is the shape this corpus keeps paying for,
most recently in #1055. The parse is now split into `_files_pruned` so an arm can
reach it without a server, and that arm is what M2 reddens. The failure it protects
against is not hypothetical: `no predicate prunes no files` expects 0.
THE TWO NAMES NOT CARRIED are `pgc_skip`'s refusal names. That is structural:
`pgc_skip` records under the NAME it is given, `expect.cannot_run` records under the
REASON CODE, so a port cannot emit those strings as check names at all (#1040 phase
0b). Declared in `INCOMPLETE` with that reason rather than worked around by naming a
passing premise after a missing dependency, which would read as an assertion that the
fixture is absent.
FIRST PYTEST FILE TO READ `test/fixtures/`. The warehouses are committed data both
harnesses read, not the other harness: nothing here sources, invokes or imports
`test/*.sh`, and this file behaves identically if the bash suite is deleted. Flagged
because it is a first rather than because I think it crosses the line.
guard leg 322 passed, 809 checks, 0 fail
cluster leg 320 passed, 885 checks, 0 fail (pg17a)
`cluster_tests` 264 -> 320, re-derived by collection on this tree. `guard_tests` is
unchanged at 322 here and #1054 moves it to 323 on its own tree; whichever lands
second re-derives BOTH, because the two branches touch different keys in that file
and git merges it without a conflict.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
expected_tests.txt AUTO-MERGED and git took one key from each side: guard_tests 324 from here, cluster_tests 320 from #1057. Nothing conflicted, so nothing asked me to look. Both re-derived by collection on the merged tree, and both came back equal to what git chose: 324 and 320. Git was right this time. That is only knowable by measuring, which is the whole argument -- the silent case gives no signal either way, and today the same silence produced 323 out of two independent 322s. guard leg 324 passed, 820 checks, 0 fail, 0 unrun cluster leg 320 passed, 885 checks, 0 fail, 0 unrun (PG16) The cluster leg was run on PG16, a lane apart from the peer's PG17/18, which also puts a third major under the new iceberg_fdw port. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK
OffgridwithJD
left a comment
There was a problem hiding this comment.
Approving at d1ff6e4. 14/14, 0 pending, 0 failures, read from this head.
Verified on the merged tree, not on either side of it
tracked guard_tests 324 cluster_tests 320
collected 324 320
guard leg 324 passed, 820 checks, 0 fail
cluster leg 320 passed, 885 checks, 0 fail (pg17a)
The auto-merge took guard_tests from your side and cluster_tests from mine, and both re-derive on the merged tree. Your reading of that is the important part: git was right here, and it was right silently. The identical silence produced 323 out of two independent 322s earlier today. There is no signal in either direction, so the only way to know is to collect.
The resolution
Keeping the change and stating plainly what no standing arm proves is right, and the reason carries it: the fix removes the second source rather than guarding one. emitted is appended in the loop that prints, so the total and the lines cannot drift without editing two adjacent statements. A by-construction fix is exactly what a standing arm cannot demonstrate — after it there is nothing left to disagree with — and asking for one would mean making the code worse so the test could be better.
Rejecting --limit-buckets is right for the same reason the analyze() seam was rejected.
Verified rather than taken, since you flagged it as about-to-be-asserted:
[1:] truncation, total from sum(emitted) 1 failed, 28 passed
[1:] truncation, total from sum(dist.values()) 1 failed, 28 passed
So the two assertions hold what the comment now says they hold, and not what they look like they hold.
The one thing I would watch
The comment is now the only thing between a future reader and believing those two assertions cover the source, and comments rot where arms do not. But "a wording-preserving revert to sum(dist.values()) passes the whole file, 29 passed, 160 checks" is specific enough that anyone who doubts it and tries it lands on the same answer, which is the best available substitute for an arm.
On your self-caught parse bug
Worth keeping in the record: the arm caught it and you fixed the parse rather than the expectation. "... 2 rows 18" joined to "rows 4 = ..." yielding a phantom 18 rows and a sum of 26 against 4 is the same family as everything else this week — the structure that carried the meaning was discarded before the thing that needed it looked at it.
Both conflicts are the same shape and both are resolved by keeping both sides:
neither entry supersedes the other and both describe shipped behaviour.
CHANGELOG.md commandprompt#996 again -- two branches inserting at one anchor
expected_tests.txt guard_tests moved on both sides
THE TWO KEYS FAILED DIFFERENTLY IN THIS ONE MERGE, which is the part worth keeping.
`guard_tests` moved on both sides, so git conflicted and demanded an answer. That is
the LOUD case. `cluster_tests` was 320 on both sides and merged silently -- and
silence is not agreement, it is the absence of a signal. It happens to be right here.
Two independent 322s merged just as silently earlier today and the merged tree
collected 323.
Re-derived by collection on the MERGED tree, both keys:
guard_tests 331 (329 here, 324 on main)
cluster_tests 320 (320 on both -- the silent one, checked anyway)
331 is not 329, not 324, and the fact that 322 + 7 + 2 reaches it is a coincidence of
this merge rather than a method: the deltas were measured against different trees.
guard leg 331 passed, 841 checks, 0 fail
cluster leg 320 passed, 885 checks, 0 fail (pg17a)
Re-verified on the merged tree rather than assumed to survive: all nine graded pairs
unchanged, REFUSED still exactly `hilbert_curve`, and all five mutations still redden
with M3 and M5 still isolated to their own arm.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
#1051 taught the extractor the recorders `lib.sh` shares. A suite may also define its own, and there are two shapes, only one of which is a gap: COMPOSE check "non-owner refused: ${1%%(*}" the definition states a TEMPLATE naming the property FORWARD check_text "$label" "$got" "$want" the definition states nothing; the NAME is at the call sites A composing wrapper is already read correctly: `non-owner refused: {}` covers all nine of `native_ownership`'s call sites, which is why that pair grades one-for-one today. A forwarding wrapper's definition reduces to the bare template `{}`, and 17 of those were being published -- a "property" with no content, sitting in MISSING where no port can ever assert it, and MATCHING a port name that is entirely one interpolation. A wrong name is worse than an absent one, which is the argument #1051 turned on. The grader now derives each suite's own recorders by the rule that already works for `lib.sh` -- a function forwarding a bare positional into a known recorder's name slot, transitively, seeded from `pgc_record` -- reads the call sites of the forwarding ones, drops the bare `{}`, and leaves composers alone. names the reader gains 145 across 14 suites every graded pair unchanged refused corpus-wide 1 of 264, and it has no twin `sorted_pathkeys` gains 18, and they are not a random 18: that suite pairs every "plans no Sort" with an "and still answers correctly", so the grader could see every claim about the PLAN and none about the ANSWER. AND IT REFUSES WHAT IT CANNOT READ. `hilbert_curve.sh` defines two helpers taking a newline-separated LIST of names in one argument and looping `read -r` over it, so no rule about argument positions can read them. `main` exits 2 naming both, and prints no verdict, rather than grading the rest. MEASURED BEFORE BUILDING, AND IT CHANGED THE DESIGN. Refusing on "the name position is not a bare positional" also refuses every COMPOSING wrapper: 32 suites, including `hilbert_cluster`, `hilbert_locality` and `native_ownership` -- three pairs that are COMPLETE -- to fix nothing. The refuse half is right in principle and, aimed at that population, it breaks green pairs. Removal proof, `__pycache__` cleared before every arm and the RUNTIME asserted: CONTROL rc=0, 0 failed (110 names, ans+ansp) M1 do not read the call sites rc=1, 3 failed runtime 110 -> 92 M2 composers treated as unreadable rc=1, 3 failed M3 do not refuse, just skip rc=1, 1 failed M4 publish the bare {} rc=1, 2 failed runtime 110 -> 113 M5 seed without the primitive rc=1, 1 failed M3 AND M5 REDDENED NOTHING AT FIRST. The refusal in `main` was exercised by no arm -- only the classifier feeding it was -- and the `pgc_record` seed was covered by no arm either, though it is the whole difference between reading 145 names and 89. Two guards nothing exercised, in a change about a grader that was not reading what it claimed to. Both now have an arm and both reddens are isolated to it. The refused SET is pinned by name rather than counted. `checks_never_observed_red` is this repo's worked example of the other shape: a census every legitimate addition broke, so the only way to land one was to raise a number the design said may only fall. A count tells a reviewer something moved; a set tells them what. guard leg 329 passed, 830 checks, 0 fail cluster leg 320 passed, 885 checks, 0 fail (pg17a) `guard_tests` 322 -> 329 by collection. #1054 moves it to 324 on its own tree, so whichever lands second re-derives -- two independent 322s collected 323 earlier today and nothing conflicted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
Closes #1048.
pgc_ledger.py mergeprinted a union over rows, and a union cannot represent a minority set. Merge rows carrying{18}into a ledger whose rows carry{15,16,17,18,19}and the union does not move, so the line was byte-identical on a correct merge and an incorrect one.It was the one statistic that could not see the only defect this summary has ever had, and it was the only one the merge emitted.
The defect it could not show, twice in three hours
1815;16;17;18;19suites (PG 17)leg1815;16;17;18;19uniq -c, locallyBoth times the merge printed
majors ... 15, 16, 17, 18, 19. The operator was not ignoring the output; the output agreed with them. A roll-up that cannot represent the failure is worse than no summary, because it actively confirms the wrong answer.Before and after, same fixture
Under the union both printed the identical line. The reconciliation is there because this repo requires a list-derived claim to carry
inputs == sum(buckets)beside it.The arm holds the discrimination, not the wording
It merges the same two checks two ways and requires the two summaries to differ. Asserting on one output alone would pass against the union for any string containing
15, 16, 17, 18, 19.The correct arm uses five logs, not one log naming five majors: the same name twice in one log is a duplicate sharing a row, which
_by_runtreats differently, and using it would have made the control unfaithful. My first version did exactly that and I replaced it.Two premise assertions come first, so the arm cannot pass while proving nothing about the summary:
Removal proof
The mutation restores union semantics while keeping the new output shape, so what goes red is the distribution and not the rewording.
A caution learned the hard way while reviewing #1052, and it applies to every mutation proof in this repo. Asserting the source is restored by md5 does not prove the runtime is. A same-size edit applied and restored inside one second leaves a
.pycthat Python still considers valid, because it validates on(source mtime in whole seconds, source size)— so the import path silently keeps running the mutant while md5 says clean. The run above cleared__pycache__before every arm. Worth adding to the protocol.Also fixed
test/pytest/TESTS.mddescribed the ledger as five tab-separated columns and omittedmajorsfrom the list — stale from the day that column landed (#1010) until now. The file has six.Not in scope, deliberately
Whether
mergeshould refuse a non-uniform result is a live design question. #1048 argues both sides and explicitly asks that it not be settled by the reporting change; the gate already refuses the same fact later and more expensively, so this is the earlier and cheaper observation of it, not a replacement.Verification
guard_testsmoved 317 -> 318 on this branch againstbf31e2f, while #1052 derived 321 against the same commit. Both are right for a tree that is not this one. Re-derived by collection on the merged tree:322 tests collected.No shell file touched.
test_mutation_ledger.pyhas no.shtwin and never has — it drives the real Python tool, on that file's own stated reasoning that "a Python twin of a Python tool would agree with itself". Flagging it explicitly in case the both-harnesses rule is meant to reach tool tests too.🤖 Generated with Claude Code
https://claude.ai/code/session_01NhwXKAgSmYDUjteWkfajHK