feat(sim): the demo script is keyed on the project the prompt names, so a new project is data
The scripted proposer answered one hard-coded pair of proposals. A second project meant a second
hand-written selector, written under demo-week time pressure -- the risk the week plan names
explicitly (§4, risk 2). It is now a registry: `ScriptedCandidate` entries selected by
`scripted_proposer`, plus `project_id` as an argument to `simulate_learning_loop`.
The open decision was WHAT identifies the candidate in the prompt blob; the plan flagged it as
unverified, so it was measured. Two prompt shapes reach the selector: the debate prompt carries the
whole bundle context, the generation prompt carries `Project: {id} - {name}` plus -- as its context
-- the debate output, which is the selector's own earlier reply. So the cost code and the measure
name are present in the generation prompt only because the script put them there; keying on them
would key the script on its own output. The project id is the one identifier both shapes carry and
the framework stamps.
Validation, never repair: no match, or more than one, raises `ScriptedCandidateError`. A default
reply would answer an unregistered project with another project's numbers, which on screen is
indistinguishable from a correct run; an ambiguous blob is a data problem that must surface at the
rehearsal rather than be decided by registry order.
Load-bearing MEASURED against the whole suite, five mutations all red plus a green control: detach
the project keying - one global flip key - fall back on an unknown project - first-match on an
ambiguous prompt - detach the `project_id` argument. The flip-key test was rewritten mid-measurement
because its first form asserted on the FIRST registry entry, where "the matched candidate's key" and
"candidates[0]'s key" coincide -- it could not separate the two implementations, and proved nothing.
766 passed / 4 skipped. Simulation still exits 0, still prints eight labelled steps, still
byte-identical across two runs.
[skip-docs] README is deliberately untouched: O4 defers the README rewrite to 14-15 August, after
the demo has produced the evidence for the level-2 claim. CLAUDE.md carries the invariant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XoHJCKBTjFKcjsfEQyGbzh
This commit is contained in:
parent
e93e921b1f
commit
688ee24973
4 changed files with 278 additions and 20 deletions
|
|
@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
bound is unchanged (`max_attempts` + token meter).
|
bound is unchanged (`max_attempts` + token meter).
|
||||||
- `simulation.scripted_factory` accepts a per-role reply *selector* over `(prompt, role)` as well as
|
- `simulation.scripted_factory` accepts a per-role reply *selector* over `(prompt, role)` as well as
|
||||||
a constant reply, so a scripted role can answer differently on a later attempt.
|
a constant reply, so a scripted role can answer differently on a later attempt.
|
||||||
|
- The offline simulation's scripted proposer is now a candidate **registry** rather than a
|
||||||
|
hand-written reply: `simulation.scripted_proposer(candidates)` builds the selector from
|
||||||
|
`ScriptedCandidate` entries keyed on the project id the prompt names, and
|
||||||
|
`simulate_learning_loop` takes `project_id` alongside `bundle_dir`. Adding a project to the
|
||||||
|
walkthrough is a data entry. A prompt matching no entry — or more than one — raises
|
||||||
|
`ScriptedCandidateError` rather than answering with another project's numbers.
|
||||||
|
|
||||||
## [0.1.0] - 2026-08-06
|
## [0.1.0] - 2026-08-06
|
||||||
|
|
||||||
|
|
|
||||||
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -267,6 +267,25 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b
|
||||||
lever på Claude-SDK-siden (minimal API-kjøring). Skriptet klient = MAF-side stillas, IKKE delt
|
lever på Claude-SDK-siden (minimal API-kjøring). Skriptet klient = MAF-side stillas, IKKE delt
|
||||||
(`shared/` forblir framework-nøytralt). Kjøres `uv run python -m portfolio_optimiser.simulation`.
|
(`shared/` forblir framework-nøytralt). Kjøres `uv run python -m portfolio_optimiser.simulation`.
|
||||||
Load-bearing: `tests/test_simulation_loadbearing.py` blir RØD når promoteringen detaches.
|
Load-bearing: `tests/test_simulation_loadbearing.py` blir RØD når promoteringen detaches.
|
||||||
|
- **Det skriptede manuset nøkles på PROSJEKT-ID-en, og det er MÅLT:** `scripted_proposer(candidates)`
|
||||||
|
bygger simuleringens proposer fra et `ScriptedCandidate`-register, så et nytt prosjekt er en
|
||||||
|
data-oppføring (demo-uke-plan §4 risiko 2) — ikke et andre håndskrevet manus. **Hvorfor ikke
|
||||||
|
kostkode/tiltaksnavn:** to prompt-former når selectoren — debatt-prompten bærer hele
|
||||||
|
bundle-konteksten, mens genererings-prompten (`generate._build_messages`) bærer
|
||||||
|
`Project: {id} - {name}` pluss *debatt-outputen* som kontekst, altså selectorens EGET tidligere
|
||||||
|
svar. Kostkode og tiltaksnavn står derfor i genererings-prompten kun fordi manuset selv la dem
|
||||||
|
der; å nøkle på dem ville nøklet manuset på sin egen output. Prosjekt-ID-en er den ene
|
||||||
|
identifikatoren BEGGE former bærer og som RAMMEVERKET stempler. **Validering, ALDRI reparasjon:**
|
||||||
|
null treff — eller mer enn ett — raiser `ScriptedCandidateError`; et default-svar ville besvart et
|
||||||
|
uregistrert prosjekt med et ANNET prosjekts tall, som på skjermen er umulig å skille fra en riktig
|
||||||
|
kjøring, og en tvetydig blob er et DATA-problem som skal falle på generalprøven, ikke avgjøres av
|
||||||
|
register-rekkefølgen. `flip_key` MÅ være fraværende fra bundelen (ellers bærer forsøk 1s prompt
|
||||||
|
den allerede). `simulate_learning_loop` tar `project_id` ved siden av `bundle_dir`. Load-bearing
|
||||||
|
MÅLT (`tests/test_content_keyed_script_loadbearing.py`), fem mutasjoner alle røde + grønn
|
||||||
|
kontroll: detach nøklingen · én global flip-key · fallback ved ukjent prosjekt · første-treff ved
|
||||||
|
tvetydighet · detach `project_id`-argumentet. **Flip-key-testen ble skrevet om under målingen** —
|
||||||
|
første form asserterte på FØRSTE register-oppføring, der «den matchede kandidatens nøkkel» og
|
||||||
|
«`candidates[0]`s nøkkel» sammenfaller; den kunne ikke skille de to implementasjonene.
|
||||||
- **Delt ekspert-persona som Agent Skill (§8, framework-nøytral):** ekspert-reviewer-personaen bor i
|
- **Delt ekspert-persona som Agent Skill (§8, framework-nøytral):** ekspert-reviewer-personaen bor i
|
||||||
`shared/skills/expert-reviewer/` (`SKILL.md` + `references/example-verdict.json`) og er den ENE
|
`shared/skills/expert-reviewer/` (`SKILL.md` + `references/example-verdict.json`) og er den ENE
|
||||||
delte artefakten begge stacker instansierer reviewer-en fra. `shared/` forblir REN DATA — MAF-siden
|
delte artefakten begge stacker instansierer reviewer-en fra. `shared/` forblir REN DATA — MAF-siden
|
||||||
|
|
|
||||||
|
|
@ -52,32 +52,96 @@ def _default_bundle_dir() -> Path:
|
||||||
return shared_root() / "examples" / "bygg-energi-mikro"
|
return shared_root() / "examples" / "bygg-energi-mikro"
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptedCandidateError(LookupError):
|
||||||
|
"""No single registered candidate matches the prompt (none, or more than one)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScriptedCandidate:
|
||||||
|
"""One project's scripted proposer script, expressed as DATA.
|
||||||
|
|
||||||
|
Adding a project to the walkthrough is a registry entry — never a second hand-written selector.
|
||||||
|
``overclaimed`` is answered until the deterministic validator's rejection comes back in the
|
||||||
|
prompt (recognised by ``flip_key``), after which ``corrected`` is answered. Both are raw reply
|
||||||
|
strings, exactly what a model would have returned.
|
||||||
|
|
||||||
|
``flip_key`` must be ABSENT from the bundle this candidate is demoed against, or attempt 1's
|
||||||
|
prompt already contains it and the proposer 'corrects' before anything was falsified.
|
||||||
|
"""
|
||||||
|
|
||||||
|
project_id: str
|
||||||
|
overclaimed: str
|
||||||
|
corrected: str
|
||||||
|
flip_key: str
|
||||||
|
|
||||||
|
|
||||||
|
def scripted_proposer(
|
||||||
|
candidates: Sequence[ScriptedCandidate],
|
||||||
|
) -> Callable[[str, str], str]:
|
||||||
|
"""Build the scripted proposer over a candidate registry — a ``reply_selector`` for the canonical
|
||||||
|
client, keyed on the PROJECT the prompt names.
|
||||||
|
|
||||||
|
**Why the project id and not the cost code or the measure name** (measured, not assumed — the
|
||||||
|
demo-week plan §6 flagged this as unverified): two prompt shapes reach this selector. The debate
|
||||||
|
prompt (``run.py``) carries the whole bundle context; the generation prompt
|
||||||
|
(``generate._build_messages``) carries ``Project: {id} - {name}`` plus, as its context, the
|
||||||
|
*debate output* — which is this selector's own earlier reply. So the cost code and measure name
|
||||||
|
are present in the generation prompt only because the script put them there; keying on them
|
||||||
|
would key the script on itself. The project id is the one identifier BOTH shapes carry and the
|
||||||
|
FRAMEWORK stamps.
|
||||||
|
|
||||||
|
Stateless — no per-turn counter — so the debate turns and the generation attempts share it.
|
||||||
|
|
||||||
|
Anything other than exactly one match raises ``ScriptedCandidateError``. Validation, never
|
||||||
|
repair: a default reply would let an unregistered project be answered with another project's
|
||||||
|
numbers, which on screen is indistinguishable from a correct run. An ambiguous blob (a bundle
|
||||||
|
context that names a sibling project) is a DATA problem, and it must surface at the rehearsal
|
||||||
|
rather than be silently decided by registry order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def select(prompt: str, _role: str) -> str:
|
||||||
|
matches = [c for c in candidates if c.project_id in prompt]
|
||||||
|
if len(matches) != 1:
|
||||||
|
found = ", ".join(c.project_id for c in matches) or "-"
|
||||||
|
raise ScriptedCandidateError(
|
||||||
|
f"no scripted candidate uniquely matches the prompt (ambiguous or unknown); "
|
||||||
|
f"matched: {found}"
|
||||||
|
)
|
||||||
|
candidate = matches[0]
|
||||||
|
return candidate.corrected if candidate.flip_key in prompt else candidate.overclaimed
|
||||||
|
|
||||||
|
return select
|
||||||
|
|
||||||
|
|
||||||
# Two SavingsProposals for BYGG-KONTOR-NORD: total = 300000 x 1.0, so the degenerate Monte Carlo
|
# Two SavingsProposals for BYGG-KONTOR-NORD: total = 300000 x 1.0, so the degenerate Monte Carlo
|
||||||
# P90 = 0.30 x 300000 = 90000 (no `assumptions`). The OVERCLAIMED one asks for 250000 — parseable,
|
# P90 = 0.30 x 300000 = 90000 (no `assumptions`). The OVERCLAIMED one asks for 250000 — parseable,
|
||||||
# and internally consistent, but above P90, so the DETERMINISTIC validator falsifies it. The
|
# and internally consistent, but above P90, so the DETERMINISTIC validator falsifies it. The
|
||||||
# corrected one claims 30000 <= 90000 and validates. Together they drive Step 5 (informed
|
# corrected one claims 30000 <= 90000 and validates. Together they drive Step 5 (informed
|
||||||
# refinement): the proposer is scripted, but the rejection that turns proposal 1 into proposal 2 is
|
# refinement): the proposer is scripted, but the rejection that turns proposal 1 into proposal 2 is
|
||||||
# genuinely computed by the validator, not scripted.
|
# genuinely computed by the validator, not scripted.
|
||||||
_OVERCLAIMED_PROPOSAL = (
|
#
|
||||||
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
|
# 250000 — the overclaimed figure the validator's rejection reason carries and
|
||||||
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":250000}'
|
# ``generate._build_messages`` appends to the NEXT attempt's prompt — is verified ABSENT from the
|
||||||
)
|
# demo bundle (``test_content_keyed_script_loadbearing``), so the correction is caused by the
|
||||||
_VALID_PROPOSAL = (
|
|
||||||
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
|
|
||||||
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
|
|
||||||
)
|
|
||||||
# The flip key: the overclaimed figure, which the validator's rejection reason carries and
|
|
||||||
# ``generate._build_messages`` appends to the NEXT attempt's prompt. Verified ABSENT from the demo
|
|
||||||
# bundle, so it cannot pre-exist in attempt 1's prompt — the correction is caused by the
|
|
||||||
# falsification travelling back, never by the proposer simply being asked twice.
|
# falsification travelling back, never by the proposer simply being asked twice.
|
||||||
_REJECTED_CLAIM_KEY = "250000"
|
_CANDIDATES: tuple[ScriptedCandidate, ...] = (
|
||||||
|
ScriptedCandidate(
|
||||||
|
project_id=_PROJECT_ID,
|
||||||
|
overclaimed=(
|
||||||
|
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
|
||||||
|
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],'
|
||||||
|
'"claimed_saving_nok":250000}'
|
||||||
|
),
|
||||||
|
corrected=(
|
||||||
|
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
|
||||||
|
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],'
|
||||||
|
'"claimed_saving_nok":30000}'
|
||||||
|
),
|
||||||
|
flip_key="250000",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_proposer_reply = scripted_proposer(_CANDIDATES)
|
||||||
def _proposer_reply(prompt: str, _role: str) -> str:
|
|
||||||
"""The scripted proposer, keyed on PROMPT CONTENT (the canonical client's ``reply_selector``
|
|
||||||
seam): it overclaims until the validator's rejection comes back in the prompt, then corrects.
|
|
||||||
Stateless — no per-turn counter — so the debate turns and the generation attempts share it."""
|
|
||||||
return _VALID_PROPOSAL if _REJECTED_CLAIM_KEY in prompt else _OVERCLAIMED_PROPOSAL
|
|
||||||
|
|
||||||
|
|
||||||
# The checker's debate turn ends with the gate marker the run parses (run._checker_verdict).
|
# The checker's debate turn ends with the gate marker the run parses (run._checker_verdict).
|
||||||
|
|
@ -216,6 +280,7 @@ async def simulate_learning_loop(
|
||||||
bundle_dir: str,
|
bundle_dir: str,
|
||||||
work_dir: str,
|
work_dir: str,
|
||||||
*,
|
*,
|
||||||
|
project_id: str = _PROJECT_ID,
|
||||||
persona_rationale: str | None = None,
|
persona_rationale: str | None = None,
|
||||||
marker: str | None = None,
|
marker: str | None = None,
|
||||||
timestamp: str = "2026-06-30",
|
timestamp: str = "2026-06-30",
|
||||||
|
|
@ -224,6 +289,11 @@ async def simulate_learning_loop(
|
||||||
"""Run the loop twice on a throwaway COPY of the bundle (the shared fixture is never mutated),
|
"""Run the loop twice on a throwaway COPY of the bundle (the shared fixture is never mutated),
|
||||||
with a promotion in between, and trace whether the persona's approved knowledge crosses runs.
|
with a promotion in between, and trace whether the persona's approved knowledge crosses runs.
|
||||||
|
|
||||||
|
``bundle_dir`` and ``project_id`` are BOTH arguments, so pointing the walkthrough at new content
|
||||||
|
is a call-site change plus a ``_CANDIDATES`` entry — no edit to this module's logic. The id must
|
||||||
|
match the bundle's own IR projection (``run._project_from_bundle`` raises on a mismatch) and must
|
||||||
|
have a registered scripted candidate.
|
||||||
|
|
||||||
The persona's verdict (decision + rationale + traced ``marker``) defaults to the shared
|
The persona's verdict (decision + rationale + traced ``marker``) defaults to the shared
|
||||||
expert-reviewer skill's canonical example (``load_persona_example``), read at CALL time — so the
|
expert-reviewer skill's canonical example (``load_persona_example``), read at CALL time — so the
|
||||||
simulation is genuinely artifact-driven, not inlined. Callers may override ``marker`` /
|
simulation is genuinely artifact-driven, not inlined. Callers may override ``marker`` /
|
||||||
|
|
@ -256,7 +326,7 @@ async def simulate_learning_loop(
|
||||||
run_a = cast(
|
run_a = cast(
|
||||||
RunResult, # the sim only drives full runs; never dry-run (S4.2 widened run_project)
|
RunResult, # the sim only drives full runs; never dry-run (S4.2 widened run_project)
|
||||||
await run_project(
|
await run_project(
|
||||||
_PROJECT_ID,
|
project_id,
|
||||||
"local",
|
"local",
|
||||||
docs_dir=copy_s,
|
docs_dir=copy_s,
|
||||||
bundle_dir=copy_s,
|
bundle_dir=copy_s,
|
||||||
|
|
@ -284,7 +354,7 @@ async def simulate_learning_loop(
|
||||||
run_b = cast(
|
run_b = cast(
|
||||||
RunResult, # the sim only drives full runs; never dry-run (S4.2 widened run_project)
|
RunResult, # the sim only drives full runs; never dry-run (S4.2 widened run_project)
|
||||||
await run_project(
|
await run_project(
|
||||||
_PROJECT_ID,
|
project_id,
|
||||||
"local",
|
"local",
|
||||||
docs_dir=copy_s,
|
docs_dir=copy_s,
|
||||||
bundle_dir=copy_s,
|
bundle_dir=copy_s,
|
||||||
|
|
|
||||||
163
tests/test_content_keyed_script_loadbearing.py
Normal file
163
tests/test_content_keyed_script_loadbearing.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""The scripted demo proposer is DATA, keyed on the project the prompt names (demo-week plan §3
|
||||||
|
Monday, §4 risk 2): adding a project to the walkthrough must be a registry entry, never a
|
||||||
|
hand-written second script under time pressure.
|
||||||
|
|
||||||
|
**The open decision this file closes — measured, not assumed.** The plan (§6) stated explicitly that
|
||||||
|
"the candidate is uniquely identifiable in the blob" was UNVERIFIED. Dumping every blob that reaches
|
||||||
|
the ``reply_selector`` across a full two-run simulation shows two prompt shapes:
|
||||||
|
|
||||||
|
* the DEBATE prompt (``run.py``: ``"Find a cost-saving measure for {project.id}.\\nContext:\\n..."``)
|
||||||
|
— the whole bundle context, ~12k chars;
|
||||||
|
* the GENERATION prompt (``generate._build_messages``: ``"Project: {id} - {name}"`` plus, as its
|
||||||
|
context, the DEBATE OUTPUT).
|
||||||
|
|
||||||
|
The cost code and the measure name reach the *generation* prompt only because the scripted reply
|
||||||
|
itself is echoed back as ``debate_output`` — keying on them would key the script on its own output.
|
||||||
|
The project id is the one identifier that BOTH shapes carry and that the FRAMEWORK stamps. So the
|
||||||
|
project id is the key, and these tests pin that decision.
|
||||||
|
|
||||||
|
The load-bearing set:
|
||||||
|
- per-project keying (RED on any proposer that ignores the prompt);
|
||||||
|
- the flip key is scoped to its own candidate (RED if one global flip token is shared, which would
|
||||||
|
make one project's falsification correct another project's proposal);
|
||||||
|
- unknown and ambiguous prompts FAIL LOUD (RED on a first-match/default-fallback implementation —
|
||||||
|
the silent-wrong-script failure this whole seam exists to prevent);
|
||||||
|
- the wiring: the simulation's own proposer is the registry-driven one (RED the moment it reverts to
|
||||||
|
two hard-coded constants — the seam would exist while the demo still ran off a hand-written
|
||||||
|
script);
|
||||||
|
- the data-entry rule: a candidate's flip key must be ABSENT from the bundle it is demoed against,
|
||||||
|
or attempt 1's prompt already contains it and the correction proves nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from portfolio_optimiser import simulation
|
||||||
|
from portfolio_optimiser.simulation import (
|
||||||
|
ScriptedCandidate,
|
||||||
|
ScriptedCandidateError,
|
||||||
|
scripted_proposer,
|
||||||
|
simulate_learning_loop,
|
||||||
|
)
|
||||||
|
|
||||||
|
_BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||||
|
|
||||||
|
# A two-entry registry: the whole point is that a SECOND project is data. The claims are distinct
|
||||||
|
# per candidate so an assert can tell whose script answered, and the flip keys are distinct so a
|
||||||
|
# leaked global flip token is observable.
|
||||||
|
_ALFA = ScriptedCandidate(
|
||||||
|
project_id="DEMO-ALFA",
|
||||||
|
overclaimed='{"measure":"alfa","affected_items":[],"claimed_saving_nok":111111}',
|
||||||
|
corrected='{"measure":"alfa","affected_items":[],"claimed_saving_nok":11}',
|
||||||
|
flip_key="111111",
|
||||||
|
)
|
||||||
|
_BETA = ScriptedCandidate(
|
||||||
|
project_id="DEMO-BETA",
|
||||||
|
overclaimed='{"measure":"beta","affected_items":[],"claimed_saving_nok":222222}',
|
||||||
|
corrected='{"measure":"beta","affected_items":[],"claimed_saving_nok":22}',
|
||||||
|
flip_key="222222",
|
||||||
|
)
|
||||||
|
_REGISTRY = (_ALFA, _BETA)
|
||||||
|
|
||||||
|
|
||||||
|
def _generation_prompt(project_id: str, *, tail: str = "") -> str:
|
||||||
|
"""A prompt in the measured shape of ``generate._build_messages`` — the framework stamps the
|
||||||
|
project id; ``tail`` stands in for the appended rejection block."""
|
||||||
|
return (
|
||||||
|
"Propose ONE concrete cost-saving measure for this project.\n"
|
||||||
|
f"Project: {project_id} - Et prosjekt\n"
|
||||||
|
"Context (prior verdicts / cited cost docs):\n\n" + tail
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reply_is_keyed_on_the_project_the_prompt_names() -> None:
|
||||||
|
"""LOAD-BEARING: one registry, two projects, two different scripts — selected by the project id
|
||||||
|
the framework stamped into the prompt. RED on a proposer that returns a constant, which is
|
||||||
|
exactly the hand-written-script state this replaces."""
|
||||||
|
proposer = scripted_proposer(_REGISTRY)
|
||||||
|
|
||||||
|
assert proposer(_generation_prompt("DEMO-ALFA"), "proposer") == _ALFA.overclaimed
|
||||||
|
assert proposer(_generation_prompt("DEMO-BETA"), "proposer") == _BETA.overclaimed
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_falsification_flips_only_its_own_candidate() -> None:
|
||||||
|
"""LOAD-BEARING: the flip key belongs to the candidate, not to the module. A prompt that names
|
||||||
|
BETA but carries ALFA's rejected figure must still get BETA's *overclaimed* reply — otherwise one
|
||||||
|
project's falsification would silently correct another project's proposal, and Step 5 would show
|
||||||
|
a correction nothing caused. RED on a single shared flip token.
|
||||||
|
|
||||||
|
**The prompt names the SECOND registry entry on purpose** (measured): the obvious wrong
|
||||||
|
implementation reads ``candidates[0].flip_key``, and asserting on the first entry cannot tell
|
||||||
|
that apart from reading the matched candidate's — the two coincide there. A test that cannot
|
||||||
|
separate two implementations proves nothing, so the assert is made where they diverge."""
|
||||||
|
proposer = scripted_proposer(_REGISTRY)
|
||||||
|
|
||||||
|
assert proposer(_generation_prompt("DEMO-BETA", tail=_ALFA.flip_key), "proposer") == (
|
||||||
|
_BETA.overclaimed
|
||||||
|
)
|
||||||
|
# Control: BETA's OWN key does flip it, so the assert above is not merely observing a proposer
|
||||||
|
# that never corrects at all.
|
||||||
|
assert proposer(_generation_prompt("DEMO-BETA", tail=_BETA.flip_key), "proposer") == (
|
||||||
|
_BETA.corrected
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unregistered_project_fails_loud() -> None:
|
||||||
|
"""CONTROL: no default, no first-match fallback. A project without a registry entry must raise —
|
||||||
|
a demo that silently answers with ANOTHER project's numbers is worse than one that stops, because
|
||||||
|
the numbers would look plausible on screen. RED on a fallback implementation."""
|
||||||
|
proposer = scripted_proposer(_REGISTRY)
|
||||||
|
|
||||||
|
with pytest.raises(ScriptedCandidateError, match="DEMO-GAMMA|no scripted candidate"):
|
||||||
|
proposer(_generation_prompt("DEMO-GAMMA"), "proposer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ambiguous_prompt_fails_loud() -> None:
|
||||||
|
"""CONTROL: two registered ids in one blob is undecidable, so it must raise rather than pick.
|
||||||
|
This is reachable for real — a bundle's context can mention a sibling project — and the fix is
|
||||||
|
the DATA (distinct ids), which is why the failure must be visible at rehearsal, not at the demo."""
|
||||||
|
proposer = scripted_proposer(_REGISTRY)
|
||||||
|
both = _generation_prompt("DEMO-ALFA") + "\nSe også DEMO-BETA.\n"
|
||||||
|
|
||||||
|
with pytest.raises(ScriptedCandidateError, match="ambiguous|DEMO-BETA"):
|
||||||
|
proposer(both, "proposer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_simulation_proposer_is_registry_driven() -> None:
|
||||||
|
"""WIRING: the seam must be the one the DEMO runs on. RED the moment ``_proposer_reply`` reverts
|
||||||
|
to two hard-coded constants — a constant proposer answers an unknown project happily."""
|
||||||
|
assert any(c.project_id == simulation._PROJECT_ID for c in simulation._CANDIDATES), (
|
||||||
|
"the demo project has no registry entry — the walkthrough would raise at the first turn"
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ScriptedCandidateError):
|
||||||
|
simulation._proposer_reply(_generation_prompt("IKKE-REGISTRERT"), "proposer")
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_candidates_flip_key_is_absent_from_the_demo_bundle() -> None:
|
||||||
|
"""DATA-ENTRY RULE: the flip key is what tells the scripted proposer that the validator's
|
||||||
|
rejection came back. If it already occurs in the bundle, attempt 1's prompt carries it, the
|
||||||
|
proposer 'corrects' before anything was falsified, and Step 5 shows a correction with no cause.
|
||||||
|
Checked against the demo bundle for every registered candidate, so adding an entry with a
|
||||||
|
colliding key is caught here rather than on stage."""
|
||||||
|
corpus = "\n".join(p.read_text("utf-8") for p in sorted(_BUNDLE_DIR.rglob("*")) if p.is_file())
|
||||||
|
assert corpus, "control: the bundle was read, so an absence assert below means something"
|
||||||
|
|
||||||
|
for candidate in simulation._CANDIDATES:
|
||||||
|
assert candidate.flip_key not in corpus, (
|
||||||
|
f"{candidate.project_id}: flip key {candidate.flip_key!r} occurs in the demo bundle, so "
|
||||||
|
"attempt 1's prompt already contains it and the correction proves nothing"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_project_id_is_data_too(tmp_path: Path) -> None:
|
||||||
|
"""A new bundle is pointed at by ARGUMENT, project id included — otherwise Tuesday's content
|
||||||
|
swap still needs a code edit. RED if ``project_id`` is ignored and the module constant is used:
|
||||||
|
the bundle's own IR projection would then match and no error would surface."""
|
||||||
|
with pytest.raises(ValueError, match="project_id"):
|
||||||
|
await simulate_learning_loop(
|
||||||
|
str(_BUNDLE_DIR), str(tmp_path), project_id="ET-ANNET-PROSJEKT"
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue