feat(portfolio): K2 — sequential multi-project run (parity row 4)
New portfolio.py: run_portfolio drives N projects sequentially from a schema-validated reference config, composing each project's §5 context (merge inbox -> seed -> fold) and running the loop core UNCHANGED per project, collecting one typed result per project IN CONFIG ORDER. This is the run path MAF got in its Fase 1 and D7 never had — the prior entrances (run.py, run_s10.py) drive a single bundle. PortfolioResult holds per-project results tagged with the config project_id. Re-entrancy (§3 Step 3): each project composes its OWN context inside the loop, never a hoisted shared one, so nothing survives one project into the next except the explicitly shared mutable state — the §8 budget meter, a portfolio-wide cap. Failure policy is a STACK-LOCAL choice until D-D: the default RAISES (today everything is thrown); K18 flips it to collect-and-continue when the D-D wave model lands. New config contract in contracts.py: ReferenceProjectContract (project_id + required non-empty bundle_dir + optional inbox_dir) + ReferenceProjectsContract, loaded fail-fast by load_reference_projects (§10) — a project without a bundle path is refused before any run. New data/reference_projects.json example (shape-validated, never executed by the suite). New repo-local mini-bundle fixture under tests/data/ (a distinct second project, VFD-retrofit — ALDRI in shared/). Two detach proofs delivered: drop the bundle_dir Field requirement -> a run starts on the invalid config and only crashes mid-run -> the fail-fast test goes red; hoist the per-project composition out of the loop -> project 2 runs on project 1's context and the VFD marker never reaches its prompt -> the re-entrancy test goes red. 11 new tests (test_portfolio.py 5 + test_contracts.py TestReferenceProjects 6). 426 -> 437 tests, golden byte-exact, full gate clean (ruff + format + mypy strict). README synced (test count + a Run layer module block). [skip-docs] — README documents the new module; CLAUDE.md holds invariants (rules/commands) only, and K2 adds no new invariant, command, or convention. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
This commit is contained in:
parent
a7e8ffecb8
commit
f2c64da9ee
10 changed files with 406 additions and 3 deletions
|
|
@ -12,7 +12,7 @@ from typing import Any
|
|||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from portfolio_optimiser_claude.contracts import load_contracts
|
||||
from portfolio_optimiser_claude.contracts import load_contracts, load_reference_projects
|
||||
|
||||
VALID_DATA_SOURCE: dict[str, Any] = {"docs_dir": "docs", "top_k": 3}
|
||||
VALID_TERMINATION: dict[str, Any] = {"max_rounds": 4, "max_tokens": 20_000}
|
||||
|
|
@ -118,6 +118,42 @@ class TestModelMap:
|
|||
assert "default" in contracts.model_map.profiles["anthropic"]
|
||||
|
||||
|
||||
class TestReferenceProjects:
|
||||
"""§10 (K2): the portfolio config — each entry needs a non-empty bundle path."""
|
||||
|
||||
def test_valid_config_accepted(self) -> None:
|
||||
config = load_reference_projects(
|
||||
{"projects": [{"project_id": "P1", "bundle_dir": "bundles/p1"}]}
|
||||
)
|
||||
assert config.projects[0].project_id == "P1"
|
||||
assert config.projects[0].inbox_dir is None
|
||||
|
||||
def test_missing_bundle_dir_rejected(self) -> None:
|
||||
# The fail-fast seam K2's detach proof removes: no bundle path is a
|
||||
# startup schema error, never a run that starts then crashes for lack of data.
|
||||
with pytest.raises(ValidationError):
|
||||
load_reference_projects({"projects": [{"project_id": "P1"}]})
|
||||
|
||||
def test_empty_bundle_dir_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load_reference_projects({"projects": [{"project_id": "P1", "bundle_dir": ""}]})
|
||||
|
||||
def test_empty_project_id_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load_reference_projects({"projects": [{"project_id": "", "bundle_dir": "b"}]})
|
||||
|
||||
def test_empty_projects_list_rejected(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
load_reference_projects({"projects": []})
|
||||
|
||||
def test_bundled_reference_projects_is_valid(self) -> None:
|
||||
# raw=None falls back to the bundled data/reference_projects.json EXAMPLE,
|
||||
# whose shape must itself satisfy the contract (fail-fast on shipped config).
|
||||
config = load_reference_projects()
|
||||
assert config.projects
|
||||
assert all(p.bundle_dir for p in config.projects)
|
||||
|
||||
|
||||
def test_happy_path_validates_all_four() -> None:
|
||||
contracts = load()
|
||||
assert contracts.data_source.top_k == 3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue