- inbox.py (§4.2+§5): VerdictDocument med verbatim-id-regel; write_verdict
authoring-primitiv (deterministisk JSON); load_inbox tolerant (skip, aldri
raise; sortert på filnavn); merge_inbox_into_store first-write-wins,
idempotent, skriver aldri (rolle-splitt §3 steg 7)
- promotion.py (§6): promote fail-closed mot {approved,
approved_with_adjustment}; eksplisitt påkrevd timestamp; minimal frontmatter
(rationale → description, aldri strukturerte læringsfelt); path-safe token
med content-hash-fallback; idempotent index-lenking med fast nøytral label
- persona.py (§4.3): load_persona_example fail-fast (run-path-vokabular,
marker ⊆ rationale); drop_persona_verdict artefakt-sourced ved kalltid mot
delt shared/-artefakt
- experience.py (kirurgisk): seeding leser verdict_id VERBATIM fra frontmatter
— re-minting ville kollidert distinkte promoterte kandidater
- 43 nye load-bearing tester (step7/step8/persona), 164/164 uten API-nøkkel;
to-runs-bevis med fersk store + tom-inbox-kontroll; fire detach-bevis kjørt
røde og revertert grønne
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""Step-7 async file loop — LOAD-BEARING (method-spec §3 Step 7, §5, §11).
|
|
|
|
The seam this file keeps alive: a verdict dropped into the inbox FOLDER after
|
|
Run A reaches Run B's hypothesis prompt via a FRESH store (merge → fold) — the
|
|
system is fully resumable across runs separated in time, with no live-session
|
|
assumption. The empty-inbox control proves the inbox is what changes the
|
|
outcome signal. Role split (§5): the system READS the inbox; the expert writes
|
|
it — the merge path never persists anything back.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from _scripted import ScriptedClient, reply
|
|
|
|
from portfolio_optimiser_claude.budget import BudgetMeter
|
|
from portfolio_optimiser_claude.contracts import TerminationContract
|
|
from portfolio_optimiser_claude.experience import (
|
|
CandidateFeatures,
|
|
VerdictRecord,
|
|
VerdictStore,
|
|
fold_experience,
|
|
mint_verdict_id,
|
|
)
|
|
from portfolio_optimiser_claude.inbox import (
|
|
ProposalFeatures,
|
|
VerdictDocument,
|
|
load_inbox,
|
|
merge_inbox_into_store,
|
|
write_verdict,
|
|
)
|
|
from portfolio_optimiser_claude.loop import ModelReply, run_project
|
|
|
|
BASE_CONTEXT = "Project context: office building, LED retrofit candidate."
|
|
MARKER = "realiseringsgrad=0.79"
|
|
RATIONALE = f"Godkjent med korreksjon: i drift realiseres ~79% ({MARKER})."
|
|
|
|
BASE_PROPOSAL = {
|
|
"project_id": "p1",
|
|
"measure": "led-retrofit",
|
|
"affected_items": [{"code": "E01", "quantity": 100, "unit_cost": 1000}],
|
|
"claimed_saving_nok": 25000,
|
|
"assumptions": {},
|
|
}
|
|
ADJUSTED_PROPOSAL = dict(BASE_PROPOSAL, claimed_saving_nok=19750)
|
|
|
|
|
|
def _meter() -> BudgetMeter:
|
|
return BudgetMeter(TerminationContract(max_rounds=50, max_tokens=100_000))
|
|
|
|
|
|
def _features(
|
|
codes: frozenset[str] = frozenset({"E01"}),
|
|
measure_type: str = "led-retrofit",
|
|
claimed: float = 25000.0,
|
|
) -> CandidateFeatures:
|
|
return CandidateFeatures(
|
|
affected_codes=codes, measure_type=measure_type, claimed_saving_nok=claimed
|
|
)
|
|
|
|
|
|
def _document(
|
|
features: CandidateFeatures | None = None,
|
|
decision: str = "approved",
|
|
rationale: str = RATIONALE,
|
|
) -> VerdictDocument:
|
|
return VerdictDocument.from_candidate(
|
|
features or _features(),
|
|
decision=decision,
|
|
rationale=rationale,
|
|
description="LED retrofit for one office building",
|
|
)
|
|
|
|
|
|
class TestVerdictDocument:
|
|
"""§4.2: the verdict file model — minted on authoring, VERBATIM on load."""
|
|
|
|
def test_from_candidate_mints_the_spec_id(self) -> None:
|
|
features = _features()
|
|
assert _document(features).id == mint_verdict_id(features)
|
|
|
|
def test_affected_codes_are_emitted_sorted(self) -> None:
|
|
doc = _document(_features(codes=frozenset({"B", "A", "C"})))
|
|
assert doc.proposal_features.affected_codes == ["A", "B", "C"]
|
|
|
|
def test_to_record_keeps_a_loaded_id_verbatim(self) -> None:
|
|
# A loaded id is NEVER re-minted (raw number formatting could diverge).
|
|
doc = VerdictDocument(
|
|
id="feedfeedfeedfeed",
|
|
decision="approved",
|
|
rationale=RATIONALE,
|
|
proposal_features=ProposalFeatures(
|
|
affected_codes=["E01"],
|
|
measure_type="led-retrofit",
|
|
claimed_saving_nok=25000.0,
|
|
description="surface text",
|
|
),
|
|
)
|
|
record = doc.to_record()
|
|
assert isinstance(record, VerdictRecord)
|
|
assert record.verdict_id == "feedfeedfeedfeed"
|
|
assert record.verdict_id != mint_verdict_id(doc.features())
|
|
|
|
def test_description_is_excluded_from_minting(self) -> None:
|
|
a = _document().model_copy(deep=True)
|
|
b = VerdictDocument.from_candidate(
|
|
_features(), decision="approved", rationale=RATIONALE, description="other text"
|
|
)
|
|
assert a.id == b.id
|
|
|
|
|
|
class TestAuthoringPrimitive:
|
|
"""§5: one verdict per file, ``{id}.json``, written deterministically."""
|
|
|
|
def test_write_creates_the_directory_and_names_by_id(self, tmp_path: Path) -> None:
|
|
inbox = tmp_path / "nested" / "inbox"
|
|
doc = _document()
|
|
path = write_verdict(inbox, doc)
|
|
assert path == inbox / f"{doc.id}.json"
|
|
assert path.is_file()
|
|
|
|
def test_write_is_deterministic_sorted_keys_two_space_indent(self, tmp_path: Path) -> None:
|
|
doc = _document()
|
|
path = write_verdict(tmp_path, doc)
|
|
text = path.read_text(encoding="utf-8")
|
|
assert text == json.dumps(json.loads(text), sort_keys=True, indent=2)
|
|
assert write_verdict(tmp_path, doc).read_text(encoding="utf-8") == text
|
|
|
|
def test_disk_layer_is_last_write_wins_per_file(self, tmp_path: Path) -> None:
|
|
write_verdict(tmp_path, _document(rationale="first"))
|
|
write_verdict(tmp_path, _document(rationale="second"))
|
|
assert len(list(tmp_path.iterdir())) == 1
|
|
(loaded,) = load_inbox(tmp_path)
|
|
assert loaded.rationale == "second"
|
|
|
|
|
|
class TestTolerantLoad:
|
|
"""§5: the raw layer is written out of band — skip, never raise."""
|
|
|
|
def test_missing_folder_yields_zero_verdicts(self, tmp_path: Path) -> None:
|
|
assert load_inbox(tmp_path / "no-such-inbox") == []
|
|
|
|
def test_non_json_broken_and_incomplete_files_are_skipped(self, tmp_path: Path) -> None:
|
|
write_verdict(tmp_path, _document())
|
|
(tmp_path / "notes.txt").write_text("not a verdict", encoding="utf-8")
|
|
(tmp_path / "broken.json").write_text("{not json", encoding="utf-8")
|
|
incomplete = _document().model_dump()
|
|
del incomplete["rationale"]
|
|
(tmp_path / "incomplete.json").write_text(json.dumps(incomplete), encoding="utf-8")
|
|
(tmp_path / "subdir.json").mkdir()
|
|
loaded = load_inbox(tmp_path)
|
|
assert len(loaded) == 1
|
|
assert loaded[0].rationale == RATIONALE
|
|
|
|
def test_files_are_processed_sorted_by_filename(self, tmp_path: Path) -> None:
|
|
late = _document(_features(codes=frozenset({"Z9"})))
|
|
early = _document(_features(codes=frozenset({"A1"})))
|
|
for doc in (late, early):
|
|
write_verdict(tmp_path, doc)
|
|
assert [d.id for d in load_inbox(tmp_path)] == sorted([late.id, early.id])
|
|
|
|
|
|
class TestMergeIntoStore:
|
|
"""§5: merge, never replace — first-write-wins per id, idempotent, read-only."""
|
|
|
|
def test_merge_ingests_and_repeats_idempotently(self, tmp_path: Path) -> None:
|
|
write_verdict(tmp_path, _document())
|
|
store = VerdictStore()
|
|
assert merge_inbox_into_store(store, tmp_path) == 1
|
|
assert merge_inbox_into_store(store, tmp_path) == 1
|
|
assert len(store) == 1
|
|
|
|
def test_passed_in_store_verdicts_survive_the_merge(self, tmp_path: Path) -> None:
|
|
# Cross-project threading: existing entries survive; on an id collision
|
|
# the store's FIRST write wins over the inbox file.
|
|
inbox_doc = _document(rationale="from the inbox")
|
|
write_verdict(tmp_path, inbox_doc)
|
|
store = VerdictStore()
|
|
other = VerdictRecord(
|
|
verdict_id="0000000000000000",
|
|
decision="approved",
|
|
rationale="other project",
|
|
features=_features(codes=frozenset({"X"})),
|
|
)
|
|
colliding = VerdictRecord(
|
|
verdict_id=inbox_doc.id,
|
|
decision="approved",
|
|
rationale="already threaded",
|
|
features=_features(),
|
|
)
|
|
store.add(other)
|
|
store.add(colliding)
|
|
merge_inbox_into_store(store, tmp_path)
|
|
assert len(store) == 2
|
|
ranked = store.retrieve(_features(), k=2)
|
|
assert {r.rationale for r in ranked} == {"already threaded", "other project"}
|
|
|
|
def test_merge_never_writes_to_the_inbox(self, tmp_path: Path) -> None:
|
|
# Role split (§3 Step 7, unwaivable): the system READS the inbox.
|
|
write_verdict(tmp_path, _document())
|
|
before = {p.name: p.read_bytes() for p in tmp_path.iterdir()}
|
|
merge_inbox_into_store(VerdictStore(), tmp_path)
|
|
after = {p.name: p.read_bytes() for p in tmp_path.iterdir()}
|
|
assert after == before
|
|
|
|
|
|
def _run_a_client() -> ScriptedClient:
|
|
return ScriptedClient(
|
|
replies=[
|
|
reply("debate reasoning"),
|
|
reply("VERDICT: APPROVE"),
|
|
reply(json.dumps(BASE_PROPOSAL)),
|
|
]
|
|
)
|
|
|
|
|
|
def _run_b_script(role: str, prompt: str) -> ModelReply:
|
|
# Prompt-SENSITIVE (flip proof): the adjusted candidate appears ONLY when the
|
|
# expert's realization marker reached the prompt via merge → fold.
|
|
if role == "checker":
|
|
return reply("VERDICT: APPROVE")
|
|
if "JSON object" in prompt:
|
|
return reply(json.dumps(ADJUSTED_PROPOSAL if MARKER in prompt else BASE_PROPOSAL))
|
|
return reply("debate reasoning")
|
|
|
|
|
|
def _run_b(inbox_dir: Path) -> tuple[ScriptedClient, float]:
|
|
# Run B is a SEPARATE, later run: FRESH store, no state carried from Run A.
|
|
store = VerdictStore()
|
|
merge_inbox_into_store(store, inbox_dir)
|
|
features = _features()
|
|
context = fold_experience(store, features, BASE_CONTEXT, k=3)
|
|
client = ScriptedClient(script=_run_b_script)
|
|
result = run_project(client, context, meter=_meter(), max_debate_rounds=3)
|
|
return client, result.proposal.claimed_saving_nok
|
|
|
|
|
|
class TestAsyncFileLoopLoadBearing:
|
|
"""LOAD-BEARING (§11): the dropped verdict reaches Run B's prompt and flips it."""
|
|
|
|
def test_verdict_dropped_after_run_a_reaches_run_b_via_a_fresh_store(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
inbox = tmp_path / "inbox"
|
|
run_a = run_project(_run_a_client(), BASE_CONTEXT, meter=_meter(), max_debate_rounds=3)
|
|
assert run_a.proposal.claimed_saving_nok == 25000
|
|
# Days later, the expert judges Run A's candidate and drops a verdict
|
|
# file — the run itself never wrote it (role split).
|
|
judged = CandidateFeatures.from_proposal(run_a.proposal)
|
|
write_verdict(inbox, _document(judged))
|
|
client_b, claimed_b = _run_b(inbox)
|
|
assert any(MARKER in prompt for prompt in client_b.prompts("proposer"))
|
|
assert claimed_b == 19750 # the flip: the marker informed Run B's candidate
|
|
|
|
def test_empty_inbox_control_keeps_run_b_at_the_base_outcome(self, tmp_path: Path) -> None:
|
|
# Control (§11): an empty inbox → no marker in any prompt, no flip.
|
|
inbox = tmp_path / "inbox"
|
|
inbox.mkdir()
|
|
client_b, claimed_b = _run_b(inbox)
|
|
assert all(MARKER not in prompt for prompt in client_b.prompts("proposer"))
|
|
assert claimed_b == 25000
|
|
|
|
def test_missing_inbox_folder_control_behaves_like_empty(self, tmp_path: Path) -> None:
|
|
client_b, claimed_b = _run_b(tmp_path / "never-created")
|
|
assert claimed_b == 25000
|