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:
parent
0a784065d0
commit
f7ade7aa8b
3 changed files with 716 additions and 13 deletions
|
|
@ -286,10 +286,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foundry": {
|
"foundry": {
|
||||||
"profiles": [
|
"profile": {
|
||||||
"AZURE",
|
"module": "backends.py",
|
||||||
"LOCAL"
|
"scope": "Profile",
|
||||||
],
|
"members": [
|
||||||
|
"AZURE",
|
||||||
|
"LOCAL"
|
||||||
|
]
|
||||||
|
},
|
||||||
"factory": {
|
"factory": {
|
||||||
"module": "run.py",
|
"module": "run.py",
|
||||||
"symbol": "_default_factory"
|
"symbol": "_default_factory"
|
||||||
|
|
|
||||||
694
src/portfolio_optimiser/evals/b_gate.py
Normal file
694
src/portfolio_optimiser/evals/b_gate.py
Normal 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 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())
|
||||||
|
|
@ -282,7 +282,9 @@ def test_a_planted_path_to_claude_turns_the_row_red_for_that_pattern(tmp_path: P
|
||||||
cfg = json.loads(json.dumps(_CONFIG["no_claude_path"]))
|
cfg = json.loads(json.dumps(_CONFIG["no_claude_path"]))
|
||||||
cfg["roots"] = ["src"]
|
cfg["roots"] = ["src"]
|
||||||
for spec in cfg["patterns"]:
|
for spec in cfg["patterns"]:
|
||||||
_write(tmp_path / "src" / f"{spec['id']}.py", gate.decode(spec["known_positive_b64"]) + "\n")
|
_write(
|
||||||
|
tmp_path / "src" / f"{spec['id']}.py", gate.decode(spec["known_positive_b64"]) + "\n"
|
||||||
|
)
|
||||||
row = gate.score_no_claude_path(cfg, tmp_path)
|
row = gate.score_no_claude_path(cfg, tmp_path)
|
||||||
assert (row.k, row.status) == (0, gate.RED)
|
assert (row.k, row.status) == (0, gate.RED)
|
||||||
assert len(row.exceptions) == _PATTERNS_TODAY
|
assert len(row.exceptions) == _PATTERNS_TODAY
|
||||||
|
|
@ -408,9 +410,7 @@ def test_m5_the_gate_never_writes_the_attestation_itself(tmp_path: Path) -> None
|
||||||
"""M-5: kan gaten skrive fila den leser, er rad 6 en sløyfe som bekrefter seg selv."""
|
"""M-5: kan gaten skrive fila den leser, er rad 6 en sløyfe som bekrefter seg selv."""
|
||||||
_write(tmp_path / _CONFIG["runbook"]["path"], "# kjørebok\n")
|
_write(tmp_path / _CONFIG["runbook"]["path"], "# kjørebok\n")
|
||||||
attest = tmp_path / _CONFIG["runbook"]["attestation"]
|
attest = tmp_path / _CONFIG["runbook"]["attestation"]
|
||||||
rows = gate.evaluate(
|
rows = gate.evaluate(config=_CONFIG, repo_root=tmp_path, src=_SRC, probe_runner=_probes())
|
||||||
config=_CONFIG, repo_root=tmp_path, src=_SRC, probe_runner=_probes()
|
|
||||||
)
|
|
||||||
assert not attest.exists(), "gaten skrev operatørens attestering — da beviser rad 6 ingenting"
|
assert not attest.exists(), "gaten skrev operatørens attestering — da beviser rad 6 ingenting"
|
||||||
assert _row(rows, "kjørebok").status == gate.NOT_MEASURED
|
assert _row(rows, "kjørebok").status == gate.NOT_MEASURED
|
||||||
assert any("aldri" in a for a in _row(rows, "kjørebok").attests)
|
assert any("aldri" in a for a in _row(rows, "kjørebok").attests)
|
||||||
|
|
@ -439,11 +439,16 @@ def test_the_gate_is_red_today_and_every_row_is_red_for_a_measured_reason() -> N
|
||||||
assert row.exceptions, f"{row.key} er ikke grønn uten å si hvorfor"
|
assert row.exceptions, f"{row.key} er ikke grønn uten å si hvorfor"
|
||||||
|
|
||||||
|
|
||||||
def test_the_probe_runner_can_return_passed_for_a_test_that_exists() -> None:
|
def test_the_probe_runner_separates_a_test_that_exists_from_one_that_does_not() -> None:
|
||||||
"""Kjent-positiv for selve probe-mekanismen: «missing» over alt ville vært et måleresultat
|
"""Kjent-positiv for selve probe-mekanismen: «missing» over hele lista ville vært et
|
||||||
uten nevner. Nevner: én nodeid som finnes i denne suiten."""
|
måleresultat uten nevner. Nevneren er to nodeid-er — én som finnes i suiten og én som ikke
|
||||||
nodeid = "tests/test_b_gate.py::test_the_probe_runner_can_return_passed_for_a_test_that_exists"
|
gjør det — og mekanismen må skille dem. Proben peker med vilje UT av denne fila: en nodeid
|
||||||
assert v1_gate.run_probes([nodeid], _REPO)[nodeid] == "passed"
|
herfra ville fått barne-pytest til å kjøre denne testen igjen, i det uendelige."""
|
||||||
|
lever = "tests/test_ledger.py::test_totals_per_project_and_portfolio"
|
||||||
|
dod = "tests/test_ledger.py::test_finnes_ikke_xyzzy"
|
||||||
|
outcomes = v1_gate.run_probes([lever, dod], _REPO)
|
||||||
|
assert outcomes[lever] == "passed"
|
||||||
|
assert outcomes[dod] == "missing"
|
||||||
|
|
||||||
|
|
||||||
def test_render_names_every_row_the_budget_choice_and_the_runbook_rule() -> None:
|
def test_render_names_every_row_the_budget_choice_and_the_runbook_rule() -> None:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue