feat(fase1): GoalContract + GoalConfig standalone fail-fast loader (F1)
This commit is contained in:
parent
9720acb18c
commit
c6f62d41db
2 changed files with 92 additions and 1 deletions
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue