feat(profiles): a profile may name a per-suffix renderer

Arm E, capability only. A profile MAY name a renderer per suffix; no
domain-aware renderer is written here, that stays a Non-Goal, and `_RENDERERS`
is empty on purpose so the emptiness reads as a decision rather than an
omission.

THE LAYERING IS THE DESIGN, not an implementation detail. `extract.py` is the
extraction registry and must not import the contract layer, or the dependency
runs backwards and the registry stops standing on its own. So `extract_text`
gains a keyword-only `renderer: Callable[[str], str] | None`, knowing nothing
about profiles, and `inbox.py` -- which already holds the profile at that call
site -- resolves a NAME to a function. A test asserts extract.py still contains
no reference to the profile layer, because that constraint is the whole reason
the parameter is shaped this way.

The renderer runs AFTER extraction, never instead of it, so it never has to
re-implement a reader and the two cannot drift. The default is identity, which
is what keeps the five byte-pinned goldens byte-pinned -- asserted per suffix
rather than once.

An unknown renderer NAME is refused rather than falling back to identity: a
silent fallback would produce a bundle that looks rendered and is not, which is
the failure mode this arm exists to make visible. That needed a registered code
(`unknown_renderer`) and its test -- slightly beyond the step's named files,
but the capability cannot ship without defining what an unknown name does.

`tests/test_profile.py`'s exact-field-set assertion went red, as the plan's risk
table predicted. Updated deliberately with the reason recorded: that assertion
exists so a field cannot arrive without someone deciding it should, and its red
run is the mechanism working.

Suite 917 -> 926. All five goldens byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-02 14:19:18 +02:00
commit 55a09d6c8e
7 changed files with 213 additions and 3 deletions

View file

@ -141,6 +141,9 @@ class MaterializationError(IngestError):
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

View file

@ -331,7 +331,9 @@ _OPTIONAL_EXTRACTORS: dict[str, Callable[[bytes], str]] = {
}
def extract_text(filename: str, data: bytes) -> str:
def extract_text(
filename: str, data: bytes, *, renderer: Callable[[str], str] | None = None
) -> str:
"""Convert one dropped file's bytes to OKF concept text, dispatched by type.
`filename` supplies the extension (case-insensitive); `data` is the raw
@ -339,11 +341,24 @@ def extract_text(filename: str, data: bytes) -> str:
without the extra, and any unregistered extension, fail fast with a typed
:class:`ExtractionError`. Extracting a `pdf` also emits an
:class:`ExtractionWarning`: drawn content has no text to recover.
`renderer`, when given, is applied to the EXTRACTED TEXT before it is
returned -- after extraction, never instead of it, so a renderer never has
to re-implement a reader and the two cannot drift. It is a plain callable
rather than anything profile-shaped ON PURPOSE: this module is the
extraction registry and must not import the contract layer, or the
dependency would run backwards and the registry would stop standing on its
own. Resolving a profile's NAMED renderer to a function is the caller's
job, in the layer that already holds the profile.
The default is identity, which is what keeps every existing byte-pinned
golden byte-pinned.
"""
suffix = Path(filename).suffix.lower()
extractor = _CORE_EXTRACTORS.get(suffix) or _OPTIONAL_EXTRACTORS.get(suffix)
if extractor is not None:
return extractor(data)
text = extractor(data)
return renderer(text) if renderer is not None else text
if suffix in _UNPARSED_OPTIONAL_EXTENSIONS:
raise _extra_missing(suffix)
raise ExtractionError(

View file

@ -465,6 +465,44 @@ def _render_segments(
return None
# Arm E's resolution half. It lives HERE rather than in `extract.py` because
# this is the layer that already holds the profile -- extraction takes a plain
# callable and never learns what a profile is, which keeps the dependency
# running from the contract layer down to the registry and not back up.
#
# A profile that names no renderers, or names none for this suffix, yields
# `None`, and `extract_text`'s default is identity. That is what keeps the five
# byte-pinned goldens byte-pinned while the capability exists.
#
# The registry is EMPTY on purpose: this step delivers the capability, not a
# renderer. Writing a domain-aware renderer is a Non-Goal, and it is named as
# unassigned here so the emptiness reads as a decision rather than an omission.
_RENDERERS: dict[str, Callable[[str], str]] = {}
def _resolve_renderer(profile: BundleProfile, filename: str) -> Callable[[str], str] | None:
"""Map a profile's named renderer for this suffix to a function, or `None`.
An unknown NAME is an error rather than a silent fallback to identity: a
profile naming a renderer that does not exist would otherwise produce a
bundle that looks rendered and is not, which is the failure mode this whole
arm exists to make visible.
"""
if profile.renderers is None:
return None
name = profile.renderers.get(Path(filename).suffix.lower())
if name is None:
return None
try:
return _RENDERERS[name]
except KeyError as exc:
raise MaterializationError(
f"the profile names renderer {name!r}, which is not registered; "
f"known renderers: {sorted(_RENDERERS)}",
code="unknown_renderer",
) from exc
def process_inbox(
inbox_dir: Path,
bundle_dir: Path,
@ -668,7 +706,9 @@ def process_inbox(
continue
outputs: list[tuple[str, str, tuple[str, ...]]] = []
try:
text = extract_text(path.name, source_bytes)
text = extract_text(
path.name, source_bytes, renderer=_resolve_renderer(profile, path.name)
)
covering = _plan_covering(segmentation, source_bytes)
if covering is not None:
blocked = _render_segments(

View file

@ -908,6 +908,13 @@ class BundleProfile:
# off" as a setting — it is the profile not having the capability at all,
# which is what the downstream `is not None` checks read.
segmentation: SegmentationPolicy | None = None
# Arm E, capability only: a profile MAY name a renderer per suffix, applied
# to extracted text before it becomes a concept body. `None` reads the same
# way `segmentation` does -- the profile does not have the capability, not
# "the capability is switched off". No domain-aware renderer exists in this
# package; writing one is a Non-Goal and is named here as unassigned so the
# absence is deliberate rather than an oversight.
renderers: Mapping[str, str] | None = None
# The ingest-spec + Phase 2 contract. Every value here was a constant in