feat(s31): --semantic-retrieval opt-in threaded through run_project/run_portfolio
This commit is contained in:
parent
6548828fe1
commit
63734f5bfa
2 changed files with 195 additions and 1 deletions
|
|
@ -17,6 +17,7 @@ import pytest
|
|||
from portfolio_optimiser import run
|
||||
from portfolio_optimiser.dimension import Dimension
|
||||
from portfolio_optimiser.ledger import LedgerEntry, SavingsLedger
|
||||
from portfolio_optimiser.run import run_project
|
||||
from portfolio_optimiser.verdicts import ProposalFeatures, capture_verdict, write_verdict
|
||||
|
||||
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||
|
|
@ -416,3 +417,161 @@ def test_json_without_report_refuses(tmp_path, capsys) -> None:
|
|||
rc = run.main(["--json", "--ledger", str(_report_ledger(tmp_path))])
|
||||
assert rc == 1
|
||||
assert "refused" in capsys.readouterr().err.lower()
|
||||
|
||||
|
||||
# --- S3.1 SC6: --semantic-retrieval opt-in (default OFF) --------------------------------------
|
||||
|
||||
_ENERGY_REPLY = (
|
||||
'{"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 (sim)"}
|
||||
|
||||
# The marker lives in the RATIONALE, which is what ``format_fewshot`` emits into the prompt.
|
||||
# "0.37" appears nowhere in the bundle (grep-verified), so its presence in a prompt can only have
|
||||
# come through the ExpeL fold — it cannot be leaked by bundle context.
|
||||
_TIE_MARKER = "realiseringsgrad=0.37"
|
||||
_MARKER_ID = "zz-s31-run-marker"
|
||||
_DISTRACTOR_ID = "aa-s31-run-distractor"
|
||||
|
||||
|
||||
def _tied_pair_store():
|
||||
"""Two verdicts that are structurally IDENTICAL to the bundle's candidate (similarity 1.0
|
||||
each), so the structural ranking can only separate them on ``id`` — and ``_MARKER_ID`` loses
|
||||
that tie. Only the cosine term can promote it.
|
||||
|
||||
The bundle's own seed verdict is deliberately NOT included: its features are byte-identical to
|
||||
the query, making it a perfect cosine match that would win every hybrid ranking and so could
|
||||
never demonstrate a tie-break."""
|
||||
from portfolio_optimiser.verdicts import Verdict, VerdictStore, bundle_candidate_features
|
||||
|
||||
query = bundle_candidate_features(str(BUNDLE_DIR))
|
||||
|
||||
def tied(verdict_id: str, description: str, rationale: str) -> Verdict:
|
||||
return Verdict(
|
||||
id=verdict_id,
|
||||
proposal_features=ProposalFeatures(
|
||||
affected_codes=query.affected_codes,
|
||||
measure_type=query.measure_type,
|
||||
claimed_saving_nok=query.claimed_saving_nok,
|
||||
description=description,
|
||||
),
|
||||
decision="approved",
|
||||
rationale=rationale,
|
||||
)
|
||||
|
||||
return VerdictStore(
|
||||
verdicts=[
|
||||
tied(
|
||||
_DISTRACTOR_ID,
|
||||
"avvist forslag om reforhandling av renholdskontrakt i administrasjonsbygget",
|
||||
"ingen realiseringsdata for dette tiltaket",
|
||||
),
|
||||
tied(
|
||||
_MARKER_ID,
|
||||
"LED-retrofit i kontorlokaler: 90 W armaturer erstattet med 40 W",
|
||||
f"tidligere LED-dom [{_TIE_MARKER}]",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _generation_prompts(sink: list[str]) -> list[str]:
|
||||
return [p for p in sink if "SavingsProposal" in p]
|
||||
|
||||
|
||||
def test_semantic_retrieval_flag_parses_offline(capsys) -> None:
|
||||
"""(a) the flag is accepted in single-project mode and the run still stops offline."""
|
||||
rc = run.main(
|
||||
[
|
||||
_PID,
|
||||
"--docs-dir",
|
||||
str(BUNDLE_DIR),
|
||||
"--bundle-dir",
|
||||
str(BUNDLE_DIR),
|
||||
"--semantic-retrieval",
|
||||
"--live-dry-run",
|
||||
]
|
||||
)
|
||||
assert rc == 0
|
||||
assert "LIVE-DRY-RUN OK" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_semantic_retrieval_is_not_refused_in_portfolio_mode(capsys) -> None:
|
||||
"""The flag is valid in BOTH modes (like --dimension-config), so the portfolio partition must
|
||||
not name it. Probed via a run that IS refused for a different flag: the refusal lists
|
||||
``--docs-dir`` and must NOT mention ``--semantic-retrieval``."""
|
||||
rc = run.main(["--portfolio", "--semantic-retrieval", "--docs-dir", str(BUNDLE_DIR)])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 1
|
||||
assert "--docs-dir" in err
|
||||
assert "--semantic-retrieval" not in err
|
||||
|
||||
|
||||
def test_report_with_semantic_retrieval_is_refused(tmp_path, capsys) -> None:
|
||||
"""--report is an ALLOWLIST: only --ledger/--json ride along. A silently-dropped
|
||||
--semantic-retrieval would break that partition."""
|
||||
ledger_file = tmp_path / "ledger.json"
|
||||
SavingsLedger(entries=[]).save(str(ledger_file))
|
||||
rc = run.main(["--report", "--ledger", str(ledger_file), "--semantic-retrieval"])
|
||||
assert rc == 1
|
||||
assert "refused" in capsys.readouterr().err.lower()
|
||||
|
||||
|
||||
async def test_semantic_retrieval_on_swaps_the_fewshot_reaching_the_prompt(
|
||||
make_recording_client_factory,
|
||||
) -> None:
|
||||
"""(c) the substantive run-level proof: with ``semantic_retrieval=True`` and ``top_k=1``, the
|
||||
verdict that only cosine can surface is the one whose rationale reaches the hypothesis prompt.
|
||||
Drives the REAL Step-1 fold via the recording client — not ``--live-dry-run``, which returns
|
||||
before the fold."""
|
||||
factory, recorded = make_recording_client_factory(_ENERGY_REPLY)
|
||||
|
||||
await run_project(
|
||||
_PID,
|
||||
"local",
|
||||
docs_dir=str(BUNDLE_DIR),
|
||||
bundle_dir=str(BUNDLE_DIR),
|
||||
verdict_input=_VERDICT_INPUT,
|
||||
store=_tied_pair_store(),
|
||||
client_factory=factory,
|
||||
top_k=1,
|
||||
semantic_retrieval=True,
|
||||
)
|
||||
|
||||
gen_prompts = _generation_prompts(recorded)
|
||||
assert gen_prompts, "the generation call must have happened"
|
||||
assert any(_TIE_MARKER in p for p in gen_prompts), (
|
||||
"the cosine-surfaced verdict did not reach the hypothesis prompt — "
|
||||
"--semantic-retrieval is not installing the HybridRanker before the Step-1 fold"
|
||||
)
|
||||
assert any(_MARKER_ID in p for p in gen_prompts)
|
||||
|
||||
|
||||
async def test_semantic_retrieval_off_leaves_the_structural_pick_in_the_prompt(
|
||||
make_recording_client_factory,
|
||||
) -> None:
|
||||
"""CAUSALITY CONTROL — the identical run with the flag OFF must carry the STRUCTURAL winner
|
||||
instead, and no marker. This is what makes the positive above load-bearing: the swap is caused
|
||||
by the flag, not by the fixture merely containing the marker."""
|
||||
factory, recorded = make_recording_client_factory(_ENERGY_REPLY)
|
||||
|
||||
await run_project(
|
||||
_PID,
|
||||
"local",
|
||||
docs_dir=str(BUNDLE_DIR),
|
||||
bundle_dir=str(BUNDLE_DIR),
|
||||
verdict_input=_VERDICT_INPUT,
|
||||
store=_tied_pair_store(),
|
||||
client_factory=factory,
|
||||
top_k=1,
|
||||
)
|
||||
|
||||
assert all(_TIE_MARKER not in p for p in recorded), (
|
||||
"the marker reached a prompt with --semantic-retrieval OFF — the default path is not the "
|
||||
"structural ranking, or the assertion is not load-bearing"
|
||||
)
|
||||
gen_prompts = _generation_prompts(recorded)
|
||||
assert any(_DISTRACTOR_ID in p for p in gen_prompts), (
|
||||
"the structural winner did not reach the prompt — the default fold is broken"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue