feat(fase2): wire Step-1 ExpeL retrieval into the hypothesis prompt

Closes maalbilde §5 gap #1 (the one missing "feedback-into-prompt" dataflow)
for the OKF-bundle path. Before, ExpeL was computed AFTER generation into a
discarded SessionContext, so a prior verdict could not influence any hypothesis
(context_providers=0).

- New okf.py: framework-neutral OKF bundle navigation (index + frontmatter +
  cross-links), pure stdlib, no agent_framework/mcp (D7-portable), enforced by
  test_okf_is_maf_free.
- verdicts.py: seed_store_from_bundle + bundle_candidate_features build the
  ExpeL substrate + the pre-hypothesis query key from a bundle.
- run_project(bundle_dir=...): folds the candidate's prior verdicts into the
  generation context BEFORE generate_via_llm; the road path is unchanged.

Load-bearing (maalbilde §7): test_step1_expel_loadbearing proves a prior verdict
reaches the hypothesis prompt and goes RED when the fold is detached (shown via
TDD red->green). The marker is the minted verdict id (content hash) because
docs_dir==bundle_dir lets keyword chunk-stuffing leak the realization rate;
clean layer separation is Fase 2b.

Suite 121->133 passed; mypy + ruff check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
This commit is contained in:
Kjell Tore Guttormsen 2026-06-29 10:56:48 +02:00
commit d6d83d42b5
9 changed files with 529 additions and 7 deletions

View file

