feat(major2): prior_feedback is the third composable block of the hypothesis prompt [skip-docs]

Ordre 20260904T173146Z-8102814273-from-portfolio-optimiser, steg 2 av 10.

_build_messages faar prior_feedback (keyword-only, None => byte-identisk base-prompt,
samme kontrakt prior_rejection og approach alt oppgir). Ekspertens ORD, ordrett - aldri
forrige forslags JSON, av samme grunn prior_rejection kun baerer grunnen.

Blokken beskriver noe annet enn en avvisning: en kandidat validatoren AKSEPTERTE og et
menneske likevel ba om aa endre. Aa slaa dem sammen ville fortalt modellen at maskinen
protesterte da en person gjorde det.

Rekkefoelgen er fast: base -> approach-hode -> avvisning -> tilbakemelding. En prompt
kan lovlig baere BEGGE - det er forsoeket etter en revise hvis kjoepte forsoek validatoren
saa avviste: menneskets instruks STAAR til mennesket svarer neste gang, mens maskinens
grunn er per forsoek (kun den nyeste, som i dag).

RODT foer impl paa tre armer (TypeError: uventet keyword). Kontrollen (None => byte-identisk)
er halvdelen som holder hver eksisterende kjoering, golden og nav-fixtur uroert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-05 06:46:49 +02:00
commit d4c8691326
2 changed files with 92 additions and 3 deletions

View file

@ -279,6 +279,7 @@ def _build_messages(
prior_rejection: Rejection | None = None, prior_rejection: Rejection | None = None,
*, *,
approach: Approach | None = None, approach: Approach | None = None,
prior_feedback: str | None = None,
) -> list[Message]: ) -> list[Message]:
"""Build the hypothesis prompt. When ``prior_rejection`` is set (Step 5, målbilde §5/§7), """Build the hypothesis prompt. When ``prior_rejection`` is set (Step 5, målbilde §5/§7),
append a revision block carrying ONLY the falsification *reason* verbatim never the prior append a revision block carrying ONLY the falsification *reason* verbatim never the prior
@ -294,8 +295,18 @@ def _build_messages(
exactly the part the model cannot infer from the cost data. ``None`` -> the byte-identical exactly the part the model cannot infer from the cost data. ``None`` -> the byte-identical
base prompt, so an un-commissioned run is untouched (mirrors ``prior_rejection``). base prompt, so an un-commissioned run is untouched (mirrors ``prior_rejection``).
The two are composable: a commissioned approach that the validator rejects is refined through When ``prior_feedback`` is set (MAJOR-2) a THIRD block carries a domain expert's own words
the SAME informed-refinement block, still bound to that approach. VERBATIM and only the words, never the previous proposal JSON, for exactly the reason
``prior_rejection`` carries only the reason. This block describes a candidate the validator
ACCEPTED and a human nonetheless asked to change, which is a different instruction from
"your numbers were refuted"; conflating the two would tell the model the machine objected
when a person did. ``None`` -> the byte-identical base prompt, like the other two.
All three are composable, and the ORDER is fixed: base -> approach head -> rejection ->
feedback. A prompt can legitimately carry a rejection AND a feedback at once that is the
attempt after a revise whose bought attempt the validator then rejected: the human's
instruction STANDS until the human next answers, while the machine's reason is per-attempt
(only the most recent, as today).
""" """
if approach is None: if approach is None:
head = "Propose ONE concrete cost-saving measure for this project.\n" head = "Propose ONE concrete cost-saving measure for this project.\n"
@ -323,6 +334,13 @@ def _build_messages(
f"Reason: {prior_rejection.reason}\n" f"Reason: {prior_rejection.reason}\n"
"Produce a REVISED SavingsProposal that resolves this." "Produce a REVISED SavingsProposal that resolves this."
) )
if prior_feedback is not None:
prompt += (
"\n\nA domain expert reviewed your previous proposal, which the deterministic "
"validator had accepted, and asked for a revision.\n"
f"Expert feedback: {prior_feedback}\n"
"Produce a REVISED SavingsProposal that follows this feedback."
)
return [Message(role="user", contents=[prompt])] return [Message(role="user", contents=[prompt])]

View file

@ -28,10 +28,13 @@ import json
from pathlib import Path from pathlib import Path
import pytest import pytest
from spikes._harness import message_texts
from portfolio_optimiser import proposal_review as pr from portfolio_optimiser import proposal_review as pr
from portfolio_optimiser.generate import _build_messages
from portfolio_optimiser.ir import AffectedItem, SavingsProposal from portfolio_optimiser.ir import AffectedItem, SavingsProposal
from portfolio_optimiser.validator import ValidatedProposal from portfolio_optimiser.reference_domain import load_reference_projects
from portfolio_optimiser.validator import Rejection, ValidatedProposal
_REPO = Path(__file__).resolve().parents[1] _REPO = Path(__file__).resolve().parents[1]
_BUNDLE_DIR = _REPO / "shared" / "examples" / "bygg-energi-mikro" _BUNDLE_DIR = _REPO / "shared" / "examples" / "bygg-energi-mikro"
@ -203,3 +206,71 @@ def test_the_payload_carries_every_field_and_takes_its_key_from_the_injected_fun
assert seen == [reviews[0].proposal.proposal, reviews[1].proposal.proposal] assert seen == [reviews[0].proposal.proposal, reviews[1].proposal.proposal]
# The payload is a PLAIN mapping: ``outbox`` stays MAF-free and byte-deterministic. # The payload is a PLAIN mapping: ``outbox`` stays MAF-free and byte-deterministic.
assert json.loads(json.dumps(payload)) == payload assert json.loads(json.dumps(payload)) == payload
# ---------------------------------------------------------------------------------------------
# Group A1 (Step 2) — ``prior_feedback``: the third composable block of the hypothesis prompt.
# ---------------------------------------------------------------------------------------------
@pytest.fixture(scope="module")
def project():
return load_reference_projects()[0] # FV42-GSV-E1
def test_prior_feedback_none_keeps_the_base_prompt_byte_identical(project) -> None:
"""CONTROL: the new block is INERT when nobody reviewed. A test that can only go green
proves nothing, and this is the half that keeps every existing run, golden and nav-fixture
untouched the contract ``prior_rejection`` and ``approach`` already state."""
base = message_texts(_build_messages(project, "ctx"))[0]
assert base == message_texts(_build_messages(project, "ctx", prior_feedback=None))[0]
assert _FEEDBACK_SENTINEL not in base
def test_the_experts_words_reach_the_next_prompt_verbatim(project) -> None:
"""Detach point: dropping the ``prior_feedback`` block (M3 — print-and-discard).
VERBATIM and only the text: never the previous proposal JSON. That is the ``prior_rejection``
rule, and it is the same reason the model must address what the human said, not parrot the
candidate they were unhappy with."""
text = message_texts(
_build_messages(
project, "ctx", prior_feedback=f"Use 150000, not 200000. {_FEEDBACK_SENTINEL}"
)
)[0]
assert _FEEDBACK_SENTINEL in text
assert "asked for a revision" in text
assert "Use 150000, not 200000." in text
def test_a_rejection_and_a_standing_feedback_compose_in_order(project) -> None:
"""The two blocks compose, and the ORDER is fixed: base -> approach head -> rejection ->
feedback.
This is the shape T6 measures end to end: a revise whose bought attempt the validator then
rejects leaves the attempt after it carrying BOTH the human's instruction stands until the
human next answers, the machine's reason is per-attempt (only the most recent, as today)."""
rejection = Rejection(proposal=_proposal(), reason="claimed saving 270000 exceeds P90 121057")
text = message_texts(
_build_messages(
project, "ctx", prior_rejection=rejection, prior_feedback=_FEEDBACK_SENTINEL
)
)[0]
assert text.count(rejection.reason) == 1
assert text.count(_FEEDBACK_SENTINEL) == 1
assert text.index("REJECTED by the deterministic validator") < text.index(_FEEDBACK_SENTINEL)
def test_the_feedback_sentinel_cannot_arrive_from_the_knowledge_base() -> None:
"""The known-positive control on every sentinel arm in this file: the marker exists NOWHERE
in the fixture bundle, so a prompt that carries it can only have got it through the expert's
feedback block. Paired with an assertion that the scan actually reads the files a scan that
silently finds nothing makes a gate that can only go green."""
texts = [
path.read_text(encoding="utf-8")
for path in sorted(_BUNDLE_DIR.rglob("*"))
if path.is_file()
]
assert texts, "the fixture bundle must exist for this control to mean anything"
assert any("ENERGI-TOTAL-EL" in text for text in texts) # the scan CAN find a known string
assert not any(_FEEDBACK_SENTINEL in text for text in texts)