feat(fase2): fail-fast contract loaders
This commit is contained in:
parent
0abc7083df
commit
36606ebee7
2 changed files with 146 additions and 0 deletions
96
src/portfolio_optimiser/contracts.py
Normal file
96
src/portfolio_optimiser/contracts.py
Normal file
|
|
@ -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),
|
||||
)
|
||||
50
tests/test_contracts.py
Normal file
50
tests/test_contracts.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue