Close polygon package seven entirely as D39.46: phases B and B-prime accepted, contexts ratified as the lever, process rules into prompt norms
This commit is contained in:
parent
975d0bd566
commit
6287c3950e
15 changed files with 2657 additions and 3 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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 на невиданном тексте.
|
||||
|
|
|
|||
|
|
@ -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/`.
|
||||
|
||||
244
docs/archive/reports/POLYGON_TERM_RESEARCH_B2_2026-07-26.md
Normal file
244
docs/archive/reports/POLYGON_TERM_RESEARCH_B2_2026-07-26.md
Normal file
|
|
@ -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 разброс не мерен.
|
||||
622
docs/archive/reports/POLYGON_TERM_RESEARCH_B_2026-07-26.md
Normal file
622
docs/archive/reports/POLYGON_TERM_RESEARCH_B_2026-07-26.md
Normal file
|
|
@ -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=<hex>` и перезагружает страницу. Воспроизведя куку (`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` — `src<TAB>dst<TAB>пояснение`.
|
||||
|
||||
**Механизм, которого нет ни у нас, ни в 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` | инъекция в промпт: `src<TAB>dst<TAB>пояснение` |
|
||||
| `译后` (после перевода) | `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<TAB>краткая статья` и подавать его терминологу тем же путём, что жанровый словарь. Для 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 пересчитываются из него без сети.
|
||||
|
|
@ -20,3 +20,29 @@
|
|||
4. **Правило вершины словосочетания зависит от типа терма** (у имени вершина последняя, у нарицательного — первая). Морфо-чекер без этого правила меряет не то: первая версия `rubric_probe.py` склоняла «Вина» в «Монах Цветочного Вина».
|
||||
5. **Контраст без опоры вырождается в частоту.** Ранжирование кандзи против словаря другого языка даёт log частоты, а не над-представленность; канал понижен до инвентаря.
|
||||
6. **Отклонение методики — замерять, а не декларировать.** Нарезка здесь не боевая; свип целевого размера чанка (800/2000/6000) дал Жаккар 1.000 — только после этого цифры можно было предъявлять.
|
||||
|
||||
## Фаза B (добавлено 26.07)
|
||||
|
||||
| Скрипт | Что делает |
|
||||
|---|---|
|
||||
| `bkrs_lookup.py` | словарные статьи БКРС списком; воспроизводит куки-челлендж `ca=<hex>`, из-за которого страница считалась 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 лежит внутри шума прогона.
|
||||
|
|
|
|||
204
eval/pkg7/audit_phaseB.py
Normal file
204
eval/pkg7/audit_phaseB.py
Normal file
|
|
@ -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())
|
||||
101
eval/pkg7/bkrs_lookup.py
Normal file
101
eval/pkg7/bkrs_lookup.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Полигон, пакет-7 (Z4-добор): словарные статьи БКРС для списка терминов.
|
||||
|
||||
Зачем. В фазе A «что говорит БКРС» осталось UNCLEAR по ВСЕМ терминам: страница считалась
|
||||
JS-рендеримой. На деле bkrs.info ставит одноразовый куки-челлендж (`ca=<hex>`) и перезагружает
|
||||
страницу; воспроизведя куку, получаем СЕРВЕРНЫЙ 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)[^>]*>.*?</\1>", " ", 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())
|
||||
191
eval/pkg7/blind_judge.py
Normal file
191
eval/pkg7/blind_judge.py
Normal file
|
|
@ -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())
|
||||
244
eval/pkg7/blind_judge2.py
Normal file
244
eval/pkg7/blind_judge2.py
Normal file
|
|
@ -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())
|
||||
207
eval/pkg7/build_termset.py
Normal file
207
eval/pkg7/build_termset.py
Normal file
|
|
@ -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())
|
||||
85
eval/pkg7/repair_judge.py
Normal file
85
eval/pkg7/repair_judge.py
Normal file
|
|
@ -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())
|
||||
253
eval/pkg7/score_b2.py
Normal file
253
eval/pkg7/score_b2.py
Normal file
|
|
@ -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())
|
||||
185
eval/pkg7/score_bws.py
Normal file
185
eval/pkg7/score_bws.py
Normal file
|
|
@ -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())
|
||||
278
eval/pkg7/terminologist_arms.py
Normal file
278
eval/pkg7/terminologist_arms.py
Normal file
|
|
@ -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())
|
||||
Loading…
Add table
Reference in a new issue