portfolio-optimiser-claude/tests/test_contracts.py
Kjell Tore Guttormsen f2c64da9ee feat(portfolio): K2 — sequential multi-project run (parity row 4)
New portfolio.py: run_portfolio drives N projects sequentially from a
schema-validated reference config, composing each project's §5 context
(merge inbox -> seed -> fold) and running the loop core UNCHANGED per
project, collecting one typed result per project IN CONFIG ORDER. This is
the run path MAF got in its Fase 1 and D7 never had — the prior entrances
(run.py, run_s10.py) drive a single bundle. PortfolioResult holds
per-project results tagged with the config project_id.

Re-entrancy (§3 Step 3): each project composes its OWN context inside the
loop, never a hoisted shared one, so nothing survives one project into the
next except the explicitly shared mutable state — the §8 budget meter, a
portfolio-wide cap. Failure policy is a STACK-LOCAL choice until D-D: the
default RAISES (today everything is thrown); K18 flips it to
collect-and-continue when the D-D wave model lands.

New config contract in contracts.py: ReferenceProjectContract (project_id +
required non-empty bundle_dir + optional inbox_dir) + ReferenceProjectsContract,
loaded fail-fast by load_reference_projects (§10) — a project without a
bundle path is refused before any run. New data/reference_projects.json
example (shape-validated, never executed by the suite). New repo-local
mini-bundle fixture under tests/data/ (a distinct second project, VFD-retrofit
— ALDRI in shared/).

Two detach proofs delivered: drop the bundle_dir Field requirement -> a run
starts on the invalid config and only crashes mid-run -> the fail-fast test
goes red; hoist the per-project composition out of the loop -> project 2 runs
on project 1's context and the VFD marker never reaches its prompt -> the
re-entrancy test goes red. 11 new tests (test_portfolio.py 5 +
test_contracts.py TestReferenceProjects 6). 426 -> 437 tests, golden
byte-exact, full gate clean (ruff + format + mypy strict). README synced
(test count + a Run layer module block).

[skip-docs] — README documents the new module; CLAUDE.md holds invariants
(rules/commands) only, and K2 adds no new invariant, command, or convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
2026-07-23 21:39:39 +02:00

162 lines
6.5 KiB
Python