@ -0,0 +1,135 @@
"""OKF (Open Knowledge Format) bundle navigation — framework-neutral, D7-portable context seam.
Reads a bundle the way OKF intends (progressive disclosure): start at ``index.md``, follow
intra-bundle cross-links, parse each file's YAML frontmatter, classify by the one required
``type`` field. **NO** ``agent_framework``, **NO** ``mcp`` pure stdlib, so the SAME navigation
serves both the MAF and the Claude-SDK implementations unchanged (målbilde §4 vendor-neutrality).
Robustness is part of the spec (OKF SPEC §4): consumers MUST tolerate broken links and unknown
fields. A link to a missing file or one escaping the bundle is silently skipped, never raised.
Path-safety reuses ``retrieval.safe_resolve`` (also pure stdlib): each cross-link is canonicalised
and boundary-checked against the bundle dir, fail-closed.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from portfolio_optimiser.retrieval import PathSecurityError, safe_resolve
_INDEX_NAME = "index.md"
_IR_PROJECTION = "validator-input.json"
# Intra-bundle markdown cross-links: ``](target.md)``. Targets with a path separator (``/``) are
# treated as out-of-bundle and skipped (only same-dir bundle files are navigated).
_LINK_RE = re.compile(r"\]\(([^)]+\.md)\)")
def parse_frontmatter(path: str | Path) -> dict[str, str]:
"""Read the leading ``---``-delimited YAML frontmatter block as key:value strings.
Minimal by design (no ``yaml`` dependency): enough for the one required ``type`` field and the
verdict's scalar fields. List values (``tags: [...]``) are kept verbatim; unknown fields are
preserved (OKF SPEC §4). Returns ``{}`` when there is no frontmatter block."""
lines = Path(path).read_text(encoding="utf-8").splitlines()
if not lines or lines[0].strip() != "---":
return {}
fm: dict[str, str] = {}
for line in lines[1:]:
if line.strip() == "---":
break
key, sep, val = line.partition(":")
if sep:
fm[key.strip()] = val.strip()
return fm
def _read_body(path: Path) -> str:
"""The markdown body after the frontmatter block (or the whole file if there is none)."""
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
if lines and lines[0].strip() == "---":
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return "".join(lines[i + 1 :]).lstrip("\n")
return "".join(lines)
@dataclass(frozen=True)
class BundleFile:
"""One OKF file: its name, declared ``type`` (``""`` if absent), frontmatter, and body."""
name: str
type: str
frontmatter: dict[str, str]
body: str
@dataclass(frozen=True)
class Bundle:
"""A navigated OKF bundle: ``index.md`` plus every cross-linked file that resolves."""
dir: str
files: tuple[BundleFile, ...]
@property
def index_summary(self) -> str:
"""The index body — the progressive-disclosure entry point (not whole-bundle stuffing)."""
return next((f.body for f in self.files if f.name == _INDEX_NAME), "")
@property
def verdicts(self) -> list[BundleFile]:
"""Every ``type: verdict`` file (the ExpeL seeds the Step-1 wiring retrieves)."""
return [f for f in self.files if f.type == "verdict"]
@property
def hypothesis(self) -> BundleFile | None:
"""The candidate ``type: hypothesis`` file, if present."""
return next((f for f in self.files if f.type == "hypothesis"), None)
def _load_file(bundle_dir: str, name: str) -> BundleFile | None:
"""Resolve ``name`` within ``bundle_dir`` and read it, or ``None`` if missing / escaping the
bundle (OKF §4 broken-link tolerance + fail-closed path-safety)."""
try:
resolved = Path(safe_resolve(bundle_dir, name))
except PathSecurityError:
return None
if not resolved.is_file():
return None
fm = parse_frontmatter(resolved)
return BundleFile(name=name, type=fm.get("type", ""), frontmatter=fm, body=_read_body(resolved))
def navigate_bundle(bundle_dir: str) -> Bundle:
"""Navigate the OKF bundle from ``index.md``: parse the index, follow its intra-bundle ``.md``
cross-links, and read each linked file's frontmatter + body. Deterministic: index first, then
links in first-seen order, de-duplicated. Broken / escaping links are skipped (§4). Raises
``ValueError`` only when ``index.md`` itself is unreadable (a bundle has no entry point)."""
index = _load_file(bundle_dir, _INDEX_NAME)
if index is None:
raise ValueError(f"OKF bundle has no readable {_INDEX_NAME}: {bundle_dir!r}")
files: list[BundleFile] = [index]
seen = {_INDEX_NAME}
for target in _LINK_RE.findall(index.body):
if "/" in target or target in seen:
continue # only same-dir bundle files; de-dup repeated links
seen.add(target)
linked = _load_file(bundle_dir, target)
if linked is not None:
files.append(linked)
return Bundle(dir=bundle_dir, files=tuple(files))
def load_ir_projection(bundle_dir: str, name: str = _IR_PROJECTION) -> dict[str, Any]:
"""Load the bundle's IR projection (``validator-input.json`` by default): the candidate
measure's cost-IR (``measure``, ``affected_items``, ``claimed_saving_nok``) — the
pre-hypothesis ExpeL query-key source. Raises if missing / escaping the bundle (fail-fast: it
is required input, not an optional cross-link)."""
resolved = Path(safe_resolve(bundle_dir, name))
if not resolved.is_file():
raise FileNotFoundError(f"IR projection not found in bundle: {name!r}")
data: dict[str, Any] = json.loads(resolved.read_text(encoding="utf-8"))
return data

View file

@ -44,11 +44,13 @@ from portfolio_optimiser.ir import SavingsProposal
from portfolio_optimiser.provenance import ProvenanceStamp
from portfolio_optimiser.reference_domain import Project, load_reference_projects
from portfolio_optimiser.validator import Rejection, ValidatedProposal
from portfolio_optimiser import okf
from portfolio_optimiser.verdicts import (
ExpeLContextProvider,
ProposalFeatures,
Verdict,
VerdictStore,
bundle_candidate_features,
capture_verdict,
)
from portfolio_optimiser.workflow import fresh_workflow
@ -114,6 +116,34 @@ def _project_by_id(project_id: str) -> Project:
raise ValueError(f"unknown project_id: {project_id!r}")
def _project_from_bundle(bundle_dir: str, project_id: str) -> Project:
"""Derive a minimal ``Project`` from an OKF bundle (so a bundle the loop runs need NOT be a
road reference-domain project). Only ``id`` + ``name`` reach the generation prompt
(``generate._build_messages``), so ``cost_items`` is empty and ``verdict_input`` is unused here
(the Layer-2 decision flows via the ``verdict_input`` argument). Fail-fast: the bundle's IR
``project_id`` must match the requested id."""
ir = okf.load_ir_projection(bundle_dir)
if ir["project_id"] != project_id:
raise ValueError(f"bundle project_id {ir['project_id']!r} != requested {project_id!r}")
project_file = next(
(f for f in okf.navigate_bundle(bundle_dir).files if f.type == "project"), None
)
name = (
project_file.frontmatter.get("title", project_id).strip('"')
if project_file is not None
else project_id
)
return Project(
id=project_id,
name=name,
description="",
currency="NOK",
cost_items=(),
docs_dir=bundle_dir,
verdict_input={},
)
def _features_of(proposal: SavingsProposal) -> ProposalFeatures:
return ProposalFeatures(
affected_codes=frozenset(item.code for item in proposal.affected_items),
@ -136,6 +166,7 @@ async def run_project(
*,
docs_dir: str,
verdict_input: dict[str, str],
bundle_dir: str | None = None,
store: VerdictStore | None = None,
client_factory: Callable[[str], BaseChatClient] | None = None,
max_rounds: int = 3,
@ -147,8 +178,11 @@ async def run_project(
) -> RunResult:
"""Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam
(defaults to the real backend). ``verdict_input`` carries the expert decision/rationale
(Layer-2). Raises ``pydantic.ValidationError`` on a bad contract and ``BudgetExceeded``
when the token/round cap is crossed."""
(Layer-2). ``bundle_dir`` (Fase 2a) makes the run OKF-bundle-driven: the project is derived
from the bundle and, before generation, the candidate's prior verdicts in ``store`` are folded
into the hypothesis prompt (Step-1 ExpeL wiring, målbilde §5/§7). Raises
``pydantic.ValidationError`` on a bad contract and ``BudgetExceeded`` when the token/round cap
is crossed."""
# 1. Fail-fast: validate ALL contracts (incl. the verdict-feedback shape) before any client.
load_contracts(
{"docs_dir": docs_dir, "top_k": top_k},
@ -156,8 +190,13 @@ async def run_project(
verdict_input,
)
# 2-3. Project + cited chunks (first-class provenance citations).
project = _project_by_id(project_id)
# 2-3. Project + cited chunks (first-class provenance citations). An OKF-bundle run derives its
# project from the bundle (need not be a road reference-domain project).
project = (
_project_from_bundle(bundle_dir, project_id)
if bundle_dir is not None
else _project_by_id(project_id)
)
chunks = retrieve_chunks("cost saving measure", docs_dir, top_k)
citations = [chunk_dict_to_citation(c) for c in chunks]
if not citations:
@ -189,6 +228,17 @@ async def run_project(
debate_output = _debate_text(result)
gen_context = debate_output or context
# Step-1 ExpeL wiring (Fase 2a, målbilde §5/§7): fold the candidate's prior verdicts INTO the
# hypothesis context BEFORE generation, keyed on the OKF bundle's candidate features (available
# pre-hypothesis). THIS is the one missing dataflow — previously ExpeL was computed
# post-generation into a discarded SessionContext (step 7 below), so a prior verdict could not
# reach the next hypothesis. Bundle-driven path with a populated store only; the road path is
# untouched (its post-hoc, proposal-keyed retrieval below is unchanged).
if bundle_dir is not None and store is not None and store.verdicts:
expel_query = bundle_candidate_features(bundle_dir)
fewshot = ExpeLContextProvider(store, expel_query, k=top_k).format_fewshot()
gen_context = f"{fewshot}\n\n{gen_context}"
# 5. Structured candidate -> blocking validation; token bound = the meter in this loop.
proposer_client = factory("proposer")
outcome = await generate_via_llm(proposer_client, project, gen_context, meter)
@ -211,8 +261,11 @@ async def run_project(
token_usage=meter.tokens,
)
# 7. ExpeL: surface prior verdicts for this proposal (exercises the two-arg
# extend_instructions injection on a real SessionContext — the learning loop).
# 7. ExpeL (regression guard + traceability): exercises the two-arg extend_instructions
# injection on a REAL SessionContext (the Critical Fase-1 GA-signature guard), and surfaces
# the proposal-keyed retrieval for RunResult.retrieved. On the bundle path the load-bearing
# ExpeL->prompt dataflow already happened pre-generation (above); this block's SessionContext
# is NOT what reaches the prompt.
store = store if store is not None else VerdictStore(verdicts=[])
features = _features_of(proposal)
provider = ExpeLContextProvider(store, features, k=top_k)

View file

@ -21,9 +21,12 @@ from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any
from agent_framework import ContextProvider, SessionContext
from portfolio_optimiser import okf
# Weights: the affected cost-code overlap dominates, then measure type, then magnitude.
_W_CODES, _W_MEASURE, _W_MAGNITUDE = 0.60, 0.25, 0.15
_MAGNITUDE_BUCKETS = [(0.0, 1e5), (1e5, 5e5), (5e5, 1e6), (1e6, float("inf"))]
@ -196,3 +199,58 @@ def seed_store() -> VerdictStore:
for vid, codes, mtype, saving, decision, desc in rows
]
)
# --- OKF-bundle seeding (Fase 2a): turn a project's bundle into the ExpeL substrate ---
def _features_from_ir(ir: dict[str, Any]) -> ProposalFeatures:
"""Map a bundle's IR projection (``validator-input.json``) to the structural features the
store ranks on: the affected cost-code set, the measure string, and the claimed magnitude."""
return ProposalFeatures(
affected_codes=frozenset(item["code"] for item in ir["affected_items"]),
measure_type=ir["measure"],
claimed_saving_nok=ir["claimed_saving_nok"],
description=ir.get("measure", ""),
)
def bundle_candidate_features(bundle_dir: str) -> ProposalFeatures:
"""The pre-hypothesis ExpeL query key: the candidate measure's structural features, read from
the OKF bundle's IR projection. Available BEFORE any proposal is generated — which is what lets
Step-1 retrieve prior verdicts and fold them into the hypothesis prompt (målbilde §2 step 1)."""
return _features_from_ir(okf.load_ir_projection(bundle_dir))
def _verdict_rationale(fm: dict[str, str]) -> str:
"""Build the few-shot rationale from a ``type: verdict`` file's frontmatter, carrying the
learning signal the deterministic validator cannot compute (the realization rate + expected
actual). This is the ExpeL signal that must reach the next hypothesis."""
base = fm.get("description", "")
signal = [
f"{label}={fm[key]}"
for key, label in (
("realization_rate", "realiseringsgrad"),
("expected_actual_saving_nok", "forventet_faktisk_NOK"),
)
if fm.get(key)
]
return f"{base} [{'; '.join(signal)}]" if signal else base
def seed_store_from_bundle(bundle_dir: str) -> VerdictStore:
"""Build a ``VerdictStore`` from an OKF bundle's ``type: verdict`` files. Each verdict is keyed
on the bundle's candidate features (so it retrieves for that measure) and carries the
realization signal in its rationale. The seed verdict stands in for the durable HITL verdict a
real expert would supply via the same folder interface (målbilde §3)."""
features = bundle_candidate_features(bundle_dir)
bundle = okf.navigate_bundle(bundle_dir)
verdicts = [
capture_verdict(
features,
vf.frontmatter.get("decision", "approved"),
_verdict_rationale(vf.frontmatter),
)
for vf in bundle.verdicts
]
return VerdictStore(verdicts=verdicts)