textmachine/eval/dovodka/runs.py

285 lines
17 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.

#!/usr/bin/env python3
"""ЖУРНАЛ АГЕНТ-СЕССИЙ ФАЗЫ Д. $0. Кап 80 (заказ владельца 10.08).
⚠ ЗАЧЕМ ОТДЕЛЬНЫЙ МОДУЛЬ, А НЕ СТРОКА В `judge.py`. В паке 23 счётчик существовал и не работал:
`log_run` звался из `--ingest`, то есть ПОСЛЕ того, как суб-агент отработал. На диске осталось
20 записей против 58 фактических сессий; при капе 80 такой счётчик пропустил бы ещё 60 — сторожить
кап можно ТОЛЬКО записью, сделанной ДО запуска. Здесь это и есть контракт: `claim()` пишет запись и возвращает имя судьи,
которое передаётся суб-агенту; не позвал `claim` — не получил имени, и разбор проставит `?`.
⚠ ВТОРОЙ ДЕФЕКТ, КОТОРЫЙ ЧИНИТ ЭТОТ ЖЕ ФАЙЛ. Карта «токен → судья» в паке 23 лежала одна на
ПРОХОД, а репликация судила ТЕ ЖЕ токены вторым жребием судей — и все её голоса получили имена
судей боевого прогона. Здесь запись помнит ПРОГОН и ВРЕМЯ, и `judge_of` разводит два жребия по
одним и тем же токенам ОКНОМ сессии `[at, done_at]`.
⚠ ТРЕТИЙ, И САМЫЙ ДОРОГОЙ. Атрибуция по времени работает, только пока сессии не перекрываются.
Пак 23 отдал пять единиц на пере-суживание ДВАЖДЫ, не дождавшись первого круга: оба круга писали
в одни файлы, `--ingest` прошёл между ними, и голоса разошлись с сырьём на 5 единицах из 32.
Поэтому `claim` ОТКАЗЫВАЕТ, если сессия с пересекающимися токенами ещё не закрыта `done`.
⚠ Первая редакция называла это «замком, который невозможно обойти механически» — неверно, и
поймано приёмкой другого модельного семейства: `claim` делал read-modify-write журнала БЕЗ
файлового лока, так что два параллельных вызова оба прочли бы леджер до записи друг друга,
оба прошли бы проверку пересечения и получили один и тот же номер. Тот же класс, что «неатомарная
проверка файла» в кассе эксп-22, стоившая ему границы пере-оплаты ≤$0.846103. Теперь журнал
берётся под атомарный `O_CREAT|O_EXCL`-замок, как `money.py`, и проверка пересечения идёт ВНУТРИ
критической секции.
Команды:
runs.py --claim <прогон> <проход> <ток> [<ток> …] [--note ""] запись ДО запуска суб-агента
runs.py --done <n> закрыть сессию ПОСЛЕ ответа
runs.py --report сколько израсходовано
runs.py --selftest обязателен ДО первой сессии
"""
from __future__ import annotations
import contextlib
import datetime as dt
import json
import os
import sys
import time
from pathlib import Path
OUT = Path.home() / "books" / "dovodka"
LEDGER = OUT / "agent-runs.json"
CAP = 80
FAR_FUTURE = 1e18
LOCK_TRIES = 600 # ×0.1 с — минута ожидания живого замка
def _now() -> str:
return dt.datetime.now(dt.timezone.utc).isoformat(timespec="microseconds")
def _ts(iso: str | None) -> float:
return dt.datetime.fromisoformat(iso).timestamp() if iso else FAR_FUTURE
def used() -> list[dict]:
return json.loads(LEDGER.read_text(encoding="utf-8")) if LEDGER.exists() else []
def _write(rs: list[dict]) -> None:
"""⚠ Запись АТОМАРНА: `write_text` усекает файл и пишет заново, и падение между этими двумя
шагами оставляло журнал нечитаемым (поймано ревью исполнением: 2 прогона из 20). Пишем во
временный файл и подменяем `os.replace` — она атомарна в пределах ФС."""
OUT.mkdir(parents=True, exist_ok=True)
tmp = LEDGER.with_suffix(".tmp")
tmp.write_text(json.dumps(rs, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, LEDGER)
@contextlib.contextmanager
def _locked():
"""Атомарный замок журнала. `O_CREAT|O_EXCL` — та же механика, что у кассы эксп-22.
⚠ Замок снимает ТОЛЬКО поставивший его процесс: в файл пишется PID, и протухшим замок
считается лишь тогда, когда процесса с этим PID больше нет (норма рига, промт фазы Д).
"""
OUT.mkdir(parents=True, exist_ok=True)
lock = LEDGER.with_suffix(".lock")
for _ in range(LOCK_TRIES):
try:
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
break
except FileExistsError:
try:
pid = int(lock.read_text() or 0)
os.kill(pid, 0) # процесс жив — ждём
except (ValueError, OSError):
lock.unlink(missing_ok=True) # ставивший умер — снимаем
continue
time.sleep(0.1)
else:
raise SystemExit("⛔ журнал агент-сессий заперт дольше минуты — разберись, кто держит")
try:
yield
finally:
with contextlib.suppress(OSError):
if lock.exists() and lock.read_text().strip() == str(os.getpid()):
lock.unlink()
def claim(run: str, pass_: str, tokens: list[str], note: str = "") -> dict:
"""Занять агент-сессию ДО запуска суб-агента. Возвращает запись с именем судьи.
⚠ Кап проверяется ПЕРЕД записью, а не после: иначе 81-я сессия успевала бы отработать.
"""
with _locked():
return _claim_locked(run, pass_, tokens, note)
def _claim_locked(run: str, pass_: str, tokens: list[str], note: str) -> dict:
rs = used()
live = [r for r in rs if r.get("done_at") is None and set(r["tokens"]) & set(tokens)]
if live:
raise SystemExit(
"⛔ ГОНКА СЕССИЙ: токены уже отданы незакрытой сессии "
f"#{live[0]['n']} ({live[0]['judge']}, {sorted(set(live[0]['tokens']) & set(tokens))}).\n"
" Дождись её ответов и закрой `--done`, либо возьми другие токены. Ровно на этом\n"
" пак 23 потерял соответствие голосов и сырья на 5 единицах из 32.")
n = len(rs) + 1
if n > CAP:
raise SystemExit(f"⛔ КАП АГЕНТ-СЕССИЙ ФАЗЫ Д ПРОБИТ: {n} > {CAP} — СТОП и пинг владельцу")
rec = dict(n=n, at=_now(), done_at=None, run=run, pass_=pass_, judge=f"д-{n:02d}",
tokens=list(tokens), note=note)
rs.append(rec)
_write(rs)
return rec
def done(n: int) -> dict:
"""⚠ ТОЖЕ ПОД ЗАМКОМ. Первая редакция брала замок только в `claim`, а `done` делала свой
read-modify-write мимо него — и параллельные `claim`+`done` теряли запись claim в 19 прогонах
из 20: суб-агент запущен, имя судьи выдано, а в журнале его нет. Кап при этом недосчитывает,
`judge_of` отдаёт `?`, а замок пересечения токенов пускает на них вторую сессию. Нашло ревью
исполнением; селфтест этого не видел, потому что гонял только последовательные сценарии."""
with _locked():
return _done_locked(n)
def _done_locked(n: int) -> dict:
rs = used()
hit = [r for r in rs if r["n"] == n]
if not hit:
raise SystemExit(f"⛔ сессии #{n} в журнале нет")
hit[0]["done_at"] = _now()
_write(rs)
return hit[0]
def judge_of(run: str, token: str, answer_mtime: float) -> str:
"""Кто судил ЭТОТ ответ. Сессия владеет ответом, если он записан в её окне `[at, done_at]`.
Хвост допуска: ответ, пришедший ПОСЛЕ закрытия последней накрывающей сессии, отдаётся ей же —
закрытие ставится руками и может отстать от файловой системы на секунды. Ответ РАНЬШЕ первой
регистрации не отдаётся никому: он не мог быть ею произведён.
"""
cand = sorted((r for r in used() if r["run"] == run and token in r["tokens"]),
key=lambda r: _ts(r["at"]))
inwin = [r for r in cand if _ts(r["at"]) <= answer_mtime <= _ts(r.get("done_at")) + 5.0]
if inwin:
return inwin[-1]["judge"]
started = [r for r in cand if _ts(r["at"]) <= answer_mtime]
return started[-1]["judge"] if started else "?"
def report() -> None:
rs = used()
print(f"агент-сессий израсходовано {len(rs)} из {CAP}")
live = [r for r in rs if r.get("done_at") is None]
per: dict[str, int] = {}
for r in rs:
per[r["run"]] = per.get(r["run"], 0) + 1
for k, v in sorted(per.items()):
print(f" {k:28s} {v}")
if live:
print(f"⚠ НЕЗАКРЫТЫХ сессий {len(live)}: " + " ".join(f"#{r['n']}" for r in live))
def selftest() -> int:
"""Проверка ИСПОЛНЕНИЕМ, а не чтением: журнал подменяется временным, гоняются сценарии."""
global LEDGER, CAP # noqa: PLW0603
keep_l, keep_c = LEDGER, CAP
OUT.mkdir(parents=True, exist_ok=True)
LEDGER = OUT / "SELFTEST-agent-runs.json"
LEDGER.unlink(missing_ok=True)
bad = 0
def ck(name: str, ok: bool, detail: str = "") -> None:
nonlocal bad
bad += not ok
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + (f" {detail}" if detail else ""))
try:
# 1. запись появляется ДО того, как что-либо отработало
a = claim("проверка", "px", ["t1", "t2"], note="первый жребий")
ck("claim пишет запись сразу", LEDGER.exists() and len(used()) == 1, f"n={a['n']}")
ck("судья именован", a["judge"] == "д-01", a["judge"])
# 2. ЗАМОК ГОНКИ: те же токены нельзя отдать, пока сессия не закрыта
try:
claim("проверка", "px", ["t2"])
ck("гонка сессий по одному токену запрещена", False, "пропустил вторую сессию")
except SystemExit:
ck("гонка сессий по одному токену запрещена", True)
ck("отказавшая сессия НЕ записана", len(used()) == 1, f"{len(used())} записей")
# ⚠ ЗАМОК ПРОВЕРЯЕТСЯ ИСПОЛНЕНИЕМ, а не чтением. Прежний селфтест гонял только
# последовательные сценарии и дыру read-modify-write не видел — её нашла приёмка
# другого модельного семейства. Здесь замок ставится ЧУЖИМ живым PID и claim обязан
# ждать, а не пройти мимо.
global LOCK_TRIES # noqa: PLW0603
keep_tries, LOCK_TRIES = LOCK_TRIES, 5 # не ждать минуту в селфтесте
lock = LEDGER.with_suffix(".lock")
lock.write_text(str(os.getpid())) # живой PID = замок держится
t0 = time.time()
try:
claim("проверка", "px", ["t5"])
waited = time.time() - t0
ck("claim ЖДЁТ живой замок, а не проходит мимо", False,
f"прошёл за {waited:.2f} с при занятом замке")
except SystemExit:
ck("claim ЖДЁТ живой замок, а не проходит мимо", True, "упёрся в таймаут — верно")
finally:
lock.unlink(missing_ok=True)
LOCK_TRIES = keep_tries
lock.write_text("999999999") # мёртвый PID = замок протух
n_before = len(used())
claim("проверка", "px", ["t6"])
ck("протухший замок (мёртвый PID) снимается", len(used()) == n_before + 1)
ck("замок за собой убран", not lock.exists())
n_now = len(used())
ck("непересекающиеся токены разрешены", claim("проверка", "px", ["t7"])["n"] == n_now + 1)
# 3. ВТОРОЙ ЖРЕБИЙ ПО ТЕМ ЖЕ ТОКЕНАМ — тот самый дефект пака 23
t_first = _ts(a["at"]) + 0.001 # ответ пришёл в окне первой
done(a["n"])
b = claim("проверка", "px", ["t1", "t2"], note="второй жребий")
t_second = _ts(b["at"]) + 0.001
done(b["n"])
ck("ответ ПЕРВОГО жребия атрибутируется первому",
judge_of("проверка", "t1", t_first) == "д-01", judge_of("проверка", "t1", t_first))
ck("ответ ВТОРОГО жребия атрибутируется второму",
judge_of("проверка", "t1", t_second) == b["judge"],
f"{judge_of('проверка', 't1', t_second)} против {b['judge']}")
ck("ответ РАНЬШЕ первой регистрации не отдаётся никому",
judge_of("проверка", "t1", _ts(a["at"]) - 10) == "?")
ck("чужой прогон не атрибутируется", judge_of("другой", "t1", t_second) == "?")
ck("незарегистрированный токен даёт '?'", judge_of("проверка", "нет", t_second) == "?")
# 4. КАП ОБЯЗАН ОТКАЗАТЬ ДО ЗАПУСКА, а не после
CAP = len(used())
try:
claim("проверка", "px", ["t9"])
ck("кап отказывает при исчерпании", False, "пропустил лишнюю сессию")
except SystemExit:
ck("кап отказывает при исчерпании", True)
ck("сверх-капная сессия НЕ записана", len(used()) == CAP, f"{len(used())} записей")
finally:
LEDGER.unlink(missing_ok=True)
LEDGER, CAP = keep_l, keep_c
print(f"\n{'ЖУРНАЛ ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}")
return bad
if __name__ == "__main__":
a = sys.argv[1:] or ["--report"]
if a[0] == "--selftest":
sys.exit(1 if selftest() else 0)
elif a[0] == "--done":
r = done(int(a[1]))
print(f"сессия #{r['n']} ({r['judge']}) закрыта {r['done_at']}")
elif a[0] == "--claim":
note = ""
if "--note" in a:
i = a.index("--note")
note, a = (a[i + 1] if len(a) > i + 1 else ""), a[:i]
if len(a) < 4:
raise SystemExit("нужно: --claim <прогон> <проход> <ток> [<ток> …]")
r = claim(a[1], a[2], a[3:], note=note)
print(f"сессия #{r['n']}/{CAP} · судья {r['judge']} · токенов {len(r['tokens'])}")
else:
report()