feat(assets): a bundle carries the images its sources declare (0.10.0)

Until now no reader in this package fetched, named, described or copied a
single image. `<img>`'s attributes were never read, a NISO-STS `<graphic>`
was walked past, a PDF was opened for its text alone, the converter's
markdown writer dropped every picture, and the only writer into a bundle
took `content: str`. The two lossiness warnings said so on every run, which
made the loss honest and did not make it smaller.

Measured on R761 Prosesskoden:2025, published as a 701-page PDF and as a
NISO-STS delivery: the process text is carried in full while 12 `Tabell N-N`
and 9 `Figur N-N` captions stand over nothing, because that publisher ships
those tables as raster pictures in both. Process 84's "toleranseklasse ...
er gitt i tabell 84-2" points at empty space.

THE GATE WAS WRITTEN FIRST AND RED. `tests/test_asset_gate.py` reads its
denominator out of the source (`page.images`, `word/media/`, `ppt/media/`,
`<img`, `<graphic`), never from a constant here. Measured at 332961a, built
from `git archive` and not from the editable tree: carried 0 of 8 local
images across 5 documents (9 declared), and no `assets/` at all. After: 8 of
8, with the ninth a remote source carried as a pointer without a file.

FIVE READERS PLACE, ONE MODULE DECIDES. `assets.py` owns what an image is
(sniffed from the bytes, never from the claimed extension), what it is
called (`<sha256[:12]>-<the source's own basename>`) and how it is pointed
at (one two-line block, one regex). `.xlsx` is deliberately not a row: a
block inside its pipe tables would break the `source_rows` locator, and 0 of
4 K2 workbooks hold media.

A PDF stream that is already a file is carried VERBATIM (29 of R761's 50
objects are DCTDecode); raw samples are encoded to PNG with stdlib zlib, so
no new dependency. Rendering the page region was the alternative and was
felled on determinism: a rasterised crop's bytes, and therefore the asset's
content-addressed name and the bundle's digest, would depend on the
installed rasteriser. What the encoder cannot express exactly is refused
with a code and counted, never approximated.

NO SIZE FLOOR, and that is a measurement: over the 4 828 image objects of
the K2 corpus the size distribution is a broad spread with no gap, unlike
OCR_CID_SHARE's bimodal one, so a threshold would be a number we chose.

ON BY DEFAULT, AND THE CONTROL IS TWO WHOLE BUILDS. The 43-document
reference corpus at 332961a versus rebuilt at HEAD with `--no-assets`:
865 files on both sides, `diff -rq` reports ONE difference, the added
`Images: NOT CARRIED` line in log.md. Every concept byte-identical.
Against the default: 453 -> 454 concepts, 865 -> 867 md, 0 -> 2 964 assets
(2 964 carried of 3 145 found, 4 622 pointers), 4.7 MB -> 115 MB, 2 414 s ->
3 088 s, peak RSS 6.26 -> 8.74 GB, 422 of 865 md files differ. The one new
concept has a measured cause: the pointers are body text, so a section
holding 146 of that document's images grew from 19.0 % to 30.6 % of the
extracted text and crossed `--outline-gate`'s 0.20 share clause.

THE IMAGE BYTES ARE NOT SCREENED. The guard is text-only, the pointer block
passes the gate as body text, the picture beside it passes nothing, and
log.md says so on every run.

Also fixed, both found by measuring rather than by reading:

- a markdown image is no longer read as a cross-reference. `structure._LINK`
  never looked at the character in front of the bracket, so every pointer
  would have arrived in the index as an edge to a concept that cannot exist.
- Door C carries the assets its merged concepts point at. Before this,
  importing a bundle built with `--assets` merged 6 of 6 concepts and wrote
  no `assets/` at all, so every pointer named a missing file.

Report: docs/2026-09-17-bilder-i-bundlen-trinn1.md
Spec proposal: docs/plan/okf-assets-section-6-4.md
Suite 1 955 passed / 1 skipped (from 1 896), ruff and mypy --strict clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-17 10:01:31 +02:00
commit bc39e8091f
33 changed files with 3638 additions and 64 deletions

View file

