"""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)