- File-layer decision vocabulary (§4.2 set) with SKIP semantics — an unknown decision never reaches the store (C-F7, the review's run proof is the fixture) - Fail-fast caps (max_files / max_rationale_chars) via InboxLimitError raised OUTSIDE the tolerant try — a cap breach is never swallowed as a skip - R-6 id grammar (mirrors ingest _ID_RE) as a pydantic pattern on VerdictDocument.id AND re-checked in write_verdict, since model_copy(update=) bypasses model validation — traversal ids can no longer write outside the inbox - promotion._filename_token: any sanitised id maps to a content hash — 'e/vil' can no longer clobber the distinct id 'evil' (restarbeid-funn 2) - SDK pinned >=0.2.111,<0.3 + version guard test naming the sdk_client.py attribute premises; resolved 0.2.120, all premises re-verified against it - sdk_client read loop bound offline with REAL SDK message types (R-4/R-5): text aggregation, error fail-paths, usage/cost extraction, _total_tokens fail-closed, non-positive budget guard - test_sdk_isolation comment no longer claims the --system-prompt "" serialization the test body does not bind (honesty rule §1) Guard-G2 assessment (guard-plan §4): the allowlist + caps + id grammar landed here are G2's necessary part; an optional scan_output depth pass over rationale (still a verbatim prose channel into the fold prompt, R-9) remains relevant as a later additive session — the trigger picture is unchanged. 4 detach proofs red → restored green. Full gate: 389 passed (365→389), ruff+format+mypy clean; golden + shared/ + runs/s10/ byte-untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
9.6 KiB
Python
223 lines
9.6 KiB
Python
"""Step-8 promotion gate — LOAD-BEARING (method-spec §3 Step 8, §6, §11).
|
|
|
|
The seam this file keeps alive: only APPROVED knowledge enters the wiki
|
|
(fail-closed), the promoted file is NAVIGABLE (index-linked — an unlinked file
|
|
is unreachable), and the index label is NEUTRAL (the index body flows verbatim
|
|
into the rendered read-context, so a descriptive label would leak the learning
|
|
signal around the gated fold). RED when a non-approved verdict reaches the
|
|
wiki, when an approved one is not navigable, or when the label leaks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from portfolio_optimiser_claude.experience import (
|
|
CandidateFeatures,
|
|
VerdictStore,
|
|
fold_experience,
|
|
seed_store_from_bundle,
|
|
)
|
|
from portfolio_optimiser_claude.inbox import VerdictDocument
|
|
from portfolio_optimiser_claude.ir import load_validator_input
|
|
from portfolio_optimiser_claude.okf import bundle_context, navigate_bundle, parse_concept_file
|
|
from portfolio_optimiser_claude.promotion import PromotionError, promote
|
|
|
|
SHARED_BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
|
|
|
MARKER = "realiseringsgrad=0.79"
|
|
RATIONALE = f"Godkjent med korreksjon: i drift realiseres ~79% ({MARKER})."
|
|
BASE_PROMPT = "Propose exactly one cost-saving measure for this project."
|
|
|
|
# Structured learning fields a promoted file MUST NOT reproduce (§6) — the raw
|
|
# verdict model carries the signal only as rationale prose.
|
|
_SEED_ONLY_FIELDS = (
|
|
"realization_rate",
|
|
"expected_actual_saving_nok",
|
|
"modelled_saving_nok",
|
|
"gap_source",
|
|
"context_key",
|
|
)
|
|
|
|
|
|
def _document(
|
|
decision: str = "approved",
|
|
rationale: str = RATIONALE,
|
|
codes: frozenset[str] = frozenset({"E01"}),
|
|
verdict_id: str | None = None,
|
|
) -> VerdictDocument:
|
|
doc = VerdictDocument.from_candidate(
|
|
CandidateFeatures(
|
|
affected_codes=codes, measure_type="led-retrofit", claimed_saving_nok=25000.0
|
|
),
|
|
decision=decision,
|
|
rationale=rationale,
|
|
description="LED retrofit",
|
|
)
|
|
if verdict_id is not None:
|
|
doc = doc.model_copy(update={"id": verdict_id})
|
|
return doc
|
|
|
|
|
|
def _promote(doc: VerdictDocument, bundle: Path) -> Path:
|
|
return promote(
|
|
doc,
|
|
bundle,
|
|
approved_by="persona:expert-reviewer",
|
|
experiment="S9-offline-sim",
|
|
timestamp="2026-07-03T12:00:00Z",
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def bundle(tmp_path: Path) -> Path:
|
|
target = tmp_path / "bundle"
|
|
shutil.copytree(SHARED_BUNDLE, target)
|
|
return target
|
|
|
|
|
|
class TestFailClosed:
|
|
"""§6: a non-approved verdict is refused — writing and linking NOTHING."""
|
|
|
|
@pytest.mark.parametrize("decision", ["rejected", "maybe"])
|
|
def test_non_approved_decisions_are_refused_writing_nothing(
|
|
self, bundle: Path, decision: str
|
|
) -> None:
|
|
index_before = (bundle / "index.md").read_bytes()
|
|
files_before = sorted(p.name for p in bundle.iterdir())
|
|
with pytest.raises(PromotionError):
|
|
_promote(_document(decision=decision), bundle)
|
|
assert (bundle / "index.md").read_bytes() == index_before
|
|
assert sorted(p.name for p in bundle.iterdir()) == files_before
|
|
|
|
@pytest.mark.parametrize("decision", ["approved", "approved_with_adjustment"])
|
|
def test_the_accepted_set_is_exactly_the_two_approval_forms(
|
|
self, bundle: Path, decision: str
|
|
) -> None:
|
|
assert _promote(_document(decision=decision), bundle).is_file()
|
|
|
|
|
|
class TestPromotedFile:
|
|
"""§6: minimal promoted file — signal as rationale prose, provenance-stamped."""
|
|
|
|
def test_frontmatter_carries_the_contract_fields(self, bundle: Path) -> None:
|
|
doc = _document()
|
|
concept = parse_concept_file(_promote(doc, bundle))
|
|
assert concept.type == "verdict"
|
|
assert concept.frontmatter["decision"] == "approved"
|
|
assert concept.frontmatter["description"] == RATIONALE
|
|
assert concept.frontmatter["verdict_id"] == doc.id # verbatim
|
|
provenance = concept.frontmatter["provenance"]
|
|
for part in ("persona:expert-reviewer", "S9-offline-sim", "2026-07-03T12:00:00Z"):
|
|
assert part in provenance
|
|
|
|
def test_no_structured_learning_fields_are_reproduced(self, bundle: Path) -> None:
|
|
concept = parse_concept_file(_promote(_document(), bundle))
|
|
for field in _SEED_ONLY_FIELDS:
|
|
assert field not in concept.frontmatter
|
|
|
|
def test_timestamp_is_an_explicit_required_argument(self, bundle: Path) -> None:
|
|
# No wall-clock default — promotion is deterministic and reproducible.
|
|
with pytest.raises(TypeError):
|
|
promote( # type: ignore[call-arg]
|
|
_document(), bundle, approved_by="x", experiment="y"
|
|
)
|
|
|
|
def test_same_candidate_id_is_last_write_wins_per_file(self, bundle: Path) -> None:
|
|
first = _promote(_document(rationale=f"first ({MARKER})"), bundle)
|
|
second = _promote(_document(rationale=f"second ({MARKER})"), bundle)
|
|
assert first == second
|
|
assert parse_concept_file(second).frontmatter["description"] == f"second ({MARKER})"
|
|
|
|
|
|
class TestNavigability:
|
|
"""LOAD-BEARING (§11): an approved verdict must be navigable — and fold back."""
|
|
|
|
def test_promoted_file_is_reachable_via_index_navigation(self, bundle: Path) -> None:
|
|
path = _promote(_document(), bundle)
|
|
assert path.name in {c.path.name for c in navigate_bundle(bundle)}
|
|
|
|
def test_promoted_verdict_closes_the_loop_through_seeding(self, bundle: Path) -> None:
|
|
# Promotion → navigation → seeding → fold: the expert's rationale (the
|
|
# learning signal as prose) reaches the next run's hypothesis prompt.
|
|
_promote(_document(), bundle)
|
|
store = VerdictStore()
|
|
seed_store_from_bundle(store, bundle)
|
|
features = CandidateFeatures.from_proposal(load_validator_input(bundle))
|
|
prompt = fold_experience(store, features, BASE_PROMPT, k=3)
|
|
assert MARKER in prompt
|
|
|
|
def test_two_promoted_candidates_both_seed_verbatim_ids(self, bundle: Path) -> None:
|
|
# §4.2 read-VERBATIM at the seeding layer: distinct candidates get
|
|
# distinct store entries — re-minting from bundle features would
|
|
# collide them (first-write-wins) and silently drop one rationale.
|
|
_promote(_document(codes=frozenset({"E01"}), rationale=f"one ({MARKER})"), bundle)
|
|
_promote(_document(codes=frozenset({"E02"}), rationale=f"two ({MARKER})"), bundle)
|
|
store = VerdictStore()
|
|
seed_store_from_bundle(store, bundle)
|
|
features = CandidateFeatures.from_proposal(load_validator_input(bundle))
|
|
rationales = {r.rationale for r in store.retrieve(features, k=10)}
|
|
assert f"one ({MARKER})" in rationales
|
|
assert f"two ({MARKER})" in rationales
|
|
|
|
def test_linking_is_idempotent(self, bundle: Path) -> None:
|
|
path = _promote(_document(), bundle)
|
|
_promote(_document(), bundle)
|
|
index_text = (bundle / "index.md").read_text(encoding="utf-8")
|
|
assert index_text.count(f"]({path.name})") == 1
|
|
|
|
|
|
class TestNeutralLabel:
|
|
"""LOAD-BEARING (§11): the index label carries NO verdict signal."""
|
|
|
|
def test_index_never_carries_the_rationale_or_marker(self, bundle: Path) -> None:
|
|
_promote(_document(), bundle)
|
|
index_text = (bundle / "index.md").read_text(encoding="utf-8")
|
|
assert MARKER not in index_text
|
|
assert RATIONALE not in index_text
|
|
|
|
def test_label_is_fixed_across_verdicts(self, bundle: Path) -> None:
|
|
a = _promote(_document(codes=frozenset({"E01"})), bundle)
|
|
b = _promote(_document(codes=frozenset({"E02"})), bundle)
|
|
index_lines = (bundle / "index.md").read_text(encoding="utf-8").splitlines()
|
|
line_a = next(line for line in index_lines if a.name in line)
|
|
line_b = next(line for line in index_lines if b.name in line)
|
|
assert line_a.replace(a.name, "") == line_b.replace(b.name, "")
|
|
|
|
def test_rendered_read_context_never_carries_the_marker(self, bundle: Path) -> None:
|
|
# Belt and braces: the index body flows verbatim into the rendered
|
|
# read-context — after promotion it must still exclude the signal.
|
|
_promote(_document(), bundle)
|
|
assert MARKER not in bundle_context(bundle)
|
|
|
|
|
|
class TestPathSafety:
|
|
"""§6: path-safe, fail-closed against escaping names."""
|
|
|
|
def test_escaping_id_is_sanitised_into_the_bundle(self, bundle: Path, tmp_path: Path) -> None:
|
|
path = _promote(_document(verdict_id="../../evil"), bundle)
|
|
assert path.parent == bundle
|
|
assert not (tmp_path / "evil").exists()
|
|
assert "/" not in path.name and "\\" not in path.name
|
|
|
|
def test_sanitised_ids_never_collide_with_a_distinct_id_owning_the_name(
|
|
self, bundle: Path
|
|
) -> None:
|
|
# C2.5 restarbeid-funn 2: 'e/vil' used to sanitise to 'evil' — the
|
|
# SAME promoted filename as the distinct id 'evil', so the second
|
|
# promotion silently overwrote the first curated verdict.
|
|
first = _promote(_document(verdict_id="e/vil", rationale=f"one ({MARKER})"), bundle)
|
|
second = _promote(_document(verdict_id="evil", rationale=f"two ({MARKER})"), bundle)
|
|
assert first != second
|
|
assert parse_concept_file(first).frontmatter["verdict_id"] == "e/vil"
|
|
assert parse_concept_file(second).frontmatter["verdict_id"] == "evil"
|
|
|
|
def test_degenerate_token_falls_back_to_a_content_hash(self, bundle: Path) -> None:
|
|
path = _promote(_document(verdict_id="///"), bundle)
|
|
assert path.parent == bundle
|
|
token = path.name.removeprefix("promoted-verdict-").removesuffix(".md")
|
|
assert token, "degenerate token must fall back to a non-empty content hash"
|
|
assert set(token) <= set("0123456789abcdef")
|