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>
269 lines
11 KiB
Python
269 lines
11 KiB
Python
"""One ordering, both doors: an index order a profile names is honoured everywhere.
|
|
|
|
The defect this closes is a drift, not a bug in either door on its own. Door B
|
|
and Door C write their indexes through separate code today, so an ordering
|
|
built on Door B's `_index_sort_key` seam alone would be a field on the profile
|
|
that Door B obeys and Door C ignores — silently, because nothing raises and
|
|
both indexes still parse. That is `IndexPolicy.per_directory` again: a field
|
|
that reads as global and acts on one path.
|
|
|
|
So the ordering lives on `IndexPolicy`, is expressed in three fields whose
|
|
values come from CLOSED sets, and is applied by ONE helper both doors call.
|
|
`sort_order` is deliberately not a caller-supplied callable: a callable is not
|
|
serialisable, not reproducible from the bundle, and not auditable, which is
|
|
exactly what a deterministic bundle promises it is not.
|
|
|
|
The cross-door test below is written so it would have FAILED in the broken
|
|
world: it runs both doors under the same profile and asserts they agree with
|
|
each other AND with the order the profile names. A test covering Door B alone
|
|
would have passed while Door C ignored the field.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from test_import_facets import run_with
|
|
from test_import_flow import StubImportGate, place
|
|
|
|
from llm_ingestion_okf.inbox import GateDecision, process_inbox
|
|
from llm_ingestion_okf.profiles import (
|
|
DEFAULT,
|
|
STRUCTURED_V1,
|
|
BundleProfile,
|
|
IndexEntry,
|
|
IndexPolicy,
|
|
)
|
|
|
|
INGESTED_AT = "2026-07-25T12:00:00Z"
|
|
|
|
# A profile that names an ordering. Built here rather than added to a shipped
|
|
# profile: STRUCTURED_V1 is a consumer's contract, and moving its index bytes
|
|
# from a test would decide another repo's ordering for it.
|
|
NUMBERED = replace(
|
|
STRUCTURED_V1,
|
|
index=replace(STRUCTURED_V1.index, sort_key="number"),
|
|
)
|
|
|
|
|
|
def numbers_in(bundle: Path, profile: BundleProfile) -> list[str]:
|
|
"""The `number` facet of every managed entry, in the order the file has them."""
|
|
text = (bundle / profile.index.name).read_text(encoding="utf-8")
|
|
entries = (profile.index.parse_entry(line) for line in text.splitlines())
|
|
return [entry.facets["number"] for entry in entries if entry is not None]
|
|
|
|
|
|
def run_door_b(tmp_path: Path, *, profile: BundleProfile) -> Path:
|
|
inbox = tmp_path / "round"
|
|
inbox.mkdir(parents=True, exist_ok=True)
|
|
# Names ascend while numbers descend, so an index left in arrival order is
|
|
# the exact reverse of the one the profile asks for.
|
|
for name, number in (("alpha.md", "Q300"), ("beta.md", "Q200"), ("gamma.md", "Q100")):
|
|
(inbox / name).write_text(
|
|
f"# {number} {name[:-3]}\n\nBody.\n", encoding="utf-8", newline=""
|
|
)
|
|
bundle = tmp_path / "bundle"
|
|
process_inbox(
|
|
inbox,
|
|
bundle,
|
|
INGESTED_AT,
|
|
okf_type="reference",
|
|
gate=lambda text: GateDecision(sanitized_text=text, disposition="warn"),
|
|
profile=profile,
|
|
)
|
|
return bundle
|
|
|
|
|
|
def run_door_c(tmp_path: Path, *, profile: BundleProfile) -> Path:
|
|
source = tmp_path / "source"
|
|
for name, number in (("alpha.md", "Q300"), ("beta.md", "Q200"), ("gamma.md", "Q100")):
|
|
place(source, name, f"---\ntype: dataset\nnumber: {number}\n---\n\nBody.\n")
|
|
_, bundle = run_with(tmp_path, StubImportGate(), profile=profile)
|
|
return bundle
|
|
|
|
|
|
# --- the cross-door requirement -------------------------------------------
|
|
|
|
|
|
def test_both_doors_order_by_the_key_the_profile_names(tmp_path: Path) -> None:
|
|
door_b = numbers_in(run_door_b(tmp_path / "b", profile=NUMBERED), NUMBERED)
|
|
door_c = numbers_in(run_door_c(tmp_path / "c", profile=NUMBERED), NUMBERED)
|
|
|
|
# Both agree with the profile...
|
|
assert door_b == ["Q100", "Q200", "Q300"]
|
|
assert door_c == ["Q100", "Q200", "Q300"]
|
|
# ...and therefore with each other. Stated separately on purpose: a door
|
|
# that ignored the field would still produce a parseable index, and the
|
|
# arrival order it would produce is the reverse of this one.
|
|
assert door_b == door_c
|
|
|
|
|
|
def test_door_c_keeps_its_order_when_the_profile_names_none(tmp_path: Path) -> None:
|
|
"""The additivity guard, on the one pair where the two candidate orders differ.
|
|
|
|
`notes-beta.md` precedes `notes/alpha.md` by concept path ('-' < '/') and
|
|
follows it by generated filename ('a' < 'b'). Door C has always ordered by
|
|
the sender's concept path, so the concept path — not the target — is the
|
|
final tie-break, and this bundle's bytes do not move.
|
|
"""
|
|
source = tmp_path / "source"
|
|
place(source, "notes/alpha.md", "---\ntype: dataset\n---\n\nOne.\n")
|
|
place(source, "notes-beta.md", "---\ntype: dataset\n---\n\nTwo.\n")
|
|
|
|
_, bundle = run_with(tmp_path, StubImportGate(), profile=DEFAULT)
|
|
|
|
assert (bundle / "index.md").read_text(encoding="utf-8") == (
|
|
"- [notes-beta](import-notes-beta.md)\n- [notes/alpha](import-notes-alpha.md)\n"
|
|
)
|
|
|
|
|
|
# --- the closed sets ------------------------------------------------------
|
|
|
|
|
|
def test_a_sort_order_outside_the_closed_set_is_refused() -> None:
|
|
with pytest.raises(ValueError, match="sort_order"):
|
|
replace(NUMBERED.index, sort_order="by-relevance")
|
|
|
|
|
|
def test_a_sort_missing_outside_the_closed_set_is_refused() -> None:
|
|
with pytest.raises(ValueError, match="sort_missing"):
|
|
replace(NUMBERED.index, sort_missing="somewhere")
|
|
|
|
|
|
def test_a_sort_key_the_facet_policy_does_not_name_is_refused() -> None:
|
|
# Every entry would be missing the key and the ordering would silently do
|
|
# nothing — the failure mode this whole row exists to prevent.
|
|
with pytest.raises(ValueError, match="sort_key"):
|
|
replace(STRUCTURED_V1.index, sort_key="relevance")
|
|
|
|
|
|
def test_a_sort_key_on_a_policy_carrying_no_facets_is_refused() -> None:
|
|
with pytest.raises(ValueError, match="sort_key"):
|
|
replace(DEFAULT.index, sort_key="number")
|
|
|
|
|
|
# --- the helper -----------------------------------------------------------
|
|
|
|
|
|
def entry(concept_path: str, target: str, **facets: str) -> IndexEntry:
|
|
return IndexEntry(label=target, target=target, facets=facets, concept_path=concept_path)
|
|
|
|
|
|
def test_the_concept_path_is_the_final_tie_break() -> None:
|
|
"""Two equal keys must not leave the order to chance — and the tie-break is
|
|
the concept path, which for Door C is not the generated filename."""
|
|
policy: IndexPolicy = NUMBERED.index
|
|
entries = [
|
|
entry("notes/alpha.md", "import-notes-alpha.md", number="Q100"),
|
|
entry("notes-beta.md", "import-notes-beta.md", number="Q100"),
|
|
]
|
|
|
|
for order in (entries, list(reversed(entries))):
|
|
assert [item.target for item in policy.sort_entries(order)] == [
|
|
"import-notes-beta.md",
|
|
"import-notes-alpha.md",
|
|
]
|
|
|
|
|
|
def test_descending_reverses_the_key_and_not_the_tie_break() -> None:
|
|
policy = replace(NUMBERED.index, sort_order="descending")
|
|
entries = [
|
|
entry("a.md", "import-a.md", number="Q100"),
|
|
entry("c.md", "import-c.md", number="Q300"),
|
|
entry("b.md", "import-b.md", number="Q300"),
|
|
]
|
|
|
|
assert [item.target for item in policy.sort_entries(entries)] == [
|
|
"import-b.md",
|
|
"import-c.md",
|
|
"import-a.md",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("order", ["ascending", "descending"])
|
|
@pytest.mark.parametrize("missing", ["first", "last"])
|
|
def test_the_missing_group_lands_where_the_policy_says_in_both_directions(
|
|
order: str, missing: str
|
|
) -> None:
|
|
"""The subtle one: reversing a single key tuple would flip the missing group
|
|
with it, so `sort_missing` would mean the opposite thing under `descending`.
|
|
"""
|
|
policy = replace(NUMBERED.index, sort_order=order, sort_missing=missing)
|
|
entries = [
|
|
entry("a.md", "import-a.md", number="Q100"),
|
|
entry("b.md", "import-b.md"),
|
|
entry("c.md", "import-c.md", number="Q300"),
|
|
]
|
|
|
|
present = (
|
|
["import-a.md", "import-c.md"] if order == "ascending" else ["import-c.md", "import-a.md"]
|
|
)
|
|
expected = ["import-b.md", *present] if missing == "first" else [*present, "import-b.md"]
|
|
assert [item.target for item in policy.sort_entries(entries)] == expected
|
|
|
|
|
|
def test_an_empty_facet_value_counts_as_missing() -> None:
|
|
# `FacetPolicy.render` already drops a falsy value, so an entry rendered
|
|
# without the facet must not sort as though it carried one.
|
|
policy = replace(NUMBERED.index, sort_missing="last")
|
|
entries = [
|
|
entry("a.md", "import-a.md", number=""),
|
|
entry("b.md", "import-b.md", number="Q300"),
|
|
]
|
|
|
|
assert [item.target for item in policy.sort_entries(entries)] == [
|
|
"import-b.md",
|
|
"import-a.md",
|
|
]
|
|
|
|
|
|
def test_navigation_entries_stay_last_under_a_sort_key() -> None:
|
|
# Navigation is an outer grouping, not a competitor to the key: a child
|
|
# index carries no facets, so under `sort_missing="first"` it would lead
|
|
# the file if the grouping were not applied over the ordering.
|
|
policy = replace(NUMBERED.index, sort_missing="first")
|
|
entries = [
|
|
entry("krav/q900.md", "q900.md", number="Q900"),
|
|
IndexEntry(label="sub (underkapitler)", target=f"sub/{NUMBERED.index.name}"),
|
|
entry("krav/q100.md", "q100.md", number="Q100"),
|
|
]
|
|
|
|
assert [item.target for item in policy.sort_entries(entries)] == [
|
|
"q100.md",
|
|
"q900.md",
|
|
f"sub/{NUMBERED.index.name}",
|
|
]
|
|
|
|
|
|
def test_door_c_orders_within_a_run_and_never_re_orders_an_earlier_one(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The bound this door's ordering actually has, pinned rather than asserted.
|
|
|
|
`link_in_index` appends what is absent and leaves what is present where it
|
|
is, so a concept merged in a later round lands at the END of the index even
|
|
when the key says it sorts first. Door B reprojects and has no such bound;
|
|
Door C writes the sender's bytes verbatim and its index is append-idempotent
|
|
by design, so making it reproject is a different piece of work. The
|
|
guarantee is therefore "within a run", and a guarantee stated in a comment
|
|
with no test that goes red is not a guarantee.
|
|
"""
|
|
source = tmp_path / "source"
|
|
place(source, "b.md", "---\ntype: dataset\nnumber: Q200\n---\n\nBody.\n")
|
|
place(source, "c.md", "---\ntype: dataset\nnumber: Q300\n---\n\nBody.\n")
|
|
_, bundle = run_with(tmp_path, StubImportGate(), profile=NUMBERED)
|
|
|
|
place(source, "a.md", "---\ntype: dataset\nnumber: Q100\n---\n\nBody.\n")
|
|
run_with(tmp_path, StubImportGate(), profile=NUMBERED)
|
|
|
|
# Q100 sorts first and lands last, because round one's lines stay put.
|
|
assert numbers_in(bundle, NUMBERED) == ["Q200", "Q300", "Q100"]
|
|
# The same three concepts merged in ONE run do come out ordered — so the
|
|
# difference above is the append bound, not an ordering that failed.
|
|
assert numbers_in(run_door_c(tmp_path / "fresh", profile=NUMBERED), NUMBERED) == [
|
|
"Q100",
|
|
"Q200",
|
|
"Q300",
|
|
]
|