portfolio-optimiser/src/portfolio_optimiser/evals/v1_gate.py
Kjell Tore Guttormsen 50c9763706
feat(frozen-bundles): the measurements read a pinned copy, not another repo's build dir
Measured 2026-09-17 17:43: vegnormal-okf rebuilt build/ferdig/r761-2025 while this
repository's v1 gate, the stress judge and four corpus tests pointed straight at it.
Rows 6-7 went IKKE MAALT and five tests fell, for a change no one here made. The failure
mode was never falsehood - the gate says IKKE MAALT and exits non-zero, never green - it
was instability: two projects shared a directory neither owns, so what this repository
MEASURES could move without a commit here.

A copy alone would push that directory one move away, so the copy comes with a pin.
frozen_bundles.json (tracked) carries path + sha256 + file count per base; the bundles
themselves are NEVER committed here. Three states, separated by construction: match ->
resolves; gone -> FrozenBundleMissing (an OSError, so the gate's existing except OSError
gives IKKE MAALT + exit 1 unchanged and the corpus tests SKIP, MAJOR-3's ceiling); drift
-> FrozenBundleDrift (a ValueError), loud, named, and never a skip. The two classes are
deliberately unrelated: a caller that catches "missing" to skip must not swallow "drift".

The NAME is hashed alongside the bytes, and the directory name carries the first 12 chars
of the digest so a stale copy is visible in ls. Renewal is a decision: new copy + new pin
in the SAME commit (README). --bundle-root / PORTFOLIO_VEGNORMAL_ROOT stays as the
operator's explicit, UNPINNED live mount.

Iron Law: the tests were written and run RED first (collection error, then two arms of my
own making). Load-bearing MEASURED, eight mutations all red against the WHOLE suite with a
green control of 1984 passed / 5 skipped / 5 xfailed and a strict node-id superset
(1977 -> 1994, 0 removed): M1 the pin is never verified (7) - M2 drift collapsed into
missing (5) - M3 the name is not hashed (40) - M4 the gate seam reverted to root/name (1) -
M5 the corpus helpers skip on drift too (4, one per file) - M6a the slash spelling back in
src (1) - M6b the quoted path segment back in a test (1) - M7 the directory name drops the
short digest (1, and 45 skipped, which proves absence is a SKIP and not a false green) -
M8 the explicit override ignored (3, two of them in test_stress_judge_loadbearing.py,
independent witnesses older than this work).

M2 FALSIFIED THE TEST FIRST: the four parametrised arms did not go red, they went to SKIP
(5 -> 9 skipped) and stayed green - pytest.skip inside a pytest.raises is not a failure.
The arm now catches pytest.skip.Exception explicitly and turns it into an AssertionError.

grep -rnE 'vegnormal-okf/build|["'"'"']vegnormal-okf["'"'"']' src tests contexts -> 0
(3 + 4 hits before; the three remaining prose mentions document history and are allowed).
Gate re-run against the frozen copy: identical to the live mount (rows 0/3 - 0/3 - 3/8 -
no report - 3/8 - IKKE MAALT - 1/20, exit 1).

Order 20260917T223645Z-1296211942-from-.claude.

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

1042 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The v1 gate: how far portfolio-optimiser is from v1, row by row, with an exit code.
One command, deterministic, offline: no model call, no network, no quota. Exit 0 only when every
FAILING row (1-6) is green; exit 1 otherwise; exit 2 on wrong usage. Row 7 is a diagnosis and never
moves the exit code.
The gate defines the CONTRACT a later capability must deliver into, not the generator. It reads a
rounds directory of a fixed shape (see ``--help``); nothing in the product writes it yet. Every
denominator comes from a source outside the thing being measured: the number of rounds is the
operator's choice (3), the feedback types are the eight named in ``v1_gate.json``, the MAF points
are a listed, approved-or-not set, and rows 6-7 read the stress round's own artefacts.
Rows 3 and 6 run NAMED tests (``v1_gate.json``) in a child pytest with ``--runxfail``: a type
counts only when every test registered for it passes, so a missing surface is a red test, never a
missing one.
"""
from __future__ import annotations
import argparse
import ast
import json
import os
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
import difflib
import re
from pathlib import Path
from typing import Any
from portfolio_optimiser.validator import rejection_stage
_DATA = Path(__file__).with_name("v1_gate.json")
_PACKAGE_SRC = Path(__file__).resolve().parents[1]
_REPO_ROOT = Path(__file__).resolve().parents[3]
#: Gitignored by default: the remote is public, and a domain expert's feedback must never reach it.
DEFAULT_ROUNDS_DIR = "v1-rounds"
#: A line of an AI-authored document shorter than this is too generic to identify its origin.
_AI_LINE_MIN = 30
#: Row 2 (d): a change in validated NOK below this share of the earlier figure is noise.
_NOK_NOISE = 0.01
#: Printed on every run: the one thing rows 1-2 cannot prove.
ATTESTATION = "rad 12 beviser ikke at en fagperson skrev feedbacken; det bekrefter operatøren"
GREEN = "GRØNN"
RED = "RØD"
DIAGNOSIS = "DIAGNOSE"
#: A row whose evidence could not be read. Never green: on a failing row it fails the exit code.
NOT_MEASURED = "IKKE MÅLT"
ROUNDS_CONTRACT = """\
Rundekatalogen (--rounds-dir) har fast form. Runde n = tilbakemelding på rapport n-1, så kjøring n:
<rounds-dir>/0/report.md rapporten fra grunnkjøringen (runde 0)
<rounds-dir>/0/outcome.json grunnkjøringen som runde 1 måles mot
<rounds-dir>/<n>/feedback.json fagpersonens tilbakemelding på rapport n-1 (n = 1, 2, 3)
<rounds-dir>/<n>/outcome.json kjøring n, gjort ETTER den tilbakemeldingen
<rounds-dir>/<n>/report.md rapporten bygget fra kjøring n
<rounds-dir>/3/report.kept.md runde 3-rapporten slik fagpersonen BEHOLDT den
feedback.json:
{"author": "<fagpersonen>", "given_at": "<ISO-8601 med tidssone>",
"report_unchanged": true (valgfri, kun runde 3: kvitterer for en urørt rapport),
"items": [{"id": "<unik id>", "type": <1-8>, "text": "<tilbakemeldingen>"}]}
outcome.json (hver rad sjekkes mot kjøringens egen <outbox>/<run_id>-coverage.json):
{"run_id": "<kjøringen>", "outbox": "<utboksen, relativ til denne fila eller absolutt>",
"approaches": [{"id": "<tilnærming>", "validated": true|false,
"stage": "<avvisningsstadium som validator.rejection_stage gir, tom når validert>",
"validated_nok": <tall eller null>,
"feedback_ids": ["<id-er fra feedback.json som forklarer raden>"]}],
"removed": [{"id": "<tilnærming fjernet siden forrige runde>", "feedback_ids": [...]}]}
En runde har målbar endring når kjøringen skiller seg fra forrige på minst én av (a) settet av
tilnærmings-id-er, (b) hvilke som er validert, (c) avvisningsstadium, (d) validert NOK (endring
under 1 % er støy) — OG minst én endret rad bærer en feedback-id gitt i DENNE runden, gitt mellom
de to kjøringene. Hver runde må ha minst ett nytt punkt og egne id-er. Tekst tatt fra et
AI-forfattet dokument (docs/ekspert-svar.md) teller aldri. Rad 1-2 beviser FORM, ikke forfatterskap.
Rad 4 teller innholdslinjer (ikke blanke, skillelinjer eller tabellrammer) som står uendret og i
samme rekkefølge; fagpersonens tillegg vises som eget tall. --rounds-dir inne i repoet må være
gitignored.
"""
@dataclass(frozen=True)
class Row:
key: str
title: str
k: int | None
n: int | None
status: str
reason: str
failing: bool = True
exceptions: tuple[str, ...] = ()
diagnostics: tuple[str, ...] = ()
def line(self) -> str:
k = "" if self.k is None else str(self.k)
n = "" if self.n is None else str(self.n)
return f"{self.title} | {k} av {n} | {self.status} | {self.reason}"
def load_config(path: Path = _DATA) -> dict[str, Any]:
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return data
# ---------------------------------------------------------------------------------------------
# Rows 1 and 2 — rounds
# ---------------------------------------------------------------------------------------------
def _norm(text: str) -> str:
"""Whitespace collapsed and case folded — the AI guard's one normalisation."""
return " ".join(text.split()).casefold()
def ai_authored_lines(repo_root: Path, docs: Sequence[str]) -> tuple[str, str] | None:
"""The normalised full text and the joined long lines of every AI-authored document, or
``None`` when one of them cannot be read — the guard then cannot run, and a round it cannot
check is never counted.
A KNOWN-TEXT filter, not an authorship detector: it refuses text lifted from the listed
documents (case and whitespace ignored), and nothing else. Authorship itself is not verifiable
here, and the gate's output says so on every run (``ATTESTATION``)."""
texts: list[str] = []
for rel in docs:
path = repo_root / rel
if not path.is_file():
return None
texts.append(path.read_text(encoding="utf-8"))
lines = [
_norm(line.strip().lstrip(">*-#|` ").strip())
for text in texts
for line in text.splitlines()
]
return _norm("\n".join(texts)), "\n".join(x for x in lines if len(x) >= _AI_LINE_MIN)
def _is_ai_text(text: str, ai: tuple[str, str]) -> bool:
whole, lines = ai
item = _norm(text)
if len(item) >= _AI_LINE_MIN and item in whole:
return True
return any(line in item for line in lines.splitlines() if line)
def _parse_time(value: Any) -> datetime | None:
try:
stamp = datetime.fromisoformat(str(value))
except ValueError:
return None
return stamp if stamp.tzinfo is not None else None
@dataclass(frozen=True)
class Feedback:
ids: frozenset[str]
texts: frozenset[str]
given_at: datetime
report_unchanged: bool
def _read_feedback_file(round_dir: Path, ai: tuple[str, str] | None) -> tuple[Feedback | None, str]:
"""One round's feedback file, checked on its own; ``(None, why)`` when it does not hold."""
name = f"runde {round_dir.name}"
path = round_dir / "feedback.json"
if not path.is_file():
others = sorted(p.name for p in round_dir.glob("feedback.*")) if round_dir.is_dir() else []
extra = f" (fant {', '.join(others)}; kontrakten er feedback.json)" if others else ""
return None, f"{name}: feedback.json mangler{extra}"
try:
data = json.loads(path.read_text(encoding="utf-8"))
author = str(data["author"]).strip()
items = list(data["items"])
given_raw = data["given_at"]
except (ValueError, KeyError, TypeError) as exc:
return None, f"{name}: feedback.json uleselig ({exc!r})"
if not author:
return None, f"{name}: feedback.json navngir ingen fagperson"
given_at = _parse_time(given_raw)
if given_at is None:
return None, f"{name}: given_at er ikke et ISO-tidsstempel med tidssone"
if ai is None:
return None, f"{name}: AI-vakten kunne ikke lese sine kilder"
ids: set[str] = set()
texts: set[str] = set()
for item in items:
try:
item_id, item_type, text = str(item["id"]), int(item["type"]), str(item["text"])
except (KeyError, TypeError, ValueError):
return None, f"{name}: et feedback-punkt mangler id/type/text"
if not item_id or not text.strip() or not 1 <= item_type <= 8:
return None, f"{name}: punkt {item_id!r} er tomt eller har ukjent type"
if item_id in ids:
return None, f"{name}: punkt-id {item_id!r} er brukt to ganger"
if _is_ai_text(text, ai):
return None, f"{name}: punkt {item_id!r} er AI-forfattet tekst"
ids.add(item_id)
texts.add(_norm(text))
if not ids:
return None, f"{name}: feedback.json har ingen punkter"
unchanged = data.get("report_unchanged") is True
return Feedback(frozenset(ids), frozenset(texts), given_at, unchanged), ""
def read_rounds(
rounds_dir: Path, required: int, ai: tuple[str, str] | None
) -> dict[int, tuple[Feedback | None, str]]:
"""Every round's feedback, with the cross-round rules applied in round order: a round must
bring at least one point no earlier round gave, may not reuse an earlier round's ids (tracing is
per round), must come after the previous round's feedback, and must have been given on a
report (``<n-1>/report.md``)."""
result: dict[int, tuple[Feedback | None, str]] = {}
seen_ids: set[str] = set()
seen_texts: set[str] = set()
last: datetime | None = None
for n in range(1, required + 1):
feedback, why = _read_feedback_file(rounds_dir / str(n), ai)
if feedback is not None:
if not (rounds_dir / str(n - 1) / "report.md").is_file():
feedback, why = (
None,
f"runde {n}: gitt på en rapport som mangler ({n - 1}/report.md)",
)
elif feedback.ids & seen_ids:
reused = ", ".join(sorted(feedback.ids & seen_ids))
feedback, why = (
None,
f"runde {n}: id-er fra en tidligere runde gjenbrukt ({reused})",
)
elif feedback.texts <= seen_texts:
feedback, why = (
None,
f"runde {n}: ingen punkt som ikke alt er gitt i en tidligere runde",
)
elif last is not None and feedback.given_at <= last:
feedback, why = None, f"runde {n}: given_at er ikke etter forrige rundes"
if feedback is not None:
seen_ids |= feedback.ids
seen_texts |= feedback.texts
last = feedback.given_at
result[n] = (feedback, why)
return result
def read_feedback(round_dir: Path, ai: tuple[str, str] | None) -> tuple[set[str], str]:
"""The ids of ONE round's feedback items checked on its own, and ``""`` — or why not."""
feedback, why = _read_feedback_file(round_dir, ai)
return (set(feedback.ids), "") if feedback is not None else (set(), why)
def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row:
exceptions: list[str] = []
k = 0
if not rounds_dir.is_dir():
exceptions.append(f"{rounds_dir} finnes ikke")
else:
for _, (feedback, why) in sorted(read_rounds(rounds_dir, required, ai).items()):
if feedback is not None:
k += 1
else:
exceptions.append(why)
status = GREEN if k == required else RED
reason = (
"form verifisert i alle runder (forfatterskap: se attestering)"
if k == required
else exceptions[0]
)
return Row(
"rounds",
"1 runder med ekte fagperson",
k,
required,
status,
reason,
exceptions=tuple(exceptions),
)
@dataclass(frozen=True)
class Outcome:
rows: dict[str, dict[str, Any]]
removed: dict[str, set[str]]
run_id: str
ran_at: datetime
def _stage_of(status: str, detail: str) -> str:
if status == "validated":
return ""
if status == "not_evaluated":
return "not_evaluated"
return rejection_stage(detail)
def read_outcome(path: Path) -> tuple[Outcome | None, str]:
"""A round's outcome file, VERIFIED against the run it names: ``outbox`` must hold
``<run_id>-coverage.json``, and every row's (a)-(d) must equal that run's own coverage. A
handwritten outcome with no run behind it is refused; the run's time is the coverage file's."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
rows = {str(a["id"]): a for a in data["approaches"]}
removed = {
str(r["id"]): set(map(str, r.get("feedback_ids", ()))) for r in data.get("removed", ())
}
run_id = str(data["run_id"]).strip()
outbox = Path(str(data["outbox"])).expanduser()
except FileNotFoundError:
return None, f"{path} mangler"
except (ValueError, KeyError, TypeError) as exc:
return None, f"{path} uleselig ({exc!r})"
if not run_id:
return None, f"{path}: run_id er tom"
if not outbox.is_absolute():
outbox = path.parent / outbox
coverage_path = outbox / f"{run_id}-coverage.json"
if not coverage_path.is_file():
return None, f"{path}: kjøringen {run_id!r} finnes ikke ({coverage_path} mangler)"
try:
coverage = json.loads(coverage_path.read_text(encoding="utf-8"))["rows"]
except (ValueError, KeyError, TypeError) as exc:
return None, f"{coverage_path} uleselig ({exc!r})"
if not coverage:
return None, f"{path}: kjøringen {run_id!r} evaluerte ingen tilnærming"
truth = {
str(r["id"]): (
r["status"] == "validated",
_stage_of(str(r["status"]), str(r.get("detail", ""))),
r.get("saving_nok") if r["status"] == "validated" else None,
)
for r in coverage
}
claimed = {aid: _row_key(row) for aid, row in rows.items()}
if claimed != truth:
return None, f"{path}: (a)-(d) stemmer ikke med kjøringens egen coverage ({run_id})"
ran_at = datetime.fromtimestamp(coverage_path.stat().st_mtime, tz=timezone.utc)
return Outcome(rows, removed, run_id, ran_at), ""
def _row_key(row: Mapping[str, Any]) -> tuple[bool, str, Any]:
nok = row.get("validated_nok")
return (
bool(row.get("validated")),
str(row.get("stage") or ""),
None if nok is None else float(nok),
)
def _nok_changed(before: Any, after: Any) -> bool:
"""(d) with a noise floor. ``validated_nok`` is the model's own claim, so two runs on the same
input can differ by rounding; a change smaller than 1 % of the earlier figure (and never less
than 1 NOK) is not something an expert's feedback asked for, and it does not count."""
if (before is None) != (after is None):
return True
if before is None or after is None:
return False
return abs(float(after) - float(before)) >= max(1.0, _NOK_NOISE * abs(float(before)))
def _changed(before: Mapping[str, Any], after: Mapping[str, Any]) -> bool:
b, a = _row_key(before), _row_key(after)
return b[:2] != a[:2] or _nok_changed(b[2], a[2])
def outcomes_changed(prev: Outcome, cur: Outcome, feedback_ids: set[str]) -> tuple[bool, str]:
"""Whether ``cur`` changed measurably against ``prev`` AND the change is traced to feedback
given before ``cur`` ran. The second half is what keeps model noise out."""
changed: dict[str, set[str]] = {}
for aid, row in cur.rows.items():
if aid not in prev.rows or _changed(prev.rows[aid], row):
changed[aid] = set(map(str, row.get("feedback_ids", ())))
for aid in prev.rows.keys() - cur.rows.keys():
changed[aid] = cur.removed.get(aid, set())
if not changed:
return False, "ingen endring i (a)-(d) over støygrensen"
traced = sorted(aid for aid, ids in changed.items() if ids & feedback_ids)
if not traced:
return False, f"{len(changed)} rad(er) endret, ingen sporet til rundens feedback-id-er"
return True, f"{len(changed)} rad(er) endret, sporet: {', '.join(traced)}"
def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row:
exceptions: list[str] = []
k = 0
base_path = rounds_dir / "0" / "outcome.json"
base_outcome, base_why = read_outcome(base_path)
base = f"runde 0 = {base_path}"
base += f" (kjøring {base_outcome.run_id})" if base_outcome else f"{base_why}"
feedback_by_round = read_rounds(rounds_dir, required, ai) if rounds_dir.is_dir() else {}
for n in range(1, required + 1):
feedback, why = feedback_by_round.get(n, (None, f"runde {n}: {rounds_dir} finnes ikke"))
if feedback is None:
exceptions.append(why)
continue
prev, why_prev = read_outcome(rounds_dir / str(n - 1) / "outcome.json")
cur, why_cur = read_outcome(rounds_dir / str(n) / "outcome.json")
if prev is None or cur is None:
exceptions.append(f"runde {n}: {why_prev or why_cur}")
continue
if not prev.ran_at <= feedback.given_at <= cur.ran_at:
exceptions.append(
f"runde {n}: feedbacken er ikke gitt mellom kjøring {prev.run_id} og {cur.run_id}"
)
continue
ok, detail = outcomes_changed(prev, cur, set(feedback.ids))
if ok:
k += 1
else:
exceptions.append(f"runde {n}: {detail}")
status = GREEN if k == required else RED
return Row(
"changes",
"2 runder med målbar endring",
k,
required,
status,
base,
exceptions=tuple(exceptions),
)
# ---------------------------------------------------------------------------------------------
# Row 3 and the row 6 probes — named tests run in a child
# ---------------------------------------------------------------------------------------------
ProbeRunner = Callable[[Sequence[str]], Mapping[str, str]]
def run_probes(nodeids: Sequence[str], repo_root: Path = _REPO_ROOT) -> dict[str, str]:
"""``nodeid -> "passed" | "failed" | "skipped" | "missing"``, from a child pytest run with
``--runxfail`` so a known gap shows as the failure it is."""
outcomes = {n: "missing" for n in nodeids}
if not (repo_root / "tests").is_dir():
return outcomes
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}
base = [sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-q"]
files = sorted(
{n.split("::", 1)[0] for n in nodeids if (repo_root / n.split("::")[0]).is_file()}
)
if not files:
return outcomes
collect = subprocess.run(
[*base, "--collect-only", *files], cwd=repo_root, env=env, capture_output=True, text=True
)
collected = {line.strip() for line in collect.stdout.splitlines() if "::" in line}
present = [n for n in nodeids if n in collected]
if not present:
return outcomes
with tempfile.TemporaryDirectory() as tmp:
junit = Path(tmp) / "junit.xml"
subprocess.run(
[*base, "--runxfail", f"--junitxml={junit}", *present],
cwd=repo_root,
env=env,
capture_output=True,
text=True,
)
if not junit.is_file():
return {n: ("failed" if n in present else "missing") for n in nodeids}
for case in ET.parse(junit).getroot().iter("testcase"):
nodeid = case.get("classname", "").replace(".", "/") + ".py::" + case.get("name", "")
if nodeid not in outcomes:
continue
if case.find("failure") is not None or case.find("error") is not None:
outcomes[nodeid] = "failed"
elif case.find("skipped") is not None:
outcomes[nodeid] = "skipped"
else:
outcomes[nodeid] = "passed"
return {n: (o if o != "missing" or n not in present else "failed") for n, o in outcomes.items()}
def green_types(types: Mapping[str, Any], outcomes: Mapping[str, str]) -> dict[int, str]:
"""``type -> ""`` when green, else the reason. Partial is no: every registered test passes."""
result: dict[int, str] = {}
for key, spec in types.items():
evidence = list(spec.get("evidence", ()))
if not evidence:
result[int(key)] = "ingen probe registrert"
continue
bad = [
f"{n.split('::')[-1]}={outcomes.get(n, 'missing')}"
for n in evidence
if outcomes.get(n) != "passed"
]
result[int(key)] = "; ".join(bad)
return result
def score_types(types: Mapping[str, Any], outcomes: Mapping[str, str]) -> Row:
verdicts = green_types(types, outcomes)
k = sum(1 for why in verdicts.values() if not why)
n = len(types)
exceptions = tuple(
f"type {t} ({types[str(t)]['label']}): {why}" for t, why in sorted(verdicts.items()) if why
)
green = ", ".join(str(t) for t, why in sorted(verdicts.items()) if not why) or "ingen"
status = GREEN if k == n else RED
return Row(
"types",
"3 tilbakemeldingstyper med vei inn OG handling",
k,
n,
status,
f"grønne: {green}",
exceptions=exceptions,
)
# ---------------------------------------------------------------------------------------------
# Row 4 — the round 3 report kept
# ---------------------------------------------------------------------------------------------
#: A line that carries no reading: horizontal rules, table rules, and non-breaking-space fillers.
_MARKUP_ONLY = re.compile(
r"^\s*(?:(?:[-*_=]\s*){3,}|\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?|&nbsp;)\s*$"
)
def content_lines(text: str) -> list[str]:
"""The lines a reader keeps or rewrites: trailing whitespace stripped (an editor's doing, never
the expert's), blank lines and markup-only lines dropped, everything else — headings included —
kept in order. Nothing else is normalised."""
lines = [line.rstrip() for line in text.splitlines()]
return [x for x in lines if re.search(r"\w", x) and not _MARKUP_ONLY.match(x)]
@dataclass(frozen=True)
class Kept:
kept: int
total: int
added: int
untouched: bool
why: str = ""
def kept_ratio(report: Path, kept: Path) -> Kept:
"""How much of the report the expert kept: content lines of ``report`` that survive in
``kept`` IN ORDER (each line matched at most once — a multiset, and a reshuffle is a change),
plus the expert's additions as their own number. A byte-identical copy is flagged as untouched:
nobody can tell it from a report nobody read."""
if not report.is_file() or not kept.is_file():
return Kept(0, 0, 0, False, "ingen rapport")
raw_report = report.read_bytes()
raw_kept = kept.read_bytes()
before = content_lines(raw_report.decode("utf-8"))
after = content_lines(raw_kept.decode("utf-8"))
if not before:
return Kept(0, 0, 0, False, "tom rapport")
matcher = difflib.SequenceMatcher(None, before, after, autojunk=False)
same = sum(block.size for block in matcher.get_matching_blocks())
return Kept(same, len(before), len(after) - same, raw_report == raw_kept)
def score_kept(rounds_dir: Path, threshold: float, ai: tuple[str, str] | None = None) -> Row:
result = kept_ratio(rounds_dir / "3" / "report.md", rounds_dir / "3" / "report.kept.md")
title = f"4 runde 3-rapport beholdt (≥ {threshold:.0%} innholdslinjer)"
if result.why:
return Row("kept", title, None, result.total or None, RED, result.why)
added = f"; {result.added} linje(r) lagt til av fagpersonen"
if result.untouched:
feedback, _ = _read_feedback_file(rounds_dir / "3", ai)
if feedback is None or not feedback.report_unchanged:
return Row(
"kept",
title,
None,
result.total,
RED,
"ikke rørt: report.kept.md er byte-identisk med report.md; kvitter med "
'"report_unchanged": true i 3/feedback.json',
)
ok = result.kept >= threshold * result.total
return Row(
"kept",
title,
result.kept,
result.total,
GREEN if ok else RED,
f"{result.kept / result.total:.1%} av innholdslinjene uendret og i rekkefølge{added}",
diagnostics=(f"lagt til: {result.added}",),
)
# ---------------------------------------------------------------------------------------------
# Row 5 — MAF points, each pointing at a type
# ---------------------------------------------------------------------------------------------
def _imports(tree: ast.AST, construct: str, package: str) -> set[str]:
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith(package):
names |= {a.asname or a.name for a in node.names if a.name == construct}
return names
def _annotations(node: ast.AST) -> set[int]:
"""ids of every node inside a type annotation — a name used only as a type is not a use."""
found: set[int] = set()
for x in ast.walk(node):
parts: list[ast.AST | None] = []
if isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef)):
parts.append(x.returns)
elif isinstance(x, ast.arg):
parts.append(x.annotation)
elif isinstance(x, ast.AnnAssign):
parts.append(x.annotation)
for part in parts:
if part is not None:
found |= {id(y) for y in ast.walk(part)}
return found
def _referenced(nodes: Iterable[ast.AST], names: set[str]) -> bool:
for node in nodes:
typed = _annotations(node)
if any(
isinstance(x, ast.Name) and x.id in names and id(x) not in typed for x in ast.walk(node)
):
return True
return False
def maf_presence(point: Mapping[str, Any], src: Path) -> tuple[bool, bool]:
"""(construct imported from MAF and used somewhere in ``src``, named call site uses it)."""
present = False
for path in sorted(src.glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
names = _imports(tree, point["construct"], point["package"])
body = [
n for n in getattr(tree, "body", []) if not isinstance(n, (ast.Import, ast.ImportFrom))
]
if names and _referenced(body, names):
present = True
break
site = point.get("callsite")
if not site:
return present, False
path = src / site["module"]
if not path.is_file():
return present, False
tree = ast.parse(path.read_text(encoding="utf-8"))
names = _imports(tree, point["construct"], point["package"])
scopes = [
n
for n in getattr(tree, "body", [])
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and n.name == site["scope"]
]
return present, bool(names) and _referenced(scopes, names)
def score_maf(maf: Mapping[str, Any], type_verdicts: Mapping[int, str], src: Path) -> Row:
points = list(maf["points"])
exceptions: list[str] = []
presence = callsites = pointers = 0
for point in points:
present, site = maf_presence(point, src)
types_green = all(not type_verdicts.get(int(t), "ukjent") for t in point["types"])
presence += present
callsites += site
counts = present and site and types_green
pointers += counts
if not counts:
missing = [
w
for w, ok in (
("presence", present),
("kallsted", site),
(f"type {point['types']} grønn", types_green),
)
if not ok
]
exceptions.append(f"{point['u_id']} {point['construct']}: mangler {', '.join(missing)}")
n = len(points)
diagnostics = (
f"presence {presence} av {n}",
f"kallsted verifisert {callsites} av {n}",
f"med grønn typepeker {pointers} av {n}",
)
title = "5 MAF-punkter med typepeker"
if maf.get("approved") is not True:
return Row(
"maf",
title,
0,
n,
RED,
"M ikke godkjent av operatøren",
exceptions=tuple(exceptions),
diagnostics=diagnostics,
)
return Row(
"maf",
title,
pointers,
n,
GREEN if pointers == n else RED,
f"presence {presence} av {n}",
exceptions=tuple(exceptions),
diagnostics=diagnostics,
)
# ---------------------------------------------------------------------------------------------
# Rows 6 and 7 — the stress round's artefacts
# ---------------------------------------------------------------------------------------------
@dataclass(frozen=True)
class StressMeasure:
validated: int = 0
undeclared: int = 0
undeclared_anywhere: int = 0
named: int = 0
rows: int = 0
commissioned: int = 0
where: str = ""
missing: str = ""
#: Declarations with no ``approach_id`` — written before the rule; the row cannot be measured.
unaddressed: int = 0
#: Of ``validated``, how many were the runs' own proposals (M-3).
own_validated: int = 0
undeclared_ids: tuple[str, ...] = field(default=())
def _own_proposals(
evidence: Mapping[str, Any], stress_root: Path
) -> tuple[int, int, tuple[str, ...]]:
"""(validated own proposals, of those without an ``own-proposal`` declaration, their labels)."""
validated = undeclared = 0
ids: list[str] = []
for run_spec in evidence["runs"]:
outbox = stress_root / run_spec["outbox"]
run_id = run_spec["run_id"]
outcome = outbox / f"{run_id}-own-proposal-outcome.json"
if not outcome.is_file():
continue
if json.loads(outcome.read_text(encoding="utf-8")).get("outcome_type") != "validated":
continue
validated += 1
debate = outbox / f"{run_id}-debate.json"
records = (
json.loads(debate.read_text(encoding="utf-8")).get("requirements", [])
if debate.is_file()
else []
)
if not any(r.get("approach_id") == "own-proposal" for r in records):
undeclared += 1
ids.append(f"own-proposal ({run_id})")
return validated, undeclared, tuple(ids)
def measure_stress(
evidence: Mapping[str, Any], repo_root: Path, stress_root: Path, bundle_root: Path | None
) -> StressMeasure:
"""Re-judge every listed outbox with the current judge. Any run that cannot be judged makes the
whole measurement absent — a partial one would carry the wrong denominator."""
from portfolio_optimiser import frozen_bundles, stress
from portfolio_optimiser.mandate import load_mandate
verdicts = []
contexts: set[str] = set()
for run_spec in evidence["runs"]:
context = repo_root / run_spec["context"]
outbox = stress_root / run_spec["outbox"]
if not outbox.is_dir():
return StressMeasure(where=str(stress_root), missing=f"{outbox} finnes ikke")
declared = stress.read_bundle_declarations(context / "bundle.txt")
wanted = run_spec.get("bundle")
chosen = [d for d in declared if wanted in (None, d["name"], d["bundle_id"])]
if len(chosen) != 1:
return StressMeasure(where=str(stress_root), missing=f"{context}: base ikke entydig")
try:
# The frozen copy this repository pins, unless the operator named a live mount.
# Drift is a ValueError and absence an OSError: both land in ``missing`` below, so a
# corpus that moved is IKKE MÅLT with the reason said, never a silently wrong number.
base = frozen_bundles.bundle_dir(chosen[0]["name"], override=bundle_root)
verdicts.append(
stress.score_context_set(
context,
outbox,
run_spec["run_id"],
base,
bundle_id=chosen[0]["bundle_id"] if len(declared) > 1 else None,
)
)
except (stress.EmptyMeasurement, OSError, ValueError) as exc:
return StressMeasure(where=str(stress_root), missing=f"{run_spec['run_id']}: {exc}")
contexts.add(run_spec["context"])
approaches = [a for v in verdicts for a in v.approaches]
validated = [a for a in approaches if a.status == "validated"]
undeclared = [a for a in validated if a.requirement_source != "approach"]
# M-3: the run's OWN proposal is gated by the same rule, but the judge scores only the
# commissioned approaches (the fasit has rows for nothing else). Read straight off each outbox.
own_validated, own_undeclared, own_ids = _own_proposals(evidence, stress_root)
unaddressed = sum(v.unaddressed_declarations for v in verdicts)
commissioned = sum(
len(load_mandate(repo_root / c / "mandate.json").approaches) for c in contexts
)
return StressMeasure(
validated=len(validated) + own_validated,
undeclared=len(undeclared) + own_undeclared,
undeclared_anywhere=sum(1 for a in validated if a.requirement_source == "absent"),
named=sum(1 for a in approaches if a.named),
rows=len(approaches),
commissioned=commissioned,
where=str(stress_root),
unaddressed=unaddressed,
undeclared_ids=tuple(a.approach_id for a in undeclared) + own_ids,
own_validated=own_validated,
)
def score_undeclared(
probes: Sequence[str], outcomes: Mapping[str, str], m: StressMeasure, label: str
) -> Row:
"""GREEN only when every probe passes AND the artefacts were measured with k = 0. Evidence that
could not be read is IKKE MÅLT — never green, and it fails the exit code like red does."""
failing = [
f"{n.split('::')[-1]}={outcomes.get(n, 'missing')}"
for n in probes
if outcomes.get(n) != "passed"
]
if not probes:
failing.append("ingen probe registrert")
title = "6 validert UTEN erklært krav (tilnærmingens egen)"
exceptions = [f"probe {x}" for x in failing]
probe_state = "prober røde" if failing else "prober grønne"
k: int | None = None
n: int | None = None
diagnostics: tuple[str, ...] = ()
if m.missing:
reason = f"{probe_state}; {label}: ikke målt, artefakter mangler ({m.missing})"
elif m.unaddressed:
reason = (
f"{probe_state}; {label}: ikke målt: artefaktene er eldre enn regelen "
f"(approach_id mangler på {m.unaddressed} erklæring(er))"
)
diagnostics = (
f"før regelen: {m.undeclared} av {m.validated} validerte (hvorav {m.own_validated} "
"egne forslag) uten tilnærmingens egen erklæring — regelen ville gjort dem "
"unsupported, men modellen fikk aldri spørsmålet",
)
else:
k, n = m.undeclared, m.validated
reason = (
f"{probe_state}; {label} ({m.where}): {k} av {n} validerte uten erklæring fra "
f"tilnærmingen (hvorav {m.own_validated} egne forslag i nevneren); "
f"{m.undeclared_anywhere} uten noen erklæring i kjøringen"
)
exceptions += [f"validert uten erklæring: {a}" for a in m.undeclared_ids]
if failing or k:
status = RED
elif k is None:
status = NOT_MEASURED
else:
status = GREEN
return Row(
"undeclared",
title,
k,
n,
status,
reason,
exceptions=tuple(exceptions),
diagnostics=diagnostics,
)
NAMED_WARNING = (
"en prompt-endring som ber modellen gjengi `ref` gjør `named` til noe modellen blir BEDT om, "
"og svekker den som uavhengig måling"
)
def score_named(m: StressMeasure, label: str) -> Row:
title = "7 named (diagnose, ingen terskel)"
if m.missing:
return Row(
"named",
title,
None,
None,
NOT_MEASURED,
f"{label}: ikke målt, artefakter mangler ({m.missing})",
failing=False,
diagnostics=(NAMED_WARNING,),
)
extra = "" if m.rows == m.commissioned else f"; {m.rows} rader dømt"
return Row(
"named",
title,
m.named,
m.commissioned,
DIAGNOSIS,
f"{label}: nevner = bestilte tilnærminger i fasitsettene{extra}",
failing=False,
diagnostics=(NAMED_WARNING,),
)
# ---------------------------------------------------------------------------------------------
# The command
# ---------------------------------------------------------------------------------------------
def evaluate(
*,
rounds_dir: Path,
config: Mapping[str, Any],
repo_root: Path = _REPO_ROOT,
src: Path = _PACKAGE_SRC,
stress_root: Path | None = None,
bundle_root: Path | None = None,
probe_runner: ProbeRunner | None = None,
stress_measure: StressMeasure | None = None,
) -> list[Row]:
ai = ai_authored_lines(repo_root, config["ai_authored"])
required = int(config["rounds_required"])
types = config["feedback_types"]
probes = list(config["row6_evidence"])
nodeids = [n for spec in types.values() for n in spec.get("evidence", ())] + probes
outcomes = (probe_runner or (lambda ids: run_probes(ids, repo_root)))(nodeids)
evidence = config["stress_evidence"]
if stress_measure is None:
stress_measure = measure_stress(
evidence,
repo_root,
stress_root or repo_root / evidence["root"],
bundle_root,
)
return [
score_rounds(rounds_dir, required, ai),
score_changes(rounds_dir, required, ai),
score_types(types, outcomes),
score_kept(rounds_dir, float(config["keep_threshold"]), ai),
score_maf(config["maf_points"], green_types(types, outcomes), src),
score_undeclared(probes, outcomes, stress_measure, evidence["label"]),
score_named(stress_measure, evidence["label"]),
]
def exit_code(rows: Sequence[Row]) -> int:
return 0 if all(r.status == GREEN for r in rows if r.failing) else 1
def render(rows: Sequence[Row]) -> str:
out = ["rad | k av N | status | grunn"]
out += [r.line() for r in rows]
out.append("")
out.append(f"Attestering: {ATTESTATION}.")
out.append("")
out.append("Unntak fra 100 %:")
for r in rows:
for x in r.exceptions:
out.append(f" [{r.title.split()[0]}] {x}")
for d in r.diagnostics:
out.append(f" [{r.title.split()[0]}] diagnose: {d}")
return "\n".join(out)
def _safe_rounds_dir(path: Path) -> bool:
"""Outside the repository, or inside it and ignored by git."""
resolved = path.resolve()
try:
resolved.relative_to(_REPO_ROOT)
except ValueError:
return True
probe = resolved / "1" / "feedback.json"
proc = subprocess.run(
["git", "check-ignore", "-q", str(probe)], cwd=_REPO_ROOT, capture_output=True
)
return proc.returncode == 0
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m portfolio_optimiser.evals.v1_gate",
description="Hvor langt portfolio-optimiser er fra v1, rad for rad. Exit 0 kun når alle "
"fellende rader (1-6) er grønne, 1 ellers, 2 ved feil bruk. Ingen modellkall, intet nett.",
epilog=ROUNDS_CONTRACT,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--rounds-dir",
default=None,
help=f"rundekatalogen (default {DEFAULT_ROUNDS_DIR}/, gitignored)",
)
parser.add_argument(
"--stress-root", default=None, help="utboks-roten for stressrunden rad 6-7 dømmer"
)
parser.add_argument(
"--bundle-root",
default=None,
help="en EKSPLISITT, UPINNET levende montering; uten den svarer den frosne kopien og "
"sha256-pinnen verifiseres",
)
parser.add_argument("--json", action="store_true", help="maskinlesbar output")
args = parser.parse_args(argv)
if args.rounds_dir is not None and not Path(args.rounds_dir).is_dir():
parser.error(f"--rounds-dir {args.rounds_dir} finnes ikke")
if args.rounds_dir is not None and not _safe_rounds_dir(Path(args.rounds_dir)):
parser.error(
f"--rounds-dir {args.rounds_dir} ligger i repoet uten å være gitignored — "
"fagpersonens tilbakemelding kunne da bli committet til den offentlige remoten"
)
for flag, value in (("--stress-root", args.stress_root), ("--bundle-root", args.bundle_root)):
if value is not None and not Path(value).expanduser().is_dir():
parser.error(f"{flag} {value} finnes ikke")
rounds_dir = Path(args.rounds_dir) if args.rounds_dir else _REPO_ROOT / DEFAULT_ROUNDS_DIR
rows = evaluate(
rounds_dir=rounds_dir,
config=load_config(),
stress_root=Path(args.stress_root) if args.stress_root else None,
bundle_root=Path(args.bundle_root).expanduser() if args.bundle_root else None,
)
code = exit_code(rows)
if args.json:
print(
json.dumps(
{"exit": code, "attestation": ATTESTATION, "rows": [asdict(r) for r in rows]},
ensure_ascii=False,
indent=2,
)
)
else:
print(render(rows))
return code
if __name__ == "__main__": # pragma: no cover - exercised by a subprocess test
raise SystemExit(main())