portfolio-optimiser/src/portfolio_optimiser/evals/round_builder.py
Kjell Tore Guttormsen b8b83fb267
fix(round-builder): a link is refused with a reason, an identical quote is said once, a shared cost line is named on both sides [skip-docs]
The four arms that were red on assert, in the order the reader meets them.

A DANGLING SYMLINK as the round directory now refuses instead of tracebacking.
exists() FOLLOWS a link, so a dangling one answers False and slipped straight past
"a round is never overwritten"; the build then died on the filesystem's own
FileExistsError. is_symlink() is checked FIRST, the message says what a link would cost
(the round's content would sit somewhere the gate does not measure), and the link is
left exactly as it was found — nothing is written, exit code 1 like every other refusal.

ONE CITATION LIST SHARED BY EVERY PROPOSAL is now stated once. The cause was measured
before anything was written, because "the builder reads the wrong field" and "the outbox
says the same thing five times" want opposite fixes: in all four archived runs every
proposal carries a byte-identical 270-citation list — the run's whole retrieved context,
stamped once per proposal. No report can make that quote say something about the
individual measure. So when every proposal carries the same list, the report says so
once, says what the list actually is ("hva kjøringen leste, ikke hva det enkelte tiltaket
bygger på"), and drops the five copies. When the lists differ, nothing changes: the quote
and its COUNT stay under each proposal, which is where they mean something.

THE SAME COST LINE ON BOTH SIDES OF THE VERDICT is named where it happens. The 19.09
report refused TUN-LYS-01 under one label and validated the same line under another and
said nothing, so a reader met two figures for one budget line with no way to see they
collided. Both sides now carry the sentence, in the run's own row order.

A REMOVED APPROACH is shown by the label the expert saw, with the id in parentheses. It
is the one row whose human name is not in this run's coverage, so the label is read from
the coverage inside the PREVIOUS round's own outbox — derived from what the round already
carries, not a new column in outcome.json. An unreadable coverage falls back on the bare
id; a missing label is not a reason to refuse a round.

39 of 39 arms green. Nothing here touches what the gate reads: outcome.json keeps its
four columns and the builder still never writes the attestation.

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

754 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""From one run's outbox to a round directory the v1 gate can read.
One command, deterministic, offline: no model call, no network. It takes a finished run's outbox
and a round number and writes ``<rounds-dir>/<n>/`` in the shape ``v1_gate --help`` states —
``outbox/`` copied from the run, ``outcome.json`` DERIVED from that copy, and ``report.md``, the
one artefact in the round a domain expert is meant to read and correct.
Measured 19.09: nothing bound a run to a round directory (every mention of ``v1-rounds`` in the
repository was in the gate, its tests, ``.gitignore``, the ledger and a deck), and nothing under
``src/`` wrote markdown at all. Round 0 therefore counted 0 of 3 because it could not be MADE,
which is a different failure from a round nobody had held.
What it verifies, it verifies with the gate's own functions rather than with a second copy:
``verify_run`` for whether the run stands up to itself, ``stage_of`` for column (c),
``parse_time`` for the run's stamp, ``safe_rounds_dir`` for where a round may be written. A
builder that judged its output by its own rules could write rounds the reader refuses.
Two things it never does, and both are the point. It never writes the operator's attestation
file: that file is the statement that a round was actually held, and a builder that could produce
it would put the gate's one un-computable step back inside the machine. And it never invents —
``ran_at`` is an argument because no outbox artefact carries a clock (they are byte-deterministic
by contract), and ``feedback_ids`` stays empty because no run records which feedback item produced
which row. An empty list is the honest reading of a run that tracked nothing.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from portfolio_optimiser.evals.v1_gate import (
DEFAULT_ROUNDS_DIR,
RUN_OUTBOX,
parse_time,
row_changed,
safe_rounds_dir,
stage_of,
verify_run,
)
from portfolio_optimiser.ledger import to_ore
_REPO_ROOT = Path(__file__).resolve().parents[3]
_COVERAGE_SUFFIX = "-coverage.json"
#: The last round the gate reads (``ROUNDS_CONTRACT``): 0 is the baseline, 1-3 carry feedback.
_LAST_ROUND = 3
#: A citation is shown to place the claim, not to reproduce the source. Longer than this and the
#: report stops being something a person reads in one pass.
_SNIPPET_MAX = 220
class RoundBuildError(Exception):
"""A round that cannot be built from what is on disk, with the reason a reader can act on."""
@dataclass(frozen=True)
class Run:
"""One finished run, as its outbox presents it — already checked against itself."""
outbox: Path
run_id: str
rows: tuple[dict[str, Any], ...]
stop_reason: str
artefacts: tuple[str, ...]
ignored: tuple[str, ...]
proposals: dict[str, dict[str, Any]]
@dataclass(frozen=True)
class Built:
"""What one build produced, in numbers the caller can check against the source directory."""
round_dir: Path
run_id: str
copied: tuple[str, ...]
ignored: tuple[str, ...]
evaluated: tuple[str, ...]
not_evaluated: tuple[str, ...]
validated_ore: int
#: Every rejection stage ``validator.rejection_stage`` can name, in the words a domain expert
#: reads — plus the two labels a coverage row carries that are not rejections. A stage added to
#: the validator without a sentence here reaches the expert as a bare identifier, so the test
#: suite pins this table against the validator's own list rather than against itself.
#:
#: Every sentence is "<kort navn>: <forklaring>", because both halves are needed in different
#: places — the full sentence heads a refusal, the short name fits inside a one-line diff of what
#: changed since the previous round. A raw ``stage4-p90`` in either is an internal identifier
#: reaching a reader who has no way to look it up.
STAGE_PROSE: dict[str, str] = {
"stage0-baseline": (
"prosjektets egen kostnadsbasis: en kostnadslinje tiltaket bygger på stemmer ikke med "
"det prosjektet faktisk har budsjettert"
),
"stage0b-grounding": (
"forankringen i kunnskapsbasen: en kode tiltaket bygger på står ikke i teksten som ble "
"levert til kjøringen"
),
"stage4-p90": (
"usikkerhetsberegningen: den påståtte besparelsen er større enn det de gunstigste ti "
"prosentene av utfallene gir"
),
"stage4b-nominal": (
"det nominelt mulige: den påståtte besparelsen er større enn tiltaket kan gi selv i "
"beste fall"
),
"stage5-method-cap": (
"metodetaket: metoden gir erfaringsmessig ikke en så stor andel av kostnaden tilbake"
),
"unsupported": (
"manglende krav: tallene holdt, men ingen krav i kunnskapsbasen ble erklært å binde "
"tiltaket, så retningen står uten hjemmel"
),
"other": ("en kontroll rapporten ikke kjenner navnet på: begrunnelsen står ordrett under"),
"not_evaluated": ("ingenting: kjøringen stoppet før tilnærmingen ble vurdert i det hele tatt"),
}
ROUND_BUILD_CONTRACT = """\
Skriver EN runde i formen v1-gaten leser (se `v1_gate --help` for hele kontrakten):
<rounds-dir>/<n>/outbox/ kjøringens egne artefakter, KOPIERT hit (aldri lenket)
<rounds-dir>/<n>/outcome.json utledet av coverage + artefaktene, aldri håndskrevet
<rounds-dir>/<n>/report.md rapporten fagpersonen leser og retter
<rounds-dir>/<n>/feedback.json tilbakemeldingen runden svarer på (n = 1, 2, 3)
To filer skriver denne kommandoen ALDRI, og begge er med vilje:
<rounds-dir>/<n>/attestering.txt operatørens bekreftelse på at runden faktisk ble holdt.
Uten den stopper gaten på FORM OK, IKKE BEVIST — og det er riktig: ingen filer kan vise
at en kjøring skjedde. Formen står i `v1_gate --help`.
<rounds-dir>/3/report.kept.md runde 3-rapporten slik fagpersonen beholdt den.
--ran-at er påkrevd fordi ingen artefakt i utboksen bærer en klokke: filene er
byte-deterministiske med vilje. Tidspunktet er operatørens opplysning, ikke en måling.
feedback_ids står tomt på hver rad. Ingen kjøring sporer i dag hvilken tilbakemelding som
førte til hvilken rad, og binderen finner ikke på en sporing som ikke finnes.
"""
# ---------------------------------------------------------------------------------------------
# Reading the run
# ---------------------------------------------------------------------------------------------
def _read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise RoundBuildError(f"{path} mangler") from exc
except (ValueError, OSError) as exc:
raise RoundBuildError(f"{path} kan ikke leses ({exc!r})") from exc
def read_run(outbox_dir: Path) -> Run:
"""The run an outbox directory holds, or ``RoundBuildError`` saying why it holds none.
The run is checked against ITSELF here, before anything is written, with the gate's own
``verify_run``: an artefact that contradicts its coverage row, a family with a missing half,
and a stray artefact naming an approach nobody commissioned are all refused at the source. A
builder that copied first and let the reader find those would hand the operator a round that
is red for something this function already knew.
"""
if not outbox_dir.is_dir():
raise RoundBuildError(f"{outbox_dir} er ingen katalog")
covers = sorted(p.name for p in outbox_dir.iterdir() if p.name.endswith(_COVERAGE_SUFFIX))
if not covers:
raise RoundBuildError(
f"{outbox_dir} har ingen <kjøring>-coverage.json. Den fila skrives bare når "
"kjøringen hadde BÅDE --mandate og --outbox-dir, så denne utboksen kan ikke si "
"hvilke tilnærminger som ble bestilt eller hvorfor hver av dem endte som den gjorde. "
"En runde kan ikke bygges av en kjøring uten mandat."
)
if len(covers) > 1:
raise RoundBuildError(
f"{outbox_dir} holder {len(covers)} kjøringer ({', '.join(covers)}) — en runde "
"presenterer ÉN kjøring, og hvilken kan ikke avgjøres av sorteringsrekkefølge"
)
run_id = covers[0][: -len(_COVERAGE_SUFFIX)]
payload = _read_json(outbox_dir / covers[0])
try:
rows = [dict(row) for row in payload["rows"]]
stamped = str(payload["run_id"])
stop_reason = str(payload.get("stop_reason", ""))
ids = [str(row["id"]) for row in rows]
except (KeyError, TypeError) as exc:
raise RoundBuildError(f"{covers[0]} mangler et felt ({exc!r})") from exc
if stamped != run_id:
raise RoundBuildError(f"{covers[0]} er stemplet kjøring {stamped!r}, ikke {run_id!r}")
if not rows:
raise RoundBuildError(f"{covers[0]}: kjøringen evaluerte ingen tilnærming")
if len(set(ids)) != len(ids):
raise RoundBuildError(f"{covers[0]}: samme tilnærming står flere ganger")
unheld = verify_run(outbox_dir, run_id, rows)
if unheld:
raise RoundBuildError(f"kjøringen {run_id!r} står ikke inne for seg selv — {unheld}")
artefacts, ignored = [], []
for path in sorted(outbox_dir.iterdir()):
if path.is_file() and path.name.startswith(f"{run_id}-") and path.suffix == ".json":
artefacts.append(path.name)
else:
ignored.append(path.name)
proposals = {
aid: _read_json(outbox_dir / f"{run_id}-{aid}-proposal.json")
for aid, row in zip(ids, rows, strict=True)
if row["status"] != "not_evaluated"
}
return Run(
outbox=outbox_dir,
run_id=run_id,
rows=tuple(rows),
stop_reason=stop_reason,
artefacts=tuple(artefacts),
ignored=tuple(ignored),
proposals=proposals,
)
# ---------------------------------------------------------------------------------------------
# The outcome file
# ---------------------------------------------------------------------------------------------
def derive_outcome(
run: Run, *, ran_at: str, previous: Mapping[str, Any] | None = None
) -> dict[str, Any]:
"""``outcome.json`` computed from the run's own coverage and per-approach artefacts.
Every column is derived: (b) and (d) from the coverage row the artefacts were just checked
against, (c) from the gate's ``stage_of``. ``removed`` is the set difference against the
PREVIOUS round's outcome — derivation, not declaration. The one thing no file knows is when
the run happened, and that is why ``ran_at`` is an argument.
"""
if parse_time(ran_at) is None:
raise RoundBuildError(
f"--ran-at {ran_at!r} er ikke et ISO-8601-tidspunkt med tidssone "
"(f.eks. 2026-09-18T11:20:00+02:00). Gaten leser dette som kjøringens tidspunkt."
)
approaches = [
{
"id": str(row["id"]),
"validated": row["status"] == "validated",
"stage": stage_of(str(row["status"]), str(row.get("detail", ""))),
"validated_nok": row.get("saving_nok") if row["status"] == "validated" else None,
"feedback_ids": [],
}
for row in run.rows
]
here = {row["id"] for row in approaches}
gone = (
[
{"id": str(row["id"]), "feedback_ids": []}
for row in previous.get("approaches", ())
if str(row["id"]) not in here
]
if previous is not None
else []
)
return {"run_id": run.run_id, "ran_at": ran_at, "approaches": approaches, "removed": gone}
def validated_ore(outcome: Mapping[str, Any]) -> int:
"""The round's validated saving in øre — quantized PER AMOUNT and summed as integers (kø-(p)).
``ledger.to_ore``'s rule, not a second one: three 60000.005 NOK lines are 18000003 øre this
way and 18000001 if the floats are summed first, and each row is a real amount. Only
``validated`` rows count; an ``unsupported`` row's numbers held but its direction has no
hjemmel, and a rejected row's figure is a claim the validator refused.
"""
return sum(
to_ore(float(row["validated_nok"]))
for row in outcome["approaches"]
if row["validated"] and row["validated_nok"] is not None
)
# ---------------------------------------------------------------------------------------------
# The report — the one artefact a person reads
# ---------------------------------------------------------------------------------------------
def _kroner(ore: int) -> str:
sign = "-" if ore < 0 else ""
whole, rest = divmod(abs(ore), 100)
return f"{sign}{whole:,}".replace(",", " ") + f",{rest:02d}"
def _antall(value: float) -> str:
text = f"{value:,.2f}".replace(",", " ").replace(".", ",")
return text[:-3] if text.endswith(",00") else text
def _stage_short(stage: str) -> str:
"""The stage's short name: the clause before the colon of its sentence, never the id."""
return STAGE_PROSE[stage].split(":", 1)[0]
def _status_word(row: Mapping[str, Any]) -> str:
return {
"validated": "validert",
"rejected": "avvist",
"unsupported": "validert, men uten erklært krav",
"not_evaluated": "ikke vurdert",
}.get(str(row["status"]), str(row["status"]))
def _citations_of(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
return [dict(c) for c in payload.get("provenance", {}).get("citations", ())]
def _citation_lines(citations: Sequence[Mapping[str, Any]], *, prefix: str) -> list[str]:
"""One quote with the COUNT of places it stands for, or nothing when there is no quote.
The count is part of the citation: a run that cited 446 places and one that cited a single
place both show one quote here, and a reader who cannot tell them apart cannot tell a
grounded proposal from a decorated one."""
if not citations:
return []
snippet = " ".join(str(citations[0].get("snippet", "")).split())
if len(snippet) > _SNIPPET_MAX:
snippet = snippet[:_SNIPPET_MAX].rstrip() + ""
if not snippet:
return []
return [
"",
f"{prefix} (1 av {len(citations)} siterte steder): «{snippet}» — "
f"{citations[0].get('file', 'ukjent fil')}",
]
def _one_list_for_every_proposal(run: Run) -> list[dict[str, Any]] | None:
"""The citation list EVERY proposal in the run carries, when they all carry the same one.
Measured 19.09 on the four archived runs: all five proposals of each carried byte-identical
270-citation lists — the run's whole retrieved context, stamped once per proposal. The cause
is in the outbox, not in this module reading a wrong field, so no report can make that quote
say something about the individual measure. What a report can do is stop repeating it five
times and say once what it actually is."""
lists = [_citations_of(run.proposals[aid]) for aid in run.proposals]
if len(lists) < 2 or not lists[0]:
return None
keyed = {json.dumps(one, ensure_ascii=False, sort_keys=True) for one in lists}
return lists[0] if len(keyed) == 1 else None
def _cost_line_notes(run: Run) -> dict[str, list[str]]:
"""For every cost line a HELD and a FELL approach both touch, the sentence that says so.
Measured on the 19.09 report: ``TUN-LYS-01`` was refused under one label and validated under
another, and the report said nothing — so the reader met two figures for one budget line
with no way to see that they were the same line. Said on BOTH sides, in the run's own row
order, or the reader has to find it by comparing sections."""
status_of = {str(row["id"]): str(row["status"]) for row in run.rows}
label_of = {str(row["id"]): str(row["label"]) for row in run.rows}
touching: dict[str, list[str]] = {}
for row in run.rows:
aid = str(row["id"])
payload = run.proposals.get(aid)
if payload is None:
continue
for item in dict(payload.get("proposal", {})).get("affected_items", ()):
touching.setdefault(str(item["code"]), []).append(aid)
notes: dict[str, list[str]] = {}
for code, involved in touching.items():
held = [a for a in involved if status_of[a] == "validated"]
fell = [a for a in involved if status_of[a] in ("rejected", "unsupported")]
if not held or not fell:
continue
for aid in involved:
other_side = fell if status_of[aid] == "validated" else held
others = [o for o in other_side if o != aid]
if not others:
continue
what = "ble avvist" if status_of[aid] == "validated" else "holdt kontrollen"
notes.setdefault(aid, []).append(
f"Merk: kostnadslinjen {code} står også i "
f"{', '.join('«' + label_of[o] + '»' for o in others)}, som {what}. Samme "
"kostnadslinje står på begge sider av dommen, og beløpene kan ikke legges "
"sammen før du har avgjort hvilket tiltak som gjelder."
)
return notes
def _lines_about(
run: Run, aid: str, *, shared_source: bool, notes: Sequence[str] = ()
) -> list[str]:
"""The proposal's own words: what it proposed, which cost lines it touched, and one source.
``shared_source`` drops the quote here because the run gave every proposal the SAME one and
the report states it once instead (``_one_list_for_every_proposal``)."""
payload = run.proposals.get(aid)
if payload is None:
return []
ir = dict(payload.get("proposal", {}))
out: list[str] = []
if ir.get("measure"):
out += ["", str(ir["measure"])]
items = [
f"{item['code']} ({_antall(float(item['quantity']))} × "
f"{_kroner(to_ore(float(item['unit_cost'])))} kroner)"
for item in ir.get("affected_items", ())
]
if items:
out += ["", "Berørte kostnadslinjer: " + "; ".join(items) + "."]
for note in notes:
out += ["", note]
if not shared_source:
out += _citation_lines(_citations_of(payload), prefix="Kilde")
return out
def _change_prose(before: Mapping[str, Any], after: Mapping[str, Any]) -> str:
def side(row: Mapping[str, Any]) -> str:
if row["validated"]:
return f"validert til {_kroner(to_ore(float(row['validated_nok'])))} kroner"
stage = str(row.get("stage") or "")
return "ikke vurdert" if stage == "not_evaluated" else f"avvist ({_stage_short(stage)})"
return f"{side(before)}{side(after)}"
def build_report(
run: Run,
n: int,
outcome: Mapping[str, Any],
*,
previous: Mapping[str, Any] | None = None,
previous_labels: Mapping[str, str] | None = None,
) -> str:
"""``report.md`` — what a domain expert reads and corrects, in Norwegian prose.
Deterministic for a given (outbox, round, previous): the sections are a fixed sequence and
every list follows the coverage's OWN order. Row 4 of the gate counts content lines in order,
so a report that reshuffled between builds would read as an expert's edit.
"""
rows = {str(row["id"]): row for row in outcome["approaches"]}
shared = _one_list_for_every_proposal(run)
notes = _cost_line_notes(run)
fell = [row for row in run.rows if str(row["status"]) in ("rejected", "unsupported")]
held = [row for row in run.rows if str(row["status"]) == "validated"]
missed = [row for row in run.rows if str(row["status"]) == "not_evaluated"]
out: list[str] = [f"# Rapport fra runde {n} — kjøring {run.run_id}", ""]
out += [
"Denne rapporten er bygget maskinelt fra kjøringens egen utboks; ingen modell har",
"skrevet den. Den sier hvilke kostnadstiltak systemet foreslo, hvilke som holdt den",
"deterministiske kontrollen, og hvorfor de øvrige falt. Rett den fritt — det du beholder",
"og det du skriver om, er selve målingen.",
"",
]
out += ["## Hva ble vurdert", ""]
stopped = (
f" Kjøringen stoppet før den var ferdig ({run.stop_reason})." if run.stop_reason else ""
)
out += [
f"Kommisjonen ba om {len(run.rows)} tilnærminger, og kjøringen rakk "
f"{len(run.rows) - len(missed)} av dem.{stopped}",
"",
]
out += [f"- **{row['label']}** — {_status_word(row)}" for row in run.rows]
out += [""]
if shared is not None:
out += [
f"Alle {len(run.proposals)} forslagene i denne kjøringen viser til NØYAKTIG samme",
"kildeliste, i samme rekkefølge. Listen er hva kjøringen leste, ikke hva det enkelte",
"tiltaket bygger på, så den står her én gang i stedet for under hvert forslag.",
]
out += _citation_lines(shared, prefix="Felles kilde")
out += [""]
out += ["## Hva holdt, og hva det er verdt", ""]
if held:
out += [
f"{len(held)} av {len(run.rows)} tilnærminger holdt kontrollen. Samlet validert "
f"besparelse: {_kroner(validated_ore(outcome))} kroner.",
"",
]
for row in held:
aid = str(row["id"])
amount = _kroner(to_ore(float(rows[aid]["validated_nok"])))
out += [f"### {row['label']}{amount} kroner"]
out += _lines_about(
run, aid, shared_source=shared is not None, notes=notes.get(aid, ())
)
out += [""]
else:
out += ["Ingen tilnærming holdt kontrollen i denne kjøringen.", ""]
out += ["## Hva falt, og hvorfor", ""]
if fell:
out += [f"{len(fell)} tilnærminger ble ikke godtatt.", ""]
for row in fell:
aid = str(row["id"])
out += [f"### {row['label']} — falt på {STAGE_PROSE[str(rows[aid]['stage'])]}"]
out += _lines_about(
run, aid, shared_source=shared is not None, notes=notes.get(aid, ())
)
out += ["", f"Kontrollens egen begrunnelse, ordrett: «{row.get('detail', '')}»", ""]
else:
out += ["Ingen tilnærming ble avvist i denne kjøringen.", ""]
out += ["## Hva kjøringen aldri rakk", ""]
if missed:
out += [
f"{len(missed)} tilnærminger ble aldri vurdert. De er verken godtatt eller avvist.",
"",
]
out += [
f"- **{row['label']}** — {row.get('detail') or 'ingen grunn oppgitt'}" for row in missed
]
out += [""]
else:
out += ["Kjøringen rakk alle tilnærmingene kommisjonen ba om.", ""]
if n >= 1:
out += ["## Endret siden forrige runde", ""]
out += _changed_section(run, n, outcome, previous or {}, previous_labels or {})
out += ["## Slik leser du tallene", ""]
out += [
"- «Validert» betyr at forslagets egne tall holdt en deterministisk kontroll mot",
" prosjektets kostnadsbasis og en usikkerhetsberegning. Det er ikke en beslutning om å",
" gjennomføre tiltaket, og ingen har vurdert om tiltaket er faglig forsvarlig.",
"- «Validert, men uten erklært krav» betyr at tallene holdt, men at ingen krav i",
" kunnskapsbasen ble erklært å binde tiltaket. Beløpet telles ikke med i summen.",
"- Beløpene er kvantisert til hele øre per beløp før de summeres, aldri etter.",
"- Om kjøringen faktisk ble gjort, og når, står i ingen fil her. Det bekrefter du selv.",
"",
]
return "\n".join(out).rstrip("\n") + "\n"
def _changed_section(
run: Run,
n: int,
outcome: Mapping[str, Any],
previous: Mapping[str, Any],
previous_labels: Mapping[str, str],
) -> list[str]:
"""Every row that moved against round n-1, and for each one whether a feedback id explains it.
Model noise between two runs looks exactly like an answered objection, so a row no feedback id
accounts for has to SAY so. Today that is every row: no run records the tracking.
"""
before = {str(row["id"]): row for row in previous.get("approaches", ())}
label_of = {str(row["id"]): str(row["label"]) for row in run.rows}
def why(row: Mapping[str, Any]) -> str:
ids = list(row.get("feedback_ids", ()))
return f"Utløst av {', '.join(ids)}" if ids else "Ingen tilbakemelding forklarer dette"
out = [f"Sammenlignet med runde {n - 1}:", ""]
moved = 0
for row in outcome["approaches"]:
aid = str(row["id"])
was = before.get(aid)
name = label_of.get(aid, aid)
if was is None:
moved += 1
out.append(f"- **{name}** — ny i denne runden. {why(row)}.")
elif row_changed(was, row):
moved += 1
out.append(f"- **{name}** — {_change_prose(was, row)}. {why(row)}.")
for row in outcome["removed"]:
moved += 1
# The one row whose human name is NOT in this run's coverage — it is only in the round
# it disappeared from. A bare id here would hand the expert an identifier they have no
# way to look up, in the section that exists for them to judge what moved.
gone = str(row["id"])
known = previous_labels.get(gone)
shown = f"**{known}** ({gone})" if known else f"**{gone}**"
out.append(
f"- {shown} — tilnærmingen er borte: den står ikke i denne kjøringens "
f"liste i det hele tatt. {why(row)}."
)
if not moved:
out.append("- Ingenting endret seg i (a)-(d) over støygrensen.")
out.append("")
return out
# ---------------------------------------------------------------------------------------------
# Building the round
# ---------------------------------------------------------------------------------------------
def _labels_of(round_dir: Path, outcome: Mapping[str, Any]) -> dict[str, str]:
"""The labels a round SHOWED, read from the coverage file inside that round's own outbox.
Derived from what the round already carries rather than declared: ``outcome.json`` keeps its
four columns, and an approach that is gone from THIS run's coverage is still named in the
round it vanished from. An unreadable or absent coverage leaves the mapping empty, and the
caller falls back on the bare id — a missing label is not a reason to refuse a round."""
name = f"{outcome.get('run_id', '')}{_COVERAGE_SUFFIX}"
try:
rows = json.loads((round_dir / RUN_OUTBOX / name).read_text(encoding="utf-8"))["rows"]
return {str(row["id"]): str(row["label"]) for row in rows}
except (OSError, ValueError, KeyError, TypeError):
return {}
def _dump(payload: Mapping[str, Any]) -> str:
"""Byte-deterministic on-disk form, mirroring ``outbox._dump`` with Norwegian text kept as is."""
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
def build_round(
outbox_dir: Path,
rounds_dir: Path,
n: int,
*,
ran_at: str,
feedback: Path | None = None,
) -> Built:
"""Write ``<rounds-dir>/<n>/`` from ``outbox_dir``, or refuse without touching the tree.
Every check that can fail runs BEFORE the first byte is written, so a refusal never leaves a
half-built round for the next command to read as a whole one.
"""
if not 0 <= n <= _LAST_ROUND:
raise RoundBuildError(f"runde {n} finnes ikke i kontrakten — gaten leser 0 til 3")
if not safe_rounds_dir(rounds_dir):
raise RoundBuildError(
f"{rounds_dir} ligger i repoet uten å være ignorert av gitignore — fagpersonens "
"tilbakemelding kunne da bli committet til den offentlige remoten"
)
round_dir = rounds_dir / str(n)
# BEFORE ``exists()``, because ``exists()`` FOLLOWS the link: a DANGLING link answers False
# and slipped past the guard below, after which the build died on the filesystem's own
# FileExistsError (measured 19.09). A refusal has to be a refusal in the form the operator
# sees, and the link is left exactly as it was found.
if round_dir.is_symlink():
raise RoundBuildError(
f"{round_dir} er en lenke ({round_dir.readlink()}), ikke en katalog. En runde er skrevet "
"i rundekatalogen selv — en lenke ville latt rundens innhold ligge et sted gaten "
"ikke måler, og en hengende lenke ville dessuten sluppet forbi «overskrives aldri». "
"Fjern lenken selv om runden skal bygges her."
)
if round_dir.exists():
raise RoundBuildError(
f"{round_dir} finnes allerede. En runde holder fagpersonens egen tilbakemelding og "
"rettinger; den overskrives aldri. Flytt eller slett den selv om den skal bygges om."
)
if n == 0 and feedback is not None:
raise RoundBuildError(
"runde 0 er grunnkjøringen og svarer på ingenting — det finnes ingen rapport noen "
"kan ha kommentert ennå, så den tar ingen feedback.json"
)
previous: dict[str, Any] | None = None
previous_labels: dict[str, str] = {}
if n >= 1:
if feedback is None:
raise RoundBuildError(
f"runde {n} er tilbakemelding på rapport {n - 1} og så en kjøring: den trenger "
"fagpersonens feedback.json (--feedback)"
)
if not feedback.is_file():
raise RoundBuildError(f"--feedback {feedback} finnes ikke")
earlier = rounds_dir / str(n - 1) / "outcome.json"
if not earlier.is_file():
raise RoundBuildError(
f"runde {n} måles mot runde {n - 1}, og {earlier} finnes ikke — bygg den runden "
"først"
)
previous = _read_json(earlier)
previous_labels = _labels_of(rounds_dir / str(n - 1), previous)
run = read_run(outbox_dir)
outcome = derive_outcome(run, ran_at=ran_at, previous=previous)
report = build_report(run, n, outcome, previous=previous, previous_labels=previous_labels)
outbox = round_dir / RUN_OUTBOX
outbox.mkdir(parents=True)
for name in run.artefacts:
shutil.copyfile(run.outbox / name, outbox / name)
(round_dir / "outcome.json").write_text(_dump(outcome), encoding="utf-8")
(round_dir / "report.md").write_text(report, encoding="utf-8")
if feedback is not None:
shutil.copyfile(feedback, round_dir / "feedback.json")
return Built(
round_dir=round_dir,
run_id=run.run_id,
copied=run.artefacts,
ignored=run.ignored,
evaluated=tuple(str(r["id"]) for r in run.rows if str(r["status"]) != "not_evaluated"),
not_evaluated=tuple(str(r["id"]) for r in run.rows if str(r["status"]) == "not_evaluated"),
validated_ore=validated_ore(outcome),
)
def main(argv: Sequence[str] | None = None) -> int:
"""Command-line front door; 0 on a built round, 1 on a refusal, 2 on wrong usage."""
parser = argparse.ArgumentParser(
prog="python -m portfolio_optimiser.evals.round_builder",
description="Bygg én rundekatalog av en ferdig kjørings utboks, i formen v1-gaten leser. "
"Deterministisk og offline: ingen modellkall, intet nett, ingen klokke.",
epilog=ROUND_BUILD_CONTRACT,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--outbox", required=True, help="kjøringens utboks-katalog")
parser.add_argument("--round", type=int, required=True, help="rundenummeret (0-3)")
parser.add_argument(
"--rounds-dir",
default=None,
help=f"rundekatalogen (default {DEFAULT_ROUNDS_DIR}/, gitignored)",
)
parser.add_argument(
"--ran-at",
required=True,
help="da kjøringen ble gjort, ISO-8601 MED tidssone. Påkrevd fordi ingen artefakt i "
"utboksen bærer en klokke — dette er din opplysning, ikke en måling",
)
parser.add_argument(
"--feedback", default=None, help="fagpersonens feedback.json (påkrevd for runde 1-3)"
)
args = parser.parse_args(argv)
rounds_dir = Path(args.rounds_dir) if args.rounds_dir else _REPO_ROOT / DEFAULT_ROUNDS_DIR
try:
built = build_round(
Path(args.outbox),
rounds_dir,
args.round,
ran_at=args.ran_at,
feedback=Path(args.feedback) if args.feedback else None,
)
except RoundBuildError as refused:
print(f"runden ble ikke bygget: {refused}", file=sys.stderr)
return 1
print(f"{built.round_dir} bygget av kjøring {built.run_id}")
print(
f" {len(built.copied)} artefakt(er) kopiert, {len(built.ignored)} fil(er) utenfor "
f"kjøringen ble stående igjen: {', '.join(built.ignored) or 'ingen'}"
)
print(
f" {len(built.evaluated)} tilnærming(er) vurdert, {len(built.not_evaluated)} ikke; "
f"validert besparelse {_kroner(built.validated_ore)} kroner"
)
print(" attesteringen skriver denne kommandoen aldri — gaten stopper på FORM OK uten den")
return 0
if __name__ == "__main__": # pragma: no cover - exercised by a subprocess test
raise SystemExit(main())