Kriterium 6 er selv-identitet: to kjøringer av en regredert demo er like enige som to av en riktig. Fasiten forlater derfor prosessen. stdout pinnes ORDRETT (og er dermed demoens abortsti); stderr normaliseres på nøyaktig to MÅLTE miljø-spann — site-packages-prefikset og temp-katalogen — med po-sim- holdt synlig, fordi det er en egenskap ved programmet og ikke ved miljøet. Pinnet stderr = fire linjer. Kontrollen som forbyr at masken vokser er load-bearing: en droppende normaliserer med fasiten regenerert under seg holder BEGGE likhets-testene grønne. Pkt. 4: planens forhåndsskrevne frø-setning sa «én av de TO tidligere dommene». Målt mot levert VEGLYS-bundle henter Kjøring B TRE — én fulgte med kunnskapsbasen, to er demoens egne, én per tidsskala. Splitten avledes derfor fra kjøringen; en håndskrevet «én av tre» ville vært den andre kopien som drifter. Fem mutasjoner alle røde + grønn kontroll (hele suiten hver gang): ett byte i en stdout-linje · detach dempingen · over-normaliser stderr · literal splitt · detach frø-setningens print. Byte- og detach-mutasjonene ble fanget av KUN golden-testen; den literale splitten av KUN skille-testen. 793 -> 801 passed / 4 skipped.
907 lines
45 KiB
Python
907 lines
45 KiB
Python
"""Offline simulation of the full agentic loop — the end-to-end method proof (replaces målbilde
|
|
§11.8's real-model run).
|
|
|
|
**Operator decision (pragmatic, cost-driven):** MAF is NOT run against a real model — neither
|
|
Azure/Foundry nor local Ollama — because paying for API runs across both repos (MAF + the
|
|
Claude-SDK sibling) is too costly privately. This module is the primary proof instead: it drives
|
|
``run_project`` with a **scripted** synthetic chat client (no network, no model) and demonstrates
|
|
that the loop's dataflow closes end to end across two runs separated by a promotion:
|
|
|
|
context -> hypothesis -> maker/checker debate -> deterministic validator -> persona verdict
|
|
-> PROMOTION into the OKF wiki -> the next run's hypothesis is informed by it.
|
|
|
|
**What this proves:** the plumbing, the deterministic spine, and that the learning loop closes —
|
|
a verdict approved in Run A reaches Run B's hypothesis prompt purely through the file-backed wiki.
|
|
**What it does NOT prove (honesty, målbilde §1):** that a live LLM would *produce* the proposal or
|
|
the verdict unprompted — those are scripted stand-ins for the swarm and the expert persona. The
|
|
genuine model-behaviour comparison lives on the Claude-SDK side (a minimal API run). The scripted
|
|
client is MAF-side scaffolding; it is NOT part of the framework-neutral ``shared/`` core.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import shutil
|
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
from agent_framework import (
|
|
BaseChatClient,
|
|
ChatResponse,
|
|
ChatResponseUpdate,
|
|
Message,
|
|
ResponseStream,
|
|
UsageDetails,
|
|
)
|
|
from agent_framework_openai import OpenAIChatCompletionClient
|
|
|
|
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
|
|
from portfolio_optimiser.validator import ValidatedProposal
|
|
from portfolio_optimiser.verdicts import (
|
|
Verdict,
|
|
VerdictStore,
|
|
bundle_candidate_features,
|
|
promote_verdict,
|
|
seed_store_from_bundle,
|
|
write_verdict,
|
|
)
|
|
|
|
_PROJECT_ID = "BYGG-KONTOR-NORD"
|
|
# The delivered domain bundle (commons `002f000`) — the project the demo runs on stage. Kept beside
|
|
# the reserve's id rather than replacing it: the reserve stays reachable as the abort path.
|
|
_VEGLYS_PROJECT_ID = "VEGLYS-FV-SOER"
|
|
|
|
# --- P4 pkt. 2: demo stderr discipline -----------------------------------------------------------
|
|
# Measured 2026-08-09, the demo wrote six stderr lines: two ``ExperimentalWarning``s from
|
|
# ``agent_framework``, two round-cap notices from the orchestrator, a blank line and the
|
|
# ``arbeidskopi:`` path. The last two are ours and stay (the path is the one deliberately
|
|
# non-deterministic value, which is exactly why it is on stderr and not on stdout).
|
|
#
|
|
# **Damped here: the round-cap notices only.** They are emitted DURING the demo's own run, by an
|
|
# event the demo deliberately provokes — the maker/checker debate is configured to run to its cap.
|
|
#
|
|
# **NOT damped: the two ``ExperimentalWarning``s — they are pinned in pkt. 3 instead.** Measured,
|
|
# not assumed: they fire while ``portfolio_optimiser/__init__.py`` imports ``run``, which imports
|
|
# ``agent_framework`` — always before this module's own import block, under BOTH invocation forms.
|
|
# Silencing them would therefore mean putting a warnings filter inside the library package, i.e.
|
|
# letting this framework decide what MAF is allowed to tell every consumer that imports it. That is
|
|
# a library-behaviour change for stderr nobody projects, days before a freeze. A wrapper that
|
|
# muted them only behind the console script was rejected for a second reason: it would make
|
|
# ``uv run portfolio-optimiser-demo`` and ``uv run python -m portfolio_optimiser.simulation`` write
|
|
# different stderr, and a byte-fasit would then pin the command rather than the program.
|
|
#
|
|
# The damping that IS here is NARROW by construction, which is the load-bearing part: a different
|
|
# orchestration warning still reaches stderr and trips the pkt. 3 pin. A filter that could only
|
|
# ever say "drop" would leave a pin that can no longer fail for the reason it exists.
|
|
|
|
#: The logger that emits the round-cap notice, read out of MAF's source (``logger.warning`` in
|
|
#: ``_base_group_chat_orchestrator``), not guessed. Logger filters apply only to the logger the
|
|
#: record was logged THROUGH — an ancestor's filters are never consulted — so this must be the
|
|
#: emitting module's own name. If MAF moves the call, the notice simply reappears on stderr and the
|
|
#: pkt. 3 pin says so; the failure mode is visible, not silent.
|
|
ROUND_CAP_LOGGER = "agent_framework_orchestrations._base_group_chat_orchestrator"
|
|
|
|
|
|
class _ExpectedRoundCapFilter(logging.Filter):
|
|
"""Drops ONLY the "round limit reached" notice, which the demo reaches by design (the
|
|
maker/checker debate runs to its cap). Keyed on the event, not on the configured number: the
|
|
cap is a demo setting, while the notice is the thing we have decided is expected."""
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
message = record.getMessage()
|
|
return not ("reached max_rounds=" in message and "forcing completion" in message)
|
|
|
|
|
|
def quiet_expected_round_cap_notice() -> logging.Filter:
|
|
"""Install the round-cap filter on the emitting logger and return it (so a caller — a test —
|
|
can remove it again). Runtime state, installed by ``main()``: importing this module must not
|
|
reconfigure logging for a library consumer."""
|
|
installed = _ExpectedRoundCapFilter()
|
|
logging.getLogger(ROUND_CAP_LOGGER).addFilter(installed)
|
|
return installed
|
|
|
|
|
|
# --- Step 7, the LONG loop: what an expert drops into the inbox between the two runs -------------
|
|
# A SECOND marker, deliberately distinct from the persona's (Step 8, promotion). The two mechanisms
|
|
# both end in Run B's hypothesis prompt, so a single shared marker would let either path carry it
|
|
# alone — and detaching one seam would then go unnoticed by both load-bearing tests. Absence from
|
|
# the bundle is asserted by tests/test_step7_demo_inbox_loadbearing.py, not assumed here.
|
|
_INBOX_MARKER = "realiseringsgrad=0.66"
|
|
# An explicit sentinel id, never a minted one: ``_mint_id`` hashes the candidate FEATURES, so a
|
|
# minted id would collide with the promoted verdict's — and ``VerdictStore.add`` is first-write-wins
|
|
# per id, which would silently drop whichever arrived second.
|
|
_INBOX_VERDICT_ID = "STEG7-EKSPERT-DRIFTSNOTAT"
|
|
_INBOX_RATIONALE = (
|
|
"Ettersendt driftsnotat fra energiingeniør, lagt i innboksen etter kjøringen: målingene "
|
|
f"fra fyringssesongen viser at realiseringen faller til {_INBOX_MARKER} når "
|
|
"tilstedeværelsesstyringen står på fabrikkinnstilling. Godkjent, med den korreksjonen."
|
|
)
|
|
_INBOX_DIR_NAME = "verdict-innboks"
|
|
|
|
|
|
def _default_bundle_dir() -> Path:
|
|
"""The demo bundle under the shared core, resolved at CALL time via ``shared_root()`` (env
|
|
``PORTFOLIO_SHARED_ROOT`` re-points it — the S4 extraction seam)."""
|
|
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
|
|
# 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
|
|
# 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
|
|
# genuinely computed by the validator, not scripted.
|
|
#
|
|
# 250000 — the overclaimed figure the validator's rejection reason carries and
|
|
# ``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
|
|
# falsification travelling back, never by the proposer simply being asked twice.
|
|
_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",
|
|
),
|
|
# VEGLYS-FV-SOER — the DELIVERED bundle (commons `002f000`), and the one on stage. Every number
|
|
# below is written FROM `shared/examples/veglys-fv-soer/validator-input.json`, never beside it
|
|
# (plan P3 b): this is `baseline_from_scripted_candidate`'s direction REVERSED — the domain team
|
|
# shipped the cost lines, so the register copies them, and the derivation helper is not used on
|
|
# this path (`main` reads the delivered `cost-baseline.json` off the bundle instead).
|
|
#
|
|
# The corrected reply IS the delivered IR projection verbatim: affected_items = the WHOLE
|
|
# portfolio's annual energy cost (their decision — scoping it to the 2 500 touched points would
|
|
# make the RIGHT proposal 38,6 % of its own baseline and the 30 % cap would fell it on stage),
|
|
# claimed_saving_nok = 445500, assumptions = the declared energy-price band. The overclaimed one
|
|
# differs in exactly ONE field.
|
|
#
|
|
# 2100000 is chosen OUTSIDE commons' number inventory and above BOTH thresholds: measured absent
|
|
# from the bundle (so the correction is caused by the falsification travelling back), and above
|
|
# the degenerate cap 0.30 x 4386150 = 1315845 as well as the banded P90. 600000/900000 would have
|
|
# CLEARED the gate — the REJECTED line would never appear and demo-criterion 2 would fail quietly.
|
|
ScriptedCandidate(
|
|
project_id=_VEGLYS_PROJECT_ID,
|
|
overclaimed=(
|
|
'{"measure":"LED-utskifting av 2 500 eldre HPS-armaturer (114 W -> 70 W)",'
|
|
'"affected_items":[{"code":"ENERGI-VEGLYS-EL","quantity":4386150,"unit_cost":1.0}],'
|
|
'"claimed_saving_nok":2100000,'
|
|
'"assumptions":{"ENERGI-VEGLYS-EL":[0.70,1.40]}}'
|
|
),
|
|
corrected=(
|
|
'{"measure":"LED-utskifting av 2 500 eldre HPS-armaturer (114 W -> 70 W)",'
|
|
'"affected_items":[{"code":"ENERGI-VEGLYS-EL","quantity":4386150,"unit_cost":1.0}],'
|
|
'"claimed_saving_nok":445500,'
|
|
'"assumptions":{"ENERGI-VEGLYS-EL":[0.70,1.40]}}'
|
|
),
|
|
flip_key="2100000",
|
|
),
|
|
)
|
|
|
|
_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ø"
|
|
# The GO-day honesty sentence (plan P4 pkt. 4). It claims exactly what the bundle's own `_note`
|
|
# documents — the cost line is derived from published sources — and nothing about the realization
|
|
# rate, which the seed verdict itself marks as BORROWED from lighting-programme literature.
|
|
_VEGLYS_PROVENANCE = (
|
|
"tallene er levert i kunnskapsbasen — utledet av fagkilder (Håndbok V124, NMFV), "
|
|
"ikke av demo-manuset"
|
|
)
|
|
|
|
|
|
def _delivered_bundle_dir() -> Path:
|
|
"""The delivered VEGLYS bundle, resolved at CALL time via ``shared_root()`` (same seam as
|
|
``_default_bundle_dir``). It ships its own ``cost-baseline.json``, so the demo is anchored
|
|
WITHOUT ``materialize_anchored_bundle`` — that helper exists for the reserve, whose numbers
|
|
cannot be given a baseline in place (pull-only subtree + byte-unchanged goldens)."""
|
|
return shared_root() / "examples" / "veglys-fv-soer"
|
|
|
|
|
|
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"
|
|
|
|
# The persona's verdict is sourced from the shared expert-reviewer skill (``load_persona_example``),
|
|
# NOT inlined here — that de-stubs the persona and makes the shared artifact genuinely consumed. Its
|
|
# marker (a realization rate ABSENT from the bundle — the seed is 0.82) is the payload we trace from
|
|
# Run A's persona judgement, through promotion, into Run B's hypothesis prompt.
|
|
|
|
|
|
class ScriptedChatClient(OpenAIChatCompletionClient):
|
|
"""The ONE canonical network-free scripted chat client (S2.5 consolidation): a single
|
|
``_inner_get_response`` body shared by the simulation's fixed-reply client AND conftest's three
|
|
test doubles (which subclass it). Parametrized by a ``reply_selector`` over
|
|
``(prompt_blob, role)`` plus an optional ``sink`` recording every prompt — the four
|
|
previously-divergent ``_inner_get_response`` bodies collapse to this one.
|
|
|
|
Subclasses the LAYERED ``OpenAIChatCompletionClient`` (not the minimal ``BaseChatClient``) so the
|
|
always-attached ``BudgetMiddleware`` is not silently no-op'd (verified). Construction is offline
|
|
(loopback ``base_url`` + dummy key); ``_inner_get_response`` intercepts before any HTTP.
|
|
|
|
Back-compat constructors are preserved (divergent PUBLIC surfaces the external test call-sites
|
|
depend on): ``ScriptedChatClient(reply, sink)`` (POSITIONAL — used by ``scripted_factory``) is
|
|
sugar for a constant selector; the ``call_count`` attribute + ``model``/OTEL ``"synthetic"`` are
|
|
always present; subclasses pass ``reply_selector=`` / ``default_reply=`` for scripted-list,
|
|
prompt-scan, or record-only behaviour."""
|
|
|
|
OTEL_PROVIDER_NAME = "synthetic"
|
|
|
|
def __init__(
|
|
self,
|
|
reply: str | None = None,
|
|
sink: list[str] | None = None,
|
|
*,
|
|
reply_selector: Callable[[str, str], str] | None = None,
|
|
role: str = "",
|
|
default_reply: str = "ok",
|
|
tokens_per_reply: int = 8,
|
|
) -> None:
|
|
super().__init__(model="synthetic", api_key="synthetic", base_url="http://127.0.0.1:9/v1")
|
|
self._sink = sink
|
|
self._role = role
|
|
self._default = default_reply
|
|
# The reply-selector over (prompt_blob, role). A positional ``reply`` is sugar for a constant
|
|
# selector (scripted_factory back-compat); with neither, the constant is ``default_reply``.
|
|
if reply_selector is not None:
|
|
self._select: Callable[[str, str], str] = reply_selector
|
|
elif reply is not None:
|
|
self._select = lambda _prompt, _role: reply
|
|
else:
|
|
self._select = lambda _prompt, _role: self._default
|
|
self._tokens = tokens_per_reply
|
|
self.call_count = 0
|
|
|
|
def _inner_get_response(
|
|
self,
|
|
*,
|
|
messages: Sequence[Message],
|
|
options: Mapping[str, Any],
|
|
stream: bool = False,
|
|
**kwargs: Any,
|
|
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
|
|
blob = " ".join(getattr(m, "text", "") or "" for m in messages)
|
|
if self._sink is not None:
|
|
self._sink.append(blob)
|
|
self.call_count += 1
|
|
reply = self._select(blob, self._role)
|
|
usage = UsageDetails(total_token_count=self._tokens)
|
|
if stream:
|
|
|
|
async def _agen() -> Any:
|
|
# The framework accepts a {"type": "text", ...} dict here (its types under-specify it).
|
|
yield ChatResponseUpdate(
|
|
role="assistant",
|
|
contents=[{"type": "text", "text": reply}], # type: ignore[list-item]
|
|
)
|
|
|
|
return self._build_response_stream(_agen())
|
|
|
|
async def _coro() -> ChatResponse:
|
|
return ChatResponse(
|
|
messages=[Message(role="assistant", contents=[reply])],
|
|
response_id="synthetic",
|
|
usage_details=usage,
|
|
)
|
|
|
|
return _coro()
|
|
|
|
|
|
def scripted_factory(
|
|
replies: Mapping[str, str | Callable[[str, str], str]], sink: list[str]
|
|
) -> Callable[[str], BaseChatClient]:
|
|
"""A role-keyed client factory: ``factory("proposer")`` and ``factory("checker")`` each return a
|
|
fresh ``ScriptedChatClient`` with that role's reply, all sharing ONE ``sink``. MAF stamps the
|
|
proposer/checker identity from the agent name, so role-keyed stateless replies suffice (no
|
|
per-turn counter); the shared ``sink`` spans the debate turns and the generation call.
|
|
|
|
A role's value may be a constant reply OR a ``reply_selector`` over ``(prompt_blob, role)`` —
|
|
the canonical client's existing seam, passed straight through. That is what lets a role answer
|
|
DIFFERENTLY on a later attempt (Step 5: the proposer corrects once the validator's rejection
|
|
comes back in the prompt) without a per-turn counter and without a second scripted body."""
|
|
|
|
def factory(role: str) -> BaseChatClient:
|
|
reply = replies[role]
|
|
if callable(reply):
|
|
return ScriptedChatClient(sink=sink, role=role, reply_selector=reply)
|
|
return ScriptedChatClient(reply, sink, role=role)
|
|
|
|
return factory
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LearningSimulationResult:
|
|
"""The trace of one two-run learning simulation, carrying BOTH feedback timescales.
|
|
|
|
``marker_in_run_b_prompt`` true while ``marker_in_run_a_prompt`` false is the Step-8 loop: the
|
|
persona knowledge approved in Run A reached Run B's hypothesis via promotion into the wiki.
|
|
``inbox_marker_*`` is the same shape for the Step-7 LONG loop: a verdict file an expert dropped
|
|
into a folder after Run A, merged into Run B's store before the fold. The two are kept apart on
|
|
purpose — see ``tests/test_step7_demo_inbox_loadbearing.py``."""
|
|
|
|
run_a: RunResult
|
|
run_b: RunResult
|
|
promoted_path: Path
|
|
marker: str
|
|
marker_in_run_a_prompt: bool
|
|
marker_in_run_b_prompt: bool
|
|
inbox_path: Path
|
|
inbox_marker: str
|
|
inbox_marker_in_run_a_prompt: bool
|
|
inbox_marker_in_run_b_prompt: bool
|
|
run_a_generation_prompts: list[str]
|
|
run_b_generation_prompts: list[str]
|
|
|
|
|
|
def _generation_prompts(sink: list[str]) -> list[str]:
|
|
"""The generation-call prompts (``generate._build_messages`` embeds 'SavingsProposal'), isolated
|
|
from the debate-round prompts also captured in the shared sink."""
|
|
return [p for p in sink if "SavingsProposal" in p]
|
|
|
|
|
|
async def simulate_learning_loop(
|
|
bundle_dir: str,
|
|
work_dir: str,
|
|
*,
|
|
project_id: str = _PROJECT_ID,
|
|
persona_rationale: str | None = None,
|
|
marker: str | None = None,
|
|
inbox_rationale: str = _INBOX_RATIONALE,
|
|
inbox_marker: str = _INBOX_MARKER,
|
|
timestamp: str = "2026-06-30",
|
|
max_rounds: int = 3,
|
|
) -> LearningSimulationResult:
|
|
"""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.
|
|
|
|
``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
|
|
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`` /
|
|
``persona_rationale`` for a control.
|
|
|
|
Run A: a fresh (empty) wiki -> an uninformed hypothesis; the persona approves with NEW realization
|
|
knowledge (``marker`` in ``persona_rationale``). ``promote_verdict`` lifts that verdict into the
|
|
wiki; ``seed_store_from_bundle`` re-reads the wiki; Run B's Step-1 ExpeL fold then carries the
|
|
marker into its hypothesis prompt. The two runs use SEPARATE sinks so each prompt set is
|
|
inspected independently."""
|
|
example = load_persona_example()
|
|
if marker is None:
|
|
marker = example.marker
|
|
if persona_rationale is None:
|
|
persona_rationale = example.rationale
|
|
if marker not in persona_rationale:
|
|
raise ValueError("marker must be a substring of persona_rationale (the carried payload)")
|
|
if inbox_marker not in inbox_rationale:
|
|
raise ValueError(
|
|
"inbox_marker must be a substring of inbox_rationale (the carried payload)"
|
|
)
|
|
if inbox_marker == marker:
|
|
raise ValueError(
|
|
"inbox_marker must differ from marker: the Step-7 inbox and the Step-8 promotion are "
|
|
"two mechanisms that both end in Run B's prompt, so a shared marker would let either "
|
|
"path carry it alone and make both load-bearing tests vacuous"
|
|
)
|
|
|
|
copy = Path(work_dir) / "bundle"
|
|
shutil.copytree(bundle_dir, copy)
|
|
copy_s = str(copy)
|
|
replies: dict[str, str | Callable[[str, str], str]] = {
|
|
"proposer": _proposer_reply,
|
|
"checker": _CHECKER_APPROVE,
|
|
}
|
|
verdict_input = {"decision": example.decision, "rationale": persona_rationale}
|
|
|
|
# Run A — empty wiki isolates the persona's NEW knowledge.
|
|
sink_a: list[str] = []
|
|
run_a = cast(
|
|
RunResult, # the sim only drives full runs; never dry-run (S4.2 widened run_project)
|
|
await run_project(
|
|
project_id,
|
|
"local",
|
|
docs_dir=copy_s,
|
|
bundle_dir=copy_s,
|
|
verdict_input=verdict_input,
|
|
store=VerdictStore(verdicts=[]),
|
|
client_factory=scripted_factory(replies, sink_a),
|
|
max_rounds=max_rounds,
|
|
),
|
|
)
|
|
|
|
# Gate-promote the persona verdict from the raw output layer into the OKF wiki (Steg 8).
|
|
promoted_path = promote_verdict(
|
|
copy_s,
|
|
run_a.verdict,
|
|
approver="ekspert-persona (sim)",
|
|
experiment="sim-run-A",
|
|
timestamp=timestamp,
|
|
)
|
|
|
|
# Steg 7, the LONG loop: an expert drops a verdict FILE into an inbox AFTER Run A. This is the
|
|
# OTHER timescale — no promotion gate, no wiki, just a folder the next run reads. The inbox sits
|
|
# beside the bundle copy, never inside it: a verdict file within the bundle would reach Run B as
|
|
# navigable context, which is a different mechanism wearing this one's label. ``write_verdict``
|
|
# is the same public primitive a human expert would use (målbilde §3: the system READS, the
|
|
# expert WRITES) — which is why ``run_project`` is never handed the writing job.
|
|
inbox = Path(work_dir) / _INBOX_DIR_NAME
|
|
inbox_path = write_verdict(
|
|
str(inbox),
|
|
Verdict(
|
|
id=_INBOX_VERDICT_ID,
|
|
proposal_features=bundle_candidate_features(copy_s),
|
|
decision="approved",
|
|
rationale=inbox_rationale,
|
|
),
|
|
)
|
|
|
|
# Re-seed the wiki: the promoted verdict is now navigable and folds into the next run.
|
|
store_b = seed_store_from_bundle(copy_s)
|
|
|
|
# Run B — a separate, later run reads the updated wiki.
|
|
sink_b: list[str] = []
|
|
run_b = cast(
|
|
RunResult, # the sim only drives full runs; never dry-run (S4.2 widened run_project)
|
|
await run_project(
|
|
project_id,
|
|
"local",
|
|
docs_dir=copy_s,
|
|
bundle_dir=copy_s,
|
|
verdict_input=verdict_input,
|
|
store=store_b,
|
|
verdict_dir=str(inbox),
|
|
client_factory=scripted_factory(replies, sink_b),
|
|
max_rounds=max_rounds,
|
|
),
|
|
)
|
|
|
|
gen_a = _generation_prompts(sink_a)
|
|
gen_b = _generation_prompts(sink_b)
|
|
return LearningSimulationResult(
|
|
run_a=run_a,
|
|
run_b=run_b,
|
|
promoted_path=promoted_path,
|
|
marker=marker,
|
|
marker_in_run_a_prompt=any(marker in p for p in gen_a),
|
|
marker_in_run_b_prompt=any(marker in p for p in gen_b),
|
|
inbox_path=inbox_path,
|
|
inbox_marker=inbox_marker,
|
|
inbox_marker_in_run_a_prompt=any(inbox_marker in p for p in gen_a),
|
|
inbox_marker_in_run_b_prompt=any(inbox_marker in p for p in gen_b),
|
|
run_a_generation_prompts=gen_a,
|
|
run_b_generation_prompts=gen_b,
|
|
)
|
|
|
|
|
|
def _outcome_line(result: RunResult) -> str:
|
|
o = result.outcome
|
|
if isinstance(o, ValidatedProposal):
|
|
return (
|
|
f"VALIDATED (påstått {o.proposal.claimed_saving_nok:.0f} <= P90 {o.p90:.0f} NOK; "
|
|
f"tiltak: {o.proposal.measure})"
|
|
)
|
|
return f"REJECTED ({o.reason})"
|
|
|
|
|
|
def _clip(text: str, limit: int = 92) -> str:
|
|
"""One-line, length-capped rendering of a captured agent text. The trace is a walkthrough a
|
|
listener can follow, not a transcript dump."""
|
|
flat = " ".join(text.split())
|
|
return flat if len(flat) <= limit else flat[: limit - 1] + "…"
|
|
|
|
|
|
def _num(value: float) -> str:
|
|
"""Render a cost magnitude without exponent notation.
|
|
|
|
``:g`` — what these lines used while the demo ran the reserve — switches to scientific notation
|
|
at the 7th significant digit, so the DELIVERED baseline printed as ``4.38615e+06``. The reserve's
|
|
``300000`` has six digits and never reached the switch: a formatting defect the synthetic numbers
|
|
hid and delivered content exposed on the first run. A demo whose headline cost line is unreadable
|
|
cannot claim the gate is anchored in real cost lines.
|
|
"""
|
|
if value == int(value):
|
|
return str(int(value))
|
|
return f"{value:f}".rstrip("0").rstrip(".")
|
|
|
|
|
|
def _items_line(proposal: SavingsProposal) -> str:
|
|
return ", ".join(
|
|
f"{i.code} {_num(i.quantity)} x {_num(i.unit_cost)}" for i in proposal.affected_items
|
|
)
|
|
|
|
|
|
def _first_hypothesis(result: RunResult) -> SavingsProposal:
|
|
"""Step 2 made visible: the FIRST candidate the run produced. When Step 5 corrected the run,
|
|
that first candidate is the one the validator falsified (``refinements[0]``); with no
|
|
refinement, the candidate that left the run IS the first one."""
|
|
if result.refinements:
|
|
return result.refinements[0].proposal
|
|
return result.outcome.proposal
|
|
|
|
|
|
def _step5_lines(result: RunResult) -> list[str]:
|
|
"""Step 5 made visible: every falsification that was fed back into a further hypothesis, then
|
|
what the bounded loop ended on. No refinement means the first hypothesis validated — saying so
|
|
is the honest output there, not printing nothing."""
|
|
if not result.refinements:
|
|
return [" (ingen — første hypotese validerte; ingen forbedring var nødvendig)"]
|
|
lines = [
|
|
f" #{n}: grunnen fra {rejected.proposal.claimed_saving_nok:.0f} NOK-hypotesen mates "
|
|
"tilbake i neste forsøk (bundet av max_attempts)"
|
|
for n, rejected in enumerate(result.refinements, start=1)
|
|
]
|
|
lines.append(f" etter forbedring: {_outcome_line(result)}")
|
|
return lines
|
|
|
|
|
|
def _decision_line(result: RunResult) -> str:
|
|
"""Step 6 made visible: the TYPED outcome that leaves the run, with BOTH falsifiers named —
|
|
the deterministic validator gated the numbers, the checker gated the reasoning."""
|
|
o = result.outcome
|
|
if isinstance(o, ValidatedProposal):
|
|
return (
|
|
f"FORESLÅTT — {o.proposal.measure}: {o.proposal.claimed_saving_nok:.0f} NOK "
|
|
f"(validator={result.provenance.validator_decision}, checker={result.checker_verdict})"
|
|
)
|
|
return f"FORKASTET — {o.reason}"
|
|
|
|
|
|
def _verdict_origin_line(verdicts: Sequence[Verdict], *, marker: str, inbox_marker: str) -> str:
|
|
"""P4 pkt. 4, the seed sentence: of Run B's previous verdicts, how many came WITH the knowledge
|
|
base and how many the demo learned in this session.
|
|
|
|
Said out loud on stage, so it has to be true of the content actually being run — and the split
|
|
is therefore DERIVED, never written down. The line above it already prints the retrieved count;
|
|
a hand-written "one of three" would be a second copy of that number, and would go on being said
|
|
unchanged after a bundle shipped a second seeded verdict.
|
|
|
|
The classifier is the two markers the demo has traced all along: a retrieved verdict carrying one
|
|
of them is one this session produced (the Step-8 promotion, the Step-7 inbox note). That rests on
|
|
the seeded verdict carrying NEITHER, which is measured on the delivered bundle in
|
|
``tests/test_p4_honesty_sentences_loadbearing.py`` rather than assumed here.
|
|
"""
|
|
learned = sum(1 for v in verdicts if marker in v.rationale or inbox_marker in v.rationale)
|
|
seeded = len(verdicts) - learned
|
|
return (
|
|
f" av disse fulgte {seeded} av {len(verdicts)} med kunnskapsbasen; "
|
|
f"de øvrige {learned} er dem demoen lærte i denne økten"
|
|
)
|
|
|
|
|
|
def _run_trace_lines(result: RunResult, *, marker: str, marker_in_prompt: bool) -> list[str]:
|
|
"""Steps 1-7 of one run, one labelled line per step (``method-spec`` §3), for the walkthrough.
|
|
|
|
PRESENTATION ONLY: every value is read off the ``RunResult`` the run already returned — nothing
|
|
is recomputed against the bundle and nothing is inferred. Two honesty limits are visible in what
|
|
is printed rather than papered over:
|
|
|
|
* ``retrieved`` is the POST-hoc, proposal-keyed retrieval (``run.py`` step 7), not the Step-1
|
|
fold itself. The marker line is the evidence that a prior verdict reached the hypothesis
|
|
PROMPT, which is the property Step 1 actually claims.
|
|
* the run carries the checker's DECISION, not its prose (``_checker_verdict`` parses the gate
|
|
marker and keeps the decision). The decision is what gates, so it is what is shown.
|
|
"""
|
|
hypothesis = _first_hypothesis(result)
|
|
files = [c.file for c in result.provenance.citations]
|
|
lines = [
|
|
" Steg 1 — FORSTÅ KONTEKSTEN (navigert kunnskapsbase + tidligere dommer)",
|
|
f" navigerte konseptfiler ({len(files)}): {', '.join(files)}",
|
|
f" tidligere dommer hentet for kandidaten: {len(result.retrieved)}",
|
|
f" markør '{marker}' i hypotese-prompten: {marker_in_prompt}",
|
|
" Steg 2 — HYPOTESE (kandidat med parametere)",
|
|
f" tiltak: {hypothesis.measure}",
|
|
f" kostlinjer: {_items_line(hypothesis)}",
|
|
f" påstått besparelse: {hypothesis.claimed_saving_nok:.0f} NOK",
|
|
" Steg 3 — DEBATT (maker-checker, Group Chat)",
|
|
f" proposer (konvergert): {_clip(result.debate_output)}",
|
|
f" checker (gate på resonnementet): VERDICT={result.checker_verdict.upper()}",
|
|
" Steg 4 — VALIDER / FALSIFISER (deterministisk, blokkerende)",
|
|
]
|
|
if result.refinements:
|
|
# The status tokens stay VALIDATED/REJECTED (English) on purpose: they are the same
|
|
# vocabulary as ``provenance.validator_decision``, which the Step-6 line prints verbatim.
|
|
lines.append(f" hypotese #1: REJECTED ({result.refinements[0].reason})")
|
|
else:
|
|
lines.append(f" {_outcome_line(result)}")
|
|
lines.append(" Steg 5 — FORBEDRE, INFORMERT OG BUNDET")
|
|
lines.extend(_step5_lines(result))
|
|
lines.append(" Steg 6 — FORKAST ELLER FORESLÅ (typet utfall forlater kjøringen)")
|
|
lines.append(f" {_decision_line(result)}")
|
|
# Step 7 has TWO timescales, and only the short one happens INSIDE a run: the expert verdict
|
|
# captured while the run is live. The long one — a verdict file dropped into a folder after the
|
|
# run — is printed by ``main`` between the two runs, because that is when it happens.
|
|
lines.append(" Steg 7 — SVAR PÅ TILBAKEMELDING (ekspert-persona, kort løkke i kjøringen)")
|
|
lines.append(f" dom: {result.verdict.decision}")
|
|
lines.append(f" begrunnelse: {_clip(result.verdict.rationale, 300)}")
|
|
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} {_num(line.quantity)} x {_num(line.unit_cost)}"
|
|
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``."""
|
|
import asyncio
|
|
import sys
|
|
import tempfile
|
|
|
|
# P4 pkt. 2: runtime half of the stderr discipline (the import-time half wrapped the
|
|
# ``agent_framework`` import above). Installed here rather than at import time so that a
|
|
# library consumer of this module keeps its own logging configuration.
|
|
quiet_expected_round_cap_notice()
|
|
|
|
work = tempfile.mkdtemp(prefix="po-sim-")
|
|
# THE call site (P3, GO): the demo runs the DELIVERED bundle, which ships its own
|
|
# `cost-baseline.json` — so the gate is anchored on numbers a domain team wrote, not on numbers
|
|
# the script derived. The abort path is these three lines reverted to the anchored reserve
|
|
# (`materialize_anchored_bundle` + `_RESERVE_PROVENANCE` + `_PROJECT_ID`); the run path's seam
|
|
# is identical either way, which is what made pointing at delivered content a call-site change.
|
|
bundle = _delivered_bundle_dir()
|
|
provenance = _VEGLYS_PROVENANCE
|
|
project_id = _VEGLYS_PROJECT_ID
|
|
result = asyncio.run(simulate_learning_loop(str(bundle), work, project_id=project_id))
|
|
|
|
print("=" * 78)
|
|
print("OFFLINE SIMULERING — skriptede agent-svar, INGEN ekte modell.")
|
|
print("Beviser dataflyten, den deterministiske ryggraden og at læringssløyfa lukkes.")
|
|
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.
|
|
print(f"\nKJØRING A ({project_id} — fersk kunnskapsbase, ingen tidligere dommer)")
|
|
for line in _run_trace_lines(
|
|
result.run_a, marker=result.marker, marker_in_prompt=result.marker_in_run_a_prompt
|
|
):
|
|
print(line)
|
|
print(" (forventet: markøren er FRAVÆRENDE her — dommen finnes ikke i wikien ennå)")
|
|
|
|
print("\n MELLOM KJØRINGENE — to uavhengige tilbakemeldings-veier tas i bruk")
|
|
|
|
print("\n Steg 7 (lang løkke) — EN EKSPERT LEGGER EN DOM I INNBOKSEN, ETTER KJØRINGEN")
|
|
print(f" fil: {result.inbox_path.parent.name}/{result.inbox_path.name}")
|
|
print(f" bærer: {result.inbox_marker} (ettersendt driftsmåling — ny kunnskap)")
|
|
print(" rollene byttes aldri: systemet LESER denne mappa, eksperten SKRIVER den")
|
|
print(" neste kjøring merger fila inn i minnet FØR hypotesen formes — dager kan gå")
|
|
|
|
print("\n Steg 8 — PROMOTER GODKJENT KUNNSKAP (gatet wiki-promotering)")
|
|
print(f" skrev: {result.promoted_path.name} (lenket i index.md, nøytral etikett)")
|
|
print(f" bærer: {result.marker} (personaens dom fra kjøring A)")
|
|
print(" gaten er fail-closed: kun en godkjent dom promoteres — rå agent-output aldri")
|
|
|
|
print("\nKJØRING B (re-seedet kunnskapsbase + innboksen lest)")
|
|
print(" samme åtte steg kjøres igjen; her vises kun det som ENDRET seg:")
|
|
print(f" tidligere dommer hentet for kandidaten: {len(result.run_b.retrieved)}")
|
|
print(
|
|
_verdict_origin_line(
|
|
result.run_b.retrieved, marker=result.marker, inbox_marker=result.inbox_marker
|
|
)
|
|
)
|
|
print(
|
|
f" markør '{result.marker}' (Steg 8, wiki) i hypotese-prompten: "
|
|
f"{result.marker_in_run_b_prompt} (forventet True)"
|
|
)
|
|
print(
|
|
f" markør '{result.inbox_marker}' (Steg 7, innboks) i hypotese-prompten: "
|
|
f"{result.inbox_marker_in_run_b_prompt} (forventet True)"
|
|
)
|
|
print(f" utfall: {_decision_line(result.run_b)}")
|
|
|
|
# Both paths must close, and neither may have been present in Run A — two markers means the two
|
|
# mechanisms are checked separately here, exactly as the load-bearing tests check them.
|
|
closed = (
|
|
result.marker_in_run_b_prompt
|
|
and not result.marker_in_run_a_prompt
|
|
and result.inbox_marker_in_run_b_prompt
|
|
and not result.inbox_marker_in_run_a_prompt
|
|
)
|
|
print("\n" + "-" * 78)
|
|
if closed:
|
|
print("LÆRINGSSLØYFA ER LUKKET, PÅ BEGGE TIDSSKALAER: kunnskapen eksperten godkjente i")
|
|
print("kjøring A nådde kjøring B's hypotese via den fil-baserte wikien (promoter ->")
|
|
print("re-seed -> fold), OG driftsnotatet som ble lagt i innboksen etterpå nådde den via")
|
|
print("fil-innboksen (skriv fil -> merge -> fold). Ingen av dem gikk gjennom minnet.")
|
|
else:
|
|
print("LÆRINGSSLØYFA ER IKKE LUKKET — en av markørene krysset ikke kjøringene som ventet.")
|
|
print("-" * 78)
|
|
# The throwaway copy's path is the ONE non-deterministic value here (``mkdtemp``), so it goes to
|
|
# stderr: stdout is then byte-identical across runs, which is what the dress rehearsal's
|
|
# ``diff <(run1) <(run2)`` check compares. It stays visible on a terminal either way.
|
|
print(f"\n(arbeidskopi: {work})", file=sys.stderr)
|
|
return 0 if closed else 1
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover - console entry
|
|
raise SystemExit(main())
|