"""Fail-fast startup-contract tests (method-spec §10, §4.1, §8).
The run must refuse to start on a bad config: missing/non-positive caps, a third
decision value on the run path, a profile without a ``default`` model. All of this
is pure config-layer validation — no model client, no API key, no network.
"""
from __future__ import annotations
from typing import Any
import pytest
from pydantic import ValidationError
from portfolio_optimiser_claude.contracts import load_contracts, load_reference_projects
VALID_DATA_SOURCE: dict[str, Any] = {"docs_dir": "docs", "top_k": 3}
VALID_TERMINATION: dict[str, Any] = {"max_rounds": 4, "max_tokens": 20_000}
VALID_FEEDBACK: dict[str, Any] = {"decision": "approved", "rationale": "sound measure"}
VALID_MODEL_MAP: dict[str, Any] = {
"profiles": {"anthropic": {"default": "some-model-id", "proposer": "some-model-id"}}
}
def load(**overrides: dict[str, Any]) -> Any:
kwargs: dict[str, Any] = {
"data_source": VALID_DATA_SOURCE,
"termination": VALID_TERMINATION,
"feedback": VALID_FEEDBACK,
"model_map": VALID_MODEL_MAP,
}
kwargs.update(overrides)
return load_contracts(
kwargs["data_source"],
kwargs["termination"],
kwargs["feedback"],
model_map=kwargs["model_map"],
)
class TestTermination:
"""§8: required at startup, positive caps, never an unbounded loop."""
def test_missing_max_tokens_rejected(self) -> None:
with pytest.raises(ValidationError):
load(termination={"max_rounds": 4})
def test_missing_max_rounds_rejected(self) -> None:
with pytest.raises(ValidationError):
load(termination={"max_tokens": 20_000})
@pytest.mark.parametrize("bad", [0, -1])
def test_nonpositive_caps_rejected(self, bad: int) -> None:
with pytest.raises(ValidationError):
load(termination={"max_rounds": bad, "max_tokens": 20_000})
with pytest.raises(ValidationError):
load(termination={"max_rounds": 4, "max_tokens": bad})
class TestFeedback:
"""§4.1: run-path decision is BINARY — exactly two values."""
@pytest.mark.parametrize("decision", ["approved", "rejected"])
def test_binary_decisions_accepted(self, decision: str) -> None:
contracts = load(feedback={"decision": decision, "rationale": "why"})
assert contracts.feedback.decision == decision
def test_third_decision_value_rejected(self) -> None:
# approved_with_adjustment lives ONLY in bundle-seed frontmatter and the
# promotion gate's accepted set — the run path must reject it (§4).
with pytest.raises(ValidationError):
load(feedback={"decision": "approved_with_adjustment", "rationale": "why"})
def test_empty_rationale_rejected(self) -> None:
with pytest.raises(ValidationError):
load(feedback={"decision": "approved", "rationale": ""})
class TestDataSource:
"""§10: a docs directory + a positive top-k."""
def test_empty_docs_dir_rejected(self) -> None:
with pytest.raises(ValidationError):
load(data_source={"docs_dir": "", "top_k": 3})
def test_nonpositive_top_k_rejected(self) -> None:
with pytest.raises(ValidationError):
load(data_source={"docs_dir": "docs", "top_k": 0})
class TestModelMap:
"""§10: role -> model id per backend profile, each profile REQUIRING a default."""
def test_profile_without_default_rejected(self) -> None:
with pytest.raises(ValidationError):
load(model_map={"profiles": {"anthropic": {"proposer": "some-model-id"}}})
def test_empty_profiles_rejected(self) -> None:
with pytest.raises(ValidationError):
load(model_map={"profiles": {}})
def test_empty_string_model_id_rejected(self) -> None:
# C2.6: an empty-string model id satisfied dict[str, str] and slipped
# through to the client layer — it is a startup schema error.
with pytest.raises(ValidationError):
load(model_map={"profiles": {"anthropic": {"default": ""}}})
def test_empty_string_role_model_id_rejected(self) -> None:
with pytest.raises(ValidationError):
load(
model_map={"profiles": {"anthropic": {"default": "some-model-id", "proposer": ""}}}
)
def test_bundled_model_map_is_valid(self) -> None:
# model_map=None falls back to the bundled data/model_map.json, which must
# itself satisfy the contract (fail-fast on the shipped config too).
contracts = load(model_map=None) # type: ignore[arg-type]
assert "default" in contracts.model_map.profiles["anthropic"]
class TestReferenceProjects:
"""§10 (K2): the portfolio config — each entry needs a non-empty bundle path."""
def test_valid_config_accepted(self) -> None:
config = load_reference_projects(
{"projects": [{"project_id": "P1", "bundle_dir": "bundles/p1"}]}
)
assert config.projects[0].project_id == "P1"
assert config.projects[0].inbox_dir is None
def test_missing_bundle_dir_rejected(self) -> None:
# The fail-fast seam K2's detach proof removes: no bundle path is a
# startup schema error, never a run that starts then crashes for lack of data.
with pytest.raises(ValidationError):
load_reference_projects({"projects": [{"project_id": "P1"}]})
def test_empty_bundle_dir_rejected(self) -> None:
with pytest.raises(ValidationError):
load_reference_projects({"projects": [{"project_id": "P1", "bundle_dir": ""}]})
def test_empty_project_id_rejected(self) -> None:
with pytest.raises(ValidationError):
load_reference_projects({"projects": [{"project_id": "", "bundle_dir": "b"}]})
def test_empty_projects_list_rejected(self) -> None:
with pytest.raises(ValidationError):
load_reference_projects({"projects": []})
def test_bundled_reference_projects_is_valid(self) -> None:
# raw=None falls back to the bundled data/reference_projects.json EXAMPLE,
# whose shape must itself satisfy the contract (fail-fast on shipped config).
config = load_reference_projects()
assert config.projects
assert all(p.bundle_dir for p in config.projects)
def test_happy_path_validates_all_four() -> None:
contracts = load()
assert contracts.data_source.top_k == 3
assert contracts.termination.max_rounds == 4
assert contracts.feedback.decision == "approved"
assert "anthropic" in contracts.model_map.profiles