test/pytest: a failed query is not a comparison (#432) - #930
Conversation
`error-swallowed-to-empty`: two queries raise, a helper turns each into the same
value, and they compare equal. The test is green and has asserted nothing about
either query. lib.sh closed this by PRODUCING the sentinel with a sequence number
per failure -- `res="QUERY_ERROR.$seq"` -- so two failures can never compare equal.
The port had the constant, a comment claiming it was "unique per occurrence" --
false of a constant -- and a refusal in exactly one assertion.
MEASURED BEFORE THE FIX, a sentinel on both sides:
expect.hash REFUSED
expect.text PASSED
expect.rows PASSED
expect.row_set PASSED
expect.ordered_rows PASSED
expect.num refused, by its type guard
expect.at_least refused, by its type guard
expect.rowcount refused, by its type guard
Four of the five comparisons accepted two failed queries as agreement.
ONE REFUSAL, CALLED BY EVERY COMPARISON, so an assertion added later inherits it
instead of being the next hole -- which is how this survived: `hash` had a refusal
and the four written after it did not. It matches the prefix AT ANY DEPTH, because a
sentinel arrives as a CELL inside a row as often as it arrives as a whole side.
`row_set` refuses BEFORE it maps its rows through repr. `repr(("QUERY_ERROR.1",))`
is `"('QUERY_ERROR.1',)"`, which does not start with the prefix, so a refusal living
only in `rows` cannot see a sentinel that arrived as a cell. Delegating an assertion
does not delegate its refusals when the delegation transforms the data. The first
version of this change had that hole and the fixture caught it.
THE REFUSAL IS THE MECHANISM; `query_error()` IS THE SECOND LINE. A non-unique
sentinel is safe against the layer, because no comparison accepts one at all. It is
not safe against a helper that compares by hand, which is what the two existing
hand-rolled sentinels in test_hilbert_locality.py do. One of those is produced by
`coalesce(...)` inside SQL and cannot use a Python producer, which is the reason the
refusal has to be the mechanism rather than the other way round. Those two sites are
left alone: the comment above one of them already measured that `expect.hash` refuses
the pair, and that is now true of every comparison.
EACH REFUSAL IS PROVED LOAD-BEARING, one at a time:
removed from the arm names arms reddened
row_set row_set COMPARED a failed query 2 (also the delegation arm)
ordered_rows ordered_rows COMPARED a failed... 1
rows rows COMPARED a failed query 1
hash hash COMPARED a failed query 1
text text COMPARED a failed query 2 (also the hatch arm)
AND THE MUTATIONS ARE ONLY DISTINGUISHABLE WITH __pycache__ CLEARED. The five
deleted lines are byte-identical, so three of the five leave the file the same size
and Python reuses the stale bytecode: without `rm -rf __pycache__` between runs,
mutations 3, 4 and 5 all report `rows COMPARED` and two refusals look like they do
not bite. I believed that for two rounds before checking.
THE ARM IS DERIVED, NOT LISTED. `_comparisons()` finds every public assertion whose
first two parameters are (got, want) or (got, floor), and a second arm requires each
to have a declared valid pair -- so an assertion added to the layer without a refusal
reddens rather than being quietly outside a loop that looked exhaustive. The first
version of that loop fudged the shapes and never placed a sentinel in `at_least`'s
floor at all; it reported `at_least ACCEPTED a failed query`, which was the fixture's
fault and not the layer's.
THE HATCH IS CLOSED TOO. Every conftest under test/pytest/ is imported before
collection, so a module global is writable from the corpus the rule polices. An arm
rewrites `pgc_vacuity.QUERY_ERROR` to three values and requires the refusal to still
arrive, distinguishing "refused" from "compared and happened to differ".
A DOC CORRECTION, WHICH IS MINE. VACUITY_MODES.md said "the port has the sentinel
constant but nothing produces it". Both halves were wrong: two sites produce
sentinels by hand, and the thing missing was the refusal in four of five comparisons.
The correction is recorded in the document rather than quietly replacing the
sentence, because a map that names the wrong gap is worse than one that admits it
does not know.
MEASURED
pytest corpus 170 passed, rc 0
harness_selftest 538 checks, 538 passed + 0 failed + 0 unrunnable, rc 0
new arms 8, all passing, every refusal proved by removal
VACUITY_MODES.md totals section 2: 25 -> 26, section 3: 47 -> 46, 72 unchanged,
and the three prose totals the doc's own arm gates
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
jdatcmd
left a comment
There was a problem hiding this comment.
Reviewed at 1e43b8db. I ran the layer rather than reading it: a worktree at that commit, a venv on the file's own pins (pytest==9.1.1, pytest-xdist==3.8.0, psycopg[binary]==3.3.5), every mutation asserted applied and the worktree left clean (git status --porcelain empty).
The idea is right and the derivation-over-a-list instinct is the correct one. Three findings, and the first is blocking because the PR claims the opposite of what I measured.
1. The hatch is open, and the arm that says otherwise is tautological (blocking)
test_the_refusal_cannot_be_switched_off_from_the_corpus_it_polices sets pgc_vacuity.QUERY_ERROR = spelling and then mints its sentinels with query_error(), which reads that same global at call time. Producer and matcher move together, so the refusal matches whatever the prefix was just set to. The arm cannot fail for the property it names.
Measured, printing what it actually mints:
prefix='' minted '.1.a' / '.2.b' -> REFUSED
prefix='NOTHING_MATCHES_THIS' minted 'NOTHING_MATCHES_THIS.3.a' / '...4.b' -> REFUSED
prefix='Q' minted 'Q.5.a' / 'Q.6.b' -> REFUSED
The faithful hatch mints while armed and rewrites afterwards, which is what a corpus file actually does:
minted while armed: 'QUERY_ERROR.7.a' / 'QUERY_ERROR.8.b'
prefix -> 'NOTHING_MATCHES_THIS' -> COMPARED, then failed: got 'QUERY_ERROR.7.a' want 'QUERY_ERROR.8.b'
prefix -> 'Q' -> REFUSED
The 'Q' case still refuses because 'QUERY_ERROR.7.a'.startswith('Q') is true — only a spelling that is not a prefix of the real one disarms it, which is worth knowing when you pick replacement spellings for the fixed arm.
This is not hypothetical, because the corpus hardcodes sentinel text that is minted independently of the global: 'QUERY_ERROR.empty-relation' at test_hilbert_locality.py:275, f"QUERY_ERROR.no-partition-for-{table}" at :300, and "QUERY_ERROR.1" at test_guards_pinned.py:130 and :139. End to end, as a corpus file — test_zz_hatch_demo.py rewriting the global at module scope, comparing that hardcoded string built two ways (with a is not b asserted first, so hash's identity guard is not what fires):
WITH the rewrite -> 1 passed in 0.01s <- two failed queries compared equal, green
CONTROL, no rewrite -> 1 failed, VacuityError: the left side is a failed query
That is error-swallowed-to-empty reintroduced through the hatch the body says is closed. File removed afterwards; worktree clean.
Separately, the "" spelling is worse than tautological. str.startswith("") is true for every string, so with the prefix set to "" I ran e.text("hello", "world", ...) and got VacuityError: the left side is a failed query: 'hello'. Two ordinary strings refused as failed queries. That leg distinguishes nothing in either direction.
2. ordering_observable is outside the derivation, and this PR turns one of its reds green (should-fix)
_comparisons() selects on the parameter names got + (want|floor). The comment above it says deriving rather than listing is what stops the next assertion being the next hole. ordering_observable(forward, reverse, name) is a two-value comparison of caller-supplied query results, and it is not selected, so it inherits no refusal.
Enumerated from the live class, reconciled:
inputs 15 == selected 8 + not selected 7 -> True
not selected: cannot_run, ordering_observable, outcomes, plan_marker, plan_node, refusal, run_failed
ordering_observable is the one of those seven that compares two caller-supplied readings. The regression:
old constant, both sides QUERY_ERROR -> RED: the forward and reverse readings are identical
new producer, two minted sentinels -> GREEN: the premise passed
the hilbert hand-rolled shape -> GREEN: the premise passed
Before this PR, two failed queries read identically and the ordering premise went red — the failure was loud. Making sentinels unique converts that into a silent pass, greenlighting every ordered assertion built on the premise. This is the one place where the PR makes the layer strictly weaker than it was, so it should not ship without either selecting ordering_observable into the derivation or refusing sentinels inside it.
3. TESTS.md now states 26 + 47 = 73 against the gated 72 (should-fix)
The PR updated the gated half of the sentence and left the ungated half stale:
TESTS.md:1182 "This layer refuses 26 of them." The other 47, ...
VACUITY_MODES.md:50-52 refused today 26 | not refused 46 | named in this document 72
base f0f1f40 "This layer refuses 25" ... The other 47 = 72, consistent
So the contradiction is introduced here. Nothing catches it: selftest 350's only TESTS.md total arm is the regex This layer refuses [0-9]+, which matches the half you changed and not the half you didn't. The body says "the inventory's own arms forced the arithmetic" — true of the 26, not of the 47.
What I could not fault
The five removal proofs in the PR's table reproduce exactly: deleting self._refuse_failed_query(...) one line at a time gives 2 / 1 / 1 / 1 / 2 reds, each naming the right assertion. The depth-walk recursion is load-bearing (deleting it reddens 2). The anti-drift arm works — adding a real newcmp(self, got, want, name) with no refusal reddens test_the_shape_table_covers_every_comparison_the_layer_offers with missing: ['newcmp']. test_failed_query_sentinel.py is 8 passed at head, and the non-DB corpus subset is 99 passed, so nothing existing broke. VACUITY_MODES.md's own arithmetic recomputes to 26 / 46 / 72 from the data.
I could not reproduce the body's "pytest corpus 170 passed" or "harness_selftest 538 checks": there is no server-side PostgreSQL on this host, and build_once installs into a shared pkglibdir that would race the other lane. Those two numbers are unverified by me rather than disputed.
Worth stating: no CI job runs this corpus at head — grep -rn pytest .github/workflows/*.yml returns nothing and SUITES has no pytest entry, which selftest 350 says itself. That is pre-existing and not this PR's doing, but it is why findings 1 and 2 would not have been caught by any gate. #921 is the PR that adds the job.
…n the ordering premise, and make both halves of the totals sentence gated (commandprompt#432) @jdatcmd found three defects in the first version. All three were real, each is reproduced below before it is fixed, and the second one is a regression I introduced. 1. THE HATCH WAS OPEN, AND MY ARM WAS TAUTOLOGICAL. It rewrote `pgc_vacuity.QUERY_ERROR` and THEN minted its sentinels with `query_error()`, which read that same global. Producer and matcher moved together, so the refusal matched whatever the prefix had just been set to: the arm could not fail for the property it names. The faithful hatch mints while armed and rewrites afterwards, which is what a corpus file actually does. Against the first version: minted while armed: 'QUERY_ERROR.7.a' / 'QUERY_ERROR.8.b' prefix -> 'NOTHING_MATCHES_THIS' COMPARED, then failed on inequality end to end, as a corpus file 1 passed over two failed queries The prefix is now bound in a DEFAULT ARGUMENT in both the producer and the matcher. A default is evaluated once, when the function is defined, and is never read from the module namespace again -- so rewriting the global changes neither what is minted nor what is refused. Measured after: all four spellings REFUSED, and a hardcoded `'QUERY_ERROR.empty-relation'` still refused, which matters because the corpus writes three such strings and one of them is built inside SQL by `coalesce(...)` where no Python producer can reach. `ZZZ_NOT_A_PREFIX` is in the arm's spellings deliberately. `'Q'` cannot disarm a prefix-reading matcher, because `'QUERY_ERROR.1'.startswith('Q')` is true -- so a spelling that is NOT a prefix of the real one is the case that finds the hole, and it is the case my old arm never tried. 2. THE PRODUCER MADE `ordering_observable` WEAKER, which is the one place this change regressed the layer rather than improving it. That assertion takes `(forward, reverse)` rather than `(got, want)`, so it sat outside the refusal AND outside the derivation that finds comparisons. With the old shared constant two failed readings were IDENTICAL, so it went red -- loudly. With unique sentinels they differ, so it PASSED and greenlit every ordered assertion resting on the premise. The refusal is now the first thing it does; the derivation recognises `(forward, reverse)`; the shape table has an entry, so the coverage premise demands one; and an arm asserts both the both-failed and one-side-failed cases. 3. TESTS.md STATED THE SPLIT TWICE AND ONLY ONE HALF WAS GATED. 26 refused and "the other 47" sums to 73 against the 72 the inventory names, and `selftest/350`'s regex matches exactly the half a change naturally updates. Fixed to 46, and an arm in the corpus's own docs guard now reads BOTH halves and checks them against the inventory's count of the ids it names rather than against a number typed twice. MEASURED pytest corpus 173 passed harness_selftest 538 checks, 538 passed + 0 failed + 0 unrunnable, rc 0 the refusal 6 call sites, each load-bearing, file restored byte-identical: row_set 2 arms · ordered_rows 1 · ordering_observable 2 · rows 1 · hash 1 · text 3 the faithful hatch 4 spellings, all REFUSED, plus a hardcoded sentinel __pycache__ cleared between mutations, without which three of them are one measurement Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
|
@jdatcmd all three were real and all three are fixed at 1. The hatch was open, and my arm was tautologicalYour reading is exact: the arm rewrote The prefix is now bound in a default argument, in both the producer and the matcher. A default is evaluated once when the function is defined and never read from the module namespace again, so rewriting the global changes neither what is minted nor what is refused. Measured, minting while armed and rewriting afterwards as a corpus file does:
2. The producer made
|
|
Correcting finding 2 of my review. An adversarial pass over my own findings refuted the harm I attached to What I got wrong. I wrote that the premise going green is "greenlighting every ordered assertion built on it", and that this PR "turns one of its reds green". Both are false, measured at The next assertion in the chain is refused by the very refusal this PR adds, so a test built on that premise is red, not green. My "greenlights" claim skipped the step that catches it. And the regression claim does not survive its own base measurement. Extracting So the shape I showed going green was green at the base too, and the shape that was red is still red. This PR neither added nor removed a refusal in What survives, and it is smaller. That makes it a note, not a should-fix: worth selecting into the derivation so the next assertion added is not the next hole, but not a blocker and not a regression. Findings 1 and 3 are unchanged. The hatch is open and its arm is tautological — that one I demonstrated end to end with a corpus file, |
jdatcmd
left a comment
There was a problem hiding this comment.
Approving at 4c8e2e20, 12/12 SUCCESS, CLEAN. I re-ran my own attack rather than reading the fix.
The hatch is closed. The end-to-end corpus file that produced 1 passed at the old head — rewriting pgc_vacuity.QUERY_ERROR at module scope, then comparing the hardcoded 'QUERY_ERROR.empty-relation' built two ways:
old head 1e43b8db -> 1 passed (two failed queries compared equal, green)
new head 4c8e2e20 -> 1 failed pgc_vacuity.py:174: VacuityError
Binding the prefix at definition time is the right mechanism: the producer and the matcher both capture it, so rewriting the module global changes neither.
And the new arm is load-bearing. Reverting the matcher to read the global at call time — one line, v.startswith(_prefix) back to v.startswith(QUERY_ERROR), diffed and restored byte-identical:
FAILED test_the_refusal_cannot_be_switched_off_from_the_corpus_it_polices
FAILED test_a_hardcoded_sentinel_survives_the_same_rewrite
2 failed, 8 passed
The second of those is the case I raised — test_hilbert_locality.py:275 mints its sentinel independently of the global — and it now has an arm of its own. Keeping ZZZ_NOT_A_PREFIX in the spelling list is the detail I would point a reader at: 'Q' cannot disarm a prefix matcher, so a spelling that is not a prefix of the real one is the only case that finds the hole, and it is the case the old arm never tried.
ordering_observable now refuses, and does not over-refuse:
two minted sentinels REFUSED
hand-rolled fwd/rev REFUSED
legitimate distinct GREEN <- the control
You fixed this even though I had already downgraded it to a note and withdrawn the regression claim I attached to it. Worth recording that my "this PR turns a red green" was wrong — the shape was green at the base too.
One note, and it is only the commit message. The subject says "make both halves of the totals sentence gated". The value is right — TESTS.md now reads 26 + 46 = 72, matching the gated table. But the second half still has no arm:
TESTS.md: "The other 46" -> "The other 99" 350: 41 checks, 41 passed, 0 failed
_mi_not gates VACUITY_MODES.md's table rows at 369 and 372, and nothing reads The other [0-9]+ in TESTS.md. Not blocking, and the number is correct today — but the claim is broader than the arms.
Also worth stating, as on #927: nothing runs this corpus in CI at this head, and #921 is the PR that changes that.
Both PRs moved a mode from VACUITY_MODES.md section 3 to section 2, so both edited the count rows and both claimed TESTS.md section 18. RESOLVED BY COMPOSING, not by choosing: section 2 keeps BOTH rows, the counts become 27 refused / 45 not refused against an unchanged 72 named, and the prose totals the inventory gates follow -- section 2s opening, section 3s opening, the closing paragraph, TESTS.md and README.md. The raises section keeps 18 and the sentinel section becomes 19. Verified structurally rather than by eye: 19 headings, 19 TOC entries, numbers contiguous 1..19, titles identical between the two lists, every TOC anchor equal to the anchor GitHub derives from its heading. AND THE FILE THAT DID NOT CONFLICT IS THE ONE THAT NEEDED RUNNING. git auto-merged pgc_vacuity.py, composing the raises scanner and the sentinel refusal without either side ever having run the composed file -- each was green only against its own base. @jdatcmd asked for the arms on the MERGED tree rather than on the branch, which is where each-green-separately-broken-together lives: test_raises_sqlstate.py + test_failed_query_sentinel.py 40 passed the whole corpus 203 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
One conflict, CHANGELOG.md: both sides append a bullet at the top of the same section. Resolved by keeping BOTH, since choosing one loses a shipped entry. TESTS.md and VACUITY_MODES.md auto-merged -- commandprompt#931 narrowed section 3.7 without moving a mode, so the 27 / 45 / 72 counts this branch composed still hold. The corpus on the merged tree: 205 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
jdatcmd
left a comment
There was a problem hiding this comment.
Re-approving at 8baef757, which is the head after your two main merges. My earlier approval named 4c8e2e20 and was stale.
I re-ran everything against this head rather than carrying the old verdict forward:
my hatch attack (the corpus file that rewrites the global) 1 failed, VacuityError (was 1 passed at 1e43b8db)
removal proof: v.startswith(_prefix) -> v.startswith(QUERY_ERROR)
FAILED test_the_refusal_cannot_be_switched_off_from_the_corpus_it_polices
FAILED test_a_hardcoded_sentinel_survives_the_same_rewrite
the composed corpus, six files including both merges' arms 101 passed
selftest 350 doc arithmetic on the composed tree 41 checks, 41 passed, 0 failed
pgc_vacuity.py restored byte-identical after the mutation; worktree clean.
The auto-merge is the part I cared about and it holds. pgc_vacuity.py took changes from #927 and from this PR without conflicting, which is exactly where "each green separately, broken together" lives — so running the composed file was the check, not the clean merge. Your 40 passed and 205 passed agree with what I measure here.
The VACUITY_MODES.md composition also reconciles: section 2 keeps both rows at 27 refused / 45 not refused against an unchanged 72, and 350 recomputes all of it from the ids rather than from the prose.
My earlier note about the commit message claiming both halves of the TESTS.md sentence were gated still applies as a note, and nothing else stands. Approved.
#927, #931 and #930 landed while this waited on review, so main gained three CHANGELOG entries and two TESTS.md sections. CHANGELOG: both sides append at the top of the same section and neither replaces anything, so the union is the resolution. TESTS.md: the numbering collided. This branch inserted its file section at 15 and pushed "Adding a test", "What this corpus does NOT yet refuse" and "Traps this corpus records" to 16-18; main kept those at 15-17 and appended its two new file sections as 18 and 19. Auto-merge produced two sections numbered 18. Resolved main's way, because main's convention is now to append a new file section after the tail sections: this branch's section becomes 20, and the three tail sections go back to main's 15, 16 and 17. That renumbers one section of this branch rather than two of main's. Checked rather than eyeballed, because an anchor that stops resolving does not announce itself: 20 headings against 20 TOC entries, every TOC text equal to its heading, every anchor equal to what GitHub derives from that heading, and the numbering contiguous 1..20. On the composed tree: selftest 350 41 checks 0 failed, selftest 400 64 checks 0 failed, selftest 080 15 checks 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Unuuvh3fRR67SceiGpfeeK
…pt#930 landed (commandprompt#432) The arm named it rather than leaving a hole: cluster-free, not driver-dependent, so the driver-free job can run it and the declaration has to say so. Third time this arm has caught a merge-order consequence rather than a mistake -- commandprompt#922 brought test_suite_accounting.py, commandprompt#927 brought test_raises_sqlstate.py (which turned out to need the DRIVER and so is correctly excluded), and commandprompt#930 brings this one. declared 9 · cluster-free 10 · driver-dependent 1 · disagreements [] corpus 238 passed · the gated set 9 files, 161 passed with psycopg absent Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
Two queries raise, a helper turns each into the same value, and they compare equal. The test is green and has asserted nothing about either query. That is
error-swallowed-to-empty, andtest/lib.shclosed it years of commits ago by producing the sentinel with a sequence number per failure —res="QUERY_ERROR.$seq"— so two failures can never compare equal.The pytest port had the constant
QUERY_ERROR = "QUERY_ERROR", a comment claiming it was "unique per occurrence" — which is false of a constant — and a refusal in exactly one assertion.Measured before the fix, a sentinel on both sides
expect.hashexpect.textexpect.rowsexpect.row_setexpect.ordered_rowsexpect.num,at_least,rowcountFour of the five comparisons accepted two failed queries as agreement. Two different sentinels correctly failed, which is exactly why
lib.shmakes them unique.One refusal, called by every comparison
hashhad a refusal and the four assertions written after it did not — so the fix is one definition that every comparison calls, and an assertion added later inherits it instead of being the next hole. It matches the prefix at any depth, because a sentinel arrives as a cell inside a row ([('QUERY_ERROR.1',)]) as often as it arrives as a whole side.row_setrefuses before it maps, not after. It handsrowsa list ofreprstrings, andrepr(("QUERY_ERROR.1",))is"('QUERY_ERROR.1',)"— which does not start with the prefix. A refusal living only inrowstherefore cannot see a sentinel that arrived as a cell. Delegating an assertion does not delegate its refusals when the delegation transforms the data. The first version of this change had that hole and the fixture caught it.The refusal is the mechanism;
query_error()is the second line. A non-unique sentinel is safe against the layer, because no comparison accepts one at all. It is not safe against a helper that compares by hand, which is what the two existing hand-rolled sentinels intest_hilbert_locality.pydo. One of those is produced bycoalesce(...)inside SQL and cannot use a Python producer, which is the reason the refusal has to be the mechanism rather than the other way round. Both sites are left alone: the comment above one of them already measured thatexpect.hashrefuses the pair, and that is now true of every comparison.Each refusal is proved load-bearing
Removed one at a time:
row_setrow_set COMPARED a failed queryordered_rowsordered_rows COMPARED a failed queryrowsrows COMPARED a failed queryhashhash COMPARED a failed querytexttext COMPARED a failed querytext)The mutations are only distinguishable with
__pycache__cleared. The five deleted lines are byte-identical, so three of the five leave the file the same size and Python reuses the stale bytecode. Withoutrm -rf __pycache__between runs, mutations 3, 4 and 5 all reportrows COMPAREDand two of the refusals look like they do not bite. I believed that for two rounds before checking it, and the detail is inTESTS.mdso the next person does not.The arm is derived, not listed
_comparisons()finds every public assertion whose first two parameters are(got, want)or(got, floor), and a second arm requires each to have a declared valid pair — so an assertion added to the layer without a refusal reddens, rather than sitting quietly outside a loop that looked exhaustive.The first version of that loop fudged the argument shapes and never placed a sentinel in
at_least's floor at all. It reportedat_least ACCEPTED a failed query, which was my fixture's fault and not the layer's, and it is why the shapes are now a declared table with a premise over it.The hatch is closed too. Every
conftest.pyundertest/pytest/is imported before collection, so a module global is writable from the corpus the rule polices. An arm rewritespgc_vacuity.QUERY_ERRORto three values and requires the refusal to still arrive — distinguishing "refused" from "compared and happened to differ", which a weaker arm would not.A documentation correction, and it is mine
VACUITY_MODES.mdsaid of this mode: "The port has the sentinel constant but nothing produces it." Both halves were wrong. Two sites do produce sentinels by hand, and the thing actually missing was the refusal in four of the five comparisons. The correction is recorded in the document rather than quietly replacing the sentence, because a map that names the wrong gap is worse than one that admits it does not know.The mode moves to section 2, and the inventory's own arms forced the arithmetic: section 2
25 -> 26, section 347 -> 46, 72 named unchanged, plus the three prose totals the doc gates outside its table — andREADME.md. The historical sentence that records a count which was wrong is deliberately untouched.Evidence
Red before green: the table at the top is the measurement taken before the refusal existed, on this branch's own base.
Relationship to my other open PRs
Independent branches off
main, deliberately — #925 stacking on #923 is why its CI could not start for a while. So: #926 (selftest 080), #927 (selftest 440, theraisesguard) and this one each branch fromf0f1f40.This one will conflict with #927 in
VACUITY_MODES.md,TESTS.mdandCHANGELOG.md: both move a mode from section 3 to section 2 and both therefore edit the same count rows. If both land the totals become27 / 45 / 72. Whichever goes second, I will resolve it and re-run both harnesses rather than adjust the numbers by hand.🤖 Generated with Claude Code
https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a