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>
213 lines
9.6 KiB
Python
213 lines
9.6 KiB
Python
"""Step 2 load-bearing seam (SC2 + brief §4.1, målbilde §2/§6): a run's ``dimension`` does TWO
|
|
things, each with a named detach point:
|
|
|
|
- **Candidate constraint (§4.1b):** a validator-VALID candidate (P90-valid, empty assumptions)
|
|
whose ``measure_type`` falls OUTSIDE the run's dimension is rejected WHEN a dimension is set.
|
|
The proposal validates on the numbers, so the ONLY possible rejecter is the ``admits`` scope
|
|
gate (closes the green-but-dead trap). RED if ``admits`` is removed ⇒ the foreign candidate
|
|
slips through. Control: an in-dimension candidate passes.
|
|
- **Context scope (§4.1a), as it stands after S2c:** the promise is unchanged — a dimension-scoped
|
|
run lets the agents read ONLY dimension-matched bundle knowledge — but the debate no longer
|
|
RECEIVES a rendered context, it NAVIGATES the base, so the filter had to move from
|
|
``bundle_context`` to the tools the debate is handed. These two arms gate the FIRST rung: the
|
|
listing the debate can see never names a foreign-dimension document. RED if the ``dimension=``
|
|
arg is dropped anywhere along ``run_project`` → ``navigator_tools`` → ``directory_listing``.
|
|
Control: ``dimension=None`` ⇒ the same document IS listed. The SECOND rung (``read_file``
|
|
refusing a foreign document by path) is gated in
|
|
``tests/test_debate_navigation_cost_loadbearing.py`` — a listing filter with an ungated reader
|
|
behind it is a filter in name only, so the two halves get their own arms and their own
|
|
mutations.
|
|
|
|
Patterns: ``test_checker_gate_loadbearing.py:59/87`` (gate + causality control),
|
|
``conftest.py:184`` (recording client), ``test_step8_promotion_loadbearing.py:51`` (bundle copy).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from agent_framework import BaseChatClient
|
|
from conftest import SyntheticUsageChatClient
|
|
|
|
from portfolio_optimiser import okf
|
|
from portfolio_optimiser.dimension import Dimension
|
|
from portfolio_optimiser.run import run_project
|
|
from portfolio_optimiser.validator import Rejection, ValidatedProposal
|
|
|
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
|
|
|
_ENERGY_DIM = Dimension(
|
|
id="energi", label="Energi", allowed_measure_types=frozenset({"energy_efficiency"})
|
|
)
|
|
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
|
|
|
|
# A marker that appears ONLY in the lisens-marked concept file's body, so it can reach the prompt
|
|
# solely through un-filtered context — its presence/absence is the §4.1a leak probe.
|
|
_LISENS_SENTINEL = "LISENS-LEAK-SENTINEL-x7y8z9"
|
|
|
|
|
|
def _valid_reply(measure: str, code: str) -> str:
|
|
"""A validator-VALID proposal: affected total 300000, degenerate P90 = 0.30 x 300000 = 90000
|
|
>= claimed 30000, empty assumptions -> validates on the numbers regardless of ``measure``/``code``
|
|
(so a rejection can only come from the dimension scope gate)."""
|
|
return (
|
|
'{"measure":"' + measure + '","affected_items":'
|
|
'[{"code":"' + code + '","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
|
|
)
|
|
|
|
|
|
def _role_factory(proposer_reply: str, checker_reply: str) -> Callable[[str], BaseChatClient]:
|
|
def factory(role: str) -> BaseChatClient:
|
|
return SyntheticUsageChatClient(
|
|
default_reply=checker_reply if role == "checker" else proposer_reply
|
|
)
|
|
|
|
return factory
|
|
|
|
|
|
# --- Candidate constraint (§4.1b) ----------------------------------------------------------------
|
|
|
|
|
|
async def test_foreign_dimension_candidate_rejected_when_dimension_set() -> None:
|
|
"""LOAD-BEARING: a foreign-dimension candidate that validates on the numbers is rejected by the
|
|
scope gate. RED if ``admits`` is detached (the foreign candidate slips through as validated).
|
|
|
|
The cost code is one the delivered base NAMES (P7). It used to be ``SENTINEL-FOREIGN``, which
|
|
the base does not carry — so once identifier grounding existed the candidate fell for a reason
|
|
that has nothing to do with the dimension, and this arm would have ridden on that instead of on
|
|
``admits``. Only the MEASURE is foreign now, which is what §4.1b is about."""
|
|
factory = _role_factory(
|
|
_valid_reply("licence_renegotiation", "ENERGI-TOTAL-EL"), "VERDICT: APPROVE"
|
|
)
|
|
|
|
result = await run_project(
|
|
"BYGG-KONTOR-NORD",
|
|
"local",
|
|
docs_dir=str(BUNDLE_DIR),
|
|
bundle_dir=str(BUNDLE_DIR),
|
|
verdict_input=_VERDICT_INPUT,
|
|
dimension=_ENERGY_DIM,
|
|
client_factory=factory,
|
|
)
|
|
|
|
assert isinstance(result.outcome, Rejection), (
|
|
"a foreign-dimension candidate slipped through — the admits scope gate is not gating"
|
|
)
|
|
assert "outside dimension" in result.outcome.reason
|
|
# Provenance honesty: the VALIDATOR passed (the numbers are feasible); only the scope gate
|
|
# rejected. validator_decision reflects the numbers ONLY — never the scope gate.
|
|
assert result.provenance.validator_decision == "validated"
|
|
|
|
|
|
async def test_in_dimension_candidate_passes() -> None:
|
|
"""CAUSALITY CONTROL: the SAME shape with an in-dimension ``measure_type`` validates normally —
|
|
proving the rejection above is caused by the dimension scope, not the fixture."""
|
|
factory = _role_factory(
|
|
_valid_reply("energy_efficiency", "ENERGI-TOTAL-EL"), "VERDICT: APPROVE"
|
|
)
|
|
|
|
result = await run_project(
|
|
"BYGG-KONTOR-NORD",
|
|
"local",
|
|
docs_dir=str(BUNDLE_DIR),
|
|
bundle_dir=str(BUNDLE_DIR),
|
|
verdict_input=_VERDICT_INPUT,
|
|
dimension=_ENERGY_DIM,
|
|
client_factory=factory,
|
|
)
|
|
|
|
assert isinstance(result.outcome, ValidatedProposal)
|
|
|
|
|
|
# --- Context scope (§4.1a) -----------------------------------------------------------------------
|
|
|
|
|
|
def _bundle_with_lisens_file(tmp_path: Path) -> str:
|
|
"""A throwaway copy of the shared bundle with one extra lisens-marked concept file carrying the
|
|
sentinel in its body, linked from the index so ``navigate_bundle`` reaches it. The shared,
|
|
framework-neutral fixture is never mutated (mirrors ``_copy_bundle``)."""
|
|
dst = tmp_path / "bundle"
|
|
shutil.copytree(BUNDLE_DIR, dst)
|
|
(dst / "lisens-note.md").write_text(
|
|
f"---\ntype: methodology\ndimension: lisens\n---\n\n{_LISENS_SENTINEL} — licence method note\n",
|
|
encoding="utf-8",
|
|
)
|
|
index = dst / "index.md"
|
|
index.write_text(
|
|
index.read_text(encoding="utf-8") + "\n- [lisens](lisens-note.md)\n", encoding="utf-8"
|
|
)
|
|
return str(dst)
|
|
|
|
|
|
async def _listing_the_debate_can_see(bundle_dir: str, dimension: Dimension | None) -> str:
|
|
"""Run the bundle path, capture the tools the debate was built with, and return what its
|
|
``read_bundle`` rung answers — the S2c successor to reading the prompt text.
|
|
|
|
The tool is invoked DIRECTLY afterwards rather than through an agent turn, because a
|
|
``ScriptedChatClient`` returns text and never emits a ``function_call``: a test that only drove
|
|
the run would leave the entire tool surface outside the gate (``test_explore_loadbearing``'s
|
|
own measured correction)."""
|
|
import portfolio_optimiser.run as run_module
|
|
|
|
captured: list[list[object]] = []
|
|
original = run_module.fresh_workflow
|
|
|
|
def spy(*args: object, **kwargs: object) -> object:
|
|
captured.append(list(kwargs.get("tools") or [])) # type: ignore[arg-type]
|
|
return original(*args, **kwargs) # type: ignore[arg-type]
|
|
|
|
def factory(role: str) -> BaseChatClient:
|
|
return SyntheticUsageChatClient(
|
|
default_reply=_valid_reply("energy_efficiency", "ENERGI-TOTAL-EL")
|
|
)
|
|
|
|
monkeypatch = pytest.MonkeyPatch()
|
|
try:
|
|
monkeypatch.setattr(run_module, "fresh_workflow", spy)
|
|
await run_project(
|
|
"BYGG-KONTOR-NORD",
|
|
"local",
|
|
docs_dir=bundle_dir,
|
|
bundle_dir=bundle_dir,
|
|
verdict_input=_VERDICT_INPUT,
|
|
dimension=dimension,
|
|
client_factory=factory,
|
|
)
|
|
finally:
|
|
monkeypatch.undo()
|
|
|
|
assert captured, "the debate was never built"
|
|
read_bundle = next(t for t in captured[0] if getattr(t, "name", "") == "read_bundle")
|
|
bundle_id = okf.reconcile_bundle_id(bundle_dir).id
|
|
answer = await read_bundle.invoke(arguments={"bundle_id": bundle_id})
|
|
return "".join(getattr(c, "text", "") or "" for c in answer)
|
|
|
|
|
|
async def test_dimension_scopes_the_agent_context(tmp_path) -> None:
|
|
"""LOAD-BEARING (§4.1a, first rung): under a dimension the listing the debate can see does NOT
|
|
name the foreign-dimension document. RED if ``dimension=`` is dropped anywhere between
|
|
``run_project`` and ``directory_listing``.
|
|
|
|
Asserts on the FILE NAME rather than the body sentinel, because a listing carries names and
|
|
sizes, never bodies — an assert on the sentinel would be green against every implementation
|
|
and would prove nothing (the vacuous form this repo keeps measuring)."""
|
|
bundle_dir = _bundle_with_lisens_file(tmp_path)
|
|
|
|
scoped = await _listing_the_debate_can_see(bundle_dir, _ENERGY_DIM)
|
|
assert "lisens-note.md" not in scoped, (
|
|
"another dimension's document is listed to the agents — the tools are not dimension-scoped"
|
|
)
|
|
|
|
|
|
async def test_no_dimension_leaves_context_unscoped(tmp_path) -> None:
|
|
"""CAUSALITY CONTROL: with ``dimension=None`` the lisens document IS listed — proving its
|
|
absence above is caused by the dimension scope, not by the file being unreachable."""
|
|
bundle_dir = _bundle_with_lisens_file(tmp_path)
|
|
|
|
unscoped = await _listing_the_debate_can_see(bundle_dir, None)
|
|
assert "lisens-note.md" in unscoped, (
|
|
"the lisens file is unreachable even without a filter — the control does not prove causality"
|
|
)
|