feat(fase1): GoalContract + GoalConfig standalone fail-fast loader (F1)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-07 08:01:53 +02:00
commit c6f62d41db
2 changed files with 92 additions and 1 deletions

View file

@ -18,6 +18,7 @@ from __future__ import annotations
import json import json
from importlib.resources import files from importlib.resources import files
from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
@ -62,6 +63,44 @@ class FeedbackContract(BaseModel):
rationale: str = Field(min_length=1) rationale: str = Field(min_length=1)
class GoalContract(BaseModel):
"""A configurable savings goal for a portfolio run: an absolute *øre* target and/or a percent
target, in ``hard`` or ``soft`` mode (default hard brief §4.3). At least one of ``absolute_ore``
/ ``percent`` must be set. Distinct from ``TerminationContract`` (the token/round budget cap):
this is a domain GOAL (savings reached), not resource exhaustion."""
absolute_ore: int | None = Field(default=None, ge=0)
percent: float | None = Field(default=None, ge=0, le=100)
mode: Literal["hard", "soft"] = "hard"
@model_validator(mode="after")
def _at_least_one_target(self) -> GoalContract:
if self.absolute_ore is None and self.percent is None:
raise ValueError("GoalContract requires at least one of absolute_ore or percent")
return self
class GoalConfig(BaseModel):
"""Both goal levels at once: an optional portfolio-wide goal plus per-project goals keyed by
``project_id`` so 'this project's goal stops THAT project' is unambiguously keyed (SC6 exercises
both branches together)."""
portfolio: GoalContract | None = None
per_project: dict[str, GoalContract] = Field(default_factory=dict)
def load_goal_config(path: str) -> GoalConfig:
"""Fail-fast standalone loader (mirrors ``okf.load_ir_projection``): a missing file raises
``FileNotFoundError``, a malformed config raises ``pydantic.ValidationError``. Deliberately NOT
folded into ``load_contracts`` that would break its positional callers (``run.py:223``); the
goal config is loaded separately at the orchestration entry point (Step 8)."""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"goal config not found: {path!r}")
data = json.loads(p.read_text(encoding="utf-8"))
return GoalConfig(**data)
class Contracts(BaseModel): class Contracts(BaseModel):
"""The validated bundle of all startup contracts.""" """The validated bundle of all startup contracts."""

View file

@ -9,7 +9,13 @@ import pytest
from pydantic import ValidationError from pydantic import ValidationError
from spikes._harness import FakeChatClient from spikes._harness import FakeChatClient
from portfolio_optimiser.contracts import Contracts, load_contracts from portfolio_optimiser.contracts import (
Contracts,
GoalConfig,
GoalContract,
load_contracts,
load_goal_config,
)
_DS = {"docs_dir": "docs", "top_k": 3} _DS = {"docs_dir": "docs", "top_k": 3}
_TERM = {"max_rounds": 3, "max_tokens": 10000} _TERM = {"max_rounds": 3, "max_tokens": 10000}
@ -51,3 +57,49 @@ def test_no_chat_client_call_on_malformed_contract() -> None:
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
load_contracts({"docs_dir": "docs", "top_k": -1}, _TERM, _FB) load_contracts({"docs_dir": "docs", "top_k": -1}, _TERM, _FB)
assert spy.call_count == 0 # contract validation never constructs or calls a client assert spy.call_count == 0 # contract validation never constructs or calls a client
# --- Step 7: configurable goal contract + standalone fail-fast loader ----------------------------
def test_goal_contract_hard_and_soft_validate() -> None:
hard = GoalContract(absolute_ore=500000)
assert hard.mode == "hard" # default mode
soft = GoalContract(percent=25.0, mode="soft")
assert soft.mode == "soft"
assert soft.percent == 25.0
def test_goal_contract_requires_a_target() -> None:
"""Neither absolute_ore nor percent -> ValidationError (a goal with no target is meaningless)."""
with pytest.raises(ValidationError):
GoalContract()
def test_goal_config_roundtrips_portfolio_and_per_project() -> None:
cfg = GoalConfig(
portfolio=GoalContract(absolute_ore=1_000_000),
per_project={"P1": GoalContract(percent=10.0, mode="soft")},
)
assert cfg.portfolio is not None and cfg.portfolio.absolute_ore == 1_000_000
assert cfg.per_project["P1"].mode == "soft"
# dict round-trip via model_validate preserves both levels
reparsed = GoalConfig.model_validate(cfg.model_dump())
assert reparsed.per_project["P1"].percent == 10.0
def test_load_goal_config_fail_fast_on_missing_file(tmp_path) -> None:
with pytest.raises(FileNotFoundError):
load_goal_config(str(tmp_path / "nope.json"))
def test_load_goal_config_parses_valid_file(tmp_path) -> None:
p = tmp_path / "goals.json"
p.write_text(
'{"portfolio": {"absolute_ore": 750000, "mode": "hard"},'
' "per_project": {"P2": {"percent": 5.0}}}',
encoding="utf-8",
)
cfg = load_goal_config(str(p))
assert cfg.portfolio is not None and cfg.portfolio.absolute_ore == 750000
assert cfg.per_project["P2"].percent == 5.0