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