"""S3.2 load-bearing seam: a verdict is keyed on ITS OWN candidate, not on the bundle's single IR projection (sesjonsplan Fase 2-6 §S3.2; målbilde §2 step 1). The gap: ``seed_store_from_bundle`` keyed EVERY ``type: verdict`` file in a bundle on ``bundle_candidate_features`` — the one candidate the bundle's ``validator-input.json`` describes. A bundle carrying verdicts about several candidates therefore collapsed them all onto one key: a verdict about candidate B scored a perfect structural match against candidate A's query and could be folded into A's hypothesis prompt. The ExpeL substrate was single-candidate by construction. S3.2 lets each verdict file carry its own structural fields in frontmatter (``affected_codes`` / ``measure_type`` / ``claimed_saving_nok``); when they are absent, keying falls back to the bundle candidate — so every hand-authored seed written before S3.2 keeps working unchanged (proven by the untouched step1/step7/step8 suites plus Test B here). Three load-bearing tests: - Test A (SEPARATION, the RED point): a bundle with verdicts about two disjoint candidates — a verdict about candidate B must NEVER reach candidate A's hypothesis prompt. Goes RED the moment per-verdict keying is detached: both verdicts then carry candidate A's key, score identically, mint an identical id, and the stable sort hands back whichever the bundle links first — which the fixture deliberately makes the B verdict. - Test B (FALLBACK): a pre-S3.2 seed with no structural frontmatter still keys on the bundle candidate and still retrieves — backward compatibility, stated as a test rather than assumed. - Test C (DISTINCT IDENTITY): the two verdicts mint DIFFERENT ids. Ids hash the structural features, so a shared id would silently collapse the two candidates in ``VerdictStore.add`` (first-write-wins) even where retrieval separated them. """ from __future__ import annotations import shutil from importlib.resources import files from pathlib import Path import pytest from portfolio_optimiser.verdicts import ( ExpeLContextProvider, VerdictFrontmatterError, bundle_candidate_features, seed_store_from_bundle, ) _MULTI_BUNDLE = str(files("portfolio_optimiser").joinpath("data/bundles/multi-kandidat-mikro")) _SINGLE_BUNDLE = str(files("portfolio_optimiser").joinpath("data/bundles/bygg-energi-mikro-a")) # Realization markers unique to each verdict file's frontmatter; they reach a prompt only through the # gated ExpeL fold, so their presence/absence in the few-shot IS the retrieval outcome. _MARKER_A = "realiseringsgrad=0.80" _MARKER_B = "realiseringsgrad=0.31" def _fewshot_for_bundle_candidate(bundle_dir: str, *, k: int = 1) -> str: """The ExpeL few-shot block Step 1 folds into the hypothesis prompt, for the bundle's OWN candidate — the exact string an agent would read.""" store = seed_store_from_bundle(bundle_dir) query = bundle_candidate_features(bundle_dir) return ExpeLContextProvider(store, query, k=k).format_fewshot() def test_verdict_about_another_candidate_never_reaches_this_candidates_prompt() -> None: """Test A — the RED point. Candidate A's hypothesis prompt carries A's verdict and NOT B's.""" fewshot = _fewshot_for_bundle_candidate(_MULTI_BUNDLE) assert _MARKER_A in fewshot, ( "candidate A's own verdict must reach A's hypothesis prompt; got:\n" + fewshot ) assert _MARKER_B not in fewshot, ( "a verdict about candidate B (different cost code, measure and magnitude) reached " "candidate A's hypothesis prompt — the verdicts are keyed on the bundle's single IR " "projection instead of on their own candidate:\n" + fewshot ) def test_verdict_without_structural_frontmatter_still_keys_on_the_bundle_candidate() -> None: """Test B — fallback. A pre-S3.2 seed (no ``affected_codes``/``measure_type``/ ``claimed_saving_nok`` in frontmatter) keys on the bundle candidate exactly as before, so it still scores a perfect structural match and still reaches the prompt.""" store = seed_store_from_bundle(_SINGLE_BUNDLE) query = bundle_candidate_features(_SINGLE_BUNDLE) assert store.verdicts, "the single-candidate fixture must still seed at least one verdict" assert all(v.proposal_features == query for v in store.verdicts), ( "verdicts with no structural frontmatter must fall back to the bundle candidate key" ) assert "realiseringsgrad=0.80" in ExpeLContextProvider(store, query, k=1).format_fewshot() def test_the_two_candidates_verdicts_mint_distinct_ids() -> None: """Test C — distinct identity. Ids hash the structural features, so per-verdict keying must also give the two verdicts different ids; a shared id would collapse them in ``VerdictStore.add`` (first-write-wins) even where retrieval kept them apart.""" verdicts = seed_store_from_bundle(_MULTI_BUNDLE).verdicts assert len(verdicts) == 2, f"expected both candidates' verdicts, got {len(verdicts)}" assert len({v.id for v in verdicts}) == 2, ( f"two disjoint candidates minted the same verdict id: {[v.id for v in verdicts]}" ) # --- Test D: a half-declared key is refused, never silently merged ------------------------------- def _bundle_with_patched_verdict(tmp_path: Path, old: str, new: str) -> str: """A throwaway copy of the multi-candidate bundle with one line of candidate B's verdict frontmatter rewritten — the packaged fixture is never mutated.""" dst = tmp_path / "bundle" shutil.copytree(_MULTI_BUNDLE, dst) target = dst / "verdict-b-asfalt.md" text = target.read_text(encoding="utf-8") assert old in text, f"fixture drift: {old!r} not in verdict-b-asfalt.md" target.write_text(text.replace(old, new, 1), encoding="utf-8") return str(dst) @pytest.mark.parametrize( ("old", "new", "why"), [ ("measure_type: ", "measure_type_disabled: ", "two of the three fields declared"), ("claimed_saving_nok: 900000", "claimed_saving_nok: nokså mye", "unparseable magnitude"), ("affected_codes: [05.2]", "affected_codes: []", "declared but empty code set"), ], ) def test_a_partial_or_unparseable_structural_key_is_refused(tmp_path, old, new, why) -> None: """Test D — fail-fast. A verdict file that declares its structural key PARTIALLY or unparseably raises rather than falling back to the bundle candidate. The silent alternative is not neutral: it keys the verdict to the WRONG candidate, which is the exact defect S3.2 closes — and a merge of some declared fields with some bundle fields mints a key belonging to NEITHER candidate. The curated context layer is validated, never repaired (mirroring ``write_concept_file``); the tolerant-skip rule belongs to the RAW inbox layer, where anyone may drop anything. RED if the refusal is relaxed into a fallback.""" bundle_dir = _bundle_with_patched_verdict(tmp_path, old, new) with pytest.raises(VerdictFrontmatterError): seed_store_from_bundle(bundle_dir)