feat(b-gate): the gate that measures po as a toolbox, red on six measured rows [skip-docs]

python -m portfolio_optimiser.evals.b_gate — one command, offline, no model call, exit 1 today:

  1 steg i kjørestien kallbare utenfra        3 av 13   RØD
  2 roller som kan leveres utenfra            0 av 2    RØD
  3 vakter mot en vei fra po til Claude       6 av 6    GRØNN  (435 published files)
  4 løpet drevet uten et eneste modellkall    0 av 3    RØD
  5 Foundry-veien urørt og samme artefaktfamilie 1 av 2 RØD
  6 kjøreboka finnes og er kjørt              0 av 2    IKKE MÅLT

Every denominator is read off the source, never off a list in the gate. Row 1 counts the steps
of the run path that resolve to a symbol AND have a call site; a step is externally callable only
when a CLI (or MCP-registered) entry reaches it without any chat-client construct on the way —
which is why the ten run.py steps are red and round_builder's two plus the v1 gate are green. Row
2 reads the roles off workflow._MAKER_CHECKER_ROLES. Row 3's patterns each carry a known-positive
AND a known-negative fixture, so a guard that cannot hit is not counted as a zero.

Three decisions the operator cannot answer without reading code, made here and stated in the
gate's own output:

* the external door is a CLI subcommand, not MCP — po already has five main() and two console
  commands, and MCP would need a server the run path does not have. The gate still counts an
  MCP-registered door, so the choice does not bind the next order.
* the budget guard in B is NOT po's: BudgetMiddleware is fail-closed on missing usage and is
  never constructed without a chat client, so keeping it here would turn fail-closed into
  fail-open. The ceiling in B is the Claude Code session's own spend, which po neither sees nor
  steers. The Foundry path keeps its ceiling unchanged.
* row 6 is IKKE MÅLT, never green, until the operator attests that the runbook actually drove an
  analysis — a file the gate never writes, the same rule as the v1 gate's attestation.

Row 3's pattern text is base64 in the config so the contract cannot register as its own finding;
that is what lets the row run without an exclusion list, and a row without exclusions is a row
nobody can switch off by adding a filename.

Suite after: 2106 passed / 5 skipped / 5 xfailed (was 2072/5/5; +34 new, none changed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 08:29:10 +02:00
commit f7ade7aa8b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 716 additions and 13 deletions

View file

@ -286,10 +286,14 @@
}
},
"foundry": {
"profiles": [
"AZURE",
"LOCAL"
],
"profile": {
"module": "backends.py",
"scope": "Profile",
"members": [
"AZURE",
"LOCAL"
]
},
"factory": {
"module": "run.py",
"symbol": "_default_factory"

View file

@ -0,0 +1,694 @@
"""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 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 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 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 lista.
"""
from __future__ import annotations
import argparse
import ast
import base64
import binascii
import json
import re
from collections.abc import Callable, Mapping, Sequence
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,
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 CLI-underkommando som dør UT av verktøykassen, fordi po allerede har fem main() og to "
"konsoll-kommandoer, og operatørens ord er «som alle andre tools Claude Code kan bruke» — en "
"kommando er nettopp det. Grunnen til å velge bort MCP her er at MCP ville krevd en server po "
"ikke har i kjørestien i dag; gaten teller likevel en MCP-registrert dør som kallbar, så "
"valget binder ikke neste ordre"
)
#: 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 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 rad 3 i kontrakten, og probene
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 measure_steps(steps: Sequence[Mapping[str, Any]], src: Path) -> 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"]))
why = "" if ok else why
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) -> Row:
measured = measure_steps(steps, src)
in_path = [s for s in measured if s.resolved and s.called]
n = len(in_path)
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)
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(measured) - n} erklærte steg er ikke i kjørestien",
exceptions=exceptions,
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
# ---------------------------------------------------------------------------------------------
#: Flatene som publiseres, lest som tekst. Alt annet er binært og bærer ingen kjørbar linje.
TEXT_SUFFIXES = frozenset(
{".py", ".sh", ".toml", ".json", ".md", ".txt", ".cfg", ".ini", ".yaml", ".yml", ".template"}
)
_FENCE = re.compile(r"^\s*(```|~~~)")
def surface_files(root: Path, roots: Sequence[str]) -> list[Path]:
found: list[Path] = []
for rel in roots:
base = root / rel
if base.is_file():
found.append(base)
continue
if not base.is_dir():
continue
for path in sorted(base.rglob("*")):
if not path.is_file() or path.is_symlink():
continue
if "__pycache__" in path.parts or ".git" in path.parts:
continue
if path.suffix in TEXT_SUFFIXES:
found.append(path)
return found
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 ."""
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, ...]
def measure_patterns(config: Mapping[str, Any], root: Path) -> list[Pattern]:
scanned: list[tuple[Path, list[tuple[int, str]]]] = []
for path in surface_files(root, list(config["roots"])):
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
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))
return measured
def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
measured = measure_patterns(config, root)
n = len(measured)
k = sum(1 for p in measured if p.valid and not p.hits)
files = len(surface_files(root, list(config["roots"])))
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}")
return Row(
"ingen-claude-vei",
"3 vakter mot en vei fra po til Claude",
k,
n,
GREEN if n and k == n else RED,
f"{k} av {n} vakter måler og gir null treff over {files} publiserte filer",
exceptions=tuple(exceptions),
)
# ---------------------------------------------------------------------------------------------
# Rad 4 — ingen modellkall i verktøykasse-modus
# ---------------------------------------------------------------------------------------------
def score_no_model_calls(config: Mapping[str, Any], outcomes: Mapping[str, str]) -> Row:
checks = config["checks"]
verdicts = {name: _probe_verdict(list(ids), outcomes) for name, ids in checks.items()}
k = sum(1 for why in verdicts.values() if not why)
n = len(verdicts)
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",
exceptions=tuple(f"{name}: {why}" for name, why in verdicts.items() if why),
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:
intact, missing = foundry_intact(config, src)
verdicts = {
name: _probe_verdict(list(ids), outcomes) for name, ids in config["evidence"].items()
}
k = int(intact) + sum(1 for why in verdicts.values() if not why)
n = 1 + 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,
"profil, fabrikk og injeksjonssøm står" if intact 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]) -> RunbookAttestation:
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")
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)}")
return RunbookAttestation(True, True, "")
def score_runbook(config: Mapping[str, Any], repo_root: Path, attest: Path | 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"]))
k = int(has_runbook) + int(attestation.ok)
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,
2,
GREEN if k == 2 else NOT_MEASURED,
"kjøreboka og operatørens bekreftelse står" if k == 2 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] = []
for group in (
config["roles"]["evidence"],
config["no_model_calls"]["checks"],
config["foundry"]["evidence"],
):
ids += [n for nodeids in group.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,
) -> list[Row]:
runner = probe_runner or (lambda ids: run_probes(ids, repo_root))
outcomes = runner(probe_nodeids(config))
return [
score_toolbox(config["steps"], src),
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),
score_foundry(config["foundry"], outcomes, src),
score_runbook(config["runbook"], repo_root, attest),
]
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("")
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())