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
217 lines
8.5 KiB
Python
217 lines
8.5 KiB
Python
"""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",
|
|
}
|