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:
Kjell Tore Guttormsen 2026-08-27 00:30:42 +02:00
commit 52c82bc3d1
3 changed files with 351 additions and 4 deletions

View file

@ -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:

View file

@ -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