refactor(phase-3): the bundle contract becomes a profile object
Phase 3 step 1. What Phases 1 and 2 hard-coded about a valid bundle now lives on one frozen `BundleProfile`, and `DEFAULT` states exactly the ingest-spec v1 + Phase 2 contract. Nothing observable changes: the 425 existing tests are unmodified and green, and `git diff --stat examples/` is empty, which is assumption C1's whole proof. Both oracles are real rather than nominal — the golden suite compares `read_bytes()`, and Door B pins its frontmatter block as an exact string. Moved onto the profile, each one previously a constant with a reader: the index name and its managed-link shape (template plus pattern, kept honest by a round-trip test), the three filename namespaces (`ingest-`/`inbox-`/`import-` and `.md`), the reserved `verdict` layer (duplicated in `manifest` and `inbox` before this), the frontmatter key order, and the `source_query` whitespace collapse. Two details are worth naming. The DEFAULT key order spans both doors: Door A's seven keys and Door B's six are subsequences of one nine-key order, so a single canonical order reproduces both doors byte-for-byte. And emission follows commons decision D1 — ordered prefix, then any remaining keys sorted — which is the mechanism the proving consumer's hash registry needs; under DEFAULT the tail is always empty. `TypePolicy` refuses the reserved layer at CONSTRUCTION (assumption C3), so a profile admitting `verdict` cannot be built, let alone passed to a door. It reports refusals rather than raising them, because Door A refuses with `ManifestError` and Door B with `MaterializationError` for the same type — the wording and the stable code come from the policy, the exception class stays each door's own. Deliberately NOT moved, each for a stated reason: the required and allowlisted key sets the proving consumer needs (no code reads them until the STRICT_V1 validator exists, and a field nothing reads is a claim nothing tests), the id grammar (a pattern the slugger derives a separator class from, not a flat name), `NAME_MAX_BYTES` (a filesystem fact, not a contract choice), and every disposition/origin/channel vocabulary (guard territory, always). The profile is also not exported from the package root yet — the optional `profile` argument on the flows is step 3's plumbing change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2aKJxLejT9S8jYwoZ9fut
This commit is contained in:
parent
08ca68fee2
commit
6f42c10608
6 changed files with 470 additions and 77 deletions
203
src/llm_ingestion_okf/profiles.py
Normal file
203
src/llm_ingestion_okf/profiles.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""The bundle contract as configuration (Phase 3).
|
||||
|
||||
What a valid bundle looks like — which concept types exist, which frontmatter
|
||||
keys are emitted and in which order, which filename namespaces the doors own,
|
||||
and what an index line looks like — is a profile, not a set of constants
|
||||
scattered across the doors. `DEFAULT` is exactly the ingest-spec v1 + Phase 2
|
||||
contract, so nothing observable changes for a caller that never mentions a
|
||||
profile; the golden fixtures are the byte-level proof.
|
||||
|
||||
Two things deliberately do NOT live here. Security is the guard's, always: no
|
||||
disposition, origin or channel vocabulary belongs on a profile. And the
|
||||
reserved `verdict` layer is a spec invariant rather than profile config — it is
|
||||
refused at construction, so a profile admitting it cannot be built, let alone
|
||||
passed to a door.
|
||||
|
||||
Profiles are constructed in code. Config-file loading and inheritance chains
|
||||
are extension points, not v1 (settled with the operator at phase start).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# The one layer no profile may admit (ingest-spec §3): the promotion gate is
|
||||
# the only path into it. Compared case-insensitively, as both doors already do.
|
||||
RESERVED_OKF_TYPE = "verdict"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TypeRejection:
|
||||
"""Why a profile refuses an `okf_type`, for the door to frame and raise.
|
||||
|
||||
The policy does not raise: Door A refuses with `ManifestError` and Door B
|
||||
with `MaterializationError`, so the refusal has to be reported rather than
|
||||
thrown. `reason` completes the sentence "<label>okf_type ..." and `code` is
|
||||
the stable `IngestError.code` the caller asserts on.
|
||||
"""
|
||||
|
||||
reason: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TypePolicy:
|
||||
"""Which `okf_type` values a bundle admits.
|
||||
|
||||
`allowed` is `None` for an open set — Phase 1/2 accept any type the
|
||||
operator names — or a closed enum. Either way the reserved layer is
|
||||
excluded, and a closed set that names it fails at construction.
|
||||
"""
|
||||
|
||||
allowed: frozenset[str] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.allowed is None:
|
||||
return
|
||||
named = sorted(value for value in self.allowed if value.lower() == RESERVED_OKF_TYPE)
|
||||
if named:
|
||||
raise ValueError(
|
||||
f"a profile must not admit the reserved {RESERVED_OKF_TYPE!r} layer "
|
||||
f"(got {', '.join(repr(value) for value in named)}) — the promotion "
|
||||
"gate is the only path into it (ingest-spec §3)"
|
||||
)
|
||||
|
||||
def rejection(self, okf_type: str) -> TypeRejection | None:
|
||||
"""The refusal for `okf_type`, or `None` when the profile admits it."""
|
||||
if okf_type.lower() == RESERVED_OKF_TYPE:
|
||||
return TypeRejection(
|
||||
reason=f"must not be {RESERVED_OKF_TYPE!r} (reserved layer)",
|
||||
code="okf_type_reserved",
|
||||
)
|
||||
if self.allowed is not None and okf_type not in self.allowed:
|
||||
return TypeRejection(
|
||||
reason=f"must be one of {', '.join(sorted(self.allowed))}, got {okf_type!r}",
|
||||
code="okf_type_not_allowed",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrontmatterSchema:
|
||||
"""The frontmatter key namespace and how it is emitted.
|
||||
|
||||
`order` is the canonical emission order and `collapsed_keys` names the keys
|
||||
whose whitespace runs collapse to single spaces on the way out. The DEFAULT
|
||||
order spans both doors' key sets: Door A emits seven of these keys and Door
|
||||
B six, and each door's subset comes out in exactly the order it wrote by
|
||||
hand in Phases 1 and 2.
|
||||
|
||||
The required/allowlisted key sets the proving consumer needs (see
|
||||
`docs/phase-3-split-table.md`) are not here yet: no code reads them until
|
||||
the STRICT_V1 validator exists, and a field nothing reads is a claim
|
||||
nothing tests.
|
||||
"""
|
||||
|
||||
order: tuple[str, ...]
|
||||
collapsed_keys: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
def emit(self, values: Mapping[str, str]) -> str:
|
||||
"""Render `values` as line-oriented `key: value`, one line per key.
|
||||
|
||||
Commons decision D1: the keys named in `order` come first, in that
|
||||
order, followed by any remaining keys SORTED. Ordering the tail rather
|
||||
than trusting insertion order is what makes a regeneration over the
|
||||
same data byte-identical.
|
||||
|
||||
Returns the lines only — the caller owns the `---` fences.
|
||||
"""
|
||||
named = [key for key in self.order if key in values]
|
||||
tail = sorted(key for key in values if key not in self.order)
|
||||
return "\n".join(f"{key}: {self._render(key, values[key])}" for key in [*named, *tail])
|
||||
|
||||
def _render(self, key: str, value: str) -> str:
|
||||
# §5 mandates whitespace-run collapse for `source_query` only
|
||||
# (ingest-spec.md:140-141), where a legitimately multi-line SQL SELECT
|
||||
# must render on one line. Every other value is validated single-line
|
||||
# at load and emitted verbatim — validation, not repair — so
|
||||
# operator-supplied bytes (e.g. a title's internal double space)
|
||||
# survive.
|
||||
return " ".join(value.split()) if key in self.collapsed_keys else value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathPolicy:
|
||||
"""The filename namespaces the three doors write into.
|
||||
|
||||
Each prefix keeps its door's generated names disjoint from `index.md`, from
|
||||
the other doors, and from `promoted-verdict-*`, for every id the grammar
|
||||
admits. The id grammar itself stays in `materialize`: it is a pattern the
|
||||
slugger derives a separator class from, not a name to configure.
|
||||
"""
|
||||
|
||||
concept_suffix: str
|
||||
ingest_prefix: str
|
||||
inbox_prefix: str
|
||||
import_prefix: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexPolicy:
|
||||
"""The index file and the shape of the links this library manages in it.
|
||||
|
||||
`link_template` renders a managed line and `link_pattern` recognises one.
|
||||
Both are carried because §6 does both — append on write, rewrite on
|
||||
maintenance — and a round-trip test is what keeps the pair honest. The
|
||||
pattern is anchored to the whole line by construction: removal keys on this
|
||||
exact shape, never a bare substring, so curated prose that mentions a
|
||||
target inline survives verbatim.
|
||||
"""
|
||||
|
||||
name: str
|
||||
link_template: str
|
||||
link_pattern: re.Pattern[str]
|
||||
|
||||
def render_link(self, label: str, target: str) -> str:
|
||||
return self.link_template.format(label=label, target=target)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleProfile:
|
||||
"""One bundle contract: types, frontmatter, filenames, index."""
|
||||
|
||||
types: TypePolicy
|
||||
frontmatter: FrontmatterSchema
|
||||
paths: PathPolicy
|
||||
index: IndexPolicy
|
||||
|
||||
|
||||
# The ingest-spec v1 + Phase 2 contract, unchanged. Every value here was a
|
||||
# constant in `manifest`, `materialize`, `inbox` or `importer` before this
|
||||
# module existed; the golden suite is what proves the move changed no bytes.
|
||||
DEFAULT = BundleProfile(
|
||||
types=TypePolicy(allowed=None),
|
||||
frontmatter=FrontmatterSchema(
|
||||
# Door A's seven keys and Door B's six, merged into one order that
|
||||
# contains both as subsequences — neither door's output moves.
|
||||
order=(
|
||||
"type",
|
||||
"title",
|
||||
"source_system",
|
||||
"source_query",
|
||||
"source_file",
|
||||
"source_sha256",
|
||||
"ingested_at",
|
||||
"ingest_manifest",
|
||||
"generated",
|
||||
),
|
||||
collapsed_keys=frozenset({"source_query"}),
|
||||
),
|
||||
paths=PathPolicy(
|
||||
concept_suffix=".md",
|
||||
ingest_prefix="ingest-",
|
||||
inbox_prefix="inbox-",
|
||||
import_prefix="import-",
|
||||
),
|
||||
index=IndexPolicy(
|
||||
name="index.md",
|
||||
link_template="- [{label}]({target})",
|
||||
link_pattern=re.compile(r"^- \[(?P<label>[^\]]*)\]\((?P<target>[^)]+)\)$"),
|
||||
),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue