feat(simulation): the demo's gate is anchored to real cost lines (P4 pkt. 0)

The validator can reconcile a proposal against the project's actual cost lines
(S4.0 stage 0), but only when the knowledge base ships a cost-baseline.json —
and no bundle under shared/ has one. So on stage the gate reasoned only about
numbers the proposal supplied itself.

The reserve can never receive the file in shared/ (pull-only subtree, and demo
criterion 8 requires the goldens byte-unchanged). That is a placement
constraint, not an impossibility: materialize_anchored_bundle copies the bundle
and adds the file outside shared/, and the run path reads it through exactly
the seam a delivered bundle would use.

The baseline is DERIVED IN CODE from the scripted register, never typed beside
it — two sources of the same numbers drift, and drift is precisely what the
10 % probe models. On GO day the direction reverses (plan P3 b). Both scripted
replies must state the same cost lines or ValueError: were they to differ,
hypothesis #1 would be falsified by stage 0 instead of by P90 — the same
REJECTED line on screen, a different mechanism behind it.

10 % probe, measured: baseline x 1.10 -> FORKASTET at stage 0, before the
solver; corrected -> FORESLÅTT. Criterion 6 re-measured (stdout byte-identical
across two runs); stderr unchanged at 6 lines. The ONLY diff against the
un-anchored demo is the new KUNNSKAPSBASE block — everything else is
byte-identical, which is the problem: an anchoring nobody can see is one nobody
can check. Hence it is printed, and hence `provenance` is a required argument.
769 -> 775 passed.

Five mutations red + green control. The measurement failed the TEST first:
"ingen kostbaseline erklært" CONTAINS "kostbaseline erklært", and
ENERGI-TOTAL-EL already appears in the Step-2 line, so both assertions survived
the detach mutation. The two branches now share no wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GD6Y2Y23NZZxPYtSRoCmst
This commit is contained in:
Kjell Tore Guttormsen 2026-08-09 14:35:07 +02:00
commit 1522e2aaaa
5 changed files with 364 additions and 4 deletions

View file

