diff --git a/docs/experiments/22-tenant-panel.md b/docs/experiments/22-tenant-panel.md
new file mode 100644
index 00000000..fab0c8b9
--- /dev/null
+++ b/docs/experiments/22-tenant-panel.md
@@ -0,0 +1,211 @@
+# 22. Панель жильцов: кто занимает роль черновика и роль редактора
+
+**Полигон-сессия, 08.08.2026.** Исполнение `docs/POLYGON_TENANT_PANEL_SESSION_PROMPT.md`
+(санкция владельца 08.08, D39.117). Зона: `eval/tenant_panel/` + точечные проверки
+`eval/role_topology/` + этот файл + пинг в PROGRESS «Полигон».
+
+> **СТАТУС: ПРЕ-РЕГ Ф0/Ф1 — зафризен этим коммитом ДО первой платной покупки.**
+> Потолки промта: Ф0 ≤$0.40 · Ф1 ≤$1.00 · Ф2 ≤$2.00 · Ф3 ≤$1.60; сумма ≤$5.00.
+> Числа отчёта пере-считываются `eval/tenant_panel/itog22.py`, сторожатся
+> `eval/tenant_panel/verify22.py`; ненулевой код возврата = отчёт не сдаётся.
+
+---
+
+## §0 ПРЕ-РЕГ. Секция Ф0/Ф1 (зафризена до трат)
+
+### 0.1 Вопрос и что НЕ мерится
+
+Топология «дешёвый черновик → дорогой редактор-переписыватель» решена эксп-21 и здесь не
+пере-меряется. Открыт вопрос ЖИЛЬЦОВ обеих ролей: черновиком за всю историю проекта была одна
+модель (`deepseek-v4-flash`), а выбор редактора проверен на паре из двух движков.
+
+Не мерится и не варьируется: **банк как фактор** (наличие/отсутствие — чужой слот, строки 5/36б);
+топология как таковая; контракт редактора (full-regen против диффов — строка 106).
+
+«Правильного ответа» не существует: армы равноправны, движок по паре и по модели не ветвится
+(§0.1 `12-go-style-notes.md`), поэтому любой исход — строки конфига, а не код.
+
+### 0.2 Материал: невиданный zh-срез
+
+Эксп-21 главный zh-замер сделал на 蛊真人, которую его же проба оценила как узнанную моделью
+**4/5** — наравне с положительным контролем. Промт эксп-22 заказывает срез повторно и жёстко.
+
+**Метод поиска (объявляется, потому что «нашёл в вебе» без метода не проверяем):**
+
+1. Площадка отбиралась по механическому критерию — **текст главы отдаётся сервером**. Проверено
+ `curl`: 七猫 и 番茄 отдают JS-каркас без текста; **纵横中文网** (`zongheng.com`) отдаёт
+ `
` с полной главой. Пиратские агрегаторы не рассматривались.
+2. Из блока новинок главной снято 17 книг; у каждой прочитано поле `首发时间` — дату первой
+ публикации печатает сама площадка на странице главы.
+3. Отобрана книга с датой первой публикации **в июле 2026** (то есть заведомо позже любого
+ правдоподобного среза обучения) и жанром, совпадающим с продуктовым (东方玄幻 — то же
+ семейство, что 蛊真人).
+
+**Книга среза:** 《阴阳天帝诀》, 玄幻奇幻 / 东方玄幻, `zongheng.com/detail/1569059`,
+**首发时间 2026-07-13**, на день замера 71 глава / 15万字, 393 суммарных клика.
+
+**Проверка непереведённости** (метод и его границы объявляются вместе):
+* поиск по названию (zh) и транслитерации — совпадений нет;
+* поиск, ограниченный восемью ru/en-площадками переводов (`tl.rulate.ru`, `ranobelib.me`,
+ `ranobes.com`, `ranobehub.org`, `novelupdates.com`, `ficbook.net`, `mlate.ru`, `bllate.org`)
+ — совпадений нет;
+* ⚠ прямой поиск в формах этих площадок механически НЕ подтверждён: `novelupdates` отдаёт 403,
+ `rulate` игнорирует параметр запроса (отдаёт весь каталог, 83 555 переводов), `ranobelib` и
+ `ranobes` — SPA. Это объявленная граница проверки, а не подтверждение;
+* базовая ставка: **393 суммарных клика за 26 дней** — вероятность существования перевода
+ пренебрежима, но это аргумент, а не замер.
+
+**⚠ ГЕЙТ ПРАВ, объявлен ДО отбора единиц.** Срез — взрослая вебновелла: слой насилия плотный (это
+рабочий случай продукта, его меряли все прежние паки), но у ДВУХ якорных провайдеров панели лейбл
+`sexually-explicit` в договоре стоит **FORBIDDEN** (quirks 00, таблица прав: deepseek ToU §3.4(5);
+zai п. g) «Submitting … sexually explicit»). Поэтому из пула единиц сняты **две главы с наибольшей
+плотностью эротических маркеров** (верхний дециль по 18 главам): гл. 1 и гл. 2.
+Честная граница: после снятия остаётся ровно 16 глав на 16 единиц, то есть отбор «из середины
+распределения длин» вырождается в «все оставшиеся». Это ограничение, а не замысел.
+
+**⚠ ОБЪЁМ СРЕЗА ОГРАНИЧЕН ВНЕШНЕ.** Скачано 18 глав (40 008 знаков), после чего площадка
+поставила анти-краулерную капчу на IP; снижение темпа до 3 с, куки живой сессии владельца и
+мобильный хост её не сняли. 16 единиц из 18 глав получены; добор до 71 главы не выполнен.
+
+**Единицы (детерминированный отбор, методика эксп-21 §0):** дедуп по подписи со снятым заголовком
+главы, ключ артефакта — хеш источника (не позиционный индекс), сортировка по (длина, uid).
+16 единиц zh: **2073…2517 знаков**, медиана 2194; **16 разных глав** (у эксп-21 — 16 единиц из
+12 глав, то есть по кластеризации срез чище); максимальная попарная близость **0.069**
+(эксп-21: 0.060). Хеши единиц — `~/books/tenant-panel/manifest.json`.
+
+**Ось переноса en (обязательна):** Kristoff, «Empire of the Dawn» (узнавание 1/5 по эксп-21),
+6 единиц из отбора эксп-21 — 3 страты `fr`, 3 страты `plain`; единица `129b73ad5a` выброшена
+(выходные данные и библиография, а не проза — §8 эксп-21).
+
+**Порог контаминации — объявлен эксп-21 и здесь не двигается: узнавание ≥3/5 = материал
+непригоден.** Проба — узнавание + клоуз по 5 отрывкам (`contam.py`). Две поправки против эксп-21:
+проба идёт **двумя** моделями (`gpt-5.6-luna` для сопоставимости и `deepseek-v4-flash` — боевой
+жилец черновой роли; §4 п.9 эксп-21 оставил это открытым) и с **двумя** опорами — положительный
+контроль 金瓶梅 и книга эксп-21 蛊真人 (там 4/5). Не пройдёт порог — материал меняется на
+резервную книгу 《吞仙葫》 (`detail/1573203`, 首发 2026-07-23), объявленную здесь ДО замера.
+
+### 0.3 Починки эксп-21 §8 — закрыты, проверены исполнением
+
+`eval/tenant_panel/checks21.py`, ненулевой код возврата = покупки запрещены. Все четыре пункта
+списка §8 оказались УЖЕ закрытыми самим эксп-21 (список не был вычеркнут). Проверено запуском, а
+не чтением; где возможно — на том артефакте, из-за которого пункт появился:
+
+| §8 | Чем проверено | Итог |
+|---|---|---|
+| гейт ре-гена ловит и ЦЕЛЕВУЮ письменность | четыре входа с известным ответом + исторический артефакт `bo2-DRAFT-34749d6ccc-r2` (латиница 1.00) | отвергается «выход не на целевом языке» |
+| карта раскладки слепого чтения вне каталога пакетов | `blind_read.KEYS ≠ PACKETS`, карт в каталоге пакетов нет | закрыт |
+| персист обоснований судьи | `absjudge --selfcheck` (три регрессии) + 120/120 боевых клеток прохода abs4 несут текст | закрыт |
+| три лживых докстринга | сверка докстринга с исполняемым кодом (`REPS_PER_ORDER`, `run_draft`, кортеж `JUDGES`) | противоречий нет |
+
+### 0.4 Каcса и фриз — механизм, а не обещание
+
+`eval/tenant_panel/money.py`. Три урока эксп-21, каждый стоил денег:
+
+* **новый тег покупок не попадал в глоб кассы** (трижды; последний раз гейт ложное число
+ СЕРТИФИЦИРОВАЛ) ⇒ теги объявлены в одном месте, и `--selftest` проверяет **по одному тегу за
+ раз**, что каждый даёт кассе фазы и кассе пака ровно свою сумму;
+* **потолки фаз пробивались** ⇒ каждая покупка проходит ДВА проекционных гарда: фазовый и
+ пакетный ($5.00);
+* **покупки шли кодом вне зафризенного дерева** ⇒ покупка отказывает, если файл вызывающего не
+ отслеживается git ИЛИ отличается от закоммиченного. «Фриз ДО покупок фазы» стал механизмом.
+
+Проверено исполнением: 7/7 тегов видны обеими кассами, проекционный гард отказывает при нулевом
+потолке, фриз-гейт отвергает покупку из НЕ-файла.
+
+### 0.5 Ф1 — кандидаты, пороги, прогнозы
+
+**Живой листинг `/models` 08.08** (`role_topology/list_models.py`): deepseek 2 · zai 8 · kimi 12 ·
+gemini 59 · xai 10 · mistral 53; **openai — ReadTimeout, пере-снять до скрина**.
+`gemini-3.1-pro-preview` в листинге ЖИВ — обязательный кандидат Ф3 доступен.
+
+**Ростер (11 моделей, `roster.py`), цены сверены 08.08 по прайс-страницам вендоров.**
+Живая сверка удалась по DeepSeek · Z.AI · Gemini · Kimi (страницы сохранены); **не удалась** по
+OpenAI (Cloudflare 403), xAI (308→403; Wayback свежее эксп-21 не имеет) и Mistral (прайс
+рендерится JS) — там цены перенесены из эксп-21 с датой его сверки, и это объявлено.
+
+Три поправки к `role_topology/prices.py`, найденные живой сверкой:
+* **у Gemini кэш-цена выделена вендором** и в 8–10 раз ниже входной ($0.20 против $2.00 у
+ 3.1-pro; $0.15 против $1.50 у 3.6-flash; $0.025 против $0.25 у flash-lite) — эксп-21 ставил
+ кэш = входу и ЗАВЫШАЛ цену Gemini;
+* **у Z.AI появились `glm-4.7-flash` (бесплатный) и `glm-4.7-flashx` ($0.07/$0.40)** — тир дешевле
+ боевого черновика; в live-`/models` их НЕТ, поэтому в ростер не берутся, а проверяются $0-пробой
+ существования слага;
+* **DeepSeek на прайс-странице 08.08 объявляет ПОВЫШЕНИЕ цен** («significant increase expected») —
+ это бьёт по проекции себестоимости книги и едет с ИТОГом.
+
+Рез по прайсу (промт разрешает при >16 кандидатах; каждому провайдеру оставлен слот): сняты
+`gpt-5.6-terra`, `kimi-k3`, `glm-5.1`, `glm-5.2`, `glm-5-turbo`, `grok-4.5`,
+`mistral-medium-latest` — каждый доминирован одноимённым семейством при той же роли (основания
+пер-модельно в `roster.CUT`).
+
+**Пороги скрина — содержательные, из требований пайплайна, записаны ДО замера** (`screen.py`).
+Порог, не способный отсеять никого, — не порог; но пороги выводятся из требований, а не
+подгоняются под желаемый состав:
+
+| Гейт | Что и почему | Провал |
+|---|---|---|
+| Г1 ФОРМАТ на боевой единице | сервис-преамбула, markdown-заголовки, обрыв — ровно те классы, которые боевой санитайзер считает вердикт-гейтом (D30.3) | нарушение более чем в одной единице из двух |
+| Г2 ЭХО/УТЕЧКА | доля исходной письменности; боевой порог `disposition.go` = 0.15 | любая единица выше 0.15 ЛИБО ханьцзы во ВСЕХ единицах |
+| Г3 ОТКАЗ на чувствительном фрагменте | вебновелла упирается в насилие; тихий отказ на главе N рвёт конвейер | отказ |
+| Г4 ЦЕЛЕВОЙ ЯЗЫК | эксп-21 оплатил и принял английский выход (латиница 0.79) | доля кириллицы среди букв < 0.5 |
+| Г5 ЦЕНА/ЕДИНИЦА | считается из `usage`, не из прайса; роль черновика имеет объявленный порог | draft: > $0.02/ед · editor: > $0.06/ед |
+| Г6 УПРАВЛЯЕМОСТЬ РАЗМЫШЛЕНИЯ | класс, уже стоивший проекту волны (quirks §10) | `finish=length` при пустом выходе |
+
+Урок эксп-20 применяется: механический показатель ≠ качество, поэтому **спорных кандидатов
+СТИЛЬ-осей скрин не решает** — они доводятся до Ф2/Ф3.
+
+**Прогнозы (калибруют удивление; совпадение НЕ цель).**
+
+1. Скрин убьёт **2–4 кандидата из 11**; наиболее вероятные — `kimi-k2.6` (многословность
+ размышления → обрыв), `grok-4.3` (кредиты xAI исчерпаны 07.08), `mistral-large-latest`
+ (преамбула в выходе), `gemini-3.1-flash-lite` (формат).
+2. **Разброс панели ЧЕРНОВИКОВ будет БОЛЬШЕ разброса панели редакторов.** Основание — §16.2
+ эксп-21 (тот же редактор над другим черновиком: 7.66 против 5.91) и §12 (`A_law/B` −0.69, ноль
+ внутри интервала).
+3. **`gemini-3.1-pro` НЕ окажется аутлаером прозы**: гипотеза эксп-04 снята мёртвым поколением и
+ слабым ригом. Прогноз: его перевес над `deepseek-v4-pro` ляжет НИЖЕ шумового пола.
+4. **Победитель черновой роли будет решаться ценой, а не качеством**: прогноз — перевесы
+ черновиков лягут в полосу 1–3 ошибки при поле ≈1.9, то есть правило ничьей D39.117 (самый
+ дешёвый жилец) окажется рабочим, а не декоративным.
+5. **Частота эхо-мины `deepseek-v4-flash` на этой книге — 25–40%** (приор эксп-21: 38% на zh,
+ 0% на en).
+
+⚠ Числа-приоры взяты из эксп-21 для сметы и дизайна, не как ожидания: новый замер их не обязан
+воспроизвести, расхождение = находка.
+
+### 0.6 Смета и план агент-запусков
+
+Смета считается по ФАКТИЧЕСКИМ $/вызов эксп-21 из сырья (медианы по `scr-*` и `bo4-*`),
+масштабированным на длину единицы (2194/1683 = **×1.31**) и на отношение цен вендоров.
+
+| Фаза | Что покупается | Смета | Потолок |
+|---|---|---|---|
+| Ф0 | контаминация 3 книги × 5 отрывков × 2 модели = 30 вызовов; терминолог 1 вызов | **$0.02** | $0.40 |
+| Ф1 | 11 моделей × 2 единицы × 2 роли = 44 вызова | **$0.62** (до $0.80, если Gemini-pro много думает) | $1.00 |
+| Ф2 | ≤5 жильцов + якорь × 22 единицы (черновик) + боевой редактор поверх каждого + шумовой пол | **$1.27** при панели из 5 | $2.00 |
+| Ф3 | ≤5 редакторов × 16 единиц поверх замороженного flash-черновика (клетка dspro переиспользуется из Ф2) | **$1.50** | $1.60 |
+| | **сумма** | **$3.41** | $5.00 |
+
+⚠ **Правило усечения панели Ф2, объявляется ЗАРАНЕЕ.** Если смета не влезает в $2.00, панель
+режется так, чтобы СОХРАНИТЬ ЦЕНОВУЮ ОСЬ: обязательны якорь `deepseek-v4-flash`, самый дешёвый
+прошедший и самый дорогой прошедший; середина добирается по возрастанию цены до упора в потолок.
+Причина: вопрос пака — «покупает ли дорогой черновик качество», и панель, срезанная по цене
+сверху, на него ответить не может. Каждый снятый кандидат печатается с его числами.
+
+**План агент-запусков (кап промта ≤60; 1 запуск = одна судейская сессия суб-агента).**
+
+| Стадия | Пакетов | Текстов в пакете | Запусков |
+|---|---|---|---|
+| Ф2 zh — черновики + редактор поверх каждого | 16 единиц | ≤5 черновиков + ≤5 редактур + декой + пара пола ≈ 13 | 16 |
+| Ф2 en — ось переноса | 6 единиц | ≤5 черновиков + декой ≈ 7 | 3 |
+| Ф3 zh — панель редакторов | 16 единиц | ≤5 редакторов + база + декой + пара пола ≈ 9 | 8 |
+| Довесок `D_/A` на сырье эксп-21 | 16 единиц | 2 текста + декой | 4 |
+| **план** | | | **31** |
+| резерв на сессии, аннулированные декоем | | | 12 |
+| **потолок** | | | **43 из 60** |
+
+### 0.7 Что фризится этим коммитом
+
+`eval/tenant_panel/` целиком (`material.py` · `checks21.py` · `roster.py` · `money.py` ·
+`prompts.py` · `bank.py` · `contam.py` · `screen.py`) + эта секция отчёта. Дальнейшие фазы
+получают свои секции и свои фриз-коммиты; amend фриза запрещён, девиации объявляются в отчёте.
diff --git a/eval/tenant_panel/bank.py b/eval/tenant_panel/bank.py
new file mode 100644
index 00000000..9ac3c093
--- /dev/null
+++ b/eval/tenant_panel/bank.py
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+"""Ф0.5 — БАНК ТЕРМИНОВ СРЕЗА: майнинг кандидатов → терминолог-прогон → ручной канон.
+
+Зачем банк нужен ДО первой панели. Закон-блок D39.104 обязателен во всех армах с переводом или
+редактурой, а метрика ПОКРЫТИЯ КАНОНА — единственный контур, которым эксп-21 поймал, что редактор
+без банка ломает терминологию черновика (0.917 → 0.566). Без банка эксп-22 остался бы без этого
+контура на новой книге.
+
+⚠ ГРАНИЦА, объявляю: это НЕ ось эксперимента. Наличие банка как ФАКТОР (строки 5/36б бэклога) —
+чужой слот и здесь не варьируется: банк собирается один раз и подаётся ВСЕМ армам одинаково.
+
+⚠ «РУЧНОЙ КАНОН ≠ ПОДПИСЬ ВЛАДЕЛЬЦА» (образец D39.47). Терминолог предлагает, сессия правит явные
+промахи, и получившийся банк помечен `signed: false`. Он годен как ЗАКОН внутри замера (все армы
+видят одно) и НЕ годен как продуктовый канон книги.
+
+Запуск: --mine кандидаты и KWIC, $0
+ --ask терминолог-прогон (Ф0, платно)
+ --canon свести ответ + ручные правки в bank.json, $0
+ --show что в банке, $0
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import sys
+from collections import Counter
+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" / "bank_arbitration"))
+
+from dotenv import load_dotenv # noqa: E402
+from openai import OpenAI # noqa: E402
+
+import material as M # noqa: E402
+import money as MONEY # noqa: E402
+import prompts as PR # noqa: E402
+from roster import CANDIDATES # noqa: E402
+
+load_dotenv(REPO / "eval" / ".env")
+
+OUT = MONEY.OUT
+TERMINOLOGIST = REPO / "backend" / "prompts" / "zh-ru" / "terminologist.md"
+# Терминолог — вспомогательный инструмент, не арм. Берётся дешёвая модель с управляемым
+# размышлением: у боевого `deepseek-v4-flash` бэнк-роли ручки эффорта не имеют и упираются в
+# стену (quirks §10 п.10, 4 батча из 5 пустые), а гасить thinking у DeepSeek запрещено.
+TERM_MODEL = "gpt-5.6-luna"
+N_TERMS = 26
+MIN_COUNT = 6
+KWIC_N, KWIC_W = 3, 46
+
+# Служебные и грамматические знаки: n-грамма, начинающаяся или кончающаяся на них, термином не
+# бывает. Список закрытый и объявлен ДО майнинга.
+STOP_EDGE = set("的了是在和有我你他她们不就都而及与也很到说着过又却把被将从对为以之其这那一二三四五"
+ "六七八九十个人上下中大小时年日月来去出可能会要没什么样如此但只还更最")
+STOP_WHOLE = {"自己", "已经", "现在", "眼中", "心中", "一个", "什么", "这样", "那些", "如今",
+ "顿时", "此刻", "随后", "所以", "因为", "如果", "虽然", "而且", "然后", "似乎"}
+
+
+def source_text() -> str:
+ return "\n".join(c["text"] for c in M.chapters())
+
+
+def mine() -> list[tuple[str, int]]:
+ """Кандидаты в термины: частые CJK n-граммы, не поглощённые более длинной n-граммой.
+
+ Метод грубый и объявлен таким: его задача — дать терминологу СПИСОК, решение принимает он
+ плюс ручная правка. Точность майнинга на банк не влияет — влияет полнота.
+ """
+ text = source_text()
+ cnt: Counter[str] = Counter()
+ for n in (4, 3, 2):
+ for m in re.finditer(r"[㐀-鿿]{%d}" % n, text):
+ cnt[m.group(0)] += 1
+ cand = {w: c for w, c in cnt.items()
+ if c >= MIN_COUNT and w not in STOP_WHOLE
+ and w[0] not in STOP_EDGE and w[-1] not in STOP_EDGE}
+ # Поглощение: короткая n-грамма выбрасывается, если длинная её содержит и почти так же часта.
+ out = {}
+ for w, c in sorted(cand.items(), key=lambda kv: (-len(kv[0]), -kv[1])):
+ if any(w in longer and c <= 1.25 * cc for longer, cc in out.items()):
+ continue
+ out[w] = c
+ return sorted(out.items(), key=lambda kv: -kv[1])[:N_TERMS]
+
+
+def kwic(term: str, n: int = KWIC_N) -> list[str]:
+ text = source_text()
+ out, pos = [], 0
+ for _ in range(n):
+ i = text.find(term, pos)
+ if i < 0:
+ break
+ out.append(text[max(0, i - KWIC_W):i + len(term) + KWIC_W].replace("\n", " "))
+ pos = i + len(term) + 200
+ return out
+
+
+def _payload() -> str:
+ rows = []
+ for t, c in mine():
+ rows.append(f"term: {t}\ncount: {c}\n" + "\n".join(f"ctx: {x}" for x in kwic(t)))
+ return "\n\n".join(rows)
+
+
+def cmd_mine() -> None:
+ ms = mine()
+ print(f"кандидатов {len(ms)} (порог частоты {MIN_COUNT}, знаков в срезе {len(source_text()):,})")
+ for t, c in ms:
+ print(f" {t:6s} {c:4d} {kwic(t, 1)[0][:70] if kwic(t, 1) else ''}")
+
+
+def cmd_ask() -> None:
+ core, _few, user = PR.load_template(TERMINOLOGIST)
+ brief = PR.BRIEF_ZH
+ msgs = [{"role": "system", "content": PR.render(core, brief, "", "")},
+ {"role": "user", "content": PR.render(user, brief, _payload(), "")}]
+ led = MONEY.Guarded("Ф0", default_expect=0.02)
+ base, env, *_ = CANDIDATES[TERM_MODEL]
+ cl = OpenAI(api_key=os.environ[env], base_url=base, timeout=600)
+ kw = dict(model=TERM_MODEL, messages=msgs, max_completion_tokens=4000,
+ extra_body={"reasoning_effort": "low"})
+ rec = MONEY.purchase(led, "tp0-bank-terminolog", TERM_MODEL, cl, kw, role="terminologist")
+ if rec.get("skipped"):
+ print("⛔ потолок Ф0 — терминолог не куплен")
+ return
+ print(rec.get("content", "")[:4000])
+ print(f"\nfinish={rec.get('finish')} ${rec.get('cost_usd', 0):.6f} · "
+ f"потрачено Ф0 ${led.spent():.6f}")
+
+
+def _rx(dst: str) -> str:
+ """Регекс канонной формы: слова длиннее 5 знаков усечены под словоизменение."""
+ parts = []
+ for w in dst.split():
+ w = re.escape(w)
+ parts.append(w[:-2] + r"\w*" if len(w) >= 6 else w)
+ return r"\s+".join(parts)
+
+
+# ⚠ РУЧНЫЕ ПРАВКИ КАНОНА. Пусто = терминолог принят как есть. Каждая строка — решение СЕССИИ,
+# не владельца; основания печатаются `--canon` и едут в отчёт.
+MANUAL: dict[str, tuple[str, str]] = {}
+DROP: set[str] = set()
+
+
+def cmd_canon() -> None:
+ f = OUT / "tp0-bank-terminolog.json"
+ if not f.exists():
+ print("нет ответа терминолога — сначала --ask")
+ return
+ ans = json.loads(f.read_text(encoding="utf-8"))["content"]
+ got: dict[str, str] = {}
+ for line in ans.splitlines():
+ if "\t" not in line:
+ continue
+ src, dst = (x.strip() for x in line.split("\t", 1))
+ if src and dst and "TM-NO-DST" not in dst:
+ got[src] = dst
+ terms = {}
+ for src, dst in got.items():
+ if src in DROP:
+ continue
+ if src in MANUAL:
+ dst, why = MANUAL[src]
+ else:
+ why = ""
+ terms[src] = dict(dst=dst, rx=_rx(dst), manual=bool(why), why=why)
+ for src, (dst, why) in MANUAL.items():
+ terms.setdefault(src, dict(dst=dst, rx=_rx(dst), manual=True, why=why))
+ PR.BANK_FILE.write_text(json.dumps(
+ dict(book=M.BOOK["title"], signed=False,
+ note="ручной канон сессии, НЕ подпись владельца (образец D39.47)",
+ model=TERM_MODEL, dropped=sorted(DROP), terms=terms),
+ ensure_ascii=False, indent=1), encoding="utf-8")
+ print(f"банк собран: {len(terms)} терминов · ручных правок {sum(1 for v in terms.values() if v['manual'])}"
+ f" · выброшено {len(DROP)}")
+ cmd_show()
+
+
+def cmd_show() -> None:
+ b = PR.bank()
+ if not b:
+ print("банка нет")
+ return
+ src = source_text()
+ print(f"{'терм':8s}{'канон':34s}{'в срезе':>9s} регекс")
+ for t, v in sorted(b.items(), key=lambda kv: -src.count(kv[0])):
+ print(f"{t:8s}{v['dst']:34s}{src.count(t):9d} {v['rx']}"
+ + (" ← ручная правка" if v.get("manual") else ""))
+
+
+def coverage(source: str, out: str) -> tuple[int, int]:
+ """(попаданий, знаменатель) канонной формы — метрика эксп-21 §16.1, счётная.
+
+ Знаменатель — упоминания термина В ИСХОДНИКЕ; попадания капятся знаменателем, иначе
+ многословный выход набирал бы покрытие повторами.
+ """
+ k = s = 0
+ for t, v in PR.bank().items():
+ n = source.count(t)
+ if n:
+ s += n
+ k += min(len(re.findall(v["rx"], out, re.I)), n)
+ return k, s
+
+
+def selfcheck() -> int:
+ bad = 0
+
+ def chk(n: str, ok: bool, d: str = "") -> None:
+ nonlocal bad
+ bad += not ok
+ print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else ""))
+
+ chk("регекс усекает словоизменение", bool(re.search(_rx("истинная ци"), "истинной ци", re.I)))
+ chk("регекс не ловит чужое слово", not re.search(_rx("апертура"), "аперитив", re.I))
+ chk("регекс держит многословную форму",
+ bool(re.search(_rx("море истинной ци"), "Море истинной ци", re.I)))
+ b = PR.bank()
+ if b:
+ t = next(iter(b))
+ k, s = coverage(source_text(), b[t]["dst"] * 3)
+ chk("покрытие не превышает знаменатель", k <= s, f"{k}/{s}")
+ chk("покрытие пустого выхода = 0", coverage(source_text(), "")[0] == 0)
+ print(f"\n{'БАНК-ОСНАСТКА ЧИСТА' if not bad else f'ПРОВАЛОВ: {bad}'}")
+ return bad
+
+
+if __name__ == "__main__":
+ a = sys.argv[1:] or ["--mine"]
+ fn = {"--mine": cmd_mine, "--ask": cmd_ask, "--canon": cmd_canon, "--show": cmd_show,
+ "--selfcheck": lambda: sys.exit(1 if selfcheck() else 0)}.get(a[0])
+ fn() if fn else print(__doc__)
diff --git a/eval/tenant_panel/checks21.py b/eval/tenant_panel/checks21.py
new file mode 100644
index 00000000..1ff8d95b
--- /dev/null
+++ b/eval/tenant_panel/checks21.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Ф0.3 — ПРОВЕРКА ИСПОЛНЕНИЕМ четырёх починок, заказанных эксп-21 §8. $0.
+
+Промт эксп-22 объявляет их обязательными ДО первой покупки. Список §8 писался как «что надо
+сделать», и часть пунктов эксп-21 закрыл сам, не вычеркнув из списка. Поэтому здесь не чтение
+кода, а ЗАПУСК: каждый пункт проверяется на входе с известным ответом, а где возможно — на том
+самом артефакте, из-за которого пункт появился.
+
+ §8-1 гейт ре-гена черновика обязан ловить и целевую письменность (был принят и оплачен
+ английский выход с долей латиницы 0.79);
+ §8-2 карта раскладки слепого чтения лежит ВНЕ каталога пакетов;
+ §8-3 обоснование судьи персистируется, и голос с ненулевой осью без цитаты отвергается;
+ §8-4 три докстринга, названные лживыми, больше не противоречат исполняемому коду.
+
+Ненулевой код возврата = покупать нельзя.
+"""
+from __future__ import annotations
+
+import io
+import json
+import re
+import sys
+from contextlib import redirect_stdout
+from pathlib import Path
+
+REPO = Path("/home/ubuntu/projects/textmachine")
+ZONE = REPO / "eval" / "role_topology"
+sys.path.insert(0, str(ZONE))
+
+RAW21 = Path.home() / "books" / "role-topology"
+
+BAD = 0
+
+
+def chk(name: str, ok: bool, detail: str = "") -> None:
+ global BAD # noqa: PLW0603
+ BAD += not ok
+ print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + (f" {detail}" if detail else ""))
+
+
+def c1_draft_gate() -> None:
+ """Гейт письменности: пустой · эхо исходника · не целевой язык · годный."""
+ import bakeoff as BO # noqa: PLC0415
+ chk("§8-1 пустой выход отвергнут", bool(BO.draft_reject_reason(" ")))
+ chk("§8-1 эхо исходника отвергнуто",
+ BO.draft_reject_reason("第一章 蛊真人转世重生,方源看着眼前的青茅山,心中冷笑" * 8)
+ == "эхо исходника")
+ chk("§8-1 английский выход отвергнут",
+ BO.draft_reject_reason("The young man looked at the mountain and smiled coldly. " * 8)
+ == "выход не на целевом языке")
+ chk("§8-1 русский выход принят",
+ BO.draft_reject_reason("Юноша посмотрел на гору и холодно усмехнулся. " * 8) == "")
+ # Регрессия на ТОМ САМОМ артефакте: он оплачен и принят прежним гейтом.
+ f = RAW21 / "bo2-DRAFT-34749d6ccc-r2.json"
+ if f.exists():
+ why = BO.draft_reject_reason(json.loads(f.read_text(encoding="utf-8"))["content"])
+ chk("§8-1 исторический артефакт (латиница) отвергается", why != "", f"вердикт: {why!r}")
+ else:
+ chk("§8-1 исторический артефакт доступен", False, f"нет {f}")
+
+
+def c2_blind_map() -> None:
+ """Карта раскладки не должна лежать в каталоге, который отдают читателю."""
+ import blind_read as BR # noqa: PLC0415
+ chk("§8-2 карта вне каталога пакетов", BR.KEYS.resolve() != BR.PACKETS.resolve(),
+ f"пакеты {BR.PACKETS.name} · карта {BR.KEYS.name}")
+ maps = list(BR.KEYS.glob("*-MAP.json")) if BR.KEYS.exists() else []
+ stray = list(BR.PACKETS.glob("*MAP*")) if BR.PACKETS.exists() else []
+ chk("§8-2 карт в каталоге пакетов нет", not stray, f"карт в ключах: {len(maps)}")
+
+
+def c3_judge_reasons() -> None:
+ """Ненулевая ось без цитаты обязана быть отвергнута, и отказ обязан снимать прежний голос."""
+ import absjudge as AJ # noqa: PLC0415
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ rc = AJ.selfcheck()
+ out = buf.getvalue()
+ chk("§8-3 selfcheck рига без провалов", rc == 0, f"провалов {rc}")
+ for probe in ("ненулевая ось без цитаты отвергнута",
+ "голое имя оси без цитаты отвергнуто",
+ "отказ СНЯЛ прежний годный голос"):
+ chk(f"§8-3 регрессия «{probe}»", f"[OK ] {probe}" in out)
+ # Персист: у боевых голосов эксп-21 обоснование обязано быть непустым там, где ось ненулевая.
+ votes = list((RAW21 / "aj-abs4-votes").glob("*.json"))
+ if votes:
+ bad = 0
+ tot = 0
+ for f in votes:
+ for r in json.loads(f.read_text(encoding="utf-8")).values():
+ tot += 1
+ if sum(r.get(a, 0) for a in ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА")) and \
+ not (r.get("why") or "").strip():
+ bad += 1
+ chk("§8-3 боевые голоса несут обоснование", bad == 0, f"{tot - bad}/{tot} клеток с текстом")
+
+
+def c4_docstrings() -> None:
+ """Три докстринга, названные лживыми. Проверяем ПРОТИВОРЕЧИЕ КОДУ, а не наличие слов."""
+ bo = (ZONE / "bakeoff.py").read_text(encoding="utf-8")
+ ju = (ZONE / "judges.py").read_text(encoding="utf-8")
+ doc_bo = bo.split('"""')[1] if '"""' in bo else ""
+ doc_ju = ju.split('"""')[1] if '"""' in ju else ""
+ # (а) «черновик стоит $0» — код черновик ПОКУПАЕТ (`run_draft`) и кладёт цену в экономику.
+ chk("§8-4 докстринг bakeoff не утверждает «черновик стоит $0»",
+ not re.search(r"черновик\s+стоит\s+\$0", doc_bo))
+ chk("§8-4 код черновик действительно покупает", "def run_draft" in bo and "bo2-DRAFT-" in bo)
+ # (б) «вердикт при ≥2 повторах из 3» — REPS_PER_ORDER=1, правило вердикта другое.
+ import bakeoff as BO # noqa: PLC0415
+ claims_2of3 = bool(re.search(r"≥\s*2\s+(?:повтор\w*\s+)?из\s+3", doc_bo))
+ chk("§8-4 докстринг bakeoff не обещает правило «≥2 из 3»", not claims_2of3,
+ f"REPS_PER_ORDER={BO.REPS_PER_ORDER}")
+ # (в) состав судей в докстринге judges.py обязан совпадать с кортежем JUDGES.
+ import judges as J # noqa: PLC0415
+ named = [m for m in J.JUDGES if m in doc_ju]
+ chk("§8-4 докстринг judges называет ровно действующий состав",
+ len(named) == len(J.JUDGES) and "grok-4.3` выпал" in doc_ju,
+ f"{', '.join(J.JUDGES)}")
+
+
+def main() -> int:
+ print("Ф0.3 — ПОЧИНКИ ЭКСП-21 §8, ПРОВЕРКА ИСПОЛНЕНИЕМ\n")
+ c1_draft_gate()
+ c2_blind_map()
+ c3_judge_reasons()
+ c4_docstrings()
+ print(f"\n{'ВСЕ ЧЕТЫРЕ ПУНКТА ЗАКРЫТЫ' if not BAD else f'ОТКРЫТО ПУНКТОВ: {BAD} — ПОКУПКИ ЗАПРЕЩЕНЫ'}")
+ return BAD
+
+
+if __name__ == "__main__":
+ sys.exit(1 if main() else 0)
diff --git a/eval/tenant_panel/contam.py b/eval/tenant_panel/contam.py
new file mode 100644
index 00000000..59c8bb5e
--- /dev/null
+++ b/eval/tenant_panel/contam.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""Ф0.2 — ПРОБА КОНТАМИНАЦИИ СРЕЗА. Методика эксп-21 §0, две поправки.
+
+Метод (не менялся): на одном отрывке задаются ДВА безобидных вопроса —
+ УЗНАВАНИЕ «что это за произведение и кто автор» — ловит знание о книге вообще;
+ КЛОУЗ из отрывка вырезано имя собственное, назвать пропущенное — ловит знание КОНКРЕТИКИ.
+Порог объявлен эксп-21 и здесь не двигается: **узнавание ≥3/5 = материал непригоден**.
+Проба на ПРОДОЛЖЕНИЕ негодна как класс (эксп-21 сняла две редакции: современные модели не
+воспроизводят текст дословно даже для заведомо заученной книги).
+
+ДВЕ ПОПРАВКИ ПРОТИВ ЭКСП-21:
+ * **две модели, а не одна.** §4 п.9 эксп-21 прямо оставил открытым, что контаминация мерена
+ ОДНОЙ моделью, а армы населяют другие. Здесь проба идёт и на `gpt-5.6-luna` (сопоставимость
+ с эксп-21), и на `deepseek-v4-flash` — боевом жильце черновой роли;
+ * **два опорных контроля.** Кроме положительного (金瓶梅, минский канон) добавлена 蛊真人 —
+ книга эксп-21, получившая 4/5. Она превращает мой результат из «ноль» в «ноль ПРОТИВ 4/5 на
+ том же приборе», то есть отвечает на «а работает ли проба» вторым независимым способом.
+
+⚠ Отрывки в репозиторий и в отчёт не попадают: печатаются вердикты и доли.
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import sys
+import time
+from collections import Counter
+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 material as M # noqa: E402
+import money as MONEY # noqa: E402
+from roster import CANDIDATES # noqa: E402
+
+load_dotenv(REPO / "eval" / ".env")
+
+N_PASSAGES = 5
+PRIME_CHARS = 700
+PROBES = ("gpt-5.6-luna", "deepseek-v4-flash")
+
+BOOKS: dict[str, dict] = {
+ "slice22": dict(kind="СРЕЗ ЭКСП-22", lang="zh",
+ titles=["阴阳天帝诀", "yin yang tian di", "инь ян"]),
+ "gu-zhenren": dict(kind="опора: книга эксп-21 (там 4/5)", lang="zh",
+ path=Path.home() / "books" / "gu-zhenren" / "guzhenren-utf8.txt",
+ titles=["蛊真人", "гу чжэнь", "reverend insanity", "гу чжэньжэнь"]),
+ "jinpingmei": dict(kind="ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ", lang="zh",
+ path=Path.home() / "books" / "jinpingmei" / "jinpingmei-ctext-zhs.txt",
+ titles=["金瓶梅", "цзинь пин мэй", "plum in the golden"]),
+}
+
+
+def book_text(name: str) -> str:
+ if name == "slice22":
+ return "\n".join(c["text"] for c in M.chapters())
+ return BOOKS[name]["path"].read_text(encoding="utf-8", errors="ignore")
+
+
+def frequent_names(text: str, top: int = 40) -> list[str]:
+ """Частые CJK-биграммы/триграммы — кандидаты на маскирование.
+
+ ⚠ Порог частоты масштабируется длиной: у эксп-21 он был константой 20 и на срезе в 40k знаков
+ отсекал ВСЕ имена, то есть проба молча осталась бы без клоуза.
+ """
+ need = max(4, len(text) // 4000)
+ cand: Counter[str] = Counter()
+ for n in (3, 2):
+ for m in re.finditer(r"[㐀-鿿]{%d}" % n, text):
+ cand[m.group(0)] += 1
+ return [w for w, c in cand.most_common(top * 4) if c > need][:top]
+
+
+def passages(text: str, names: list[str], n: int) -> list[tuple[str, str]]:
+ body = text[len(text) // 10: -len(text) // 10]
+ out: list[tuple[str, str]] = []
+ for i in range(n * 8):
+ if len(out) >= n:
+ break
+ pos = len(body) * (i + 1) // (n * 8 + 1)
+ chunk = body[pos:pos + PRIME_CHARS]
+ if len(chunk) < PRIME_CHARS:
+ continue
+ hit = next((w for w in names if w in chunk), None)
+ if not hit:
+ continue
+ out.append((chunk.replace(hit, "[???]"), hit))
+ return out
+
+
+SYS = ("Тебе дают отрывок художественного текста, в котором одно имя собственное заменено на "
+ "[???]. Ответь РОВНО двумя строками и ничем больше:\n"
+ "ПРОИЗВЕДЕНИЕ: <название и автор, либо НЕ ЗНАЮ>\n"
+ "ИМЯ: <что стоит вместо [???], либо НЕ ЗНАЮ>\n"
+ "Не пересказывай отрывок и не продолжай его.")
+
+
+def client(model: str) -> OpenAI:
+ base, env, *_ = CANDIDATES[model]
+ return OpenAI(api_key=os.environ[env], base_url=base, timeout=300)
+
+
+def ask(led, model: str, prime: str, tag: str) -> dict:
+ msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": prime}]
+ kw: dict = dict(model=model, messages=msgs)
+ if model in ("gpt-5.6-luna",):
+ kw["max_completion_tokens"] = 400
+ kw["extra_body"] = {"reasoning_effort": "none"}
+ else:
+ # DeepSeek: thinking НЕ гасим (эхо-мина), но эффорт обязателен — иначе стена quirks §10.
+ kw["max_tokens"] = 3000
+ kw["extra_body"] = {"reasoning_effort": "low"}
+ return MONEY.purchase(led, tag, model, client(model), kw, probe="contamination")
+
+
+def parse(answer: str) -> tuple[str, str]:
+ work = name = ""
+ for line in (answer or "").splitlines():
+ if line.upper().startswith("ПРОИЗВЕДЕНИЕ"):
+ work = line.split(":", 1)[-1].strip()
+ elif line.upper().startswith("ИМЯ"):
+ name = line.split(":", 1)[-1].strip()
+ return work, name
+
+
+def main() -> None:
+ dry = "--dry" in sys.argv
+ led = MONEY.Guarded("Ф0", default_expect=0.004)
+ rows = []
+ for book, meta in BOOKS.items():
+ if book != "slice22" and not meta["path"].exists():
+ print(f"{book}: файла нет — пропуск")
+ continue
+ text = book_text(book)
+ ps = passages(text, frequent_names(text), N_PASSAGES)
+ print(f"\n{book} ({len(text):,} знаков) — отрывков {len(ps)} · {meta['kind']}")
+ if dry:
+ continue
+ for model in PROBES:
+ rec_ok = cloze_ok = n = 0
+ for i, (prime, hidden) in enumerate(ps):
+ r = ask(led, model, prime, f"tp0-cont-{book}-{model}-{i}")
+ if r.get("skipped"):
+ print(" ⛔ потолок Ф0 — стоп")
+ return
+ work, name = parse(r.get("content", ""))
+ r_hit = any(t in work.lower() for t in meta["titles"])
+ c_hit = bool(name) and "НЕ ЗНАЮ" not in name.upper() and \
+ (hidden in name or name in hidden)
+ rec_ok += r_hit
+ cloze_ok += c_hit
+ n += 1
+ time.sleep(0.2)
+ rows.append(dict(book=book, model=model, n=n, recognized=rec_ok, cloze=cloze_ok,
+ kind=meta["kind"]))
+ print(f" {model:22s} узнавание {rec_ok}/{n} · клоуз {cloze_ok}/{n}")
+ if dry:
+ return
+ print(f"\n{'книга':14s}{'модель':22s}{'узнано':>8s}{'клоуз':>7s} вердикт (порог: узнавание ≥3/5)")
+ print("-" * 86)
+ for r in rows:
+ v = ("НЕПРИГОДЕН" if r["recognized"] * 5 >= 3 * max(1, r["n"]) else
+ "знает конкретику" if r["cloze"] >= 2 else "следов нет")
+ print(f"{r['book']:14s}{r['model']:22s}{r['recognized']:>4d}/{r['n']:<3d}"
+ f"{r['cloze']:>4d}/{r['n']:<2d} {v}")
+ (MONEY.OUT / "contamination.json").write_text(
+ json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
+ print(f"\nпотрачено Ф0 ${led.spent():.6f} из ${led.ceiling:.2f}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/eval/tenant_panel/material.py b/eval/tenant_panel/material.py
new file mode 100644
index 00000000..726801c2
--- /dev/null
+++ b/eval/tenant_panel/material.py
@@ -0,0 +1,294 @@
+#!/usr/bin/env python3
+"""Ф0.2 — МАТЕРИАЛ ЭКСП-22: невиданный zh-срез + ось переноса en. $0 (кроме проб контаминации).
+
+Почему новая книга. Эксп-21 мерил главный zh-контраст на 蛊真人, которую собственная проба
+оценила как узнанную моделью 4/5 — наравне с положительным контролем. Внутреннюю валидность
+сравнения армов это не рушит (все армы видят один материал), но перенос ВЕЛИЧИН — рушит, и
+строка 55 бэклога осталась открытой. Промт эксп-22 заказывает срез повторно и жёстко.
+
+Метод поиска (объявляется здесь, потому что «нашёл в вебе» без метода не проверяемо):
+ 1. Площадка выбрана по критерию «текст главы отдаётся сервером» — проверено curl'ом:
+ 七猫/番茄 отдают Vue-каркас без текста, 纵横中文网 (zongheng.com, Baidu) отдаёт
+ `
` с полной главой. Пиратские агрегаторы не рассматривались.
+ 2. Из блока новинок главной страницы взяты 17 книг, у каждой снято поле `首发时间`
+ (дата ПЕРВОЙ публикации, печатается самой площадкой на странице главы).
+ 3. Отобрана книга с `首发时间` в июле 2026 — то есть заведомо ПОСЛЕ любого правдоподобного
+ среза обучения — и с жанром, совпадающим с продуктовым (东方玄幻 = то же семейство, что
+ 蛊真人), и с объёмом, которого хватает на 16 единиц с запасом на дедуп.
+ 4. Непереведённость проверяется поиском по ru-площадкам (`--check-ru`), контаминация —
+ пробой узнавания/клоуза (`contam.py`), порог эксп-21: узнавание ≥3/5 = материал непригоден.
+
+⚠ Текст книги в репозиторий и в отчёт НЕ попадает: здесь только метрики и хеши.
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+REPO = Path("/home/ubuntu/projects/textmachine")
+sys.path.insert(0, str(REPO / "eval" / "role_topology"))
+
+OUT = Path.home() / "books" / "tenant-panel"
+SRC = OUT / "zh-source"
+UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36")
+
+# Книга среза. `first_chapter` снят со страницы книги, дальше идём по ссылке «следующая глава» —
+# нумерация id не гарантирована последовательной, поэтому обход по ссылкам, а не по арифметике.
+BOOK = dict(
+ site="zongheng.com", book_id=1569059, first_chapter=94741845,
+ title="阴阳天帝诀", author="", genre="玄幻奇幻 / 东方玄幻",
+ first_pub="2026-07-13", # поле 首发时间 со страницы главы
+ url="https://www.zongheng.com/detail/1569059",
+)
+# Резерв на случай провала пробы контаминации у основной книги (объявляется ДО замера, чтобы
+# выбор не оказался подгонкой под результат).
+BACKUP = dict(
+ site="zongheng.com", book_id=1573203, first_chapter=0,
+ title="吞仙葫", genre="玄幻奇幻 / 东方玄幻", first_pub="2026-07-23",
+ url="https://www.zongheng.com/detail/1573203",
+)
+
+TARGET_CHARS = 1700 # медиана источника единицы эксп-21 — 1679 знаков
+MIN_CHARS, MAX_CHARS = 1100, 2600
+N_ZH, N_EN = 16, 6
+
+# ⚠ ГЕЙТ ToS, объявлен ДО отбора единиц. Срез — взрослая вебновелла: слой насилия плотный (это
+# рабочий случай продукта и его меряют все прежние паки), но у ДВУХ якорных провайдеров панели
+# лейбл `sexually-explicit` в договоре стоит FORBIDDEN (quirks 00, таблица прав: deepseek §3.4(5),
+# zai п. g) «Submitting … sexually explicit»). Отправлять им эротически плотную главу — нарушение
+# ПРАВА, а не вопрос поведения модели. Поэтому из пула единиц снимаются ДВЕ главы с наибольшей
+# плотностью эротических маркеров (верхний дециль по 18 главам); остаток и есть материал.
+# Честная граница: после снятия остаётся ровно 16 глав на 16 единиц, то есть выбор «из середины
+# длин» вырождается в «все оставшиеся». Это ограничение, а не замысел.
+SE_MARKERS = ("炉鼎", "双修", "凌辱", "玉体", "娇躯", "呻吟", "裸", "私处", "交合",
+ "情欲", "欲望", "媚", "胸")
+SE_DROP_TOP = 2
+
+
+def _get(url: str) -> str:
+ r = subprocess.run(["curl", "-s", "--compressed", "-m", "60", "-A", UA, url], # noqa: S603, S607
+ capture_output=True, text=True)
+ return r.stdout
+
+
+def _get_polite(url: str, tries: int = 4) -> str:
+ """Площадка ставит капчу на быстрый обход. Отступаем и ждём, а не долбим: это чужой сервер.
+
+ Поймано исполнением: при паузе 0.4 с обход умер на 19-й главе редиректом на `/captcha`.
+ """
+ for k in range(tries):
+ html = _get(url)
+ if "captcha" not in html[:200].lower():
+ return html
+ wait = 20 * (k + 1)
+ print(f" капча — пауза {wait} с")
+ time.sleep(wait)
+ return ""
+
+
+def _chapter_text(html: str) -> tuple[str, str]:
+ """(заголовок, текст) главы. Пусто — страница не глава."""
+ m = re.search(r'
]*>(.*?)
', html, re.S)
+ if not m:
+ return "", ""
+ body = re.sub(r"
", "\n", m.group(1))
+ body = re.sub(r"", "\n", body)
+ body = re.sub(r"<[^>]+>", "", body)
+ body = re.sub(r" ", " ", body)
+ lines = [ln.strip() for ln in body.split("\n")]
+ t = re.sub(r"\n{2,}", "\n", "\n".join(ln for ln in lines if ln)).strip()
+ h = re.search(r"
\s*([^<_]+)", html)
+ return (h.group(1).strip() if h else ""), t
+
+
+def crawl(limit: int = 200) -> None:
+ """Скачать главы по цепочке «следующая глава». Идемпотентно: скачанное не перекачивается."""
+ SRC.mkdir(parents=True, exist_ok=True)
+ cid, n = str(BOOK["first_chapter"]), 0
+ while cid and cid != "0" and n < limit:
+ f = SRC / f"{n + 1:04d}-{cid}.json"
+ if f.exists():
+ nxt = json.loads(f.read_text(encoding="utf-8"))["next"]
+ else:
+ html = _get_polite(f"https://read.zongheng.com/chapter/{BOOK['book_id']}/{cid}.html")
+ title, text = _chapter_text(html)
+ if not text:
+ print(f" глава {cid}: текста нет — стоп")
+ break
+ m = re.search(rf'href="/chapter/{BOOK["book_id"]}/(\d+)\.html\?"[^>]*>\s*下一章', html)
+ nxt = m.group(1) if m else "0"
+ f.write_text(json.dumps(dict(idx=n + 1, cid=cid, title=title, next=nxt, text=text),
+ ensure_ascii=False), encoding="utf-8")
+ time.sleep(3.0)
+ n += 1
+ cid = nxt
+ tot = sum(len(json.loads(p.read_text(encoding="utf-8"))["text"]) for p in SRC.glob("*.json"))
+ print(f"глав на диске {len(list(SRC.glob('*.json')))} · знаков {tot:,}")
+
+
+def chapters(all_: bool = False) -> list[dict]:
+ """Главы среза. По умолчанию — БЕЗ снятых гейтом ToS (см. SE_DROP_TOP)."""
+ chs = [json.loads(p.read_text(encoding="utf-8")) for p in sorted(SRC.glob("*.json"))]
+ if all_ or not chs:
+ return chs
+ return [c for c in chs if c["idx"] not in se_dropped(chs)]
+
+
+def se_density(text: str) -> int:
+ return sum(text.count(k) for k in SE_MARKERS)
+
+
+def se_dropped(chs: list[dict] | None = None) -> set[int]:
+ """Индексы глав, снятых гейтом прав: верхние SE_DROP_TOP по плотности маркеров."""
+ chs = chs if chs is not None else chapters(all_=True)
+ order = sorted(chs, key=lambda c: (-se_density(c["text"]) / max(1, len(c["text"])), c["idx"]))
+ return {c["idx"] for c in order[:SE_DROP_TOP]}
+
+
+def uid_of(source: str) -> str:
+ return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10] # noqa: S324
+
+
+_RE_CHAPTER = re.compile(r"^\s*第[零一二两三四五六七八九十百千\d]+[节章回]\s*[::]?\s*")
+
+
+def _dedup_sig(source: str) -> str:
+ """Подпись близких дублей — как в эксп-21: снимаются заголовок главы и все пробелы."""
+ s = _RE_CHAPTER.sub("", source.strip())
+ return hashlib.sha1("".join(s.split()).encode("utf-8")).hexdigest() # noqa: S324
+
+
+def chunks() -> list[dict]:
+ """Абзацные чанки ~1700 знаков. Абзац не рвётся: боевая сегментация тоже держит абзац.
+
+ ⚠ Хвост главы КОРОЧЕ порога не выбрасывается, а прирастает к предыдущему чанку той же главы.
+ Первая версия его роняла и теряла ~30% текста книги — то есть меняла материал молча.
+ """
+ out = []
+ for ch in chapters():
+ made: list[dict] = []
+ buf: list[str] = []
+ size = 0
+ for para in ch["text"].split("\n"):
+ para = para.strip()
+ if not para:
+ continue
+ buf.append(para)
+ size += len(para)
+ if size >= TARGET_CHARS:
+ made.append(dict(text="\n".join(buf), chapter=ch["idx"]))
+ buf, size = [], 0
+ if size:
+ tail = "\n".join(buf)
+ if size >= MIN_CHARS or not made:
+ made.append(dict(text=tail, chapter=ch["idx"]))
+ elif len(made[-1]["text"]) + size <= MAX_CHARS:
+ made[-1]["text"] += "\n" + tail
+ else:
+ made.append(dict(text=tail, chapter=ch["idx"]))
+ out += made
+ return [c for c in out if MIN_CHARS <= len(c["text"]) <= MAX_CHARS]
+
+
+def units_zh(n: int = N_ZH) -> list[dict]:
+ """N единиц: дедуп по подписи, ключ — хеш источника, отбор из СЕРЕДИНЫ длин (эксп-21 §0).
+
+ Из середины — чтобы единицы были боевого размера и при этом не выбросами; ключ сортировки
+ (длина, uid), а не одна длина: при равной длине порядок иначе решает обход множества.
+ """
+ uniq: dict[str, dict] = {}
+ seen: set[str] = set()
+ for c in chunks():
+ sig = _dedup_sig(c["text"])
+ if sig in seen:
+ continue
+ seen.add(sig)
+ uniq.setdefault(uid_of(c["text"]), dict(source=c["text"], uid=uid_of(c["text"]),
+ chapter=c["chapter"]))
+ keys = sorted(uniq, key=lambda k: (len(uniq[k]["source"]), k))
+ lo = (len(keys) - n) // 2
+ return [uniq[k] for k in keys[lo:lo + n]]
+
+
+# ⚠ Единица `129b73ad5a` выброшена по §8 эксп-21: это выходные данные и библиография, а не проза.
+EN_DROP = {"129b73ad5a"}
+
+
+def units_en(n: int = N_EN) -> list[dict]:
+ """Ось переноса: единицы пары en из эксп-21, минус выброшенная библиография.
+
+ Берутся ПЕРВЫЕ n по стабильному порядку (страта, длина, uid) — набор объявлен здесь, чтобы
+ «≥6» не превратилось в выбор после замера.
+ """
+ import pair_en as PE # noqa: PLC0415
+ us = [u for u in PE.units() if u["uid"] not in EN_DROP]
+ us.sort(key=lambda u: (u["stratum"], len(u["source"]), u["uid"]))
+ fr = [u for u in us if u["stratum"] == "fr"][: n // 2]
+ pl = [u for u in us if u["stratum"] == "plain"][: n - n // 2]
+ return fr + pl
+
+
+def manifest() -> dict:
+ zs, es = units_zh(), units_en()
+ return dict(
+ book={k: v for k, v in BOOK.items() if k != "first_chapter"},
+ zh=dict(n=len(zs), chars=[len(u["source"]) for u in zs],
+ uids=[u["uid"] for u in zs], chapters=[u["chapter"] for u in zs],
+ sha=[hashlib.sha256(u["source"].encode()).hexdigest()[:16] for u in zs]),
+ en=dict(n=len(es), uids=[u["uid"] for u in es],
+ strata=[u["stratum"] for u in es],
+ sha=[hashlib.sha256(u["source"].encode()).hexdigest()[:16] for u in es]),
+ )
+
+
+def _pairwise_max_sim(us: list[dict]) -> float:
+ """Максимальная попарная близость отобранных единиц — контроль дедупа (эксп-21 дал 0.060).
+
+ ⚠ `ratio`, а не `quick_ratio`: последний игнорирует порядок и на CJK даёт 0.6+ у заведомо
+ разных глав (общие служебные иероглифы), то есть с числом эксп-21 несравним.
+ """
+ import difflib # noqa: PLC0415
+ best = 0.0
+ for i, a in enumerate(us):
+ for b in us[i + 1:]:
+ best = max(best, difflib.SequenceMatcher(None, a["source"], b["source"]).ratio())
+ return best
+
+
+def cmd_units() -> None:
+ zs, es = units_zh(), units_en()
+ allch = chapters(all_=True)
+ drop = se_dropped(allch)
+ print(f"КНИГА {BOOK['title']} · {BOOK['genre']} · 首发 {BOOK['first_pub']} · {BOOK['url']}")
+ print(f"глав скачано {len(allch)} · снято гейтом прав {sorted(drop)} "
+ f"(плотность эротических маркеров: "
+ f"{', '.join(f'гл.{c["idx"]}={se_density(c["text"])}' for c in allch if c['idx'] in drop)})")
+ print(f"главы в пуле {len(chapters())} · чанков {len(chunks())}")
+ print(f"\nzh-единиц {len(zs)} · знаков "
+ f"{min(len(u['source']) for u in zs)}…{max(len(u['source']) for u in zs)} "
+ f"(медиана {sorted(len(u['source']) for u in zs)[len(zs) // 2]})")
+ print(f"глав-источников {len(set(u['chapter'] for u in zs))} "
+ f"· макс. попарная близость {_pairwise_max_sim(zs):.3f}")
+ for u in zs:
+ print(f" {u['uid']} гл.{u['chapter']:3d} {len(u['source']):5d} зн")
+ print(f"\nen-единиц {len(es)}: " + " · ".join(f"{u['uid']}({u['stratum']})" for u in es))
+ (OUT / "manifest.json").write_text(json.dumps(manifest(), ensure_ascii=False, indent=1),
+ encoding="utf-8")
+ print(f"\nманифест → {OUT / 'manifest.json'} (хеши единиц — во фриз пре-рега)")
+
+
+if __name__ == "__main__":
+ OUT.mkdir(parents=True, exist_ok=True)
+ a = sys.argv[1:] or ["--units"]
+ if a[0] == "--crawl":
+ crawl()
+ elif a[0] == "--units":
+ cmd_units()
+ else:
+ print(__doc__)
diff --git a/eval/tenant_panel/money.py b/eval/tenant_panel/money.py
new file mode 100644
index 00000000..a512596e
--- /dev/null
+++ b/eval/tenant_panel/money.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""КАССА ЭКСП-22: потолки фаз + потолок пака + механический фриз-гейт. $0.
+
+Три урока эксп-21, каждый стоил денег, и все три чинятся ЗДЕСЬ, а не дисциплиной:
+ 1. новый тег покупок не попадал в глоб кассы, и фаза «стоила» ноль — трижды, последний раз
+ гейт ЭТО СЕРТИФИЦИРОВАЛ (§17.4). ⇒ теги объявлены в одном месте, и `selftest` проверяет,
+ что КАЖДЫЙ тег виден своей кассой и кассой пака;
+ 2. потолки фаз промта пробивались, потому что касса кода охраняла другие границы. ⇒ каждая
+ покупка проходит ДВА гарда: фазовый и пакетный, оба проекционные;
+ 3. $2.57 из $2.62 потрачены кодом вне зафризенного дерева. ⇒ покупка отказывает, если файл
+ вызывающего не отслеживается git или отличается от закоммиченного: «фриз ДО покупок фазы»
+ становится механизмом, а не обещанием.
+
+Обход — только `TM_ALLOW_UNFROZEN=1` в окружении: громко, осознанно и видно в истории.
+"""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from functools import lru_cache
+from pathlib import Path
+
+REPO = Path("/home/ubuntu/projects/textmachine")
+ZONE = Path(__file__).resolve().parent
+sys.path.insert(0, str(REPO / "eval" / "role_topology"))
+
+import buy as BUY # noqa: E402
+
+OUT = Path.home() / "books" / "tenant-panel"
+OUT.mkdir(parents=True, exist_ok=True)
+BUY.OUT = OUT # сырьё пака — своё, не эксп-21
+
+# Теги покупок по фазам. ⚠ Заводишь новый тег — вписываешь СЮДА тем же коммитом: ровно этого
+# не сделали трижды в эксп-21, и расход фазы печатался заниженным.
+TAGS = {
+ "Ф0": ("tp0-cont-*.json", "tp0-bank-*.json"),
+ "Ф1": ("tp1-scr-*.json",),
+ "Ф2": ("tp2-draft-*.json", "tp2-edit-*.json", "tp2-floor-*.json"),
+ "Ф3": ("tp3-ed-*.json",),
+}
+CEILINGS = {"Ф0": 0.40, "Ф1": 1.00, "Ф2": 2.00, "Ф3": 1.60}
+PACK_CEILING = 5.00
+
+
+class Pack:
+ """Пакетный потолок поверх фазового. Дублирует утиный интерфейс `buy.Ledger`."""
+
+ def __init__(self) -> None:
+ self.name, self.ceiling = "ПАК", PACK_CEILING
+ self.prefixes = tuple(p for ps in TAGS.values() for p in ps)
+ self._led = BUY.Ledger("ПАК", PACK_CEILING, self.prefixes, default_expect=0.05)
+
+ def spent(self) -> float:
+ return self._led.spent()
+
+ def expect(self) -> float:
+ self._led.spent()
+ return self._led.expect()
+
+ def afford(self) -> tuple[bool, str]:
+ return self._led.afford()
+
+
+class Guarded:
+ """Фазовая касса, которая не пускает покупку, пробивающую ЛЮБОЙ из двух потолков."""
+
+ def __init__(self, phase: str, default_expect: float = 0.02) -> None:
+ self.phase = phase
+ self.name = f"{phase} эксп-22"
+ self.ceiling = CEILINGS[phase]
+ self._led = BUY.Ledger(self.name, self.ceiling, TAGS[phase], default_expect=default_expect)
+ self._pack = Pack()
+
+ def spent(self) -> float:
+ return self._led.spent()
+
+ def expect(self) -> float:
+ self._led.spent()
+ return self._led.expect()
+
+ def afford(self) -> tuple[bool, str]:
+ ok, why = self._led.afford()
+ if not ok:
+ return ok, why
+ return self._pack.afford()
+
+
+@lru_cache(maxsize=64)
+def _git_clean(path: str) -> tuple[bool, str]:
+ """Файл отслеживается git и совпадает с закоммиченным? Это и есть проверка фриза."""
+ rel = str(Path(path).resolve().relative_to(REPO))
+ r = subprocess.run(["git", "-C", str(REPO), "ls-files", "--error-unmatch", rel], # noqa: S603, S607
+ capture_output=True, text=True)
+ if r.returncode:
+ return False, f"файл не в git: {rel}"
+ d = subprocess.run(["git", "-C", str(REPO), "diff", "--quiet", "HEAD", "--", rel]) # noqa: S603, S607
+ if d.returncode:
+ return False, f"файл отличается от закоммиченного: {rel}"
+ return True, ""
+
+
+def _refuse_unfrozen() -> None:
+ if os.environ.get("TM_ALLOW_UNFROZEN") == "1":
+ print("⚠ ФРИЗ-ГЕЙТ ОТКЛЮЧЁН (TM_ALLOW_UNFROZEN=1) — покупка вне фриза")
+ return
+ import inspect # noqa: PLC0415
+ for fr in inspect.stack()[1:]:
+ 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",):
+ continue
+ if p.parent != ZONE:
+ raise SystemExit(f"⛔ покупка из кода вне зоны эксп-22 запрещена: {p}")
+ ok, why = _git_clean(str(p))
+ if not ok:
+ raise SystemExit(f"⛔ ФРИЗ НЕ ВЗЯТ: {why}\n"
+ " зафризь фазу коммитом ДО её покупок (промт, §ЗАКОН)")
+ return
+ raise SystemExit("⛔ вызывающий файл не определён")
+
+
+def purchase(led: Guarded, tag: str, model: str, client, kw: dict, **extra) -> dict:
+ """Один платный вызов. Кэш → фриз-гейт → проекционный гард → покупка."""
+ f = OUT / f"{tag}.json"
+ if f.exists():
+ return json.loads(f.read_text(encoding="utf-8"))
+ if not any(tag.startswith(p.split("*")[0]) for p in TAGS[led.phase]):
+ raise SystemExit(f"⛔ тег {tag!r} не покрыт глобом кассы {led.phase} — впиши в TAGS")
+ _refuse_unfrozen()
+ return BUY.purchase(led, tag, model, client, kw, **extra)
+
+
+def report() -> None:
+ pack = Pack()
+ print(f"{'фаза':6s}{'потрачено':>12s}{'потолок':>10s} теги")
+ for ph in ("Ф0", "Ф1", "Ф2", "Ф3"):
+ g = Guarded(ph)
+ print(f"{ph:6s}{g.spent():12.6f}{g.ceiling:10.2f} {' '.join(TAGS[ph])}")
+ print(f"{'ПАК':6s}{pack.spent():12.6f}{PACK_CEILING:10.2f}")
+
+
+def selftest() -> int:
+ """Проверка кассы НА СВОИХ ТЕГАХ до покупок: каждый тег виден и фазой, и паком."""
+ bad = 0
+ # ⚠ ПО ОДНОМУ ТЕГУ ЗА РАЗ. Если писать все пробы сразу, `spent() > 0` у последней кассы
+ # доказывается ЧУЖИМ тегом — ровно та слепота глоба, которая в эксп-21 трижды дала
+ # заниженный расход фазы.
+ base_pack = Pack().spent()
+ for ph, globs in TAGS.items():
+ base_ph = Guarded(ph).spent()
+ for g in globs:
+ stem = g.split("*")[0] + "SELFTEST"
+ f = OUT / f"{stem}.json"
+ f.write_text(json.dumps(dict(tag=stem, cost_usd=0.011)), encoding="utf-8")
+ try:
+ d_ph = Guarded(ph).spent() - base_ph
+ d_pack = Pack().spent() - base_pack
+ ok = abs(d_ph - 0.011) < 1e-9 and abs(d_pack - 0.011) < 1e-9
+ bad += not ok
+ print(f"[{'OK ' if ok else 'ПРОВАЛ'}] тег {stem} даёт +{d_ph:.3f} кассе {ph} "
+ f"и +{d_pack:.3f} кассе пака")
+ finally:
+ f.unlink()
+ # Проекционный гард обязан ОТКАЗЫВАТЬ, когда ожидание пробивает потолок.
+ g = Guarded("Ф0")
+ g._led.ceiling = 0.0 # noqa: SLF001
+ ok, why = g.afford()
+ bad += ok
+ print(f"[{'OK ' if not ok else 'ПРОВАЛ'}] проекционный гард отказывает при нулевом потолке")
+ # Фриз-гейт: НЕ-файл (встроенный питон) обязан быть отвергнут.
+ try:
+ exec(compile("import money; money._refuse_unfrozen()", "", "exec"), # noqa: S102
+ {"__name__": "x"})
+ print("[ПРОВАЛ] фриз-гейт пропустил покупку из НЕ-файла")
+ bad += 1
+ except SystemExit:
+ print("[OK ] фриз-гейт отвергает покупку из НЕ-файла")
+ print(f"\n{'КАССА ГОДНА' if not bad else f'ПРОВАЛОВ: {bad}'}")
+ return bad
+
+
+if __name__ == "__main__":
+ a = sys.argv[1:] or ["--report"]
+ if a[0] == "--selftest":
+ sys.exit(1 if selftest() else 0)
+ report()
diff --git a/eval/tenant_panel/prompts.py b/eval/tenant_panel/prompts.py
new file mode 100644
index 00000000..4bc5c747
--- /dev/null
+++ b/eval/tenant_panel/prompts.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+"""СБОРКА СООБЩЕНИЙ ЭКСП-22: боевые промты + бриф МОЕЙ книги + блоки банка. $0.
+
+Почему свой модуль, а не `inject_probe.render_translator`. Тот прошит книгой 蛊真人: заголовок,
+жанр и транскрипция лежат КОНСТАНТАМИ в коде. Для эксп-22 книга другая, и вся книго-специфика
+обязана быть данными (§0.1 общности: «книжный термин в общем пар-слое = утечка»). Здесь брифы —
+данные, промты — боевые файлы `backend/prompts/`, а рендер побайтно повторяет `render.go`.
+
+Что берётся ДОСЛОВНО и почему:
+ * `translator.md` / `editor.md` — боевые, из `backend/prompts/zh-ru/` (сравнение идёт с тем, что
+ реально поедет в прод);
+ * `HEADER_LAW_PLAIN` — константа эксп-21, байт-идентичная движковому `editor_header`, БЕЗ
+ оговорки области: закон-блок пинится промтом эксп-22 (D39.104);
+ * блок ГЛОССАРИЯ переводчика (банкнота на переводческом проходе) — формат `injection.txt`;
+ * пара en идёт пар-пакетом `role_topology/prompts/en-ru/` — тем же, что мерил эксп-21.
+"""
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+REPO = Path("/home/ubuntu/projects/textmachine")
+sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
+
+import editor_wire_probe as P # noqa: E402
+
+RT = REPO / "eval" / "role_topology"
+BATTLE = REPO / "backend" / "prompts" / "zh-ru"
+PAIR_EN = RT / "prompts" / "en-ru"
+USER_SEP = "\n---USER---\n"
+FEWSHOT_SEP = "\n---FEWSHOT---\n"
+BANK_FILE = Path.home() / "books" / "tenant-panel" / "bank.json"
+
+# Бриф КНИГИ СРЕЗА — данные, не код. Поля те же, что у движка (`render.go` Render).
+BRIEF_ZH = {"source_lang": "zh", "target_lang": "ru", "genre": "вебновелла",
+ "audience": "взрослые читатели вебновелл", "title": "阴阳天帝诀", "venuti": "0.60",
+ "honorifics": "keep", "transcription": "palladius", "footnotes": "minimal"}
+BRIEF_EN = {"source_lang": "en", "target_lang": "ru", "genre": "тёмное фэнтези",
+ "audience": "взрослые читатели фэнтези", "title": "Empire of the Dawn",
+ "venuti": "0.60", "honorifics": "keep", "transcription": "practical",
+ "footnotes": "minimal"}
+
+GLOSSARY_HEADER = ("ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; "
+ "строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):")
+
+
+def render(tpl: str, brief: dict, text: str, draft: str) -> str:
+ """Зеркало `render.go` Render: ОДИН проход, подставленные значения не пере-сканируются."""
+ vals = dict(brief, text=text, draft=draft)
+ out, rest = [], tpl
+ while True:
+ i = rest.find("{{")
+ if i < 0:
+ out.append(rest)
+ return "".join(out)
+ j = rest.find("}}", i)
+ if j < 0:
+ out.append(rest)
+ return "".join(out)
+ out.append(rest[:i])
+ out.append(str(vals.get(rest[i + 2:j].strip(), rest[i:j + 2])))
+ rest = rest[j + 2:]
+
+
+def load_template(path: Path) -> tuple[str, str, str]:
+ """(системная часть без FEWSHOT, блок FEWSHOT, пользовательская часть).
+
+ FEWSHOT дропается: в проде `stages[edit].few_shot: false`, и эксп-19/20/21 его тоже дропали —
+ иначе база сравнения не боевая.
+ """
+ canon = P.strip_comments(path.read_text(encoding="utf-8"))
+ sys_part, user = canon.split(USER_SEP, 1)
+ head = sys_part.split(FEWSHOT_SEP, 1)
+ return head[0].strip(), (head[1].strip() if len(head) == 2 else ""), user.strip()
+
+
+def bank() -> dict:
+ """Банк среза: {термин исходника: {"dst": канон, "rx": регекс формы}}. Пусто — банка нет."""
+ return json.loads(BANK_FILE.read_text(encoding="utf-8"))["terms"] if BANK_FILE.exists() else {}
+
+
+def terms_in(src: str) -> list[str]:
+ """Термы банка, реально встреченные В ЭТОЙ единице — так инъектирует боевой путь (D39.104)."""
+ return [t for t in bank() if t in src]
+
+
+def glossary_block(terms: list[str]) -> str | None:
+ """Банкнота переводческого прохода: формат `internal/lang/data/injection.txt`."""
+ b = bank()
+ if not terms:
+ return None
+ return GLOSSARY_HEADER + "\n" + "\n".join(f"{t} → {b[t]['dst']}" for t in terms)
+
+
+def law_block(terms: list[str]) -> str | None:
+ """Закон-блок редактора: заголовок эксп-21 БЕЗ оговорки + строки `- src → «dst»`."""
+ b = bank()
+ if not terms:
+ return None
+ return P.HEADER_LAW_PLAIN + "\n" + "\n".join(f"- {t} → «{b[t]['dst']}»" for t in terms)
+
+
+def translator_msgs(src: str, block: str | None, en: bool = False) -> list[dict]:
+ core, _few, user = load_template(PAIR_EN / "translator.md" if en else BATTLE / "translator.md")
+ brief = BRIEF_EN if en else BRIEF_ZH
+ m = [{"role": "system", "content": render(core, brief, src, "")}]
+ if block:
+ m.append({"role": "system", "content": block})
+ m.append({"role": "user", "content": render(user, brief, src, "")})
+ return m
+
+
+def editor_msgs(src: str, draft: str, block: str | None, en: bool = False) -> list[dict]:
+ core, _few, user = load_template(PAIR_EN / "editor.md" if en else BATTLE / "editor.md")
+ brief = BRIEF_EN if en else BRIEF_ZH
+ m = [{"role": "system", "content": render(core, brief, src, draft)}]
+ if block:
+ m.append({"role": "system", "content": block})
+ m.append({"role": "user", "content": render(user, brief, src, draft)})
+ return m
+
+
+def selfcheck() -> int:
+ """Проверка исполнением: плейсхолдеров не осталось, книга подставлена, блоки различаются."""
+ bad = 0
+
+ def chk(n: str, ok: bool, d: str = "") -> None:
+ nonlocal bad
+ bad += not ok
+ print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else ""))
+
+ src, draft = "第一章 试炼\n少年抬头看天。", "Глава первая. Испытание\nЮноша поднял глаза к небу."
+ for en in (False, True):
+ for name, msgs in (("переводчик", translator_msgs(src, None, en)),
+ ("редактор", editor_msgs(src, draft, None, en))):
+ joined = "\n".join(m["content"] for m in msgs)
+ chk(f"{name} {'en' if en else 'zh'}: плейсхолдеров не осталось", "{{" not in joined)
+ title = (BRIEF_EN if en else BRIEF_ZH)["title"]
+ chk(f"{name} {'en' if en else 'zh'}: подставлена книга среза", title in joined)
+ chk(f"{name} {'en' if en else 'zh'}: исходник в сообщении", src in joined)
+ chk("FEWSHOT не уехал в системный промт",
+ "FEWSHOT" not in editor_msgs(src, draft, None)[0]["content"])
+ b = bank()
+ if b:
+ t = list(b)[:2]
+ law, glo = law_block(t), glossary_block(t)
+ chk("закон-блок несёт заголовок эксп-21 без оговорки",
+ law.startswith(P.HEADER_LAW_PLAIN) and "глоссарий обязателен" not in law)
+ chk("блоки закона и глоссария различаются", law != glo)
+ chk("термы фильтруются по единице", terms_in("нет иероглифов") == [])
+ else:
+ print("[ПРОП ] банк ещё не собран — блоки не проверены")
+ print(f"\n{'СБОРКА ЧИСТА' if not bad else f'ПРОВАЛОВ: {bad}'}")
+ return bad
+
+
+if __name__ == "__main__":
+ sys.exit(1 if selfcheck() else 0)
diff --git a/eval/tenant_panel/roster.py b/eval/tenant_panel/roster.py
new file mode 100644
index 00000000..ce92ecb5
--- /dev/null
+++ b/eval/tenant_panel/roster.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Ф1 — РОСТЕР КАНДИДАТОВ И ЦЕНЫ. Снято 08.08.2026, каждая строка с провенансом.
+
+Гардрейл CLAUDE.md: цены берутся с ПРАЙС-СТРАНИЦЫ ВЕНДОРА, не по памяти и не из соседнего пака.
+Урок календаря D39.61 («слаг живой ≠ модель та же») требует ещё и живого `/models` в день старта —
+он снят `role_topology/list_models.py`, снимок лежит рядом с сырьём.
+
+⚠ ЧТО ЗДЕСЬ ЗАМЕР, А ЧТО ЗАЯВЛЕНИЕ. Цена — заявление вендора. Фактическая стоимость считается из
+`usage` и сверяется вторым путём (`verify22.independent_price`). Расхождение прайса с леджером —
+находка, а не повод молча поправить одну из сторон.
+
+ЖИВАЯ СВЕРКА 08.08 УДАЛАСЬ по DeepSeek · Z.AI · Gemini · Kimi (curl, HTTP 200, страницы сохранены
+в `~/books/tenant-panel/prices/`). НЕ УДАЛАСЬ по OpenAI (Cloudflare 403), xAI (308→403) и Mistral
+(прайс рендерится JS) — там строки перенесены из эксп-21 с датой его сверки, и это объявлено.
+
+Три поправки к `role_topology/prices.py`, найденные живой сверкой:
+ * у Gemini кэш-цена ВЫДЕЛЕНА вендором и втрое-десятикратно ниже входной ($0.20 против $2.00 у
+ 3.1-pro; $0.15 против $1.50 у 3.6-flash; $0.025 против $0.25 у flash-lite) — эксп-21 ставил
+ кэш = входу и тем ЗАВЫШАЛ цену Gemini;
+ * у Z.AI появились `glm-4.7-flash` (бесплатный) и `glm-4.7-flashx` ($0.07/$0.40) — тир дешевле
+ боевого черновика; в live-`/models` их НЕТ, поэтому в ростер они не берутся, а проверяются
+ отдельной $0-пробой существования слага (урок quirks: «нет в /models ≠ не работает»);
+ * DeepSeek на прайс-странице 08.08 несёт объявление о ПОВЫШЕНИИ цен («significant increase
+ expected») — это бьёт по проекции себестоимости книги и обязано ехать с ИТОГом.
+"""
+from __future__ import annotations
+
+# (base_url, env-ключ, вход, кэш-хит, выход, тир, провенанс цены)
+CANDIDATES: dict[str, tuple] = {
+ "deepseek-v4-flash": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY",
+ 0.14, 0.0028, 0.28, "draft",
+ "api-docs.deepseek.com/quick_start/pricing, live 08.08"),
+ "deepseek-v4-pro": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY",
+ 0.435, 0.003625, 0.87, "editor",
+ "api-docs.deepseek.com/quick_start/pricing, live 08.08"),
+ "glm-4.7": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY",
+ 0.60, 0.11, 2.20, "draft", "docs.z.ai/guides/overview/pricing, live 08.08"),
+ "glm-5": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY",
+ 1.00, 0.20, 3.20, "editor", "docs.z.ai/guides/overview/pricing, live 08.08"),
+ "gpt-5.6-luna": ("https://api.openai.com/v1", "OPENAI_API_KEY",
+ 0.20, 0.02, 1.20, "draft",
+ "эксп-20 §1.3 / эксп-21 (05–06.08); живой ре-чек 08.08 — Cloudflare 403"),
+ "gemini-3.1-flash-lite": ("https://generativelanguage.googleapis.com/v1beta/openai",
+ "GEMINI_API_KEY", 0.25, 0.025, 1.50, "draft",
+ "ai.google.dev/gemini-api/docs/pricing, live 08.08"),
+ "gemini-3.6-flash": ("https://generativelanguage.googleapis.com/v1beta/openai",
+ "GEMINI_API_KEY", 1.50, 0.15, 7.50, "editor",
+ "ai.google.dev/gemini-api/docs/pricing, live 08.08"),
+ "gemini-3.1-pro-preview": ("https://generativelanguage.googleapis.com/v1beta/openai",
+ "GEMINI_API_KEY", 2.00, 0.20, 12.00, "editor",
+ "ai.google.dev/gemini-api/docs/pricing, live 08.08"),
+ "kimi-k2.6": ("https://api.moonshot.ai/v1", "KIMI_API_KEY",
+ 0.95, 0.16, 4.00, "draft", "platform.kimi.ai/docs/pricing/chat-k26, live 08.08"),
+ "mistral-large-latest": ("https://api.mistral.ai/v1", "MISTRAL_API_KEY",
+ 2.00, 2.00, 6.00, "draft",
+ "эксп-21 (06.08); живой ре-чек 08.08 — прайс рендерится JS"),
+ "grok-4.3": ("https://api.x.ai/v1", "XAI_API_KEY",
+ 1.25, 0.20, 2.50, "editor",
+ "эксп-21 (06.08); живой ре-чек 08.08 — 308→403, Wayback свежее не имеет"),
+}
+
+# ⚠ РЕЗ ПО ПРАЙСУ, объявлен ДО замера. Промт разрешает срезать при >16 кандидатах и требует
+# оставить каждому провайдеру хотя бы один слот. Снимаются те, кто ДОМИНИРОВАН одноимённым
+# семейством: дороже при той же роли и без уникальной способности.
+CUT = {
+ "gpt-5.6-terra": "выход $12.00 против $1.20 у luna того же семейства",
+ "kimi-k3": "выход $15.00 против $4.00 у k2.6 того же семейства",
+ "glm-5.1": "цена в цент совпадает с glm-5.2, семейство представлено glm-5 и glm-4.7",
+ "glm-5.2": "выход $4.40 против $3.20 у glm-5 — сильный тир Z.AI уже представлен",
+ "glm-5-turbo": "выход $4.00 против $3.20 у glm-5",
+ "grok-4.5": "выход $6.00 против $2.50 у grok-4.3",
+ "mistral-medium-latest": "цену вендора живьём подтвердить не удалось — в смету не берётся",
+}
+
+# Учёт размышления в биллинге (эксп-21 `prices.py`, пере-проверено прайс-страницами 08.08):
+# subset — размышление внутри `completion_tokens` (DeepSeek, OpenAI, Z.AI, Kimi);
+# additive — сверх completion (xAI);
+# additive_total — видно только в `total_tokens` (Gemini: OpenAI-слой отдаёт reasoning 0).
+# Gemini-страница 08.08 подтверждает дословно: «Output price (including thinking tokens)».
+REASONING_BILLING = {"grok-4.3": "additive",
+ "gemini-3.6-flash": "additive_total",
+ "gemini-3.1-flash-lite": "additive_total",
+ "gemini-3.1-pro-preview": "additive_total"}
+
+# БОЕВАЯ КОНФИГУРАЦИЯ РАЗМЫШЛЕНИЯ — из quirks 00, не из вкуса. `none` = ручку не шлём (вендор-
+# дефолт), и это осознанный выбор, а не пропуск.
+# deepseek — НИКОГДА не гасить (эхо-мина, гардрейл CLAUDE.md); у flash боевая ставит
+# `reasoning_effort: low`, иначе размышление съедает бюджет (quirks §10); у pro ручка `low`
+# признана НЕ УСТАНОВЛЕННОЙ (quirks 3б) ⇒ едем вендор-дефолтом;
+# glm — гасится `extra_body {"thinking":{"type":"disabled"}}`, так шёл арм B эксп-21;
+# luna — без явной ручки выжигает бюджет и отдаёт пустой `content` (провод-факт эксп-21 §8);
+# gemini — конфиг размышления НЕ шлём: у 3.1-pro `thinking_budget:0` даёт HTTP 400, а у
+# 3.x-flash поведение ручки не замерено, и гадать запрещено правилом двух направлений;
+# grok — reasoning-off из РЕДАКТОРСКИХ ролей снят как no-op (D30.1) ⇒ вендор-дефолт `low`;
+# kimi — ручки нет, зато принимает ТОЛЬКО `temperature: 1` (quirks, «Параметры сэмплинга»).
+THINK = {
+ "deepseek-v4-flash": ("effort", "low"),
+ "deepseek-v4-pro": ("none", ""),
+ "glm-4.7": ("extra_body_disable", ""),
+ "glm-5": ("extra_body_disable", ""),
+ "gpt-5.6-luna": ("effort", "low"),
+ "gemini-3.1-flash-lite": ("none", ""),
+ "gemini-3.6-flash": ("none", ""),
+ "gemini-3.1-pro-preview": ("mandatory", ""),
+ "kimi-k2.6": ("none", ""),
+ "mistral-large-latest": ("none", ""),
+ "grok-4.3": ("none", ""),
+}
+OPENAI_FAMILY = {"gpt-5.6-luna"} # max_completion_tokens, temperature не шлём
+FIXED_TEMP = {"kimi-k2.6": 1.0} # quirks: иное → HTTP 400 «only 1 is allowed»
+
+
+def call_kwargs(model: str, msgs: list[dict], max_out: int = 16000,
+ temperature: float = 0.3) -> dict:
+ """Тело вызова в боевой конфигурации модели. Одно место — чтобы квирк не разъехался по файлам."""
+ kw: dict = dict(model=model, messages=msgs)
+ if model in OPENAI_FAMILY:
+ kw["max_completion_tokens"] = max_out
+ else:
+ kw["max_tokens"] = max_out
+ kw["temperature"] = FIXED_TEMP.get(model, temperature)
+ mode, val = THINK[model]
+ if mode == "effort":
+ kw["extra_body"] = {"reasoning_effort": val}
+ elif mode == "effort_none":
+ kw["extra_body"] = {"reasoning_effort": "none"}
+ elif mode == "extra_body_disable":
+ kw["extra_body"] = {"thinking": {"type": "disabled"}}
+ return kw
+
+
+def billed_output(model: str, completion_tokens: int, reasoning_tokens: int,
+ total_tokens: int = 0, prompt_tokens: int = 0) -> int:
+ mode = REASONING_BILLING.get(model)
+ if mode == "additive":
+ return completion_tokens + reasoning_tokens
+ if mode == "additive_total":
+ return max(total_tokens - prompt_tokens, completion_tokens + reasoning_tokens)
+ return completion_tokens
+
+
+def cost(model: str, prompt_tokens: int, cached: int, completion_tokens: int,
+ reasoning_tokens: int = 0, total_tokens: int = 0) -> float:
+ _, _, pin, pcache, pout, _, _ = CANDIDATES[model]
+ out = billed_output(model, completion_tokens, reasoning_tokens, total_tokens, prompt_tokens)
+ return ((prompt_tokens - cached) / 1e6 * pin + cached / 1e6 * pcache + out / 1e6 * pout)
+
+
+def by_out_price() -> list[str]:
+ """Порядок замера — по ВОЗРАСТАНИЮ цены выхода: если потолок кончится, непокрытыми останутся
+ дорогие, и это будет объявлено числом, а не скрыто."""
+ return sorted(CANDIDATES, key=lambda m: CANDIDATES[m][4])
+
+
+if __name__ == "__main__":
+ print(f"{'модель':26s}{'тир':>8s}{'вход':>8s}{'кэш':>8s}{'выход':>8s} провенанс")
+ print("-" * 110)
+ for m in by_out_price():
+ _, _, pin, pc, pout, tier, src = CANDIDATES[m]
+ print(f"{m:26s}{tier:>8s}{pin:8.3f}{pc:8.3f}{pout:8.2f} {src}")
+ print(f"\nсрезано по прайсу ({len(CUT)}):")
+ for m, why in sorted(CUT.items()):
+ print(f" {m:24s} {why}")
diff --git a/eval/tenant_panel/screen.py b/eval/tenant_panel/screen.py
new file mode 100644
index 00000000..56151c53
--- /dev/null
+++ b/eval/tenant_panel/screen.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+"""Ф1 — ПРОФИЛЬ-СКРИН ЖИЛЬЦОВ. Оси = гейты цены и дисциплины, НЕ прокси качества.
+
+Что делает и чего НЕ делает. Скрин отсеивает кандидатов, неработоспособных в роли: рвут формат,
+эхают исходник, отказываются, отдают не тот язык, дороги сверх объявленного порога роли или не
+управляют размышлением. Он НЕ ранжирует прозу — урок эксп-20 прямой: механический показатель
+(число абзацев) оказался не качеством, а слепой суд его опроверг. Спорные по СТИЛЮ доводятся
+до Ф2/Ф3 обоими.
+
+Пороги записаны ЗДЕСЬ, в коде, ДО первого замера, и продублированы в пре-реге §0.5.
+
+Скрин ОБЯЗАН убивать: у эксп-21 он убил 2 из 12 и клейм «Ф1 отобрала жильцов» был снят.
+Но пороги выводятся из требований пайплайна, а не подгоняются под желаемый состав.
+
+Запуск: --dry план и смета, $0
+ --draft черновик для редакторской роли на тех же 2 единицах (боевой flash)
+ --run сам скрин
+ --score свод по персистированному сырью, $0
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+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))
+
+from dotenv import load_dotenv # noqa: E402
+from openai import OpenAI # noqa: E402
+
+import material as M # noqa: E402
+import money as MONEY # noqa: E402
+import prompts as PR # noqa: E402
+from roster import CANDIDATES, billed_output, by_out_price, call_kwargs # noqa: E402
+
+load_dotenv(REPO / "eval" / ".env")
+OUT = MONEY.OUT
+
+DRAFT_MODEL = "deepseek-v4-flash"
+ROLES = ("translator", "editor")
+# Пороги цены роли, объявленные ДО замера. Основание — экономика пака: черновик на книгу в
+# 1500 единиц при $0.02/ед стоит $30, что уже вне продуктовой рамки «низкий COGS»; редактор
+# оправдан втрое дороже, потому что покупает измеренное качество (эксп-21 §12).
+PRICE_CAP = {"translator": 0.02, "editor": 0.06}
+CYR_MIN = 0.5 # Г4: доля кириллицы среди букв
+ECHO_MAX = 0.15 # Г2: боевой порог disposition.go
+
+_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)
+# Маркеры насилия — только для ВЫБОРА чувствительной единицы, не для оценки.
+VIO = ("杀", "血", "尸", "斩", "惨叫", "骨", "折磨", "残忍", "痛苦")
+
+
+def units() -> list[dict]:
+ """Две боевые единицы: «обычная» (медиана длины) и ЧУВСТВИТЕЛЬНАЯ (максимум насилия).
+
+ Вторая нужна гейту Г3: вебновелла гарантированно упирается в насилие, и тихий отказ на главе N
+ рвёт конвейер. Выбор детерминирован и объявлен до замера.
+ """
+ us = M.units_zh()
+ by_len = sorted(us, key=lambda u: len(u["source"]))
+ normal = by_len[len(by_len) // 2]
+ dens = sorted(us, key=lambda u: (-sum(u["source"].count(v) for v in VIO), u["uid"]))
+ sens = next(u for u in dens if u["uid"] != normal["uid"])
+ normal, sens = dict(normal, kind="обычная"), dict(sens, kind="чувствительная")
+ return [normal, sens]
+
+
+def client(model: str) -> OpenAI:
+ base, env, *_ = CANDIDATES[model]
+ return OpenAI(api_key=os.environ[env], base_url=base, timeout=900)
+
+
+def draft_of(uid: str) -> str:
+ f = OUT / f"tp1-scr-draft-{uid}.json"
+ return json.loads(f.read_text(encoding="utf-8")).get("content", "") if f.exists() else ""
+
+
+def cmd_draft() -> None:
+ """Черновик под редакторскую роль скрина. Один и тот же для ВСЕХ кандидатов — парный дизайн."""
+ led = MONEY.Guarded("Ф1", default_expect=0.004)
+ for u in units():
+ blk = PR.glossary_block(PR.terms_in(u["source"]))
+ msgs = PR.translator_msgs(u["source"], blk)
+ rec = MONEY.purchase(led, f"tp1-scr-draft-{u['uid']}", DRAFT_MODEL,
+ client(DRAFT_MODEL), call_kwargs(DRAFT_MODEL, msgs),
+ role="draft-for-screen", uid=u["uid"])
+ if rec.get("skipped"):
+ print("⛔ потолок Ф1 — стоп")
+ return
+ print(f" черновик {u['uid'][:6]} ({u['kind']}) finish={rec.get('finish')} "
+ f"{len(rec.get('content', '')):5d} зн ${rec.get('cost_usd', 0):.5f}")
+ print(f"потрачено Ф1 ${led.spent():.6f}")
+
+
+def msgs_for(role: str, u: dict) -> list[dict]:
+ src = u["source"]
+ if role == "translator":
+ return PR.translator_msgs(src, PR.glossary_block(PR.terms_in(src)))
+ return PR.editor_msgs(src, draft_of(u["uid"]), PR.law_block(PR.terms_in(src)))
+
+
+def gates(rec: dict, source: str) -> dict:
+ t = rec.get("content", "") or ""
+ letters = [c for c in t if c.isalpha()]
+ han = len(_RE_HAN.findall(t))
+ cyr = sum(1 for c in letters if "а" <= c.lower() <= "я" or c.lower() == "ё")
+ return dict(
+ empty=not t.strip(),
+ truncated=rec.get("finish") == "length",
+ md=bool(_RE_MD.search(t)),
+ preamble=bool(_RE_PREAMBLE.search(t)),
+ han_chars=han,
+ han_share=round(han / max(1, len(letters)), 5),
+ cyr_share=round(cyr / max(1, len(letters)), 5),
+ refusal=bool(_RE_REFUSAL.search(t[:400])) or bool(rec.get("no_message")),
+ no_message=bool(rec.get("no_message")),
+ reasoning_share=round(rec.get("reasoning_tokens", 0) / max(1, billed_output(
+ rec["model"], rec.get("completion_tokens", 0), rec.get("reasoning_tokens", 0),
+ rec.get("total_tokens", 0), rec.get("prompt_tokens", 0))), 3),
+ cost=rec.get("cost_usd", 0.0), latency=rec.get("latency_s", 0.0), role=rec.get("role", ""),
+ )
+
+
+def verdict(rows: list[dict]) -> tuple[str, list[str]]:
+ """Пороги применяются как объявлены. Пустой список — НЕ провал, а отсутствие замера."""
+ if not rows:
+ return "НЕ ЗАМЕРЕН", ["потолок кончился до этой модели"]
+ why = []
+ fmt_bad = sum(1 for r in rows if r["md"] or r["preamble"] or r["empty"] or r["truncated"])
+ if fmt_bad > 1:
+ why.append(f"Г1 формат: нарушений в {fmt_bad} клетках")
+ if any(r["han_share"] > ECHO_MAX for r in rows):
+ why.append("Г2 эхо: клетка выше боевого порога 0.15")
+ elif all(r["han_chars"] for r in rows):
+ why.append("Г2 утечка: ханьцзы во ВСЕХ клетках")
+ if any(r["refusal"] for r in rows):
+ why.append("Г3 отказ на чувствительном фрагменте")
+ if any(r["cyr_share"] < CYR_MIN and not r["empty"] for r in rows):
+ why.append("Г4 выход не на целевом языке")
+ for role in ROLES:
+ rr = [r["cost"] for r in rows if r["role"] == role]
+ if rr and st.median(rr) > PRICE_CAP[role]:
+ why.append(f"Г5 цена {role}: ${st.median(rr):.4f} > ${PRICE_CAP[role]}")
+ if any(r["truncated"] and r["empty"] for r in rows):
+ why.append("Г6 размышление съело бюджет, выход пуст")
+ return ("ПРОВАЛ" if why else "прошёл"), why
+
+
+def cmd_run(dry: bool = False) -> None:
+ us = units()
+ models = by_out_price()
+ print(f"кандидатов {len(models)} · единиц {len(us)} · ролей {len(ROLES)} · "
+ f"вызовов {len(models) * len(us) * len(ROLES)} · потолок Ф1 ${MONEY.CEILINGS['Ф1']}")
+ for u in us:
+ print(f" единица {u['uid']} ({u['kind']}, гл.{u['chapter']}) — {len(u['source'])} зн, "
+ f"черновик {len(draft_of(u['uid']))} зн")
+ print("порядок — по ВОЗРАСТАНИЮ цены выхода: кончится потолок — непокрытыми останутся дорогие,\n"
+ "и это будет объявлено числом, а не скрыто")
+ if dry:
+ for m in models:
+ print(f" {m}")
+ return
+ if not all(draft_of(u["uid"]) for u in us):
+ print("⛔ нет черновика для редакторской роли — сначала --draft")
+ return
+ led = MONEY.Guarded("Ф1", default_expect=0.02)
+ for m in models:
+ for role in ROLES:
+ for u in us:
+ rec = MONEY.purchase(led, f"tp1-scr-{m}-{role}-{u['uid'][:6]}", m, client(m),
+ call_kwargs(m, msgs_for(role, u)),
+ role=role, uid=u["uid"], kind=u["kind"])
+ if rec.get("skipped"):
+ print(f" ⛔ потолок — {m} и дальше НЕ ЗАМЕРЕНЫ")
+ cmd_score()
+ return
+ g = gates(rec, u["source"])
+ print(f" {m:24s} {role:11s} {u['kind']:14s} finish={str(rec.get('finish')):9s}"
+ f" {len(rec.get('content', '')):5d} зн ханьцзы={g['han_chars']:3d}"
+ f" кир={g['cyr_share']:.2f} ${g['cost']:.5f} {g['latency']:.0f}с")
+ time.sleep(0.2)
+ cmd_score()
+
+
+def cmd_score() -> None:
+ us = {u["uid"]: u for u in units()}
+ per: dict[str, list[dict]] = {}
+ for f in sorted(OUT.glob("tp1-scr-*.json")):
+ r = json.loads(f.read_text(encoding="utf-8"))
+ if r.get("skipped") or r.get("role") == "draft-for-screen" or r.get("finish") == "error":
+ continue
+ u = us.get(r.get("uid"))
+ if u:
+ per.setdefault(r["model"], []).append(gates(r, u["source"]))
+ print(f"\n{'модель':26s}{'вердикт':>11s}{'$/ед transl':>13s}{'$/ед edit':>11s}"
+ f"{'эхо':>6s}{'размышл':>9s} причины")
+ print("-" * 116)
+ res = {}
+ for m in by_out_price():
+ rows = per.get(m, [])
+ v, why = verdict(rows)
+ pt = [r["cost"] for r in rows if r["role"] == "translator"]
+ pe = [r["cost"] for r in rows if r["role"] == "editor"]
+ han = sum(r["han_chars"] for r in rows)
+ rs = st.median([r["reasoning_share"] for r in rows]) if rows else 0
+ print(f"{m:26s}{v:>11s}{(st.median(pt) if pt else 0):13.5f}"
+ f"{(st.median(pe) if pe else 0):11.5f}{han:6d}{rs:9.1%} {'; '.join(why)}")
+ 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,
+ han=han, reasoning_share=rs)
+ (OUT / "screen.json").write_text(json.dumps(res, ensure_ascii=False, indent=1),
+ encoding="utf-8")
+ led = MONEY.Guarded("Ф1")
+ print(f"\nпотрачено Ф1 ${led.spent():.6f} из ${led.ceiling:.2f} · "
+ f"пак ${MONEY.Pack().spent():.6f} из ${MONEY.PACK_CEILING:.2f}")
+ print("⚠ числа печатаются по КАЖДОМУ кандидату — и вошедшему, и нет (симметрия промта)")
+
+
+def cmd_probe_unlisted() -> None:
+ """$0-проба существования слагов, которых нет в /models (урок «нет в списке ≠ не работает»)."""
+ import requests # noqa: PLC0415
+ key = os.environ.get("ZAI_API_KEY", "")
+ for slug in ("glm-4.7-flash", "glm-4.7-flashx"):
+ r = requests.get(f"https://api.z.ai/api/paas/v4/models/{slug}",
+ headers={"Authorization": f"Bearer {key}"}, timeout=30)
+ print(f" {slug:18s} HTTP {r.status_code} {r.text[:120]}")
+
+
+if __name__ == "__main__":
+ a = sys.argv[1:] or ["--dry"]
+ fn = {"--dry": lambda: cmd_run(dry=True), "--draft": cmd_draft, "--run": cmd_run,
+ "--score": cmd_score, "--probe-unlisted": cmd_probe_unlisted}.get(a[0])
+ fn() if fn else print(__doc__)