feat(context): S7 — D7 context seam: OKF navigation + gated ExpeL fold
Step 1 of the loop, built from method-spec §3 alone: - okf.py (pure stdlib): frontmatter parse, deterministic/tolerant/boundary-checked index navigation, bundle_context rendering with type:verdict exclusion - experience.py: CandidateFeatures from the IR projection, §4.2 id minting, structural ranking (0.60·Jaccard + 0.25·type + 0.15·magnitude bucket), first-write-wins store, bundle seeding with the realization marker, fold-before-generation (empty retrieval → base unchanged) Load-bearing (§11), each proved RED on detach: verdict-layer exclusion, fold, seed learning fields, agent-toolkit import guard. 76/76 without an API key; ruff + mypy --strict clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
This commit is contained in:
parent
1e1b7e4506
commit
46f2f521f7
4 changed files with 730 additions and 0 deletions
168
src/portfolio_optimiser_claude/experience.py
Normal file
168
src/portfolio_optimiser_claude/experience.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""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.
|
||||
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=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}"
|
||||
112
src/portfolio_optimiser_claude/okf.py
Normal file
112
src/portfolio_optimiser_claude/okf.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""OKF bundle navigation and read-context rendering (method-spec §3 Step 1).
|
||||
|
||||
The read-context is built by NAVIGATING the bundle with progressive disclosure —
|
||||
never by stuffing the whole bundle (or keyword-retrieved chunks) into the prompt.
|
||||
Navigation starts at ``index.md`` and follows its intra-bundle cross-links; broken
|
||||
or bundle-escaping links are tolerated (skipped, never raised — the OKF robustness
|
||||
rule), while a missing ``index.md`` is an error (no entry point). ``type: verdict``
|
||||
files are EXCLUDED from rendering: prior verdicts reach the hypothesis prompt ONLY
|
||||
via the gated experience fold (see ``experience``), never via context rendering.
|
||||
|
||||
Pure stdlib by design — the context seam imports no agent toolkit (§11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_INDEX_FILENAME = "index.md"
|
||||
_VERDICT_TYPE = "verdict"
|
||||
# (reference) the intra-bundle cross-link pattern, per §3 Step 1.
|
||||
_CROSSLINK_PATTERN = re.compile(r"\]\(([^)]+\.md)\)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConceptFile:
|
||||
"""One parsed OKF concept file: frontmatter (``type`` required) plus body."""
|
||||
|
||||
path: Path
|
||||
frontmatter: dict[str, str]
|
||||
body: str
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self.frontmatter["type"]
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.frontmatter.get("title", self.path.stem)
|
||||
|
||||
|
||||
def _strip_matching_quotes(value: str) -> str:
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def parse_concept_file(path: Path) -> ConceptFile:
|
||||
"""Parse frontmatter (leading ``---`` block, line-oriented ``key: value``) + body.
|
||||
|
||||
The single required field is ``type``; unknown fields are preserved as strings.
|
||||
"""
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines or lines[0].strip() != "---":
|
||||
raise ValueError(f"{path.name}: missing frontmatter block")
|
||||
frontmatter: dict[str, str] = {}
|
||||
body_start = len(lines)
|
||||
for i, line in enumerate(lines[1:], start=1):
|
||||
if line.strip() == "---":
|
||||
body_start = i + 1
|
||||
break
|
||||
key, sep, value = line.partition(":")
|
||||
if sep:
|
||||
frontmatter[key.strip()] = _strip_matching_quotes(value.strip())
|
||||
if "type" not in frontmatter:
|
||||
raise ValueError(f"{path.name}: frontmatter lacks the required field 'type'")
|
||||
return ConceptFile(
|
||||
path=path, frontmatter=frontmatter, body="\n".join(lines[body_start:]).strip()
|
||||
)
|
||||
|
||||
|
||||
def navigate_bundle(bundle_dir: Path) -> list[ConceptFile]:
|
||||
"""Navigate from ``index.md`` — deterministic order: index first, links first-seen.
|
||||
|
||||
Targets containing a path separator are out-of-bundle and skipped; resolution is
|
||||
boundary-checked against the bundle directory (fail-closed); broken links are
|
||||
skipped, never raised. Repeated links are de-duplicated.
|
||||
"""
|
||||
index_path = bundle_dir / _INDEX_FILENAME
|
||||
if not index_path.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"bundle has no entry point: missing {_INDEX_FILENAME} in {bundle_dir}"
|
||||
)
|
||||
index = parse_concept_file(index_path)
|
||||
bundle_root = bundle_dir.resolve()
|
||||
concepts = [index]
|
||||
seen = {_INDEX_FILENAME}
|
||||
for target in _CROSSLINK_PATTERN.findall(index_path.read_text(encoding="utf-8")):
|
||||
if "/" in target or "\\" in target or target in seen:
|
||||
continue
|
||||
seen.add(target)
|
||||
resolved = (bundle_dir / target).resolve()
|
||||
if not resolved.is_relative_to(bundle_root) or not resolved.is_file():
|
||||
continue
|
||||
concepts.append(parse_concept_file(resolved))
|
||||
return concepts
|
||||
|
||||
|
||||
def bundle_context(bundle_dir: Path) -> str:
|
||||
"""Render the read-context: index body, then ``## {type}: {title}`` sections.
|
||||
|
||||
Empty sections are dropped. ``type: verdict`` files are excluded — the verdict
|
||||
layer must never leak into the read-context (§3 Step 1, load-bearing §11).
|
||||
"""
|
||||
index, *concepts = navigate_bundle(bundle_dir)
|
||||
sections = [index.body] if index.body else []
|
||||
for concept in concepts:
|
||||
if concept.type == _VERDICT_TYPE or not concept.body:
|
||||
continue
|
||||
sections.append(f"## {concept.type}: {concept.title}\n\n{concept.body}")
|
||||
return "\n\n".join(sections)
|
||||
Loading…
Add table
Add a link
Reference in a new issue