518 lines
41 KiB
Python
518 lines
41 KiB
Python
#!/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 общий с
|
||
паками 21–23, и правка его тела потребовала бы пере-снятия их гейтов.
|
||
|
||
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"\bне\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 _cell_file(arm: str, uid: str):
|
||
"""Файл клетки арма — ОДНА точка правды для `text_of` и для гейтов.
|
||
|
||
⚠ Раздвоение путей и было вторым дефектом finish-гейта. `text_of` знает, что клетки добивки
|
||
лежат под своим префиксом (`dv-e-r1-…`, касса ФД-E), а гейт спрашивал `C.tag`, который для
|
||
того же арма даёт `dv-a-r1-…` — файла нет, клетка молча выпадает из проверки. Так клетка
|
||
`dv-e-r1-de3916de62.json` с finish=length прошла в судейскую пачку оси Д1.
|
||
"""
|
||
f = C.OUT / f"dv-e-r1-{uid}.json" if arm == "R1" else C.OUT / f"{C.tag(arm, uid)}.json"
|
||
return f if f.exists() else None
|
||
|
||
|
||
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 не должны попадать в пачку ни одним армом
|
||
# ⚠ ОСЬ — ПАРАМЕТР И ЗДЕСЬ. Прежняя редакция перебирала ARMS_D4 + ARMS_OLD на ЛЮБОЙ оси,
|
||
# то есть на d1/d3/d6 сертифицировала ЧУЖУЮ панель и была зелена вхолостую. Цена дефекта
|
||
# замерена: клетка `dv-e-r1-de3916de62.json` с finish=length ушла в судейскую пачку оси Д1,
|
||
# хотя норма Д0.6 это прямо запрещает, и гейт промолчал. Пере-счёт без неё: перевес R1/A0
|
||
# у claude −0.100 → +0.000, у Sol +2.533 → +2.143; вердикты не переворачиваются, но норма
|
||
# была нарушена и прибор об этом не сказал.
|
||
lens = [(a, x["uid"]) for a in (ARMS + ARMS_OLD if AXIS == "d4" else ARMS) for x in us
|
||
if _cell_file(a, x["uid"]) is not None
|
||
and json.loads(_cell_file(a, x["uid"]).read_text(encoding="utf-8")).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 for x in us} # ⚠ панель ОСИ, а не всегда d4
|
||
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
|
||
# ⚠ И СЕМЕЙСТВО — ТОЖЕ ПО ОСИ. Проверка была прибита к «Д4 edit-контур zh» и на трёх осях
|
||
# из четырёх сверяла чужое объявление со своим — то есть не проверяла ничего.
|
||
P = _load("power_d", ZONE / "power.py")
|
||
pname = {"d4": "Д4 edit-контур zh", "d3": "Д3 en→ru несущая",
|
||
"d1": "Д1 gemini добивка"}.get(AXIS)
|
||
if pname and pname in P.PRIMARY:
|
||
declared = {c.split(" — ")[0] for c in P.PRIMARY[pname]}
|
||
mine = {f"{a}/{b}" for a, b, _ in FAMILY}
|
||
ck(f"семейство оси совпадает с объявленным в power.py ({pname})", declared == mine,
|
||
f"{sorted(declared)} против {sorted(mine)}")
|
||
else:
|
||
ck("ось объявлена ОПИСАТЕЛЬНОЙ — первичного семейства в power.py нет",
|
||
AXIS == "d6", f"ось {AXIS}")
|
||
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")
|