llm-ingestion-okf/src/llm_ingestion_okf/errors.py
Kjell Tore Guttormsen b0b5e71658
feat(assets): every carried image is one a model can be shown
Chosen: a stdlib BMP reader, because `read_image` is on the CORE path and an
asset's name is its content digest. Measured first, as the order requires:
Pillow 12.3.0 IS in this tree (transitively under `pdfplumber`) and it DOES
decode RLE8 correctly -- a hand-written stdlib decoder and Pillow agree on
19 of 19 of R761's real files, RGB per pixel. So the choice does not rest on
capability. It rests on two properties of this package: `.html` and `.xml`
carry images with no `[extract]` extra installed, so a Pillow converter
either makes a core path depend on an optional binary wheel or buys the
second runtime dependency; and encoding through an installed library would
make a bundle's identity move with that library's version, which is the
property 0.10.0 felled page rasterisation over and `encode_png`'s docstring
already defends. Pillow keeps the job it is good for: the INDEPENDENT decoder
in the tests, on neither side of the conversion.

The defect, measured over the frozen R761 delivery's `assets/`, denominator
50: 29 JPEG, 2 PNG and 19 RLE8 BMP. The 19 are byte-correct files nothing
reads, so 19 figures were present and invisible while `images: N` reported
that they had arrived.

- `VIEWABLE_MEDIA_TYPES` is tested against every asset's SNIFFED type, so it
  is a property and not a list of formats we met. WebP is on it and `sniff`
  does not recognise one; the limit is stated, not implied.
- `bmp_to_png`: 8-bit uncompressed, 8-bit RLE8, 24-bit uncompressed. All five
  RLE8 opcodes. 19 of 19 real files convert with RGB identical to Pillow's
  decoding of the source, 2 366 365 pixels compared.
- `asset_not_viewable` and `asset_bmp_unsupported`, both published, both
  leaving the concept's "not carried" line.
- Traceability on the pointer's second line, where the rest of the asset
  metadata already lives: original media type, original sha256 in full, new
  sha256 in full. A converted asset is ONE asset.
- The ceiling is paid on the DECLARATION before a row is allocated, and an
  RLE run is one clipped slice -- painting pixel by pixel leaves the memory
  bounded and the CPU unbounded.

Two repairs the change forced, each measured rather than assumed:

- `tests/test_assets.py`'s "dimensions absent is absent" used a TIFF, which
  is now refused before `read_image` returns. The property still has a
  reachable case -- a JPEG whose frame header never arrives -- and uses it.
- `asset_holds` in the accounting gate proved a carry by hashing the SOURCE
  file, which a converted image's bundle cannot satisfy. It now also reads
  the two digests the bundle states and HASHES THE ASSET ITSELF, so a bundle
  claiming a conversion it did not perform still fails.

`tools/okf_asset_census.py` is the committed instrument for the
known-positive: one row per image, from two pinned trees. It was caught by
the rule it serves -- its first version handed `_pdf_images` the wrong page
object and reported 0 images over 67 PDFs with exit 0. The attribute is
asserted now and a known-positive runs before the sweep.

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

293 lines
16 KiB
Python

