Land pilot-harness package: memory_eval echo control and fidelity axis, M0-M3 rename, artifact blinding, exp09 v2 with D13, kana precision, Mistral probe, webnovel slice; journal entries for both sessions
This commit is contained in:
parent
153277e3bb
commit
9afea37bee
13 changed files with 1904 additions and 232 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Журнал прогресса
|
||||
|
||||
> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (обновляемый блок; на 2026-07-09). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D17); этот файл — ЖУРНАЛ, не спека.**
|
||||
> - **Фаза:** 0 (каркас) завершена; машинерия Фазы 1 в основном построена — банк памяти v2, чанкер + txt/epub импорт, disposition/coverage-гейт/эскалация, durable resume, глоссарий→редактор. **Проведено стратегическое ревью (`architecture/07-strategic-review.md`, 09.07): архитектура end-to-end цела; ратифицирован пакет пост-ревью решений D13–D17.** Следующее: пакет фиксов D15/D16 (бэкенд) + починка пилот-харнесса D13 (полигон) → реальный ja→ru прогон end-to-end → пилот Ф2.5.
|
||||
> - **Фаза:** 0 (каркас) завершена; машинерия Фазы 1 в основном построена — банк памяти v2, чанкер + txt/epub импорт, disposition/coverage-гейт/эскалация, durable resume, глоссарий→редактор. **Проведено стратегическое ревью (`architecture/07-strategic-review.md`, 09.07): архитектура end-to-end цела; ратифицирован пакет пост-ревью решений D13–D17.** Следующее: пакет фиксов D15/D16 (бэкенд, **D16 лендится 09.07 — `memmatch-v3`**) → реальный ja→ru прогон end-to-end → пилот Ф2.5. **Пилот-харнесс D13 ПОЧИНЕН (полигон, 09.07): memory_eval эхо-контроль+fidelity-ось+M0–M3+Go-синк, индикатив пере-прогнан (M1≈M2 банк оправдан; M3 роняет верность −45.6 → страх владельца подтверждён); exp09 v2; Mistral заведён (D14.2, refusal 11/11 ok); kana-precision (MIN=3 подтверждён); вебновелл-срез готов.**
|
||||
> - **Стек (2026-07-05, подтверждён ревью):** черновик DeepSeek V4 Flash · редактор **grok-4.3** (thinking-OFF через ЯВНЫЙ `reasoning_effort:none` — дефолт grok = `low`; D1-монолингв оспорен внешним замером — решает пилот-арм D13.1/D17) · судья/апекс Gemini 3.1 Pro (mandatory thinking) · эскалация A: DeepSeek V4 Pro → GLM-5.1 → Gemini · канал B (18+): Grok + локальная abliterated (**DeepSeek для explicit ЗАПРЕЩЁН его ToS — D14.1; Mistral — кандидат-фолбэк после пробы, D14.2**). Anthropic выброшен, OpenAI оставлен. 6 ключей.
|
||||
> - **Экономика:** `experiments/08-cost-model-v2.md` (~$0.69 ранобэ / ~$10.94 вебновелла-500) — заменяет старые пороги $0.6 / $5 / $30. ⚠ Resume: любой `--resnapshot` = переоплата книги — онгоинг гейтится D15.2.
|
||||
> - **Ждём от владельца:** 3–5 глав реальных вебновелл (вся текущая эмпирика — классика из претрейна) + explicit-фрагменты для 18+ руки (единственный critical ревью) + провенанс двух внешних 12-*-доков.
|
||||
|
|
@ -527,9 +527,64 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор
|
|||
|
||||
Тесты: `capability_test` кейс assert'ит **присутствие** `reasoning_effort:"none"` (закрыл eval-указанную дыру — ложный «off шлёт nothing» тут падает); `TestBoevoyConfigGrokEditorExplicitNone` (config end-to-end: grok→explicit-none, не echo-flagged, edit=grok, c2-select=glm-5). Полный `-race` зелёный, `tmctl report` $0. Eval подтвердил: прогоны переигрывать не надо, баг был латентным (grok не был в стадии) — пойман до флипа.
|
||||
|
||||
### 2026-07-09 — Пакет фиксов стратегического ревью (D15/D16, промт `BACKEND_SESSION_PROMPT_REVIEW_FIXES`) ЗАВЕРШЁН
|
||||
|
||||
6 задач + 3 самопойманных дефекта + 7 находок финального адверсариального селфревью — всё исправлено, каждый фикс **мутационно-проверен**, `go build/vet/test ./... -race` зелёный, `tmctl report` $0.
|
||||
|
||||
- **Задача 1 (гигиена).** Лгущие resume-комментарии (`runner.go` coverageSnap, `coverage.go` coverageGateVersion) переписаны честно: `--resnapshot` переоплачивает ВСЮ книгу (snapshotID в RequestHash), никакого «free re-classify» — только внутри неизменного снапшота. Edit `prompt_version` `v0-draft`→`v1-monolingual` в обоих `pipeline-c*.yaml` (D1-уточнение №3: один лейбл на два SHA закрыт). `capability.go:3` ссылка 03→05.
|
||||
- **Задача 2 (D16 память).** 2a полисемия fail-loud (same-src/разные sense/dst/пересекающиеся окна, scoped к matchable src — `道` инертен, не регресс); 2b sticky **наследует** disposition (`activeIDs` → `map[string]injectionDisposition`, `Select`/`unionSticky`/`stickyWin` обновлены) — collision-prone AMBIGUOUS больше не апгрейдится в CONFIRMED мимо post-check/editor; 2c source word-boundary для **латиница/кириллица** (`suppressUnboundedPhonetic`); 2d ruby-reading→alias ручного термина (`attachRubyAliasesToManual`) + чек-лист ja-книги в `backend/README.md`. `memoryMatchVersion` v2→v3, `memoryNormAlgoVersion` v2→v3.
|
||||
- **⚠ [→ оркестратору] 2c: kana/Han НЕ boundary-checked (осознанное расхождение с буквой промта «латиница, кана»).** Обоснование: у каны нет сегментации (как у Han) — letter-boundary ломает частый кейс «имя+частица» (すずきは) и противоречит контракту «длинный кана-ключ = CONFIRMED». ≥4-кана-компаунд-precision отложен на kana-precision-замер полигона + токенизатор B6 (согласуется с D16 «minKeyLenPhonetic=3 — консервативный дефолт до замера»). Прошу ратифицировать это сужение.
|
||||
- **Задача 3 (`tmctl status`+`redrive`, D15.3).** `status.go`: read-only проекция (0 LLM, 0 replay) — book-manifest N/M (честный знаменатель через ре-чанкинг $0), unit-счёт done/in_progress/flagged/pending, паспорта глав (total/ok/flagged/skipped-stages, worst_flag, вердикт pass|attention|fail по exp07, $), деньги committed/reserved/ceiling%/projected, ETA от throughput (вторично), `--json` со стабильными enum. Колонка `escalated`/`escalation_model` в `chunk_status` (миграция v6) — телеметрия эскалации на flagged-resume (отложенная находка №6 Вехи 2.5). `redrive`: точечная переатака флагнутых (`--chapter/--chunk/--reason/--dry-run`), сброс терминального флага (`ResetChunkStages` удаляет chunk_status+чекпоинты сброшенных стадий, DispOK не тронут), свежий retry/escalation-бюджет, ре-ран durable-цикла. Продемонстрировано на example-книге ($0). **Деньги-семантика (задокументирована):** redrive НЕ возвращает committed (реально потраченное остаётся) → после redrive `committed ≥ SUM(checkpoints)`, безопасное направление; инвариант №1 в `README.md` дополнен исключением.
|
||||
- **Задача 4 (спека D15.2, документ).** `backend/docs/D15.2-content-addressed-resume-spec.md` (в моей зоне). Суть: `snapshotID` в ключе вызова смешивает wire- и вердикт-входы; расщепить (`wireSnapshotID`/`verdictSnapshotID`), ключ чекпоинта держать на wire-идентичности (по факту `msgsContentHash` + не-msgs wire-параметры), вердикт-версии убрать из ключа и re-classify-ить на resume бесплатно. Снимает онгоинг-мину D15 (append-термин re-bill-ит только реально затронутые чанки). **[→ оркестратору] на утверждение; при ок — свернуть в `03-implementation-notes.md`.**
|
||||
- **Задача 5 (reasoning-буфер, D13.6).** `EstimateUSD(+reasoningBudgetTokens)` резервирует reasoning как output для additive-провайдеров (xAI); fail-fast в `LoadPipeline`: reasoning-стадия на additive-провайдере без `reasoning_max_tokens` (+ escalate_to). Снимает D6.2-блок think-ON grok.
|
||||
- **Задача 6 (перепроверка отсевов).** Артефакты 44-агентки эфемерны (PROGRESS называет лишь sticky-отсев — уже закрыт 2b). Адверсариальные пробы с исполнением по границам доверия → **2 подтверждённых + пофикшено:** (F) shared-alias livelock (общий firing-key между разными approved-терминами → `approvedSharedKeyCollisions` fail-loud) и (A) apostrophe-fold тихо-пусто (типографский апостроф → ASCII в обоих нормализаторах). Пробы удалены, `git status backend/` чист.
|
||||
|
||||
**Самопойманный при селфревью (сверх задач):** 2d+Задача6 могли hard-блокировать легитимную ja-книгу с омофонами (鈴木/鈴樹 → оба すずき) — `attachRubyAliasesToManual` теперь graceful skip+log коллизии (best-effort, не hard-block), ручные коллизии сида по-прежнему fail-loud.
|
||||
|
||||
**Финальное адверсариальное селфревью — воркфлоу 5 дименсий × верификация (15 агентов, refute-by-default): 7 CONFIRMED / 3 refuted, все 7 исправлены:**
|
||||
1. [major] Полисемия same-src через общий matchable ALIAS проскакивала оба гарда D16.1 (sub-floor src `道` + общий алиас `大道`) → снял same-src-skip в `approvedSharedKeyCollisions` (ловит через общий alias-ключ; в обычном eligible-src кейсе `loadGlossarySeed` короткозамыкает — двойного репорта нет).
|
||||
2. [major] `glossary_miss` (post-check-гейт, флаг на уровне ЧАНКА после стадий) был невидим `status`/`redrive` (все stage-строки DispOK) → status показывал done/pass против exit-2 translate. Фикс: при gate ON чанк с post-check-промахом промотится в flagged/glossary_miss (не re-drivable — правка глоссария = --resnapshot).
|
||||
3. [major] additive-гейт ложно-негативил на `reasoning:""` (омиссия у grok = дефолт low = думает additive) → гейт и буфер теперь на `!= "off"` (не только low|med|high).
|
||||
4. [minor] `status` ловил только intra-store multi-snapshot, не store-vs-current-config → добавлен ConfigDrift (read-only расчёт текущего снапшота, сравнение).
|
||||
5. [minor] инвариант №1 README (`committed==SUM(checkpoints)`) — дополнен redrive-исключением.
|
||||
6. [minor] `unionSticky` newest-wins не тестировался → тест на конфликтующие disposition.
|
||||
7. [nit] `ProjectedBookUSD` пере-считывал при in-progress-спенде → база проекции = processedCost (только done+flagged).
|
||||
|
||||
**Техдолг / вопросы оркестратору:**
|
||||
- Спека D15.2 и сужение 2c (kana-boundary) — на ратификацию (см. выше).
|
||||
- **⚠ Рабочее дерево несёт незакоммиченные правки ПАРАЛЛЕЛЬНОЙ полигон-сессии** (`eval/`, `docs/experiments/`) — не мои, не трогал; коммитить пакет надо аккуратно (только `backend/` + `backend/docs/` + эта запись).
|
||||
- Известная граница `status`/`redrive`: glossary_miss re-деривится из неизменного глоссария → не re-drivable (фикс = правка глоссария = --resnapshot); status теперь честно это показывает.
|
||||
- F3 at-most-once — следующий D12-хвост (status/redrive закрыты).
|
||||
|
||||
## Полигон
|
||||
(секция параллельной сессии — записи добавлять сюда)
|
||||
|
||||
### 2026-07-09 — Сессия «Починка пилот-харнесса + пробы» (D13/D14.2/D16-хвост) ЗАВЕРШЕНА
|
||||
|
||||
Мандат `POLYGON_SESSION_PROMPT_PILOT_HARNESS.md` (6 задач) выполнен. Всё с провенансом (model id, UTC-дата, usage, полные сырые выходы в `data/pilot/raw_*`).
|
||||
|
||||
**1. `memory_eval.py` + `declmetric.py` починены (D13.5) и валидированы.** Эхо-контроль (детект/стрип глоссарий-преамбулы+source-эха, флаг+исключение, `--regen-on-echo`); полные сырые выходы (не `out[:160]`); **ось верности РЕАЛИЗОВАНА** (cross-family LLM-судья vs источник, family-guard D13.3); C0–C3→**M0–M3** (D13.4); зеркало синхронизировано с `memory.go` по 7 расхождениям (sticky depth-2+наследование disposition D16.2, until_ch, спойлер-гейт containment, токен-бюджет/eviction, полная trad2simp 508 hash-синк, ignorables Cf+VS, Han по всем плоскостям). Self-test 12 инвариантов зелёный; адверсариально верифицирован (faithful; supplementary-plane дельты закрыты). `declmetric` — зеркало normalizeTargetForm/containsWholeWord (NFC/dash/ignorable/letter-boundary).
|
||||
- **Индикатив пере-прогнан на Ah-Q (grok-non-reasoning, судья gemini-2.5-flash, 5 чанков):** M0 verность 94.6/cons 0.567 · **M1 93.4/1.0** · **M2 93.4/1.0** · **M3 49.0/0.467** · эхо 0/5. Правила решения разрешились: **M1≈M2 (банк оправдан, M1 дешевле по input); M3 роняет ВЕРНОСТЬ −45.6 vs M0 → страх владельца подтверждён**; M3 consistency≈M0 → вред ловит ТОЛЬКО ось верности (судья: 阿Q→«Вэйчжуан», модель послушалась неверной инъекции). Таблица+чтение — exp09 §6-результаты.
|
||||
- **⚠ Старые числа контаминированы** («C1 1.0=C2 1.0, C3 0.60»): C3 надут эхом, оси верности не было. Помечено в exp09.
|
||||
|
||||
**2. Гигиена оценочных артефактов (exp04):** `build_editor_artifact.py` — рандомизация меток ПО ФРАГМЕНТУ (соль в ключе), ключ+цена в ОТДЕЛЬНОМ `--key` файле (не в артефакте). Проверено: артефакт без утечки имён/цены, маппинг разный на каждом фрагменте. Честная сноска в exp04 §Оговорки: слепота LLM-судейского плеча была структурно сломана (ключ+цена в `<details>` того же артефакта) → grok-4.3 ПРОВИЗОРЕН, страхуют объективная цена + пилот (7-bis + D13.1).
|
||||
|
||||
**3. `09-pilot-protocol.md` v2 (D13):** армы D13.1 (моно-vs-билингв на одной модели, §3a) и гетерогенный C2 (D13.2); панель судей D13.3 (§5: 2–3 семейства, 11+ повторов+свап, Bradley-Terry/медиана, family-guard, κ+tie-rate/top-1); пре-регистрационный каркас §12 (MDE/мощность, N≥3/безκ, правила по руке). **Pearmut оценён (§10): НЕ брать как BWS-движок** (BWS отсутствует; протоколы DA/ESA/cESA/MQM) → строить тонкий свой BWS + красть attention-checks; Potato как опц. фронт (лицензию проверить). Pearmut = arXiv:2601.02933, MIT, self-host — verified GitHub/PyPI.
|
||||
|
||||
**4. Mistral канал B (D14.2):** заведён в `providers.json` (`mistral-large-2512` — сверен вживую `/models`; research-агент угадал `mistral-large-3-25-12` НЕВЕРНО, потому и сверяем; `safe_prompt:false`). **Refusal SFW/L1/L2-violence: 11/11 ok, 0 отказов/вырезаний/эха.** Базовое качество (индикатив, судья gemini): fidelity 85/95/90 zh/ja/en → **годен как переводчик канала B**. Explicit-часть гейтится фрагментами владельца. Детали — exp02 §Mistral.
|
||||
|
||||
**5. Kana-precision (D16-хвост, `kana_precision.py`+`kana_traps.json`):** MIN=3 подтверждён как дефолт (гасит len-2 substring-шум, сохраняет 3-kana имена メロス/おさん; MIN=4 роняет recall 13→6). Потолок precision = **homograph-класс** (имя==частое слово ひまわり/あさひ), ДЛИНО-ИНВАРИАНТЕН → MIN не гасит, нужен POS/known-word/ruby-якорь (вход B6). **D16.3 source-граница на кане КАТАСТРОФИЧНА** (TP 15→1 — имена+kana-частицы) → бэкенд правильно скоупнул на Latin/Cyrillic, на кану НЕ распространять. Таблица — exp09 §6-bis.
|
||||
|
||||
**6. Вебновелл-срез (`webnovel_slice.py`):** одна команда по появлению корпуса в `data/samples/webnovel/<lang>/*.txt` → r-коэффициенты (token_calc) + частота DeepSeek-эха вне претрейна + coverage-precision на терсных репликах (по плотности диалога). Блокируется корпусом graceful (exit 0 + инструкция куда класть файлы).
|
||||
|
||||
### [ПИНГИ ОРКЕСТРАТОРУ / БЭКЕНДУ / ВЛАДЕЛЬЦУ] (09.07)
|
||||
|
||||
- **[БЭКЕНД, координация] D16 лендится ПРЯМО СЕЙЧАС параллельно.** На момент моей работы `memory.go` показывал `M` с бампом `memoryMatchVersion`→`memmatch-v3` (D16.2 sticky-inherit + D16.3 phonetic-srcboundary), НО изменилась ПОКА только строка-константа/коммент — логика (`dispositionFor` sticky-ветка, `suppressContained` word-boundary) ещё в полёте. Моё зеркало уже целит контракт v3 (наследование disposition). **Пере-сверить зеркало с финальным Go после лендинга логики** (я на это оставил TODO в `memory_eval.py`).
|
||||
- **[БЭКЕНД/ОРКЕСТРАТОР] D16.3 на кану НЕ распространять** — эмпирически подтверждено (kana TP 15→1 из-за kana-частиц; в японском нет пробелов). Текущее скоупирование на Latin/Cyrillic верно. Для homograph-класса каны (длино-инвариантен) — отдельная митигация (POS/known-word denylist или AMBIGUOUS для kana-only name-ключей вне ruby), вход в B6.
|
||||
- **[ВЛАДЕЛЕЦ] `MISTRAL_API_KEY` уже в наборе** (в промте значился отсутствующим — ты добавил; спасибо, разблокировало D14.2 полностью). Осталось: explicit-фрагменты 18+ (gitignored) для L2-erotica/danmei/L3 руки exp02 — единственный critical D14.4.
|
||||
- **[ОРКЕСТРАТОР] Внешняя перезапись git-истории** (reset→cherry-pick «Add MISTRAL_API_KEY») в ходе сессии стёрла мои незакоммиченные правки 3 tracked-файлов (declmetric/memory_eval/providers.json) — переприменил; результаты прогонов (gitignored `data/`) уцелели. Если это была твоя операция — учти, что рабочее дерево полигона было «грязным».
|
||||
- **[ОРКЕСТРАТОР] Pearmut-вердикт (§10 exp09): своё тонкое BWS**, не Pearmut (у него нет BWS). Решение по инструменту закрыто, реализация — с корпусом+аннотаторами.
|
||||
|
||||
> **Сообщение от основной сессии (04.07, после ревью твоих экспериментов 01–02).** Работа принята, качество высокое. Важные вводные:
|
||||
> 1. **Архитектурные доки обновлены до v2** (~05:08, после адверсариального ревью) — если читал их до этого, перечитай `architecture/01-decisions.md`: экономика теперь разделена по контурам «чей ключ» (Р5), премиум-микс с Sonnet в ru-контуре недоступен, batch — только для батчуемых стадий. Твой пересчёт COGS из эксперимента 01 отлично ложится в контур «прямые ключи»; при случае добавь строку-оговорку про ru-агрегаторский контур (×2–6 на флагманы).
|
||||
> 2. Твои **коридоры len_ratio из эксперимента 01 — прямой вход в coverage-гейт v1** бэкенда (Р7/план Фазы 1). Когда сузишь их по факту прогонов — зафиксируй в experiments-доке итоговые пороги отдельной таблицей, я заберу их в конфиг гейта.
|
||||
|
|
@ -537,6 +592,18 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор
|
|||
> 4. Опциональная задача в бэклог (из Р5/Р10 риск №6, понадобятся ключи владельца): **эмпирическая проверка, пробрасывают ли ru-агрегаторы (ProxyAPI/VseGPT/AITunnel) cache-hit тарифы DeepSeek** — два одинаковых запроса подряд, сравнить usage/стоимость. Это разблокирует экономику ru-контура.
|
||||
> 5. Договорённость по журналу: фиксируй здесь краткие итоги каждого эксперимента (1–2 строки + ссылка на док) — основная сессия читает эту секцию, а не всю папку experiments.
|
||||
|
||||
### 2026-07-09 (доп.) — ПЕРВЫЕ измерения на РЕАЛЬНОЙ вебновелле + en→ru проба (владелец дал корпус)
|
||||
|
||||
Владелец дал 蛊真人 (*Reverend Insanity*, снята с Qidian) в чистой вычитанной .txt (GB18030) и *Empire of the Dawn* Кристоффа (en, книга 3 — подлинность подтверждена файлом+сетью, St. Martin's, релиз 04.11.2025). Активирована Задача 6 на реальном тексте (не претрейн-классике — закрытие методдыры §V1.6/07-review):
|
||||
|
||||
- **Вебновелл-срез `webnovel_slice.py` (7 глав 蛊真人, 16 чанков, deepseek):**
|
||||
- **B — DeepSeek-эхо: 4/16 = 25% РЕАЛЬНОГО эха** (выход 80–83% CJK — исходник вместо перевода), **при thinking-ON**. Vs претрейн-классика 13/24 (54%): вне претрейна эхо вдвое реже, но **25% — живой продовый риск**; thinking-ON её НЕ снимает полностью → downstream эхо/coverage-гейт load-bearing. Верифицировано вручную (не артефакт классификатора).
|
||||
- **C — coverage-гейт: 0 FP/16** (precision держится, как exp07 0/57). ⚠ Но терсных-диалоговых чанков не поймалось (в .txt реплики склеены в абзацы , не строками) → precision гейта на ТЕРСНЫХ репликах этим срезом НЕ закрыт; нужен диалог-разбитый вход.
|
||||
- **A — r-коэффициенты:** DeepSeek V3.2 ~0.75 ток/CJK-симв, GPT-o200k ~0.90 — тот же порядок, что классик-калибровка exp01 → COGS exp08 индикативно держится (`token_calc_webnovel.json` — точный diff).
|
||||
- **en→ru проба Кристоффа (короткий отрывок, приватная калибровка — текст книги в проект НЕ сохранён):** deepseek fidelity **90**, grok **85**; оба `ok`, живой литературный русский. Судья поймал ровно то, что чинит банк памяти: **серийные термины** («coldblood» = обращение в мире серии, оба перевели как «хладнокровный») + имена (Dyvok/Lachie). ⇒ **банк памяти нужен и на en→ru**, не только zh/ja.
|
||||
|
||||
Пинги: **[БЭКЕНД] (1)** DeepSeek-эхо 25% на вебновелле при thinking-ON — эхо-мина живее, чем «13/24» на классике; downstream-детект эха обязателен. **(2)** реальные zh .txt часто **GB18030**, не UTF-8 — ingest должен детектить/конвертить кодировку. **[ОРКЕСТРАТОР]** en→ru тоже выигрывает от глоссария (серийные коины) — учесть в дизайне банка для en-книг.
|
||||
|
||||
### 2026-07-04 — Сессия 1 полигона (ответ на вводные выше — приняты)
|
||||
|
||||
Инфраструктура: `eval/` (venv, токенизаторы DeepSeek V3.2/Qwen3/Qwen2.5/GLM-4.6 + tiktoken), PD-корпус 16 текстов zh/ja/en/ru с 3 параллельными парами. Ключи — `eval/.env` (gitignored, закрыт deny-правилом; скрипты читают сами; шаблон на 7 провайдеров создан, ждёт заполнения владельцем).
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@
|
|||
|
||||
Плюс порог покрытия предложений sent_cov < 0.75 (не сработал ложно ни разу). Оговорки: выборка — 11 литературных фрагментов × 6 провайдеров; нижняя граница en→ru задана локальной моделью (0.79 — она переводит суше); на вебновелльном корпусе пороги перепроверить. Коридоры уже зашиты в `EXPECT_LEN_RATIO` (`eval/refusal_bench.py`) — основная сессия может забирать таблицу в конфиг гейта как v1.
|
||||
|
||||
## Mistral — проба канала B (D14.2, 2026-07-09)
|
||||
|
||||
DeepSeek выведен из explicit-части канала B (ToS §3.4(5), D14.1) → Mistral — кандидат-фолбэк. Заведён в `providers.json` (`mistral-large-2512`, слаг сверен вживую `/models`; `safe_prompt:false` явно; OpenAI-совместимый). `MISTRAL_API_KEY` **появился в наборе** (владелец добавил — в промте значился как отсутствующий).
|
||||
|
||||
**Refusal-срез SFW/L1/L2-violence (11 фрагментов, populated levels):**
|
||||
|
||||
| Уровень | ok | content_refusal | excision | echo |
|
||||
|---|---|---|---|---|
|
||||
| L0 clean | 6/6 | 0 | 0 | 0 |
|
||||
| L1 warm | 4/4 | 0 | 0 | 0 |
|
||||
| L2 violence | 1/1 | 0 | 0 | 0 |
|
||||
|
||||
**ZERO отказов/вырезаний/эха на всём доступном срезе.** Explicit-часть (L2-erotica/danmei, L3) — пусто в корпусе (гейтится фрагментами владельца; та же 18+ рука, единственный critical D14.4).
|
||||
|
||||
**Базовое качество ru-перевода (индикатив, 2–3 PD-фрагмента, судья gemini чужого семейства):** zh(А-Кью) fidelity 85 / ja(Мелос) 95 / en(Уэллс) 90; все `ok`, `cjk_share=0` (эхо-мины НЕТ). Имена-мистрансы на zh — БЕЗ глоссария (ровно то, что чинит банк). **Вывод: Mistral годен как переводчик канала B** (не только «не отказывает»). Граббля: на en выдал преамбулу «Вот перевод фрагмента:» — при боевом использовании усилить промпт/стрип. Цена ~$0.50/$1.50 за 1M (API-pricing 07-09).
|
||||
|
||||
## Что сделано
|
||||
|
||||
1. **Прогонный скрипт `eval/refusal_bench.py`** — конфигурируемые OpenAI-совместимые провайдеры (`eval/providers.json`: DeepSeek, Qwen intl, Grok, GLM z.ai, Gemini OpenAI-слой c safety OFF через extra_body, OpenAI, OpenRouter, локальная ollama), ключи только из env, резюмируемость (пере-запуск дописывает недостающее). Промпт — режим перевода с явным transformation-фреймингом («перевод предоставленного правообладателем текста, не генерация»), т.е. меряем именно то, что будет делать продукт.
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@
|
|||
- **Короткие фрагменты** (по одному отрывку) — срез, не приговор моделям в целом; полный выбор ядра пайплайна — пилот Фазы 2.5 (задача 4).
|
||||
- Судьи поймали реальный дефект данных: версия C (Gemini) на «Мелосе» была **обрезана** (бюджет 8k при обязательном thinking) — **исправлено** после оценки (перегенерирована с 16k, артефакт обновлён); вывод по «Мелосу» (лидер A) от этого не меняется.
|
||||
- «Якорь ≠ эталон»: оба судьи отметили места, где английский якорь сам упрощает оригинал (напр. оттенок «жалостливо усмехнуться» у Дадзая) — подтверждает методическое следствие ниже.
|
||||
- **⚠ Слепота задним числом сломана (сноска D13.5, 2026-07-09; историю не переписываем).** Оценку вынесли LLM-судьи (GPT-фронтир + Opus), фетчащие артефакт целиком. А в том же артефакте лежали (а) **ключ «метка→модель»** (строка «Ключ слепой разметки» выше) и (б) **цена/латентность** — за `<details>`, что для DOM/markdown-фетча НЕ скрытие; плюс маппинг A/B/C/D был **зафиксирован одинаково на всех 4 фрагментах**. Значит слепота LLM-судейского плеча **структурно нарушена** (07-review §4.5, verified). ⇒ **Выбор `grok-4.3` дефолтным редактором остаётся ПРОВИЗОРНЫМ** — из этого плеча его нельзя считать чисто-слепым результатом. Что страхует выбор независимо от слепоты: **объективные латентность/цена** (grok — самый быстрый/дешёвый; модель-независимые факты, не подверженные предвзятости судьи) и **руки пилота Ф2.5** — мономодельный бейк-офф редактора (exp09 §7-bis, реальная моно-задача без исходника) + арм «моно-vs-билингв на одной модели» (D13.1). Инструмент починен: `build_editor_artifact.py` теперь рандомизирует метки ПО ФРАГМЕНТУ (соль в ключ-файле) и пишет ключ+цену в ОТДЕЛЬНЫЙ (непубликуемый до оценки) файл `--key` — будущие оценки слепы структурно.
|
||||
|
||||
## Методическое следствие (важно для пилота, задача 4)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
# Эксперимент 09. Протокол пилота Фазы 2.5 — выбор ядра, memory-bet, судья, think-режим
|
||||
# Эксперимент 09. Протокол пилота Фазы 2.5 — выбор ядра, memory-bet, судья, think-режим (v2, D13)
|
||||
|
||||
**Дата:** 2026-07-05 · **Зона:** полигон (флагман) · **Статус:** протокол + харнесс-скелет; решающий прогон ждёт корпус владельца + человеческих аннотаторов.
|
||||
**Дата:** 2026-07-05 · **v2:** 2026-07-09 (поправки D13, починка харнесса, Pearmut-вердикт) · **Зона:** полигон (флагман) · **Статус:** протокол + харнесс (memory_eval починен и валидирован индикативом); решающий прогон ждёт корпус владельца + человеческих аннотаторов.
|
||||
**Закрывает решения:** выбор ядра C0–C3 (Р2), go/no-go на банк памяти (research/13 §Вердикт), валидность LLM-судьи (research/11-gap-3), think-ON/OFF (D6), ценность пре-пасса Annotator (04-unhappy).
|
||||
|
||||
Пилот — единственная точка, где сходятся все отложенные «решаемые только человеком» вопросы качества. Он же — **дециждающий эксперимент банка памяти** (ставка не морозится до него, вердикт GO-на-схему был условным).
|
||||
|
||||
> **⚠ Разведение имён (D13.4, устраняет коллизию exp09):** **C0–C3 = конфигурации ЯДРА** (§3, baseline/draft→editor/generate-select/select-refine). **M0–M3 = режимы ТЕРМИНОЛОГИИ memory-eval** (§6, no-gloss/selective-bank/full-context/wrong-gloss). Раньше оба назывались C0–C3 — риск путаницы в пре-регистрации; исправлено в доке, харнессе (`memory_eval.py`), данных и pre-registration §12.
|
||||
|
||||
## Сводка поправок v2 (D13, 2026-07-09)
|
||||
|
||||
- **§3a Новый арм D13.1** — моно-vs-билингв редактор на ОДНОЙ модели, простой general-промпт (прямой тест D1; отдельно от 7-bis бейк-оффа моделей).
|
||||
- **§3b C2 только на ГЕТЕРОГЕННЫХ кандидатах (D13.2)** — draft'ы от разных моделей, не N сэмплов одной (иначе селекция ≈ монета, плечо предрешено).
|
||||
- **§5 Панель судей (D13.3)** — 2–3 семейства; 11+ повторов со свапом позиций; Bradley-Terry/медиана; grok не судит grok-выходы; валидация судьи — κ vs человеческий IAA + tie-rate/top-1.
|
||||
- **§6 memory-eval починен (D13.5)** — эхо-контроль, полные выходы, **ось верности реализована**, C0–C3→M0–M3, зеркало синхронизировано с Go по 7 расхождениям; индикатив пере-прогнан (§6-результаты).
|
||||
- **§9/§12 Пре-регистрация расширена (D13.4)** — MDE/мощность, N аннотаторов ≥3 (иначе «безκ-режим»), правила решения по каждой руке — неизменяемый каркас §12.
|
||||
- **§10 Инструмент — Pearmut оценён (D13.5): НЕ берём как BWS-движок** (у него нет BWS), строим тонкий свой + крадём его attention-checks. Вердикт §10.
|
||||
|
||||
## 1. Что пилот решает (и почему только он)
|
||||
|
||||
| Решение | Почему не решено раньше | Рука пилота |
|
||||
|
|
@ -22,7 +33,7 @@
|
|||
- **Рекуррентные имена/термины** обязательны (≥8 сущностей, повторяющихся через главы) — иначе memory-eval нечего мерить.
|
||||
- **⚠ Методическое ограничение (эксп.04): владелец читает только en/ru.** → человеческая оценка **ВЕРНОСТИ** возможна лишь на **en→ru** (и русская сторона стиля любого перевода). Для zh/ja верность оценивается: (а) **английским якорь-подстрочником** (профессиональный en-перевод как мост), (б) LLM-судьёй чужого семейства против исходника, (в) сверкой с приватным русским эталоном. Заложено в дизайн (§4–5).
|
||||
|
||||
Пока корпуса нет — индикативный прогон на PD-Ah-Q (`eval/pilot/memory_eval.py`) валидирует харнесс и decl-метрику.
|
||||
Пока корпуса нет — индикативный прогон на PD-Ah-Q (`eval/pilot/memory_eval.py`) валидирует харнесс и метрики; **вебновелл-срез добора** (r-коэффициенты/эхо/coverage-precision вне претрейна) — `eval/webnovel_slice.py`, одна команда по появлению файлов в `eval/data/samples/webnovel/`.
|
||||
|
||||
## 3. Дизайн ядра (C0–C3, Р2)
|
||||
|
||||
|
|
@ -30,50 +41,93 @@
|
|||
|
||||
- **C0 baseline** — один проход переводчика (+ детерминированный глоссарий = уровень VseGPT; baseline метрики, PROGRESS §синтез).
|
||||
- **C1 draft→editor** — черновик → моноязычный редактор (D1). Дефолт Ф1.
|
||||
- **C2 generate-then-select** — N черновиков → судья-селектор выбирает лучший (arXiv:2603.20324). Висит на калибровке s\* и валидности судьи (§5).
|
||||
- **C2 generate-then-select** — N черновиков → судья-селектор выбирает лучший (arXiv:2603.20324). Висит на калибровке s\* и валидности судьи (§5). **D13.2: кандидаты ГЕТЕРОГЕННЫ** — draft'ы от РАЗНЫХ моделей (deepseek/glm/kimi/mistral), НЕ N сэмплов одной. Основание: на гомогенных селекция ≈ монета (2603.20324 WR 0.512; LitMT 2606.05924 best-of-8+судья — последнее место). На гомогенных C2 предрешён и жжёт бюджет впустую.
|
||||
- **C3 select-then-refine** — селект + точечный re-ask/refine на выбранном (verifier-gated, research/14 M2; рычаг L3 эксп.adaptive_reask).
|
||||
|
||||
Каждая конфигурация × {think-OFF, think-ON} (§7) × {Annotator-pre-pass OFF/ON} (§8) — факторный, но приоритезированный (полный крест дорог; ядро × think — первично, Annotator — вторичный A/B на подвыборке).
|
||||
|
||||
### 3a. Арм D13.1 — моно-vs-билингв редактор на ОДНОЙ модели (прямой тест D1)
|
||||
|
||||
Отдельная рука, **не путать с 7-bis** (тот — бейк-офф РАЗНЫХ моделей на моно-редактуре). Здесь одна модель (grok-4.3) правит черновик в двух режимах:
|
||||
- **моно**: вход = только русский черновик + глоссарий-констрейнты (боевой D1, без исходника);
|
||||
- **билингв general**: вход = исходник + черновик + ПРОСТОЙ general-промпт «улучши, не переври».
|
||||
|
||||
**Основание (верифицировано по PDF arXiv:2605.13368, вкл. en→ru):** monolingual-рефайнмент теряет accuracy с ПЕРВОГО прохода (−2.2…−5.6 MQM у всех 6 моделей); general-рефайнмент С исходником даёт тот же fluency при accuracy ~flat и лучшем overall. **Вход в решение (D17):** если моно даёт паритет верности — D1 подтверждён дёшево; если налог на верность воспроизводится — флип на «редактор с исходником, простой промпт» (дешевле любой альтернативной митигации калек). Метрики те же (§4 BWS-стиль + §6 decl-консистентность + верность через сверку с эталоном/якорем).
|
||||
|
||||
## 4. Человеческая оценка — Best-Worst Scaling (BWS)
|
||||
|
||||
**Почему BWS, не шкала Ликерта:** BWS (выбери ЛУЧШИЙ и ХУДШИЙ из набора) даёт более надёжные, менее шумные ранги, чем абсолютные оценки (устойчив к разнице калибровки аннотаторов) — стандарт для сравнения близких систем. LAIT показал: абсолютные оценки судей плывут, ранги — надёжнее.
|
||||
|
||||
- **Единица:** абзац/короткий чанк (не вся глава — усталость аннотатора). Наборы из 3–4 систем (C0–C3, или think-ON vs OFF пары).
|
||||
- **Слепота:** аннотатор не видит, какая система какой выход дал (рандомизация порядка + меток).
|
||||
- **Слепота:** аннотатор не видит, какая система какой выход дал (рандомизация порядка + меток) — **и рандомизация ПО НАБОРУ, ключ вне артефакта** (урок exp04, D13.5: за `<details>` для фетча не скрытие).
|
||||
- **Оси оценки (раздельно, НЕ смешивать):**
|
||||
- **Стиль/естественность** (русская сторона) — владелец/русскоязычный аннотатор, любой языковой парой (не нужен исходник).
|
||||
- **Верность/полнота** — только en→ru напрямую; для zh/ja — через en-якорь-подстрочник (§2). Отдельная BWS-задача с исходником/якорем на виду.
|
||||
- **N:** ≥3 аннотатора на набор для inter-annotator agreement (κ); при низком κ — расширять, не усреднять шум.
|
||||
- **N и κ (D13.4):** ≥3 аннотатора на набор для inter-annotator agreement (κ); при низком κ — расширять, не усреднять шум. **Если аннотатор реально один (владелец) — κ неисчислим → это ЯВНЫЙ «безκ-режим»** (пишем честно, не выдаём одиночную оценку за согласованную).
|
||||
- **Мощность/MDE (D13.4):** число BWS-наборов пред-регистрируется под целевой MDE (минимально-детектируемую разницу рангов) — иначе «нет разницы» неотличимо от «мало мощности». См. §12.
|
||||
- **Стилевая ось для M1** (in-context демонстрации, research/14): бьёт ли демо-рука стиль БЕЗ потери консистентности/fidelity (индикатив: по консистентности не бьёт; стиль не мерен).
|
||||
|
||||
## 5. LLM-судейская панель (валидация + калибровка)
|
||||
## 5. LLM-судейская панель (валидация + калибровка, D13.3)
|
||||
|
||||
Судья держит две вещи: (а) **выбор в C2/C3** (селектор), (б) **валидацию качества** offline. Обе непроверены на домене → пилот их валидирует, не принимает на веру.
|
||||
|
||||
- **Панель чужих семейств** (анти-self-preference): Gemini-3.1-Pro (нативный, safety-off для 18+), grok-4.3, gpt-5-mini — судят выходы deepseek/grok. Никогда не судить моделью своего семейства свой выход.
|
||||
- **Панель — 2–3 семейства МАКСИМУМ (D13.3а).** Не 9 судей: 9 ≈ 2 эффективных голоса (2605.29800), лучший ОДИНОЧНЫЙ судья ≥ панели. Кандидаты чужих семейств (анти-self-preference): Gemini-3.1-Pro (нативный, safety-off для 18+), grok-4.3, gpt-5-mini.
|
||||
- **11+ повторов на вердикт со свапом позиций A/B (D13.3б).** Одиночный прогон нестабилен (13.6% флипов; парафраз меняет исход в 25%; 2606.13685). Позиции A/B свапаются, вердикт агрегируется по повторам.
|
||||
- **Агрегация — медиана / Bradley-Terry (D13.3в)**, НЕ среднее (JP-TL-Bench-паттерн).
|
||||
- **grok НЕ судит grok-редактированные выходы (D13.3г)** — нарушение собственного анти-self-preference правила. Реализуется на уровне харнесса (family-guard; тот же принцип, что cross-family fidelity-судья в memory_eval — `family_of(judge) != family_of(translator)`).
|
||||
- **Валидация судьи — κ vs человеческий IAA + tie-rate/top-1 within-prompt (D13.3д)**, НЕ средняя корреляция (2603.12520). Метрики: доля совпадений top-1 судья↔человек ВНУТРИ набора, tie-rate; Kendall τ судья↔человек на BWS-ранге как вторичная.
|
||||
- **Шкала Комиссарова** (анкета MQM-весов, 04-unhappy): mistranslation / omission / addition / терминология / согласование рода / стиль-канцелярит.
|
||||
- **Валидация #1 — корреляция судья↔человек** на BWS-ранге (LAIT: расходятся). Если ранговая корреляция (Kendall τ) судья↔человек низка → судья не годен как прокси, C2/C3 под вопросом.
|
||||
- **Валидация #2 — калибровка порога s\*** (на нём вся ставка generate-select, arXiv:2603.20324): при каком судейском скоре «выбрать/переспросить» максимизируется человеческий win-rate. Пред-регистрировать s\* ДО финального прогона.
|
||||
- **Cross-family fidelity-судья vs источника** — для memory-eval (§6, ось б).
|
||||
- **Калибровка порога s\*** (на нём вся ставка generate-select, arXiv:2603.20324): при каком судейском скоре «выбрать/переспросить» максимизируется человеческий win-rate. **Пред-регистрировать s\* ДО финального прогона** (§12).
|
||||
|
||||
## 6. Memory-eval — дециждающий эксперимент банка (WMT25 три-режимный)
|
||||
## 6. Memory-eval — дециждающий эксперимент банка (WMT25 три-режимный; M0–M3)
|
||||
|
||||
**Вопрос (страх владельца + go/no-go):** оправдана ли ДЕТЕРМИНИРОВАННАЯ селективная инъекция (наш банк) vs просто весь глоссарий в длинном контексте, и **вредит ли ошибочная инъекция** (тихая деградация).
|
||||
|
||||
**Три режима терминологии (WMT25) × baseline:**
|
||||
- **C0** без глоссария; **C1** селективная инъекция (наш банк, §ниже); **C2** полный глоссарий + скользящее резюме (длинный контекст); **C3** случайный/неверный глоссарий (dst перемешан) — **адверсариальная рука, прямой замер тихой деградации**.
|
||||
**Три режима терминологии (WMT25) × baseline — переименованы M0–M3 (D13.4):**
|
||||
- **M0** без глоссария; **M1** селективная инъекция (наш банк); **M2** полный глоссарий + скользящее резюме (длинный контекст); **M3** случайный/неверный глоссарий (dst перемешан) — **адверсариальная рука, прямой замер тихой деградации**.
|
||||
|
||||
**Инъекция C1 = ЗЕРКАЛО Go-горячего-пути** (`backend/internal/pipeline/memory.go` — ИСТОЧНИК ИСТИНЫ): нормализованный точный матч, per-язык min-key (Han≥2/фонетич.≥3), collision-prone короткий фонетический ключ → AMBIGUOUS, longest-match containment, спойлер-окно since/until (hard-reject), sticky scene-inertia. Сверено с Go-юнит-тестами; при расхождении — **верить Go**. `eval/memory_hotpath.py` — прототип ДО `cc57c7b` (глобальный MIN_KEY=2, без collision-downgrade) — **не переиспользовать**; актуальный контракт — в `eval/pilot/memory_eval.py`.
|
||||
**Инъекция M1 = ЗЕРКАЛО Go-горячего-пути** (`backend/internal/pipeline/memory.go` — ИСТОЧНИК ИСТИНЫ). Синхронизировано D13.5 по 7 расхождениям (07-review §4.5): sticky depth=2 union+reset; until_ch-гейт; span-containment со спойлер-гейтом суппрессора; токен-бюджет+eviction; **sticky-диспозиция НАСЛЕДУЕТСЯ** (D16.2 — collision-prone AMBIGUOUS не апгрейдится sticky'ем в CONFIRMED; Go бампнул `memmatch-v3`, лендится 09.07); полная вендоренная trad2simp-таблица (508, hash-синк); ignorables=Cf+VS-диапазоны. Han-скрипт по ВСЕМ плоскостям, significantLen по Unicode-letter (закрыты дельты адверсариального ревью — supplementary-plane имена). D16.3 source-граница Go скоупнута на **Latin/Cyrillic** и НЕ применяется к кане (см. kana-precision §6-bis). **При расхождении — верить Go.**
|
||||
|
||||
**Метрики (три оси раздельно):**
|
||||
- **(а) консистентность** — decl-осознанная (`eval/pilot/declmetric.py`, pymorphy3): одинаково ли рендерится approved-dst во всех чанках, где встречается src. **ОБЯЗАНА быть decl-осознанной** — наивный матч даёт 18–67% ложных флагов (research/14 §2; backend memory_e1_test.go full-decl 0% / base-only 67%). Заодно — **валидация E1-надёжности post-check на РЕАЛЬНОМ корпусе** (жёсткий гейт `postcheck_gate` флипается только после этого замера precision на живых книгах — mirror D12/Q4).
|
||||
- **(б) верность/пропуски** — CometKiwi (валидировать на ru-литературе — не подтверждён, research/11-gap-2) + LLM-судья чужого семейства против источника + сверка с эталоном. ru-специфика: согласование рода (вкл. глагол прош.вр.), склонение dst.
|
||||
- **(в) стоимость** — input/output токены по условиям (C2 заметно дороже — часть решения; эксп.08 цены).
|
||||
- **(а) консистентность** — decl-осознанная (`eval/pilot/declmetric.py`, pymorphy3 + Go-style stored-decl): одинаково ли рендерится approved-dst во всех чанках, где встречается src. **ОБЯЗАНА быть decl-осознанной** — наивный матч даёт 18–67% ложных флагов (research/14 §2; backend memory_e1_test.go full-decl 0% / base-only 67%). Заодно — **валидация E1-надёжности post-check на РЕАЛЬНОМ корпусе** (жёсткий гейт `postcheck_gate` флипается только после этого замера precision на живых книгах).
|
||||
- **(б) верность/пропуски — РЕАЛИЗОВАНА (D13.5, была дырой):** LLM-судья ЧУЖОГО семейства против источника (family-guard `family_of(judge)!=family_of(translator)`; mistranslation/omission/addition/wrong_names → JSON). Без неё пред-регистрированное правило «M3 роняет vs M0 по верности» непроверяемо. CometKiwi — опционально и только с оговоркой «на ru-литературе не валидирован» (research/11-gap-2), НЕ подключён.
|
||||
- **(в) стоимость** — input/output токены по режимам.
|
||||
|
||||
**Пред-регистрированное правило решения:** C1≈C2 по качеству но C1 дешевле → банк оправдан; C2≫C1 → нужен полнее контекст; **C3 роняет vs C0 по верности → страх владельца подтверждён, post-check/disposition обоснованы** (и мы их конвертируем в громкое, а не устраняем).
|
||||
**Эхо-контроль (D13.5):** модель эхает глоссарий-преамбулу («ГЛОССАРИЙ … 阿Q → А-кью …») перед переводом; скоринг всего выхода тогда считает эхнутые dst «отрендеренными» → M-режимы завышены, вред M3 занижен (ревью проследил chunk1-M3 надут 0.667→1.0). Теперь: детект преамбулы/source-эха (переиспользован CJK-классификатор refusal_bench) → стрип до тела перевода → **флаг echo_contaminated + исключение из чистого агрегата** (опц. `--regen-on-echo` переспрашивает с усиленным промптом) — не тихий стрип.
|
||||
|
||||
**Спойлер-рука:** отдельный замер — инъектится ли спойлер-запись (since_ch) до её главы (должна hard-reject) — валидировано в харнессе (глава 6 отвергает 革命党, глава 7 инъектит).
|
||||
**Пред-регистрированное правило решения:** M1≈M2 по качеству но M1 дешевле → банк оправдан; M2≫M1 → нужен полнее контекст; **M3 роняет vs M0 по ВЕРНОСТИ → страх владельца подтверждён, post-check/disposition обоснованы** (и мы их конвертируем в громкое, а не устраняем).
|
||||
|
||||
### 6-результаты. Индикатив на Ah-Q (пере-прогнан 2026-07-09 ПОСЛЕ починки; grok-4.20-non-reasoning, судья gemini-2.5-flash, 5 чанков)
|
||||
|
||||
| Режим | consistency (decl) | **fidelity (судья)** | in_tok (ср.) | echo |
|
||||
|---|---|---|---|---|
|
||||
| **M0** без глоссария | 0.567 | 94.6 | 1155 | 0/5 |
|
||||
| **M1** селективный банк | **1.0** | 93.4 | 1237 | 0/5 |
|
||||
| **M2** полный глоссарий | **1.0** | 93.4 | 1260 | 0/5 |
|
||||
| **M3** неверный глоссарий | 0.467 | **49.0** | 1218 | 0/5 |
|
||||
|
||||
**Чтение (все пред-регистрированные правила разрешились чисто):**
|
||||
1. **M1≈M2** (consistency 1.0=1.0, fidelity 93.4=93.4), M1 дешевле по input (1237<1260; разрыв растёт с полной книжной глоссарией) → **банк оправдан** vs длинный контекст.
|
||||
2. **M3 роняет ВЕРНОСТЬ: 49.0 vs M0 94.6 (−45.6)** → страх владельца ПОДТВЕРЖДЁН количественно. Судья ловит: 阿Q (А-Кью) отрендерен как «Вэйчжуан» (имя человека → топоним) — модель ПОСЛУШАЛАСЬ неверной инъекции.
|
||||
3. **Ключевое: M3 consistency (0.467) ≈ M0 (0.567)** — по консистентности M3 выглядит как baseline, вред НЕВИДЕН. Его ловит ТОЛЬКО ось верности → почему её реализация (D13.5) была решающей, а старый харнесс давал ложный «вред меньше».
|
||||
|
||||
> **⚠ Старые числа КОНТАМИНИРОВАНЫ, не использовать:** до починки харнесс показывал «C1 1.0 = C2 1.0, C3 0.60» (только консистентность; C3 chunk1 надут эхом до 1.0; оси верности НЕ было). Индикатив на PD-классике из претрейна — предварительный; решающий замер — на вебновелл-корпусе владельца + человеческий BWS.
|
||||
|
||||
### 6-bis. Kana-precision (D16-хвост, `eval/pilot/kana_precision.py`, 2026-07-09)
|
||||
|
||||
Замер precision матчера на ja-kana ключах при MIN=2/3/4 (вход в прод-ja решение: minKeyLenPhonetic=3 был «консервативным дефолтом до замера»). Trap-корпус (`kana_traps.json`): имена из PD-ja сэмплов (メロス/おさん/…) + коллизии, размеченные по типу (substring vs homograph).
|
||||
|
||||
| MIN (substring-матчер = текущий Go) | TP_fire | FP всего | FP substring | FP homograph | **FP CONFIRMED** | precision | conf-precision |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 2 | 15 | 40 | 26 | 14 | 22 | 0.273 | 0.371 |
|
||||
| **3 (дефолт)** | 13 | 22 | 8 | 14 | **8** (все len-4, 7 homograph) | 0.371 | 0.429 |
|
||||
| 4 | 6 | 8 | 1 | 7 | 0 | 0.429 | 1.0 |
|
||||
|
||||
**Выводы:**
|
||||
1. **Подтвердить MIN=3 как дефолт каны.** Он гасит len-2 substring-шум (substring-FP 26→8, precision 0.273→0.371), СОХРАНЯЯ 3-kana имена (メロス/ゼウス/おさん — реальные корпус-имена). MIN=4 даёт conf-precision 1.0, НО роняет recall (TP 13→6 — банит легитимные 3-kana имена, большой класс в ja).
|
||||
2. **collision-downgrade (≤3→AMBIGUOUS) реально работает:** при MIN=3 len-3 фаерятся AMBIGUOUS (софт, ⟨проверить⟩+forced post-check), CONFIRMED только len≥4 → опасные авторитетные FP = 8 штук len-4.
|
||||
3. **Потолок precision — класс HOMOGRAPH** (имя == целое частое слово: ひまわり=подсолнух, あさひ=утреннее солнце, ひかる=光る), ДЛИНО-ИНВАРИАНТЕН — MIN-порог его НЕ гасит (это полный substring на любой длине). Митигация ортогональна: POS/known-word гейтинг или требование ruby/kanji-якоря, или даунгрейд kana-only name-ключей в AMBIGUOUS вне зависимости от длины. Вход для B6 — оркестратору/бэкенду.
|
||||
4. **D16.3 source-граница на кане КАТАСТРОФИЧНА (эмуляция):** TP 15→1 (14 реальных имён подавлены — за именем идёт kana-частица は/が, нет kana-run границы). Бэкенд правильно скоупнул D16.3 на Latin/Cyrillic; **на кану НЕ распространять** (в японском нет пробелов между словами). Замер это подтверждает.
|
||||
|
||||
## 7. Think-ON vs OFF (D6, встроено в ядро)
|
||||
|
||||
|
|
@ -84,11 +138,11 @@
|
|||
|
||||
## 7-bis. Моно-редактор бейк-офф (F8 — доп-рука, из caveat эксп.08:92)
|
||||
|
||||
**Зачем:** эксп.04 выбрал grok-4.3 редактором, кормя его **билингвально** (исходник+черновик), но боевой редактор D1 — **моноязычный** (только черновик, без исходника) → рейтинг grok на моноредактуре **строго не перенесён** (оркестратор свернул caveat в D3/D1). Отдельная рука пилота — валидировать выбор редактора на РЕАЛЬНОЙ моноязычной задаче:
|
||||
**Зачем:** эксп.04 выбрал grok-4.3 редактором, кормя его **билингвально** (исходник+черновик), но боевой редактор D1 — **моноязычный**; рейтинг grok на моноредактуре **строго не перенесён**, а слепота exp04 была структурно сломана (D13.5-сноска exp04) → выбор ПРОВИЗОРЕН. Отдельная рука — валидировать выбор редактора на РЕАЛЬНОЙ моноязычной задаче:
|
||||
- **grok-4.3 моно-редактура** (вход = только русский черновик + глоссарий-констрейнты, БЕЗ исходника) vs кандидаты (gemini-3.1-pro, gpt-5-mini, glm-5) на той же моно-задаче;
|
||||
- крест с think-ON/OFF (§7): reasoning может значить для моноредактуры иначе, чем для билингвальной;
|
||||
- метрика — BWS-стиль (§4, русская сторона, исходник не нужен) + консистентность глоссаря (§6, decl-метрика) + верность через сверку с эталоном (моноредактор не должен «улучшать» смысл прочь от исходника — риск калек убран D1, но проверить, что моноредактор не дрейфит factual);
|
||||
- **вход в решение:** держится ли ранг grok-редактора при переходе билингв→моно; если нет — перевыбрать редактора Ф1 на моно-режиме.
|
||||
- крест с think-ON/OFF (§7); метрика — BWS-стиль (§4) + консистентность (§6) + верность через сверку с эталоном;
|
||||
- **вход в решение:** держится ли ранг grok-редактора при переходе билингв→моно; если нет — перевыбрать редактора Ф1.
|
||||
- **Инструмент слепоты починен:** `eval/build_editor_artifact.py` теперь рандомизирует метки ПО ФРАГМЕНТУ (соль в ключе) и пишет ключ+цену в ОТДЕЛЬНЫЙ файл (`--key`), не публикуемый до оценки.
|
||||
|
||||
## 8. Пре-пасс Annotator (A/B)
|
||||
|
||||
|
|
@ -96,31 +150,53 @@
|
|||
|
||||
## 9. Пред-регистрация (до финального прогона — против p-hacking)
|
||||
|
||||
Зафиксировать ДО прогона: пороги s\*, допустимый flag-volume на главу (эксп.07: ≥2 excision-флага = fail), decl-метрику и её матчер, состав судейской панели, правило решения memory-eval, N аннотаторов и κ-порог. Изменения после — отдельным разделом с обоснованием.
|
||||
Зафиксировать ДО прогона **неизменяемым разделом §12**: пороги s\*, допустимый flag-volume на главу (эксп.07: ≥2 excision-флага = fail), decl-метрику и её матчер, состав судейской панели (§5), правило решения memory-eval (§6), N аннотаторов и κ-порог (§4), **MDE/мощность и число BWS-наборов**, правила решения по КАЖДОЙ руке (C0–C3, D13.1 моно-vs-билингв, think, Annotator). Изменения после — отдельным разделом с обоснованием.
|
||||
|
||||
## 10. Харнесс `eval/pilot/` (построено / нужно)
|
||||
## 10. Инструмент человеческой оценки — Pearmut vs своё (вердикт D13.5)
|
||||
|
||||
**Построено (runnable):**
|
||||
- `declmetric.py` — decl-осознанная консистентность (pymorphy3 lemma + hyphen-translit + Go-style stored-decl whole-word). Smoke: склонённые формы (Вэйчжуане/Вана/Дэна/сюцая) → 0 ложных флагов.
|
||||
- `memory_eval.py` — WMT25 3-режим + bank-vs-long-context, инъекция = зеркало Go-контракта (спойлер/disposition/sticky/containment валидированы dry-run). Индикативный прогон на PD-Ah-Q; масштабируется на корпус владельца (`--src`).
|
||||
**Оценён Pearmut (arXiv:2601.02933, Zouhar & Kocmi, ETH; MIT, `pip install pearmut`, self-host файловый, verified по GitHub/PyPI 2026-07-09).**
|
||||
|
||||
**Нужно достроить (когда есть корпус):**
|
||||
- BWS item-generator + агрегатор (наборы, рандомизация, κ, ранги) → `bws.py`.
|
||||
- CometKiwi-обёртка (+ её валидация на ru-литературе, research/11-gap-2) → `cometkiwi.py`.
|
||||
- Судейская панель обёртка (cross-family, Комиссаров-анкета) → `judge_panel.py`.
|
||||
**Вердикт: НЕ брать Pearmut как BWS-движок; строить тонкий свой BWS-тул + красть attention-checks Pearmut.** Три решающих факта:
|
||||
1. **BWS в Pearmut ОТСУТСТВУЕТ (дилбрейкер).** Его протоколы — DA/ESA/cESA/MQM (скоринг + error-span), НЕ best-worst-из-набора. Наш дизайн (§4) целиком на BWS с рандомизацией меток и агрегацией в ранги — под data-model Pearmut это ломать.
|
||||
2. **Self-host НЕ различает кандидатов** (все self-host'ятся: Pearmut MIT, Potato, Label-Studio Apache-2.0, Appraise BSD) → приватность/18+ проходят все, решает BWS-fit, где Pearmut проигрывает.
|
||||
3. **Наша логика тонкая и это то, что важно:** генерация наборов, слепая рандомизация меток, best-minus-worst (Orme) агрегация, κ, **безκ-режим** — ни один тул не даёт их turnkey; несколько сотен строк. Владение = пришпилить пре-регистрацию (§12) в тот же репо + держать две-строгие-оси (стиль/верность) как инвариант.
|
||||
|
||||
**Практический путь:** нужен нулевой UI-труд → поднять **Potato** (native BWS-схема; ⚠ лицензия MIT-vs-GPLv3 в источниках расходится — проверить `github.com/davidjurgens/potato/LICENSE` перед принятием) как фронт, свой export→агрегация→κ поверх. Нужен полный контроль слепоты/двух-осей/безκ — писать тонкий харнесс напрямую. В обоих — **украсть attention-checks Pearmut** (Loud/Silent/Tutorial — его самая переиспользуемая идея) + doc-level-context.
|
||||
|
||||
## 11. Харнесс `eval/pilot/` (состояние)
|
||||
|
||||
**Построено и валидировано:**
|
||||
- `declmetric.py` — decl-осознанная консистентность (pymorphy3 + Go-style stored-decl); зеркало memnorm.go normalizeTargetForm/containsWholeWord (NFC/dash-fold/ignorable-strip/letter-boundary). Parity-asserts зелёные.
|
||||
- `memory_eval.py` — WMT25 M0–M3 + **fidelity-ось (cross-family судья) + эхо-контроль + полные выходы + провенанс**; инъекция = зеркало Go D13.5 (self-test 12 инвариантов зелёный; адверсариально верифицирован — faithful, дельты supplementary-plane закрыты). Индикатив пере-прогнан (§6-результаты).
|
||||
- `kana_precision.py` (+ `kana_traps.json`) — kana-precision MIN=2/3/4 (§6-bis).
|
||||
- `trad2simp.txt` — вендоренная таблица (508, hash-синк с Go-embed).
|
||||
- `webnovel_slice.py` — одна команда добора вне претрейна (§2; блокируется корпусом, не падает).
|
||||
|
||||
**Нужно достроить (когда есть корпус + аннотаторы):**
|
||||
- BWS item-generator + агрегатор (наборы, рандомизация, best-minus-worst, κ, безκ-режим) → `bws.py` (или Potato-фронт; §10). attention-checks по Pearmut.
|
||||
- Судейская панель обёртка (2–3 семейства, 11+ повторов+свап, Bradley-Terry, family-guard, Комиссаров-анкета) → `judge_panel.py`.
|
||||
- en-якорь-подстрочник pipeline для zh/ja верности.
|
||||
|
||||
## 11. Ограничения и что нужно от владельца
|
||||
## 12. Пред-регистрационный каркас (НЕИЗМЕНЯЕМЫЙ; заполнить ДО решающего прогона)
|
||||
|
||||
- **Корпус** (§2) — блокер решающего прогона: 25–35 глав с приватными эталонами, рекуррентные имена, диалог/нарратив баланс.
|
||||
- **Человеческие аннотаторы** BWS (≥3) — минимум владелец (en/ru); для zh/ja верности — en-якорь.
|
||||
- **18+ рука** (канал B, судья 18+) — explicit-фрагменты только от владельца, gitignored; на выделенном xAI-промо-аккаунте (не клиентские книги).
|
||||
- CometKiwi на ru-литературе не валидирован (research/11-gap-2) — пилот его и валидирует; до того — не доверять как единственной оси верности.
|
||||
> Заполняется ОДИН раз перед решающим прогоном и не меняется; правки — отдельным датированным подразделом с обоснованием (D13.4).
|
||||
|
||||
- **Корпус:** ____ глав, языки ____, ≥8 рекуррентных сущностей: да/нет.
|
||||
- **Аннотаторы:** N = ____ (≥3 иначе «безκ-режим: да»), κ-порог = ____ (низкий → расширять, не усреднять).
|
||||
- **Мощность:** число BWS-наборов = ____ под MDE рангов = ____.
|
||||
- **Судья:** семейства = ____ (2–3), повторов/вердикт = ____ (≥11) со свапом A/B, агрегация = медиана/Bradley-Terry, family-guard: судья ∉ семейства выхода. Валидация = κ vs IAA + tie-rate/top-1.
|
||||
- **s\*:** порог селектора C2/C3 = ____ (калибр §5).
|
||||
- **memory-eval:** правило = M1≈M2&дешевле→банк; M3<M0 верность→страх подтверждён; матчер = decl-осознанный (stored-decl primary, pymorphy cross-check); fidelity-судья = ____ (чужого семейства).
|
||||
- **coverage-гейт:** flag-volume/глава fail-порог = ____ (exp07: ≥2 excision = fail).
|
||||
- **Правила решения по руке:** C0–C3 = ____; D13.1 моно-vs-билингв = ____ (паритет верности→D1; налог→флип); think-ON/OFF = ____ (+25% COGS оправдан?); Annotator A/B = ____.
|
||||
|
||||
## Воспроизведение (индикатив, без корпуса владельца)
|
||||
|
||||
```bash
|
||||
eval/.venv/bin/python eval/pilot/declmetric.py # decl-метрика smoke
|
||||
eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run --chapter 6 # инъекция-зеркало + спойлер-гейт
|
||||
eval/.venv/bin/python eval/pilot/memory_eval.py --chapter 6 --max-chunks 5 # индикативный C0–C3 на Ah-Q
|
||||
eval/.venv/bin/python eval/pilot/declmetric.py # decl-метрика + parity-asserts
|
||||
eval/.venv/bin/python eval/pilot/memory_eval.py --self-test # 12 инвариантов зеркала (без API)
|
||||
eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run --chapter 6 # инъекция-зеркало + спойлер/until/eviction
|
||||
eval/.venv/bin/python eval/pilot/memory_eval.py --chapter 6 --max-chunks 5 --regen-on-echo # индикатив M0–M3 + fidelity
|
||||
eval/.venv/bin/python eval/pilot/kana_precision.py # kana-precision MIN=2/3/4
|
||||
eval/.venv/bin/python eval/webnovel_slice.py # вебновелл-срез (по появлению корпуса)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@
|
|||
|---|---|---|
|
||||
| `refusal_bench.py` | exp02; его `split_sentences`/`classify_output` = **приёмочный оракул** coverage-гейта Go (паритет закреплён Go-тестом; любое изменение — Python-first, лок-степ) | `run_refusal.sh` — однокоманда |
|
||||
| `token_calc.py` | exp01 токен-калибровка (r-множители) | токенизаторы в `tokenizers/` |
|
||||
| `editor_bench.py` + `build_editor_artifact.py` | exp04 бейк-офф редакторов | ⚠ слепота артефакта была сломана (ключ внутри) — при новых оценках ключ ВНЕ артефакта, рандомизация меток по фрагментам (D13.5) |
|
||||
| `editor_bench.py` + `build_editor_artifact.py` | exp04 бейк-офф редакторов | ✅ слепота починена (D13.5): рандомизация меток ПО ФРАГМЕНТУ (соль), ключ+цена в отдельном `--key` файле. Выбор grok-4.3 провизорен — exp04 §Оговорки |
|
||||
| `coverage_precision.py`, `content_filter_probe.py` | exp07 precision гейта (0 FP/57) | |
|
||||
| `cost_model_v2.py` | exp08 COGS (~$0.69/ранобэ) | цены датировать и перепроверять |
|
||||
| `extract_bench.py` | exp06 экстракция терминов (спот=локаль/рендер=облако) | health-верификация каждого ответа — образец |
|
||||
| `retrieval_bench.py` | эмбеддинг-бенч памяти (bge-m3 дефолт) | |
|
||||
| `pilot/declmetric.py`, `pilot/memory_eval.py` | харнесс пилота Ф2.5 (exp09) | ⚠ чинится ДО решающего прогона: стрип эха глоссария, ПОЛНЫЕ выходы (не [:160]), fidelity-ось, имена M0–M3 (D13.5); инъекция = зеркало `backend/internal/pipeline/memory.go` — при расхождении верить Go |
|
||||
| `pilot/declmetric.py`, `pilot/memory_eval.py`, `pilot/kana_precision.py` | харнесс пилота Ф2.5 (exp09) | ✅ починен (D13.5): эхо-контроль, ПОЛНЫЕ выходы, **fidelity-ось** (cross-family судья), M0–M3, Go-синк 7 расхождений (self-test+адверсариально verified). `--self-test`/`--dry-run` без API. Инъекция = зеркало `memory.go` — при расхождении верить Go (D16 лендится — пере-сверить). `kana_traps.json`+`trad2simp.txt` (508, hash-синк) |
|
||||
| `webnovel_slice.py` | добор вне претрейна (r-коэф/эхо/coverage-precision) | одна команда по появлению `data/samples/webnovel/<lang>/*.txt`; блокируется корпусом graceful |
|
||||
| `adaptive_*.py` | research/14 пробы (гибрид отклонён) | индикативные, не решения |
|
||||
| `memory_hotpath.py` | УСТАРЕЛ (контракт до cc57c7b) — не переиспользовать | актуальное зеркало — `pilot/memory_eval.py` |
|
||||
| `providers.json` | базовые URL/слаги для зондов | квирки — `docs/experiments/00-provider-quirks.md`, читать ПЕРЕД любым вызовом |
|
||||
|
|
|
|||
|
|
@ -1,20 +1,40 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Генератор страницы слепого сравнения редакторов (эксперимент 04).
|
||||
Читает eval/data/editor_bench/<fragment>/*.txt, применяет слепую разметку A/B/C/D,
|
||||
эмитит самодостаточный HTML (контент для Artifact-обёртки). Запуск:
|
||||
eval/.venv/bin/python eval/build_editor_artifact.py <out.html>
|
||||
|
||||
⚠ ГИГИЕНА СЛЕПОТЫ (D13.5 / 07-review §4.5 / POLYGON handoff Task 2): предыдущая версия была
|
||||
структурно НЕ-слепой при LLM-оценке — (1) ключ «метка→модель» и цена лежали В ТОМ ЖЕ артефакте
|
||||
(за <details> — для LLM-фетча это НЕ скрытие, весь DOM виден), (2) маппинг A/B/C/D был
|
||||
ЗАФИКСИРОВАН одинаково на всех фрагментах (узнав раз — знаешь везде). Исправлено:
|
||||
- метки рандомизируются ПО ФРАГМЕНТУ (детерминированная перестановка на соли, разная у каждого
|
||||
фрагмента) — узнать порядок на одном фрагменте не даёт его на других;
|
||||
- ключ (метка→модель) + цена/латентность пишутся в ОТДЕЛЬНЫЙ файл (--key), КОТОРЫЙ НЕ
|
||||
ПУБЛИКУЕТСЯ до вынесения оценки; в самом артефакте НЕТ ни имён моделей, ни цены;
|
||||
- соль генерится случайно на сборку и хранится ТОЛЬКО в ключ-файле → даже код-осведомлённый
|
||||
оценщик не инвертирует перестановку без ключа.
|
||||
|
||||
Читает eval/data/editor_bench/<fragment>/*.txt, эмитит слепой HTML (контент для Artifact-обёртки)
|
||||
и рядом — приватный ключ-файл. Запуск:
|
||||
eval/.venv/bin/python eval/build_editor_artifact.py <out.html> [--key <out.key.json>]
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
BENCH = ROOT / "data" / "editor_bench"
|
||||
|
||||
# Слепая разметка: фиксированный перемешанный порядок (одинаков на всех фрагментах)
|
||||
BLIND = [("A", "grok-4.3"), ("B", "glm-4.6"), ("C", "gemini-3.1-pro"), ("D", "kimi-k2.6")]
|
||||
MODELS = ["grok-4.3", "glm-4.6", "gemini-3.1-pro", "kimi-k2.6"]
|
||||
LABELS = ["A", "B", "C", "D"]
|
||||
|
||||
# стоимость/латентность — В КЛЮЧ-ФАЙЛ, НЕ в артефакт (цена коррелирует с моделью → утечка слепоты)
|
||||
COST = {"grok-4.3": "~13 с, без «размышлений» — самый быстрый",
|
||||
"glm-4.6": "~105 с (thinking off)",
|
||||
"gemini-3.1-pro": "~60–86 с, обязательный thinking",
|
||||
"kimi-k2.6": "~150 с, ~11 000 токенов «размышлений» на абзац — самый дорогой"}
|
||||
|
||||
FRAGMENTS = [
|
||||
("rashomon", "японский → русский", "Акутагава Рюноскэ, «Расёмон»", False),
|
||||
|
|
@ -23,11 +43,13 @@ FRAGMENTS = [
|
|||
("wells", "английский → русский", "Г. Уэллс, «Машина времени»", True),
|
||||
]
|
||||
|
||||
# стоимость/латентность из results.json (последний прогон) — для секции-расшифровки
|
||||
COST = {"grok-4.3": "~13 с, без «размышлений» — самый быстрый",
|
||||
"glm-4.6": "~105 с (thinking off)",
|
||||
"gemini-3.1-pro": "~60–86 с, обязательный thinking",
|
||||
"kimi-k2.6": "~150 с, ~11 000 токенов «размышлений» на абзац — самый дорогой"}
|
||||
|
||||
def label_map(salt: str, frag: str) -> list[tuple[str, str]]:
|
||||
"""Детерминированная перестановка меток ПО ФРАГМЕНТУ на секретной соли: сортируем модели по
|
||||
sha256(salt|frag|model) и присваиваем A,B,C,D. Разная у каждого фрагмента; без соли (в ключе)
|
||||
не инвертируется. Возвращает [(label, model), …] в порядке меток."""
|
||||
order = sorted(MODELS, key=lambda m: hashlib.sha256(f"{salt}|{frag}|{m}".encode()).hexdigest())
|
||||
return list(zip(LABELS, order))
|
||||
|
||||
|
||||
def paras(text: str) -> str:
|
||||
|
|
@ -44,11 +66,18 @@ def read(frag: str, name: str) -> str:
|
|||
|
||||
|
||||
def main() -> None:
|
||||
out = Path(sys.argv[1])
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("out")
|
||||
ap.add_argument("--key", default="", help="приватный ключ-файл (по умолчанию <out>.key.json)")
|
||||
ap.add_argument("--salt", default="", help="фиксировать соль (для воспроизводимости; иначе случайная)")
|
||||
args = ap.parse_args()
|
||||
out = Path(args.out)
|
||||
key_path = Path(args.key) if args.key else out.with_suffix(".key.json")
|
||||
salt = args.salt or secrets.token_hex(8)
|
||||
|
||||
S = []
|
||||
S.append("<title>Сравнение редакторов ru-стиля · Эксперимент 04</title>")
|
||||
S.append(f"<style>{CSS}</style>")
|
||||
|
||||
S.append('<div class="wrap">')
|
||||
S.append('<header class="intro">')
|
||||
S.append('<p class="eyebrow">TextMachine · полигон · эксперимент 04</p>')
|
||||
|
|
@ -60,11 +89,19 @@ def main() -> None:
|
|||
'<li><span class="k k-style">стиль</span> — какая русская проза читается живее, без канцелярита и калек;</li>'
|
||||
'<li><span class="k k-fidel">верность</span> — кто не потерял смысл и не добавил отсебятины (сверяй со «смысловым якорем»).</li>'
|
||||
'</ul>')
|
||||
S.append('<p class="note">Названия моделей и цена — внизу, под ключом-расшифровкой, чтобы не влияли на оценку. '
|
||||
'Фрагмент Уэллса — англ.→рус.: оригинал перед тобой, судишь и стиль, и верность напрямую.</p>')
|
||||
S.append('<p class="note">⚠ Метки A–D перемешаны СВОИМ порядком в каждом фрагменте — узнав, кто есть кто '
|
||||
'в одном, ты НЕ знаешь этого в другом. Имена моделей и цена в артефакте НЕ приводятся '
|
||||
'(ключ — в отдельном файле, вскрывается только ПОСЛЕ оценки). Фрагмент Уэллса — англ.→рус.: '
|
||||
'оригинал перед тобой, судишь и стиль, и верность напрямую.</p>')
|
||||
S.append("</header>")
|
||||
|
||||
key = {"salt": salt, "note": "ПРИВАТНЫЙ КЛЮЧ эксп.04 — НЕ публиковать вместе с артефактом; "
|
||||
"вскрывать только ПОСЛЕ вынесения слепой оценки (урок 07-review §4.5). "
|
||||
"Метки рандомизированы по фрагменту.", "cost": COST, "fragments": {}}
|
||||
|
||||
for i, (frag, direction, work, is_en) in enumerate(FRAGMENTS, 1):
|
||||
lm = label_map(salt, frag)
|
||||
key["fragments"][frag] = {lab: model for lab, model in lm}
|
||||
anchor = read(frag, "source") if is_en else read(frag, "english_ref")
|
||||
anchor_label = "Оригинал (английский)" if is_en else "Смысловой якорь — дословный английский перевод"
|
||||
draft = read(frag, "draft")
|
||||
|
|
@ -74,7 +111,6 @@ def main() -> None:
|
|||
f'<h2>{html.escape(work)}</h2></div>'
|
||||
+ ('<span class="badge">твой полный контроль</span>' if is_en else '') +
|
||||
'</div>')
|
||||
|
||||
S.append('<details class="context"><summary>Смысловой якорь и черновик</summary>'
|
||||
'<div class="context-grid">')
|
||||
S.append(f'<div class="ctx"><p class="ctx-label">{html.escape(anchor_label)}</p>'
|
||||
|
|
@ -82,32 +118,23 @@ def main() -> None:
|
|||
S.append(f'<div class="ctx"><p class="ctx-label">Черновой перевод (вход редактора)</p>'
|
||||
f'<div class="prose muted">{paras(draft)}</div></div>')
|
||||
S.append("</div></details>")
|
||||
|
||||
S.append('<div class="versions">')
|
||||
for letter, model in BLIND:
|
||||
for letter, model in lm: # per-fragment shuffled order
|
||||
txt = read(frag, model)
|
||||
S.append(f'<article class="ver"><header class="ver-head"><span class="chip">{letter}</span></header>'
|
||||
f'<div class="prose">{paras(txt)}</div></article>')
|
||||
S.append("</div>")
|
||||
S.append("</section>")
|
||||
|
||||
# ключ-расшифровка + стоимость (свёрнуто)
|
||||
S.append('<details class="reveal"><summary>Показать расшифровку и стоимость</summary>')
|
||||
S.append('<table><thead><tr><th>Метка</th><th>Модель</th><th>Латентность / цена редактуры</th></tr></thead><tbody>')
|
||||
for letter, model in BLIND:
|
||||
S.append(f'<tr><td class="chip-cell"><span class="chip sm">{letter}</span></td>'
|
||||
f'<td class="mono">{html.escape(model)}</td><td>{html.escape(COST.get(model, ""))}</td></tr>')
|
||||
S.append("</tbody></table>")
|
||||
S.append('<p class="note">Черновик — DeepSeek (на китайском А-Кью DeepSeek «эхал» оригинал, '
|
||||
'поэтому там черновик от GLM — деталь для бэкенда, на сравнение редакторов не влияет). '
|
||||
'Латентность — на коротком фрагменте; в продакшене всё идёт батчем.</p>')
|
||||
S.append("</details>")
|
||||
|
||||
# NO reveal/key section in the artifact (the exp04 blindness break). Key → separate file.
|
||||
S.append('<footer class="foot"><p>Выбор дефолтного редактора для Фазы 1. '
|
||||
'Твоя оценка — золотой стандарт: LLM-судьи на художественном качестве расходятся с людьми.</p></footer>')
|
||||
'Твоя оценка — золотой стандарт: LLM-судьи на художественном качестве расходятся с людьми. '
|
||||
'Ключ «метка→модель» и цена — в приватном файле, вскрываются после оценки.</p></footer>')
|
||||
S.append("</div>")
|
||||
out.write_text("\n".join(S), encoding="utf-8")
|
||||
key_path.write_text(json.dumps(key, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print("written", out, out.stat().st_size, "bytes")
|
||||
print("KEY (do NOT publish until scored):", key_path)
|
||||
|
||||
|
||||
CSS = r"""
|
||||
|
|
@ -176,23 +203,12 @@ h1{font-family:var(--serif);font-weight:600;font-size:clamp(1.9rem,5vw,3rem);lin
|
|||
.chip{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;
|
||||
font-family:var(--serif);font-weight:700;font-size:1.05rem;color:#fff;background:var(--accent);
|
||||
border-radius:50%;}
|
||||
.chip.sm{width:1.6rem;height:1.6rem;font-size:.85rem;}
|
||||
|
||||
.prose{font-family:var(--serif);font-size:1.02rem;line-height:1.62;max-width:var(--measure);}
|
||||
.prose p{margin:0 0 .75em;}
|
||||
.prose p:last-child{margin-bottom:0;}
|
||||
.prose.muted{color:var(--muted);font-size:.94rem;line-height:1.55;}
|
||||
|
||||
.reveal{margin:2rem 0 0;border:1px solid var(--hair);border-radius:10px;background:var(--surface);}
|
||||
.reveal>summary{cursor:pointer;padding:.9rem 1.1rem;font-weight:600;color:var(--accent);list-style:none;}
|
||||
.reveal>summary::-webkit-details-marker{display:none;}
|
||||
.reveal>summary::before{content:"🔑 ";}
|
||||
.reveal table{width:100%;border-collapse:collapse;margin:0 0 .5rem;}
|
||||
.reveal th,.reveal td{text-align:left;padding:.55rem 1.1rem;border-top:1px solid var(--hair);font-size:.92rem;}
|
||||
.reveal th{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;}
|
||||
.chip-cell{width:3rem;} .mono{font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:.88rem;}
|
||||
.reveal .note{padding:0 1.1rem 1rem;}
|
||||
|
||||
.foot{margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--hair);color:var(--muted);font-size:.9rem;}
|
||||
a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:4px;}
|
||||
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;}}
|
||||
|
|
|
|||
|
|
@ -19,19 +19,66 @@ Two matchers, both reusable:
|
|||
Also exposes GO-STYLE decl-form whole-word matching (given stored decl forms), so the eval
|
||||
can compare "stored-decl completeness" (Go's real mechanism) against pymorphy fallback —
|
||||
the E1 measurement the handoff asks the polygon to validate on real books.
|
||||
|
||||
D13.5 SYNC WITH GO (07-strategic-review §4.5; POLYGON handoff Task 1e-tail): normalize_target
|
||||
and containsWholeWord are now a FAITHFUL mirror of memnorm.go normalizeTargetForm /
|
||||
mempostcheck.go containsWholeWord — NFC → dash/hyphen-variant fold → default-ignorable strip
|
||||
(Cf + variation selectors) → whitespace-collapse → lower → ё→е, and a boundary test on the
|
||||
Unicode IsLetter equivalent (category L*) rather than Cyrillic-only. Before this, a translit
|
||||
name adjacent to a Latin letter or split by a zero-width joiner false-missed/false-hit vs Go.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import unicodedata
|
||||
import pymorphy3
|
||||
|
||||
_MORPH = pymorphy3.MorphAnalyzer()
|
||||
_WORD_RE = re.compile(r"[А-Яа-яЁё]+(?:-[А-Яа-яЁё]+)?")
|
||||
_CYR = re.compile(r"[А-Яа-яЁё]")
|
||||
|
||||
# Dash/hyphen/minus variants that fold to ASCII '-' (mirror of Go isDashVariant): hyphen,
|
||||
# non-breaking hyphen, figure dash, en/em dash, horizontal bar, math minus, soft hyphen.
|
||||
_DASH_VARIANTS = frozenset("‐‑‒–—―−")
|
||||
|
||||
|
||||
def _is_ignorable(c: str) -> bool:
|
||||
"""Mirror of Go isIgnorableForMatch: default-ignorable / format code points (unicode.Cf:
|
||||
ZWSP/ZWNJ/ZWJ/WJ/BOM/bidi) plus the variation selectors (VS1-16 U+FE00..FE0F and the
|
||||
ideographic supplement U+E0100..E01EF). Dropped before matching so a stored «вана» is not
|
||||
a false MISS against «ва<WJ>на». Soft hyphen (also Cf) is folded to '-' BEFORE this."""
|
||||
o = ord(c)
|
||||
return unicodedata.category(c) == "Cf" or (0xFE00 <= o <= 0xFE0F) or (0xE0100 <= o <= 0xE01EF)
|
||||
|
||||
|
||||
def _is_letter(c: str) -> bool:
|
||||
"""unicode.IsLetter equivalent: Unicode general category L* (Lu/Ll/Lt/Lm/Lo). Used for
|
||||
the whole-word boundary so a Latin-letter neighbour bounds a translit name too (Go parity;
|
||||
the old Cyrillic-only [А-Яа-яЁё] test missed Latin boundaries)."""
|
||||
return unicodedata.category(c)[0] == "L"
|
||||
|
||||
|
||||
def normalize_target(s: str) -> str:
|
||||
"""ё→е fold + lower + whitespace collapse (mirror of Go normalizeTargetForm, target side)."""
|
||||
return re.sub(r"\s+", " ", s).lower().replace("ё", "е").strip()
|
||||
"""NFC → dash/hyphen-variant fold → drop default-ignorables → whitespace-collapse → lower →
|
||||
ё→е. Symmetric mirror of Go normalizeTargetForm (memnorm.go), applied to BOTH the stored
|
||||
decl forms AND the model output so a correctly-rendered term is NEVER a false MISS."""
|
||||
s = unicodedata.normalize("NFC", s)
|
||||
out: list[str] = []
|
||||
prev_space = False
|
||||
for c in s:
|
||||
if c in _DASH_VARIANTS:
|
||||
c = "-" # soft hyphen folds here, so it is kept (not dropped below)
|
||||
elif _is_ignorable(c):
|
||||
continue
|
||||
if c.isspace():
|
||||
if not prev_space:
|
||||
out.append(" ")
|
||||
prev_space = True
|
||||
continue
|
||||
prev_space = False
|
||||
c = c.lower()
|
||||
if c == "ё":
|
||||
c = "е"
|
||||
out.append(c)
|
||||
return "".join(out).strip()
|
||||
|
||||
|
||||
def lemmas(text: str) -> set[str]:
|
||||
|
|
@ -56,14 +103,15 @@ def rendered_pymorphy(output: str, lemma_keys: list[str], hyphen_pat: str | None
|
|||
|
||||
def contains_whole_word(text_norm: str, form_norm: str) -> bool:
|
||||
"""Go-style whole-word (letter-boundary) containment (mirror of Go containsWholeWord).
|
||||
Both args must be normalize_target()'d."""
|
||||
Boundary = Unicode IsLetter (category L*) on both ends. Both args must be normalize_target()'d."""
|
||||
if not form_norm:
|
||||
return False
|
||||
n = len(form_norm)
|
||||
i = text_norm.find(form_norm)
|
||||
while i != -1:
|
||||
left_ok = i == 0 or not _CYR.match(text_norm[i - 1])
|
||||
j = i + len(form_norm)
|
||||
right_ok = j == len(text_norm) or not _CYR.match(text_norm[j])
|
||||
left_ok = i == 0 or not _is_letter(text_norm[i - 1])
|
||||
j = i + n
|
||||
right_ok = j == len(text_norm) or not _is_letter(text_norm[j])
|
||||
if left_ok and right_ok:
|
||||
return True
|
||||
i = text_norm.find(form_norm, i + 1)
|
||||
|
|
@ -99,7 +147,7 @@ def _score_stored(output: str, e: dict) -> bool:
|
|||
return rendered_stored_decl(output, e.get("decl_forms", []), e["dst"])
|
||||
|
||||
|
||||
if __name__ == "__main__": # smoke: inflected forms must NOT false-flag
|
||||
if __name__ == "__main__": # smoke: inflected forms must NOT false-flag; Latin-boundary parity
|
||||
out = "Бородатого Вана встретили в Вэйчжуане; Дэна и сюцая тоже."
|
||||
ents = {
|
||||
"王胡": {"dst": "Бородатый Ван", "lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван"]},
|
||||
|
|
@ -109,3 +157,10 @@ if __name__ == "__main__": # smoke: inflected forms must NOT false-flag
|
|||
}
|
||||
import json
|
||||
print(json.dumps(consistency(out, ents), ensure_ascii=False, indent=2))
|
||||
# boundary + normalization parity asserts (Go containsWholeWord / normalizeTargetForm)
|
||||
assert contains_whole_word(normalize_target("Ван ушёл"), normalize_target("Ван"))
|
||||
assert not contains_whole_word(normalize_target("караВАН ушёл"), normalize_target("Ван"))
|
||||
assert contains_whole_word(normalize_target("А‑кью"), normalize_target("А-кью")) # NB-hyphen folds
|
||||
assert contains_whole_word(normalize_target("Вана"), normalize_target("вана")) # ZWSP stripped
|
||||
assert not contains_whole_word(normalize_target("Ivan"), normalize_target("van")) # Latin boundary
|
||||
print("declmetric parity asserts: OK")
|
||||
|
|
|
|||
151
eval/pilot/kana_precision.py
Normal file
151
eval/pilot/kana_precision.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
#!/usr/bin/env python3
|
||||
"""kana-precision measurement (D16-tail / E1-protocol extension; POLYGON handoff Task 5).
|
||||
|
||||
QUESTION (memory.go:37-48): minKeyLenPhonetic=3 is a "conservative default pending a ja-kana
|
||||
precision measurement". This quantifies matcher PRECISION on kana name-keys at MIN ∈ {2,3,4},
|
||||
so the prod-ja decision (confirm 3 / raise / split by kana type) is grounded, not asserted.
|
||||
|
||||
The failure mode: a short kana name-key (glossary key) firing as a FALSE POSITIVE inside an
|
||||
unrelated common Japanese word — the kana analog of a Han homograph trap. Two distinct causes,
|
||||
tagged in kana_traps.json (the agent partitioned them, and they behave DIFFERENTLY under a floor):
|
||||
- substring: the name hides inside a LONGER unrelated word (リン ⊂ リンゴ apple). Killed by a
|
||||
high-enough MIN floor.
|
||||
- homograph: the name IS a whole common noun (ひかる=光る shine, あさひ=morning sun, ひまわり=
|
||||
sunflower). LENGTH-INVARIANT — a MIN floor cannot filter it; it is a full substring at every
|
||||
length. Needs POS / known-word gating, not a longer key.
|
||||
|
||||
Matcher variants measured:
|
||||
- substring: the CURRENT Go source-side matcher (Aho-Corasick = pure substring, no boundary).
|
||||
- script_boundary: emulates the D16.3 fix ("script/word-boundary on the source side for
|
||||
phonetic keys"). For kana this means "the match must be flanked by NON-kana (kanji/latin/
|
||||
punct) or a string edge". This is the critical ja finding: unlike Latin (rose⊂roseanne,
|
||||
where spaces/letters bound words), Japanese has NO word spaces and names take kana PARTICLES
|
||||
(メロスは…, 名前が…), so a kana-run boundary rule SUPPRESSES real names followed by a particle
|
||||
→ recall collapse. Measured on both so the orchestrator sees the trade, not just the win.
|
||||
|
||||
Both operate on the NORMALIZED (kana-folded) text — imported from the memory_eval Go-mirror so
|
||||
this measures the SAME normalization the hot path uses (katakana→hiragana, NFKC, ignorables).
|
||||
|
||||
Usage: eval/.venv/bin/python eval/pilot/kana_precision.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
from pathlib import Path
|
||||
import memory_eval as me # reuse the Go-mirror normalization (norm_src, significant_len, any_han)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
TRAPS = json.loads((ROOT / "kana_traps.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def is_kana(c: str) -> bool:
|
||||
"""Hiragana/katakana (incl. prolonged mark ー). After norm_src katakana folds to hiragana,
|
||||
so the flanking test operates on a hiragana run; ー (U+30FC) is not folded, still kana."""
|
||||
return 0x3040 <= ord(c) <= 0x30FF
|
||||
|
||||
|
||||
def eligible(name_norm: str, min_phonetic: int) -> bool:
|
||||
"""A kana key (no Han) is eligible only if significantLen >= the phonetic floor under test
|
||||
(min_key_len_for with minKeyLenPhonetic := min_phonetic). Han keys are out of scope here."""
|
||||
floor = 2 if me.any_han(name_norm) else min_phonetic
|
||||
return me.significant_len(name_norm) >= floor
|
||||
|
||||
|
||||
def collision_prone_at(name_norm: str, min_phonetic: int) -> bool:
|
||||
"""Mirror of collisionProneKey with the floor == the const under test: a fired key of
|
||||
significantLen <= min_phonetic is AMBIGUOUS (softened + forced post-check); a longer key
|
||||
fires CONFIRMED (authoritative). The dangerous FP is the CONFIRMED one."""
|
||||
return (not me.any_han(name_norm)) and me.significant_len(name_norm) <= min_phonetic
|
||||
|
||||
|
||||
def fires_substring(name_norm: str, text_norm: str) -> bool:
|
||||
return name_norm in text_norm
|
||||
|
||||
|
||||
def fires_script_boundary(name_norm: str, text_norm: str) -> bool:
|
||||
"""D16.3 emulation for kana: a substring hit is valid only if flanked by non-kana or edge."""
|
||||
i = text_norm.find(name_norm)
|
||||
n = len(name_norm)
|
||||
while i != -1:
|
||||
left_ok = i == 0 or not is_kana(text_norm[i - 1])
|
||||
j = i + n
|
||||
right_ok = j == len(text_norm) or not is_kana(text_norm[j])
|
||||
if left_ok and right_ok:
|
||||
return True
|
||||
i = text_norm.find(name_norm, i + 1)
|
||||
return False
|
||||
|
||||
|
||||
def measure(matcher, min_phonetic: int) -> dict:
|
||||
"""Precision over the trap set at one floor with one matcher. FP from collision_traps,
|
||||
TP from true_positive_contexts. Partition FP by cause (substring/homograph) and by
|
||||
disposition (ambiguous=softened / confirmed=authoritative)."""
|
||||
fp = {"substring": 0, "homograph": 0, "confirmed": 0, "ambiguous": 0, "banned": 0, "items": []}
|
||||
for t in TRAPS["collision_traps"]:
|
||||
nk = me.norm_src(t["name_kana"])
|
||||
cw = me.norm_src(t["containing_word_kana"])
|
||||
if not eligible(nk, min_phonetic):
|
||||
fp["banned"] += 1
|
||||
continue
|
||||
if matcher(nk, cw):
|
||||
fp[t["type"]] += 1
|
||||
disp = "ambiguous" if collision_prone_at(nk, min_phonetic) else "confirmed"
|
||||
fp[disp] += 1
|
||||
fp["items"].append(f'{t["name_kana"]}({t["len"]})⊂{t["containing_word_kana"]}[{t["type"]},{disp}]')
|
||||
tp = {"fired": 0, "banned": 0, "missed_boundary": 0}
|
||||
for c in TRAPS["true_positive_contexts"]:
|
||||
nk = me.norm_src(c["name_kana"])
|
||||
sent = me.norm_src(c["sentence_kana"])
|
||||
if not eligible(nk, min_phonetic):
|
||||
tp["banned"] += 1
|
||||
continue
|
||||
if matcher(nk, sent):
|
||||
tp["fired"] += 1
|
||||
else:
|
||||
tp["missed_boundary"] += 1 # eligible but the boundary rule suppressed a REAL name
|
||||
n_fp = fp["substring"] + fp["homograph"]
|
||||
prec = round(tp["fired"] / (tp["fired"] + n_fp), 3) if (tp["fired"] + n_fp) else None
|
||||
# CONFIRMED-precision: among AUTHORITATIVE firings only (AMBIGUOUS is softened by ⟨проверить⟩)
|
||||
tp_conf = sum(1 for c in TRAPS["true_positive_contexts"]
|
||||
if eligible(me.norm_src(c["name_kana"]), min_phonetic)
|
||||
and not collision_prone_at(me.norm_src(c["name_kana"]), min_phonetic)
|
||||
and matcher(me.norm_src(c["name_kana"]), me.norm_src(c["sentence_kana"])))
|
||||
conf_prec = round(tp_conf / (tp_conf + fp["confirmed"]), 3) if (tp_conf + fp["confirmed"]) else None
|
||||
return {"min": min_phonetic, "tp_fired": tp["fired"], "tp_banned": tp["banned"],
|
||||
"tp_missed_boundary": tp["missed_boundary"], "fp_total": n_fp,
|
||||
"fp_substring": fp["substring"], "fp_homograph": fp["homograph"],
|
||||
"fp_confirmed": fp["confirmed"], "fp_ambiguous": fp["ambiguous"], "fp_banned": fp["banned"],
|
||||
"precision": prec, "confirmed_precision": conf_prec, "fp_items": fp["items"]}
|
||||
|
||||
|
||||
def main():
|
||||
print("kana-precision (D16-tail). Trap set: "
|
||||
f'{len(TRAPS["collision_traps"])} collision pairs, '
|
||||
f'{len(TRAPS["true_positive_contexts"])} true-positive contexts. '
|
||||
"FP by cause; disposition confirmed=authoritative / ambiguous=softened.\n")
|
||||
for label, matcher in (("substring (CURRENT Go)", fires_substring),
|
||||
("script_boundary (D16.3 emulation)", fires_script_boundary)):
|
||||
print(f"### matcher = {label}")
|
||||
print("MIN | TP_fire | TP_missed(bound) | FP_tot | FP_substr | FP_homog | FP_CONFIRMED | FP_ambig | precision | conf_prec")
|
||||
rows = [measure(matcher, m) for m in (2, 3, 4)]
|
||||
for r in rows:
|
||||
print(f" {r['min']} | {r['tp_fired']:2} | {r['tp_missed_boundary']:2} |"
|
||||
f" {r['fp_total']:2} | {r['fp_substring']:2} | {r['fp_homograph']:2} |"
|
||||
f" {r['fp_confirmed']:2} | {r['fp_ambiguous']:2} | "
|
||||
f"{r['precision'] if r['precision'] is not None else ' -'} | "
|
||||
f"{r['confirmed_precision'] if r['confirmed_precision'] is not None else ' -'}")
|
||||
print()
|
||||
# dump machine-readable for the report / re-run comparison
|
||||
out = {"substring": [measure(fires_substring, m) for m in (2, 3, 4)],
|
||||
"script_boundary": [measure(fires_script_boundary, m) for m in (2, 3, 4)]}
|
||||
outp = ROOT.parent / "data" / "pilot" / "kana_precision.json"
|
||||
outp.parent.mkdir(parents=True, exist_ok=True)
|
||||
outp.write_text(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
print(f"wrote {outp}")
|
||||
# headline read
|
||||
s3 = out["substring"][1]
|
||||
print(f"\nHEADLINE (substring, MIN=3): precision={s3['precision']} conf_prec={s3['confirmed_precision']} "
|
||||
f"| FP_confirmed(len>MIN)={s3['fp_confirmed']} are {[i for i in s3['fp_items'] if 'confirmed' in i]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
76
eval/pilot/kana_traps.json
Normal file
76
eval/pilot/kana_traps.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"_provenance": "ja-kana precision trap corpus for kana_precision.py (D16-tail / E1-protocol extension). Sourced 2026-07-09 by a research agent: names extracted by reading the four ja PD samples (soseki-neko, akutagawa-rashomon, akutagawa-hana, dazai-merosu); collision traps from Japanese lexical knowledge, each ADJACENCY- and VOICING-checked (3 faulty candidates おなか/みがく/みんな rejected). Every trap tagged type: substring (name hides inside a longer unrelated word) vs homograph (name == a whole common noun). KEY FINDING: the substring class vanishes by MIN=3-4; the homograph class is length-INVARIANT (a MIN threshold cannot filter it — needs POS/known-word gating). kana length = CHARACTER count a substring matcher sees (ディオニス = 5 chars). Matcher kana-folds katakana->hiragana, so same-fold substrings fire.",
|
||||
"names_from_corpus": [
|
||||
{"name_kana":"メロス","len":3,"work":"merosu","romaji":"Merosu","kind":"person(protagonist)","written_kana_in_source":true},
|
||||
{"name_kana":"ゼウス","len":3,"work":"merosu","romaji":"Zeusu","kind":"deity","written_kana_in_source":true},
|
||||
{"name_kana":"シラクス","len":4,"work":"merosu","romaji":"Shirakusu","kind":"place","written_kana_in_source":true},
|
||||
{"name_kana":"アレキス","len":4,"work":"merosu","romaji":"Arekisu","kind":"person(minor)","written_kana_in_source":true},
|
||||
{"name_kana":"ディオニス","len":5,"work":"merosu","romaji":"Dionis","kind":"person(antagonist)","written_kana_in_source":true},
|
||||
{"name_kana":"セリヌンティウス","len":8,"work":"merosu","romaji":"Serinuntiusu","kind":"person","written_kana_in_source":true},
|
||||
{"name_kana":"フィロストラトス","len":8,"work":"merosu","romaji":"Firosutoratosu","kind":"person(minor)","written_kana_in_source":true},
|
||||
{"name_kana":"おさん","len":3,"work":"neko","romaji":"Osan","kind":"person(maid)","written_kana_in_source":true},
|
||||
{"name_kana":"ギボン","len":3,"work":"neko","romaji":"Gibon","kind":"reference(person)","written_kana_in_source":true},
|
||||
{"name_kana":"ニコラス","len":4,"work":"neko","romaji":"Nikorasu","kind":"reference(person)","written_kana_in_source":true},
|
||||
{"name_kana":"ハリソン","len":4,"work":"neko","romaji":"Harison","kind":"reference(person)","written_kana_in_source":true},
|
||||
{"name_kana":"アンドレア","len":5,"work":"neko","romaji":"Andorea","kind":"reference(person)","written_kana_in_source":true}
|
||||
],
|
||||
"collision_traps": [
|
||||
{"name_kana":"リン","len":2,"romaji":"Rin","containing_word_kana":"リンゴ","containing_word_meaning":"apple","type":"substring","confidence":"high"},
|
||||
{"name_kana":"アイ","len":2,"romaji":"Ai","containing_word_kana":"アイス","containing_word_meaning":"ice/ice cream","type":"substring","confidence":"high"},
|
||||
{"name_kana":"ラン","len":2,"romaji":"Ran","containing_word_kana":"ランチ","containing_word_meaning":"lunch","type":"substring","confidence":"high"},
|
||||
{"name_kana":"はる","len":2,"romaji":"Haru","containing_word_kana":"はるばる","containing_word_meaning":"all the way/from afar","type":"substring","confidence":"high","grounded":"merosu"},
|
||||
{"name_kana":"かな","len":2,"romaji":"Kana","containing_word_kana":"かなしい","containing_word_meaning":"sad","type":"substring","confidence":"high"},
|
||||
{"name_kana":"なお","len":2,"romaji":"Nao","containing_word_kana":"すなお","containing_word_meaning":"obedient/honest","type":"substring","confidence":"high"},
|
||||
{"name_kana":"みか","len":2,"romaji":"Mika","containing_word_kana":"みかん","containing_word_meaning":"mandarin orange","type":"substring","confidence":"high"},
|
||||
{"name_kana":"ゆき","len":2,"romaji":"Yuki","containing_word_kana":"ゆきだるま","containing_word_meaning":"snowman","type":"substring","confidence":"high"},
|
||||
{"name_kana":"まり","len":2,"romaji":"Mari","containing_word_kana":"あまり","containing_word_meaning":"remainder/too much","type":"substring","confidence":"high"},
|
||||
{"name_kana":"かい","len":2,"romaji":"Kai","containing_word_kana":"せかい","containing_word_meaning":"world","type":"substring","confidence":"high"},
|
||||
{"name_kana":"れい","len":2,"romaji":"Rei","containing_word_kana":"きれい","containing_word_meaning":"pretty/clean","type":"substring","confidence":"high"},
|
||||
{"name_kana":"ゆう","len":2,"romaji":"Yuu","containing_word_kana":"ゆうがた","containing_word_meaning":"evening","type":"substring","confidence":"high","grounded":"rashomon,neko"},
|
||||
{"name_kana":"なな","len":2,"romaji":"Nana","containing_word_kana":"バナナ","containing_word_meaning":"banana","type":"substring","confidence":"high"},
|
||||
{"name_kana":"さや","len":2,"romaji":"Saya","containing_word_kana":"ささやく","containing_word_meaning":"to whisper","type":"substring","confidence":"high","grounded":"hana,merosu"},
|
||||
{"name_kana":"まい","len":2,"romaji":"Mai","containing_word_kana":"まいにち","containing_word_meaning":"every day","type":"substring","confidence":"high"},
|
||||
{"name_kana":"ちか","len":2,"romaji":"Chika","containing_word_kana":"ちかい","containing_word_meaning":"near","type":"substring","confidence":"high"},
|
||||
{"name_kana":"みな","len":2,"romaji":"Mina","containing_word_kana":"みなと","containing_word_meaning":"harbor","type":"substring","confidence":"high"},
|
||||
{"name_kana":"ゆい","len":2,"romaji":"Yui","containing_word_kana":"ゆいいつ","containing_word_meaning":"the only/sole","type":"substring","confidence":"high"},
|
||||
{"name_kana":"まこと","len":3,"romaji":"Makoto","containing_word_kana":"まことに","containing_word_meaning":"truly/indeed","type":"substring","confidence":"high"},
|
||||
{"name_kana":"さくら","len":3,"romaji":"Sakura","containing_word_kana":"さくらんぼ","containing_word_meaning":"cherry (fruit)","type":"substring","confidence":"high"},
|
||||
{"name_kana":"あきら","len":3,"romaji":"Akira","containing_word_kana":"あきらめる","containing_word_meaning":"to give up","type":"substring","confidence":"high"},
|
||||
{"name_kana":"あすか","len":3,"romaji":"Asuka","containing_word_kana":"あすから","containing_word_meaning":"from tomorrow","type":"substring","confidence":"high"},
|
||||
{"name_kana":"ひろし","len":3,"romaji":"Hiroshi","containing_word_kana":"ひろしま","containing_word_meaning":"Hiroshima","type":"substring","confidence":"high"},
|
||||
{"name_kana":"ひとみ","len":3,"romaji":"Hitomi","containing_word_kana":"ひとみしり","containing_word_meaning":"shyness with strangers","type":"substring","confidence":"high"},
|
||||
{"name_kana":"ちなみ","len":3,"romaji":"Chinami","containing_word_kana":"ちなみに","containing_word_meaning":"by the way/incidentally","type":"substring","confidence":"high"},
|
||||
{"name_kana":"あさひ","len":3,"romaji":"Asahi","containing_word_kana":"あさひ","containing_word_meaning":"morning sun","type":"homograph","confidence":"high","grounded":"neko,hana"},
|
||||
{"name_kana":"いずみ","len":3,"romaji":"Izumi","containing_word_kana":"いずみ","containing_word_meaning":"spring/fountain","type":"homograph","confidence":"high","grounded":"merosu"},
|
||||
{"name_kana":"ひかる","len":3,"romaji":"Hikaru","containing_word_kana":"ひかる","containing_word_meaning":"to shine","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"かおり","len":3,"romaji":"Kaori","containing_word_kana":"かおり","containing_word_meaning":"fragrance/scent","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"のぞみ","len":3,"romaji":"Nozomi","containing_word_kana":"のぞみ","containing_word_meaning":"wish/hope","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"みなみ","len":3,"romaji":"Minami","containing_word_kana":"みなみ","containing_word_meaning":"south","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"ひなた","len":3,"romaji":"Hinata","containing_word_kana":"ひなた","containing_word_meaning":"a sunny spot","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"ひまわり","len":4,"romaji":"Himawari","containing_word_kana":"ひまわり","containing_word_meaning":"sunflower","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"なでしこ","len":4,"romaji":"Nadeshiko","containing_word_kana":"やまとなでしこ","containing_word_meaning":"idealized Japanese woman/dianthus","type":"substring","confidence":"high"},
|
||||
{"name_kana":"すずらん","len":4,"romaji":"Suzuran","containing_word_kana":"すずらん","containing_word_meaning":"lily of the valley","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"あじさい","len":4,"romaji":"Ajisai","containing_word_kana":"あじさい","containing_word_meaning":"hydrangea","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"たんぽぽ","len":4,"romaji":"Tanpopo","containing_word_kana":"たんぽぽ","containing_word_meaning":"dandelion","type":"homograph","confidence":"high"},
|
||||
{"name_kana":"きさらぎ","len":4,"romaji":"Kisaragi","containing_word_kana":"きさらぎ","containing_word_meaning":"February (old lunar calendar)","type":"homograph","confidence":"med"},
|
||||
{"name_kana":"みなづき","len":4,"romaji":"Minazuki","containing_word_kana":"みなづき","containing_word_meaning":"June (old lunar calendar)","type":"homograph","confidence":"med"},
|
||||
{"name_kana":"ことのは","len":4,"romaji":"Kotonoha","containing_word_kana":"ことのは","containing_word_meaning":"words/language (poetic)","type":"homograph","confidence":"med"}
|
||||
],
|
||||
"true_positive_contexts": [
|
||||
{"name_kana":"メロス","sentence_kana":"メロスは激怒した。","source":"corpus:merosu"},
|
||||
{"name_kana":"メロス","sentence_kana":"走れ! メロス。","source":"corpus:merosu"},
|
||||
{"name_kana":"セリヌンティウス","sentence_kana":"竹馬の友、セリヌンティウスは、深夜、王城に召された。","source":"corpus:merosu"},
|
||||
{"name_kana":"ディオニス","sentence_kana":"暴君ディオニスは静かに、けれども威厳を以て問いつめた。","source":"corpus:merosu"},
|
||||
{"name_kana":"フィロストラトス","sentence_kana":"フィロストラトスでございます。","source":"corpus:merosu"},
|
||||
{"name_kana":"ゼウス","sentence_kana":"待ってくれ、ゼウスよ。","source":"corpus:merosu"},
|
||||
{"name_kana":"アレキス","sentence_kana":"それから、賢臣のアレキス様を。","source":"corpus:merosu"},
|
||||
{"name_kana":"シラクス","sentence_kana":"きょう未明メロスは村を出発し、野を越え山越え、十里はなれた此のシラクスの市にやって来た。","source":"corpus:merosu"},
|
||||
{"name_kana":"おさん","sentence_kana":"第一に逢ったのがおさんである。","source":"corpus:neko"},
|
||||
{"name_kana":"おさん","sentence_kana":"おさんは未だに嫌いである。","source":"corpus:neko"},
|
||||
{"name_kana":"リン","sentence_kana":"リンは今日も学校に遅刻した。","source":"constructed"},
|
||||
{"name_kana":"あい","sentence_kana":"あいちゃんは私の妹です。","source":"constructed"},
|
||||
{"name_kana":"まこと","sentence_kana":"まことは正直な男だ。","source":"constructed"},
|
||||
{"name_kana":"さくら","sentence_kana":"さくらは転校生と仲良くなった。","source":"constructed"},
|
||||
{"name_kana":"ひまわり","sentence_kana":"ひまわりは弟の名前です。","source":"constructed"}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,36 +1,72 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ф2.5 memory-eval (INDICATIVE run + harness scaffold) — the deciding-experiment design
|
||||
for the memory-bank bet (research/13 §Вердикт, decisions-log D-пилот).
|
||||
for the memory-bank bet (research/13 §Вердикт, decisions-log D-пилот, D13.5).
|
||||
|
||||
QUESTION (owner's central fear + go/no-go on the bank bet): is DETERMINISTIC SELECTIVE
|
||||
glossary injection (our bank) worth it vs just putting the WHOLE glossary in a long context,
|
||||
and does a WRONG injection silently degrade the translation? WMT25 three-mode protocol:
|
||||
and does a WRONG injection silently degrade the translation? WMT25 three-mode protocol,
|
||||
RENAMED M0–M3 per D13.4 (the old C0–C3 collided with exp09 §3 CORE configurations):
|
||||
|
||||
C0 no glossary (baseline — model's base behaviour)
|
||||
C1 SELECTIVE bank injection (only records whose keys fire in the chunk) — OUR bank
|
||||
C2 FULL glossary + sliding summary in the prompt — "just long context"
|
||||
C3 RANDOM/WRONG glossary (dst shuffled) — adversarial arm, directly tests silent degradation
|
||||
M0 no glossary (baseline — model's base behaviour)
|
||||
M1 SELECTIVE bank injection (only records whose keys fire in the chunk) — OUR bank
|
||||
M2 FULL glossary + sliding summary in the prompt — "just long context"
|
||||
M3 RANDOM/WRONG glossary (dst shuffled) — adversarial arm, directly tests silent degradation
|
||||
|
||||
Decision rule (pre-registered): C1≈C2 quality but C1 cheaper → bank justified; C2≫C1 →
|
||||
need fuller context; C3 drops vs C0 on FIDELITY → wrong injection harms → post-check/disposition
|
||||
Decision rule (pre-registered, exp09 §6): M1≈M2 quality but M1 cheaper → bank justified; M2≫M1
|
||||
→ need fuller context; M3 drops vs M0 on FIDELITY → wrong injection harms → post-check/disposition
|
||||
justified (owner's fear confirmed & mitigated, not just asserted).
|
||||
|
||||
INJECTION = eval-side MIRROR of backend/internal/pipeline/memory.go (THE SOURCE OF TRUTH):
|
||||
normalized exact key match, per-language min-key ban (Han≥2 / phonetic≥3), collision-prone
|
||||
short phonetic key → AMBIGUOUS, longest-match containment, spoiler since/until window, sticky
|
||||
scene-inertia. Cross-checked against the Go unit tests; on any divergence the Go code wins.
|
||||
(eval/memory_hotpath.py is the PRE-cc57c7b prototype with a global MIN_KEY=2 and no
|
||||
collision-downgrade → do NOT reuse it; this file reflects the current Go contract.)
|
||||
THREE AXES, measured SEPARATELY (exp09 §6):
|
||||
(a) consistency — decl-aware (declmetric.py, pymorphy3 + Go-style stored-decl) approved-dst
|
||||
rendering across the chunks where src fires;
|
||||
(b) FIDELITY/omission — LLM judge of a DIFFERENT family vs the SOURCE (D13.3 family rule:
|
||||
judge NOT from the draft/translator model's family). This is the axis that makes the
|
||||
pre-registered "M3 drops vs M0 on fidelity" rule TESTABLE — without it the central
|
||||
owner-fear measurement is unverifiable (07-review §4.5). CometKiwi is OPTIONAL and only
|
||||
with the caveat "not validated on ru-literature" (research/11-gap-2) — not wired here.
|
||||
(c) cost — input/output tokens per mode.
|
||||
|
||||
METRICS: (a) consistency — decl-aware (declmetric.py, pymorphy3) approved-dst rendering across
|
||||
overlapping chunks; (b) fidelity/omission — LLM judge of a DIFFERENT family (cross-family) vs
|
||||
source + Rogov anchor; (c) cost — input/output tokens per condition.
|
||||
INJECTION = eval-side MIRROR of backend/internal/pipeline/memory.go (THE SOURCE OF TRUTH),
|
||||
synced D13.5 across the 7 divergences the review flagged (07-review §4.5):
|
||||
1. sticky depth = stickyDepth(2), union window, reset at chapter boundary (runner.go:534-560);
|
||||
2. until_ch spoiler gate (memory.go:374-382), not only since_ch;
|
||||
3. span-containment gated on a SPOILER-VALID suppressor (memory.go:591-617);
|
||||
4. token budget + F2 eviction in priority order (memory.go:262-370);
|
||||
5. sticky disposition INHERITANCE — a collision-prone AMBIGUOUS carried by sticky stays
|
||||
AMBIGUOUS, not upgraded to CONFIRMED. This is the ratified D16.2 fix — as of 2026-07-09 the
|
||||
backend is LANDING it (memoryMatchVersion bumped to memmatch-v3 "sticky-inherit-disp"): the
|
||||
mirror already matches the v3 contract. Re-verify vs the final Go once the D16.2 logic edit
|
||||
completes (PROGRESS §Полигон ping — version const changed before the dispositionFor edit);
|
||||
6. FULL vendored trad2simp table (eval/pilot/trad2simp.txt, hash-synced with the Go embed),
|
||||
not a 12-entry stub;
|
||||
7. default-ignorables = unicode.Cf + variation selectors FE00..FE0F / E0100..E01EF (memnorm.go
|
||||
isIgnorableForMatch), not one hardcoded VS char.
|
||||
Han-script tests cover ALL planes (Ext A–I + non-decomposing compat), and significantLen counts
|
||||
Unicode LETTERS only (mirrors Go's unicode.IsLetter branch; excludes kana middle-dot U+30FB and
|
||||
combining marks) — closes the adversarial-review deltas #1/#2 (supplementary-plane names). D16.3
|
||||
source word-boundary (rose⊄roseanne) is scoped by Go to LATIN/CYRILLIC phonetic keys and is NOT
|
||||
applied to kana (Japanese has no word spaces; names take kana particles) — see kana_precision.py.
|
||||
On any divergence the Go code wins. (eval/memory_hotpath.py is the PRE-cc57c7b prototype — do NOT reuse.)
|
||||
|
||||
INDICATIVE (small N, LLM judge). The deciding version adds human BWS + owner corpus (see
|
||||
09-pilot-protocol.md). Usage: eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run
|
||||
ECHO CONTROL (D13.5 / 07-review §4.5): the model ECHOES the injected glossary preamble ("ГЛОССАРИЙ
|
||||
… 阿Q → А-кью …") before the translation. Scoring the whole output then counts the echoed dst
|
||||
forms as "rendered" → M-modes inflated, and the ADVERSARIAL M3 harm UNDER-counted (the review
|
||||
traced chunk1-M3 inflated 0.667→1.0). We now (a) DETECT the preamble/source echo (reusing
|
||||
refusal_bench's CJK classifier), (b) STRIP it to the translation body before scoring, and (c)
|
||||
FLAG the datapoint echo_contaminated and EXCLUDE it from the clean headline aggregate (optionally
|
||||
--regen-on-echo re-asks once with a hardened prompt) — never a silent strip.
|
||||
|
||||
Provenance (eval rule #1): FULL raw outputs saved (not out[:160]); every run stamps model id +
|
||||
UTC date + usage; a NEW output file per run (never overwrite — exp02 lost 66 raw calls that way).
|
||||
|
||||
Usage:
|
||||
eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run # selection mechanism
|
||||
eval/.venv/bin/python eval/pilot/memory_eval.py --self-test # mirror invariants (no API)
|
||||
eval/.venv/bin/python eval/pilot/memory_eval.py --chapter 6 # indicative M0–M3 on Ah-Q
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, json, re, sys, unicodedata
|
||||
import argparse, json, re, sys, time, unicodedata
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
|
@ -41,16 +77,54 @@ ROOT = Path(__file__).resolve().parent
|
|||
SAMPLES = ROOT.parent / "data" / "samples"
|
||||
OUTDIR = ROOT.parent / "data" / "pilot"
|
||||
OUTDIR.mkdir(parents=True, exist_ok=True)
|
||||
TRAD_TABLE = ROOT / "trad2simp.txt" # vendored from backend/internal/pipeline/data/trad2simp.txt
|
||||
|
||||
# --- normalization (mirror of memnorm.go normalizeSourceKey) ---------------------
|
||||
TRAD2SIMP = {"趙": "赵", "莊": "庄", "錢": "钱", "陳": "陈", "萬": "万", "舊": "旧", "臉": "脸",
|
||||
"魯": "鲁", "鎮": "镇", "剛": "刚", "動": "动", "們": "们"}
|
||||
# --- normalization (faithful mirror of memnorm.go) --------------------------------
|
||||
|
||||
def _load_trad2simp(path: Path) -> dict[str, str]:
|
||||
"""Parse the vendored '<trad> <simp>' table (mirror of parseTradTable). A corrupt line is
|
||||
a loud error — the same fail-loud contract as Go, so a truncated table can't silently
|
||||
re-open the A4 orthography hole."""
|
||||
m: dict[str, str] = {}
|
||||
if not path.exists():
|
||||
raise SystemExit(f"memory_eval: vendored trad2simp table missing: {path}\n"
|
||||
f" cp backend/internal/pipeline/data/trad2simp.txt {path}")
|
||||
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
fields = s.split()
|
||||
if len(fields) != 2 or len(fields[0]) != 1 or len(fields[1]) != 1:
|
||||
raise SystemExit(f"memory_eval: trad2simp line {i}: want 2 single-rune fields, got {s!r}")
|
||||
m[fields[0]] = fields[1]
|
||||
return m
|
||||
|
||||
TRAD2SIMP = _load_trad2simp(TRAD_TABLE)
|
||||
|
||||
def _is_ignorable(c: str) -> bool:
|
||||
"""Mirror of Go isIgnorableForMatch: unicode.Cf + variation selectors FE00..FE0F / E0100..E01EF."""
|
||||
o = ord(c)
|
||||
return unicodedata.category(c) == "Cf" or (0xFE00 <= o <= 0xFE0F) or (0xE0100 <= o <= 0xE01EF)
|
||||
|
||||
def _is_letter(c: str) -> bool:
|
||||
return unicodedata.category(c)[0] == "L" # unicode.IsLetter equivalent (L*)
|
||||
|
||||
def _is_han(c: str) -> bool:
|
||||
"""Full-plane Han (mirror of Go unicode.Is(unicode.Han)): Ext A, URO, compat, Ext B–I,
|
||||
compat-supplement. Covers supplementary-plane ideographs (rare surnames/fantasy names a
|
||||
webnovel corpus can hit) that BMP-only ranges miss — adversarial-review delta #2."""
|
||||
o = ord(c)
|
||||
return (0x3400 <= o <= 0x4DBF or 0x4E00 <= o <= 0x9FFF or 0xF900 <= o <= 0xFAFF
|
||||
or 0x20000 <= o <= 0x2A6DF or 0x2A700 <= o <= 0x2EBEF or 0x2F800 <= o <= 0x2FA1F
|
||||
or 0x30000 <= o <= 0x323AF)
|
||||
|
||||
def norm_src(s: str) -> str:
|
||||
"""Mirror of memnorm.go normalizeSourceKey: NFKC → drop ignorables → trad→simp (per rune)
|
||||
→ katakana→hiragana → Unicode lower. Applied SYMMETRICALLY to keys and chunk text."""
|
||||
s = unicodedata.normalize("NFKC", s)
|
||||
out = []
|
||||
for c in s:
|
||||
if unicodedata.category(c) in ("Cf",) or c in "️":
|
||||
if _is_ignorable(c):
|
||||
continue
|
||||
c = TRAD2SIMP.get(c, c)
|
||||
o = ord(c)
|
||||
|
|
@ -59,92 +133,214 @@ def norm_src(s: str) -> str:
|
|||
out.append(c.lower())
|
||||
return "".join(out)
|
||||
|
||||
HAN = re.compile(r"[一-鿿㐀-䶿]")
|
||||
def significant_len(nk: str) -> int:
|
||||
return sum(1 for c in nk if HAN.match(c) or c.isalpha() or
|
||||
("" <= c <= "ヿ") or ("가" <= c <= ""))
|
||||
"""Mirror of memnorm.go significantLen: count Unicode LETTERS (category L*). Since Han/kana/
|
||||
hangul letters are all category L across every plane, this subsumes Go's explicit
|
||||
Han/Hiragana/Katakana/Hangul clause AND excludes the non-letters it also excludes
|
||||
(kana middle-dot U+30FB Po, combining marks Mn) — more faithful than a fixed block range."""
|
||||
return sum(1 for c in nk if _is_letter(c))
|
||||
|
||||
def any_han(nk: str) -> bool:
|
||||
return bool(HAN.search(nk))
|
||||
def min_key_len(nk: str) -> int:
|
||||
return 2 if any_han(nk) else 3
|
||||
return any(_is_han(c) for c in nk)
|
||||
|
||||
def min_key_len_for(nk: str) -> int:
|
||||
return 2 if any_han(nk) else 3 # minKeyLenHan / minKeyLenPhonetic
|
||||
|
||||
def collision_prone(nk: str) -> bool:
|
||||
"""Short AND purely phonetic (no Han anchor) → likely to fire inside an unrelated word →
|
||||
injected AMBIGUOUS (memory.go collisionProneKey; threshold = minKeyLenPhonetic=3)."""
|
||||
return (not any_han(nk)) and significant_len(nk) <= 3
|
||||
|
||||
# --- glossary (Ah-Q; dst + decl + lemma + aliases + spoiler window) --------------
|
||||
# One deliberate SPOILER entry (未庄 revolution outcome) demonstrates the since/until gate.
|
||||
STICKY_DEPTH = 2 # memory.go stickyDepth
|
||||
|
||||
# --- disposition (mirror of memory.go dispositionFor + injectionDisposition) ------
|
||||
CONFIRMED, AMBIGUOUS, REJECT = "confirmed", "ambiguous", "reject"
|
||||
|
||||
def disposition_for(status: str, via: str, allow_short: bool) -> str:
|
||||
"""Exact-match disposition. approved → confirmed; auto/draft → ambiguous; an approved
|
||||
entry matched by a COLLISION-PRONE key is downgraded to ambiguous (A2 surface-collision).
|
||||
via=='sticky' NEVER reaches here in the D16.2 mirror — sticky INHERITS its disposition."""
|
||||
if via != "sticky" and not allow_short and collision_prone(via):
|
||||
return AMBIGUOUS
|
||||
return CONFIRMED if status == "approved" else AMBIGUOUS
|
||||
|
||||
# --- glossary (Ah-Q) as store-like rows -------------------------------------------
|
||||
# Fields mirror store.GlossaryEntry columns the hot path reads. One SPOILER entry (革命党,
|
||||
# since_ch=7) demonstrates the since gate; 尼姑 is status=draft to exercise AMBIGUOUS-by-status.
|
||||
GLOSSARY = [
|
||||
{"src": "阿Q", "dst": "А-кью", "lemma_keys": ["а-кью"], "hyphen_pat": r"А[-\s]?кью",
|
||||
"decl_forms": ["А-кью"], "aliases": [], "type": "name"},
|
||||
{"src": "未庄", "dst": "Вэйчжуан", "lemma_keys": ["вэйчжуан"],
|
||||
"decl_forms": ["Вэйчжуане", "Вэйчжуан", "Вэйчжуана"], "aliases": [], "type": "place"},
|
||||
{"src": "赵太爷", "dst": "почтенный Чжао", "lemma_keys": ["чжао"],
|
||||
"decl_forms": ["почтенного Чжао", "почтенный Чжао", "Чжао"], "aliases": ["赵老太爷"], "type": "name"},
|
||||
{"src": "王胡", "dst": "Бородатый Ван", "lemma_keys": ["ван"],
|
||||
"decl_forms": ["Бородатого Вана", "Бородатый Ван", "Бородатому Вану"], "aliases": ["王癞胡", "癞胡"], "type": "name"},
|
||||
{"src": "小D", "dst": "Маленький Дэн", "lemma_keys": ["дэн"],
|
||||
"decl_forms": ["Маленького Дэна", "Маленький Дэн", "Дэна"], "aliases": [], "type": "name"},
|
||||
{"src": "假洋鬼子", "dst": "Поддельный заморский чёрт", "lemma_keys": ["заморский", "чёрт"],
|
||||
"decl_forms": ["Поддельного заморского чёрта", "заморский чёрт"], "aliases": [], "type": "nickname"},
|
||||
{"src": "吴妈", "dst": "У-ма", "lemma_keys": ["у-ма"], "hyphen_pat": r"У[-\s]?ма",
|
||||
"decl_forms": ["У-ма", "У-мы"], "aliases": [], "type": "name"},
|
||||
{"src": "秀才", "dst": "сюцай", "lemma_keys": ["сюцай"],
|
||||
"decl_forms": ["сюцая", "сюцай", "сюцаю"], "aliases": [], "type": "title"},
|
||||
{"src": "尼姑", "dst": "монашка", "lemma_keys": ["монашка"],
|
||||
"decl_forms": ["монашку", "монашка", "монашки"], "aliases": [], "type": "term"},
|
||||
{"src": "阿Q", "dst": "А-кью", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": False, "aliases": [], "type": "name",
|
||||
"lemma_keys": ["а-кью"], "hyphen_pat": r"А[-\s]?кью", "decl_forms": ["А-кью"]},
|
||||
{"src": "未庄", "dst": "Вэйчжуан", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": False, "aliases": [], "type": "place",
|
||||
"lemma_keys": ["вэйчжуан"], "decl_forms": ["Вэйчжуане", "Вэйчжуан", "Вэйчжуана"]},
|
||||
{"src": "赵太爷", "dst": "почтенный Чжао", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": False, "aliases": ["赵老太爷"], "type": "name",
|
||||
"lemma_keys": ["чжао"], "decl_forms": ["почтенного Чжао", "почтенный Чжао", "Чжао"]},
|
||||
{"src": "王胡", "dst": "Бородатый Ван", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": False, "aliases": ["王癞胡", "癞胡"], "type": "name",
|
||||
"lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван", "Бородатому Вану"]},
|
||||
{"src": "小D", "dst": "Маленький Дэн", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": False, "aliases": [], "type": "name",
|
||||
"lemma_keys": ["дэн"], "decl_forms": ["Маленького Дэна", "Маленький Дэн", "Дэна"]},
|
||||
{"src": "假洋鬼子", "dst": "Поддельный заморский чёрт", "status": "approved", "sense": "",
|
||||
"since_ch": 0, "until_ch": 0, "allow_short": False, "aliases": [], "type": "nickname",
|
||||
"lemma_keys": ["заморский", "чёрт"], "decl_forms": ["Поддельного заморского чёрта", "заморский чёрт"]},
|
||||
{"src": "吴妈", "dst": "У-ма", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": False, "aliases": [], "type": "name",
|
||||
"lemma_keys": ["у-ма"], "hyphen_pat": r"У[-\s]?ма", "decl_forms": ["У-ма", "У-мы"]},
|
||||
{"src": "秀才", "dst": "сюцай", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": False, "aliases": [], "type": "title",
|
||||
"lemma_keys": ["сюцай"], "decl_forms": ["сюцая", "сюцай", "сюцаю"]},
|
||||
{"src": "尼姑", "dst": "монашка", "status": "draft", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": False, "aliases": [], "type": "term",
|
||||
"lemma_keys": ["монашка"], "decl_forms": ["монашку", "монашка", "монашки"]},
|
||||
# SPOILER demo: only valid from chapter 7 (revolution). Injected earlier → hard reject.
|
||||
{"src": "革命党", "dst": "революционеры", "lemma_keys": ["революционер"],
|
||||
"decl_forms": ["революционеров", "революционеры"], "aliases": [], "type": "term", "since_ch": 7},
|
||||
{"src": "革命党", "dst": "революционеры", "status": "approved", "sense": "", "since_ch": 7, "until_ch": 0,
|
||||
"allow_short": False, "aliases": [], "type": "term",
|
||||
"lemma_keys": ["революционер"], "decl_forms": ["революционеров", "революционеры"]},
|
||||
]
|
||||
|
||||
def eligible_keys(entry: dict) -> list[tuple[str, str]]:
|
||||
"""(normalized_key, raw) surfaces (src+aliases) passing the per-lang min-key ban."""
|
||||
def eligible_keys(entry: dict) -> list[str]:
|
||||
"""Normalized source surfaces (src + aliases) passing the per-lang min-key ban (A3).
|
||||
A record with empty dst is NOT matchable (renders nothing) — mirror memory.go:167-173."""
|
||||
if not str(entry.get("dst", "")).strip():
|
||||
return []
|
||||
out = []
|
||||
for raw in [entry["src"]] + entry.get("aliases", []):
|
||||
nk = norm_src(raw)
|
||||
if nk and significant_len(nk) >= min_key_len(nk):
|
||||
out.append((nk, raw))
|
||||
if nk and (significant_len(nk) >= min_key_len_for(nk) or entry.get("allow_short")):
|
||||
out.append(nk)
|
||||
return out
|
||||
|
||||
def select(chunk: str, chapter: int, sticky_prev: set[str]) -> dict:
|
||||
"""Mirror of MemoryBank.Select: which entries inject, with disposition + spoiler rejects."""
|
||||
nchunk = norm_src(chunk)
|
||||
fired = {} # src → (matched_key, is_collision_prone)
|
||||
for e in GLOSSARY:
|
||||
best = None
|
||||
for nk, raw in eligible_keys(e):
|
||||
if nk in nchunk and (best is None or len(nk) > len(best)):
|
||||
best = nk
|
||||
if best is not None:
|
||||
fired[e["src"]] = best
|
||||
# longest-match containment: drop a fired key fully inside a strictly-longer fired key
|
||||
keys = {e["src"]: fired[e["src"]] for e in GLOSSARY if e["src"] in fired}
|
||||
suppressed = set()
|
||||
for s1, k1 in keys.items():
|
||||
for s2, k2 in keys.items():
|
||||
if s1 != s2 and k1 in k2 and len(k1) < len(k2):
|
||||
suppressed.add(s1)
|
||||
injected, rejected = [], []
|
||||
active = set()
|
||||
for e in GLOSSARY:
|
||||
s = e["src"]
|
||||
if s in fired and s not in suppressed:
|
||||
if e.get("since_ch", 0) and chapter < e["since_ch"]:
|
||||
rejected.append({"src": s, "reason": f"spoiler since_ch={e['since_ch']}>{chapter}"})
|
||||
continue
|
||||
disp = "ambiguous" if collision_prone(fired[s]) else "confirmed"
|
||||
injected.append({**e, "_disp": disp, "_via": fired[s]})
|
||||
active.add(s)
|
||||
# sticky scene-inertia (depth handled by caller passing prior active set)
|
||||
for e in GLOSSARY:
|
||||
s = e["src"]
|
||||
if s in sticky_prev and s not in active and not (e.get("since_ch", 0) and chapter < e["since_ch"]):
|
||||
injected.append({**e, "_disp": "confirmed", "_via": "sticky"})
|
||||
return {"injected": injected, "rejected": rejected, "active": active}
|
||||
def spoiler_blocked(entry: dict, chapter: int) -> bool:
|
||||
"""Mirror of memory.go spoilerBlocked: since_ch/until_ch window (0 = unbounded)."""
|
||||
if entry.get("since_ch", 0) and chapter < entry["since_ch"]:
|
||||
return True
|
||||
if entry.get("until_ch", 0) and chapter > entry["until_ch"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
# --- prompt assembly per condition -----------------------------------------------
|
||||
def glossary_line_tokens(entry: dict) -> int:
|
||||
"""Mirror of memory.go glossaryLineTokens: cjk + other/3 over 'src → dst' (floor-free).
|
||||
cjk bucket = full-plane Han + kana block + hangul (matches Go unicode.In(Han,Hiragana,
|
||||
Katakana,Hangul); supplementary-plane Han now weighted ×1, not ÷3)."""
|
||||
cjk = other = 0
|
||||
for c in f"{entry['src']} → {entry.get('dst','')}":
|
||||
o = ord(c)
|
||||
if _is_han(c) or (0x3040 <= o <= 0x30FF) or (0xAC00 <= o <= 0xD7A3):
|
||||
cjk += 1
|
||||
elif c.isspace():
|
||||
pass
|
||||
else:
|
||||
other += 1
|
||||
return cjk + other // 3
|
||||
|
||||
def _key_occurrences(ntext: str, key: str) -> list[tuple[int, int]]:
|
||||
"""All [start,end) occurrences of key in ntext (rune indices == str indices in Python)."""
|
||||
spans, i = [], ntext.find(key)
|
||||
while i != -1:
|
||||
spans.append((i, i + len(key)))
|
||||
i = ntext.find(key, i + 1)
|
||||
return spans
|
||||
|
||||
def select(chunk: str, chapter: int, sticky_prev: dict[str, str], budget_tokens: int = 0) -> dict:
|
||||
"""Faithful mirror of MemoryBank.Select for ONE chunk: span-match → spoiler-gated
|
||||
containment → disposition → sticky (inherited disposition) → priority token budget.
|
||||
sticky_prev: {id -> inherited disposition} from the prior ≤stickyDepth chunks of this
|
||||
chapter. Returns injected/rejected/evicted lists + active {id->disposition} for the next
|
||||
chunk's sticky window."""
|
||||
ntext = norm_src(chunk)
|
||||
# 1. all key occurrences with owning entry indices
|
||||
occ = [] # (start, end, key, entry_idx)
|
||||
for ei, e in enumerate(GLOSSARY):
|
||||
for k in eligible_keys(e):
|
||||
for (s, t) in _key_occurrences(ntext, k):
|
||||
occ.append((s, t, k, ei))
|
||||
occ.sort(key=lambda m: (m[0], m[1], m[2]))
|
||||
|
||||
# 2. longest-match containment, suppressor gated on spoiler-VALIDITY (memory.go:591-617)
|
||||
def valid_suppressor(m):
|
||||
return not spoiler_blocked(GLOSSARY[m[3]], chapter)
|
||||
kept = []
|
||||
for i, m in enumerate(occ):
|
||||
contained = False
|
||||
for j, o in enumerate(occ):
|
||||
if i == j:
|
||||
continue
|
||||
if o[0] <= m[0] and o[1] >= m[1] and (o[1]-o[0]) > (m[1]-m[0]) and valid_suppressor(o):
|
||||
contained = True
|
||||
break
|
||||
if not contained:
|
||||
kept.append(m)
|
||||
|
||||
# 3. surviving occ → entries, longest firing key per entry
|
||||
matched_via: dict[int, str] = {}
|
||||
for (s, t, k, ei) in kept:
|
||||
if ei not in matched_via or len(k) > len(matched_via[ei]):
|
||||
matched_via[ei] = k
|
||||
|
||||
injected_hits: dict[int, dict] = {} # entry_idx -> picked
|
||||
rejected = []
|
||||
active: dict[str, str] = {} # id -> disposition (exact matches, for next sticky)
|
||||
for ei in range(len(GLOSSARY)):
|
||||
via = matched_via.get(ei)
|
||||
if via is None:
|
||||
continue
|
||||
e = GLOSSARY[ei]
|
||||
eid = _eid(e)
|
||||
if spoiler_blocked(e, chapter):
|
||||
rejected.append({**e, "_via": via, "_disp": REJECT})
|
||||
continue
|
||||
disp = disposition_for(e["status"], via, e.get("allow_short", False))
|
||||
injected_hits[ei] = {**e, "_via": via, "_disp": disp}
|
||||
active[eid] = disp # exact-matched ids feed the next chunk's sticky (NOT sticky-carried)
|
||||
|
||||
# 4. sticky scene-inertia: carry prior-chunk exact matches not re-matched here, INHERITING
|
||||
# their disposition (D16.2), unless the spoiler window blocks them (spoiler beats sticky).
|
||||
hit_ids = {_eid(p) for p in injected_hits.values()}
|
||||
for ei, e in enumerate(GLOSSARY):
|
||||
eid = _eid(e)
|
||||
if eid not in sticky_prev or eid in hit_ids or ei in injected_hits:
|
||||
continue
|
||||
if spoiler_blocked(e, chapter):
|
||||
continue
|
||||
injected_hits[ei] = {**e, "_via": "sticky", "_disp": sticky_prev[eid]}
|
||||
|
||||
# 5. priority order + F2 token budget/eviction (memory.go:342-368)
|
||||
picked = list(injected_hits.values())
|
||||
picked.sort(key=_priority_rank)
|
||||
evicted = []
|
||||
if budget_tokens > 0:
|
||||
used, cut = 0, len(picked)
|
||||
for i, p in enumerate(picked):
|
||||
used += glossary_line_tokens(p)
|
||||
if used > budget_tokens:
|
||||
cut = i
|
||||
break
|
||||
picked, evicted = picked[:cut], picked[cut:]
|
||||
return {"injected": picked, "rejected": rejected, "evicted": evicted, "active": active}
|
||||
|
||||
def _eid(e: dict) -> str:
|
||||
return f'{e["src"]}\x1f{e.get("sense","")}\x1f{e.get("since_ch",0)}\x1f{e.get("until_ch",0)}'
|
||||
|
||||
def _priority_rank(p: dict) -> int:
|
||||
rank = 0
|
||||
if p["_disp"] != CONFIRMED:
|
||||
rank |= 1 << 2
|
||||
if p["_via"] == "sticky":
|
||||
rank |= 1 << 1
|
||||
if p["status"] != "approved":
|
||||
rank |= 1 << 0
|
||||
return rank
|
||||
|
||||
# --- prompt assembly per mode -----------------------------------------------------
|
||||
SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент китайского "
|
||||
"произведения на русский язык. Сохрани все реплики и детали без пропусков; стиль — "
|
||||
"живой литературный русский. Выведи ТОЛЬКО перевод.")
|
||||
"живой литературный русский. Выведи ТОЛЬКО перевод — НЕ повторяй глоссарий и инструкции, "
|
||||
"начни сразу с текста перевода.")
|
||||
SYSTEM_HARDENED = SYSTEM + (" ВАЖНО: не выводи блок ГЛОССАРИЙ, строки вида «иероглиф → перевод», "
|
||||
"заголовки или пояснения — только сам художественный перевод.")
|
||||
GHEAD = "ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно):"
|
||||
|
||||
def gloss_block(entries: list[dict], full: bool = False) -> str:
|
||||
|
|
@ -153,29 +349,143 @@ def gloss_block(entries: list[dict], full: bool = False) -> str:
|
|||
lines = []
|
||||
for e in entries:
|
||||
line = f"{e['src']} → {e['dst']}"
|
||||
if not full and e.get("_disp") == "ambiguous":
|
||||
if not full and e.get("_disp") == AMBIGUOUS:
|
||||
line += " ⟨проверить⟩"
|
||||
lines.append(line)
|
||||
return GHEAD + "\n" + "\n".join(lines)
|
||||
|
||||
def build_prompt(chunk: str, condition: str, sel: dict, summary: str, shuffled: dict) -> str:
|
||||
if condition == "C0":
|
||||
def build_prompt(chunk: str, mode: str, sel: dict, summary: str, shuffled: dict) -> str:
|
||||
if mode == "M0":
|
||||
return "ФРАГМЕНТ:\n" + chunk
|
||||
if condition == "C1": # selective bank
|
||||
if mode == "M1": # selective bank
|
||||
blk = gloss_block(sel["injected"])
|
||||
return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk
|
||||
if condition == "C2": # full glossary + sliding summary (long context)
|
||||
blk = gloss_block([{**e, "_disp": "confirmed"} for e in GLOSSARY if not e.get("since_ch")], full=True)
|
||||
if mode == "M2": # full glossary + sliding summary (long context)
|
||||
blk = gloss_block([{**e, "_disp": CONFIRMED} for e in GLOSSARY
|
||||
if not e.get("since_ch") and not e.get("until_ch")
|
||||
and str(e.get("dst", "")).strip()], full=True)
|
||||
pre = (f"КРАТКОЕ СОДЕРЖАНИЕ ПРЕДЫДУЩЕГО:\n{summary}\n\n" if summary else "")
|
||||
return pre + blk + "\n\nФРАГМЕНТ:\n" + chunk
|
||||
if condition == "C3": # random/wrong glossary (adversarial): fired entries with SHUFFLED dst
|
||||
wrong = [{**e, "dst": shuffled[e["src"]], "_disp": "confirmed"} for e in sel["injected"]
|
||||
if mode == "M3": # random/wrong glossary (adversarial): fired entries with SHUFFLED dst
|
||||
wrong = [{**e, "dst": shuffled[e["src"]], "_disp": CONFIRMED} for e in sel["injected"]
|
||||
if e["_via"] != "sticky"]
|
||||
blk = gloss_block(wrong, full=True)
|
||||
return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk
|
||||
raise ValueError(condition)
|
||||
raise ValueError(mode)
|
||||
|
||||
# --- chunking (reuse the coverage harness's chunker semantics) -------------------
|
||||
# --- echo control (D13.5 / 07-review §4.5) ----------------------------------------
|
||||
GLOSS_ECHO_MARKERS = ("ГЛОССАРИЙ", "глоссарий", "КАНОНИЧЕСКИЕ ПЕРЕВОД", "используй эти утверждённые",
|
||||
"использовать последовательно", "используйте эти", "КРАТКОЕ СОДЕРЖАНИЕ",
|
||||
"⟨проверить", "проверить⟩")
|
||||
ARROW_RE = re.compile(r".+\s*(→|->|=>)\s*.+")
|
||||
|
||||
def detect_echo(raw: str) -> tuple[str, list[str]]:
|
||||
"""Strip a leading glossary/instruction-echo preamble to the translation body AND report
|
||||
contamination reasons. Preamble lines = a gloss marker, a 'src → dst' arrow line carrying
|
||||
CJK, or blanks within the preamble region. Also flags source-echo (CJK leaked into the body,
|
||||
the DeepSeek-style untranslated echo — reuses refusal_bench.cjk_share)."""
|
||||
lines = raw.split("\n")
|
||||
body_start, saw_pre = 0, False
|
||||
for i, ln in enumerate(lines):
|
||||
s = ln.strip()
|
||||
if not s:
|
||||
if saw_pre:
|
||||
body_start = i + 1
|
||||
continue
|
||||
is_marker = any(m in s for m in GLOSS_ECHO_MARKERS)
|
||||
is_arrow_gloss = bool(ARROW_RE.match(s)) and bool(rb.CJK_RE.search(s))
|
||||
if is_marker or is_arrow_gloss:
|
||||
saw_pre, body_start = True, i + 1
|
||||
continue
|
||||
break # first prose line → body begins
|
||||
body = "\n".join(lines[body_start:]).strip()
|
||||
reasons = []
|
||||
if saw_pre:
|
||||
reasons.append("glossary_preamble_echo")
|
||||
if not body: # whole output was preamble/echo → keep raw but flag hard
|
||||
reasons.append("empty_after_strip")
|
||||
body = raw.strip()
|
||||
ces = rb.cjk_share(body)
|
||||
if ces > 0.10:
|
||||
reasons.append(f"source_echo(cjk={ces:.0%})")
|
||||
return body, reasons
|
||||
|
||||
# --- fidelity judge (cross-family, D13.3) -----------------------------------------
|
||||
FAMILY = {"deepseek": "deepseek", "grok": "xai", "gemini": "google", "gpt": "openai",
|
||||
"glm": "zhipu", "kimi": "moonshot", "mistral": "mistral", "qwen": "alibaba"}
|
||||
|
||||
def family_of(model: str) -> str:
|
||||
for k, v in FAMILY.items():
|
||||
if k in model:
|
||||
return v
|
||||
return model
|
||||
|
||||
FIDELITY_SYSTEM = (
|
||||
"Ты — строгий редактор-оценщик ВЕРНОСТИ художественного перевода. Тебе дают ИСХОДНЫЙ "
|
||||
"фрагмент (китайский) и его ПЕРЕВОД на русский. Оцени ТОЛЬКО верность исходнику (не стиль): "
|
||||
"искажения смысла (mistranslation), пропуски (omission), отсебятину (addition), особенно "
|
||||
"НЕВЕРНО переданные имена/термины. Верни СТРОГО JSON без пояснений: "
|
||||
'{"fidelity": <0-100>, "n_mistranslation": <int>, "n_omission": <int>, "n_addition": <int>, '
|
||||
'"wrong_names": [<строки>], "notes": "<кратко>"}. fidelity=100 — идеально верно; ниже — за '
|
||||
"каждое искажение/пропуск. Имена, переданные не как в источнике, — это mistranslation.")
|
||||
|
||||
def fidelity_judge(source: str, translation: str, judge_model: str, translator_model: str,
|
||||
providers: dict) -> dict:
|
||||
"""Cross-family fidelity judge vs the SOURCE (D13.3: judge family != translator family)."""
|
||||
if family_of(judge_model) == family_of(translator_model):
|
||||
raise ValueError(f"fidelity judge {judge_model} shares family with translator "
|
||||
f"{translator_model} ({family_of(judge_model)}) — violates D13.3")
|
||||
user = (f"ИСХОДНЫЙ ФРАГМЕНТ (китайский):\n{source}\n\n"
|
||||
f"ПЕРЕВОД НА РУССКИЙ (оцени его верность):\n{translation}\n\nВерни JSON.")
|
||||
p = providers[judge_model]
|
||||
txt, err, usage = _retry_call({"name": judge_model, "model": p["model"], **p}, FIDELITY_SYSTEM, user, timeout=200)
|
||||
if err or not txt:
|
||||
return {"error": err or "empty", "usage": usage or {}}
|
||||
m = re.search(r"\{.*\}", txt, re.S)
|
||||
if not m:
|
||||
return {"error": "no-json", "raw": txt[:300], "usage": usage or {}}
|
||||
try:
|
||||
out = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
return {"error": "bad-json", "raw": txt[:300], "usage": usage or {}}
|
||||
out["usage"] = usage or {}
|
||||
out["judge_model"] = judge_model
|
||||
return out
|
||||
|
||||
# --- providers (translator + judge) -----------------------------------------------
|
||||
PROVIDERS = {
|
||||
"deepseek-v4-flash": {"base_url": "https://api.deepseek.com/v1", "api_key_env": "DEEPSEEK_API_KEY",
|
||||
"model": "deepseek-v4-flash", "max_tokens": 8000},
|
||||
"grok-4.20-0309-non-reasoning": {"base_url": "https://api.x.ai/v1", "api_key_env": "XAI_API_KEY",
|
||||
"model": "grok-4.20-0309-non-reasoning", "max_tokens": 8000, "temperature": 0.3},
|
||||
"gemini-2.5-flash": {"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
"api_key_env": "GEMINI_API_KEY", "model": "gemini-2.5-flash", "max_tokens": 8000,
|
||||
"extra_body": {"extra_body": {"google": {"thinking_config": {"thinking_budget": 0}}}}},
|
||||
"gpt-5-mini": {"base_url": "https://api.openai.com/v1", "api_key_env": "OPENAI_API_KEY",
|
||||
"model": "gpt-5-mini", "reasoning_params": True, "max_tokens": 8000,
|
||||
"extra_body": {"reasoning_effort": "minimal"}},
|
||||
}
|
||||
|
||||
def _retry_call(p: dict, system: str, user: str, timeout: int = 200, attempts: int = 3):
|
||||
"""rb.call_provider with backoff on TRANSIENT errors (503 overload / 429 rate / 5xx /
|
||||
transport) so a provider spike does not punch a hole in the fidelity axis or a translation.
|
||||
A content refusal / config error is NOT retried (it is a real signal)."""
|
||||
last = (None, "unknown", {})
|
||||
for i in range(attempts):
|
||||
t, err, usage = rb.call_provider(p, system, user, timeout=timeout)
|
||||
if not err or not re.match(r"(http\|(503|429|500|502|504))|transport\|", err or ""):
|
||||
return t, err, usage
|
||||
last = (t, err, usage)
|
||||
time.sleep(2 * (i + 1))
|
||||
return last
|
||||
|
||||
def translate(prompt: str, model: str, hardened: bool = False) -> tuple[str, dict]:
|
||||
p = PROVIDERS[model]
|
||||
sysmsg = SYSTEM_HARDENED if hardened else SYSTEM
|
||||
t, err, usage = _retry_call({"name": model, **p}, sysmsg, prompt, timeout=200)
|
||||
return (t or ""), (usage or {})
|
||||
|
||||
# --- chunking (reuse the coverage harness's chunker semantics) --------------------
|
||||
def chunk_text(text: str, target=1200) -> list[str]:
|
||||
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||
chunks, buf = [], []
|
||||
|
|
@ -187,68 +497,205 @@ def chunk_text(text: str, target=1200) -> list[str]:
|
|||
chunks.append("\n\n".join(buf))
|
||||
return chunks
|
||||
|
||||
def translate(chunk_prompt: str, model="deepseek-v4-flash") -> tuple[str, dict]:
|
||||
cfg = {"deepseek-v4-flash": {"base_url": "https://api.deepseek.com/v1", "api_key_env": "DEEPSEEK_API_KEY", "max_tokens": 8000},
|
||||
"grok-4.20-0309-non-reasoning": {"base_url": "https://api.x.ai/v1", "api_key_env": "XAI_API_KEY", "max_tokens": 8000, "temperature": 0.3}}[model]
|
||||
t, err, usage = rb.call_provider({"name": model, "model": model, **cfg}, SYSTEM, chunk_prompt, timeout=200)
|
||||
return (t or ""), (usage or {})
|
||||
# --- self-test: mirror invariants without any API call ----------------------------
|
||||
def self_test() -> int:
|
||||
fails = []
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
fails.append(msg)
|
||||
# since gate: 革命党 rejected at ch6, injected at ch7
|
||||
s6 = select("革命党来了,未庄震动。", 6, {})
|
||||
check(any(r["src"] == "革命党" for r in s6["rejected"]), "since-gate: 革命党 not rejected at ch6")
|
||||
s7 = select("革命党来了,未庄震动。", 7, {})
|
||||
check(any(p["src"] == "革命党" for p in s7["injected"]), "since-gate: 革命党 not injected at ch7")
|
||||
# until gate (synthetic): a term valid only until ch3 must be rejected at ch10
|
||||
global GLOSSARY
|
||||
saved = GLOSSARY
|
||||
GLOSSARY = saved + [{"src": "旧党", "dst": "старая партия", "status": "approved", "sense": "",
|
||||
"since_ch": 0, "until_ch": 3, "allow_short": False, "aliases": [], "type": "term",
|
||||
"lemma_keys": ["партия"], "decl_forms": ["старой партии", "старая партия"]}]
|
||||
check(spoiler_blocked(GLOSSARY[-1], 10), "until-gate: 旧党 not blocked at ch10")
|
||||
check(not spoiler_blocked(GLOSSARY[-1], 2), "until-gate: 旧党 wrongly blocked at ch2")
|
||||
GLOSSARY = saved
|
||||
# collision downgrade: a short phonetic key (allow_short off) → AMBIGUOUS even if approved
|
||||
check(disposition_for("approved", "ai", False) == AMBIGUOUS, "collision: 'ai' not downgraded")
|
||||
check(disposition_for("approved", "мерос", False) == CONFIRMED, "collision: 5-char key wrongly downgraded")
|
||||
check(disposition_for("approved", "リン", False) == AMBIGUOUS, "collision: 2-kana key not downgraded")
|
||||
# supplementary-plane Han is Han-anchored (floor 2, CONFIRMED), not phonetic — review delta #2
|
||||
check(any_han("\U00020000") and not collision_prone("\U00020000\U00020001"),
|
||||
"supplementary-plane Han not treated as Han")
|
||||
# significant_len counts letters only (kana middle-dot U+30FB excluded) — review delta #1
|
||||
check(significant_len("リ・ン") == 2, "significant_len counts kana middle-dot U+30FB")
|
||||
# sticky depth-2 + disposition inheritance: a draft term inherits AMBIGUOUS across a
|
||||
# pronominal chunk, then falls out after depth 2.
|
||||
win = []
|
||||
def union(w): # mirror unionSticky
|
||||
out = {}
|
||||
for d in w:
|
||||
out.update(d)
|
||||
return out
|
||||
c0 = select("这尼姑是谁。", 6, union(win)); win = (win + [c0["active"]])[-STICKY_DEPTH:]
|
||||
check(any(p["src"] == "尼姑" and p["_disp"] == AMBIGUOUS for p in c0["injected"]),
|
||||
"sticky: 尼姑(draft) not AMBIGUOUS on exact match")
|
||||
c1 = select("他走了。", 6, union(win)); win = (win + [c1["active"]])[-STICKY_DEPTH:]
|
||||
sk = [p for p in c1["injected"] if p["src"] == "尼姑"]
|
||||
check(sk and sk[0]["_via"] == "sticky" and sk[0]["_disp"] == AMBIGUOUS,
|
||||
"sticky: 尼姑 not carried with INHERITED ambiguous disposition (D16.2)")
|
||||
# depth-2 window: a term exact-matched at c0 is carried across c1 AND c2 (the window holds the
|
||||
# last stickyDepth active sets), then falls out at c3 — mirror of runner.go unionSticky/trim.
|
||||
c2 = select("又走了。", 6, union(win)); win = (win + [c2["active"]])[-STICKY_DEPTH:]
|
||||
check(any(p["src"] == "尼姑" and p["_via"] == "sticky" for p in c2["injected"]),
|
||||
"sticky: 尼姑 should still be carried at depth 2 (c2)")
|
||||
c3 = select("再走了。", 6, union(win)); win = (win + [c3["active"]])[-STICKY_DEPTH:]
|
||||
check(not any(p["src"] == "尼姑" for p in c3["injected"]),
|
||||
"sticky: 尼姑 should fall out after the depth-2 window (c3)")
|
||||
# spoiler-gated containment (synthetic): a spoiler-blocked longer key must NOT suppress a
|
||||
# valid shorter nested name.
|
||||
GLOSSARY = saved + [
|
||||
{"src": "林", "dst": "Линь", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
|
||||
"allow_short": True, "aliases": [], "type": "name", "lemma_keys": ["линь"], "decl_forms": ["Линь"]},
|
||||
{"src": "林动的父亲", "dst": "отец Линь Дуна", "status": "approved", "sense": "", "since_ch": 0,
|
||||
"until_ch": 3, "allow_short": False, "aliases": [], "type": "name",
|
||||
"lemma_keys": ["отец"], "decl_forms": ["отца Линь Дуна"]}]
|
||||
sc = select("林动的父亲来了。", 10, {})
|
||||
check(any(p["src"] == "林" for p in sc["injected"]),
|
||||
"containment: 林 suppressed by spoiler-blocked longer key at ch10")
|
||||
GLOSSARY = saved
|
||||
# token budget eviction: tiny budget keeps only the highest-priority record
|
||||
sb = select("阿Q和未庄和王胡和秀才。", 6, {}, budget_tokens=3)
|
||||
check(len(sb["evicted"]) > 0 and len(sb["injected"]) >= 1, "budget: no eviction under tiny budget")
|
||||
print("SELF-TEST:", "OK" if not fails else "FAIL")
|
||||
for f in fails:
|
||||
print(" ✗", f)
|
||||
return 1 if fails else 0
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--src", default=str(SAMPLES / "zh" / "luxun-ah-q-ch5-9.txt"))
|
||||
ap.add_argument("--chapter", type=int, default=6, help="chapter number for the spoiler gate")
|
||||
ap.add_argument("--conditions", default="C0,C1,C2,C3")
|
||||
ap.add_argument("--model", default="grok-4.20-0309-non-reasoning")
|
||||
ap.add_argument("--chapter", type=int, default=6, help="chapter number for the spoiler gate "
|
||||
"(single-chapter indicative: no per-chunk chapter-reset; the deciding run "
|
||||
"feeds real multi-chapter books, where the sticky window resets per chapter)")
|
||||
ap.add_argument("--modes", default="M0,M1,M2,M3")
|
||||
ap.add_argument("--model", default="grok-4.20-0309-non-reasoning", help="translator/draft model")
|
||||
ap.add_argument("--judge", default="gemini-2.5-flash", help="cross-family fidelity judge (D13.3)")
|
||||
ap.add_argument("--no-fidelity", action="store_true", help="skip the LLM fidelity axis")
|
||||
ap.add_argument("--budget", type=int, default=0, help="glossary token budget (0 = unbounded)")
|
||||
ap.add_argument("--regen-on-echo", action="store_true", help="re-ask once with a hardened prompt on echo")
|
||||
ap.add_argument("--max-chunks", type=int, default=5)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--out", default=str(OUTDIR / "memory_eval.json"))
|
||||
ap.add_argument("--self-test", action="store_true")
|
||||
ap.add_argument("--out", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.self_test:
|
||||
sys.exit(self_test())
|
||||
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
out_path = Path(args.out) if args.out else OUTDIR / f"memory_eval_M_{stamp}.json"
|
||||
raw_dir = OUTDIR / f"raw_{stamp}"
|
||||
|
||||
text = Path(args.src).read_text(encoding="utf-8")
|
||||
chunks = chunk_text(text)[:args.max_chunks]
|
||||
conditions = args.conditions.split(",")
|
||||
# deterministic wrong-dst shuffle for C3 (rotate dst among present names)
|
||||
modes = args.modes.split(",")
|
||||
# deterministic wrong-dst shuffle for M3 (rotate dst among present names/places)
|
||||
names = [e for e in GLOSSARY if e["type"] in ("name", "place")]
|
||||
rot = {names[i]["src"]: names[(i + 1) % len(names)]["dst"] for i in range(len(names))}
|
||||
shuffled = {e["src"]: rot.get(e["src"], "НЕВЕРНО") for e in GLOSSARY}
|
||||
|
||||
print(f"src={Path(args.src).name} chapter={args.chapter} chunks={len(chunks)} "
|
||||
f"model={args.model} conditions={conditions}", file=sys.stderr)
|
||||
rows, sticky_prev = [], set()
|
||||
print(f"src={Path(args.src).name} chapter={args.chapter} chunks={len(chunks)} model={args.model} "
|
||||
f"judge={args.judge} modes={modes} budget={args.budget}", file=sys.stderr)
|
||||
if not args.dry_run and not args.no_fidelity:
|
||||
if family_of(args.judge) == family_of(args.model):
|
||||
sys.exit(f"D13.3: judge {args.judge} shares family with translator {args.model}")
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows, sticky_win = [], [] # sticky_win: list of {id->disp}, capped at STICKY_DEPTH
|
||||
def union_sticky():
|
||||
out = {}
|
||||
for d in sticky_win:
|
||||
out.update(d)
|
||||
return out
|
||||
|
||||
for ci, ch in enumerate(chunks):
|
||||
sel = select(ch, args.chapter, sticky_prev)
|
||||
sel = select(ch, args.chapter, union_sticky(), args.budget)
|
||||
present = {e["src"]: e for e in sel["injected"] if e["_via"] != "sticky"}
|
||||
row = {"chunk": ci, "src_chars": len(ch), "n_injected": len(sel["injected"]),
|
||||
"injected_srcs": [e["src"] for e in sel["injected"]],
|
||||
"dispositions": {e["src"]: e["_disp"] for e in sel["injected"]},
|
||||
"rejected_spoiler": sel["rejected"], "present_for_consistency": list(present)}
|
||||
row = {"chunk": ci, "src_chars": len(ch),
|
||||
"injected": [{"src": e["src"], "disp": e["_disp"], "via": e["_via"]} for e in sel["injected"]],
|
||||
"rejected_spoiler": [{"src": r["src"]} for r in sel["rejected"]],
|
||||
"evicted": [{"src": e["src"]} for e in sel["evicted"]],
|
||||
"present_for_consistency": list(present)}
|
||||
if not args.dry_run:
|
||||
row["by_condition"] = {}
|
||||
for cond in conditions:
|
||||
prompt = build_prompt(ch, cond, sel, summary="", shuffled=shuffled)
|
||||
out, usage = translate(prompt, args.model)
|
||||
cons = dm.consistency(out, {s: {"dst": e["dst"], "lemma_keys": e.get("lemma_keys", []),
|
||||
row["by_mode"] = {}
|
||||
for mode in modes:
|
||||
prompt = build_prompt(ch, mode, sel, summary="", shuffled=shuffled)
|
||||
raw, usage = translate(prompt, args.model)
|
||||
body, echo = detect_echo(raw)
|
||||
regen = None
|
||||
if echo and args.regen_on_echo:
|
||||
raw2, usage2 = translate(prompt, args.model, hardened=True)
|
||||
body2, echo2 = detect_echo(raw2)
|
||||
regen = {"raw": raw2, "echo": echo2, "usage": usage2}
|
||||
if not echo2: # clean on retry → use it
|
||||
body, echo, usage, raw = body2, echo2, usage2, raw2
|
||||
cons = dm.consistency(body, {s: {"dst": e["dst"], "lemma_keys": e.get("lemma_keys", []),
|
||||
"hyphen_pat": e.get("hyphen_pat"), "decl_forms": e.get("decl_forms", [])}
|
||||
for s, e in present.items()}) if present else None
|
||||
row["by_condition"][cond] = {
|
||||
fid = None
|
||||
if not args.no_fidelity:
|
||||
fid = fidelity_judge(ch, body, args.judge, args.model, PROVIDERS)
|
||||
# persist FULL raw output for post-hoc audit (never a preview)
|
||||
(raw_dir / f"chunk{ci}_{mode}.txt").write_text(
|
||||
f"# model={args.model} chunk={ci} mode={mode} chapter={args.chapter} echo={echo}\n"
|
||||
f"# usage={usage}\n\n{raw}", encoding="utf-8")
|
||||
row["by_mode"][mode] = {
|
||||
"echo_contaminated": echo or None,
|
||||
"consistency_pymorphy": cons["pymorphy"]["score"] if cons else None,
|
||||
"consistency_stored_decl": cons["stored_decl"]["score"] if cons else None,
|
||||
"missed_pymorphy": cons["pymorphy"]["missed"] if cons else [],
|
||||
"missed_stored_decl": cons["stored_decl"]["missed"] if cons else [],
|
||||
"fidelity": fid,
|
||||
"in_tok": usage.get("prompt_tokens"), "out_tok": usage.get("completion_tokens"),
|
||||
"out_preview": out[:160]}
|
||||
print(f" chunk{ci} {cond}: consistency(pymorphy)="
|
||||
f"{row['by_condition'][cond]['consistency_pymorphy']} "
|
||||
"out_full": raw, "body_scored": body, "regen": regen}
|
||||
fscore = (fid or {}).get("fidelity") if isinstance(fid, dict) else None
|
||||
print(f" chunk{ci} {mode}: cons(pym)={row['by_mode'][mode]['consistency_pymorphy']} "
|
||||
f"cons(decl)={row['by_mode'][mode]['consistency_stored_decl']} "
|
||||
f"fidelity={fscore} echo={echo or '-'} "
|
||||
f"in={usage.get('prompt_tokens')} out={usage.get('completion_tokens')}", file=sys.stderr)
|
||||
sticky_prev = sel["active"]
|
||||
sticky_win = (sticky_win + [sel["active"]])[-STICKY_DEPTH:]
|
||||
rows.append(row)
|
||||
|
||||
Path(args.out).write_text(json.dumps({"config": vars(args), "rows": rows}, ensure_ascii=False, indent=2))
|
||||
print(f"\nwrote {args.out}", file=sys.stderr)
|
||||
# dry-run: show the selection mechanism (spoiler reject, disposition, sticky)
|
||||
meta = {"run_utc": stamp, "src": args.src, "chapter": args.chapter, "modes": modes,
|
||||
"translator_model": args.model, "judge_model": None if args.no_fidelity else args.judge,
|
||||
"budget_tokens": args.budget, "regen_on_echo": args.regen_on_echo,
|
||||
"trad2simp_entries": len(TRAD2SIMP), "sticky_depth": STICKY_DEPTH,
|
||||
"note": "M0-M3 = terminology modes (exp09 §6); mirror synced with memory.go D13.5; "
|
||||
"sticky-disposition mirrors the D16.2 fix (Go v3, landing 2026-07-09)."}
|
||||
out_path.write_text(json.dumps({"meta": meta, "rows": rows}, ensure_ascii=False, indent=2))
|
||||
print(f"\nwrote {out_path}", file=sys.stderr)
|
||||
if not args.dry_run:
|
||||
print(f"raw outputs: {raw_dir}", file=sys.stderr)
|
||||
_print_aggregate(rows, modes)
|
||||
if args.dry_run:
|
||||
for r in rows:
|
||||
print(f"chunk{r['chunk']} inj={r['injected_srcs']} disp={r['dispositions']} "
|
||||
f"spoiler_rej={r['rejected_spoiler']}")
|
||||
print(f"chunk{r['chunk']} inj={[(e['src'], e['disp'], e['via']) for e in r['injected']]} "
|
||||
f"spoiler_rej={[e['src'] for e in r['rejected_spoiler']]} evicted={[e['src'] for e in r['evicted']]}")
|
||||
|
||||
def _print_aggregate(rows, modes):
|
||||
"""Headline aggregate with clean (echo-excluded) vs all points, per mode."""
|
||||
print("\n=== AGGREGATE (mean over chunks; clean = echo-contaminated points excluded) ===", file=sys.stderr)
|
||||
for mode in modes:
|
||||
pts = [r["by_mode"][mode] for r in rows if "by_mode" in r]
|
||||
clean = [p for p in pts if not p["echo_contaminated"]]
|
||||
def mean(seq, key, src=pts):
|
||||
vals = [p[key] for p in src if isinstance(p.get(key), (int, float))]
|
||||
return round(sum(vals) / len(vals), 3) if vals else None
|
||||
def fmean(src):
|
||||
vals = [p["fidelity"]["fidelity"] for p in src
|
||||
if isinstance(p.get("fidelity"), dict) and isinstance(p["fidelity"].get("fidelity"), (int, float))]
|
||||
return round(sum(vals) / len(vals), 1) if vals else None
|
||||
n_echo = sum(1 for p in pts if p["echo_contaminated"])
|
||||
print(f" {mode}: cons(decl) all={mean(pts,'consistency_stored_decl')} clean={mean(clean,'consistency_stored_decl',clean)} "
|
||||
f"| fidelity all={fmean(pts)} clean={fmean(clean)} | echo_pts={n_echo}/{len(pts)}", file=sys.stderr)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
563
eval/pilot/trad2simp.txt
Normal file
563
eval/pilot/trad2simp.txt
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
# Traditional → Simplified Chinese character map (A4 normalization, memory bank v2).
|
||||
#
|
||||
# Format: one mapping per line, "<trad> <simp>" (whitespace-separated, single rune
|
||||
# each). '#' comments and blank lines are ignored. Loaded once via go:embed and
|
||||
# applied character-by-character in memnorm.go, SYMMETRICALLY to glossary keys and
|
||||
# to chunk text — so a key written in Simplified still matches a Traditional source
|
||||
# surface (and vice versa), closing the "тихо пусто" orthography hole (registry A4).
|
||||
#
|
||||
# PROVENANCE / COMPLETENESS (read before trusting): this is a curated HIGH-FREQUENCY
|
||||
# set, NOT the full OpenCC TSCharacters table. It was authored offline (no OpenCC/
|
||||
# Unihan data available on the stand) with accuracy prioritized over size — a WRONG
|
||||
# trad→simp mapping silently corrupts matching, so only high-confidence single-char
|
||||
# mappings are included; phrase-level OpenCC (one-to-many, e.g. 後→后 vs 后→后) is
|
||||
# deliberately out of scope for v1. The table's content is versioned by
|
||||
# memoryNormVersion (memnorm.go), folded into the snapshot via memoryVersion() — so
|
||||
# COMPLETING it with the full OpenCC data is a data-file swap that fires a loud
|
||||
# --resnapshot, never a silent behaviour change. The acceptance book is ja→ru, where
|
||||
# trad→simp is not load-bearing (NFKC + kana-fold + ruby carry ja); this table matters
|
||||
# for zh sources and is the one tracked data-completion item before a zh acceptance run.
|
||||
# Known live-bug cases from research/14 (爺→爷 etc.) and every retrieval_bench trap
|
||||
# character are covered and asserted in memnorm_test.go.
|
||||
|
||||
# --- referenced by the eval specs / research/14 live bugs (must stay covered) ---
|
||||
魯 鲁
|
||||
鎮 镇
|
||||
趙 赵
|
||||
蕭 萧
|
||||
煉 炼
|
||||
門 门
|
||||
闆 板
|
||||
莊 庄
|
||||
錢 钱
|
||||
陳 陈
|
||||
萬 万
|
||||
舊 旧
|
||||
臉 脸
|
||||
爺 爷
|
||||
羅 罗
|
||||
莊 庄
|
||||
|
||||
# --- 訁 (speech) radical ---
|
||||
說 说
|
||||
話 话
|
||||
語 语
|
||||
讀 读
|
||||
誰 谁
|
||||
課 课
|
||||
談 谈
|
||||
請 请
|
||||
謝 谢
|
||||
詩 诗
|
||||
詞 词
|
||||
試 试
|
||||
記 记
|
||||
訪 访
|
||||
許 许
|
||||
論 论
|
||||
訴 诉
|
||||
譯 译
|
||||
議 议
|
||||
護 护
|
||||
譽 誉
|
||||
讚 赞
|
||||
變 变
|
||||
讓 让
|
||||
認 认
|
||||
識 识
|
||||
訊 讯
|
||||
訂 订
|
||||
計 计
|
||||
訓 训
|
||||
設 设
|
||||
詳 详
|
||||
誠 诚
|
||||
語 语
|
||||
誤 误
|
||||
說 说
|
||||
誦 诵
|
||||
調 调
|
||||
諒 谅
|
||||
談 谈
|
||||
謎 谜
|
||||
講 讲
|
||||
謙 谦
|
||||
謠 谣
|
||||
證 证
|
||||
評 评
|
||||
|
||||
# --- 貝 (shell/money) radical ---
|
||||
貝 贝
|
||||
財 财
|
||||
貨 货
|
||||
貧 贫
|
||||
貪 贪
|
||||
責 责
|
||||
貫 贯
|
||||
貴 贵
|
||||
買 买
|
||||
費 费
|
||||
貼 贴
|
||||
賀 贺
|
||||
貿 贸
|
||||
資 资
|
||||
賊 贼
|
||||
賓 宾
|
||||
賦 赋
|
||||
賠 赔
|
||||
賣 卖
|
||||
賤 贱
|
||||
賬 账
|
||||
賭 赌
|
||||
賴 赖
|
||||
購 购
|
||||
賽 赛
|
||||
贈 赠
|
||||
贏 赢
|
||||
賺 赚
|
||||
賞 赏
|
||||
賢 贤
|
||||
質 质
|
||||
贖 赎
|
||||
貸 贷
|
||||
賃 赁
|
||||
賄 贿
|
||||
賂 赂
|
||||
|
||||
# --- 車 (vehicle) radical ---
|
||||
車 车
|
||||
輪 轮
|
||||
軍 军
|
||||
較 较
|
||||
輕 轻
|
||||
轉 转
|
||||
輸 输
|
||||
輩 辈
|
||||
輝 辉
|
||||
軟 软
|
||||
軌 轨
|
||||
轟 轰
|
||||
輔 辅
|
||||
輛 辆
|
||||
載 载
|
||||
轎 轿
|
||||
|
||||
# --- 門 (gate) radical ---
|
||||
開 开
|
||||
閉 闭
|
||||
問 问
|
||||
間 间
|
||||
閃 闪
|
||||
閒 闲
|
||||
閑 闲
|
||||
閣 阁
|
||||
閩 闽
|
||||
閱 阅
|
||||
闊 阔
|
||||
關 关
|
||||
闖 闯
|
||||
闕 阙
|
||||
悶 闷
|
||||
聞 闻
|
||||
|
||||
# --- 食 (food) radical ---
|
||||
飛 飞
|
||||
風 风
|
||||
飯 饭
|
||||
飲 饮
|
||||
餓 饿
|
||||
館 馆
|
||||
養 养
|
||||
餘 余
|
||||
餅 饼
|
||||
饑 饥
|
||||
饞 馋
|
||||
飄 飘
|
||||
|
||||
# --- 金 (metal) radical ---
|
||||
鐘 钟
|
||||
鐵 铁
|
||||
銀 银
|
||||
銅 铜
|
||||
鋼 钢
|
||||
錯 错
|
||||
鎖 锁
|
||||
鏡 镜
|
||||
鑰 钥
|
||||
鑽 钻
|
||||
鍋 锅
|
||||
鍵 键
|
||||
錄 录
|
||||
針 针
|
||||
釘 钉
|
||||
鈴 铃
|
||||
鉛 铅
|
||||
銷 销
|
||||
鋒 锋
|
||||
鏈 链
|
||||
鑼 锣
|
||||
鈔 钞
|
||||
鑲 镶
|
||||
|
||||
# --- 頁 (head/page) radical ---
|
||||
頁 页
|
||||
頂 顶
|
||||
項 项
|
||||
順 顺
|
||||
須 须
|
||||
預 预
|
||||
頑 顽
|
||||
頓 顿
|
||||
頗 颇
|
||||
領 领
|
||||
頸 颈
|
||||
頻 频
|
||||
顆 颗
|
||||
題 题
|
||||
額 额
|
||||
顏 颜
|
||||
願 愿
|
||||
類 类
|
||||
顧 顾
|
||||
顯 显
|
||||
顫 颤
|
||||
|
||||
# --- 馬 (horse) radical ---
|
||||
馬 马
|
||||
駕 驾
|
||||
駛 驶
|
||||
駐 驻
|
||||
駝 驼
|
||||
騎 骑
|
||||
騙 骗
|
||||
驕 骄
|
||||
驗 验
|
||||
驚 惊
|
||||
驢 驴
|
||||
駭 骇
|
||||
駱 骆
|
||||
騰 腾
|
||||
|
||||
# --- 鳥 (bird) radical ---
|
||||
鳥 鸟
|
||||
鴨 鸭
|
||||
鵝 鹅
|
||||
鴻 鸿
|
||||
鵬 鹏
|
||||
鶴 鹤
|
||||
鷹 鹰
|
||||
鷗 鸥
|
||||
鴉 鸦
|
||||
鳴 鸣
|
||||
鴿 鸽
|
||||
|
||||
# --- 魚 (fish) radical ---
|
||||
魚 鱼
|
||||
鮮 鲜
|
||||
鯨 鲸
|
||||
鯉 鲤
|
||||
鱗 鳞
|
||||
|
||||
# --- 糸 (silk/thread) radical ---
|
||||
紀 纪
|
||||
約 约
|
||||
紅 红
|
||||
紙 纸
|
||||
級 级
|
||||
納 纳
|
||||
紛 纷
|
||||
純 纯
|
||||
紗 纱
|
||||
綱 纲
|
||||
網 网
|
||||
綿 绵
|
||||
線 线
|
||||
練 练
|
||||
組 组
|
||||
細 细
|
||||
終 终
|
||||
結 结
|
||||
絕 绝
|
||||
給 给
|
||||
絡 络
|
||||
統 统
|
||||
綠 绿
|
||||
維 维
|
||||
綜 综
|
||||
緊 紧
|
||||
緒 绪
|
||||
編 编
|
||||
緩 缓
|
||||
締 缔
|
||||
緣 缘
|
||||
縣 县
|
||||
縫 缝
|
||||
縮 缩
|
||||
績 绩
|
||||
繩 绳
|
||||
繫 系
|
||||
繪 绘
|
||||
繼 继
|
||||
續 续
|
||||
纏 缠
|
||||
紋 纹
|
||||
綁 绑
|
||||
繳 缴
|
||||
經 经
|
||||
絲 丝
|
||||
繡 绣
|
||||
|
||||
# --- water / fire / misc very-high-frequency ---
|
||||
漢 汉
|
||||
灣 湾
|
||||
濟 济
|
||||
潔 洁
|
||||
澤 泽
|
||||
濕 湿
|
||||
灑 洒
|
||||
滅 灭
|
||||
溫 温
|
||||
準 准
|
||||
淚 泪
|
||||
減 减
|
||||
滾 滚
|
||||
滿 满
|
||||
漁 渔
|
||||
漸 渐
|
||||
潑 泼
|
||||
濁 浊
|
||||
瀉 泻
|
||||
決 决
|
||||
沒 没
|
||||
沖 冲
|
||||
況 况
|
||||
淨 净
|
||||
涼 凉
|
||||
凍 冻
|
||||
熱 热
|
||||
煩 烦
|
||||
燒 烧
|
||||
燈 灯
|
||||
燦 灿
|
||||
爛 烂
|
||||
營 营
|
||||
爐 炉
|
||||
煙 烟
|
||||
熾 炽
|
||||
燭 烛
|
||||
氣 气
|
||||
氫 氢
|
||||
|
||||
# --- 犭 (animal) radical ---
|
||||
獨 独
|
||||
獲 获
|
||||
獸 兽
|
||||
獻 献
|
||||
獄 狱
|
||||
猶 犹
|
||||
狹 狭
|
||||
獅 狮
|
||||
獵 猎
|
||||
豬 猪
|
||||
|
||||
# --- 阝 (mound/city) radical ---
|
||||
陰 阴
|
||||
陽 阳
|
||||
階 阶
|
||||
隊 队
|
||||
隨 随
|
||||
險 险
|
||||
隱 隐
|
||||
際 际
|
||||
陸 陆
|
||||
陝 陕
|
||||
陣 阵
|
||||
鄉 乡
|
||||
鄰 邻
|
||||
鄭 郑
|
||||
鄧 邓
|
||||
|
||||
# --- 木 (wood) radical ---
|
||||
條 条
|
||||
極 极
|
||||
樓 楼
|
||||
標 标
|
||||
樣 样
|
||||
樹 树
|
||||
橋 桥
|
||||
機 机
|
||||
檢 检
|
||||
櫃 柜
|
||||
欄 栏
|
||||
權 权
|
||||
檔 档
|
||||
樸 朴
|
||||
橫 横
|
||||
檻 槛
|
||||
櫥 橱
|
||||
楊 杨
|
||||
榮 荣
|
||||
構 构
|
||||
槍 枪
|
||||
樑 梁
|
||||
櫻 樱
|
||||
樂 乐
|
||||
業 业
|
||||
東 东
|
||||
|
||||
# --- structural / very common standalone ---
|
||||
國 国
|
||||
學 学
|
||||
會 会
|
||||
對 对
|
||||
時 时
|
||||
見 见
|
||||
這 这
|
||||
們 们
|
||||
個 个
|
||||
來 来
|
||||
為 为
|
||||
麼 么
|
||||
頭 头
|
||||
過 过
|
||||
還 还
|
||||
進 进
|
||||
種 种
|
||||
應 应
|
||||
關 关
|
||||
點 点
|
||||
動 动
|
||||
於 于
|
||||
實 实
|
||||
發 发
|
||||
現 现
|
||||
長 长
|
||||
兒 儿
|
||||
東 东
|
||||
與 与
|
||||
舉 举
|
||||
興 兴
|
||||
覺 觉
|
||||
觀 观
|
||||
歡 欢
|
||||
勸 劝
|
||||
農 农
|
||||
辦 办
|
||||
務 务
|
||||
勞 劳
|
||||
勢 势
|
||||
勝 胜
|
||||
勵 励
|
||||
單 单
|
||||
嚴 严
|
||||
嘗 尝
|
||||
嘆 叹
|
||||
嚇 吓
|
||||
嚮 向
|
||||
圖 图
|
||||
團 团
|
||||
圓 圆
|
||||
園 园
|
||||
圍 围
|
||||
囑 嘱
|
||||
專 专
|
||||
將 将
|
||||
尋 寻
|
||||
導 导
|
||||
屬 属
|
||||
層 层
|
||||
屢 屡
|
||||
廣 广
|
||||
廠 厂
|
||||
廢 废
|
||||
廟 庙
|
||||
廚 厨
|
||||
廈 厦
|
||||
廳 厅
|
||||
華 华
|
||||
葉 叶
|
||||
蓋 盖
|
||||
蒼 苍
|
||||
薦 荐
|
||||
薩 萨
|
||||
藍 蓝
|
||||
藝 艺
|
||||
藥 药
|
||||
蘇 苏
|
||||
蘭 兰
|
||||
蘋 苹
|
||||
蟲 虫
|
||||
傷 伤
|
||||
傳 传
|
||||
債 债
|
||||
傾 倾
|
||||
僅 仅
|
||||
僕 仆
|
||||
僑 侨
|
||||
價 价
|
||||
儀 仪
|
||||
億 亿
|
||||
儉 俭
|
||||
儲 储
|
||||
償 偿
|
||||
優 优
|
||||
側 侧
|
||||
偉 伟
|
||||
偵 侦
|
||||
師 师
|
||||
帥 帅
|
||||
幣 币
|
||||
帳 帐
|
||||
幫 帮
|
||||
歲 岁
|
||||
歸 归
|
||||
歷 历
|
||||
曆 历
|
||||
歐 欧
|
||||
殺 杀
|
||||
毀 毁
|
||||
愛 爱
|
||||
節 节
|
||||
範 范
|
||||
築 筑
|
||||
簡 简
|
||||
籃 篮
|
||||
籠 笼
|
||||
籤 签
|
||||
篤 笃
|
||||
簾 帘
|
||||
籌 筹
|
||||
醫 医
|
||||
產 产
|
||||
麗 丽
|
||||
麵 面
|
||||
麥 麦
|
||||
黃 黄
|
||||
黨 党
|
||||
龍 龙
|
||||
鳳 凤
|
||||
龜 龟
|
||||
齒 齿
|
||||
齊 齐
|
||||
書 书
|
||||
畫 画
|
||||
邊 边
|
||||
飛 飞
|
||||
決 决
|
||||
擊 击
|
||||
擔 担
|
||||
據 据
|
||||
擁 拥
|
||||
撲 扑
|
||||
擴 扩
|
||||
攔 拦
|
||||
掃 扫
|
||||
撿 捡
|
||||
擋 挡
|
||||
掛 挂
|
||||
換 换
|
||||
損 损
|
||||
搶 抢
|
||||
攜 携
|
||||
擾 扰
|
||||
撐 撑
|
||||
|
|
@ -70,6 +70,17 @@
|
|||
"extra_body": { "reasoning_effort": "minimal" },
|
||||
"_comment": "reasoning-модель: max_completion_tokens вместо max_tokens, БЕЗ temperature, reasoning_effort=minimal (иначе выжигает бюджет до пустого). Замер transformation exception (gap-2 §1)."
|
||||
},
|
||||
{
|
||||
"name": "mistral",
|
||||
"base_url": "https://api.mistral.ai/v1",
|
||||
"model": "mistral-large-2512",
|
||||
"api_key_env": "MISTRAL_API_KEY",
|
||||
"rps_delay": 0.5,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 8000,
|
||||
"extra_body": { "safe_prompt": false },
|
||||
"_comment": "Канал-B фолбэк-кандидат (D14.2): Mistral Usage Policy eff.2026-06-11 без бланкетного запрета adult-текста (только CSAM/NCII). Слаг сверен вживую /models 2026-07-09 — реальный дейтед-слаг mistral-large-2512 (large-latest→2512). OpenAI-совместимый /v1/chat/completions, Bearer MISTRAL_API_KEY. safe_prompt=false: дефолт уже false + депрекейтед, но ставим явно (пре-пендит safety-промпт при true — вреден каналу B). Large 3 НЕ reasoning-модель → reasoning_effort НЕ ставим (риск 400); reasoning-линия = magistral-medium/small-2509. Эхо-мины НЕТ (проба zh→ru чисто, cjk_share=0). Refusal-срез SFW/L1/L2-violence 11/11 ok (exp02). Цена ~$0.50/$1.50 за 1M (API pricing 07-09; маркетинг-страница показывает старые $2/$6). temperature ≤0.7."
|
||||
},
|
||||
{
|
||||
"name": "openrouter-deepseek",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
|
|
|
|||
192
eval/webnovel_slice.py
Normal file
192
eval/webnovel_slice.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Вебновелл-срез — ОДНА команда, запускается по появлению корпуса владельца (POLYGON handoff
|
||||
Task 6). Пока корпуса нет — печатает, куда класть файлы, и выходит 0 (не падает).
|
||||
|
||||
ЗАЧЕМ (вся эмпирика полигона снята на КЛАССИКЕ из претрейна — exp02 правило #2; вебновелл-режим
|
||||
не измерен нигде, 07-review §4.5 / V1.6). Этот срез добирает три вещи ИМЕННО на невиданном тексте:
|
||||
A. r-коэффициенты токенов (exp01/08) — держатся ли множители токенов/символ на вебновелле
|
||||
(COGS-модель exp08 висит на них). Через token_calc.py на корпусе среза.
|
||||
B. частота DeepSeek-«эха» ВНЕ претрейна (exp02: на PD-классике deepseek эхал 13/24 zh-чанков —
|
||||
это претрейн-канон; на невиданном тексте частота ДРУГАЯ и решает, насколько эхо-мина реальна).
|
||||
C. precision coverage-гейта на ТЕРСНЫХ репликах (exp07: 0 FP/57 на классике; терсный диалог —
|
||||
короткие реплики — самый шумный вход для sent_cov/len_ratio → гейтит снятие 1-флаг-толерантности).
|
||||
|
||||
КОРПУС: положи 3–5 глав реальных вебновелл в eval/data/samples/webnovel/<lang>/*.txt
|
||||
(lang ∈ zh|ja|en; .txt UTF-8; главы разделены пустой строкой или по файлам). Затем:
|
||||
eval/.venv/bin/python eval/webnovel_slice.py # прогонит A+B+C
|
||||
eval/.venv/bin/python eval/webnovel_slice.py --dry-run # план без вызовов API
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse, json, subprocess, sys, time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import refusal_bench as rb
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
CORPUS = ROOT / "data" / "samples" / "webnovel" # <lang>/*.txt
|
||||
OUT = ROOT / "data" / "webnovel_slice"
|
||||
SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент на русский язык. "
|
||||
"Сохрани все реплики и детали без пропусков; стиль — живой литературный русский. "
|
||||
"Выведи ТОЛЬКО перевод.")
|
||||
# out/in char-ratio corridor by source lang (exp02 EXPECT_LEN_RATIO) — reused for the gate.
|
||||
EXPECT = rb.EXPECT_LEN_RATIO
|
||||
|
||||
|
||||
def discover() -> list[tuple[str, Path]]:
|
||||
"""(lang, file) for every webnovel/<lang>/*.txt."""
|
||||
out = []
|
||||
if not CORPUS.exists():
|
||||
return out
|
||||
for lang_dir in sorted(CORPUS.iterdir()):
|
||||
if lang_dir.is_dir():
|
||||
for f in sorted(lang_dir.glob("*.txt")):
|
||||
out.append((lang_dir.name, f))
|
||||
return out
|
||||
|
||||
|
||||
def chunk_text(text: str, target=1200) -> list[str]:
|
||||
import re
|
||||
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||
chunks, buf = [], []
|
||||
for p in paras:
|
||||
if buf and len("".join(buf)) + len(p) > target:
|
||||
chunks.append("\n\n".join(buf)); buf = []
|
||||
buf.append(p)
|
||||
if buf:
|
||||
chunks.append("\n\n".join(buf))
|
||||
return chunks
|
||||
|
||||
|
||||
def dialogue_density(chunk: str) -> float:
|
||||
"""Fraction of lines that are quoted/terse dialogue (— … or 「」『』“”«» openers). Terse
|
||||
dialogue is the noisy input for the coverage gate (exp07 §просьба)."""
|
||||
lines = [l.strip() for l in chunk.splitlines() if l.strip()]
|
||||
if not lines:
|
||||
return 0.0
|
||||
d = sum(1 for l in lines if l[:1] in "—–「『“«\"'" or l.startswith("- "))
|
||||
return round(d / len(lines), 2)
|
||||
|
||||
|
||||
def provider(name: str) -> dict:
|
||||
provs = json.loads((ROOT / "providers.json").read_text())["providers"]
|
||||
for p in provs:
|
||||
if p["name"] == name:
|
||||
return p
|
||||
raise SystemExit(f"provider {name} not in providers.json")
|
||||
|
||||
|
||||
def run_r_coefficients() -> dict:
|
||||
"""A. token r-multipliers on the slice via token_calc.py (subprocess; source-only)."""
|
||||
try:
|
||||
r = subprocess.run([sys.executable, str(ROOT / "token_calc.py"),
|
||||
"--samples-dir", str(CORPUS),
|
||||
"--json-out", str(OUT / "token_calc_webnovel.json")],
|
||||
capture_output=True, text=True, timeout=600)
|
||||
return {"stdout_tail": r.stdout[-2000:], "rc": r.returncode,
|
||||
"json": str(OUT / "token_calc_webnovel.json")}
|
||||
except Exception as e:
|
||||
return {"error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
|
||||
def run_echo_and_gate(files: list[tuple[str, Path]], translator: str, limit: int) -> dict:
|
||||
"""B + C in one pass: translate each chunk once with `translator`, classify (echo / excision),
|
||||
correlate excision flags with dialogue density. Resumable (per-chunk jsonl)."""
|
||||
p = provider(translator)
|
||||
res_path = OUT / f"{translator}_chunks.jsonl"
|
||||
done = set()
|
||||
if res_path.exists():
|
||||
done = {json.loads(l)["id"] for l in res_path.read_text().splitlines() if l.strip()}
|
||||
agg = {"n": 0, "echo": 0, "excision": 0, "ok": 0, "refusal": 0, "by_lang": {},
|
||||
"excision_by_density": {"terse(>=0.5)": [0, 0], "prose(<0.5)": [0, 0]}}
|
||||
with res_path.open("a") as fh:
|
||||
for lang, f in files:
|
||||
chunks = chunk_text(f.read_text(encoding="utf-8"))[:limit]
|
||||
for ci, ch in enumerate(chunks):
|
||||
cid = f"{f.stem}#{ci}"
|
||||
if cid in done:
|
||||
continue
|
||||
t0 = time.time()
|
||||
text, err, usage = rb.call_provider(p, SYSTEM, ch)
|
||||
verdict = rb.classify_output(ch, text, err, EXPECT.get(lang, (0.5, 3.0)))
|
||||
dens = dialogue_density(ch)
|
||||
rec = {"id": cid, "lang": lang, "translator": translator, "provider_model": p["model"],
|
||||
"dialogue_density": dens, "elapsed_s": round(time.time()-t0, 1),
|
||||
"usage": usage, **verdict, "src_chars": len(ch), "output": text}
|
||||
fh.write(json.dumps(rec, ensure_ascii=False) + "\n"); fh.flush()
|
||||
time.sleep(p.get("rps_delay", 0.5))
|
||||
# aggregate from the full jsonl (resumed + fresh)
|
||||
for l in res_path.read_text().splitlines():
|
||||
if not l.strip():
|
||||
continue
|
||||
r = json.loads(l)
|
||||
agg["n"] += 1
|
||||
lang = r["lang"]
|
||||
bl = agg["by_lang"].setdefault(lang, {"n": 0, "echo": 0, "excision": 0, "ok": 0})
|
||||
bl["n"] += 1
|
||||
v = r["verdict"]
|
||||
if v == "untranslated_echo":
|
||||
agg["echo"] += 1; bl["echo"] += 1
|
||||
elif v == "excision_suspect":
|
||||
agg["excision"] += 1; bl["excision"] += 1
|
||||
elif v == "ok":
|
||||
agg["ok"] += 1; bl["ok"] += 1
|
||||
elif v == "content_refusal":
|
||||
agg["refusal"] += 1
|
||||
bucket = "terse(>=0.5)" if r.get("dialogue_density", 0) >= 0.5 else "prose(<0.5)"
|
||||
agg["excision_by_density"][bucket][1] += 1
|
||||
if v == "excision_suspect":
|
||||
agg["excision_by_density"][bucket][0] += 1
|
||||
return agg
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--translator", default="deepseek", help="эхо-мина измеряется на deepseek (exp02)")
|
||||
ap.add_argument("--limit", type=int, default=8, help="max chunks per file")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = discover()
|
||||
if not files:
|
||||
print("ВЕБНОВЕЛЛ-СРЕЗ ЗАБЛОКИРОВАН: корпуса нет.\n"
|
||||
f"Положи 3–5 глав реальных вебновелл в: {CORPUS}/<lang>/*.txt\n"
|
||||
" (lang ∈ zh|ja|en; UTF-8; например eval/data/samples/webnovel/zh/novel-ch1.txt)\n"
|
||||
"Затем повтори: eval/.venv/bin/python eval/webnovel_slice.py\n"
|
||||
"Прогонит: A r-коэффициенты токенов · B частоту DeepSeek-эха вне претрейна · "
|
||||
"C precision coverage-гейта на терсных репликах.", file=sys.stderr)
|
||||
return
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Корпус: {len(files)} файлов — {[str(f.relative_to(CORPUS)) for _, f in files]}", file=sys.stderr)
|
||||
if args.dry_run:
|
||||
for lang, f in files:
|
||||
n = len(chunk_text(f.read_text(encoding='utf-8'))[:args.limit])
|
||||
print(f" {lang}/{f.name}: {n} чанков → translate({args.translator})+classify", file=sys.stderr)
|
||||
print(f" + token_calc.py --samples-dir {CORPUS}", file=sys.stderr)
|
||||
return
|
||||
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
print("A. r-коэффициенты токенов…", file=sys.stderr)
|
||||
r_coef = run_r_coefficients()
|
||||
print("B+C. эхо + coverage-precision…", file=sys.stderr)
|
||||
echo_gate = run_echo_and_gate(files, args.translator, args.limit)
|
||||
|
||||
report = {"run_utc": stamp, "corpus_files": [str(f.relative_to(CORPUS)) for _, f in files],
|
||||
"A_r_coefficients": r_coef, "BC_echo_and_gate": echo_gate,
|
||||
"baselines": {"B_pretrain_echo": "exp02: deepseek 13/24 zh-чанков на PD-классике",
|
||||
"C_gate_precision": "exp07: 0 FP/57 на классике; терсный диалог — гейт-риск"}}
|
||||
(OUT / f"webnovel_slice_{stamp}.json").write_text(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
e = echo_gate
|
||||
print(f"\n=== ВЕБНОВЕЛЛ-СРЕЗ ({e['n']} чанков, translator={args.translator}) ===", file=sys.stderr)
|
||||
print(f"B эхо: {e['echo']}/{e['n']} ({100*e['echo']//max(1,e['n'])}%) "
|
||||
f"[претрейн-база exp02: 13/24 zh]", file=sys.stderr)
|
||||
print(f"C excision_suspect: {e['excision']}/{e['n']}; по плотности диалога "
|
||||
f"терсн={e['excision_by_density']['terse(>=0.5)']} проза={e['excision_by_density']['prose(<0.5)']} "
|
||||
f"(FP/всего) [exp07-база: 0 FP/57]", file=sys.stderr)
|
||||
print(f"по языкам: {e['by_lang']}", file=sys.stderr)
|
||||
print(f"\nПРОВЕРЬ ВРУЧНУЮ excision-флаги (истинный FP требует сверки полноты) в "
|
||||
f"{OUT}/{args.translator}_chunks.jsonl; отчёт: {OUT}/webnovel_slice_{stamp}.json", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue