feat(index): one ordering helper, called by both doors
An index ordering a profile names must be honoured wherever this library writes an index. Door B and Door C have separate index writers, so an ordering built on Door B's `_index_sort_key` seam alone would have been a profile field Door B obeys and Door C ignores -- silently, because nothing raises and both files still parse. That is `IndexPolicy.per_directory` again: a field that reads as global and acts on one path. `IndexPolicy` gains `sort_key`, `sort_order` and `sort_missing`. Both order fields draw from CLOSED sets, and `sort_order` is deliberately not a caller-supplied callable: a callable cannot be serialised into the bundle, reproduced from it, or audited by a reader, which is the whole of what a deterministic bundle claims. A `sort_key` the facet policy does not name is refused too -- every entry would be missing the key and the ordering would silently do nothing, which is this row's own defect class. `IndexPolicy.sort_entries` is the one helper. Four stable passes, so each is the tie-break of the next: concept path, then the named key, then the missing group partitioned to whichever end the policy says, then navigation last. Passes 2 and 3 are separate on purpose -- folding them into one reversible key tuple would flip the missing group along with the order, so `sort_missing="last"` would mean "first" under `descending`. The tie-break is the CONCEPT PATH, not the link target, and that is measured rather than assumed: `notes-beta.md` precedes `notes/alpha.md` by concept path and follows it by generated filename, so ordering Door C on the target would have re-ordered every existing Door C bundle. `IndexEntry` carries the path for that reason; `parse_entry` leaves it `None` and the ordering falls back to the target, which costs nothing because no caller sorts entries it read back off disk. Door B's two reprojection writers and Door C's index emission all route through the helper. Door B's unfaceted path is not routed and does not need to be: `sort_key` requires a facet policy, and a faceted profile never reaches that writer. Door C's guarantee is bounded and stated in the code -- `link_in_index` appends what is absent and leaves what is present, so the order holds within a run and never re-orders entries an earlier run wrote. Default ordering, unchanged and now stated: with no `sort_key`, concepts before navigation, each group ascending by concept path. TDD, and the red was watched twice. First behaviourally with the fields inert (both doors emitted the exact reverse of the named order), then again with Door B routed and Door C not -- the broken world reproduced, where a Door-B-only test would have passed. 882 tests (868 before). The five byte-pinned goldens are untouched and green; no shipped profile moved. Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
parent
ac6dffe51e
commit
d2a8c43d77
5 changed files with 391 additions and 36 deletions
|
|
@ -21,7 +21,7 @@ are extension points, not v1 (settled with the operator at phase start).
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Collection, Mapping, Sequence
|
||||
from collections.abc import Collection, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field, replace
|
||||
|
||||
# The one layer no profile may admit (ingest-spec §3): the promotion gate is
|
||||
|
|
@ -454,14 +454,45 @@ class FacetPolicy:
|
|||
return parsed
|
||||
|
||||
|
||||
#: The closed set `IndexPolicy.sort_order` draws from. A CALLER-SUPPLIED
|
||||
#: CALLABLE is deliberately not an option here: it cannot be serialised into
|
||||
#: the bundle, cannot be reproduced from it, and cannot be audited by anyone
|
||||
#: reading it back — which is the whole of what a deterministic bundle claims.
|
||||
SORT_ASCENDING = "ascending"
|
||||
SORT_DESCENDING = "descending"
|
||||
SORT_ORDERS = (SORT_ASCENDING, SORT_DESCENDING)
|
||||
|
||||
#: The closed set `IndexPolicy.sort_missing` draws from: where the entries that
|
||||
#: do not carry the key at all are put. Closed for the same reason.
|
||||
SORT_MISSING_FIRST = "first"
|
||||
SORT_MISSING_LAST = "last"
|
||||
SORT_MISSING = (SORT_MISSING_FIRST, SORT_MISSING_LAST)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IndexEntry:
|
||||
"""One managed index line, read back into its parts."""
|
||||
"""One managed index line, read back into its parts.
|
||||
|
||||
`concept_path` is the bundle-relative path of the concept the entry is
|
||||
ABOUT, and it is the ordering tie-break. It is not part of the rendered
|
||||
line and is therefore only ever populated on the write path — `parse_entry`
|
||||
leaves it `None`, and the ordering then falls back to the link target.
|
||||
That costs nothing today because no caller sorts entries it read back off
|
||||
disk; every ordering happens where the entry is being built.
|
||||
|
||||
It exists because the two doors disagree about what the target IS. Door B's
|
||||
target is the concept's own name, but Door C reduces a sender's concept
|
||||
path to a generated filename, and the two do not order alike:
|
||||
`notes-beta.md` precedes `notes/alpha.md` by concept path and follows it by
|
||||
generated name. Ordering on the target would silently re-order every
|
||||
existing Door C bundle.
|
||||
"""
|
||||
|
||||
label: str
|
||||
target: str
|
||||
description: str | None = None
|
||||
facets: Mapping[str, str] = field(default_factory=dict)
|
||||
concept_path: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -533,6 +564,9 @@ class IndexPolicy:
|
|||
root_frontmatter: tuple[str, ...] = ()
|
||||
root_frontmatter_required: frozenset[str] = field(default_factory=frozenset)
|
||||
facets: FacetPolicy | None = None
|
||||
sort_key: str | None = None
|
||||
sort_order: str = SORT_ASCENDING
|
||||
sort_missing: str = SORT_MISSING_LAST
|
||||
_faceted_pattern: re.Pattern[str] | None = field(
|
||||
init=False, repr=False, compare=False, default=None
|
||||
)
|
||||
|
|
@ -564,6 +598,25 @@ class IndexPolicy:
|
|||
f"(got {', '.join(repr(key) for key in stray)}) — a key demanded "
|
||||
"but never named could not be judged for position"
|
||||
)
|
||||
if self.sort_order not in SORT_ORDERS:
|
||||
raise ValueError(
|
||||
f"sort_order must be one of {SORT_ORDERS}, got {self.sort_order!r} — "
|
||||
"the set is closed so that an index order is reproducible from the "
|
||||
"bundle rather than from the caller that happened to write it"
|
||||
)
|
||||
if self.sort_missing not in SORT_MISSING:
|
||||
raise ValueError(
|
||||
f"sort_missing must be one of {SORT_MISSING}, got {self.sort_missing!r}"
|
||||
)
|
||||
if self.sort_key is not None and (
|
||||
self.facets is None or self.sort_key not in self.facets.keys
|
||||
):
|
||||
named = self.facets.keys if self.facets is not None else ()
|
||||
raise ValueError(
|
||||
f"sort_key {self.sort_key!r} is not named by this index policy's facets, "
|
||||
f"which pin {named or '()'} — every entry would be missing the key and "
|
||||
"the ordering would silently do nothing"
|
||||
)
|
||||
if self.requires_description and "description" not in self.link_pattern.groupindex:
|
||||
raise ValueError(
|
||||
"link_template names {description} but link_pattern has no "
|
||||
|
|
@ -632,6 +685,58 @@ class IndexPolicy:
|
|||
facets=self.facets.parse(tail) if (self.facets is not None and tail) else {},
|
||||
)
|
||||
|
||||
def sort_entries(self, entries: Iterable[IndexEntry]) -> tuple[IndexEntry, ...]:
|
||||
"""The ONE ordering every index this library writes goes through.
|
||||
|
||||
Both doors call this. Not because sharing is tidy, but because the
|
||||
alternative was measured: with Door B and Door C writing their indexes
|
||||
through separate code, an ordering wired into one of them is a profile
|
||||
field the other ignores in silence — nothing raises, and both files
|
||||
still parse. Two implementations of one ordering ARE the drift.
|
||||
|
||||
Four passes, each a stable sort, so an earlier pass is exactly the
|
||||
tie-break of a later one:
|
||||
|
||||
1. by concept path, which is the final tie-break and makes the order
|
||||
total — two entries sharing a key never fall back to chance;
|
||||
2. by the named key, reversed for `descending`;
|
||||
3. the entries missing the key partitioned to whichever end
|
||||
`sort_missing` says. Separate from pass 2 on purpose: folding the two
|
||||
into one reversible key tuple would flip the missing group along with
|
||||
the order, so `sort_missing="last"` would mean "first" under
|
||||
`descending`;
|
||||
4. navigation last. An outer GROUPING rather than a competitor to the
|
||||
key: a link down to a child index is about a directory, carries no
|
||||
facets, and would lead the file under `sort_missing="first"`.
|
||||
|
||||
A value that is present but empty counts as missing, because
|
||||
`FacetPolicy.render` already drops it — an entry that renders without
|
||||
the facet must not sort as though it carried one.
|
||||
|
||||
With no `sort_key` the whole of passes 2 and 3 is skipped and the result
|
||||
is concepts before navigation, each group ascending by concept path.
|
||||
That is what every profile shipped today already emitted.
|
||||
"""
|
||||
ordered = sorted(entries, key=lambda entry: entry.concept_path or entry.target)
|
||||
if self.sort_key is not None:
|
||||
key = self.sort_key
|
||||
ordered = sorted(
|
||||
ordered,
|
||||
key=lambda entry: entry.facets.get(key) or "",
|
||||
reverse=self.sort_order == SORT_DESCENDING,
|
||||
)
|
||||
missing = [entry for entry in ordered if not entry.facets.get(key)]
|
||||
if missing:
|
||||
present = [entry for entry in ordered if entry.facets.get(key)]
|
||||
ordered = (
|
||||
[*missing, *present]
|
||||
if self.sort_missing == SORT_MISSING_FIRST
|
||||
else [*present, *missing]
|
||||
)
|
||||
return tuple(
|
||||
sorted(ordered, key=lambda entry: entry.target.rpartition("/")[2] == self.name)
|
||||
)
|
||||
|
||||
def required_indexes(self, directories: Sequence[str]) -> tuple[str, ...]:
|
||||
"""The index paths this policy requires, given the caller's directories.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue