portfolio-optimiser/tests/test_portfolio_failure_accounting_loadbearing.py
Kjell Tore Guttormsen 126807aee7 feat(validator): anchor the deterministic gate to the project's real cost baseline (S4.0)
Every stage of validate_proposal reasoned only about numbers the proposal itself
supplied, so an internally-consistent hallucination cleared the whole gate (F3).
A new stage 0 reconciles each affected_item against the project's CostBaseline
before the CBC solve: an unknown cost code is rejected, and a real code carrying
a quantity/unit_cost outside the configured tolerance (5% default, relative to
the baseline value) is rejected. Validation, never repair.

The baseline argument is OPTIONAL (None = pre-S4.0 behaviour), but both run
paths set it: the road path projects project.cost_items, the bundle path loads
cost-baseline.json when the bundle ships one. Bundles written before the
amendment stay un-anchored, so the commons-owned goldens run byte-identically;
a baseline that exists but is malformed still raises on both loaders.

F8: the method-specific cap now comes from the METHOD_CAPS registry (measure
type -> fraction, injectable) instead of an energy_efficiency string comparison.

The baseline format and tolerance semantics were decided locally — the commons
amendment (D-A pt. 2) never arrived, exactly as in S3.2. D7 mirroring stays open.

Three portfolio fixtures quoted cost codes belonging to OTHER projects; the new
gate caught them. They now quote each project's own lines, and the two copied
REPLIES tables import the single source instead of drifting from it.

Load-bearing measured (tests/test_s40_cost_baseline_loadbearing.py), six
mutations all red: detach the reconciliation stage; detach the magnitude
tolerance; detach the road wiring; detach the bundle wiring; ignore the injected
cap registry; make the optional loader tolerant of malformed content. Control:
with the road wiring detached the repaired portfolio fixtures still pass, so
they are not masking the seam. 597 -> 612 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JdwK7bQ4BZkWH4t8MRDKb4
2026-08-03 17:19:31 +02:00

233 lines
11 KiB
Python

