test(propose): a heading-only section points at the ancestor holding its text

Red. A process code states its lettered points once, on the section that
owns them, and every section nested below inherits them; built faithfully,
the nested section is a concept whose body is one heading line (710 of 2 761
on one measured standard), and the two-level directory tree does not carry
the parent either.

Behind `--shell-parent`, off by default: a plan entry whose span holds only
its heading gets `parent_id` naming the nearest preceding entry at a smaller
level whose own span holds text. An empty ancestor is passed over; a shell
with no ancestor holding text gets no parent. The rule reads the plan's level
and order, never the row, so the same outline through the bookmark arm's
route reaches the same parents. The fixture is hand-written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-11 05:06:32 +02:00
commit f7fd0d4a43
2 changed files with 204 additions and 0 deletions

25
tests/fixtures/sts-inherit.xml vendored Normal file
View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<standard>
<front>
<std-meta>
<title-wrap><main>Fikstur for arvet kontekst</main></title-wrap>
</std-meta>
</front>
<body>
<sec sec-type="procedure" id="s7"><label>7</label><title>Vedlikehold av utstyr</title>
<sec sec-type="spec"><label>a)</label><p>Omfatter vedlikehold av alt utstyr langs vegen.</p></sec>
<sec sec-type="procedure" id="s7-6"><label>7.6</label><title>Rekkverk</title>
<sec sec-type="procedure" id="s7-61"><label>7.61</label><title>Utskifting</title>
<sec sec-type="procedure" id="s7-612"><label>7.612</label><title>Utskifting av enkeltdeler</title>
<sec sec-type="spec"><label>a)</label><p>Omfatter utskifting av skadde enkeltdeler i rekkverk.</p></sec>
<sec sec-type="procedure" id="s7-6121"><label>7.6121</label><title>Utskifting av list</title></sec>
<sec sec-type="procedure" id="s7-6122"><label>7.6122</label><title>Utskifting av stolpe</title></sec>
</sec>
</sec>
</sec>
</sec>
<sec sec-type="procedure" id="s9"><label>9</label><title>Kapittel uten tekst</title>
<sec sec-type="procedure" id="s9-1"><label>9.1</label><title>Underkapittel uten tekst</title></sec>
</sec>
</body>
</standard>

179
tests/test_shell_parent.py Normal file
View file

