- 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
171 lines
6.5 KiB
Python
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}"
|