#!/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__)