llm-ingestion-okf/src/llm_ingestion_okf/inbox.py
Kjell Tore Guttormsen 1c958ab8d6
fix(accounting,gate): the conversion claim comes from the run's ledger
Chose the side channel over neutralising pointer-shaped document text,
because the second fix changes what every document SAYS in order to
defend a tool outside the build: a source quoting a bundle listing would
come out altered and existing bundles would move bytes. This reads a
file the run already writes.

`assets.conversion` names the pair, `DocumentAssets.conversions` carries
it out of the run, `DocumentAccount.conversions` books it, and the
accounting JSON states it per document. `_declared_conversions` reads it;
`_conversions` now believes a pair only when the RUN booked it AND a
pointer block confirms it for the asset it names. The confirmation can be
forged and the ledger cannot, which is why the ledger decides.

Measured through the real `okf build`: the three arms PM reproduced
(two `<p>`, one `<p>` with `<br>`, a markdown note beside the carrier)
go forged -> refused, 3 of 3, with the known-positive True in all three.
The text-level regression guard goes 3 arms to 13, the two new ones being
a perfectly written pointer block the run never booked.

R761, rebuilt: 25 BMP sources, 19 held, 19 of 19 conversions confirmed
against 19 declared, 50 assets (29 JPEG + 21 PNG, 0 BMP), SHY 71,
u = 0, d = 0, exit 0.

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

1869 lines
84 KiB
Python

