feat(s33): concurrency parameter + wave partitioning, k<1 fail-fast

Add keyword-only `concurrency: int = 1` to `run_portfolio`, a module-level
`_waves` partitioner, and a fail-fast on k < 1 that precedes any project load.

The execution loop is deliberately NOT changed here. At the default k=1 every
wave holds a single project, so the wave partition reproduces the existing
`for pid in ids` order by construction — the parameter is inert until Step 3
wires the executor. The 5 tests pin exactly that: order preservation across
every k, nothing dropped or duplicated, and the k<1 raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQapztREtC2mkr5oU811pr
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 15:39:47 +02:00
commit c15165ca77
2 changed files with 82 additions and 1 deletions

View file

@ -556,6 +556,16 @@ def _goal_limit_if_reached(goal: GoalContract, observed_ore: int, baseline_ore:
return None
def _waves(ids: list[str], k: int) -> list[list[str]]:
"""Partition ``ids`` into consecutive waves of at most ``k``, preserving caller order (D-D).
Order preservation is the whole point: the wave model may change WHEN a project runs, never
WHICH projects run nor in what sequence they are submitted. At ``k=1`` every wave holds a
single project, so the wave loop degenerates to the pre-S3.3 sequential order by
construction that is what makes the parameter behaviour-preserving at its default."""
return [ids[i : i + k] for i in range(0, len(ids), k)]
async def run_portfolio(
project_ids: Sequence[str] | None = None,
profile: Profile | str = Profile.LOCAL,
@ -568,6 +578,7 @@ async def run_portfolio(
max_rounds: int = 3,
max_tokens: int = 100_000,
top_k: int = 3,
concurrency: int = 1,
meter_factory: Callable[[], TokenMeter] | None = None,
semantic_retrieval: bool = False,
embedder: Embedder | None = None,
@ -592,7 +603,16 @@ async def run_portfolio(
(its further runs are future passes, not more runs here); a SOFT goal flags ``stop_reason`` but
continues. Because the ledger is static during the pass, a reached goal is observed on the first
iteration. ``stop_reason`` surfaces the first goal event; a per-project skip is also observable
as the pid's absence from ``runs``."""
as the pid's absence from ``runs``.
``concurrency`` (S3.3, D-D wave model) caps how many projects run at once. It is validated
BEFORE anything loads an unusable cap must surface as an error, never as a silently-empty
pass. At the default ``1`` the pass is the sequential one described above."""
if concurrency < 1:
raise ValueError(
f"concurrency must be >= 1, got {concurrency}: a non-positive wave size would run no "
"projects at all and read as an empty portfolio. Pass 1 for the sequential pass."
)
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=[])