"""Typed error hierarchy rooted in IngestError.
STABILITY CONTRACT: `IngestError.code` is the machine-readable API for
distinguishing sub-causes — consumers assert on it, never on message
wording. Codes listed in the class docstrings below are stable across
releases; message text is NOT stable and may improve freely.
"""
from __future__ import annotations
class IngestError(Exception):
"""Base class for every error raised by this library.
Carries a stable, machine-readable `code` naming the sub-cause (see the
subclass docstrings for the registry). Errors constructed without an
explicit code carry `"unspecified"`.
"""
def __init__(self, message: str, *, code: str = "unspecified") -> None:
super().__init__(message)
self.code = code
class ManifestError(IngestError):
"""The manifest failed fail-fast schema validation (ingest-spec §4).
Codes:
- `manifest_unreadable` — the manifest file cannot be read
- `manifest_invalid_json` — the bytes are not valid UTF-8 JSON
- `manifest_version_unsupported` — manifest_version is not the integer 1
- `manifest_schema` — a generic shape violation (wrong type, missing or
unknown field, empty string, bad identifier, multi-line title,
non-positive max_rows, empty extractions list)
- `source_type_unknown` — source.type is not 'file', 'sql', or 'http'
- `credential_embedded` — source.base_url embeds userinfo credentials
- `extraction_id_duplicate` — two extractions share an id
- `okf_type_reserved` — an extraction claims the reserved 'verdict' layer
"""
class RenderError(IngestError):
"""A value cannot be rendered under the §5 body rules (never silent coercion).
Codes:
- `unsupported_cell_type` — a SQL cell is not integer/float/text/NULL
"""
class SourceError(IngestError):
"""An extraction failed against its source (ingest-spec §4, §8).
Covers fail-closed path-boundary violations, missing/malformed source
content, and max_rows cap violations — always typed, never a leaked
OSError and never silent truncation.
Codes:
- `path_escape` — a path resolves outside its root directory, or cannot
be resolved at all (e.g. an embedded NUL byte)
- `source_root_missing` — the file-source root is not a directory
- `source_file_missing` — the query does not resolve to a file
- `csv_no_header` — the CSV has no header row
- `csv_ragged_row` — a CSV row's cell count differs from the header's
- `max_rows_exceeded` — an extraction exceeds max_rows (any source type)
- `connection_ref_unset` — the sql connection_ref env var is not set
- `database_missing` — the sql connection_ref points at a missing file
- `sql_no_columns` — the sql statement produced no result columns
- `sql_failed` — the sql statement failed at the database
- `credential_ref_unset` — the http credential_ref env var is not set
- `http_transport` — the http GET failed at transport or decode
- `fence_marker_in_body` — an http body line is a code-fence marker
"""
class ExtractionError(IngestError):
"""A dropped file could not be converted to text (Door B, Phase 2).
File-type -> text extraction is text-only plumbing; a corrupt file, an
unknown type, or a binary type without its optional parser is a typed
per-file failure — never a silent skip, never a leaked stdlib error, and
never a bundled parser in core.
Codes:
- `extractor_unknown` — no extractor is registered for the file extension
- `extractor_extra_missing` — a `[extract]`-gated binary type (pdf/docx/
xlsx/pptx/odt/rtf) was given but the optional extra is not installed
- `extractor_decode_error` — a text-type file's bytes are not valid UTF-8
- `extractor_empty_csv` — a CSV has no header row
- `extractor_empty_pdf` — a PDF yielded no text on any page (a scanned or
image-only document); refused rather than persisted as an empty concept,
which would be the silent skip this registry exists to prevent
- `extractor_pdf_error` — the PDF parser failed on the file's bytes; the
third-party exception is wrapped, never leaked
- `extractor_binary_missing` — the converter binary is absent; distinct
from the extra not being installed, because the wheel can be present
while the binary it should carry is not
- `extractor_binary_version` — the converter binary is present but is not
the pinned version; refused rather than used, because extraction is
deterministic only within one converter version and a byte-pinned
fixture cannot tell "different version" from "defect"
- `extractor_convert_error` — the converter failed on this file's bytes;
the third-party failure is wrapped, never leaked
- `extractor_empty_conversion` — the converter returned no text; refused
rather than persisted as an empty concept, for the same reason as
`extractor_empty_pdf`
- `extractor_ocr_group_missing` — a PDF page was to be read with OCR but
the optional `ocr` group is not installed. DISTINCT from
`extractor_extra_missing`: the `[extract]` extra can be fully installed
and the document parsed, with only the OCR engine absent, and one code
for both would send an operator to reinstall what they already have
ASSET codes (0.10.0). None of these fails a document: an image a reader
could not carry is a ROW in the run log and a line in the concept saying
what was there, because one unreadable picture must not cost the three
thousand concepts of text around it.
- `asset_type_unknown` — the bytes behind a pointer are not an image format
this package recognises. Sniffed from the bytes, never from the claimed
extension: a `.jpg` that is really a PNG would otherwise be written under
a name whose extension lies
- `asset_unresolved` — the file a document points at was not found beside
it, or an inline data URI could not be decoded. Containment is the
document's own directory, so a reference above it lands here rather than
being followed
- `asset_remote` — the source is off this machine. Extraction opens no
socket: network access is an explicit per-run opt-in and extraction is not
on that path, so a remote image is carried as a pointer and never as bytes
- `asset_pdf_unsupported` — a PDF image whose samples this encoder does not
express: a stencil mask, a `Decode` array, a colour space with no exact
PNG form, a soft mask that cannot be carried, or anything but 8-bit
samples. Refused rather than approximated, because a picture that is
plausibly the wrong colour is wrong in a way no consumer can detect
- `asset_samples_invalid` — the sample buffer does not fit the dimensions
the image dictionary declares. Refused rather than padded: a short buffer
means the dictionary was read wrong
- `asset_too_large` — the picture is over this package's bound: because it
DECLARES a size beyond `MAX_IMAGE_PIXELS`, because the file itself is
that large, because the stream behind it DECOMPRESSES to more than
`MAX_IMAGE_BYTES`, or because one link of its filter chain would COST
more than `MAX_FILTER_DECODE_BYTES` to decode. The four are one code
because they are one decision — this run will not hold that picture —
and because a consumer counting refusals wants the picture, not the
mechanism. Each bound is read off the corpora and sits an order of
magnitude above anything measured
- `asset_size_invalid` — the container declares a size that is not a size:
a zero or negative `/Width` or `/Height`. DISTINCT from
`asset_too_large`, because the two say different things about the
document — one is a legitimate publisher shipping a picture bigger than
this package carries, the other is a dictionary written wrong or written
to be read wrong — and counting them together would make a corpus
statistic about the first untrue. Refused before the stream is read: a
negative dimension multiplies to a negative pixel count, under which
every bound reads as satisfied
- `asset_not_viewable` — the bytes are a real image in a format no model
can be SHOWN (TIFF, JPEG 2000), and this package has no lossless
conversion for it. DISTINCT from `asset_type_unknown`, which says the
bytes are not an image at all: this one says they are, and carrying them
would put a file in the bundle that the `images: N` count reports as
arrived and nothing downstream can read. Measured 2026-09-19 on the
frozen R761 delivery: 19 of its 50 assets were carried in exactly that
condition, as RLE8 BMP
- `asset_bmp_unsupported` — a BMP variant this reader does not express
(RLE4, BITFIELDS, 16- or 32-bit samples, a 12-byte BITMAPCOREHEADER, a
palette over 256 entries). DISTINCT from `asset_not_viewable`, which
says there is no conversion route for the format at all: this one says
there is one and this file is outside it, which is a different fact
about the document and a different thing to go and fix
- `asset_pdf_unbounded` — the image is reached through a PDF stream filter
this package has no measured cost ratio for (`LZWDecode`,
`RunLengthDecode`, `CCITTFaxDecode`, `/Crypt`, anything unknown), or
through an encrypted stream it cannot decipher. DISTINCT from
`asset_too_large`, which says a measurement was taken or predicted and
came out over the bound: this one says neither was possible, so the
picture is refused UNREAD rather than decoded to find out what it costs.
Measured 2026-09-18: bounding only the first link of a filter chain let
1 636 bytes of PDF cost 886 554 624 bytes of peak RSS, and bounding
every link's OUTPUT still let 33 475 bytes cost 3 261 599 744 through a
filter whose decoder holds a hundred bytes per byte of input
"""
class ExtractionWarning(UserWarning):
"""Extraction succeeded, but the output is lossy in a way worth stating.
Text extraction recovers text. Anything a PDF *draws* — figures, diagrams,
images — has no text to recover, so a bundle built from drawn documents is
incomplete by construction. That is categorically true rather than
document-specific, so it is warned about rather than detected: deciding
"is there a figure on this page" is a layout heuristic this library does
not own. A named class so a consumer can filter it deliberately.
"""
class MaterializationError(IngestError):
"""Materialization refused or failed (ingest-spec §5).
Codes:
- `ingested_at_invalid` — ingested_at is not ISO-8601 UTC with a Z suffix
- `collision_unstamped` — the §3 collision gate: a generated name is
occupied by a file without the ingest stamp
- `asset_collision` — two different images reduce to one asset name in one
run, or a name in `assets/` is occupied by different bytes (Doors B and
C, 0.10.0). An asset name carries the digest of its own contents, so this
is a `sha256[:12]` collision; refused rather than resolved, because
resolving it silently means one of two pictures is lost and every pointer
to it shows the other
- `source_reference_unquotable` — a manifest source's id or locator
contains a character that would restructure the `sources` flow mapping
(Door A, v0.2 profiles); refused rather than emitted, because the
resulting document parses cleanly into a record no one wrote
- `sources_empty` — a `sources` list with no entries; `sources: []` reads
as a measured absence when it is the absence of a measurement
- `inbox_slug_empty` — a dropped file's name reduces to an empty slug
under the id grammar (Door B; never an invented fallback name)
- `inbox_slug_too_long` — the generated inbox filename would exceed the
255-byte filesystem limit (Door B; never a truncated name, which would
be lossy and could collide with another long name sharing its prefix)
- `inbox_slug_collision` — two files dropped in the same run reduce to one
generated filename (Door B); both are refused rather than letting
iteration order decide which one survives
- `inbox_title_invalid` — an inbox title is multi-line or contains `[`/`]`,
either of which would break frontmatter or an index link
- `inbox_source_file_invalid` — an inbox `source_file` is multi-line and
would inject frontmatter lines
- `unknown_renderer` — a profile names a per-suffix renderer that is not
registered; refused rather than falling back to identity, which would
produce a bundle that looks rendered and is not
- `okf_type_reserved` — an inbox concept claims the reserved 'verdict'
layer (the same reservation ManifestError enforces at Door A)
- `import_path_empty` — an external concept path reduces to an empty slug
under the id grammar (Door C; never an invented fallback name)
- `import_path_too_long` — the generated import filename would exceed the
255-byte filesystem limit (Door C; never a truncated name)
- `import_slug_collision` — two concepts in one external bundle reduce to
one generated filename (Door C); both are refused rather than letting
iteration order decide which one survives
- `import_label_invalid` — an external concept path contains `[`/`]`, which
would break its index link (the guard's path gate permits them)
- `import_provenance_invalid` — an `origin`/`channel` outside the guard's
pinned vocabulary (Door C); refused rather than carried, because the
guard derives trust from `origin` by enum identity and an unrecognised
value would be silently downgraded
"""
class SegmentationError(IngestError):
"""A segmentation plan is unusable, or does not apply (Door B, 1-to-N).
A concept is "a single unit of knowledge within a bundle" (OKF v0.2 §2),
not a file someone dropped, so splitting one document into several is a
judgement. A judgement cannot be made on a run path that promises zero
model calls, so it is made once, written down as data, adjudicated by a
human, and replayed deterministically. Every failure here is that replay
refusing to guess: a plan that no longer matches its extraction is never
silently re-derived, because the offsets it carries would then point into
text no one adjudicated.
Codes:
- `segmentation_plan_invalid` — the plan's shape is wrong: a missing or
wrongly-typed field, an empty entry list, or a `parent_id` naming no
entry in the same plan
- `segmentation_path_invalid` — an entry's path is not a bundle-relative
`/`-separated path (absolute, empty, or containing `.`/`..`), a
component reduces to nothing under the id grammar, or two entries claim
one path after normalisation
- `segmentation_span_invalid` — a span is not a half-open pair of
non-negative offsets with `start < end`, or it reaches past the end of
the canonical extracted text
- `segmentation_duplicate_id` — two entries share a `segment_id`
- `segmentation_extractor_mismatch` — the plan was adjudicated against a
different extraction. Source bytes cannot see an extractor swap or a
version bump, so the hash alone would still match while every stored
offset had silently moved
- `segmentation_unsupported_profile` — a plan was passed to a profile that
does not declare the segmentation capability
- `segmentation_plan_unmatched` — the plan is well-formed but its
`source_sha256` matches no dropped file, so nothing would be segmented
and the run would report an ordinary success over a flat bundle. A
mistyped hash is the likely cause and it is unreadable from the result;
refusing is the only way the operator learns that the judgement they
adjudicated was never replayed
"""
class NetworkGateError(IngestError):
"""A network source was used without the per-run opt-in flag (spec §8).
Local-only default, no silent egress: the flag is a run argument — the
manifest cannot grant itself network access.
Codes:
- `network_opt_in_missing` — an http source without allow_network=True
"""