textmachine/eval/dovodka/gain.py

364 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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