#!/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 жильцов черновика + `deepseek-v4-flash` # (якорь)» ⇒ пять СВЕРХ якоря, панель ≤6 клеток 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: return [ANCHOR, *ok] keep = [ok[0], ok[-1]] for m in ok[1:-1]: if len(keep) >= MAX_TENANTS: break keep.append(m) return [ANCHOR, *sorted(keep, key=lambda m: res[m]["price_translator"])] def units_all(part: str = "") -> list[tuple[dict, bool]]: """(единица, en?) — обе оси обязательны. `part="i/n"` режет СПИСОК единиц на n равных долей для параллельных процессов. Деление детерминированное (по позиции в уже отсортированном списке), поэтому доли не пересекаются и ни одна единица не теряется: это проверяется `--plan`, который печатает сумму долей. """ us = [(u, False) for u in M.units_zh()] + [(u, True) for u in M.units_en()] if not part: return us i, n = (int(x) for x in part.split("/")) return [x for k, x in enumerate(us) if k % n == i] # ⚠ ПОЛ КЛАДЁТСЯ ОТДЕЛЬНОЙ ПАРОЙ, а не «боевая клетка + повтор». Иначе одна половина пола # побайтно совпала бы с армом `E0` в том же судейском пакете: судья увидел бы один текст дважды, # а контроль «близнец» сработал бы на боевом арме. Эксп-21 обошёл это тем, что его пол лежал на # ДРУГОМ входе (§build_unit), — здесь то же достигается двумя независимыми прогонами `p1`/`p2`. def tag_draft(model: str, uid: str, attempt: int = 1, floor: int = 0) -> str: base = f"tp2-floor-p{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: int = 0) -> str: return f"{f'tp2-floor-p{floor}' if floor else 'tp2-edit'}-E-{model}-{uid}" def draft_text(model: str, uid: str, floor: int = 0) -> 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: int = 0) -> 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 edit_ok(model: str, uid: str, floor: int = 0) -> bool: """Клетка редактора ГОДНА: вендор закончил сам, а не упёрся в потолок вывода. ⛔ Заведено блокером Б8 ревью 21.08: `edit_text` отдаёт `content` независимо от `finish`, и оборванная клетка боевой связки уехала армом `A0` в шесть судейских пачек. Потребители текста обязаны спрашивать годность ОТДЕЛЬНО — менять контракт `edit_text` нельзя, на нём висят паки, которым нужен текст как есть. """ f = OUT / f"{tag_edit(model, uid, floor)}.json" if not f.exists(): return False return json.loads(f.read_text(encoding="utf-8")).get("finish") == "stop" def buy_draft(led, model: str, u: dict, en: bool, floor: int = 0) -> 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: int = 0) -> 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 _selected(argv: list[str]) -> list[str]: """Подмножество жильцов для параллельного прогона. Клетки идемпотентны по тегу, поэтому несколько процессов не мешают друг другу; касса читает диск, так что расход виден всем. ⚠ Гонка ограничена сверху: перерасход не больше (процессов−1)×цена одного вызова.""" only = [a.split("=", 1)[1] for a in argv if a.startswith("--only=")] ts = tenants() return [m for m in ts if m in only] if only else ts def _part(argv: list[str]) -> str: p = [a.split("=", 1)[1] for a in argv if a.startswith("--part=")] return p[0] if p else "" def cmd_drafts(argv: list[str] | None = None) -> None: led = MONEY.Guarded("Ф2", default_expect=0.01) for m in _selected(argv or []): for u, en in units_all(_part(argv or [])): 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(argv: list[str] | None = None) -> None: led = MONEY.Guarded("Ф2", default_expect=0.01) for m in _selected(argv or []): for u, en in units_all(_part(argv or [])): 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(argv: list[str] | None = None) -> None: """ШУМОВОЙ ПОЛ: ДВА независимых прогона якорного арма end-to-end на тех же zh-единицах. Истинная разница между `p1` и `p2` — ноль по построению, поэтому измеренный разброс и есть шум пайплайна (генерация черновика + генерация редактуры + судья). Он применяется к Ф2 И Ф3: перевес ниже пола печатается вердиктом «ниже порога различимости». """ led = MONEY.Guarded("Ф2", default_expect=0.01) argv = argv or [] part = _part(argv) passes = [int(a.split("=", 1)[1]) for a in argv if a.startswith("--pass=")] or [1, 2] zh = M.units_zh() if part: i, n = (int(x) for x in part.split("/")) zh = [u for k, u in enumerate(zh) if k % n == i] for p in passes: for u in zh: d = buy_draft(led, ANCHOR, u, False, floor=p) if d.get("skipped"): print("⛔ потолок Ф2 — стоп") return e = buy_edit(led, ANCHOR, u, False, floor=p) if e.get("skipped"): print("⛔ потолок Ф2 — стоп") return print(f" пол p{p} {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: int = 0) -> 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": lambda: cmd_drafts(a), "--edit": lambda: cmd_edit(a), "--floor": lambda: cmd_floor(a), "--score": cmd_score}.get(a[0]) fn() if fn else print(__doc__)