Story S3-06 — Cost-cap path + partial reports¶
Step: Step 3 — Implement the runner: asyncio fan-out, subprocess rubric, aggregator with BCa bootstrap
Status: Ready (HARDENED 2026-05-27)
Effort: M
Depends on: S3-02 (aggregator + cancellation surface — HARDENED), S3-04 (failure-mode mapping + ADR-0004 amendment removing sut.cancelled — HARDENED)
ADRs honored: phase-arch §Gap 4 (complete: bool field on BenchRunReport + gate refuses partial — S4-04's IncompleteReportForPromotion), ADR-0009 (humans-always-merge — partial runs feed advisory verdicts), ADR-0010 (isolation_class is emitted on partial reports unchanged)
Validation notes¶
Hardened 2026-05-27 by the phase-story-validator skill. 24 critic findings (7 blocks, 13 hardens, 4 nits) applied. Highlights:
- F-CON-1 / F-DP-1 (block). The synthesized
FailureMode(code="sut.cancelled", ...)for every cancelled case was removed. It collided head-on with three HARDENED contracts: (a) S3-04 makes an ADR-0004 §Consequences amendment to dropsut.cancelledfrom the seed taxonomy in favor ofsut.timeout; (b) S3-04 AC-4a forbids synthesizing anyFailureModefor cases during the cost-cap path; (c) S3-04 AC-7 reserved-namespace defense would rewrite any post-amendmentsut.cancelledtorubric.unknown_failure_mode, destroying the discriminator. The replacement design: workers cooperatively check a sharedcost_cap_event; the aggregator enqueues an_Aborted(case_id)tagged-union marker for each uncompleted case (the queue widening S3-02 HARDENED already prescribed in its Notes-for-implementer block, line 900); the aggregator translates_Abortedmarkers into placeholderBenchScore(passed=False, score=0.0, breakdown={}, failure_modes=(), cost_usd=0.0, wall_clock_ms=0)entries at finalize time —failure_modes=()(empty), not a synthetic FailureMode. The placeholder reports the fact ("this case was cancelled before dispatch") without fabricating a per-case SUT event that never occurred (CLAUDE.md "Facts, not judgments"). - F-CON-2 (block).
max_cost_usd: float | None = 5.0reverted tomax_cost_usd: float = 5.0(the arch + final-design contract). Operators who want effectively-unlimited pass a large float. Surfacing the conflict, not averaging it (Rule 7). - F-CON-3 (block).
BenchRunReport.original_run_id: str | Nonedropped — S1-02 is HARDENED and frozen; a wire-contract field cannot land as an S3-06 refactor. Forensic chain-walking usesrun_id.removeprefix("partial:")(thepartial:prefix already encodes the source run id). - F-CON-4 (block). The CLI exit-code discriminator switched from "
complete=FalseAND anysut.cancelledfailure mode" tocomplete is Falsealone. Thecompleteboolean is the contract; no FailureMode-based conjunction. (Future partial-reasons — Phase 13 cost-prediction abort, Phase 16 microVM OOM — should add apartial_reason: Literal["cost_cap"] | None = Nonediscriminated field onBenchRunReport; surfaced as Notes-for-implementer with the rule-of-three trigger, not as an AC today.) - F-CON-5 (block). Cross-process
flockon<bench_root>/.<task_class>.runlock(final-design line 324) is explicitly deferred to a follow-up story in Out-of-scope — silent omission would close the critic's[P]concurrent-cost-leak attack without evidence. Phase 6.5 ships single-process cost-cap; live-mode cross-host protection is a separate story. - F-CON-6 (harden). ADR attribution corrected: ADR-0002 is the lower_bound_95/BCa contract, NOT the "gate refuses partial" contract. The partial-rejection semantic is owned by phase-arch §Gap 4 + S4-04's
IncompleteReportForPromotion. Updated in front-matter. - F-DP-2 (block). Aggregator's audit append removed from
Runner.execute(...). S3-02 AC-13 pinsexecuteas audit-write-free; S3-06 introducesRunner.run_eval(plan, *, …, out_dir) -> BenchRunReportas the composition root that ownsaudit.write_run_record. Regression test mirrors S3-02 AC-13'smonkeypatch.setattr("codegenie.eval.audit.write_run_record", lambda *a, **kw: pytest.fail(...))from insideexecute. - F-DP-3 (harden). Cancellation choreography pinned: workers respect
cost_cap_event.is_set()cooperatively at safe-points (top of_run_case; before semaphore acquire; afterwait_for(system_under_test, …)). The aggregator drives the cap detection and_Aborted-marker emission. Externaltask.cancel()is reserved for S3-04 AC-4a's external-cancel path and is not how cost-cap fires. The conflation in the prior draft would have caused workers to raiseCancelledError(propagating per S3-02 AC-12), bypassing the aggregator's synthesis path. - F-DP-4 (harden). Two named-seam extractions promoted from refactor-note to AC (mirroring S3-02 F-DP-6's precedent of promoting
_run_case/_aggregatefrom refactor to AC): _finalize_partial_report(welford, completed_buf, plan, remaining_case_ids, started_at) -> BenchRunReport— pure._next_cap_phase(phase, running_total, cap) -> CostCapPhase— pure predicate;CostCapPhaseis aStrEnum(RUNNING | APPROACHING | BREACHED). Eliminates the flag-pair anti-pattern (two booleanswarned_at_80+ implicit "breached") and forbids the impossible state (warn-after-breach re-fire).- F-DP-5 (harden). Structured-logging contract pinned: events emitted via
structlog.get_logger("codegenie.eval.runner"); event namescost_cap_approaching(WARNING) andcost_cap_exceeded(ERROR); tests usestructlog.testing.capture_logs()(NOT stdlibcaplog). - F-COV-1 (harden). Edge cases pinned: cap fires on case 1 (immediate breach); boundary
running_total == max_cost_usddoes NOT fire (>, not>=);max_cost_usd <= 0.0raisesValueErrorat entry; exact cancelled cardinality (not>= 2). - F-COV-2 (harden). ERROR-log test, single-fire WARNING test, and
cost_cap_approachingNOT-fired-when-disabled test added. Negative tests catch the early-fire mutation. - F-COV-3 (harden). Audit-chain test asserts both cardinality (
+1file) AND chain integrity (audit.verify(out_dir).ok is True,latest.prev_hashmatches the previously-written record's identity). - F-TQ-1 (block). Test 1's unreadable nested set-comprehension replaced with a clean
for cid, score in cancelled: assert all of …. The replacement asserts every field of the placeholder score (passed=False, score=0.0, breakdown={}, failure_modes=(), cost_usd=0.0, wall_clock_ms=0). - F-TQ-2 (block).
caplogcalls replaced withstructlog.testing.capture_logs()everywhere — the codebase uses structlog (tests/smoke/test_cli_end_to_end.pyprecedent);caplogwould silently miss structlog records. - F-TQ-3 (harden). Deterministic-completion-order tests use a
GatedCostingSUT(per-caseasyncio.Eventrelease) so the test assertscancelled_ids == {"d", "e"}andcompleted_ids == {"a", "b", "c"}exactly — not fuzzy>= 2. - F-TQ-4 (harden). Property test added:
report.complete is False ⇔ sum(c.cost for c in completed) > max_cost_usd, andreport.total_cost_usd <= max_cost_usd + max_single_case_cost(bounded over-shoot). Catches double-counting and off-by-one on the boundary. - F-TQ-5 (harden). Mutation guards on the
complete=Truehappy-path test: assert NO_Abortedmarker leaked into per_case; assert NOcost_cap_approaching/cost_cap_exceededlog records emitted. - F-TQ-6 (nit). Tempdir-cleanup test pins the
CostingStubSUT.tempdir_observercontract explicitly in §Implementation outline (otherwise the assertion is vacuous when the SUT never creates tempdirs). - F-DP-6 (notes only — Rule 2).
CostCapsmart-constructor value object deferred until a third threshold-check site lands (Phase 13 cost-prediction). 80% threshold list deferred similarly. - F-DP-7 (notes only — Rule 2).
partial_reason: Literal["cost_cap"] | None = Nonefield onBenchRunReportdeferred until Phase 13 / Phase 16 introduce a second partial-reason consumer.complete=Falseis the single discriminator today.
Full audit trail: _validation/S3-06-cost-cap-and-partial-reports.md.
Context¶
When the operator (or CI) sets --max-cost-usd, the aggregator monitors running_total_cost_usd after each BenchScore lands. If the cap is breached, a shared cost_cap_event is set; workers respect it cooperatively at safe-points; the aggregator drains its queue and emits _Aborted(case_id) markers for every case that did not produce a real BenchScore. At finalize time, _finalize_partial_report translates each _Aborted marker into a placeholder BenchScore with empty failure_modes (the fact is recorded; no fabricated SUT event). The partial report is tagged complete=False and run_id = f"partial:{plan.run_id}" (arch §Gap 4); the audit chain still records the partial run. The promotion gate (S4-04) refuses evidence_sufficient=True on any report with complete=False — so the cost cap becomes a structural reason for a verdict-refusal, not a silent truncation.
This is the operationalization of CLAUDE.md "Fail loud": a half-finished run leaves evidence in the chain that it was half-finished. Phase 13's outcome ledger and cost-analysis surfaces can see "we tried, the cap fired" instead of seeing nothing. The CLI surfaces this with exit code 2 (cost-cap exceeded — S4-01 owns the mapping; the discriminator is complete is False alone).
References — where to look¶
- Architecture:
../phase-arch-design.md §Failure modesrow 2 ("Cost-cap breached") — semantics.../phase-arch-design.md §Process viewcost-cap branch — sequence-diagram view of cancellation.../phase-arch-design.md §Control flow → Decision points #2—total_cost_usd > max_cost_usd→ cancel + partial.../phase-arch-design.md §Components → runner.pystep 5 — cost-cap is phase 5 of the six-phase pipeline;max_cost_usd: float = 5.0(noNone— the contract is plainfloat).../phase-arch-design.md §Gap analysis Gap 4— three contractual additions:complete: bool(S1-02 HARDENED ships this), gate rejects (S4-04 ownsIncompleteReportForPromotion), verify breakdown (this story's audit-still-records AC).../phase-arch-design.md §Logging strategy—WARNINGat >80% of cap;ERRORat breach.- Phase ADRs:
../ADRs/0004-per-task-class-failure-modes-taxonomy.md— post-S3-04 amendment:sut.cancelledremoved from the seed taxonomy; this story does NOT emit it.../ADRs/0009-automatic-demotion-as-recommendation-shift.md— humans-always-merge applies symmetrically to demotion-suggesting evidence.../ADRs/0010-isolation-class-annotation-on-bench-run-report.md—isolation_classis still emitted on partial reports (Phase 16 may want to see "we hit the cap on subprocess isolation").- Sibling stories (HARDENED — the contracts THIS story consumes):
S1-02-wire-models-frozen-extra-forbid.md—BenchRunReport.complete: bool = Trueis the contract; this story does NOT add fields.S3-02-asyncio-fan-out-and-aggregator.md—Runner.executeshape;_Sentineldiscipline; Notes-for-implementer line 900 explicitly prescribes the_Abortedqueue widening that S3-06 must adopt; AC-13 pinsexecuteas audit-write-free.S3-04-six-per-case-failure-paths.md— ADR-0004 amendment removingsut.cancelled; AC-4a forbidsFailureModesynthesis on the external-cancel path. This story'scost_cap_event-driven cooperative cancellation produces NOFailureModes.- Source design:
../final-design.md §Components → runner.pystep 5 (cost-cap path);../final-design.md §Failure modes & recoveryrow "Cost cap exceeded".
Goal¶
Implement cooperative cost-cap enforcement in the aggregator: when running_total_cost_usd > max_cost_usd, set the shared cost_cap_event; workers honor it at safe-points and exit early; the aggregator emits an _Aborted(case_id) marker for every uncompleted case; _finalize_partial_report translates each marker into a placeholder BenchScore with empty failure_modes; the returned BenchRunReport is tagged complete=False, run_id=f"partial:{plan.run_id}". A separate composition surface Runner.run_eval(...) calls audit.write_run_record regardless of complete so the audit chain records the partial run. Runner.execute(...) itself does NOT write to the audit chain (S3-02 AC-13 stays green).
Acceptance criteria¶
Public surface¶
-
[ ] AC-1.
Runner.execute(plan, *, system_under_test, rubric_runner, cache_dir, timeout_per_case_seconds, concurrency=None, on_score=None, max_cost_usd: float = 5.0) -> BenchRunReport—max_cost_usdis added as a kwarg to the HARDENED S3-02 signature; type is plainfloat(noNone; matches arch + final-design).max_cost_usd <= 0.0raisesValueError(f"max_cost_usd must be > 0.0, got {max_cost_usd}")at entry, before any worker is scheduled. -
[ ] AC-2.
Runner.run_eval(plan, *, system_under_test, rubric_runner, cache_dir, timeout_per_case_seconds, concurrency=None, on_score=None, max_cost_usd: float = 5.0, out_dir: Path) -> BenchRunReport— new public composition surface. Body is a 4-line composition:report = await self.execute(...); (S3-05'sbootstrapstep is a future fold-in, no-op today);path, head = audit.write_run_record(report, out_dir);return report.model_copy(update={"chain_head": head}). The audit write is unconditional onreport.complete— partial runs land in the chain.
Queue widening (S3-02 hand-off)¶
- [ ] AC-3.
_Abortedis a@dataclass(frozen=True)carryingcase_id: str, declared insrc/codegenie/eval/runner.py. The worker→aggregator queue type widens fromtuple[str, BenchScore] | _Sentinel(HARDENED S3-02) totuple[str, BenchScore] | _Sentinel | _Aborted. The aggregator branches viaisinstance(item, _Aborted)(NOT viaFailureModeinspection). The queue-type widening is an additive change to the S3-02 contract (_Sentinelis still the run-end marker;_Abortedis the per-case cancellation marker).
Cost-cap state machine¶
-
[ ] AC-4.
CostCapPhaseis aStrEnuminsrc/codegenie/eval/runner.py:RUNNING | APPROACHING | BREACHED. The aggregator carries a singlephase: CostCapPhasevariable; transitions are monotonic (RUNNING → APPROACHING → BREACHED; never backward;RUNNING → BREACHEDis allowed when the first score already exceeds the cap). A pure helper_next_cap_phase(phase: CostCapPhase, running_total: float, cap: float) -> CostCapPhaselives at module level; unit-tested intests/unit/test_runner_cost_cap_helpers.pyfor all transition paths (including idempotent re-call:_next_cap_phase(BREACHED, running_total=anything, cap=anything) == BREACHED). -
[ ] AC-5. After each non-marker queue item is consumed, the aggregator updates
running_total += score.cost_usdand callsphase = _next_cap_phase(phase, running_total, cap). On transitionRUNNING → APPROACHINGorRUNNING/APPROACHING → BREACHED, the corresponding log fires (AC-9, AC-10).
Cooperative cancellation (no external task.cancel for cost-cap)¶
- [ ] AC-6.
cost_cap_event: asyncio.Eventis shared between aggregator and workers. The aggregator callscost_cap_event.set()on transition toBREACHED. Workers checkcost_cap_event.is_set()at TWO safe-points in_run_case: (i) beforeasync with sem:(skip if already breached), (ii) afterawait asyncio.wait_for(system_under_test(case), …)returns (do not invoke rubric on a breached run). On either check returning True, the workerreturns without enqueuing anything for that case. Externaltask.cancel()is NOT used by the cost-cap path — that is reserved for S3-04 AC-4a's external-cancel path. (Mutation guard: a wrong impl that callsfor t in worker_tasks: t.cancel()causesCancelledErrorto propagate per S3-02 AC-12, bypassing the aggregator's synthesis path. Red test pins that NOCancelledErrorreachesexecute()'s caller on a cost-cap fire.)
Aggregator drains + emits _Aborted markers¶
- [ ] AC-7. When the aggregator transitions to
BREACHED, it: (i) records the set of case_ids that have already produced a(case_id, score)item; (ii) drains the queue without blocking (pulls all items already enqueued); (iii) computesremaining_case_ids = {c.case_id for c in plan.cases} - completed_case_ids - already_aborted_case_ids; (iv) enqueues_Aborted(case_id=cid)for everycidinremaining_case_ids(in lexicographic order for determinism); (v) continues the consume loop until_SENTINEL. Workers that bypass at the cooperative check (AC-6) never reach the queue; the aggregator's_Aborted-emission is what guaranteeslen(report.per_case) == len(plan.cases).
Placeholder score synthesis (NO synthetic FailureMode)¶
-
[ ] AC-8.
_finalize_partial_report(welford, completed_buf, aborted_case_ids, plan, started_at) -> BenchRunReportis a pure module-level helper. For eachcase_idinaborted_case_ids, it emits a placeholderBenchScore(passed=False, score=0.0, breakdown={}, failure_modes=(), cost_usd=0.0, wall_clock_ms=0). Critically,failure_modes=()is empty — NOFailureModewithcode="sut.cancelled"is synthesized. S3-04's ADR-0004 amendment removessut.cancelledfrom the seed taxonomy; emitting it would resolve via S3-04 AC-7's reserved-namespace defense torubric.unknown_failure_mode, destroying the discriminator. Per-case fact: cancelled-before-dispatch records are placeholderBenchScores, not fabricated SUT events. -
[ ] AC-9. The returned
BenchRunReporthas, on a cap-fired run: complete = Falserun_id = f"partial:{plan.run_id}"per_casecontains exactlylen(plan.cases)entries (every case_id from the plan is present), sorted lexicographically bycase_idtotal_cost_usd = sum(s.cost_usd for cid, s in per_case)— placeholder scores contribute0.0block_severity_failure_modesreflects ONLY real rubric-emitted block-severity codes from the completed cases (placeholder scores contribute none, by AC-8)isolation_class = "subprocess"(per ADR-0010, unconditional on partial)- On a non-cap-fired run:
complete = True,run_id = plan.run_id(nopartial:prefix), and NO_Abortedmarker reachesper_case.
Logging contract¶
-
[ ] AC-10. WARNING fires at the
RUNNING → APPROACHINGtransition (i.e.,running_total >= 0.8 * max_cost_usdand< max_cost_usd). Emitted viastructlog.get_logger("codegenie.eval.runner").warning(event="cost_cap_approaching", running_total=<float>, max_cost_usd=<float>, n_completed=<int>)wheren_completedis the count of non-placeholderBenchScores consumed at the moment of emission. The WARNING fires exactly once per run — theRUNNING → APPROACHINGtransition is monotonic (AC-4), so re-firing is structurally impossible. -
[ ] AC-11. ERROR fires at the
* → BREACHEDtransition. Emitted viastructlog.get_logger("codegenie.eval.runner").error(event="cost_cap_exceeded", running_total=<float>, max_cost_usd=<float>, n_completed=<int>, n_remaining=<int>)wheren_remaining = len(plan.cases) - n_completed. -
[ ] AC-12. Neither
cost_cap_approachingnorcost_cap_exceededis emitted on a run that does not transition pastRUNNING(i.e.,sum_of_costs < 0.8 * max_cost_usd). Negative-fire mutation test pins this.
Boundary semantics¶
-
[ ] AC-13. Boundary
running_total == max_cost_usdexactly does NOT fire BREACHED — the predicate is>, not>=. Test pins:cost_each=2.5, n=2, cap=5.0→complete is True, no_Abortedmarkers inper_case. -
[ ] AC-14. Cap fires on case 1 when the first completed score alone exceeds the cap. Test:
cost_each=10.0, cap=5.0, n=3with deterministic completion via a gated SUT (caseareleases first) → exactly 1 case completed (a), exactly 2 cancelled (b,c);report.per_casehas 3 entries.
Audit chain integrity (run_eval-owned)¶
-
[ ] AC-15.
Runner.run_eval(...)callsaudit.write_run_record(report, out_dir)regardless ofreport.complete. The chain grows by exactly 1 record. Test asserts: (i)len(list(out_dir.glob("*.json"))) == 0before; (ii)== 1after; (iii)audit.verify(out_dir).ok is True(chain integrity); (iv)audit.read_latest(out_dir).complete is Falseandaudit.read_latest(out_dir).run_id.startswith("partial:")(the persisted record matches the in-memory report). The pre-test directory is fresh-mkdir'd (no prior records). -
[ ] AC-16.
Runner.execute(...)does NOT callaudit.write_run_recordeven when the cap fires — S3-02 AC-13 stays green. Regression test mirrors S3-02 AC-13:monkeypatch.setattr("codegenie.eval.audit.write_run_record", lambda *a, **kw: pytest.fail("execute() must not write to audit chain — that's run_eval's job")); assert the cap-firedexecute()returns a partial report without raising.
CLI exit-code discriminator (single field)¶
- [ ] AC-17. The unambiguous "cost-cap fired" signal is
report.complete is Falsealone — no FailureMode-based conjunction. S4-01 mapscomplete is Falseto exit code2. (Future partial reasons — Phase 13 / Phase 16 — extend additively via apartial_reasonfield; surfaced in Notes-for-implementer, deferred today.)
Tooling¶
-
[ ] AC-18.
mypy --strict,ruff format --check,ruff checkclean on touched files. -
[ ] AC-19. All red tests in §TDD plan exist, were committed at the red marker, and are now green.
Implementation outline¶
-
Widen the queue type in
Queue type becomessrc/codegenie/eval/runner.py:asyncio.Queue[tuple[str, BenchScore] | _Sentinel | _Aborted]. Aggregator'sisinstancechain:_Sentinel(break loop),_Aborted(record case_id inaborted_case_ids), else (record completed score). -
Cost-cap state machine at module level:
class CostCapPhase(StrEnum): RUNNING = "running" APPROACHING = "approaching" BREACHED = "breached" _APPROACH_RATIO: Final[float] = 0.8 def _next_cap_phase(phase: CostCapPhase, running_total: float, cap: float) -> CostCapPhase: if phase is CostCapPhase.BREACHED: return phase # monotonic if running_total > cap: return CostCapPhase.BREACHED if phase is CostCapPhase.RUNNING and running_total >= _APPROACH_RATIO * cap: return CostCapPhase.APPROACHING return phase -
Add
Threadmax_cost_usdtoRunner.execute(...)with the validation:max_cost_usdandcost_cap_eventinto_aggregate. -
Aggregator extension — inside
_aggregate(queue, plan, on_score, started_at, max_cost_usd, cost_cap_event): - Track
phase: CostCapPhase = CostCapPhase.RUNNING,running_total: float = 0.0,completed_buf: list[tuple[str, BenchScore]] = [],aborted_case_ids: set[str] = set(). - On a
(case_id, score)item:running_total += score.cost_usd; append tocompleted_buf; awaiton_score(...)(if provided — S3-02 AC-11 contract); computenew_phase = _next_cap_phase(phase, running_total, max_cost_usd); onRUNNING → APPROACHINGlog WARNING (AC-10); on* → BREACHEDlog ERROR (AC-11), setcost_cap_event.set(), then drain remaining queue items (usequeue.get_nowait()untilQueueEmptyto capture in-flight items), then computeremaining = {c.case_id for c in plan.cases} - {cid for cid, _ in completed_buf} - aborted_case_idsand enqueue_Aborted(cid)for each (sorted) — these enqueues are picked up in subsequent loop iterations before_SENTINEL. - On an
_Aborted(case_id)item:aborted_case_ids.add(case_id). -
On
_SENTINEL: break loop; finalize. -
Worker cooperation in
_run_case(the S3-02-defined seam):async def _run_case(case, plan, sem, queue, *, cost_cap_event, …): if cost_cap_event.is_set(): # AC-6 (i) return async with sem: if cost_cap_event.is_set(): # AC-6 (i, after acquire) return # ... cache probe, SUT, rubric ... if cost_cap_event.is_set(): # AC-6 (ii) return await queue.put((case.case_id, score)) -
Runner.run_eval(...)— the new composition surface, body shape: -
_finalize_partial_report(welford, completed_buf, aborted_case_ids, plan, started_at) -> BenchRunReport(pure, module-level): - Build placeholder
BenchScores foraborted_case_ids(AC-8:failure_modes=()). - Merge with
completed_buf; sort bycase_id. - If
aborted_case_idsis empty:run_id=plan.run_id,complete=True. Else:run_id=f"partial:{plan.run_id}",complete=False. - All other fields populated per S3-02 AC-7 (plan-bound + aggregated).
TDD plan — red / green / refactor¶
Red — write failing tests first¶
tests/unit/test_runner_cost_cap.py:
import pytest
import asyncio
from pathlib import Path
from structlog.testing import capture_logs
from codegenie.eval.runner import Runner, _Aborted, CostCapPhase, _next_cap_phase
from codegenie.eval import audit
from tests.helpers.bench import make_plan_with_costs
from tests.helpers.suts import GatedCostingSUT, CostingStubSUT
from tests.helpers.rubrics import InProcessStubRubric
@pytest.mark.asyncio
async def test_cap_fires_partial_prefix_and_complete_false_exact_cardinality(tmp_path):
"""Mutation-resistant: exact completed/cancelled cardinality via gated SUT."""
plan = make_plan_with_costs(case_ids=["a", "b", "c", "d", "e"], cost_each=2.0)
sut = GatedCostingSUT(cost_each=2.0, release_order=["a", "b", "c", "d", "e"])
rubric = InProcessStubRubric()
report = await Runner().execute(
plan, system_under_test=sut, rubric_runner=rubric,
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0,
)
# AC-9: shape
assert report.complete is False
assert report.run_id == f"partial:{plan.run_id}"
assert {cid for cid, _ in report.per_case} == {"a", "b", "c", "d", "e"}
assert len(report.per_case) == 5
# AC-7 / AC-8: exact cardinality via deterministic gated completion
completed_ids = {cid for cid, s in report.per_case if s.cost_usd > 0.0}
cancelled_ids = {cid for cid, s in report.per_case if s.cost_usd == 0.0}
assert completed_ids == {"a", "b", "c"} # 3 cases at $2 = $6 > cap $5
assert cancelled_ids == {"d", "e"}
# AC-8: placeholder score field-by-field
for cid, score in report.per_case:
if cid in cancelled_ids:
assert score.passed is False
assert score.score == 0.0
assert score.breakdown == {}
assert score.failure_modes == () # NO synthetic FailureMode
assert score.cost_usd == 0.0
assert score.wall_clock_ms == 0
# AC-9: total cost from real cases only
assert report.total_cost_usd == 6.0
@pytest.mark.asyncio
async def test_cap_fires_on_case_1_immediately(tmp_path):
"""AC-14: a single case whose cost alone exceeds the cap still produces a partial report."""
plan = make_plan_with_costs(case_ids=["a", "b", "c"], cost_each=10.0)
sut = GatedCostingSUT(cost_each=10.0, release_order=["a", "b", "c"])
report = await Runner().execute(
plan, system_under_test=sut, rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0,
)
assert report.complete is False
completed = {cid for cid, s in report.per_case if s.cost_usd > 0.0}
cancelled = {cid for cid, s in report.per_case if s.cost_usd == 0.0}
assert completed == {"a"}
assert cancelled == {"b", "c"}
@pytest.mark.asyncio
async def test_complete_true_no_aborted_markers_no_logs(tmp_path):
"""AC-9 happy path. Mutation guards: no _Aborted leaked into per_case; no cap logs."""
plan = make_plan_with_costs(case_ids=["a", "b"], cost_each=0.5)
sut = CostingStubSUT(cost_each=0.5)
with capture_logs() as logs:
report = await Runner().execute(
plan, system_under_test=sut, rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0,
)
assert report.complete is True
assert report.run_id == plan.run_id # no partial: prefix
# Mutation guards
assert not any(s.failure_modes for cid, s in report.per_case) # no synthesized FMs
assert not any(e.get("event") in {"cost_cap_approaching", "cost_cap_exceeded"} for e in logs)
@pytest.mark.asyncio
async def test_boundary_equality_does_not_fire(tmp_path):
"""AC-13: running_total == max_cost_usd does NOT breach (predicate is `>`, not `>=`)."""
plan = make_plan_with_costs(case_ids=["a", "b"], cost_each=2.5)
report = await Runner().execute(
plan, system_under_test=CostingStubSUT(cost_each=2.5),
rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0,
)
assert report.complete is True
@pytest.mark.asyncio
async def test_partial_run_appended_to_audit_chain_with_verify(tmp_path):
"""AC-15: run_eval writes regardless of complete; chain verify passes."""
out_dir = tmp_path / "runs"
out_dir.mkdir(parents=True)
assert len(list(out_dir.glob("*.json"))) == 0 # fresh
plan = make_plan_with_costs(["a", "b", "c"], cost_each=10.0)
report = await Runner().run_eval(
plan,
system_under_test=GatedCostingSUT(cost_each=10.0, release_order=["a", "b", "c"]),
rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0, out_dir=out_dir,
)
files = list(out_dir.glob("*.json"))
assert len(files) == 1 # AC-15 (i)/(ii)
assert audit.verify(out_dir).ok is True # AC-15 (iii) — chain integrity
latest = audit.read_latest(out_dir)
assert latest.complete is False # AC-15 (iv)
assert latest.run_id.startswith("partial:")
assert report.chain_head != "" # run_eval populated it
@pytest.mark.asyncio
async def test_execute_does_not_write_audit_chain_on_cap_fire(tmp_path, monkeypatch):
"""AC-16: regression of S3-02 AC-13 under the cap-fire path."""
monkeypatch.setattr(
"codegenie.eval.audit.write_run_record",
lambda *a, **kw: pytest.fail("execute() must not write to audit chain"),
)
plan = make_plan_with_costs(["a", "b", "c"], cost_each=10.0)
report = await Runner().execute(
plan, system_under_test=GatedCostingSUT(cost_each=10.0, release_order=["a", "b", "c"]),
rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0,
)
assert report.complete is False
assert report.chain_head == "" # untouched by execute
@pytest.mark.asyncio
async def test_warning_at_80_percent_fires_exactly_once(tmp_path):
"""AC-10 + single-fire (structural via monotonic CostCapPhase)."""
plan = make_plan_with_costs(["a", "b", "c", "d", "e"], cost_each=1.0)
with capture_logs() as logs:
await Runner().execute(
plan, system_under_test=CostingStubSUT(cost_each=1.0),
rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0, # 80% = 4.0 → fires after 4 cases
)
warnings = [e for e in logs if e.get("event") == "cost_cap_approaching"]
assert len(warnings) == 1
assert warnings[0]["max_cost_usd"] == 5.0
assert warnings[0]["running_total"] >= 4.0
assert "n_completed" in warnings[0]
@pytest.mark.asyncio
async def test_error_log_at_breach(tmp_path):
"""AC-11: ERROR fires once at breach."""
plan = make_plan_with_costs(["a", "b", "c"], cost_each=3.0)
with capture_logs() as logs:
await Runner().execute(
plan, system_under_test=GatedCostingSUT(cost_each=3.0, release_order=["a", "b", "c"]),
rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0,
)
errors = [e for e in logs if e.get("event") == "cost_cap_exceeded"]
assert len(errors) == 1
assert errors[0]["n_remaining"] >= 1
assert "n_completed" in errors[0]
@pytest.mark.asyncio
async def test_no_warning_when_under_80_percent(tmp_path):
"""AC-12: catches the early-fire mutation."""
plan = make_plan_with_costs(["a", "b"], cost_each=1.0) # total=2.0, cap=5.0 → 40%
with capture_logs() as logs:
await Runner().execute(
plan, system_under_test=CostingStubSUT(cost_each=1.0),
rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0,
)
assert not any(e.get("event") in {"cost_cap_approaching", "cost_cap_exceeded"} for e in logs)
@pytest.mark.asyncio
async def test_max_cost_usd_zero_or_negative_raises(tmp_path):
"""AC-1: ValueError at entry; no workers scheduled."""
plan = make_plan_with_costs(["a"], cost_each=1.0)
for bad in (0.0, -1.0, -0.001):
with pytest.raises(ValueError, match="max_cost_usd must be > 0.0"):
await Runner().execute(
plan, system_under_test=CostingStubSUT(cost_each=1.0),
rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=bad,
)
@pytest.mark.asyncio
async def test_no_stranded_subprocess_after_cap_fires(tmp_path):
"""AC-6 cooperative cancellation: tempdir cleanup robust under early-return."""
plan = make_plan_with_costs(["a", "b", "c", "d"], cost_each=3.0)
sut = CostingStubSUT(cost_each=3.0, tempdir_observer=tmp_path)
await Runner().execute(
plan, system_under_test=sut, rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=3.5,
)
leftover = list(tmp_path.glob("rubric-tempdir-*"))
assert leftover == [], f"stranded tempdirs: {leftover}"
@pytest.mark.asyncio
async def test_no_cancellederror_propagates_on_cap_fire(tmp_path):
"""AC-6: cost-cap uses cooperative cancellation, not external task.cancel.
A wrong impl that calls task.cancel() would let CancelledError escape per S3-02 AC-12."""
plan = make_plan_with_costs(["a", "b", "c"], cost_each=10.0)
# Must not raise CancelledError
report = await Runner().execute(
plan, system_under_test=GatedCostingSUT(cost_each=10.0, release_order=["a", "b", "c"]),
rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / "cache", timeout_per_case_seconds=5.0,
max_cost_usd=5.0,
)
assert report.complete is False
tests/unit/test_runner_cost_cap_helpers.py — direct unit tests for _next_cap_phase:
import pytest
from codegenie.eval.runner import CostCapPhase, _next_cap_phase
def test_running_stays_running_below_80pct():
assert _next_cap_phase(CostCapPhase.RUNNING, running_total=3.9, cap=5.0) is CostCapPhase.RUNNING
def test_running_to_approaching_at_exact_80pct():
assert _next_cap_phase(CostCapPhase.RUNNING, running_total=4.0, cap=5.0) is CostCapPhase.APPROACHING
def test_approaching_stays_approaching_below_breach():
assert _next_cap_phase(CostCapPhase.APPROACHING, running_total=4.9, cap=5.0) is CostCapPhase.APPROACHING
def test_running_to_breached_skips_approaching_when_first_score_exceeds():
assert _next_cap_phase(CostCapPhase.RUNNING, running_total=10.0, cap=5.0) is CostCapPhase.BREACHED
def test_boundary_equality_is_not_breached():
"""AC-13: > not >=."""
assert _next_cap_phase(CostCapPhase.APPROACHING, running_total=5.0, cap=5.0) is CostCapPhase.APPROACHING
def test_breached_is_terminal_idempotent():
assert _next_cap_phase(CostCapPhase.BREACHED, running_total=0.0, cap=5.0) is CostCapPhase.BREACHED
assert _next_cap_phase(CostCapPhase.BREACHED, running_total=999.0, cap=5.0) is CostCapPhase.BREACHED
tests/property/test_runner_cost_cap_properties.py:
from hypothesis import given, strategies as st, settings
import pytest
from codegenie.eval.runner import Runner
from tests.helpers.bench import make_plan_with_costs
from tests.helpers.suts import GatedCostingSUT
from tests.helpers.rubrics import InProcessStubRubric
@settings(max_examples=30, deadline=5000)
@given(
n=st.integers(min_value=1, max_value=8),
cost_each=st.floats(min_value=0.1, max_value=10.0, allow_nan=False, allow_infinity=False),
cap=st.floats(min_value=0.5, max_value=20.0, allow_nan=False, allow_infinity=False),
)
@pytest.mark.asyncio
async def test_cap_fires_iff_total_exceeds(n, cost_each, cap, tmp_path):
"""AC-13 + AC-9 invariant: complete is False ⇔ n * cost_each > cap."""
case_ids = [f"c{i:02d}" for i in range(n)]
plan = make_plan_with_costs(case_ids=case_ids, cost_each=cost_each)
sut = GatedCostingSUT(cost_each=cost_each, release_order=case_ids)
report = await Runner().execute(
plan, system_under_test=sut, rubric_runner=InProcessStubRubric(),
cache_dir=tmp_path / f"cache_{n}_{cost_each}_{cap}",
timeout_per_case_seconds=5.0, max_cost_usd=cap,
)
expected_partial = (n * cost_each) > cap
assert (report.complete is False) == expected_partial
# No-silent-drops accounting (AC-9)
assert {cid for cid, _ in report.per_case} == set(case_ids)
# Bounded over-shoot
assert report.total_cost_usd <= cap + cost_each + 1e-9
Run all tests; confirm failures. Commit as the red marker.
Green — make them pass¶
Implement per §Implementation outline: _Aborted dataclass, CostCapPhase StrEnum, _next_cap_phase pure helper, cost_cap_event plumbing, cooperative _run_case checks, aggregator drain + _Aborted emission, _finalize_partial_report pure helper, Runner.run_eval composition.
Refactor — clean up¶
- Module docstring on
runner.pydocuments Gap #4 ("partial reports are first-class audit records; the gate refuses promotion on them; Phase 13's outcome ledger can still see them") and references S3-04's ADR-0004 amendment removingsut.cancelled. - Single
_format_partial_run_id(run_id: str) -> str(f"partial:{run_id}") for symmetry with_finalize_partial_report. - Verify no stdlib
loggingcalls leaked in (everything viastructlog.get_logger).
Files to touch¶
| Path | Why |
|---|---|
src/codegenie/eval/runner.py |
_Aborted dataclass, CostCapPhase StrEnum, _next_cap_phase helper, cooperative _run_case checks, aggregator drain + _Aborted emission, _finalize_partial_report, Runner.run_eval composition, max_cost_usd kwarg on execute |
tests/unit/test_runner_cost_cap.py |
New: all integration tests for the cap-fires path |
tests/unit/test_runner_cost_cap_helpers.py |
New: pure-helper unit tests for _next_cap_phase (functional core) |
tests/property/test_runner_cost_cap_properties.py |
New: Hypothesis property for "complete is False ⇔ total > cap" + bounded over-shoot |
tests/helpers/suts.py |
Add CostingStubSUT (per-case cost emission, optional tempdir-observer hook) and GatedCostingSUT (deterministic release-order via per-case asyncio.Event) |
Note: src/codegenie/eval/models.py is NOT touched. S1-02 ships complete: bool already; no new field is added by this story (the original_run_id field has been dropped — see Validation notes F-CON-3).
Out of scope¶
- The exit-code mapping at CLI (S4-01 — exit code 2 for
complete=False). - Promotion gate's refusal on
complete=False(S4-04 —IncompleteReportForPromotion). - Live-LLM cost tracking source (
SandboxCostEntry.cost_usd— already wired in Phase 5, consumed via S2-06). - Per-case cost prediction / forecasting (deferred to Phase 13).
--allow-isolation-mixoverride flag for ADR-0010 (deferred to Phase 16).- Cross-process cost-cap protection via
flockon<bench_root>/.<task_class>.runlock(final-design line 324). Phase 6.5 ships single-process cooperative cancellation. The cross-host / cross-process protection is a separate story (proposedS3-06b — Cross-process cost-cap runlock) once a real live-mode operator workflow exists; today's CI cassette runs do not cross processes. Deferring is explicit, not silent (Validation notes F-CON-5). BenchRunReport.partial_reason: Literal["cost_cap"] | None = Nonefield as the open/closed discriminator for future partial-reason consumers (Phase 13 cost-prediction abort, Phase 16 microVM OOM). Todaycomplete is Falseis the sole discriminator (AC-17); when a second partial-reason lands, the field amendment is the rule-of-three threshold (Notes-for-implementer).
Notes for the implementer¶
- The partial run is a real audit record, not a degraded one. The whole point of Gap #4 is that the chain captures evidence of "we tried, the cap fired, here's what we got." Promotion is the next decision, not the audit's.
- Cooperative cancellation matters. Do NOT
task.cancel()worker tasks for the cost-cap path — that propagatesCancelledErrorthroughasyncio.gatherper S3-02 AC-12, bypassing the aggregator's_Aborted-emission. Externaltask.cancel()is reserved for S3-04 AC-4a's external-cancel CLI path (operator hits Ctrl-C). Cost-cap usescost_cap_eventcooperative checks at safe-points in_run_case. - Do NOT raise on cost cap. Raising would skip the audit append in
run_evaland lose the evidence. Returning the partial report is the contract. failure_modes=()on placeholder scores is intentional. Do not synthesize aFailureMode(code="sut.cancelled", …)— S3-04's ADR-0004 amendment removedsut.cancelledfrom the seed taxonomy; emitting it would resolve via S3-04 AC-7's reserved-namespace defense torubric.unknown_failure_mode, silently destroying the discriminator. The placeholder records the fact (the case was cancelled before dispatch) without fabricating a SUT event.- The 80%-of-cap WARNING is a curator-UX nicety. The structural single-fire guarantee comes from
CostCapPhase's monotonicity (RUNNING → APPROACHINGhappens at most once per run — re-fire is impossible because_next_cap_phasenever returns toRUNNING). Tested directly intest_runner_cost_cap_helpers.py. _finalize_partial_reportis pure — no I/O, no clock reads beyond thestarted_atargument. This is what makes the partial-run synthesis re-testable in isolation and what lets a future Phase 13 cost-predictor build "what would the report have looked like" simulations without spinning up a runner.- Future extension:
partial_reasonfield. When Phase 13 cost-prediction-abort or Phase 16 microVM-OOM partial lands as the second partial-reason, addBenchRunReport.partial_reason: Literal["cost_cap", "<next>"] | None = None. S4-01's CLI dispatch and S4-04's gate refusal then read one discriminated field. Today (1 producer),complete is Falseis sufficient and avoids a wire-contract change to a HARDENED S1-02 model. - Future extension:
CostCapvalue object. If a third threshold-check site lands (e.g., Phase 13 introduces a "soft warn at 50%" threshold), promotemax_cost_usd: floattoCostCap(limit_usd: float, warn_ratio: float = 0.8)orcost_warning_thresholds: tuple[float, ...]. Today (2 sites — warn at 80%, breach at 100%), the bare kwarg is fine (Rule 2: three similar lines is better than premature abstraction). _Abortedqueue marker honors the precedent S3-02 set. The aggregator'sisinstance(item, _Aborted)branch is the structural seam — a future "partial because of OOM" path would addclass _OOMAborted(_Aborted): ...(or an adjacent variant) without rewriting the aggregator.