portfolio-optimiser/src/portfolio_optimiser/verdicts.py
Kjell Tore Guttormsen a362504108
fix(v1-gate): row 2 binds a round to the run's own artefact family, and says out loud what it still cannot prove
The 18.09 re-measurement took row 2 to 3 of 3 GREEN on a tree this product had never
run in: four handwritten outcome.json, four handwritten <run_id>-coverage.json in an
outbox the forger named in those same files, and os.utime for the ordering. "Round 0
must be a named real run" was implemented as "a file with that name exists" — which
touch satisfies. The attack is committed as a red test in b769537.

Three bindings, chosen because each removes one of the forger's three moves:

1. The outbox is DERIVED, never declared. It is <rounds-dir>/<n>/outbox/, and an
   outcome.json that names one is refused by name. A path a round file chooses is a
   path it can point at a directory the same hand just wrote.

2. "The run exists" now means the run's own artefacts agree WITH EACH OTHER on content
   the gate recomputes (verify_run). Every evaluated approach has the proposal/outcome
   pair write_outbox actually persists; outcome_type IS the coverage status, reason IS
   its detail, a validated row's figure IS the proposal's own claimed_saving_nok, the
   provenance stamp follows the same branch, and verdict_id is RE-MINTED here from the
   proposal's own IR with the product's one minting rule (A5) rather than read. A
   not_evaluated approach wrote neither file, and an artefact naming an approach the
   coverage omits belongs to another run. verdicts._features_from_ir is made public for
   this: a second private copy in the gate could drift from the rule the run stamped
   with, which would turn the binding into a coincidence.

3. mtime decides nothing. The run's time is the round's declared ran_at (ISO-8601 with
   zone, required). An mtime is not evidence — it is a filesystem attribute one utime
   call sets.

What this does NOT do is prove a run happened. Nothing in a directory can: the outbox
writers are byte-deterministic and carry no clock by contract. So row 2 states its own
limit on every run instead of leaving GREEN to imply it (Row.attests / RUN_ATTESTATION):
that a run was actually made, and when, is the operator's to confirm. The cost of a
forgery moves from touch to reproducing the product's own artefact set, minting rule
included.

