portfolio-optimiser/src/portfolio_optimiser/evals/b_gate.py
Kjell Tore Guttormsen 59f35fde22
fix(b-gate): every denominator answers to a source outside the gate, and a name is no longer a door [skip-docs]
Row 1's M was a curated list of 13 in the gate's OWN b_gate.json. It is now DERIVED: run_path_calls
reads what run.py::run_project actually calls (41 po-functions, re-counted here against PM's 39 —
the two that differ are generate_via_llm and fresh_workflow, held out because they need a chat
client). A call that is neither a declared step nor named-with-a-reason counts in the denominator
WITHOUT a door, so the number cannot shrink in silence: removing a step from the contract now
leaves N unchanged and names the orphan. The four outbox writers the run path uses and nobody had
declared (write_prepass, write_parse_failures, write_proposal_reviews, write_debate_tools) are
steps now; 28 calls are held out, each with its reason printed under the row.

A door must be REGISTERED and PROVEN. entry["kind"] is read (it was read 0 times before) and must
be one of three kinds the gate has code to verify: console-script in pyproject, module-main with
its own __main__ guard, subcommand registered in the module's argparse. On top of that every step
needs a named probe that calls the door and reads the artefact. The MCP sentence is struck from
EXTERNAL_DOOR: it claimed a capability with no code behind it.

Row 3 now scans the repo manifest (git ls-files, or the tree itself in an extract), not a hand
list of 11 roots: 512 published files instead of 433, so main.py, examples/, spikes/, contexts/,
CLAUDE.md and llms.txt are inside the guard for the first time. Three new patterns catch the
indirect invocations that walked past the old six — absolute path, list in a variable, constant,
shell string — 5 of 5 of PM's variants are refused now, with 0 false positives measured over the
whole surface. An empty surface is IKKE MAALT, not GREEN: the row demands a sentinel file and
prints the file count and the manifest it read.

Rows 4, 5 and 6 get sources for their denominators. Row 4 counts only checks whose named source
symbol resolves in the code. Row 5 counts each structural requirement on its own (2 profile
members + factory + seam + probe = 5) instead of collapsing three into one unit. Row 6's N comes
from the artefacts the contract names, and the attestation is VALIDATED: it must name the
contract's runbook, carry its sha256, say who ran it, and bear a real ISO date that is not in the
future — v1_gate's own date rule, reused, BOM tolerated as there.

Measured in a scratch clone (/tmp/claude-po/bgate-mut): 12 of 12 mutants felled, control 65 of 65.
All six of PM's broken attacks reproduced as refused, with the rc-0 control green.
No row got greener: 3 of 17 (was 3 of 13), 0 of 2, GREEN, 0 of 3, 4 of 5 (was 1 of 2), IKKE MAALT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:40:42 +02:00

1012 lines
40 KiB
Python

