Skip to content

images: count the pictures paired by size, and say so - #92

Merged
tannevaled merged 29 commits into
mainfrom
size-paired-share
Oct 6, 2026
Merged

tannevaled merged 29 commits into
mainfrom
size-paired-share

Conversation

@tannevaled

@tannevaled tannevaled commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Two commits, and the second is the one worth reading.

1. The field that documented a requirement the tool did not meet

PairedBy records how each picture was matched with poppler's: by object
number, or by the fallback that matches on decoded size. Its comment already
says what follows from that:

A run whose size share is large is a run whose numbers are worth less, and
that has to be visible.

It was visible nowhere. Tally dropped the field, so the share appeared in no
report line and in no JSON record — a reader of either could not tell a run
whose pictures were all matched by object number from one where the matching
was guesswork. The requirement was written down and not met.

What changes: Counts.SizePaired, incremented in Tally on PairedBySize
only; N paired by size at the end of the filter line; FilterCounts.SizePaired
with omitempty, so records written before this keep their present shape. No
comparison, no threshold and no gate changes.

How it is checked. The test asserts both directions, because a counter
that increments on every picture reads exactly like one that works — right up to
the run that is all object-paired and reports every picture as suspect. Three
mutations, each caught:

mutation result
increment unconditionally FAIL — DCTDecode counted 3 … want 2, JPXDecode counted 1 … want 0
drop the report column FAIL — the report does not carry the share
drop the record field FAIL — the record says 0 paired by size, want 2

First reading: ZERO, over 1 606 pictures — 330 across the five pdfscans
populations, 1 276 across all eighteen of pdfforms, 53 filter rows, none
paired by size. With a control, because a counter that never fires reads exactly
like a fallback that never fires: ablating the object test (object forced to 0
at the top of match) pairs every picture of the same sample by size. The
counter is live; the zero belongs to the corpus. Figures in the first comment.

2. §30 — §29's hazard, in the half of the document a program reads

§29 says this file must be read backwards because a section is true of the run
that wrote it. That applies to the records too, and there a strikethrough
will not do: a .json either describes the code or it does not.

