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>
181 lines
7.3 KiB
Python
181 lines
7.3 KiB
Python
"""Door B writes the images beside the text, and says how many (0.10.0).
|
|
|
|
The gate (`test_asset_gate.py`) asks whether the bytes arrive. This file pins
|
|
the four properties that decide whether their arrival is trustworthy.
|
|
|
|
- **A concept says how many images it carries.** `images: N`, conditional, so a
|
|
document without figures pays nothing and every bundle built before this
|
|
existed is byte-identical. Without the count a consumer cannot tell "this
|
|
document had no figures" from "this build dropped them", which is the
|
|
distinction the whole capability exists to restore.
|
|
- **The key belongs to the profiles this repository owns.** `DEFAULT` states
|
|
commons' ingest-spec SS 5 layer and `STRICT_V1` the wiki's ratified contract;
|
|
naming a key in either from here is this repository editing someone else's
|
|
contract (O2), the same reason `sources` sits on the segmented v0.2 profile
|
|
alone. Under those profiles the images are still CARRIED and still POINTED
|
|
at -- only the count is absent.
|
|
- **`--no-assets` reproduces the pre-move bytes.** Every default this package
|
|
has ever moved carries an opt-out that does, and the claim is measured on a
|
|
whole bundle rather than asserted.
|
|
- **The run log states the denominator.** SS 9's `log.md` already carries the
|
|
gate's name and the file counts because they are the facts about a run that
|
|
the bundle cannot otherwise recover. "51 images carried of 53 found" is the
|
|
same class of fact, and without it a bundle whose figures were all refused
|
|
looks exactly like a bundle of documents that had none.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import filecmp
|
|
import warnings
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_okf import cli, corpus
|
|
from llm_ingestion_okf.assets import ASSETS_DIR
|
|
from llm_ingestion_okf.profiles import DEFAULT, SEGMENTED_OKF_V0_2, STRICT_V1, STRUCTURED_V1
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures" / "image-inbox"
|
|
BUNDLE_ID = "asset-bundle-fixture"
|
|
OKF_VERSION = "0.2"
|
|
|
|
|
|
def _inbox(root: Path) -> Path:
|
|
inbox = root / "inbox"
|
|
(inbox / "graphics").mkdir(parents=True)
|
|
for source in sorted(FIXTURES.rglob("*")):
|
|
if source.is_file():
|
|
(inbox / source.relative_to(FIXTURES)).write_bytes(source.read_bytes())
|
|
return inbox
|
|
|
|
|
|
def _build(inbox: Path, bundle: Path, *extra: str) -> int:
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore")
|
|
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}
|
|
]
|
|
|
|
|
|
# --- the count -------------------------------------------------------------
|
|
|
|
|
|
def test_a_concept_that_carries_images_counts_them(tmp_path: Path) -> None:
|
|
pytest.importorskip("pdfplumber")
|
|
pytest.importorskip("llm_ingestion_guard")
|
|
bundle = tmp_path / "bundle"
|
|
assert _build(_inbox(tmp_path), bundle) == 0
|
|
counted = [path for path in _concepts(bundle) if "\nimages: " in path.read_text("utf-8")]
|
|
assert counted, "no concept declared an image count"
|
|
for path in counted:
|
|
text = path.read_text("utf-8")
|
|
line = next(row for row in text.splitlines() if row.startswith("images: "))
|
|
assert int(line.split(":", 1)[1]) == text.count("](/assets/")
|
|
|
|
|
|
def test_a_concept_without_images_declares_no_count(tmp_path: Path) -> None:
|
|
"""Conditional, like `req_number`: absent is the document saying nothing."""
|
|
pytest.importorskip("llm_ingestion_guard")
|
|
inbox = tmp_path / "inbox"
|
|
inbox.mkdir()
|
|
(inbox / "ren.md").write_text("# Kostnader\n\nIngen figurer her.\n", encoding="utf-8")
|
|
bundle = tmp_path / "bundle"
|
|
assert _build(inbox, bundle) == 0
|
|
for path in _concepts(bundle):
|
|
assert "images:" not in path.read_text("utf-8")
|
|
|
|
|
|
def test_the_key_is_named_by_the_profiles_this_repository_owns() -> None:
|
|
assert "images" in STRUCTURED_V1.frontmatter.order
|
|
assert "images" in SEGMENTED_OKF_V0_2.frontmatter.order
|
|
# O2: naming a key in either of these is editing a contract owned elsewhere.
|
|
assert "images" not in DEFAULT.frontmatter.order
|
|
assert "images" not in STRICT_V1.frontmatter.order
|
|
|
|
|
|
# --- the opt-out reproduces the pre-move bytes -----------------------------
|
|
|
|
|
|
def test_no_assets_writes_no_assets_directory(tmp_path: Path) -> None:
|
|
pytest.importorskip("pdfplumber")
|
|
pytest.importorskip("llm_ingestion_guard")
|
|
bundle = tmp_path / "bundle"
|
|
assert _build(_inbox(tmp_path), bundle, "--no-assets") == 0
|
|
assert not (bundle / ASSETS_DIR).exists()
|
|
for path in _concepts(bundle):
|
|
assert "](/assets/" not in path.read_text("utf-8")
|
|
|
|
|
|
def test_the_two_runs_differ_in_the_asset_layer_and_nowhere_else(tmp_path: Path) -> None:
|
|
"""A document with NO images is byte-identical under both settings.
|
|
|
|
The measurement that matters for every consumer who has already built a
|
|
bundle: turning the capability on must move nothing in a corpus that has no
|
|
pictures in it. Asserted on files rather than on a count, because a count is
|
|
insensitive to exactly the change this would hide.
|
|
"""
|
|
pytest.importorskip("llm_ingestion_guard")
|
|
inbox = tmp_path / "inbox"
|
|
inbox.mkdir()
|
|
(inbox / "ren.md").write_text(
|
|
"# Kostnader\n\nIngen figurer.\n\n## Pris\n\nEn tabell uten bilde.\n", encoding="utf-8"
|
|
)
|
|
on, off = tmp_path / "on", tmp_path / "off"
|
|
assert _build(inbox, on) == 0
|
|
assert _build(inbox, off, "--no-assets") == 0
|
|
names = sorted(path.relative_to(on) for path in on.rglob("*") if path.is_file())
|
|
assert names == sorted(path.relative_to(off) for path in off.rglob("*") if path.is_file())
|
|
for name in names:
|
|
if name.name == corpus.LOG_NAME:
|
|
continue # the log names the setting on purpose
|
|
assert filecmp.cmp(on / name, off / name, shallow=False), name
|
|
|
|
|
|
# --- the log states the denominator ----------------------------------------
|
|
|
|
|
|
def test_the_log_carries_images_found_and_carried(tmp_path: Path) -> None:
|
|
pytest.importorskip("pdfplumber")
|
|
pytest.importorskip("llm_ingestion_guard")
|
|
bundle = tmp_path / "bundle"
|
|
assert _build(_inbox(tmp_path), bundle) == 0
|
|
log = (bundle / corpus.LOG_NAME).read_text("utf-8")
|
|
assert "**Images**:" in log
|
|
assert " of " in log.split("**Images**:", 1)[1].splitlines()[0]
|
|
|
|
|
|
def test_the_log_names_the_capability_as_off_when_it_is(tmp_path: Path) -> None:
|
|
"""`--no-assets` is stated, never implied by a missing line.
|
|
|
|
The same rule `--gate none` follows: a bundle built without screening says
|
|
`NOTHING WAS SCREENED` rather than leaving a reader to infer it from an
|
|
absent bullet. A bundle whose figures were never looked for must not read
|
|
as a bundle of documents that had none.
|
|
"""
|
|
pytest.importorskip("llm_ingestion_guard")
|
|
inbox = tmp_path / "inbox"
|
|
inbox.mkdir()
|
|
(inbox / "ren.md").write_text("# Kostnader\n\nIngen figurer.\n", encoding="utf-8")
|
|
bundle = tmp_path / "bundle"
|
|
assert _build(inbox, bundle, "--no-assets") == 0
|
|
log = (bundle / corpus.LOG_NAME).read_text("utf-8")
|
|
assert "NOT CARRIED" in log
|