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
|
|
@ -28,6 +28,7 @@ from typing import Annotated, Any, Literal
|
|||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
_MODEL_MAP_RESOURCE = "data/model_map.json"
|
||||
_REFERENCE_PROJECTS_RESOURCE = "data/reference_projects.json"
|
||||
|
||||
# C2.6: an empty-string model id is a startup schema error, never a client-layer one.
|
||||
_ModelId = Annotated[str, Field(min_length=1)]
|
||||
|
|
@ -67,6 +68,25 @@ class FeedbackContract(BaseModel):
|
|||
rationale: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ReferenceProjectContract(BaseModel):
|
||||
"""One portfolio entry: a project id + its OKF bundle dir (+ an optional inbox).
|
||||
|
||||
The ``bundle_dir`` is REQUIRED and non-empty — an entry without a bundle path
|
||||
is a startup schema error (§10), never a run that starts and then crashes for
|
||||
lack of a data source. ``inbox_dir`` is optional (the tolerant §5 inbox).
|
||||
"""
|
||||
|
||||
project_id: str = Field(min_length=1)
|
||||
bundle_dir: str = Field(min_length=1)
|
||||
inbox_dir: str | None = None
|
||||
|
||||
|
||||
class ReferenceProjectsContract(BaseModel):
|
||||
"""The schema-validated portfolio config (§10: fail-fast before any run)."""
|
||||
|
||||
projects: list[ReferenceProjectContract] = Field(min_length=1)
|
||||
|
||||
|
||||
class Contracts(BaseModel):
|
||||
"""The validated bundle of all startup contracts."""
|
||||
|
||||
|
|
@ -98,6 +118,27 @@ def _bundled_model_map() -> dict[str, Any]:
|
|||
return raw
|
||||
|
||||
|
||||
def _bundled_reference_projects() -> dict[str, Any]:
|
||||
raw: dict[str, Any] = json.loads(
|
||||
files("portfolio_optimiser_claude")
|
||||
.joinpath(_REFERENCE_PROJECTS_RESOURCE)
|
||||
.read_text(encoding="utf-8")
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def load_reference_projects(raw: dict[str, Any] | None = None) -> ReferenceProjectsContract:
|
||||
"""Validate the portfolio config at startup (§10, fail-fast before any run).
|
||||
|
||||
Raises ``pydantic.ValidationError`` on the first malformed entry — a project
|
||||
without a bundle path never reaches ``run_portfolio``. ``raw`` defaults to the
|
||||
bundled ``data/reference_projects.json`` EXAMPLE, whose shape (not its paths)
|
||||
is validated; the suite never executes that example (offline invariant).
|
||||
"""
|
||||
data = _bundled_reference_projects() if raw is None else raw
|
||||
return ReferenceProjectsContract(**data)
|
||||
|
||||
|
||||
def load_contracts(
|
||||
data_source: dict[str, Any],
|
||||
termination: dict[str, Any],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"_note": "EXAMPLE portfolio config (contracts.ReferenceProjectsContract). Schema-validated fail-fast at startup (§10); the suite validates only its SHAPE and never executes it (offline invariant). bundle_dir/inbox_dir are illustrative paths relative to the repo root — operators point them at their own OKF bundles. The one shipped example bundle is shared/examples/bygg-energi-mikro; a real portfolio lists several.",
|
||||
"projects": [
|
||||
{
|
||||
"project_id": "BYGG-KONTOR-NORD",
|
||||
"bundle_dir": "shared/examples/bygg-energi-mikro"
|
||||
}
|
||||
]
|
||||
}
|
||||
77
src/portfolio_optimiser_claude/portfolio.py
Normal file
77
src/portfolio_optimiser_claude/portfolio.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Sequential multi-project portfolio run (method-spec §3, §5, §8; paritetsrad 4).
|
||||
|
||||
``run_portfolio`` drives N projects SEQUENTIALLY from a schema-validated reference
|
||||
config. For each project it composes the §5 read-context (merge inbox → seed →
|
||||
fold, via ``compose_run_context``) and runs the loop core UNCHANGED
|
||||
(``run_project``), collecting one typed result per project IN CONFIG ORDER — the
|
||||
run path MAF got in its Fase 1 and D7 never had (the prior entrances,
|
||||
``run_s10.py`` and ``run.py``, drive a single bundle).
|
||||
|
||||
Re-entrancy (§3 Step 3): the loop core keeps all debate state local to a run, so
|
||||
nothing survives one project into the next EXCEPT the explicitly shared mutable
|
||||
state — the §8 budget ``meter``, a PORTFOLIO-WIDE cap threaded through every run.
|
||||
Each project composes its OWN context inside the loop, never a hoisted, shared one.
|
||||
|
||||
Failure policy is a STACK-LOCAL choice until D-D: the default RAISES (today
|
||||
everything is thrown — a budget stop or any run error propagates and the portfolio
|
||||
stops). K18 flips this to a collect-and-continue wave model when the D-D fasit
|
||||
lands. Goals/ledger are out of scope here beyond being accepted as optional
|
||||
arguments in a later session (K3/K11); this module only wires the run path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from portfolio_optimiser_claude.budget import BudgetMeter
|
||||
from portfolio_optimiser_claude.contracts import ReferenceProjectsContract
|
||||
from portfolio_optimiser_claude.loop import ModelClient, RunResult, run_project
|
||||
from portfolio_optimiser_claude.run import compose_run_context
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectRunResult:
|
||||
"""One project's outcome in the portfolio, tagged with its config ``project_id``."""
|
||||
|
||||
project_id: str
|
||||
run: RunResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortfolioResult:
|
||||
"""The portfolio's per-project results, in config order."""
|
||||
|
||||
results: list[ProjectRunResult]
|
||||
|
||||
|
||||
def run_portfolio(
|
||||
projects: ReferenceProjectsContract,
|
||||
client: ModelClient,
|
||||
meter: BudgetMeter,
|
||||
*,
|
||||
top_k: int,
|
||||
max_debate_rounds: int,
|
||||
max_attempts: int,
|
||||
) -> PortfolioResult:
|
||||
"""Run each configured project through the loop, collecting results in config order.
|
||||
|
||||
The ``projects`` config is ALREADY schema-validated (``load_reference_projects``,
|
||||
§10) — an invalid entry never reaches here. The shared ``meter`` is the
|
||||
portfolio-wide §8 cap; the default failure policy RAISES on the first run error.
|
||||
"""
|
||||
results: list[ProjectRunResult] = []
|
||||
for project in projects.projects:
|
||||
inbox_dir = Path(project.inbox_dir) if project.inbox_dir is not None else None
|
||||
# Fresh composition per project (re-entrancy §3 Step 3) — never hoisted.
|
||||
composed = compose_run_context(Path(project.bundle_dir), inbox_dir, k=top_k)
|
||||
run = run_project(
|
||||
client,
|
||||
composed.context,
|
||||
meter=meter,
|
||||
max_debate_rounds=max_debate_rounds,
|
||||
max_attempts=max_attempts,
|
||||
default_project_id=composed.ir_projection.project_id,
|
||||
)
|
||||
results.append(ProjectRunResult(project_id=project.project_id, run=run))
|
||||
return PortfolioResult(results=results)
|
||||
Loading…
Add table
Add a link
Reference in a new issue