Skip to content

conformance: cmd/specdelta — hold the corpus still and vary only the binary - #869

Merged
tannevaled merged 4 commits into
mainfrom
specdelta-tool
Nov 2, 2026
Merged

tannevaled merged 4 commits into
mainfrom
specdelta-tool

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

cmd/ratchet judges one sweep against BASELINE. That is the right gate for
"may this land" and the wrong instrument for "what does this change do": a
baseline comparison folds in every difference between the recording machine and
this one. The 4.0 version move in #799 needed the other reading — hold the
corpus and the machine fixed, vary only the interpreter, and name every file
that moved.

$ specdelta -root ~/ruby-spec-corpus -dir core/set -quiet -repeat 3 ./rbgo-main ./rbgo-409
file                                        control   treat  delta
core/set/flatten_merge_spec.rb                    3       0     -3
core/set/to_s_spec.rb                             2       4     +2
core/set/divide_spec.rb                           9       8     -1
core/set/equal_value_spec.rb                      4       3     -1
...
57 file(s): control 175 passing, treatment 167 passing, -8
9 file(s) moved, 8 regressed

That table took the Set half of the version move from "eight files to work
through" to two defects: six of the eight are examples 4.0 stopped asking
for, which the deltas cannot say and the ruby_version_is guards can. The same
reading cut the whole move's remaining work from "−28 across 13 files" to ten
examples
, and corrected its benefit from a recorded +54 to a measured
+91 — the old figure was taken against a treatment seven merged fixes behind
its control.

⛔ -repeat N, because one run cannot tell a flake from a difference

The tool's first real use reported

core/random/bytes_spec.rb      9       8     -1

between two binaries differing by one file and three constants. Three runs
of each:

run 1 run 2 run 3
control 9 9 8
treatment 8 9 9

A coin flip, attributed to the change. That file carries no version guard at
all, which is what made the row suspicious enough to re-run — a row that did
sit behind a guard would have looked explained and gone into a report.

Above -repeat 1 each file runs N times per engine, the lowest count is
kept — not the mean and not the last, because the question is "did anything get
worse" and the answer must not rest on a lucky run — and an engine that
disagrees with itself marks the file UNSTABLE and has its delta excluded from
the verdict. The default stays 1, and the flag's help says plainly that the
regression verdict is only trustworthy above it.

Two channels, and the invariant I got wrong first

RBGO_RESULT pass=… comes out of the subject's own stdout (#843), so a
subject that miscounts can claim anything. The RBGO_DETAIL lines are the
independent channel.

My first version asserted details == fail + error and fired on its first real
run against core/set/set_spec.rb — pass=0 fail=0 error=0 skip=1 with one
skip detail, entirely well-formed. Reading spec_helper.rb rather than
assuming its shape gives five emission sites, two of which break any equality: a
guard-driven skip bumps its counter with no detail (:970), and a
before(:all) failure pushes a detail counted in no tally at all (:965,
:1020). What holds is details >= fail + error, and that last row is a hole in
the number the ratchet sums — filed as #865.

Refusals are exit 2, never 1

An engine that would not start, an unreadable RBGO_RESULT line, two result
lines in one run, and an empty population — a mistyped -dir would
otherwise print 0 files, no regressions and exit 0, which is the most
convincing possible way for this tool to tell someone their change is safe. A
file that produced no result line is named rather than counted as zero, which
is the distinction that made an endless range look like a load failure in #853.

Tests

parse first and hardest, since that is where a silent wrong answer would live:
seven malformed result lines that must each be an error, the no-result case that
must not be, the detail-shortfall table naming every shim emission site, the
empty-population refusal, four usage refusals, and end-to-end passes over fake
/bin/sh engines — an improvement exits 0 and the same pair reversed exits 1.
The instability test asserts both modes, so the difference between them is
visible rather than claimed.

The engines are shell scripts, not Ruby, because the tool knows nothing about
Ruby and a test needing an interpreter installed would measure the machine.

⛔ Two of those tests failed first time and the tool was right both times: a
"good" result line written as fail=3 error=1 with three detail lines (four
expected), and stderr 0 vs 26 bytes where warning: something\n is 19.

README: a section beside cmd/oracle's, with the core/set table as the worked
example.

🤖 Generated with Claude Code

tannevaled and others added 4 commits November 1, 2026 11:22
…binary

`cmd/ratchet` judges one sweep against `BASELINE`. That is the right gate for
*"may this land"* and the wrong instrument for *"what does this change do"*: a
baseline comparison folds in every difference between the recording machine and
this one, and the version move in #799 needs the other reading — hold the corpus
and the machine fixed, vary only the interpreter, and name every file that moved.

```console
$ specdelta -root ~/ruby-spec-corpus -dir core/set -quiet ./rbgo-main ./rbgo-409
file                                        control   treat  delta
core/set/flatten_merge_spec.rb                    3       0     -3
core/set/to_s_spec.rb                             2       4     +2
core/set/divide_spec.rb                           9       8     -1
core/set/equal_value_spec.rb                      4       3     -1
core/set/exclusion_spec.rb                        3       2     -1
core/set/flatten_spec.rb                         10       9     -1
core/set/join_spec.rb                             5       4     -1
core/set/proper_superset_spec.rb                  3       2     -1
core/set/superset_spec.rb                         3       2     -1

57 file(s): control 175 passing, treatment 167 passing, -8
9 file(s) moved, 8 regressed
```

That table took the `Set` half of the version move from "eight files to work
through" to **two defects**, which is written up on #799.

## What it refuses, and why each refusal is not a 1

