#!/usr/bin/env python3 """СУДЕЙСКИЙ РИГ НА АГЕНТАХ СЕССИИ. $0 по ключам провайдеров. ⚠ Заведён 07.08 после того, как адверсариальное ревью третьей редакции показало: судейство агентами делалось встроенным питоном из командной строки, и потому не имело ни воспроизводимости, ни слепоты, ни персиста обоснований. Все четыре дефекта чинятся здесь, в дереве. ЧТО БЫЛО СЛОМАНО И ЧЕМ ЧИНИТСЯ. 1. **Слепота ломалась именем файла.** Пакет назывался `A_lawvB--o0.txt`, и соглашение «в порядке 0 первый названный арм — это ВАРИАНТ 1» делало раскладку выводимой без ключа. ⇒ Имя пакета теперь НЕПРОЗРАЧНЫЙ токен, а базовая ориентация пары рандомизируется по единице: какой физический арм окажется первым, решает хеш, а не порядок в имени контраста. 2. **Обоснования не персистировались.** Агенты их давали, а сохранялись только числа — ровно тот дефект платных судей, за который они и были заменены. Условие 2 амендмента владельца 07.08 не исполнялось. ⇒ `record()` требует поле `why` непустым для каждой ненулевой оси, иначе голос не пишется. 3. **Контроли не покрывали сам агент-судью.** Декой, пол и разброс были измерены только на ПЛАТНЫХ судьях; инструмент, породивший числа третьей редакции, не проверялся ничем. ⇒ Контрольные пакеты подмешиваются в ту же пачку и НЕОТЛИЧИМЫ от боевых: * `decoy` — чистый вариант против него же с посаженной деградацией (судья обязан поймать); * `floor` — арм против собственного повтора (истинная разница ноль, перевес обязан быть ~0); * `twin` — побайтно один и тот же текст в обе позиции (тавтологический контроль разбора). 4. **Кода не было в дереве.** ⇒ Он здесь, и покупок не делает вовсе: агенты бесплатны. Запуск: --emit <контрасты через запятую> выпустить пачку с подмешанными контролями --score свод по персистированным голосам, $0 --controls отдельно: что показали контроли """ from __future__ import annotations import hashlib import json import random import statistics as st import sys from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") sys.path.insert(0, str(REPO / "eval" / "role_topology")) import bakeoff as BO # noqa: E402 import judges as J # noqa: E402 OUT = Path.home() / "books" / "role-topology" PACKETS = OUT / "aj-packets" KEYS = OUT / "blind-keys" VOTES = OUT / "aj-votes" SALT = "exp21-aj-v1" # меняется только вместе с experiment-ID FORMAT = """Отвечай РОВНО в таком виде и ничем больше: В1-ВЕРНОСТЬ: <число> В1-ТЕРМИН: <число> В1-ЯЗЫК: <число> В1-ФОРМА: <число> В2-ВЕРНОСТЬ: <число> В2-ТЕРМИН: <число> В2-ЯЗЫК: <число> В2-ФОРМА: <число> СТИЛЬ: <В1|В2|НИЧЬЯ> ПОЧЕМУ: <по одной короткой ЦИТАТЕ из текста на КАЖДУЮ ненулевую ось, через « | ». Цитата обязательна: число без цитаты не принимается — именно из-за их отсутствия показания прошлых судей оказались непроверяемыми по диску.> """ def token(contrast: str, uid: str, order: int) -> str: """Непрозрачное имя пакета: из него не выводится ни контраст, ни порядок, ни арм.""" h = hashlib.sha1(f"{SALT}|{contrast}|{uid}|{order}".encode()).hexdigest() # noqa: S324 return h[:12] def flipped(contrast: str, uid: str) -> bool: """Базовая ориентация пары: какой ФИЗИЧЕСКИЙ арм идёт первым. Рандомизируется по единице, чтобы «порядок 0» не означал всегда «первый арм контраста».""" h = hashlib.sha1(f"{SALT}|flip|{contrast}|{uid}".encode()).digest() # noqa: S324 return bool(h[0] & 1) _PLANTS = [ # (что заменить, на что, класс) — каждая посадка есть дефект, названный рубрикой (r"\bне\s+(выдерж\w+)", r"легко \1", "инверсия полярности"), (r"\bдесят(ь|и)\b", "сорок", "подмена величины"), (r"\bсто\b", "триста", "подмена величины"), (r"\bпоразительн(\w+)", r"взгрумительн\1", "выдуманное слово"), (r"\bспокойн(\w+)", r"безмятежн\1 и невозмутительн\1", "выдуманное слово"), (r"—\s*", '"', "сбой диалоговой вёрстки"), ] def plant(text: str) -> tuple[str, int]: """Посадить контролируемую деградацию в ПРОИЗВОЛЬНЫЙ текст. ⚠ Своя реализация нужна потому, что `editor_wire_probe.planted_draft` ищет дословные якоря окна пакета-6 и на выходах армов не срабатывает (поймано исполнением). Здесь посадка идёт по регулярным выражениям, а число реально применённых замен возвращается — клетка, где посадить не удалось, в контроль не берётся, иначе «декой не пойман» будет означать «декоя не было». """ import re as _re # noqa: PLC0415 out, n = text, 0 for pat, rep, _cls in _PLANTS: new, cnt = _re.subn(pat, rep, out, count=1) if cnt: out, n = new, n + cnt return out, n def emit(contrasts: list[str]) -> None: PACKETS.mkdir(parents=True, exist_ok=True) KEYS.mkdir(parents=True, exist_ok=True) us = BO.units() key: dict[str, dict] = {} def write(contrast: str, uid: str, order: int, src: str, ta: str, tb: str, arm_a: str, arm_b: str, kind: str) -> None: fl = flipped(contrast, uid) first, second = (tb, ta) if fl else (ta, tb) fa, fb = (arm_b, arm_a) if fl else (arm_a, arm_b) v1, v2 = (first, second) if order == 0 else (second, first) n1, n2 = (fa, fb) if order == 0 else (fb, fa) tok = token(contrast, uid, order) (PACKETS / f"{tok}.txt").write_text( J.RUBRIC + "\n" + FORMAT + "\n" + J.packet(src, v1, v2), encoding="utf-8") key[tok] = dict(contrast=contrast, uid=uid, order=order, v1=n1, v2=n2, kind=kind) for c in contrasts: a, b = c.split("v", 1) for u in us: ta, tb = BO.text3_of(a, u), BO.text3_of(b, u) if not ta.strip() or not tb.strip(): continue for o in (0, 1): write(c, u["uid"], o, u["source"], ta, tb, a, b, "боевой") # --- КОНТРОЛИ, неотличимые от боевых пакетов ------------------------------------------- rnd = random.Random(20260807) for u in rnd.sample(us, min(6, len(us))): clean = BO.text3_of("A", u) planted, n = plant(clean) if n < 2: continue # посадка не удалась — клетку не берём for o in (0, 1): write("CTRLdecoy", u["uid"], o, u["source"], clean, planted, "чистый", "посаженный", "контроль-декой") for u in rnd.sample(us, min(6, len(us))): f = OUT / f"bo3-Aprime-{u['uid']}.json" if not f.exists(): continue for o in (0, 1): write("CTRLfloor", u["uid"], o, u["source"], BO.text3_of("A", u), json.loads(f.read_text(encoding="utf-8"))["content"], "A", "Aprime", "контроль-пол") for u in rnd.sample(us, min(4, len(us))): t = BO.text3_of("A", u) for o in (0, 1): write("CTRLtwin", u["uid"], o, u["source"], t, t, "A", "A", "контроль-близнец") (KEYS / "aj-KEY.json").write_text(json.dumps(key, ensure_ascii=False, indent=1), encoding="utf-8") kinds: dict[str, int] = {} for v in key.values(): kinds[v["kind"]] = kinds.get(v["kind"], 0) + 1 print(f"пакетов {len(key)} → {PACKETS} (имена непрозрачны, раскладка рандомизирована)") print(f" состав: {kinds}") print(f" ключ → {KEYS}/aj-KEY.json · судье НЕ давать") def record(tok: str, v1: dict, v2: dict, style: str, why: str) -> None: """Записать голос. Пустое `why` при ненулевой оси — отказ записи (условие 2 амендмента).""" VOTES.mkdir(parents=True, exist_ok=True) nonzero = any(v for v in list(v1.values()) + list(v2.values())) if nonzero and not (why or "").strip(): raise ValueError(f"{tok}: ненулевые оси без обоснования — голос не принимается") key = json.loads((KEYS / "aj-KEY.json").read_text(encoding="utf-8"))[tok] (VOTES / f"{tok}.json").write_text(json.dumps( dict(token=tok, judge="agent", cost_usd=0.0, style=style, why=why, **{f"В1-{k}": val for k, val in v1.items()}, **{f"В2-{k}": val for k, val in v2.items()}, **key), ensure_ascii=False), encoding="utf-8") ANSWERS = OUT / "aj-answers" def ingest() -> None: """Разобрать ответы судей из `aj-answers/<токен>.txt` в голоса. $0. ⚠ Заведено потому, что иначе ответ агента попадает на диск через МОИ руки, а транскрипция — это тот самый канал, по которому в третьей редакции потерялись обоснования, а слепое чтение вообще не персистировалось. Судья пишет файл сам; здесь он только разбирается и проверяется. Голос с ненулевой осью и пустым обоснованием отвергается (условие 2 амендмента владельца). """ import re as _re # noqa: PLC0415 if not ANSWERS.exists(): print(f"нет каталога ответов {ANSWERS}") return ok = bad = 0 reasons: dict[str, int] = {} for f in sorted(ANSWERS.glob("*.txt")): txt = f.read_text(encoding="utf-8") v1, v2 = {}, {} for side, dst in (("В1", v1), ("В2", v2)): for ax in J.AXES: m = _re.search(rf"^{side}-{ax}\s*:\s*(\d+)", txt, _re.M) if m: dst[ax] = int(m.group(1)) style = (_re.search(r"^СТИЛЬ\s*:\s*(В1|В2|НИЧЬЯ)", txt, _re.M) or [None, ""])[1] \ if _re.search(r"^СТИЛЬ\s*:\s*(В1|В2|НИЧЬЯ)", txt, _re.M) else "" why_m = _re.search(r"^ПОЧЕМУ\s*:\s*(.+)$", txt, _re.M | _re.S) why = why_m.group(1).strip() if why_m else "" if len(v1) != len(J.AXES) or len(v2) != len(J.AXES): bad += 1 reasons["не разобран формат"] = reasons.get("не разобран формат", 0) + 1 continue try: record(f.stem, v1, v2, style, why) ok += 1 except (ValueError, KeyError) as e: # noqa: PERF203 bad += 1 k = "нет обоснования" if isinstance(e, ValueError) else "токена нет в ключе" reasons[k] = reasons.get(k, 0) + 1 print(f"принято голосов {ok} · отвергнуто {bad} {reasons if reasons else ''}") def _margins(kind_filter: str | None = None) -> dict[str, dict[str, list[float]]]: out: dict[str, dict[str, list[float]]] = {} for f in VOTES.glob("*.json"): r = json.loads(f.read_text(encoding="utf-8")) if kind_filter and r["kind"] != kind_filter: continue e1 = sum(r[f"В1-{a}"] for a in J.AXES) e2 = sum(r[f"В2-{a}"] for a in J.AXES) # перевес приводится к ориентации «за первый арм КОНТРАСТА», а не за позицию a_first = r["contrast"].split("v", 1)[0] m = (e2 - e1) if r["v1"] == a_first else (e1 - e2) out.setdefault(r["contrast"], {}).setdefault(r["uid"], []).append(m) return out def score() -> None: data = _margins() if not data: print("голосов нет") return print(f"{'контраст':14s}{'ед.':>5s}{'перевес':>10s}{'95% ДИ':>20s}{'p':>9s}") for c, per in sorted(data.items()): mar = [st.mean(v) for v in per.values() if len(v) == 2] if not mar: continue lo, hi = BO.boot_ci(mar) print(f"{c:14s}{len(mar):5d}{st.mean(mar):+10.2f} [{lo:+.2f}, {hi:+.2f}]" f"{BO.sign_perm_p(mar):9.4f}") def controls() -> None: """Что показали контроли — читается ДО боевых чисел, иначе они не интерпретируются.""" print("КОНТРОЛИ САМОГО АГЕНТА-СУДЬИ\n") for c, need in (("CTRLdecoy", "обязан поймать посаженную деградацию: перевес СИЛЬНО > 0"), ("CTRLfloor", "истинная разница НОЛЬ: перевес обязан быть ~0"), ("CTRLtwin", "побайтно один текст: перевес обязан быть РОВНО 0")): per = _margins().get(c, {}) mar = [st.mean(v) for v in per.values() if len(v) == 2] if not mar: print(f" {c:11s} не измерен") continue lo, hi = BO.boot_ci(mar) print(f" {c:11s} {st.mean(mar):+6.2f} [{lo:+.2f}, {hi:+.2f}] n={len(mar)} {need}") # транспозиция: признак того, что судья переставил числа вместо повторного чтения tr = 0 tot = 0 for _c, per in _margins().items(): for _uid, v in per.items(): if len(v) == 2: tot += 1 tr += abs(v[0] - v[1]) < 1e-9 print(f"\n клеток, где два порядка дали ТОЖДЕСТВЕННЫЙ перевес: {tr}/{tot}") print(" (высокая доля = судья транспонировал числа, а не пере-читал; зеркало вырождено)") if __name__ == "__main__": a = sys.argv[1:] or ["--score"] if a[0] == "--emit": emit((a[1] if len(a) > 1 else "AvF3,BvF3,D_vA,A_lawvB").split(",")) elif a[0] == "--ingest": ingest() elif a[0] == "--controls": controls() elif a[0] == "--score": score() else: print(__doc__)