"""The experience seam (ExpeL-style): store, structural retrieval, fold (§3 Step 1). Prior verdicts reach the hypothesis prompt ONLY through this seam: bundle seeding → in-memory store → structural retrieval → fold-before-generation. Ranking is structural, never textual — surface text must not contribute to similarity. The rationale is the carrier of the learning signal: the fold is what lets an expert's realization-rate correction reach the next hypothesis. """ from __future__ import annotations import hashlib import json from dataclasses import dataclass from pathlib import Path from .ir import SavingsProposal, load_validator_input from .okf import navigate_bundle _VERDICT_TYPE = "verdict" _DEFAULT_SEED_DECISION = "approved" # §3 Step 1: frozen similarity weights and magnitude bucket edges. _JACCARD_WEIGHT = 0.60 _MEASURE_TYPE_WEIGHT = 0.25 _MAGNITUDE_WEIGHT = 0.15 _MAGNITUDE_BUCKET_EDGES = (1e5, 5e5, 1e6) @dataclass(frozen=True) class CandidateFeatures: """The structural features retrieval ranks over (§4.2 ``proposal_features``).""" affected_codes: frozenset[str] measure_type: str claimed_saving_nok: float @classmethod def from_proposal(cls, proposal: SavingsProposal) -> CandidateFeatures: # The IR projection carries no separate measure-type field; its ``measure`` # string is the candidate's measure type at this level (§3 Step 1: the # query key is read from the IR projection, before any proposal exists). return cls( affected_codes=frozenset(item.code for item in proposal.affected_items), measure_type=proposal.measure, claimed_saving_nok=proposal.claimed_saving_nok, ) @dataclass(frozen=True) class VerdictRecord: """One store entry: id (the learning-loop key), decision, rationale, features.""" verdict_id: str decision: str rationale: str features: CandidateFeatures def mint_verdict_id(features: CandidateFeatures) -> str: """First 16 hex chars of SHA-256 over the canonical feature JSON (§4.2). Raw JSON number formatting participates in the hash (30000 vs 30000.0 differ), which is why a LOADED verdict's id is kept verbatim — never re-minted. """ canonical = json.dumps( { "affected_codes": sorted(features.affected_codes), "claimed_saving_nok": features.claimed_saving_nok, "measure_type": features.measure_type, }, sort_keys=True, separators=(",", ":"), ) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] def _magnitude_bucket(claimed_saving_nok: float) -> int: # Buckets [0, 1e5), [1e5, 5e5), [5e5, 1e6), [1e6, ∞) over the claimed saving. return sum(1 for edge in _MAGNITUDE_BUCKET_EDGES if claimed_saving_nok >= edge) def similarity(a: CandidateFeatures, b: CandidateFeatures) -> float: """Structural similarity — surface text never contributes (§3 Step 1).""" if not a.affected_codes and not b.affected_codes: jaccard = 1.0 else: union = a.affected_codes | b.affected_codes jaccard = len(a.affected_codes & b.affected_codes) / len(union) return ( _JACCARD_WEIGHT * jaccard + _MEASURE_TYPE_WEIGHT * (a.measure_type == b.measure_type) + _MAGNITUDE_WEIGHT * (_magnitude_bucket(a.claimed_saving_nok) == _magnitude_bucket(b.claimed_saving_nok)) ) class VerdictStore: """In-memory verdict store — FIRST-write-wins per id (idempotent merges, §4.2).""" def __init__(self) -> None: self._records: dict[str, VerdictRecord] = {} def __len__(self) -> int: return len(self._records) def add(self, record: VerdictRecord) -> None: self._records.setdefault(record.verdict_id, record) def retrieve(self, features: CandidateFeatures, k: int) -> list[VerdictRecord]: """Top-k by structural similarity, ties broken by verdict id ascending.""" if k <= 0: raise ValueError(f"retrieval k must be positive, got {k}") ranked = sorted( self._records.values(), key=lambda record: (-similarity(record.features, features), record.verdict_id), ) return ranked[:k] def seed_store_from_bundle(store: VerdictStore, bundle_dir: Path) -> int: """Seed the store from the bundle's navigable ``type: verdict`` files (§3 Step 1). Entries are keyed on the bundle's candidate features, read from the IR projection (fail-fast, required input). The rationale is built from the ``description`` frontmatter plus, when present, the structured learning fields. A ``verdict_id`` in the frontmatter (promoted files, §6) is read VERBATIM (§4.2) — re-minting from the bundle features would collide distinct promoted candidates into one first-write-wins store slot. Returns the number of verdict files seeded. """ features = CandidateFeatures.from_proposal(load_validator_input(bundle_dir)) seeded = 0 for concept in navigate_bundle(bundle_dir): if concept.type != _VERDICT_TYPE: continue rationale = concept.frontmatter.get("description", "") realization_rate = concept.frontmatter.get("realization_rate") expected_actual = concept.frontmatter.get("expected_actual_saving_nok") if realization_rate is not None and expected_actual is not None: learning = ( f"[realiseringsgrad={realization_rate}; forventet_faktisk_NOK={expected_actual}]" ) rationale = f"{rationale} {learning}".strip() store.add( VerdictRecord( verdict_id=concept.frontmatter.get("verdict_id") or mint_verdict_id(features), decision=concept.frontmatter.get("decision", _DEFAULT_SEED_DECISION), rationale=rationale, features=features, ) ) seeded += 1 return seeded def fold_experience( store: VerdictStore, features: CandidateFeatures, base_context: str, k: int ) -> str: """Prepend the retrieved prior verdicts to the generation context (§3 Step 1). One line per verdict — id, decision, rationale. An empty retrieval returns the base context unchanged (the empty-store control, §11). """ retrieved = store.retrieve(features, k) if not retrieved: return base_context lines = "\n".join( f"- {record.verdict_id} [{record.decision}]: {record.rationale}" for record in retrieved ) return f"Prior expert verdicts (most similar first):\n{lines}\n\n{base_context}"