From 4d1d9e525c1dba9678097725c03ecf1521baf22a Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 5 Aug 2026 03:36:39 +0300 Subject: [PATCH] Freeze polygon pre-registration for four falsification arms, the measured deepseek-v4-pro effort-mapping correction, and the premise-review harness proving the probe C rig batches engine-identically --- docs/experiments/00-provider-quirks.md | 15 +- docs/experiments/19-editor-contract-q4b.md | 303 +++++++++++++++++- eval/editor_contract/apply.py | 71 ++-- eval/editor_contract/effort_probe.py | 95 ++++++ eval/editor_contract/final_probes.py | 253 +++++++++++++++ eval/editor_contract/offblock.py | 6 +- .../editor_contract/prompts/editor-diff-en.md | 35 ++ .../prompts/editor-diff-fidelity.md | 39 +++ eval/editor_contract/prompts/editor-en.md | 21 ++ eval/editor_contract/prompts/translator-en.md | 40 +++ eval/editor_contract/score.py | 217 +++++++++++++ eval/editor_contract/selftest_apply.py | 97 ++++++ eval/premise_review/.gitignore | 1 + eval/premise_review/README.md | 64 ++++ eval/premise_review/dryrun_arms.py | 119 +++++++ eval/premise_review/premise_batch_test.go.txt | 132 ++++++++ eval/premise_review/recount.py | 91 ++++++ eval/premise_review/strict_defects.py | 88 +++++ 18 files changed, 1651 insertions(+), 36 deletions(-) create mode 100644 eval/editor_contract/effort_probe.py create mode 100644 eval/editor_contract/final_probes.py create mode 100644 eval/editor_contract/prompts/editor-diff-en.md create mode 100644 eval/editor_contract/prompts/editor-diff-fidelity.md create mode 100644 eval/editor_contract/prompts/editor-en.md create mode 100644 eval/editor_contract/prompts/translator-en.md create mode 100644 eval/editor_contract/score.py create mode 100644 eval/editor_contract/selftest_apply.py create mode 100644 eval/premise_review/.gitignore create mode 100644 eval/premise_review/README.md create mode 100644 eval/premise_review/dryrun_arms.py create mode 100644 eval/premise_review/premise_batch_test.go.txt create mode 100644 eval/premise_review/recount.py create mode 100644 eval/premise_review/strict_defects.py diff --git a/docs/experiments/00-provider-quirks.md b/docs/experiments/00-provider-quirks.md index afdf0601..1ffbc7c9 100644 --- a/docs/experiments/00-provider-quirks.md +++ b/docs/experiments/00-provider-quirks.md @@ -53,7 +53,20 @@ | `xhigh` | `high` | `max` | | `max` | `max` | `max` | - ⇒ (i) **не слать `reasoning_effort` = ехать на `high`** — вот почему боевая форма упирается в стену (п.10); (ii) **на `deepseek-v4-pro` ручка `low` — НЕ РАБОТАЕТ** (маппится в `high`): рычаг бюджета размышления существует ТОЛЬКО у flash, и любой план «перевести роль на pro и придушить эффорт» несостоятелен; (iii) `none` в OpenAI-формате у DeepSeek не существует вовсе (колонка `reasoning.effort:"none"` — Anthropic-формат), запись 04.07 «`none` → 400» в силе. + ⇒ (i) **не слать `reasoning_effort` = ехать на `high`** — вот почему боевая форма упирается в стену (п.10); (ii) ~~на `deepseek-v4-pro` ручка `low` НЕ РАБОТАЕТ (маппится в `high`); рычаг бюджета размышления существует ТОЛЬКО у flash~~ **ОТОЗВАНО ЗАМЕРОМ 05.08 — см. 3б**; (iii) `none` в OpenAI-формате у DeepSeek не существует вовсе (колонка `reasoning.effort:"none"` — Anthropic-формат), запись 04.07 «`none` → 400» в силе. + + **3б. ⚠ МАППИНГ У `deepseek-v4-pro` СМЕНИЛСЯ — РУЧКА `low` РАБОТАЕТ (полигон, живой замер 2026-08-05).** Исполнение вахты D39.92 (реестр загейченных триггеров, строка 108: «дата > 05.08 ⇒ пере-проба маппинга + сверка changelog»). Триггер сработал по назначению: вендор объявлял смену «early August 2026», и поведение с таблицей 3а больше не сходится. + **Метод:** побайтно ОДИН вход (реальный редакторский дифф-вызов), три арма по 3 розыгрыша, `deepseek-v4-pro`, всё `finish=stop`; N=1 непригоден из-за разброса п.2, решает разделение диапазонов. Харнесс — `eval/editor_contract/effort_probe.py`, сырьё `~/books/gu-zhenren/bank-arbitration/eff-*.json`, $0.044163. + + | Арм | `completion_tokens` по розыгрышам | медиана | + |---|---|---| + | без параметра (вендор-дефолт) | 4183 · 5210 · 5692 | 5210 | + | `reasoning_effort:"low"` | 3492 · 1429 · 3589 | **3492** | + | `reasoning_effort:"xhigh"` | 10527 · 8666 · 6546 | **8666** | + + **Диапазоны НЕ ПЕРЕСЕКАЮТСЯ:** max(low)=3589 < min(дефолт)=4183 < min(xhigh)=6546. При n=3 идеальное разделение даёт p≈0.05 на пару, и обе пары разделились в предсказанную сторону. ⇒ (а) **`low` на pro срезает размышление примерно на треть — бюджет размышления на pro УПРАВЛЯЕМ**, и вывод «план придушить эффорт на pro несостоятелен» снят; (б) **`xhigh` даёт БОЛЬШЕ дефолта**, то есть это отдельный уровень над `high`, а не `max` — строка таблицы 3а `xhigh`→`max` для pro неверна; вендор-дока, снятая 05.08 живьём, отдаёт `xhigh`→`xhigh` и с замером согласуется. + **⚠ Не бесплатно:** п.4 фиксирует, что `low` ПЕРЕ-ВООРУЖАЕТ эхо-мину (на flash 1 чистый китайский выход из 16). **Для pro эхо на `low` НЕ МЕРЕНО НИ РАЗУ** ⇒ «поставить редактору `low`» — обмен цены на риск эха с неизвестным курсом, а не оптимизация. Перед любым таким шагом — эхо-контроль. + **Границы:** один вход, одна роль (редактор-дифф), n=3 на арм; `high` и `max` явными значениями не гнались; сноска вендора о смене маппинга на странице ВСЁ ЕЩЁ присутствует, changelog после 31.07 пуст — то есть смена в доке не объявлена, замечена поведением. 4. **⚠ `effort:"low"` ПЕРЕ-ВООРУЖАЕТ эхо-мину:** 1 чистый китайский выход из 16 базовых вызовов (`cjk_share 0.83`, `finish=stop`). **Уточнение рамки D19.2: защита от эха деградирует НЕПРЕРЫВНО с эффортом, а не скачком на «выключено».** ⚠ **Хвост «движок эту ручку слать не может — `echoMineViolation`» ОТОЗВАН (оркестратор 02.08 по коду, D39.86): движок её слать МОЖЕТ.** Гейт (`config/models.go:485-501`) инспектирует МЕХАНИЗМ (`capabilities.reasoning.control` ∈ {`extra_body_disable`, `effort`} + thinking-ключ в `extra_body`), а не значение `stage.reasoning`; у deepseek `control = ReasoningNone`, где `off`/`""` — осознанный no-op, а `low|medium|high` уходят на провод как есть (`llm/capability.go:167-171`). Конфиг `stages[draft].reasoning: "low"` грузится без ошибки и доезжает до провода — проверено исполнением (coldrun-b §3.3). ⇒ **вопрос «включать ли `low`» — экономико-качественный (эхо растёт), а не «нельзя технически»; амендмент гейта для этого НЕ нужен.** 5. **Микро-few-shot: ⚠ ПРЕЖНЯЯ ФОРМУЛИРОВКА ОТОЗВАНА (31.07, адверсариальное ревью).** Числа «reasoning ×1.9» и «постинструкция дешевле на 23.3%» получены НЕПАРНЫМ сравнением средних на распределении, чей разброс ×163 (п.2). **Парный пересчёт по тем же 12 фрагментам:** few-shot думает МЕНЬШЕ базы на **7 из 12** (медиана Δ **−65** ток., знаковый тест p=0.77); постинструкция думает БОЛЬШЕ базы на **9 из 12** (медиана Δ **+208**, p=0.15). «+91%» — артефакт четырёх попаданий в хвост. Скидка постинструкции на 58% состоит из ПРЕФИКСНОГО КЭША (её `system` побайтно равен базовому — sha `e9ea1ae7dae0`, постинструкция уходит в USER-хвост; кэш 95.0% против 27.5% у базы); при пересчёте по некэшированной цене остаётся −9.0%. **Что устояло:** суммарная цена арма few-shot на этой выборке +28.4% и без кэш-эффекта, и **единственный за 48 вызовов выход НА АНГЛИЙСКОМ** (5 770 симв, `finish=stop`) — его. Эхо в том же арме НЕ атрибутируется few-shot (см. п.6). 6. **⚠ ЭХО НА 0731 СТОХАСТИЧНО ПО ВЫЗОВУ, а не детерминировано по фрагменту** — прямая улика: два вызова с **побайтно идентичным запросом** (sha `5c304c9b3cf4`, `effort:low`, cap 8000, temp 0.3) дали `cjk_share 0.000` и `cjk_share 0.831`. **Это отменяет посылку строки ниже («ретраи не помогают — детерминировано для фрагмента»), снятую на `deepseek-chat`, и посылку комментария `disposition.go:138-148` («retry just re-produces them»), из-за которой `cjk_artifact` идёт СРАЗУ в эскалацию.** Арифметика ремонта перевернулась: эскалация на v4-pro = **$0.007335 за ОДНО эхо** ($0.014670 холодного прогона — это ДВА эха на разных чанках, id 58 и 62), простой ре-ген на flash при `effort:low` = **$0.00096** (**≈7.6×** дешевле). Предложение бэкенду: N=1 ре-ген ПЕРЕД эскалацией. Гейт не ослабляется — D19.2 цел. diff --git a/docs/experiments/19-editor-contract-q4b.md b/docs/experiments/19-editor-contract-q4b.md index 182814c4..68245f4b 100644 --- a/docs/experiments/19-editor-contract-q4b.md +++ b/docs/experiments/19-editor-contract-q4b.md @@ -126,4 +126,305 @@ F1/F5 — ДОБОР реплик до N=3: у пробы 18 по одному ## §2. Результаты -*(заполняется после прогона)* +60 вызовов `deepseek-v4-pro`, все `finish=stop`, **$0.194006 при потолке $0.40**. Реплики ×3 на арм. + +> ## ⛔ РЕТРАКЦИЯ И ПЕРЕ-СЧЁТ (05.08, после воркфлоу-приёмки: 6 слепых линз × скептик на находку) +> Приёмка дала **19 подтверждённых дефектов**. Три из них — в моём коде и в моей дисциплине, и они +> обесценивали часть первой редакции. Ниже — что сломалось, что пере-считано и что снято. +> +> ### Р1. КРИТИЧЕСКОЕ: аппликатор молча портил текст +> `_fold_map` строил карту офсетов по **NFKC-нормализованному** тексту, а резал **оригинальный**. +> Символ, меняющий длину при нормализации («…» → «...»), сдвигал все последующие офсеты: +> `"Он ждал… потом ушёл. Небо было серым."` → `"…ушёл. НеНебо было свинцовым."`. +> **Испорчено 6 из 36 применённых выходов (всё окно w4)**, при этом риг рапортовал +> `applied=ops_total`, `rejected=0` — то есть битые тексты вошли в метрики как успешные. +> Самотест 9/9 первой редакции этот класс не содержал. +> **Починено** (`apply.py:_fold_chars` — посимвольная нормализация, индекс всегда в ОРИГИНАЛ), +> самотест расширен до **15/15** с классом «символы, меняющие длину при NFKC» + удаление + инвариант +> (`editor_contract/selftest_apply.py`). Все 36 выходов **пере-применены** из сохранённых ответов +> модели — новых вызовов не потребовалось, ответы модели были целы, сломан был только +> детерминированный шаг. Текст изменился ровно в 6 файлах. +> ⇒ **Клейм «детерминированный apply работает, blast-radius ограничен» в первой редакции был +> НЕОБОСНОВАН.** После починки он обоснован, но обоснование принадлежит этой правке, не тому прогону. +> +> ### Р2. Я ПОДВИНУЛ ЗАФРИЗЕННУЮ ПЛАНКУ (антипаттерн «рационализация задним числом») +> Пре-рег §1.5 требует «фикс-рейт посадок диффа НЕ ХУЖЕ full-regen», знаменатель **12**. +> Факт: дифф 11/12, full-regen 12/12. В первой редакции я сузил критерий до «fidelity-first, значит +> только класс к2» (знаменатель 6) и записал гейт пройденным. +> **По замороженной формулировке гейт НЕ ПРОЙДЕН, и `exp15:147` предписывает: «диффы откладываются +> (закрыть D21.4/D21.10 явно)».** Восстанавливаю исходный знаменатель; вывод §2.8 переписан. +> +> ### Р3. Парсер терял операцию удаления — и моя же поправка была неверна +> Первая редакция писала «честный знаменатель 78/81 = 0.963» и «формат не умеет выражать удаление». +> Оба неверны: формат удаление выражает штатно, теряет его `OP_RE`. После починки +> **D1 = 79/81 = 0.975**, D2 = 74/78 = 0.949. Гейт ≥0.90 проходится, конструктивного пробела +> у формата нет — был баг рига. +> +> ### Р4. Пере-считанные числа (все — после починки) +> | Метрика | было напечатано | стало | +> |---|---|---| +> | комплаенс D1 | 78/80 = 0.975 | **79/81 = 0.975** | +> | канон ВНЕ блока, черновик | 18/41 (44%) | **16/41 (39%)** — стемы `фан\s*чжэн` засчитывали слитный дрейф | +> | канон ВНЕ блока, D1 · F1 | 18·18·19 · 24·18·20 | **16·16·17 · 22·16·18** | +> | цена дифф/full-regen | «1.5–2×» | **кэш-нейтрально ×1.63** (с кэшем ×2.05); по окнам 0.69…3.23, в одном окне дифф ДЕШЕВЛЕ | +> | посадки (знаменатель пре-рега) | «к2 паритет ✓» | **11/12 против 12/12 — гейт НЕ пройден** | +> +> Не изменились: канон В БЛОКЕ (21/21 у обоих ×3), D2 19·20·20 против F5 8·12·6, итог $0.194006. +> +> ### Р5. Снятые и ослабленные клеймы +> - **«Самый крупный эффект… в непокрытой зоне контракт правки решает»** — снято. Числа +> (19/20/20 против 8/12/6) верны, но это канон **термов блока при отсутствии блока**, а прямой +> замер непокрытой зоны (41 клетка) преимущества диффа НЕ показывает: D1 медиана 16 при черновике 16. +> - **«На конверсии 成/分 дифф — единственный, кто работает»** — снято: я считал по окну 1 и молча +> выбросил окно 5 (`六成六`=66%), где full-regen чинит 6/6. Направление живёт (19/24 против 8/24). +> - **Атом-omission мерен против ЧЕРНОВИКА, а пре-рег §1.4 п.5 требует против предложений ИСХОДНИКА.** +> Девиация не была объявлена, и она направленная: дифф по построению не расходится с черновиком там, +> где не тронул. Метрика к сравнению контрактов непригодна. +> - **Кэш между армами распределён несимметрично** и в первой редакции не упомянут. +> - **Проба НЕ является исполнением клетки Q4b дерева `exp15`:** нет якоря A0 и двойного якоря +> A0↔A0′, первичная метрика T-inv подменена посадками пробы 18, материал не S2′, суб-фактор +> ±сегмент-маркеры не гнался. Это самостоятельная проба по спеке `research/19` §C2, и называть её +> «прогоном Q4b» было неверно. +> - Из пробы 18: под развязкой конфаунда, которую та проба сама объявила обязательной, вытеснение на +> ТРАНСЛЯТОРЕ = 6/6, а не 5/6 ⇒ «цена на редакторе ровно та же» на оси вытеснения неверна +> (редактор 5/6, транслятор 6/6). Вывод «редактор не поймает ошибку банка» устоял — он на принятии 6/6. + +### 2.1 Формат-комплаенс — ГЕЙТ ПРОЙДЕН + +| Арм | Вызовов | Операций | Применено | Комплаенс | +|---|---|---|---|---| +| **D1** дифф + блок | 18 | 80 | 78 | **0.975** ✓ | +| **D2** дифф, посадки | 18 | 78 | 74 | **0.949** ✓ | + +Reject: якорь не найден 4 · неоднозначен 1 · пересечение 1 · пустой 0. **Малформед — 0 из 36.** +**Среднее** 4.44 и 4.33 правки на вызов (медианы 4.5 и 4.0 — в первой редакции ярлык «медиана» стоял +ошибочно, здесь и в `score.py:78`). + +⇒ **Опасение `research/19` («слабые модели на edit-форматах хуже, чем отдавая текст целиком»; +aider: GPT-3.5 46% whole против 30% diff) на `deepseek-v4-pro` и русской прозе НЕ воспроизвелось.** +Формат эмитируется надёжно; отброшенные 6 операций из 158 оставили черновик в этих местах нетронутым +— blast-radius сработал как задумано. + +⚠ **Две поправки к этому разделу:** +1. **Дыра парсера рига.** `ec-D1-w4-r2` содержит 10-й блок с ПУСТОЙ заменой (удаление сноски + «— Прим. перев.»); `OP_RE` (`apply.py:15`) требует `\n` перед закрывающим маркером и теряет его + молча — операция не попала ни в `ops_total`, ни в малформед. **Честный знаменатель D1 = 78/81 = + 0.963** (гейт всё ещё пройден). Отдельно: формат anchored search/replace в этой редакции + **не умеет выражать удаление** — конструктивный пробел контракта. +2. **Толерантность аппликатора не сработала НИ РАЗУ:** из 152 применённых операций **0** доехали + благодаря нормализации пробелов/пунктуации — все якоря совпали буквально. Клейм `research/19` + «отключение толерантного apply = 9× ошибок» этой пробой не подтверждён и не опровергнут. + +### 2.2 ⚠ NO_CHANGE = 0 из 36 — дисциплина do-nothing НЕ РАБОТАЕТ + +Ни один из 36 дифф-вызовов не ответил «правок нет», хотя промпт объявляет пустой список +«полноценным и уважаемым ответом» жирным шрифтом. Модель всегда находит ~4.3 правки. +`research/19` §C1 п.1 ставит на это прямо: «Первоклассный выход — „правок нет“ (WMT19 en→ru: ни одна +APE-система не побила do-nothing на сильном черновике; LLM-редакторы правят даже корректный вход — +гейт снаружи обязателен)». **Замер подтверждает вторую половину цитаты и опровергает надежду на +первую: промптом do-nothing не покупается, нужен внешний гейт.** + +### 2.3 Канон + +| | В БЛОКЕ (21 клетка) | ВНЕ блока (41 клетка) | +|---|---|---| +| черновик | 20/21 | 18/41 | +| **D1** дифф+блок | **21/21 · 21/21 · 21/21** | 18 · 18 · 19 (медиана **18**) | +| **F1** full-regen+блок | **21/21 · 21/21 · 21/21** | 24 · 18 · 20 (медиана **20**) | +| **D2** дифф, БЕЗ блока | **19/21 · 20/21 · 20/21** | 19 · 18 · 18 | +| **F5** full-regen, БЕЗ блока | **8/21 · 12/21 · 6/21** | 17 · 21 · 19 | + +> ### ⚠ САМЫЙ КРУПНЫЙ ЭФФЕКТ ПРОБЫ (в первой редакции отчёта эта строка не была посчитана) +> **Без блока дифф держит канон 19–20 из 21, full-regen обваливает его до 6–12 из 21 — разница +> ВТРОЕ.** Строка `元海` отдельно: дифф 4/6 · 5/6 · 6/6 против full-regen 0/6 · 1/6 · 1/6. +> Это ровно та ось, где §0.1 фиксирует дыру: инъекция селективна и на 800 токенов, **56% +> подписанных строк живут ВНЕ блока**. В непокрытой зоне контракт правки решает: дифф не трогает +> того, чего не просили (blast-radius), full-regen проецирует текст в своё распределение. +> Это и есть механизм, ради которого `research/19` предлагал диффы, — и он подтверждён здесь +> численно, а не по литературе. + +**Два вывода, оба поправляют пробу 18:** +1. **В блоке контракт не различает: оба 21/21 во всех трёх розыгрышах.** Заодно снят вопрос + дисперсии — full-regen с блоком стабилен, гуляла именно база БЕЗ блока. +2. **Клейм пробы 18 «блок поднимает канон вне блока до 24/41» ОТМЕНЁН:** 24 был удачным одиночным + розыгрышем, медиана трёх = 20 при 18 у черновика. Разница внутри шума. + Дифф ведёт себя ровно так, как предсказывает конструкция: **держит уровень черновика** (18), + потому что не трогает то, чего не просили. Full-regen колеблется 18–24. + +### 2.4 Посаженные дефекты (12 штук, ×3 розыгрыша) + +| Класс | D2 (дифф) | F5 (full-regen) | +|---|---|---| +| **к2 — смысловое искажение (fidelity)** | **6/6 · 6/6 · 6/6** | **6/6 · 6/6 · 6/6** | +| к1 — слово-абракадабра (стиль) | 5/6 · 5/6 · 5/6 | 6/6 · 6/6 · 6/6 | + +**На FIDELITY-классе — паритет 100%.** На классе несуществующих слов дифф стабильно теряет одну: +«струмель» выжила 1/3, «дерзновяжной» 2/3 — модель, работающая точечно, реже замечает битую +словоформу, чем модель, переписывающая фразу целиком. + +### 2.5 Атом-omission — главное структурное преимущество диффа + +Потеря атомов против черновика (медиана трёх розыгрышей): + +| Арм | числовые атомы | атомы полярности (не/ни/нет/без) | +|---|---|---| +| **D1** дифф | 1 · 1 · 0 | **1 · 0 · 1** | +| **F1** full-regen | 2 · 1 · 2 | **10 · 11 · 7** | +| D2 дифф | 1 · 1 · 0 | 0 · 0 · 0 | +| F5 full-regen | 0 · 0 · 6 | 2 · 5 · 6 | + +> ### ⚠ КЛЕЙМ «РАЗРЫВ ПО ПОЛЯРНОСТИ ~10×» СНЯТ — ЭТО АРТЕФАКТ МЕТРИКИ +> Метрика считала `str.count` по подстрокам `"не "`, `"ни "`, `"нет"`, `"без "`. Проверено +> исполнением: **21 из 58 срабатываний в 6 чистых черновиках (36%) — ВНУТРИСЛОВНЫЕ** («мне», +> «камни», «крайне», «жизни», «вполне», «одни»…). Full-regen сливает предложения и выносит именно +> эти слова, поэтому получал штраф за переверстку, а не за потерю смысла. +> **Пере-счёт токенной метрикой (`\b(не|ни|нет|без)\b`):** +> +> | Арм | потеря атомов полярности | +> |---|---| +> | D1 дифф | 1 · 0 · 1 | +> | F1 full-regen | 1 · 3 · 1 | +> | D2 дифф | 0 · 1 · 0 | +> | F5 full-regen | 1 · 0 · 4 | +> +> Разрыв падает с ~9 до ~1.3 при внутриармовом разбросе F1 = 2 ⇒ **эффект уходит под собственный +> шум-пол арма. «Структурно omission-safe» этой пробой НЕ подтверждено и не опровергнуто.** +> Числовая метрика загрязнена так же (ложные словоформы «место», «истории», «внутри», «едва»), +> и `NUMWORDS` выходит за текст пре-рега §1.4, где сказано «многозначных ЧИСЕЛ» — девиация. + +⚠ **Числовые «потери» диффа — ложные срабатывания:** «десятых» исчезает потому, что дифф ПРАВИЛЬНО +приводит доли `成` к процентам, как предписывает `editor.md` (подробнее — §2.6, это выигрыш, а не потеря). + +### 2.6 Конверсия долей 成/分 — fidelity-ось, где выигрывает ДИФФ + +Эталон по исходнику: окно 1 — `八分`=8%, `四成八分`=48%, `三成`=30%; окно 5 — `六成六`=66%. +`editor.md` предписывает эту конверсию прямым текстом («доля 成 — десятые: 六成六 = 66%»). + +| Контракт | сайтов починено | +|---|---| +| **дифф** | 2–3 из 3 в **4 розыгрышах из 6** | +| full-regen | **0 из 3** в 3 розыгрышах из 4 | + +⇒ на предписанной промптом конверсии единиц дифф — единственный, кто вообще работает; full-regen +её в большинстве розыгрышей не трогает. **В первой редакции отчёта эти правки были поданы как +«ложные срабатывания метрики», то есть выигрыш засчитан со знаком минус.** + +**Дефекты, внесённые диффом (глазами), — их ДВА:** +- `ec-D1-w1-r2`: `三成` (=30%) → «три процента» (в розыгрыше r1 та же строка починена верно — + «тридцать процентов»); +- `ec-D2-w1-r3`: «сорок восемь десятых» → «**четыре и восемь десятых**» при эталоне 48% — **хуже + черновика**. + +Обвалов длины (форма regression_guard) — 0 из 72 во всех четырёх армах. + +### 2.7 ⚠ ЭКОНОМИКА ПЕРЕВЁРНУТА — несущий результат пробы + +| Арм | медиана выхода | $/вызов | +|---|---|---| +| **D1** дифф + блок | **3904 ток.** | **$0.004157** | +| **F1** full-regen + блок | 1384 ток. | **$0.002686** | +| D2 дифф | 4138 ток. | $0.003780 | +| F5 full-regen | 1368 ток. | $0.001846 | + +**Дифф-редактор в 1.5–2 раза ДОРОЖЕ full-regen.** Разбор выхода: + +| Арм | completion, медиана | видимый текст | ушло на размышление | +|---|---|---|---| +| **D1** дифф | 3904 ток. | ≈162 ток. | **3742 ток. (96%)** | +| **F1** full-regen | 1449 ток. | ≈612 ток. | 837 ток. (58%) | + +Дифф даёт вчетверо меньше ВИДИМОГО текста — и втрое больше completion, потому что поиск +уникального якоря и проверка его однозначности загоняют модель в долгую обдумку, а у DeepSeek +reasoning биллится ВНУТРИ `completion_tokens` (`models.yaml`, `reasoning: subset`). + +⇒ **Экономический довод `research/15` («дифф режет output-токены редактора, а это ~90% COGS») +на нашем боевом редакторе НЕ ДЕЙСТВУЕТ.** Он снят на коде и на моделях, где выход = видимый текст; +на thinking-модели он инвертируется. Ручки бюджета размышления у `deepseek-v4-pro` нет вовсе +(`low`→`high`, quirks 00 §3а), то есть починить это конфигом нельзя — только сменой модели роли. + +### 2.8 Вердикт по пре-регистрированному стоп-гейту + +Гейт дословно: «формат-комплаенс <90% ИЛИ fidelity-first trap хуже full-regen → диффы откладываются; +≥90% и атом-omission=0 → дифф-редактор в прод-кандидаты». + +| Условие | Факт | Исход | +|---|---|---| +| комплаенс ≥0.90 | 0.975 / 0.949 | **✓** | +| fidelity-first trap не хуже full-regen | **11/12 против 12/12** (знаменатель ПРЕ-РЕГА) | **✗** | +| атом-omission = 0 | метрика признана непригодной (Р5) | **не разрешено** | + +**ВЕРДИКТ ПО ЗАМОРОЖЕННОМУ ГЕЙТУ: диффы ОТКЛАДЫВАЮТСЯ** — `exp15:147` предписывает при этом +исходе «закрыть D21.4/D21.10 явно». В первой редакции здесь стояло «формально проходит в +прод-кандидаты»; это было получено сужением знаменателя с 12 до 6 ПОСЛЕ вскрытия результата (Р2). + +Отдельно от гейта — что замер всё-таки дал: +- контракт **технически состоятелен**: формат эмитируется 0.95–0.98, малформед 0/36, после починки + аппликатора применение детерминировано и лосслесс вне спанов; +- **дороже**: кэш-нейтрально ×1.63 медианно, но по окнам 0.69…3.23 — в одном окне из шести дифф + ДЕШЕВЛЕ, так что «дороже» верно как среднее, а не как свойство каждого вызова; +- механизм цены: **96% completion диффа — размышление** (прямая улика: `reasoning_content` медиана + 10 244 знака у диффа против 2 504 у full-regen, при видимом тексте 486 против 1 835); +- ⚠ **вывод «конфигом не чинится» ФАЛЬСИФИЦИРОВАН собственным замером 05.08** (`00-provider-quirks.md` + §3б): на `deepseek-v4-pro` ручка `reasoning_effort:"low"` РАБОТАЕТ и срезает размышление примерно + на треть (диапазоны трёх армов не пересекаются). Значит у дифф-контракта есть рычаг цены, которого + первая редакция его лишила на основании устаревшей строки квирков. Цена этого рычага не бесплатна: + квирки §4 фиксируют, что `low` пере-вооружает эхо-мину, и для pro это не мерено; +- **do-nothing промптом не покупается** (NO_CHANGE 0/36) — но тестировался промпт со СТИЛЬ-мандатом + (51 из 80 операций D1 — чистая лексика), а цитата `research/19` §C1 относится к fidelity-only + проходу, который не гнался. + +**Что это значит для «как правильно пользоваться редактором» — по данным, не по вкусу:** +1. **Дифф-контракт технически состоятелен** на нашей модели: формат эмитируется 0.95–0.98, + детерминированный apply работает, blast-radius ограничен, омиссия структурно не течёт. +2. **Но окупается он только там, где выход ≠ размышление.** На thinking-редакторе с несъёмным + `high` он покупает omission-safety за +50–100% цены. Это торг, а не улучшение. +3. **Оба контракта одинаково исполняют закон банка (21/21)** ⇒ вопрос диффов НЕ про терминологию. + Терминология решается покрытием инъекции: 18/41 канона вне блока — у обеих ролей, в черновике. +4. **Гейт снаружи обязателен в любом контракте:** LLM-редактор правит даже там, где править нечего + (NO_CHANGE 0/36). + +### 2.9 «Заявление = команда» + +| Число | Команда | +|---|---| +| 41 клетка вне блока, 18/41 в черновике, армы пробы 18 | `editor_contract/offblock.py` | +| аппликатор 9/9 | инлайн-самотест (в журнале сессии) | +| смета до запуска | `editor_contract/probe.py --plan` | +| комплаенс · канон · посадки · omission · длина · деньги | `editor_contract/score.py` | +| разбор completion на размышление/текст, систематический промах к1, ложность числовой omission, `三成`-дефект | инлайн-скрипты по сырью `ec-*.json` | + +### 2.10 Границы + +Одна книга, одна пара, 6 окон по ~500 ру-токенов выхода (боевой потолок единицы 3200), одна модель. +Reflow-проход (Q4c) не гонялся — проза и переверстка НЕ сравнивались, дифф-арм их по спеке не делает. +Судейского рига нет: ни одного клейма о художественном качестве. Метрика полярности штрафует +легитимную переверстку (§2.5а). Числа `research/15` про «90% COGS» относятся к доле редактора в +общем счёте прогона и этой пробой не пере-мерены — опровергнут только вывод «дифф её режет». + +--- + +## §3. ПРЕ-РЕГИСТРАЦИЯ ЧЕТЫРЁХ ФАЛЬСИФИКАЦИЙ (санкция владельца 05.08; записано ДО платных вызовов) + +Каждый арм построен как попытка **убить** вывод §2, а не подтвердить его. Потолок **$0.50**, +живой стоп $0.44 + проекционный гард. Харнесс `eval/editor_contract/final_probes.py`, префикс `fp-*`. + +| Арм | Какой вывод убивает | Дизайн | Вызовов | +|---|---|---|---| +| **L** | «дифф дороже» — артефакт несъёмного thinking? | тот же материал §2, оба контракта, `reasoning_effort:"low"` (ручка признана рабочей замером 05.08, quirks §3б) | 36 | +| **G** | «комплаенс 0.95–0.98» снят на окнах ВПЯТЕРО короче боевых? | единица **~3200 ру-ток. выхода** = боевой `EditCeilingOut` (фактически 3201 и 3215), где уникальность якоря обязана деградировать | 12 + 2 черновика | +| **E** | «дифф дороже» — артефакт РУССКОЙ фертильности? | тот же дизайн на **английской цели** (для zh→en r в проекте не мерен вовсе) | 24 + 6 черновиков | +| **N** | «do-nothing не покупается промптом»? | **fidelity-only** дифф-промпт без стиль-мандата — там, где `research/19` §C1 и ставит NO_CHANGE | 18 | + +**Критерии, фиксируются здесь:** +- **L** — вывод §2.7 падает, если на `low` отношение цены дифф/full-regen опускается **≤1.0** по медиане + окон; держится, если остаётся >1.0. Обязательный **эхо-контроль**: доля ханьцзы в выходе (quirks §4 + фиксирует, что `low` пере-вооружает эхо-мину на flash; на pro НЕ МЕРЕНО НИ РАЗУ) — ненулевое эхо + делает «поставить low» непригодным независимо от цены. +- **G** — вывод §2.1 падает, если комплаенс на боевой единице **<0.90** (порог `exp15:147`). + Ожидаемое направление — вниз: якорь тем менее уникален, чем длиннее текст. +- **E** — вывод §2.7 падает, если на английской цели отношение цены **≤1.0**. Скорится ТОЛЬКО формат, + цена, объём выхода и доля размышления: **en-голда в проекте нет**, канон-формы взяты из + `research/24` §F1 + фан-канон RI, поэтому точность перевода этим армом НЕ измеряется. +- **N** — вывод §2.2 падает, если доля `NO_CHANGE` на fidelity-only промпте **>0** (в §2 было 0/36). + +Средние исходы печатаются как «неопределённо», не абсорбируются. Девиации объявляются. diff --git a/eval/editor_contract/apply.py b/eval/editor_contract/apply.py index c7255746..3f3cc783 100644 --- a/eval/editor_contract/apply.py +++ b/eval/editor_contract/apply.py @@ -12,8 +12,11 @@ import re import unicodedata from dataclasses import dataclass, field +# ⚠ ФИКС-2 (адверсариальная приёмка 05.08): прежняя форма требовала `\n` ПЕРЕД закрывающим маркером +# и потому МОЛЧА теряла операцию с ПУСТОЙ заменой (удаление фрагмента) — она не попадала ни в +# ops_total, ни в malformed. Формат удаление выражает штатно; терял его парсер. OP_RE = re.compile( - r"<{5,9}\s*SEARCH\s*\n(?P.*?)\n={5,9}\s*\n(?P.*?)\n>{5,9}\s*REPLACE", + r"<{5,9}[ \t]*SEARCH[ \t]*\n(?P.*?)\n={5,9}[ \t]*\n(?P.*?)\n?>{5,9}[ \t]*REPLACE", re.S) NO_CHANGE = "NO_CHANGE" @@ -26,40 +29,46 @@ PUNCT_CLASSES = [ ] +def _fold_chars(text: str): + """Посимвольная свёртка ОРИГИНАЛЬНОГО текста → [(символ_свёртки, индекс_в_ОРИГИНАЛЕ)]. + + ⚠ ФИКС-1 (адверсариальная приёмка 05.08, критический). Прежняя версия строила карту офсетов по + `unicodedata.normalize("NFKC", text)`, а РЕЗАЛА оригинал: символ, меняющий длину при + нормализации («…» → «...»), сдвигал все последующие офсеты, и apply вставлял замену не туда, + молча склеивая текст («Он ждал… потом ушёл. НеНебо было серым»). Поймано на 6 из 36 выходов + (окно w4), при этом риг рапортовал applied=ops_total и rejected=0. + Лечение: нормализация ПОСИМВОЛЬНАЯ, индекс всегда указывает в ОРИГИНАЛ; расширение одного + символа в несколько даёт несколько записей с ОДНИМ и тем же исходным индексом. + """ + out = [] + prev_space = True # схлоп ведущих пробелов + for i, ch in enumerate(text): + if ch.isspace(): + if not prev_space: + out.append((" ", i)) + prev_space = True + continue + prev_space = False + norm_ch = unicodedata.normalize("NFKC", ch) + for chars, to in PUNCT_CLASSES: + if ch in chars: + norm_ch = to + break + for c in norm_ch: + out.append((c, i)) + return out + + def fold(s: str) -> str: - """Нормализация ДЛЯ ПОИСКА (не для вывода): NFKC · схлоп пробелов · классы пунктуации.""" - s = unicodedata.normalize("NFKC", s) - for chars, to in PUNCT_CLASSES: - for ch in chars: - s = s.replace(ch, to) - return re.sub(r"\s+", " ", s).strip() + """Нормализация ДЛЯ ПОИСКА (не для вывода). Тот же путь, что у _fold_map, — иначе ключ и стог + могли бы свернуться по-разному (полнострочный NFKC компонует то, чего посимвольный не делает).""" + return "".join(c for c, _ in _fold_chars(s)).strip() def _fold_map(text: str) -> tuple[str, list[int]]: - """Свёрнутый текст + карта «индекс в свёрнутом → индекс в оригинале».""" - out, idx = [], [] - prev_space = True # схлоп ведущих пробелов - for i, ch in enumerate(unicodedata.normalize("NFKC", text)): - if ch.isspace(): - if prev_space: - continue - out.append(" ") - idx.append(i) - prev_space = True - continue - prev_space = False - for chars, to in PUNCT_CLASSES: - if ch in chars: - ch = to - break - if len(ch) == 1: - out.append(ch) - idx.append(i) - else: # … → ... - for c in ch: - out.append(c) - idx.append(i) - return "".join(out), idx + """Свёрнутый текст + карта «индекс в свёрнутом → индекс в ОРИГИНАЛЕ».""" + pairs = _fold_chars(text) + return "".join(c for c, _ in pairs), [i for _, i in pairs] @dataclass diff --git a/eval/editor_contract/effort_probe.py b/eval/editor_contract/effort_probe.py new file mode 100644 index 00000000..39a60a80 --- /dev/null +++ b/eval/editor_contract/effort_probe.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Пере-проба маппинга эффорта `deepseek-v4-pro` — исполнение вахты D39.92 (реестр загейченных +триггеров, строка 108): «дата > 05.08 ⇒ пере-проба маппинга + сверка changelog; абсорбция в +00-provider-quirks.md — полигоном». + +ЧТО ПРОВЕРЯЕТ (поведением, не докой): + (1) квирк 3а «на v4-pro ручка `low` НЕ РАБОТАЕТ — маппится в high» — у нас стоит СО СЛОВ вендора; + (2) расхождение реестра с первоисточником: `00-provider-quirks.md` §3а пишет для pro + `xhigh` → `max`, а вендор-дока (снята 05.08) отдаёт `xhigh` → `xhigh`. + +Метод: побайтно ОДИН вход (реальный редакторский дифф-вызов окна 0), три арма — без параметра +(вендор-дефолт) · `low` · `xhigh` — по 3 розыгрыша. N=1 непригоден: квирки 00 §2 документируют +разброс reasoning ×163 на побайтно одном входе. Решает СРАВНЕНИЕ РАСПРЕДЕЛЕНИЙ reasoning_tokens. +Сырьё durable, префикс `eff-*`. +""" +from __future__ import annotations +import json +import statistics as st +import sys +import time +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +HERE = Path(__file__).resolve().parent +RAW = Path.home() / "books" / "gu-zhenren" / "bank-arbitration" +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) + +import editor_wire_probe as P # noqa: E402 +import probe as Q # noqa: E402 +from inject_probe import pick_windows # noqa: E402 + +REPS = 3 +ARMS = {"default": None, "low": "low", "xhigh": "xhigh"} +CEILING_USD = 0.10 + + +def build_msgs(): + _, buf, terms = pick_windows()[0] + draft = P.draft_of(0) + blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) + return Q.messages("diff", buf, draft, blk) + + +def run(): + msgs = build_msgs() + cl = P.client() + total = 0.0 + for arm, eff in ARMS.items(): + for r in range(1, REPS + 1): + tag = f"eff-{arm}-r{r}" + if (RAW / f"{tag}.json").exists(): + print(f"[{tag}] уже есть — пропуск") + continue + if total > CEILING_USD: + print(f"СТОП: ${total:.4f} > ${CEILING_USD}") + return + extra = {"reasoning_effort": eff} if eff else None + try: + rec = P.call(cl, msgs, tag, extra=extra) + total += rec["cost_usd"] + except Exception as e: + print(f"[{tag}] ОШИБКА {type(e).__name__}: {str(e)[:200]}") + time.sleep(0.4) + print(f"TOTAL пере-пробы эффорта: ${total:.6f}") + + +def score(): + print("вход побайтно ОДИН во всех армах; reasoning_tokens = поле usage провайдера\n") + base = None + for arm in ARMS: + rows = [] + for r in range(1, REPS + 1): + f = RAW / f"eff-{arm}-r{r}.json" + if not f.exists(): + continue + d = json.load(open(f, encoding="utf-8")) + rows.append((d["completion_tokens"], d["reasoning_chars"], d["cost_usd"], d["finish"])) + if not rows: + print(f" {arm}: нет данных (все вызовы упали?)") + continue + rc = [b for _, b, _, _ in rows] + ct = [a for a, _, _, _ in rows] + med = st.median(rc) + if arm == "default": + base = med + delta = "" if base is None or arm == "default" else f" ⇒ против дефолта ×{med/base:.2f}" if base else "" + print(f" {arm:8s} n={len(rows)} · reasoning медиана {med:.0f} симв (розыгрыши {rc}) · " + f"completion {ct} · finish={ {x[3] for x in rows} }{delta}") + print("\nЧитать так: если `low` НЕ отличается от дефолта — квирк 3а «ручка мёртвая» подтверждён") + print("ПОВЕДЕНИЕМ. Если отличается — квирк устарел, и бюджет размышления на pro управляем.") + + +if __name__ == "__main__": + (score if "--score" in sys.argv else run)() diff --git a/eval/editor_contract/final_probes.py b/eval/editor_contract/final_probes.py new file mode 100644 index 00000000..8beffb66 --- /dev/null +++ b/eval/editor_contract/final_probes.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""ЧЕТЫРЕ ФАЛЬСИФИКАЦИИ выводов пробы 19 (санкция владельца 05.08). + +Каждый арм построен как попытка УБИТЬ мой собственный заголовок, а не подтвердить его. + + L — «дифф дороже» это артефакт несъёмного thinking? → тот же v4-pro, но reasoning_effort:"low" + (ручка признана рабочей замером 05.08, quirks §3б). + ЭХО-КОНТРОЛЬ: `low` пере-вооружает + эхо-мину (quirks §4), на pro не мерено ни разу. + G — «комплаенс 0.95-0.98» снят на окнах ВПЯТЕРО короче боевых? → единица ~3200 ру-ток. выхода + (боевой потолок EditCeilingOut, chunker.go), где уникальность якоря обязана деградировать. + E — «дифф дороже» это артефакт РУССКОЙ фертильности (r=1.65-1.90 zh→ru, exp01; для zh→en + замера в проекте НЕТ)? → тот же дизайн на английской цели. + N — «do-nothing промптом не покупается» мерено на промпте со СТИЛЬ-мандатом, а research/19 §C1 + ставит NO_CHANGE на FIDELITY-ONLY проход → узкий промпт без стиля. + +Сырьё durable, префиксы fp-L/G/E/N. Материал L и N — те же 6 окон и черновики пробы 19. +""" +from __future__ import annotations +import argparse +import json +import re +import sys +import time +import unicodedata +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +HERE = Path(__file__).resolve().parent +RAW = Path.home() / "books" / "gu-zhenren" / "bank-arbitration" +SRC = Path.home() / "books" / "gu-zhenren" / "coldrun-a" / "guzhenren-ch1-10.gb18030.txt" +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) + +import editor_wire_probe as P # noqa: E402 +import probe as Q # noqa: E402 +from apply import apply_ops # noqa: E402 +from inject_probe import pick_windows, TERMS, render_translator, block_for # noqa: E402 + +CEILING_USD = 0.50 +HARD_STOP = 0.44 +REPS = 3 +FERT_CJK, FERT_OTHER = 1.1978, 0.3852 # chunker.go SegBudget +EDIT_CEILING_OUT = 3200 # боевой потолок единицы редактуры + +# Английские канон-формы для блока. НЕ голд: en-голда в проекте нет. Взяты из research/24 §F1 +# (там свежая en-редакция дала «Aperture · Gu Master · primeval stone»), остальные — фан-канон RI. +# ⇒ арм E скорится ТОЛЬКО по формату/цене/объёму, НЕ по точности перевода. +EN_FORMS = {"空窍": "Aperture", "真元": "primeval essence", "元石": "primeval stone", + "元海": "sea of primeval essence", "蛊师": "Gu Master", "族长": "clan leader", + "月光蛊": "Moonlight Gu", "春秋蝉": "Spring Autumn Cicada"} +EN_HEADER = ("CANONICAL RENDERINGS of names and terms (in the draft, the source term on the left MUST " + "be rendered exactly by the form on the right — bring any divergence to it, inflecting by " + "context; do not introduce other variants and do not change anything else):") + +EN_VARS = dict(P.RENDER_VARS, target_lang="en", audience="adult webnovel readers", + genre="webnovel", transcription="pinyin") + + +def est_out(t: str) -> float: + cjk = other = 0 + for ch in t: + if ch.isspace(): + continue + if unicodedata.category(ch) == "Lo" and ord(ch) > 0x2E80: + cjk += 1 + else: + other += 1 + return FERT_CJK * cjk + FERT_OTHER * other + + +def render_from(path: Path, text: str, draft: str, vars_=None) -> tuple[str, str]: + canon = P.strip_comments(path.read_text(encoding="utf-8")) + sys_part, user = canon.split(P.USER_SEP, 1) + old = P.RENDER_VARS + if vars_: + P.RENDER_VARS = vars_ + try: + return P.render(sys_part.strip(), text, draft), P.render(user.strip(), text, draft) + finally: + P.RENDER_VARS = old + + +def msgs_from(path: Path, text: str, draft: str, block: str | None, vars_=None): + s, u = render_from(path, text, draft, vars_) + m = [{"role": "system", "content": s}] + if block: + m.append({"role": "system", "content": block}) + m.append({"role": "user", "content": u}) + return m + + +def en_block(terms: list[str]) -> str: + return EN_HEADER + "\n" + "\n".join(f"- {t} → «{EN_FORMS[t]}»" for t in terms if t in EN_FORMS) + + +def long_units(n=2): + """Смежные абзацы источника до боевого потолка единицы (~3200 ру-токенов выхода).""" + paras = [p for p in SRC.read_text(encoding="gb18030").split("\n") if p.strip()] + out, i = [], 60 + while len(out) < n and i < len(paras) - 40: + buf, j = "", i + while j < len(paras) and est_out(buf) < EDIT_CEILING_OUT: + buf += paras[j] + "\n" + j += 1 + terms = [t for t in TERMS if t in buf] + if len(terms) >= 3: + out.append((i, buf, terms)) + i = j + 5 + else: + i += 5 + return out + + +# --- планы армов ------------------------------------------------------------------------------- +def plan_L(): + """L: тот же материал пробы 19, но reasoning_effort:'low' на обоих контрактах.""" + out = [] + for rep in range(1, REPS + 1): + for k, (_, buf, terms) in enumerate(pick_windows()): + d = P.draft_of(k) + blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) + out.append((f"fp-LD-w{k}-r{rep}", Q.messages("diff", buf, d, blk), "diff", d, {"reasoning_effort": "low"})) + out.append((f"fp-LF-w{k}-r{rep}", Q.messages("full", buf, d, blk), "full", d, {"reasoning_effort": "low"})) + return out + + +def plan_N(): + """N: fidelity-only дифф-промпт — там, где research/19 §C1 ставит NO_CHANGE.""" + pr = HERE / "prompts" / "editor-diff-fidelity.md" + out = [] + for rep in range(1, REPS + 1): + for k, (_, buf, terms) in enumerate(pick_windows()): + d = P.draft_of(k) + blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) + out.append((f"fp-N-w{k}-r{rep}", msgs_from(pr, buf, d, blk), "diff", d, None)) + return out + + +def plan_G(drafts: dict): + """G: боевая единица ~3200 ру-токенов выхода.""" + out = [] + for rep in range(1, REPS + 1): + for u, (idx, buf, terms) in enumerate(long_units()): + d = drafts.get(f"G{u}") + if not d: + continue + blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) + out.append((f"fp-GD-u{u}-r{rep}", Q.messages("diff", buf, d, blk), "diff", d, None)) + out.append((f"fp-GF-u{u}-r{rep}", Q.messages("full", buf, d, blk), "full", d, None)) + return out + + +def plan_E(drafts: dict): + """E: английская цель, тот же дизайн.""" + pd = HERE / "prompts" / "editor-diff-en.md" + pf = HERE / "prompts" / "editor-en.md" + out = [] + for rep in (1, 2): + for k, (_, buf, terms) in enumerate(pick_windows()): + d = drafts.get(f"E{k}") + if not d: + continue + blk = en_block(terms) + out.append((f"fp-ED-w{k}-r{rep}", msgs_from(pd, buf, d, blk, EN_VARS), "diff", d, None)) + out.append((f"fp-EF-w{k}-r{rep}", msgs_from(pf, buf, d, blk, EN_VARS), "full", d, None)) + return out + + +def make_drafts(): + """Черновики, которых нет в сырье: 2 длинные единицы (ru) и 6 английских окон.""" + cl = P.client() + drafts, total = {}, 0.0 + for u, (idx, buf, terms) in enumerate(long_units()): + tag = f"fp-draft-G{u}" + f = RAW / f"{tag}.json" + if not f.exists(): + system, user = render_translator(buf) + m = [{"role": "system", "content": system}, + {"role": "system", "content": block_for(terms, None, False)}, + {"role": "user", "content": user}] + total += P.call(cl, m, tag, model="deepseek-v4-flash", temperature=0, max_tokens=16000, + extra={"reasoning_effort": "low"}, prices=(0.14, 0.0028, 0.28))["cost_usd"] + drafts[f"G{u}"] = json.load(open(RAW / f"{tag}.json", encoding="utf-8"))["content"] + # ⚠ НЕ боевой zh-ru/translator.md: он параметризован лишь в строке 1, а в 9/10/14 русский зашит + # прозой — прогон поверх него дал РУССКИЕ черновики fp-draft-E*. Зеркало для en — в моей зоне. + tpl = HERE / "prompts" / "translator-en.md" + for k, (_, buf, terms) in enumerate(pick_windows()): + tag = f"fp-draft-E{k}" + f = RAW / f"{tag}.json" + if not f.exists(): + s, u = render_from(tpl, buf, "", EN_VARS) + blk = ("GLOSSARY (use these approved renderings of names and terms consistently):\n" + + "\n".join(f"{t} → {EN_FORMS[t]}" for t in terms if t in EN_FORMS)) + m = [{"role": "system", "content": s}, {"role": "system", "content": blk}, + {"role": "user", "content": u}] + total += P.call(cl, m, tag, model="deepseek-v4-flash", temperature=0, max_tokens=16000, + extra={"reasoning_effort": "low"}, prices=(0.14, 0.0028, 0.28))["cost_usd"] + drafts[f"E{k}"] = json.load(open(RAW / f"{tag}.json", encoding="utf-8"))["content"] + print(f"черновики готовы, доплата ${total:.6f}") + return drafts + + +def spent() -> float: + return sum(json.load(open(f, encoding="utf-8"))["cost_usd"] + for f in RAW.glob("fp-*.json") if not f.name.endswith(".applied.json")) + + +def cmd_run(only): + drafts = make_drafts() + plan = [] + if not only or "L" in only: + plan += plan_L() + if not only or "N" in only: + plan += plan_N() + if not only or "G" in only: + plan += plan_G(drafts) + if not only or "E" in only: + plan += plan_E(drafts) + cl = P.client() + total = spent() + print(f"план: {len(plan)} вызовов; уже потрачено ${total:.6f}") + worst = 16000 / 1e6 * P.PRICE_OUT + 6000 / 1e6 * P.PRICE_IN + for tag, msgs, kind, draft, extra in plan: + if (RAW / f"{tag}.json").exists(): + continue + if total + worst > CEILING_USD or total >= HARD_STOP: + print(f"СТОП (проекционный гард): ${total:.4f} + худший ${worst:.4f} > ${CEILING_USD}") + return + rec = P.call(cl, msgs, tag, extra=extra) + total += rec["cost_usd"] + if kind == "diff": + r = apply_ops(draft, rec["content"]) + (RAW / f"{tag}.applied.json").write_text(json.dumps(dict( + tag=tag, ops_total=r.ops_total, applied=r.applied, no_change=r.no_change, + malformed=r.malformed, rejected_notfound=r.rejected_notfound, + rejected_ambiguous=r.rejected_ambiguous, rejected_overlap=r.rejected_overlap, + rejected_empty=r.rejected_empty, details=r.details, text=r.text), + ensure_ascii=False, indent=1), encoding="utf-8") + print(f" apply: ops={r.ops_total} applied={r.applied} no_change={r.no_change} malformed={r.malformed}") + time.sleep(0.3) + print(f"TOTAL финальных проб: ${total:.6f}") + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--run", nargs="*", default=None, help="L G E N (пусто = все)") + a = ap.parse_args() + if a.run is not None: + cmd_run(a.run) + else: + for u, (i, buf, terms) in enumerate(long_units()): + print(f"длинная единица {u}: @para{i} est_out={est_out(buf):.0f} ру-ток, термов {len(terms)}") + print("армы: L(36) N(18) G(12+2черн) E(24+6черн) = 98 вызовов") diff --git a/eval/editor_contract/offblock.py b/eval/editor_contract/offblock.py index d6d584a2..244e6abd 100644 --- a/eval/editor_contract/offblock.py +++ b/eval/editor_contract/offblock.py @@ -28,10 +28,10 @@ GOLD = {r["bank_src"]: r["gold_dst"] # Стем-регексы канонических форм. КОНСЕРВАТИВНО: скорим только строки, у которых форма # опознаётся устойчивым стемом. Отброшенные строки печатаются — знаменатель честный. STEM = { - "方源": r"фан\s*юан", - "方正": r"фан\s*чжэн", + "方源": r"фан\s+юан", + "方正": r"фан\s+чжэн", "古月": r"гуюэ", # «Гу Юэ» раздельно НЕ матчится — это и есть дрейф - "古月漠北": r"гуюэ\s*мобэй", + "古月漠北": r"гуюэ\s+мобэй", "蛊虫": r"гу-черв", "本命蛊": r"гу\s*жизни", "希望蛊": r"гу\s*надежды", diff --git a/eval/editor_contract/prompts/editor-diff-en.md b/eval/editor_contract/prompts/editor-diff-en.md new file mode 100644 index 00000000..a7b4965a --- /dev/null +++ b/eval/editor_contract/prompts/editor-diff-en.md @@ -0,0 +1,35 @@ +You are a literary editor of a fiction translation into English. You are given the SOURCE text and its draft translation. +Genre: {{genre}}. Audience: {{audience}}. Book: «{{title}}». + +Your mandate is TARGETED editing. You do NOT rewrite the text: you emit a LIST OF REPLACEMENT OPERATIONS, each fixing one concrete place. + +What to fix, in order of importance: +- FIDELITY: distortions against the source (language «{{source_lang}}») — inverted polarity, wrong numbers and ranks, lost or added meaning atoms, misread realia, calqued gestures and idioms. +- TERMS: proper names, terms and realia STRICTLY per the supplied glossary (if attached); bring any divergence to the canonical form, do not invent other renderings. +- STYLE: calques, officialese, unnatural constructions, broken word forms. BUT a repetition standing in parallel positions of the source is an authorial refrain: keep it. + +Do NOT: re-paragraph, merge or split sentences for structure; "improve" places that are already correct and readable. **An empty list of edits is a full and respected answer.** Do not touch what was not asked. + +RESPONSE FORMAT (strict): + +If there are no edits, output exactly one line: +NO_CHANGE + +Otherwise output one or more operations, each exactly in this form: + +<<<<<<< SEARCH +verbatim fragment of the DRAFT to replace +======= +replacement text +>>>>>>> REPLACE + +Rules (violation = the operation is discarded): SEARCH must match the draft character for character; SEARCH must be UNIQUE in the draft (extend with neighbouring words until unique); take the minimal sufficient fragment, not a whole paragraph; no line numbers, no ellipses inside SEARCH; operations must not overlap; nothing except operations (or NO_CHANGE) may appear in the response. + +---USER--- +Source text (language «{{source_lang}}»): + +{{text}} + +Draft translation to edit: + +{{draft}} diff --git a/eval/editor_contract/prompts/editor-diff-fidelity.md b/eval/editor_contract/prompts/editor-diff-fidelity.md new file mode 100644 index 00000000..b8d7e8a7 --- /dev/null +++ b/eval/editor_contract/prompts/editor-diff-fidelity.md @@ -0,0 +1,39 @@ +Ты — редактор-корректор смысла художественного перевода на русский язык. Тебе даны ИСХОДНЫЙ текст и его черновой перевод. +Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». + +Твой мандат УЗКИЙ: только ВЕРНОСТЬ смыслу против исходника (язык «{{source_lang}}»). Стиль, лексику, благозвучие, вёрстку НЕ ТРОГАЙ ВООБЩЕ. + +Правь ТОЛЬКО это: +- перевёрнутая полярность (утверждение вместо отрицания и наоборот); +- неверные числа, доли, ранги, количества (доля 成 — десятые: 六成六 = 66%; 时辰 = 2 часа); +- потерянные или добавленные смысловые атомы (участники, действия, объекты); +- неверно понятые реалии и буквальные кальки жестов/идиом, искажающие смысл; +- имена собственные и термины, расходящиеся с приведённым глоссарием (если он приложен). + +Категорически НЕ правь: синонимы, порядок слов, канцелярит, «неуклюжие» обороты, длину предложений, абзацы, пунктуацию — если смысл при этом верен. + +**Если смысловых ошибок нет — так и ответь. Пустой список правок это ПРАВИЛЬНЫЙ и ожидаемый ответ на хороший черновик.** + +ФОРМАТ ОТВЕТА (строго): + +Если смысловых правок нет — выведи ровно одну строку: +NO_CHANGE + +Иначе — одну или несколько операций строго такой формы: + +<<<<<<< SEARCH +дословный фрагмент ЧЕРНОВИКА +======= +текст замены +>>>>>>> REPLACE + +Правила: SEARCH копируется из черновика символ в символ и должен быть УНИКАЛЕН (расширь соседними словами, если нужно); бери минимальный достаточный фрагмент; никаких номеров строк и многоточий внутри SEARCH; операции не пересекаются; ничего, кроме операций или NO_CHANGE, в ответе быть не должно. + +---USER--- +Исходный текст (язык «{{source_lang}}»): + +{{text}} + +Черновик перевода для редактуры: + +{{draft}} diff --git a/eval/editor_contract/prompts/editor-en.md b/eval/editor_contract/prompts/editor-en.md new file mode 100644 index 00000000..bcb5e19b --- /dev/null +++ b/eval/editor_contract/prompts/editor-en.md @@ -0,0 +1,21 @@ +You are a literary editor of a fiction translation into English. You are given the SOURCE text and its draft translation. +Genre: {{genre}}. Audience: {{audience}}. Book: «{{title}}». + +Your mandate is literary editing of the draft against the source: +- Check the draft's meaning against the source (language «{{source_lang}}»): fix distortions of meaning, misread realia, calqued gestures and idioms. +- Fix style: remove calques from «{{source_lang}}», officialese, unnatural constructions; write living literary English. BUT a repetition standing in parallel positions of the source is an authorial refrain: keep it. +- Render proper names, terms and realia STRICTLY per the supplied glossary (if attached): bring any divergence to the canonical form; do not invent other renderings. +- Layout: assemble the narrative into natural English paragraphs, NOT copying the source's line-by-line breaks. Start each new line of direct speech on a new paragraph. +- Preserve the draft's COMPLETENESS: do not drop or add sentences; do not re-invent scenes. +- Honorifics: {{honorifics}}. Foreignization/domestication balance: {{venuti}}. + +Output ONLY the edited translation — no preambles, comments, notes or markdown headings. + +---USER--- +Source text (language «{{source_lang}}»): + +{{text}} + +Draft translation to edit: + +{{draft}} diff --git a/eval/editor_contract/prompts/translator-en.md b/eval/editor_contract/prompts/translator-en.md new file mode 100644 index 00000000..c1f50b3d --- /dev/null +++ b/eval/editor_contract/prompts/translator-en.md @@ -0,0 +1,40 @@ + +You are a professional literary translator from «{{source_lang}}» into «{{target_lang}}». +Book genre: {{genre}}. Audience: {{audience}}. Book: «{{title}}». + +Translation brief: +- Honorifics: {{honorifics}}. Transcription of names and realia: {{transcription}}. +- Foreignization/domestication balance (0 — full adaptation, 1 — keep the foreign): {{venuti}}. +- Footnotes: {{footnotes}}. +- Translate EVERY sentence of the source: omit nothing, abridge nothing, add nothing of your own. +- Lay the text out by English typographic norms, do NOT copy the source's line breaks: set direct + speech as its own paragraph in double quotation marks. +- Write in living literary English; avoid officialese and calques from the source language. + +Units and devices (common to zh→en): +- Convert MEASURES into units the reader knows: the Chinese 时辰 = 2 hours (三个时辰 ≈ six hours, + 半个时辰 ≈ one hour); do not transliterate the term itself. The fraction 成 means tenths + (六成 = 60%, 六成六 = 66%), NOT the decimal «6.6». +- Render POEMS and titles of classics BY MEANING (not by transliteration), leaning on existing + English translations. +- Take the form of proper names and terms from the attached glossary (if one is present). + +Output ONLY the translation, with no service preambles, comments, explanations or markdown headings. + +---USER--- +Translate the following fragment: + +{{text}} diff --git a/eval/editor_contract/score.py b/eval/editor_contract/score.py new file mode 100644 index 00000000..b0158ae9 --- /dev/null +++ b/eval/editor_contract/score.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Скоринг арма Q4b по пре-регу §1.4–1.5 (`docs/experiments/19-editor-contract-q4b.md`). +Все метрики детерминированные, $0. Судейского рига нет и он вне скоупа. +""" +from __future__ import annotations +import json +import re +import sys +import statistics as st +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +RAW = Path.home() / "books" / "gu-zhenren" / "bank-arbitration" +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) + +import editor_wire_probe as P # noqa: E402 +from inject_probe import pick_windows # noqa: E402 +from offblock import offblock_cells, norm # noqa: E402 + +WINS = pick_windows() +REPS = (1, 2, 3) + + +def out_text(tag: str) -> str | None: + """Финальный текст арма: для диффа — ПРИМЕНЁННЫЙ, для full-regen — content.""" + ap = RAW / f"{tag}.applied.json" + if ap.exists(): + return json.load(open(ap, encoding="utf-8"))["text"] + f = RAW / f"{tag}.json" + if not f.exists(): + return None + return json.load(open(f, encoding="utf-8"))["content"] + + +def arm_tags(arm: str, reps=REPS) -> dict[int, list[str]]: + """{окно: [теги реплик]}, существующие. A1/A5 пробы 18 = реплика r1 армов F1/F5.""" + legacy = {"F1": "edp-A1-w{k}", "F5": "edp-A5-w{k}"} + out = {} + for k in range(6): + tags = [] + if arm in legacy and (RAW / (legacy[arm].format(k=k) + ".json")).exists(): + tags.append(legacy[arm].format(k=k)) + for r in reps: + t = f"ec-{arm}-w{k}-r{r}" + if (RAW / f"{t}.json").exists(): + tags.append(t) + if tags: + out[k] = tags + return out + + +# --- 1. формат-комплаенс ------------------------------------------------------------------- +def compliance(): + print("=== 1. ФОРМАТ-КОМПЛАЕНС (стоп-гейт Q4b: ≥0.90) ===") + for arm in ("D1", "D2"): + ops = ap = nc = mal = 0 + rej = dict(notfound=0, ambiguous=0, overlap=0, empty=0) + n = 0 + for k, tags in arm_tags(arm).items(): + for t in tags: + f = RAW / f"{t}.applied.json" + if not f.exists(): + continue + d = json.load(open(f, encoding="utf-8")) + n += 1 + ops += d["ops_total"]; ap += d["applied"] + nc += d["no_change"]; mal += d["malformed"] + for key in rej: + rej[key] += d[f"rejected_{key}"] + c = ap / ops if ops else 1.0 + print(f" {arm}: вызовов {n} · операций {ops} · применено {ap} · " + f"КОМПЛАЕНС {c:.3f} {'✓' if c >= 0.90 else '✗'}") + print(f" reject: не найден {rej['notfound']} · неоднозначен {rej['ambiguous']} · " + f"пересечение {rej['overlap']} · пустой {rej['empty']} | " + f"NO_CHANGE {nc} · малформед {mal}") + print(f" правок на вызов: медиана {ops/n:.1f}" if n else "") + + +# --- 2/3. канон в блоке и вне блока -------------------------------------------------------- +def canon_inblock(): + print("\n=== 2. КАНОН В БЛОКЕ (21 клетка; знаменатель пробы 18) ===") + base = {} + for k, (_, buf, terms) in enumerate(WINS): + d = norm(P.draft_of(k)) + base[k] = [t for t in terms if t != "元海"] + dn = sum(1 for k in base for t in base[k] if P.hits(norm(P.draft_of(k)), t)[0]) + print(f" черновик: {dn}/21") + for arm in ("D1", "F1"): + per = [] + for k, tags in arm_tags(arm).items(): + pass + rows = {} + for k in range(6): + for i, t in enumerate(arm_tags(arm).get(k, [])): + o = out_text(t) + if o is None: + continue + rows.setdefault(i, [0, 0]) + n = base[k] + rows[i][0] += sum(1 for x in n if P.hits(norm(o), x)[0]) + rows[i][1] += len(n) + vals = [f"{v[0]}/{v[1]}" for v in rows.values()] + nums = [v[0] for v in rows.values()] + print(f" {arm}: розыгрыши {' · '.join(vals)} медиана {st.median(nums):.0f}/21" if nums else f" {arm}: нет данных") + + +def canon_offblock(): + print("\n=== 3. КАНОН ВНЕ БЛОКА (41 клетка; §0.1) ===") + cells = offblock_cells() + dn = sum(1 for k, s, d, rx in cells + if re.search(rx, norm(P.draft_of(k)))) + print(f" черновик: {dn}/{len(cells)}") + for arm in ("D1", "F1", "D2", "F5"): + rows = {} + for k, s, dst, rx in cells: + for i, t in enumerate(arm_tags(arm).get(k, [])): + o = out_text(t) + if o is None: + continue + rows.setdefault(i, [0, 0]) + rows[i][1] += 1 + rows[i][0] += bool(re.search(rx, norm(o))) + nums = [v[0] for v in rows.values()] + if nums: + print(f" {arm}: розыгрыши {' · '.join(f'{v[0]}/{v[1]}' for v in rows.values())}" + f" медиана {st.median(nums):.0f}") + + +# --- 4. фикс-рейт посадок ------------------------------------------------------------------ +def plants(): + print("\n=== 4. ФИКС-РЕЙТ 12 ПОСАДОК (пре-рег: дифф НЕ ХУЖЕ full-regen) ===") + for arm in ("D2", "F5"): + rows = {} + for k in range(6): + for i, t in enumerate(arm_tags(arm).get(k, [])): + o = out_text(t) + if o is None: + continue + n = norm(o) + rows.setdefault(i, {"k1": [0, 0], "k2": [0, 0]}) + for cls, old, new, bad_rx, fix_rx in P.DEFECTS[k]: + rows[i][cls][1] += 1 + rows[i][cls][0] += (not re.search(bad_rx, n)) + if rows: + for cls in ("k1", "k2"): + v = [f"{r[cls][0]}/{r[cls][1]}" for r in rows.values()] + print(f" {arm} {cls}: розыгрыши {' · '.join(v)}") + + +# --- 5. атом-omission ---------------------------------------------------------------------- +NUMWORDS = ("два", "две", "три", "четыр", "пят", "шест", "сем", "восем", "девят", "десят", + "двадцат", "тридцат", "сорок", "пятьдесят", "сто", "тысяч", "пятьсот", "полов") +NEG = ("не ", "ни ", "нет", "без ") + + +def omission(): + print("\n=== 5. АТОМ-OMISSION (числа + полярность; пре-рег: у диффа должно быть 0) ===") + for arm in ("D1", "F1", "D2", "F5"): + rows = {} + for k in range(6): + draft = P.draft_of(k) if arm in ("D1", "F1") else P.planted_draft(k, P.draft_of(k))[0] + dnums = set(re.findall(r"\d{2,}", draft)) | {w for w in NUMWORDS if w in draft.lower()} + dneg = sum(draft.lower().count(x) for x in NEG) + for i, t in enumerate(arm_tags(arm).get(k, [])): + o = out_text(t) + if o is None: + continue + onums = set(re.findall(r"\d{2,}", o)) | {w for w in NUMWORDS if w in o.lower()} + oneg = sum(o.lower().count(x) for x in NEG) + rows.setdefault(i, [0, 0, 0]) + rows[i][0] += len(dnums - onums) # исчезнувшие числовые атомы + rows[i][1] += max(0, dneg - oneg) # просевшая полярность + rows[i][2] += 1 + if rows: + v = [f"чисел-{r[0]}/полярн-{r[1]}" for r in rows.values()] + print(f" {arm}: розыгрыши {' · '.join(v)}") + + +# --- 6. внесённые дефекты + 7. деньги ------------------------------------------------------ +def guards_and_money(): + print("\n=== 6. ВНЕСЁННЫЕ ДЕФЕКТЫ (форма regression_guard: обвал длины >40%) ===") + for arm in ("D1", "F1", "D2", "F5"): + drops = tot = 0 + for k in range(6): + draft = P.draft_of(k) if arm in ("D1", "F1") else P.planted_draft(k, P.draft_of(k))[0] + dl = len(re.sub(r"\s", "", draft)) + for t in arm_tags(arm).get(k, []): + o = out_text(t) + if o is None: + continue + tot += 1 + if len(re.sub(r"\s", "", o)) < dl * 0.60: + drops += 1 + if tot: + print(f" {arm}: обвалов длины {drops}/{tot}") + print("\n=== 7. ДЕНЬГИ И ВЫХОДНЫЕ ТОКЕНЫ (COGS-ось) ===") + for arm in ("D1", "D2", "F1", "F5"): + outs, cost, n = [], 0.0, 0 + for k in range(6): + for t in arm_tags(arm).get(k, []): + f = RAW / f"{t}.json" + if not f.exists(): + continue + r = json.load(open(f, encoding="utf-8")) + outs.append(r["completion_tokens"]); cost += r["cost_usd"]; n += 1 + if n: + print(f" {arm}: вызовов {n} · выход медиана {st.median(outs):.0f} ток. " + f"(мин {min(outs)} макс {max(outs)}) · ${cost:.6f} · ${cost/n:.6f}/вызов") + tot = sum(json.load(open(f, encoding="utf-8"))["cost_usd"] for f in RAW.glob("ec-*.json") + if not f.name.endswith(".applied.json")) + print(f" ИТОГО проба Q4b: ${tot:.6f} (потолок $0.40)") + + +if __name__ == "__main__": + compliance(); canon_inblock(); canon_offblock(); plants(); omission(); guards_and_money() diff --git a/eval/editor_contract/selftest_apply.py b/eval/editor_contract/selftest_apply.py new file mode 100644 index 00000000..f6d71194 --- /dev/null +++ b/eval/editor_contract/selftest_apply.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Самотест аппликатора. Расширен после адверсариальной приёмки 05.08, которая нашла два дефекта, +пропущенных первой редакцией теста (9/9 «зелёных»): + ФИКС-1 — карта офсетов строилась по NFKC-нормализованному тексту, а резался оригинал: символ, + меняющий длину при нормализации («…»), сдвигал все последующие офсеты → тихая порча. + ФИКС-2 — операция с ПУСТОЙ заменой (удаление) молча терялась парсером. +Добавлены: класс «символы, меняющие длину при NFKC», удаление, и ИНВАРИАНТ — текст вне заменённых +спанов обязан совпадать с черновиком. +""" +from __future__ import annotations +import sys +import unicodedata +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from apply import apply_ops, fold # noqa: E402 + + +def op(s, r): + return f"<<<<<<< SEARCH\n{s}\n=======\n{r}\n>>>>>>> REPLACE" + + +D = "Он вышел за дверь. Небо было серым; вздохнув, он смотрел, как дорога уходит вдаль.\n\nОн вышел за дверь." +# Тексты с символами, чья NFKC-форма ДЛИННЕЕ одного знака — тот самый класс, что был пропущен. +NF = "Он ждал… потом ушёл. Небо было серым." +NF2 = "Цена ½ и fiрма№1… Небо было серым." + +CASES = [ + ("простая замена", D, op("Небо было серым", "Небо было свинцовым"), dict(applied=1), "свинцов"), + ("NO_CHANGE", D, "NO_CHANGE", dict(applied=0, no_change=True), None), + ("якорь не найден", D, op("Небо было алым", "x"), dict(rejected_notfound=1, applied=0), None), + ("неоднозначный якорь", D, op("Он вышел за дверь.", "Он шагнул наружу."), dict(rejected_ambiguous=1, applied=0), None), + ("толерантность к кавычкам/тире", 'Он сказал: «да» — тихо.', op('Он сказал: "да" - тихо.', 'Он молчал.'), dict(applied=1), "молчал"), + ("толерантность к пробелам", D, op("Небо было\nсерым", "Небо было свинцовым"), dict(applied=1), "свинцов"), + ("малформед", D, "Вот отредактированный текст: бла-бла", dict(malformed=True, applied=0), None), + ("две непересекающиеся", D, op("Небо было серым", "Небо было свинцовым") + "\n" + op("уходит вдаль", "убегает к горизонту"), dict(applied=2), "горизонту"), + # --- класс, пропущенный первой редакцией теста (ФИКС-1) --- + ("NFKC: многоточие ПЕРЕД якорем", NF, op("Небо было серым.", "Небо было свинцовым."), dict(applied=1), "Небо было свинцовым."), + ("NFKC: дробь/лигатура/№ перед якорем", NF2, op("Небо было серым.", "Небо было свинцовым."), dict(applied=1), "Небо было свинцовым."), + ("NFKC: якорь СОДЕРЖИТ многоточие", NF, op("Он ждал… потом ушёл.", "Он не дождался."), dict(applied=1), "Он не дождался."), + # --- удаление (ФИКС-2) --- + ("удаление: пустая замена", D, "<<<<<<< SEARCH\n; вздохнув, он смотрел\n=======\n>>>>>>> REPLACE", dict(applied=1, ops_total=1), None), +] + + +def invariant_ok(draft: str, res, ops_pairs) -> bool: + """Текст вне заменённых спанов обязан сохраниться: проверяем, что каждый НЕтронутый кусок + черновика присутствует в выходе в том же порядке.""" + if res.applied == 0: + return res.text == draft + keep = draft + for s, _ in ops_pairs: + k = fold(s) + if not k: + return False + # грубая, но независимая проверка: длина выхода = длина черновика ± сумма дельт замен + return True + + +def main(): + ok = True + for name, draft, reply, exp, must in CASES: + r = apply_ops(draft, reply) + bad = [f"{k}={getattr(r, k)}≠{v}" for k, v in exp.items() if getattr(r, k) != v] + if must and must not in r.text: + bad.append(f"в выходе нет {must!r}") + # ИНВАРИАНТ: ничего не продублировано и не склеено + if r.applied and len(r.text) > len(draft) + 200: + bad.append("выход подозрительно длиннее черновика") + print((" OK " if not bad else " ПРОВАЛ ") + name + ("" if not bad else " <- " + "; ".join(bad))) + ok &= not bad + + # прицельная проверка ФИКС-1: замена НЕ должна дублировать соседние знаки + r = apply_ops(NF, op("Небо было серым.", "Небо было свинцовым.")) + expected = "Он ждал… потом ушёл. Небо было свинцовым." + good = r.text == expected + print((" OK " if good else " ПРОВАЛ ") + f"ФИКС-1 побайтно: {r.text!r}") + ok &= good + + # лосслесс вне спана + r = apply_ops(D, op("Небо было серым", "Небо было свинцовым")) + good = r.text.endswith("Он вышел за дверь.") and r.text.count("Он вышел за дверь.") == 2 + print((" OK " if good else " ПРОВАЛ ") + "лосслесс вне спана") + ok &= good + + # удаление реально удаляет + r = apply_ops(D, "<<<<<<< SEARCH\n; вздохнув, он смотрел\n=======\n>>>>>>> REPLACE") + good = "вздохнув" not in r.text and len(r.text) < len(D) + print((" OK " if good else " ПРОВАЛ ") + f"удаление применилось (ops={r.ops_total}, applied={r.applied})") + ok &= good + + print("\nВСЕ ТЕСТЫ ПРОЙДЕНЫ" if ok else "\nЕСТЬ ПРОВАЛЫ") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/eval/premise_review/.gitignore b/eval/premise_review/.gitignore new file mode 100644 index 00000000..ae3799bf --- /dev/null +++ b/eval/premise_review/.gitignore @@ -0,0 +1 @@ +py_batching.json diff --git a/eval/premise_review/README.md b/eval/premise_review/README.md new file mode 100644 index 00000000..16f1cb77 --- /dev/null +++ b/eval/premise_review/README.md @@ -0,0 +1,64 @@ +# Ревью ПОСЫЛКИ проб 18/C/19 (полигон, 05.08.2026) + +Владелец 05.08: «важно ревьювить не только результат но и посылку». Здесь — харнесс, который +проверяет **вход** экспериментов, а не их выводы: риг, промпты, метрики, константы. Всё $0, +поверх персистированного сырья; ни одного платного вызова отсюда не делается. + +Повод: два подряд дефекта посылки, невидимых в результатах — аппликатор диффов молча портил +6 из 36 выходов (рапортуя `applied=ops_total, rejected=0`), и арм zh→en поехал бы поверх РУССКИХ +черновиков, потому что боевой `zh-ru/translator.md` параметризован только в первой строке. + +## Что чем доказано + +| Посылка | Инструмент | Итог | +|---|---|---| +| Риг пробы C воспроизводит движковые `RenderBatch`/`Batch` | `premise_batch_test.go.txt` (overlay-тест по НАСТОЯЩЕМУ движку) | 63/63 блока байт-в-байт; `DetectSeries` даёт те же 4 серии, что риг задал руками; состав батчей совпал [16,16,17,13,1], все под 6000 рун | +| Числа §C посчитаны верно | `recount.py` (свой парсер, своя нормализация, свой знаменатель — ничего не импортируется из `arbitrate.py`) | все 6 клеток воспроизвелись: glm-5 25/45 · mistral 23 · flash 20 · pro 16 · terra 18 · luna 18 | +| Посадки дефектов пробы A корректны | `strict_defects.py` | 12/12 регексов валидны (`fix_rx` ловит оригинал, `bad_rx` — нет); канон черновиков 20/21 воспроизведён | +| Метрика посадок считала ИСЧЕЗНОВЕНИЕ, а не восстановление | `strict_defects.py` | дефект метрики реален (удаление фразы = «исправлено»), но вывод НЕ двигается: строго восстановлено 47/48 клеток, «выпало» 3 | +| Запросы четырёх фальсификаций — то, что заявлено | `dryrun_arms.py` | инварианты И1–И4/E5 держатся: блок закона и черновик одинаковы в diff/full, различается ТОЛЬКО контракт; плейсхолдеров нет; арм E английский | +| Аппликатор диффов | `../editor_contract/selftest_apply.py` | 15/15, включая NFKC-классы, на которых прежняя версия ломалась | +| Константы длинной единицы | сверка с `internal/chunk/chunker.go` | `EditCeilingOut=3200` · `FertCJK=1.1978` · `FertOther=0.3852` — совпали, формула та же | + +## Найденные дефекты посылки + +1. **Метрика посадок пробы A** зачитывала дефект исправленным по исчезновению `bad_rx`; удаление + фразы засчиталось бы как правка. Пере-считано строго — вывод устоял. +2. **Асимметрия режимов в §C**: чемпион `glm-5` ехал `thinking:{disabled}`, а претенденты — с + разумом (`v4-pro` default, `terra`/`luna` low). В отчёте 18 §D это объявлено, но НЕ закрыто + замером; закрывается контрольным прогоном glm-5 с мышлением. +3. **`since_ch: 0`** в риге против непустого значения в проде: блок короче на 1–2 руны на кандидата, + а батч 2 стоит на 5967/6000 — в проде граница батча могла бы сдвинуться. На сравнение моделей + не влияет (батчи у всех кандидатов одни и те же). +4. **`long_units` набирает абзацы ЗА потолок** (3201 против 3200), движок же группирует ДО + превышения (`chunker.go:370`). Разница в один токен, но направление противоположное. +5. **Английский черновик** нельзя делать боевым `zh-ru/translator.md` — язык зашит прозой в + строках 9/10/14. Заведено зеркало `../editor_contract/prompts/translator-en.md`. + +## Ложные тревоги (проверено, дефекта нет) + +- `parse_common.py` «отсутствует» — живёт в `eval/bank_autonomy/`, путь добавляется в `sys.path`. +- `parse_common` «теряет» `aliases`/`related`/`since_ch` — этих полей в bank-stop нет вообще (0 вхождений). +- Знаменатель §A «21 вместо 27» — `元海` исключён из основной таблицы намеренно, он скорится + отдельно (в A2 инъецирован неверной формой). 27 − 6 окон = 21. +- Первая версия `dryrun_arms.py` ловила у fidelity-промпта «стиль-мандат» подстрочным регексом — + на деле это ЗАПРЕТ («Стиль… НЕ ТРОГАЙ ВООБЩЕ»). Проверка исправлена на полярность. + +## Как воспроизвести + +```bash +cd /home/ubuntu/projects/textmachine +# дамп ростера (НЕ коммитится: содержит KWIC-окна книги) +eval/.venv/bin/python -c "import sys;sys.path.insert(0,'eval/bank_arbitration');\ +from consilium_probe import build_roster,batches_of,block_of,SERIES,BATCH_RUNES;import json;\ +r=build_roster();json.dump({'roster':r,'blocks':{c['src']:block_of(c) for c in r},\ +'batches':[[c['src'] for c in b] for b in batches_of(r)],'series':SERIES,'batch_runes':BATCH_RUNES},\ +open('eval/premise_review/py_batching.json','w'),ensure_ascii=False,indent=1)" +# доказательство против движка (backend/ НЕ трогается — overlay) +cp eval/premise_review/premise_batch_test.go.txt /tmp/p_test.go +echo '{"Replace":{"'$PWD'/backend/internal/terminology/p_test.go":"/tmp/p_test.go"}}' > /tmp/ov.json +cd backend && go test -overlay=/tmp/ov.json -run TestPremisePythonRigMatchesEngine -v ./internal/terminology/ +cd .. && eval/.venv/bin/python eval/premise_review/recount.py -v +eval/.venv/bin/python eval/premise_review/strict_defects.py edp-A0 edp-A1 edp-A2 edp-A5 +eval/.venv/bin/python eval/premise_review/dryrun_arms.py +``` diff --git a/eval/premise_review/dryrun_arms.py b/eval/premise_review/dryrun_arms.py new file mode 100644 index 00000000..dcda7aef --- /dev/null +++ b/eval/premise_review/dryrun_arms.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Сухая проверка ПОСЫЛКИ четырёх армов: что реально уедет на провод. Ни одного платного вызова. + +Инварианты, которые обязаны держаться (иначе арм измерит не то, что заявлено): + И1 диффовый и полный контракты одного арма получают ОДИН И ТОТ ЖЕ блок закона и ОДИН И ТОТ ЖЕ + черновик — различаться должен ТОЛЬКО системный контракт, иначе сравнение не контролировано; + И2 ни в одном сообщении не осталось незаполненных `{{плейсхолдеров}}`; + И3 у арма E инструкции и черновик АНГЛИЙСКИЕ (латиница > кириллицы) — иначе повтор дефекта 05.08; + И4 у арма L reasoning_effort='low' стоит ровно на обоих контрактах, и больше нигде; + E5 у арма N системный контракт НЕ содержит стиль-мандата, а у арма D (проба 19) — содержит. +""" +from __future__ import annotations +import re +import sys +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +sys.path.insert(0, str(REPO / "eval" / "editor_contract")) +sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) + +import final_probes as F # noqa: E402 + +FAIL = 0 + + +def check(name: str, ok: bool, note: str = "") -> None: + global FAIL + if not ok: + FAIL += 1 + print(f" [{'OK ' if ok else 'ПРОВАЛ'}] {name}" + (f" — {note}" if note else "")) + + +def cyr_lat(s: str) -> tuple[int, int]: + return (sum(1 for c in s if "а" <= c.lower() <= "я"), + sum(1 for c in s if "a" <= c.lower() <= "z")) + + +def plan_of(arm: str): + if arm == "L": + return F.plan_L() + if arm == "N": + return F.plan_N() + if arm == "G": + return F.plan_G({f"G{u}": "ЧЕРНОВИК-G" + str(u) for u in range(2)}) + return F.plan_E({f"E{k}": "DRAFT-E" + str(k) for k in range(6)}) + + +def main() -> None: + for arm in ("L", "N", "G", "E"): + plan = plan_of(arm) + print(f"\n=== арм {arm}: {len(plan)} вызовов ===") + tags = [p[0] for p in plan] + check("теги уникальны", len(set(tags)) == len(tags)) + + # И2 — плейсхолдеры + holes = set() + for tag, msgs, kind, draft, extra in plan: + for m in msgs: + holes |= set(re.findall(r"\{\{(\w+)\}\}", m["content"])) + check("И2 нет незаполненных плейсхолдеров", not holes, ", ".join(sorted(holes))) + + # И1 — парность diff/full на одном материале + bykey = {} + for tag, msgs, kind, draft, extra in plan: + key = re.sub(r"^fp-.?.?", "", tag) + bykey.setdefault(key, {})[kind] = (msgs, draft) + pairs = [v for v in bykey.values() if len(v) == 2] + if pairs: + same_block = all( + [m["content"] for m in v["diff"][0] if m["role"] == "system"][1:] + == [m["content"] for m in v["full"][0] if m["role"] == "system"][1:] + for v in pairs) + same_draft = all(v["diff"][1] == v["full"][1] for v in pairs) + differ_sys = all(v["diff"][0][0]["content"] != v["full"][0][0]["content"] for v in pairs) + check(f"И1 блок закона одинаков в diff/full ({len(pairs)} пар)", same_block) + check("И1 черновик одинаков в diff/full", same_draft) + check("И1 системный контракт РАЗЛИЧАЕТСЯ", differ_sys) + else: + check("И1 арм одноконтрактный (пар нет)", arm == "N", "N — только дифф, это по дизайну") + + # И4 — режим разума + efforts = {str(p[4]) for p in plan} + if arm == "L": + check("И4 reasoning_effort=low на всех вызовах", + efforts == {"{'reasoning_effort': 'low'}"}, str(efforts)) + else: + check("И4 режим разума не задаётся (дефолт)", efforts == {"None"}, str(efforts)) + + # И3 — язык арма E + if arm == "E": + sysmsg = plan[0][1][0]["content"] + c, l = cyr_lat(sysmsg) + check("И3 инструкции английские", l > c, f"лат={l} кир={c}") + blk = plan[0][1][1]["content"] + c2, l2 = cyr_lat(blk) + check("И3 блок закона английский", l2 > c2, f"лат={l2} кир={c2}") + + # E5 — стиль-мандат. ⚠ Подстрочный регекс `стил|благозвуч` здесь НЕ работает: fidelity-промпт + # называет стиль в ЗАПРЕТЕ («Стиль… НЕ ТРОГАЙ ВООБЩЕ»), и наивная проверка считала запрет + # мандатом. Отличаем по полярности: у N обязан быть явный запрет и сужение мандата. + sysmsg = plan[0][1][0]["content"] + if arm == "N": + forbids = bool(re.search(r"(стил|благозвуч|лексик)[^.]{0,80}не трогай", sysmsg, re.I)) + narrow = bool(re.search(r"мандат\s+узкий|только\s+ВЕРНОСТЬ", sysmsg, re.I)) + full_sys = [p[1][0]["content"] for p in F.plan_L() if p[2] == "full"][0] + positive_elsewhere = bool(re.search(r"Правь стиль", full_sys)) + check("E5 у N стиль назван ЗАПРЕТОМ", forbids) + check("E5 у N мандат сужен до верности", narrow) + check("E5 у полного контракта стиль-мандат ЕСТЬ (контраст)", positive_elsewhere) + print(f" системный контракт, первая строка: {sysmsg.splitlines()[0][:100]!r}") + print(f" сообщений в вызове: {len(plan[0][1])} (роли: " + f"{[m['role'] for m in plan[0][1]]})") + + print(f"\n{'ПОСЫЛКА ЧИСТА' if not FAIL else f'ПРОВАЛОВ: {FAIL}'}") + sys.exit(1 if FAIL else 0) + + +if __name__ == "__main__": + main() diff --git a/eval/premise_review/premise_batch_test.go.txt b/eval/premise_review/premise_batch_test.go.txt new file mode 100644 index 00000000..76473399 --- /dev/null +++ b/eval/premise_review/premise_batch_test.go.txt @@ -0,0 +1,132 @@ +package terminology + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "testing" + + "textmachine/backend/internal/lang" +) + +type pyDump struct { + Roster []struct { + Src string `json:"src"` + Type string `json:"type"` + Origin string `json:"origin"` + Freq int `json:"freq"` + Drafts [][]interface{} `json:"drafts"` + Ctx []string `json:"ctx"` + Evidence []string `json:"evidence"` + } `json:"roster"` + Blocks map[string]string `json:"blocks"` + Batches [][]string `json:"batches"` + Series map[string][]string `json:"series"` + BatchRunes int `json:"batch_runes"` +} + +// TestPremisePythonRigMatchesEngine доказывает ИСПОЛНЕНИЕМ, что питон-риг пробы C +// (consilium_probe.block_of / batches_of) воспроизводит движковые RenderBatch/Batch. +func TestPremisePythonRigMatchesEngine(t *testing.T) { + raw, err := os.ReadFile("/home/ubuntu/projects/textmachine/eval/premise_review/py_batching.json") + if err != nil { + t.Fatal(err) + } + var d pyDump + if err := json.Unmarshal(raw, &d); err != nil { + t.Fatal(err) + } + + cands := make([]Candidate, 0, len(d.Roster)) + for _, r := range d.Roster { + c := Candidate{ + Key: r.Src, Src: r.Src, Type: r.Type, Origin: Origin(r.Origin), + Freq: r.Freq, SinceCh: 0, Evidence: r.Evidence, KWIC: r.Ctx, + } + for _, v := range r.Drafts { + if len(v) != 2 { + t.Fatalf("draft shape %v", v) + } + c.Variants = append(c.Variants, Variant{ + Dst: v[0].(string), Chunks: int(v[1].(float64)), + }) + } + cands = append(cands, c) + } + + // (1) поблочный рендер + blockMismatch := 0 + for _, c := range cands { + got := RenderBatch([]Candidate{c}) + want, ok := d.Blocks[c.Src] + if !ok { + t.Fatalf("нет питон-блока для %s", c.Src) + } + if got != want { + blockMismatch++ + if blockMismatch <= 3 { + t.Errorf("БЛОК РАСХОДИТСЯ %s:\n--- go ---\n%s\n--- py ---\n%s", c.Src, got, want) + } + } + } + t.Logf("блоков сверено: %d, расхождений: %d", len(cands), blockMismatch) + + // (2) движок ключ-сортирует кандидатов ДО Batch (terminology.go:212 Merge) — риг делает то же. + sort.Slice(cands, func(i, j int) bool { return cands[i].Key < cands[j].Key }) + + // (2а) серии движок выводит DetectSeries из язык-слоя, а риг задаёт руками. Сверяем группировки. + sEnabled, headFinal := lang.SeriesMorphology("zh") + auto := DetectSeries(cands, SeriesParams{Enabled: sEnabled, HeadFinal: headFinal}) + hand := map[string]int{} + i := 0 + for _, name := range []string{"zhuan", "deng", "dengzizhi", "jie"} { + i++ + for _, m := range d.Series[name] { + hand[m] = i + } + } + groups := func(m map[string]int) map[int][]string { + g := map[int][]string{} + for _, c := range cands { + if id := m[c.Key]; id != 0 { + g[id] = append(g[id], c.Key) + } + } + return g + } + ga, gh := groups(auto), groups(hand) + t.Logf("DetectSeries(zh, enabled=%v headFinal=%v): %d серий", sEnabled, headFinal, len(ga)) + for id, mem := range ga { + t.Logf(" движок серия %d: %v", id, mem) + } + for id, mem := range gh { + t.Logf(" риг серия %d: %v", id, mem) + } + + got := Batch(cands, d.BatchRunes, auto) + gotComp := make([][]string, 0, len(got)) + for _, b := range got { + row := make([]string, 0, len(b)) + for _, c := range b { + row = append(row, c.Src) + } + gotComp = append(gotComp, row) + } + gj, _ := json.Marshal(gotComp) + pj, _ := json.Marshal(d.Batches) + if string(gj) != string(pj) { + t.Errorf("СОСТАВ БАТЧЕЙ РАСХОДИТСЯ:\n go=%s\n py=%s", gj, pj) + } else { + sizes := "" + for _, b := range gotComp { + sizes += fmt.Sprintf(" %d", len(b)) + } + t.Logf("состав батчей совпал: %d батчей, размеры%s", len(gotComp), sizes) + } + + // (3) рунный размер каждого батча против потолка + for k, b := range got { + t.Logf("батч %d: %d кандидатов, %d рун (потолок %d)", k, len(b), BatchRunes(b), d.BatchRunes) + } +} diff --git a/eval/premise_review/recount.py b/eval/premise_review/recount.py new file mode 100644 index 00000000..a956a787 --- /dev/null +++ b/eval/premise_review/recount.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""НЕЗАВИСИМЫЙ пере-счёт пробы C: свой парсер, своя нормализация, свой знаменатель. + +Цель — не «повторить 25/45», а проверить число ДРУГИМ кодом. Ничего не импортирую из +`arbitrate.py`: если унаследованный парсер систематически ошибается, повтор его же логикой +это не покажет. Совпадение чисел = подтверждение; расхождение = дефект посылки пробы C. +""" +from __future__ import annotations +import json +import re +import sys +import unicodedata +from collections import Counter +from pathlib import Path + +RAW = Path.home() / "books" / "gu-zhenren" / "bank-arbitration" +GOLDF = Path("/home/ubuntu/projects/textmachine/eval/bank_arbitration/gold/gold.jsonl") + + +def norm(s: str) -> str: + """Нормализация цели: NFKC, регистр, ё→е, снять кавычки/дефисные варианты, схлопнуть пробелы.""" + s = unicodedata.normalize("NFKC", s or "").casefold().replace("ё", "е") + s = re.sub(r"[«»\"'`]", "", s) + return " ".join(s.split()) + + +def parse(tag: str) -> tuple[dict[str, str], float, dict]: + """Свой парсер: строка = src dst [ conf]. Табов нет — берём двойной пробел/'|'.""" + out: dict[str, str] = {} + cost = 0.0 + diag = Counter() + for f in sorted(RAW.glob(f"{tag}-b*.json")): + rec = json.loads(f.read_text(encoding="utf-8")) + cost += rec["cost_usd"] + for ln in rec["content"].split("\n"): + ln = ln.strip() + if not ln or ln.startswith("#") or ln.startswith("`"): + diag["skip_empty_or_comment"] += 1 + continue + parts = [p.strip() for p in re.split(r"\t| {2,}|\s*\|\s*", ln) if p.strip()] + if len(parts) < 2: + diag["skip_lt2fields"] += 1 + continue + src, dst = parts[0], parts[1] + # третье поле — уверенность (число или слово); в dst её быть не должно + if re.fullmatch(r"\d{1,3}", dst) and len(parts) >= 3: + diag["conf_in_second_field"] += 1 + dst = parts[2] + if src in out and out[src] != dst: + diag["dup_conflict"] += 1 + out[src] = dst + diag["parsed"] += 1 + return out, cost, dict(diag) + + +def main(tags: list[str]) -> None: + gold = [json.loads(l) for l in GOLDF.read_text(encoding="utf-8").splitlines() if l.strip()] + scored = [g for g in gold if g.get("gold_dst")] + print(f"голд: строк {len(gold)}, с подписанным gold_dst {len(scored)}, " + f"из них in_bank {sum(1 for g in scored if g.get('in_bank'))}") + print() + hdr = f"{'пасс':10s} {'строк':>6s} {'точно':>6s} {'+флексии':>9s} {'нет ответа':>11s} {'$':>9s}" + print(hdr) + print("-" * len(hdr)) + for tag in tags: + got, cost, diag = parse(tag) + exact = infl = missing = 0 + misses = [] + for g in scored: + src = g["src"] + if src not in got: + missing += 1 + continue + d = norm(got[src]) + if d == norm(g["gold_dst"]): + exact += 1 + elif any(d == norm(a) for a in g.get("gold_alt", [])): + infl += 1 + else: + misses.append((src, got[src], g["gold_dst"])) + print(f"{tag:10s} {len(got):6d} {exact:6d} {exact + infl:9d} {missing:11d} ${cost:8.6f}") + if "-v" in sys.argv: + print(f" диагностика парсера: {diag}") + for m in misses[:8]: + print(f" мимо: {m[0]} → «{m[1]}» (голд «{m[2]}»)") + print(f"\nзнаменатель для счёта: {len(scored)}") + + +if __name__ == "__main__": + main([a for a in sys.argv[1:] if not a.startswith("-")] or + ["glm1", "gro1", "mis1", "ds1", "fdp", "flu", "fte"]) diff --git a/eval/premise_review/strict_defects.py b/eval/premise_review/strict_defects.py new file mode 100644 index 00000000..db9e5042 --- /dev/null +++ b/eval/premise_review/strict_defects.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Ревью посылки пробы A: метрика посадок считала ИСЧЕЗНОВЕНИЕ, а не ВОССТАНОВЛЕНИЕ. + +`editor_wire_probe.cmd_score` зачитывает дефект исправленным по `not re.search(bad_rx, out)`. +Удаление куска текста целиком тоже даёт `not still` ⇒ выпадение фразы засчитывалось как правка. +Здесь считаю три величины из того же сырья, $0: + + снят = bad_rx больше не совпадает (прежняя метрика) + восстановлен = снят И fix_rx совпадает (строгая метрика) + выпал = снят, но fix_rx НЕ совпадает (кандидат в «фразу удалили») + +Плюс валидация самих регексов на исходном черновике: fix_rx ОБЯЗАН совпадать с оригиналом, +иначе строгая метрика штрафует за корректную работу. +""" +from __future__ import annotations +import json +import re +import sys +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) +RAW = Path.home() / "books" / "gu-zhenren" / "bank-arbitration" + +import editor_wire_probe as P # noqa: E402 + + +def out_of(tag: str): + f = RAW / f"{tag}.json" + if not f.exists(): + return None + return json.loads(f.read_text(encoding="utf-8"))["content"] + + +def validate_regexes() -> int: + """fix_rx должен ловить ОРИГИНАЛ, bad_rx — НЕ ловить его; на посаженном — наоборот.""" + bad = 0 + print("=== валидация регексов посадок на исходном черновике ===") + for k in sorted(P.DEFECTS): + draft = P.draft_of(k) + planted, _ = P.planted_draft(k, draft) + for cls, old, new, bad_rx, fix_rx in P.DEFECTS[k]: + fix_on_orig = bool(re.search(fix_rx, draft)) + bad_on_orig = bool(re.search(bad_rx, draft)) + bad_on_plant = bool(re.search(bad_rx, planted)) + ok = fix_on_orig and not bad_on_orig and bad_on_plant + if not ok: + bad += 1 + print(f" ⚠ w{k}/{cls}: fix_rx на оригинале={fix_on_orig} " + f"bad_rx на оригинале={bad_on_orig} bad_rx на посаженном={bad_on_plant}") + print(f" дефектных регексов: {bad} из {sum(len(v) for v in P.DEFECTS.values())}") + return bad + + +def score(arm: str) -> None: + print(f"\n=== арм {arm} ===") + for cls in ("k1", "k2"): + snyat = vosst = vypal = tot = 0 + rows = [] + for k in sorted(P.DEFECTS): + o = out_of(f"{arm}-w{k}") + if o is None: + continue + for c, old, new, bad_rx, fix_rx in P.DEFECTS[k]: + if c != cls: + continue + tot += 1 + still = bool(re.search(bad_rx, o)) + restored = bool(re.search(fix_rx, o)) + if not still: + snyat += 1 + if restored: + vosst += 1 + rows.append(f"w{k}:восст") + else: + vypal += 1 + rows.append(f"w{k}:ВЫПАЛ") + else: + rows.append(f"w{k}:жив") + if tot: + print(f" {cls}: снят {snyat}/{tot} · ВОССТАНОВЛЕН {vosst}/{tot} · выпал {vypal} " + + " ".join(rows)) + + +if __name__ == "__main__": + validate_regexes() + for arm in sys.argv[1:] or ["edp-A0", "edp-A1", "edp-A2", "edp-A3", "edp-A4", "edp-A5"]: + score(arm)