textmachine/eval/dovodka/sud.py

492 lines
39 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 — генерация заданий и разбор ответов, платных вызовов нет.
Судей ДВА и оба бесплатны (решение владельца 11.08 дословно: «НИКАКИХ ВЫЗОВОВ ПО АПИ, всё
локально у нас на харнесах»): агент-сессии и локальный харнесс Sol 5-6 владельца — второе мнение
о судействе первого и о его слепоте. Правило расхождения пре-регистрировано в Д0.13 П-1 и здесь
не меняется: расхождение разрешается АДЪЮДИКАЦИЕЙ находок, а не выбором удобного семейства.
Риг не пере-изобретается: `absjudge` эксп-21 настраивается через `configure` — тем же приёмом,
которым пользовались паки 22 и 23. Методика (абсолютная шкала · слепые метки · декой · пол ·
обоснование цитатой · семейство, впечатанное в ключ ДО ответов · Холм) остаётся общей.
⚠⚠ ТРИ МЕСТА, ГДЕ ФАЗА Д ОТСТУПАЕТ ОТ УНАСЛЕДОВАННОГО РИГА, И ПОЧЕМУ.
1. ПОЛ РАЗВОДИТСЯ ПО РАЗНЫМ ПАЧКАМ. `absjudge.build_unit` кладёт обе половины пола (`CTRLfloorA`
и `CTRLfloorB`) в ОДНУ пачку — значит их судит одна сессия, и межсессионная компонента в пол
не попадает вовсе. Ровно так эксп-23 занизил порог различимости на 19% (§12б). Норма Д0.6
требует обратного: «шумовой пол — парой, чьи половины судят РАЗНЫЕ сессии». Поэтому здесь
пачки выпускаются ДВУМЯ наборами: `П1` несёт первую половину каждой пары, `П2` — вторую, и
наборы отдаются разным сессиям. Вторая норма («все армы единицы — в одном пакете») при этом
не нарушается: боевые армы целиком лежат в каждом наборе.
2. ДЕКОЕВ ДВА, А НЕ ОДИН. Грубый (`CTRLdecoy`, штатный механизм рига) аннулирует семейство, если
не пойман. Маржевый (`CTRLmargin`, заведён фазой Д по П-2) несёт ДВЕ ТОНКИЕ посадки классов,
реально пропущенных судьёй в проходе `tier`. Он проверяет то, чего грубый не проверяет вовсе:
способность прибора видеть эффект РАЗМЕРОМ С МАРЖУ эквивалентности. Без него вывод «не хуже»
неотличим от слепоты прибора — это assay sensitivity, требование, без которого вердикт
non-inferiority пуст. **Семейство, чей перевес на маржевом декое не пересекает его собственный
порог, теряет право говорить «не хуже» и понижается до описательного.**
Маржевый декой заводится ПСЕВДО-АРМОМ через `TEXT_HOOK`, а не правкой `absjudge`: рig общий с
паками 2123, и правка его тела потребовала бы пере-снятия их гейтов.
3. ИЗОЛЯЦИЯ ПЛЮС ЖУРНАЛ, А НЕ ОДНА ИЗОЛЯЦИЯ. Пре-рег Д0.12 писал, что постфактум-детектора
подглядывания не существует. Для внешнего харнесса это верно, для агент-сессий — НЕТ, и это
проверено исполнением: оплаченная клетка несёт поле `arm` открытым текстом рядом с `content`,
поэтому слепая метка деанонимизируется ОДНИМ грепом по фрагменту варианта; а транскрипт
агент-сессии логирует вызовы инструментов, то есть аудит «куда лазил судья» возможен. Поэтому
задания уходят в каталог, где нет ничего кроме заданий, и после каждой пачки гоняется
греп-аудит транскриптов (`--audit`).
Запуск: sud.py --selftest | --emit | --audit <каталог-транскриптов> | --floor
"""
from __future__ import annotations
import importlib.util
import json
import re
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"):
sys.path.insert(0, str(REPO / "eval" / d))
def _load(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
C = _load("contour", ZONE / "contour.py")
import absjudge as AJ # noqa: E402
# ⚠ РАБОЧИЙ КАТАЛОГ СУДЬИ — ВНЕ `~/books/dovodka`. Рядом с заданиями не должно лежать ни ключа
# слепых меток, ни сырья с полем `arm`, ни кода. Ключи живут ОТДЕЛЬНО и судье не выдаются.
# ⚠ ОСЬ — ПАРАМЕТР. Каталоги, ключи, состав панели и семейство контрастов у английской оси свои,
# и класть её пачки рядом с китайскими нельзя: судья, увидевший обе, перестаёт быть слепым к
# паре, а ключи разных осей в одном каталоге — прямая дорога к разбору чужой приманки (класс,
# уже пойманный дважды: `itog_d4.py:53` и селфтест этого файла).
AXIS = next((a.split("=", 1)[1] for a in sys.argv if a.startswith("--axis=")), "d4")
if AXIS not in ("d4", "d3", "d6", "d1"):
raise SystemExit(f"⛔ ось {AXIS} харнессу неизвестна")
# ⚠ СЕМЕЙСТВО — ПАРАМЕТР. Пре-рег Д0.12 объявил ДВА судейских семейства; на осях фазы Д пачки
# готовились только своему. `--fam=sol` кладёт пачки в каталог внешнего харнесса владельца.
FAM = next((a.split("=", 1)[1] for a in sys.argv if a.startswith("--fam=")), "claude")
# ⚠ Судейское сырьё живёт в РЕПОЗИТОРИИ КНИГ (`~/books/judging/`), а не в домашнем каталоге:
# ответы судей — такой же артефакт замера, как купленные клетки, и терялись бы вне git.
WORK = Path.home() / "books" / "judging" / (f"sud-{AXIS}" if FAM == "claude" else f"sol-{AXIS}-work")
TASKS1, TASKS2 = WORK / "p1-tasks", WORK / "p2-tasks"
ANSW1, ANSW2 = WORK / "p1-answers", WORK / "p2-answers"
VOTES = WORK / "votes"
# ⚠ КЛЮЧИ РАЗВЕДЕНЫ ПО СЕМЕЙСТВАМ, и это не аккуратность, а защита данных. Раскладка меток —
# чистая функция от соли, uid и НАБОРА армов (`absjudge.layout`), поэтому любая эмиссия с другим
# составом панели или другим контролем ПЕРЕПИСЫВАЕТ ключ. Пока каталог был общим, эмиссия пачек
# для второго семейства обвалила бы разбор ответов первого: проверено исполнением 15.08 —
# вердикт `E0/D0` съехал с +1.56 (перешёл порог) на +0.38 (ниже порога), и восстановился только
# потому, что СОЛЬ лежит отдельным файлом и эмиссией не трогается. Своя соль у семейства = свой
# независимый порядок меток, что вдобавок правильнее: два семейства не должны видеть одну
# раскладку.
KEYS = Path.home() / "books" / "dovodka" / (f"blind-keys-{AXIS}" if FAM == "claude"
else f"blind-keys-{AXIS}-{FAM}")
# ⚠ ПЕРЕВОДИМ РИГ НА КЛЮЧИ ФАЗЫ СРАЗУ, а не только внутри `emit`. Селфтест зовёт `AJ._plant`
# напрямую и при дефолтном `AJ.KEYS` строил приманку ПОСАДКАМИ ЧУЖОГО ПАКА (7 против 6, правила
# разные) — то есть гейт «грубый декой грубее маржевого» сертифицировал не тот текст, который
# реально видели судьи. Ровно этот класс уже был пойман в своде (`itog_d4.py:53`), здесь остался.
AJ.KEYS = KEYS
# Армы панели Д4. `A1` (только канон-флаги) — описательный, в первичное семейство не входит.
ARMS_D4 = ("A0", "A1B", "A2", "A3", "A4", "A1")
ARMS_OLD = ("A6", "A6F") # есть только у 16 старых единиц
# Панель английской оси. `D0` — черновик: он ЧЛЕН панели, потому что первичный контраст оси
# именно `E0/D0` (H-3 «покупает ли редактор поверх черновика»), а не контраст контуров.
ARMS_D3 = ("D0", "E0", "E1", "E2", "E3", "E4", "E6")
# Панель японской РАЗВЕДКИ: черновик, связка, однопроходка. Контроль оси — J0/D0.
ARMS_D6 = ("D0", "J0", "J2")
# Панель Д1: боевой перепис против добитого gemini, обе поверх ОДНОЙ якорной базы эксп-22.
# Гипотеза эксп-04 «проза gemini — аутлаер» закрывается именно этим контрастом.
ARMS_D1 = ("A0", "R1")
FAMILY_D1 = [
("R1", "A0", "gemini против боевого переписа на одной базе (гипотеза эксп-04)"),
]
FAMILY_D6 = [
("J0", "D0", "боевой редактор поверх черновика на ja (ОПИСАТЕЛЬНО, ось разведочная)"),
]
# ⚠ ПЕРВИЧНОЕ семейство оси — ОДИН контраст, как объявила мощность (`power.CONTRAST_N`): при n=16
# и k=1 ось несущая, при k=5 её MDE вырос бы выше цели. Остальные контрасты — ОПИСАТЕЛЬНЫЕ и
# в поправку Холма не входят; печатать их как несущие нельзя.
FAMILY_D3 = [
("E0", "D0", "боевой редактор поверх черновика (H-3, несущая оси)"),
]
SECONDARY_D3 = [
("E1", "E0", "флаговый контур против связки — на en это фактически КАНОН-контур"),
("E2", "E0", "однопроходка уравненного мандата против связки"),
("E3", "E0", "смысловой контур против связки (ставка владельца, на en описательно)"),
("E4", "E0", "связка + канон-фиксер ПОСЛЕ"),
("E6", "E0", "ВТОРОЙ редактор glm-5 (H-4: сменится ли порядок жильцов на другой паре)"),
]
# ⚠ ПЕРВИЧНОЕ СЕМЕЙСТВО — по одному контрасту на гипотезу владельца, Холм по трём. Объявлено в
# пре-реге Д0.5 и `power.py`; здесь оно ПОВТОРЕНО, а не переопределено, и впечатывается в ключ
# до первого ответа судьи.
FAMILY_D4 = [
("A1B", "A0", "флаговый контур против переписа (H-2а)"),
("A3", "A0", "смысловой контур против переписа (H-2б, ставка владельца)"),
("A6", "A0", "качественный черновик + контур против переписа (H-1)"),
]
# ⚠ МАРЖЕВЫЕ ПОСАДКИ — ТОНКИЕ, и это их суть. Грубые посадки рига (`один`→`сорок`, выдуманное
# слово, сломанная вёрстка) ловятся 32/32 и говорят только о потолочном режиме. Классы ниже
# выбраны по тому, что судья РЕАЛЬНО пропустил в проходе `tier`: смысл переворачивался, а оценка
# была ноль. Замена правдоподобна: текст остаётся связным и грамотным, ошибка — только смысловая.
MARGIN_PLANTS = [
# (что ищем, чем заменяем, класс) — по одной замене каждого класса, не больше.
# ⚠ КЛАССЫ ВЫБРАНЫ ЗАМЕРОМ, А НЕ ВООБРАЖЕНИЕМ. Первая редакция сажала «потерянный разряд
# числа» регексом по цифрам — и не сработала НИ РАЗУ на 31 единице: русская художественная
# проза пишет числа СЛОВАМИ, цифр в ней нет вовсе (замер: 0 вхождений `\d{2,}` на 31 тексте).
# Посадка, которая не сажается, превращает «не пойман» в «не было» и делает контроль пустым —
# ровно тем, чем был контроль в паке 23. Ниже три класса, замеренные на этом же сырье:
# снятие отрицания 31/31 единиц (151 вхождение) · подмена числительного 26/31 (108) ·
# инверсия сравнения 20/31 (33). Все три — тонкие: меняется одно слово, текст остаётся
# связным и грамотным, ошибка ТОЛЬКО смысловая. Это и есть маргинальный режим, ради которого
# декой заведён: грубые посадки рига ловятся 32/32 и о нём не говорят ничего.
[r"\е\s+([а-яё]{3,}(?:л|ла|ло|ли|ет|ит))\b", r"\1", "снято отрицание — смысл инвертирован"],
[r"\b(три|четыре|пять|шесть|семь|восемь|девять)\b", "двенадцать", "подмена числительного"],
[r"\b(больше|выше|сильнее)\b", "меньше", "инверсия сравнения"],
]
# ⚠ КЛАССЫ ПОСАДОК — ОСЬ-ЗАВИСИМЫ, И ЭТО НЕ УДОБСТВО. Список выше подобран ЗАМЕРОМ на КИТАЙСКОМ
# сырье (числительные словами — 26 единиц из 31, потому что сянься всё пересчитывает). На
# английской оси тот же список ложится на 5 единиц из 16: у Кристоффа числительных почти нет
# (2 из 16), сравнений мало (4 из 16). Гейт чувствительности при этом печатался ПРОЙДЕННЫМ.
# Замер по 16 эталонным текстам английской оси дал класс, который ложится на 15 из 16 при 59
# вхождениях: подмена принадлежности «его»→«её». Она ровно того сорта, ради которого декой и
# заведён — меняется одна буква, текст остаётся грамотным, а смысл «чьё» переворачивается.
# С ней единиц с ≥2 посадками становится 11 из 16, то есть гейт берётся честно.
#
# ⚠⚠ ПОЧЕМУ ЭТО НЕ ПРИМЕНЕНО К УЖЕ ОТСУЖЕННОМУ. Маржевый декой НЕ хранится — он ВОССТАНАВЛИВАЕТСЯ
# из эталона при разборе (`text_of("CTRLmargin", …)`). Смена списка классов меняет восстановленный
# текст, и он перестаёт совпадать с тем, что реально видел судья: разбор начнёт сверять ответы с
# ДРУГОЙ приманкой. Ровно этот класс уже стоил фазе починки ключей (`itog_d4.py:53`). Поэтому
# расширенный список включается ТОЛЬКО для новых эмиссий, а оси, уже отсуженные на узком списке,
# несут его как объявленное ограничение.
# Класс подобран ЗАМЕРОМ на материале КАЖДОЙ оси отдельно, а не перенесён с китайской:
# en: подмена принадлежности ложится на 15 единиц из 16 (59 вхождений)
# ja: на 8 из 9 (14 вхождений); снятие отрицания там же — 9 из 9
# Подмена указания («этот»→«тот», 9 из 9 у ja) отвергнута сознательно: она грамматична в обе
# стороны, референт бывает неоднозначен, и судья вправе счесть её допустимой — посадка обязана
# быть однозначной ошибкой, иначе «не пойман» перестаёт что-либо значить.
_POSS = [r"\b(его)(\s+[а-яё]{3,})", r"её\2",
"подмена принадлежности — смысл «чьё» инвертирован"]
MARGIN_EXTRA = {"d3": [_POSS], "d6": [_POSS]}
if "--wide-plants" in sys.argv:
MARGIN_PLANTS = MARGIN_PLANTS + MARGIN_EXTRA.get(AXIS, [])
# Сколько единиц и пар пола ОБЪЯВЛЕНО у каждой оси. Прежде тут стояли китайские константы (31 и
# 15), и селфтест на новых осях падал не потому, что прибор плох, а потому, что сверялся с чужим
# числом. Настоящие дефекты за этим шумом терялись.
EXPECT_N = {"d4": 31, "d3": 16, "d6": 9, "d1": 15}
MIN_FLOOR = {"d4": 15, "d3": 10, "d6": 3, "d1": 12}
TWIN_GATE = "--no-twins" in sys.argv
MARGIN_MIN = 2 # клетка, где посадить удалось меньше, в маржевый декой не идёт
ARMS = {"d3": ARMS_D3, "d6": ARMS_D6, "d1": ARMS_D1}.get(AXIS, ARMS_D4)
FAMILY = {"d3": FAMILY_D3, "d6": FAMILY_D6, "d1": FAMILY_D1}.get(AXIS, FAMILY_D4)
# Эталон оси — против него строятся декой и пол. У японской это связка J0.
BASE_ARM = {"d3": "E0", "d6": "J0"}.get(AXIS, "A0")
def _wire_axis() -> None:
"""Включить пару оси. Для d4 — no-op (китайская пара и так умолчание)."""
if AXIS in ("d3", "d6"):
import importlib.util # noqa: PLC0415
drv = "en_axis.py" if AXIS == "d3" else "ja_axis.py"
sp = importlib.util.spec_from_file_location(
"en_axis_sud", Path(__file__).resolve().parent / drv)
ax = importlib.util.module_from_spec(sp)
sys.modules["en_axis_sud"] = ax
sp.loader.exec_module(ax)
# ⚠ НЕ `ax.wire()`: драйвер держит СВОЙ экземпляр контура, и его настройка нашего не
# касается — судейство осталось бы на китайской паре с 31 единицей вместо 16. Ловушка
# та же, что с кассой (`money_d` настраивал свой экземпляр). Настраиваем СВОЙ.
C.configure(C.PARA.EN if AXIS == "d3" else C.PARA.JA, units_provider=ax.units,
base_draft_hook=ax.base_draft,
base_a0_hook=ax.base_e0 if AXIS == "d3" else ax.base_j0)
_wire_axis()
def units():
"""Единицы, допустимые к судейству: без брака базы и без клеток finish=length."""
us = C.units_ok()
if AXIS == "d1":
# ⚠ Только единицы, где добивка РЕАЛЬНО собрана: судить пустую клетку нельзя, а
# молчаливое включение дало бы контраст на разной выборке.
us = [u for u in us if (C.OUT / f"dv-e-r1-{u['uid']}.json").exists()]
return us
def _twin_of_base(arm: str, uid: str, txt: str) -> bool:
"""Совпадает ли текст арма ПОБАЙТНО с эталоном оси.
⚠ Зачем гейт. Норма фазы запрещает класть в ОДНУ пачку два одинаковых текста под разными
метками («урок близнеца», эксп-23: `CTRLfloorA ≡ T1` в 16/16 и контроль оказался пуст).
На английской оси это воспроизвелось само: канон-гейт не нашёл потерь на 12 единицах из 16,
фиксер не вызывался, и арм `E4` записал вход — то есть текст `E0` — как свой выход. Судья
читал бы один текст дважды и, увидев совпадение, мог бы оценить его сличением, а не чтением.
Такая клетка в пачку НЕ идёт; арм на этой единице объявляется «равен эталону», а не судится.
"""
if arm == BASE_ARM or not txt.strip():
return False
base = C.base_a0(uid)
return bool(base.strip()) and txt.strip() == base.strip()
def text_of(arm: str, u: dict) -> str:
"""Текст арма. Клетка, не прошедшая гейт годности, в пачку НЕ идёт — возвращается пусто."""
uid = u["uid"]
if arm == "R1":
# ⚠ Клетки добивки живут в кассе ФД-E под своим префиксом, а не в схеме тегов контура.
f = C.OUT / f"dv-e-r1-{uid}.json"
if not f.exists():
return ""
return json.loads(f.read_text(encoding="utf-8")).get("content") or ""
if arm == "A0":
return C.base_a0(uid)
if arm == "CTRLmargin":
planted, n = margin_plant(C.base_a0(uid))
return planted if n >= MARGIN_MIN else ""
f = C.OUT / f"{C.tag(arm, uid)}.json"
if not f.exists():
return ""
d = json.loads(f.read_text(encoding="utf-8"))
# ⚠ ГЕЙТ ПАЧКИ (норма Д0.6): клетка с `finish=length` не допускается ни боевым армом, ни
# половиной пола — её выход оборван на середине предложения, и судья оценит обрыв, а не арм.
if d.get("finish") != "stop":
return ""
txt = d.get("content") or ""
# ⚠ ГЕЙТ ВКЛЮЧАЕТСЯ ЯВНО (`--no-twins`), и это не педантизм. Он МЕНЯЕТ СОСТАВ ПАНЕЛИ, а
# значит и раскладку меток в слепом ключе. Включённый по умолчанию, он при повторной эмиссии
# переписал ключ уже отсуженной оси, и разбор начал сопоставлять ответы с ДРУГИМИ армами:
# вердикт `E0/D0` съехал с +1.56 (перешёл порог) на +0.38 (ниже). Ключ восстановим, потому
# что соль перетасовки лежит отдельно и не перезаписывается, — но цена ошибки была бы
# невосстановимой, будь соль в том же файле.
if TWIN_GATE and _twin_of_base(arm, uid, txt):
return "" # близнец эталона — в пачку не идёт, см. `_twin_of_base`
return txt
def margin_plant(text: str) -> tuple[str, int]:
"""Тонкая контролируемая порча. Возвращает (текст, число РЕАЛЬНО применённых замен)."""
out, n = text, 0
for pat, rep, _c in MARGIN_PLANTS:
new, cnt = re.subn(pat, rep, out, count=1)
if cnt:
out, n = new, n + cnt
return out, n
def floor_pair(u: dict, half: int) -> str:
"""ОДНА половина пары пола. Наборы П1/П2 берут разные половины — см. отступление 1 в шапке."""
# ⚠ Префикс — фазовая буква ОСИ, а не константа `a`: у английской оси пол лежит в `dv-b-flo*`,
# и жёсткое `dv-a-` подсунуло бы ей КИТАЙСКИЙ шум как собственный порог.
# ⚠ ПОБАЙТНО РАВНЫЕ ПОЛОВИНЫ — ЭТО НЕ ПАРА. Пол строится двумя прогонами точечного фиксера
# по ОДНОМУ списку мест над ОДНИМ черновиком, то есть операцией почти детерминированной: на
# коротких списках она воспроизводится дословно. Такая «пара» даёт нулевую разницу и ЗАНИЖАЕТ
# sd, а с ним и порог различимости — то есть делает вердикты СМЕЛЕЕ, чем данные позволяют.
# Перекупка не помогает (проверено 15.08: три японские пары вернулись теми же текстами).
# Поэтому пара исключается из пола целиком; сколько таких — печатается в отчёте числом.
pfx = f"dv-{C.phase_letter(BASE_ARM)}"
fa, fb = C.OUT / f"{pfx}-flo1-{u['uid']}.json", C.OUT / f"{pfx}-flo2-{u['uid']}.json"
if fa.exists() and fb.exists():
ta = (json.loads(fa.read_text(encoding="utf-8")).get("content") or "")
tb = (json.loads(fb.read_text(encoding="utf-8")).get("content") or "")
if ta and ta == tb:
return ""
f = C.OUT / f"{pfx}-flo{half}-{u['uid']}.json"
if not f.exists():
return ""
d = json.loads(f.read_text(encoding="utf-8"))
return (d.get("content") or "") if d.get("finish") == "stop" else ""
def setup(half: int) -> None:
"""Настроить общий риг на панель Д4 и на НАБОР `half` (1 или 2).
⚠ Крючки сбрасываются ЯВНО все до одного. `absjudge` держит их глобалями, и после работы с
чужим паком касса и каталоги молча читали чужое — этот класс стоил паку 22 пересчёта
32 единиц вместо 16 (дефект D-3). Поэтому здесь перечислено ВСЁ, а не только изменяемое.
"""
old = {u["uid"] for u in C.P22.M.units_zh()} if AXIS == "d4" else set()
arms = (ARMS + ARMS_OLD + ("CTRLmargin",)) if AXIS == "d4" else (ARMS + ("CTRLmargin",))
t, a = (TASKS1, ANSW1) if half == 1 else (TASKS2, ANSW2)
AJ.configure(
PASS=f"{AXIS}p{half}",
ARMS=arms,
FAMILY=FAMILY,
SRC_LANG=C.pair().lang_name,
UNITS_HOOK=units,
TEXT_HOOK=lambda arm, u: ("" if arm in ARMS_OLD and u["uid"] not in old
else text_of(arm, u)),
# декой сажается в БОЕВОЙ ПЕРЕПИС, и донор объявлен: в паке 23 донором во всех 16 единицах
# был один и тот же арм, и это осталось необъявленным до самого разбирательства
DECOY_HOOK=lambda slots: slots.get(BASE_ARM) or "",
# пол НЕ через штатный крючок: он кладёт обе половины в одну пачку — см. отступление 1
FLOOR_HOOK=lambda u: ((floor_pair(u, half), floor_pair(u, half))
if False else None),
DIRS_HOOK=lambda: (t, a, VOTES),
JUDGES_HOOK=None,
)
AJ.KEYS = KEYS
def emit(half: int) -> None:
"""Выпустить набор пачек. Ключ пишется ДО существования хоть одного ответа."""
setup(half)
# половина пола входит в набор ОТДЕЛЬНЫМ псевдо-армом, чтобы попасть в ту же пачку, но
# в ДРУГУЮ сессию, чем её пара
base = AJ.TEXT_HOOK
AJ.configure(ARMS=tuple(AJ.ARMS) + ("CTRLfloor",),
TEXT_HOOK=lambda arm, u: (floor_pair(u, half) if arm == "CTRLfloor"
else base(arm, u)))
AJ.emit()
print(f"набор П{half}: задания → {(TASKS1 if half == 1 else TASKS2)} · "
f"ключ → {KEYS} (судье НЕ давать)")
# ⚠ Список запретов — ПО ВСЕМ осям, а не по своей. Судья английской оси, заглянувший в `dv-a-`,
# так же скомпрометирован, как заглянувший в `dv-b-`: он увидит сырьё с полем `arm` и перестанет
# быть слепым. Перечисляем все префиксы фазы, а не только свой.
FORBIDDEN = ("blind-keys", "dv-a-", "dv-b-", "dv-c-", "dv-f-", "dv-g-",
"/books/dovodka", "contour.py", "sud.py", "en_axis.py", "ja_axis.py")
def audit(tr_dir: Path) -> int:
"""⚠ ПОСТФАКТУМ-АУДИТ: куда судья ходил инструментами. Существует, вопреки Д0.12."""
bad = 0
# ⚠ Транскрипты суб-агентов харнесса лежат с расширением `.output`, а содержимое у них
# JSONL. Маска только по `*.jsonl` печатала «транскриптов нет — пачка НЕ принимается», то
# есть аудит выглядел проваленным там, где он просто смотрел не на те имена.
files = sorted([*Path(tr_dir).glob("*.jsonl"), *Path(tr_dir).glob("*.output")])
# ⚠ АУДИТ КАСАЕТСЯ ТОЛЬКО СУДЕЙСКИХ сессий. В каталоге задач харнесса лежат вперемешку и
# транскрипты судей, и вывод собственных фоновых команд полигона — а те законно трогают
# сырьё (`dv-c-` и прочее). Без фильтра аудит помечал ПОКУПКУ как «судья лез куда нельзя»:
# ложная тревога, которая при беглом чтении выглядит как скомпрометированная пачка.
# Признак судейской сессии — она открывала каталог заданий своей оси.
marker = str(TASKS1.parent.name)
files = [f for f in files if marker in f.read_text(encoding="utf-8", errors="replace")]
if not files:
print(f"⛔ транскриптов в {tr_dir} нет — аудит невозможен, пачка НЕ принимается")
return 1
for f in files:
txt = f.read_text(encoding="utf-8", errors="replace")
hits = sorted({w for w in FORBIDDEN if w in txt})
if hits:
bad += 1
print(f"[ПРОВАЛ] {f.name}: судья касался запрещённого — {hits}")
else:
print(f"[OK ] {f.name}: запрещённых путей не касался")
print(f"\n{'АУДИТ ЧИСТ' if not bad else f'ПАЧКА НЕ ПРИНИМАЕТСЯ: подозрительных сессий {bad}'}")
return bad
def selftest() -> int:
"""Обязан пройти ДО выдачи первой пачки судье."""
bad = 0
def ck(n: str, ok: bool, d: str = "") -> None:
nonlocal bad
bad += not ok
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else ""))
us = units()
ck(f"единиц к судейству — сколько объявила ось ({EXPECT_N[AXIS]})",
len(us) == EXPECT_N[AXIS], str(len(us)))
u = us[0]
# клетки finish=length не должны попадать в пачку ни одним армом
lens = [(a, x["uid"]) for a in ARMS_D4 + ARMS_OLD for x in us
if (C.OUT / f"{C.tag(a, x['uid'])}.json").exists()
and json.loads((C.OUT / f"{C.tag(a, x['uid'])}.json").read_text()).get("finish")
!= "stop" and text_of(a, x)]
ck("клетка finish=length в пачку не попадает", not lens, str(lens[:2]))
# маржевый декой обязан РЕАЛЬНО сажаться, иначе «не пойман» = «не было»
ok_m = sum(1 for x in us if text_of("CTRLmargin", x))
ck("маржевый декой сажается на большинстве единиц", ok_m >= len(us) * 0.6,
f"{ok_m} из {len(us)}")
# и обязан быть ТОНКИМ: текст меняется мало, иначе это второй грубый декой
import difflib # noqa: PLC0415
sims = []
for x in us[:8]:
m = text_of("CTRLmargin", x)
if m:
sims.append(difflib.SequenceMatcher(None, C.base_a0(x["uid"]), m,
autojunk=False).ratio())
ck("маржевый декой ТОНКИЙ (меняет <1% знаков)", sims and min(sims) > 0.99,
f"схожесть мин {min(sims):.4f}" if sims else "нет посадок")
# Грубый декой рига обязан оставаться ГРУБЫМ — иначе оба контроля меряют одно.
# ⚠ Сравнение ПОЭДИНИЧНОЕ. Прежняя редакция брала грубый декой на ОДНОЙ единице и сравнивала
# с МИНИМУМОМ схожести маржевого по всем — разные величины, и вердикт зависел от того, какая
# единица попалась. Теперь оба декоя считаются на одном и том же тексте, и требуется, чтобы
# грубый был грубее на КАЖДОЙ единице: гейт от этого строже, а не мягче.
worse = tot = 0
for x in us:
b = C.base_a0(x["uid"])
if not b.strip():
continue
ct, cn = AJ._plant(b) # noqa: SLF001
mt, mn = margin_plant(b)
if cn < 2 or mn < 1:
continue
tot += 1
worse += (difflib.SequenceMatcher(None, b, ct, autojunk=False).ratio()
< difflib.SequenceMatcher(None, b, mt, autojunk=False).ratio())
ck("грубый декой ГРУБЕЕ маржевого на КАЖДОЙ единице", tot and worse == tot,
f"грубее на {worse} из {tot}")
# половины пола идут в РАЗНЫЕ наборы и не совпадают побайтно
pairs = [(floor_pair(x, 1), floor_pair(x, 2)) for x in us]
pairs = [p for p in pairs if p[0] and p[1]]
ck(f"пар пола не меньше объявленного осью ({MIN_FLOOR[AXIS]})",
len(pairs) >= MIN_FLOOR[AXIS], str(len(pairs)))
ck("половины пола НЕ равны побайтно (иначе контроль пуст)",
not [1 for a, b in pairs if a == b])
# половина пола не должна совпадать с боевой клеткой (урок «близнеца» пака 23)
battle = {text_of(a, x) for a in ARMS_D4 for x in us}
ck("половина пола не совпадает с боевым армом",
not [1 for a, b in pairs if a in battle or b in battle])
# ключи лежат ВНЕ рабочего каталога судьи
ck("ключ слепых меток вне каталога заданий",
KEYS not in TASKS1.parents and TASKS1 not in KEYS.parents and KEYS.parent != WORK,
f"{KEYS} против {WORK}")
# семейство первичных контрастов совпадает с объявленным в power.py
P = _load("power_d", ZONE / "power.py")
declared = {c.split("")[0] for c in P.PRIMARY["Д4 edit-контур zh"]}
mine = {f"{a}/{b}" for a, b, _ in FAMILY_D4}
ck("семейство совпадает с объявленным в power.py", declared == mine,
f"{sorted(declared)} против {sorted(mine)}")
print(f"\n{'СУДЕЙСКИЙ ХАРНЕСС ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}")
return bad
if __name__ == "__main__":
# ⚠ `--axis=` разбирается на уровне модуля (см. AXIS) и здесь ОТБРАСЫВАЕТСЯ из разбора
# режима: иначе он попадает первым аргументом и падает как «неизвестный режим».
a = [x for x in sys.argv[1:]
if not x.startswith(("--axis=", "--fam=", "--wide-plants", "--no-twins"))] or ["--selftest"]
if a[0] == "--selftest":
sys.exit(1 if selftest() else 0)
elif a[0] == "--emit":
emit(1)
emit(2)
elif a[0] == "--audit":
sys.exit(1 if audit(Path(a[1])) else 0)
else:
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --selftest --emit --audit")