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
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
import difflib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
|
@ -69,7 +69,12 @@ ATTEST_KEYS = ("runde", "kjøring", "dato")
|
|||
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"
|
||||
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"
|
||||
|
|
@ -211,11 +216,23 @@ def _parse_time(value: Any) -> datetime | 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:
|
||||
return datetime.fromisoformat(value).date()
|
||||
return date.fromisoformat(value), ""
|
||||
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)
|
||||
|
|
@ -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"))
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
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."""
|
||||
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
|
||||
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(
|
||||
False,
|
||||
False,
|
||||
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:
|
||||
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:
|
||||
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())
|
||||
name = key.strip().casefold()
|
||||
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)]
|
||||
if missing:
|
||||
return Attestation(True, False, f"{ATTEST_FILE} mangler {', '.join(missing)}")
|
||||
|
|
@ -290,17 +318,30 @@ def read_attestation(round_dir: Path) -> Attestation:
|
|||
False,
|
||||
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:
|
||||
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}: {why}")
|
||||
clock = now if now is not None else datetime.now(tz=timezone.utc)
|
||||
if isinstance(given, datetime):
|
||||
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(
|
||||
True,
|
||||
False,
|
||||
f"{ATTEST_FILE}: attestert {given.isoformat()}, før kjøringen {run_id} ble gjort "
|
||||
f"({ran_at.date().isoformat()})",
|
||||
f"{ATTEST_FILE}: attestert {given.isoformat()}, før kjøringen {run_id} ble gjort ({ran})",
|
||||
)
|
||||
if future:
|
||||
return Attestation(
|
||||
True,
|
||||
False,
|
||||
f"{ATTEST_FILE}: attestert {given.isoformat()}, i framtiden (nå: "
|
||||
f"{clock.isoformat(timespec='seconds')})",
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
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] = []
|
||||
pending: list[str] = []
|
||||
k = 0
|
||||
|
|
@ -424,7 +467,7 @@ def score_rounds(rounds_dir: Path, required: int, ai: tuple[str, str] | None) ->
|
|||
if feedback is None:
|
||||
exceptions.append(why)
|
||||
continue
|
||||
attested = read_attestation(rounds_dir / str(n))
|
||||
attested = read_attestation(rounds_dir / str(n), now)
|
||||
if attested.ok:
|
||||
k += 1
|
||||
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)}"
|
||||
|
||||
|
||||
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] = []
|
||||
pending: list[str] = []
|
||||
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}"
|
||||
# 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")
|
||||
base_attested = read_attestation(rounds_dir / "0", now)
|
||||
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}")
|
||||
|
|
@ -751,7 +796,7 @@ def score_changes(rounds_dir: Path, required: int, ai: tuple[str, str] | None) -
|
|||
if not ok:
|
||||
exceptions.append(f"runde {n}: {detail}")
|
||||
continue
|
||||
attested = read_attestation(rounds_dir / str(n))
|
||||
attested = read_attestation(rounds_dir / str(n), now)
|
||||
if not attested.ok:
|
||||
(exceptions if attested.present else pending).append(f"runde {n}: {attested.why}")
|
||||
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:
|
||||
title = "7 named (diagnose, ingen terskel)"
|
||||
if m.missing:
|
||||
|
|
@ -1267,6 +1320,7 @@ def evaluate(
|
|||
bundle_root: Path | None = None,
|
||||
probe_runner: ProbeRunner | None = None,
|
||||
stress_measure: StressMeasure | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> list[Row]:
|
||||
ai = ai_authored_lines(repo_root, config["ai_authored"])
|
||||
required = int(config["rounds_required"])
|
||||
|
|
@ -1283,8 +1337,8 @@ def evaluate(
|
|||
bundle_root,
|
||||
)
|
||||
return [
|
||||
score_rounds(rounds_dir, required, ai),
|
||||
score_changes(rounds_dir, required, ai),
|
||||
score_rounds(rounds_dir, required, ai, now),
|
||||
score_changes(rounds_dir, required, ai, now),
|
||||
score_types(types, outcomes),
|
||||
score_kept(rounds_dir, float(config["keep_threshold"]), ai),
|
||||
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 a in r.attests:
|
||||
out.append(f" [{r.title.split()[0]}] {a}.")
|
||||
out.append(f"Stressrunde: {STRESS_DEPENDENCY}.")
|
||||
out.append("")
|
||||
out.append("Unntak fra 100 %:")
|
||||
for r in rows:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue