The short loop captured the expert verdict inline into an in-memory store, so a verdict arriving days/weeks later in a separate run could not influence any future hypothesis (målbilde §5 row 7). Steg 7 adds the long timescale: run_project gains an opt-in verdict_dir async inbox that load_verdicts_from_dir -> store.add MERGES into the store BEFORE the Step-1 ExpeL fold, so a verdict dropped after an earlier run reaches a separate, later run's hypothesis — fully resumable across runs separated in time. - verdicts.py: verdict_to_dict / verdict_from_dict (id read verbatim, never re-minted), write_verdict (public authoring primitive, NOT wired into run_project — system reads the folder, expert/persona writes it, §3 role split), tolerant load_verdicts_from_dir (missing/foreign/half-written files skipped, not raised — RAW layer per §10 R2), VerdictStore.from_dir. - run.py: verdict_dir kwarg; ingest-merge block after load_contracts (merge not replace keeps run_portfolio's cross-project threading; store.add idempotent on content-hash id; no change to the fold). CLI --bundle-dir/--verdict-dir thread the long loop to the console entry. No auto-persist of the run's own captured verdict (outbox/Steg 8). - Load-bearing PAIR (test_step7_async_loop_loadbearing.py): a verdict dropped after run A must reach run B's prompt (run B uses a FRESH store -> the transfer is the file loop, not in-memory carryover); empty-inbox control proves causality. Marker = a realization value absent from the bundle (not the seed's 0.82). Proven RED on ingest detach. Suite 138 -> 140 passed, 4 skipped; mypy + ruff check clean. Målbilde treated as frozen (no §3/§5/§7 edit). Step 8 (gated wiki promotion) remains. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
151 lines
7.1 KiB
Python
151 lines
7.1 KiB
Python
"""Step-7 load-bearing seam (målbilde §3 / §5 row 7 / §7): the LONG/async feedback loop — a
|
|
verdict file that lands in a folder AFTER one run MUST be consumed by a separate, later run.
|
|
|
|
The gap (fot-i-bakken, verified in code): the short loop captured the expert verdict inline
|
|
(``verdict_input``) into an IN-MEMORY ``VerdictStore`` — so a verdict that arrives days/weeks
|
|
later, in a separate run, could not influence any future hypothesis. Fase 5 adds a file inbox:
|
|
``run_project(verdict_dir=...)`` ingests expert/persona-authored verdict files (plain JSON, R2 raw
|
|
layer) and MERGES them into the store BEFORE the Step-1 ExpeL fold, so the dropped verdict reaches
|
|
the next run's hypothesis prompt through the existing fold.
|
|
|
|
These two tests are a load-bearing PAIR (per the Fase-2 green-but-dead trap):
|
|
- the positive test keys on a realization marker that occurs NOWHERE in the bundle (0.57, not the
|
|
bundle seed's 0.82), carried in the dropped verdict's rationale — proving the learning PAYLOAD
|
|
round-tripped end to end (write -> file -> ingest -> retrieve -> fold -> prompt), not merely that
|
|
an object was retrieved. It goes RED the moment the ingest is detached.
|
|
- the control proves causality: the SAME run with an EMPTY inbox carries no marker — so the signal
|
|
is caused by the file loop, not incidental to the bundle. ``bundle_context`` excludes
|
|
``type: verdict`` files (okf.py), so the marker can ONLY arrive via ingest -> store -> fold.
|
|
|
|
Critical invariant: run B uses a FRESH store (never run A's object) — otherwise the transfer would
|
|
be in-memory carryover, not the file loop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from portfolio_optimiser.run import run_project
|
|
from portfolio_optimiser.validator import ValidatedProposal
|
|
from portfolio_optimiser.verdicts import (
|
|
Verdict,
|
|
VerdictStore,
|
|
bundle_candidate_features,
|
|
write_verdict,
|
|
)
|
|
|
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
|
|
|
# A VALID BYGG-KONTOR-NORD proposal (same as the Step-1 test): P90 = 0.30 x 300000 = 90000 >=
|
|
# claimed 30000 -> validates on the first attempt.
|
|
_ENERGY_REPLY = (
|
|
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
|
|
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
|
|
)
|
|
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
|
|
|
|
# Two load-bearing markers carried by the DROPPED verdict, by design:
|
|
# - a realization value absent from EVERY bundle file (the bundle seed is 0.82). It can reach a
|
|
# prompt only via the ingest->fold path -> isolates the file loop AND would catch a regression
|
|
# where bundle navigation starts leaking verdict files (a 0.82 marker would pass for the wrong
|
|
# reason and mask that).
|
|
# - a sentinel verdict id (cannot appear in the bundle's raw text) for object identity.
|
|
_MARKER = "realiseringsgrad=0.57"
|
|
_DROP_ID = "STEG7-DROP"
|
|
|
|
|
|
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 sink."""
|
|
return [p for p in sink if "SavingsProposal" in p]
|
|
|
|
|
|
def _drop_expert_verdict(inbox: Path) -> None:
|
|
"""Simulate the long loop: an expert/persona drops a verdict file into the inbox AFTER a run.
|
|
Keyed on the bundle candidate's features (the same key the Step-1 fold queries on), carrying the
|
|
realization marker in its rationale. ``write_verdict`` is the public authoring primitive a
|
|
persona (sim) or human (prod) uses against the SAME folder interface."""
|
|
verdict = Verdict(
|
|
id=_DROP_ID,
|
|
proposal_features=bundle_candidate_features(str(BUNDLE_DIR)),
|
|
decision="approved",
|
|
rationale=f"LED-retrofit godkjent med realiseringskorreksjon ({_MARKER})",
|
|
)
|
|
write_verdict(str(inbox), verdict)
|
|
|
|
|
|
async def test_dropped_verdict_reaches_later_run(make_recording_client_factory, tmp_path) -> None:
|
|
"""LOAD-BEARING: a verdict dropped into the inbox AFTER run A reaches run B's hypothesis prompt.
|
|
Goes RED if ``run_project`` stops ingesting ``verdict_dir`` before the Step-1 fold (the marker
|
|
never arrives -> no fold -> the assertion fails)."""
|
|
inbox = tmp_path / "verdict-inbox"
|
|
inbox.mkdir()
|
|
|
|
# Run A: an earlier run with an EMPTY store and an EMPTY inbox -> nothing folds in, and (no
|
|
# auto-persist) run A does NOT write its own captured verdict back to the inbox.
|
|
factory_a, recorded_a = make_recording_client_factory(_ENERGY_REPLY)
|
|
await run_project(
|
|
"BYGG-KONTOR-NORD",
|
|
"local",
|
|
docs_dir=str(BUNDLE_DIR),
|
|
bundle_dir=str(BUNDLE_DIR),
|
|
verdict_input=_VERDICT_INPUT,
|
|
store=VerdictStore(verdicts=[]),
|
|
verdict_dir=str(inbox),
|
|
client_factory=factory_a,
|
|
)
|
|
assert all(_MARKER not in p for p in recorded_a), "precondition: run A carries no marker"
|
|
|
|
# Days/weeks later, out of band: the expert drops a verdict into the inbox folder.
|
|
_drop_expert_verdict(inbox)
|
|
|
|
# Run B: a SEPARATE, later run with a FRESH store (never run A's object) -> the dropped verdict
|
|
# must reach the hypothesis prompt purely via the file loop.
|
|
factory_b, recorded_b = make_recording_client_factory(_ENERGY_REPLY)
|
|
result = await run_project(
|
|
"BYGG-KONTOR-NORD",
|
|
"local",
|
|
docs_dir=str(BUNDLE_DIR),
|
|
bundle_dir=str(BUNDLE_DIR),
|
|
verdict_input=_VERDICT_INPUT,
|
|
store=VerdictStore(verdicts=[]),
|
|
verdict_dir=str(inbox),
|
|
client_factory=factory_b,
|
|
)
|
|
|
|
assert isinstance(result.outcome, ValidatedProposal)
|
|
gen_prompts = _generation_prompts(recorded_b)
|
|
assert gen_prompts, "the generation call must have happened"
|
|
assert any(_MARKER in p for p in gen_prompts), (
|
|
"the dropped verdict's realization signal did not reach run B's hypothesis prompt — the "
|
|
"async file inbox is not ingested before the Step-1 fold"
|
|
)
|
|
assert any(_DROP_ID in p for p in gen_prompts), (
|
|
"the dropped verdict's id did not reach the prompt"
|
|
)
|
|
|
|
|
|
async def test_empty_inbox_leaves_no_signal(make_recording_client_factory, tmp_path) -> None:
|
|
"""CAUSALITY CONTROL: the same run with an EMPTY inbox carries no marker into any prompt —
|
|
proving the positive test's signal is caused by the file loop, not incidental to the bundle
|
|
(``bundle_context`` excludes ``type: verdict`` files, so the marker has no other path)."""
|
|
inbox = tmp_path / "verdict-inbox"
|
|
inbox.mkdir()
|
|
|
|
factory, recorded = make_recording_client_factory(_ENERGY_REPLY)
|
|
await run_project(
|
|
"BYGG-KONTOR-NORD",
|
|
"local",
|
|
docs_dir=str(BUNDLE_DIR),
|
|
bundle_dir=str(BUNDLE_DIR),
|
|
verdict_input=_VERDICT_INPUT,
|
|
store=VerdictStore(verdicts=[]),
|
|
verdict_dir=str(inbox),
|
|
client_factory=factory,
|
|
)
|
|
|
|
assert all(_MARKER not in p for p in recorded), (
|
|
"the realization marker reached a prompt with an EMPTY inbox — the positive test is not "
|
|
"load-bearing (the signal is incidental, not caused by the ingest seam)"
|
|
)
|
|
assert all(_DROP_ID not in p for p in recorded), "the sentinel id leaked without a dropped file"
|