#!/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"): raise SystemExit(f"⛔ ось {AXIS} харнессу неизвестна") WORK = Path.home() / f"sud-{AXIS}" TASKS1, TASKS2 = WORK / "p1-tasks", WORK / "p2-tasks" ANSW1, ANSW2 = WORK / "p1-answers", WORK / "p2-answers" VOTES = WORK / "votes" KEYS = Path.home() / "books" / "dovodka" / f"blind-keys-{AXIS}" # НЕ в WORK: судья туда не ходит # ⚠ ПЕРЕВОДИМ РИГ НА КЛЮЧИ ФАЗЫ СРАЗУ, а не только внутри `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", "E4") FAMILY_D3 = [ ("E0", "D0", "боевой редактор поверх черновика (H-3, несущая оси)"), ] # ⚠ ПЕРВИЧНОЕ СЕМЕЙСТВО — по одному контрасту на гипотезу владельца, Холм по трём. Объявлено в # пре-реге Д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", "меньше", "инверсия сравнения"], ] MARGIN_MIN = 2 # клетка, где посадить удалось меньше, в маржевый декой не идёт ARMS = ARMS_D3 if AXIS == "d3" else ARMS_D4 FAMILY = FAMILY_D3 if AXIS == "d3" else FAMILY_D4 BASE_ARM = "E0" if AXIS == "d3" else "A0" # эталон, против которого строится декой и пол def _wire_axis() -> None: """Включить пару оси. Для d4 — no-op (китайская пара и так умолчание).""" if AXIS == "d3": import importlib.util # noqa: PLC0415 sp = importlib.util.spec_from_file_location( "en_axis_sud", Path(__file__).resolve().parent / "en_axis.py") 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, units_provider=ax.units, base_draft_hook=ax.base_draft, base_a0_hook=ax.base_e0) _wire_axis() def units(): """Единицы, допустимые к судейству: без брака базы и без клеток finish=length.""" return C.units_ok() def text_of(arm: str, u: dict) -> str: """Текст арма. Клетка, не прошедшая гейт годности, в пачку НЕ идёт — возвращается пусто.""" uid = u["uid"] 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 "" return d.get("content") or "" 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 в шапке.""" f = C.OUT / f"dv-a-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()} arms = ARMS_D4 + ARMS_OLD + ("CTRLmargin",) t, a = (TASKS1, ANSW1) if half == 1 else (TASKS2, ANSW2) AJ.configure( PASS=f"d4p{half}", ARMS=arms, FAMILY=FAMILY_D4, SRC_LANG="китайском", 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("A0") 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 files = sorted(Path(tr_dir).glob("*.jsonl")) 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("единиц к судейству 31 (32 минус эхо-мина)", len(us) == 31, 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("пар пола не меньше 15", len(pairs) >= 15, 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__": a = sys.argv[1:] 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")