Exit **2**, never 1, for: an engine that would not start, output with an
unreadable `RBGO_RESULT` line, two result lines in one run, and an **empty
population**. The last one matters most — a mistyped `-dir` would otherwise
print `0 files, no regressions` and exit 0, which is the most convincing
possible way for this tool to tell someone their change is safe.

`parse` refuses a missing or non-numeric field rather than defaulting it to
zero, for `cmd/ratchet`'s reason: a zero meaning "unreadable" and a zero meaning
"nothing passed" lead to opposite conclusions. A file that produced no result
line at all is *named* — "DID NOT RUN on the treatment" — rather than counted as
zero, which is the distinction that made an endless range look like a load
failure in #853.

## ⛔ The second channel, and the invariant I got wrong first

The `RBGO_RESULT pass=…` line comes out of the **subject's own stdout** (#843),
so a subject that miscounts can report any total it likes. The `RBGO_DETAIL`
lines are an independent channel, and reading them rather than the tally is what
caught a figure misattributed between two branches earlier today.

My first version asserted `details == fail + error` and fired on its first real
run, against `core/set/set_spec.rb` — `pass=0 fail=0 error=0 skip=1` with one
`skip` detail, entirely well-formed. Reading spec_helper.rb instead of assuming
its shape gives this:

| kind | counter | detail line | |
|---|---|---|---|
| `fail` | `$RB_FAIL` | yes | :983, :987 |
| `error` | `$RB_ERROR` | yes | :995, :1029, :1102 |
| `skip` | `$RB_SKIP` | yes | :990 |
| `skip` | `$RB_SKIP` | **no** | :970 — a version/platform guard |
| `hookerror` | **none** | yes | :965, :1020 — before/after `:all` |

So no equality is derivable from the four printed numbers. What holds is
`details >= fail + error`, because those two kinds each push exactly one line,
and a shortfall means detail lines went missing. That is what the tool checks,
and the test names every row above — including the two that made equality wrong
in opposite directions.

The last row of that table is a defect in the harness rather than in this tool:
a `before(:all)` that raises is reported in the detail lines and counted in no
tally at all. Filed separately.

## Tests

`parse` first and hardest, since that is where a silent wrong answer would live:
seven malformed result lines that must each be an error, the no-result case that
must not be, the detail-shortfall table, the empty-population refusal, the four
usage refusals, and an end-to-end pass over fake `/bin/sh` engines in both
directions — an improvement must exit 0 and the same pair reversed must exit 1.
The engines are shell scripts, not Ruby, because the tool knows nothing about
Ruby and a test needing an interpreter installed would measure the machine.

⛔ One test failed first time and the tool was right: I had written a "good"
result line as `fail=3 error=1` with three detail lines, which is four expected
and three given. The message it printed said "check the arithmetic", and the
arithmetic to check was mine.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The tool's first real use reported

```
core/random/bytes_spec.rb      9       8     -1
```

between two binaries that differ by **one file and three constants** — a version
string. Three runs of each said why:

| | run 1 | run 2 | run 3 |
|---|---|---|---|
| control | 9 | 9 | **8** |
| treatment | **8** | 9 | 9 |

A coin flip, attributed to the change. `core/random/bytes_spec.rb` carries no
version guard at all, which is what made the row suspicious enough to re-run —
but a row that *did* sit behind a guard would have looked explained and gone
into a report.

## `-repeat N`

Above 1, each file runs N times per engine and the tool keeps the **lowest**
pass count — not the mean and not the last, because the question a reader asks
of this table is "did anything get worse" and the answer must not rest on a
lucky run. An engine that disagrees with itself marks the file `UNSTABLE`, and
its delta is **excluded from the verdict**, so the exit status stops depending
on which way the coin landed:

```console
$ specdelta -dir core/random -repeat 3 ./rbgo-main ./rbgo-409
core/random/bytes_spec.rb   8   8   +0  ⛔ UNSTABLE: an engine disagreed with itself, delta excluded
...
10 file(s): control 65 passing, treatment 65 passing, +0
0 file(s) moved, 0 regressed
```

The default stays 1, because three passes over `core/` is three times the
twenty-odd minutes — and the flag's help says plainly that the regression
verdict is only trustworthy above 1.

`-repeat 0` is a usage refusal rather than a silent no-op.

## Tests

The instability test asserts **both** modes, so the difference between them is
visible rather than claimed: with `-repeat 1` the flake reads as a regression
and exits 1; with `-repeat 3` the file is named `UNSTABLE`, counted as
`0 regressed`, and exits 0. Its fake engine scores 8 the first time it is asked
and 9 after, which is the direction that looks like a regression on the first
run. Two controls beside it: a stable engine must not be called unstable, and a
stable pair must stay at exit 0.

Also pinned, because `abs` measured 0% and the ordering is documented
behaviour: the loudest movement prints first. It needs two files — with one, the
sort comparator is never called — and they are named so that alphabetical order
and movement order disagree.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A section beside `cmd/oracle`'s, with the `core/set` table as the worked example
and the three refusals spelled out: the flake detection, the two-channel check
and the empty-population exit 2.

The `Set` row is the point of the tool in one line — it took that half of the
4.0 version move from "eight files to work through" to **two defects**, because
six of the eight are examples 4.0 stopped asking for and a delta cannot say so.
Only the `ruby_version_is` guards can, which is why the section says to read
them before reading the numbers.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 96b10c4 into main Nov 2, 2026
26 checks passed
@tannevaled
tannevaled deleted the specdelta-tool branch November 2, 2026 11:35
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