feat(profiles): a faceted index policy and the additive STRUCTURED_V1 profile
The measured defect, as data: the 2026-08-26 bake-off had every arm retrieve 40/40, so quality could not separate them. The only axis that did was trap exposure -- 18/20 for the OKF-index arm against 8/20 for a frontmatter head-scan -- and both sides measured the reason independently: the flat index carries title/date/status/supersedes 0 times while its own documents carry them 55/55/55/5. The metadata is in the bundle; the index throws it away. FacetPolicy lets an index entry keep it. The grammar is thin on purpose (one separator, then key: value joined by '; ') because index lines are read by regex on both sides of this library, and a value carrying either delimiter is REFUSED rather than escaped -- validation, not repair, as everywhere else here. Additive by construction, not by caution. entry_pattern IS link_pattern when a policy carries no facets, so DEFAULT and STRICT_V1 match the same lines and emit the same bytes; the goldens are the proof. Facets arrive as STRUCTURED_V1, a new profile, because DEFAULT states commons' ingest-spec index layer and changing its bytes from here would be this repo editing a contract it does not own. 17 new tests; suite 660 -> 677.
This commit is contained in:
parent
05cda5ded5
commit
52c82bc3d1
3 changed files with 351 additions and 4 deletions
|
|
@ -271,7 +271,7 @@ def _update_index_lines(
|
|||
for line in lines:
|
||||
content = line.rstrip("\r\n")
|
||||
ending = line[len(content) :]
|
||||
match = profile.index.link_pattern.match(content)
|
||||
match = profile.index.entry_pattern.match(content)
|
||||
if match is not None:
|
||||
target = match.group("target")
|
||||
if target in removed_targets:
|
||||
|
|
|
|||
|
|
@ -375,6 +375,86 @@ def _split_frontmatter(text: str) -> tuple[dict[str, str], list[str]]:
|
|||
return head, []
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FacetPolicy:
|
||||
"""The metadata an index ENTRY carries beside its label and target.
|
||||
|
||||
A flat index is a table of contents. A faceted one is a table a consumer
|
||||
can reason over without opening a single document - which is the whole
|
||||
difference the 2026-08-26 bake-off measured: every arm retrieved 40/40, and
|
||||
the only axis that separated them was trap exposure, where the flat index
|
||||
lost precisely because it carried title/date/status/supersedes 0 times
|
||||
while its own documents carried them 55/55/55/5.
|
||||
|
||||
`keys` is the ordered, closed set. Ordering by the POLICY rather than by
|
||||
the caller's mapping is what keeps two callers passing the same facts from
|
||||
emitting different bytes, exactly as the root frontmatter does; closing the
|
||||
set is what keeps a value out of a file no reader of this contract looks
|
||||
at.
|
||||
|
||||
The grammar is deliberately thin - `separator` once, then `key: value`
|
||||
joined by `joiner` - because an index line is read by regex on both sides
|
||||
of this library. A value carrying either delimiter is REFUSED rather than
|
||||
escaped or repaired: escaping would make the line unreadable to a consumer
|
||||
that splits naively, and this library validates rather than repairs
|
||||
everywhere else.
|
||||
"""
|
||||
|
||||
keys: tuple[str, ...]
|
||||
separator: str = " \u2014 "
|
||||
joiner: str = "; "
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.keys:
|
||||
raise ValueError(
|
||||
"a facet policy must name at least one key - a policy naming "
|
||||
"none would render a separator with nothing after it"
|
||||
)
|
||||
|
||||
def render(self, values: Mapping[str, str]) -> str:
|
||||
"""The facet tail for `values`, or "" when none of them are present."""
|
||||
unknown = sorted(set(values) - set(self.keys))
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"facet key(s) {', '.join(repr(key) for key in unknown)} are not named "
|
||||
f"by this index policy, which pins {self.keys} - rendering an unnamed "
|
||||
"key would put a value in a file no reader of this contract looks at"
|
||||
)
|
||||
present = [(key, values[key]) for key in self.keys if values.get(key)]
|
||||
for key, value in present:
|
||||
if "\n" in value or "\r" in value:
|
||||
raise ValueError(f"facet {key!r} must be single-line, got {value!r}")
|
||||
for label, delimiter in (("separator", self.separator), ("joiner", self.joiner)):
|
||||
if delimiter in value:
|
||||
raise ValueError(
|
||||
f"facet {key!r} contains this policy's {label} {delimiter!r} "
|
||||
f"({value!r}) - refusing to escape or repair it, which would "
|
||||
"make the line parse one way here and another way downstream"
|
||||
)
|
||||
if not present:
|
||||
return ""
|
||||
return self.separator + self.joiner.join(f"{key}: {value}" for key, value in present)
|
||||
|
||||
def parse(self, tail: str) -> dict[str, str]:
|
||||
"""The facet tail read back. The inverse of `render` by construction."""
|
||||
parsed: dict[str, str] = {}
|
||||
for chunk in tail.split(self.joiner):
|
||||
key, sep, value = chunk.partition(":")
|
||||
if sep:
|
||||
parsed[key.strip()] = value.strip()
|
||||
return parsed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexEntry:
|
||||
"""One managed index line, read back into its parts."""
|
||||
|
||||
label: str
|
||||
target: str
|
||||
description: str | None = None
|
||||
facets: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexViolation:
|
||||
"""One way an index file departs from the policy.
|
||||
|
|
@ -443,8 +523,31 @@ class IndexPolicy:
|
|||
entries_match_directory: bool = False
|
||||
root_frontmatter: tuple[str, ...] = ()
|
||||
root_frontmatter_required: frozenset[str] = field(default_factory=frozenset)
|
||||
facets: FacetPolicy | None = None
|
||||
_faceted_pattern: re.Pattern[str] | None = field(
|
||||
init=False, repr=False, compare=False, default=None
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.facets is not None:
|
||||
# The faceted pattern is BUILT from the base one, so an unanchored
|
||||
# base would silently produce an unanchored faceted pattern - and
|
||||
# index maintenance keys on this pattern to decide which lines it
|
||||
# may rewrite. A substring match there edits curated prose.
|
||||
if not self.link_pattern.pattern.endswith("$"):
|
||||
raise ValueError(
|
||||
"a link pattern carrying facets must be anchored to the end "
|
||||
"of the line ('$'), because the faceted pattern is derived "
|
||||
"from it and an unanchored match would rewrite curated prose"
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_faceted_pattern",
|
||||
re.compile(
|
||||
self.link_pattern.pattern[:-1]
|
||||
+ f"(?:{re.escape(self.facets.separator)}(?P<facets>.+))?$"
|
||||
),
|
||||
)
|
||||
stray = sorted(self.root_frontmatter_required - set(self.root_frontmatter))
|
||||
if stray:
|
||||
raise ValueError(
|
||||
|
|
@ -464,7 +567,24 @@ class IndexPolicy:
|
|||
"""Whether an entry carries a description alongside label and target."""
|
||||
return "{description}" in self.link_template
|
||||
|
||||
def render_link(self, label: str, target: str, description: str | None = None) -> str:
|
||||
@property
|
||||
def entry_pattern(self) -> re.Pattern[str]:
|
||||
"""The pattern that recognises a managed line, facets included.
|
||||
|
||||
IS `link_pattern` when this policy carries no facets, which is what
|
||||
makes the feature additive: DEFAULT and STRICT_V1 match exactly the
|
||||
lines they always matched, byte for byte.
|
||||
"""
|
||||
return self._faceted_pattern if self._faceted_pattern is not None else self.link_pattern
|
||||
|
||||
def render_link(
|
||||
self,
|
||||
label: str,
|
||||
target: str,
|
||||
description: str | None = None,
|
||||
*,
|
||||
facets: Mapping[str, str] | None = None,
|
||||
) -> str:
|
||||
if self.requires_description and description is None:
|
||||
raise ValueError(
|
||||
"this index policy's entries carry a description; rendering "
|
||||
|
|
@ -475,7 +595,33 @@ class IndexPolicy:
|
|||
"this index policy's entries carry no description; the value "
|
||||
"offered would be dropped silently"
|
||||
)
|
||||
return self.link_template.format(label=label, target=target, description=description)
|
||||
if self.facets is None and facets:
|
||||
raise ValueError(
|
||||
"this index policy carries no facets; the values offered would be dropped silently"
|
||||
)
|
||||
line = self.link_template.format(label=label, target=target, description=description)
|
||||
if self.facets is None or facets is None:
|
||||
return line
|
||||
return line + self.facets.render(facets)
|
||||
|
||||
def parse_entry(self, line: str) -> IndexEntry | None:
|
||||
"""One managed line read back into its parts, or `None` for anything else.
|
||||
|
||||
Anything this returns `None` for is curated content and survives
|
||||
verbatim: the index is the one file where this library writes beside
|
||||
somebody else's prose.
|
||||
"""
|
||||
match = self.entry_pattern.match(line.rstrip("\r\n"))
|
||||
if match is None:
|
||||
return None
|
||||
groups = match.groupdict()
|
||||
tail = groups.get("facets")
|
||||
return IndexEntry(
|
||||
label=match.group("label"),
|
||||
target=match.group("target"),
|
||||
description=groups.get("description"),
|
||||
facets=self.facets.parse(tail) if (self.facets is not None and tail) else {},
|
||||
)
|
||||
|
||||
def required_indexes(self, directories: Sequence[str]) -> tuple[str, ...]:
|
||||
"""The index paths this policy requires, given the caller's directories.
|
||||
|
|
@ -526,7 +672,7 @@ class IndexPolicy:
|
|||
if line.startswith("# "):
|
||||
headings.append(line)
|
||||
continue
|
||||
match = self.link_pattern.match(line)
|
||||
match = self.entry_pattern.match(line)
|
||||
if match is not None:
|
||||
listed.add(match.group("target"))
|
||||
continue
|
||||
|
|
@ -728,6 +874,53 @@ STRICT_V1 = BundleProfile(
|
|||
)
|
||||
|
||||
|
||||
# The ordered structure keys: what `structure.py` derives, plus the two
|
||||
# producer-declared keys the 2026-08-26 bake-off measured missing from the
|
||||
# index (`status` 55/55 in the documents, 0/55 in the index; `date` likewise).
|
||||
# One tuple feeds both the frontmatter order and the facet set, because two
|
||||
# lists of the same keys drift.
|
||||
_STRUCTURE_KEYS = (
|
||||
"number",
|
||||
"parent",
|
||||
"status",
|
||||
"date",
|
||||
"version",
|
||||
"supersedes",
|
||||
"references",
|
||||
# LAST, and load-bearing: it names which of the keys before it this library
|
||||
# INFERRED rather than read. A consumer that trusts nothing derived can
|
||||
# still use everything else, and one that accepts both knows which half it
|
||||
# is betting on. An unmarked heuristic is worse than no heuristic.
|
||||
"derived",
|
||||
)
|
||||
|
||||
# DEFAULT plus structure. Additive in the strict sense: the namespaces, the
|
||||
# type policy and the ownership stamp are DEFAULT's own objects, so a bundle
|
||||
# written under either profile stays re-runnable under the other, and DEFAULT's
|
||||
# bytes do not move.
|
||||
#
|
||||
# Why a new profile rather than facets on DEFAULT: DEFAULT states commons'
|
||||
# ingest-spec 6 index layer. Changing its rendered bytes from here would be
|
||||
# this repo editing another repo's contract (O2), and it would churn every
|
||||
# golden fixture that door has ever written. The measured defect is real, but
|
||||
# the fix belongs beside the contract it changes, not inside one we do not own.
|
||||
#
|
||||
# The cost is deliberate and small. Facets are rendered only where a value
|
||||
# exists, so a bundle whose documents declare nothing pays nothing, and the
|
||||
# 2026-08-26 arm that lost on trap exposure was 6 031 characters against
|
||||
# 21 879 for the head-scan it lost to - the headroom for carrying the metadata
|
||||
# back into the index is most of that gap.
|
||||
STRUCTURED_V1 = BundleProfile(
|
||||
types=DEFAULT.types,
|
||||
frontmatter=FrontmatterSchema(
|
||||
order=(*DEFAULT.frontmatter.order, *_STRUCTURE_KEYS),
|
||||
collapsed_keys=DEFAULT.frontmatter.collapsed_keys,
|
||||
),
|
||||
paths=DEFAULT.paths,
|
||||
index=replace(DEFAULT.index, facets=FacetPolicy(keys=_STRUCTURE_KEYS)),
|
||||
ownership=DEFAULT.ownership,
|
||||
)
|
||||
|
||||
# OKF v0.2, as an ADDITIVE profile: `DEFAULT` states commons' ingest-spec §5
|
||||
# layer and keeps stating it, so nothing here migrates anything. The key order
|
||||
# is DEFAULT's followed by the §5 families v0.2 adds, which is also the order
|
||||
|
|
|
|||
154
tests/test_faceted_index.py
Normal file
154
tests/test_faceted_index.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""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(
|
||||
"N500 Vegbygging",
|
||||
"inbox-n500-vegbygging.md",
|
||||
facets={"status": "gjeldende", "number": "N500"},
|
||||
)
|
||||
assert line == (
|
||||
"- [N500 Vegbygging](inbox-n500-vegbygging.md) — number: N500; 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": "N500", "status": "gjeldende", "references": "[N200, N300?]"}
|
||||
line = policy.render_link("N500 Vegbygging", "inbox-n500.md", facets=facets)
|
||||
entry = policy.parse_entry(line)
|
||||
assert entry is not None
|
||||
assert entry.label == "N500 Vegbygging"
|
||||
assert entry.target == "inbox-n500.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
|
||||
Loading…
Add table
Add a link
Reference in a new issue