what render jpeg2000 dated
the 23 baseline/*.json v0.35.0 v0.1.0 2026-09-24
go.mod v0.60.0 v0.9.1 —
§24 (timings) v0.60.0 v0.9.1 2026-09-28
§29 v0.67.0 v0.13.2 2026-09-30

Four subjects in one document, the machine-readable half the oldest of them.
Nineteen jpeg2000 releases have landed since the version the records name,
among them the decoder of §25 and the three blank pages of §26.

This is what blocks the aggregate bound. The 21 buckets carrying terms
invite one — mse.worst 0.654, |mean.worst| 0.579, peak.worst 4 in every
bucket but gh-openpdf's converted one at 20 — but a bound fitted to that is
fitted to v0.35.0, and borrowed strictness reddens on the first honest change
and is then deleted. The order is #55, then re-take the records, then read the
distribution again.

No gate is added, deliberately: a test asserting the records name go.mod's
version would redden every dependency bump, including the ones that change no
pixel. The move is the same one as §28 and as the commit above — make the number
visible.

gofmt, go vet clean; the full suite passes at 100.0% of statements in every
package, which is what the gate asks for.

🤖 Generated with Claude Code

PairedBy's own comment states the requirement:

	A run whose size share is large is a run whose numbers are worth
	less, and that has to be visible.

It was not visible anywhere. The pairing already records how each picture
was matched -- by object number, or by falling back to size -- but Tally
threw that away, so neither the report nor the JSON record carried it. A
reader of either had no way to tell a run where every picture was matched
by its object number from one where the matching was guesswork.

Counts gains SizePaired, Tally increments it on PairedBySize only, the
filter line ends with "N paired by size", and FilterCounts carries it into
the record (omitempty, so existing records that have no such number stay
as they are).

The test asserts both directions. A counter that increments on every
picture reads exactly like one that works, until the run that is all
object-paired reports every picture as suspect -- so the JPXDecode row,
which has no size-paired picture in it, must come out zero. Checked by
mutation: counting unconditionally, dropping the report column, and
dropping the record field each make it fail.
Two entries in "What is not measured, and why" are addressed, one of them
by closing it and one by naming what blocks it.

The pairing entry asked whether a per-picture pairing audit had been run
since conformance#13 closed. It is now counted in every run rather than
audited once, and the first reading is ZERO over 1 606 pictures -- 330
across the five pdfscans populations, 1 276 across all eighteen of
pdfforms, 53 filter rows, none paired by size.

A zero needs a control, because a counter that never fires reads exactly
like a fallback that never fires. Ablating the object test -- object
forced to 0 inside match, the only input its first loop reads -- pairs
EVERY picture of the same sample by size. The counter is live and the
zero belongs to the corpus.

§30 is the other half, and it is the uncomfortable one. §29 says this
file must be read backwards because a section is true of the run that
wrote it; that applies to the records too, and there a strikethrough will
not do. All twenty-three are dated 2026-09-24 at render v0.35.0 and
jpeg2000 v0.1.0, while go.mod holds v0.60.0/v0.9.1 and §29 measures
v0.67.0/v0.13.2 -- four subjects in one document, with the
machine-readable half the oldest of them. Nineteen jpeg2000 releases have
landed since the version the records name, including the decoder of §25
and the three blank pages of §26.

That is what blocks the aggregate bound. The twenty-one buckets carrying
terms do invite one -- mse.worst 0.654, |mean.worst| 0.579, peak.worst 4
everywhere but gh-openpdf's converted bucket at 20 -- but a bound fitted
to them is fitted to v0.35.0, and a gate borrowed from code that no
longer exists reddens on the first honest change and is then deleted. The
next step is the records, not the bound.

No gate is added. A test asserting the records name go.mod's version
would redden every dependency bump; the move here is §28's and
conformance#92's -- make the number visible and let the reader see that a
record is six days and thirty-two releases behind what it is cited for.
@tannevaled

Copy link
Copy Markdown
Contributor Author

The first reading, with a control

The counter is not only plumbing now -- it has been run.

Zero, over 1 606 pictures. Measured 2026-10-01 at this branch's go.mod
(render v0.60.0, go-images/jpeg2000 v0.9.1):

corpus populations pictures paired by size
pdfscans (-limit 40) 5 330 0
pdfforms (-limit 60) 18 1 276 0
53 filter rows 1 606 0

Every picture both sides took out was matched on the one identity both publish.

The zero needed a control

A counter that never fires reads exactly like a fallback that never fires, and
nothing in the output distinguishes them. So the object test was ablated --
object forced to 0 at the top of match, which is the only input its first
loop reads -- and the same sample came back with every picture paired by
size:

DCTDecode                  7 pictures   ...   7 paired by size
(samples)                  1 pictures   ...   1 paired by size
(samples) mask             3 pictures   ...   3 paired by size
JBIG2Decode mask           8 pictures   ...   8 paired by size
JPXDecode                 16 pictures   ...  16 paired by size

The counter is live. The zero is a property of the corpus, which is the answer
conformance#13 was looking
for when it was closed.

And the second commit, which is less comfortable

Reading the baseline backwards for open items turned up a worse version of §29's
own hazard. §29 says a section of that file is true of the run that wrote it. So
are the records, and a .json cannot be struck through.

what render jpeg2000 dated
the 23 baseline/*.json v0.35.0 v0.1.0 2026-09-24
go.mod v0.60.0 v0.9.1 —
§24 (timings) v0.60.0 v0.9.1 2026-09-28
§29 v0.67.0 v0.13.2 2026-09-30

Four subjects in one document, the machine-readable half the oldest. Nineteen
jpeg2000 releases have landed since the version the records name, including
the decoder of §25 and the three blank pages of §26.

This is what blocks the "no aggregate bound is applied" entry. The 21 buckets
that carry terms do invite one — mse.worst 0.654, |mean.worst| 0.579,
peak.worst 4 in every bucket but gh-openpdf's converted one at 20 — but a
bound fitted to that is fitted to v0.35.0, and borrowed strictness reddens on
the first honest change and is then deleted. §30 says so and does not add a
gate
: a test asserting the records name go.mod's version would redden every
dependency bump. The order is #55, then re-take the records, then read the
distribution.

🤖 Generated with Claude Code

§30 names the records as the next step, and nothing in the repository said
how to take them. The top-level README shows one invocation of
"images -json"; neither file said how the SET is taken, what it is named,
or what it costs.

So: one invocation per population, which is where the filenames in
baseline/ come from, and omitting -only gives one record carrying every
population of that corpus -- not the shape baseline/ holds.

With the cost, measured one at a time on 2026-10-01: fr-cerfa (450 forms)
about two minutes, ia-biodiversity (250 scans) about fifty. And with the
reason to take them one at a time, which is §22's: the judge is held to a
wall-clock bound, that bound is a property of the machine rather than of
the corpus, and two runs beside each other can turn a document into a
hung entry neither would produce alone.
§28 is titled "The one cell in this document that said «defect», and
closing it", and it is not closed. Re-taken 2026-10-03 at the versions §28
names -- render v0.67.0, go-images/jpeg2000 v0.13.2, one population at a
time, with a v0.60.0 run of the same population as control -- four of its
five cells reproduce and the fifth does not:

                   §28 before  §28 after  re-measured
    pictures              754        755          755  ok
    direct                705        706          706  ok
    compared              650        651          651  ok
    exact                 650        651          651  ok
    refused                 1          0            1  NOT

bulletinno38tasm.pdf is still refused, byte-identically at v0.60.0 and
v0.67.0, so the seven releases §28 credits changed nothing about it. It is
not §26's ceiling either. Page 1 names THREE pictures of 9449 by 13701 --
two JPEG 2000 greys and a JBIG2 soft mask, all three of which pdfimages
takes out -- each 129 460 749 pixels. Two spend 258 921 498 of the
268 435 456 budget and leave 9 513 958, which is the number in the
message; the third cannot be afforded. The charge is honest for the path:
decodeBase says r.bounded is true exactly on the Images path, which hands
pictures out through raster.Image and so spends four bytes a pixel. Three
pictures of 129.5 Mpx is 1.447 GiB at four bytes against a 1.000 GiB page
budget, and 0.362 GiB as the single-component greys they are. Tracked as
go-pdfkit/render#100.

Looking for the document turned up a defect in the instrument instead.
Ours is documented as "ours would not draw it AND THE JUDGE WOULD", and it
is set at two places: reader.Open failing, which goes through blame(), and
render.Images failing on a page, which asked nobody. A page refusal went
into the defect column whatever poppler did with the same page. Here it
happens to be right, but by hand, not by instrument.

judgePage now asks, of the same tool on the same page, the way the branch
below it already does: pictures back is Ours, a tool that does not finish
is Hung, and a refusal -- or a run that takes nothing out, which
judgeShots reports the same way -- is Neither. The vocabulary now says
that the last two are not told apart instead of implying a distinction the
code cannot make.

It changed an answer at once: TestAPageThatIsNotThereSaysSo asks for page
nine of a one-page document and asserted Ours, so the instrument called a
page that does not exist a defect of ours. Asked, poppler cannot give page
nine either. Checked by mutation -- never asking the judge, and swapping
Hung for Neither, each make the tests fail. 100.0% of statements in every
package.

And §30 is why this sat for nine days: §28's figures were taken in a tree
with the modules upgraded by hand and never written to baseline/, so the
only copy was prose, which cannot be re-run. The committed record said
refused: 1 the whole time. The repository contained its own refutation.
ia-biodiversity re-taken a third time with the asking in place: refused is
still 1, unopenable and declined still 0, every filter row identical to the
run before it. A classification that quietly moved a real defect into
Neither would be indistinguishable from a fix when read off the test suite
alone; asked of the document it was written for, it leaves it where it
belongs, because pdfimages does hand three pictures back.

Says explicitly that none of the three runs is committed to baseline/:
they were taken at render v0.67.0 while go.mod holds v0.60.0, which is
what §30 is about.
@tannevaled

Copy link
Copy Markdown
Contributor Author

§28's one cell does not reproduce, and the instrument was asserting half of its own definition

Two more commits, and they are the reason this PR grew.

The measurement

ia-biodiversity re-taken at the versions §28 names — render v0.67.0,
go-images/jpeg2000 v0.13.2 — one population at a time, with a v0.60.0 run of
the same population as a control. The control reproduces §28's before column
exactly, which is what makes the after column readable:

ia-biodiversity §28 before §28 after re-measured
pictures 754 755 755 ✓
direct 705 706 706 ✓
compared 650 651 651 ✓
exact 650 651 651 ✓
refused 1 0 1 ✗

bulletinno38tasm.pdf is still refused, byte-identically at v0.60.0 and
v0.67.0
, so the seven releases §28 credits changed nothing about it. It is not
§26's ceiling either. Page 1 names three pictures of 9 449 × 13 701 — two
JPEG 2000 greys and a JBIG2 soft mask, and pdfimages -list takes out all three
— each 129 460 749 pixels. Two spend 258 921 498 of the 268 435 456 budget and
leave 9 513 958, which is the figure in the message; the third cannot be
afforded. The charge is honest for the path: decodeBase says r.bounded is
true exactly on the Images path, which hands pictures out through
raster.Image and so spends four bytes a pixel. 1.447 GiB for that page against
a 1.000 GiB budget; 0.362 GiB as the single-component greys they are. Filed as
go-pdfkit/render#100.

The defect that was in the instrument

Ours is documented as "ours would not open the document or draw the page and
the judge would
. That is a defect and the only one of these that is."
It is
set at two places:

where asked the judge?
reader.Open fails yes — blame()
render.Images fails on a page no

A page refusal went into the defect column whatever poppler did with the same
page. Here it happens to be right, but by hand in a markdown file, not by the
instrument.

judgePage now asks, of the same tool on the same page, the way the branch
below it already does when the judge takes nothing out of a page ours drew for:
pictures back is Ours, a tool that does not finish is Hung, and a refusal —
or a run that takes nothing out, which judgeShots reports the same way — is
Neither. The vocabulary now states that those last two are not told apart,
instead of implying a distinction the code cannot make.

It changed an answer at once. TestAPageThatIsNotThereSaysSo asks for page
nine of a one-page document and asserted Ours: the instrument called a page
that does not exist a defect of ours. Asked, poppler cannot give page nine
either. Two mutations — never asking, and swapping Hung for Neither — each
make the tests fail.

And it was checked against the corpus, not only its tests.
ia-biodiversity re-taken a third time with the asking in place: refused
still 1, every filter row identical. A classification that quietly moved a real
defect into Neither would look like a fix from the test suite alone.

Why this sat for nine days — which is §30

§28's figures were taken in a tree with the modules upgraded by hand and were
never written to baseline/, so the only copy of them was prose, and prose
cannot be re-run. The committed record said refused: 1 the whole time. The
repository contained its own refutation and nothing compared the two halves of
its document.

None of the three runs here is committed: they are at render v0.67.0 while
go.mod holds v0.60.0, which is exactly what §30 says not to do. #55 first.

gofmt, go vet clean; 100.0% of statements in every package.

🤖 Generated with Claude Code

… is one

A correction to §31, found the same day, by asking the renderer the
question the column's own words name. afford() and r.budget are reached
from images.go ALONE, and bounded:true is set at exactly one place in the
whole module -- inside Images. So:

    Images  REFUSED: ... with 9513958 of the 268435456 pixels left
    Page    ok: 411x596, 23659 dark pixels, 216735 light, 4562 between

Not a blank page with a clean exit either. Through this repository's own
compare, against pdftoppm at 72 dpi: 7.30% of pixels differ, mean 14.875
levels, and we take 795ms against poppler's 2.992s. A 9449x13701 scan
reduced to 411x596 is a 23x downscale, which is the resampling
disagreement §1 describes and the reason this baseline measures codecs
through images rather than through a rasteriser.

So Ours, whose comment said "would not open the document or draw the
page", is here NEITHER: ours draws the page. The comment now says what the
column measures -- no picture came out of the extraction -- and names the
instance, because a vocabulary that overstates is how §28 came to be
written. render#100 drops from a gap against poppler to a limit of one
API, and says so.

Then the census, so that the limit is sized rather than guessed. Every
document of both corpora, page 1, our side only, never poppler:

    3281 documents
      67 our side produced nothing for
      66 of them refused at reader.Open
       1 of them refused by the page budget

One document in 3 281. And the sixty-six are §27's sixty-six, recovered by
an instrument that asks neither build nor poppler: 39 unsupported security
handler, 23 wrong password, 2 no indirect objects, 1 /Brotli, 1 no
startxref, in the same populations (ia-americana 30, gh-openpdf 14,
gh-pdfbox 8, ia-texts 7, gh-safedocs 5, gh-pypdf 1, ia-medical 1). Two
instruments that share no dependency agreeing on a census is a control
this file has been short of -- three agreed once before and all three read
the same layer.
@tannevaled

Copy link
Copy Markdown
Contributor Author

Correction, and a census that sizes it

The page draws. Only the extraction refuses.

My own §31 said the refusal reaches the renderer. It does not. afford and
r.budget are reached from images.go alone, and bounded: true is set at
exactly one place in the whole module — inside Images.

Images  REFUSED: ... with 9513958 of the 268435456 pixels left
Page    ok: 411x596, 23659 dark pixels, 216735 light, 4562 between

Not a blank page with a clean exit: through this repository's own compare
against pdftoppm at 72 dpi, 7.30% of pixels differ, mean 14.875 levels, and we
take 795 ms against poppler's 2.992 s. A 9 449 × 13 701 scan reduced to
411 × 596 is a 23× downscale, which is the resampling §1 describes and the
reason this baseline measures codecs through images rather than a rasteriser.

So Ours — "would not open the document or draw the page" — is here
neither. The comment now says what the column measures and names the
instance, because a vocabulary that overstates is how §28 came to be written.

One document in 3 281

Every document of both corpora, page 1, our side only, never poppler:

documents 3 281
our side produced nothing for page 1 67
reader.Open refused the file 66
the page budget refused 1

And the 66 are §27's 66, recovered by an instrument that shares nothing with
the one that found them
: 39 unsupported security handler, 23 wrong password,
2 with no indirect objects, 1 /BrotliDecode, 1 with no startxref — in the
same populations (ia-americana 30, gh-openpdf 14, gh-pdfbox 8, ia-texts
7, gh-safedocs 5, gh-pypdf 1, ia-medical 1). §27 came out of a sweep that
compares two builds and asks poppler separately; this came out of a program that
asks neither. Two instruments with no shared dependency agreeing on a census is
a control this file has been short of — three agreed once before, and all three
were reading the same layer.

render#100 is re-framed
accordingly: a limit of one API, not a gap against poppler, priority set by a
census rather than by a guess.

Named, not acted on

Whether refused should ask render.Page as well is now a question about what
the column should mean, not a defect. It is in "What is not measured, and
why"
with its cost — one render.Page per refusal, and refusals are one
document in 3 281.

gofmt, go vet clean; the suite passes, 100.0% of statements in every package.

🤖 Generated with Claude Code

PairedBy's own comment states the requirement:

	A run whose size share is large is a run whose numbers are worth
	less, and that has to be visible.

It was not visible anywhere. The pairing already records how each picture
was matched -- by object number, or by falling back to size -- but Tally
threw that away, so neither the report nor the JSON record carried it. A
reader of either had no way to tell a run where every picture was matched
by its object number from one where the matching was guesswork.

Counts gains SizePaired, Tally increments it on PairedBySize only, the
filter line ends with "N paired by size", and FilterCounts carries it into
the record (omitempty, so existing records that have no such number stay
as they are).

The test asserts both directions. A counter that increments on every
picture reads exactly like one that works, until the run that is all
object-paired reports every picture as suspect -- so the JPXDecode row,
which has no size-paired picture in it, must come out zero. Checked by
mutation: counting unconditionally, dropping the report column, and
dropping the record field each make it fail.
Two entries in "What is not measured, and why" are addressed, one of them
by closing it and one by naming what blocks it.

The pairing entry asked whether a per-picture pairing audit had been run
since conformance#13 closed. It is now counted in every run rather than
audited once, and the first reading is ZERO over 1 606 pictures -- 330
across the five pdfscans populations, 1 276 across all eighteen of
pdfforms, 53 filter rows, none paired by size.

A zero needs a control, because a counter that never fires reads exactly
like a fallback that never fires. Ablating the object test -- object
forced to 0 inside match, the only input its first loop reads -- pairs
EVERY picture of the same sample by size. The counter is live and the
zero belongs to the corpus.

§30 is the other half, and it is the uncomfortable one. §29 says this
file must be read backwards because a section is true of the run that
wrote it; that applies to the records too, and there a strikethrough will
not do. All twenty-three are dated 2026-09-24 at render v0.35.0 and
jpeg2000 v0.1.0, while go.mod holds v0.60.0/v0.9.1 and §29 measures
v0.67.0/v0.13.2 -- four subjects in one document, with the
machine-readable half the oldest of them. Nineteen jpeg2000 releases have
landed since the version the records name, including the decoder of §25
and the three blank pages of §26.

That is what blocks the aggregate bound. The twenty-one buckets carrying
terms do invite one -- mse.worst 0.654, |mean.worst| 0.579, peak.worst 4
everywhere but gh-openpdf's converted bucket at 20 -- but a bound fitted
to them is fitted to v0.35.0, and a gate borrowed from code that no
longer exists reddens on the first honest change and is then deleted. The
next step is the records, not the bound.

No gate is added. A test asserting the records name go.mod's version
would redden every dependency bump; the move here is §28's and
conformance#92's -- make the number visible and let the reader see that a
record is six days and thirty-two releases behind what it is cited for.
§30 names the records as the next step, and nothing in the repository said
how to take them. The top-level README shows one invocation of
"images -json"; neither file said how the SET is taken, what it is named,
or what it costs.

So: one invocation per population, which is where the filenames in
baseline/ come from, and omitting -only gives one record carrying every
population of that corpus -- not the shape baseline/ holds.

With the cost, measured one at a time on 2026-10-01: fr-cerfa (450 forms)
about two minutes, ia-biodiversity (250 scans) about fifty. And with the
reason to take them one at a time, which is §22's: the judge is held to a
wall-clock bound, that bound is a property of the machine rather than of
the corpus, and two runs beside each other can turn a document into a
hung entry neither would produce alone.
§28 is titled "The one cell in this document that said «defect», and
closing it", and it is not closed. Re-taken 2026-10-03 at the versions §28
names -- render v0.67.0, go-images/jpeg2000 v0.13.2, one population at a
time, with a v0.60.0 run of the same population as control -- four of its
five cells reproduce and the fifth does not:

                   §28 before  §28 after  re-measured
    pictures              754        755          755  ok
    direct                705        706          706  ok
    compared              650        651          651  ok
    exact                 650        651          651  ok
    refused                 1          0            1  NOT

bulletinno38tasm.pdf is still refused, byte-identically at v0.60.0 and
v0.67.0, so the seven releases §28 credits changed nothing about it. It is
not §26's ceiling either. Page 1 names THREE pictures of 9449 by 13701 --
two JPEG 2000 greys and a JBIG2 soft mask, all three of which pdfimages
takes out -- each 129 460 749 pixels. Two spend 258 921 498 of the
268 435 456 budget and leave 9 513 958, which is the number in the
message; the third cannot be afforded. The charge is honest for the path:
decodeBase says r.bounded is true exactly on the Images path, which hands
pictures out through raster.Image and so spends four bytes a pixel. Three
pictures of 129.5 Mpx is 1.447 GiB at four bytes against a 1.000 GiB page
budget, and 0.362 GiB as the single-component greys they are. Tracked as
go-pdfkit/render#100.

Looking for the document turned up a defect in the instrument instead.
Ours is documented as "ours would not draw it AND THE JUDGE WOULD", and it
is set at two places: reader.Open failing, which goes through blame(), and
render.Images failing on a page, which asked nobody. A page refusal went
into the defect column whatever poppler did with the same page. Here it
happens to be right, but by hand, not by instrument.

judgePage now asks, of the same tool on the same page, the way the branch
below it already does: pictures back is Ours, a tool that does not finish
is Hung, and a refusal -- or a run that takes nothing out, which
judgeShots reports the same way -- is Neither. The vocabulary now says
that the last two are not told apart instead of implying a distinction the
code cannot make.

It changed an answer at once: TestAPageThatIsNotThereSaysSo asks for page
nine of a one-page document and asserted Ours, so the instrument called a
page that does not exist a defect of ours. Asked, poppler cannot give page
nine either. Checked by mutation -- never asking the judge, and swapping
Hung for Neither, each make the tests fail. 100.0% of statements in every
package.

And §30 is why this sat for nine days: §28's figures were taken in a tree
with the modules upgraded by hand and never written to baseline/, so the
only copy was prose, which cannot be re-run. The committed record said
refused: 1 the whole time. The repository contained its own refutation.
ia-biodiversity re-taken a third time with the asking in place: refused is
still 1, unopenable and declined still 0, every filter row identical to the
run before it. A classification that quietly moved a real defect into
Neither would be indistinguishable from a fix when read off the test suite
alone; asked of the document it was written for, it leaves it where it
belongs, because pdfimages does hand three pictures back.

Says explicitly that none of the three runs is committed to baseline/:
they were taken at render v0.67.0 while go.mod holds v0.60.0, which is
what §30 is about.
… is one

A correction to §31, found the same day, by asking the renderer the
question the column's own words name. afford() and r.budget are reached
from images.go ALONE, and bounded:true is set at exactly one place in the
whole module -- inside Images. So:

    Images  REFUSED: ... with 9513958 of the 268435456 pixels left
    Page    ok: 411x596, 23659 dark pixels, 216735 light, 4562 between

Not a blank page with a clean exit either. Through this repository's own
compare, against pdftoppm at 72 dpi: 7.30% of pixels differ, mean 14.875
levels, and we take 795ms against poppler's 2.992s. A 9449x13701 scan
reduced to 411x596 is a 23x downscale, which is the resampling
disagreement §1 describes and the reason this baseline measures codecs
through images rather than through a rasteriser.

So Ours, whose comment said "would not open the document or draw the
page", is here NEITHER: ours draws the page. The comment now says what the
column measures -- no picture came out of the extraction -- and names the
instance, because a vocabulary that overstates is how §28 came to be
written. render#100 drops from a gap against poppler to a limit of one
API, and says so.

Then the census, so that the limit is sized rather than guessed. Every
document of both corpora, page 1, our side only, never poppler:

    3281 documents
      67 our side produced nothing for
      66 of them refused at reader.Open
       1 of them refused by the page budget

One document in 3 281. And the sixty-six are §27's sixty-six, recovered by
an instrument that asks neither build nor poppler: 39 unsupported security
handler, 23 wrong password, 2 no indirect objects, 1 /Brotli, 1 no
startxref, in the same populations (ia-americana 30, gh-openpdf 14,
gh-pdfbox 8, ia-texts 7, gh-safedocs 5, gh-pypdf 1, ia-medical 1). Two
instruments that share no dependency agreeing on a census is a control
this file has been short of -- three agreed once before and all three read
the same layer.
The pairing walks OUR pictures and appends one result for each, so a row
of the judge's that nothing claimed left no trace in any count. The one
direction this harness could not see was the direction that matters most:
a picture the field gets out of a file and we do not.

Its own test suite had already set the case up and recorded the blind spot
as the expected answer. TestAPictureTheOtherSideDoesNotHave stands in a
judge with a 9x9 against a page holding a 2x1: ours is unmatched AND
theirs is, the first has been reported since the day it was written, the
second was dropped, and the test asserted len == 1.

So: Missing.Unseen, one result per unclaimed row with Name left empty so
Tally skips it -- there is no filter of OURS to group it under, because
there is no picture of ours -- Summary.Unseen (omitempty), and a report
line, for the reason the hangs have one: a report that leaves it out
cannot be told from one that has none.

It is known to be non-empty by construction for at least one class.
render.Images does not return inline images, and says why: they are the
objects of nothing. pdfimages lists them with an object of 0. Whether
this corpus holds any is what the count is for.

Two mutations, and the SECOND needed a test written for it: dropping the
loop fails TestAPictureTheOtherSideDoesNotHave, but counting the CLAIMED
rows as well passed everything, because no test had a judge with one row
paired and one not. TestOnlyTheJudgePicturesNothingClaimedAreCounted is
that case, and it is the same hole SizePaired was guarded against -- a
counter that fires on everything reads exactly like one that works.

100.0% of statements in every package. §31's closing note is amended:
#55 landed on 2026-10-03, so a record taken at v0.67.0 now agrees with
the tree that reads it.
The branch was rebased onto main locally to get go.mod at render v0.67.0
for the record re-take, which made the push a force-push. Merging the
commits already on the remote instead keeps both histories and needs no
force. The trees are identical; only the SHAs differ.

# Conflicts:
#	baseline/README.md
#	images/images_test.go
The counter added in the commit before this one said 7 614 over the
eighteen forms populations, 7 480 of them in fr-cerfa, and that number was
wrong in the way a number is wrong when one document carries 304 of the
316 in a sixty-document sample.

The document is one this file already names, cerfa_10074.pdf, the page of
211 uniform 2x2 swatches under /SMasks. Asked directly:

    rows pdfimages -list prints              729
    distinct objects among them              214
    rows for object 147 alone                 62
    pictures render.Images returns           425
    729 - 425                                304   = the unseen count

pdfimages lists one row PER DRAW. The judgePage comment said a repeated
draw came back "against pdfimages's one"; that was never measured and is
wrong, and it is corrected where it stands.

Ours is one entry per object on purpose -- decoding the same stream twice
says nothing about a codec -- so the asymmetry is expected and harmless to
every fidelity figure here. Counting it as pictures we failed to get is
not. Missing.Repeated is now its own count, in the record and on a report
line printed beside Unseen so the small number is read as small.

What is left in Unseen is a scope difference and it is real: six rows in
those sixty documents, one of which is cerfa_10011.pdf's 106x56 DCTDecode
reached as /Annots -> /Widget /Btn -> /MK /I -> Form /FRM -> /Im0, a push
button's ICON. Images walks the content stream and cannot see it;
pdfimages renders the page with its annotations. A tool written to count
exactly those agreed DOCUMENT BY DOCUMENT on 56 of 60, and the four it
missed are the repeat-draw case. render.Page draws annotations, so this is
not a decoder gap -- it is the extraction API's scope, which is §31's
finding in a different place.

Both classifications are mutation-checked in both directions: never
repeated gives "repeated 0, want 1" and "unseen 2, want 1"; always
repeated breaks the two tests that need an unseen. 100.0% of statements.

§32 has the measurement. Whether Images should walk /AP and /MK is named
there and not decided: it changes every count in the file.
§32 offered annotations as the cause and that was fitted to a sample where
the thing being counted was almost always nought. Fifty-six of sixty
documents "agreeing" was fifty-four pairs of noughts.

Over the whole fr-cerfa population instead -- 450 forms, page 1 each --
there are 4 153 unseen rows after the repeat split, and a program that
counts the pictures only an annotation reaches finds 25 of them in all
450. Grouped by what the judge's own listing says:

    4 147  object 0
    2 614  stencil type
    1 538  image type
    2 592  16x16
       13  documents of 450 carrying any
    2 592  cerfa_12481.pdf alone

An object of nought is what pdfimages prints for an INLINE image, and
render.Images says plainly that it does not return those. Read at the
byte level, the first three of that document's eight content streams hold
751 BI...ID images, the first being

    /IM true /W 16 /H 16 /BPC 1 /D[1 0] /F /CCF /DP<</K -1 /Columns 16>>

a 16x16 CCITT-fax image mask, which is exactly the stencil 16x16 row
poppler lists.

So a class of 4 147 pictures in one population is drawn by render.Page
and compared against the reference by nothing: a defect in inline-image
decoding would be invisible to every figure in this baseline. Filed as
go-pdfkit/render#101.

It also explains a nought from two sections earlier. SizePaired is zero in
every population because the pairing falls back to size exactly when an
object number is missing, and the one class whose object number is missing
never reaches the pairing. The fallback was written for a case the API
cannot deliver.

The annotation case stays, measured and small, with cerfa_10011.pdf's
106x56 DCTDecode reached through /MK /I. Neither case is a decoder gap --
render.Page draws both -- and §32 keeps the method note, because this is
the third total in one day dominated by a single document.
@tannevaled

Copy link
Copy Markdown
Contributor Author

The counter's first number was wrong twice, and the second correction is the finding

The commit that added Unseen reported 7 614 pictures the reference extracts
and we do not, over the eighteen pdfforms populations. Two corrections later it
is 4 243, and it is not a gap against poppler.

First: a difference of UNIT, not of capability

One document carried 304 of the 316 in a sixty-document sample — the shape of a
dominating artefact. Asked directly, page 1 of cerfa_10074.pdf:

rows pdfimages -list prints 729
distinct objects among them 214
rows for object 147 alone 62
pictures render.Images returns 425
729 − 425 304 = the unseen count exactly

pdfimages lists one row per DRAW. The judgePage comment said a repeated
draw came back "against pdfimages's one" — never measured, and wrong; corrected
where it stands. Missing.Repeated now counts those apart, printed beside
Unseen so the small number reads as small.

Second: the cause I offered was the small one

I then named annotations as the cause, on a sample where a second program agreed
with the count on 56 of 60 documents. Fifty-four of those were 0 = 0.
Over the whole 450-document population, annotations explain 25 of 4 153.

Grouped by what the judge's own listing says:

of fr-cerfa's 4 153 unseen rows
object 0 4 147
stencil type 2 614
16×16 2 592
documents carrying any 13 of 450
cerfa_12481.pdf alone 2 592

Object nought is what pdfimages prints for an inline image, and
render.Images says plainly it does not return those. At the byte level, the
first three of that document's eight content streams hold 751 BI…ID
images, the first being

/IM true /W 16 /H 16 /BPC 1 /D[1 0] /F /CCF /DP<</K -1 /Columns 16>>

a 16×16 CCITT-fax image mask — exactly the stencil 16x16 row poppler lists.

So a class of 4 147 pictures in one population is drawn by render.Page and
compared against the reference by nothing.
A defect in inline-image decoding
would be invisible to every figure in this baseline. Filed as
go-pdfkit/render#101.

And it explains the nought this PR opened with

SizePaired is zero in all twenty-three populations because the pairing falls
back to size exactly when an object number is missing — and the one class whose
object number is missing never reaches the pairing. The fallback was written
for a case the API cannot deliver.
That is why the first commit's zero was
real and also uninformative, and it took the counter at the other end to say so.

The method note, which §32 keeps

Three totals in one day were dominated by a single document, and twice the first
explanation fitted the sample and not the population. An agreement where both
sides are mostly nought is not an agreement: a control's power is in its
positive cases, and a sample can hold six of them while reading as 93%.

gofmt, go vet clean; 100.0% of statements in every package; both
classifications mutation-checked in both directions.

🤖 Generated with Claude Code

… fixed

gh-qpdf's 13 unseen rows are 3 inline images, 0 annotation pictures, and
TEN that were a defect. The fixture names itself:
form-xobjects-some-resources2.pdf.

Both of render's paths had half a rule. drawForm and imagesDrawn each fell
back to the parent when a form XObject's /Resources was ABSENT, and a
PRESENT one replaced it wholesale -- so a name the form did not list
resolved to nothing and the picture was silently not drawn. A form's
resource dictionary is not required to be complete.

Six images against our two on that page, and it reached the page and not
only the extraction: through this repository's own compare against
pdftoppm, 8.54% of pixels away at a worst square mean of 130 levels.
After the fix, 0.03% and 8.80.

go-pdfkit/render#103 fixes it, filed as #102, checked in both directions
over the whole forms corpus: exactly one population moves, gh-qpdf's
pictures 63 -> 73, and the ten recovered land in (samples) direct as 51 of
51 exact and 51 of 51 byte-identical with poppler.

Which is what the counter was for. Unseen was added as a field documenting
a requirement, its first number was wrong twice, and what survived the two
corrections found a rendering defect every fidelity figure in this file
had been blind to -- because a picture nobody returns is a picture nobody
compares.
…r ranks

§32 demoted annotations to "real and small: 25 in 450", which is true of
fr-cerfa and false as a general claim -- the same mistake as the one the
section is about, in miniature.

    population   documents  unseen  object 0  annotation-reached
    fr-cerfa           450    4153      4147                  25
    us-uscis            88      73         0                  74

In us-uscis it is ONE picture per document in 73 of 88, and not one is
inline: g-1041.pdf's is a 1125x75 one-bit strip, which is a barcode. On a
population of government forms the barcode or logo that identifies the
form is the one picture per page nothing ever compares.

Filed as go-pdfkit/render#104. Neither cause ranks above the other -- each
dominates a different population -- and a single ordering of them would
have been wrong whichever way round it was written.
§30 said the records were thirty-two render releases and nineteen
jpeg2000 releases behind the tree that reads them, and that the next step
was the records and not a bound. Re-taken 2026-10-04 at render v0.67.0 and
go-images/jpeg2000 v0.13.2, one population at a time for §22's reason,
against the same pdfimages 26.04.0.

TWO CELLS MOVED in thirty-two releases:

    ia-biodiversity  JPXDecode direct  pictures  496 -> 497
                                       exact     496 -> 497
    gh-pdfbox        JPXDecode conv.   mean    -0.0020404 -> -0.0020666

and nothing else, in twenty-three populations: every other bucket is
identical cell for cell, to every digit recorded. The first is §26's page,
and the picture it gained comes out exact -- the one claim of §28's table
that §31 did not refute, now standing on a record instead of on prose. The
second is a signed mean moving by 0.000026 of a level on one picture.

So §30 was right about the fact and wrong about the risk it implied: a
record can be thirty-two releases stale in its metadata and current in its
numbers, and re-taking it is the only way to know which. What it buys is
that the prose and the records now describe the same code, which is what
§31 found had failed.

The fresh records also carry what the old ones could not: sizePaired 0,
unseen 4 277, repeated 3 371 over 3 280 documents and 8 067 pictures.

And §33 reconciles 66 with 63. §27 counts 66 documents neither draws; the
records say unopenable 63. Four scans files are on disk and in no manifest
row, and three of them are refusals -- two in ia-americana, one in
ia-medical. images and compare walk the MANIFEST; the sweeps behind §27
and §31 walk the directory. Neither is wrong and no count is transferable
between them without saying which it walked, which is why §24 reads 3 208,
§27 reads 3 281 and §33 reads 3 280.

One row has a fix waiting: render#103 takes gh-qpdf from 63 pictures to
73. These records are at the go.mod as it stands, so that row is expected
to move when it lands, and §33 says so before it does.
… the gate

The re-take needed the Conditions table to follow it, and the repository
said so itself: TestTheBaselineReadmeDescribesTheRecordsBesideIt failed on
the first run with three rows stale -- render v0.35.0, go-gfx v0.31.0 and
jpeg2000 v0.1.0 against records taken at v0.67.0, v0.34.0 and v0.13.2.

One of the three is worth naming. The jpeg2000 mention the guard reads is
a PROSE sentence, and my first correction put the new version on the next
line of the rewrapped paragraph -- so the version was in the file and not
on the row, which is exactly the case the guard's own comment says it was
narrowed to catch. Reworded so the version sits on the matched line.

And §30 is corrected. It argued against adding a gate, and a gate was
already there -- a better one. It asserts the README's rows match the
RECORDS, not go.mod, so a dependency bump alone cannot redden it; what
reddens it is a re-take whose README was not amended, which is what
happened here. A guard on agreement between two things this repository
holds costs nothing to keep green; a guard on agreement with the outside
world is a tax on every upgrade.

The "these figures still hold twelve releases later" paragraph is struck:
they held for thirty-two, and §33 says which two cells moved.
@tannevaled

Copy link
Copy Markdown
Contributor Author

The twenty-three records are re-taken, and the staleness was almost entirely immaterial

§30 said the records were thirty-two render releases and nineteen
go-images/jpeg2000 releases behind the tree that reads them, and that the next
step was the records and not a bound. Taken 2026-10-04 at render v0.67.0,
jpeg2000 v0.13.2
, one population at a time for §22's reason, same pdfimages
26.04.0.

Two cells moved

population cell v0.35.0, 2026-09-24 v0.67.0, 2026-10-04
ia-biodiversity JPXDecode direct pictures, and exact 496 497
gh-pdfbox JPXDecode converted signed mean −0.0020404 −0.0020666

And nothing else, in twenty-three populations — every other bucket identical
cell for cell, to every digit recorded. The first is §26's page, and the picture
it gained comes out exact: the one claim of §28's table that §31 did not
refute, now on a record instead of in prose. The second is a signed mean moving
by 0.000026 of a level on one picture.

So §30 was right about the fact and wrong about the risk it implied. A record
can be thirty-two releases stale in its metadata and current in its numbers
,
and re-taking it is the only way to know which.

What the fresh records carry

23 populations · 3 280 documents · 8 067 pictures
refused 1 (render#100)
unopenable / declined / hung 63 / 0 / 0
sizePaired 0
unseen 4 277
repeated 3 371

66 against 63, settled

§27 counts 66 documents neither implementation draws; the records say
unopenable 63. Both are right:

pdfscans  MANIFEST.tsv 1012 rows, 1013 files on disk
pdfforms  MANIFEST.tsv 2268 rows, 2268 files on disk

Four scans files are on disk and in no manifest row, and three of the four are
refusals
— two in ia-americana, one in ia-medical. 66 − 3 = 63, exactly.
images and compare walk the MANIFEST; the sweeps behind §27 and §31 walk the
directory, which is why §24 reads 3 208, §27 reads 3 281 and §33 reads 3 280.
No count is transferable between them without saying which it walked.

§30 was wrong about one more thing: the gate exists

§30 argued against adding a test that asserts the records name go.mod's
version, because it would redden on every dependency bump. That reasoning
stands — and a better gate was already there, which §30 should have said.
TestTheBaselineReadmeDescribesTheRecordsBesideIt asserts this file's version
rows match the records, on the row's own line. A dep bump alone cannot
redden it; a re-take whose README was not amended does. It caught this one on
the first run
, three rows stale, including one where my correction put the new
version on the next line of a rewrapped sentence — the exact case the guard's
own comment says it was narrowed for.

I pushed one commit before reading that failure, because I had written
go test ./... | grep -v '^ok' | head -3; echo green, and echo runs whatever
the pipeline said. The fix is in the next commit; the lesson is that a report
line which cannot say "no" is not a check.

One row has a fix waiting

render#103 takes gh-qpdf from
63 pictures to 73. These records are at the go.mod as it stands, so that row
is expected to move when it lands, and §33 says so before it does.

🤖 Generated with Claude Code

Two paragraphs had outlived their subject, which is the hazard §29 is
about, in the file a reader opens first.

"refused is 4 across 3280 documents, all in ia-biodiversity, and the same
four documents as before" -- it is 1, in the records re-taken 2026-10-04,
and the one document is bulletinno38tasm.pdf, whose page render.Page
DRAWS: it is the extraction API that refuses it under a per-page budget
the Images path spends four bytes a pixel of. §31 has the measurement and
render#100 holds the question.

"The instrument folds cannot-read and declined-to-decode into one count
and should not" -- it no longer asserts the other half either. Ours is
documented as "ours produced nothing and the judge did", and the page path
set it WITHOUT ASKING poppler. It asks now, and the first thing that
changed was a test demanding Ours for page nine of a one-page document.

"The bound is unexercised by images on this corpus" -- §22 struck that
months ago: the bound fired three times and it is a property of the
machine, not of the corpus. The narrower true thing is that none of these
23 records has a hung entry.

And the three counts the records now carry are described where the record
is described: sizePaired, unseen and repeated, with what each is FOR and
what it reads today -- 0, 4 277 and 3 371. All omitempty.
Everything above §34 measures extracted pictures -- images against
pdfimages, 23 records, every codec drilled to one picture -- and §24
measures time. The page itself was never recorded: baseline/pages/ held
one population, as a control of one binary against itself.

compare against pdftoppm at 72 dpi with -cropbox, one population at a
time, 2026-10-04, at the go.mod of the records beside it. All 23 raw
outputs are in baseline/pages/, unedited.

    3214 pages compared, 66 not
    3001 of 3214 differ on under 1% of their pixels  (93.4%)
    the MEDIAN is at or under 0.35% in every population, 0.000% in five

The 66 not compared are 63 refused, 2 of different pixel sizes and 1 hung,
and the 63 are the records' unopenable 63 by an instrument that renders
instead of extracting. That this run's total is ALSO 66 is a coincidence
of three causes rather than §27's 66, and §34 says so, because the number
invites the mistake.

The one hung makes concrete the paragraph the top-level README carries:
images reports hung 0 in all 23 records because that document draws no
picture on its first page and the extraction harness never asks poppler
about it. compare meets it.

And §34 says not to read a low byte-identical share as a defect.
ia-medical is 15.69% byte-identical against gh-verapdf's 99.60% and is not
twenty times worse -- its median difference is 0.000% of pixels. A
9000-pixel scan reduced to 600 is a 15x to 23x downscale, where any
resampling difference puts a level into most pixels and byte-identity
collapses while the picture does not.

The worst page of the corpus is ia-americana's 19.23% and is not diagnosed
here. Sixth worst is gh-qpdf's 8.54%, which render#103 takes to 0.03%:
these records are at the go.mod as it stands, and §33 says the same of the
row it moves.

Two things these files are not, both named: the timing columns were
measured while the load moved between 7 and 59 and are not to be read --
only the fidelity columns are deterministic, which is the sole reason the
run is usable -- and they are text, because compare has no -json. That is
a real gap against this repository's own "a number that is not written
down cannot be regressed against", and §34 records why it was not closed
here: the record type and its helpers live in cmd/images, package main, so
compare cannot import them, and moving them touches files an unmerged pull
request is changing.
@tannevaled

Copy link
Copy Markdown
Contributor Author

§34 — the page compared against poppler, which this repository had never recorded

Everything above §34 measures extracted pictures — images against
pdfimages, 23 records, every codec drilled to one picture — and §24 measures
time. The page itself was missing: baseline/pages/ held one population, as
a control of one binary against itself.

compare against pdftoppm at 72 dpi with -cropbox, one population at a
time, at the go.mod of the records beside it. All 23 raw outputs are committed
under baseline/pages/, unedited.

pages compared 3 214
differing on under 1% of their pixels 3 001 — 93.4%
median, in every one of the 23 populations ≤ 0.35%, and 0.000% in five
not compared 66

The 66, and why that number keeps appearing

63  refused — neither implementation opens the file
 2  different sizes — the two renderers produced different pixel dimensions
 1  hung — pdftoppm did not finish within the bound

The 63 are the records' unopenable 63, the same set, found by an instrument
that renders instead of extracting. That this run's total is also 66 is a
coincidence of three causes and not §27's 66 — §33 explains that one as a
directory-versus-manifest difference. §34 says so plainly, because the number
invites the mistake.

The one hung makes concrete the paragraph the top-level README carries.
images reports hung: 0 in all 23 records because that document draws no
picture on its first page, so the extraction harness never asks poppler about it
at all. compare meets it. Two instruments, two populations, neither count
wrong.

Do not read a low byte-identical share as a defect

population byte-identical median pixels differing
gh-verapdf 99.60% 0.000%
ia-medical 15.69% 0.000%

ia-medical is not twenty times worse. Its pages are 9 000 pixels wide reduced
to about 600 — a 15× to 23× downscale, where any difference in resampling puts a
level into most pixels and byte-identity collapses while the picture does not.
Byte-identity is a property of the scale asked for. The median and the
under 1% column are the figures to read, and this is the same reasoning §1
gives for measuring codecs through extraction rather than through a rasteriser.

The worst pages

ia-americana's 19.23% is the worst in the corpus and is not diagnosed.
Below it: gh-safedocs 13.57% (PatternTextInsideText.pdf), gh-pdfbox
11.11%, us-uscourts 10.49%, ia-biodiversity 9.90%, and then gh-qpdf
8.54%, which render#103 takes to
0.03%
— it is in the table because these records are taken at the go.mod as
it stands, and §33 says the same of the row it moves.

Two things these files are not, both stated in §34

The timing columns are not to be read. They were measured while the load
average moved between 7 and 59. Only the fidelity columns are deterministic —
both rasterisers are — which is the sole reason the run is usable at all. §24 is
where timings live, with its own conditions.

They are text, not a record. images has -json; compare has none, so
this is a tabulation of prose, exactly as §21's control is. That is a real gap
against this repository's own "a number that is not written down cannot be
regressed against"
, and §34 records why it was not closed here: judgeVersion,
modules and the baseline type live in cmd/images, package main, so
compare cannot import them, and moving them to an internal package touches the
very files this pull request is changing.

Also in this push: the top-level README said refused is 4 across 3 280
documents when the records say 1, and that the judge's bound was
"unexercised by images on this corpus" when §22 struck that months ago. Both
corrected, and the three counts the records now carry — sizePaired 0,
unseen 4 277, repeated 3 371 — are described where the record is described.

23 commits, 9/9 green.

🤖 Generated with Claude Code

The package comment of corpus has said since it was written that the
manifest "is what makes the corpus a measurement rather than a pile: it
says where each file came from, when, how big it is and what its bytes
hash to, so a figure quoted from it can be reproduced and a file that
changed underneath can be noticed".

Nothing noticed. The hash was written and never read back, and a file in
no row was invisible to every tool here. A documented capability with no
implementation.

corpus.Check reports a row with no file, a file that will not open, a size
or digest that moved, and a file on disk in no row. harvest -check prints
them and exits NON-ZERO, so it can be the first line of a measuring script
rather than something to remember to run; harvest because harvest is what
writes the manifest.

Run over the two corpora:

    pdfforms   2268 rows, clean
    pdfscans   1012 rows, clean, and FOUR files on disk in no row

    ia-americana/calcflh_000254.pdf
    ia-americana/epn11-1968countclip2restricted.pdf
    ia-biodiversity/bidragtillknne38suom.pdf
    ia-medical/b22346703.pdf

Three of those four are documents our reader refuses, which is the whole
of why images and compare count 63 refusals and a directory-walking sweep
counts 66 (§33). And every digest that IS recorded verifies, so no
document has changed underneath -- 1 012 full hashes and 2 268 prefixes.

The digest is compared over AS MANY CHARACTERS AS THE MANIFEST RECORDED.
The forms corpus keeps sixteen under the header "sha256-8"; the scans
corpus keeps sixty-four. Sixteen hex characters is sixty-four bits, which
answers "did this file change" perfectly well, and a check demanding all
sixty-four would call every one of 2 268 rows changed and be deleted by
the first person to run it. That is the mutation this is proved against.

An unreadable file is reported as unreadable and NOT as missing: missing
would send a reader to re-fetch a document that is already on the disk.

One branch was removed rather than covered: filepath.Rel cannot fail on a
path WalkDir has just produced under its own root, and an error branch
nothing can reach is a branch nobody can test. The relative path is
trimmed instead.

Three mutations, each caught: comparing the whole digest instead of the
recorded prefix, not looking for files in no row, and calling an
unreadable file missing. 100.0% of statements.
@tannevaled

Copy link
Copy Markdown
Contributor Author

A documented capability with no implementation

The corpus package comment has said since it was written that the manifest

says where each file came from, when, how big it is and what its bytes hash
to, so a figure quoted from it can be reproduced and a file that changed
underneath can be noticed
.

Nothing noticed. The hash was written by harvest and never compared by
anything, and a file on disk in no manifest row was invisible to every tool
here. This is the second instance in this pull request of the same shape — the
first was PairedBy's comment saying a large size-matched share "has to be
visible" while Tally dropped the field.

corpus.Check + harvest -check report a row with no file, a file that will
not open, a size or digest that moved, and a file in no row. Non-zero exit, so
it can be the first line of a measuring script rather than something to
remember.

$ harvest -check -dir /Users/Shared/pdfscans
ia-americana/calcflh_000254.pdf: on disk and in no manifest row
ia-americana/epn11-1968countclip2restricted.pdf: on disk and in no manifest row
ia-biodiversity/bidragtillknne38suom.pdf: on disk and in no manifest row
ia-medical/b22346703.pdf: on disk and in no manifest row
4 disagreement(s)

Three of those four are documents our reader refuses, which is the whole of
why images and compare count 63 refusals and a directory-walking sweep
counts 66 (§33). And every digest that is recorded verifies — 1 012 full
hashes and 2 268 prefixes — so no document has changed underneath.

The detail that would have broken it

The digest is compared over as many characters as the manifest recorded.
pdfforms, gathered before this repository existed, keeps sixteen under the
header sha256-8; pdfscans keeps all sixty-four. Sixteen hex characters is
sixty-four bits, which answers did this file change perfectly well — and a
check demanding all sixty-four would report every one of those 2 268 rows as
changed and be deleted by the first person who ran it. That is the mutation this
is proved against, with two others (not looking for files in no row, and calling
an unreadable file missing).

An unreadable file is reported as unreadable and not as missing: missing
would send a reader to re-fetch a document that is already on the disk.

One branch was removed rather than covered: filepath.Rel cannot fail on a
path WalkDir has just produced under its own root, and an error branch nothing
can reach is a branch nobody can test.

gofmt, go vet clean; 100.0% of statements.

And a note on how the mutation check nearly lied to me

The first run of the digest-prefix mutation printed nothing through
grep -E '^--- FAIL|^ok ', which I read as "survived". It had not survived: the
edit left an unused variable, the package did not compile, and a compiler error
is neither --- FAIL nor ok. Re-applied properly it fails two tests. Build
before testing, and assert the anchor before editing — a replace that matches
nothing looks exactly like a mutation the tests caught.

🤖 Generated with Claude Code

The windows-latest lane caught a defect in the check itself, not in its
tests: Entry.Path is slash-separated -- deliberately, so a corpus can be
moved -- and WalkDir hands back paths separated by the host's separator.
On Windows the comparison was "a\one.pdf" against the manifest's
"a/one.pdf", so EVERY file of a corpus came back as being in no manifest
row. A check that reports 3 280 disagreements on a clean corpus is worse
than no check.

filepath.ToSlash on the walked path, which is a no-op on the two platforms
I can run and the whole of the fix on the one I cannot.

And two tests are skipped there rather than failing: Chmod(0) denies
neither a file read nor a directory listing on Windows, so the unreadable
cases cannot be built at all. Skipped with the reason, beside the existing
skip for root, which cannot build them either.

This fix is verified by CI and not locally -- on macOS ToSlash is the
identity and the local run cannot tell the two versions apart. Said here
because it is the kind of claim worth marking.
judgePage returned nil as soon as render.Images handed back an empty
slice, so a page we produce nothing for produced no row -- not a refusal,
not an unseen picture, nothing. It has done that since it was written.

The corpus's WORST page is one of those. Page 1 of
sim_unitarian-...-1825-06-25_4_25.pdf:

    render.Images  ->  0 images, err == nil
    render.Page    ->  1269x1775, 154 055 dark pixels
    pdfimages      ->  3 pictures of 7048x9856

Each picture is 69 465 088 pixels, 3.5% past the per-picture ceiling, and
decodeBase drops it by returning nil -- which is not an error. compare
measures that page at 19.23% of pixels differing from poppler, the worst
of 3 214, and this instrument measured it not at all. Filed as
go-pdfkit/render#108.

Which also makes the count added in §32 an UNDERCOUNT, and systematically
so for the heaviest pages -- the ones a reader would most want it for.

So the judge is asked, and what it took out is counted as Unseen, which is
exactly what that word means. A page neither side produced a picture for
returns no row still: that is what most pages of most documents look like,
and a row each would bury the ones above. A judge that will not finish is
Hung, named.

Two mutations, caught: returning nil again, and counting them as Neither.
Both run after a go build, because a mutation that does not compile has
not been tested -- which is how a mutation "survived" earlier in this
branch. 100.0% of statements.
A security audit of the harness, from one question: what in here is data
that something else wrote? Three answers, and two had no guard.

1. A MANIFEST PATH WAS READ VERBATIM AND JOINED TO THE CORPUS.

   Entry.Path comes out of MANIFEST.tsv -- a data file beside the
   documents, in a shared directory, which nothing here necessarily wrote
   -- and was joined to the corpus directory by os.Stat, os.Open, a hash
   and the argv of a poppler invocation. filepath.Join neutralises an
   ABSOLUTE path by construction and does NOT neutralise "..":

       Join("/corpus", "../../etc/passwd") == "/etc/passwd"

   corpus.Read now refuses such a row. Refuses rather than cleans: a row
   trying to leave the corpus is not a row with a typo, and rewriting it
   would put a file in a measurement under a name the manifest did not
   give.

2. A REMOTE SERVER'S IDENTIFIER BECAME A LOCAL FILE NAME.

   harvest names what it fetches ReplaceAll(id, "/", "_") + ".pdf", where
   id comes from the archive's search results. The slash was replaced,
   which blocks a directory escape; a leading dash was not. Every poppler
   tool parses its arguments with getopt and the document path is
   positional, so a corpus file called "-v.pdf" makes pdfimages print its
   version and take no picture -- and the harness records the page as one
   the judge drew nothing for. A wrong measurement, from a file name.

   Guarded twice on purpose: internal/poppler.Document prefixes "./" at
   every call site (pdfinfo, pdfimages, pdfimages -list, pdftoppm), and
   corpus.safeName strips a leading "-" or "." when the file is created so
   the awkward name never enters the corpus. The "." goes for §33's
   reason: a file nothing lists is measured by every tool that walks the
   manifest and by none that walks the directory.

3. What was already right is stated in §36 rather than assumed: no shell
   anywhere (exec takes an argv, so command injection was never available
   -- argument injection was), every poppler call bounded and a bound that
   fires named, a decode charged what it will cost before a byte is spent,
   and temporary files under os.MkdirTemp.

Both fixes mutation-checked, each after a go build: accepting a ".." row
fails two cases, leaving a leading dash alone fails the poppler test.
100.0% of statements.
…pty because nobody asked

judgePage returned nil the moment render.Images handed back an empty slice,
so a page we produce no picture for produced no row at all. Re-taken over
all 23 populations with the fix:

    fr-cerfa        4153 -> 5351   +1198
    gh-safedocs        0 ->   11     +11
    ia-americana      24 ->   32      +8
    gh-pdfbox          4 ->   10      +6
    gh-qpdf           13 ->   19      +6
    four others                   +1 each
    ----------------------------------------
    23 populations  4277 -> 5510  +1233   = a 29% undercount

Nine of twenty-three moved. fr-cerfa's 1 198 come from THIRTEEN documents
whose page 1 draws only inline images -- 1 197 of the 1 198 rows carry
object 0 -- so those pages did not lose a count, they vanished whole. And
§34 had already found the consequence without looking for it: the page
compare ranks worst of 3 214, at 19.23%, is one of these, and so is the
second worst at 14.70%.

ONE CELL NOBODY EXPECTED. Everything else in all 23 records is identical
cell for cell, and the exception is not an unseen:

    gh-qpdf  hung: qpdf_qtest_qpdf_shared-unnamed-field.pdf page 1, pdfimages

That document draws no picture on page 1, so images NEVER ASKED poppler
about it -- which is precisely what this file and the top-level README
have both said for weeks to explain hung: 0. The sentence was right and it
described a hole, not a corpus. Asking the judge on a page we drew nothing
for asks about that document for the first time, and pdfimages does not
come back. §34's independent page comparison reached the same file, page
and tool. Two instruments, neither looking for it, agreeing on the one
document in 3 280 that stops a poppler tool.

The top-level README's hung paragraph and its unseen row are corrected to
1 and 5 510.
The audit's fourth finding, from carrying the same question past the
inputs a FILE provides to the inputs a TOOL provides. render refuses to
decode a page past a ceiling; three png.Decode calls read poppler's answer
about the same page with no bound at all.

Measured on the page of §35, where our own decoder refuses to spend
277 MB:

    PNGs pdfimages writes for page 1     3 x 7048 x 9856
    on disk                              44 MB
    png.Decode -> three *image.Gray      69.5 MB each
    raster.FromImage -> RGBA             278 MB each
    retained                             834 MB

An instrument that refuses to spend what it asks the other side to spend
is measuring two different things, and nothing here limits what poppler is
asked to write.

maxJudgePixels is the same number render bounds a page by, in the same
unit, read from png.DecodeConfig BEFORE a byte is decoded -- for the reason
afford() gives: a limit noticed after the allocation has not helped.
Today's corpus is inside it (that page is 3 x 69 465 088 = 208 395 264
against 268 435 456), so no figure in baseline/ moves; ia-texts re-taken
to confirm, cell for cell.

And the silence beside it is gone. A PNG that could not be read was
SKIPPED with continue, so the pairing saw fewer of the judge's pictures
and said nothing -- the same mistake as §35, three functions away. It is
reported now, the way the hung listing beside it already was.

readPNG reads the file once and decodes from memory twice rather than
seeking one open file back to the start: the Seek error is a branch
nothing can reach on a regular file, and a branch nothing can reach is a
branch nobody can test.

Mutation-checked, each after a go build: dropping the bound lets a picture
past the budget be read, restoring the continue turns a page that should
be reported into a page measured on nothing. 100.0% of statements.
@tannevaled
tannevaled merged commit 5fdd286 into main Oct 6, 2026
9 checks passed
@tannevaled
tannevaled deleted the size-paired-share branch October 6, 2026 13:18
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