diff --git a/eval/tenant_panel/editors.py b/eval/tenant_panel/editors.py new file mode 100644 index 00000000..065e4732 --- /dev/null +++ b/eval/tenant_panel/editors.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Ф3 — ПАНЕЛЬ РЕДАКТОРОВ поверх ОДНОЙ якорной базы. + +База — ЗАМОРОЖЕННЫЙ боевой черновик `deepseek-v4-flash` из Ф2 (тот самый, что прошёл гейт эха). +Все редакторы правят побайтно один текст, поэтому контраст между ними чист от разницы черновиков — +ровно того конфаунда, который эксп-21 назвал носителем 70% эффекта. + +Конфигурация редактора одинакова у всех: full-regen боевым `editor.md` + ЗАКОН-БЛОК (D39.104). +Единственное различие клеток — модель. Это проверяется `--isolation` до покупок. + +`gemini-3.1-pro-preview` — ОБЯЗАТЕЛЬНЫЙ кандидат промта: он идёт в Ф3 независимо от скрина +(гипотеза эксп-04 «gemini-pro — аутлаер прозы»), скрин-числа при нём печатаются. + +Вторая база (лучший черновик Ф2) — ОПЦИЯ промта: берётся только если Ф2 дал отрыв выше пола И +смета влезает; решение и его основание печатаются `--plan`. + +Запуск: --plan состав панели, смета, изоляция, $0 + --isolation чем клетки различаются — сверка сообщений, $0 + --run покупка редактур + --score батарея, канон, цена, $0 +""" +from __future__ import annotations + +import json +import os +import statistics as st +import sys +import time +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")) + +from dotenv import load_dotenv # noqa: E402 +from openai import OpenAI # noqa: E402 + +import bank as BANK # noqa: E402 +import battery as B # noqa: E402 +import material as M # noqa: E402 +import money as MONEY # noqa: E402 +import panel as P2 # noqa: E402 +import prompts as PR # noqa: E402 +from roster import CANDIDATES, call_kwargs # noqa: E402 + +load_dotenv(REPO / "eval" / ".env") +OUT = MONEY.OUT + +BATTLE = "deepseek-v4-pro" # R0 — боевой редактор, база сравнения +MANDATORY = "gemini-3.1-pro-preview" # обязательный кандидат промта +MAX_EDITORS = 5 + + +def client(model: str) -> OpenAI: + base, env, *_ = CANDIDATES[model] + return OpenAI(api_key=os.environ[env], base_url=base, timeout=900) + + +def panel() -> list[str]: + """R0 = боевой редактор · затем обязательный кандидат · затем прошедшие скрин по цене. + + Порядок фиксирован, потому что имена армов (R0, R1, …) впечатываются в ключ судейского + прохода: сдвиг порядка после выпуска заданий сделал бы семейство неповторимым. + """ + res = P2.screen_result() + out = [BATTLE, MANDATORY] + ok = [m for m, r in res.items() + if r["verdict"] == "прошёл" and m not in out and r.get("price_editor") is not None] + ok.sort(key=lambda m: res[m]["price_editor"]) + return (out + ok)[:MAX_EDITORS] + + +def base_text(uid: str) -> str: + """Якорная база: замороженный боевой черновик Ф2.""" + return P2.draft_text(P2.ANCHOR, uid) + + +def tag(model: str, uid: str) -> str: + return f"tp3-ed-{model}-{uid}" + + +def text_of(arm: str, uid: str) -> str: + if arm == "F": + return base_text(uid) + model = panel()[int(arm[1:])] + f = OUT / f"{tag(model, uid)}.json" + return json.loads(f.read_text(encoding="utf-8")).get("content", "") if f.exists() else "" + + +def msgs_for(model: str, u: dict) -> list[dict]: + return PR.editor_msgs(u["source"], base_text(u["uid"]), + PR.law_block(PR.terms_in(u["source"]))) + + +def cmd_isolation() -> None: + """Клетки обязаны различаться ТОЛЬКО моделью. Проверяется сборкой сообщений, а не верой.""" + u = M.units_zh()[0] + ms = {m: msgs_for(m, u) for m in panel()} + ref = ms[BATTLE] + bad = 0 + for m, v in ms.items(): + same = [a["content"] == b["content"] for a, b in zip(ref, v, strict=True)] + ok = all(same) and len(ref) == len(v) + bad += not ok + print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {m:26s} сообщения совпадают с базой " + f"({len(v)} блоков)") + print(f" закон-блок в сообщениях: {'да' if any('КАНОНИЧЕСКИЕ ПЕРЕВОДЫ' in x['content'] for x in ref) else 'НЕТ'}") + print(f" база — замороженный черновик {P2.ANCHOR}, {len(base_text(u['uid']))} знаков") + print(f"\n{'ИЗОЛЯЦИЯ ЧИСТА' if not bad else f'ПРОВАЛОВ: {bad}'}") + + +def cmd_plan() -> None: + pan, res, us = panel(), P2.screen_result(), M.units_zh() + print(f"ПАНЕЛЬ Ф3: {len(pan)} редакторов × {len(us)} единиц поверх ОДНОЙ якорной базы") + est = 0.0 + for i, m in enumerate(pan): + r = res.get(m, {}) + price = r.get("price_editor") + free = " (куплено в Ф2, переиспользуется)" if m == BATTLE else "" + if m != BATTLE: + est += (price or 0.02) * len(us) + print(f" R{i} {m:26s} скрин={r.get('verdict', 'не скринился'):12s} " + f"$/ед={price if price is not None else float('nan'):.5f}{free}") + print(f"\nсмета ${est:.4f} при потолке ${MONEY.CEILINGS['Ф3']:.2f}") + if est > MONEY.CEILINGS["Ф3"]: + print("⛔ СМЕТА НЕ ВЛЕЗАЕТ — сократить панель или пинг владельцу ДО покупок") + + +def cmd_run() -> None: + led = MONEY.Guarded("Ф3", default_expect=0.03) + for m in panel(): + if m == BATTLE: + print(f" {m}: клетка куплена в Ф2 (tp2-edit) — не перекупается") + continue + for u in M.units_zh(): + if not base_text(u["uid"]): + print(f" ⚠ {u['uid'][:6]}: якорной базы нет — клетка пропущена") + continue + rec = MONEY.purchase(led, tag(m, u["uid"]), m, client(m), + call_kwargs(m, msgs_for(m, u)), + role="editor3", uid=u["uid"], editor=m) + if rec.get("skipped"): + print("⛔ потолок Ф3 — стоп") + return + print(f" {m:26s} {u['uid'][:6]} finish={str(rec.get('finish')):9s} " + f"{len(rec.get('content', '')):5d} зн ${rec.get('cost_usd', 0):.5f}") + time.sleep(0.15) + print(f"потрачено Ф3 ${led.spent():.6f}") + + +def cmd_score() -> None: + pan, us, cards = panel(), M.units_zh(), B.load_cards() + print(f"{'арм':30s}{'ед.':>5s}{'ханьцзы':>9s}{'типогр':>8s}{'канон':>14s}" + f"{'покрытие':>10s}{'$/ед':>10s}") + for i, m in enumerate(pan + ["БАЗА"]): + rows, k, s, costs = [], 0, 0, [] + for u in us: + t = base_text(u["uid"]) if m == "БАЗА" else text_of(f"R{i}", u["uid"]) + if not t.strip(): + continue + rows.append(B.run_unit(B.Unit(source=u["source"], draft=t, final=t, cards=cards))) + kk, ss = BANK.coverage(u["source"], t) + k += kk + s += ss + if m == "БАЗА": + costs.append(P2.draft_cost(P2.ANCHOR, u["uid"])) + elif m == BATTLE: + f = OUT / f"{P2.tag_edit(P2.ANCHOR, u['uid'])}.json" + if f.exists(): + costs.append(json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0)) + else: + f = OUT / f"{tag(m, u['uid'])}.json" + if f.exists(): + costs.append(json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0)) + if not rows: + continue + label = "БАЗА (черновик)" if m == "БАЗА" else f"R{i} {m}" + print(f"{label:30s}{len(rows):5d}" + f"{sum(r['cjk_leak']['han_chars'] for r in rows):9d}" + f"{sum(r['typography']['violations'] for r in rows):8d}" + f"{f'{k}/{s}':>14s}{(k / s if s else 0):>10.3f}" + f"{(st.median(costs) if costs else 0):>10.5f}") + led = MONEY.Guarded("Ф3") + print(f"\nпотрачено Ф3 ${led.spent():.6f} из ${led.ceiling:.2f}") + + +if __name__ == "__main__": + a = sys.argv[1:] or ["--plan"] + fn = {"--plan": cmd_plan, "--isolation": cmd_isolation, "--run": cmd_run, + "--score": cmd_score}.get(a[0]) + fn() if fn else print(__doc__) diff --git a/eval/tenant_panel/judge.py b/eval/tenant_panel/judge.py new file mode 100644 index 00000000..98be913e --- /dev/null +++ b/eval/tenant_panel/judge.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""АГЕНТ-СУДЕЙСТВО ЭКСП-22 — тот же абсолютный риг, другая панель. $0 по ключам провайдеров. + +Риг НЕ пере-изобретается: методика (абсолютная шкала · слепые метки Т1..Тn · перемешивание хешем +соли · декой · пара пола · обоснование ЦИТАТОЙ на каждую ненулевую ось · семейство, впечатанное +в ключ ДО первого ответа · Холм) живёт в `role_topology/absjudge.py` и подключается через +`configure()`. Здесь только панель эксп-22: свои единицы, свои армы, свои семейства и свой +каталог сырья. + +ЧЕТЫРЕ ПРОХОДА, каждый со своим семейством, объявленным ДО выпуска заданий: + p2zh — панель черновиков на zh: доезжает ли качество черновика до финала после боевого редактора; + p2en — та же панель на оси переноса en; + p3 — панель редакторов поверх ОДНОЙ якорной базы; + dva — довесок на купленном сырье эксп-21: прямой контраст `D_` против `A` агент-голосами. + +ЗНАК: перевес > 0 означает «ПЕРВЫЙ арм лучше» (меньше ошибок). + +КАП АГЕНТ-ЗАПУСКОВ ≤60 на пак. Счётчик ведётся файлом `agent-runs.json`: одна судейская сессия = +одна запись. Превышение — стоп и пинг, а не тихое продолжение. + +Запуск: --plan PASS что будет выпущено, $0 + --emit PASS выпустить задания судьям, $0 + --ingest PASS разобрать ответы, $0 + --controls PASS контроли (читать ДО боевых чисел), $0 + --score PASS контрасты с поправкой Холма, $0 + --axes PASS разложение по осям (разведочно), $0 + --runs сколько агент-запусков израсходовано +""" +from __future__ import annotations + +import json +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 material as M # noqa: E402 +import money as MONEY # noqa: E402 +import panel as P2 # noqa: E402 + +OUT = MONEY.OUT +KEYS = OUT / "blind-keys" +RUNS = OUT / "agent-runs.json" +CAP = 60 + + +def arm_map(pass_: str) -> dict[str, str]: + """Арм → модель. Имена армов НЕЙТРАЛЬНЫ (D0/E0/R0): в задание судье имя арма не попадает + вовсе, но и в ключе оно не должно подсказывать «дорогая модель», если ключ кто-то откроет.""" + if pass_ in ("p2zh", "p2en"): + ts = P2.tenants() + return {**{f"D{i}": m for i, m in enumerate(ts)}, + **{f"E{i}": m for i, m in enumerate(ts)}} + if pass_ == "p3": + import editors as ED # noqa: PLC0415 + return {f"R{i}": m for i, m in enumerate(ED.panel())} | {"F": P2.ANCHOR} + return {"A": "эксп-21 arm A", "D_": "эксп-21 arm D_"} + + +def family(pass_: str) -> list[tuple[str, str, str]]: + """Семейство контрастов прохода. Объявляется ДО выпуска и впечатывается в ключ.""" + if pass_ in ("p2zh", "p2en"): + ts = P2.tenants() + fam = [(f"E{i}", "E0", f"финал поверх черновика жильца {i} против финала поверх якоря") + for i in range(1, len(ts))] + fam.append(("E0", "D0", "КОНТРОЛЬ: покупает ли боевой редактор поверх якорного черновика")) + return fam + if pass_ == "p3": + import editors as ED # noqa: PLC0415 + pan = ED.panel() + fam = [(f"R{i}", "R0", f"редактор {i} против боевого над ТОЙ ЖЕ базой") + for i in range(1, len(pan))] + fam.append(("R0", "F", "КОНТРОЛЬ: покупает ли боевой редактор поверх якорной базы")) + return fam + return [("D_", "A", "однопроходка без мандата против связки (сырьё эксп-21)")] + + +def arms(pass_: str) -> tuple[str, ...]: + return tuple(arm_map(pass_)) + + +def units(pass_: str) -> list[dict]: + if pass_ == "p2en": + return M.units_en() + if pass_ == "dva": + import bakeoff as BO # noqa: PLC0415 + return BO.units() + return M.units_zh() + + +def text_of(pass_: str): + amap = arm_map(pass_) + + def hook(arm: str, u: dict) -> str: + if pass_ == "dva": + import bakeoff as BO # noqa: PLC0415 + return BO.text3_of(arm, u) + if pass_ == "p3": + import editors as ED # noqa: PLC0415 + return ED.text_of(arm, u["uid"]) + model = amap[arm] + return (P2.draft_text(model, u["uid"]) if arm.startswith("D") + else P2.edit_text(model, u["uid"])) + return hook + + +def floor_of(pass_: str): + """Пара повторов ОДНОГО арма на одном входе — истинная разница ноль.""" + def hook(u: dict): + if pass_ not in ("p2zh", "p3"): + return None + a = P2.edit_text(P2.ANCHOR, u["uid"]) + b = P2.edit_text(P2.ANCHOR, u["uid"], floor=True) + return (a, b) if a.strip() and b.strip() else None + return hook + + +def decoy_base(slots: dict) -> str: + """В декой сажается порча ЧИСТОГО текста. Берётся финал якоря — он есть во всех проходах.""" + for k in ("E0", "R0", "A", "D0"): + if slots.get(k, "").strip(): + return slots[k] + return next(t for t in slots.values() if t.strip()) + + +def setup(pass_: str) -> None: + AJ.configure(OUT=OUT, KEYS=KEYS, PASS=pass_, ARMS=arms(pass_), ARMS_2B=(), + FAMILY=family(pass_), TEXT_HOOK=text_of(pass_), + UNITS_HOOK=lambda: units(pass_), FLOOR_HOOK=floor_of(pass_), + DECOY_HOOK=decoy_base, + SRC_LANG="английском" if pass_ == "p2en" else "китайском") + KEYS.mkdir(parents=True, exist_ok=True) + + +def runs_used() -> list[dict]: + return json.loads(RUNS.read_text(encoding="utf-8")) if RUNS.exists() else [] + + +def log_run(pass_: str, tokens: list[str], note: str = "") -> int: + rs = runs_used() + rs.append(dict(n=len(rs) + 1, pass_=pass_, tokens=tokens, note=note)) + RUNS.write_text(json.dumps(rs, ensure_ascii=False, indent=1), encoding="utf-8") + if len(rs) > CAP: + raise SystemExit(f"⛔ КАП АГЕНТ-ЗАПУСКОВ ПРОБИТ: {len(rs)} > {CAP} — стоп и пинг") + return len(rs) + + +def cmd_plan(pass_: str) -> None: + setup(pass_) + us = units(pass_) + amap = arm_map(pass_) + print(f"проход {pass_} · единиц {len(us)} · армов {len(amap)}") + for a, m in amap.items(): + print(f" {a:4s} → {m}") + print("\nсемейство (впечатывается в ключ ДО ответов):") + for a, b, w in family(pass_): + print(f" {a:4s} vs {b:4s} {w}") + have = sum(1 for u in us for a in amap if (text_of(pass_)(a, u) or "").strip()) + print(f"\nтекстов готово {have} из {len(us) * len(amap)}") + print(f"агент-запусков израсходовано {len(runs_used())} из {CAP}") + + +def main() -> None: + a = sys.argv[1:] + if not a: + print(__doc__) + return + if a[0] == "--runs": + rs = runs_used() + print(f"агент-запусков {len(rs)} из {CAP}") + for r in rs: + print(f" {r['n']:3d} {r['pass_']:6s} токенов {len(r['tokens'])} {r.get('note', '')}") + return + if len(a) < 2: + print("нужен PASS: p2zh | p2en | p3 | dva") + return + cmd, pass_ = a[0], a[1] + setup(pass_) + fn = {"--plan": lambda: cmd_plan(pass_), "--emit": AJ.emit, "--ingest": AJ.ingest, + "--controls": AJ.controls, "--score": AJ.score, "--axes": AJ.axes, + "--position": AJ.position, "--agree": AJ.judges_agree}.get(cmd) + if fn: + fn() + else: + print(__doc__) + + +if __name__ == "__main__": + main() diff --git a/eval/tenant_panel/money.py b/eval/tenant_panel/money.py index a512596e..f28f609f 100644 --- a/eval/tenant_panel/money.py +++ b/eval/tenant_panel/money.py @@ -28,9 +28,17 @@ sys.path.insert(0, str(REPO / "eval" / "role_topology")) import buy as BUY # noqa: E402 +import roster as ROSTER # noqa: E402 + OUT = Path.home() / "books" / "tenant-panel" OUT.mkdir(parents=True, exist_ok=True) BUY.OUT = OUT # сырьё пака — своё, не эксп-21 +# ⚠ ПРАЙС-ТАБЛИЦА ТОЖЕ СВОЯ. Поймано исполнением на середине скрина: `buy.priced` звал +# `role_topology/prices.cost`, где нет ни одной новой модели ростера — прогон упал на `kimi-k2.6` +# c KeyError. Хуже тихого падения было бы его отсутствие: у пересекающихся моделей таблица +# эксп-21 ставит кэш-цену Gemini равной входной, то есть цена считалась бы по устаревшему прайсу +# и расходилась с объявленным в пре-реге. Подмена делается ЗДЕСЬ, один раз и явно. +BUY.cost = ROSTER.cost # Теги покупок по фазам. ⚠ Заводишь новый тег — вписываешь СЮДА тем же коммитом: ровно этого # не сделали трижды в эксп-21, и расход фазы печатался заниженным. @@ -170,6 +178,15 @@ def selftest() -> int: ok, why = g.afford() bad += ok print(f"[{'OK ' if not ok else 'ПРОВАЛ'}] проекционный гард отказывает при нулевом потолке") + # Прайс: КАЖДАЯ модель ростера обязана считаться кассой, иначе покупка падает на середине. + for m in ROSTER.CANDIDATES: + try: + BUY.cost(m, 1000, 0, 1000, 0, 2000) + except Exception as e: # noqa: BLE001 + bad += 1 + print(f"[ПРОВАЛ] {m}: касса не умеет считать цену ({type(e).__name__})") + print(f"[OK ] цена считается для всех {len(ROSTER.CANDIDATES)} моделей ростера" + if not bad else "") # Фриз-гейт: НЕ-файл (встроенный питон) обязан быть отвергнут. try: exec(compile("import money; money._refuse_unfrozen()", "", "exec"), # noqa: S102 diff --git a/eval/tenant_panel/panel.py b/eval/tenant_panel/panel.py new file mode 100644 index 00000000..a72cd346 --- /dev/null +++ b/eval/tenant_panel/panel.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Ф2 — ПАНЕЛЬ ЧЕРНОВИКОВ. Главный рычаг пака. + +Почему именно эта роль. Эксп-21 §16.2: тот же редактор над ДРУГИМ черновиком той же длины даёт +7.66 ошибки против 5.91 — это ~70% заголовочного эффекта топологии. Черновиком за всю историю +проекта была одна модель. Самая чувствительная роль ни разу не перебиралась. + +Что меряется на КАЖДОМ жильце (заказ промта, стр.55): + (а) черновик как ТЕКСТ — абсолютный агент-суд (`judge.py`, проход `p2zh`/`p2en`); + (б) боевой редактор `A_law`-конфига ПОВЕРХ каждого черновика — доезжает ли качество до финала; + (в) частота эхо-мины — доля единиц, потребовавших ре-гена; + (г) батарея + покрытие канона (детерминированно, $0); + (д) цена/единица и проекция на книгу. + +ШУМОВОЙ ПОЛ. Один арм гоняется ДВАЖДЫ end-to-end на тех же единицах — выбран ЯКОРНЫЙ +(`deepseek-v4-flash` → `deepseek-v4-pro`), потому что именно с ним сравниваются все перевесы, и +пол обязан быть измерен на боевой паре, а не на удобной. Пол применяется к Ф2 И Ф3. + +Черновики ФРИЗЯТСЯ: Ф3 правит замороженный якорный черновик, клетка «dspro над flash» покупается +здесь ОДИН раз и в Ф3 переиспользуется. + +Запуск: --plan состав панели и смета, $0 + --drafts черновики всех жильцов (zh + en) + --edit боевой редактор поверх каждого черновика + --floor вторая генерация якорного арма end-to-end + --score батарея, канон, эхо, цена — $0 +""" +from __future__ import annotations + +import json +import os +import statistics as st +import sys +import time +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")) + +from dotenv import load_dotenv # noqa: E402 +from openai import OpenAI # noqa: E402 + +import bakeoff as BO # noqa: E402 гейт годности черновика +import bank as BANK # noqa: E402 +import battery as B # noqa: E402 +import material as M # noqa: E402 +import money as MONEY # noqa: E402 +import prompts as PR # noqa: E402 +from roster import CANDIDATES, call_kwargs # noqa: E402 + +load_dotenv(REPO / "eval" / ".env") +OUT = MONEY.OUT + +ANCHOR = "deepseek-v4-flash" # боевой черновик — якорь панели +EDITOR = "deepseek-v4-pro" # боевой редактор, конфиг `A_law` (full-regen + закон-блок) +MAX_TENANTS = 5 # потолок промта: до 5 жильцов + якорь +RETRIES = 3 + + +def client(model: str) -> OpenAI: + base, env, *_ = CANDIDATES[model] + return OpenAI(api_key=os.environ[env], base_url=base, timeout=900) + + +def screen_result() -> dict: + f = OUT / "screen.json" + return json.loads(f.read_text(encoding="utf-8")) if f.exists() else {} + + +def tenants() -> list[str]: + """Состав панели по ОБЪЯВЛЕННОМУ правилу (пре-рег §0.6). + + Берутся прошедшие скрин кандидаты черновой роли. Если смета не влезает — панель режется так, + чтобы СОХРАНИТЬ ЦЕНОВУЮ ОСЬ: обязательны якорь, самый дешёвый прошедший и самый дорогой + прошедший; середина добирается по возрастанию цены. Причина: вопрос пака — «покупает ли + дорогой черновик качество», и панель, срезанная по цене сверху, на него не отвечает. + """ + res = screen_result() + ok = [m for m, r in res.items() + if r["verdict"] == "прошёл" and r.get("price_translator") is not None] + ok.sort(key=lambda m: res[m]["price_translator"]) + if ANCHOR in ok: + ok.remove(ANCHOR) + if len(ok) <= MAX_TENANTS - 1: + return [ANCHOR, *ok] + keep = [ok[0], ok[-1]] + for m in ok[1:-1]: + if len(keep) >= MAX_TENANTS - 1: + break + keep.append(m) + return [ANCHOR, *sorted(keep, key=lambda m: res[m]["price_translator"])] + + +def units_all() -> list[tuple[dict, bool]]: + """(единица, en?) — обе оси обязательны.""" + return [(u, False) for u in M.units_zh()] + [(u, True) for u in M.units_en()] + + +def tag_draft(model: str, uid: str, attempt: int = 1, floor: bool = False) -> str: + base = "tp2-floor" if floor else "tp2-draft" + return f"{base}-D-{model}-{uid}" + ("" if attempt == 1 else f"-r{attempt}") + + +def tag_edit(model: str, uid: str, floor: bool = False) -> str: + return f"{'tp2-floor' if floor else 'tp2-edit'}-E-{model}-{uid}" + + +def draft_text(model: str, uid: str, floor: bool = False) -> str: + """ГОДНЫЙ черновик: последняя попытка, прошедшая гейт. Пусто — годного нет.""" + pre = tag_draft(model, uid, floor=floor) + best = "" + for f in sorted(OUT.glob(f"{pre}*.json")): + c = json.loads(f.read_text(encoding="utf-8")).get("content", "") + if c and not BO.draft_reject_reason(c): + best = c + return best + + +def edit_text(model: str, uid: str, floor: bool = False) -> str: + f = OUT / f"{tag_edit(model, uid, floor)}.json" + return json.loads(f.read_text(encoding="utf-8")).get("content", "") if f.exists() else "" + + +def buy_draft(led, model: str, u: dict, en: bool, floor: bool = False) -> dict: + blk = None if en else PR.glossary_block(PR.terms_in(u["source"])) + msgs = PR.translator_msgs(u["source"], blk, en=en) + rec = {} + for attempt in range(1, RETRIES + 1): + rec = MONEY.purchase(led, tag_draft(model, u["uid"], attempt, floor), model, client(model), + call_kwargs(model, msgs), role="draft", uid=u["uid"], en=en, + tenant=model, attempt=attempt, floor=floor) + if rec.get("skipped") or not BO.draft_reject_reason(rec.get("content", "")): + return rec + print(f" ⚠ {BO.draft_reject_reason(rec['content'])} — ре-ген {attempt + 1}") + return rec + + +def buy_edit(led, model: str, u: dict, en: bool, floor: bool = False) -> dict: + draft = draft_text(model, u["uid"], floor) + if not draft: + return dict(skipped=False, content="", no_draft=True) + blk = None if en else PR.law_block(PR.terms_in(u["source"])) + msgs = PR.editor_msgs(u["source"], draft, blk, en=en) + return MONEY.purchase(led, tag_edit(model, u["uid"], floor), EDITOR, client(EDITOR), + call_kwargs(EDITOR, msgs), role="edit", uid=u["uid"], en=en, + tenant=model, floor=floor) + + +def cmd_plan() -> None: + ts = tenants() + res = screen_result() + us = units_all() + print(f"ПАНЕЛЬ Ф2: {len(ts)} жильцов черновой роли · единиц {len(us)} " + f"({sum(1 for _u, e in us if not e)} zh + {sum(1 for _u, e in us if e)} en)") + for m in ts: + r = res.get(m, {}) + print(f" {m:26s} скрин={r.get('verdict', '—'):10s} $/ед transl=" + f"{r.get('price_translator') or 0:.5f} edit={r.get('price_editor') or 0:.5f}") + print("\nне вошли: " + ", ".join(f"{m}({r['verdict']})" for m, r in res.items() + if m not in ts) or " —") + n_dr = len(ts) * len(us) + n_ed = len(ts) * len(us) + n_fl = 2 * len(M.units_zh()) + est = sum((res.get(m, {}).get("price_translator") or 0.005) * len(us) for m in ts) \ + + (res.get(EDITOR, {}).get("price_editor") or 0.005) * len(ts) * len(us) \ + + ((res.get(ANCHOR, {}).get("price_translator") or 0.001) + + (res.get(EDITOR, {}).get("price_editor") or 0.005)) * len(M.units_zh()) + print(f"\nвызовов: черновики {n_dr} + редактуры {n_ed} + пол {n_fl} = {n_dr + n_ed + n_fl}") + print(f"смета по ФАКТИЧЕСКИМ ценам скрина: ${est:.4f} при потолке ${MONEY.CEILINGS['Ф2']:.2f}") + if est > MONEY.CEILINGS["Ф2"]: + print("⛔ СМЕТА НЕ ВЛЕЗАЕТ — резать панель по объявленному правилу или пинг владельцу") + + +def cmd_drafts() -> None: + led = MONEY.Guarded("Ф2", default_expect=0.01) + for m in tenants(): + for u, en in units_all(): + rec = buy_draft(led, m, u, en) + if rec.get("skipped"): + print("⛔ потолок Ф2 — стоп") + return + print(f" {m:24s} {'en' if en else 'zh'} {u['uid'][:6]} " + f"finish={str(rec.get('finish')):8s} {len(rec.get('content', '')):5d} зн " + f"${rec.get('cost_usd', 0):.5f}") + time.sleep(0.15) + print(f"потрачено Ф2 ${led.spent():.6f}") + + +def cmd_edit() -> None: + led = MONEY.Guarded("Ф2", default_expect=0.01) + for m in tenants(): + for u, en in units_all(): + rec = buy_edit(led, m, u, en) + if rec.get("no_draft"): + print(f" {m:24s} {u['uid'][:6]} — годного черновика нет, редактура пропущена") + continue + if rec.get("skipped"): + print("⛔ потолок Ф2 — стоп") + return + print(f" E[{m:22s}] {'en' if en else 'zh'} {u['uid'][:6]} " + f"finish={str(rec.get('finish')):8s} {len(rec.get('content', '')):5d} зн " + f"${rec.get('cost_usd', 0):.5f}") + time.sleep(0.15) + print(f"потрачено Ф2 ${led.spent():.6f}") + + +def cmd_floor() -> None: + """Вторая генерация ЯКОРНОГО арма end-to-end на тех же zh-единицах.""" + led = MONEY.Guarded("Ф2", default_expect=0.01) + for u in M.units_zh(): + d = buy_draft(led, ANCHOR, u, False, floor=True) + if d.get("skipped"): + print("⛔ потолок Ф2 — стоп") + return + e = buy_edit(led, ANCHOR, u, False, floor=True) + print(f" пол {u['uid'][:6]} черновик {len(d.get('content', '')):5d} зн · " + f"редактура {len(e.get('content', '')):5d} зн") + time.sleep(0.15) + print(f"потрачено Ф2 ${led.spent():.6f}") + + +def draft_cost(model: str, uid: str, floor: bool = False) -> float: + """Цена черновика = ВСЕ попытки: ре-ген оплачен, значит он в цене роли.""" + pre = tag_draft(model, uid, floor=floor) + return sum(json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0) + for f in OUT.glob(f"{pre}*.json")) + + +def regen_rate(model: str, en: bool = False) -> tuple[int, int]: + us = [u for u, e in units_all() if e == en] + need = 0 + for u in us: + pre = tag_draft(model, u["uid"]) + if len(list(OUT.glob(f"{pre}-r*.json"))): + need += 1 + return need, len(us) + + +def cmd_score() -> None: + ts = tenants() + zh = M.units_zh() + cards = B.load_cards() + print("═══ Ф2 (в) ЭХО-МИНА: доля единиц, потребовавших ре-гена ═══") + for m in ts: + nz, tz = regen_rate(m, en=False) + ne, te = regen_rate(m, en=True) + print(f" {m:26s} zh {nz}/{tz} = {nz / max(1, tz):.0%} · en {ne}/{te} = {ne / max(1, te):.0%}") + print("\n═══ Ф2 (г) БАТАРЕЯ И ПОКРЫТИЕ КАНОНА (zh, детерминированно) ═══") + print(f"{'арм':30s}{'ед.':>5s}{'ханьцзы':>9s}{'типогр':>8s}{'потеря':>8s}" + f"{'канон':>14s}{'покрытие':>10s}") + for m in ts: + for kind in ("D", "E"): + rows, k, s = [], 0, 0 + for u in zh: + t = draft_text(m, u["uid"]) if kind == "D" else edit_text(m, u["uid"]) + if not t.strip(): + continue + rows.append(B.run_unit(B.Unit(source=u["source"], draft=t, final=t, cards=cards))) + kk, ss = BANK.coverage(u["source"], t) + k += kk + s += ss + if not rows: + continue + print(f"{kind + '[' + m + ']':30s}{len(rows):5d}" + f"{sum(r['cjk_leak']['han_chars'] for r in rows):9d}" + f"{sum(r['typography']['violations'] for r in rows):8d}" + f"{sum(r['numbers']['lost_n'] for r in rows):8d}" + f"{f'{k}/{s}':>14s}{(k / s if s else 0):>10.3f}") + print("\n═══ Ф2 (д) ЦЕНА/ЕДИНИЦА И ПРОЕКЦИЯ НА КНИГУ ═══") + print(f"{'жилец':26s}{'черновик':>11s}{'редактор':>11s}{'связка':>11s}{'книга 1500 ед.':>16s}") + for m in ts: + dc = [draft_cost(m, u["uid"]) for u in zh] + ec = [json.loads((OUT / f"{tag_edit(m, u['uid'])}.json").read_text(encoding="utf-8")) + .get("cost_usd", 0.0) for u in zh if (OUT / f"{tag_edit(m, u['uid'])}.json").exists()] + if not dc: + continue + d50, e50 = st.median(dc), (st.median(ec) if ec else 0.0) + print(f"{m:26s}{d50:11.5f}{e50:11.5f}{d50 + e50:11.5f}{(d50 + e50) * 1500:16.2f}") + led = MONEY.Guarded("Ф2") + print(f"\nпотрачено Ф2 ${led.spent():.6f} из ${led.ceiling:.2f} · " + f"пак ${MONEY.Pack().spent():.6f} из ${MONEY.PACK_CEILING:.2f}") + + +if __name__ == "__main__": + a = sys.argv[1:] or ["--plan"] + fn = {"--plan": cmd_plan, "--drafts": cmd_drafts, "--edit": cmd_edit, + "--floor": cmd_floor, "--score": cmd_score}.get(a[0]) + fn() if fn else print(__doc__) diff --git a/eval/tenant_panel/sol.py b/eval/tenant_panel/sol.py new file mode 100644 index 00000000..7d9c85b3 --- /dev/null +++ b/eval/tenant_panel/sol.py @@ -0,0 +1,245 @@ +#!/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__)