"""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")