From a03ac66b0bfe6bd1c36d82ecb301ad643f8a43ab Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sat, 15 Aug 2026 19:30:47 +0300 Subject: [PATCH] Freeze the pre-registration and density gate for re-judging the tier pass before any answer arrives --- eval/dovodka/gain.py | 364 ++++++++++++++++++++++++++++++++++++++++++ eval/dovodka/tier2.py | 348 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 712 insertions(+) create mode 100644 eval/dovodka/gain.py create mode 100644 eval/dovodka/tier2.py diff --git a/eval/dovodka/gain.py b/eval/dovodka/gain.py new file mode 100644 index 00000000..454a8929 --- /dev/null +++ b/eval/dovodka/gain.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""КАЛИБРОВКА УСИЛЕНИЯ СУДЬИ. $0, только чтение сырья. + +⚠ ЗАЧЕМ. Риг сравнивает армы ВНУТРИ пачки, и общий сдвиг строгости судьи в контрасте +сокращается. Но абсолютные числа «ошибок на единицу» печатаются в отчётах рядом друг с другом +между осями и проходами — а строгость счёта у этого рига между прогонами гуляет вчетверо. +Здесь она меряется числом, чтобы (а) межпрогонные сравнения не делались молча и (б) пачка, +легшая на пол шкалы, была видна ДО того, как её «не различимо» пойдёт в вывод. + +Что печатает: + --density плотность счёта и доля нулей по каждому проходу и семейству + --samearm ОДИН арм на ОДНИХ единицах в РАЗНЫХ проходах (подписи текста сверяются) + --agree корреляция трудности единицы между семействами — меряют ли они одно и то же + --tier пере-счёт контрастов прохода `tier` с числом ничьих на нуле + --selftest проверки самого инструмента + +Гейт: пачка, где доля нулей по несущей сумме ≥ ZERO_FLOOR_ALARM, не имеет права нести вывод +«ниже порога различимости» — там прибор упёрся в пол, а не «не увидел разницы». +""" +from __future__ import annotations + +import collections +import json +import pathlib +import re +import statistics as st +import sys + +BOOKS = pathlib.Path.home() / "books" +AXRE = re.compile(r"^[ТT](\d+)-(ВЕРНОСТЬ|ТЕРМИН|ЯЗЫК|ФОРМА):\s*(\d+)", re.MULTILINE) + +# Несущая сумма судьи — ВЕРНОСТЬ+ЯЗЫК (эррата Д0.13 П-5): ТЕРМИН отдан детерминированному +# канон-гейту, ФОРМА наполовину механизируема и однажды переворачивала знак вывода. +CARRY = ("ВЕРНОСТЬ", "ЯЗЫК") + +ZERO_FLOOR_ALARM = 0.25 # доля нулевых клеток, выше которой «не различимо» не читается + +PASSES = { + "tier": (["editor-tier/aj-tier"], ["editor-tier/sol-tier"]), + "border": (["editor-tier/aj-border"], ["editor-tier/sol-border"]), + "d4": (["judging/sud-d4/p1-answers", "judging/sud-d4/p2-answers"], + ["judging/sol-d4-work/p1-answers", "judging/sol-d4-work/p2-answers"]), + "d3": (["judging/sud-d3/p1-answers", "judging/sud-d3/p2-answers"], + ["judging/sol-d3-work/p1-answers", "judging/sol-d3-work/p2-answers"]), + "d6": (["judging/sud-d6/p1-answers", "judging/sud-d6/p2-answers"], + ["judging/sol-d6-work/p1-answers", "judging/sol-d6-work/p2-answers"]), + "d1": (["judging/sud-d1/p1-answers", "judging/sud-d1/p2-answers"], + ["judging/sol-d1-work/p1-answers", "judging/sol-d1-work/p2-answers"]), +} + +KEYS = { # проход → (ключ claude, ключ Sol) + "tier": ("editor-tier/blind-keys/tier-KEY.json", None), + "border": ("editor-tier/blind-keys/border-KEY.json", None), + "d3": ("dovodka/blind-keys-d3", "dovodka/blind-keys-d3-sol"), + "d6": ("dovodka/blind-keys-d6", "dovodka/blind-keys-d6-sol"), + "d1": ("dovodka/blind-keys-d1", "dovodka/blind-keys-d1-sol"), + "d4": ("dovodka/blind-keys-d4", None), +} + + +def load_key(rel: str | None) -> dict: + """Ключ лежит либо файлом, либо каталогом с `*-KEY.json` по пачкам.""" + if not rel: + return {} + p = BOOKS / rel + if p.is_dir(): + k: dict = {} + for f in sorted(p.glob("*-KEY.json")): + k.update(json.loads(f.read_text(encoding="utf-8"))) + return k + return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {} + + +def read_answers(rels: list[str]) -> dict[str, dict[str, int]]: + """токен → {метка: несущая сумма}. `.OLD` и каталоги заданий не читаются.""" + out: dict[str, dict[str, int]] = {} + for rel in rels: + d = BOOKS / rel + if not d.exists(): + continue + for f in sorted(d.glob("*.txt")): + cells: dict[str, dict[str, int]] = collections.defaultdict(dict) + for m in AXRE.finditer(f.read_text(encoding="utf-8", errors="replace")): + cells["Т" + m.group(1)][m.group(2)] = int(m.group(3)) + if cells: + out.setdefault(f.stem, {}).update( + {lab: sum(ax.get(a, 0) for a in CARRY) for lab, ax in cells.items()}) + return out + + +def density(rels: list[str]) -> tuple | None: + sc = read_answers(rels) + vals = [v for labs in sc.values() for v in labs.values()] + if not vals: + return None + zeros = sum(1 for v in vals if v == 0) / len(vals) + return len(sc), len(vals), st.mean(vals), st.pstdev(vals), zeros + + +def by_arm(rels: list[str], keyrel: str | None) -> dict[str, list[tuple[str, int]]]: + """арм → [(uid, счёт)]. Метки де-слепятся ключом того же семейства.""" + key = load_key(keyrel) + out: dict[str, list[tuple[str, int]]] = collections.defaultdict(list) + for tok, labs in read_answers(rels).items(): + km = key.get(tok) + if not isinstance(km, dict): + continue + for lab, v in km.items(): + if isinstance(v, dict) and lab in labs and v.get("arm"): + out[v["arm"]].append((v.get("uid", ""), labs[lab])) + return out + + +def sigs(keyrel: str, arm: str) -> dict[str, str]: + """uid → подпись текста арма: доказывает, что в двух проходах судили одни байты.""" + out = {} + for km in load_key(keyrel).values(): + if not isinstance(km, dict): + continue + for v in km.values(): + if isinstance(v, dict) and v.get("arm") == arm: + out[v["uid"]] = v.get("sig") + return out + + +def pearson(a: dict, b: dict) -> tuple[float | None, int]: + k = sorted(set(a) & set(b)) + if len(k) < 4: + return None, len(k) + x, y = [a[i] for i in k], [b[i] for i in k] + mx, my = st.mean(x), st.mean(y) + num = sum((i - mx) * (j - my) for i, j in zip(x, y)) + den = (sum((i - mx) ** 2 for i in x) * sum((j - my) ** 2 for j in y)) ** 0.5 + return (num / den if den else None), len(k) + + +def per_unit(rels: list[str], keyrel: str | None) -> dict[str, float]: + """uid → средний счёт по БОЕВЫМ армам: трудность единицы глазами семейства. + + ⚠ Отбор идёт по ИМЕНИ арма, а не по полю `kind`: в ключах фазы Д контроли `CTRLfloor` + и `CTRLmargin` помечены `kind='боевой'`, и фильтр по kind тянул бы их в трудность единицы. + """ + key = load_key(keyrel) + agg: dict[str, list[int]] = collections.defaultdict(list) + for tok, labs in read_answers(rels).items(): + km = key.get(tok) + if not isinstance(km, dict): + continue + for lab, v in km.items(): + if not (isinstance(v, dict) and lab in labs and v.get("uid")): + continue + if str(v.get("arm", "")).startswith("CTRL"): + continue + agg[v["uid"]].append(labs[lab]) + return {u: st.mean(v) for u, v in agg.items() if v} + + +def floor_pairs(keyrel: str | None) -> tuple[int, int, int, int]: + """Пары шумового пола: (пар, половины в РАЗНЫХ заданиях, побайтных близнецов, всего половин). + + Норма Д0.6: половины пары судят РАЗНЫЕ сессии (иначе межсессионная компонента в порог не + попадает), и побайтно равные половины дают нулевую разницу, то есть занижают порог. + """ + key = load_key(keyrel) + loc: dict[str, list[tuple[str, str]]] = collections.defaultdict(list) + for tok, km in key.items(): + if not isinstance(km, dict): + continue + for v in km.values(): + if isinstance(v, dict) and str(v.get("arm", "")).startswith("CTRLfloor"): + loc[v["uid"]].append((tok, v.get("sig", ""))) + pairs = [(u, it) for u, it in loc.items() if len(it) >= 2] + diff_task = sum(1 for _, it in pairs if len({t for t, _ in it}) > 1) + twins = sum(1 for _, it in pairs if len({s for _, s in it}) == 1) + return len(pairs), diff_task, twins, sum(len(it) for it in loc.values()) + + +def cmd_density() -> int: + print(f"{'проход':10s} {'семья':7s} {'единиц':>6s} {'клеток':>7s} " + f"{'ошибок/ед.':>11s} {'sd':>6s} {'нулей':>7s}") + bad = [] + for name, (ca, so) in PASSES.items(): + for fam, rels in (("claude", ca), ("Sol", so)): + r = density(rels) + if r is None: + print(f"{name:10s} {fam:7s} — ответов нет") + continue + files, n, mean, sd, z = r + mark = " ⛔ ПОЛ ШКАЛЫ" if z >= ZERO_FLOOR_ALARM else "" + print(f"{name:10s} {fam:7s} {files:6d} {n:7d} {mean:11.2f} {sd:6.2f} {z:6.0%}{mark}") + if z >= ZERO_FLOOR_ALARM: + bad.append(f"{name}/{fam}") + print() + print("⚠ Плотность счёта — свойство ПРОГОНА. Числа «ошибок на единицу» из разных проходов") + print(" между собой НЕ сравнивать: внутри пачки сдвиг сокращается в контрасте, между") + print(" пачками — нет.") + if bad: + print(f"\n⛔ Пачки на полу шкалы (нулей ≥{ZERO_FLOOR_ALARM:.0%}): {', '.join(bad)}") + print(" «Ниже порога различимости» от них не принимается: прибор упёрся в ноль.") + return 1 + return 0 + + +def cmd_samearm(arm: str = "R0", a: str = "tier", b: str = "border") -> int: + ta, tb = sigs(KEYS[a][0], arm), sigs(KEYS[b][0], arm) + common = sorted(set(ta) & set(tb)) + same = [u for u in common if ta[u] == tb[u] and ta[u]] + if not same: + print(f"общих единиц с совпадающей подписью у арма {arm} нет — контроль неприменим") + return 0 + A = {u: v for u, v in by_arm(PASSES[a][0], KEYS[a][0]).get(arm, [])} + B = {u: v for u, v in by_arm(PASSES[b][0], KEYS[b][0]).get(arm, [])} + k = [u for u in same if u in A and u in B] + print(f"арм {arm}, семья claude, подписи текста совпадают {len(same)}/{len(common)} — " + f"судили ОДНИ БАЙТЫ") + print(f" проход {a:8s} среднее {st.mean([A[u] for u in k]):5.2f} " + f"нулей {sum(1 for u in k if A[u] == 0)}/{len(k)}") + print(f" проход {b:8s} среднее {st.mean([B[u] for u in k]):5.2f} " + f"нулей {sum(1 for u in k if B[u] == 0)}/{len(k)}") + lower = sum(1 for u in k if A[u] < B[u]) + print(f" по-единично {a} ниже {b}: {lower}/{len(k)}") + print(f"\n uid {a:>6s} {b:>7s}") + for u in k: + print(f" {u} {A[u]:6d} {B[u]:7d}") + return 0 + + +def cmd_floor() -> int: + print("ШУМОВОЙ ПОЛ: половины пары обязаны судиться РАЗНЫМИ сессиями и НЕ быть близнецами") + print(f"{'проход':10s} {'пар':>4s} {'в разных заданиях':>18s} {'ПОБАЙТНЫХ близнецов':>20s}") + rc = 0 + for name in ("tier", "border", "d4", "d3", "d6", "d1"): + pairs, diff_task, twins, halves = floor_pairs(KEYS[name][0]) + if not halves: + print(f"{name:10s} — пола в ключе нет") + continue + mark = " ⛔" if twins else "" + print(f"{name:10s} {pairs:4d} {diff_task:18d} {twins:20d}{mark}") + if twins: + rc = 1 + print("\n⚠ Побайтно равная половина даёт нулевую разницу и ЗАНИЖАЕТ порог различимости.") + print(" Половины в одном задании не ловят межсессионную компоненту строгости.") + return rc + + +def cmd_agree() -> int: + print("корреляция ТРУДНОСТИ ЕДИНИЦЫ между семействами — меряют ли они одно и то же") + print(f"{'проход':10s} {'общих ед.':>9s} {'r':>7s}") + for name in ("tier", "d3", "d6", "d1"): + kc, ks = KEYS[name] + A = per_unit(PASSES[name][0], kc) + B = per_unit(PASSES[name][1], ks or kc) + r, n = pearson(A, B) + print(f"{name:10s} {n:9d} {('—' if r is None else f'{r:+.3f}'):>7s}") + print("\n⚠ Отрицательная или нулевая корреляция = семейства меряют РАЗНОЕ, и спор между их") + print(" вердиктами не разрешается нормировкой на пороги.") + return 0 + + +def cmd_tier() -> int: + key = KEYS["tier"][0] + print(f"{'контраст':12s} {'семья':7s} {'n':>3s} {'перевес':>8s} {'ничьих':>7s} " + f"{'на нуле':>8s}") + for a, b in (("T1", "R0"), ("T2", "R0"), ("T3", "R0"), ("R0", "F")): + for fam, rels in (("claude", PASSES["tier"][0]), ("Sol", PASSES["tier"][1])): + arms = by_arm(rels, key) + A = dict(arms.get(a, [])), dict(arms.get(b, [])) + k = sorted(set(A[0]) & set(A[1])) + if not k: + continue + d = [A[0][u] - A[1][u] for u in k] + ties = sum(1 for x in d if x == 0) + tie0 = sum(1 for u in k if A[0][u] == 0 and A[1][u] == 0) + print(f"{a + ' vs ' + b:12s} {fam:7s} {len(k):3d} {-st.mean(d):+8.2f} " + f"{ties:7d} {tie0:8d}") + print("\n⚠ Перевес положителен, когда ПЕРВЫЙ арм лучше (у него меньше ошибок).") + print(" Ничья на нуле = обоим армам поставлен ноль: разница ненаблюдаема по построению,") + print(" знаковый тест на таких парах мощности не имеет.") + return 0 + + +def cmd_selftest() -> int: + fails = [] + ok = lambda c, m: fails.append(m) if not c else None + + d = density(PASSES["tier"][0]) + ok(d is not None, "проход tier не читается") + if d: + ok(d[1] == 128, f"tier/claude: клеток {d[1]}, ожидалось 128") + ok(d[4] >= ZERO_FLOOR_ALARM, "tier/claude обязан флагаться как пол шкалы") + + ta, tb = sigs(KEYS["tier"][0], "R0"), sigs(KEYS["border"][0], "R0") + common = set(ta) & set(tb) + ok(len(common) == 16, f"общих единиц R0 между tier и border {len(common)}, ожидалось 16") + ok(all(ta[u] == tb[u] for u in common), + "подписи арма R0 разошлись между проходами — контроль недействителен") + + r, n = pearson(per_unit(PASSES["d3"][0], KEYS["d3"][0]), + per_unit(PASSES["d3"][1], KEYS["d3"][1])) + ok(n == 16, f"Д3: общих единиц {n}, ожидалось 16") + ok(r is not None and r > 0, "Д3: семейства обязаны коррелировать положительно") + + rt, nt = pearson(per_unit(PASSES["tier"][0], KEYS["tier"][0]), + per_unit(PASSES["tier"][1], KEYS["tier"][0])) + ok(nt == 16, f"tier: общих единиц {nt}, ожидалось 16") + ok(rt is not None and rt < r, "tier обязан коррелировать ХУЖЕ, чем Д3") + + # Контроли не должны попадать в трудность единицы. Ловушка: в ключах фазы Д у + # CTRLfloor/CTRLmargin поле kind='боевой', поэтому фильтр по kind их не отсекает. + # Проверка прямая: трудность считается ровно по боевым армам прохода. + key = load_key(KEYS["d3"][0]) + war = {v["arm"] for km in key.values() if isinstance(km, dict) for v in km.values() + if isinstance(v, dict) and not str(v.get("arm", "")).startswith("CTRL")} + ctrl = {v["arm"] for km in key.values() if isinstance(km, dict) for v in km.values() + if isinstance(v, dict) and str(v.get("arm", "")).startswith("CTRL")} + ok("CTRLfloor" in ctrl and "CTRLmargin" in ctrl, "Д3: контролей в ключе не найдено") + got = by_arm(PASSES["d3"][0], KEYS["d3"][0]) + ok(war <= set(got), "Д3: не все боевые армы разобрались") + pu = per_unit(PASSES["d3"][0], KEYS["d3"][0]) + per_uid_labels = collections.Counter() + for tok, labs in read_answers(PASSES["d3"][0]).items(): + km = key.get(tok) + if isinstance(km, dict): + for lab, v in km.items(): + if isinstance(v, dict) and lab in labs and v.get("uid") \ + and not str(v.get("arm", "")).startswith("CTRL"): + per_uid_labels[v["uid"]] += 1 + ok(set(pu) == set(per_uid_labels), "Д3: набор единиц трудности разошёлся с ключом") + ok(max(per_uid_labels.values()) <= 2 * len(war), + f"Д3: на единицу приходится больше меток, чем боевых армов ×2 прохода ({len(war)})") + + halves = floor_pairs(KEYS["d6"][0])[3] + ok(halves > 0, "Д6: пол в ключе не найден") + + for m in fails: + print("ПРОВАЛ:", m) + print(f"селфтест: провалов {len(fails)}") + return 1 if fails else 0 + + +def main() -> int: + args = sys.argv[1:] or ["--density"] + if "--selftest" in args: + return cmd_selftest() + rc = 0 + if "--density" in args: + rc |= cmd_density() + if "--samearm" in args: + rc |= cmd_samearm() + if "--floor" in args: + rc |= cmd_floor() + if "--agree" in args: + rc |= cmd_agree() + if "--tier" in args: + rc |= cmd_tier() + if "--all" in args: + for f in (cmd_density, cmd_samearm, cmd_floor, cmd_agree, cmd_tier): + print("=" * 78) + rc |= f() + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/eval/dovodka/tier2.py b/eval/dovodka/tier2.py new file mode 100644 index 00000000..956cec00 --- /dev/null +++ b/eval/dovodka/tier2.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 +"""ПЕРЕ-СУДЕЙСТВО ПРОХОДА `tier` ПОЧИНЕННЫМ ПРИБОРОМ. $0 (агент-сессии). + +Заказано владельцем 15.08: «Можно пересудить. Но не подгонять. Нужно понять, кто ошибается из +судей». Пере-судятся ТЕ ЖЕ ТЕКСТЫ — ни одна клетка не покупается заново, поэтому разница между +старым и новым замером есть разница ПРИБОРА, а не материала. + +⚠ ЧТО ИМЕННО СЛОМАНО В СТАРОМ ЗАМЕРЕ (замерено, не предположено — `gain.py`): + · пачка легла на пол шкалы: 38% клеток ровно ноль, из них 24% с ПУСТЫМ обоснованием; у арма `T2` + нулей 75%, у боевого `R0` — 56%; в решающем контрасте 9 ничьих из 16, 7 из них на нуле; + · тот же арм `R0` на тех же 16 единицах в проходе `border` дал 4.31 ошибки против 0.69 здесь + (подписи текста совпадают 16/16) — прибор читал те же байты вшестеро глуше; + · семейства не коррелируют по трудности единицы (r=−0.22) при +0.31…+0.70 на осях фазы Д; + · разобранная руками единица `38c99e5efb`: боевой редактор превратил адресата угрозы в союзника + («мы с тобой покараем его» против 我二人…除了你), судья поставил 0 с пустым обоснованием. + +⚠ ТРИ ПРАВКИ ПАНЕЛИ — ПРЕ-РЕГИСТРИРУЮТСЯ ЗДЕСЬ, ДО ПЕРВОГО ОТВЕТА: + 1. `CTRLfloorA` УБРАН как отдельная метка: он побайтно равен боевому арму `T1` в 16/16, то есть + один текст лежал в пачке дважды. Пол считается как и раньше — по РАЗНОСТИ двух генераций + того же лечения, но вторая метка теперь одна (`CTRLfloor` = прежний `CTRLfloorB`), а первой + служит сам `T1`. Дублей в пачке не остаётся. + 2. `CTRLmargin` ДОБАВЛЕН — маржевый декой (норма П-2), которого у прохода `tier` не было никогда. + Без него «не различимо» неотличимо от слепоты прибора; именно этого контроля не хватило паку 23. + 3. Ключ ВЫПУСКАЕТСЯ НОВЫЙ (`tier2-KEY.json`, своя соль), старый не трогается. Раскладка меток — + функция соли, uid и НАБОРА армов; эмиссия поверх старого ключа переписала бы отсуженную + раскладку (авария фазы Д, хендофф §5 п.1). + +⚠ ТРИ ПРАВКИ ЗАДАНИЯ — ТОЖЕ ПРЕ-РЕГИСТРИРУЮТСЯ. Лечится ЗАДАНИЕМ, а не сменой судьи: прибор +устойчив (побайтно один текст под двумя метками дал sd=0.000 по всем осям 16/16), у него высокий +порог счёта, а не разболтанность. + A. МОЛЧАЛИВЫЙ НОЛЬ ЗАПРЕЩЁН: клетка с нулём обязана нести непустое «почему». + B. В рубрику ВЕРНОСТЬ внесены КЛАССЫ, которые старая пачка пропускала: инверсия адресата или + участника реплики · потеря/подмена разряда, ранга, числа · состояние идиомы · снятое или + добавленное отрицание. Классы арм-нейтральны и ни на один арм не указывают. + C. Сказано прямо, что ноль — утверждение, а не отсутствие ответа. +Всё прочее в задании побайтно то же, что судили агенты пака 23. + +⚠ ПРАВИЛО РЕШЕНИЯ — ОБЪЯВЛЯЕТСЯ ДО ОТВЕТОВ: + · пачка, не взявшая гейт плотности (`gain.py`: доля нулей ≥25%), НЕ несёт вывода «не различимо»; + · семейство, чей перевес на маржевом декое не пересекает свой порог, теряет право говорить + «не хуже» (П-2); + · контрасты `T1/R0`, `T2/R0`, `T3/R0` — точный знаковый тест с поправкой Холма по трём; + `R0/F` — позитивный контроль, в семейство не входит; + · порог различимости = 2.8·sd/√n по разностям пар пола, как во всей фазе; + · сравнение со старым замером печатается по-армно и является ГЛАВНЫМ результатом прогона: + вопрос стоит не «кто лучше», а «что показывает прибор, когда ему запрещено молчать». + +Запуск: tier2.py --emit | --score | --selftest +""" +from __future__ import annotations + +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)) + +ET = Path.home() / "books" / "editor-tier" +OLD_KEY = ET / "blind-keys" / "tier-KEY.json" +OLD_TASKS, OLD_ANSW = ET / "aj-tier-tasks", ET / "aj-tier" +NEW_KEY = ET / "blind-keys" / "tier2-KEY.json" +SALT_F = ET / "blind-keys" / "SALT-tier2.txt" +TASKS, ANSW = ET / "tier2-tasks", ET / "tier2" + +AX = ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА") +CARRY = ("ВЕРНОСТЬ", "ЯЗЫК") +ARMS = ("R0", "T1", "T2", "T3", "F") # боевые; контраст — T*/R0, контроль — R0/F +CTRL = ("CTRLdecoy", "CTRLfloor", "CTRLmargin") +PER_SESSION = 4 # заданий на сессию, как гонялся `border` +SALT = "tier2-2026-08-15" # своя соль; старая не трогается + +_axre = re.compile(r"^[ТT](\d+)-(ВЕРНОСТЬ|ТЕРМИН|ЯЗЫК|ФОРМА):\s*(\d+)", re.MULTILINE) +_whyre = re.compile(r"^[ТT](\d+)-ПОЧЕМУ:(.*)$", re.MULTILINE) + + +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 + + +def _sud(): + """`sud` грузится лениво: он разбирает флаги на уровне модуля и тянет сырьё своей оси.""" + return sys.modules.get("sud") or _load("sud", ZONE / "sud.py") + + +def old_texts() -> dict[str, dict[str, str]]: + """(uid → {арм: текст}) из ОТСУЖЕННЫХ заданий пака 23. Тексты не покупаются заново.""" + key = json.loads(OLD_KEY.read_text(encoding="utf-8")) + out: dict[str, dict[str, str]] = {} + for tok, km in key.items(): + if not isinstance(km, dict): + continue + f = OLD_TASKS / f"{tok}.txt" + if not f.exists(): + continue + body = f.read_text(encoding="utf-8") + # варианты разделены строками вида «Т3:»; исходник идёт до первой такой строки + parts = re.split(r"^(Т\d):\s*$", body, flags=re.MULTILINE) + src = re.search(r"^ИСХОДНИК:\s*\n(.*?)(?=\n=+\n|\Z)", parts[0], re.DOTALL | re.MULTILINE) + texts = {} + for i in range(1, len(parts) - 1, 2): + texts[parts[i]] = parts[i + 1].strip() + for lab, meta in km.items(): + if not isinstance(meta, dict) or lab not in texts: + continue + uid = meta["uid"] + out.setdefault(uid, {})["_src"] = (src.group(1).strip() if src else "") + out[uid][meta["arm"]] = texts[lab] + return out + + +def layout(uid: str, arms: tuple[str, ...]) -> dict[str, str]: + """Метка → арм. Чистая функция от соли, uid и НАБОРА армов — как во всей дуге.""" + order = sorted(arms) + seed = hashlib.sha256(f"{SALT}|{uid}|{'|'.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 = """Ты — литературный редактор-эксперт. Тебе дан ИСХОДНИК на китайском и {n} независимых русских перевода этого же фрагмента под слепыми метками. + +Посчитай ОШИБКИ в каждом варианте по типам: + ВЕРНОСТЬ — сказано не то, что в исходнике: искажение, инверсия, выдумка, пропуск факта. + Сюда же входят, и их пропускают чаще всего: + · инверсия адресата или участника реплики (кто кому говорит, кто кого бьёт); + · потеря или подмена разряда, ранга, числа, единицы измерения; + · состояние идиомы или действия: совершилось против ещё не совершилось; + · снятое или, наоборот, добавленное отрицание. + ТЕРМИН — имя или термин передан не так, как в других местах того же текста. + ЯЗЫК — то, чего носитель не напишет: калька, канцелярит, несуществующее слово, кривой + порядок слов, рассогласование. + ФОРМА — вёрстка и пунктуация: оформление реплик, кавычки, тире, абзацы. + +Оцени КАЖДЫЙ вариант ОТДЕЛЬНО против исходника — не сравнивая варианты между +собой. Порядок вариантов ничего не значит и специально перемешан. + +⚠ НОЛЬ — ЭТО УТВЕРЖДЕНИЕ, А НЕ ОТСУТСТВИЕ ОТВЕТА. Ноль означает «я сверил этот вариант с +исходником по этой оси и дефектов не нашёл». Поэтому ПОЧЕМУ обязательно для КАЖДОЙ метки, в том +числе при нулях: если по всем осям ноль, напиши «прочитано, дефектов не найдено». Пустое ПОЧЕМУ +не принимается и на диск не попадает. + +Ответ ЗАПИШИ ФАЙЛОМ в {path} — ровно в таком виде и ничем больше, блок на каждую метку: + +{skeleton} +ПОЧЕМУ для НЕНУЛЕВОЙ оси: короткая ЦИТАТА из текста через « | ». +Число без цитаты не принимается и на диск не попадает: именно из-за отсутствия цитат показания +прошлых судей оказались непроверяемыми. +""" + + +def emit() -> None: + S = _sud() + src = old_texts() + TASKS.mkdir(parents=True, exist_ok=True) + ANSW.mkdir(parents=True, exist_ok=True) + NEW_KEY.parent.mkdir(parents=True, exist_ok=True) + SALT_F.write_text(SALT + "\n", encoding="utf-8") + key: dict[str, dict] = { + "_проход": "tier2", + "_семейство": ["T1/R0", "T2/R0", "T3/R0"], + "_контроль": ["R0/F"], + "_армы": list(ARMS) + list(CTRL), + } + made = 0 + for uid, arms in sorted(src.items()): + if not all(arms.get(a, "").strip() for a in ARMS): + print(f" ⚠ {uid}: не хватает армов, единица пропущена") + continue + texts = {a: arms[a] for a in ARMS} + texts["CTRLdecoy"] = arms.get("CTRLdecoy", "") + texts["CTRLfloor"] = arms.get("CTRLfloorB", "") + planted, n_pl = S.margin_plant(arms["R0"]) + texts["CTRLmargin"] = planted if n_pl >= 2 else "" + panel = tuple(a for a in (*ARMS, *CTRL) if texts.get(a, "").strip()) + lay = layout(uid, panel) + tok = hashlib.sha256(f"{SALT}|{uid}".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(n=len(lay), path=ANSW / f"{tok}.txt", skeleton=skel), + "", "=" * 60, "ИСХОДНИК:", arms.get("_src", "")] + for lab, arm in lay.items(): + body += ["", "=" * 60, f"{lab}:", texts[arm]] + (TASKS / f"{tok}.txt").write_text("\n".join(body), encoding="utf-8") + key[tok] = {lab: {"arm": a, "uid": uid, + "kind": "контроль" if a.startswith("CTRL") else "боевой", + "sig": hashlib.sha256(texts[a].encode()).hexdigest()[:12]} + for lab, a in lay.items()} + made += 1 + NEW_KEY.write_text(json.dumps(key, ensure_ascii=False, indent=1), encoding="utf-8") + toks = [t for t in key if not t.startswith("_")] + print(f"заданий {made} → {TASKS}") + print(f"ключ → {NEW_KEY} (судье НЕ давать); соль → {SALT_F}") + print(f"сессий по {PER_SESSION} задания: {-(-len(toks) // PER_SESSION)}") + for i in range(0, len(toks), PER_SESSION): + print(f" сессия {i // PER_SESSION + 1}: " + " · ".join(toks[i:i + PER_SESSION])) + + +def read(dirpath: Path, keypath: Path) -> tuple[dict, list]: + """{(uid, арм): сумма несущих осей} + список замечаний годности.""" + key = json.loads(keypath.read_text(encoding="utf-8")) + out, bad = {}, [] + for f in sorted(dirpath.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]] = {} + for m in _axre.finditer(t): + cells.setdefault("Т" + 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}: ПУСТОЕ обоснование (запрещено заданием)") + out[(meta["uid"], meta["arm"])] = sum(ax.get(a, 0) for a in CARRY) + 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) + c = lambda a, b: math.comb(a, b) + tail = sum(c(n, i) for i in range(min(k, n - k) + 1)) / 2 ** n + return min(1.0, 2 * tail) + + +def score() -> int: + if not NEW_KEY.exists(): + print("ключа нет — сначала --emit") + return 1 + new, bad = read(ANSW, NEW_KEY) + old, _ = read(OLD_ANSW, OLD_KEY) + if not new: + print(f"ответов в {ANSW} нет") + return 1 + uids = sorted({u for u, _ in new}) + print(f"единиц отсужено: {len(uids)} · клеток {len(new)} · замечаний годности {len(bad)}") + for b in bad[:8]: + print(" ⚠", b) + + vals = [v for (u, a), v in new.items() if not a.startswith("CTRL")] + zeros = sum(1 for v in vals if v == 0) / max(len(vals), 1) + print(f"\nГЕЙТ ПЛОТНОСТИ: ошибок/клетку {st.mean(vals):.2f} · доля нулей {zeros:.0%} " + f"→ {'ГОДНО' if zeros < 0.25 else '⛔ ПОЛ ШКАЛЫ, вывод «не различимо» не принимается'}") + + dec = [new[(u, "CTRLdecoy")] - new[(u, "R0")] for u in uids + if (u, "CTRLdecoy") in new and (u, "R0") in new] + print(f"ГРУБЫЙ ДЕКОЙ: пойман {sum(1 for d in dec if d > 0)}/{len(dec)} · медиана +{st.median(dec) if dec else 0:.1f}") + + fl = [new[(u, "T1")] - new[(u, "CTRLfloor")] for u in uids + if (u, "T1") in new and (u, "CTRLfloor") in new] + 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)} · sd {sd:.2f} → ПОРОГ РАЗЛИЧИМОСТИ {thr:.2f}") + + mg = [new[(u, "CTRLmargin")] - new[(u, "R0")] for u in uids + if (u, "CTRLmargin") in new and (u, "R0") in new] + mgm = st.mean(mg) if mg else 0.0 + ok_m = mgm > thr + print(f"МАРЖЕВЫЙ ДЕКОЙ (гейт чувствительности П-2): n={len(mg)} среднее {mgm:+.2f} " + f"против порога {thr:.2f} → {'ПРОЙДЕН' if ok_m else '⛔ НЕ ПРОЙДЕН — права на «не хуже» нет'}") + + print(f"\n{'контраст':12s}{'ед.':>5s}{'перевес':>9s}{'p':>9s}{'p Холма':>10s} вердикт") + ps = {} + for a in ("T1", "T2", "T3"): + d = [new[(u, "R0")] - new[(u, a)] for u in uids if (u, a) in new and (u, "R0") in new] + if d: + ps[a] = (_sign_p(d), st.mean(d), len(d)) + for i, (a, (p, m, n)) in enumerate(sorted(ps.items(), key=lambda x: x[1][0])): + holm = min(1.0, p * (len(ps) - i)) + v = "ПЕРЕШЁЛ ПОРОГ" if abs(m) > thr and holm < 0.05 else "ниже порога" + print(f"{a + ' vs R0':12s}{n:5d}{m:+9.2f}{p:9.4f}{holm:10.4f} {v}") + d = [new[(u, "R0")] - new[(u, "F")] for u in uids if (u, "F") in new and (u, "R0") in new] + if d: + print(f"{'R0 vs F':12s}{len(d):5d}{st.mean(d):+9.2f}{_sign_p(d):9.4f}{'—':>10s} позитивный контроль") + + print(f"\n{'арм':12s}{'СТАРЫЙ замер':>14s}{'НОВЫЙ замер':>13s}{'сдвиг':>8s} ← главный результат") + for a in (*ARMS, *CTRL): + o = [old[(u, a)] for u in uids if (u, a) in old] + n_ = [new[(u, a)] for u in uids if (u, a) in new] + if not n_: + continue + om = st.mean(o) if o else float("nan") + print(f"{a:12s}{om:14.2f}{st.mean(n_):13.2f}{st.mean(n_) - om:+8.2f}") + print("\n⚠ Тексты не менялись ни в одном арме. Любой сдвиг здесь — свойство ПРИБОРА.") + return 0 + + +def selftest() -> int: + fails = [] + ok = lambda c, m: fails.append(m) if not c else None + src = old_texts() + ok(len(src) == 16, f"старых единиц разобрано {len(src)}, ожидалось 16") + if src: + u0 = next(iter(src)) + ok(all(src[u0].get(a, "").strip() for a in ARMS), f"{u0}: не все боевые армы извлеклись") + ok(len(src[u0].get("_src", "")) > 200, f"{u0}: исходник не извлёкся") + # дублей в новой панели быть не должно + S = _sud() + t = {a: src[u0][a] for a in ARMS} + t["CTRLfloor"] = src[u0].get("CTRLfloorB", "") + t["CTRLmargin"] = S.margin_plant(src[u0]["R0"])[0] + sigs = [hashlib.sha256(v.encode()).hexdigest() for v in t.values() if v.strip()] + ok(len(sigs) == len(set(sigs)), f"{u0}: в новой панели остались побайтные дубли") + # старая панель дубли содержала — проверка, что мы чиним реальную болезнь + old_pair = src[u0].get("CTRLfloorA", "") == src[u0].get("T1", "") + ok(old_pair, f"{u0}: ожидалось, что старый CTRLfloorA ≡ T1 (дефект пака 23)") + lay = layout(u0, tuple(a for a in (*ARMS, *CTRL) if t.get(a, "").strip())) + ok(len(lay) == len(set(lay.values())), "раскладка меток не биективна") + ok(layout(u0, tuple(sorted(lay.values()))) == lay, "раскладка не воспроизводима") + ok(_sign_p([1, 1, 1, 1, 1]) < 0.07, "знаковый тест: пять единиц в одну сторону должны дать p<0.07") + ok(_sign_p([0, 0, 0]) == 1.0, "знаковый тест: одни ничьи должны дать p=1") + for m in fails: + print("ПРОВАЛ:", m) + print(f"селфтест tier2: провалов {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()) + elif a[0] == "--selftest": + sys.exit(selftest()) + else: + raise SystemExit(f"⛔ неизвестный режим {a[0]!r}")