The metric corpus is 16 PDFs frozen in testkit/fixtures/, pinned by SHA-256 in
testkit/corpus_manifest.json. They are regression evidence, not market
coverage (ROADMAP.md). Every number this repository reports describes those 16
files and nothing else, which is exactly the property that makes them useful and
exactly the property that makes them narrow.
This document specifies how the corpus grows to roughly 40–60 frozen documents without putting a single existing number at risk.
| producer | documents | share |
|---|---|---|
| ReportLab | 01–05, r1_reportlab_report |
6/16 |
| Chromium/Skia | c1–c8 |
8/16 |
| fpdf2 | f1_fpdf_brief |
1/16 |
| LibreOffice (word-processor dialect) | l1_word_native |
1/16 |
| downloaded real-world document | — | 0/16 |
Two gaps follow directly from that table.
The word-processor dialect is one document. It is also the single most
common shape of PDF that a user will hand this converter, and the one whose
round trip back to DOCX matters most. One sample cannot distinguish a general
rule from a fixture accident — and the project already made that mistake once,
with l1's symbol-font list markers.
Nothing here was produced by a stranger. All 16 were generated by scripts in this repository, so every one of them inherits this repository's assumptions about margins, fonts, and structure. A producer we have never seen is the only honest test of a general rule.
Separately, the 16 over-sample designed stress. c3 (nested tables), c4
(CJK + RTL) and c5 (gradients, rotation, SVG) are 3 of 16 — 19% of the corpus
spent on constructs the policy already classifies as non-blocking, while the
blocking ordinary_digital tier carries the whole product claim on 13
documents that are mostly one prose layout with a table in it.
Expansion fixtures live in testkit/fixtures_expansion/, pinned by a separate
manifest testkit/corpus_expansion.json. The gate's manifest and fixture
directory are not touched at all.
This is not tidiness. Three mechanisms currently make testkit/fixtures/ a
closed set, and adding a file to it breaks all three:
-
corpus_manifest.verify()rejects strangers. It liststestkit/fixtures/, and any.pdfthere that is not in the manifest is reported asunexpected.runall.resolve_corpus()turns that into a gate problem. A new fixture in that directory is a red gate. -
runall.resolve_corpus()measures whatever the manifest names. It iteratesmanifest["documents"]and hands every entry to both lanes. Adding an entry silently enlarges the gated corpus, andgate.pythen compares a 32-document run against a 16-document baseline. -
gdocs_quality_policy.jsonpins the manifest file's own SHA-256, and that pin is live:sha256(testkit/corpus_manifest.json) = cc4dd4c1c449ddabe85d7538454ed4a3d6163d762f68d2e59d97801e11c3c7da gdocs_quality_policy.json "manifest" = cc4dd4c1c449ddabe85d7538454ed4a3d6163d762f68d2e59d97801e11c3c7dagdocs_oracle._load_quality_policy()compares them. It used to return policy statusmismatchwithtiers = Noneon any difference, which silently stopped evaluating the Google Docs quality policy altogether — a one-byte edit to the manifest disabled the tiered assessment without failing anything, and the resultingfindings: []reads like "nothing wrong" rather than "nothing was checked".Fixed: a pin mismatch now raises
QualificationError, naming both the hash the policy pins and the hash the manifest actually has. It refuses in preflight, before any Drive service is constructed, so a consented run cannot spend an upload collecting evidence nothing can grade;assessrefuses the same way offline. Schema and candidate-profile mismatches still returnmismatchwithout raising — those describe a file that is not this policy at all, and accusing it of the wrong pin would send the reader to the wrong file._load_quality_policyadditionally requires every manifest document to be assigned to a tier andunsupported.documentsto stay empty, so even a correctly re-pinned manifest with new documents comes backmalformed.gdocs_oracle._source_plan()also requires the set of.pdffiles in the fixtures directory to match the manifest exactly, so the two cannot be desynchronised in either direction.
A sibling directory rather than testkit/fixtures/expansion/ is deliberate.
The two os.listdir scans above are non-recursive today, so a subdirectory
would survive — but it would survive by accident, and the first glob("**/*.pdf")
anyone writes over the fixtures tree would quietly fold the expansion set into
the gate. A sibling cannot be reached by a mistake of that shape.
| file | pins | consumed by | gates |
|---|---|---|---|
testkit/corpus_manifest.json |
the 16 metric inputs | runall.py, gate.py, gdocs_oracle.py |
yes |
testkit/corpus_expansion.json |
the expansion inputs | expansion.py, corpus_manifest.py verify |
no |
testkit/parity_policy.json |
acceptance for the gated 16 | backend_parity.py; parity_expansion.py reads its margins and nothing else |
yes |
testkit/expansion_parity_policy.json |
acceptance for the expansion corpus | expansion_policy.py, via parity_expansion.py |
no |
corpus_manifest.py verifies both corpora. It does so through two separate
functions: verify() is untouched and still answers only for the 16, so
runall.py — which calls verify(manifest) directly — cannot see an expansion
problem. The CLI verify command calls both and reports them in separate
sections. That is the whole coupling: identity is checked in one place, and the
gate reads one half of it.
The two acceptance policies are separate for the same reason, and the readers
enforce it in both directions. An expansion finding written into
parity_policy.json would have been unreachable — backend_parity.adjudicate
never sees a document outside the gated manifest, and parity_expansion.py
refuses the waiver sections by design — while carrying corpus_manifest_sha256,
a binding naming a manifest that does not contain the document. So
expansion_parity_policy.json pins corpus_expansion.json by its own hash and
keys its entries by full profile ID, because the expansion corpus is measured at
two profiles and a finding at one says nothing about the other.
expansion_policy.load() refuses a file carrying the gated policy's sections,
and parity_expansion.load_margins() refuses a file carrying the expansion
schema. Neither artifact can be read as the other by accident.
That policy annotates and never adjudicates: a ratified row keeps the
verdict the measurement produced, with ratified: true beside it, and
parity_expansion.py's exit code still reports only whether every document
could be measured. What the reader does enforce is the policy's own integrity —
a moved corpus, a malformed section, an entry naming an absent document, a floor
breached, a finding that has stopped describing anything, or an entry no run
measured are all errors, in the same class as a corpus that does not match its
manifest.
Target the midpoint, ~48 documents. Tranche 1 (this change) is 16 generated
documents; the remainder is downloads, which need explicit approval per
docs/corpus-download-candidates.md.
| producer class | target | now (16) | +tranche 1 | to find |
|---|---|---|---|---|
| word-processor export (LibreOffice, Word) | 12 | 1 | 7 | 5 |
| browser print-to-PDF (Chromium, WebKit) | 12 | 8 | 14 | — |
| report generator (ReportLab, fpdf2, JasperReports, Crystal) | 10 | 7 | 11 | — |
| LaTeX-light (pdfTeX, XeTeX, LuaTeX) | 8 | 0 | 0 | 8 |
| other / unknown real-world producers | 6 | 0 | 0 | 6 |
| total | 48 | 16 | 32 | ~16 |
Two things this table admits.
LaTeX cannot be generated here. The canonical image has no TeX distribution, and adding one is a ~2 GB change to the environment of record for the sake of input files — which would then need a fresh image digest and a baseline migration. arXiv is full of permissively licensed pdfTeX output. The LaTeX tranche is therefore download-only and is deferred to tranche 2.
The browser and report-generator classes overshoot after tranche 1 (14 and 11
against targets of 12 and 10). That is fine and is not corrected by deleting
fixtures: the targets are floors for coverage, and the surplus is in
ordinary_digital, which is the tier the product claim rests on. If the ratio
matters later, it is corrected by adding to the thin classes, never by
unfreezing a document.
Running headers and footers with real page-number fields; nested bulleted
and numbered lists at three levels; borderless tables alongside bordered ones;
footnote-shaped page-bottom apparatus; block quotes; inline hyperlinks in
ordinary prose; four-level heading hierarchies; TOC pages with tab leaders;
Latin-diacritic, Cyrillic and Greek text (all inside the pinned font set, so
unlike c4_i18n this is ordinary, not stress); multi-page memos and reports
where a per-page offset compounds.
x17_resume_twocol and x18_resume_twocol_tnr were added because the release
bar names resume-class documents as the exemplar and the corpus contained no
resume at all. That gap was found the way gaps usually are: the owner converted
their own resume, opened it in Docs, and it was wrong in three separate ways at
once. Their document is personal and is not in this repository and never will
be; these two are the synthetic stand-in, with fictional employers and every URL
on example.com.
The pair is a treatment and its control. Both carry the same three structures:
- role/date pairs on one baseline — two per page. Inference used to split the pair and push the right-hand member across with an absolute indent, one of which reached 103% of the content width, i.e. past the right margin;
- two populations of link anchor — four in the contact header, each of which is its own span, and four in prose, each a fragment of a longer span. The first four were always tagged and the second four were always dropped. One of the prose anchors covers 0.462 of its span, deliberately just under the 0.5 test, so that lowering the constant cannot be mistaken for fixing the cause;
- a family the DOCX declares nowhere — x17 has a monospaced run and x18 does not, which is the only difference between them.
What this pair cannot reproduce. The real document's body font was Georgia,
and Georgia is not installed in the measurement container — nor is Verdana;
fc-match resolves both to DejaVu Serif. Of the installed families, the only one
the mapper emits that python-docx's stock fontTable.xml omits is Courier New.
So the pair holds the declaration mechanism (a family on runs that the document
never declares) but not the severity of the real case, where the undeclared
family was the body text rather than one line of it. That severity is measured
only on the owner's own document, outside this repository, and the honest place
to say so is here.
testkit/gdocs_quality_policy.json defines three tiers. Assignment has been ad
hoc; these are the rules, written down so a new document classifies itself.
A document is ordinary_digital — the blocking tier — when all hold:
- born-digital with a real text layer; extractable text covers essentially all visible glyphs (no scanned or outlined text);
- reading order is single-column, or plain two-column with a full-width header;
- every table is a rectangular grid of cells — no nesting, no cell that contains another table;
- all text is left-to-right, and every script used is fully covered by the
pinned font set in
scripts/fonts.confwithout fallback (Latin incl. diacritics, Cyrillic, Greek); - no rotated or transformed text, no text knocked out of a gradient or image, no vector graphic that carries meaning the text does not.
Size is not a tier criterion. The first version of this rule required
ordinary_digital to be ≤ 20 pages and ≤ 2 MB, and tranche 2 broke it
immediately: the real documents that arrived are 46, 80, 114 and 126 pages, and
an 80-page Word-to-Acrobat government report is the most ordinary document
imaginable. That rule conflated two unrelated axes — how hard a document is to
convert, which is what a tier means, and how expensive it is to measure,
which is a corpus budget. Length is not difficulty. The budget is stated
separately in §6 and governs what we choose to freeze, never what tier a frozen
document belongs to.
A document is designed_stress — tracked, non-blocking — when it is
otherwise ordinary but deliberately exercises a construct on the known-hard
list: nested tables, RTL or bidirectional text, CJK or any script requiring font
fallback, rotated/transformed text, gradients or knockout text, meaning-bearing
vector graphics, or true multi-column with column-spanning elements.
A document is unsupported — expected to be refused before qualification —
when it has no usable text layer (pure scan), is encrypted or password
protected, uses XFA or interactive form logic for data entry, or exceeds the
converter's own declared limits: > 250 pages or > 250 MiB, which are
MAX_PAGES_PER_DOCUMENT and MAX_BYTES_PER_DOCUMENT in exactdoc/batch.py.
Those two numbers are read from the product rather than invented here, and that
is the point: the unsupported tier should mean "the converter says it refuses
this", not "the corpus authors guessed it would be hard". The earlier
> 200 pages, > 50 MB was a guess and disagreed with the shipping code.
Interactive form logic means fields a user fills in — text fields, checkboxes,
radio groups. Navigation widgets are not form logic: y03_nist_fips197 carries
16 Button annotations emitted by LaTeX's hyperref, which are links, and it
is ordinary_digital.
Two consequences worth stating plainly.
Tranche 1 is 100% ordinary_digital, by design. The scope was the ordinary
variety the current 16 under-sample, and ordinary_digital is the only blocking
tier — it is where a broader claim has to be earned. The stress and unsupported
tiers are populated in tranche 2, where real downloaded documents (scans,
LaTeX with unusual encodings) provide honest examples rather than manufactured
ones. A stress case we invented to fail is worth less than one we found.
The tier is a property of the document, not of the result. A document does
not move to designed_stress because it scored badly. Reclassification requires
a rule above to have been misapplied, and shows up in the diff as a changed
tier field with a reason.
Every expansion entry carries a provenance object. The gate's manifest has no
such field and does not gain one.
"x01_lo_memo_pageno.pdf": {
"sha256": "…64 hex…",
"bytes": 24096,
"content": "…32 hex…",
"src_pages": 3,
"tier": "ordinary_digital",
"dialect": "libreoffice",
"why": "running header/footer with a real page-number field",
"provenance": {
"origin": "generated",
"recipe": "testkit/gen_expansion.py::x01_lo_memo_pageno",
"source_url": null,
"license": "Apache-2.0",
"acquired": "2026-08-04",
"toolchain": "exactdoc-gate:dev / LibreOffice 24.2.7.2 / Chrome 149.0.7827.55 / Python 3.12.3"
}
}| field | required | rule |
|---|---|---|
origin |
always | generated or downloaded; nothing else verifies |
recipe |
when generated |
path::function naming the code that made it; source_url must be null |
source_url |
when downloaded |
absolute https:// URL it came from; recipe must be null |
license |
always | non-empty SPDX identifier or a named public-domain statement. unknown is not acceptable — an unlicensed file does not enter the corpus |
acquired |
always | ISO YYYY-MM-DD the bytes were frozen or fetched |
toolchain |
when generated |
the environment of record that produced it |
origin is exclusive-or by construction: exactly one of recipe and
source_url is non-null, and corpus_manifest.verify_expansion() rejects a
document that satisfies both or neither. This is what keeps "we made this" and
"we found this" from blurring after a year, which is the failure mode that makes
a licence audit impossible.
Identical in spirit to the 16: generate in the canonical container, freeze the bytes, pin the hash, never regenerate in place.
docker run -d --name exactdoc-corpus -w /work exactdoc-gate:dev sleep infinity
docker cp <worktree> exactdoc-corpus:/work/repo
docker exec exactdoc-corpus bash -lc \
'cd /work/repo && FONTCONFIG_FILE=/work/repo/scripts/fonts.conf bash scripts/bootstrap.sh --strict'
# 1. generate into a scratch directory (never into fixtures_expansion/)
docker exec exactdoc-corpus bash -lc \
'cd /work/repo && FONTCONFIG_FILE=/work/repo/scripts/fonts.conf \
uv run python testkit/gen_expansion.py /work/build'
# 2. seal: copy in, compute identity, merge the generator's provenance sidecar
docker exec exactdoc-corpus bash -lc \
'cd /work/repo && uv run python testkit/corpus_manifest.py expansion-seal /work/build'
# 3. verify, then carry the bytes back out
docker exec exactdoc-corpus bash -lc \
'cd /work/repo && uv run python testkit/corpus_manifest.py verify'
docker cp exactdoc-corpus:/work/repo/testkit/fixtures_expansion <worktree>/testkit/
docker cp exactdoc-corpus:/work/repo/testkit/corpus_expansion.json <worktree>/testkit/
docker rm -f exactdoc-corpusgen_expansion.py writes expansion_provenance.json beside the PDFs: tier,
dialect, why, licence, recipe and toolchain — everything except the hashes.
expansion-seal computes the hashes. The split is the point: a human authors
the claims, the tool computes the identity, and neither can forge the other.
Fixtures are not byte-reproducible — LibreOffice, Chromium and ReportLab all
stamp a creation time and a document ID into every file. Re-running
gen_expansion.py produces 16 different SHA-256s and the same documents. That
is why the bytes are frozen and committed rather than rebuilt, and it is the
same argument recorded in corpus_manifest.py for the original 16. The
content fingerprint (page geometry + whitespace-normalised text) is what moves
only when a document really changes.
Measuring the expansion set, at any time, without touching the gate:
uv run python testkit/expansion.py --lane product # or raw, or bothIt refuses to run on a corpus that does not match its manifest, prints per-tier
summaries, writes testkit/expansion_out/, and exits 0 on any metric value.
It has no baseline, makes no comparison, and imports nothing from gate.py.
They do not become gating by being measured, by scoring well, or by being merged. There is no automatic promotion, no "after N green runs", and no threshold that trips. Promotion is one reviewable commit, and this is the protocol.
Preconditions, all of them:
- The expansion set has been measured in the canonical container on at least
two separate commits, and the numbers are stable — no document moving more
than the
gate.pytolerance between runs on identical bytes. - Every document proposed for promotion is
ordinary_digitalunder §4, or is being promoted into an explicitly non-blocking tier. - Any document that would enter the gate already failing has a recorded
defect ID, exactly as
gate.pyrequires of the existing known shortfalls. A new document is not permitted to arrive as an unexplained red. - The Google Docs quality policy has been reviewed and ratified
(
review.status), because promotion changes the document set that policy binds. Ratification is a decision for the owner and is not part of this protocol.
The promotion commit, which must be a single commit and must contain all of:
- the promoted entries moved from
corpus_expansion.jsonintocorpus_manifest.json, and the fixture bytes moved fromtestkit/fixtures_expansion/intotestkit/fixtures/unchanged — a promotion that regenerates a document is not a promotion, it is a new document; gate_baseline.jsonre-recorded in the canonical container, because the gate compares against per-document records and has none for the newcomers;gdocs_quality_policy.jsonre-pinned: themanifest.sha256updated to the new manifest bytes, and every promoted document assigned to a tier — otherwise_load_quality_policyrefuses outright on the pin, or returnsmalformedon the tier assignment, and no quality result can be produced at all (§2);parity_policy.jsonre-pinned for the same reason;- the provenance and tier of every promoted document preserved in the new manifest. Provenance does not get dropped on the way into the gate.
The rule underneath all of it is the one the project already paid to learn: a change to the corpus re-bases every recorded number, so it must land together with the re-recorded numbers, in one reviewable diff. A promotion spread over two commits leaves an interval in which every published figure describes a different corpus than the one on disk.
Expansion fixtures are non-gating until such a commit exists. Tranche 1 contains no such commit.
Recorded here because it is the reason the corpus was expanded, and because a tranche that is merely frozen and never measured proves nothing.
testkit/expansion.py --lane product, run in the canonical container against
the 16 sealed fixtures: 16/16 converted, rendered, scored, and page-exact.
Medians across the tier:
| metric | median | draft ordinary_digital bar |
|---|---|---|
live_text_cov |
0.998 | ≥ 0.90 |
doc_recall |
0.996 | ≥ 0.90 |
word_recall |
0.996 | ≥ 0.90 |
mean_ssim |
0.826 | ≥ 0.70 |
dy_p50 |
12.2 pt | ≤ 10.0 |
within2pt |
0.188 | not gated |
raster_frac |
0.002 | not gated |
This is not a policy verdict. gdocs_quality_policy.json describes the
pdfium/gdocs/none/refine0@240dpi candidate measured through the Google Docs
oracle; this run is the product lane through the LibreOffice proxy. Same
metric names, different renderer, and no cloud call was made. Read against those
thresholds anyway, as an indication, the split is stark:
- text fidelity: zero findings. Every document clears coverage, document recall, word recall and page match. Whatever is wrong is not that the words are missing.
- geometry: 23 findings across 12 documents —
dx_p50on 9,dy_p50on 9,mean_ssimon 5.
The offenders are not random. Ranked by dx_p50: x02_lo_report_toc 103.3pt,
x01_lo_memo_pageno 83.7pt, x07_chrome_memo_running 45.5pt,
x08_chrome_print_default 43.1pt. Ranked by dy_p50: x11_chrome_toc_headings
74.7pt, x01 29.3pt, x08 27.7pt, x02 16.4pt, x07 15.5pt.
Every one of the worst documents carries a running header, a running footer,
or browser-furnished page furniture — and those are precisely the constructs
the frozen 16 do not sample. l1_word_native, the only word-processor document
in the gated corpus, is a single page with no header and no footer, so no gated
number has ever been able to move in response to this.
That is the expansion doing its job on the first run: it did not discover that the converter is bad at ordinary prose, it discovered that page furniture is the dominant unmodelled construct in ordinary documents, and that the gate is currently blind to it. Whether that becomes a defect ID, a layout rule, or a deliberate non-goal is a product decision and is not settled here. What is settled is that it is now measurable, repeatably, on committed bytes.
The corresponding raw-lane numbers were not collected in this pass; the two
lanes are run together by default and only product was needed to establish the
above. That is a gap in the record, not a claim about the raw lane.
Twelve candidates were attempted under the §5 protocol. Six arrived, six failed, five were sealed and one was fetched and then deliberately discarded. The expansion corpus is now 21 fixtures; the gated 16 are untouched.
Verified from the statement inside each document, which is first-party and needs no network call to re-check. Where a document is silent, that silence is recorded as silence rather than upgraded into a claim.
| fixture | licence basis | evidence found in the document |
|---|---|---|
y01_nist_sp80063b |
US Gov work, 17 U.S.C. §105 | p4: "…is not subject to copyright in the United States. Attribution would, however, be appreciated by NIST." |
y02_nist_sp800171r2 |
US Gov work | p4: same NIST statement. A p6 notice disclaims third-party names as trademarks; it reserves no rights over the text |
y03_nist_fips197 |
US Gov work | none found — no copyright or trademark notice anywhere. Public domain by publisher identity (a NIST FIPS), not by a statement |
y06_irs_1040_instructions |
US Gov work | none found. Public domain by publisher identity (IRS instruction booklet) |
y07_irs_f1040_form |
US Gov work | none found. Public domain by publisher identity (official IRS form) |
Three of five rest on publisher identity rather than an explicit grant. That is
sound — a US Government work is uncopyrighted whether or not it says so, and
agencies routinely omit the notice for exactly that reason — but it is weaker
than a statement and is recorded as such in corpus_expansion.json rather than
smoothed over.
y05_gao_report.pdf was fetched successfully and is not in the corpus. Its
GAO notice is the most explicit of the six: "This is a work of the U.S.
government and is not subject to copyright protection… may be reproduced and
distributed in its entirety without further permission." So it could have been
kept. Three facts said not to:
- 286 pages exceeds
MAX_PAGES_PER_DOCUMENT(250) inexactdoc/batch.py, so its only possible role was as an over-cap refusal input. - 15.3 MB is 13× the entire existing corpus — the 16 gated fixtures and 16
tranche-1 fixtures together are about 1.1 MB. Permanently carrying that in
git history to prove one
ResourceLimitErroris a bad trade, and the trade is what matters: any 251-page document tests the same branch. - The same notice adds that the work "may contain copyrighted images or other material" needing separate permission. It has 176 images. Whole-file redistribution is covered, but it is the one document of the six whose redistribution carries a qualification at all.
Splitting or trimming it was never an option: a trimmed document is a document this project wrote, which is the exact property tranche 2 exists to avoid. The over-cap slot is refilled in the corrected plan by NIST SP 800-53r5 (~490 pages, ~5 MB) — same test, one third the weight, from a host that has already answered three times.
Five producer chains it had never contained, none of them ours:
| fixture | producer | creator |
|---|---|---|
y01 |
Adobe PDF Library 15.0 | Acrobat PDFMaker 17 for Word |
y02 |
Adobe PDF Library 20.13.106 | Acrobat PDFMaker 20 for Word |
y03 |
pdfTeX 3.14159265-2.6-1.40.21 (TeX Live 2020) | LaTeX with hyperref |
y06 |
Antenna House PDF Output Library 6.6.1437 | AH XSL Formatter V6.6 |
y07 |
Adobe LiveCycle Designer 6.5 | Designer 6.5 |
Two of these matter beyond variety. y01/y02 are genuine Word exports —
the dialect the whole product targets, which the corpus previously sampled with
one single-page document. And y03 closes the LaTeX gap that §3 said could only
be closed by download: pdfTeX output, without touching arXiv's licence problem,
because a NIST FIPS is a US Government work.
y03 also produced an artifact no generator here could fake: its text layer
drops the fi ligature. "Specification" extracts as "Specifcation", "affine"
as "affne", "field" as "feld". That is a real Type-1/pdfTeX extraction property,
it is invisible to a human reading the PDF, and it will suppress word recall on
every document from that toolchain.
The tier ceiling was wrong (§4). Requiring ordinary_digital to be ≤ 20
pages and ≤ 2 MB failed on contact: the real documents are 46, 80, 114 and 126
pages, and an 80-page Word-to-Acrobat government report is as ordinary as a
document gets. Length is not difficulty, and the ceiling has been split out into
a corpus-budget concern. The unsupported thresholds now read 250 pages and
250 MiB from exactdoc/batch.py rather than the invented "200 pages, 50 MB".
unsupported needed refusal semantics in expansion.py. A fixture the
converter is expected to refuse would otherwise make the runner red for
working correctly. A raised exception on an unsupported document is now
recorded as refused and is a pass; the converse — an unsupported document
that converts anyway — is flagged as a refusal-contract gap, which is the case
actually worth knowing about.
| candidate | failure | remedy |
|---|---|---|
| D11a CRS | 403 Forbidden | Not retried, User-Agent not disguised. Getting past a bot filter by impersonating a browser is evasion, and a corpus is not worth it. Re-pointed at EveryCRSReport, which publishes an open access policy |
| D19a/D19b/D20a RFC | 404 | The path /rfc/rfcNNNN.pdf serves a native PDF only for RFCXML v3 documents (~2019+). RFCs 8259, 2119 and 793 predate it; their generated PDFs live at /rfc/pdfrfc/rfcNNNN.txt.pdf. Corrected, plus RFC 9110 added to exercise the native form |
| D26a ECB | CERTIFICATE_VERIFY_FAILED |
A real bug in fetch_expansion.py: Windows populates chain intermediates lazily through CryptoAPI and Python does not drive it. Now prefers certifi's bundle, which resolves identically on every platform. The ECB URL was also an unverified guess and is dropped |
| D32a Wikimedia | 429 | A real gap: no backoff. Now honours Retry-After exactly once — no loop, no exponential hammering. If a host says no twice, the answer is no |
Round 1 also established which hosts are worth planning around:
nvlpubs.nist.gov answered 3/3 and irs.gov 2/2, so the corrected plan is
weighted to them — 13 candidates, ~12.3 MB, still unfetched and still requiring
--allow-download.
testkit/expansion.py --lane product, canonical container, same caveat as §8 —
this is the LibreOffice proxy, not the Google Docs oracle the policy describes.
| fixture | pages in → out | live_text_cov | doc_recall | word_recall | dy_p50 | mean_ssim |
|---|---|---|---|---|---|---|
y01_nist_sp80063b |
80 → 158 | 0.906 | 0.923 | 0.131 | 88.8 | 0.314 |
y02_nist_sp800171r2 |
114 → 314 | 0.869 | 0.900 | 0.128 | 81.4 | 0.243 |
y03_nist_fips197 |
46 → 53 | 0.931 | 0.961 | 0.558 | 16.8 | 0.614 |
y07_irs_f1040_form |
2 → 10 | 0.937 | 0.919 | 0.457 | 63.7 | 0.085 |
y06_irs_1040_instructions |
— | crashed: UnrecognizedImageError |
Three things, in order of how much they matter.
1. Pagination doubles. y01 produces 158 pages from 80; y02 produces 314
from 114. That is 1.98× and 2.75×, and it is the same defect tranche 1 saw at
small scale finally showing its actual shape. Note which metrics move and which
do not: doc_recall stays at 0.90–0.92, so the words are nearly all there,
while word_recall collapses to 0.13, because word recall is position-aware and
every word after the first overflow is on the wrong page. A converter that
keeps 92% of the text and puts 87% of it on the wrong page is not 92% correct.
The gated 16 could not have found this. They are 1–7 pages, and a per-page
overflow needs length to compound into a page-count error — which is exactly
what c6_long was designed to catch at 7 pages and was too short to see.
2. The refusal contract is not enforced. y07_irs_f1040_form is a 199-widget
fillable form, tiered unsupported, meaning the converter is expected to reject
it. It converted: 2 pages became 10, at mean_ssim 0.085 — visually almost
nothing in common with the input. The new refusal-semantics check in
expansion.py flagged it as REFUSAL CONTRACT GAP. exactdoc/batch.py enforces
page and byte ceilings but nothing rejects interactive form logic, so a user
handing this converter a tax form gets ten pages of confident garbage rather
than a refusal. That is a product decision to make, not a bug to quietly fix
here, but it is now measurable and named.
3. One document crashes outright. y06_irs_1040_instructions, the Antenna
House XSL-FO booklet, raises UnrecognizedImageError and produces no
measurement at all. The runner exits 1 and calls it an infrastructure failure
rather than a fidelity score, which is the honest reading: we do not know how
well it converts, because it does not convert.
On the tier medians, which look fine and are not. The ordinary_digital
median row barely moved between §8 and now — dy_p50 12.2 → 13.0, mean_ssim
0.826 → 0.803. That is an artefact of counting: 16 of the 19 documents in that
tier are the small tranche-1 fixtures, so they outvote the three real ones. The
median is doing what a median does and it is the wrong statistic for a corpus
this lopsided. Read the per-document table, not the tier summary, until the real
documents are no longer a minority — which is an argument for finishing tranche
2, not for adjusting the statistic.
None of this gates anything. No baseline describes these documents and
gate.py has never seen them.
Thirteen candidates attempted, eight fetched and all eight sealed — the first
round with no discards. The corpus is now 16 gated + 29 expansion = 45
documents, inside the 40–60 target ROADMAP.md set, and acquisition is
closed. testkit/expansion_download_plan.json now carries an empty
candidates array, so fetch_expansion.py refuses it even when handed
--allow-download; the five that never arrived are kept as an annex in the same
file.
| fixture | basis | evidence in the document |
|---|---|---|
y08_nist_sp80088r1 |
US Gov work | p4: explicit NIST "not subject to copyright in the United States" |
y09_nist_sp800207 |
US Gov work | p3: same NIST statement |
y10_nist_fips180 |
US Gov work | none found — publisher identity (NIST FIPS) |
y11_nist_sp80053r5 |
US Gov work | p3: explicit NIST statement |
y12_irs_pub15 |
US Gov work | no notice over the publication; a partner's NCMEC trademark credit only |
y13_irs_pub501 |
US Gov work | same NCMEC credit; no rights asserted over the text |
y14_irs_fw9_form |
US Gov work | none found — publisher identity (IRS form) |
y17_rfc9110 |
IETF Trust TLP — not public domain | p1: "Copyright (c) 2022 IETF Trust… All rights reserved." p2 forbids modification and derivative works outside the IETF Standards Process |
y17_rfc9110 is the only fixture in the entire corpus that is not public
domain, and it is the only one carrying an active restriction. The restriction
is on modification and derivative works; verbatim redistribution of an
unmodified copy is what the TLP permits and is exactly what a frozen fixture is.
Its provenance record says so explicitly, and says that it must never be edited,
trimmed or re-rendered in place. That is a constraint the freezing procedure
already imposes on every fixture, so it costs nothing — but it is now written
down against the one document where breaking it would be a licence breach rather
than merely bad method.
The plan guesses tiers before anyone has seen the file. Column count is the criterion §4 turns on, and it was measured rather than eyeballed — by detecting text blocks that overlap vertically while being horizontally disjoint, which is what a real column does and what an indented sub-list never does. A plain left-edge histogram cannot tell those apart and initially mis-read NIST SP 800-171's indented requirement lists as a second column.
| fixture | measured layout | tier | note |
|---|---|---|---|
y08, y09, y10, y17 |
single column | ordinary_digital |
|
y12_irs_pub15 |
2 columns on 100% of pages, 3 on 8% | ordinary_digital |
corrected from the plan's designed_stress; §4 places plain two-column in ordinary |
y13_irs_pub501 |
3+ columns on 92% of pages | designed_stress |
genuine multi-column reading order |
y11_nist_sp80053r5 |
single column | unsupported |
492 pages > the 250-page cap. Its only disqualification is length, which makes it a clean test of that one branch |
y14_irs_fw9_form |
— | unsupported |
23 form widgets over 6 pages |
The same measurement re-checked the already-sealed tranche-2 tiers and confirmed
them: y06_irs_1040_instructions really is 3+ column on 68% of pages, so its
designed_stress tier holds, and y01/y02/y03 really are single column.
Fourteen documents fetched across three rounds introduced producer chains the corpus had never contained, none of them written by this project:
| producer chain | fixtures |
|---|---|
| Acrobat PDFMaker for Word, gen. 11 / 17 / 20 | y08, y01, y02 y09 y11 |
| Microsoft Word 2010, direct — no Acrobat | y10 |
| pdfTeX (TeX Live 2020) | y03 |
| Antenna House PDF Output Library 6.6 / AH XSL Formatter | y06, y12, y13 |
| Adobe LiveCycle Designer 6.5 | y07, y14 |
| cairo 1.16.0 via xml2rfc 3.12.10 | y17 |
y10 is worth singling out: producer and creator are both "Microsoft Word
2010", so it is Word's own PDF writer rather than Acrobat's. Against the five
PDFMaker documents it separates "what Word's layout does" from "what Acrobat's
writer does" — two things the corpus previously could not distinguish at all,
because it had one word-processor document in total.
testkit/expansion.py --lane product, canonical container, measured at commit
0b4e289 — the unmodified tip, before the pagination fix being built
concurrently. This is the "before" record; same §8 caveat, LibreOffice proxy
rather than the Google Docs oracle.
| fixture | producer chain | pages in → out | ratio | doc_recall | word_recall | mean_ssim |
|---|---|---|---|---|---|---|
y09_nist_sp800207 |
Adobe PDF Library / PDFMaker 20 | 59 → 116 | 1.97× | 0.956 | 0.155 | 0.312 |
y10_nist_fips180 |
Microsoft Word 2010 direct | 36 → 40 | 1.11× | 0.800 | 0.585 | 0.693 |
y17_rfc9110 |
cairo / xml2rfc | 194 → 221 | 1.14× | 0.978 | 0.346 | 0.561 |
y14_irs_fw9_form |
LiveCycle Designer | 6 → 9 | 1.50× | 0.990 | 0.213 | 0.268 |
y08_nist_sp80088r1 |
Adobe PDF Library 11.0 | — | crashed: UnrecognizedImageError |
|||
y12_irs_pub15 |
Antenna House | — | crashed: UnrecognizedImageError |
|||
y13_irs_pub501 |
Antenna House | — | crashed: UnrecognizedImageError |
The headline question has a sharp answer, and it is not the one §9 assumed. Lining up every measured document by producer:
| producer chain | documents | inflation |
|---|---|---|
| Acrobat PDFMaker for Word / Adobe PDF Library | y01 1.98×, y02 2.75×, y09 1.97× |
~2× and up |
| Microsoft Word 2010 direct | y10 1.11× |
near-correct |
| pdfTeX | y03 1.15× |
near-correct |
| cairo / xml2rfc | y17 1.14× |
near-correct |
Three PDFMaker documents all roughly double. Three non-PDFMaker documents all
land within 15%. No overlap. §9 called this "pagination doubles on real
documents" and attributed it to length; that was wrong. y17_rfc9110 is 194
pages — by far the longest document ever put through this converter — and
inflates 1.14×. Length is not the variable. The variable is the producer, and
specifically the Adobe PDF Library writer that Acrobat PDFMaker emits.
y10 is what makes the claim clean rather than suggestive. It is a Word
document, from the same publisher, in the same series as y08/y09/y11,
differing in exactly one respect: Word wrote the PDF itself instead of handing
off to Acrobat. It does not double. So this is not "Word documents inflate" —
it is the Adobe PDF Library output specifically.
The word/document recall split holds everywhere and is worth restating: y09
keeps 95.6% of the text (doc_recall) and places 15.5% of it (word_recall).
The words survive; the pagination destroys their positions.
A second crash class. UnrecognizedImageError now accounts for four
documents — y06, y08, y12, y13 — and the grouping is stark: all three
Antenna House documents in the corpus fail, plus one Adobe PDF Library 11.0
document. Three of the seven round-3 documents produce no measurement at all,
which is why the runner exits 1. That is an infrastructure failure, not a
fidelity score: we do not know how well these convert, because they do not
convert.
The refusal contract is absent, not leaky. y14 (23 widgets over 6 pages)
converted, exactly as y07 (199 widgets over 2 pages) did — so the gap is
general, not one document's accident. Reading the code explains why:
MAX_PAGES_PER_DOCUMENT = 250 and the byte ceiling live only in
exactdoc/batch.py, and neither expansion.py nor gdocs_oracle.prepare
goes through it — both call exactdoc.convert.convert directly, which has no
resource or capability check at all. The unsupported tier's expectation of
"reject-before-qualification" is therefore unmet by construction on the
qualification path. y11_nist_sp80053r5_overcap (492 pages) was deliberately
not measured for that reason: it would not be refused, it would grind, and the
code already answers the question.
45 documents: 16 gated, 29 expansion. Inside the 40–60 target ROADMAP.md set. The gated 16 are byte-identical to what they were before any of this started; every number this repository has ever published still describes exactly the same inputs.
| documents | origin | |
|---|---|---|
| gated metric corpus | 16 | generated in-repo |
| expansion tranche 1 | 16 | generated in-repo |
| expansion tranche 2 | 13 | downloaded |
| total | 45 |
Expansion tiers: 24 ordinary_digital, 2 designed_stress, 3 unsupported.
| producer class | target | achieved | |
|---|---|---|---|
| word-processor export | 12 | 13 | LibreOffice ×7, Acrobat PDFMaker for Word ×5, Word 2010 direct ×1 |
| browser print-to-PDF | 12 | 14 | Chromium/Skia |
| report generator | 10 | 16 | ReportLab ×9, fpdf2 ×2, Antenna House ×3, LiveCycle Designer ×2 |
| LaTeX-light | 8 | 1 | pdfTeX (y03) |
| other / unknown real-world | 6 | 1 | cairo via xml2rfc (y17) |
Two honest shortfalls. LaTeX is 1 of a target 8, because the only bulk
source is arXiv and its default submission licence does not grant onward
redistribution; the one LaTeX document held was reachable only because a NIST
FIPS happens to be typeset with pdfTeX. "Other real-world" is 1 of 6,
because the three RFC candidates and the CRS candidate never resolved to a
working URL and Wikimedia rate-limited twice. Both are recorded in the annex in
testkit/expansion_download_plan.json rather than quietly rounded up.
Against the two gaps §1 named at the start, the picture is different: word-processor coverage went from 1 document to 13, and the corpus went from 0 documents this project did not write to 13.
Four defects, none of which the gated 16 could express:
- Adobe PDF Library pagination inflation (~2×), producer-specific, proven by a same-publisher Word-direct control that does not inflate.
UnrecognizedImageErroron 4 of 13 real documents — every Antenna House document plus one Adobe, a third of the downloaded corpus producing no measurement at all.- No refusal contract on the conversion path — resource and capability
limits exist only in
batch.py, which neither the qualification path nor any single-document caller goes through. fi-ligature loss in pdfTeX text extraction (§9), invisible to a reader and silently suppressing word recall.
All four are properties of the converter as of 0b4e289, all four are now
measurable on committed bytes, and none of them gates anything yet. Promotion
into the gate remains the deliberate commit described in §7 — and these numbers
are the reason to be careful about it, not a reason to hurry.