Skip to content

specdelta: three refusals, each earned by a published figure that was wrong - #874

Merged
tannevaled merged 5 commits into
mainfrom
specdelta-move-gate
Nov 3, 2026
Merged

tannevaled merged 5 commits into
mainfrom
specdelta-move-gate

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Three refusals in the instrument that measures this repository's conformance,
each one earned by a figure that was published and wrong.

1. -move is required, and has no default

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 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 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.

So the reading moved into internal/guardcensus — shared with cmd/guards, so
the two cannot drift — and specdelta will not produce a table until the caller
says which comparison this is:

declaration what it asserts
-move none both engines read the corpus at the same RUBY_VERSION
-move 3.4.1:4.0.9 they do not: the guards move too

With a move declared it censuses the corpus and refuses before starting either
engine
when the move touches a file -dir does not measure:

specdelta: the move 3.4.1 → 4.0.9 touches 49 file(s) that -dir core/set does not measure:
    core/binding/implicit_parameter_defined_spec.rb
    core/binding/implicit_parameter_get_spec.rb
    core/binding/implicit_parameters_spec.rb
    …

The refusal comes first because a core/ sweep is an hour. Against the
pinned corpus, 3.4.1 → 4.0.9 opens 67 block guards in 50 files, closes 45,
flips 5 inline sites.

2. Provenance — a repeat offence

pass=118 fail=1 was published for #858 from a binary built before that
branch existed
. The notes already held the same shape from an earlier arc: a
control built from the working tree rather than the commit, attributing +3
to the wrong fix.

go build stamps vcs.revision and vcs.modified into every binary, verified
on a real one rather than from the documentation. specdelta reads both engines
before running either, prints what it found above the table, and refuses:

what it finds why the comparison is void
the same revision twice one of them was not rebuilt
vcs.modified=true the revision does not name the code in the binary
the work tree moved past the binary's revision the binary is not the code being measured — #858

An 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:

core/thread/raise_spec.rb   51 → 68   +17
1 file(s) moved, 0 regressed          ← exit 0

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:

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

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

-partial and -unprovenanced stamp 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.

Every refusal test carries a control beside it, or it would only prove the gate
always fires: widening -dir is accepted; a move declared none leaves a quiet
file out of a -quiet report; a file that only improves is not flagged; and a
differing 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

tannevaled and others added 5 commits November 2, 2026 16:27
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant