334 lines
23 KiB
Python
334 lines
23 KiB
Python
#!/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()
|