Skip to content

Commit 08c64b5

Browse files
donislawdevclaude
andcommitted
format: settle the four things that were waiting on a breaking change
All four were open because the honest fix moved bytes or a contract, and the owner cleared that on 2026-08-03: nothing depends on either yet, so take the better answer rather than the cheaper one. PDF LINES ARE A FIXED WIDTH The text on a page was eight to thirteen drawn words of four to nine characters, so the smallest document one seed could produce was not the smallest another could: the floor across 200 seeds 3090 B to 3499 B --size 3300 accepted for 6 seeds out of 10 The earlier repair kept the ragged lines and took the theoretical worst case as the floor. That was consistent and honest and it cost about 1400 B of range no request could reach any more. A fixed line width settles it properly: the floor is 3409 B for all 300 seeds checked, and it is the real one. The words still come from the seed, so two seeds still read differently. Only the length is settled - the same thing every record based format here does to its closing record. A line ends on a whole word rather than being cut at the byte. core.AppendFiller does almost this and is not used, deliberately: it cuts and pads, which is right inside a CSV field nobody reads and wrong for the visible text of a page, where every line ending in "refere" reads as broken rather than as filler. Two pinned values moved. The reason is in the golden file under remeasured, beside the one from the day before, because that file is the record of every deliberate shift. The version is not bumped - that is the owner's to do. A RULE BINDING TWO SETTINGS IS DECLARED PNG allows each side up to 20000 pixels and the two multiplied up to 40 megapixels. A Property bounds one value, so the second rule lived in the generator and in a sentence - and "tfg formats png" offered a pair it then refused, while a window building fields from Min and Max would offer the same. The registry carries JointLimit now. PNG declares it, the generator asks the declaration rather than repeating the rule, and it is printed on its own line and carried in --json. Kept to a product of two because that is the shape every case in Tier 1 has - pixels, samples times channels, pages times page size. A general expression would be a language to learn and this is a line to read. THE MANIFEST NAME IS TAKEN BEFORE THE FIRST FILE Claiming it at save time stopped two runs from both writing one, and it happened at the end - so the second run wrote its whole set and only then found it had nowhere to record them. Measured: two runs together left sixteen files with eight in nobody's manifest. Now: eight files, one manifest, the second run refused before writing. The claim is given back when a run ends without writing a manifest, or the name would stay taken by a run that never happened. THE REFERENCE TOOLS ARE ACTUALLY INSTALLED SOMEWHERE The oracle guards skip when their tool is missing, and the matrix runners have python and node and nothing else - so most of them reported a skip and the run was green having checked almost nothing about the files. A separate job installs Inkscape, 7z, ffmpeg and poppler and runs them for real, with -v so a failed install cannot turn it back into a green nothing. What that job does not do is written into it: it does not walk many sizes. The wider sweep stays a manual probe, and that is a decision rather than a gap - after the SVG repair it found nothing across 452 files, because the risk was at the minimum and a guard covers that on every run now. Moving it into Go would need a build tag, and the coverage guard reads every test file regardless of tags, so it would land on the list of guards nothing proves - a list that is only allowed to shrink. See O51. Five guards, each seen red first, and five mutations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d092f3e commit 08c64b5

11 files changed

Lines changed: 478 additions & 111 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,51 @@ jobs:
213213
|| { echo "coverage ${actual}% is below the threshold ${threshold}%"; exit 1; }
214214
shell: bash
215215

