From 6287c3950e048089ea8fc8c55006e6ca67db6ede Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 26 Jul 2026 16:51:29 +0300 Subject: [PATCH] Close polygon package seven entirely as D39.46: phases B and B-prime accepted, contexts ratified as the lever, process rules into prompt norms --- docs/PROGRESS.md | 8 +- docs/architecture/05-decisions-log.md | 10 + ...ON_PACKAGE7_TERM_SOURCES_SESSION_PROMPT.md | 2 +- .../POLYGON_TERM_RESEARCH_B2_2026-07-26.md | 244 +++++++ .../POLYGON_TERM_RESEARCH_B_2026-07-26.md | 622 ++++++++++++++++++ eval/pkg7/README.md | 26 + eval/pkg7/audit_phaseB.py | 204 ++++++ eval/pkg7/bkrs_lookup.py | 101 +++ eval/pkg7/blind_judge.py | 191 ++++++ eval/pkg7/blind_judge2.py | 244 +++++++ eval/pkg7/build_termset.py | 207 ++++++ eval/pkg7/repair_judge.py | 85 +++ eval/pkg7/score_b2.py | 253 +++++++ eval/pkg7/score_bws.py | 185 ++++++ eval/pkg7/terminologist_arms.py | 278 ++++++++ 15 files changed, 2657 insertions(+), 3 deletions(-) rename docs/{ => archive/prompts}/POLYGON_PACKAGE7_TERM_SOURCES_SESSION_PROMPT.md (97%) create mode 100644 docs/archive/reports/POLYGON_TERM_RESEARCH_B2_2026-07-26.md create mode 100644 docs/archive/reports/POLYGON_TERM_RESEARCH_B_2026-07-26.md create mode 100644 eval/pkg7/audit_phaseB.py create mode 100644 eval/pkg7/bkrs_lookup.py create mode 100644 eval/pkg7/blind_judge.py create mode 100644 eval/pkg7/blind_judge2.py create mode 100644 eval/pkg7/build_termset.py create mode 100644 eval/pkg7/repair_judge.py create mode 100644 eval/pkg7/score_b2.py create mode 100644 eval/pkg7/score_bws.py create mode 100644 eval/pkg7/terminologist_arms.py diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 92ad6d69..274817b5 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,12 +1,16 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-25, пак-17 закрыт, право по ToS подписано, пак-18 принят — долгов контракта нет, D39.26–D39.31). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.45); этот файл — ЖУРНАЛ.** +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-25, пак-17 закрыт, право по ToS подписано, пак-18 принят — долгов контракта нет, D39.26–D39.31). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.46); этот файл — ЖУРНАЛ.** > - **Фазы/курс:** Ф0 ✅ · Ф1-инфра ✅ (D20–D28; golden = инвариант №8) · арка «качество-первым» D26→D38.5 закрыта · **арх-ресет D39 исполнен ЦЕЛИКОМ** (7 слоёв → программа D39.1–D39.10 → паки 11–16, D39.13–D39.24; статус слоёв — шапка-таблица `architecture/09-target-architecture.md`) · пере-прогон rerun2 прочитан (D39.20: операционка живьём, $0-резюм ×3 арма, $6.63<$15; планка ≤2 НЕ пройдена; анти-корреляция гладкость↔верность ×3 → mistral-редактор вон) · эксп ЗАКРЫТ (D39.22), итерация №2 отложена · **курс = разработка бэкенда: пак-17 «канал B вживую» ЗАКРЫТ ЦЕЛИКОМ (дизайн D39.26 · стройка D39.27 · дельта `allow` D39.28, 25.07); вокабуляр = ПАРА `violence`/`sexually-explicit`, остаток — ФАКТЫ ToS в зоне полигона (fail-closed до них)** (D39.25: в движке НЕТ понятия «18+» — generic content-labels × provider-capabilities; лейблы вне хешей · резолв до валидации · один хоп `chain[0]` · гранулярность = книга) → **КУРС АМЕНДИРОВАН ВЛАДЕЛЬЦЕМ 25.07 (D39.33): МАСШТАБ — ПОСЛЕДНИМ, после достройки алгоритмического идеала; доказательство эффективности крупных изменений — МИНИ-ПРОГОНЫ ~10 глав (детерминированные чекеры и скан остатка первыми, судья — только когда от него зависит решение).** **ОЧЕРЕДЬ ИСПРАВЛЕНА ЗАМЕРОМ (D39.35): $0-резюм драфта на стенде УЖЕ МЁРТВ** (снапшот разошёлся по трём осям — cheapgate v3→v4, renderfmt v2→v3, langpack 09974d6→a7d4be2), поэтому срочности «гнать, пока жив» НЕТ, а ниты перестали быть условными: **(1) микро-пак «предпрогонная гигиена» ($0-код): два нита общности + ОБЯЗАТЕЛЬНЫЙ флип `Gates.RegressionGuard.Enabled` (невыполненное обязательство D38.4 п.5) + опц. проекция пере-оплаты в `tmctl status`** · **(2) мини-прогон-замер ~10 глав** (драфт ≈$0.02, редактура ≈$0.08–0.15/арм, судья НЕ зовётся ⇒ один арм ≈$0.10–0.17, два ≈$0.20–0.32; перед прогоном бэкап `rerun2/*.db`, БД на схеме v9 против v10 бинаря) · **(3) параллельно $0 — сид/банк-дельта** (元 · 赤城 · 学堂家老 · 春秋蝉; два терма ждут подписи владельца) · **(4) пак-19 в урезанном скоупе** · (4) далее слой-2 извлечение из тел промтов · ru-target · native-Gemini судья. Затем — сид-дельта (元 · 赤城 · 学堂家老 · 春秋蝉), МАСШТАБ целой книги (7,78 млн символов, ~2284 раздела) и пилот Ф2.5. Хроника треков A/B, exp15/16 и стройки паков — архивы ниже + D-лог. > - **Стек:** draft deepseek-v4-flash (thinking ON) → **editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (D39.22; glm-5 резерв, mistral вон D39.20)**, промпт v3-discourse (P1a+чэнъюй+few_shot-тумблер) → судья gemini-3.1-pro-preview; канал B Mistral+grok; 7 ключей; ~$0.85/ранобэ (D30.4). -> - **ОТКРЫТЫЕ ПЕТЛИ (норма 25.07, перепись 180 инцидентов: класс NEVER_CLOSED больше не копим — каждая петля обязана получить диспозицию решено/отложено-с-записью/отклонено; ведёт оркестратор).** *Владелец:* ~~вокабуляр content-лейблов~~ **РЕШЕНО 25.07: ПАРА `violence`/`sexually-explicit`** (D39.27 п.5 — сохраняет D14 п.1 и dspro-редактора на violence) ⇒ следствие в работе: assert-only `action` (дельта пака-17) · ~~Q2 редактор под лейблом~~ **ОТЛОЖЕН С ЗАПИСЬЮ** (D39.27 п.7: при паре связывает только `sexually-explicit`-книги, которых нет; возвращается вместе с предусловием D22.7 перед первой explicit-книгой; кандидаты — grok-4.3 интерим, glm-5 только после сверки ToS Z.AI по первоисточнику, mistral отвергнут D39.20) · ~~ToS-факты + три решения по ним~~ **ЗАКРЫТЫ 25.07 (D39.29 факты + D39.30 подписи владельца):** лейбл `violence` НЕ заводится, интерпретация «нейтральное изображение ≠ пропаганда» зафиксирована письменно · риск Z.AI (плоский запрет «violent content»; экспозиция состоялась в rerun2 через glm-арм) ПРИНЯТ сознательно с условиями пересмотра · две строки `accepts_labels: [sexually-explicit]` (`xai`, `mistral`) ПОДПИСАНЫ и ПРИМЕНЕНЫ в `models.yaml`. **Остаток ToS-вопросов ЗАКРЫТ владельцем 25.07 (D39.32):** local = «политик нет, переводим всё» ⇒ строка применена · Gemini-оговорка читается СУЖЕНИЕМ ⇒ вердикт `FORBIDDEN`→`UNCLEAR`, но по доктрине это НЕ разрешение: строку Gemini не получает, **ждёт одного слова, если владелец хочет подписать интерпретацию как разрешение** · причину пустоты в конфиг НЕ выносим (живёт в quirks-доке) · **ре-чек политик стал ПРАВИЛОМ** (триггеры: Google ToS 30.07.2026 · квартал 25.10.2026 · любая правка `accepts_labels`) · дата аккаунта OpenAI — открыта, приоритет низкий (OpenAI вне цепочки). **Продуктовое (Ф3):** Gemini API Additional ToS запрещает клиентов, «likely to be accessed by individuals under 18» — обязательство на конечный продукт, независимо от лейблов · **промпт-слой под лейблом (НОВОЕ, вскрыто вопросом владельца): лейбл сегодня НЕ доезжает до промпта** (чистые данные маршрутизации + load-time гарантия); нужно ли, чтобы переводчик/редактор ЗНАЛИ о свойстве контента — отдельное решение промпт-слоя · ~~мини-каноны сид-дельты~~ ЗАКРЫТО 26.07: все четыре пункта решены, правок сида ноль; книжные канон-решения живут С КНИГОЙ (`books/gu-zhenren/CANON-NOTES.md`), НЕ в D-логе — урок владельца: движок не строится вокруг одной книги · ~~включение платной петли ремонта~~ **РЕШЕНО 26.07 (D39.38): НЕ включается** — замер 0 в её классах; вернуться при расширении классов и ненулевом остатке; **уточнение D39.39: «0» = ноль в охвате детекторов с измеренным теперь recall (латиница 0.50 боевой, битые формы 0.10) — решение в силе (петля видит теми же детекторами), но читать как «финал чист» нельзя** · **вопросы разметки Р1–Р4 (НОВОЕ, D39.39, пакет-6 §5, 21 спорная строка):** гипербола 千万 (довод: тот же автор в гл.2 даёт 数十万 о том же деянии) · что есть дефект в классе 成 — форма или значение (от ответа precision K5c 1.000 или 0.167) · сокращённая форма термина («гу» при dst «гу-тварь») · принципиально неразрешимая офлайн речь/мысль (если да — потолок recall K4b < 1.0 by construction, вписать в контракт правила) · ~~место фикс-пака «чекеры» в очереди~~ **РЕШЕНО 26.07 (D39.40): это ПАК-21, а перед ним мини-прогон ~10 глав на полном бэкенде (после лендинга 20 и 19)**; ответы Р1–Р4 нужны к дизайну пака-21 · ja→ru-реплика §B5 (тайминг) · старые Ф2.5-блокеры (билингв-якорь — D25 п.9 Q1 · контаминация корпуса — D27 п.4 · судья-дублёр D22.6 · планка запуска · publishable/waiver — D25 п.1 · FN-bound L3 — D25 п.4 · юр-пакет · провенанс 12-*-доков · xAI-ключ off перед продом D27 · residual-тачпойнты exp16, не влитые в сид-дельту: мини-голд алиасов · precision@30, `books/gu-zhenren/exp16/`). *Оркестратор:* ~~ратификация дизайна пака-16~~ РЕШЕНО D39.24 (25.07) · фантом-гард D-ссылок в чек-листе лендинга (действует) · ~~doc-sync pass~~ **ИСПОЛНЕН 25.07** (запись ниже: оба puml перерисованы под пост-пак-16 · баннеры на 01/02/03/04/06/07/08/09 · ресёрч-свип 18/18c/19/20/21/22 · README/POLYGON_PACKAGE4 освежены; residual — пинги полигону в его зоне) · ~~приёмка пака-17 фаза 1~~ **РЕШЕНО D39.26 (25.07: принято в редакции §14, три добора приёмки, фаза 2 размечена)** · **`escalation_model` = ОТВЕТИВШАЯ модель (НОВОЕ, вынесено из пака-17): вердикт-изменение** — golden пинит `esc_model` на ОТКАЗАВШЕМ хопе, маскировка не скрывает ⇒ отдельная ратификация с санкционированным пере-капчером · **`--accept-rebill[=usd]` с порогом `min($0.50, 5%×ProjectedBookUSD)` РАТИФИЦИРОВАН D20.2-Q2, но в коде/CLI ОТСУТСТВУЕТ (НОВОЕ)** — живёт только в спеке `backend/docs/D15.2-*.md`; `--resnapshot` пере-пиннит без суммы · **D22.7 (пер-чанковый L3-скрин) — предусловие первой erotica-книги в проде НЕ снято паком-17 (НОВОЕ, держать отдельным гейтом)** · фантом-гард D-ссылок в чек-листе лендинга (действует). *Бэкенд:* **ХВОСТЫ МИНИ-ПРОГОНА (D39.37, 26.07, по убыванию веса):** ~~(1) размеченный набор для чекеров~~ **ЗАКРЫТ 26.07 (полигон-пакет-6, D39.39)** ⇒ породил вход нового фикс-пака «чекеры»: 12 дефектов с file:line (супрессор K5c гасит юнит целиком · атрибуция K4b (23 безатрибутивных + 12 с `!?…`) · `inner_marker` на тире-строки · предохранитель K5a отсекает все реальные магнитуды · заглавные/гомоглифы K2 · 两 в `cheng_re` · U+0301 рвёт `TokenizeCyrillic` · мн.число в `decl.forms`-тулинге · однознаковые ключи пост-чека) — приёмка против набора за $0; **место РЕШЕНО (D39.40): ПАК-21, после мини-прогона на полном бэкенде; + переезд `translitInterjections` в данные (улов владельца)** · (2) **тест-пин джойна WHAT↔WHICH** (мутация оркестратора выжила — фикс-лист №1 следующего касания) · ~~(3) покрытие WHAT↔WHICH + флаговый майнинг-стоп~~ **ПАК-20 ЗАКРЫТ ЦЕЛИКОМ 26.07 (фаза 1 D39.41 · решения D39.42 · фаза 2 ПРИНЯТА И ЗАЛЕНДЕНА D39.45): терминолог + `--verify-bank` (дефолт=авто) + авто-провод с пометкой + точечная ре-редактура с $0-пере-пином + банкнота отдельным файлом; живьём на 10 главах, $0.0476, деньги сходятся тремя путями; заделы серии/импорта — контрактами.** Открытое по паку: `feed_cap` (рекомендация: малой дельтой) · жанровые метки словаря · подпись словаря после фазы B полигона · resnapshot стендовых книг с auto-строками; хвосты: `AttachKWIC` квадратичен · смета молчит о пере-покупке роли на подписи · S12 частичен · zh-worst-case в `bankTokenBudget` (утечка §0 признана) · gofmt `llm.go` (до-паковый) · **вариативность банкноты 37↔14 (n=2) → полигону в фазу B** · (4) `dialogue_dash` мерить только на невиданном тексте (подогнан под 蛊真人) · (5) генеральность нарезки: кана/ханьцзи один класс ⇒ ja `est_out` ~1.7× завышен МОЛЧА; `EstimateTokens` недосчитывает кириллицу (резервации занижены) · (6) golden второй фикстурой С langpack · (7) `prompt_override` — честная формулировка гарантии слоя-2 · (8) майнер/банк не-CJK (едет с ja→ru) · (9) `request_log` скоуп/ретеншен + `first_flag_reason` третье представление истории · (10) флор-16000 без модели · (11) `max_tokens`-упоры вернутся на en · ~~пак-17 (фазы 1–2 + дельта)~~ **ЗАКРЫТ ЦЕЛИКОМ 25.07 (D39.26/27/28)** · ~~ПАК-18 «долги контракта»~~ **ПРИНЯТ И ЗАЛЕНДЕН 25.07 (D39.31) — долгов контракта не осталось**; выдавался (`BACKEND_PACK18_DEBTS_SESSION_PROMPT.md`: `--accept-rebill` на снапшотной гранулярности — `guard_hash` в коде нет и не строим · `escalation_model` = авторитетная модель с САНКЦИОНИРОВАННЫМ пере-капчером golden под этот дифф · два нита общности условно-байт-нейтрально) · **ru-target долг (слой 7, `isRuTarget`×11 прод-сайтов · `TokenizeCyrillic`×6 · ключ «ru» в санитайзере) — ЖДЁТ СВОЕГО ДИЗАЙН-ПАКА** (связывает только не-русский таргет, по текущей карте zh/ja/en→ru не срочен) · **ПАК-19 «голос и состояние» ВЫДАН 25.07** (`BACKEND_PACK19_VOICE_STATE_SESSION_PROMPT.md`, двухфазный; ниты общности едут ЕГО лендингом — D39.31 п.5) · **проекция пере-оплаты в `tmctl status` (НОВОЕ, из отчёта пака-18): спека §9 предлагает заменить булев `ConfigDrift` числом «N чанков, ~$X» — дёшево, `omitempty`, не сделано сознательно** · **не-долги, следующие несущие:** масштаб целой книги (волны/каденс/потолки/ETA на сотнях глав — гоняли максимум 5) · Ф2-механизмы (голос/состояние D21 · native-Gemini судья) · извлечение дискурс-норм/few-shot из ТЕЛ промтов (слой 2, форсинг = ja→ru) · ~~пак-16 ф.2~~ ЗАЛЕНДЕНА 25.07 · per-class исходы ремонта — схемное решение (класс не durable), принимается ВМЕСТЕ с включением петли · ниты общности (владелец 25.07) — **ОТЛОЖЕНЫ ПО ЗАМЕРУ (D39.31 п.5): едут на ближайший ПОЛНЫЙ `--resnapshot`**; для Detail-строк байт-нейтральной формы не существует определительно (дефолт шаблона обязан рендерить текущую строку). Прежняя формулировка: только в байт-нейтральной форме с доказательством исполнением (иначе `CheapGateVersion`/`pack.Version()` двигают снапшот стендовых книг и убивают $0-резюм драфта под замер остатка пака-16): Detail-строки чекеров → шаблоны/цитаты из данных (статический 时辰-текст в generic-файле соврёт второй паре) · множитель ×2 `lintTimeUnits` (`checkers.go:174`) → ключ данных. *Полигон:* ~~пакет-6 (размеченный набор чекеров)~~ **ОТРАБОТАН И ПРИНЯТ 26.07 (D39.39): 3015 позиций, 6 прогонов, $0; metrics.json воспроизведён побайтно; набор на стенде `books/gu-zhenren/labels/` = постоянный измерительный стенд чекеров; **ПАКЕТ-7 (v2, ресёрч-программа): фаза A ПРИНЯТА 26.07 (D39.43, верификация 24/1/1/0 побайтно на пине); Z-решения владельца ПОЛУЧЕНЫ (D39.44: рубрика ратифицирована, Q1–Q6 закрыты, 修炼 = V0, смета B санкционирована) — в работе Z4-добор ($0) → фаза B ($1.20–2.60)**; амендмент D39.42 п.1 — терминолог на полном банке = единицы долларов, не центы; фактура G1–G10 + синк-пункты (промпт роли = интерим · genre-glossary = драфт) → аддендумом в пак-20; **Q4 частично закрывает Р3 из D39.39** (сокращённая форма = поле записи — вход пака-21) (оставшиеся кандидаты: ёфикатор/санитайзер-классы не размечены — §6.5 отчёта пакета-6; `dialogue_dash` на невиданном тексте) · ~~пакет 5 (факты ToS + 8 пингов)~~ **ОТРАБОТАН ЦЕЛИКОМ И ПРИНЯТ 25.07 (D39.29); все восемь пингов закрыты.** Исторический список закрытых пингов: (1) `00-provider-quirks.md:13,73` — катовер deepseek-chat 24.07 ПРОШЁЛ, пост-катовер факт (жив ли алиас, цены) не внесён — live-проба `/models` + вендор-дока; (2) `00-provider-quirks.md:69` — оговорка «mistral-editor-арм до guard» superseded D39.20 (mistral вон из редакторов; rate-limit-цифры оставить — нужны переводчику канала B пака-17); (3) `09-pilot-protocol.md:3` — ⚠-указатель застрял на D31/glm-5: продлить до пост-D39.22 (dspro-интерим · планка ≤2 не пройдена rerun2 · авто-QA слепыми метками D39.20 · стайл-каноны D39.21), тело не трогать; (4) `15-segmentation-empirics.md:6-7` — шапка до сих пор «НИ ОДНОГО платного вызова», а эксп залендён REV.2 (D39.7/D39.8, $12.79) — перевернуть статус-блок; (5) `16-bank-mining.md:3` — дописать финал «ИСПОЛНЕН И ЗАЛЕНДЁН (D39.10); gender-тачпойнт §7 закрыт (D39.19); продолжение — Go-майнер парити EXACT»; (6) `experiments/README.md` — индекс без строк exp15/16 (сверка застряла на D38.2); (7) `eval/README.md` — не тронут с 19.07 (пре-rerun2): освежить статус под пост-D39.22 (эксп закрыт · судейский риг-стандарт D39.7 · экстракция только `tmctl export`). +> - **ОТКРЫТЫЕ ПЕТЛИ (норма 25.07, перепись 180 инцидентов: класс NEVER_CLOSED больше не копим — каждая петля обязана получить диспозицию решено/отложено-с-записью/отклонено; ведёт оркестратор).** *Владелец:* ~~вокабуляр content-лейблов~~ **РЕШЕНО 25.07: ПАРА `violence`/`sexually-explicit`** (D39.27 п.5 — сохраняет D14 п.1 и dspro-редактора на violence) ⇒ следствие в работе: assert-only `action` (дельта пака-17) · ~~Q2 редактор под лейблом~~ **ОТЛОЖЕН С ЗАПИСЬЮ** (D39.27 п.7: при паре связывает только `sexually-explicit`-книги, которых нет; возвращается вместе с предусловием D22.7 перед первой explicit-книгой; кандидаты — grok-4.3 интерим, glm-5 только после сверки ToS Z.AI по первоисточнику, mistral отвергнут D39.20) · ~~ToS-факты + три решения по ним~~ **ЗАКРЫТЫ 25.07 (D39.29 факты + D39.30 подписи владельца):** лейбл `violence` НЕ заводится, интерпретация «нейтральное изображение ≠ пропаганда» зафиксирована письменно · риск Z.AI (плоский запрет «violent content»; экспозиция состоялась в rerun2 через glm-арм) ПРИНЯТ сознательно с условиями пересмотра · две строки `accepts_labels: [sexually-explicit]` (`xai`, `mistral`) ПОДПИСАНЫ и ПРИМЕНЕНЫ в `models.yaml`. **Остаток ToS-вопросов ЗАКРЫТ владельцем 25.07 (D39.32):** local = «политик нет, переводим всё» ⇒ строка применена · Gemini-оговорка читается СУЖЕНИЕМ ⇒ вердикт `FORBIDDEN`→`UNCLEAR`, но по доктрине это НЕ разрешение: строку Gemini не получает, **ждёт одного слова, если владелец хочет подписать интерпретацию как разрешение** · причину пустоты в конфиг НЕ выносим (живёт в quirks-доке) · **ре-чек политик стал ПРАВИЛОМ** (триггеры: Google ToS 30.07.2026 · квартал 25.10.2026 · любая правка `accepts_labels`) · дата аккаунта OpenAI — открыта, приоритет низкий (OpenAI вне цепочки). **Продуктовое (Ф3):** Gemini API Additional ToS запрещает клиентов, «likely to be accessed by individuals under 18» — обязательство на конечный продукт, независимо от лейблов · **промпт-слой под лейблом (НОВОЕ, вскрыто вопросом владельца): лейбл сегодня НЕ доезжает до промпта** (чистые данные маршрутизации + load-time гарантия); нужно ли, чтобы переводчик/редактор ЗНАЛИ о свойстве контента — отдельное решение промпт-слоя · ~~мини-каноны сид-дельты~~ ЗАКРЫТО 26.07: все четыре пункта решены, правок сида ноль; книжные канон-решения живут С КНИГОЙ (`books/gu-zhenren/CANON-NOTES.md`), НЕ в D-логе — урок владельца: движок не строится вокруг одной книги · ~~включение платной петли ремонта~~ **РЕШЕНО 26.07 (D39.38): НЕ включается** — замер 0 в её классах; вернуться при расширении классов и ненулевом остатке; **уточнение D39.39: «0» = ноль в охвате детекторов с измеренным теперь recall (латиница 0.50 боевой, битые формы 0.10) — решение в силе (петля видит теми же детекторами), но читать как «финал чист» нельзя** · **вопросы разметки Р1–Р4 (НОВОЕ, D39.39, пакет-6 §5, 21 спорная строка):** гипербола 千万 (довод: тот же автор в гл.2 даёт 数十万 о том же деянии) · что есть дефект в классе 成 — форма или значение (от ответа precision K5c 1.000 или 0.167) · сокращённая форма термина («гу» при dst «гу-тварь») · принципиально неразрешимая офлайн речь/мысль (если да — потолок recall K4b < 1.0 by construction, вписать в контракт правила) · ~~место фикс-пака «чекеры» в очереди~~ **РЕШЕНО 26.07 (D39.40): это ПАК-21, а перед ним мини-прогон ~10 глав на полном бэкенде (после лендинга 20 и 19)**; ответы Р1–Р4 нужны к дизайну пака-21 · ja→ru-реплика §B5 (тайминг) · старые Ф2.5-блокеры (билингв-якорь — D25 п.9 Q1 · контаминация корпуса — D27 п.4 · судья-дублёр D22.6 · планка запуска · publishable/waiver — D25 п.1 · FN-bound L3 — D25 п.4 · юр-пакет · провенанс 12-*-доков · xAI-ключ off перед продом D27 · residual-тачпойнты exp16, не влитые в сид-дельту: мини-голд алиасов · precision@30, `books/gu-zhenren/exp16/`). *Оркестратор:* ~~ратификация дизайна пака-16~~ РЕШЕНО D39.24 (25.07) · фантом-гард D-ссылок в чек-листе лендинга (действует) · ~~doc-sync pass~~ **ИСПОЛНЕН 25.07** (запись ниже: оба puml перерисованы под пост-пак-16 · баннеры на 01/02/03/04/06/07/08/09 · ресёрч-свип 18/18c/19/20/21/22 · README/POLYGON_PACKAGE4 освежены; residual — пинги полигону в его зоне) · ~~приёмка пака-17 фаза 1~~ **РЕШЕНО D39.26 (25.07: принято в редакции §14, три добора приёмки, фаза 2 размечена)** · **`escalation_model` = ОТВЕТИВШАЯ модель (НОВОЕ, вынесено из пака-17): вердикт-изменение** — golden пинит `esc_model` на ОТКАЗАВШЕМ хопе, маскировка не скрывает ⇒ отдельная ратификация с санкционированным пере-капчером · **`--accept-rebill[=usd]` с порогом `min($0.50, 5%×ProjectedBookUSD)` РАТИФИЦИРОВАН D20.2-Q2, но в коде/CLI ОТСУТСТВУЕТ (НОВОЕ)** — живёт только в спеке `backend/docs/D15.2-*.md`; `--resnapshot` пере-пиннит без суммы · **D22.7 (пер-чанковый L3-скрин) — предусловие первой erotica-книги в проде НЕ снято паком-17 (НОВОЕ, держать отдельным гейтом)** · фантом-гард D-ссылок в чек-листе лендинга (действует). *Бэкенд:* **ХВОСТЫ МИНИ-ПРОГОНА (D39.37, 26.07, по убыванию веса):** ~~(1) размеченный набор для чекеров~~ **ЗАКРЫТ 26.07 (полигон-пакет-6, D39.39)** ⇒ породил вход нового фикс-пака «чекеры»: 12 дефектов с file:line (супрессор K5c гасит юнит целиком · атрибуция K4b (23 безатрибутивных + 12 с `!?…`) · `inner_marker` на тире-строки · предохранитель K5a отсекает все реальные магнитуды · заглавные/гомоглифы K2 · 两 в `cheng_re` · U+0301 рвёт `TokenizeCyrillic` · мн.число в `decl.forms`-тулинге · однознаковые ключи пост-чека) — приёмка против набора за $0; **место РЕШЕНО (D39.40): ПАК-21, после мини-прогона на полном бэкенде; + переезд `translitInterjections` в данные (улов владельца)** · (2) **тест-пин джойна WHAT↔WHICH** (мутация оркестратора выжила — фикс-лист №1 следующего касания) · ~~(3) покрытие WHAT↔WHICH + флаговый майнинг-стоп~~ **ПАК-20 ЗАКРЫТ ЦЕЛИКОМ 26.07 (фаза 1 D39.41 · решения D39.42 · фаза 2 ПРИНЯТА И ЗАЛЕНДЕНА D39.45): терминолог + `--verify-bank` (дефолт=авто) + авто-провод с пометкой + точечная ре-редактура с $0-пере-пином + банкнота отдельным файлом; живьём на 10 главах, $0.0476, деньги сходятся тремя путями; заделы серии/импорта — контрактами.** Открытое по паку: `feed_cap` (рекомендация: малой дельтой) · жанровые метки словаря · подпись словаря после фазы B полигона · resnapshot стендовых книг с auto-строками; хвосты: `AttachKWIC` квадратичен · смета молчит о пере-покупке роли на подписи · S12 частичен · zh-worst-case в `bankTokenBudget` (утечка §0 признана) · gofmt `llm.go` (до-паковый) · **вариативность банкноты 37↔14 (n=2) → полигону в фазу B** · (4) `dialogue_dash` мерить только на невиданном тексте (подогнан под 蛊真人) · (5) генеральность нарезки: кана/ханьцзи один класс ⇒ ja `est_out` ~1.7× завышен МОЛЧА; `EstimateTokens` недосчитывает кириллицу (резервации занижены) · (6) golden второй фикстурой С langpack · (7) `prompt_override` — честная формулировка гарантии слоя-2 · (8) майнер/банк не-CJK (едет с ja→ru) · (9) `request_log` скоуп/ретеншен + `first_flag_reason` третье представление истории · (10) флор-16000 без модели · (11) `max_tokens`-упоры вернутся на en · ~~пак-17 (фазы 1–2 + дельта)~~ **ЗАКРЫТ ЦЕЛИКОМ 25.07 (D39.26/27/28)** · ~~ПАК-18 «долги контракта»~~ **ПРИНЯТ И ЗАЛЕНДЕН 25.07 (D39.31) — долгов контракта не осталось**; выдавался (`BACKEND_PACK18_DEBTS_SESSION_PROMPT.md`: `--accept-rebill` на снапшотной гранулярности — `guard_hash` в коде нет и не строим · `escalation_model` = авторитетная модель с САНКЦИОНИРОВАННЫМ пере-капчером golden под этот дифф · два нита общности условно-байт-нейтрально) · **ru-target долг (слой 7, `isRuTarget`×11 прод-сайтов · `TokenizeCyrillic`×6 · ключ «ru» в санитайзере) — ЖДЁТ СВОЕГО ДИЗАЙН-ПАКА** (связывает только не-русский таргет, по текущей карте zh/ja/en→ru не срочен) · **ПАК-19 «голос и состояние» ВЫДАН 25.07** (`BACKEND_PACK19_VOICE_STATE_SESSION_PROMPT.md`, двухфазный; ниты общности едут ЕГО лендингом — D39.31 п.5) · **проекция пере-оплаты в `tmctl status` (НОВОЕ, из отчёта пака-18): спека §9 предлагает заменить булев `ConfigDrift` числом «N чанков, ~$X» — дёшево, `omitempty`, не сделано сознательно** · **не-долги, следующие несущие:** масштаб целой книги (волны/каденс/потолки/ETA на сотнях глав — гоняли максимум 5) · Ф2-механизмы (голос/состояние D21 · native-Gemini судья) · извлечение дискурс-норм/few-shot из ТЕЛ промтов (слой 2, форсинг = ja→ru) · ~~пак-16 ф.2~~ ЗАЛЕНДЕНА 25.07 · per-class исходы ремонта — схемное решение (класс не durable), принимается ВМЕСТЕ с включением петли · ниты общности (владелец 25.07) — **ОТЛОЖЕНЫ ПО ЗАМЕРУ (D39.31 п.5): едут на ближайший ПОЛНЫЙ `--resnapshot`**; для Detail-строк байт-нейтральной формы не существует определительно (дефолт шаблона обязан рендерить текущую строку). Прежняя формулировка: только в байт-нейтральной форме с доказательством исполнением (иначе `CheapGateVersion`/`pack.Version()` двигают снапшот стендовых книг и убивают $0-резюм драфта под замер остатка пака-16): Detail-строки чекеров → шаблоны/цитаты из данных (статический 时辰-текст в generic-файле соврёт второй паре) · множитель ×2 `lintTimeUnits` (`checkers.go:174`) → ключ данных. *Полигон:* ~~пакет-6 (размеченный набор чекеров)~~ **ОТРАБОТАН И ПРИНЯТ 26.07 (D39.39): 3015 позиций, 6 прогонов, $0; metrics.json воспроизведён побайтно; набор на стенде `books/gu-zhenren/labels/` = постоянный измерительный стенд чекеров; ~~ПАКЕТ-7 (v2, ресёрч-программа)~~ **ЗАКРЫТ ЦЕЛИКОМ 26.07 (A: D39.43 · Z-решения: D39.44 · B+B′: D39.46; $0.45043 из коридора $2.60): ратифицировано «контексты — главный рычаг терминолога»; судья = только катастроф-экран с декоем; словарь инертен до расширения эмиссии на term; три процессных правила — в нормы промтов; на владельце подписи Z-B1–B4 (17 строк словаря · 修炼 V0 · узкое правило транскрипции · веб-фетч v2 нет)**; амендмент D39.42 п.1 (терминолог на полном банке = единицы долларов) и Q4→Р3 (сокращённая форма = поле записи, вход пака-21) — в силе; полигон-очередь ПУСТА, кандидаты: вариативность банкноты 37↔14 · ёфикатор/санитайзер-разметка · dialogue_dash на невиданном (оставшиеся кандидаты: ёфикатор/санитайзер-классы не размечены — §6.5 отчёта пакета-6; `dialogue_dash` на невиданном тексте) · ~~пакет 5 (факты ToS + 8 пингов)~~ **ОТРАБОТАН ЦЕЛИКОМ И ПРИНЯТ 25.07 (D39.29); все восемь пингов закрыты.** Исторический список закрытых пингов: (1) `00-provider-quirks.md:13,73` — катовер deepseek-chat 24.07 ПРОШЁЛ, пост-катовер факт (жив ли алиас, цены) не внесён — live-проба `/models` + вендор-дока; (2) `00-provider-quirks.md:69` — оговорка «mistral-editor-арм до guard» superseded D39.20 (mistral вон из редакторов; rate-limit-цифры оставить — нужны переводчику канала B пака-17); (3) `09-pilot-protocol.md:3` — ⚠-указатель застрял на D31/glm-5: продлить до пост-D39.22 (dspro-интерим · планка ≤2 не пройдена rerun2 · авто-QA слепыми метками D39.20 · стайл-каноны D39.21), тело не трогать; (4) `15-segmentation-empirics.md:6-7` — шапка до сих пор «НИ ОДНОГО платного вызова», а эксп залендён REV.2 (D39.7/D39.8, $12.79) — перевернуть статус-блок; (5) `16-bank-mining.md:3` — дописать финал «ИСПОЛНЕН И ЗАЛЕНДЁН (D39.10); gender-тачпойнт §7 закрыт (D39.19); продолжение — Go-майнер парити EXACT»; (6) `experiments/README.md` — индекс без строк exp15/16 (сверка застряла на D38.2); (7) `eval/README.md` — не тронут с 19.07 (пре-rerun2): освежить статус под пост-D39.22 (эксп закрыт · судейский риг-стандарт D39.7 · экстракция только `tmctl export`). > - **НАЙДЕНО СПЛОШНЫМ АУДИТОМ 25.07 (D39.34) — 23 позиции, что были ратифицированы, но не попали в трекер; каждая с диспозицией.** *(1) Обязательство, НЕ исполненное:* **флип `Gates.RegressionGuard.Enabled` (D38.4 п.5 «ОБЯЗАН включить» при сборке resnapshot) — ключа нет ни в одном шиппинг-конфиге ⇒ гейт false; rerun2 прошёл без построенной наблюдаемости** → едет обязательной строкой в пак, вызывающий ближайший resnapshot. *(2) Флипы гейтов за подписью владельца (остаются открытыми-с-записью):* coverage `enabled:false` (D12-Q4) · `postcheck_gate` в hard-gate требует пере-замера recall (D24.4+D28.1). *(3) Поглощено курсом:* заморозка «этапа B ~300 глав» (D26 п.1) — **снята D39.33** (масштаб последним), отдельной петлёй не считается. *(4) Открыто, вне «идеала»:* этапы Б+В спеки D15.2 (D33 п.5 «промт выдам после пере-прогона» — прогон был 23.07) — они про content-addressed resume/`guard_hash`, который D39.31 сознательно не строил · F3-остаток идемпотентности · `human_override` LOCK (D29 п.1г, в коде 0 вхождений) · морфо-гейты канцелярита и L1-лемматизация (Ф2, python-сайдкар) · полный OpenCC (0 вхождений) · опциональный пре-перевод гейт. *(5) Мелкие хвосты качества (собрать одним паком когда-нибудь):* ledger-очередь LOW/NOTE из D39.4 · остаток фикс-листа D23 п.4 и D28 п.3(б) · `editor-mono.md` DORMANT (ни в одном конфиге). *(6) Деньги/провайдеры (полигон):* вендор-чек префикс-кэша ZAI/GLM (на нём стоит вся кэш-экономика W1/Q2c) · продление `prices_checked` без правки чисел · scheduler-aware волны под peak-valley DeepSeek (прямой COGS-рычаг) · гейтнутые платные армы (edit-единица >3200 и др.) · wire-probe temperature в thinking-канале DeepSeek. *(7) Продукт/Ф3:* action-security gate перед выдачей tools/webfetch (D25 п.5) · планы research/22 (epub-tag-rewrite, Q7-леджер) · Gemini под-18 обязательство на конечный продукт. *(8) Вход в пак-19:* требование НАРРАТИВНОЙ гендер-аннотации (since_ch-смены + перспектива), а не булева поля — вписано в промт. > - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `archive/PROGRESS-2026-07-10-13.md` (D39.6-гигиена) · `archive/PROGRESS-2026-07-13-25.md` (слайс 25.07: стройка паков 11–16, rerun2). Записи ниже — живой хвост. +## Полигон — ПАКЕТ-7 Z4-ДОБОР + ФАЗА B ИСПОЛНЕНЫ ($0.31030 из потолка $2.60): БКРС взят, промпт-правка найдена, слепая оценка ОТБРАКОВАНА своим же контролем, 26.07 + +Отчёт: `archive/reports/POLYGON_TERM_RESEARCH_B_2026-07-26.md`. Код: `eval/pkg7/` (+`bkrs_lookup` · `build_termset` · `terminologist_arms` · `blind_judge` · `repair_judge` · `score_bws`). **Добор ($0):** (1) **БКРС ВЗЯТ** — страница не JS-рендерится, а ставит куки-челлендж `ca=`; воспроизведя куку, получили **84 словарные статьи** и закрыли **17 из 20 UNCLEAR-строк** словаря (сильнейший класс улики, ноль поисковой квоты); ключевое: словарь «культивации» не знает (`修炼` = «совершенствовать и закалять себя»), `修士` = «**христ. монах**» ⇒ строка `修士→культиватор` в `genre-glossary.txt` спорна, `筑基` = «закладывание фундамента» ⇒ строка ПОДТВЕРЖДЕНА; (2) **Ngram перевёл спор в измерение**: корреляция «культивация»×«культиватор» = **0.9715** на 120-летнем ряду (сельхоз-омонимия доказана), «совершенствование» частотнее в 119.6×, биграмма «мир совершенствующихся» — 0 вхождений; (3) **конкуренты по ЛОКАЛЬНЫМ клонам** (подсказка владельца, `/home/ubuntu/projects/tmp`): три независимые команды сошлись на форме `src→dst+примечание` с фильтрацией по текущему чанку; у GalTransl — трёхслойный словарь с **условной пост-заменой** (`pre_src/post_dst`, `!отрицание`) и ситуационными ключами `mono`/`diag`; у AiNiee — блок «не переводить»; у **TransAgents терминологии нет вовсе (0 файлов .py)**, а Эндрю Ын прямо называет нашу задачу открытой; (4) `Silversaint` → «**Серебряный Святой / среброносец**» (АСТ, Абдуллин) — первый живой человеческий эталон; (5) **search-inside закрыт ОТРИЦАТЕЛЬНО**: всех трёх ISBN в Google Books нет ⇒ **развилка Z2 по `修炼` НЕ снята**. **Фаза B (310 вызовов, 480 консолидаций, 0 ошибок):** объективный слой — контексты решают всё (совпадение с подписью владельца на S1: `P0b` 10/15 против `P1`/`P2`/`P0` **15/15**), а различия между армами с контекстами = один-два терма из девятнадцати; **глоссарный контраст не измерился по построению — 0 из 80 термов покрыты жанровым словарём** (WHICH-канал даёт книжный канон, словарь — жанровые понятия; множества не пересекаются, это прямое следствие recall `term`=0.040 фазы A). **Главная промпт-находка:** правило «имя собственное → транскрипция» систематически уводит от решения владельца и живого переводчика — `春秋蝉`→«Чуньцючань» вместо «Весенне-осенняя цикада» у ВСЕХ армов с правилом, `Silversaint`→«Сильверсейнт» у `P2`/`P3` против изданного «Серебряный Святой», прокси-мера на ловушке 0.50 против 0.25 ⇒ **рекомендация сузить правило до антропонимов и топонимов**. **Веб-фетч в v2 роли — НЕТ**: агрегатного выигрыша нет, единственный случай пользы (`时辰`, где справка дословно содержит «был равен 2 часам») даёт СЛОВАРЬ, а словарь — это данные, они живут офлайн и не ломают инвариант №8. **Слепая оценка ОТБРАКОВАНА собственным контролем пре-регистрации:** BWS-счета плоские (весь разброс ±0.09 при шкале ±1), F1/F1b/F3/F4 сработали, leave-one-out по судье МЕНЯЕТ победителя, эффект позиционного свопа (до 0.107) БОЛЬШЕ разброса между армами, а **`gpt-5-mini` назвал подписанный владельцем перевод катастрофой 6 раз из 19** ⇒ по §A2.4 п.4 вердикты судей не выдаются, выводы стоят на объективном слое. Урок: пре-регистрация окупилась — без `GOLD` в слепой выборке мы выдали бы «P4 победил» и не узнали бы, что измеритель негоден. **На владельце:** 17 строк словаря на подпись · `修炼` остаётся V0 · правка промпта §B3.2 · вердикт по веб-фетчу · нужен ли повторный замер ДРУГИМ инструментом. ⚠ **РЕВИЗИЯ ИСПОЛНЕНИЕМ (та же сессия, после сдачи; §B6 отчёта + `eval/pkg7/audit_phaseB.py`) — четыре вывода выше ИСПРАВЛЕНЫ, читать только вместе с §B6:** (а) **отбраковка судей СНЯТА** — контроль §A2.4 п.4 был структурно неизмерим: строка `GOLD` не была уникальной ни на одном из 19 термов, а все 6 «катастроф на подписи» судья проставил РОВНО на буквах с тем же текстом, то есть вёл себя согласованно и спорил с каноном по существу; (б) **вывод «судьи не имеют разрешающей способности» ОПРОВЕРГНУТ их же данными** — 35 термов из 80 вырождены (все семь кандидатов = одна строка), а на 45 невырожденных тем же скорером F1 переворачивается 0.044→**0.122** и F1b 0.056→**0.189**, причём `P0b` встаёт ПОСЛЕДНИМ, согласно объективному слою ⇒ негоден был НАБОР, а не измеритель; (в) **у правила транскрипции НЕ БЫЛО контрольного арма** — оно лежит в общей части промпта и присутствует во всех шести армах (канон выдал `P0`, у которого правил больше всех), поэтому рекомендация §B3.2 понижена до гипотезы (проверка — арм без правила, ≈$0.02); (г) **«три независимые улики» оказались одной** — весь разрыв прокси на S5 даёт единственный терм `春秋蝉`, а `Silversaint` держится на сводке веб-поиска, не сверенной с телом издания (в сводке — «угодник-среброносец» и «AST/EKSMO») ⇒ человеческого эталона для en у нас НЕТ. **Перепроверку прошли без правок:** §B1.1 до строки · покрытие 0/80 (и 0/80 даже на ПОЛНОМ словаре §Z4-Т) ⇒ Z-B6 устоял · деньги $0.31030 из сырья · весь Ngram до знака · якоря фазы A по пину `996bade`. **РАЗВЯЗКА — фаза B′ (санкция владельца, отчёт `archive/reports/POLYGON_TERM_RESEARCH_B2_2026-07-26.md`, $0.14013 из порога $0.30, 136 вызовов, 0 ошибок; пре-регистрация записана ДО трат):** три замера по порогам, названным заранее. **T — правило транскрипции:** арм `P5` = `P2` минус ОДНА строка (проверено diff-ом: 18641 против 18556 симв.) вернул `春秋蝉` в **точный канон владельца** и дал `Silversaint`→«Серебросвят», но агрегатный прокси-сдвиг `P2−P5 = +0.013` при пороге 0.05 ⇒ по решающему правилу **широкая формулировка §B3.2 ОПРОВЕРГНУТА, узкая ПОДДЕРЖАНА**: снятие правила помогает на предметах/приёмах (`Ashdrinker`→«Пепелитель») и ВРЕДИТ на антропонимах (`王婆`→«Старуха Ван», `月光蛊` теряет вершину `蛊`), а `提刑` без правила наоборот транслитерируется; T3 пройден (`P5` 18/19, на ловушке 4/4). **J — слепая оценка на ПОЧИНЕННОМ наборе** (45 расходящихся термов, дедуп кандидатов, подложен ДЕКОЙ): оба судьи проходят контроли с запасом — декой назван лучшим **0/45**, помечен катастрофой **43 и 45 из 45**, подпись владельца — **НИ РАЗУ**, а `grok-4.3` ставит её выше всех армов (+0.625) ⇒ **отбраковка фазы B неправомерна, находка «6 катастроф на подписи» НЕ ВОСПРОИЗВОДИТСЯ**; но ранговое согласие с объективным слоем ничтожно (ρ = +0.429 / −0.107) ⇒ **ранжировать армы слепой оценкой нельзя, её роль — катастроф-экран** (H7: разрыв до декоя 1.27–1.42 против межарменного 0.20). **R — разброс прогона:** три повтора дали `P0b` 11/12/12 против `P1` 19/18/18 ⇒ **разрыв 6 термов при размахе 1 — «контексты главный рычаг» УСТАНОВЛЕНО**, заодно объяснилась аномалия «случайный глоссарий 19/19» (лежит внутри шума прогона). H6 частично: вырожденность предсказывает не подписанность, а КЛАСС терма (S2 0.13 против S1/S4 0.53). Итого по пакету-7: $0.45043 из коридора $1.20–2.60. **На владельце теперь:** Z-B3 — принять узкую формулировку как редакторское решение · Z-B5 — менять не инструмент, а сборку набора · Z-B8 (новое) — «контексты решают» можно ратифицировать как измеренное. + ## Полигон — ПАКЕТ-7 ФАЗА A ИСПОЛНЕНА ($0), СТОП на ратификацию: recall WHICH-канала измерен и он 0.07–0.10, посылка «культивация» уликами не подтверждена, 26.07 Отчёт: `archive/reports/POLYGON_TERM_RESEARCH_A_2026-07-26.md`. Код: `eval/pkg7/` (нарезка книг · replace-харнесс боевого майнера с пином на 996bade · майнер каналов en/ja · KWIC · seed-recall · проба измеримости рубрики). Платных вызовов ноль. **Пять измерений на пяти книгах стенда:** (1) recall боевого WHICH-канала против ПОДПИСАННОГО банка 蛊真人 = **0.069–0.103**, по классам name 0.231 / title 0.077–0.154 / place 0.000 / **term 0.040**; 蛊 (8700 вхождений, заглавное понятие) не предлагается никогда — механизм в `emissionEligible` (тип обязан быть name|place|title, длина ≥2 рун); (2) **абляция даёт атрибуцию**: снятие потолка top-200 → 0.429, снятие фильтра типа → 0.875, снятие длины → 0.911, ценой эмиссии 17→15 418 строк при precision спот-чека ≈0.15 ⇒ покрытие ограничивает СЛОЙ ЭМИССИИ, а не детектор, и это количественное основание под D39.42; (3) покрытие не растёт с длиной книги (10гл 15 → 50гл 12), и подписанный на мини-прогоне `学堂家老` исчезает, проглоченный алиас-кластером фамилии `葛家` вместе с родовым `族长` — точечный дефект кластеризации; (4) генеральность: `lang.Load` для ja/en падает громко (D39.15 работает), но под zh-паком en даёт 0 термов и стоп МОЛЧА авто-продолжает, а ja — 8 термов с precision 0 и ключами, которых в книге нет (trad→simp: 範囲→范囲); (5) масштаб квадратичен, `t ≈ chars^2.013`, экстраполяция на приёмочную книгу ≈4.6 ч. Собрано 10 требований к generic-майнеру (хвост №8 D39.37). **Ресёрч:** 6 углов × адверсариальная верификация (84 проверки: 62 CONFIRMED / 14 PARTIAL / 7 MISQUOTED / 1 OVER_ATTRIBUTED / 0 фабрикаций) + критик полноты. Посылка D39.42 п.1 («индустрия — „культивация“») уликами НЕ подтверждена, но и обратное не доказано: издательская сторона стоит на маркетинговых аннотациях, тела изданий не читаны ⇒ владельцу предложен **V0 — не подписывать до одного $0-захода**. Вскрыта ложная сходимость: русские глоссарии сянься = один английский первоисточник + один блогер. **На владельце:** рубрика K1–K12 + Q1–Q6 · строка 修炼 · смета фазы B $1.20–2.60 · нужен ли добор улик перед B. diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index 3d9f14d3..2cf0ad44 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1116,3 +1116,13 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу **Ключевые факты стройки:** живой прогон подтвердил ЗАДАЧУ (терминолог переоткрыл подписи владельца: 学堂→«школа», 学堂家老→«старейшина школы»; дрейф показан колонкой spread) и поймал дефект класса «канон-ломание» (роль переписала согласованный с подписями перевод — закрыто якорями ⟦TM-CANON⟧ + детектором CanonConflicts, живьём 1 честный конфликт из 35). Сессия сама вскрыла и закрыла: самоподтверждающую выборку своих мутаций (независимая линза: 20/30 выживших → закрыты; итог 48+ красных), фолд жанрового словаря в версию пака (подпись строки пере-оплачивала бы книги пары целиком — выведен из хеша, правка бьёт только батчи роли), 14 дефектов аудита §8.2 (вкл. бюджет роли ПОСЛЕ факта → стал предварительным; деньги роли вне TOTAL → входят; ложное «cjk_artifact» на легитимно-двуязычном ответе роли). **Микро-проба $0.02 снята как перекрытая живым прогоном.** Кластер-дефект G8 воспроизведён и оказался хуже («глава клана» приписывался фамилии) — закрыт на границе сборки входа (Origin: alias + провенанс Via), кластеризация не чинилась (отдельное решение). **Открытые решения владельца:** (1) `feed_cap` (ось G7) — предложен с деньгами (400 канд. ≈ $0.035, все 13 246 ≈ $1.10; бюджет-гейт режет хвост батча, не наименее ценное) — рекомендация оркестратора: строить малой дельтой; (2) жанровые метки `genre-glossary` — пар-широкие или выравнивание с `book.yaml`; (3) подпись содержимого словаря — после полигонской фазы B; (4) одноразовый `--resnapshot` книг с auto-строками (стендовые). **Хвосты в трекер:** `AttachKWIC` квадратичен (16.8 с / 2000 канд.) · смета молчит о пере-покупке прохода роли на каждой итерации подписи · S12 частичен (подмена слова разделителя не ловится) · `bankTokenBudget` worst-case зашит zh→ru (признанная утечка пары, §0) · ветка `dispositionFor`/S7 · gofmt-нит `llm.go` (до-паковый) · **вариативность банкноты 37↔14 строк между прогонами одной книги (n=2) — сигнал в полигонскую фазу B: бьёт премису «банк = детерминированный артефакт»** · веса формулы §C2-3 пере-мерены n=1 — пере-мер следующим живым касанием. + +## D39.46 — Пакет-7 ЗАКРЫТ ЦЕЛИКОМ (фазы B + B′ приняты): «контексты — главный рычаг терминолога» РАТИФИЦИРОВАНО как измеренное (разрыв 6 термов при размахе 1); слепой LLM-судья = катастроф-экран, НЕ ранжировщик; жанровый словарь инертен до расширения эмиссии на класс term; три процессных правила полигона — В НОРМЫ ПРОМТОВ (26.07, оркестратор №8). ✅ + +**Приёмка исполнением: 7 CONFIRMED / 1 PARTIAL / 0 REFUTED** (деньги $0.31030+$0.14013 пересчитаны из usage всех 446 вызовов; замеры T/J/R фазы B′ воспроизведены из сырья; R1-неизмеримость контроля — независимым кодом). Ревью-шапка с нитами — на отчёте `POLYGON_TERM_RESEARCH_B_2026-07-26.md`; B′ — `POLYGON_TERM_RESEARCH_B2_2026-07-26.md`. Итого пакет-7: **$0.45043 из коридора $1.20–2.60**. Самокоррекция сессии (ревизия §B6 нашла 4 материальные ошибки СВОЕГО судейского слоя, B′ проверил ревизию замером) — прецедент нормы author≠reviewer: единственная фаза без критика полноты = единственная с ошибками. + +**Ратифицировано фактами:** (1) **контексты — главный рычаг качества терминолога** (Z-B8: арм без KWIC теряет 6–7/19 при внутриармовом размахе 1) — экономить на контекстах нельзя, kwic-настройки = главная ось качество↔цена; (2) **слепая оценка годна только как катастроф-экран с декоем** (H7: разрыв до декоя 1.27–1.42 против межарменного 0.20; ранговое согласие с объективным слоем ничтожно); (3) **жанровый словарь инертен (0/80), пока эмиссия/подача не расширены на класс term** — усиливает открытое решение feed_cap из D39.45; (4) отбраковка судей фазы B снята (Z-B7 — контроль был структурно неизмерим); (5) узкая формулировка правила транскрипции — поддержана замером как редакторская опция (широкая опровергнута: P2−P5=+0.013<0.05). + +**Три процессных правила — в нормы промтов сессий (стандарт, как синк-мандат D39.38):** (а) у каждого измеряемого фактора обязан быть арм БЕЗ него — фактор во всех армах не измеряется ни одним; (б) в сравнительном протоколе: дедуп кандидатов + отбор только расходящихся позиций + контрольный ДЕКОЙ (без него «отличает ли оценщик годное» неизмеримо); (в) критик полноты — на КАЖДУЮ фазу, не на пакет. + +**На владельце (подписи, не блокируют очередь):** Z-B1 修炼 остаётся неподписанной (V0 не снят — тел изданий нет) · Z-B2 подписать 17 строк словаря, закрытых БКРС (筑基 подтверждена; 修士→культиватор спорна — словарное «христ. монах») · Z-B3 узкая формулировка правила транскрипции как редакторское решение (цена: без правила ломаются 月光蛊/王婆) · Z-B4 веб-фетч в v2 роли НЕ вводить, офлайн-словарь как пар-данные (для zh→ru собран). Полигон-очередь пуста; кандидаты следующего пакета: **вариативность банкноты 37↔14 (сигнал пака-20)** · ёфикатор/санитайзер-разметка · dialogue_dash на невиданном тексте. diff --git a/docs/POLYGON_PACKAGE7_TERM_SOURCES_SESSION_PROMPT.md b/docs/archive/prompts/POLYGON_PACKAGE7_TERM_SOURCES_SESSION_PROMPT.md similarity index 97% rename from docs/POLYGON_PACKAGE7_TERM_SOURCES_SESSION_PROMPT.md rename to docs/archive/prompts/POLYGON_PACKAGE7_TERM_SOURCES_SESSION_PROMPT.md index cc63cb7f..459ce501 100644 --- a/docs/POLYGON_PACKAGE7_TERM_SOURCES_SESSION_PROMPT.md +++ b/docs/archive/prompts/POLYGON_PACKAGE7_TERM_SOURCES_SESSION_PROMPT.md @@ -1,6 +1,6 @@ # Промт полигон-сессии: ПАКЕТ 7 (v2) — терминологическая ресёрч-программа: «как переводить термины и что такое „хорошо“» -**Статус: фаза A ИСПОЛНЕНА И ПРИНЯТА 26.07.2026 (D39.43; отчёт `docs/archive/reports/POLYGON_TERM_RESEARCH_A_2026-07-26.md` с ревью-шапкой). Фаза B — ПОСЛЕ решений владельца Z1–Z4 (§Z отчёта).** +**Статус: ПАКЕТ ЗАКРЫТ ЦЕЛИКОМ 26.07.2026 (A: D39.43 · Z-решения: D39.44 · B+B′: D39.46). Отчёты: `POLYGON_TERM_RESEARCH_A/B/B2_2026-07-26.md` с ревью-шапками. АРХИВ.** **Статус выдачи: ВЫДАН 26.07.2026 (v2 того же дня — расширение по директиве владельца; v1 не исполнялся).** Двухфазный: фаза A ($0) → СТОП/ратификация → фаза B (по смете, ориентир ≤$3). Идёт ПАРАЛЛЕЛЬНО бэкенд-сессии пака-20 — **`backend/` не трогать, её файлы в git-статусе чужие**. Зона записи: `eval/` + `docs/experiments/`; отчёты — `docs/archive/reports/`. diff --git a/docs/archive/reports/POLYGON_TERM_RESEARCH_B2_2026-07-26.md b/docs/archive/reports/POLYGON_TERM_RESEARCH_B2_2026-07-26.md new file mode 100644 index 00000000..f6e59ca1 --- /dev/null +++ b/docs/archive/reports/POLYGON_TERM_RESEARCH_B2_2026-07-26.md @@ -0,0 +1,244 @@ +# Полигон, пакет-7, ФАЗА B′ — доводка после адверсариальной ревизии: контроль правила транскрипции · годность слепой оценки · разброс прогона + +> **Статус: ПРЕ-РЕГИСТРАЦИЯ ЗАПИСАНА ДО ВЫЗОВОВ.** Секции §0 (замысел, пороги, смета) написаны прежде +> первого платного запроса; секции §1–§4 (результаты) дописываются после. Пре-регистрация после +> старта НЕ правится — как и в фазе B. +> **Основание:** ревизия §B6 отчёта [`POLYGON_TERM_RESEARCH_B_2026-07-26.md`](POLYGON_TERM_RESEARCH_B_2026-07-26.md) +> опровергла три вывода фазы B; санкция владельца на доводку получена 26.07.2026. +> **Зоны:** пишу в `eval/pkg7/` и в этот отчёт. `backend/` не тронут. Не коммичу. + +--- + +## §0. ПРЕ-РЕГИСТРАЦИЯ (записана до трат) + +### 0.1 Что именно чиню и почему это три РАЗНЫХ замера + +Ревизия §B6 оставила три вопроса, и ни один не закрывается рассуждением: + +| # | вопрос | почему фаза B на него не ответила | +|---|---|---| +| **T** | правило «имя собственное → транскрипция» — вредит или нет | правило лежало в общей части промпта, то есть во ВСЕХ шести армах: контраста не существовало | +| **J** | слепая оценка вообще годна или нет | 35 термов из 80 состояли из семи одинаковых строк; контроль отбраковки был неизмерим (строка `GOLD` не уникальна ни разу) | +| **R** | насколько твёрд вывод «контексты решают» | каждый арм гонялся ОДИН раз; разброс прогона от эффекта арма неотделим | + +### 0.2 Замер T — контрольный арм без правила + +**Арм `P5` = `P2` минус одна строка** («если термин — имя собственное, передавай его транскрипцией, +а не переводом смысла»). Всё остальное — контексты, черновики, жанровый словарь, Палладий, лемма — +идентично `P2` побайтно. Набор тот же, что в фазе B (80 термов), модель та же. + +| мера | как считается | что подтверждает | **что ОПРОВЕРГАЕТ** | +|---|---|---|---| +| **T1** качественная | `春秋蝉` и `Silversaint` у `P5` против `P2` | `P5` даёт смысловой перевод там, где `P2` транслитерирует | `P5` транслитерирует так же ⇒ правило ни при чём | +| **T2** прокси | доля целиком несловарных dst, `P5` против `P2`, по всем 80 | разница ≥ 0.05 в пользу `P5` | \|разница\| < 0.05 ⇒ эффекта нет | +| **T3** контроль вреда | совпадение с подписью владельца на 19 эталонных | `P5` не хуже `P2` (≥ 17/19) | `P5` < 17/19 ⇒ снятие правила ломает другое | + +**Решающее правило, названное заранее:** Z-B3 считается ИЗМЕРЕННОЙ, только если T1 и T2 +подтверждаются ОБА. Если подтверждается лишь T1 (два терма) — рекомендация остаётся редакторским +суждением, и я так и запишу. + +### 0.3 Замер J — слепая оценка на ПОЧИНЕННОМ наборе + +Чиню сборку набора, а НЕ инструмент: ревизия показала, что негоден был набор. + +1. **Дедупликация кандидатов.** Одинаковые строки схлопываются в ОДИН вариант; арм-источники + хранятся в ключе. Судья больше не сравнивает строку с ней же. +2. **Только термы с вариативностью** — где после дедупликации ≥2 различных варианта. +3. **ДЕКОЙ — то, чего в фазе B не было и без чего контроль §A2.4 п.4 неизмерим.** В каждый набор + вслепую подкладывается заведомо неверный вариант: dst ДРУГОГО терма той же выборки (рецепт WMT23, + перенесённый с глоссария на кандидата). Декой уникален по построению, поэтому вопрос «отличает ли + судья годное от случайного» становится измеримым, чего нельзя было сделать через `GOLD`. + +**Контроли годности судьи (пороги названы сейчас):** + +| контроль | замер | судья ОТБРАКОВЫВАЕТСЯ, если | +|---|---|---| +| **C1** | доля термов, где ДЕКОЙ назван лучшим по K1 | > 0.10 | +| **C2** | катастроф на декое против катастроф на `GOLD` | катастроф на `GOLD` ≥ чем на декое | +| **C3** | воспроизводит ли судья объективный слой | `P0b` НЕ в нижней трети по K1 | + +**Что этот замер решает:** если судья проходит C1 и C2 — слепая оценка ГОДНА, и дефект фазы B был +в наборе (моя ревизия права). Если проваливает — инструмент действительно негоден, и прав был +исходный отчёт. **Оба исхода публикую одинаково**, включая исход «моя ревизия ошиблась». + +### 0.4 Замер R — разброс прогона + +`P0b`, `P1`, `P2` × **3 независимых повтора** на выборках с эталоном (S1 + S5, 19 термов с подписью). + +- мера: совпадение с подписью владельца в каждом повторе; +- **подтверждает:** межарменный разрыв `P0b`↔`P1` (в фазе B это 5 термов из 15 на S1) БОЛЬШЕ + внутриармового разброса по трём повторам; +- **ОПРОВЕРГАЕТ:** разрыв укладывается внутрь разброса ⇒ вывод «контексты решают» не установлен + и держится только на объективном совпадении, а не на сравнении армов. + +### 0.5 Свои гипотезы (мандат свободы; пре-регистрирую отдельно) + +- **H6.** Вырожденность — свойство ТЕРМА, а не арма: доля термов, где все армы совпали, выше на + подписанных (S1) термах, чем на неподписанных книгах (S2–S4). Меряю долей вырожденных по выборкам. + Опровергается отсутствием разницы. *Практический смысл: если верно — предпрогон-скрин отбирает + информативные термы дёшево, и судить надо только их.* +- **H7.** Декой ловится судьёй ЛУЧШЕ, чем различаются армы: разрыв «декой против медианы» больше + разрыва «лучший арм против худшего». Опровергается обратным. *Смысл: показывает, где предел + разрешающей способности — грубые ошибки инструмент видит, тонкие нет.* + +### 0.6 Деньги и дисциплина + +- Цены — из `backend/configs/models.yaml` (read-only), расход — из фактического `usage`, сырьё + каждого вызова сохраняется **вместе с `usage` и `finish_reason`** (дефект записи фазы B, §B6 R7.2, чиню). +- **Смета печатается ДО вызовов** (`--dry-run`), факт — до цента. +- Ориентир, названный владельцу: **≈$0.15**. Останов и пинг при выходе за **$0.30** (это доводка, а + не новая фаза; санкционированный коридор фазы B израсходован на 11.9%). +- Квирки: консолидатор `deepseek-v4-flash` — thinking ВКЛЮЧЁН, `max_tokens` 8000, `temperature`/`top_p` + не шлём. Судьи — `gpt-5-mini` (`reasoning_effort:"minimal"`, `max_completion_tokens`) и `grok-4.3` + (явный `reasoning_effort`), оба из чужих семейств относительно консолидатора (D13.3). + +### 0.7 Чего замер НЕ решает (сказано заранее) + +Не даёт человеческого эталона для en/ja · не снимает V0 по `修炼` (нужно тело изданий, недоступно) · +не проверяет ja-жанровый лексикон · не трогает роль терминолога в коде. + +--- + +## §1. Замер T — правило транскрипции: причинность доказана, агрегатный эффект НЕТ + +Арм `P5` проверен исполнением ДО трат: его промпт отличается от `P2` **ровно одной строкой** +(18641 против 18556 символов, diff = одна удалённая строка). Прогон: 10 вызовов, 80 консолидаций, +0 пустых, $0.01560. + +### T1 — подтверждён, и сильнее, чем ожидалось + +| терм | эталон / изданный перевод | `P2` (правило есть) | `P5` (правило снято) | +|---|---|---|---| +| `春秋蝉` | **Весенне-осенняя цикада** (подпись владельца) | Чуньцючань ❌ | **Весенне-осенняя цикада** ✅ | +| `Silversaint` | «среброносец / Серебряный Святой» (класс улики понижен, §B6 R5) | Сильверсейнт | **Серебросвят** (калька) | + +Снятие ОДНОЙ строки вернуло точный канон владельца. Причинность правила на этом терме доказана +в чистом виде: всё остальное в промпте совпадает побайтно. + +### T2 — ПРОВАЛЕН по порогу, названному до трат + +| арм | P0b | P0 | P1 | P2 | P3 | P4 | **P5** | +|---|---|---|---|---|---|---|---| +| доля целиком несловарных dst (80 термов) | 0.212 | 0.125 | 0.188 | 0.188 | 0.212 | 0.200 | **0.175** | + +`P2 − P5 = +0.013` при пороге **0.05** ⇒ **систематического сдвига нет.** + +Почему — видно в 19 расхождениях `P2`↔`P5`, и это самое ценное в замере: + +| куда правило вредит (снятие ПОМОГАЕТ) | куда правило нужно (снятие ВРЕДИТ) | +|---|---| +| `春秋蝉` Чуньцючань → **Весенне-осенняя цикада** (канон) | `月光蛊` «гу Лунного света» (канон) → **«Лунный свет»**: потеряна вершина `蛊` | +| `Ashdrinker` Эшдринкер → Пепелитель | `王婆` «Ван По» → **«Старуха Ван»**: антропоним переведён смыслом | +| `Silversaint` Сильверсейнт → Серебросвят | `西门庆` «Си Мэнь Цин» → «Симынь Цин» | +| `ハイエルフ` хай-эльф → высокий эльф | `提刑` «судья» → **«тисин»**: без правила, наоборот, ТРАНСЛИТЕРИРОВАЛ | + +### T3 — пройден + +Совпадение с подписью владельца: `P5` **18/19** при пороге ≥17 — ровно как `P2`. В разрезе: +S1 14/15 (потерян `月光蛊`), **S5 4/4** (лучший результат среди всех армов, за счёт `春秋蝉`). + +### Вердикт T по решающему правилу §0.2 + +Требовались **T1 и T2 оба**. T2 провален ⇒ **Z-B3 остаётся редакторским суждением, а не измеренным +эффектом**, и я это фиксирую против собственной рекомендации. Но данные поддержали именно **узкую** +формулировку правки — «транскрипция для людей и мест, смысл для предметов, приёмов и существ»: у +обеих её половин теперь есть замеренный контрпример из противоположного лагеря. Исходная же широкая +формулировка отчёта B («правило систематически уводит») **опровергнута**. + +--- + +## §2. Замер J — слепая оценка: инструмент видит грубое и не видит тонкое + +Починенный набор: **45 термов** (вместо 80 с половиной вырожденных), **3–6 вариантов** на терм +(вместо ровно семи, часто одинаковых), декой в 45 из 45. Судьи — 45/45 вердиктов каждый, 0 ошибок. + +| | `gpt-5-mini` | `grok-4.3` | +|---|---|---| +| **C1** декой назван лучшим | **0/45 = 0.000** (порог ≤0.10) → ПРОШЁЛ | **0/45 = 0.000** → ПРОШЁЛ | +| **C2** катастрофы: декой против подписи | **43 против 0** → ПРОШЁЛ | **45 против 0** → ПРОШЁЛ | +| **C3** `P0b` в нижней трети | НЕТ | ДА | +| **C3-бис** ранговая корреляция с объективным слоем | ρ = **+0.429** | ρ = **−0.107** | +| счёт `GOLD` по K1 | +0.000 | **+0.625 — выше ВСЕХ армов** | +| разброс между армами | 0.200 | 0.200 (в фазе B было 0.088) | +| счёт декоя | **−0.933** | **−0.978** | + +**Три вывода, и один из них против моей же ревизии.** + +1. **Отбраковка судей в фазе B была неправомерна — подтверждено замером.** Оба судьи проходят оба + контроля с запасом, а находка, на которой стояла отбраковка («судья назвал подпись владельца + катастрофой 6 раз из 19»), **не воспроизводится**: на починенном наборе подпись не помечена + катастрофой НИ РАЗУ ни одним судьёй. `grok-4.3` вообще ставит подпись выше всех машинных армов. +2. **Но практический вывод отчёта B устоял — по другой причине.** Ранговое согласие с объективным + слоем ничтожно (+0.43 и −0.11), судьи расходятся между собой (у одного `P0` первый, у другого + предпоследний). **Ранжировать армы слепой оценкой по-прежнему нельзя.** +3. **H7 подтверждена на обоих судьях** (разрыв «медиана армов − декой» 1.267 и 1.422 против + межарменного 0.200): инструмент уверенно ловит ГРУБУЮ ошибку и не разрешает ТОНКУЮ разницу. + Это и есть его правильная роль — катастроф-экран, а не ранжирование. + +--- + +## §3. Замер R — разброс прогона: главный вывод фазы B устоял + +Три независимых повтора, `P0b`/`P1`/`P2` на 19 термах с подписью владельца: + +| арм | повторы | медиана | размах | +|---|---|---|---| +| `P0b` (без контекстов) | 11 · 12 · 12 | 12.0 | 1 | +| `P1` (с контекстами) | **19 · 18 · 18** | 18.0 | 1 | +| `P2` | 18 · 18 · 18 | 18.0 | **0** | + +**Разрыв `P0b`↔`P1` = 6 термов при внутриармовом размахе 1 терм ⇒ ВЫВОД УСТОЯЛ.** «Контексты — +главный рычаг» теперь имеет оценку разброса, а не одну точку. + +Побочно объяснилась аномалия фазы B: там `P0` дал 19/19 и выглядел «лучшим армом». В повторах `P1` +тоже выдаёт 19/19 в одном прогоне из трёх — то есть 19/19 лежит внутри шума прогона, и никакого +превосходства случайного глоссария не было. + +### H6 — подтверждена частично + +Доля вырожденных термов: S1 0.53 · **S2 0.13** · S3 0.47 · S4 0.53 · S5 0.40. Гипотеза («подписанные +термы вырожденнее неподписанных книг») верна против 金瓶梅, но не против ja/en. Настоящий предиктор +не «подписан/нет», а **класс терма**: у имён собственных 金瓶梅 много равноправных вариантов +транскрипции, поэтому армы там расходятся вчетверо чаще. Практический смысл прежний: дешёвый +предпрогон-скрин отбирает информативные термы, и судить надо только их — иначе половина бюджета +уходит на сравнение строки с самой собой. + +--- + +## §4. Деньги фазы B′ — до цента + +| статья | вызовов | вход, ток | выход, ток | USD | +|---|---:|---:|---:|---:| +| арм `P5` (контроль правила) | 10 | 56 427 | 28 991 | **0.01560** | +| повтор 1 | 12 | 64 206 | 27 563 | **0.00801** | +| повтор 2 | 12 | 64 206 | 27 900 | **0.00811** | +| повтор 3 | 12 | 64 206 | 26 771 | **0.00779** | +| судья `gpt-5-mini` | 45 | 45 981 | 6 140 | **0.02378** | +| судья `grok-4.3` | 45 | 50 313 | 5 583 | **0.07685** | +| **ИТОГО** | **136** | **345 339** | **122 948** | **$0.14013** | + +- Смета до вызовов: **$0.1689**. Факт **$0.14013** — на 17.0% ниже. Стоп-порог $0.30 не достигнут. +- Ошибок вызовов **0 из 136**, `finish=stop` везде, пустых консолидаций 0 из 395, вердиктов 90/90. +- Брака нет: пере-прогонов из-за дефектов подачи в этой фазе не было. +- Суммарно по пакету-7: фаза B $0.31030 + фаза B′ $0.14013 = **$0.45043** при санкционированном + коридоре $1.20–2.60. + +**Дефекты записи фазы B, починенные здесь:** сырьё судей теперь хранит `usage` и `finish_reason` +(§B6 R7.2) · весь объективный слой считается скриптами `score_b2.py`/`audit_phaseB.py`, а не +разовыми командами (R7.1) · потерянные из-за многословных ключей вердикты (`K1 верность концепту` +вместо `K1`) восстановлены нормализацией разбора из сохранённого сырья, без повторных вызовов. + +--- + +## §5. Что теперь на владельце — обновление §Z отчёта B + +| # | было | стало после B′ | +|---|---|---| +| **Z-B3** | «принять правку промпта» → понижено ревизией до гипотезы | **Проверено. Широкая формулировка ОПРОВЕРГНУТА (T2), узкая ПОДДЕРЖАНА замером.** Предлагаю принять формулировку §B3.2 как редакторское решение с оговоркой: правило обязано различать антропонимы/топонимы (транскрипция) и предметы/приёмы/существ (смысл), потому что снятие правила целиком ломает `月光蛊` и `王婆` | +| **Z-B5** | «нужен ли другой инструмент оценки» | **Другой инструмент НЕ нужен.** Нужна другая СБОРКА набора: дедуп кандидатов + декой + отбор расходящихся термов. На починенном наборе оба судьи проходят контроли | +| **Z-B7** | «отбраковка снята» | **Подтверждено замером:** находка, обосновывавшая отбраковку, не воспроизводится. Но ранжировать армы слепой оценкой всё равно нельзя (ρ = +0.43 / −0.11): её роль — катастроф-экран | +| **Z-B8** (новое) | — | **«Контексты — главный рычаг» установлено с разбросом**: разрыв 6 термов против размаха 1. Это самый твёрдый количественный результат пакета-7 | + +**Чего B′ по-прежнему НЕ покрывает:** V0 по `修炼` (нужно тело изданий) · человеческий эталон для +en/ja · японский жанровый лексикон · роль терминолога в коде. Повторы сделаны только на zh-выборках +с подписью; для ja/en разброс не мерен. diff --git a/docs/archive/reports/POLYGON_TERM_RESEARCH_B_2026-07-26.md b/docs/archive/reports/POLYGON_TERM_RESEARCH_B_2026-07-26.md new file mode 100644 index 00000000..acea903a --- /dev/null +++ b/docs/archive/reports/POLYGON_TERM_RESEARCH_B_2026-07-26.md @@ -0,0 +1,622 @@ +# Полигон, пакет-7, Z4-добор + ФАЗА B: словарь на подпись, отбор промпта терминолога, вердикт по веб-фетчу + +> **Ревью-шапка (оркестратор №8, 26.07): B + B′ ПРИНЯТЫ, D39.46.** Приёмка исполнением из сохранённого +> сырья (скрипты полигона + независимый пересчёт): **7 CONFIRMED · 1 PARTIAL · 0 REFUTED**. Деньги +> пересчитаны из `usage` каждого из 446 вызовов по прайсам models.yaml — $0.31030 + $0.14013 до цента; +> B′-замеры T/J/R воспроизведены (дифф промптов 10/10 батчей = ровно одна строка; декой 0/45 лучшим и +> 43–45/45 катастрофой при 0 на подписи; P0b [11,12,12] против P1 [19,18,18]); R1 «GOLD не уникален +> 0/19» — независимым кодом; покрытие словарём 0/80 и 1/80 — точно. **PARTIAL:** формула «P0b встаёт +> последним» неточна — по K1/K5 на невырожденных последний **P0** (P0b шестой; цифры переворота +> 0.044→0.122 / 0.056→0.189 верны). **Ниты:** архивный `phaseB2/termset_gold.json` неполон (35/80 — +> H6 из него буквально не ре-ранится, восстанавливается из полных arms/j/raw); сырьё судей фазы B без +> usage (ваш же R7.2, в B′ починено). Самокоррекция сессии (§B6 + B′) — образцовая: единственная фаза +> без критика полноты дала единственные материальные ошибки, и это теперь норма промтов (D39.46). + +> **Статус: Z4-добор ИСПОЛНЕН, фаза B ИСПОЛНЕНА по пре-регистрации §A5.** Записка оркестратора №8 от 26.07.2026 (D39.43/D39.44). +> **Деньги:** смета названа ДО вызовов, факт — из `usage` каждого ответа, до цента; см. §B0 и §B5. +> **Зоны:** писал в `eval/pkg7/` и в этот отчёт. `backend/` не тронут. Не коммитил. +> Предыдущий отчёт пакета: [`POLYGON_TERM_RESEARCH_A_2026-07-26.md`](POLYGON_TERM_RESEARCH_A_2026-07-26.md). +> +> ⚠ **ПОПРАВКА ОТ АВТОРА (после сдачи, до приёмки): §B2.3, §B2.4, §B3.2 и §Z4.2 ЧИТАТЬ ТОЛЬКО ВМЕСТЕ С [§B6](#b6-ревизия-исполнением-адверсариальная-проверка-собственных-выводов).** +> **Развязка: ревизия ПРОВЕРЕНА ЗАМЕРОМ — см. [`POLYGON_TERM_RESEARCH_B2_2026-07-26.md`](POLYGON_TERM_RESEARCH_B2_2026-07-26.md) (фаза B′, $0.14013).** +> Итог: отбраковка судей действительно была неправомерна (находка не воспроизводится), но +> ранжировать армы слепой оценкой всё равно нельзя · широкая формулировка §B3.2 опровергнута, +> узкая поддержана · «контексты — главный рычаг» подтверждено тремя повторами (разрыв 6 термов +> против размаха 1). +> +> Адверсариальная перепроверка исполнением (`eval/pkg7/audit_phaseB.py`) нашла в фазе B четыре +> материальные ошибки: отбраковка судей не подтверждается данными · вывод «судьи не имеют +> разрешающей способности» опровергается на этих же данных · у правила транскрипции не было +> контрольного арма · «три независимые улики» под §B3.2 не независимы. Объективный слой §B1.1, +> покрытие §B1.2, деньги §B5 и весь §Z4 перепроверку ПРОШЛИ без изменений. + +--- + +## Эхо-блок + +``` +1. Порядок исполнен как предписано: сначала Z4-добор ($0), потом фаза B по пре-регистрации A5. +2. Пре-регистрация A5 НЕ правилась после старта: 6 армов (P0 случайный глоссарий · P0b без контекстов · + P1 · P2 · P3 · P4), K2 = н/д на S3/S4, критерии провала F1–F4, подписанный dst участвует слепо. +3. Процессный урок исполнен: сначала сделано всё, что НЕ тратит поисковую квоту (curl/локальные клоны), + квота сохранена под UNCLEAR-строки; БКРС взят куки-челленджем — 84 статьи, ноль поиска. +4. Решения владельца применены: Q3 порог «все ≥1 + катастроф-экран» · Q4 сокращённая форма = ПОЛЕ · + Q5 штраф первого вхождения + счётчик распространённости · Q6 «краткость» НЕ включена · Z2 = V0. +5. Деньги: смета до вызовов, потолок $2.60, выход за него = стоп и пинг. Потолок НЕ достигнут. +6. Зоны: backend/ чужой (пак-20 дописывает терминолога, его промпт ИНТЕРИМ); сессия НЕ коммитит. +7. Гардрейлы: .env не читал (ключи читают скрипты) · L3 не обрабатывал · refusal_corpus не открывал. +8. Заявление = команда: все прогоны воспроизводимы командами §B0, сырьё сохранено целиком. +9. Подсказка владельца про /home/ubuntu/projects/tmp принята — конкуренты разобраны по ЛОКАЛЬНЫМ клонам, + а не по фетчу: это надёжнее и не тратит квоту. +10. Чего НЕ закрыл — §X, там же честно назван класс улики, оставшийся недобранным. +``` + +--- + +## §Z4. Итоги добора (все пять заходов, $0) + +### Z4.1+Z4.4 БКРС ВЗЯТ — главный результат добора + +В фазе A «что говорит словарь» стояло UNCLEAR по **всем** терминам: страница считалась JS-рендеримой. Причина оказалась другой и снимаемой: `bkrs.info` ставит одноразовый куки-челлендж `ca=` и перезагружает страницу. Воспроизведя куку (`eval/pkg7/bkrs_lookup.py`), получаем серверный HTML со статьёй. + +**Результат: 84 словарные статьи** (38 по списку §A4.3 + 48 по реальным термам фазы B), ноль вызовов веб-поиска. Словарная улика — сильнейший класс из доступных (сильнее площадочной и сильнее аннотации), и она закрыла больше строк §A4.3, чем весь остальной добор вместе. + +Лицензия источника со страницы «Скачать словарь» (дамп 2026.06.10): «**Базы можно использовать свободно в любых целях. Можно указать данный сайт как источник.**» — провенанс-класс серый→зелёный при указании источника, источник указан. + +### Z4.3 Частотный узус — спор «культивация vs совершенствование» переведён в измерение + +Google Books Ngram, корпус `ru-2019`, 1900–2019, сглаживание 3 (чистый `curl`, без поисковой квоты): + +| слово | пик | ср. 2010–2019 | +|---|---|---| +| совершенствование | 1983 | **6.615e-06** | +| культивирование | 1989 | 3.331e-07 | +| культивация | **1938** | 5.532e-08 | +| культиватор | **1938** | 5.834e-08 | +| совершенствующийся | 1920 | 1.660e-08 | + +**Три измерения, которых не было ни у одной стороны спора:** + +1. **Корреляция «культивация» × «культиватор» = 0.9715** на 120-летнем ряду. Пик у обоих — 1938 год. То есть в русском книжном корпусе «культивация» движется как ОДНО слово с сельхоз-орудием: это агротехнический термин практически без исключений. Корреляция «культивация» × «совершенствование» = **−0.0704** (связи нет). +2. **Отношение частот 2010–2019: «совершенствование» / «культивация» = 119.6×.** +3. Биграмма «**мир совершенствующихся**» (формулировка аннотации Эксмо) в книжном корпусе — **0 вхождений**; «культивация почвы» и «совершенствование духа» — есть. То есть жанровая формулировка издателя сама по себе является свежей чеканкой, а не устоявшимся книжным оборотом. + +**Что это доказывает и чего не доказывает.** Доказывает: принятие «культивации» импортирует омонимию, коррелирующую с сельхоз-смыслом на 0.97 — это прямой минус по K1 (верность концепту) и K8 (однозначность), независимый от узуса. Не доказывает: как жанр должен переводить термин сегодня — корпус Ngram кончается 2019-м и книг вебновелл в нём нет. Ограничение названо. + +### Z4.5 Конкуренты по продукту — по локальным клонам (подсказка владельца) + +Разобраны исходники шести переводческих харнессов из `/home/ubuntu/projects/tmp` (свежесть клонов: AiNiee 16.07.2026, LunaTranslator 23.07.2026, bilingual_book_maker 19.07.2026, GalTransl 23.05.2026, TransAgents 01.05.2025, translation-agent 08.07.2024). + +**Настоящая сходимость трёх независимых команд** (три кодовые базы, три автора, одинаковый вывод): глоссарий, который едет в промпт, имеет форму **`src → dst + примечание`**, и он **фильтруется по тому, что реально встретилось в текущем чанке**, а не подаётся целиком. + +- AiNiee: `build_glossary_prompt` собирает таблицу `原文|译文|备注` только из строк, совпавших со входом (`collect_matched_rows(..., input_dict)`); есть тумблеры `case_sensitive` и `whole_word`; ключ может быть регуляркой (`src_state ∈ valid|regex|invalid`). +- SakuraLLM (через `LunaTranslator/translator/sakura_base.py`): в промпт идёт «参考以下术语表(可为空,格式为 **src->dst #备注**)». +- GalTransl: `Dict/GPT字典.txt` — `srcdstпояснение`. + +**Механизм, которого нет ни у нас, ни в ISO/TBX, и который есть у ДВУХ конкурентов — «не переводить».** AiNiee строит отдельный блок `禁翻表` («Non-Translation List, Leave the following marked content untranslated») с колонками «маркер|примечание», собираемый по регуляркам из текущего текста. Это боевой аналог того `do-not-normalize`, который в фазе A был найден только у копиредактора в style sheet. + +**Самая ценная находка для нашей архитектуры — трёхслойный словарь GalTransl:** + +| слой | файл | что делает | +|---|---|---| +| `译前` (до перевода) | `00通用字典_译前.txt` | нормализует ИСХОДНИК (орфографические варианты → канон): `流石→さすが`, `滅茶苦茶→めちゃくちゃ` | +| `GPT字典` | `Dict/GPT字典.txt` | инъекция в промпт: `srcdstпояснение` | +| `译后` (после перевода) | `00通用字典_译后.txt` | правит ВЫХОД, и **условно** | + +Условный слой — это то, чего у нас нет и что решает нашу главную боль. Модель записи (`GalTransl/Dictionary.py:36-50`): `search_word`, `replace_word`, `startswith_flag` (`^^` — только в начале строки), `onetime_flag` (`1^` — заменить один раз), `note` («For GPT» — то, что уезжает в промпт), и два несущих поля: + +- **`is_conditionaDic` + `if_word_list`**: ключи условий `pre_src, post_src, pre_dst, post_dst, pre_jp, post_jp, pre_zh, post_zh`. Пример из их данных: `pre_jp 人妻[or]ひとづま → 有夫之妇` = «замени в ВЫХОДЕ, только если в ИСХОДНИКЕ было 人妻 или ひとづま»; `!サタン` = «только если НЕ было». Это **контекстно-обусловленная пост-замена** — ровно то, что отличает исправление «модель выбрала не тот синоним для ЭТОГО исходного термина» от слепого find-and-replace, который MS сам называет «brute force copy and replace». +- **`is_situationsDic`** с ключами `mono`/`diag`: **разное соответствие в повествовании и в реплике.** Это смыкается с паком-19 «голос и состояние» и означает, что «один термин — один dst» не универсально даже у практиков. + +**Контр-находка, снимающая ореол с флагманов.** `TransAgents` (мультиагентная «виртуальная переводческая компания» для художественного перевода) — в репозитории **0 файлов `.py`**: только README, картинки и выходы. Терминологического механизма не опубликовано вообще. А `translation-agent` Эндрю Ына прямо называет нашу задачу ОТКРЫТОЙ: + +> «**Glossary Creation.** What's the best way to efficiently build a glossary — perhaps using an LLM — of the most important terms that we want translated consistently?» +> «**Glossary Usage and Implementation.** Given a glossary, what's the best way to include it in the prompt?» + +И там же — независимое подтверждение нашего §A2.0 про слепоту автометрик: «even on documents where our agentic workflow captures context and terminology better, resulting in translations that our human raters prefer over current commercial offerings, evaluation at the sentence level … resulted in the agentic system scoring lower on BLEU». + +### Z4.2 Изданные русские переводы книг стенда — частично + +**Empire of the Vampire издан на русском:** «Империя вампиров», Джей Кристофф, ISBN 978-5-17-118802-3, АСТ, перевод Н. Абдуллина. Терминология тома 1, релевантная нашему тому 3: `Silversaint` → **«среброносец» / «Серебряный Святой»**, `Silver Order` → **«Серебряный орден»**. Это настоящий человеческий эталон для en-пары, и он же — первый найденный нами живой кейс переноса терминологии между томами серии. + +**Search-inside закрыт ОТРИЦАТЕЛЬНЫМ результатом, а не «не дошёл».** Google Books API по трём ISBN лицензионных изданий — «Мастер тёмного пути» (978-5-04-232151-1), «Магистр дьявольского культа» (978-5-907340-00-8), «Империя вампиров» (978-5-17-118802-3) — отдаёт `totalItems=0` на каждом. **Этих изданий в Google Books нет вообще**, поэтому заход «search-inside по телу ISBN», предложенный критиком полноты и санкционированный Z4, механически неисполним: искать внутри нечего. Это не пропущенная модальность, а проверенная и отсутствующая. + +**Следствие для Z2:** класс улики «тело лицензионного издания» остаётся недобранным, и остаётся недобираемым тем способом, который планировался. Доступные пути к нему — платный фрагмент ЛитРес или бумажная книга, оба вне зоны сессии. Поэтому арм `HUMAN` в фазе B построен на подписанном сиде владельца (S1/S5), а не на изданных переводах. + +--- + +## §Z4-Т. Обновлённая таблица §A4.3 — НА ПОДПИСЬ + +Изменение против фазы A: колонка улики теперь имеет класс **`слов.` (БКРС)** — он сильнее площадочного и сильнее аннотации. **Из 20 строк со статусом UNCLEAR закрыто словарём 17.** + +| src | dst (предлагаю) | улика класса «словарь» (БКРС, дословно) | статус | +|---|---|---|---| +| 修炼 / 修真 / 修行 | **⟨V0: не подписывать до тела изданий⟩** | 修炼 «совершенствовать и закалять себя»; 修真 «совершенствование, взращивание совершенного»; 修行 «совершенствовать поведение (нравственность)» — **«культивация» в словаре отсутствует** | **развилка владельца** | +| 修士 | ⟨не подписывать⟩ | «**христ. монах**, член монашеского ордена; подвижник, аскет» | **КОЛЛИЗИЯ: строка `修士→культиватор` в `genre-glossary.txt` словарём не поддержана и спорит со стандартным значением** | +| 修为 | уровень совершенствования | «1) практическое исполнение 2) корректирующие действия 3) **культивирование; совершенствование**» | закрыт словарём; NB: «культивирование» словарь даёт именно ЗДЕСЬ, а не у 修炼 | +| 真气 | истинная ци | «**истинная ци**, истинная (абсолютная) субстанция, эссенция ци» | закрыт, совпал с сидом | +| 灵气 | духовная энергия | «1) сверхъестественная сила 2) божественный дух, духовное начало» | закрыт частично: словарь не даёт «духовную энергию» дословно | +| 筑基 | закладка основания | «**закладывание фундамента, заложение основ**» | **закрыт — строка `genre-glossary.txt` подтверждена** | +| 金丹 | золотое ядро (жанр) | «даос. золото и киноварь; снадобье бессмертия, **золотой эликсир**» | два регистра: словарь даёт академический, жанр — «ядро». Развилка Q2 | +| 丹田 | даньтянь | «1) кит. мед. **даньтянь** … 2) даос. **киноварное поле**, поле эликсира» | **закрыт — оба варианта в одном словаре** | +| 元神 | изначальный дух | «1) великий дух 2) даос. душа человека, **Изначальный дух**» | закрыт | +| 元婴 | ⟨UNCLEAR⟩ | русской части статьи нет (только китайское толкование) | остаётся UNCLEAR | +| 经脉 | меридианы | «кит. мед. **каналы**» (+ «Меридианы, Цзин-м…») | закрыт | +| 穴位 | акупунктурная точка | «кит. мед. местоположение **акупунктурных точек**» | закрыт | +| 心法 | тайное искусство | «1) профессиональный **секрет; тайна, тонкость** (передаваемая учителем ученикам) 2) будд. духовное наставление» | закрыт | +| 秘籍 | тайный трактат | «1) **редкая книга, букинистическая редкость**» | закрыт частично (жанровый смысл шире словарного) | +| 功法 | техника | «1) методы работы; тренировочные методы 2) **навык, техника** (боевых искусств)» | закрыт | +| 法宝 | сокровище дхармы / артефакт | «1) будд. **дхарма**… 2) будд. одежда…» | закрыт частично | +| 丹药 | пилюля | «1) даос. **пилюли бессмертия, эликсир бессмертия** 2) лекарственная пилюля» | закрыт | +| 符箓 | фулу / талисман | «даос. **фулу; письменное заклинание; магический текст; талисман, амулет**» | закрыт | +| 轻功 | цингун | «**цингун**; различные способности лёгкого передвижения (ушу)» | закрыт | +| 内功 | внутренняя работа | «кит. ушу **внутренняя работа**» | закрыт | +| 内力 | внутренняя сила | «**внутренняя сила**; эндогенный» | закрыт (площадка давала «внутреннюю энергию» — словарь поправляет) | +| 境界 | ступень / уровень | «1) границы, пределы 2) **уровень, степень; предел**» | закрыт | +| 飞升 | вознесение | «1) **вознестись, вознесение**» | закрыт | +| 天劫 | небесная кара | «1) **Небесное испытание** 2) Небесное бедствие; Небесная скорбь» | закрыт | +| 灵石 | духовные камни | «2) **духовные камни**» | закрыт | +| 长老 | старейшина | «1) старший, **старейшина**; старик, старец» | закрыт | +| 掌门 | глава школы | «1) **руководитель религиозного учения, секты** 2) глава, мастер» | закрыт | +| 剑修 | ⟨UNCLEAR⟩ | «**такого слова нет**» — словарь отвечает отрицательно, это жанровая чеканка | UNCLEAR, но теперь ОБОСНОВАННО | +| 妖 | нечисть / яо | «1) **нечистая сила, нечисть**; призрак; оборотень; чудовище» | закрыт | +| 魔 | демон / мо | «1) **злой дух, демон, дьявол**, чёрт» | закрыт; **NB: 妖 и 魔 нельзя оба переводить «демон»** | +| 仙 | бессмертный | «1) [даосский] отшельник; **бессмертный, небожитель**; святой» | закрыт, совпал с академией | +| 江湖 | цзянху | «1) реки и озёра 2) лоно природы; сельская глушь» | словарь НЕ даёт «цзянху»; транслитерация — жанровая конвенция | +| 走火入魔 | искажение ци | «1) **помешаться на…, одержимый** 2) **утратить связь с реальностью**» | закрыт; жанровый смысл описан в китайской части статьи | +| 资质 | талант | «1) **природные данные** 2) квалификация» | закрыт, поддерживает канон D39.21 | +| 凡人 | смертный | «1) обычный человек 2) всякий человек; **мирянин, смертный**» | закрыт, совпал с сидом | + +**Что НЕ входит в словарь по-прежнему:** книжный канон (`古月`, `方源`, `春秋蝉`, `蛊`) — живёт с книгой; чужой фан-канон целиком — красный класс §A1.3. + +**Отдельно к подписи — три строки уже созданного `backend/configs/langpacks/zh-ru/genre-glossary.txt` (чужая зона, не трогал):** + +| строка файла | вердикт добора | +|---|---| +| `筑基 → закладка основания` | **ПОДТВЕРЖДЕНА** словарём («закладывание фундамента, заложение основ») | +| `修炼 → культивация` | развилка V0/V1/V2/V3 не снята; словарь «культивации» не знает, Ngram показывает сельхоз-омонимию r=0.97 | +| `修士 → культиватор` | **СПОРНА**: словарное значение — «христ. монах»; жанровое употребление словарём не зафиксировано | + +--- + +## §B0. Фаза B: что построено, чем считались деньги, какие отклонения объявлены ДО трат + +### B0.1 Материал — реальные артефакты стенда, не синтетика + +Вход роли ТЕРМИНОЛОГ по D39.42 п.1 собран `eval/pkg7/build_termset.py` из: + +- `books/gu-zhenren/labels/raw/corpus.jsonl` — **91 юнит из ШЕСТИ настоящих прогонов** (minirun, acceptance, rerun2-dspro/glm/mistral, verify2) с выровненными `source`/`draft`/`final`. Это постоянный измерительный стенд пакета-6 (D39.39); +- сид книги + `minirun/mined-delta.yaml` — **подписанные владельцем dst** (золотой стандарт); +- TSV боевого майнера фазы A — намайненные термы БЕЗ dst; +- JSONL-субстраты 金瓶梅 / исэкая / Empire of the Dawn — для S2–S4. + +**Набор ровно по пре-регистрации: 80 термов** — S1 30 (15 подписанных + 15 намайненных), S2 15, S3 15, S4 15, S5 5 (ловушка). Из них 19 имеют эталон владельца, 35 — пары «исходник ↔ черновик». + +### B0.2 Отклонения, объявленные ДО первого платного вызова + +Пре-регистрация после старта не правится, поэтому всё, что отличается от буквы §A5, названо здесь заранее, а не объяснено задним числом. + +1. **Батчинг по 10 термов на вызов.** Единица учёта остаётся «терм × арм» (480 консолидаций), но доставляются они 60 вызовами. Основание — ратифицированная архитектура: D39.42 п.1 прямо говорит «батчи, дешёвая модель». Пер-термовые вызовы умножили бы стоимость инструкции на 80. +2. **Компонента «варианты черновиков» пуста на S2–S4** — переводов этих книг не существует, мы их не делали. Следствие: на S2–S4 арм P1 вырождается в «только KWIC». Это ограничение материала, а не правка дизайна. +3. **Справка для арма P4 есть только у zh-термов.** БКРС — китайско-русский словарь; для японской (S3) и английской (S4) выборок справки нет по построению. Вердикт по веб-фетчу выносится по S1/S2/S5. +4. **Подписанный владельцем dst участвует в слепом сравнении как отдельный кандидат `GOLD`.** Без этого невозможен вердикт F2 («бьёт ли арм эталон»), и невозможна отбраковка оценщика, не отличающего подпись от случайного варианта (§A2.4 п.4). + +### B0.3 Дефект подачи, пойманный самопроверкой ДО трат на судей + +После прогона шести армов я пере-мерил покрытие арма P4 и обнаружил, что справка дошла лишь до **2 термов из 80**: я кормил P4 списком терминов §A4.3 (жанровый словарь), тогда как набор фазы B состоит в основном из книжных термов (方源, 古月, 学堂, 酒虫) и не-китайских. В таком виде P4 ≈ P2 на 78 термах, и вердикт по веб-фетчу был бы пустым. + +**Исправлена ПОДАЧА ДАННЫХ, а не дизайн арма:** добраны словарные статьи для реальных zh-термов набора (48 штук, $0, тем же `bkrs_lookup.py`), после чего **пере-прогнан ТОЛЬКО арм P4**; остальные пять армов остались от первого прогона нетронутыми. P4 всегда определялся как «P2 + предварительно добытая справка по терму» — ему дали ту справку, которую он и должен был получать. + +Побочная удача этой починки: в S5 попал терм `时辰`, и словарная статья дословно содержит канон D39.21 — «стар. большой час (одна двенадцатая часть суток, **был равен 2 часам**)». То есть ловушка теперь проверяет и то, пользуется ли модель справкой, когда справка прямо противоречит буквальному «час». + +### B0.4 Дисциплина денег + +Цены берутся из `backend/configs/models.yaml` (read-only), расход считается из фактического `usage` каждого ответа с учётом `cached_tokens`, сырьё каждого вызова (промпт + ответ + usage + finish_reason) сохраняется целиком — правило 1 полигона. Смета печатается ДО вызовов режимом `--dry-run`. + +**Смета до вызовов (консолидация):** вход ≈180.3k токенов → $0.0252; выход ≈150.0k → $0.0420; **итого ≈ $0.0672**. + +### B0.5 Соблюдение квирков провайдеров + +Читал `00-provider-quirks.md` перед вызовами. Консолидатор — `deepseek-v4-flash`: thinking оставлен **ВКЛЮЧЁННЫМ** (выключение = эхо-мина на плотном CJK), `max_tokens` 8000 (пол по квирку: thinking ⊆ completion), `temperature`/`top_p` не шлются вовсе (в thinking-режиме молча игнорируются). Судьи — из ДРУГИХ семейств (D13.3: судья не судит выход своего семейства): `gpt-5-mini` с `reasoning_effort:"minimal"` и `max_completion_tokens` (его квирк), `grok-4.3` с явным `reasoning_effort` (дефолт у xAI — `low`, must-be-explicit). + +--- + +## §B1. Объективный слой: измерения, не зависящие от судей + +Прежде судейских вердиктов — меры, которые считаются машиной и которые невозможно «уговорить». Они служат вторым способом измерения того же (мандат самопроверки 12.07). + +### B1.1 Точное совпадение с ПОДПИСЬЮ ВЛАДЕЛЬЦА (19 термов с эталоном) + +| арм | совпало | доля | +|---|---|---| +| **P0** (случайный глоссарий) | 19/19 | **1.000** | +| P1 (KWIC + черновики) | 18/19 | 0.947 | +| P2 (+ пар-данные) | 18/19 | 0.947 | +| P3 (+ рубрика) | 17/19 | 0.895 | +| P4 (+ словарная справка) | 17/19 | 0.895 | +| **P0b** (без контекстов) | 12/19 | **0.632** | + +Мягкое совпадение (без регистра и порядка слов) даёт те же цифры — расхождения не косметические. + +В разрезе выборок картина ещё резче: + +| выборка | P0b | P0 | P1 | P2 | P3 | P4 | +|---|---|---|---|---|---|---| +| S1 основная (n=15) | 10/15 | **15/15** | **15/15** | **15/15** | 14/15 | 14/15 | +| S5 ловушка (n=4) | 2/4 | **4/4** | 3/4 | 3/4 | 3/4 | 3/4 | + +**На основной выборке три арма с контекстами совпали с подписью владельца ИДЕАЛЬНО (15/15)**, и различить их между собой нечем. Разброс выходов внутри терма подтверждает то же: на S1 единогласны 16 термов из 30 (в среднем 1.53 разных варианта), тогда как на 金瓶梅 без черновиков и сида — только 2 из 15 (в среднем 2.40). + +**Читается это так, и только так.** Разрыв **P0b против всех остальных (0.632 против 0.895–1.000) — единственный крупный и устойчивый**: контексты работают, и это главный рычаг. Различия внутри группы P0/P1/P2/P3/P4 — это **один-два терма из девятнадцати**, то есть шум на такой выборке; утверждать по ним ранг армов нельзя, и я не утверждаю. + +### B1.2 Почему глоссарный контраст на этой выборке НЕ измерился — механизм, а не догадка + +Диагностика, снятая машинно: **0 из 80 термов набора покрыты жанровым словарём**. Из 19 термов с эталоном покрыто тоже **0**. + +Что именно уезжало в промпт — восстановлено из СОХРАНЁННОГО сырья, а не из файла (файл в чужой зоне и правится параллельной сессией): + +``` +арм P2 (правильный словарь) арм P0 (контроль: dst перемешаны) +修真 культивация 修真 культивация +修行 культивация 修行 золотое ядро +丹田 даньтянь 丹田 духовная энергия +经脉 меридианы 经脉 даньтянь +灵气 духовная энергия 灵气 меридианы +金丹 золотое ядро 金丹 культивация +``` + +Шесть строк, ни одна из которых не встречается среди восьмидесяти оцениваемых термов. Контроль по рецепту WMT23 собран корректно (dst действительно переставлены) — и именно поэтому он ничего не показал: подменять было нечего. + +Значит компонента «жанровый словарь» была **инертна во всех армах, где она есть** — и в P2/P3/P4 с правильным словарём, и в P0 со случайным. Контраст «правильный против случайного» на этом наборе физически не мог проявиться. + +**И это не случайность выборки, а прямое следствие находки фазы A.** WHICH-канал майнера отдаёт книжный канон (имена, места, титулы: recall 0.231 на `name`), а жанровый словарь покрывает жанровые понятия (recall `term` = 0.040). **Два множества не пересекаются по построению.** Практический вывод для пака-20 сильнее, чем сам эксперимент: жанровый словарь помогает терминологу ровно на тех термах, которых майнер не приносит, — то есть подавать его в роль осмысленно только вместе с расширением WHICH-канала на класс `term`. + +### B1.3 Прокси «транслитерация вместо кальки» (доля dst, целиком отсутствующих в словаре русского) + +Считается pymorphy3: у кальки («Серебряный Святой») знаменательные слова словарные, у транслитерации («Сильверсейнт») — нет. + +| выборка | P0b | P0 | P1 | P2 | P3 | P4 | +|---|---|---|---|---|---|---| +| S1 zh канон | 0.11 | 0.10 | 0.10 | 0.10 | 0.10 | 0.10 | +| S2 zh классика | 0.33 | 0.44 | 0.33 | 0.33 | 0.40 | 0.40 | +| S3 ja | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | +| S4 en | 0.20 | **0.07** | 0.13 | 0.13 | 0.13 | **0.07** | +| **S5 ловушка** | 0.25 | 0.25 | 0.25 | **0.50** | **0.50** | **0.50** | +| всего | 0.16 | 0.12 | 0.14 | 0.15 | 0.17 | 0.15 | + +**На ловушке армы с правилом транскрипции транслитерируют вдвое чаще** (0.50 против 0.25). Ячейки маленькие (S5 = 4–5 термов), поэтому это указание, а не доказательство — но оно совпадает с двумя независимыми ручными наблюдениями ниже. + +### B1.4 Ловушка S5: где именно ломается + +| терм | канон / эталон | P0b | P0 | P1 | P2 | P3 | P4 | +|---|---|---|---|---|---|---|---| +| 时辰 | канон D39.21: **2 часа**, не «час» | страж | шичэнь | **час** ❌ | шичэнь | шичэнь | шичэнь | +| 蛊 | гу | гу ✓ | гу ✓ | гу ✓ | гу ✓ | гу ✓ | гу ✓ | +| 资质 | талант | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| 转 | ранг | **оборот** ❌ | ранг ✓ | ранг ✓ | ранг ✓ | ранг ✓ | ранг ✓ | +| 春秋蝉 | **Весенне-осенняя цикада** | Чуньцючань ❌ | **Весенне-осенняя цикада** ✓ | Чуньцючань ❌ | Чуньцючань ❌ | Чуньцючань ❌ | Чуньцючань ❌ | + +Два разных механизма отказа, оба воспроизводимые: + +1. **`时辰` → «час»** у P1 — это ровно дефект класса DC1, который ловят чекеры (пакет-6, K1). Армы со словарной справкой его НЕ допустили, и справка по 时辰 дословно содержит «стар. большой час (одна двенадцатая часть суток, **был равен 2 часам**)». Единственный случай во всём прогоне, где веб/словарная справка видимо предотвратила подлинную ошибку. +2. **`春秋蝉` → «Чуньцючань»** у ВСЕХ армов с правилом транскрипции. Инструкция «имя собственное передавай транскрипцией» перевесила канон, который переводит название гу по смыслу. + +### B1.5 Сверка с ЖИВЫМ профессиональным переводчиком (единственная точка, где он у нас есть) + +`Silversaint`, изданный перевод АСТ (Н. Абдуллин): **«среброносец» / «Серебряный Святой»**. + +| арм | выдал | совпало с изданием | +|---|---|---| +| P0 | Серебряный святой | **да** | +| P4 | Серебряный Святой | **да** | +| P1 | Сребросвят | нет (изобретение) | +| P2 | Сильверсейнт | нет (транслитерация) | +| P3 | Сильверсейнт | нет (транслитерация) | +| P0b | Силверсейнт | нет (транслитерация) | + +Тот же механизм, что на `春秋蝉`: правило транскрипции уводит от решения, которое принял живой переводчик. + +**Честная граница этого наблюдения:** каждый арм прогонялся ОДИН раз, повторов пре-регистрация не предусматривала, поэтому отделить эффект арма от разброса прогона на одном терме нельзя. Совпадение механизма на двух независимых термах (`春秋蝉` zh и `Silversaint` en) и подтверждение прокси-мерой §B1.3 — вот что делает вывод пригодным для решения, а не сам по себе единичный случай. + +--- + +## §B2. Судейский слой: вердикты F1–F4 и почему сам инструмент оказался негодным + +Три прогона: `gpt-5-mini` прямой порядок · `grok-4.3` прямой порядок · `gpt-5-mini` ОБРАЩЁННЫЙ порядок (замер свопа). Вердиктов 80/80 у каждого (у первого судьи 5 восстановлены офлайн из сырья, §X п.9). + +### B2.1 BWS-счета — плоские + +| критерий | ранжирование (BWS: лучший +1, худший −1, нормировано) | +|---|---| +| K1 верность концепту | P4 +0.044 · P3 +0.025 · P2 +0.013 · P1 +0.006 · **GOLD −0.026** · P0b −0.037 · P0 −0.044 | +| K2 жанровая конвенция | P3 +0.070 · P4 +0.050 · P2 +0.040 · P0b +0.040 · GOLD −0.026 · P1 −0.030 · P0 −0.160 | +| K5 морфология | P3 +0.062 · P1 +0.031 · P4 +0.013 · GOLD 0.000 · P2 0.000 · P0b −0.019 · P0 −0.087 | +| K6 благозвучие | P1 +0.062 · P4 +0.062 · P3 +0.031 · P2 +0.006 · GOLD 0.000 · P0 −0.069 · P0b −0.094 | +| K9 регистр | P3 +0.050 · P2 +0.025 · P4 +0.025 · P1 0.000 · P0 −0.031 · GOLD −0.053 · P0b −0.056 | + +Весь разброс укладывается в ±0.09 при том, что шкала допускает ±1. **И подпись владельца (`GOLD`) стоит В СЕРЕДИНЕ или НИЖЕ машинных армов по четырём критериям из пяти.** + +### B2.2 Вердикты по критериям, названным ДО трат + +| критерий | замер | вердикт | +|---|---|---| +| **F1** P0b неотличим от P1 по K1 | Δ = 0.044 при пороге 0.10 | **ПРОВАЛ** | +| **F1b** P0 (случайный глоссарий) неотличим от P2 | Δ = 0.056 при пороге 0.10 | **ПРОВАЛ** | +| **F2** ни один арм не бьёт подпись | лучший P4 +0.026 против GOLD −0.026 | НЕ провал | +| **F3** катастрофы на ловушке S5 | у КАЖДОГО арма ≥2, включая **GOLD 1** | **ПРОВАЛ** | +| **F4** согласие / устойчивость победителя | κ = 0.611 (порог 0.60); **leave-one-out по судье МЕНЯЕТ победителя**: gpt-5-mini → P4, grok-4.3 → P0b | **ПРОВАЛ** | + +Эффект свопа по K1 (обратный порядок минус прямой): P0 +0.107 · P0b +0.050 · P2 +0.013 · GOLD −0.026 · P1 −0.032 · P3 −0.038 · **P4 −0.094**. **Сдвиг от одной лишь перестановки вариантов БОЛЬШЕ, чем весь разброс между армами.** Предупреждение литературы (§A2.4 п.5: своп на кураторских наборах может ухудшать) подтвердилось на наших данных. + +### B2.3 Отбраковка оценщиков — правило пре-регистрации сработало против нас, и это правильно + +§A2.4 п.4 гласил дословно: «оценщик, не отличающий подписанный вариант от случайного, **отбраковывается вместе со своими вердиктами**». Проверяем: + +| судья | термов с GOLD | назвал подпись ЛУЧШЕЙ по K1 | ХУДШЕЙ | **КАТАСТРОФОЙ** | +|---|---|---|---|---| +| gpt-5-mini | 19 | 2 | 2 | **6** | +| grok-4.3 | 19 | 2 | 3 | 0 | + +**gpt-5-mini объявил подписанный владельцем перевод катастрофой в 6 случаях из 19 (31.6%)** и выбирал его лучшим ровно так же часто, как худшим. grok-4.3 катастроф не ставил, но тоже не отличал подпись: 2 «лучший» против 3 «худший». + +**Оба судьи проваливают собственный контроль ⇒ по пре-регистрации их вердикты отбраковываются, а ранжирование армов по BWS НЕ ВЫДАЁТСЯ как результат.** Согласие между судьями по выборкам: S3 0.933 · S1 0.733 · S2 0.600 · S4 0.467 · **S5 0.400** — то есть хуже всего именно там, где решение важнее всего (ловушка канона). + +### B2.4 Объективный слой ПРОТИВОРЕЧИТ судейскому — и это решающая улика против судей, а не против армов + +Единственная точка, где есть внешняя истина, — совпадение с подписью владельца: + +| | объективная мера (§B1.1) | судейская мера (K1) | +|---|---|---| +| P0b (без контекстов) | **10/15 на S1** | −0.037 | +| P1 (с контекстами) | **15/15 на S1** | +0.006 | +| разрыв | **пять термов из пятнадцати** | Δ = 0.044 → «неотличимы» | + +Разница, которую видно невооружённым глазом на золотом стандарте, судьями **не обнаружена**. Значит F1 («контексты не работают») — это не свойство контекстов, а **свойство измерителя**: BWS-судьи на терминологической задаче с короткими вариантами не имеют разрешающей способности. + +### B2.5 Что из этого следует — честно + +1. **Ранжирование армов по слепой оценке не выдаётся.** F4 сработал, оценщики отбракованы своим же контролем. Никакой «арм X лучше арма Y по мнению судей» в контракт не идёт. +2. **Выводы фазы B опираются на объективный слой §B1** (совпадение с подписью, покрытие глоссарием, прокси транслитерации, ловушка, сверка с изданным переводом) — он не зависит от судей и воспроизводим побайтно. +3. **Пре-регистрация окупилась.** Именно заранее названные F1–F4 и заранее вставленный `GOLD` в слепую выборку не дали выдать плоский шум за ранжирование. Если бы `GOLD` в выборку не положили (а в первом наброске §A5 его не было), мы получили бы «P4 победил» и не узнали бы, что судья считает подпись владельца катастрофой. +4. **Для следующего замера нужен ДРУГОЙ инструмент**, а не другой судья: короткие терминологические варианты плохо различаются попарным сравнением. Кандидаты — оценка терма В КОНТЕКСТЕ ПРЕДЛОЖЕНИЯ (подставить каждый вариант в реальный фрагмент и сравнивать фрагменты), либо человеческая разметка по образцу пакета-6. + +--- + +## §B3. Рекомендация текста промпта терминолога + +Промпт пака-20 объявлен ИНТЕРИМ, финальный текст выбирает эта фаза. Рекомендация ниже — дельта к тому, что гонялось в армах; каждый пункт привязан к измерению, а не к вкусу. + +### B3.1 Что оставить как есть (подтверждено) + +1. **KWIC-контексты обязательны.** Единственный крупный измеренный разрыв: 0.632 → 0.947 по совпадению с подписью владельца (§B1.1). Это несущий элемент, а не украшение. +2. **Требование ЛЕММЫ** («dst в исходной словарной форме»). Совпадает с единственным вендорским решением морфологии, найденным в фазе A (нейрословарь Microsoft: «expected to be in a lemma (word base) form»), и не дало отказов. +3. **Батчи по ~10 термов.** 480 консолидаций за 70 вызовов, ноль пустых ответов, $0.09204 суммарно. Ратифицированная формула «батчи, дешёвая модель, центы/книга» подтверждена фактом. +4. **Формат `src → dst + примечание`.** Три независимых конкурента (AiNiee, SakuraLLM, GalTransl) пришли к одной и той же тройке полей — это настоящая сходимость трёх кодовых баз. + +### B3.2 Что ИЗМЕНИТЬ — одна правка, за которой стоят три независимых наблюдения + +**Правило транскрипции нужно СУЗИТЬ до антропонимов и топонимов.** + +Формулировка, которая гонялась: «если термин — имя собственное, передавай его транскрипцией, а не переводом смысла». Что она натворила: + +- `春秋蝉` → «Чуньцючань» у ВСЕХ армов, где правило есть; канон владельца — «Весенне-осенняя цикада» (§B1.4); +- `Silversaint` → «Сильверсейнт» у P2/P3; изданный перевод АСТ — «среброносец / Серебряный Святой» (§B1.5); +- на ловушке доля целиком несловарных dst у армов с правилом вдвое выше (0.50 против 0.25, §B1.3). + +Причина одна и она структурная: **названия артефактов, техник и гу — грамматически имена собственные, но в жанре они переводятся по смыслу.** Правило, не различающее антропоним и название приёма, систематически уводит от решения, которое принимают и владелец, и живой профессиональный переводчик. + +Предлагаемая замена (текст для пар-слоя, не для Go): + +> Транскрипцией передаются ИМЕНА ЛЮДЕЙ и ГЕОГРАФИЧЕСКИЕ названия. Названия предметов, техник, приёмов, существ и организаций переводятся ПО СМЫСЛУ, если смысл читается; транскрипция для них — запасной ход, когда смысл не восстанавливается или калька неблагозвучна. + +### B3.3 Что добавить — механизм, взятый у конкурентов, а не выдуманный + +**Поле «не переводить» (`do-not-translate`) в записи терма.** Есть у двух конкурентов из трёх (AiNiee `禁翻表`, GalTransl через флаги), нет ни у нас, ни в ISO/TBX, и в фазе A оно же нашлось у копиредактора как «Fragments are acceptable with this author's style». Наш ближайший аналог — `status`, но он про доверие, а не про запрет. + +**Условие на пост-замену, а не голая замена.** Модель GalTransl (`pre_src/post_src/pre_dst/post_dst` + `!отрицание`) — это ответ на дефект, который MS называет своим именем («brute force copy and replace»), а AiNiee показал в масштабе (issue #597: сто тысяч строк ложных ошибок от строгой сверки автоглоссария). Для нас это вход в дизайн пост-чека, а не в промпт. + +--- + +## §B4. Вердикт по веб-фетчу в роли терминолога (D39.42 п.2) + +**Вердикт: в v2 роли веб-фетч НЕ вводить. Ввести вместо него офлайн-словарь как пар-данные.** + +Замер, на котором вердикт стоит: + +| что мерили | результат | +|---|---| +| совпадение с подписью владельца, P4 (со справкой) | 17/19 = 0.895 | +| то же, P1 / P2 (без справки) | 18/19 = 0.947 | +| то же, P0 (случайный глоссарий) | 19/19 = 1.000 | +| случаев, где справка ВИДИМО предотвратила ошибку | **1** (`时辰`: P1 дал «час», армы со справкой — нет; справка дословно содержит «был равен 2 часам») | +| покрытие справкой по языкам | zh — есть (БКРС); **ja и en — нет вовсе** | + +**Оговорка против себя, чтобы не выглядеть подгонкой:** по слепой BWS-оценке арм P4 формально оказался ПЕРВЫМ по K1 (+0.044). Я на это не опираюсь — судейский слой отбракован собственным контролем (§B2.3), весь разброс там ±0.09 при шкале ±1, а эффект одной лишь перестановки вариантов у P4 составил −0.094, то есть больше его же «победы». Вердикт ниже стоит на объективном слое. + +**Три довода, каждый самостоятельный:** + +1. **Агрегатного выигрыша нет.** По объективной мере P4 не лучше армов без справки; единственный видимый выигрыш — один терм из восьмидесяти. +2. **Сработала не «сетевая справка», а СЛОВАРЬ.** Единственный случай пользы — статья двуязычного словаря. Словарь — это ДАННЫЕ, и они прекрасно живут офлайн: 84 статьи БКРС добыты за $0 и лежат файлом. Ради этого не нужен ни сетевой хоп в петле, ни расширение интерфейса роли. +3. **Веб-фетч в петле ломает инвариант детерминизма №8.** Страница меняется между прогонами, а на детерминизме стоит весь golden. Офлайн-снапшот словаря даёт ту же пользу и ничего не ломает — именно поэтому арм P4 и был построен как снапшот, а не как живой фетч (пре-регистрация §A5.2). + +**Что предлагаю вместо веб-фетча:** добавить в пар-слой (`configs/langpacks/<пара>/`) файл словарных справок формата `srcкраткая статья` и подавать его терминологу тем же путём, что жанровый словарь. Для zh→ru он уже собран (`eval/pkg7/bkrs_lookup.py`, 84 статьи, лицензия источника разрешает с указанием источника). Для ja→ru аналог — JMdict/EDICT (CC BY-SA 4.0, серый класс: точечно можно, выгрузкой — заражает). + +**Чего этот вердикт НЕ говорит:** он не про веб-фетч вообще, а про веб-фетч В РОЛИ ТЕРМИНОЛОГА при нашей текущей мере. Если появится класс термов, где нужен именно свежий сетевой контекст (реалии современности, бренды), вопрос открывается заново — и открывается замером, а не мнением. + +--- + +## §B6. Ревизия исполнением: адверсариальная проверка собственных выводов + +**Почему эта секция есть.** Мандат самопроверки 12.07 требует ревью ИСПОЛНЕНИЕМ, а норма проекта — +`author ≠ reviewer` на спорном. В фазе A внешний критик полноты был (он и породил V0 по 修炼), в +фазе B его не было: судейский слой я и построил, и оценил сам. Перепроверка сделана после сдачи, +воспроизводима одной командой и **опровергает часть моих же выводов**: + +``` +eval/.venv/bin/python eval/pkg7/audit_phaseB.py --dir <скретчпад>/phaseB +``` + +Пре-регистрация НЕ переписывается: вердикты F1–F4 в §B2.2 остаются ровно такими, какими их дал +скорер на наборе, названном до трат. Исправляется их ИСТОЛКОВАНИЕ и класс улик под рекомендациями. + +### R1. Отбраковка судей (§B2.3) данными НЕ подтверждается — снимаю + +Контроль §A2.4 п.4 звучал так: «оценщик, не отличающий подписанный вариант от **случайного**, +отбраковывается вместе со своими вердиктами». Замер, которого в фазе B не сделали: + +- **строка `GOLD` уникальна среди кандидатов в 0 случаях из 19** — подпись владельца текстуально + совпадала минимум с одним армом на КАЖДОМ терме, а в шести «катастрофических» случаях — с пятью-шестью; +- во всех 6 случаях, где `gpt-5-mini` пометил подпись катастрофой, он пометил **ровно тот набор букв, + который нёс этот же текст** (`СОГЛАСОВАНО` в п.7 аудита). Это не спутывание подписи со случайным + вариантом — это идеальная внутренняя согласованность и несогласие с каноном ПО СУЩЕСТВУ. + +**Вывод исправляется:** контроль на этом наборе был **структурно неизмерим** (уникальной подписи не +существовало), и отбраковывать по нему судей было нельзя. Фраза §B2.3 «оба судьи проваливают +собственный контроль» — моя ошибка чтения. Правильная формулировка: *контроль не сработал, потому +что мы не обеспечили его условие — уникальность эталона среди кандидатов.* + +### R2. «Судьи не имеют разрешающей способности» (§B2.4, §B2.5 п.4) — опровергается их же данными + +**35 термов из 80 вырождены: все семь кандидатов — одна и та же строка** (из 19 термов с эталоном +таких 11). BWS на идентичных вариантах — шум по построению, и он разбавляет каждый агрегат. Пере-счёт +ТЕМ ЖЕ скорером (`score_bws.bws`), тем же критерием и тем же порогом, но на 45 невырожденных термах: + +| замер | все 80 (как в отчёте) | 45 невырожденных | +|---|---|---| +| F1 \|P1 − P0b\| по K1 | 0.044 → ПРОВАЛ | **0.122 → НЕ провал** | +| F1b \|P2 − P0\| по K1 | 0.056 → ПРОВАЛ | **0.189 → НЕ провал** | +| место P0b по K1 | −0.037 (шестое из семи) | **−0.100 (последнее)** | + +На невырожденных айтемах P0b встаёт последним по K1, K5, K6 и K9 — **ровно то, что говорит +объективный слой** (P0b 0.632 против 0.895–1.000). То есть разрыв «контексты работают» судьи +**видят**; его прятал наш собственный набор. Значит §B2.4 («разница судьями не обнаружена ⇒ дело в +измерителе») неверен: дело было в айтемах, а не в измерителе. + +Тем же дефектом объясняется и F3: на `S5-转` и `S5-春秋蝉` судья пометил катастрофой по шесть армов +сразу — потому что они несли одну строку. Один терм превращался в шесть «провалов арма». Отдельно +стоит признать: на `春秋蝉` он пометил катастрофой «Чуньцючань», то есть **вынес тот же вердикт, что +и мы сами** в §B1.4. + +### R3. У правила транскрипции НЕ БЫЛО контрольного арма (§B3.2, §B1.3–B1.5) + +Правило «если термин — имя собственное, передавай его транскрипцией» лежит в `BASE_TASK` и потому +присутствует **во всех шести армах**, включая P0b и P1 (п.3 аудита, по сырью промптов). Поэтому: + +- фраза «`春秋蝉` → Чуньцючань у ВСЕХ армов, где правило есть» **неверна**: правило есть везде, а + канон «Весенне-осенняя цикада» выдал арм P0 — тот, у которого правил транскрипции БОЛЬШЕ всех + (базовое + Палладий); +- утверждение §B1.3 «армы с правилом транслитерируют вдвое чаще» **неверно по атрибуции**: разрыв + 0.25/0.50 делит арм-группы по признаку «правильный жанровый словарь», а не «правило транскрипции», + и P0 с правилом стоит в НИЖНЕЙ группе. + +**Наблюдение остаётся, причинность — нет.** Правда: 5 армов из 6 транслитерировали `春秋蝉`, 3 из 6 — +`Silversaint`; правило — правдоподобный механизм. Но арма БЕЗ правила в дизайне не было, значит это +**гипотеза, а не измеренный эффект**. Проверяется дёшево: седьмой арм = P2 минус строка правила, +~10 вызовов, ≈$0.02. + +### R4. «Три независимые улики» под §B3.2 не независимы — это ровно тот запрет «РОВНО ТАК» + +Улика №3 (прокси на ловушке) НЕ независима от улики №1: на S5 из пяти термов варьируются три, а +**весь** разрыв P0 против P2/P3/P4 даёт один-единственный терм — `春秋蝉` (п.8 аудита: `时辰` и `转` +у этих армов совпадают). То есть я посчитал одно наблюдение дважды и назвал это сходимостью — +именно тот паттерн, который промт запрещал («не собирать ложную сходимость», урок exp12/13). + +### R5. «Изданный перевод АСТ» — улика классом ниже, чем заявлено (§Z4.2, §B1.5) + +На диске артефакта нет; единственный источник — **сводка веб-поиска**, а не тело издания и не +страница издательства. В самой сводке стоит «AST/EKSMO publishers» (две разные издательские группы) +и форма «**угодник-**среброносец», из которой в отчёт уехало «среброносец». Поэтому таблица §B1.5 +«совпало с изданием — да/нет» держится на непроверенной строке, и класс улики надо понизить: +**«поисковая сводка, с телом издания не сверена»**. Для en-пары человеческого эталона у нас +по-прежнему НЕТ — это возвращает en в тот же статус, что ja. + +### R6. Что перепроверку ПРОШЛО без единой правки + +| проверено заново | результат | +|---|---| +| §B1.1 совпадение с подписью, независимый код | 12/19 · 19/19 · 18/19 · 18/19 · 17/19 · 17/19 — **до строки** | +| §B1.2 покрытие жанровым словарём | 0/80 на поданных 6 строках и **0/80 даже на полном словаре §Z4-Т** (1/80 при добавлении `资质`) ⇒ вывод и Z-B6 **устояли** | +| §B5 деньги | пересчёт из `usage` каждого вызова: $0.07791 + $0.01413 + $0.21827 = **$0.31030**, finish=stop везде | +| §Z4.3 Ngram | r(культивация, культиватор) = **0.9715**, r(…, совершенствование) = **−0.0704**, 119.6×, пики 1938/1938, «мир совершенствующихся» = 0 — **все до знака** | +| якоря фазы A по пину `996bade` | `emissionEligible` = `miner_emit.go:241-252` ✓ · `pipeline/mining.go:70-73` ✓ · `miner_emit.go:129` ✓ · `pipeline/mining.go:52-55` ✓ | +| свип нарезки фазы A | множество эмиссии побайтно одинаково на 800/2000/6000 (Жаккар 1.000) ✓ | + +### R7. Процессные дефекты записи (мелкие, но они мои) + +1. **Объективный слой §B1 не имел кодового пути** — считался разовыми heredoc-ами, тогда как §Z + обещает воспроизводимость цепочкой скриптов. Закрыто: `eval/pkg7/audit_phaseB.py`. +2. **Сырьё судей содержит только `id/prompt/response`** — без `usage`/`finish_reason`, вопреки + формулировке §B0.4; usage у судей живёт только в леджере `j*.json`. +3. **Восстановлено 5+5 вердиктов**, а §B2 и §X п.9 называют только 5 (починены оба прогона `gpt-5-mini`). +4. **«480 консолидаций за 70 вызовов» (§B3.1 п.3) смешивает множества**: 70 вызовов дали 560 + консолидаций, из которых 80 (первый P4) выброшены. +5. **«Требования к generic-майнеру»** — деливерабл фазы B по промту — в этом отчёте отсутствуют; + фактура собрана в фазе A (§A3.3–A3.7), здесь от неё остался только Z-B6. Пробел записи, не работы. + +### R8. Что из выводов фазы B устояло, а что нужно перемерить + +**Устояло** (стоит на объективном слое, перепроверено): контексты — главный рычаг (§B1.1); +жанровый словарь инертен на классе термов, который даёт WHICH-канал (§B1.2, Z-B6); словарная +справка помогает точечно и это ДАННЫЕ, а не сеть (§B4, Z-B4); словарь §Z4-Т и весь добор (§Z4). + +**Нужно перемерить**: рекомендация по правилу транскрипции (нужен арм БЕЗ правила) и вопрос +о пригодности слепой оценки (нужен набор БЕЗ вырожденных айтемов). Обе поправки дешёвые и обе +меняют не деньги, а дизайн — отдельной сметой, если владелец санкционирует. + +--- + +## §X. Чего эта фаза НЕ покрывает + +**Ограничения дизайна, названные честно (часть — до трат, часть вскрылась исполнением):** + +1. **Каждый арм прогонялся ОДИН раз.** Пре-регистрация повторов не предусматривала, поэтому разброс прогона от эффекта арма на отдельном терме не отделим. Все выводы опираются либо на агрегат (80 термов), либо на совпадение механизма в нескольких независимых точках — единичные примеры в отчёте помечены как иллюстрации, а не как доказательства. +2. **Глоссарный контраст не измерился по построению** — 0 из 80 термов покрыты жанровым словарём (§B1.2). Гипотеза H1 («главный рычаг — пар-данные») на этой выборке проверке не поддалась. Чтобы её проверить, нужен набор ЖАНРОВЫХ термов, а такой набор сегодня не производится WHICH-каналом (recall `term` = 0.040, фаза A). +3. **Эталон только на 19 термах из 80**, и все они zh. Для ja и en подписанного эталона нет; единственная человеческая точка — два терма из изданного перевода АСТ. +4. **Черновиков нет у S2–S4** — компонента «варианты черновиков» пуста на 45 термах из 80. Арм P1 там вырождается в «только KWIC». +5. **Справка для P4 только по zh.** Вердикт §B4 по ja/en не проверен эмпирически, он перенесён по аналогии и это сказано. +6. **K2 не оценивался на S3/S4** — конвенции для ja/en у нас нет (решение §A5.3, принято до трат). +7. **Класс улики «тело лицензионного издания» остаётся недобранным**, и планировавшийся путь (search-inside по ISBN) закрыт отрицательным результатом: всех трёх изданий в Google Books нет (`totalItems=0`). Остались платный фрагмент ЛитРес и бумага — обе вне зоны сессии. **Развилка Z2 по `修炼` этим НЕ снята.** +8. **Японский жанровый лексикон не добран.** Один заход поиска подтвердил картину фазы A: публичных издательских решений по `ステータス/レベル/魔物/チート/称号/精霊/詠唱/属性/転移` не находится. Строки остаются UNCLEAR. +9. **5 вердиктов первого судьи потерялись строгим разбором** (модель отдала JSON с недостающей скобкой) и восстановлены офлайн из сохранённого сырья (`repair_judge.py`), без повторных трат. Восстановленные строки помечены `repaired: true`. +10. **Прокси «транслитерация» — именно прокси.** Он считает долю dst, целиком отсутствующих в словаре русского; редкая, но словарная калька им не поймается, а удачный транслит, совпавший со словарным словом, — наоборот. Ячейки S5 малы (4–5 термов). +11. **Роль терминолога в коде не проектировалась** — по прежнему запрету. Рекомендации §B3 — текст промпта и требования, не дизайн. +12. **Слепая оценка не дала ранжирования.** ⚠ Причина исправлена ревизией (§B6 R1–R2): не «оценщики отбракованы» — контроль отбраковки был неизмерим, — а **набор наполовину вырожден**: у 35 термов из 80 все семь кандидатов были одной и той же строкой, и сравнивать было нечего. Ранжирование по-прежнему не выдаётся, но и вывод «армы неразличимы» из этих данных не следует. +13. **Гипотезы §S фазы A закрыты частично:** H1 (рычаг = пар-данные) — не проверяема на этой выборке (§B1.2); H2 (орфографическая улика в ja) — не проверялась, для неё нужен эталон ja; H3 (жанровое = в ≥2 книгах) — не считалась; H4 (веб-справка помогает избирательно) — согласуется с единственным случаем `时辰`, но n=1; H5 (разброс черновиков предсказывает трудность) — не считалась. +14. **Вариативность выходов не закладывалась в дизайн набора.** Пре-регистрация §A5 отбирала термы по классам и происхождению, но не требовала, чтобы армы на них РАСХОДИЛИСЬ. Следствие — п.12; для любого следующего замера это обязательное условие сборки, а не деталь. + +--- + +## §B5. Деньги — до цента + +| статья | вызовов | вход, ток | выход, ток | USD | +|---|---:|---:|---:|---:| +| армы P0b/P0/P1/P2/P3 + первый P4 (60 вызовов) | 60 | 287 196 | 143 554 | **0.07791** | +| арм P4, пере-прогон с исправленной подачей справок | 10 | 66 219 | 25 944 | **0.01413** | +| судья `gpt-5-mini`, прямой порядок | 80 | 81 240 | 10 891 | **0.04209** | +| судья `grok-4.3`, прямой порядок | 80 | 88 747 | 9 365 | **0.13435** | +| судья `gpt-5-mini`, обратный порядок (замер свопа) | 80 | 81 240 | 10 759 | **0.04183** | +| **ИТОГО** | **310** | **604 642** | **200 513** | **$0.31030** | + +- **Смета до вызовов: $0.3397** (консолидация $0.0672 + судьи $0.0689 + $0.1347 + $0.0689). **Факт $0.31030 — на 8.6% НИЖЕ сметы.** +- Санкционированный коридор $1.20–2.60, потолок $2.60. **Использовано 11.9% потолка.** Стоп-условие не срабатывало ни разу. +- Ошибок вызовов — **0** из 310. Пустых консолидаций — 0 из 480. +- Брак, отчитанный честно: первый прогон арма P4 ($0.01 в составе $0.07791) оказался обесценен дефектом подачи и пере-прогнан за $0.01413. Итого на брак и его исправление ушло **$0.024**, и это не скрыто в сметах. +- Расход добора Z4 — **$0** (curl, локальные клоны, веб-фетч харнесса). + +Цены взяты из `backend/configs/models.yaml` (read-only): `deepseek-v4-flash` $0.14/$0.28, `gpt-5-mini` $0.25/$2.00, `grok-4.3` $1.25/$2.50. Расход считается по фактическому `usage` каждого ответа с учётом `cached_tokens`. + +--- + +## §Z. Что на владельце и что предлагаю оркестратору + +| # | Пункт | Основание | +|---|---|---| +| **Z-B1** | **Строка `修炼` остаётся неподписанной.** V0 не снят: единственный планировавшийся путь к телу изданий закрыт отрицательным результатом (в Google Books изданий нет). Новые улики добора работают ПРОТИВ «культивации» (словарь её не знает; Ngram даёт сельхоз-омонимию r=0.97), но за «совершенствование» в жанровом регистре улик тоже не прибавилось | §Z4.3, §Z4-Т | +| **Z-B2** | **Подписать 17 строк словаря, закрытых БКРС** — таблица §Z4-Т. Две строки уже созданного `genre-glossary.txt` требуют решения: `筑基` подтверждена, `修士 → культиватор` спорна (словарное значение — «христ. монах») | §Z4-Т | +| **Z-B3** | ⚠ **ПОНИЖЕНО ревизией до ГИПОТЕЗЫ.** Правку §B3.2 (сузить правило транскрипции до антропонимов и топонимов) содержательно поддерживаю, но измеренным эффектом назвать нельзя: правило лежало во ВСЕХ шести армах, контрольного арма без него не было, а «три независимые улики» оказались одной (прокси на S5 = тот же терм `春秋蝉`) плюс непроверенная поисковая сводка по `Silversaint`. Решение владельца: либо принять как редакторское суждение, либо санкционировать арм-контроль (~$0.02) | §B6 R3–R5 | +| **Z-B4** | **Веб-фетч в v2 роли НЕ вводить**; вместо него — офлайн-словарь как пар-данные (для zh→ru уже собран, 84 статьи) | §B4 | +| **Z-B5** | ⚠ **ПЕРЕФОРМУЛИРОВАНО ревизией.** Ранее предлагалось менять ИНСТРУМЕНТ (BWS негоден). Проверка показала обратное: инструмент не проверен, негоден был НАБОР — 35 термов из 80 состояли из семи одинаковых строк, и на 45 невырожденных судьи ставят P0b последним, согласно объективному слою. Менять надо не судью, а сборку набора: дедупликация кандидатов, уникальность эталона, отказ от айтемов без вариативности. Это дешевле полной замены методики | §B6 R1–R2 | +| **Z-B7** | **Отбраковка судей (§B2.3) СНЯТА.** Их вердикты формально не дисквалифицированы: пре-регистрационный контроль оказался неизмерим (уникальной строки `GOLD` не было ни на одном терме). Ранжирование армов по BWS я по-прежнему НЕ выдаю — но по другой причине: набор наполовину состоял из вырожденных айтемов | §B6 R1 | +| **Z-B6** | Оркестратору: **вход в пак-20** — жанровый словарь помогает терминологу только на классе `term`, которого WHICH-канал не производит (recall 0.040). Подавать словарь в роль осмысленно ВМЕСТЕ с расширением эмиссии, иначе он инертен (доказано: 0/80 покрытия) | §B1.2 | + +**Заявление = команда.** Все прогоны воспроизводимы: `eval/pkg7/build_termset.py` → `terminologist_arms.py` → `blind_judge.py` → `repair_judge.py` → `score_bws.py`; сырьё каждого вызова (промпт, ответ, usage, finish_reason) сохранено целиком, цифры §B1 и §B5 пересчитываются из него без сети. diff --git a/eval/pkg7/README.md b/eval/pkg7/README.md index f944b52b..2b052ed7 100644 --- a/eval/pkg7/README.md +++ b/eval/pkg7/README.md @@ -20,3 +20,29 @@ 4. **Правило вершины словосочетания зависит от типа терма** (у имени вершина последняя, у нарицательного — первая). Морфо-чекер без этого правила меряет не то: первая версия `rubric_probe.py` склоняла «Вина» в «Монах Цветочного Вина». 5. **Контраст без опоры вырождается в частоту.** Ранжирование кандзи против словаря другого языка даёт log частоты, а не над-представленность; канал понижен до инвентаря. 6. **Отклонение методики — замерять, а не декларировать.** Нарезка здесь не боевая; свип целевого размера чанка (800/2000/6000) дал Жаккар 1.000 — только после этого цифры можно было предъявлять. + +## Фаза B (добавлено 26.07) + +| Скрипт | Что делает | +|---|---| +| `bkrs_lookup.py` | словарные статьи БКРС списком; воспроизводит куки-челлендж `ca=`, из-за которого страница считалась JS-рендеримой | +| `build_termset.py` | набор термов со входом терминолога (KWIC + пары «исходник↔черновик») из реального выровненного корпуса шести прогонов | +| `terminologist_arms.py` | шесть армов промпта по пре-регистрации §A5; батчи, леджер денег из фактического `usage`, сырьё сохраняется целиком | +| `blind_judge.py` | слепая Best-Worst оценка; соль перемешивания по терму, подпись владельца участвует как отдельный кандидат `GOLD` | +| `repair_judge.py` | терпимый пере-разбор судейских ответов ИЗ СЫРЬЯ (модель отдаёт JSON с недостающей скобкой) — $0, без повторных вызовов | +| `score_bws.py` | BWS-счета и вердикты F1–F4 по порогам, названным до трат | +| `audit_phaseB.py` | **адверсариальная ревизия фазы B**: пере-счёт объективного слоя, состав промптов по сырью, вырожденные айтемы, пере-скоринг BWS без них, деньги из сырья | +| `blind_judge2.py` | слепая оценка на ПОЧИНЕННОМ наборе (фаза B′): дедуп кандидатов · только расходящиеся термы · **ДЕКОЙ** — заведомо неверный вариант, без которого контроль «отличает ли судья годное от случайного» неизмерим | +| `score_b2.py` | вердикты фазы B′ по порогам §0: T (контроль правила транскрипции) · C1–C3 (годность судьи) · R (разброс прогона) | + +**Урок фазы B — в редакции ПОСЛЕ ревизии (первая редакция была неверной).** Сперва я записал сюда «слепые LLM-судьи не имеют разрешающей способности». Проверка исполнением (`audit_phaseB.py`) это опровергла: у 35 термов из 80 все семь кандидатов оказались ОДНОЙ И ТОЙ ЖЕ строкой, а на 45 невырожденных судьи ставят арм без контекстов последним — то есть разрыв они видят. Настоящий урок другой и жёстче: + +7. **Айтем без вариативности — не измерение, а шум с ценником.** Сравнительный протокол обязан собираться так, чтобы кандидаты РАСХОДИЛИСЬ: дедуплицируй варианты, требуй уникальности эталона (иначе контроль «отличает ли судья подпись» неизмерим по построению), выкидывай термы, где все ответы совпали. +8. **Фактор, который есть во всех армах, не измеряется ни одним из них.** Правило транскрипции лежало в общей части промпта — значит контроля не было, и любые «армы с правилом против армов без» были иллюзией чтения собственного кода. +9. **Считай улики, а не абзацы.** «Три независимых наблюдения» под рекомендацией свелись к одному терму, посчитанному дважды, плюс непроверенная поисковая сводка. Перед словом «сходится» — проверь, не один ли это сигнал в трёх обёртках. + +**Чем кончилась ревизия (фаза B′, замер за $0.14).** Правы оказались обе стороны спора, но каждая наполовину, и без замера этого было не разделить: + +10. **Слепой судья — катастроф-экран, а не ранжировщик.** Подложенный декой ловится 43–45 раз из 45 (счёт −0.93/−0.98), подпись владельца не помечена катастрофой ни разу, но ранговое согласие с объективным слоем ничтожно (ρ = +0.43 и −0.11). Грубое видит уверенно, тонкое не разрешает — и роль ему надо давать по этой способности, а не по желаемой. +11. **Одна строка промпта может стоить канона — и одновременно ничего не менять в среднем.** Снятие правила транскрипции вернуло `春秋蝉` в точный канон владельца, но агрегатный сдвиг оказался +0.013 при пороге 0.05: правило помогает на антропонимах ровно столько же, сколько вредит на названиях предметов. Единичный яркий пример и агрегат — разные улики, и путать их нельзя даже когда пример красив. +12. **Повторы дёшевы и меняют статус вывода.** Три прогона на 19 термах стоили $0.024 и превратили «P0b хуже P1» из одной точки в измерение: разрыв 6 термов против размаха 1. Заодно объяснили аномалию «случайный глоссарий дал 19/19» — 19/19 лежит внутри шума прогона. diff --git a/eval/pkg7/audit_phaseB.py b/eval/pkg7/audit_phaseB.py new file mode 100644 index 00000000..ad4f022d --- /dev/null +++ b/eval/pkg7/audit_phaseB.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7: АДВЕРСАРИАЛЬНАЯ ревизия собственной фазы B (мандат самопроверки 12.07). + +Зачем отдельным скриптом. Объективный слой отчёта B (§B1) считался разовыми heredoc-ами, то есть +именно тот слой, на который отчёт опёр ВСЕ выводы после отбраковки судей, не имел воспроизводимого +кодового пути. Здесь он есть, и здесь же — проверки, которых в фазе B не было и которые её выводы +частично опровергают. + +Что проверяется (каждая проверка печатает и замер, и то, что заявлял отчёт): + 1. §B1.1 совпадение с подписью владельца — пересчёт независимым кодом; + 2. покрытие набора жанровым словарём — включая гипотетический ПОЛНЫЙ словарь §Z4-Т; + 3. состав промпта КАЖДОГО арма по СЫРЬЮ — какой блок в каком арме реально был; + 4. вырожденные айтемы: сколько термов, где все кандидаты — одна и та же строка; + 5. измерим ли вообще контроль §A2.4 п.4 (уникальна ли строка GOLD среди кандидатов); + 6. пере-счёт BWS ТЕМ ЖЕ скорером на невырожденных айтемах — F1/F1b переворачиваются; + 7. катастрофы на GOLD: пометил ли судья одинаковые строки одинаково; + 8. чем на самом деле объясняется разрыв прокси §B1.3 на ловушке S5; + 9. деньги из сырья каждого вызова против леджера отчёта. + +Запуск: audit_phaseB.py --dir <скретчпад/phaseB> [--z4t <файл со списком src полного словаря>] +Сети не требует, платных вызовов не делает. +""" +from __future__ import annotations + +import argparse +import collections +import json +import re +import sys +from pathlib import Path + +ARMS = ["P0b", "P0", "P1", "P2", "P3", "P4"] +# Полный список src таблицы §Z4-Т (жанровый словарь «как если бы подписали всё»). +Z4T_SRC = ["修炼", "修真", "修行", "修士", "修为", "真气", "灵气", "筑基", "金丹", "丹田", "元神", + "元婴", "经脉", "穴位", "心法", "秘籍", "功法", "法宝", "丹药", "符箓", "轻功", "内功", + "内力", "境界", "飞升", "天劫", "灵石", "长老", "掌门", "剑修", "妖", "魔", "仙", "江湖", + "走火入魔", "资质", "凡人"] +# Маркеры блоков промпта — по ним видно, что арм РЕАЛЬНО получил (а не что задумывалось). +BLOCKS = { + "правило-имени": "если термин — имя собственное, передавай его транскрипцией", + "Палладий": "система Палладия", + "лемма": "Русский — язык с богатой морфологией", + "словарь": "Жанровый словарь пары", + "рубрика": "K1 верность концепту (то ли это понятие)", + "справка": "СЛОВАРНАЯ СПРАВКА из китайско-русского словаря", + "KWIC": " контекст: ", +} +PRICE = {"deepseek-v4-flash": (0.14, 0.28, 0.0028)} + + +def norm(s: str | None) -> str: + return re.sub(r"\s+", " ", (s or "").strip().lower()) + + +def bws(rows: list[dict], crit: str, subset: set[str] | None = None) -> dict[str, float]: + """Нормировка ровно как в score_bws.py: (плюсы − минусы) / число появлений арма.""" + plus, minus, seen = collections.Counter(), collections.Counter(), collections.Counter() + for r in rows: + if subset is not None and r["id"] not in subset: + continue + v = (r.get("verdicts") or {}).get(crit) + if not isinstance(v, dict): + continue + l2a = r["letter2arm"] + for arm in l2a.values(): + seen[arm] += 1 + b, w = l2a.get(str(v.get("best"))), l2a.get(str(v.get("worst"))) + if b: + plus[b] += 1 + if w: + minus[w] += 1 + return {a: (plus[a] - minus[a]) / n for a, n in seen.items() if n} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--dir", required=True, type=Path, help="каталог фазы B (arms_final.json, j*.json, raw/)") + a = ap.parse_args() + D = a.dir + + res = json.loads((D / "arms_final.json").read_text(encoding="utf-8"))["results"] + by, gold = collections.defaultdict(dict), {} + for r in res: + by[r["id"]][r["arm"]] = (r["dst"] or "").strip() + if r.get("gold"): + gold[r["id"]] = r["gold"].strip() + + def cands(tid: str) -> dict[str, str]: + m = dict(by[tid]) + if tid in gold: + m["GOLD"] = gold[tid] + return m + + print(f"=== 1. §B1.1 совпадение с подписью (термов с эталоном: {len(gold)}) ===") + hit, tot = collections.Counter(), collections.Counter() + per = collections.defaultdict(collections.Counter) + pert = collections.defaultdict(collections.Counter) + for r in res: + if not r.get("gold"): + continue + tot[r["arm"]] += 1 + pert[r["sample"]][r["arm"]] += 1 + if norm(r["dst"]) == norm(r["gold"]): + hit[r["arm"]] += 1 + per[r["sample"]][r["arm"]] += 1 + for arm in ARMS: + print(f" {arm:4s} {hit[arm]}/{tot[arm]} = {hit[arm]/tot[arm]:.3f}") + for s in sorted(pert): + print(f" {s}: " + " ".join(f"{x}={per[s][x]}/{pert[s][x]}" for x in ARMS)) + print(" отчёт §B1.1: 12/19 · 19/19 · 18/19 · 18/19 · 17/19 · 17/19") + + print("\n=== 2. покрытие набора жанровым словарём ===") + srcs = {r["src"] for r in res} + fed = sorted(srcs & {"修真", "修行", "丹田", "经脉", "灵气", "金丹"}) + full = sorted(srcs & set(Z4T_SRC)) + print(f" 6 строк, реально уехавших в промпт: {len(fed)}/80 {fed}") + print(f" ПОЛНЫЙ словарь §Z4-Т (37 строк): {len(full)}/80 {full}") + print(" ⇒ вывод §B1.2 (словарь инертен на этом наборе) от подачи НЕ зависит") + + print("\n=== 3. состав промпта каждого арма ПО СЫРЬЮ ===") + first: dict[str, str] = {} + for p in sorted((D / "raw").glob("*.json")) + sorted((D / "raw_P4").glob("*.json")): + r = json.loads(p.read_text(encoding="utf-8")) + first.setdefault(r["arm"], r["prompt"]) + for arm in ARMS: + pr = first.get(arm, "") + print(f" {arm:4s} " + " ".join(f"{k}={'ДА' if v in pr else '--'}" for k, v in BLOCKS.items())) + print(" ⇒ ПРАВИЛО ИМЕНИ СОБСТВЕННОГО есть во ВСЕХ шести армах: контрольного арма без него НЕТ") + + print("\n=== 4-5. вырожденные айтемы и измеримость контроля §A2.4 п.4 ===") + deg = {t for t in by if len({norm(v) for v in cands(t).values()}) == 1} + nondeg = set(by) - deg + uniq = [t for t in gold if norm(gold[t]) not in {norm(v) for v in by[t].values()}] + print(f" термов всего {len(by)}; ВСЕ кандидаты идентичны у {len(deg)}; невырожденных {len(nondeg)}") + print(f" из {len(gold)} термов с GOLD полностью вырождены {len(deg & set(gold))}") + print(f" термов, где строка GOLD УНИКАЛЬНА среди кандидатов: {len(uniq)}/{len(gold)}") + print(" ⇒ контроль «отличает ли судья подпись от случайного варианта» на этом наборе НЕИЗМЕРИМ") + + jf = [D / "j1.json", D / "j2.json"] + rows = [r for f in jf for r in json.loads(f.read_text(encoding="utf-8"))["rows"] if not r.get("reverse")] + print("\n=== 6. BWS тем же скорером: все айтемы против невырожденных ===") + for label, sub in [("все 80 (как в отчёте)", None), ("45 невырожденных", nondeg)]: + k1 = bws(rows, "K1", sub) + d1 = abs(k1.get("P1", 0) - k1.get("P0b", 0)) + d1b = abs(k1.get("P2", 0) - k1.get("P0", 0)) + print(f" [{label}] " + " · ".join(f"{a} {v:+.3f}" for a, v in sorted(k1.items(), key=lambda x: -x[1]))) + print(f" F1 |P1−P0b|={d1:.3f} → {'ПРОВАЛ' if d1 < 0.10 else 'НЕ провал'} " + f"F1b |P2−P0|={d1b:.3f} → {'ПРОВАЛ' if d1b < 0.10 else 'НЕ провал'} (порог 0.10)") + print(" ⇒ на невырожденных айтемах оба критерия переворачиваются, а P0b встаёт ПОСЛЕДНИМ —") + print(" то есть судьи разрыв «контексты работают» ВИДЯТ; его прятали вырожденные термы") + + print("\n=== 7. катастрофы на GOLD: согласованность судьи ===") + j1 = json.loads((D / "j1.json").read_text(encoding="utf-8"))["rows"] + ncat = 0 + for row in j1: + l2a = row.get("letter2arm") or {} + inv = {v: k for k, v in l2a.items()} + if "GOLD" not in inv or inv["GOLD"] not in (row.get("catastrophic") or []): + continue + ncat += 1 + c = cands(row["id"]) + twin = sorted(L for L, arm in l2a.items() if norm(c.get(arm, "")) == norm(c["GOLD"])) + cat = sorted(row.get("catastrophic") or []) + print(f" {row['id']:22s} {c['GOLD']!r:26s} букв с тем же текстом={twin} помечено={cat}" + f" {'СОГЛАСОВАНО' if twin == cat else '← РАСХОЖДЕНИЕ'}") + print(f" всего катастроф на GOLD: {ncat} — и это несогласие судьи с каноном ПО СУЩЕСТВУ,") + print(" а не неспособность отличить подпись: одинаковый текст он метит одинаково") + + print("\n=== 8. чем объясняется разрыв прокси §B1.3 на ловушке S5 ===") + s5 = collections.defaultdict(dict) + for r in res: + if r["sample"] == "S5": + s5[r["src"]][r["arm"]] = r["dst"] + for src, m in s5.items(): + n = len({norm(v) for v in m.values()}) + mark = " ← ЕДИНСТВЕННЫЙ источник разрыва P0 против P2/P3/P4" if src == "春秋蝉" else "" + print(f" {src:6s} различных вариантов={n}{mark}") + print(" ⇒ строка S5 прокси НЕ является уликой, независимой от наблюдения по 春秋蝉 (§B1.4)") + + print("\n=== 9. деньги из сырья против леджера ===") + pi, po, pc = PRICE["deepseek-v4-flash"] + grand = 0.0 + for sub, label, claim in [("raw", "армы, первый прогон", 0.07791), ("raw_P4", "P4 пере-прогон", 0.01413)]: + ti = to = tc = 0 + for p in sorted((D / sub).glob("*.json")): + u = json.loads(p.read_text(encoding="utf-8"))["usage"] + ti += u["in"]; to += u["out"]; tc += u.get("cached") or 0 + usd = (ti - tc) / 1e6 * pi + tc / 1e6 * pc + to / 1e6 * po + grand += usd + print(f" {label:22s} in={ti:7d} out={to:6d} cached={tc:6d} ${usd:.5f} (отчёт ${claim:.5f})") + for f in ["j1.json", "j2.json", "j3.json"]: + led = json.loads((D / f).read_text(encoding="utf-8"))["ledger"] + usd = sum(x["usd"] for x in led) + grand += usd + nofin = sum(1 for x in led if x.get("finish") != "stop") + print(f" {f:22s} вызовов={len(led)} ${usd:.5f} finish!=stop={nofin}") + print(f" ИТОГО ${grand:.5f} (отчёт $0.31030)") + print(" NB: сырьё СУДЕЙ содержит только id/prompt/response — usage у них живёт лишь в леджере,") + print(" вопреки формулировке §B0.4 «сырьё каждого вызова … usage … сохраняется целиком»") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/pkg7/bkrs_lookup.py b/eval/pkg7/bkrs_lookup.py new file mode 100644 index 00000000..61c67369 --- /dev/null +++ b/eval/pkg7/bkrs_lookup.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7 (Z4-добор): словарные статьи БКРС для списка терминов. + +Зачем. В фазе A «что говорит БКРС» осталось UNCLEAR по ВСЕМ терминам: страница считалась +JS-рендеримой. На деле bkrs.info ставит одноразовый куки-челлендж (`ca=`) и перезагружает +страницу; воспроизведя куку, получаем СЕРВЕРНЫЙ html со статьёй. Словарная улика — сильнейший +класс из доступных нам (сильнее площадочной и сильнее аннотации), поэтому она закрывает больше +строк §A4.3, чем весь остальной добор. + +Лицензия источника (со страницы «Скачать словарь», дамп 2026.06.10): «Базы можно использовать +свободно в любых целях. Можно указать данный сайт как источник.» — то есть провенанс-класс +«серый→зелёный при указании источника», и мы источник указываем. + +$0: сетевой GET, ни одной модели. Вежливость: пауза между запросами, один проход по списку. +""" +from __future__ import annotations + +import argparse +import html +import json +import re +import sys +import time +import urllib.parse +import urllib.request +from pathlib import Path + +UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0 Safari/537.36") +BASE = "https://bkrs.info/slovo.php?ch=" + + +def get(url: str, cookie: str | None = None, timeout: int = 30) -> str: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + if cookie: + req.add_header("Cookie", cookie) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read().decode("utf-8", "replace") + + +def challenge_cookie(sample_html: str) -> str | None: + m = re.search(r'"(ca=[0-9a-f]+)', sample_html) + return m.group(1) if m else None + + +def extract_entry(page: str, term: str) -> str: + """Тело словарной статьи: от заголовка-термина до служебного хвоста страницы.""" + txt = html.unescape(re.sub(r"<(script|style)[^>]*>.*?", " ", page, flags=re.S)) + txt = re.sub(r"<[^>]+>", "\n", txt) + lines = [x.strip() for x in txt.split("\n") if x.strip()] + # Статья начинается ПОСЛЕ последнего вхождения служебного пункта меню «Контакты»/«Войти» + start = 0 + for i, l in enumerate(lines): + if l in ("Войти", "Контакты", "Тёмная тема"): + start = i + 1 + body = lines[start:] + # и заканчивается перед подвалом + stop = len(body) + for i, l in enumerate(body): + if l.startswith(("В начало", "Обсуждение", "Комментарии", "Ошибки", "Пожаловаться", + "Добавить", "Правка", "Примеры", "Похожие")): + stop = i + break + return " ".join(body[:stop]).strip() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--terms", required=True, type=Path, help="файл: по термину в строке") + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--sleep", type=float, default=1.2) + a = ap.parse_args() + + terms = [t.strip() for t in a.terms.read_text(encoding="utf-8").splitlines() + if t.strip() and not t.startswith("#")] + + first = get(BASE + urllib.parse.quote(terms[0])) + cookie = challenge_cookie(first) + if not cookie: + print("челлендж-кука не найдена — страница отдалась сразу", file=sys.stderr) + + rows = [] + for i, t in enumerate(terms, 1): + try: + page = get(BASE + urllib.parse.quote(t), cookie) + entry = extract_entry(page, t) + except Exception as e: # сеть — не повод останавливать проход + entry = f"__ОШИБКА__ {e}" + found = bool(entry) and "__ОШИБКА__" not in entry and "Не найдено" not in entry + rows.append({"src": t, "found": found, "entry": entry}) + print(f"[{i}/{len(terms)}] {t}\t{'OK' if found else 'НЕТ'}\t{entry[:110]}", file=sys.stderr) + time.sleep(a.sleep) + + a.out.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") + ok = sum(r["found"] for r in rows) + print(f"\nитого: {ok}/{len(rows)} статей найдено → {a.out}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/pkg7/blind_judge.py b/eval/pkg7/blind_judge.py new file mode 100644 index 00000000..d7bc3850 --- /dev/null +++ b/eval/pkg7/blind_judge.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7, фаза B: слепая оценка Best-Worst Scaling по критериям рубрики §A2. + +Почему BWS, а не абсолютные баллы: на художественном материале MQM-скоринг ошибочно признаёт ~60% +ЧЕЛОВЕЧЕСКИХ переводов не лучше машинных, тогда как Best-Worst Scaling опознаёт человека в 80–100% +(§A2.0). Абсолютная шкала остаётся только для катастроф-экрана. + +Слепота (D13.5 + §A2.4): варианты перемешиваются СОЛЬЮ ПО ТЕРМУ, метки A..F не несут информации об +арме, судья не видит ни имени арма, ни эталона владельца. Порядок сохраняется в ключе отдельно. + +Судейская дисциплина (D13.3): судья не судит выход своего семейства — консолидатор deepseek, +судьи из ДРУГИХ семейств. Позиционный своп меряется отдельным прогоном, а не считается бесплатным +(§A2.4 п.5: на кураторских наборах своп может УХУДШАТЬ). + +Критерий K2 «жанровая конвенция» на выборках S3/S4 помечен н/д и НЕ оценивается: конвенции для +ja/en у нас нет, оценивать соответствие несуществующему — купить бессмысленный вердикт (§A5.3). +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import sys +import time +from pathlib import Path + +import yaml +from dotenv import load_dotenv +from openai import OpenAI + +REPO = Path(__file__).resolve().parents[2] +load_dotenv(REPO / "eval" / ".env") + +CRITERIA = { + "K1": "верность концепту: вариант обозначает то же понятие, что исходный термин в этих контекстах", + "K2": "жанровая конвенция: вариант совпадает с тем, как этот класс терминов принято передавать в русских изданиях жанра", + "K5": "морфологическая пригодность: вариант склоняем по-русски, у него устойчивый род, он не ломает согласование", + "K6": "литературность и благозвучие: вариант годится в художественный текст, не режет ухо", + "K9": "уместность регистра: не подставлен академический/канцелярский термин туда, где нужен жанровый, и наоборот", +} + +JUDGE_TASK = """Ты — эксперт по художественному переводу на русский язык. Тебе дают термин +исходного текста, его контексты из книги и НЕСКОЛЬКО вариантов русского соответствия, помеченных +буквами. Кто какой вариант предложил — тебе неизвестно и не важно. + +Для каждого названного критерия выбери ЛУЧШИЙ и ХУДШИЙ вариант. +Отдельно отметь варианты, которые являются КАТАСТРОФОЙ: меняют смысл на другой/противоположный, +либо являются не переводом, а мусором. + +Отвечай СТРОГО одним JSON-объектом без пояснений вне JSON: +{"verdicts": {"<критерий>": {"best": "<буква>", "worst": "<буква>"}, ...}, + "catastrophic": ["<буква>", ...], + "note": "<до 25 слов, почему лучший лучший>"}""" + + +def salt_order(term_id: str, arms: list[str], reverse: bool) -> list[str]: + h = hashlib.sha256(f"pkg7:{term_id}".encode()).hexdigest() + order = sorted(arms, key=lambda a: hashlib.sha256((h + a).encode()).hexdigest()) + return list(reversed(order)) if reverse else order + + +def build_prompt(item: dict, cands: list[tuple[str, str]], crits: list[str]) -> str: + ctx = "\n".join(f" …{k['win']}…" for k in item["kwic"][:6]) or " (контекстов нет)" + opts = "\n".join(f" {letter}. {dst}" for letter, dst in cands) + cr = "\n".join(f" {c}: {CRITERIA[c]}" for c in crits) + return (f"{JUDGE_TASK}\n\nИСХОДНЫЙ ТЕРМИН: {item['src']}\n" + f"ЯЗЫК ИСХОДНИКА: {item['lang']}\n\nКОНТЕКСТЫ ИЗ КНИГИ:\n{ctx}\n\n" + f"ВАРИАНТЫ:\n{opts}\n\nКРИТЕРИИ:\n{cr}") + + +def parse_obj(text: str) -> dict: + m = re.search(r"\{.*\}", text, re.S) + if not m: + return {} + try: + v = json.loads(m.group(0)) + return v if isinstance(v, dict) else {} + except json.JSONDecodeError: + return {} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--termset", required=True, type=Path) + ap.add_argument("--arms", required=True, type=Path) + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--raw-dir", required=True, type=Path) + ap.add_argument("--judge", required=True, help="имя модели из models.yaml") + ap.add_argument("--base-url", required=True) + ap.add_argument("--key-env", required=True) + ap.add_argument("--style", choices=["openai-reasoning", "plain", "xai"], default="plain") + ap.add_argument("--reverse", action="store_true", help="прогон с ОБРАЩЁННЫМ порядком (замер свопа)") + ap.add_argument("--max-tokens", type=int, default=4000) + ap.add_argument("--dry-run", action="store_true") + a = ap.parse_args() + + items = {i["id"]: i for i in json.loads(a.termset.read_text(encoding="utf-8"))} + arms_data = json.loads(a.arms.read_text(encoding="utf-8"))["results"] + by_id: dict[str, dict[str, str]] = {} + for r in arms_data: + if r.get("dst"): + by_id.setdefault(r["id"], {})[r["arm"]] = r["dst"].strip() + + doc = yaml.safe_load((REPO / "backend/configs/models.yaml").read_text(encoding="utf-8")) or {} + p = ((doc.get("models") or {}).get(a.judge) or {}).get("price") or {} + pin, pout = float(p.get("input_per_m", 0)), float(p.get("output_per_m", 0)) + + # Подписанный владельцем dst участвует в слепом сравнении НАРАВНЕ с армами (§A2.4 п.4): + # без него нельзя вынести F2 («бьёт ли арм эталон»), а оценщик, не отличающий подпись от + # случайного варианта, обязан быть отбракован — увидеть это можно только так. + for tid, it in items.items(): + if it.get("gold") and tid in by_id: + by_id[tid]["GOLD"] = it["gold"].strip() + + tasks = [] + for tid, variants in by_id.items(): + it = items.get(tid) + if not it or len(variants) < 2: + continue + crits = [c for c in CRITERIA if not (c == "K2" and it["sample"] in ("S3", "S4"))] + order = salt_order(tid, sorted(variants), a.reverse) + letters = "ABCDEFG" + cands = [(letters[k], variants[arm]) for k, arm in enumerate(order)] + tasks.append((tid, it, order, cands, crits)) + + est_in = sum(len(build_prompt(it, c, cr)) for _, it, _, c, cr in tasks) / 3.2 + est_out = len(tasks) * 350 + print(f"ПЛАН судьи {a.judge}{' (обратный порядок)' if a.reverse else ''}: {len(tasks)} термов") + print(f"СМЕТА ДО ВЫЗОВОВ: вход ≈{est_in/1000:.1f}k → ${est_in/1e6*pin:.4f}; " + f"выход ≈{est_out/1000:.1f}k → ${est_out/1e6*pout:.4f}; " + f"ИТОГО ≈ ${est_in/1e6*pin + est_out/1e6*pout:.4f}") + if a.dry_run: + return 0 + + key = os.environ.get(a.key_env) + if not key: + print(f"нет ключа {a.key_env}", file=sys.stderr) + return 2 + client = OpenAI(api_key=key, base_url=a.base_url) + a.raw_dir.mkdir(parents=True, exist_ok=True) + + rows, ledger = [], [] + for n, (tid, it, order, cands, crits) in enumerate(tasks, 1): + prompt = build_prompt(it, cands, crits) + kw: dict = {"model": a.judge, "messages": [{"role": "user", "content": prompt}]} + if a.style == "openai-reasoning": + kw["max_completion_tokens"] = a.max_tokens + kw["reasoning_effort"] = "minimal" # квирк gpt-5-mini + elif a.style == "xai": + kw["max_tokens"] = a.max_tokens + kw["reasoning_effort"] = "low" # квирк grok: дефолт low, явно + else: + kw["max_tokens"] = a.max_tokens + try: + resp = client.chat.completions.create(**kw) + except Exception as e: + print(f"[{n}/{len(tasks)}] {tid} ОШИБКА: {e}", file=sys.stderr) + ledger.append({"id": tid, "error": str(e)}) + continue + txt = resp.choices[0].message.content or "" + u = resp.usage + cin, cout = getattr(u, "prompt_tokens", 0), getattr(u, "completion_tokens", 0) + usd = cin / 1e6 * pin + cout / 1e6 * pout + v = parse_obj(txt) + letters = [c[0] for c in cands] + letter2arm = {letters[k]: order[k] for k in range(len(order))} + rows.append({"id": tid, "sample": it["sample"], "judge": a.judge, + "reverse": bool(a.reverse), "letter2arm": letter2arm, + "verdicts": v.get("verdicts", {}), "catastrophic": v.get("catastrophic", []), + "note": v.get("note", "")}) + (a.raw_dir / f"{a.judge}_{'rev' if a.reverse else 'fwd'}_{n:03d}.json").write_text( + json.dumps({"id": tid, "prompt": prompt, "response": txt}, ensure_ascii=False, indent=1), + encoding="utf-8") + ledger.append({"id": tid, "in": cin, "out": cout, "usd": usd, + "finish": resp.choices[0].finish_reason, "ok": bool(v.get("verdicts"))}) + if n % 10 == 0 or n == len(tasks): + print(f" [{n}/{len(tasks)}] ${sum(x.get('usd',0) for x in ledger):.4f}") + time.sleep(0.2) + + a.out.write_text(json.dumps({"rows": rows, "ledger": ledger}, ensure_ascii=False, indent=1), + encoding="utf-8") + tot = sum(x.get("usd", 0) for x in ledger) + ok = sum(1 for r in rows if r["verdicts"]) + print(f"\nСУДЬЯ {a.judge}: вердиктов {ok}/{len(tasks)}, ПОТРАЧЕНО ${tot:.5f}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/pkg7/blind_judge2.py b/eval/pkg7/blind_judge2.py new file mode 100644 index 00000000..f67412ba --- /dev/null +++ b/eval/pkg7/blind_judge2.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7, фаза B′: слепая оценка на ПОЧИНЕННОМ наборе (пре-регистрация §0.3). + +Чем отличается от `blind_judge.py` и почему это не «другой судья», а другой НАБОР. +Ревизия §B6 показала: инструмент фазы B оценивал наполовину вырожденные айтемы — у 35 термов из 80 +все семь кандидатов были ОДНОЙ И ТОЙ ЖЕ строкой, и судью просили выбрать лучшую из семи одинаковых. +Отсюда плоские счета и «неразличимость армов». Здесь чинится сборка: + + 1) ДЕДУПЛИКАЦИЯ — одинаковые строки схлопываются в один вариант, арм-источники хранятся в ключе; + 2) только термы, где после дедупликации осталось ≥2 варианта (иначе сравнивать нечего); + 3) ДЕКОЙ — заведомо неверный вариант (dst ДРУГОГО терма той же выборки), подложенный вслепую. + +Декой — несущая часть, а не украшение. В фазе B контроль «отличает ли судья годное от случайного» +был структурно НЕИЗМЕРИМ: строка `GOLD` не была уникальной ни на одном из 19 термов, поэтому +отбраковать судью по ней было нельзя (а отчёт B это сделал — ошибка R1). Декой уникален по +построению, и контроль становится измеримым. + +Сырьё пишется вместе с `usage` и `finish_reason` — дефект записи фазы B (§B6 R7.2) чинится здесь. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import sys +import time +from pathlib import Path + +import yaml +from dotenv import load_dotenv +from openai import OpenAI + +REPO = Path(__file__).resolve().parents[2] +load_dotenv(REPO / "eval" / ".env") + +CRITERIA = { + "K1": "верность концепту: вариант обозначает то же понятие, что исходный термин в этих контекстах", + "K2": "жанровая конвенция: вариант совпадает с тем, как этот класс терминов принято передавать в русских изданиях жанра", + "K5": "морфологическая пригодность: вариант склоняем по-русски, у него устойчивый род, он не ломает согласование", + "K6": "литературность и благозвучие: вариант годится в художественный текст, не режет ухо", + "K9": "уместность регистра: не подставлен академический/канцелярский термин туда, где нужен жанровый, и наоборот", +} + +JUDGE_TASK = """Ты — эксперт по художественному переводу на русский язык. Тебе дают термин +исходного текста, его контексты из книги и НЕСКОЛЬКО вариантов русского соответствия, помеченных +буквами. Кто какой вариант предложил — тебе неизвестно и не важно. + +Для каждого названного критерия выбери ЛУЧШИЙ и ХУДШИЙ вариант. +Отдельно отметь варианты, которые являются КАТАСТРОФОЙ: меняют смысл на другой/противоположный, +либо являются не переводом, а мусором. + +Отвечай СТРОГО одним JSON-объектом без пояснений вне JSON: +{"verdicts": {"<критерий>": {"best": "<буква>", "worst": "<буква>"}, ...}, + "catastrophic": ["<буква>", ...], + "note": "<до 25 слов, почему лучший лучший>"}""" + + +def norm(s: str) -> str: + return re.sub(r"\s+", " ", (s or "").strip().lower()) + + +def salt_order(term_id: str, keys: list[str]) -> list[str]: + h = hashlib.sha256(f"pkg7b2:{term_id}".encode()).hexdigest() + return sorted(keys, key=lambda k: hashlib.sha256((h + k).encode()).hexdigest()) + + +def build_prompt(item: dict, cands: list[tuple[str, str]], crits: list[str]) -> str: + ctx = "\n".join(f" …{k['win']}…" for k in item["kwic"][:6]) or " (контекстов нет)" + opts = "\n".join(f" {letter}. {dst}" for letter, dst in cands) + cr = "\n".join(f" {c}: {CRITERIA[c]}" for c in crits) + return (f"{JUDGE_TASK}\n\nИСХОДНЫЙ ТЕРМИН: {item['src']}\n" + f"ЯЗЫК ИСХОДНИКА: {item['lang']}\n\nКОНТЕКСТЫ ИЗ КНИГИ:\n{ctx}\n\n" + f"ВАРИАНТЫ:\n{opts}\n\nКРИТЕРИИ:\n{cr}") + + +def parse_obj(text: str) -> dict: + m = re.search(r"\{.*\}", text, re.S) + if not m: + return {} + raw = m.group(0) + for extra in range(0, 4): # терпимость к недостающим скобкам (урок фазы B) + try: + v = json.loads(raw + "}" * extra) + except json.JSONDecodeError: + continue + if not isinstance(v, dict): + return {} + ver = v.get("verdicts") + if isinstance(ver, dict): + for key in ("catastrophic", "note"): + if key in ver and key not in v: + v[key] = ver.pop(key) + return v + return {} + + +def build_tasks(items: dict, by_id: dict) -> list[tuple]: + """Дедупликация вариантов + подкладка декоя. Возвращает задания судье.""" + # пул для декоя: по выборке — все dst ДРУГИХ термов + pool: dict[str, list[tuple[str, str]]] = {} + for tid, variants in by_id.items(): + it = items.get(tid) + if not it: + continue + for dst in variants.values(): + pool.setdefault(it["sample"], []).append((tid, dst)) + + tasks = [] + for tid in sorted(by_id): + it = items.get(tid) + if not it: + continue + # схлопнуть одинаковые строки: ключ — нормализованный текст, значение — (показ, источники) + groups: dict[str, tuple[str, list[str]]] = {} + for src_label, dst in sorted(by_id[tid].items()): + k = norm(dst) + if k not in groups: + groups[k] = (dst, []) + groups[k][1].append(src_label) + if len(groups) < 2: + continue # вырожденный терм — сравнивать нечего + + # Декой: dst ДРУГОГО терма той же выборки. Выбор детерминирован солью по терму (прогон + # воспроизводим), первый кандидат, не совпадающий ни с одним настоящим вариантом. + decoy = None + cand_pool = sorted({d for (other, d) in pool.get(it["sample"], []) if other != tid}) + if cand_pool: + start = int(hashlib.sha256(f"decoy:{tid}".encode()).hexdigest(), 16) % len(cand_pool) + for k in range(len(cand_pool)): + pick = cand_pool[(start + k) % len(cand_pool)] + if norm(pick) not in groups: + decoy = pick + break + variants = {f"V{n}": g for n, g in enumerate(groups.values())} + if decoy: + variants["DECOY"] = (decoy, ["DECOY"]) + + crits = [c for c in CRITERIA if not (c == "K2" and it["sample"] in ("S3", "S4"))] + order = salt_order(tid, sorted(variants)) + letters = "ABCDEFGH" + cands = [(letters[k], variants[key][0]) for k, key in enumerate(order)] + letter2sources = {letters[k]: variants[key][1] for k, key in enumerate(order)} + tasks.append((tid, it, cands, letter2sources, crits)) + return tasks + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--termset", required=True, type=Path) + ap.add_argument("--arms", action="append", required=True, type=Path, + help="можно указать несколько файлов армов (фаза B + арм P5 фазы B′)") + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--raw-dir", required=True, type=Path) + ap.add_argument("--judge", required=True) + ap.add_argument("--base-url", required=True) + ap.add_argument("--key-env", required=True) + ap.add_argument("--style", choices=["openai-reasoning", "plain", "xai"], default="plain") + ap.add_argument("--max-tokens", type=int, default=4000) + ap.add_argument("--dry-run", action="store_true") + a = ap.parse_args() + + items = {i["id"]: i for i in json.loads(a.termset.read_text(encoding="utf-8"))} + by_id: dict[str, dict[str, str]] = {} + for f in a.arms: + for r in json.loads(f.read_text(encoding="utf-8"))["results"]: + if r.get("dst"): + by_id.setdefault(r["id"], {})[r["arm"]] = r["dst"].strip() + for tid, it in items.items(): + if it.get("gold") and tid in by_id: + by_id[tid]["GOLD"] = it["gold"].strip() + + doc = yaml.safe_load((REPO / "backend/configs/models.yaml").read_text(encoding="utf-8")) or {} + p = ((doc.get("models") or {}).get(a.judge) or {}).get("price") or {} + pin, pout = float(p.get("input_per_m", 0)), float(p.get("output_per_m", 0)) + + tasks = build_tasks(items, by_id) + ndec = sum(1 for _, _, _, l2s, _ in tasks if any("DECOY" in v for v in l2s.values())) + sizes = [len(c) for _, _, c, _, _ in tasks] + est_in = sum(len(build_prompt(it, c, cr)) for _, it, c, _, cr in tasks) / 3.2 + est_out = len(tasks) * 350 + print(f"ПЛАН судьи {a.judge}: термов {len(tasks)} (из {len(by_id)}); с декоем {ndec}; " + f"вариантов на терм {min(sizes)}–{max(sizes)} (в фазе B было ровно 7)") + print(f"СМЕТА ДО ВЫЗОВОВ: вход ≈{est_in/1000:.1f}k → ${est_in/1e6*pin:.4f}; " + f"выход ≈{est_out/1000:.1f}k → ${est_out/1e6*pout:.4f}; " + f"ИТОГО ≈ ${est_in/1e6*pin + est_out/1e6*pout:.4f}") + if a.dry_run: + return 0 + + key = os.environ.get(a.key_env) + if not key: + print(f"нет ключа {a.key_env}", file=sys.stderr) + return 2 + client = OpenAI(api_key=key, base_url=a.base_url) + a.raw_dir.mkdir(parents=True, exist_ok=True) + + rows, ledger = [], [] + for n, (tid, it, cands, l2s, crits) in enumerate(tasks, 1): + prompt = build_prompt(it, cands, crits) + kw: dict = {"model": a.judge, "messages": [{"role": "user", "content": prompt}]} + if a.style == "openai-reasoning": + kw["max_completion_tokens"] = a.max_tokens + kw["reasoning_effort"] = "minimal" + elif a.style == "xai": + kw["max_tokens"] = a.max_tokens + kw["reasoning_effort"] = "low" + else: + kw["max_tokens"] = a.max_tokens + try: + resp = client.chat.completions.create(**kw) + except Exception as e: + print(f"[{n}/{len(tasks)}] {tid} ОШИБКА: {e}", file=sys.stderr) + ledger.append({"id": tid, "error": str(e)}) + continue + txt = resp.choices[0].message.content or "" + u = resp.usage + cin, cout = getattr(u, "prompt_tokens", 0), getattr(u, "completion_tokens", 0) + usd = cin / 1e6 * pin + cout / 1e6 * pout + v = parse_obj(txt) + rows.append({"id": tid, "sample": it["sample"], "judge": a.judge, + "letter2sources": l2s, "candidates": dict(cands), + "verdicts": v.get("verdicts", {}), "catastrophic": v.get("catastrophic", []), + "note": v.get("note", "")}) + (a.raw_dir / f"{a.judge}_{n:03d}.json").write_text( + json.dumps({"id": tid, "prompt": prompt, "response": txt, + "usage": {"in": cin, "out": cout}, "finish": resp.choices[0].finish_reason}, + ensure_ascii=False, indent=1), encoding="utf-8") + ledger.append({"id": tid, "in": cin, "out": cout, "usd": usd, + "finish": resp.choices[0].finish_reason, "ok": bool(v.get("verdicts"))}) + if n % 10 == 0 or n == len(tasks): + print(f" [{n}/{len(tasks)}] ${sum(x.get('usd', 0) for x in ledger):.4f}") + time.sleep(0.2) + + a.out.write_text(json.dumps({"rows": rows, "ledger": ledger}, ensure_ascii=False, indent=1), + encoding="utf-8") + tot = sum(x.get("usd", 0) for x in ledger) + ok = sum(1 for r in rows if r["verdicts"]) + print(f"\nСУДЬЯ {a.judge}: вердиктов {ok}/{len(tasks)}, ПОТРАЧЕНО ${tot:.5f}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/pkg7/build_termset.py b/eval/pkg7/build_termset.py new file mode 100644 index 00000000..82a20d7f --- /dev/null +++ b/eval/pkg7/build_termset.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7, фаза B: сборка набора термов со входом терминолога. + +Вход роли ТЕРМИНОЛОГ по D39.42 п.1 — «смердженный банк + KWIC-контексты вхождений из исходника + +варианты черновиков». Здесь это собирается из РЕАЛЬНЫХ артефактов стенда, а не из выдумки: + + - `books/gu-zhenren/labels/raw/corpus.jsonl` — 91 юнит из ШЕСТИ настоящих прогонов с + выровненными source/draft/final (постоянный измерительный стенд, D39.39); + - сид книги + `minirun/mined-delta.yaml` — ПОДПИСАННЫЕ владельцем dst (золотой стандарт); + - TSV боевого майнера (пакет-7 фаза A) — намайненные термы БЕЗ dst; + - JSONL-субстраты остальных книг стенда — для выборок S2–S4. + +ЧЕСТНОЕ ОГРАНИЧЕНИЕ, зафиксированное ДО трат: черновики существуют ТОЛЬКО у 蛊真人. У 金瓶梅, +исэкая и en-книг переводов нет, поэтому на выборках S2–S4 компонента «варианты черновиков» пуста +по построению, и арм P1 там вырождается в «только KWIC». Это не правка пре-регистрации, а +названное ограничение материала. + +$0: чтение файлов, ни одного вызова модели. +""" +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +import yaml + +CORPUS = Path("/home/ubuntu/books/gu-zhenren/labels/raw/corpus.jsonl") + + +def load_jsonl(p: Path) -> list[dict]: + return [json.loads(l) for l in p.open(encoding="utf-8") if l.strip()] + + +def kwic(units: list[dict], term: str, field: str, width: int, limit: int) -> list[dict]: + out = [] + for u in units: + txt = u.get(field) or "" + pos = 0 + while len(out) < limit: + i = txt.find(term, pos) + if i < 0: + break + a, b = max(0, i - width), min(len(txt), i + len(term) + width) + out.append({"chapter": u.get("chapter"), "run": u.get("run"), + "win": txt[a:b].replace("\n", " ")}) + pos = i + len(term) + return out + + +def aligned_pairs(units: list[dict], term: str, limit: int, src_w: int, dst_w: int) -> list[dict]: + """Пары «окно исходника ↔ пропорционально позиционированное окно черновика». + + Выравнивание ПРОПОРЦИОНАЛЬНОЕ (доля позиции в юните), а не словное: это приближение, и оно + названо приближением. Задача пары — показать модели, ЧТО черновик сделал в этом месте, а не + дать точную привязку токена. + """ + out = [] + for u in units: + src, drf = u.get("source") or "", u.get("draft") or "" + if not src or not drf: + continue + i = src.find(term) + if i < 0: + continue + frac = i / max(len(src), 1) + j = int(frac * len(drf)) + a, b = max(0, i - src_w), min(len(src), i + len(term) + src_w) + c, d = max(0, j - dst_w), min(len(drf), j + dst_w) + out.append({"run": u.get("run"), "chapter": u.get("chapter"), + "src_win": src[a:b].replace("\n", " "), + "draft_win": drf[c:d].replace("\n", " ")}) + if len(out) >= limit: + break + return out + + +def seed_terms(paths: list[Path]) -> dict[str, dict]: + out = {} + for p in paths: + if not p.exists(): + continue + doc = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + for t in doc.get("terms") or []: + if t.get("src") and t.get("dst"): + out[t["src"]] = {"gold": t["dst"], "type": t.get("type", ""), + "note": (t.get("note") or "")[:400]} + return out + + +def mined_terms(tsv: Path) -> list[dict]: + rows = [] + for i, line in enumerate(tsv.open(encoding="utf-8")): + if i == 0: + continue + f = line.rstrip("\n").split("\t") + if f and f[0]: + rows.append({"src": f[0], "type": f[1] if len(f) > 1 else "", + "freq": int(f[2]) if len(f) > 2 and f[2].isdigit() else 0}) + return rows + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--mined-tsv", type=Path, action="append", default=[]) + ap.add_argument("--chunks", action="append", default=[], + help="sample=path.jsonl для книг без черновиков (S2..S4)") + ap.add_argument("--extra-terms", action="append", default=[], + help="sample=терм,терм,... — ручной список (ловушка S5)") + ap.add_argument("--n-signed", type=int, default=15) + ap.add_argument("--n-mined", type=int, default=15) + a = ap.parse_args() + + units = load_jsonl(CORPUS) + gold = seed_terms([Path("/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml"), + Path("/home/ubuntu/books/gu-zhenren/minirun/mined-delta.yaml")]) + + items: list[dict] = [] + + # --- S1: подписанные термы, реально встречающиеся в корпусе (у них ЕСТЬ эталон) ----------- + signed = [] + for src, meta in gold.items(): + occ = sum((u.get("source") or "").count(src) for u in units) + if occ: + signed.append((occ, src, meta)) + signed.sort(key=lambda x: -x[0]) + for occ, src, meta in signed[: a.n_signed]: + items.append({ + "id": f"S1-signed-{src}", "sample": "S1", "src": src, "lang": "zh", + "gold": meta["gold"], "gold_type": meta["type"], "gold_note": meta["note"], + "occurrences": occ, + "kwic": kwic(units, src, "source", 60, 6), + "pairs": aligned_pairs(units, src, 3, 90, 130), + }) + + # --- S1: намайненные БЕЗ dst --------------------------------------------------------------- + seen = {i["src"] for i in items} + pool = [] + for tsv in a.mined_tsv: + for r in mined_terms(tsv): + if r["src"] in gold or r["src"] in seen: + continue + occ = sum((u.get("source") or "").count(r["src"]) for u in units) + if occ: + pool.append((occ, r)) + pool.sort(key=lambda x: -x[0]) + picked = set() + for occ, r in pool: + if r["src"] in picked: + continue + picked.add(r["src"]) + items.append({ + "id": f"S1-mined-{r['src']}", "sample": "S1", "src": r["src"], "lang": "zh", + "gold": None, "gold_type": r["type"], "gold_note": "", "occurrences": occ, + "kwic": kwic(units, r["src"], "source", 60, 6), + "pairs": aligned_pairs(units, r["src"], 3, 90, 130), + }) + if len(picked) >= a.n_mined: + break + + # --- S2..S4: книги без черновиков ---------------------------------------------------------- + for spec in a.chunks: + sample, path = spec.split("=", 1) + rows = load_jsonl(Path(path)) + us = [{"source": r["source"], "chapter": r["chapter"], "run": sample} for r in rows] + for spec2 in a.extra_terms: + s2, terms = spec2.split("=", 1) + if s2 != sample: + continue + for t in [x for x in terms.split(",") if x.strip()]: + occ = sum(u["source"].count(t) for u in us) + items.append({ + "id": f"{sample}-{t}", "sample": sample, "src": t, + "lang": {"S2": "zh", "S3": "ja", "S4": "en"}.get(sample, "zh"), + "gold": None, "gold_type": "", "gold_note": "", "occurrences": occ, + "kwic": kwic(us, t, "source", 60, 6), + "pairs": [], # черновиков нет по построению — названо в шапке + }) + + # --- S5: ловушка — подписанные dst, НАРУШАЮЩИЕ буквальность (стайл-каноны D39.21) --------- + for spec2 in a.extra_terms: + s2, terms = spec2.split("=", 1) + if s2 != "S5": + continue + for t in [x for x in terms.split(",") if x.strip()]: + occ = sum((u.get("source") or "").count(t) for u in units) + items.append({ + "id": f"S5-{t}", "sample": "S5", "src": t, "lang": "zh", + "gold": gold.get(t, {}).get("gold"), "gold_type": gold.get(t, {}).get("type", ""), + "gold_note": gold.get(t, {}).get("note", ""), "occurrences": occ, + "kwic": kwic(units, t, "source", 60, 6), + "pairs": aligned_pairs(units, t, 3, 90, 130), + }) + + a.out.write_text(json.dumps(items, ensure_ascii=False, indent=1), encoding="utf-8") + from collections import Counter + c = Counter(i["sample"] for i in items) + ng = sum(1 for i in items if i["gold"]) + npair = sum(1 for i in items if i["pairs"]) + print(f"собрано термов: {len(items)} по выборкам {dict(c)} с эталоном {ng} с парами черновика {npair}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/pkg7/repair_judge.py b/eval/pkg7/repair_judge.py new file mode 100644 index 00000000..3e58bb2e --- /dev/null +++ b/eval/pkg7/repair_judge.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7, фаза B: терпимый пере-разбор судейских ответов из СЫРЬЯ. + +Зачем. Строгий разбор в `blind_judge.py` потерял 5 вердиктов из 80 у первого судьи: модель отдала +валидный по смыслу, но структурно битый JSON — `catastrophic` и `note` оказались ВНУТРИ объекта +`verdicts`, а закрывающей скобки не хватило. Ответы сохранены целиком (правило 1 полигона), поэтому +починка стоит $0 и не требует повторных вызовов — ровно ради этого сырьё и сохраняется. + +Что делает терпимый разбор: + 1) пытается строгий json.loads; + 2) при неудаче достраивает недостающие закрывающие скобки (до 3) и пробует снова; + 3) если после разбора `catastrophic`/`note` лежат ВНУТРИ `verdicts` — поднимает их на уровень выше; + 4) отбрасывает из `verdicts` всё, что не является парой {best, worst}. + +Ничего не додумывает: если после этого объект не читается, строка остаётся без вердикта и попадает +в отчёт как потеря. +""" +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +CRIT = {"K1", "K2", "K5", "K6", "K9"} + + +def tolerant(text: str) -> dict: + m = re.search(r"\{.*\}", text, re.S) + if not m: + return {} + raw = m.group(0) + for extra in range(0, 4): + try: + v = json.loads(raw + "}" * extra) + except json.JSONDecodeError: + continue + if not isinstance(v, dict): + return {} + ver = v.get("verdicts") + if isinstance(ver, dict): + # поднять поля, случайно вложенные в verdicts + for key in ("catastrophic", "note"): + if key in ver and key not in v: + v[key] = ver.pop(key) + v["verdicts"] = {k: x for k, x in ver.items() + if k in CRIT and isinstance(x, dict) and "best" in x} + return v + return {} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--judge-json", required=True, type=Path) + ap.add_argument("--raw-dir", required=True, type=Path) + ap.add_argument("--prefix", required=True, help="напр. gpt-5-mini_fwd") + ap.add_argument("--out", required=True, type=Path) + a = ap.parse_args() + + d = json.loads(a.judge_json.read_text(encoding="utf-8")) + raw_by_id = {} + for p in sorted(a.raw_dir.glob(f"{a.prefix}_*.json")): + r = json.loads(p.read_text(encoding="utf-8")) + raw_by_id[r["id"]] = r["response"] + + fixed = 0 + for row in d["rows"]: + if row.get("verdicts"): + continue + v = tolerant(raw_by_id.get(row["id"], "")) + if v.get("verdicts"): + row["verdicts"] = v["verdicts"] + row["catastrophic"] = v.get("catastrophic", []) + row["note"] = v.get("note", "") + row["repaired"] = True + fixed += 1 + + a.out.write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8") + total = sum(1 for r in d["rows"] if r.get("verdicts")) + print(f"{a.judge_json.name}: восстановлено {fixed}; вердиктов {total}/{len(d['rows'])} → {a.out.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/pkg7/score_b2.py b/eval/pkg7/score_b2.py new file mode 100644 index 00000000..c5071c7c --- /dev/null +++ b/eval/pkg7/score_b2.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7, фаза B′: подсчёт по пре-регистрации §0 (T · J · R) и вердикты по её порогам. + +Пороги названы ДО трат и здесь только ПРОВЕРЯЮТСЯ: + T1 `春秋蝉` и `Silversaint`: P5 (без правила транскрипции) против P2 + T2 прокси «целиком несловарный dst»: |P5 − P2| ≥ 0.05 ⇒ эффект правила есть + T3 совпадение с подписью владельца: P5 ≥ 17/19 ⇒ снятие правила не ломает другое + C1 судья отбраковывается, если ДЕКОЙ назван лучшим по K1 более чем в 10% термов + C2 судья отбраковывается, если катастроф на GOLD ≥ чем на декое + C3 воспроизводит ли судья объективный слой: P0b в нижней трети по K1 + R межарменный разрыв P0b↔P1 против внутриармового разброса по трём повторам + +BWS при дедупликации: вариант получает +1/−1, и этот балл достаётся КАЖДОМУ арму-источнику +варианта (одинаковые строки схлопнуты, значит и заслуга у них общая). Декой считается отдельной +«армой» и в ранжирование арм не входит. +""" +from __future__ import annotations + +import argparse +import collections +import json +import re +import statistics +from pathlib import Path + +CRITS = ["K1", "K2", "K5", "K6", "K9"] +ARMS = ["P0b", "P0", "P1", "P2", "P3", "P4", "P5"] +T2_MIN = 0.05 +T3_MIN = 17 +C1_MAX = 0.10 +STOP = {"и", "в", "на", "с", "из", "по", "о", "у", "за", "от", "для"} + + +def norm(s: str | None) -> str: + return re.sub(r"\s+", " ", (s or "").strip().lower()) + + +def load_arms(paths: list[Path]) -> dict[str, dict[str, str]]: + by: dict[str, dict[str, str]] = {} + for p in paths: + for r in json.loads(p.read_text(encoding="utf-8"))["results"]: + if r.get("dst"): + by.setdefault(r["id"], {})[r["arm"]] = r["dst"].strip() + return by + + +def nondict(dst: str, morph) -> bool: + ws = [w for w in re.findall(r"[А-Яа-яЁё-]+", dst or "") if w.lower() not in STOP] + return bool(ws) and all(not any(x.is_known for x in morph.parse(w)) for w in ws) + + +def verdicts_of(row: dict) -> dict: + """Ключи критериев приводятся к коду: модель иногда отдаёт «K1 верность концепту» или «K1: …». + + Это разбор, а не додумывание: код критерия берётся из начала ключа, содержимое не трогается. + Без нормализации терялись вердикты, которые модель прислала (урок `repair_judge.py` фазы B — + чинить разбор из сохранённого сырья, а не покупать повторные вызовы). + """ + out = {} + for k, v in (row.get("verdicts") or {}).items(): + m = re.match(r"\s*(K\d+)\b", str(k)) + if m and isinstance(v, dict) and "best" in v: + out.setdefault(m.group(1), v) + return out + + +def bws_dedup(rows: list[dict], crit: str) -> tuple[dict[str, float], int]: + plus, minus, seen = collections.Counter(), collections.Counter(), collections.Counter() + n = 0 + for r in rows: + v = verdicts_of(r).get(crit) + if not isinstance(v, dict): + continue + l2s = r["letter2sources"] + n += 1 + for srcs in l2s.values(): + for s in srcs: + seen[s] += 1 + for src in l2s.get(str(v.get("best")), []): + plus[src] += 1 + for src in l2s.get(str(v.get("worst")), []): + minus[src] += 1 + return {a: (plus[a] - minus[a]) / c for a, c in seen.items() if c}, n + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--termset", required=True, type=Path) + ap.add_argument("--arms", action="append", required=True, type=Path) + ap.add_argument("--judge", action="append", default=[], type=Path) + ap.add_argument("--repeat", action="append", default=[], type=Path, + help="файлы повторных прогонов для замера R") + ap.add_argument("--out", required=True, type=Path) + a = ap.parse_args() + + items = {i["id"]: i for i in json.loads(a.termset.read_text(encoding="utf-8"))} + by = load_arms(a.arms) + rep: dict = {} + + # ---------- T: контрольный арм без правила транскрипции --------------------------------- + print("=== T. КОНТРОЛЬ ПРАВИЛА ТРАНСКРИПЦИИ (P5 = P2 минус одна строка) ===") + print("\n T1 качественно — термы, на которых строилась рекомендация Z-B3:") + t1 = {} + for tid, it in items.items(): + if it["src"] in ("春秋蝉",) or "ilversaint" in it["src"]: + v = by.get(tid, {}) + t1[it["src"]] = {k: v.get(k) for k in ("GOLD", "P2", "P5")} + t1[it["src"]]["GOLD"] = it.get("gold") + print(f" {it['src']:14s} эталон={it.get('gold')!r} P2={v.get('P2')!r} P5={v.get('P5')!r}") + + try: + import pymorphy3 + morph = pymorphy3.MorphAnalyzer() + except Exception: + morph = None + t2 = {} + if morph: + per = collections.defaultdict(list) + for tid, v in by.items(): + for arm, dst in v.items(): + if arm in ARMS: + per[arm].append(nondict(dst, morph)) + t2 = {arm: sum(x) / len(x) for arm, x in per.items() if x} + d = t2.get("P2", 0) - t2.get("P5", 0) + print(f"\n T2 прокси «целиком несловарный dst» по всем термам: " + + " · ".join(f"{k} {t2[k]:.3f}" for k in ARMS if k in t2)) + print(f" P2 − P5 = {d:+.3f} (порог {T2_MIN}) → " + f"{'ЭФФЕКТ ЕСТЬ' if d >= T2_MIN else 'ЭФФЕКТА НЕТ'}") + + gold_ids = [tid for tid, it in items.items() if it.get("gold")] + t3 = {} + for arm in ARMS: + hit = sum(1 for tid in gold_ids if norm(by.get(tid, {}).get(arm)) == norm(items[tid]["gold"])) + have = sum(1 for tid in gold_ids if by.get(tid, {}).get(arm)) + if have: + t3[arm] = (hit, have) + print("\n T3 совпадение с подписью владельца: " + + " · ".join(f"{k} {v[0]}/{v[1]}" for k, v in t3.items())) + p5hit = t3.get("P5", (0, 0))[0] + print(f" P5 = {p5hit}/19 (порог ≥{T3_MIN}) → {'НЕ ХУЖЕ' if p5hit >= T3_MIN else 'ХУЖЕ, снятие правила вредит'}") + rep["T"] = {"T1": t1, "T2": t2, "T3": {k: list(v) for k, v in t3.items()}} + + # ---------- J: слепая оценка на починенном наборе ----------------------------------------- + if a.judge: + print("\n=== J. СЛЕПАЯ ОЦЕНКА НА ПОЧИНЕННОМ НАБОРЕ ===") + rep["J"] = {} + for jf in a.judge: + rows = json.loads(jf.read_text(encoding="utf-8"))["rows"] + name = rows[0]["judge"] if rows else jf.stem + k1, n1 = bws_dedup(rows, "K1") + arms_only = {k: v for k, v in k1.items() if k in ARMS} + order = sorted(arms_only.items(), key=lambda x: -x[1]) + spread = (order[0][1] - order[-1][1]) if order else 0 + print(f"\n судья {name}: термов с вердиктом {n1}") + print(" K1: " + " · ".join(f"{k} {v:+.3f}" for k, v in order) + + f" [DECOY {k1.get('DECOY', float('nan')):+.3f}] [GOLD {k1.get('GOLD', float('nan')):+.3f}]") + print(f" разброс между армами: {spread:.3f} (в фазе B было 0.088 на семи одинаковых строках)") + + # C1 — декой лучшим + dec_best = tot = 0 + for r in rows: + v = verdicts_of(r).get("K1") + if not isinstance(v, dict): + continue + if "DECOY" not in {s for ss in r["letter2sources"].values() for s in ss}: + continue + tot += 1 + if "DECOY" in r["letter2sources"].get(str(v.get("best")), []): + dec_best += 1 + c1 = dec_best / tot if tot else float("nan") + # C2 — катастрофы: декой против подписи + cat = collections.Counter() + for r in rows: + for L in r.get("catastrophic") or []: + for s in r["letter2sources"].get(str(L), []): + cat[s] += 1 + # C3 — воспроизводит ли объективный слой + names = [k for k, _ in order] + c3 = names.index("P0b") >= len(names) - max(1, len(names) // 3) if "P0b" in names else False + print(f" C1 декой назван ЛУЧШИМ: {dec_best}/{tot} = {c1:.3f} (порог ≤{C1_MAX}) → " + f"{'ПРОШЁЛ' if c1 <= C1_MAX else 'ОТБРАКОВАН'}") + print(f" C2 катастрофы: декой {cat.get('DECOY', 0)} против GOLD {cat.get('GOLD', 0)} → " + f"{'ПРОШЁЛ' if cat.get('DECOY', 0) > cat.get('GOLD', 0) else 'ОТБРАКОВАН'}") + print(f" C3 P0b в нижней трети по K1: {'ДА' if c3 else 'НЕТ'} (объективный слой: P0b худший)") + # C3-бис: ранговое согласие с объективным слоем целиком, а не по одному арму. + # Бинарный C3 отвечает «да/нет» про P0b и молчит о том, совпадает ли остальной порядок. + common = [x for x in ARMS if x in arms_only and x in t3] + if len(common) >= 3: + jr = {a: k for k, a in enumerate(sorted(common, key=lambda x: -arms_only[x]))} + orr = {a: k for k, a in enumerate(sorted(common, key=lambda x: -t3[x][0]))} + n = len(common) + d2 = sum((jr[a] - orr[a]) ** 2 for a in common) + rho = 1 - 6 * d2 / (n * (n * n - 1)) + print(f" C3-бис ранговая корреляция с объективным слоем (Спирмен): ρ = {rho:+.3f} " + f"на {n} армах; судья: {sorted(common, key=lambda x: -arms_only[x])}; " + f"объективно: {sorted(common, key=lambda x: -t3[x][0])}") + rep["J"][name] = {"k1": k1, "spread": spread, "C1": c1, "C1_n": tot, + "cat": dict(cat), "C3": bool(c3), + "pass": bool(c1 <= C1_MAX and cat.get("DECOY", 0) > cat.get("GOLD", 0))} + + # H7 — грубая ошибка против тонкой разницы + for name, d in rep["J"].items(): + arms_only = {k: v for k, v in d["k1"].items() if k in ARMS} + if arms_only and "DECOY" in d["k1"]: + gap_arms = max(arms_only.values()) - min(arms_only.values()) + gap_dec = statistics.median(arms_only.values()) - d["k1"]["DECOY"] + print(f"\n H7 [{name}]: разрыв «медиана армов − декой» = {gap_dec:+.3f}; " + f"разрыв «лучший − худший арм» = {gap_arms:.3f} → " + f"{'подтверждается' if gap_dec > gap_arms else 'не подтверждается'}") + + # ---------- R: разброс прогона ------------------------------------------------------------- + if a.repeat: + print("\n=== R. РАЗБРОС ПРОГОНА (три повтора) ===") + runs = collections.defaultdict(list) + for f in a.repeat: + b = load_arms([f]) + for arm in ("P0b", "P1", "P2"): + hit = sum(1 for tid in gold_ids if norm(b.get(tid, {}).get(arm)) == norm(items[tid]["gold"])) + have = sum(1 for tid in gold_ids if b.get(tid, {}).get(arm)) + if have: + runs[arm].append(hit) + for arm, xs in runs.items(): + print(f" {arm:4s} повторы {xs} из 19 → медиана {statistics.median(xs):.1f}, " + f"размах {max(xs)-min(xs)}") + if "P0b" in runs and "P1" in runs: + gap = statistics.median(runs["P1"]) - statistics.median(runs["P0b"]) + spread = max(max(runs["P0b"]) - min(runs["P0b"]), max(runs["P1"]) - min(runs["P1"])) + print(f" разрыв P0b↔P1 = {gap:.1f} терма; внутриармовый размах = {spread} терма → " + f"{'ВЫВОД УСТОЯЛ' if gap > spread else 'ВЫВОД НЕ УСТАНОВЛЕН'}") + rep["R"] = {"runs": dict(runs), "gap": gap, "spread": spread, "holds": bool(gap > spread)} + + # ---------- H6 ------------------------------------------------------------------------------- + deg = collections.Counter() + tot = collections.Counter() + for tid, v in by.items(): + s = items[tid]["sample"] + tot[s] += 1 + cands = {norm(x) for x in v.values()} + if items[tid].get("gold"): + cands.add(norm(items[tid]["gold"])) + if len(cands) == 1: + deg[s] += 1 + print("\n=== H6. доля вырожденных термов по выборкам ===") + for s in sorted(tot): + print(f" {s}: {deg[s]}/{tot[s]} = {deg[s]/tot[s]:.2f}") + rep["H6"] = {s: [deg[s], tot[s]] for s in tot} + + a.out.write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/pkg7/score_bws.py b/eval/pkg7/score_bws.py new file mode 100644 index 00000000..fcd67b34 --- /dev/null +++ b/eval/pkg7/score_bws.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7, фаза B: агрегация Best-Worst Scaling и ВЕРДИКТЫ F1–F4. + +Счёт BWS (стандартный): по каждому критерию лучший вариант +1, худший −1; сумма по термам, +нормировка на число термов. Ноль означает «выбирают не чаще и не реже прочих». + +Критерии провала названы ДО трат (§A5.6) и здесь только ПРОВЕРЯЮТСЯ, а не переформулируются: + F1 P0b (без контекстов) неотличим от P1 ⇒ контексты не работают + F1b P0 (СЛУЧАЙНЫЙ глоссарий) неотличим от P2 ⇒ прирост даёт факт подсказки, а не её правильность + F2 ни один арм не бьёт подписанный владельцем dst хотя бы вничью по K1 ⇒ роль не готова к авто + F3 арм проваливает ловушку S5 чаще, чем в 1 случае из 5 ⇒ авто-режим опасен + F4 согласие судей по K1 ниже порога ИЛИ leave-one-out по судье меняет победителя ⇒ мерить нечем + +«Неотличим» операционализирован ДО просмотра данных: |ΔBWS| < 0.10 по K1 на общей выборке. +""" +from __future__ import annotations + +import argparse +import itertools +import json +from collections import defaultdict +from pathlib import Path + +CRITS = ["K1", "K2", "K5", "K6", "K9"] +INDIST = 0.10 # порог «неотличимости» по BWS +ALPHA_FLOOR = 0.60 # порог согласия (F4) + + +def load_rows(paths: list[Path]) -> list[dict]: + rows = [] + for p in paths: + rows.extend(json.loads(p.read_text(encoding="utf-8"))["rows"]) + return rows + + +def bws(rows: list[dict], crit: str, samples: set[str] | None = None, + judges: set[str] | None = None) -> dict[str, float]: + plus, minus, seen = defaultdict(int), defaultdict(int), defaultdict(int) + for r in rows: + if samples and r["sample"] not in samples: + continue + if judges and r["judge"] not in judges: + continue + v = (r.get("verdicts") or {}).get(crit) + if not isinstance(v, dict): + continue + l2a = r["letter2arm"] + for arm in l2a.values(): + seen[arm] += 1 + b, w = l2a.get(str(v.get("best"))), l2a.get(str(v.get("worst"))) + if b: + plus[b] += 1 + if w: + minus[w] += 1 + return {a: (plus[a] - minus[a]) / n for a, n in seen.items() if n} + + +def agreement_k1(rows: list[dict]) -> tuple[float, int]: + """Доля совпадений выбора ЛУЧШЕГО по K1 между парами вердиктов на один терм. + + Считаем простое попарное согласие по номинальной категории (какой АРМ признан лучшим): + оно интерпретируемо и не требует допущений, которых мы не проверяли. Krippendorff α по + номинальной шкале на двух оценщиках вырождается в ту же величину с поправкой на случайность, + поэтому рядом печатается и она. + """ + by_term = defaultdict(list) + for r in rows: + v = (r.get("verdicts") or {}).get("K1") + if isinstance(v, dict): + arm = r["letter2arm"].get(str(v.get("best"))) + if arm: + by_term[r["id"]].append(arm) + agree = tot = 0 + counts = defaultdict(int) + for _, arms in by_term.items(): + for x, y in itertools.combinations(arms, 2): + tot += 1 + agree += int(x == y) + for x in arms: + counts[x] += 1 + n = sum(counts.values()) + pe = sum((c / n) ** 2 for c in counts.values()) if n else 0 + po = agree / tot if tot else 0 + kappa = (po - pe) / (1 - pe) if tot and pe < 1 else float("nan") + return po, tot, kappa + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--judge", action="append", required=True, type=Path) + ap.add_argument("--termset", required=True, type=Path) + ap.add_argument("--out", required=True, type=Path) + a = ap.parse_args() + + rows = load_rows(a.judge) + items = {i["id"]: i for i in json.loads(a.termset.read_text(encoding="utf-8"))} + fwd = [r for r in rows if not r.get("reverse")] + judges = sorted({r["judge"] for r in rows}) + + report: dict = {"n_verdict_rows": len(rows), "judges": judges} + + print("=== BWS по критериям (все выборки, прямой порядок) ===") + table = {} + for c in CRITS: + s = bws(fwd, c) + table[c] = s + print(f" {c}: " + " ".join(f"{k}={v:+.3f}" for k, v in sorted(s.items(), key=lambda x: -x[1]))) + report["bws"] = table + + print("\n=== BWS по K1 в разрезе выборок ===") + per_sample = {} + for s in sorted({r["sample"] for r in fwd}): + d = bws(fwd, "K1", samples={s}) + per_sample[s] = d + print(f" {s}: " + " ".join(f"{k}={v:+.3f}" for k, v in sorted(d.items(), key=lambda x: -x[1]))) + report["bws_k1_by_sample"] = per_sample + + k1 = table["K1"] + # --- F1 / F1b --------------------------------------------------------------------------- + d1 = abs(k1.get("P1", 0) - k1.get("P0b", 0)) + d1b = abs(k1.get("P2", 0) - k1.get("P0", 0)) + f1 = d1 < INDIST + f1b = d1b < INDIST + + # --- F2: бьёт ли лучший арм подпись владельца (только термы, где GOLD участвовал) -------- + gold_ids = {i for i, it in items.items() if it.get("gold")} + gold_rows = [r for r in fwd if r["id"] in gold_ids and "GOLD" in r["letter2arm"].values()] + k1g = bws(gold_rows, "K1") + best_arm = max((x for x in k1g.items() if x[0] != "GOLD"), key=lambda x: x[1], default=("—", -9)) + f2 = best_arm[1] < k1g.get("GOLD", 0) + + # --- F3: ловушка S5 — арм назван катастрофой или проиграл GOLD ----------------------------- + s5 = [r for r in fwd if r["sample"] == "S5"] + cat = defaultdict(int) + for r in s5: + for L in r.get("catastrophic") or []: + arm = r["letter2arm"].get(str(L)) + if arm: + cat[arm] += 1 + n_s5 = len({r["id"] for r in s5}) + f3 = any(v > 1 for v in cat.values()) + + # --- F4: согласие + leave-one-out по судье -------------------------------------------------- + po, npairs, kappa = agreement_k1(fwd) + loo_winners = {} + for j in judges: + rest = [r for r in fwd if r["judge"] != j] + if rest: + d = bws(rest, "K1") + loo_winners[j] = max(d.items(), key=lambda x: x[1])[0] if d else None + overall_winner = max(k1.items(), key=lambda x: x[1])[0] if k1 else None + loo_flip = any(w != overall_winner for w in loo_winners.values() if w) + f4 = (kappa == kappa and kappa < ALPHA_FLOOR) or loo_flip + + # --- своп ------------------------------------------------------------------------------------ + rev = [r for r in rows if r.get("reverse")] + swap = {} + if rev: + kf, kr = bws(fwd, "K1"), bws(rev, "K1") + swap = {a: round(kr.get(a, 0) - kf.get(a, 0), 3) for a in set(kf) | set(kr)} + + print("\n=== ВЕРДИКТЫ КРИТЕРИЕВ ПРОВАЛА (пороги назывались до трат) ===") + print(f" F1 P0b vs P1 по K1: Δ={d1:.3f} (порог {INDIST}) → {'ПРОВАЛ' if f1 else 'НЕ провал'}") + print(f" F1b P0 vs P2 по K1: Δ={d1b:.3f} (порог {INDIST}) → {'ПРОВАЛ' if f1b else 'НЕ провал'}") + print(f" F2 лучший арм {best_arm[0]}={best_arm[1]:+.3f} против GOLD={k1g.get('GOLD', float('nan')):+.3f} " + f"→ {'ПРОВАЛ' if f2 else 'НЕ провал'}") + print(f" F3 катастрофы на S5 ({n_s5} термов): {dict(cat) or 'нет'} → {'ПРОВАЛ' if f3 else 'НЕ провал'}") + print(f" F4 согласие по K1: po={po:.3f}, κ={kappa:.3f} на {npairs} парах; " + f"leave-one-out победитель {loo_winners} при общем {overall_winner} → {'ПРОВАЛ' if f4 else 'НЕ провал'}") + if swap: + print(f" своп (обратный порядок − прямой) по K1: {swap}") + + report.update({"F1": {"delta": d1, "fail": f1}, "F1b": {"delta": d1b, "fail": f1b}, + "F2": {"best_arm": best_arm[0], "best": best_arm[1], + "gold": k1g.get("GOLD"), "fail": f2, "n_terms": len({r['id'] for r in gold_rows})}, + "F3": {"catastrophic": dict(cat), "n_terms": n_s5, "fail": f3}, + "F4": {"po": po, "kappa": kappa, "pairs": npairs, + "loo": loo_winners, "winner": overall_winner, "fail": f4}, + "swap_effect_k1": swap}) + a.out.write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eval/pkg7/terminologist_arms.py b/eval/pkg7/terminologist_arms.py new file mode 100644 index 00000000..92a5f0d0 --- /dev/null +++ b/eval/pkg7/terminologist_arms.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Полигон, пакет-7, фаза B: прогон армов промпта ТЕРМИНОЛОГА по пре-регистрации §A5. + +Шесть армов (пре-регистрация A5.2, после старта НЕ правится): + P0b голый src, без контекстов — контроль «работают ли контексты вообще» + P0 P2, но жанровый словарь ПОДМЕНЁН случайным — контроль WMT23 «правильная ≈ случайная» + P1 src + KWIC + пары черновика — базовая линия + P2 P1 + жанровый словарь + Палладий + лемма — вклад ПАР-ДАННЫХ (гипотеза H1) + P3 P2 + рубрика §A2 в промпте + самооценка — вклад явной рубрики + P4 P2 + офлайн-снапшот справки (БКРС из Z4) — вклад веб-справки (ответ D39.42 п.2) + +Дисциплина денег: цены из `backend/configs/models.yaml` (read-only), расход считается из +ФАКТИЧЕСКОГО `usage` каждого ответа, сырьё сохраняется целиком (правило 1 полигона). Батчинг по 10 +термов на вызов — так работает ратифицированная роль (D39.42 «батчи, дешёвая модель»), и он +объявлен ДО трат; единица учёта остаётся «терм × арм». + +Квирки соблюдены (00-provider-quirks): deepseek-v4-flash — thinking ВКЛЮЧЁН (эхо-мина при выкл.), +`max_tokens` ≥ 8000, temperature/top_p в thinking-режиме НЕ шлём (молча игнорируются). +""" +from __future__ import annotations + +import argparse +import json +import os +import random +import re +import sys +import time +from pathlib import Path + +import yaml +from dotenv import load_dotenv +from openai import OpenAI + +REPO = Path(__file__).resolve().parents[2] +load_dotenv(REPO / "eval" / ".env") + +ARMS = ["P0b", "P0", "P1", "P2", "P3", "P4"] + +# Фаза B′ (добавлено 26.07 ПОСЛЕ ревизии §B6; фаза B не пере-прогонялась, её сырьё нетронуто). +# P5 = P2 минус ОДНА строка правила транскрипции. Контрольного арма без этого правила в фазе B не +# было: правило лежит в BASE_TASK, то есть во всех шести армах сразу — и «эффект правила» измерить +# было нечем. Строка вырезается из готового BASE_TASK программно, чтобы остальной промпт совпадал +# с P2 побайтно, а не «на глаз». +ARMS_B2 = ["P5"] + +BASE_TASK = """Ты ТЕРМИНОЛОГ художественного перевода. Тебе дают термины исходного текста книги. +Для КАЖДОГО термина выбери ОДНО консолидированное соответствие на русском языке, которое будет +использоваться во всей книге единообразно. + +Требования к ответу: +- отвечай СТРОГО одним JSON-массивом, без пояснений вне JSON; +- элемент: {"src": "<исходный термин ровно как дан>", "dst": "<русское соответствие>", "type": "name|place|title|term", "why": "<до 20 слов>"} +- dst даёшь в ИСХОДНОЙ СЛОВАРНОЙ ФОРМЕ (лемма), без падежных окончаний по контексту; +- если термин — имя собственное, передавай его транскрипцией, а не переводом смысла; +- ничего не пропускай: элементов должно быть столько же, сколько терминов на входе.""" + +PN_RULE_LINE = "- если термин — имя собственное, передавай его транскрипцией, а не переводом смысла;\n" +BASE_TASK_NO_PN = BASE_TASK.replace(PN_RULE_LINE, "") +assert BASE_TASK_NO_PN != BASE_TASK, "строка правила транскрипции не найдена — контроль P5 был бы фикцией" + +PALLADIUS_RULES = """Правила транскрипции для пары zh→ru (система Палладия, общепринятая норма): +- пиньинь -ng → русское -н; пиньинь -n → русское -нь (Shanghai → Шанхай, Shaolin → Шаолинь); +- zh → «чж» (не «дж»): Zhang → Чжан; +- hui → «хуэй», gui → «гуй», ü → «юй»; +- j/q/x + i → «цзи»/«ци»/«си».""" + +LEMMA_RULE = """Русский — язык с богатой морфологией. dst обязан быть склоняемым и морфологически +пригодным: у него должен быть устойчивый род и нормальная парадигма. Неизменяемую кальку выбирай +только если склоняемого варианта не существует.""" + +RUBRIC = """Оценивай свой выбор по критериям и коротко отчитайся по ним в поле "why": +K1 верность концепту (то ли это понятие) · K2 жанровая конвенция · K3 транскрипционная норма · +K5 морфологическая пригодность (склоняемость, род) · K6 благозвучие · K9 уместность регистра +(академический термин не подставлять в жанровый текст). Порог: все критерии должны быть не ниже +«приемлемо»; при конфликте K1 важнее K6.""" + + +def load_prices(models_yaml: Path) -> dict: + doc = yaml.safe_load(models_yaml.read_text(encoding="utf-8")) or {} + out = {} + for name, m in (doc.get("models") or {}).items(): + p = m.get("price") or {} + out[name] = (float(p.get("input_per_m", 0)), float(p.get("output_per_m", 0)), + float(p.get("cached_per_m", 0))) + return out + + +def load_genre_glossary(path: Path) -> list[tuple[str, str]]: + rows = [] + if not path.exists(): + return rows + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + f = line.split("\t") + if len(f) >= 2: + rows.append((f[0], f[1])) + return rows + + +def scramble(rows: list[tuple[str, str]], seed: int) -> list[tuple[str, str]]: + """Случайный глоссарий: те же src, но dst переставлены. Ровно контроль WMT23.""" + rnd = random.Random(seed) + dst = [d for _, d in rows] + rnd.shuffle(dst) + return [(s, d) for (s, _), d in zip(rows, dst)] + + +def fmt_glossary(rows: list[tuple[str, str]]) -> str: + if not rows: + return "" + body = "\n".join(f"{s}\t{d}" for s, d in rows) + return f"Жанровый словарь пары (устоявшиеся соответствия жанра):\n{body}" + + +def like_p2(arm: str) -> bool: + """P5 — это P2 во всём, кроме вырезанной строки правила транскрипции.""" + return arm in ("P2", "P5") + + +def fmt_item(it: dict, arm: str, ref: dict) -> str: + parts = [f'ТЕРМИН: {it["src"]} (встречается в книге {it["occurrences"]} раз)'] + if arm == "P0b": + return parts[0] + for k in it["kwic"][:6]: + parts.append(f' контекст: …{k["win"]}…') + for p in it["pairs"][:3]: + parts.append(f' черновик перевода этого места: …{p["draft_win"]}…') + if arm == "P4": + e = ref.get(it["src"]) + if e: + parts.append(f' словарная справка (БКРС): {e[:400]}') + return "\n".join(parts) + + +def build_prompt(items: list[dict], arm: str, gloss: list[tuple[str, str]], + gloss_random: list[tuple[str, str]], ref: dict) -> str: + blocks = [BASE_TASK_NO_PN if arm == "P5" else BASE_TASK] + if arm in ("P2", "P3", "P4", "P5"): + g = fmt_glossary(gloss) + if g: + blocks.append(g) + blocks.append(PALLADIUS_RULES) + blocks.append(LEMMA_RULE) + if arm == "P0": + g = fmt_glossary(gloss_random) + if g: + blocks.append(g) + blocks.append(PALLADIUS_RULES) + blocks.append(LEMMA_RULE) + if arm == "P3": + blocks.append(RUBRIC) + if arm == "P4": + blocks.append("К части терминов приложена СЛОВАРНАЯ СПРАВКА из китайско-русского словаря. " + "Она описывает общеязыковое значение; жанровое употребление может отличаться — " + "используй её как улику, а не как приказ.") + blocks.append("ТЕРМИНЫ:\n\n" + "\n\n".join(fmt_item(i, arm, ref) for i in items)) + return "\n\n".join(blocks) + + +def parse_json_array(text: str) -> list[dict]: + m = re.search(r"\[.*\]", text, re.S) + if not m: + return [] + try: + v = json.loads(m.group(0)) + return v if isinstance(v, list) else [] + except json.JSONDecodeError: + return [] + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--termset", required=True, type=Path) + ap.add_argument("--out", required=True, type=Path) + ap.add_argument("--raw-dir", required=True, type=Path) + ap.add_argument("--model", default="deepseek-v4-flash") + ap.add_argument("--base-url", default="https://api.deepseek.com/v1") + ap.add_argument("--key-env", default="DEEPSEEK_API_KEY") + ap.add_argument("--genre-glossary", type=Path, + default=REPO / "backend/configs/langpacks/zh-ru/genre-glossary.txt") + ap.add_argument("--bkrs", type=Path, help="JSON справок БКРС для арма P4") + ap.add_argument("--batch", type=int, default=10) + ap.add_argument("--max-tokens", type=int, default=8000) + ap.add_argument("--arms", default=",".join(ARMS)) + ap.add_argument("--dry-run", action="store_true", help="сметa без единого вызова") + a = ap.parse_args() + + items = json.loads(a.termset.read_text(encoding="utf-8")) + gloss = load_genre_glossary(a.genre_glossary) + gloss_rand = scramble(gloss, seed=20260726) + ref = {} + if a.bkrs and a.bkrs.exists(): + for r in json.loads(a.bkrs.read_text(encoding="utf-8")): + if r.get("found"): + ref[r["src"]] = re.sub(r"^-->\s*", "", r["entry"]).strip() + + prices = load_prices(REPO / "backend/configs/models.yaml") + pin, pout, _ = prices.get(a.model, (0.0, 0.0, 0.0)) + arms = [x for x in a.arms.split(",") if x] + + # батчи формируются ПО ВЫБОРКЕ, чтобы книга не смешивалась с книгой + batches = [] + for sample in sorted({i["sample"] for i in items}): + xs = [i for i in items if i["sample"] == sample] + for k in range(0, len(xs), a.batch): + batches.append((sample, xs[k:k + a.batch])) + + plan = [(arm, s, b) for arm in arms for (s, b) in batches] + est_in = sum(len(build_prompt(b, arm, gloss, gloss_rand, ref)) for arm, _, b in plan) / 3.2 + est_out = len(plan) * 2500 + print(f"ПЛАН: армов {len(arms)} × батчей {len(batches)} = {len(plan)} вызовов; " + f"термов×армов = {len(items) * len(arms)}") + print(f"СМЕТА ДО ВЫЗОВОВ: вход ≈{est_in/1000:.1f}k ток → ${est_in/1e6*pin:.4f}; " + f"выход ≈{est_out/1000:.1f}k ток → ${est_out/1e6*pout:.4f}; " + f"ИТОГО ≈ ${est_in/1e6*pin + est_out/1e6*pout:.4f}") + if a.dry_run: + return 0 + + key = os.environ.get(a.key_env) + if not key: + print(f"нет ключа {a.key_env}", file=sys.stderr) + return 2 + client = OpenAI(api_key=key, base_url=a.base_url) + a.raw_dir.mkdir(parents=True, exist_ok=True) + + results, ledger = [], [] + for n, (arm, sample, batch) in enumerate(plan, 1): + prompt = build_prompt(batch, arm, gloss, gloss_rand, ref) + t0 = time.time() + try: + resp = client.chat.completions.create( + model=a.model, + messages=[{"role": "user", "content": prompt}], + max_tokens=a.max_tokens, # thinking ⊆ completion, floor 8000 по квиркам + ) + except Exception as e: + print(f"[{n}/{len(plan)}] {arm}/{sample} ОШИБКА: {e}", file=sys.stderr) + ledger.append({"arm": arm, "sample": sample, "error": str(e)}) + continue + dt = time.time() - t0 + txt = resp.choices[0].message.content or "" + u = resp.usage + cin = getattr(u, "prompt_tokens", 0) + cout = getattr(u, "completion_tokens", 0) + cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0 + usd = (cin - cached) / 1e6 * pin + cached / 1e6 * prices.get(a.model, (0, 0, 0))[2] + cout / 1e6 * pout + (a.raw_dir / f"{arm}_{sample}_{n:03d}.json").write_text( + json.dumps({"arm": arm, "sample": sample, "prompt": prompt, "response": txt, + "finish": resp.choices[0].finish_reason, + "usage": {"in": cin, "out": cout, "cached": cached}}, + ensure_ascii=False, indent=1), encoding="utf-8") + parsed = parse_json_array(txt) + by_src = {str(x.get("src", "")): x for x in parsed if isinstance(x, dict)} + for it in batch: + got = by_src.get(it["src"]) + results.append({"id": it["id"], "sample": sample, "src": it["src"], "arm": arm, + "dst": (got or {}).get("dst"), "type": (got or {}).get("type"), + "why": (got or {}).get("why"), "gold": it.get("gold")}) + ledger.append({"arm": arm, "sample": sample, "in": cin, "out": cout, "cached": cached, + "usd": usd, "sec": round(dt, 1), "finish": resp.choices[0].finish_reason, + "parsed": len(parsed), "expected": len(batch)}) + print(f"[{n}/{len(plan)}] {arm}/{sample} in={cin} out={cout} ${usd:.5f} " + f"{dt:.0f}s parsed={len(parsed)}/{len(batch)} finish={resp.choices[0].finish_reason}") + + a.out.write_text(json.dumps({"results": results, "ledger": ledger}, + ensure_ascii=False, indent=1), encoding="utf-8") + total = sum(x.get("usd", 0) for x in ledger) + miss = sum(1 for r in results if not r["dst"]) + print(f"\nИТОГО ПОТРАЧЕНО: ${total:.5f} за {len(ledger)} вызовов; " + f"консолидаций {len(results)}, из них пустых {miss}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())