Repository navigation
specdelta: three refusals, each earned by a published figure that was wrong - #874
Merged
Merged
Conversation
The tool compared **passing** counts. A file that goes from *no examples* to *many failing examples* has a pass delta of zero and never appeared in the report — not even without `-quiet`. That is not hypothetical: it cost a published figure. Measuring the 4.0 version move, these were invisible: ```text core/binding/implicit_parameter_get_spec.rb 0 → 0 +0 fail+error 0 → 14 core/binding/implicit_parameter_defined_spec.rb 0 → 0 +0 fail+error 0 → 13 core/binding/implicit_parameters_spec.rb 0 → 0 +0 fail+error 0 → 10 core/marshal/dump_spec.rb 191 → 191 +0 fail+error 17 → 20 language/predefined_spec.rb 141 → 141 +0 fail+error 20 → 24 ``` The three `core/binding/` files are `pass=0 fail=0 error=0` at 3.4.1 — every example behind a `ruby_version_is "4.0"` guard — and 37 failures at 4.0.9, where the guard opens and three new Binding methods turn out not to exist. "Ten examples of work" was short by about sixty-five, and #799 is corrected. Six more files were reported as **gains** while also gaining failures. The worst reads `+3` in the old output: ```text core/thread/raise_spec.rb 48 → 51 +3 ⛔ fail+error 10 → 26 ``` Nineteen examples arrive at 4.0; three pass and sixteen fail. A pass-only delta cannot say that, and it was quoted as though it could. ## The change A row prints whenever **either** the passes moved or the failures did, carrying `⛔ fail+error A → B`, and the summary counts the pass-neutral ones separately: `N gained failures without losing passes`. They are named apart because such a file is not a regression in the passing total and would otherwise read as nothing at all. The test asserts that `-quiet` does **not** suppress them, since a delta of zero is exactly how they hid, and keeps the control beside it: a file quiet on both engines must still stay out of a `-quiet` report, or every unchanged file would print. ⛔ The lesson behind it was already written down and not applied: ask what the target version **adds** before measuring what moves. A delta cannot see an API absent on both sides of the comparison when the specs for it are guarded away on one of them. Refs #799 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…refusal
The census of what a version move does to the corpus already existed, in
cmd/guards, and its own README said what it was: "an analysis tool, not a gate:
nothing fails when it is not run."
On 2026-11-01 it was not run. The 4.0 move was measured by comparing PASSING
counts between two engines and published as "ten examples of work". The real
figure is about seventy-five. Three core/binding/implicit_parameter*_spec.rb
files are `pass=0 fail=0 error=0` below 4.0 -- every example behind a
`ruby_version_is "4.0"` guard -- and 37 failures above it, and `-dir` had never
been pointed at core/binding at all. The note saying "ask what the target
version ADDS before measuring what moves" had been written months earlier. The
note was not the missing part.
## internal/guardcensus
The reading moves out of cmd/guards into a package both commands use, so the
two cannot drift. It gains the part a census of one version never needed: a
version PREDICATE, which is spec_helper.rb's `_version_in_range` (:133) and
`_ver_cmp` (:119) reimplemented in Go -- a bare string is a lower bound, `...`
excludes the top, an empty endpoint is unbounded, absent components read as
zero. Reimplemented rather than called because the census must answer for
versions the running interpreter is not, which is the whole question.
`ClassifyMove(sites, from, to)` then sorts every site into what the move
OPENS (examples appear), CLOSES (they vanish) and FLIPS (the same example
asserts something else). Against the pinned corpus, 3.4.1 -> 4.0.9 opens 67
block guards in 50 files, closes 45 in 37, and flips 5 inline sites in 3.
## The gate
`specdelta -move` is REQUIRED and has no default, because the results cannot
answer it: a run across a version move and a run within one produce the same
table, and only one is a reading of the engines.
-move none both engines read the corpus at the same RUBY_VERSION
-move 3.4.1:4.0.9 they do not
With a move declared, specdelta censuses the corpus and REFUSES -- before
starting either engine -- when the move touches a file `-dir` does not measure.
On the real corpus:
specdelta: the move 3.4.1 → 4.0.9 touches 49 file(s) that -dir core/set
does not measure:
core/array/detect_spec.rb
core/binding/implicit_parameter_defined_spec.rb
core/binding/implicit_parameter_get_spec.rb
core/binding/implicit_parameters_spec.rb
…
The three files that cost the published figure are named, and nothing ran. The
refusal comes first on purpose: a core/ sweep is an hour, and an hour spent on
a figure already known to be incomplete is the cost this exists to avoid.
`-partial` is the named derogation and it STAMPS the report rather than
silencing the gate -- an opt-in that only removed a message would leave the same
number published with nothing attached to it.
A measured file the move opens is marked `⛔ THE MOVE OPENS ≥N examples here`
and is never dropped by `-quiet`: a delta of zero on such a file is the
signature of the defect, not of calm. The summary reports the move's share
apart from the engines'.
## Tests
The gate's test asserts the refusal AND that no engine was started (the fake
engine appends to a file; the file must not exist). The out-of-reach refusal is
tested against the real shape -- core/set measured, core/binding opened -- with
two controls beside it: widening `-dir` is accepted, and a move declared `none`
leaves the same quiet file out of a `-quiet` report. Without those controls the
refusal would only prove the gate always fires.
cmd/guards keeps its own control: 56 guards at "4.0" under a `do$` anchor still
REPRODUCES.
Refs #799
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second rule this tool now enforces, and it is a repeat offence. On 2026-11-01 `pass=118 fail=1` was published for #858 from a binary built BEFORE that branch existed; it carried the previous branch's code, and the true figure was 117/2. The correction had to go on the record. The same shape was already in the notes from an earlier arc -- a control built from the WORKING TREE rather than from the commit, which attributed +3 to the wrong fix. Written down twice, and it happened a third time. `go build` stamps `vcs.revision` and `vcs.modified` into every binary it produces from a repository. Verified on a real one rather than from the documentation: $ go version -m ./sd build vcs.revision=acc31a7cf31e787267ebbaef4cc3088f50eefd86 build vcs.modified=true So the binary can be asked what it is, through debug/buildinfo, before either engine runs. Three refusals, each a way the comparison is void before it starts: the same revision twice one of them was not rebuilt vcs.modified=true the revision does not name the code inside the tree moved past it the binary is not the code being measured (#858) Run against this worktree while it was dirty, the gate answers: specdelta: this comparison cannot be attributed to any pair of commits: the control was built from a tree with uncommitted changes, so acc31a7 does not name the code it contains An engine with no Go build information -- MRI, or the shell fakes the tests use -- is NOT refused: it is legitimately not a Go binary. It is STAMPED `⛔ UNPROVENANCED`, because a table nobody can attribute must not look like one that can. `-unprovenanced` is the named derogation and it stamps rather than silences, for the same reason. Both revisions print above the table, so a pasted figure carries what produced it. The one that was wrong carried nothing at all. The policy is tested as a table, through a replaceable `readBuildFacts`, rather than by building Go binaries inside a test to observe a decision that does not depend on building them. The #858 row is in it by name. Refs #799 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured today, control and treatment built from the same commit minus one
change:
core/thread/raise_spec.rb 51 → 68 +17
53 file(s): control 328 passing, treatment 345 passing, +17
1 file(s) moved, 0 regressed
Exit 0. True, and incomplete: NINETEEN examples turned green and TWO turned
red. No count of passes, of failures, or of both can show a swap -- the two
movements partly cancel and the remainder reads as a clean win.
They were found by hand, by comparing the two runs' failure DESCRIPTIONS rather
than their number. The example that broke had been passing BECAUSE of a
different defect (Thread#raise ignored cause: entirely, so there was no trailing
Hash to mispop), which is why nothing about it looked wrong.
## The change
The detail lines' DESCRIPTIONS are collected as a multiset -- a shared example
block included twice in one file reports the same description twice, and losing
both is two examples, not one -- and every description the treatment reports
MORE OFTEN than the control is named, with its count:
core/thread/raise_spec.rb 51 68 +17
⛔ WENT RED: fail shared kernel_raise | raises a TypeError when passed
a message and an extra argument (×2)
1 file(s) moved, 0 regressed, 2 example(s) WENT RED
⛔ And it EXITS 1. An example that went red is a regression whatever the file's
total did; letting a per-file "0 regressed" carry it out on exit 0 is the exact
reading this exists to stop.
It keys on the description and NOT the message, because a message carries object
addresses (`#<Class:0x0000000000000008>`) that differ between two runs of the
SAME binary -- keying on those would make every file look changed. That is one
of the three cases the test pins.
## Verified against the run that produced the defect
Rebuilt and pointed at the same two binaries, the tool names the same two
examples I had found by hand, and the exit status goes 0 → 1.
## Tests
The swap, plus two controls: a file that ONLY improves must stay silent (or
every improved file would be flagged), and a differing MESSAGE under the same
description must not read as an example that broke.
Refs #799
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # scripts/conformance/rubyspec/cmd/specdelta/main.go # scripts/conformance/rubyspec/cmd/specdelta/main_test.go
This was referenced Nov 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three refusals in the instrument that measures this repository's conformance,
each one earned by a figure that was published and wrong.
1.
-moveis required, and has no defaultThe census of what a version move does to the corpus already existed, in
cmd/guards, and its own README said what it was: "an analysis tool, not agate: nothing fails when it is not run."
On 2026-11-01 it was not run. The 4.0 move was measured by comparing passing
counts and published as ten examples of work. The real figure is about
seventy-five. Three
core/binding/implicit_parameter*_spec.rbfiles arepass=0 fail=0 error=0below 4.0 — every example behind a guard — and 37failures above it, and
-dirhad never been pointed atcore/bindingat all.The note saying "ask what the target version adds before measuring what moves"
had been written months earlier.
So the reading moved into
internal/guardcensus— shared withcmd/guards, sothe two cannot drift — and specdelta will not produce a table until the caller
says which comparison this is:
-move noneRUBY_VERSION-move 3.4.1:4.0.9With a move declared it censuses the corpus and refuses before starting either
engine when the move touches a file
-dirdoes not measure:The refusal comes first because a
core/sweep is an hour. Against thepinned corpus,
3.4.1 → 4.0.9opens 67 block guards in 50 files, closes 45,flips 5 inline sites.
2. Provenance — a repeat offence
pass=118 fail=1was published for #858 from a binary built before thatbranch existed. The notes already held the same shape from an earlier arc: a
control built from the working tree rather than the commit, attributing
+3to the wrong fix.
go buildstampsvcs.revisionandvcs.modifiedinto every binary, verifiedon a real one rather than from the documentation. specdelta reads both engines
before running either, prints what it found above the table, and refuses:
vcs.modified=trueAn engine with no Go build information (MRI, a shell fake) is not refused —
it is legitimately not a Go binary — but the report is stamped
⛔ UNPROVENANCED.3. An example that went red inside a file that improved
Measured yesterday, control and treatment built from the same commit minus one
change:
True, and incomplete. Nineteen examples turned green and two turned red, and
no count of passes, of failures, or of both can show a swap. They were found by
hand, comparing the two runs' failure descriptions; the example that broke had
been passing because of a different defect, so nothing about it looked wrong.
specdelta now compares those descriptions as a multiset, names each one the
treatment reports more often, and exits 1:
Rebuilt and pointed at the same two binaries, it finds what I had found by hand
and the exit status goes 0 → 1.
Derogations, and controls
-partialand-unprovenancedstamp the report rather than silencing thegate: an opt-in that only removed a message would leave the same number
published with nothing attached to it.
Every refusal test carries a control beside it, or it would only prove the gate
always fires: widening
-diris accepted; a move declarednoneleaves a quietfile out of a
-quietreport; a file that only improves is not flagged; and adiffering message under the same description is not an example that broke
(messages carry object addresses that differ between two runs of the same
binary).
Refs #799
🤖 Generated with Claude Code