portfolio-optimiser-claude/tests/test_okf.py
Kjell Tore Guttormsen f41264fbd3 fix(okf): the index entry point never requires 'type', frontmatter or not
navigate_bundle died on an index.md that carried a frontmatter block without
a 'type' field: _parse_index_entry keyed its tolerance on the ABSENCE of the
block, so a frontmatter-ful index fell through to parse_concept_file and
raised. Not merely the index read failed — the whole navigation did.

OKF v0.2 (A-E6) triggers this: it stamps okf_version into index.md. Our own
assumption ("a generated index has no frontmatter") is frozen in the golden,
so nothing caught it. The defect is ours, not theirs.

method-spec §3 Step 1 renders the index as "the index body (the summary)"
and every OTHER file as a "non-index concept file" — the index is not a
concept file, so the 'type' requirement never reached it in the first place.
The tolerance is now keyed on being the index, which is what it always meant.

Load-bearing, both directions detach-proved:
- test_index_with_frontmatter_lacking_type_navigates goes RED when the
  tolerance is re-keyed on absence-of-frontmatter.
- test_missing_type_is_an_error goes RED when the index default leaks onto
  concept files. Its vehicle moved from index.md to a non-index file: it
  proves the concept-file rule, and an index.md vehicle now proves the
  opposite of what the test is named for. This also closes a real gap —
  nothing tested a non-index file WITH frontmatter but WITHOUT 'type'.

Goldens untouched (shared bundle has type: index; ingest golden index has no
frontmatter). Suite 627 -> 628 passed.
2026-07-26 14:48:37 +02:00

270 lines
13 KiB
Python

