The context sets, the packaged knowledge bases and the example bundles are replaced by one fictitious example set about IT operations in an invented organisation: three context sets (serverrom-2027, driftsavtale-2027 and the two-base drift-og-avtale-2027), two synthetic knowledge bases under src/portfolio_optimiser/data/kunnskapsbaser and two example bundles under src/portfolio_optimiser/data/bundles. Numbers, codes and structural values in tests and fixtures are kept; names, ids and wording change. Dated measurement documents that only recorded runs on the replaced material are deleted. Gate figures measured on the new set are not comparable with earlier ones. The exclusion gate from the previous commit is green: 0 tracked files hit outside the shared/ subtree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
6.9 KiB
Python
137 lines
6.9 KiB
Python
"""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-lisens.md"
|
|
text = target.read_text(encoding="utf-8")
|
|
assert old in text, f"fixture drift: {old!r} not in verdict-b-lisens.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)
|