"""Door B: the bundle inbox — provenance rendering, slugging, and the flow.
Pure functions on top of the Phase 1 primitives: a dropped file's name is
reduced to the Phase 1 id grammar and namespaced `inbox-{slug}.md` — disjoint
from `index.md`, Door A's `ingest-*`, and `promoted-verdict-*` — and the
concept body is framed with the §7-analogous honesty marker (`type`, `title`,
`source_file`, `source_sha256`, `ingested_at`, `generated: true`).
`source_sha256` is taken over the ORIGINAL dropped bytes, never over the
extracted text, so provenance stays re-verifiable against the operator's file.
`ingested_at` is explicit and validated by the same rule as Door A: no
wall-clock anywhere. Output is LF-only with exactly one trailing newline.
`process_inbox` is the flow: per file, extracted text goes through the guard
gate before anything is written, and only the gate's non-blocking floor
persists. No model call anywhere, and no security decision here — the gate
supplies the verdict and this module only obeys it.
"""
from __future__ import annotations
import hashlib
import os
import posixpath
import re
import unicodedata
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, replace
from pathlib import Path, PurePosixPath
from .assets import (
ASSETS_DIR,
IMAGE_POINTER,
AssetRejection,
ExtractedImage,
asset_name,
conversion,
)
from .connectors import safe_resolve
from .errors import IngestError, MaterializationError, SegmentationError, SourceError
from .extract import (
DeclaredIdentity,
SourceUnits,
declared_identity,
directory_resolver,
extract_document,
source_units,
)
from .materialize import (
_render_root_frontmatter,
check_filename_length,
link_in_index,
parse_frontmatter,
reduce_to_id_grammar,
validate_ingested_at,
write_bytes,
)
from .profiles import (
ASSET_COUNT_KEY,
DEFAULT,
BundleProfile,
IndexEntry,
ProvenancePolicy,
yaml_block_plain,
yaml_flow_collection,
yaml_flow_collection_plain,
yaml_flow_plain,
)
from .segmentation import (
SegmentationPlan,
SegmentEntry,
assert_plan_applies,
heading_only,
observed_extractor_version,
slice_segments,
)
from .structure import (
DocumentStructure,
_render_flow_list,
derive_document_structure,
facet_values,
resolve_structure,
structure_from_frontmatter,
structure_frontmatter,
)
def inbox_slug(source_filename: str) -> str:
"""Reduce a dropped file's name to the Phase 1 id grammar.
The final extension is dropped, the rest is lowercased, and every run of
non-grammar characters collapses to a single `-` (stripped at both ends).
A name that reduces to nothing fails fast: never an invented fallback like
`untitled`, which would silently collide across unrelated files.
"""
slug = reduce_to_id_grammar(Path(source_filename).stem)
if not slug:
raise MaterializationError(
f"inbox filename {source_filename!r} reduces to an empty slug under the "
"id grammar ([a-z0-9][a-z0-9-]*) — refusing to invent a filename",
code="inbox_slug_empty",
)
return slug
def inbox_filename(slug: str, *, profile: BundleProfile = DEFAULT) -> str:
"""The concept filename for an inbox file.
The `inbox-` prefix keeps the namespace disjoint from `index.md`, Door A's
`ingest-*`, and `promoted-verdict-*` for every slug the grammar admits.
A name the filesystem cannot hold fails fast rather than being truncated
(see :func:`check_filename_length`). Refusing keeps the same posture as
`inbox_slug_empty` — the library never invents a filename the operator did
not give it.
"""
return check_filename_length(
f"{profile.paths.inbox_prefix}{slug}{profile.paths.concept_suffix}",
code="inbox_slug_too_long",
)
#: The words around the one link a heading-only body gains. SPEC SS 6.1: the
#: kind of relationship "is conveyed by the surrounding prose, not by the link
#: itself", so the line names the relation, in two words, because generic code
#: writes them into a body in the source's own language.
ENCLOSING_SECTION = "Enclosing section"
def _link_enclosing(body: str, parent: SegmentEntry, gate: Gate) -> str:
"""`body` plus one line linking the section that encloses it.
SPEC SS 5.1: "Lineage is expressed through links, not a dedicated field";
the `parent:` key stays beside it as a SS 4.1 extension. Bundle-relative
and absolute, SS 6.1's "recommended form", because the parent sits in
another directory and a relative link would count `..` across a layout the
next round may change.
The line carries the parent's title, which is document text, and joins a
body the gate has already judged -- so it is screened on its own, the rule
`_screened` applies to a `description`, and dropped rather than refused
when the gate would not persist it.
"""
line = _screened(gate, f"{ENCLOSING_SECTION}: [{parent.title}](/{parent.path})")
if line is None:
return body
return f"{body.rstrip(chr(10))}\n\n{line}\n"
def _normalize_body(text: str) -> str:
# LF-only with exactly one trailing newline is a byte-level guarantee, and
# dropped files legitimately arrive with CRLF — normalising is the
# deterministic answer here, where Door A can validate instead because it
# renders its own bodies.
return text.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n") + "\n"
def render_inbox_concept(
text: str,
*,
okf_type: str,
title: str,
source_file: str,
source_bytes: bytes,
ingested_at: str,
profile: BundleProfile = DEFAULT,
structure: DocumentStructure | None = None,
segment: SegmentEntry | None = None,
bundle_id: str | None = None,
units: SourceUnits | None = None,
span: tuple[int, int] | None = None,
source_title: str | None = None,
concept_frontmatter_values: Mapping[str, str] | None = None,
) -> str:
"""Frame extracted text as an inbox concept file with its provenance layer.
`source_bytes` are the ORIGINAL dropped bytes — hashed here so the marker
cannot drift onto the extracted text. Fail-fast on an invalid
`ingested_at`, on the reserved verdict layer, and on a title or
`source_file` that would break an index link or inject frontmatter lines.
`units` and `span` carry the provenance locator and are read ONLY when the
profile declares that capability. `span` defaults to the segment's own when
the concept is segmented; a whole-document concept must supply it, because
the text arriving here is the SANITIZED text and its length is not
necessarily the extracted text's.
`source_title` is what the document calls itself, for the `sources`
entry's `title`; `None` keeps the file name there, as before it existed.
`concept_frontmatter_values` are keys the CALLER states for every concept
of a run, validated by :func:`validate_concept_frontmatter` and applied
LAST, so a stated `sources` or `description` replaces the derived one.
`segment` and `bundle_id` carry the 1-to-N identity layer and are read ONLY
when the profile declares the segmentation capability. A concept the plan
does not cover keeps today's rule verbatim, and the four shipped profiles
emit the bytes they always did — `emit` sorts unnamed keys into the tail, so
a key added unconditionally here would churn every golden.
"""
segmented = profile.segmentation is not None and segment is not None
if segmented:
assert segment is not None
# The PLAN's timestamp, never the call's. A plan replays an
# adjudication, so a rebuild months later has to reproduce the bytes of
# the round that first wrote the concept -- a call-level `ingested_at`
# would make every rebuild differ from every incremental update, which
# is precisely the invariant S7 exists to hold.
ingested_at = segment.ingested_at
validate_ingested_at(ingested_at)
# The verdict layer is RESERVED: the promotion gate is the only path into
# it, at this door exactly as at Door A's manifest validation — the same
# profile decides, each door raises its own typed error.
rejection = profile.types.rejection(okf_type)
if rejection is not None:
raise MaterializationError(f"okf_type {rejection.reason}", code=rejection.code)
# The title is rendered verbatim into `- [title](target)` and into
# line-oriented frontmatter — met by fail-fast validation, never repair.
if any(char in title for char in "\n\r[]"):
raise MaterializationError(
f"title must be single-line and must not contain '[' or ']', got {title!r}",
code="inbox_title_invalid",
)
if "\n" in source_file or "\r" in source_file:
raise MaterializationError(
f"source_file must be single-line, got {source_file!r}",
code="inbox_source_file_invalid",
)
# HOW MANY ASSET POINTERS THIS CONCEPT CARRIES, counted out of the concept's
# own text rather than threaded down from the extraction. Two reasons, and
# the second is the load-bearing one: a segmented document's images belong
# to the segments whose spans hold them, so a document-level total would be
# written onto every segment and be wrong on all but one of them; and a
# count a reader can verify from the file in front of them is a different
# kind of fact from a count only the producer could have known.
#
# Named on this repository's own profiles only, so a bundle written under
# `DEFAULT` or `STRICT_V1` keeps exactly the key set its contract names.
assets_carried = (
len(IMAGE_POINTER.findall(text)) if ASSET_COUNT_KEY in profile.frontmatter.order else 0
)
frontmatter = {
"type": okf_type,
"title": title,
"source_file": source_file,
"source_sha256": hashlib.sha256(source_bytes).hexdigest(),
"ingested_at": ingested_at,
# The literal stamp, NOT `profile.ownership.stamp(...)`. Door B has
# written `true` since Phase 2 and `_is_inbox_owned` reads it back;
# routing it through the profile would move DEFAULT's bytes to the O2
# mapping and orphan every bundle this door has already written. Which
# stamp Door B should write is a separate question from this order's,
# and answering it here would have answered it silently.
"generated": "true",
}
if structure is not None and profile.index.facets is not None:
if segmented:
# A segment's title comes from the PLAN, so it is DECLARED by the
# adjudicator -- but derivation runs over the segment BODY, finds
# no title key and no usable heading, falls back, and marks it
# derived. The concept then emits a stated fact under an inferred
# marker, and a consumer that distrusts derived fields distrusts
# exactly the thing a human decided. Scoped to `title` alone: every
# other field here really was inferred from the body, and blunting
# the marker would be the opposite defect.
structure = replace(structure, derived=structure.derived - {"title"})
frontmatter.update(structure_frontmatter(structure, profile.index.facets.keys))
if segmented:
assert segment is not None
policy = profile.segmentation
assert policy is not None
# AFTER the structure update, and that order is the rule rather than an
# accident: a plan's `parent_id` is DECLARED by the adjudicator, while
# `structure`'s `parent` is DERIVED from a document number. Declared
# beats derived, so the authored hierarchy wins over the inferred one.
if bundle_id is not None:
frontmatter[policy.bundle_id_key] = bundle_id
frontmatter[policy.segment_id_key] = segment.segment_id
frontmatter[policy.offset_key] = _render_flow_list([str(offset) for offset in segment.span])
if segment.parent_id is not None:
frontmatter["parent"] = segment.parent_id
if segment.description is not None and yaml_block_plain(segment.description):
# The SOURCE's words, carried by the plan, and written only where
# they read back verbatim. Absent is the source saying nothing, or
# saying it in a form this line cannot carry -- never a summary
# derived from the title, and never a cleaned-up one.
frontmatter["description"] = segment.description
if policy.adjudication_key is not None:
# The per-entry verdict IS the discriminator. A plan-level
# `adjudicated: true` with no record for THIS entry leaves it
# `proposed`: B2 forbids the ratified state without the three keys
# beside it, so a flag alone cannot promote a segment.
verdict = segment.adjudication
if verdict is None:
frontmatter[policy.adjudication_key] = ADJUDICATION_PROPOSED
else:
frontmatter[policy.adjudication_key] = ADJUDICATION_ADJUDICATED
frontmatter["adjudicated_by"] = verdict.adjudicated_by
frontmatter["adjudicated_at"] = verdict.adjudicated_at
frontmatter["adjudication_dwell_s"] = str(verdict.adjudication_dwell_s)
if profile.provenance is not None:
# A segment's own span is the default, but only where the segment is
# being READ -- `segmented` is the same discriminator the identity
# layer above uses, so a profile with provenance and no segmentation
# cannot silently locate by a span it is ignoring everywhere else.
located = span
if located is None and segmented:
assert segment is not None
located = segment.span
frontmatter.update(
_provenance_frontmatter(
profile.provenance,
source_file=source_file,
units=units,
span=located,
title=source_title,
)
)
if assets_carried:
# Conditional, like `req_number`: absent is "this concept carries no
# image", which is what every bundle written before 0.10.0 says, so a
# corpus without pictures is byte-identical across the move.
frontmatter[ASSET_COUNT_KEY] = str(assets_carried)
if concept_frontmatter_values:
# LAST, and the position is the precedence: a value the caller states
# for the run beats what the document declares, which beats the file
# name. Validation keeps it to the keys that have a derived layer to
# replace -- everything else this door writes is refused.
frontmatter.update(
validate_concept_frontmatter(concept_frontmatter_values, profile=profile)
)
return f"---\n{profile.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
#: The two keys a run may REPLACE rather than only add. Each has a layer below
#: the flag -- the document's own title or the file name for `sources`, the
#: document's own first spec point for `description` -- so stating one for the
#: run is choosing a layer, which is what the precedence exists for.
RUN_FRONTMATTER_OVERRIDES = frozenset({"sources", "description"})
# A key the line-oriented readers here recover exactly: no `:`, no space, no
# leading `-` that a YAML reader would take for a sequence entry.
_RUN_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
def _door_keys(profile: BundleProfile) -> frozenset[str]:
"""Every key this door writes itself under `profile`, less the overrides.
Refused as run values because each is something a run cannot restate
without lying: measured from the bytes (the hash, the offsets, the
locators), owned by another argument (`type`, `ingested_at`, the bundle
id), the ownership stamp a later run reads back (`generated`, and Door A's
`ingest_manifest`, which would make that door claim this one's file), or a
derived facet whose `derived` marker would go on naming a value the run had
replaced.
"""
keys = set(DEFAULT.frontmatter.order)
keys.update(("parent", "adjudicated_by", "adjudicated_at", "adjudication_dwell_s"))
if profile.index.facets is not None:
keys.update(profile.index.facets.keys)
if profile.segmentation is not None:
policy = profile.segmentation
keys.update((policy.bundle_id_key, policy.segment_id_key, policy.offset_key))
if policy.adjudication_key is not None:
keys.add(policy.adjudication_key)
if profile.provenance is not None:
address = profile.provenance
keys.update(
(
address.sources_key,
address.pages_key,
address.sheet_key,
address.rows_key,
address.lines_key,
)
)
return frozenset(keys - RUN_FRONTMATTER_OVERRIDES)
def validate_concept_frontmatter(
values: Mapping[str, str], *, profile: BundleProfile
) -> dict[str, str]:
"""Refuse a run-stated key or value that would not read back as stated.
SPEC SS 4.1 lets a producer add any key and SS 11 forbids a consumer to
reject one, so the limits here are this package's own and each is a
reader it has to survive: the value is written on ONE line because every
reader here is line-oriented, which rules out a line break and -- since
`parse_frontmatter` strips -- surrounding whitespace. A scalar goes out
plain where a YAML reader returns it verbatim and double-quoted otherwise;
a flow collection goes out as given, so every leaf in it must be one both a
YAML reader and the guard read back (K3-22).
"""
written = _door_keys(profile)
for key, value in values.items():
if not _RUN_KEY.match(key):
raise MaterializationError(
f"frontmatter key {key!r} is not a plain key ([A-Za-z_][A-Za-z0-9_-]*)",
code="run_frontmatter_invalid",
)
if key in written:
raise MaterializationError(
f"frontmatter key {key!r} is written by this door itself; a run may "
f"add keys and replace only {sorted(RUN_FRONTMATTER_OVERRIDES)}",
code="run_frontmatter_invalid",
)
if not value or value != value.strip() or "\n" in value or "\r" in value:
raise MaterializationError(
f"frontmatter value for {key!r} must be one non-empty line with no "
f"surrounding whitespace, got {value!r}",
code="run_frontmatter_invalid",
)
if yaml_flow_collection(value) and not yaml_flow_collection_plain(value):
raise MaterializationError(
f"frontmatter value for {key!r} is a flow collection a YAML reader and "
"the guard would not both read back as written: a leaf carrying `?`, a "
"quote, ': ', ' #', a trailing `:` or a leading indicator has no flow "
f"form both accept, got {value!r}",
code="run_frontmatter_invalid",
)
return dict(values)
def _provenance_frontmatter(
policy: ProvenancePolicy,
*,
source_file: str,
units: SourceUnits | None,
span: tuple[int, int] | None,
title: str | None = None,
) -> dict[str, str]:
"""The address, and the locator when one is available.
The address is written whether or not a locator is: `sources` answers
"which document", the locator answers "where in it", and a consumer is owed
the first even when the second cannot be computed.
"""
# Validation, never quoting: plain is the one form of a flow-mapping leaf
# that a YAML reader and the guard both read back verbatim -- the guard
# refuses any quote in a flow mapping (1.3.0, measured) -- so a value it
# cannot carry is refused rather than mangled (K3-22). The file name is
# checked too: it is the entry's `title` when the document declares none.
shown = title if title is not None else PurePosixPath(source_file).name
if not yaml_flow_plain(source_file) or (title is None and not yaml_flow_plain(shown)):
raise MaterializationError(
f"source_file {source_file!r} has no plain form in the `sources` flow "
"mapping that both a YAML reader and the guard read back verbatim; this "
"profile writes an address a consumer can follow, and a path it cannot "
"express is refused rather than mangled",
code="inbox_source_file_unaddressable",
)
if title is not None and not yaml_flow_plain(title):
raise MaterializationError(
f"source title {title!r} cannot be written into the `sources` flow "
"mapping verbatim; refused rather than mangled",
code="inbox_source_title_unaddressable",
)
values = {policy.sources_key: f"[{{ resource: {source_file}, title: {shown} }}]"}
if units is None or span is None:
return values
first, last = units.covering(*span)
if units.unit == "pages":
values[policy.pages_key] = _render_flow_list([str(first), str(last)])
elif units.unit == "rows":
scopes = units.scopes_covering(*span)
# A row number means nothing until a sheet is named, so a range that
# crosses sheets gets neither key. An absence, never a first-sheet
# guess: a guess here reads exactly like a fact.
if len(scopes) == 1 and scopes[0] is not None:
values[policy.sheet_key] = scopes[0]
values[policy.rows_key] = _render_flow_list([str(first), str(last)])
else:
values[policy.lines_key] = _render_flow_list([str(first), str(last)])
return values
def _screened(gate: Gate, value: str | None) -> str | None:
"""A value read from the DOCUMENT and persisted outside its screened body.
The body goes through the gate before anything is written; a frontmatter
value taken from the same bytes would otherwise be the one route around
it. Kept only on the gate's non-blocking floor, as the SANITIZED text, and
dropped rather than refused otherwise: the body carrying the same words is
judged on its own, and a document is never lost over an optional key.
"""
if value is None:
return None
decision = gate(value)
if decision.disposition != _DISPOSITION_PERSIST:
return None
return " ".join(decision.sanitized_text.split()) or None
def _declared_sources_title(identity: DeclaredIdentity | None, gate: Gate) -> str | None:
"""The `sources` title a document declares, or `None` for the file name.
`<doc-number>` + `<year>` first, then the `<title-wrap>` title: measured on
the one STS document this row has, the `<full>` title carries a COMMA,
which ends a flow mapping, and the guard refuses the quoted scalar that
could have carried it. A declared value that cannot be written verbatim
falls to the next layer -- never cleaned up, because a title with its comma
removed is a title the document does not carry.
"""
if identity is None:
return None
candidates: list[str] = []
if identity.doc_number is not None:
candidates.append(
f"{identity.doc_number} {identity.year}" if identity.year else identity.doc_number
)
if identity.title is not None:
candidates.append(identity.title)
for candidate in candidates:
kept = _screened(gate, candidate)
if kept is not None and yaml_flow_plain(kept):
return kept
return None
# --- the guard seam -------------------------------------------------------
# The guard's non-blocking floor. `Disposition` is a `str, Enum` in
# llm-ingestion-guard, so its VALUE is the stable thing to compare against
# across the pinned `>=1.2,<2.0` range. Pinned as a constant here rather than
# imported, because the dependency is injected (see `Gate`): the step-4
# adapter's signature smoke test is what catches a rename in the guard.
_DISPOSITION_PERSIST = "warn"
_DISPOSITION_QUARANTINE = "quarantine_review"
@dataclass(frozen=True)
class GateDecision:
"""One guard verdict, carried verbatim from `llm-ingestion-guard`.
`sanitized_text` is what the gate actually screened, and therefore the only
text that may be persisted — screening one string and writing another would
make the verdict a statement about bytes nobody kept. `disposition` is the
guard's `Disposition` VALUE and `reasons` its audit trail; neither is
interpreted here beyond the single persist/do-not-persist branch.
"""
sanitized_text: str
disposition: str
reasons: tuple[str, ...] = ()
# The persist gate, injected. The library never imports the guard directly:
# the caller supplies the adapter, which keeps this module dependency-free and
# lets the flow's branches be exercised deterministically by a test double.
Gate = Callable[[str], GateDecision]
# --- per-file outcomes ----------------------------------------------------
@dataclass(frozen=True)
class PersistedFile:
"""A dropped file that cleared the gate and was written."""
source_file: str
path: Path
reasons: tuple[str, ...] = ()
@dataclass(frozen=True)
class BlockedFile:
"""A dropped file the guard refused. Quarantine and rejection are reported
separately: quarantine means "hold for human review" and is the operator's
queue, while a fail-secure verdict is a decision, not a queue."""
source_file: str
disposition: str
reasons: tuple[str, ...]
@dataclass(frozen=True)
class FailedFile:
"""A dropped file this library could not process — extraction, filename or
collision. Always a typed error, never a leaked stdlib exception."""
source_file: str
error: IngestError
#: Why the walk refused to descend into a directory. A CODE rather than a log
#: line: a skipped directory is an outcome the caller has to be able to count,
#: and "we found nothing under here" is a measurement, not a fact about the
#: inbox.
SKIPPED_DOT_DIRECTORY = "skipped_dot_directory"
SKIPPED_BUNDLE_DIRECTORY = "skipped_bundle_directory"
@dataclass(frozen=True)
class SkippedPath:
"""A directory the recursive walk did not descend into, and why.
`path` is relative to the inbox root, `/`-separated, exactly like a
concept's `source_file`.
"""
path: str
code: str
@dataclass(frozen=True)
class InboxResult:
"""Every dropped file's outcome, in sorted filename order.
Four disjoint buckets, and every file lands in exactly one: a run's report
is complete by construction, so a file that silently vanished would show up
as a missing entry rather than as nothing at all.
"""
persisted: tuple[PersistedFile, ...]
quarantined: tuple[BlockedFile, ...]
rejected: tuple[BlockedFile, ...]
failed: tuple[FailedFile, ...]
# One entry per CONCEPT, where `persisted` is one per SOURCE FILE. A new
# field rather than a changed meaning: under 1-to-N the two counts diverge,
# and redefining `persisted` would silently change what every existing
# consumer's number means. Without segmentation the two are equal, which is
# what makes this additive rather than a second thing to keep in step.
concepts: tuple[PersistedFile, ...] = ()
# Directories the walk refused to enter, each with its code. Additive and
# last, so every existing positional construction and every existing
# consumer's four buckets keep their meaning: a skipped directory holds no
# dropped FILE outcome, it explains a set of files that were never dropped.
skipped: tuple[SkippedPath, ...] = ()
# THE ASSET DENOMINATOR (0.10.0). `assets` is what reached the bundle;
# `assets_rejected` is what was found and could not be. Both, or neither
# number means anything: "51 carried" is a measurement only beside "of 53
# found", and a run whose figures were all refused would otherwise look
# exactly like a run over documents that had none.
assets: tuple[str, ...] = ()
assets_rejected: tuple[AssetRejection, ...] = ()
# Inbox files whose bytes a PERSISTED document carried as an image, as
# inbox-relative paths. Such a file has one fate -- carried -- and is not
# also a coded rejection of the walk; the conservation identity counts it
# in its own column.
carried_files: tuple[str, ...] = ()
# Per persisted document: how many image placements were carried, and the
# ones that were found and not carried, with their codes. The content
# accounting books a document's images from this, never from the bundle.
document_assets: tuple[DocumentAssets, ...] = ()
# Per document the run READ: how many U+00AD the normalisation door
# removed before the persist gate saw the text. One entry per document
# that carried at least one, so a run over a corpus with none of them
# carries an empty tuple and says `0` rather than nothing.
normalised: tuple[DocumentNormalisation, ...] = ()
@dataclass(frozen=True)
class DocumentAssets:
"""One persisted document's image outcome.
`conversions` is the run's own list of `(source digest, asset digest)`
pairs for the images it REWROTE, in the order they were carried. The
bundle states the same pairs in prose on each pointer's second line; this
is the machine-readable side of the same fact, and the difference is who
wrote it -- a document can produce that prose and cannot produce this.
"""
source_file: str
carried: int
rejected: tuple[AssetRejection, ...]
conversions: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True)
class DocumentNormalisation:
"""What the normalisation door removed from one document.
Recorded where the removal HAPPENED rather than counted again off the
source afterwards: a second count would be a second reader, and the number
the accounting publishes has to be the number the run acted on.
"""
source_file: str
soft_hyphens: int
def relative_source(path: Path, inbox: Path) -> str:
"""A dropped file's name as the provenance layer records it.
Relative to the inbox root and `/`-separated, so the recorded name is the
same on every platform and two documents sharing a basename in different
folders stay distinguishable. For a flat inbox this is the bare filename,
which is why every existing bundle's bytes are unchanged.
"""
return path.relative_to(inbox).as_posix()
def walk_inbox(
inbox: Path, *, exclude: Path | None = None
) -> tuple[tuple[Path, ...], tuple[SkippedPath, ...]]:
"""Every dropped file at any depth, sorted by relative path, plus the skips.
ONE implementation, shared with the corpus harness: the corpus
measurement counts the denominator N, and a walk that disagreed with the
door's would report a count over a different set of files than the one that
was ingested.
Sorted on the whole relative path rather than the basename -- the order has
to be a function of the TREE, or two inboxes holding the same documents
would walk them differently and their indexes would diverge.
Two directories are refused, both with a code. A dot-directory is
machinery, not documents. `exclude` is the bundle: with a flat listing a
bundle nested inside the inbox was invisible by accident, and recursion
removes the accident -- without the skip the door would extract its own
concepts and ingest them as documents on the next run.
"""
excluded = exclude.resolve() if exclude is not None else None
dropped: list[Path] = []
skipped: list[SkippedPath] = []
for parent_name, dirnames, filenames in os.walk(inbox):
parent = Path(parent_name)
kept: list[str] = []
for name in sorted(dirnames):
child = parent / name
if name.startswith("."):
skipped.append(
SkippedPath(path=relative_source(child, inbox), code=SKIPPED_DOT_DIRECTORY)
)
elif excluded is not None and child.resolve() == excluded:
skipped.append(
SkippedPath(path=relative_source(child, inbox), code=SKIPPED_BUNDLE_DIRECTORY)
)
else:
kept.append(name)
# In place: this is how `os.walk` is told not to descend, and a skipped
# directory must not be entered at all rather than entered and filtered.
dirnames[:] = kept
for name in filenames:
child = parent / name
if child.is_file():
dropped.append(child)
dropped.sort(key=lambda item: relative_source(item, inbox))
skipped.sort(key=lambda item: item.path)
return (tuple(dropped), tuple(skipped))
def _is_inbox_owned(path: Path) -> bool:
# Door B's ownership marker: `generated: true` AND a `source_file`
# reference. Deliberately disjoint from Door A's test, which keys on
# `ingest_manifest` — a Door A concept is never this door's to replace,
# and curated content carries neither key.
frontmatter = parse_frontmatter(path)
return frontmatter.get("generated") == "true" and "source_file" in frontmatter
def _owned_concepts_by_source(bundle: Path, profile: BundleProfile) -> dict[str, set[str]]:
"""Every inbox-owned concept in the bundle, grouped by the `source_file` it came from.
The question ownership has to answer under 1-to-N. Keyed on the concept
FILENAME -- which is what it was -- a round that re-adjudicates a document
into fewer segments ORPHANS the ones it no longer names: they survive in the
incremental bundle, are absent from a scratch rebuild, and the two diverge
with nothing failing. Grouping by `source_file` is what makes "these are the
concepts this document currently owns" expressible at all.
Recursive, and reached only when the profile declares the capability: the
four shipped profiles keep flat scans, because switching the shared glob
would change their ownership behaviour and break their byte-stability pin.
"""
owned: dict[str, set[str]] = {}
if not bundle.is_dir():
return owned
for path in sorted(bundle.rglob(f"*{profile.paths.concept_suffix}")):
if not path.is_file() or path.name == profile.index.name:
continue
frontmatter = parse_frontmatter(path)
if frontmatter.get("generated") != "true":
continue
source_file = frontmatter.get("source_file")
if source_file is not None:
owned.setdefault(source_file, set()).add(path.relative_to(bundle).as_posix())
return owned
def _retire_stale_segments(bundle: Path, stale: set[str], profile: BundleProfile) -> None:
"""Delete concepts this round's plan no longer names, and prune what empties.
An emptied directory is deleted too, and so is the index left standing in
it. A scratch rebuild writes an index only where a concept lives, so an
orphaned one is exactly the kind of one-sided difference `diff -r` reports
as `Only in ...` -- measured 2026-09-01, when retiring a directory's last
concept left its index behind and S7 caught the divergence.
The emptiness test is "holds no concept anywhere BENEATH it", not "holds no
files": a directory whose own concepts are gone may still be an ancestor of
ones that remain, and deleting its index would break the walk from the root.
"""
for relative in sorted(stale):
(bundle / relative).unlink(missing_ok=True)
suffix = profile.paths.concept_suffix
# Deepest first, so a parent is judged only after its children are gone.
for relative in sorted(stale, key=lambda item: item.count("/"), reverse=True):
directory = (bundle / relative).parent
while directory != bundle and directory.is_dir():
if any(
path.is_file() and path.name != profile.index.name
for path in directory.rglob(f"*{suffix}")
):
break
(directory / profile.index.name).unlink(missing_ok=True)
if any(directory.iterdir()):
break
directory.rmdir()
directory = directory.parent
def _check_segment_path(path: str) -> str:
"""Refuse a segment path the filesystem cannot hold, measured PER COMPONENT.
`check_filename_length` measures one name against NAME_MAX, which is a
per-directory-entry limit. Measuring the JOINED path against it gets the
question backwards in both directions: a perfectly legal deep hierarchy
would be refused, and an illegal component inside a short path would be
accepted and then fail at the write with an errno that differs per platform
-- the untyped, unportable failure the length gate exists to replace.
"""
for component in path.split("/"):
check_filename_length(component, code="inbox_slug_too_long")
return path
def _bundle_id_key(profile: BundleProfile) -> str:
assert profile.segmentation is not None
return profile.segmentation.bundle_id_key
def _resolve_plans(
segmentation: SegmentationPlan | None,
segmentations: Mapping[str, SegmentationPlan] | None,
) -> tuple[SegmentationPlan, ...]:
"""The plans this run replays, from either call form, never from both.
The mapping is keyed by SOURCE FILENAME because that is the name an
operator reads and maintains, but the key never selects anything -- see
:func:`_plan_covering`. Keeping selection on content identity is what lets
a renamed file still find its plan, and stops a plan filed under the wrong
name from segmenting the wrong document.
Both forms at once is REFUSED rather than merged. They are two ways to say
the same thing, and merging them would let a caller hold a plan in each and
never learn the two disagreed.
"""
if segmentation is not None and segmentations:
raise SegmentationError(
"both `segmentation` and `segmentations` were given — pass one form or the "
"other; merging them would hide a disagreement between two plans for the "
"same document",
code="segmentation_plan_invalid",
)
plans = tuple(segmentations.values()) if segmentations else ()
if segmentation is not None:
plans = (segmentation,)
seen: dict[str, SegmentationPlan] = {}
for plan in plans:
if plan.source_sha256 in seen and plan is not seen[plan.source_sha256]:
raise SegmentationError(
f"two segmentation plans claim source_sha256 {plan.source_sha256!r}"
"selection is by content identity, so which one segmented the document "
"would depend on mapping order; refusing rather than picking one",
code="segmentation_plan_invalid",
)
seen[plan.source_sha256] = plan
return plans
def _plan_covering(
plans: Sequence[SegmentationPlan], source_bytes: bytes
) -> SegmentationPlan | None:
"""The plan for THESE bytes, or None when this file is not plan-covered.
Selection is by content hash, so a run may drop several documents while
only some of them are segmented; every other file keeps today's one-concept
rule verbatim. The hash SELECTS; :func:`assert_plan_applies` VALIDATES,
and the two are deliberately different questions -- see S5b.
"""
digest = hashlib.sha256(source_bytes).hexdigest()
for plan in plans:
if plan.source_sha256 == digest:
return plan
return None
def _render_segments(
plan: SegmentationPlan,
outputs: list[tuple[str, str, tuple[str, ...]]],
*,
path: Path,
text: str,
source_bytes: bytes,
gate: Gate,
profile: BundleProfile,
bundle_id: str,
source_file: str,
units: SourceUnits | None,
source_title: str | None = None,
concept_frontmatter_values: Mapping[str, str] | None = None,
) -> BlockedFile | None:
"""Render every segment, or refuse the WHOLE document.
The ordering here is the security property, not a style choice: all N
segments are gated and all N decisions collected BEFORE a single byte is
written. Gating and writing one at a time would leave a half-screened
document on disk the moment segment 3 of 5 quarantines -- part of a
document the guard refused, persisted and indexed, with the run reporting
success for everything it managed to write first.
A refusal is therefore reported once, for the document, rather than once
per segment: the operator's unit of review is the document they dropped.
"""
extractor_id = path.suffix.lower().lstrip(".") or "none"
assert_plan_applies(
plan,
source_sha256=hashlib.sha256(source_bytes).hexdigest(),
# `text` is the canonical extracted text this run produced, AFTER the
# profile's renderer -- exactly the string the plan's offsets index.
text_sha256=hashlib.sha256(text.encode("utf-8")).hexdigest(),
extractor_id=extractor_id,
# OBSERVED, never the plan's own value passed back in. That is what
# this line used to do, and comparing a value with itself made the
# version half of S5b unable to fail: a plan adjudicated under one
# converter replayed silently under another. The version of a
# third-party parser is knowable here after all -- `pdfplumber`'s
# transitive `pdfminer.six` pin is the measured example, and
# `observed_extractor_version` is where each row names its source.
extractor_version=observed_extractor_version(extractor_id),
)
sliced = slice_segments(text, plan)
# A body that is its heading alone gains ONE line linking the section its
# `parent` names. Only such a body: one holding text already has something
# to read, and the segmented goldens' declared parents are bodies holding
# text. Decided on the SLICE, the window the proposer's own predicate read.
enclosing = {entry.segment_id: entry for entry in plan.entries}
linked = {
entry.segment_id
for entry, body in sliced
if entry.parent_id is not None and heading_only(body)
}
decisions = [(entry, gate(body)) for entry, body in sliced]
refused = [
decision for _, decision in decisions if decision.disposition != _DISPOSITION_PERSIST
]
if refused:
return BlockedFile(
source_file=source_file,
disposition=refused[0].disposition,
reasons=tuple(reason for decision in refused for reason in decision.reasons),
)
for entry, decision in decisions:
structure: DocumentStructure | None = None
if profile.index.facets is not None:
structure = derive_document_structure(decision.sanitized_text, source_file=source_file)
_validate_facets(structure, profile)
body = decision.sanitized_text
if entry.segment_id in linked:
# AFTER the structure is derived, and that order is the rule: read
# as body text, the link is a bundle-local target, so derivation
# would restate the `parent` relation as a `references` edge -- one
# relation under two kinds, the second rendered unresolved because
# nothing resolves the absolute form. Measured on the fixture
# before this order was chosen.
assert entry.parent_id is not None
body = _link_enclosing(body, enclosing[entry.parent_id], gate)
outputs.append(
(
entry.path,
render_inbox_concept(
body,
okf_type=entry.okf_type,
# DECLARED by the adjudicator, never derived from the
# segment's own first line: the plan is the record of the
# judgement, and a heading inside a slice is not it.
title=entry.title,
source_file=source_file,
source_bytes=source_bytes,
ingested_at=entry.ingested_at,
profile=profile,
structure=structure,
# The description is document text persisted OUTSIDE the
# body this gate just screened, so it is screened too.
segment=(
replace(entry, description=_screened(gate, entry.description))
if entry.description is not None
else entry
),
bundle_id=bundle_id,
units=units,
source_title=source_title,
concept_frontmatter_values=concept_frontmatter_values,
),
decision.reasons,
)
)
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,
ingested_at: str,
*,
okf_type: str,
gate: Gate,
profile: BundleProfile = DEFAULT,
root_frontmatter_values: Mapping[str, str] | None = None,
segmentation: SegmentationPlan | None = None,
segmentations: Mapping[str, SegmentationPlan] | None = None,
pdf_headings: bool = False,
heading_reserve: Callable[[str], bool] | None = None,
ocr: bool = False,
assets: bool = False,
concept_frontmatter_values: Mapping[str, str] | None = None,
) -> InboxResult:
"""Convert every file dropped in `inbox_dir` into an OKF concept.
An explicit operator command, never a watcher or a scheduler (spec §9's
human-in-the-loop rule). `ingested_at` is required and stamped verbatim, as
at Door A. `gate` is the guard adapter: extracted text is screened before
anything is written, and only the guard's non-blocking floor persists —
anything else, INCLUDING a disposition this library does not recognise,
fails closed.
One bad file never aborts the run. Extraction failures, unusable filenames
and collisions are reported per file in :class:`InboxResult` while the
remaining files still process. Only three conditions fail the whole run,
and all three are wrong for every file at once: an invalid `ingested_at`,
a reserved `okf_type`, and a missing inbox directory.
"""
validate_ingested_at(ingested_at)
# Wrong for every file at once, so refused for the run before any is read.
run_values = validate_concept_frontmatter(concept_frontmatter_values or {}, profile=profile)
# Rendered HERE, before anything is read or written, and the result carried
# to the index write at the bottom. `_render_root_frontmatter` refuses a key
# the policy does not name, and a refusal must leave no bundle behind --
# `materialize.py` states the same rule for Door A, and a door that half-built
# a bundle before refusing would be worse than one that never started.
root_head = _render_root_frontmatter(root_frontmatter_values or {}, profile=profile)
plans = _resolve_plans(segmentation, segmentations)
if plans:
if profile.segmentation is None:
raise SegmentationError(
"a segmentation plan was passed to a profile that does not declare the "
"segmentation capability — a plan would be silently ignored and the "
"document would land as one flat concept, which is the shape this "
"capability exists to replace",
code="segmentation_unsupported_profile",
)
if profile.segmentation.bundle_id_key not in (root_frontmatter_values or {}):
raise SegmentationError(
f"a segmentation plan requires {profile.segmentation.bundle_id_key!r} in "
"root_frontmatter_values — a segmented bundle's concept paths collide "
"with any other bundle built from the same plan, so the bundle "
"identifier is what a consumer joins on; refusing to write concepts "
"nothing can tell apart",
code="segmentation_plan_invalid",
)
run_rejection = profile.types.rejection(okf_type)
if run_rejection is not None:
raise MaterializationError(f"okf_type {run_rejection.reason}", code=run_rejection.code)
inbox = Path(inbox_dir)
if not inbox.is_dir():
raise SourceError(f"inbox directory does not exist: {inbox}", code="source_root_missing")
bundle = Path(bundle_dir)
# RECURSIVE, sorted by relative path. The operator's nested structure is
# theirs to arrange, but it is not theirs to lose: a file under a folder
# used to be neither ingested nor refused, so a bundle built from a nested
# drop was silently short of what was dropped.
dropped, skipped = walk_inbox(inbox, exclude=bundle)
def source_name(path: Path) -> str:
return relative_source(path, inbox)
persisted: list[PersistedFile] = []
concepts: list[PersistedFile] = []
quarantined: list[BlockedFile] = []
rejected: list[BlockedFile] = []
failed: list[FailedFile] = []
# Keyed by asset name, so one image dropped by two documents is one entry
# and the bundle holds one file. The bytes are kept until the write, which
# happens per document AFTER that document's gate decision -- an image
# belonging to a document the guard refused must not be left behind in
# `assets/`, where nothing would ever point at it and nothing would ever
# retire it.
carried_assets: dict[str, bytes] = {}
refused_assets: list[AssetRejection] = []
carried_files: set[str] = set()
document_assets: list[DocumentAssets] = []
normalised: list[DocumentNormalisation] = []
# Phase 1: name every file BEFORE any gate call or write, so an intra-run
# collision is caught while both files can still be refused together. Under
# 1-to-N a document does not claim ONE name — it claims the whole set of
# paths its plan expands to, and the gate is keyed on that set. Keyed on one
# name per file, the same defect returns one level down: the second document
# would silently claim the first's concepts.
named: list[tuple[Path, tuple[str, ...], bytes, bool]] = []
slug_owners: dict[str, list[Path]] = {}
# Recorded at the moment of SELECTION, not after validation: a plan whose
# hash matched a drop but whose entry paths were then refused is a covered
# document with a bad plan, and it must keep reporting its own per-file
# code rather than being re-reported as a plan that matched nothing.
# WHICH plans matched, not merely that one did: a corpus run where four of
# five plans matched would otherwise report success over four segmented
# documents and one silently flat one.
matched_hashes: set[str] = set()
for path in dropped:
try:
# Read HERE rather than in the write loop: a plan is selected by
# content hash, so the set of names a document claims is not knowable
# without its bytes, and the whole point of this phase is to know
# every name before anything happens.
source_bytes = path.read_bytes()
except OSError as exc:
failed.append(
FailedFile(
source_file=source_name(path),
error=SourceError(
f"cannot read dropped file {source_name(path)}: {exc}",
code="source_file_missing",
),
)
)
continue
try:
covering = _plan_covering(plans, source_bytes)
if covering is not None:
matched_hashes.add(covering.source_sha256)
targets: tuple[str, ...]
if covering is None:
# From the BASENAME, not the relative path: the concept name
# is the bundle's, and folding a folder into it would rename
# every concept the moment an operator tidied their inbox. Two
# folders holding the same basename therefore collide, and the
# §3 gate below refuses both rather than picking a winner.
targets = (inbox_filename(inbox_slug(path.name), profile=profile),)
else:
targets = tuple(_check_segment_path(item.path) for item in covering.entries)
except IngestError as exc:
failed.append(FailedFile(source_file=source_name(path), error=exc))
continue
named.append((path, targets, source_bytes, covering is not None))
for target in targets:
slug_owners.setdefault(target, []).append(path)
# A plan that covered nothing is a misuse, not an outcome. `_plan_covering`
# selects on content hash, so a mistyped `source_sha256` matches no drop,
# every file falls through to the one-concept rule, and the run returns an
# ordinary success over a flat bundle -- the silent skip this library
# refuses everywhere else. Asked as "was a covering plan actually found?"
# rather than "was every file examined?", so a file that could not be read
# cannot mask the refusal. Still before any disk mutation: Phase 1 only
# named things.
unmatched = sorted(set(plan.source_sha256 for plan in plans) - matched_hashes)
if unmatched:
raise SegmentationError(
f"{len(unmatched)} of {len(plans)} segmentation plan(s) match none of the "
f"{len(dropped)} dropped file(s) — first unmatched source_sha256 "
f"{unmatched[0]!r}; nothing would be segmented for those documents and the "
"run would report success over a flat bundle; check each hash against the "
"bytes it was adjudicated over",
code="segmentation_plan_unmatched",
)
contested = {name for name, owners in slug_owners.items() if len(owners) > 1}
# One refusal per DOCUMENT, not per contested path: a document expanding to
# five colliding paths is one thing the operator has to fix, and five
# identical entries would report the same rename five times.
for path in sorted(
{owner for name in contested for owner in slug_owners[name]}, key=source_name
):
claimed = sorted(name for name in contested if path in slug_owners[name])
others = sorted(
{source_name(other) for name in claimed for other in slug_owners[name] if other != path}
)
failed.append(
FailedFile(
source_file=source_name(path),
error=MaterializationError(
f"{source_name(path)!r} and {', '.join(repr(other) for other in others)}"
f" both reduce to {', '.join(repr(name) for name in claimed)}"
" — rename one; refusing to pick a winner",
code="inbox_slug_collision",
),
)
)
# Phase 2: the §3 ownership scan, evaluated against the bundle as it was
# BEFORE this run — a file written below must never be mistaken for
# pre-existing curated content by a later file's check.
owned_by_source: dict[str, set[str]] = {}
if profile.segmentation is not None:
# RECURSIVE, and only here. A nested concept is invisible to a flat
# glob, so our own file would look like curated content and the §3
# collision gate would fire on it.
owned_by_source = _owned_concepts_by_source(bundle, profile)
pre_existing = (
{
path.relative_to(bundle).as_posix()
for path in bundle.rglob(f"*{profile.paths.concept_suffix}")
if path.is_file() and path.name != profile.index.name
}
if bundle.is_dir()
else set()
)
else:
pre_existing = (
{
path.name
for path in bundle.glob(f"*{profile.paths.concept_suffix}")
if path.name != profile.index.name
}
if bundle.is_dir()
else set()
)
owned = {name for name in pre_existing if _is_inbox_owned(bundle / name)}
for path, targets, source_bytes, segmented_file in named:
if any(name in contested for name in targets):
continue
unstamped = [name for name in targets if name in pre_existing and name not in owned]
if unstamped:
failed.append(
FailedFile(
source_file=source_name(path),
error=MaterializationError(
f"generated filename {unstamped[0]!r} collides with an existing file "
"that does not carry the inbox marker — refusing to overwrite curated "
"content (§3)",
code="collision_unstamped",
),
)
)
continue
outputs: list[tuple[str, str, tuple[str, ...]]] = []
try:
# The resolver is rooted at the DOCUMENT's own directory, which is
# the same root `propose.propose_segments` computes from the file it
# reads off disk. One root both sides derive independently is what
# makes the two renderings identical -- and a plan indexes the exact
# string it was proposed against, so a resolver that disagreed would
# turn every document carrying a pointer into a coded rejection.
resolve = directory_resolver(path.parent) if assets else None
document = extract_document(
source_name(path),
source_bytes,
renderer=_resolve_renderer(profile, path.name),
pdf_headings=pdf_headings,
ocr=ocr,
assets=assets,
resolve=resolve,
)
text = document.text
# The heading RESERVE, supplied as a predicate rather than decided
# here: the condition is the proposer's outline grammar, and the
# door does not own that grammar. A callable keeps the dependency
# pointing the way the layers do -- the same shape `gate` already
# has -- while guaranteeing the two sides ask ONE question. Both
# `text` and `units` move together, because a locator built from
# one rendering cannot address the other.
reading_fonts = pdf_headings
if heading_reserve is not None and not pdf_headings and heading_reserve(text):
reading_fonts = True
document = extract_document(
source_name(path),
source_bytes,
renderer=_resolve_renderer(profile, path.name),
pdf_headings=True,
ocr=ocr,
assets=assets,
resolve=resolve,
)
text = document.text
# Computed from the SAME text the plan's offsets index, so the
# locator and the offset can never disagree about which rendering
# they describe. `None` when the profile names no provenance:
# building a unit table nobody writes would re-parse every PDF for
# a key that is never emitted.
units = (
source_units(
source_name(path),
source_bytes,
text,
pdf_headings=reading_fonts,
ocr=ocr,
assets=assets,
)
if profile.provenance is not None
else None
)
# What the document says it is, for the address's title. Asked only
# where an address is written, so the four profiles without one do
# not parse anything they would never emit.
source_title = (
_declared_sources_title(declared_identity(source_name(path), source_bytes), gate)
if profile.provenance is not None
else None
)
covering = _plan_covering(plans, source_bytes)
if covering is not None:
blocked = _render_segments(
covering,
outputs,
path=path,
text=text,
source_bytes=source_bytes,
gate=gate,
profile=profile,
bundle_id=(root_frontmatter_values or {})[_bundle_id_key(profile)],
source_file=source_name(path),
units=units,
source_title=source_title,
concept_frontmatter_values=run_values,
)
if blocked is not None:
if blocked.disposition == _DISPOSITION_QUARANTINE:
quarantined.append(blocked)
else:
rejected.append(blocked)
continue
else:
decision = gate(text)
if decision.disposition != _DISPOSITION_PERSIST:
blocked = BlockedFile(
source_file=source_name(path),
disposition=decision.disposition,
reasons=decision.reasons,
)
# Quarantine is a queue for the operator; everything else —
# fail-secure, or a disposition from outside the pinned range —
# is a refusal. Unknown values land here by construction.
if decision.disposition == _DISPOSITION_QUARANTINE:
quarantined.append(blocked)
else:
rejected.append(blocked)
continue
structure: DocumentStructure | None = None
title = unicodedata.normalize("NFC", path.stem)
if profile.index.facets is not None:
# Derived from the SANITIZED text, never the extracted text:
# deriving from bytes the gate rejected would put unscreened
# content in the frontmatter and the index.
structure = derive_document_structure(
decision.sanitized_text, source_file=source_name(path)
)
title = structure.title
_validate_facets(structure, profile)
outputs.append(
(
targets[0],
render_inbox_concept(
decision.sanitized_text,
okf_type=okf_type,
title=title,
source_file=source_name(path),
source_bytes=source_bytes,
ingested_at=ingested_at,
profile=profile,
structure=structure,
units=units,
# The EXTRACTED text's span, never the sanitized
# text's: the unit table indexes the former, and a
# gate that removed a character would shift every
# unit boundary after it.
span=(0, len(text)),
source_title=source_title,
concept_frontmatter_values=run_values,
),
decision.reasons,
)
)
except OSError as exc:
failed.append(
FailedFile(
source_file=source_name(path),
error=SourceError(
f"cannot read dropped file {source_name(path)}: {exc}",
code="source_file_missing",
),
)
)
continue
except IngestError as exc:
failed.append(FailedFile(source_file=source_name(path), error=exc))
continue
bundle.mkdir(parents=True, exist_ok=True)
if outputs and (document.images or document.rejected):
# AFTER the gate, and only where the document actually produced
# concepts. An asset written for a refused document would be an
# orphan no pointer names and no retirement pass reaches.
_write_assets(bundle, document.images, carried_assets)
refused_assets.extend(document.rejected)
directory = PurePosixPath(source_name(path)).parent
carried_files.update(
posixpath.normpath((directory / reference).as_posix())
for reference in document.files
)
if document.soft_hyphens:
normalised.append(
DocumentNormalisation(
source_file=source_name(path), soft_hyphens=document.soft_hyphens
)
)
if outputs:
document_assets.append(
DocumentAssets(
source_file=source_name(path),
carried=len(document.images),
rejected=document.rejected,
conversions=tuple(
pair
for pair in (conversion(image) for image in document.images)
if pair is not None
),
)
)
for target_name, content, reasons in outputs:
# `write_bytes` resolves a subpath through `safe_resolve` but never
# creates one. Without this the very first hierarchical write fails.
(bundle / target_name).parent.mkdir(parents=True, exist_ok=True)
written = write_bytes(bundle, target_name, content)
concepts.append(
PersistedFile(source_file=source_name(path), path=written, reasons=reasons)
)
# One entry per SOURCE FILE, whatever the document expanded into. That
# is what `persisted` has always meant, so an existing consumer's count
# does not change under a profile that segments.
if outputs:
persisted.append(concepts[-len(outputs)])
if segmented_file:
# Round N owns exactly what round N's plan names. Everything this
# document owned before and does not now is retired HERE, after the
# writes, so a failure above leaves the previous round intact.
_retire_stale_segments(
bundle, owned_by_source.get(source_name(path), set()) - set(targets), profile
)
# §6 index — the last disk mutation, and only when something was written.
if persisted:
index_path = bundle / profile.index.name
if not index_path.is_file():
write_bytes(bundle, profile.index.name, root_head)
else:
_refresh_root_frontmatter(index_path, root_head)
if profile.index.facets is None:
for entry in concepts:
link_in_index(
bundle,
entry.path.name,
unicodedata.normalize("NFC", Path(entry.source_file).stem),
profile=profile,
)
elif profile.segmentation is not None:
# The caller scans; the writer projects. `listing` is the WHOLE
# bundle's owned concepts, not this round's, which is what keeps
# rebuild-from-scratch equal to an incremental update.
_reproject_indexes(
bundle,
profile,
listing=_owned_listing(bundle, profile),
root_head=root_head,
)
else:
_reproject_index(bundle, profile)
return InboxResult(
persisted=tuple(persisted),
quarantined=tuple(quarantined),
rejected=tuple(rejected),
failed=tuple(sorted(failed, key=lambda entry: entry.source_file)),
concepts=tuple(concepts),
skipped=skipped,
assets=tuple(sorted(carried_assets)),
assets_rejected=tuple(refused_assets),
carried_files=tuple(sorted(carried_files)),
document_assets=tuple(document_assets),
normalised=tuple(normalised),
)
#: The adjudication states a segment concept may carry, and the whole set.
#: CLOSED on purpose (PM decision B2, `docs/plan/office-intake.md` § 5): a
#: value outside it is an error, not an extension point. The consumer's half of
#: the same contract is that ABSENCE of the key means `unknown` -- an older
#: bundle -- never a collapse to `absent`, so a producer emitting the key
#: inconsistently would make that distinction unmeasurable on their side.
ADJUDICATION_PROPOSED = "proposed"
ADJUDICATION_ADJUDICATED = "adjudicated"
ADJUDICATION_STATES = (ADJUDICATION_PROPOSED, ADJUDICATION_ADJUDICATED)
#: Written beside the state when, and only when, it is `adjudicated`. The dwell
#: time travels WITH the verdict: a ratified flag carrying no per-item time is
#: unfalsifiable, and it is the same number that makes adjudication throughput
#: measurable at all.
ADJUDICATION_COMPANION_KEYS = ("adjudicated_by", "adjudicated_at", "adjudication_dwell_s")
def _write_assets(bundle: Path, images: Sequence[ExtractedImage], seen: dict[str, bytes]) -> None:
"""Put one document's images in the bundle's `assets/` directory.
OWNERSHIP IS PROVEN BY CONTENT IDENTITY, which is Door C's rule reused
verbatim: an occupied name is re-used only when the bytes there are already
identical, and never overwritten otherwise. Here the name carries the
digest of those very bytes, so an occupied name with different contents is
a `sha256[:12]` collision -- refused loudly rather than resolved silently,
because silently resolving it would mean one of two pictures is lost and
every pointer to it shows the other.
A binary write, and the only one in this package. `materialize.write_bytes`
takes `content: str` and encodes UTF-8, which is correct for every text
guarantee it holds and cannot carry a JPEG.
"""
directory = bundle / ASSETS_DIR
for image in images:
name = asset_name(image)
known = seen.get(name)
if known is not None:
if known != image.data:
raise MaterializationError(
f"two different images reduce to the asset name {name!r} in one run; "
"refusing to overwrite the first, because every pointer to it would "
"then show the second",
code="asset_collision",
)
continue
directory.mkdir(parents=True, exist_ok=True)
target = safe_resolve(directory, name)
if target.exists() and target.read_bytes() != image.data:
raise MaterializationError(
f"the asset {name!r} already exists in the bundle with different bytes; "
"refusing to overwrite content this run did not write",
code="asset_collision",
)
target.write_bytes(image.data)
seen[name] = image.data
def _validate_facets(structure: DocumentStructure, profile: BundleProfile) -> None:
"""Refuse a document whose values cannot be rendered as index facets.
Run BEFORE the write and per file, not at reprojection time, because
reprojection happens once for the whole bundle: a value refused there would
fail the index for every document instead of the one that carried it, and
Door B's standing promise is that one bad file never aborts the run.
Only the producer's own values can fail here — the relation subjects and
markers are this library's own tokens.
"""
assert profile.index.facets is not None
values = structure_frontmatter(structure, profile.index.facets.keys)
policy = profile.segmentation
key = None if policy is None else policy.adjudication_key
# Refused HERE rather than at reprojection, for the reason above: the only
# adjudication value that can be wrong is one the producer declared in the
# dropped document itself, and refusing it per file keeps a single bad
# document from failing the index for every other one.
if key is not None and key in values and values[key] not in ADJUDICATION_STATES:
raise MaterializationError(
f"frontmatter key {key!r} carries {values[key]!r}, which is outside the closed "
f"set {ADJUDICATION_STATES} — the adjudication state is a contract, not an "
"extension point",
code="index_facet_invalid",
)
try:
profile.index.facets.render(values)
except ValueError as exc:
raise MaterializationError(str(exc), code="index_facet_invalid") from exc
def _refresh_root_frontmatter(index_path: Path, head: str) -> None:
"""Put `head` at the top of an existing index, replacing any block already there.
Idempotent by construction: the leading block is removed and the current one
written, so applying this twice produces the same bytes. That is what makes a
second round with the same `bundle_id` a no-op and a rebuild-from-scratch
byte-identical to an incremental update -- neither is diffed against the
other, both are the same function of the same inputs.
The block is recognised the way `parse_frontmatter` recognises one: an
opening `---` on the very first line, up to the next `---`. Nothing else is
read, because a value inside the block is the caller's and this door only
ever restates it.
"""
body = index_path.read_bytes().decode("utf-8")
lines = body.splitlines(keepends=True)
if lines and lines[0].strip() == "---":
for position, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
rest = lines[position + 1 :]
# The blank line the block is separated by belongs to the block.
if rest and rest[0].strip() == "":
rest = rest[1:]
body = "".join(rest)
break
if body == head:
return
index_path.write_bytes((head + body).encode("utf-8"))
def _owned_listing(bundle: Path, profile: BundleProfile) -> dict[str, DocumentStructure]:
"""Every inbox-owned concept in the bundle, keyed by bundle-relative path.
The path IS the concept ID (OKF v0.2 §2), so two files called `a.md` in
different directories are two concepts and must not share a key.
"""
listing: dict[str, DocumentStructure] = {}
for path in sorted(bundle.rglob(f"*{profile.paths.concept_suffix}")):
if not path.is_file() or path.name == profile.index.name or not _is_inbox_owned(path):
continue
listing[path.relative_to(bundle).as_posix()] = structure_from_frontmatter(
parse_frontmatter(path)
)
return listing
def _reproject_indexes(
bundle: Path,
profile: BundleProfile,
*,
listing: Mapping[str, DocumentStructure],
root_head: str,
) -> None:
"""Write one index per directory, each linking down to its children.
`listing` is supplied by the caller rather than enumerated here -- the same
division `IndexPolicy` records for the judging side. What the caller hands
over is the WHOLE bundle's owned concepts, not just this round's, which is
what keeps this a projection: recomputing every index from the whole set
each round is exactly why a rebuild-from-scratch equals an incremental
update, with nothing diffed and so no diffing algorithm to be wrong.
Managed lines are recognised with the ANCHORED `link_pattern` through
`parse_entry`, never with `link_in_index`'s substring test: once targets are
relative subdirectory paths, `](krav/3-1/a.md)` also contains
`](3-1/a.md)`, and a substring matcher would drop or double an entry.
"""
assert profile.index.facets is not None
resolved = resolve_structure(listing)
# Every directory that holds a concept, plus every ancestor of one: a
# bundle whose middle level had no index would break the walk from the root.
by_directory: dict[str, dict[str, DocumentStructure]] = {}
directories: set[str] = {""}
for relative, document in listing.items():
parent = PurePosixPath(relative).parent
directory = "" if str(parent) == "." else str(parent)
by_directory.setdefault(directory, {})[relative] = document
while directory:
directories.add(directory)
directory = str(PurePosixPath(directory).parent).replace(".", "")
children: dict[str, set[str]] = {}
for directory in directories:
if not directory:
continue
parent = PurePosixPath(directory).parent
children.setdefault("" if str(parent) == "." else str(parent), set()).add(directory)
policy = profile.segmentation
assert policy is not None
for directory in sorted(directories):
entries: list[IndexEntry] = []
for relative, document in by_directory.get(directory, {}).items():
target = PurePosixPath(relative).name
entries.append(
IndexEntry(
label=document.title or target,
target=target,
facets=facet_values(relative, resolved, profile.index.facets.keys),
concept_path=relative,
)
)
for child in children.get(directory, set()):
name = PurePosixPath(child).name
entries.append(
IndexEntry(
label=f"{name} ({policy.nav_label})",
target=f"{name}/{profile.index.name}",
)
)
block = [
profile.index.render_link(entry.label, entry.target, facets=entry.facets or None) + "\n"
for entry in profile.index.sort_entries(entries)
]
_write_index(
bundle,
directory,
profile,
head=root_head if directory == "" else "",
block=block,
)
def _write_index(
bundle: Path,
directory: str,
profile: BundleProfile,
*,
head: str,
block: list[str],
) -> None:
"""Replace one index's managed region, preserving everything else in order."""
index_path = (
bundle / directory / profile.index.name if directory else bundle / profile.index.name
)
index_path.parent.mkdir(parents=True, exist_ok=True)
existing = index_path.read_bytes().decode("utf-8") if index_path.is_file() else ""
kept: list[str] = []
insert_at: int | None = None
for line in existing.splitlines(keepends=True):
if profile.index.parse_entry(line) is not None:
if insert_at is None:
insert_at = len(kept)
continue
kept.append(line)
if insert_at is None:
insert_at = len(kept)
if insert_at > 0 and not kept[insert_at - 1].endswith("\n"):
kept[insert_at - 1] += "\n"
body = "".join(kept[:insert_at] + block + kept[insert_at:])
if head:
# The frontmatter block is re-established rather than appended to, so a
# second round with the same values produces the same bytes.
lines = body.splitlines(keepends=True)
if lines and lines[0].strip() == "---":
for position, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
rest = lines[position + 1 :]
if rest and rest[0].strip() == "":
rest = rest[1:]
body = "".join(rest)
break
body = head + body
index_path.write_bytes(body.encode("utf-8"))
def _reproject_index(bundle: Path, profile: BundleProfile) -> None:
"""Rewrite the managed region of the index from the WHOLE bundle.
Not an append and not a diff. Every inbox-owned concept is read back, the
relations between them are resolved as a pure function of that whole set,
and the managed lines are re-emitted in one canonically ordered block.
Three properties fall out by construction rather than by argument:
- rebuild-from-scratch equals incremental update, because both are the same
function of the same files;
- re-dropping a document replaces its entry instead of doubling it, because
the concept name is the identity;
- a relation formed in a later round (round 3 supersedes round 1) UPDATES
the entry it is about, which an append-only index could never do.
Everything this library did not write survives verbatim and in order —
curated prose, headings, and links to files this door does not own. The
derived block sits where the first managed line was, so an operator's
layout around it is stable across rounds.
"""
assert profile.index.facets is not None
documents: dict[str, DocumentStructure] = {}
# M4's second flat glob. Under the capability a concept lives at a nested
# path, and a flat scan would reproject an index that silently omits every
# one of them -- the reprojection is the whole-bundle recompute that makes
# rebuild equal an incremental update, so a scan that cannot see a file
# makes that equality false rather than merely incomplete.
segmented = profile.segmentation is not None
found = (
bundle.rglob(f"*{profile.paths.concept_suffix}")
if segmented
else bundle.glob(f"*{profile.paths.concept_suffix}")
)
for path in sorted(found):
if not path.is_file() or path.name == profile.index.name or not _is_inbox_owned(path):
continue
# The concept ID is the bundle-relative path (OKF v0.2 §2), and two
# files called `a.md` in different directories are two concepts.
key = path.relative_to(bundle).as_posix() if segmented else path.name
documents[key] = structure_from_frontmatter(parse_frontmatter(path))
resolved = resolve_structure(documents)
entries = [
IndexEntry(
label=documents[name].title or name,
target=name,
facets=facet_values(name, resolved, profile.index.facets.keys),
# Flat: the concept ID and the link target are the same string. It
# is still named, because the tie-break is the concept path and
# agreeing with the target here is this writer's fact, not a rule.
concept_path=name,
)
for name in sorted(documents)
]
block = [
profile.index.render_link(entry.label, entry.target, facets=entry.facets) + "\n"
for entry in profile.index.sort_entries(entries)
]
index_path = bundle / profile.index.name
kept: list[str] = []
insert_at: int | None = None
for line in index_path.read_bytes().decode("utf-8").splitlines(keepends=True):
entry = profile.index.parse_entry(line)
# A managed line pointing at a file this door does not own is somebody
# else's link that happens to share our shape. Claiming it would delete
# curated content on the strength of a regex.
if entry is not None and entry.target in documents:
if insert_at is None:
insert_at = len(kept)
continue
kept.append(line)
if insert_at is None:
insert_at = len(kept)
# A preserved line without its own newline would run into the first
# derived entry, silently merging two lines into one unparseable one.
if insert_at > 0 and not kept[insert_at - 1].endswith("\n"):
kept[insert_at - 1] += "\n"
index_path.write_bytes("".join(kept[:insert_at] + block + kept[insert_at:]).encode("utf-8"))