364 lines
19 KiB
Python
364 lines
19 KiB
Python
#!/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())
|