textmachine/eval/tenant_panel/sol.py

406 lines
26 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]]
# ⚠ РАМКА ВНЕШНЕГО СУДЬИ. Sol работает в UI-подписке владельца, а не по API: у него нет ни ключа,
# ни жёсткого формата ответа, ни запрета на веб. Владелец 09.08: «он пипец как любит посмотреть
# туда, куда его не просят». Три риска, каждый обесценивает пакет: узнавание книги вместо сверки с
# исходником (контаминация — тот самый класс, ради которого искали невиданный срез); собственный
# вариант перевода вместо счёта ошибок; разметка и преамбулы, ломающие построчный разбор.
FRAME = """РАМКА ЗАДАНИЯ — прочитай целиком до того, как начнёшь.
Это слепой замер, а не консультация. От тебя нужен РОВНО ОДИН артефакт: блок строк с числами.
Материал оплачен и одноразовый; всё, что сверх формата, ломает машинный разбор и обесценивает его.
ЧЕГО ДЕЛАТЬ НЕЛЬЗЯ:
* НЕ искать ничего вовне этого сообщения — ни в сети, ни в памяти: ни книгу, ни автора, ни главу,
ни чужие переводы. Если ты узнал произведение — не опирайся на это ни одним словом. Единственный
эталон — ИСХОДНИК ниже. Узнавание вместо сверки с исходником делает замер недействительным.
* НЕ предлагать свой перевод, не переписывать, не редактировать и не «улучшать» варианты.
* НЕ гадать, кто автор вариантов — человек, модель, какая именно, какой вариант «основной».
Порядок вариантов перемешан жребием и не значит ничего.
* НЕ оценивать варианты друг через друга. Каждый считается ОТДЕЛЬНО против исходника: вопрос не
«какой лучше», а «сколько ошибок в этом».
* НЕ добавлять заголовки, таблицы, markdown-разметку, жирный шрифт, преамбулы и рекомендации.
* НЕ задавать уточняющих вопросов и не просить контекст: его не будет, отвечай по тому, что дано.
* НЕ менять имена строк ответа и не переставлять их — разбор ищет их буквально, посимвольно."""
FORMAT = """ФОРМАТ ОТВЕТАРОВНО ЭТИ ДЕВЯТЬ СТРОК, КАЖДАЯ С НОВОЙ, БЕЗ РАЗМЕТКИ И БЕЗ ТЕКСТА МЕЖДУ НИМИ:
В1-ВЕРНОСТЬ: <число>
В1-ТЕРМИН: <число>
В1-ЯЗЫК: <число>
В1-ФОРМА: <число>
В2-ВЕРНОСТЬ: <число>
В2-ТЕРМИН: <число>
В2-ЯЗЫК: <число>
В2-ФОРМА: <число>
СТИЛЬ: <В1 или В2 или НИЧЬЯ — где живее и естественнее русская проза>
Пояснения и разбор можно дать, но ТОЛЬКО ПОСЛЕ этих девяти строк, отдельным абзацем. Ничего перед
ними и ничего между ними. Если по какой-то оси ошибок нет — ставь 0, а не пропуск и не прочерк."""
def _packet(src: str, v1: str, v2: str, tok: str) -> str:
axes = J.RUBRIC.split("---", 1)[0].strip() if "---" in J.RUBRIC else J.RUBRIC.strip()
return "\n".join([
f"ПАКЕТ {tok}", "",
FRAME, "", "ЧТО СЧИТАТЬ ОШИБКОЙ — по типам:", axes, "", FORMAT, "",
"=" * 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")
# ── ПАКЕТ (б): слепые пары ФИНАЛИСТОВ Ф2/Ф3 ────────────────────────────────────────────────
# Заказан промтом «по готовности Ф2/Ф3». Смысл иной, чем у пакета (а): (а) арбитрирует спор
# ДВУХ платных судей эксп-21, а (б) выносит наружу решение, которое внутри пака вынесла ОДНА
# модельная семья. Все 40 судейских сессий — один и тот же судья в разных контекстах, и это
# объявлено непереносимым (§11 отчёта). Sol — единственный контур другой семьи.
PAIRS_B = [
# (проход, арм A, арм B, зачем)
("p3", "R0", "R2", "редактор: рекомендованный против ПОГРАНИЧНОГО (на нём висит Резерв D39.22)"),
("p2zh", "E0", "E2", "черновик: финал поверх якоря против финала поверх лучшего соперника"),
]
N_UNITS_B = 3
def _no_openai(models: list[str]) -> None:
"""Семейный запрет симметричен (ЗАКОН промта): Sol не судит клетки OpenAI-моделей."""
import roster as R # noqa: PLC0415
bad = [m for m in models if "openai.com" in R.CANDIDATES.get(m, ("",))[0]]
if bad:
raise SystemExit(f"семейный запрет: Sol не судит клетки OpenAI — {bad}")
def pick_units_b(pass_: str, a: str, b: str) -> list[dict]:
"""Единицы, где ВНУТРИСЕМЕЙНЫЙ судья был увереннее всего.
Для арбитража это правильнее, чем брать спорные: если внешний судья не воспроизводит перевес
там, где свой был максимально уверен, — под вопросом сам перевес, а не его величина.
"""
import judge as JU # noqa: PLC0415
JU.setup(pass_)
import absjudge as AJ2 # noqa: PLC0415
per = []
for uid, d in AJ2._by_unit().items(): # noqa: SLF001
if a in d and b in d:
per.append((abs(d[b]["errors"] - d[a]["errors"]), uid))
per.sort(key=lambda x: (-x[0], x[1]))
byid = {u["uid"]: u for u in _units_b()}
return [byid[uid] for _g, uid in per[:N_UNITS_B] if uid in byid]
def _units_b() -> list[dict]:
import material as M # noqa: PLC0415
return M.units_zh()
def _text_b(pass_: str, arm: str, uid: str) -> str:
import judge as JU # noqa: PLC0415
return JU.text_of(pass_)(arm, {"uid": uid})
def emit_b() -> None:
"""Выпуск пакета (б). $0: ничего не покупается, только сборка уже купленных клеток."""
import judge as JU # noqa: PLC0415
out = MONEY.OUT / "sol-b"
out.mkdir(parents=True, exist_ok=True)
(out / "answers").mkdir(parents=True, exist_ok=True)
key: dict[str, dict] = {}
n = 0
for pass_, a, b, why in PAIRS_B:
_no_openai([JU.arm_map(pass_).get(a, ""), JU.arm_map(pass_).get(b, "")])
for u in pick_units_b(pass_, a, b):
ta, tb = _text_b(pass_, a, u["uid"]), _text_b(pass_, b, u["uid"])
if not ta.strip() or not tb.strip():
continue
for order in (0, 1):
tok = hashlib.sha1( # noqa: S324
f"{_salt()}|b|{pass_}|{a}|{b}|{u['uid']}|{order}".encode()).hexdigest()[:10]
v1, v2 = (ta, tb) if order == 0 else (tb, ta)
(out / f"{tok}.txt").write_text(_packet(u["source"], v1, v2, tok), encoding="utf-8")
key[tok] = dict(contrast=f"{a}v{b}", pass_=pass_, why=why,
first=a if order == 0 else b, second=b if order == 0 else a,
uid=u["uid"], order=order, kind="боевой")
n += 1
# тот же обязательный декой: сессия, не поймавшая посаженную деградацию, аннулируется
u0 = _units_b()[0]
clean = _text_b("p3", "R0", u0["uid"])
planted, k = AJ._plant(clean) # noqa: SLF001
if k >= 2:
for order in (0, 1):
tok = hashlib.sha1(f"{_salt()}|b-decoy|{u0['uid']}|{order}".encode()).hexdigest()[:10] # noqa: S324
v1, v2 = (clean, planted) if order == 0 else (planted, clean)
(out / 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-b-KEY.json").write_text(json.dumps(key, ensure_ascii=False, indent=1),
encoding="utf-8")
(out / "ИНСТРУКЦИЯ.md").write_text(_instr_b(len(key)), encoding="utf-8")
print(f"пакет (б): {n} файлов → {out} · ключ → {KEYS}/sol-b-KEY.json (владельцу НЕ давать)")
def _instr_b(n: int) -> str:
return f"""# Пакеты для Sol — ПАКЕТ (б), финалисты эксп-22
Всего пакетов: **{n}**. Правила те же, что в пакете (а): один пакет — один НОВЫЙ чат, ответ
копируется дословно в `answers/<тот же токен>.txt`, порядок вариантов внутри пакета не менять,
какая модель какой вариант писала — не сообщать.
## Чем этот пакет отличается от (а)
Пакет (а) разрешал спор двух платных судей эксп-21. Здесь спора нет — здесь есть **решение,
которое целиком вынесла ОДНА модельная семья**: все 40 судейских сессий эксп-22 — один и тот же
судья в разных контекстах. Он мог систематически предпочитать один стиль русской прозы другому,
и изнутри пака это неотличимо от истины. Sol — единственный судейский контур другой семьи.
Поэтому важны именно эти пары:
* **редактор рекомендованный против пограничного** — на этом контрасте держится вопрос о
Резерве D39.22, и внутри пака он прошёл на грани (перевес 2.12 при пороге прохода 2.47);
* **финал поверх якорного черновика против финала поверх лучшего соперника** — крупнейший
перевес фазы 2.
Единицы отобраны те, где внутренний судья был увереннее всего: если внешний судья не
воспроизводит перевес там, где свой был максимально уверен, под вопросом сам перевес.
## Что будет с ответами
Возврат не блокер: не вернётся — так и будет записано. Вернётся — числа лягут рядом с
внутренними, и расхождение станет находкой, а не поводом переписать вывод.
"""
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, "--emit-b": emit_b, "--ingest": ingest, "--score": score}.get(a[0])
fn() if fn else print(__doc__)