Every fixture, test document, tool example and document now uses an invented kitchen-and-baking handbook series, written in this repository. The package's behaviour is unchanged; src/ changes are comments and help text only. - Generated fixtures are regenerated from their generators. Their structural counts are identical before and after: elements, images, rows, cells, headings, bookmarks and the witness inventory's per-document totals. The image-inbox and accounting documents are renamed kapittel-84-*. - tools/okf_accounting_gate.py: the two options that named one real corpus each are replaced by a generic, repeatable --corpus PATH with no default. Row 5 compares the PDF pair alone. Gate verdict unchanged: RED rows 2, 3, 6. - tools/okf_witness.py: the STS JSON reader for one publisher's delivery is removed, along with its three twins and five tests. The mutation harness loses W09. - docs/: 13 dated reports that documented runs on a retired reference corpus are removed, and 40 are neutralized. Dead links are removed, and no new dangling path is introduced. - The synthetic MCP-gate corpus and the residual probe words are neutral. Valgt: keep the `okf quality --fasit` bar value (the measured fraction, one corpus) and rewrite only its provenance, because the verdict stays unchanged and the number names nothing. Term check with the local list: 0 of 411 tracked files, 0 file names, 0 of 27 binary fixtures. Suite after git add: 2457 passed, 1 skipped. The base tree had 2460 passed and 2 skipped; five tests went with the JSON reader and four were added by the term check. ruff, ruff format and mypy --strict src/ are clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
6.7 KiB
Python
154 lines
6.7 KiB
Python
"""A faceted index: the entry carries the metadata its document carries.
|
|
|
|
The measured defect this closes, as data. ms-ai-architect ran a pre-registered
|
|
bake-off on 2026-08-26 over 55 documents and 40 gold questions; every arm hit
|
|
40/40, so retrieval quality could not separate them. The only axis that did
|
|
separate them was trap exposure — 18/20 for the OKF-index arm against 8/20 for
|
|
a frontmatter head-scan — and the reason was measured on both sides
|
|
independently: the flat DEFAULT index carries title/date/status/supersedes
|
|
0 times while the documents in the same bundle carry them 55/55/55/5.
|
|
|
|
The metadata IS in the bundle. The index throws it away. A facet policy is what
|
|
lets an index keep it, at a cost the index arm can still afford.
|
|
|
|
DEFAULT is untouched, and that is not caution: DEFAULT states commons'
|
|
ingest-spec §6 layer, so changing its bytes from here would be this repo
|
|
editing another repo's contract. Facets arrive as a new profile, additively.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_okf.profiles import DEFAULT, STRICT_V1, STRUCTURED_V1, FacetPolicy, IndexPolicy
|
|
|
|
|
|
def test_default_carries_no_facets_and_renders_exactly_what_it_did() -> None:
|
|
# The byte-level proof that this feature is additive.
|
|
assert DEFAULT.index.facets is None
|
|
assert DEFAULT.index.render_link("A Title", "inbox-a.md") == "- [A Title](inbox-a.md)"
|
|
assert DEFAULT.index.entry_pattern is DEFAULT.index.link_pattern
|
|
assert STRICT_V1.index.facets is None
|
|
|
|
|
|
def test_a_faceted_policy_renders_present_facets_in_policy_order() -> None:
|
|
policy = STRUCTURED_V1.index
|
|
line = policy.render_link(
|
|
"Q500 Surdeigsbaking",
|
|
"inbox-q500-surdeigsbaking.md",
|
|
facets={"status": "gjeldende", "number": "Q500"},
|
|
)
|
|
assert line == (
|
|
"- [Q500 Surdeigsbaking](inbox-q500-surdeigsbaking.md) — number: Q500; status: gjeldende"
|
|
)
|
|
|
|
|
|
def test_facet_order_follows_the_policy_not_the_mapping() -> None:
|
|
# Two callers passing the same facts must emit the same bytes; a dict
|
|
# preserves insertion order, so ordering by the mapping would make a golden
|
|
# depend on how a caller happened to build its argument.
|
|
policy = STRUCTURED_V1.index
|
|
forwards = policy.render_link("T", "a.md", facets={"number": "N1", "status": "x"})
|
|
backwards = policy.render_link("T", "a.md", facets={"status": "x", "number": "N1"})
|
|
assert forwards == backwards
|
|
|
|
|
|
def test_an_entry_with_no_facet_values_renders_as_the_bare_link() -> None:
|
|
# A separator with nothing after it is a half-written entry, and it would
|
|
# cost every entry in a bundle that declares nothing.
|
|
assert STRUCTURED_V1.index.render_link("T", "a.md", facets={}) == "- [T](a.md)"
|
|
assert STRUCTURED_V1.index.render_link("T", "a.md") == "- [T](a.md)"
|
|
|
|
|
|
def test_a_facet_the_policy_does_not_name_is_refused() -> None:
|
|
# Silently dropping it would put a value in a file no reader of this
|
|
# contract looks at — the same posture the root frontmatter takes.
|
|
with pytest.raises(ValueError, match="not named"):
|
|
STRUCTURED_V1.index.render_link("T", "a.md", facets={"invented": "x"})
|
|
|
|
|
|
def test_offering_facets_to_a_policy_that_has_none_is_refused() -> None:
|
|
with pytest.raises(ValueError, match="carries no facets"):
|
|
DEFAULT.index.render_link("T", "a.md", facets={"number": "N1"})
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["a; b", "a — b", "a\nb"])
|
|
def test_a_facet_value_that_would_break_the_grammar_is_refused_never_repaired(bad: str) -> None:
|
|
# Validation, not repair — the standing posture everywhere in this library.
|
|
with pytest.raises(ValueError, match="separator|joiner|single-line"):
|
|
STRUCTURED_V1.index.render_link("T", "a.md", facets={"status": bad})
|
|
|
|
|
|
# --- round trip -----------------------------------------------------------
|
|
|
|
|
|
def test_render_and_parse_round_trip() -> None:
|
|
policy = STRUCTURED_V1.index
|
|
facets = {"number": "Q500", "status": "gjeldende", "references": "[Q200, Q300?]"}
|
|
line = policy.render_link("Q500 Surdeigsbaking", "inbox-q500.md", facets=facets)
|
|
entry = policy.parse_entry(line)
|
|
assert entry is not None
|
|
assert entry.label == "Q500 Surdeigsbaking"
|
|
assert entry.target == "inbox-q500.md"
|
|
assert entry.facets == facets
|
|
|
|
|
|
def test_a_bare_link_still_parses_under_a_faceted_policy() -> None:
|
|
# An index built before facets existed must keep working: its lines are
|
|
# managed lines with no facets, not unmanaged prose.
|
|
entry = STRUCTURED_V1.index.parse_entry("- [A](inbox-a.md)")
|
|
assert entry is not None
|
|
assert entry.facets == {}
|
|
|
|
|
|
def test_prose_is_not_an_entry() -> None:
|
|
assert STRUCTURED_V1.index.parse_entry("Some curated prose about inbox-a.md.") is None
|
|
assert DEFAULT.index.parse_entry("# Heading") is None
|
|
|
|
|
|
def test_entry_pattern_is_anchored_to_the_whole_line() -> None:
|
|
# Index maintenance keys on this pattern to decide what it may rewrite;
|
|
# a substring match would let it edit curated prose.
|
|
assert STRUCTURED_V1.index.entry_pattern.match("prefix - [A](a.md)") is None
|
|
|
|
|
|
# --- construction guards --------------------------------------------------
|
|
|
|
|
|
def test_a_facet_policy_needs_at_least_one_key() -> None:
|
|
with pytest.raises(ValueError, match="at least one"):
|
|
FacetPolicy(keys=())
|
|
|
|
|
|
def test_a_link_pattern_that_is_not_line_anchored_cannot_carry_facets() -> None:
|
|
# The faceted pattern is built from this one, so an unanchored base would
|
|
# silently produce an unanchored faceted pattern.
|
|
with pytest.raises(ValueError, match="anchored"):
|
|
IndexPolicy(
|
|
name="index.md",
|
|
link_template="- [{label}]({target})",
|
|
link_pattern=re.compile(r"^- \[(?P<label>[^\]]*)\]\((?P<target>[^)]+)\)"),
|
|
facets=FacetPolicy(keys=("number",)),
|
|
)
|
|
|
|
|
|
def test_structured_v1_is_default_in_every_respect_but_the_index() -> None:
|
|
# An additive profile, stated as one: the namespaces, the type policy and
|
|
# the ownership stamp are DEFAULT's, so a bundle written under this profile
|
|
# stays re-runnable under DEFAULT and vice versa.
|
|
assert STRUCTURED_V1.paths == DEFAULT.paths
|
|
assert STRUCTURED_V1.types == DEFAULT.types
|
|
assert STRUCTURED_V1.ownership == DEFAULT.ownership
|
|
assert STRUCTURED_V1.index.name == DEFAULT.index.name
|
|
|
|
|
|
def test_structured_v1_names_the_structure_keys_in_its_frontmatter_order() -> None:
|
|
# A key the schema does not name is emitted in `emit`'s sorted tail, where
|
|
# `derived` would precede `number`: alphabetical order standing in for the
|
|
# contract's own.
|
|
order = STRUCTURED_V1.frontmatter.order
|
|
assert DEFAULT.frontmatter.order == order[: len(DEFAULT.frontmatter.order)]
|
|
for key in STRUCTURED_V1.index.facets.keys if STRUCTURED_V1.index.facets else ():
|
|
assert key in order
|