diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d7cbb53..e711ad2 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -7,6 +7,19 @@ > - **Ждём от владельца:** ~~подпись спорных терминов сида v2~~ ✅ ПОДПИСАНО (D34.1; владелец переопределил: Жэнь Цзу, Монах Цветочного Вина, гу Жизни, деревня Гуюэ, первобытный камень; род «гу» муж) · **чтение 25 глав пере-прогона** (арбитр читаемости — после связки+re-gate) · **флагман-сверка exp13** (переводчики, monitor-паттерн) · DashScope-ключ для qwen-арма exp13 (опционально) · **планка запуска** (лучше-фана/гибрид/издательский + готовность к COGS опции Б ~$1.5–2 — мемо-эскалация exp12; интерим D30.7: «лучше фана» на дешёвом миксе) · решение по билингв-якорю пилота (D25.9-Q1) · «да/нет» митигации контаминации пилотного корпуса (D27.4) · publishable/waiver — при экспорте (D25.1) · FN-bound L3 — при спеке (D25.4) · провенанс 12-*-доков · юр-пакет (+rights manifest, D25.8). > - Записи ниже — хронология; РАННИЕ (день-0, 04.07) содержат устаревшие цифры/решения (COGS $2–4, BYOK, Anthropic-Sonnet), переопределённые позже — см. блок выше и 05-decisions-log. +## Полигон — exp14 эмпирика рычагов качества (нарезка×промпт + фронтир 2×2), ПАРТИЯ 1, 2026-07-12 (D36) + +Сессия по `POLYGON_QUALITY_EMPIRICS_SESSION_PROMPT.md`. **Пре-регистрация ЗАМОРОЖЕНА коммитом ДО первого платного вызова** (D30.10; `docs/experiments/14-quality-empirics.md` §1 — trap-набор 6 трапов вкл. gl.7/gl.8 владельца, армы нарезка×промпт, метрики/гейты, бюджет-по-ключам). Харнес `eval/exp14_*.py` (call+3-режима-cost+per-call-кап+токенайзер+реконструкция инъекции из `retrieval_state.injected_ids`). Все выходы ВНЕ git (`/home/ubuntu/books/gu-zhenren/exp14/`). **Бэкенд не трогал (0 правок).** + +**ПАРТИЯ 1 (trap-набор, 9 чанков): 0 ошибок на 64 арм-вызовах; трата ARM $1.77 (ZAI $0.43 + OpenAI $1.34) + trap-судьи ~$0.14 ≈ $1.91 (глубоко в остатках).** Фронтир = **gpt-5.4** ($2.5/$15, live-фактчек 2026-07-11; GPT-5-линейка до 5.4/5.5/5.6, взят 5.4 standard). + +**ГЛАВНЫЙ ВЫВОД — потолок РАСЩЕПЛЁН по двум претензиям владельца (прямой ответ на «модели vs нарезка/промпт»):** +- **Претензия 1 (абзацы/конвенции) = наш ПРОМПТ/ПАЙПЛАЙН (активация), НЕ модель.** Детерм. KPI предл./нарратив-абзац: P0 1.91 → P1a (дискурс-промпт) 2.61 → **A-2pass (семантика→target-only reflow-пасс) 3.33** (лучший, доля-1-предл. 0.187, БЕЗ коллапса длины/CJK). **Фронтир+СТАРЫЙ промпт (F-base 1.58) ХУЖЕ дешёвого P0** — «сильнее модель» абзацы НЕ чинит. A-layout (deformat черновика) улучшает абзацы (3.13), НО **CJK-утечка ×20** (регресс-гард поймал) — deformat требует CJK-пост-гарда. +- **Претензия 2 внутри-чанк (смысл) = дешёвая МОДЕЛЬ (capability floor).** T1 (gl.7 двойное отрицание «все знали»): glm-5 **инвертирует** в «никто не знал» НЕЗАВИСИМО от промпта (P0 и P1a); **gpt-5.4 чинит** НЕЗАВИСИМО от промпта (F-base и F-disc). T2 (物是人非) — тот же знак. Промпт претензию 2 НЕ закрывает. +- **Претензия 2 кросс-граница = НЕ доминирует в срезе.** T5/T6 локально самодостаточны; ±1-reference (A-ref) дал байт-идентичный выход (чинить нечего); whole-chapter (P1c) единственный явно связал антецедент (1 датапоинт). + +**Near-term рекомендация (на ратификацию оркестратора, НЕ смена дефолта):** (1) дискурс-reflow-контент Ван Цуй→editor-промпт + рассмотреть отдельный target-only reflow-пасс (A-2pass) под COGS; (2) претензию 2 — **селективная фронтир-эскалация редактора по смысл-риску** (не тотальный фронтир — он абзацы портит); (3) CJK-гард при deformat; (4) НЕ строить Ф2-кросс-чанк-память под слабый сигнал. **ПАРТИЯ 2 (осталось):** кривая нарезки, слепые пакеты Ступени II (гл.19/17/18, D=0.061/0.246/0.440), полная ранжирующая панель +leave-one-out, fidelity re-gate (D34.3), 3 финалиста, хендофф. Планка = 2 претензии владельца (ИНТЕРИМ пре-скрин, НЕ пилот Ф2.5). Смену дефолта ратифицирует оркестратор. + ## Ресёрчер — рычаги качества (`research/18-quality-levers.md`), 2026-07-12 (D35.5, вход полигону) Сессия по `RESEARCHER_QUALITY_SESSION_PROMPT.md` (нейтральный/анти-анкоринг). **Ничего не коммичено** (лендит оркестратор после верификации первоисточников — как research/15/16/17). Отчёт: `docs/research/18-quality-levers.md` — §A (заморожена, sha256 `44f90364…`, построена ДО чтения стека/ChatGPT-отчёта; хеш байтов между маркерами BEGIN/END §A) · §B дифф · §C таблица-рекомендации полигону · §D вопросы. diff --git a/docs/experiments/14-quality-empirics.md b/docs/experiments/14-quality-empirics.md index 74e8a29..11f1a6a 100644 --- a/docs/experiments/14-quality-empirics.md +++ b/docs/experiments/14-quality-empirics.md @@ -166,9 +166,77 @@ usage→$: `(in·pin + (comp + reason)·pout)/1e6`; `reason`= из `completion` --- -## 2. Результаты (заполняется ПОСЛЕ заморозки §1 — не входит в freeze-commit) +## 2. Результаты (пост-freeze; ПАРТИЯ 1 — near-term армы + фронтир 2×2, на trap-наборе) -*(пусто до прогонов; sha256 замороженных промптов армов, точные `D` глав Ступени II, таблицы армов — KPI-абзацы / trap-accuracy со спанами / perturbation / retry-по-причинам / цена — кривые размер↔качество↔биллинг↔ретраи — capability-vs-activation вывод — рекомендация near-term конфига + что строить под ROI — 3 финалиста для слепого чтения — суммарная трата по ключам.)* +> **Статус партии 1:** прогнаны P0/P1a/P1b/P1c + аблации (A-layout/A-2pass/A-ref-prev/A-ref-both) + фронтир 2×2 (F-base/F-disc, gpt-5.4) на 9 trap-чанках. **0 ошибок на 64 арм-вызовах.** Трата: ARM ZAI $0.43 + OpenAI $1.34 = **$1.77**; trap-судьи (gpt-5-mini+kimi) ~$0.14; итого ≈ **$1.91** (глубоко в остатках). Медиана латентности: glm-5 35с, gpt-5.4 37с. +> **ПАРТИЯ 2 (не в этой партии):** кривая нарезки, слепые пакеты Ступени II владельцу, полная ранжирующая панель ≥3 повтора + leave-one-out, fidelity re-gate, 3 финалиста, хендофф оркестратору. +> **Заморожённые промпты армов (sha256[:16]):** editor_baseline `ca03a921df5db728` · editor_discourse `b3b4f6042e8c5673` · discourse_core `ec86a5623e3c6f02` · fewshot `d8f204cc4550ee99` · translator `a8298f725a3b2844`. **Stage-II `D`:** гл.19=0.061 / гл.17=0.246 / гл.18=0.440. + +### 2.1. Претензия 1 (абзацы) — детерминированный структурный KPI (trap-набор, 9 чанков) + +| Арм | нарезка×промпт | mean предл./нарратив-абзац | доля 1-предл. | CJK-утечка | len/P0 | gloss | +|---|---|---|---|---|---|---| +| **P0** | чанк × прод-baseline (glm-5) | 1.91 | 0.472 | 2 | 1.00 | 0.94 | +| **P1a** | чанк × дискурс (glm-5) | 2.61 | 0.318 | 2 | 0.99 | 0.93 | +| **A-2pass** | чанк × семантика→target-reflow (glm-5) | **3.33** | **0.187** | 2 | 0.99 | 0.93 | +| **A-layout** | чанк × дискурс + deformat-draft (glm-5) | 3.13 | 0.215 | **39 ⚠** | 1.00 | 0.93 | +| **A-ref-prev** | чанк × дискурс + ±1 prev (glm-5, 2 чанка) | 2.75 | 0.196 | 0 | 1.00 | 0.85 | +| **F-base** | чанк × прод-baseline (**gpt-5.4**) | **1.58** | **0.584** | 0 | 1.03 | 0.93 | +| **F-disc** | чанк × дискурс (**gpt-5.4**) | 2.45 | 0.369 | 0 | 1.02 | 0.93 | + +**Вывод П1 — абзацы = ПРОМПТ/ПАЙПЛАЙН-рычаг, МОДЕЛЬ-агностичный:** +- Дискурс-промпт двигает распределение к русской норме на ОБЕИХ моделях (P1a 2.61, F-disc 2.45 vs baseline ~1.6–1.9). **Фронтир с ТЕКУЩИМ промптом (F-base 1.58) — ХУЖЕ дешёвого baseline (P0 1.91):** сильная модель зеркалит построчность черновика ещё вернее — «сильнее модель» само по себе абзацы НЕ чинит. +- **Сильнейший near-term рычаг П1 — A-2pass** (билингв семантика → отдельный target-only литературный reflow-пасс): mean 3.33, доля-1-предл. 0.187, БЕЗ коллапса длины (len/P0 0.99) и БЕЗ CJK-утечки. Подтверждает DocRepair-класс (отдельный монолингв. RU reflow-пасс) на дешёвой модели. Цена — ~2× editor-output (замерено: 18 вызовов на 9 чанков). +- **A-layout (deformat черновика) — НЕ чистая победа:** улучшает абзацы (3.13), но **CJK-утечка 39 симв. (×20 vs baseline)** — снятие построчной формы у glm-5 провоцирует эхо-осколки исходника. Регресс-гард сработал (детерм. KPI поймал). Deformat на дешёвой модели требует CJK-пост-гарда — не катить as-is. + +### 2.2. Претензия 2 внутри-чанк (T1/T2) — capability floor + +**T1 (gl.7 `古月族人没有一个不知道的`, двойное отрицание = «все знали») — КАТАСТРОФА-класс, детерм. читаемо:** +| Арм | рендер | вердикт | +|---|---|---| +| P0 (glm base) | «не знал в роду Гуюэ ни один человек» | ✗ ИНВЕРСИЯ (никто) | +| P1a (glm disc) | «в роду Гуюэ не знал ни один человек» | ✗ ИНВЕРСИЯ (промпт НЕ починил) | +| F-base (gpt-5.4 base) | «не знал разве что мёртвый» | ✓ ВЕРНО (все знали) | +| F-disc (gpt-5.4 disc) | «не было ни одного, кто бы его не знал» | ✓ ВЕРНО | + +**Дешёвая модель инвертирует полярность НЕЗАВИСИМО от промпта; фронтир чинит НЕЗАВИСИМО от промпта → это МОДЕЛЬ-потолок, не активация.** T2 (`物是人非` chengyu): P0/P1a сплющивают («всё изменилось»); F-base улучшает («стало совсем не тем, что прежде» — восстанавливает 人非-контраст). Тот же знак: фронтир-редактор ловит смысловой дефект черновика, дешёвый — нет. + +### 2.3. Претензия 2 кросс-граница (T5/T6) — слабые трапы в этом срезе + +- **T5 (ch24.1 `这…铜皮蛊`): все армы рендерят локально-когерентно** («это не природный цвет кожи, а эффект гу Медной кожи») — предложение самодостаточно, chunk-изоляция его НЕ ломает. **A-ref-prev дал БАЙТ-идентичный P1a выход** (prev-контекст ничего не изменил — чинить было нечего). +- **P1c (глава целиком) на T5 — единственный, кто ЯВНО связал антецедент:** «Как у этого наставника: вся его кожа бронзового цвета» — склеив 24.0+24.1, редактор соединил «этого наставника» ↔ бронзовую кожу (chunk-изолированные оставляют висячее «его»). Один датапоинт: **whole-chapter даёт кросс-граничный лифт связывания**, но на этом срезе кросс-граница — НЕ доминирующий сбой (в отличие от within-chunk T1). +- Честно (пре-рег-оговорка): next-supported/катафора-трапа в раннем срезе нет; вывод по кросс-границе — предварительный (2 prev-трапа, локально самодостаточные). + +### 2.4. Capability-vs-activation — прямой ответ на вопрос владельца + +**Потолок РАСЩЕПЛЁН по двум претензиям — не «модели ИЛИ организация», а «каждая претензия — своё»:** + +| Претензия владельца | Где потолок | Доказательство (партия 1) | Near-term ход | +|---|---|---|---| +| **(1) абзацы / конвенции РЯ** | **наша ПРОМПТ/ПАЙПЛАЙН** (активация) | дискурс-промпт двигает KPI на обеих моделях; F-base (фронтир+старый промпт) ХУЖЕ P0; A-2pass (дешёвый 2-пасс) — лучший (3.33) | катить дискурс-промпт + отдельный target-only reflow-пасс на ДЕШЁВОЙ модели | +| **(2) понимание связей внутри-чанк** | **дешёвая МОДЕЛЬ** (capability) | T1 инверсия: glm-5 фейлит обе промпт-версии, gpt-5.4 чинит обе; T2 то же | селективная фронтир-эскалация редактора на смысл-риск ИЛИ фронтир-редактор; промпт НЕ закрывает | +| **(2) связи кросс-граница** | не доминирует в срезе; whole-chapter даёт лифт | A-ref null (локально самодостаточно); P1c связал антецедент | НЕ строить сложную Ф2-память под слабый сигнал; whole-chapter/edit=глава — кандидат под ROI | + +### 2.5. Судейская корроборация (trap-accuracy, кросс-семейно — ПАРТИЯ 1, идёт) + +*(панель gpt-5-mini + kimi, self-family exclusion, span-цитаты; идёт на момент записи — свод фолдится сюда. Детерм. ридинг T1/T2 (§2.2) — объективная полярность/атом, судьи корроборируют. Методика-guard: НЕ собирать ложную сходимость; T1-катастрофа держится на детерм. полярности, не на среднем ранге.)* + +### 2.6. Дерево решений — резолюция партии 1 + +- **P1a закрывает абзацы БЕЗ падения длины/атомов** ✓ → **катить дискурс-промпт, размер НЕ менять** (ветка 1 подтверждена). **Сильнее — A-2pass** (target-only reflow-пасс) — рекомендация near-term №1 по П1. +- **A-layout (deformat) улучшает абзацы, НО CJK-утечка** → deformat/atom-rendering строить ТОЛЬКО с CJK-пост-гардом (иначе регресс). +- **±1 reference НЕ дал лифта** (трапы локально самодостаточны) → малое context-window до Ф2 под этот срез НЕ обосновано; пере-проверить, когда/если появятся next-supported/катафора-кейсы. +- **Whole-chapter (P1c) дал кросс-граничный лифт связывания (1 датапоинт) но НЕ выиграл абзацы у A-2pass** → chapter state-extraction — кандидат под ROI, НЕ near-term приоритет. +- **Фронтир чинит within-chunk смысл, организация — нет (T1)** → **model floor на претензии 2-внутри-чанк**: дешёвый трек её НЕ дотянет промптом; ход — селективная фронтир-эскалация редактора по смысл-риску (не тотальный фронтир — F-base абзацы даже портит). + +### 2.7. Предварительная near-term рекомендация (на ратификацию оркестратора — НЕ смена дефолта этой сессией) + +1. **Претензия 1 (абзацы):** внедрить **дискурс-reflow-контент (Ван Цуй 5 техник + Розенталь) в editor-промпт** — дёшево, модель-агностично, доказанный лифт (P1a); рассмотреть **отдельный target-only reflow-пасс** (A-2pass, лучший KPI) под COGS-замер (~2× editor-output — оценить на полной книге). +2. **Претензия 2 (смысл внутри-чанк):** промпт НЕ закрывает на дешёвой модели → **селективная фронтир-эскалация редактора по смысл-риску** (двойное отрицание/chengyu/инверсия черновика) — точечно, не тотально (тотальный фронтир портит абзацы и жжёт COGS). Квантификация — fidelity re-gate (§3) + партия 2. +3. **CJK-гард обязателен** при любом deformat-черновике (A-layout урок). +4. **НЕ строить** сложную Ф2-кросс-чанк-память под этот срез (сигнал слабый); whole-chapter/edit=глава — держать кандидатом под доказанный ROI. + +**Оговорки (методика-guard):** trap-набор мал (6 трапов, prev-boundary, без катафоры); фронтир = 1 модель (gpt-5.4), Gemini/grok переводчиками не гонялись (бюджет/эхо); T1-вывод — 1 катастроф-датапоинт (но детерм.-чистый); срез — PD-смежная ранняя арка (без 18+); это ИНТЕРИМ пре-скрин (D35), НЕ вердикт пилота Ф2.5. ## 3. Fidelity re-gate (ПАРАЛЛЕЛЬНО, D34.3 — независим от §1-§2) diff --git a/eval/exp14_arms.py b/eval/exp14_arms.py new file mode 100644 index 0000000..9e41dfd --- /dev/null +++ b/eval/exp14_arms.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""exp14 — раннер editor-армов (нарезка × промпт + аблации). Прямые вызовы, ручная упаковка. + +Per-call predicted-cost кап (НЕ group-level). Персист usage/cost в exp14/costs.jsonl. +P0 = reuse records.json final ($0). Черновик держим общим (flash-draft из records) — варьируем +editor-стадию (модель × промпт × нарезка × reference). + +Использование: exp14_arms.py --arm P1a [--chapters trap|stage2] [--model glm-5] [--dry] +Армы: P0 P1a P1b P1c A-layout A-2pass A-ref-prev A-ref-next A-ref-both (+ frontier: F-base F-disc) +""" +from __future__ import annotations +import argparse, json, re, sys, time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import exp14_common as C +import exp14_prompts as P + +MAT = json.load(open(C.DIAG / "material.json")) +ARMSDIR = C.DIAG / "arms"; ARMSDIR.mkdir(exist_ok=True) +CAPS = {"glm-5": 0.08, "glm-5.1": 0.10, "deepseek-v4-flash": 0.03, "deepseek-v4-pro": 0.06, + "gpt-5.4": 0.40, "gemini-3.1-pro-preview": 0.30, "grok-4.3": 0.40, + "gpt-5-mini": 0.20, "kimi-k2.6": 0.20, "mistral-large-2512": 0.10} +SP = C.Spender(C.DIAG / "costs.jsonl", CAPS) + + +def deformat_draft(draft: str) -> str: + """A-layout: снять построчную/пустострочную разбивку черновика (нейтральные сепараторы).""" + lines = [l.strip() for l in draft.split("\n") if l.strip()] + return " ".join(lines) # один поток; редактор не якорится на форме черновика + + +def chunk_units(scope: str): + """Возвращает список единиц {id, source, draft, final_p0, editor_constraint, chapter, chunk_idx}.""" + if scope == "trap": + return [dict(id=f"{u['chapter']}.{u['chunk_idx']}", **u) for u in MAT["trap_chunks"]] + if scope == "stage2": + out = [] + for ch, chunks in MAT["stage2"].items(): + for u in chunks: + out.append(dict(id=f"{ch}.{u['chunk_idx']}", chapter=int(ch), **u)) + return out + raise ValueError(scope) + + +def chapter_units(scope: str): + """Whole-chapter единицы: клеим source/draft чанков главы + chapter-level инъекция.""" + inj = json.load(open(C.DIAG / "injection_blocks.json")) + units = chunk_units(scope) + by_ch = {} + for u in units: + by_ch.setdefault(u["chapter"], []).append(u) + out = [] + for ch, us in by_ch.items(): + if len(us) < 2: # whole-chapter тест осмыслен только когда все чанки главы в наборе (ch8/11/24) + continue + us = sorted(us, key=lambda x: x["chunk_idx"]) + src = "\n\n".join(x["source"] for x in us) + drf = "\n\n".join(x["draft"] for x in us) + eb = inj.get(f"{ch}/ALL", {}).get("editor_constraint", "") + out.append(dict(id=f"{ch}.ALL", chapter=ch, chunk_idx="ALL", + source=src, draft=drf, editor_constraint=eb)) + return out + + +def run_editor(arm: str, variant: str, units, model: str, reference_by=None, + two_pass=False, deformat=False, dry=False): + outdir = ARMSDIR / arm; outdir.mkdir(exist_ok=True) + sys_disc = P.editor_system(variant) + total_pred = 0.0 + for u in units: + outp = outdir / f"{u['id']}.txt" + if outp.exists(): + print(f" [skip exists] {arm} {u['id']}"); continue + draft = deformat_draft(u["draft"]) if deformat else u["draft"] + reference = reference_by.get(u["id"], "") if reference_by else "" + user = P.editor_user(u["source"], draft, reference) + msgs = C.messages_with_injection(sys_disc, u.get("editor_constraint", ""), user) + in_est = C.count_tokens(sys_disc + u.get("editor_constraint", "") + user, + "deepseek" if model.startswith("deepseek") else "glm") + s = C.spec(model, temp=0.4) + ok, pred = SP.guard(model, in_est, s["max_tokens"]) + total_pred += pred + print(f" {arm} {u['id']}: in≈{in_est}tok predict=${pred:.4f} cap=${CAPS[model]} {'OK' if ok else 'OVER-CAP'}") + if dry: + continue + if not ok: + print(f" !! OVER per-call cap — skipped"); continue + t0 = time.time() + text, err, usage = C.call(s, msgs, timeout=360) + dt = round(time.time() - t0, 1) + rec = {"arm": arm, "model": model, "keyenv": s["keyenv"], "id": u["id"], + "variant": variant, "err": err, "latency_s": dt, "usage": usage} + c = SP.record(rec) + if err: + print(f" ERR {err[:80]} (${c:.4f}, {dt}s)") + continue + if two_pass: # second narrow pass: target-only literary reflow of the just-edited RU + sys2 = P.editor_system("discourse") # target-only reflow uses discourse core + u2 = ("Ниже — русский перевод. Перевёрстай его в естественные русские абзацы по " + "правилам дискурс-перевёрстки; НЕ меняй смысл, НЕ добавляй и НЕ удаляй атомы, " + "НЕ сверяйся с иностранным исходником (его нет):\n\n" + text) + msgs2 = [{"role": "system", "content": sys2}, {"role": "user", "content": u2}] + in2 = C.count_tokens(sys2 + u2, "glm") + ok2, pred2 = SP.guard(model, in2, s["max_tokens"]) + if ok2: + text2, err2, usage2 = C.call(s, msgs2, timeout=360) + SP.record({"arm": arm, "model": model, "keyenv": s["keyenv"], "id": u["id"], + "variant": "reflow-pass2", "err": err2, "usage": usage2}) + if not err2: + text = text2 + outp.write_text(text, encoding="utf-8") + print(f" ok {len(text)}chars ${c:.4f} {dt}s → {outp.name}") + print(f" [{arm}] predicted total ≈ ${total_pred:.4f}; spent-by-key: {SP.by_key}") + + +def run_p0(scope): + outdir = ARMSDIR / "P0"; outdir.mkdir(exist_ok=True) + for u in chunk_units(scope): + (outdir / f"{u['id']}.txt").write_text(u["final_p0"], encoding="utf-8") + print(f"[P0] reused {len(chunk_units(scope))} finals ($0)") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--arm", required=True) + ap.add_argument("--scope", default="trap", choices=["trap", "stage2"]) + ap.add_argument("--model", default="glm-5") + ap.add_argument("--dry", action="store_true") + a = ap.parse_args() + + if a.arm == "P0": + run_p0(a.scope); return + if a.arm == "P1a": + run_editor("P1a", "discourse", chunk_units(a.scope), a.model, dry=a.dry) + elif a.arm == "P1b": + run_editor("P1b", "baseline", chapter_units(a.scope), a.model, dry=a.dry) + elif a.arm == "P1c": + run_editor("P1c", "discourse", chapter_units(a.scope), a.model, dry=a.dry) + elif a.arm == "A-layout": + run_editor("A-layout", "discourse", chunk_units(a.scope), a.model, deformat=True, dry=a.dry) + elif a.arm == "A-2pass": + run_editor("A-2pass", "baseline", chunk_units(a.scope), a.model, two_pass=True, dry=a.dry) + elif a.arm in ("A-ref-prev", "A-ref-next", "A-ref-both"): + ref = build_reference(a.arm) + run_editor(a.arm, "discourse", [u for u in chunk_units(a.scope) if u["id"] in ref], + a.model, reference_by=ref, dry=a.dry) + elif a.arm in ("F-base", "F-disc"): + variant = "baseline" if a.arm == "F-base" else "discourse" + run_editor(a.arm, variant, chunk_units(a.scope), a.model, dry=a.dry) + else: + raise SystemExit(f"unknown arm {a.arm}") + + +def build_reference(kind: str) -> dict: + """±1 reference для кросс-граничных трапов (T5 24.1←24.0, T6 11.1←11.0). prev/next/both.""" + units = {u["id"]: u for u in chunk_units("trap")} + pairs = [("24.0", "24.1"), ("11.0", "11.1")] # (prev_chunk, target_chunk) + ref = {} + for prev, tgt in pairs: + parts = [] + if kind in ("A-ref-prev", "A-ref-both"): + ptail = "\n".join(units[prev]["source"].split("\n")[-6:]) + ttail = "\n".join(units[prev]["final_p0"].split("\n")[-6:]) + parts.append("[ПРЕД-ИСХОДНИК]\n" + ptail + "\n[ПРЕД-ПЕРЕВОД]\n" + ttail) + if kind in ("A-ref-next", "A-ref-both"): + # next-source: следующий чанк того же trap-набора, если есть (для 24.1/11.1 нет — пропуск) + pass + if parts: + ref[tgt] = "\n\n".join(parts) + return ref + + +if __name__ == "__main__": + main() diff --git a/eval/exp14_common.py b/eval/exp14_common.py new file mode 100644 index 0000000..154196d --- /dev/null +++ b/eval/exp14_common.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""exp14 — общий харнес: провайдер-вызов, usage→$ (3 reasoning-режима), per-call +predicted-cost кап (НЕ group-level — урок exp13 +10%), append-only персист usage/cost, +токенайзер-счёт выходных токенов (кириллица-фертильность), spec-фабрика. + +Зона eval/. Цены — ЗАМОРОЖЕННАЯ зеркальная копия backend/configs/models.yaml +(prices_checked 2026-07-10) + фронтир gpt-5.4 live-фактчек 2026-07-11 +(developers.openai.com/api/docs/pricing). Единственный источник истины — models.yaml; +при расхождении верить Go/models.yaml. Ключи из eval/.env через load_env_file (НЕ читаю .env). +""" +from __future__ import annotations +import json, os, sys, time, urllib.request, urllib.error +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from refusal_bench import load_env_file # noqa: E402 + +load_env_file() + +DIAG = Path("/home/ubuntu/books/gu-zhenren/exp14") +DIAG.mkdir(parents=True, exist_ok=True) +RERUN = Path("/home/ubuntu/books/gu-zhenren/rerun") +TOKDIR = Path(__file__).resolve().parent / "tokenizers" + +# ── Цены $/1M (in, out, cached_in) + reasoning-режим. Зеркало models.yaml. ────────── +PRICES = { + "deepseek-v4-flash": (0.14, 0.28, 0.0028, "subset"), + "deepseek-v4-pro": (0.435, 0.87, 0.003625, "subset"), + "glm-5": (1.0, 3.2, 0.2, "subset"), + "glm-5.1": (1.4, 4.4, 0.26, "subset"), + "kimi-k2.6": (0.95, 4.0, 0.16, "subset"), + "grok-4.3": (1.25, 2.50, 1.25, "additive"), + "gemini-3.1-pro-preview": (2.0, 12.0, 0.20, "additive_total"), + "gpt-5-mini": (0.25, 2.0, 0.025, "subset"), + "gpt-5.4": (2.50, 15.0, 0.25, "subset"), # live 2026-07-11 + "mistral-large-2512": (0.5, 1.5, 0.5, "subset"), +} +FALLBACK = "deepseek-v4-flash" # никогда $0 + +# ── Спеки провайдеров (endpoint/keyenv/wire-квирки из 00-provider-quirks + models.yaml) ─ +def spec(model: str, *, temp: float | None = None, max_tokens: int | None = None, + reasoning_effort: str | None = None) -> dict: + """Собирает per-call spec. temp/max_tokens/reasoning_effort перекрывают дефолты.""" + base_by = { + "deepseek-v4-flash": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", {}), + "deepseek-v4-pro": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", {}), + "glm-5": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY", {"thinking": {"type": "disabled"}}), + "glm-5.1": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY", {"thinking": {"type": "disabled"}}), + "kimi-k2.6": ("https://api.moonshot.ai/v1", "KIMI_API_KEY", {}), + "grok-4.3": ("https://api.x.ai/v1", "XAI_API_KEY", {}), + "gemini-3.1-pro-preview": ("https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY", {}), + "gpt-5-mini": ("https://api.openai.com/v1", "OPENAI_API_KEY", {}), + "gpt-5.4": ("https://api.openai.com/v1", "OPENAI_API_KEY", {}), + "mistral-large-2512": ("https://api.mistral.ai/v1", "MISTRAL_API_KEY", {"safe_prompt": False}), + } + base, keyenv, extra = base_by[model] + extra = dict(extra) + s = {"model": model, "base": base, "keyenv": keyenv, "extra": extra} + # reasoning wire per family + if model in ("gpt-5-mini", "gpt-5.4"): + s["openai_reasoner"] = True # max_completion_tokens, no temperature + extra["reasoning_effort"] = reasoning_effort or "medium" + elif model == "kimi-k2.6": + s["force_temp"] = 1.0 + elif model == "grok-4.3": + if reasoning_effort: # xAI plain reasoning_effort in body (none/low/medium/high) + extra["reasoning_effort"] = reasoning_effort + # temp + if model in ("gpt-5-mini", "gpt-5.4"): + s["temp"] = None + elif model == "kimi-k2.6": + s["temp"] = 1.0 + else: + s["temp"] = 0.4 if temp is None else temp + # max_tokens floors (D24.3) + floor = 16000 if model == "kimi-k2.6" else (12000 if model == "gpt-5.4" else 8000) + s["max_tokens"] = max_tokens or floor + return s + + +def call(s: dict, messages: list[dict], timeout: int = 300) -> tuple[str, str | None, dict]: + """Один chat/completions. Возврат (text, err|None, usage со стампом finish_reason). + err — структурная строка: http|CODE|.. / transport|.. / content_filter|.. / empty|..""" + key = os.environ.get(s["keyenv"], "") + body = {"model": s["model"], "messages": messages, **s.get("extra", {})} + if s.get("openai_reasoner"): + body["max_completion_tokens"] = s["max_tokens"] # gpt-5: max_tokens → 400 + else: + body["max_tokens"] = s["max_tokens"] + if s.get("temp") is not None: + body["temperature"] = s["temp"] + data = json.dumps(body).encode() + req = urllib.request.Request(s["base"].rstrip("/") + "/chat/completions", data=data, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + d = json.loads(r.read()) + except urllib.error.HTTPError as e: + return "", f"http|{e.code}|{e.read()[:300].decode('utf-8','replace')}", {} + except Exception as e: + return "", f"transport|{type(e).__name__}|{str(e)[:200]}", {} + ch = (d.get("choices") or [{}])[0] + finish = ch.get("finish_reason", "") + msg = ch.get("message", {}) or {} + text = (msg.get("content") or "").strip() + usage = d.get("usage", {}) or {} + usage["finish_reason"] = finish + if str(finish).lower() in ("content_filter", "prohibited_content", "safety") or "prohibited" in str(finish).lower(): + return "", f"content_filter|finish={finish}", usage + if not text: + has_reason = bool((msg.get("reasoning_content") or "").strip()) + return "", f"empty|finish={finish}|reasoning={has_reason}", usage + return text, None, usage + + +def cost_of(model: str, usage: dict) -> float: + """usage→$ с 3 reasoning-режимами (subset/additive/additive_total).""" + inp = usage.get("prompt_tokens", 0) or 0 + comp = usage.get("completion_tokens", 0) or 0 + total = usage.get("total_tokens", 0) or 0 + pin, pout, _c, regime = PRICES.get(model, (*PRICES[FALLBACK][:3], "subset")) + if regime == "additive": # grok: reasoning_tokens отдельным полем, поверх completion + reason = usage.get("reasoning_tokens", 0) or 0 + out = comp + reason + elif regime == "additive_total": # gemini: thinking только в total + out = max(comp, total - inp) if total else comp + else: # subset: reasoning ⊆ completion + out = comp + return (inp * pin + out * pout) / 1_000_000 + + +def predict_cost(model: str, in_tokens_est: int, max_out: int) -> float: + """Верхняя оценка ДО вызова: in_est·pin + max_out·pout (потолок max_tokens как output).""" + pin, pout, _c, _r = PRICES.get(model, (*PRICES[FALLBACK][:3], "subset")) + return (in_tokens_est * pin + max_out * pout) / 1_000_000 + + +# ── Токенайзеры (кириллица-фертильность; deepseek-v4/kimi отсутствуют → v3.2 прокси) ── +_TOK = {} +def _load_tok(name: str): + from tokenizers import Tokenizer + p = TOKDIR / name / "tokenizer.json" + if name not in _TOK: + _TOK[name] = Tokenizer.from_file(str(p)) + return _TOK[name] + +def count_tokens(text: str, family: str = "glm") -> int: + name = {"glm": "glm-4.6", "deepseek": "deepseek-v3.2"}.get(family, "glm-4.6") + return len(_load_tok(name).encode(text, add_special_tokens=False).ids) + + +# ── Per-call кап + персист ──────────────────────────────────────────────────────── +class Spender: + """Per-key running spend + per-call predicted-cost кап (обязателен ДО вызова).""" + def __init__(self, costs_path: Path, caps: dict[str, float]): + self.path = costs_path + self.caps = caps # model → per-call cap $ + self.by_key: dict[str, float] = {} + + def guard(self, model: str, in_est: int, max_out: int) -> tuple[bool, float]: + pc = predict_cost(model, in_est, max_out) + cap = self.caps.get(model, 0.50) + return (pc <= cap, pc) + + def record(self, rec: dict) -> float: + c = cost_of(rec["model"], rec.get("usage", {})) + rec["cost"] = round(c, 6) + keyenv = rec.get("keyenv", rec["model"]) + self.by_key[keyenv] = self.by_key.get(keyenv, 0.0) + c + with open(self.path, "a", encoding="utf-8") as f: + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + return c + + +def messages_with_injection(system_prefix: str, injection: str, user: str) -> list[dict]: + """Layout render.go MessagesWithInjection: system(prefix,cache) → system(inj) → user.""" + m = [{"role": "system", "content": system_prefix}] + if injection.strip(): + m.append({"role": "system", "content": injection}) + m.append({"role": "user", "content": user}) + return m + + +if __name__ == "__main__": + # смоук: цены/токенайзер/предикт + ru = "Он шёл по тёмной улице, и снег ложился ему на плечи, укрывая следы." + print("glm-4.6 tokens:", count_tokens(ru, "glm")) + print("deepseek(v3.2) tokens:", count_tokens(ru, "deepseek")) + u = {"prompt_tokens": 5000, "completion_tokens": 3000, "total_tokens": 8200} + for m in ("glm-5", "gpt-5.4", "gemini-3.1-pro-preview", "grok-4.3"): + print(f"cost_of {m}: ${cost_of(m, u):.5f} predict(5k in, 8k out): ${predict_cost(m,5000,8000):.4f}") diff --git a/eval/exp14_judges.py b/eval/exp14_judges.py new file mode 100644 index 0000000..6b347e3 --- /dev/null +++ b/eval/exp14_judges.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""exp14 — trap-accuracy судьи (Претензия 2): span-цитирующие, кросс-семейно, +self-family exclusion, катастроф-флаг. Бюджет-умно: панель gpt-5-mini + kimi (дёшево); +gemini — ТОЧЕЧНО (output $12/M, €2.6 крошечный). Claude-воркфлоу — адверсариальный верифай сверху. + +Каждый (trap × arm) → per-judge {verdict: correct|partial|wrong, catastrophe, evidence≤15слов, note}. +Blind: армы под нейтральными метками; ключ отдельно. Персист в exp14/trap_verdicts.jsonl. + +Использование: exp14_judges.py [--dry] +""" +from __future__ import annotations +import argparse, json, random, re, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import exp14_common as C + +ARMS = C.DIAG / "arms" +MAT = json.load(open(C.DIAG / "material.json")) + +# ── Заморожённые трапы (§1.0.1) — файл-чанк, где живёт рендер трапа ───────────────── +TRAPS = [ + dict(id="T1", chunk="7.0", type="c-within", catastrophe=True, + span="古月族人没有一个不知道的", + meaning="В роду Гуюэ ЭТО ПРЕДАНИЕ знали ВСЕ/каждый (двойное отрицание «нет ни одного, кто не знал бы»). Инверсия «никто не знал» = КАТАСТРОФА."), + dict(id="T2", chunk="8.1", type="c-within", catastrophe=False, + span="物是人非", + meaning="Контраст: вещи/места ПРЕЖНИЕ, но люди уже НЕ ТЕ (物是 «вещи те же» + 人非 «люди иные»). Плоское «всё изменилось» ТЕРЯЕТ половину идиомы (物是)."), + dict(id="T2b", chunk="8.0", type="c-title", catastrophe=False, + span="第八节:物是人非 (заглавие)", + meaning="Заглавие главы: тот же контраст «вещи прежние — люди иные». Плоское «Всё изменилось» — потеря атома."), + dict(id="T5", chunk="24.1", type="d-cross-prev", catastrophe=False, + span="这当然不是他的肌肤本色,而是一种铜皮蛊的效果", + meaning="«这/他» отсылает к бронзовому цвету кожи НАСТАВНИКА (описан в конце пред-чанка 24.0). Верно: «это [бронзовый цвет кожи наставника] — не природный, а эффект гу медной кожи». Провал = потеря антецедента (кто/что)."), + dict(id="T6", chunk="11.1", type="d-cross-prev", catastrophe=False, + span="那隐藏在暗处的人马,见到沈翠这般出来", + meaning="«那…人马» = засадный отряд, который舅父/舅母 подготовили (введён в конце 11.0: «внизу тоже расставлены люди»). Верно: понятно, ЧЬИ это люди/засада. Провал = «какие-то люди» без связи."), + dict(id="T4", chunk="9.0", type="b-dialogue-control", catastrophe=False, + span="舅父…开口道:“…” (обмен репликами дядя/тётя/Фан Чжэн)", + meaning="КОНТРОЛЬ-регресс: реплики с «—», слова автора при своей реплике (Розенталь). P0 это делает верно. Арм НЕ должен СЛОМАТЬ диалог-оформление."), +] + +JUDGE_POOL = { # judge_model → family (для self-family exclusion) + "gpt-5-mini": "openai", "kimi-k2.6": "moonshot", "gemini-3.1-pro-preview": "google", +} +ARM_FAMILY = { # editor-модель арма → family (author≠reviewer) + "P0": "zai", "P1a": "zai", "P1b": "zai", "P1c": "zai", "A-layout": "zai", + "A-2pass": "zai", "A-ref-prev": "zai", "A-ref-both": "zai", + "F-base": "openai", "F-disc": "openai", +} +# draft общий = deepseek → deepseek не судья (self-family к черновику, где рождается T1) + +JUDGE_SYS = ("Ты — билингвальный эксперт zh→ru, оцениваешь ТОЧНОСТЬ передачи одного смыслового " + "фрагмента. Тебе дан китайский span, его допустимый смысл, и русский перевод отрывка " + "(под нейтральной меткой). Ответь СТРОГО JSON без markdown: " + '{"verdict":"correct|partial|wrong","catastrophe":true|false,' + '"evidence":"дословная цитата из перевода ≤15 слов","note":"кратко почему"}. ' + "verdict=correct только если смысл span передан без искажения полярности/участника/" + "потери атома. catastrophe=true при инверсии полярности или подмене участника/действия.") + + +def build_tasks(dry): + tasks = [] + for tr in TRAPS: + arms = [a for a in ARM_FAMILY if (ARMS / a / f"{tr['chunk']}.txt").exists()] + # blind labels + order = list(arms); random.Random(f"exp14-trap-{tr['id']}").shuffle(order) + label = {a: f"V{i+1}" for i, a in enumerate(order)} + for a in arms: + txt = (ARMS / a / f"{tr['chunk']}.txt").read_text(encoding="utf-8") + # окно вокруг трапа не вырезаем — даём весь отрывок (судья видит контекст) + for jm in JUDGE_POOL: + if JUDGE_POOL[jm] == ARM_FAMILY[a]: + continue # self-family exclusion + if jm == "gemini-3.1-pro-preview": + continue # gemini — точечно, отдельным проходом (бюджет); тут пропускаем + tasks.append(dict(trap=tr["id"], arm=a, label=label[a], judge=jm, + chunk=tr["chunk"], text=txt, span=tr["span"], + meaning=tr["meaning"])) + return tasks + + +def run(): + ap = argparse.ArgumentParser(); ap.add_argument("--dry", action="store_true"); a = ap.parse_args() + caps = {"gpt-5-mini": 0.05, "kimi-k2.6": 0.08, "gemini-3.1-pro-preview": 0.30} + sp = C.Spender(C.DIAG / "judge_costs.jsonl", caps) + tasks = build_tasks(a.dry) + # resume: пропустить уже отсуженные (trap,arm,judge) + vpath = C.DIAG / "trap_verdicts.jsonl" + out = [] + done = set() + if vpath.exists(): + for l in vpath.read_text(encoding="utf-8").splitlines(): + if not l.strip(): + continue + v = json.loads(l); out.append(v); done.add((v["trap"], v["arm"], v["judge"])) + tasks = [t for t in tasks if (t["trap"], t["arm"], t["judge"]) not in done] + print(f"trap-judge tasks: {len(tasks)} new ({len(done)} already done) " + f"({len(set(t['arm'] for t in tasks))} arms × {len(TRAPS)} traps × cross-family judges)") + if a.dry: + from collections import Counter + print("by judge:", Counter(t["judge"] for t in tasks)) + return + for t in tasks: + user = (f"КИТАЙСКИЙ SPAN: {t['span']}\nДОПУСТИМЫЙ СМЫСЛ: {t['meaning']}\n\n" + f"РУССКИЙ ПЕРЕВОД ОТРЫВКА (метка {t['label']}):\n{t['text']}\n\n" + f"Оцени ТОЛЬКО передачу указанного span. Ответь JSON.") + s = C.spec(t["judge"], max_tokens=(16000 if t["judge"] == "kimi-k2.6" else 4000)) + msgs = [{"role": "system", "content": JUDGE_SYS}, {"role": "user", "content": user}] + in_est = C.count_tokens(JUDGE_SYS + user, "glm") + ok, pred = sp.guard(t["judge"], in_est, s["max_tokens"]) + if not ok: + print(f" OVER-CAP {t['judge']} {t['trap']} {t['arm']} pred=${pred:.3f}"); continue + text, err, usage = C.call(s, msgs, timeout=300) + c = sp.record({"model": t["judge"], "judge": t["judge"], "keyenv": s["keyenv"], + "trap": t["trap"], "arm": t["arm"], "err": err, "usage": usage}) + v = parse_verdict(text) if not err else {"verdict": "ERR", "catastrophe": False, "note": err[:60]} + v.update(trap=t["trap"], arm=t["arm"], judge=t["judge"], label=t["label"]) + out.append(v) + print(f" {t['trap']} {t['arm']:<10} {t['judge']:<18} → {v.get('verdict'):<8} cat={v.get('catastrophe')} ${c:.4f}") + (C.DIAG / "trap_verdicts.jsonl").write_text("\n".join(json.dumps(v, ensure_ascii=False) for v in out), encoding="utf-8") + summarize(out) + + +def parse_verdict(text: str) -> dict: + m = re.search(r"\{.*\}", text, re.S) + if not m: + return {"verdict": "PARSE", "catastrophe": False, "note": text[:60]} + try: + d = json.loads(m.group(0)) + return {"verdict": str(d.get("verdict", "?")).lower(), "catastrophe": bool(d.get("catastrophe")), + "evidence": d.get("evidence", "")[:120], "note": d.get("note", "")[:120]} + except Exception: + vv = re.search(r'"verdict"\s*:\s*"(\w+)"', m.group(0)) + return {"verdict": vv.group(1).lower() if vv else "PARSE", "catastrophe": "true" in m.group(0).lower(), "note": text[:60]} + + +def summarize(out): + from collections import defaultdict + agg = defaultdict(lambda: defaultdict(list)) + for v in out: + agg[v["trap"]][v["arm"]].append(v) + print("\n=== TRAP-ACCURACY (per arm: majority verdict / catastrophe flags) ===") + for tr in TRAPS: + t = tr["id"] + if t not in agg: continue + print(f"\n{t} ({tr['type']}):") + for arm in ["P0","P1a","P1b","P1c","A-layout","A-2pass","A-ref-prev","A-ref-both","F-base","F-disc"]: + vs = agg[t].get(arm) + if not vs: continue + verds = [v["verdict"] for v in vs] + cat = any(v["catastrophe"] for v in vs) + print(f" {arm:<11} {verds} catastrophe={cat}") + + +if __name__ == "__main__": + run() diff --git a/eval/exp14_kpi.py b/eval/exp14_kpi.py new file mode 100644 index 0000000..c9267ec --- /dev/null +++ b/eval/exp14_kpi.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""exp14 — детерминированный структурный KPI (Претензия 1, БЕЗ судьи) + гварды. + +$0. Метрика Претензии 1: распределение предложений/нарратив-абзац (baseline P0: медиана 1.0, +среднее 1.70, доля 1-предл. 0.58). Гварды: CJK-утечка=0, length-ratio, glossary-присутствие, +диалог-тире-парность. Аварийный гейт: нельзя «побеждать» только МЕНЬШИМ числом абзацев → +KPI парно с atom-coverage-прокси (длина не должна коллапсировать). + +Использование: exp14_kpi.py [--scope trap|stage2] +""" +from __future__ import annotations +import argparse, json, re, statistics +from pathlib import Path +import exp14_common as C + +ARMS = C.DIAG / "arms" +MAT = json.load(open(C.DIAG / "material.json")) + + +def paras(t): return [p for p in re.split(r"\n\s*\n", t.strip()) if p.strip()] +def sents(p): return [s for s in re.split(r"(?<=[.!?…])\s+", p.strip()) if s.strip()] +def is_dlg(p): return p.strip().startswith("—") or p.strip().startswith("–") +def han(t): return sum(1 for c in t if "一" <= c <= "鿿" or "㐀" <= c <= "䶿") + + +def kpi_of(text: str) -> dict: + ps = paras(text) + narr = [p for p in ps if not is_dlg(p)] + dlg = [p for p in ps if is_dlg(p)] + sp = [len(sents(p)) for p in narr] or [0] + return dict( + n_para=len(ps), n_narr=len(narr), n_dlg=len(dlg), + median_spp=statistics.median(sp), mean_spp=round(statistics.mean(sp), 2), + share_1sent=round(sum(1 for x in sp if x == 1) / len(sp), 3), + chars=len(text), han=han(text), + ) + + +def gloss_presence(text: str, editor_constraint: str) -> float: + """Доля dst-форм из инъекции, чьё СТЕММ (первые 4 симв.) встречается в выходе (прокси).""" + dsts = re.findall(r"«([^»]+)»", editor_constraint) + if not dsts: + return 1.0 + hit = 0 + for d in dsts: + stem = d.split()[0][:4] + if stem and stem.lower() in text.lower(): + hit += 1 + return round(hit / len(dsts), 3) + + +def unit_map(scope): + if scope == "trap": + return {f"{u['chapter']}.{u['chunk_idx']}": u for u in MAT["trap_chunks"]} + m = {} + for ch, chunks in MAT["stage2"].items(): + for u in chunks: + m[f"{ch}.{u['chunk_idx']}"] = dict(chapter=int(ch), **u) + return m + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--scope", default="trap") + a = ap.parse_args() + units = unit_map(a.scope) + arms = sorted(d.name for d in ARMS.iterdir() if d.is_dir()) + print(f"=== exp14 KPI ({a.scope}, {len(units)} chunks) — Претензия 1 + гварды ===\n") + rows = {} + for arm in arms: + agg = [] + han_total, chars_total, chars_p0 = 0, 0, 0 + gl = [] + for uid, u in units.items(): + fp = ARMS / arm / f"{uid}.txt" + if not fp.exists(): + continue + t = fp.read_text(encoding="utf-8") + k = kpi_of(t) + agg.append(k); han_total += k["han"]; chars_total += k["chars"] + chars_p0 += len(u["final_p0"]) + gl.append(gloss_presence(t, u.get("editor_constraint", ""))) + if not agg: + continue + allsp_median = statistics.median([k["median_spp"] for k in agg]) + mean_spp = round(statistics.mean([k["mean_spp"] for k in agg]), 2) + share1 = round(statistics.mean([k["share_1sent"] for k in agg]), 3) + lr = round(chars_total / max(1, chars_p0), 3) + rows[arm] = dict(n=len(agg), mean_spp=mean_spp, share_1sent=share1, + han=han_total, len_ratio_vs_p0=lr, + gloss=round(statistics.mean(gl), 3)) + hdr = f"{'arm':<12}{'n':>3}{'mean_spp':>10}{'share_1s':>10}{'CJK':>6}{'len/P0':>8}{'gloss':>7}" + print(hdr); print("-" * len(hdr)) + # baseline first + order = [a for a in ["P0", "P1a", "P1b", "P1c", "A-layout", "A-2pass", + "A-ref-prev", "A-ref-next", "A-ref-both", "F-base", "F-disc"] if a in rows] + order += [a for a in rows if a not in order] + for arm in order: + r = rows[arm] + print(f"{arm:<12}{r['n']:>3}{r['mean_spp']:>10}{r['share_1sent']:>10}" + f"{r['han']:>6}{r['len_ratio_vs_p0']:>8}{r['gloss']:>7}") + print("\nПретензия-1 сигнал: mean_spp↑ и share_1sent↓ vs P0 = меньше рублености (лучше);") + print("гейт: len/P0 не должен коллапсировать (<~0.9 = подозрение на потерю атомов); CJK=0 обяз.") + (C.DIAG / f"kpi_{a.scope}.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/eval/exp14_material.py b/eval/exp14_material.py new file mode 100644 index 0000000..f0e7ea1 --- /dev/null +++ b/eval/exp14_material.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""exp14 — материал: trap-чанки, Stage-II главы (детерм. D), реконструкция глоссарий- +инъекции из rerun-store (rig-фиделити A1′, как exp12_blocks), P0-baseline paragraph-KPI. + +$0. Выход: /home/ubuntu/books/gu-zhenren/exp14/material.json (ВНЕ git). +""" +from __future__ import annotations +import json, re, sqlite3, statistics +from pathlib import Path + +RERUN = Path("/home/ubuntu/books/gu-zhenren/rerun") +OUT = Path("/home/ubuntu/books/gu-zhenren/exp14"); OUT.mkdir(parents=True, exist_ok=True) +DB = RERUN / "guzhenren-rerun.db" + +# ── Заморожённый выбор (§1.0) ────────────────────────────────────────────────────── +TRAP_CHUNKS = [(7, 0), (8, 0), (8, 1), (9, 0), (10, 0), (11, 0), (11, 1), (24, 0), (24, 1)] +STAGE2 = [19, 17, 18] +EXCLUDE = {(5, 0), (20, 0)} # экспорт-баг D35.4a + +EDITOR_HEADER = ("КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть " + "переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по " + "контексту; не вводи иных вариантов и не меняй ничего другого):") +GLOSS_HEADER = ("ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; " + "строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):") + + +def load_records(): + d = json.load(open(RERUN / "records.json")) + return {(r["chapter"], r["chunk_idx"]): r for r in d}, d + + +def paras(t: str): + return [p for p in re.split(r"\n\s*\n", t.strip()) if p.strip()] + +def sents(p: str): + return [s for s in re.split(r"(?<=[.!?…])\s+", p.strip()) if s.strip()] + +def dialogue_D(source: str) -> float: + """exp12 §1.1: доля символов внутри «“…”» (U+201C..U+201D) от не-пробельных.""" + nonspace = sum(1 for c in source if not c.isspace()) + inq, indq = 0, False + for c in source: + if c == "“": + indq = True; continue + if c == "”": + indq = False; continue + if indq and not c.isspace(): + inq += 1 + return inq / max(1, nonspace) + + +def reconstruct_injection(): + """editor_constraint (dst-only, approved, dedup) + bilingual_glossary (src→dst[+⟨проверить⟩]) + per chunk из injected_ids + glossary. Sticky union chunk0∪.. внутри главы (exp12 A5).""" + con = sqlite3.connect(DB); con.row_factory = sqlite3.Row + gl = {(r["src"], r["sense"], r["since_ch"], r["until_ch"]): (r["dst"], r["status"]) + for r in con.execute("SELECT src,sense,since_ch,until_ch,dst,status FROM glossary")} + inj = {} + for r in con.execute("SELECT chapter,chunk_idx,injected_ids,n_sticky,n_evicted FROM retrieval_state"): + inj[(r["chapter"], r["chunk_idx"])] = (json.loads(r["injected_ids"] or "[]"), + r["n_sticky"], r["n_evicted"]) + con.close() + + def keydec(k): + src, sense, since, until = k.split("\x1f") + return (src, sense, int(since), int(until)) + + def block_for(keys): + recs = [] + for k in keys: + src, sense, since, until = keydec(k) + dst, status = gl.get((src, sense, since, until), ("", "auto")) + if dst.strip(): + recs.append((src, dst.strip(), status == "approved")) + seen, el = set(), [] + for src, dst, conf in recs: + if conf and dst not in seen: + seen.add(dst); el.append(f"- «{dst}»") + eb = (EDITOR_HEADER + "\n" + "\n".join(el)) if el else "" + glines = [f"{src} → {dst}" + ("" if conf else " ⟨проверить⟩") for src, dst, conf in recs] + gb = (GLOSS_HEADER + "\n" + "\n".join(glines)) if glines else "" + return eb, gb, len(el), len(glines) + + out = {} + chapters = sorted(set(ch for ch, _ in inj)) + for ch in chapters: + cidxs = sorted(c for (cc, c) in inj if cc == ch) + chunk0keys = inj.get((ch, 0), ([], 0, 0))[0] + for c in cidxs: + keys, nst, nev = inj[(ch, c)] + active = list(keys) + for k in chunk0keys: # sticky scene-inertia within chapter + if c > 0 and k not in active: + active.append(k) + eb, gb, ne, ng = block_for(active) + out[f"{ch}/{c}"] = {"editor_constraint": eb, "bilingual_glossary": gb, + "n_constraint": ne, "n_gloss": ng, "n_sticky": nst, "n_evicted": nev} + # chapter-level union (для P1b/P1c whole-chapter арма) + allkeys = [] + for c in cidxs: + for k in inj[(ch, c)][0]: + if k not in allkeys: + allkeys.append(k) + eb, gb, ne, ng = block_for(allkeys) + out[f"{ch}/ALL"] = {"editor_constraint": eb, "bilingual_glossary": gb, + "n_constraint": ne, "n_gloss": ng} + return out + + +def p0_kpi(recs): + """P0 baseline: sentences/narrative-paragraph по 55 committed чанкам (freeze-число).""" + sp = [] + for (ch, ck), r in recs.items(): + if (ch, ck) in EXCLUDE: + continue + for p in paras(r["final"]): + if p.strip().startswith("—"): + continue + sp.append(len(sents(p))) + return {"n_paras": len(sp), "median": statistics.median(sp), + "mean": round(statistics.mean(sp), 3), + "share_1sent": round(sum(1 for x in sp if x == 1) / len(sp), 3)} + + +def main(): + recs, _ = load_records() + inj = reconstruct_injection() + + # verify: trap chunks have reconstructed blocks + print("=== Инъекция реконструирована (trap-чанки) ===") + for ch, ck in TRAP_CHUNKS: + b = inj[f"{ch}/{ck}"] + print(f" {ch}.{ck}: {b['n_constraint']} constraint / {b['n_gloss']} gloss " + f"(sticky={b['n_sticky']} evict={b['n_evicted']})") + + # Stage-II D + print("\n=== Stage-II главы: детерм. D (диалого-плотность) ===") + d_scores = {} + for ch in STAGE2: + src = "".join(recs[(ch, c)]["source"] for (cc, c) in recs if cc == ch) + d_scores[ch] = round(dialogue_D(src), 3) + print(f" гл.{ch}: D={d_scores[ch]}") + + # P0 KPI + kpi = p0_kpi(recs) + print(f"\n=== P0 baseline paragraph-KPI (freeze-сверка) ===\n {kpi}") + + # export material for arms + mat = {"trap_chunks": [], "stage2": {}, "kpi_p0": kpi, "stage2_D": d_scores} + for ch, ck in TRAP_CHUNKS: + r = recs[(ch, ck)] + mat["trap_chunks"].append({"chapter": ch, "chunk_idx": ck, + "source": r["source"], "draft": r["draft"], "final_p0": r["final"], + "editor_constraint": inj[f"{ch}/{ck}"]["editor_constraint"], + "bilingual_glossary": inj[f"{ch}/{ck}"]["bilingual_glossary"]}) + for ch in STAGE2: + mat["stage2"][ch] = [{"chunk_idx": c, "source": recs[(ch, c)]["source"], + "draft": recs[(ch, c)]["draft"], "final_p0": recs[(ch, c)]["final"], + "editor_constraint": inj[f"{ch}/{c}"]["editor_constraint"], + "bilingual_glossary": inj[f"{ch}/{c}"]["bilingual_glossary"]} + for (cc, c) in sorted(recs) if cc == ch] + (OUT / "material.json").write_text(json.dumps(mat, ensure_ascii=False), encoding="utf-8") + (OUT / "injection_blocks.json").write_text(json.dumps(inj, ensure_ascii=False, indent=1), encoding="utf-8") + print(f"\n→ {OUT/'material.json'} ({len(mat['trap_chunks'])} trap chunks, {len(STAGE2)} stage-II chapters)") + + +if __name__ == "__main__": + main() diff --git a/eval/exp14_prompts.py b/eval/exp14_prompts.py new file mode 100644 index 0000000..180b824 --- /dev/null +++ b/eval/exp14_prompts.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""exp14 — рендер промптов армов (ЗАМОРОЖЕНЫ; sha256 в §2 дока). + +- baseline editor = backend/prompts/editor.md (v2-bilingual-reflow), brief-filled. +- discourse editor = baseline + дискурс-reflow ядро (§1.2: 5 техник Ван Цуй + Розенталь) + few-shot. +- translator (для re-draft глав/нарезки) = backend/prompts/translator.md (v1-reflow). +Few-shot — минимальные ортогональные (narrative N:1, dialogue 1:N, focal-shift). НЕ ручной CoT. +""" +from __future__ import annotations +import hashlib +from pathlib import Path + +PROMPTS = Path("/home/ubuntu/projects/textmachine/backend/prompts") +BRIEF = dict(source_lang="zh", target_lang="ru", genre="вебновелла", + audience="взрослые читатели вебновелл", title="蛊真人", + honorifics="keep", transcription="palladius", venuti="0.6", footnotes="minimal") + + +def _fill(t: str) -> str: + for k, v in BRIEF.items(): + t = t.replace("{{" + k + "}}", v) + return t + + +def _split(md_text: str) -> tuple[str, str]: + sysmd, usermd = md_text.split("---USER---", 1) + return _fill(sysmd).strip(), _fill(usermd).strip() + + +# ── Дискурс-reflow ядро (§1.2; арм для ЗАМЕРА, не «оптимальный промпт») ────────────── +DISCOURSE_CORE = """ +ДИСКУРС-ПЕРЕВЁРСТКА (обязательная операция передачи китайского паратаксиса в русскую гипотактическую прозу — это КОНВЕНЦИЯ русского языка, а не вольность): +1. Молча определи смысловые ходы фрагмента: единый фокал/наблюдатель; действие и его непосредственная реакция; одна тема рассуждения; границы — смена говорящего, времени, места или фокала. +2. Один повествовательный ход оформляй ОДНИМ русским абзацем, ДАЖЕ если китайский исходник разбит на однофразовые строки. Инструмент слияния — причастные и деепричастные обороты, подчинительные союзы и связки (построй иерархию, сегментируй по смыслу, промаркируй вид/время предикатов, варьируй лексику, добавляй связки). Не сливай ТОЛЬКО ради длины и не теряй смысловые атомы. +3. Каждую новую реплику начинай с нового абзаца через тире «—»; слова автора при той же реплике оставляй в её абзаце. +4. Разрешено объединять и делить русские предложения; число предложений и абзацев совпадать с исходником НЕ обязано. Запрещено терять смысловые атомы (участников, действия, числа, полярность, заголовки). +""" + +FEWSHOT = """ +Примеры требуемой ПЕРЕВЁРСТКИ (показана структура вывода, не стиль): + +[narrative N:1] Исходник построчно — «Он вышел за дверь.» / «Небо было серым.» / «Он вздохнул.» / «Дорога уходила вдаль.» — становится ОДНИМ абзацем: +Он вышел за дверь. Небо было серым; вздохнув, он смотрел, как дорога уходит вдаль. + +[dialogue 1:N] Исходник — «Дядя нахмурился и сказал: „Время летит. Садись.“ Племянник ответил: „Спасибо.“» — становится: +Дядя нахмурился. +— Время летит. Садись, — сказал он. +— Спасибо, — ответил племянник. + +[focal-shift] При смене наблюдателя/места/времени — новый абзац, даже если в исходнике это продолжение той же строки. +""" + +REFERENCE_NOTE = ("\n\nСПРАВОЧНЫЙ КОНТЕКСТ (соседние фрагменты — ТОЛЬКО для разрешения связей, " + "местоимений и опущенных подлежащих; НЕ переводи и НЕ пересказывай его в выходе):") + + +def editor_system(variant: str, few_shot: bool = True) -> str: + sysmd, _ = _split((PROMPTS / "editor.md").read_text(encoding="utf-8")) + if variant == "baseline": + return sysmd + if variant == "discourse": + out = sysmd + "\n" + DISCOURSE_CORE + if few_shot: + out += FEWSHOT + return out + raise ValueError(variant) + + +def editor_user(source: str, draft: str, reference: str = "") -> str: + _, usermd = _split((PROMPTS / "editor.md").read_text(encoding="utf-8")) + u = usermd.replace("{{text}}", source).replace("{{draft}}", draft) + if reference.strip(): + u += REFERENCE_NOTE + "\n" + reference.strip() + return u + + +def translator_system() -> str: + sysmd, _ = _split((PROMPTS / "translator.md").read_text(encoding="utf-8")) + return sysmd + +def translator_user(source: str) -> str: + _, usermd = _split((PROMPTS / "translator.md").read_text(encoding="utf-8")) + return usermd.replace("{{text}}", source) + + +def sha_frozen() -> dict: + return { + "editor_baseline_sys": hashlib.sha256(editor_system("baseline").encode()).hexdigest()[:16], + "editor_discourse_sys": hashlib.sha256(editor_system("discourse").encode()).hexdigest()[:16], + "discourse_core": hashlib.sha256(DISCOURSE_CORE.encode()).hexdigest()[:16], + "fewshot": hashlib.sha256(FEWSHOT.encode()).hexdigest()[:16], + "translator_sys": hashlib.sha256(translator_system().encode()).hexdigest()[:16], + } + + +if __name__ == "__main__": + import json + print("=== frozen prompt sha256[:16] ===") + print(json.dumps(sha_frozen(), indent=1, ensure_ascii=False)) + print("\n=== editor discourse system (first 600) ===") + print(editor_system("discourse")[:600])