Land exp14 batch-1: empirics show claim-1 paragraphs is a cheap prompt/pipeline lever while claim-2 within-chunk comprehension is a frontier-model-capability floor

This commit is contained in:
Claude (backend session) 2026-07-11 22:50:09 +03:00
parent 353bb52676
commit dc5cf3bb9d
8 changed files with 982 additions and 2 deletions

View file

@ -7,6 +7,19 @@
> - **Ждём от владельца:** ~~подпись спорных терминов сида v2~~ ✅ ПОДПИСАНО (D34.1; владелец переопределил: Жэнь Цзу, Монах Цветочного Вина, гу Жизни, деревня Гуюэ, первобытный камень; род «гу» муж) · **чтение 25 глав пере-прогона** (арбитр читаемости — после связки+re-gate) · **флагман-сверка exp13** (переводчики, monitor-паттерн) · DashScope-ключ для qwen-арма exp13 (опционально) · **планка запуска** (лучше-фана/гибрид/издательский + готовность к COGS опции Б ~$1.52 — мемо-эскалация 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 $24, 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 вопросы.

View file

@ -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.61.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)

175
eval/exp14_arms.py Normal file
View file

@ -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()

191
eval/exp14_common.py Normal file
View file

@ -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}")

156
eval/exp14_judges.py Normal file
View file

@ -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, evidence15слов, 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()

108
eval/exp14_kpi.py Normal file
View file

@ -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()

168
eval/exp14_material.py Normal file
View file

@ -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()

101
eval/exp14_prompts.py Normal file
View file

@ -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])