run_project KREVDE verdict_input og kjorte capture_verdict ubetinget; CLI-en
defaultet det til {"approved", "reviewed by expert"} og hosting listet det som
PAAKREVD. Netto: hver flaggloes kjoering myntet en ekspertgodkjenning ingen ga,
den gikk inn i den delte storen, og run_portfolio bar den inn i neste prosjekts
hypotese-prompt som en prior expert verdict -- paa flaten som ble overlevert
14.08. Non-goal 3, brutt i en soem.
RunResult.verdict er naa Verdict | None, og None er hva stillhet produserer:
ingenting myntes, ingenting lagres, ingenting varsles. Prinsippet sto allerede i
repoet -- RunFailure sin docstring: aa fylle et felt med en dummy legger
FABRIKKERT proveniens inn i aggregatet.
Traceability koster ingenting: RunResult.verdict_key (property, derivert fra
kandidaten) er verdicts.verdict_key sitt alt dokumenterte formaal -- identisk
med verdict.id naar en dom BLE gitt, og fortsatt meningsfull naar ingen ble det.
Det er den outboxen og den hostede responsen stempler.
Halv dom NEKTES paa begge doerer (FeedbackContract er eneste sted formen
valideres; CLI-en nekter ved navn FOER enhver mode-dispatch). Validering, aldri
reparasjon. De to mode-partisjonene fikk --decision/--rationale inn: kommentarene
sa ordrett at en aerlig nekt var uimplementerbar fordi de non-None
argparse-defaultene gjorde en eksplisitt verdi uskillbar fra defaulten -- med
defaultene borte er den implementerbar.
Hosting er WIDENING, ikke bryting: verdict_input flyttet fra _REQUIRED_FIELDS
til _OPTIONAL_FIELDS. Ingen ekstern kaller brekker.
AERLIGHETS-GRENSE: referanse-fixturens SYNTETISKE verdict_input-rader staar
uroert -- de er merket SYNTETISK paa fire steder og er reviewens F5 (maaling av
misjonspaastanden), ikke F2. Project.verdict_input er naa valgfri.
Load-bearing MAALT (tests/test_ungiven_verdict_loadbearing.py, 15 armer), aatte
mutasjoner alle roede mot HELE suiten + gronn kontroll 1080/5 og golden
demo-transcript.stdout BYTE-UENDRET (ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
En mutasjon falsifiserte testen foerst (vakuoes-gate-klassen, ellevte gang):
--report-armen brukte et bart --report, som nekter rc 1 uansett fordi --ledger
mangler.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
5.7 KiB
Python
141 lines
5.7 KiB
Python
"""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 pathlib import Path
|
|
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 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):
|
|
"""The validated bundle of all startup contracts."""
|
|
|
|
data_source: DataSourceContract
|
|
model_map: ModelMapContract
|
|
termination: TerminationContract
|
|
#: ``None`` when the run carries no expert verdict at all (F2, non-goal 3). A run nobody
|
|
#: reviewed has no feedback to validate; a run that DOES claim one is validated exactly as
|
|
#: before, so a half-given verdict still fails fast here rather than being completed for the
|
|
#: expert further down.
|
|
feedback: FeedbackContract | None
|
|
|
|
|
|
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] | None,
|
|
*,
|
|
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). ``feedback`` is ``None`` for a
|
|
run nobody reviewed — the ONE place the expert-verdict shape is validated, so a caller that
|
|
supplies half a verdict is refused here by field name (F2)."""
|
|
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=None if feedback is None else FeedbackContract(**feedback),
|
|
)
|