textmachine/eval/dovodka/sud.py

291 lines
20 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`, ни кода. Ключи живут ОТДЕЛЬНО и судье не выдаются.
WORK = Path.home() / "sud-d4"
TASKS1, TASKS2 = WORK / "p1-tasks", WORK / "p2-tasks"
ANSW1, ANSW2 = WORK / "p1-answers", WORK / "p2-answers"
VOTES = WORK / "votes"
KEYS = Path.home() / "books" / "dovodka" / "blind-keys-d4" # НЕ в WORK: судья туда не ходит
# Армы панели Д4. `A1` (только канон-флаги) — описательный, в первичное семейство не входит.
ARMS_D4 = ("A0", "A1B", "A2", "A3", "A4", "A1")
ARMS_OLD = ("A6", "A6F") # есть только у 16 старых единиц
# ⚠ ПЕРВИЧНОЕ СЕМЕЙСТВО — по одному контрасту на гипотезу владельца, Холм по трём. Объявлено в
# пре-реге Д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", "меньше", "инверсия сравнения"],
]
MARGIN_MIN = 2 # клетка, где посадить удалось меньше, в маржевый декой не идёт
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} (судье НЕ давать)")
FORBIDDEN = ("blind-keys", "dv-a-", "dv-f-", "/books/dovodka", "contour.py", "sud.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 "нет посадок")
# грубый декой рига обязан оставаться ГРУБЫМ — иначе оба контроля меряют одно
coarse, nc = AJ._plant(C.base_a0(u["uid"])) # noqa: SLF001
cs = difflib.SequenceMatcher(None, C.base_a0(u["uid"]), coarse, autojunk=False).ratio()
ck("грубый декой ГРУБЕЕ маржевого", nc >= 2 and cs < (min(sims) if sims else 1.0),
f"грубый {cs:.4f} против маржевого {min(sims):.4f}" if sims else "")
# половины пола идут в РАЗНЫЕ наборы и не совпадают побайтно
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")