fix(v1-gate): an attestation cannot be dated in the future or before the run by a timezone, and a doubled key, a link or a directory is red
Chosen: a key written twice is RED (PM recommendation: two answers to one question are not an answer). A time without an offset is RED as ambiguous. A hard link is NOT refused but declared a limit in the output (content binds round, run and date either way). A BOM is tolerated. The clock is a parameter (now=), read by ONE check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
cd6fb4302e
commit
c067d0e85b
1 changed files with 78 additions and 23 deletions
|
|
@ -27,7 +27,7 @@ import tempfile
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timezone
|
||||||
import difflib
|
import difflib
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -69,7 +69,12 @@ ATTEST_KEYS = ("runde", "kjøring", "dato")
|
||||||
ATTEST_RULE = (
|
ATTEST_RULE = (
|
||||||
"grønt på rad 1-2 regner gaten ikke ut: filene kan stemme innbyrdes og likevel beskrive en "
|
"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 "
|
"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"
|
f"attestering i <runde>/{ATTEST_FILE} (runde, kjøring, dato), som gaten ALDRI skriver selv. "
|
||||||
|
"Datoen kan ikke ligge i framtiden (den ENE sjekken som leser klokka) eller før kjøringen; "
|
||||||
|
"en dato med klokkeslett må ha tidssone og sammenlignes som tidspunkt, en ren dato kan "
|
||||||
|
"ikke skilles fra kjøringens egen dag (samme dag passerer). En nøkkel som står to ganger, "
|
||||||
|
"en katalog eller en symlenke i stedet for fila, og ikke-UTF-8 er RØDT; en hardlenke "
|
||||||
|
"avvises IKKE (innholdet binder runde, kjøring og dato uansett) — det er en kjent grense"
|
||||||
)
|
)
|
||||||
|
|
||||||
GREEN = "GRØNN"
|
GREEN = "GRØNN"
|
||||||
|
|
@ -211,11 +216,23 @@ def _parse_time(value: Any) -> datetime | None:
|
||||||
return stamp if stamp.tzinfo is not None else None
|
return stamp if stamp.tzinfo is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value: str) -> date | None:
|
def _parse_given(value: str) -> tuple[date | datetime | None, str]:
|
||||||
|
"""An attestation's ``dato``: a plain date, or an instant, or ``(None, why)``.
|
||||||
|
|
||||||
|
A plain date carries no time and stays a ``date`` — same day cannot be told apart, and the
|
||||||
|
contract says so. A date WITH a time must say which zone, and is then an instant: comparing
|
||||||
|
only its wall-clock date let ``+14:00`` put an attestation ten hours before the run."""
|
||||||
try:
|
try:
|
||||||
return datetime.fromisoformat(value).date()
|
return date.fromisoformat(value), ""
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
pass
|
||||||
|
try:
|
||||||
|
stamp = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None, f"dato {value!r} er ikke en ISO-dato"
|
||||||
|
if stamp.tzinfo is None:
|
||||||
|
return None, f"dato {value!r} har klokkeslett uten tidssone (tvetydig)"
|
||||||
|
return stamp, ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -242,7 +259,7 @@ def _declared_run(round_dir: Path) -> tuple[str, datetime | None]:
|
||||||
return str(data.get("run_id", "")).strip(), _parse_time(data.get("ran_at"))
|
return str(data.get("run_id", "")).strip(), _parse_time(data.get("ran_at"))
|
||||||
|
|
||||||
|
|
||||||
def read_attestation(round_dir: Path) -> Attestation:
|
def read_attestation(round_dir: Path, now: datetime | None = None) -> Attestation:
|
||||||
"""The operator's confirmation for ONE round: that it is there, and that it is about THIS
|
"""The operator's confirmation for ONE round: that it is there, and that it is about THIS
|
||||||
round and the run this round declares.
|
round and the run this round declares.
|
||||||
|
|
||||||
|
|
@ -251,23 +268,34 @@ def read_attestation(round_dir: Path) -> Attestation:
|
||||||
pairs, ``verdict_id`` minted with the product's own rule — took row 2 to 3 of 3 GREEN, as did
|
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``
|
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
|
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."""
|
computation stops at ``FORM_OK`` and green comes from a person saying so, per round.
|
||||||
|
|
||||||
|
``now`` is the clock, injected so a test is deterministic; the default is the real one. It is
|
||||||
|
read for ONE check only — an attestation cannot be dated in the future."""
|
||||||
path = round_dir / ATTEST_FILE
|
path = round_dir / ATTEST_FILE
|
||||||
if not path.is_file():
|
if path.is_symlink():
|
||||||
|
return Attestation(True, False, f"{ATTEST_FILE} er en lenke, ikke en vanlig fil")
|
||||||
|
if not path.exists():
|
||||||
return Attestation(
|
return Attestation(
|
||||||
False,
|
False,
|
||||||
False,
|
False,
|
||||||
f"{ATTEST_FILE} mangler — operatøren har ikke bekreftet at runden ble holdt",
|
f"{ATTEST_FILE} mangler — operatøren har ikke bekreftet at runden ble holdt",
|
||||||
)
|
)
|
||||||
|
if not path.is_file():
|
||||||
|
return Attestation(True, False, f"{ATTEST_FILE} er ikke en vanlig fil")
|
||||||
try:
|
try:
|
||||||
text = path.read_text(encoding="utf-8")
|
# utf-8-sig: an ordinary editor may put a BOM first, and that is not the person's error.
|
||||||
|
text = path.read_text(encoding="utf-8-sig")
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
return Attestation(True, False, f"{ATTEST_FILE} uleselig ({exc!r})")
|
return Attestation(True, False, f"{ATTEST_FILE} uleselig ({exc!r})")
|
||||||
fields: dict[str, str] = {}
|
fields: dict[str, str] = {}
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
key, sep, value = line.partition(":")
|
key, sep, value = line.partition(":")
|
||||||
if sep and key.strip().casefold() in ATTEST_KEYS:
|
name = key.strip().casefold()
|
||||||
fields.setdefault(key.strip().casefold(), value.strip())
|
if sep and name in ATTEST_KEYS:
|
||||||
|
if name in fields:
|
||||||
|
return Attestation(True, False, f"{ATTEST_FILE}: nøkkelen {name!r} står to ganger")
|
||||||
|
fields[name] = value.strip()
|
||||||
missing = [k for k in ATTEST_KEYS if not fields.get(k)]
|
missing = [k for k in ATTEST_KEYS if not fields.get(k)]
|
||||||
if missing:
|
if missing:
|
||||||
return Attestation(True, False, f"{ATTEST_FILE} mangler {', '.join(missing)}")
|
return Attestation(True, False, f"{ATTEST_FILE} mangler {', '.join(missing)}")
|
||||||
|
|
@ -290,17 +318,30 @@ def read_attestation(round_dir: Path) -> Attestation:
|
||||||
False,
|
False,
|
||||||
f"{ATTEST_FILE} navngir kjøring {fields['kjøring']!r}, runden er kjøring {run_id!r}",
|
f"{ATTEST_FILE} navngir kjøring {fields['kjøring']!r}, runden er kjøring {run_id!r}",
|
||||||
)
|
)
|
||||||
given = _parse_date(fields["dato"])
|
given, why = _parse_given(fields["dato"])
|
||||||
if given is None:
|
if given is None:
|
||||||
return Attestation(
|
return Attestation(True, False, f"{ATTEST_FILE}: {why}")
|
||||||
True, False, f"{ATTEST_FILE}: dato {fields['dato']!r} er ikke en ISO-dato"
|
clock = now if now is not None else datetime.now(tz=timezone.utc)
|
||||||
)
|
if isinstance(given, datetime):
|
||||||
if ran_at is not None and given < ran_at.date():
|
before = ran_at is not None and given < ran_at
|
||||||
|
future = given > clock
|
||||||
|
ran = ran_at.isoformat() if ran_at is not None else ""
|
||||||
|
else:
|
||||||
|
before = ran_at is not None and given < ran_at.date()
|
||||||
|
future = given > clock.astimezone(timezone.utc).date()
|
||||||
|
ran = ran_at.date().isoformat() if ran_at is not None else ""
|
||||||
|
if before:
|
||||||
return Attestation(
|
return Attestation(
|
||||||
True,
|
True,
|
||||||
False,
|
False,
|
||||||
f"{ATTEST_FILE}: attestert {given.isoformat()}, før kjøringen {run_id} ble gjort "
|
f"{ATTEST_FILE}: attestert {given.isoformat()}, før kjøringen {run_id} ble gjort ({ran})",
|
||||||
f"({ran_at.date().isoformat()})",
|
)
|
||||||
|
if future:
|
||||||
|
return Attestation(
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
f"{ATTEST_FILE}: attestert {given.isoformat()}, i framtiden (nå: "
|
||||||
|
f"{clock.isoformat(timespec='seconds')})",
|
||||||
)
|
)
|
||||||
return Attestation(True, True, "")
|
return Attestation(True, True, "")
|
||||||
|
|
||||||
|
|
@ -413,7 +454,9 @@ def read_feedback(round_dir: Path, ai: tuple[str, str] | None) -> tuple[set[str]
|
||||||
return (set(feedback.ids), "") if feedback is not None else (set(), why)
|
return (set(feedback.ids), "") if feedback is not None else (set(), why)
|
||||||
|
|
||||||
|
|
||||||
def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row:
|
def score_rounds(
|
||||||
|
rounds_dir: Path, required: int, ai: tuple[str, str] | None, now: datetime | None = None
|
||||||
|
) -> Row:
|
||||||
exceptions: list[str] = []
|
exceptions: list[str] = []
|
||||||
pending: list[str] = []
|
pending: list[str] = []
|
||||||
k = 0
|
k = 0
|
||||||
|
|
@ -424,7 +467,7 @@ def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) ->
|
||||||
if feedback is None:
|
if feedback is None:
|
||||||
exceptions.append(why)
|
exceptions.append(why)
|
||||||
continue
|
continue
|
||||||
attested = read_attestation(rounds_dir / str(n))
|
attested = read_attestation(rounds_dir / str(n), now)
|
||||||
if attested.ok:
|
if attested.ok:
|
||||||
k += 1
|
k += 1
|
||||||
elif attested.present:
|
elif attested.present:
|
||||||
|
|
@ -717,7 +760,9 @@ def outcomes_changed(prev: Outcome, cur: Outcome, feedback_ids: set[str]) -> tup
|
||||||
return True, f"{len(changed)} rad(er) endret, sporet: {', '.join(traced)}"
|
return True, f"{len(changed)} rad(er) endret, sporet: {', '.join(traced)}"
|
||||||
|
|
||||||
|
|
||||||
def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -> Row:
|
def score_changes(
|
||||||
|
rounds_dir: Path, required: int, ai: tuple[str, str] | None, now: datetime | None = None
|
||||||
|
) -> Row:
|
||||||
exceptions: list[str] = []
|
exceptions: list[str] = []
|
||||||
pending: list[str] = []
|
pending: list[str] = []
|
||||||
k = 0
|
k = 0
|
||||||
|
|
@ -727,7 +772,7 @@ def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -
|
||||||
base += f" (kjøring {base_outcome.run_id})" if base_outcome else f" — {base_why}"
|
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
|
# 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.
|
# comparison and no round can count. Said ONCE here rather than repeated on every round.
|
||||||
base_attested = read_attestation(rounds_dir / "0")
|
base_attested = read_attestation(rounds_dir / "0", now)
|
||||||
if not base_attested.ok:
|
if not base_attested.ok:
|
||||||
target = exceptions if base_attested.present else pending
|
target = exceptions if base_attested.present else pending
|
||||||
target.append(f"runde 0 (grunnkjøringen rad 2 måler mot): {base_attested.why}")
|
target.append(f"runde 0 (grunnkjøringen rad 2 måler mot): {base_attested.why}")
|
||||||
|
|
@ -751,7 +796,7 @@ def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -
|
||||||
if not ok:
|
if not ok:
|
||||||
exceptions.append(f"runde {n}: {detail}")
|
exceptions.append(f"runde {n}: {detail}")
|
||||||
continue
|
continue
|
||||||
attested = read_attestation(rounds_dir / str(n))
|
attested = read_attestation(rounds_dir / str(n), now)
|
||||||
if not attested.ok:
|
if not attested.ok:
|
||||||
(exceptions if attested.present else pending).append(f"runde {n}: {attested.why}")
|
(exceptions if attested.present else pending).append(f"runde {n}: {attested.why}")
|
||||||
continue
|
continue
|
||||||
|
|
@ -1226,6 +1271,14 @@ NAMED_WARNING = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Printed on rows 6-7: the stress round is read from a directory the repository does not track.
|
||||||
|
STRESS_DEPENDENCY = (
|
||||||
|
"rad 6-7 leser stressrundens artefakter fra scratchpad/, som IKKE er sporet av git: i en "
|
||||||
|
"klone uten den mappen er begge IKKE MÅLT, så «1 av 20» er en påstand om denne sjekkouten, "
|
||||||
|
"ikke om produktet"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def score_named(m: StressMeasure, label: str) -> Row:
|
def score_named(m: StressMeasure, label: str) -> Row:
|
||||||
title = "7 named (diagnose, ingen terskel)"
|
title = "7 named (diagnose, ingen terskel)"
|
||||||
if m.missing:
|
if m.missing:
|
||||||
|
|
@ -1267,6 +1320,7 @@ def evaluate(
|
||||||
bundle_root: Path | None = None,
|
bundle_root: Path | None = None,
|
||||||
probe_runner: ProbeRunner | None = None,
|
probe_runner: ProbeRunner | None = None,
|
||||||
stress_measure: StressMeasure | None = None,
|
stress_measure: StressMeasure | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
) -> list[Row]:
|
) -> list[Row]:
|
||||||
ai = ai_authored_lines(repo_root, config["ai_authored"])
|
ai = ai_authored_lines(repo_root, config["ai_authored"])
|
||||||
required = int(config["rounds_required"])
|
required = int(config["rounds_required"])
|
||||||
|
|
@ -1283,8 +1337,8 @@ def evaluate(
|
||||||
bundle_root,
|
bundle_root,
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
score_rounds(rounds_dir, required, ai),
|
score_rounds(rounds_dir, required, ai, now),
|
||||||
score_changes(rounds_dir, required, ai),
|
score_changes(rounds_dir, required, ai, now),
|
||||||
score_types(types, outcomes),
|
score_types(types, outcomes),
|
||||||
score_kept(rounds_dir, float(config["keep_threshold"]), ai),
|
score_kept(rounds_dir, float(config["keep_threshold"]), ai),
|
||||||
score_maf(config["maf_points"], green_types(types, outcomes), src),
|
score_maf(config["maf_points"], green_types(types, outcomes), src),
|
||||||
|
|
@ -1305,6 +1359,7 @@ def render(rows: Sequence[Row]) -> str:
|
||||||
for r in rows:
|
for r in rows:
|
||||||
for a in r.attests:
|
for a in r.attests:
|
||||||
out.append(f" [{r.title.split()[0]}] {a}.")
|
out.append(f" [{r.title.split()[0]}] {a}.")
|
||||||
|
out.append(f"Stressrunde: {STRESS_DEPENDENCY}.")
|
||||||
out.append("")
|
out.append("")
|
||||||
out.append("Unntak fra 100 %:")
|
out.append("Unntak fra 100 %:")
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue