textmachine/eval/dovodka/naklon.py

153 lines
7.3 KiB
Python
Raw Permalink 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, только чтение сырья.
⚠ ЗАЧЕМ ОТДЕЛЬНЫЙ ФАЙЛ. Норма рига требует наклон ЗАМЕРИТЬ, ВЫЧЕСТЬ и СТОРОЖИТЬ ГЕЙТОМ, и отчёт
фазы (23-editor-tier.md:1027) прямо утверждает, что так и делается. Фактически в зоне фазы Д кода
наклона не было ни строки: замер сделан ОДИН раз руками при финальном аудите, уже ПОСЛЕ вердиктов
(девиация объявлена в Д10), сторожа нет. Здесь он есть.
ЧТО МЕРИТСЯ. Наклон — это зависимость выставленного счёта от НОМЕРА МЕТКИ в пачке: судья,
устающий к концу задания, ставит поздним меткам больше ошибок. Сам по себе наклон вердикту не
вредит — он сокращается в контрасте, ЕСЛИ средние позиции двух армов совпадают. Вредит
ПРОИЗВЕДЕНИЕ наклона на дисбаланс позиций, и именно оно здесь и гейтится.
поправка контраста = наклон × (средняя позиция арма A средняя позиция арма B)
ГЕЙТ: пачка не годна, если поправка хоть к одному первичному контрасту превышает
`MAX_SHARE` от порога различимости этой оси — то есть если перестановка меток способна
съесть заметную долю решающей величины.
Запуск: naklon.py [--pass=<проход>] | --selftest
"""
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)
CARRY = ("ВЕРНОСТЬ", "ЯЗЫК")
MAX_SHARE = 0.25 # доля порога, которую поправке позволено съедать
PASSES = {
"tier": (["editor-tier/aj-tier"], "editor-tier/blind-keys/tier-KEY.json",
[("T1", "R0"), ("T2", "R0"), ("T3", "R0")], 1.02),
"tier2": (["editor-tier/tier2"], "editor-tier/blind-keys/tier2-KEY.json",
[("T1", "R0"), ("T2", "R0"), ("T3", "R0")], 1.84),
"border": (["editor-tier/aj-border"], "editor-tier/blind-keys/border-KEY.json",
[("R2", "R0")], 1.48),
"d4": (["judging/sud-d4/p1-answers", "judging/sud-d4/p2-answers"], "dovodka/blind-keys-d4",
[("A1B", "A0"), ("A3", "A0"), ("A6", "A0")], 1.65),
"d3": (["judging/sud-d3/p1-answers", "judging/sud-d3/p2-answers"], "dovodka/blind-keys-d3",
[("E0", "D0")], 0.90),
"d6": (["judging/sud-d6/p1-answers", "judging/sud-d6/p2-answers"], "dovodka/blind-keys-d6",
[("J0", "D0")], 2.10),
"d1": (["judging/sud-d1/p1-answers", "judging/sud-d1/p2-answers"], "dovodka/blind-keys-d1",
[("R1", "A0")], 1.47),
}
def load_key(rel: str) -> dict:
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 collect(rels: list[str], keyrel: str):
"""[(номер метки, арм, счёт)] по всем разобранным клеткам прохода."""
key = load_key(keyrel)
rows = []
for rel in rels:
d = BOOKS / rel
if not d.exists():
continue
for f in sorted(d.glob("*.txt")):
km = key.get(f.stem)
if not isinstance(km, dict):
continue
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))
for lab, ax in cells.items():
meta = km.get(lab)
if isinstance(meta, dict) and meta.get("arm"):
rows.append((int(lab[1:]), meta["arm"], sum(ax.get(a, 0) for a in CARRY)))
return rows
def slope(rows) -> float:
"""Ошибок на ШАГ метки — обычная линейная регрессия счёта на номер метки."""
if len(rows) < 4:
return 0.0
xs = [r[0] for r in rows]
ys = [r[2] for r in rows]
mx, my = st.mean(xs), st.mean(ys)
den = sum((x - mx) ** 2 for x in xs)
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den if den else 0.0
def report(name: str) -> int:
rels, keyrel, fam, thr = PASSES[name]
rows = collect(rels, keyrel)
if not rows:
print(f"{name:8s} — ответов нет")
return 0
s = slope(rows)
pos = collections.defaultdict(list)
for lab, arm, _ in rows:
pos[arm].append(lab)
mean_pos = {a: st.mean(v) for a, v in pos.items()}
print(f"\n=== {name} · точек {len(rows)} · наклон {s:+.4f} ошибки на шаг метки · "
f"порог оси {thr:.2f} ===")
rc = 0
for a, b in fam:
if a not in mean_pos or b not in mean_pos:
continue
dp = mean_pos[a] - mean_pos[b]
corr = s * dp
share = abs(corr) / thr if thr else 0.0
mark = " ⛔ ПРОБОЙ" if share > MAX_SHARE else ""
print(f" {a + '/' + b:12s} позиции {mean_pos[a]:5.2f} против {mean_pos[b]:5.2f} "
f"(разница {dp:+.2f}) → поправка {corr:+.3f} = {share:.0%} порога{mark}")
if share > MAX_SHARE:
rc = 1
return rc
def selftest() -> int:
fails = []
# синтетика с ИЗВЕСТНЫМ наклоном: счёт = 2 × номер метки
rows = [(i, "A" if i % 2 else "B", 2 * i) for i in range(1, 21)]
s = slope(rows)
if abs(s - 2.0) > 1e-9:
fails.append(f"наклон на синтетике {s:.4f}, ожидалось 2.0")
flat = [(i, "A", 5) for i in range(1, 21)]
if abs(slope(flat)) > 1e-9:
fails.append("на плоских данных наклон обязан быть нулём")
if len(collect(*PASSES["d4"][:2])) < 100:
fails.append("проход d4 не разбирается")
for m in fails:
print("ПРОВАЛ:", m)
print(f"селфтест наклона: провалов {len(fails)}")
return 1 if fails else 0
if __name__ == "__main__":
args = sys.argv[1:]
if "--selftest" in args:
sys.exit(selftest())
want = [a.split("=", 1)[1] for a in args if a.startswith("--pass=")] or list(PASSES)
rc = 0
for n in want:
rc |= report(n)
print(f"\n⚠ Гейт роняет проход, если поправка съедает больше {MAX_SHARE:.0%} порога "
f"различимости: столько способна дать одна перестановка меток.")
sys.exit(rc)