feat(portfolio): K3 — portfolio learning loop (shared verdict store, parity row 5) [skip-docs]
A single VerdictStore threaded through run_portfolio: a verdict available when project k composes survives into project k+1's fold (method-spec §5 cross-project threading). The optional verdict_dir is the portfolio-level expert inbox, read before each fold (role split §3 Step 7 — the portfolio never writes a run's own verdict back; §1/§6 — no self-contamination, only expert/seed verdicts cross). compose_run_context gains an optional passed-in store (None = fresh; every existing caller composes exactly as before). Load-bearing (tests/test_portfolio_learning_loadbearing.py), 2 detach proofs + control + §4.2 idempotency: - cross-project threading: project 1's bundle seed survives into project 2's prompt via the shared store; detach (compose ignores the passed-in store, always fresh) -> red. - portfolio inbox fold: a verdict_dir marker reaches the project's fold; detach (drop the run_portfolio merge) -> red; control (no verdict_dir) -> marker absent. - double-merge idempotency: a verdict merged before every project folds exactly once (first-write-wins on id). 437->442 green, golden byte-exact, full gate clean (ruff + format + mypy strict). run_s10.py and runs/ byte-untouched. README synced (test count, portfolio block, load-bearing list). K2 re-entrancy test stays green — the shared store threads verdict fold lines only, never bundle context markers. [skip-docs]: no invariant changed (CLAUDE.md untouched); the run_portfolio and compose_run_context docstrings + README carry the doc need. 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
f2c64da9ee
commit
9bae4fb563
4 changed files with 275 additions and 17 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""Sequential multi-project portfolio run (method-spec §3, §5, §8; paritetsrad 4).
|
||||
"""Sequential multi-project portfolio run + learning loop (method-spec §3, §5, §8; paritetsrad 4–5).
|
||||
|
||||
``run_portfolio`` drives N projects SEQUENTIALLY from a schema-validated reference
|
||||
config. For each project it composes the §5 read-context (merge inbox → seed →
|
||||
|
|
@ -8,15 +8,18 @@ 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.
|
||||
nothing survives one project into the next EXCEPT the deliberately shared state —
|
||||
the §8 budget ``meter`` (a PORTFOLIO-WIDE cap) and, for the learning loop (K3,
|
||||
paritetsrad 5; §3 Step 1, §5), a single ``VerdictStore``: a verdict available when
|
||||
project k composes survives into project k+1's fold (cross-project threading). Each
|
||||
project still 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.
|
||||
arguments in a later session (K11); this module wires the run path and the
|
||||
portfolio learning store, nothing more.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -26,6 +29,8 @@ from pathlib import Path
|
|||
|
||||
from portfolio_optimiser_claude.budget import BudgetMeter
|
||||
from portfolio_optimiser_claude.contracts import ReferenceProjectsContract
|
||||
from portfolio_optimiser_claude.experience import VerdictStore
|
||||
from portfolio_optimiser_claude.inbox import merge_inbox_into_store
|
||||
from portfolio_optimiser_claude.loop import ModelClient, RunResult, run_project
|
||||
from portfolio_optimiser_claude.run import compose_run_context
|
||||
|
||||
|
|
@ -53,18 +58,36 @@ def run_portfolio(
|
|||
top_k: int,
|
||||
max_debate_rounds: int,
|
||||
max_attempts: int,
|
||||
verdict_dir: Path | None = None,
|
||||
) -> 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.
|
||||
|
||||
The portfolio learning loop (K3, paritetsrad 5; §3 Step 1, §5): a SINGLE
|
||||
``VerdictStore`` is threaded through every project, so a verdict available
|
||||
when project k composes survives into project k+1's fold (§5 cross-project
|
||||
threading). ``verdict_dir`` is the OPTIONAL portfolio-level expert inbox —
|
||||
the system READS it before each run's fold (role split §3 Step 7: the
|
||||
portfolio never writes a run's own verdict back). Composition still happens
|
||||
INSIDE the loop (re-entrancy §3 Step 3) — only the learning store and the
|
||||
§8 meter are the deliberately shared, portfolio-wide state.
|
||||
"""
|
||||
shared_store = VerdictStore()
|
||||
results: list[ProjectRunResult] = []
|
||||
for project in projects.projects:
|
||||
# The portfolio inbox is merged BEFORE each fold (§5); repeated merges are
|
||||
# idempotent (first-write-wins on id), so re-merging every project is safe.
|
||||
if verdict_dir is not None:
|
||||
merge_inbox_into_store(shared_store, verdict_dir)
|
||||
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)
|
||||
# Fresh composition per project (re-entrancy §3 Step 3) — never hoisted;
|
||||
# the shared store is the ONLY verdict state carried across projects.
|
||||
composed = compose_run_context(
|
||||
Path(project.bundle_dir), inbox_dir, k=top_k, store=shared_store
|
||||
)
|
||||
run = run_project(
|
||||
client,
|
||||
composed.context,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class ComposedRunContext:
|
|||
|
||||
|
||||
def compose_run_context(
|
||||
bundle_dir: Path, inbox_dir: Path | None = None, *, k: int
|
||||
bundle_dir: Path, inbox_dir: Path | None = None, *, k: int, store: VerdictStore | None = None
|
||||
) -> ComposedRunContext:
|
||||
"""Compose the run context per §5: merge inbox → seed → fold — read-only.
|
||||
|
||||
|
|
@ -69,10 +69,17 @@ def compose_run_context(
|
|||
fast ahead of any spend (§9). A missing/empty ``inbox_dir`` (or ``None``)
|
||||
leaves the composition identical to the no-inbox base. Nothing is ever
|
||||
written — the system reads the inbox, the expert writes it (§3 Step 7).
|
||||
|
||||
A passed-in ``store`` is used AS-IS (its existing verdicts survive the
|
||||
merge, first-write-wins) — the §5 cross-project threading a portfolio pass
|
||||
(K3) relies on: a verdict available at project k reaches project k+1's
|
||||
fold. ``None`` (the single-run default) builds a fresh store, so every
|
||||
existing caller composes exactly as before.
|
||||
"""
|
||||
citations = build_citations(navigate_bundle(bundle_dir))
|
||||
ir_projection = load_validator_input(bundle_dir)
|
||||
store = VerdictStore()
|
||||
if store is None:
|
||||
store = VerdictStore()
|
||||
inbox_merged = merge_inbox_into_store(store, inbox_dir) if inbox_dir is not None else 0
|
||||
seeded = seed_store_from_bundle(store, bundle_dir)
|
||||
context = fold_experience(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue