llm-ingestion-okf/src/llm_ingestion_okf/materialize.py
Kjell Tore Guttormsen 16eeeb007e feat(materialize): sources takes a list and renders N flow mappings
PM decision B6 asked for a list-taking _render_sources so a concept can record
more than one source, and prescribed the block list as the emitted form. The
list is delivered; the block form is not.

Three measurements, not an argument. Our own parse_frontmatter skips indented
lines, so a block list round-trips to an empty value with every entry silently
gone -- and _is_ingest_owned reads through that same parser. The consumer B6
was written for accepts the multi-entry flow sequence and classifies a block
sequence as unreadable provenance, so block would hand it exactly the state it
cannot read. And B6's own acceptance test asks for a round trip through this
parser, which no block form can pass.

A single source renders byte-identically, so all six goldens are unmoved. The
unquotable-value gate now runs on every entry, not just the first. New code
sources_empty refuses an empty list.

1023 -> 1034 tests, including the negative control that pins the block form's
silent data loss.
2026-09-02 16:12:19 +02:00

601 lines
27 KiB
Python

"""Materialization: manifest → OKF bundle (ingest-spec §5, §6, §8).
Three explicit inputs — manifest path, bundle directory, ingested_at (no
wall-clock default). Deterministic and offline for `file`/`sql`; zero model
calls. All extractions execute and render in memory before the first disk
mutation; the §3 collision gate runs before any mutation; only files carrying
the ingest stamp are ever replaced. Output is LF-only raw bytes.
"""
from __future__ import annotations
import hashlib
import logging
import re
import unicodedata
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from .connectors import HttpGet, read_csv, read_http, read_sql, safe_resolve, urllib_get
from .errors import ManifestError, MaterializationError, NetworkGateError
from .manifest import (
Extraction,
FileSource,
HttpSource,
Manifest,
Source,
SqlSource,
generated_filename,
load_manifest_bytes,
)
from .profiles import DEFAULT, BundleProfile
from .render import render_fenced_block, render_table
_LOGGER = logging.getLogger(__name__)
_INGESTED_AT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
@dataclass(frozen=True)
class IngestResult:
"""The concept files written by one materialization run, and the §5
provenance stamp (`{stem}@{sha256(raw)[:16]}`) they were stamped with."""
written: tuple[Path, ...]
stamp: str
def validate_ingested_at(ingested_at: str) -> None:
"""Refuse an `ingested_at` that is not ISO-8601 UTC with a Z suffix.
Shared by every door: the value is stamped verbatim into frontmatter, so
one rule in one place is what keeps the determinism contract from drifting
between Door A and the inbox.
"""
if not _INGESTED_AT_RE.match(ingested_at):
raise MaterializationError(
"ingested_at must be ISO-8601 UTC with a Z suffix "
f"(e.g. 2026-07-03T12:00:00Z), got {ingested_at!r}",
code="ingested_at_invalid",
)
# --- generated-name primitives (shared by Doors B and C) ------------------
# Every character outside the Phase 1 id grammar (`[a-z0-9][a-z0-9-]*`) is a
# separator. Deliberately NOT a transliteration: mapping non-ASCII letters to
# ASCII ones would be a semantic claim the slugger cannot make — Norwegian
# `møte` (meeting) would become `mote` (fashion). The readable name survives
# verbatim in the concept's title or index label; the slug is an identifier,
# not a label.
_SEPARATOR_RUN_RE = re.compile(r"[^a-z0-9]+")
# NAME_MAX: the per-component limit on every filesystem this library targets
# (APFS, ext4, NTFS all cap at 255). Checked before the write rather than
# caught at it, because the OS signals it as an OSError whose errno differs per
# platform (63 on macOS, 36 on Linux) — an untyped, unportable failure at the
# very moment the caller needs a typed per-file outcome. Verified empirically
# on APFS 2026-07-25: a 255-byte name writes, a 258-byte one raises errno 63.
NAME_MAX_BYTES = 255
def reduce_to_id_grammar(text: str) -> str:
"""Reduce arbitrary text to the Phase 1 id grammar, or to the empty string.
Lowercased, with every run of non-grammar characters collapsed to a single
`-` and both ends stripped. The caller decides what an empty result means:
both doors refuse it rather than invent a fallback name.
"""
# NFC first: macOS (APFS/HFS+) hands filenames over DECOMPOSED, so an `é`
# arrives as `e` + combining acute. Without normalising, the same visual
# name reduces differently depending on where it came from — the combining
# mark alone becomes a separator and the base letter survives (`cafe`),
# where a composed `é` is one non-grammar character (`caf`). Composing
# first makes the whole letter one unit, so non-ASCII is uniformly a
# separator and the result is stable across both forms.
return _SEPARATOR_RUN_RE.sub("-", unicodedata.normalize("NFC", text).lower()).strip("-")
def check_filename_length(name: str, *, code: str) -> str:
"""Refuse a generated filename the filesystem cannot hold.
Never truncated: truncation is lossy AND collision-prone (two long names
sharing a prefix would reduce to one filename, and the second write would
silently claim the first file). The message carries both the actual size
and the limit, because the operator's fix is to shorten the source name.
"""
size = len(name.encode("utf-8"))
if size > NAME_MAX_BYTES:
raise MaterializationError(
f"the generated filename would be {size} bytes, over the "
f"{NAME_MAX_BYTES}-byte filesystem limit — shorten the source name; "
"refusing to truncate (lossy and collision-prone)",
code=code,
)
return name
def parse_frontmatter(path: Path) -> dict[str, str]:
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
return {}
frontmatter: dict[str, str] = {}
for line in lines[1:]:
if line.strip() == "---":
break
# An INDENTED key belongs to the block above it, not to the document.
# Without this, `key.strip()` would flatten it into the same namespace
# as the top-level keys and, arriving later, SUBSTITUTE for one of them
# -- a `sources:` entry's own `title:` silently becoming the document's,
# carrying `number` and `parent` with it. Skipping is deliberately not
# parsing: the nested value is not read, only refused. The structured
# reader is D1b.
if line[:1] in (" ", "\t"):
continue
key, sep, value = line.partition(":")
if sep:
frontmatter[key.strip()] = value.strip()
return frontmatter
def _is_ingest_owned(path: Path, manifest_stem: str, *, profile: BundleProfile = DEFAULT) -> bool:
# §3/§5 ownership: the profile's ingest stamp AND an `ingest_manifest`
# reference. Promoted verdict files carry neither key, so they can never
# classify as ingest-owned.
#
# The stamp is the PROFILE's because it differs per profile (the older form
# is `generated: true`, the O2 form a `{ by: ..., at: ... }` mapping, and
# which one a profile writes follows the contract it states) — and because the
# emitter and this predicate are coupled through that value. Changing the
# emitted form without the predicate is what makes the library stop
# recognising its own output, firing the collision gate on the files its own
# previous run wrote. `OwnershipPolicy` is where the two meet, so they can
# only be changed together.
#
# §10.2 per-manifest ownership: a file is THIS manifest's to replace only
# when the reference names it by stem. The stamp is `{stem}@{sha256[:16]}`;
# matching on the stem — not the whole stamp — lets an edited manifest (new
# content -> new sha -> new stamp) still reclaim the files a prior run of
# the same manifest wrote, while a DIFFERENT manifest sharing the bundle
# keeps its own. rsplit strips the trailing `@{sha}`, so a stem that itself
# contains `@` still compares correctly.
frontmatter = parse_frontmatter(path)
generated = frontmatter.get("generated")
if generated is None or not profile.ownership.owns(generated):
return False
reference = frontmatter.get("ingest_manifest")
if reference is None:
return False
return reference.rsplit("@", 1)[0] == manifest_stem
# The characters that terminate or restructure a YAML flow mapping. `:\s`
# catches a colon that would open a nested key; a colon inside `https://host`
# does not, and stays a plain scalar.
_FLOW_UNSAFE_RE = re.compile(r"[,\[\]{}]|:\s")
def _source_locator(source: Source) -> str:
"""Where a manifest source points, per source type.
A filesystem root, the NAME of the environment variable holding the DSN, or
the base URL. `credential_ref` is not a locator and is never returned here:
a credential reference has no reader in a bundle.
"""
if isinstance(source, FileSource):
return source.root
if isinstance(source, SqlSource):
return source.connection_ref
return source.base_url
def _render_sources(sources: Sequence[Source]) -> str:
"""§5 `sources` as an inline flow sequence of N flow mappings.
Two keys per entry, not upstream's five: a manifest source has no `author`,
no `last_modified`, and no bundle-internal `resource` in upstream's sense,
and inventing them would be writing fields with no reader.
**A LIST, and the flow form — PM decision B6 delivered, its mechanism not.**
B6 asked for a list-taking renderer and prescribed the BLOCK list as the
emitted form. The list is here; the block form is not, and the reason is
measured rather than argued:
- `parse_frontmatter` is line-oriented and skips indented lines, so a block
list round-trips to an EMPTY value with every entry gone, silently. We
would be writing provenance we cannot read back, and `_is_ingest_owned`
reads through that same parser.
- The consumer B6 was written for accepts `[{ k: v }, { k: v }]` — plural —
and classifies a block sequence as unreadable provenance. Block would hand
it exactly the state it reports as unreadable.
- B6's own acceptance test asks for a round trip through this parser. No
block form can pass it.
The flow form also satisfies commons' §5 "all values MUST be single-line",
and §11 requires parseable YAML rather than block YAML. Reading block needs
the structured reader (D1b); until then the constraint binds what we write.
A single source renders byte-identically to the one-entry form that shipped
before this took a list, which is what keeps all six goldens unmoved.
Refusing an unquotable value is the point of the check rather than a nicety:
`[{ id: x, resource: data, backup }]` is not a parse ERROR, it is a mapping
with a `backup` key nobody wrote. A silently wrong provenance record is
worse than a refused run, and repairing the value by quoting it would change
bytes the operator supplied. Validation, not repair — the same posture as
the filename-length gate. Every entry is checked, not only the first: a gate
that reads the head of a list is a gate the second entry walks past.
"""
if not sources:
raise MaterializationError(
"a `sources` list must name at least one source — `sources: []` "
"reads as a measured absence when it is the absence of a "
"measurement",
code="sources_empty",
)
entries = []
for source in sources:
locator = _source_locator(source)
for label, value in (("id", source.id), ("resource", locator)):
if _FLOW_UNSAFE_RE.search(value):
raise MaterializationError(
f"the source {label} {value!r} contains a character that would "
"restructure the `sources` flow mapping (one of `,[]{}` or a "
"colon followed by whitespace) — refusing to emit a provenance "
"record that parses cleanly into something no one wrote",
code="source_reference_unquotable",
)
entries.append(f"{{ id: {source.id}, resource: {locator} }}")
return f"[{', '.join(entries)}]"
def _render_concept_file(
manifest: Manifest,
extraction: Extraction,
body: str,
*,
ingested_at: str,
stamp: str,
profile: BundleProfile = DEFAULT,
) -> str:
# §5 frontmatter: exactly these keys. The order and the `source_query`
# whitespace collapse are the profile's — DEFAULT states the §5 layer.
frontmatter = {
"type": extraction.okf_type,
"title": extraction.title,
"source_system": manifest.source.id,
"source_query": extraction.query,
"ingested_at": ingested_at,
"ingest_manifest": stamp,
"generated": profile.ownership.stamp(ingested_at),
}
# Written only by a profile that NAMES it. `emit` sorts an unnamed key into
# the tail rather than dropping it, so building one mapping for both
# profiles would append `sources` to every v0.1 bundle — additivity is a
# property of what is constructed here, not of the emitter.
if "sources" in profile.frontmatter.order:
frontmatter["sources"] = _render_sources([manifest.source])
return f"---\n{profile.frontmatter.emit(frontmatter)}\n---\n\n{body}"
def write_bytes(bundle_dir: Path, name: str, content: str) -> Path:
# LF-only + exactly one trailing newline are byte-level guarantees (§5),
# so the write is raw bytes — never write_text, whose platform newline
# translation would break golden byte-determinism.
resolved = safe_resolve(bundle_dir, name)
resolved.write_bytes(content.encode("utf-8"))
return resolved
def _update_index_lines(
index_path: Path,
removed_targets: set[str],
labels_by_target: dict[str, str],
*,
profile: BundleProfile = DEFAULT,
) -> None:
"""§6 maintenance on an EXISTING index: drop managed lines whose target is
an ingest file removed in this run; refresh in place a managed label that
no longer equals the extraction title. Every other line is preserved
verbatim, in order — including its own line ending.
"""
original = index_path.read_bytes().decode("utf-8")
lines = original.splitlines(keepends=True)
updated: list[str] = []
changed = False
for line in lines:
content = line.rstrip("\r\n")
ending = line[len(content) :]
match = profile.index.entry_pattern.match(content)
if match is not None:
target = match.group("target")
if target in removed_targets:
changed = True
continue
new_label = labels_by_target.get(target)
if new_label is not None and match.group("label") != new_label:
line = profile.index.render_link(new_label, target) + ending
changed = True
updated.append(line)
if changed:
index_path.write_bytes("".join(updated).encode("utf-8"))
def _refresh_index_entry(
index_path: Path,
target_name: str,
label: str,
facets: Mapping[str, str],
*,
profile: BundleProfile,
) -> None:
"""Re-render the managed line for `target_name`, in place and alone.
Keyed on the policy's entry pattern and on the parsed target, never on a
substring: the index is the one file this library writes beside somebody
else's prose, so a curated line that merely MENTIONS the target has to
survive verbatim, and so does its own line ending.
"""
lines = index_path.read_bytes().decode("utf-8").splitlines(keepends=True)
updated: list[str] = []
changed = False
for line in lines:
content = line.rstrip("\r\n")
ending = line[len(content) :]
match = profile.index.entry_pattern.match(content)
if match is not None and match.group("target") == target_name:
refreshed = profile.index.render_link(label, target_name, facets=facets)
if refreshed != content:
line = refreshed + ending
changed = True
updated.append(line)
if changed:
index_path.write_bytes("".join(updated).encode("utf-8"))
def link_in_index(
bundle_dir: Path,
target_name: str,
label: str,
*,
profile: BundleProfile = DEFAULT,
facets: Mapping[str, str] | None = None,
) -> None:
# §6: idempotent by target — a link whose target is already present in
# the index is never added twice.
#
# `profile` is keyword-only with a default because this function is public
# and called from all three doors (A here, B in inbox.py, C in importer.py).
# Door A and Door B's unfaceted path keep the default; which profile a
# caller should own is a separate question, and answering it by changing
# this signature would have decided it silently.
#
# `facets` also decides what "already present" MEANS, and the split is not a
# convenience. A flat entry carries a label and a target, both stable, so it
# can never disagree with the file it points at and returning early is
# exactly right — a hand-edited label survives. An entry carrying the
# concept's FACTS can go stale, and an index that contradicts the bundle it
# indexes is worse than one that says nothing: the consumer reads the index
# and stops there. So a faceted entry for a target already present is
# REFRESHED in place rather than skipped.
#
# Additive by construction: with `facets=None` nothing below the early
# return runs, so every unfaceted caller emits the bytes it always did.
index_path = safe_resolve(bundle_dir, profile.index.name)
body = index_path.read_bytes().decode("utf-8")
if f"]({target_name})" in body:
if facets is None:
return
_refresh_index_entry(index_path, target_name, label, facets, profile=profile)
return
# An empty index needs no separator: Door A always seeds its index with
# bundle_summary first, but Door B has no summary to invent, so its index
# starts empty and must not open with a blank line.
prefix = body if (body == "" or body.endswith("\n")) else body + "\n"
line = profile.index.render_link(label, target_name, facets=facets)
index_path.write_bytes(f"{prefix}{line}\n".encode())
def _render_root_frontmatter(values: Mapping[str, str], *, profile: BundleProfile) -> str:
"""The root index's frontmatter block (§8, §12), or "" when nothing is
declared.
The policy names the keys and fixes their order; the caller supplies the
values. Ordering by the POLICY rather than by the mapping is what keeps two
callers passing the same keys from emitting different bytes — a dict
preserves insertion order, and a golden fixture would then depend on the
order a caller happened to build its argument in.
Values are written verbatim. `okf_version` must reach catalog's shape gate
unquoted, so nothing here may add quoting; the golden fixture asserts that
on raw bytes.
"""
unknown = sorted(set(values) - set(profile.index.root_frontmatter))
if unknown:
raise MaterializationError(
f"root frontmatter key(s) {', '.join(repr(key) for key in unknown)} are not "
f"named by the {profile.index.name} policy, which pins "
f"{profile.index.root_frontmatter or '()'} — writing an unnamed key would "
"put a value in a file no reader of this contract looks at",
code="index_root_frontmatter_unexpected",
)
declared = [key for key in profile.index.root_frontmatter if key in values]
if not declared:
return ""
lines = "".join(f"{key}: {values[key]}\n" for key in declared)
return f"---\n{lines}---\n\n"
def materialize_bundle(
manifest_path: Path,
bundle_dir: Path,
ingested_at: str,
*,
allow_network: bool = False,
http_get: HttpGet | None = None,
profile: BundleProfile = DEFAULT,
root_frontmatter_values: Mapping[str, str] | None = None,
) -> IngestResult:
"""Materialize a manifest's extractions into an OKF bundle (§5).
`ingested_at` is REQUIRED (ISO-8601 UTC with a Z suffix, stamped
verbatim) — no wall-clock default; this is what makes golden extractions
bit-deterministic. `allow_network` is the §8 per-run network opt-in: an
`http` source is refused fail-fast unless it is set — the manifest itself
cannot grant network access. `http_get` optionally injects the transport
seam (default urllib_get, the only socket path; ignored for `file`/`sql`)
so tests run socket-free (§11). Source calls are logged per §8 (which
source, when, row count) — never cell contents, never secrets.
`profile` selects the bundle contract: `DEFAULT` (commons' ingest-spec §5)
or `OKF_V0_2`. It is keyword-only behind the `*` the signature already
carried, so every three-positional call site stays source-compatible —
support for a new upstream version is additive, never a migration. The
profile governs the emitted frontmatter, the ownership stamp the collision
gate recognises, the concept filenames, and the index; it does NOT reach
manifest type validation, which runs against `DEFAULT` (the two policies
compare equal today). `STRICT_V1` is not supported here: its index policy
sets `per_directory` and `entries_match_directory`, neither of which this
materializer honours.
`root_frontmatter_values` supplies the values for the keys the profile's
index policy names — `okf_version` under `OKF_V0_2` (§8, §12). The split is
deliberate: the profile names the key, the caller owns the value, because
`okf_version`'s value tracks the upstream Google version and belongs to
catalog (E1). Offering a key the policy does not name is refused fail-fast,
before any disk mutation. Omitting the argument emits no block at all — §12
is a MAY, and none of upstream's reference bundles declares it.
The block is written only when the index is CREATED. A re-run into an
existing bundle leaves it untouched, which is what makes the second run
byte-identical to the first (A-E5).
"""
validate_ingested_at(ingested_at)
# Before any source access or disk mutation: a caller error here must not
# leave a partially written bundle behind.
root_frontmatter = _render_root_frontmatter(root_frontmatter_values or {}, profile=profile)
manifest_file = Path(manifest_path)
try:
raw = manifest_file.read_bytes()
except OSError as exc:
raise ManifestError(
f"cannot read manifest {manifest_file}: {exc}", code="manifest_unreadable"
) from exc
# §5 provenance stamp over the exact bytes that are parsed below.
stamp = f"{manifest_file.stem}@{hashlib.sha256(raw).hexdigest()[:16]}"
manifest = load_manifest_bytes(raw)
source = manifest.source
# §8 network gate: refuse fail-fast BEFORE any source access.
if isinstance(source, HttpSource) and not allow_network:
raise NetworkGateError(
"http source requires the per-run network opt-in "
"(materialize_bundle(..., allow_network=True)) — the manifest cannot "
"grant itself network access (spec §8, local-only default)",
code="network_opt_in_missing",
)
# Pinned decision (file): a relative root resolves against the manifest
# file's directory — never the process cwd, or the extraction would not
# be reproducible.
root: Path | None = None
if isinstance(source, FileSource):
root = Path(source.root)
if not root.is_absolute():
root = manifest_file.parent / root
# Stage everything in memory BEFORE any disk mutation (crash-window
# mitigation for the non-atomic §5 replace sequence; recovery is the
# idempotent re-run, §10).
staged: list[tuple[str, str]] = []
for extraction in manifest.extractions:
if isinstance(source, FileSource):
assert root is not None
header, rows = read_csv(root, extraction.query, max_rows=extraction.max_rows)
body = render_table(header, rows)
row_count = len(rows)
elif isinstance(source, SqlSource):
header, rows = read_sql(
source.connection_ref, extraction.query, max_rows=extraction.max_rows
)
body = render_table(header, rows)
row_count = len(rows)
else: # HttpSource — the gate above guarantees allow_network here.
get = http_get if http_get is not None else urllib_get
text = read_http(
source.base_url,
extraction.query,
max_rows=extraction.max_rows,
credential_ref=source.credential_ref,
get=get,
)
body = render_fenced_block(text) # verbatim, NOT table-escaped
row_count = len(text.splitlines())
_LOGGER.info(
"source call: source=%s ingested_at=%s rows=%d", source.id, ingested_at, row_count
)
content = _render_concept_file(
manifest, extraction, body, ingested_at=ingested_at, stamp=stamp, profile=profile
)
staged.append((generated_filename(extraction.id, profile=profile), content))
# Disk phase.
bundle = Path(bundle_dir)
bundle.mkdir(parents=True, exist_ok=True)
staged_names = {name for name, _ in staged}
# §3 ownership scan (sorted for determinism): only files carrying the
# ingest stamp are ours to replace. Globs by THIS door's prefix, not just
# the shared suffix: `_is_ingest_owned` reads through the line-oriented
# parser that flattens nested blocks (pinned in
# test_two_nested_block_mappings_sharing_a_key_collide_in_the_scalar_parser),
# so a Door B/C file whose nested content happens to share a key name
# with the ownership markers could otherwise spoof ownership here and get
# unlinked below — content this door never wrote. Scoping the glob to
# `ingest_prefix` closes that by construction: a Door B/C file is never
# even a candidate, regardless of what its frontmatter parses to.
owned = {
path.name
for path in sorted(
bundle.glob(f"{profile.paths.ingest_prefix}*{profile.paths.concept_suffix}")
)
if path.name != profile.index.name
and _is_ingest_owned(path, manifest_file.stem, profile=profile)
}
# §3 collision gate — BEFORE any mutation: a staged filename occupied by
# a file WITHOUT the stamp is curated content; never overwrite it.
for name in sorted(staged_names):
if (bundle / name).is_file() and name not in owned:
raise MaterializationError(
f"generated filename {name!r} collides with an existing file that does "
"not carry the ingest stamp — refusing to overwrite curated content (§3)",
code="collision_unstamped",
)
# §5 replacement: remove every stamped file, then write the new set.
for name in sorted(owned):
(bundle / name).unlink()
written = tuple(write_bytes(bundle, name, content) for name, content in staged)
# §6 index generation — the last disk mutation. A fresh index gets
# bundle_summary as its body; links are appended in extraction order.
index_path = bundle / profile.index.name
labels_by_target = {
generated_filename(extraction.id, profile=profile): extraction.title
for extraction in manifest.extractions
}
if not index_path.is_file():
write_bytes(bundle, profile.index.name, root_frontmatter + manifest.bundle_summary + "\n")
else:
# Links whose target is an ingest-owned file removed this run MUST be
# removed; all other links — curated and promoted — are preserved.
_update_index_lines(index_path, owned - staged_names, labels_by_target, profile=profile)
for extraction in manifest.extractions:
link_in_index(
bundle,
generated_filename(extraction.id, profile=profile),
extraction.title,
profile=profile,
)
return IngestResult(written=written, stamp=stamp)