fix(v1-gate): rows 1-2 stop at FORM OK — what files cannot show, a person has to say
The PM checkpoint of 18.09 paid M-6's price three times and row 2 still read 3 of 3 GREEN:
a whole artefact family written by hand and made internally consistent (verdict_id minted
with the product's own rule, about sixty lines of script), four REAL runs' artefacts under
a handwritten feedback file, and `<n>/outbox` symlinked out of the round to a real run
elsewhere. `RUN_ATTESTATION` stood in the output the whole time, and the row was green
anyway — which is the finding: a row that PRINTS its limit and reports GREEN has not
stated a limit, it has decorated one.
One of the three is a hole and is closed; two are not holes at all.
1. The outbox must lie in the round ITSELF (`outbox_escape`). Derived is only derived if
the filesystem cannot redirect it: `<n>/outbox` as a symlink, or any symlinked artefact
inside it, is refused by name.
2. Rows 1 and 2 can no longer be green from files. What the computation gives is
`FORM_OK` ("FORM OK, IKKE BEVIST"): every check the gate knows how to make passed, the
round counts 0 against the criterion, and the exit code still fails — it is un-green in
every way RED is. What separates the two is what the reader must do next, not what the
gate concludes.
3. Green is `<n>/attestering.txt`, per round: the operator's own statement, naming the
round, the run and the date. Plain text with three keyed lines, deliberately not JSON —
it is the one artefact in this contract a PERSON types, and a missing brace must never
be able to read as "not confirmed". Nothing in the product writes one, and a test in
`src/` says so: a product that can produce a witness to its own run has produced
exactly the thing these rows cannot.
A MISSING attestation is FORM OK: nobody has confirmed anything yet, and the gate is
not entitled to call that a lie. A PRESENT one that names another round or run, carries
no date, an unparsable date, or a date before the run, is RED: it is a statement about
something this is not. Row 2 needs round 0 attested as well, since round 1 is measured
against it — an unattested baseline is an unattested comparison.
The attestation is read raw off `outcome.json` (`_declared_run`), not through
`read_outcome`, so row 1 — which reads no run — does not inherit row 2's artefact checks
through the back door.
Nothing moved on the criterion: 0/3 · 0/3 · 3/8 · no report · 3/8 · IKKE MÅLT · 1/20,
exit 1. What changed is that rows 1-2 now name what they are waiting for, and it is not
another file.
Suite after `git add` on a clean tree: `uv run pytest -q` = 2014 passed, 5 skipped,
5 xfailed (508 s). The attack of b769537/ab00016 is the red half of this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ab00016ed5
commit
1b48124a01
2 changed files with 204 additions and 21 deletions
|
|
@ -27,7 +27,7 @@ import tempfile
|
|||
import xml.etree.ElementTree as ET
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
import difflib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
|
@ -58,8 +58,26 @@ RUN_ATTESTATION = (
|
|||
"operatøren"
|
||||
)
|
||||
|
||||
#: Rows 1-2: the file the gate can neither derive nor write, and the one thing that turns their
|
||||
#: computation into GREEN. Plain text with three keyed lines, deliberately not JSON: it is the one
|
||||
#: artefact in the contract a PERSON types by hand, and a missing brace must never be able to read
|
||||
#: as "not confirmed".
|
||||
ATTEST_FILE = "attestering.txt"
|
||||
#: What an attestation has to name: which round, which run, and when it was given.
|
||||
ATTEST_KEYS = ("runde", "kjøring", "dato")
|
||||
#: Printed on rows 1-2 on every run: what GREEN on those rows means, and whose statement it is.
|
||||
ATTEST_RULE = (
|
||||
"grønt på rad 1-2 regner gaten ikke ut: filene kan stemme innbyrdes og likevel beskrive en "
|
||||
"runde ingen har holdt. Målingen gir FORM OK; skrittet derfra til grønt er operatørens egen "
|
||||
f"attestering i <runde>/{ATTEST_FILE} (runde, kjøring, dato), som gaten ALDRI skriver selv"
|
||||
)
|
||||
|
||||
GREEN = "GRØNN"
|
||||
RED = "RØD"
|
||||
#: Rows 1-2 only: every computed check passed, and the round is still waiting for the operator's
|
||||
#: attestation. Never green, so it fails the exit code exactly as RED does — what separates it
|
||||
#: from RED is what the reader must do next, not what the gate concludes.
|
||||
FORM_OK = "FORM OK, IKKE BEVIST"
|
||||
DIAGNOSIS = "DIAGNOSE"
|
||||
#: A row whose evidence could not be read. Never green: on a failing row it fails the exit code.
|
||||
NOT_MEASURED = "IKKE MÅLT"
|
||||
|
|
@ -75,6 +93,7 @@ Rundekatalogen (--rounds-dir) har fast form. Runde n = tilbakemelding på rappor
|
|||
<rounds-dir>/<n>/outbox/ kjøring n sin EGEN utboks, kopiert hit urørt
|
||||
<rounds-dir>/<n>/report.md rapporten bygget fra kjøring n
|
||||
<rounds-dir>/3/report.kept.md runde 3-rapporten slik fagpersonen BEHOLDT den
|
||||
<rounds-dir>/<n>/attestering.txt operatørens bekreftelse på runden (n = 0, 1, 2, 3)
|
||||
|
||||
feedback.json:
|
||||
{"author": "<fagpersonen>", "given_at": "<ISO-8601 med tidssone>",
|
||||
|
|
@ -101,6 +120,17 @@ kjøring må stå inne for seg selv: hver EVALUERT tilnærming har <run_id>-<id>
|
|||
hvis verdict_id gaten selv minter PÅ NYTT fra forslagets egen IR (verdicts.verdict_key). En
|
||||
not_evaluated-rad har ingen av delene, og en artefakt for en tilnærming coverage ikke nevner hører
|
||||
til en annen kjøring. Dette beviser ikke at kjøringen skjedde — se attesteringen for rad 2.
|
||||
attestering.txt (ren tekst, skrevet for hånd av operatøren — gaten skriver den ALDRI):
|
||||
runde: <n>
|
||||
kjøring: <run_id slik rundens outcome.json oppgir den>
|
||||
dato: <ISO-dato, ikke før kjøringen ble gjort>
|
||||
<fritekst under, om ønskelig>
|
||||
|
||||
Rad 1 og 2 kan ALDRI bli grønne av filer alene. Uten attesteringen er taket FORM OK, IKKE BEVIST:
|
||||
runden teller 0 mot ferdig-kriteriet og gaten feller fortsatt. En attestering som FINNES, men
|
||||
navngir en annen runde eller kjøring, mangler dato eller er datert før kjøringen, er RØD - den er
|
||||
et utsagn om noe annet. Rad 2 krever i tillegg at grunnkjøringen (runde 0) er attestert: det er
|
||||
den runde 1 måles mot. Utboksen må dessuten ligge i runden SELV - en lenke ut av runden avvises.
|
||||
Rad 4 teller innholdslinjer (ikke blanke, skillelinjer eller tabellrammer) som står uendret og i
|
||||
samme rekkefølge; fagpersonens tillegg vises som eget tall. --rounds-dir inne i repoet må være
|
||||
gitignored.
|
||||
|
|
@ -181,6 +211,111 @@ def _parse_time(value: Any) -> datetime | None:
|
|||
return stamp if stamp.tzinfo is not None else None
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date | None:
|
||||
try:
|
||||
return datetime.fromisoformat(value).date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Attestation:
|
||||
"""One round's attestation, read back.
|
||||
|
||||
``present`` separates the two ways it can fail, because they ask different things of the
|
||||
reader: no file at all means nobody has confirmed the round YET (the row waits at
|
||||
``FORM_OK``), while a file that contradicts the round is a statement about some other round
|
||||
(``RED``). Both are un-green, and neither is a measurement."""
|
||||
|
||||
present: bool
|
||||
ok: bool
|
||||
why: str
|
||||
|
||||
|
||||
def _declared_run(round_dir: Path) -> tuple[str, datetime | None]:
|
||||
"""The run id and run time the round's own ``outcome.json`` DECLARES — read raw, without the
|
||||
verification ``read_outcome`` does, so row 1 (which reads no run) does not inherit row 2's
|
||||
artefact checks through the attestation."""
|
||||
data, _ = _read_json(round_dir / "outcome.json")
|
||||
if not isinstance(data, Mapping):
|
||||
return "", None
|
||||
return str(data.get("run_id", "")).strip(), _parse_time(data.get("ran_at"))
|
||||
|
||||
|
||||
def read_attestation(round_dir: Path) -> Attestation:
|
||||
"""The operator's confirmation for ONE round: that it is there, and that it is about THIS
|
||||
round and the run this round declares.
|
||||
|
||||
This is the gate's one input it cannot compute, and that is the point. Measured 18.09: a
|
||||
whole artefact family written by hand and made internally consistent — coverage, artefact
|
||||
pairs, ``verdict_id`` minted with the product's own rule — took row 2 to 3 of 3 GREEN, as did
|
||||
four real runs' artefacts under a handwritten feedback file. Neither is a hole ``verify_run``
|
||||
can close: files agreeing with each other is not a witness that anything happened. So the
|
||||
computation stops at ``FORM_OK`` and green comes from a person saying so, per round."""
|
||||
path = round_dir / ATTEST_FILE
|
||||
if not path.is_file():
|
||||
return Attestation(
|
||||
False,
|
||||
False,
|
||||
f"{ATTEST_FILE} mangler — operatøren har ikke bekreftet at runden ble holdt",
|
||||
)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, ValueError) as exc:
|
||||
return Attestation(True, False, f"{ATTEST_FILE} uleselig ({exc!r})")
|
||||
fields: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
key, sep, value = line.partition(":")
|
||||
if sep and key.strip().casefold() in ATTEST_KEYS:
|
||||
fields.setdefault(key.strip().casefold(), value.strip())
|
||||
missing = [k for k in ATTEST_KEYS if not fields.get(k)]
|
||||
if missing:
|
||||
return Attestation(True, False, f"{ATTEST_FILE} mangler {', '.join(missing)}")
|
||||
if fields["runde"] != round_dir.name:
|
||||
return Attestation(
|
||||
True,
|
||||
False,
|
||||
f"{ATTEST_FILE} attesterer runde {fields['runde']!r}, ikke {round_dir.name!r}",
|
||||
)
|
||||
run_id, ran_at = _declared_run(round_dir)
|
||||
if not run_id:
|
||||
return Attestation(
|
||||
True,
|
||||
False,
|
||||
f"{ATTEST_FILE} navngir kjøring {fields['kjøring']!r}, men runden oppgir ingen",
|
||||
)
|
||||
if fields["kjøring"] != run_id:
|
||||
return Attestation(
|
||||
True,
|
||||
False,
|
||||
f"{ATTEST_FILE} navngir kjøring {fields['kjøring']!r}, runden er kjøring {run_id!r}",
|
||||
)
|
||||
given = _parse_date(fields["dato"])
|
||||
if given is None:
|
||||
return Attestation(
|
||||
True, False, f"{ATTEST_FILE}: dato {fields['dato']!r} er ikke en ISO-dato"
|
||||
)
|
||||
if ran_at is not None and given < ran_at.date():
|
||||
return Attestation(
|
||||
True,
|
||||
False,
|
||||
f"{ATTEST_FILE}: attestert {given.isoformat()}, før kjøringen {run_id} ble gjort "
|
||||
f"({ran_at.date().isoformat()})",
|
||||
)
|
||||
return Attestation(True, True, "")
|
||||
|
||||
|
||||
def _cap(k: int, required: int, exceptions: Sequence[str], pending: Sequence[str]) -> str:
|
||||
"""Rows 1-2 stop where the files stop. Green is not something these rows can COMPUTE: a
|
||||
tree can pass every check the gate knows how to make and still describe a round nobody held.
|
||||
So a round that holds in form but has no attestation leaves the row at ``FORM_OK`` — un-green,
|
||||
still failing the exit code, and counted as 0 against the criterion — while something the
|
||||
gate CAN refuse (bad form, or an attestation about another round) is ``RED``."""
|
||||
if k >= required:
|
||||
return GREEN
|
||||
return RED if exceptions else FORM_OK
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Feedback:
|
||||
ids: frozenset[str]
|
||||
|
|
@ -280,20 +415,27 @@ def read_feedback(round_dir: Path, ai: tuple[str, str] | None) -> tuple[set[str]
|
|||
|
||||
def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row:
|
||||
exceptions: list[str] = []
|
||||
pending: list[str] = []
|
||||
k = 0
|
||||
if not rounds_dir.is_dir():
|
||||
exceptions.append(f"{rounds_dir} finnes ikke")
|
||||
else:
|
||||
for _, (feedback, why) in sorted(read_rounds(rounds_dir, required, ai).items()):
|
||||
if feedback is not None:
|
||||
k += 1
|
||||
else:
|
||||
for n, (feedback, why) in sorted(read_rounds(rounds_dir, required, ai).items()):
|
||||
if feedback is None:
|
||||
exceptions.append(why)
|
||||
status = GREEN if k == required else RED
|
||||
continue
|
||||
attested = read_attestation(rounds_dir / str(n))
|
||||
if attested.ok:
|
||||
k += 1
|
||||
elif attested.present:
|
||||
exceptions.append(f"runde {n}: {attested.why}")
|
||||
else:
|
||||
pending.append(f"runde {n}: {attested.why}")
|
||||
status = _cap(k, required, exceptions, pending)
|
||||
reason = (
|
||||
"form verifisert i alle runder (forfatterskap: se attestering)"
|
||||
if k == required
|
||||
else exceptions[0]
|
||||
"form verifisert i alle runder, og hver runde attestert av operatøren"
|
||||
if status == GREEN
|
||||
else (exceptions + pending)[0]
|
||||
)
|
||||
return Row(
|
||||
"rounds",
|
||||
|
|
@ -302,7 +444,8 @@ def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) ->
|
|||
required,
|
||||
status,
|
||||
reason,
|
||||
exceptions=tuple(exceptions),
|
||||
exceptions=tuple(exceptions + pending),
|
||||
attests=(ATTEST_RULE,),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -440,6 +583,31 @@ def verify_run(outbox: Path, run_id: str, coverage: Sequence[Mapping[str, Any]])
|
|||
return ""
|
||||
|
||||
|
||||
def outbox_escape(round_dir: Path) -> str:
|
||||
"""``""`` when the run the round presents lies IN the round, else why not.
|
||||
|
||||
The outbox is DERIVED (``RUN_OUTBOX``) rather than declared — but a derived path is only
|
||||
derived if the filesystem cannot redirect it. Measured 18.09: ``<n>/outbox`` made a symlink
|
||||
to a real run's directory elsewhere let a round present another run's artefacts as its own,
|
||||
and row 2 read 3 of 3 GREEN. Every artefact the round stands on must therefore be a real file
|
||||
under the round itself, symlink-free in both directions."""
|
||||
outbox = round_dir / RUN_OUTBOX
|
||||
if outbox.is_symlink():
|
||||
return (
|
||||
f"{RUN_OUTBOX}/ er en lenke ut av runden ({os.readlink(outbox)}) — kjøringen må "
|
||||
f"ligge i {round_dir.name}/{RUN_OUTBOX}/ selv"
|
||||
)
|
||||
if not outbox.is_dir():
|
||||
return ""
|
||||
for entry in sorted(outbox.rglob("*")):
|
||||
if entry.is_symlink():
|
||||
return (
|
||||
f"{RUN_OUTBOX}/{entry.relative_to(outbox)} er en lenke "
|
||||
f"({os.readlink(entry)}) — kjøringens artefakter må ligge i runden selv"
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def read_outcome(round_dir: Path) -> tuple[Outcome | None, str]:
|
||||
"""A round's outcome file, VERIFIED against the run it names — and the run is the one in the
|
||||
round's OWN ``outbox/``, never a path the file points at.
|
||||
|
|
@ -473,6 +641,9 @@ def read_outcome(round_dir: Path) -> tuple[Outcome | None, str]:
|
|||
)
|
||||
if ran_at is None:
|
||||
return None, f"{path}: ran_at er ikke et ISO-tidsstempel med tidssone"
|
||||
escape = outbox_escape(round_dir)
|
||||
if escape:
|
||||
return None, f"{path}: {escape}"
|
||||
outbox = round_dir / RUN_OUTBOX
|
||||
coverage_path = outbox / f"{run_id}-coverage.json"
|
||||
payload, why = _read_json(coverage_path)
|
||||
|
|
@ -548,11 +719,18 @@ def outcomes_changed(prev: Outcome, cur: Outcome, feedback_ids: set[str]) -> tup
|
|||
|
||||
def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row:
|
||||
exceptions: list[str] = []
|
||||
pending: list[str] = []
|
||||
k = 0
|
||||
base_path = rounds_dir / "0" / "outcome.json"
|
||||
base_outcome, base_why = read_outcome(rounds_dir / "0")
|
||||
base = f"runde 0 = {base_path}"
|
||||
base += f" (kjøring {base_outcome.run_id})" if base_outcome else f" — {base_why}"
|
||||
# Round 1 is measured AGAINST round 0's run, so an unattested baseline is an unattested
|
||||
# comparison and no round can count. Said ONCE here rather than repeated on every round.
|
||||
base_attested = read_attestation(rounds_dir / "0")
|
||||
if not base_attested.ok:
|
||||
target = exceptions if base_attested.present else pending
|
||||
target.append(f"runde 0 (grunnkjøringen rad 2 måler mot): {base_attested.why}")
|
||||
feedback_by_round = read_rounds(rounds_dir, required, ai) if rounds_dir.is_dir() else {}
|
||||
for n in range(1, required + 1):
|
||||
feedback, why = feedback_by_round.get(n, (None, f"runde {n}: {rounds_dir} finnes ikke"))
|
||||
|
|
@ -570,20 +748,24 @@ def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -
|
|||
)
|
||||
continue
|
||||
ok, detail = outcomes_changed(prev, cur, set(feedback.ids))
|
||||
if ok:
|
||||
k += 1
|
||||
else:
|
||||
if not ok:
|
||||
exceptions.append(f"runde {n}: {detail}")
|
||||
status = GREEN if k == required else RED
|
||||
continue
|
||||
attested = read_attestation(rounds_dir / str(n))
|
||||
if not attested.ok:
|
||||
(exceptions if attested.present else pending).append(f"runde {n}: {attested.why}")
|
||||
continue
|
||||
if base_attested.ok:
|
||||
k += 1
|
||||
return Row(
|
||||
"changes",
|
||||
"2 runder med målbar endring",
|
||||
k,
|
||||
required,
|
||||
status,
|
||||
_cap(k, required, exceptions, pending),
|
||||
base,
|
||||
exceptions=tuple(exceptions),
|
||||
attests=(RUN_ATTESTATION,),
|
||||
exceptions=tuple(exceptions + pending),
|
||||
attests=(RUN_ATTESTATION, ATTEST_RULE),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -521,12 +521,13 @@ def _forged_rounds(root: Path) -> Path:
|
|||
|
||||
|
||||
def test_m6_a_handwritten_outbox_is_not_a_run(tmp_path: Path) -> None:
|
||||
"""Row 2 must not be satisfiable by files a forger wrote. Row 1 stays GREEN on the same tree —
|
||||
the feedback there IS well formed, and that is what makes this an attack on row 2 rather than
|
||||
a broken fixture. The control that the row can still go green is ``_green_rounds``, which
|
||||
carries a whole run family (``test_row2_a_traced_change_counts``)."""
|
||||
"""Row 2 must not be satisfiable by files a forger wrote. Row 1 reads FORM OK on the same
|
||||
tree — the feedback there IS well formed, and that is what makes this an attack on row 2
|
||||
rather than a broken fixture; since M-7 that form is as far as row 1 can get without the
|
||||
operator's attestation. The control that the row can still go green is ``_green_rounds``,
|
||||
which carries a whole run family AND an attestation (``test_row2_a_traced_change_counts``)."""
|
||||
root = _forged_rounds(tmp_path)
|
||||
assert gate.score_rounds(root, 3, _AI).status == gate.GREEN
|
||||
assert gate.score_rounds(root, 3, _AI).status == gate.FORM_OK
|
||||
row = gate.score_changes(root, 3, _AI)
|
||||
assert (row.k, row.status) == (0, gate.RED), row.exceptions
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue