From 0eefc97df83c7f89a8500a48e9f5c131205af6ce Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 7 Aug 2026 15:01:58 +0300 Subject: [PATCH] Freeze the fourth-edition agent judging rig before judging: opaque packet names, randomised layout, mandatory quoted justification, embedded decoy floor and twin controls --- eval/role_topology/agentjudge.py | 250 +++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 eval/role_topology/agentjudge.py diff --git a/eval/role_topology/agentjudge.py b/eval/role_topology/agentjudge.py new file mode 100644 index 00000000..d972ea28 --- /dev/null +++ b/eval/role_topology/agentjudge.py @@ -0,0 +1,250 @@ +#!/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") + + +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] == "--controls": + controls() + elif a[0] == "--score": + score() + else: + print(__doc__)