#!/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())