295 lines
16 KiB
Python
295 lines
16 KiB
Python
#!/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"\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__)
|