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 at332961a, 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 at332961aversus 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>
244 lines
9.4 KiB
Python
244 lines
9.4 KiB
Python
"""THE GATE: does a bundle carry the images its sources declare? (0.10.0)
|
|
|
|
Written BEFORE the capability, and red on purpose. Its whole job is to state
|
|
one number per reader with the DENOMINATOR read out of the source document
|
|
rather than out of this package -- "carried 0 of 9" is a measurement; "no image
|
|
support" is a sentence. The denominators here are computed by opening the
|
|
fixture bytes and counting what the FORMAT says is there (`page.images`,
|
|
`word/media/`, `ppt/media/`, `<img`, `<graphic`), so the gate keeps working
|
|
when the fixtures change and cannot drift into asserting our own output back at
|
|
us.
|
|
|
|
**Measured at `332961a`**, built from `git archive` rather than from the
|
|
editable tree -- an editable install reads `src/` live, so a "before" run taken
|
|
in this working tree would have been measuring the change it was supposed to
|
|
predate:
|
|
|
|
carried 0 of 2 local (2 declared) prosess-84-tabell.pdf
|
|
carried 0 of 1 local (1 declared) prosess-84-notat.docx
|
|
carried 0 of 1 local (1 declared) prosess-84-presentasjon.pptx
|
|
carried 0 of 2 local (3 declared) prosess-84-web.html
|
|
carried 0 of 2 local (2 declared) prosess-84-sts.xml
|
|
---------------------------------------------------------------
|
|
carried 0 of 8 local images across 5 documents (9 declared),
|
|
and the bundle held no `assets/` directory at all.
|
|
|
|
Two of the seven files in the fixture inbox are the PNGs the HTML and STS
|
|
documents point at, and the run reports them as `extractor_unknown: 2/7` at
|
|
both commits. That is deliberate and unchanged: `.png` as a DROPPED FILE is a
|
|
separate question with its own order, and an image reached through a document
|
|
is this one.
|
|
|
|
WHY THE GATE IS A BUNDLE-LEVEL COUNT and not a per-function assertion: the
|
|
failure this exists to stop is not "the reader returned nothing". It is a
|
|
bundle that looks complete and is not -- R761's process 84 saying "toleranse-
|
|
klasse ... er gitt i tabell 84-2" over an empty space, with `okf check` green
|
|
and `okf quality` unable to see it. So the gate runs the real command, walks
|
|
the written bundle, and asks whether the bytes are there.
|
|
|
|
THE REMOTE IMAGE IS PART OF THE DENOMINATOR AND NOT PART OF THE TARGET. The
|
|
HTML fixture carries three `<img>`; one points at `https://example.invalid/`.
|
|
Extraction opens no socket -- network access here is an explicit per-run opt-in
|
|
and extraction is not on that path -- so that image is FOUND, counted, and
|
|
carried as a pointer without a file. A gate that quietly dropped it from the
|
|
denominator would report 2 of 2 and hide the one case a reader most needs told.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import warnings
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_okf import cli, corpus
|
|
from llm_ingestion_okf.assets import ASSETS_DIR, IMAGE_POINTER
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "image-inbox"
|
|
BUNDLE_ID = "asset-gate-fixture"
|
|
OKF_VERSION = "0.2"
|
|
|
|
#: Every document in the fixture inbox, with the reader it exercises.
|
|
DOCUMENTS = (
|
|
"prosess-84-tabell.pdf",
|
|
"prosess-84-notat.docx",
|
|
"prosess-84-presentasjon.pptx",
|
|
"prosess-84-web.html",
|
|
"prosess-84-sts.xml",
|
|
)
|
|
|
|
|
|
# --- denominators, read out of the source ----------------------------------
|
|
|
|
|
|
def declared_images(path: Path) -> int:
|
|
"""How many images the SOURCE says it holds, by the format's own rule.
|
|
|
|
Never a constant: a constant is this package asserting its own expectation,
|
|
and it goes stale the moment a fixture is regenerated. Each branch reads the
|
|
container the way the format defines it, which is also the number a person
|
|
checking the bundle by hand would arrive at.
|
|
"""
|
|
suffix = path.suffix.lower()
|
|
data = path.read_bytes()
|
|
if suffix == ".pdf":
|
|
pdfplumber = pytest.importorskip("pdfplumber")
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore")
|
|
with pdfplumber.open(path) as pdf:
|
|
return sum(len(page.images) for page in pdf.pages)
|
|
if suffix in (".docx", ".pptx", ".xlsx"):
|
|
with zipfile.ZipFile(path) as archive:
|
|
return sum(1 for name in archive.namelist() if "/media/" in name)
|
|
text = data.decode("utf-8")
|
|
if suffix in (".html", ".htm"):
|
|
return len(re.findall(r"<img\b", text))
|
|
if suffix == ".xml":
|
|
return len(re.findall(r"<(?:inline-)?graphic\b", text))
|
|
raise AssertionError(f"no declared-image rule for {path.name}")
|
|
|
|
|
|
def local_images(path: Path) -> int:
|
|
"""The declared images MINUS the ones whose source is off this machine.
|
|
|
|
Separate from :func:`declared_images` and both are reported: the difference
|
|
between them is exactly the network boundary, and collapsing the two would
|
|
turn a boundary into an absence.
|
|
"""
|
|
declared = declared_images(path)
|
|
if path.suffix.lower() in (".html", ".htm"):
|
|
remote = len(re.findall(r'<img\b[^>]*src="(?:https?:)?//', path.read_text("utf-8")))
|
|
return declared - remote
|
|
return declared
|
|
|
|
|
|
# --- the run ---------------------------------------------------------------
|
|
|
|
|
|
def _inbox(tmp_path: Path) -> Path:
|
|
inbox = tmp_path / "inbox"
|
|
(inbox / "graphics").mkdir(parents=True)
|
|
for source in sorted(FIXTURES.rglob("*")):
|
|
if source.is_file():
|
|
target = inbox / source.relative_to(FIXTURES)
|
|
target.write_bytes(source.read_bytes())
|
|
return inbox
|
|
|
|
|
|
def _build(inbox: Path, bundle: Path, *extra: str) -> int:
|
|
return cli.main(
|
|
[
|
|
"build",
|
|
str(inbox),
|
|
"--bundle",
|
|
str(bundle),
|
|
"--bundle-id",
|
|
BUNDLE_ID,
|
|
"--okf-version",
|
|
OKF_VERSION,
|
|
*extra,
|
|
]
|
|
)
|
|
|
|
|
|
def _concepts(bundle: Path) -> list[Path]:
|
|
return [
|
|
path
|
|
for path in sorted(bundle.rglob("*.md"))
|
|
if path.name not in {"index.md", corpus.LOG_NAME}
|
|
]
|
|
|
|
|
|
def carried_per_source(bundle: Path) -> dict[str, int]:
|
|
"""Pointers in the bundle whose asset file is actually present, per source.
|
|
|
|
A pointer whose bytes are missing is NOT carried. Counting pointers alone
|
|
would let an empty `assets/` pass the gate, which is the same defect one
|
|
level down -- a bundle that looks complete and is not.
|
|
"""
|
|
counts: dict[str, int] = {}
|
|
for concept in _concepts(bundle):
|
|
text = concept.read_text("utf-8")
|
|
source = ""
|
|
for line in text.splitlines():
|
|
if line.startswith("source_file:"):
|
|
source = line.split(":", 1)[1].strip().strip("'\"")
|
|
break
|
|
for match in IMAGE_POINTER.finditer(text):
|
|
if (bundle / ASSETS_DIR / match.group("asset")).is_file():
|
|
counts[source] = counts.get(source, 0) + 1
|
|
return counts
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def built(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, dict[str, int]]:
|
|
pytest.importorskip("pdfplumber")
|
|
pytest.importorskip("pypandoc")
|
|
pytest.importorskip("llm_ingestion_guard")
|
|
root = tmp_path_factory.mktemp("asset-gate")
|
|
inbox = _inbox(root)
|
|
bundle = root / "bundle"
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore")
|
|
assert _build(inbox, bundle) == 0
|
|
return bundle, carried_per_source(bundle)
|
|
|
|
|
|
# --- the gate --------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("document", DOCUMENTS)
|
|
def test_every_local_image_reaches_the_bundle(
|
|
document: str, built: tuple[Path, dict[str, int]]
|
|
) -> None:
|
|
"""carried == local, per source document, with both numbers printed."""
|
|
bundle, carried = built
|
|
want = local_images(FIXTURES / document)
|
|
got = carried.get(document, 0)
|
|
assert got == want, (
|
|
f"{document}: carried {got} of {want} local images "
|
|
f"({declared_images(FIXTURES / document)} declared by the source)"
|
|
)
|
|
|
|
|
|
def test_the_bundle_carries_every_local_image_of_every_document(
|
|
built: tuple[Path, dict[str, int]],
|
|
) -> None:
|
|
"""The whole-bundle row, so the gate reports one number a person can quote."""
|
|
bundle, carried = built
|
|
want = sum(local_images(FIXTURES / document) for document in DOCUMENTS)
|
|
got = sum(carried.values())
|
|
assert got == want, f"carried {got} of {want} local images across {len(DOCUMENTS)} documents"
|
|
|
|
|
|
def test_a_remote_image_is_a_pointer_without_a_file_never_a_silent_drop(
|
|
built: tuple[Path, dict[str, int]],
|
|
) -> None:
|
|
"""The network boundary, stated in the artifact rather than implied by absence."""
|
|
bundle, _ = built
|
|
html = [path for path in _concepts(bundle) if "prosess-84-web.html" in path.read_text("utf-8")]
|
|
assert html, "the html document produced no concept at all"
|
|
body = "\n".join(path.read_text("utf-8") for path in html)
|
|
assert "https://example.invalid/ekstern.png" in body, (
|
|
"a remote image must leave a pointer naming where it was, so a reader "
|
|
"learns the document had a figure this bundle does not hold"
|
|
)
|
|
assert "not carried" in body
|
|
|
|
|
|
def test_the_asset_bytes_are_the_sources_own_bytes(
|
|
built: tuple[Path, dict[str, int]],
|
|
) -> None:
|
|
"""A carried image is byte-identical to the file the source shipped.
|
|
|
|
Only the two files the fixture carries as real files can be checked this
|
|
way; the PDF and office images arrive inside a container. That is enough to
|
|
pin the property that matters -- nothing re-encodes an image that already
|
|
is one.
|
|
"""
|
|
bundle, _ = built
|
|
written = {path.read_bytes() for path in (bundle / ASSETS_DIR).glob("*")}
|
|
for original in sorted((FIXTURES / "graphics").glob("*.png")):
|
|
assert original.read_bytes() in written, original.name
|