diff --git a/CLAUDE.md b/CLAUDE.md index 0413c40..1ee1ce0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,24 @@ Python ≥3.10. MAF (`agent-framework-core` 1.9.0). Pakkehåndtering: `uv`. To b `tests/test_step7_async_loop_loadbearing.py` — en dom droppet etter Run A MÅ nå Run B's prompt (Run B bruker FERSK store → overføringen er fil-løkka, ikke in-memory-carryover); tom-inbox-kontroll beviser kausalitet. Markør = realiseringsverdi som finnes ingen steder i bundelen (ikke frøets 0.82). +- **Gated wiki-promotering (Steg 8, målbilde §3/§6/§7):** når en ekspert/persona GODKJENNER et + utfall, løfter `verdicts.promote_verdict` det fra RAW output-laget inn i kontekst-laget (OKF-bundelen) + som en `type: verdict`-konseptfil, navigerbar av neste kjørings `seed_store_from_bundle`. **Gaten er + fail-closed:** en ikke-godkjent dom (`decision ∉ {approved, approved_with_adjustment}`) raiser + `PromotionRefused` og skriver/linker INGENTING — kun menneske/persona-godkjent kunnskap når wikien, + aldri rå agent-output (selv-forurensning). Provenance-stemplet (hvem/eksperiment/når; `timestamp` er + påkrevd keyword, ingen wall-clock-default → deterministisk). **OKF-skriveren bor i `okf.py` og er ren + stdlib** (D7-portabel, MAF-fri — håndhevet av `test_okf_is_maf_free`); navigasjon følger KUN + index-cross-links, så `promote_verdict` linker filen i `index.md` via en NØYTRAL label (ellers lekker + signalet inn i `index_summary`→`bundle_context` utenom gaten). **R4 = valgfri+gated:** `promote_verdict` + er en offentlig opt-in-primitiv, wires IKKE inn i `run_project` (speiler `write_verdict` — systemet + leser; gaten/personaen promoterer). Ærlighets-grenser: promotert fil er MINIMAL (læringssignal kun som + `description`/body-prosa, reproduserer ikke seedens strukturerte `realization_rate` o.l.); id = + læringsnøkkel, så to godkjenninger om samme kandidat deler filnavn (last-write-wins, som `write_verdict`) + — wikien vokser én kuratert fil per distinkt kandidat, ikke per dom-hendelse. Load-bearing-trio + (`tests/test_step8_promotion_loadbearing.py`): gaten avviser ikke-godkjent dom (RØD uten gate); godkjent + dom er navigerbar (RØD når `link_in_index` detaches); promotert signal holdes ute av `bundle_context` + (RØD når en beskrivende index-label lekker det inn). Index-RMW er ikke-atomisk (enprosess-MVP). - **Kostnadsdisiplin:** utvikle primært på lokal profil (gratis); Foundry/Azure (privat tenant finnes) kun til målrettet, minimal verifisering; billigste modeller + små syntetiske data + harde token-tak. Ingen tunge test-kjøringer. - **STATE.md er local-only** (gitignored). Voyage session-state er efemert; STATE.md er kanonisk kontinuitet. - Prosess: Voyage-plugin (`/trekbrief → /trekplan → /trekexecute → /trekreview`) per større fase. diff --git a/README.md b/README.md index af2dcda..267bf6e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Generic, open framework on **Microsoft Agent Framework (MAF)** for finding cost-savings / efficiency proposals *within* each project of a portfolio of independent projects. Multiple agents collaborate to generate candidate proposals; a mandatory deterministic validator (solver + Monte Carlo) decides the numbers; domain experts review via human-in-the-loop, and the system learns from their verdicts. -> **Status:** Early development. The deterministic backbone is solid; the agentic learning loop is being wired one load-bearing seam at a time — **Steps 1, 3/4, 5 and 7 are wired** (OKF-navigated context, checker gate, informed refinement, and the async file feedback loop; see below). Step 8 (gated wiki promotion) remains. Not yet end-to-end usable. +> **Status:** Early development. The deterministic backbone is solid; the agentic learning loop is being wired one load-bearing seam at a time — **Steps 1, 3/4, 5, 7 and 8 are wired** (OKF-navigated context, checker gate, informed refinement, the async file feedback loop, and gated wiki promotion; see below). Not yet end-to-end usable. > **Disclaimer — technical framework only.** This project is a *technical framework*. Organizations that deploy it are themselves responsible for ensuring a valid processing purpose and for any required assessments (DPIA, risk/ROS, security reviews, etc.). The framework ships technical affordances (local-only mode, provenance/audit logging, no silent data egress) to *enable* compliant use, but makes no compliance guarantees. @@ -21,7 +21,7 @@ The mandatory deterministic backbone (validator + budget meter + provenance) is - **Steps 3/4 — checker gates the reasoning (wired).** Two falsifiers now act on the same candidate: the deterministic validator gates the **numbers** (blocking, as before), and the maker-checker's checker gates the **reasoning** (target picture §2/§6). The checker ends its turn with a `VERDICT: APPROVE` / `VERDICT: REJECT — ` line; an explicit reject blocks an otherwise-validated proposal (`run_project` surfaces both debate participants via `output_from=agents` and overrides the outcome to a checker-sourced `Rejection`). The gate is opt-in-reject (fail-open on a missing marker), and `provenance.validator_decision` stays honest — it reflects the validator only, never the checker. Load-bearing: `tests/test_checker_gate_loadbearing.py` goes red on *either* detach (revert `output_from`, or drop the override). - **Step 5 — informed refinement (wired).** The proposer's bounded retry is no longer blind: the validator's *previous* `Rejection.reason` is fed into the next attempt's prompt (`generate.py` `generate_via_llm` → `_build_messages(prior_rejection=...)`), so the model corrects against the falsification instead of re-answering identically (target picture §5/§7). It carries only the most-recent reason (never an accumulated history, never the rejected proposal JSON) and runs under the *existing* `max_attempts` + token-meter cap — no new loop, so "improve until good enough" without a ceiling stays impossible. The only per-attempt falsifier here is the validator; seeding generation with the checker's critique is a run-level, separately-scoped concern and is deliberately not done here. Load-bearing: `tests/test_step5_refine_loadbearing.py` goes red when the reason is detached from the prompt (the outcome never flips *and* the verbatim-reason assertion fails), with a bounded control proving the loop still stops at `max_attempts`. - **Step 7 — async file feedback loop (wired).** `run_project(..., verdict_dir=...)` adds the long feedback timescale (target picture §3/§7): an expert/persona drops a verdict file (plain JSON — the raw output layer, §10 R2) into an inbox folder *after* a run, and a separate, later run ingests it — merged into the store *before* the Step-1 fold — so a verdict that landed out of band reaches the next hypothesis. The loop is fully resumable across runs separated in time; no live session is assumed. The system *reads* the folder, the expert/persona *writes* it (§3 role split), so `run_project` deliberately does **not** persist its own captured verdict back (that is the outbox / Step-8 concern). Ingestion is tolerant (a missing folder, foreign or half-written files are skipped, not raised) and **merges** (never replaces), preserving `run_portfolio`'s cross-project store. Reachable from the CLI via `--bundle-dir --verdict-dir`. Load-bearing: `tests/test_step7_async_loop_loadbearing.py` — a verdict dropped after run A must reach run B's prompt (run B uses a *fresh* store, so the transfer is the file loop, not in-memory carryover), with an empty-inbox control proving causality. -- **Step 8** (gated wiki promotion) — not yet wired. +- **Step 8 — gated wiki promotion (wired).** When an expert/persona **approves** an outcome, `verdicts.promote_verdict` lifts it from the raw output layer into the context layer (the OKF bundle) as a `type: verdict` concept file, navigable by the next run's `seed_store_from_bundle` (target picture §3/§6/§7). The **gate** is fail-closed: a verdict whose decision is not an approval raises `PromotionRefused` and writes/links nothing — only human/persona-approved knowledge enters the wiki, never raw agent output (self-contamination). The promotion is provenance-stamped (who approved / which experiment / when — `timestamp` is a required keyword, no wall-clock default). The OKF writer lives in `okf.py` and stays pure stdlib (D7-portable, MAF-free). **R4 = optional + gated:** `promote_verdict` is a public opt-in primitive, deliberately **not** wired into `run_project` (mirrors `write_verdict` — the system reads context; the gate/persona promotes). Two honesty limits: the promoted file is *minimal* (it carries the learning signal only as `description`/body prose — it does not reproduce the hand-authored seed's structured `realization_rate` etc.), and because the verdict id is the learning key, two approvals about the same candidate share a filename (last-write-wins, like `write_verdict`) — the wiki grows one curated file per distinct candidate, not per verdict event. Load-bearing trio (`tests/test_step8_promotion_loadbearing.py`): the gate refuses a non-approved verdict (red if the gate is removed), the approved verdict is navigable (red if `link_in_index` is detached), and the promoted signal stays out of `bundle_context` — reaching a prompt only via the gated ExpeL fold (red if a descriptive index label leaks it into the read-context). ## Docs diff --git a/src/portfolio_optimiser/okf.py b/src/portfolio_optimiser/okf.py index d0c3b41..f58162d 100644 --- a/src/portfolio_optimiser/okf.py +++ b/src/portfolio_optimiser/okf.py @@ -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 diff --git a/src/portfolio_optimiser/verdicts.py b/src/portfolio_optimiser/verdicts.py index 70f669b..b854cac 100644 --- a/src/portfolio_optimiser/verdicts.py +++ b/src/portfolio_optimiser/verdicts.py @@ -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 diff --git a/tests/test_okf.py b/tests/test_okf.py index dddade8..e9920ba 100644 --- a/tests/test_okf.py +++ b/tests/test_okf.py @@ -97,6 +97,71 @@ def test_parse_frontmatter_reads_scalar_fields() -> None: assert fm["decision"] == "approved_with_adjustment" +def test_render_frontmatter_roundtrips_consumed_fields(tmp_path) -> None: + """Step-8 writer: ``render_frontmatter`` + ``write_concept_file`` emit a block that + ``parse_frontmatter`` re-reads with the fields ``seed_store_from_bundle`` consumes + (``type``, ``decision``, ``description``) intact. NOT a bijection — only these scalar fields + are guaranteed to survive write -> read.""" + fm = { + "type": "verdict", + "decision": "approved", + "description": "LED-retrofit godkjent (realiseringsgrad=0.57)", + "verdict_id": "abc123", + } + okf.write_concept_file(str(tmp_path), "promoted-verdict-abc123.md", fm, "body prose\n") + parsed = okf.parse_frontmatter(tmp_path / "promoted-verdict-abc123.md") + assert parsed["type"] == "verdict" + assert parsed["decision"] == "approved" + assert parsed["description"] == "LED-retrofit godkjent (realiseringsgrad=0.57)" + assert parsed["verdict_id"] == "abc123" + + +def test_render_frontmatter_single_lines_scalars(tmp_path) -> None: + """A multi-line rationale must NOT corrupt the line-oriented frontmatter block (parse stops at + ``---``). Newlines in a scalar value are flattened to spaces, so every following key survives.""" + fm = { + "type": "verdict", + "description": "line one\nline two\n---\nnot a delimiter", + "decision": "approved", + } + okf.write_concept_file(str(tmp_path), "f.md", fm, "body\n") + parsed = okf.parse_frontmatter(tmp_path / "f.md") + assert "\n" not in parsed["description"] + assert parsed["decision"] == "approved" # the trailing key was NOT lost to a spurious --- + + +def _minimal_bundle(tmp_path) -> str: + (tmp_path / "index.md").write_text( + "---\ntype: index\n---\n\n# Bundle\n\n- [proj](bygg.md)\n", encoding="utf-8" + ) + (tmp_path / "bygg.md").write_text("---\ntype: project\n---\n\nbody\n", encoding="utf-8") + return str(tmp_path) + + +def test_link_in_index_makes_concept_file_navigable(tmp_path) -> None: + """Step-8 writer: a file written into a bundle is reachable by ``navigate_bundle`` only after + ``link_in_index`` adds an intra-bundle cross-link the navigator follows (``_LINK_RE``).""" + bundle_dir = _minimal_bundle(tmp_path) + fm = {"type": "verdict", "decision": "approved", "description": "d"} + okf.write_concept_file(bundle_dir, "promoted-verdict-x.md", fm, "body\n") + assert "promoted-verdict-x.md" not in {f.name for f in okf.navigate_bundle(bundle_dir).files} + added = okf.link_in_index(bundle_dir, "promoted-verdict-x.md", "Promotert") + assert added is True + bundle = okf.navigate_bundle(bundle_dir) + assert "promoted-verdict-x.md" in {f.name for f in bundle.files} + assert [f.name for f in bundle.verdicts] == ["promoted-verdict-x.md"] + + +def test_link_in_index_is_idempotent(tmp_path) -> None: + """Linking the same target twice adds exactly one bullet (returns ``False`` the second time) — + so re-promoting an existing verdict does not double-link the index.""" + bundle_dir = _minimal_bundle(tmp_path) + assert okf.link_in_index(bundle_dir, "promoted-verdict-x.md", "A") is True + assert okf.link_in_index(bundle_dir, "promoted-verdict-x.md", "B") is False + body = (tmp_path / "index.md").read_text(encoding="utf-8") + assert body.count("(promoted-verdict-x.md)") == 1 + + def test_okf_is_maf_free() -> None: """D7 portability: ``okf.py`` IMPORTS no ``agent_framework`` / ``mcp`` (the docstring may name them to document the constraint, exactly as ``retrieval.py`` does) — checked via the AST, not a diff --git a/tests/test_step8_promotion_loadbearing.py b/tests/test_step8_promotion_loadbearing.py new file mode 100644 index 0000000..08f2947 --- /dev/null +++ b/tests/test_step8_promotion_loadbearing.py @@ -0,0 +1,174 @@ +"""Step-8 load-bearing seam (målbilde §3 / §6 / §7 / §11 step 6): GATED wiki-promotion — when an +expert/persona APPROVES an outcome, it is promoted from the raw output layer into the context layer +(the OKF bundle) as a ``type: verdict`` concept file; a NON-approved verdict MUST NOT reach the wiki. + +The gap (fot-i-bakken): an approved verdict never re-entered the context layer, so the loop learned +only via injected/dropped seeds. Step 8 adds ``promote_verdict`` — the public, opt-in (R4) promotion +primitive, deliberately NOT wired into ``run_project`` (the system reads context; the gate promotes). + +Three load-bearing tests (per the Fase-2 green-but-dead trap): +- Test A (GATE, §7 mandatory): a rejected verdict — otherwise fully promotable — raises + ``PromotionRefused`` and writes/links NOTHING. Goes RED the moment the gate (the ``raise``) is + detached: the rejected verdict's file + index link then appear (self-contamination, §6). +- Test B (NAVIGABLE): an approved verdict is written AND linked so ``navigate_bundle`` reaches it — + proving promotion is non-decorative (a file the loop cannot navigate never reaches the next run). + Goes RED if ``link_in_index`` is detached (the promoted file ∉ ``bundle.verdicts``). +- Test C (NO-LEAK): the promoted verdict's realization marker (0.57 — absent from every bundle file) + stays OUT of ``bundle_context`` — it reaches a prompt only via the gated ExpeL fold, never the + read-context. Goes RED if ``promote_verdict`` passes the rationale (not the neutral label) into + the index, leaking the signal into ``index_summary`` -> context (the BLOCKER the design closes). + +Transitive coverage: ``test_step1_expel_loadbearing.py`` already proves +``seed_store_from_bundle`` -> ExpeL fold -> prompt; Test B proves the promoted verdict ∈ +``bundle.verdicts`` (what that seeder reads). Together: promoted approved knowledge reaches the next +run's hypothesis — proven without re-running ``run_project``. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from portfolio_optimiser import okf +from portfolio_optimiser.verdicts import ( + ProposalFeatures, + PromotionRefused, + Verdict, + bundle_candidate_features, + promote_verdict, +) + +BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro" + +# A realization marker absent from EVERY bundle file (the seed is 0.82). It can reach a prompt only +# via the ingest -> ExpeL fold path; in the read-context it must NEVER appear (Test C). +_MARKER = "realiseringsgrad=0.57" +_APPROVED_ID = "STEG8-APPROVED" + + +def _copy_bundle(tmp_path: Path) -> str: + """Promotion WRITES into the bundle, so every test works on a throwaway copy — the shared + framework-neutral fixture is never mutated.""" + dst = tmp_path / "bundle" + shutil.copytree(BUNDLE_DIR, dst) + return str(dst) + + +def _approved_verdict(bundle_dir: str, *, vid: str = _APPROVED_ID) -> Verdict: + """An approved verdict keyed on the bundle candidate, carrying the realization marker in its + rationale — the same key/payload shape the Step-1 fold consumes.""" + return Verdict( + id=vid, + proposal_features=bundle_candidate_features(bundle_dir), + decision="approved", + rationale=f"LED-retrofit godkjent med realiseringskorreksjon ({_MARKER})", + ) + + +def _promoted_files(bundle_dir: str) -> list[str]: + return [p.name for p in Path(bundle_dir).glob("promoted-verdict-*.md")] + + +# --- Test A: the gate --------------------------------------------------------------------------- + + +def test_rejected_verdict_is_not_promoted(tmp_path) -> None: + """LOAD-BEARING GATE (§7): a NON-approved verdict — otherwise fully promotable (valid id, + writable bundle) so detaching the gate WOULD produce a file+link — raises ``PromotionRefused`` + and leaves the wiki untouched. RED if the gate is removed.""" + bundle_dir = _copy_bundle(tmp_path) + index_before = (Path(bundle_dir) / "index.md").read_text(encoding="utf-8") + rejected = Verdict( + id="STEG8-REJECTED", + proposal_features=ProposalFeatures( + affected_codes=frozenset({"ENERGI-TOTAL-EL"}), + measure_type="LED-retrofit", + claimed_saving_nok=30000, + ), + decision="rejected", + rationale="expert rejected: realiseringsgapet for stort i denne konteksten", + ) + + with pytest.raises(PromotionRefused): + promote_verdict( + bundle_dir, rejected, approver="persona", experiment="exp-A", timestamp="2026-06-30" + ) + + assert _promoted_files(bundle_dir) == [], "a rejected verdict must NOT be written to the wiki" + assert (Path(bundle_dir) / "index.md").read_text(encoding="utf-8") == index_before, ( + "a rejected verdict must NOT be linked into the index" + ) + + +# --- Test B: positive promotion is navigable ---------------------------------------------------- + + +def test_approved_verdict_is_promoted_and_navigable(tmp_path) -> None: + """LOAD-BEARING NAVIGABILITY: an approved verdict is written as a ``type: verdict`` file AND + linked so ``navigate_bundle`` reaches it (the seeder ``seed_store_from_bundle`` reads exactly + ``bundle.verdicts``). RED if ``link_in_index`` is detached.""" + bundle_dir = _copy_bundle(tmp_path) + path = promote_verdict( + bundle_dir, + _approved_verdict(bundle_dir), + approver="persona", + experiment="exp-B", + timestamp="2026-06-30", + ) + + assert path.name == "promoted-verdict-STEG8-APPROVED.md" + fm = okf.parse_frontmatter(path) + assert fm["type"] == "verdict" + assert fm["decision"] == "approved" + assert fm["verdict_id"] == _APPROVED_ID + assert "persona" in fm["provenance"] and "exp-B" in fm["provenance"] + + verdict_names = {f.name for f in okf.navigate_bundle(bundle_dir).verdicts} + assert path.name in verdict_names, ( + "the promoted verdict is not navigable — link_in_index did not make it reachable, so it " + "could never reach a later run (decorative promotion)" + ) + + +def test_promote_sanitizes_unsafe_verdict_id(tmp_path) -> None: + """A verdict id is read verbatim and can be an arbitrary author string; it must be sanitized + before it becomes a filename/link, or a ``/`` makes the link unnavigable and ``..`` escapes the + bundle. The original id is preserved in the ``verdict_id`` frontmatter.""" + bundle_dir = _copy_bundle(tmp_path) + nasty = _approved_verdict(bundle_dir, vid="../../etc/evil id") + path = promote_verdict(bundle_dir, nasty, approver="p", experiment="e", timestamp="2026-06-30") + + assert path.parent == Path(bundle_dir) # written INSIDE the bundle, not escaped + assert "/" not in path.name.replace("promoted-verdict-", "").replace(".md", "") + assert path.name in {f.name for f in okf.navigate_bundle(bundle_dir).verdicts} + assert okf.parse_frontmatter(path)["verdict_id"] == "../../etc/evil id" # original preserved + + +# --- Test C: the promoted signal does NOT leak into the read-context ----------------------------- + + +def test_promoted_signal_stays_out_of_bundle_context(tmp_path) -> None: + """LOAD-BEARING NO-LEAK (§3/§6): after promotion the realization marker is in the bundle (in the + promoted ``type: verdict`` file) but ABSENT from ``bundle_context`` — it reaches a prompt only + via the gated ExpeL fold, never the read-context. RED if ``promote_verdict`` passes the rationale + (not the neutral label) into the index link, leaking the marker into ``index_summary``.""" + bundle_dir = _copy_bundle(tmp_path) + path = promote_verdict( + bundle_dir, + _approved_verdict(bundle_dir), + approver="persona", + experiment="exp-C", + timestamp="2026-06-30", + ) + + bundle = okf.navigate_bundle(bundle_dir) + context = okf.bundle_context(bundle) + assert _MARKER not in context, ( + "the promoted verdict's realization signal leaked into the read-context — promotion must " + "pass a NEUTRAL index label, so the signal reaches a prompt only via the gated ExpeL fold" + ) + # though it IS present in the bundle (just excluded from context, like the seed verdict): + assert _MARKER in okf.parse_frontmatter(path)["description"] + assert path.name in {f.name for f in bundle.verdicts}