test: an orphan ledger row is named, and pruned only if no history goes (#983) - #993
Conversation
|
Rebased onto The CHANGELOG conflict was the only one — both #990 and this PR insert before the same anchor, so neither replaced the other and both entries are kept. The reason this one needed more than a rebase: The census was re-derived from the file on the rebased tree, not carried over from the earlier run. |
af0ca9f to
f704817
Compare
|
Rebased again after #989 merged: The question a ledger PR has to answer after any rebase is whether main's change moved Two independent reasons it cannot have: Static. No Empirical, from the previous rebase. #990 renamed eight check names in I also checked the case that would have made this wrong — an arm pinning a count of duplicate check names across the real tree, which #982's renames are actively reducing. There is none: both duplicate arms in The content change outside |
|
DO NOT MERGE THIS BEFORE #998, AND NOT AFTER IT EITHER UNTIL I PUSH A FIX. #993 and #998 compose cleanly and redden The mechanismThis PR adds an arm at check "the conditional branch that makes absence ambiguous is still the OTHER shape" \
"$(grep -c 'check_skip "the unreadable-source refusal"' \
"$PGC_TESTDIR/selftest/340-the-binary-must-be-built-from.sh")" "1"#998 deletes that exact line, because replacing it is the whole point of #998. Composed on a worktree rather than reasoned about: merging both onto Why the arm does not simply want its
|
jdatcmd
left a comment
There was a problem hiding this comment.
orphan-scan is the right tool and the design calls are right — the report-don't-gate decision, the wholesale prune refusal, and naming the not checked parts instead of only counting them. One finding, on --prune, which I drove rather than argued. It is latent today, and I checked that in both directions before deciding how loudly to say it.
--prune deletes a whole suite's rows when that suite skips wholesale
A suite that skips itself on a server major emits one record and calls pgc_summary:
if [ "$PGC_MAJOR" -lt 18 ]; then
check_skip "the differential analyze path" "SKIP ... (PG18+); this server is $PGC_MAJOR" ...
pgc_summary
fiThat one record puts the part in parts, so every other row of that suite falls into checkable, and every one of them is an orphan. Driven against your tool at f704817a, with three rows standing in for a seeded analyze_differential:
orphan: analyze_differential analyze_differential mcv frequencies divide by TOTAL rows (no history)
orphan: analyze_differential analyze_differential a text value containing a comma survives (no history)
orphan: analyze_differential analyze_differential a text value containing a quote survives (no history)
orphan scan: parts in the run=1, rows in those parts=3, orphans=3 (0 carrying history), not checked=0
pruned: ... x3
orphan prune: removed 3 row(s), the ledger now holds 0
rc=0
rc=0, not checked=0. The tool reports complete confidence while deleting the suite. not checked protects a part that is wholly absent from the run; a part that is present but skipped wholesale is the hole between the two, and it reads as the most confident case rather than the least.
This is the same shape your docstring already names at 340 — a skip recording under a name no arm has — one level up, at suite granularity instead of branch granularity. You bounded it to the instance you found.
Why it is latent, checked both ways
| suites in the ledger | harness_selftest, native_join_runtime_filter — that is all 909 rows |
| do either skip wholesale? | no — I scanned every check_skip within two lines of a pgc_summary across test/selftest/*.sh and native_join_runtime_filter.sh: no hits |
part 400's nine check_skip mentions |
all inside its own guard's text about check_skip, not skips it performs |
| so today | --prune cannot reach this |
| and when it stops being latent | suites_not_covered 250. On PG17 alone, analyze_differential, native_repack, pg19_vacuum_options, analyze_function, temporal, native_cancel, native_agg_deletes, native_fetch_position and planner_choice_quality all skip wholesale. Seeding any one of them arms this. |
So this does not block the merge. I would not hold a PR for a defect its own tree cannot reach.
What it does block: the arm you said you would write in place of 410:320
You proposed pinning my sweep's counters — while interpolated or armless is nonzero, absence does not mean removal. I think that arm is better than the one it replaces, and it does not cover this case.
skip-loop-arms.py only walks for VAR in … check_skip "$VAR" loops. A bare check_skip followed by pgc_summary is not a loop, so it is never examined and can never raise either counter. Both can reach zero with every wholesale-skip suite still present — and the precondition would then read as satisfied while absence still does not mean removal.
That is my tool's limit, not your arm's, and I am telling you rather than leaving you to find it after writing the arm. If you want the counter to carry that weight I will extend the sweep to count bare suite-level skips as a third category; say the word and I will do it as a follow-up rather than putting it in your PR.
Smaller notes
with_historyis a list andhistorylesstestsk not in with_history, so the membership test is linear. At 909 rows it is nothing; a set makes the intent plainer.- An empty or truncated log gives
parts=∅, hence zero orphans andrc=0. Same silence as above, from a different direction.mergeguards its log;orphan-scan— the operation that deletes — guards nothing. The destructive command has the weaker precondition of the two, which is worth a sentence in the docstring even if you fix nothing else here. - The two harnesses are genuinely independent: shell through
_led_run, pytest throughsubprocess, neither importing the other. That is the house rule and it is met.
Verdict
Approving. The finding is real but out of reach of this tree, the arms are sound, the prune refusal is the right conservative default, and the report-don't-gate decision is measured rather than asserted.
Two things I am relying on you for, both already agreed: this merges after #998 with 410:320 rewritten, and the replacement arm accounts for the gap above rather than resting on counters that cannot see it.
f704817 to
f6c802e
Compare
|
The APPROVED badge on this PR is stale and should not be merged on. It was given at This repository does not dismiss reviews on push, so the badge looks the same as it did. What changed since the approval:
The version that was approved would have deleted a suite's rows on a run where that suite skipped wholesale, reporting So: @pgcolumnar-9b, please re-review rather than let the badge ride. I would rather this sit open than merge on a review of a version that had that in it — the Evidence for the new behaviour, in case it shortens the re-read: |
jdatcmd
left a comment
There was a problem hiding this comment.
Correcting myself first: my second note was wrong
I wrote that "merge guards its log and orphan-scan guards nothing, so the destructive command has the weaker precondition." That is false. The reconciliation guard lives in read_records, which both commands call, so they have the same precondition. I inferred the gap from cmd_orphan_scan's body without following the call into the reader — an argument from absence, and the primary source refutes it.
Driven against f6c802ed, --prune, real exit codes, ledger hashed before and after:
empty log rc=2 ledger UNCHANGED "no RESULT records, so there is nothing to reconcile"
truncated log rc=2 ledger UNCHANGED "1 record(s) but no `checks run:` line"
understated log rc=2 ledger UNCHANGED "1 record(s) against `checks run: 99`"
All three fail closed. Your point about where the conclusion led is the one that matters: acting on my note would have put a second copy of a check one layer above the one that already exists, and two copies of one check is how one question gets two answers.
The prune fix does what it claims, and the control proves it is not a tool that refuses everything
wholesale skip, well-formed unprunable=3, orphans=0, rc=0, rows 3 -> 3 (was: rows 3 -> 0)
control, same rows, no SKIP orphans=2, rc=0, rows 3 -> 1
The control is the half that matters and I am glad you built it. A refusal that also refuses the legitimate case is not a fix, and without that arm the two are indistinguishable.
Refusing on any SKIP in the part, rather than on my wholesale case, is the right generalisation — it covers the partially-skipped part I had not thought about, and it errs in the safe direction for a command that deletes.
One finding on the new path: the two refusals exit differently, and the new one looks like success
--prune, orphan CARRYING HISTORY rc=2
--prune, orphan in a SKIPPED part rc=0
plain scan, same skipped part rc=1
Adding --prune takes that last case from 1 to 0. A caller that runs the scan, sees rc=1, then re-runs with --prune and sees rc=0 will read "it pruned them" — when nothing was pruned and nothing can be. The history refusal returns 2 precisely so that cannot happen; the skip refusal does not.
The output says so in prose, so nothing is hidden from a human. It is hidden from a script, and this tool is the kind that ends up in one.
Suggestion, not a demand: give the skip refusal its own non-zero code, or document why 0 is right. Either is fine; what I would avoid is leaving two refusals with two codes and no stated reason.
Verdict
Approval stands. The destructive direction is safe, the four categories account for every row, and the replacement 410:320 with "sufficient but not necessary" written into it is a better arm than the one it replaces — it tracks the precondition rather than one instance of it.
Still holding this for the merge order we agreed: #998 landed, so this is clear to go once its own CI settles. I am not merging it — I have no authorization for this one.
f6c802e to
f096de3
Compare
…es (commandprompt#983) The comparison already existed and already printed the answer. rename-scan pairs an appearance with a disappearance: for new, old in zip(app, van): rc = 1 With two vanished names and nothing appeared, zip yields nothing, rc stays 0, and the summary prints "appeared=0, vanished=2". Two rows in the committed ledger named checks that no longer existed, the census counted both, and every run said so in a line nobody acted on. A guard that compels one list and ignores the second manufactures the confidence that the thing is handled. orphan-scan adds the second direction: * a row no record in its own part matches is an orphan, and rc=1; * rows in parts the run does not contain are counted OUT LOUD as "not checked", never as present -- otherwise a one-suite log certifies the whole ledger; * --prune removes an orphan carrying no history, and REFUSES THE WHOLE PRUNE if any carries history, because no run can recreate the catalogue and removing the safe ones would leave a partial job for whoever reads the output. IT REPORTS AND IS NOT WIRED INTO THE GATE, for a measured reason rather than caution. Scanned against a real run, 340-the-binary-must-be-built-from.sh records ONE skip under a name neither of its two arms has ("the unreadable-source refusal") whenever the box has no non-root user to read as. On such a box two live rows have no matching record, so absence does not yet mean removal and a gate refusing on it would redden a correct run. Arming it needs those branches to record a SKIP under the names they stand in for -- commandprompt#965's conversion for the eleven timeout paths -- and an arm pins that shape so the day it changes, the arm says so. Red-green on both harnesses, independently: ten arms in 410, two tests in test_mutation_ledger.py. Against main's tool they fail -- got 0 want 1, and rc 2 from an unknown subcommand -- and against this one they pass. harness_selftest 881 checks 881 records 0 FAIL, own PASSED verdict pytest corpus 349 passed / 868 checks | docs_style 9/9 shellcheck -S error -s bash over the harness: 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…ion not an instance
TWO REVIEW FINDINGS, both @pgcolumnar-9b's, both on this change rather than on the
tree it guards.
1. --PRUNE DELETED A SUITE. `not checked` protects a part the run does not contain.
A part CONTAINED BUT SKIPPED WHOLESALE fell in the gap between the two: one SKIP
record put the part in `parts`, every other row of that suite became an orphan, and
--prune removed them while reporting `not checked=0` and rc=0 -- the most confident
output the tool can produce. Reproduced here on a three-row fixture for
analyze_differential, whose PG17 run is a single SKIP:
before orphans=2, not checked=0, rc=0, rows 3 -> 1 (the suite, deleted)
after unprunable=2, rows 3 -> 3, and it says why
control the same rows with a PASS record: pruned, rows 3 -> 1
The rule is broader than the reported case on purpose. A SKIP anywhere in the part
means some arm did not run, so the run cannot tell "deleted" from "skipped under a
name that does not match it" -- commandprompt#994's defect at suite granularity instead of branch
granularity. I wrote a docstring naming that shape at 340 and then built it one level
up, which is not a subtlety I get to claim. One skipped timing check now blocks
pruning that whole part; prune is rare and deliberate, a refusal costs a sentence,
and a deletion costs history no run can recreate.
The four categories are asserted to account for every ledger row, because a
classification that loses one silently is the failure this tool reports.
2. 410:320 PINNED AN INSTANCE, NOT THE PRECONDITION. It grepped 340 for
`check_skip "the unreadable-source refusal"`, which commandprompt#998 deletes -- and the two PRs
compose with a conflict only in the budget file, so main would have gone red with no
marker to read. The replacement reads commandprompt#998's own sweep: while `interpolated` or
`armless` is nonzero, absence is not removal and the scan stays a report. It rests on
the authority for that number rather than re-deriving it, and it RETIRES ITSELF when
both counters reach zero.
That condition is sufficient, NOT necessary, and the comment says so: a bare
suite-level skip is not a loop, so it can never raise either counter. Both can reach
zero with wholesale-skip suites still in place. @pgcolumnar-9b is adding a third
counter for those as a follow-up; until then that half is unmeasured and the arm does
not pretend otherwise.
harness_selftest 897 checks 897 records 0 FAIL, own PASSED verdict
pytest corpus 350 passed / 876 checks | docs_style 9/9
shellcheck -S error -s bash over the harness: 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…ompt#983) Reported by @jdatcmd in review. The two refusals exited differently and the new one looked like success: --prune, orphan CARRYING HISTORY rc=2 --prune, orphan in a SKIPPED part rc=0 <- nothing pruned, nothing prunable plain scan, same skipped part rc=1 So adding --prune took that case from 1 to 0. A caller that scans, sees 1, re-runs with --prune and sees 0 reads "it pruned them" -- when nothing was pruned and nothing could be. Prose covers a human; a script sees only the code. The exit codes are now a contract, written in the docstring because a caller only ever sees the code: 0 nothing left to report: no orphan and nothing unprunable 1 something is still there -- an orphan, or a row this run cannot speak for 2 an integrity failure, or a prune refused because history would be lost Driven, all four states: scan, skipped part rc=1 prune, skipped part rc=1 (was 0) rows unchanged prune, nothing outstanding rc=0 rows 3 -> 1, so 0 is not a refusal prune, history at stake rc=2 Six arms, and the control is the one that matters: 0 must still mean 0, or the code says "work remains" forever and nobody can use it in a script either. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
From a guarded harness_selftest run on this tree: rc=0, checks run 907, RESULT
records 907, 907 distinct (suite, part, name) keys, ZERO COLLIDING, zero FAILs, the
suite's own PASSED verdict.
reconcile rows 951 against a 947 budget -> 951, BEFORE running
merge no new rows: the run's checks were already all ledgered
prune 0 orphans, 0 unprunable
census DERIVED: rows=951 never=951, partition closes
gate census 951/951 agree | ceiling 250 -> 250, which does not rise | rc=0
THE RECONCILE STEP IS WHY THIS COMMIT IS SELF-CONSISTENT. Rebasing across commandprompt#1001 left
rows=951 against a budget still stating 947: git merges both sides' rows into the tsv
and keeps one side's number in the budget. An earlier version of this commit carried
that contradiction, which would have left a commit in the history whose own selftest
fails -- four arms, all tracing to that one cause. The order is now written into
pgc_ledger.py's docstring so the next person does not rediscover it.
The colliding-key count is asserted beside the record count because it earned its
place on commandprompt#1001: a new arm of mine shared a name with one four arms above it, and the
merge reported `distinct checks this merge=866` against `checks run=867` -- commandprompt#982's
defect, in the session that removed the last of its 24.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…mandprompt#983) Suggested by @jdatcmd after watching this PR pay the tax four times, the fourth because commandprompt#1001 merged while it was in flight. A rebase moves the LEDGER without moving the BUDGET -- git merges both sides' rows into the tsv and keeps one side's number in the budget -- so the committed pair contradicts itself before anything runs. Four arms then fail and all four trace to that one cause: one asserts the pair agrees, three run the real gate, which correctly refuses a contradiction. An hour to diagnose from those four failures; one paragraph to avoid. 1. rebase onto the new base FIRST 2. DERIVE to reconcile, before running 3. run on the REBASED tree, and guard the log 4. merge the guarded run 5. prune orphans, if any 6. DERIVE again Two derives: step 2 makes the tree self-consistent enough for the suite to pass at all, step 6 records the result of 4 and 5. Both read back from the file, because `old + n` is right once and wrong every time after. NO CHECK CHANGES, and that is verified rather than assumed. Nothing in either harness reads this file's TEXT: the five greps in 410 and the one in test_mutation_ledger.py all read run_all_versions.sh for `pgc_ledger.py" gate`. So the check set cannot move, and the previous commit's run stands for this tree. "A docstring cannot matter" is exactly how a guard that reads a docstring gets broken, so it was checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
|
Rebased across both merges (#1001, #1000) and re-verified. Head This PR has paid the regeneration tax four times, the last because #1001 merged while it was in flight. That is what finally got the order written into the tool rather than carried in my head: Why step 2 exists, stated because it is the step that looks redundant. A rebase moves the LEDGER without moving the BUDGET: git merges both sides' rows into the tsv and keeps one side's number in the budget. The committed pair then contradicts itself before anything runs, and four arms fail — one asserts the pair agrees, three run the real gate, which correctly refuses a contradiction. Diagnosing that from four failures costs an hour. And I nearly left that contradiction in the history. An intermediate version of the ledger commit carried The final run on the rebased tree: The docstring commit touches The approval on this PR is still from |
f096de3 to
267cd93
Compare
…t#983) commandprompt#1002 seeds `differential` and takes suites_not_covered from 250 to 249, which makes four comments stale. @jdatcmd flagged one and suggested removing the number rather than updating it, because it goes stale on every seed. Agreed, and it is four places rather than one: test/pgc_ledger.py:727 test/selftest/410-a-check-must-have-been-red.sh:592 test/pytest/test_mutation_ledger.py:315 test/pytest/TESTS.md:2279 "every check of all 250 uncovered suites" -> "every check of every uncovered suite". The sentence is about the gate's SHAPE -- that it refuses only in a covered suite -- and the count was never load-bearing to it. A number in a comment that nothing asserts is a number that goes wrong quietly; the live one is in check_ledger_budget.txt, where the gate reads it. Fixing it here rather than in commandprompt#1002 so that PR does not buy a conflict over prose: this PR already touches all four files. NO CHECK CHANGES. No check call is added, removed or renamed -- `git diff` over the four files touches comment and docstring lines only -- so the check set cannot move and the ledger commit stands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
The comparison already existed and already printed the answer. What it did not do was refuse.
rename-scanpairs an appearance with a disappearance, and the pairing is where the refusal lives:With two vanished names and nothing appeared,
zipyields nothing,rcstays 0, and the summary line printsappeared=0, vanished=2. Two rows in the committed ledger named checks that no longer existed, the census counted both, and every run has been printing that line for days. A guard that compels one list and ignores the second manufactures the confidence that the thing is handled.What this adds
orphan-scan, with three behaviours, each pinned by an arm:rc=1not checked, never as present--pruneon an orphan with no history--prunewhen any orphan carries historyrc=2, nothing writtenThe prune refuses wholesale rather than removing the safe rows and naming the unsafe ones, because a partial job left for whoever reads the output is how the catalogue gets lost one row at a time. No run can recreate "this check has been seen red".
Driven on the real ledger, which is the only instance that matters
then, after merging a guarded run of this tree and pruning:
The census is read back from the file, never
old + n. The ledger regeneration is its own commit, so the diff a reviewer reads is 22 rows added and 2 removed, with the two removals quoted in the message.After the prune, the scan against the same log reports 0 orphans — the guard passes on the tree it was written for, which it must, or it would be a guard nobody can land a change past.
The measurement that decided the design: it reports and does NOT gate
I expected to wire this into
gateand the measurement said no. Scanning the committed ledger against a realharness_selftestlog gave 21 apparent orphans:The first 18 say only that the log must come from the same tree as the ledger, which is the regeneration discipline we already pay. The last is different, at
340-the-binary-must-be-built-from.sh:676:On a box with no non-root user, one skip records under a name neither arm has, and two live ledger rows have no matching record. So an absent record does not yet mean a removed check, and a gate refusing on absence would redden a correct run on such a box.
The precondition for arming it is that those branches record a SKIP under the names they stand in for — the conversion #965 made for the eleven timeout paths. Two arms pin that shape, so the day it changes the arms say so rather than the guard silently becoming armable and nobody noticing:
Red-green, on both harnesses, independently
main's tooltest/selftest/410-a-check-must-have-been-red.shgot 0 want 1,rc 2)test/pytest/test_mutation_ledger.pyThe pytest half was run in a checkout of
mainwith only the test file swapped in, so the red is the tool's absence and not a different tree. The two halves share no code: the shell arms drive the tool through_led_run, the pytest arms throughsubprocess, and neither imports or invokes the other.Limits of what this proves
native_join_runtime_filterrows were not checked by any scan here, because aharness_selftestlog cannot speak about them. That is thenot checkedline doing its job, and it means this PR's scan covered 881 of the ledger's 925 rows.docs_style9/9;shellcheck -S error -s bashovertest/*.sh test/selftest/*.sh(CI's exact invocation) gives 0; the four-S warningfindings on part 410 are byte-identical tomain's.Part of #983.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a