"""Portfolio learning loop — LOAD-BEARING (K3; method-spec §3 Step 1, §5, §11; paritetsrad 5). The seam this file keeps alive: within ONE portfolio pass, a verdict available when project k is composed SURVIVES into project k+1's hypothesis prompt. The carrier is a SINGLE ``VerdictStore`` threaded through every project (§5: "a passed-in store's existing verdicts survive — cross-project threading") plus a portfolio-level ``verdict_dir`` (the expert inbox, merged read-only before each run's fold). Honesty rule (§1): the crossing carries EXPERT / bundle-seed verdicts only — never a run's own captured output. The role split is unwaivable (§3 Step 7): the portfolio READS ``verdict_dir``; it NEVER writes a run's verdict back into it, and the promotion gate / authoring primitive remain the only writers. Detach proofs (each restored from a copy of the implemented version, never ``git checkout`` — that would go to HEAD and erase the uncommitted src): * Shared store (§5, cross-project threading): make ``compose_run_context`` ignore its passed-in ``store`` and always build a fresh one (``store = VerdictStore()`` unconditionally) → project 2 seeds only its OWN bundle → project 1's seed id no longer reaches project 2's prompt → ``test_earlier_project_seed_survives_into_ later_project`` goes red. * Inbox threading (§5): drop the ``verdict_dir`` merge in ``run_portfolio`` (or stop passing the shared store into ``compose_run_context``) → the portfolio inbox verdict never reaches any fold → ``test_portfolio_inbox_verdict_folds_into_ project_prompt`` goes red. """ from __future__ import annotations import json from pathlib import Path import pytest from _scripted import ScriptedClient, reply from portfolio_optimiser_claude.budget import BudgetMeter from portfolio_optimiser_claude.contracts import ( ReferenceProjectsContract, TerminationContract, load_reference_projects, ) from portfolio_optimiser_claude.experience import ( CandidateFeatures, mint_verdict_id, ) from portfolio_optimiser_claude.inbox import VerdictDocument, write_verdict from portfolio_optimiser_claude.ir import load_validator_input from portfolio_optimiser_claude.portfolio import run_portfolio _REPO = Path(__file__).resolve().parents[1] LED_BUNDLE = _REPO / "shared" / "examples" / "bygg-energi-mikro" VFD_BUNDLE = _REPO / "tests" / "data" / "mini-bundle" VFD_MARKER = "K2-VFD-CONTEXT-MARKER" # in the repo-local fixture's rendered context # A token that lives NOWHERE in either bundle — its presence in a prompt can only # come from the portfolio ``verdict_dir`` fold, never from context or seeding. INBOX_MARKER = "K3-PORTFOLIO-LEARNING-MARKER" 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.""" 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]} ) def _seed_id(bundle: Path) -> str: """The id the bundle's ``type: verdict`` seed is keyed on (§3 Step 1 / §4.2).""" return mint_verdict_id(CandidateFeatures.from_proposal(load_validator_input(bundle))) def _author_inbox_verdict(verdict_dir: Path, marker: str) -> VerdictDocument: """Drop an EXPERT verdict into the portfolio inbox (§5 write side, expert-authored). Keyed on features similar to the VFD candidate (same code + measure type, a distinct saving so its id does not collide with the VFD seed), so it ranks high for the VFD project. The marker rides in the rationale — the fold's learning-signal carrier (§3 Step 1). """ vfd = CandidateFeatures.from_proposal(load_validator_input(VFD_BUNDLE)) features = CandidateFeatures( affected_codes=vfd.affected_codes, measure_type=vfd.measure_type, claimed_saving_nok=vfd.claimed_saving_nok + 3000.0, # same bucket, distinct id ) verdict = VerdictDocument.from_candidate( features, decision="approved", rationale=f"prior portfolio verdict — realiseringskorreksjon [{marker}]", description="K3 portfolio inbox fixture (surface text, excluded from ranking)", ) write_verdict(verdict_dir, verdict) return verdict class TestCrossProjectThreading: """§5 cross-project threading: a passed-in store's verdicts survive into the next run. The shared ``VerdictStore`` ``run_portfolio`` owns carries project 1's bundle seed forward, so it is still retrievable when project 2 composes — the portfolio learns across projects in a single pass (S2.0, paritetsrad 5). """ def test_earlier_project_seed_survives_into_later_project(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) led_seed_id = _seed_id(LED_BUNDLE) vfd_prompts = [p for p in client.prompts("proposer") if VFD_MARKER in p] assert vfd_prompts, "project 2's own context must reach its prompts" # The shared store carried project 1's seed into project 2's fold. assert any(led_seed_id in p for p in vfd_prompts), ( "project 1's seed must survive into project 2's prompt via the shared store" ) def test_threading_is_order_directional(self) -> None: # Reverse the config: now VFD is project 1 → its seed threads into LED's prompt, # and LED's seed (project 2, composed last) is absent from project 1's prompt. led_seed_id = _seed_id(LED_BUNDLE) vfd_seed_id = _seed_id(VFD_BUNDLE) projects = _config([("PUMPE-SOR", VFD_BUNDLE), ("BYGG-KONTOR-NORD", LED_BUNDLE)]) client = ScriptedClient(replies=_happy_replies([VFD_BUNDLE, LED_BUNDLE])) run_portfolio(projects, client, _meter(), top_k=3, max_debate_rounds=3, max_attempts=3) vfd_prompts = [p for p in client.prompts("proposer") if VFD_MARKER in p] assert vfd_prompts # Project 1 (VFD) ran before LED was ever seeded → LED's seed cannot appear. assert not any(led_seed_id in p for p in vfd_prompts), ( "a later project's seed must not reach an earlier project's prompt" ) # VFD's own seed is of course present in its own fold. assert any(vfd_seed_id in p for p in vfd_prompts) class TestPortfolioInboxFold: """A portfolio-level ``verdict_dir`` (expert inbox) folds into each project (§5, §3 Step 1).""" def test_portfolio_inbox_verdict_folds_into_project_prompt(self, tmp_path: Path) -> None: verdict_dir = tmp_path / "portfolio-inbox" inbox_verdict = _author_inbox_verdict(verdict_dir, INBOX_MARKER) 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, verdict_dir=verdict_dir, ) vfd_prompts = [p for p in client.prompts("proposer") if VFD_MARKER in p] assert vfd_prompts assert any(INBOX_MARKER in p and inbox_verdict.id in p for p in vfd_prompts), ( "the portfolio inbox verdict (id + marker) must reach the project's fold" ) def test_no_verdict_dir_leaves_the_marker_absent(self) -> None: # Control: without a portfolio inbox, the marker appears in NO prompt — it # has no other channel into the context (seeding and bundle context never # carry it). 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) assert not any(INBOX_MARKER in prompt for _role, prompt in client.calls) class TestPortfolioMergeIdempotency: """§4.2 first-write-wins: repeated portfolio merges are idempotent (double-merge, §5). ``verdict_dir`` is merged before EVERY project's fold. Across a three-project pass the same inbox verdict is merged three times, yet the shared store holds it exactly ONCE — its fold line is not duplicated. """ def test_repeated_merges_keep_a_single_store_entry(self, tmp_path: Path) -> None: verdict_dir = tmp_path / "portfolio-inbox" inbox_verdict = _author_inbox_verdict(verdict_dir, INBOX_MARKER) projects = _config( [ ("BYGG-KONTOR-NORD", LED_BUNDLE), ("PUMPE-SOR", VFD_BUNDLE), ("BYGG-KONTOR-NORD-2", LED_BUNDLE), ] ) client = ScriptedClient(replies=_happy_replies([LED_BUNDLE, VFD_BUNDLE, LED_BUNDLE])) run_portfolio( projects, client, _meter(), top_k=3, max_debate_rounds=3, max_attempts=3, verdict_dir=verdict_dir, ) vfd_prompts = [p for p in client.prompts("proposer") if VFD_MARKER in p] assert vfd_prompts # The verdict was merged three times (once before each project) but appears # once per fold — first-write-wins deduplicates on id. assert all(p.count(inbox_verdict.id) == 1 for p in vfd_prompts), ( "a verdict merged N times must fold exactly once (first-write-wins, §4.2)" ) if __name__ == "__main__": # pragma: no cover raise SystemExit(pytest.main([__file__, "-q"]))