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
|
|
@ -34,20 +34,18 @@ from typing import Protocol
|
|||
from .errors import IngestError, MaterializationError, SourceError
|
||||
from .extract import decode_text
|
||||
from .materialize import (
|
||||
INDEX_NAME,
|
||||
check_filename_length,
|
||||
link_in_index,
|
||||
reduce_to_id_grammar,
|
||||
validate_ingested_at,
|
||||
write_bytes,
|
||||
)
|
||||
from .profiles import DEFAULT
|
||||
|
||||
# An OKF concept is a `.md` document by definition — the guard's path gate
|
||||
# rejects anything else outright — so nothing else in the source tree is a
|
||||
# concept, and nothing else is this door's to merge.
|
||||
_CONCEPT_SUFFIX = ".md"
|
||||
|
||||
_FILENAME_PREFIX = "import-"
|
||||
# concept, and nothing else is this door's to merge. The suffix and the
|
||||
# `import-` namespace are the profile's (`DEFAULT.paths`).
|
||||
|
||||
# The guard's non-blocking floor and its review queue, by VALUE (`Disposition`
|
||||
# is a `str, Enum`, so the value is the stable thing to compare against across
|
||||
|
|
@ -176,7 +174,7 @@ def import_slug(concept_path: str) -> str:
|
|||
collapse them onto one filename. A path that reduces to nothing fails fast
|
||||
rather than being given an invented name.
|
||||
"""
|
||||
concept_id = concept_path[: -len(_CONCEPT_SUFFIX)]
|
||||
concept_id = concept_path[: -len(DEFAULT.paths.concept_suffix)]
|
||||
slug = reduce_to_id_grammar(concept_id)
|
||||
if not slug:
|
||||
raise MaterializationError(
|
||||
|
|
@ -195,7 +193,8 @@ def import_filename(slug: str) -> str:
|
|||
grammar admits.
|
||||
"""
|
||||
return check_filename_length(
|
||||
f"{_FILENAME_PREFIX}{slug}{_CONCEPT_SUFFIX}", code="import_path_too_long"
|
||||
f"{DEFAULT.paths.import_prefix}{slug}{DEFAULT.paths.concept_suffix}",
|
||||
code="import_path_too_long",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -206,7 +205,7 @@ def _index_label(concept_path: str) -> str:
|
|||
does not. Fail-fast, never repair — the same rule Door A applies to a
|
||||
manifest title and Door B to a dropped filename.
|
||||
"""
|
||||
label = concept_path[: -len(_CONCEPT_SUFFIX)]
|
||||
label = concept_path[: -len(DEFAULT.paths.concept_suffix)]
|
||||
if any(char in label for char in "\n\r[]"):
|
||||
raise MaterializationError(
|
||||
f"concept path {concept_path!r} contains '[' or ']', which would break "
|
||||
|
|
@ -229,7 +228,7 @@ def _read_bundle(source: Path) -> tuple[dict[str, str], list[FailedConcept]]:
|
|||
# glob: glob case-sensitivity follows the FILESYSTEM, so `NOTE.MD`
|
||||
# would be a concept on APFS and not one on ext4 — the same bundle
|
||||
# importing differently per platform. The guard folds case here too.
|
||||
if not path.is_file() or path.suffix.lower() != _CONCEPT_SUFFIX:
|
||||
if not path.is_file() or path.suffix.lower() != DEFAULT.paths.concept_suffix:
|
||||
continue
|
||||
concept_path = path.relative_to(source).as_posix()
|
||||
try:
|
||||
|
|
@ -409,9 +408,9 @@ def import_bundle(
|
|||
|
||||
# §6 index — the last disk mutation, and only when something merged.
|
||||
if merged:
|
||||
index_path = bundle / INDEX_NAME
|
||||
index_path = bundle / DEFAULT.index.name
|
||||
if not index_path.is_file():
|
||||
write_bytes(bundle, INDEX_NAME, "")
|
||||
write_bytes(bundle, DEFAULT.index.name, "")
|
||||
for entry in merged:
|
||||
link_in_index(bundle, entry.path.name, _index_label(entry.concept_path))
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ from pathlib import Path
|
|||
from .errors import IngestError, MaterializationError, SourceError
|
||||
from .extract import extract_text
|
||||
from .materialize import (
|
||||
INDEX_NAME,
|
||||
check_filename_length,
|
||||
link_in_index,
|
||||
parse_frontmatter,
|
||||
|
|
@ -36,11 +35,7 @@ from .materialize import (
|
|||
validate_ingested_at,
|
||||
write_bytes,
|
||||
)
|
||||
|
||||
_RESERVED_OKF_TYPE = "verdict"
|
||||
|
||||
_FILENAME_PREFIX = "inbox-"
|
||||
_FILENAME_SUFFIX = ".md"
|
||||
from .profiles import DEFAULT
|
||||
|
||||
|
||||
def inbox_slug(source_filename: str) -> str:
|
||||
|
|
@ -73,7 +68,8 @@ def inbox_filename(slug: str) -> str:
|
|||
not give it.
|
||||
"""
|
||||
return check_filename_length(
|
||||
f"{_FILENAME_PREFIX}{slug}{_FILENAME_SUFFIX}", code="inbox_slug_too_long"
|
||||
f"{DEFAULT.paths.inbox_prefix}{slug}{DEFAULT.paths.concept_suffix}",
|
||||
code="inbox_slug_too_long",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -104,12 +100,11 @@ def render_inbox_concept(
|
|||
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.
|
||||
if okf_type.lower() == _RESERVED_OKF_TYPE:
|
||||
raise MaterializationError(
|
||||
f"okf_type must not be {_RESERVED_OKF_TYPE!r} (reserved layer)",
|
||||
code="okf_type_reserved",
|
||||
)
|
||||
# it, at this door exactly as at Door A's manifest validation — the same
|
||||
# profile decides, each door raises its own typed error.
|
||||
rejection = DEFAULT.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[]"):
|
||||
|
|
@ -131,8 +126,7 @@ def render_inbox_concept(
|
|||
"ingested_at": ingested_at,
|
||||
"generated": "true",
|
||||
}
|
||||
rendered = "\n".join(f"{key}: {value}" for key, value in frontmatter.items())
|
||||
return f"---\n{rendered}\n---\n\n{_normalize_body(text)}"
|
||||
return f"---\n{DEFAULT.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
|
||||
|
||||
|
||||
# --- the guard seam -------------------------------------------------------
|
||||
|
|
@ -248,11 +242,9 @@ def process_inbox(
|
|||
a reserved `okf_type`, and a missing inbox directory.
|
||||
"""
|
||||
validate_ingested_at(ingested_at)
|
||||
if okf_type.lower() == _RESERVED_OKF_TYPE:
|
||||
raise MaterializationError(
|
||||
f"okf_type must not be {_RESERVED_OKF_TYPE!r} (reserved layer)",
|
||||
code="okf_type_reserved",
|
||||
)
|
||||
run_rejection = DEFAULT.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")
|
||||
|
|
@ -299,7 +291,11 @@ def process_inbox(
|
|||
# pre-existing curated content by a later file's check.
|
||||
bundle = Path(bundle_dir)
|
||||
pre_existing = (
|
||||
{path.name for path in bundle.glob("*.md") if path.name != INDEX_NAME}
|
||||
{
|
||||
path.name
|
||||
for path in bundle.glob(f"*{DEFAULT.paths.concept_suffix}")
|
||||
if path.name != DEFAULT.index.name
|
||||
}
|
||||
if bundle.is_dir()
|
||||
else set()
|
||||
)
|
||||
|
|
@ -369,9 +365,9 @@ def process_inbox(
|
|||
|
||||
# §6 index — the last disk mutation, and only when something was written.
|
||||
if persisted:
|
||||
index_path = bundle / INDEX_NAME
|
||||
index_path = bundle / DEFAULT.index.name
|
||||
if not index_path.is_file():
|
||||
write_bytes(bundle, INDEX_NAME, "")
|
||||
write_bytes(bundle, DEFAULT.index.name, "")
|
||||
for entry in persisted:
|
||||
link_in_index(
|
||||
bundle, entry.path.name, unicodedata.normalize("NFC", Path(entry.source_file).stem)
|
||||
|
|
|
|||
|
|
@ -15,11 +15,10 @@ from pathlib import Path
|
|||
from typing import Any, Union
|
||||
|
||||
from .errors import ManifestError
|
||||
from .profiles import DEFAULT
|
||||
|
||||
_ID_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*\Z")
|
||||
|
||||
_RESERVED_OKF_TYPE = "verdict"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSource:
|
||||
|
|
@ -66,7 +65,7 @@ def generated_filename(extraction_id: str) -> str:
|
|||
The `ingest-` prefix keeps the namespace disjoint from `index.md` and
|
||||
`promoted-verdict-*` (spec §3) for every id the §4 grammar admits.
|
||||
"""
|
||||
return f"ingest-{extraction_id}.md"
|
||||
return f"{DEFAULT.paths.ingest_prefix}{extraction_id}{DEFAULT.paths.concept_suffix}"
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> Manifest:
|
||||
|
|
@ -193,11 +192,12 @@ def _validate_extraction(data: object, index: int) -> Extraction:
|
|||
|
||||
okf_type = _require_str(obj["okf_type"], f"{label}.okf_type")
|
||||
# The verdict layer is RESERVED (spec §3): the promotion gate is the only
|
||||
# path into it — enforced here, fail-fast, before any source call.
|
||||
if okf_type.lower() == _RESERVED_OKF_TYPE:
|
||||
raise ManifestError(
|
||||
f"{label}.okf_type must not be 'verdict' (reserved layer)", code="okf_type_reserved"
|
||||
)
|
||||
# path into it — enforced here, fail-fast, before any source call. The
|
||||
# profile decides which types a bundle admits; the door raises its own
|
||||
# typed error, since Door B refuses the same type as a MaterializationError.
|
||||
rejection = DEFAULT.types.rejection(okf_type)
|
||||
if rejection is not None:
|
||||
raise ManifestError(f"{label}.okf_type {rejection.reason}", code=rejection.code)
|
||||
|
||||
max_rows = obj["max_rows"]
|
||||
if not _is_int(max_rows) or max_rows < 1:
|
||||
|
|
|
|||
|
|
@ -27,19 +27,13 @@ from .manifest import (
|
|||
generated_filename,
|
||||
load_manifest_bytes,
|
||||
)
|
||||
from .profiles import DEFAULT
|
||||
from .render import render_fenced_block, render_table
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
INDEX_NAME = "index.md"
|
||||
_INGESTED_AT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
||||
|
||||
# One managed index line: `- [<label>](<target>)`. Anchored full-line —
|
||||
# removal keys on this exact shape for specific ingest targets, never a bare
|
||||
# substring (a promoted verdict's line has the same shape but a non-ingest
|
||||
# target; curated prose mentioning a target inline does not match).
|
||||
_MANAGED_LINE_RE = re.compile(r"^- \[(?P<label>[^\]]*)\]\((?P<target>[^)]+)\)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IngestResult:
|
||||
|
|
@ -120,24 +114,6 @@ def check_filename_length(name: str, *, code: str) -> str:
|
|||
return name
|
||||
|
||||
|
||||
def _collapse_whitespace(value: str) -> str:
|
||||
# §5 mandates whitespace-run collapse for ONE field only: `source_query`
|
||||
# (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
|
||||
# manifest load and emitted verbatim — validation, not repair — so
|
||||
# operator-supplied bytes (e.g. a title's internal double space) survive.
|
||||
return " ".join(value.split())
|
||||
|
||||
|
||||
def _render_frontmatter(frontmatter: dict[str, str]) -> str:
|
||||
# Line-oriented `key: value`, insertion order. Only `source_query` is
|
||||
# collapsed; all other values pass through verbatim.
|
||||
return "\n".join(
|
||||
f"{key}: {_collapse_whitespace(value) if key == 'source_query' else value}"
|
||||
for key, value in frontmatter.items()
|
||||
)
|
||||
|
||||
|
||||
def parse_frontmatter(path: Path) -> dict[str, str]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
|
|
@ -176,7 +152,8 @@ def _is_ingest_owned(path: Path, manifest_stem: str) -> bool:
|
|||
def _render_concept_file(
|
||||
manifest: Manifest, extraction: Extraction, body: str, *, ingested_at: str, stamp: str
|
||||
) -> str:
|
||||
# §5 frontmatter: exactly these keys, in exactly this order.
|
||||
# §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,
|
||||
|
|
@ -186,7 +163,7 @@ def _render_concept_file(
|
|||
"ingest_manifest": stamp,
|
||||
"generated": "true",
|
||||
}
|
||||
return f"---\n{_render_frontmatter(frontmatter)}\n---\n\n{body}"
|
||||
return f"---\n{DEFAULT.frontmatter.emit(frontmatter)}\n---\n\n{body}"
|
||||
|
||||
|
||||
def write_bytes(bundle_dir: Path, name: str, content: str) -> Path:
|
||||
|
|
@ -213,7 +190,7 @@ def _update_index_lines(
|
|||
for line in lines:
|
||||
content = line.rstrip("\r\n")
|
||||
ending = line[len(content) :]
|
||||
match = _MANAGED_LINE_RE.match(content)
|
||||
match = DEFAULT.index.link_pattern.match(content)
|
||||
if match is not None:
|
||||
target = match.group("target")
|
||||
if target in removed_targets:
|
||||
|
|
@ -221,7 +198,7 @@ def _update_index_lines(
|
|||
continue
|
||||
new_label = labels_by_target.get(target)
|
||||
if new_label is not None and match.group("label") != new_label:
|
||||
line = f"- [{new_label}]({target})" + ending
|
||||
line = DEFAULT.index.render_link(new_label, target) + ending
|
||||
changed = True
|
||||
updated.append(line)
|
||||
if changed:
|
||||
|
|
@ -231,7 +208,7 @@ def _update_index_lines(
|
|||
def link_in_index(bundle_dir: Path, target_name: str, label: str) -> None:
|
||||
# §6: idempotent by target — a link whose target is already present in
|
||||
# the index is never added twice.
|
||||
index_path = safe_resolve(bundle_dir, INDEX_NAME)
|
||||
index_path = safe_resolve(bundle_dir, DEFAULT.index.name)
|
||||
body = index_path.read_bytes().decode("utf-8")
|
||||
if f"]({target_name})" in body:
|
||||
return
|
||||
|
|
@ -239,7 +216,8 @@ def link_in_index(bundle_dir: Path, target_name: str, label: str) -> None:
|
|||
# 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"
|
||||
index_path.write_bytes(f"{prefix}- [{label}]({target_name})\n".encode())
|
||||
line = DEFAULT.index.render_link(label, target_name)
|
||||
index_path.write_bytes(f"{prefix}{line}\n".encode())
|
||||
|
||||
|
||||
def materialize_bundle(
|
||||
|
|
@ -336,8 +314,8 @@ def materialize_bundle(
|
|||
# ingest stamp are ours to replace.
|
||||
owned = {
|
||||
path.name
|
||||
for path in sorted(bundle.glob("*.md"))
|
||||
if path.name != INDEX_NAME and _is_ingest_owned(path, manifest_file.stem)
|
||||
for path in sorted(bundle.glob(f"*{DEFAULT.paths.concept_suffix}"))
|
||||
if path.name != DEFAULT.index.name and _is_ingest_owned(path, manifest_file.stem)
|
||||
}
|
||||
# §3 collision gate — BEFORE any mutation: a staged filename occupied by
|
||||
# a file WITHOUT the stamp is curated content; never overwrite it.
|
||||
|
|
@ -356,12 +334,12 @@ def materialize_bundle(
|
|||
|
||||
# §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 / INDEX_NAME
|
||||
index_path = bundle / DEFAULT.index.name
|
||||
labels_by_target = {
|
||||
generated_filename(extraction.id): extraction.title for extraction in manifest.extractions
|
||||
}
|
||||
if not index_path.is_file():
|
||||
write_bytes(bundle, INDEX_NAME, manifest.bundle_summary + "\n")
|
||||
write_bytes(bundle, DEFAULT.index.name, 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.
|
||||
|
|
|
|||
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>[^)]+)\)$"),
|
||||
),
|
||||
)
|
||||
217
tests/test_profile.py
Normal file
217
tests/test_profile.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""The bundle contract as configuration (Phase 3, step 1).
|
||||
|
||||
What Phases 1 and 2 hard-coded about a valid bundle — the index name and its
|
||||
managed-link shape, the three filename namespaces, the reserved layer, and the
|
||||
frontmatter key order — becomes a profile object here. `DEFAULT` must express
|
||||
exactly the existing contract: the Phase 1/2 suite and the golden fixtures are
|
||||
the byte-level proof (assumption C1), and these tests pin the profile's own
|
||||
behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_okf.profiles import (
|
||||
DEFAULT,
|
||||
BundleProfile,
|
||||
FrontmatterSchema,
|
||||
TypePolicy,
|
||||
)
|
||||
|
||||
|
||||
def test_the_profile_and_every_policy_on_it_are_frozen() -> None:
|
||||
"""A profile is a value, not a mutable registry.
|
||||
|
||||
A consumer holds DEFAULT while a run is in flight; a profile that could be
|
||||
mutated mid-run would make the contract a moving target and the golden
|
||||
determinism claim unprovable.
|
||||
"""
|
||||
for value in (DEFAULT, DEFAULT.types, DEFAULT.frontmatter, DEFAULT.paths, DEFAULT.index):
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
value.name = "mutated" # type: ignore[misc]
|
||||
|
||||
|
||||
def test_default_profile_pins_the_phase_1_2_names() -> None:
|
||||
"""The names the two doors already write, now stated in one place."""
|
||||
assert DEFAULT.index.name == "index.md"
|
||||
assert DEFAULT.paths.concept_suffix == ".md"
|
||||
assert DEFAULT.paths.ingest_prefix == "ingest-"
|
||||
assert DEFAULT.paths.inbox_prefix == "inbox-"
|
||||
assert DEFAULT.paths.import_prefix == "import-"
|
||||
|
||||
|
||||
# --- the reserved layer (assumption C3) -----------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reserved", ["verdict", "Verdict", "VERDICT"])
|
||||
def test_no_type_policy_can_admit_the_reserved_verdict_layer(reserved: str) -> None:
|
||||
"""C3: the verdict reservation is a spec invariant, not profile config.
|
||||
|
||||
Refused at CONSTRUCTION, not at use: a profile that admits `verdict` must
|
||||
not exist at all, or the promotion gate stops being the only path into that
|
||||
layer the moment someone passes the profile to a door.
|
||||
"""
|
||||
with pytest.raises(ValueError, match="verdict"):
|
||||
TypePolicy(allowed=frozenset({"Concept", reserved}))
|
||||
|
||||
|
||||
def test_a_closed_type_policy_without_the_reserved_layer_constructs() -> None:
|
||||
policy = TypePolicy(allowed=frozenset({"Concept", "Guide", "Reference", "Release"}))
|
||||
assert policy.rejection("Concept") is None
|
||||
|
||||
|
||||
def test_default_type_policy_is_open_apart_from_the_reserved_layer() -> None:
|
||||
"""Phase 1/2 accept any okf_type but `verdict`; DEFAULT must not narrow that."""
|
||||
assert DEFAULT.types.allowed is None
|
||||
for admitted in ("Concept", "note", "whatever-the-operator-wants"):
|
||||
assert DEFAULT.types.rejection(admitted) is None
|
||||
for reserved in ("verdict", "Verdict", "VERDICT"):
|
||||
assert DEFAULT.types.rejection(reserved) is not None
|
||||
|
||||
|
||||
def test_the_reserved_rejection_reproduces_both_doors_message_and_code() -> None:
|
||||
"""The doors keep their own typed errors; the policy supplies the wording.
|
||||
|
||||
Door A raises ManifestError and Door B MaterializationError for the same
|
||||
refusal, so the policy cannot raise — it hands back the reason and the
|
||||
stable code, and each door frames it. These are the exact strings the
|
||||
Phase 1/2 tests already assert on.
|
||||
"""
|
||||
rejection = DEFAULT.types.rejection("verdict")
|
||||
assert rejection is not None
|
||||
assert rejection.code == "okf_type_reserved"
|
||||
assert f"okf_type {rejection.reason}" == "okf_type must not be 'verdict' (reserved layer)"
|
||||
|
||||
|
||||
def test_a_closed_type_policy_rejects_an_off_enum_type_distinctly() -> None:
|
||||
"""An off-enum refusal is not the reserved-layer refusal wearing its code."""
|
||||
policy = TypePolicy(allowed=frozenset({"Concept", "Release"}))
|
||||
rejection = policy.rejection("Guide")
|
||||
assert rejection is not None
|
||||
assert rejection.code == "okf_type_not_allowed"
|
||||
assert "Concept, Release" in rejection.reason
|
||||
|
||||
|
||||
# --- index policy ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_index_link_template_and_pattern_describe_the_same_shape() -> None:
|
||||
"""Two fields that must agree, proven to agree rather than trusted to.
|
||||
|
||||
Rendering and parsing managed index links are separate operations (§6
|
||||
appends, and maintenance rewrites), so the profile carries both a template
|
||||
and a pattern. A round-trip is what keeps them from drifting apart.
|
||||
"""
|
||||
for label, target in [("Title", "ingest-x.md"), ("", "inbox-y.md")]:
|
||||
line = DEFAULT.index.render_link(label, target)
|
||||
match = DEFAULT.index.link_pattern.match(line)
|
||||
assert match is not None, f"pattern does not match its own template output: {line!r}"
|
||||
assert match.group("label") == label
|
||||
assert match.group("target") == target
|
||||
|
||||
|
||||
def test_default_index_link_is_the_phase_1_shape() -> None:
|
||||
assert DEFAULT.index.render_link("Sales", "ingest-sales.md") == "- [Sales](ingest-sales.md)"
|
||||
|
||||
|
||||
def test_index_link_pattern_is_anchored_to_the_whole_line() -> None:
|
||||
"""§6 removal keys on the exact shape, never a bare substring — a curated
|
||||
line that merely mentions a link must survive verbatim."""
|
||||
assert DEFAULT.index.link_pattern.match("see - [Sales](ingest-sales.md) below") is None
|
||||
|
||||
|
||||
# --- frontmatter emission -------------------------------------------------
|
||||
|
||||
|
||||
def test_emission_is_the_ordered_prefix_then_the_sorted_tail() -> None:
|
||||
"""Commons decision D1, carried over intact.
|
||||
|
||||
Keys named in the profile's order come first in that order; anything else
|
||||
follows sorted. That is what makes a regeneration over the same data
|
||||
byte-identical, which is what the proving consumer's hash registry needs.
|
||||
"""
|
||||
schema = FrontmatterSchema(order=("type", "title"))
|
||||
emitted = schema.emit({"zeta": "3", "title": "T", "alpha": "1", "type": "Concept"})
|
||||
assert emitted == "type: Concept\ntitle: T\nalpha: 1\nzeta: 3"
|
||||
|
||||
|
||||
def test_emission_skips_ordered_keys_that_are_absent() -> None:
|
||||
schema = FrontmatterSchema(order=("type", "title", "generated"))
|
||||
assert schema.emit({"generated": "true", "type": "Concept"}) == "type: Concept\ngenerated: true"
|
||||
|
||||
|
||||
def test_default_emission_reproduces_door_a_key_order() -> None:
|
||||
"""Byte-identity with `_render_concept_file`'s §5 frontmatter."""
|
||||
emitted = DEFAULT.frontmatter.emit(
|
||||
{
|
||||
"type": "Concept",
|
||||
"title": "Sales",
|
||||
"source_system": "crm",
|
||||
"source_query": "sales.csv",
|
||||
"ingested_at": "2026-07-03T12:00:00Z",
|
||||
"ingest_manifest": "m@0123456789abcdef",
|
||||
"generated": "true",
|
||||
}
|
||||
)
|
||||
assert emitted.splitlines() == [
|
||||
"type: Concept",
|
||||
"title: Sales",
|
||||
"source_system: crm",
|
||||
"source_query: sales.csv",
|
||||
"ingested_at: 2026-07-03T12:00:00Z",
|
||||
"ingest_manifest: m@0123456789abcdef",
|
||||
"generated: true",
|
||||
]
|
||||
|
||||
|
||||
def test_default_emission_reproduces_door_b_key_order() -> None:
|
||||
"""Byte-identity with `render_inbox_concept`'s provenance layer.
|
||||
|
||||
Door B's six keys are a different subset of the same schema, and the
|
||||
canonical order must reproduce BOTH doors — a single order that reordered
|
||||
either one would change bytes already frozen in the fixtures.
|
||||
"""
|
||||
emitted = DEFAULT.frontmatter.emit(
|
||||
{
|
||||
"type": "Concept",
|
||||
"title": "Notes",
|
||||
"source_file": "notes.md",
|
||||
"source_sha256": "abc",
|
||||
"ingested_at": "2026-07-03T12:00:00Z",
|
||||
"generated": "true",
|
||||
}
|
||||
)
|
||||
assert emitted.splitlines() == [
|
||||
"type: Concept",
|
||||
"title: Notes",
|
||||
"source_file: notes.md",
|
||||
"source_sha256: abc",
|
||||
"ingested_at: 2026-07-03T12:00:00Z",
|
||||
"generated: true",
|
||||
]
|
||||
|
||||
|
||||
def test_source_query_is_the_only_collapsed_key() -> None:
|
||||
"""§5 collapses whitespace runs for `source_query` alone (a multi-line
|
||||
SELECT must render on one line); every other value is validated single-line
|
||||
at load and emitted verbatim, so an operator's internal double space
|
||||
survives."""
|
||||
assert DEFAULT.frontmatter.collapsed_keys == frozenset({"source_query"})
|
||||
emitted = DEFAULT.frontmatter.emit(
|
||||
{"title": "two spaces", "source_query": "SELECT a\n FROM t"}
|
||||
)
|
||||
assert emitted == "title: two spaces\nsource_query: SELECT a FROM t"
|
||||
|
||||
|
||||
def test_a_profile_is_assembled_from_its_policies() -> None:
|
||||
"""The profile is the four policies and nothing else — no behavior branches
|
||||
outside what the object expresses."""
|
||||
assert {field.name for field in dataclasses.fields(BundleProfile)} == {
|
||||
"types",
|
||||
"frontmatter",
|
||||
"paths",
|
||||
"index",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue