llm-ingestion-okf/tests/test_index_sort.py
Kjell Tore Guttormsen 9d1f4b14ed test(fixtures): replace sector-specific example material with generic, fictitious examples — green
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>
2026-09-23 14:52:02 +02:00

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",
]