textmachine/eval/role_topology/screen.py

334 lines
23 KiB
Python
Raw Permalink 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
"""Ф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()