#!/usr/bin/env python3 """Ф1 — СКРИН МОДЕЛЕЙ. Оси = гейты цены и дисциплины, НЕ прокси качества. Что это и чего это НЕ делает. Скрин отсеивает кандидатов, которые в роли неработоспособны: рвут формат, эхают исходник, отказываются, недетерминированы или дороги сверх смысла. Он НЕ ранжирует по качеству прозы — урок эксп-20 прямой: «число абзацев было ПРОКСИ, а не качеством», и слепой суд опроверг механический показатель. Поэтому спорных кандидатов стиль-ролей скрин не решает: они доводятся до Ф2 обоими. Пороги записаны ЗДЕСЬ, в коде, ДО первого замера — иначе «порог» превращается в подгонку под полученные числа. Скрин ОБЯЗАН убивать: на Ф2 проходит не больше трёх жильцов на роль. Оси и их пороги: Г1 ФОРМАТ на БОЕВОЙ ЕДИНИЦЕ (не коротком окне) — сервис-преамбула, markdown-заголовки, обрыв. Провал: ≥1 нарушение более чем в одной единице из двух. Основание: это ровно те классы, которые боевой санитайзер считает вердикт-гейтом (D30.3), и модель, которая их производит, требует постпроцессинга, которого в роли нет. Г2 ЭХО/УТЕЧКА — доля исходной письменности в выходе. Провал: любая единица выше боевого порога 0.15 (`disposition.go:224`) ЛИБО ханьцзы в обеих единицах. Второе строже боевого сознательно: эксп-20 §5.4 нашёл живую микро-утечку, которую порог 0.15 не видит. Г3 ОТКАЗ на чувствительном фрагменте. Провал: отказ. Вебновелла гарантированно упрётся в насилие, и тихий отказ на главе N ломает конвейер. Г4 ДЕТЕРМИНИЗМ при T=0 — два побайтно одинаковых вызова. Не провал сам по себе (сэмплинг у reasoning-моделей стохастичен), но печатается: недетерминированный жилец удорожает воспроизводимость прогона, а она в целях продукта (§6 канона). Г5 ЦЕНА в боевой конфигурации — p50/p95 за единицу, из `usage`, а не из прайса. Расхождение с прайсом само по себе находка. Г6 ПРОФИЛЬ РАЗМЫШЛЕНИЯ — доля в completion и управляемость. Провал: размышление не ограничивается и выход обрывается (`finish=length` при пустом content) — класс, который уже стоил проекту волны (quirks §10). Запуск: eval/.venv/bin/python eval/role_topology/screen.py --dry # план и смета, $0 eval/.venv/bin/python eval/role_topology/screen.py """ from __future__ import annotations import json import os import re import statistics import sys import time from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") sys.path.insert(0, str(REPO / "eval" / "editor_contract")) sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) sys.path.insert(0, str(REPO / "eval" / "editor_harness")) sys.path.insert(0, str(REPO / "eval" / "role_topology")) from dotenv import load_dotenv # noqa: E402 from openai import OpenAI # noqa: E402 import editor_wire_probe as P # noqa: E402 import probe as Q # noqa: E402 import inject_probe as IP # noqa: E402 from prices import CANDIDATES, billed_output, cost, out_price_order # noqa: E402 load_dotenv(REPO / "eval" / ".env") OUT = Path.home() / "books" / "role-topology" OUT.mkdir(parents=True, exist_ok=True) # ⚠ ПОТОЛОК ПОДНЯТ 06.08 СЛОВОМ ВЛАДЕЛЬЦА («продолжай, потолки подними где нужно») с $1.00 # до $1.15 — ровно на величину уже случившегося перерасхода ($1.066428, §2.7) плюс арм боевой # конфигурации flash (~$0.02). Объявлено ДО траты, как требует норма фаз. CEILING_USD = 1.15 HARD_STOP = 1.12 # Леджер модульного уровня: `call` обязан знать потраченное, чтобы гард стоял перед ПОКУПКОЙ. # ⚠ ИНИЦИАЛИЗИРУЕТСЯ ИЗ УЖЕ КУПЛЕННОГО НА ДИСКЕ. Без этого пере-прогон считает, что потрачено # ноль, и докупает сверх потолка — именно так потолок Ф1 был пробит на $0.066 (см. отчёт §3): # я перенёс гард к покупке, но забыл, что «потрачено» переживает процесс, а память — нет. _LEDGER = {"spent": 0.0} def _init_ledger() -> None: tot = 0.0 for f in OUT.glob("scr-*.json"): try: r = json.loads(f.read_text(encoding="utf-8")) except Exception: # noqa: BLE001, S112 continue if not r.get("skipped"): tot += r.get("cost_usd", 0.0) _LEDGER["spent"] = tot # ⚠ РЕЗ ПО ПРАЙСУ, объявленный до замера. Кандидатов 15, промт разрешает не более 14 и требует # резать по цене с объявлением. Снимаются три САМЫХ ДОРОГИХ по выходу, и ни один из них не несёт # уникальной способности: kimi-k3 ($15/1M выход) — вдобавок самый многословный ростера (quirks: # ~11k токенов размышления на абзац), gemini-3.1-pro ($12) дублируется более дешёвым 3.6-flash # того же семейства, gpt-5.6-terra ($12) — более дешёвым luna того же семейства. Семейства при # этом НЕ теряются: каждое представлено хотя бы одним жильцом, и требование «дешёвый + сильный # тир каждого провайдера» соблюдено. CUT = {"kimi-k3", "gemini-3.1-pro-preview", "gpt-5.6-terra"} OPENAI_FAMILY = {"gpt-5.6-luna", "gpt-5.6-terra"} # Роли, под которые скринится. Разные роли — разные оси: переводчик обязан не эхать, редактор # обязан держать формат и банк. ROLES = ("translator", "editor") def screened() -> list[str]: return [m for m in out_price_order() if m not in CUT] def client(model: str) -> OpenAI: base, env, *_ = CANDIDATES[model] return OpenAI(api_key=os.environ[env], base_url=base, timeout=900) def call(model: str, msgs: list[dict], tag: str, temperature: float = 0.0, effort: str | None = None) -> dict: """Один замер. Идемпотентен по тегу: пере-запуск не пере-покупает. `effort` — БОЕВАЯ КОНФИГУРАЦИЯ модели, если она у неё есть. Скрин по умолчанию зовёт всех на вендор-дефолте, и это честный вопрос «работает ли модель из коробки»; но промт требует мерить цену и дисциплину В БОЕВОЙ конфигурации, а у `deepseek-v4-flash` боевая включает `reasoning_effort:"low"` — без ручки размышление съедает бюджет и выход пуст (quirks §10, подтверждено этим же скрином: 4 вызова из 4 дали `length` при нулевом содержимом). """ f = OUT / f"scr-{tag}.json" if f.exists(): return json.loads(f.read_text(encoding="utf-8")) if _LEDGER["spent"] > HARD_STOP: return dict(tag=tag, model=model, model_returned="", finish="skipped", prompt_tokens=0, cached_tokens=0, completion_tokens=0, reasoning_tokens=0, reasoning_chars=0, content="", latency_s=0.0, cost_usd=0.0, skipped=True) kw: dict = dict(model=model, messages=msgs) if effort: kw["extra_body"] = {"reasoning_effort": effort} if model in OPENAI_FAMILY: kw["max_completion_tokens"] = 16000 # quirks 00: не max_tokens у reasoning-моделей else: kw["max_tokens"] = 16000 kw["temperature"] = temperature t0 = time.time() try: r = client(model).chat.completions.create(**kw) except Exception as e: # noqa: BLE001 # ⚠ Обёртка добавлена по живому наблюдению: у glm-4.7 редакторский вызов занял 898.5 с # при таймауте 900 с — следующий такой уронил бы весь прогон и потерял уже купленное. # Провал ОДНОГО вызова — это данные (модель не укладывается в бюджет времени роли), # а не повод останавливать скрин. Записывается как клетка с отказом провода. rec = dict(tag=tag, model=model, model_returned="", finish="error", error=f"{type(e).__name__}: {str(e)[:200]}", prompt_tokens=0, cached_tokens=0, completion_tokens=0, reasoning_tokens=0, reasoning_chars=0, content="", latency_s=round(time.time() - t0, 1), cost_usd=0.0) f.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8") return rec ch, u = r.choices[0], r.usage cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0 rec = dict(tag=tag, model=model, model_returned=r.model, finish=ch.finish_reason, prompt_tokens=u.prompt_tokens or 0, cached_tokens=cached, completion_tokens=u.completion_tokens or 0, reasoning_tokens=getattr(getattr(u, "completion_tokens_details", None), "reasoning_tokens", 0) or 0, reasoning_chars=len(getattr(ch.message, "reasoning_content", None) or ""), content=ch.message.content or "", latency_s=round(time.time() - t0, 1), cost_usd=round(cost(model, u.prompt_tokens or 0, cached, u.completion_tokens or 0, getattr(getattr(u, "completion_tokens_details", None), "reasoning_tokens", 0) or 0), 6)) f.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8") _LEDGER["spent"] += rec["cost_usd"] return rec # ─────────────────────────── гейты (детерминированные) ─────────────────────────── _RE_HAN = re.compile(r"[㐀-鿿]") _RE_MD = re.compile(r"^\s{0,3}#{1,6}\s", re.M) _RE_PREAMBLE = re.compile(r"^\s*(вот|ниже|here is|here's)\b[^\n:]{0,80}:", re.I) _RE_REFUSAL = re.compile( r"не могу|не буду|отказ|прости(те)?, но|cannot (assist|help|comply)|i'm sorry", re.I) def gates(rec: dict, source: str) -> dict: t = rec["content"] letters = [c for c in t if c.isalpha()] han = len(_RE_HAN.findall(t)) return dict( empty=not t.strip(), truncated=rec["finish"] == "length", md_headers=len(_RE_MD.findall(t)), preamble=bool(_RE_PREAMBLE.search(t)), han_chars=han, han_share=round(han / max(1, len(letters)), 5), refusal=bool(_RE_REFUSAL.search(t[:400])), len_ratio=round(len(t) / max(1, len(source)), 3), # ⚠ Доля считается от ТАРИФИЦИРУЕМОГО выхода, а не от completion: у аддитивных # провайдеров (xAI) отношение к completion даёт бессмыслицу вроде 203.8%. reasoning_share=round(rec["reasoning_tokens"] / max(1, billed_output( rec["model"], rec["completion_tokens"], rec["reasoning_tokens"])), 3), ) def verdict(rows: list[dict]) -> tuple[str, list[str]]: """Пороги применяются как объявлены в шапке. Возвращает (вердикт, список причин). ⚠ Пустой список — это НЕ провал, а отсутствие замера, и различать их обязательно: первая редакция на пустых строках давала `0 >= 0` и печатала «утечка ханьцзы» модели, которую жёсткий стоп не дал купить вовсе. Незамеренная модель, объявленная провалившейся, — ложное утверждение о вендоре, и поймано оно только сверкой невозможного числа с сырьём. """ if not rows: return "НЕ ЗАМЕРЕН", ["потолок кончился до этой модели"] why = [] fmt_bad = sum(1 for r in rows if r["md_headers"] or r["preamble"] or r["empty"] or r["truncated"]) if fmt_bad > 1: why.append(f"Г1 формат: нарушений в {fmt_bad} единицах") if any(r["han_share"] > 0.15 for r in rows): why.append("Г2 эхо: единица выше боевого порога 0.15") elif sum(1 for r in rows if r["han_chars"]) >= len(rows): why.append("Г2 утечка: ханьцзы во ВСЕХ единицах") if any(r["refusal"] for r in rows): why.append("Г3 отказ на чувствительном фрагменте") if any(r["truncated"] and r["empty"] for r in rows): why.append("Г6 размышление съело бюджет, выход пуст") return ("ПРОВАЛ" if why else "прошёл"), why def load_units() -> list[dict]: """БОЕВЫЕ единицы редактуры из корпуса пакета-6, а не короткие окна пробы 18. ⚠ Первая редакция брала окна `pick_windows()` — по ~500 знаков источника. Промт требует мерить формат-дисциплину «НА ДЛИННЫХ входах (боевая единица, не короткое окно)», а реальная единица прогона несёт медианно 1683 знака источника и 5594 черновика, то есть в 3.4 раза больше. На коротком окне модель формат не рвёт, и скрин показал бы всем «прошёл» — то есть не убивал бы никого, а промт требует, чтобы скрин убивал. Поймано замером длин до первого вызова. Берутся две единицы около 70-го и 80-го процентиля длины — боевой размер, но не выброс. Выбор детерминированный (сортировка по длине), случайности здесь не нужно. """ corp = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl" us = [json.loads(l) for l in corp.read_text(encoding="utf-8").splitlines() if l.strip()] us = [u for u in us if all((u.get(k) or "").strip() for k in ("source", "draft"))] order = sorted(range(len(us)), key=lambda i: len(us[i]["source"])) return [us[order[int(0.7 * len(order))]], us[order[int(0.8 * len(order))]]] def main() -> None: dry = "--dry" in sys.argv _init_ledger() if _LEDGER["spent"]: print(f"уже куплено скрином ранее: ${_LEDGER['spent']:.6f} — гард считает от этой суммы") models = screened() units = load_units() n_calls = len(models) * len(units) * len(ROLES) print(f"кандидатов после реза: {len(models)} из {len(CANDIDATES)} " f"(снято по прайсу: {', '.join(sorted(CUT))})") print(f"единиц {len(units)} · ролей {len(ROLES)} · вызовов {n_calls} · потолок ${CEILING_USD}") for u in units: print(f" единица {u['run']}:{u['chapter']}:{u['chunk_idx']} — источник " f"{len(u['source'])} зн, черновик {len(u['draft'])} зн") print("порядок моделей — по ВОЗРАСТАНИЮ цены выхода: если потолок кончится, непокрытыми\n" "останутся дорогие, и это будет объявлено, а не скрыто") for m in models: print(f" {m}") if dry: return spent = 0.0 results: dict[str, list[dict]] = {} # АРМ БОЕВОЙ КОНФИГУРАЦИИ. Скрин зовёт всех на вендор-дефолте — это честный вопрос «работает # ли модель из коробки». Но у `deepseek-v4-flash` боевая черновая роль ставит `reasoning_effort: # "low"`, без которого размышление съедает бюджет (quirks §10, подтверждено скрином: 4/4 # пустых). Без этого арма вывод «flash непригоден» был бы подменой: доказано лишь «непригоден # дефолт». Арм объявлен девиацией §3 и гонится теми же единицами и ролями. if "--flash-low" in sys.argv: rows = [] for role in ROLES: for ui, u in enumerate(units): src, draft = u["source"], u["draft"] if role == "translator": msgs = Q.messages("direct", src, draft, None) else: terms = [t for t in IP.TERMS if t in src] blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) if terms else None msgs = Q.messages("full", src, draft, blk) rec = call("deepseek-v4-flash", msgs, f"flashlow-{role}-u{ui}", effort="low") spent += rec["cost_usd"] rows.append(dict(role=role, unit=ui, **gates(rec, src), cost=rec["cost_usd"], latency=rec["latency_s"])) time.sleep(0.2) v, why = verdict(rows) print(f"\nАРМ БОЕВОЙ КОНФИГУРАЦИИ deepseek-v4-flash (effort=low): {v} {'; '.join(why)}") for r in rows: print(f" {r['role']:11s} u{r['unit']} пусто={r['empty']!s:5s} обрыв={r['truncated']!s:5s} " f"ханьцзы={r['han_chars']:3d} размышл.={r['reasoning_share']:.0%} ${r['cost']:.5f}") print(f"потрачено армом ${spent:.6f}") return for m in models: rows = [] for role in ROLES: for ui, u in enumerate(units): # ⚠ Гард стоит ВНУТРИ `call` (перед покупкой), а не здесь: первая редакция # проверяла стоп ДО обращения к кэшу, и при пере-прогоне уже КУПЛЕННЫЕ клетки # не загружались — модель с четырьмя артефактами на диске получала вердикт # «НЕ ЗАМЕРЕН». Гард ограничивает ПОКУПКУ, а не чтение. src, draft = u["source"], u["draft"] if role == "translator": msgs = Q.messages("direct", src, draft, None) else: # Блок закона собирается из термов, реально встреченных в ЭТОЙ единице: # инъекция боевого пути именно такая (D39.104), пустой блок был бы не боевым. # Термы берутся тем же способом, что в пробе 18: из ростера `inject_probe.TERMS` # отбираются те, что реально встречены В ЭТОЙ единице. Пустой блок был бы # не боевым путём — ЗАКОН промта требует инъекцию во всех армах (D39.104). terms = [t for t in IP.TERMS if t in src] blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) if terms else None msgs = Q.messages("full", src, draft, blk) rec = call(m, msgs, f"{m}-{role}-u{ui}") spent += rec["cost_usd"] if rec.get("skipped"): continue rows.append(dict(role=role, unit=ui, **gates(rec, src), cost=rec["cost_usd"], latency=rec["latency_s"])) time.sleep(0.2) results[m] = rows print(f"\n{'модель':24s}{'вердикт':>9s}{'p50 $/ед':>10s}{'эхо':>7s}" f"{'размышл.':>10s} причины") print("-" * 96) for m, rows in results.items(): v, why = verdict(rows) p50 = statistics.median(r["cost"] for r in rows) if rows else 0 han = sum(r["han_chars"] for r in rows) rs = statistics.median(r["reasoning_share"] for r in rows) if rows else 0 print(f"{m:24s}{v:>9s}{p50:10.5f}{han:7d}{rs:10.1%} {'; '.join(why)}") (OUT / "screen.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), encoding="utf-8") print(f"\nпотрачено ${spent:.6f} из ${CEILING_USD}") if __name__ == "__main__": main()