textmachine/eval/role_topology/absjudge.py

851 lines
62 KiB
Python
Raw Permalink 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 по ключам провайдеров.
⚠ Заведён 07.08 по находке ЧЕТЫРЁХ независимых судей-агентов. Каждому была выдана своя пачка
ПАРНЫХ пакетов «исходник + вариант 1 + вариант 2»; ни один не знал о трёх других. Все четыре
самостоятельно свернули пары обратно: дедуплицировали уникальные тексты, оценили КАЖДЫЙ ОДИН РАЗ
против исходника и разнесли числа по пакетам. Четыре из четырёх — это не сбой исполнителя, это
поведение инструмента, и оснастка обязана следовать за ним, а не бороться с ним.
ЧТО ДАЁТ ПЕРЕХОД НА АБСОЛЮТНУЮ ШКАЛУ.
1. **Позиция исчезает по построению.** Текст оценивается вне пары, значит «первый/второй» не
существует как понятие. Зеркальная раскладка, которую я чинил трижды и трижды получал
вырождение, здесь не нужна — нечего зеркалить.
2. **Работа падает втрое.** 160 парных пакетов = 320 прочтений; уникальных текстов 128. Причём
парная схема покупала 4 контраста, а абсолютная даёт ВСЕ 7 объявленных, включая деконфаунды.
3. **Строгость калибрована внутри единицы.** Все варианты ОДНОГО исходника судятся в одном
заходе, а контраст считается ВНУТРИ единицы и лишь затем усредняется по единицам. Дрейф
строгости между единицами в контраст не попадает — он сокращается вычитанием.
ЧЕГО ЭТО НЕ ЧИНИТ. Шум ОДНОГО вызова велик: два прогона одного и того же арма на одном входе
расходятся на sd≈2.7 ошибки по единице (контроль пола, n=16). Значит на ОТДЕЛЬНОЙ главе армы
надёжно не упорядочены — пере-прогон сдвинет счёт сильнее, чем разница между армами. Упорядочено
только среднее: парный дизайн делит шум на √n, и при 16 единицах различимый эффект ≈1.9.
⚠ Первая версия этой шапки утверждала, что эффект ниже двух не различим «никаким числом единиц».
Это неверно и снято собственным замером: порог кладёт стандартная ошибка среднего, а она падает
с ростом n. Формулировка была завышением порога и держалась ровно до того, как пол измерили.
СЛЕПОТА. Метки Т1..Тn внутри единицы, порядок перемешан хешем соли и uid: из метки не выводится
ни арм, ни то, редактированный это текст или черновик. Ключ лежит отдельно и судье не даётся.
ПЕРСИСТ. Судья пишет ответ файлом САМ, в `aj-abs/<токен>.txt`. Так убирается моя транскрипция —
тот самый канал, по которому в третьей редакции потерялись обоснования, а слепое чтение не
попало на диск вовсе. `ingest` только разбирает и проверяет; голос с ненулевой осью и пустым
обоснованием отвергается (условие 2 амендмента владельца 07.08).
Запуск: --selfcheck проверка разбора и арифметики на входах с известным ответом, $0
--emit выпустить задания судьям, $0
--ingest разобрать ответы в голоса, $0
--controls что показали контроли (читать ДО боевых чисел), $0
--score контрасты с поправкой Холма, $0
"""
from __future__ import annotations
import hashlib
import json
import re
import statistics as st
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
import bakeoff as BO # noqa: E402
import judges as J # noqa: E402
OUT = Path.home() / "books" / "role-topology"
KEYS = OUT / "blind-keys"
def _dirs():
"""Каталоги прохода. ВТОРОЙ проход обязан лежать отдельно: он и есть репликация,
и затирание первого превратило бы её в пере-запись.
⚠ `DIRS_HOOK` заведён приёмкой №16 (чек-лист фазы Д, п.9). Репликацию пака 23 гоняли,
КОПИРУЯ каталоги руками, потому что имя каталога выводилось только из `PASS`: разбор шёл под
`PASS='border'` и штамповал голоса репликации именами судей БОЕВОГО прогона. Крючок даёт
прогону собственные каталоги, не трогая имя прохода (а с ним — ключ, соль и метки).
"""
if DIRS_HOOK is not None:
return DIRS_HOOK()
return (OUT / f"aj-{PASS}-tasks", OUT / f"aj-{PASS}", OUT / f"aj-{PASS}-votes")
# ⚠ СОЛЬ ЖИВЁТ ВНЕ РЕПОЗИТОРИЯ. Адверсариальное ревью 07.08 восстановило 138 меток из 138, не
# открывая ключ: `layout()` — чистая функция от соли и `uid = sha1(source)`, а источник лежит В
# ЗАДАНИИ. Пока соль была константой в коде, слепота держалась только запретом судье читать код.
# Теперь она в файле рядом с ключом, куда судье ходить запрещено тем же правилом, что и к ключу.
def _salt(name: str = "abs") -> str:
# ⚠ Путь берётся из KEYS, а не константой: у другого пака свой каталог ключей (эксп-22).
# Для эксп-21 значение то же самое, поэтому метки и токены не сдвигаются.
f = KEYS / f"SALT-{name}.txt"
if not f.exists():
import secrets # noqa: PLC0415
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(secrets.token_hex(16), encoding="utf-8")
return f.read_text(encoding="utf-8").strip()
PASS = "abs" # переключается --emit2 на вторую пачку
SALT = _salt(PASS)
# Армы, попадающие в пачку. F3 — собственный черновик пака (текст и цена из одного вызова).
# D/D_ берутся из второй редакции СОЗНАТЕЛЬНО: однопроходки черновика не видят, их вход
# (исходник + блок закона) в обеих редакциях побайтно один, и это проверяется в selfcheck.
ARMS = ("F3", "A", "A_law", "B", "D", "D_")
# ⚠ Армы Ф2б (точечный ремонт C и обратная связка E) судятся ТЕМ ЖЕ ригом и теми же агентами.
# Иначе их числа несравнимы с Ф2а, и покупка обесценивается — это первое, что назвала
# верификация до траты. Тексты берутся из `phase2b`, всё остальное в риге не меняется.
ARMS_2B = ("C2", "E2")
# ⚠ ТОЧКИ ПОДКЛЮЧЕНИЯ ДРУГОЙ ПАНЕЛИ (заведены эксп-22, 08.08). Риг здесь — методика: абсолютная
# шкала, слепые метки, декой, пол, персист обоснования с цитатой, семейство, впечатанное в ключ
# ДО ответов, Холм. Методику пере-изобретать нельзя, а панель у следующего пака другая, поэтому
# сюда вынесены ровно пять крючков. Значения по умолчанию = поведение эксп-21 без изменений;
# это проверяется тем, что `itog.py` и `verify_report.py` после правки дают те же числа.
TEXT_HOOK = None # callable(arm, unit) -> str
UNITS_HOOK = None # callable() -> list[dict]
FLOOR_HOOK = None # callable(unit) -> tuple[str, str] | None — пара повторов одного арма
DECOY_HOOK = None # callable(slots: dict) -> str — чистый текст, в который сажается порча
SRC_LANG = "китайском" # как называется язык исходника в задании судье
DIRS_HOOK = None # callable() -> (tasks, answers, votes) — свои каталоги прогона
JUDGES_HOOK = None # callable(token, answer_mtime) -> str — кто судил ЭТОТ ответ
def configure(**kw) -> None:
"""Переключить риг на панель другого пака. Ключи — имена глобалей этого модуля."""
for k, v in kw.items():
if k not in globals():
raise SystemExit(f"⛔ absjudge.configure: неизвестный ключ {k!r}")
globals()[k] = v
def _text(arm, u):
if TEXT_HOOK is not None:
return TEXT_HOOK(arm, u)
if arm in ARMS_2B:
import phase2b as P2 # noqa: PLC0415
return P2.text_of(arm, u["uid"])
return BO.text3_of(arm, u)
def _units():
return UNITS_HOOK() if UNITS_HOOK is not None else BO.units()
# СЕМЕЙСТВО КОНТРАСТОВ, объявленное ДО первого ответа судьи. Поправка Холма считается по всем
# семи; сузить семейство после того, как увидены p-значения, — это и есть p-hacking, и именно
# за него снята первая редакция. Знак: перевес > 0 означает «ПЕРВЫЙ арм лучше».
FAMILY = [
("A", "F3", "редактор поверх черновика против самого черновика"),
("B", "F3", "другой движок-редактор поверх ТОГО ЖЕ черновика"),
("D_", "A", "однопроходка против связки черновик+редактор"),
("D", "A", "однопроходка с переливкой абзацев против связки"),
("A_law", "A", "деконфаунд: сколько покупает банкнота-как-закон"),
("A_law", "B", "два движка, оба с законом"),
("D", "D_", "деконфаунд: сколько покупает переливка абзацев"),
]
FORMAT_HEAD = """Оцени КАЖДЫЙ вариант ОТДЕЛЬНО против исходника — не сравнивая варианты между
собой. Порядок вариантов ничего не значит и специально перемешан.
Ответ ЗАПИШИ ФАЙЛОМ в {path} — ровно в таком виде и ничем больше, блок на каждую метку:
{skeleton}
ПОЧЕМУ обязательно для КАЖДОЙ ненулевой оси, и ось надо НАЗВАТЬ. Единственный принимаемый вид —
имя оси заглавными, двоеточие, цитаты в «ёлочках» через « | »; перечисляй только ненулевые оси:
Т1-ПОЧЕМУ: ВЕРНОСТЬ: «цитата» | «ещё цитата». ЯЗЫК: «цитата». ФОРМА: «цитата».
Разбор ищет имя оси буквально. Цитаты без имени оси не принимаются, и единица выбрасывается
ЦЕЛИКОМ — вся работа по ней пропадает; так уже терялась треть пачки в трёх прогонах подряд, потому
что это требование стояло в приёмке, но не в задании. Ноль по оси обоснования не требует; нечем
подпереть число цитатой — ставь ноль, а не число без цитаты.
"""
def utoken(uid: str) -> str:
return hashlib.sha1(f"{_salt(PASS)}|unit|{uid}".encode()).hexdigest()[:10] # noqa: S324
def layout(uid: str, slots: list[str]) -> list[str]:
"""Порядок меток внутри единицы: детерминированная перестановка по хешу, не по алфавиту."""
return sorted(slots, key=lambda s: hashlib.sha1(f"{_salt(PASS)}|{uid}|{s}".encode()).hexdigest()) # noqa: S324
def build_unit(u: dict) -> tuple[str, dict]:
"""Задание на одну единицу + ключ раскладки. Контроли подмешаны и неотличимы от боевых."""
# ⚠ Проход 4 узкий: только армы объявленных контрастов Ф2б плюс база сравнения. Судить
# заново все одиннадцать меток незачем — числа Ф2а уже сняты дважды и воспроизвелись.
if TEXT_HOOK is not None:
arms = tuple(ARMS)
else:
arms = ("F3", "A_law", "D_") + ARMS_2B if PASS == "abs4" else tuple(ARMS) + ARMS_2B
slots: dict[str, str] = {a: _text(a, u) for a in arms}
slots = {a: t for a, t in slots.items() if (t or "").strip()}
kinds: dict[str, str] = {a: "боевой" for a in slots}
clean = DECOY_HOOK(slots) if DECOY_HOOK is not None else (slots.get("A") or slots["A_law"])
decoy_from = next((a for a, t in slots.items() if t == clean), "")
planted, n = _plant(clean)
if n >= 2:
slots["CTRLdecoy"], kinds["CTRLdecoy"] = planted, "контроль-декой"
# ⚠ ПОЛ КЛАДЁТСЯ ПАРОЙ, а не против боевого арма `A`. Поймано чтением кода 07.08: `bo3-Aprime`
# куплен по `build_msgs` (черновик ИЗ КОРПУСА), а боевой `A` третьей редакции — по `build_msgs3`
# (СОБСТВЕННЫЙ черновик). Их входы различаются, и пол мерил бы разницу черновиков, а не шум
# вызова. Повтором `Aprime` является `bo2-A`, и именно он идёт второй половиной пары.
if FLOOR_HOOK is not None:
pair = FLOOR_HOOK(u)
if pair:
slots["CTRLfloorA"], slots["CTRLfloorB"] = pair
kinds["CTRLfloorA"] = kinds["CTRLfloorB"] = "контроль-пол"
else:
fa, fb = OUT / f"bo2-A-{u['uid']}.json", OUT / f"bo3-Aprime-{u['uid']}.json"
if fa.exists() and fb.exists():
slots["CTRLfloorA"] = json.loads(fa.read_text(encoding="utf-8"))["content"]
slots["CTRLfloorB"] = json.loads(fb.read_text(encoding="utf-8"))["content"]
kinds["CTRLfloorA"] = kinds["CTRLfloorB"] = "контроль-пол"
order = layout(u["uid"], list(slots))
label = {name: f"Т{i + 1}" for i, name in enumerate(order)}
skel = "\n".join(f"{label[n]}-ВЕРНОСТЬ: <число>\n{label[n]}-ТЕРМИН: <число>\n"
f"{label[n]}-ЯЗЫК: <число>\n{label[n]}-ФОРМА: <число>\n"
f"{label[n]}-ПОЧЕМУ: <цитаты>\n" for n in order)
tok = utoken(u["uid"])
# Шапка своя, а не `J.RUBRIC`: та написана под ПАРУ («два русских перевода», «два варианта»)
# и в абсолютной раскладке, где меток до восьми, противоречила бы заданию. Определения осей
# берутся из неё дословно — меняется только обрамление.
axes_def = J.RUBRIC.split("Посчитай ОШИБКИ", 1)[1].split("Отвечай РОВНО")[0].rstrip()
ANSW = _dirs()[1]
body = [f"Ты — литературный редактор-эксперт. Тебе дан ИСХОДНИК на {SRC_LANG} и {len(order)} "
f"независимых русских перевода этого же фрагмента под слепыми метками.",
"", "Посчитай ОШИБКИ" + axes_def,
"", FORMAT_HEAD.format(path=ANSW / f"{tok}.txt", skeleton=skel),
"", "=" * 60, "ИСХОДНИК:", u["source"]]
for n in order:
body += ["", "=" * 60, f"{label[n]}:", slots[n]]
# ⚠ `decoy_from` пишется В КЛЮЧ. Без него контроль декоя считался ПРОТИВ ЖЁСТКО ЗАШИТОГО арма
# `A`, которого у другой панели нет, и обязательный контроль молча не считался вовсе
# (поймано эксп-22 чтением контролей перед боевыми числами).
key = {label[n]: dict(arm=n, kind=kinds[n], uid=u["uid"],
sig=hashlib.sha1(slots[n].encode()).hexdigest()[:12], # noqa: S324
**({"decoy_from": decoy_from} if n == "CTRLdecoy" else {}))
for n in order}
return "\n".join(body), key
# ⚠ ПРАВИЛА ПОСАДКИ ЖИВУТ ВНЕ РЕПОЗИТОРИЯ, рядом с ключом и солью.
# Адверсариальное ревью 07.08: пока `_PLANTS` лежали в коде, декой опознавался БЕЗ соли и БЕЗ
# ключа — он побайтная копия арма `A` с 16 подменами, сходство 0.99 против 0.90 у ближайшего
# конкурента, и ориентация снималась прогоном самой посадки на обоих кандидатах (10/10 верно).
# Значит читающий код судья опознавал и контроль, и арм `A` — базу четырёх контрастов из семи.
# Вынос соли закрывал один канал из двух, и не главный. Здесь закрывается второй.
# ⚠ ПОСАДКИ ПЕРЕПИСАНЫ ЭКСП-22 НА КНИГО-НЕЗАВИСИМЫЕ. Прежний список был написан под лексику
# 蛊真人 («не выдержат», «десять», «сто», «поразительна», «спокойно») и на ДРУГОЙ книге давал
# одну сработку из шести при пороге две — декой молча не создавался, и судейская сессия уходила
# БЕЗ контроля. Поймано чтением контролей перед боевыми числами (проход `p2en` эксп-22: 0 декоев
# на 6 единиц). Это ровно ревью-вопрос проекта «заработает ли на книге, которой в репо ещё нет».
# Ниже — правила, опирающиеся на грамматику и типографику русской прозы, а не на её словарь.
_PLANTS_DEFAULT = [
[r"\е\s+([а-яё]{4,})", r"\1", "инверсия полярности: снято отрицание"],
[r"\b(один|два|три|четыре|пять|шесть|семь|восемь|девять|десять|сто|тысяч[а-яё]*)\b",
"сорок", "подмена величины"],
[r"\b(\d+)\b", "77", "подмена величины (цифрой)"],
[r"\b([а-яё]{4,})ние\b", r"\1нительние", "выдуманное слово"],
[r"\s*", '"', "сбой диалоговой вёрстки"],
[r"\n\n", " ", "слипание абзацев"],
]
def _plants() -> list:
f = KEYS / "PLANTS.json"
if not f.exists():
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(json.dumps(_PLANTS_DEFAULT, ensure_ascii=False, indent=1), encoding="utf-8")
return json.loads(f.read_text(encoding="utf-8"))
def _plant(text: str) -> tuple[str, int]:
"""Контролируемая деградация. Число РЕАЛЬНО применённых замен возвращается: клетка, где
посадить не удалось, в декой не берётся, иначе «не пойман» будет означать «не было»."""
out, n = text, 0
for pat, rep, _c in _plants():
new, cnt = re.subn(pat, rep, out, count=1)
if cnt:
out, n = new, n + cnt
return out, n
def emit() -> None:
TASKS, ANSW, VOTES = _dirs()
TASKS.mkdir(parents=True, exist_ok=True)
KEYS.mkdir(parents=True, exist_ok=True)
ANSW.mkdir(parents=True, exist_ok=True)
keys, n_tx = {}, 0
for u in _units():
body, key = build_unit(u)
tok = utoken(u["uid"])
(TASKS / f"{tok}.txt").write_text(body, encoding="utf-8")
keys[tok] = key
n_tx += len(key)
# ⚠ СЕМЕЙСТВО ВПЕЧАТЫВАЕТСЯ В КЛЮЧ, который создаётся ДО существования хоть одного ответа.
# Так порядок «объявлено → отвечено» доказывается артефактом, а не словом исполнителя.
# Заведено после того, как ревью показало: `absjudge.py` незакоммичен и датируется ПОЗЖЕ
# последнего ответа судьи, то есть провенанс проверить было нечем.
(KEYS / f"{PASS}-KEY.json").write_text(json.dumps(
dict(_семейство=[f"{a} vs {b}{w}" for a, b, w in FAMILY],
_армы=list(ARMS), _проход=PASS, **keys), ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"заданий {len(keys)}{TASKS} · прочтений {n_tx} (парная схема требовала 320)")
print(f"ключ → {KEYS}/{PASS}-KEY.json · судье НЕ давать")
def ingest() -> None:
_TASKS, ANSW, VOTES = _dirs()
if not ANSW.exists():
print(f"нет каталога ответов {ANSW}")
return
keys = {k: v for k, v in json.loads((KEYS / f"{PASS}-KEY.json").read_text(
encoding="utf-8")).items() if not k.startswith("_")}
# ⚠ ИДЕНТИЧНОСТЬ СУДЬИ. Первая версия рига её не персистировала вовсе, и по диску нельзя было
# ни посчитать согласие судей, ни разложить вердикты по семействам (требование промта стр.79).
# Поймано адверсариальным ревью 07.08. Карта раздачи лежит рядом с ключом и судье не даётся.
# ⚠ ПЛОСКАЯ КАРТА ПО ИМЕНИ ПРОХОДА НЕ РАЗЛИЧАЕТ ПРОГОНЫ (приёмка №16, чек-лист Д8 п.9).
# Репликация пака 23 судила ТЕ ЖЕ 32 токена вторым жребием судей, а карта лежит одна на
# проход — все 32 голоса репликации получили имена судей БОЕВОГО прогона. Перевесы от
# этого не двигаются (имя судьи в разность не входит), но «согласие судей» и разложение
# дисперсии по сессиям считались бы по ложной раскладке. Поэтому имя берётся крючком,
# который знает про ПРОГОН и про время ответа; плоская карта осталась дефолтом эксп-21/22.
jf = KEYS / f"{PASS}-JUDGES.json"
judges = json.loads(jf.read_text(encoding="utf-8")) if jf.exists() else {}
def judge_of(stem: str, path: Path) -> str:
if JUDGES_HOOK is not None:
return JUDGES_HOOK(stem, path.stat().st_mtime)
return judges.get(stem, "?")
VOTES.mkdir(parents=True, exist_ok=True)
ok = bad = 0
reasons: dict[str, int] = {}
def fail(r: str, stem: str = "") -> None:
nonlocal bad
bad += 1
reasons[r] = reasons.get(r, 0) + 1
# ⚠ Отказ ОБЯЗАН снимать прежний голос: иначе негодный ответ оставлял на диске
# предыдущий разбор, и `_by_unit` подхватывал его как действительный. Ревью 07.08.
if stem and (VOTES / f"{stem}.json").exists():
(VOTES / f"{stem}.json").unlink()
for f in sorted(ANSW.glob("*.txt")):
key = keys.get(f.stem)
if not key:
fail("токена нет в ключе", f.stem)
continue
txt = f.read_text(encoding="utf-8")
rows = {}
for lab, meta in key.items():
ax = {}
for a in J.AXES:
# ⚠ `[ \t]*`, а НЕ `\s*`: `\s` включает перевод строки, и пустое поле молча
# утаскивало значение СЛЕДУЮЩЕЙ строки. Поймано известным входом в selfcheck —
# голос без обоснования проходил с обоснованием, списанным с соседней строки.
m = re.search(rf"^{lab}-{a}[ \t]*:[ \t]*(\d+)[ \t]*$", txt, re.M)
if m:
ax[a] = int(m.group(1))
# обоснование может занимать несколько строк — берём до следующей метки или конца
wm = re.search(rf"^{lab}-ПОЧЕМУ[ \t]*:[ \t]*(.*?)(?=^\S+-(?:{'|'.join(J.AXES)}|ПОЧЕМУ)"
rf"[ \t]*:|\Z)", txt, re.M | re.S)
why = wm.group(1).strip() if wm else ""
if len(ax) != len(J.AXES):
fail(f"не разобран блок {lab}", f.stem)
break
# ⚠ ПОИМЁННО ПО ОСЯМ, а не «непустая строка на блок». Ревью исполнением показало:
# прежняя проверка пропускала «ПОЧЕМУ: ok» при двух ненулевых осях, то есть приёмка
# требовала МЕНЬШЕ, чем задание обещает судье. Условие 2 амендмента владельца держалось
# дисциплиной судьи, а не механизмом. Теперь каждая ненулевая ось обязана быть названа.
# ⚠ ОСЬ КАК ОТДЕЛЬНОЕ СЛОВО (иначе «ФОРМА» засчитывалась внутри «формально») И
# ОБЯЗАТЕЛЬНАЯ ЦИТАТА (иначе проходило голое имя оси и эхо скелета рубрики).
# Обе дыры найдены ревью исполнением 07.08.
up = (why or "").upper()
miss = [a for a in J.AXES if ax[a] and not re.search(rf"(?<!\w){a}(?!\w)", up)]
if miss:
fail(f"ось без обоснования: {'/'.join(miss)}", f.stem)
break
if any(ax.values()) and not re.search(r"\"“„].+?[»\"”]", why or ""):
fail("обоснование без цитаты", f.stem)
break
dup = [a for a in J.AXES
if len(re.findall(rf"^{lab}-{a}[ \t]*:", txt, re.M)) > 1]
if dup:
fail(f"блок {lab} задан дважды: {'/'.join(dup)}", f.stem)
break
rows[lab] = dict(**meta, **ax, why=why, errors=sum(ax.values()),
slot=int(lab[1:]), judge=judge_of(f.stem, f))
else:
(VOTES / f"{f.stem}.json").write_text(
json.dumps(rows, ensure_ascii=False), encoding="utf-8")
ok += 1
print(f"принято единиц {ok} · отвергнуто {bad} {reasons if reasons else ''}")
def _by_unit() -> dict[str, dict[str, dict]]:
VOTES = _dirs()[2]
out = {}
# ⚠ sorted, а не голый glob: `boot_ci` ресемплит по ПОЗИЦИЯМ списка, поэтому от порядка обхода
# каталога зависели границы доверительных интервалов — точечные оценки и p воспроизводились, а
# интервалы нет. Поймано приёмкой пака 23.
for f in sorted(VOTES.glob("*.json")):
rows = json.loads(f.read_text(encoding="utf-8"))
uid = next(iter(rows.values()))["uid"]
out[uid] = {r["arm"]: r for r in rows.values()}
return out
def _margins(a: str, b: str) -> list[float]:
"""Перевес ЗА арм `a` внутри каждой единицы: ошибки(b) ошибки(a)."""
return [d[b]["errors"] - d[a]["errors"]
for d in _by_unit().values() if a in d and b in d]
def _family_proven() -> bool:
f = KEYS / f"{PASS}-KEY.json"
if not f.exists():
return False
return json.loads(f.read_text(encoding="utf-8")).get("_семейство") is not None
def _assert_family() -> None:
"""Сверить работающее семейство с тем, что впечатано в ключ ДО ответов.
⚠ Ревью 07.08: `emit()` штамп ставил, а `score()` с ним не сверялся — штамп был декоративен,
и правка `FAMILY` после выпуска ничем не ловилась. Провенанс замыкается здесь.
"""
f = KEYS / f"{PASS}-KEY.json"
if not f.exists():
return
decl = json.loads(f.read_text(encoding="utf-8")).get("_семейство")
if decl is None:
print(f"⚠ в ключе {f.name} семейство НЕ впечатано — провенанс этого прохода не доказуем")
return
now = [f"{a} vs {b}{w}" for a, b, w in FAMILY]
if decl != now:
raise SystemExit("⛔ СЕМЕЙСТВО РАЗОШЛОСЬ с впечатанным в ключ ДО ответов:\n"
f" в ключе: {decl}\n в коде: {now}")
if PASS == "abs4":
# ⚠ ЧЕСТНОСТЬ ПРОВЕНАНСА. В ключ прохода 4 впечатано семейство Ф2а — оно на узкой панели
# НЕПРИМЕНИМО (там нет арма `A`, и ни один его контраст не собирается). Сверка выше
# проходит, но она ничего не доказывает для тех контрастов, которые здесь считаются.
# Молчать об этом — значит выдать декоративный штамп за провенанс.
print("⚠ ПРОВЕНАНС ПРОХОДА 4 НЕПОЛОН: в ключ впечатано семейство Ф2а, неприменимое к узкой\n"
" панели. Считается пятёрка Ф2б (`FAMILY_2B`), объявленная в `phase2b.FAMILY` ДО\n"
" покупок с одной заменой: недоступный судье `C_oracle/C_real` → контроль `A_law/F3`.\n")
# СЕМЕЙСТВО УЗКОГО ПРОХОДА 4 (армы Ф2б). ⚠ Заведено 08.08 аудитом: `--score4` печатал ПУСТУЮ
# таблицу («поправка Холма по 0 контрастам») — ни один контраст `FAMILY` не имеет обоих армов на
# узкой панели (там нет `A`), и у таблицы п.7 отчёта не было пути воспроизведения в дереве.
# ⚠ ОТКЛОНЕНИЕ ОТ ПРЕ-РЕГА, объявляю: `phase2b.FAMILY` пятым членом называет `C_oracle vs C_real`
# — разрыв потолка и пола схемы. Он НЕ судейский (меряется посаженными дефектами и детекторами,
# судье не предъявлялся, p не имеет). В судейскую пятёрку вместо него вошёл контроль `A_law/F3`.
# Подмена КОНСЕРВАТИВНА — член семейства сохранён, поправка не ослаблена, — но это правка
# семейства после пре-рега, и читать её надо как девиацию, а не как замысел.
FAMILY_2B = [
("C2", "A_law", "точечный ремонт против полного переписывания"),
("C2", "F3", "и лучше ли он черновика, который чинит"),
("E2", "D_", "обратная связка против своей базы"),
("E2", "A_law", "обратная связка против прямой"),
("A_law", "F3", "КОНТРОЛЬ: воспроизводится ли эффект редактора"),
]
def score() -> None:
_assert_family()
data = _by_unit()
if not data:
print("голосов нет — сначала --ingest")
return
fam = FAMILY_2B if PASS == "abs4" else FAMILY
rows = []
for a, b, what in fam:
m = _margins(a, b)
if len(m) < 3:
continue
rows.append((a, b, what, m, BO.sign_perm_p(m)))
order = sorted(range(len(rows)), key=lambda i: rows[i][4])
holm, k = {}, len(rows)
run = 0.0
for rank, i in enumerate(order):
run = max(run, min(1.0, rows[i][4] * (k - rank)))
holm[i] = run
prov = ("⚠ штамп в ключе относится к семейству Ф2а и к этим контрастам НЕприменим" if PASS == "abs4"
else "семейство впечатано в ключ ДО ответов" if _family_proven()
else "⚠ провенанс семейства НЕ доказуем: в ключе штампа нет")
print(f"единиц с голосами: {len(data)} · {prov}, поправка Холма по {k} контрастам\n")
print(f"{'контраст':14s}{'ед.':>4s}{'перевес':>9s}{'95% ДИ':>18s}{'p':>8s}{'p Холма':>9s} что меряет")
for i, (a, b, what, m, p) in enumerate(rows):
lo, hi = BO.boot_ci(m)
mark = "" if holm[i] < 0.05 else " "
print(f"{a + ' vs ' + b:14s}{len(m):4d}{st.mean(m):+9.2f} [{lo:+.2f}, {hi:+.2f}]"
f"{p:8.4f}{holm[i]:9.4f}{mark} {what}")
print("\n⚠ ПОРОГ КЛАДЁТ ПОЛ, А НЕ SD. Шум одного вызова sd≈2.7 на единицу — это значит, что на"
"\n ОТДЕЛЬНОЙ главе армы надёжно не упорядочены: пере-прогон того же арма сдвинет счёт"
"\n сильнее, чем разница между армами. Упорядочено только СРЕДНЕЕ по единицам.")
def axes() -> None:
"""Разложение контрастов по осям. Заведено потому, что в третьей редакции ОДНА ось (ФОРМА)
переворачивала знак вывода: она штрафует слипание абзацев, а слипание есть прямое исполнение
мандата дискурс-перевёрстки боевого `editor.md`. Сумма это прячет, разложение — показывает."""
data = _by_unit()
if not data:
print("голосов нет — сначала --ingest")
return
print(f"{'контраст':14s}" + "".join(f"{a:>12s}" for a in J.AXES) + f"{'сумма':>10s}")
for a, b, _w in FAMILY:
row = []
for ax in J.AXES:
m = [d[b][ax] - d[a][ax] for d in data.values() if a in d and b in d]
# ⚠ Критерий значимости — ТОЧНЫЙ знаковый тест, а не «бутстрап-ДИ не накрывает ноль».
# Первая версия метила звёздочку по ДИ; адверсариальное ревью исполнением показало,
# что так помечались значимыми три клетки с p>0.05 (перцентильный бутстрап при n=16
# накрывает ~93%, а не 95%). ДИ отвечает «где лежит среднее», а не «отличимо ли от нуля».
row.append(f"{st.mean(m):+7.2f}{'*' if BO.sign_perm_p(m) < 0.05 else ' '} ")
tot = _margins(a, b)
print(f"{a + ' vs ' + b:14s}" + "".join(row) + f"{st.mean(tot):+10.2f}")
print("\n* = p<0.05 точного знакового теста, БЕЗ поправки на 28 клеток. Разложение —"
"\n разведочное: `score` корректирует на 7 гипотез, здесь их 28. Читать как направление,"
"\n не как подтверждение. Перевес > 0 ⇒ ПЕРВЫЙ арм лучше по этой оси.")
def position() -> None:
"""Остаточная позиционная фора: влияет ли НОМЕР МЕТКИ внутри задания на счёт.
⚠ Заведено 07.08 адверсариальным ревью. Шапка рига утверждала, что «позиция исчезает по
построению», и контроля не ставила вовсе — регрессия против третьей редакции, где
`bakeoff.position_bias()` был. Утверждение неверно: меток в задании 89, порядок один,
и хеш-перестановка НЕ сбалансирована по армам. Здесь наклон измеряется, а контрасты
печатаются с поправкой на него — чтобы видеть, какие выводы от раскладки зависят.
"""
data = _by_unit()
if not data:
print("голосов нет — сначала --ingest")
return
xs, ys = [], []
for d in data.values():
mx = st.mean([r["slot"] for r in d.values()])
my = st.mean([r["errors"] for r in d.values()])
for r in d.values():
xs.append(r["slot"] - mx)
ys.append(r["errors"] - my)
slope = sum(a * b for a, b in zip(xs, ys, strict=True)) / sum(a * a for a in xs)
print(f"наклон {slope:+.3f} ошибки на шаг метки (внутри единицы, n={len(xs)})")
print("средняя позиция арма: " + " · ".join(
f"{a} {st.mean([d[a]['slot'] for d in data.values() if a in d]):.2f}"
for a in ARMS))
print(f"\n{'контраст':14s}{'перевес':>9s}{'Δметки':>8s}{'вклад':>8s}{'с поправкой':>13s}{'p':>9s}")
for a, b, _w in FAMILY:
m = [d[b]["errors"] - d[a]["errors"] for d in data.values() if a in d and b in d]
dl = [d[a]["slot"] - d[b]["slot"] for d in data.values() if a in d and b in d]
corr = [mi + slope * x for mi, x in zip(m, dl, strict=True)]
print(f"{a + ' vs ' + b:14s}{st.mean(m):+9.2f}{st.mean(dl):+8.2f}"
f"{-slope * st.mean(dl):+8.2f}{st.mean(corr):+13.2f}{BO.sign_perm_p(corr):9.4f}")
print("\nВывод, зависящий от раскладки, — тот, чей знак или значимость меняются поправкой.")
def pool() -> None:
"""Свод ДВУХ проходов и разложение шума. $0.
⚠ Заведено адверсариальным ревью 07.08: этот анализ считался ВНЕ зафризенного дерева, то есть
невоспроизводим из репозитория — тот же класс дефекта, за который снята первая редакция,
только для чисел, а не для денег. Касса отказывает незамороженному коду в ПОКУПКАХ, но
анализ она не сторожит; сторожит только нахождение кода здесь.
⚠ АЛГЕБРА РАЗЛОЖЕНИЯ, дважды исправленная.
пол = две РАЗНЫЕ генерации одного арма, ОДИН судья, одна единица
⇒ var = 2σ²ген + 2σ²идиосинкр; УРОВЕНЬ судьи сокращается точно.
кросс = ОДИН И ТОТ ЖЕ текст, ДВА разных судьи
⇒ var = 2σ²идиосинкр + var(разница УРОВНЕЙ).
Если брать кросс СЫРЫМ, разница уровней вычитается из пола как если бы это был шум судьи,
и σ²ген выходит заниженной. Уровень снимается центрированием внутри единицы и прохода.
Первая публикация этого числа («судья 73%, генерация 27%») сделана на сыром кроссе и неверна.
"""
a1, a2 = _by_unit_pass("abs"), _by_unit_pass("abs2")
common = sorted(set(a1) & set(a2))
if not common:
print("нужны оба прохода")
return
sig_ok = sum(1 for u in common for k in a1[u] if k in a2[u] and a1[u][k]["sig"] == a2[u][k]["sig"])
n_cells = sum(1 for u in common for k in a1[u] if k in a2[u])
print(f"единиц в обоих проходах {len(common)} · клеток {n_cells} · "
f"подписи текстов совпали {sig_ok}/{n_cells}"
f"{' ⛔ СУДИЛИСЬ РАЗНЫЕ ТЕКСТЫ' if sig_ok != n_cells else ''}")
E = lambda d, u, k: d[u][k]["errors"] # noqa: E731
flo = [E(d, u, "CTRLfloorA") - E(d, u, "CTRLfloorB")
for d in (a1, a2) for u in common if "CTRLfloorA" in d[u]]
cen = []
for u in common:
m1 = st.mean([r["errors"] for r in a1[u].values()])
m2 = st.mean([r["errors"] for r in a2[u].values()])
cen += [(E(a1, u, k) - m1) - (E(a2, u, k) - m2) for k in a1[u] if k in a2[u]]
# ⚠ ПОПРАВКА НА СТЕПЕНИ СВОБОДЫ. Кросс-разности центрированы ВНУТРИ единицы (89 клеток),
# поэтому `pvariance` занижает σ судьи ~на 11%, и печатный вердикт о доминирующей компоненте
# переворачивается. Категорического вердикта нет ни в какую сторону — разрыв меньше смещения.
k = st.mean([len(v) for v in a1.values()])
vf, vc = st.pvariance(flo), st.pvariance(cen) * k / max(1.0, k - 1)
s_j, s_g = (vc / 2) ** 0.5, max(0.0, (vf - vc) / 2) ** 0.5
print(f"\nпол var {vf:.2f} (n={len(flo)}) = 2σ²ген + 2σ²судья")
print(f"кросс var {vc:.2f} (n={len(cen)}) = 2σ²судья, уровень снят центрированием")
print(f"σ судьи {s_j:.2f} · σ генерации {s_g:.2f}"
f"доминирует {'ГЕНЕРАЦИЯ' if s_g > s_j else 'СУДЬЯ'}")
print("\nчто эффективнее для точности контраста:")
for n, k, lbl in ((len(common), 1, "как есть"), (len(common), 2, "×2 судьи ($0)"),
(2 * len(common), 1, "×2 единицы (платно)")):
print(f" {lbl:22s} SE={((2 * s_g ** 2 + 2 * s_j ** 2 / k) / n) ** 0.5:.3f}")
rows = []
for a, b, w in FAMILY:
m = [st.mean([E(a1, u, b) - E(a1, u, a), E(a2, u, b) - E(a2, u, a)]) for u in common]
rows.append((a, b, w, m, BO.sign_perm_p(m)))
order = sorted(range(len(rows)), key=lambda i: rows[i][4])
holm, run = {}, 0.0
for rank, i in enumerate(order):
run = max(run, min(1.0, rows[i][4] * (len(rows) - rank)))
holm[i] = run
print(f"\n{'контраст':14s}{'перевес':>9s}{'95% ДИ':>19s}{'p':>9s}{'p Холма':>10s}{'вылетов LOO':>13s}")
for i, (a, b, _w, m, pv) in enumerate(rows):
lo, hi = BO.boot_ci(m)
loo = sum(1 for k in range(len(m))
if BO.sign_perm_p(m[:k] + m[k + 1:]) * len(rows) >= 0.05) if holm[i] < 0.05 else None
print(f"{a + ' vs ' + b:14s}{st.mean(m):+9.2f} [{lo:+.2f}, {hi:+.2f}]{pv:9.4f}{holm[i]:10.4f}"
f"{'' if holm[i] < 0.05 else ' '}{(f'{loo}/{len(m)}' if loo is not None else ''):>12s}")
def _by_unit_pass(name: str) -> dict:
global PASS # noqa: PLW0603
sv = PASS
PASS = name
try:
return _by_unit()
finally:
PASS = sv
def judges_agree() -> None:
"""Согласие судей и разброс порогов. Возможно только после персиста идентичности судьи."""
data = _by_unit()
per: dict[str, list[float]] = {}
for d in data.values():
j = next(iter(d.values()))["judge"]
per.setdefault(j, []).append(st.mean([r["errors"] for r in d.values()]))
if len(per) < 2:
print("идентичность судьи не персистирована — согласие не считается")
return
lv = {j: st.mean(v) for j, v in per.items()}
print(f"судей {len(per)} · средний уровень строгости по судьям: "
f"{min(lv.values()):.2f}{max(lv.values()):.2f} (разброс {max(lv.values()) - min(lv.values()):.2f})")
print(" " + " · ".join(f"{j} {v:.2f}" for j, v in sorted(lv.items())))
print("\n⚠ Разброс порогов между судьями — причина, по которой контраст считается ВНУТРИ")
print(" единицы: единицу целиком судит ОДИН судья, и его порог сокращается вычитанием.")
def controls() -> None:
data = _by_unit()
if not data:
print("голосов нет — сначала --ingest")
return
print("КОНТРОЛИ САМОГО СУДЬИ — читать ДО боевых чисел\n")
# База декоя берётся ИЗ КЛЮЧА (`decoy_from`), а не из имени арма: панель у каждого пака своя.
dec = [d[d["CTRLdecoy"].get("decoy_from", "A")]["errors"] - d["CTRLdecoy"]["errors"]
for d in data.values()
if "CTRLdecoy" in d and d["CTRLdecoy"].get("decoy_from", "A") in d]
if dec:
print(f" ДЕКОЙ посаженная деградация против чистого: {st.mean(dec):+.2f} "
f"· поймано {sum(1 for x in dec if x < 0)}/{len(dec)} (нужен отрицательный знак)")
flo = [d["CTRLfloorA"]["errors"] - d["CTRLfloorB"]["errors"]
for d in data.values() if "CTRLfloorA" in d and "CTRLfloorB" in d]
if flo:
lo, hi = BO.boot_ci(flo)
print(f" ПОЛ два вызова ОДНОГО арма на одном входе: {st.mean(flo):+.2f} "
f"[{lo:+.2f}, {hi:+.2f}] n={len(flo)} · истинная разница НОЛЬ")
# ⚠ sd по единицам — это НЕ порог различимости. Порог кладёт стандартная ошибка
# СРЕДНЕГО, то есть sd/√n, и парный дизайн именно этим и полезен. Первая печать этого
# блока написала «перевес меньше sd неотличим» — завышение порога вдвое-втрое,
# снятое до того, как попало в отчёт.
sd = st.pstdev(flo)
se = sd / len(flo) ** 0.5
print(f" шум одного вызова: sd по единицам {sd:.2f} · ст.ошибка среднего "
f"{se:.2f} при n={len(flo)}")
# ⚠ ВЕРДИКТ, а не только числа. Ревью исполнением: заведомо сломанный пол (сдвиг +6 на
# каждой единице) печатался тем же видом, что честный ноль, и вдобавок объявлял
# «различимый эффект ≈0.00», потому что sd при систематическом сдвиге равен нулю.
# критерий — ТОЧНЫЙ знаковый тест, как в `axes`. Прежде здесь стоял бутстрап-ДИ, от
# которого `axes` уже отказался: непоследовательность, названная ревью 07.08.
ok = BO.sign_perm_p(flo) >= 0.05
print(f" ⇒ различимый средний эффект ≈ {2.8 * se:.2f} (80% мощности); "
"sd сам по себе порогом НЕ является — парный дизайн делит его на √n")
print(f" ВЕРДИКТ: {'пол ЧЕСТЕН — ноль внутри интервала' if ok else '⛔ ПОЛ СМЕЩЁН: ноль ВНЕ интервала, боевые числа снимаются'}")
if sd < 1e-9:
print(" ⛔ нулевой sd при ненулевом среднем = систематический сдвиг, не шум")
twin = [(u[:8], x["arm"], y["arm"], x["errors"] - y["errors"])
for uid, d in data.items() for u in [uid]
for x in d.values() for y in d.values()
if x["arm"] < y["arm"] and x["sig"] == y["sig"]]
if twin:
bad = [t for t in twin if t[3] != 0]
print(f" БЛИЗНЕЦ побайтно одинаковых пар: {len(twin)} · "
f"перевес ровно ноль в {len(twin) - len(bad)}/{len(twin)}")
for t in bad:
print(f"{t[0]} {t[1]}/{t[2]} разошлись на {t[3]}")
else:
print(" БЛИЗНЕЦ побайтно одинаковых текстов в пачке нет")
# --------------------------------------------------------------------------------------------
def selfcheck() -> int:
"""Проверка ИСПОЛНЕНИЕМ: разбор, арифметика перевеса и Холм на входах с известным ответом."""
bad = 0
def chk(name: str, got, want) -> None:
nonlocal bad
ok = got == want
bad += not ok
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + ("" if ok else f" получено {got!r}, ждали {want!r}"))
# 1. Однопроходки: вход второй и третьей редакции обязан совпадать побайтно, иначе брать
# их тексты из bo2 нельзя.
u = BO.units()[0]
for arm in ("D", "D_"):
chk(f"вход {arm} одинаков в обеих редакциях",
BO.build_msgs(arm, u) == BO.build_msgs3(arm, u), True)
# Пол обязан быть ПАРОЙ ПОВТОРОВ. Проверяется тем, что вход боевого `A` третьей редакции
# ОТЛИЧАЕТСЯ от входа пары пола: если бы совпадал, пол можно было бы брать против боевого.
chk("вход боевого A ≠ вход пары пола (иначе пол мерил бы черновик)",
BO.build_msgs3("A", u) != BO.build_msgs("A", u), True)
for u2 in _units():
k = build_unit(u2)[1]
arms_in = {v["arm"] for v in k.values()}
if "CTRLfloorA" in arms_in:
chk("пол лежит парой, а не одиночкой", "CTRLfloorB" in arms_in, True)
break
# 2. Раскладка: перестановка детерминирована и не алфавитна.
sl = ["F3", "A", "A_law", "B", "D", "D_"]
chk("раскладка детерминирована", layout("u1", sl), layout("u1", sl))
chk("раскладка зависит от единицы", layout("u1", sl) != layout("u2", sl), True)
# 3. Разбор: ненулевая ось без обоснования отвергается, нулевая — принимается.
import tempfile # noqa: PLC0415
global _dirs, KEYS # noqa: PLW0603
sv = (_dirs, KEYS)
d = Path(tempfile.mkdtemp())
VOTES, ANSW, KEYS = d / "v", d / "a", d / "k"
for q in (VOTES, ANSW, KEYS):
q.mkdir(parents=True)
_dirs = lambda: (d / "t", ANSW, VOTES) # noqa: E731
key = {"tok": {"Т1": dict(arm="A", kind="боевой", uid="u1", sig="aa"),
"Т2": dict(arm="B", kind="боевой", uid="u1", sig="bb")}}
(KEYS / f"{PASS}-KEY.json").write_text(json.dumps(key), encoding="utf-8")
def answer(t1: tuple, w1: str, t2: tuple, w2: str) -> None:
(ANSW / "tok.txt").write_text(
"".join(f"Т{i}-{a}: {v}\n" for i, t in ((1, t1), (2, t2))
for a, v in zip(J.AXES, t, strict=True)) .replace("Т1-ФОРМА", "Т1-ФОРМА")
+ f"Т1-ПОЧЕМУ: {w1}\nТ2-ПОЧЕМУ: {w2}\n", encoding="utf-8")
answer((1, 0, 0, 0), "", (0, 0, 0, 0), "")
ingest()
chk("ненулевая ось без цитаты отвергнута", list(VOTES.glob("*.json")), [])
answer((1, 0, 0, 0), "ВЕРНОСТЬ: «кривая фраза»", (0, 0, 0, 0), "")
ingest()
chk("нулевая ось без цитаты принята", len(list(VOTES.glob("*.json"))), 1)
chk("перевес за A при 1 ошибке у A и 0 у B", _margins("A", "B"), [-1.0])
chk("перевес за B зеркален", _margins("B", "A"), [1.0])
# 3а. РЕГРЕССИЯ на найденный дефект: `\s` включает перевод строки, и пустое поле утаскивало
# значение соседней строки. Проверяется на обеих ролях — числовой и обосновании.
for f in VOTES.glob("*.json"):
f.unlink()
(ANSW / "tok.txt").write_text(
"Т1-ВЕРНОСТЬ: 2\nТ1-ТЕРМИН: 0\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\nТ1-ПОЧЕМУ:\n"
"Т2-ВЕРНОСТЬ: 0\nТ2-ТЕРМИН: 0\nТ2-ЯЗЫК: 0\nТ2-ФОРМА: 0\nТ2-ПОЧЕМУ:\n", encoding="utf-8")
ingest()
chk("пустое ПОЧЕМУ не утаскивает соседнюю строку", list(VOTES.glob("*.json")), [])
(ANSW / "tok.txt").write_text(
"Т1-ВЕРНОСТЬ:\nТ1-ТЕРМИН: 5\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\nТ1-ПОЧЕМУ: ТЕРМИН «а»\n"
"Т2-ВЕРНОСТЬ: 0\nТ2-ТЕРМИН: 0\nТ2-ЯЗЫК: 0\nТ2-ФОРМА: 0\nТ2-ПОЧЕМУ:\n", encoding="utf-8")
ingest()
chk("пустое число не утаскивает соседнюю строку", list(VOTES.glob("*.json")), [])
(ANSW / "tok.txt").write_text(
"Т1-ВЕРНОСТЬ: 1\nТ1-ТЕРМИН: 0\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\n"
"Т1-ПОЧЕМУ: ВЕРНОСТЬ «первая строка»\n и «вторая строка»\n"
"Т2-ВЕРНОСТЬ: 0\nТ2-ТЕРМИН: 0\nТ2-ЯЗЫК: 0\nТ2-ФОРМА: 0\nТ2-ПОЧЕМУ:\n", encoding="utf-8")
ingest()
got = json.loads((VOTES / "tok.json").read_text(encoding="utf-8"))["Т1"]["why"]
chk("многострочное обоснование взято целиком", "вторая строка" in got, True)
# 3б. РЕГРЕССИЯ: обоснование обязано НАЗЫВАТЬ каждую ненулевую ось, а не быть просто непустым.
for f in VOTES.glob("*.json"):
f.unlink()
(ANSW / "tok.txt").write_text(
"Т1-ВЕРНОСТЬ: 3\nТ1-ТЕРМИН: 4\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\nТ1-ПОЧЕМУ: ВЕРНОСТЬ «а»\n"
"Т2-ВЕРНОСТЬ: 0\nТ2-ТЕРМИН: 0\nТ2-ЯЗЫК: 0\nТ2-ФОРМА: 0\nТ2-ПОЧЕМУ:\n", encoding="utf-8")
ingest()
chk("ненулевая ось, НЕ названная в обосновании, отвергнута", list(VOTES.glob("*.json")), [])
(ANSW / "tok.txt").write_text(
"Т1-ВЕРНОСТЬ: 3\nТ1-ТЕРМИН: 4\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\n"
"Т1-ПОЧЕМУ: ВЕРНОСТЬ «а» | ТЕРМИН «б»\n"
"Т2-ВЕРНОСТЬ: 0\nТ2-ТЕРМИН: 0\nТ2-ЯЗЫК: 0\nТ2-ФОРМА: 0\nТ2-ПОЧЕМУ:\n", encoding="utf-8")
ingest()
chk("обе ненулевые оси названы — принято", len(list(VOTES.glob("*.json"))), 1)
# 3в. РЕГРЕССИИ на дыры приёмки, найденные ревью исполнением 07.08.
def one(body: str) -> None:
for f in VOTES.glob("*.json"):
f.unlink()
(ANSW / "tok.txt").write_text(
body + "Т2-ВЕРНОСТЬ: 0\nТ2-ТЕРМИН: 0\nТ2-ЯЗЫК: 0\nТ2-ФОРМА: 0\nТ2-ПОЧЕМУ:\n",
encoding="utf-8")
ingest()
one("Т1-ВЕРНОСТЬ: 3\nТ1-ТЕРМИН: 0\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\nТ1-ПОЧЕМУ: ВЕРНОСТЬ\n")
chk("голое имя оси без цитаты отвергнуто", list(VOTES.glob("*.json")), [])
one("Т1-ВЕРНОСТЬ: 0\nТ1-ТЕРМИН: 0\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 4\n"
"Т1-ПОЧЕМУ: формально «а»\n")
chk("ось как подстрока слова не засчитана", list(VOTES.glob("*.json")), [])
one("Т1-ВЕРНОСТЬ: 1\nТ1-ВЕРНОСТЬ: 7\nТ1-ТЕРМИН: 0\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\n"
"Т1-ПОЧЕМУ: ВЕРНОСТЬ «а»\n")
chk("дубль блока пойман", list(VOTES.glob("*.json")), [])
one("Т1-ВЕРНОСТЬ: 1\nТ1-ТЕРМИН: 0\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\nТ1-ПОЧЕМУ: ВЕРНОСТЬ «а»\n")
chk("годный ответ принят", len(list(VOTES.glob("*.json"))), 1)
one("Т1-ВЕРНОСТЬ: 3\nТ1-ТЕРМИН: 0\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\nТ1-ПОЧЕМУ: ВЕРНОСТЬ\n")
chk("отказ СНЯЛ прежний годный голос", list(VOTES.glob("*.json")), [])
one("Т1-ВЕРНОСТЬ: 7\nТ1-ТЕРМИН: 0\nТ1-ЯЗЫК: 0\nТ1-ФОРМА: 0\nТ1-ПОЧЕМУ: ВЕРНОСТЬ «а»\n")
# 4. Близнец: одинаковая подпись при разном счёте обязана быть поймана.
key["tok"]["Т2"]["sig"] = "aa"
(KEYS / f"{PASS}-KEY.json").write_text(json.dumps(key), encoding="utf-8")
ingest()
import io # noqa: PLC0415
import contextlib # noqa: PLC0415
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
controls()
chk("расхождение близнецов поймано", "разошлись на 7" in buf.getvalue(), True)
_dirs, KEYS = sv
# 5. Холм: на семи одинаковых p поправка обязана умножать наибольший на 7.
chk("Холм монотонен", min(1.0, 0.01 * 7) > min(1.0, 0.01 * 1), True)
print(f"\n{'ОСНАСТКА ЧИСТА' if not bad else f'ПРОВАЛОВ: {bad}'}")
return bad
if __name__ == "__main__":
a = sys.argv[1:] or ["--selfcheck"]
if a[0][-1] in "34":
globals()["PASS"] = "abs" + a[0][-1]
a[0] = a[0][:-1]
if a[0] in ("--emit2", "--ingest2", "--score2", "--controls2", "--position2", "--judges2",
"--axes2", "--agree"):
PASS = "abs2" # noqa: PLW0127
globals()["PASS"] = "abs2"
a[0] = a[0].replace("2", "") if a[0] != "--agree" else a[0]
fn = {"--selfcheck": lambda: sys.exit(1 if selfcheck() else 0), "--emit": emit,
"--ingest": ingest, "--score": score, "--controls": controls, "--axes": axes, "--position": position, "--judges": judges_agree, "--pool": pool}.get(a[0])
fn() if fn else print(__doc__)