@ -20,6 +20,7 @@ client is MAF-side scaffolding; it is NOT part of the framework-neutral ``shared
from __future__ import annotations
import json
import shutil
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
@ -36,7 +37,8 @@ from agent_framework import (
)
from agent_framework_openai import OpenAIChatCompletionClient
from portfolio_optimiser.ir import SavingsProposal
from portfolio_optimiser import okf
from portfolio_optimiser.ir import AffectedItem, CostBaseline, CostBaselineLine, SavingsProposal
from portfolio_optimiser.persona import load_persona_example
from portfolio_optimiser.run import RunResult, run_project
from portfolio_optimiser.shared_root import shared_root
@ -167,6 +169,89 @@ _CANDIDATES: tuple[ScriptedCandidate, ...] = (
_proposer_reply = scripted_proposer(_CANDIDATES)
# The filename ``okf``'s loaders default to. Kept local rather than reaching into ``okf``'s private
# constant; the coupling is measured, not assumed — a drifted name makes the materialized bundle
# un-anchored, which ``test_anchored_reserve_loadbearing`` turns red.
_COST_BASELINE_FILE = "cost-baseline.json"
_ANCHORED_DIR_NAME = "forankret-reserve"
_RESERVE_PROVENANCE = "tallene er syntetiske — avledet av demo-manuset, ikke levert av et fagmiljø"
def baseline_from_scripted_candidate(candidate: ScriptedCandidate) -> CostBaseline:
"""Derive a project's cost baseline FROM the scripted register's own cost lines (P4 pkt. 0).
The reserve bundle's numbers are synthetic, so the script is the only ground truth there is;
deriving in code rather than typing the same numbers into a second file is what keeps the two
from drifting apart. **On GO day the direction reverses** (plan P3 b): the register is written
FROM the delivered ``cost-baseline.json``, and this function is not used.
Both scripted replies must state the SAME cost lines, or ``ValueError``. Validation, never
repair: were they to differ, hypothesis #1 would be falsified by the reconciliation stage
instead of by the P90 stage, and the demo's REJECTED line would come from another mechanism
than the one it narrates visible on screen as the same line either way.
"""
lines = {
item.code: CostBaselineLine(quantity=item.quantity, unit_cost=item.unit_cost)
for item in (
AffectedItem.model_validate(raw)
for raw in json.loads(candidate.corrected)["affected_items"]
)
}
overclaimed = {
raw["code"]: (raw["quantity"], raw["unit_cost"])
for raw in json.loads(candidate.overclaimed)["affected_items"]
}
if overclaimed != {code: (line.quantity, line.unit_cost) for code, line in lines.items()}:
raise ValueError(
f"scripted candidate {candidate.project_id} states different cost lines in its two "
"replies; a baseline derived from one of them would falsify the other at stage 0"
)
return CostBaseline(project_id=candidate.project_id, items=lines)
def _reserve_baseline() -> CostBaseline:
"""The reserve bundle's baseline: the registry entry for the reserve's project, never
``_CANDIDATES[0]`` the registry is a set of DATA entries whose order carries no meaning, and
an index would silently anchor the demo to another project once a second entry lands."""
(candidate,) = [c for c in _CANDIDATES if c.project_id == _PROJECT_ID]
return baseline_from_scripted_candidate(candidate)
def materialize_anchored_bundle(
dest: str | Path,
*,
source: str | Path | None = None,
baseline: CostBaseline | None = None,
) -> Path:
"""Copy the reserve bundle and ADD the ``cost-baseline.json`` it cannot be given in place — the
copy-and-extend variant that anchors the deterministic gate (S4.0 stage 0) for the demo.
``shared/`` is a pull-only subtree and demo criterion 8 requires the commons-owned goldens
byte-unchanged, so the reserve can never ship the file itself. That is a PLACEMENT constraint,
not an impossibility: the run already reads the baseline from whichever bundle directory it is
handed (``run.py`` -> ``okf.load_optional_cost_baseline``), so an extended copy outside
``shared/`` is anchored by exactly the same seam a delivered bundle would use.
``baseline`` defaults to the one derived from the scripted register; a caller passes its own to
model a DELIVERED baseline that disagrees with the script (the 10 % test).
"""
src = Path(source) if source is not None else _default_bundle_dir()
out = Path(dest)
shutil.copytree(src, out)
resolved = baseline if baseline is not None else _reserve_baseline()
payload = {
"_note": (
"SYNTHETIC cost baseline, materialized for the demo (P4 pkt. 0) — NOT delivered data. "
"Derived from the scripted register in portfolio_optimiser.simulation so the two "
"cannot drift. The source bundle is never modified."
),
**resolved.model_dump(),
}
(out / _COST_BASELINE_FILE).write_text(
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
return out
# The checker's debate turn ends with the gate marker the run parses (run._checker_verdict).
_CHECKER_APPROVE = "Tallene er innenfor feasibelt område og resonnementet holder. VERDICT: APPROVE"
@ -550,6 +635,37 @@ def _run_trace_lines(result: RunResult, *, marker: str, marker_in_prompt: bool)
return lines
def _baseline_lines(bundle_dir: Path, provenance: str) -> list[str]:
"""What the knowledge base DECLARES about the project's own cost lines.
Read off the BUNDLE, not off a ``RunResult`` which is why it is printed by ``main`` and not by
``_run_trace_lines`` (whose contract is that every value comes from the run). Whether the run
then USES the baseline is not something a screen can show: every other line of the demo is
byte-identical anchored or not, so that property is measured by
``tests/test_anchored_reserve_loadbearing.py`` instead.
``provenance`` is a required argument, not a default: the caller who chooses the bundle is the
only one who knows where its numbers came from, and saying so is the honesty claim itself.
The two branches share no wording (measured: an "ingen kostbaseline erklært" phrasing CONTAINS
"kostbaseline erklært", which made the entry-point test pass with the anchoring detached)."""
baseline = okf.load_optional_cost_baseline(str(bundle_dir))
if baseline is None:
return [
f"KUNNSKAPSBASE: {bundle_dir.name} — uten kostbaseline",
" validatoren regner kun på tallene forslaget selv oppgir",
]
items = ", ".join(
f"{code} {line.quantity:g} x {line.unit_cost:g}"
for code, line in sorted(baseline.items.items())
)
return [
f"KUNNSKAPSBASE: {bundle_dir.name} — kostbaseline erklært ({items})",
" validatorens stage 0 avstemmer forslagets kostlinjer mot disse, FØR løseren",
f" {provenance}",
]
def main(argv: list[str] | None = None) -> int: # pragma: no cover - console trace
"""Run the simulation against the energi bundle in a throwaway temp dir and print an honest,
readable trace. Invoke: ``uv run python -m portfolio_optimiser.simulation``."""
@ -558,7 +674,12 @@ def main(argv: list[str] | None = None) -> int: # pragma: no cover - console tr
import tempfile
work = tempfile.mkdtemp(prefix="po-sim-")
result = asyncio.run(simulate_learning_loop(str(_default_bundle_dir()), work))
# THE call site (P4 pkt. 0): the demo runs the ANCHORED reserve — the shared bundle plus the
# cost baseline it cannot be given in place. On GO day these two lines point at the delivered
# bundle and state ITS provenance instead; the run path's seam is the same either way.
bundle = materialize_anchored_bundle(Path(work) / _ANCHORED_DIR_NAME)
provenance = _RESERVE_PROVENANCE
result = asyncio.run(simulate_learning_loop(str(bundle), work))
print("=" * 78)
print("OFFLINE SIMULERING — skriptede agent-svar, INGEN ekte modell.")
@ -566,6 +687,10 @@ def main(argv: list[str] | None = None) -> int: # pragma: no cover - console tr
print("Beviser IKKE at en levende modell ville produsert dette — forslag og dom er skriptet.")
print("=" * 78)
print()
for line in _baseline_lines(bundle, provenance):
print(line)
# Run A walks steps 1-7 of the method; the promotion between the runs IS step 8. Run B is not
# re-numbered — it re-runs the same eight steps, and what the demo needs from it is the ONE
# thing that changed: the marker now reaches the hypothesis prompt.