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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 19:40:42 +02:00
commit 59f35fde22
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 734 additions and 221 deletions

View file

@ -1,5 +1,124 @@
{
"outcome": "En Claude Code-økt driver en hel analyse med po som verktøykasse, uten at po gjør ett eneste modellkall.",
"run_path": {
"module": "run.py",
"scope": "run_project",
"package": "portfolio_optimiser",
"held_out": [
{
"symbol": "admits",
"reason": "dimensjonsfilteret inne i mandat-vurderingen; skriver ingen egen artefakt"
},
{
"symbol": "baseline_from_project",
"reason": "utleder grunnlaget fra prosjektobjektet inne i «kostnadsgrunnlag»"
},
{
"symbol": "build_mcp_tools",
"reason": "bygger verktøylista debatten kaller; hører til modellveien"
},
{
"symbol": "bundle_citations",
"reason": "siteringshjelper inne i «kontekst»"
},
{
"symbol": "bundle_excerpt_citations",
"reason": "siteringshjelper inne i «kontekst»"
},
{
"symbol": "chunk_dict_to_citation",
"reason": "formkonvertering inne i «kontekst»"
},
{
"symbol": "classify_codes",
"reason": "klassifiserer avvisningskodene inne i «validering»"
},
{
"symbol": "criteria_block",
"reason": "renderer mandatkriteriene inn i prompten; modellveien"
},
{
"symbol": "fresh_workflow",
"reason": "krever chatklient: bygger MAF-debatten"
},
{
"symbol": "generate_via_llm",
"reason": "krever chatklient: ER modellkallet"
},
{
"symbol": "grounding_offer",
"reason": "bygger grunnlagstilbudet til prompten; modellveien"
},
{
"symbol": "load_contracts",
"reason": "leser kontraktsfilene før kjøringen; egen dør hører i preflight"
},
{
"symbol": "load_verdicts_from_dir",
"reason": "leser tidligere dommer inn i ExpeL-konteksten; inne i «dom»"
},
{
"symbol": "make_retrieval_tool",
"reason": "pakker «kontekst» som FunctionTool for agentene; modellveien"
},
{
"symbol": "navigator_tools",
"reason": "explore-modusens verktøy; rad 2 avgrenser B til proposer og checker"
},
{
"symbol": "optional_bundle_candidate_features",
"reason": "henter valgfrie trekk inne i «verdict-id»"
},
{
"symbol": "proposal_reviews_payload",
"reason": "bygger nyttelasten «forslagsvurderinger» skriver"
},
{
"symbol": "requirement_payload",
"reason": "explore-modus; utenfor B slik rad 2 avgrenser den"
},
{
"symbol": "resolve_model",
"reason": "slår opp deployment-navnet for chatklienten; modellveien"
},
{
"symbol": "tool_call_payload",
"reason": "bygger nyttelasten «debatt-verktøy» skriver"
},
{
"symbol": "tool_server_index",
"reason": "indekserer MCP-serverne for debatten; modellveien"
},
{
"symbol": "assert_declared_ids_agree",
"reason": "konsistenssjekk inne i «pakke»"
},
{
"symbol": "declared_reference_numbers",
"reason": "leser de erklærte referansetallene inne i «pakke»"
},
{
"symbol": "load_optional_cost_baseline",
"reason": "valgfri overstyring inne i «kostnadsgrunnlag»"
},
{
"symbol": "reconcile_bundle_id",
"reason": "binder bundle-id-en inne i «pakke»"
},
{
"symbol": "declaration_of",
"reason": "leser prepass-erklæringen inne i «prepass»"
},
{
"symbol": "declaration_payload",
"reason": "bygger erklæringens nyttelast inne i «prepass»"
},
{
"symbol": "render_context",
"reason": "renderer prepass-konteksten inn i prompten; modellveien"
}
]
},
"steps": [
{
"id": "pakke",
@ -11,10 +130,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "kostnadsgrunnlag",
@ -26,10 +146,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "kontekst",
@ -41,10 +162,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "prepass",
@ -56,10 +178,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "validering",
@ -71,10 +194,11 @@
"scope": "evaluate_mandate_candidates"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "verdict-id",
@ -86,10 +210,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "dom",
@ -101,10 +226,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "kjørekonfig",
@ -116,10 +242,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "coverage",
@ -131,10 +258,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "utboks",
@ -146,10 +274,11 @@
"scope": "run_project"
},
"entry": {
"kind": "cli",
"kind": "console-script",
"module": "run.py",
"scope": "main"
}
},
"probe": []
},
{
"id": "rundebinding",
@ -161,10 +290,13 @@
"scope": "main"
},
"entry": {
"kind": "cli",
"kind": "module-main",
"module": "evals/round_builder.py",
"scope": "main"
}
},
"probe": [
"tests/test_round_builder_loadbearing.py::test_the_command_line_builds_a_round_and_refuses_one_it_cannot"
]
},
{
"id": "rapport",
@ -176,10 +308,13 @@
"scope": "build_round"
},
"entry": {
"kind": "cli",
"kind": "module-main",
"module": "evals/round_builder.py",
"scope": "main"
}
},
"probe": [
"tests/test_round_builder_loadbearing.py::test_the_report_names_every_approach_that_was_considered"
]
},
{
"id": "gate",
@ -191,10 +326,77 @@
"scope": "main"
},
"entry": {
"kind": "cli",
"kind": "module-main",
"module": "evals/v1_gate.py",
"scope": "main"
}
},
"probe": [
"tests/test_v1_gate.py::test_the_command_is_red_today_with_every_row_in_its_output"
]
},
{
"id": "prepass-artefakt",
"label": "skriv prepassets egen artefakt",
"module": "outbox.py",
"symbol": "write_prepass",
"driver": {
"module": "run.py",
"scope": "run_project"
},
"entry": {
"kind": "console-script",
"module": "run.py",
"scope": "main"
},
"probe": []
},
{
"id": "parse-feil",
"label": "skriv det som ikke lot seg lese",
"module": "outbox.py",
"symbol": "write_parse_failures",
"driver": {
"module": "run.py",
"scope": "run_project"
},
"entry": {
"kind": "console-script",
"module": "run.py",
"scope": "main"
},
"probe": []
},
{
"id": "forslagsvurderinger",
"label": "skriv vurderingene av hvert forslag",
"module": "outbox.py",
"symbol": "write_proposal_reviews",
"driver": {
"module": "run.py",
"scope": "run_project"
},
"entry": {
"kind": "console-script",
"module": "run.py",
"scope": "main"
},
"probe": []
},
{
"id": "debatt-verktøy",
"label": "skriv verktøyene debatten kalte",
"module": "outbox.py",
"symbol": "write_debate_tools",
"driver": {
"module": "run.py",
"scope": "run_project"
},
"entry": {
"kind": "console-script",
"module": "run.py",
"scope": "main"
},
"probe": []
}
],
"roles": {
@ -214,19 +416,20 @@
}
},
"no_claude_path": {
"roots": [
"src",
"scripts",
"shared",
"docs",
"tests",
"pyproject.toml",
"README.md",
"CHANGELOG.md",
"DEPLOY.md",
"CONTRIBUTING.md",
"SECURITY.md"
],
"manifest": {
"sentinel": "src/portfolio_optimiser/run.py",
"skip_dirs": [
".git",
"__pycache__",
".venv",
".pytest_cache",
".ruff_cache",
".mypy_cache",
"node_modules",
".idea",
".vscode"
]
},
"patterns": [
{
"id": "prosess",
@ -269,20 +472,59 @@
"pattern_b64": "KEFOVEhST1BJQ19CQVNFX1VSTHxBTlRIUk9QSUNfQVBJX0tFWXxhcGlcLmFudGhyb3BpY1wuY29tKQ==",
"known_positive_b64": "b3MuZW52aXJvblsiQU5USFJPUElDX0JBU0VfVVJMIl0gPSBlbmRwb2ludA==",
"known_negative_b64": "b3MuZW52aXJvblsiQVpVUkVfT1BFTkFJX0VORFBPSU5UIl0gPSBlbmRwb2ludA=="
},
{
"id": "argv-literal",
"label": "claude som programnavn i en argumentliste",
"pattern_b64": "W1xbKCxdXHMqWyInXSg/OlteIidcc10qLyk/Y2xhdWRlWyInXQ==",
"known_positive_b64": "Y21kID0gWyJjbGF1ZGUiLCAiLXAiLCAiYW5hbHlzZXIgcHJvc2pla3RldCJd",
"known_negative_b64": "bW9kZWxsZXIgPSBbImNsYXVkZS1vcHVzLTUiLCAiZ3B0LTUiXQ=="
},
{
"id": "kommando-streng",
"label": "claude med flagg inne i en streng",
"pattern_b64": "WyInXSg/OlteIidcc10qLyk/Y2xhdWRlXHMrLXsxLDJ9W0EtWmEtel0=",
"known_positive_b64": "c3VicHJvY2Vzcy5ydW4oc2hsZXguc3BsaXQoImNsYXVkZSAtcCBhbmFseXNlciIpKQ==",
"known_negative_b64": "c3VicHJvY2Vzcy5ydW4oc2hsZXguc3BsaXQoInB5dGhvbiAtbSBwb3J0Zm9saW9fb3B0aW1pc2VyLnJ1biBQMSIpKQ=="
},
{
"id": "binaersti",
"label": "en konstant som navngir claude-binaeren",
"pattern_b64": "PVxzKlsiJ10oPzpbXiInXHNdKi8pP2NsYXVkZVsiJ10=",
"known_positive_b64": "Q0xBVURFX0JJTiA9ICIvdXNyL2xvY2FsL2Jpbi9jbGF1ZGUi",
"known_negative_b64": "TU9ERUxMID0gImNsYXVkZS1vcHVzLTUi"
}
]
},
"no_model_calls": {
"checks": {
"fullfører": [
"tests/test_b_toolbox_probes.py::test_the_whole_run_completes_with_the_chat_client_raising"
],
"rundekatalog": [
"tests/test_b_toolbox_probes.py::test_the_toolbox_outbox_binds_to_a_scratch_round_directory"
],
"gate-form-ok": [
"tests/test_b_toolbox_probes.py::test_v1_gate_reads_that_round_as_form_ok_not_proven"
]
"fullfører": {
"source": {
"module": "run.py",
"symbol": "run_project"
},
"probe": [
"tests/test_b_toolbox_probes.py::test_the_whole_run_completes_with_the_chat_client_raising"
]
},
"rundekatalog": {
"source": {
"module": "evals/round_builder.py",
"symbol": "build_round"
},
"probe": [
"tests/test_b_toolbox_probes.py::test_the_toolbox_outbox_binds_to_a_scratch_round_directory"
]
},
"gate-form-ok": {
"source": {
"module": "evals/v1_gate.py",
"symbol": "FORM_OK"
},
"probe": [
"tests/test_b_toolbox_probes.py::test_v1_gate_reads_that_round_as_form_ok_not_proven"
]
}
}
},
"foundry": {
@ -310,10 +552,15 @@
}
},
"runbook": {
"artefacts": [
"kjørebok",
"attestering"
],
"path": "docs/kjoerebok-verktoeykassen.md",
"attestation": "v1-rounds/kjoerebok-attestering.txt",
"keys": [
"kjørebok",
"sjekksum",
"kjørt",
"dato"
]

View file

@ -27,9 +27,12 @@ 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
@ -39,6 +42,7 @@ from portfolio_optimiser.evals.v1_gate import (
NOT_MEASURED,
RED,
Row,
_parse_given,
exit_code,
run_probes,
)
@ -75,13 +79,18 @@ CHAT_CLIENT_NAMES = frozenset(
#: 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"
"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 "
@ -123,37 +132,6 @@ finnes er RØD, aldri grønn og aldri hoppet over.
"""
# ---------------------------------------------------------------------------------------------
# STUBBER (rød-først). Navnene finnes her for at armene under skal felle på en ASSERT om ATFERD
# og ikke ved innsamling; verdiene er MED VILJE gale og erstattes i reparasjons-commiten.
# ---------------------------------------------------------------------------------------------
#: Inngangsartene gaten kan VERIFISERE. Tom her med vilje: stubben kjenner ingen.
ENTRY_KINDS: tuple[str, ...] = ()
def run_path_calls(src: Path, spec: Mapping[str, Any]) -> dict[str, str]:
"""STUB: skal lese kjørestiens faktiske kall av KILDEN. Returnerer ingenting ennå."""
return {}
def console_scripts(repo_root: Path) -> dict[str, str]:
"""STUB: skal lese [project.scripts] fra pyproject.toml."""
return {}
def registered_entry(
src: Path, repo_root: Path, package: str, entry: Mapping[str, Any]
) -> tuple[bool, str]:
"""STUB: godtar ALT som dør — nøyaktig feilen reparasjonen skal fjerne."""
return True, ""
def published_files(root: Path, manifest: Mapping[str, Any]) -> tuple[list[Path], str]:
"""STUB: skal utlede den publiserte flaten av repo-manifestet."""
return [], "stub"
def load_config(path: Path = _DATA) -> dict[str, Any]:
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return data
@ -285,7 +263,148 @@ def entry_reaches(src: Path, entry: Mapping[str, Any], symbol: str) -> tuple[boo
return True, ""
def measure_steps(steps: Sequence[Mapping[str, Any]], src: Path) -> list[Step]:
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 være registrert et sted utenfor modulen
selv i pakkemanifestet, i en ``__main__``-vakt, eller i en argparse-underkommando og
arten 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 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"]))
@ -299,7 +418,16 @@ def measure_steps(steps: Sequence[Mapping[str, Any]], src: Path) -> list[Step]:
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
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
@ -312,18 +440,28 @@ def score_toolbox(
outcomes: Mapping[str, str] | None = None,
repo_root: Path = _REPO_ROOT,
) -> Row:
measured = measure_steps(steps, src)
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]
undeclared = sorted(
name
for name in (run_path_calls(src, run_path) if run_path else {})
if name not in {str(s["symbol"]) for s in steps}
and name not in {str(h["symbol"]) for h in (run_path or {}).get("held_out", ())}
)
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 kjørestien uten å være et steg" for name in undeclared
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",
@ -331,8 +469,10 @@ def score_toolbox(
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",
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,),
)
@ -397,30 +537,55 @@ def score_roles(roles: Mapping[str, Any], outcomes: Mapping[str, str], src: Path
# 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*(```|~~~)")
#: 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 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 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]]:
@ -451,17 +616,25 @@ class Pattern:
hits: tuple[str, ...]
def measure_patterns(config: Mapping[str, Any], root: Path) -> list[Pattern]:
@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]]]] = []
surface = (
surface_files(root, list(config["roots"]))
if "roots" in config
else published_files(root, config.get("manifest", {}))[0]
)
for path in surface:
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] = []
@ -482,18 +655,14 @@ def measure_patterns(config: Mapping[str, Any], root: Path) -> list[Pattern]:
if rx.search(line)
)
measured.append(Pattern(str(spec["id"]), str(spec["label"]), not why, why, hits))
return measured
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 = measure_patterns(config, root)
measured, surface = 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"])))
if "roots" in config
else len(published_files(root, config.get("manifest", {}))[0])
)
exceptions: list[str] = []
for pattern in measured:
if not pattern.valid:
@ -501,14 +670,23 @@ def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
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,
GREEN if n and k == n else RED,
f"{k} av {n} vakter måler og gir null treff over {files} publiserte filer",
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,),
)
@ -520,18 +698,35 @@ def score_no_claude_path(config: Mapping[str, Any], root: Path) -> Row:
def score_no_model_calls(
config: Mapping[str, Any], outcomes: Mapping[str, str], src: Path | None = None
) -> 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)
"""Rad 4s nevner er sjekkene hvis NAVNGITTE kilde faktisk finnes i koden.
M = 3 var en konstant i gatens egen konfig. 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",
exceptions=tuple(f"{name}: {why}" for name, why in verdicts.items() if why),
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,),
)
@ -582,12 +777,18 @@ def foundry_intact(config: Mapping[str, Any], src: Path) -> tuple[bool, tuple[st
def score_foundry(config: Mapping[str, Any], outcomes: Mapping[str, str], src: Path) -> Row:
intact, missing = foundry_intact(config, src)
"""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 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 = int(intact) + sum(1 for why in verdicts.values() if not why)
n = 1 + len(verdicts)
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(
@ -596,7 +797,8 @@ def score_foundry(config: Mapping[str, Any], outcomes: Mapping[str, str], src: P
k,
n,
GREEN if k == n else RED,
"profil, fabrikk og injeksjonssøm står" if intact else "Foundry-veien er rørt",
f"{structural - len(missing)} av {structural} strukturkrav står"
+ ("" if not missing else " — Foundry-veien er rørt"),
exceptions=tuple(exceptions),
)
@ -613,7 +815,23 @@ class RunbookAttestation:
why: str
def read_runbook_attestation(path: Path, keys: Sequence[str]) -> RunbookAttestation:
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 stemme : 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 ha tidssone.
BOM tåles (``utf-8-sig``), som 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():
@ -621,7 +839,7 @@ def read_runbook_attestation(path: Path, keys: Sequence[str]) -> RunbookAttestat
if not path.is_file():
return RunbookAttestation(False, False, f"{path} finnes ikke")
try:
text = path.read_text(encoding="utf-8")
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] = {}
@ -638,6 +856,37 @@ def read_runbook_attestation(path: Path, keys: Sequence[str]) -> RunbookAttestat
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, "")
@ -645,13 +894,16 @@ def score_runbook(
config: Mapping[str, Any],
repo_root: Path,
attest: Path | None = None,
now: Any | 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"]))
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")
@ -661,9 +913,9 @@ def score_runbook(
"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",
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,),
)
@ -675,13 +927,12 @@ def score_runbook(
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]
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
@ -692,16 +943,17 @@ def evaluate(
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),
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),
score_no_model_calls(config["no_model_calls"], outcomes, src),
score_foundry(config["foundry"], outcomes, src),
score_runbook(config["runbook"], repo_root, attest),
score_runbook(config["runbook"], repo_root, attest, now),
]
@ -713,6 +965,11 @@ def render(rows: Sequence[Row]) -> str:
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: