feat(verdicts): a cross-base candidate collision is reported, never dropped in silence

[skip-docs] — the invariant row for this plan lands in Step 13, after the mutations.

Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-02 21:15:50 +02:00
commit 2edd3dce2b
3 changed files with 214 additions and 0 deletions

View file

@ -54,6 +54,7 @@ import pytest
from portfolio_optimiser import explore, okf, run
from portfolio_optimiser.mandate import Approach, Mandate
from portfolio_optimiser.simulation import ScriptedChatClient
from portfolio_optimiser.verdicts import VerdictCollision
_EXAMPLES = Path(__file__).resolve().parents[1] / "shared" / "examples"
_BYGG = _EXAMPLES / "bygg-energi-mikro"
@ -273,3 +274,126 @@ async def test_the_dispatcher_refuses_a_disagreeing_base_before_it_starts_any_ru
(str(base),),
)
assert started == [], "the dispatcher started a run before reconciling the base it routed to"
# ==================================================================================================
# Step 11 - a cross-base candidate collision stops being silent
# ==================================================================================================
#
# **The defect (operator decision D2).** ``VerdictStore.add`` is first-write-wins per id and
# ``_mint_id`` excludes the corpus BY CONSTRUCTION (it hashes ``affected_codes`` / ``measure_type``
# / ``claimed_saving_nok``, and nothing else). Two independently produced bases that describe the
# same candidate therefore collapse onto ONE verdict and the second is dropped in silence. No test
# covered it.
#
# **``_mint_id`` is NOT changed** -- its form is normative in ``shared/method-spec.md`` and
# commons-owned, so altering it is an amendment, not a local edit. **``add`` is not changed either:**
# it is called from inside ``run_project`` and ``run_portfolio``, never from
# ``run_mandate_across_bundles``, so a return value there would reach no code in this step's scope,
# and a drop there can equally be against a Step-7 inbox verdict or a bundle seed rather than a
# second base. The mechanism is dispatcher-side accounting, because the dispatcher is the one place
# that holds the id -> base map the store does not keep.
_COLLIDING_PROPOSAL = (
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
)
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (fixture)"}
def _colliding_factory() -> Any:
"""Both bases answer with the SAME proposal, which is what makes the two ids collide -- the
corrected fixture note: ``_mint_id`` hashes the PROPOSAL's features, not the base's IR
projection, so matching projections alone would mint nothing and the arm would pass for the
wrong reason.
The role names are ``workflow._MAKER_CHECKER_ROLES`` verbatim (``proposer`` / ``checker``); a
typo there would feed the proposal JSON to the checker and the arm would still pass, since F2
mints a verdict on either outcome -- green for a different reason than the docstring claims.
The outcome itself is deliberately NOT asserted: this arm is about candidate IDENTITY, and
``_mint_id`` reads the same three structural fields whether the gate validated or rejected."""
return lambda role: ScriptedChatClient(
role=role,
default_reply=(_COLLIDING_PROPOSAL if role != "checker" else "Holder. VERDICT: APPROVE"),
)
def _two_bases(tmp_path: Path) -> tuple[Path, Path]:
return _base_copy(tmp_path, name="base-one"), _base_copy(tmp_path, name="base-two")
def _mandate_over(*bundle_ids: str) -> Any:
return Mandate(
objective="find the same saving twice",
approaches=tuple(
Approach(id=f"a{i}", label=f"A{i}", bundle_id=bid) for i, bid in enumerate(bundle_ids)
),
allow_own_proposals=False,
)
@pytest.mark.asyncio
async def test_two_bases_describing_one_candidate_are_reported_not_dropped(tmp_path: Path) -> None:
"""(k) The measured silence, closed. Two bases, one candidate: the collision names BOTH bases
and the shared id, in dispatch order."""
one, two = _two_bases(tmp_path)
result = await run.run_mandate_across_bundles(
_mandate_over(one.name, two.name),
(str(one), str(two)),
verdict_input=_VERDICT_INPUT,
client_factory=_colliding_factory(),
)
assert len(result.collisions) == 1, f"expected one collision, got {result.collisions}"
collision = result.collisions[0]
assert collision.first_bundle_id == one.name
assert collision.second_bundle_id == two.name
assert collision.verdict_id == result.runs[0].result.verdict_key
@pytest.mark.asyncio
async def test_a_single_base_reports_no_collision(tmp_path: Path) -> None:
"""(l) CONTROL. An empty trace is an honest POSITIVE statement ("no candidate was described by
two bases") -- the ``skipped_links`` half of the required-vs-default rule, not the
``cost_baseline_anchored`` half. Without this arm, an implementation that always appended would
pass (k)."""
one = _base_copy(tmp_path, name="base-one")
result = await run.run_mandate_across_bundles(
_mandate_over(one.name),
(str(one),),
verdict_input=_VERDICT_INPUT,
client_factory=_colliding_factory(),
)
assert result.collisions == ()
@pytest.mark.asyncio
async def test_the_drop_itself_is_unchanged_only_its_visibility(tmp_path: Path) -> None:
"""(m) This step changes VISIBILITY, never semantics. The store still holds exactly one verdict
for the shared id: ``add`` is untouched and first-write-wins still wins."""
one, two = _two_bases(tmp_path)
result = await run.run_mandate_across_bundles(
_mandate_over(one.name, two.name),
(str(one), str(two)),
verdict_input=_VERDICT_INPUT,
client_factory=_colliding_factory(),
)
shared = result.collisions[0].verdict_id
assert [v.id for v in result.store.verdicts].count(shared) == 1
def test_the_collision_notice_is_omitted_when_nothing_collided() -> None:
"""(n) Omission, never an empty row (``mandate.announce``'s rule, as ``skipped_links_notice``
and ``cost_baseline_notice`` already follow). Both directions are asserted: a renderer that
always returns a line is as wrong as one that never does."""
assert run.collision_notice(()) is None
line = run.collision_notice(
(
VerdictCollision(
verdict_id="abc123", first_bundle_id="base-one", second_bundle_id="base-two"
),
)
)
assert line is not None
assert "abc123" in line and "base-one" in line and "base-two" in line