"""The verdict file and the inbox folder contract (method-spec §4.2, §5). The long feedback loop's folder interface: an expert (or, in simulation, the persona) drops one verdict per ``{id}.json`` file into an inbox folder; a separate, later run picks it up — fully resumable, no live-session assumption. Role split (§3 Step 7, unwaivable): the system READS the inbox (tolerant load, merge into the store); writing is the authoring primitive's job, used only by the expert/persona side — a run never persists its own captured verdict back. Decision vocabulary at this layer (C2.5, C-F7): loading polices the §4.2 set {approved, rejected, approved_with_adjustment} with SKIP semantics — an unknown decision never reaches the store, but never raises either (the raw layer is written out of band). The run-path feedback contract (§4.1) and the promotion gate's accepted set (§6) police their own vocabularies on top. Capacity is the exception to tolerance: an inbox over the file cap, or a rationale over the length cap, FAILS FAST with a precise error — a silent skip there would silently drop expert knowledge (never a silent cut). """ from __future__ import annotations import json import re from pathlib import Path from pydantic import BaseModel, Field, ValidationError from portfolio_optimiser_claude.experience import ( CandidateFeatures, VerdictRecord, VerdictStore, mint_verdict_id, ) # R-6 id grammar — mirrors the ingest-spec §4 id grammar (enforced by the # llm-ingestion-okf library on the ingest side): lowercase alphanumerics and # hyphens only, so a verdict id can NEVER traverse paths (no dots, no # separators). Minted ids (16 hex chars, §4.2) always match. _ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") # §4.2: the file layer's full decision vocabulary. _FILE_DECISIONS = frozenset({"approved", "rejected", "approved_with_adjustment"}) # Fail-fast capacity defaults — generous for any real expert inbox, small # enough that a runaway writer cannot flood the fold. _DEFAULT_MAX_FILES = 1_000 _DEFAULT_MAX_RATIONALE_CHARS = 20_000 class InboxLimitError(ValueError): """An inbox cap was exceeded — refusing to load (fail-fast, never a silent cut).""" class ProposalFeatures(BaseModel): """§4.2 ``proposal_features``: the structural features of the judged candidate. ``description`` is surface text — deliberately excluded from both similarity ranking and id minting. """ affected_codes: list[str] measure_type: str claimed_saving_nok: float description: str class VerdictDocument(BaseModel): """One verdict file (§4.2). A LOADED ``id`` is kept verbatim — never re-minted.""" id: str = Field(min_length=1, pattern=_ID_RE.pattern) decision: str = Field(min_length=1) rationale: str = Field(min_length=1) proposal_features: ProposalFeatures @classmethod def from_candidate( cls, features: CandidateFeatures, *, decision: str, rationale: str, description: str, ) -> VerdictDocument: """Author a verdict for a candidate — the id is minted per §4.2.""" return cls( id=mint_verdict_id(features), decision=decision, rationale=rationale, proposal_features=ProposalFeatures( affected_codes=sorted(features.affected_codes), measure_type=features.measure_type, claimed_saving_nok=features.claimed_saving_nok, description=description, ), ) def features(self) -> CandidateFeatures: return CandidateFeatures( affected_codes=frozenset(self.proposal_features.affected_codes), measure_type=self.proposal_features.measure_type, claimed_saving_nok=self.proposal_features.claimed_saving_nok, ) def to_record(self) -> VerdictRecord: return VerdictRecord( verdict_id=self.id, # verbatim (§4.2) — raw number formatting could diverge decision=self.decision, rationale=self.rationale, features=self.features(), ) def write_verdict(inbox_dir: Path, verdict: VerdictDocument) -> Path: """The authoring primitive (§5): ``{id}.json``, written deterministically. Creates the directory if needed; sorted keys, 2-space indent. The disk layer is LAST-write-wins per file (§4.2). The id grammar is re-checked HERE (R-6): ``model_copy(update=...)`` bypasses model validation, so the write seam fails closed on its own — nothing is ever written outside ``inbox_dir``. """ if _ID_RE.fullmatch(verdict.id) is None: raise ValueError( f"refusing to write verdict: id {verdict.id!r} violates the id grammar " f"{_ID_RE.pattern!r} (R-6 path safety)" ) inbox_dir.mkdir(parents=True, exist_ok=True) path = inbox_dir / f"{verdict.id}.json" payload = json.dumps(verdict.model_dump(), sort_keys=True, indent=2) path.write_text(payload, encoding="utf-8") return path def load_inbox( inbox_dir: Path, *, max_files: int = _DEFAULT_MAX_FILES, max_rationale_chars: int = _DEFAULT_MAX_RATIONALE_CHARS, ) -> list[VerdictDocument]: """Tolerant load (§5): the raw layer is written out of band — skip, never raise. A missing folder yields zero verdicts; files that are not ``.json``, fail to parse, lack a required top-level key, violate the id grammar, or carry a decision outside the §4.2 vocabulary are SKIPPED. Deterministic order: sorted by filename. The caps are the one place tolerance ends: more candidate files than ``max_files``, or a rationale longer than ``max_rationale_chars``, raises :class:`InboxLimitError` (C2.5). """ if not inbox_dir.is_dir(): return [] paths = [ path for path in sorted(inbox_dir.iterdir(), key=lambda p: p.name) if path.suffix == ".json" and path.is_file() ] if len(paths) > max_files: raise InboxLimitError( f"inbox {inbox_dir} holds {len(paths)} verdict files, over the cap of " f"{max_files} — refusing to load" ) verdicts: list[VerdictDocument] = [] for path in paths: try: document = VerdictDocument.model_validate(json.loads(path.read_text("utf-8"))) except (OSError, ValueError, ValidationError): continue if document.decision not in _FILE_DECISIONS: continue # §4.2 vocabulary (C-F7): unknown decision → SKIP, never the store # Outside the tolerant try on purpose — a cap breach must NEVER be # swallowed as one more skipped file. if len(document.rationale) > max_rationale_chars: raise InboxLimitError( f"verdict file {path.name} carries a rationale of " f"{len(document.rationale)} chars, over the cap of " f"{max_rationale_chars} — refusing to load" ) verdicts.append(document) return verdicts def merge_inbox_into_store( store: VerdictStore, inbox_dir: Path, *, max_files: int = _DEFAULT_MAX_FILES, max_rationale_chars: int = _DEFAULT_MAX_RATIONALE_CHARS, ) -> int: """Merge, never replace (§5): per-verdict add, first-write-wins per id. Runs BEFORE the Step-1 fold, so a passed-in store's existing verdicts survive (cross-project threading) and repeated merges are idempotent. Returns the number of inbox verdicts ingested; never writes anything. """ verdicts = load_inbox(inbox_dir, max_files=max_files, max_rationale_chars=max_rationale_chars) for verdict in verdicts: store.add(verdict.to_record()) return len(verdicts)