216+
fidelity:
217+
name: reference tools actually installed
218+
# The oracle guards skip when the tool they need is missing, loudly, and a
219+
# skip is not a check. The matrix runners have python and node and nothing
220+
# else, so on an ordinary push most of those guards report a skip and the
221+
# run is green having verified almost nothing about the files themselves.
222+
#
223+
# This job installs Inkscape, 7z, ffmpeg and poppler so the same guards run
224+
# for real. It is separate from the matrix because installing a graphics
225+
# stack takes minutes and a push should not wait for it.
226+
#
227+
# What it does NOT do, said plainly: it does not walk many sizes. The
228+
# guards it runs check the realistic size and the smallest ones, which is
229+
# where the one defect this ever found was hiding - an SVG that rendered to
230+
# a blank canvas at exactly its minimum, 2026-08-03. The wider sweep across
231+
# sizes, seeds and label settings lives in tools/probes/fidelity-sweep.py
232+
# and is run by hand, because tools/ is outside the repository. See O51.
233+
runs-on: ubuntu-latest
234+
timeout-minutes: 30
235+
env:
236+
CGO_ENABLED: "0"
237+
steps:
238+
- uses: actions/checkout@v7
239+
240+
- uses: actions/setup-go@v7
241+
with:
242+
go-version: ${{ env.GO_VERSION }}
243+
check-latest: false
244+
cache: true
245+
246+
- name: the reference tools
247+
run: |
248+
set -euo pipefail
249+
sudo apt-get update
250+
sudo apt-get install -y --no-install-recommends \
251+
p7zip-full ffmpeg poppler-utils inkscape python3-pil
252+
shell: bash
253+
254+
- name: every format past its reference tool
255+
# -v so that a skip is visible in the log. A tool that failed to install
256+
# would otherwise turn this job back into the green nothing it exists
257+
# to replace.
258+
run: go test ./internal/guard/ -count=1 -run 'ReferenceTool' -v
259+
shell: bash
260+
216261
fuzz:
217262
name: fuzzing with a time budget
218263
# Weekly and by hand, never on a push. Fuzzing searches without end, so a

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,30 @@ because it turns other people's test suites red.
8686
the same one the recipe uses, and a value that is not on it is refused with
8787
the list to pick from.
8888

89+
### Breaking
90+
91+
- **PDF bytes have changed.** The text on a page is laid out to a fixed line
92+
width now. It used to be eight to thirteen drawn words of four to nine
93+
characters, which meant the smallest document one seed could produce was not
94+
the smallest another could - so `--size 3300` was accepted for six seeds out
95+
of ten, and the floor moved between 3090 and 3499 bytes depending on the
96+
seed. The words still come from the seed, so two seeds still read
97+
differently. Only the length is settled. Anything holding a hash of a PDF
98+
this tool made will need to take it again.
99+
89100
### Changed
90101

102+
- **A rule binding two settings is declared rather than described.** PNG allows
103+
each side of a picture up to 20000 pixels and the two multiplied up to 40
104+
megapixels, and that second rule lived in the generator and in a sentence -
105+
so `tfg formats png` offered a pair it then refused, and nothing but a person
106+
could read the limit. It is in the registry now, printed on its own line and
107+
carried in `--json`, so a script or a window sees it.
108+
- **A second run into the same directory refuses before it writes anything.**
109+
It used to write its whole set of files and only then find it had nowhere to
110+
record them: two runs started together left sixteen files on the disk with
111+
eight of them in nobody's manifest. The manifest name is taken before the
112+
first file now, and given back if the run ends without writing one.
91113
- **The minimum `tfg formats` prints is a size it will take.** For pdf, wav
92114
and zip it was not: the number came from the registry, which holds the
93115
structural floor of a format with no label, and the label is on unless you

‎internal/cli/formats.go‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,20 @@ type formatEntry struct {
2828
PaddingCap int64 `json:"padding_capacity,omitempty"`
2929
Label string `json:"label_carrier"`
3030
Properties []propertyEntry `json:"properties,omitempty"`
31-
Oracle string `json:"oracle"`
32-
Version string `json:"generator_version"`
31+
// JointLimits are the rules binding two settings that neither can state on
32+
// its own, so a script or a window sees them without reading prose.
33+
JointLimits []jointLimitEntry `json:"joint_limits,omitempty"`
34+
Oracle string `json:"oracle"`
35+
Version string `json:"generator_version"`
36+
}
37+
38+
// jointLimitEntry is a rule binding two settings, as a script sees it.
39+
type jointLimitEntry struct {
40+
Of string `json:"of"`
41+
By string `json:"by"`
42+
Max int64 `json:"max"`
43+
Unit string `json:"unit,omitempty"`
44+
Detail string `json:"detail"`
3345
}
3446

3547
// propertyEntry is one setting as a script sees it. Everything a window would
@@ -57,6 +69,19 @@ func smallestAccepted(d format.Descriptor) int64 {
5769
return d.SmallestAccepted(format.Request{Label: true})
5870
}
5971

72+
func jointsOf(d format.Descriptor) []jointLimitEntry {
73+
if len(d.JointLimits) == 0 {
74+
return nil
75+
}
76+
out := make([]jointLimitEntry, 0, len(d.JointLimits))
77+
for _, j := range d.JointLimits {
78+
out = append(out, jointLimitEntry{
79+
Of: j.Of, By: j.By, Max: j.Max, Unit: j.Unit, Detail: j.Describe(),
80+
})
81+
}
82+
return out
83+
}
84+
6085
func entryFor(d format.Descriptor) formatEntry {
6186
props := make([]propertyEntry, 0, len(d.Properties))
6287
for _, p := range d.Properties {
@@ -70,7 +95,7 @@ func entryFor(d format.Descriptor) formatEntry {
7095
Fidelity: string(d.Fidelity), Determinism: string(d.Determinism),
7196
MinBytes: d.MinBytes, SmallestAccepted: smallestAccepted(d),
7297
Padding: d.Padding.Name, PaddingCap: d.Padding.Capacity,
73-
Label: string(d.Label), Properties: props,
98+
Label: string(d.Label), Properties: props, JointLimits: jointsOf(d),
7499
Oracle: d.Oracle, Version: d.GeneratorVersion,
75100
}
76101
}
@@ -99,6 +124,14 @@ func describeOne(d format.Descriptor, out io.Writer) {
99124
fmt.Fprintf(out, " %-14s %s\n", "", p.Detail)
100125
}
101126
}
127+
128+
// A rule binding two settings gets its own line. Folded into the
129+
// description of each one it reads as decoration, and it was invisible
130+
// altogether before the registry could carry it - so this command offered
131+
// twenty thousand by twenty thousand and the run refused the pair.
132+
for _, j := range d.JointLimits {
133+
fmt.Fprintf(out, "\n and together: %s\n", j.Describe())
134+
}
102135
}
103136

104137
// allowedText says what a property accepts, in the same words the refusal uses.