"""S3.3 load-bearing — what a FAILED project does to the wave handler and to the token ledger.
Two properties on the same seam (``run.py`` wave-result handling), sharing one fixture because they
share one code path: a project that raises mid-wave.
1. **The wave handler catches ``BaseException``, not ``Exception``.** The existing collect-and-
continue test (``test_one_project_failure_does_not_cancel_its_siblings``) raises ``RuntimeError``,
which is an ``Exception`` — so it stays GREEN if the handler is narrowed, and cannot gate the
width of the catch. ``asyncio.CancelledError`` derives from ``BaseException`` (3.8+) and is the
one realistic vector that separates the two.
MEASURED, not assumed, before this test was written: ``asyncio.gather(return_exceptions=True)``
COLLECTS a ``CancelledError`` raised by a member coroutine into the result list, so the
``BaseException`` branch is genuinely reachable. ``KeyboardInterrupt`` is NOT a usable vector —
asyncio special-cases it and propagates out of ``gather`` regardless of ``return_exceptions``,
so widening the catch could never help there. That measurement is why this file tests exactly
one BaseException subclass and does not pretend to cover the rest.
2. **A failed project's tokens still count against the GLOBAL cap.** ``PortfolioResult.
sum_token_usage`` sums ``provenance.token_usage`` over ``runs``, and a failed project has no
``RunResult`` — so the aggregate necessarily UNDER-reports what the pass actually spent. That is
the honest answer rather than a bug to be patched: a run that died before producing a stamp has
no provenance, and inventing one would fabricate exactly the thing this repo's provenance rules
exist to prevent (the same reasoning that made ``RunFailure`` a distinct type).
What must NOT be true is that those tokens vanish from the ledger the cap is enforced against.
If they did, a project that fails repeatedly could burn unbounded budget while the meter read
clean. This pins the split: ``meter.spent`` is the pass's real cost, ``sum_token_usage`` is the
completed-run subtotal, and the difference is exactly the failed project's spend.
"""
from __future__ import annotations
import asyncio
from typing import Any, Mapping, Sequence
from test_portfolio import REPLIES
from portfolio_optimiser.budget import PortfolioBudget, PortfolioMeter
from portfolio_optimiser.run import RunFailure, run_portfolio
from portfolio_optimiser.simulation import ScriptedChatClient
_PORTFOLIO_IDS = ["FV42-GSV-E1", "RV13-RAS-TP", "BRU-LAKS-REHAB"]
# The MIDDLE project. Middle is deliberate (mirroring the Step-4 test): a failure at either end can
# be dropped by a truncation bug and still leave the survivors in the right relative order.
_FAILING_PID = "RV13-RAS-TP"
_DEFAULT_REPLY = (
'{"measure":"Reduce scope","affected_items":'
'[{"code":"01.1","quantity":1,"unit_cost":100000}],"claimed_saving_nok":20000}'
)
# ``REPLIES`` is IMPORTED from tests/test_portfolio.py (see the import above) rather than copied —
# the local copy had drifted onto other projects' cost codes, which S4.0's baseline anchoring
# rejects. ``_DEFAULT_REPLY`` above is reached only by a prompt naming none of the three mapped
# projects; on the anchored road path such a reply is rejected as a fabricated cost line, which is
# the correct outcome for a project this fixture never described.
# Measured in tests/test_portfolio_budget_loadbearing.py: 4 chat calls x ``tokens`` per reply, so a
# completed run costs a flat 32 tokens at tokens=8.
_TOKENS_PER_REPLY = 8
_PER_RUN_SPEND = 32
_SURVIVOR_COUNT = 2
class _FailAfterNCallsClient(ScriptedChatClient):
"""Project-aware scripted client that raises a chosen exception for ONE project, after that
project has already completed ``fail_after`` calls.
``fail_after`` is what makes property 2 measurable: at 0 the project dies having spent nothing,
which would make the ledger assertion trivially equal. The counter is SHARED across the clients
the factory produces, because ``client_factory`` is called per ROLE and a run uses several — a
per-instance counter would restart on every role and never reach the threshold.
The failure is raised from inside a coroutine, not synchronously: ``_inner_get_response`` is a
sync method RETURNING an awaitable, so raising synchronously would blow up during ``gather``'s
argument construction — before any concurrency exists — and would test a different thing than a
mid-flight failure.
"""
def __init__(
self,
replies: dict[str, str],
*,
failing_pid: str,
error: BaseException,
counter: dict[str, int],
fail_after: int = 0,
) -> None:
table = dict(replies)
def _select(blob: str, _role: str) -> str:
return next((r for pid, r in table.items() if pid in blob), _DEFAULT_REPLY)
super().__init__(
reply_selector=_select,
default_reply=_DEFAULT_REPLY,
tokens_per_reply=_TOKENS_PER_REPLY,
)
self._failing_pid = failing_pid
self._error = error
self._counter = counter
self._fail_after = fail_after
def _inner_get_response(
self,
*,
messages: Sequence[Any],
options: Mapping[str, Any],
stream: bool = False,
**kwargs: Any,
) -> Any:
blob = " ".join(getattr(m, "text", "") or "" for m in messages)
if not stream and self._failing_pid in blob:
seen = self._counter.get("n", 0)
if seen >= self._fail_after:
error = self._error
async def _boom() -> Any:
raise error
return _boom()
self._counter["n"] = seen + 1
return super()._inner_get_response(
messages=messages, options=options, stream=stream, **kwargs
)
def _factory(error: BaseException, *, fail_after: int = 0) -> Any:
counter: dict[str, int] = {}
def factory(_role: str) -> Any:
return _FailAfterNCallsClient(
REPLIES,
failing_pid=_FAILING_PID,
error=error,
counter=counter,
fail_after=fail_after,
)
return factory
async def test_a_cancelled_project_is_collected_not_propagated(fresh_store) -> None:
"""RED 1: a wave member raising ``asyncio.CancelledError`` is recorded as a ``RunFailure``
while its siblings complete — the pass does not raise and does not lose their results.
**Detach point: narrow ``run.py``'s ``isinstance(outcome, BaseException)`` to ``Exception``
-> this test goes RED (measured).** ``CancelledError`` then misses the failure branch and falls
through to ``cast(RunResult, outcome)``, which puts a live exception object into ``runs``; the
pass dies in ``_aggregate`` reaching for ``.provenance`` on it. Note the failure mode is a
crash in a DIFFERENT function than the defect — collect-and-continue is negated for cancelled
members, and the traceback points away from the cause. That is precisely why the width of this
catch is worth pinning rather than leaving to the ``RuntimeError`` test.
Why ``CancelledError`` specifically is documented in this module's docstring: it is the only
``BaseException`` subclass ``gather(return_exceptions=True)`` actually hands back.
"""
result = await run_portfolio(
_PORTFOLIO_IDS,
"local",
store=fresh_store,
client_factory=_factory(asyncio.CancelledError("synthetic cancellation")),
)
# Reaching this line at all is half the contract: the pass RETURNED rather than propagating.
assert len(result.failures) == 1, (
f"expected exactly one failure slot, got {result.failures} — a CancelledError that is not "
"collected either propagates out of run_portfolio or is cast into runs as a fake RunResult"
)
failure = result.failures[0]
assert isinstance(failure, RunFailure)
assert failure.project_id == _FAILING_PID
assert failure.error_type == "CancelledError"
# The siblings survived and are real RunResults — the assertion that catches an exception
# object smuggled into ``runs`` by a narrowed catch.
assert len(result.runs) == _SURVIVOR_COUNT
assert [r.verdict.proposal_features.claimed_saving_nok for r in result.runs] == [
200_000.0,
210_000.0,
]
assert result.validated_count + result.rejected_count == _SURVIVOR_COUNT
async def test_a_failed_projects_tokens_still_count_against_the_global_cap(fresh_store) -> None:
"""RED 2: tokens spent by a project that later failed remain in the ledger the global cap is
enforced against, even though they are absent from ``sum_token_usage``.
The split is the point, and both halves are asserted:
- ``meter.spent`` — the pass's REAL cost — includes the failed project's partial spend. If it
did not, a project failing on every attempt would burn budget invisibly and the S3.4 cap
would not bound the pass at all.
- ``sum_token_usage`` — the completed-run subtotal — excludes it, because a run that never
produced provenance has no honest token figure to contribute. Pinned so that a later
"fix" cannot quietly fabricate one to make the two numbers agree.
The difference between them is asserted to be EXACTLY the failed project's spend, so this stays
a statement about accounting rather than a loose inequality that would hold for many wrong
reasons.
"""
# Two calls complete for the failing project before it dies, so its partial spend is non-zero
# and known: without that, both sides of the ledger assertion would be equal by accident.
fail_after = 2
partial_spend = fail_after * _TOKENS_PER_REPLY
meter = PortfolioMeter(PortfolioBudget(max_total_tokens=10_000, max_tokens_per_run=1_000))
result = await run_portfolio(
_PORTFOLIO_IDS,
"local",
store=fresh_store,
client_factory=_factory(RuntimeError("synthetic backend failure"), fail_after=fail_after),
portfolio_meter=meter,
)
assert len(result.runs) == _SURVIVOR_COUNT
assert len(result.failures) == 1
assert result.failures[0].project_id == _FAILING_PID
completed_spend = _SURVIVOR_COUNT * _PER_RUN_SPEND
assert result.sum_token_usage == completed_spend, (
"sum_token_usage must total the COMPLETED runs' provenance and nothing else; a failed "
"project has no provenance to contribute and must not be given a fabricated one"
)
assert meter.spent == completed_spend + partial_spend, (
f"the global ledger recorded {meter.spent}, expected {completed_spend + partial_spend}"
f"the failed project's {partial_spend} tokens were really spent, so a cap that does not "
"see them does not bound the pass"
)
# The under-report is real, bounded, and exactly the failed project's spend.
assert meter.spent - result.sum_token_usage == partial_spend