textmachine/eval/tenant_panel/sol.py

245 lines
13 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.4 — ПАКЕТЫ ДЛЯ SOL: внешний судейский контур руками владельца. $0.
Что это. Sol — флагманская reasoning-модель OpenAI в ЧАТ-подписке владельца. Она вне обеих
популяций судей пака (платные ключи + агенты сессии), поэтому её голос — третий независимый
инструмент. Сессия готовит paste-ready пакеты, владелец вставляет их руками и возвращает ответы;
транскрипты персистятся дословно.
**Пакет (а) — АРБИТРАЖ ЭКСП-21.** Там два платных судьи разошлись на величину, которая
собственным шумом не объясняется: разброс судьи-замены на повторе даёт sd перевеса 0.81 на голос,
то есть ±0.15 на контраст из 15 единиц. Отобраны ТРИ контраста по ФАКТИЧЕСКОМУ расхождению:
контраст luna gemini разрыв в sd собственного шума
A vs F +0.84 +3.03 2.19 ~14
B vs F +1.44 +3.33 1.90 ~12
A_law vs A +0.91 0.20 1.11 ~7 ← ЗНАК РАСХОДИТСЯ
Почему именно эти. `A/F` заказан промтом. `B/F` — тот же заголовочный вопрос («покупает ли второй
проход качество») и второй по величине разрыв. `A_law/A` взят НЕ по величине: это единственный
контраст, где судьи расходятся ЗНАКОМ, а расхождение знака хуже расхождения величины — оно меняет
вердикт, а не точность. `D/A` (разрыв 0.74) и `D/D_` (1.23) не берутся: там знак у обоих судей
один, и арбитраж уточнил бы величину, которая решения не несёт.
Протокол — ПАРНЫЙ, потому что арбитражу подлежит парный замер: слепые метки, ОБА порядка, декой.
Правило зеркальности для пар-протокола живо (для абсолютного снято, D39.117 п.2в).
**Семейный запрет.** Sol не судит клетки OpenAI-моделей. В армах эксп-21 их нет (deepseek, glm),
поэтому запрет здесь не связывает — но он объявлен, и в пакете (б) по финалистам эксп-22 будет
применён механически.
Запуск: --emit выпустить пакеты и инструкцию, $0
--ingest принять ответы владельца из sol/answers/, $0
--score что сказал Sol против платных судей, $0
"""
from __future__ import annotations
import hashlib
import json
import statistics as st
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
sys.path.insert(0, str(ZONE))
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
import absjudge as AJ # noqa: E402
import bakeoff as BO # noqa: E402
import judges as J # noqa: E402
import money as MONEY # noqa: E402
SOL = MONEY.OUT / "sol"
ANSW = SOL / "answers"
KEYS = MONEY.OUT / "blind-keys"
CONTRASTS = [("A", "F"), ("B", "F"), ("A_law", "A")]
N_UNITS = 3
SALT_FILE = KEYS / "SALT-sol.txt"
def _salt() -> str:
if not SALT_FILE.exists():
import secrets # noqa: PLC0415
SALT_FILE.parent.mkdir(parents=True, exist_ok=True)
SALT_FILE.write_text(secrets.token_hex(16), encoding="utf-8")
return SALT_FILE.read_text(encoding="utf-8").strip()
def pick_units(a: str, b: str) -> list[dict]:
"""Единицы с НАИБОЛЬШИМ расхождением судей на этом контрасте — там арбитраж и нужен."""
us = BO.units()
scored = []
for u in us:
per = []
for judge in J.JUDGES:
bo = BO.margins_by_order(a, b, u["uid"], judge)
if len(bo) == 2:
per.append(st.mean([st.mean(bo[0]), st.mean(bo[1])]))
if len(per) == len(J.JUDGES):
scored.append((abs(per[0] - per[1]), u))
scored.sort(key=lambda x: (-x[0], x[1]["uid"]))
return [u for _g, u in scored[:N_UNITS]]
def _packet(src: str, v1: str, v2: str, tok: str) -> str:
return "\n".join([
f"ПАКЕТ {tok}", "",
J.RUBRIC.split("---", 1)[0].strip() if "---" in J.RUBRIC else J.RUBRIC.strip(), "",
"=" * 60, "ИСХОДНИК:", src, "",
"=" * 60, "ВАРИАНТ 1:", v1, "",
"=" * 60, "ВАРИАНТ 2:", v2, ""])
def emit() -> None:
SOL.mkdir(parents=True, exist_ok=True)
ANSW.mkdir(parents=True, exist_ok=True)
key: dict[str, dict] = {}
n = 0
for a, b in CONTRASTS:
for u in pick_units(a, b):
ta, tb = BO.text_of(a, u), BO.text_of(b, u)
if not ta.strip() or not tb.strip():
continue
for order in (0, 1):
tok = hashlib.sha1(f"{_salt()}|{a}|{b}|{u['uid']}|{order}".encode()).hexdigest()[:10] # noqa: S324
v1, v2 = (ta, tb) if order == 0 else (tb, ta)
(SOL / f"{tok}.txt").write_text(_packet(u["source"], v1, v2, tok), encoding="utf-8")
key[tok] = dict(contrast=f"{a}v{b}", first=a if order == 0 else b,
second=b if order == 0 else a, uid=u["uid"], order=order,
kind="боевой")
n += 1
# ДЕКОЙ: посаженная деградация против чистого текста. Сессия, не поймавшая декой, аннулируется.
u0 = BO.units()[0]
clean = BO.text3_of("A", u0)
planted, k = AJ._plant(clean) # noqa: SLF001
if k >= 2:
for order in (0, 1):
tok = hashlib.sha1(f"{_salt()}|decoy|{u0['uid']}|{order}".encode()).hexdigest()[:10] # noqa: S324
v1, v2 = (clean, planted) if order == 0 else (planted, clean)
(SOL / f"{tok}.txt").write_text(_packet(u0["source"], v1, v2, tok), encoding="utf-8")
key[tok] = dict(contrast="ДЕКОЙ", first="чистый" if order == 0 else "порченый",
second="порченый" if order == 0 else "чистый", uid=u0["uid"],
order=order, kind="контроль-декой", plants=k)
n += 1
(KEYS / "sol-KEY.json").write_text(json.dumps(key, ensure_ascii=False, indent=1),
encoding="utf-8")
(SOL / "ИНСТРУКЦИЯ.md").write_text(_instr(len(key)), encoding="utf-8")
print(f"пакетов {n}{SOL} · ключ → {KEYS}/sol-KEY.json (владельцу и Sol НЕ давать)")
print(f"инструкция → {SOL}/ИНСТРУКЦИЯ.md")
def _instr(n: int) -> str:
return f"""# Пакеты для Sol — инструкция владельцу
Всего пакетов: **{n}**. Каждый — отдельный файл `<токен>.txt` в этом каталоге.
Порядок вставки НЕ важен; частичный возврат тоже годится — что вернётся, то и посчитается.
## Что делать
1. Открыть новый чат с Sol (high reasoning).
2. Вставить содержимое ОДНОГО файла целиком, без правок и без пояснений от себя.
3. Скопировать ответ Sol **дословно** в файл `answers/<тот же токен>.txt`.
4. Следующий пакет — **новым чатом**: иначе Sol узнаёт зеркальные пары и позиционного контроля
не остаётся (эта беда измерена на агенте-судье в эксп-21 §10).
## Формат ответа, который ждёт разбор
Sol просят вернуть ровно такие строки (это часть текста пакета, отдельно требовать не нужно):
```
В1-ВЕРНОСТЬ: <число>
В1-ТЕРМИН: <число>
В1-ЯЗЫК: <число>
В1-ФОРМА: <число>
В2-ВЕРНОСТЬ: <число>
В2-ТЕРМИН: <число>
В2-ЯЗЫК: <число>
В2-ФОРМА: <число>
```
Если Sol добавит рассуждение — не удалять: транскрипт персистится дословно, лишнее разбор
игнорирует.
## Чего делать НЕ надо
* не сообщать Sol, какая модель какой вариант писала (в пакетах этого нет, и это важно);
* не менять порядок вариантов внутри пакета;
* не сводить несколько пакетов в один чат.
## Что это даёт
Два платных судьи эксп-21 разошлись на 2.19 ошибки там, где собственный шум судьи ±0.15.
Направление вывода «второй проход покупает качество» держится, значимость — нет: её несёт ОДИН
судья, аварийная лёгкая замена. Голос Sol — третий независимый инструмент, и он решает, к кому
из двух ближе истина.
Среди пакетов есть **контрольные** (в них одна сторона заведомо испорчена). Если Sol их не
ловит — его голоса по боевым парам не читаются. Какие именно контрольные — не сказано намеренно.
"""
def ingest() -> None:
key = json.loads((KEYS / "sol-KEY.json").read_text(encoding="utf-8"))
got = {}
for f in sorted(ANSW.glob("*.txt")):
if f.stem not in key:
print(f" ⚠ токена {f.stem} нет в ключе — пропуск")
continue
p = J.parse(f.read_text(encoding="utf-8"))
if p["В1-ВЕРНОСТЬ"] is None:
print(f"{f.stem}: ответ не разобран")
continue
e1 = sum(p[f"В1-{ax}"] or 0 for ax in J.AXES)
e2 = sum(p[f"В2-{ax}"] or 0 for ax in J.AXES)
got[f.stem] = dict(**key[f.stem], e1=e1, e2=e2)
(MONEY.OUT / "sol-votes.json").write_text(json.dumps(got, ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"принято голосов {len(got)} из {len(key)} пакетов")
def score() -> None:
f = MONEY.OUT / "sol-votes.json"
if not f.exists() or not json.loads(f.read_text(encoding="utf-8")):
print("Sol не вернулся — голосов нет. Это НЕ блокер: отчёт сдаётся с явной строкой.")
return
votes = json.loads(f.read_text(encoding="utf-8"))
dec = [v for v in votes.values() if v["kind"] == "контроль-декой"]
if dec:
caught = sum(1 for v in dec
if (v["e1"] < v["e2"]) == (v["first"] == "чистый"))
print(f"ДЕКОЙ: поймано {caught}/{len(dec)}"
+ ("" if caught == len(dec) else " ⛔ сессия Sol аннулируется целиком"))
if caught != len(dec):
return
print(f"\n{'контраст':14s}{'ед.':>5s}{'Sol':>9s}{'luna':>9s}{'gemini':>9s}")
for a, b in CONTRASTS:
per: dict[str, list[float]] = {}
for v in votes.values():
if v["contrast"] != f"{a}v{b}":
continue
m = (v["e2"] - v["e1"]) if v["first"] == a else (v["e1"] - v["e2"])
per.setdefault(v["uid"], []).append(m)
vals = [st.mean(x) for x in per.values() if len(x) == 2]
if not vals:
continue
paid = {}
for judge in J.JUDGES:
pv = []
for u in BO.units():
if u["uid"] not in per:
continue
bo = BO.margins_by_order(a, b, u["uid"], judge)
if len(bo) == 2:
pv.append(st.mean([st.mean(bo[0]), st.mean(bo[1])]))
paid[judge] = st.mean(pv) if pv else float("nan")
print(f"{a + ' vs ' + b:14s}{len(vals):5d}{st.mean(vals):+9.2f}"
+ "".join(f"{paid[j]:+9.2f}" for j in J.JUDGES))
print("\n⚠ единицы Sol — подмножество (наибольшее расхождение судей), поэтому платные колонки"
"\n пере-считаны на ТЕХ ЖЕ единицах, а не взяты из свода по всем 15.")
if __name__ == "__main__":
a = sys.argv[1:] or ["--emit"]
fn = {"--emit": emit, "--ingest": ingest, "--score": score}.get(a[0])
fn() if fn else print(__doc__)