diff --git a/eval/dovodka/chtenie.py b/eval/dovodka/chtenie.py new file mode 100644 index 00000000..b9584ad2 --- /dev/null +++ b/eval/dovodka/chtenie.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""ПРИБОР ТКАНИ — слепое ранжирование читаемости. $0. + +⚠ ЗАЧЕМ. Весь риг 19→23 считает ЛОКАЛЬНЫЕ ошибки внутри чанка и по построению не видит того, что +владелец назвал целью №1: победы над translationese. Пре-рег П-6 завёл под это слепое ранжирование +издательской пригодности, владелец 11.08 сказал «берём» — и оно не было построено. + +Файл прошёл две редакции, и обе живут здесь, а не в двух файлах: + 1. КАЛИБРОВКА 16.08 — одна единица на ось, без контролей. Читал Fable по поручению владельца. + Дала результат, ради которого всё дальнейшее и делается: на английской оси Спирмен между + счётом ошибок и читаемостью **−0.10**, связи нет. Улики: `~/books/chtenie-vladeltsa/ОТВЕТ-*.md`, + ключ `~/books/dovodka/blind-keys-chtenie/`. Пере-считать: `--score-calib`. + 2. ПОЛНАЯ ПАНЕЛЬ захода Д17 — выборка единиц, ДВА независимых читателя и контроли, которых у + калибровки не было: + · КОНТРОЛЬ ДЕКОЯ: намеренно испорченный вариант обязан ранжироваться последним; + · КОНТРОЛЬ ПОЛА: две генерации ОДНОГО редактора (`Z0`/`ZF`) обязаны оказаться СОСЕДЯМИ — + развёл далеко, значит порядок читателя есть шум, и «победа» внутри этого расстояния + ничего не значит. Это для чтения то же, чем шумовой пол служит счёту. + +⚠ ЧЕГО ПРИБОР НЕ МОЖЕТ. Абсолютной оценки качества он не даёт и читателя-человека не заменяет: +читают модели. Он отвечает на ОДИН вопрос — совпадает ли порядок по читаемости с порядком по +счёту ошибок, — и потому решает судьбу СЧЁТА как дешёвого прокси, а не судьбу армов. + +Запуск: chtenie.py --emit | --score | --selftest | --score-calib +""" +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 + + +ZS = _load("zsud_c", ZONE / "zsud.py") +Z = ZS.Z + +WORK = Path.home() / "books" / "chtenie-z17" +KEYD = Path.home() / "books" / "dovodka" / "blind-keys-chtenie-z17" +CALIB = Path.home() / "books" / "chtenie-vladeltsa" +CALIB_KEY = Path.home() / "books" / "dovodka" / "blind-keys-chtenie" / "chtenie-KEY.json" +SALT = "chtenie-z17-2026-08-16" +N_UNITS = 8 # на ось; больше читатель за раз честно не осилит +READERS = ("A", "B") # два независимых читателя РАЗНЫХ семейств +# Единиц в задании: китайская глава ~9.7 тыс. знаков × 7 вариантов = 68 тыс. на единицу, +# английская ~1.9 тыс. × 7 = 13 тыс. Поэтому китайские читаются по одной, английские — по четыре. +PER_TASK = {"zh": 1, "en": 4} +PANEL = ("ZD", "Z0", "ZF", "ZP", "Z8R", "Z1") +DECOY = "CTRLdecoy" + +HEAD = """# Слепое чтение — {pair}, задание {k} + +## Что это и зачем + +Наш измерительный аппарат считает ЛОКАЛЬНЫЕ ОШИБКИ: искажения смысла, кальки, сбитую вёрстку — +штука за штукой, внутри отрывка. Это дёшево и воспроизводимо, но у такого прибора есть встроенная +слепота: **текст может быть без единой ошибки и при этом мёртвый**. Серая гладкая проза получит +«претензий нет», а читатель закроет книгу. + +Поэтому здесь спрашивают ЧИТАТЕЛЯ. Ниже — один и тот же отрывок в нескольких вариантах перевода, +сделанных разными способами. Способы не названы намеренно: метки перемешаны, соответствие +«метка → способ» лежит в отдельном файле и до твоего ответа не открывается. + +Твой ответ решает не «какой способ лучше» — он решает, **совпадает ли человеческий порядок с тем, +что печатает наш счётчик ошибок**. Совпал — счёт можно и дальше использовать как дешёвую замену +чтению. Разошёлся — значит все выводы мерили не то. + +## Как читать + +Читай как ЧИТАТЕЛЬ, а не как корректор. Вопрос один: **какой текст ты взял бы в книгу**. +Главный критерий — живая русская проза против переводного канцелярита. Смысловые ошибки, если +заметишь, называй, но ранжируй по читаемости. + +⚠ **Три вещи, которые могут тебя сбить, — говорю заранее.** + +1. **Вёрстка выдаёт метод.** Часть вариантов прошла полное переписывание, а оно обязано сливать + построчный исходник в русские абзацы; остальные вёрстку исходника сохраняют. Разницу видно ДО + чтения — не выводи из неё, «какой это способ», и не давай ей решать за тебя. +2. **Не угадывай гипотезы.** Не пытайся понять, каким способом сделан вариант, и не проверяй по + ходу, совпало ли с ожиданием. Запиши порядок ДО любых рассуждений о методах. +3. **Похожие варианты — это нормально.** Часть вариантов может оказаться очень близка друг к + другу. Не различаешь — так и напиши через «=», это законный и ценный ответ. + +## Что записать + +Ответ — в файл `{answer}`. На КАЖДЫЙ отрывок обязательно две вещи: + +1. Строка `ПОРЯДОК <номер отрывка>: Т3 > Т1 > Т5 > Т2 > Т4 > Т6 > Т7` — от лучшего к худшему. + Неразличимые варианты соединяй знаком `=`. +2. По одной-две фразы на вариант: почему. Годится и «серо, но гладко», и «живой ритм, но во + втором абзаце потерян смысл». + +Если какой-то вариант покажется откровенно машинным — скажи прямо, это самая ценная информация. + +## Границы + +* Не открывай на диске ничего, кроме этого задания, и не пиши никуда, кроме указанного файла. +* Не выясняй происхождение вариантов; не применяй `diff`, `difflib`, `cmp` и механическое сличение. +* Не запускай своих агентов: сессия — это ты один. +* Не переводи сам и не предлагай свою версию. +""" + + +def pick(pair: str) -> list[str]: + """N_UNITS единиц из ОТСУЖЕННОГО набора — чтобы порядки читателя и счётчика сравнивались + на одних и тех же текстах. Отбор детерминирован своей солью.""" + uids = sorted(u["uid"] for u in Z.chosen(pair)) + return sorted(uids, key=lambda u: hashlib.sha256(f"{SALT}|{pair}|{u}".encode()).hexdigest() + )[:N_UNITS] + + +def variants(pair: str, tx: dict[str, str]) -> dict[str, str]: + """⚠ ПОЛНАЯ ПАНЕЛЬ ИЛИ НИЧЕГО (починка по ревью). Прежняя редакция отдавала читателю столько + вариантов, сколько куплено, — а средние ранги, снятые на пятёрке и на восьмёрке вариантов, + несравнимы между собой (ранг 1..5 против 1..8). Неполная единица просто не эмитируется.""" + if not all(tx.get(a, "").strip() for a in PANEL): + return {} + out = {a: tx[a] for a in PANEL} + dec, n = ZS.AJ._plant(tx.get("Z0", "")) + if n >= ZS.MARGIN_MIN: + out[DECOY] = dec + return out + + +def layout(pair: str, uid: str, reader: str, arms) -> dict[str, str]: + """Своя раскладка КАЖДОМУ читателю: одинаковая давала бы им общий позиционный наклон.""" + order = sorted(arms) + seed = hashlib.sha256(f"{SALT}|{pair}|{uid}|{reader}|{'|'.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)} + + +def emit() -> None: + key: dict = {"_что": "слепое чтение по полной панели захода Д17 (П-6)", "_соль": SALT, + "_контроли": ["декой обязан быть последним", "Z0 и ZF обязаны быть соседями"]} + made = 0 + for pair in Z.PAIRS: + tx_all = ZS.texts_of(pair) + uids = [u for u in pick(pair) if variants(pair, tx_all[u])] + for reader in READERS: + d = WORK / pair / reader + (d / "tasks").mkdir(parents=True, exist_ok=True) + (d / "answers").mkdir(parents=True, exist_ok=True) + step = PER_TASK[pair] + for k in range(0, len(uids), step): + grp = uids[k:k + step] + tok = hashlib.sha256(f"{SALT}|{pair}|{reader}|{grp[0]}".encode()).hexdigest()[:10] + ans = d / "answers" / f"{tok}.md" + body = [HEAD.format(pair=("китайская ось" if pair == "zh" else "английская ось"), + k=k // step + 1, answer=ans)] + for j, uid in enumerate(grp, 1): + vs = variants(pair, tx_all[uid]) + lay = layout(pair, uid, reader, tuple(vs)) + body += ["", "=" * 70, f"## ОТРЫВОК {j} — ИСХОДНИК", "", + tx_all[uid]["_src"], ""] + for lab, arm in lay.items(): + body += ["-" * 70, f"### ОТРЫВОК {j}, вариант {lab}", "", vs[arm], ""] + key.setdefault(tok, {})[str(j)] = { + "uid": uid, "pair": pair, "reader": reader, "метки": lay, + "знаков": {a: len(t) for a, t in vs.items()}} + (d / "tasks" / f"{tok}.md").write_text("\n".join(body), encoding="utf-8") + made += 1 + KEYD.mkdir(parents=True, exist_ok=True) + (KEYD / "chtenie-z17-KEY.json").write_text(json.dumps(key, ensure_ascii=False, indent=1), + encoding="utf-8") + print(f"заданий {made} → {WORK}/<пара>/<читатель>/tasks") + print(f"ключ → {KEYD} (читателю НЕ давать)") + + +# ⚠ Латинская `T` принимается наравне с кириллической `Т` (починка по ревью): судьи уже писали +# латиницей, а `zsud`/`ja6` это учитывают. Молча выпавший ответ читателя стоил бы целой сессии. +_ORD = re.compile(r"ПОРЯДОК\s*(\d+)\s*:\s*((?:[ТT]\d+\s*[>=]\s*)+[ТT]\d+)", re.IGNORECASE) + + +def _ranks(order: str) -> dict[str, float]: + """Метка → ранг (1 = лучший). Связанные через `=` получают средний ранг.""" + out, pos = {}, 1 + for g in [x.strip() for x in re.split(r">", order)]: + labs = [x.replace("T", "Т") for x in re.findall(r"[ТT]\d+", g)] + for x in labs: + out[x] = pos + (len(labs) - 1) / 2 + pos += len(labs) + return out + + +def _rank(v: list[float]) -> list[float]: + """Ранги со средним для связок — как `scipy.rankdata`, сверено с ним.""" + order = sorted(range(len(v)), key=lambda i: v[i]) + out = [0.0] * len(v) + i = 0 + while i < len(order): + j = i + while j + 1 < len(order) and v[order[j + 1]] == v[order[i]]: + j += 1 + r = (i + j) / 2 + 1 + for k in range(i, j + 1): + out[order[k]] = r + i = j + 1 + return out + + +def _spearman(a: list[float], b: list[float]) -> float: + """⚠ ПОЧИНКА ПО РЕВЬЮ: прежняя редакция называлась Спирменом, а считала ПИРСОНА по входам. + Там, где на вход шли СРЕДНИЕ ранги и СРЕДНИЕ счёты ошибок (решающий гейт «следит ли счёт за + читаемостью»), это давало другое число: на демо-входах своё +0.79 против настоящего +1.00. + Гейт `rho > 0.5` — по пре-регу Д17 решающий эндпойнт, и он не имеет права быть приблизительным. + """ + if len(a) < 3: + return 0.0 + a, b = _rank(a), _rank(b) + ma, mb = st.mean(a), st.mean(b) + num = sum((x - ma) * (y - mb) for x, y in zip(a, b)) + da = sum((x - ma) ** 2 for x in a) ** 0.5 + db = sum((y - mb) ** 2 for y in b) ** 0.5 + return num / (da * db) if da and db else 0.0 + + +def score() -> int: + kf = KEYD / "chtenie-z17-KEY.json" + if not kf.exists(): + print("ключа нет — сначала --emit") + return 1 + key = json.loads(kf.read_text(encoding="utf-8")) + sc, _bad = ZS.read() + ranks: dict[tuple, dict[str, float]] = {} + for tok, blocks in key.items(): + if tok.startswith("_"): + continue + meta0 = next(iter(blocks.values())) + f = WORK / meta0["pair"] / meta0["reader"] / "answers" / f"{tok}.md" + if not f.exists(): + continue + got = {m.group(1): m.group(2) for m in _ORD.finditer( + f.read_text(encoding="utf-8", errors="replace"))} + for j, meta in blocks.items(): + if j not in got: + print(f" ⚠ {tok}/отрывок {j}: порядка нет") + continue + r = _ranks(got[j]) + ranks[(meta["pair"], meta["uid"], meta["reader"])] = { + arm: r[lab] for lab, arm in meta["метки"].items() if lab in r} + if not ranks: + print("ответов читателей нет") + return 1 + rc = 0 + for pair in Z.PAIRS: + rows = {k: v for k, v in ranks.items() if k[0] == pair} + if not rows: + continue + print(f"\n=== ПАРА {pair} · прочитано единиц-читателей {len(rows)} ===") + bad_d = [k for k, v in rows.items() if DECOY in v and v[DECOY] < max(v.values())] + have_d = sum(1 for v in rows.values() if DECOY in v) + print(f"КОНТРОЛЬ ДЕКОЯ: не последним в {len(bad_d)} чтениях из {have_d} → " + f"{'ГОДНО' if not bad_d else '⛔ читатель невнимателен'}") + rc |= bool(bad_d) + gaps = [abs(v["Z0"] - v["ZF"]) for v in rows.values() if "Z0" in v and "ZF" in v] + if gaps: + far = sum(1 for g in gaps if g > 2) + print(f"КОНТРОЛЬ ПОЛА (две генерации одного редактора): медиана расстояния " + f"{st.median(gaps):.1f} позиции · развёл дальше двух в {far} из {len(gaps)} → " + f"{'ГОДНО' if far <= len(gaps) // 4 else '⛔ порядок читателя = шум'}") + rc |= far > len(gaps) // 4 + per_reader = collections.defaultdict(dict) + for (_p, uid, rd), v in rows.items(): + per_reader[rd][uid] = v + if len(per_reader) == 2: + (ra, va), (rb, vb) = sorted(per_reader.items()) + rr = [_spearman([va[u][x] for x in va[u] if x in vb[u]], + [vb[u][x] for x in va[u] if x in vb[u]]) for u in va if u in vb] + if rr: + print(f"СОГЛАСИЕ ЧИТАТЕЛЕЙ {ra}/{rb}: Спирмен по единицам {st.mean(rr):+.2f} " + f"(единиц {len(rr)})") + avg = {a: st.mean([v[a] for v in rows.values() if a in v]) + for a in PANEL + (DECOY,) if any(a in v for v in rows.values())} + print(f"\n{'арм':12s}{'ранг чтения':>13s}{'ошибок/ед.':>12s}") + xy = [] + for a in sorted(avg, key=lambda x: avg[x]): + errs = [ZS._carry(ax) for (pp, u, aa, _h), ax in sc.items() + if pp == pair and aa == a and u in {k[1] for k in rows}] + print(f"{a:12s}{avg[a]:13.2f}" + + (f"{st.mean(errs):12.2f}" if errs else f"{'—':>12s}")) + if errs and not a.startswith("CTRL"): + xy.append((avg[a], st.mean(errs))) + if len(xy) >= 3: + rho = _spearman([x for x, _ in xy], [y for _, y in xy]) + print(f"\nСПИРМЕН «ранг чтения против счёта ошибок»: {rho:+.2f} — " + + ("счёт следит за читаемостью, прокси годен" if rho > 0.5 else + "⛔ СЧЁТ НЕ СЛЕДИТ ЗА ЧИТАЕМОСТЬЮ: по пре-регу Д17 побеждает ЧТЕНИЕ")) + print("\n⚠ Читают модели, не человек. Прибор отвечает на вопрос «годен ли счёт как прокси», " + "а не «какой арм лучше вообще».") + return rc + + +def score_calib() -> int: + """Де-слепление КАЛИБРОВКИ 16.08: печатает, какой арм стоял за какой меткой в ответе + читателя. + + ⚠ ЧЕСТНАЯ ГРАНИЦА (правка по ревью): режим НЕ пере-считывает корреляцию −0.10 — он + печатает только порядок читателя. Прежний докстринг обещал воспроизводимость этого + числа, и обещание было ложным. Само число проверено независимо (адверсариальное ревью + 16.08 пере-считало его по рангам из ключа и панели Д3 — вышло ровно −0.10), но КОМАНДЫ, + делающей это одним вызовом, нет. Долг назван, а не замаскирован.""" + if not CALIB_KEY.exists(): + print("ключа калибровки нет") + return 1 + key = json.loads(CALIB_KEY.read_text(encoding="utf-8")) + for axis in ("zh", "en"): + f = CALIB / f"ОТВЕТ-{axis}.md" + if not f.exists() or axis not in key: + print(f"{axis}: ответа нет") + continue + m = re.search(r"((?:Т\d\s*[>=]\s*)+Т\d)", f.read_text(encoding="utf-8")) + if not m: + print(f"{axis}: порядок не найден") + continue + lay = key[axis]["метки"] + print(f"\n{axis}: единица {key[axis]['uid']} · порядок читателя (лучший → худший):") + for i, lab in enumerate(re.findall(r"Т\d", m.group(1)), 1): + print(f" {i}. {lab} = {lay.get(lab, '?')}") + return 0 + + +def selftest() -> int: + fails = [] + ok = lambda c, m: fails.append(m) if not c else None # noqa: E731 + r = _ranks("Т3 > Т1 = Т5 > Т2") + ok(r == {"Т3": 1.0, "Т1": 2.5, "Т5": 2.5, "Т2": 4.0}, f"разбор порядка сломан: {r}") + ok(abs(_spearman([1, 2, 3], [1, 2, 3]) - 1.0) < 1e-9, "Спирмен не даёт +1 на совпадении") + ok(abs(_spearman([1, 2, 3], [3, 2, 1]) + 1.0) < 1e-9, "Спирмен не даёт −1 на инверсии") + m = _ORD.search("ПОРЯДОК 2: Т3 > Т1 > Т5") + ok(bool(m) and m.group(1) == "2", "строка порядка не находится") + for pair in Z.PAIRS: + u = pick(pair) + ok(len(u) == N_UNITS, f"{pair}: единиц {len(u)}, ожидалось {N_UNITS}") + ok(set(u) <= {x["uid"] for x in Z.chosen(pair)}, + f"{pair}: единицы чтения не подмножество отсуженных — Спирмен считать не на чем") + la, lb = layout(pair, u[0], "A", PANEL), layout(pair, u[0], "B", PANEL) + ok(la != lb, f"{pair}: у читателей ОДНА раскладка — общий позиционный наклон") + ok(layout(pair, u[0], "A", PANEL) == la, f"{pair}: раскладка не воспроизводима") + for pair in Z.PAIRS: # панель чтения обязана СОБИРАТЬСЯ, иначе гейт пуст + tx_all = ZS.texts_of(pair) + n = sum(1 for u in pick(pair) if variants(pair, tx_all[u])) + ok(n == N_UNITS, f"{pair}: панель чтения собирается на {n} единицах из {N_UNITS} — " + "клетки захода не куплены, эмиссия дала бы неполные пачки") + for m in fails: + print("ПРОВАЛ:", m) + print(f"селфтест чтения: провалов {len(fails)}") + return 1 if fails else 0 + + +if __name__ == "__main__": + a = sys.argv[1:] or ["--selftest"] + fn = {"--emit": emit, "--score": lambda: sys.exit(score()), + "--score-calib": lambda: sys.exit(score_calib()), + "--selftest": lambda: sys.exit(selftest())}.get(a[0]) + if not fn: + raise SystemExit(f"⛔ неизвестный режим {a[0]!r}") + fn() diff --git a/eval/dovodka/paritet.py b/eval/dovodka/paritet.py index e7bb89ce..c7decd03 100644 --- a/eval/dovodka/paritet.py +++ b/eval/dovodka/paritet.py @@ -53,7 +53,9 @@ def main() -> int: norm(c) in norm(f.read_text(encoding="utf-8"))) # ядро обязано нести правила, купленные дорого прошлыми паками must = ["ОТДЕЛЬНО против исходника", "требует обоснования ЦИТАТОЙ", - "Ноль — это утверждение", "потерянное отрицание", "Не ищи и не открывай ничего другого"] + "Ноль — это утверждение", "потерянное отрицание", "Не ищи и не открывай ничего другого", + # решение владельца 16.08, меняющее шкалу: см. `zsud.RUBRIC_*` и эрратy Д18 + "переврать смысл исходника — ошибка; обойтись с его буквой вольно — нет"] for m in must: ck(f"ядро несёт правило: «{m[:44]}»", norm(m) in norm(c)) print(f"\n{'ПАРИТЕТ ОБВЯЗОК СОБЛЮДЁН' if not bad else f'ПРОВАЛОВ: {bad}'}") diff --git a/eval/dovodka/power.py b/eval/dovodka/power.py index 7ede58ce..6d161afa 100644 --- a/eval/dovodka/power.py +++ b/eval/dovodka/power.py @@ -63,8 +63,36 @@ AXES = [ "и продуктового смысла при цене 28× не имеет. ⚠ Именно этот порог решил спор о числе " "клеток: при n=12 (минимум промта) MDE 2.11 БОЛЬШЕ цели и ось была бы ОПИСАТЕЛЬНОЙ; " "владелец 10.08 выбрал полные 16, и только на них ось несущая"), + # ⚠ ЗАХОД Д17 ВПИСАН 16.08, УЖЕ ПОСЛЕ НАЧАЛА ПОКУПКИ — ЭТО ДЕВИАЦИЯ, И ОНА ОБЪЯВЛЕНА ЗДЕСЬ, + # А НЕ ПРИМЕЧАНИЕМ ПОСТ-ФАКТУМ. Норма заказа (:168) требует НАПЕЧАТАТЬ MDE при плановом n до + # денег; пре-рег Д17.4 объявил ПРАВИЛО («MDE считается по своему полу, не проходит цель — ось + # описательная»), но числа не напечатал, хотя инструмент для этого стоит в зоне. Покупка к + # моменту записи прошла ~40 клеток из 160. Числа ниже посчитаны ДО первого судейского ответа, + # то есть право пере-классифицировать ось до вскрытия вердиктов сохранено. + # ⚠ И ГЛАВНАЯ ОГОВОРКА ПРАЙОРА: все полы выше сняты СТАРОЙ рубрикой, а заход судится + # ПОЧИНЕННОЙ («вольность не штрафуется»), которая по построению убирает часть счёта. Перенос + # прайора между шкалами — допущение, а не замер, и потому статус оси решает НЕ он, а СВОЙ пол + # захода (`ZF` против `Z0`, две генерации боевой связки), снятый на новой шкале. + ("Д17 заход zh (новая рубрика)", 16, 4, 2.12, 1.50, + "армы правят ОДНУ базу — структура `tier`/`border` (sd 1.45/2.12), берётся худшая; " + "⚠ прайор снят СТАРОЙ рубрикой и на новую шкалу переносится как допущение", + "та же цель, что у Д4: решение «менять архитектуру» стоит принимать при разрыве ≥ трети " + "собственного эффекта редактора (+4.44 эксп-22 Ф3) — это 1.50"), + ("Д17 заход en (новая рубрика)", 16, 4, 2.12, 1.50, + "своего пола на en в новой шкале нет; прайор с zh той же структуры, свой пол докупается", + "цель снижена с 2.00 до 1.50 против оси Д3 сознательно: там вопрос был «переносится ли " + "эффект редактора вообще», здесь — «какая из четырёх архитектур лучше», и решение о смене " + "архитектуры принимается при меньшем разрыве"), ] -FORCED_DESCRIPTIVE = {"Д6 ja→ru разведка"} # объявлено промтом, арифметикой не отменяется +# ⚠ РЕШЕНИЕ ВЛАДЕЛЬЦА 16.08, ПРИНЯТО ДО ЕДИНОГО СУДЕЙСКОГО ОТВЕТА. Мощность захода посчитана уже +# после начала покупки (девиация объявлена выше), и она показала: при n=16 и цели 1.50 несущий +# вывод недостижим — нужен свой пол sd ≤ 1.32, а все замеренные полы фазы 1.45…2.63. Владельцу +# предложено добрать китайскую ось до 31 главы за $0.48 (тогда порог требований падает до 1.83, +# то есть достижим); он выбрал НЕ добирать. Значит обе оси захода ОПИСАТЕЛЬНЫЕ, и решающим +# эндпойнтом остаётся слепое чтение — ровно так, как пре-рег Д17 и объявил заранее. +# Пере-классификации задним числом не будет ни при каком исходе арифметики. +FORCED_DESCRIPTIVE = {"Д6 ja→ru разведка", + "Д17 заход zh (новая рубрика)", "Д17 заход en (новая рубрика)"} # ⚠ ПЕРВИЧНОЕ И ВТОРИЧНОЕ СЕМЕЙСТВА — объявляются ДО покупок, и это НЕ сужение задним числом. # Поправка Холма делится на РАЗМЕР семейства, поэтому «взять все контрасты несущими» = сделать @@ -78,6 +106,18 @@ PRIMARY = { "A6/A0 — качественный черновик + контур (H-1)"], "Д3 en→ru несущая": ["E0/D0 — КОНТРОЛЬ: покупает ли редактор поверх черновика (H-3)"], "Д1 gemini добивка": ["R1/R0 — аутлаер ли проза gemini (гипотеза эксп-04)"], + # Заход Д17: по одному контрасту на КАЖДЫЙ вопрос, ради которого куплен арм. `Z7` в семействе + # нет намеренно — он меряется детерминированным канон-гейтом и судейского слота не занимает. + "Д17 заход zh (новая рубрика)": [ + "Z8/ZD — чинит ли черновик обогащённый промт (вопрос владельца 16.08)", + "Z8R/Z0 — лучше ли связка на лучшем черновике (H-1 в сильной форме)", + "Z1/Z0 — критик → ПОЛНЫЙ перепис (H-2б в сильнейшей форме)", + "ZP/Z0 — однопроходка против связки на ПОЧИНЕННОЙ шкале"], + "Д17 заход en (новая рубрика)": [ + "Z8/ZD — чинит ли черновик обогащённый промт (вопрос владельца 16.08)", + "Z8R/Z0 — лучше ли связка на лучшем черновике (H-1 в сильной форме)", + "Z1/Z0 — критик → ПОЛНЫЙ перепис (H-2б в сильнейшей форме)", + "ZP/Z0 — однопроходка против связки на ПОЧИНЕННОЙ шкале"], } # ⚠⚠ n У КАЖДОГО КОНТРАСТА СВОЙ, И ЭТО РЕШАЕТ. Прежняя редакция печатала MDE и статус ПО ОСИ при # плановом n=32 — а члены первичного семейства живут на разных выборках, потому что база у них @@ -92,6 +132,14 @@ CONTRAST_N = { "A6/A0 — качественный черновик + контур (H-1)": 16, "E0/D0 — КОНТРОЛЬ: покупает ли редактор поверх черновика (H-3)": 16, "R1/R0 — аутлаер ли проза gemini (гипотеза эксп-04)": 16, + # ⚠ У захода n=16 у ВСЕХ четырёх контрастов, и это не совпадение, а условие отбора единиц: + # в пул берутся только те, где ВСЕ бесплатные армы и ответ критика уже есть (`zakhod.pool`). + # Именно поконтрастная проверка n погубила носителя H-1 (`A6/A0` жил на 16 из 32) — здесь + # разъехаться выборкам структурно не на чем. + "Z8/ZD — чинит ли черновик обогащённый промт (вопрос владельца 16.08)": 16, + "Z8R/Z0 — лучше ли связка на лучшем черновике (H-1 в сильной форме)": 16, + "Z1/Z0 — критик → ПОЛНЫЙ перепис (H-2б в сильнейшей форме)": 16, + "ZP/Z0 — однопроходка против связки на ПОЧИНЕННОЙ шкале": 16, } SECONDARY = { @@ -199,16 +247,19 @@ def main() -> int: m = mde(sd, n_c, k) ok = m <= target if not ok: - weak.append((c, n_c, m, target)) + weak.append((c, n_c, m, target, k)) print(f" {c[:56]:56s}{n_c:4d}{m:7.2f}{target:7.2f} " f"{'НЕСУЩИЙ' if ok else '⛔ НЕДОМОЩЕН'}") if weak: print("\n ⛔ НЕДОМОЩНЫЕ КОНТРАСТЫ ПЕРВИЧНОГО СЕМЕЙСТВА — объявляются ОПИСАТЕЛЬНЫМИ:") - for c, n_c, m, t in weak: - need_sd = t * math.sqrt(n_c) / ((_t(1 - 0.05 / 6, PRIOR_DF) + _t(0.80, PRIOR_DF)) - * BETWEEN) - need_n = math.ceil((((_t(1 - 0.05 / 6, PRIOR_DF) + _t(0.80, PRIOR_DF)) - * 2.12 * BETWEEN) / t) ** 2) + # ⚠ ПОЧИНКА ПО РЕВЬЮ: множитель Холма был ЗАШИТ как k=3 (`0.05 / 6`) для всех осей, тогда + # как сам `mde()` считает по настоящему k. Печатались ремонтные числа мягче честных: для + # контрастов захода (k=4) выходило «sd ≤ 1.37 либо n ≥ 39» вместо честных 1.32 и 42. + # Купив по этой строке пол с sd 1.35, ось объявили бы несущей ошибочно. + for c, n_c, m, t, k_c in weak: + mult = (_t(1 - 0.05 / (2 * max(1, k_c)), PRIOR_DF) + _t(0.80, PRIOR_DF)) * BETWEEN + need_sd = t * math.sqrt(n_c) / mult + need_n = math.ceil((mult * 2.12 / t) ** 2) print(f" • {c}\n при n={n_c} MDE {m:.2f} > цели {t:.2f}; несущим станет " f"либо при своём поле sd ≤ {need_sd:.2f}, либо при n ≥ {need_n}") print("\nСЕМЕЙСТВА, объявленные ДО покупок:") diff --git a/eval/dovodka/zakhod.py b/eval/dovodka/zakhod.py index d17c05fc..b5b50733 100644 --- a/eval/dovodka/zakhod.py +++ b/eval/dovodka/zakhod.py @@ -46,7 +46,7 @@ ОТКАЗЫВАЕТСЯ, пока ростер не пере-пинен. Это не осторожность, а единственное, что отличает леджер-измеритель от леджера-фантазёра. -Запуск: zakhod.py --dry | --selftest | --buy [zh|en] [армы…] | --plan +Запуск: zakhod.py --dry | --selftest | --buy [zh|en] [армы…] | --echo """ from __future__ import annotations @@ -95,7 +95,11 @@ JULY_PIN_PRO = 0.435 # если в ростере всё ещё # ── ПАРА КАК ДАННЫЕ ─────────────────────────────────────────────────────────────────────────── # Ни одна ветка ниже не спрашивает «а это китайский?». Всё, что у пары своё, — здесь. PAIRS = { - "zh": {"para": lambda: PARA.ZH, "letter": "i", "phase": "ФД-I", "max_out": 16000, + # ⚠ БЮДЖЕТ ВЫВОДА 32000 НА ОБЕИХ ПАРАХ (починка по ревью). Хендофф §5.4 предупреждал прямо: + # «бюджет вывода новых пар 32000, 16000 мало». Я оставил китайской 16000 по умолчанию контура — + # и получил ДВА обрыва `finish=length` на первых шести клетках `Z8R` ($0.0324 списано, текста + # нет). У DeepSeek размышление тарифицируется ВНУТРИ бюджета, а обогащённый черновик длиннее. + "zh": {"para": lambda: PARA.ZH, "letter": "i", "phase": "ФД-I", "max_out": 32000, "onepass": "A2", "crit": "A3", "wire": lambda: C.configure(PARA.ZH), "units": lambda: C.units_ok()}, "en": {"para": lambda: PARA.EN, "letter": "j", "phase": "ФД-J", "max_out": 32000, @@ -111,14 +115,27 @@ FREE = ("ZD", "Z0", "ZP") # просто перепишет как обычно, а потерянная находка не вернётся ничем. # Поэтому строка выбрасывается, ТОЛЬКО если (а) принимающая формула стоит в ХВОСТЕ вердикта — # то есть критик закончил согласием, — И (б) во всей строке нет ни одного маркера дефекта. +# ⚠⚠ ПОЧИНКА ПО АДВЕРСАРИАЛЬНОМУ РЕВЬЮ (другое модельное семейство, 16.08). Прежняя редакция +# матчила принимающие формулы ВНУТРИ отрицательных слов: «неправильно», «некорректно», +# «недопустимо», «неуместно», «неадекватно», «неточно передан» опознавались как СОГЛАСИЕ, потому +# что `правильно`/`корректн`/`допустим`/`уместн`/`адекватн` — их подстроки, а в списке маркеров +# дефекта этих форм не было. Замер по живому корпусу (1669 строк критика обеих пар): **10 реальных +# находок ложно отсеяны**, среди них «недопустимое смешение языков» и «неправильное употребление +# предлога». Ставка владельца покупалась бы по спискам, из которых выпали дефекты. +# Лечение: отрицательная приставка ловится ЯВНО и стоит в маркерах дефекта, а принимающие формулы +# требуют границы слова слева, чтобы не цепляться к «не-». CONFIRM_RX = re.compile( - r"(?:вс[е]\s+верно|верно\b|правильно\b|корректн|допустим|уместн|не\s+ошибк|ошибки\s+нет|" - r"претензий\s+нет|нареканий\s+нет|приемлем|адекватн|точно\s+переда|передано\s+верно|" - r"соответствует\s+исходник|без\s+замечаний|(?:^|[\s,;—-])ок[.,;]?$)", re.IGNORECASE) + r"(? list[dict]: + """⚠ ПОЧИНКА ПО РЕВЬЮ: замечания дописываются в ПОСЛЕДНЕЕ сообщение с ролью `user`, а не в + `m[1]`. При непустом банке `editor_msgs` вставляет ВТОРЫМ сообщением закон-блок (роль + `system`), и замечания критика уезжали в системный промт — то есть структура запроса Z1 + различалась между единицами, где термы банка есть и где их нет. Селфтест этого не ловил, + потому что проверял ИНДЕКС, а не роль: `m[1]` существует всегда.""" key = _pair(p)["para"]().key bf = None if key == "zh-ru" else _pair(p)["para"]().bank_file m = PR.editor_msgs(src, draft, PR.law_block(PR.terms_in(src, bf), bf), pair=key) - m[1]["content"] = m[1]["content"] + Z1_HEAD + "\n".join(f"- {n}" for n in notes) + if m[-1].get("role") != "user": + raise SystemExit(f"⛔ последнее сообщение редактора не `user` ({m[-1].get('role')}) — " + "замечания критика уехали бы в системный промт, СТОП") + m[-1]["content"] = m[-1]["content"] + Z1_HEAD + "\n".join(f"- {n}" for n in notes) return m @@ -320,13 +345,14 @@ def chosen(p: str) -> list[dict]: # ── ГЕЙТ ПЕРЕЛОМА ЦЕНЫ ──────────────────────────────────────────────────────────────────────── -def price_gate() -> None: +def price_gate(quiet: bool = False) -> None: now = dt.datetime.now(dt.timezone.utc) pin = ROSTER.CANDIDATES[EDITOR][2] stale = abs(pin - JULY_PIN_PRO) < 1e-9 left = (PRICE_CUTOVER - now).total_seconds() / 3600 if now < PRICE_CUTOVER: - print(f" цена DeepSeek: до перелома {left:.1f} ч, июльский пин ${pin}/1M ВЕРЕН") + if not quiet: + print(f" цена DeepSeek: до перелома {left:.1f} ч, июльский пин ${pin}/1M ВЕРЕН") return if stale: raise SystemExit( @@ -348,20 +374,88 @@ def _buy(led, tg: str, model: str, msgs: list[dict], max_out: int, **extra) -> N " Это СТОП и вопрос владельцу: назвать недостающую сумму.") +def preflight(models: list[str], phase: str, letter: str) -> None: + """Пред-полётная проба: КАЖДАЯ модель прогона отвечает, ДО того как пойдёт цикл покупок. + + ⚠ ЗАВЕДЕНО ПО ИНЦИДЕНТУ 16.08, И ЭТО ДЫРА В ИНФРЕ, А НЕ ПЕДАНТИЗМ. Заход ушёл в цикл на 160 + клеток, не проверив ничего; на 62-й клетке у провайдера кончился баланс, и оставшиеся 96 + запросов ушли в `402 Insufficient Balance` — по 0.9 с каждый, с записью файла на диск. Денег + это не стоило (отказ бесплатен), но выглядело как «купили 80 клеток»: файлы есть, годного + содержимого нет. Ровно тот класс, о котором предупреждает хендофф — «файл клетки ≠ годная + клетка», — и он стоил мне ложного доклада владельцу. + ⚠ ПРОБА ИДЁТ ЧЕРЕЗ КАССУ (починка по ревью): первая редакция звала клиента напрямую, то есть + делала ПЛАТНЫЙ вызов мимо леджера — прямое нарушение нормы «все платные вызовы через кассу, + два пути счёта». Копейки, но именно так и заводится расход, которого касса не видит. Тег + пробы несёт порядковый номер, иначе кэш `purchase` вернул бы прошлую запись и гейт перестал + бы что-либо проверять на втором прогоне. + """ + led = MONEY.Guarded(phase, default_expect=0.001) + for m in dict.fromkeys(models): + k = 1 + while (OUT / f"dv-{letter}-probe-{m}-{k}.json").exists(): + k += 1 + tg = f"dv-{letter}-probe-{m}-{k}" + rec = MONEY.purchase(led, tg, m, ED22.client(m), + ROSTER.call_kwargs(m, [{"role": "user", "content": "ок"}], + max_out=8000), + role="preflight") + if rec.get("finish") != "stop": + raise SystemExit( + f"⛔ ПРЕД-ПОЛЁТНАЯ ПРОБА НЕ ПРОШЛА: {m} → finish={rec.get('finish')} " + f"{str(rec.get('error'))[:200]}\n" + " Покупка НЕ запускается. Цикл на сотню клеток против неотвечающего\n" + " провайдера пишет файлы без содержимого и выглядит как успешный прогон.") + print(f" проба {m}: отвечает, ${rec.get('cost_usd', 0):.6f} (учтено кассой)") + + +def refill(p: str, which: list[str]) -> int: + """Увести оборванные клетки (`finish=length`, пусто) в карантин, чтобы их можно было ПЕРЕ-купить. + + ⚠ ЗАВЕДЕНО ПО РЕВЬЮ: без этого оборванная клетка — мёртвая зона. Кэш `purchase` смотрит на + СУЩЕСТВОВАНИЕ файла и возвращает старую запись, а `cell()` требует `finish=stop` и отдаёт + пусто; значит смета считает клетку в «надо», а покупка на ней — вечный бесшумный no-op. + Файл не удаляется, а переименовывается: деньги за него списаны и обязаны остаться видимы + кассе (глоб считает `dv-*.json`, карантинное имя под него попадает). + """ + n = 0 + for arm in which: + for u in chosen(p): + f = OUT / f"{tag(p, arm, u['uid'])}.json" + if not f.exists(): + continue + rec = json.loads(f.read_text(encoding="utf-8")) + if rec.get("finish") == "length" and not (rec.get("content") or "").strip(): + k = 1 + while (OUT / f"{f.stem}.LENGTH{k}.json").exists(): + k += 1 + f.rename(OUT / f"{f.stem}.LENGTH{k}.json") + print(f" {f.stem}: обрыв на потолке вывода → карантин, клетка пере-покупаема") + n += 1 + return n + + def buy(p: str, which: list[str]) -> None: price_gate() cfg = _pair(p) cfg["wire"]() + preflight([DRAFT_MODEL if a == "Z8" else EDITOR for a in which], + cfg["phase"], cfg["letter"]) us = chosen(p) led = MONEY.Guarded(cfg["phase"], default_expect=0.009) mo = cfg["max_out"] print(f"\nпара {p} · единиц {len(us)} · армы {' '.join(which)} · касса {cfg['phase']}") + print(f" оборванных клеток уведено в карантин: {refill(p, which)}") for arm in which: for u in us: uid, src = u["uid"], u["source"] tg = tag(p, arm, uid) if cell(tg).strip(): continue + # ⚠ ГЕЙТ ПЕРЕЛОМА ЦЕНЫ — ПЕРЕД КАЖДОЙ КЛЕТКОЙ, А НЕ ОДИН РАЗ НА СТАРТЕ (починка по + # ревью): прогон длится часами, и пересёкший 16:00 UTC цикл писал бы пост-переломные + # вызовы по июльскому прайсу — леджер занижен втрое ровно на том куске, который куплен + # после перелома. + price_gate(quiet=True) if arm == "Z8": # штатный гейт годности + ОДИН ре-ген: эхо стохастично по вызову (квирки п.6) for attempt in range(REGEN_MAX + 1): @@ -482,10 +576,20 @@ def cmd_selftest() -> int: ed = editor_msgs(p, u["source"], free_text(p, "ZD", u["uid"])) ck(f"{p}: Z1 несёт системный промт боевого редактора ПОБАЙТНО", z1[0]["content"] == ed[0]["content"]) - ck(f"{p}: замечания критика уходят Z1 пользовательским сообщением", - z1[1]["content"].startswith(ed[1]["content"]) and len(z1[1]["content"]) > len(ed[1]["content"])) - ck(f"{p}: мандат редактуры в Z1 остался ПОЛНЫМ", "ДОБАВЛЕНИЕ к твоей обычной работе" - in z1[1]["content"]) + # ⚠ ПРОВЕРКА ПО РОЛИ, А НЕ ПО ИНДЕКСУ (починка по ревью). Прежняя редакция смотрела в + # `m[1]`, который при непустом банке есть закон-блок с ролью `system`: замечания критика + # уезжали в системный промт, а гейт этого не видел, потому что индекс существует всегда. + usr = [m for m in z1 if m["role"] == "user"] + edu = [m for m in ed if m["role"] == "user"] + ck(f"{p}: у Z1 ровно одно пользовательское сообщение", len(usr) == 1, f"их {len(usr)}") + ck(f"{p}: замечания критика уходят ПОЛЬЗОВАТЕЛЬСКИМ сообщением", + bool(usr) and bool(edu) and usr[-1]["content"].startswith(edu[-1]["content"]) + and len(usr[-1]["content"]) > len(edu[-1]["content"])) + ck(f"{p}: в системный промт Z1 замечания НЕ попали", + not any("ДОБАВЛЕНИЕ к твоей обычной работе" in m["content"] + for m in z1 if m["role"] == "system")) + ck(f"{p}: мандат редактуры в Z1 остался ПОЛНЫМ", + bool(usr) and "ДОБАВЛЕНИЕ к твоей обычной работе" in usr[-1]["content"]) # ZF обязан быть побайтной копией запроса Z0 — иначе это не пол, а другой арм ck(f"{p}: ZF = ТОТ ЖЕ запрос, что у боевой связки (пол, а не новый арм)", editor_msgs(p, u["source"], free_text(p, "ZD", u["uid"])) == ed) @@ -517,26 +621,12 @@ def cmd_selftest() -> int: return bad -def cmd_plan() -> None: - for p in PAIRS: - us = chosen(p) - print(f"\n=== {p}: {len(us)} единиц ===") - for a in FREE + ARMS: - have = sum(1 for u in us - if (free_text(p, a, u["uid"]) if a in FREE else cell(tag(p, a, u["uid"]))) - .strip()) - print(f" {a:4s} {have:2d}/{len(us)}" + (" (бесплатно)" if a in FREE else "")) - MONEY.report() - - if __name__ == "__main__": a = sys.argv[1:] or ["--dry"] if a[0] == "--selftest": sys.exit(1 if cmd_selftest() else 0) elif a[0] == "--dry": cmd_dry() - elif a[0] == "--plan": - cmd_plan() elif a[0] == "--echo": echo_report() elif a[0] == "--buy": @@ -545,4 +635,4 @@ if __name__ == "__main__": for x in pr: buy(x, ar) else: - raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --dry --selftest --plan --buy") + raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --dry --selftest --buy --echo") diff --git a/eval/dovodka/zsud.py b/eval/dovodka/zsud.py new file mode 100644 index 00000000..fde9e241 --- /dev/null +++ b/eval/dovodka/zsud.py @@ -0,0 +1,561 @@ +#!/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}")