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:
parent
6618f67c3f
commit
c15165ca77
2 changed files with 82 additions and 1 deletions
|
|
@ -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=[])
|
||||
|
|
|
|||
61
tests/test_portfolio_concurrent_loadbearing.py
Normal file
61
tests/test_portfolio_concurrent_loadbearing.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""S3.3 concurrent fan-out — load-bearing seams for the D-D wave model.
|
||||
|
||||
``run_portfolio`` fans out over independent projects. S3.3 lets it run up to ``k`` projects
|
||||
concurrently WITHOUT spending the property the whole suite rests on: determinism.
|
||||
|
||||
The design rests on one measured fact (``verdicts.py:303-304``): the only order-sensitive shared
|
||||
state in the pass is ``VerdictStore.verdicts`` — ONE list with ONE append site. Retrieval ranking
|
||||
is already order-independent (``verdicts.py:274-279`` ranks on ``(-similarity, id)``). So a
|
||||
per-wave snapshot plus a merge barrier that sorts each wave's new verdicts on ``project_id``
|
||||
restores byte-identity exactly — nothing wider is needed, and nothing narrower suffices.
|
||||
|
||||
This file grows across the plan's six steps. Step 1 covers the partitioning helper and the
|
||||
fail-fast; the determinism contract and its probe arrive in Step 2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from portfolio_optimiser.run import _waves, run_portfolio
|
||||
|
||||
|
||||
def test_waves_single_wave_when_k_covers_every_project() -> None:
|
||||
"""k >= len(ids) is one wave — the whole portfolio runs concurrently."""
|
||||
assert _waves(["a", "b", "c"], 3) == [["a", "b", "c"]]
|
||||
assert _waves(["a", "b", "c"], 99) == [["a", "b", "c"]]
|
||||
|
||||
|
||||
def test_waves_partitions_into_consecutive_chunks() -> None:
|
||||
"""A k that does not divide the portfolio leaves a short final wave — never a dropped id."""
|
||||
assert _waves(["a", "b", "c"], 2) == [["a", "b"], ["c"]]
|
||||
assert _waves(["a", "b", "c", "d"], 2) == [["a", "b"], ["c", "d"]]
|
||||
|
||||
|
||||
def test_waves_k1_is_the_sequential_path() -> None:
|
||||
"""k=1 degenerates to one project per wave — the pre-S3.3 execution order, by construction.
|
||||
|
||||
This is what makes Step 1 behaviour-preserving: with ``concurrency=1`` every wave holds a
|
||||
single project, so the wave loop visits ids in exactly the order the old ``for pid in ids``
|
||||
loop did."""
|
||||
assert _waves(["a", "b", "c"], 1) == [["a"], ["b"], ["c"]]
|
||||
|
||||
|
||||
def test_waves_preserves_caller_order_and_loses_nothing() -> None:
|
||||
"""Flattening any partition reproduces the input exactly — order preserved, no id dropped or
|
||||
duplicated. The wave model may change WHEN a project runs, never WHICH projects run."""
|
||||
ids = ["delta", "alpha", "charlie", "bravo", "echo"]
|
||||
for k in range(1, len(ids) + 2):
|
||||
flattened = [pid for wave in _waves(ids, k) for pid in wave]
|
||||
assert flattened == ids, f"k={k} did not preserve caller order"
|
||||
assert _waves([], 3) == []
|
||||
|
||||
|
||||
async def test_concurrency_below_one_fails_fast_before_any_project_loads() -> None:
|
||||
"""k < 1 raises ``ValueError`` with an explanatory message, mirroring the fail-fast idiom at
|
||||
``run.py:547-553``. The raise precedes project loading, so the caller gets the error rather
|
||||
than a silently-empty pass — CLAUDE.md's "stoppkriterier + budsjett-tak påkrevd ved oppstart,
|
||||
fail-fast, aldri ubegrenset loop"."""
|
||||
for bad in (0, -1):
|
||||
with pytest.raises(ValueError, match="concurrency must be >= 1"):
|
||||
await run_portfolio(concurrency=bad)
|
||||
Loading…
Add table
Add a link
Reference in a new issue