"""Portfolio run — LOAD-BEARING (K2; method-spec §3, §5, §8, §10, §11; paritetsrad 4). The seam this file keeps alive: ``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 per-project results IN CONFIG ORDER. This is the run path MAF got in its Fase 1 and D7 never had — the only prior run entrance (``run_s10.py``, ``run.py``) drives a single bundle. Detach proofs (each restored from a copy, never ``git checkout``): * Fail-fast (§10): drop the ``bundle_dir`` Field requirement in ``ReferenceProjectContract`` (e.g. ``bundle_dir: str = ""``) → a config missing its bundle path no longer raises at load → ``run_portfolio`` STARTS on the invalid config and only crashes mid-run → ``test_malformed_config_never_reaches _a_client`` goes red (``FileNotFoundError``, not ``ValidationError``). * Re-entrancy (§3 Step 3, the key assumption): hoist the per-project ``compose_run_context`` call OUT of the loop (compose project 1 once, reuse it for every project) → project 2 runs on project 1's context → the VFD marker never reaches any prompt → ``test_each_project_composes_its_own_context`` goes red. """ from __future__ import annotations import json from pathlib import Path import pytest from pydantic import ValidationError from _scripted import ScriptedClient, reply from portfolio_optimiser_claude.budget import BudgetExceeded, BudgetMeter from portfolio_optimiser_claude.contracts import ( ReferenceProjectsContract, TerminationContract, load_reference_projects, ) from portfolio_optimiser_claude.ir import load_validator_input from portfolio_optimiser_claude.portfolio import PortfolioResult, run_portfolio from portfolio_optimiser_claude.validator import ValidatedProposal _REPO = Path(__file__).resolve().parents[1] LED_BUNDLE = _REPO / "shared" / "examples" / "bygg-energi-mikro" VFD_BUNDLE = _REPO / "tests" / "data" / "mini-bundle" # Distinctive tokens that reach the composed context of ONE bundle only. LED_MARKER = "LED-retrofit" # in the shared bundle's rendered context VFD_MARKER = "K2-VFD-CONTEXT-MARKER" # embedded in the repo-local fixture's index def _meter(*, max_rounds: int = 100, max_tokens: int = 10_000) -> BudgetMeter: return BudgetMeter(TerminationContract(max_rounds=max_rounds, max_tokens=max_tokens)) def _happy_replies(bundles: list[Path]) -> list[object]: """Three scripted replies per project: debate reasoning → APPROVE → candidate JSON. The candidate JSON is each bundle's own validated IR projection, so the deterministic validator returns ``ValidatedProposal`` first try (§3 Steps 2–4). """ replies: list[object] = [] for bundle in bundles: replies += [ reply("debate reasoning"), reply("VERDICT: APPROVE"), reply(json.dumps(load_validator_input(bundle).model_dump())), ] return replies def _config(entries: list[tuple[str, Path]]) -> ReferenceProjectsContract: return load_reference_projects( {"projects": [{"project_id": pid, "bundle_dir": str(path)} for pid, path in entries]} ) class TestSequentialConfigOrder: """Two-project config → both run, results collected in config order (§3, paritetsrad 4).""" def test_two_project_config_runs_both_in_config_order(self) -> None: projects = _config([("BYGG-KONTOR-NORD", LED_BUNDLE), ("PUMPE-SOR", VFD_BUNDLE)]) client = ScriptedClient(replies=_happy_replies([LED_BUNDLE, VFD_BUNDLE])) result = run_portfolio( projects, client, _meter(), top_k=3, max_debate_rounds=3, max_attempts=3 ) assert isinstance(result, PortfolioResult) assert [r.project_id for r in result.results] == ["BYGG-KONTOR-NORD", "PUMPE-SOR"] assert all(isinstance(r.run.outcome, ValidatedProposal) for r in result.results) def test_result_order_follows_config_order_not_incidentally(self) -> None: # Reverse the config → the results reverse with it: order is the config's. projects = _config([("PUMPE-SOR", VFD_BUNDLE), ("BYGG-KONTOR-NORD", LED_BUNDLE)]) client = ScriptedClient(replies=_happy_replies([VFD_BUNDLE, LED_BUNDLE])) result = run_portfolio( projects, client, _meter(), top_k=3, max_debate_rounds=3, max_attempts=3 ) assert [r.project_id for r in result.results] == ["PUMPE-SOR", "BYGG-KONTOR-NORD"] class TestReEntrancy: """Key assumption (§3 Step 3): the loop core is re-entrant — fresh state per project. Two sequential runs share no mutable state beyond the explicitly shared meter: each project composes its OWN context, so the two projects' distinctive markers never appear together in a single prompt. """ def test_each_project_composes_its_own_context(self) -> None: projects = _config([("BYGG-KONTOR-NORD", LED_BUNDLE), ("PUMPE-SOR", VFD_BUNDLE)]) client = ScriptedClient(replies=_happy_replies([LED_BUNDLE, VFD_BUNDLE])) run_portfolio(projects, client, _meter(), top_k=3, max_debate_rounds=3, max_attempts=3) proposer_prompts = client.prompts("proposer") led = [p for p in proposer_prompts if LED_MARKER in p] vfd = [p for p in proposer_prompts if VFD_MARKER in p] assert led, "project 1's own context must reach its prompts" assert vfd, "project 2's own context must reach its prompts (no reuse of project 1's)" # No single prompt ever mixes the two projects' contexts. assert not any(LED_MARKER in p and VFD_MARKER in p for p in proposer_prompts) class TestFailurePolicyRaise: """Failure policy is a stack-local choice until D-D: the default RAISES (§8). Today everything is thrown; K18 flips this to collect-and-continue when the D-D wave model lands. A budget stop in project 1 propagates as the typed ``BudgetExceeded`` and the portfolio stops — project 2 is never touched. """ def test_project_error_propagates_and_stops_the_portfolio(self) -> None: projects = _config([("BYGG-KONTOR-NORD", LED_BUNDLE), ("PUMPE-SOR", VFD_BUNDLE)]) client = ScriptedClient(replies=_happy_replies([LED_BUNDLE, VFD_BUNDLE])) # max_tokens=5: project 1's first proposer reply (10 tokens) breaches the cap. meter = _meter(max_tokens=5) with pytest.raises(BudgetExceeded): run_portfolio(projects, client, meter, top_k=3, max_debate_rounds=3, max_attempts=3) # The stop fired inside project 1's first call; project 2 never ran. assert len(client.calls) == 1 assert not any(VFD_MARKER in prompt for _role, prompt in client.calls) class TestFailFastSchemaValidation: """§10: a malformed config is refused at load, BEFORE any model client is touched.""" def test_malformed_config_never_reaches_a_client(self) -> None: spy = ScriptedClient(replies=[reply("must never be used")]) with pytest.raises(ValidationError): projects = load_reference_projects({"projects": [{"project_id": "NO-BUNDLE"}]}) run_portfolio(projects, spy, _meter(), top_k=3, max_debate_rounds=3, max_attempts=3) assert spy.calls == []