Land exp15 REV.2: floor-gated corrected results with per-vote judge persistence, the retracted boundary headline, and orchestrator landing fixes to the deviations ledger

This commit is contained in:
Claude (backend session) 2026-07-18 17:06:11 +03:00
parent cabdfb35b4
commit fe34a16896
29 changed files with 5134 additions and 0 deletions

View file

@ -311,3 +311,214 @@ within-chapter size-span ДО записи вывода. «0 ошибок ≠ 0
8. **Судьи подтверждены живьём:** grok-4.3 reasoning-ON (262 rt), mistral-large-latest резолвится, glm-5 PAYG доступен. Gemini как 3-й судья — эмпир-верификация thoughts-accounting ДО опоры на его usage. 8. **Судьи подтверждены живьём:** grok-4.3 reasoning-ON (262 rt), mistral-large-latest резолвится, glm-5 PAYG доступен. Gemini как 3-й судья — эмпир-верификация thoughts-accounting ДО опоры на его usage.
9. **`reflow_omission.py` реюзабелен** для детерм. вторички (entity-omission), НО ПОТОЛОК (сущности выживают) — первичка Q1/Q3 = trap-точность span-судей; term-substitution (класс/разряд) = glossary-drift-ось, не omission. 9. **`reflow_omission.py` реюзабелен** для детерм. вторички (entity-omission), НО ПОТОЛОК (сущности выживают) — первичка Q1/Q3 = trap-точность span-судей; term-substitution (класс/разряд) = glossary-drift-ось, не omission.
10. **Метрика границы Q1 (вторичка):** доля разрезов на scene-shift-швы (выше=лучше) vs continuation-швы (ниже=лучше); первичка — T-tense+T-ana точность (общий знаменатель в A0). 10. **Метрика границы Q1 (вторичка):** доля разрезов на scene-shift-швы (выше=лучше) vs continuation-швы (ниже=лучше); первичка — T-tense+T-ana точность (общий знаменатель в A0).
---
## §7 — ИСПОЛНЕНИЕ ПЛАТНОГО Q1/Q3 (полигон, 2026-07-18)
> Исполнено по хендофф-промту + курс-коррекциям оркестратора/владельца (три раунда решений по ходу).
> Пре-рег §1 НЕ пере-сочинялся; все отклонения от него — явно в §7.7. Скрипты — `eval/exp15/` (ВНЕ git,
> лендит оркестратор); артефакты/выходы — `/home/ubuntu/books/gu-zhenren/exp15/` (ВНЕ git).
> **⚠ РЕВИЗИЯ 2 (2026-07-18, пост-верификация оркестратора).** Оркестратор верифицировал первый прогон по
> сырью (6 осей) и нашёл, что **три заголовка НЕ лендятся** — не из-за арифметики (она воспроизводится до
> цента), а из-за **артефакта рига** и **пропущенного floor-гейта**. Ключевое: судейский head-excerpt
> (`HEAD_CHARS=1100`) показывал судье лишь первые 1100 символов каждого финала; в оракул-арме зависимый
> спан сидит в СЕРЕДИНЕ бо́льшего seam-выровненного чанка → его рендер уходил ЗА окно → судья видел
> «отсутствует» = wrong/катастрофа ПО ПОСТРОЕНИЮ. Именно это, НЕ граница, дало заголовок «оракул 0.564».
> Данная ревизия: **полно-чанковый пере-суд Q1b** (окно снято, весь финал), **полно-чанковый FLOOR** (A0
> vs A0, шум-пол), **re-analysis Q3a/Q0** (floor-relative, обе оценки, пул-fix-rate, TOST). Первый прогон
> (§7.4-v1) сохранён как провенанс. Полный список правок — §7.8. Пре-рег §1 НЕ пере-сочинялся.
### §7.0 Итог одной строкой (РЕВ.2)
Судейский **шум-пол ≈ 0.126** (FLOOR: A0 vs A0, mean|Δ|; сходится с 0.119/0.115 из двух независимых прокси)
**больше любого маргинального эффекта** на этом срезе → **ни граница (Q1), ни carryover (Q3a), ни редактор
(Q0) не отличимы от судейского шума**. Заголовок v1 «оракул хуже (0.564)» = **артефакт head-excerpt** (пере-суд
полными чанками сдвинул на **+0.689** → T-ana **+0.125** на самом полу, LOO-хрупко): вывод «граница ВРЕДИТ»
**отозван** — seam-выравнивание НЕ вредит и НЕ является ратифицируемым рычагом; единственный устойчивый сигнал —
слепые greedy-разрезы иногда **рвут сущностную кореференцию** (T-ent катастрофы flat 15 vs оракул 1). Базовый
flat-A0 (greedy-1500 + глоссарий) держит бо́льшую часть кросс-граничных анафор — кросс-граничные сбои РЕДКИ.
Ось решения — **слепое чтение владельца** (метрики номинируют, чтение ратифицирует) + больший/трудный корпус.
### §7.1 Материал (актуальный — отклонение от пре-рег S2-12节)
- Пре-рег S2 (12 节, склейка) нёс **пустые строки на швах** (`\n\n\n`) → бэкендовый `splitParagraphs` видит
их как абзацы → greedy A0 режет на ВСЕХ 11 швах → **премиса Q1 нарушена** (A0 не «слеп» к сценам). Диагностика
байт-верифицирована (Δ2=индент на всех швах; §7.7-D2).
- **Фикс (решение оркестратора):** collapse `\n\n→\n` = **flat** (швы неявные, greedy слеп). Старый blank-line
анкор (aligned_a0, режет на истинных швах) **не выброшен — стал ОРАКУЛ-армом Q1b** (оракул-коррекция).
- **Расширение (решение владельца, полная мощность):** срез **节 145→30** (после проекции time/money).
Итог: **S2prime_big_flat.txt** (30 节, 87.5k рун, **flat-A0-30 = 51 чанк** greedy-1500) + **blanklines**-версия
(**oracle-30 = 63 чанка**, режет на 29 швах + бюджет). Sid-покрытие здорово до 节60 (медиана ~16 термов/节) *[испр. оркестратором: расчёт не персистирован — сессионный; к пере-выводу скриптом при переиспользовании]*.
### §7.2 Порт инъекции — валидирован БАЙТ-В-БАЙТ (несущее)
`eval/exp15/mem_select.py` — полный Python-порт Go `Select` (memnorm нормализация + Aho-Corasick +
trust-gated `suppressContained` + sticky depth-2 + budget + render). Валидация **методом оркестратора**
(`LOG_LLM_BODIES=1 LOG_LEVEL=debug LOG_FORMAT=json` → байт-дифф 2-го system-msg против отрендеренного Go-блока):
**L1 injected_ids + L2 translator-блок = 24/24 (оракул), 51/51 (flat-A0-30), 51/51 (flat-A0-30)** — БАЙТ-идентично.
Ключи к идентичности: frozen entry order = `ORDER BY src,sense,since_ch,until_ch,status,dst` (glossary.go:178);
`injected_ids` слеп к sticky (валидировать против блока, НЕ ids). Greedy-чанкер тоже портирован
(`chunker.py greedy_chunks`, воспроизводит A0-границы точно). Арм-риг промпт байт-совпал с бэкендом (773c translator).
### §7.3 Трап-сет (детерм-первый + kimi-обогащение; guard 1/3)
`final_traps.json`**44 трапа, все straddling flat-A0-30-границу** (общий знаменатель):
- **T-ana = 19** (первичка, carryover-дискриминирующая): 7 детерминистических (anaphor-head) + 12 kimi-обогащения
на границах (батчами, $0.68, 0 ретраев). *19 — на 1 ниже квоты ≥20; честно-недомощно.*
- **T-ent = 23** (NEGATIVE CONTROL): глоссарий-инъекция уже держит каноническую dst в обоих чанках → carryover
не должен добавить; контроль квантует size-конфаунд.
- **T-ell = 2** (вторичка; коротко — honest-power).
- **T-tense/T-cat/T-gen/T-reg = «undecidable a priori на материале»** (guard 3): плотность 3/80 в майнинге =
свойство прозы, не размер среза; НЕ имитировали.
- Найдено ключевое: kimi-майнинг (11 окон, $1.45) дал 80 кандидатов, но ЛОКАЛЬНЫХ (не straddling) — 4/77
пересекали границу; детерминистический entity/anaphor-детект на границах + таргет-обогащение — верный метод.
### §7.4 Результаты по Q (РЕВ.2 — floor-relative; судьи grok-4.3 reasoning-ON + mistral-large-latest, оба порядка, 6→10, LOO, катастроф-скрин)
**§7.4.0 FLOOR — судейский шум-пол (A0 vs A0, полные чанки, $1.24, 404 вызова, n=21 первичных, 0 транспорт-ошибок).**
A0 и A0 = два независимых прогона ОДНОГО конфига (разный семпл-сид) → истинно эквивалентны; любой |Δ| = шум
судьи+генерации. Результат: **mean|Δ| = 0.126** (median 0.000, max 1.0 — бимодально: 12/21 ячеек ровно 0, хвост
до полного разворота), signed mean **0.019** (нет системного смещения), катастрофы **58/58 симметрично**. Сходится
с прокси a0↔a0 из Q3a-данных (0.115) и оценкой оркестратора (0.119). **ГЕЙТ ИНТЕРПРЕТАЦИИ (фриз §1.5): любой
|эффект| < ~0.126 не отличим от судейского шума.**
**Q1a — детекция ($0):** §B1.5-DP-чанкер восстанавливает **0/7** истинных швов на flat (точное рунное совпадение;
= слепой greedy, тоже 0/7). *[v1 сообщал 1/7 — из СТЕЙЛ `q1_setup.json` (mtime до flat-фикса); пере-прогон рига
даёт 0/7 у обоих.]* Судей не жгли.
**Q1b — граница (оракул seam-aligned vs flat blind). ⚠ ПЕРЕ-СУЖДЕНО ПОЛНЫМИ ЧАНКАМИ ($1.72, 556 вызовов, 0
транспорт-ошибок).** [+diff = оракул лучше; сравнение с 0 + шум-полом 0.126]
| тип | n | mean_diff (v2 полн-чанк) | CI90 | катастрофы оракул/flat | v1 (head-excerpt, АРТЕФАКТ) |
|---|---|---|---|---|---|
| **T-ana (первичка)** | 18 | **+0.125** | [+0.008, +0.243] | 55/71 | ~~0.564~~ |
| T-ana+T-ell (primary) | 20 | **+0.072** | [0.052, +0.196] | 65/72 | — |
| T-ent (контроль) | 10 | +0.112 | [0.001, +0.225] | **1/15** | ~~0.308~~ |
| T-ell | 2 | ≈0.4 | (n=2) | — | ~~0.834~~ |
- **Артефакт head-excerpt подтверждён и снят.** v1 заголовок T-ana **0.564** был целиком следствием усечения
1100 символов (в оракуле зависимый спан — в середине, за окном → судья «не видит» → wrong/катастрофа). Полный
чанк сдвинул T-ana на **+0.689** (полная смена знака). Диагностический тест: split по позиции зависимого в
оракул-чанке — v1 дал **dep-в-окне +0.238 / dep-вне 0.872** (сигнатура артефакта); v2 полными чанками
**+0.176 / +0.028** — split **СХЛОПНУЛСЯ** (артефакт устранён). Катастроф-асимметрия v1 (168/43) тоже была
артефактом → v2 почти симметрично (65/72).
- **Скорректированный вывод: seam-выравнивание НЕ вредит и НЕ является ратифицируемым рычагом.** Первичка
(T-ana+T-ell) **+0.072 — В ПРЕДЕЛАХ шум-пола 0.126**; T-ana **+0.125 на самом полу** и **LOO-хрупко**: grok видит
оракул лучше (+0.307), mistral ~нейтрально (0.029) — судьи расходятся по знаку. T-ell (n=2) оба судьи ниже нуля.
- **Единственный устойчивый сигнал** (оба судьи согласны, +0.11): на **T-ent** слепые greedy-разрезы иногда рвут
сущностную кореференцию через границу → катастрофы (T-ent-1 5, T-ent-6 1, T-ent-12 9 = flat 15), которых
seam-выравнивание избегает (оракул 1). Это реальный механизм, но класс — негативный контроль, эффект на полу.
**НЕ вычитаем T-ent из T-ana** (пост-хок size-контроль 0.31 из v1 сам нёс тот же артефакт — не лендится).
- Оговорка (оркестратор): **17/18 трапов оракул НЕ разрезал на швах** (0 на seam) → механизм «оракул режет
continuation-клиффхэнгеры» НЕ протестирован ни одним трапом; тест — про качество когезии ВНУТРИ разного чанкинга.
T-ana-33 исключён из Q1b (оракул-чанк 12 = cjk_artifact, editor-skip; оракул-чанк 42 — тот же класс, трапов на него не мапилось). *[испр. оркестратором:]* T-ana-33 присутствует ТОЛЬКО в FLOOR; Q3a/Q0/Q1b-v1 судились на n=18 (в сессии-1 он не входил ни в один блок).
**Q3a — carryover (пере-АНАЛИЗ существующих данных; артефакт head-excerpt Q1b-специфичен — у Q3a обе стороны
flat-чанкинг, зависимый в ГОЛОВЕ у обеих → усечения-асимметрии нет; $2.32, 1303 голоса).** Обе оценки + CI90:
- **Первичка (T-ana+T-ell), парная**: src **0.005** [0.12,+0.11], draft 0.008, final **0.055**; маргинальная
arm-acc ≈ парной (src 0.005). **ВСЕ ниже шум-пола 0.126 → не интерпретируемы.** (Заголовок v1 «+0.042» —
пост-хок ТОЛЬКО T-ana; по пре-рег §1.5 первичка = T-ana+T-ell.)
- **Преимущества последовательного final-соседа НЕ обнаружено НИГДЕ**: точечная оценка ≤0 в обеих метриках и во
всех LOO-срезах (final: парная 0.055, LOO без-grok 0.061 / без-mistral 0.038). «Цена параллелизма ≈0» v1 —
подмена метрики (arm-acc 0.0002 vs парная 0.047 для final); честно: **цена параллелизма НЕ исключена до
~0.050.10** (MDE≈0.18, p≈0.5); «не ратифицированный зелёный свет», не «безопасно».
- **§D5 fix-rate = пул-определение 2/6 = 33% во ВСЕХ режимах** (v1 «25/25/0%» — артефакт пере-суженных per-mode
знаменателей: A0 пере-суждён отдельно на режим). A0-провалено 6 трапов (пул a0<0.5).
- **Раскрытие T-ell-43** (катастрофа «победителя»): a0 1.0 → арм src 0.0 / draft 0.125 / final 0.042; src
cat_arm 11 vs a0 0 — source-carryover САМ создал грубую ошибку там, где flat был идеален.
- LOO src меняет знак по судье; final устойчиво-отрицателен.
**Q0 — do-nothing (draft) vs A0 (editor final); эквивалентность НЕ установлена ($0.99, 578 голосов).** Точка
mean(A0do-nothing) **+0.041** верна, но **TOST ПРОВАЛЕН**: sd 0.155, **90% CI (0.008, +0.089)** — верх за
маржой M=0.05 → по правилу фриза **«данных мало», ветка §D5 «мандаты редактора пересматриваются» НЕ триггерится**.
Симметрия (в заголовок рядом с carryover-плюсом): редактор **режет катастроф-голоса** T-ana 66→52 (21%, размер
как у Q3a-плюса). Единицы: это ГОЛОСА, не трапы (distinct трапов-с-катастрофой editor 8 vs donothing 11); редактор
добавил флаги на 5/18 трапов, чистый срез тянут T-ana-32 (13→0) и T-ell-40 (6→0). ⚠ Каветка v1 в силе: трап-сет —
когезия, которую редактор и не адресует (стиль/reflow/глоссарий-энфорс ЗДЕСЬ не мерен).
### §7.5 Дерево решений §D5 (РЕВ.2 — floor-relative)
- **Q1 (граница):** первичка **+0.072 в пределах шум-пола** + Q1a 0/7 → **строить §B1.4/B1.5-DP НЕ показано; граница
не является ратифицируемым рычагом когезии на этом срезе.** Но вывод v1 «граница ВРЕДИТ» **ОТОЗВАН** (был артефакт):
seam-выравнивание нейтрально-к-слабо-позитивно и режет сущностные катастрофы разрезов. Практический итог тот же
(граница вторична), но по ВЕРНОЙ причине (sub-floor null, не вред).
- **Q3a (carryover):** все эффекты ниже пола; преимущества последовательного final-соседа не обнаружено нигде
**оживление carryover НЕ ратифицируется**; source-carryover в волне 1: вреда не видно выше шум-пола (MDE≈0.18 — вред до ~0.050.10 не исключён), пользы не доказано. Недомощно
(база держит бо́льшую часть, ~6 провалов) → человеческий эндпоинт + больший/трудный корпус.
- **Q0 (редактор):** эквивалентность на когезия-трапах **НЕ установлена** (CI за M) → **мандаты редактора НЕ
пересматриваются по этим данным** (данных мало); редактор режет катастроф-хвост; его стиль/reflow-ценность — ВНЕ
этого трап-сета (не мерена).
- **Финал:** ни одна ветка не даёт прод-кандидата ТОЛЬКО по метрикам (все — на/под судейским полом); гейтящий
эндпоинт — **слепое чтение владельца** (`exp15/monitor/blind_read_packet.md`, метки перемешаны; ключ отдельно) +
больший корпус. Метрики НОМИНИРУЮТ, чтение РАТИФИЦИРУЕТ (планка ≤2 претензии/глава).
### §7.6 Деньги (РЕВ.2)
Глобальный спент **$12.79 / $15** (маржа ~$2.2). Сессия-1 (первый прогон) $9.82 [авторитетный учёт оркестратора,
2541 биллинг-строка, 0 расхождений — включает tmctl-генерацию анкоров, которой НЕТ в Python-леджерах]. Сессия-2
(ревизия): **FLOOR полн-чанк $1.24 + Q1b пере-суд полн-чанк $1.73 = $2.97**. Гейты: пер-call predicted-cost
($0.04) + **ГЛОБАЛЬНЫЙ потолок $14.5** (seed = $9.82 + новые леджеры; фикс review-I8: раньше per-block потолок
суммировался в ~4×бюджета). Ошибки: **0 транспорт-ошибок; 3 parse-drop / 2313 голосов = 99.87% успех** (v1 «0
ошибок» уточнён). **«~46 таймаутов» УБРАН** — артефакта нет (max latency 66.5s при timeout 240s; литеральных
таймаутов 0). Rate-limit: FLOOR полн-чанк показал mistral 48% retry-fails @1.3s (max lat 64.7s), grok 0% →
интервал mistral расширен до 2.6s для Q1b *[испр. оркестратором: retry-fail-rate не изменился, 49.5%→48.2% — эффект интервала не подтверждён леджерами; транспорт-ошибок 0, все ретраи успешны]*. **Q4a вынесен ВНЕ этого капа** (отдельная долинная
мини-сессия, ~$2, дефолт-да владельца — §7.8/дев.9).
### §7.7 Самопроверка + ОТКЛОНЕНИЯ от пре-рега (явно)
**Ревью ИСПОЛНЕНИЕМ поймал 9 багов ДО искажения платных результатов** (мандат самопроверки в действии):
адверсариальный ревью рригов (5) + sanity-гейты (4). Ключевые:
- (A) **arms.py editor-context double-inject** (HIGH) — 3 q3a-режима слали контекст в оба стейджа; фикс: shared
src-carryover draft → 3 редактора (только target-side разнится). Поймано sanity-гейтом.
- (B) **predict_cost под-предсказывал kimi-reasoning** → потом ЭТОТ фикс уронил grok в судьях (гейт-блок $0.033>$0.03);
фикс: judge max_tokens=1500. Поймано judge-selftest'ом.
- (C) mistral 429 rate-limit → retry+backoff+per-model delay. (D) trap_curate `cut<=hi` off-by-one + grid-dedup.
(E) arms draft finish=length → draft_max 16000. (F) mem_select entry-order (byte-diff). (G) mem_select
editor transitive-валидация. (H) S2 CRLF/seam-offset дрейф (36186 vs 35289). (I) стейл seam-файл.
**Отклонения от §D/пре-рег (санкционированы оркестратором/владельцем):**
1. S2-12节 → **flat 30节** (материал-баг + полная мощность); aligned = оракул-арм (не выброшен).
2. Механизм-армы = eval-риги (бэкенд не умеет), НО инъекция БАЙТ-валидирована против Go (не «верить на слово»).
3. Трапы: **детерм-первый** (entity-straddle + anaphor-head) + kimi таргет-обогащение НА границах (не seam-окна).
4. **T-tense/cat/gen/reg = undecidable a priori** (плотность прозы, не размер) — НЕ имитированы.
5. Q1 декомпозирован: Q1a-детекция ($0) + Q1b-оракул-контраст. Судьи grok+mistral (апекс-gemini инфизибл, §1.12).
6. T-ana=19 (на 1 ниже квоты). Голоса 6→10, оба порядка, LOO — как §1.6.
7. **Q4a вынесен ВНЕ капа exp15** в отдельную долинную мини-сессию (~$2, дефолт-да владельца; пин: draft =
deepseek-v4-pro, суб-арм do-nothing обязателен, судейские окна полно-evidence — HEAD_CHARS-класс НЕ наследовать).
Дизайн — Q4a-дополнение оркестратора (концерн №1 «сильный переводчик»; слой-3 контракт t/e). Причина выноса:
полн-чанк Q1b+FLOOR при капе $15 не оставляют Q4a места без урезаний — решение оркестратора/владельца (не резать).
8. Q2a/Q2c/Q3b/Q3a/Q5 — не исполнены (бюджет/приоритет; §D2-порядок, стоп-гейт легитимен).
9. **T-ent-контроль кап 10/23** (только код `--tent-control`; для бюджета судейского сэмпла) — раскрыто.
10. **Маркер §1.14г:** материал S2 — СИНТЕТИЧЕСКАЯ сцен-склейка + PD-претрейн-канон → все эмпирические выводы §7
**ПРЕДВАРИТЕЛЬНЫ** до вебновелл-среза (не претрейн). Планка чистовика — тот же пилот на непретрейн-корпусе.
11. **D30.10 (леджер-дисциплина):** ad-hoc kimi-пробы (~$0.24) шли без леджера — впредь персистить; Q1b-v1-леджер
сохранён как `judge_costs_Q1b_v1_headexcerpt.jsonl` (НЕ сброшен — провенанс артефакта); v2 пишет свежий.
#
*[испр. оркестратором при лендинге — дополнение списка девиаций по пере-верификации:]*
12. **Q1b-пере-суд отказался от пре-рег size-matched ~34k-бюджета** (§1.5/§1.14в): судились greedy-1500 flat (51 чанк) vs oracle (63); честность размера держится на парности судимых чанков (flat≈oracle 0.95×), не на пре-рег дизайне.
13. **Holm-коррекция (§1-пин) нигде не применялась** — репортились CI90 без множественной коррекции.
14. Восстановление T-ell в primary и пропуск floor-гейта в v1 — задокументированы в §7.8, входят в этот список по ссылке.
## §7.8 Раунд коррекции методологии (2-й рубеж самопроверки: верификация оркестратора → фикс-лист)
Мандат самопроверки сработал на ДВУХ рубежах: (1) сессия-1 — ревью исполнением поймал 9 багов ДО спенда (§7.7);
(2) **пост-хок верификация оркестратора при лендинге (author≠reviewer)** — 6 осей по сырью: арифметика чистая
(2541 строка, 0 расхождений), судейская механика чистая (оба порядка 69/70, эскалация-инварианты 0 нарушений),
но **3 заголовка не лендятся**. Это ровно тот случай, ради которого правило существует.
**Что чинилось (полн-чанк пере-прогон, санкция оркестратора; пре-рег НЕ пере-сочинялся):**
- **Q1b CRITICAL:** `HEAD_CHARS=1100` усекал финал → в оракуле зависимый спан за окном → 0.564 = артефакт.
Фикс: **полные чанки** (окно снято; flat≈oracle по размеру 5565/5263 → size-конфаунда нет; зависимый+якорь
всегда видны). Пере-суд: T-ana 0.564 → **+0.125** (сдвиг +0.689); dep-split схлопнулся. Заголовок отозван.
- **Пропущенный floor-гейт:** добавлен **FLOOR-блок** (A0 vs A0 полн-чанк) → шум-пол 0.126 → все маргиналы
Q3a/Q0 sub-floor (не интерпретируемы). Это пре-регнутый §1.5-гейт, которого в v1 не было.
- **Q3a/Q0 пере-анализ** (без пере-суда — артефакт Q1b-специфичен): первичка = T-ana+T-ell (не пост-хок T-ana),
обе оценки+CI, пул-fix-rate 2/6, T-ell-43 раскрыт, TOST (Q0 экв. НЕ установлена).
**Адверсариальный ревью риггов (2-й, ДО спенда, 4 линзы + синтез)** — 10 находок; реальные закрыты $0-верифицированно:
- **windowing** (I3): oracle-выбор чанка не проверял якорь (короткие пробы <8 симв. коллидируют) **anchor-guard**
(чанк с ЯКОРЕМ+зависимым; иначе skip). Дал повод перейти на полные чанки (I7: FLOOR не валидировал бы окна).
- **per-vote** (I4): cat булев через bool() («"false"»→True) + tolerant-parse ронял cat/ev → нормализация +
regex-восстановление. Плюс **per-vote персист** (сырые вердикты в результатах — катастроф-LOO аудит).
- **budget** (I8): per-block потолок суммировался в ~4×бюджета**глобальный seed** ($9.82 floor + новые леджеры,
потолок $14.5); gate-block отделён от parse-fail (`budget_truncated`-флаг — не скорить недобранную ячейку);
retry-fails в леджер (видимость).
- **rate-limit** (эмпирика FLOOR): mistral 48% retry @1.3s → интервал 2.6s.
Все скрипты пере-прогона: `eval/exp15/{judge_run,judges,exp15_llm,q1b_reanalysis,q3a_reanalysis,q0_reanalysis}.py`.
Артефакты: `judge_results_{FLOOR,Q1b}.json`, `q{1b,3a,0}_reanalysis.json`, `S2prime_big_seam_profile_1_29.json`
(швы 1229 доклассифицированы: 30节 = 15 scene-shift / 14 continuation / soft {1,7,15}), v1-провенанс
`*_v1_headexcerpt.*`. Слепой пакет владельца НЕ трогался (его эндпоинт — полные тексты, чист).

