feat(prepass): the debate is handed the declared cut and the navigator tools are withdrawn [skip-docs]
[skip-docs]: CLI-flagget og README-blokka kommer i steg 6/7; `prepass_payload` er foreloepig bare naabar for en bibliotek-kaller. `run_project(prepass_payload=...)` forgrener bundle-armen. UTEN payload er hver linje uendret -- pekeren, de fire verktoeyene, siteringer over hele den navigerte basen. MED et payload faar debatten et DEKLARERT KUTT og verktoeyene trekkes (SS 2.2: kontekst pre-passet holdt tilbake ble holdt tilbake med vilje; en debatt som holder BEGGE er fri til aa gaa rundt kuttet den nettopp erklaerte). Nekten PROPAGERER, aldri en stille degradering tilbake til pekeren -- `load_mandate`s regel. Maalt paa NULL modellkall, ikke paa exit-koden. `delivered == 0` nektes ved NAVN foer debatten, med nevnerne, spoersmaalet og ref-en sitert: maalt er den tilstanden bare naabar naar hvert konsept feilet leksikalsk (den andre tomme saken nekter produsenten selv), altsaa bevis for FRAVAER. Uten den falt kjoeringen gjennom til `run.py`s siteringsvakt, hvis melding navngir `docs_dir` -- som er `None` paa denne stien. `bundle_excerpt_citations` (datasource) siterer de LEVERTE konseptene alene: et stempel som siterer hele korpuset for et forslag som saa fire, gjenoppfinner den uerklaerte paastanden sømmen finnes for. Kroppen tas fra den NAVIGERTE `BundleFile`, ikke fra payloadets `text` -- den leverte teksten er NFC-normalisert med strippet hale, saa en locator over den ville ikke indeksert fila den navngir. Deler dermed ogsaa `bundle_citations`' verdict-eksklusjon i stedet for aa gjenta den. MCP-appenden ligger BEVISST under forgreningen: dette trekker navigatoerverktoeyene, ikke verktoeylista. Maalt: en tom liste naar traaden som `tools: None`, saa ingen uproevd tom-array-form innfoeres. `RunResult.prepass` og `DryRunReport.prepass` DEFAULTER (`skipped_links`-halvdelen: `None` er det sanne utsagnet "ingen payload ble gitt"), bundet i BEGGE grener saa ingen `NameError` venter paa veg-stien. `ProvenanceStamp` er BEVISST urørt -- stempelet beskriver gaten som doemte EN kandidat, dette er et RUN-nivaa-faktum om hva kjoeringen i det hele tatt fikk lese. Tilbaketrekkingen asserteres paa `fresh_workflow(tools=...)`, ALDRI paa `debate_tool_calls`: maalt er det sporet allerede tomt MED alle fire verktoeyene, fordi en `ScriptedChatClient` aldri emitterer et verktoeykall. En arm skrevet paa det kan ikke skille de to implementasjonene. GATE, IKKE VEGG: en egen arm beviser at den gatede ExpeL-folden fortsatt naar hypotese-prompten (0.82) under et payload. 1440 passed / 5 skipped (fra 1425/5, +15, 0 fjernet). ruff + mypy rene. Golden `shasum -a 1` av INNHOLDET = ea8c534773acdbe41ae68f2c55724d69aaf8be4f, BYTE-UENDRET. Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
parent
651c83f9df
commit
7aa06d581f
3 changed files with 513 additions and 7 deletions
|
|
@ -13,6 +13,7 @@ byte-identical whether the agents reach it in-process or over stdio.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from agent_framework import FunctionTool, tool
|
from agent_framework import FunctionTool, tool
|
||||||
|
|
@ -52,6 +53,44 @@ def bundle_citations(bundle: Bundle) -> list[Citation]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_excerpt_citations(bundle: Bundle, concept_ids: Sequence[str]) -> list[Citation]:
|
||||||
|
"""Citations for exactly the concepts a pre-pass DELIVERED (order 20260907T080223Z).
|
||||||
|
|
||||||
|
``bundle_citations`` above cites the whole navigated base. Under a declared cut that would
|
||||||
|
have the run's stamp claim every concept for a proposal that saw a handful — the same
|
||||||
|
undeclared claim the pre-pass seam exists to remove, one artefact over.
|
||||||
|
|
||||||
|
The body comes from the NAVIGATED ``BundleFile``, never from the payload's ``text``: the
|
||||||
|
delivered text is NFC-normalised with trailing whitespace stripped, so a locator over it would
|
||||||
|
not index the file it names. Reusing ``context_files`` also means this shares
|
||||||
|
``bundle_citations``' verdict-layer exclusion rather than restating it.
|
||||||
|
|
||||||
|
An id the navigation did not reach raises: ``prepass.verify_against_bundle`` has already
|
||||||
|
resolved every one of them on disk, so a miss here means the two disagree about the base, and
|
||||||
|
a citation list that silently drops entries would under-report the very denominator this seam
|
||||||
|
publishes.
|
||||||
|
"""
|
||||||
|
by_name = {f.name: f for f in bundle.context_files}
|
||||||
|
citations: list[Citation] = []
|
||||||
|
for concept_id in concept_ids:
|
||||||
|
name = concept_id + ".md"
|
||||||
|
try:
|
||||||
|
file = by_name[name]
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(
|
||||||
|
f"the pre-pass delivered {concept_id!r}, which this bundle's navigation does not "
|
||||||
|
"reach; the payload and the navigated base disagree"
|
||||||
|
) from error
|
||||||
|
citations.append(
|
||||||
|
Citation(
|
||||||
|
file=name,
|
||||||
|
locator=TextSpan(start_index=0, end_index=len(file.body)),
|
||||||
|
snippet=file.body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return citations
|
||||||
|
|
||||||
|
|
||||||
def chunk_dict_to_citation(d: dict[str, Any]) -> Citation:
|
def chunk_dict_to_citation(d: dict[str, Any]) -> Citation:
|
||||||
"""Map a structuredContent chunk dict into a first-class ``provenance.Citation``."""
|
"""Map a structuredContent chunk dict into a first-class ``provenance.Citation``."""
|
||||||
loc = d["locator"]
|
loc = d["locator"]
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ from portfolio_optimiser.contracts import GoalConfig, GoalContract, load_contrac
|
||||||
from portfolio_optimiser.ledger import SavingsLedger, to_ore
|
from portfolio_optimiser.ledger import SavingsLedger, to_ore
|
||||||
from portfolio_optimiser.datasource import (
|
from portfolio_optimiser.datasource import (
|
||||||
bundle_citations,
|
bundle_citations,
|
||||||
|
bundle_excerpt_citations,
|
||||||
chunk_dict_to_citation,
|
chunk_dict_to_citation,
|
||||||
make_retrieval_tool,
|
make_retrieval_tool,
|
||||||
retrieve_chunks,
|
retrieve_chunks,
|
||||||
|
|
@ -118,7 +119,7 @@ from portfolio_optimiser.validator import (
|
||||||
baseline_from_project,
|
baseline_from_project,
|
||||||
validate_proposal,
|
validate_proposal,
|
||||||
)
|
)
|
||||||
from portfolio_optimiser import hitl, okf, outbox
|
from portfolio_optimiser import hitl, okf, outbox, prepass
|
||||||
from portfolio_optimiser.semretrieval import (
|
from portfolio_optimiser.semretrieval import (
|
||||||
SEMANTIC_WEIGHT_DEFAULT,
|
SEMANTIC_WEIGHT_DEFAULT,
|
||||||
Embedder,
|
Embedder,
|
||||||
|
|
@ -234,6 +235,17 @@ class RunResult:
|
||||||
#: Carried HERE and on neither other carrier: ``ProvenanceStamp`` describes the gate that
|
#: Carried HERE and on neither other carrier: ``ProvenanceStamp`` describes the gate that
|
||||||
#: judged ONE candidate, and ``DryRunReport`` returns above generation entirely.
|
#: judged ONE candidate, and ``DryRunReport`` returns above generation entirely.
|
||||||
expert_revisions: tuple[ProposalReview, ...] = ()
|
expert_revisions: tuple[ProposalReview, ...] = ()
|
||||||
|
#: The cut this run was GIVEN, when it was given one (order 20260907T080223Z): the base's ref,
|
||||||
|
#: the question it was computed for, the three denominators and the withheld rules by count.
|
||||||
|
#:
|
||||||
|
#: DEFAULTED, unlike ``cost_baseline_anchored``, and the difference is the one that row states:
|
||||||
|
#: both of that field's possible defaults would assert something about an event, whereas
|
||||||
|
#: ``None`` here is the true statement "no payload was supplied" — and there is exactly one way
|
||||||
|
#: to supply one. This is ``skipped_links``' half of the rule.
|
||||||
|
#:
|
||||||
|
#: Carried HERE and not on ``ProvenanceStamp``: the stamp describes the gate that judged ONE
|
||||||
|
#: candidate, while this is a RUN-level fact about what the run was allowed to read at all.
|
||||||
|
prepass: prepass.PrepassDeclaration | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def verdict_key(self) -> str:
|
def verdict_key(self) -> str:
|
||||||
|
|
@ -294,6 +306,11 @@ class DryRunReport:
|
||||||
#: (and both claims would sometimes be false), while a missing trace asserts only that the event
|
#: (and both claims would sometimes be false), while a missing trace asserts only that the event
|
||||||
#: list is empty. The road path navigates no bundle, so empty is literally true there too.
|
#: list is empty. The road path navigates no bundle, so empty is literally true there too.
|
||||||
skipped_links: tuple[okf.SkippedLink, ...] = ()
|
skipped_links: tuple[okf.SkippedLink, ...] = ()
|
||||||
|
#: The cut a dry run was given, when it was given one. Carried here for ``skipped_links``'
|
||||||
|
#: reason and NOT for ``unkeyed_verdicts``' one: the dry-run cut returns BELOW the fork that
|
||||||
|
#: resolves a payload, so a dry run can honestly report what it would have read — whereas a
|
||||||
|
#: field resolved above that cut could only ever report zero.
|
||||||
|
prepass: prepass.PrepassDeclaration | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -889,6 +906,14 @@ async def run_project(
|
||||||
#: every commissioned approach, and a ``revise`` buys ONE more attempt out of the budget the
|
#: every commissioned approach, and a ``revise`` buys ONE more attempt out of the budget the
|
||||||
#: loop already has. It mints no verdict and gates nothing (F2).
|
#: loop already has. It mints no verdict and gates nothing (F2).
|
||||||
proposal_reviewer: ProposalReviewer | None = None,
|
proposal_reviewer: ProposalReviewer | None = None,
|
||||||
|
#: A verified OKF consumption pre-pass payload (order 20260907T080223Z). Bundle path only, and
|
||||||
|
#: OPT-IN by construction: ``None`` (the default) leaves the debate on ``_bundle_pointer``'s
|
||||||
|
#: pointer and the four navigator tools, byte-identically. Given one, the debate is handed a
|
||||||
|
#: DECLARED CUT and the tools are withdrawn — a debate holding both would be free to walk
|
||||||
|
#: around the cut it just declared, which is the undeclared cut in a costume (contract SS 2.2).
|
||||||
|
#: A LOADED object, never a path: the library seam takes the validated artefact and the CLI
|
||||||
|
#: owns the file, exactly as ``mandate=`` and ``dimension=`` already do.
|
||||||
|
prepass_payload: prepass.PrepassPayload | None = None,
|
||||||
) -> RunResult | DryRunReport:
|
) -> RunResult | DryRunReport:
|
||||||
"""Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam
|
"""Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam
|
||||||
(defaults to the real backend). ``verdict_input`` carries the expert decision/rationale
|
(defaults to the real backend). ``verdict_input`` carries the expert decision/rationale
|
||||||
|
|
@ -957,6 +982,14 @@ async def run_project(
|
||||||
# * bundle path: anchored only when the bundle SHIPS a ``cost-baseline.json``. A bundle written
|
# * bundle path: anchored only when the bundle SHIPS a ``cost-baseline.json``. A bundle written
|
||||||
# before the amendment (every commons-owned golden) is legitimately un-anchored -> None =
|
# before the amendment (every commons-owned golden) is legitimately un-anchored -> None =
|
||||||
# pre-S4.0 behaviour. A baseline that exists but is malformed still raises (fail-closed).
|
# pre-S4.0 behaviour. A baseline that exists but is malformed still raises (fail-closed).
|
||||||
|
if prepass_payload is not None and bundle_dir is None:
|
||||||
|
# Hoisted above the arm below, and refused rather than ignored: a payload is a cut OF a
|
||||||
|
# knowledge base, and the road path has none for it to agree with. A silently dropped
|
||||||
|
# payload would leave the caller with a navigating run that reported a declared one.
|
||||||
|
raise prepass.PrepassRefused(
|
||||||
|
"a pre-pass payload declares a cut of a knowledge base, so it needs the bundle it "
|
||||||
|
"was cut from; this run was given no bundle_dir"
|
||||||
|
)
|
||||||
if bundle_dir is not None:
|
if bundle_dir is not None:
|
||||||
bundle = okf.navigate_bundle(bundle_dir)
|
bundle = okf.navigate_bundle(bundle_dir)
|
||||||
# ONE bundle-id rule (Step 10, slackened S7a-3 pkt. 1): the DECLARED id is the identity and
|
# ONE bundle-id rule (Step 10, slackened S7a-3 pkt. 1): the DECLARED id is the identity and
|
||||||
|
|
@ -985,15 +1018,63 @@ async def run_project(
|
||||||
# tools the exploration uses; ``gen_context = debate_output or context`` below means the
|
# tools the exploration uses; ``gen_context = debate_output or context`` below means the
|
||||||
# generation fallback is bounded by the same change rather than by a second policy.
|
# generation fallback is bounded by the same change rather than by a second policy.
|
||||||
dimension_id = dimension.id if dimension else None
|
dimension_id = dimension.id if dimension else None
|
||||||
context = _bundle_pointer(bundle, resolved.id, dimension=dimension_id)
|
# The pre-pass fork (order 20260907T080223Z). WITHOUT a payload every line below is what
|
||||||
citations = bundle_citations(bundle)
|
# it was: the pointer, the four tools, and citations over the whole navigated base.
|
||||||
|
#
|
||||||
|
# WITH one, the debate is handed a DECLARED CUT and the tools are withdrawn. The refusal
|
||||||
|
# PROPAGATES — never a silent degrade back to the pointer, which is ``load_mandate``'s
|
||||||
|
# rule: a caller who asked for a declared cut and got a navigating run instead was
|
||||||
|
# answered by a silently downgraded order.
|
||||||
|
prepass_declaration: prepass.PrepassDeclaration | None = None
|
||||||
|
debate_tools: list[Any]
|
||||||
|
if prepass_payload is not None:
|
||||||
|
prepass.check_payload_shape(prepass_payload)
|
||||||
|
prepass.verify_against_bundle(
|
||||||
|
prepass_payload,
|
||||||
|
bundle_dir=bundle_dir,
|
||||||
|
resolved_id=resolved,
|
||||||
|
dimension=dimension_id,
|
||||||
|
)
|
||||||
|
if not prepass_payload.excerpts:
|
||||||
|
# Measured: ``delivered == 0`` is reachable only when every concept failed to
|
||||||
|
# match (the producer REFUSES the other empty case, where concepts matched and the
|
||||||
|
# budget admitted none). So this is evidence of ABSENCE for this question at this
|
||||||
|
# ref, and saying it is better than two silent alternatives: an empty prompt, or
|
||||||
|
# falling through to the citation guard below, whose message names ``docs_dir`` —
|
||||||
|
# ``None`` on this path. SS 7.3's own posture: the skill stops and says so.
|
||||||
|
raise prepass.PrepassRefused(
|
||||||
|
f"the pre-pass delivered 0 of {prepass_payload.denominators.considered} "
|
||||||
|
f"concepts for the question {prepass_payload.question!r} at ref "
|
||||||
|
f"{prepass_payload.bundle.ref}; an empty cut is evidence that this knowledge "
|
||||||
|
"base does not answer that question, not something to run a debate over"
|
||||||
|
)
|
||||||
|
prepass_declaration = prepass.declaration_of(prepass_payload)
|
||||||
|
context = prepass.render_context(prepass_payload)
|
||||||
|
# Citations over the DELIVERED concepts alone: a stamp citing the whole corpus for a
|
||||||
|
# proposal that saw eight documents re-creates the undeclared claim this seam removes.
|
||||||
|
# Built from the MOUNTED bodies in ``bundle_citations``' own shape, so ``snippet ==
|
||||||
|
# body[start:end]`` stays exact by construction rather than indexing normalised text.
|
||||||
|
citations = bundle_excerpt_citations(
|
||||||
|
bundle, [excerpt.concept_id for excerpt in prepass_payload.excerpts]
|
||||||
|
)
|
||||||
|
# Contract SS 2.2: "Context the pre-pass withheld was withheld deliberately." A debate
|
||||||
|
# holding both the payload and the ladder could walk around the cut it just declared.
|
||||||
|
# Measured: an empty list reaches the wire as ``tools: None``, so no untested empty-
|
||||||
|
# array form is introduced. The MCP append BELOW this fork is deliberately untouched —
|
||||||
|
# this withdraws the navigator tools, not the tool list.
|
||||||
|
debate_tools = []
|
||||||
|
else:
|
||||||
|
context = _bundle_pointer(bundle, resolved.id, dimension=dimension_id)
|
||||||
|
citations = bundle_citations(bundle)
|
||||||
|
# §4.1a context-scope, carried over: the agents read ONLY dimension-matched knowledge.
|
||||||
|
# The filter used to live in the rendering; with navigation it lives in the TOOLS, on
|
||||||
|
# both rungs (``navigator_tools``' own gate), because that is now where the bytes
|
||||||
|
# leave. Under a payload the SAME two gates are re-raised by
|
||||||
|
# ``prepass.verify_against_bundle`` on the mounted documents instead.
|
||||||
|
debate_tools = list(navigator_tools([bundle_dir], dimension=dimension_id))
|
||||||
# What the navigation could NOT reach, taken from the run's ONE walk. The road path below
|
# What the navigation could NOT reach, taken from the run's ONE walk. The road path below
|
||||||
# navigates no bundle at all, so its empty tuple is literally true rather than a stand-in.
|
# navigates no bundle at all, so its empty tuple is literally true rather than a stand-in.
|
||||||
skipped_links: tuple[okf.SkippedLink, ...] = bundle.skipped
|
skipped_links: tuple[okf.SkippedLink, ...] = bundle.skipped
|
||||||
# §4.1a context-scope, carried over: the agents read ONLY dimension-matched knowledge. The
|
|
||||||
# filter used to live in the rendering; with navigation it lives in the TOOLS, on both
|
|
||||||
# rungs (``navigator_tools``' own gate), because that is now where the bytes leave.
|
|
||||||
debate_tools: list[Any] = list(navigator_tools([bundle_dir], dimension=dimension_id))
|
|
||||||
else:
|
else:
|
||||||
project = _project_by_id(project_id)
|
project = _project_by_id(project_id)
|
||||||
baseline = baseline_from_project(project)
|
baseline = baseline_from_project(project)
|
||||||
|
|
@ -1003,6 +1084,10 @@ async def run_project(
|
||||||
skipped_links = ()
|
skipped_links = ()
|
||||||
# No knowledge base, so no bundle identity — said by ABSENCE rather than by minting one.
|
# No knowledge base, so no bundle identity — said by ABSENCE rather than by minting one.
|
||||||
resolved_bundle_id = None
|
resolved_bundle_id = None
|
||||||
|
# Bound in BOTH branches for ``resolved_bundle_id``'s reason: the ``DryRunReport`` and the
|
||||||
|
# ``RunResult`` below read it unconditionally, and a name bound in one arm only is a
|
||||||
|
# ``NameError`` waiting for the other caller.
|
||||||
|
prepass_declaration = None
|
||||||
debate_tools = [make_retrieval_tool(docs_dir, top_k=top_k)]
|
debate_tools = [make_retrieval_tool(docs_dir, top_k=top_k)]
|
||||||
|
|
||||||
# Trekk B2 (krav 3): configured MCP servers become tools the AGENTS can call during the debate.
|
# Trekk B2 (krav 3): configured MCP servers become tools the AGENTS can call during the debate.
|
||||||
|
|
@ -1077,6 +1162,7 @@ async def run_project(
|
||||||
cost_baseline_anchored=baseline is not None,
|
cost_baseline_anchored=baseline is not None,
|
||||||
bundle_id_source=resolved_bundle_id,
|
bundle_id_source=resolved_bundle_id,
|
||||||
skipped_links=skipped_links,
|
skipped_links=skipped_links,
|
||||||
|
prepass=prepass_declaration,
|
||||||
)
|
)
|
||||||
# The MCP lifecycle (Trekk B2): entered HERE, after the dry-run cut above, so a dry run never
|
# The MCP lifecycle (Trekk B2): entered HERE, after the dry-run cut above, so a dry run never
|
||||||
# opens a connection — its promise to stop before the first call covers egress too. Constructed
|
# opens a connection — its promise to stop before the first call covers egress too. Constructed
|
||||||
|
|
@ -1407,6 +1493,7 @@ async def run_project(
|
||||||
unkeyed_verdicts=unkeyed_verdicts,
|
unkeyed_verdicts=unkeyed_verdicts,
|
||||||
debate_tool_calls=tuple(debate_tool_calls),
|
debate_tool_calls=tuple(debate_tool_calls),
|
||||||
expert_revisions=tuple(expert_reviews),
|
expert_revisions=tuple(expert_reviews),
|
||||||
|
prepass=prepass_declaration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
380
tests/test_prepass_run_seam_loadbearing.py
Normal file
380
tests/test_prepass_run_seam_loadbearing.py
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
"""Load-bearing gate for the pre-pass seam inside ``run_project`` (order 20260907T080223Z).
|
||||||
|
|
||||||
|
Given a verified payload, the bundle arm hands the debate a DECLARED CUT instead of
|
||||||
|
``_bundle_pointer``'s pointer, and WITHDRAWS the four navigator tools. Without one, every byte of
|
||||||
|
today's behaviour stands — which is the control every arm here is paired against.
|
||||||
|
|
||||||
|
**The withdrawal is asserted on the tool list passed to ``fresh_workflow``, never on
|
||||||
|
``RunResult.debate_tool_calls``.** Measured: that trace is ALREADY empty with all four tools
|
||||||
|
attached, because a ``ScriptedChatClient`` returns text and never emits a function call. An arm
|
||||||
|
written on it cannot distinguish the two implementations at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import portfolio_optimiser.run as run_module
|
||||||
|
from portfolio_optimiser import okf, prepass
|
||||||
|
from portfolio_optimiser.run import RunResult, run_project
|
||||||
|
from portfolio_optimiser.mcp_tools import McpServerConfig
|
||||||
|
from portfolio_optimiser.simulation import ScriptedChatClient
|
||||||
|
from portfolio_optimiser.verdicts import VerdictStore, seed_store_from_bundle
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "prepass" / "bygg-energi-mikro-fixture.payload.json"
|
||||||
|
SHIPPED_BASE = Path(__file__).parent.parent / "shared" / "examples" / "bygg-energi-mikro"
|
||||||
|
PROJECT_ID = "BYGG-KONTOR-NORD"
|
||||||
|
NAVIGATOR_TOOLS = {"list_bundles", "read_bundle", "read_dir", "read_file"}
|
||||||
|
|
||||||
|
_LADDER = "read it with your tools"
|
||||||
|
_EXCERPT_SENTINEL = "SENTINEL-I-ET-LEVERT-UTDRAG"
|
||||||
|
|
||||||
|
_PROPOSAL = json.dumps(
|
||||||
|
{
|
||||||
|
"project_id": PROJECT_ID,
|
||||||
|
"measure": "energy_efficiency",
|
||||||
|
"claimed_saving_nok": 30000,
|
||||||
|
"affected_items": [
|
||||||
|
{"code": "ENERGI-TOTAL-EL", "quantity": 120000.0, "unit_cost": 1.25},
|
||||||
|
],
|
||||||
|
"assumptions": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_blob(messages: Any) -> str:
|
||||||
|
"""Text PLUS function calls and results (the corrected S7a-2 instrument). ``.text`` alone
|
||||||
|
measures a context-bearing prompt at a few characters."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for message in messages:
|
||||||
|
for content in getattr(message, "contents", []) or []:
|
||||||
|
for attribute in ("text", "arguments", "result"):
|
||||||
|
value = getattr(content, attribute, None)
|
||||||
|
if value is not None:
|
||||||
|
parts.append(str(value))
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _recording_factory(sink: list[str]) -> Any:
|
||||||
|
"""A scripted client whose instance ``_inner_get_response`` is REBOUND to a recorder.
|
||||||
|
|
||||||
|
Rebinding rather than subclassing is deliberate: ``tests/test_scripted_client_consolidation``
|
||||||
|
keeps a registry of every site that DEFINES that method, and a new definition here would go
|
||||||
|
red there. This is the shape the two existing prompt gates use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def factory(role: str) -> Any:
|
||||||
|
client = ScriptedChatClient(_PROPOSAL, role=role)
|
||||||
|
original = client._inner_get_response
|
||||||
|
|
||||||
|
async def recording(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
messages = kwargs.get("messages") or (args[0] if args else [])
|
||||||
|
sink.append(_prompt_blob(messages))
|
||||||
|
return await original(*args, **kwargs)
|
||||||
|
|
||||||
|
client._inner_get_response = recording # type: ignore[method-assign]
|
||||||
|
return client
|
||||||
|
|
||||||
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
def _base(tmp_path: Path, *, sentinel: bool = False) -> tuple[str, prepass.PrepassPayload]:
|
||||||
|
"""A copy of the shipped base declaring its own id, with a matching payload.
|
||||||
|
|
||||||
|
The MOUNT differs from the DECLARATION (S7a-3's slack case), so the identity check cannot be
|
||||||
|
satisfied by a mount comparison.
|
||||||
|
"""
|
||||||
|
root = tmp_path / "mounted-under-another-name"
|
||||||
|
shutil.copytree(SHIPPED_BASE, root)
|
||||||
|
index = root / "index.md"
|
||||||
|
lines = index.read_text(encoding="utf-8").split("\n")
|
||||||
|
lines.insert(1, "bundle_id: bygg-energi-mikro-fixture")
|
||||||
|
index.write_text("\n".join(lines), encoding="utf-8")
|
||||||
|
|
||||||
|
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
if sentinel:
|
||||||
|
first = raw["excerpts"][0]
|
||||||
|
path = root / (first["concept_id"] + ".md")
|
||||||
|
path.write_text(
|
||||||
|
path.read_text(encoding="utf-8") + f"\n\n{_EXCERPT_SENTINEL}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
first["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
first["text"] = prepass.concept_text(path)
|
||||||
|
first["text_sha256"] = hashlib.sha256(first["text"].encode("utf-8")).hexdigest()
|
||||||
|
return str(root), prepass.PrepassPayload.model_validate(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _docs(tmp_path: Path) -> str:
|
||||||
|
"""The road path's data source, unused on the bundle arm but required by the signature."""
|
||||||
|
d = tmp_path / "docs"
|
||||||
|
d.mkdir(exist_ok=True)
|
||||||
|
(d / "cost.txt").write_text("Energitiltak i kontorbygg.", encoding="utf-8")
|
||||||
|
return str(d)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(bundle_dir: str, **kwargs: Any) -> tuple[Any, list[str], list[list[Any]]]:
|
||||||
|
"""Drive the bundle arm offline, capturing both the prompts and the tool list."""
|
||||||
|
sink: list[str] = []
|
||||||
|
captured: list[list[Any]] = []
|
||||||
|
original = run_module.fresh_workflow
|
||||||
|
|
||||||
|
def spy(*args: Any, **spy_kwargs: Any) -> Any:
|
||||||
|
captured.append(list(spy_kwargs.get("tools") or []))
|
||||||
|
return original(*args, **spy_kwargs)
|
||||||
|
|
||||||
|
run_module.fresh_workflow = spy # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
result = await run_project(
|
||||||
|
PROJECT_ID,
|
||||||
|
bundle_dir=bundle_dir,
|
||||||
|
docs_dir=_docs(Path(bundle_dir).parent),
|
||||||
|
store=VerdictStore(verdicts=[]),
|
||||||
|
client_factory=_recording_factory(sink),
|
||||||
|
max_rounds=2,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
run_module.fresh_workflow = original # type: ignore[assignment]
|
||||||
|
return result, sink, captured
|
||||||
|
|
||||||
|
|
||||||
|
# --- the rendering replaces the pointer -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_payload_replaces_the_pointer_in_the_debate(tmp_path: Path) -> None:
|
||||||
|
bundle_dir, payload = _base(tmp_path, sentinel=True)
|
||||||
|
_, sink, _ = await _run(bundle_dir, prepass_payload=payload)
|
||||||
|
joined = " ".join(sink)
|
||||||
|
assert _EXCERPT_SENTINEL in joined
|
||||||
|
assert _LADDER not in joined
|
||||||
|
|
||||||
|
|
||||||
|
async def test_without_a_payload_the_pointer_stands(tmp_path: Path) -> None:
|
||||||
|
"""The control. Without it the arm above is satisfied by a run that built no prompt at all."""
|
||||||
|
bundle_dir, _ = _base(tmp_path, sentinel=True)
|
||||||
|
_, sink, _ = await _run(bundle_dir)
|
||||||
|
joined = " ".join(sink)
|
||||||
|
assert _LADDER in joined
|
||||||
|
assert _EXCERPT_SENTINEL not in joined
|
||||||
|
|
||||||
|
|
||||||
|
# --- the tools are withdrawn, and only they -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_payload_withdraws_the_four_navigator_tools(tmp_path: Path) -> None:
|
||||||
|
bundle_dir, payload = _base(tmp_path)
|
||||||
|
_, _, captured = await _run(bundle_dir, prepass_payload=payload)
|
||||||
|
assert captured, "the debate was never built"
|
||||||
|
assert {getattr(t, "name", "") for t in captured[0]} & NAVIGATOR_TOOLS == set()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_without_a_payload_the_debate_gets_exactly_those_four(tmp_path: Path) -> None:
|
||||||
|
"""The paired control, asserting the EXACT set: `not any(...)` alone is also what an empty
|
||||||
|
list produces, and an empty list is what a run that never built produces."""
|
||||||
|
bundle_dir, _ = _base(tmp_path)
|
||||||
|
_, _, captured = await _run(bundle_dir)
|
||||||
|
assert captured, "the debate was never built"
|
||||||
|
assert {getattr(t, "name", "") for t in captured[0]} == NAVIGATOR_TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_configured_mcp_tool_survives_the_withdrawal(tmp_path: Path) -> None:
|
||||||
|
"""Distinguishes "withdraw the navigator tools" from ``debate_tools = []``. The MCP append
|
||||||
|
sits BELOW the fork on purpose."""
|
||||||
|
|
||||||
|
class _FakeMcpTool:
|
||||||
|
name = "an_external_tool"
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "_FakeMcpTool":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bundle_dir, payload = _base(tmp_path)
|
||||||
|
original = run_module.build_mcp_tools
|
||||||
|
run_module.build_mcp_tools = lambda servers: [_FakeMcpTool()] # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
_, _, captured = await _run(
|
||||||
|
bundle_dir,
|
||||||
|
prepass_payload=payload,
|
||||||
|
mcp_servers=(
|
||||||
|
McpServerConfig(
|
||||||
|
name="prisregister",
|
||||||
|
transport="http",
|
||||||
|
url="https://intern.example/mcp",
|
||||||
|
allowed_tools=("an_external_tool",),
|
||||||
|
timeout_seconds=15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
run_module.build_mcp_tools = original # type: ignore[assignment]
|
||||||
|
names = {getattr(t, "name", "") for t in captured[0]}
|
||||||
|
assert "an_external_tool" in names
|
||||||
|
assert names & NAVIGATOR_TOOLS == set()
|
||||||
|
|
||||||
|
|
||||||
|
# --- what else the fork must get right ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_citations_are_the_delivered_concepts(tmp_path: Path) -> None:
|
||||||
|
"""A stamp citing all five for a proposal that saw four re-creates the undeclared claim this
|
||||||
|
seam removes. Each snippet stays exact by construction."""
|
||||||
|
bundle_dir, payload = _base(tmp_path)
|
||||||
|
result, _, _ = await _run(bundle_dir, prepass_payload=payload)
|
||||||
|
assert isinstance(result, RunResult)
|
||||||
|
cited = {c.file for c in result.provenance.citations}
|
||||||
|
assert cited == {e.concept_id + ".md" for e in payload.excerpts}
|
||||||
|
bodies = {f.name: f.body for f in okf.navigate_bundle(bundle_dir).context_files}
|
||||||
|
for citation in result.provenance.citations:
|
||||||
|
body = bodies[citation.file]
|
||||||
|
assert citation.snippet == body[citation.locator.start_index : citation.locator.end_index]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_payload_run_reaches_an_outcome(tmp_path: Path) -> None:
|
||||||
|
"""Nothing else here drives the fork past the debate; without this arm the citation guard at
|
||||||
|
``run.py:1015``, the checker gate and the outbox path are all unexercised on the new path."""
|
||||||
|
bundle_dir, payload = _base(tmp_path)
|
||||||
|
result, _, _ = await _run(bundle_dir, prepass_payload=payload)
|
||||||
|
assert isinstance(result, RunResult)
|
||||||
|
assert result.provenance.validator_decision in {"validated", "rejected"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_gated_expel_fold_still_reaches_the_hypothesis(tmp_path: Path) -> None:
|
||||||
|
"""GATE, not wall. A prior judgement must still reach the hypothesis prompt through the
|
||||||
|
ExpeL fold — otherwise an implementation that simply refuses everything verdict-shaped
|
||||||
|
passes every other arm here while having removed the loop's whole learning path."""
|
||||||
|
bundle_dir, payload = _base(tmp_path)
|
||||||
|
store = seed_store_from_bundle(bundle_dir)
|
||||||
|
assert store.verdicts, "the shipped base no longer seeds a verdict"
|
||||||
|
|
||||||
|
sink: list[str] = []
|
||||||
|
await run_project(
|
||||||
|
PROJECT_ID,
|
||||||
|
bundle_dir=bundle_dir,
|
||||||
|
docs_dir=_docs(Path(bundle_dir).parent),
|
||||||
|
store=store,
|
||||||
|
client_factory=_recording_factory(sink),
|
||||||
|
max_rounds=2,
|
||||||
|
prepass_payload=payload,
|
||||||
|
)
|
||||||
|
assert any("0.82" in prompt for prompt in sink), "the ExpeL fold no longer reaches generation"
|
||||||
|
|
||||||
|
|
||||||
|
# --- refusals -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_payload_for_another_base_refuses_without_building_a_debate(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
bundle_dir, _ = _base(tmp_path)
|
||||||
|
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
raw["bundle"]["bundle_id"] = "a-different-corpus"
|
||||||
|
with pytest.raises(prepass.PrepassRefused):
|
||||||
|
await _run(bundle_dir, prepass_payload=prepass.PrepassPayload.model_validate(raw))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_refused_payload_never_falls_back_to_the_pointer(tmp_path: Path) -> None:
|
||||||
|
"""``load_mandate``'s rule: a caller who asked for a declared cut and got a navigating run
|
||||||
|
instead was answered by a silently downgraded order."""
|
||||||
|
bundle_dir, _ = _base(tmp_path)
|
||||||
|
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
raw["bundle"]["bundle_id"] = "a-different-corpus"
|
||||||
|
sink: list[str] = []
|
||||||
|
with pytest.raises(prepass.PrepassRefused):
|
||||||
|
await run_project(
|
||||||
|
PROJECT_ID,
|
||||||
|
bundle_dir=bundle_dir,
|
||||||
|
docs_dir=_docs(Path(bundle_dir).parent),
|
||||||
|
store=VerdictStore(verdicts=[]),
|
||||||
|
client_factory=_recording_factory(sink),
|
||||||
|
max_rounds=2,
|
||||||
|
prepass_payload=prepass.PrepassPayload.model_validate(raw),
|
||||||
|
)
|
||||||
|
assert sink == [], "the run made model calls despite refusing the payload"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_payload_that_delivers_nothing_is_refused_by_name(tmp_path: Path) -> None:
|
||||||
|
"""Measured: ``delivered == 0`` is only reachable when every concept failed to match, which
|
||||||
|
IS evidence of absence for this question at this ref. Saying so beats falling through to
|
||||||
|
``run.py:1015``'s ``no citable content in docs_dir`` — a surface that is ``None`` here."""
|
||||||
|
bundle_dir, _ = _base(tmp_path)
|
||||||
|
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
raw["withheld"] = [
|
||||||
|
{"concept_id": e["concept_id"], "rule": "no_lexical_match"} for e in raw["excerpts"]
|
||||||
|
] + raw["withheld"]
|
||||||
|
raw["excerpts"] = []
|
||||||
|
raw["denominators"]["withheld"] = len(raw["withheld"])
|
||||||
|
raw["denominators"]["delivered"] = 0
|
||||||
|
sink: list[str] = []
|
||||||
|
with pytest.raises(prepass.PrepassRefused) as excinfo:
|
||||||
|
await run_project(
|
||||||
|
PROJECT_ID,
|
||||||
|
bundle_dir=bundle_dir,
|
||||||
|
docs_dir=_docs(Path(bundle_dir).parent),
|
||||||
|
store=VerdictStore(verdicts=[]),
|
||||||
|
client_factory=_recording_factory(sink),
|
||||||
|
max_rounds=2,
|
||||||
|
prepass_payload=prepass.PrepassPayload.model_validate(raw),
|
||||||
|
)
|
||||||
|
message = str(excinfo.value)
|
||||||
|
assert "0" in message and raw["question"] in message
|
||||||
|
assert sink == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_payload_without_a_bundle_dir_is_refused(tmp_path: Path) -> None:
|
||||||
|
"""The road path has no base for the payload to agree with."""
|
||||||
|
_, payload = _base(tmp_path)
|
||||||
|
with pytest.raises(prepass.PrepassRefused, match="bundle"):
|
||||||
|
await run_project(
|
||||||
|
PROJECT_ID,
|
||||||
|
docs_dir=_docs(tmp_path),
|
||||||
|
store=VerdictStore(verdicts=[]),
|
||||||
|
client_factory=_recording_factory([]),
|
||||||
|
max_rounds=2,
|
||||||
|
prepass_payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the declaration on the result ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_run_carries_the_declaration(tmp_path: Path) -> None:
|
||||||
|
bundle_dir, payload = _base(tmp_path)
|
||||||
|
result, _, _ = await _run(bundle_dir, prepass_payload=payload)
|
||||||
|
assert isinstance(result, RunResult)
|
||||||
|
assert result.prepass is not None
|
||||||
|
assert result.prepass.ref == payload.bundle.ref
|
||||||
|
assert result.prepass.delivered == payload.denominators.delivered
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_run_without_a_payload_carries_none(tmp_path: Path) -> None:
|
||||||
|
bundle_dir, _ = _base(tmp_path)
|
||||||
|
result, _, _ = await _run(bundle_dir)
|
||||||
|
assert isinstance(result, RunResult)
|
||||||
|
assert result.prepass is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_dry_run_report_carries_the_declaration(tmp_path: Path) -> None:
|
||||||
|
"""The dry-run cut sits BELOW the fork, so a dry run can honestly report the cut it was
|
||||||
|
given — unlike ``unkeyed_verdicts``, which is resolved above it and could only report zero."""
|
||||||
|
bundle_dir, payload = _base(tmp_path)
|
||||||
|
report = await run_project(
|
||||||
|
PROJECT_ID,
|
||||||
|
bundle_dir=bundle_dir,
|
||||||
|
docs_dir=_docs(Path(bundle_dir).parent),
|
||||||
|
store=VerdictStore(verdicts=[]),
|
||||||
|
client_factory=_recording_factory([]),
|
||||||
|
max_rounds=2,
|
||||||
|
live_dry_run=True,
|
||||||
|
prepass_payload=payload,
|
||||||
|
)
|
||||||
|
assert isinstance(report, run_module.DryRunReport)
|
||||||
|
assert report.prepass is not None
|
||||||
|
assert report.prepass.considered == payload.denominators.considered
|
||||||
Loading…
Add table
Add a link
Reference in a new issue