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:
parent
842c51401d
commit
2edd3dce2b
3 changed files with 214 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue