561 lines
38 KiB
Python
561 lines
38 KiB
Python
#!/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}")
|