feat(fase3): run_portfolio sequential orchestrator + PortfolioResult aggregate

This commit is contained in:
Kjell Tore Guttormsen 2026-06-26 12:02:08 +02:00
commit 52f6f65b7d
4 changed files with 239 additions and 3 deletions

View file

@ -1,7 +1,13 @@
"""portfolio-optimiser — generic MAF framework for per-project cost-savings optimization."""
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.run import PortfolioResult, RunResult, run_portfolio, run_project
__version__ = "0.1.0"
__all__ = ["RunResult", "run_project", "__version__"]
__all__ = [
"PortfolioResult",
"RunResult",
"run_portfolio",
"run_project",
"__version__",
]

View file

@ -25,7 +25,7 @@ durable learned verdict captured out-of-band in the VerdictStore (D7-portable).
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Any
@ -68,6 +68,25 @@ class RunResult:
debate_output: str
@dataclass(frozen=True)
class PortfolioResult:
"""The outcome of a sequential fan-out over N projects (SC2).
``runs`` is one ``RunResult`` per project in input order; ``store`` is the ONE shared
``VerdictStore`` threaded across every run (the cross-project ExpeL learning loop).
The remaining fields are a thin aggregate over ``runs``: ``validated_count`` /
``rejected_count`` partition the outcomes; ``sum_claimed_saving_nok`` totals the claimed
saving of the validated proposals only; ``sum_token_usage`` totals every run's
provenance token usage."""
runs: tuple[RunResult, ...]
store: VerdictStore
validated_count: int
rejected_count: int
sum_claimed_saving_nok: float
sum_token_usage: int
# The GroupChat orchestrator emits this terminal notice as a participant output; it is NOT the
# debate's substantive result, so the F1 extractor filters it out (verified against MAF 1.9.0).
_ORCH_NOTICE = "reached the maximum number of rounds"
@ -209,6 +228,63 @@ async def run_project(
)
def _aggregate(runs: tuple[RunResult, ...], store: VerdictStore) -> PortfolioResult:
"""Thin aggregate over the per-project runs (SC2): partition validated/rejected, total the
validated claimed saving, and total every run's provenance token usage."""
validated = [r for r in runs if isinstance(r.outcome, ValidatedProposal)]
rejected = [r for r in runs if isinstance(r.outcome, Rejection)]
return PortfolioResult(
runs=runs,
store=store,
validated_count=len(validated),
rejected_count=len(rejected),
sum_claimed_saving_nok=sum(r.outcome.proposal.claimed_saving_nok for r in validated),
sum_token_usage=sum(r.provenance.token_usage for r in runs),
)
async def run_portfolio(
project_ids: Sequence[str] | None = None,
profile: Profile | str = Profile.LOCAL,
*,
store: VerdictStore | None = None,
client_factory: Callable[[str], BaseChatClient] | None = None,
max_rounds: int = 3,
max_tokens: int = 100_000,
top_k: int = 3,
meter_factory: Callable[[], TokenMeter] | None = None,
) -> PortfolioResult:
"""Fan out over a portfolio of independent projects SEQUENTIALLY, composing ``run_project``
as-is (every project's execution state — meter, debate, retrieval context — is built fresh
per call, so sequential reuse is inherently isolated). ONE ``VerdictStore`` is threaded
across every run, so a verdict on project k informs the ExpeL retrieval of project k+1 (the
cross-project learning loop). ``project_ids`` defaults to every loaded project; an unknown id
raises ``ValueError``. ``meter_factory`` (test seam) supplies a per-project meter inject a
shared meter to make the isolation guard go red (SC3)."""
projects = {p.id: p for p in load_reference_projects()}
ids = list(project_ids) if project_ids is not None else list(projects)
store = store if store is not None else VerdictStore(verdicts=[])
runs: list[RunResult] = []
for pid in ids:
if pid not in projects:
raise ValueError(f"unknown project_id: {pid!r}")
project = projects[pid]
result = await run_project(
pid,
profile,
docs_dir=project.docs_dir,
verdict_input=project.verdict_input,
store=store,
client_factory=client_factory,
max_rounds=max_rounds,
max_tokens=max_tokens,
top_k=top_k,
meter=meter_factory() if meter_factory is not None else None,
)
runs.append(result)
return _aggregate(tuple(runs), store)
def main(argv: list[str] | None = None) -> int:
"""Single-command console entry: run the slice for one project against a docs folder."""
import argparse