textmachine/eval/role_topology/agentjudge.py

295 lines
16 KiB
Python
Raw 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 по ключам провайдеров.
⚠ Заведён 07.08 после того, как адверсариальное ревью третьей редакции показало: судейство
агентами делалось встроенным питоном из командной строки, и потому не имело ни воспроизводимости,
ни слепоты, ни персиста обоснований. Все четыре дефекта чинятся здесь, в дереве.
ЧТО БЫЛО СЛОМАНО И ЧЕМ ЧИНИТСЯ.
1. **Слепота ломалась именем файла.** Пакет назывался `A_lawvB-<uid>-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"\е\s+(выдерж\w+)", r"легко \1", "инверсия полярности"),
(r"\есят(ь|и)\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__)