llm-ingestion-okf/tests/test_profile.py
Kjell Tore Guttormsen 6f42c10608 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
2026-07-25 15:09:14 +02:00

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",
}