"""B-gaten: hvor langt po er fra å være en verktøykasse Claude Code kan drive, rad for rad.
Rammen er operatørbeslutningen 19.09.2026: i utvikling og test LEDER Claude Code, og po er
verktøykassen som brukes — som ethvert annet verktøy. po kaller ALDRI den veien tilbake. I
produksjon er det Microsoft Foundry som kjører agentene, og Claude Code er ikke inne i bildet.
«Bryteren» er derfor ikke et flagg som bytter modell bak samme orkestrator: det er to måter å
drive SAMME verktøykasse på — utenfra, eller av MAF-agentene mot Foundry.
Én kommando, deterministisk, offline: intet modellkall, intet nett, ingen kvote. Exit 0 kun når
hver fellende rad er grønn; 1 ellers; 2 ved feil bruk.
Gaten definerer KONTRAKTEN en senere kapabilitet skal leveres inn i, ikke kapabiliteten. Hver
nevner hentes fra kilden utenfor det som måles: stegene telles i kjørestien (et steg uten symbol
eller uten kallsted er ikke et steg), rollene leses av ``workflow``-modulens egen liste, og
vaktene på rad 3 bærer hver sin kjent-positive og kjent-negative prøve — en vakt som ikke kan
treffe, måler ikke null, den måler ingenting.
Mønstrene på rad 3 ligger base64-kodet i ``b_gate.json`` med vilje: flaten gaten leser er den
samme flaten gaten selv bor i, og en kontrakt skrevet i klartekst ville registrert seg som sitt
eget funn. Kodingen gjør at raden slipper unntakslister — og en rad uten unntak er en rad ingen
kan skru av ved å legge en fil på lista.
"""
from __future__ import annotations
import argparse
import ast
import base64
import binascii
import hashlib
import json
import re
import subprocess
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timezone
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from portfolio_optimiser.evals.v1_gate import (
GREEN,
NOT_MEASURED,
RED,
Row,
_parse_given,
exit_code,
run_probes,
)
__all__ = [
"GREEN",
"NOT_MEASURED",
"RED",
"Row",
"evaluate",
"exit_code",
"main",
"render",
]
_DATA = Path(__file__).with_name("b_gate.json")
_PACKAGE_SRC = Path(__file__).resolve().parents[1]
_REPO_ROOT = Path(__file__).resolve().parents[3]
ProbeRunner = Callable[[Sequence[str]], Mapping[str, str]]
#: Navnene som gjør et steg avhengig av en modell. Treffer ett av dem på veien fra inngangen til
#: steget, er steget kallbart — men ikke uten en chatklient, og da ikke som verktøy i B.
CHAT_CLIENT_NAMES = frozenset(
{
"create_chat_client",
"client_factory",
"BaseChatClient",
"ChatBackend",
"get_backend",
"_default_factory",
}
)
#: Rad 1: hvilken dør som teller, og hvorfor den ble valgt framfor den andre.
EXTERNAL_DOOR = (
"valgt kommandolinja som dør UT av verktøykassen, fordi operatørens ord er «som alle andre "
"tools Claude Code kan bruke» — en kommando er nettopp det. Gaten teller tre arter den kan "
"VERIFISERE mot kilden: console-script (registrert i pyproject), module-main (kjørbar med "
"python -m, med sin egen __main__-vakt) og subcommand (et navn registrert i modulens egen "
"argparse). En art gaten ikke kan verifisere er ikke en dør her — og et navn er uansett ikke "
"nok: hvert steg må ha en navngitt probe som kaller døren og leser artefaktet den skriver"
)
#: Inngangsartene rad 1 kan VERIFISERE mot kilden. En art utenfor lista teller aldri som en dør:
#: gaten skal ikke kunne bli grønn av en påstand den ikke har kode for å etterprøve.
ENTRY_KINDS: tuple[str, ...] = ("console-script", "module-main", "subcommand")
#: Rad 2: det ene kravet som skiller en ekte felles vei fra to veier som ser like ut.
BYTE_IDENTICAL_RULE = (
"et forslag gitt UTENFRA skal gjennom NØYAKTIG samme validator, samme avvisningsstadier og "
"samme verdict_id-minting som når en MAF-agent leverte det. Beviset er byte-identiske "
"proposal- og outcome-artefakter fra samme forslags-JSON inn begge veier — at filene FINNES "
"begge veier beviser ingenting, for to ulike veier skriver gjerne samme filnavn"
)
#: Rad 4: hva budsjettvernet ER i B. BudgetMiddleware er fail-closed på manglende usage, og i B
#: finnes ingen usage i po i det hele tatt — tokenene brukes i Claude Code-økten.
BUDGET_IN_B = (
"budsjett i B: valgt at po IKKE har et token-tak i verktøykasse-modus, fordi et tak po ikke "
"kan måle forbruk mot er et tak som ikke finnes. BudgetMiddleware er fail-closed på manglende "
"usage og konstrueres aldri uten chatklient; å beholde det her ville gjort fail-closed til "
"fail-open i praksis. Taket i B er Claude Code-øktens eget forbruk, som po verken ser eller "
"styrer, og som gaten derfor ikke påstår noe om. Foundry-veien beholder taket uendret"
)
#: Rad 6: hva gaten aldri gjør, og hvorfor raden ellers ville vært en sløyfe.
RUNBOOK_RULE = (
"rad 6 regner gaten aldri ut: en kjørebok kan være komplett på papiret og likevel ikke ha "
"drevet én analyse. Skrittet til grønt er operatørens egen bekreftelse på at han kjørte den "
"og at den virket — en fil gaten ALDRI skriver selv, like lite som attesteringen på v1-gaten"
)
CONTRACT = """\
Radene, og hva som flytter hver av dem:
1 verktøykasse hvert deterministiske steg i kjørestien, kallbart UTENFRA uten chatklient
2 roller proposer og checker kan leveres utenfra og dømmes like hardt
3 ingen-claude-vei null treff på en vei fra po til Claude i alt som publiseres
4 ingen-modellkall hele løpet kjørt med chatklienten satt til å reise, nett stengt
5 foundry Foundry-veien urørt, og samme artefaktfamilie fra begge modi
6 kjørebok en fersk økt kan drive en analyse etter den — bekreftet av operatøren
Nevnerne: steg og roller telles i KILDEN (src/), mønstrene på rad 3 i kontrakten, og probene på
rad 2, 4 og 5 er navngitte tester som kjøres i en barne-pytest med --runxfail. En test som ikke
finnes er RØD, aldri grønn og aldri hoppet over.
"""
def load_config(path: Path = _DATA) -> dict[str, Any]:
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return data
def decode(value: str) -> str:
"""Base64 fra kontrakten til teksten den beskriver. Feil koding er en feil, ikke en tom vakt."""
try:
return base64.b64decode(value.encode("ascii"), validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError, ValueError) as exc: # pragma: no cover - kontrakt
raise ValueError(f"ugyldig base64 i kontrakten: {value!r}") from exc
# ---------------------------------------------------------------------------------------------
# Rad 1 — verktøykassen er komplett
# ---------------------------------------------------------------------------------------------
@dataclass(frozen=True)
class Step:
id: str
label: str
resolved: bool
called: bool
external: bool
why: str
def _tree(src: Path, module: str) -> ast.AST | None:
path = src / module
if not path.is_file():
return None
try:
return ast.parse(path.read_text(encoding="utf-8"))
except (SyntaxError, UnicodeDecodeError): # pragma: no cover - en ulesbar modul er ikke kilde
return None
def _defined(tree: ast.AST, symbol: str) -> bool:
for node in getattr(tree, "body", []):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
if node.name == symbol:
return True
if isinstance(node, ast.Assign) and any(
isinstance(t, ast.Name) and t.id == symbol for t in node.targets
):
return True
return False
def _call_names(node: ast.AST) -> set[str]:
names: set[str] = set()
for child in ast.walk(node):
if isinstance(child, ast.Call):
func = child.func
if isinstance(func, ast.Name):
names.add(func.id)
elif isinstance(func, ast.Attribute):
names.add(func.attr)
return names
def _scopes(tree: ast.AST, name: str) -> list[ast.AST]:
return [
n
for n in ast.walk(tree)
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and n.name == name
]
def calls_symbol(src: Path, module: str, scope: str, symbol: str) -> bool:
"""Steget har et kallsted: ``scope`` i ``module`` kaller det faktisk."""
tree = _tree(src, module)
if tree is None:
return False
return any(symbol in _call_names(s) for s in _scopes(tree, scope))
def _functions(tree: ast.AST) -> dict[str, ast.AST]:
return {
n.name: n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def _reachable(functions: Mapping[str, ast.AST], start: str) -> set[str]:
seen: set[str] = set()
stack = [start]
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
stack += [n for n in _call_names(functions[current]) if n in functions and n not in seen]
return seen
def _chat_client_names(functions: Mapping[str, ast.AST], reached: Sequence[str]) -> set[str]:
found: set[str] = set()
for name in reached:
for node in ast.walk(functions[name]):
if isinstance(node, ast.Name) and node.id in CHAT_CLIENT_NAMES:
found.add(node.id)
elif isinstance(node, ast.Attribute) and node.attr in CHAT_CLIENT_NAMES:
found.add(node.attr)
return found
def entry_reaches(src: Path, entry: Mapping[str, Any], symbol: str) -> tuple[bool, str]:
"""(steget er kallbart utenfra uten chatklient, grunnen når det ikke er det).
Kjent grense: chatklient-sjekken leser HELE det ``main`` når i sin egen modul, ikke bare den
ene veien ned til steget. En inngang som bygger en chatklient i en annen gren teller derfor
som avhengig. Det er med vilje strengere enn nødvendig: en dør som noen ganger drar en modell
med seg er ikke en dør Claude Code kan bruke uten å vite når."""
scope = str(entry.get("scope", "main"))
tree = _tree(src, str(entry["module"]))
if tree is None:
return False, f"inngangsmodulen {entry['module']} finnes ikke"
functions = _functions(tree)
if scope not in functions:
return False, f"{entry['module']} har ingen {scope}()"
reached = _reachable(functions, scope)
called = {n for name in reached for n in _call_names(functions[name])}
if symbol not in called:
return False, f"{scope}() i {entry['module']} når aldri {symbol}"
chat = _chat_client_names(functions, sorted(reached))
if chat:
return False, f"veien fra {scope}() krever chatklient ({', '.join(sorted(chat))})"
return True, ""
def console_scripts(repo_root: Path) -> dict[str, str]:
"""``[project.scripts]`` i pyproject.toml, lest som tekst.
Lest som tekst og ikke med ``tomllib``, fordi pakken støtter Python 3.10, der ``tomllib``
ikke finnes — og en gate som krever en nyere tolk enn produktet er en gate som ikke kjører
der produktet gjør."""
path = repo_root / "pyproject.toml"
if not path.is_file():
return {}
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError): # pragma: no cover - et ulesbart manifest er ingen dør
return {}
scripts: dict[str, str] = {}
inside = False
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("["):
inside = stripped == "[project.scripts]"
continue
if not inside or "=" not in stripped or stripped.startswith("#"):
continue
name, _, target = stripped.partition("=")
scripts[name.strip().strip("\"'")] = target.strip().strip("\"'")
return scripts
def _dotted(package: str, module: str) -> str:
return f"{package}." + str(module).removesuffix(".py").replace("/", ".")
def _has_dunder_main(tree: ast.AST, scope: str) -> bool:
"""``if __name__ == "__main__":`` som faktisk kaller ``scope`` — en modul kjørbar med -m."""
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
test = node.test
if not isinstance(test, ast.Compare) or not isinstance(test.left, ast.Name):
continue
if test.left.id != "__name__":
continue
if not any(isinstance(c, ast.Constant) and c.value == "__main__" for c in test.comparators):
continue
if any(scope in _call_names(child) for child in node.body):
return True
return False
def _registers_subcommand(tree: ast.AST, command: str) -> bool:
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
if node.func.attr != "add_parser":
continue
if any(isinstance(a, ast.Constant) and a.value == command for a in node.args):
return True
return False
def registered_entry(
src: Path, repo_root: Path, package: str, entry: Mapping[str, Any]
) -> tuple[bool, str]:
"""(inngangen er REGISTRERT, grunnen når den ikke er det).
Målt 19.09.2026: en modul med ``def main(): return <symbol>()`` og ingenting annet tok rad 1
fra 3 til 4 av 13. Et navn er ikke en dør. Døren må være registrert et sted utenfor modulen
selv — i pakkemanifestet, i en ``__main__``-vakt, eller i en argparse-underkommando — og
arten må være en gaten har kode for å etterprøve."""
kind = str(entry.get("kind", ""))
if kind not in ENTRY_KINDS:
return False, f"inngangsarten {kind!r} er ikke en gaten kan verifisere"
module = str(entry["module"])
scope = str(entry.get("scope", "main"))
tree = _tree(src, module)
if tree is None:
return False, f"inngangsmodulen {module} finnes ikke"
if kind == "console-script":
target = f"{_dotted(package, module)}:{scope}"
if target not in console_scripts(repo_root).values():
return False, f"{target} er ikke registrert i pyproject [project.scripts]"
return True, ""
if kind == "module-main":
if not _has_dunder_main(tree, scope):
return False, f"{module} har ingen __main__-vakt som kaller {scope}()"
return True, ""
command = str(entry.get("command", ""))
if not command:
return False, f"{module} navngir ingen underkommando"
if not _registers_subcommand(tree, command):
return False, f"{module} registrerer ingen underkommando {command!r}"
return True, ""
def run_path_calls(src: Path, spec: Mapping[str, Any]) -> dict[str, str]:
"""``symbol -> modulen det kom fra``: po-funksjonene kjørestien FAKTISK kaller.
Dette er rad 1s nevner, og den leses av KILDEN. Regelen: et navn importert fra en
undermodul av pakken og kalt i scopet, eller et attributt på en modul importert som
``from <pakke> import X``. Klasser og unntak (stor forbokstav) er ikke steg, og private
hjelpere i driveren heller ikke. Et kall som verken er erklært som steg eller navngitt som
holdt utenfor, teller med i nevneren UTEN en dør — nevneren kan derfor ikke krympe stille,
som en kuratert liste i gatens egen datafil kunne."""
tree = _tree(src, str(spec["module"]))
if tree is None:
return {}
package = str(spec.get("package", "portfolio_optimiser"))
symbols: dict[str, str] = {}
aliases: set[str] = set()
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom):
continue
module = node.module or ""
if module == package:
aliases |= {a.asname or a.name for a in node.names}
elif module.startswith(f"{package}."):
for alias in node.names:
symbols[alias.asname or alias.name] = module.split(".")[-1] + ".py"
found: dict[str, str] = {}
for scope in _scopes(tree, str(spec["scope"])):
for call in ast.walk(scope):
if not isinstance(call, ast.Call):
continue
func = call.func
if isinstance(func, ast.Name) and func.id in symbols and func.id[:1].islower():
found[func.id] = symbols[func.id]
elif (
isinstance(func, ast.Attribute)
and isinstance(func.value, ast.Name)
and func.value.id in aliases
and func.attr[:1].islower()
):
found[func.attr] = f"{func.value.id}.py"
return found
def measure_steps(
steps: Sequence[Mapping[str, Any]],
src: Path,
outcomes: Mapping[str, str] | None = None,
repo_root: Path = _REPO_ROOT,
package: str = "portfolio_optimiser",
) -> list[Step]:
measured: list[Step] = []
for step in steps:
tree = _tree(src, str(step["module"]))
resolved = tree is not None and _defined(tree, str(step["symbol"]))
called = calls_symbol(
src, str(step["driver"]["module"]), str(step["driver"]["scope"]), str(step["symbol"])
)
if not resolved:
why = f"symbolet {step['symbol']} finnes ikke i {step['module']}"
elif not called:
why = f"{step['driver']['module']}::{step['driver']['scope']} kaller ikke {step['symbol']}"
else:
ok, why = entry_reaches(src, step["entry"], str(step["symbol"]))
if ok:
ok, why = registered_entry(src, repo_root, package, step["entry"])
if ok:
probes = list(step.get("probe", ()))
verdict = _probe_verdict(probes, outcomes or {})
why = (
"ingen atferdsprobe registrert — et navn er ikke en atferd"
if not probes
else (f"atferdsprobe: {verdict}" if verdict else "")
)
external = resolved and called and not why
measured.append(Step(str(step["id"]), str(step["label"]), resolved, called, external, why))
return measured
def score_toolbox(
steps: Sequence[Mapping[str, Any]],
src: Path,
run_path: Mapping[str, Any] | None = None,
outcomes: Mapping[str, str] | None = None,
repo_root: Path = _REPO_ROOT,
) -> Row:
package = str((run_path or {}).get("package", "portfolio_optimiser"))
measured = measure_steps(steps, src, outcomes, repo_root, package)
in_path = [s for s in measured if s.resolved and s.called]
declared = {str(s["symbol"]) for s in steps}
held = {
str(h["symbol"]): str(h.get("reason", "")) for h in (run_path or {}).get("held_out", ())
}
calls = run_path_calls(src, run_path) if run_path else {}
undeclared = sorted(name for name in calls if name not in declared and name not in held)
n = len(in_path) + len(undeclared)
k = sum(1 for s in in_path if s.external)
exceptions = tuple(f"{s.id}: {s.why}" for s in measured if not s.external) + tuple(
f"udeklarert: {name} kalles i {calls[name]} fra kjørestien uten å være et steg"
for name in undeclared
)
diagnostics = tuple(
f"holdt utenfor: {symbol}{reason or 'UTEN GRUNN'}"
for symbol, reason in sorted(held.items())
) + tuple(
f"holdt utenfor: {symbol} kalles ikke lenger i kjørestien (foreldet unntak)"
for symbol in sorted(held)
if calls and symbol not in calls
)
return Row(
"verktøykasse",
"1 steg i kjørestien kallbare utenfra",
k,
n,
GREEN if n and k == n else RED,
f"{k} av {n} steg har en dør ut; {len(held)} kall holdt utenfor med grunn, "
f"{len(measured) - len(in_path)} erklærte steg er ikke i kjørestien",
exceptions=exceptions,
diagnostics=diagnostics,
attests=(EXTERNAL_DOOR,),
)
# ---------------------------------------------------------------------------------------------
# Rad 2 — det modellen leverte, kan leveres utenfra
# ---------------------------------------------------------------------------------------------
def run_roles(src: Path, source: Mapping[str, Any]) -> tuple[str, ...]:
"""Rollene en vanlig kjøring spør, lest av KILDEN — aldri av en liste i denne fila."""
tree = _tree(src, str(source["module"]))
if tree is None:
return ()
for node in getattr(tree, "body", []):
if not isinstance(node, ast.Assign):
continue
if not any(isinstance(t, ast.Name) and t.id == source["symbol"] for t in node.targets):
continue
value = node.value
if isinstance(value, (ast.Tuple, ast.List)):
return tuple(
e.value
for e in value.elts
if isinstance(e, ast.Constant) and isinstance(e.value, str)
)
return ()
def _probe_verdict(nodeids: Sequence[str], outcomes: Mapping[str, str]) -> str:
if not nodeids:
return "ingen probe registrert"
bad = [
f"{n.split('::')[-1]}={outcomes.get(n, 'missing')}"
for n in nodeids
if outcomes.get(n) != "passed"
]
return "; ".join(bad)
def score_roles(roles: Mapping[str, Any], outcomes: Mapping[str, str], src: Path) -> Row:
names = run_roles(src, roles["source"])
evidence = roles["evidence"]
verdicts = {name: _probe_verdict(list(evidence.get(name, ())), outcomes) for name in names}
k = sum(1 for why in verdicts.values() if not why)
n = len(names)
exceptions = tuple(f"{name}: {why}" for name, why in verdicts.items() if why)
green = ", ".join(name for name, why in verdicts.items() if not why) or "ingen"
return Row(
"roller",
"2 roller som kan leveres utenfra",
k,
n,
GREEN if n and k == n else RED,
f"grønne: {green}",
exceptions=exceptions,
attests=(BYTE_IDENTICAL_RULE,),
)
# ---------------------------------------------------------------------------------------------
# Rad 3 — po har ingen vei til Claude
# ---------------------------------------------------------------------------------------------
_FENCE = re.compile(r"^\s*(```|~~~)")
#: Rad 3: hva vakten IKKE rekker, sagt av raden selv. Fire av PMs fem indirekte kall-varianter
#: slapp forbi før 19.09; disse to gjør det fortsatt, og de er linje-lokale grenser i en
#: tekstvakt, ikke noe en regex kan lukke.
NO_CLAUDE_LIMIT = (
"rad 3 er en TEKSTVAKT over hele det publiserte manifestet, ikke en dataflyt-analyse: den "
"feller et programnavn skrevet ut, en absolutt sti, en liste lagt i en variabel, en "
"konstant og en shell-streng. To veier står igjen og kan ikke felles linje for linje — et "
"kommandonavn satt sammen av deler ved kjøretid, og et navn lest ut av en miljøvariabel. "
"Raden påstår derfor ikke at ingen vei finnes; den påstår at ingen SKREVET vei finnes, over "
"en nevner den oppgir i filer"
)
def published_files(root: Path, manifest: Mapping[str, Any]) -> tuple[list[Path], str]:
"""(filene som PUBLISERES, hvor lista kom fra).
Kilden er repo-manifestet, ikke en håndliste: ``git ls-files`` når det finnes en ``.git``,
og ellers filtreet selv — et rent uttrekk (``git archive | tar -x``) ER nøyaktig det
publiserte, og har ingen git-metadata å spørre. Håndlista den erstattet utelot ``main.py``,
``examples/``, ``spikes/`` og ``contexts/``: 433 av 512 filer ble skannet, og alle seks
vaktenes kjent-positive kunne plantes i ``main.py`` uten at raden merket det.
Ingen suffiks-allowlist heller: en fil som lar seg lese som tekst kan bære en kommandolinje
uansett hva den heter. Det som ikke lar seg avkode, telles og oppgis."""
skip = set(manifest.get("skip_dirs", (".git", "__pycache__")))
if (root / ".git").exists():
try:
listing = subprocess.run(
["git", "ls-files", "-z"],
cwd=root,
capture_output=True,
text=True,
check=True,
).stdout
except (OSError, subprocess.CalledProcessError): # pragma: no cover - git kan mangle
listing = None
if listing is not None:
found = [root / rel for rel in listing.split("\0") if rel]
return [p for p in found if p.is_file() and not p.is_symlink()], "git-manifestet"
walked = [
path
for path in sorted(root.rglob("*"))
if path.is_file()
and not path.is_symlink()
and not (skip & set(path.relative_to(root).parts))
]
return walked, "filtreet (uttrekk uten git-metadata)"
def scannable_lines(path: Path, text: str) -> list[tuple[int, str]]:
"""Linjene som kan bære en KJØRBAR kommando.
I markdown er det linjene inne i en kodeblokk: en omtale av et pakkenavn i brødtekst er en
vurdering noen skrev ned, ikke en vei noen kan gå."""
lines = text.splitlines()
if path.suffix != ".md":
return list(enumerate(lines, 1))
inside = False
kept: list[tuple[int, str]] = []
for number, line in enumerate(lines, 1):
if _FENCE.match(line):
inside = not inside
continue
if inside:
kept.append((number, line))
return kept
@dataclass(frozen=True)
class Pattern:
id: str
label: str
valid: bool
why: str
hits: tuple[str, ...]
@dataclass(frozen=True)
class Surface:
files: int
unreadable: int
source: str
sentinel: bool
def measure_patterns(config: Mapping[str, Any], root: Path) -> tuple[list[Pattern], Surface]:
manifest = config.get("manifest", {})
files, source = published_files(root, manifest)
sentinel = str(manifest.get("sentinel", ""))
scanned: list[tuple[Path, list[tuple[int, str]]]] = []
unreadable = 0
for path in files:
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
unreadable += 1
continue
scanned.append((path, scannable_lines(path, text)))
measured: list[Pattern] = []
for spec in config["patterns"]:
rx = re.compile(decode(str(spec["pattern_b64"])))
positive = decode(str(spec["known_positive_b64"]))
negative = decode(str(spec["known_negative_b64"]))
if not rx.search(positive):
why = "kjent-positiv treffer ikke — vakten måler ingenting"
elif rx.search(negative):
why = "kjent-negativ treffer — vakten skiller ikke"
else:
why = ""
hits = tuple(
f"{path.relative_to(root)}:{number}"
for path, lines in scanned
for number, line in lines
if rx.search(line)
)
measured.append(Pattern(str(spec["id"]), str(spec["label"]), not why, why, hits))
present = bool(sentinel) and any(str(p.relative_to(root)) == sentinel for p in files)
return measured, Surface(len(files), unreadable, source, present)
def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
measured, surface = measure_patterns(config, root)
n = len(measured)
k = sum(1 for p in measured if p.valid and not p.hits)
exceptions: list[str] = []
for pattern in measured:
if not pattern.valid:
exceptions.append(f"{pattern.id}: {pattern.why}")
elif pattern.hits:
shown = ", ".join(pattern.hits[:3])
exceptions.append(f"{pattern.id}: {len(pattern.hits)} treff — {shown}")
sentinel = str(config.get("manifest", {}).get("sentinel", ""))
if not surface.sentinel:
exceptions.append(
f"flaten mangler sin sentinel ({sentinel}) — {surface.files} filer sett, "
"et fravær uten nevner er ikke et bevis"
)
status = NOT_MEASURED if not surface.sentinel else GREEN if n and k == n else RED
return Row(
"ingen-claude-vei",
"3 vakter mot en vei fra po til Claude",
k,
n,
status,
f"{k} av {n} vakter måler og gir null treff over {surface.files} publiserte filer "
f"({surface.source}; {surface.unreadable} ulesbare)",
exceptions=tuple(exceptions),
attests=(NO_CLAUDE_LIMIT,),
)
# ---------------------------------------------------------------------------------------------
# Rad 4 — ingen modellkall i verktøykasse-modus
# ---------------------------------------------------------------------------------------------
def score_no_model_calls(
config: Mapping[str, Any], outcomes: Mapping[str, str], src: Path | None = None
) -> Row:
"""Rad 4s nevner er sjekkene hvis NAVNGITTE kilde faktisk finnes i koden.
M = 3 var en konstant i gatens egen konfig. Nå bærer hver sjekk et symbol i kilden den
handler om — kjøringen selv, rundebinderen, og v1-gatens ``FORM_OK``. Forsvinner symbolet,
faller sjekken ut av nevneren OG navngis, i stedet for å bli en grønn rad over ingenting."""
source_root = src if src is not None else _PACKAGE_SRC
counted: dict[str, str] = {}
exceptions: list[str] = []
for name, check in config["checks"].items():
source = check["source"]
tree = _tree(source_root, str(source["module"]))
if tree is None or not _defined(tree, str(source["symbol"])):
exceptions.append(
f"{name}: kilden {source['module']}::{source['symbol']} finnes ikke — "
"sjekken teller ikke med i nevneren"
)
continue
counted[name] = _probe_verdict(list(check.get("probe", ())), outcomes)
k = sum(1 for why in counted.values() if not why)
n = len(counted)
exceptions += [f"{name}: {why}" for name, why in counted.items() if why]
return Row(
"ingen-modellkall",
"4 løpet drevet uten et eneste modellkall",
k,
n,
GREEN if n and k == n else RED,
f"{k} av {n} sjekker har en bestått probe (nevneren er sjekker med en kilde i koden)",
exceptions=tuple(exceptions),
attests=(BUDGET_IN_B,),
)
# ---------------------------------------------------------------------------------------------
# Rad 5 — Foundry-veien urørt
# ---------------------------------------------------------------------------------------------
def foundry_intact(config: Mapping[str, Any], src: Path) -> tuple[bool, tuple[str, ...]]:
"""(Foundry-veien står slik den sto, hva som mangler når den ikke gjør det)."""
missing: list[str] = []
profile = config["profile"]
tree = _tree(src, str(profile["module"]))
members: set[str] = set()
if tree is not None:
for scope in _scopes(tree, str(profile["scope"])):
for node in ast.walk(scope):
if isinstance(node, ast.Assign):
members |= {t.id for t in node.targets if isinstance(t, ast.Name)}
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
members.add(node.target.id)
missing += [
f"{profile['scope']}.{m} borte fra {profile['module']}"
for m in profile["members"]
if m not in members
]
factory = config["factory"]
tree = _tree(src, str(factory["module"]))
if tree is None or not _defined(tree, str(factory["symbol"])):
missing.append(f"{factory['symbol']} borte fra {factory['module']}")
seam = config["seam"]
tree = _tree(src, str(seam["module"]))
found = False
if tree is not None:
for scope in _scopes(tree, str(seam["scope"])):
args = getattr(scope, "args", None)
if args is None:
continue
names = {a.arg for a in [*args.args, *args.posonlyargs, *args.kwonlyargs]}
if seam["parameter"] in names:
found = True
if not found:
missing.append(f"{seam['scope']}() tar ikke lenger {seam['parameter']}")
return not missing, tuple(missing)
def score_foundry(config: Mapping[str, Any], outcomes: Mapping[str, str], src: Path) -> Row:
"""Hvert strukturelle krav telles for seg, og hvert av dem er et symbol i kilden.
M = 2 blandet profil-medlemmene, fabrikken og injeksjonssømmen til ÉN tellende enhet: tre
krav kunne falle uten at tallet endret seg med mer enn én. Nevneren er nå medlemmene
kontrakten navngir, pluss fabrikken, pluss sømmen, pluss probene."""
_intact, missing = foundry_intact(config, src)
structural = len(config["profile"]["members"]) + 2
verdicts = {
name: _probe_verdict(list(ids), outcomes) for name, ids in config["evidence"].items()
}
k = structural - len(missing) + sum(1 for why in verdicts.values() if not why)
n = structural + len(verdicts)
exceptions = [f"urørt: {m}" for m in missing]
exceptions += [f"{name}: {why}" for name, why in verdicts.items() if why]
return Row(
"foundry",
"5 Foundry-veien urørt og samme artefaktfamilie",
k,
n,
GREEN if k == n else RED,
f"{structural - len(missing)} av {structural} strukturkrav står"
+ ("" if not missing else " — Foundry-veien er rørt"),
exceptions=tuple(exceptions),
)
# ---------------------------------------------------------------------------------------------
# Rad 6 — kjøreboka finnes
# ---------------------------------------------------------------------------------------------
@dataclass(frozen=True)
class RunbookAttestation:
present: bool
ok: bool
why: str
def read_runbook_attestation(
path: Path,
keys: Sequence[str],
runbook: Path,
runbook_name: str,
now: datetime | None = None,
) -> RunbookAttestation:
"""Operatørens bekreftelse på kjøreboka — VALIDERT, ikke bare til stede.
Målt 19.09.2026: den gamle parseren godtok ``kjørebok: x``, datoen ``x``, en framtidsdato og
en peker til en helt annen fil — alle tre ga 2 av 2 GRØNN. Fire ting må stemme nå: fila
navngir kontraktens kjørebok, sjekksummen er kjørebokas egen (en attestering av en annen
versjon er ikke en attestering av denne), noen står som den som kjørte den, og datoen er en
ekte ISO-dato som ikke ligger i framtiden. Datoregelen er v1-gatens egen (``_parse_given``),
gjenbrukt og ikke skrevet om: en dato med klokkeslett må ha tidssone.
BOM tåles (``utf-8-sig``), som på v1-gaten: en editor som skriver den er ikke personens feil."""
if path.is_symlink():
return RunbookAttestation(False, False, f"{path.name} er en symlenke, ikke en fil")
if path.is_dir():
return RunbookAttestation(False, False, f"{path.name} er en katalog, ikke en fil")
if not path.is_file():
return RunbookAttestation(False, False, f"{path} finnes ikke")
try:
text = path.read_text(encoding="utf-8-sig")
except (UnicodeDecodeError, OSError):
return RunbookAttestation(True, False, f"{path.name} er ikke UTF-8")
seen: dict[str, str] = {}
for line in text.splitlines():
key, sep, value = line.partition(":")
if not sep:
continue
key = key.strip()
if key not in keys:
continue
if key in seen:
return RunbookAttestation(True, False, f"nøkkelen «{key}» står to ganger")
seen[key] = value.strip()
missing = [k for k in keys if not seen.get(k)]
if missing:
return RunbookAttestation(True, False, f"mangler {', '.join(missing)}")
if seen["kjørebok"] != runbook_name:
return RunbookAttestation(
True,
False,
f"attesterer kjørebok {seen['kjørebok']!r}, ikke kontraktens {runbook_name!r}",
)
if not runbook.is_file():
return RunbookAttestation(True, False, f"kjørebok {runbook_name} finnes ikke å sjekksumme")
digest = hashlib.sha256(runbook.read_bytes()).hexdigest()
if seen["sjekksum"].casefold() != digest:
return RunbookAttestation(
True,
False,
f"sjekksum {seen['sjekksum']!r} er ikke kjørebokas ({digest[:12]}…) — attesteringen "
"gjelder en annen versjon",
)
given, why = _parse_given(seen["dato"])
if given is None:
return RunbookAttestation(True, False, why)
clock = now if now is not None else datetime.now(tz=timezone.utc)
future = (
given > clock
if isinstance(given, datetime)
else given > clock.astimezone(timezone.utc).date()
)
if future:
return RunbookAttestation(
True,
False,
f"attestert {given.isoformat()}, i framtiden (nå: {clock.isoformat(timespec='seconds')})",
)
return RunbookAttestation(True, True, "")
def score_runbook(
config: Mapping[str, Any],
repo_root: Path,
attest: Path | None = None,
now: datetime | None = None,
) -> Row:
runbook = repo_root / str(config["path"])
has_runbook = runbook.is_file() and bool(runbook.read_text(encoding="utf-8").strip())
attest_path = attest if attest is not None else repo_root / str(config["attestation"])
attestation = read_runbook_attestation(
attest_path, list(config["keys"]), runbook, str(config["path"]), now
)
k = int(has_runbook) + int(attestation.ok)
n = len(config["artefacts"])
exceptions: list[str] = []
if not has_runbook:
exceptions.append(f"kjørebok: {config['path']} finnes ikke")
if not attestation.ok:
exceptions.append(f"attestering: {attestation.why}")
return Row(
"kjørebok",
"6 kjøreboka finnes og er kjørt",
k,
n,
GREEN if k == n else NOT_MEASURED,
"kjøreboka og operatørens bekreftelse står" if k == n else "venter på operatøren",
exceptions=tuple(exceptions),
attests=(RUNBOOK_RULE,),
)
# ---------------------------------------------------------------------------------------------
# Hele gaten
# ---------------------------------------------------------------------------------------------
def probe_nodeids(config: Mapping[str, Any]) -> list[str]:
ids: list[str] = [n for step in config["steps"] for n in step.get("probe", ())]
ids += [n for nodeids in config["roles"]["evidence"].values() for n in nodeids]
ids += [
n for check in config["no_model_calls"]["checks"].values() for n in check.get("probe", ())
]
ids += [n for nodeids in config["foundry"]["evidence"].values() for n in nodeids]
return ids
def evaluate(
*,
config: Mapping[str, Any],
repo_root: Path = _REPO_ROOT,
src: Path = _PACKAGE_SRC,
probe_runner: ProbeRunner | None = None,
attest: Path | None = None,
now: datetime | None = None,
) -> list[Row]:
runner = probe_runner or (lambda ids: run_probes(ids, repo_root))
outcomes = runner(probe_nodeids(config))
return [
score_toolbox(config["steps"], src, config.get("run_path"), outcomes, repo_root),
score_roles(config["roles"], outcomes, src),
score_no_claude_path(config["no_claude_path"], repo_root),
score_no_model_calls(config["no_model_calls"], outcomes, src),
score_foundry(config["foundry"], outcomes, src),
score_runbook(config["runbook"], repo_root, attest, now),
]
def render(rows: Sequence[Row]) -> str:
out = ["rad | k av N | status | grunn"]
out += [r.line() for r in rows]
out.append("")
for row in rows:
for attest in row.attests:
out.append(f" [{row.title.split()[0]}] {attest}.")
out.append("")
for row in rows:
for note in row.diagnostics:
out.append(f" [{row.title.split()[0]}] {note}")
if any(r.diagnostics for r in rows):
out.append("")
out.append("Unntak fra 100 %:")
for row in rows:
for exception in row.exceptions:
out.append(f" [{row.title.split()[0]}] {exception}")
return "\n".join(out)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m portfolio_optimiser.evals.b_gate",
description="Hvor langt po er fra å være en verktøykasse Claude Code kan drive, rad for "
"rad. Exit 0 kun når hver fellende rad er grønn, 1 ellers, 2 ved feil bruk. Ingen "
"modellkall, intet nett.",
epilog=CONTRACT,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--attest",
default=None,
help="operatørens bekreftelse på kjøreboka (default: den gitignorerte stien i kontrakten)",
)
parser.add_argument("--json", action="store_true", help="maskinlesbar output")
args = parser.parse_args(argv)
if args.attest is not None and not Path(args.attest).is_file():
parser.error(f"--attest {args.attest} finnes ikke — gaten skriver den aldri selv")
rows = evaluate(config=load_config(), attest=Path(args.attest) if args.attest else None)
code = exit_code(rows)
if args.json:
print(
json.dumps(
{"exit": code, "rows": [asdict(r) for r in rows]}, ensure_ascii=False, indent=2
)
)
else:
print(render(rows))
return code
if __name__ == "__main__": # pragma: no cover - dekket av en subprosess-test
raise SystemExit(main())