@ -0,0 +1,96 @@
# Proposed SPEC § 6.4: `assets/`, the bytes a concept points at
Status: **a proposal, raised from a consumer**. Written in this repository
because this repository implements the shape; the wording belongs upstream and
`_okf-canonical` is not edited from here. Pinned commit read while writing:
`ad30107` (OKF v0.2).
## Why it is needed
OKF v0.2 is silent about non-markdown files. § 3 says "A bundle is a directory
tree of markdown files"; § 11's conformance list scopes every clause to `.md`
files; § 6.3 makes `references/` a convention for external material carried as
concepts. So a picture is neither permitted nor forbidden — it is unaddressed,
and a producer that carries one is guessing about where it goes and what a
consumer may assume.
The need is not hypothetical. Measured on R761 Prosesskoden:2025, a Norwegian
road-construction process code published both as a 701-page PDF and as a
NISO-STS XML delivery: the process text is carried in full, and 12 `Tabell N-N`
and 9 `Figur N-N` captions stand over nothing, because the publisher ships
those tables as raster images in **both** deliveries. Process 84 says
"toleranseklasse ... er gitt i tabell 84-2" and table 84-2 is a JPEG. A bundle
built from that document reads as complete and is not.
## The proposed wording
> ### 6.4 The `assets/` convention
>
> A bundle MAY carry non-markdown files that its concepts point at — images
> extracted from a source document, and anything else a concept embeds rather
> than describes. An `assets/` directory at the bundle root conventionally
> holds them.
>
> A concept points at an asset with a standard markdown image or link whose
> target is a path-valued reference under § 6.2 — the bundle-relative form
> (`/assets/<name>`) is recommended, for the same reason § 6.1 recommends it
> for links between concepts: it is stable when a concept moves within its
> subdirectory.
>
> Asset file names are the producer's. A content-addressed name (for example a
> prefix of the file's SHA-256 followed by a readable remnant of the source's
> own name) is RECOMMENDED, because it makes the same bytes dropped twice one
> file and makes a rebuild of one corpus produce one bundle.
>
> An asset is not a concept. It carries no frontmatter, it is not enumerated by
> § 8's index files, and § 11's conformance clauses do not apply to it — they
> are scoped to `.md` files, and this section does not widen them.
>
> Consumers MUST NOT reject a bundle because it carries files they do not
> recognise, and MUST tolerate an asset pointer whose target is absent, for the
> same reason § 6.1 requires them to tolerate a broken link: the pointer may
> record that the source had a figure this bundle does not hold.
## What it does NOT propose
- **No screening claim.** Whether the bytes of an asset were examined is
outside this section and outside the format. This library states it per run
in `log.md` because its own gate is text-only; a picture is not text and did
not pass it.
- **No required directory.** `assets/` is a convention, exactly as
`references/` is. A producer that puts its images elsewhere and points at
them correctly is conformant.
- **No new frontmatter family.** This library writes a count (`images: N`) on
its own profiles, and that is a local key, not a proposal. § 11 already tells
consumers not to reject a concept over an unknown key.
## Conformance measured, not assumed
The claim "existing consumers do not break" is § 11 item 1 scoping to `.md`
files, plus the consumer-side MUST NOTs. Measured on a bundle WITH `assets/`,
built by `okf build` from the fixture inbox:
| Surface | Result |
| --- | --- |
| `okf check` (17 rules) | `conformant: 17 rules over 4 excerpts and 2 withheld entries, 0 findings`, rc 0 |
| `okf skill` | rc 0 |
| `okf consume` | rc 0, 4 excerpts; the pointers travel with the excerpt text |
| `okf quality` | rc 3 — "nothing could be judged", because each file type has one document and the floor is five. No false `PASS`, no crash |
| guard 1.4.0 `okf.import_bundle` | 6 of 6 concepts merged; the pointer blocks pass the gate as body text |
The guard does not reject a bundle carrying binary files — it does not see
them, because the importer walks `.md`. So no coordination message to
`llm-ingestion-pipeline-security` is needed for this step.
The measurement that did NOT pass first time is in the report: Door C merged
the concepts and wrote no `assets/` at all, so every pointer in the imported
bundle named a missing file. Fixed here, by the content-identity rule that door
already owns. The run record is
`docs/2026-09-17-bilder-i-bundlen-trinn1.md` § 5.
## Route
Raised through `portfolio-optimiser-commons`, which owns the ingest-spec this
library implements, and from there upstream. Not edited into `_okf-canonical`
from here: that mirror is a read-only pin, and a spec change written by its
implementer is not a spec change.