From 36606ebee72aca422e76a7773c2cb37cd008eb3d Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Wed, 24 Jun 2026 13:50:28 +0200 Subject: [PATCH] feat(fase2): fail-fast contract loaders --- src/portfolio_optimiser/contracts.py | 96 ++++++++++++++++++++++++++++ tests/test_contracts.py | 50 +++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/portfolio_optimiser/contracts.py create mode 100644 tests/test_contracts.py diff --git a/src/portfolio_optimiser/contracts.py b/src/portfolio_optimiser/contracts.py new file mode 100644 index 0000000..23adc4f --- /dev/null +++ b/src/portfolio_optimiser/contracts.py @@ -0,0 +1,96 @@ +"""Fail-fast contract loaders (brief NFR: validate ALL configs at startup, before any +chat-client is constructed or called). + +Four Pydantic contracts give JSON-Schema-grade validation (CLAUDE.md convention): + +* ``DataSourceContract`` — the local-folder data source (docs dir + top_k). +* ``ModelMapContract`` — validates ``data/model_map.json`` (Step 8): a role->model map per + backend ``Profile``, each with a ``default``. +* ``TerminationContract`` — the stop criteria + budget cap (max_rounds, max_tokens) required + at startup (never an unbounded loop). +* ``FeedbackContract`` — the expert-verdict feedback shape (decision + rationale). + +``load_contracts`` validates all four and raises ``pydantic.ValidationError`` on the first +malformed one — purely at the config layer, so it can run before any backend/client exists. +""" + +from __future__ import annotations + +import json +from importlib.resources import files +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + +from portfolio_optimiser.backends import Profile + +_MODEL_MAP_RESOURCE = "data/model_map.json" + + +class DataSourceContract(BaseModel): + """The local-folder data source config (JSON-Schema-validated, fail-fast).""" + + docs_dir: str = Field(min_length=1) + top_k: int = Field(gt=0) + + +class ModelMapContract(BaseModel): + """Role -> model/deployment map per backend profile (validates data/model_map.json).""" + + local: dict[str, str] = Field(min_length=1) + azure: dict[str, str] = Field(min_length=1) + + @model_validator(mode="after") + def _each_profile_has_default(self) -> ModelMapContract: + for prof in (Profile.LOCAL.value, Profile.AZURE.value): + if "default" not in getattr(self, prof): + raise ValueError(f"model_map.{prof} must include a 'default' model id") + return self + + +class TerminationContract(BaseModel): + """Stop criteria + budget cap required at startup (fail-fast, never unbounded).""" + + max_rounds: int = Field(gt=0) + max_tokens: int = Field(gt=0) + + +class FeedbackContract(BaseModel): + """The expert-verdict feedback shape fed back into the VerdictStore (Layer-2).""" + + decision: Literal["approved", "rejected"] + rationale: str = Field(min_length=1) + + +class Contracts(BaseModel): + """The validated bundle of all startup contracts.""" + + data_source: DataSourceContract + model_map: ModelMapContract + termination: TerminationContract + feedback: FeedbackContract + + +def _bundled_model_map() -> dict[str, Any]: + return json.loads( + files("portfolio_optimiser").joinpath(_MODEL_MAP_RESOURCE).read_text(encoding="utf-8") + ) + + +def load_contracts( + data_source: dict[str, Any], + termination: dict[str, Any], + feedback: dict[str, Any], + *, + model_map: dict[str, Any] | None = None, +) -> Contracts: + """Validate ALL contracts at startup (fail-fast, before any chat-client is built). Raises + ``pydantic.ValidationError`` on the first malformed contract. ``model_map`` defaults to the + bundled ``data/model_map.json`` (the same file Step 8 ships).""" + raw_map = _bundled_model_map() if model_map is None else model_map + return Contracts( + data_source=DataSourceContract(**data_source), + model_map=ModelMapContract(**raw_map), + termination=TerminationContract(**termination), + feedback=FeedbackContract(**feedback), + ) diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..82343d0 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,50 @@ +"""Step 11 tests — fail-fast contract loaders (malformed config raises at startup, no LLM). + +Every malformed contract raises a ValidationError at load — and, critically, no chat-client +is ever touched (the validation is pure config-layer, ordered before any backend exists). +Pattern: tests/test_backends.py (raises) + FakeChatClient.call_count spy. +""" + +import pytest +from pydantic import ValidationError +from spikes._harness import FakeChatClient + +from portfolio_optimiser.contracts import Contracts, load_contracts + +_DS = {"docs_dir": "docs", "top_k": 3} +_TERM = {"max_rounds": 3, "max_tokens": 10000} +_FB = {"decision": "approved", "rationale": "feasible within range"} + + +def test_valid_contracts_load() -> None: + contracts = load_contracts(_DS, _TERM, _FB) + assert isinstance(contracts, Contracts) + assert contracts.model_map.local["default"] # bundled model_map validated + + +def test_malformed_data_source_raises() -> None: + with pytest.raises(ValidationError): + load_contracts({"docs_dir": "docs", "top_k": 0}, _TERM, _FB) # top_k must be > 0 + + +def test_malformed_model_map_raises() -> None: + bad_map = {"local": {"proposer": "qwen3:4b"}, "azure": {"default": "x"}} # local missing 'default' + with pytest.raises(ValidationError): + load_contracts(_DS, _TERM, _FB, model_map=bad_map) + + +def test_malformed_termination_raises() -> None: + with pytest.raises(ValidationError): + load_contracts(_DS, {"max_rounds": 0, "max_tokens": 10000}, _FB) # unbounded + + +def test_malformed_feedback_raises() -> None: + with pytest.raises(ValidationError): + load_contracts(_DS, _TERM, {"decision": "maybe", "rationale": "x"}) # not a literal + + +def test_no_chat_client_call_on_malformed_contract() -> None: + spy = FakeChatClient() + with pytest.raises(ValidationError): + load_contracts({"docs_dir": "docs", "top_k": -1}, _TERM, _FB) + assert spy.call_count == 0 # contract validation never constructs or calls a client