feat(scaffold): S5 — D7 sibling scaffold: SDK dep, fail-fast startup contracts, CLAUDE.md
Claude Agent SDK verified against official docs + PyPI 2026-07-03 (0.2.110, CLI bundled, offline import without API key). Contracts mirror method-spec §10/§4.1/§8: data-source, model-map (per-profile default required), termination (positive caps), binary feedback decision. TDD: tests written red-first; suite 14/14 green without any API key; ruff + mypy --strict clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaQCFnfsh3tfq1VfzdJpoi
This commit is contained in:
parent
eed2f02bbb
commit
4efb72943c
7 changed files with 1604 additions and 0 deletions
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# portfolio-optimiser-claude
|
||||
|
||||
## Kontekst
|
||||
Søsken-implementasjon (beslutning D7) av portfolio-optimiser-metoden på **Claude Agent SDK**:
|
||||
generisk, åpent Python-rammeverk som finner kostnadsbesparelser INNI hvert prosjekt i en
|
||||
portefølje. Bygges **fra metode-spec-en alene** (`shared/method-spec.md`) — aldri ved
|
||||
reverse-engineering av MAF-søskenet (`~/repos/portfolio-optimiser`). Program-planer
|
||||
(sesjonsplan S5–S12, sammenligningsprotokoll, frossent målbilde) bor i hovedrepoets
|
||||
`docs/plan/`. Løpende state: `STATE.md` (local-only).
|
||||
|
||||
## Stack
|
||||
Python ≥3.10. **Claude Agent SDK** (`claude-agent-sdk` ≥0.2, verifisert 0.2.110 per
|
||||
2026-07-03: MIT-lisens, bundler Claude Code CLI — ingen separat installasjon; import og
|
||||
konstruksjon fungerer uten API-nøkkel, nøkkel trengs kun ved faktisk `query()`).
|
||||
Pakkehåndtering: `uv`. Pydantic for kontrakts-validering.
|
||||
|
||||
## Konvensjoner
|
||||
- Type hints overalt; `mypy --strict`. `ruff` for lint+format. `pytest` for test.
|
||||
- **Testsuiten kjører uten API-nøkkel og uten nettverk** (invariant fra S5; håndhevet ved at
|
||||
all kontrakts-/kontekst-logikk er ren config/fil-lag).
|
||||
- Modell-valg som konfig: `src/portfolio_optimiser_claude/data/model_map.json`
|
||||
(rolle→Claude-modell-id per backend-profil, hver profil krever `default`; validert av
|
||||
`contracts.py`). Modell-id-er verifiseres mot offisiell doc før endring.
|
||||
- SDK-primitiver (verifisert 2026-07-03): `query()`/`ClaudeSDKClient`, `ClaudeAgentOptions`
|
||||
(`max_turns`, `max_budget_usd` — førsteklasses USD-tak), `AgentDefinition` (subagenter;
|
||||
sub-felt er camelCase), `@tool` + `create_sdk_mcp_server` (in-process MCP), hooks
|
||||
(`PreToolUse` med `permissionDecision: "deny"` for deterministisk gating). MERK:
|
||||
`SessionStart`/`SessionEnd` finnes IKKE som Python-callback-hooks.
|
||||
- `shared/` er en **git subtree** av
|
||||
[`portfolio-optimiser-commons`](https://git.fromaitochitta.com/ktg/portfolio-optimiser-commons)
|
||||
(source of truth; remote `commons`). Synk er **PULL-ONLY**: endringer committes i commons og
|
||||
hentes med `git subtree pull --prefix=shared commons main --squash`. ALDRI `git subtree push`
|
||||
fra konsument — re-split lekker hele konsument-historikken inn i commons (observert + opprydd
|
||||
i MAF-repoet 2026-07-03). Se `shared/README.md`.
|
||||
|
||||
## Kommandoer
|
||||
- Sync: `uv sync`
|
||||
- Test: `uv run pytest`
|
||||
- Lint: `uv run ruff check .` + `uv run ruff format .`
|
||||
- Type: `uv run mypy src`
|
||||
|
||||
## Arbeidsflyt (invarianter)
|
||||
- **Rent teknisk rammeverk:** deployer eier DPIA/ROS/behandlingsformål — kun tekniske
|
||||
forutsetninger (lokal-only, provenance, ingen stille egress) + disclaimer.
|
||||
- **Metode-spec-en er normativ** (RFC 2119). Ærlighets-regelen (§1) er ufravikelig: ingen
|
||||
artefakt påstår mer enn implementasjonen gjør; skriptede stand-ins merkes som det.
|
||||
- **Deterministisk validator obligatorisk + blokkerende** (§3 steg 4); golden-suiten
|
||||
(`shared/examples/bygg-energi-mikro/golden.json`) er ENESTE fasit (§7).
|
||||
- **Load-bearing tester** (§11): hver søm bevises med en test som blir RØD når sømmen
|
||||
detaches — grønn-men-død er feilmoden regelen finnes for.
|
||||
- **Stoppkriterier + budsjett-tak påkrevd ved oppstart** (§8; `contracts.py` fail-fast).
|
||||
På kjørestien kommer USD-taket i tillegg via `ClaudeAgentOptions.max_budget_usd`.
|
||||
- **Kostnadsdisiplin (D6):** alt utvikles offline/deterministisk; den ENE ekte API-kjøringen
|
||||
i programmet er S10 (mikro-bundle, billigste egnede modell — per model_map Haiku 4.5 —
|
||||
harde tak, kost loggføres).
|
||||
- **STATE.md er local-only** (gitignored — repoet skal publiseres; STATE når aldri et
|
||||
offentlig speil).
|
||||
|
||||
## Communication patterns
|
||||
|
||||
### Linking to local files
|
||||
|
||||
When pointing to local files in responses, always use markdown link syntax with a descriptive name:
|
||||
|
||||
- Use `[Human-friendly name](file:///absolute/path)` — never bare `file:///...` URLs or autolinks `<file://...>`.
|
||||
- Always use absolute paths. Never `~/` or relative paths.
|
||||
- For multiple files, render as a bullet list of named markdown links.
|
||||
|
||||
Why: bare `file://` URLs only render the first as clickable across multiple lines. Named markdown links make each entry independently clickable and look cleaner.
|
||||
34
pyproject.toml
Normal file
34
pyproject.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[project]
|
||||
name = "portfolio-optimiser-claude"
|
||||
version = "0.1.0"
|
||||
description = "Sibling implementation of the portfolio-optimiser method on the Claude Agent SDK (D7)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"claude-agent-sdk>=0.2",
|
||||
"pydantic>=2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"ruff>=0.8",
|
||||
"mypy>=1.13",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/portfolio_optimiser_claude"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
0
src/portfolio_optimiser_claude/__init__.py
Normal file
0
src/portfolio_optimiser_claude/__init__.py
Normal file
103
src/portfolio_optimiser_claude/contracts.py
Normal file
103
src/portfolio_optimiser_claude/contracts.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Fail-fast startup-contract loaders (method-spec §10).
|
||||
|
||||
ALL configuration is schema-validated at startup, BEFORE any model client is
|
||||
constructed: the data source, the model map, the termination contract (§8), and the
|
||||
run-path feedback shape (§4.1). The first malformed contract raises
|
||||
``pydantic.ValidationError``; a run never starts on a bad config.
|
||||
|
||||
Four Pydantic contracts:
|
||||
|
||||
* ``DataSourceContract`` — the local-folder data source (docs dir + positive top-k).
|
||||
* ``ModelMapContract`` — role -> model id per backend profile (``anthropic`` today;
|
||||
the SDK reaches Bedrock/Vertex/Foundry via env switches, so extra profiles are
|
||||
config, not code), each profile REQUIRING a ``default`` entry. Validates the
|
||||
bundled ``data/model_map.json``.
|
||||
* ``TerminationContract`` — positive ``max_rounds`` + ``max_tokens`` caps required at
|
||||
startup (never an unbounded loop, §8).
|
||||
* ``FeedbackContract`` — the expert-verdict run-path shape: BINARY decision (§4.1;
|
||||
``approved_with_adjustment`` is seed-frontmatter/promotion-gate vocabulary only and
|
||||
MUST be rejected here) + non-empty rationale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from importlib.resources import files
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
_MODEL_MAP_RESOURCE = "data/model_map.json"
|
||||
|
||||
|
||||
class DataSourceContract(BaseModel):
|
||||
"""The local-folder data source config (schema-validated, fail-fast)."""
|
||||
|
||||
docs_dir: str = Field(min_length=1)
|
||||
top_k: int = Field(gt=0)
|
||||
|
||||
|
||||
class ModelMapContract(BaseModel):
|
||||
"""Role -> model id per backend profile (validates data/model_map.json)."""
|
||||
|
||||
profiles: dict[str, dict[str, str]] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _each_profile_has_default(self) -> ModelMapContract:
|
||||
for name, mapping in self.profiles.items():
|
||||
if "default" not in mapping:
|
||||
raise ValueError(f"model_map profile '{name}' 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 run-path feedback shape (§4.1: binary decision)."""
|
||||
|
||||
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]:
|
||||
raw: dict[str, Any] = json.loads(
|
||||
files("portfolio_optimiser_claude")
|
||||
.joinpath(_MODEL_MAP_RESOURCE)
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
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 model client is built).
|
||||
|
||||
Raises ``pydantic.ValidationError`` on the first malformed contract. ``model_map``
|
||||
defaults to the bundled ``data/model_map.json``.
|
||||
"""
|
||||
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),
|
||||
)
|
||||
10
src/portfolio_optimiser_claude/data/model_map.json
Normal file
10
src/portfolio_optimiser_claude/data/model_map.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"_note": "Role -> Claude model id per backend profile. Validated by contracts.py (method-spec §10). Model ids verified against platform.claude.com/docs 2026-07-03; Haiku 4.5 is the cheapest generally-suitable model (cost discipline: the single live run in S10 uses it unless re-decided). Bedrock/Vertex/Foundry are reached via SDK env switches — extra profiles are config, not code.",
|
||||
"profiles": {
|
||||
"anthropic": {
|
||||
"default": "claude-haiku-4-5-20251001",
|
||||
"proposer": "claude-haiku-4-5-20251001",
|
||||
"checker": "claude-haiku-4-5-20251001"
|
||||
}
|
||||
}
|
||||
}
|
||||
114
tests/test_contracts.py
Normal file
114
tests/test_contracts.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Fail-fast startup-contract tests (method-spec §10, §4.1, §8).
|
||||
|
||||
The run must refuse to start on a bad config: missing/non-positive caps, a third
|
||||
decision value on the run path, a profile without a ``default`` model. All of this
|
||||
is pure config-layer validation — no model client, no API key, no network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from portfolio_optimiser_claude.contracts import load_contracts
|
||||
|
||||
VALID_DATA_SOURCE: dict[str, Any] = {"docs_dir": "docs", "top_k": 3}
|
||||
VALID_TERMINATION: dict[str, Any] = {"max_rounds": 4, "max_tokens": 20_000}
|
||||
VALID_FEEDBACK: dict[str, Any] = {"decision": "approved", "rationale": "sound measure"}
|
||||
VALID_MODEL_MAP: dict[str, Any] = {
|
||||
"profiles": {"anthropic": {"default": "some-model-id", "proposer": "some-model-id"}}
|
||||
}
|
||||
|
||||
|
||||
def load(**overrides: dict[str, Any]) -> Any:
|
||||
kwargs: dict[str, Any] = {
|
||||
"data_source": VALID_DATA_SOURCE,
|
||||
"termination": VALID_TERMINATION,
|
||||
"feedback": VALID_FEEDBACK,
|
||||
"model_map": VALID_MODEL_MAP,
|
||||
}
|
||||
kwargs.update(overrides)
|
||||
return load_contracts(
|
||||
kwargs["data_source"],
|
||||
kwargs["termination"],
|
||||
kwargs["feedback"],
|
||||
model_map=kwargs["model_map"],
|
||||
)
|
||||
|
||||
|
||||
class TestTermination:
|
||||
"""§8: required at startup, positive caps, never an unbounded loop."""
|
||||
|
||||
def test_missing_max_tokens_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load(termination={"max_rounds": 4})
|
||||
|
||||
def test_missing_max_rounds_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load(termination={"max_tokens": 20_000})
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1])
|
||||
def test_nonpositive_caps_rejected(self, bad: int) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load(termination={"max_rounds": bad, "max_tokens": 20_000})
|
||||
with pytest.raises(ValidationError):
|
||||
load(termination={"max_rounds": 4, "max_tokens": bad})
|
||||
|
||||
|
||||
class TestFeedback:
|
||||
"""§4.1: run-path decision is BINARY — exactly two values."""
|
||||
|
||||
@pytest.mark.parametrize("decision", ["approved", "rejected"])
|
||||
def test_binary_decisions_accepted(self, decision: str) -> None:
|
||||
contracts = load(feedback={"decision": decision, "rationale": "why"})
|
||||
assert contracts.feedback.decision == decision
|
||||
|
||||
def test_third_decision_value_rejected(self) -> None:
|
||||
# approved_with_adjustment lives ONLY in bundle-seed frontmatter and the
|
||||
# promotion gate's accepted set — the run path must reject it (§4).
|
||||
with pytest.raises(ValidationError):
|
||||
load(feedback={"decision": "approved_with_adjustment", "rationale": "why"})
|
||||
|
||||
def test_empty_rationale_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load(feedback={"decision": "approved", "rationale": ""})
|
||||
|
||||
|
||||
class TestDataSource:
|
||||
"""§10: a docs directory + a positive top-k."""
|
||||
|
||||
def test_empty_docs_dir_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load(data_source={"docs_dir": "", "top_k": 3})
|
||||
|
||||
def test_nonpositive_top_k_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load(data_source={"docs_dir": "docs", "top_k": 0})
|
||||
|
||||
|
||||
class TestModelMap:
|
||||
"""§10: role -> model id per backend profile, each profile REQUIRING a default."""
|
||||
|
||||
def test_profile_without_default_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load(model_map={"profiles": {"anthropic": {"proposer": "some-model-id"}}})
|
||||
|
||||
def test_empty_profiles_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load(model_map={"profiles": {}})
|
||||
|
||||
def test_bundled_model_map_is_valid(self) -> None:
|
||||
# model_map=None falls back to the bundled data/model_map.json, which must
|
||||
# itself satisfy the contract (fail-fast on the shipped config too).
|
||||
contracts = load(model_map=None) # type: ignore[arg-type]
|
||||
assert "default" in contracts.model_map.profiles["anthropic"]
|
||||
|
||||
|
||||
def test_happy_path_validates_all_four() -> None:
|
||||
contracts = load()
|
||||
assert contracts.data_source.top_k == 3
|
||||
assert contracts.termination.max_rounds == 4
|
||||
assert contracts.feedback.decision == "approved"
|
||||
assert "anthropic" in contracts.model_map.profiles
|
||||
Loading…
Add table
Add a link
Reference in a new issue