llm-ingestion-okf/docs/2026-09-19-hvert-baaret-bilde-kan-vises.md
Kjell Tore Guttormsen c39b666c11
docs: the viewable-asset round, measured per image
README, CHANGELOG (under the untagged 0.10.1), CLAUDE.md and the round's
report. The numbers are the census's, not a summary of them.

- 9 714 image rows over 18 403 files and 67 PDFs, two pinned trees: exactly
  35 rows moved, 9 321 of 9 321 JPEG and PNG rows byte-identical.
- The 16 the round did not go looking for: JPEG 2000 objects out of PDF
  streams, also unreadable by a model, with no stdlib route to convert them.
  They become `asset_not_viewable` -- 16 pictures lost from a bundle, 16
  statements gained that a picture stood there. Stated in the README, the
  CHANGELOG, CLAUDE.md and the report rather than left in a total.
- The accounting gate: R761 0 -> 19 claimed-and-not-found when the conversion
  landed, 0 again after the judge learned its second route; row 6 90 of 111
  -> 110 of 111. The gate's verdict is the same `RED: rows 2, 3, 6` as before
  the round, with row 3's numbers identical.
- `<!-- asset-viewable-media-types: ... -->` is pinned to
  `assets.VIEWABLE_MEDIA_TYPES` by a test, compared as a whole set: a README
  naming three of four would pass every containment check and tell a consumer
  that a format is refused when it is carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:26:17 +02:00

11 KiB
Raw Blame History

Every carried image is one a model can be shown

llm-ingestion-okf, 2026-09-19. Order 20260919T033624Z-4535320370, raised from PM's measurement of the frozen R761 package's assets/ directory.

Nothing is tagged, pushed or released here. No new version: the entries go under the untagged [0.10.1].

1. The defect

file over the frozen R761 delivery's own assets/, denominator 50:

Format Count A model can show it
JPEG 29 yes
PNG 2 yes
PC bitmap, Windows 3.x, 8-bit, compression 1 (RLE8 BMP) 19 no

The 19 are byte-correct files. They are also files nothing decodes, so 19 of that document's figures were present and invisible at the same time — and the concept's own images: N reported that they had arrived. An absence a reader is shown is information; a picture that is there and unreadable is worse than either, because the count says the opposite.

Reproduced here before anything was written, with the committed census (§ 5): 19 of 50 CARRY image/bmp.bmp, 29 of 50 CARRY image/jpeg.jpg, 2 of 50 CARRY image/png.png.

2. The decoder: measured, then chosen

The order requires the choice to rest on a measurement of what the tree already has. Pillow 12.3.0 is present, transitively under pdfplumber in the [extract] extra, and PIL occurs 0 times in src/, tests/ and tools/.

It decodes RLE8 correctly. A hand-written stdlib decoder and Pillow were compared on all 19 real files, RGB per pixel through the palette: 19 of 19 identical. So the measurement's answer is yes, and the choice is not made on capability.

Chosen: a stdlib reader. Why, and both reasons are properties of this package rather than preferences.

  1. read_image is on the CORE path. .html and .xml are stdlib file types that carry images with no [extract] extra installed, so a Pillow-based converter either makes a core path depend on an optional binary wheel or buys this package its second runtime dependency — against the "exactly one runtime dependency, ever" rule a packaging test enforces.
  2. An asset's name is its content digest, so the bytes the converter emits are part of the bundle's identity. Encoding through an installed library would make that identity move with the library's version. This is the property 0.10.0 felled page rasterisation over, stated in encode_png's own docstring, and it would be odd to defend it there and give it away here.

encode_png already writes a PNG from samples with zlib alone; this round adds the reader in front of it. Pillow keeps the job it is good for: the INDEPENDENT decoder in the tests, on neither side of the conversion.

3. What was built

  • assets.VIEWABLE_MEDIA_TYPES — image/png, image/jpeg, image/gif, image/webp. read_image tests every asset's SNIFFED type against it. It is a property, not a list of formats we happened to meet. WebP is on the set and sniff does not recognise one, so a WebP is refused one step earlier as asset_type_unknown; the limit is stated rather than left implied.
  • assets.bmp_to_png — 8-bit uncompressed, 8-bit RLE8, 24-bit uncompressed. All five RLE8 opcodes: encoded run, absolute run with its pad byte, delta, end-of-line, end-of-bitmap. Bottom-up unless the declared height is negative. Palette read BGRA → RGB.
  • Two published codes. asset_not_viewable — a real image in a format no model can be shown, with no lossless conversion here (TIFF, JPEG 2000). asset_bmp_unsupported — a BMP variant this reader does not express (RLE4, BITFIELDS, 16/32-bit, BITMAPCOREHEADER, over 256 palette entries). Both leave the concept's own "not carried" line and a row in the run log.
  • ExtractedImage.converted_from and .source_sha256, written onto the pointer's second line where the source's file name and pixel size already live: original media type, original sha256 in full, new sha256 in full. Full digests because the asset's file name carries only 12 hex characters of the new one, and a checksum a reader cannot paste into shasum -a 256 is decoration.

A converted asset is ONE asset. One file in assets/, one pointer, one row in the accounting — the accounting's own columns read ExtractedDocument.files, which is what the resolver resolved, and conversion does not touch it.

The ceiling is paid before the pixels exist. bmp_to_png bounds the DECLARED size through the same check_size the rest of the image path uses, before one row is allocated, and each RLE run is written as one CLIPPED slice. Painting pixel by pixel would leave the memory bounded and the CPU unbounded: a megabyte of FF runs is a hundred million paint steps against a 32-pixel frame.

4. The eval, red first

Two red commits, because the mutant survey found holes in the first one.

Commit What
fea0435 12 guards, 8 red on their claim
9e99bb2 2 more guards the mutant survey found missing; 9 of 13 red

The second commit is the interesting one. Walking the five mutants the order names against the eval as committed, two survived:

  • "the format is read from the file extension instead of the bytes" survived because every image in the fixture was named after what it is: .bmp maps to image/bmp either way. A BMP named graphics/figur.png can tell the two apart.
  • "the ceiling is checked after decoding" survived because the guard only asserted that a refusal happens, and encode_png's own check_size refuses too — one frame later, after the memory is spent. The guard already measured the peak; what it could not do was measure it affordably, since a 50 000 × 50 000 frame is 2.5 GB. At 7 000 × 7 000 the declaration is still over the 40 MP bound and the unbounded frame is 49 MB.

The lossless guard needed the same care. Comparing "the carried asset that holds these pixels" against the source is green before any conversion exists, because a BMP carried verbatim trivially holds its own pixels. The holder has to be required to be VIEWABLE; with that clause the guard reads 0 carried assets hold these pixels before, and passes after.

5. The measurements

Lossless, on the real files. Method: Pillow decodes the SOURCE BMP to RGB; read_image converts it; Pillow decodes the RESULT; the two sizes and two RGB buffers must be equal. Pillow is on neither side of the conversion.

  • 19 of 19 lossless, 0 differing, 2 366 365 pixels compared.

Mutants, in a scratch copy of the tree. Control green first, because "killed N of N" can also mean the harness cannot invoke pytest at all.

Mutant Result
M1 the format is read from the file extension KILLED
M2 the conversion skips 8-bit images KILLED
M3 the palette is ignored (grey out) KILLED
M4 the traceability field is omitted KILLED
M5 the ceiling is checked after decoding KILLED
M6 the BMP's BGR order is carried unswapped KILLED
M7 the RLE delta opcode is ignored KILLED

killed 7 of 7, survived 0, not measured 0, control green at 13 passed.

The census (tools/okf_asset_census.py) is the committed instrument for the known-positive. One row per image — the source's own sha256, the format read off its bytes, and what read_image did with it — over every image file and, with --pdf, every image object in every PDF it finds. Run from two pinned trees and diff the rows.

It was itself caught by the rule it exists to serve. Its first version handed _pdf_images a page.page_obj where the function reads page.images through getattr(..., []), so it reported 0 images over 67 PDFs and exit 0 — a clean-looking table that had measured nothing. The attribute is asserted now, and the known-positive (one PDF, 8 images) is run before the sweep.

6. What the change costs, row by row

Two census runs over every image this machine holds — 18 403 files walked, 67 PDFs opened, 9 714 image rows — one from the tree before the conversion and one after.

Outcome Before After
CARRY image/jpeg.jpg (from PDF streams) 3 053 3 053
CARRY image/jpeg.jpg (standalone files) 29 29
CARRY image/png.png (from PDF streams) 6 233 6 233
CARRY image/png.png (standalone files) 2 2
CARRY image/bmp.bmp 19 0
CARRY image/png.png in=image/bmp 0 19
CARRY image/jp2.jp2 16 0
REJECT asset_not_viewable 0 16
REJECT asset_pdf_unbounded 8 8
REJECT asset_pdf_unsupported 314 314
REJECT asset_samples_invalid 40 40
carried 9 352 9 336

Exactly 35 of 9 714 rows moved, and 9 679 are byte-identical — same source digest, same carried digest, same origin. The known-positive is stated on the rows rather than on the totals: 9 321 of 9 321 JPEG and PNG rows are identical on both sides, so not one already-viewable picture changed hands.

The 16 are the finding this round did not go looking for. JPEG 2000 objects, carried out of PDF streams by JPXDecode, in a format no model decodes either. They were invisible before and are refused with a code and a line now, which is the round's rule applied where it happens to bite. There is no stdlib route to decoding JPEG 2000, so converting them is not available: a consumer loses 16 pictures from a bundle and gains 16 statements that a picture was there. That is the trade this round makes, stated rather than buried in a total.

7. The accounting gate

The conversion broke the judge, and the judge caught it. asset_holds proved a carry by hashing the SOURCE file and looking for those bytes in assets/; a converted image's source bytes are not in the bundle. Measured on R761:

claimed and not found row 6
with the conversion, before the judge's second route 19 90 of 111
after 0 110 of 111

19 is exactly R761's RLE8 count. The judge now also reads the two digests the bundle states on the pointer line and then hashes the asset itself, so a bundle claiming a conversion it did not perform still fails — three cases in one test, one positive and two negative.

The gate's verdict is unchanged: GATE RED: rows 2, 3, 6, the same rows STATE recorded before the round, with row 3's numbers (u = 19 unaccounted, d = 2 double-booked, 17 of 22) identical across both runs — they are the reference corpus's own losses, which the operator left standing red on 2026-09-17. R761 reads exit 0, 1 of 1 persisted, 50 asset files, 31 081 carried elements found, 0 claimed and not found.

8. What this round does NOT do

  • JPEG 2000 is refused, not converted. No stdlib route exists.
  • WebP is not sniffed, so the viewable set's fourth member is unreachable today. The set states what a model can be shown, not what this package reads.
  • .xlsx still carries no image — a pointer block inside a sheet's pipe table would break the source_rows locator, unchanged from 0.10.0.
  • Nothing is tagged, pushed or released. The CHANGELOG entries go under the untagged [0.10.1].
  • The reference corpus is not rebuilt. The exposure is measured per image by the census, which is the finer instrument for this change: a rebuild reports that bytes moved, the census reports which image moved and why.