portfolio-optimiser/tests/test_method_spec_loadbearing.py
Kjell Tore Guttormsen 5de1c93b69 feat(spec): S2 — normative framework-neutral method spec as the fourth shared artifact
Author shared/method-spec.md: the 8-step loop (normative, RFC-2119), the
verdict JSON contract incl. the id-minting algorithm and the chosen conflict
semantics, the inbox/outbox folder contract, the fail-closed promotion-gate
semantics, the IR projection + golden suite as the only ground truth (incl.
the reproducible Monte Carlo procedure), and the budget/stop, provenance and
startup-contract requirements — every normative claim cross-checked against
the load-bearing tests/code. The sibling implementation builds from this spec
alone.

Load-bearing trio (tests/test_method_spec_loadbearing.py, persona-trio
style): required structure, a name-shaped framework-neutrality guard over the
spec + the persona skill tree, and a cross-check-completeness test driven
from the REAL artifacts and the REAL verdict serializer (red on code drift).
All three detach points proven RED (missing file / framework name / dropped
field). shared/README.md: the "(planned)" line replaced with the real entry.

Suite 152 -> 155 passed / 4 skipped; ruff check+format clean; mypy src clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaQCFnfsh3tfq1VfzdJpoi
2026-07-03 00:50:42 +02:00

131 lines
6.3 KiB
Python

"""Load-bearing tests for the shared method specification (S2, målbilde §8 — the fourth shared
artifact).
The method spec is the normative, framework-neutral document the sibling implementation is built
FROM — it must be implementable from the spec alone, without reverse-engineering this repo's code.
These tests make the artifact load-bearing in the persona-trio's style: structure + framework
neutrality (grep-shaped), and a cross-check that every field of the machine-readable contracts the
pipeline actually consumes is documented in the spec. Each test goes RED when its seam is detached:
the spec goes missing, prose starts naming a concrete framework, or the code's verdict contract
drifts away from what the spec documents.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from portfolio_optimiser.verdicts import ProposalFeatures, capture_verdict, verdict_to_dict
REPO_ROOT = Path(__file__).resolve().parents[1]
SPEC_PATH = REPO_ROOT / "shared" / "method-spec.md"
SKILL_DIR = REPO_ROOT / "shared" / "skills" / "expert-reviewer"
EXAMPLE_VERDICT = SKILL_DIR / "references" / "example-verdict.json"
BUNDLE_DIR = REPO_ROOT / "shared" / "examples" / "bygg-energi-mikro"
# Name-shaped framework guard (stricter than the persona test's import-shaped guard): the spec's
# PROSE must never name a concrete agent framework or vendor stack — the same rule the persona
# SKILL.md follows (CLAUDE.md §delt ekspert-persona). Scoped to the spec + the persona skill tree;
# shared/README.md and the bundle legitimately NAME the two implementations and are exempt.
_FRAMEWORK_NAMES = re.compile(
r"\bagent[_ -]framework\b|\bMAF\b|\bClaude\b|\bAnthropic\b|\bMicrosoft\b|\bAzure\b"
r"|\bFoundry\b|\bOpenAI\b|\bOllama\b|\bLangChain\b|\bAutoGen\b|\bSemantic Kernel\b"
r"|\bCrewAI\b|\bMagentic\b",
re.IGNORECASE,
)
# The spec's required skeleton: the 8-step loop normative + one section per contract the session
# plan names (verdict JSON, inbox/outbox, promotion gate, IR+golden as fasit, budget, provenance)
# + the cross-check table the verification criterion requires.
_REQUIRED_MARKERS = [
"# Method specification",
"## 1. Scope",
"## 3. The loop",
"## 4. The verdict contract",
"## 5. The inbox/outbox folder contract",
"## 6. The promotion gate",
"## 7. Ground truth",
"## 8. Budget and stop criteria",
"## 9. Provenance",
"## 12. Cross-check table",
]
def _spec_text() -> str:
assert SPEC_PATH.is_file(), "shared/method-spec.md missing (the fourth shared artifact)"
return SPEC_PATH.read_text(encoding="utf-8")
def test_method_spec_exists_with_required_structure() -> None:
"""Test 1: the spec exists and carries the normative skeleton — the 8 steps plus every
contract section the session plan names. RED before the artifact exists, or when a required
section is dropped."""
text = _spec_text()
for marker in _REQUIRED_MARKERS:
assert marker in text, f"method-spec.md is missing required section marker: {marker!r}"
for step in range(1, 9):
assert f"### Step {step}" in text, f"method-spec.md must specify loop step {step}"
# Normative, not descriptive: RFC-2119-style requirement language must be present.
assert "MUST" in text, "the spec must be normative (RFC-2119 MUST language)"
def test_method_spec_is_framework_neutral() -> None:
"""Test 2 (grep-shaped guard): the spec — and the persona skill tree it sits beside — never
NAME a concrete agent framework or vendor stack. Mirrors the persona SKILL.md rule; stricter
than the import-shaped guard because the spec is pure prose (no AST to parse). RED the moment
a framework name leaks into the shared method prose."""
for f in [SPEC_PATH, *sorted(p for p in SKILL_DIR.rglob("*") if p.is_file())]:
assert f.is_file(), f"expected shared artifact missing: {f}"
hit = _FRAMEWORK_NAMES.search(f.read_text(encoding="utf-8"))
assert hit is None, f"framework name {hit.group(0)!r} in shared method prose: {f}"
def test_spec_documents_every_contract_field() -> None:
"""Test 3 (cross-check completeness): every field of the machine-readable contracts the
pipeline consumes is documented in the spec — driven from the REAL artifacts and the REAL
serializer, so the test goes RED when either the persona example, the golden suite, the IR
projection, or the code's verdict serialization drifts away from the spec."""
text = _spec_text()
def documented(field: str, source: str) -> None:
assert f"`{field}`" in text, f"spec does not document {source} field `{field}`"
# Persona-artifact contract: the canonical example verdict's fields, read from the artifact.
example = json.loads(EXAMPLE_VERDICT.read_text(encoding="utf-8"))
for field in example:
documented(field, "example-verdict.json")
# Serialized verdict contract: emitted by the REAL serializer (red on code drift).
verdict = capture_verdict(
ProposalFeatures(
affected_codes=frozenset({"X.1"}),
measure_type="m",
claimed_saving_nok=1.0,
description="d",
),
"approved",
"r",
)
payload = verdict_to_dict(verdict)
for field in payload:
documented(field, "verdict file")
for field in payload["proposal_features"]:
documented(field, "verdict proposal_features")
# IR projection + golden suite: the ground-truth contracts, read from the shared fixture
# ("_"-prefixed keys are annotations, not contract).
ir = json.loads((BUNDLE_DIR / "validator-input.json").read_text(encoding="utf-8"))
for field in (k for k in ir if not k.startswith("_")):
documented(field, "validator-input.json")
for field in ir["affected_items"][0]:
documented(field, "affected_items")
golden = json.loads((BUNDLE_DIR / "golden.json").read_text(encoding="utf-8"))
for section in ("validator", "learning_surface"):
for field in (k for k in golden[section] if not k.startswith("_")):
documented(field, f"golden.json {section}")
# The decision vocabulary: binary on the run path; the gate additionally admits the seed's
# approved_with_adjustment (and nothing else may claim otherwise).
for value in ("approved", "rejected", "approved_with_adjustment"):
documented(value, "decision vocabulary")