Measured, in a scratch copy, never in the work tree — four new mutants in this class,
each one line, each felled by the whole suite:
  M7  if "outbox" in data:          -> if False:   1 failed (declares its own outbox)
  M8  if verdict_id != minted:      -> if False:   1 failed (key is not the IR's)
  M9  if strays:                    -> if False:   1 failed (artefact of another run)
  M10 ran_at                        -> coverage mtime  18 failed (incl. the utime test)
Control, same scratch copy, unmutated: 1993 passed, 10 skipped, 5 xfailed.
Work tree, re-run after git add: uv run pytest -q -> 1998 passed, 5 skipped, 5 xfailed.
Gate: uv run python -m portfolio_optimiser.evals.v1_gate -> exit 1, row 2 RED (0 of 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:32:14 +02:00

779 lines
35 KiB
Python

"""VerdictStore + ExpeL retrieval + Layer-2 out-of-band verdict capture (B2).
The learning substrate: a structurally-ranked store of historical expert verdicts, surfaced
as ExpeL few-shots for the next run. **Similarity is structural, not textual** — a weighted
score over the affected cost-code set (Jaccard) + measure-type match + a magnitude bucket;
raw ``description`` text is deliberately excluded, so a true match with different wording
beats surface-text decoys.
Two corrections vs the Fase 1 spike:
1. **Two-arg ``extend_instructions``** (the Critical Fase 1 bug): ``before_run`` calls
``context.extend_instructions(self.source_id, [...])`` — the genuine GA signature
(``_sessions.py:253``), exercised against a REAL ``SessionContext`` in the tests.
2. **Layer-2 capture** (``capture_verdict``): mints a stable content-hash ``Verdict.id`` so a
structurally identical proposal maps to the same id (the learning-loop key). The store is
**in-memory only** for the MVP — durable persistence is deferred to Fase 3.
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from agent_framework import ContextProvider, SessionContext
from portfolio_optimiser import okf, semretrieval
_log = logging.getLogger(__name__)
# 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"))]
@dataclass(frozen=True)
class ProposalFeatures:
"""The *structured* features retrieval ranks on. ``description`` is surface text and is
deliberately NOT part of the similarity score (nor the minted id)."""
affected_codes: frozenset[str]
measure_type: str
claimed_saving_nok: float
description: str = ""
@dataclass(frozen=True)
class Verdict:
"""One historical expert verdict in the store."""
id: str
proposal_features: ProposalFeatures
decision: str # "approved" | "rejected"
rationale: str
def _magnitude_bucket(value: float) -> int:
for i, (low, high) in enumerate(_MAGNITUDE_BUCKETS):
if low <= value < high:
return i
return len(_MAGNITUDE_BUCKETS) - 1
def _jaccard(a: frozenset[str], b: frozenset[str]) -> float:
union = a | b
return len(a & b) / len(union) if union else 1.0
def similarity(query: ProposalFeatures, candidate: ProposalFeatures) -> float:
"""Weighted structural similarity in [0, 1] — text is ignored by design."""
codes = _jaccard(query.affected_codes, candidate.affected_codes)
measure = 1.0 if query.measure_type == candidate.measure_type else 0.0
magnitude = (
1.0
if _magnitude_bucket(query.claimed_saving_nok)
== _magnitude_bucket(candidate.claimed_saving_nok)
else 0.0
)
return _W_CODES * codes + _W_MEASURE * measure + _W_MAGNITUDE * magnitude
def _mint_id(features: ProposalFeatures) -> str:
"""Stable content-hash id over the STRUCTURAL fields (not description), so a
structurally identical proposal maps to the same id."""
canonical = json.dumps(
{
"affected_codes": sorted(features.affected_codes),
"measure_type": features.measure_type,
"claimed_saving_nok": features.claimed_saving_nok,
},
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def verdict_key(features: ProposalFeatures) -> str:
"""The id a verdict on a proposal with these features keys under — the learning-loop key.
Public because a run must be able to stamp an artefact with the key an expert verdict on THAT
candidate will arrive under, WITHOUT capturing a decision nobody has made yet (A5: every
evaluated approach gets its own judgeable artefact, but only one of them is the run's outcome).
One key, one minting rule: this delegates to ``_mint_id`` rather than restating the hash, so a
caller can never drift from the id ``capture_verdict`` actually assigns (the ``(p)`` precedent
— a second private copy of a keying rule is the defect, not the convenience)."""
return _mint_id(features)
@dataclass(frozen=True)
class VerdictCollision:
"""Two knowledge bases described ONE candidate, and the second verdict was dropped.
``VerdictStore.add`` is first-write-wins per id and ``_mint_id`` excludes the corpus BY
CONSTRUCTION, so this drop is CORRECT — one candidate, one learning key — but it used to be
silent, and a base whose finding never entered the store looked exactly like a base that found
nothing (operator decision D2).
A DIAGNOSTIC, never an aggregate over bases: it reports that two bases described one candidate
and never merges, ranks or sums them, which is why it needs no combination rule. ``add`` itself
is unchanged — it is called from inside ``run_project``/``run_portfolio``, where a drop can
equally be against a Step-7 inbox verdict or a bundle seed, so only the dispatcher holds the
id -> base map that makes "a SECOND base" a statement worth making.
"""
verdict_id: str
first_bundle_id: str
second_bundle_id: str
def capture_verdict(features: ProposalFeatures, decision: str, rationale: str) -> Verdict:
"""Layer-2 out-of-band verdict constructor: mint a stable content-hash id (the
learning-loop key) and build the ``Verdict`` to persist in the store."""
return Verdict(
id=_mint_id(features),
proposal_features=features,
decision=decision,
rationale=rationale,
)
# --- Async file inbox (Fase 5, Steg 7): plain-JSON verdict serialization + tolerant folder load ---
# The raw output layer is a plain JSON store (R2), NOT OKF — OKF is reserved for the promoted
# (Step-8) layer. One verdict per file lets an expert/persona drop verdicts into a folder out of
# band, and a separate later run pick them up (målbilde §3 long loop).
_REQUIRED_VERDICT_KEYS = {"id", "decision", "rationale", "proposal_features"}
# The binary run-path decision vocabulary (Verdict.decision domain + FeedbackContract). An inbox file
# with any other decision is SKIPPED by load_verdicts_from_dir — ``approved_with_adjustment`` is
# deliberately EXCLUDED here (it lives only in bundle-seed frontmatter + the promotion gate).
_INBOX_DECISION_VOCABULARY = frozenset({"approved", "rejected"})
def verdict_to_dict(verdict: Verdict) -> dict[str, Any]:
"""Serialize a ``Verdict`` to a JSON-ready dict. ``affected_codes`` (a frozenset) is emitted as
a SORTED list — matching ``_mint_id``'s canonical form — so the round-trip is lossless and the
on-disk form is deterministic."""
f = verdict.proposal_features
return {
"id": verdict.id,
"decision": verdict.decision,
"rationale": verdict.rationale,
"proposal_features": {
"affected_codes": sorted(f.affected_codes),
"measure_type": f.measure_type,
"claimed_saving_nok": f.claimed_saving_nok,
"description": f.description,
},
}
def verdict_from_dict(data: dict[str, Any]) -> Verdict:
"""Reconstruct a ``Verdict`` from its serialized dict. The ``id`` is read VERBATIM — never
re-minted — so a verdict authored elsewhere keeps its identity and the int/float magnitudes in
its features survive the JSON round-trip untouched (re-minting would re-hash and could diverge,
since ``_mint_id`` hashes the raw value, e.g. ``30000`` vs ``30000.0``)."""
pf = data["proposal_features"]
return Verdict(
id=data["id"],
proposal_features=ProposalFeatures(
affected_codes=frozenset(pf["affected_codes"]),
measure_type=pf["measure_type"],
claimed_saving_nok=pf["claimed_saving_nok"],
description=pf.get("description", ""),
),
decision=data["decision"],
rationale=data["rationale"],
)
def write_verdict(directory: str, verdict: Verdict) -> Path:
"""Write one verdict as ``{id}.json`` into ``directory`` (created if needed) and return the path.
This is the public authoring primitive the expert/persona (sim) or human (prod) uses to drop a
verdict into the async inbox via the SAME folder interface — and the writer a future Step-8
promotion would reuse. It is deliberately NOT called inside ``run_project``: the system READS the
inbox, it does not write to it (målbilde §3 role split).
Limitation: ``id`` is a content hash of the FEATURES only (``_mint_id``), so two verdicts with
identical features but different decisions share a filename — last write wins on disk (mirroring
``VerdictStore.add``'s first-wins in memory). Acceptable for the raw MVP layer."""
path = Path(directory)
path.mkdir(parents=True, exist_ok=True)
target = path / f"{verdict.id}.json"
target.write_text(
json.dumps(verdict_to_dict(verdict), sort_keys=True, indent=2), encoding="utf-8"
)
return target
def load_verdicts_from_dir(
directory: str,
*,
max_rationale_len: int | None = None,
max_files: int | None = None,
) -> list[Verdict]:
"""Load every well-formed verdict JSON file from an async inbox folder, TOLERANTLY (OKF §4
spirit): a missing folder yields ``[]``; files that are not ``.json``, fail to parse, lack a
required key, carry a decision outside the binary vocabulary ``{approved, rejected}``, or exceed
``max_rationale_len`` are SKIPPED, never raised. The folder is written out of band by an external
party, so half-written or foreign files (e.g. a bundle's ``golden.json``) are realistic — this is
the raw layer, not required input (contrast ``okf.load_ir_projection``'s fail-fast). Order is
deterministic (sorted by filename).
Herding (S2.5), two distinct axes by design:
- ``max_rationale_len`` (per-file, TOLERANT): an over-long rationale is skipped + logged, never
raised — matching the per-file skip contract the Step-7 loop relies on.
- ``max_files`` (aggregate, FAIL-FAST): more files than the cap RAISES ``ValueError`` before any
parsing — an oversized inbox is an aggregate integrity signal, not a per-file anomaly.
Both default to ``None`` (no cap), so existing callers are byte-for-byte unaffected."""
path = Path(directory)
if not path.is_dir():
return []
files = sorted(path.glob("*.json"))
if max_files is not None and len(files) > max_files:
raise ValueError(
f"verdict inbox {directory!r} has {len(files)} files, over the max_files cap "
f"({max_files}) — an oversized inbox is a fail-fast aggregate guard (contrast the "
"per-file tolerant skips)"
)
verdicts: list[Verdict] = []
for file in files:
try:
data = json.loads(file.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
# UnicodeDecodeError: a *.json file hand-saved in Latin-1 (Norwegian æ/ø/å) is invalid
# UTF-8 — a per-file tolerant skip, not a raise (a ValueError subclass, caught by neither
# OSError nor JSONDecodeError). Kept in lockstep with hitl._load_json_dict.
continue
if not isinstance(data, dict) or not _REQUIRED_VERDICT_KEYS <= data.keys():
continue
if data.get("decision") not in _INBOX_DECISION_VOCABULARY:
_log.debug(
"skipping inbox verdict %s: decision %r outside vocabulary",
file.name,
data.get("decision"),
)
continue
if (
max_rationale_len is not None
and len(str(data.get("rationale", ""))) > max_rationale_len
):
_log.warning(
"skipping inbox verdict %s: rationale length %d exceeds max_rationale_len %d",
file.name,
len(str(data.get("rationale", ""))),
max_rationale_len,
)
continue
try:
verdicts.append(verdict_from_dict(data))
except (KeyError, TypeError):
continue
return verdicts
@dataclass
class VerdictStore:
"""Minimal store of historical verdicts. In-memory by default; ``from_dir`` / the
``load_verdicts_from_dir`` merge primitive back it with the async file inbox (Steg 7)."""
verdicts: list[Verdict]
# S3.1 opt-in seam: ``None`` means the structural default, which is byte-identical to the
# pre-seam inline sort. Only an explicit caller (``run.py --semantic-retrieval``) installs a
# different ranker, so default retrieval behaviour is unchanged.
retriever: semretrieval.Retriever | None = None
@classmethod
def from_dir(cls, directory: str) -> VerdictStore:
"""Build a store from an async inbox folder (convenience constructor). ``run_project`` uses
the merge primitive (``add`` per loaded verdict) instead, to preserve a passed store's
existing verdicts (the cross-project threading in ``run_portfolio``); this is for callers
that want a fresh store straight from a folder."""
return cls(verdicts=load_verdicts_from_dir(directory))
def retrieve(
self,
query: ProposalFeatures,
k: int,
*,
retriever: semretrieval.Retriever | None = None,
) -> list[Verdict]:
"""Return the top-``k`` verdicts. Deterministic: ties break by verdict id, so ordering
is stable across runs.
Ranking resolves in three steps: the explicit ``retriever`` argument, then
``self.retriever``, then ``StructuralRetriever`` — the same weighted structural score and
``(-similarity, id)`` key as before the seam existed.
``retriever`` is PER CALL and keyword-only. It exists because the alternative — assigning
``store.retriever`` — mutates an object the caller owns, so an opt-in made for one
retrieval silently governed every later use of that store (including a subsequent run with
the flag OFF). ``self.retriever`` survives as the store-level default for callers that
genuinely want a store to rank one way for its whole lifetime; see ``docs/extending.md``."""
if k <= 0:
raise ValueError(f"k must be positive, got {k}")
ranker = retriever if retriever is not None else self.retriever
if ranker is None:
ranker = semretrieval.StructuralRetriever(similarity)
return ranker.rank(query, self.verdicts, k)
def add(self, verdict: Verdict) -> None:
"""Persist a captured verdict in-memory.
Conflict semantics (chosen, documented): the store is FIRST-write-wins per ``id`` — a
later verdict with the same content-hash id is dropped, which makes repeated Step-7 inbox
merges idempotent. The disk layers are the opposite: ``write_verdict`` (inbox) and
``promote_verdict`` (wiki) are last-write-wins per file. Because ids hash the candidate
FEATURES, "same id" means "same candidate measure", not "same verdict event". A full
verdict-conflict taxonomy (B10: rejection categories + a rule for conflicting expert
verdicts) is deliberately deferred until real experts produce conflicting verdicts."""
if all(v.id != verdict.id for v in self.verdicts):
self.verdicts.append(verdict)
class ExpeLContextProvider(ContextProvider):
"""Wraps ``VerdictStore.retrieve`` for ExpeL few-shot injection via the GA
``ContextProvider`` hook."""
def __init__(
self,
store: VerdictStore,
query: ProposalFeatures,
*,
k: int = 3,
retriever: semretrieval.Retriever | None = None,
) -> None:
super().__init__(source_id="expel-verdictstore")
self._store = store
self._query = query
self._k = k
# Per-call ranker, threaded straight through to ``retrieve`` — see its docstring for why
# this is a parameter rather than an assignment on the store.
self._retriever = retriever
def format_fewshot(self) -> str:
hits = self._store.retrieve(self._query, self._k, retriever=self._retriever)
body = "\n".join(f"- [{v.id}] {v.decision}: {v.rationale}" for v in hits)
return f"Relevant prior verdicts (ExpeL few-shot):\n{body}"
async def before_run(
self,
*,
agent: object,
session: object,
context: SessionContext,
state: dict,
) -> None:
# Two-arg GA signature (_sessions.py:253) — the Fase 1 single-arg bug is fixed.
context.extend_instructions(self.source_id, [self.format_fewshot()])
def seed_store() -> VerdictStore:
"""Seed 12 synthetic verdicts spanning the reference domain's cost codes, measure types,
magnitudes, and decisions (B2 store of 10-20)."""
rows = [
(
"V01",
{"05.2", "03.1"},
"scope_reduction",
180_000,
"approved",
"asphalt + base course trimmed within feasible range",
),
(
"V02",
{"05.2"},
"rate_renegotiation",
60_000,
"approved",
"renegotiated asphalt unit rate",
),
(
"V03",
{"07.4"},
"material_substitution",
120_000,
"rejected",
"granite kerb substitution unsafe",
),
(
"V04",
{"09.1"},
"scope_reduction",
240_000,
"approved",
"fewer LED masts on low-traffic stretch",
),
(
"V05",
{"02.3", "03.1"},
"scope_reduction",
350_000,
"rejected",
"soil replacement is load-bearing, cannot cut",
),
("V06", {"21.2"}, "rate_renegotiation", 90_000, "approved", "blasting rate renegotiated"),
(
"V07",
{"22.4"},
"material_substitution",
700_000,
"rejected",
"fiber shotcrete spec is mandated",
),
(
"V08",
{"88.2"},
"scope_reduction",
150_000,
"approved",
"concrete repair area re-measured smaller",
),
(
"V09",
{"87.3"},
"material_substitution",
130_000,
"approved",
"alternative membrane qualified",
),
(
"V10",
{"05.2", "03.1"},
"rate_renegotiation",
200_000,
"approved",
"combined paving rate discount",
),
(
"V11",
{"01.1"},
"scope_reduction",
95_000,
"rejected",
"rigging is fixed cost, no scope to cut",
),
(
"V12",
{"31.3"},
"scope_reduction",
110_000,
"approved",
"drainage length reduced after survey",
),
]
return VerdictStore(
verdicts=[
Verdict(
id=vid,
proposal_features=ProposalFeatures(
affected_codes=frozenset(codes),
measure_type=mtype,
claimed_saving_nok=saving,
description=desc,
),
decision=decision,
rationale=desc,
)
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 proposal IR dict (a bundle's ``validator-input.json`` projection, or an outbox
artefact's own ``proposal`` payload) to the structural features the store ranks on: the
affected cost-code set, the measure string, and the claimed magnitude.
PUBLIC for the same reason ``verdict_key`` is (A5, one minting rule): the v1 gate re-mints an
artefact's verdict id from the artefact's OWN IR to check that a run's outbox agrees with
itself, and a second private copy of this mapping in the gate would be a rule that can drift
from the one the run actually used — the defect, not the convenience."""
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).
Fail-fast. Use ``optional_bundle_candidate_features`` where a base without a projection is
legitimate — and note that its two consumers answer that absence DIFFERENTLY, on purpose."""
return features_from_ir(okf.load_ir_projection(bundle_dir))
def optional_bundle_candidate_features(bundle_dir: str) -> ProposalFeatures | None:
"""``bundle_candidate_features`` where a base with no IR projection is legitimate: ``None``
instead of raising (S7b søm 1). An ingested corpus has no hand-written projection, so it has no
pre-hypothesis candidate to key retrieval against — a fact about the base, not an error.
Tolerance stops at absence, as everywhere: a projection that exists but is malformed still
raises (``okf.load_optional_ir_projection``'s rule)."""
ir = okf.load_optional_ir_projection(bundle_dir)
return None if ir is None else features_from_ir(ir)
class VerdictKeyUnavailable(ValueError):
"""A ``type: verdict`` concept declares no structural key AND its bundle has no IR projection to
fall back on (S7b søm 1).
Fail-closed, and the ONE place in this seam where absence is not tolerable. The pre-S3.2
fallback keys such a verdict on the bundle's projection candidate; with no projection there is
no candidate, and minting a key anyway would attach the verdict to a candidate it is not about —
precisely the defect S3.2 closes. Validation, never repair (``write_concept_file``'s rule): the
three fields are the author's to declare, and ``promote_verdict`` writes them, so a base grown
by the loop itself is never affected."""
# S3.2: a verdict file MAY carry its own structural key. All three fields or none — see
# ``_features_from_verdict_frontmatter``.
_STRUCTURAL_FRONTMATTER_KEYS = ("affected_codes", "measure_type", "claimed_saving_nok")
class VerdictFrontmatterError(ValueError):
"""A ``type: verdict`` file declares its structural key PARTIALLY or unparseably. Fail-fast
(validation, never repair — mirroring ``write_concept_file``): the curated context layer is
hand-written or written by ``promote_verdict``, and the silent alternative — falling back to the
bundle candidate — keys the verdict to the WRONG candidate, which is the exact defect S3.2
closes. Contrast the tolerant RAW inbox layer (``load_verdicts_from_dir``), which skips
malformed files because anyone may drop anything there."""
# ``parse_frontmatter`` preserves quotes (OKF SPEC §4); the structural fields are compared and
# hashed against the IR projection's RAW JSON values, so the quotes have to come off. DELEGATED,
# not copied: this was a private implementation while ``okf.bundle_context`` stripped only ``"``,
# and a duplicated conversion drifts (the (p) precedent). ``okf`` owns ``parse_frontmatter``, so it
# owns the unquoting rule. Gated by ``tests/test_frontmatter_unquote_loadbearing.py``.
_unquote = okf.unquote_scalar
def _parse_affected_codes(raw: str) -> frozenset[str]:
"""Parse ``affected_codes`` — written by ``promote_verdict`` as ``[A, B]``, and accepted bare
(``A, B``) for hand-authored files. Empty is an error: a declared-but-contentless code set
Jaccard-matches every other empty set, which is a silent mis-key rather than a key."""
codes = {_unquote(part) for part in _unquote(raw).strip("[]").split(",")}
codes.discard("")
if not codes:
raise VerdictFrontmatterError("'affected_codes' is declared but empty")
return frozenset(codes)
def _parse_claimed_saving(raw: str) -> float:
"""Parse ``claimed_saving_nok`` with ``json.loads`` — deliberately the SAME literal rule the IR
projection went through, so ``18000`` stays an int and ``18000.0`` a float. ``_mint_id`` hashes
the raw value, so a parser that normalised the type would mint a different id for a promoted
verdict than for the bundle-keyed seed describing the same candidate."""
try:
value = json.loads(_unquote(raw))
except ValueError as exc:
raise VerdictFrontmatterError(f"'claimed_saving_nok' is not a number: {raw!r}") from exc
if not isinstance(value, (int, float)) or isinstance(value, bool):
raise VerdictFrontmatterError(f"'claimed_saving_nok' is not a number: {raw!r}")
return value
def _features_from_verdict_frontmatter(fm: dict[str, str]) -> ProposalFeatures | None:
"""The verdict's OWN structural key, or ``None`` when it declares none (caller falls back to the
bundle candidate — how every pre-S3.2 seed keeps working).
ALL THREE fields or none. A partial declaration is refused rather than merged with the bundle
candidate, because the merge would mint a key belonging to NEITHER candidate — a synthetic third
proposal that retrieves for nothing. Both fully-present and fully-absent are honest; half is not.
"""
present = [key for key in _STRUCTURAL_FRONTMATTER_KEYS if fm.get(key, "").strip()]
if not present:
return None
if len(present) != len(_STRUCTURAL_FRONTMATTER_KEYS):
missing = [k for k in _STRUCTURAL_FRONTMATTER_KEYS if k not in present]
raise VerdictFrontmatterError(
f"a verdict file declares {present} but not {missing}; a verdict carries its whole "
"structural key or none of it (a partial key belongs to no candidate)"
)
measure_type = _unquote(fm["measure_type"])
if not measure_type:
raise VerdictFrontmatterError("'measure_type' is declared but empty")
return ProposalFeatures(
affected_codes=_parse_affected_codes(fm["affected_codes"]),
measure_type=measure_type,
claimed_saving_nok=_parse_claimed_saving(fm["claimed_saving_nok"]),
description=measure_type,
)
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 carries
the realization signal in its rationale, and stands in for the durable HITL verdict a real
expert would supply via the same folder interface (målbilde §3).
KEYING (S3.2): a verdict is keyed on ITS OWN candidate when its frontmatter declares the
structural fields (``affected_codes`` / ``measure_type`` / ``claimed_saving_nok``), and on the
bundle's IR-projection candidate otherwise. The bundle key alone was single-candidate by
construction: a bundle holding verdicts about several candidates collapsed them onto one key, so
a verdict about candidate B scored a perfect match against candidate A's query and could be
folded into A's hypothesis prompt. The fallback is what keeps every pre-S3.2 seed working
unchanged; the fields are OPTIONAL, never required."""
bundle = okf.navigate_bundle(bundle_dir)
fallback: ProposalFeatures | None = None
verdicts = []
for vf in bundle.verdicts:
features = _features_from_verdict_frontmatter(vf.frontmatter)
if features is None:
# Read the IR projection lazily: a bundle whose verdicts all carry their own key does
# not need one, and this keeps the fallback path's behaviour byte-identical.
fallback = (
fallback if fallback is not None else optional_bundle_candidate_features(bundle_dir)
)
if fallback is None:
raise VerdictKeyUnavailable(
f"{vf.name}: this verdict declares no structural key "
f"({', '.join(_STRUCTURAL_FRONTMATTER_KEYS)}) and the knowledge base carries no "
"IR projection to fall back on, so there is no candidate to key it to"
)
features = fallback
verdicts.append(
capture_verdict(
features,
vf.frontmatter.get("decision", "approved"),
_verdict_rationale(vf.frontmatter),
)
)
return VerdictStore(verdicts=verdicts)
# --- Gated wiki-promotion (Fase 6, Steg 8): output layer -> context layer, HITL-gated ------------
# målbilde §3 (promoterings-gate) / §6 (kun godkjent kunnskap, aldri rå agent-output; provenance) /
# §7 (load-bearing: a non-approved verdict must NOT reach the wiki). R4 = optional+gated: this is a
# PUBLIC opt-in primitive, deliberately NOT wired into run_project — the system reads context; the
# gate/persona promotes (mirrors write_verdict's role split).
_APPROVED_DECISIONS = frozenset({"approved", "approved_with_adjustment"})
# A FIXED neutral index label carrying NO verdict signal. link_in_index folds it into index.md ->
# index_summary -> bundle_context verbatim, so passing the rationale here would leak the realization
# signal into the read-context on a path that bypasses the gate (målbilde §3/§6). Load-bearing:
# test_step8 Test C goes red if this is replaced by the rationale.
_PROMOTED_LINK_LABEL = "Promotert ekspert-vurdering (gated)"
class PromotionRefused(Exception):
"""The gate (målbilde §6): a non-approved verdict was offered for promotion. Fail-closed — the
wiki receives ONLY human/persona-approved knowledge, never raw agent output (self-contamination).
"""
def _safe_filename_token(verdict_id: str) -> str:
"""Turn a verbatim ``Verdict.id`` (arbitrary author string — sentinels, hashes, anything) into a
filename/link-safe token: keep ``[A-Za-z0-9._-]``, replace the rest with ``-``. A token that is
only separators/dots (degenerate, e.g. ``".."``) falls back to a content hash. This prevents an
id with ``/`` (an unnavigable link) or ``..`` from steering the written path — defense beside
``write_concept_file``'s fail-closed ``safe_resolve``. The original id is kept in frontmatter."""
token = re.sub(r"[^A-Za-z0-9._-]", "-", verdict_id)
if not token.strip(".-_"):
return hashlib.sha256(verdict_id.encode("utf-8")).hexdigest()[:16]
return token
def promote_verdict(
bundle_dir: str,
verdict: Verdict,
*,
approver: str,
experiment: str,
timestamp: str,
) -> Path:
"""Promote an APPROVED verdict from the raw output layer into the OKF context layer (the wiki) as
a ``type: verdict`` concept file, navigable by the next run's ``seed_store_from_bundle`` (Steg 8,
målbilde §3/§6/§7). GATE (fail-closed): a verdict whose ``decision`` is not an approval raises
``PromotionRefused`` and writes/links NOTHING — only human/persona-approved knowledge enters the
wiki. Provenance-stamped (who/which-experiment/when). ``timestamp`` is a required keyword (no
wall-clock default) so promotion is deterministic and the stamp reproducible.
The promoted file is MINIMAL as to the LEARNING SIGNAL: it does NOT reproduce the hand-authored
seed's structured signal fields (``realization_rate`` etc.) — the raw ``Verdict`` model carries
that only as ``rationale`` prose, which becomes the ``description`` frontmatter
``seed_store_from_bundle`` folds into ExpeL. It DOES carry its own structural KEY (S3.2:
``affected_codes`` / ``measure_type`` / ``claimed_saving_nok``), so the next run keys it on the
candidate it is about — a promoted verdict is frequently about a different candidate than the
one that bundle's IR projection describes. The index link uses a NEUTRAL label
(``_PROMOTED_LINK_LABEL``), so the signal reaches a prompt only via the gated fold, never via
``bundle_context`` (§3/§6) — and the structural key is signal-free by construction.
Round-trip caveat: ``render_frontmatter`` single-lines every value, so a ``measure_type``
containing newlines is re-read with those collapsed to spaces and would re-mint a different id.
Measure strings are single-line in practice; this is stated rather than defended against.
Known limitation (mirrors ``write_verdict``): ``_mint_id`` keys on the candidate features, so two
approved verdicts about the SAME candidate share an id -> share a filename -> last-write-wins;
the wiki grows one curated verdict file per distinct candidate measure, not per verdict event.
Returns the written path."""
if verdict.decision not in _APPROVED_DECISIONS:
raise PromotionRefused(
f"refusing to promote a non-approved verdict (decision={verdict.decision!r}); "
"only human/persona-approved knowledge enters the wiki (målbilde §6)"
)
f = verdict.proposal_features
frontmatter = {
"type": "verdict",
"decision": verdict.decision,
"description": verdict.rationale,
# S3.2: the promoted file carries its OWN structural key, so the next run's
# seed_store_from_bundle keys it on the candidate it is actually about rather than on
# whichever candidate that bundle's IR projection happens to describe. Written as the three
# fields _features_from_verdict_frontmatter reads back — all three, never a partial key.
"affected_codes": "[" + ", ".join(sorted(f.affected_codes)) + "]",
"measure_type": f.measure_type,
# ``str`` of the raw value, NOT a normalised format: it round-trips through
# ``_parse_claimed_saving``'s ``json.loads`` back to the same int/float, and ``_mint_id``
# hashes that raw value (``18000`` and ``18000.0`` are different keys).
"claimed_saving_nok": str(f.claimed_saving_nok),
"verdict_id": verdict.id,
"provenance": f"godkjent av {approver}; eksperiment {experiment}; {timestamp}",
"timestamp": timestamp,
# SPEC §5.2 provenance, reusing the EXISTING required ``timestamp`` keyword so no
# wall-clock default sneaks in. The approver is written verbatim: a scripted stand-in lands
# in ``machine-confirmed``, which is true of it.
"verified": okf.verified_field(approver, timestamp),
"tags": "[verdict, promoted, HITL]",
}
codes = ", ".join(sorted(f.affected_codes))
body = (
"# Promotert ekspert-vurdering\n\n"
f"{verdict.rationale}\n\n"
f"- Tiltak: {f.measure_type}\n"
f"- Berørte koder: {codes}\n"
f"- Beslutning: {verdict.decision}\n"
f"- Provenance: {frontmatter['provenance']}\n"
)
filename = f"promoted-verdict-{_safe_filename_token(verdict.id)}.md"
path = okf.write_concept_file(bundle_dir, filename, frontmatter, body)
okf.link_in_index(bundle_dir, filename, _PROMOTED_LINK_LABEL)
return path