View file

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""exp15 — extract A0/A0' from the real backend DBs+logs and compute:
- per-chunk SOURCE (recovered from draft user-msg heads), DRAFT, FINAL (editor) texts
- greedy cut offsets (A0 boundaries the Q1 common-denominator boundaries) + est_out distribution
- deterministic KPI per whole-anchor (mean_spp, 1-sent-para frac, dialogue-dash frac, CJK-leak,
glossary/term consistency vs seed)
- A0<->A0' deterministic divergence (chunk count, boundary offsets, KPI deltas, term consistency,
char-level similarity) = PRELIMINARY noise floor (the judged trap floor is measured with the judges)
Chunk SOURCE recovery reuses validate_injection.recover_chunk_sources (draft user-msg head -> offset in
S2'.txt). FINAL text = the edit-stage response_text per chunk (the authoritative editor output).
"""
from __future__ import annotations
import json
import re
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from validate_injection import load_exchanges, recover_chunk_sources
from chunker import est_out, est_tokens
import regex
S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt")
_RE_HAN = regex.compile(r"\p{Han}")
_RE_CYR = regex.compile(r"[А-Яа-яЁё]")
def final_texts(db):
"""chunk_idx -> editor (final) response_text, latest attempt. S2' is one chapter."""
con = sqlite3.connect(db)
rows = con.execute(
"SELECT chunk_idx, attempt, response_text FROM checkpoints WHERE stage='edit' ORDER BY chunk_idx, attempt"
).fetchall()
con.close()
out = {}
for ck, att, txt in rows:
out[ck] = txt # later attempt overwrites
return out
def draft_texts(db):
con = sqlite3.connect(db)
rows = con.execute(
"SELECT chunk_idx, attempt, response_text FROM checkpoints WHERE stage='draft' ORDER BY chunk_idx, attempt"
).fetchall()
con.close()
out = {}
for ck, att, txt in rows:
out[ck] = txt
return out
def load_anchor(db, log):
src_text = S2.read_text(encoding="utf-8")
exchanges = load_exchanges(Path(log))
srcs = recover_chunk_sources(exchanges, src_text, whole_file=False) # {(chap,ck): source}
finals = final_texts(db)
drafts = draft_texts(db)
chunks = []
for (chap, ck) in sorted(srcs.keys()):
src = srcs[(chap, ck)]
chunks.append({"chapter": chap, "chunk": ck, "source": src,
"draft": drafts.get(ck), "final": finals.get(ck),
"src_est_out": est_out(src) if src else None,
"src_est_tokens": est_tokens(src) if src else None})
return chunks, src_text
def cut_offsets(chunks):
"""Rune offsets in S2' where each greedy cut falls (chunk starts, excluding 0)."""
offs, cur = [], 0
for c in chunks:
if c["source"] is None:
return None
if cur > 0:
offs.append(cur)
cur += len(c["source"])
return offs
# --- deterministic KPI ------------------------------------------------------------------------------
def paragraphs(text):
return [p for p in re.split(r"\n\s*\n|\n", text) if p.strip()]
def sentences_ru(text):
return [s for s in re.split(r"(?<=[.!?…])\s+", text) if s.strip()]
def kpi(final_text):
paras = paragraphs(final_text)
spp = [len(sentences_ru(p)) for p in paras] or [0]
mean_spp = sum(spp) / len(spp)
one_sent = sum(1 for x in spp if x == 1) / len(spp) if spp else 0
dash_paras = sum(1 for p in paras if p.lstrip().startswith(("", "", "-")))
dash_frac = dash_paras / len(paras) if paras else 0
# CJK leak: Han runes in the RU output NOT immediately after a Cyrillic word in parens gloss
# (owner decision #3: «Кулак Ло Ханя (羅漢拳)» balanced Han-in-parens is NOT a leak). Crude: count
# Han runes that are inside parentheses -> whitelisted; Han outside parens -> leak.
han_total = len(_RE_HAN.findall(final_text))
han_in_parens = 0
for m in re.finditer(r"[(]([^)]*)[)]", final_text):
han_in_parens += len(_RE_HAN.findall(m.group(1)))
han_leak = han_total - han_in_parens
return {"n_paras": len(paras), "mean_spp": round(mean_spp, 2), "one_sent_para_frac": round(one_sent, 3),
"dialogue_dash_frac": round(dash_frac, 3), "han_total": han_total,
"han_in_parens": han_in_parens, "han_leak_outside_parens": han_leak,
"ru_chars": len(_RE_CYR.findall(final_text))}
def term_consistency(final_text, seed_path):
"""LTCR-style: for each approved seed term present in S2', is its canonical dst (or a decl form)
present in the final? Reports coverage and any missing (a drift/omission signal, deterministic)."""
import yaml
d = yaml.safe_load(Path(seed_path).read_text(encoding="utf-8"))
src_text = S2.read_text(encoding="utf-8")
hits, miss = [], []
for t in d["terms"]:
src = t.get("src"); dst = (t.get("dst") or "").strip()
if not src or not dst or (t.get("status") or "approved") != "approved":
continue
variants = [src] + [a.get("alias") for a in (t.get("aliases") or []) if a.get("alias")]
if not any(v in src_text for v in variants):
continue
forms = set([dst] + list((t.get("decl") or {}).get("forms") or []))
# match the distinctive first content token of the dst (case-insensitive)
present = any(f.lower() in final_text.lower() for f in forms)
if not present:
# fallback: distinctive stem (first word >=4 chars)
toks = [w for w in re.findall(r"[А-Яа-яЁё]+", dst) if len(w) >= 4]
present = any(w.lower()[:5] in final_text.lower() for w in toks)
(hits if present else miss).append(dst)
return {"n_terms_in_source": len(hits) + len(miss), "present": len(hits),
"missing": miss, "coverage": round(len(hits) / (len(hits) + len(miss)), 3) if (hits or miss) else None}
def main():
seed = "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml"
AH = "/home/ubuntu/books/gu-zhenren/exp15/anchors"
out = {}
for tag in ("flat_a0", "flat_a0prime"):
chunks, _ = load_anchor(f"{AH}/{tag}.db", f"{AH}/{tag}.log.json")
whole_final = "\n\n".join(c["final"] for c in chunks if c["final"])
offs = cut_offsets(chunks)
src_outs = [c["src_est_out"] for c in chunks if c["src_est_out"]]
out[tag] = {
"n_chunks": len(chunks),
"cut_offsets": offs,
"src_est_out": {"mean": round(sum(src_outs) / len(src_outs), 1), "min": round(min(src_outs), 1),
"max": round(max(src_outs), 1), "per_chunk": [round(x, 1) for x in src_outs]},
"kpi": kpi(whole_final),
"term_consistency": term_consistency(whole_final, seed),
"final_chars": len(whole_final),
}
Path(f"{AH}/{tag}.final.txt").write_text(whole_final, encoding="utf-8")
Path(f"{AH}/{tag}.chunks.json").write_text(
json.dumps([{k: c[k] for k in ("chunk", "source", "draft", "final", "src_est_out")} for c in chunks],
ensure_ascii=False, indent=2), encoding="utf-8")
# A0<->A0' deterministic floor
a, b = out["flat_a0"], out["flat_a0prime"]
floor = {
"same_chunk_count": a["n_chunks"] == b["n_chunks"],
"same_cut_offsets": a["cut_offsets"] == b["cut_offsets"],
"n_chunks": [a["n_chunks"], b["n_chunks"]],
"kpi_delta": {k: round(a["kpi"][k] - b["kpi"][k], 3) for k in ("mean_spp", "one_sent_para_frac",
"dialogue_dash_frac", "han_leak_outside_parens") if isinstance(a["kpi"][k], (int, float))},
"term_coverage": [a["term_consistency"]["coverage"], b["term_consistency"]["coverage"]],
"final_chars": [a["final_chars"], b["final_chars"]],
}
result = {"anchors": out, "deterministic_floor_A0_vs_A0prime": floor}
Path(f"{AH}/anchors_analysis.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"a0_chunks": a["n_chunks"], "a0prime_chunks": b["n_chunks"],
"a0_cut_offsets": a["cut_offsets"], "same_boundaries": floor["same_cut_offsets"],
"a0_src_est_out_mean": a["src_est_out"]["mean"],
"floor": floor}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

265
eval/exp15/arms.py Normal file
View file

@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""exp15 — Q3 cohesion arm-runner (eval-rig; design §B3/§D2). The backend can't do carryover/lookahead
(dead knobs), so Q3 arms are eval-rigs that reuse A0's EXACT prompts + validated mem_select injection,
adding ONLY the cohesion context. To keep the contrast carryover-ONLY (no backend-vs-rig confound), the
rig generates BOTH a rig-A0 baseline (no context) and the Q3 arms; rig-A0 is validated to fall within the
backend flat-A0 <-> flat-A0' noise floor.
Modes (§B3 / §D2 Q3a/Q3a'/Q3b):
a0 : rig baseline, no cohesion context (bridge to backend flat-A0)
q3a-src : + carryover = last K=3 SOURCE sentences of prev chunk (parallelism-safe)
q3a-draft : + carryover = prev src tail prev chunk DRAFT tail (what the wave model gives)
q3a-final : + carryover = prev src tail prev chunk FINAL tail (target-side, sequential pipeline)
q3a-state : q3a-final + deterministic scene-state (active entities + their dst + gender from seed)
q3b-look : + lookahead = first M=2 SOURCE sentences of NEXT chunk (+ seam-dedup guard in scoring)
Prompt assembly mirrors render.go: system(tpl.System + "\n\n" + FewShot) [CacheBoundary] -> system(injection)
-> [system(cohesion context, read-only)] -> user(filled). Draft deepseek-v4-flash temp0.3 thinking-ON;
editor glm-5 temp0.4 thinking-OFF. Per-call gate + usage/cost ledger (D30.10); resumable (skip existing).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp15_llm as L
from mem_select import (build_bank, render_glossary_block, render_editor_constraint_block, union_sticky)
from chunker import greedy_chunks, split_source_sentences
BACKEND = Path("/home/ubuntu/projects/textmachine/backend")
S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_big_flat.txt") # 30-节 flat slice
SEED = "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml"
OUTDIR = Path("/home/ubuntu/books/gu-zhenren/exp15/arms")
# Q3 baseline + carryover source = the REAL backend flat-A0-30 (not a rig-a0). Contrast = carryover-only.
BASE_DB = "/home/ubuntu/books/gu-zhenren/exp15/anchors/flat_a0_30.db"
BUDGET_TOK = 800
K_CARRY = 3
M_LOOK = 2
BRIEF = {"book_id": "exp15", "title": "蛊真人", "source_lang": "zh", "target_lang": "ru",
"genre": "вебновелла", "audience": "взрослые читатели вебновелл", "venuti": "0.60",
"honorifics": "keep", "transcription": "palladius", "footnotes": "minimal"}
CTX_HEADER = ("КОНТЕКСТ ДЛЯ СВЯЗНОСТИ (предыдущий/следующий фрагмент, ТОЛЬКО ДЛЯ ПОНИМАНИЯ переходов "
"и разрешения местоимений/времён/рода — его НЕ переводить и НЕ включать в вывод):")
STATE_HEADER = "АКТИВНЫЕ СУЩНОСТИ СЦЕНЫ (канонический перевод и род — для согласования местоимений/времён):"
def parse_template(path):
raw = Path(path).read_text(encoding="utf-8")
parts = raw.split("---USER---", 1)
head, user = parts[0], parts[1].strip()
hp = head.split("---FEWSHOT---", 1)
system = hp[0].strip()
fewshot = hp[1].strip() if len(hp) == 2 else ""
return {"system": system, "fewshot": fewshot, "user": user}
def fill(tpl_text, **extra):
vals = dict(BRIEF)
vals.update(extra)
out, rest = [], tpl_text
while True:
i = rest.find("{{")
if i < 0:
out.append(rest); break
out.append(rest[:i]); rest = rest[i:]
e = rest.find("}}")
name = rest[2:e]
out.append(vals[name]); rest = rest[e + 2:]
return "".join(out)
def system_msg(tpl):
return tpl["system"] + ("\n\n" + tpl["fewshot"] if tpl["fewshot"] else "")
def seed_entities():
import yaml
d = yaml.safe_load(Path(SEED).read_text(encoding="utf-8"))
return d["terms"]
def scene_state_block(chunk_src, terms):
"""Deterministic scene-state: seed entities present in the chunk, with dst + gender (§B3 state slot)."""
lines = []
seen = set()
for t in terms:
src = t.get("src"); dst = (t.get("dst") or "").strip()
if not src or not dst:
continue
variants = [src] + [a.get("alias") for a in (t.get("aliases") or []) if a.get("alias")]
if not any(v in chunk_src for v in variants):
continue
if t.get("type") not in ("name", "nickname"):
continue
g = t.get("gender") or ""
gtag = {"male": "муж.", "female": "жен.", "hidden": "?", "": ""}.get(g, "")
key = dst
if key in seen:
continue
seen.add(key)
lines.append(f"- {dst}" + (f" ({gtag} род)" if gtag else ""))
return (STATE_HEADER + "\n" + "\n".join(lines)) if lines else ""
def tail_sentences(text, k):
s = split_source_sentences(text)
return "".join(s[-k:]).strip() if s else ""
def head_sentences(text, m):
s = split_source_sentences(text)
return "".join(s[:m]).strip() if s else ""
def draft_context(mode, i, chunk_srcs, terms):
"""SOURCE-side context for the DRAFT/translator (§B3-бис wave 1): src-carryover + scene-state + lookahead.
The THREE q3a modes SHARE the src-tail into the draft; they differ only in the editor's target-side."""
blocks = []
if mode.startswith("q3a") and i > 0:
blocks.append(CTX_HEADER + f"\n[предыдущий фрагмент, источник]:\n{tail_sentences(chunk_srcs[i-1], K_CARRY)}")
if mode == "q3a-state":
st = scene_state_block(chunk_srcs[i], terms)
if st:
blocks.append(st)
if mode == "q3b-look" and i < len(chunk_srcs) - 1:
blocks.append(CTX_HEADER + f"\n[следующий фрагмент, источник]:\n{head_sentences(chunk_srcs[i+1], M_LOOK)}")
return "\n\n".join(blocks)
def editor_context(mode, i, drafts, finals):
"""TARGET-side context for the EDITOR (§B3-бис wave 2): prev neighbor's draft (wave) or final
(sequential) tail. q3a-src has NONE (source-carryover baseline); q3b-look targets the draft only."""
if i == 0:
return ""
if mode == "q3a-draft" and drafts and drafts.get(i - 1):
return CTX_HEADER + f"\n[предыдущий фрагмент, черновой перевод соседа]:\n{tail_sentences(drafts[i-1], K_CARRY)}"
if mode in ("q3a-final", "q3a-state") and finals and finals.get(i - 1):
return CTX_HEADER + f"\n[предыдущий фрагмент, итоговый перевод соседа]:\n{tail_sentences(finals[i-1], K_CARRY)}"
return ""
def _reject(r):
txt = (r.get("text") or "").strip()
bad = bool(r.get("err")) or not txt or r.get("finish") not in ("stop", None)
return txt, bad
def run_q3a(sp, bank, terms, chunk_srcs, base_drafts, base_finals, caps):
"""Q3a carrying block: ONE shared src-carryover DRAFT per chunk (§B3-бис: source-side -> wave-1 draft),
then THREE editors differing ONLY in target-side neighbor context:
q3a-src : no target context (source-carryover only)
q3a-draft : flat-A0 prev-DRAFT tail (wave / draft-neighbor)
q3a-final : flat-A0 prev-FINAL tail (sequential / final-neighbor)
Sharing the draft removes the draft-noise confound between the 3 modes. Baseline = flat-A0-30 (backend).
Resume-safe (skip non-empty existing files). Neighbors come from flat-A0-30 (parallel wave, no seq dep)."""
tr = parse_template(BACKEND / "prompts/translator.md")
ed = parse_template(BACKEND / "prompts/editor.md")
tr_sys, ed_sys = fill(system_msg(tr)), fill(system_msg(ed))
shared_dir = OUTDIR / "q3a-shared-draft"
shared_dir.mkdir(parents=True, exist_ok=True)
editor_modes = ["q3a-src", "q3a-draft", "q3a-final"]
for m in editor_modes:
(OUTDIR / m).mkdir(parents=True, exist_ok=True)
active_ids = []
limit = caps.get("max_chunks") or len(chunk_srcs)
for i, src in enumerate(chunk_srcs):
if i >= limit:
break
sticky = union_sticky(active_ids, len(active_ids))
sel = bank.select(src, chapter=1, sticky_prev=sticky, budget_tokens=BUDGET_TOK)
active_ids.append(sel.active_ids)
gloss = render_glossary_block(sel.injected)
edconstr = render_editor_constraint_block(sel.injected)
dctx = draft_context("q3a-src", i, chunk_srcs, terms) # src-carryover (SHARED across the 3 modes)
# --- shared src-carryover draft (deepseek, temp 0.3), generated ONCE ---
dpath = shared_dir / f"{i:02d}.draft.txt"
if dpath.exists() and dpath.read_text(encoding="utf-8").strip():
draft = dpath.read_text(encoding="utf-8")
else:
msgs = [{"role": "system", "content": tr_sys}]
if gloss:
msgs.append({"role": "system", "content": gloss})
if dctx:
msgs.append({"role": "system", "content": dctx})
msgs.append({"role": "user", "content": fill(tr["user"], text=src)})
ok, pred, why = sp.gate("deepseek-v4-flash", sum(len(m["content"]) for m in msgs) // 2, caps["draft_max"])
if not ok:
print(f" [gate BLOCK] shared draft ch{i}: {why}"); return
r = L.call("deepseek-v4-flash", msgs, temp=0.3, max_tokens=caps["draft_max"])
sp.record({"mode": "q3a-shared", "stage": "draft", "chunk": i, "usage": r.get("usage"), "cost": r.get("cost", 0.0), "err": r.get("err"), "finish": r.get("finish")})
draft, bad = _reject(r)
if bad:
print(f" [BAD DRAFT] ch{i}: err={r.get('err')} finish={r.get('finish')}"); return
dpath.write_text(draft, encoding="utf-8")
# --- three editors on the shared draft (glm-5, temp 0.4), differing only in target context ---
for mode in editor_modes:
fpath = OUTDIR / mode / f"{i:02d}.final.txt"
if fpath.exists() and fpath.read_text(encoding="utf-8").strip():
continue
ectx = editor_context(mode, i, base_drafts, base_finals) # target-side (none/prev-draft/prev-final)
emsgs = [{"role": "system", "content": ed_sys}]
if edconstr:
emsgs.append({"role": "system", "content": edconstr})
if ectx:
emsgs.append({"role": "system", "content": ectx})
emsgs.append({"role": "user", "content": fill(ed["user"], text=src, draft=draft)})
ok, pred, why = sp.gate("glm-5", sum(len(m["content"]) for m in emsgs) // 2, caps["edit_max"])
if not ok:
print(f" [gate BLOCK] {mode} ch{i} edit: {why}"); return
r = L.call("glm-5", emsgs, temp=0.4, max_tokens=caps["edit_max"])
sp.record({"mode": mode, "stage": "edit", "chunk": i, "usage": r.get("usage"), "cost": r.get("cost", 0.0), "err": r.get("err"), "finish": r.get("finish")})
final, bad = _reject(r)
if bad:
print(f" [BAD FINAL] {mode} ch{i}: err={r.get('err')} finish={r.get('finish')}"); return
fpath.write_text(final, encoding="utf-8")
print(f" ch{i} {mode}: final {len(final)}c cost cum ${sp.total:.4f}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--modes", nargs="+", required=False,
help="a0 q3a-src q3a-draft q3a-final q3a-state q3b-look")
ap.add_argument("--per-call-cap", type=float, default=0.05)
ap.add_argument("--hard-cap", type=float, default=6.0)
ap.add_argument("--max-chunks", type=int, default=0, help="sanity-gate: only first N chunks (0=all)")
a = ap.parse_args()
bank = build_bank(SEED)
terms = seed_entities()
chunk_srcs = greedy_chunks(S2.read_text(encoding="utf-8"))
def load_base():
"""Carryover source + Q3 baseline = REAL backend flat-A0-30 (draft + editor final per chunk)."""
import sqlite3
con = sqlite3.connect(BASE_DB)
d, f = {}, {}
for ck, txt in con.execute("SELECT chunk_idx, response_text FROM checkpoints WHERE stage='draft' ORDER BY chunk_idx, attempt"):
d[ck] = txt
for ck, txt in con.execute("SELECT chunk_idx, response_text FROM checkpoints WHERE stage='edit' ORDER BY chunk_idx, attempt"):
f[ck] = txt
con.close()
return d, f
sp = L.Spender("/home/ubuntu/books/gu-zhenren/exp15/arm_costs.jsonl",
per_call_cap=a.per_call_cap, hard_cap=a.hard_cap)
caps = {"draft_max": 16000, "edit_max": 12000, "max_chunks": a.max_chunks}
base_d, base_f = load_base()
n_needed = len(chunk_srcs)
if len(base_d) < n_needed or len(base_f) < n_needed:
print(f" [ABORT] flat-A0-30 INCOMPLETE: draft {len(base_d)}/{n_needed}, final {len(base_f)}/{n_needed} "
f"— the Q3 baseline + carryover source must be fully generated (regen must finish) first.")
return
lim = f", first {a.max_chunks} (sanity-gate)" if a.max_chunks else ""
print(f"=== Q3a arms: shared src-carryover draft -> 3 editors (src/draft/final) over {len(chunk_srcs)} chunks{lim} ===")
run_q3a(sp, bank, terms, chunk_srcs, base_d, base_f, caps)
print(f"\narm spend this run: ${sp.total:.4f}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""exp15 — assemble the FINAL 30-节 trap set: merge deterministic straddles (straddle_ledger.json:
23 T-ent control + 7 T-ana) with kimi enrichment (ana_enrichment.json: T-ana/T-ell), dedup by
(type, boundary), require each to STRADDLE a flat-A0-30 greedy cut (common denominator), and map each
to the dependent chunk (i+1) so the judge can score each arm's rendering of that chunk.
Output final_traps.json: the trap set for Q1b (oracle vs flat) and Q3a (carryover) judging.
Run: python assemble_traps.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from chunker import greedy_chunks
SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
FLAT = SD / "S2prime_big_flat.txt"
OUT = SD / "final_traps.json"
def flat_boundaries(text):
ch = greedy_chunks(text)
offs, starts, pos = [], [0], 0
for i, c in enumerate(ch):
idx = text.find(c[:25], max(0, pos - 5))
if idx < 0:
idx = text.find(c[:25])
if i > 0 and idx >= 0:
offs.append(idx); starts.append(idx)
pos = idx + len(c) if idx >= 0 else pos + len(c)
return ch, offs, starts
def chunk_of_offset(starts, off):
c = 0
for i, s in enumerate(starts):
if off >= s:
c = i
return c
def straddled_boundary(text, offs, a_span, d_span):
ai = text.find(a_span[:25]); di = text.find(d_span[:25])
if ai < 0 or di < 0:
return None, None, None
lo, hi = min(ai, di), max(ai + len(a_span), di + len(d_span))
crossed = [k for k, cut in enumerate(offs) if lo < cut < hi]
if not crossed:
return None, ai, di
return crossed[0], ai, di # boundary index (0-based cut = between chunk k and k+1)
def main():
text = FLAT.read_text(encoding="utf-8")
ch, offs, starts = flat_boundaries(text)
traps = []
seen = set() # (type, boundary_cut)
def add(t, typ, control, a_span, d_span, phen, exp):
bcut, ai, di = straddled_boundary(text, offs, a_span, d_span)
if bcut is None:
return "not_straddling"
key = (typ, bcut)
if key in seen:
return "dup"
seen.add(key)
dep_chunk = chunk_of_offset(starts, di) # chunk containing the dependent (scored chunk)
anc_chunk = chunk_of_offset(starts, ai)
traps.append({"id": f"{typ}-{len(traps)+1}", "type": typ, "control": control,
"boundary_cut": bcut, "anc_chunk": anc_chunk, "dep_chunk": dep_chunk,
"anchor_span": a_span, "dependent_span": d_span,
"phenomenon": phen, "expected_ru": exp})
return "ok"
stats = {"ok": 0, "dup": 0, "not_straddling": 0}
# deterministic
det = json.load(open(SD / "straddle_ledger.json"))["traps"]
for t in det:
r = add(t, t["type"], t.get("control", False), t["anchor_span"], t["dependent_span"],
t["phenomenon"], t["expected_ru"])
stats[r] += 1
# enrichment
enr = json.load(open(SD / "ana_enrichment.json"))["straddles"]
for t in enr:
r = add(t, t.get("type", "T-ana"), False, t.get("antecedent_span", ""), t.get("dependent_span", ""),
t.get("phenomenon", ""), t.get("expected_ru", ""))
stats[r] += 1
from collections import Counter
by = Counter(t["type"] for t in traps)
prim = Counter(t["type"] for t in traps if not t["control"])
OUT.write_text(json.dumps({
"n": len(traps), "by_type": dict(by),
"quota": {"T-ana": {"have": prim.get("T-ana", 0), "need": 20, "meets": prim.get("T-ana", 0) >= 20},
"T-ent": {"have": by.get("T-ent", 0), "need": 20, "meets": by.get("T-ent", 0) >= 20, "role": "negative-control"},
"T-ell": {"have": by.get("T-ell", 0), "need": 10, "meets": by.get("T-ell", 0) >= 10, "note": "secondary; short = honest-power"}},
"undecidable_a_priori": ["T-tense", "T-cat", "T-gen", "T-reg"],
"merge_stats": stats,
"traps": traps,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"final trap set: {dict(by)} (total {len(traps)}) merge {stats}")
print(f" T-ana (primary): {prim.get('T-ana',0)} {'MEETS>=20' if prim.get('T-ana',0)>=20 else 'SHORT'}")
print(f" T-ent (control): {by.get('T-ent',0)} {'MEETS>=20' if by.get('T-ent',0)>=20 else 'SHORT'}")
print(f" T-ell (secondary): {by.get('T-ell',0)} (>=10? {by.get('T-ell',0)>=10})")
print(f" undecidable a-priori: T-tense/T-cat/T-gen/T-reg -> {OUT}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""exp15 — BLIND reading packets for the owner (§D3 human endpoint: metrics NOMINATE, blind read RATIFIES,
planка = <=2 claims/chapter). Presents the SAME opening source passage translated 3 ways flat-A0-30
(blind-greedy baseline), oracle-30 (seam-aligned), q3a-src (source-carryover arm) under SHUFFLED neutral
labels (V1/V2/V3), with a separate key. The owner reads without knowing which is which and counts claims.
Deterministic shuffle (fixed seed). Outputs to exp15/monitor/ (blind packet + key, key kept separate).
Run: python blind_packets.py
"""
from __future__ import annotations
import json
import random
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from chunker import greedy_chunks
SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
AH = SD / "anchors"
MON = SD / "monitor"
N_CHUNKS = 6 # ~first 3-4 节 of the opening (readable passage)
def db_finals(db):
con = sqlite3.connect(db)
out = {}
for ck, txt in con.execute("SELECT chunk_idx, response_text FROM checkpoints WHERE stage='edit' ORDER BY chunk_idx, attempt"):
out[ck] = txt
con.close()
return out
def main():
MON.mkdir(parents=True, exist_ok=True)
flat_src = greedy_chunks((SD / "S2prime_big_flat.txt").read_text(encoding="utf-8"))
oracle_src = greedy_chunks((SD / "S2prime_big_blanklines.txt").read_text(encoding="utf-8"))
flat_fin = db_finals(AH / "flat_a0_30.db")
oracle_fin = db_finals(AH / "oracle_30.db")
# source span covered by the first N flat chunks (used for oracle coverage matching AND now shown as
# the fidelity reference — the reader compares each version against this original for omissions/errors)
src_span = "".join(flat_src[:N_CHUNKS])
src_display = "\n\n".join(flat_src[:N_CHUNKS]) # paragraph-joined for readability
# flat + q3a-src share flat chunking; oracle differs -> take oracle chunks until it covers ~the same source len
flat_text = "\n\n".join(flat_fin[i] for i in range(N_CHUNKS) if i in flat_fin)
arm_dir = SD / "arms" / "q3a-src"
src_arm = "\n\n".join((arm_dir / f"{i:02d}.final.txt").read_text(encoding="utf-8")
for i in range(N_CHUNKS) if (arm_dir / f"{i:02d}.final.txt").exists())
# oracle: chunks whose cumulative source length ~covers src_span
cov, oi, otexts = 0, 0, []
target = len(src_span)
while oi < len(oracle_src) and cov < target:
if oi in oracle_fin:
otexts.append(oracle_fin[oi])
cov += len(oracle_src[oi]); oi += 1
oracle_text = "\n\n".join(otexts)
arms = {"flat-A0-30 (blind greedy baseline)": flat_text,
"oracle-30 (seam-aligned)": oracle_text,
"q3a-src (source-carryover)": src_arm}
labels = ["V1", "V2", "V3"]
keys = list(arms.keys())
rnd = random.Random("exp15-blind-2026-07-18")
rnd.shuffle(keys)
mapping = dict(zip(labels, keys)) # V1->arm, ...
# blind packet (no arm names) — original source at top for FIDELITY comparison, then the 3 versions
pkt = [f"# exp15 — СЛЕПОЙ ПАКЕТ для чтения (открывающий фрагмент 蛊真人, ~{N_CHUNKS} чанков)",
"",
"Три версии перевода ОДНОГО фрагмента под нейтральными метками (V1/V2/V3, перемешаны; ключ отдельно).",
"Оцени: (а) художественность/связность и (б) ВЕРНОСТЬ — сверяя каждую версию с китайским ОРИГИНАЛОМ",
"ниже (ничего не потеряно; смысл/род/референт/порядок событий переданы). Планка: ≤2 претензии на главу.",
"Примечание: V1/V2/V3 переводились с разной нарезкой на чанки, поэтому у одной из версий хвост",
"фрагмента может слегка отличаться по границе — это часть эксперимента, не ошибка перевода.",
"", "---", "",
"## ОРИГИНАЛ — китайский исходник (эталон для сверки верности)",
"",
src_display,
"", "---", ""]
for lab in labels:
pkt += [f"## {lab}", "", arms[mapping[lab]], "", "---", ""]
(MON / "blind_read_packet.md").write_text("\n".join(pkt), encoding="utf-8")
(MON / "blind_read_KEY.json").write_text(json.dumps(
{"_warning": "KEY — не показывать читающему до вынесения вердикта",
"mapping_label_to_arm": mapping, "n_chunks": N_CHUNKS,
"source_span_runes": len(src_span)}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"blind packet -> {MON}/blind_read_packet.md (key -> blind_read_KEY.json)")
print(f" labels: {mapping}")
print(f" lengths: flat {len(flat_text)}c, oracle {len(oracle_text)}c, q3a-src {len(src_arm)}c")
if __name__ == "__main__":
main()

139
eval/exp15/budget_calc.py Normal file
View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""exp15 — pre-reg budget recompute ($0), from live prices (freeze_facts.json).
Judge budget is recomputed FROM trap-N x votes x 2 orders x >=2 judges (prompt discipline: not the
other way round). Hard cap $15 (owner). Per-call predicted-cost gate lives in the run scripts; this
sizes the PACKAGE and picks a judge pair that fits. All assumptions are explicit + conservative so the
freeze can pin them. Self-review: prints per-Q breakdown; asserts total logic.
Cost model (USD/1M tokens from freeze_facts.json):
arm call = in_tok*price_in*(1-cache_frac) + in_tok*price_hit*cache_frac + out_tok*price_out
judge call= in_tok*price_in + out_tok(+reasoning)*price_out (judges: no prefix cache assumed)
Order of Qs (prompt): Q2b($0) -> Q1+Q3 (S2, carrying) -> Q2a/Q2c -> Q4 -> Q0. Budget likely covers
the carrying block; later Qs run only if budget remains (pre-reg fixes priority, stop is legitimate).
"""
from __future__ import annotations
import json
from pathlib import Path
FACTS = json.load(open("/home/ubuntu/books/gu-zhenren/exp15/freeze_facts.json"))
P = FACTS["prices"]
def price(model, field, tier=0):
v = P[model][field]
return v[tier] if isinstance(v, list) else v
def arm_call(model, in_tok, out_tok, cache_frac=0.0):
pin = price(model, "in"); phit = P[model].get("cache_hit", pin); pout = price(model, "out")
return (in_tok * pin * (1 - cache_frac) + in_tok * phit * cache_frac + out_tok * pout) / 1e6
def judge_call(model, in_tok, out_tok):
# reasoning/thinking billed as output for grok/gemini/gpt/kimi -> fold into out_tok by caller
return (in_tok * price(model, "in") + out_tok * price(model, "out")) / 1e6
# ---------------- ARM (generation) sizing ----------------
# Conservative per-chunk token estimates (zh->ru; injection small; reflow-invariant).
DRAFT_IN, DRAFT_OUT = 2600, 3200 # deepseek-v4-flash: src+small injection in; ru + thinking (billed out)
EDIT_IN, EDIT_OUT = 5200, 2800 # glm-5: src+draft+injection in; ru edits out (thinking off for editor)
CACHE_FRAC = 0.55 # static prefix (system+glossary) cached on sequential chunks (verified auto-cache)
CHUNKS_PER_CH = 2.2 # ~2 greedy-1500 chunks per webnovel chapter (S2 est; S1 ~1/节)
S2_CH = 10 # carrying-slice chapters (8-12)
def arm_cost_per_chapter(draft="deepseek-v4-flash", editor="glm-5"):
per_chunk = arm_call(draft, DRAFT_IN, DRAFT_OUT, CACHE_FRAC) + arm_call(editor, EDIT_IN, EDIT_OUT, CACHE_FRAC)
return per_chunk * CHUNKS_PER_CH
# Arms that GENERATE (Q0 do-nothing = $0 export; Q3 modes share one draft, differ in editor-context):
ARMS_GEN = {
"A0": 1, "A0'": 1, # double anchor
"Q1-logical": 1,
"Q2a-editGLava": 1, "Q2c-wideread": 1,
"Q3a-src": 1, "Q3a-draft": 1, "Q3a-final": 1, "Q3a'-state": 1, "Q3b-lookahead": 1,
"Q4a-strongdraft(v4pro)": 1, "Q4b-diff": 1, "Q4c-reflow": 1,
}
# ---------------- JUDGE sizing (recompute from trap-N) ----------------
# Trap set (common denominator, defined in A0). Minimums per prompt D1.
TRAP_N = {"T-ana": 20, "T-ent": 20, "T-tense": 20, "T-cat": 10, "T-ell": 10,
"T-gen": 10, "T-reg": 10, "T-inv": 10}
# Which Qs judge which trap types, and #arm-contrasts each Q compares against A0 (pairwise A0-vs-arm):
Q_JUDGE = {
"Q1": (["T-tense", "T-ana"], 1), # A0 vs logical
"Q3a": (["T-ana", "T-ell"], 3), # A0 vs {src,draft,final}
"Q3a'": (["T-gen", "T-reg"], 1), # A0 vs +state
"Q3b": (["T-cat"], 1), # A0 vs +lookahead
# later Qs (run only if budget remains):
"Q2c": (["T-ana", "T-tense"], 1),
"Q4a": (["T-inv"], 1),
"Q4b": (["T-inv"], 1),
}
CARRYING = {"Q1", "Q3a", "Q3a'", "Q3b"}
VOTES_BASE = 6 # 3 per order x 2 orders
ESCALATE_FRAC = 0.35 # fraction of cells that split -> +4 votes to 10
JUDGES = ["grok-4.3", "mistral-large-latest"] # 2 cross-family; family-clean vs DeepSeek/GLM arms + kimi miner
JUDGE_IN, JUDGE_OUT = {"grok-4.3": 1500, "mistral-large-latest": 1500}, {"grok-4.3": 900, "mistral-large-latest": 500}
# grok reasoning ON (additive) -> larger out; mistral no reasoning surcharge.
def votes_expected():
return VOTES_BASE + ESCALATE_FRAC * 4 # ~7.4
def judge_cost_for(qset, judges, jin, jout):
total = 0.0; breakdown = {}
for q, (types, contrasts) in Q_JUDGE.items():
if q not in qset:
continue
traps = sum(TRAP_N[t] for t in types)
cells = traps * contrasts # pairwise (A0-vs-arm) trap cells
calls_per_judge = cells * votes_expected()
c = 0.0
for j in judges:
c += calls_per_judge * judge_call(j, jin[j], jout[j])
breakdown[q] = (traps, contrasts, cells, round(c, 2))
total += c
return total, breakdown
# ---------------- MINER (trap mining, paid) ----------------
# kimi-k2.6 mines candidate cohesion phenomena over the S2 source; verbose reasoning billed as output.
MINER = "kimi-k2.6"
MINER_CALLS = S2_CH # ~1 mining pass per chapter
MINER_IN, MINER_OUT = 3000, 6000 # chapter source in; candidate list + verbose reasoning out
miner_cost = MINER_CALLS * judge_call(MINER, MINER_IN, MINER_OUT)
def main():
per_ch = arm_cost_per_chapter()
arm_total = sum(ARMS_GEN.values()) * S2_CH * per_ch
carrying_arm = 6 * S2_CH * per_ch # A0,A0',Q1,Q3a(counts as 1 draft+3 editor-ctx ~ approx 3),Q3a',Q3b ~6 arm-equivs
j_all, jb = judge_cost_for(set(Q_JUDGE), JUDGES, JUDGE_IN, JUDGE_OUT)
j_carry, jbc = judge_cost_for(CARRYING, JUDGES, JUDGE_IN, JUDGE_OUT)
print("=== exp15 budget recompute (live prices, 2026-07-17) ===")
print(f"arm cost / chapter (draft deepseek-v4-flash + editor glm-5, cache_frac={CACHE_FRAC}): ${per_ch:.4f}")
print(f" draft/chunk ${arm_call('deepseek-v4-flash',DRAFT_IN,DRAFT_OUT,CACHE_FRAC):.4f} editor/chunk ${arm_call('glm-5',EDIT_IN,EDIT_OUT,CACHE_FRAC):.4f} chunks/ch {CHUNKS_PER_CH}")
print(f"trap miner ({MINER}, {MINER_CALLS} calls): ${miner_cost:.2f}")
print(f"expected votes/cell: {votes_expected():.1f} judges: {JUDGES}")
print("\n-- judge cost per Q (traps, contrasts, cells, $) --")
for q, v in jb.items():
tag = "CARRY" if q in CARRYING else "later"
print(f" [{tag}] {q}: traps={v[0]} contrasts={v[1]} cells={v[2]} -> ${v[3]}")
print(f"\nCARRYING block: arms ${carrying_arm:.2f} + judges ${j_carry:.2f} + miner ${miner_cost:.2f} = ${carrying_arm+j_carry+miner_cost:.2f}")
print(f"FULL package (all listed Qs): arms ${arm_total:.2f} + judges ${j_all:.2f} + miner ${miner_cost:.2f} = ${arm_total+j_all+miner_cost:.2f}")
print(f"HARD CAP: $15.00")
carry_total = carrying_arm + j_carry + miner_cost
print(f"\nVERDICT: carrying block ${carry_total:.2f} {'FITS' if carry_total<=15 else 'EXCEEDS'} $15; "
f"later Qs (Q2c/Q4) run only if budget remains after carrying block + per-call gate.")
# sensitivity: apex secondary judge (gemini mandatory thinking billed as output)
apex_judges = ["grok-4.3", "gemini-3.1-pro-preview"]
apex_in = {**JUDGE_IN, "gemini-3.1-pro-preview": 1500}
apex_out = {**JUDGE_OUT, "gemini-3.1-pro-preview": 2500}
jg, _ = judge_cost_for(CARRYING, apex_judges, apex_in, apex_out)
print(f"\n[sensitivity] if secondary judge = gemini-3.1-pro (apex, thinking billed out): carrying judges ${jg:.2f} "
f"-> block ${carrying_arm+jg+miner_cost:.2f} ({'FITS' if carrying_arm+jg+miner_cost<=15 else 'EXCEEDS'} $15)")
if __name__ == "__main__":
main()

113
eval/exp15/build_s2prime.py Normal file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""exp15 — S2' synthetic scene-marked slice via guzhenren chapter concatenation (owner decision 2026-07-17).
S2' = concatenate the FIRST N consecutive 节 of 蛊真人 (opening arc), STRIP the 第X节 headers at the seams,
so each seam is an IMPLICIT scene boundary (no explicit marker). Every seam is recorded as a ground-truth
boundary (rune-offset in the concatenated text + the two adjacent ids). The seam previews (tail of 节_i +
head of 节_{i+1}) are emitted so each seam can be MANUALLY classified scene-shift vs continuation-cliffhanger
(§1.14b): continuation seams are negative-boundaries ("must NOT cut here"), separate strata for Q1.
Deterministic, $0, pure function of the book bytes. Outputs OUTSIDE git.
Run: eval/.venv/bin/python eval/exp15/build_s2prime.py [N] # default N=12 节
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
BOOK = Path("/home/ubuntu/books/gu-zhenren/guzhenren-utf8.txt")
OUT_DIR = Path("/home/ubuntu/books/gu-zhenren/exp15")
OUT_TXT = OUT_DIR / "S2prime.txt"
OUT_SEAMS = OUT_DIR / "S2prime_seams.json"
OUT_PREVIEW = OUT_DIR / "S2prime_seam_previews.md"
SEC_RE = re.compile(r"^第[0-9一二三四五六七八九十百千零两]{1,10}[节節][: ]?.*$", re.M)
PREVIEW_TAIL, PREVIEW_HEAD = 220, 220
def load_text() -> str:
raw = BOOK.read_bytes()
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw.decode("gb18030", errors="replace")
def sections(text: str):
"""Return list of (header_line, body_text) for each 节 in file order (body = up to next 节)."""
marks = list(SEC_RE.finditer(text))
out = []
for k, m in enumerate(marks):
header = m.group(0).strip()
body_start = m.end()
body_end = marks[k + 1].start() if k + 1 < len(marks) else len(text)
body = text[body_start:body_end].strip("\n")
out.append((k + 1, header, body)) # sec index n = k+1 (running, per gu_corpus_build convention)
return out
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 12
OUT_DIR.mkdir(parents=True, exist_ok=True)
text = load_text()
secs = sections(text)
if len(secs) < n:
print(f"only {len(secs)} 节 found; using all", file=sys.stderr)
n = len(secs)
chosen = secs[:n]
# concatenate bodies (headers STRIPPED at seams); single "\n" join = the implicit seam
parts, seams, cursor = [], [], 0
JOIN = "\n"
for i, (idx, header, body) in enumerate(chosen):
if i > 0:
cursor += len(JOIN) # account the join separator (rune count)
seam_offset = cursor # rune offset where this section's body begins
if i > 0:
seams.append({
"seam_id": i,
"rune_offset": seam_offset, # boundary between prev body and this body
"prev_sec": chosen[i - 1][0],
"next_sec": idx,
"prev_header": chosen[i - 1][1],
"next_header": header,
"class": "UNCLASSIFIED", # -> scene-shift | continuation (manual, §1.14b)
"rationale": "",
})
parts.append(body)
cursor += len(body)
joined = JOIN.join(parts)
OUT_TXT.write_text(joined, encoding="utf-8")
OUT_SEAMS.write_text(json.dumps({
"book": "guzhenren", "arc": "opening", "n_sections": n,
"section_ids": [c[0] for c in chosen],
"total_runes": len(joined),
"headers_stripped_at_seams": True,
"seam_marker": "implicit (single \\n; no 第X节 header)",
"seams": seams,
}, ensure_ascii=False, indent=2), encoding="utf-8")
# seam-context previews for MANUAL classification (read tail_i + head_{i+1})
pv = [f"# S2' seam previews — classify each: scene-shift | continuation-cliffhanger (§1.14b)",
f"# {n} 节 concatenated, headers stripped; {len(seams)} seams; total {len(joined):,} runes", ""]
for s in seams:
off = s["rune_offset"]
tail = joined[max(0, off - len(JOIN) - PREVIEW_TAIL):off - len(JOIN)]
head = joined[off:off + PREVIEW_HEAD]
pv += [f"## seam {s['seam_id']} (节{s['prev_sec']} → 节{s['next_sec']}, offset {off})",
f"**{s['prev_header']}** → **{s['next_header']}**",
f"…tail of 节{s['prev_sec']}: …{tail}",
f"head of 节{s['next_sec']}: {head}", ""]
OUT_PREVIEW.write_text("\n".join(pv), encoding="utf-8")
print(f"S2' built: {n} 节, {len(seams)} seams, {len(joined):,} runes -> {OUT_TXT}")
print(f"section ids: {[c[0] for c in chosen]}")
print(f"seams (offset, 节i->节j): " + ", ".join(f"{s['rune_offset']}({s['prev_sec']}->{s['next_sec']})" for s in seams))
print(f"previews for manual classification -> {OUT_PREVIEW}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""exp15 — build the EXPANDED S2' (full-power, owner decision 2026-07-18) from the first N 节 of 蛊真人.
Produces TWO aligned versions of the same content (only the seam separator differs), + seam ledger:
S2prime_big_flat.txt seams = single '\\n' -> greedy A0 is BLIND (blind-budget cuts)
S2prime_big_blanklines.txt seams = '\\n\\n' -> greedy ORACLE cuts AT every seam (paragraph flush)
Within a the body is normalized to single '\\n' (collapse internal blank lines) so the ONLY '\\n\\n'
are at seams that is what makes the oracle cut exactly at scene boundaries.
Labels for seams are NOT hand-classified here (too many); Q1b uses the oracle's cut-at-all-seams behavior
and the covariate is per-seam. Deterministic straddle-trap finding happens in straddle_traps.py.
Run: python build_s2prime_big.py [N] # default N=45
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import build_s2prime as B
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15")
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 45
secs = B.sections(B.load_text())[:n]
bodies = []
for idx, header, body in secs:
b = body.replace("\r\n", "\n").replace("\r", "\n")
b = re.sub(r"\n{2,}", "\n", b).strip("\n") # single-\n within 节
bodies.append((idx, b))
flat = "\n".join(b for _, b in bodies)
blank = "\n\n".join(b for _, b in bodies)
# seam offsets in each version (start of body i, i>0)
def seam_offsets(sep):
offs, cur = [], 0
for i, (idx, b) in enumerate(bodies):
if i > 0:
cur += len(sep)
if i > 0:
offs.append({"seam_id": i, "prev_sec": bodies[i - 1][0], "next_sec": idx, "rune_offset": cur})
cur += len(b)
return offs
(OUT / "S2prime_big_flat.txt").write_text(flat, encoding="utf-8")
(OUT / "S2prime_big_blanklines.txt").write_text(blank, encoding="utf-8")
(OUT / "S2prime_big_seams_flat.json").write_text(json.dumps(
{"version": "flat", "n_sections": n, "section_ids": [b[0] for b in bodies],
"total_runes": len(flat), "seams": seam_offsets("\n")}, ensure_ascii=False, indent=2), encoding="utf-8")
(OUT / "S2prime_big_seams_blank.json").write_text(json.dumps(
{"version": "blanklines", "n_sections": n, "section_ids": [b[0] for b in bodies],
"total_runes": len(blank), "seams": seam_offsets("\n\n")}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"S2'-big: {n}节 flat={len(flat)} runes blank={len(blank)} runes seams={n-1}")
# verify seam alignment
fs = seam_offsets("\n"); bs = seam_offsets("\n\n")
ok = all(flat[s["rune_offset"]:s["rune_offset"]+3] == bodies[i+1][1][:3] for i, s in enumerate(fs))
okb = all(blank[s["rune_offset"]:s["rune_offset"]+3] == bodies[i+1][1][:3] for i, s in enumerate(bs))
print(f"seam alignment: flat={ok} blank={okb}")
if __name__ == "__main__":
main()

441
eval/exp15/chunker.py Normal file
View file

@ -0,0 +1,441 @@
#!/usr/bin/env python3
"""exp15 — chunkers for Q1: a faithful zh source-sentence splitter + the §B1.5 LOGICAL DP chunker.
Q1 contrast (design §D2): A0 (greedy-1500, from the real backend) vs a LOGICAL-boundary arm sized to
A0's ACTUAL est_out distribution (±10% mean). The ONLY variable is cut ALIGNMENT (greedy is blind to
scene structure; logical aligns cuts to scene-shift boundaries and avoids mid-scene/dialogue cuts).
Size is held equal so Q1 does not confound boundary class with chunk size (that is Q2's domain).
Ports (backend/internal/pipeline/chunker.go):
splitSourceSentences / isOpenQuote / isCloseQuote / isSourceTerminator / isCJKTerminator /
isClosingBracket / isASCIISpace (chunker.go:196-357) quote-depth-aware, lossless tiling.
Token math:
est_tokens = cjk + other//3 (EstimateTokens units the greedy 1500 budget space)
est_out = 1.1978*cjk + 0.3852*other (fertility_calib.json glm-4.6 ru output tokens; §B1.2)
The logical chunker implements §B1.4 boundary classes (B0 chapter/separator, B1 scene-shift lexicon,
B2 TextTiling cohesion valley, B3 paragraph, B4 sentence) and §B1.5 DP packing (Knuth-Plass shortest
path over candidate cuts, INTEGER-scaled costs, asymmetric size penalty). It does NOT peek at the
ground-truth seam offsets scene-shifts are DISCOVERED (that is the Q1 test).
Self-review by execution: `python chunker.py --selftest` checks tiling losslessness + a size sweep.
"""
from __future__ import annotations
import math
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
import regex
_RE_CJK = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]")
TARGET_CHUNK_TOKENS = 1500 # chunker.go:47 const
F_CJK, F_OTHER = 1.1978, 0.3852 # fertility_calib.json (glm-4.6 ru out tokens from src char classes)
# --- char-class token estimators -------------------------------------------------------------------
def _classes(text: str):
cjk = other = 0
for ch in text:
if _RE_CJK.match(ch):
cjk += 1
elif ch.isspace():
pass
else:
other += 1
return cjk, other
def est_tokens(text: str) -> int:
"""EstimateTokens units (source side): cjk + other//3, floored at 16 (chunker.go estTokensFrom)."""
cjk, other = _classes(text)
v = cjk + other // 3
return max(v, 16)
def est_out(text: str) -> float:
"""Predicted glm-4.6 ru OUTPUT tokens for a source slice (fertility model; §B1.2)."""
cjk, other = _classes(text)
return F_CJK * cjk + F_OTHER * other
# --- GREEDY chunker port (chunker.go:82-194 + render.go:285 NormalizeSource) -----------------------
def normalize_source(s: str) -> str:
"""render.go:285 — BOM strip, CRLF/CR->LF, valid-UTF8 (Python strs already are), NFC, TrimSpace."""
s = s.lstrip("")
s = s.replace("\r\n", "\n").replace("\r", "\n")
s = unicodedata.normalize("NFC", s)
return s.strip() # Go strings.TrimSpace strips Unicode whitespace incl. U+3000 (== Python str.strip())
def _class_counts(s: str):
cjk = other = 0
for ch in s:
if _RE_CJK.match(ch):
cjk += 1
elif ch.isspace():
pass
else:
other += 1
return cjk, other
def _est_tokens_from(cjk, other):
return max(cjk + other // 3, 16) # chunker.go estTokensFrom (integer // 3, floor 16)
def split_paragraphs(chapter: str):
return [p.strip() for p in chapter.split("\n\n") if p.strip()]
def _pack_sentences(paragraph: str):
segs = split_source_sentences(paragraph)
chunks, buf, bc, bo = [], [], 0, 0
def flush():
nonlocal buf, bc, bo
if buf:
chunks.append("".join(buf)); buf, bc, bo = [], 0, 0
for s in segs:
sc, so = _class_counts(s)
if buf and _est_tokens_from(bc + sc, bo + so) > TARGET_CHUNK_TOKENS:
flush()
buf.append(s); bc, bo = bc + sc, bo + so
flush()
return chunks
def greedy_chunks(raw_text: str):
"""Port of chunker.go SplitChunks for ONE chapter (S2' has no chapter markers). Returns chunk texts."""
chapter = normalize_source(raw_text)
paras = split_paragraphs(chapter)
chunks, buf, bc, bo = [], [], 0, 0
def flush():
nonlocal buf, bc, bo
if buf:
text = "\n\n".join(buf).strip()
if text:
chunks.append(text)
buf, bc, bo = [], 0, 0
for p in paras:
pc, po = _class_counts(p)
if _est_tokens_from(pc, po) > TARGET_CHUNK_TOKENS:
flush()
for sub in _pack_sentences(p):
t = sub.strip()
if t:
chunks.append(t)
continue
if buf and _est_tokens_from(bc + pc, bo + po) > TARGET_CHUNK_TOKENS:
flush()
buf.append(p); bc, bo = bc + pc, bo + po
flush()
return chunks
# --- source sentence splitter (port of chunker.go:196-357) -----------------------------------------
_OPEN_Q = set("「『(【《〈[{“‘")
_CLOSE_Q = set("」』)】》〉]}”’")
_TERM = set("。!?.!?…")
_CJK_TERM = set("。!?")
_CLOSE_BRK = set("」』】))》〉]]}\"'”’")
_ASCII_SPACE = set(" \t\n\r\f\v ")
def split_source_sentences(paragraph: str):
runes = list(paragraph)
segs = []
start = i = depth = 0
n = len(runes)
while i < n:
r = runes[i]
if r in _OPEN_Q:
depth += 1
i += 1
continue
if r in _CLOSE_Q:
if depth > 0:
depth -= 1
i += 1
continue
if r not in _TERM:
i += 1
continue
j = i
cjk = False
while j < n and runes[j] in _TERM:
if runes[j] in _CJK_TERM:
cjk = True
j += 1
depth_here = depth
e = j
while e < n and runes[e] in _CLOSE_BRK:
if runes[e] in _CLOSE_Q and depth > 0:
depth -= 1
e += 1
if cjk:
boundary = depth_here == 0
else:
boundary = depth_here == 0 and (e >= n or runes[e] in _ASCII_SPACE)
# abbrev guard omitted for zh source (CJK terminators dominate; en not in S2')
if not boundary:
i = e
continue
w = e
while w < n and runes[w] in _ASCII_SPACE:
w += 1
segs.append("".join(runes[start:w]))
start = i = w
if start < n:
segs.append("".join(runes[start:]))
return segs
# --- units: split the text into atomic sentence-ish spans that TILE it -----------------------------
def units_from_text(text: str):
"""Atomic units for the DP = source sentences. Preserves whether each unit ENDS a paragraph
(\\n\\n) or a line (\\n) for boundary classification. Tiling: ''.join(u.text) == text."""
out = []
# split into paragraph blocks on blank lines, remember the separator that followed each block
# (so paragraph-end vs line-end vs sentence-end is recoverable). We tile exactly by re-attaching
# the separators to the preceding unit.
for seg, sep in _iter_blocks(text):
sents = split_source_sentences(seg)
for k, s in enumerate(sents):
ends_para = (k == len(sents) - 1) and sep == "\n\n"
ends_line = (k == len(sents) - 1) and sep == "\n"
out.append((s, ends_para, ends_line))
# re-attach the block separator to the last unit so tiling is exact
if out and sep:
s, ep, el = out[-1]
out[-1] = (s + sep, ep, el)
return out
def _iter_blocks(text: str):
"""Yield (block_text, separator) where separator is '\\n\\n' | '\\n' | ''. Tiles the input."""
i = 0
n = len(text)
buf_start = 0
while i < n:
if text[i] == "\n":
if i + 1 < n and text[i + 1] == "\n":
yield text[buf_start:i], "\n\n"
i += 2
buf_start = i
continue
else:
yield text[buf_start:i], "\n"
i += 1
buf_start = i
continue
i += 1
if buf_start < n:
yield text[buf_start:], ""
# --- boundary classification (§B1.4) ---------------------------------------------------------------
# B1 scene-shift lexicon: STRONG time/place/POV shift markers in the first chars of the NEXT unit.
# Curated generic zh markers (NOT the S2' seam text — scene-shifts must be DISCOVERED). Kept to strong
# temporal/spatial jumps + narrative shift particles; weak mid-scene connectors (顿时/忽然) excluded.
_B1_MARKERS = [
# temporal jumps
"次日", "翌日", "第二天", "第三天", "隔日", "数日后", "数天后", "几天后", "三天后", "三天三夜",
"一个星期", "一周", "几个月", "半年", "一年后", "三年后", "五年后", "多年后",
"当天", "这一天", "那一天", "这一日", "翌朝", "清晨", "早晨", "拂晓", "五更", "天亮", "天明",
"傍晚", "黄昏", "夜里", "半夜", "深夜", "入夜", "朝阳", "红日", "夕阳", "日落", "日出", "正午",
"转眼", "转眼间", "很快", "不久", "不多时", "没过多久", "光阴荏苒", "时光飞逝",
# narrative-shift particles / POV or place switch
"却说", "再说", "话说", "单说", "且说", "与此同时", "另一边", "另一方", "一方", "此时此刻",
"回到", "镜头", "视线",
]
def _b1_scene_shift(next_unit_text: str) -> bool:
head = next_unit_text.lstrip("  \n\t")[:12]
return any(m in head for m in _B1_MARKERS)
def _dialogue_boundary_penalty(prev_unit: str, next_unit: str) -> int:
"""Penalize cutting between a reply and its attribution or inside a dialogue exchange (§A.6):
if prev ends inside/with a quote and next continues speech attribution, cutting is bad."""
p = prev_unit.rstrip("  \n\t")
nx = next_unit.lstrip("  \n\t")
pen = 0
# prev ends with a closing quote AND next starts with attribution-ish (说道/道/问/答 ...) -> keep together
if p and p[-1] in _CLOSE_Q and nx[:2] in ("说道", "", "", "问道", "答道", "喝道", "笑道"):
pen += 1
# next starts a quote while prev did not close one cleanly (mid-exchange) — mild
if nx[:1] in _OPEN_Q and p and p[-1] in _OPEN_Q:
pen += 1
return pen
# --- TextTiling cohesion valley (B2) ---------------------------------------------------------------
def _char_bigrams(text: str):
t = [c for c in text if not c.isspace()]
return [t[k] + t[k + 1] for k in range(len(t) - 1)]
def _cosine(a: dict, b: dict) -> float:
if not a or not b:
return 0.0
dot = sum(v * b.get(k, 0) for k, v in a.items())
na = math.sqrt(sum(v * v for v in a.values()))
nb = math.sqrt(sum(v * v for v in b.values()))
return dot / (na * nb) if na and nb else 0.0
def _bag(units, lo, hi):
d = {}
for u in units[lo:hi]:
for bg in _char_bigrams(u[0]):
d[bg] = d.get(bg, 0) + 1
return d
def texttiling_depths(units, w=4):
"""Depth score of the lexical-cohesion valley at each inter-unit gap (higher = stronger boundary).
Sliding window of w units either side; cosine of char-bigram bags; depth = local valley prominence."""
n = len(units)
gaps = n - 1
sims = [0.0] * gaps
for g in range(gaps):
left = _bag(units, max(0, g + 1 - w), g + 1)
right = _bag(units, g + 1, min(n, g + 1 + w))
sims[g] = _cosine(left, right)
# depth score: for each gap, (peak_left - sim) + (peak_right - sim)
depths = [0.0] * gaps
for g in range(gaps):
lpk = sims[g]
k = g
while k > 0 and sims[k - 1] >= sims[k]:
k -= 1
lpk = max(lpk, sims[k])
rpk = sims[g]
k = g
while k < gaps - 1 and sims[k + 1] >= sims[k]:
k += 1
rpk = max(rpk, sims[k])
depths[g] = (lpk - sims[g]) + (rpk - sims[g])
return depths
# --- §B1.5 DP logical chunker ----------------------------------------------------------------------
@dataclass
class ChunkPlan:
chunks: list # list of (text, est_out, right_boundary_class)
cut_offsets: list # rune offsets in the ORIGINAL text where a cut falls (chunk starts, excl 0)
# boundary-class penalty (§B1.5): B0=0 < B1 < B2 < B3 < B4 (prefer cutting at strong boundaries)
_CLASS_PEN = {"B0": 0, "B1": 1, "B2": 3, "B3": 6, "B4": 10}
def logical_chunks(text: str, target_out: float, hard_max_out: float, min_out: float = None,
w_size=3, w_bound=60, w_dialog=200, b2_z=0.8,
corridor_lo=0.62, corridor_hi=1.12) -> ChunkPlan:
"""§B1.5 DP: minimize Σ [w_size·size_pen + w_bound·class_pen(right boundary) + w_dialog·dialogue].
Cuts only at unit (sentence) boundaries never mid-sentence (B5 forbidden). Costs INTEGER-scaled.
target_out/hard_max_out = A0's actual est_out mean/max (size-match)."""
units = units_from_text(text)
n = len(units)
if min_out is None:
min_out = 0.45 * target_out
# per-unit est_out and cumulative rune offsets
out_of = [est_out(u[0]) for u in units]
cum_out = [0.0] * (n + 1)
cum_rune = [0] * (n + 1)
for k in range(n):
cum_out[k + 1] = cum_out[k] + out_of[k]
cum_rune[k + 1] = cum_rune[k] + len(units[k][0])
# boundary class at each inter-unit gap g (cut AFTER unit g, i.e. between unit g and g+1)
depths = texttiling_depths(units)
if depths:
mu = sum(depths) / len(depths)
sd = math.sqrt(sum((d - mu) ** 2 for d in depths) / len(depths)) or 1e-9
gap_class = []
gap_dialog = []
for g in range(n - 1):
cls = "B4" # default: sentence boundary
if units[g][2] or units[g][1]:
cls = "B3" # ends a line/paragraph
if depths and (depths[g] - mu) / sd >= b2_z:
cls = "B2" # TextTiling cohesion valley
if _b1_scene_shift(units[g + 1][0]):
cls = "B1" # scene-shift lexicon in next unit
gap_class.append(cls)
gap_dialog.append(_dialogue_boundary_penalty(units[g][0], units[g + 1][0]))
lo_band, hi_band = corridor_lo * target_out, corridor_hi * target_out
def size_pen(a, b):
"""Corridor size penalty (§B1.5 intent: size defines a BUDGET CORRIDOR; boundary class decides
WITHIN it). Flat (0) inside [lo_band, hi_band]; steep outside so chunks stay near target size."""
o = cum_out[b] - cum_out[a]
if o > hard_max_out:
return 100000 + int(40 * (o - hard_max_out)) # hard ceiling — never exceed
if o > hi_band:
return int(30 * (o - hi_band)) # over corridor
if o < lo_band:
return int(30 * (lo_band - o)) # under corridor
return 0 # inside corridor: size is free, boundary decides
INF = float("inf")
# dp[b] = min cost to segment units[0:b]; back[b] = previous cut index a
dp = [INF] * (n + 1)
back = [0] * (n + 1)
dp[0] = 0
for b in range(1, n + 1):
# try all chunk starts a < b (bounded to a window where size is plausible)
a = b - 1
while a >= 0:
o = cum_out[b] - cum_out[a]
if o > hard_max_out * 1.6 and a < b - 1:
break # window: chunks far over hardMax are never optimal
cost = dp[a] + w_size * size_pen(a, b)
if b < n: # boundary penalty on the RIGHT cut (gap b-1)
cost += w_bound * _CLASS_PEN[gap_class[b - 1]] + w_dialog * gap_dialog[b - 1]
if cost < dp[b]:
dp[b] = cost
back[b] = a
a -= 1
# reconstruct
cuts = []
b = n
while b > 0:
a = back[b]
cuts.append((a, b))
b = a
cuts.reverse()
chunks = []
cut_offsets = []
for (a, b) in cuts:
txt = "".join(units[k][0] for k in range(a, b))
rcls = gap_class[b - 1] if b < n else "B0"
chunks.append((txt, est_out(txt), rcls))
if a > 0:
cut_offsets.append(cum_rune[a])
return ChunkPlan(chunks=chunks, cut_offsets=cut_offsets)
if __name__ == "__main__":
if "--selftest" in sys.argv:
text = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt").read_text(encoding="utf-8")
units = units_from_text(text)
tiled = "".join(u[0] for u in units)
print("units:", len(units), "tiling lossless:", tiled == text)
assert tiled == text, "UNIT TILING NOT LOSSLESS"
total_out = est_out(text)
print(f"S2' total est_out={total_out:.0f} est_tokens={est_tokens(text)}")
# size sweep: how many logical chunks at a few targets
for tgt in (1500, 1797, 2200):
plan = logical_chunks(text, target_out=tgt, hard_max_out=tgt * 1.25)
outs = [c[1] for c in plan.chunks]
classes = [c[2] for c in plan.chunks[:-1]] # right-boundary classes (excl last)
from collections import Counter
print(f" target_out={tgt}: {len(plan.chunks)} chunks "
f"mean_out={sum(outs)/len(outs):.0f} min={min(outs):.0f} max={max(outs):.0f} "
f"cut-classes={dict(Counter(classes))}")
print("chunker selftest OK")

100
eval/exp15/enrich_ana.py Normal file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""exp15 — kimi enrichment of T-ana/T-ell straddles AT flat-A0-30 boundaries (guard 1: kimi ONLY as
targeted enrichment on the boundaries, deterministic-first). For each greedy boundary we give kimi the
TAIL of chunk_i and the HEAD of chunk_{i+1} and ask ONLY: is there an anaphora (esp. zh zero-subject) or
ellipsis in the HEAD whose antecedent/elided element is in the TAIL (i.e. it straddles THIS cut)? Batched
(several boundaries per call) to bound kimi cost. Family-isolated miner (Moonshot), temp 1, 24k + retry.
Run: python enrich_ana.py --run
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp15_llm as L
from chunker import greedy_chunks, split_source_sentences
SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
FLAT = SD / "S2prime_big_flat.txt"
OUT = SD / "ana_enrichment.json"
LEDGER = SD / "enrich_costs.jsonl"
MINER = "kimi-k2.6"
BATCH = 6 # boundaries per kimi call
TAIL_N, HEAD_N = 3, 3
SYS = (
"Ты — эксперт по китайско-русскому переводу и связности. Тебе дают несколько ГРАНИЦ нарезки текста "
"(ранобэ 蛊真人): для каждой — ХВОСТ предыдущего чанка и ГОЛОВА следующего. Для КАЖДОЙ границы найди, "
"есть ли в ГОЛОВЕ анафора (особенно китайское НУЛЕВОЕ подлежащее — предложение без явного субъекта, "
"субъект тянется из ХВОСТА) ИЛИ эллипсис, чей антецедент/опущенный элемент — в ХВОСТЕ (т.е. зависимость "
"ПЕРЕСЕКАЕТ именно эту границу). Если да — верни объект; если нет — пропусти границу.\n"
'Формат (JSON-массив): [{"boundary_idx":ЦЕЛОЕ,"type":"T-ana|T-ell",'
'"antecedent_span":"цитата из ХВОСТА","dependent_span":"цитата из ГОЛОВЫ",'
'"phenomenon":"какая зависимость и почему разрез её ломает",'
'"expected_ru":"что должен сделать верный перевод (референт/род/восстановленное сказуемое)"}]\n'
"Только реальные пересекающие границу случаи; цитаты дословные. Без текста вне JSON."
)
def boundaries():
text = FLAT.read_text(encoding="utf-8")
ch = greedy_chunks(text)
out = []
for i in range(len(ch) - 1):
tail = "".join(split_source_sentences(ch[i])[-TAIL_N:]).strip()
head = "".join(split_source_sentences(ch[i + 1])[:HEAD_N]).strip()
out.append({"boundary_idx": i, "tail": tail, "head": head})
return out
def parse_array(t):
m = re.search(r"\[.*\]", t or "", re.S)
if not m:
return None
try:
return json.loads(m.group(0))
except Exception:
return None
def main():
ap = argparse.ArgumentParser(); ap.add_argument("--run", action="store_true"); a = ap.parse_args()
b = boundaries()
print(f"{len(b)} boundaries, batch {BATCH} -> {(len(b)+BATCH-1)//BATCH} kimi calls")
if not a.run:
return
sp = L.Spender(str(LEDGER), per_call_cap=0.28, hard_cap=2.5)
found = []
NUDGE = {"role": "user", "content": "Не рассуждай долго. Сразу выведи ТОЛЬКО компактный валидный JSON-массив, без markdown-ограды."}
for k in range(0, len(b), BATCH):
batch = b[k:k + BATCH]
user = "\n\n".join(f"ГРАНИЦА {x['boundary_idx']}:\n[ХВОСТ]: {x['tail']}\n[ГОЛОВА]: {x['head']}" for x in batch)
msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": user}]
in_est = (len(SYS) + len(user)) // 2
ok, pred, why = sp.gate(MINER, in_est, L.MODELS[MINER]["max_tokens"])
print(f"[gate] batch {k//BATCH} (bnd {batch[0]['boundary_idx']}-{batch[-1]['boundary_idx']}) pred=${pred:.4f} {'OK' if ok else why}")
if not ok:
continue
r = L.call(MINER, msgs, temp=1.0, max_tokens=24000, timeout=600)
sp.record({"batch": k // BATCH, "usage": r.get("usage"), "cost": r.get("cost", 0.0), "err": r.get("err"), "latency_s": r.get("latency_s")})
cands = parse_array(r.get("text", ""))
if cands is None:
r2 = L.call(MINER, msgs + [NUDGE], temp=1.0, max_tokens=24000, timeout=600)
sp.record({"batch": k // BATCH, "retry": True, "usage": r2.get("usage"), "cost": r2.get("cost", 0.0), "err": r2.get("err")})
cands = parse_array(r2.get("text", "")) if not r2.get("err") else None
cands = cands or []
found.extend(cands)
with open(str(OUT) + ".partial.jsonl", "a", encoding="utf-8") as pf:
pf.write(json.dumps({"batch": k // BATCH, "n": len(cands), "cands": cands}, ensure_ascii=False) + "\n")
print(f" batch {k//BATCH}: {len(cands)} straddles cost=${r.get('cost',0):.4f}")
from collections import Counter
by = Counter(c.get("type") for c in found)
OUT.write_text(json.dumps({"n": len(found), "by_type": dict(by), "straddles": found}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\nenriched straddles: {dict(by)} (total {len(found)}) spend ${sp.total:.4f} -> {OUT}")
if __name__ == "__main__":
main()

215
eval/exp15/exp15_llm.py Normal file
View file

@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""exp15 — shared LLM call / cost / per-call-gate / ledger layer for paid rigs (judges, miner, arms).
Uses the openai SDK (proven in wirecheck.py) with the frozen live prices (freeze_facts.json) NOT
exp14_common.PRICES (a stale 2026-07-10 mirror). Cost accounting is per-provider and VERIFIED against
the wire-check usage:
grok-4.3 : total = prompt + completion + reasoning => output billed = completion + reasoning (ADDITIVE)
kimi-k2.6: verbose reasoning billed as output => output billed = completion + reasoning (ADDITIVE)
mistral : no reasoning surcharge => output billed = completion
glm-5 : output billed = completion; cached prompt tokens billed at cache_hit rate
deepseek : output billed = completion (thinking folded / reported 0 for flash)
Tiered (grok/gemini) at 200k prompt tokens. Every paid call persists raw usage + cost (D30.10).
Per-call PREDICTED-cost gate BEFORE the call (worst-case max_tokens as output). Errors are returned,
not raised (honest accounting: record every attempt incl. failures D37 lesson).
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from openai import OpenAI
from dotenv import load_dotenv
EVAL = Path(__file__).resolve().parent.parent
load_dotenv(EVAL / ".env")
FACTS = json.load(open("/home/ubuntu/books/gu-zhenren/exp15/freeze_facts.json"))
PRICES = FACTS["prices"]
# model -> provider wiring (base_url, env key, default temp, max_tokens floor, static extra_body,
# whether reasoning tokens are billed additively as output)
MODELS = {
"deepseek-v4-flash": dict(base="https://api.deepseek.com/v1", env="DEEPSEEK_API_KEY",
temp=0.3, max_tokens=8000, extra={}, reasoning_additive=False),
"deepseek-v4-pro": dict(base="https://api.deepseek.com/v1", env="DEEPSEEK_API_KEY",
temp=0.3, max_tokens=8000, extra={}, reasoning_additive=False),
"glm-5": dict(base="https://api.z.ai/api/paas/v4", env="ZAI_API_KEY",
temp=0.4, max_tokens=8000, extra={"thinking": {"type": "disabled"}},
reasoning_additive=False),
"grok-4.3": dict(base="https://api.x.ai/v1", env="XAI_API_KEY",
temp=0.0, max_tokens=8000, extra={}, reasoning_additive=True), # reasoning ON (default)
"mistral-large-latest": dict(base="https://api.mistral.ai/v1", env="MISTRAL_API_KEY",
temp=0.0, max_tokens=8000, extra={"safe_prompt": False},
reasoning_additive=False),
"kimi-k2.6": dict(base="https://api.moonshot.ai/v1", env="KIMI_API_KEY",
temp=1.0, max_tokens=16000, extra={}, reasoning_additive=True), # temp MUST be 1
}
def _price(model, field, prompt_tokens=0):
v = PRICES[model][field]
if isinstance(v, list):
tier_at = PRICES[model].get("tier_at", 200000)
return v[1] if prompt_tokens >= tier_at else v[0]
return v
def _reasoning_tokens(usage_obj) -> int:
d = usage_obj if isinstance(usage_obj, dict) else getattr(usage_obj, "__dict__", {})
if d.get("reasoning_tokens"):
return d["reasoning_tokens"]
ctd = d.get("completion_tokens_details")
if ctd is not None:
ctd = ctd if isinstance(ctd, dict) else getattr(ctd, "__dict__", {})
return ctd.get("reasoning_tokens", 0) or 0
# xAI: total = prompt + completion + reasoning -> infer if fields present
return 0
def _cached_tokens(usage_obj) -> int:
d = usage_obj if isinstance(usage_obj, dict) else getattr(usage_obj, "__dict__", {})
# DeepSeek surfaces cache hits at top-level (prompt_cache_hit_tokens); GLM/OpenAI/Gemini nest it.
if d.get("prompt_cache_hit_tokens"):
return d["prompt_cache_hit_tokens"]
ptd = d.get("prompt_tokens_details")
if ptd is not None:
ptd = ptd if isinstance(ptd, dict) else getattr(ptd, "__dict__", {})
return ptd.get("cached_tokens", 0) or 0
return 0
def cost(model, usage: dict) -> float:
pt = usage.get("prompt_tokens", 0)
ct = usage.get("completion_tokens", 0)
rt = usage.get("reasoning_tokens", 0)
cached = usage.get("cached_tokens", 0)
pin = _price(model, "in", pt)
pout = _price(model, "out", pt)
phit = _price(model, "cache_hit", pt) if "cache_hit" in PRICES[model] else pin
out_tok = ct + (rt if MODELS[model]["reasoning_additive"] else 0)
in_cost = (pt - cached) * pin + cached * phit
return (in_cost + out_tok * pout) / 1e6
# reasoning tokens are NOT capped by max_tokens for these models -> the gate must budget them too,
# else predict_cost under-estimates and a call can overshoot per_call_cap (kimi ~12k reasoning/para).
REASONING_CEIL = {"kimi-k2.6": 14000, "grok-4.3": 4000}
def predict_cost(model, in_tok, max_out) -> float:
"""Worst-case gate estimate: full max_tokens as billed output PLUS an uncapped-reasoning allowance
for additive-reasoning models (kimi/grok), whose reasoning tokens are billed but not bounded by
max_tokens. Without this the per-call/hard gate under-predicts (seen: kimi $0.124 vs $0.066)."""
pin = _price(model, "in", in_tok)
pout = _price(model, "out", in_tok)
out_est = max_out + (REASONING_CEIL.get(model, 0) if MODELS[model]["reasoning_additive"] else 0)
return (in_tok * pin + out_est * pout) / 1e6
class Spender:
"""Per-call predicted-cost gate + append-only jsonl ledger + running total (D30.10)."""
def __init__(self, ledger_path: str, per_call_cap: float, hard_cap: float = None, seed_total: float = 0.0):
# seed_total = spend already booked in OTHER ledgers, so hard_cap can be a GLOBAL ceiling even
# though each block writes its own per-block ledger (review I8: per-block caps summed to 4x budget).
self.path = Path(ledger_path)
self.per_call_cap = per_call_cap
self.hard_cap = hard_cap
self.seed_total = seed_total
self.total = seed_total
if self.path.exists():
for line in self.path.read_text(encoding="utf-8").splitlines():
try:
self.total += json.loads(line).get("cost", 0.0)
except Exception:
pass
def gate(self, model, in_tok, max_out):
pred = predict_cost(model, in_tok, max_out)
if pred > self.per_call_cap:
return False, pred, f"per-call gate: pred ${pred:.4f} > cap ${self.per_call_cap}"
if self.hard_cap is not None and self.total + pred > self.hard_cap:
return False, pred, f"hard-cap gate: total ${self.total:.4f}+${pred:.4f} > ${self.hard_cap}"
return True, pred, "ok"
def record(self, rec: dict):
c = rec.get("cost", 0.0)
self.total += c
with self.path.open("a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
# proactive per-model min interval between calls (avoid 429 storms; mistral rate-limits aggressively).
# FLOOR full-chunk run measured mistral at 48% retry-fails @1.3s (max lat 64.7s) vs grok 0% @0.4s -> widen
# mistral spacing so its per-cell burst stays under the rate limit (fewer backoffs => cleaner AND faster).
MIN_INTERVAL = {"mistral-large-latest": 2.6, "grok-4.3": 0.4, "kimi-k2.6": 0.0}
_LAST_CALL = {}
def call(model, messages, temp=None, max_tokens=None, extra_body=None, timeout=240, retries=4):
"""Return {text, reasoning, usage, cost, model_returned, finish, err, latency_s}. Never raises.
Retries transient errors (429 rate-limit, 5xx, timeouts) with exponential backoff (5/15/35/60s)."""
m = MODELS[model]
key = os.environ.get(m["env"])
if not key:
return {"err": f"no key {m['env']}", "cost": 0.0, "usage": {}}
mi = MIN_INTERVAL.get(model, 0.0)
if mi:
dt = time.time() - _LAST_CALL.get(model, 0.0)
if dt < mi:
time.sleep(mi - dt)
_LAST_CALL[model] = time.time()
cli = OpenAI(base_url=m["base"], api_key=key, timeout=timeout)
body_extra = dict(m["extra"])
if extra_body:
body_extra.update(extra_body)
kw = dict(model=model, messages=messages,
temperature=m["temp"] if temp is None else temp,
max_tokens=max_tokens or m["max_tokens"])
if body_extra:
kw["extra_body"] = body_extra
t0 = time.time()
backoff = [5, 15, 35, 60]
last_err = None
retry_fails = 0 # transient attempts that were retried (may have billed server-side; review I8)
for attempt in range(retries + 1):
try:
r = cli.chat.completions.create(**kw)
break
except Exception as e: # noqa: BLE001
last_err = f"{type(e).__name__}: {str(e)[:200]}"
transient = any(s in last_err.lower() for s in ("ratelimit", "429", "rate limit", "timeout",
"timed out", "500", "502", "503", "529", "overloaded"))
if attempt < retries and transient:
retry_fails += 1
time.sleep(backoff[min(attempt, len(backoff) - 1)])
continue
return {"err": last_err, "cost": 0.0, "usage": {}, "latency_s": round(time.time() - t0, 1),
"retry_fails": retry_fails}
u = r.usage
usage = {"prompt_tokens": getattr(u, "prompt_tokens", 0),
"completion_tokens": getattr(u, "completion_tokens", 0),
"total_tokens": getattr(u, "total_tokens", 0),
"reasoning_tokens": _reasoning_tokens(u),
"cached_tokens": _cached_tokens(u)}
# xAI: reasoning often only inferable from total-prompt-completion
if usage["reasoning_tokens"] == 0 and usage["total_tokens"] > usage["prompt_tokens"] + usage["completion_tokens"]:
usage["reasoning_tokens"] = usage["total_tokens"] - usage["prompt_tokens"] - usage["completion_tokens"]
msg = r.choices[0].message
text = (msg.content or "").strip()
reasoning = (getattr(msg, "reasoning_content", None) or "").strip()
c = cost(model, usage)
return {"text": text, "reasoning": reasoning, "usage": usage, "cost": round(c, 6),
"model_returned": getattr(r, "model", None), "finish": r.choices[0].finish_reason,
"err": None, "latency_s": round(time.time() - t0, 1), "retry_fails": retry_fails}
if __name__ == "__main__":
# smoke: cost accounting sanity vs the frozen wire-check numbers
grok_usage = {"prompt_tokens": 223, "completion_tokens": 11, "reasoning_tokens": 262, "total_tokens": 496, "cached_tokens": 0}
print("grok additive cost (should exceed the wire-check's completion-only $0.000306):",
round(cost("grok-4.3", grok_usage), 6))
print(" = (223*1.25 + (11+262)*2.50)/1e6 =", round((223*1.25 + 273*2.50)/1e6, 6))
mis_usage = {"prompt_tokens": 41, "completion_tokens": 15, "reasoning_tokens": 0, "total_tokens": 56, "cached_tokens": 0}
print("mistral cost:", round(cost("mistral-large-latest", mis_usage), 6), "= (41*0.5+15*1.5)/1e6 =", round((41*0.5+15*1.5)/1e6, 6))

View file

@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""exp15 — fertility calibration B1.2 ($0, offline): src chars -> ru output-token coefficients.
Regresses ru output tokens of the 25-chapter rerun finals on (cjk, other) source-char counts, giving
the chunker's est_out = f_cjk*cjk + f_other*other (§B1.1/B1.2). Chunker-spec deliverable feeding the
Q1 size-match. Offline tokenizer per §B1.2: deepseek-v4 tokenizer dir is EMPTY in repo, so we use the
editor-family glm-4.6 tokenizer (present) and label it; Go needs only the COEFFICIENTS, not a tokenizer.
Run: eval/.venv/bin/python eval/exp15/fertility_calib.py
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
from tokenizers import Tokenizer
REC = Path("/home/ubuntu/books/gu-zhenren/rerun/records.json")
TOK = Path("/home/ubuntu/projects/textmachine/eval/tokenizers/glm-4.6/tokenizer.json")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/fertility_calib.json")
def is_cjk(ch: str) -> bool:
o = ord(ch)
return (0x4E00 <= o <= 0x9FFF or 0x3040 <= o <= 0x30FF or 0x3400 <= o <= 0x4DBF
or 0xF900 <= o <= 0xFAFF or 0x20000 <= o <= 0x2A6DF)
def char_classes(s: str):
cjk = sum(1 for c in s if is_cjk(c))
other = sum(1 for c in s if not c.isspace() and not is_cjk(c))
return cjk, other
def main():
tok = Tokenizer.from_file(str(TOK))
recs = json.load(open(REC))
rows, meta = [], []
for r in recs:
src = r.get("source") or ""
fin = r.get("final") or ""
if not src.strip() or not fin.strip():
continue
cjk, other = char_classes(src)
out_tok = len(tok.encode(fin).ids)
if cjk + other < 200: # skip tiny/metadata chunks (e.g. title block)
continue
rows.append((cjk, other, out_tok))
meta.append((r.get("chapter"), r.get("chunk_idx")))
A = np.array([[c, o] for c, o, _ in rows], dtype=float)
y = np.array([t for _, _, t in rows], dtype=float)
# non-negative-ish least squares, no intercept (est_out = f_cjk*cjk + f_other*other)
coef, *_ = np.linalg.lstsq(A, y, rcond=None)
f_cjk, f_other = float(coef[0]), float(coef[1])
pred = A @ coef
ss_res = float(np.sum((y - pred) ** 2))
ss_tot = float(np.sum((y - y.mean()) ** 2))
r2 = 1 - ss_res / ss_tot if ss_tot else float("nan")
mape = float(np.mean(np.abs((y - pred) / y))) * 100
result = {
"n_records": len(rows),
"tokenizer": "glm-4.6 (editor family; deepseek-v4 tokenizer dir empty in repo — §B1.2 caveat)",
"model": "est_out = f_cjk*cjk_src + f_other*other_src (no intercept)",
"f_cjk": round(f_cjk, 4),
"f_other": round(f_other, 4),
"r2": round(r2, 4),
"mape_pct": round(mape, 2),
"placeholder_B1_1": {"cjk_char": 0.75, "other_char": 0.25},
"note": "per-language-pair config (layer 2); recompute = loud resnapshot. Src-char est is the SECOND "
"quantity (§B1.2); primary budget is ru-out tokens. Calibrated on 25-ch guzhenren rerun (in-pretrain).",
}
OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"fertility calibration (n={len(rows)} chunks, glm-4.6 ru tokens):")
print(f" est_out = {f_cjk:.4f}*cjk + {f_other:.4f}*other R2={r2:.4f} MAPE={mape:.1f}%")
print(f" vs §B1.1 placeholder cjk=0.75/other=0.25")
print(f" sample src cjk/other -> out (first 6):")
for (c, o, t), m in list(zip(rows, meta))[:6]:
print(f" ch{m[0]}#{m[1]}: cjk={c} other={o} out_tok={t} pred={f_cjk*c+f_other*o:.0f}")
print(f"-> {OUT}")
# self-review: coefficients must be positive and R2 reasonable
assert f_cjk > 0, f"non-positive cjk fertility {f_cjk}"
assert r2 > 0.5, f"poor fit R2={r2} — investigate before trusting coefficients"
print("OK: positive fertility coefficients, R2>0.5.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""exp15 — deterministic gender transport for the memory-bank seed (owner decision #2, 16.07).
Mirrors the exp14b_reseed_promote provenance pattern: IDEMPOTENT + byte-stable + one-time
pre-snapshot + provenance log + loud self-review asserts. The `gender` field ALREADY exists in
guzhenren-seed-v2.yaml and is consumed by Go (memseed.go:35 -> memory.go:273 hash -> store;
migrate.go:183 valid values male|female|hidden|''). So this transport does three jobs:
(1) AUDIT gender coverage of every person (type=name) record and EMIT an owner-signable
disputed/missing list (`gender_signoff.yaml` template) the T-gen trap subjects and the
Q3a' state-arm need signed gender.
(2) APPLY only owner-SIGNED additions from `gender_signoff.yaml` (src -> male|female|hidden),
idempotently + byte-stably. n-a rows document "confirmed no gender field" (clan/place/concept)
and are NOT mutated. No signoff file, or seed already complete -> NO-OP (snapshot hash preserved).
(3) SELF-REVIEW by execution: build the eval-side scene-state slot from matched entries and assert
gender surfaces for gendered persons (this is the Q3a' state mechanism; hidden -> gender-avoidant).
Vocabulary map (prompt shorthand -> DB string, migrate.go:183): m->male f->female hidden->hidden n-a->''(absent).
Discipline: seed is OUTSIDE git; population is owner's signature (approved-invariant). If signature does
not arrive before Q3a', the state-arm is honestly marked "state without gender", NOT imitated (prompt #2).
Run: eval/.venv/bin/python eval/exp15/gender_transport.py # audit + apply signed (default)
eval/.venv/bin/python eval/exp15/gender_transport.py --audit # audit only, never mutate
"""
from __future__ import annotations
import sys
from pathlib import Path
import yaml
BOOK = Path("/home/ubuntu/books/gu-zhenren")
SEED = BOOK / "guzhenren-seed-v2.yaml"
SIGNOFF = BOOK / "exp15" / "gender_signoff.yaml" # owner-editable; src -> gender
BAK = BOOK / "exp15" / "guzhenren-seed-v2.pre-gender.yaml" # one-time immutable "before"
LOG = BOOK / "exp15" / "gender_transport.md"
DUMP = dict(allow_unicode=True, sort_keys=False, width=200) # SAME params that wrote the seed (byte-stable)
VALID_DB = {"male", "female", "hidden"} # values that mutate a record
VALID_SIGN = VALID_DB | {"n-a"} # n-a = documented, no field
SHORT = {"m": "male", "f": "female", "hidden": "hidden", "n-a": "n-a",
"male": "male", "female": "female"}
def load_yaml(p: Path):
return yaml.safe_load(p.read_text(encoding="utf-8"))
def persons(data):
return [t for t in data["terms"] if t.get("type") == "name"]
def load_signoff():
"""Owner-signed src->gender. Absent => empty (pure audit / no additions)."""
if not SIGNOFF.exists():
return {}
raw = load_yaml(SIGNOFF) or {}
entries = raw.get("gender", raw) if isinstance(raw, dict) else {}
out = {}
for src, g in entries.items():
g = str(g).strip()
norm = SHORT.get(g, g)
assert norm in VALID_SIGN, f"signoff bad value for {src!r}: {g!r} (allowed m/f/hidden/n-a)"
out[src] = norm
return out
def apply_signed(data, signoff):
"""Mutate in place from SIGNED table; return list of (src, old, new). Idempotent."""
by_src = {t.get("src"): t for t in data["terms"]}
applied, warn = [], []
for src, g in signoff.items():
if src not in by_src:
warn.append(src)
continue
rec = by_src[src]
if g == "n-a":
# documented no-gender; assert the record indeed has no meaningful gender
if rec.get("gender"):
warn.append(f"{src}: signed n-a but record has gender={rec['gender']!r}")
continue
old = rec.get("gender")
if old != g:
rec["gender"] = g
applied.append((src, old, g))
return applied, warn
def scene_state_slot(data, chunk, chapter=15):
"""Q3a' state mechanism (eval-side): deterministic scene-state slot from matched seed entries,
including gender (hidden -> gender-avoidant instruction). Substring match on Han keys."""
lines = []
for t in persons(data):
src = t.get("src")
if src and src in chunk and int(t.get("since_ch", 0)) <= chapter:
dst = (t.get("dst") or "").strip()
g = t.get("gender") or ""
if g == "male":
tag = "муж. (прош. вр. м.р.)"
elif g == "female":
tag = "жен. (прош. вр. ж.р.)"
elif g == "hidden":
tag = "пол скрыт до раскрытия -> гендерно-нейтральные конструкции"
else:
tag = "пол НЕ задан"
lines.append(f"- {dst}: {tag}")
return lines
def emit_signoff_template(data, missing):
"""Write a signoff template ONLY if none exists — pre-fill current gendered persons + disputed rows."""
if SIGNOFF.exists():
return False
doc = ["# exp15 gender sign-off (owner) — src -> m | f | hidden | n-a",
"# m/f = person; hidden = gender concealed until reveal (render gender-avoidant);",
"# n-a = clan/place/concept (NO gender field). Edit values, then re-run gender_transport.py.",
"# Rows already gendered in seed are shown for confirmation; DISPUTED rows need your call.",
"gender:"]
for t in persons(data):
src, dst, g = t.get("src"), t.get("dst"), t.get("gender")
if g in VALID_DB:
doc.append(f" {src}: {g} # {dst} (already in seed; confirm)")
for t in missing:
doc.append(f" {t.get('src')}: DISPUTED # {t.get('dst')} — NEEDS YOUR CALL (person=m/f, else n-a)")
SIGNOFF.write_text("\n".join(doc) + "\n", encoding="utf-8")
return True
def main():
audit_only = "--audit" in sys.argv
data0 = load_yaml(SEED)
ppl = persons(data0)
gendered = [t for t in ppl if t.get("gender") in VALID_DB]
missing = [t for t in ppl if t.get("gender") not in VALID_DB]
signoff = load_signoff()
template_written = emit_signoff_template(data0, missing)
# one-time immutable "before"
if not audit_only and not BAK.exists():
BAK.write_text(SEED.read_text(encoding="utf-8"), encoding="utf-8")
data = load_yaml(SEED)
applied, warn = ([], [])
if not audit_only and signoff:
applied, warn = apply_signed(data, signoff)
new_bytes = yaml.dump(data, **DUMP)
if applied:
SEED.write_text(new_bytes, encoding="utf-8")
# ---- self-review: scene-state slot surfaces gender ----
demo_chunk = "".join(t["src"] for t in gendered[:3]) + "在村中相遇。"
slot = scene_state_slot(data, demo_chunk)
# ---- provenance log ----
lg = ["# exp15 — gender transport (owner decision #2, 2026-07-16)", "",
f"Seed: `{SEED.name}` (OUTSIDE git). Pattern: exp14b_reseed_promote (idempotent, byte-stable, pre-snapshot+log).",
f"Gender already consumed by Go: memseed.go:35 -> memory.go:273 (hashed) -> store; migrate.go:183 = male|female|hidden|''.",
"", f"Person (type=name) records: {len(ppl)}; gendered {len(gendered)}; needing signature {len(missing)}.",
f"Signed additions applied this run: {len(applied)} (0 = seed already complete / no signoff -> snapshot hash UNCHANGED).",
""]
lg += ["## Gendered persons (already signed via approved status)", "| src | dst | gender |", "|---|---|---|"]
for t in gendered:
lg.append(f"| {t.get('src')} | {t.get('dst')} | {t.get('gender')} |")
lg += ["", "## DISPUTED / missing gender — need owner signature", "| src | dst | note |", "|---|---|---|"]
for t in missing:
lg.append(f"| {t.get('src')} | {t.get('dst')} | {(t.get('note') or '')[:80]} |")
if applied:
lg += ["", "## Applied signed additions this run", "| src | old | new |", "|---|---|---|"]
lg += [f"| {s} | {o} | {n} |" for s, o, n in applied]
if warn:
lg += ["", "⚠ warnings: " + "; ".join(warn)]
lg += ["", "## Self-review — Q3a' scene-state slot (eval-side, gender surfaces)",
f"synthetic chunk (my construct): `{demo_chunk}`", "```", *(slot or ["(empty)"]), "```"]
if not audit_only:
LOG.write_text("\n".join(lg), encoding="utf-8")
# ---- console ----
print(f"{'[AUDIT] ' if audit_only else ''}persons={len(ppl)} gendered={len(gendered)} need-signature={len(missing)} applied={len(applied)}")
print("gendered:", ", ".join(f"{t.get('src')}={t.get('gender')}" for t in gendered))
print("NEEDS SIGNATURE:", ", ".join(f"{t.get('src')}({t.get('dst')})" for t in missing) or "(none)")
if template_written:
print(f"-> wrote sign-off TEMPLATE: {SIGNOFF} (owner edits DISPUTED rows, then re-run)")
print("-- scene-state slot (self-review) --")
for x in (slot or ["(empty)"]):
print(" " + x)
# ---- loud asserts (self-review by execution) ----
assert all(t.get("gender") in VALID_DB for t in gendered), "gendered set contains a bad value"
# every gendered person surfaces its tag in the slot when present in chunk:
for t in gendered[:3]:
assert any(t.get("dst") in line for line in slot), f"state slot dropped {t.get('dst')}"
assert not warn or audit_only, f"apply warnings: {warn}"
print("\nOK: gender field consumed by Go, transport idempotent/byte-stable, state slot surfaces gender.")
return 0
if __name__ == "__main__":
sys.exit(main())

300
eval/exp15/judge_run.py Normal file
View file

@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""exp15 — judge driver for the carrying block (Q1b oracle-vs-flat + Q3a carryover + Q0 + FLOOR), §D3.
Maps each final_traps.json trap to the chunk whose SOURCE contains its dependent_span, in each of:
flat-A0-30 (blind baseline) oracle-30 (seam-aligned) arm modes q3a-src/draft/final
then gives the pairwise span judge (judges.py: both-orders, 6->10, LOO, catastrophe, per-vote persist)
the trap + a DEPENDENT-CENTRED SYMMETRIC WINDOW of each side's translation.
WINDOW POLICY (orchestrator CRITICAL fix, replaces the old HEAD_CHARS=1100 head-excerpt):
The old head-excerpt showed the judge only the first 1100 chars of each final. In the FLAT arm the
straddle-dependent lands at frac ~0 (chunk head) -> always visible. In the ORACLE arm the SAME
dependent sits mid-chunk (frac 0.09-1.0) -> its Russian rendering fell PAST char 1100 in 13/18
T-ana cells, so the judge scored "absent"=wrong/catastrophe by construction. That artifact, not the
boundary, produced the -0.564 headline. Fix: a fixed-length window centred on the dependent's
PROPORTIONAL position in each side's target, SAME length L for every arm (size-symmetric). Both
sides now always contain the local phenomenon (anchor+dependent sit within a few hundred source
chars of each other -> inside one window). The floor block (A0 vs A0') validates the policy: if the
window systematically hid phenomena the floor would be large/asymmetric.
Q1b: a0=flat, arm=oracle (does cutting AT the scene boundary help vs a blind mid-scene cut?)
Q3a: a0=flat, arm=mode (does carryover fix the cross-boundary dependency? per mode)
Q0 : a0=do-nothing (draft), arm=A0 (editor final) (TOST equivalence APE lesson)
FLOOR: a0=flat-A0, arm=flat-A0' (independent regen, same config) — the judge+generation NOISE floor;
effects below it are not interpretable (freeze §1.5). Same window policy as every block.
Oracle mapping is REQUIRED only for Q1b. Flat mapping is mandatory for all blocks. (Old code required
BOTH -> T-ana-33, whose oracle chunk 12 was cjk_artifact-skipped, was silently dropped from Q3a/Q0/FLOOR
too. Now flat-only blocks keep it; Q1b documents its exclusion.)
Judge SUBSET (budget scope, guard 5): ALL T-ana (primary) + ALL T-ell + a capped T-ent CONTROL sample.
--project : $0 cell/token/cost estimate (guard-5 projection; NO api calls)
--run Q1b|Q3a|Q0|FLOOR : execute that block | --tent-control N : cap the T-ent control cells
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp15_llm as L
from judges import judge_cell, JUDGES
from chunker import greedy_chunks
SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
AH = SD / "anchors"
ARMS = SD / "arms"
WIN_CHARS = 1500 # dependent-centred symmetric window fed to the judge (same L for both arms)
GLOBAL_CEILING = 14.5 # hard global spend ceiling across ALL blocks/ledgers (review I8; $15 freeze - margin)
# Orchestrator's AUTHORITATIVE pre-session-2 spend (2541 billing lines, 0 discrepancies). Used as the seed
# floor because it includes the tmctl anchor-generation (flat/oracle/a0prime .db) that ran through the Go
# backend, NOT the Python Spender ledgers — so a raw ledger-sum would under-count the true global by ~$2.
ALREADY_SPENT = 9.82
NEW_LEDGERS = ("judge_costs_FLOOR.jsonl", "judge_costs_Q1b.jsonl", "judge_costs_Q4a.jsonl")
def global_spent_excluding(block):
"""ALREADY_SPENT floor + this session's NEW re-run ledgers (except the current block's own, which its
own Spender loads). Enforces a true global ceiling via seed_total (review I8)."""
tot = ALREADY_SPENT
for name in NEW_LEDGERS:
if name == f"judge_costs_{block}.jsonl":
continue
p = SD / name
if p.exists():
for line in p.read_text(encoding="utf-8").splitlines():
try:
tot += json.loads(line).get("cost", 0.0)
except Exception:
pass
return tot
def _db_stage(db, stage):
con = sqlite3.connect(db)
out = {}
for ck, txt in con.execute("SELECT chunk_idx, response_text FROM checkpoints WHERE stage=? "
"ORDER BY chunk_idx, attempt", (stage,)):
out[ck] = txt
con.close()
return out
def db_finals(db):
return _db_stage(db, "edit")
def db_drafts(db):
return _db_stage(db, "draft")
def find_off(chunks, span):
"""(chunk_idx, char_offset) of the first chunk whose source contains span's 20-char head."""
probe = (span or "").strip()[:20]
for i, c in enumerate(chunks):
if probe and probe in c:
return i, c.find(probe)
return None, None
def locate_flat(trap, flat_src):
"""Prefer the trap's authoritative dep_chunk (verified to contain the dependent); else scan."""
d = (trap["dependent_span"] or "").strip()[:20]
fi = trap.get("dep_chunk")
if fi is not None and 0 <= fi < len(flat_src) and d and d in flat_src[fi]:
return fi, flat_src[fi].find(d)
return find_off(flat_src, trap["dependent_span"])
def frac(chunk_src, off):
n = len(chunk_src or "")
return (off / n) if (n and off is not None and off >= 0) else 0.0
def window(text, fr, L=WIN_CHARS):
"""Fixed-length (L) window of `text` centred on proportional position `fr`, clamped-and-extended
so it is always exactly L chars when text is longer than L (size-symmetric across arms)."""
text = text or ""
if len(text) <= L:
return text
center = int(round(fr * len(text)))
lo, hi = center - L // 2, center - L // 2 + L
if lo < 0:
lo, hi = 0, L
if hi > len(text):
hi, lo = len(text), len(text) - L
return text[lo:hi]
def load():
flat_src = greedy_chunks((SD / "S2prime_big_flat.txt").read_text(encoding="utf-8"))
oracle_src = greedy_chunks((SD / "S2prime_big_blanklines.txt").read_text(encoding="utf-8"))
flat_fin = db_finals(AH / "flat_a0_30.db")
oracle_fin = db_finals(AH / "oracle_30.db")
traps = json.load(open(SD / "final_traps.json"))["traps"]
return flat_src, oracle_src, flat_fin, oracle_fin, traps
def arm_final(mode, chunk_idx):
p = ARMS / mode / f"{chunk_idx:02d}.final.txt"
return p.read_text(encoding="utf-8") if p.exists() else None
def locate_oracle(trap, oracle_src):
"""Select the oracle chunk holding the phenomenon. Prefer a chunk containing BOTH the anchor and the
dependent (seam-aligned -> co-located for T-ana/T-ell). Else accept a dependent-only chunk ONLY when
the dependent probe is distinctive (>=8 chars) AND unique short/ambiguous probes are skipped rather
than judged on the wrong passage (review I3). Returns (chunk_idx, dep_offset) or (None, None)."""
d = (trap["dependent_span"] or "").strip()[:20]
a = (trap["anchor_span"] or "").strip()[:20]
for i, c in enumerate(oracle_src):
if d and a and d in c and a in c:
return i, c.find(d)
hits = [i for i, c in enumerate(oracle_src) if d and d in c]
if len(d) >= 8 and len(hits) == 1:
return hits[0], oracle_src[hits[0]].find(d)
return None, None
def excerpt(final, fr, full):
"""Full final (no windowing) when full=True; else the dependent-centred window."""
return final if full else window(final, fr)
def map_trap(trap, flat_src, oracle_src, flat_fin, oracle_fin, full=False):
"""Flat mapping is mandatory (returns None if absent). Oracle mapping is optional (None if absent
-> Q1b skips, flat-only blocks proceed). full=True feeds whole finals (no windowing)."""
fi, fpos = locate_flat(trap, flat_src)
if fi is None or fi not in flat_fin:
return None
ffr = frac(flat_src[fi], fpos)
jt = {"id": trap["id"], "type": trap["type"],
"src_zone": trap["anchor_span"] + "" + trap["dependent_span"],
"phenomenon": trap["phenomenon"], "expected": trap["expected_ru"]}
orac = None
oi, opos = locate_oracle(trap, oracle_src)
if oi is not None and oi in oracle_fin:
ofr = frac(oracle_src[oi], opos)
orac = {"chunk": oi, "frac": round(ofr, 3), "win": excerpt(oracle_fin[oi], ofr, full)}
return {"jt": jt, "flat_chunk": fi, "flat_frac": round(ffr, 3),
"flat_win": excerpt(flat_fin[fi], ffr, full), "oracle": orac}
def select_traps(traps, tent_control, primary_only=False):
prim = [t for t in traps if t["type"] in ("T-ana", "T-ell")]
if primary_only:
return prim
tent = [t for t in traps if t["type"] == "T-ent"][:tent_control]
return prim + tent
def project(traps, tent_control):
flat_src, oracle_src, flat_fin, oracle_fin, _ = load()
sub = select_traps(traps, tent_control)
mapped = [t for t in sub if map_trap(t, flat_src, oracle_src, flat_fin, oracle_fin)]
n = len(mapped)
q1b_ok = sum(1 for t in mapped
if (map_trap(t, flat_src, oracle_src, flat_fin, oracle_fin) or {}).get("oracle"))
in_tok = (600 + 400 + 2 * WIN_CHARS // 2) # sys + trap + 2 windows (~2 chars/token)
votes = 7.4 # 6 base + 0.35*4 escalation avg, per judge x2
g = (in_tok * L._price("grok-4.3", "in") + 900 * L._price("grok-4.3", "out")) / 1e6
m = (in_tok * L._price("mistral-large-latest", "in") + 200 * L._price("mistral-large-latest", "out")) / 1e6
cc = votes * (g + m)
q3a_cells = sum(1 for t in mapped if t["type"] in ("T-ana", "T-ell")) * 3 + \
sum(1 for t in mapped if t["type"] == "T-ent")
print(f"=== guard-5 judge projection (WIN_CHARS={WIN_CHARS}, tent_control={tent_control}) ===")
print(f"subset {len(sub)} flat-mappable {n} (T-ana {sum(1 for t in mapped if t['type']=='T-ana')}, "
f"T-ell {sum(1 for t in mapped if t['type']=='T-ell')}, T-ent {sum(1 for t in mapped if t['type']=='T-ent')}) "
f"oracle-mappable {q1b_ok}")
print(f"per-cell in_tok~{in_tok} votes~{votes} cost/cell ~${cc:.4f}")
print(f"Q1b(re-judge) cells {q1b_ok} -> ~${q1b_ok*cc:.2f}")
print(f"FLOOR cells {n} -> ~${n*cc:.2f}")
print(f"Q3a cells {q3a_cells} -> ~${q3a_cells*cc:.2f} | Q0 cells {n} -> ~${n*cc:.2f}")
def run(block, tent_control, per_call_cap, limit=0, full=False):
flat_src, oracle_src, flat_fin, oracle_fin, traps = load()
primary_only = block in ("FLOOR",)
sub = select_traps(traps, tent_control, primary_only=primary_only)
if limit:
sub = sub[:limit]
a0p_fin = db_finals(AH / "flat_a0prime_30.db") if block == "FLOOR" else None
draft_fin = db_drafts(AH / "flat_a0_30.db") if block == "Q0" else None
# GLOBAL cap: seed this block's Spender with spend already booked in all OTHER ledgers so hard_cap is
# a true cross-block ceiling (review I8), not a per-block one that would sum to ~4x the freeze.
seed = global_spent_excluding(block)
sp = L.Spender(str(SD / f"judge_costs_{block}.jsonl"), per_call_cap=per_call_cap,
hard_cap=GLOBAL_CEILING, seed_total=seed)
print(f"[budget] global spent (other ledgers) ${seed:.3f}; block start ${sp.total:.3f}; "
f"global ceiling ${GLOBAL_CEILING}; excerpt={'FULL-CHUNK' if full else f'window{WIN_CHARS}'}")
out, skipped = [], []
for t in sub:
m = map_trap(t, flat_src, oracle_src, flat_fin, oracle_fin, full=full)
if not m:
skipped.append((t["id"], "flat-unmappable")); continue
dep = m["flat_chunk"]
if block == "Q1b":
if not m["oracle"]:
skipped.append((t["id"], "oracle-unmappable")); continue
agg = judge_cell(sp, m["jt"], a0_text=m["flat_win"], arm_text=m["oracle"]["win"])
agg.update(block="Q1b", arm="oracle", flat_chunk=dep, flat_frac=m["flat_frac"],
oracle_chunk=m["oracle"]["chunk"], oracle_frac=m["oracle"]["frac"], full=full)
out.append(agg)
print(f" Q1b {t['id']}: oracle-flat {agg['paired_diff_arm_minus_a0']} "
f"(ffrac {m['flat_frac']} ofrac {m['oracle']['frac']} trunc={agg.get('budget_truncated')}) spend ${sp.total:.3f}")
elif block == "FLOOR":
if dep not in a0p_fin:
skipped.append((t["id"], "a0prime-missing")); continue
agg = judge_cell(sp, m["jt"], a0_text=m["flat_win"],
arm_text=excerpt(a0p_fin[dep], m["flat_frac"], full))
agg.update(block="FLOOR", arm="a0prime", flat_chunk=dep, flat_frac=m["flat_frac"], full=full)
out.append(agg)
print(f" FLOOR {t['id']}: |diff| {abs(agg['paired_diff_arm_minus_a0'] or 0):.3f} spend ${sp.total:.3f}")
elif block == "Q3a":
modes = ["q3a-src", "q3a-draft", "q3a-final"] if t["type"] in ("T-ana", "T-ell") else ["q3a-final"]
for mode in modes:
af = arm_final(mode, dep)
if not af:
skipped.append((t["id"], f"{mode}-missing")); continue
agg = judge_cell(sp, m["jt"], a0_text=m["flat_win"], arm_text=excerpt(af, m["flat_frac"], full))
agg.update(block="Q3a", arm=mode, flat_chunk=dep, flat_frac=m["flat_frac"], full=full)
out.append(agg)
print(f" Q3a {t['id']} {mode}: {agg['paired_diff_arm_minus_a0']} spend ${sp.total:.3f}")
elif block == "Q0":
if dep not in draft_fin:
skipped.append((t["id"], "draft-missing")); continue
agg = judge_cell(sp, m["jt"], a0_text=excerpt(draft_fin[dep], m["flat_frac"], full),
arm_text=m["flat_win"]) # a0=do-nothing(draft), arm=A0(editor final)
agg.update(block="Q0", arm="A0-editor", flat_chunk=dep, flat_frac=m["flat_frac"], full=full)
out.append(agg)
print(f" Q0 {t['id']}: A0-donothing {agg['paired_diff_arm_minus_a0']} spend ${sp.total:.3f}")
if any(a.get("budget_truncated") for a in out):
print(f" [HARD-CAP] budget ceiling ${GLOBAL_CEILING} bound mid-run -> stopping block"); break
Path(SD / f"judge_results_{block}.json").write_text(
json.dumps({"n": len(out), "win_chars": (None if full else WIN_CHARS), "full_chunk": full,
"skipped": skipped, "results": out}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n{block}: {len(out)} cells judged, {len(skipped)} skipped {skipped}, block+global spend ${sp.total:.4f}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--project", action="store_true")
ap.add_argument("--run", choices=["Q1b", "Q3a", "Q0", "FLOOR"])
ap.add_argument("--tent-control", type=int, default=10)
ap.add_argument("--per-call-cap", type=float, default=0.03)
ap.add_argument("--full", action="store_true", help="feed WHOLE finals (no windowing) — used for Q1b")
ap.add_argument("--limit", type=int, default=0, help="judge only first N traps (selftest)")
a = ap.parse_args()
traps = json.load(open(SD / "final_traps.json"))["traps"]
if a.project:
project(traps, a.tent_control)
elif a.run:
run(a.run, a.tent_control, a.per_call_cap, a.limit, full=a.full)
else:
ap.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""exp15 — judge-harness SELFTEST on 2-3 pilot traps (checkpoint item d + preliminary FLOOR).
Judges A0 vs A0' (SAME chunking = the noise floor: a well-behaved harness should return ~tie, low
|paired_diff|, no systematic order bias). Exercises: JSON parse-rate, both-orders, the 6->10 split
escalation branch, leave-one-judge-out, catastrophe screen. Cheap (cents).
Pilot traps are hand-specified cohesion phenomena located in specific chunks; each is judged on that
chunk's A0-final vs A0'-final. Run AFTER anchors_analyze.py (needs *.chunks.json).
Run: python judge_selftest.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp15_llm as L
from judges import judge_cell
AH = Path("/home/ubuntu/books/gu-zhenren/exp15/anchors")
TRAP_LEDGER = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_ledger.json")
LEDGER = Path("/home/ubuntu/books/gu-zhenren/exp15/judge_selftest_costs.jsonl")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/judge_selftest.json")
A0_TAG = "flat_a0" # blind-greedy anchor (regenerated on flat S2')
A0P_TAG = "flat_a0prime"
def chunks(tag):
return {c["chunk"]: c for c in json.load(open(AH / f"{tag}.chunks.json"))}
def locate_chunk(chunk_map, src_probe):
"""Return the chunk_idx whose source contains src_probe (first ~20 chars)."""
probe = src_probe.strip()[:20]
for idx, c in chunk_map.items():
if c["source"] and probe in c["source"]:
return idx
return None
def pick_pilots(n=3):
"""2-3 diverse-type traps from the curated ledger (prefer distinct types crossing an A0 cut)."""
led = json.load(open(TRAP_LEDGER))["ledger"]
cross = [t for t in led if t.get("crosses_A0_cut")]
picks, seen_types = [], set()
for t in cross:
if t["type"] not in seen_types:
picks.append(t); seen_types.add(t["type"])
if len(picks) >= n:
break
return picks or led[:n]
def main():
a0 = chunks(A0_TAG)
a0p = chunks(A0P_TAG)
pilots = [{"id": t["id"], "type": t["type"], "chunk_idx": locate_chunk(a0, t["anchor_span"]),
"src_zone": (t["anchor_span"] + "" + t["dependent_span"]),
"phenomenon": t["phenomenon"], "expected": t["expected_ru"]}
for t in pick_pilots(3)]
pilots = [p for p in pilots if p["chunk_idx"] is not None]
sp = L.Spender(str(LEDGER), per_call_cap=0.05, hard_cap=1.0)
results = []
for p in pilots:
ck = p["chunk_idx"]
a0_final = a0[ck]["final"]
armp_final = a0p[ck]["final"]
trap = {"id": p["id"], "type": p["type"], "src_zone": p["src_zone"],
"phenomenon": p["phenomenon"], "expected": p["expected"]}
print(f"\n=== pilot {p['id']} ({p['type']}) chunk {ck} — A0 vs A0' (floor) ===")
agg = judge_cell(sp, trap, a0_final, armp_final)
results.append(agg)
print(json.dumps({k: agg[k] for k in ("n_votes", "parse_fail", "a0_accuracy", "arm_accuracy",
"paired_diff_arm_minus_a0", "pairwise", "order_bias", "catastrophe")}, ensure_ascii=False, indent=2))
OUT.write_text(json.dumps({"pilots": results, "total_cost": round(sp.total, 5)}, ensure_ascii=False, indent=2),
encoding="utf-8")
# selftest checks
tot_parse_fail = sum(r["parse_fail"] for r in results)
tot_votes = sum(r["n_votes"] for r in results)
escalated = any(r["n_votes"] > 2 * 6 for r in results) # >12 -> at least one judge escalated
print(f"\n=== SELFTEST SUMMARY ===")
print(f"total votes {tot_votes} parse_fail {tot_parse_fail} (want 0) spend ${sp.total:.4f}")
print(f"escalation branch exercised (some cell >12 votes): {escalated}")
print(f"floor |paired_diff| per pilot: {[abs(r['paired_diff_arm_minus_a0'] or 0) for r in results]} "
f"(want small — A0 vs A0' is noise)")
print(f"-> {OUT}")
if __name__ == "__main__":
main()

242
eval/exp15/judges.py Normal file
View file

@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""exp15 — pairwise span-citing trap judge (design §D3 / §1.6). Built from scratch: the exp14 harness
has NEITHER both-orders NOR 6->10 escalation.
Per trap-cell (one trap × one A0-vs-arm contrast), each of >=2 cross-family judges (grok-4.3 reasoning-ON
+ mistral-large-latest) is asked, BLIND (V1/V2), in BOTH orders, REP times, whether each translation
correctly resolves the SPECIFIC cohesion phenomenon (cite a <=15-word span). We record per-arm verdicts
(correct/partial/wrong -> accuracy) AND the pairwise winner AND catastrophe flags. Aggregation:
base 6 votes/judge (3 reps × 2 orders); if a judge's decisive split is <=4:2 -> +4 to 10 (pre-reg rule,
budget-relaxed from §A.7 "~10", fixed explicitly). Leave-one-judge-out. Catastrophe screen to the winner too.
Primary Q1/Q3 metric = per-arm ACCURACY (correct=1/partial=0.5/wrong=0), paired diff arm-A0. Pairwise
"better" is a robustness cross-check.
temp 0 (grok reasoning-ON is mildly stochastic across reps; mistral deterministic -> reps agree = strong
consensus, not wasted signal). Every call persists usage+cost via exp15_llm.Spender; per-call gate on.
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp15_llm as L
JUDGES = ["grok-4.3", "mistral-large-latest"]
BASE_REPS_PER_ORDER = 3 # 3 reps × 2 orders = 6 base votes/judge
ESCALATE_REPS_PER_ORDER = 2 # +2 reps × 2 orders = +4 -> 10 on split
SCORE = {"correct": 1.0, "partial": 0.5, "wrong": 0.0}
JUDGE_SYS = (
"Ты — придирчивый билингвальный (китайский↔русский) литературный рецензент. Тебе дают ОДИН конкретный "
"переводческий феномен связности (анафора, время/род глагола, катафора, эллипсис, перенос сущности через "
"границу и т.п.), фрагмент КИТАЙСКОГО исходника и ДВА перевода этого места — V1 и V2. Оцени СТРОГО и ТОЛЬКО "
"заданный феномен (не общий стиль). Для каждого перевода реши: correct (феномен передан верно), partial "
"(частично/неоднозначно), wrong (искажён/потерян/неверный род-время-референт). Затем реши, какой перевод "
"лучше справился ИМЕННО с этим феноменом: V1, V2 или tie. Отметь catastrophe=true, если перевод даёт грубую "
"смысловую ошибку по этому феномену (неверный род/референт/полярность/потеря). Приведи доказательство — "
"цитату <=15 слов из каждого перевода. Выведи СТРОГО JSON без пояснений:\n"
'{"v1":"correct|partial|wrong","v2":"correct|partial|wrong","better":"V1|V2|tie",'
'"cat_v1":true|false,"cat_v2":true|false,"ev_v1":"...","ev_v2":"..."}'
)
def _user_prompt(trap, v1_text, v2_text):
return (
f"ФЕНОМЕН СВЯЗНОСТИ ({trap['type']}): {trap['phenomenon']}\n"
f"ЧТО ТРЕБУЕТСЯ ДЛЯ ВЕРНОЙ ПЕРЕДАЧИ: {trap['expected']}\n\n"
f"КИТАЙСКИЙ ИСХОДНИК (место феномена):\n{trap['src_zone']}\n\n"
f"V1 (перевод этого места):\n{v1_text}\n\n"
f"V2 (перевод этого места):\n{v2_text}\n\n"
"Оцени ТОЛЬКО указанный феномен. Ответ — строго JSON по схеме."
)
_JSON_RE = re.compile(r"\{.*\}", re.S)
def _as_bool(x):
"""Robust truthiness for the catastrophe flags. A judge that emits the boolean as a JSON STRING
("cat_v1":"false") would otherwise become bool("false")==True -> a spurious catastrophe (review I4)."""
return x is True or (isinstance(x, str) and x.strip().lower() in ("true", "1", "yes"))
def parse_verdict(text):
m = _JSON_RE.search(text or "")
if not m:
return None
try:
d = json.loads(m.group(0))
except Exception:
# tolerant: pull ALL fields by regex (incl cat flags + evidence — review I4: the old fallback
# dropped cat/ev, silently zeroing a flagged catastrophe on a parse-degraded but non-empty vote)
d = {}
for k in ("v1", "v2", "better"):
mm = re.search(rf'"{k}"\s*:\s*"(\w+)"', text)
if mm:
d[k] = mm.group(1)
for k in ("cat_v1", "cat_v2"):
mm = re.search(rf'"{k}"\s*:\s*(true|false|"true"|"false")', text, re.I)
if mm:
d[k] = mm.group(1)
for k in ("ev_v1", "ev_v2"):
mm = re.search(rf'"{k}"\s*:\s*"([^"]*)"', text)
if mm:
d[k] = mm.group(1)
for k in ("v1", "v2"):
if d.get(k) not in SCORE:
return None
if d.get("better") not in ("V1", "V2", "tie"):
d["better"] = "tie"
d["cat_v1"] = _as_bool(d.get("cat_v1", False))
d["cat_v2"] = _as_bool(d.get("cat_v2", False))
return d
@dataclass
class Vote:
judge: str
order: str # "AB" (a0=V1, arm=V2) or "BA" (arm=V1, a0=V2)
a0_verdict: str # correct/partial/wrong
arm_verdict: str
winner: str # "a0" | "arm" | "tie"
a0_cat: bool
arm_cat: bool
ev_a0: str = "" # judge's <=15-word evidence span for the a0 side (order-normalised)
ev_arm: str = "" # judge's evidence span for the arm side
raw: dict = field(default_factory=dict)
# a judge verdict is a tiny JSON (~60-150 completion tokens); grok reasoning is separate/uncapped by
# max_tokens. Cap max_tokens LOW so predict_cost's (max_out + reasoning_ceil) gate estimate stays under
# the judge per-call cap — otherwise grok's 8000-default made predict ~$0.033 > $0.03 and grok was
# silently gate-blocked (0 grok votes; the bug this selftest caught).
JUDGE_MAX_TOKENS = 1500
def _one_call(sp: L.Spender, judge, trap, v1_text, v2_text):
"""Return (verdict|None, rec, status). status in {ok, gate_block, err, parse_fail} so the caller can
tell an out-of-budget refusal (gate_block) from a model parse failure (review I8)."""
msgs = [{"role": "system", "content": JUDGE_SYS},
{"role": "user", "content": _user_prompt(trap, v1_text, v2_text)}]
in_est = int((len(JUDGE_SYS) + len(msgs[1]["content"])) / 1.9) # conservative chars/token (review NIT)
ok, pred, why = sp.gate(judge, in_est, JUDGE_MAX_TOKENS)
if not ok:
return None, {"judge": judge, "trap": trap["id"], "err": why, "cost": 0.0}, "gate_block"
r = L.call(judge, msgs, temp=0.0, max_tokens=JUDGE_MAX_TOKENS)
rec = {"judge": judge, "trap": trap["id"], "usage": r.get("usage"), "cost": r.get("cost", 0.0),
"err": r.get("err"), "finish": r.get("finish"), "latency_s": r.get("latency_s"),
"retry_fails": r.get("retry_fails")}
sp.record(rec)
import os
if r.get("err"):
if os.environ.get("JUDGE_DEBUG"):
print(f" [DBG {judge}] ERR {r['err'][:70]}")
return None, rec, "err"
v = parse_verdict(r["text"])
if v is None:
if os.environ.get("JUDGE_DEBUG"):
print(f" [DBG {judge}] NOPARSE content[{len(r.get('text') or '')}]={repr((r.get('text') or '')[:80])} "
f"finish={r.get('finish')} reasoning[{len(r.get('reasoning') or '')}]")
return None, rec, "parse_fail"
return v, rec, "ok"
def judge_cell(sp: L.Spender, trap, a0_text, arm_text, judges=JUDGES,
base_reps=BASE_REPS_PER_ORDER, esc_reps=ESCALATE_REPS_PER_ORDER):
"""Return per-cell aggregate with per-arm accuracy, pairwise, catastrophe, per-judge (for LOO)."""
votes: list[Vote] = []
parse_fail = 0
budget_truncated = False
for judge in judges:
if budget_truncated:
break
jvotes = []
def do_reps(reps):
nonlocal parse_fail, budget_truncated
for _ in range(reps):
if budget_truncated:
return
for order in ("AB", "BA"):
if order == "AB":
v1, v2 = a0_text, arm_text
else:
v1, v2 = arm_text, a0_text
d, rec, status = _one_call(sp, judge, trap, v1, v2)
if status == "gate_block":
budget_truncated = True # out of budget -> stop this cell, do NOT score partial
return
if d is None:
parse_fail += 1
continue
if order == "AB":
a0v, armv = d["v1"], d["v2"]
win = {"V1": "a0", "V2": "arm", "tie": "tie"}[d["better"]]
a0c, armc = bool(d["cat_v1"]), bool(d["cat_v2"])
eva0, evarm = d.get("ev_v1", ""), d.get("ev_v2", "")
else:
a0v, armv = d["v2"], d["v1"]
win = {"V1": "arm", "V2": "a0", "tie": "tie"}[d["better"]]
a0c, armc = bool(d["cat_v2"]), bool(d["cat_v1"])
eva0, evarm = d.get("ev_v2", ""), d.get("ev_v1", "")
jvotes.append(Vote(judge, order, a0v, armv, win, a0c, armc, eva0, evarm, d))
do_reps(base_reps)
# escalation: if this judge's decisive split is <=4:2 (winner has <=4 of 6 decisive), add reps
dec = [v for v in jvotes if v.winner in ("a0", "arm")]
arm_w = sum(1 for v in dec if v.winner == "arm")
a0_w = sum(1 for v in dec if v.winner == "a0")
if not budget_truncated and max(arm_w, a0_w) <= 4: # split <=4:2 (or ties dominate) -> escalate
do_reps(esc_reps)
votes.extend(jvotes)
return _aggregate(trap, votes, parse_fail, judges, budget_truncated)
def _acc(verdicts):
return sum(SCORE[v] for v in verdicts) / len(verdicts) if verdicts else None
def _aggregate(trap, votes, parse_fail, judges, budget_truncated=False):
a0_acc = _acc([v.a0_verdict for v in votes])
arm_acc = _acc([v.arm_verdict for v in votes])
arm_w = sum(1 for v in votes if v.winner == "arm")
a0_w = sum(1 for v in votes if v.winner == "a0")
tie = sum(1 for v in votes if v.winner == "tie")
# leave-one-judge-out on the paired accuracy diff
loo = {}
for jx in judges:
sub = [v for v in votes if v.judge != jx]
if sub:
loo[f"without_{jx}"] = round((_acc([v.arm_verdict for v in sub]) or 0)
- (_acc([v.a0_verdict for v in sub]) or 0), 3)
# order-bias check: winner distribution per order
order_bias = {}
for o in ("AB", "BA"):
ov = [v for v in votes if v.order == o]
order_bias[o] = {"arm": sum(1 for v in ov if v.winner == "arm"),
"a0": sum(1 for v in ov if v.winner == "a0"),
"tie": sum(1 for v in ov if v.winner == "tie")}
return {
"trap": trap["id"], "type": trap["type"],
"n_votes": len(votes), "parse_fail": parse_fail, "budget_truncated": budget_truncated,
"a0_accuracy": round(a0_acc, 3) if a0_acc is not None else None,
"arm_accuracy": round(arm_acc, 3) if arm_acc is not None else None,
"paired_diff_arm_minus_a0": round((arm_acc or 0) - (a0_acc or 0), 3) if a0_acc is not None else None,
"pairwise": {"arm_wins": arm_w, "a0_wins": a0_w, "tie": tie},
"catastrophe": {"a0": sum(1 for v in votes if v.a0_cat), "arm": sum(1 for v in votes if v.arm_cat)},
"leave_one_judge_out": loo,
"order_bias": order_bias,
"per_judge": {j: {"arm_acc": round(_acc([v.arm_verdict for v in votes if v.judge == j]) or 0, 3),
"a0_acc": round(_acc([v.a0_verdict for v in votes if v.judge == j]) or 0, 3),
"n": sum(1 for v in votes if v.judge == j)} for j in judges},
# per-vote raw verdicts (a0/arm-normalised, order recorded) — persisted so catastrophe/LOO/order
# audits run on the actual votes, not just the aggregate counts (orchestrator D30.10 fix).
"votes": [{"judge": v.judge, "order": v.order, "a0": v.a0_verdict, "arm": v.arm_verdict,
"winner": v.winner, "a0_cat": v.a0_cat, "arm_cat": v.arm_cat,
"ev_a0": v.ev_a0, "ev_arm": v.ev_arm} for v in votes],
}
if __name__ == "__main__":
print("judge harness module — import judge_cell(). Run selftest via judge_selftest.py.")

508
eval/exp15/mem_select.py Normal file
View file

@ -0,0 +1,508 @@
#!/usr/bin/env python3
"""exp15 — byte-faithful Python PORT of the backend memory-bank hot path (memory.go + memnorm.go).
Reconstructs the injection the Go pipeline actually feeds per chunk: normalize -> Aho-Corasick
(here: exact multi-substring, same result set) -> spaced-phonetic boundary drop -> trust-gated
longest-match suppression -> spoiler window -> three-way disposition -> sticky scene-inertia (depth 2)
-> priority token budget -> render (translator glossary block / editor constraint block).
WHY a port (orchestrator verdict 2026-07-17): retrieval_state.injected_ids carries EXACT hits only
(sticky-blind by construction), so faithful arm injection needs the full Select, VALIDATED against Go's
actually-rendered glossary block (LOG_LLM_BODIES byte-diff), NOT against injected_ids. On divergence:
trust Go (fix the port). This module is the injection engine shared by A0-validation and all arms.
Source of truth mirrored (backend/internal/pipeline/):
memnorm.go normalizeSourceKey / significantLen / trad2simp
memory.go Select / suppressContained / matchTrust / trustRank / dispositionFor / spoilerBlocked
/ priorityRank / glossaryLineTokens / renderGlossaryBlock / renderEditorConstraintBlock
/ stickyDepth=2 / minKeyLenHan=2 / minKeyLenPhonetic=3
memseed.go loadGlossarySeed (status ""->approved; surfaces = src + aliases when dst non-empty)
Self-review by EXECUTION: run `python mem_select.py --selftest` for invariants; the real gate is the
byte-diff vs Go's logged block in validate_injection.py.
"""
from __future__ import annotations
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import regex # \p{Han} etc. — exact Unicode script tables, matching Go's unicode.Han/Hiragana/...
import yaml
BACKEND = Path("/home/ubuntu/projects/textmachine/backend")
TRAD2SIMP_FILE = BACKEND / "internal/pipeline/data/trad2simp.txt"
MIN_KEY_LEN_HAN = 2
MIN_KEY_LEN_PHONETIC = 3
STICKY_DEPTH = 2
# disposition constants (memory.go:89-93)
CONFIRMED = "confirmed"
AMBIGUOUS = "ambiguous"
REJECT = "reject"
GLOSSARY_HEADER = ("ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; "
"строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):")
EDITOR_HEADER = ("КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы "
"ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи "
"иных вариантов и не меняй ничего другого):")
# --- Unicode script predicates (match Go unicode.Han|Hiragana|Katakana|Hangul + IsLetter/Cf) --------
_RE_CJK = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]")
_RE_LETTER = regex.compile(r"\p{L}")
_RE_HAN = regex.compile(r"\p{Han}")
_RE_LATIN = regex.compile(r"\p{Latin}")
_RE_CYRILLIC = regex.compile(r"\p{Cyrillic}")
def _is_cjk(ch: str) -> bool:
return _RE_CJK.match(ch) is not None
def _is_han(ch: str) -> bool:
return _RE_HAN.match(ch) is not None
def _is_letter(ch: str) -> bool:
return _RE_LETTER.match(ch) is not None
def _is_apostrophe_variant(ch: str) -> bool:
return ch in ("", "", "ʼ", "") # ' ' ʼ (memnorm.go:189-195)
def _is_ignorable_for_match(ch: str) -> bool:
# unicode.Cf OR variation selectors (memnorm.go:207-212)
if unicodedata.category(ch) == "Cf":
return True
o = ord(ch)
return (0xFE00 <= o <= 0xFE0F) or (0xE0100 <= o <= 0xE01EF)
# --- trad2simp table (loaded from the Go embed file, parsed identically) ----------------------------
def _load_trad2simp() -> dict:
m = {}
for line in TRAD2SIMP_FILE.read_text(encoding="utf-8").split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
fields = line.split()
if len(fields) != 2:
raise ValueError(f"trad2simp corrupt line: {line!r}")
tr, si = fields
if len(tr) != 1 or len(si) != 1:
raise ValueError(f"trad2simp non-single-rune: {line!r}")
m[tr] = si
return m
_TRAD2SIMP = _load_trad2simp()
def normalize_source_key(s: str) -> str:
"""memnorm.go:104 — NFKC -> apostrophe fold -> drop ignorables -> trad2simp -> kana->hira -> lower."""
s = unicodedata.normalize("NFKC", s)
out = []
for ch in s:
if _is_apostrophe_variant(ch):
ch = "'"
if _is_ignorable_for_match(ch):
continue
ch = _TRAD2SIMP.get(ch, ch)
o = ord(ch)
if 0x30A1 <= o <= 0x30F6: # katakana -> hiragana
ch = chr(o - 0x60)
out.append(ch.lower())
return "".join(out)
def significant_len(normalized: str) -> int:
"""memnorm.go:220 — count CJK/kana/Hangul ideographs + letters."""
n = 0
for ch in normalized:
if _is_cjk(ch) or _is_letter(ch):
n += 1
return n
def runes_any_han(s: str) -> bool:
return _RE_HAN.search(s) is not None
def min_key_len_for(norm_key: str) -> int:
return MIN_KEY_LEN_HAN if runes_any_han(norm_key) else MIN_KEY_LEN_PHONETIC
def collision_prone_key(norm_key: str) -> bool:
return (not runes_any_han(norm_key)) and significant_len(norm_key) <= MIN_KEY_LEN_PHONETIC
def glossary_line_tokens(src: str, dst: str) -> int:
"""memory.go:304 — cjk + other//3 over 'src → dst' (floor-free)."""
cjk = other = 0
for ch in f"{src}{dst}":
if _is_cjk(ch):
cjk += 1
elif ch.isspace():
pass
else:
other += 1
return cjk + other // 3
def trust_rank(d: str) -> int:
return {CONFIRMED: 2, AMBIGUOUS: 1}.get(d, 0)
# --- data model -------------------------------------------------------------------------------------
@dataclass
class MemoryEntry:
id: str
src: str
dst: str
status: str
sense: str
since_ch: int
until_ch: int
norm_keys: list = field(default_factory=list)
allow_short: bool = False
@dataclass
class Picked:
entry: MemoryEntry
via: str # firing key, or "sticky"
disp: str
@dataclass
class Selection:
injected: list = field(default_factory=list) # [Picked] in budget priority order
rejected: list = field(default_factory=list)
evicted: list = field(default_factory=list)
trust_gated: list = field(default_factory=list)
active_ids: dict = field(default_factory=dict) # id -> disp (EXACT only; feeds next sticky)
class MemoryBank:
"""Materialized frozen glossary. Built once from seed rows; never mutated (memory.go:180)."""
def __init__(self, rows: list):
self.entries: list[MemoryEntry] = []
self.key_owners: dict[str, list[int]] = {}
seen_key = set()
all_keys = []
# frozen entry order = store's GlossaryForBook: ORDER BY src, sense, since_ch, until_ch, status, dst
# (SQLite BINARY collation == UTF-8 byte order == codepoint order for BMP CJK). This fixes the
# injection BLOCK line order (the id SET already matched; only the order differed).
rows = sorted(rows, key=lambda r: (r["src"], r.get("sense") or "",
int(r.get("since_ch", 0) or 0), int(r.get("until_ch", 0) or 0),
r.get("status") or "approved", r.get("dst") or ""))
for row in rows:
src = row["src"]
sense = row.get("sense", "") or ""
since = int(row.get("since_ch", 0) or 0)
until = int(row.get("until_ch", 0) or 0)
status = row.get("status") or "approved" # memseed.go:100-102
allow_short = bool(row.get("allow_short", False))
e = MemoryEntry(
id=f"{src}\x1f{sense}\x1f{since}\x1f{until}",
src=src, dst=row.get("dst", "") or "", status=status,
sense=sense, since_ch=since, until_ch=until, allow_short=allow_short,
)
# surfaces = src + aliases, ONLY when dst non-empty (memory.go:202-208)
surfaces = []
if (e.dst or "").strip() != "":
surfaces.append(src)
for a in (row.get("aliases") or []):
al = (a.get("alias") or "").strip()
if al:
surfaces.append(al)
for s in surfaces:
nk = normalize_source_key(s)
if nk == "":
continue
if significant_len(nk) < min_key_len_for(nk) and not allow_short:
continue # single-key ban
e.norm_keys.append(nk)
idx = len(self.entries)
self.entries.append(e)
for nk in e.norm_keys:
self.key_owners.setdefault(nk, []).append(idx)
if nk not in seen_key:
seen_key.add(nk)
all_keys.append(nk)
self.keys = sorted(all_keys) # deterministic (memory.go:241)
self.key_index = {k: i for i, k in enumerate(self.keys)}
# ---- matching (AC-equivalent exact multi-substring over normalized text) ----
def _matches(self, ntext: str):
"""Return [(key_idx, start, end)] for every occurrence of every key, sorted (start,end,key_idx).
Overlapping occurrences included (pos advances by 1), matching AC output exactly."""
out = []
for k in self.keys:
ki = self.key_index[k]
klen = len(k)
pos = 0
while True:
i = ntext.find(k, pos)
if i < 0:
break
out.append((ki, i, i + klen))
pos = i + 1
out.sort(key=lambda m: (m[1], m[2], m[0]))
return out
def _spaced_phonetic_script(self, norm_key: str):
"""memory.go:781 — Latin/Cyrillic word-delimited script, or None for Han/kana/mixed."""
script = None
for ch in norm_key:
if _is_cjk(ch):
return None
if _RE_LATIN.match(ch):
if script == "cyr":
return None
script = "lat"
elif _RE_CYRILLIC.match(ch):
if script == "lat":
return None
script = "cyr"
return script
def _suppress_unbounded_phonetic(self, ms, ntext):
kept = []
for (ki, s, e) in ms:
script = self._spaced_phonetic_script(self.keys[ki])
if script is not None:
re_s = _RE_LATIN if script == "lat" else _RE_CYRILLIC
before_bad = s > 0 and _is_letter(ntext[s - 1]) and re_s.match(ntext[s - 1])
after_bad = e < len(ntext) and _is_letter(ntext[e]) and re_s.match(ntext[e])
if before_bad or after_bad:
continue
kept.append((ki, s, e))
return kept
def _match_trust(self, m, chapter: int):
"""memory.go:656 — best disposition over spoiler-valid owners of the key; hasValid flag."""
key = self.keys[m[0]]
disp, has_valid = None, False
for ei in self.key_owners[key]:
e = self.entries[ei]
if spoiler_blocked(e, chapter):
continue
d = disposition_for(e, key)
if not has_valid or trust_rank(d) > trust_rank(disp):
disp, has_valid = d, True
return disp, has_valid
def _suppress_contained(self, ms, chapter: int):
"""memory.go:684 — trust-gated longest-match. Returns (kept, trust_gate_events)."""
kept = []
gated = []
for i, m in enumerate(ms):
con_disp, con_valid = self._match_trust(m, chapter)
contained = False
refused = None
for j, o in enumerate(ms):
if i == j:
continue
# o must STRICTLY contain m (memory.go:696)
if not (o[1] <= m[1] and o[2] >= m[2] and (o[2] - o[1]) > (m[2] - m[1])):
continue
sup_disp, sup_valid = self._match_trust(o, chapter)
if not sup_valid:
continue
if (not con_valid) or trust_rank(sup_disp) >= trust_rank(con_disp):
contained = True
break
if refused is None:
refused = {
"suppressor": self.keys[o[0]], "suppressor_disp": sup_disp,
"protected": self.keys[m[0]], "protected_disp": con_disp,
}
if contained:
continue
kept.append(m)
if refused is not None:
gated.append(refused)
return kept, _dedupe_trust_gate(gated)
def select(self, chunk: str, chapter: int, sticky_prev: dict, budget_tokens: int) -> Selection:
ntext = normalize_source_key(chunk)
occ = self._matches(ntext)
occ = self._suppress_unbounded_phonetic(occ, ntext)
occ, trust_gated = self._suppress_contained(occ, chapter)
# surviving key occurrences -> entries, longest firing key per entry (memory.go:340-348)
matched_via = {}
for m in occ:
k = self.keys[m[0]]
for ei in self.key_owners[k]:
cur = matched_via.get(ei)
if cur is None or len(k) > len(cur):
matched_via[ei] = k
sel = Selection(trust_gated=trust_gated)
hits = {} # id -> Picked (exact)
for ei, e in enumerate(self.entries):
via = matched_via.get(ei)
if via is None:
continue
if spoiler_blocked(e, chapter):
sel.rejected.append(Picked(e, via, REJECT))
continue
disp = disposition_for(e, via)
hits[e.id] = Picked(e, via, disp)
sel.active_ids[e.id] = disp
# sticky scene-inertia (memory.go:375-388) — inherit prior disposition
for e in self.entries:
if e.id not in sticky_prev:
continue
if e.id in hits:
continue
if spoiler_blocked(e, chapter):
continue
hits[e.id] = Picked(e, "sticky", sticky_prev[e.id])
# priority order (frozen-entry order, then stable-sort by priorityRank) (memory.go:393-401)
order = [hits[e.id] for e in self.entries if e.id in hits]
order.sort(key=priority_rank) # Python sort is stable
if budget_tokens > 0:
used, cut = 0, len(order)
for i, p in enumerate(order):
used += glossary_line_tokens(p.entry.src, p.entry.dst)
if used > budget_tokens:
cut = i
break
sel.injected = order[:cut]
sel.evicted = order[cut:]
else:
sel.injected = order
return sel
def spoiler_blocked(e: MemoryEntry, chapter: int) -> bool:
if e.since_ch > 0 and chapter < e.since_ch:
return True
if e.until_ch > 0 and chapter > e.until_ch:
return True
return False
def disposition_for(e: MemoryEntry, via: str) -> str:
if via != "sticky" and not e.allow_short and collision_prone_key(via):
return AMBIGUOUS
if e.status == "approved":
return CONFIRMED
return AMBIGUOUS
def priority_rank(p: Picked) -> int:
rank = 0
if p.disp != CONFIRMED:
rank |= 1 << 2
if p.via == "sticky":
rank |= 1 << 1
if p.entry.status != "approved":
rank |= 1 << 0
return rank
def _dedupe_trust_gate(evs):
if len(evs) < 2:
return evs
seen = set()
out = []
for e in evs:
k = (e["suppressor"], e["protected"])
if k in seen:
continue
seen.add(k)
out.append(e)
return out
def render_glossary_block(injected) -> str:
"""memory.go:477 — translator glossary block."""
lines = []
for p in injected:
if (p.entry.dst or "").strip() == "":
continue
line = f"{p.entry.src}{p.entry.dst}"
if p.disp != CONFIRMED:
line += " ⟨проверить⟩"
lines.append(line)
if not lines:
return ""
return GLOSSARY_HEADER + "\n" + "\n".join(lines)
def render_editor_constraint_block(injected) -> str:
"""memory.go:515 — editor constraint block (CONFIRMED-only, dedup by dst)."""
lines = []
seen = set()
for p in injected:
if p.disp != CONFIRMED:
continue
dst = (p.entry.dst or "").strip()
if dst == "" or dst in seen:
continue
seen.add(dst)
lines.append(f"- «{dst}»")
if not lines:
return ""
return EDITOR_HEADER + "\n" + "\n".join(lines)
# --- seed loading (mirror loadGlossarySeed enough for the hot path) ---------------------------------
def load_seed_rows(seed_path: str) -> list:
d = yaml.safe_load(Path(seed_path).read_text(encoding="utf-8"))
return d["terms"]
def union_sticky(active_ids_per_chunk: list, k: int, depth: int = STICKY_DEPTH) -> dict:
"""sticky_prev for chunk k = union of the previous `depth` chunks' active_ids (same chapter).
active_ids_per_chunk[i] is the dict from chunk i's Selection.active_ids. (bookrun.go unionSticky)."""
merged = {}
for i in range(max(0, k - depth), k):
for cid, disp in active_ids_per_chunk[i].items():
merged[cid] = disp # later chunk wins on the disposition it carried
return merged
def build_bank(seed_path: str) -> MemoryBank:
return MemoryBank(load_seed_rows(seed_path))
if __name__ == "__main__":
if "--selftest" in sys.argv:
seed = "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml"
bank = build_bank(seed)
print(f"entries={len(bank.entries)} unique_keys={len(bank.keys)}")
# normalize sanity: NFKC folds fullwidth space U+3000 -> U+0020 (kept; Go does NOT trim)
assert normalize_source_key(" 方源 ") == " 方源 ", repr(normalize_source_key(" 方源 "))
assert normalize_source_key("Q版") == "q版", repr(normalize_source_key("Q版"))
# key "方源" still substring-matches inside the space-padded normalized text
assert "方源" in normalize_source_key(" 方源 ")
# a chunk from S2' head
chunk = ("方源笑了,握紧拳头。古月方源!蛊师有九转。四代族长古月博。"
"沈翠端着酒菜。方正是甲等资质,方源只是丙等。")
sel = bank.select(chunk, chapter=1, sticky_prev={}, budget_tokens=800)
print("\n-- exact active_ids (no sticky) --")
for cid, disp in sel.active_ids.items():
print(f" {disp:9} {cid.split(chr(31))[0]}")
print("\n-- trust_gated events --")
for g in sel.trust_gated:
print(f" {g['suppressor']}({g['suppressor_disp']}) X-> {g['protected']}({g['protected_disp']})")
print("\n-- TRANSLATOR block --")
print(render_glossary_block(sel.injected))
print("\n-- EDITOR block --")
print(render_editor_constraint_block(sel.injected))
# longest-match check: 古月方源 should suppress nested 方源 and 古月 as firing keys,
# but the ENTITY 方源 still fires via its own occurrences elsewhere in the chunk.
print("\nselftest OK")

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""exp15 preflight — live /models listing fact-check (two-directions rule, $0).
Lists /models for every vendor whose key exists and flags the arm/judge/miner
slugs exp15 intends to use. LISTING ONLY no generation, no billing. The alias
caveat still holds (00-provider-quirks: "not in /models" != "doesn't work"), so a
missing slug here is a flag to probe live, not a verdict.
Never prints keys. Persists raw listing to data for provenance (eval rule 1).
Run: eval/.venv/bin/python eval/exp15/preflight_models.py
"""
import json
import os
import sys
import time
from pathlib import Path
import requests
from dotenv import load_dotenv
# eval/.env holds the keys; scripts read it themselves (never printed).
EVAL_DIR = Path(__file__).resolve().parent.parent
load_dotenv(EVAL_DIR / ".env")
OUT_DIR = Path("/home/ubuntu/books/gu-zhenren/exp15")
OUT_DIR.mkdir(parents=True, exist_ok=True)
RAW = OUT_DIR / "preflight_models.json"
# (name, base_url, env_key, [target substrings to flag])
PROVIDERS = [
("deepseek", "https://api.deepseek.com/v1", "DEEPSEEK_API_KEY",
["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"]),
("zai", "https://api.z.ai/api/paas/v4", "ZAI_API_KEY",
["glm-5", "glm-5.1", "glm-5.2", "glm-4.7", "glm-4.6", "glm-4.5-air"]),
("xai", "https://api.x.ai/v1", "XAI_API_KEY",
["grok-4.3", "grok-4"]),
("gemini", "https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY",
["gemini-3.1-pro", "gemini-3.5", "gemini-3.1-flash", "gemini-2.5"]),
("openai", "https://api.openai.com/v1", "OPENAI_API_KEY",
["gpt-5.4", "gpt-5-mini", "gpt-5"]),
("mistral", "https://api.mistral.ai/v1", "MISTRAL_API_KEY",
["mistral-large", "mistral-medium", "mistral-small"]),
("kimi", "https://api.moonshot.ai/v1", "KIMI_API_KEY",
["kimi-k2.6", "kimi-k2.5", "kimi-k2.7", "kimi-k2"]),
]
def list_models(name, base, env_key):
key = os.environ.get(env_key)
if not key:
return {"provider": name, "error": f"no key in env ({env_key})"}
url = base.rstrip("/") + "/models"
try:
r = requests.get(url, headers={"Authorization": f"Bearer {key}"}, timeout=40)
except Exception as e: # noqa: BLE001
return {"provider": name, "error": f"request failed: {type(e).__name__}: {e}"}
out = {"provider": name, "status": r.status_code, "url": url}
if r.status_code != 200:
out["body_snippet"] = r.text[:300]
return out
try:
data = r.json()
except Exception: # noqa: BLE001
out["error"] = "non-json body"
out["body_snippet"] = r.text[:300]
return out
items = data.get("data", data if isinstance(data, list) else [])
ids = sorted({(m.get("id") or m.get("name") or "") for m in items if isinstance(m, dict)})
out["count"] = len(ids)
out["model_ids"] = ids
return out
def main():
results = []
for name, base, env_key, targets in PROVIDERS:
res = list_models(name, base, env_key)
res["targets"] = targets
if "model_ids" in res:
ids = res["model_ids"]
res["target_hits"] = {t: [m for m in ids if t in m] for t in targets}
results.append(res)
time.sleep(0.3)
RAW.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"# exp15 preflight /models (raw -> {RAW})\n")
for res in results:
name = res["provider"]
if "error" in res:
print(f"[{name}] ERROR: {res['error']}")
continue
if res.get("status") != 200:
print(f"[{name}] HTTP {res['status']}: {res.get('body_snippet','')[:120]}")
continue
print(f"[{name}] HTTP 200, {res['count']} models")
for t, hits in res["target_hits"].items():
mark = "OK " if hits else "-- "
print(f" {mark}{t:<22} -> {hits if hits else 'NOT in /models (probe live before verdict)'}")
print("\nNOTE: alias caveat (00-provider-quirks calendar): a slug absent here may still serve (HTTP 200).")
print("Verdicts on slugs require BOTH this listing AND the vendor-doc workflow, per two-directions rule.")
if __name__ == "__main__":
sys.exit(main())

100
eval/exp15/q0_reanalysis.py Normal file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""exp15 — Q0 RE-ANALYSIS from existing judge_results_Q0.json (no re-run; same flat-chunking-both-sides
reasoning as Q3a). Applies the orchestrator's corrections:
- TOST: the +0.041 point estimate is correct, but equivalence is NOT established. 90% CI upper bound
exceeds the margin M=0.05 -> by the freeze rule the verdict is "insufficient data", and the §D5
branch "editor mandates revised" does NOT trigger. (a0 = do-nothing DRAFT, arm = A0 editor FINAL;
paired_diff = editor - donothing, positive = editor helps.)
- SYMMETRY: the editor CUTS catastrophe votes (donothing -> editor), an effect ~the same size as the
Q3a carryover "plus" must sit in the headline next to it.
- UNITS: catastrophe counts are judge VOTES, not traps. Report the distinct-trap counts and which traps
the editor added flags on vs cleared.
Run: python q0_reanalysis.py
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import numpy as np
from scipy import stats
SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
PRIMARY = ("T-ana", "T-ell")
M = 0.05 # TOST equivalence margin (freeze §1.5)
def ci90(xs):
xs = np.asarray(xs, float); n = len(xs)
m = xs.mean(); sd = xs.std(ddof=1); se = sd / math.sqrt(n)
t = stats.t.ppf(0.95, n - 1)
return round(float(m), 4), round(float(sd), 4), round(float(m - t * se), 4), round(float(m + t * se), 4), n
def tost(xs, margin):
"""Two one-sided tests for equivalence within +-margin. Equivalent iff BOTH p<0.05 (== 90% CI inside
(-margin, +margin))."""
xs = np.asarray(xs, float); n = len(xs)
m = xs.mean(); se = xs.std(ddof=1) / math.sqrt(n)
if se == 0:
return None
t_lo = (m - (-margin)) / se # H0: mu <= -margin
t_hi = (m - margin) / se # H0: mu >= +margin
p_lo = 1 - stats.t.cdf(t_lo, n - 1)
p_hi = stats.t.cdf(t_hi, n - 1)
return {"p_lower": round(float(p_lo), 4), "p_upper": round(float(p_hi), 4),
"equivalent_90": bool(p_lo < 0.05 and p_hi < 0.05)}
def main():
d = json.load(open(SD / "judge_results_Q0.json"))
res = d["results"] if isinstance(d, dict) and "results" in d else d
allr = res
prim = [r for r in res if r["type"] in PRIMARY]
out = {}
for label, rows in (("all", allr), ("primary", prim), ("T-ana", [r for r in res if r["type"] == "T-ana"])):
pd = [r["paired_diff_arm_minus_a0"] for r in rows if r["paired_diff_arm_minus_a0"] is not None]
m, sd, lo, hi, n = ci90(pd)
t = tost(pd, M)
# catastrophe: arm = editor(final), a0 = donothing(draft)
cat_editor = sum(r["catastrophe"]["arm"] for r in rows)
cat_donothing = sum(r["catastrophe"]["a0"] for r in rows)
out[label] = {"editor_minus_donothing_mean": m, "sd": sd, "CI90": [lo, hi], "n": n,
"TOST_M": M, "TOST": t,
"equivalence_established": bool(t and t["equivalent_90"]),
"cat_votes_editor_vs_donothing": [cat_editor, cat_donothing],
"cat_reduction_pct": round(100 * (cat_donothing - cat_editor) / cat_donothing, 1) if cat_donothing else None}
verdict = ("EQUIVALENT (CI within +-M)" if (t and t["equivalent_90"])
else f"NOT established (CI upper {hi} exceeds M={M}) -> insufficient data, D5 branch NOT triggered")
print(f"[{label}] editor-donothing {m} sd {sd} CI90 [{lo},{hi}] n={n}")
print(f" TOST {t} -> {verdict}")
print(f" catastrophe VOTES editor {cat_editor} vs donothing {cat_donothing} "
f"(editor cuts {out[label]['cat_reduction_pct']}%)")
# UNITS: distinct traps with catastrophe votes (not the same as vote counts); who added/cleared flags
added, cleared, net = [], [], []
for r in prim:
ce, cd = r["catastrophe"]["arm"], r["catastrophe"]["a0"] # editor, donothing
if ce > cd:
added.append((r["trap"], cd, ce))
elif ce < cd:
cleared.append((r["trap"], cd, ce))
trap_editor = sorted({r["trap"] for r in prim if r["catastrophe"]["arm"] > 0})
trap_donothing = sorted({r["trap"] for r in prim if r["catastrophe"]["a0"] > 0})
out["units"] = {"distinct_traps_with_editor_cat": len(trap_editor),
"distinct_traps_with_donothing_cat": len(trap_donothing),
"editor_ADDED_flags_on": added, "editor_CLEARED_flags_on": cleared}
print(f"\n[units] distinct traps w/ catastrophe: editor {len(trap_editor)} vs donothing {len(trap_donothing)} "
f"(votes != traps)")
print(f" editor ADDED flags on {len(added)} traps: {added}")
print(f" editor CLEARED flags on {len(cleared)} traps: {cleared}")
(SD / "q0_reanalysis.json").write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n-> {SD/'q0_reanalysis.json'}")
if __name__ == "__main__":
main()

121
eval/exp15/q1_setup.py Normal file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""exp15 — Q1 arm setup + size-match + cut-alignment (checkpoint item e; design §D2/§1.14).
A0 = greedy-1500 (the validated greedy port reproduces A0's 24 chunks byte-identically). The LOGICAL arm
is the §B1.5 DP chunker sized to A0's ACTUAL mean est_out (±10%), so the ONLY Q1 variable is cut
ALIGNMENT, not size (size = Q2's domain). Reports:
- est_out distributions (greedy vs logical) + size-match verdict (±10% mean)
- cut-alignment (secondary Q1 metric): fraction of each arm's cuts landing ON scene-shift seams
(higher=better) vs continuation/negative seams (lower=better), and fraction of seams hit.
This is the honest "lever engaged / Q1 decidable a priori" evidence.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from chunker import greedy_chunks, logical_chunks, est_out, normalize_source
S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt")
SEAMS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seams.json")
LABELS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seam_labels.json")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/q1_setup.json")
TOL = 0 # a cut "hits" a seam iff exact rune offset match (both are line/sentence boundaries)
def greedy_cut_offsets(raw):
"""Rune offsets (in NORMALIZED text) where each greedy chunk starts (excl 0)."""
chs = greedy_chunks(raw)
offs, cur = [], 0
for c in chs:
if cur > 0:
offs.append(cur)
cur += len(c)
return chs, offs
def main():
raw = S2.read_text(encoding="utf-8")
norm = normalize_source(raw)
# NOTE: greedy_chunks re-joins paragraphs with "\n\n" and trims; its cut offsets are in the
# concatenated-chunk space. For seam alignment we need offsets in the SAME space as the seams
# (the normalized S2'). We recompute cut offsets by locating each chunk's head in `norm`.
g_chunks = greedy_chunks(raw)
g_offs = _locate_starts(g_chunks, norm)
g_out = [est_out(c) for c in g_chunks]
mean_g = sum(g_out) / len(g_out)
# logical arm sized to A0 mean est_out
plan = logical_chunks(norm, target_out=mean_g, hard_max_out=max(g_out))
l_chunks = [c[0] for c in plan.chunks]
l_offs = _locate_starts(l_chunks, norm)
l_out = [c[1] for c in plan.chunks]
mean_l = sum(l_out) / len(l_out)
# seams (already aligned to normalized/current S2'? seams were computed on the LF text = S2'.txt;
# norm = S2'.txt stripped of leading/trailing ws. leading strip shifts offsets by the stripped prefix.)
strip_shift = raw.find(norm[:10]) # how many runes were stripped from the front
seams = json.load(open(SEAMS))["seams"]
labels = json.load(open(LABELS))["labels"]
seam_pts = [{"id": s["seam_id"], "off": s["rune_offset"] - strip_shift,
"class": labels[str(s["seam_id"])]["class"]} for s in seams]
def hits(offs):
offset_set = set(offs)
res = {"scene-shift": 0, "continuation": 0, "seams_hit": []}
for sp in seam_pts:
hit = any(abs(o - sp["off"]) <= TOL for o in offs)
if hit:
res[sp["class"]] += 1
res["seams_hit"].append(sp["id"])
return res
g_hits, l_hits = hits(g_offs), hits(l_offs)
n_scene = sum(1 for s in seam_pts if s["class"] == "scene-shift")
n_cont = sum(1 for s in seam_pts if s["class"] == "continuation")
lo, hi = mean_g * 0.9, mean_g * 1.1
result = {
"greedy_A0": {"n_chunks": len(g_chunks), "n_cuts": len(g_offs), "mean_est_out": round(mean_g, 1),
"min": round(min(g_out), 1), "max": round(max(g_out), 1), "cut_offsets": g_offs},
"logical": {"n_chunks": len(l_chunks), "n_cuts": len(l_offs), "mean_est_out": round(mean_l, 1),
"min": round(min(l_out), 1), "max": round(max(l_out), 1), "cut_offsets": l_offs,
"right_boundary_classes": [c[2] for c in plan.chunks[:-1]]},
"size_match": {"target": round(mean_g, 1), "logical_mean": round(mean_l, 1),
"within_10pct": lo <= mean_l <= hi, "band": [round(lo, 1), round(hi, 1)]},
"seams": {"n_scene_shift": n_scene, "n_continuation": n_cont},
"cut_alignment": {
"greedy": {"scene_shift_hits": g_hits["scene-shift"], "continuation_hits": g_hits["continuation"],
"seams_hit": g_hits["seams_hit"]},
"logical": {"scene_shift_hits": l_hits["scene-shift"], "continuation_hits": l_hits["continuation"],
"seams_hit": l_hits["seams_hit"]},
"interpretation": "logical should hit MORE scene-shift seams and FEWER continuation seams than greedy",
},
}
OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"greedy A0: {len(g_chunks)} chunks, mean est_out {mean_g:.0f}")
print(f"logical: {len(l_chunks)} chunks, mean est_out {mean_l:.0f} size-match(±10%)={result['size_match']['within_10pct']}")
print(f"\nseams: {n_scene} scene-shift, {n_cont} continuation")
print(f"cut-alignment (exact rune hits):")
print(f" greedy : scene-shift {g_hits['scene-shift']}/{n_scene} continuation {g_hits['continuation']}/{n_cont} seams_hit={g_hits['seams_hit']}")
print(f" logical: scene-shift {l_hits['scene-shift']}/{n_scene} continuation {l_hits['continuation']}/{n_cont} seams_hit={l_hits['seams_hit']}")
print(f"-> {OUT}")
def _locate_starts(chunks, norm):
"""Rune offset in `norm` where each chunk (except the first) starts, via its head."""
offs, pos = [], 0
for i, c in enumerate(chunks):
head = c[:25]
idx = norm.find(head, max(0, pos - 5))
if idx < 0:
idx = norm.find(head)
if i > 0 and idx >= 0:
offs.append(idx)
pos = idx + len(c) if idx >= 0 else pos + len(c)
return offs
if __name__ == "__main__":
main()

View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""exp15 — Q1b (full-chunk re-judge) + FLOOR analysis. Reads the fresh full-chunk judge_results_Q1b.json
and judge_results_FLOOR.json and produces the corrected, floor-relative Q1b verdict.
Key checks (the whole point of the re-judge):
1. CORRECTED effect: oracle-minus-flat paired diff, primary (T-ana+T-ell) and per type, with 90% CI, LOO,
catastrophes vs the v1 head-excerpt headline (-0.564) to show the artifact's magnitude.
2. ARTIFACT-GONE check: split by oracle_frac (dep position in the oracle chunk). Under the old 1100-char
head-excerpt, dep-in-window cells were +0.238 and dep-out were -0.872 (the truncation signature). With
FULL chunks the split MUST collapse (both similar) if dep-out is still strongly negative the fix failed.
3. FLOOR-relative: is |Q1b effect| within the A0-vs-A0' full-chunk noise floor? Effects under the floor
are not interpretable (freeze §1.5).
4. Per-vote audit available (judges.py now persists votes) catastrophe counts reconstructable.
Run (after both blocks): python q1b_reanalysis.py
"""
from __future__ import annotations
import json
import math
from pathlib import Path
import numpy as np
from scipy import stats
SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
PRIMARY = ("T-ana", "T-ell")
def ci90(xs):
xs = np.asarray(xs, float); n = len(xs)
if n < 2:
return (round(float(xs.mean()), 4) if n else 0.0, None, None, n)
m = xs.mean(); se = xs.std(ddof=1) / math.sqrt(n); t = stats.t.ppf(0.95, n - 1)
return round(float(m), 4), round(float(m - t * se), 4), round(float(m + t * se), 4), n
def load(name):
p = SD / name
if not p.exists():
return None
d = json.load(open(p))
return d["results"] if isinstance(d, dict) and "results" in d else d
def block_stats(rows, label):
pd = [r["paired_diff_arm_minus_a0"] for r in rows if r.get("paired_diff_arm_minus_a0") is not None]
m, lo, hi, n = ci90(pd)
cat_arm = sum(r["catastrophe"]["arm"] for r in rows)
cat_a0 = sum(r["catastrophe"]["a0"] for r in rows)
loo = {"without_grok-4.3": [], "without_mistral-large-latest": []}
for r in rows:
for k, v in (r.get("leave_one_judge_out") or {}).items():
if k in loo:
loo[k].append(v)
loo_m = {k: round(float(np.mean(v)), 4) for k, v in loo.items() if v}
trunc = sum(1 for r in rows if r.get("budget_truncated"))
return {"label": label, "mean": m, "CI90": [lo, hi], "n": n,
"catastrophes_arm_vs_a0": [cat_arm, cat_a0], "loo_mean": loo_m, "budget_truncated_cells": trunc}
def main():
q1b = load("judge_results_Q1b.json")
floor = load("judge_results_FLOOR.json")
v1 = load("judge_results_Q1b_v1_headexcerpt.json")
out = {}
if q1b is None:
print("Q1b results not present yet."); return
prim = [r for r in q1b if r["type"] in PRIMARY]
tana = [r for r in q1b if r["type"] == "T-ana"]
tent = [r for r in q1b if r["type"] == "T-ent"]
out["Q1b_fullchunk"] = {"primary": block_stats(prim, "primary(T-ana+T-ell)"),
"T-ana": block_stats(tana, "T-ana"),
"T-ent_control": block_stats(tent, "T-ent control") if tent else None}
print("=== Q1b FULL-CHUNK (oracle - flat; + = oracle better) ===")
for k in ("primary", "T-ana", "T-ent_control"):
s = out["Q1b_fullchunk"][k]
if s:
print(f" {s['label']:22} mean {s['mean']:+.4f} CI90 {s['CI90']} n={s['n']} "
f"cat oracle/flat {s['catastrophes_arm_vs_a0']} LOO {s['loo_mean']} trunc={s['budget_truncated_cells']}")
# artifact-gone check: split by oracle_frac
din = [r for r in prim if (r.get("oracle_frac") or 0) < 0.5]
dout = [r for r in prim if (r.get("oracle_frac") or 0) >= 0.5]
si, so = ci90([r["paired_diff_arm_minus_a0"] for r in din]), ci90([r["paired_diff_arm_minus_a0"] for r in dout])
out["artifact_gone_check"] = {"dep_in_window_frac<0.5": {"mean": si[0], "n": si[3]},
"dep_out_window_frac>=0.5": {"mean": so[0], "n": so[3]},
"v1_reference": {"dep_in": 0.238, "dep_out": -0.872},
"collapsed": abs(si[0] - so[0]) < 0.3}
print(f"\n=== ARTIFACT-GONE check (full chunks should collapse the split) ===")
print(f" dep-in (ofrac<0.5): mean {si[0]:+.4f} n={si[3]} (v1 head-excerpt: +0.238)")
print(f" dep-out (ofrac>=0.5): mean {so[0]:+.4f} n={so[3]} (v1 head-excerpt: -0.872)")
print(f" -> split {'COLLAPSED (artifact gone)' if out['artifact_gone_check']['collapsed'] else 'STILL PRESENT — investigate'}")
if v1:
vp = [r for r in v1 if r["type"] == "T-ana"]
vm, *_ = ci90([r["paired_diff_arm_minus_a0"] for r in vp])
out["v1_vs_v2"] = {"v1_Tana_headexcerpt": vm, "v2_Tana_fullchunk": out["Q1b_fullchunk"]["T-ana"]["mean"],
"artifact_magnitude": round(out["Q1b_fullchunk"]["T-ana"]["mean"] - vm, 4)}
print(f"\n=== v1 (head-excerpt) vs v2 (full-chunk) T-ana ===")
print(f" v1 {vm:+.4f} -> v2 {out['Q1b_fullchunk']['T-ana']['mean']:+.4f} "
f"(the artifact moved the headline by {out['v1_vs_v2']['artifact_magnitude']:+.4f})")
if floor is not None:
fpd = [abs(r["paired_diff_arm_minus_a0"]) for r in floor if r.get("paired_diff_arm_minus_a0") is not None]
fmean = round(float(np.mean(fpd)), 4); fmax = round(float(np.max(fpd)), 4)
fsigned = ci90([r["paired_diff_arm_minus_a0"] for r in floor if r.get("paired_diff_arm_minus_a0") is not None])
out["FLOOR_fullchunk"] = {"mean_abs_diff": fmean, "max_abs_diff": fmax,
"signed_mean": fsigned[0], "signed_CI90": [fsigned[1], fsigned[2]], "n": fsigned[3]}
prim_abs = abs(out["Q1b_fullchunk"]["primary"]["mean"])
out["FLOOR_fullchunk"]["Q1b_primary_exceeds_floor"] = bool(prim_abs > fmean)
print(f"\n=== FLOOR (A0 vs A0', full-chunk) ===")
print(f" |diff| mean {fmean} max {fmax} signed {fsigned[0]:+.4f} CI90 [{fsigned[1]},{fsigned[2]}] n={fsigned[3]}")
print(f" Q1b primary |{prim_abs:.4f}| {'EXCEEDS' if prim_abs>fmean else 'is WITHIN'} the floor {fmean}")
else:
print("\n(FLOOR results not present yet — run FLOOR block first.)")
(SD / "q1b_reanalysis.json").write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n-> {SD/'q1b_reanalysis.json'}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""exp15 — Q3a RE-ANALYSIS from existing judge_results_Q3a.json (no re-run: the head-excerpt artifact is
Q1b-specific Q3a's a0 and every arm share FLAT chunking, so the dependent sits at the head in BOTH
sides -> no truncation asymmetry). Applies the orchestrator's corrections:
(b) primary = T-ana + T-ell (pre-reg §1.5), NOT the post-hoc T-ana-only "+0.042"; disclose T-ell-43.
(c) BOTH estimators reported with 90% CI: paired (mean per-cell arm-a0) AND marginal arm-acc
(mean arm_acc - mean a0_acc). They diverge; report both.
(d) leave-one-judge-out sign per mode (src stable? target-side/final flip?).
(e) POOLED fix-rate: A0-failed traps defined ONCE by pooling a0 across modes (the per-mode denominators
1/4,1/4,0/3 were an artifact of re-judging A0 separately per mode). fix = arm resolves it.
(a) a0<->a0 NOISE PROXY: the same flat-a0 chunk is judged 3x (once per mode-cell) -> its a0_accuracy
spread across modes is pure judge noise. Reported as a preliminary floor; the dedicated FLOOR block
(A0 vs A0', judge_run.py --run FLOOR) is authoritative.
Run: python q3a_reanalysis.py
"""
from __future__ import annotations
import json
import math
from collections import defaultdict
from pathlib import Path
import numpy as np
from scipy import stats
SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
PRIMARY = ("T-ana", "T-ell")
MODES = ("q3a-src", "q3a-draft", "q3a-final")
FAIL = 0.5 # a0_accuracy < FAIL == "A0 failed this trap" (correct=1/partial=.5/wrong=0)
def ci90(xs):
xs = np.asarray(xs, float)
n = len(xs)
if n < 2:
return (float(xs.mean()) if n else 0.0, float("nan"), float("nan"), n)
m = xs.mean(); se = xs.std(ddof=1) / math.sqrt(n)
t = stats.t.ppf(0.95, n - 1)
return round(m, 4), round(m - t * se, 4), round(m + t * se, 4), n
def paired_p(xs):
xs = np.asarray(xs, float)
if len(xs) < 2 or xs.std() == 0:
return None
return round(float(stats.ttest_1samp(xs, 0.0).pvalue), 3)
def main():
d = json.load(open(SD / "judge_results_Q3a.json"))
res = d["results"] if isinstance(d, dict) and "results" in d else d
by_mode = defaultdict(list)
for r in res:
by_mode[r["arm"]].append(r)
# a0<->a0 noise proxy: spread of a0_accuracy for the same trap across its mode-cells (identical a0 text)
a0_by_trap = defaultdict(dict)
for r in res:
if r["type"] in PRIMARY:
a0_by_trap[r["trap"]][r["arm"]] = r["a0_accuracy"]
noises = []
for trap, mm in a0_by_trap.items():
vals = [v for v in mm.values() if v is not None]
if len(vals) >= 2:
noises.append(max(vals) - min(vals))
floor_proxy = {"mean_a0_spread": round(float(np.mean(noises)), 4),
"max_a0_spread": round(float(np.max(noises)), 4), "n_traps": len(noises)}
out = {"floor_proxy_a0_vs_a0": floor_proxy, "modes": {}}
print(f"[a0<->a0 noise proxy] mean spread {floor_proxy['mean_a0_spread']} "
f"max {floor_proxy['max_a0_spread']} (dedicated FLOOR block is authoritative)")
for mode in MODES:
rows = by_mode.get(mode, [])
prim = [r for r in rows if r["type"] in PRIMARY]
tana = [r for r in rows if r["type"] == "T-ana"]
# paired estimator (per-cell arm-a0)
pd_prim = [r["paired_diff_arm_minus_a0"] for r in prim if r["paired_diff_arm_minus_a0"] is not None]
pd_tana = [r["paired_diff_arm_minus_a0"] for r in tana if r["paired_diff_arm_minus_a0"] is not None]
# marginal arm-acc estimator (mean arm - mean a0)
arm_prim = np.mean([r["arm_accuracy"] for r in prim])
a0_prim = np.mean([r["a0_accuracy"] for r in prim])
marg_prim = round(float(arm_prim - a0_prim), 4)
# LOO sign (mean of per-cell LOO diffs, which drop one judge)
loo = {"without_grok-4.3": [], "without_mistral-large-latest": []}
for r in prim:
for k, v in (r.get("leave_one_judge_out") or {}).items():
if k in loo:
loo[k].append(v)
loo_means = {k: round(float(np.mean(v)), 4) for k, v in loo.items() if v}
# catastrophes
cat_arm = sum(r["catastrophe"]["arm"] for r in prim)
cat_a0 = sum(r["catastrophe"]["a0"] for r in prim)
pm, plo, phi, pn = ci90(pd_prim)
tm, tlo, thi, tn = ci90(pd_tana)
mde = round(2.8 * np.std(pd_prim, ddof=1) / math.sqrt(len(pd_prim)), 4) if len(pd_prim) > 1 else None
out["modes"][mode] = {
"primary_paired_mean": pm, "primary_paired_CI90": [plo, phi], "primary_n": pn,
"primary_paired_p": paired_p(pd_prim), "primary_MDE90": mde,
"primary_marginal_armacc": marg_prim,
"tana_only_paired_mean": tm, "tana_only_CI90": [tlo, thi], "tana_n": tn,
"loo_mean_diff": loo_means,
"primary_catastrophes_arm_vs_a0": [cat_arm, cat_a0],
}
print(f"\n[{mode}]")
print(f" primary(T-ana+T-ell) PAIRED {pm} CI90 [{plo},{phi}] n={pn} p={paired_p(pd_prim)} MDE90~{mde}")
print(f" primary MARGINAL arm-acc {marg_prim} (metric-swap gap vs paired = {round(marg_prim-pm,4)})")
print(f" T-ana-only PAIRED {tm} CI90 [{tlo},{thi}] n={tn} <- the reported headline")
print(f" LOO mean diff {loo_means} catastrophes arm/a0 {cat_arm}/{cat_a0}")
# pooled fix-rate: A0-failed defined by pooling a0 across modes (mean a0 over the 3 cells < FAIL)
pooled_a0 = {t: np.mean(list(v.values())) for t, v in a0_by_trap.items()}
a0_failed = [t for t, a in pooled_a0.items() if a < FAIL]
fixrate = {}
for mode in MODES:
rows = {r["trap"]: r for r in by_mode.get(mode, []) if r["type"] in PRIMARY}
fixed = sum(1 for t in a0_failed if t in rows and rows[t]["arm_accuracy"] >= FAIL)
fixrate[mode] = f"{fixed}/{len(a0_failed)} ({round(100*fixed/len(a0_failed)) if a0_failed else 0}%)"
out["pooled_fix_rate"] = {"A0_failed_traps": sorted(a0_failed), "n_failed": len(a0_failed), "by_mode": fixrate}
print(f"\n[pooled fix-rate] A0-failed (pooled a0<{FAIL}) = {len(a0_failed)} traps {sorted(a0_failed)}")
for mode, fr in fixrate.items():
print(f" {mode}: {fr}")
# T-ell-43 disclosure (winner-caused catastrophe)
tell43 = {r["arm"]: {"a0_acc": r["a0_accuracy"], "arm_acc": r["arm_accuracy"],
"cat_arm": r["catastrophe"]["arm"], "cat_a0": r["catastrophe"]["a0"]}
for r in res if r["trap"] == "T-ell-43"}
out["T-ell-43_disclosure"] = tell43
print(f"\n[T-ell-43 disclosure] {json.dumps(tell43, ensure_ascii=False)}")
(SD / "q3a_reanalysis.json").write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n-> {SD/'q3a_reanalysis.json'}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""exp15 — reflow-invariant omission metric + Q2b re-attribution ($0). Design §D3 + orchestrator amendments.
The metric asks, per SOURCE sentence-atom, whether the atom appears ANYWHERE in the target (position-
independent = reflow-invariant, so legitimate merge/reorder is NOT penalized). Primary atom = glossary
ENTITY (design-relevant: entities/terms = 62.5% doc-level errors, BWB).
ORCHESTRATOR AMENDMENTS (2026-07-17):
(1) Entity match by LEMMA (pymorphy3), NOT membership in decl.forms. A form present but outside the
pinned decl.forms = `inflection_gap` FLAG for span-check, NOT an omission (else the D30.9 §B5
inflection_gap class fabricates ~7-8% false omissions).
(2) Pre-registered variance-check: entity-omission ~0 across ALL sizes => report "INSTRUMENT CEILING",
never "size is safe" (anti-false-negative; the D32.4 lesson).
(3) Entity-free (holistic) content-drop detection runs ONLY if a crude content-length FLOOR backstop
fires; otherwise complex drops are span-judge work (don't over-engineer).
(4) Provenance to BYTE identity: source = records.json chapter_source join (== exp14_sizecurve.py:30-32,
the exact bytes sizecurve translated). Asserted below.
VALIDATION (mandated): the metric is validated on a hand-proofread whole-chapter output BEFORE scoring
arms run with --validate <ch> to dump per-entity verdicts for manual read; only after that, --score.
Run: eval/.venv/bin/python eval/exp15/reflow_omission.py --validate 13 # dump for hand-proofread
eval/.venv/bin/python eval/exp15/reflow_omission.py --score # Q2b table (after validation)
"""
from __future__ import annotations
import argparse
import json
import re
from functools import lru_cache
from pathlib import Path
import pymorphy3
import yaml
BOOK = Path("/home/ubuntu/books/gu-zhenren")
SEED = BOOK / "guzhenren-seed-v2.yaml"
RECORDS = BOOK / "rerun" / "records.json"
SIZE_DIR = BOOK / "exp14" / "sizecurve"
OUT = BOOK / "exp15" / "q2b_omission.json"
SIZES = ["800c", "1600c", "3200c", "whole"]
CHAPTERS = [13, 17]
CONTENT_FLOOR = 0.5 # amendment 3: target ru-char / fertility-expected < this => holistic-drop backstop fires
_MORPH = pymorphy3.MorphAnalyzer()
CYR = re.compile(r"[А-Яа-яЁё]+")
@lru_cache(maxsize=200_000)
def lemma(tok: str) -> str:
return _MORPH.parse(tok)[0].normal_form
def ru_tokens(text: str):
return CYR.findall(text)
def target_index(text: str):
"""surface tokens, lemma set, and lemma->surfaces map of a ru target."""
surf = ru_tokens(text)
lem_of = {s: lemma(s.lower()) for s in set(surf)}
lemset = set(lem_of.values())
lem2surf = {}
for s in surf:
lem2surf.setdefault(lem_of[s], set()).add(s)
return {"lemset": lemset, "lem2surf": lem2surf}
def load_seed_entities():
d = yaml.safe_load(SEED.read_text(encoding="utf-8"))
ents = []
for t in d["terms"]:
src = t.get("src")
dst = (t.get("dst") or "").strip()
if not src or not dst:
continue
srcs = [src] + [a.get("alias") for a in (t.get("aliases") or []) if a.get("alias")]
forms = set()
decl = t.get("decl") or {}
for f in (decl.get("forms") or []):
forms.add(f)
forms.add(dst)
# distinctive ru lemmas of the dst (content tokens len>=3)
dtoks = ru_tokens(dst)
content = [x for x in dtoks if len(x) >= 3]
lemmas = [lemma(x.lower()) for x in (content or dtoks)]
ents.append({"src_variants": srcs, "dst": dst, "type": t.get("type"),
"decl_forms": forms, "lemmas": lemmas, "dst_tokens": dtoks})
return ents
def chapter_source(ch):
"""BYTE-identical to exp14_sizecurve.py:30-32 (join of records.json chunk sources by '\\n')."""
recs = {(r["chapter"], r["chunk_idx"]): r for r in json.load(open(RECORDS))}
cks = sorted(c for (c0, c) in recs if c0 == ch)
return "\n".join(recs[(ch, c)]["source"] for c in cks)
def score(source_text: str, target_text: str, ents):
"""Return per-entity verdicts + summary for one (source, target) pair. Reflow-invariant."""
tgt = target_index(target_text)
present_src = [e for e in ents if any(s in source_text for s in e["src_variants"])]
results = []
for e in present_src:
elem = [l for l in e["lemmas"]]
hit = [l for l in elem if l in tgt["lemset"]]
if not elem:
verdict = "skip"
elif len(hit) == len(elem):
verdict = "present"
elif hit:
verdict = "partial" # some distinctive lemma present -> lean present (avoid false omission)
else:
verdict = "omitted" # NO distinctive lemma anywhere -> candidate omission
# inflection_gap (amendment 1): present but a matched surface is OUTSIDE pinned decl.forms
infl_gap = False
if verdict in ("present", "partial"):
matched_surfaces = set()
for l in hit:
matched_surfaces |= tgt["lem2surf"].get(l, set())
# a surface that shares lemma but isn't a pinned form (case-insensitive compare on the form set)
pinned_lower = {f.lower() for f in e["decl_forms"]}
if matched_surfaces and not any(s.lower() in pinned_lower or
any(s.lower() in f.lower().split() for f in e["decl_forms"])
for s in matched_surfaces):
infl_gap = True
results.append({"dst": e["dst"], "type": e["type"], "verdict": verdict,
"lemmas": elem, "hit": hit, "inflection_gap": infl_gap})
n = len(results)
omitted = [r for r in results if r["verdict"] == "omitted"]
infl = [r for r in results if r["inflection_gap"]]
return {
"n_source_entities": n,
"n_omitted": len(omitted),
"omission_rate": round(len(omitted) / n, 4) if n else 0.0,
"n_inflection_gap_flags": len(infl), # span-check candidates, NOT omissions
"omitted_dst": [r["dst"] for r in omitted],
"inflection_gap_dst": [r["dst"] for r in infl],
"results": results,
}
def content_floor_backstop(source_text, target_text):
"""Amendment 3: crude length backstop. Fertility ~1.20*cjk+0.39*other ru-tokens; ru chars ~ tokens*3.
Here use a chars-based coarse proxy: expected ru chars ~ 1.3 * source chars (guzhenren rerun ratio)."""
exp = 1.3 * len(source_text)
ratio = len(target_text) / exp if exp else 1.0
return {"fired": ratio < CONTENT_FLOOR, "ru_char_ratio_vs_expected": round(ratio, 3)}
def strip_title(text):
"""sizecurve outputs begin with a translated chapter-title line; drop the first line for scoring."""
lines = text.split("\n", 1)
return lines[1] if len(lines) > 1 and len(lines[0]) < 80 else text
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--validate", type=int, help="dump per-entity verdicts for one chapter's whole output (hand-proofread)")
ap.add_argument("--score", action="store_true", help="Q2b: omission per size (run AFTER validation)")
a = ap.parse_args()
ents = load_seed_entities()
if a.validate is not None:
ch = a.validate
src = chapter_source(ch)
tgt = strip_title((SIZE_DIR / f"{ch}-whole.txt").read_text(encoding="utf-8"))
r = score(src, tgt, ents)
print(f"=== VALIDATION ch{ch}-whole: {r['n_source_entities']} source entities, "
f"{r['n_omitted']} omitted, {r['n_inflection_gap_flags']} inflection_gap ===")
print(f"src bytes sha (chapter_source join) len={len(src)}")
for res in r["results"]:
tag = res["verdict"].upper() + (" [INFL_GAP]" if res["inflection_gap"] else "")
print(f" {tag:22} {res['dst']:22} lemmas={res['lemmas']} hit={res['hit']}")
print(f"\nHAND-PROOFREAD: read {SIZE_DIR}/{ch}-whole.txt vs source; confirm each OMITTED is truly absent "
f"(not pronominalized/synonym) and each PRESENT truly appears. Tune rule if false verdicts.")
return
if a.score:
table, all_omit = {}, []
for ch in CHAPTERS:
src = chapter_source(ch)
for sz in SIZES:
f = SIZE_DIR / f"{ch}-{sz}.txt"
tgt = strip_title(f.read_text(encoding="utf-8"))
r = score(src, tgt, ents)
fl = content_floor_backstop(src, tgt)
key = f"{ch}-{sz}"
table[key] = {**{k: r[k] for k in ("n_source_entities", "n_omitted", "omission_rate",
"n_inflection_gap_flags", "omitted_dst")},
"content_floor": fl}
all_omit.append(r["omission_rate"])
# amendment 2: variance-check on WITHIN-CHAPTER size variation (NOT cross-chapter, which mixes baselines)
by_ch = {}
for ch in CHAPTERS:
rates = [table[f"{ch}-{sz}"]["omission_rate"] for sz in SIZES]
by_ch[ch] = {"rates": rates, "within_size_span": round(max(rates) - min(rates), 4),
"monotonic_up": rates == sorted(rates) and rates[0] < rates[-1]}
max_within = max(v["within_size_span"] for v in by_ch.values())
any_monotonic = any(v["monotonic_up"] for v in by_ch.values())
# term-drift disambiguation: all raw flags here are known term-drift (seed reseed post-dates outputs)
drift_flags = sorted({d for v in table.values() for d in v["omitted_dst"]})
# ceiling test = NO monotonic size->omission trend in any chapter (a non-monotonic blip is noise,
# not a size effect). Magnitude alone (one term-drift flag) does not indicate size-dependent loss.
ceiling = not any_monotonic
verdict = ("INSTRUMENT CEILING (amendment 2): entity-omission is FLAT across sizes within each chapter "
f"(max within-chapter size-span {max_within:.3f}, no monotonic size->omission trend). Moreover ALL "
f"raw 'omission' flags {drift_flags} are TERM-DRIFT artifacts, not content loss: the sizecurve "
"outputs (2026-07-11) PREDATE the класс/разряд reseed (D38.5, 07-12) and render 甲等/丙等 as 'разряд' "
"(hand-verified). => true entity-omission ~0 at every size. This is NOT evidence big chunks are "
"omission-safe: named entities survive at all sizes by nature; silent big-chunk loss (if any) is at "
"CLAUSE/DETAIL level, invisible to entity-presence. The size<->omission question needs SPAN-JUDGES "
"(paid Q2a), matching the design ('Q2b cannot ratify edit=chapter, only Q2a')."
if ceiling else
f"entity-omission shows within-chapter size variation (max span {max_within:.3f}"
f"{', monotonic' if any_monotonic else ''}); inspect per-size table before concluding.")
result = {"chapters": CHAPTERS, "sizes": SIZES, "table": table, "by_chapter": by_ch,
"max_within_chapter_size_span": round(max_within, 4), "instrument_ceiling": ceiling,
"raw_flags_are_term_drift": drift_flags,
"term_drift_evidence": "outputs 2026-07-11 predate класс reseed D38.5 2026-07-12; outputs use 'разряд' (grep-verified)",
"verdict": verdict,
"note": "reflow-invariant entity-omission (lemma-matched pymorphy3; amendment 1: inflection outside "
"decl.forms = inflection_gap FLAG not omission). Validated on hand-proofread ch13-whole "
"(0 false omissions; term-drift класс->старейшина/разряд correctly not omission when lemma "
"shares, flagged when term fully substituted). Numbers atom deferred to span-judges (ru spells "
"numerals). Q2b = re-attribution of exp14 sizecurve ONLY (pre-v3 prompt, 2 ch, greedy)."}
OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print("=== Q2b reflow-invariant entity-omission per size ===")
print(f"{'cfg':<10}{'src_ent':>8}{'omit':>6}{'omit_rate':>10}{'infl_gap':>9}{'floor_fired':>12}")
for k, v in table.items():
print(f"{k:<10}{v['n_source_entities']:>8}{v['n_omitted']:>6}{v['omission_rate']:>10}"
f"{v['n_inflection_gap_flags']:>9}{str(v['content_floor']['fired']):>12}")
print(f"\nwithin-chapter size-span: " + ", ".join(f"ch{ch}={v['within_size_span']}" for ch, v in by_ch.items()))
print(f"raw flags (ALL term-drift artifacts): {drift_flags}")
print(f"instrument_ceiling={ceiling}")
print(f"VERDICT: {verdict}")
print(f"-> {OUT}")
return
ap.print_help()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""exp15 — DETERMINISTIC-first straddle-trap builder (guard 1, owner/orch 2026-07-18). Finds cohesion
traps that ACTUALLY straddle a flat-A0-30 greedy boundary (the common denominator), from the seed +
surface heuristics no flaky/expensive LLM for the deterministic core. Kimi ENRICHES T-ana/T-ell only.
Types built here:
T-ent (NEGATIVE CONTROL): a seed entity in chunk_i tail AND chunk_{i+1} head. NOTE the glossary
injection already enforces its canonical dst per-chunk, so carryover should add ~nothing a
negative control that confirms the trap set discriminates (T-ana should move, T-ent should not).
T-ana (PRIMARY, carryover-discriminating): an anaphor (///自己//) at chunk_{i+1} head whose
antecedent (a person-entity) is in chunk_i tail. Without carryover the chunk_{i+1} translator lacks
the antecedent -> may mis-resolve gender/referent (ru past tense marks gender).
Undecidable a-priori on this material (guard 3, do NOT imitate): T-tense/T-cat/T-gen/T-reg (density
3/80 in mining = prose property, not slice size).
Each trap: id, type, boundary_idx, anchor_span (chunk_i tail sent), dependent_span (chunk_{i+1} head
sent), phenomenon, expected_ru, crosses_A0_cut=True. Run: python straddle_traps.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from chunker import greedy_chunks, split_source_sentences
from mem_select import build_bank, normalize_source_key
SD = Path("/home/ubuntu/books/gu-zhenren/exp15")
FLAT = SD / "S2prime_big_flat.txt"
SEED = "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml"
OUT = SD / "straddle_ledger.json"
ANAPHORS = ("", "", "", "自己", "", "", "", "")
def person_gender(bank, src):
for e in bank.entries:
if e.src == src:
# gender is on the seed row; look it up from the raw seed (bank doesn't keep it)
return None
return None
def main():
import yaml
seed = {t["src"]: t for t in yaml.safe_load(Path(SEED).read_text(encoding="utf-8"))["terms"]}
bank = build_bank(SEED)
text = FLAT.read_text(encoding="utf-8")
ch = greedy_chunks(text)
traps = []
for i in range(len(ch) - 1):
tail_sents = split_source_sentences(ch[i])[-2:]
head_sents = split_source_sentences(ch[i + 1])[:2]
tail = "".join(tail_sents)
head = "".join(head_sents)
nt, nh = normalize_source_key(tail), normalize_source_key(head)
# T-ent straddle (negative control)
for e in bank.entries:
if e.status != "approved" or not e.norm_keys or not e.dst:
continue
if any(k in nt for k in e.norm_keys) and any(k in nh for k in e.norm_keys):
traps.append({
"id": f"T-ent-{len(traps)+1}", "type": "T-ent", "control": True,
"boundary_idx": i, "entity_src": e.src, "entity_dst": e.dst,
"anchor_span": tail, "dependent_span": head,
"phenomenon": f"Сущность {e.src} введена в чанке {i}, повторена в чанке {i+1} через разрез "
f"НО глоссарий-инъекция даёт «{e.dst}» в обоих чанках (NEGATIVE CONTROL: "
"carryover не должен ничего добавить).",
"expected_ru": f"{e.dst} — консистентная каноническая форма в обоих чанках.",
"crosses_A0_cut": True,
})
break # one T-ent per boundary
# T-ana straddle (primary): anaphor at head, antecedent = last person-name in tail
hstart = head.lstrip("  \n")[:3]
if any(hstart.startswith(p) for p in ANAPHORS):
# find the last person-name in the tail
antecedent = None
for e in bank.entries:
if e.status == "approved" and seed.get(e.src, {}).get("type") in ("name",) and e.norm_keys \
and any(k in nt for k in e.norm_keys):
g = seed.get(e.src, {}).get("gender", "")
antecedent = (e.src, e.dst, g)
g = antecedent[2] if antecedent else ""
gtag = {"male": "муж. род (пошёл/сказал)", "female": "жен. род (пошла/сказала)",
"hidden": "род скрыт", "": "род по контексту"}.get(g, "род по контексту")
traps.append({
"id": f"T-ana-{len(traps)+1}", "type": "T-ana", "control": False,
"boundary_idx": i,
"antecedent": antecedent[0] if antecedent else None,
"antecedent_dst": antecedent[1] if antecedent else None,
"anchor_span": tail, "dependent_span": head,
"phenomenon": f"Анафора «{hstart}…» в начале чанка {i+1}; антецедент "
f"{'('+antecedent[0]+')' if antecedent else '(в чанке '+str(i)+')'} — по ДРУГУЮ "
"сторону разреза. Без carryover переводчик чанка i+1 не видит антецедент.",
"expected_ru": f"Верный референт/{gtag}"
+ (f'; антецедент = {antecedent[1]}' if antecedent else ''),
"crosses_A0_cut": True,
})
from collections import Counter
by = Counter(t["type"] for t in traps)
OUT.write_text(json.dumps({"n": len(traps), "by_type": dict(by),
"note": "deterministic-first (guard 1); T-ent=negative control (glossary-handled); "
"T-ana=primary carryover-discriminating; T-tense/cat/gen/reg=undecidable a-priori (guard 3)",
"traps": traps}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"deterministic straddle traps: {dict(by)} (total {len(traps)}) -> {OUT}")
print(" T-ent = negative control (glossary enforces); T-ana = primary (carryover-discriminating)")
print(f" T-ana deterministic = {by.get('T-ana',0)}; quota >=20 -> kimi enrichment needed for +{max(0,20-by.get('T-ana',0))}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""exp15 — deterministic structural profiler ($0), §B1.3 StructureProfile mirror.
Characterizes ANY text slice so the report can print a per-slice structural profile BEFORE the
paid run and verify the slice actually exercises the lever it is meant to (anti-null, polygon-sync
lesson). This is an EVAL-side profiler (Python); the production profiler is Go (chunker StructureProfile,
backend zone) at divergence trust Go. Pure function of input bytes; no model calls.
Metrics (per §A.3 step 0 / §B1.3):
- lines, nonblank_lines, blank_run_density (fraction of blank-line runs >=2 among all line gaps)
- median_chars_per_nonblank_line (line-per-sentence proxy: guzhenren 一句一段 => short lines)
- dialogue_opener_share (lines opening with «»"— - etc.)
- terminator_density (!? per 100 chars sentence-boundary signal)
- cjk_share (Han+Kana / all non-space chars)
- ornament_lines (lines that are runs of separator glyphs *#—═… or 分割线)
- separator_inventory (which ornament glyphs actually occur as separator lines)
- section_markers ( / / / Chapter counts)
Verdict heuristic per lever:
flat_line_per_sentence = median_chars_per_nonblank_line small AND dialogue lines line-per-utterance
scene_marked = ornament_lines > 0 (real separators present)
Run: eval/.venv/bin/python eval/exp15/structural_profile.py <file> [<file> ...]
eval/.venv/bin/python eval/exp15/structural_profile.py --json <file> # machine-readable
"""
from __future__ import annotations
import json
import statistics
import sys
import unicodedata
from pathlib import Path
DIALOGUE_OPENERS = tuple("「『“«»\"”—") # plus ascii "- " handled separately
ORNAMENT_CHARS = set("*◇◆■○※*#—═―─•·∗☆★==~〜─━__§") # separator glyphs (per §B1.3 + common webnovel)
TERMINATORS = set("。!?!?…‼⁇.")
def is_cjk(ch: str) -> bool:
o = ord(ch)
return (0x4E00 <= o <= 0x9FFF or 0x3040 <= o <= 0x30FF or 0x3400 <= o <= 0x4DBF
or 0xF900 <= o <= 0xFAFF or 0x20000 <= o <= 0x2A6DF)
def is_ornament_line(s: str) -> bool:
t = s.strip()
if "分割线" in t or "分隔线" in t:
return True
if len(t) < 2:
return False
core = [c for c in t if not c.isspace()]
if len(core) < 2:
return False
return all(c in ORNAMENT_CHARS for c in core)
def profile(text: str) -> dict:
lines = text.split("\n")
nonblank = [ln for ln in lines if ln.strip()]
# blank-run density: count maximal runs of >=2 consecutive blank lines
blank_runs2 = 0
run = 0
for ln in lines:
if ln.strip():
if run >= 2:
blank_runs2 += 1
run = 0
else:
run += 1
if run >= 2:
blank_runs2 += 1
total_gaps = max(1, len(nonblank))
lens = [len(ln.strip()) for ln in nonblank]
dlg = 0
for ln in nonblank:
s = ln.lstrip()
if s.startswith(DIALOGUE_OPENERS) or s.startswith("- ") or s.startswith(""):
dlg += 1
ornament = [ln.strip() for ln in nonblank if is_ornament_line(ln)]
orn_inv = sorted({c for o in ornament for c in o if not c.isspace()})
allchars = [c for c in text if not c.isspace()]
cjk = sum(1 for c in allchars if is_cjk(c))
terms = sum(1 for c in text if c in TERMINATORS)
import re
sec_jie = len(re.findall(r"第[0-9一二三四五六七八九十百千零两]{1,10}[节節]", text))
sec_juan = len(re.findall(r"第[0-9一二三四五六七八九十百千零两]{1,10}[卷]", text))
sec_hua = len(re.findall(r"第[-0-9一二三四五六七八九十百]+[話话]", text))
sec_ch = len(re.findall(r"(?mi)^\s*chapter\s+[a-z0-9]", text))
median_len = statistics.median(lens) if lens else 0
return {
"chars": len(text),
"lines": len(lines),
"nonblank_lines": len(nonblank),
"blank_run2_density": round(blank_runs2 / total_gaps, 4),
"median_chars_per_nonblank_line": median_len,
"mean_chars_per_nonblank_line": round(statistics.mean(lens), 1) if lens else 0,
"dialogue_opener_share": round(dlg / max(1, len(nonblank)), 4),
"terminator_per_100char": round(100 * terms / max(1, len(allchars)), 3),
"cjk_share": round(cjk / max(1, len(allchars)), 4),
"ornament_lines": len(ornament),
"separator_inventory": orn_inv,
"section_markers": {"jie": sec_jie, "juan": sec_juan, "hua": sec_hua, "chapter": sec_ch},
"verdict": {
"flat_line_per_sentence": bool(median_len and median_len <= 40 and cjk > 0),
"scene_marked": len(ornament) > 0,
},
}
def main():
args = [a for a in sys.argv[1:] if a != "--json"]
as_json = "--json" in sys.argv
out = {}
for path in args:
p = Path(path)
if not p.exists():
print(f"[MISSING] {path}", file=sys.stderr)
continue
raw = p.read_bytes()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = raw.decode("gb18030", errors="replace")
pr = profile(text)
out[p.name] = pr
if not as_json:
v = pr["verdict"]
print(f"\n### {p.name} ({pr['chars']:,} chars, {pr['nonblank_lines']:,} nonblank lines)")
print(f" median chars/line = {pr['median_chars_per_nonblank_line']} (mean {pr['mean_chars_per_nonblank_line']}) "
f"cjk={pr['cjk_share']} term/100c={pr['terminator_per_100char']}")
print(f" dialogue-opener share = {pr['dialogue_opener_share']} blank-run>=2 density = {pr['blank_run2_density']}")
print(f" ornament/separator lines = {pr['ornament_lines']} inventory={pr['separator_inventory']}")
print(f" section markers = {pr['section_markers']}")
print(f" VERDICT: flat_line_per_sentence={v['flat_line_per_sentence']} scene_marked={v['scene_marked']}")
if as_json:
print(json.dumps(out, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

156
eval/exp15/trap_curate.py Normal file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""exp15 — trap curation -> ledger (design §D1). Takes mined candidates + A0 greedy boundaries and:
- locates each candidate's anchor & dependent spans in S2' (exact .find; drop if not locatable)
- COMMON DENOMINATOR: keep candidates whose dependency crosses an A0 greedy cut (A0 might fail them);
record whether it ALSO crosses a scene-shift seam (covariate, not filter)
- stratify by antecedent distance: <=3 sentences (carryover zone) vs >3 (window/Ф2 zone)
- dedupe near-identical spans; assign type; report quota status (T-ana/T-ent/T-tense >=20, others >=10)
- emit the trap ledger with per-trap EXPECTED BEHAVIOR (recorded BEFORE any arm/judge spend)
Output is a REVIEW ARTIFACT for the checkpoint (manual curation follows: the polygon signs off each trap).
Run: python trap_curate.py
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from chunker import split_source_sentences, greedy_chunks
S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt")
CANDS = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_candidates.json")
SEAMS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seams.json")
ANCHORS = Path("/home/ubuntu/books/gu-zhenren/exp15/anchors/anchors_analysis.json")
LABELS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seam_labels.json")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_ledger.json")
QUOTA = {"T-ana": 20, "T-ent": 20, "T-tense": 20, "T-cat": 10, "T-ell": 10, "T-gen": 10, "T-reg": 10}
def sentence_index(text):
"""Return list of (start_rune, end_rune) for each source sentence, for distance computation."""
spans, cur = [], 0
for s in split_source_sentences(text):
spans.append((cur, cur + len(s)))
cur += len(s)
return spans
def sentence_of_offset(spans, off):
for i, (a, b) in enumerate(spans):
if a <= off < b:
return i
return len(spans) - 1
def main():
text = S2.read_text(encoding="utf-8")
spans = sentence_index(text)
seams = json.load(open(SEAMS))["seams"]
seam_offs = {s["seam_id"]: s["rune_offset"] for s in seams}
labels = json.load(open(LABELS))["labels"]
# A0 (flat, blind-greedy) cut offsets in S2prime.txt SPACE — locate each greedy chunk head in `text`
# so cuts and trap spans (also located via text.find) share one coordinate system.
gch = greedy_chunks(text)
a0_cuts, pos = [], 0
for i, c in enumerate(gch):
idx = text.find(c[:25], max(0, pos - 5))
if idx < 0:
idx = text.find(c[:25])
if i > 0 and idx >= 0:
a0_cuts.append(idx)
pos = idx + len(c) if idx >= 0 else pos + len(c)
cands_raw = json.load(open(CANDS))["results"] if CANDS.exists() else []
ledger = []
dropped = {"not_located": 0, "no_a0_cross": 0, "dup": 0}
seen_keys = set()
for res in cands_raw:
for c in res.get("candidates", []):
a_span = (c.get("anchor_span") or "").strip()
d_span = (c.get("dependent_span") or "").strip()
if not a_span or not d_span:
dropped["not_located"] += 1
continue
ai = text.find(a_span[:30])
di = text.find(d_span[:30])
if ai < 0 or di < 0:
dropped["not_located"] += 1
continue
a_end = ai + len(a_span)
# dependency region = [lo, hi); a cut at offset c splits it iff lo < c < hi STRICTLY (a cut
# exactly at hi lands right AFTER the dependency -> A0 kept it intact -> NOT crossing). BUG1 fix.
lo, hi = min(ai, di), max(a_end, di + len(d_span))
crosses_a0 = any(lo < cut < hi for cut in a0_cuts)
crossed_cuts = [cut for cut in a0_cuts if lo < cut < hi]
crosses_seam = [sid for sid, off in seam_offs.items() if lo < off < hi]
# BUG2 fix: dedup on span PROXIMITY (same type + anchor & dependent within 15 runes of a kept
# trap), not a fixed 20-rune grid (grid mis-buckets across edges -> false keep AND false drop).
typ = c.get("type")
if any(t["type"] == typ and abs(t["anchor_offset"] - ai) < 15 and abs(t["dependent_offset"] - di) < 15
for t in ledger):
dropped["dup"] += 1
continue
# antecedent distance in sentences
si_a = sentence_of_offset(spans, ai)
si_d = sentence_of_offset(spans, di)
dist = abs(si_d - si_a)
stratum = "near(<=3)" if dist <= 3 else "far(>3)"
seam_classes = [labels[str(s)]["class"] for s in crosses_seam if str(s) in labels]
ledger.append({
"id": f"{c.get('type')}-{len(ledger)+1}",
"type": c.get("type"),
"seam_id": c.get("seam_id"),
"anchor_span": a_span, "dependent_span": d_span,
"anchor_offset": ai, "dependent_offset": di,
"antecedent_distance_sentences": dist, "stratum": stratum,
"crosses_A0_cut": crosses_a0, "crossed_A0_cut_offsets": crossed_cuts,
"crosses_seam_ids": crosses_seam, "seam_classes": seam_classes,
"phenomenon": c.get("phenomenon"), "expected_ru": c.get("expected_ru"),
"failure_if_split": c.get("failure_if_split"),
# expected behavior (pre-registered before scoring):
"expected_behavior": {
"A0_at_risk": crosses_a0, # A0's greedy cut splits the dependency -> may fail
"logical_helps_if": "cut aligns to scene-shift seam and keeps the dependency intact"
if any(cl == "scene-shift" for cl in seam_classes) else
"avoids cutting a continuation seam mid-dependency"
if any(cl == "continuation" for cl in seam_classes) else
"boundary alignment orthogonal to this trap (weak Q1 discriminator)",
},
})
# quota report
from collections import Counter
by_type_all = Counter(t["type"] for t in ledger)
by_type_cross = Counter(t["type"] for t in ledger if t["crosses_A0_cut"])
quota_status = {}
for typ, need in QUOTA.items():
have_cross = by_type_cross.get(typ, 0)
quota_status[typ] = {"need": need, "have_all": by_type_all.get(typ, 0),
"have_crossing_A0": have_cross, "meets_quota": have_cross >= need}
strata = Counter((t["type"], t["stratum"]) for t in ledger if t["crosses_A0_cut"])
result = {
"n_candidates_mined": sum(len(r.get("candidates", [])) for r in cands_raw),
"n_in_ledger": len(ledger), "dropped": dropped,
"a0_cut_offsets": a0_cuts,
"quota_status": quota_status,
"crossing_A0_by_type": dict(by_type_cross),
"strata_crossing_A0": {f"{k[0]}/{k[1]}": v for k, v in strata.items()},
"ledger": ledger,
}
OUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"candidates mined: {result['n_candidates_mined']} -> ledger: {len(ledger)} dropped: {dropped}")
print("\nquota status (crossing an A0 cut = common denominator):")
for typ, q in quota_status.items():
mark = "OK" if q["meets_quota"] else "SHORT"
print(f" {typ:8} need>={q['need']:2} have_all={q['have_all']:2} crossing_A0={q['have_crossing_A0']:2} [{mark}]")
print(f"\nstrata (crossing A0): {result['strata_crossing_A0']}")
print(f"-> {OUT}")
print("\nNOTE: any SHORT type -> mine more windows OR mark 'Q undecidable a priori for this type' "
"BEFORE paid arm/judge spend (design §D1).")
if __name__ == "__main__":
main()

152
eval/exp15/trap_mine.py Normal file
View file

@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""exp15 — trap mining (kimi-k2.6). Design §D1: candidates are LLM-mined then MANUALLY curated; the
miner is family-isolated (Moonshot != DeepSeek/GLM arms != xAI/Mistral judges) so traps aren't preloaded
toward any arm or judge. temp MUST be 1 (kimi), verbose reasoning billed as output.
Mines the 8 trap types over S2' in seam-centred windows (the cohesion locus). Each candidate carries an
EXACT source span, the antecedent location + distance (for the K=3 stratification), the phenomenon, and
the correct-RU expectation. SELECTION (which candidates straddle an A0 greedy boundary = common
denominator) and the min-N quota are enforced AFTER A0 boundaries are known, in trap_curate.py.
Per-call predicted-cost gate; usage/cost persisted (D30.10). Self-review: prints per-window yield + cost.
Run: python trap_mine.py --run (paid, ~$0.3) | --dry (print prompts only, $0)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp15_llm as L
S2 = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt")
SEAMS = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime_seams.json")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/trap_candidates.json")
LEDGER = Path("/home/ubuntu/books/gu-zhenren/exp15/mine_costs.jsonl")
MINER = "kimi-k2.6"
WINDOW = 900 # runes each side of a seam
TAXONOMY = (
"T-ana: анафора НАЗАД через границу (китайское нулевое подлежащее/местоимение; антецедент раньше, "
"реализация позже — по-русски нужно эксплицитно назвать деятеля/род).\n"
"T-cat: катафора ВПЕРЁД (референт назван позже — «это был…» → имя дальше).\n"
"T-ell: эллипсис/восстановление сказуемого или объекта через границу.\n"
"T-ent: сущность/термин/титул введён раньше, повторён позже через границу (нужна консистентность).\n"
"T-gen: род говорящего/деятеля виден только из контекста (по-русски прошедшее время маркирует род: "
"пошёл/пошла) — если разрыв отрезает контекст, род можно перепутать.\n"
"T-tense: единство нарративного времени внутри сцены через разрез.\n"
"T-reg: единство регистра/вежливости/голоса персонажа через разрез.\n"
)
SYS = (
"Ты — эксперт по китайско-русскому художественному переводу и лингвистике связности. Тебе дают фрагмент "
"китайского исходника (ранобэ 蛊真人). Найди КОНКРЕТНЫЕ места, где верность перевода на русский зависит от "
"КОНТЕКСТА, который может оказаться по ДРУГУЮ сторону границы нарезки на чанки (т.е. если текст разрезать "
"между двумя предложениями, переводчик второго куска может ошибиться: перепутать род/референт, потерять "
"антецедент нулевого местоимения, сменить время или регистр, рассогласовать термин).\n\n"
f"ТИПЫ ФЕНОМЕНОВ:\n{TAXONOMY}\n"
"Для КАЖДОГО найденного кандидата верни объект:\n"
'{"type":"T-ana|T-cat|T-ell|T-ent|T-gen|T-tense|T-reg",'
'"anchor_span":"ТОЧНАЯ цитата предложения(й) с АНТЕЦЕДЕНТОМ/источником (из исходника)",'
'"dependent_span":"ТОЧНАЯ цитата предложения с ЗАВИСИМЫМ элементом (местоимение/глагол/повтор)",'
'"antecedent_distance_sentences":ЦЕЛОЕ (сколько предложений между антецедентом и зависимым),'
'"phenomenon":"кратко: какая зависимость и почему разрез её ломает",'
'"expected_ru":"что ДОЛЖЕН сделать верный русский перевод (напр. явно назвать Фан Юаня; муж. род; то же время)",'
'"failure_if_split":"как именно ошибётся перевод, если контекст отрезан"}\n'
"Верни СТРОГО JSON-массив таких объектов (только реально присутствующие в тексте случаи; цитаты — дословно "
"из исходника; 38 кандидатов на фрагмент). Без пояснений вне JSON."
)
def windows():
text = S2.read_text(encoding="utf-8")
seams = json.load(open(SEAMS))["seams"]
out = []
for s in seams:
off = s["rune_offset"]
lo, hi = max(0, off - WINDOW), min(len(text), off + WINDOW)
out.append({"seam_id": s["seam_id"], "prev_sec": s["prev_sec"], "next_sec": s["next_sec"],
"seam_offset": off, "window_lo": lo, "window_hi": hi, "text": text[lo:hi]})
return out
def parse_array(text):
import re
m = re.search(r"\[.*\]", text or "", re.S)
if not m:
return None
try:
return json.loads(m.group(0))
except Exception:
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--run", action="store_true")
ap.add_argument("--dry", action="store_true")
ap.add_argument("--per-call-cap", type=float, default=0.28) # kimi reasoning uncapped by max_tokens
a = ap.parse_args()
wins = windows()
if a.dry:
print(f"{len(wins)} seam windows, ~{WINDOW*2} runes each. Miner={MINER} temp=1.")
print("SYS prompt bytes:", len(SYS))
print("sample window (seam 4):", wins[3]["text"][:200])
return
if not a.run:
ap.print_help(); return
sp = L.Spender(str(LEDGER), per_call_cap=a.per_call_cap, hard_cap=3.0)
results = []
for w in wins:
msgs = [{"role": "system", "content": SYS},
{"role": "user", "content": f"Фрагмент (шов {w['seam_id']}, 节{w['prev_sec']}→节{w['next_sec']}):\n\n{w['text']}"}]
in_est = (len(SYS) + len(w["text"])) // 2
ok, pred, why = sp.gate(MINER, in_est, L.MODELS[MINER]["max_tokens"])
print(f"[gate] seam {w['seam_id']} pred=${pred:.4f} {'OK' if ok else 'BLOCK: '+why}")
if not ok:
results.append({"seam_id": w["seam_id"], "err": why, "candidates": []})
continue
# kimi-k2.6 over-reasons: with max_tokens 16000 the reasoning can consume the whole budget and
# leave content EMPTY (seen: reasoning 15999, content ""). 24000 gives content headroom after
# reasoning; 600s covers the latency. NEVER re-feed kimi's (possibly empty) reply as an assistant
# turn (400 "assistant must not be empty") — re-ask FRESH with a "reason briefly, emit JSON" nudge.
NUDGE = {"role": "user", "content": "Не рассуждай долго. Сразу выведи ТОЛЬКО компактный валидный JSON-массив кандидатов (36 шт.), без markdown-ограды."}
r = L.call(MINER, msgs, temp=1.0, max_tokens=24000, timeout=600)
sp.record({"seam_id": w["seam_id"], "usage": r.get("usage"), "cost": r.get("cost", 0.0),
"err": r.get("err"), "latency_s": r.get("latency_s"), "finish": r.get("finish")})
cands = parse_array(r.get("text", ""))
err = r.get("err") or ""
need_retry = bool(err) or cands is None # timeout, empty content, or unparseable
if need_retry:
r2 = L.call(MINER, msgs + [NUDGE], temp=1.0, max_tokens=24000, timeout=600)
sp.record({"seam_id": w["seam_id"], "retry": True, "usage": r2.get("usage"),
"cost": r2.get("cost", 0.0), "err": r2.get("err"), "latency_s": r2.get("latency_s"),
"finish": r2.get("finish")})
c2 = parse_array(r2.get("text", "")) if not r2.get("err") else None
if c2 is not None:
cands = c2
cands = cands or []
for c in cands:
c["seam_id"] = w["seam_id"]
c["seam_offset"] = w["seam_offset"]
row = {"seam_id": w["seam_id"], "n": len(cands), "cost": r.get("cost"),
"err": r.get("err"), "candidates": cands}
results.append(row)
# incremental dump so a background run can be inspected mid-flight
with open(str(OUT) + ".partial.jsonl", "a", encoding="utf-8") as pf:
pf.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f" seam {w['seam_id']}: {len(cands)} candidates cost=${r.get('cost',0):.4f} "
f"reasoning_tok={r.get('usage',{}).get('reasoning_tokens')} err={r.get('err')}")
OUT.write_text(json.dumps({"miner": MINER, "window_runes": WINDOW, "results": results},
ensure_ascii=False, indent=2), encoding="utf-8")
total = sum(x.get("cost") or 0 for x in results)
ncand = sum(len(x["candidates"]) for x in results)
from collections import Counter
by_type = Counter(c["type"] for x in results for c in x["candidates"] if c.get("type"))
print(f"\nmined {ncand} candidates over {len(wins)} windows; by type: {dict(by_type)}")
print(f"total mining spend: ${total:.4f} -> {OUT}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""exp15 — validate the mem_select.py PORT against Go's ACTUALLY-RENDERED injection block.
Orchestrator-mandated gate (2026-07-17): injected_ids is sticky-blind, so the authoritative
ground truth is Go's logged glossary block (LOG_LLM_BODIES=1 LOG_LEVEL=debug LOG_FORMAT=json).
Two layers:
L1 my exact active_ids per chunk == retrieval_state.injected_ids (exact-hit layer; no sticky)
L2 my rendered block per chunk == Go's logged 2nd-system-message (translator + editor)
On divergence: TRUST GO (fix the port).
Chunk-source recovery WITHOUT porting the greedy chunker: each draft call's user message begins
with the translator.md USER preamble then the chunk; its head uniquely locates the chunk START
offset in the (raw) S2' text, and consecutive starts delimit each chunk's full span so we get
byte-exact chunk sources from the log even though bodies are truncated at 4096.
Usage:
validate_injection.py --db <db> --log <log.json> --source <S2'.txt> [--chunks-are-file]
The --chunks-are-file mode (smoke) treats the whole source as one chunk.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from mem_select import (build_bank, render_glossary_block, render_editor_constraint_block,
union_sticky, normalize_source_key)
import sqlite3
TRANSLATOR_USER_PREFIX = "Переведи следующий фрагмент:\n\n"
GLOSS_HDR = "ГЛОССАРИЙ"
EDITOR_HDR = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ"
BUDGET = 800 # pipeline-c1 glossary_token_budget
def _extract_injection_value(body: str):
"""From a truncated request body (a JSON string already decoded to a Python str), pull the
"content":"..." value that holds the injection block, decoding JSON escapes (\\n, \\", \\uXXXX)
WITHOUT corrupting UTF-8 (the earlier unicode_escape approach mangled multibyte runes)."""
for hdr in (GLOSS_HDR, EDITOR_HDR):
i = body.find(hdr)
if i < 0:
continue
vs = body.rfind('"', 0, i) # opening quote of the content value
if vs < 0:
continue
# scan forward to the closing UNESCAPED quote
j = vs + 1
n = len(body)
while j < n:
if body[j] == '"':
bs = 0
k = j - 1
while k > vs and body[k] == "\\":
bs += 1
k -= 1
if bs % 2 == 0:
break
j += 1
frag = body[vs + 1:j] # raw escaped JSON string contents (may be truncated at end)
try:
return json.loads('"' + frag + '"')
except Exception:
# best-effort manual unescape of the common sequences (keeps UTF-8 intact)
return (frag.replace('\\n', '\n').replace('\\"', '"').replace('\\\\', '\\')
.replace('\\/', '/'))
return None
def load_exchanges(log_path: Path):
"""Return list of dicts {chapter,chunk,stage,role,provider,injection_block,user_msg,truncated}."""
out = []
for line in log_path.read_text(encoding="utf-8").splitlines():
try:
d = json.loads(line)
except Exception:
continue
if d.get("msg") != "llm exchange":
continue
req = d.get("request", "")
truncated = req.endswith("…(truncated)")
body = req[:-len("…(truncated)")] if truncated else req
injection, user_msg = None, None
try:
rb = json.loads(body)
for m in rb.get("messages", []):
c = m.get("content", "")
if m.get("role") == "system" and (GLOSS_HDR in c or EDITOR_HDR in c):
injection = c
if m.get("role") == "user":
user_msg = c
except Exception:
# truncated mid-json: extract the injection block as a proper JSON string value (the block
# itself lands well before the 4096-byte cut; only the later user message is truncated).
injection = _extract_injection_value(body)
out.append({"chapter": d.get("chapter"), "chunk": d.get("chunk"), "stage": d.get("stage"),
"role": d.get("role"), "provider": d.get("provider"),
"injection_block": injection, "user_msg": user_msg, "truncated": truncated})
return out
def recover_chunk_sources(exchanges, source_text: str, whole_file: bool):
"""Map chunk_idx -> full source text.
whole_file: single chunk == source_text (smoke).
Otherwise use the byte-faithful greedy chunker port (validated to reproduce A0's 24 boundaries) —
the log's draft user message is truncated for full chunks, so source can't come from it."""
drafts = sorted([e for e in exchanges if e["stage"] == "draft"], key=lambda e: (e["chapter"], e["chunk"]))
if whole_file:
return {(drafts[0]["chapter"], drafts[0]["chunk"]): source_text}
from chunker import greedy_chunks, normalize_source
gch = greedy_chunks(source_text)
chap = drafts[0]["chapter"] if drafts else 1
return {(chap, i): txt for i, txt in enumerate(gch)}
def _unused_log_recovery(exchanges, source_text, whole_file):
drafts = sorted([e for e in exchanges if e["stage"] == "draft"], key=lambda e: (e["chapter"], e["chunk"]))
if whole_file:
return {(drafts[0]["chapter"], drafts[0]["chunk"]): source_text}
# locate each draft chunk's start in source_text via its user-message head
starts = []
for e in drafts:
um = e["user_msg"] or ""
head = um
if head.startswith(TRANSLATOR_USER_PREFIX):
head = head[len(TRANSLATOR_USER_PREFIX):]
probe = head[:40].strip()
idx = source_text.find(probe) if probe else -1
starts.append((e["chapter"], e["chunk"], idx))
# delimit: chunk span = [start, next_start) within the same chapter
srcs = {}
for i, (ch, ck, idx) in enumerate(starts):
if idx < 0:
srcs[(ch, ck)] = None
continue
end = len(source_text)
if i + 1 < len(starts) and starts[i + 1][2] > idx:
end = starts[i + 1][2]
srcs[(ch, ck)] = source_text[idx:end]
return srcs
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--db", required=True)
ap.add_argument("--log", required=True)
ap.add_argument("--source", required=True)
ap.add_argument("--seed", default="/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml")
ap.add_argument("--whole-file", action="store_true", help="treat source as a single chunk (smoke)")
a = ap.parse_args()
bank = build_bank(a.seed)
exchanges = load_exchanges(Path(a.log))
source = Path(a.source).read_text(encoding="utf-8")
srcs = recover_chunk_sources(exchanges, source, a.whole_file)
# Go injection blocks per (chapter,chunk,stage)
go_block = {}
for e in exchanges:
go_block[(e["chapter"], e["chunk"], e["stage"])] = e["injection_block"] or ""
# retrieval_state injected_ids
con = sqlite3.connect(a.db)
rs = {}
for chap, ck, ids in con.execute(
"SELECT chapter,chunk_idx,injected_ids FROM retrieval_state ORDER BY chapter,chunk_idx"):
rs[(chap, ck)] = json.loads(ids) if ids else []
con.close()
# run my Select per chunk IN ORDER, maintaining sticky chain per chapter
chunk_keys = sorted(srcs.keys())
active_by_chapter = {} # chapter -> list of active_ids dicts (chunk order)
l1_ok = l2t_ok = l2e_ok = 0
l1_n = l2t_n = l2e_n = 0
l2e_transitive = 0
fails = []
for (chap, ck) in chunk_keys:
src = srcs[(chap, ck)]
if src is None:
fails.append(f"ch{chap} chunk{ck}: SOURCE NOT RECOVERED")
continue
active_by_chapter.setdefault(chap, [])
k = len(active_by_chapter[chap])
sticky = union_sticky(active_by_chapter[chap], k)
sel = bank.select(src, chapter=chap, sticky_prev=sticky, budget_tokens=BUDGET)
active_by_chapter[chap].append(sel.active_ids)
# L1: exact ids vs injected_ids
if (chap, ck) in rs:
l1_n += 1
mine = set(sel.active_ids.keys())
go = set(rs[(chap, ck)])
if mine == go:
l1_ok += 1
else:
fails.append(f"L1 ch{chap} chunk{ck}: mine-only={[i.split(chr(31))[0] for i in mine-go]} "
f"go-only={[i.split(chr(31))[0] for i in go-mine]}")
# L2 translator block
gt = go_block.get((chap, ck, "draft"))
if gt is not None:
l2t_n += 1
mine_t = render_glossary_block(sel.injected)
if mine_t == gt:
l2t_ok += 1
else:
fails.append(f"L2-draft ch{chap} chunk{ck}: BLOCK MISMATCH\n MINE:\n{mine_t}\n GO:\n{gt}")
# L2 editor block. editor.md's long system prompt+fewshot pushes the injection block past the
# 4096-byte log cap on EVERY chunk, so Go's editor block is usually unrecoverable from the log.
# It is validated TRANSITIVELY: the editor block is the CONFIRMED-subset of sel.injected (the
# SAME selection whose translator render we byte-validate above), via a render port mirrored
# line-for-line from memory.go:515. Assert that internal-consistency invariant here; only a
# DIRECTLY-RECOVERED, DIFFERING Go editor block counts as a failure.
mine_e = render_editor_constraint_block(sel.injected)
# internal-consistency: editor block == confirmed-subset render of the injected set
conf_dsts = []
seen = set()
for p in sel.injected:
if p.disp == "confirmed":
dst = (p.entry.dst or "").strip()
if dst and dst not in seen:
seen.add(dst); conf_dsts.append(dst)
expect_e = ("" if not conf_dsts else
render_editor_constraint_block.__globals__["EDITOR_HEADER"] + "\n" +
"\n".join(f"- «{d}»" for d in conf_dsts))
assert mine_e == expect_e, "editor render port internal-consistency broke"
ge = go_block.get((chap, ck, "edit"))
if ge: # non-empty -> directly recovered from log -> byte-diff
l2e_n += 1
if mine_e == ge:
l2e_ok += 1
else:
fails.append(f"L2-edit ch{chap} chunk{ck}: BLOCK MISMATCH\n MINE:\n{mine_e}\n GO:\n{ge}")
else:
l2e_transitive += 1
print(f"=== injection port validation ({len(chunk_keys)} chunks) ===")
print(f"L1 exact-ids vs injected_ids: {l1_ok}/{l1_n}")
print(f"L2 translator block byte-diff: {l2t_ok}/{l2t_n}")
print(f"L2 editor block byte-diff: {l2e_ok}/{l2e_n} (+{l2e_transitive} transitive via translator block, log-truncated)")
if fails:
print(f"\n{len(fails)} MISMATCH(es):")
for f in fails[:20]:
print(" -", f)
ok = (l1_ok == l1_n) and (l2t_ok == l2t_n) and (l2e_ok == l2e_n) and not any("NOT RECOVERED" in f for f in fails)
print("\nPORT VALIDATION:", "PASS (byte-faithful to Go)" if ok else "FAIL — trust Go, fix port")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

126
eval/exp15/wirecheck.py Normal file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""exp15 — pre-paid WIRE-CHECK gate (§1.13-C). FIRST paid calls (freeze committed b9272a1 already).
Confirms the three at-risk behaviors BEFORE spending on arms/judges:
- grok-4.3 judge runs reasoning-ON (reasoning tokens > 0) [orchestrator: confirmed reasoning-ON]
- mistral-large-latest slug resolves + reports its version [freeze: live-factcheck]
- glm-5 is callable PAYG with our ZAI key (not gated to Coding Plan) [freeze risk glm5_payg_access]
Tiny prompts (~1 sentence); per-call predicted cost printed + gated; usage/cost persisted (D30.10).
Run: eval/.venv/bin/python eval/exp15/wirecheck.py
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from openai import OpenAI
from dotenv import load_dotenv
EVAL = Path(__file__).resolve().parent.parent
load_dotenv(EVAL / ".env")
FACTS = json.load(open("/home/ubuntu/books/gu-zhenren/exp15/freeze_facts.json"))["prices"]
OUT = Path("/home/ubuntu/books/gu-zhenren/exp15/wirecheck.json")
PER_CALL_CAP = 0.05 # $ predicted per-call gate
PROMPT = [{"role": "system", "content": "Ты переводчик. Выведи ТОЛЬКО перевод."},
{"role": "user", "content": "Переведи на русский: 方源笑了,握紧拳头。"}]
def price(model, field):
v = FACTS[model][field]
return v[0] if isinstance(v, list) else v
def cost(model, usage):
pin, pout = price(model, "in"), price(model, "out")
return (usage.get("prompt_tokens", 0) * pin + usage.get("completion_tokens", 0) * pout) / 1e6
def reasoning_tokens(usage_obj):
# xAI/OpenAI-style nested; be defensive across shapes
d = usage_obj if isinstance(usage_obj, dict) else usage_obj.__dict__
for k in ("reasoning_tokens",):
if d.get(k):
return d[k]
ctd = d.get("completion_tokens_details")
if ctd:
ctd = ctd if isinstance(ctd, dict) else getattr(ctd, "__dict__", {})
return ctd.get("reasoning_tokens", 0) or 0
return 0
def probe(name, base, env, model, extra=None, expect_reasoning=False):
key = os.environ.get(env)
if not key:
return {"name": name, "error": f"no key {env}"}
cli = OpenAI(base_url=base, api_key=key)
kw = dict(model=model, messages=PROMPT, max_tokens=1200)
if extra:
kw.update(extra)
try:
r = cli.chat.completions.create(**kw)
except Exception as e: # noqa: BLE001
return {"name": name, "model": model, "error": f"{type(e).__name__}: {str(e)[:200]}"}
u = r.usage
usage = {"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens,
"total_tokens": u.total_tokens}
rt = reasoning_tokens(u)
txt = (r.choices[0].message.content or "").strip()
c = cost(model, usage)
res = {"name": name, "model_requested": model, "model_returned": getattr(r, "model", None),
"usage": usage, "reasoning_tokens": rt, "cost_usd": round(c, 6),
"output_sample": txt[:120], "finish": r.choices[0].finish_reason}
if expect_reasoning:
res["reasoning_on_OK"] = rt > 0
return res
def main():
checks = [
# grok judge: NO reasoning_effort -> default (reasoning ON). Confirm reasoning tokens > 0.
dict(name="grok-judge", base="https://api.x.ai/v1", env="XAI_API_KEY",
model="grok-4.3", expect_reasoning=True),
# mistral judge/arm: confirm slug resolves + version returned
dict(name="mistral-large", base="https://api.mistral.ai/v1", env="MISTRAL_API_KEY",
model="mistral-large-latest"),
# glm-5 editor: confirm PAYG callable (thinking off for a clean quick probe)
dict(name="glm-5-editor", base="https://api.z.ai/api/paas/v4", env="ZAI_API_KEY",
model="glm-5", extra={"extra_body": {"thinking": {"type": "disabled"}}}),
]
results, total = [], 0.0
for ch in checks:
# per-call predicted cost gate (worst case max_tokens fully used as output)
pred = price(ch["model"], "out") * 1200 / 1e6 + price(ch["model"], "in") * 60 / 1e6
print(f"[gate] {ch['name']} ({ch['model']}) predicted <= ${pred:.4f} {'OK' if pred<=PER_CALL_CAP else 'BLOCKED'}")
if pred > PER_CALL_CAP:
results.append({"name": ch["name"], "error": f"per-call gate blocked (pred ${pred:.4f} > ${PER_CALL_CAP})"})
continue
res = probe(**ch)
results.append(res)
total += res.get("cost_usd", 0) or 0
if "error" in res:
print(f" ERROR: {res['error']}")
else:
extra = f" reasoning_tokens={res['reasoning_tokens']}" if res.get("reasoning_tokens") else ""
print(f" OK model_returned={res['model_returned']} cost=${res['cost_usd']:.5f}{extra}")
print(f" out: {res['output_sample']!r}")
OUT.write_text(json.dumps({"total_cost_usd": round(total, 6), "results": results}, ensure_ascii=False, indent=2),
encoding="utf-8")
print(f"\ntotal wire-check spend: ${total:.5f} -> {OUT}")
# self-review asserts
by = {r["name"]: r for r in results}
grok = by.get("grok-judge", {})
if "error" not in grok:
assert grok.get("reasoning_on_OK"), f"grok reasoning NOT on (reasoning_tokens={grok.get('reasoning_tokens')})"
print("OK: grok-4.3 judge reasoning-ON confirmed.")
if "error" not in by.get("glm-5-editor", {}):
print("OK: glm-5 PAYG callable with ZAI key.")
if "error" not in by.get("mistral-large", {}):
print(f"OK: mistral-large-latest -> {by['mistral-large'].get('model_returned')}")
if __name__ == "__main__":
main()