"""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 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 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 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 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, ...] 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())