portfolio-optimiser/tests/test_step1_expel_loadbearing.py
Kjell Tore Guttormsen d6d83d42b5 feat(fase2): wire Step-1 ExpeL retrieval into the hypothesis prompt
Closes maalbilde §5 gap #1 (the one missing "feedback-into-prompt" dataflow)
for the OKF-bundle path. Before, ExpeL was computed AFTER generation into a
discarded SessionContext, so a prior verdict could not influence any hypothesis
(context_providers=0).

- New okf.py: framework-neutral OKF bundle navigation (index + frontmatter +
  cross-links), pure stdlib, no agent_framework/mcp (D7-portable), enforced by
  test_okf_is_maf_free.
- verdicts.py: seed_store_from_bundle + bundle_candidate_features build the
  ExpeL substrate + the pre-hypothesis query key from a bundle.
- run_project(bundle_dir=...): folds the candidate's prior verdicts into the
  generation context BEFORE generate_via_llm; the road path is unchanged.

Load-bearing (maalbilde §7): test_step1_expel_loadbearing proves a prior verdict
reaches the hypothesis prompt and goes RED when the fold is detached (shown via
TDD red->green). The marker is the minted verdict id (content hash) because
docs_dir==bundle_dir lets keyword chunk-stuffing leak the realization rate;
clean layer separation is Fase 2b.

Suite 121->133 passed; mypy + ruff check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
2026-06-29 10:56:48 +02:00

93 lines
4.4 KiB
Python

"""Step-1 load-bearing seam (målbilde §5 / §7): a prior expert verdict MUST reach the next
hypothesis prompt.
The diagnosis (fot-i-bakken, verified in code): ExpeL retrieval was computed AFTER generation and
written into a discarded ``SessionContext`` — so the seed verdict could not influence any
hypothesis (``context_providers`` = 0). Fase 2a wires it: before ``generate_via_llm``, the
candidate's prior verdicts are folded into the generation context.
These two tests are a load-bearing PAIR, not a smoke test (per the Fase-2 green-but-dead trap):
- the positive test asserts the seed verdict's realization signal ("0.82") IS in the generation
prompt — it goes RED the moment the fold is detached;
- the empty-store test asserts the signal is ABSENT without a seed — proving the signal's presence
is CAUSED by the seam, not incidental to the fixture.
"""
from __future__ import annotations
from pathlib import Path
from portfolio_optimiser.run import run_project
from portfolio_optimiser.validator import ValidatedProposal
from portfolio_optimiser.verdicts import VerdictStore, seed_store_from_bundle
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
# A VALID BYGG-KONTOR-NORD proposal: affected total = 300000 x 1.0; no assumptions -> degenerate
# Monte Carlo P90 = 0.30 x 300000 = 90000 >= claimed 30000 -> validates on the first attempt.
_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 load-bearing marker is the seed verdict's MINTED id (a 16-char content hash that
# ``ExpeLContextProvider.format_fewshot`` emits as ``- [<id>] ...``). It cannot appear in the
# bundle's raw text, so its presence in the prompt isolates the STRUCTURAL ExpeL path. (The
# realization rate "0.82" is NOT a usable marker here: ``docs_dir == bundle_dir``, so keyword
# chunk-stuffing reads the verdict file and would leak "0.82" regardless of the fold. Layer
# separation — verdicts out of the keyword docs folder — is Fase 2b.)
_SEED_ID = seed_store_from_bundle(str(BUNDLE_DIR)).verdicts[0].id
def _generation_prompts(sink: list[str]) -> list[str]:
"""The generation-call prompts (``generate._build_messages`` embeds 'SavingsProposal'),
isolated from the debate-round prompts also captured in the sink."""
return [p for p in sink if "SavingsProposal" in p]
async def test_prior_verdict_reaches_hypothesis_prompt(make_recording_client_factory) -> None:
"""LOAD-BEARING: with the energi bundle's seed verdict in the store, the realization signal
reaches the hypothesis-generation prompt. Detach the fold in ``run.py`` and this goes red."""
store = seed_store_from_bundle(str(BUNDLE_DIR))
assert store.verdicts, "precondition: the bundle seeds exactly one verdict"
factory, recorded = make_recording_client_factory(_ENERGY_REPLY)
result = await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
store=store,
client_factory=factory,
)
assert isinstance(result.outcome, ValidatedProposal)
gen_prompts = _generation_prompts(recorded)
assert gen_prompts, "the generation call must have happened"
assert any(_SEED_ID in p for p in gen_prompts), (
"the seed verdict did not reach the hypothesis prompt — the ExpeL->prompt fold is detached"
)
async def test_empty_store_leaves_no_signal_in_prompt(make_recording_client_factory) -> None:
"""CAUSALITY CONTROL: the same run with an EMPTY store carries no realization signal into the
prompt — proving the signal above is caused by the seam, making the positive test load-bearing
(not merely green against the fixture's mere presence)."""
factory, recorded = make_recording_client_factory(_ENERGY_REPLY)
await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
store=VerdictStore(verdicts=[]),
client_factory=factory,
)
assert all(_SEED_ID not in p for p in recorded), (
"no seed verdict in the store, yet its id appeared in a prompt — "
"the assertion is not actually load-bearing"
)