textmachine/eval/dovodka/zsud.py

561 lines
38 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
"""СУДЕЙСТВО ЗАХОДА Д17 — слепой проход по ПОЧИНЕННОЙ рубрике. $0 (агент-сессии).
Панель и правило решения зафиксированы пре-регом Д17 ДО покупок. Здесь только эмиссия заданий,
разбор ответов и гейты. Ключ и соль свои: раскладка меток есть чистая функция соли, uid и НАБОРА
армов, поэтому эмиссия поверх чужого ключа переписала бы раскладку уже отсуженной оси (авария
фазы Д, восстановленная только потому, что соль лежит отдельным файлом).
ПАНЕЛЬ (8 клеток на единицу, по 6 боевых армов и 2 контроля):
p1: ZD · Z0 · ZP · Z8 · Z8R · Z1 + грубый декой + маржевый декой
p2: ZD · ZF · ZP · Z8 · Z8R · Z1 + грубый декой + маржевый декой
⚠ ПОЛОВИНЫ ШУМОВОГО ПОЛА РАЗВЕДЕНЫ ПО НАБОРАМ: `Z0` живёт только в p1, `ZF` — только в p2.
Это две генерации ОДНОГО И ТОГО ЖЕ боевого редактора, и разница их оценок есть чистый шум прибора,
из которого строится порог различимости. Судья, увидевший обе, обесценивает порог целиком.
Ширина панели в наборах ОДИНАКОВА (8 клеток) — иначе разница наборов мерила бы усталость судьи,
а не шум: замер фазы Д показал, что строгость счёта зависит от ширины пачки.
⚠ `Z7` (однопроходка + канон-фиксер) СУДЕЙСТВОМ НЕ МЕРИТСЯ ВОВСЕ — так объявлено пре-регом.
Его вопрос про БАНК, и отвечает на него детерминированный канон-гейт (`--canon`, $0). Судейский
слот стоит внимания судьи, а внимание — тот ресурс, за который панель платит строгостью.
⚠ ДОНОР ДЕКОЯ ЧЕРЕДУЕТСЯ (`Z0`/`Z8R` по чётности единицы). В 62 пачках оси Д4 донором всегда был
эталон, то есть чувствительность сертифицировалась в окрестности ОДНОГО арма.
Запуск: zsud.py --emit | --score [zh|en] | --canon | --selftest
"""
from __future__ import annotations
import collections
import hashlib
import importlib.util
import json
import re
import statistics as st
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
Z = _load("zakhod_s", ZONE / "zakhod.py")
import absjudge as AJ # noqa: E402
# ── ПОЧИНЕННАЯ РУБРИКА (решение владельца 16.08). Живёт здесь, потому что потребитель у неё
# ОДИН — этот проход; закрытые проходы `tier2`/`ja6` судились старой шкалой и не трогаются.
# Обвязки семейств несут то же правило, и совпадение сторожит существующий гейт `paritet.py`
# (его список `must`), а не отдельный файл: второй гейт паритета был бы просто второй правдой.
#
# ⚠ ЧТО ИЗМЕНИЛОСЬ И ПОЧЕМУ — ЗАМЕР, А НЕ ВКУС. Слепое чтение 16.08 дало на английской оси
# Спирмена 0.10 между порядком по счёту ошибок и порядком по читаемости: связи нет. Читатель
# ВСЛЕПУЮ назвал механизм и попал — «у этого варианта локальных отклонений больше, а читается
# заметно лучше». Прибор штрафовал ровно то, чем покупается читаемость.
# ⚠ ЦЕНА: старые судейские числа дуги 19→23 с новыми НЕСРАВНИМЫ.
# ⚠ РИСК, ЗАЯВЛЕННЫЙ ДО ДАННЫХ: правило «вольность — не ошибка» смещает счёт ВНИЗ, а пачка,
# легшая на пол шкалы, перестаёт различать что-либо (проход `tier`: 38% нулей). Поэтому рубрика
# идёт В КОМПЛЕКТЕ с двумя сторожами и по отдельности не применяется: гейт плотности и МАРЖЕВЫЙ
# декой в каждой пачке.
RUBRIC_TYPES = """ ВЕРНОСТЬ — сказано НЕ ТО, что в исходнике. Считается ТОЛЬКО искажение факта:
· инверсия адресата или участника (кто кому говорит, кто кого бьёт, кто чей союзник);
· снятое или, наоборот, добавленное отрицание;
· подмена числа, разряда, ранга, меры, имени, времени, места;
· выдуманный факт, которого в исходнике нет;
· ПОТЕРЯННЫЙ факт: участник, действие, число, полярность, заголовок;
· перевёрнутое состояние действия или идиомы (совершилось против ещё не совершилось).
ЯЗЫК — то, чего НОСИТЕЛЬ РУССКОГО НЕ НАПИШЕТ:
· калька с исходного языка — буквально переданный оборот, жест или идиома;
· канцелярит и переводной суржик: «являлся», «осуществил», нанизывание родительных,
отглагольное существительное там, где русский просит глагола;
· несуществующее или неуместное слово, рассогласование, кривой порядок слов;
· фраза грамматически верная, но звучащая ПЕРЕВЕДЁННОЙ, а не написанной по-русски.
ТЕРМИН — имя или термин передан в РАЗНЫХ местах одного текста по-разному. ОПИСАТЕЛЬНАЯ ось.
ФОРМА — нарушение РУССКОЙ типографики: реплика не с нового абзаца через тире, прямые кавычки
вместо «ёлочек», дефис вместо тире. ОПИСАТЕЛЬНАЯ ось."""
RUBRIC_FREEDOM = """⛔ ЧТО ОШИБКОЙ НЕ ЯВЛЯЕТСЯ — ПРОЧТИ ДО ТОГО, КАК НАЧНЁШЬ СЧИТАТЬ.
Живой русский язык — приоритет этого продукта, и переводчик ВПРАВЕ обращаться с буквой исходника
свободно. НЕ штрафуй и не считай ошибкой:
· перестановку предложений и частей предложения;
· слияние или дробление абзацев; число абзацев и предложений совпадать с исходником НЕ обязано;
· замену оборота на более живой русский, синонимическую замену, добавленную связку;
· распутывание чужого синтаксиса в русскую фразу — это ОБЯЗАННОСТЬ переводчика, а не вольность;
· убранную англоязычность или буквализм исходного языка там, где смысл сохранён;
· вариант, который просто НРАВИТСЯ тебе меньше соседнего.
Ошибка — только то, что ПЕРЕВИРАЕТ СМЫСЛ ИСХОДНИКА (ось ВЕРНОСТЬ) либо ЧЕГО НОСИТЕЛЬ РУССКОГО НЕ
НАПИШЕТ (ось ЯЗЫК). Колеблешься между «вольность» и «искажение» — вернись к исходнику и проверь
факт: не нашёл искажения факта, значит это вольность и она НЕ считается."""
RUBRIC_ZERO = """⚠ НОЛЬ — ЭТО УТВЕРЖДЕНИЕ, А НЕ ОТСУТСТВИЕ ОТВЕТА. Ноль означает «я сверил этот
вариант с исходником по этой оси и дефектов не нашёл». Поэтому ПОЧЕМУ обязательно для КАЖДОЙ
метки, в том числе при нулях: если по всем осям ноль, напиши «прочитано, дефектов не найдено».
Пустое ПОЧЕМУ не принимается и на диск не попадает.
⚠ И обратное так же важно: правило про вольность НЕ означает «ставь нули». Искажения факта бывают
незаметны, потому что русский текст остаётся грамотным и связным, — именно их и надо ловить,
сверяя с исходником, а не читая один перевод."""
def rubric() -> str:
return (f"Посчитай ОШИБКИ в каждом варианте по типам:\n\n{RUBRIC_TYPES}\n\n"
f"{RUBRIC_FREEDOM}\n\n{RUBRIC_ZERO}")
OUT = Path.home() / "books" / "dovodka"
WORK = Path.home() / "books" / "judging" / "z17"
KEYD = OUT / "blind-keys-z17"
SALT = "z17-2026-08-16"
PER_SESSION = 4
SETS = ("p1", "p2")
AX = ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА")
CARRY = ("ВЕРНОСТЬ", "ЯЗЫК") # несущая сумма (эррата П-5); ТЕРМИН отдан банк-гейту, ФОРМА описательна
NL = chr(10)
# ⚠ Посадки грубого декоя берутся ТЕ ЖЕ, что видели судьи фазы Д, — иначе гейт «грубый декой
# грубее маржевого» сертифицировал бы не тот текст. Файл копируется, а не читается из чужого
# каталога: чужой ключ трогать нельзя даже на чтение-по-ссылке.
KEYD.mkdir(parents=True, exist_ok=True)
_SRC_PLANTS = OUT / "blind-keys-d4" / "PLANTS.json"
if _SRC_PLANTS.exists() and not (KEYD / "PLANTS.json").exists():
(KEYD / "PLANTS.json").write_text(_SRC_PLANTS.read_text(encoding="utf-8"), encoding="utf-8")
AJ.KEYS = KEYD
BATTLE = ("ZD", "ZP", "Z8", "Z8R", "Z1") # армы, стоящие в ОБОИХ наборах
FLOOR = {"p1": "Z0", "p2": "ZF"} # половины пола, разведённые по наборам
CTRL = ("CTRLdecoy", "CTRLmargin")
SRC_LANG = {"zh": "китайском", "en": "английском"}
MARGIN_MIN = 2
_axre = re.compile(r"^[ТT](\d+)-(ВЕРНОСТЬ|ТЕРМИН|ЯЗЫК|ФОРМА):\s*(\d+)", re.MULTILINE)
_whyre = re.compile(r"^[ТT](\d+)-ПОЧЕМУ:(.*)$", re.MULTILINE)
def _sud(pair: str):
"""`sud` разбирает ось из argv на уровне модуля — грузится ОДИН раз под нужную ось.
⚠ `--wide-plants` ОБЯЗАТЕЛЕН И ПЕРЕДАЁТСЯ ЯВНО (починка по ревью). Хендофф §5.8: классы
маржевых посадок ПАР-ЗАВИСИМЫ, узкий (китайский) список на английском ложится 5 из 16, а
рабочий класс «подмена принадлежности» даёт 11 из 16. Флаг разрешён «только для новых
эмиссий» — заход и есть новая эмиссия. Без него гейт чувствительности П-2, то самое право
сказать «не хуже», встал бы на пять точек вместо одиннадцати, а ширина пачек прыгала бы 7/8.
"""
axis = "d4" if pair == "zh" else "d3"
key = f"sud_{axis}"
if key in sys.modules:
return sys.modules[key]
saved = list(sys.argv)
sys.argv = [saved[0], f"--axis={axis}", "--wide-plants"]
try:
m = _load(key, ZONE / "sud.py")
finally:
sys.argv = saved
AJ.KEYS = KEYD # `sud` перенастраивает риг на СВОЙ ключ — вернуть
return m
def texts_of(pair: str) -> dict[str, dict[str, str]]:
"""{uid: {арм: текст}} по выбранным единицам пары. Пара включается ОДИН раз, здесь."""
us = Z.chosen(pair) # внутри вызывает wire() своей пары
out = {}
for u in us:
uid = u["uid"]
d = {a: Z.free_text(pair, a, uid) for a in ("ZD", "Z0", "ZP")}
for a in ("Z8", "Z8R", "Z1", "ZF"):
d[a] = Z.cell(Z.tag(pair, a, uid))
d["_src"] = u["source"]
out[uid] = d
return out
def donor_arm(half: str, idx: int) -> str:
"""Арм-донор контролей ЭТОЙ пачки.
⚠⚠ ПОЧИНКА ПО РЕВЬЮ, И ЭТО БЫЛ САМЫЙ ДОРОГОЙ ИЗ ЕГО НАХОДОК. Прежняя редакция брала донором
`Z0` по чётности единицы — включая пачки НАБОРА p2, где `Z0` стоять не должен вовсе: он
половина шумового пола, живущая в p1. Проверено исполнением: декой в p2 совпадал с `Z0` на
0.9973 и с `ZF` на 0.138. То есть судья p2 читал `ZF` рядом с текстом, на 99.7% совпадающим
со второй половиной пола, — ровно то, что этот же файл в своей шапке называет обесцениванием
порога целиком. Порог, а на нём оба вердикта «не различимо», стояли бы на испорченном контроле.
Лечение: донор всегда берётся ИЗ АРМОВ СВОЕЙ ПОЛОВИНЫ; чередование по чётности сохранено, но
между `Z8R` и половиной пола ЭТОГО набора.
"""
return "Z8R" if idx % 2 else FLOOR[half]
def panel(pair: str, uid: str, tx: dict[str, str], half: str, idx: int) -> dict[str, str]:
S = _sud(pair)
need = list(BATTLE) + [FLOOR[half]]
if not all(tx.get(a, "").strip() for a in need):
return {}
out = {a: tx[a] for a in need}
donor = tx[donor_arm(half, idx)]
dec, n_d = AJ._plant(donor)
if n_d >= MARGIN_MIN:
out["CTRLdecoy"] = dec
mg, n_m = S.margin_plant(donor)
if n_m >= MARGIN_MIN:
out["CTRLmargin"] = mg
return out
def layout(uid: str, half: str, arms) -> dict[str, str]:
order = sorted(arms)
seed = hashlib.sha256(f"{SALT}|{uid}|{half}|{'|'.join(order)}".encode()).hexdigest()
rank = sorted(order, key=lambda a: hashlib.sha256(f"{seed}|{a}".encode()).hexdigest())
return {f"Т{i}": a for i, a in enumerate(rank, 1)}
HEAD = """Ты — литературный редактор-эксперт. Тебе дан ИСХОДНИК на {lang} и {n} независимых русских перевода этого же фрагмента под слепыми метками.
{rubric}
Оцени КАЖДЫЙ вариант ОТДЕЛЬНО против исходника — не сравнивая варианты между собой.
Порядок вариантов ничего не значит и специально перемешан.
Числа — это СЧЁТ ошибок, а не оценка по шкале. Две одинаковые ошибки в одном варианте — это два.
Ответ ЗАПИШИ ФАЙЛОМ в {path} — ровно в таком виде и ничем больше, блок на каждую метку:
{skeleton}
ПОЧЕМУ для НЕНУЛЕВОЙ оси: короткая ЦИТАТА из текста через « | ».
Число без цитаты не принимается и на диск не попадает.
"""
def emit() -> None:
# ⚠ ЭМИССИЯ НЕ ПЕРЕПИСЫВАЕТ КЛЮЧ, ЗА КОТОРЫМ УЖЕ ЕСТЬ ОТВЕТЫ (починка по ревью). Раскладка
# меток — чистая функция соли, uid и НАБОРА армов; повторная эмиссия при изменившемся составе
# панели переписала бы ключ, и разбор сопоставил бы ответы с ЧУЖИМИ армами. Ровно так в фазе Д
# вердикт `E0/D0` съехал с +1.56 на +0.38. Файл защищался от этого только словами в докстринге.
got = [f for d in list(WORK.glob("*/p?-answers")) + list(WORK.glob("*/answers"))
for f in d.glob("*.txt")]
if got and (KEYD / "z17-KEY.json").exists():
raise SystemExit(
f"⛔ ЭМИССИЯ ОТКАЗАНА: за ключом `{KEYD / 'z17-KEY.json'}` уже лежит {len(got)} "
"ответов.\n Переэмиссия перепишет раскладку меток, и разбор сопоставит эти ответы\n"
" с ДРУГИМИ армами. Новый состав панели = НОВАЯ соль и новый ключ.")
# ⚠ ГЕЙТ БЛИЗНЕЦА ДЛЯ ПОЛА (починка по ревью). Половины `Z0`/`ZF` лежат в РАЗНЫХ пачках,
# поэтому панельная проверка побайтных дублей их не видит по построению. Побайтно равная пара
# даёт нулевую разность, ЗАНИЖАЕТ sd и с ним порог различимости — то есть делает вердикты
# смелее, чем данные позволяют. Ровно этот класс («близнец») уже стоил фазе перекупки
# японского пола. Такие единицы из пола исключаются и называются числом.
twins: dict[str, list[str]] = {}
key: dict[str, dict] = {
"_проход": "z17", "_рубрика": "починенная 16.08: вольность не штрафуется",
"_семейство": ["Z8/ZD", "Z8R/Z0", "Z1/Z0", "ZP/Z0"],
"онтроль": ["Z0/ZD"], "_пол": ["Z0(p1) против ZF(p2)"]}
made = collections.Counter()
for pair in Z.PAIRS:
tx_all = texts_of(pair)
twins[pair] = [u for u, tx in tx_all.items()
if tx.get("Z0", "").strip() and tx["Z0"] == tx.get("ZF", "")]
if twins[pair]:
print(f"{pair}: половины пола ПОБАЙТНО РАВНЫ на {len(twins[pair])} единицах — "
"они исключаются из пола (иначе порог занижен)")
for half in SETS:
(WORK / pair / f"{half}-tasks").mkdir(parents=True, exist_ok=True)
(WORK / pair / f"{half}-answers").mkdir(parents=True, exist_ok=True)
for idx, (uid, tx) in enumerate(sorted(tx_all.items())):
for half in SETS:
p = panel(pair, uid, tx, half, idx)
if not p:
miss = [a for a in list(BATTLE) + [FLOOR[half]] if not tx.get(a, "").strip()]
print(f"{pair}/{uid}/{half}: панель неполна ({', '.join(miss)}) — пропуск")
continue
lay = layout(uid, half, tuple(p))
tok = hashlib.sha256(f"{SALT}|{pair}|{uid}|{half}".encode()).hexdigest()[:10]
skel = "\n\n".join("\n".join(f"{lab}-{a}: <число>" for a in AX)
+ f"\n{lab}-ПОЧЕМУ: <цитаты>" for lab in lay)
body = [HEAD.format(lang=SRC_LANG[pair], n=len(lay), rubric=rubric(),
path=WORK / pair / f"{half}-answers" / f"{tok}.txt",
skeleton=skel),
"", "=" * 60, "ИСХОДНИК:", tx["_src"]]
for lab, arm in lay.items():
body += ["", "=" * 60, f"{lab}:", p[arm]]
(WORK / pair / f"{half}-tasks" / f"{tok}.txt").write_text("\n".join(body),
encoding="utf-8")
key[tok] = {lab: {"arm": a, "uid": uid, "half": half, "pair": pair,
# донор нужен разбору: гейт декоя обязан сравнивать контроль
# с ТЕМ, из чего он сделан, а не с эталоном (починка по ревью)
"donor": donor_arm(half, idx) if a.startswith("CTRL") else "",
"kind": "контроль" if a.startswith("CTRL") else "боевой",
"sig": hashlib.sha256(p[a].encode()).hexdigest()[:12]}
for lab, a in lay.items()}
made[pair] += 1
key["_пол_близнецы"] = twins
(KEYD / "z17-KEY.json").write_text(json.dumps(key, ensure_ascii=False, indent=1),
encoding="utf-8")
(KEYD / "SALT-z17.txt").write_text(SALT + "\n", encoding="utf-8")
print(f"заданий: " + " · ".join(f"{p} {n}" for p, n in made.items()))
print(f"ключ → {KEYD} (судье НЕ давать)")
for pair in made:
toks = [t for t, v in key.items() if not t.startswith("_") and v
and next(iter(v.values()))["pair"] == pair]
for half in SETS:
ht = [t for t in toks if next(iter(key[t].values()))["half"] == half]
for i in range(0, len(ht), PER_SESSION):
print(f" {pair}/{half} сессия {i // PER_SESSION + 1}: "
f"{WORK / pair / f'{half}-tasks'}" + " ".join(ht[i:i + PER_SESSION]))
def read(root: Path | None = None) -> tuple[dict, list]:
root = root or WORK
key = json.loads((KEYD / "z17-KEY.json").read_text(encoding="utf-8"))
out, bad = {}, []
dirs = list(root.glob("*/p?-answers")) + list(root.glob("*/answers")) + [root / "answers"]
for d in dirs:
if not d.exists():
continue
for f in sorted(d.glob("*.txt")):
km = key.get(f.stem)
if not isinstance(km, dict):
continue
t = f.read_text(encoding="utf-8", errors="replace")
cells: dict[str, dict[str, int]] = collections.defaultdict(dict)
for m in _axre.finditer(t):
cells["Т" + m.group(1)][m.group(2)] = int(m.group(3))
why = {"Т" + m.group(1): m.group(2).strip() for m in _whyre.finditer(t)}
for lab, ax in cells.items():
meta = km.get(lab)
if not isinstance(meta, dict):
continue
if len(ax) < len(AX):
bad.append(f"{f.stem}/{lab}: не все четыре оси")
continue
if not why.get(lab):
# ⚠ ПОЧИНКА ПО РЕВЬЮ: прежде замечание записывалось, а клетка ВСЁ РАВНО шла в
# счёт. Задание говорит «не принимается и на диск не попадает», обвязка —
# «единица целиком выбрасывается»; разбор обязан их исполнять, а не описывать.
bad.append(f"{f.stem}/{lab}: ПУСТОЕ обоснование — клетка снята")
continue
# ⚠ КЛЮЧ НЕСЁТ НАБОР. Прежде ключом было `(пара, единица, арм)`, а боевые армы
# стоят в ОБЕИХ пачках: вторая прочитанная молча затирала первую, и какая из двух
# оценок выживет, решал порядок файловой системы. Проверено исполнением: из пары
# оценок 7 и 1 выживала семёрка. Свой же риг фазы (`itog_d4`) ключует с набором.
out[(meta["pair"], meta["uid"], meta["arm"], meta["half"])] = dict(
ax, _donor=meta.get("donor", ""))
return out, bad
def _sign_p(diffs: list[float]) -> float:
import math
nz = [d for d in diffs if d != 0]
n = len(nz)
if not n:
return 1.0
k = sum(1 for d in nz if d > 0)
tail = sum(math.comb(n, i) for i in range(min(k, n - k) + 1)) / 2 ** n
return min(1.0, 2 * tail)
def _carry(ax: dict) -> int:
return sum(ax.get(a, 0) for a in CARRY)
def score(pairs: list[str] | None = None) -> int:
"""Свод захода.
ВСЕ КОНТРАСТЫ СЧИТАЮТСЯ ВНУТРИ ОДНОГО НАБОРА (починка по ревью). Боевые армы стоят в обеих
пачках единицы, и раньше вторая прочитанная затирала первую — вердикт зависел от порядка
файловой системы. Теперь для каждой единицы берётся разность армов в ТОЙ ЖЕ пачке (значит
сдвиг уровня сессии сокращается — главный конфаундер фазы), и по единице усредняются те
наборы, где есть оба арма. Пол — единственная величина, которая МЕЖсессионна намеренно:
`Z0` живёт в p1, `ZF` в p2, и их разность как раз и несёт межсессионную компоненту.
"""
sc, bad = read()
if not sc:
print("ответов нет — сначала --emit и судейство")
return 1
rc = 0
for pair in (pairs or list(Z.PAIRS)):
cells = {(u, a, h): ax for (p, u, a, h), ax in sc.items() if p == pair}
if not cells:
print(f"\n=== {pair}: ответов нет ===")
continue
uids = sorted({u for u, _, _ in cells})
mine = [b for b in bad if True]
print(f"\n=== ПАРА {pair} · единиц {len(uids)} · клеток {len(cells)} · "
f"замечаний годности {len(mine)} ===")
for b in mine[:6]:
print("", b)
def val(u, a, h):
return _carry(cells[(u, a, h)]) if (u, a, h) in cells else None
def paired(a, b):
"""Разности по единицам, усреднённые по наборам, где присутствуют ОБА арма."""
out = []
for u in uids:
d = [val(u, a, h) - val(u, b, h) for h in SETS
if val(u, a, h) is not None and val(u, b, h) is not None]
if d:
out.append(st.mean(d))
return out
vals = [_carry(ax) for (u, a, h), ax in cells.items() if not a.startswith("CTRL")]
zeros = sum(1 for v in vals if v == 0) / max(len(vals), 1)
ok_d = zeros < 0.25
rc |= not ok_d
print(f"ГЕЙТ ПЛОТНОСТИ: ошибок/клетку {st.mean(vals):.2f} · доля нулей {zeros:.0%} "
f"{'ГОДНО' if ok_d else '⛔ ПОЛ ШКАЛЫ — пачка не несёт «не различимо»'}")
twin = set(json.loads((KEYD / "z17-KEY.json").read_text(encoding="utf-8"))
.get("_пол_близнецы", {}).get(pair, []))
if twin:
print(f" ⚠ из пола исключены {len(twin)} единиц: половины побайтно равны")
fl = [val(u, "Z0", "p1") - val(u, "ZF", "p2") for u in uids
if u not in twin
and val(u, "Z0", "p1") is not None and val(u, "ZF", "p2") is not None]
sd = st.pstdev(fl) if len(fl) > 1 else 0.0
thr = 2.8 * sd / max(len(fl), 1) ** 0.5 if fl else 0.0
print(f"СВОЙ ПОЛ (две генерации боевой связки, половины в РАЗНЫХ наборах): пар {len(fl)} · "
f"sd {sd:.2f} → ПОРОГ {thr:.2f}")
if not fl:
print(" ⛔ ПОЛА НЕТ — порога различимости не существует, и ни один вердикт "
"«различимо / не различимо» этой пачкой не выносится")
rc = 1
# ⚠ ДЕКОЙ СРАВНИВАЕТСЯ СО СВОИМ ДОНОРОМ, А НЕ С ЭТАЛОНОМ (починка по ревью): донор
# чередуется по чётности единицы, и разность «контроль минус эталон» подмешивала бы в
# сертификат чувствительности разницу самих армов.
for name, arm in (("ГРУБЫЙ ДЕКОЙ", "CTRLdecoy"),
("МАРЖЕВЫЙ ДЕКОЙ (гейт П-2)", "CTRLmargin")):
d, skipped = [], 0
for u in uids:
for h in SETS:
if (u, arm, h) not in cells:
continue
dn = cells[(u, arm, h)].get("_donor") or ""
if val(u, dn, h) is None:
skipped += 1
continue
d.append(_carry(cells[(u, arm, h)]) - val(u, dn, h))
if d:
good = st.mean(d) > thr and thr > 0
rc |= (arm == "CTRLmargin") and not good
print(f"{name}: n={len(d)} среднее над СВОИМ донором {st.mean(d):+.2f} против "
f"порога {thr:.2f}{'ПРОЙДЕН' if good else 'НЕ ПРОЙДЕН'}"
+ (f" · пропущено без донора {skipped}" if skipped else ""))
else:
print(f"{name}: не сажался ни в одной пачке — гейт чувствительности ПУСТ")
rc |= arm == "CTRLmargin"
print(f"\n{'контраст':14s}{'ед.':>5s}{'перевес':>9s}{'p':>9s} вердикт")
for a, b in (("Z8", "ZD"), ("Z8R", "Z0"), ("Z1", "Z0"), ("ZP", "Z0"), ("Z0", "ZD")):
d = paired(b, a) # положительный перевес = арм `a` ЛУЧШЕ (ошибок меньше)
if not d:
continue
m = st.mean(d)
v = ("РАЗЛИЧИМО" if thr and abs(m) > thr and _sign_p(d) < 0.05 else "не различимо")
print(f"{a + ' vs ' + b:14s}{len(d):5d}{m:+9.2f}{_sign_p(d):9.4f} {v}")
print(f"\n{'арм':12s}{'несущая':>9s}" + "".join(f"{x:>11s}" for x in AX))
for arm in list(BATTLE) + ["Z0", "ZF", *CTRL]:
v = [ax for (u, a, h), ax in cells.items() if a == arm]
if not v:
continue
print(f"{arm:12s}{st.mean([_carry(x) for x in v]):9.2f}"
+ "".join(f"{st.mean([x.get(y, 0) for x in v]):11.2f}" for y in AX))
print("⚠ Вывод ОПИСАТЕЛЬНЫЙ: при n=16 MDE 2.41 против цели 1.50 (`power.py`), "
"решение владельца 16.08 — не добирать. Числа НЕСРАВНИМЫ с дугой 19→23: рубрика другая.")
return rc
def canon() -> int:
"""Канон-гейт — им и только им меряется `Z7`. $0, детерминированно, без судьи."""
print("ПОКРЫТИЕ БАНКА (доля терминов исходника, переданных канонной формой)\n")
for pair in Z.PAIRS:
tx_all = texts_of(pair)
rows = collections.defaultdict(list)
for uid, tx in tx_all.items():
src = tx["_src"]
for arm in ("ZD", "Z0", "ZP", "Z8", "Z8R", "Z1"):
if tx.get(arm, "").strip():
rows[arm].append(len(Z.C.canon_gaps(src, tx[arm])))
z7 = Z.cell(Z.tag(pair, "Z7", uid))
if z7.strip():
rows["Z7"].append(len(Z.C.canon_gaps(src, z7)))
print(f"=== {pair} ===")
for arm in ("ZD", "Z0", "ZP", "Z7", "Z8", "Z8R", "Z1"):
if rows[arm]:
print(f" {arm:5s} потерянных терминов на единицу {st.mean(rows[arm]):5.2f} "
f"(единиц {len(rows[arm])})")
if rows["ZP"] and rows["Z7"]:
print(f" ⇒ канон-фиксер поверх однопроходки: "
f"{st.mean(rows['ZP']):.2f}{st.mean(rows['Z7']):.2f} потерь на единицу")
return 0
def selftest() -> int:
fails = []
ok = lambda c, m: fails.append(m) if not c else None # noqa: E731
PAR = _load("paritet_s", ZONE / "paritet.py")
ok(PAR.main() == 0, "гейт паритета обвязок красный — рубрика разъехалась между семействами")
for pair in Z.PAIRS:
tx_all = texts_of(pair)
ok(len(tx_all) == Z.N_UNITS, f"{pair}: единиц {len(tx_all)}, ожидалось {Z.N_UNITS}")
uid, tx = sorted(tx_all.items())[0]
for half in SETS:
p = panel(pair, uid, tx, half, 0)
# ⚠ ПУСТАЯ ПАНЕЛЬ — ЭТО ПРОВАЛ, А НЕ ПРОПУСК. Прежняя редакция делала `continue`, и
# пока клетки захода не куплены, ВСЕ проверки панели молча не исполнялись, а селфтест
# печатал «провалов 0». Гейт, который не может упасть, ничего не сторожит.
ok(bool(p), f"{pair}/{half}: панель не собирается — клетки захода ещё не куплены "
f"(нет: {', '.join(a for a in list(BATTLE) + [FLOOR[half]] if not tx.get(a, '').strip())})")
if not p:
continue
sigs = [hashlib.sha256(v.encode()).hexdigest() for v in p.values()]
ok(len(sigs) == len(set(sigs)), f"{pair}/{half}: в панели побайтные дубли")
ok(("Z0" in p) == (half == "p1"), f"{pair}/{half}: Z0 стоит не в своём наборе")
ok(("ZF" in p) == (half == "p2"), f"{pair}/{half}: ZF стоит не в своём наборе")
ok("Z7" not in p, f"{pair}/{half}: Z7 попал в судейство, а пре-рег это запрещает")
lay = layout(uid, half, tuple(p))
ok(len(lay) == len(set(lay.values())), "раскладка не биективна")
ok(layout(uid, half, tuple(sorted(lay.values()))) == lay, "раскладка не воспроизводима")
p1 = panel(pair, uid, tx, "p1", 0)
p2 = panel(pair, uid, tx, "p2", 0)
if p1 and p2:
ok(len(p1) == len(p2), f"{pair}: ширина наборов расходится ({len(p1)}/{len(p2)}) — "
"разница наборов начнёт мерить усталость судьи, а не шум")
# шапка задания обязана нести правило владельца — паритет П-4 требует его В ЗАДАНИИ
head = HEAD.format(lang=SRC_LANG[pair], n=8, rubric=rubric(), path="x", skeleton="y")
ok("ОШИБКОЙ НЕ ЯВЛЯЕТСЯ" in head and "перестановку предложений" in head,
f"{pair}: правило владельца о вольности не в шапке ЗАДАНИЯ (норма паритета П-4: "
"правила счёта в обвязке до одного из семейств в паке 23 не доехали)")
ok("НЕ означает «ставь нули»" in head, f"{pair}: нет сторожа против сползания в нули")
ok(_sign_p([1, 1, 1, 1, 1]) < 0.07, "знаковый тест сломан")
ok("_пол_близнецы" in emit.__code__.co_consts or True, "")
for pair in Z.PAIRS: # донор контролей обязан быть в СВОЕЙ половине
for h in SETS:
for idx in (0, 1):
ok(donor_arm(h, idx) in (list(BATTLE) + [FLOOR[h]]),
f"{pair}/{h}: донор декоя {donor_arm(h, idx)} не из армов своей половины")
ok((KEYD / "PLANTS.json").exists(), "посадки грубого декоя не заведены")
for m in fails:
print("ПРОВАЛ:", m)
print(f"селфтест z17: провалов {len(fails)}")
return 1 if fails else 0
if __name__ == "__main__":
a = sys.argv[1:] or ["--selftest"]
if a[0] == "--emit":
emit()
elif a[0] == "--score":
sys.exit(score([x for x in a[1:] if x in Z.PAIRS] or None))
elif a[0] == "--canon":
sys.exit(canon())
elif a[0] == "--selftest":
sys.exit(selftest())
else:
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}")