"""OKF navigation + read-context rendering (method-spec §3 Step 1, §11).
Load-bearing seams proved here:
- **Verdict-layer exclusion:** the realization signal must NEVER appear in the
rendered read-context — prior verdicts reach the prompt ONLY via the gated
experience fold. The test is RED if ``type: verdict`` files leak into rendering.
- **Context-seam purity (import guard):** the navigation/context module imports
no agent toolkit — and stays pure stdlib (D7-portable by design).
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
import pytest
from portfolio_optimiser_claude.okf import ConceptFile, bundle_context, navigate_bundle
BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
SRC_PKG = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser_claude"
def _write(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
def _make_bundle(tmp_path: Path, index_body: str, files: dict[str, str]) -> Path:
_write(tmp_path / "index.md", f"---\ntype: index\ntitle: Test\n---\n{index_body}\n")
for name, text in files.items():
_write(tmp_path / name, text)
return tmp_path
class TestNavigation:
"""§3 Step 1: navigate from index.md — deterministic, tolerant, boundary-checked."""
def test_missing_index_is_an_error(self, tmp_path: Path) -> None:
# A bundle has no entry point without index.md.
with pytest.raises(FileNotFoundError):
navigate_bundle(tmp_path)
def test_order_is_index_first_then_first_seen_deduplicated(self, tmp_path: Path) -> None:
bundle = _make_bundle(
tmp_path,
"See [b](b.md) then [a](a.md) then [b again](b.md).",
{
"a.md": "---\ntype: project\ntitle: A\n---\nBody A.",
"b.md": "---\ntype: reference\ntitle: B\n---\nBody B.",
},
)
names = [c.path.name for c in navigate_bundle(bundle)]
assert names == ["index.md", "b.md", "a.md"]
def test_frontmatterless_index_navigates(self, tmp_path: Path) -> None:
# method-spec §3 Step 1: the index contributes "the index body (the summary)"
# and is NOT a "non-index concept file" rendered as a `## {type}: {title}`
# section — so a generated index carrying only the summary + links (ingest
# spec §6 shape, frozen in the ingest golden) is valid. The index MAY omit
# frontmatter; okf must navigate it via its body, never raise.
_write(tmp_path / "index.md", "Summary line.\n- [A](a.md)\n")
_write(tmp_path / "a.md", "---\ntype: project\ntitle: A\n---\nBody A.")
concepts = navigate_bundle(tmp_path)
assert [c.path.name for c in concepts] == ["index.md", "a.md"]
index = concepts[0]
assert index.type == "index" # default type, no KeyError for downstream .type reads
assert index.body == "Summary line.\n- [A](a.md)" # body = the whole file
# The summary flows into the read-context as the leading section.
assert bundle_context(tmp_path).startswith("Summary line.")
def test_index_with_frontmatter_lacking_type_navigates(self, tmp_path: Path) -> None:
# The index is the bundle ENTRY POINT, not a "non-index concept file" — so
# `type` is not required OF IT, whether or not it carries a frontmatter
# block. Keying the tolerance on the ABSENCE of frontmatter was the defect:
# an index that carries frontmatter for some OTHER reason (an OKF v0.2
# generated index stamps `okf_version` there) reached `parse_concept_file`
# and killed the whole navigation — not just the index read. RED if the
# tolerance is keyed on absence-of-frontmatter again.
_write(tmp_path / "index.md", "---\nokf_version: 0.2\n---\nSummary line.\n- [A](a.md)\n")
_write(tmp_path / "a.md", "---\ntype: project\ntitle: A\n---\nBody A.")
concepts = navigate_bundle(tmp_path)
assert [c.path.name for c in concepts] == ["index.md", "a.md"]
index = concepts[0]
assert index.type == "index" # defaulted, not read from the file
assert index.frontmatter["okf_version"] == "0.2" # unknown fields preserved
assert index.body == "Summary line.\n- [A](a.md)" # frontmatter stripped from body
assert bundle_context(tmp_path).startswith("Summary line.")
def test_frontmatterless_tolerance_is_scoped_to_the_index(self, tmp_path: Path) -> None:
# LOAD-BEARING (honesty): the relaxation is for the index ENTRY POINT only.
# A NON-index concept file without frontmatter is still malformed and MUST
# raise — else the relaxation has silently weakened the `type`-required rule
# for concept files (method-spec §3 Step 1). RED if the tolerance leaks.
_write(tmp_path / "index.md", "Summary.\n- [A](a.md)\n")
_write(tmp_path / "a.md", "no frontmatter here\n")
with pytest.raises(ValueError, match="frontmatter"):
navigate_bundle(tmp_path)
def test_out_of_bundle_and_escaping_targets_are_skipped(self, tmp_path: Path) -> None:
# A target containing a path separator is out-of-bundle — skipped, never
# raised; ../-escapes never resolve outside the bundle (fail-closed).
outside = tmp_path / "outside.md"
_write(outside, "---\ntype: project\ntitle: Outside\n---\nSecret.")
bundle_dir = tmp_path / "bundle"
bundle_dir.mkdir()
bundle = _make_bundle(
bundle_dir,
"Links: [up](../outside.md), [sub](sub/inner.md), [ok](a.md).",
{"a.md": "---\ntype: project\ntitle: A\n---\nBody A."},
)
names = [c.path.name for c in navigate_bundle(bundle)]
assert names == ["index.md", "a.md"]
def test_broken_link_is_tolerated_never_raised(self, tmp_path: Path) -> None:
bundle = _make_bundle(
tmp_path,
"Links: [gone](missing.md), [ok](a.md).",
{"a.md": "---\ntype: project\ntitle: A\n---\nBody A."},
)
names = [c.path.name for c in navigate_bundle(bundle)]
assert names == ["index.md", "a.md"]
def test_null_byte_target_is_tolerated_never_raised(self, tmp_path: Path) -> None:
# LOAD-BEARING (§11, method-spec §72: a broken cross-link MUST be "skipped,
# never raised"). A NUL-byte target carries no path separator, so it slips
# past the out-of-bundle filter and reaches resolution, where
# ``(bundle_dir / target).resolve()`` raises ``ValueError: embedded null
# byte``. That ValueError MUST be caught and the link skipped — a NUL target
# is a broken link, not a fatal error. This is the FIRST test that drives a
# dangerous target THROUGH the filter into resolution (the ``/``-filter
# short-circuits every ``/``-bearing and scheme-bearing case before it).
# RED (ValueError propagates) if the resolve guard is detached.
bundle = _make_bundle(
tmp_path,
"Links: [bad](ba\x00d.md), [ok](a.md).",
{"a.md": "---\ntype: project\ntitle: A\n---\nBody A."},
)
names = [c.path.name for c in navigate_bundle(bundle)]
assert names == ["index.md", "a.md"]
def test_shared_bundle_navigates_all_linked_concepts(self) -> None:
# Integration on the shared example bundle: every index-linked file is
# reached exactly once; the out-of-bundle ../../README.md link is skipped.
names = [c.path.name for c in navigate_bundle(BUNDLE)]
assert names[0] == "index.md"
assert len(names) == len(set(names))
assert set(names) == {
"index.md",
"bygg-kontor-nord.md",
"tiltak-led-retrofit.md",
"metode-ipmvp-a.md",
"kilder-realiseringsgap.md",
"verdict-led-fro.md",
}
class TestFrontmatter:
"""§3 Step 1: leading ``---`` block, line-oriented ``key: value``; ``type`` required."""
def test_missing_type_is_an_error(self, tmp_path: Path) -> None:
# LOAD-BEARING (honesty), and the scope guard for the index tolerance: the
# vehicle MUST be a NON-index concept file. `type` is required of concept
# files, never of the entry point — an index.md vehicle would only re-assert
# the entry-point behaviour, which is now (correctly) the opposite. RED if
# the index's `type` default ever leaks onto concept files.
_write(tmp_path / "index.md", "Summary. See [a](a.md).")
_write(tmp_path / "a.md", "---\ntitle: No type\n---\nBody.")
with pytest.raises(ValueError, match="type"):
navigate_bundle(tmp_path)
def test_unknown_fields_preserved_and_quotes_stripped(self, tmp_path: Path) -> None:
_write(
tmp_path / "index.md",
'---\ntype: index\ntitle: "Quoted title"\ncustom_field: kept\n---\nBody.',
)
index = navigate_bundle(tmp_path)[0]
assert isinstance(index, ConceptFile)
assert index.type == "index"
assert index.title == "Quoted title"
assert index.frontmatter["custom_field"] == "kept"
class TestRendering:
"""§3 Step 1: index body first, then ``## {type}: {title}`` sections."""
def test_sections_are_typed_and_titled_after_index_body(self, tmp_path: Path) -> None:
bundle = _make_bundle(
tmp_path,
"The summary.\n\nSee [a](a.md).",
{"a.md": "---\ntype: project\ntitle: Building A\n---\nProject body."},
)
context = bundle_context(bundle)
assert context.startswith("The summary.")
assert "## project: Building A" in context
assert "Project body." in context
def test_empty_sections_are_dropped(self, tmp_path: Path) -> None:
bundle = _make_bundle(
tmp_path,
"Summary. See [empty](empty.md).",
{"empty.md": "---\ntype: reference\ntitle: Empty\n---\n"},
)
context = bundle_context(bundle)
assert "## reference: Empty" not in context
class TestVerdictLayerExclusion:
"""LOAD-BEARING (§3 Step 1, §11): verdicts never leak via the read-context."""
def test_navigable_verdict_is_excluded_from_rendering(self, tmp_path: Path) -> None:
# Control + seam in one: the verdict file IS navigable (so exclusion is
# doing real work), yet its unique marker never reaches the read-context.
# RED if rendering stops gating on ``type: verdict``.
marker = "UNIQUE-REALIZATION-MARKER-0.82"
bundle = _make_bundle(
tmp_path,
"Summary. See [v](v.md) and [a](a.md).",
{
"v.md": f"---\ntype: verdict\ntitle: Seed\n---\nSignal: {marker}.",
"a.md": "---\ntype: project\ntitle: A\n---\nBody A.",
},
)
navigated = [c.path.name for c in navigate_bundle(bundle)]
assert "v.md" in navigated
context = bundle_context(bundle)
assert marker not in context
assert "## verdict" not in context
assert "## project: A" in context
def test_shared_bundle_context_carries_no_realization_signal(self) -> None:
# The seed verdict's learning signal (realization rate 0.82, expected
# actual 24 600 NOK) must be absent from the rendered context — it may
# reach the prompt ONLY via the gated experience fold.
context = bundle_context(BUNDLE)
assert "## verdict" not in context
for leak in ("0.82", "0,82", "24600", "24 600", "realization_rate"):
assert leak not in context
# ...while the non-verdict concept layers ARE rendered:
assert "## project:" in context
assert "## hypothesis:" in context
assert "## methodology:" in context
assert "## reference:" in context
def _imported_module_names(module_path: Path) -> set[str]:
tree = ast.parse(module_path.read_text(encoding="utf-8"))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
names.add(node.module.split(".")[0])
return names
class TestContextSeamPurity:
"""LOAD-BEARING (§11): the context seam imports no agent toolkit."""
@pytest.mark.parametrize("module", ["okf.py", "experience.py"])
def test_context_seam_never_imports_an_agent_toolkit(self, module: str) -> None:
names = _imported_module_names(SRC_PKG / module)
assert not names & {"claude_agent_sdk", "anthropic"}
def test_okf_is_pure_stdlib(self) -> None:
# D7-portable by design: navigation/rendering depends on nothing beyond
# the standard library (relative imports would show as level > 0).
names = _imported_module_names(SRC_PKG / "okf.py")
assert names <= set(sys.stdlib_module_names)