@ -0,0 +1,179 @@
"""A section whose body is its heading alone points at the ancestor holding its text.
THE SHAPE, in the mechanism rather than in a corpus. A process code states its
lettered points once, on the section that owns them, and every section nested
below inherits them; the nested section itself carries a title and nothing
else. Built faithfully, it becomes a concept whose body is one heading line --
measured on one 2 761-concept standard, 710 such concepts -- and a reader
handed one has no way to reach the text it inherits: the directory tree is two
levels deep, so the parent is not in the path either.
The rule, behind `--shell-parent` and OFF by default: a plan entry whose span
holds no line but its heading gets `parent_id` naming the NEAREST preceding
entry at a smaller level whose own span holds text. An empty ancestor is passed
over, and a shell with no ancestor holding text gets no parent. Nothing is
copied: the door writes the existing `parent:` key and not one borrowed line.
It reads the PLAN -- level and order -- and never the row, so the same outline
reaches the same parents through a route that is not NISO-STS.
`sts-inherit.xml` is hand-written in an invented setting and carries no
sentence from any source.
"""
from __future__ import annotations
from pathlib import Path
from llm_ingestion_okf import cli, extract, propose
FIXTURE = Path(__file__).parent / "fixtures" / "sts-inherit.xml"
#: Every flag `okf build` turns on by default.
BUILD_DEFAULTS = dict(
outline_run=3,
table_grid=True,
unit_fold=True,
keep_table_heading=True,
sheet_section_rows=True,
drop_wrapped_outline=True,
outline_gate=True,
first_span_from_zero=True,
close_span_gaps=True,
contents_name=True,
)
#: shell title -> title of the nearest ancestor holding text, or None.
EXPECTED = {
"Rekkverk": "Vedlikehold av utstyr",
# Its parent `Rekkverk` is a shell too, so the pointer goes one further up.
"Utskifting": "Vedlikehold av utstyr",
"Utskifting av list": "Utskifting av enkeltdeler",
"Utskifting av stolpe": "Utskifting av enkeltdeler",
# A shell whose only ancestor is empty, and one with no ancestor at all.
"Kapittel uten tekst": None,
"Underkapittel uten tekst": None,
# Sections holding text never get one.
"Vedlikehold av utstyr": None,
"Utskifting av enkeltdeler": None,
}
def _plan(source: Path, **flags: object) -> dict: # type: ignore[type-arg]
data = FIXTURE.read_bytes()
text = extract.extract_text(FIXTURE.name, data)
return propose.build_plan(
source,
text,
data,
okf_type="reference",
proposed_at="2026-01-01T00:00:00Z",
**BUILD_DEFAULTS,
**flags, # type: ignore[arg-type]
)
def _parents(plan: dict) -> dict[str, str | None]: # type: ignore[type-arg]
titles = {entry["segment_id"]: entry["title"] for entry in plan["entries"]}
return {
entry["title"]: titles[entry["parent_id"]] if "parent_id" in entry else None
for entry in plan["entries"]
}
def test_a_shell_points_at_the_nearest_ancestor_holding_text() -> None:
assert _parents(_plan(FIXTURE, shell_parent=True)) == EXPECTED
def test_nothing_moves_without_the_flag() -> None:
plain = _plan(FIXTURE)
assert not [entry for entry in plain["entries"] if "parent_id" in entry]
flagged = _plan(FIXTURE, shell_parent=True)
for entry in flagged["entries"]:
entry.pop("parent_id", None)
assert flagged == plain
def test_the_rule_reads_the_plan_and_not_the_row(tmp_path: Path) -> None:
"""The same outline through the bookmark arm's route: the same parents."""
data = FIXTURE.read_bytes()
text = extract.extract_text(FIXTURE.name, data)
source = tmp_path / "same-outline.md"
source.write_text(text, encoding="utf-8")
plan = propose.build_plan(
source,
text,
source.read_bytes(),
okf_type="reference",
proposed_at="2026-01-01T00:00:00Z",
outline_marks=extract.xml_outline(FIXTURE.name, data),
shell_parent=True,
**BUILD_DEFAULTS, # type: ignore[arg-type]
)
assert {entry["derived"][1] for entry in plan["entries"]} == {propose.RULE_PDF_OUTLINE}
assert _parents(plan) == EXPECTED
def _frontmatter(bundle: Path) -> dict[str, dict[str, str]]:
by_title: dict[str, dict[str, str]] = {}
for path in bundle.rglob("*.md"):
if path.name in ("index.md", "log.md"):
continue
head = path.read_text(encoding="utf-8").split("\n---\n", 1)[0]
values = dict(line.split(": ", 1) for line in head.split("\n")[1:] if ": " in line)
by_title[values["title"]] = values
return by_title
def _build(tmp_path: Path, name: str, *extra: str) -> Path:
inbox = tmp_path / "inbox"
if not inbox.exists():
inbox.mkdir()
(inbox / FIXTURE.name).write_bytes(FIXTURE.read_bytes())
bundle = tmp_path / name
assert (
cli.main(
[
"build",
str(inbox),
"--bundle",
str(bundle),
"--bundle-id",
"inherit-fixture",
"--okf-version",
"0.2",
*extra,
]
)
== 0
)
return bundle
def test_the_door_writes_parent_as_the_ancestor_s_segment_id(tmp_path: Path) -> None:
concepts = _frontmatter(_build(tmp_path, "flagged", "--shell-parent"))
for title, ancestor in EXPECTED.items():
if ancestor is None:
assert "parent" not in concepts[title]
else:
assert concepts[title]["parent"] == concepts[ancestor]["segment_id"]
def test_a_build_without_the_flag_is_the_build_with_the_opt_out(tmp_path: Path) -> None:
default = _build(tmp_path, "default")
opted_out = _build(tmp_path, "opted-out", "--no-shell-parent")
flagged = _build(tmp_path, "flagged", "--shell-parent")
files = sorted(p.relative_to(default) for p in default.rglob("*") if p.is_file())
assert files == sorted(p.relative_to(opted_out) for p in opted_out.rglob("*") if p.is_file())
for relative in files:
assert (default / relative).read_bytes() == (opted_out / relative).read_bytes()
# The flag adds exactly one `parent:` line to each shell with an ancestor.
added = 0
for relative in files:
before = (default / relative).read_text(encoding="utf-8").split("\n")
after = (flagged / relative).read_text(encoding="utf-8").split("\n")
extra = [line for line in after if line not in before]
assert all(line.startswith("parent: ") for line in extra)
assert [line for line in after if not line.startswith("parent: ")] == before
added += len(extra)
assert added == sum(1 for ancestor in EXPECTED.values() if ancestor is not None)