From c0dd228d648a71e3a0548a8af0c2d5dcc4db1269 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 9 Aug 2026 02:44:28 +0300 Subject: [PATCH] Freeze editor-tier phase A: pack roster with vendor-verified prices, configurable ledger, unit pinning by manifest, and screen reusing exp-22 thresholds --- eval/conformance.py | 119 ++++++++++++++++++++++++ eval/editor_tier/material.py | 127 ++++++++++++++++++++++++++ eval/editor_tier/money.py | 122 +++++++++++++++++++++++++ eval/editor_tier/roster.py | 160 +++++++++++++++++++++++++++++++++ eval/editor_tier/screen.py | 164 ++++++++++++++++++++++++++++++++++ eval/tenant_panel/material.py | 28 ++++++ eval/tenant_panel/money.py | 29 +++++- 7 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 eval/conformance.py create mode 100644 eval/editor_tier/material.py create mode 100644 eval/editor_tier/money.py create mode 100644 eval/editor_tier/roster.py create mode 100644 eval/editor_tier/screen.py diff --git a/eval/conformance.py b/eval/conformance.py new file mode 100644 index 00000000..0e863335 --- /dev/null +++ b/eval/conformance.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""СВЕРКА С БУКВОЙ ЗАКАЗА. $0. Инструмент зоны `eval/`, не одного пака. + +Зачем он есть. Эксп-22 сдал отчёт, у которого ВСЕ числа сходились с сырьём (два гейта, 85 +проверок, пере-счёт двумя путями) — и при этом ШЕСТЬ требований промта не были выполнены. Числа и +заказ — разные классы отказа, и прибор для первого не ловит второй. Все шесть пропусков стояли не +в нумерованных блоках фаз, а в придаточных, и «критик полноты», написанный тем же, кто вёл пак, +их не поймал: он проверял по МОДЕЛИ заказа, а не по заказу. + +Что делает. Читает из markdown-документа таблицу требований (маркер ``) и по +КАЖДОЙ строке проверяет улику исполнения. Улика бывает двух видов: + `$ <команда>` — команда должна завершиться кодом 0 (только $0-команды; сеть и покупки здесь + неуместны и не предусмотрены); + `<путь>` — артефакт должен существовать и быть непустым. +Улика `—` означает «улики нет»: в режиме плана это законно (требование ещё не исполнено), в +режиме сдачи — отказ. + +Режимы: + --plan требование без улики = ОЖИДАЕТ (код 0, если ни одна улика не ПАДАЕТ); + --final требование без улики = ПРОВАЛ (код ≠0). Гонять перед сдачей пака. + +Запуск: eval/.venv/bin/python eval/conformance.py <файл.md> [--plan|--final] + +⚠ Границы инструмента, чтобы клейм не был шире прибора. Он проверяет, что у требования ЕСТЬ +проходящая улика, а не что улика доказывает именно это требование: связь «требование ↔ улика» +устанавливает автор таблицы, и подменить её ничего не мешает. Это сдвигает работу с «вспомнить всё +из прозы» на «объявить улику заранее» — но не отменяет чтения. +""" +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +MARK = "" + + +def rows(md: str) -> list[tuple[str, str, str]]: + """Строки таблицы требований: (ID, требование, улика).""" + if MARK not in md: + raise SystemExit(f"в документе нет маркера {MARK} — реестр требований не объявлен") + tail = md.split(MARK, 1)[1] + out = [] + for line in tail.splitlines(): + s = line.strip() + if not s.startswith("|"): + if out: # таблица кончилась + break + continue + cells = [c.strip() for c in s.strip("|").split("|")] + if len(cells) < 3 or set(cells[0]) <= set("-: "): + continue + if cells[0].upper() in ("ID", "№"): + continue + out.append((cells[0], cells[1], cells[2])) + if not out: + raise SystemExit("реестр требований пуст") + return out + + +def check(ev: str) -> tuple[str, str]: + """Вернуть (статус, деталь). Статусы: OK · ПРОВАЛ · ОЖИДАЕТ.""" + ev = ev.strip().strip("`") + if ev in ("—", "-", ""): + return "ОЖИДАЕТ", "улика не объявлена" + if ev.startswith("$"): + cmd = ev[1:].strip() + # ⚠ Улика не имеет права звать этот же инструмент в режиме сверки: реестр, чьей уликой + # служит проверка самого реестра, уходит в бесконечную рекурсию. Поймано первым прогоном. + if "conformance.py" in cmd and ("--plan" in cmd or "--final" in cmd): + return "ПРОВАЛ", "улика рекурсивна: зовёт conformance.py в режиме сверки" + try: + r = subprocess.run(cmd, shell=True, cwd=REPO, capture_output=True, # noqa: S602 + text=True, timeout=1800) + except subprocess.TimeoutExpired: + return "ПРОВАЛ", "таймаут" + if r.returncode == 0: + return "OK", "" + return "ПРОВАЛ", (r.stderr or r.stdout).strip().splitlines()[-1][:120] if ( + r.stderr or r.stdout).strip() else f"код {r.returncode}" + p = (REPO / ev) if not ev.startswith("/") else Path(ev) + if p.is_dir(): + return ("OK", "") if any(p.iterdir()) else ("ПРОВАЛ", "каталог пуст") + if p.exists() and p.stat().st_size > 0: + return "OK", "" + return "ПРОВАЛ", "артефакта нет или он пуст" + + +def main() -> int: + args = [a for a in sys.argv[1:] if a] + if not args: + print(__doc__) + return 1 + mode = "--final" if "--final" in args else "--plan" + src = next((a for a in args if not a.startswith("--")), None) + if not src: + print("не указан файл") + return 1 + md = (REPO / src if not src.startswith("/") else Path(src)).read_text(encoding="utf-8") + bad = pend = 0 + print(f"СВЕРКА С БУКВОЙ ЗАКАЗА: {src} [{mode[2:]}]\n") + for rid, req, ev in rows(md): + st, detail = check(ev) + if st == "ОЖИДАЕТ" and mode == "--final": + st = "ПРОВАЛ" + detail = "улики нет, а пак сдаётся" + bad += st == "ПРОВАЛ" + pend += st == "ОЖИДАЕТ" + print(f"[{st:8s}] {rid:6s} {req[:78]}" + (f" ← {detail}" if detail else "")) + total = len(rows(md)) + print(f"\nтребований {total} · ожидают {pend} · провалов {bad}") + if bad: + print("СВЕРКА НЕ ПРОЙДЕНА") + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/eval/editor_tier/material.py b/eval/editor_tier/material.py new file mode 100644 index 00000000..e4286744 --- /dev/null +++ b/eval/editor_tier/material.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""МАТЕРИАЛ ПАКА: 16 НОВЫХ единиц той же книги. $0 — глава уже скачана эксп-22. + +Зачем новые единицы. Контраст `R2 glm-5` против боевого редактора дал −2.12 при шуме одного вызова +sd≈3.5: это вопрос МОЩНОСТИ, а не судьи. Пред-объявленный порог эксп-22 (2.03) он проходит, +собственный порог своего прохода (2.47) — нет, и вердикт зависит от того, какой взять. Удвоение +n с 16 до 32 либо разводит интервал от порога, либо честно говорит «эффект меньше, чем стоит +различать». Оба ответа годны; зависимость вердикта от выбора порога — нет. + +Методика отбора та же, что в эксп-21 §0 и эксп-22: абзацные чанки, дедуп по подписи без заголовка +главы, ключ = хеш источника, отбор из СЕРЕДИНЫ распределения длин. Отличие ровно одно: главы, +из которых эксп-22 уже брал единицы, исключаются целиком — иначе «новые» единицы были бы соседними +абзацами тех же глав, и прирост n оказался бы мнимым (соседние куски одной главы делят персонажей, +сцену и лексику, то есть коррелируют сильнее случайных). + +Гейт прав эксп-22 (снятые главы верхнего дециля эротических маркеров) продолжает действовать: он +применяется в `chapters()`, который здесь переиспользуется как есть. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +ZONE = Path(__file__).resolve().parent +sys.path.insert(0, str(REPO / "eval" / "tenant_panel")) + +import material as M22 # noqa: E402 + +N_NEW = 16 + + +def used_chapters() -> set[int]: + """Главы, из которых эксп-22 уже взял единицы.""" + return {u["chapter"] for u in M22.units_zh()} + + +def used_uids() -> set[str]: + return {u["uid"] for u in M22.units_zh()} + + +def rights_dropped(pool: set[int]) -> set[int]: + """Гейт прав по СВОЕМУ пулу: верхний дециль по плотности эротических маркеров. + + ⚠ Число снятых глав НЕ переносится из эксп-22. Там гейт снял две главы — это был верхний + дециль пула из 18. Пул этого пака другой, и перенос константы «2» пропустил бы в замер главы, + которые объявленное правило снимает. Правило одно и то же, число считается заново. + """ + allch = {c["idx"]: c for c in M22.chapters(all_=True)} + ranked = sorted(pool, key=lambda i: (-M22.se_density(allch[i]["text"]), i)) + k = max(1, round(len(pool) * 0.10)) + return set(ranked[:k]) + + +def units_new(n: int = N_NEW) -> list[dict]: + """N новых единиц из глав, которых эксп-22 не касался и которые прошли гейт прав.""" + allidx = {c["idx"] for c in M22.chapters(all_=True)} + free = allidx - used_chapters() - M22.se_dropped(M22.chapters(all_=True)) + dropped = rights_dropped(free) + busy = used_chapters() | dropped | M22.se_dropped(M22.chapters(all_=True)) + globals()["RIGHTS_DROPPED"] = sorted(dropped) + uniq: dict[str, dict] = {} + seen: set[str] = set() + for c in M22.chunks(): + if c["chapter"] in busy: + continue + sig = M22._dedup_sig(c["text"]) # noqa: SLF001 + if sig in seen: + continue + seen.add(sig) + uid = M22.uid_of(c["text"]) + uniq.setdefault(uid, dict(source=c["text"], uid=uid, chapter=c["chapter"])) + keys = sorted(uniq, key=lambda k: (len(uniq[k]["source"]), k)) + if len(keys) < n: + raise SystemExit(f"свободных единиц {len(keys)}, нужно {n} — материал не набирается") + lo = (len(keys) - n) // 2 + picked = [uniq[k] for k in keys[lo:lo + n]] + # одна глава — не более одной единицы: иначе внутри «новых» появится та же корреляция, + # ради снятия которой исключались главы эксп-22 + out, taken = [], set() + for u in picked: + if u["chapter"] in taken: + continue + taken.add(u["chapter"]) + out.append(u) + if len(out) < n: # добираем по тому же порядку, соблюдая правило главы + for k in keys[lo + n:] + keys[:lo][::-1]: + u = uniq[k] + if u["chapter"] in taken: + continue + taken.add(u["chapter"]) + out.append(u) + if len(out) == n: + break + return out[:n] + + +def selfcheck() -> int: + bad = 0 + us = units_new() + if len(us) != N_NEW: + print(f"[ПРОВАЛ] единиц {len(us)}, нужно {N_NEW}") + bad += 1 + if set(u["uid"] for u in us) & used_uids(): + print("[ПРОВАЛ] единица повторяет эксп-22") + bad += 1 + if set(u["chapter"] for u in us) & used_chapters(): + print("[ПРОВАЛ] глава повторяет эксп-22") + bad += 1 + if len({u["chapter"] for u in us}) != len(us): + print("[ПРОВАЛ] две единицы из одной главы") + bad += 1 + sim = M22._pairwise_max_sim(us) # noqa: SLF001 + if sim > 0.5: + print(f"[ПРОВАЛ] единицы слишком похожи: макс. близость {sim:.3f}") + bad += 1 + ln = sorted(len(u["source"]) for u in us) + print(f"новых единиц {len(us)} из {len({u['chapter'] for u in us})} глав · " + f"знаков {ln[0]}…{ln[-1]} медиана {ln[len(ln) // 2]} · макс. близость {sim:.3f}") + print(f"главы: {sorted(u['chapter'] for u in us)}") + print(f"снято гейтом прав из своего пула: {globals().get('RIGHTS_DROPPED')}") + print(f"провалов {bad}") + return bad + + +if __name__ == "__main__": + sys.exit(1 if selfcheck() else 0) diff --git a/eval/editor_tier/money.py b/eval/editor_tier/money.py new file mode 100644 index 00000000..f5515dff --- /dev/null +++ b/eval/editor_tier/money.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""КАССА ПАКА «СИЛЬНЫЙ ТИР». $0 сама по себе; через неё идут ВСЕ платные вызовы пака. + +Не копия кассы эксп-22, а её НАСТРОЙКА. Механизм там проверенный и дорого доставшийся — фриз-гейт, +атомарный замок клетки, проекционный сторож перед каждым вызовом, два независимых пути счёта, — и +копирование размножило бы будущие расхождения вместо того, чтобы их ловить. Пак-специфика (зона +кода, каталог сырья, теги фаз, потолки, прайс-таблица) подставляется через `money.configure`. + +Потолки — санкция владельца 09.08: пак ≤$4.00. Фазовые суммируются в $3.35, разница — объявленный +резерв на ре-гены эхо-мины и ретраи, а не запас «на всякий случай»: тратится он только через те же +теги и виден в отчёте. +""" +from __future__ import annotations + +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" / "tenant_panel")) +sys.path.insert(0, str(REPO / "eval" / "role_topology")) + +def _load(name: str, path: Path): + """⚠ Ростер пака грузится ПО ПУТИ. `import roster` находил ростер эксп-22: три + `sys.path.insert(0, …)` подряд кладут ПОСЛЕДНИЙ вставленный первым, и своя папка оказывалась + в хвосте. Поймано печатью селфтеста — «моделей 11» вместо 15, то есть проверка «цена считается + для ВСЕХ моделей» молча проверяла только старые.""" + import importlib.util # noqa: PLC0415 + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +# ⚠ И касса эксп-22 тоже грузится ПО ПУТИ. `import money` здесь работал лишь по счастливому +# порядку sys.path: стоило вызвать модуль из файла, который кладёт свою папку первой, как он +# находил САМ СЕБЯ и падал циклическим импортом. Правило без исключений: чужое — только _load. +M22 = _load("money22", REPO / "eval" / "tenant_panel" / "money.py") +ROSTER_ET = _load("roster_et", ZONE / "roster.py") + +OUT = Path.home() / "books" / "editor-tier" + +# Теги покупок по фазам. ⚠ Новый тег вписывается СЮДА тем же коммитом, что и код, который его +# заводит: расход мимо глоба невидим для кассы, и эксп-21 напоролся на это трижды. +TAGS = { + "ФA": ("et-scr-*.json",), # скрин сильного тира + "ФB": ("et-unit-*.json",), # 16 новых единиц для границы glm-5 (черновик + R0 + R2) + "ФC": ("et-ed-*.json",), # панель редакторов поверх якорной базы эксп-22 +} +CEILINGS = {"ФA": 1.10, "ФB": 0.45, "ФC": 1.80} +PACK_CEILING = 4.00 + +M22.configure(ZONE=ZONE, OUT=OUT, TAGS=TAGS, CEILINGS=CEILINGS, + PACK_CEILING=PACK_CEILING, GLOB_ALL="et-*.json", ROSTER=ROSTER_ET) + +# Всё остальное берётся у настроенной кассы — своих реализаций тут нет намеренно. +Guarded, Pack, purchase, model_expect = M22.Guarded, M22.Pack, M22.purchase, M22.model_expect + + +def report() -> None: + print(f"{'фаза':6s}{'потрачено':>12s}{'потолок':>10s}") + tot = 0.0 + for ph in TAGS: + sp = Guarded(ph).spent() + tot += sp + mark = " ⛔ ПРОБОЙ" if sp > CEILINGS[ph] + 1e-9 else "" + print(f"{ph:6s}{sp:12.6f}{CEILINGS[ph]:10.2f}{mark}") + print(f"{'ПАК':6s}{tot:12.6f}{PACK_CEILING:10.2f}") + + +def selftest() -> int: + """Обязан пройти ДО первой покупки пака.""" + bad = 0 + # 1. касса смотрит в СВОЁ сырьё, а не в сырьё эксп-22 + if M22.OUT != OUT: + print("[ПРОВАЛ] касса не пере-настроена на своё сырьё") + bad += 1 + if M22.BUY.OUT != OUT: + print("[ПРОВАЛ] buy.OUT указывает мимо каталога пака — покупки уедут к соседу") + bad += 1 + # 2. цена считается для ВСЕХ моделей, включая новые (эксп-22 упал тут на середине скрина) + for m in ROSTER_ET.CANDIDATES: + try: + if not M22.BUY.cost(m, 4500, 0, 2500, 1000, 8000) > 0: + raise ValueError(m) + except Exception as e: # noqa: BLE001 + print(f"[ПРОВАЛ] цена не считается: {m} ({type(e).__name__}: {e})") + bad += 1 + # 3. в таблице кассы ДОЛЖНЫ быть новые кандидаты — иначе подставился чужой ростер + missing = [m for m in ("gpt-5.6-terra", "kimi-k3", "grok-4.5", "glm-5.2") + if m not in ROSTER_ET.CANDIDATES] + if missing: + print(f"[ПРОВАЛ] в таблице кассы нет новых кандидатов: {missing}") + bad += 1 + + # 4. потолки фаз не превышают пакетный + if sum(CEILINGS.values()) > PACK_CEILING + 1e-9: + print("[ПРОВАЛ] сумма фазовых потолков выше пакетного") + bad += 1 + # 5. КАЖДЫЙ тег покрыт глобом GLOB_ALL — иначе покупка невидима для `model_expect` + for globs in TAGS.values(): + for g in globs: + if not g.startswith("et-"): + print(f"[ПРОВАЛ] тег вне шаблона пака: {g}") + bad += 1 + # 6. фриз-гейт обязан отказывать коду вне зоны пака + import inspect # noqa: PLC0415 + if M22.ZONE != ZONE: + print("[ПРОВАЛ] фриз-гейт сторожит чужую зону") + bad += 1 + _ = inspect + print(f"касса: тегов {sum(len(v) for v in TAGS.values())} · моделей " + f"{len(ROSTER_ET.CANDIDATES)} · провалов {bad}") + return bad + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + sys.exit(1 if selftest() else 0) + report() diff --git a/eval/editor_tier/roster.py b/eval/editor_tier/roster.py new file mode 100644 index 00000000..89773e3d --- /dev/null +++ b/eval/editor_tier/roster.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""РОСТЕР ПАКА «СИЛЬНЫЙ ТИР РЕДАКТОРСКОЙ РОЛИ». $0. + +Пак закрывает дыру эксп-22: промт разрешал до 16 кандидатов и требовал у КАЖДОГО провайдера два +тира — дешёвый и сильный; сессия срезала до 11 и снесла сильный тир четырёх провайдеров. Отсюда +вывод «`deepseek-v4-pro` побеждает» верен только ВНУТРИ панели. Здесь берутся ровно те четверо. + +Ростер НЕ дублирует эксп-22, а расширяет его: `tenant_panel.roster` остаётся источником для армов, +которые пак переиспользует (якорь `deepseek-v4-flash`, боевой редактор `deepseek-v4-pro`, +пограничный `glm-5`). Дублирование прайс-таблицы уже стоило эксп-22 падения прогона на середине +скрина, и повторять его незачем. + +Цены — с ВЕНДОР-страниц, снятых 09.08.2026, а не из памяти и не из оценки. Слаги — живой листинг +`/models` того же дня (`role_topology/list_models.py`): все четыре присутствуют. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +sys.path.insert(0, str(REPO / "eval" / "tenant_panel")) +sys.path.insert(0, str(REPO / "eval" / "role_topology")) + +# ⚠ Ростер эксп-22 грузится ПО ПУТИ, а не `import roster`. Причина механическая и поймана первым +# же прогоном: файл называется так же, и при прямом запуске Python находит сам себя — циклический +# импорт с пустым модулем. Явная загрузка под своим именем снимает зависимость от порядка путей. +def _load(name: str, path: Path): + import importlib.util # noqa: PLC0415 + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +R22 = _load("roster22", REPO / "eval" / "tenant_panel" / "roster.py") + +# (base_url, env-ключ, $/M вход, $/M кэш, $/M выход, роль, провенанс цены) +NEW: dict[str, tuple] = { + "gpt-5.6-terra": ("https://api.openai.com/v1", "OPENAI_API_KEY", + 2.00, 0.20, 12.00, "editor", + "developers.openai.com/api/docs/pricing, live 09.08 (standard tier)"), + "kimi-k3": ("https://api.moonshot.ai/v1", "KIMI_API_KEY", + 3.00, 0.30, 15.00, "editor", + "platform.moonshot.ai (сохранено prices/kimi_k3.html 08.08): " + "cache-miss 3.00 / cache-hit 0.30 / out 15.00"), + "grok-4.5": ("https://api.x.ai/v1", "XAI_API_KEY", + 2.00, 0.30, 6.00, "editor", + "docs.x.ai/docs/models, live 09.08; тариф <200k контекста — наш случай (вход ~4.5k)"), + "glm-5.2": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY", + 1.40, 0.26, 4.40, "editor", + "docs.z.ai/guides/overview/pricing (сохранено prices/zai.html 08.08)"), +} + +# Полная таблица = переиспользуемые армы эксп-22 + новые кандидаты. Пересечений быть не должно: +# если вендор завёл слаг, уже известный эксп-22, это надо заметить, а не тихо перекрыть. +_dup = set(NEW) & set(R22.CANDIDATES) +if _dup: + raise SystemExit(f"слаг есть и в ростере эксп-22, и в новом: {_dup}") +CANDIDATES: dict[str, tuple] = {**R22.CANDIDATES, **NEW} + +# Режим биллинга размышления наследуется; у новых моделей он НЕ ОБЪЯВЛЕН вендором и будет снят +# замером в фазе A. До замера считаем консервативно — как аддитивный, то есть дороже. +REASONING_BILLING = {**R22.REASONING_BILLING, + "grok-4.5": "additive"} # grok-4.3 аддитивен (quirks); семейство то же + +# ⚠ ОЖИДАНИЕ ЦЕНЫ РЕДАКТОРСКОЙ КЛЕТКИ — из ЗАМЕРОВ эксп-22, не из прайса. Считается как +# отношение «замеренная цена клетки / цена выхода» внутри ТОГО ЖЕ семейства, потому что отношение +# определяется поведением размышления, а оно у вендора своё. У Kimi отношение вчетверо хуже всех: +# `k2.6` жёг бюджет на размышление и отдавал пустой выход. +EXPECT_PER_UNIT = { + "gpt-5.6-terra": 12.00 * 0.00364, # отношение luna: 0.0043745 / 1.20 + "kimi-k3": 15.00 * 0.01690, # отношение k2.6: 0.0677265 / 4.00 ⚠ ~$0.25 + "grok-4.5": 6.00 * 0.00581, # отношение grok-4.3: 0.0145165 / 2.50 + "glm-5.2": 4.40 * 0.00407, # отношение glm-5: 0.013033 / 3.20 +} + + +def cost(model: str, prompt_tokens: int, cached: int, completion_tokens: int, + reasoning_tokens: int = 0, total_tokens: int = 0) -> float: + """Цена вызова по СВОЕЙ таблице. Считает `tenant_panel.roster`, но по расширенным CANDIDATES — + иначе новые слаги дают KeyError на середине прогона (тот самый отказ эксп-22 §2.2). Сигнатура + повторяет исходную побайтно: касса зовёт её позиционно.""" + saved_c, saved_b = R22.CANDIDATES, R22.REASONING_BILLING + R22.CANDIDATES, R22.REASONING_BILLING = CANDIDATES, REASONING_BILLING + try: + return R22.cost(model, prompt_tokens, cached, completion_tokens, + reasoning_tokens, total_tokens) + finally: + R22.CANDIDATES, R22.REASONING_BILLING = saved_c, saved_b + + +# БОЕВАЯ КОНФИГУРАЦИЯ РАЗМЫШЛЕНИЯ новых кандидатов — из quirks и поведения СОСЕДА ПО СЕМЕЙСТВУ, +# не из вкуса. Гардрейл DeepSeek (не гасить) новых моделей не касается: их тут нет. +THINK_ET = { + "gpt-5.6-terra": ("effort", "low"), # как luna того же семейства (эксп-22 §0.5) + "kimi-k3": ("none", ""), # ручки эффорта у Kimi в quirks не описано (эксп-22 §2.3) + "grok-4.5": ("none", ""), # grok-4.3: reasoning-off только явным none; дефолт low + "glm-5.2": ("extra_body_disable", ""), # как glm-5/glm-4.7 — так шёл арм B эксп-21 +} +OPENAI_FAMILY_ET = R22.OPENAI_FAMILY | {"gpt-5.6-terra"} +FIXED_TEMP_ET = {**R22.FIXED_TEMP, "kimi-k3": 1.0} # у k2.6 иное → HTTP 400; семейство то же + + +def by_new_price() -> list[str]: + """Порядок замера — по ВОЗРАСТАНИЮ цены выхода: кончится потолок — непокрытыми останутся + дорогие, и это будет объявлено числом, а не скрыто.""" + return sorted(NEW, key=lambda m: NEW[m][4]) + + +def call_kwargs(model: str, msgs: list[dict], max_out: int = 16000, + temperature: float = 0.3) -> dict: + """Тело вызова в боевой конфигурации. Считает `tenant_panel.roster`, но по РАСШИРЕННЫМ + таблицам — иначе новый слаг падает с KeyError в `THINK`.""" + sv = (R22.CANDIDATES, R22.THINK, R22.OPENAI_FAMILY, R22.FIXED_TEMP) + R22.CANDIDATES = CANDIDATES + R22.THINK = {**R22.THINK, **THINK_ET} + R22.OPENAI_FAMILY, R22.FIXED_TEMP = OPENAI_FAMILY_ET, FIXED_TEMP_ET + try: + return R22.call_kwargs(model, msgs, max_out, temperature) + finally: + R22.CANDIDATES, R22.THINK, R22.OPENAI_FAMILY, R22.FIXED_TEMP = sv + + +def selfcheck() -> int: + """Проверки, которые обязаны пройти ДО первой покупки.""" + bad = 0 + U = (4500, 0, 2500, 1000, 8000) + for m in NEW: + c = cost(m, *U) + if not c > 0: + print(f"[ПРОВАЛ] цена не считается: {m}") + bad += 1 + for m in ("deepseek-v4-pro", "glm-5", "deepseek-v4-flash"): + if m not in CANDIDATES: + print(f"[ПРОВАЛ] переиспользуемый арм пропал из таблицы: {m}") + bad += 1 + # цена унаследованных моделей не должна поехать от расширения таблицы + for m in ("deepseek-v4-pro", "glm-5", "grok-4.3"): + if abs(cost(m, *U) - R22.cost(m, *U)) > 1e-12: + print(f"[ПРОВАЛ] цена унаследованной модели поехала: {m}") + bad += 1 + for m in NEW: + if m not in THINK_ET: + print(f"[ПРОВАЛ] боевая конфигурация размышления не объявлена: {m}") + bad += 1 + kw = call_kwargs(m, [{"role": "user", "content": "x"}]) + if "model" not in kw: + print(f"[ПРОВАЛ] тело вызова не строится: {m}") + bad += 1 + if m in OPENAI_FAMILY_ET and "temperature" in kw: + print(f"[ПРОВАЛ] OpenAI-семейству шлётся temperature: {m}") + bad += 1 + print(f"ростер: кандидатов {len(CANDIDATES)} (новых {len(NEW)}) · провалов {bad}") + return bad + + +if __name__ == "__main__": + sys.exit(1 if selfcheck() else 0) diff --git a/eval/editor_tier/screen.py b/eval/editor_tier/screen.py new file mode 100644 index 00000000..2b8f130d --- /dev/null +++ b/eval/editor_tier/screen.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""ФАЗА A — СКРИН СИЛЬНОГО ТИРА. Платный (потолок $1.10), идёт через кассу пака. + +Пороги НЕ свои: берутся у эксп-22 побайтно (`screen.PRICE_CAP/CYR_MIN/ECHO_MAX`, `gates`, +`verdict`). Порог, взятый из прошлого пака без правки, — единственная защита от подгонки состава +под желаемый результат, а состав здесь и есть предмет спора: пак существует потому, что эксп-22 +срезал сильный тир. + +⚠ ОСОБЫЙ РЕЖИМ `kimi-k3`, объявлен ДО покупок. По отношению «цена клетки / цена выхода» внутри +Kimi он ожидается ~$0.25/ед — вчетверо выше порога роли, — и `k2.6` уже отдал пустой выход на трёх +клетках из четырёх при 100% доли размышления. Полный скрин был бы покупкой предсказуемого провала +примерно за доллар. Берётся ОДНА клетка редакторской роли с жёстким потолком выхода: этого хватает +и на ценовой вердикт, и на второй замер отказного режима. Отдай он против ожидания годный дешёвый +выход — досняли бы полным скрином отдельным решением. +""" +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(REPO / "eval" / "tenant_panel")) +sys.path.insert(0, str(REPO / "eval" / "role_topology")) + +def _load(name: str, path: Path): + """⚠ Модули соседнего пака грузятся ПО ПУТИ, а не `import <имя>`. При прямом запуске своя + папка стоит в sys.path первой, и `import screen` находит ЭТОТ файл, а не скрин эксп-22 — + молчаливый само-импорт. Ловилось трижды подряд (ростер, касса, скрин), поэтому правило одно: + всё чужое — только явной загрузкой.""" + import importlib.util # noqa: PLC0415 + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +TP = REPO / "eval" / "tenant_panel" +S22 = _load("screen22", TP / "screen.py") +MONEY = _load("money_et", ZONE / "money.py") +ROSTER = MONEY.ROSTER_ET + +PHASE = "ФA" +NEW = list(ROSTER.NEW) # gpt-5.6-terra · kimi-k3 · grok-4.5 · glm-5.2 +SPECIAL = {"kimi-k3": dict(units=1, roles=("editor",), max_out=4000)} +ROLES = ("translator", "editor") + + +def tag(model: str, role: str, uid: str) -> str: + return f"et-scr-{model}-{role}-{uid[:6]}" + + +def plan() -> list[tuple[str, str, dict]]: + """Что будет куплено. Печатается ДО покупок и лежит во фризе.""" + us = S22.units() # те же две единицы, что скринил эксп-22 + out = [] + for m in ROSTER.by_new_price(): + sp = SPECIAL.get(m, {}) + for role in sp.get("roles", ROLES): + for u in us[:sp.get("units", len(us))]: + out.append((m, role, u)) + return out + + +def cmd_plan() -> None: + tot = 0.0 + print(f"{'модель':16s}{'роль':12s}{'ед.':>4s}{'ожидание $':>12s}") + for m in ROSTER.by_new_price(): + sp = SPECIAL.get(m, {}) + n = len(sp.get("roles", ROLES)) * sp.get("units", len(S22.units())) + exp = ROSTER.EXPECT_PER_UNIT[m] * n + tot += exp + print(f"{m:16s}{'/'.join(sp.get('roles', ROLES)):12s}{n:4d}{exp:12.3f}" + + (" ← особый режим" if m in SPECIAL else "")) + print(f"{'ИТОГО':16s}{'':12s}{'':4s}{tot:12.3f} потолок {MONEY.CEILINGS[PHASE]:.2f}") + if tot > MONEY.CEILINGS[PHASE]: + print("⛔ смета выше потолка фазы — СТОП, не резать молча") + raise SystemExit(1) + + +def cmd_run() -> None: + led = MONEY.Guarded(PHASE) + for m, role, u in plan(): + t = tag(m, role, u["uid"]) + if (MONEY.OUT / f"{t}.json").exists(): + continue + msgs = S22.msgs_for(role, u) + kw = ROSTER.call_kwargs(m, msgs, max_out=SPECIAL.get(m, {}).get("max_out", 16000)) + rec = MONEY.purchase(led, t, m, S22.client(m), kw, role=role, uid=u["uid"]) + if rec is None: + print(f" {t}: не куплено") + continue + print(f" {t}: ${rec.get('cost_usd', 0):.5f} finish={rec.get('finish')} " + f"знаков={len(rec.get('content') or '')}") + MONEY.report() + + +def result() -> dict: + """Вердикт по каждому кандидату — порогами эксп-22, без единой правки.""" + res = {} + for m in NEW: + rows = [] + for f in MONEY.OUT.glob(f"et-scr-{m}-*.json"): + rec = json.loads(f.read_text(encoding="utf-8")) + u = next((x for x in S22.units() if x["uid"] == rec.get("uid")), None) + rows.append(S22.gates(rec, u["source"] if u else "")) + v, why = S22.verdict(rows) + pt = [r["cost"] for r in rows if r["role"] == "translator"] + pe = [r["cost"] for r in rows if r["role"] == "editor"] + import statistics as st # noqa: PLC0415 + res[m] = dict(verdict=v, why=why, n=len(rows), + price_translator=(st.median(pt) if pt else None), + price_editor=(st.median(pe) if pe else None), + reasoning_share=(max((r["reasoning_share"] for r in rows), default=0.0)), + han=sum(r["han_chars"] for r in rows)) + return res + + +def cmd_score() -> None: + print(f"{'модель':16s}{'вердикт':>10s}{'ед.':>4s}{'$/ед transl':>13s}{'$/ед edit':>11s}" + f"{'размышл':>9s} причины") + for m, r in result().items(): + print(f"{m:16s}{r['verdict']:>10s}{r['n']:4d}" + f"{(r['price_translator'] or 0):13.5f}{(r['price_editor'] or 0):11.5f}" + f"{r['reasoning_share']:9.1%} {'; '.join(r['why'])}") + ok = [m for m, r in result().items() if r["verdict"] == "прошёл"] + print(f"\nпрошли фазу A: {ok or '—'}") + + +def selfcheck() -> int: + """До покупок: пороги те же, план в потолке, теги уникальны.""" + bad = 0 + if (S22.PRICE_CAP, S22.CYR_MIN, S22.ECHO_MAX) != ({"translator": 0.02, "editor": 0.06}, + 0.5, 0.15): + print("[ПРОВАЛ] пороги эксп-22 изменились — состав пака стал бы подгонкой") + bad += 1 + tags = [tag(m, r, u["uid"]) for m, r, u in plan()] + if len(tags) != len(set(tags)): + print("[ПРОВАЛ] теги покупок не уникальны — клетки перетрут друг друга") + bad += 1 + if not all(t.startswith("et-scr-") for t in tags): + print("[ПРОВАЛ] тег вне глоба фазы — расход будет невидим кассе") + bad += 1 + for m in NEW: + if m not in ROSTER.THINK_ET: + print(f"[ПРОВАЛ] боевая конфигурация размышления не объявлена: {m}") + bad += 1 + if "kimi-k3" not in SPECIAL: + print("[ПРОВАЛ] особый режим kimi-k3 потерян") + bad += 1 + print(f"скрин: клеток в плане {len(tags)} · провалов {bad}") + return bad + + +if __name__ == "__main__": + a = sys.argv[1:] or ["--plan"] + fn = {"--plan": cmd_plan, "--run": cmd_run, "--score": cmd_score, + "--selfcheck": lambda: sys.exit(1 if selfcheck() else 0)}.get(a[0]) + if fn: + fn() + else: + print(__doc__) diff --git a/eval/tenant_panel/material.py b/eval/tenant_panel/material.py index 726801c2..32108a7d 100644 --- a/eval/tenant_panel/material.py +++ b/eval/tenant_panel/material.py @@ -196,12 +196,40 @@ def chunks() -> list[dict]: return [c for c in out if MIN_CHARS <= len(c["text"]) <= MAX_CHARS] +def _pinned_uids() -> list[str]: + """uid единиц, ЗАФИКСИРОВАННЫЕ манифестом пака при отборе.""" + f = OUT / "manifest.json" + if not f.exists(): + return [] + try: + return list(json.loads(f.read_text(encoding="utf-8")).get("zh", {}).get("uids", [])) + except (ValueError, AttributeError): + return [] + + def units_zh(n: int = N_ZH) -> list[dict]: """N единиц: дедуп по подписи, ключ — хеш источника, отбор из СЕРЕДИНЫ длин (эксп-21 §0). Из середины — чтобы единицы были боевого размера и при этом не выбросами; ключ сортировки (длина, uid), а не одна длина: при равной длине порядок иначе решает обход множества. + + ⚠ ПРИКОЛОТО К МАНИФЕСТУ. Отбор «из середины длин» считается от того, что лежит на диске, и + поймано это исполнением: доскачка глав для следующего пака СДВИНУЛА единицы уже сданного + эксп-22 (главы 3–18 превратились в 2,5,9…41), то есть его гейты стали считать по другому + материалу, чем куплен. Детерминизм прогона — цель 6 канона владельца, и «воспроизводимо, пока + никто не трогал каталог» ей не отвечает. Поэтому если манифест отбора существует, единицы + берутся ИМЕННО ЕГО uid; правило отбора применяется только когда манифеста ещё нет. """ + pins = _pinned_uids() + if pins: + byid = {} + for c in chunks(): + byid.setdefault(uid_of(c["text"]), + dict(source=c["text"], uid=uid_of(c["text"]), chapter=c["chapter"])) + miss = [u for u in pins if u not in byid] + if miss: + raise SystemExit(f"единицы манифеста не находятся в материале: {miss}") + return [byid[u] for u in pins] uniq: dict[str, dict] = {} seen: set[str] = set() for c in chunks(): diff --git a/eval/tenant_panel/money.py b/eval/tenant_panel/money.py index 44020fe2..2a524f31 100644 --- a/eval/tenant_panel/money.py +++ b/eval/tenant_panel/money.py @@ -61,6 +61,26 @@ TAGS = { # отклонена в пользу полной мощности панели. CEILINGS = {"Ф0": 0.40, "Ф1": 1.05, "Ф2": 2.00, "Ф3": 3.00} PACK_CEILING = 6.50 +GLOB_ALL = "tp*.json" # по какому шаблону касса видит СВОИ покупки + + +def configure(**kw) -> None: + """Пере-настроить кассу под ДРУГОЙ пак, не копируя её. + + Касса — проверенный механизм: фриз-гейт, атомарный замок, проекционный сторож, два пути счёта. + Копировать её ради нового пака значит копировать и будущие расхождения; поэтому пак-специфика + (зона кода, каталог сырья, теги, потолки, прайс-таблица) вынесена в параметры. Дефолты + оставлены эксп-22 побайтно — вызова `configure` в его коде нет, и поведение не меняется. + Тот же приём уже ратифицирован в судейском риге (`absjudge.configure`). + """ + g = globals() + for k, v in kw.items(): + if k not in g: + raise SystemExit(f"касса: неизвестный параметр configure: {k}") + g[k] = v + OUT.mkdir(parents=True, exist_ok=True) + BUY.OUT = OUT # сырьё пака + BUY.cost = ROSTER.cost # прайс-таблица пака class Pack: @@ -93,11 +113,11 @@ def model_expect(model: str) -> float: множителем на скрытое размышление у провайдеров класса `additive_total`. """ mine = [json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0) - for f in OUT.glob("tp*.json") + for f in OUT.glob(GLOB_ALL) if f.name.count("SELFTEST") == 0] prices = [c for c, m in ((json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0), json.loads(f.read_text(encoding="utf-8")).get("model")) - for f in OUT.glob("tp*.json")) if m == model and c > 0] + for f in OUT.glob(GLOB_ALL)) if m == model and c > 0] if len(prices) >= 2: return max(prices) if model not in ROSTER.CANDIDATES: @@ -162,7 +182,10 @@ def _refuse_unfrozen() -> None: if fr.filename.startswith("<") or not Path(fr.filename).is_file(): raise SystemExit(f"⛔ покупка из НЕ-ФАЙЛА ({fr.filename}) запрещена") p = Path(fr.filename).resolve() - if p.parent == ZONE and p.name in ("money.py",): + # кадры САМОЙ кассы пропускаются по файлу, а не по каталогу: настроенная под другой пак + # касса лежит в зоне эксп-22, а покупает код из зоны того пака, и сверка по каталогу + # отвергала бы собственный же модуль (поймано до первой покупки нового пака) + if p == Path(__file__).resolve() or (p.parent == ZONE and p.name == "money.py"): continue if p.parent != ZONE: raise SystemExit(f"⛔ покупка из кода вне зоны эксп-22 запрещена: {p}")