Freeze the pre-registration and density gate for re-judging the tier pass before any answer arrives

This commit is contained in:
Claude (backend session) 2026-08-15 19:30:47 +03:00
parent 3aaba0dd3b
commit a03ac66b0b
2 changed files with 712 additions and 0 deletions

364
eval/dovodka/gain.py Normal file
View file

@ -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())

348
eval/dovodka/tier2.py Normal file
View file

@ -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}")