portfolio-optimiser-claude/src/portfolio_optimiser_claude/experience.py
Kjell Tore Guttormsen 22bfc80dda feat(learning): S9 — D7 læringssløyfe: verdict-inbox, fail-closed promoteringsgate, artefakt-sourced persona
- 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
2026-07-03 07:36:15 +02:00

171 lines
6.5 KiB
Python

"""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}"