‎internal/engine/engine.go‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -414,11 +414,7 @@ func preflight(files []PlannedFile, opt Options) error {
414414
// files could never be cleaned up by this tool again. It happened on a
415415
// successful run as readily as on a refused one, and on the successful one
416416
// it happened in silence.
417-
name := opt.ManifestName
418-
if name == "" {
419-
name = DefaultManifestName
420-
}
421-
if path := filepath.Join(opt.OutDir, name); exists(path) {
417+
if path := filepath.Join(opt.OutDir, manifestNameOf(opt)); exists(path) {
422418
return &CollisionError{Path: path, Manifest: true}
423419
}
424420

@@ -456,6 +452,15 @@ func exists(path string) bool {
456452
return err == nil
457453
}
458454

455+
// manifestNameOf is where this run's record lands. One definition, because the
456+
// preflight check, the claim and the save all have to mean the same file.
457+
func manifestNameOf(opt Options) string {
458+
if opt.ManifestName == "" {
459+
return DefaultManifestName
460+
}
461+
return opt.ManifestName
462+
}
463+
459464
func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error) {
460465
m := manifest.New(
461466
"testing-files-generator", version.Version,
@@ -490,6 +495,26 @@ func Run(ctx context.Context, files []PlannedFile, opt Options) (*Result, error)
490495
return res, fmt.Errorf("cannot create the output directory %s: %w", opt.OutDir, err)
491496
}
492497

498+
// The manifest name is taken before the first file, not after the last one.
499+
//
500+
// Claiming it at save time already stopped two runs from both writing a
501+
// manifest, but it happened at the end - so a second run wrote its whole set
502+
// of files and only then found out it had nowhere to record them. Measured
503+
// on 2026-08-03: two runs started together under different ids ended 0 and
504+
// 5, with sixteen files on the disk and eight of them in nobody's manifest.
505+
// Taking the name here turns that into a refusal before anything is written.
506+
manifestPath := filepath.Join(opt.OutDir, manifestNameOf(opt))
507+
if err := manifest.Claim(manifestPath); err != nil {
508+
return res, &CollisionError{Path: manifestPath, Manifest: true}
509+
}
510+
// Given back if this run ends without writing one, or the name would stay
511+
// taken by a run that never happened.
512+
defer func() {
513+
if !res.Manifest.Run.Complete && res.Failures == 0 && len(res.Manifest.Files) == 0 {
514+
_ = manifest.Release(manifestPath)
515+
}
516+
}()
517+
493518
totalBytes := TotalBytes(files)
494519
var bytesDone int64
495520

‎internal/format/format.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,64 @@ type Property struct {
156156
Detail string
157157
}
158158

159+
// JointLimit is a rule binding two settings that neither of them can state
160+
// alone.
161+
//
162+
// A Property bounds one value. PNG needs more than that: each side of a picture
163+
// may go up to twenty thousand pixels and the two multiplied may not pass forty
164+
// megapixels, because the picture is held in memory while it is encoded. That
165+
// rule had nowhere to live, so it lived in the generator and in a sentence of
166+
// prose - and "tfg formats png" offered a pair it then refused.
167+
//
168+
// A sentence is enough for a person and not for anything else. AR9 has the
169+
// registry as the one place a consumer asks what a format accepts, and a window
170+
// drawing two number fields from Min and Max would offer twenty thousand in
171+
// both and produce a request the run rejects. Declaring it is what lets the
172+
// refusal, the printed description and a future field all come from one place.
173+
//
174+
// Kept to a product of two because that is the shape every case in Tier 1 has -
175+
// pixels, samples times channels, pages times page size. A general expression
176+
// would be a language to learn, and this is a line to read.
177+
type JointLimit struct {
178+
// Of and By are the two settings multiplied together.
179+
Of, By string
180+
// Max is the largest their product may be, counted in the same units the
181+
// settings themselves use.
182+
Max int64
183+
// Unit is what the product is reported in, and Per is how many of the
184+
// settings' own units make one of it. Pixels are counted in millions when
185+
// spoken about, so Unit is "megapixels" and Per is a million - "400
186+
// megapixels and the limit is 40" is a sentence somebody can act on and
187+
// "400000000 and the limit is 40000000" is not. Per of nought means one.
188+
Unit string
189+
Per int64
190+
// Why is the reason, in the words the refusal uses.
191+
Why string
192+
}
193+
194+
// Allows reports whether a pair of values satisfies the limit, and says what is
195+
// wrong when it does not.
196+
func (j JointLimit) Allows(of, by int64) (bad string) {
197+
if of*by <= j.Max {
198+
return ""
199+
}
200+
return fmt.Sprintf("together they come to %d %s and the limit is %d, because %s",
201+
of*by/j.per(), j.Unit, j.Max/j.per(), j.Why)
202+
}
203+
204+
// Describe is the rule as one sentence, for the format list and for a window.
205+
func (j JointLimit) Describe() string {
206+
return fmt.Sprintf("%s times %s cannot pass %d %s, because %s",
207+
j.Of, j.By, j.Max/j.per(), j.Unit, j.Why)
208+
}
209+
210+
func (j JointLimit) per() int64 {
211+
if j.Per == 0 {
212+
return 1
213+
}
214+
return j.Per
215+
}
216+
159217
// Descriptor is everything a format announces about itself. A format missing
160218
// any of it fails the registry test rather than shipping half implemented.
161219
type Descriptor struct {
@@ -176,6 +234,10 @@ type Descriptor struct {
176234
// it should not. An empty list means the format takes no properties.
177235
Properties []Property
178236

237+
// JointLimits are the rules binding two settings that neither can state on
238+
// its own. Empty for every format that has none.
239+
JointLimits []JointLimit
240+
179241
// Container says this format holds other files, so a recipe may declare
180242
// contains for it.
181243
//

0 commit comments

Comments
 (0)