feat(fase6): gate-promote approved verdicts back into the OKF wiki (Steg 8)
Close the last agentic-loop seam (målbilde §3/§6/§7/§11 step 6): an APPROVED verdict is promoted from the raw output layer into the context layer (the OKF bundle) as a navigable `type: verdict` concept file, so human/persona-approved knowledge reaches the next run's hypothesis. - okf.py (pure stdlib, MAF-free): render_frontmatter / write_concept_file / link_in_index — the D7-portable OKF write counterpart of navigate. - verdicts.py: promote_verdict + PromotionRefused gate (fail-closed; only approved decisions enter the wiki, never raw agent output), provenance stamp (who/experiment/when; timestamp a required kwarg), neutral index label (signal reaches a prompt only via the gated ExpeL fold, never bundle_context), _safe_filename_token (id sanitised for path/link). - R4 = optional+gated: a public opt-in primitive, NOT wired into run_project (mirrors write_verdict — the system reads, the gate promotes). - Load-bearing trio (test_step8_promotion_loadbearing.py): gate refuses a non-approved verdict, approved verdict is navigable, promoted signal stays out of the read-context — all proven RED-on-detach. Suite 144->148. Design hardened by an adversarial plan-critic (12 findings; the BLOCKER — index-link leak into bundle_context via index_summary — closed by the neutral label + a no-leak test). Honesty limits documented: promoted file is minimal (signal as prose only), and the learning-key id means same-candidate approvals share a filename (last-write-wins). 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:
parent
e2861cac0c
commit
6b645ad32a
6 changed files with 385 additions and 2 deletions
|
|
@ -143,6 +143,44 @@ def bundle_context(bundle: Bundle) -> str:
|
|||
return "\n\n".join(s for s in sections if s.strip())
|
||||
|
||||
|
||||
def render_frontmatter(frontmatter: dict[str, str]) -> str:
|
||||
"""Render a frontmatter dict as ``key: value`` lines (the inverse direction of
|
||||
``parse_frontmatter``, used by the Step-8 promotion writer). Scalar values are **single-lined**
|
||||
(every newline/CR collapses to a space) because ``parse_frontmatter`` is line-oriented and stops
|
||||
at the first ``---`` line — a multi-line value would otherwise corrupt the block or terminate it
|
||||
early. NOT a bijection: this only guarantees that the single-line fields it writes re-parse to
|
||||
the same strings; ``parse_frontmatter`` keeps quotes and treats ``tags: [...]`` as a literal
|
||||
string, so callers pass already-formatted values. Keys are emitted in insertion order."""
|
||||
return "\n".join(f"{key}: {' '.join(str(value).split())}" for key, value in frontmatter.items())
|
||||
|
||||
|
||||
def write_concept_file(bundle_dir: str, name: str, frontmatter: dict[str, str], body: str) -> Path:
|
||||
"""Write a typed OKF concept file (``---`` frontmatter + markdown body) into ``bundle_dir``,
|
||||
path-safe via ``safe_resolve`` (fail-closed: a ``name`` escaping the bundle raises
|
||||
``PathSecurityError``). Pure stdlib — the D7-portable counterpart of ``navigate_bundle``'s read.
|
||||
Returns the written path."""
|
||||
resolved = Path(safe_resolve(bundle_dir, name))
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved.write_text(f"---\n{render_frontmatter(frontmatter)}\n---\n\n{body}", encoding="utf-8")
|
||||
return resolved
|
||||
|
||||
|
||||
def link_in_index(bundle_dir: str, target_name: str, label: str) -> bool:
|
||||
"""Append an intra-bundle cross-link ``- [label](target_name)`` to ``index.md`` so
|
||||
``navigate_bundle`` (which follows ONLY index cross-links) reaches a newly written file.
|
||||
Idempotent: if a link to ``target_name`` already exists the index is left untouched. Returns
|
||||
whether a link was added. ``label`` is supplied by the caller and ends up in ``index_summary``
|
||||
(hence ``bundle_context``) verbatim, so the promotion policy passes a NEUTRAL label carrying no
|
||||
verdict signal (målbilde §3/§6). Known MVP limitation: the read-modify-write is not atomic."""
|
||||
resolved = Path(safe_resolve(bundle_dir, _INDEX_NAME))
|
||||
body = resolved.read_text(encoding="utf-8")
|
||||
if f"]({target_name})" in body:
|
||||
return False
|
||||
prefix = body if body.endswith("\n") else body + "\n"
|
||||
resolved.write_text(f"{prefix}- [{label}]({target_name})\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from __future__ import annotations
|
|||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -354,3 +355,90 @@ def seed_store_from_bundle(bundle_dir: str) -> VerdictStore:
|
|||
for vf in bundle.verdicts
|
||||
]
|
||||
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: it does NOT reproduce the hand-authored seed's structured fields
|
||||
(``realization_rate`` etc.) — the raw ``Verdict`` model carries the learning signal only as
|
||||
``rationale`` prose, which becomes the ``description`` frontmatter ``seed_store_from_bundle``
|
||||
folds into ExpeL. 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).
|
||||
|
||||
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,
|
||||
"verdict_id": verdict.id,
|
||||
"provenance": f"godkjent av {approver}; eksperiment {experiment}; {timestamp}",
|
||||
"timestamp": 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue