Compare commits

...

25 commits

Author SHA1 Message Date
Claude (backend session)
1db62d41a3 Record the D39.17 errata: the runWave parent-cancellation bug my rubezh-2 missed, caught by an independent second review and fixed 2026-07-19 20:41:10 +03:00
Claude (backend session)
5fc4860d64 Fix runWave swallowing parent cancellation (D39.17 errata): return parent.Err() when no worker errored, so a Ctrl-C resume surfaces context.Canceled instead of a nil-deref panic or false exit-0 partial success; regression-pinned 2026-07-19 20:40:04 +03:00
Claude (backend session)
5be69939fc Archive the R1 driver-switch session report 2026-07-19 20:11:26 +03:00
Claude (backend session)
89d5bf9d6a Land R1 wave driver-switch (D39.17): parallel draft/edit waves with bank-mining stop, editor never re-renders draft, Sh-1/Sh-2, per-unit read-models; rubezh-2 clean bill, three MINOR mining-wiring fixes to pre-rerun; pack-11 complete 2026-07-19 20:11:05 +03:00
Claude (backend session)
a263365be3 Record the exp16 signature-map approval and the Bai Ningbing finding: time-varying/perspective-split gender is the limit of deterministic DC-3, routed to the F2 editor with narrative annotation 2026-07-19 18:29:39 +03:00
Claude (backend session)
fa620e2015 Tighten the R1 prompt: mark FL-1/2/3 as landed context and Sh-1/Sh-2 as the work to do this pass 2026-07-19 18:05:52 +03:00
Claude (backend session)
e0b8a52783 Clean the backend code zone: move session reports to docs/archive/reports, stop persisting backend/*_REPORT.md, and ratify the Sh-1/Sh-2 snapshot-guard findings into the R1 fix-list 2026-07-19 18:03:57 +03:00
Claude (backend session)
d358143342 Actualize all documentation to post-D39.16: refresh current-state headers, status lines, D-log ranges, langpack realization notes, and zone README cross-references 2026-07-19 17:55:51 +03:00
Claude (backend session)
0b529725b5 Add the worked-out banner to the archived arch-cleanup prompt copy 2026-07-19 17:47:52 +03:00
Claude (backend session)
91c400c8ae Land the language-data isolation arch pass (D39.16): move miner zh/Palladius data to internal/lang and configs/langpacks byte-exactly, land FL-1/2/3, ratify the three press-tested divergences, archive the worked prompt 2026-07-19 17:47:14 +03:00
Claude (backend session)
3798643740 Issue the intermediate architecture-cleanup session prompt per D39.15: isolate language data out of the pipeline package before R1, ratify the direction and re-sequence the queue 2026-07-19 15:51:56 +03:00
Claude (backend session)
13c4375e2a Land backend pack-11 continuation: Go miner with exact parity, banknote 12-point integration, DC checkers with export --pairs, editor arm configs, and additive wave snapshot; clear the rubezh-2 debt with a three-item fix list 2026-07-19 15:24:48 +03:00
Claude (backend session)
88b5e7b47d Actualize the backend pack-11 session prompt for the continuation run over residuals R1-R5 2026-07-19 08:59:53 +03:00
Claude (backend session)
85f5b9e633 Land backend pack-11 block A: output-token chunker with edit units, src-to-dst editor block, eager clients, per-model rate guard, memory version split, banknote parser core 2026-07-19 08:53:54 +03:00
Claude (backend session)
1f285250ab Add the worked-out banner to the archived design-synthesis prompt copy 2026-07-19 04:03:52 +03:00
Claude (backend session)
09ca24b50a Land the ratified implementation plan as D39.12 with design11 verifications, record the mistral rate-limit quirk, and issue the backend single-pack session prompt 2026-07-19 04:03:39 +03:00
Claude (backend session)
766a60e304 Issue the design-synthesis session prompt per D39.11 after a 20-finding adversarial review, and update the progress journal and active-prompt index 2026-07-19 01:06:55 +03:00
Claude (backend session)
c86bdcef2b Close orchestrator session six: ratify D39.11 design-first directive, issue handoff five, actualize status docs and archive the spent experiment prompts 2026-07-19 00:35:33 +03:00
Claude (backend session)
a7f9adf2a3 Ratify D39.10 landing exp16: the which-what split, banknote as the dst channel, the clean re-audit, closing the arch-reset research program 2026-07-19 00:25:13 +03:00
Claude (backend session)
aaa4e12bf8 Commit the stray Q4a polygon addendum note left untracked from the mini-session 2026-07-19 00:25:13 +03:00
Claude (backend session)
667eec0dc5 Land exp16 bank-mining: the which-what split with the 0.97 recall code detector, banknote as the dst channel, the clean exp14b re-audit, and the stop-only truncation errata 2026-07-19 00:24:24 +03:00
Claude (backend session)
96345f589d Freeze exp16 bank-mining pre-registration: miner-v1 detectors V-A/V-B/V-C, alias/canon/banknote code, GT filter and thresholds tuned on ch1-15, before any paid call 2026-07-18 23:32:53 +03:00
Claude (backend session)
b9e62a7f49 Fold the exp15 and Q4a rig lessons into the exp16 prompt: valley-only DeepSeek generation, length-rejection everywhere, hardened judge pattern with the measured noise floor 2026-07-18 22:12:53 +03:00
Claude (backend session)
f6c52d11e9 Ratify D39.9 closing the exp15 program and order the zero-cost exp14b re-audit with corrected rules as an exp16 pre-task 2026-07-18 22:08:20 +03:00
Claude (backend session)
cfcf0f08e3 Land Q4a: fidelity is not bought at the translate stage, the pro arm is not ratified, the glm editor is the confirmed weak link, with corrected trap rules and the truncation-accepting harness bug documented 2026-07-18 22:08:20 +03:00
130 changed files with 15104 additions and 787 deletions

3
.gitignore vendored
View file

@ -30,3 +30,6 @@ backend/dist/
# Claude Code — личные (некоммитируемые) настройки прав
.claude/settings.local.json
*.key.json
# exp16 versioned contrast corpus (jieba 0.42.1 dict.txt, 5MB) — SHA pinned in the report, reproducible
eval/exp16/data/

View file

@ -14,7 +14,7 @@ Go-бэкенд издательского художественного пер
## Источники истины (по убыванию)
1. **`docs/architecture/05-decisions-log.md` (D1D39.6)** — ратифицированный контракт; при конфликте с любым другим доком побеждает он; сверху файла — карта актуальности (что чем superseded).
1. **`docs/architecture/05-decisions-log.md` (D1D39.11)** — ратифицированный контракт; при конфликте с любым другим доком побеждает он; сверху файла — карта актуальности (что чем superseded).
2. `docs/architecture/07-strategic-review.md` — стратегический аудит 09.07 (вердикт, риски, курс-коррекции).
3. `docs/experiments/00-provider-quirks.md` — wire-квирки провайдеров (читать ПЕРЕД правкой адаптеров/вызовами провайдеров).
4. `docs/architecture/01-decisions.md` (Р1Р10), `02-mvp-plan.md` (фазы v3), `04-unhappy-paths.md`, `06-memory-risk-registry.md`.
@ -38,6 +38,6 @@ Go-бэкенд издательского художественного пер
2. `docs/architecture/05-decisions-log.md` целиком (контракт).
3. По роли: Бэкенд — `backend/README.md` + `03-implementation-notes.md`; Полигон — `eval/README.md` + `experiments/00` + `09-pilot-protocol.md`; всем — `07-strategic-review.md` §69 (вердикт/риски/курс).
## Текущее состояние (2026-07-17, пост-D39.6 — эра арх-ресета)
## Текущее состояние (2026-07-19, пост-D39.16 — эра «стройка пере-прогонного стека»)
Фаза 0 ✅; **Ф1-инфра ✅** (D20D28; golden = инвариант №8). Арка «качество-первым» D26→D38.5 ЗАКРЫТА (хроника — `docs/archive/PROGRESS-2026-07-10-13.md`; итог: потолок художественности — в НАШЕЙ организации пайплайна, не в моделях). **АРХ-РЕСЕТ D39 (13.07):** синк-аудит «сломанного телефона» полигон→оркестратор→бэкенд (65 находок / 0 refuted — `architecture/08-sync-audit-ledger.md`) → **целевая 7-слойная архитектура + фазовый план** (`architecture/09-target-architecture.md`; промпт-тема — `10-prompt-architecture.md`); **инвариант общности §0.1** — любая книга/пара/структура, пара = ось данных. **Трек A (build-now) ЗАКРЫТ** (D39.2/39.4/39.5): терм-дрейф закрыт в КОДЕ (trust-gated suppressor), export-contract нормализация + fold-first санитайзер, pair-сеам `prompts:{zh-ru}` (fail-loud), `tmctl export` (полигон-экстракция впредь ТОЛЬКО через него), реестры/target-гейты, quality-report. **Трек B залендён:** research/19 (нарезка+когезия+контракт t/e, W0W3; D39.1) + research/20 (банк-майнинг W1.5: детектор V-A/B/C, алиас-ярусы, banknote-v1, плагины P1P6; D39.6). **Очередь:** exp15 сегментация-эмпирика (промт выдан, ждёт запуска) → полигон-стадия банк-майнинга (после exp15) → бэкенд-пак слоя 1 под доказанный конфиг → единый resnapshot (D30.9) → пере-прогон 310 глав → чтение владельца (планка 2 претензии). Стек не менялся с D38.5 (draft flash thinking-ON → editor glm-5 БИЛИНГВ v3 → судья gemini-preview; ~$0.85/ранобэ D30.4). Ключ xAI: единый, data-sharing, off перед продом (D27). Решающая точка — пилот Ф2.5 (блокеры: билингв-якорь/аннотаторы ≥3 — D25.9-Q1, корпус, судья-дублёр — D22.6). Книга на стенде `/home/ubuntu/books/gu-zhenren/` (GB18030; текст и ВСЕ производные ВНЕ git). Ключи: DEEPSEEK, ZAI, KIMI, OPENAI, GEMINI, XAI, MISTRAL. Стенд: WSL2, GTX 1070 8GB (localhost из-под прокси = 403 — no-proxy транспорт для local).
Фаза 0 ✅; **Ф1-инфра ✅** (D20D28; golden = инвариант детерминизма, №8 README = экспорт-контракт). Арка «качество-первым» D26→D38.5 ЗАКРЫТА (хроника — `docs/archive/PROGRESS-2026-07-10-13.md`; потолок художественности — в организации пайплайна, не в моделях). **АРХ-РЕСЕТ D39:** синк-аудит «сломанного телефона» (65 находок/0 refuted — `08-sync-audit-ledger.md`) → **7-слойная целевая архитектура** (`09-target-architecture.md`; промпт-тема — `10`); **инвариант общности §0.1** — любая книга/пара/структура, пара = ось данных. **Исследовательская программа ЗАВЕРШЕНА (D39.1D39.10):** research/19 (нарезка+когезия+волны W0W3) + research/20 (банк-майнинг W1.5: детектор V-A/B/C, алиас-ярусы, banknote-v1, плагины P1P6); exp15/Q4a/exp16 — несущее: сцен-детекцию/carryover/logical-границу НЕ строить, слабое звено = РЕДАКТОР → свап-арм (glm→mistral/deepseek-pro), банк W1.5 = детектор-код (WHICH recall 0.97) + банкнота (dst) + Палладий (имена) + подпись, фертильность 1.20·cjk+0.39·other. **План реализации РАТИФИЦИРОВАН (D39.12, `architecture/11-implementation-plan.md`)** — три рубежа верификации, 4 гейтнутых арма. **Стройка (пак-11, ЕДИНОЙ сессией по решению владельца, блоками):** Block A залендён (D39.13: чанкер output-бюджет + edit-единицы + src→dst-блок редактора [закрытие D30.1] + eager-clients + rate-guard + base/enriched split + банкнота-ядро); continuation залендён (D39.14: Go-майнер паритет-EXACT + банкнота 12-точек + DC-чекеры + `export --pairs` + арм-конфиги + волновой снапшот-аддитив; рубеж-2-долг погашен, clean bill). **Арх-проход залендён (D39.16, директива владельца): язык-данные майнера вынесены из `pipeline/`-констант в `internal/lang`+`configs/langpacks/` (граница компиль-enforced, байт-точно — парити EXACT, golden байт-идентичен); `x/text/language.Tag` ОТВЕРГНУТ (CLDR-инстабильность в вердиктах — держим точные предикаты `isRuTarget`); DC-чекеры/снапшот-фолд DEFER (майнер offline/нейтральность).** **Осталось: R1 драйвер-свитч** (волновой исполнитель W1/W1.5/W2 + структурный golden-рерайт + фолд `pack.Version()`/загрузка langpack при wiring майнера живым) → приёмка → единый resnapshot (D30.9) → пере-прогон 310 глав → чтение владельца (планка 2 претензии). Стек не менялся с D38.5 (draft flash thinking-ON → editor glm-5 БИЛИНГВ v3 → судья gemini-preview; ~$0.85/ранобэ D30.4). **Висит на владельце:** тачпойнты exp16 (пол/алиасы/precision@30) · W1.5-UX · политика стиха DC5 · ja→ru-реплика (тест общности §B5, DEFER-чекеры едут туда) · **ре-чек прайса DeepSeek у катовера слагов 24.07** (до него платных deepseek-прогонов нет) · старые висящие (пилот Ф2.5: билингв-якорь D25.9-Q1, корпус, судья-дублёр D22.6). Ключ xAI: единый, data-sharing, off перед продом (D27). Книга на стенде `/home/ubuntu/books/gu-zhenren/` (GB18030; текст и производные ВНЕ git). Ключи: DEEPSEEK, ZAI, KIMI, OPENAI, GEMINI, XAI, MISTRAL. Стенд: WSL2, GTX 1070 8GB (localhost из-под прокси = 403 — no-proxy транспорт для local).

View file

@ -1,6 +1,6 @@
# backend/ — Go-бэкенд TextMachine
Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (D1D39.6); целевая архитектура арх-ресета (7 слоёв, инвариант общности §0.1 — любая книга/пара/структура) — `docs/architecture/09-target-architecture.md`; контракты Фазы 0 — `03-implementation-notes.md`; квирки провайдеров — `docs/experiments/00-provider-quirks.md`. **`.env` не читать.**
Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (D1D39.16); целевая архитектура арх-ресета (7 слоёв, инвариант общности §0.1 — любая книга/пара/структура) — `docs/architecture/09-target-architecture.md`; **план реализации пере-прогонного стека — `docs/architecture/11-implementation-plan.md` (D39.12, ратифицирован); активный промт стройки — `docs/BACKEND_PLAN11_SESSION_PROMPT.md` (R1 драйвер-свитч)**; контракты Фазы 0 — `03-implementation-notes.md`; квирки провайдеров — `docs/experiments/00-provider-quirks.md`. **Язык-данные майнера — вне пайплайна: `internal/lang`+`configs/langpacks/` (D39.16).** **`.env` не читать.**
*(Актуализировано оркестратором 17.07 по мандату владельца, факты сверены по коду; предыдущая ревизия была эры D23.)*

View file

@ -17,9 +17,11 @@ import (
type invocation struct {
cmd string
cfgPath string
seedPath string // seed-lint: the glossary seed YAML to validate (no --config)
resnapshot bool
asJSON bool
asPlaintext bool
asPairs bool // export: include the source column (--pairs) for the DC1/DC2 FP-measure
sel pipeline.RedriveSelector
}
@ -48,18 +50,27 @@ func parseInvocation(args []string, flagOut io.Writer) (invocation, error) {
resnapshot := fs.Bool("resnapshot", false, "re-pin existing jobs to the current config snapshot (пере-перевод оплаченных чанков — явное согласие)")
asJSON := fs.Bool("json", false, "status: emit the projection as JSON (stable disposition/flag_reason enums) for CI/IDE")
asPlaintext := fs.Bool("plaintext", false, "export: emit the concatenated human text instead of the default stable JSON")
asPairs := fs.Bool("pairs", false, "export: include the source text per chunk (src↔target column for the DC1/DC2 FP-measure, WS5)")
chapter := fs.Int("chapter", -1, "redrive: restrict to this chapter (default: any)")
chunk := fs.Int("chunk", -1, "redrive: restrict to this chunk index within the chapter (default: any)")
reason := fs.String("reason", "", "redrive: restrict to this flag_reason (default: any)")
dryRun := fs.Bool("dry-run", false, "redrive: report what would be re-attacked without touching anything")
seed := fs.String("seed", "", "seed-lint: path to the glossary seed YAML to validate ($0, no --config)")
if err := fs.Parse(rest); err != nil {
return invocation{}, err
}
// seed-lint validates a standalone seed YAML — it takes --seed, not --config (no book/store/keys).
if cmd == "seed-lint" {
if *seed == "" {
return invocation{}, fmt.Errorf("--seed <glossary.yaml> is required for seed-lint")
}
return invocation{cmd: cmd, seedPath: *seed}, nil
}
if *cfgPath == "" {
return invocation{}, fmt.Errorf("--config book.yaml is required")
}
return invocation{
cmd: cmd, cfgPath: *cfgPath, resnapshot: *resnapshot, asJSON: *asJSON, asPlaintext: *asPlaintext,
cmd: cmd, cfgPath: *cfgPath, resnapshot: *resnapshot, asJSON: *asJSON, asPlaintext: *asPlaintext, asPairs: *asPairs,
sel: pipeline.RedriveSelector{
Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun,
},

View file

@ -69,11 +69,13 @@ func run() error {
case "status":
return status(ctx, inv.cfgPath, inv.asJSON)
case "export":
return export(inv.cfgPath, inv.asPlaintext)
return export(inv.cfgPath, inv.asPlaintext, inv.asPairs)
case "redrive":
return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.sel)
case "seed-lint":
return seedLint(inv.seedPath)
default:
return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive)", inv.cmd)
return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|seed-lint)", inv.cmd)
}
}
@ -94,6 +96,17 @@ func translate(ctx context.Context, cfgPath string, resnapshot bool) error {
})
}
// seedLint validates a glossary seed YAML (a manual seed or the emitted mined delta) through the REAL
// loadGlossarySeed fail-louds + the shared-key collision check (WS3). $0, no store, no provider keys — a
// pre-flight the operator runs on the mined delta before the W1.5 reseed. Prints "OK" on a clean seed.
func seedLint(seedPath string) error {
if err := pipeline.SeedLint(seedPath); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "seed-lint OK: %s is loadable (0 fail-louds, 0 shared-key collisions)\n", seedPath)
return nil
}
func report(cfgPath string) error {
// Read-only ($0): no API keys required (D20.4 — a store audit must not demand provider keys).
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
@ -125,14 +138,14 @@ func report(cfgPath string) error {
// extractor / reader-samples read the SAME bytes the backend ships instead of the raw checkpoint. Like
// report/status it is $0 and needs no provider keys (D20.4 — an audit surface must not demand keys).
// Default output is stable JSON (the machine surface); --plaintext emits the human concatenation.
func export(cfgPath string, asPlaintext bool) error {
func export(cfgPath string, asPlaintext, asPairs bool) error {
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
exp, err := r.Export()
exp, err := r.Export(asPairs)
if err != nil {
return err
}

View file

@ -0,0 +1,88 @@
# Palladius (Палладий) pinyin→Cyrillic table (palladius.INITIALS/FINALS/Y_W/SPECIAL_I). `category<TAB>pinyin<TAB>cyrillic` per line.
initials b б
initials c ц
initials ch ч
initials d д
initials f ф
initials g г
initials h х
initials j цз
initials k к
initials l л
initials m м
initials n н
initials p п
initials q ц
initials r ж
initials s с
initials sh ш
initials t т
initials x с
initials z цз
initials zh чж
finals a а
finals ai ай
finals an ань
finals ang ан
finals ao ао
finals e э
finals ei эй
finals en энь
finals eng эн
finals er эр
finals i и
finals ia я
finals ian янь
finals iang ян
finals iao яо
finals ie е
finals in инь
finals ing ин
finals iong юн
finals iu ю
finals o о
finals ong ун
finals ou оу
finals u у
finals ua уа
finals uai уай
finals uan уань
finals uang уан
finals ueng ун
finals ui уй
finals un унь
finals uo о
finals v юй
finals van юань
finals ve юэ
finals vn юнь
yw wa ва
yw wai вай
yw wan вань
yw wang ван
yw wei вэй
yw wen вэнь
yw weng вэн
yw wo во
yw wu у
yw ya я
yw yan янь
yw yang ян
yw yao яо
yw ye е
yw yi и
yw yin инь
yw ying ин
yw yong юн
yw you ю
yw yu юй
yw yuan юань
yw yue юэ
yw yun юнь
special_i chi чи
special_i ci цы
special_i ri жи
special_i shi ши
special_i si сы
special_i zhi чжи
special_i zi цзы

View file

@ -0,0 +1,2 @@
# Trailing-particle set marking a boundary fragment (alias.PARTICLE). A rune SET; stored sorted.
上下不与之也了今其前后和在多大小少就心是的都面

View file

@ -0,0 +1,2 @@
# Grade/stem prefix chars 甲乙丙… (patterns.GRADE_PREFIX). A rune SET; stored sorted.
丁丙乙壬己庚戊甲癸辛

View file

@ -0,0 +1,2 @@
# CJK numeral chars (patterns.NUMERAL). A rune SET; stored sorted.
一七三九二五八六十千四百零

View file

@ -0,0 +1,16 @@
# Ordinal titles 一代/第一… (patterns.ORDINAL). Ordered; one per line.
一代
二代
三代
四代
五代
六代
七代
八代
九代
十代
第一
第二
第三
第四
第五

View file

@ -0,0 +1,9 @@
# Rank/measure words 等/转… (patterns.RANK_WORD). Ordered; one per line.

View file

@ -0,0 +1,17 @@
# Two-char compound surnames (patterns compound set). One per line.
上官
东方
公孙
南宫
古月
司徒
司马
夏侯
宇文
尉迟
慕容
欧阳
皇甫
诸葛
长孙
鲜于

View file

@ -0,0 +1,2 @@
# 百家姓 single-char surnames (patterns.SURNAMES_SINGLE, 凝 discarded — not a surname). A rune SET (order-free); stored sorted by code point.
丁万严乐于云任伍伏何余俞倪傅元冯凌凤刁包华单卜卞卢卫危史吉吕吴周和唐喻夏奚姚姜娄孔孙孟季安宋宗宣尤尹屈岑崔左席常干平应庞康廉张强彭徐成戚戴房支方施时昌明昝曹朱李杜杨杭林柏柯柳梁梅樊殷毕毛水江汤汪沈洪湛滕潘熊狄王田白皮盛石祁祝禹秦穆窦章童管米纪经缪罗胡臧舒花苏苗范茅莫萧葛董蒋蓝蔡薛虞袁裘褚解计许诸谈谢贝贲费贺贾赵路邓邬邱邵邹郁郎郑郝郭酆金钟钮钱闵阮陈陶雷霍韦韩项顾颜马骆高魏鲁鲍麻黄齐龚

View file

@ -0,0 +1,27 @@
# Title suffixes → title (patterns.TITLE_SUFFIX). Ordered; one per line.
公子
大人
长老
前辈
族长
家老
老祖
祖师
真人
上人
道人
先生
夫人
娘子
姑娘
嬷嬷
师傅
师父
掌门
宗主
少爷
老爷
小姐
婆婆
大娘
大爷

View file

@ -0,0 +1,2 @@
# Topographic suffix chars → place (patterns.TOPO_SUFFIX). A rune SET; stored sorted.
原城堂宗寨山岛岭峰府村林楼殿江河洞海湖潭疆祠谷门阁院

View file

@ -218,10 +218,16 @@ models:
# = $0.5/$1.5 (OpenRouter согласен) → разрешает D19-подозрение ($0.5/$1.5 при $2/$6 = 4×). cached=input
# консервативно (cache-read не моделируем; канал B — свежий вход/чанк, hit≈0, потолок не слепнет).
price: { input_per_m: 0.5, cached_per_m: 0.5, cache_write_per_m: 0, output_per_m: 1.5 }
# WS1 §1б / WS6 gate №4: mistral-large — агрессивный rate-limiter (~48% retry-fails под N-∥ @1.3s;
# токен-бакет/конкуренц-кап, тир-зависим — 00-provider-quirks §Транспорт). Пер-модельный семафор
# конкуренции волнового раннера ограничивает одновременные вызовы (транспорт-ось, НЕ снапшот).
# Значение — консервативный старт; пере-замерить на прод-тире (правило двух направлений). Свап-арм
# редактора mistral НЕ идёт через прод-путь без этого guard (ревью-2 F4).
rate_limit: { max_concurrency: 2, min_interval_ms: 0 }
note: >-
Переводчик-дефолт канала B (D19.1): 10/10 чисто на violence, policy 11.06.2026 без запрета adult, не
reasoning-модель (эхо-мины нет). Полный wiring канала B — Ф2 (конфиг-слот). Полигон перепроверяет цену (D19.5б).
Живьём 2026-07-10: /models ✓, chat 200.
reasoning-модель (эхо-мины нет). Свап-арм редактора (WS6, за rate-guard). Полный wiring канала B — Ф2.
Полигон перепроверяет цену (D19.5б). Живьём 2026-07-10: /models ✓, chat 200.
gpt-5-mini: # кандидат-редактор/second-opinion судья (D3) — НЕ в дефолтной цепочке Ф1
provider: openai

View file

@ -0,0 +1,76 @@
# Свап-арм редактора: DeepSeek-v4-pro reasoning (WS6 / D39.9). Копия боевого C1 (pipeline-c1.yaml) с
# ЕДИНСТВЕННЫМ изменением: editor glm-5 → deepseek-v4-pro + few_shot:false. Арм = КОНФИГ (другой
# editor-model → другой snapshot_W2). Атрибуция едет в chunk_status.model_actual → export/report.
#
# ⚠️ ЭХО-МИНА (несущая): deepseek — echo-prone провайдер (thinking-off на CJK → эхо исходника). deepseek-
# v4-pro резолвится в ReasoningNone (нет reasoning-capability → reasoning:off = НО-ОП, thinking остаётся
# ON) → echoMineViolation НЕ срабатывает, thinking-ON держится (эхо-мина НЕ вооружается). Вход редактора —
# русский черновик (не CJK), так что эхо-класс тут вырожден, но thinking-ON держим по дисциплине провайдера.
#
# few_shot:false (D38.4): у reasoning-редактора собственный CoT нарушается рукописными few-shot-примерами
# (exp14 §2а) → ---FEWSHOT---блок editor.md дропается. Фолдится в снапшот (snapshot.go FewShot) — осознанный
# --resnapshot. min_max_tokens 8000-флор deepseek-v4-pro (D24.3) держит thinking внутри бюджета.
core: C1
version: 1
defaults:
max_output_ratio: 2.2
min_max_tokens: 2048
context:
glossary_injection: selective
glossary_token_budget: 800
cache_ttl: "5m"
segmentation:
draft_budget_out: 1797
edit_ceiling_out: 3200
fertility:
cjk: 1.1978
other: 0.3852
retries:
regenerate_before_escalate: 1
stages:
- name: draft
role: translator
model: deepseek-v4-flash
prompts:
zh-ru: ../prompts/translator.md
prompt_version: v1-reflow
temperature: 0.3
reasoning: "off"
escalate_to: deepseek-v4-pro
- name: edit
role: editor
# СВАП-АРМ: deepseek-v4-pro reasoning-редактор (COGS $0.117 vs glm $0.335 WS6(г); thinking-ON держится
# через ReasoningNone no-op). Пиннут (editor не эскалирует — D12).
model: deepseek-v4-pro
prompts:
zh-ru: ../prompts/editor.md
prompt_version: v3-discourse-reflow
temperature: 0.4
reasoning: "off" # NO-OP на deepseek-v4-pro (ReasoningNone) → thinking ON; НЕ вооружает эхо-мину
few_shot: false # reasoning-редактор: собственный CoT vs рукописные примеры (exp14 §2а) → дроп ---FEWSHOT---
gates:
coverage:
enabled: false
len_ratio_bounds:
zh-ru: [2.2, 4.2]
ja-ru: [1.4, 2.6]
en-ru: [0.70, 1.4]
sent_cov_min: 0.75
min_chunk_chars: 500
sanitizer:
enabled: true
escalation:
chains:
default: [deepseek-v4-pro, glm-5.1, gemini-3.1-pro-preview]
adult: [grok-4.3]
budget_usd: 0
fanout:
candidates: 1

View file

@ -0,0 +1,74 @@
# Свап-арм редактора: Mistral (WS6 / D39.9 — слабое звено = редактор). Копия боевого C1
# (pipeline-c1.yaml) с ЕДИНСТВЕННЫМ изменением: editor.model glm-5 → mistral-large-2512. Арм = КОНФИГ,
# не код: другой editor-model → другой stageSnap.Model → другой snapshot_W2 (отдельный прогон, не
# конфликтует с базлайном). Атрибуция арма едет в chunk_status.model_actual → export/report.
#
# ⚠️ ПРЕД-УСЛОВИЕ (WS1 §1б / ревью-2 F4): mistral-large — агрессивный rate-limiter (~48% retry-fails под
# N-∥ без guard'а — 00-provider-quirks §Транспорт). models.yaml несёт rate_limit:{max_concurrency:2} на
# mistral-large-2512; пер-модельный семафор волнового раннера (WS1b, сдан Block A) реально ограничивает
# конкуренцию. Свап-арм mistral НЕ идёт через прод-путь без этого guard.
core: C1
version: 1
defaults:
max_output_ratio: 2.2
min_max_tokens: 2048
context:
glossary_injection: selective
glossary_token_budget: 800
cache_ttl: "5m"
# WS2 (слой 1): бюджет нарезки в ВЫХОДНЫХ (ru) токенах — те же ратифицированные zh-ru дефолты, что C1.
segmentation:
draft_budget_out: 1797
edit_ceiling_out: 3200
fertility:
cjk: 1.1978
other: 0.3852
retries:
regenerate_before_escalate: 1
stages:
- name: draft
role: translator
model: deepseek-v4-flash # черновик — тот же, что базлайн (свап только редактора)
prompts:
zh-ru: ../prompts/translator.md
prompt_version: v1-reflow
temperature: 0.3
reasoning: "off"
escalate_to: deepseek-v4-pro
- name: edit
role: editor
# СВАП-АРМ: mistral-large-2512 вместо glm-5 (D39.9 — редактор = слабое звено; COGS $0.205 vs glm $0.335
# WS6(г); не reasoning-модель → эхо-мины нет). Идёт ТОЛЬКО за rate-guard (models.yaml rate_limit).
model: mistral-large-2512
prompts:
zh-ru: ../prompts/editor.md
prompt_version: v3-discourse-reflow
temperature: 0.4
reasoning: "off"
# few_shot по дефолту ON: mistral НЕ reasoning-модель, few-shot-примеры P1a-дискурса не мешают её CoT.
gates:
coverage:
enabled: false
len_ratio_bounds:
zh-ru: [2.2, 4.2]
ja-ru: [1.4, 2.6]
en-ru: [0.70, 1.4]
sent_cov_min: 0.75
min_chunk_chars: 500
sanitizer:
enabled: true # премиум-редактор за санитайзером — как C1 (утечки преамбул ловятся)
escalation:
chains:
default: [deepseek-v4-pro, glm-5.1, gemini-3.1-pro-preview]
adult: [grok-4.3]
budget_usd: 0
fanout:
candidates: 1

View file

@ -22,9 +22,20 @@ context:
# DeepSeek $0.0028/M — решается расчётом на прототипе).
glossary_injection: selective
glossary_token_budget: 800
stm_depth: 2 # глубина STM в чанках (Фаза 1)
overlap_tokens: 200 # перекрытие чанков = read-only контекст (Фаза 1)
cache_ttl: "5m"
# STMDepth/OverlapTokens сняты (WS2 §2а): carryover/overlap НЕ строится (D39.7/8 — метрики
# под полом когезии 0.126); мёртвые кнобы в снапшоте приглашали тихий «re-activate».
# WS2 (слой 1): бюджет нарезки в ВЫХОДНЫХ (ru) токенах через фертильность (est_out = cjk·CJK +
# other·Other); снапшот-folded (segmentationSnap → громкий --resnapshot при правке). Пропуск блока =
# ратифицированные zh-ru дефолты (см. LoadPipeline). Крупно-главный арм (edit_ceiling_out>3200)
# гейтнут платными Q2a span-судьями (§11) — 3200 = консервативный дефолт.
segmentation:
draft_budget_out: 1797 # мелкий DRAFT-чанк, ru-выход (→ 56 чанков на 25-гл пере-прогоне)
edit_ceiling_out: 3200 # крупная EDIT-единица = глава/группа целых чанков (→ 37 единиц)
fertility: # независимо пере-выведено на пере-прогоне: R²=0.9633
cjk: 1.1978
other: 0.3852
retries:
# Содержательные регенерации после провала гейта ДО эскалации (транспортные

View file

@ -12,9 +12,8 @@ defaults:
context:
glossary_injection: selective
glossary_token_budget: 800
stm_depth: 2
overlap_tokens: 200
cache_ttl: "5m"
# STMDepth/OverlapTokens сняты (WS2 §2а — carryover не строим).
retries:
regenerate_before_escalate: 1

View file

@ -45,21 +45,32 @@ type Book struct {
StyleAllowlist []string `yaml:"style_allowlist"`
// Wiring: paths are resolved relative to the book.yaml location.
Pipeline string `yaml:"pipeline"`
ModelsFile string `yaml:"models"`
SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк
Pipeline string `yaml:"pipeline"`
ModelsFile string `yaml:"models"`
SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк
// Encoding of the txt source: auto|utf8|gb18030 ("" defaults to auto). Real zh .txt are often
// GB18030 (the 蛊真人 acceptance book is), which the auto-detect handles; declare it explicitly
// to skip detection. Ignored for epub (its documents carry their own charset). See ingest.go.
Encoding string `yaml:"encoding"`
ProjectDB string `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
Encoding string `yaml:"encoding"`
ProjectDB string `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
// GlossarySeed is the optional path to the manual glossary seed YAML (memory v2,
// шаг 4). Its curated approved/draft terms + the classified ruby readings are the
// deterministic inputs the glossary is REPLACED from each run; editing it changes the
// materialized approved set → a loud --resnapshot (F1). Empty = ruby-seed only (or an
// empty glossary → the injection is inert, a safe no-op).
GlossarySeed string `yaml:"glossary_seed"`
Ceilings BookCap `yaml:"ceilings"`
GlossarySeed string `yaml:"glossary_seed"`
// LangpackRoot is the optional root of the language-data packs (configs/langpacks/, D39.15/16): the
// miner reads configs/langpacks/<src>/ (source morphology) + configs/langpacks/<src>-<tgt>/ (Palladius)
// from here. Resolved relative to book.yaml. Empty ⇒ no pack (the miner is inert / W1.5 auto-continues);
// set ⇒ the runner loads the pack in W0, failing LOUD when the pair's catalog dir exists but a file is
// missing/corrupt, and folds pack.Version() into the snapshot so a pack edit is a loud --resnapshot (R1).
LangpackRoot string `yaml:"langpack_root"`
// MinedDelta is the optional path to the owner-curated mined-term delta YAML (the W1.5 sign artifact,
// R1). Its terms are loaded as Source:"mined" (NOT Source:"seed"), so they fold into the ENRICHED bank
// version but NOT the base — adding them moves only snapshot_W2 («переоплата ОДНА»). Same seedTerm
// schema as glossary_seed; resolved relative to book.yaml. Empty = no mined terms.
MinedDelta string `yaml:"mined_delta"`
Ceilings BookCap `yaml:"ceilings"`
}
// BookCap are the ledger admission limits (Р7: потолок $ на книгу/день).
@ -90,6 +101,8 @@ func LoadBook(path string) (*Book, error) {
b.ModelsFile = resolve(b.ModelsFile)
b.SourceFile = resolve(b.SourceFile)
b.GlossarySeed = resolve(b.GlossarySeed)
b.LangpackRoot = resolve(b.LangpackRoot)
b.MinedDelta = resolve(b.MinedDelta)
if b.ProjectDB == "" {
b.ProjectDB = filepath.Join(dir, b.BookID+".db")
} else {
@ -123,6 +136,11 @@ func LoadBook(path string) (*Book, error) {
bad("glossary_seed %s is not readable: %v", b.GlossarySeed, err)
}
}
if b.MinedDelta != "" {
if _, err := os.Stat(b.MinedDelta); err != nil {
bad("mined_delta %s is not readable: %v", b.MinedDelta, err)
}
}
if b.Encoding == "" {
b.Encoding = "auto"
}

View file

@ -213,3 +213,63 @@ func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) {
}
}
}
// TestSwapArmConfigs guards the WS6 editor swap-arms (D39.9 — редактор = слабое звено). The arms are
// CONFIG, not code: each pipeline-arm-*.yaml swaps ONLY the editor model, so a different editor-model
// resolves a different stageSnap.Model → a distinct snapshot_W2 (a separate run). This pins: (1) each arm
// loads and its editor is the arm model; (2) NEITHER arm re-arms the echo mine — the deepseek-pro editor
// resolves to ReasoningNone (thinking ON, off is a no-op) so echoMineViolation stays empty on an echo-
// prone provider; (3) the deepseek reasoning-editor drops few-shot (few_shot:false), the mistral arm
// keeps it (nil = ON); (4) the mistral arm's PRE-CONDITION — mistral-large-2512 carries a rate_limit so
// the WS1 per-model rate-guard actually throttles it (§6(б) F4). All three boevoy pipelines (c1 + two
// arms) still pin the same translator+draft; only the editor differs.
func TestSwapArmConfigs(t *testing.T) {
m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
if err != nil {
t.Fatalf("load boevoy models.yaml: %v", err)
}
arms := []struct {
file, editorModel string
fewShotOff bool // the deepseek reasoning editor drops few-shot; mistral keeps it
}{
{"pipeline-arm-mistral.yaml", "mistral-large-2512", false},
{"pipeline-arm-deepseek-pro.yaml", "deepseek-v4-pro", true},
}
for _, a := range arms {
t.Run(a.file, func(t *testing.T) {
p, err := LoadPipeline(filepath.Join("..", "..", "configs", a.file), m)
if err != nil {
t.Fatalf("load %s: %v", a.file, err)
}
var edit *Stage
for i := range p.Stages {
if p.Stages[i].Role == "editor" {
edit = &p.Stages[i]
}
}
if edit == nil {
t.Fatalf("%s has no editor stage", a.file)
}
if edit.Model != a.editorModel {
t.Errorf("%s editor model = %q, want the swap-arm %q", a.file, edit.Model, a.editorModel)
}
// (2) echo-mine safety: the arm editor's model must not re-arm the mine.
if why := m.echoMineViolation(edit.Model); why != "" {
t.Errorf("%s editor %q re-arms the echo mine: %s", a.file, edit.Model, why)
}
// (3) few-shot policy: deepseek reasoning-editor drops the block; mistral keeps it (nil = ON).
if a.fewShotOff {
if edit.FewShot == nil || *edit.FewShot {
t.Errorf("%s deepseek reasoning-editor must set few_shot:false (drop hand examples), got %v", a.file, edit.FewShot)
}
} else if edit.FewShot != nil {
t.Errorf("%s mistral editor must keep few_shot default (nil=ON), got %v", a.file, edit.FewShot)
}
})
}
// (4) mistral rate-guard pre-condition: mistral-large-2512 must carry a rate_limit so the WS1 wave
// per-model semaphore actually throttles it (mistral ~48% retry-fails under N-∥ without it).
if rl := m.Models["mistral-large-2512"].RateLimit; rl.MaxConcurrency <= 0 {
t.Errorf("mistral-large-2512 must configure rate_limit.max_concurrency>0 (WS6 pre-condition — the swap-arm needs the WS1 rate-guard), got %d", rl.MaxConcurrency)
}
}

View file

@ -130,6 +130,19 @@ type Model struct {
// Capabilities overrides this model's provider-default wire shape (D3.1),
// field-by-field (model wins). Nil = inherit the provider/kind baseline.
Capabilities *CapabilitiesConfig `yaml:"capabilities"`
// RateLimit caps how many parallel wave workers may call THIS model at once (WS1 §1б, ревью-2
// F4): mistral-large-latest fails ~48% of calls under N-parallelism (a token-bucket / concurrency
// cap, tier-dependent — quirks §Транспорт) while grok is 0%, so a model can bound its own
// wave concurrency. Optional min_interval paces call STARTS. A TRANSPORT axis — wire-neutral,
// NOT snapshot-folded (it never touches the request bytes). Zero = unlimited (the default).
RateLimit RateLimit `yaml:"rate_limit"`
}
// RateLimit is the per-model wave-concurrency guard (WS1). max_concurrency 0 = unlimited;
// min_interval_ms 0 = no pacing.
type RateLimit struct {
MaxConcurrency int `yaml:"max_concurrency"`
MinIntervalMS int `yaml:"min_interval_ms"`
}
// Price mirrors ledger.ModelPrice in YAML form (USD per 1M tokens).
@ -210,6 +223,9 @@ func LoadModels(path string) (*Models, error) {
bad("model %s: non-local models require non-zero input/output prices", name)
}
validateCapabilities(bad, "model "+name, mod.Capabilities)
if mod.RateLimit.MaxConcurrency < 0 || mod.RateLimit.MinIntervalMS < 0 {
bad("model %s: rate_limit.max_concurrency/min_interval_ms must be ≥0 (a concurrency cap / pacing interval)", name)
}
if why := m.echoMineViolation(name); why != "" {
bad("model %s on echo-prone provider %s: %s — this re-arms the DeepSeek echo mine "+
"(the provider echoes the untranslated CJK source when thinking is OFF; PROGRESS «Ответ Полигону», §2); "+

View file

@ -19,15 +19,69 @@ import (
// Pipeline is the parsed pipeline-*.yaml.
type Pipeline struct {
Core string `yaml:"core"` // C0|C1|C2|C3
Version int `yaml:"version"`
Defaults PipelineDefaults `yaml:"defaults"`
Context ContextAssembly `yaml:"context"`
Retries Retries `yaml:"retries"`
Stages []Stage `yaml:"stages"`
Gates Gates `yaml:"gates"`
Escal Escalation `yaml:"escalation"`
Fanout Fanout `yaml:"fanout"`
Core string `yaml:"core"` // C0|C1|C2|C3
Version int `yaml:"version"`
Defaults PipelineDefaults `yaml:"defaults"`
Context ContextAssembly `yaml:"context"`
Segmentation Segmentation `yaml:"segmentation"`
Retries Retries `yaml:"retries"`
Stages []Stage `yaml:"stages"`
Gates Gates `yaml:"gates"`
Escal Escalation `yaml:"escalation"`
Fanout Fanout `yaml:"fanout"`
Waves Waves `yaml:"waves"`
Mining Mining `yaml:"mining"`
}
// Waves configures the wave executor's parallelism (WS1 §1б / R1): how many worker goroutines fan out
// over the draft chunks (W1) and edit units (W2). It is a TRANSPORT axis — it changes NOTHING on the wire
// (each unit of work renders identical bytes regardless of which goroutine runs it), so it is deliberately
// NOT folded into the snapshot (folding it would make a worker-count edit a spurious --resnapshot / whole-
// book re-bill). The inner per-model cap stays RateLimit.MaxConcurrency (models.yaml). Workers ≤ 0 defaults
// to 1: a wave-STRUCTURED but sequential run (W1 all drafts → W1.5 → W2 all units), deterministic and safe;
// prod sets it higher (the COGS sim assumed 8). A run with workers=1 is byte-identical in results to
// workers=N (only request_log/wire ORDER differs — the golden capture sorts to absorb that).
type Waves struct {
Workers int `yaml:"workers"`
}
// Mining configures the W1.5 bank-mining stop (WS3 / R1): the general-zh contrast corpus the WHICH-detector
// scores candidates against. It is OFF unless ContrastPath is set AND a language pack is loaded (book
// langpack_root): the miner then runs at the W1.5 boundary over the W1 drafts, emits the seed-delta +
// signature map, and STOPS for owner sign (or auto-continues on an empty delta). ContrastPath is the jieba-
// style word-freq artifact (large, deployment-specific, NOT in git), resolved relative to pipeline.yaml. It
// is NOT snapshot-folded: mining produces Source:mined PROPOSALS (status:auto, inert until owner-approved),
// so it touches no existing checkpoint's wire or verdict — only the langpack VERSION (which shapes the
// proposals) is folded, via the runner's pack.Version() (§8). Empty ContrastPath ⇒ W1.5 auto-continues.
type Mining struct {
ContrastPath string `yaml:"contrast_path"`
}
// Segmentation is the WS2 OUTPUT-token chunking budget (слой 1, L2-budget-wrong-unit fix): the
// draft-chunk and edit-unit ceilings in ru-OUTPUT tokens, plus the per-pair fertility coefficients
// that convert source char-classes to an output-token estimate (est_out = cjk·CJK + other·Other).
// Snapshot-folded (segmentationSnap), so a budget/fertility edit is a loud --resnapshot (D30.9).
// The char-class CLASSIFIER is the backend's real unicode ranges (EstimateTokens: Han|Hiragana|
// Katakana|Hangul) for generality (§0.1) — the coefficients are the only per-pair datum (authored
// per project). Zero fields fall to the ratified zh-ru defaults in LoadPipeline.
type Segmentation struct {
// DraftBudgetOut is the fine DRAFT-chunk ceiling (ru-output tokens, default 1797 → 56 chunks on
// the 25-chapter rerun). A draft chunk is the small unit for COGS/coverage/alignment.
DraftBudgetOut int `yaml:"draft_budget_out"`
// EditCeilingOut is the coarse EDIT-unit ceiling (ru-output tokens, default 3200 → 37 units).
// The edit unit is a chapter (or a greedy grouping of whole draft chunks when a chapter exceeds
// the ceiling) — the large unit the reflow editor needs for cross-chunk cohesion (D39 п.4). The
// large-chapter arm (>3200, up to 8000) is GATED behind paid Q2a span-judges (§11); the ceiling
// stays config-tunable but 3200 is the conservative ratified default (span-omission unproven).
EditCeilingOut int `yaml:"edit_ceiling_out"`
Fertility Fertility `yaml:"fertility"`
}
// Fertility holds the output-token-per-source-char coefficients (WS2). Independently re-derived on
// the rerun corpus: cjk=1.1978, other=0.3852 (R²=0.9633). A recompute is a loud --resnapshot.
type Fertility struct {
CJK float64 `yaml:"cjk"`
Other float64 `yaml:"other"`
}
// PipelineDefaults are cross-stage knobs.
@ -39,13 +93,13 @@ type PipelineDefaults struct {
}
// ContextAssembly holds the prompt-layout budgets (Р5-раскладка: стабильный
// префикс / волатильный хвост — сборка Фазы 1; поля фиксируются сейчас, чтобы
// схема конфига не менялась под ногами «Редакции»).
// префикс / волатильный хвост). WS2 (§2а) removed the dead STMDepth/OverlapTokens knobs:
// carryover/overlap is NOT built (D39.7/8 — metrics under the 0.126 floor), and leaving
// no-op knobs in the wire snapshot invited a silent "re-activate" — so они снесены (a
// contextSnap structural change → §8 resnapshot manifest line).
type ContextAssembly struct {
GlossaryInjection string `yaml:"glossary_injection"` // selective | full_prefix (Р5: обе схемы)
GlossaryTokenBudget int `yaml:"glossary_token_budget"`
STMDepth int `yaml:"stm_depth"`
OverlapTokens int `yaml:"overlap_tokens"`
CacheTTL string `yaml:"cache_ttl"` // per-stage TTL override — Фаза 1
}
@ -109,6 +163,21 @@ type Gates struct {
Glossary GlossaryGate `yaml:"glossary"`
Sanitizer SanitizerGate `yaml:"sanitizer"`
RegressionGuard RegressionGuardGate `yaml:"regression_guard"`
Banknote BanknoteGate `yaml:"banknote"`
}
// BanknoteGate controls the banknote-v1 in-band footnote channel (WS4, RATIFIED D39.10): when enabled,
// the translator MAY emit a versioned ⟦TM-BANK-v1⟧ separator + tab-delimited term lines for NEW terms
// after the translation — the DIRECT dst delivery the co-occurrence miner could not extract (蛊→гу).
// Enabling it is TWO coordinated changes: this backend gate (the runner slices the block off BEFORE the
// gates/editor, commits the cleaned draft as a derived export checkpoint, and folds banknoteSnap into the
// snapshot) AND the footnote INSTRUCTION baked into the translator prompt file (which moves PromptSHA256).
// Opt-in (default false), like the sanitizer/coverage gates; a no-op unless the prompt actually instructs
// the model to emit banknotes (a normal draft has no separator → the slice is a no-op). Its parser/slice
// VERSION is folded into banknoteSnap (verdict-axis) only when enabled, so a parser change is a loud
// --resnapshot even without a prompt edit (§4в point 6).
type BanknoteGate struct {
Enabled bool `yaml:"enabled"`
}
// RegressionGuardGate controls the post-reflow regression guard (D38 infra-pack,
@ -266,6 +335,8 @@ func LoadPipeline(path string, models *Models) (*Pipeline, error) {
p.Stages[i].Prompts[pair] = resolvePrompt(pr)
}
}
// The mining contrast artifact path is resolved like the prompts (relative to pipeline.yaml).
p.Mining.ContrastPath = resolvePrompt(p.Mining.ContrastPath)
var problems []string
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
@ -285,6 +356,32 @@ func LoadPipeline(path string, models *Models) (*Pipeline, error) {
if p.Fanout.Candidates <= 0 {
p.Fanout.Candidates = 1
}
// Wave workers default to 1 (a wave-structured but sequential, deterministic run). A negative value
// is a config typo, not a request — clamp loudly-neutral to 1 rather than fail (transport axis).
if p.Waves.Workers <= 0 {
p.Waves.Workers = 1
}
// WS2 segmentation defaults (ratified zh-ru, §2в): a config that omits the block gets the
// output-token budget + independently-re-derived fertility. Zero/negative → default.
if p.Segmentation.DraftBudgetOut <= 0 {
p.Segmentation.DraftBudgetOut = 1797
}
if p.Segmentation.EditCeilingOut <= 0 {
p.Segmentation.EditCeilingOut = 3200
}
if p.Segmentation.Fertility.CJK <= 0 {
p.Segmentation.Fertility.CJK = 1.1978
}
if p.Segmentation.Fertility.Other <= 0 {
p.Segmentation.Fertility.Other = 0.3852
}
// An edit unit is a grouping of WHOLE draft chunks, so the edit ceiling must be ≥ the draft
// budget — otherwise a single draft chunk already exceeds the unit ceiling and every unit is
// one chunk (the decoupling collapses). Loud config error, not a silent degenerate segmentation.
if p.Segmentation.EditCeilingOut < p.Segmentation.DraftBudgetOut {
bad("segmentation.edit_ceiling_out (%d) must be ≥ draft_budget_out (%d) — an edit unit groups whole draft chunks (WS2)",
p.Segmentation.EditCeilingOut, p.Segmentation.DraftBudgetOut)
}
switch p.Context.GlossaryInjection {
case "", "selective", "full_prefix":
default:

View file

@ -0,0 +1,270 @@
// Package lang holds the language-specific DATA the translation engine reads, isolated OUT of
// internal/pipeline/ as versioned files under configs/langpacks/ (owner directive D39.15: language data
// must not live as Go constants inside the engine; horizon = hundreds of languages, data-as-files, add a
// pair = drop a directory, no recompile, no pipeline/ edits).
//
// This package carries NO behaviour. The miner / checker ALGORITHMS stay in internal/pipeline (parity-
// locked, owner: "from the engine, DATA leaves; the ALGORITHM stays") and READ these tables. The one-way
// dependency (pipeline → lang, never the reverse) is compile-enforced: nothing here imports pipeline, so
// the data/algorithm boundary is real, not a convention.
//
// A Pack is resolved by (source, target) language and content-hashed at load, mirroring the two in-repo
// precedents: the pair-keyed prompt seam (config.Stage.Prompts + PromptPathFor + PromptSHA256, resolved
// by Book.LangPair(), fail-loud on a missing pair) and the memnorm trad→simp table (a data file whose
// bytes ARE its version via a content hash). Editing a pack file changes its Version() → the pipeline
// folds that into the snapshot → a loud --resnapshot, drift-proof by mechanism, not discipline.
package lang
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
// packAlgoVersion tags the PARSE/layout of a pack (the file manifest + how each file is read). Bump it on
// a schema change (a new file, a format change). The DATA content is versioned separately by hashing the
// files into Version(), so editing a table also invalidates — you cannot forget to bump a version when
// you change the data, because the data's bytes ARE the version (the memnorm.go drift-proofing).
const packAlgoVersion = "langpack-v1"
// Pack is a loaded, versioned language-data pack for one source→target pair. Fields are the DATA the
// pipeline algorithms read; the zero value is unusable (load via Load). Maps are membership sets / lookup
// tables (order-free); slices preserve their authored order.
type Pack struct {
Pair string // "<src>-<tgt>", e.g. "zh-ru" (== config.Book.LangPair())
// <src> source morphology (configs/langpacks/<src>/) — the miner's typed-candidate channels.
SurnamesSingle map[rune]bool // 百家姓 single-char surnames
SurnamesCompound map[string]bool // two-char compound surnames
TitleSuffix []string // title suffixes → title (ordered)
OrdinalTitle []string // ordinal titles 一代/第一… (ordered)
RankWord []string // rank/measure words 等/转… (ordered)
TopoSuffix map[rune]bool // topographic suffix chars → place
GradePrefix map[rune]bool // grade/stem prefix chars 甲乙丙…
Numeral map[rune]bool // CJK numeral chars
AliasParticle map[rune]bool // trailing-particle set marking a boundary fragment
// <src>-<tgt> pair transliteration (configs/langpacks/<pair>/) — the Palladius (Палладий) table.
PalladiusInitials map[string]string
PalladiusFinals map[string]string
PalladiusYW map[string]string
PalladiusSpecialI map[string]string
version string
}
// Version is the content hash of the pack (packAlgoVersion + a sha256 of the authored file bytes). A pack
// edit changes it, so the pipeline can fold it into the snapshot (a loud --resnapshot on any data edit).
func (p *Pack) Version() string { return p.version }
// srcFiles are the source-morphology files (under configs/langpacks/<src>/), read in this fixed order.
var srcFiles = []string{
"surnames-single.txt", "surnames-compound.txt", "title-suffix.txt", "ordinal-title.txt",
"rank-word.txt", "topo-suffix.txt", "grade-prefix.txt", "numeral.txt", "alias-particle.txt",
}
// pairFiles are the pair-transliteration files (under configs/langpacks/<pair>/).
var pairFiles = []string{"palladius.txt"}
// Load resolves and reads the pack for (sourceLang, targetLang) from root (e.g. "configs/langpacks"): the
// source-morphology files under root/<src>/ and the pair-transliteration files under root/<src>-<tgt>/.
// EVERY declared file must be present and well-formed — a missing/corrupt file fails LOUD (the caller runs
// this at load, before any billing, mirroring the prompt-pack os.Stat loop). The content hash covers all
// files in a fixed order, so it is deterministic and drift-proof.
func Load(root, sourceLang, targetLang string) (*Pack, error) {
pair := sourceLang + "-" + targetLang
p := &Pack{Pair: pair}
h := sha256.New()
h.Write([]byte(packAlgoVersion))
read := func(dir, name string) ([]byte, error) {
path := filepath.Join(root, dir, name)
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("langpack %q: %w (add the file or fix the book's source_lang/target_lang; D39.15: language data is required, never silently empty)", pair, err)
}
// Fold the RELATIVE path + bytes so a rename or a moved byte both shift the hash.
h.Write([]byte("\x00" + dir + "/" + name + "\x00"))
h.Write(b)
return b, nil
}
for _, name := range srcFiles {
b, err := read(sourceLang, name)
if err != nil {
return nil, err
}
if err := p.assignSrc(name, b); err != nil {
return nil, fmt.Errorf("langpack %q %s: %w", pair, name, err)
}
}
for _, name := range pairFiles {
b, err := read(pair, name)
if err != nil {
return nil, err
}
if err := p.assignPair(name, b); err != nil {
return nil, fmt.Errorf("langpack %q %s: %w", pair, name, err)
}
}
if err := p.validate(); err != nil {
return nil, fmt.Errorf("langpack %q: %w", pair, err)
}
p.version = packAlgoVersion + "-" + hex.EncodeToString(h.Sum(nil))[:12]
return p, nil
}
// validate makes the "never silently empty" contract real: a present-but-empty or comment-only data file
// (a fat-fingered edit once packs are hand-authored at R1) parses to an empty table with no error and would
// silently disable a miner channel — recall degradation with no load-time signal. Every required table must
// be non-empty. The content hash catches an EDIT (a --resnapshot signal), but "edited to empty" is a corrupt
// pack, not an intended change, so it is refused at load, before any consumer, like the missing-file path.
func (p *Pack) validate() error {
var empty []string
req := func(name string, n int) {
if n == 0 {
empty = append(empty, name)
}
}
req("surnames-single", len(p.SurnamesSingle))
req("surnames-compound", len(p.SurnamesCompound))
req("title-suffix", len(p.TitleSuffix))
req("ordinal-title", len(p.OrdinalTitle))
req("rank-word", len(p.RankWord))
req("topo-suffix", len(p.TopoSuffix))
req("grade-prefix", len(p.GradePrefix))
req("numeral", len(p.Numeral))
req("alias-particle", len(p.AliasParticle))
req("palladius/initials", len(p.PalladiusInitials))
req("palladius/finals", len(p.PalladiusFinals))
req("palladius/yw", len(p.PalladiusYW))
req("palladius/special_i", len(p.PalladiusSpecialI))
if len(empty) > 0 {
return fmt.Errorf("empty required table(s) %s — a present-but-empty/comment-only data file is a corrupt pack, not a valid one", strings.Join(empty, ", "))
}
return nil
}
func (p *Pack) assignSrc(name string, b []byte) error {
switch name {
case "surnames-single.txt":
p.SurnamesSingle = runeSet(b)
case "surnames-compound.txt":
p.SurnamesCompound = stringSet(b)
case "title-suffix.txt":
p.TitleSuffix = lines(b)
case "ordinal-title.txt":
p.OrdinalTitle = lines(b)
case "rank-word.txt":
p.RankWord = lines(b)
case "topo-suffix.txt":
p.TopoSuffix = runeSet(b)
case "grade-prefix.txt":
p.GradePrefix = runeSet(b)
case "numeral.txt":
p.Numeral = runeSet(b)
case "alias-particle.txt":
p.AliasParticle = runeSet(b)
default:
return fmt.Errorf("unknown source file")
}
return nil
}
func (p *Pack) assignPair(name string, b []byte) error {
switch name {
case "palladius.txt":
ini, fin, yw, si, err := parsePalladius(b)
if err != nil {
return err
}
p.PalladiusInitials, p.PalladiusFinals, p.PalladiusYW, p.PalladiusSpecialI = ini, fin, yw, si
default:
return fmt.Errorf("unknown pair file")
}
return nil
}
// runeSet reads a rune SET: every non-whitespace rune of every non-comment line is a member (order-free).
func runeSet(b []byte) map[rune]bool {
m := map[rune]bool{}
for _, ln := range contentLines(b) {
for _, r := range ln {
if !isSpace(r) {
m[r] = true
}
}
}
return m
}
// stringSet reads a set of whole tokens, one per non-comment line (trimmed).
func stringSet(b []byte) map[string]bool {
m := map[string]bool{}
for _, ln := range contentLines(b) {
if t := strings.TrimSpace(ln); t != "" {
m[t] = true
}
}
return m
}
// lines reads an ORDERED slice of whole tokens, one per non-comment line (trimmed), in file order.
func lines(b []byte) []string {
var out []string
for _, ln := range contentLines(b) {
if t := strings.TrimSpace(ln); t != "" {
out = append(out, t)
}
}
return out
}
// parsePalladius reads the `category<TAB>pinyin<TAB>cyrillic` table into the four maps. It scans the raw
// lines directly (not contentLines) so an error names the PHYSICAL file line — the point of the diagnostic
// is to send a human editing the table to the right line.
func parsePalladius(b []byte) (ini, fin, yw, si map[string]string, err error) {
ini, fin, yw, si = map[string]string{}, map[string]string{}, map[string]string{}, map[string]string{}
for i, raw := range strings.Split(string(b), "\n") {
t := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if t == "" || strings.HasPrefix(t, "#") {
continue
}
f := strings.Split(t, "\t")
if len(f) != 3 {
return nil, nil, nil, nil, fmt.Errorf("line %d: want 3 tab-separated fields, got %d (%q)", i+1, len(f), t)
}
switch f[0] {
case "initials":
ini[f[1]] = f[2]
case "finals":
fin[f[1]] = f[2]
case "yw":
yw[f[1]] = f[2]
case "special_i":
si[f[1]] = f[2]
default:
return nil, nil, nil, nil, fmt.Errorf("line %d: unknown category %q", i+1, f[0])
}
}
return ini, fin, yw, si, nil
}
// contentLines splits into lines, dropping '#'-comment and blank lines.
func contentLines(b []byte) []string {
var out []string
for _, ln := range strings.Split(string(b), "\n") {
s := strings.TrimRight(ln, "\r")
if strings.HasPrefix(strings.TrimSpace(s), "#") || strings.TrimSpace(s) == "" {
continue
}
out = append(out, s)
}
return out
}
func isSpace(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' }

View file

@ -0,0 +1,143 @@
package lang
import (
"os"
"path/filepath"
"strings"
"testing"
)
// realRoot is the in-repo langpack root, relative to this package's test cwd (internal/lang/).
const realRoot = "../../configs/langpacks"
// TestLoadResolvesRealZhRu loads the REAL zh→ru pack from disk and pins a spot-check of the moved data
// (the const→file move must be byte-faithful; the full-book parity that pins the exact miner output is
// the stand-gated TestMinerFullBookParity, so this is the CI-runnable anchor). It exercises the real
// disk-loading path end to end.
func TestLoadResolvesRealZhRu(t *testing.T) {
p, err := Load(realRoot, "zh", "ru")
if err != nil {
t.Fatalf("Load(zh,ru): %v", err)
}
if p.Pair != "zh-ru" {
t.Errorf("Pair = %q, want zh-ru", p.Pair)
}
// Source morphology spot-check (byte-faithful move).
if !p.SurnamesSingle['赵'] || !p.SurnamesSingle['方'] {
t.Error("surnames-single missing 赵/方")
}
if p.SurnamesSingle['凝'] {
t.Error("surnames-single must NOT contain 凝 (discarded — not a surname)")
}
if !p.SurnamesCompound["古月"] || !p.SurnamesCompound["欧阳"] {
t.Error("surnames-compound missing 古月/欧阳")
}
if len(p.TitleSuffix) == 0 || p.TitleSuffix[0] != "公子" {
t.Errorf("title-suffix order not preserved: %v", p.TitleSuffix)
}
if !p.TopoSuffix['山'] || !p.Numeral['三'] || !p.GradePrefix['甲'] || !p.AliasParticle['的'] {
t.Error("rune-set membership missing an expected char (山/三/甲/的)")
}
// Pair transliteration spot-check.
if p.PalladiusInitials["b"] != "б" || p.PalladiusInitials["zh"] != "чж" {
t.Errorf("palladius initials wrong: b=%q zh=%q", p.PalladiusInitials["b"], p.PalladiusInitials["zh"])
}
if p.PalladiusSpecialI["zhi"] != "чжи" {
t.Errorf("palladius special_i zhi = %q, want чжи", p.PalladiusSpecialI["zhi"])
}
if !strings.HasPrefix(p.Version(), packAlgoVersion+"-") {
t.Errorf("Version() = %q, want %s-<hash>", p.Version(), packAlgoVersion)
}
}
// TestRoutesByPairToDifferentBytes is the load-bearing generality proof: a SECOND pair, dropped as data
// (no pipeline/ edit, no recompile), routes to DIFFERENT bytes and a DIFFERENT version. Proves "add a
// language = data only" — the pair is a genuine routing key, not a baked-in zh constant.
func TestRoutesByPairToDifferentBytes(t *testing.T) {
root := t.TempDir()
writeSyntheticPack(t, root, "xx", "yy")
real, err := Load(realRoot, "zh", "ru")
if err != nil {
t.Fatalf("load real: %v", err)
}
synth, err := Load(root, "xx", "yy")
if err != nil {
t.Fatalf("load synthetic xx-yy: %v", err)
}
// The synthetic pair carries its OWN surnames (not zh's), proving the resolver routes by pair, not
// to a hardcoded set.
if !synth.SurnamesSingle['甴'] {
t.Error("synthetic pack must carry its own surname 甴")
}
if synth.SurnamesSingle['赵'] {
t.Error("synthetic pack must NOT inherit zh's surname 赵 (would mean a baked-in constant)")
}
if !real.SurnamesSingle['赵'] || real.SurnamesSingle['甴'] {
t.Error("real zh pack routing leaked/borrowed synthetic bytes")
}
if real.Version() == synth.Version() {
t.Errorf("different-byte packs must have different versions (real=%s synth=%s)", real.Version(), synth.Version())
}
}
// TestFailsLoudOnMissingPair pins the fail-loud contract: a pair with no pack directory errors, naming the
// missing file — never a silent empty pack (mirrors the prompt seam's PromptPathFor fail-loud). This is
// the load-time invariant a live consumer relies on (fail before billing).
func TestFailsLoudOnMissingPair(t *testing.T) {
_, err := Load(realRoot, "ja", "ru")
if err == nil {
t.Fatal("Load(ja,ru) must fail loud (no ja langpack), got nil error")
}
if !strings.Contains(err.Error(), "ja-ru") {
t.Errorf("error must name the missing pair ja-ru, got: %v", err)
}
}
// TestFailsLoudOnEmptyTable pins the "never silently empty" contract: a present-but-empty (comment-only)
// required file is a corrupt pack and must fail loud at load — not parse to an empty set that silently
// disables a miner channel once packs are hand-authored (R1).
func TestFailsLoudOnEmptyTable(t *testing.T) {
root := t.TempDir()
writeSyntheticPack(t, root, "xx", "yy")
// Blank out one required table (keep the file present, drop its content).
if err := os.WriteFile(filepath.Join(root, "xx", "surnames-single.txt"), []byte("# emptied by a bad edit\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Load(root, "xx", "yy")
if err == nil {
t.Fatal("an empty required table must fail loud, got nil error")
}
if !strings.Contains(err.Error(), "surnames-single") {
t.Errorf("error must name the empty table, got: %v", err)
}
}
// writeSyntheticPack writes a minimal, VALID pack for (src, tgt) under root with bytes distinct from zh —
// enough for Load to succeed and for the routing assertion to bite.
func writeSyntheticPack(t *testing.T, root, src, tgt string) {
t.Helper()
pair := src + "-" + tgt
files := map[string]string{
filepath.Join(src, "surnames-single.txt"): "# synthetic\n甴甶甹\n",
filepath.Join(src, "surnames-compound.txt"): "# synthetic\n甲乙\n",
filepath.Join(src, "title-suffix.txt"): "# synthetic\n阁下\n",
filepath.Join(src, "ordinal-title.txt"): "# synthetic\n第甲\n",
filepath.Join(src, "rank-word.txt"): "# synthetic\n級\n",
filepath.Join(src, "topo-suffix.txt"): "# synthetic\n峰\n",
filepath.Join(src, "grade-prefix.txt"): "# synthetic\n子丑\n",
filepath.Join(src, "numeral.txt"): "# synthetic\n壹貳\n",
filepath.Join(src, "alias-particle.txt"): "# synthetic\n之乎\n",
filepath.Join(pair, "palladius.txt"): "# synthetic\ninitials\tb\tб\nfinals\ta\tа\nyw\tyi\tи\nspecial_i\tzhi\tчжи\n",
}
for rel, body := range files {
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
}

View file

@ -0,0 +1,207 @@
package pipeline
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strings"
"unicode"
"textmachine/backend/internal/config"
"textmachine/backend/internal/store"
)
// banknote.go: the banknote-v1 in-band footnote channel (WS4, план §4 / research/20 §B3 / exp16
// banknote.py — RATIFIED D39.10 as the dst-delivery channel). The translator, AFTER the translation,
// MAY emit a versioned separator + tab-delimited term lines for NEW terms only (not in the injected
// glossary). This file is the pure, deterministic PARSER + SLICER — the wire-invariant core; the
// integration seams (slice BEFORE classifyOutput, derived checkpoint, snapshot fold, telemetry —
// план §4(а) 12 points) consume these functions. A faithful Go↔Python port (banknote.py is the
// reference): same separator, same tolerant field split, same Han-in-src rule, same truncation
// tolerance. Pin (ws4_banknote_verify.py): re-parse of 95 saved lines = 0 parse_fail / 0 truncated.
// bankSeparator marks the start of the banknote block. Chosen to (a) not occur in natural prose and
// (b) NOT be a "Примечание/Сноска/Комментарий" trailing-note word the sanitizer reserves (§B3-1), so
// the two channels never collide. Changing it is a wire/parser change → a loud --resnapshot (§4в).
const bankSeparator = "⟦TM-BANK-v1⟧"
// bankMaxLines is the per-chunk banknote line budget (§B3-5, feeds the max_tokens sizing at
// integration point 5). The parser does not enforce it (a model over-emitting is tolerated and
// merely over-counted); it bounds the prompt-side budget.
const bankMaxLines = 12
// bankParserVersion versions the split/parse algorithm — the verdict-axis component folded into
// banknoteSnap{enabled, parser_version} (§4в/§4а point 6), mirroring sanitizerSnap: a parser change
// re-resolves the stripped draft, so it must be a loud --resnapshot even without a prompt edit.
const bankParserVersion = "banknote-v1"
// bankDerivedNS is the derived-checkpoint id NAMESPACE (§4б EXACT formula) — the "tm-<name>-v1" prefix
// convention the sibling commitSanitizedExport uses ("tm-sanitized-v1"), kept DISTINCT from the parser
// version above exactly as sanitizerSnap.Version (sanitizer-v6) is distinct from the "tm-sanitized-v1"
// namespace: it embeds the channel version so a version change yields a fresh (never colliding) id.
const bankDerivedNS = "tm-banknote-v1"
// bankTypeOK is the accepted type set; anything else falls back to "term" (a benign default).
var bankTypeOK = map[string]bool{"name": true, "place": true, "title": true, "term": true, "nickname": true}
// bankFieldSplit tolerates a tab, a run of ≥2 spaces, or a pipe (optionally whitespace-padded) as the
// field delimiter — the exact tolerance of banknote.py so a model that emits spaces instead of a real
// TAB still parses. (Go \s is ASCII-whitespace; banknote lines are tab/space-delimited, so the parity
// with Python's unicode \s around the pipe is exact on the term corpus.)
var bankFieldSplit = regexp.MustCompile(`\t| {2,}|\s*\|\s*`)
// bankEntry is one parsed candidate line (evidence for the bank-mining stop (§C); NOT written to the store until owner
// signoff). Dst is the model's proposed translation — the direct dst delivery the co-occurrence
// miner could not extract (蛊→гу, non-seed 龙公→Лун Гун).
type bankEntry struct {
Src string
Dst string
Type string
}
// bankFlags is the per-chunk telemetry surfaced loud at integration point 10.
type bankFlags struct {
NLines int // accepted (well-formed) banknote lines
ParseFail bool // any malformed residual line (non-Han src / <2 fields) that was NOT a tolerated truncation
Truncated bool // the LAST line was cut by generation length (tolerated, not a parse fail)
}
// splitBanknote slices the banknote block off the raw model output BEFORE any gate/editor (integration
// point 1). Returns (clean_translation, raw_block). No separator → the whole (right-trimmed) output is
// the clean translation and the block is "". Faithful to banknote.py: clean = output[:idx].rstrip(),
// block = output[idx+len(SEP):].strip("\n").
func splitBanknote(output string) (clean, block string) {
idx := strings.Index(output, bankSeparator)
if idx < 0 {
return strings.TrimRightFunc(output, unicode.IsSpace), ""
}
clean = strings.TrimRightFunc(output[:idx], unicode.IsSpace)
block = strings.Trim(output[idx+len(bankSeparator):], "\n")
return clean, block
}
// hasBankSrcHan reports whether src contains a CJK ideograph in the exact [㐀-鿿] range (U+3400U+9FFF)
// banknote.py checks — a zh→ru channel line whose src has no Han is malformed. Kept as the exact
// Python range (NOT unicode.Han, which is wider) for byte-faithful parity.
func hasBankSrcHan(src string) bool {
for _, r := range src {
if r >= 0x3400 && r <= 0x9FFF {
return true
}
}
return false
}
// parseBanknote parses the tab-delimited block (integration seam feeding §C evidence + telemetry).
// Tolerant of a TRUNCATED final line when truncatedGeneration is set (§B3-5): a short last line under
// truncation is flagged banknote_truncated, not counted as a parse fail. Any other malformed line
// (fewer than 2 fields, or a src with no Han) sets banknote_parse_fail. Deterministic, no time/rand.
func parseBanknote(block string, truncatedGeneration bool) ([]bankEntry, bankFlags) {
var entries []bankEntry
var flags bankFlags
if strings.TrimSpace(block) == "" {
return entries, flags
}
var lines []string
for _, ln := range strings.Split(block, "\n") {
if strings.TrimSpace(ln) != "" {
lines = append(lines, ln)
}
}
bad := 0
for i, ln := range lines {
raw := bankFieldSplit.Split(strings.TrimSpace(ln), -1)
var parts []string
for _, p := range raw {
if p = strings.TrimSpace(p); p != "" {
parts = append(parts, p)
}
}
if len(parts) < 2 {
// A short LAST line under a truncated generation is a tolerated cut, not a failure.
if i == len(lines)-1 && truncatedGeneration {
flags.Truncated = true
continue
}
bad++
continue
}
src, dst := parts[0], parts[1]
typ := "term"
if len(parts) >= 3 {
typ = strings.ToLower(parts[2])
}
if !bankTypeOK[typ] {
typ = "term"
}
if !hasBankSrcHan(src) {
bad++
continue
}
entries = append(entries, bankEntry{Src: src, Dst: dst, Type: typ})
}
flags.NLines = len(entries)
flags.ParseFail = bad > 0
return entries, flags
}
// --- integration seam (план §4(а) 12 points) ----------------------------------------------------
// bankTokenBudget is the extra max_tokens the translator draft reserves for the ≤12-line footnote block
// (integration point 5): the translation comes FIRST and the block LAST, so a length cut hits the block
// (tolerated), not the translation. ~12 tokens per line. Added ONLY when the channel is enabled.
const bankTokenBudget = bankMaxLines * 12
// applyBanknote slices the banknote block off a translator draft (integration points 15, 8). It runs
// ONLY on the translator role with the channel enabled — the editor never emits banknotes, and a normal
// draft has no separator (the slice is then a no-op that returns the raw text unchanged, so a
// banknote-OFF run is byte-identical). When a ⟦TM-BANK-v1⟧ block IS present it returns the CLEANED
// translation (what classify/coverage/echo/editor/export all see — points 2/3/4) plus that same cleaned
// text as the `stripped` export to commit as a derived checkpoint (point 8), and the per-chunk telemetry
// (point 10). Candidates are PARSED/accepted only under finish=="stop" (the finish=stop-only gate §4б
// a truncated block is not trusted); the slice itself runs regardless, so the length/echo classify never
// sees the footnote's Han src column. Deterministic (a pure function of the raw text + gate state).
func (r *Runner) applyBanknote(role, rawText, finish string) (clean, stripped string, flags bankFlags) {
if role != roleTranslator || !r.Pipeline.Gates.Banknote.Enabled {
return rawText, "", bankFlags{}
}
cleanText, block := splitBanknote(rawText)
if block == "" {
return rawText, "", bankFlags{} // no separator → no strip, no derived checkpoint (byte-identical path)
}
if finish == "stop" {
// finish=stop-only gate: accept candidates + count telemetry only for a complete generation.
// truncatedGeneration=false is unreachable-otherwise in prod (a truncated gen is finish≠stop).
_, flags = parseBanknote(block, false)
}
return cleanText, cleanText, flags
}
// bankDerivedHash is the CONTENT-ADDRESSED id of a banknote-stripped export checkpoint (§4б EXACT
// formula, mirroring commitSanitizedExport's namespacing): sha256("tm-banknote-v1\x00"+reqHash+"\x00"+
// stripped), prefixed "tm-banknote-v1:" so it can never collide with a real hex attempt hash and a
// resume re-derives the identical id for free. The namespace embeds the channel version → a version
// change yields a new id.
func bankDerivedHash(reqHash, stripped string) string {
sum := sha256.Sum256([]byte(bankDerivedNS + "\x00" + reqHash + "\x00" + stripped))
return bankDerivedNS + ":" + hex.EncodeToString(sum[:])
}
// commitBanknoteExport persists the banknote-stripped CLEAN draft as a $0 derived checkpoint and returns
// its request_hash (→ chunk_status.final_hash), so the OK-path final_hash→checkpoint export contract
// (and the editor's resume read) yields the CLEANED draft instead of the raw one carrying the footnote
// (integration point 8). Mirrors commitSanitizedExport: cost 0, escalation 0, idempotent, written BEFORE
// chunk_status references it. `att` is the terminal (possibly escalated) OK attempt whose block was
// sliced; att.bankStripped is the cleaned text.
func (r *Runner) commitBanknoteExport(st config.Stage, ch Chunk, job *store.Job, att stageAttempt) (string, error) {
derivedHash := bankDerivedHash(att.reqHash, att.bankStripped)
if err := r.Store.PutDerivedCheckpoint(store.Checkpoint{
RequestHash: derivedHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: att.attempt,
Stage: st.Name, Role: st.Role, ModelRequested: att.modelActual, ModelActual: att.modelActual,
ResponseText: att.bankStripped, UsageJSON: "{}", FinishReason: "banknote_export",
}); err != nil {
return "", fmt.Errorf("pipeline: commit banknote export ch%d/chunk%d/%s: %w", ch.Chapter, ch.ChunkIdx, st.Name, err)
}
return derivedHash, nil
}

View file

@ -0,0 +1,249 @@
package pipeline
import (
"context"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// banknote_integration_test.go: the WS4 (д) end-to-end pins for the banknote channel wired into the
// runner (banknote ON), covering the load-bearing integration points §4(а): the block is SLICED before
// the editor/gates (points 1-4), the cleaned draft is committed as a derived export and final_hash is
// re-pointed (point 8), resume re-serves the cleaned draft at $0 (point 7), the echo/length classify
// runs over the CLEANED text (point 3), the telemetry is persisted (point 10), and banknoteSnap folds
// into the snapshot only when enabled (point 6 / §4в). The banknote-OFF golden fixture stays byte-stable
// (the TestGolden guard), so these ON-only cells live here rather than in the shared golden.
// bankBlock is a well-formed 2-line banknote block a translator emits after a clean RU translation.
const bankBlock = bankSeparator + "\n龙公\tЛун Гун\tname\n蛊\tгу\tterm"
func TestBanknoteSliceReTelemetryResume(t *testing.T) {
rec := &reqRec{}
const cleanDraft = "Тихое утро в библиотеке."
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
// The editor MUST receive the CLEANED draft — the block was sliced off upstream (point 8).
if strings.Contains(body, bankSeparator) || strings.Contains(body, "龙公") {
t.Errorf("editor request leaked the banknote block:\n%s", body)
}
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return cleanDraft + "\n" + bankBlock, "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true})
ctx := context.Background()
r1 := newRunner(t, bookPath)
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
ch := res1.Chunks[0]
// The draft stage's usable text is the CLEANED translation (block removed, points 1-3).
if got := ch.Stages[0].Text; got != cleanDraft {
t.Fatalf("draft text must be the cleaned translation %q, got %q", cleanDraft, got)
}
if ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("final text must come from the edit over the cleaned draft, got %q", ch.FinalText)
}
// The draft chunk_status.final_hash must be RE-POINTED at the tm-banknote-v1 derived checkpoint (point 8).
cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft")
if cs == nil || !strings.HasPrefix(cs.FinalHash, bankDerivedNS+":") {
t.Fatalf("draft final_hash must be a tm-banknote-v1 derived checkpoint, got %+v", cs)
}
// The derived checkpoint holds the CLEANED draft (so export/resume yield it, not the raw block).
dcp, err := r1.Store.GetCheckpoint(cs.FinalHash)
if err != nil || dcp == nil {
t.Fatalf("derived checkpoint missing: %v", err)
}
if dcp.ResponseText != cleanDraft || dcp.FinishReason != "banknote_export" {
t.Fatalf("derived checkpoint = %q/%q, want cleaned draft / banknote_export", dcp.ResponseText, dcp.FinishReason)
}
// Telemetry (point 10): 2 accepted lines, no parse fail / truncation.
rs, _ := r1.Store.GetRetrievalState("test-book", 1, 0)
if rs == nil || rs.NBanknoteLines != 2 || rs.BanknoteParseFail != 0 || rs.BanknoteTruncated != 0 {
t.Fatalf("banknote telemetry wrong: %+v", rs)
}
callsAfterRun1 := rec.count()
r1.Close()
// Resume (new process): $0, NO new provider calls, cleaned draft re-served identically (point 7).
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("resume must not re-call the provider, calls %d -> %d", callsAfterRun1, rec.count())
}
if res2.TotalUSD != 0 || res2.Chunks[0].FinalText != res1.Chunks[0].FinalText {
t.Fatalf("resume must be $0 and byte-identical, got %+v", res2.Chunks[0])
}
// FL-2: the resume must PRESERVE the banknote telemetry, not zero it. resumeFromChunkStatus serves the
// banknote-CLEANED derived checkpoint and never re-parses the raw block, so without the carry-forward
// persistRetrievalState would overwrite NBanknoteLines 2 -> 0 on this second run. Re-read to guard it.
rs2, _ := r2.Store.GetRetrievalState("test-book", 1, 0)
if rs2 == nil || rs2.NBanknoteLines != 2 || rs2.BanknoteParseFail != 0 || rs2.BanknoteTruncated != 0 {
t.Fatalf("FL-2: resume must preserve banknote telemetry (want 2/0/0), got %+v", rs2)
}
}
// A clean RU translation followed by a Han-heavy banknote block must classify OK — the echo/CJK check
// runs over the CLEANED text (point 3). WITHOUT the slice, the block's Han src column would push
// cjk_share over the 15% threshold and false-flag cjk_artifact.
func TestBanknoteClassifyOverCleaned(t *testing.T) {
rec := &reqRec{}
// A short RU translation + a LONG Han-heavy block: raw cjk_share > 0.15, cleaned cjk_share ≈ 0.
bigBlock := bankSeparator + "\n" +
strings.Repeat("龙公\tЛун Гун\tname\n古月\tГу Юэ\tname\n方源\tФан Юань\tname\n", 3)
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ред", "stop"
}
return "Он ушёл." + "\n" + bigBlock, "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
ch := res.Chunks[0]
if ch.Stages[0].Disposition != DispOK {
t.Fatalf("a clean RU draft + Han banknote block must classify OK over the cleaned text, got %+v", ch.Stages[0])
}
if ch.Stages[0].Text != "Он ушёл." {
t.Fatalf("draft text must be the cleaned translation, got %q", ch.Stages[0].Text)
}
}
// Enabling the banknote channel folds banknoteSnap into the snapshot (point 6 / §4в); a banknote-OFF
// project renders a DIFFERENT snapshot (so a channel flip is a loud --resnapshot). Off omits the field
// (omitempty), on adds banknote:{enabled,parser_version,token_budget} (token_budget = FL-1: the
// wire-affecting max_tokens the channel adds, folded so editing bankMaxLines is a loud --resnapshot).
func TestBanknoteSnapshotFold(t *testing.T) {
srv := httptest.NewServer(nil)
defer srv.Close()
off := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{}))
defer off.Close()
on := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{banknote: true}))
defer on.Close()
offID, offPayload, err := off.snapshotID()
if err != nil {
t.Fatal(err)
}
onID, onPayload, err := on.snapshotID()
if err != nil {
t.Fatal(err)
}
if offID == onID {
t.Fatalf("enabling the banknote channel must move the snapshot (loud --resnapshot)")
}
if strings.Contains(offPayload, "banknote") {
t.Fatalf("a banknote-OFF snapshot must omit the field (omitempty), got: %s", offPayload)
}
if !strings.Contains(onPayload, fmt.Sprintf(`"banknote":{"enabled":true,"parser_version":"%s","token_budget":%d}`, bankParserVersion, bankTokenBudget)) {
t.Fatalf("a banknote-ON snapshot must fold {enabled,parser_version,token_budget}, got: %s", onPayload)
}
}
// A truncated banknote generation (finish=length) is a length flag (retryable) — the block is not parsed
// (finish=stop-only gate §4б), and with the tolerant parser held for robustness it never counts a parse
// fail. Here a channel-off run is unaffected; this pins that the stop-only gate does not accept a
// truncated block's candidates. With regenerate=0 the length draft flags immediately.
func TestBanknoteFinishStopOnlyGate(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ред", "stop"
}
// A truncated generation: clean text + a partial block, finish=length.
return "Незаконченный перевод" + "\n" + bankSeparator + "\n龙公\tЛун", "length"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true, regenerate: 0})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
ch := res.Chunks[0]
// finish=length → flagged length (the block is not accepted; the translation is retried in prod).
if ch.Stages[0].Disposition != DispFlagged || ch.Stages[0].FlagReason != FlagLength {
t.Fatalf("a truncated generation must flag length (stop-only gate rejects the block), got %+v", ch.Stages[0])
}
// No candidates accepted under a non-stop finish → zero telemetry.
rs, _ := r.Store.GetRetrievalState("test-book", 1, 0)
if rs != nil && rs.NBanknoteLines != 0 {
t.Fatalf("a non-stop generation must accept 0 banknote lines, got %+v", rs)
}
}
// The banknote slice + derived-commit + re-point must run AFTER the escalation resolve and consume the
// ESCALATED draft's stripped text (WS4 point 8's escalation-ordering note): the primary echoes CJK
// (cjk_artifact) → escalates → the fallback returns a clean RU translation + a banknote block, which is
// sliced and committed as a derived checkpoint keyed on the ESCALATED attempt's request_hash.
func TestBanknoteEscalatedDraftStripped(t *testing.T) {
rec := &reqRec{}
const fbClean = "Тихое утро в библиотеке."
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
if strings.Contains(body, bankSeparator) {
t.Errorf("editor leaked the escalated banknote block:\n%s", body)
}
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if strings.Contains(body, "fake-fallback") {
return fbClean + "\n" + bankBlock, "stop" // the fallback emits a clean translation + banknote
}
return "静かな図書館の朝。", "stop" // the primary echoes the CJK source → cjk_artifact
})
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 1.0, false, "")
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
writeFile(t, pipePath, string(raw)+"\ngates:\n banknote:\n enabled: true\n")
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
ch := res.Chunks[0]
draft := ch.Stages[0]
if !draft.Escalated || draft.EscalationModel != "fake-fallback" {
t.Fatalf("the draft must have escalated to fake-fallback, got %+v", draft)
}
// The escalated draft's usable text is the CLEANED fallback translation (block sliced).
if draft.Text != fbClean {
t.Fatalf("escalated draft text must be the cleaned fallback translation %q, got %q", fbClean, draft.Text)
}
// final_hash re-pointed at a tm-banknote-v1 derived checkpoint holding the cleaned ESCALATED draft.
cs, _ := r.Store.GetChunkStatus("test-book", 1, 0, "draft")
if cs == nil || !strings.HasPrefix(cs.FinalHash, bankDerivedNS+":") {
t.Fatalf("escalated draft final_hash must be a derived checkpoint, got %+v", cs)
}
dcp, _ := r.Store.GetCheckpoint(cs.FinalHash)
if dcp == nil || dcp.ResponseText != fbClean {
t.Fatalf("derived checkpoint must hold the cleaned escalated draft, got %+v", dcp)
}
if ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("final text must come from the edit over the cleaned escalated draft, got %q", ch.FinalText)
}
}

View file

@ -0,0 +1,101 @@
package pipeline
import (
"strings"
"testing"
)
// banknote_test.go pins the WS4 §4(д) parser invariants (byte-faithful port of exp16 banknote.py).
// The 95-line corpus pin (0 parse_fail / 0 truncated over 14 saved raw outputs) is the domain of the
// $0 reference eval/design11/ws4_banknote_verify.py; these unit tests cover the parser CONTRACT.
func TestSplitBanknoteNoSeparatorIsAllClean(t *testing.T) {
out := "Судзуки шёл по коридору. \n"
clean, block := splitBanknote(out)
if clean != "Судзуки шёл по коридору." || block != "" {
t.Fatalf("no-SEP: clean=%q block=%q, want the whole right-trimmed output and empty block", clean, block)
}
}
func TestSplitBanknoteSlicesBlock(t *testing.T) {
out := "Перевод... последнее предложение.\n\n" + bankSeparator + "\n方源\tФан Юань\tname\n蛊师\tгу-мастер\ttitle"
clean, block := splitBanknote(out)
if !strings.HasSuffix(clean, "последнее предложение.") {
t.Fatalf("clean tail wrong: %q", clean)
}
if strings.Contains(clean, bankSeparator) || strings.Contains(clean, "方源") {
t.Fatalf("the banknote block leaked into the clean translation: %q", clean)
}
ents, flags := parseBanknote(block, false)
if len(ents) != 2 || flags.ParseFail || flags.Truncated || flags.NLines != 2 {
t.Fatalf("parse = %+v flags=%+v, want 2 clean entries", ents, flags)
}
if ents[0].Src != "方源" || ents[0].Dst != "Фан Юань" || ents[0].Type != "name" {
t.Fatalf("entry[0] = %+v", ents[0])
}
if ents[1].Type != "title" {
t.Fatalf("entry[1] type = %q, want title", ents[1].Type)
}
}
func TestParseBanknoteTolerantFieldSplit(t *testing.T) {
// tab, ≥2 spaces, and pipe are all accepted delimiters (a model that emits spaces instead of a
// real TAB still parses). type falls back to "term" when absent or unknown.
block := "方源\tФан Юань\tname\n蛊师 гу-мастер title\n青茅山 | гора Цинмао | place\n古月\tГу Юэ"
ents, flags := parseBanknote(block, false)
if flags.ParseFail {
t.Fatalf("tolerant split should not fail: %+v", flags)
}
if len(ents) != 4 {
t.Fatalf("want 4 entries across tab/space/pipe delimiters, got %d: %+v", len(ents), ents)
}
if ents[3].Type != "term" { // no type field → default term
t.Fatalf("missing type must default to term, got %q", ents[3].Type)
}
}
func TestParseBanknoteNonHanSrcIsBad(t *testing.T) {
// A zh→ru channel line whose src has no Han ideograph is malformed → parse_fail.
block := "方源\tФан Юань\tname\nRoseanne\tРозанна\tname"
ents, flags := parseBanknote(block, false)
if !flags.ParseFail {
t.Fatalf("a non-Han src must set parse_fail")
}
if len(ents) != 1 || ents[0].Src != "方源" {
t.Fatalf("only the Han-src line should survive, got %+v", ents)
}
}
func TestParseBanknoteTruncationTolerated(t *testing.T) {
// The LAST line cut by generation length is tolerated (banknote_truncated), not a parse fail —
// but ONLY under truncated_generation; the same short line otherwise IS a parse fail.
block := "方源\tФан Юань\tname\n蛊" // last line has no dst
entsT, flagsT := parseBanknote(block, true)
if !flagsT.Truncated || flagsT.ParseFail || len(entsT) != 1 {
t.Fatalf("truncated=true: want 1 entry + truncated flag + no parse_fail, got %+v %+v", entsT, flagsT)
}
entsF, flagsF := parseBanknote(block, false)
if flagsF.Truncated || !flagsF.ParseFail || len(entsF) != 1 {
t.Fatalf("truncated=false: the short last line must be a parse_fail, got %+v %+v", entsF, flagsF)
}
}
func TestParseBanknoteEmptyBlock(t *testing.T) {
ents, flags := parseBanknote("", false)
if len(ents) != 0 || flags.ParseFail || flags.Truncated || flags.NLines != 0 {
t.Fatalf("empty block must yield no entries and clean flags, got %+v %+v", ents, flags)
}
}
func TestBankSeparatorIsNotANoteWord(t *testing.T) {
// The separator must not be a trailing-note word the sanitizer reserves (§B3-1) — the two
// channels must never collide.
for _, note := range []string{"Примечание", "Сноска", "Комментарий", "Note", "TN"} {
if strings.Contains(bankSeparator, note) {
t.Fatalf("separator %q collides with the reserved note-word %q", bankSeparator, note)
}
}
if !strings.HasPrefix(bankSeparator, "⟦") {
t.Fatalf("separator lost its unusual delimiter: %q", bankSeparator)
}
}

View file

@ -36,6 +36,9 @@ type StageResult struct {
// fallback passed the re-gate, Model above is the fallback (it answered).
Escalated bool
EscalationModel string // the fallback model when Escalated ("" otherwise)
// BankFlags carries the translator draft's banknote telemetry (WS4 point 10): accepted line count +
// parse-fail / truncation flags. Zero-valued on every non-translator stage and every channel-off run.
BankFlags bankFlags
}
// ChunkOutcome is one chunk's result across the stage list.
@ -97,6 +100,15 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return nil, err
}
// the precompute pass: eager-build every reachable client BEFORE any wave goroutine, so the clients map is
// read-only in the waves (r.client is lock-free, a miss is loud — closes the D12 lazy-init race).
if err := r.buildClients(); err != nil {
return nil, err
}
// the precompute pass: build the per-model rate-guards (transport axis, read-only in the waves; a no-op until a
// model configures rate_limit — WS1 §1б, the mistral-arm precondition WS6).
r.buildRateGuards()
// Persist captured ruby readings (idempotent; consumed by seedGlossary below into
// auto glossary candidates — шаг 4). Never injected into a prompt here (§7d); a
// resume re-persists the same rows at $0.
@ -111,63 +123,30 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return nil, err
}
// Snapshot is book-level (brief + stage plan + memory), computed once and
// upserted before the loop; every job pins to it. memoryVersion() now reflects the
// materialized bank, so a changed approved glossary re-pins loudly (F1).
snapID, snapPayload, err := r.snapshotID()
if err != nil {
return nil, err
}
if err := r.Store.UpsertSnapshot(snapID, r.Book.BriefHash(), snapPayload); err != nil {
return nil, fmt.Errorf("pipeline: upsert snapshot %.12s: %w", snapID, err)
}
chunks := SplitChunks(doc.Chapters)
chunks := SplitChunks(doc.Chapters, r.segBudget())
if len(chunks) == 0 {
return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile)
}
// Масштаб прогона — одной строкой на stderr (боль smoke-прогона: N/M и знаменатель
// прогресса не появлялись в логах вообще, только в stdout/status).
r.Log.InfoContext(ctx, "book run started", "book", r.Book.BookID, "snapshot", snapID[:12],
"chapters", len(doc.Chapters), "chunks", len(chunks), "stages_per_chunk", len(r.Pipeline.Stages))
r.Log.InfoContext(ctx, "book run started", "book", r.Book.BookID,
"chapters", len(doc.Chapters), "chunks", len(chunks), "stages", len(r.Pipeline.Stages))
res := &BookResult{BookID: r.Book.BookID}
// Sticky scene-inertia state (A5): the exact-matched ids of the recent chunks in
// the CURRENT chapter, reset at a chapter boundary (a new chapter is a scene change).
// Rebuilt deterministically each run because translateChunk recomputes the ($0)
// selection for EVERY chunk, resumed ones included — so sticky is resume-stable.
var stickyWin []map[string]injectionDisposition
prevChapter := 0
for i, ch := range chunks {
if ch.Chapter != prevChapter {
stickyWin = nil
prevChapter = ch.Chapter
// Пульс прогресса на границе главы: done/total + деньги ЭТОГО прогона —
// оператор длинной книги видит движение, не открывая status.
r.Log.InfoContext(ctx, "chapter started", "chapter", ch.Chapter,
"chunks_done", i, "chunks_total", len(chunks), "run_usd", fmt.Sprintf("%.6f", res.TotalUSD))
}
outcome, activeIDs, err := r.translateChunk(ctx, snapID, ch, unionSticky(stickyWin))
if err != nil {
// Infra failure: abort. Chunks already committed are checkpointed;
// resume continues from here at $0 for the done work.
return res, err
}
res.Chunks = append(res.Chunks, *outcome)
res.TotalUSD += outcome.CostUSD
if outcome.Disposition == DispFlagged {
res.Flagged++
}
// Advance the sticky window (keep the last stickyDepth chunks' exact matches).
stickyWin = append(stickyWin, activeIDs)
if len(stickyWin) > stickyDepth {
stickyWin = stickyWin[len(stickyWin)-stickyDepth:]
}
}
r.Log.InfoContext(ctx, "book run finished", "book", r.Book.BookID,
"chunks", len(res.Chunks), "flagged", res.Flagged, "run_usd", fmt.Sprintf("%.6f", res.TotalUSD))
return res, nil
// the precompute pass: pre-compute the sticky-chain memory selection for EVERY chunk (WS1 §1б, precomputeSticky). The
// sticky window (A5 scene-inertia — the recent chunks' exact matches in the current chapter, reset at
// a chapter boundary) is a CROSS-CHUNK sequential dependency, so it is computed once here in the precompute pass and
// consumed by the parallel draft-wave workers (it cannot be recomputed inside a wave). Byte-identical to the
// retired inline computation (Select is $0 and pure — the golden test proves the injected bytes hold).
// The draft wave selects over the BASE bank (mined-excluded) so its injection is byte-identical across
// a bank-mining enrichment (the review-confirmed «переоплата ОДНА» fix); the editor uses the enriched bank.
stickySel := precomputeSticky(chunks, r.baseMemory, r.Pipeline.Context.GlossaryTokenBudget)
// The wave executor (R1, waverun.go): the draft wave (draft ∥) → the bank-mining stop → the edit wave (edit ∥). It computes +
// upserts the per-wave snapshots (draft-wave snapshot base-bank / edit-wave snapshot enriched) itself and pins each
// wave's jobs to its own — a the bank-mining stop enrichment moves only edit-wave snapshot, keeping draft-wave checkpoints valid
// («переоплата ОДНА»). Returns a *WaveSignatureStop when the bank-mining stop stops for owner sign.
return r.translateBookWaves(ctx, chunks, stickySel)
}
// unionSticky merges the recent chunks' exact-matched ids into one sticky_prev, carrying

View file

@ -38,7 +38,14 @@ import (
// false-flagged against a STRAY output integer (a year, a count) — only a mismatching magnitude
// WORD triggers, bare integers can merely confirm; (3) same mechanism covers a 万-in-a-name +
// unrelated output number. No live book has run under v1 (D18 acceptance pending), so the re-pin is free.
const cheapGateVersion = "cheapgate-v2"
//
// v3 (WS5, R4) adds the defect-class checkers DC1 (时辰 double-hour units), DC2 (千万/数十万 magnitude
// scale) and DC6 (register negative-list — the zh-ru pack, checkers_zh_ru.go). They are observability
// (never a disposition), tuned precision-over-recall; the bump is a loud --resnapshot as the discipline
// requires (a rule/pack edit shifts recorded counts). They fire 0 on a non-zh source / non-register text.
// Ш-2 (see memnorm.go): the cheap style/DC checkers classify via unicode predicates + NFC folding, so a
// toolchain Unicode bump that shifts a class is a loud --resnapshot rather than a silent count change.
const cheapGateVersion = "cheapgate-v3-dc-checkers+u" + unicode.Version
// cheapGateConfig carries the brief-derived knobs: the ё-policy and the per-project allowlist of
// surfaces that look like a blocklisted interjection but are legitimate here (e.g. a character
@ -55,20 +62,26 @@ type cheapGateConfig struct {
// cheapGateResult is the per-chunk outcome: a count per flagger plus human-readable detail lines
// (deterministic order) for the report. total() is what the passport surfaces.
type cheapGateResult struct {
DialogueDash int `json:"dialogue_dash,omitempty"`
YoInconsistent int `json:"yo,omitempty"`
TranslitInterj int `json:"translit_interj,omitempty"`
NumberMagnitude int `json:"number_magnitude,omitempty"`
DialogueDash int `json:"dialogue_dash,omitempty"`
YoInconsistent int `json:"yo,omitempty"`
TranslitInterj int `json:"translit_interj,omitempty"`
NumberMagnitude int `json:"number_magnitude,omitempty"`
// LengthCollapse / NumberDrift are the opt-in post-reflow regression guard (D38,
// regressionguard.go), folded into this observability result. They stay 0 unless
// cfg.regressionEnabled, so a book that does not enable the guard serialises identically.
LengthCollapse int `json:"length_collapse,omitempty"`
NumberDrift int `json:"number_drift,omitempty"`
Detail []string `json:"detail,omitempty"`
LengthCollapse int `json:"length_collapse,omitempty"`
NumberDrift int `json:"number_drift,omitempty"`
// DC1TimeUnits / DC2Magnitude / DC6Register are the WS5 defect-class checkers (checkers_zh_ru.go),
// observability like the others. Zero on a non-zh source / non-register final (the golden fixture).
DC1TimeUnits int `json:"dc1_time_units,omitempty"`
DC2Magnitude int `json:"dc2_magnitude,omitempty"`
DC6Register int `json:"dc6_register,omitempty"`
Detail []string `json:"detail,omitempty"`
}
func (c cheapGateResult) total() int {
return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude + c.LengthCollapse + c.NumberDrift
return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude + c.LengthCollapse + c.NumberDrift +
c.DC1TimeUnits + c.DC2Magnitude + c.DC6Register
}
// runCheapGates runs the four always-on style flaggers over one chunk's source and FINAL text, plus
@ -87,6 +100,16 @@ func runCheapGates(source, draft, final string, cfg cheapGateConfig) cheapGateRe
n, det = lintNumberMagnitude(source, final)
r.NumberMagnitude = n
r.Detail = append(r.Detail, det...)
// WS5 defect-class checkers (DC1/DC2/DC6, checkers_zh_ru.go) — src↔target observability flaggers.
n, det = lintTimeUnits(source, final)
r.DC1TimeUnits = n
r.Detail = append(r.Detail, det...)
n, det = lintMagnitudeScale(source, final)
r.DC2Magnitude = n
r.Detail = append(r.Detail, det...)
n, det = lintRegisterLexicon(final)
r.DC6Register = n
r.Detail = append(r.Detail, det...)
if cfg.regressionEnabled {
rg := runRegressionGuard(draft, final)
r.LengthCollapse = rg.LengthCollapse
@ -210,7 +233,7 @@ func quoteShape(after []rune) bool {
// needs a ё-dictionary (deferred, B-tier). Names — the primary target (Пётр/Петр) — are never
// homographs, so they are always caught regardless.
var yoHomographEForms = map[string]bool{
"все": true, "всех": true, "всем": true, "всеми":true,
"все": true, "всех": true, "всем": true, "всеми": true,
"небо": true, "небом": true,
"узнаем": true, "узнаете": true, "узнает": true,
"падеж": true, "падежа": true,

View file

@ -0,0 +1,168 @@
package pipeline
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
)
// checkers_zh_ru.go: the WS5 defect-class checkers DC1 (时辰 double-hour units), DC2 (千万/数十万 magnitude
// scale) and DC6 (register negative-list) — deterministic, $0 flaggers on the source↔FINAL text, ported
// from the frozen ws5_checkers_verify.py. Like the four cheap style gates they are OBSERVABILITY, NEVER a
// disposition (research/20: judges too noisy → deterministic gates; a hit is recorded in the retrieval-
// state / report, never dropping a chunk). They are tuned PRECISION over recall — DC1/DC2 fire only on an
// EXPLICIT src↔target mismatch; the LANDING (whether the flag is trusted) is gated by a §5(д) false-
// positive measure on a fresh rerun, not by this build. Their rule VERSION rides cheapGateVersion (folded
// into the snapshot as StyleCheckVersion), so editing a rule / the pack is a loud --resnapshot.
//
// PER-PAIR PACK (слой-2 data, §5(в)): the unit table + negative-list below are the zh-ru convention pack.
// They are code consts versioned by cheapGateVersion (the same discipline the existing cheap-gate data
// uses), not a config-loaded per-pair file — a config-loaded versioned pack is a cleaner future form
// (noted residual). The checkers self-gate on source content (时辰/千万 are zh-specific → no fire on a
// non-zh source), and DC6 is target-side (gated behind ru like the other readability flaggers).
// --- DC-1: 时辰 (double-hour) unit checker (ws5.shichen_checker) -----------------------------------
// dcCNNum maps the small CJK numerals the 时辰 pattern accepts to their value (1 时辰 = 2 modern hours).
var dcCNNum = map[rune]int{
'一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
}
// dcShichenRE captures the count before 时辰 (an Arabic digit or a small CJK numeral), tolerating 个.
var dcShichenRE = regexp.MustCompile(`([0-9一二两三四五六七八九十])\s*个?\s*时辰`)
// dcRuHoursRE captures a Russian "<count> час…" rendering; the alternatives mirror the reference map.
var dcRuHoursRE = regexp.MustCompile(`(\d+|один|два|двух|три|трёх|трех|четыре|пять|шесть)\s+час`)
// dcRuHourWord maps the Russian count words dcRuHoursRE captures to their value.
var dcRuHourWord = map[string]int{
"один": 1, "два": 2, "двух": 2, "три": 3, "трёх": 3, "трех": 3, "четыре": 4, "пять": 5, "шесть": 6,
}
// lintTimeUnits flags a 时辰 (=2h) unit error: N个时辰 rendered as N часов (the count copied as hours)
// instead of ~2N hours (三个时辰 → «три часа» should be ~6h). It fires ONLY on an explicit mismatch — a
// paraphrase with no hours count is a valid rendering, not a defect (the reference dropped the "no hours
// found" branch that false-flagged 1.8%). Pure and deterministic.
func lintTimeUnits(source, final string) (int, []string) {
m := dcShichenRE.FindStringSubmatch(source)
if m == nil {
return 0, nil
}
n, ok := dcParseCount(m[1])
if !ok {
return 0, nil
}
hm := dcRuHoursRE.FindStringSubmatch(final)
if hm == nil {
return 0, nil // no explicit hours rendering → a valid paraphrase, not a defect
}
ruNum, ok := dcParseRuHours(hm[1])
if !ok {
return 0, nil
}
expectedHours := n * 2
if ruNum == n && ruNum != expectedHours {
return 1, []string{fmt.Sprintf("DC1 时辰: %d个时辰 передано как «%d час…» (счёт как часы) вместо ~%d ч (1 时辰 = 2 ч)", n, ruNum, expectedHours)}
}
return 0, nil
}
// dcParseCount parses the DC1 count group: an Arabic digit string or a single small CJK numeral.
func dcParseCount(s string) (int, bool) {
if v, err := strconv.Atoi(s); err == nil {
return v, true
}
r := []rune(s)
if len(r) == 1 {
if v, ok := dcCNNum[r[0]]; ok {
return v, true
}
}
return 0, false
}
// dcParseRuHours parses the DC1 hours group: an Arabic digit string or a Russian count word.
func dcParseRuHours(s string) (int, bool) {
if v, err := strconv.Atoi(s); err == nil {
return v, true
}
if v, ok := dcRuHourWord[s]; ok {
return v, true
}
return 0, false
}
// --- DC-2: number-scale magnitude checker (ws5.magnitude_checker, 千万 / 数十万) --------------------
// DC2 regexes — a BYTE-FAITHFUL port of ws5.magnitude_checker (_MAG). The ok_re SUPPRESSION guards are
// case-INsensitive (the reference passes re.I); the FIRE predicates are case-SENSITIVE (the reference
// does NOT pass re.I to the inner тысяч/десятки-тысяч searches). \w is ported as \p{L}* (RE2's \w is
// ASCII; the Cyrillic word-continuation the reference intends is letters).
var (
dcQianwanOkRE = regexp.MustCompile(`(?i)десят\p{L}* миллион|10\s*000\s*000|10000000`) // 千万 correct render → suppress
dcShushiwanOkRE = regexp.MustCompile(`(?i)сотн\p{L}* тысяч|нескольк\p{L}* сот\p{L}* тысяч|[1-9]00\s*000`) // 数十万 correct → suppress
dcDesyatkiTysRE = regexp.MustCompile(`десятк\p{L}* тысяч`) // 数十万 under-render (case-SENSITIVE, per reference)
)
// lintMagnitudeScale flags a 千万 (10^7) / 数十万 (~several×10^5) magnitude rendered at a WRONG smaller
// scale (a разряд error DC2 targets). 千万 → «тысячи» without «миллион» = 10000× under; 数十万 → «десятки
// тысяч» = 10× under. A CORRECT rendering anywhere in the chunk (the ok_re guard) SUPPRESSES the flag, so
// a chunk that renders 数十万 as «сотни тысяч» does not fire even if «десятки тысяч» appears elsewhere as
// an unrelated quantity (the ws5 reference parity — required so the §5(д) FP-measure taken against the
// frozen reference matches what ships). ⚠ 千万 is also a stock HYPERBOLE («несметно») whose «тысячи»
// rendering is in-register literary, NOT a hard error (§5 A4) — hyperbole-exposed, landing FP-gated §5(д).
// Observability only. (Word↔word fraction inversion — 四成四=44% via q4a rule_b1/b2 — is a residual
// sub-checker, not built here.)
func lintMagnitudeScale(source, final string) (int, []string) {
var flags []string
if strings.Contains(source, "千万") && !dcQianwanOkRE.MatchString(final) {
if strings.Contains(final, "тысяч") && !strings.Contains(final, "миллион") { // case-sensitive, per reference
flags = append(flags, "DC2 千万=10^7 передано как «тысячи» (≈10000× занижение) — возможна ошибка разряда (гипербола-риск, §5-A4)")
}
}
if strings.Contains(source, "数十万") && !dcShushiwanOkRE.MatchString(final) {
if dcDesyatkiTysRE.MatchString(final) {
flags = append(flags, "DC2 数十万≈неск.×10^5 передано как «десятки тысяч» (≈10× занижение)")
}
}
return len(flags), flags
}
// --- DC-6: register-lexicon negative-list (ws5.register_checker) ----------------------------------
// dcRegisterNegList is the zh-ru register negative-list (слой-2 pack): fairy-tale-Russian / chancery
// lexemes that break the xianxia register («терем» in a cultivation novel is a hard register error). The
// owner extends this per corpus finding. Lower-cased; whole-word matched.
var dcRegisterNegList = []string{"терем", "терема", "тереме", "теремом", "терему", "теремах"}
// lintRegisterLexicon flags whole-word occurrences of a register negative-list lexeme in the FINAL text.
func lintRegisterLexicon(final string) (int, []string) {
low := []rune(strings.ToLower(final))
hitSet := map[string]bool{}
for _, w := range dcRegisterNegList {
wr := []rune(w)
for i := 0; i+len(wr) <= len(low); i++ {
if !runesEqual(low[i:i+len(wr)], wr) {
continue
}
if (i == 0 || !isCyrLetter(low[i-1])) && (i+len(wr) == len(low) || !isCyrLetter(low[i+len(wr)])) {
hitSet[w] = true
}
}
}
if len(hitSet) == 0 {
return 0, nil
}
hits := make([]string, 0, len(hitSet))
for w := range hitSet {
hits = append(hits, w)
}
sort.Strings(hits)
return len(hits), []string{"DC6 регистр: сказочно-русская лексика вне жанра сянься: " + strings.Join(hits, ", ")}
}
// isCyrLetter reports whether r is a Cyrillic letter (the word boundary for the register match).
func isCyrLetter(r rune) bool { return unicode.IsLetter(r) && unicode.Is(unicode.Cyrillic, r) }

View file

@ -0,0 +1,107 @@
package pipeline
import (
"strings"
"testing"
)
// checkers_zh_ru_test.go: WS5 (г) — the DC1/DC2/DC6 checkers must CATCH the empirical trap positives
// (ws5_checkers_verify.py §a) and stay SILENT on clean/in-register text (precision over recall). These
// are the regression fixtures the plan requires (positives from exp15 §7.9 / exp14b).
func TestDC1TimeUnits(t *testing.T) {
cases := []struct {
name, src, tgt string
wantFlag bool
}{
{"count-as-hours", "他闭关了三个时辰。", "Он затворился на три часа.", true}, // 三时辰=6h rendered «три часа»
{"correct-conversion", "他闭关了三个时辰。", "Он затворился на шесть часов.", false}, // 6h — correct, no flag
{"paraphrase-no-hours", "他闭关了三个时辰。", "Он затворился надолго.", false}, // no hours count → valid
{"no-shichen", "他走了三里。", "Он прошёл три ли.", false},
{"arabic-count", "过了2个时辰。", "Прошло 2 часа.", true}, // 2时辰=4h rendered «2 часа»
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := lintTimeUnits(c.src, c.tgt)
if (n > 0) != c.wantFlag {
t.Fatalf("lintTimeUnits(%q,%q) fired=%v, want %v", c.src, c.tgt, n > 0, c.wantFlag)
}
})
}
}
func TestDC2MagnitudeScale(t *testing.T) {
cases := []struct {
name, src, tgt string
wantFlag bool
}{
{"qianwan-as-thousands", "有千万条蛊虫。", "Там были тысячи гу-червей.", true}, // 千万=10M as «тысячи»
{"qianwan-correct", "有千万条蛊虫。", "Там были десятки миллионов гу-червей.", false}, // «миллион» present → ok
{"shushiwan-as-tens", "聚集了数十万人。", "Собрались десятки тысяч человек.", true}, // 数十万 as «десятки тысяч»
{"shushiwan-correct", "聚集了数十万人。", "Собрались сотни тысяч человек.", false}, // «сотни тысяч» → ok
// ws5 parity (R4-review MAJOR): a CORRECT rendering «сотни тысяч» SUPPRESSES the flag even when
// «десятки тысяч» appears elsewhere as an unrelated quantity (the ok_re guard).
{"shushiwan-okre-suppress", "聚集了数十万人。", "Здесь сотни тысяч воинов, а не десятки тысяч слуг.", false},
// ws5 parity (R4-review MINOR): the inner fire check is CASE-SENSITIVE (reference no re.I), so a
// sentence-initial capitalized «Десятки тысяч» does NOT fire.
{"case-sensitive-capitalized", "聚集了数十万人。", "Десятки тысяч человек собрались.", false},
{"no-magnitude", "他有三个朋友。", "У него три друга.", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, _ := lintMagnitudeScale(c.src, c.tgt)
if (n > 0) != c.wantFlag {
t.Fatalf("lintMagnitudeScale(%q,%q) fired=%v, want %v", c.src, c.tgt, n > 0, c.wantFlag)
}
})
}
}
func TestDC6RegisterLexicon(t *testing.T) {
cases := []struct {
name, tgt string
wantN int
}{
{"terem", "Он вошёл в высокий терем.", 1},
{"terem-inflected", "В тереме было тихо, у терема стоял страж.", 2}, // тереме + терема → 2 distinct register forms
{"clean", "Он вошёл в высокий зал павильона.", 0},
{"substring-not-word", "Термин был странным.", 0}, // «терм» inside «термин» must NOT fire (whole-word)
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
n, det := lintRegisterLexicon(c.tgt)
if n != c.wantN {
t.Fatalf("lintRegisterLexicon(%q) = %d (%v), want %d", c.tgt, n, det, c.wantN)
}
})
}
}
// The DC checkers must stay SILENT on a non-zh / clean chunk (they self-gate on content), so a
// non-Chinese book never accrues style flags from them.
func TestDCCheckersSilentOnCleanChunk(t *testing.T) {
r := runCheapGates("静かな図書館の朝。", "", "Тихое утро в библиотеке.", cheapGateConfig{})
if r.DC1TimeUnits != 0 || r.DC2Magnitude != 0 || r.DC6Register != 0 {
t.Fatalf("DC checkers must be silent on a clean ja→ru chunk, got %+v", r)
}
}
// TestDC3GenderInjection pins the DC3 gender constraint annotation on the editor block (WS5 §5(б)): a
// gendered CONFIRMED term carries a gender directive; a genderless term does not (byte-identical line).
func TestDC3GenderInjection(t *testing.T) {
mk := func(gender string) []pickedEntry {
return []pickedEntry{{entry: &memoryEntry{src: "方源", dst: "Фан Юань", status: "approved", gender: gender}, via: "方源", disp: memConfirmed}}
}
male := renderEditorConstraintBlock(mk("male"))
if !strings.Contains(male, "方源 → «Фан Юань» (муж.") {
t.Fatalf("male term must carry a masculine directive, got:\n%s", male)
}
hidden := renderEditorConstraintBlock(mk("hidden"))
if !strings.Contains(hidden, "пол СКРЫТ") {
t.Fatalf("hidden term must carry the gender-avoidance mandate, got:\n%s", hidden)
}
none := renderEditorConstraintBlock(mk(""))
if !strings.HasSuffix(none, "«Фан Юань»") {
t.Fatalf("a genderless term must have NO gender note (line ends at the dst), got:\n%s", none)
}
}

View file

@ -7,19 +7,21 @@ import (
// chunker.go: the шаг-3a SOURCE segmenter. It turns the per-chapter normalized
// text produced by ingest.go into an ordered list of (chapter, chunk) units for
// the runner loop. It replaced the Веха-2 placeholder (chapter split on \f +
// blank-line paragraph packing to a CHAR budget) with real segmentation: pack
// whole paragraphs to a ~12k-TOKEN budget, and when a single paragraph exceeds
// the budget descend to SENTENCE boundaries — never splitting a sentence.
// the runner loop. WS2 (слой 1) moved the packing budget to OUTPUT (ru) tokens via
// the fertility estimate (est_out = f_cjk·cjk + f_other·other over source char-classes),
// decoupling draft sizing (small chunk, COGS/coverage/alignment) from the EDIT unit
// (coarse chapter-scale unit for cross-chunk cohesion): pack whole paragraphs to
// DraftBudgetOut, and when a single paragraph exceeds it descend to SENTENCE boundaries
// (never splitting a sentence; a lone over-budget sentence is a passthrough
// OversizedSentence chunk). Edit units then group WHOLE draft chunks to EditCeilingOut.
//
// Contract kept intact for the loop (bookrun.go/chunkrun.go depend on it): SplitChunks emits
// Chunk{Chapter,ChunkIdx,Text}; ChunkIdx resets to 0 per chapter; the function is
// a PURE, deterministic function of its input (no time/rand, no map iteration).
// Its behavior is versioned by chunkerVersion (render.go), folded into the job
// snapshot — so changing the segmentation is a loud --resnapshot re-translation,
// never a silent cache miss (§3.4/R6/D5.2). The chunk target is a CONST covered by
// that version (Р2 = code); were it ever made config-tunable it MUST be folded into
// the top-level snapshot like coverageSnap (§7d), not left to the version string.
// Chunk{Chapter,ChunkIdx,Text,…}; ChunkIdx resets to 0 per chapter; the new fields (EstOut,
// OversizedSentence, EditUnitID) are ADDITIVE; the function is a PURE, deterministic function of
// (chapters, SegBudget) (no time/rand, no map iteration). Its behavior is versioned by
// chunkerVersion (render.go); the SegBudget is snapshot-folded via segmentationSnap — so changing
// the segmentation OR the budget/fertility is a loud --resnapshot re-translation, never a silent
// cache miss (§3.4/R6/D5.2/D30.9).
//
// IMPORTANT: this sentence splitter is a SEPARATE, independent piece of logic from
// coverage.go's splitSentences. That one is a bit-for-bit port of the Python oracle
@ -33,84 +35,113 @@ type Chunk struct {
Chapter int // 1-based
ChunkIdx int // 0-based within its chapter
Text string
// EstOut is the fertility-estimated OUTPUT (ru) token cost of this chunk (WS2, слой 1):
// est_out = fert.CJK·cjk_src + fert.Other·other_src over the source char-classes. It is the
// unit the draft budget packs against (DraftBudgetOut), decoupling draft sizing from the
// old input-token heuristic (L2-budget-wrong-unit). Additive field; the {Chapter,ChunkIdx,Text}
// contract the loop/ingest/status depend on is untouched.
EstOut float64
// OversizedSentence marks a chunk that is a SINGLE source sentence whose est_out alone exceeds
// DraftBudgetOut (WS2 §2б). It is passthrough (never clause-split — clause splitting is
// boundary machinery, anti-scope), and the flag makes the un-budgetable unit OBSERVABLE
// ("деградация не молчит") rather than silently oversized.
OversizedSentence bool
// EditUnitID is the 0-based book-global id of the coarse EDIT unit this draft chunk belongs to
// (WS2 decoupling: draft = small chunk, edit = large unit). An edit unit is a greedy grouping of
// WHOLE draft chunks up to EditCeilingOut (per chapter), so the edit wave's editor reads a unit's draft as
// the concatenation of its member chunks' the draft wave outputs — clean reconstruction by construction (no
// straddle; verified: grouping whole chunks reproduces the paragraph-packed count 37 with 0
// straddle on the rerun corpus). Draft chunks never cross an edit-unit boundary.
EditUnitID int
}
// targetChunkTokens is the packing budget: paragraphs (or, inside an oversize
// paragraph, sentences) are greedily packed until the WHOLE chunk's estimated
// tokens (EstimateTokens' formula, applied once — see appendChapterChunks) would
// exceed this. ~1500 sits in the plan's "12k токенов по границам абзацев" corridor
// (02-mvp Фаза 1); a chunk of ordinary prose lands well above the coverage gate's
// 500-char floor, so the excision gate is not silently disabled (§7b). A chunk can
// still fall under the floor only when a whole chapter (or its tail) is genuinely
// that short — a documented boundary. A const, not config: the size is a code
// segmentation rule (Р2), covered by chunkerVersion.
const targetChunkTokens = 1500
// SegBudget is the resolved output-token segmentation budget (WS2 §2в): the draft-chunk and
// edit-unit ceilings in ru-OUTPUT tokens, plus the per-pair fertility coefficients that convert
// source char-classes to an output-token estimate. Resolved from config.Segmentation by the runner
// and snapshot-folded via segmentationSnap, so a budget/fertility edit is a loud --resnapshot
// (D30.9). The classifier (tokenClassCounts) uses the REAL unicode.RangeTable of EstimateTokens
// (Han|Hiragana|Katakana|Hangul) for generality (§0.1) — NOT script-specific ord ranges.
type SegBudget struct {
DraftBudgetOut float64 // draft-chunk ceiling, ru-output tokens (default 1797)
EditCeilingOut float64 // edit-unit ceiling, ru-output tokens (default 3200, gated arm at 8000)
FertCJK float64 // output tokens per CJK source char (default 1.1978)
FertOther float64 // output tokens per non-CJK/non-space source char (default 0.3852)
}
// estOut applies the fertility formula to pre-counted source char-classes — identical to
// est_out over the joined chunk text (whitespace is class-free, as tokenClassCounts drops it).
func (s SegBudget) estOut(cjk, other int) float64 {
return s.FertCJK*float64(cjk) + s.FertOther*float64(other)
}
// SplitChunks segments the ordered, per-chapter NORMALIZED text of a Document
// (ingest.go already applied NormalizeSource and split chapters) into an ordered
// chunk list. Chapters that yield no text (blank/whitespace-only, e.g. an epub
// cover/nav page) do NOT consume a chapter number, so numbering stays dense over
// real content (Фаза-0 backward compat). Fully deterministic.
func SplitChunks(chapters []string) []Chunk {
// real content (Фаза-0 backward compat). Fully deterministic. Each chunk carries its
// EstOut + OversizedSentence flag + EditUnitID (WS2); the edit-unit id is monotone across
// the book so a the edit wave unit is uniquely addressable.
func SplitChunks(chapters []string, seg SegBudget) []Chunk {
var out []Chunk
chapterNo := 0
editUnitID := 0
for _, chapText := range chapters {
paras := splitParagraphs(chapText)
if len(paras) == 0 {
continue // an empty chapter does not consume a chapter number
}
chapterNo++
out = appendChapterChunks(out, chapterNo, paras)
chapterChunks := chapterDraftChunks(chapterNo, paras, seg)
assignEditUnits(chapterChunks, seg, &editUnitID)
out = append(out, chapterChunks...)
}
return out
}
// appendChapterChunks packs one chapter's paragraphs into chunks and appends them.
// Rule: pack whole paragraphs while the running token estimate stays within
// targetChunkTokens (prefer paragraph boundaries); a paragraph that alone exceeds
// the budget is flushed on its own and then SENTENCE-packed (never splitting a
// sentence; a single sentence over budget is its own chunk).
//
// The running budget is tracked as ADDITIVE char-class counts (CJK vs other),
// applying EstimateTokens' formula (and its 16-token floor) ONCE to the whole
// chunk — NOT by summing per-unit EstimateTokens. Summing per-unit floors every
// tiny paragraph up to 16 tokens, so a dialogue chapter of many short lines would
// flush a chunk at ~⅓ its true token content, needlessly dropping real content
// under the coverage gate's 500-char floor (self-review finding). Counting classes
// and flooring once reproduces EstimateTokens(joined text) exactly (the "\n\n"
// separators are whitespace, which EstimateTokens ignores).
func appendChapterChunks(out []Chunk, chapterNo int, paras []string) []Chunk {
// chapterDraftChunks packs one chapter's paragraphs into DRAFT chunks (the fine tiling). Rule:
// pack whole paragraphs while the running OUTPUT-token estimate stays within DraftBudgetOut
// (prefer paragraph boundaries); a paragraph that alone exceeds the budget is flushed on its own
// and then SENTENCE-packed (never splitting a sentence; a single sentence over budget is its own
// OversizedSentence chunk). The running budget is tracked as ADDITIVE char-class counts (CJK vs
// other), applying est_out ONCE to the whole chunk — the "\n\n" separators are whitespace, which
// tokenClassCounts drops, so counting classes and estimating once reproduces est_out(joined text)
// exactly. Each emitted chunk gets its ChunkIdx (per-chapter) + EstOut; EditUnitID is set later.
func chapterDraftChunks(chapterNo int, paras []string, seg SegBudget) []Chunk {
var out []Chunk
chunkIdx := 0
emit := func(text string, oversized bool, cjk, other int) {
if t := strings.TrimSpace(text); t != "" {
out = append(out, Chunk{
Chapter: chapterNo, ChunkIdx: chunkIdx, Text: t,
EstOut: seg.estOut(cjk, other), OversizedSentence: oversized,
})
chunkIdx++
}
}
var buf []string // paragraphs accumulated for the current chunk
var bufCJK, bufOther int
flush := func() {
if len(buf) == 0 {
return
}
if text := strings.TrimSpace(strings.Join(buf, "\n\n")); text != "" {
out = append(out, Chunk{Chapter: chapterNo, ChunkIdx: chunkIdx, Text: text})
chunkIdx++
}
emit(strings.Join(buf, "\n\n"), false, bufCJK, bufOther)
buf = buf[:0]
bufCJK, bufOther = 0, 0
}
for _, p := range paras {
pCJK, pOther := tokenClassCounts(p)
if estTokensFrom(pCJK, pOther) > targetChunkTokens {
if seg.estOut(pCJK, pOther) > seg.DraftBudgetOut {
// Oversize paragraph: close any open chunk at this paragraph boundary,
// then split the paragraph at sentence boundaries into its own chunks.
flush()
for _, sub := range packSentences(p) {
if text := strings.TrimSpace(sub); text != "" {
out = append(out, Chunk{Chapter: chapterNo, ChunkIdx: chunkIdx, Text: text})
chunkIdx++
}
for _, sub := range packSentences(p, seg) {
emit(sub.text, sub.oversizedSentence, sub.cjk, sub.other)
}
continue
}
// Normal paragraph: start a new chunk if adding it would exceed the budget
// and the current chunk is non-empty (never split a paragraph here).
if len(buf) > 0 && estTokensFrom(bufCJK+pCJK, bufOther+pOther) > targetChunkTokens {
if len(buf) > 0 && seg.estOut(bufCJK+pCJK, bufOther+pOther) > seg.DraftBudgetOut {
flush()
}
buf = append(buf, p)
@ -120,28 +151,67 @@ func appendChapterChunks(out []Chunk, chapterNo int, paras []string) []Chunk {
return out
}
// packSentences splits one oversize paragraph into sentence segments (lossless
// tiling — concatenation reproduces the paragraph) and packs consecutive segments
// into ≤ targetChunkTokens sub-chunks, never splitting a sentence. A single
// sentence larger than the budget becomes its own (oversize) sub-chunk — the only
// way to honor "never split a sentence". Returns the sub-chunk texts in order. Uses
// the same additive char-class budget as appendChapterChunks (floor applied once).
func packSentences(paragraph string) []string {
// assignEditUnits groups a chapter's already-built DRAFT chunks into EDIT units — greedy packing of
// WHOLE draft chunks until the accumulated est_out would exceed EditCeilingOut — and stamps each
// chunk's EditUnitID (monotone across the book via *nextID). Because units are formed from whole
// draft chunks, a unit's source/draft span is exactly the concatenation of its member chunks (no
// straddle) and the edit wave reconstructs the unit's draft losslessly. A single draft chunk larger than the
// ceiling (an OversizedSentence, or a chapter whose first chunk already exceeds the ceiling) is its
// own unit — grouping never splits a chunk (never-split-below-chunk mirrors never-split-paragraph).
func assignEditUnits(chunks []Chunk, seg SegBudget, nextID *int) {
acc := 0.0
started := false
for i := range chunks {
if started && acc+chunks[i].EstOut > seg.EditCeilingOut {
*nextID++ // close the current unit at this chunk boundary
acc = 0
}
chunks[i].EditUnitID = *nextID
acc += chunks[i].EstOut
started = true
}
if started {
*nextID++ // advance past the last unit so the next chapter starts a fresh id
}
}
// subChunk is one sentence-packed sub-chunk with its char-class counts and the oversized-sentence
// flag (a single sentence whose est_out alone exceeds DraftBudgetOut).
type subChunk struct {
text string
cjk, other int
oversizedSentence bool
}
// packSentences splits one oversize paragraph into sentence segments (lossless tiling —
// concatenation reproduces the paragraph) and packs consecutive segments into ≤ DraftBudgetOut
// (est_out) sub-chunks, never splitting a sentence. A single sentence larger than the budget
// becomes its own sub-chunk flagged OversizedSentence (passthrough, never clause-split — the plan's
// §2б decision: "деградация не молчит" is satisfied by the FLAG, not emergency clause machinery).
func packSentences(paragraph string, seg SegBudget) []subChunk {
segs := splitSourceSentences(paragraph)
var chunks []string
var chunks []subChunk
var buf strings.Builder
var bufCJK, bufOther int
flush := func() {
if buf.Len() == 0 {
return
}
chunks = append(chunks, buf.String())
chunks = append(chunks, subChunk{text: buf.String(), cjk: bufCJK, other: bufOther})
buf.Reset()
bufCJK, bufOther = 0, 0
}
for _, s := range segs {
sCJK, sOther := tokenClassCounts(s)
if buf.Len() > 0 && estTokensFrom(bufCJK+sCJK, bufOther+sOther) > targetChunkTokens {
// A lone sentence over budget: flush what precedes it, then emit it as its own
// OversizedSentence sub-chunk (never split a sentence, and never merge it with a
// neighbour so the oversized flag stays attributable to the single sentence).
if seg.estOut(sCJK, sOther) > seg.DraftBudgetOut {
flush()
chunks = append(chunks, subChunk{text: s, cjk: sCJK, other: sOther, oversizedSentence: true})
continue
}
if buf.Len() > 0 && seg.estOut(bufCJK+sCJK, bufOther+sOther) > seg.DraftBudgetOut {
flush()
}
buf.WriteString(s)
@ -170,16 +240,6 @@ func tokenClassCounts(s string) (cjk, other int) {
return cjk, other
}
// estTokensFrom applies EstimateTokens' formula (cjk + other/3, floored at 16) to
// pre-counted class totals — identical to EstimateTokens over the joined text.
func estTokensFrom(cjk, other int) int {
est := cjk + other/3
if est < 16 {
est = 16
}
return est
}
// splitParagraphs breaks a chapter into paragraphs on blank lines and trims each,
// dropping empties. Deterministic; whitespace-only paragraphs vanish. (Ingest emits
// a blank line between block-level epub elements, so paragraphs survive extraction.)

View file

@ -8,43 +8,72 @@ import (
"unicode/utf8"
)
// testSeg is the ratified WS2 output-token budget (zh-ru defaults) used by the chunker tests.
func testSeg() SegBudget {
return SegBudget{DraftBudgetOut: 1797, EditCeilingOut: 3200, FertCJK: 1.1978, FertOther: 0.3852}
}
// stripMeta zeroes the WS2 additive fields (EstOut/OversizedSentence/EditUnitID) so a
// {Chapter,ChunkIdx,Text}-level DeepEqual still expresses the intent of the pre-WS2 assertions.
func stripMeta(cs []Chunk) []Chunk {
out := make([]Chunk, len(cs))
for i, c := range cs {
out[i] = Chunk{Chapter: c.Chapter, ChunkIdx: c.ChunkIdx, Text: c.Text}
}
return out
}
func TestSplitChunksSingleParagraph(t *testing.T) {
got := SplitChunks([]string{"静かな図書館の朝。"})
got := SplitChunks([]string{"静かな図書館の朝。"}, testSeg())
want := []Chunk{{Chapter: 1, ChunkIdx: 0, Text: "静かな図書館の朝。"}}
if !reflect.DeepEqual(got, want) {
if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("single paragraph = %+v, want %+v", got, want)
}
// WS2: the additive fields are populated — est_out>0 and a single unit id.
if got[0].EstOut <= 0 || got[0].EditUnitID != 0 || got[0].OversizedSentence {
t.Fatalf("WS2 fields = est_out %.3f unit %d oversized %v", got[0].EstOut, got[0].EditUnitID, got[0].OversizedSentence)
}
}
func TestSplitChunksChapters(t *testing.T) {
got := SplitChunks([]string{"Глава один.", "Глава два.", "Глава три."})
got := SplitChunks([]string{"Глава один.", "Глава два.", "Глава три."}, testSeg())
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: "Глава один."},
{Chapter: 2, ChunkIdx: 0, Text: "Глава два."},
{Chapter: 3, ChunkIdx: 0, Text: "Глава три."},
}
if !reflect.DeepEqual(got, want) {
if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("chapters = %+v, want %+v", got, want)
}
// WS2: each chapter is its own edit unit → distinct monotone EditUnitIDs 0,1,2.
for i, c := range got {
if c.EditUnitID != i {
t.Fatalf("chapter %d chunk edit_unit_id = %d, want %d (each short chapter is its own unit)", c.Chapter, c.EditUnitID, i)
}
}
}
func TestSplitChunksPacksToTarget(t *testing.T) {
// Two paragraphs that together exceed the TOKEN target (each ~1000 tokens for
// cyrillic: 3000 chars / 3) must split into two chunks, each kept whole.
// Two paragraphs that together exceed the OUTPUT-token budget (each ~1156 out-tokens for
// cyrillic: 3000 chars × 0.3852) must split into two draft chunks, each kept whole.
p1 := strings.Repeat("а", 3000)
p2 := strings.Repeat("б", 3000)
got := SplitChunks([]string{p1 + "\n\n" + p2})
got := SplitChunks([]string{p1 + "\n\n" + p2}, testSeg())
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: p1},
{Chapter: 1, ChunkIdx: 1, Text: p2},
}
if !reflect.DeepEqual(got, want) {
if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("packing = %d chunks, want 2 whole paragraphs; got %+v", len(got), summarize(got))
}
// WS2: the two ~1156-out chunks (2311 together ≤ 3200) group into ONE edit unit.
if got[0].EditUnitID != got[1].EditUnitID {
t.Fatalf("two sub-ceiling draft chunks must share an edit unit, got ids %d and %d", got[0].EditUnitID, got[1].EditUnitID)
}
}
func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) {
got := SplitChunks([]string{"Абзац один.\n\nАбзац два.\n\nАбзац три."})
got := SplitChunks([]string{"Абзац один.\n\nАбзац два.\n\nАбзац три."}, testSeg())
if len(got) != 1 {
t.Fatalf("small paragraphs must pack into one chunk, got %d: %+v", len(got), summarize(got))
}
@ -54,17 +83,17 @@ func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) {
}
func TestSplitChunksOversizeParagraphSplitsAtSentences(t *testing.T) {
// One paragraph far over the token target, built of many whole sentences, must
// One paragraph far over the output budget, built of many whole sentences, must
// split into >1 chunk AND never cut a sentence: every original sentence must
// survive intact and in order across the chunks.
sentence := strings.Repeat("слово ", 40) + "конец." // ~80 tokens each
sentence := strings.Repeat("слово ", 40) + "конец." // ~79 out-tokens each
var sb strings.Builder
for i := 0; i < 40; i++ { // ~3200 tokens total ≫ 1500
for i := 0; i < 40; i++ { // ~3160 out-tokens total ≫ 1797
sb.WriteString(sentence)
sb.WriteByte(' ')
}
para := strings.TrimSpace(sb.String())
got := SplitChunks([]string{para})
got := SplitChunks([]string{para}, testSeg())
if len(got) < 2 {
t.Fatalf("oversize paragraph must split into >1 chunk, got %d", len(got))
}
@ -79,41 +108,43 @@ func TestSplitChunksOversizeParagraphSplitsAtSentences(t *testing.T) {
}
func TestSplitChunksShortLinesPackByTrueTokenBudget(t *testing.T) {
// Many short dialogue paragraphs must pack by TRUE token content, not by summing
// the per-unit 16-token floor. 150 lines of 「はい。」 are ~450 true tokens (cjk=300,
// other=450 → 300+150), so they belong in ONE chunk. Summing the floor (150×16)
// would wrongly flush into ~2 sub-500-char chunks the coverage gate then skips.
// Many short dialogue paragraphs must pack by TRUE output-token content (est_out on the
// joined text), not by summing a per-unit floor. 150 lines of 「はい。」 are ~533 out-tokens,
// so they belong in ONE chunk (a per-unit floor would wrongly flush into sub-500-char pieces).
var paras []string
for i := 0; i < 150; i++ {
paras = append(paras, "「はい。」")
}
got := SplitChunks([]string{strings.Join(paras, "\n\n")})
got := SplitChunks([]string{strings.Join(paras, "\n\n")}, testSeg())
if len(got) != 1 {
t.Fatalf("short lines must pack by true token budget into 1 chunk, got %d chunks", len(got))
}
if EstimateTokens(got[0].Text) > targetChunkTokens {
t.Fatalf("packed chunk overshoots the budget: %d tokens", EstimateTokens(got[0].Text))
if got[0].EstOut > testSeg().DraftBudgetOut {
t.Fatalf("packed chunk overshoots the budget: est_out %.1f", got[0].EstOut)
}
}
func TestSplitChunksOversizeSentenceIsOwnChunk(t *testing.T) {
// A single sentence (no internal terminator) larger than the budget can only be
// its own chunk — never split.
big := strings.Repeat("ы", 6000) // ~2000 tokens, one un-terminated run
got := SplitChunks([]string{big})
// A single sentence (no internal terminator) larger than the budget can only be its own
// chunk — never split — and is flagged OversizedSentence (passthrough, WS2 §2б).
big := strings.Repeat("ы", 6000) // ~2311 out-tokens, one un-terminated run
got := SplitChunks([]string{big}, testSeg())
if len(got) != 1 || got[0].Text != big {
t.Fatalf("an oversize sentence must be one un-split chunk, got %+v", summarize(got))
}
if !got[0].OversizedSentence {
t.Fatalf("a single over-budget sentence must be flagged OversizedSentence")
}
}
func TestSplitChunksDropsEmpties(t *testing.T) {
// A blank/whitespace-only chapter vanishes and does NOT consume a chapter number.
got := SplitChunks([]string{"A", " \n\n ", "B"})
got := SplitChunks([]string{"A", " \n\n ", "B"}, testSeg())
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: "A"},
{Chapter: 2, ChunkIdx: 0, Text: "B"},
}
if !reflect.DeepEqual(got, want) {
if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("empties = %+v, want %+v", got, want)
}
}
@ -123,7 +154,7 @@ func TestSplitChunksDeterministic(t *testing.T) {
"Абзац.\n\nЕщё абзац.",
strings.Repeat("слово ", 400) + "конец.",
}
a, b := SplitChunks(chapters), SplitChunks(chapters)
a, b := SplitChunks(chapters, testSeg()), SplitChunks(chapters, testSeg())
if !reflect.DeepEqual(a, b) {
t.Fatal("SplitChunks must be deterministic")
}
@ -133,7 +164,7 @@ func TestSplitChunksNeverSplitsAbbreviation(t *testing.T) {
// Two long en paragraphs each ending mid-flow with "Mr. Smith" must not chunk
// between "Mr." and "Smith" — the abbreviation guard keeps the sentence whole.
p := strings.Repeat("The road went on and on. ", 100) + "It was Mr. Smith who arrived."
got := SplitChunks([]string{p})
got := SplitChunks([]string{p}, testSeg())
for _, c := range got {
if strings.HasSuffix(strings.TrimSpace(c.Text), "Mr.") {
t.Fatalf("chunk ended at an abbreviation 'Mr.' — a sentence was cut: %q", c.Text)
@ -141,6 +172,32 @@ func TestSplitChunksNeverSplitsAbbreviation(t *testing.T) {
}
}
// TestSplitChunksEditUnitGroupsWholeChunks pins the WS2 decoupling: a chapter whose draft chunks
// together exceed EditCeilingOut is grouped into >1 edit unit at DRAFT-CHUNK boundaries (never
// splitting a chunk), so every chunk of a unit reconstructs the unit's draft losslessly.
func TestSplitChunksEditUnitGroupsWholeChunks(t *testing.T) {
// Three ~1156-out paragraphs (0.3852×3000): draft chunks {p1},{p2},{p3} (each ~1156, two
// together 2311 ≤ 1797? no — 2311>1797 so each is its own draft chunk). Edit ceiling 3200:
// group whole chunks → {p1,p2}=2311 ≤3200, +p3=3467>3200 → unit0={p1,p2}, unit1={p3}.
p := strings.Repeat("а", 3000)
q := strings.Repeat("б", 3000)
r := strings.Repeat("в", 3000)
got := SplitChunks([]string{p + "\n\n" + q + "\n\n" + r}, testSeg())
if len(got) != 3 {
t.Fatalf("want 3 draft chunks, got %d", len(got))
}
ids := []int{got[0].EditUnitID, got[1].EditUnitID, got[2].EditUnitID}
if ids[0] != ids[1] || ids[1] == ids[2] {
t.Fatalf("edit-unit grouping = %v, want first two together and the third separate", ids)
}
// Every draft chunk belongs to exactly one unit; a unit's chunks are contiguous.
for i := 1; i < len(got); i++ {
if got[i].EditUnitID < got[i-1].EditUnitID {
t.Fatalf("edit unit ids must be monotone, got %v", ids)
}
}
}
// --- source sentence splitter (zh/ja/en) ---------------------------------------
func TestSplitSourceSentencesTilesLosslessly(t *testing.T) {
@ -262,7 +319,7 @@ func FuzzSplitChunksPreservesContent(f *testing.F) {
norm := NormalizeSource(s)
want := countNonSpace(norm)
got := 0
for _, c := range SplitChunks([]string{norm}) {
for _, c := range SplitChunks([]string{norm}, testSeg()) {
got += countNonSpace(c.Text)
}
if got != want {

View file

@ -1,9 +1,7 @@
package pipeline
import (
"context"
"encoding/json"
"fmt"
"maps"
"slices"
"strings"
@ -79,159 +77,6 @@ func (r *Runner) classifyOutput(role, source, output, finish string, isFinal boo
return cls, ""
}
// translateChunk drives ONE chunk through the stage list. Stages run in order,
// each feeding the next; the FIRST flagged stage stops the chunk — later stages
// are recorded `skipped` (no paid edit over a garbage draft, D2). It also runs the
// memory bank v2 hot path: it Selects the glossary injection once ($0, deterministic),
// injects it into the TRANSLATOR stage, post-checks the translator output (E1), and
// persists the per-chunk retrieval-state (observability). Returns the chunk outcome, the
// exact-matched entity ids (the next chunk's sticky_prev, A5), and an error only on an
// infra failure. stickyPrev is the prior chunks' exact matches in this chapter.
func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, stickyPrev map[string]injectionDisposition) (*ChunkOutcome, map[string]injectionDisposition, error) {
out := &ChunkOutcome{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Disposition: DispOK}
prev := ""
flagged := false
var flagReason FlagReason
// recovered carries a cosmetic sanitizer strip's cleaned export forward from the flagging
// stage (D35.4a): non-empty only when the FINAL stage flagged FlagSanitizerStripped, "" for a
// dropped substantive flag. Captured from the FIRST flagging stage (the sanitizer flags only
// the final stage, so an upstream substantive flag leaves it "" and the chunk exports empty).
recovered := ""
// draftText is the FIRST (translator) stage's output — the pre-reflow baseline for the opt-in
// regression guard's draft→final comparison (== final in a single-stage pipeline).
draftText := ""
// Hot path: select the glossary records for this chunk ONCE (deterministic, $0), and serialize the
// per-role injection blocks via the role→renderer registry (D39 слой 7). Recomputed on every run
// (resumed chunks included) so the sticky window and the injected bytes are resume-stable.
var memSel memorySelection
injectionByRole := map[string]string{}
activeIDs := map[string]injectionDisposition{}
if r.memory != nil {
memSel = r.memory.Select(ch.Text, ch.Chapter, stickyPrev, r.Pipeline.Context.GlossaryTokenBudget)
for role, render := range roleInjectionRenderers {
injectionByRole[role] = render(memSel.injected) // pure → per-role result is map-order-independent
}
activeIDs = memSel.activeIDs
// The disposition-gated suppressor refused to let a lower-trust longer key eat a higher-trust
// nested one (D39 слой 4): the approved term survives, but surface the seed-hygiene collision
// loudly so an operator can reconcile the draft-nesting-over-approved seed (research/13 §7).
if len(memSel.trustGated) > 0 {
r.Log.WarnContext(ctx, "memory: lower-trust longer key refused from suppressing a higher-trust nested key (approved term preserved; reconcile the seed)",
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "trust_gated", len(memSel.trustGated),
"first", memSel.trustGated[0].Suppressor+"⊃"+memSel.trustGated[0].Protected)
}
}
for stageIdx, st := range r.Pipeline.Stages {
if flagged {
// An earlier stage flagged → this stage is not attempted or billed.
detail := fmt.Sprintf("skipped: an upstream stage was flagged (%s)", flagReason)
if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name,
SnapshotID: snapID, Disposition: string(DispSkipped), FlagReason: string(flagReason),
Detail: detail,
}); err != nil {
return out, activeIDs, fmt.Errorf("pipeline: record skipped chunk_status: %w", err)
}
out.Stages = append(out.Stages, StageResult{
Stage: st.Name, Role: st.Role, Model: st.Model,
Disposition: DispSkipped, FlagReason: flagReason, Detail: detail,
})
continue
}
// Resolve the per-role memory injection from the registry (D39 слой 7): the TRANSLATOR gets the
// src→dst glossary block (keys matched against the source chunk); the BILINGUAL editor (D30.1)
// gets the CONFIRMED dst forms as target-consistency constraints; every other role gets none
// (empty string → the plain 2-message layout). The injection is a message, so it enters this
// stage's request_hash automatically (a resumed chunk reproduces it deterministically).
injection := injectionByRole[st.Role]
sr, err := r.runStage(ctx, st, stageIdx, snapID, ch, prev, injection)
if err != nil {
return out, activeIDs, err
}
out.Stages = append(out.Stages, *sr)
out.CostUSD += sr.CostUSD
if sr.Disposition == DispFlagged {
flagged = true
flagReason = sr.FlagReason
recovered = sr.RecoveredText
continue
}
prev = sr.Text
if stageIdx == 0 {
draftText = sr.Text // the translator draft, before any reflow (regression-guard baseline)
}
}
// Post-check the FINAL, exported output (E1): the reader sees the last stage's text
// (the editor's), not the translator draft — the monolingual editor can drift an
// approved term the translator got right, so checking the draft alone would miss it.
// Runs on the fresh OR fully-resumed chunk (both carry the final text through `prev`),
// so it is resume-reproducible. In the default FLAGGER mode a miss is recorded only in
// the retrieval-state (observability); with the opt-in gate a miss flags the chunk
// (glossary_miss). Skipped when a stage already flagged (no usable output to check).
var postMisses []postcheckMiss
outputChecked := false
if r.memory != nil && !flagged && prev != "" {
outputChecked = true
postMisses = r.memory.postcheck(memSel.injected, prev)
// The gate flips ONLY on CONFIRMED (approved) misses — an AMBIGUOUS miss is an
// unverified candidate the model may legitimately reject, so it must not discard a
// correct translation (external-review major #1).
if r.Pipeline.Gates.Glossary.PostcheckGate && countConfirmedMisses(postMisses) > 0 {
flagged = true
flagReason = FlagGlossaryMiss
r.Log.WarnContext(ctx, "glossary post-check gate flagged the chunk",
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "confirmed_misses", countConfirmedMisses(postMisses))
}
}
// Cheap deterministic style/number flaggers on the FINAL text (cheapgates.go): observability
// only, never a disposition. Runs on the same fresh-or-resumed output as the post-check, so it
// is resume-reproducible; skipped when a stage flagged (no usable output). Source is ch.Text
// (the 万/億 magnitude gate compares source↔output). These are READABILITY flaggers whose rules
// bake in Russian conventions (em-dash dialogue, ёфикатор, ru magnitude words), so they are gated
// behind a Russian target (D39 слой 7, L8-readability-gates-target-blind) — a no-op for any other
// pair, which would false-flag. Prod zh→ru is unaffected (target=ru).
var cheap cheapGateResult
if !flagged && prev != "" && isRuTarget(r.Book.TargetLang) {
cheap = runCheapGates(ch.Text, draftText, prev, r.cheapGateConfig())
if cheap.total() > 0 {
r.Log.InfoContext(ctx, "cheap style gates flagged the chunk (observability, not a gate)",
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "style_flags", cheap.total(),
"dialogue_dash", cheap.DialogueDash, "yo", cheap.YoInconsistent,
"translit_interj", cheap.TranslitInterj, "number_magnitude", cheap.NumberMagnitude)
}
}
// Persist the per-chunk retrieval-state (registry gate #4: convert silent memory
// degradation into a loud, visible record) plus the cheap style-flag counts. Deterministic +
// idempotent, so a resume re-derives the identical row. Only when a glossary is materialized
// (always true in a normal run — seedGlossary sets an at-least-empty bank).
if r.memory != nil {
if err := r.persistRetrievalState(snapID, ch, memSel, postMisses, outputChecked, cheap); err != nil {
return out, activeIDs, err
}
}
// The export contract (D39 слой 6): NORMALISE every final text before it ships — clean OR
// flagged-stripped — so a cosmetic Unicode artifact (fullwidth glyph, U+3000 indent, stray CJK
// punctuation, a leaked combining stress mark) is fixed uniformly, not only on a flagged chunk
// (L5-normalization-fused-to-flag-path). A cosmetic sanitizer strip exports its cleaned remainder
// (recovered != ""); every other flag exports "" (the contaminated output never reaches TM/export,
// D2 / D35.4a), and exportNormalize("") == "". Pure ephemeral projection — no wire/checkpoint touch.
if flagged {
out.Disposition = DispFlagged
out.FlagReason = flagReason
out.FinalText = exportNormalize(recovered)
} else {
out.FinalText = exportNormalize(prev)
}
return out, activeIDs, nil
}
// injectionRenderer serializes a chunk's selected memory records into a role's injection message.
type injectionRenderer func(injected []pickedEntry) string
@ -249,10 +94,13 @@ var roleInjectionRenderers = map[string]injectionRenderer{
// selection + the post-check result. n_exact_hits/n_sticky/n_ambiguous count the INJECTED
// records (what the model saw); spoiler/eviction are the dropped-and-logged totals;
// post-check misses are recorded only when the translator actually produced text.
func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelection, misses []postcheckMiss, outputChecked bool, cheap cheapGateResult) error {
func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelection, misses []postcheckMiss, outputChecked bool, cheap cheapGateResult, bank bankFlags) error {
rs := store.RetrievalState{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, SnapshotID: snapID,
NStyleFlags: cheap.total(),
NStyleFlags: cheap.total(),
NBanknoteLines: bank.NLines,
BanknoteParseFail: boolToStoreInt(bank.ParseFail),
BanknoteTruncated: boolToStoreInt(bank.Truncated),
}
if cheap.total() > 0 {
if b, err := json.Marshal(cheap); err == nil {
@ -304,6 +152,14 @@ func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelect
return r.Store.UpsertRetrievalState(rs)
}
// boolToStoreInt maps a bool telemetry flag to the store's 0/1 integer column form.
func boolToStoreInt(b bool) int {
if b {
return 1
}
return 0
}
// cheapGateConfig builds the cheap-gate knobs from the book brief: the ё-policy and the
// lower-cased per-project interjection allowlist. Pure, no store access.
func (r *Runner) cheapGateConfig() cheapGateConfig {

View file

@ -111,7 +111,9 @@ const (
// re-runs a skipped stage (fresh spend), ok→flagged burns a fresh escalation hop.
// Folded into the snapshot exactly like coverageGateVersion, so such a change is a
// loud --resnapshot, not a silent divergence (external-review finding).
const classifierVersion = "classify-v1-refusal+echo015+loop"
// Ш-2 (see memnorm.go): unicode.Version is woven in so a toolchain Unicode bump that shifts the
// echo/refusal normalization or character classification re-verdicts a resumed chunk LOUDLY (--resnapshot).
const classifierVersion = "classify-v1-refusal+echo015+loop+u" + unicode.Version
// decodeErrorFinish is the finish_reason the runner stores on a billed-but-
// unreadable 2xx (BilledDecodeError). classify() recognises it so a RESUMED

View file

@ -109,6 +109,15 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
}
mayHop := fbExists != nil
if !mayHop {
// Fresh hop: serialize the budget admission THROUGH the paid settle across the parallel the draft wave draft
// workers (R1, escMu). escalationBudgetRemains is a non-atomic read-then-act over EscalationSpentUSD,
// so without this N concurrent draft chunks could each read spent<budget and all be admitted →
// overshoot by up to N-1 hops. Holding escMu until this function returns (past runAttempt's settle)
// makes a later worker read the UPDATED spend, restoring the sequential soft-cap (overshoot ≤ 1 hop).
// The $0 replay path above (fbExists != nil) stays lock-free; escalations are the rare content-failure
// exception, so the contention is negligible.
r.escMu.Lock()
defer r.escMu.Unlock()
if mayHop, err = r.escalationBudgetRemains(); err != nil {
return out, err
}

View file

@ -35,6 +35,11 @@ type ChunkExport struct {
// substantive flag / upstream-skip / pending chunk (exportNormalize("")=="", so the paths coincide
// byte-for-byte with ChunkOutcome.FinalText).
FinalText string `json:"final_text"`
// Source is the chunk's normalized source text — populated ONLY under `tmctl export --pairs` (WS5
// §5(д) / research/20 §E-3), the src↔target column the polygon needs to measure the DC1 (时辰) / DC2
// (千万) source-vs-target checkers' false-positive rate. Omitted by default (the export contract is
// target-only); the source is the $0 manifest re-chunk, never a stored/billed artifact.
Source string `json:"source,omitempty"`
}
// BookExport is the whole-book export projection (chunks in manifest — (chapter, chunk_idx) — order, so
@ -70,7 +75,7 @@ type BookExport struct {
// - MANIFEST (F4): a chunk absent from the current source is a GHOST row (dropped + counted + WARNed);
// a manifest chunk with no final row is PENDING; both keep a partial/edited book from exporting as
// if complete (the D37-skew the polygon extractor must not inherit).
func (r *Runner) Export() (*BookExport, error) {
func (r *Runner) Export(pairs bool) (*BookExport, error) {
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
return nil, err
@ -96,46 +101,57 @@ func (r *Runner) Export() (*BookExport, error) {
if n := len(r.Pipeline.Stages); n > 0 {
lastStage = r.Pipeline.Stages[n-1].Name
}
// Index the final-stage rows by chunk key (the exported text + verdict live there) and record the
// snapshot ids the rows carry (for the drift check).
// Index the final-stage rows by their addressing key (the exported text + verdict live there) and
// record the snapshot ids they carry. Under the R1 wave model the SHIPPING stage is the editor, run
// per EDIT UNIT — so its chunk_status rows exist only at each unit's LEADER (chapter, firstChunkIdx),
// NOT per draft chunk. finalRow therefore holds one entry per output unit (per draft chunk for a
// draft-only pipeline). All these rows carry the FINAL wave's snapshot (edit-wave snapshot for an edit
// pipeline, draft-wave snapshot for draft-only).
finalRow := map[chunkKey]store.ChunkStatus{}
snapSeen := map[string]bool{}
for _, cs := range statuses {
if cs.Stage != lastStage {
continue // the exported text and the chunk's final verdict live on the final stage's row
continue // the exported text and the unit's final verdict live on the final stage's row
}
finalRow[chunkKey{cs.Chapter, cs.ChunkIdx}] = cs
if cs.SnapshotID != "" {
snapSeen[cs.SnapshotID] = true
}
}
// F4: the $0 source manifest is the authoritative chunk set — join it so a PENDING chunk is explicit
// and a GHOST (deleted-source) row is not exported as if live.
// F4: the $0 source manifest is authoritative. Under the wave model the export granularity is the EDIT
// UNIT (the editor collapses a unit's member chunks into ONE final text), so join the manifest into
// output units — each a (chapter, firstChunkIdx) key + the unit's joined source (the --pairs column
// aligned byte-for-byte to how the edit wave concatenated the members). A draft-only pipeline has one output per
// draft chunk (the draft ships). Iterating units keeps a PENDING unit explicit and a GHOST row visible.
manifest, err := r.bookChunks()
if err != nil {
return nil, err
}
units := r.outputUnits(manifest)
exp := &BookExport{BookID: r.Book.BookID, Chunks: []ChunkExport{}}
inManifest := map[chunkKey]bool{}
for _, ch := range manifest {
k := chunkKey{ch.Chapter, ch.ChunkIdx}
inManifest[k] = true
for _, u := range units {
key := chunkKey{u.Chapter, u.FirstChunkIdx}
inManifest[key] = true
exp.TotalChunks++
cs, ok := finalRow[k]
cs, ok := finalRow[key]
if !ok {
pend := ChunkExport{Chapter: u.Chapter, ChunkIdx: u.FirstChunkIdx, Disposition: exportPending}
if pairs {
pend.Source = u.sourceText()
}
exp.PendingChunks++
exp.Chunks = append(exp.Chunks, ChunkExport{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Disposition: exportPending})
exp.Chunks = append(exp.Chunks, pend)
continue
}
ce, err := r.chunkExport(cs, gateOn, missByChunk[k])
ce, err := r.chunkExport(cs, gateOn, missByChunk[key])
if err != nil {
return nil, err
}
if pairs {
ce.Source = u.sourceText() // the unit src↔target column for the DC1/DC2 FP-measure (--pairs)
}
exp.Chunks = append(exp.Chunks, ce)
}
// F4: GHOST rows — a stored final row whose chunk is NOT in the current manifest (source shrunk since
// the run). Dropped (never exported as if live) but counted + WARNed so the drop is loud.
// F4: GHOST rows — a stored final row whose unit-leader key is NOT in the current manifest units
// (source shrunk since the run). Dropped (never exported as if live) but counted + WARNed.
for k := range finalRow {
if !inManifest[k] {
exp.GhostRows++
@ -146,23 +162,51 @@ func (r *Runner) Export() (*BookExport, error) {
"book", r.Book.BookID, "ghost_rows", exp.GhostRows)
}
// F3: config/gate drift — only meaningful on a single stored snapshot (multi-snapshot drift is
// already visible in the per-chunk snapshot_id). Compute the CURRENT config's projected snapshot
// read-only and compare; surface a mismatch as a flag + WARN, never silently.
if len(snapSeen) == 1 {
var stored string
for s := range snapSeen {
stored = s
// F3: config/gate drift — the stored rows carry PER-WAVE snapshots (draft rows → draft-wave snapshot, edit rows
// → edit-wave snapshot), so compare EACH wave against its current projection (like status). A change in EITHER
// wave means `translate` would --resnapshot (a draft-config change re-pins the draft wave and cascades
// to the edit via content-hash). Only meaningful when a wave's rows carry a single snapshot (a
// mid-book multi-snapshot drift is already visible in the per-row snapshot_id).
draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft))
editStageNames := stageNameSet(r.waveStagesIndexed(waveEdit))
draftSnaps, editSnaps := map[string]bool{}, map[string]bool{}
for _, cs := range statuses {
if cs.SnapshotID == "" {
continue
}
if cur, serr := r.currentSnapshotProjected(); serr != nil {
r.Log.Warn("export: config-drift check failed; drift state unknown (reported as none)", "err", serr)
} else if cur != stored {
exp.ConfigDrift = true
exp.CurrentSnapshot = cur
r.Log.Warn("export: CONFIG-DRIFT — current config renders a different snapshot than the stored rows; the gate/stage re-derivation may not match the run (translate would require --resnapshot)",
"book", r.Book.BookID, "stored", stored, "current", cur)
switch {
case draftStageNames[cs.Stage]:
draftSnaps[cs.SnapshotID] = true
case editStageNames[cs.Stage]:
editSnaps[cs.SnapshotID] = true
}
}
if err := r.projectStoredMemory(); err != nil {
r.Log.Warn("export: config-drift check failed; drift state unknown (reported as none)", "err", err)
} else {
checkWave := func(snaps map[string]bool, w wave) {
if len(snaps) != 1 {
return
}
var stored string
for s := range snaps {
stored = s
}
cur, _, serr := r.snapshotIDForWave(w)
if serr != nil {
r.Log.Warn("export: config-drift check failed for a wave; drift state unknown", "err", serr)
return
}
if cur != stored {
exp.ConfigDrift = true
exp.CurrentSnapshot = cur
r.Log.Warn("export: CONFIG-DRIFT — current config renders a different snapshot than the stored rows; the gate/stage re-derivation may not match the run (translate would require --resnapshot)",
"book", r.Book.BookID, "stored", stored, "current", cur, "wave", w)
}
}
checkWave(draftSnaps, waveDraft)
checkWave(editSnaps, waveEdit)
}
return exp, nil
}

View file

@ -0,0 +1,61 @@
package pipeline
import (
"context"
"testing"
)
// export_pairs_test.go: WS5 `tmctl export --pairs` — the src↔target column the polygon needs for the
// DC1/DC2 false-positive measure (§5(д)). Default export is target-only (Source omitted); --pairs adds
// the $0 manifest source per chunk, matching what the runner chunked.
func TestExportPairsIncludesSource(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "静かな図書館の朝。"})
ctx := context.Background()
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
// Recover the manifest source the runner chunked, to compare against the --pairs column.
manifest, err := r.bookChunks()
if err != nil {
t.Fatal(err)
}
bySource := map[[2]int]string{}
for _, ch := range manifest {
bySource[[2]int{ch.Chapter, ch.ChunkIdx}] = ch.Text
}
// Default export: Source omitted (target-only contract).
plain, err := r.Export(false)
if err != nil {
t.Fatal(err)
}
for _, ce := range plain.Chunks {
if ce.Source != "" {
t.Fatalf("default export must omit Source, got %q for ch%d/%d", ce.Source, ce.Chapter, ce.ChunkIdx)
}
}
// --pairs export: Source populated with the exact manifest source per chunk.
pairs, err := r.Export(true)
if err != nil {
t.Fatal(err)
}
if len(pairs.Chunks) == 0 {
t.Fatal("export produced no chunks")
}
for _, ce := range pairs.Chunks {
want := bySource[[2]int{ce.Chapter, ce.ChunkIdx}]
if ce.Source != want {
t.Fatalf("--pairs ch%d/%d Source = %q, want the manifest source %q", ce.Chapter, ce.ChunkIdx, ce.Source, want)
}
if ce.Source == "" {
t.Fatalf("--pairs must populate a non-empty Source for a real chunk (ch%d/%d)", ce.Chapter, ce.ChunkIdx)
}
}
}

View file

@ -95,7 +95,7 @@ func TestExportMatchesTranslateFinalText(t *testing.T) {
assertExportEquals := func(tag string, r *Runner) {
t.Helper()
exp, err := r.Export()
exp, err := r.Export(false)
if err != nil {
t.Fatalf("%s: Export: %v", tag, err)
}
@ -165,7 +165,7 @@ func TestExportGlossaryGateWithheld(t *testing.T) {
if len(res.Chunks) != 1 || res.Chunks[0].FlagReason != FlagGlossaryMiss || res.Chunks[0].FinalText != "" {
t.Fatalf("precondition: want 1 chunk flagged glossary_miss with empty text, got %+v", res.Chunks)
}
exp, err := r.Export()
exp, err := r.Export(false)
if err != nil {
t.Fatalf("Export: %v", err)
}
@ -197,7 +197,7 @@ func TestExportManifestPending(t *testing.T) {
// The ceiling stop is an infra error — expected; we only need the partial store state it leaves.
_, _ = r.TranslateBook(ctx)
exp, err := r.Export()
exp, err := r.Export(false)
if err != nil {
t.Fatalf("Export: %v", err)
}
@ -240,7 +240,7 @@ func TestExportDetectsConfigDrift(t *testing.T) {
// Unchanged config → no drift.
r2 := newRunner(t, bookPath)
exp, err := r2.Export()
exp, err := r2.Export(false)
if err != nil {
t.Fatal(err)
}
@ -263,7 +263,7 @@ func TestExportDetectsConfigDrift(t *testing.T) {
r3 := newRunner(t, bookPath)
defer r3.Close()
exp3, err := r3.Export()
exp3, err := r3.Export(false)
if err != nil {
t.Fatal(err)
}
@ -297,7 +297,7 @@ func TestExportFailLoudOnInconsistentStore(t *testing.T) {
if err := r.Store.UpsertChunkStatus(*cs); err != nil {
t.Fatal(err)
}
if _, err := r.Export(); err == nil {
if _, err := r.Export(false); err == nil {
t.Fatal("Export must fail loud on a DispOK row with an empty final_hash")
} else if !strings.Contains(err.Error(), "empty final_hash") {
t.Fatalf("unexpected error: %v", err)

View file

@ -10,6 +10,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@ -35,21 +36,23 @@ import (
//
// D28.2 fixture expansion (D30.9 diff-class, this package): three ratified behaviours now
// PINNED by golden, not only by the dedicated unit tests —
// FLOOR: fake-model floors max_tokens to 4000, fake-fallback to 6000 (per-model
// - FLOOR: fake-model floors max_tokens to 4000, fake-fallback to 6000 (per-model
// capabilities.min_max_tokens) → the wire max_tokens and the resolved capability move,
// and the escalation HOP takes ITS OWN model's floor (ch2 hop = 6000, primary = 4000);
// UNION: ch5 fires an OBLIQUE-ONLY decl term (老人→старик) whose FINAL text carries the
// - UNION: ch5 fires an OBLIQUE-ONLY decl term (老人→старик) whose FINAL text carries the
// NOMINATIVE «старик» → postcheck_miss=0 only because the base-dstdecl union checks the
// base (revert the union → this chunk false-flags a miss);
// SANITIZER (SUBSTANTIVE): ch6 edit leaks a service preamble → the output-sanitizer gate (ON
// - SANITIZER (SUBSTANTIVE): ch6 edit leaks a service preamble → the output-sanitizer gate (ON
// in the fixture pipeline) flags it FlagSanitizerDefect and the edit is DROPPED (final_text "").
//
// D38 infra-pack cells (pin the cosmetic strip-and-export path, D35.4a — final_hash points at a
// derived $0 sanitized checkpoint so the export contract yields the cleaned text; resume re-serves
// it identically at $0):
// SANITIZER (COSMETIC markdown): ch7 edit leaks a leading «### Глава 7» → flagged
// - SANITIZER (COSMETIC markdown): ch7 edit leaks a leading «### Глава 7» → flagged
// sanitizer_stripped, final_text = «Глава 7\n\n…» (the «### » stripped), a derived checkpoint holds it;
// SANITIZER (COSMETIC CJK): ch8 edit leaks a sparse «特产» → flagged sanitizer_stripped, final_text
// - SANITIZER (COSMETIC CJK): ch8 edit leaks a sparse «特产» → flagged sanitizer_stripped, final_text
// = the run removed and the seam tidied.
//
// A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check) remains an OPTIONAL
// future extension, deliberately NOT added here.
//
@ -64,9 +67,9 @@ const (
goldenEchoMarker = "響動計画"
goldenRefusalMarker = "拒絶計画"
goldenUnionMarker = "老人の秘密" // ch5 — oblique-only decl union case
goldenSanitizerMarker = "序文漏洩" // ch6 — sanitizer preamble-leak case (SUBSTANTIVE → dropped)
goldenMarkdownMarker = "見出漏洩" // ch7 — cosmetic markdown «### Глава» leak (STRIPPED + exported)
goldenCJKMarker = "漢字漏洩" // ch8 — cosmetic CJK-leak «特产» (STRIPPED + exported)
goldenSanitizerMarker = "序文漏洩" // ch6 — sanitizer preamble-leak case (SUBSTANTIVE → dropped)
goldenMarkdownMarker = "見出漏洩" // ch7 — cosmetic markdown «### Глава» leak (STRIPPED + exported)
goldenCJKMarker = "漢字漏洩" // ch8 — cosmetic CJK-leak «特产» (STRIPPED + exported)
)
// goldenRespond is the deterministic mock provider brain. The response text is a pure
@ -178,14 +181,35 @@ func captureGolden(t *testing.T, label string, r *Runner, res *BookResult, wireB
fl := func(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
w("==== run %s ====", label)
snapID, payload, err := r.snapshotID()
// R1: the driver pins each wave's jobs/rows to its OWN snapshot (draft rows → the draft-wave snapshot
// over the base bank, edit rows → the edit-wave snapshot over the enriched bank), so the golden pins
// BOTH — and the base/enriched split that keeps «переоплата ОДНА».
draftSnap, draftSnapPayload, err := r.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatal(err)
}
w("snapshot_id: %s", snapID)
w("snapshot_payload: %s", payload)
editSnap, editSnapPayload, err := r.snapshotIDForWave(waveEdit)
if err != nil {
t.Fatal(err)
}
w("snapshot_draft: %s", draftSnap)
w("snapshot_draft_payload: %s", draftSnapPayload)
w("snapshot_edit: %s", editSnap)
w("snapshot_edit_payload: %s", editSnapPayload)
w("brief_hash: %s", r.Book.BriefHash())
w("memory_version: %s", r.memoryVersion())
w("base_memory_version: %s", r.baseMemoryVersion())
// stageWaveSnap maps a stage NAME to its wave's snapshot, so each stored row's snap_match compares
// against the RIGHT per-wave snapshot (a draft row against the draft-wave snapshot, an edit row against
// the edit-wave one). retrieval_state rows are draft-basis (written in the draft wave), so they compare
// against the draft-wave snapshot.
stageWaveSnap := map[string]string{}
for _, ws := range r.waveStagesIndexed(waveDraft) {
stageWaveSnap[ws.st.Name] = draftSnap
}
for _, ws := range r.waveStagesIndexed(waveEdit) {
stageWaveSnap[ws.st.Name] = editSnap
}
w("-- book result --")
w("chunks=%d flagged=%d exit=%d total_usd=%s", len(res.Chunks), res.Flagged, res.ExitCode(), fl(res.TotalUSD))
@ -217,7 +241,7 @@ func captureGolden(t *testing.T, label string, r *Runner, res *BookResult, wireB
})
for _, cs := range css {
w("ch%d/%d %s snap_match=%t content_hash=%s disp=%s flag=%q attempts=%d final_hash=%s cost=%s escalated=%t esc_model=%q detail=%q",
cs.Chapter, cs.ChunkIdx, cs.Stage, cs.SnapshotID == snapID, cs.ContentHash, cs.Disposition,
cs.Chapter, cs.ChunkIdx, cs.Stage, cs.SnapshotID == stageWaveSnap[cs.Stage], cs.ContentHash, cs.Disposition,
cs.FlagReason, cs.Attempts, cs.FinalHash, fl(cs.CostUSD), cs.Escalated, cs.EscalationModel, cs.Detail)
}
@ -246,7 +270,7 @@ func captureGolden(t *testing.T, label string, r *Runner, res *BookResult, wireB
})
for _, rs := range rss {
w("ch%d/%d snap_match=%t exact=%d sticky=%d ambiguous=%d spoiler=%d evicted=%d postcheck_miss=%d style_flags=%d trust_gated=%d",
rs.Chapter, rs.ChunkIdx, rs.SnapshotID == snapID, rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged,
rs.Chapter, rs.ChunkIdx, rs.SnapshotID == draftSnap, rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged,
rs.NSpoilerBlocked, rs.NEvicted, rs.NPostcheckMiss, rs.NStyleFlags, rs.NTrustGatedSuppress)
w(" injected_ids=%s postcheck_detail=%s style_detail=%s trust_gate_detail=%s", rs.InjectedIDs, rs.PostcheckDetail, rs.StyleDetail, rs.TrustGateDetail)
}
@ -304,6 +328,12 @@ func TestGoldenDeterminism(t *testing.T) {
capture += captureGolden(t, "2 (resume)", r2, res2, nil)
if os.Getenv("TM_UPDATE_GOLDEN") == "1" {
// Ш-1: print the masked structural diff BEFORE writing, so a re-capture is safe-by-construction —
// a version-only re-capture shows 0 verdict/wire changes; a ratified structural change (the R1 wave
// switch) shows them for a human to confirm (attached to the landing report).
if prev, rerr := os.ReadFile(goldenFile); rerr == nil {
t.Logf("%s", goldenMaskedDiff(string(prev), capture))
}
if err := os.WriteFile(goldenFile, []byte(capture), 0o644); err != nil {
t.Fatal(err)
}
@ -321,6 +351,83 @@ func TestGoldenDeterminism(t *testing.T) {
}
}
// goldenHexRE matches a hash-like run (≥12 hex chars) — snapshot ids, request/content/final hashes, the
// memory versions — everything that moves on a --resnapshot without a verdict change.
var goldenHexRE = regexp.MustCompile(`[0-9a-f]{12,}`)
// goldenWireIdxRE matches a wire-body line's call index prefix ("[12] ") — normalised so a call reorder
// (the wave driver runs all drafts, then all edits) is not counted as a wire change.
var goldenWireIdxRE = regexp.MustCompile(`^\[\d+\] `)
// maskGoldenLines masks the volatile hash/version fields of a golden capture: a snapshot PAYLOAD line
// (metadata whose version strings + field set move on a toolchain/table/schema bump) is blanked whole; every
// other line has its hash-like runs replaced. What survives is the verdict/wire content — final texts,
// dispositions, flags, counts, and the request BODIES (whose bytes are the wire).
func maskGoldenLines(s string) []string {
lines := strings.Split(s, "\n")
for i, ln := range lines {
if strings.HasPrefix(ln, "snapshot_draft_payload:") || strings.HasPrefix(ln, "snapshot_edit_payload:") || strings.HasPrefix(ln, "snapshot_payload:") {
lines[i] = "snapshot_payload: ⟨masked⟩"
continue
}
// Normalise the wire-body call index: the wave driver reorders the calls (all drafts, then all
// edits) vs the sequential interleave, so a byte-identical request body carries a different [N].
// Masking the index lets a reordered-but-identical body match, so the diff shows only GENUINE
// wire changes (the concatenated edit input of a multi-chunk unit), not the reorder.
ln = goldenWireIdxRE.ReplaceAllString(ln, "[⟨i⟩] ")
lines[i] = goldenHexRE.ReplaceAllString(ln, "⟨hex⟩")
}
return lines
}
// multisetDiff returns the lines of `a` that are not covered by `b` (multiset semantics), i.e. lines added
// or changed going b→a. Order-insensitive, so it survives the row insert/delete a structural change makes.
func multisetDiff(a, b []string) []string {
counts := map[string]int{}
for _, ln := range b {
counts[ln]++
}
var out []string
for _, ln := range a {
if counts[ln] > 0 {
counts[ln]--
continue
}
out = append(out, ln)
}
return out
}
// goldenMaskedDiff (Ш-1) makes a golden re-capture SAFE-BY-CONSTRUCTION: before overwriting the golden it
// masks every volatile hash/version field in BOTH the old golden and the new capture, then reports how many
// lines changed ONLY in masked fields ("version-only") vs in real verdict/wire content. A clean toolchain/
// version re-capture reports ZERO verdict/wire changes; a ratified structural change (the R1 wave switch)
// reports them so a human confirms each is intended (the masked diff attached to the landing report). The
// masked lines are compared as multisets, so row insertions/deletions (the wave switch drops per-unit edit
// rows) do not smear the count across every following line.
func goldenMaskedDiff(oldGolden, newCapture string) string {
rawAdded := multisetDiff(strings.Split(newCapture, "\n"), strings.Split(oldGolden, "\n"))
rawRemoved := multisetDiff(strings.Split(oldGolden, "\n"), strings.Split(newCapture, "\n"))
maskedAdded := multisetDiff(maskGoldenLines(newCapture), maskGoldenLines(oldGolden))
maskedRemoved := multisetDiff(maskGoldenLines(oldGolden), maskGoldenLines(newCapture))
verdictChanges := len(maskedAdded) + len(maskedRemoved)
versionOnly := (len(rawAdded) + len(rawRemoved)) - verdictChanges
var b strings.Builder
fmt.Fprintf(&b, "golden masked-diff (Ш-1): %d verdict/wire change line(s), %d version-only line(s)\n", verdictChanges, versionOnly)
show := maskedAdded
if len(maskedRemoved) > len(show) {
show = maskedRemoved
}
for i, ln := range show {
if i >= 30 {
fmt.Fprintf(&b, " … (+%d more masked changes)\n", len(show)-i)
break
}
fmt.Fprintf(&b, " ~ %s\n", ln)
}
return b.String()
}
// diffHint points a human at the first diverging line — the full capture is too big
// for a useful t.Fatalf dump.
func diffHint(want, got string) string {

View file

@ -226,7 +226,7 @@ func TestIngestEPUBRubyChapterMatchesDenseNumbering(t *testing.T) {
if len(doc.Ruby) != 1 || doc.Ruby[0].Chapter != 2 {
t.Fatalf("ruby dense chapter = %#v, want chapter 2", doc.Ruby)
}
chunks := SplitChunks(doc.Chapters)
chunks := SplitChunks(doc.Chapters, testSeg())
var rubyChunkChapter int
for _, c := range chunks {
if strings.Contains(c.Text, "朱雀") {

View file

@ -40,7 +40,14 @@ var trad2simpRaw string
// editing/completing the data file also invalidates — drift-proof (D8): you cannot
// forget to bump a version when you change the table, because the table's bytes ARE
// the version.
const memoryNormAlgoVersion = "memnorm-v3-nfkc+apos+stripignorable+trad+kana+lower/nfc+dash+apos+stripignorable+lower+yofold"
// Ш-2 (D39.16, folded here in R1): unicode.Version is woven into the algorithm tag so a Unicode-table
// bump (a go toolchain upgrade — NFKC/NFC via x/text, unicode.Is(Cf), the kana range, significantLen's
// unicode.Han/Cyrillic predicates) changes this version → the snapshot moves → a LOUD --resnapshot,
// never a silent match-behaviour change on already-checkpointed chunks. TRIPWIRE: do NOT bump the
// toolchain past the pinned Unicode edition without a --resnapshot (we are on go1.26.4 / Unicode 15.0.0).
// (x/text/norm carries its OWN Unicode edition independent of stdlib unicode.Version — a standalone x/text
// dependency bump must be treated like a toolchain bump, i.e. a deliberate --resnapshot; residual noted.)
const memoryNormAlgoVersion = "memnorm-v3-nfkc+apos+stripignorable+trad+kana+lower/nfc+dash+apos+stripignorable+lower+yofold+u" + unicode.Version
var (
// trad2simp is the parsed Traditional→Simplified single-char map (data/trad2simp.txt).

View file

@ -106,6 +106,7 @@ type memoryEntry struct {
dst string // raw approved translation (may be "" for a ruby candidate)
status string // auto|draft|approved
sense string
gender string // male|female|hidden|"" (C2) — feeds the DC3 gender constraint on the editor block
sinceCh int
untilCh int
// normKeys are the normalized source surfaces (src + aliases) ELIGIBLE to fire
@ -129,7 +130,15 @@ type MemoryBank struct {
entries []memoryEntry
ac *ahoCorasick
keyOwners map[string][]int // normalized key → indices of entries that contributed it
version string // memoryVersion(): hash of frozen APPROVED rows + algo versions (F1/D8)
// enrichedVersion folds ALL foldable rows incl Source:mined — the memory version of the EDIT
// wave (edit-wave snapshot). baseVersion EXCLUDES Source:mined — the memory version of the DRAFT wave
// (draft-wave snapshot), so a the bank-mining stop bank-enrichment (adding mined rows) moves ONLY edit-wave snapshot, keeping
// draft-wave checkpoints valid (WS1 §1в, «переоплата ОДНА»). The two are DOMAIN-SEPARATED (a "base-excl-
// mined" tag in the base fold), so base ≠ enriched for EVERY row set — even a mined-free book (a
// the draft wave job must never content-address to a the edit wave checkpoint). The load-bearing invariant is not
// equality but STABILITY: base stays fixed as mined rows are added; only enriched moves.
enrichedVersion string
baseVersion string
}
// pickedEntry is one selected record for a chunk with its firing key and disposition.
@ -170,7 +179,13 @@ type memorySelection struct {
activeIDs map[string]injectionDisposition
}
func (b *MemoryBank) Version() string { return b.version }
// Version is the ENRICHED memory version (all approved rows incl mined) — the edit-wave / whole-book
// memory component. BaseVersion excludes Source:mined (the draft-wave component). They are
// DOMAIN-SEPARATED — base ≠ enriched for ANY row set, including a mined-free book (never assert
// equality) — but base stays STABLE when a the bank-mining stop pass adds mined rows, which is what keeps the draft wave
// checkpoints valid («переоплата ОДНА»).
func (b *MemoryBank) Version() string { return b.enrichedVersion }
func (b *MemoryBank) BaseVersion() string { return b.baseVersion }
// materializeMemory builds a MemoryBank from the book's stored glossary rows (ORDER
// BY-stable — GlossaryForBook). Pure and deterministic. It computes memoryVersion as a
@ -184,11 +199,12 @@ func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
for _, row := range rows {
e := memoryEntry{
id: row.Src + "\x1f" + row.Sense + "\x1f" + strconv.Itoa(row.SinceCh) + "\x1f" + strconv.Itoa(row.UntilCh),
src: row.Src,
dst: row.Dst,
status: row.Status,
sense: row.Sense,
id: row.Src + "\x1f" + row.Sense + "\x1f" + strconv.Itoa(row.SinceCh) + "\x1f" + strconv.Itoa(row.UntilCh),
src: row.Src,
dst: row.Dst,
status: row.Status,
sense: row.Sense,
gender: row.Gender,
sinceCh: row.SinceCh,
untilCh: row.UntilCh,
allowShort: row.AllowShort,
@ -240,7 +256,8 @@ func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
}
sort.Strings(allKeys) // deterministic automaton construction
b.ac = buildAC(allKeys)
b.version = computeMemoryVersion(rows, gateOn)
b.enrichedVersion = computeMemoryVersion(rows, gateOn) // all approved incl mined (the edit wave)
b.baseVersion = computeMemoryVersionScoped(rows, gateOn, true) // excl Source:mined (the draft wave)
return b
}
@ -256,14 +273,31 @@ func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
// glossary.id autoincrement is deliberately EXCLUDED (fresh each replace); only content
// columns are hashed.
func computeMemoryVersion(rows []store.GlossaryEntry, gateOn bool) string {
return computeMemoryVersionScoped(rows, gateOn, false)
}
// computeMemoryVersionScoped is computeMemoryVersion with an explicit Source-scope: excludeMined
// drops every Source:"mined" row from the fold (WS1 §1в base-bank-version). The DRAFT-wave snapshot
// (draft-wave snapshot) folds base (excludeMined=true) so a the bank-mining stop pass that ADDS mined rows moves ONLY the
// enriched (edit-wave) version — keeping draft-wave checkpoints valid, «переоплата ОДНА». Deterministic (a
// Source filter over the same ORDER BY-stable rows). excludeMined=false is BYTE-IDENTICAL to the
// pre-split fold (no extra field), so the enriched hash / existing snapshots do not move; the
// excludeMined=true variant adds a domain separator so base and enriched never collide.
func computeMemoryVersionScoped(rows []store.GlossaryEntry, gateOn, excludeMined bool) string {
h := sha256.New()
h.Write([]byte("tm-memory-v2\x00"))
h.Write([]byte(memoryNormVersion + "\x00" + memoryMatchVersion + "\x00"))
h.Write([]byte("gate:" + strconv.FormatBool(gateOn) + "\x00"))
if excludeMined {
h.Write([]byte("base-excl-mined\x00")) // domain separator: base ≠ enriched even over identical rows
}
for _, r := range rows {
if r.Status != "approved" && !gateOn {
continue
}
if excludeMined && r.Source == "mined" {
continue
}
// A fixed, length-prefixed field layout so no content can forge a boundary.
writeField(h, r.Status)
writeField(h, r.Src)
@ -492,39 +526,55 @@ func renderGlossaryBlock(injected []pickedEntry) string {
return glossaryBlockHeader + "\n" + strings.Join(lines, "\n")
}
// editorConstraintHeader introduces the editor's dst-constraint block. It gives the editor the
// approved TARGET renderings as consistency constraints — it must normalise any divergent rendering
// in the draft to these canonical forms (inflecting for context) and touch nothing else.
const editorConstraintHeader = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):"
// renderFormatVersion versions the FORMAT of the role-injection renderers whose output is NOT
// captured by memoryMatchVersion (a scope/matcher-ALGORITHM version, not a render-layout version):
// the editor constraint block's src→dst layout (WS2 §2в) and the DC3 gender-constraint annotation
// (WS5 §5(б)). A format change shifts the injected bytes → the wire, so it must be a loud --resnapshot;
// folding it as a dedicated snapshot component (snapshot.go) keeps that loudness a MECHANISM, not
// discipline — memoryMatchVersion (const memory.go) would not move on a render edit.
// v2 (WS5 R4): the editor constraint line now carries a gender annotation for a gendered CONFIRMED
// term (DC3 injection — the Bai Ninbing fix: the injection DIRECTS the editor, no coreference needed).
const renderFormatVersion = "renderfmt-v2-editor-src2dst+dc3-gender"
// renderEditorConstraintBlock serializes the selected records' CONFIRMED dst renderings into the
// editor's injection. Unlike renderGlossaryBlock it emits the TARGET forms only — bare canonical
// Russian, not "src → dst". It is CONFIRMED-only on purpose: an AMBIGUOUS record is a candidate the
// translator MAY have legitimately rejected, so forcing the editor to rewrite the draft toward it
// would corrupt a correct translation (mirrors the gate's CONFIRMED-only discipline, external-review
// #1). NOTE (D30.1 supersede of D1): the editor is now BILINGUAL — editor.md feeds it the source, so
// the earlier "monolingual editor never sees the source" rationale for target-forms-only is stale
// (the CONFIRMED-only discipline above is the LIVE reason and is unchanged here). Whether a bilingual
// editor should receive the src→dst mapping (like the translator) so it can bind each canonical form
// to its source term, rather than bare Russian, is an OPEN design question the D30.1 flip left
// unclosed (flagged to the orchestrator; behaviour deliberately unchanged in this pack). Deduped by
// dst (an alias and its main entry share a rendering), in the budget priority order fixed by Select.
// Returns "" when nothing CONFIRMED renders → the editor gets NO injection (the plain draft-only
// editorConstraintHeader introduces the editor's canonical-constraint block. It gives the BILINGUAL
// editor (D30.1) the approved src→dst bindings as consistency constraints — it must render each
// listed source term with EXACTLY the paired canonical form (inflecting for context) and touch
// nothing else.
const editorConstraintHeader = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):"
// renderEditorConstraintBlock serializes the selected records' CONFIRMED renderings into the
// editor's injection as a src→dst MAPPING (WS2 §2в resolves the D30.1-open question): "源термин →
// «dst»", like the translator block, NOT bare canonical Russian. Rationale (the single load-bearing
// one, ревью-1 F4): the editor is BILINGUAL (editor.md feeds it the source), and binding the canon
// to its SOURCE term disambiguates HOMONYMIC dst (one Russian surface for two entities) — bare
// forms cannot. It stays CONFIRMED-only on purpose: an AMBIGUOUS record is a candidate the
// translator MAY have legitimately rejected, so forcing the editor to rewrite toward it would
// corrupt a correct translation (mirrors the gate's CONFIRMED-only discipline, external-review #1);
// AMBIGUOUS/mined-draft rows never enter here. Deduped by (src,dst) — an alias and its main entry
// share the pair — which only WIDENS the list (harmless), in the budget priority order fixed by
// Select. Returns "" when nothing CONFIRMED renders → the editor gets NO injection (plain draft-only
// layout). The block is a subset of the already budget-limited memSel.injected, so it needs no
// separate token budget.
// separate token budget. The src→dst FORMAT is snapshot-folded via renderFormatVersion (a format
// edit is a loud --resnapshot); the injection is a message, so it also enters request_hash
// directly (no silent false-hit).
func renderEditorConstraintBlock(injected []pickedEntry) string {
var lines []string
seen := map[string]bool{}
seen := map[[2]string]bool{}
for _, p := range injected {
if p.disp != memConfirmed {
continue
}
src := strings.TrimSpace(p.entry.src)
dst := strings.TrimSpace(p.entry.dst)
if dst == "" || seen[dst] {
if dst == "" {
continue
}
seen[dst] = true
lines = append(lines, "- «"+dst+"»")
key := [2]string{src, dst}
if seen[key] {
continue
}
seen[key] = true
lines = append(lines, "- "+src+" → «"+dst+"»"+genderConstraintNote(p.entry.gender))
}
if len(lines) == 0 {
return ""
@ -532,6 +582,23 @@ func renderEditorConstraintBlock(injected []pickedEntry) string {
return editorConstraintHeader + "\n" + strings.Join(lines, "\n")
}
// genderConstraintNote is the DC3 gender directive appended to a CONFIRMED editor-constraint line (WS5
// §5(б)): the injection DIRECTS the editor to the character's grammatical gender, which is the fix for
// the Bai Ninbing class — no coreference needed. male/female ⇒ hard gender forms; hidden ⇒ a mandate to
// AVOID gender-marking constructions until the reveal (a masculine default when unavoidable, D19.3). ""
// for a term with no gender datum (the common case → the line is unchanged, byte-identical to before).
func genderConstraintNote(gender string) string {
switch gender {
case "male", "m":
return " (муж. — мужские родовые формы)"
case "female", "f":
return " (жен. — женские родовые формы)"
case "hidden":
return " (пол СКРЫТ до раскрытия — избегай родовых форм; при неизбежности — мужские)"
}
return ""
}
// --- Aho-Corasick multi-pattern automaton over runes ----------------------------
type acMatch struct {

View file

@ -529,36 +529,45 @@ func TestInjectivityCollision(t *testing.T) {
}
}
// TestRenderEditorConstraintBlock covers D1: the monolingual editor's injection is the
// CONFIRMED dst forms ONLY (target constraints), deduped, with no source or "src → dst".
// Reverting the CONFIRMED-only guard leaks an AMBIGUOUS candidate and fails here.
// TestRenderEditorConstraintBlock covers WS2 §2в (D30.1 open-question resolved): the BILINGUAL
// editor's injection is a CONFIRMED src→dst MAPPING (binding each canon to its source term, so a
// homonymic dst stays distinguishable), NOT bare target forms. It stays CONFIRMED-only (an
// AMBIGUOUS candidate must not force a rewrite); dedup is by (src,dst), which only widens the list.
func TestRenderEditorConstraintBlock(t *testing.T) {
suzuki := &memoryEntry{src: "鈴木", dst: "Судзуки", status: "approved"}
cand := &memoryEntry{src: "小D", dst: "Малыш Дэ", status: "auto"} // AMBIGUOUS
tanaka := &memoryEntry{src: "田中", dst: "Танака", status: "approved"}
tanakaKana := &memoryEntry{src: "たなか", dst: "Танака", status: "approved"} // same dst → deduped
tanakaDup := &memoryEntry{src: "田中", dst: "Танака", status: "approved"} // same (src,dst) → deduped
// A distinct source term that renders to the SAME Russian surface — the homonym case the
// src→dst binding exists for: both lines survive (widening), the editor can tell them apart.
homonym := &memoryEntry{src: "済", dst: "Танака", status: "approved"}
sel := []pickedEntry{
{entry: suzuki, via: "鈴木", disp: memConfirmed},
{entry: cand, via: "小d", disp: memAmbiguous},
{entry: tanaka, via: "田中", disp: memConfirmed},
{entry: tanakaKana, via: "たなか", disp: memConfirmed},
{entry: tanakaDup, via: "田中", disp: memConfirmed},
{entry: homonym, via: "済", disp: memConfirmed},
}
block := renderEditorConstraintBlock(sel)
if !strings.Contains(block, "«Судзуки»") || !strings.Contains(block, "«Танака»") {
t.Errorf("confirmed dst forms missing: %q", block)
// src→dst mapping present under the editor's own header.
if !strings.Contains(block, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ") {
t.Errorf("editor header missing: %q", block)
}
if !strings.Contains(block, "鈴木 → «Судзуки»") || !strings.Contains(block, "田中 → «Танака»") {
t.Errorf("src→dst mapping missing: %q", block)
}
// AMBIGUOUS excluded — the editor must not be forced toward an unverified rendering.
if strings.Contains(block, "Малыш Дэ") {
if strings.Contains(block, "Малыш Дэ") || strings.Contains(block, "小D") {
t.Errorf("AMBIGUOUS candidate leaked into the editor constraints: %q", block)
}
// dst-only: no source key, no arrow (the editor is monolingual).
if strings.Contains(block, "→") || strings.Contains(block, "鈴木") || strings.Contains(block, "ГЛОССАРИЙ") {
t.Errorf("editor block must be target-only, no src→dst: %q", block)
// Deduped by (src,dst): 田中→Танака appears once despite the duplicate entry.
if n := strings.Count(block, "田中 → «Танака»"); n != 1 {
t.Errorf("(src,dst) not deduped: 田中→Танака appears %d times: %q", n, block)
}
// Deduped by dst (Танака appears once despite two source keys).
if n := strings.Count(block, "«Танака»"); n != 1 {
t.Errorf("dst not deduped: «Танака» appears %d times: %q", n, block)
// Homonym widening: a DIFFERENT source with the same dst is NOT collapsed — both lines survive.
if !strings.Contains(block, "済 → «Танака»") {
t.Errorf("homonymic dst must stay distinguishable by source term: %q", block)
}
// An all-AMBIGUOUS (or empty) selection yields no block at all.
if got := renderEditorConstraintBlock([]pickedEntry{{entry: cand, disp: memAmbiguous}}); got != "" {

View file

@ -0,0 +1,38 @@
package pipeline
import (
"testing"
"textmachine/backend/internal/store"
)
// TestBaseEnrichedMemoryVersionSplit pins the WS1 §1в per-wave bank-version mechanism: the BASE
// version (draft wave / draft-wave snapshot) excludes Source:"mined" rows, so adding a mined approved row
// after the draft wave moves ONLY the ENRICHED version (edit wave / snapshot_W2) — keeping the draft wave checkpoints valid
// («переоплата ОДНА»). A broken split (base folding mined) would move draft-wave snapshot and re-bill the draft wave.
func TestBaseEnrichedMemoryVersionSplit(t *testing.T) {
seedRows := []store.GlossaryEntry{
{Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed"},
{Src: "古月", Dst: "Гу Юэ", Status: "approved", Source: "ruby"},
}
base0 := materializeMemory(seedRows, false)
// Same rows PLUS a mined approved row (as a the draft wave.5 pass would add).
enrichedRows := append([]store.GlossaryEntry{}, seedRows...)
enrichedRows = append(enrichedRows, store.GlossaryEntry{Src: "蛊", Dst: "гу", Status: "approved", Source: "mined"})
bank1 := materializeMemory(enrichedRows, false)
// 1) Base version is UNCHANGED by the mined addition (draft wave stays valid).
if base0.BaseVersion() != bank1.BaseVersion() {
t.Fatalf("base version moved on a mined-row addition — draft-wave snapshot would re-bill the draft wave:\n before %s\n after %s",
base0.BaseVersion(), bank1.BaseVersion())
}
// 2) Enriched version DID move (edit wave sees the new mined canon).
if base0.Version() == bank1.Version() {
t.Fatalf("enriched version did NOT move on a mined-row addition — the edit wave would miss the mined canon")
}
// 3) Base ≠ enriched even before any mined row (domain-separated), so a the draft wave job can never
// accidentally content-address to a the edit wave checkpoint.
if base0.BaseVersion() == base0.Version() {
t.Fatalf("base and enriched versions collide over identical rows — they must be domain-separated")
}
}

View file

@ -501,6 +501,58 @@ func hasAliasSurface(aliases []store.GlossaryAlias, surface string) bool {
return false
}
// SeedLint dry-runs the REAL loadGlossarySeed fail-louds + the approved shared-key collision check over
// a glossary seed YAML (a manual seed OR the emitted mined delta), returning nil on a clean seed or a
// loud error listing every problem (WS3 (д): "сухой прогон фейл-лаудов реального loadGlossarySeed по
// дельте"). $0, no store, no LLM — it runs the same loader translate would, so a delta that lints clean
// here is guaranteed loadable, and a shared-key livelock / duplicate / missing-dst is caught BEFORE the
// bank-mining reseed instead of aborting a paid run.
func SeedLint(path string) error {
entries, err := loadGlossarySeed(path)
if err != nil {
return err
}
if problems := approvedSharedKeyCollisions(entries); len(problems) > 0 {
return fmt.Errorf("glossary seed %s shared-key collisions:\n - %s", path, strings.Join(problems, "\n - "))
}
return nil
}
// --- mined → candidates (WS3 bank-mining mined-write path) -----------------------------
// minedToCandidates converts the miner's WHICH proposals (MineBank) into store candidates for the the bank-mining stop
// reseed (WS3 / §1в mined-write path). It is the DEDICATED mined path the plan requires: it stamps
// Source:"mined" (mirroring rubyToCandidates' Source:"ruby"), NOT the Source:"seed" that loadGlossarySeed
// hardcodes — so a mined row is EXCLUDED from the base-bank-version (BaseVersion) and the the bank-mining stop bank
// enrichment moves ONLY snapshot_W2, keeping the draft wave checkpoints valid («переоплата ОДНА», §1в/F2). Every
// candidate is status=auto with NO dst (the WHAT is delivered by the banknote/owner sign, WS4): inert on
// the hot path until a human/batch promotes it, never a blind name-lock. A src already covered by the
// manual seed is skipped (the curated entry wins), exactly like rubyToCandidates. Deterministic (MineBank
// emits a src-sorted delta; aliases are re-sorted on read by GlossaryForBook).
func minedToCandidates(mined []MinedTerm, manualSrcs map[string]bool) []store.GlossaryEntry {
var out []store.GlossaryEntry
for _, m := range mined {
if manualSrcs[m.Src] {
continue // the curated seed already owns this src
}
var aliases []store.GlossaryAlias
seen := map[string]bool{}
for _, a := range m.Aliases {
if a == "" || a == m.Src || seen[a] {
continue
}
seen[a] = true
aliases = append(aliases, store.GlossaryAlias{Alias: a, AliasType: "mined"})
}
out = append(out, store.GlossaryEntry{
Src: m.Src, Dst: "", Type: m.Type, Status: "auto", Source: "mined",
SinceCh: m.SinceCh, Confidence: m.Freq, Aliases: aliases,
Note: "mined WHICH candidate (miner-v1); dst delivered by banknote/owner sign — promote before use",
})
}
return out
}
// ruby classifier classes (deterministic HINTS for human promotion).
const (
rubyClassName = "name_candidate" // all-Han base + all-kana reading: the furigana-name SHAPE

View file

@ -0,0 +1,156 @@
package pipeline
import (
"sort"
"textmachine/backend/internal/lang"
)
// miner.go: the WS3 bank-mining detector orchestration (a Go port of the frozen exp16 arm A3 = V-C,
// LEMMATIZER-DEFAULT-B). Default B drops the pymorphy3-backed sub-channels the ratified §10-14 default
// removes: the spread multiplier (λ=0 already zeroed it), the dst-variants co-occurrence, and the
// Palladius ru-side name confirmation (+56 bonus). The dst (WHAT) is delivered by the banknote (WS4),
// never by co-occurrence, so exact rank is not a product value — the invariant guarantees are the
// candidate SET (membership), recall@proposed, and the catastrophe screen (∈top-50); dropping the
// Palladius sub-channel moves ONLY 古月's exact rank (21→22 vs the pymorphy3 reference), screen still
// PASS. Pure and deterministic ($0, no LLM, no network, no time/rand).
// MinerConfig is the frozen miner-v1 config (thresholds tuned on chapters 115, §D1). Values mirror
// arms.FROZEN; the pymorphy3-dependent knobs (lam, spread_freq_min) are absent by default-B design.
type MinerConfig struct {
FreqFloor int
SubsumeAlpha float64
FormantMinPartners int
FormantMinOverRep float64
Bonus map[string]float64 // typ → base bonus (name/place/title/term)
PatternWeight map[string]float64 // evidence-source → weight (max over a candidate's evidence)
SubfloorFreqScale float64
TopK int
PackVersion string
}
// frozenMinerConfig is the ratified default-B config (versionable — a threshold edit is a mining-config
// bump the caller folds; a ±50% sweep is invariant, A3@f≥3=1.0 holds by floor/over_rep/subsume/partners).
func frozenMinerConfig() MinerConfig {
return MinerConfig{
FreqFloor: minerFreqFloor,
SubsumeAlpha: minerSubsumeAlpha,
FormantMinPartners: 3,
FormantMinOverRep: 15.0,
Bonus: map[string]float64{"name": 140.0, "place": 140.0, "title": 120.0, "term": 80.0},
PatternWeight: map[string]float64{
"surname": 1.0, "ordinal_title": 1.0, "title_suffix": 0.9, "topo_suffix": 0.9,
"rank_grade": 1.0, "formant_suffix": 0.6, "formant_prefix": 0.5, "title_bare": 0.4,
"palladius": 0.8, // retained for parity of the weight table; the sub-channel is off in default B
},
SubfloorFreqScale: 40.0,
TopK: 90,
PackVersion: minerPackVersion,
}
}
// ScoredCand is one candidate after the V-C arm assembly (arms.ScoredCand, minus the default-B-dropped
// dst_variants/spread). types/evidence are in deterministic first-seen order; fromPattern marks a
// pattern-channel contribution.
type ScoredCand struct {
Src string
Score float64
Freq int
Types []string
Evidence []string
FromPattern bool
}
// mineResult carries the detector outputs the alias/emit layers consume.
type mineResult struct {
ranked []ScoredCand // A3 ranking, sorted (-score,-freq,src)
subsumed map[string]bool // V-A ranking-dropped boundary fragments
formants map[rune]formantInfo
cfg MinerConfig
}
// mineDetect runs V-A → V-C (arm A3, default B) over the normalized chunks and returns the ranked
// candidate list. contrast is the general-zh reference (loaded once). Deterministic.
func mineDetect(chunks []MinerChunk, contrast *Contrast, cfg MinerConfig, pack *lang.Pack) mineResult {
va := runVA(chunks, contrast, cfg.FreqFloor, cfg.SubsumeAlpha)
pats, formants := patternCandidates(chunks, va.candFreq, contrast, cfg.FormantMinPartners, cfg.FormantMinOverRep, pack)
merged := make(map[string]*ScoredCand, len(va.ranked)+len(pats))
for _, c := range va.ranked {
merged[c.src] = &ScoredCand{Src: c.src, Score: c.score, Freq: c.freq} // score·(1+λ·sp) with λ=0
}
for cand, info := range pats {
pw := 0.3
for _, ev := range info.evidence {
if w, ok := cfg.PatternWeight[evSource(ev)]; ok && w > pw {
pw = w
}
}
typ := "term"
if len(info.types) > 0 {
typ = info.types[0]
}
base, ok := cfg.Bonus[typ]
if !ok {
base = cfg.Bonus["term"]
}
bonus := base * pw
if sc, ok := merged[cand]; ok {
sc.Score += bonus
for _, t := range info.types {
if !containsStr(sc.Types, t) {
sc.Types = append(sc.Types, t)
}
}
sc.Evidence = append(sc.Evidence, firstN(info.evidence, 3)...)
sc.FromPattern = true
} else {
f := countOccurrences(cand, chunks)
merged[cand] = &ScoredCand{
Src: cand, Score: bonus + cfg.SubfloorFreqScale*float64(f)*pw, Freq: f,
Types: append([]string(nil), info.types...), Evidence: firstN(info.evidence, 3),
FromPattern: true,
}
}
}
// Palladius ru-side confirmation loop is INTENTIONALLY OMITTED (default B — it needs dst_variants +
// is_name_lemma, both pymorphy3; it only shifts 古月's exact rank, which is not a product value).
ranked := make([]ScoredCand, 0, len(merged))
for _, sc := range merged {
ranked = append(ranked, *sc)
}
sortScored(ranked)
return mineResult{ranked: ranked, subsumed: va.subsumed, formants: formants, cfg: cfg}
}
// sortScored orders the arm ranking by (-score, -freq, src) — the frozen key.
func sortScored(cs []ScoredCand) {
sort.Slice(cs, func(i, j int) bool {
if cs[i].Score != cs[j].Score {
return cs[i].Score > cs[j].Score
}
if cs[i].Freq != cs[j].Freq {
return cs[i].Freq > cs[j].Freq
}
return cs[i].Src < cs[j].Src
})
}
// evSource is the pattern-weight key: the evidence tag before its ':' (arms' ev.split(":")[0]).
func evSource(ev string) string {
for i := 0; i < len(ev); i++ {
if ev[i] == ':' {
return ev[:i]
}
}
return ev
}
// firstN returns up to n elements of ss (a fresh slice, never aliasing the input).
func firstN(ss []string, n int) []string {
if len(ss) < n {
n = len(ss)
}
return append([]string(nil), ss[:n]...)
}

View file

@ -0,0 +1,192 @@
package pipeline
import (
"sort"
"strings"
"textmachine/backend/internal/lang"
)
// miner_alias.go: alias tier-1 clustering (WS3 — a Go port of exp16 alias.py, DEFAULT-B). Precision-safe
// PROP-layer rules over a set of src surfaces:
//
// R1 surface containment X ⊂ Y, |X|≥2 → 'extension' edge (方源 ⊂ 古月方源), COMPOSITIONAL guard
// (古月+族长 = a phrase, not an alias) blocks the clan↔title chaining
// R2 surname anchor+compose shared surname → FAMILY supercluster (NOT identity)
// R4 NEGATIVE hard-blocks (checked FIRST): (ii) different confirmed gender; (iii/v) different approved
// dst → different entities EVEN under containment (族长⊂四代族长, 古月⊂古月方源); (i) same surname +
// different given names → family, not identity; (iv) co-presence in one source sentence → not identity
//
// The R3 "shared ru rendering → identity" edge is OMITTED in default B (it needs the pymorphy3-backed
// dst-variant lemmas, which the ratified default drops). Precision is measured entity-level (B³, §A5).
// aliasSurface is one src surface with the seed metadata the rules read (alias.Surface, minus the
// default-B-dropped dst_lemmas).
type aliasSurface struct {
src string // normalized src surface
gender string
approvedDst string
typ string
}
// aliasEdge is one proposed edge between two surfaces.
type aliasEdge struct {
a, b string
rule string
kind string
}
// isFragment reports whether src is a known surface + a trailing particle (alias.frag): a boundary
// artifact, not an independent entity. seedSurfaces is the normalized seed src/alias set.
func isFragment(src string, seedSurfaces map[string]bool, p *lang.Pack) bool {
r := []rune(src)
if len(r) < 2 {
return false
}
return seedSurfaces[string(r[:len(r)-1])] && p.AliasParticle[r[len(r)-1]]
}
// cooccurSameSentence reports whether a and b appear in one source sentence anywhere (alias.
// cooccur_same_sentence: split on 。!?\n).
func cooccurSameSentence(chunks []MinerChunk, aNorm, bNorm string) bool {
for _, c := range chunks {
for _, sent := range splitMinerSentences(c.NSource) {
if strings.Contains(sent, aNorm) && strings.Contains(sent, bNorm) {
return true
}
}
}
return false
}
func splitMinerSentences(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return r == '。' || r == '' || r == '' || r == '\n'
})
}
// proposeAliasEdges applies the tier-1 rules over the surfaces, returning identity, family and weak
// edges (alias.propose_edges, default B). seedSurfaces is the normalized seed src/alias set (the R1
// entity-vs-fragment guard). Deterministic: surfaces are iterated in a fixed sorted order.
func proposeAliasEdges(surfaces map[string]aliasSurface, chunks []MinerChunk, seedSurfaces map[string]bool, p *lang.Pack) (ident, family, weak []aliasEdge) {
keys := make([]string, 0, len(surfaces))
for k := range surfaces {
keys = append(keys, k)
}
sort.Strings(keys)
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
a, b := surfaces[keys[i]], surfaces[keys[j]]
// R4-ii different confirmed gender (hidden never blocks).
if a.gender != "" && b.gender != "" && a.gender != b.gender && a.gender != "hidden" && b.gender != "hidden" {
continue
}
// R4-iii/v different approved dst → different entities, even under containment.
if a.approvedDst != "" && b.approvedDst != "" && normalizeSourceKey(a.approvedDst) != normalizeSourceKey(b.approvedDst) {
continue
}
surA, surB := isSurnameStart([]rune(a.src), p), isSurnameStart([]rune(b.src), p)
// R1 containment → extension edge (same entity, fuller vs shorter surface).
if runeLen(a.src) >= 2 && runeLen(b.src) >= 2 && a.src != b.src && (strings.Contains(b.src, a.src) || strings.Contains(a.src, b.src)) {
short, long := a, b
if runeLen(b.src) < runeLen(a.src) {
short, long = b, a
}
shortIsEntity := short.typ != "" || seedSurfaces[short.src]
rest := containmentRest(short.src, long.src)
restEnt, restKnown := surfaces[rest]
compositional := rest != "" && seedSurfaces[rest] &&
(!restKnown || restEnt.typ == "title" || restEnt.typ == "place" || restEnt.typ == "term")
switch {
case compositional:
weak = append(weak, aliasEdge{a.src, b.src, "R1-compositional", "phrase_not_alias"})
case shortIsEntity:
ident = append(ident, aliasEdge{a.src, b.src, "R1", "extension"})
default:
weak = append(weak, aliasEdge{a.src, b.src, "R1-fragment?", "containment_weak"})
}
continue
}
// R2 surname anchor → family (NOT identity).
if surA != "" && surB != "" && surA == surB && a.src != b.src {
givenA := trimRunePrefix(a.src, surA)
givenB := trimRunePrefix(b.src, surB)
if givenA != givenB && givenA != "" && givenB != "" { // R4-i different given names → family only
if cooccurSameSentence(chunks, a.src, b.src) {
family = append(family, aliasEdge{a.src, b.src, "R2+R4iv", "family_copresent"})
} else {
family = append(family, aliasEdge{a.src, b.src, "R2", "family"})
}
continue
}
}
// R3 (shared ru rendering → identity) is OMITTED in default B.
}
}
return ident, family, weak
}
// containmentRest is alias.py's rest = long[len(short):] if long.startswith(short) else long[:-len(short)]
// (by character). short is contained in long.
func containmentRest(short, long string) string {
sr, lr := []rune(short), []rune(long)
if len(sr) > len(lr) {
return ""
}
if runesEqual(lr[:len(sr)], sr) {
return string(lr[len(sr):])
}
return string(lr[:len(lr)-len(sr)])
}
// trimRunePrefix removes the leading prefix (a surname) from src, by character.
func trimRunePrefix(src, prefix string) string {
sr, pr := []rune(src), []rune(prefix)
if len(pr) <= len(sr) && runesEqual(sr[:len(pr)], pr) {
return string(sr[len(pr):])
}
return src
}
// clusterAlias runs union-find over the identity edges and returns clusters of size > 1, each sorted
// (alias.cluster). allSurfaces bounds the universe.
func clusterAlias(ident []aliasEdge, allSurfaces []string) [][]string {
parent := make(map[string]string, len(allSurfaces))
for _, s := range allSurfaces {
parent[s] = s
}
var find func(string) string
find = func(x string) string {
for parent[x] != x {
parent[x] = parent[parent[x]]
x = parent[x]
}
return x
}
for _, e := range ident {
if _, ok := parent[e.a]; ok {
if _, ok := parent[e.b]; ok {
parent[find(e.a)] = find(e.b)
}
}
}
groups := map[string][]string{}
for _, s := range allSurfaces {
r := find(s)
groups[r] = append(groups[r], s)
}
var out [][]string
roots := make([]string, 0, len(groups))
for r := range groups {
roots = append(roots, r)
}
sort.Strings(roots)
for _, r := range roots {
if len(groups[r]) > 1 {
g := append([]string(nil), groups[r]...)
sort.Strings(g)
out = append(out, g)
}
}
return out
}

View file

@ -0,0 +1,160 @@
package pipeline
import (
"math"
"sort"
"strings"
"unicode/utf8"
)
// miner_detect.go: the deterministic V-A detector (WS3 — a Go port of exp16 detectors.py). Frequency ×
// contrast(weirdness) × c-value(nested discount). Output: ranked src candidates. The thresholds (freq
// floor 3, subsume α 0.80) are frozen miner-v1 (tuned on chapters 115, §D1). Pure and deterministic —
// map iteration only ever precedes a total sort by (-score, -freq, src).
// minerSubsumeAlpha: drop candidate a if a longer container b has f(b) >= α·f(a) — a is a boundary
// fragment, not an independent term (detectors.SUBSUME_ALPHA). It only cleans the candidate RANKING;
// the occurrence axis (countOccurrences) is suppress-free (D24.2).
const minerSubsumeAlpha = 0.80
// runeLen is the character length of a candidate (Han n-grams are ≤6 runes).
func runeLen(s string) int { return utf8.RuneCountInString(s) }
// minerCandidate is one V-A-scored candidate (detectors.Candidate; the V-B/V-C enrichments live on the
// arm-level ScoredCand, not here).
type minerCandidate struct {
src string
freq int
cvalue float64
weirdness float64
termhood float64
score float64
length int
}
// computeCValue is the c-value with nested discount (detectors.compute_cvalue). For a candidate a nested
// in longer candidates T_a: cval(a) = g(|a|)·(f(a) (1/|T_a|)·Σ_{b∈T_a} f(b)); non-nested: g(|a|)·f(a).
// g(L)=log2(L+1). Deterministic (accumulates into maps, order-independent).
func computeCValue(candFreq map[string]int) map[string]float64 {
tcount := make(map[string]int, len(candFreq))
tfreqsum := make(map[string]int, len(candFreq))
for b, fb := range candFreq {
br := []rune(b)
Lb := len(br)
if Lb < 2 {
continue
}
// Distinct proper substrings a of b that are themselves candidates.
subs := map[string]bool{}
for n := 1; n < Lb; n++ {
for i := 0; i+n <= Lb; i++ {
a := string(br[i : i+n])
if a == b {
continue
}
if _, ok := candFreq[a]; ok {
subs[a] = true
}
}
}
for a := range subs {
tcount[a]++
tfreqsum[a] += fb
}
}
cval := make(map[string]float64, len(candFreq))
for a, fa := range candFreq {
g := lengthMult(runeLen(a))
if tcount[a] > 0 {
cval[a] = g * (float64(fa) - float64(tfreqsum[a])/float64(tcount[a]))
} else {
cval[a] = g * float64(fa)
}
}
return cval
}
// subsumedCandidates returns the boundary-fragment candidates to drop from the RANKING (detectors.
// subsumed_candidates): a whose every occurrence is (almost) inside a strictly longer candidate b with
// f(b) ≥ α·f(a). 方源(559) survives 方源的(85) [ratio 0.15]; 花酒行(65) is dropped by 花酒行者(65) [1.0].
func subsumedCandidates(candFreq map[string]int, alpha float64) map[string]bool {
byLen := map[int][]string{}
for c := range candFreq {
L := runeLen(c)
byLen[L] = append(byLen[L], c)
}
drop := map[string]bool{}
for a, fa := range candFreq {
La := runeLen(a)
found := false
for Lb := La + 1; Lb <= minerNgramMax && !found; Lb++ {
for _, b := range byLen[Lb] {
if strings.Contains(b, a) && float64(candFreq[b]) >= alpha*float64(fa) {
found = true
break
}
}
}
if found {
drop[a] = true
}
}
return drop
}
// vaResult carries the V-A build outputs the arms/patterns layers need.
type vaResult struct {
candFreq map[string]int // freq ≥ floor
nBook int // total n-gram token mass (weirdness denominator scale)
subsumed map[string]bool // ranking-dropped boundary fragments
cval map[string]float64 // per-candidate c-value
ranked []minerCandidate // V-A ranking (subsumed excluded), sorted (-score,-freq,src)
}
// runVA builds and scores V-A over the chunks (detectors.VA.build + score_all). candFreq is the
// freq≥floor multiset; nBook is the FULL (pre-floor) token mass. score = max(cval,0)·termhood, where
// termhood=log1p(weirdness), weirdness = P_book / max(word_rel, char_indep_rel, 1e-9).
func runVA(chunks []MinerChunk, contrast *Contrast, freqFloor int, alpha float64) vaResult {
allFreq, nBook := enumerateCandidates(chunks)
candFreq := make(map[string]int, len(allFreq))
for k, v := range allFreq {
if v >= freqFloor {
candFreq[k] = v
}
}
subsumed := subsumedCandidates(candFreq, alpha)
cval := computeCValue(candFreq)
const weirdFloor = 1e-9
var ranked []minerCandidate
for a, f := range candFreq {
if subsumed[a] {
continue
}
pBook := float64(f) / float64(nBook)
gen := math.Max(math.Max(contrast.wordRel(a), contrast.charIndepRel(a)), weirdFloor)
w := pBook / gen
termhood := math.Log1p(w)
cv := cval[a]
score := math.Max(cv, 0.0) * termhood
ranked = append(ranked, minerCandidate{
src: a, freq: f, cvalue: cv, weirdness: w, termhood: termhood, score: score, length: runeLen(a),
})
}
sortCandidates(ranked)
return vaResult{candFreq: candFreq, nBook: nBook, subsumed: subsumed, cval: cval, ranked: ranked}
}
// sortCandidates orders by (-score, -freq, src) — the frozen miner-v1 ranking key (stable across Go/
// Python via the same tie-break).
func sortCandidates(cs []minerCandidate) {
sort.Slice(cs, func(i, j int) bool {
if cs[i].score != cs[j].score {
return cs[i].score > cs[j].score
}
if cs[i].freq != cs[j].freq {
return cs[i].freq > cs[j].freq
}
return cs[i].src < cs[j].src
})
}

View file

@ -0,0 +1,243 @@
package pipeline
import (
"fmt"
"sort"
"gopkg.in/yaml.v3"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/store"
)
// miner_emit.go: the WS3 seed-delta emission (§C2-7) — the miner's owner-facing output. It runs the
// default-B detector, applies the subsumption + fragment + type + freq FILTERS (never a raw 13618-dump,
// which carries §B2 fragment noise), clusters aliases (tier-1), and emits one MinedTerm per NEW entity.
//
// Discipline: the miner NEVER writes `approved` — every emitted term is a `draft`/`auto` PROPOSAL for the
// owner to sign (§C2). In default B the WHAT (dst) is delivered by the banknote (WS4) at the bank-mining sign
// boundary, so the miner emits WHICH-only candidates (status:auto, no dst — inert until a dst + owner
// promotion). A candidate that clusters with an existing seed surface is an alias-of-existing and is left
// for the owner to attach (not emitted as a new entity), so the delta is genuinely new terms.
// emitMinFreq is the emission frequency floor (emit_owner_sheets.build_precision30: freq ≥ 5).
const emitMinFreq = 5
// emitRankCap is the top-N cap applied to the ranked candidate list BEFORE the emission filters (FL-3),
// mirroring the reference's a3[:200] slice (emit_owner_sheets / alias.py:165). It bounds the owner-signed
// signature-map volume to the reference; it does NOT touch the miner's WHICH-invariant SET/recall/screen
// (those are properties of mr.ranked, computed before emission).
const emitRankCap = 200
// MinedTerm is one WHICH candidate the miner proposes (a seed-delta entry). The miner never writes a dst
// (default B) or `approved`; the caller persists it via the mined-write path (Source:"mined", status
// auto) and the owner signs it at the bank-mining stop.
type MinedTerm struct {
Src string
Type string // name|place|title|term (the candidate's first pattern type; "" → term)
SinceCh int // first chapter of appearance across the term + its aliases (auto)
Freq int
Aliases []string // identity-cluster co-surfaces (normalized), sorted; the entity's other surfaces
Evidence []string // up to 3 pattern-evidence tags (for the owner sidecar)
}
// MineBank runs the full default-B miner over the normalized chunks and emits the alias-clustered,
// filtered mined delta (WHICH candidates for owner sign). seed is the current glossary (its surfaces
// scope the non-seed filter, the fragment guard, and the alias entity/metadata). Deterministic and $0.
func MineBank(chunks []MinerChunk, contrast *Contrast, seed []store.GlossaryEntry, cfg MinerConfig, pack *lang.Pack) []MinedTerm {
mr := mineDetect(chunks, contrast, cfg, pack)
// Seed surfaces (normalized) + their metadata for the alias rules and the non-seed / fragment guards.
seedSurfaces := map[string]bool{}
seedMeta := map[string]aliasSurface{}
for _, e := range seed {
for _, surf := range append([]string{e.Src}, aliasStrings(e.Aliases)...) {
nk := normalizeSourceKey(surf)
if nk == "" {
continue
}
seedSurfaces[nk] = true
// The primary src carries the dst/gender/type; an alias inherits them (same entity).
if _, ok := seedMeta[nk]; !ok {
meta := aliasSurface{src: nk, typ: e.Type}
if e.Status == "approved" {
meta.approvedDst = e.Dst
}
meta.gender = e.Gender
seedMeta[nk] = meta
}
}
}
// Qualifying candidate pool (build_precision30 filters), in ranked order (the ranked order is the
// representative-selection order below — the top-ranked cluster member owns the aliases). The ranked
// list is capped to the top emitRankCap BEFORE the eligibility filters (FL-3), mirroring the
// reference's a3[:200] slice (emit_owner_sheets.build_precision30 / alias.py:165): the cap bounds the
// owner-signed signature map to the reference's volume, so an eligible candidate below the cap is not
// surfaced. WHICH-invariants (the 13618-member SET, recall, the catastrophe screen) are unaffected —
// they are properties of mr.ranked itself, not of the capped emission slice.
ranked := mr.ranked
if len(ranked) > emitRankCap {
ranked = ranked[:emitRankCap]
}
var pool []ScoredCand
for _, c := range ranked {
if !emissionEligible(c, mr.subsumed, seedSurfaces, pack) {
continue
}
pool = append(pool, c)
}
// Alias universe = qualifying candidate surfaces seed surfaces; run tier-1 clustering.
surfaces := map[string]aliasSurface{}
for _, c := range pool {
typ := ""
if len(c.Types) > 0 {
typ = c.Types[0]
}
surfaces[c.Src] = aliasSurface{src: c.Src, typ: typ}
}
for nk, meta := range seedMeta {
surfaces[nk] = meta // seed metadata wins (gender/approved_dst)
}
universe := make([]string, 0, len(surfaces))
for s := range surfaces {
universe = append(universe, s)
}
sort.Strings(universe)
ident, _, _ := proposeAliasEdges(surfaces, chunks, seedSurfaces, pack)
clusters := clusterAlias(ident, universe)
// clusterOf maps a surface to its identity cluster (members); a surface not in a multi-cluster maps
// to a singleton.
clusterOf := map[string][]string{}
for _, cl := range clusters {
for _, s := range cl {
clusterOf[s] = cl
}
}
var out []MinedTerm
emitted := map[string]bool{}
for _, c := range pool { // ranked order → the top-ranked cluster member is the representative
if seedSurfaces[c.Src] || emitted[c.Src] {
continue
}
cl := clusterOf[c.Src]
// A cluster touching a seed surface is an alias-of-existing entity → the owner attaches it; skip.
if clusterHasSeed(cl, seedSurfaces) {
markEmitted(cl, emitted)
continue
}
var aliases []string
if len(cl) > 1 {
for _, m := range cl {
if m != c.Src && !seedSurfaces[m] {
aliases = append(aliases, m)
}
}
sort.Strings(aliases)
markEmitted(cl, emitted) // the whole cluster is represented by this one term
} else {
emitted[c.Src] = true
}
typ := "term"
if len(c.Types) > 0 {
typ = c.Types[0]
}
out = append(out, MinedTerm{
Src: c.Src, Type: typ, Freq: c.Freq, Aliases: aliases, Evidence: c.Evidence,
SinceCh: entitySinceCh(append([]string{c.Src}, aliases...), chunks),
})
}
// Deterministic output order: by src (the caller may re-sort, but pin a stable delta).
sort.Slice(out, func(i, j int) bool { return out[i].Src < out[j].Src })
return out
}
// MinedDeltaYAML serializes the mined delta into the seedTerm YAML schema (§C2-7) — the owner-sign
// artifact and the `tmctl seed-lint` input. Every term is status:auto with NO dst (WHICH-only, default
// B; the WHAT is delivered by the banknote at the bank-mining stop). It reuses the EXISTING seedTerm schema (no new
// fields — §3(б)); evidence / zones live in the sidecar sign-map, not the seed. The output is guaranteed
// loadable by loadGlossarySeed (status:auto may lack a dst); seed-lint proves it against the real loader.
func MinedDeltaYAML(mined []MinedTerm) (string, error) {
sf := seedFile{}
for _, m := range mined {
st := seedTerm{Src: m.Src, Type: m.Type, Status: "auto", SinceCh: m.SinceCh}
for _, a := range m.Aliases {
st.Aliases = append(st.Aliases, seedAlias{Alias: a, Type: "mined"})
}
if len(m.Evidence) > 0 {
st.Note = "mined WHICH candidate; evidence: " + fmt.Sprint(m.Evidence)
}
sf.Terms = append(sf.Terms, st)
}
b, err := yaml.Marshal(sf)
if err != nil {
return "", fmt.Errorf("pipeline: marshal mined delta: %w", err)
}
return string(b), nil
}
// emissionEligible applies the build_precision30 filters: type ∈ {name,place,title}, freq ≥ 5, src not
// subsumed, len ≥ 2 rune, not a boundary fragment.
func emissionEligible(c ScoredCand, subsumed, seedSurfaces map[string]bool, pack *lang.Pack) bool {
if !hasAnyType(c.Types, "name", "place", "title") {
return false
}
if c.Freq < emitMinFreq || subsumed[c.Src] || runeLen(c.Src) < 2 {
return false
}
if isFragment(c.Src, seedSurfaces, pack) {
return false
}
return true
}
func hasAnyType(types []string, want ...string) bool {
for _, t := range types {
for _, w := range want {
if t == w {
return true
}
}
}
return false
}
func clusterHasSeed(cl []string, seedSurfaces map[string]bool) bool {
for _, s := range cl {
if seedSurfaces[s] {
return true
}
}
return false
}
func markEmitted(cl []string, emitted map[string]bool) {
for _, s := range cl {
emitted[s] = true
}
}
// entitySinceCh is the earliest chapter any of the entity's surfaces appears in (canon.since_ch = first
// chapter of appearance).
func entitySinceCh(surfaces []string, chunks []MinerChunk) int {
min := 0
for _, s := range surfaces {
for ch := range candidateChapters(s, chunks) {
if min == 0 || ch < min {
min = ch
}
}
}
return min
}
func aliasStrings(as []store.GlossaryAlias) []string {
out := make([]string, len(as))
for i, a := range as {
out[i] = a.Alias
}
return out
}

View file

@ -0,0 +1,111 @@
package pipeline
import (
"sort"
"strings"
"unicode"
"textmachine/backend/internal/lang"
)
// miner_palladius.go: the Palladius (Палладий) transliteration syllable GENERATOR + ru-side name-shape
// detector (WS3 — a Go port of exp16 palladius.py). A ru token that segments cleanly into Palladius
// syllables looks like a transliterated Chinese NAME. The pinyin→Cyrillic TABLE is DATA (a pair pack,
// configs/langpacks/<pair>/palladius.txt, loaded via internal/lang); this file keeps only the ALGORITHM —
// the syllable-inventory generator (over the table) and the greedy segmenter (D39.15: data as files,
// algorithm in code).
//
// In the ratified DEFAULT-B miner the ru-side name CONFIRMATION sub-channel is OFF (it also needs the
// pymorphy3 is_name_lemma gate to block the «найти»=най+ти false-positive class), so isPalladiusToken is
// not wired into the default-B detector pipeline. It is kept as a deterministic, tested artifact — the
// name-shape signal a future faithful-A variant (with a Go morphology backend) would consume, and the
// pattern P4 transliteration-by-pair seam (§B5).
var palladiusJQX = map[string]bool{"j": true, "q": true, "x": true}
// buildPalladiusCyrSyllables derives the distinct Cyrillic syllable set, longest-first, for greedy
// segmentation (palladius._CYR_SYL) from the pack's pinyin→Cyrillic table. Ties in length never both match
// a position (distinct strings), so the order among equal-length forms is immaterial — sorted (len desc,
// then value) for a stable artifact. The table (initials/finals/Y_W/SPECIAL_I) is langpack DATA.
func buildPalladiusCyrSyllables(p *lang.Pack) []string {
set := map[string]bool{}
for _, v := range p.PalladiusYW {
set[v] = true
}
for _, v := range p.PalladiusSpecialI {
set[v] = true
}
retroflex := map[string]bool{"zh": true, "ch": true, "sh": true, "r": true, "z": true, "c": true, "s": true}
for pi, ci := range p.PalladiusInitials {
for pf, cf := range p.PalladiusFinals {
// ü finals (written v) are valid only after j/q/x (pinyin writes ü as plain u) or l/n.
switch pf {
case "v", "ve", "van", "vn":
if !palladiusJQX[pi] && pi != "l" && pi != "n" {
continue
}
}
// skip the retroflex/sibilant + bare i (handled by SPECIAL_I).
if pf == "i" && retroflex[pi] {
continue
}
set[ci+cf] = true
}
}
out := make([]string, 0, len(set))
for s := range set {
if s != "" {
out = append(out, s)
}
}
sort.Slice(out, func(i, j int) bool {
if len([]rune(out[i])) != len([]rune(out[j])) {
return len([]rune(out[i])) > len([]rune(out[j]))
}
return out[i] < out[j]
})
return out
}
// isPalladiusToken reports whether a ru word segments fully into Palladius syllables (greedy longest-
// match, palladius.is_palladius_token): a high-precision "this looks like a transliterated Chinese name"
// signal. ъ is dropped before segmentation. minSyllables is the minimum syllable count. syllables is the
// longest-first inventory from buildPalladiusCyrSyllables(pack).
func isPalladiusToken(token string, minSyllables int, syllables []string) bool {
t := strings.ToLower(strings.TrimSpace(token))
t = strings.ReplaceAll(t, "ъ", "")
if t == "" || !allCyrillic(t) {
return false
}
tr := []rune(t)
nSyl := 0
for i := 0; i < len(tr); {
matched := false
for _, s := range syllables {
sr := []rune(s)
if i+len(sr) <= len(tr) && runesEqual(tr[i:i+len(sr)], sr) {
i += len(sr)
nSyl++
matched = true
break
}
}
if !matched {
return false
}
}
return nSyl >= minSyllables
}
// allCyrillic reports whether every rune of s is Cyrillic (palladius._RE_CYR_WORD).
func allCyrillic(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !unicode.Is(unicode.Cyrillic, r) {
return false
}
}
return true
}

View file

@ -0,0 +1,133 @@
package pipeline
import (
"encoding/json"
"os"
"testing"
)
// miner_parity_test.go: the WS3 (д) Go↔Python full-book parity check. It re-runs the default-B miner on
// the PINNED exp16 inputs (jieba contrast + the 25-chapter records.json + the seed GT) and asserts the
// Palladius-INVARIANT guarantees the Go port must reproduce (research/20 §D, ws3_miner_verify.py):
//
// - candidate SET count = 13618 (membership, integer-determined — Palladius-invariant);
// - recall@proposed (overall) = 0.965 (set membership — Palladius-invariant);
// - catastrophe screen: 方源/蛊/蛊师 at ranks 0/1/2 (invariant), 古月 ∈ top-50 (rank 22 in default B —
// the +56 Palladius bonus is dropped; the SCREEN still passes, exact rank is not a product value).
//
// It is DATA-GATED: the jieba artifact + book records are OUT of git (CLAUDE.md), so the test SKIPS when
// they are absent (CI / a fresh checkout) and runs on the stand (or when TM_MINER_PARITY=1 forces it,
// failing loud if the data is missing). $0, deterministic — Python is the reference; a divergence means
// the Go port is wrong (fix Go), never the reference.
// The stand-data paths default to the stand layout but are overridable via env so the test is not
// pinned to one machine's absolute paths (test hygiene): TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}.
// The data is out of git (CLAUDE.md), so the test still SKIPS when a path is absent unless
// TM_MINER_PARITY=1 forces it (then a missing path fails loud).
var (
minerParityContrast = envOr("TM_MINER_PARITY_CONTRAST", "/home/ubuntu/projects/textmachine/eval/exp16/data/jieba_dict_general_zh.txt")
minerParityRecords = envOr("TM_MINER_PARITY_RECORDS", "/home/ubuntu/books/gu-zhenren/rerun/records.json")
minerParitySeed = envOr("TM_MINER_PARITY_SEED", "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml")
)
// envOr returns the environment override for key, or def when it is unset/empty.
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func TestMinerFullBookParity(t *testing.T) {
force := os.Getenv("TM_MINER_PARITY") == "1"
for _, p := range []string{minerParityContrast, minerParityRecords, minerParitySeed} {
if _, err := os.Stat(p); err != nil {
if force {
t.Fatalf("TM_MINER_PARITY=1 but required data is missing: %s", p)
}
t.Skipf("stand data absent (%s) — skipping full-book parity (set TM_MINER_PARITY=1 to force)", p)
}
}
// Contrast (jieba dict).
cf, err := os.Open(minerParityContrast)
if err != nil {
t.Fatal(err)
}
defer cf.Close()
contrast, err := LoadContrast(cf)
if err != nil {
t.Fatal(err)
}
// Chunks (records.json → normalized miner chunks).
raw, err := os.ReadFile(minerParityRecords)
if err != nil {
t.Fatal(err)
}
var recs []struct {
Chapter int `json:"chapter"`
ChunkIdx int `json:"chunk_idx"`
Source string `json:"source"`
}
if err := json.Unmarshal(raw, &recs); err != nil {
t.Fatal(err)
}
chunks := make([]MinerChunk, 0, len(recs))
for _, r := range recs {
chunks = append(chunks, MinerChunk{Chapter: r.Chapter, ChunkIdx: r.ChunkIdx, NSource: normalizeSourceKey(r.Source)})
}
// GT (the seed, parsed by the REAL loader — a divergence would fail seed-lint too).
gt, err := loadGlossarySeed(minerParitySeed)
if err != nil {
t.Fatal(err)
}
mr := mineDetect(chunks, contrast, frozenMinerConfig(), testLangPack(t))
// 1) candidate SET count.
n := len(mr.ranked)
if n != 13618 {
t.Errorf("candidate SET count = %d, want 13618 (Palladius-invariant)", n)
}
// 2) catastrophe screen.
rank := map[string]int{}
for i, c := range mr.ranked {
rank[c.Src] = i
}
catExpect := map[string]int{"方源": 0, "蛊": 1, "蛊师": 2}
for src, want := range catExpect {
if got, ok := rank[normalizeSourceKey(src)]; !ok || got != want {
t.Errorf("catastrophe rank[%s] = %d (present=%v), want %d (Palladius-invariant)", src, got, ok, want)
}
}
if got, ok := rank[normalizeSourceKey("古月")]; !ok || got >= 50 {
t.Errorf("catastrophe rank[古月] = %d (present=%v), want ∈top-50 (default B ≈22)", got, ok)
} else {
t.Logf("古月 rank = %d (default B — Palladius sub-channel dropped; reference A3 = 21)", got)
}
// 3) recall@proposed (overall): fraction of GT entities with any normalized surface in the SET.
candSet := make(map[string]bool, n)
for _, c := range mr.ranked {
candSet[c.Src] = true
}
hits := 0
for _, e := range gt {
surfaces := append([]string{e.Src}, aliasStrings(e.Aliases)...)
for _, s := range surfaces {
if nk := normalizeSourceKey(s); nk != "" && candSet[nk] {
hits++
break
}
}
}
recall := float64(hits) / float64(len(gt))
if recall < 0.960 || recall > 0.970 {
t.Errorf("recall@proposed = %.4f (%d/%d), want ≈0.965", recall, hits, len(gt))
}
t.Logf("PARITY: n=%d catastrophe{方源:%d 蛊:%d 蛊师:%d 古月:%d} recall@proposed=%.4f (%d/%d GT)",
n, rank[normalizeSourceKey("方源")], rank[normalizeSourceKey("蛊")], rank[normalizeSourceKey("蛊师")],
rank[normalizeSourceKey("古月")], recall, hits, len(gt))
}

View file

@ -0,0 +1,306 @@
package pipeline
import "textmachine/backend/internal/lang"
// miner_patterns.go: the V-C pattern channels (WS3 — a Go port of exp16 patterns.py). A language×genre
// pattern pack for zh (§B5 plugin P3, universal set only — owner decision 18.07: genre packs are NOT
// built). Each channel proposes TYPED candidates (name/title/place/term) with evidence, closing V-A's
// frequency-blind classes (rare surname-anchored names, rank/grade titles, one-off realia). Versioned
// data (surnames / title affixes / topo suffixes) + a book-adaptive productive-morphology detector
// (channel 4 — auto-detects the domain formant, NOT hardcoded 蛊). Pure and deterministic.
// minerPackVersion is the pattern-inventory (channel) version (patterns.PACK_VERSION) — the ALGORITHM
// schema, distinct from the DATA. The surname / title-topo-rank inventories / particle / Palladius tables
// this file's channels consult now live in a langpack (internal/lang, configs/langpacks/), content-hashed
// by Pack.Version(); this const versions the pattern CHANNELS, not the tables (D39.15: data as files).
const minerPackVersion = "zh-universal-v1"
// isSurnameStart returns the surname prefix (compound preferred) if s starts with one, else "" (patterns.
// is_surname_start). s is a []rune (a Han run suffix); the surname sets come from the langpack.
func isSurnameStart(s []rune, p *lang.Pack) string {
if len(s) >= 2 {
if pre := string(s[:2]); p.SurnamesCompound[pre] {
return pre
}
}
if len(s) >= 1 && p.SurnamesSingle[s[0]] {
return string(s[0])
}
return ""
}
// patternInfo is one candidate's channel-proposed types + evidence, in first-seen (deterministic) order.
type patternInfo struct {
types []string
evidence []string
}
// formantInfo describes a detected productive-morphology formant (patterns.detect_formants entry).
type formantInfo struct {
role string // suffix|prefix|both
overRep float64
nSuffix int
nPrefix int
}
// runeHasPrefixAt reports whether run[i:] starts with p (patterns' run.startswith(suf, i)).
func runeHasPrefixAt(run []rune, i int, p []rune) bool {
if i+len(p) > len(run) {
return false
}
for k := range p {
if run[i+k] != p[k] {
return false
}
}
return true
}
// formantType maps a formant char to a candidate type (patterns.formant_type): topo→place, 等/转/阶→
// title, else term.
func formantType(c rune, p *lang.Pack) string {
if p.TopoSuffix[c] {
return "place"
}
if c == '等' || c == '转' || c == '阶' {
return "title"
}
return "term"
}
// bookCharFreq counts Han chars over the normalized sources (patterns.book_char_freq).
func bookCharFreq(chunks []MinerChunk) map[rune]int {
bc := map[rune]int{}
for _, c := range chunks {
for _, r := range c.NSource {
if isMinerHan(r) {
bc[r]++
}
}
}
return bc
}
// detectFormants auto-detects the productive DOMAIN formants (patterns.detect_formants): a Han char that
// binds (suffix OR prefix) with ≥ minPartners DISTINCT content morphemes among candidates AND is
// over-represented in-book vs general zh (over_rep ≥ minOverRep). Over-representation — NOT char-rarity
// — isolates 蛊/窍/虫 while rejecting common chars 师/花/等. Deterministic (per-char, order-independent).
func detectFormants(chunks []MinerChunk, candFreq map[string]int, contrast *Contrast, minPartners int, minOverRep float64) map[rune]formantInfo {
bc := bookCharFreq(chunks)
totBook := 0
for _, v := range bc {
totBook += v
}
if totBook == 0 {
totBook = 1
}
suf := map[rune]map[string]bool{}
pre := map[rune]map[string]bool{}
addPartner := func(m map[rune]map[string]bool, key rune, part string) {
if m[key] == nil {
m[key] = map[string]bool{}
}
m[key][part] = true
}
for a := range candFreq {
ar := []rune(a)
if len(ar) < 2 {
continue
}
addPartner(suf, ar[len(ar)-1], string(ar[:len(ar)-1]))
addPartner(pre, ar[0], string(ar[1:]))
}
chars := map[rune]bool{}
for c := range suf {
chars[c] = true
}
for c := range pre {
chars[c] = true
}
formants := map[rune]formantInfo{}
for c := range chars {
pBook := float64(bc[c]) / float64(totBook)
overRep := contrast.charOverRep(c, pBook)
if overRep < minOverRep {
continue
}
ns, npr := len(suf[c]), len(pre[c])
role := ""
if ns >= minPartners {
role = "suffix"
}
if npr >= minPartners {
if role != "" {
role = "both"
} else {
role = "prefix"
}
}
if role != "" {
formants[c] = formantInfo{role: role, overRep: overRep, nSuffix: ns, nPrefix: npr}
}
}
return formants
}
// patternCandidates produces the typed pattern candidates from the source (patterns.pattern_candidates).
// It operates over the ALREADY-normalized source and may propose candidates BELOW the V-A freq floor
// (that is the point — patterns close the rare-term blind spot). Returns the candidate→info map and the
// detected formants. Deterministic (fixed chunk/run/position iteration; add() preserves first-seen order).
func patternCandidates(chunks []MinerChunk, candFreq map[string]int, contrast *Contrast, minPartners int, minOverRep float64, p *lang.Pack) (map[string]*patternInfo, map[rune]formantInfo) {
out := map[string]*patternInfo{}
add := func(cand []rune, typ, ev string) {
cn := normalizeSourceKey(string(cand))
if cn == "" || !minerHanOnly(cn) {
return
}
info := out[cn]
if info == nil {
info = &patternInfo{}
out[cn] = info
}
if !containsStr(info.types, typ) {
info.types = append(info.types, typ)
}
if !containsStr(info.evidence, ev) {
info.evidence = append(info.evidence, ev)
}
}
titleSufR := toRuneSlices(p.TitleSuffix)
ordinalR := toRuneSlices(p.OrdinalTitle)
rankR := toRuneSlices(p.RankWord)
for _, c := range chunks {
for _, run := range hanRuns(c.NSource) {
L := len(run)
for i := 0; i < L; i++ {
// (1) surname anchor: surname + 12 Han given-name window → name
if sn := isSurnameStart(run[i:], p); sn != "" {
base := i + len([]rune(sn))
for _, gl := range [2]int{1, 2} {
if base+gl <= L {
full := run[i : base+gl]
if len(full) >= 2 && len(full) <= 4 {
add(full, "name", "surname:"+sn)
}
}
}
}
// (2) title suffix: content + suffix → title
for si, suf := range titleSufR {
if runeHasPrefixAt(run, i, suf) {
for _, left := range [4]int{3, 2, 1, 0} {
if i-left >= 0 {
cand := run[i-left : i+len(suf)]
if len(cand) >= 2 && len(cand) <= 6 {
add(cand, "title", "title_suffix:"+p.TitleSuffix[si])
}
}
}
add([]rune(p.TitleSuffix[si]), "title", "title_bare:"+p.TitleSuffix[si])
}
}
// (2b) ordinal + title (四代族长-class) → title
for oi, od := range ordinalR {
if runeHasPrefixAt(run, i, od) {
for si, suf := range titleSufR {
end := i + len(od)
if runeHasPrefixAt(run, end, suf) {
add(run[i:end+len(suf)], "title", "ordinal_title:"+p.OrdinalTitle[oi]+"+"+p.TitleSuffix[si])
}
}
}
}
// (2c) rank/grade compositional: (numeral|grade-prefix) + rank-word → title
for ri, rw := range rankR {
if runeHasPrefixAt(run, i, rw) && i >= 1 {
left := run[i-1]
if p.Numeral[left] || p.GradePrefix[left] {
add(run[i-1:i+len(rw)], "title", "rank_grade:"+string(left)+"+"+p.RankWord[ri])
}
}
}
// (3) topo suffix: 13 Han base + topo char → place
if p.TopoSuffix[run[i]] && i >= 1 {
for _, left := range [3]int{3, 2, 1} {
if i-left >= 0 {
cand := run[i-left : i+1]
if len(cand) >= 2 && len(cand) <= 4 {
add(cand, "place", "topo_suffix:"+string(run[i]))
}
}
}
}
}
}
}
// (4) productive morphology: for each detected formant, propose all binding n-grams.
formants := detectFormants(chunks, candFreq, contrast, minPartners, minOverRep)
for _, c := range chunks {
for _, run := range hanRuns(c.NSource) {
L := len(run)
for i := 0; i < L; i++ {
info, ok := formants[run[i]]
if !ok {
continue
}
typ := formantType(run[i], p)
if info.role == "suffix" || info.role == "both" {
for _, left := range [3]int{3, 2, 1} {
if i-left >= 0 {
cand := run[i-left : i+1]
if len(cand) >= 2 && len(cand) <= 4 {
add(cand, typ, "formant_suffix:"+string(run[i]))
}
}
}
}
if info.role == "prefix" || info.role == "both" {
for _, r := range [2]int{2, 3} {
if i+r <= L {
cand := run[i : i+r]
if len(cand) >= 2 && len(cand) <= 4 {
add(cand, typ, "formant_prefix:"+string(run[i]))
}
}
}
}
}
}
}
return out, formants
}
// minerHanOnly reports whether s is non-empty and all Han (exp16_common.han_only).
func minerHanOnly(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !isMinerHan(r) {
return false
}
}
return true
}
// toRuneSlices converts a []string inventory to []([]rune) once (avoids re-decoding per scan position).
func toRuneSlices(ss []string) [][]rune {
out := make([][]rune, len(ss))
for i, s := range ss {
out[i] = []rune(s)
}
return out
}
// containsStr reports whether ss contains s.
func containsStr(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}

View file

@ -0,0 +1,224 @@
package pipeline
import (
"bufio"
"io"
"math"
"strconv"
"strings"
"unicode"
)
// miner_substrate.go: the deterministic bank-mining substrate (WS3, слой 4 — a Go port of the FROZEN
// exp16 miner-v1: exp16_common.py). $0, no LLM, no embeddings. It provides the candidate generator (Han
// char n-grams 16 over the memnorm-normalized source, NO word segmentation — §B1/Qiu&Zhang), the
// general-zh contrast corpus (a versioned jieba word-freq artifact), overlapping occurrence counting
// (Aho-Corasick-equivalent WITHOUT the hot-path suppressContained — D24.2), and the frequency strata.
//
// It reuses the VALIDATED normalizeSourceKey (memnorm.go, byte-faithful to the eval mem_select port), so
// a candidate key and a seed/GT key live in the SAME normalized space (trad→simp, NFKC, kana-fold, lower)
// — comparable to the jieba dict (simplified zh). The whole detector (this + miner_detect/patterns/arms)
// is the WHICH channel; the dst (WHAT) is delivered by the banknote (WS4), never by co-occurrence
// (canon-recovery 0.200.68 < 70% — research/20). Determinism is load-bearing: no time/rand, fixed
// iteration; the mined delta is a pure function of (chunks, contrast, config).
// Miner n-gram bounds and the V-A frequency floor (exp16_common: NGRAM_MIN/MAX, FREQ_FLOOR).
const (
minerNgramMin = 1
minerNgramMax = 6
minerFreqFloor = 3 // §A1/§B1: below this V-A does not score (a conscious blind spot patterns close)
)
// MinerChunk is one source chunk for mining: its chapter and the memnorm-normalized zh source. The
// caller normalizes once (normalizeSourceKey) so the candidate space matches the glossary/GT space.
type MinerChunk struct {
Chapter int
ChunkIdx int
NSource string // normalizeSourceKey(raw source)
}
// isMinerHan reports whether r is a Han ideograph — the candidate alphabet (§B1). Mirrors the Python
// reference's \p{Han}: candidate n-grams are runs of Han characters only.
func isMinerHan(r rune) bool { return unicode.Is(unicode.Han, r) }
// hanRuns returns the maximal runs of Han characters in normalized text (each run a []rune slice so
// n-gram slicing is by CHARACTER, exactly like the Python str slicing).
func hanRuns(ntext string) [][]rune {
var runs [][]rune
var cur []rune
for _, r := range ntext {
if isMinerHan(r) {
cur = append(cur, r)
} else if len(cur) > 0 {
runs = append(runs, cur)
cur = nil
}
}
if len(cur) > 0 {
runs = append(runs, cur)
}
return runs
}
// enumerateCandidates counts every Han n-gram (length minerNgramMin..minerNgramMax) with OVERLAPPING
// counts across the chunk sources (exp16_common.enumerate_candidates). The annotation chunk is included
// (blurb terms live there; the GT/seed filter handles the blurb rule separately). Returns the full
// multiset (before the freq floor) — its total token mass is the weirdness denominator (N_book).
func enumerateCandidates(chunks []MinerChunk) (freq map[string]int, nBook int) {
freq = make(map[string]int, 1<<16)
for _, c := range chunks {
for _, run := range hanRuns(c.NSource) {
L := len(run)
for n := minerNgramMin; n <= minerNgramMax; n++ {
for i := 0; i+n <= L; i++ {
freq[string(run[i:i+n])]++
nBook++
}
}
}
}
return freq, nBook
}
// countOccurrences totals the OVERLAPPING occurrences of a normalized needle across the chunk sources
// (exp16_common.count_occurrences: pos advances by 1, so 重重 counts twice in 重重重). Suppress-free
// (D24.2) — this is the occurrence axis, never the hot-path longest-match.
func countOccurrences(needleNorm string, chunks []MinerChunk) int {
if needleNorm == "" {
return 0
}
total := 0
for _, c := range chunks {
hay := c.NSource
for pos := 0; pos <= len(hay); {
rel := strings.Index(hay[pos:], needleNorm)
if rel < 0 {
break
}
total++
pos = pos + rel + 1 // overlap advance by ONE byte-position (mirrors Python find(needle, pos+1))
}
}
return total
}
// candidateChapters returns the set of chapters whose normalized source contains the candidate (for
// since_ch = the first chapter of appearance, exp16_common.candidate_chapters). Deterministic.
func candidateChapters(candNorm string, chunks []MinerChunk) map[int]bool {
chs := map[int]bool{}
if candNorm == "" {
return chs
}
for _, c := range chunks {
if strings.Contains(c.NSource, candNorm) {
chs[c.Chapter] = true
}
}
return chs
}
// freqStratum buckets an occurrence count (exp16_common.freq_stratum).
func freqStratum(f int) string {
switch {
case f >= 10:
return "f>=10"
case f >= minerFreqFloor:
return "f3-9"
default:
return "f<3"
}
}
// --- general-zh contrast corpus (jieba dict.txt: "word freq POS" per line) ------------------------
// Contrast is the general-domain zh reference: normalized word frequencies + derived char frequencies,
// used for the weirdness (domain-specificity) signal. A versioned artifact (the jieba 0.42.1 dict.txt,
// SHA-pinned, kept OUT of git — reproducible from jieba). Load it once (miner-build time, $0).
type Contrast struct {
wordFreq map[string]int // normalized word → max freq (trad/simp collisions fold to the max)
charFreq map[rune]int // char → summed freq across normalized words
totalWord int64
totalChar int64
nDistinct int // len(charFreq), the Laplace |distinct| term
}
// LoadContrast reads a jieba-style "word freq POS" corpus from r and builds the contrast model. Each
// word is normalized into the candidate space (normalizeSourceKey) and, on a normalization collision,
// the MAX frequency is kept (mirrors exp16_common.Contrast). Malformed / non-integer-freq lines are
// skipped, exactly like the reference.
func LoadContrast(r io.Reader) (*Contrast, error) {
c := &Contrast{wordFreq: map[string]int{}, charFreq: map[rune]int{}}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
w := fields[0]
fr, err := strconv.Atoi(fields[1])
if err != nil {
continue
}
wn := normalizeSourceKey(w)
if wn == "" {
continue
}
if prev, ok := c.wordFreq[wn]; !ok || fr > prev {
c.wordFreq[wn] = fr
}
c.totalWord += int64(fr)
for _, ch := range wn {
c.charFreq[ch] += fr
}
}
if err := sc.Err(); err != nil {
return nil, err
}
for _, v := range c.charFreq {
c.totalChar += int64(v)
}
c.nDistinct = len(c.charFreq)
return c, nil
}
// wordRel is P_general(ngram) treated as a whole word (0 for OOV).
func (c *Contrast) wordRel(ngramNorm string) float64 {
if c.totalWord == 0 {
return 0
}
return float64(c.wordFreq[ngramNorm]) / float64(c.totalWord)
}
// charIndepRel is the expected P_general(ngram) under char-independence with Laplace smoothing
// (exp16_common.char_indep_rel): pc = (cf+1)/(total_char+|distinct|), product over chars.
func (c *Contrast) charIndepRel(ngramNorm string) float64 {
denom := float64(c.totalChar) + float64(c.nDistinct)
if denom == 0 {
return 0
}
p := 1.0
for _, ch := range ngramNorm {
pc := float64(c.charFreq[ch]+1) / denom
p *= pc
}
return p
}
// charOverRep is p_book(char)/p_general(char) — the domain over-representation used by the formant
// detector (patterns). p_general uses the same Laplace denominator as charIndepRel.
func (c *Contrast) charOverRep(ch rune, pBook float64) float64 {
denom := float64(c.totalChar) + float64(c.nDistinct)
if denom == 0 {
return 0
}
pGen := float64(c.charFreq[ch]+1) / denom
if pGen == 0 {
return 0
}
return pBook / pGen
}
// lengthMult is the c-value g(L)=log2(L+1) length multiplier (miner_detect uses it; kept here beside
// the substrate math). §B1: NOT log2(L) — that zeros |a|=1 (蛊/转) and breaks the catastrophe screen.
func lengthMult(L int) float64 { return math.Log2(float64(L) + 1) }

View file

@ -0,0 +1,353 @@
package pipeline
import (
"reflect"
"strings"
"testing"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/store"
)
// testLangPack loads the real in-repo zh→ru language pack (configs/langpacks/) for the miner fixtures.
// The pack files are byte-derived from the former constants (langpack move, D39.15), so the miner is
// exercised over the exact same data.
func testLangPack(t *testing.T) *lang.Pack {
t.Helper()
p, err := lang.Load("../../configs/langpacks", "zh", "ru")
if err != nil {
t.Fatalf("load langpack: %v", err)
}
return p
}
// miner_test.go: synthetic, in-repo unit fixtures for the WS3 default-B miner (no external book data —
// the full-book Go↔Python parity is the data-gated TestMinerFullBookParity). Each fixture pins one
// load-bearing algorithm piece: the c-value length-multiplier (蛊/转 non-degenerate), subsumption α, the
// pattern channels, is_palladius_token, the alias tier-1 rules (the plan's required §3(д) fixtures), the
// emission filters, determinism, and the Source:"mined" write path.
// mchunks builds miner chunks from raw zh strings (one chunk per chapter, normalized like the runner).
func mchunks(sources ...string) []MinerChunk {
out := make([]MinerChunk, len(sources))
for i, s := range sources {
out[i] = MinerChunk{Chapter: i + 1, ChunkIdx: 0, NSource: normalizeSourceKey(s)}
}
return out
}
// smallContrast is a tiny general-zh reference: common function/name chars carry high freq; the domain
// formant 蛊 is deliberately ABSENT so its book over-representation is high (the formant signal).
func smallContrast(t *testing.T) *Contrast {
t.Helper()
const data = "的 100000 uj\n是 80000 v\n人 50000 n\n出 9000 v\n现 9000 v\n" +
"月 4000 n\n光 3800 n\n希 900 n\n望 3000 v\n影 800 n\n在 20000 p\n此 1200 r\n" +
"古 400 nr\n方 900 nr\n源 300 n\n族 500 n\n长 6000 a\n青 700 n\n茅 20 n\n山 5000 n\n" +
"四 8000 m\n代 3000 n\n甲 300 n\n等 12000 u\n正 4000 a\n"
c, err := LoadContrast(strings.NewReader(data))
if err != nil {
t.Fatalf("LoadContrast: %v", err)
}
return c
}
func TestMinerLengthMultNonDegenerate(t *testing.T) {
// §B1: g(L)=log2(L+1), NOT log2(L) — a single-char candidate (蛊/转) must keep a NON-ZERO length
// multiplier, else the catastrophe screen zeros out and 蛊 sinks to rank ~4753.
if got := lengthMult(1); got != 1.0 {
t.Fatalf("lengthMult(1) = %v, want 1.0 (log2(2)); log2(1)=0 would degenerate |a|=1 candidates", got)
}
if lengthMult(2) <= lengthMult(1) {
t.Fatalf("length multiplier must be monotone in L")
}
}
func TestMinerCValueSingleCharNested(t *testing.T) {
// 蛊 (len 1) nested in 蛊师 (len 2): its c-value is g(1)·(f(蛊) f(蛊师)) — computed, finite, and its
// magnitude is non-degenerate because g(1)=1 (not 0). Pins the length-mult fix at the c-value layer.
cf := map[string]int{"蛊": 10, "蛊师": 4}
cv := computeCValue(cf)
want := 1.0 * (10.0 - 4.0/1.0) // g(1)·(f(蛊) avg over its 1 container)
if got := cv["蛊"]; got != want {
t.Fatalf("cval[蛊] = %v, want %v (g(1)=1 non-degenerate)", got, want)
}
if cv["蛊师"] != lengthMult(2)*4 { // non-nested → g(2)·f
t.Fatalf("cval[蛊师] = %v, want %v", cv["蛊师"], lengthMult(2)*4)
}
}
func TestMinerSubsumption(t *testing.T) {
// 方源(559) survives 方源的(85) [ratio 0.15 < 0.80]; 花酒行(65) is dropped by 花酒行者(65) [ratio 1.0].
cf := map[string]int{"方源": 559, "方源的": 85, "花酒行": 65, "花酒行者": 65}
drop := subsumedCandidates(cf, minerSubsumeAlpha)
if drop["方源"] {
t.Fatalf("方源 must NOT be subsumed by 方源的 (85 < 0.8·559)")
}
if !drop["花酒行"] {
t.Fatalf("花酒行 must be subsumed by 花酒行者 (65 >= 0.8·65)")
}
}
func TestMinerPatternChannels(t *testing.T) {
chunks := mchunks(
"古月方源走进青茅山。四代族长甲等弟子。", // surname+name, topo, ordinal_title, rank_grade
)
// candFreq is only needed for the formant channel; the structural channels scan the source directly.
cf := map[string]int{}
pats, _ := patternCandidates(chunks, cf, smallContrast(t), 3, 15.0, testLangPack(t))
cases := []struct {
src, typ string
}{
{"古月方源", "name"}, // surname anchor (compound 古月 + 方源)
{"青茅山", "place"}, // topo suffix 山
{"四代族长", "title"}, // ordinal_title 四代+族长
{"甲等", "title"}, // rank_grade 甲+等
{"族长", "title"}, // title_suffix / title_bare
}
for _, c := range cases {
info, ok := pats[normalizeSourceKey(c.src)]
if !ok {
t.Errorf("pattern channel missed %q", c.src)
continue
}
if !containsStr(info.types, c.typ) {
t.Errorf("%q types = %v, want to include %q", c.src, info.types, c.typ)
}
}
}
func TestMinerFormantDetection(t *testing.T) {
// 蛊 binds ≥3 distinct morphemes among freq≥3 candidates AND is over-represented in-book vs the
// contrast (absent there) → auto-detected as a suffix formant; a common char (的) is NOT.
chunks := mchunks(strings.Repeat("月光蛊出现。希望蛊出现。月影蛊出现。", 3))
va := runVA(chunks, smallContrast(t), minerFreqFloor, minerSubsumeAlpha)
formants := detectFormants(chunks, va.candFreq, smallContrast(t), 3, 15.0)
if _, ok := formants['蛊']; !ok {
t.Fatalf("蛊 must be auto-detected as a productive formant, got %v", formantKeys(formants))
}
if _, ok := formants['的']; ok {
t.Fatalf("的 (common char) must NOT be a formant")
}
}
func TestMinerPalladiusToken(t *testing.T) {
syl := buildPalladiusCyrSyllables(testLangPack(t))
// Positives: transliterated Chinese-name shapes segment cleanly.
for _, tok := range []string{"юань", "фан", "гу", "цзюй"} {
if !isPalladiusToken(tok, 1, syl) {
t.Errorf("isPalladiusToken(%q) = false, want true (Palladius name shape)", tok)
}
}
// Negative control (palladius.py): common Russian words are NOT Palladius tokens.
for _, tok := range []string{"человек", "который", "деревня", "камень"} {
if isPalladiusToken(tok, 1, syl) {
t.Errorf("isPalladiusToken(%q) = true, want false (common ru word)", tok)
}
}
// The «найти»=най+ти FALSE-POSITIVE the pymorphy3 is_name_lemma gate exists to block: it IS
// Palladius-shaped, which is exactly why default B drops the whole ru-side name confirmation channel
// (no morphology backend to run the gate) rather than trust the token shape alone.
if !isPalladiusToken("найти", 2, syl) {
t.Fatalf("найти must be Palladius-shaped (documents why default-B drops the ru name channel)")
}
}
// aliasSurf builds an aliasSurface for the rule tests.
func aliasSurf(src, typ, gender, approvedDst string) aliasSurface {
return aliasSurface{src: normalizeSourceKey(src), typ: typ, gender: gender, approvedDst: approvedDst}
}
func TestMinerAliasRules(t *testing.T) {
seedSurf := func(ss ...string) map[string]bool {
m := map[string]bool{}
for _, s := range ss {
m[normalizeSourceKey(s)] = true
}
return m
}
hasIdent := func(edges []aliasEdge, a, b string) bool {
an, bn := normalizeSourceKey(a), normalizeSourceKey(b)
for _, e := range edges {
if (e.a == an && e.b == bn) || (e.a == bn && e.b == an) {
return true
}
}
return false
}
pack := testLangPack(t)
// R1 extension: 方源 (seed entity) ⊂ 古月方源 → identity (same person, fuller surface).
t.Run("R1_extension", func(t *testing.T) {
surf := map[string]aliasSurface{
normalizeSourceKey("方源"): aliasSurf("方源", "name", "", ""),
normalizeSourceKey("古月方源"): aliasSurf("古月方源", "name", "", ""),
}
ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("方源"), pack)
if !hasIdent(ident, "方源", "古月方源") {
t.Fatalf("方源 ⊂ 古月方源 must be an identity extension, got %+v", ident)
}
})
// Composite guard: 古月 (seed clan) + 族长 (seed title) = 古月族长 is a PHRASE, not an alias (blocks
// the clan↔title chaining). But 古月 + 方源 (name) stays a fullname alias.
t.Run("composite_guard", func(t *testing.T) {
surf := map[string]aliasSurface{
normalizeSourceKey("古月"): aliasSurf("古月", "name", "", ""),
normalizeSourceKey("族长"): aliasSurf("族长", "title", "", ""),
normalizeSourceKey("古月族长"): aliasSurf("古月族长", "title", "", ""),
normalizeSourceKey("古月方源"): aliasSurf("古月方源", "name", "", ""),
normalizeSourceKey("方源"): aliasSurf("方源", "name", "", ""),
}
seeds := seedSurf("古月", "族长", "方源")
ident, _, weak := proposeAliasEdges(surf, nil, seeds, pack)
if hasIdent(ident, "古月", "古月族长") {
t.Fatalf("古月+族长 (clan+title) must be a phrase, NOT an identity alias")
}
phraseFlagged := false
for _, e := range weak {
if e.kind == "phrase_not_alias" && e.a == normalizeSourceKey("古月") && e.b == normalizeSourceKey("古月族长") {
phraseFlagged = true
}
}
if !phraseFlagged {
t.Fatalf("古月+族长 must be flagged phrase_not_alias, got weak=%+v", weak)
}
if !hasIdent(ident, "古月", "古月方源") {
t.Fatalf("古月+方源 (clan+name) must stay a fullname identity alias, got %+v", ident)
}
})
// R4-iii/v: 族长 (approved «глава клана») ⊂ 四代族长 (approved «четвёртый глава клана») — DIFFERENT
// approved dst → different entities EVEN under containment (D38 §3). No identity edge.
t.Run("R4_different_approved_dst", func(t *testing.T) {
surf := map[string]aliasSurface{
normalizeSourceKey("族长"): aliasSurf("族长", "title", "", "глава клана"),
normalizeSourceKey("四代族长"): aliasSurf("四代族长", "title", "", "четвёртый глава клана"),
}
ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("族长", "四代族长"), pack)
if hasIdent(ident, "族长", "四代族长") {
t.Fatalf("族长 vs 四代族长 with different approved dst must NOT be identity, got %+v", ident)
}
})
// R4-ii: different confirmed gender blocks identity; hidden never blocks.
t.Run("R4_gender", func(t *testing.T) {
surf := map[string]aliasSurface{
normalizeSourceKey("方源"): aliasSurf("方源", "name", "male", ""),
normalizeSourceKey("方源儿"): aliasSurf("方源儿", "name", "female", ""),
}
ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("方源"), pack)
if hasIdent(ident, "方源", "方源儿") {
t.Fatalf("different confirmed gender must block identity, got %+v", ident)
}
})
// R4-i / R2: same surname, DIFFERENT given names → family, not identity (方源 vs 方正, surname 方).
t.Run("R2_family_R4i", func(t *testing.T) {
surf := map[string]aliasSurface{
normalizeSourceKey("方源"): aliasSurf("方源", "name", "", ""),
normalizeSourceKey("方正"): aliasSurf("方正", "name", "", ""),
}
ident, family, _ := proposeAliasEdges(surf, nil, seedSurf("方源", "方正"), pack)
if hasIdent(ident, "方源", "方正") {
t.Fatalf("same surname + different given names must be family, NOT identity")
}
if len(family) == 0 {
t.Fatalf("方源 ~ 方正 must be a family edge, got none")
}
})
}
func TestMinerEmissionFilters(t *testing.T) {
subsumed := map[string]bool{"花酒行": true}
seeds := map[string]bool{normalizeSourceKey("方源"): true}
pack := testLangPack(t)
cases := []struct {
name string
c ScoredCand
want bool
}{
{"typed name freq5", ScoredCand{Src: "龙公", Types: []string{"name"}, Freq: 5}, true},
{"below freq floor", ScoredCand{Src: "龙公", Types: []string{"name"}, Freq: 4}, false},
{"untyped", ScoredCand{Src: "什么", Types: []string{"term"}, Freq: 20}, false},
{"subsumed", ScoredCand{Src: "花酒行", Types: []string{"place"}, Freq: 9}, false},
{"single char", ScoredCand{Src: "蛊", Types: []string{"term", "name"}, Freq: 99}, false},
{"fragment", ScoredCand{Src: normalizeSourceKey("方源心"), Types: []string{"name"}, Freq: 9}, false},
}
for _, tc := range cases {
if got := emissionEligible(tc.c, subsumed, seeds, pack); got != tc.want {
t.Errorf("%s: emissionEligible = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestMineBankDeterministicAndNonSeed(t *testing.T) {
chunks := mchunks(strings.Repeat("龙公来到青茅山。龙公很强。青茅山很高。", 4))
seed := []store.GlossaryEntry{{Src: "青茅山", Dst: "гора Цинмао", Type: "place", Status: "approved", Source: "seed"}}
cfg := frozenMinerConfig()
a := MineBank(chunks, smallContrast(t), seed, cfg, testLangPack(t))
b := MineBank(chunks, smallContrast(t), seed, cfg, testLangPack(t))
if !reflect.DeepEqual(a, b) {
t.Fatalf("MineBank must be deterministic byte-for-byte:\n a=%+v\n b=%+v", a, b)
}
// The seed surface 青茅山 must NOT be re-emitted as a new mined term (the curated entry owns it).
for _, m := range a {
if m.Src == normalizeSourceKey("青茅山") {
t.Fatalf("MineBank re-emitted a seed surface as a new term: %+v", m)
}
}
}
func TestMinedToCandidatesStampsMinedSource(t *testing.T) {
mined := []MinedTerm{
{Src: "龙公", Type: "name", SinceCh: 3, Freq: 7, Aliases: []string{"龙公子"}},
{Src: "青茅山", Type: "place", SinceCh: 1, Freq: 9}, // covered by manual seed → skipped
}
manual := map[string]bool{"青茅山": true}
got := minedToCandidates(mined, manual)
if len(got) != 1 {
t.Fatalf("want 1 mined candidate (青茅山 skipped as manual), got %d", len(got))
}
e := got[0]
if e.Source != "mined" {
t.Fatalf("mined candidate Source = %q, want \"mined\" (NOT seed — else it moves base-bank-version)", e.Source)
}
if e.Status != "auto" || e.Dst != "" {
t.Fatalf("mined candidate must be status=auto with no dst, got status=%q dst=%q", e.Status, e.Dst)
}
if e.SinceCh != 3 || e.Confidence != 7 {
t.Fatalf("mined candidate metadata wrong: %+v", e)
}
if len(e.Aliases) != 1 || e.Aliases[0].Alias != "龙公子" || e.Aliases[0].AliasType != "mined" {
t.Fatalf("mined alias wrong: %+v", e.Aliases)
}
}
func TestMinedDeltaStaysBaseBankStable(t *testing.T) {
// The mined delta (Source:"mined") must NOT move BaseVersion — the load-bearing «переоплата ОДНА»
// invariant (§1в): a the draft wave.5 mined-row addition moves only the enriched version. (Sibling of the
// Block-A TestBaseEnrichedMemoryVersionSplit, here through the real mined-write path.)
seed := []store.GlossaryEntry{{Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed"}}
base0 := materializeMemory(seed, false)
mined := minedToCandidates([]MinedTerm{{Src: "龙公", Type: "name", SinceCh: 1, Freq: 5}}, map[string]bool{"方源": true})
// A mined candidate is status=auto (not folded even into enriched when gate off) — promote it to
// approved to exercise the split, as the owner sign would at the draft wave.5.
mined[0].Status = "approved"
mined[0].Dst = "Лун Гун"
enriched := append(append([]store.GlossaryEntry{}, seed...), mined...)
bank1 := materializeMemory(enriched, false)
if base0.BaseVersion() != bank1.BaseVersion() {
t.Fatalf("base-bank-version moved on a Source:mined addition — draft-wave snapshot would re-bill the draft wave")
}
if base0.Version() == bank1.Version() {
t.Fatalf("enriched version must move on a mined approved addition")
}
}
func formantKeys(m map[rune]formantInfo) []string {
out := make([]string, 0, len(m))
for r := range m {
out = append(out, string(r))
}
return out
}

View file

@ -0,0 +1,91 @@
package pipeline
import (
"context"
"fmt"
"os"
"textmachine/backend/internal/store"
)
// mining.go: bank-mining stop boundary (WS3 wired live, R1). Between the drafts done and the edit wave, the
// miner scores WHICH-candidates over the the draft wave source (the detector is offline — it reads the SOURCE, not the
// drafts; drafts only mark that a chapter was reached) against the general-zh contrast, emits an
// alias-clustered seed-delta (default B, WHICH-only — the dst is the owner's to attach via the banknote at
// sign time), and — on a NON-EMPTY delta — writes the owner SIGNATURE MAP and STOPS before the edit wave. The owner
// reviews it, promotes terms into the mined-delta file (approved + dst), and re-runs: seedGlossary loads
// those as Source:mined (moving only edit-wave snapshot), the draft wave resumes at $0, the bank-mining stop re-mines (the now-seeded terms are
// excluded → the delta empties), and the edit wave runs. Mining is OFF (auto-continue) unless a langpack AND a contrast
// artifact are both configured — so every $0 test / the golden fixture (no contrast) auto-continues.
// signatureMapPath is where the bank-mining stop writes the mined-delta YAML for owner sign — beside the project DB, so it
// travels with the book state (never in git, like the DB). Deterministic (no time/rand).
func (r *Runner) signatureMapPath() string {
return r.Book.ProjectDB + ".mined-signature.yaml"
}
// runBankMiningStop executes the bank-mining stop. Returns stopped=true (with the signature map written and
// r.lastMinedCount set) when the miner proposes a non-empty delta; stopped=false (auto-continue) when mining
// is unconfigured or the delta is empty. $0 to providers (the detector is deterministic + offline).
func (r *Runner) runBankMiningStop(ctx context.Context, chunks []Chunk) (stopped bool, err error) {
if r.pack == nil || r.Pipeline.Mining.ContrastPath == "" {
return false, nil // mining not configured → auto-continue to the edit wave
}
f, err := os.Open(r.Pipeline.Mining.ContrastPath)
if err != nil {
return false, fmt.Errorf("pipeline: the bank-mining stop open mining contrast %s: %w", r.Pipeline.Mining.ContrastPath, err)
}
defer f.Close()
contrast, err := LoadContrast(f)
if err != nil {
return false, fmt.Errorf("pipeline: the bank-mining stop load mining contrast %s: %w", r.Pipeline.Mining.ContrastPath, err)
}
// The miner works over the memnorm-normalized SOURCE of every chunk (the candidate space matches the
// glossary/GT space). Deterministic; the annotation/blurb rule is handled inside MineBank's filters.
minerChunks := make([]MinerChunk, len(chunks))
for i, ch := range chunks {
minerChunks[i] = MinerChunk{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, NSource: normalizeSourceKey(ch.Text)}
}
seed, err := r.Store.GlossaryForBook(r.Book.BookID)
if err != nil {
return false, fmt.Errorf("pipeline: the bank-mining stop read glossary for mining: %w", err)
}
mined := MineBank(minerChunks, contrast, seed, frozenMinerConfig(), r.pack)
r.lastMinedCount = len(mined)
if len(mined) == 0 {
r.Log.InfoContext(ctx, "bank-mining: empty delta, auto-continuing to the edit wave", "book", r.Book.BookID)
return false, nil
}
// Non-empty delta → write the owner signature map (the mined seed-delta YAML) and STOP before the edit wave.
yamlDelta, err := MinedDeltaYAML(mined)
if err != nil {
return false, fmt.Errorf("pipeline: the bank-mining stop marshal mined delta: %w", err)
}
if err := os.WriteFile(r.signatureMapPath(), []byte(yamlDelta), 0o644); err != nil {
return false, fmt.Errorf("pipeline: the bank-mining stop write signature map %s: %w", r.signatureMapPath(), err)
}
r.Log.WarnContext(ctx, "bank-mining: new terms await owner signature; run STOPPED before the edit wave (review the signature map, promote terms into the mined-delta file, then resume)",
"book", r.Book.BookID, "terms", len(mined), "signature_map", r.signatureMapPath())
return true, nil
}
// loadMinedDelta reads the owner-curated mined-delta YAML (book.MinedDelta) and stamps every entry
// Source:"mined" — NOT via loadGlossarySeed (which hardcodes Source:"seed", memseed.go, moving the base
// bank / draft-wave snapshot). This is the mined-write path (plan §1(в), F2): the mined terms land in the ENRICHED
// bank version but NOT the base, so adding them moves ONLY edit-wave snapshot («переоплата ОДНА»). Reuses
// loadGlossarySeed's parser/validation, then re-stamps the Source. Empty path → nil (no mined terms).
func (r *Runner) loadMinedDelta() ([]store.GlossaryEntry, error) {
if r.Book.MinedDelta == "" {
return nil, nil
}
entries, err := loadGlossarySeed(r.Book.MinedDelta)
if err != nil {
return nil, fmt.Errorf("pipeline: load mined-delta %s: %w", r.Book.MinedDelta, err)
}
for i := range entries {
entries[i].Source = "mined" // override the seed loader's Source:seed → mined (base-excluded)
}
return entries, nil
}

View file

@ -102,8 +102,25 @@ func (r *Runner) QualityReport() (*QualityReport, error) {
if err != nil {
return nil, err
}
// Total = the SHIPPING units (the manifest re-chunk, $0), matching status/export + the per-unit
// BookResult (R1): under the wave model the editor's final text is per EDIT UNIT, so ProcessedChunks
// (the lastStage rows, one per unit leader) and TotalChunks agree at unit granularity. The per-unit
// KPI/echo/strip signals land on the leader's edit row; a non-leader member's ChunkQuality row carries
// only its draft-side signals (trust-gated) with a 0 structural KPI — observability, never a gate.
chunks, err := r.bookChunks()
if err != nil {
return nil, err
}
units := r.outputUnits(chunks)
// GHOST guard (parity with Export/Status): a stored row whose unit-leader key is NOT in the current
// manifest (source shrank since the run) is a ghost — dropping it keeps ProcessedChunks ≤ TotalChunks
// and the echo/strip rates over live units only, instead of mixing in stale leader rows.
inManifest := map[chunkKey]bool{}
for _, u := range units {
inManifest[chunkKey{u.Chapter, u.FirstChunkIdx}] = true
}
rep := &QualityReport{BookID: r.Book.BookID}
rep := &QualityReport{BookID: r.Book.BookID, TotalChunks: len(units)}
byChunk := map[chunkKey]*ChunkQuality{}
order := []chunkKey{}
chunkOf := func(k chunkKey) *ChunkQuality {
@ -124,7 +141,17 @@ func (r *Runner) QualityReport() (*QualityReport, error) {
withheld := map[chunkKey]bool{}
// Aggregate the stored retrieval-state signals (glossary consistency, style breakdown, trust-gated).
// A retrieval_state row is keyed per DRAFT chunk, so it is live iff its chunk is still in the manifest;
// a chunk that left the source is a ghost. (A leader row also carries the unit's post-check; if the
// leader survives, so does the unit.)
liveChunks := map[chunkKey]bool{}
for _, ch := range chunks {
liveChunks[chunkKey{ch.Chapter, ch.ChunkIdx}] = true
}
for _, rs := range states {
if !liveChunks[chunkKey{rs.Chapter, rs.ChunkIdx}] {
continue // ghost retrieval_state row (chunk dropped from source)
}
q := chunkOf(chunkKey{rs.Chapter, rs.ChunkIdx})
q.GlossaryMisses += rs.NPostcheckMiss
q.TrustGated += rs.NTrustGatedSuppress
@ -151,15 +178,10 @@ func (r *Runner) QualityReport() (*QualityReport, error) {
if n := len(r.Pipeline.Stages); n > 0 {
lastStage = r.Pipeline.Stages[n-1].Name
}
seen := map[chunkKey]bool{}
for _, cs := range statuses {
k := chunkKey{cs.Chapter, cs.ChunkIdx}
if !seen[k] {
seen[k] = true
rep.TotalChunks++
}
if cs.Stage != lastStage {
continue // the exported text and the final verdict live on the final stage's row
if cs.Stage != lastStage || !inManifest[k] {
continue // the final verdict lives on the final stage's row (per unit); drop ghost leader rows
}
// Every chunk that REACHED the final stage has exactly one lastStage row (ok, cosmetic-strip,
// or skipped-because-an-upstream-stage-flagged). This is the common rate denominator so the

View file

@ -0,0 +1,97 @@
package pipeline
import (
"context"
"sync"
"time"
)
// ratelimit.go: the per-model rate-guard for the parallel waves (WS1 §1б, ревью-2 F4). The per-CALL
// 429/Retry-After backoff already lives in the transport adapter (httpllm.go); the gap the waves
// open is N-CONCURRENCY against a rate-limited model — mistral-large-latest fails ~48% of calls under
// parallelism (a token-bucket / concurrency cap, tier-dependent — 00-provider-quirks §Транспорт),
// while grok is 0%. A model caps its own wave concurrency (max_concurrency) and, optionally, paces
// call STARTS (min_interval). This is a TRANSPORT axis: it never touches the request bytes, so it is
// wire-neutral and NOT snapshot-folded. Guards are built once in the precompute pass, read-only in the waves; each
// guard's internals (a buffered-channel semaphore + a mutex-protected pacer) are concurrency-safe,
// so acquiring from N wave goroutines is race-clean.
// rateGuard limits concurrent calls to one model (+ optional pacing). The zero/nil guard is a no-op
// (unlimited), so an unconfigured model — or a nil map entry — costs nothing.
type rateGuard struct {
sem chan struct{} // buffered to max_concurrency; nil = unlimited
minInterval time.Duration // 0 = no pacing
mu sync.Mutex // guards last
last time.Time // start time of the most recent acquired call (pacer)
}
// newRateGuard builds a guard. maxConcurrency ≤ 0 → unlimited concurrency; minInterval ≤ 0 → no
// pacing. Both off → newRateGuard returns a guard that acquire treats as a pure no-op.
func newRateGuard(maxConcurrency int, minInterval time.Duration) *rateGuard {
g := &rateGuard{minInterval: minInterval}
if maxConcurrency > 0 {
g.sem = make(chan struct{}, maxConcurrency)
}
return g
}
// acquire blocks until a concurrency slot is free and, if pacing is on, until the min-interval since
// the last acquired call has elapsed — both respecting ctx cancellation. It returns a release func
// the caller MUST defer (a no-op when acquire failed or the guard is unlimited). A nil guard (no
// config) acquires instantly. The pacer holds the mutex only to read/stamp `last`; the wait itself
// is outside the lock, so a paced model still admits one caller per interval without serializing the
// lock for the whole wait.
func (g *rateGuard) acquire(ctx context.Context) (func(), error) {
if g == nil {
return func() {}, nil
}
if g.sem != nil {
select {
case g.sem <- struct{}{}:
case <-ctx.Done():
return func() {}, ctx.Err()
}
}
release := func() {
if g.sem != nil {
<-g.sem
}
}
if g.minInterval > 0 {
for {
g.mu.Lock()
wait := time.Until(g.last.Add(g.minInterval))
if wait <= 0 {
g.last = time.Now()
g.mu.Unlock()
break
}
g.mu.Unlock()
select {
case <-time.After(wait):
// re-check: another caller may have taken the slot while we slept.
case <-ctx.Done():
release()
return func() {}, ctx.Err()
}
}
}
return release, nil
}
// buildRateGuards constructs a guard for every reachable model in the precompute pass (read-only in the waves).
// A model with no rate_limit config gets an unlimited (no-op) guard, so rateGuard(model) is always
// non-nil and lookup-safe.
func (r *Runner) buildRateGuards() {
r.rateGuards = make(map[string]*rateGuard, len(r.Models.Models))
for _, m := range r.reachableModels() {
rl := r.Models.Models[m].RateLimit
r.rateGuards[m] = newRateGuard(rl.MaxConcurrency, time.Duration(rl.MinIntervalMS)*time.Millisecond)
}
}
// rateGuard returns the pre-built guard for a model (nil-safe: acquire treats a missing/nil guard as
// unlimited, so a model reached before buildRateGuards ran simply is not throttled).
func (r *Runner) rateGuard(model string) *rateGuard {
return r.rateGuards[model]
}

View file

@ -0,0 +1,113 @@
package pipeline
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestRateGuardLimitsConcurrency proves the semaphore REALLY bounds concurrency (WS6 precondition,
// ревью-2 F4): with max_concurrency=2, no more than 2 goroutines are ever inside the guarded region
// at once, even under 20 racing callers. A broken guard (missing/over-sized semaphore) trips the
// observed-max assertion.
func TestRateGuardLimitsConcurrency(t *testing.T) {
const cap, workers = 2, 20
g := newRateGuard(cap, 0)
var inFlight, maxSeen int64
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
rel, err := g.acquire(context.Background())
if err != nil {
t.Errorf("acquire: %v", err)
return
}
cur := atomic.AddInt64(&inFlight, 1)
for {
m := atomic.LoadInt64(&maxSeen)
if cur <= m || atomic.CompareAndSwapInt64(&maxSeen, m, cur) {
break
}
}
time.Sleep(time.Millisecond) // widen the window a concurrency bug would exploit
atomic.AddInt64(&inFlight, -1)
rel()
}()
}
wg.Wait()
if maxSeen > cap {
t.Fatalf("rate-guard admitted %d concurrent callers, cap is %d", maxSeen, cap)
}
if maxSeen == 0 {
t.Fatalf("no caller was observed in-flight — the guard never admitted anyone")
}
}
// TestRateGuardNilAndUnlimitedAreNoOps: a nil guard and a zero-config guard both admit instantly and
// never block (the sequential path / an unconfigured model must cost nothing).
func TestRateGuardNilAndUnlimitedAreNoOps(t *testing.T) {
var nilGuard *rateGuard
rel, err := nilGuard.acquire(context.Background())
if err != nil {
t.Fatalf("nil guard acquire: %v", err)
}
rel() // must not panic
unlimited := newRateGuard(0, 0)
for i := 0; i < 100; i++ {
rel, err := unlimited.acquire(context.Background())
if err != nil {
t.Fatalf("unlimited acquire %d: %v", i, err)
}
rel()
}
}
// TestRateGuardHonoursContextCancel: a caller blocked on a full semaphore unblocks on ctx cancel and
// does NOT leak a slot (the wave dispatcher must be able to abort a run cleanly).
func TestRateGuardHonoursContextCancel(t *testing.T) {
g := newRateGuard(1, 0)
rel, err := g.acquire(context.Background())
if err != nil {
t.Fatalf("first acquire: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
r, err := g.acquire(ctx) // blocks: the one slot is held
r()
done <- err
}()
cancel()
select {
case err := <-done:
if err != context.Canceled {
t.Fatalf("blocked acquire on cancel = %v, want context.Canceled", err)
}
case <-time.After(time.Second):
t.Fatal("acquire did not unblock on ctx cancel")
}
rel() // release the first; a fresh acquire must now succeed (no leaked slot)
r2, err := g.acquire(context.Background())
if err != nil {
t.Fatalf("acquire after release: %v", err)
}
r2()
}
// TestRateGuardPacingSpacesStarts: with min_interval set, two serial acquires are spaced by at least
// the interval (best-effort pacing of call STARTS).
func TestRateGuardPacingSpacesStarts(t *testing.T) {
g := newRateGuard(0, 30*time.Millisecond)
start := time.Now()
r1, _ := g.acquire(context.Background())
r1()
r2, _ := g.acquire(context.Background())
r2()
if elapsed := time.Since(start); elapsed < 30*time.Millisecond {
t.Fatalf("paced acquires took %v, want ≥30ms between starts", elapsed)
}
}

View file

@ -43,13 +43,23 @@ import (
// now normalized once in NormalizeSource (#3), the ASCII sentence boundary honors
// quote-depth (#4), and <br> inside <ruby> no longer leaks a newline into the body
// (#5) — all segmentation/ingest behaviour changes → a loud --resnapshot.
const chunkerVersion = "chunker-v4-utf8-quotedepth"
// Bumped v4→v5 (WS2): the packing budget moved from input-token heuristic (const 1500) to OUTPUT
// (ru) tokens via fertility (est_out), draft chunks decoupled from coarse EDIT units (grouped whole
// chunks to EditCeilingOut), oversized-sentence flag added. The budget itself is ALSO folded via
// segmentationSnap (snapshot.go), so this version covers only the algorithm shape.
// Ш-2 EXTENSION (see memnorm.go; beyond the owner-named memnorm/classifier/style set — justified: same
// silent-drift class): tokenClassCounts (chunker.go) classifies source runes via unicode.Han/Hiragana/
// Katakana/Hangul to compute est_out, which SETS chunk boundaries → the wire. A toolchain Unicode bump that
// reclassifies a rune would silently re-chunk the book; weaving unicode.Version makes it a loud --resnapshot.
const chunkerVersion = "chunker-v5-output-budget-editunit+u" + unicode.Version
// estimatorVersion versions EstimateTokens: его выход входит в max_tokens и
// через него в request-hash, поэтому перекалибровка весов — тоже явная
// инвалидация через snapshot, а не тихий промах всех чекпоинтов (находка
// ревью: code-only правка эстиматора пере-оплатила бы полкниги).
const estimatorVersion = "estimator-v0"
// Ш-2 EXTENSION (see memnorm.go): EstimateTokens classifies runes via the same unicode ranges to size
// max_tokens (∈ request_hash), so a toolchain Unicode reclassification shifts the wire — folded loudly.
const estimatorVersion = "estimator-v0+u" + unicode.Version
// maxTokensPolicyVersion versions the attempt→max_tokens scaling
// (maxTokensForAttempt, disposition.go). attempt-0 budget is already covered by

View file

@ -2,10 +2,14 @@ package pipeline
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"textmachine/backend/internal/config"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/ledger"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
@ -43,6 +47,9 @@ type Runner struct {
clients map[string]llm.LLMClient
templates map[string]*PromptTemplate
// rateGuards is the per-model wave-concurrency guard set (WS1 §1б), built once in the precompute pass and
// read-only in the waves — a transport axis, never snapshot-folded. nil until buildRateGuards.
rateGuards map[string]*rateGuard
// memory is the book's glossary FROZEN for this job (materialized once in
// TranslateBook, after seeding, before snapshotID). Its Version() is the F1
@ -50,6 +57,36 @@ type Runner struct {
// nil until materialized (report path / no glossary) → memoryVersion() falls back
// to the empty-materialization hash, a stable constant.
memory *MemoryBank
// baseMemory is the DRAFT-wave glossary bank: the BASE rows only (Source∈{seed,ruby,auto}, EXCLUDING
// Source:mined). The draft wave's injection MUST be selected over this — not the enriched `memory` —
// so it is BYTE-IDENTICAL across a bank-mining enrichment, matching the draft-wave snapshot which folds
// baseMemoryVersion (mined-excluded). Otherwise signing a mined term would change the draft wire (the
// injection is a message folded into request_hash) and silently re-bill the draft wave on the owner
// re-run — even though the base snapshot is unchanged (the review-confirmed «переоплата ОДНА» hole).
// When the book has NO mined rows (every $0 test / the golden), it is the SAME object as `memory`
// (identical content) — no double materialization, the injection is unchanged. The editor keeps the
// enriched `memory`. nil ⇔ memory is nil (materialized together in seedGlossary).
baseMemory *MemoryBank
// pack is the book's language-data pack (internal/lang), loaded once in openRunner from
// book.LangpackRoot (D39.15/16). The the bank-mining stop bank-miner reads its tables; pack.Version() is folded into
// the snapshot (a pack edit is a loud --resnapshot). nil when the book declares no langpack_root, or
// its pair has no catalog dir — then the miner is inert (the bank-mining stop auto-continues) and the fold is omitted.
pack *lang.Pack
// escMu serializes the single-hop escalation budget admission across the PARALLEL draft workers
// (R1). escalationBudgetRemains is a non-atomic read-then-act over EscalationSpentUSD, so N concurrent
// draft chunks could each read spent<budget and all be admitted → the premium soft-cap overshoots by
// up to N-1 hops. Holding escMu across the budget check AND the fresh hop's settle makes the cap exact
// (a later chunk reads the updated spend). Escalations are the rare content-failure exception, so the
// contention is negligible; the $0 checkpoint-replay of an already-paid hop stays lock-free.
escMu sync.Mutex
// lastMinedCount is the size of the most recent the bank-mining stop mined delta (set by runBankMiningStop) — carried into the
// WaveSignatureStop the driver returns so the CLI can report "N terms await signature". Single-writer
// (runBankMiningStop runs between the parallel waves, not inside one), so no synchronization is needed.
lastMinedCount int
}
// NewRunner loads all three configs rooted at book.yaml and opens the project
@ -140,9 +177,46 @@ func openRunner(bookPath string, logger *slog.Logger, forWrite bool) (*Runner, e
st.Close()
return nil, err
}
if err := r.loadLangPack(); err != nil {
st.Close()
return nil, err
}
return r, nil
}
// loadLangPack resolves the book's language-data pack (D39.15/16), loaded on BOTH the write path and the
// read-only path so status/export/report reproduce the identical snapshot (pack.Version() is folded). The
// contract mirrors the owner directive R1: a pair WITH a catalog directory (configs/langpacks/<src>-<tgt>/)
// loads FAIL-LOUD (a missing/corrupt file stops the run, never a silently-empty miner); a pair WITHOUT a
// catalog — or a book with no langpack_root — runs with a nil pack (the miner is inert, the bank-mining stop auto-continues,
// and the snapshot fold is omitted). Presence of the pair directory is the "catalog exists" signal.
func (r *Runner) loadLangPack() error {
if r.Book.LangpackRoot == "" {
return nil // no langpack declared → nil pack, miner inert
}
pairDir := filepath.Join(r.Book.LangpackRoot, r.Book.LangPair())
if fi, err := os.Stat(pairDir); err != nil || !fi.IsDir() {
// No catalog for this pair → nil-and-run (a ja book against a zh-only root just runs без майнинга).
return nil
}
pack, err := lang.Load(r.Book.LangpackRoot, r.Book.SourceLang, r.Book.TargetLang)
if err != nil {
return fmt.Errorf("pipeline: load langpack for %s: %w", r.Book.LangPair(), err)
}
r.pack = pack
return nil
}
// packVersion is the snapshot-folded language-pack version: pack.Version() when a pack is loaded, "" when
// not (omitted from the snapshot so a no-pack book is byte-stable and never re-billed for a feature it does
// not use). A pack DATA edit changes Version() → the snapshot moves → a loud --resnapshot (R1, drift-proof).
func (r *Runner) packVersion() string {
if r.pack != nil {
return r.pack.Version()
}
return ""
}
func (r *Runner) Close() error { return r.Store.Close() }
func (r *Runner) loadTemplates() error {
@ -168,14 +242,63 @@ func (r *Runner) loadTemplates() error {
return nil
}
// segBudget resolves the pipeline's config.Segmentation into the pipeline-package SegBudget the
// chunker consumes (WS2). Kept in the runner (not chunker.go) so the segmenter stays config-free.
func (r *Runner) segBudget() SegBudget {
seg := r.Pipeline.Segmentation
return SegBudget{
DraftBudgetOut: float64(seg.DraftBudgetOut),
EditCeilingOut: float64(seg.EditCeilingOut),
FertCJK: seg.Fertility.CJK,
FertOther: seg.Fertility.Other,
}
}
// reachableModels enumerates every model a run can call: the union of the stage models and their
// single-hop escalate_to fallbacks (stagerun.go calls r.client ONLY with model∈{st.Model, EscalateTo}).
// This set is complete and static for a book, which is what lets the precompute pass pre-build every client (a third
// model axis — channel B / annotator — must extend this to stay race-free). Deterministic order.
func (r *Runner) reachableModels() []string {
seen := map[string]bool{}
var out []string
add := func(m string) {
if m != "" && !seen[m] {
seen[m] = true
out = append(out, m)
}
}
for _, st := range r.Pipeline.Stages {
add(st.Model)
add(st.EscalateTo)
}
return out
}
// buildClients EAGER-constructs every reachable LLM client BEFORE any wave goroutine starts (the precompute pass).
// After this the clients map is READ-ONLY in the waves, so r.client is lock-free and a miss is a
// loud error, not a lazy build under a data race — closing the runner.go lazy-init race (D12) and
// tripwiring a future un-enumerated model axis. Idempotent; BuildClient needs only the provider
// config (not the API key), so eager-build is safe even on a key-less resume.
func (r *Runner) buildClients() error {
for _, m := range r.reachableModels() {
if _, ok := r.clients[m]; ok {
continue
}
c, err := BuildClient(r.Models, m, r.Log)
if err != nil {
return fmt.Errorf("pipeline: eager-build client for model %q (the precompute pass): %w", m, err)
}
r.clients[m] = c
}
return nil
}
// client returns the PRE-BUILT client for a model. Read-only (no lazy build, no lock): buildClients
// constructed every reachable client in the precompute pass, so the map is only read in the waves; a miss means an
// un-enumerated model reached the wire and is a loud error, never a silent lazy build under a race.
func (r *Runner) client(model string) (llm.LLMClient, error) {
if c, ok := r.clients[model]; ok {
return c, nil
}
c, err := BuildClient(r.Models, model, r.Log)
if err != nil {
return nil, err
}
r.clients[model] = c
return c, nil
return nil, fmt.Errorf("pipeline: no pre-built client for model %q — the precompute pass buildClients enumerates stage models escalate_to; a model outside that set reached the wire, add it to the eager set to keep the waves race-free", model)
}

View file

@ -89,9 +89,9 @@ func TestRunnerMemoryInjectionAndPostcheck(t *testing.T) {
if strings.Contains(msgs[2].Content, "ГЛОССАРИЙ") {
t.Errorf("glossary leaked into the user (ch.Text) message: %q", msgs[2].Content)
}
// D1: the monolingual EDITOR now gets the CONFIRMED dst forms as target constraints — its
// OWN dst-constraint block (distinct header), NOT the translator's "src → dst" ГЛОССАРИЙ,
// and never the source key inside the injection.
// D30.1/WS2 §2в: the BILINGUAL editor gets the CONFIRMED canon as a src→dst MAPPING under its
// OWN header (КАНОНИЧЕСКИЕ), binding each canonical form to its source term (homonym-safe) — NOT
// the translator's ГЛОССАРИЙ header, and never an AMBIGUOUS row.
editorSaw := false
for _, b := range rec.all() {
if !isEditBody(b) {
@ -101,11 +101,11 @@ func TestRunnerMemoryInjectionAndPostcheck(t *testing.T) {
if len(em) != 3 || em[1].Role != "system" {
t.Fatalf("editor must have 3 messages (system, dst-constraints, user), got %d: %+v", len(em), em)
}
if !strings.Contains(em[1].Content, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ") || !strings.Contains(em[1].Content, "Судзуки") {
t.Errorf("editor dst-constraint block missing/wrong: %q", em[1].Content)
if !strings.Contains(em[1].Content, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ") || !strings.Contains(em[1].Content, "鈴木 → «Судзуки»") {
t.Errorf("editor src→dst constraint block missing/wrong: %q", em[1].Content)
}
if strings.Contains(em[1].Content, "ГЛОССАРИЙ") || strings.Contains(em[1].Content, "→") || strings.Contains(em[1].Content, "鈴木") {
t.Errorf("editor block must be dst-only (no ГЛОССАРИЙ header / arrow / source): %q", em[1].Content)
if strings.Contains(em[1].Content, "ГЛОССАРИЙ") {
t.Errorf("editor block must use its own КАНОНИЧЕСКИЕ header, not the translator ГЛОССАРИЙ: %q", em[1].Content)
}
editorSaw = true
}

View file

@ -102,15 +102,17 @@ func maxTokensOf(t *testing.T, body string) int {
const fakeCallUSD = (800*1.0 + 200*0.1 + 500*2.0) / 1e6
type projectOpts struct {
source string
epub []epubChapter // when set, the source is an epub (source.epub) instead of txt
spine []string // spine order for epub
regenerate int
minMaxTokens int
bookUSD float64
gatesYAML string // optional gates:/... block appended to pipeline.yaml
glossarySeed string // optional glossary seed YAML content; "" = no glossary
postcheckGate bool // enable the memory post-check hard gate (assumes gatesYAML is empty)
source string
epub []epubChapter // when set, the source is an epub (source.epub) instead of txt
spine []string // spine order for epub
regenerate int
minMaxTokens int
bookUSD float64
gatesYAML string // optional gates:/... block appended to pipeline.yaml
glossarySeed string // optional glossary seed YAML content; "" = no glossary
postcheckGate bool // enable the memory post-check hard gate (assumes gatesYAML is empty)
banknote bool // enable the banknote channel gate (WS4; assumes gatesYAML/postcheckGate empty)
waveWorkers int // wave-executor parallelism (0 → default 1, a deterministic sequential-structured run)
}
func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string {
@ -149,16 +151,20 @@ models:
if o.postcheckGate {
gatesBlock += "\ngates:\n glossary:\n postcheck_gate: true\n"
}
if o.banknote {
gatesBlock += "\ngates:\n banknote:\n enabled: true\n"
}
writeFile(t, filepath.Join(dir, "pipeline.yaml"), fmt.Sprintf(`
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: %d }
retries: { regenerate_before_escalate: %d }
context: { glossary_injection: selective, glossary_token_budget: 800 }
waves: { workers: %d }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off" }
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
%s`, o.minMaxTokens, o.regenerate, gatesBlock))
%s`, o.minMaxTokens, o.regenerate, o.waveWorkers, gatesBlock))
sourceName := "source.txt"
if len(o.epub) > 0 {
@ -371,8 +377,14 @@ func TestRunnerSnapshotPinning(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if rec.count() != 4 {
t.Fatalf("resnapshot run must re-call both stages, calls=%d", rec.count())
// R1 per-wave --resnapshot is SURGICAL, not all-or-nothing: the changed TRANSLATOR prompt moves only
// snapshot_W1, so the DRAFT wave re-pins and re-calls (+1). The EDITOR resumes: snapshot_W2 is unchanged
// AND its wire input (the mock draft "ЧЕРНОВИК ПЕРЕВОДА" is prompt-independent) is byte-identical, so
// its content-addressed checkpoint hits — re-billing a byte-identical wire request is exactly the D15
// waste the per-wave snapshot avoids. Total = 2 (fresh) + 1 (draft re-call) = 3. In production a draft
// prompt change WOULD change the draft output, cascading to the editor via its content-hash (→ 4).
if rec.count() != 3 {
t.Fatalf("resnapshot run must re-call only the changed (draft) wave; the editor resumes on an unchanged wire, calls=%d", rec.count())
}
if res.TotalUSD <= 0 {
t.Fatal("re-translation must be billed")

View file

@ -43,7 +43,16 @@ func (r *Runner) seedGlossary(ctx context.Context) error {
r.Log.WarnContext(ctx, "ruby kana-alias skipped as a homophone collision (kana form left unmatchable; disambiguate in the seed if needed)",
"skipped", strings.Join(skipped, "; "))
}
entries = append(entries, rubyToCandidates(ruby, manualSrcs)...)
// Mined-write path (R1, plan §1(в)/F2): the owner-curated mined-delta file joins the seed as
// Source:"mined" (loadMinedDelta re-stamps the seed loader's Source), so its terms fold into the
// ENRICHED bank version but NOT the base — a re-run that adds signed mined terms moves ONLY snapshot_W2.
// Loaded AFTER the seed/ruby so manualSrcs already reflects the curated seed. A `mined` term that
// duplicates a seed src is caught by approvedSharedKeyCollisions below like any other collision.
minedDelta, err := r.loadMinedDelta()
if err != nil {
return err
}
entries = append(entries, minedDelta...)
for i := range entries {
entries[i].BookID = r.Book.BookID
}
@ -61,6 +70,25 @@ func (r *Runner) seedGlossary(ctx context.Context) error {
return fmt.Errorf("pipeline: read glossary for %s: %w", r.Book.BookID, err)
}
r.memory = materializeMemory(rows, r.Pipeline.Gates.Glossary.PostcheckGate)
// The DRAFT wave selects over a BASE-scoped bank (Source:mined excluded) so its injection is
// byte-identical across a bank-mining enrichment — matching the draft-wave snapshot (baseMemoryVersion),
// which keeps «переоплата ОДНА» honest at the WIRE level, not only the version-hash level. Only the
// editor sees mined terms (the enriched `memory`). When there are no mined rows (every $0 test / the
// golden) the base bank IS the enriched one — share the object, no double materialization, no drift.
baseRows := rows[:0:0]
hasMined := false
for _, row := range rows {
if row.Source == "mined" {
hasMined = true
continue
}
baseRows = append(baseRows, row)
}
if hasMined {
r.baseMemory = materializeMemory(baseRows, r.Pipeline.Gates.Glossary.PostcheckGate)
} else {
r.baseMemory = r.memory
}
if cols := injectivityCollisions(rows); len(cols) > 0 {
r.Log.WarnContext(ctx, "glossary approved dst-collisions (B2: two source terms share one Russian surface — the reader cannot tell them apart)",
"collisions", strings.Join(cols, "; "))

View file

@ -0,0 +1,66 @@
package pipeline
import (
"strings"
"testing"
"textmachine/backend/internal/store"
)
// seedlint_test.go: WS3 (д) `tmctl seed-lint` — the dry-run of the REAL loadGlossarySeed fail-louds over
// a seed YAML (the mined delta or a manual seed). Both directions: a clean seed lints OK; a defect fails
// loud; and the emitted mined delta (MinedDeltaYAML) round-trips through the real loader with 0 fails.
func TestSeedLintCleanPasses(t *testing.T) {
p := writeSeed(t, `
terms:
- src: 方源
dst: Фан Юань
type: name
status: approved
- src:
status: auto
`)
if err := SeedLint(p); err != nil {
t.Fatalf("a clean seed must lint OK, got: %v", err)
}
}
func TestSeedLintCatchesDefects(t *testing.T) {
cases := []struct {
name, yaml, want string
}{
{"approved without dst", "terms:\n - src: 方源\n status: approved\n", "non-empty dst"},
{"unknown status", "terms:\n - src: 方源\n dst: X\n status: bogus\n", "status must be"},
{"duplicate key", "terms:\n - src: 方源\n dst: A\n - src: 方源\n dst: B\n", "duplicate"},
{"shared-key collision", "terms:\n - src: 老赵\n dst: Старина Чжао\n status: approved\n - src: 赵大\n dst: Чжао Да\n status: approved\n aliases:\n - alias: 老赵\n", "shared"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := writeSeed(t, tc.yaml)
err := SeedLint(p)
if err == nil {
t.Fatalf("%s: seed-lint must fail loud", tc.name)
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: error %q must mention %q", tc.name, err.Error(), tc.want)
}
})
}
}
func TestSeedLintEmittedMinedDelta(t *testing.T) {
// The emitted mined delta must be loadable by the REAL loader (0 fail-louds) — the pre-condition for
// the bank-mining reseed.
chunks := mchunks(strings.Repeat("龙公来到青茅山。龙公很强。青茅山很高。", 4))
seed := []store.GlossaryEntry{{Src: "青茅山", Dst: "гора Цинмао", Type: "place", Status: "approved", Source: "seed"}}
mined := MineBank(chunks, smallContrast(t), seed, frozenMinerConfig(), testLangPack(t))
yamlStr, err := MinedDeltaYAML(mined)
if err != nil {
t.Fatal(err)
}
p := writeSeed(t, yamlStr)
if err := SeedLint(p); err != nil {
t.Fatalf("the emitted mined delta must lint clean (loadable), got:\n%v\n--- delta ---\n%s", err, yamlStr)
}
}

View file

@ -4,6 +4,8 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"textmachine/backend/internal/config"
)
// snapshot.go: материализация snapshotID (§3.2/D5.2/D8) — контент-хеш всего, что
@ -11,15 +13,36 @@ import (
// громкий --resnapshot (= переоплата книги, D15), никогда тихий false-hit.
// contextSnap freezes the context-assembly knobs (§3.8) inside the snapshot.
// Fixed field order — it is part of a content hash.
// Fixed field order — it is part of a content hash. WS2 dropped STMDepth/OverlapTokens
// (dead carryover knobs — §2а), a structural change folded loudly (§8 manifest).
type contextSnap struct {
GlossaryInjection string `json:"glossary_injection"`
GlossaryTokenBudget int `json:"glossary_token_budget"`
STMDepth int `json:"stm_depth"`
OverlapTokens int `json:"overlap_tokens"`
CacheTTL string `json:"cache_ttl"`
}
// segmentationSnap freezes the WS2 output-token chunking budget (draft/edit ceilings + per-pair
// fertility) inside the snapshot, mirroring coverageSnap's discipline: a budget or fertility edit
// re-chunks the book (changes chunk boundaries → the wire), so it is a loud --resnapshot (D30.9),
// never a silent divergence. Unlike coverageSnap it is ALWAYS folded — segmentation always runs and
// its values are wire-determining. Fixed field order — part of a content hash.
type segmentationSnap struct {
DraftBudgetOut int `json:"draft_budget_out"`
EditCeilingOut int `json:"edit_ceiling_out"`
FertilityCJK float64 `json:"fertility_cjk"`
FertilityOther float64 `json:"fertility_other"`
}
func (r *Runner) segmentationSnapshot() segmentationSnap {
seg := r.Pipeline.Segmentation
return segmentationSnap{
DraftBudgetOut: seg.DraftBudgetOut,
EditCeilingOut: seg.EditCeilingOut,
FertilityCJK: seg.Fertility.CJK,
FertilityOther: seg.Fertility.Other,
}
}
// coverageSnap freezes the excision coverage-gate config inside the snapshot, so
// enabling the gate OR tuning its thresholds is a loud --resnapshot, never a silent
// mismatch between an old checkpoint's stored disposition and a changed gate. HONEST
@ -76,6 +99,34 @@ func (r *Runner) sanitizerSnapshot() sanitizerSnap {
return s
}
// banknoteSnap freezes the banknote channel (WS4 §4в, F9) inside the snapshot — the VERDICT-AXIS that
// governs the resolved draft (the slice + the derived-checkpoint), mirroring sanitizerSnap. parser_version
// is the split/parse algorithm version: bumping it re-resolves the stripped draft, so it must be a loud
// --resnapshot even without a prompt edit (§4а point 6). DEVIATION-with-rationale from "mirror
// sanitizerSnap exactly": it is folded via a POINTER with omitempty (nil when the channel is off) rather
// than an unconditional {enabled:false} — so ADDING this optional feature does NOT invalidate every
// banknote-OFF book (incl. the golden fixture), while ENABLING the channel or bumping the parser is still
// a loud snapshot move (the substantive loudness point 6 requires). A snapshot re-pin is never billed for
// a feature a book does not use.
type banknoteSnap struct {
Enabled bool `json:"enabled"`
ParserVersion string `json:"parser_version,omitempty"`
// TokenBudget folds bankTokenBudget (FL-1): the extra max_tokens the enabled channel adds to the
// translator draft (stagerun.go, integration point 5) enters request_hash, but bankParserVersion
// versions only the split/parse ALGORITHM — so editing bankMaxLines (or the per-line multiplier)
// would change the wire without bumping the parser version = a silent re-bill on resume. Folding
// the resolved budget makes any such edit a loud --resnapshot by mechanism, not discipline.
TokenBudget int `json:"token_budget,omitempty"`
}
// banknoteSnapshot returns the fold ONLY when the channel is enabled (nil otherwise → omitempty drops it).
func (r *Runner) banknoteSnapshot() *banknoteSnap {
if !r.Pipeline.Gates.Banknote.Enabled {
return nil
}
return &banknoteSnap{Enabled: true, ParserVersion: bankParserVersion, TokenBudget: bankTokenBudget}
}
// memoryVersion is the content-hash of the deterministically materialized injected
// memory (the frozen APPROVED glossary rows + the normalization/matcher algorithm
// versions), the memory component of the snapshot (D5.2/D8, F1 CLOSED). It is the
@ -93,12 +144,77 @@ func (r *Runner) memoryVersion() string {
return computeMemoryVersion(nil, r.Pipeline.Gates.Glossary.PostcheckGate)
}
// snapshotID materializes the job-context snapshot (§3.2): brief_hash, chunker
// version, context-assembly knobs, the memory version and the full stage plan
// (model, prompt version + CONTENT hash, sampling, resolved capability).
// Payload is rendered with a fixed field order — the id is a content hash, so
// identical context re-upserts idempotently.
// wave selects which pipeline wave a per-wave snapshot folds (WS1 §1в).
type wave int
const (
waveDraft wave = iota // DRAFT wave: translator-role stages + the BASE bank version (excl Source:mined)
waveEdit // EDIT wave: non-translator stages + the ENRICHED bank version (all approved incl mined)
)
// snapshotID materializes the BOOK-GLOBAL job-context snapshot (§3.2): brief_hash, chunker version,
// context-assembly knobs, the (enriched) memory version and the FULL stage plan. It is the snapshot the
// SEQUENTIAL driver pins every job to; the byte output is unchanged (buildSnapshotID over all stages +
// the enriched memory version reproduces it exactly). Payload is a fixed-field-order content hash.
func (r *Runner) snapshotID() (id, payload string, err error) {
return r.buildSnapshotID(r.Pipeline.Stages, r.memoryVersion())
}
// snapshotIDForWave computes the PER-WAVE snapshot (WS1 §1в, ADDITIVE — the wave executor residual pins
// each wave's jobs to it; the sequential driver still uses snapshotID). the draft wave folds the DRAFT stages
// (translator role) + the BASE bank version (Source∈{seed,ruby,auto}, EXCL mined); the edit wave folds the EDIT
// stages (non-translator) + the ENRICHED bank version (all approved incl Source:mined). A the bank-mining stop bank
// enrichment (adding mined rows) moves ONLY the enriched version → ONLY edit-wave snapshot, keeping the draft wave
// checkpoints valid («переоплата ОДНА»). Byte-consistent with snapshotID's building — the SAME payload
// struct, only the stage subset and the memory version differ.
func (r *Runner) snapshotIDForWave(w wave) (id, payload string, err error) {
memVer := r.memoryVersion() // enriched (the edit wave)
if w == waveDraft {
memVer = r.baseMemoryVersion()
}
return r.buildSnapshotID(waveStages(r.Pipeline.Stages, w), memVer)
}
// finalStageWave is the wave that owns the SHIPPING (last) stage: the edit wave when the last stage is an editor/other
// role (the normal 2-stage pipeline — the edit unit ships), the draft wave when the pipeline is draft-only (the draft
// itself ships). The wave-aware read-models (export/status) use it to compare a stored final-stage row
// against the correct per-wave snapshot and to know whether the shipping unit is an edit unit or a chunk.
func (r *Runner) finalStageWave() wave {
n := len(r.Pipeline.Stages)
if n > 0 && r.Pipeline.Stages[n-1].Role != roleTranslator {
return waveEdit
}
return waveDraft
}
// waveStages partitions the pipeline stages by role for a wave: the draft wave = the translator-role (draft) stages,
// the edit wave = every non-translator (editor/other) stage. Order preserved. The prod C1 core is one draft + one
// edit, so this yields [draft] and [edit] respectively.
func waveStages(stages []config.Stage, w wave) []config.Stage {
var out []config.Stage
for _, st := range stages {
isDraft := st.Role == roleTranslator
if (w == waveDraft) == isDraft {
out = append(out, st)
}
}
return out
}
// baseMemoryVersion is the DRAFT-wave (the draft wave) memory component: the BASE bank version (excl Source:mined).
// nil bank → the empty-materialization base hash (a stable constant), mirroring memoryVersion().
func (r *Runner) baseMemoryVersion() string {
if r.memory != nil {
return r.memory.BaseVersion()
}
return computeMemoryVersionScoped(nil, r.Pipeline.Gates.Glossary.PostcheckGate, true)
}
// buildSnapshotID materializes the snapshot payload for a given stage subset + memory version (§3.2).
// Extracted from snapshotID so the book-global snapshot (all stages + enriched version) AND the two
// per-wave snapshots (WS1 §1в) share one byte-exact builder — a stage subset / memory version is the
// ONLY axis that differs. Fixed field order (a content hash).
func (r *Runner) buildSnapshotID(stages []config.Stage, memVersion string) (id, payload string, err error) {
type stageSnap struct {
Name string `json:"name"`
Role string `json:"role"`
@ -182,6 +298,22 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
// ваем ДО инъекции, чтобы смена budget'а инъекции не промахнулась мимо
// resnapshot-гейта (D5.2). Фикс-порядок полей — это content-hash.
ContextAssembly contextSnap `json:"context_assembly"`
// Segmentation — the WS2 output-token chunk/edit-unit budget + fertility (segmentationSnap).
// It changes chunk boundaries → the wire, so a budget/fertility edit is a loud --resnapshot.
Segmentation segmentationSnap `json:"segmentation"`
// RenderFormatVersion versions the FORMAT of the role-injection renderers not captured by
// memoryMatchVersion (a scope/matcher-algorithm version): the editor constraint block's
// src→dst layout (WS2 §2в) and the gender-constraint render (WS5). A format change shifts the
// injected bytes → the wire, so folding it separately keeps a flip a loud --resnapshot.
RenderFormatVersion string `json:"render_format_version"`
// LangpackVersion — the loaded language-data pack's content hash (internal/lang, D39.15/16),
// folded ONLY when a pack is present (omitempty otherwise, so a no-pack book is byte-stable and
// never re-billed for a feature it does not use). The pack feeds the the bank-mining stop bank-miner; a pack DATA
// edit changes what it proposes, so folding its version makes a pack edit a loud --resnapshot,
// drift-proof by mechanism (the pack's bytes ARE its version). Wire-neutral for existing
// checkpoints (the miner produces Source:mined proposals, inert until owner-approved), but folded
// per the R1 directive so the run's snapshot durably records which pack version produced it.
LangpackVersion string `json:"langpack_version,omitempty"`
// MemoryVersion — content-hash детерминированно материализованной инъекти-
// руемой памяти (approved-глоссарий+резюме+series-bible). Не bump-счётчик
// (D8): забыть пересчитать нельзя, а забытый bump переоткрыл бы класс тихой
@ -209,7 +341,10 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
// but determines a checkpoint's RESOLVED verdict (a defect flips the chunk to flagged), so a
// gate flip / rule edit is a loud --resnapshot. Folded only when enabled (sanitizerSnapshot).
Sanitizer sanitizerSnap `json:"sanitizer"`
Stages []stageSnap `json:"stages"`
// Banknote — the banknote channel (WS4). Verdict-axis (governs the sliced/derived draft), folded
// only when ENABLED (nil→omitempty otherwise), so adding the feature never re-bills an off book.
Banknote *banknoteSnap `json:"banknote,omitempty"`
Stages []stageSnap `json:"stages"`
}{
BriefHash: r.Book.BriefHash(),
ChunkerVersion: chunkerVersion,
@ -222,17 +357,19 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
ContextAssembly: contextSnap{
GlossaryInjection: r.Pipeline.Context.GlossaryInjection,
GlossaryTokenBudget: r.Pipeline.Context.GlossaryTokenBudget,
STMDepth: r.Pipeline.Context.STMDepth,
OverlapTokens: r.Pipeline.Context.OverlapTokens,
CacheTTL: r.Pipeline.Context.CacheTTL,
},
MemoryVersion: r.memoryVersion(),
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
Coverage: r.coverageSnapshot(),
StyleCheckVersion: cheapGateVersion,
Sanitizer: r.sanitizerSnapshot(),
Segmentation: r.segmentationSnapshot(),
RenderFormatVersion: renderFormatVersion,
LangpackVersion: r.packVersion(),
MemoryVersion: memVersion,
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
Coverage: r.coverageSnapshot(),
StyleCheckVersion: cheapGateVersion,
Sanitizer: r.sanitizerSnapshot(),
Banknote: r.banknoteSnapshot(),
}
for _, st := range r.Pipeline.Stages {
for _, st := range stages {
ss := stageSnap{
Name: st.Name, Role: st.Role, Model: st.Model,
PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256,

View file

@ -0,0 +1,71 @@
package pipeline
import (
"strings"
"testing"
"textmachine/backend/internal/store"
)
// snapshot_wave_test.go: WS1 §1в per-wave snapshot mechanics (R1 ADDITIVE sub-step — the wave executor
// itself is residual; this pins the snapshot machinery it will use, WITHOUT switching the driver). The
// book-global snapshotID() is byte-unchanged (TestGolden proves it); these tests exercise the new
// snapshotIDForWave.
func TestSnapshotIDForWave(t *testing.T) {
r := newRunner(t, setupProject(t, "http://127.0.0.1:1"))
defer r.Close()
seed := []store.GlossaryEntry{
{Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed"},
{Src: "古月", Dst: "Гу Юэ", Status: "approved", Source: "ruby"},
}
r.memory = materializeMemory(seed, false)
w1id, w1p, err := r.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatal(err)
}
w2id, w2p, err := r.snapshotIDForWave(waveEdit)
if err != nil {
t.Fatal(err)
}
// the draft wave and the edit wave differ (different stage subset AND different bank version).
if w1id == w2id {
t.Fatalf("draft-wave snapshot must differ from snapshot_W2 (stage subset + bank version)")
}
// The stage PARTITION: the draft wave folds the draft (translator) stage, the edit wave folds the edit (editor) stage.
if !strings.Contains(w1p, `"name":"draft"`) || strings.Contains(w1p, `"name":"edit"`) {
t.Fatalf("draft-wave snapshot must fold ONLY the draft stage, payload: %s", w1p)
}
if !strings.Contains(w2p, `"name":"edit"`) || strings.Contains(w2p, `"name":"draft"`) {
t.Fatalf("snapshot_W2 must fold ONLY the edit stage, payload: %s", w2p)
}
// «Переоплата ОДНА» at the snapshot level (§1в): a the draft wave.5 mined-approved addition moves ONLY the edit wave.
enriched := append(append([]store.GlossaryEntry{}, seed...), store.GlossaryEntry{
Src: "蛊", Dst: "гу", Status: "approved", Source: "mined",
})
r.memory = materializeMemory(enriched, false)
w1id2, _, _ := r.snapshotIDForWave(waveDraft)
w2id2, _, _ := r.snapshotIDForWave(waveEdit)
if w1id2 != w1id {
t.Fatalf("draft-wave snapshot moved on a mined-approved addition — the draft wave checkpoints would re-bill («переоплата ОДНА» broken)")
}
if w2id2 == w2id {
t.Fatalf("snapshot_W2 did NOT move on a mined-approved addition — the edit wave would miss the mined canon")
}
}
// TestWaveStagesPartition pins the role partition independently of the runner.
func TestWaveStagesPartition(t *testing.T) {
r := newRunner(t, setupProject(t, "http://127.0.0.1:1"))
defer r.Close()
draftStages := waveStages(r.Pipeline.Stages, waveDraft)
editStages := waveStages(r.Pipeline.Stages, waveEdit)
if len(draftStages) != 1 || draftStages[0].Role != roleTranslator {
t.Fatalf("the draft wave must be the translator stage(s), got %+v", draftStages)
}
if len(editStages) != 1 || editStages[0].Role != roleEditor {
t.Fatalf("the edit wave must be the editor stage(s), got %+v", editStages)
}
}

View file

@ -99,6 +99,13 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
// inherited 2048/3291 and returned length.
baseMaxTokens = r.applyModelFloor(baseMaxTokens, st.Model)
// Banknote (WS4 point 5): reserve room for the ≤12-line footnote block so a length cut hits the
// block (tolerated), not the translation. Translator-only + channel-enabled; wire-affecting
// (max_tokens ∈ request_hash) and reproducible on resume (banknoteSnap gates the enabled state).
if st.Role == roleTranslator && r.Pipeline.Gates.Banknote.Enabled {
baseMaxTokens += bankTokenBudget
}
maxRegen := r.Pipeline.Retries.RegenerateBeforeEscalate
if maxRegen < 0 {
maxRegen = 0
@ -167,6 +174,19 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
recovered := ""
if disposition == DispOK {
finalHash = last.reqHash
// Banknote OK-path (WS4 point 8, the main code change): if the translator draft carried a
// ⟦TM-BANK-v1⟧ block, commit the CLEANED draft as a $0 derived checkpoint and re-point final_hash
// at it — extending the flagged→ok derived-export precedent to the OK path — so the editor + the
// export read the cleaned draft on resume, never the raw footnote-bearing one. `last` is the
// terminal (possibly escalated) attempt whose block was sliced, so this runs AFTER the escalation
// resolve and consumes the escalated draft's stripped text (point 8's escalation-ordering note).
if last.bankStripped != "" {
dh, err := r.commitBanknoteExport(st, ch, job, last)
if err != nil {
return nil, err
}
finalHash = dh
}
} else if last.cls.Reason == FlagSanitizerStripped {
// Cosmetic leak (D35.4a): COMMIT the cleaned remainder as a $0 derived export checkpoint, then
// point final_hash at it — so the standard final_hash→checkpoint.response_text export
@ -211,6 +231,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
Attempts: attemptsMade,
Escalated: escalated,
EscalationModel: escModel,
BankFlags: last.bankFlags,
}
if disposition == DispOK {
sr.Text = last.text
@ -243,20 +264,26 @@ func (r *Runner) commitSanitizedExport(st config.Stage, ch Chunk, job *store.Job
// stageAttempt is the result of one attempt (a checkpoint hit or a fresh call).
type stageAttempt struct {
reqHash string
attempt int // the attempt index (for a derived checkpoint's Attempt column)
cls classification
// stripped is the resolved cosmetic-strip EXPORT text classifyOutput computed ONCE (T3.4):
// non-empty only when cls.Reason == FlagSanitizerStripped. runStage ships it verbatim instead of
// re-deriving stripCosmetic, so the "non-empty & clean" invariant classifyOutput asserts is the
// exact text committed to the derived export checkpoint.
stripped string
text string
usage llm.Usage
finish string
modelActual string
latency int
runCost float64 // billed THIS run (0 on a checkpoint hit)
cumCost float64 // this attempt's cost (checkpoint cost on a hit, fresh cost on a call)
freshCall bool // a provider call was made this run
stripped string
// bankStripped is the banknote-CLEANED draft (WS4 point 8): non-empty only for a translator draft
// that carried a ⟦TM-BANK-v1⟧ block, when the channel is enabled. runStage commits it as a derived
// export checkpoint and re-points final_hash so the editor/export read the cleaned draft on resume.
bankStripped string
bankFlags bankFlags // per-chunk banknote telemetry (point 10) for the translator draft
text string
usage llm.Usage
finish string
modelActual string
latency int
runCost float64 // billed THIS run (0 on a checkpoint hit)
cumCost float64 // this attempt's cost (checkpoint cost on a hit, fresh cost on a call)
freshCall bool // a provider call was made this run
}
// runAttempt executes exactly one attempt on the request-hash axis: a checkpoint
@ -267,7 +294,7 @@ type stageAttempt struct {
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation, isFinal bool) (stageAttempt, error) {
reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, model,
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
att := stageAttempt{reqHash: reqHash, modelActual: model}
att := stageAttempt{reqHash: reqHash, attempt: attempt, modelActual: model}
// Resume on the attempt axis: a checkpoint means THIS attempt already happened
// and was billed — classify its text and never re-bill (kill -9 loses ≤1 call;
@ -282,11 +309,16 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
r.Log.DebugContext(ctx, "checkpoint usage_json unreadable; tokens report as zero", "hash", reqHash[:12], "err", uerr)
}
att.usage = usage
att.text = cp.ResponseText
att.finish = cp.FinishReason
att.modelActual = cp.ModelActual
att.cumCost = cp.CostUSD
att.cls, att.stripped = r.classifyOutput(st.Role, ch.Text, cp.ResponseText, cp.FinishReason, isFinal)
// Banknote: slice the block off the RAW checkpoint text BEFORE classify + before feeding the
// editor (WS4 points 1-3,8). The checkpoint stores the RAW draft; resume re-derives the clean
// text deterministically (a no-op returning the raw text for a channel-off / no-separator draft).
cleanText, bankStripped, bankFlags := r.applyBanknote(st.Role, cp.ResponseText, cp.FinishReason)
att.text = cleanText
att.bankStripped, att.bankFlags = bankStripped, bankFlags
att.cls, att.stripped = r.classifyOutput(st.Role, ch.Text, cleanText, cp.FinishReason, isFinal)
rl := r.baseRequestLog(st, ch, model, reqHash)
rl.ModelActual = cp.ModelActual
rl.TMHit, rl.OK, rl.FinishReason, rl.Degraded = true, att.cls.ok(), cp.FinishReason, degradedTag(att.cls)
@ -352,6 +384,18 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
}
att.freshCall = true
// Per-model rate-guard (WS1 §1б): a wave-concurrency cap (+ optional pacing) around the ONE
// expensive provider call, so a rate-limited model (mistral) does not 429-storm under N-parallel
// waves. A no-op for an unconfigured model / the sequential path. Acquire respects ctx; on cancel
// the reservation is released and the call surfaces as an infra failure (like a dead client).
relGuard, err := r.rateGuard(model).acquire(ctx)
if err != nil {
r.releaseReservation(ctx, resv)
r.setJobStatus(ctx, job.ID, "failed")
return att, fmt.Errorf("pipeline: rate-guard acquire for %s/ch%d/chunk%d/%s (model %s): %w", r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, model, err)
}
defer relGuard()
// «Вызов в полёте» обязан быть виден: между этой строкой и «attempt completed»
// могут пройти минуты (attempt_timeout × ретраи транспорта), и без неё «висит
// провайдер» неотличимо от «умер процесс» — главная боль smoke-прогона №4.
@ -451,8 +495,13 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
// Classify AFTER the money is durably settled+checkpointed. F4 lives here: a
// non-empty truncated length draft is now classified (flagged/retried), never
// silently passed downstream as OK; an empty completion is flagged too, not a
// run-crash.
att.cls, att.stripped = r.classifyOutput(st.Role, ch.Text, resp.Text, resp.FinishReason, isFinal)
// run-crash. The banknote block is sliced off the RAW draft FIRST (WS4 points 1-3,8): the
// checkpoint above holds the raw response, but classify/coverage/echo and the editor see the
// CLEANED translation, and the cleaned text is committed as a derived export in runStage.
cleanText, bankStripped, bankFlags := r.applyBanknote(st.Role, resp.Text, resp.FinishReason)
att.text = cleanText
att.bankStripped, att.bankFlags = bankStripped, bankFlags
att.cls, att.stripped = r.classifyOutput(st.Role, ch.Text, cleanText, resp.FinishReason, isFinal)
rl := r.baseRequestLog(st, ch, model, reqHash)
rl.ModelActual = modelActual

View file

@ -141,7 +141,7 @@ func (r *Runner) bookChunks() ([]Chunk, error) {
if err != nil {
return nil, err
}
return SplitChunks(doc.Chapters), nil
return SplitChunks(doc.Chapters, r.segBudget()), nil
}
// chunkKey identifies a chunk positionally.
@ -193,16 +193,12 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
return nil, err
}
byChunk := map[chunkKey][]store.ChunkStatus{}
snapSeen := map[string]bool{}
for _, cs := range statuses {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
if cs.SnapshotID != "" {
snapSeen[cs.SnapshotID] = true
}
}
// Per-chunk post-check misses (retrieval_state) — the glossary-consistency signal that is
// re-derived each run and NOT a chunk_status disposition (default flagger mode).
// Post-check misses + style flags (retrieval_state) — the unit-level signal the the edit-wave editor merged onto
// its LEADER chunk's row (a non-leader member carries only its draft injection, post-check=0).
states, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
if err != nil {
return nil, err
@ -214,40 +210,51 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
styleByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NStyleFlags
}
stagesTotal := len(r.Pipeline.Stages)
// The post-check GATE (opt-in) flags a chunk at the CHUNK level AFTER the stage loop, so it
// is NEVER written as a chunk_status row (every stage stays DispOK). Without accounting for
// it here, status would report a gate-flagged chunk as done/pass while `tmctl translate`
// exits 2 (finding #2). When the gate is ON, promote an otherwise-done chunk with a CONFIRMED
// post-check miss to flagged/glossary_miss so status matches the run.
// The shipping granularity is the OUTPUT UNIT (edit unit for an edit pipeline, draft chunk for a
// draft-only one) — status projects it like export + the per-unit BookResult. A unit is DONE when every
// member draft AND the unit's edit resolved ok, so its expected-ok count is len(members)·|draft stages| +
// |edit stages| (a non-leader member has draft rows only; the single edit row lives at the leader).
draftStages := r.waveStagesIndexed(waveDraft)
editStages := r.waveStagesIndexed(waveEdit)
draftStageNames, editStageNames := stageNameSet(draftStages), stageNameSet(editStages)
units := r.outputUnits(chunks)
// The post-check GATE (opt-in) flags a unit at the CHUNK level AFTER the stage loop, so it is NEVER
// written as a chunk_status row (every stage stays DispOK). Without accounting for it here, status
// would report a gate-flagged unit as done/pass while `tmctl translate` exits 2 (finding #2).
gateOn := r.Pipeline.Gates.Glossary.PostcheckGate
rep := &StatusReport{BookID: r.Book.BookID, TotalChunks: len(chunks)}
rep := &StatusReport{BookID: r.Book.BookID, TotalChunks: len(units)}
passports := map[int]*ChapterPassport{}
var chapterOrder []int
var processedCost float64 // spend of PROCESSED chunks (done+flagged) — the projection base (finding #7)
var processedCost float64 // spend of PROCESSED units (done+flagged) — the projection base (finding #7)
for _, ch := range chunks {
p := passports[ch.Chapter]
for _, u := range units {
p := passports[u.Chapter]
if p == nil {
p = &ChapterPassport{Chapter: ch.Chapter, Verdict: "pass"}
passports[ch.Chapter] = p
chapterOrder = append(chapterOrder, ch.Chapter)
p = &ChapterPassport{Chapter: u.Chapter, Verdict: "pass"}
passports[u.Chapter] = p
chapterOrder = append(chapterOrder, u.Chapter)
}
p.ChunksTotal++
key := chunkKey{ch.Chapter, ch.ChunkIdx}
state, reason, cost, escalated, skipped := resolveChunkState(byChunk[key], stagesTotal)
leader := chunkKey{u.Chapter, u.FirstChunkIdx}
var rows []store.ChunkStatus
for _, m := range u.Members {
rows = append(rows, byChunk[chunkKey{m.Chapter, m.ChunkIdx}]...)
}
expected := len(u.Members)*len(draftStages) + len(editStages)
state, reason, cost, escalated, skipped := resolveChunkState(rows, expected)
p.CostUSD += cost
p.StagesSkipped += skipped
miss := missByChunk[key]
miss := missByChunk[leader] // the unit's post-check ran once, at the leader row
p.PostcheckMisses += miss
rep.PostcheckMisses += miss
style := styleByChunk[key]
style := styleByChunk[leader]
p.StyleFlags += style
rep.StyleFlags += style
if gateOn && state == ChunkDone && miss > 0 {
// Matches translateChunk: the gate flags only an otherwise-ok chunk. Not re-drivable
// (the miss re-derives from the unchanged glossary; a fix is a glossary edit =
// --resnapshot, not a redrive) — counted separately so the CLI advises the right action.
// Matches the wave editor: the gate flags only an otherwise-ok unit. Not re-drivable (the miss
// re-derives from the unchanged glossary; a fix is a glossary edit = --resnapshot, not a
// redrive) — counted separately so the CLI advises the right action.
state, reason = ChunkFlagged, string(FlagGlossaryMiss)
rep.GlossaryMissFlagged++
}
@ -273,11 +280,12 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
p.ChunksPending++
}
if state == ChunkDone || state == ChunkFlagged {
processedCost += cost // a fully-attempted chunk's cost feeds the projection
processedCost += cost // a fully-attempted unit's cost feeds the projection
}
}
// Chapter verdicts (exp07 chapter rule: 0 flagged = pass, 1 = attention, ≥2 = fail).
// Chapter verdicts (exp07 chapter rule: 0 flagged = pass, 1 = attention, ≥2 = fail — over UNITS: a
// flagged edit unit counts once, not per member chunk).
sort.Ints(chapterOrder)
for _, n := range chapterOrder {
p := passports[n]
@ -295,31 +303,59 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
if rep.TotalChunks > 0 {
rep.PercentDone = 100 * float64(rep.Done) / float64(rep.TotalChunks)
}
if len(snapSeen) == 1 {
for s := range snapSeen {
// Per-wave snapshot drift (finding #3, wave-aware): the rows carry draft-wave snapshot (draft) + edit-wave snapshot
// (edit), so a normal book has TWO snapshots — that is NOT drift. SnapshotDrift means a disagreement
// WITHIN a wave (a config changed mid-book without a full re-pin). ConfigDrift compares each wave's
// single stored snapshot against the CURRENT projection of THAT wave (materialize the stored glossary
// once, then project both). rep.Snapshot reports the SHIPPING wave's snapshot.
draftSnaps, editSnaps := map[string]bool{}, map[string]bool{}
for _, cs := range statuses {
if cs.SnapshotID == "" {
continue
}
switch {
case draftStageNames[cs.Stage]:
draftSnaps[cs.SnapshotID] = true
case editStageNames[cs.Stage]:
editSnaps[cs.SnapshotID] = true
}
}
rep.SnapshotDrift = len(draftSnaps) > 1 || len(editSnaps) > 1
finalSnaps := editSnaps
if r.finalStageWave() == waveDraft {
finalSnaps = draftSnaps
}
if len(finalSnaps) == 1 {
for s := range finalSnaps {
rep.Snapshot = s
}
} else if len(snapSeen) > 1 {
rep.SnapshotDrift = true
}
// Config-drift (finding #3): compute the CURRENT config's snapshot READ-ONLY (materialize
// the STORED glossary — no re-seed, no write) and compare to the single snapshot the stored
// rows carry. A wire/verdict-affecting config edit since the last run (a prompt bump, a gate
// flip — this very package bumps the editor prompt_version) is otherwise hidden behind
// "done/pass" until translate forces a --resnapshot. Only meaningful when a single snapshot
// is on record (multi-snapshot is already flagged as SnapshotDrift). Note: a change to the
// seed FILE that was not re-run is NOT detected here (the stored glossary is what status
// projects); it surfaces on the next translate's re-seed.
if rep.Snapshot != "" {
if curSnap, serr := r.currentSnapshotProjected(); serr != nil {
// Не молчать: провал проекции раньше тихо читался как «дрифта нет», и
// оператор верил чистому статусу при реально изменённом конфиге (аудит
// цепочек). Drift остаётся false (проекция неизвестна), но с WARN.
r.Log.WarnContext(ctx, "config-drift check failed; drift state unknown (reported as none)", "err", serr)
} else if curSnap != rep.Snapshot {
rep.ConfigDrift = true
rep.CurrentSnapshot = curSnap
if !rep.SnapshotDrift && (len(draftSnaps) > 0 || len(editSnaps) > 0) {
if err := r.projectStoredMemory(); err != nil {
// Не молчать: провал проекции раньше тихо читался как «дрифта нет» (аудит цепочек).
r.Log.WarnContext(ctx, "config-drift check failed; drift state unknown (reported as none)", "err", err)
} else {
checkWave := func(snaps map[string]bool, w wave) {
if len(snaps) != 1 {
return
}
var stored string
for s := range snaps {
stored = s
}
cur, _, serr := r.snapshotIDForWave(w)
if serr != nil {
r.Log.WarnContext(ctx, "config-drift check failed for a wave; drift state unknown", "err", serr)
return
}
if cur != stored {
rep.ConfigDrift = true
rep.CurrentSnapshot = cur
}
}
checkWave(draftSnaps, waveDraft)
checkWave(editSnaps, waveEdit)
}
}
@ -361,21 +397,17 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
return rep, nil
}
// currentSnapshotProjected computes the CURRENT config's snapshotID from the STORED glossary
// (read-only: materialize what is persisted, no re-seed, no write) — the single projection
// Status uses for ConfigDrift and Redrive uses to guard its destructive reset. Side effect
// (deliberate, on the read path): it sets r.memory to the stored-glossary materialization, the
// same as Status did inline. A seed-FILE edit not yet re-run is NOT reflected here (the STORED
// glossary is projected, not the seed file) — that drift surfaces on the next translate's
// projectStoredMemory materializes r.memory from the STORED glossary (read-only: no re-seed, no write) —
// the side effect the wave-snapshot projections need. A seed-FILE edit not yet re-run is NOT reflected here
// (the STORED glossary is projected, not the seed file) — that drift surfaces on the next translate's
// re-seed, exactly as it does for `translate` itself.
func (r *Runner) currentSnapshotProjected() (string, error) {
func (r *Runner) projectStoredMemory() error {
rows, err := r.Store.GlossaryForBook(r.Book.BookID)
if err != nil {
return "", err
return err
}
r.memory = materializeMemory(rows, r.Pipeline.Gates.Glossary.PostcheckGate)
id, _, err := r.snapshotID()
return id, err
return nil
}
// RedriveSelector picks the flagged chunks to re-attack. -1 on Chapter/ChunkIdx means "any"
@ -508,13 +540,26 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm
// (the operator explicitly accepts the re-pin/re-pay) — but ONLY the snapshot COMPARISON is skipped,
// never the seed-error-before-reset protection above.
if !r.Resnapshot {
curSnap, _, serr := r.snapshotID()
if serr != nil {
return summary, nil, fmt.Errorf("pipeline: redrive snapshot check: %w", serr)
// Per-wave drift (R1): a stored row carries its WAVE's snapshot (draft rows → draft-wave snapshot, edit
// rows → edit-wave snapshot), never the whole-pipeline one — so compare each row against the current
// projection of ITS wave (from the SEEDED r.memory seedGlossary just set, matching what the re-run
// will render). A whole-pipeline comparison would abort every redrive spuriously.
w1cur, _, e1 := r.snapshotIDForWave(waveDraft)
if e1 != nil {
return summary, nil, fmt.Errorf("pipeline: redrive draft-wave snapshot check: %w", e1)
}
w2cur, _, e2 := r.snapshotIDForWave(waveEdit)
if e2 != nil {
return summary, nil, fmt.Errorf("pipeline: redrive edit-wave snapshot check: %w", e2)
}
draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft))
for _, cs := range statuses {
if cs.SnapshotID != "" && cs.SnapshotID != curSnap {
return summary, nil, fmt.Errorf("pipeline: redrive прерван — строки книги под снапшотом %.12s, а текущий конфиг/сид рендерит %.12s (конфиг/промпты/капабилити/сид-глоссарий изменились): сброс сейчас пере-оплатил бы книгу и уничтожил бы флаг-телеметрию; повторите `translate --resnapshot`, чтобы принять пере-оплату явно, либо верните конфиг/сид", cs.SnapshotID, curSnap)
cur := w2cur
if draftStageNames[cs.Stage] {
cur = w1cur
}
if cs.SnapshotID != "" && cs.SnapshotID != cur {
return summary, nil, fmt.Errorf("pipeline: redrive прерван — строки книги под снапшотом %.12s, а текущий конфиг/сид рендерит %.12s (конфиг/промпты/капабилити/сид-глоссарий изменились): сброс сейчас пере-оплатил бы книгу и уничтожил бы флаг-телеметрию; повторите `translate --resnapshot`, чтобы принять пере-оплату явно, либо верните конфиг/сид", cs.SnapshotID, cur)
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,92 @@
package pipeline
import "strings"
// unitJoinSeparator joins a the edit wave edit-unit's member draft chunks into the editor's input draft AND (in
// export --pairs) their source chunks into the unit's source column. It MUST be identical on both sides so
// the exported src↔dst pair aligns byte-for-byte (export map §4). "\n\n" mirrors the chunker's own paragraph
// separator (chunkDraftChunks joins paragraphs with "\n\n"), so a re-joined unit reads as clean prose.
const unitJoinSeparator = "\n\n"
// editUnit is one the edit wave unit of edit work (WS2 decoupling: draft = small chunk, edit = coarse unit). It groups
// the WHOLE draft chunks sharing a Chunk.EditUnitID (assignEditUnits stamps them per chapter), so the unit's
// source/draft is exactly the concatenation of its members — no straddle, lossless reconstruction. It is
// addressed by (Chapter, FirstChunkIdx): FirstChunkIdx is the lowest ChunkIdx among the members, the
// LEADER chunk whose (chapter, chunk_idx, "edit") key carries the unit's single edit chunk_status row.
type editUnit struct {
EditUnitID int
Chapter int
FirstChunkIdx int
Members []Chunk // in ChunkIdx order (append order from SplitChunks is already sorted)
}
// sourceText is the unit's source: the members' source joined with unitJoinSeparator (the editor's
// bilingual {{text}} and the fresh the edit wave memory.Select both run over this whole-unit source).
func (u editUnit) sourceText() string {
parts := make([]string, len(u.Members))
for i, m := range u.Members {
parts[i] = m.Text
}
return strings.Join(parts, unitJoinSeparator)
}
// buildEditUnits groups the ordered draft chunks into edit units by EditUnitID (WS2). SplitChunks emits
// chunks in (chapter, chunk_idx) order and stamps a monotone book-global EditUnitID, so a single ordered
// pass yields units in book order with members in ChunkIdx order. Pure and deterministic.
func buildEditUnits(chunks []Chunk) []editUnit {
var units []editUnit
byID := map[int]int{} // EditUnitID → index into units
for _, ch := range chunks {
if idx, ok := byID[ch.EditUnitID]; ok {
units[idx].Members = append(units[idx].Members, ch)
continue
}
byID[ch.EditUnitID] = len(units)
units = append(units, editUnit{
EditUnitID: ch.EditUnitID, Chapter: ch.Chapter, FirstChunkIdx: ch.ChunkIdx,
Members: []Chunk{ch},
})
}
return units
}
// wave.go: the the precompute pass (deterministic, $0) pre-compute of the wave runner (WS1 §1б). This is the FIRST piece
// of the wave executor built ahead of the driver switch (R1 residual): the sticky-chain memory selection
// is a CROSS-CHUNK sequential dependency (a chunk's sticky_prev is the union of the prior chunks' exact
// matches in the same chapter), so the parallel waves cannot recompute it per-chunk — it must be
// precomputed once in the precompute pass. precomputeSticky replicates EXACTLY the sequential driver's inline computation
// (bookrun.go's per-chapter reset + memory.Select + sticky advance), and the sequential driver now
// CONSUMES it — so it is byte-identical by construction, and the golden test proves the injection bytes
// did not move. When the driver switches to waves (residual), the waves consume this same precompute.
//
// Select is a PURE deterministic function of (chunk, chapter, sticky_prev, budget, frozen-bank) with NO
// model output, so precomputing it reproduces byte-for-byte the injection the sequential runner rendered
// (parallelism does not change the bytes — the order is fixed and the input carries no model state).
// precomputeSticky computes the per-chunk memory selection for EVERY chunk in one ordered the precompute pass pass
// (WS1 §1б): the sticky window is reset at each chapter boundary (a new chapter is a scene change) and
// advanced by each chunk's exact matches, exactly as the sequential loop did inline. Returns a slice
// aligned index-for-index with `chunks`. A nil bank (report path / no glossary) yields zero-valued
// selections (no injection), so a caller can index it unconditionally.
func precomputeSticky(chunks []Chunk, mem *MemoryBank, budget int) []memorySelection {
out := make([]memorySelection, len(chunks))
if mem == nil {
return out
}
var stickyWin []map[string]injectionDisposition
prevChapter := 0
for i, ch := range chunks {
if ch.Chapter != prevChapter {
stickyWin = nil // chapter boundary: reset scene-inertia (A5)
prevChapter = ch.Chapter
}
sel := mem.Select(ch.Text, ch.Chapter, unionSticky(stickyWin), budget)
out[i] = sel
// Advance the sticky window with THIS chunk's exact matches (keep the last stickyDepth).
stickyWin = append(stickyWin, sel.activeIDs)
if len(stickyWin) > stickyDepth {
stickyWin = stickyWin[len(stickyWin)-stickyDepth:]
}
}
return out
}

View file

@ -0,0 +1,549 @@
package pipeline
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"textmachine/backend/internal/config"
"textmachine/backend/internal/store"
)
// waverun.go: the R1 WAVE EXECUTOR — the driver switch from the sequential per-chunk-all-stages loop
// (bookrun.go/chunkrun.go, retired) to the precompute pass ($0) → the draft wave (draft stage ∥ over draft CHUNKS) →
// the bank-mining stop → the edit wave (edit stage ∥ over edit UNITS). The load-bearing decoupling (plan §1/§2):
// the DRAFT is the fine per-chunk unit (COGS/coverage/alignment), the EDIT is the coarse per-unit unit
// (a chapter-scale grouping of whole draft chunks — cross-chunk cohesion, D39 п.4). the edit wave reads a unit's draft
// as the concatenation of its members' draft-wave outputs and does a FRESH memory.Select over the whole-unit source;
// it NEVER re-renders the draft stage (the несущий invariant — a re-render over the enriched bank would move
// the draft request_hash and re-bill the draft wave). Money stays single-writer-safe (Reserve/Settle serialize); the
// waves add only read-only shared state (clients/templates/memory/rate-guards built in the precompute pass). Per-wave
// snapshots (draft-wave snapshot base-bank, edit-wave snapshot enriched) keep «переоплата ОДНА»: a the bank-mining stop enrichment moves
// only edit-wave snapshot, so draft-wave checkpoints stay valid.
// wavedStage carries a wave stage with its GLOBAL index in Pipeline.Stages — runStage needs the global
// index for isFinal (the LAST stage is the shipping stage the sanitizer runs on) and for the >0 sizing
// path (a later stage sizes max_tokens from its input draft, not the source, D2.5).
type wavedStage struct {
st config.Stage
idx int
}
// waveStagesIndexed partitions Pipeline.Stages by role for a wave, carrying each stage's global index
// (mirrors waveStages but retains the index runStage needs). the draft wave = translator (draft) stages; the edit wave = every
// non-translator (editor/other) stage. Order preserved.
func (r *Runner) waveStagesIndexed(w wave) []wavedStage {
var out []wavedStage
for i, st := range r.Pipeline.Stages {
isDraft := st.Role == roleTranslator
if (w == waveDraft) == isDraft {
out = append(out, wavedStage{st: st, idx: i})
}
}
return out
}
// stageNameSet is the set of a wave's stage names — used by the read-models to classify a stored
// chunk_status row by wave (its snapshot belongs to that wave's per-wave snapshot).
func stageNameSet(staged []wavedStage) map[string]bool {
m := make(map[string]bool, len(staged))
for _, ws := range staged {
m[ws.st.Name] = true
}
return m
}
// outputUnits is the SHIPPING granularity the read-models (export/status) project — matching the per-unit
// BookResult: edit units (member draft chunks grouped) for an edit pipeline, or one singleton unit per
// draft chunk for a draft-only pipeline (the draft itself ships). One helper so export and status agree.
func (r *Runner) outputUnits(chunks []Chunk) []editUnit {
if r.finalStageWave() == waveEdit {
return buildEditUnits(chunks)
}
out := make([]editUnit, len(chunks))
for i, ch := range chunks {
out[i] = editUnit{EditUnitID: ch.EditUnitID, Chapter: ch.Chapter, FirstChunkIdx: ch.ChunkIdx, Members: []Chunk{ch}}
}
return out
}
// WaveSignatureStop is the typed sentinel the CLI surfaces when bank-mining proposes a non-empty seed-delta:
// the run completes the draft wave, writes the owner signature map, and STOPS before the edit wave (plan §1(в) default — an explicit
// stop boundary, not a mid-run append). The owner reviews the signature map, curates the mined-delta file
// (promoting terms to approved with a dst), and re-runs: the draft wave resumes at $0, the bank-mining stop re-mines (the now-seeded
// terms are excluded → the delta empties → auto-continue), and the edit wave runs under edit-wave snapshot. NOT an infra
// failure — a deliberate human-in-the-loop pause, carried up like CompletedWithFlags.
type WaveSignatureStop struct {
Terms int
SignaturePath string
}
func (e *WaveSignatureStop) Error() string {
return fmt.Sprintf("bank-mining proposed %d new term(s) — review + sign %s, then resume (the edit wave)", e.Terms, e.SignaturePath)
}
// translateBookWaves is the wave driver (R1). It runs the draft wave (draft ∥), the bank-mining stop, the edit wave (edit ∥) and
// assembles the BookResult. chunks + stickySel come from the precompute pass (SplitChunks + precomputeSticky), computed by
// the caller (TranslateBook) after ingest/seed/eager-build. Returns a *WaveSignatureStop when the bank-mining stop stops.
func (r *Runner) translateBookWaves(ctx context.Context, chunks []Chunk, stickySel []memorySelection) (*BookResult, error) {
draftStages := r.waveStagesIndexed(waveDraft)
editStages := r.waveStagesIndexed(waveEdit)
workers := r.Pipeline.Waves.Workers
editWave := len(editStages) > 0 // a real editor wave exists; else the draft IS the shipping output (draft-only)
// --- the draft wave: draft stage(s) ∥ over draft chunks under draft-wave snapshot (base-bank version) ---
draftSnapshot, draftPayload, err := r.snapshotIDForWave(waveDraft)
if err != nil {
return nil, err
}
if err := r.Store.UpsertSnapshot(draftSnapshot, r.Book.BriefHash(), draftPayload); err != nil {
return nil, fmt.Errorf("pipeline: upsert draft-wave snapshot %.12s: %w", draftSnapshot, err)
}
r.Log.InfoContext(ctx, "draft wave started", "snapshot", draftSnapshot[:12],
"chunks", len(chunks), "workers", workers, "draft_stages", len(draftStages))
draftResults := make([]stageSeqResult, len(chunks))
if err := r.runWave(ctx, workers, len(chunks), func(ctx context.Context, i int) error {
d, err := r.runDraftChunk(ctx, draftSnapshot, chunks[i], stickySel[i], draftStages, !editWave)
if err != nil {
return err
}
draftResults[i] = d
return nil
}); err != nil {
return nil, err
}
// --- the bank-mining stop: bank-mining stop boundary (auto-continues when mining is not configured) ---
if stopped, err := r.runBankMiningStop(ctx, chunks); err != nil {
return nil, err
} else if stopped {
return nil, &WaveSignatureStop{Terms: r.lastMinedCount, SignaturePath: r.signatureMapPath()}
}
res := &BookResult{BookID: r.Book.BookID}
// --- Draft-only pipeline: the draft IS the shipping output; assemble per draft chunk (no edit units) ---
if !editWave {
for i, ch := range chunks {
oc := draftOnlyOutcome(ch, draftResults[i])
res.Chunks = append(res.Chunks, oc)
res.TotalUSD += oc.CostUSD
if oc.Disposition == DispFlagged {
res.Flagged++
}
}
r.Log.InfoContext(ctx, "book run finished (draft-only)", "book", r.Book.BookID,
"chunks", len(res.Chunks), "flagged", res.Flagged, "run_usd", fmt.Sprintf("%.6f", res.TotalUSD))
return res, nil
}
// --- the edit wave: edit stage(s) ∥ over edit units under edit-wave snapshot (enriched-bank version) ---
editSnapshot, editPayload, err := r.snapshotIDForWave(waveEdit)
if err != nil {
return nil, err
}
if err := r.Store.UpsertSnapshot(editSnapshot, r.Book.BriefHash(), editPayload); err != nil {
return nil, fmt.Errorf("pipeline: upsert edit-wave snapshot %.12s: %w", editSnapshot, err)
}
units := buildEditUnits(chunks)
draftByKey := make(map[chunkKey]stageSeqResult, len(chunks))
for i, ch := range chunks {
draftByKey[chunkKey{ch.Chapter, ch.ChunkIdx}] = draftResults[i]
}
r.Log.InfoContext(ctx, "edit wave started", "snapshot", editSnapshot[:12],
"units", len(units), "workers", workers, "edit_stages", len(editStages))
unitOutcomes := make([]*ChunkOutcome, len(units))
if err := r.runWave(ctx, workers, len(units), func(ctx context.Context, i int) error {
oc, err := r.runEditUnit(ctx, editSnapshot, units[i], draftByKey, editStages)
if err != nil {
return err
}
unitOutcomes[i] = oc
return nil
}); err != nil {
return nil, err
}
for _, oc := range unitOutcomes {
res.Chunks = append(res.Chunks, *oc)
res.TotalUSD += oc.CostUSD
if oc.Disposition == DispFlagged {
res.Flagged++
}
}
r.Log.InfoContext(ctx, "book run finished", "book", r.Book.BookID,
"units", len(res.Chunks), "flagged", res.Flagged, "run_usd", fmt.Sprintf("%.6f", res.TotalUSD))
return res, nil
}
// runWave fans `n` items out over `workers` goroutines, calling work(ctx, i) for each index. The first
// error cancels the rest (via a derived context) and is returned; work items are independent (a wave has no
// shared mutable input), and money/store writes serialize through the single-writer pool, so this is
// race-clean. Results are written by the callback into a caller-owned slice indexed by i, so the result
// ORDER is deterministic (item index) regardless of completion order — the golden capture sorts the wire/log
// side-effects separately. workers ≤ 0 is treated as 1 (LoadPipeline already clamps).
//
// PARENT CANCELLATION (Ctrl-C / SIGTERM — the CLI wires a signal.NotifyContext) is surfaced as an error too,
// NOT a silent nil: the feeder drops the remaining items on ctx.Done(), and a worker need not observe the
// cancellation to return (a $0 resume worker resolves entirely through its own opContext store reads, never
// touching the cancellable ctx), so firstErr can stay nil while items are left UNDONE. Returning nil then
// would let the caller index uninitialised result slots — a nil-deref on the edit-wave `*oc` assembly, or a
// draft-only run reporting empty translations as exit-0 success. So after the workers drain, a still-nil
// firstErr defers to parent.Err(): nil on a clean run, context.Canceled on an interrupted one (the durable
// store stays correct — the undone items were never checkpointed, so a re-run resumes them).
func (r *Runner) runWave(parent context.Context, workers, n int, work func(ctx context.Context, i int) error) error {
if workers < 1 {
workers = 1
}
if n == 0 {
return nil
}
ctx, cancel := context.WithCancel(parent)
defer cancel()
idxCh := make(chan int)
var wg sync.WaitGroup
var mu sync.Mutex
var firstErr error
fail := func(err error) {
mu.Lock()
if firstErr == nil {
firstErr = err
cancel()
}
mu.Unlock()
}
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range idxCh {
if err := work(ctx, i); err != nil {
fail(err)
return
}
}
}()
}
for i := 0; i < n; i++ {
select {
case idxCh <- i:
case <-ctx.Done():
}
}
close(idxCh)
wg.Wait()
if firstErr != nil {
return firstErr
}
// No worker failed, but the PARENT may have been cancelled (Ctrl-C), which the feeder honoured by
// dropping items — surface that as an error so the driver never assembles a partial result as success.
return parent.Err()
}
// stageSeqResult is the outcome of running one chunk/unit through an ORDERED list of stages (one wave):
// the per-stage results, the final OK stage's text, and the terminal flag state.
type stageSeqResult struct {
stages []StageResult
finalText string // the last OK stage's Text (empty until a stage runs; unused when flagged)
flagged bool
flagReason FlagReason
recovered string // sanitizer-stripped export text from the flagging stage (D35.4a)
cost float64 // THIS run's spend across the sequence
bankTelem bankFlags // the translator-role stage's banknote telemetry (WS4 point 10)
}
// runStageSequence runs `staged` in order over one chunk/unit, feeding each stage's output to the next as
// `prev` (starting from startPrev), stopping the sequence at the FIRST flagged stage (later stages are
// recorded `skipped` — no paid edit over a garbage draft, D2). It is the generalized inner loop of the
// retired translateChunk, reused by BOTH waves (draft over a chunk, the edit-wave editor over a unit). injectionByRole
// maps a stage role to its already-rendered memory-injection message (the translator's src→dst glossary in
// the draft wave, the editor's CONFIRMED-dst constraint block in the edit wave). Deterministic; content-verified resume via runStage.
func (r *Runner) runStageSequence(ctx context.Context, staged []wavedStage, snapID string, ch Chunk, startPrev string, injectionByRole map[string]string) (stageSeqResult, error) {
var res stageSeqResult
prev := startPrev
flagged := false
var flagReason FlagReason
recovered := ""
for _, ws := range staged {
st := ws.st
if flagged {
detail := fmt.Sprintf("skipped: an upstream stage was flagged (%s)", flagReason)
if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name,
SnapshotID: snapID, Disposition: string(DispSkipped), FlagReason: string(flagReason), Detail: detail,
}); err != nil {
return res, fmt.Errorf("pipeline: record skipped chunk_status: %w", err)
}
res.stages = append(res.stages, StageResult{
Stage: st.Name, Role: st.Role, Model: st.Model,
Disposition: DispSkipped, FlagReason: flagReason, Detail: detail,
})
continue
}
sr, err := r.runStage(ctx, st, ws.idx, snapID, ch, prev, injectionByRole[st.Role])
if err != nil {
return res, err
}
res.stages = append(res.stages, *sr)
res.cost += sr.CostUSD
if st.Role == roleTranslator {
res.bankTelem = sr.BankFlags
// FL-2: the resume fast-path serves the banknote-cleaned final_hash and leaves BankFlags zero;
// restore the telemetry the fresh run persisted so persistRetrievalState does not zero the three
// banknote columns on every resume of a ready chunk (guarded on an empty BankFlags so the
// attempt-loop path — which re-derives telemetry from the raw checkpoint — is never overwritten).
if res.bankTelem == (bankFlags{}) && sr.FromResume && r.Pipeline.Gates.Banknote.Enabled {
if prevRS, err := r.Store.GetRetrievalState(r.Book.BookID, ch.Chapter, ch.ChunkIdx); err == nil && prevRS != nil {
res.bankTelem = bankFlags{
NLines: prevRS.NBanknoteLines, ParseFail: prevRS.BanknoteParseFail != 0, Truncated: prevRS.BanknoteTruncated != 0,
}
}
}
}
if sr.Disposition == DispFlagged {
flagged = true
flagReason = sr.FlagReason
recovered = sr.RecoveredText
continue
}
prev = sr.Text
}
res.finalText = prev
res.flagged = flagged
res.flagReason = flagReason
res.recovered = recovered
return res, nil
}
// runDraftChunk runs the draft stage(s) over one chunk under the draft-wave snapshot (plan §1). It renders
// the translator's src→dst glossary injection from the precomputed per-chunk selection over the BASE bank
// (baseMemory — mined-excluded, so the injection is stable across a bank-mining enrichment), runs the
// sequence, and persists the draft retrieval_state (injection counts + banknote telemetry). When this is the
// FINAL wave (a draft-only pipeline with no editor), the draft IS the shipping text, so the post-check +
// cheap gates run here over the draft; otherwise they belong to the edit wave (the edit unit's final output).
func (r *Runner) runDraftChunk(ctx context.Context, draftSnapshot string, ch Chunk, memSel memorySelection, draftStages []wavedStage, isFinalWave bool) (stageSeqResult, error) {
injectionByRole := map[string]string{}
if r.baseMemory != nil {
// Serialize the per-role injection blocks via the role→renderer registry (D39 слой 7) from the
// precomputed base-bank selection: the translator gets its src→dst glossary block; any other
// role's block is rendered too but consumed only if a stage of that role runs in this wave.
for role, render := range roleInjectionRenderers {
injectionByRole[role] = render(memSel.injected) // pure → map-order-independent
}
if len(memSel.trustGated) > 0 {
r.Log.WarnContext(ctx, "memory: lower-trust longer key refused from suppressing a higher-trust nested key (approved term preserved; reconcile the seed)",
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "trust_gated", len(memSel.trustGated),
"first", memSel.trustGated[0].Suppressor+"⊃"+memSel.trustGated[0].Protected)
}
}
seq, err := r.runStageSequence(ctx, draftStages, draftSnapshot, ch, "", injectionByRole)
if err != nil {
return seq, err
}
var postMisses []postcheckMiss
outputChecked := false
var cheap cheapGateResult
if isFinalWave && r.baseMemory != nil && !seq.flagged && seq.finalText != "" {
// Draft-only pipeline: the draft is the shipping output → run the FINAL-output checks here, over the
// SAME base bank whose terms the draft was injected with (post-check parity with the injection).
outputChecked = true
postMisses = r.baseMemory.postcheck(memSel.injected, seq.finalText)
if r.Pipeline.Gates.Glossary.PostcheckGate && countConfirmedMisses(postMisses) > 0 {
seq.flagged = true
seq.flagReason = FlagGlossaryMiss
r.Log.WarnContext(ctx, "glossary post-check gate flagged the chunk",
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "confirmed_misses", countConfirmedMisses(postMisses))
}
}
if isFinalWave && !seq.flagged && seq.finalText != "" && isRuTarget(r.Book.TargetLang) {
cheap = runCheapGates(ch.Text, seq.finalText, seq.finalText, r.cheapGateConfig())
}
if r.baseMemory != nil {
if err := r.persistRetrievalState(draftSnapshot, ch, memSel, postMisses, outputChecked, cheap, seq.bankTelem); err != nil {
return seq, err
}
}
return seq, nil
}
// runEditUnit runs the editor stage(s) over one edit unit under the edit-wave snapshot (plan §1/§2). It reads
// the unit's draft as the concatenation of its member chunks' draft outputs (NEVER re-rendering the draft
// stage — the несущий invariant), does a FRESH memory.Select over the WHOLE-unit source over the ENRICHED
// bank (sticky degenerate at unit scope → nil), and runs the editor over (unit source, unit draft). A unit whose
// ANY member draft flagged is flagged (skip edit — no paid edit over a partial draft, D2 flag+skip); the
// good members' draft-wave spend stays honestly committed. Post-check + cheap gates run over the edit output at
// unit granularity, merged into the LEADER chunk's retrieval_state row.
func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit editUnit, draftByKey map[chunkKey]stageSeqResult, editStages []wavedStage) (*ChunkOutcome, error) {
leader := Chunk{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx, Text: unit.sourceText()}
out := &ChunkOutcome{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx, Disposition: DispOK}
var draftStages []StageResult
var draftCost float64
var draftParts []string
memberFlagged := false
var memberFlagReason FlagReason
memberRecovered := ""
for _, m := range unit.Members {
d, ok := draftByKey[chunkKey{m.Chapter, m.ChunkIdx}]
if !ok {
return nil, fmt.Errorf("pipeline: edit unit ch%d chunk%d: missing draft result (wave sequencing bug)", m.Chapter, m.ChunkIdx)
}
draftStages = append(draftStages, d.stages...)
draftCost += d.cost
if d.flagged {
if !memberFlagged {
memberFlagged = true
memberFlagReason = d.flagReason
memberRecovered = d.recovered
}
continue
}
draftParts = append(draftParts, d.finalText)
}
out.CostUSD = draftCost
if memberFlagged {
// A member draft flagged → the unit's edit is skipped (no paid edit over a partial draft). Record a
// skipped edit chunk_status at the leader and export the recovered draft (or "" for a dropped flag).
skipped, err := r.recordSkippedStages(ctx, editStages, editSnapshot, leader, memberFlagReason)
if err != nil {
return nil, err
}
out.Stages = append(draftStages, skipped...)
out.Disposition = DispFlagged
out.FlagReason = memberFlagReason
out.FinalText = exportNormalize(memberRecovered)
return out, nil
}
unitDraft := strings.Join(draftParts, unitJoinSeparator)
var editSel memorySelection
injectionByRole := map[string]string{}
if r.memory != nil {
// FRESH Select over the WHOLE-unit source over the ENRICHED bank (§1(б)/F3): NOT the the precompute pass per-chunk
// memSel (that was over the base bank). Sticky is degenerate at unit scope (a unit == a chapter or a
// sub-chapter split, so the intra-chapter sticky window does not apply) → nil sticky_prev.
editSel = r.memory.Select(leader.Text, unit.Chapter, nil, r.Pipeline.Context.GlossaryTokenBudget)
// Render the per-role injection from the ENRICHED unit selection via the registry (D39 слой 7): the
// editor gets its CONFIRMED-dst constraint block; other roles' blocks are rendered but consumed
// only if a stage of that role runs in the edit wave.
for role, render := range roleInjectionRenderers {
injectionByRole[role] = render(editSel.injected)
}
}
seq, err := r.runStageSequence(ctx, editStages, editSnapshot, leader, unitDraft, injectionByRole)
if err != nil {
return nil, err
}
out.Stages = append(draftStages, seq.stages...)
out.CostUSD += seq.cost
finalText, flagged, flagReason, recovered := seq.finalText, seq.flagged, seq.flagReason, seq.recovered
var postMisses []postcheckMiss
outputChecked := false
if r.memory != nil && !flagged && finalText != "" {
outputChecked = true
postMisses = r.memory.postcheck(editSel.injected, finalText)
if r.Pipeline.Gates.Glossary.PostcheckGate && countConfirmedMisses(postMisses) > 0 {
flagged = true
flagReason = FlagGlossaryMiss
r.Log.WarnContext(ctx, "glossary post-check gate flagged the edit unit",
"chapter", unit.Chapter, "leader_chunk", unit.FirstChunkIdx, "confirmed_misses", countConfirmedMisses(postMisses))
}
}
var cheap cheapGateResult
if !flagged && finalText != "" && isRuTarget(r.Book.TargetLang) {
cheap = runCheapGates(leader.Text, unitDraft, finalText, r.cheapGateConfig())
if cheap.total() > 0 {
r.Log.InfoContext(ctx, "cheap style gates flagged the edit unit (observability, not a gate)",
"chapter", unit.Chapter, "leader_chunk", unit.FirstChunkIdx, "style_flags", cheap.total())
}
}
if r.memory != nil {
if err := r.mergeUnitRetrievalState(unit.Chapter, unit.FirstChunkIdx, postMisses, outputChecked, cheap); err != nil {
return nil, err
}
}
if flagged {
out.Disposition = DispFlagged
out.FlagReason = flagReason
out.FinalText = exportNormalize(recovered)
} else {
out.FinalText = exportNormalize(finalText)
}
return out, nil
}
// recordSkippedStages writes a `skipped` chunk_status row (+ a skipped StageResult) for each stage in a
// wave, at chunk `ch` under `snapID` — used when a unit's member draft flagged, so the unit's edit is
// skipped (mirrors runStageSequence's flagged-skip path but for a pre-decided skip).
func (r *Runner) recordSkippedStages(ctx context.Context, staged []wavedStage, snapID string, ch Chunk, flagReason FlagReason) ([]StageResult, error) {
detail := fmt.Sprintf("skipped: a member draft chunk of this edit unit was flagged (%s)", flagReason)
var out []StageResult
for _, ws := range staged {
st := ws.st
if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name,
SnapshotID: snapID, Disposition: string(DispSkipped), FlagReason: string(flagReason), Detail: detail,
}); err != nil {
return nil, fmt.Errorf("pipeline: record skipped edit chunk_status: %w", err)
}
out = append(out, StageResult{
Stage: st.Name, Role: st.Role, Model: st.Model,
Disposition: DispSkipped, FlagReason: flagReason, Detail: detail,
})
}
return out, nil
}
// mergeUnitRetrievalState folds a edit unit's post-check + cheap-gate observability into the LEADER chunk's
// retrieval_state row — the row the draft wave already wrote with the leader chunk's draft-injection counts + banknote
// telemetry. A read-modify-write (single-writer safe; each unit owns a distinct leader chunk_idx) preserves
// the draft fields and adds the unit-level post-check/style fields, so the read-models aggregate injection
// per draft chunk and post-check/style per unit. A missing row (r.memory nil path) is a no-op.
func (r *Runner) mergeUnitRetrievalState(chapter, firstChunkIdx int, misses []postcheckMiss, outputChecked bool, cheap cheapGateResult) error {
rs, err := r.Store.GetRetrievalState(r.Book.BookID, chapter, firstChunkIdx)
if err != nil {
return fmt.Errorf("pipeline: read leader retrieval_state ch%d/chunk%d: %w", chapter, firstChunkIdx, err)
}
if rs == nil {
return nil // no draft row (no memory / no draft) → nothing to merge onto
}
rs.NStyleFlags = cheap.total()
rs.StyleDetail = ""
if cheap.total() > 0 {
if b, err := json.Marshal(cheap); err == nil {
rs.StyleDetail = string(b)
}
}
rs.NPostcheckMiss = 0
rs.PostcheckDetail = ""
if outputChecked {
rs.NPostcheckMiss = countConfirmedMisses(misses)
if len(misses) > 0 {
if b, err := json.Marshal(misses); err == nil {
rs.PostcheckDetail = string(b)
}
}
}
return r.Store.UpsertRetrievalState(*rs)
}
// draftOnlyOutcome assembles a ChunkOutcome for a draft-only pipeline (no editor): the draft is the shipping
// output, so the per-chunk draft result maps straight to the outcome (post-check/cheap already ran in
// runDraftChunk as the final wave). Mirrors the export contract: a cosmetic strip exports its recovered
// text; every other flag exports "".
func draftOnlyOutcome(ch Chunk, d stageSeqResult) ChunkOutcome {
oc := ChunkOutcome{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stages: d.stages, CostUSD: d.cost, Disposition: DispOK}
if d.flagged {
oc.Disposition = DispFlagged
oc.FlagReason = d.flagReason
oc.FinalText = exportNormalize(d.recovered)
} else {
oc.FinalText = exportNormalize(d.finalText)
}
return oc
}

View file

@ -0,0 +1,387 @@
package pipeline
import (
"context"
"errors"
"math"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/obs"
)
// waverun_test.go: the R1 wave-driver behaviours the 1-chunk-per-chapter fixtures cannot exercise — a
// MULTI-CHUNK edit unit (the editor collapses several draft chunks into one edit call over their
// concatenation, addressed by the leader), parallel-worker money conservation, a flagged member draft
// skipping the unit's edit, and the bank-mining stop gate.
// cjkSource builds a single-chapter ja source of `paras` paragraphs, each `hanPerPara` Han chars + a full
// stop, separated by a blank line — enough to make the chunker split it into several draft chunks.
func cjkSource(paras, hanPerPara int) string {
p := make([]string, paras)
for i := range p {
p[i] = strings.Repeat("文", hanPerPara) + "。"
}
return strings.Join(p, "\n\n")
}
// TestWaveMultiChunkEditUnit is the load-bearing R1 pin: a chapter that splits into TWO draft chunks but
// groups into ONE edit unit. The editor must run ONCE over the concatenation of the two members' drafts
// (never re-rendering either draft), the unit's outcome is a single ChunkOutcome at the leader, and export
// projects one row — not two "pending" chunks.
func TestWaveMultiChunkEditUnit(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// para1 est_out ≈ 1677 (≤ draft budget 1797 → its own chunk); para2 est_out ≈ 1318 forces a second
// chunk; together ≈ 2995 ≤ edit ceiling 3200 → ONE edit unit with both members.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(1, 1400) + "\n\n" + strings.Repeat("字", 1100) + "。", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// Confirm the fixture actually produces the 2-chunk / 1-unit shape the test targets.
r0 := newRunner(t, bookPath)
manifest, err := r0.bookChunks()
r0.Close()
if err != nil {
t.Fatal(err)
}
units := buildEditUnits(manifest)
if len(manifest) != 2 || len(units) != 1 || len(units[0].Members) != 2 {
t.Fatalf("fixture must be 2 draft chunks in 1 edit unit, got %d chunks / %d units", len(manifest), len(units))
}
r1 := newRunner(t, bookPath)
res, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
r1.Close()
// One outcome PER UNIT, at the leader chunk, carrying both member drafts + the single edit stage.
if len(res.Chunks) != 1 {
t.Fatalf("a 2-chunk unit must yield ONE unit outcome, got %d", len(res.Chunks))
}
oc := res.Chunks[0]
if oc.ChunkIdx != 0 {
t.Fatalf("the unit outcome must address the leader chunk (0), got %d", oc.ChunkIdx)
}
if len(oc.Stages) != 3 {
t.Fatalf("the unit outcome must carry 2 member drafts + 1 edit, got %d stages", len(oc.Stages))
}
if oc.Stages[0].Role != roleTranslator || oc.Stages[1].Role != roleTranslator || oc.Stages[2].Role != roleEditor {
t.Fatalf("stages must be [draft, draft, edit], got %s/%s/%s", oc.Stages[0].Role, oc.Stages[1].Role, oc.Stages[2].Role)
}
if oc.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("the unit's final text is the single edit output, got %q", oc.FinalText)
}
// EXACTLY 3 provider calls: 2 drafts + 1 edit — the editor did NOT re-render either draft.
if rec.count() != 3 {
t.Fatalf("multi-chunk unit must be 2 draft + 1 edit calls, got %d", rec.count())
}
// The editor's request carried the CONCATENATION of both members' drafts (joined by the unit separator).
var editBody string
for _, b := range rec.all() {
if isEditBody(b) {
editBody = b
}
}
// Both members' drafts appear in the editor's input (the body JSON-escapes the join, so count the
// draft marker rather than match the raw separator).
if n := strings.Count(editBody, "ЧЕРНОВИК ПЕРЕВОДА"); n != 2 {
t.Fatalf("the editor must read BOTH member drafts concatenated, found %d in the edit body", n)
}
// Export projects ONE unit row (not 2, and not a phantom "pending" for the non-leader member).
r2 := newRunner(t, bookPath)
exp, err := r2.Export(true)
if err != nil {
t.Fatal(err)
}
if exp.TotalChunks != 1 || exp.PendingChunks != 0 || len(exp.Chunks) != 1 {
t.Fatalf("export must be 1 unit / 0 pending, got total=%d pending=%d rows=%d", exp.TotalChunks, exp.PendingChunks, len(exp.Chunks))
}
if exp.Chunks[0].FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("export unit text mismatch: %q", exp.Chunks[0].FinalText)
}
// --pairs source is the members' joined source (the src↔target pair aligns to the editor's input).
if !strings.Contains(exp.Chunks[0].Source, unitJoinSeparator) {
t.Fatalf("export --pairs source must be the joined unit source, got %q", exp.Chunks[0].Source[:min(40, len(exp.Chunks[0].Source))])
}
// Status counts the unit as ONE done chunk (not two).
st, err := r2.Status(ctx)
if err != nil {
t.Fatal(err)
}
if st.TotalChunks != 1 || st.Done != 1 {
t.Fatalf("status must be 1 unit done, got total=%d done=%d", st.TotalChunks, st.Done)
}
r2.Close()
// Resume is $0 (all checkpoints hit; the editor re-derives its content-addressed id for free).
r3 := newRunner(t, bookPath)
defer r3.Close()
before := rec.count()
res2, err := r3.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != before || res2.TotalUSD != 0 {
t.Fatalf("resume must be $0 with no new calls: calls=%d usd=%v", rec.count()-before, res2.TotalUSD)
}
}
// TestRunWaveSurfacesParentCancellation pins the cancellation contract: when the PARENT context is
// cancelled (a Ctrl-C / SIGTERM during a live run — main.go wires a signal.NotifyContext), runWave must
// surface that as an ERROR, never a silent nil. The feeder drops the remaining items on ctx.Done(), and a
// $0 resume worker never touches the cancellable ctx (its store ops use their own opContext), so firstErr
// stays nil — without a parent.Err() check runWave returns nil with items UNDONE, and the driver then
// indexes uninitialised result slots (an edit pipeline nil-derefs *oc; a draft-only run reports empty
// translations as exit-0 success). A cancelled parent → a non-nil error is the fix's whole point.
func TestRunWaveSurfacesParentCancellation(t *testing.T) {
r := &Runner{} // runWave touches no Runner field
ctx, cancel := context.WithCancel(context.Background())
cancel() // the operator hit Ctrl-C before/at the wave boundary
// Workers return nil (a $0 resume worker never observes the cancellable ctx) — so firstErr stays nil
// and ONLY a parent-cancellation check can turn this into the error the driver needs.
err := r.runWave(ctx, 2, 8, func(context.Context, int) error { return nil })
if err == nil {
t.Fatal("runWave returned nil on a cancelled parent ctx — items are left undone and the driver indexes uninitialised result slots (nil-deref panic on an edit pipeline / silent exit-0 partial success on draft-only)")
}
}
// TestWaveParallelWorkersMoneyConserved runs the wave driver with several parallel workers over a book of
// many chunks and asserts the money invariant survives concurrency: committed spend == the sum of the
// settled checkpoints (the single-writer store serializes Reserve/Settle), and the per-unit result is
// complete + deterministic. Run under `-race` this also proves the waves share only read-only state.
func TestWaveParallelWorkersMoneyConserved(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// 5 paragraphs of ~1400 Han each → 5 draft chunks, and since any two exceed the 3200 edit ceiling,
// 5 edit units → 10 wave work items fanned over 4 workers.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(5, 1400), regenerate: 0, waveWorkers: 4, bookUSD: 100})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
res, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 5 {
t.Fatalf("5 chapters-worth of single-chunk units expected, got %d", len(res.Chunks))
}
for _, oc := range res.Chunks {
if oc.Disposition != DispOK || oc.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("every parallel unit must be ok+edited, got %s / %q", oc.Disposition, oc.FinalText)
}
}
// Money invariant under parallelism: the ledger's committed spend matches the run's own aggregate and
// no reservation leaked — the single-writer store serialized every Reserve/Settle despite N workers
// (the store-level atomicity + kill-9 durability is separately pinned by store/kill9_test.go).
committed, reserved, err := r1.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
if reserved != 0 {
t.Fatalf("no reservation may leak after a clean parallel run, reserved=%v", reserved)
}
if math.Abs(res.TotalUSD-committed) > 1e-9 {
t.Fatalf("run total %v != ledger committed %v under parallel workers — a settle raced/leaked", res.TotalUSD, committed)
}
}
// TestWaveEditUnitFlaggedMemberDraft: when ONE member draft of a multi-chunk unit flags, the whole unit is
// flagged and its edit is SKIPPED (no paid edit over a partial draft), while the good member's draft money
// stays committed. There is exactly ONE edit call fewer than a clean run.
func TestWaveEditUnitFlaggedMemberDraft(t *testing.T) {
rec := &reqRec{}
// The second paragraph carries a marker; the mock returns an untranslated CJK echo for it → cjk_artifact.
const echoMarker = "禁"
respond := func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if strings.Contains(body, echoMarker) {
return "这是完全没有翻译的中文内容。", "stop" // fully-CJK → classify flags cjk_artifact
}
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
}
srv := newJSONProvider(rec, respond)
defer srv.Close()
src := strings.Repeat("文", 1400) + "。\n\n" + strings.Repeat(echoMarker, 1100) + "。"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 1 {
t.Fatalf("still one unit outcome, got %d", len(res.Chunks))
}
oc := res.Chunks[0]
if oc.Disposition != DispFlagged || oc.FlagReason != FlagCJKArtifact {
t.Fatalf("a flagged member draft must flag the unit (cjk_artifact), got %s/%s", oc.Disposition, oc.FlagReason)
}
if oc.FinalText != "" {
t.Fatalf("a flagged unit exports nothing, got %q", oc.FinalText)
}
// 2 draft calls (one ok, one echo), ZERO edit calls — the unit's edit was skipped over the partial draft.
if rec.count() != 2 {
t.Fatalf("a partial-draft unit runs 2 drafts + 0 edit, got %d calls", rec.count())
}
}
// TestWaveEscalationBudgetSerializedUnderParallelism pins the escMu: two draft chunks escalate
// CONCURRENTLY under a budget that admits only ONE hop. The escalation soft-cap is a non-atomic
// read-then-act over EscalationSpentUSD, so without escMu both parallel workers could read spent<budget and
// both be admitted (overshoot by a whole hop). escMu serializes the admission through the paid settle, so
// exactly one hop fires — the same guarantee the sequential TestRunnerEscalationBudgetExhaustionDeniesLaterHop
// gives, now under parallel workers. (Run under -race this also proves the shared spend path is race-clean.)
func TestWaveEscalationBudgetSerializedUnderParallelism(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean) // primary echoes (cjk_artifact); fake-fallback cleans
defer srv.Close()
bookPath := setupTwoChapterEscalation(t, srv.URL, 0.001) // budget 0.001 < one hop (≈0.00182)
// Bump to parallel workers so both chapters' drafts escalate at once.
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
writeFile(t, pipePath, strings.Replace(string(raw), "core: C1", "core: C1\nwaves: { workers: 4 }", 1))
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
esc, err := r.Store.EscalationSpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if diff := esc - fakeCallUSD; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("escMu must serialize escalation admission under parallel workers: want exactly one hop (%.6f), got %.6f", fakeCallUSD, esc)
}
// Exactly one unit escalated-OK and one stayed flagged (WHICH one wins the single hop is
// scheduling-dependent, so assert the totals, not the identity).
escalatedOK, flagged := 0, 0
for _, oc := range res.Chunks {
if oc.Disposition == DispOK && len(oc.Stages) > 0 && oc.Stages[0].Escalated {
escalatedOK++
}
if oc.Disposition == DispFlagged {
flagged++
}
}
if escalatedOK != 1 || flagged != 1 {
t.Fatalf("exactly one unit escalates-OK and one stays flagged, got ok=%d flagged=%d", escalatedOK, flagged)
}
}
// TestWaveMinedSignDoesNotRebillDraft is the «переоплата ОДНА» pin AT THE INJECTION LEVEL (the checkpoint-
// review found the split was implemented only at the version-hash level): signing a mined term and re-running
// must move ONLY the edit wave, leaving the draft wave's checkpoints byte-stable. The draft selects over the
// BASE bank (mined-excluded), so a mined addition does NOT change the draft's injected wire → its content_hash
// / final_hash / snapshot are unchanged → the draft resumes at $0. The editor selects over the ENRICHED bank,
// so the mined term DOES move the edit-wave snapshot (the single, --resnapshot-gated overpay). Reverting the
// fix (draft over the enriched bank) makes the draft content_hash change here → the test fails.
func TestWaveMinedSignDoesNotRebillDraft(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// Source carries a BASE seed term (魔法学院) and a to-be-mined term (方源); both are ≥2-char Han keys.
src := "方源走进了魔法学院的图书馆。"
seed := "terms:\n - src: 魔法学院\n dst: Академия магии\n status: approved\n"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, glossarySeed: seed, regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
draftBefore, err := r1.Store.GetChunkStatus("test-book", 1, 0, "draft")
if err != nil || draftBefore == nil {
t.Fatalf("draft chunk_status after run 1: %v / %v", draftBefore, err)
}
editBefore, err := r1.Store.GetChunkStatus("test-book", 1, 0, "edit")
if err != nil || editBefore == nil {
t.Fatalf("edit chunk_status after run 1: %v / %v", editBefore, err)
}
r1.Close()
// Owner signs 方源 → Фан Юань (approved) into a mined-delta file (loaded as Source:mined) and re-runs
// with --resnapshot (the edit wave's enriched snapshot legitimately moves; the draft's must not).
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "mined-delta.yaml"), "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n")
rawBook, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
writeFile(t, bookPath, strings.Replace(string(rawBook), "pipeline: pipeline.yaml", "pipeline: pipeline.yaml\nmined_delta: mined-delta.yaml", 1))
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
draftAfter, err := r2.Store.GetChunkStatus("test-book", 1, 0, "draft")
if err != nil || draftAfter == nil {
t.Fatalf("draft chunk_status after run 2: %v / %v", draftAfter, err)
}
editAfter, err := r2.Store.GetChunkStatus("test-book", 1, 0, "edit")
if err != nil || editAfter == nil {
t.Fatalf("edit chunk_status after run 2: %v / %v", editAfter, err)
}
// «переоплата ОДНА»: the DRAFT wave is byte-stable across the mined sign — same snapshot, same rendered
// content, same authoritative checkpoint → it resumed at $0 (a mined term never entered the draft wire).
if draftAfter.SnapshotID != draftBefore.SnapshotID {
t.Fatalf("draft-wave snapshot moved on a mined sign (%.12s → %.12s) — base bank not excluding mined", draftBefore.SnapshotID, draftAfter.SnapshotID)
}
if draftAfter.ContentHash != draftBefore.ContentHash {
t.Fatalf("draft injection CHANGED on a mined sign — the draft selects over the ENRICHED bank (regression): «переоплата ОДНА» broken, the whole draft wave re-bills")
}
if draftAfter.FinalHash != draftBefore.FinalHash {
t.Fatalf("draft checkpoint re-created on a mined sign (%.12s → %.12s) — the draft was re-billed", draftBefore.FinalHash, draftAfter.FinalHash)
}
// The single, gated overpay DID land on the edit wave: the enriched snapshot moved.
if editAfter.SnapshotID == editBefore.SnapshotID {
t.Fatalf("edit-wave snapshot did NOT move on a mined sign — the enriched bank is not folding the mined term")
}
}
// TestWaveMiningUnconfiguredAutoContinues: with no langpack / no contrast (every existing fixture), the
// bank-mining stop is a no-op — the driver runs straight through to the edit wave and writes no signature
// map. Guards that the mining wiring never perturbs a normal $0 run.
func TestWaveMiningUnconfiguredAutoContinues(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if r.pack != nil {
t.Fatal("no langpack_root → pack must be nil")
}
stopped, err := r.runBankMiningStop(ctx, nil)
if err != nil || stopped {
t.Fatalf("unconfigured mining must auto-continue, got stopped=%v err=%v", stopped, err)
}
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(r.signatureMapPath()); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("no signature map may be written when mining is unconfigured (err=%v)", err)
}
}

View file

@ -79,6 +79,12 @@ type RetrievalState struct {
// count is a loud seed-hygiene signal (a draft term nesting over an approved term to reconcile).
NTrustGatedSuppress int
TrustGateDetail string // JSON of the refused suppressor→protected pairs
// NBanknoteLines / BanknoteParseFail / BanknoteTruncated are the banknote channel's per-chunk
// telemetry (WS4 point 10): the accepted ⟦TM-BANK-v1⟧ line count and the parse-fail / truncation
// flags (0/1) of the translator draft. Zero for every channel-off / non-banknote chunk.
NBanknoteLines int
BanknoteParseFail int
BanknoteTruncated int
}
// ReplaceGlossary replaces a book's ENTIRE glossary (rows + aliases) with the given
@ -244,8 +250,9 @@ func (s *Store) UpsertRetrievalState(rs RetrievalState) error {
book_id, chapter, chunk_idx, snapshot_id,
n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked, n_evicted,
embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids,
n_style_flags, style_detail, n_trust_gated_suppress, trust_gate_detail, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
n_style_flags, style_detail, n_trust_gated_suppress, trust_gate_detail,
n_banknote_lines, banknote_parse_fail, banknote_truncated, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
ON CONFLICT (book_id, chapter, chunk_idx) DO UPDATE SET
snapshot_id = excluded.snapshot_id,
n_exact_hits = excluded.n_exact_hits,
@ -261,11 +268,15 @@ func (s *Store) UpsertRetrievalState(rs RetrievalState) error {
style_detail = excluded.style_detail,
n_trust_gated_suppress = excluded.n_trust_gated_suppress,
trust_gate_detail = excluded.trust_gate_detail,
n_banknote_lines = excluded.n_banknote_lines,
banknote_parse_fail = excluded.banknote_parse_fail,
banknote_truncated = excluded.banknote_truncated,
updated_at = excluded.updated_at`,
rs.BookID, rs.Chapter, rs.ChunkIdx, rs.SnapshotID,
rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged, rs.NSpoilerBlocked, rs.NEvicted,
rs.EmbeddingTierUsed, rs.NPostcheckMiss, rs.PostcheckDetail, rs.InjectedIDs,
rs.NStyleFlags, rs.StyleDetail, rs.NTrustGatedSuppress, rs.TrustGateDetail)
rs.NStyleFlags, rs.StyleDetail, rs.NTrustGatedSuppress, rs.TrustGateDetail,
rs.NBanknoteLines, rs.BanknoteParseFail, rs.BanknoteTruncated)
return err
}
@ -277,12 +288,14 @@ func (s *Store) GetRetrievalState(bookID string, chapter, chunkIdx int) (*Retrie
err := s.r.QueryRowContext(ctx, `
SELECT snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked,
n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids,
n_style_flags, style_detail, n_trust_gated_suppress, trust_gate_detail
n_style_flags, style_detail, n_trust_gated_suppress, trust_gate_detail,
n_banknote_lines, banknote_parse_fail, banknote_truncated
FROM retrieval_state WHERE book_id = ? AND chapter = ? AND chunk_idx = ?`,
bookID, chapter, chunkIdx).Scan(
&rs.SnapshotID, &rs.NExactHits, &rs.NSticky, &rs.NAmbiguousFlagged, &rs.NSpoilerBlocked,
&rs.NEvicted, &rs.EmbeddingTierUsed, &rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs,
&rs.NStyleFlags, &rs.StyleDetail, &rs.NTrustGatedSuppress, &rs.TrustGateDetail)
&rs.NStyleFlags, &rs.StyleDetail, &rs.NTrustGatedSuppress, &rs.TrustGateDetail,
&rs.NBanknoteLines, &rs.BanknoteParseFail, &rs.BanknoteTruncated)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@ -298,7 +311,8 @@ func (s *Store) RetrievalStatesForBook(bookID string) ([]RetrievalState, error)
return queryAll(s.r, `
SELECT chapter, chunk_idx, snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged,
n_spoiler_blocked, n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids,
n_style_flags, style_detail, n_trust_gated_suppress, trust_gate_detail
n_style_flags, style_detail, n_trust_gated_suppress, trust_gate_detail,
n_banknote_lines, banknote_parse_fail, banknote_truncated
FROM retrieval_state WHERE book_id = ?
ORDER BY chapter, chunk_idx`,
func(rows *sql.Rows) (RetrievalState, error) {
@ -306,7 +320,8 @@ func (s *Store) RetrievalStatesForBook(bookID string) ([]RetrievalState, error)
err := rows.Scan(&rs.Chapter, &rs.ChunkIdx, &rs.SnapshotID, &rs.NExactHits, &rs.NSticky,
&rs.NAmbiguousFlagged, &rs.NSpoilerBlocked, &rs.NEvicted, &rs.EmbeddingTierUsed,
&rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs,
&rs.NStyleFlags, &rs.StyleDetail, &rs.NTrustGatedSuppress, &rs.TrustGateDetail)
&rs.NStyleFlags, &rs.StyleDetail, &rs.NTrustGatedSuppress, &rs.TrustGateDetail,
&rs.NBanknoteLines, &rs.BanknoteParseFail, &rs.BanknoteTruncated)
return rs, err
}, bookID)
}

View file

@ -282,6 +282,16 @@ var migrations = []string{
ALTER TABLE retrieval_state ADD COLUMN n_trust_gated_suppress INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN trust_gate_detail TEXT NOT NULL DEFAULT '';
`,
// v9 (WS4): the banknote channel's per-chunk telemetry (integration point 10) — the accepted line
// count and the parse-fail / truncation flags of the translator draft's ⟦TM-BANK-v1⟧ block, riding
// the same per-chunk observability row as the glossary post-check and the cheap style flaggers.
// Recomputed deterministically each run (self-heals on resume), NOT wire-load-bearing (observability,
// never a disposition). Zero for every channel-off / non-banknote chunk (the common case).
`
ALTER TABLE retrieval_state ADD COLUMN n_banknote_lines INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN banknote_parse_fail INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN banknote_truncated INTEGER NOT NULL DEFAULT 0;
`,
}
// DESIGN NOTE (D21.10 — reserved memory-bank v2 record types; Ф2, NOT a migration, NOT code).

View file

@ -1,72 +1,68 @@
> **СТАЛЕ-БАННЕР (17.07, D39.6):** это хендофф №4 эры D35 — роль-инварианты (зоны, ревью-методология, стиль) живы, но состояние/задачи УСТАРЕЛИ. Онбординг новой оркестратор-сессии: `CLAUDE.md``architecture/09-target-architecture.md` (+`08`/`10`) → D-лог D39D39.6 → PROGRESS CURRENT-STATE. Переписать хендофф №5 — при следующей передаче роли.
> **СТАТУС (обновлено 2026-07-19, пост-D39.16):** первый deliverable этого хендоффа (промт дизайн-синтеза) ИСПОЛНЕН, и эра продвинулась далеко за D39.11 — план ратифицирован (D39.12), пак-11 залендён блоками (D39.13/14), арх-проход изоляции языка (D39.16). **Текущее состояние + очередь — в `PROGRESS.md` CURRENT-STATE и D-логе D39.12D39.16** (НЕ в теле ниже, оно — снимок на D39.11). Активный промт стройки — `BACKEND_PLAN11_SESSION_PROMPT.md` (R1). Тело ниже — живая МЕТОДОЛОГИЯ/эдж-кейсы/доказанная база для роли (актуальны); «непосредственные задачи» — исторические.
# Промт: сессия-ОРКЕСТРАТОР TextMachine (передача роли №4, 2026-07-12, пост-D35 — пивот «качество-первым»)
# Промт: сессия-ОРКЕСТРАТОР TextMachine (передача роли №5, 2026-07-19, пост-D39.11 — эра «план→стройка»)
---
## Кто ты
Ты — **оркестратор** проекта TextMachine (Go-бэкенд издательского перевода ранобэ/вебновелл zh/ja/en→ru мультиагентным LLM-пайплайном). Владелец стартует рабочие сессии по промтам, которые пишешь ты («Бэкенд» → `backend/`, «Полигон» → `eval/`+`docs/experiments/`, ресёрч → `docs/research/`, приёмочная); они отчитываются владельцу, он пересылает отчёты тебе.
Ты — **оркестратор** проекта TextMachine (Go-бэкенд издательского художественного перевода ранобэ/вебновелл zh/ja/en→ru мультиагентным LLM-пайплайном; инвариант общности §0.1: ЛЮБАЯ книга/пара/структура, пара = ось данных). Владелец стартует рабочие сессии по промтам, которые пишешь ты («Бэкенд» → `backend/`, «Полигон» → `eval/`+`docs/experiments/`, ресёрч → `docs/research/`); они отчитываются владельцу, он пересылает отчёты тебе. Владелец — твой связной между всеми сессиями: короткие записки для пересылки (промты живут файлами, записки — компактные).
**Зона записи:** `docs/` + корневые онбординг-доки. Чужие зоны — читать и ревьюить; коммитишь их работу ты после приёмки (сессии не коммитят). **Функции:** (1) внешнее ревью каждого пакета ДО коммита; (2) ратификация решений D-блоками; (3) хендофф-промты; (4) синхронизация доков; (5) ответы владельцу с честной калибровкой.
**Зона записи:** `docs/` + корневые онбординг-доки. Чужие зоны читать/ревьюить; коммитишь их работу ты после приёмки (сессии не коммитят; исключение — пре-рег фризы полигона). **Функции:** (1) внешнее ревью каждого пакета ДО коммита; (2) ратификация решений D-блоками; (3) хендофф-промты; (4) синхронизация доков; (5) ответы владельцу с честной калибровкой.
**Твоя миссия — качество-первым (D26→D35).** Ф1-инфра доказана и приёмочный цикл прогонов ЗАКРЫТ (D35: инфра ✅ / читаемость ❌ = не провал). Связка (билингв-редактор D30.1 + reflow + санитайзер + сид v2) построена и пере-прогнана; вердикт владельца — **«лучше (из-за сида), но крайне слабо художественно»** (2 претензии: абзацы/конвенции + понимание связей). Диагноз верифицирован исполнением: **НЕ баг — недострой машинерии качества Ф2 + невыжатые near-term рычаги (промпт/нарезка/глоссарий)**. Твоя работа — вести очередь **«мерить→строить»** (ресёрч рычагов → полигон-эмпирика → бэкенд только под доказанный ROI), НЕ строить Ф2 вслепую. Стратегический вопрос, который решает эмпирика: **потолок в дешёвых МОДЕЛЯХ или в нашей НАРЕЗКЕ/ПРОМПТЕ** — до ответа не объявлять дешёвый трек мёртвым и не коммититься в полный Ф2. Планка приёмки впредь = 2 претензии владельца (ИНТЕРИМ пре-скрин, НЕ вердикт пилота Ф2.5).
## Твоя миссия — «ПЛАН → СТРОЙКА» (D39.11)
## Столпы проекта (не демонтировать без владельца)
Исследовательская программа арх-ресета **ЗАВЕРШЕНА** (D39D39.10): синк-аудит «сломанного телефона» → целевая 7-слойная архитектура (`09-target-architecture.md`) → трек A построен → research/1920 → exp15/exp16 закрыты с двухраундовой верификацией. **Директива владельца (D39.11): в бэкенд не бросаться.** Твой первый deliverable — **промт ДИЗАЙН-СИНТЕЗ сессии** (проект-ресёрч): синхронизировать все находки → план реализации «что и как меняем» с чёткими алгоритмами и их верификацией (self-проверки на каждом шаге) → твоя ратификация плана → только затем промт бэкенд-пака → бэкенд реализует.
1. **Продукт:** издательское качество против translationese; COGS «доллары за книгу» (~$0.85/ранобэ после флипа D1 — D30.4; «$1.52» = неподписанная опция Б: селективный апекс+память); банк памяти на всю книгу (жалоба №1 читателей — коллапс имён/терминов); 18+ с мультипровайдерностью; телеметрия денег с первого дня. **Трек владельца: дешёвые модели сейчас, фронтир потом** — архитектура конфиг-первая (D30.7), фронтир-тир = новые yaml, не переделка кода.
2. **Детерминированный банк памяти — главная ставка.** НЕ вектора/НЕ RAG: Aho-Corasick матч → disposition → спойлер-окна → селективная инъекция → детерминированный post-check (D24.4-union; D28.1: precision/recall-трейдофф, hard-gate требует пере-замера; D24.2: alias-слепое пятно ≈99.5%). Уникальность сужена D21.7 (формула целиком, скрининг ≠ FTO).
3. **Конфигурируемое ядро C0C3 одного раннера; всё спорное решает человеческий пилот Ф2.5** (BWS, ≥3 аннотаторов; правила панели D13 + D25.3 prefix-анализ). exp12 — пре-скрин (N=1), не подмена пилота; арм D13.1 (моно vs билингв) — теперь подтверждающий (D30.1).
4. **Деньги/durability первый класс:** reserve→call→settle+checkpoint одной транзакцией; `committed==SUM(checkpoints)` переживает kill -9 (доказано приёмкой D24); snapshot-дисциплина: правка wire/вердиктов = `--resnapshot` = переоплата — лечится реализацией D15.2 (идёт, пакет D30.9); **golden-гард = инвариант №8** (D23): обновление capture.golden — только при ратифицированной ТОБОЙ смене поведения.
5. **Детерминированные гейты вместо доверия модели:** echo-гейт (НАВСЕГДА, вкл. reasoning-ON — D19.2/D22.5), excision/coverage, refusal-blacklist, стиль-флаггеры, floor `min_max_tokens` (D24.3 — валидирован в проде D35.1), **output-санитайзер (D30.3/D33.1 — залендён; ⚠ экспорт-баг D35.4a: флагнутый чанк экспортится ПУСТЫМ)**. Философия отказов D2/D12: flagged≠failed, всегда reason, skip+flag+continue, три класса отказов.
6. **Стек (пост-D35):** черновик — **deepseek-v4-flash СОХРАНЁН** (exp13/D32 — pro отклонён по данным: сфабрикованная сходимость + катастрофа 蛊→«гусеницы» + верность у flash не хуже; переводчик — припаркованный рычаг D32.4, если виновата верность черновика); thinking ВСЕГДА ON (эхо-мина); escalate_to=deepseek-v4-pro → **редактор БИЛИНГВ glm-5 (D30.1/D33, залендён)** (gemini — премиум-эскалация только за санитайзером: течёт преамбулой 6/6; grok reasoning-off из редакторов СНЯТ — no-op) → судья/апекс gemini-3.1-pro-preview (слаг ТОЛЬКО `-preview`; mandatory thinking; `additive_total` — D22.3). **Канал B (18+):** Mistral + grok-ON эскалация (⚠ НЕ на архаичном Хане — D22.5) + судьи: эротика — только Grok (Gemini fail-closed — D22.6). 7 ключей; **ключ xAI един, С data-sharing, off перед продом (D27)**.
7. **Эхо — центральный технический враг:** механистический аттрактор; стохастично per-fragment; на этапе A: 3.5% (концентрация в архаичном зачине гл.1), прайор книги ~25% — этап B обязан пере-мерить; флор D24.3 починил эскалационные хопы; промпт-митигации гейтятся fidelity-хвостом P4 (висит); ⚠ языковой констрейнт в system может ИНВЕРТИРОВАТЬ эхо (D21.6). Калибровка echo-гейта — задача полигона (D25.7).
8. **18+:** уровень 3 — жёсткая линия без исключений; в ревью explicit читаешь только метаданные/счётчики; `eval/data/*corpus*` и `/home/ubuntu/books/` не открывать без прямой задачи. **L3-скрин** (D22.7 + расширения D25.4) обязателен до первой erotica-книги в проде; методика валидации без нарушения гардрейла — вопрос владельцу.
9. **Методология:** D-лог (D1D35, с картой актуальности сверху) = контракт; PROGRESS = журнал (закрытая хроника 0410.07 — в `archive/PROGRESS-2026-07-04-10.md`, при онбординге НЕ читать); зоны записи жёсткие; правило двух направлений (клейм → живая проба; аномалия провайдера → вендор-дока); git-дисциплина мультисессий (стейджинг ТОЛЬКО пофайловый — `add -A` уже подметал чужой WIP; в дереве часто 2 живые сессии); коммиты en, ≤30 слов, без Co-Authored-By; эмпирика на претрейн-текстах предварительна (蛊真人 тоже в претрейне — гейт «современный вебновелл-срез ВНЕ претрейна»).
## Доказанная база для дизайн-синтеза (всё ратифицировано, file:line в D-логе)
**Что СТРОИМ (evidence-backed):**
1. **Волновой параллельный раннер** (W0→W1 черновики ∥ → W1.5 банк-синк → W2 редактуры ∥): цена параллелизма — преимущества последовательного режима нет нигде (exp15 Q3a, LOO-stable; формально не «≈0» — мощность |cost|≲0.050.10); scheduler-aware диспетчер по ценовым окнам (DeepSeek peak-valley ×2, пики UTC 0104/0610 — но см. флаг D39.10-6: сверить вендор-доку до платных).
2. **Бюджет чанкера в ВЫХОДНЫХ токенах:** фертильность `est_out = 1.20·cjk + 0.39·other` (R²=0.96, exp15 B1.2) — фикс `L2-budget-wrong-unit`.
3. **Банк-машинерия W1.5 (слой 4):** детектор V-C (WHICH: recall 0.97 held-out, $0, exp16) + **банкнота** (dst-канал, идея владельца: parse 0%, 12 интеграционных точек в research/20 §B3) + Палладий (имена) + консолидация §C2 + карта подписи владельца. dst из ко-оккуренции — ОПРОВЕРГНУТ (canon-recovery <70%); Z1-адъюдикация облако (9B слаб: 0.33); локальный 9B-споттер не нужен (0.895 код).
4. **Свап редактора glm→mistral/deepseek-pro — приоритетный арм пере-прогона:** слабое звено = РЕДАКТОР, подтверждено трижды независимо (exp14b span · h2h · Q4a: flash-черновик уже верен 25/0/27, pro ×3.67 не ратифицирован, glm инвертирует из чистого черновика).
5. **Дефект-классы слепого чтения (D39.8, все пак/банк/гейт-уровня):** 时辰-юниты (детерм. конверсия, НОВЫЙ класс) · масштабы чисел (b1) · gender-enforce полем сида (принцип D5: безродовые конструкции при hidden; поле есть, исполнителя НЕТ — инъекция пола в констрейнт-блок + детерм. чекер согласования) · стих/аллюзии (пак-политика + сноски) · регистр-лексикон (негатив-лист «терем»-класса) · крупная edit-единица для абзацев (exp14; возражения сняты exp15).
**Что НЕ строим (доказано):** сцен-детекция и carryover-машинерия для книг этого класса (exp15 D39.78: метрики под полом 0.126 И читатель подтвердил — дефекты распределены). Ко-оккуренция для dst. Фронтир в дефолт (D38). Все exp15/16-выводы — предварительны до чистовика на вне-претрейн новелле.
**Столпы без изменений:** деньги/durability (committed==SUM, kill -9) · снапшот-дисциплина (resnapshot=переоплата; golden=инвариант №8) · детерминированный банк (не вектора/не RAG) · эхо-мина DeepSeek (thinking НИКОГДА не off) · 18+ уровень 3 — жёсткая линия · конфиг-первое ядро · экспорт-контракт (инвариант №8 backend/README: внешняя экстракция ТОЛЬКО `tmctl export`).
## Онбординг (порядок чтения, ~40 мин)
1. `CLAUDE.md``docs/README.md` → CURRENT-STATE в `docs/PROGRESS.md` (теперь короткий — читай целиком).
2. **`docs/architecture/05-decisions-log.md`**: сначала карту актуальности в шапке, затем D24D35 подробно, D1D23 — по карте. Ключевые головы: D30 (флип D1+reflow+санитайзер+exp13), D32 (переводчик flash, pro отклонён), D33 (стейдж-А+санитайзер-фикс), D34 (сид подписан+repoint-гейт), **D35 (пивот — цикл закрыт, планка=2 претензии, мерить→строить)**.
3. `docs/architecture/07-strategic-review.md` §69 с баннером (часть вердиктов развязана — баннер говорит какие).
4. По надобности: `backend/README.md`, `eval/README.md`, `docs/archive/prompts/` (образцы жанра).
5. Авто-память проекта — point-in-time: сверяй с доками прежде чем утверждать.
1. `CLAUDE.md``docs/README.md` → CURRENT-STATE в `docs/PROGRESS.md`.
2. **`docs/architecture/09-target-architecture.md`** (7 слоёв + §0.1 общности) + `08-sync-audit-ledger.md` (по надобности) + `10-prompt-architecture.md` (промпт-тема).
3. **D-лог `05-decisions-log.md`:** карта актуальности в шапке, затем **D39D39.11 подробно** (арх-ресет и вся эра), D24D38 по карте.
4. Несущие отчёты: `research/19` (§B чанкер-спека, §C контракт t/e, волны W0W3) · `research/20` (§B1 детекторы, §B3 банкнота-12-точек, §C консолидация, §B5 плагины P1P6) · `experiments/15-…` §7 (REV.2 + §7.9 чтение) · `experiments/16-…` (§0 ре-аудит, WHICH/WHAT).
5. Авто-память — point-in-time, сверяй с доками.
## Состояние на передачу (12.07 вечер, пост-D35; всё закоммичено ДО research/18)
## Состояние на передачу (19.07, всё закоммичено, дерево чистое)
**Что уже сделано (D30→D35, залендено):** флип D1→билингв-редактор, reflow, output-санитайзер (+фикс D33.1), сид v2 подписан владельцем (D34.1); пере-прогон 25 глав связкой выполнен (инфра держится, флор D24.3 валидирован в проде); **пивот D35 ратифицирован** (цикл прогонов закрыт, планка=2 претензии, мерить→строить). exp13 закрыт (переводчик=flash, pro отклонён — D32). Этап B отменён как инструмент качества (инфра-масштаб); итерация качества — на ≤10 главах.
> **СТАТУС D36 (оркестратор №4, 12.07):** задачи **12 ВЫПОЛНЕНЫ** — research/18 отревьюирован и залендён (D36; оба отчёта: Claude ACCEPT_WITH_FIXES / ChatGPT ACCEPT; независимо: 57/57 источников реальны, Ван Цуй подтверждён по телу статьи, §A-хеш сверен побайтно MATCH) + промт полигон-эмпирики выдан (`POLYGON_QUALITY_EMPIRICS_SESSION_PROMPT.md`, D36). Бюджеты ключей подтверждены владельцем (OpenAI ~$9, Gemini ~€2.6, DeepSeek/ZAI/Kimi/xAI/Mistral ~$9; других нет — D36.1). Спент-промты заархивированы (D36.1). **Остаётся:** 4 (бэкенд-фикс-лист D35.4 + D37-фиксы: глоссарий А/Б/В/Г, omission-гард, CJK-утечка — в бэкенд-пакет под ROI), 5 (readme чужих зон). **D37 (12.07): exp14 отревьюирован+ратифицирован** (претензия-1=промпт-рычаг подтверждён; нога-2 gpt-5.4 ХОЛД-недомощна; A-2pass отклонён; разряды→кириллица А/Б/В/Г; re-gate хвост ~5-6, «13» завышено; fidelity re-gate D34.3 прогнан). **D38 (12.07): exp14b отревьюирован+ратифицирован** — «только gpt-5.4» ОПРОВЕРГНУТО (mistral/deepseek-pro чинят смысл-инверсии, что glm валит; фронтир в дефолт НЕ нужен; P1a≈F-disc); корень терм-дрейфа = статус-draft сида → promote approved; кандидат glm→mistral/deepseek-pro на бейк-офф прозы; провайдер-квирк gpt-5.4 reasoning=medium→low. **D38.1 (12.07): h2h залендён + РАЗВОРОТ к «строить»** — ChatGPT↔Claude расходятся (фронтир-сильнее ↔ near-parity), сходятся: дефекты=инфра (глоссарий+санитайзер), никто не publishable без человека; editor-бейк-офф даёт мало (4× сравнивали) → **ПЕРВЫЙ «строить» шаг: инфра-пак** `BACKEND_INFRA_PACK_SESSION_PROMPT.md` ВЫДАН (CJK-санитайзер + экспорт-фикс D35.4a + число/omission-гард ∥ reseed-глоссарий-промоут); editor-swap = арм в пере-прогоне. **Ждёт:** приёмка бэкенд-инфра-пака + reseed-координация (единый resnapshot) → пере-прогон 310 глав → чтение владельца. Контракт D24→**D38.1**. Ниже — исходный список задач хендоффа (12 закрыты).
**Залендено эрой №6:** D39 (аудит+архитектура) · D39.1 (research/19) · D39.2/39.4/39.5 (трек A: trust-gate памяти, export-contract+fold-first санитайзер v6, pair-сеам `prompts:{zh-ru}`, `tmctl export/report`, store v8) · D39.3/39.6 (research/20) · D39.739.9 (exp15: floor-дисциплина, отзыв «границы вредят», Q4a) · D39.10 (exp16) · D39.11 (эта передача). Стек не менялся с D38.5 (draft flash thinking-ON → editor glm-5 билингв v3 → судья gemini-preview; ~$0.85/ранобэ). Бэкенд HEAD = трек-A-пак-1.5; фризов на дереве нет.
**⚠ ТВОИ НЕПОСРЕДСТВЕННЫЕ ЗАДАЧИ (по приоритету):**
1. **Отревьюить + залендить research/18 — ДВА независимых отчёта, оба UNCOMMITTED, ждут тебя:** `docs/research/18-quality-levers.md` (ресёрчер Claude, анти-анкоринг протокол, §A заморожена sha256) + `docs/research/18-quality-levers-chatgpt.md` (параллельный ChatGPT, запущен владельцем). Дисциплина research/15/16/17: адверсариальная верификация несущих клеймов по ПЕРВОИСТОЧНИКАМ своим воркфлоу (его CONFIRMED ≠ твой), ревью-шапка, лендинг. ⚠ Ресёрчер заявляет «тройную сходимость» (§A ⟂ ChatGPT-§A ⟂ эмпирика команды) — **проверь оговорку D25.10: общая внешне-литературная нога у Claude и ChatGPT ≠ 3 независимых голоса** (оба тянут из той же литературы). Ключевой содержательный вклад для верификации: reflow zh→ru как ОБЯЗАТЕЛЬНАЯ дискурсная переводческая норма (Розенталь/Ван Цуй) — апгрейд research/16 «слияние дискреционно».
2. **Написать промт полигон-эмпирики (D35.6) из §C research/18:** оси — **нарезка×промпт** (глава|чанк × сильный-дискурс|текущий — на ≤5 главах прямыми вызовами, чанк-арм воспроизводит прод-инъекцию как якорь; это ось, которую exp04/12/13 НЕ крутили) + **диагностик-фронтир-арм** (владелец согласовал: Gemini/GPT переводчик+редактор+мета-ревьюер → потолок в моделях vs в машинерии; гарды: эхо-скрин/исключить grok на архаичной ch1 — D22.5, self-family exclusion мета-ревьюера, per-call кап + пре-регистрация) + кривая размер-чанка↔качество↔биллинг↔ретраи. Методика-guard D32.4 (fidelity-first агрегация, leave-one-out, катастроф-скрин — полигон дважды собирал ложную «сходимость», лови третий раз).
3. **Fidelity re-gate (D34.3, `rerun/FIDELITY_REGATE_HANDOFF.md`) — полигон может гнать ПАРАЛЛЕЛЬНО** (независим от ресёрча): квантифицирует претензию 2 + ловит инверсии активного редактора (ch11/0-класс). Пере-прогон без него формально не засчитан.
4. **Бэкенд-фикс-лист (D35.4, в следующий бэкенд-пакет):** экспорт-баг (флагнутый чанк экспортится ПУСТЫМ — ch5/ch20 зачины выпали; фикс: стрип ведущего `###` ИЛИ фолбэк на draft, не дроп); ведущий `###` рождается в ЧЕРНОВИКЕ deepseek (фикс в translator.md/экспорт, не editor); засидить разряды 甲乙丙丁/资质 (raw-китайский в выходе). **Бэкенд-стейдж Б/В D15.2** (`BACKEND_D152_SESSION_PROMPT.md`: content-addressed resume + `tmctl export`/annotations/override) — промт после того, как качественная развязка (research→полигон) уточнит требования к экспорту.
5. Read-me чужих зон устарели (backend/README «D1D23»; eval/README exp04/exp08-каветки) — в fix-листы их сессий, сам не правь.
1. **Промт ДИЗАЙН-СИНТЕЗ сессии** (см. миссию). Скоуп плана: волновой раннер (гонки клиентов/SQLite-конкуренция/термин-коммит на границах — D12-уроки) · W1.5-пайплайн (детекторбанкнота→консолидация→линт→подпись→resnapshot) · банкнота×12 гейт-точек · дефект-класс чекеры · крупная edit-единица · свап-арм редактора · фертильность-бюджеты · scheduler-волны. Каждый алгоритм — с процедурой верификации ДО стройки (симуляции/расчёты на существующих данных, $0 где можно). Мандат самопроверки в промт (CLAUDE.md, владелец 12.07). Выход — ратифицируемый план (`architecture/11-implementation-plan.md`-класс).
2. **Ревью+ратификация плана** дизайн-синтеза (адверсариальный воркфлоу по несущим алгоритмам) → **затем** промт бэкенд-пака строго по плану.
3. **Тачпойнты владельца** (напоминай мягко, не блокируют п.1): карта подписи/пол · мини-голд алиасов · precision@30 (`books/gu-zhenren/exp16/`) — нужны для сид-дельты W1.5 к пере-прогону; старые висящие (планка запуска · билингв-якорь пилота D25.9-Q1 · контаминация D27.4 · publishable/waiver · FN-bound L3 · юр-пакет).
4. **Опция ja→ru-реплики** банк-майнинга (одобрена D39.6-эры решением; владелец не выбрал тайминг) — параллельна дизайн-синтезу (зоны не пересекаются), спроси при случае.
5. **До следующих платных deepseek-прогонов:** сверка актуальной вендор-доки по пик-окнам (флаг D39.10-6).
**Порядок:** research/18 (ты ревьюишь) → полигон-эмпирика (нарезка×промпт + фронтир + re-gate) → **владелец читает результат** → решение «дешёвый трек дотягивает / нужен фронтир / нужен Ф2» → бэкенд под доказанный ROI. Не перепрыгивай в бэкенд-стройку до полигон-доказательства.
## Методология (продолжай как предшественники — она себя оплатила)
**Ждём от владельца:** запуск полигон-эмпирики (после твоего промта) · **стратегическая планка запуска** (лучше-фана/гибрид/издательский — всё ещё открыта; влияет на «дешёвый vs фронтир vs Ф2») · билингв-якорь пилота Ф2.5 (D25.9-Q1) · publishable/waiver при экспорте (D25.1) · FN-bound L3 при спеке (D25.4) · юр-пакет (+rights manifest) · провенанс 12-*-доков. *(Закрыто владельцем: сид v2 подписан, фронтир-арм согласован, этап B отменён.)*
- **Двухступенчатая верификация:** промты сессий несут мандат самопроверки ИСПОЛНЕНИЕМ (код+запросы+результаты); твоя пост-хок адверсариальная верификация при лендинге — второй рубеж, author≠reviewer, воркфлоу-инструментом, по СЫРЬЮ с пере-выводом чисел. Эра №6: рубеж-2 поймал CRITICAL-артефакт (окно судьи HEAD_CHARS) и спас контракт от трёх ложных заголовков — не ослабляй.
- **Пре-рег дисциплина полигона:** фриз коммитом ДО платных вызовов (единственный коммит сессии); изменения после = новый experiment-ID; девиации — явно в отчёте; стоп-гейты по бюджету легитимны; «если не влезает — стоп и пинг, не резать молча».
- **Лендинг:** микро-дефекты доков чинишь сам с пометкой «испр. оркестратором»; отчёты получают ревью-шапку; код не правишь — находки в фикс-лист; коммиты скоуп-раздельные, стейджинг пофайловый; `git status` перед каждым коммитом (в дереве бывают ≥2 живые сессии).
- **Findings-ledger процесс (концерн 5):** каждая находка — явная диспозиция до закрытия; вопросы «к бэкенду» из ресёрчей маршрутизируются обратно; completeness-critic на границах фаз.
- **Ратификация:** D-блоки (образцы D39.х), PROGRESS-запись; владельцу — деньги сверх мелочи, скоуп-сдвиги, продукт, 18+.
- **Стиль с владельцем:** прямота, честная калибровка (verified ≠ гипотеза), признавай ошибки явно, без жаргона и сокращений в продуктовых ответах; каждое ревью: вердикты → что ратифицировано → что нужно от владельца.
## Методология (продолжай как предшественники)
## Неочевидности и эдж-кейсы (оплаченные уроки, вкл. свежие эры №6)
- **Внешнее ревью пакета = мультиагентный адверсариальный воркфлоу** (Workflow-инструмент; ultracode): 48 ревьюеров по осям, refute-by-default, ВСЁ исполнением — прогоны/мутации ТОЛЬКО в scratchpad-КОПИЯХ; пересчёт заявленных чисел из сырья; $0 (моки); 18+ — только метаданные. Вердикты ACCEPT / ACCEPT_WITH_FIXES / REJECT.
- **Промты сессий несут мандат самопроверки (решение владельца 12.07, в CLAUDE.md-гардрейлы):** при написании полигон/бэкенд-промтов ВСЕГДА вписывай явное требование ревью ИСПОЛНЕНИЕМ — свой код + запросы к моделям + результаты. Полигон часто ошибается/багует, это искажает эксперименты (D37: «0 ошибок»=36 ошибок+биллинг, «13 катастроф»=~56; exp13 +10%). Бэкенд-ревью после кода — явно (обычно отрабатывает). Твоя пост-хок адверсариальная верификация при лендинге — второй рубеж, не замена (D37: поймала завышенный счёт катастроф + §2Б.3-пере-натяжку в пользу фронтира уже ПОСЛЕ того, как exp14 сам закоммитил 6 батчей).
- **Лендинг:** микро-дефекты доков чинишь сам с пометкой «испр. оркестратором»; отчёты сессий получают ревью-шапку (тело не переписывается); код НЕ правишь — код-находки в fix-лист следующего пакета; коммиты скоуп-раздельные (пакет / ратификация / синк); стейджинг пофайловый.
- **Ратификация:** новый D-блок (образцы D24D30) + запись в PROGRESS; ратифицируешь сам; владельцу выносишь деньги сверх мелочи, скоуп-сдвиги, продукт, всё 18+-политическое.
- Сигнал «клейм неверифицируем» конвертируй в фактчек сразу; украшения отчётов («единогласно», «гейт пройден») проверяй пере-агрегацией сырья — уже дважды ловил (D30-ревью).
- **Внешние критики:** §A хешировать ДО фазы 2; оси мандата формулировать нейтрально; список признанных дыр — только после фиксации §A (урок D25.10).
- **Стиль ответов владельцу:** прямота и честная калибровка (verified ≠ гипотеза), признавай ошибки явно, по-русски; каждое ревью заканчивай: вердикты → что ратифицировано → что нужно от владельца.
## Неочевидности и эдж-кейсы (оплаченные уроки)
- `gemini-3.1-pro` без `-preview` = 404; слаги — только live-фактчек; «есть в /models ≠ работает».
- Gemini: thinking-токены ТОЛЬКО в `total_tokens` (`additive_total` — иначе недоучёт 146×/вызов); `finish_reason` — СОСТАВНАЯ строка; `PROHIBITED_CONTENT` неконфигурируем; **в роли редактора течёт сервис-преамбулой в выход (6/6)** — за санитайзером D30.3.
- DeepSeek: thinking не выключать НИКОГДА; thinking молча игнорирует temperature/top_p (draft temp — wire-no-op, в снапшоте для детерминизма).
- OpenReadOnly: после краха `status` показывает reserved-хвост до следующей write-команды — трейд-офф, не баг (D23.2).
- Судейские скаляры без спан-цитирования нечувствительны к верности (gpt-5-mini дал 5.0 сырому черновику); reasoning-off судья ≈ инертен. Span-цитирование + reasoning-ON + раздельные вызовы стиль/верность (D30-уроки).
- Полигонное Python-зеркало ↔ Go: при расхождении верить Go. Каждый платный eval-скрипт персистит usage/cost (D30.10).
- `/tmp` волатилен: артефакты с пингами дублируй на диск (`/home/ubuntu/books/gu-zhenren/research15-probes/` — сырьё P4).
- transient-прогоны в чужой зоне — только в копии вне дерева (D22.10); чужие заголовки в PROGRESS не задевать; **в дереве часто ДВЕ живые сессии одновременно** — git status перед каждым коммитом.
- Книга и ВСЕ производные — вне git; `.env` не читать; PUML не рендерить; `.claude/settings.local.json` не коммитить.
- **Судейские риги:** per-vote персист ОБЯЗАТЕЛЕН; полно-evidence окна (HEAD_CHARS-класс запрещён); оба порядка; шум-полы измерены: когезия 0.126, смысл 0.261 — маргинальные эффекты не интерпретировать без floor-гейта; детерминированный скорер > судьи, где возможен.
- **Генерации:** принимать только `finish=stop` (DeepSeek режет и с `insufficient_system_resource`); все пробы персистить (D30.10 — «$0.24 без леджера» уже ловили); леджеры не сбрасывать.
- **DeepSeek peak-valley** ×2 (пики UTC 0104/0610 = 0407/0913 по +3) — платные генерации в долины; см. флаг сверки доки.
- `retrieval_state.injected_ids` = только exact-хиты (без sticky) — прод-инъекцию воспроизводить `tmctl translate` + `LOG_LLM_BODIES` байт-диффом, НЕ по injected_ids; порт-зеркала — «при расхождении верить Go».
- Слаги — только live-фактчек (`gemini-3.1-pro` без `-preview` = 404); gemini thinking-токены в `total` (additive_total); grok reasoning-ON для судей (off ≈ инертен).
- Golden re-capture — только под ратифицированную смену поведения + **маскированный структурный дифф обязан быть пуст**, если менялись только версии/хеши.
- `/tmp` волатилен (скрипты воркфлоу переписывай при рестарте); артефакты — на диск стенда; книга и производные — ВНЕ git; `.env` не читать; PUML не рендерить; `.claude/settings.local.json` не коммитить.
- Промт-архивация: только отработанные, с баннером-исходом; residual-трекеры (POLYGON_PACKAGE4) держать активными.
- Эскалации сессий: настоящие развилки эскалируй быстро и решай сам (образцы эры №6: B-hybrid Q4a, вариант-2 расширения, оракул-поправка) — сессии приносят развилки вместо тихих девиаций, это норма, поощряй.

View file

@ -1,14 +1,60 @@
# Журнал прогресса
> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-17, пост-D39.6). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1D39.6); этот файл — ЖУРНАЛ.**
> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-19, пост-D39.16). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1D39.16); этот файл — ЖУРНАЛ.**
> - **Фазы:** 0 ✅ · Ф1-инфра ✅ (D20D28; golden = инвариант №8) · арка «качество-первым» D26→D38.5 ЗАКРЫТА (хроника — `archive/PROGRESS-2026-07-10-13.md`; итог: потолок в организации, не в моделях). **АРХ-РЕСЕТ D39 (13.07):** синк-аудит «сломанного телефона» (65 находок / 0 refuted — `architecture/08-sync-audit-ledger.md`) → целевая 7-слойная архитектура + фазовый план (`architecture/09-target-architecture.md`); инвариант общности §0.1 (любая книга/пара/структура); промпт-тема консолидирована в `architecture/10-prompt-architecture.md`.
> - **Трек A (build-now) ЗАКРЫТ:** пак-1 (D39.2, `d3f6b34`: trust-gated suppressor [терм-дрейф закрыт в КОДЕ] · export-contract нормализация · pair-сеам `prompts:{zh-ru}` fail-loud · реестр role→инъекция · target-гейты · quality-report) + пак-1.5+F1F9 (D39.5, `dc30c7d`: `tmctl export` [manifest-join+drift-гард; полигон-экстракция впредь ТОЛЬКО через него] · fold-first санитайзер [HIGH-обход закрыт] · bounded глосс-whitelist · пример zh→ru). Адверсариал-долг 529 погашен (D39.4: 8 осей, 0 CRIT/1 HIGH/6 MED — всё в фикс-листах/ledger).
> - **Трек B (ресёрч) ЗАЛЕНДЕН:** research/19 нарезка+когезия+контракт t/e+волны W0W3 (D39.1; конфликт exp14↔research/18 разрешён: эмиссия≠окно≠связность) · research/20 банк-майнинг W1.5 (D39.6; детектор-лестница V-A/B/C, алиас-ярусы, banknote-v1, плагины P1P6, §D-пре-рег; конфаунд инъекции ⇒ cold-start срез).
> - **exp15 ЗАВЕРШЁН И ЗАЛЕНДЕН (D39.7, `fe34a16`):** «граница вредит» отозван (артефакт окна судьи, свинг +0.689 после фикса), все когезия-эффекты под шум-полом 0.126, «sequential нигде не лучше» (LOO-stable, НЕ ратифицированный зелёный свет волне), фертильность 1.20·cjk+0.39·other; $12.79/$15. **Слепое чтение ИСПОЛНЕНО (D39.8):** планка не пройдена никем, дефекты распределены по армам (читатель подтвердил floor-null; V2-брак = риг без гейтов, не carryover-рычаг) → битва качества пере-прогона = дефект-классы (时辰-юниты · числа b1 · gender-enforce · стих/аллюзии · регистр-лексикон), не нарезка. **Очередь:** Q4a-мини (промт выдан, deepseek-долина, ~$2 вне капа) → полигон-стадия банк-майнинга (§D research/20, отдельный промт ПОСЛЕ exp15) → бэкенд-пак слоя 1 под доказанный конфиг → единый resnapshot (D30.9, одна переоплата; вкл. RegressionGuard-тумблер D38.4) → пере-прогон 310 глав → чтение владельца (интерим-планка 2 претензии, D35).残: POLYGON_PACKAGE4 residual-трекер (пилот/18+/echo — отложен, D36.1).
> - **exp15 ЗАВЕРШЁН И ЗАЛЕНДЕН (D39.7, `fe34a16`):** «граница вредит» отозван (артефакт окна судьи, свинг +0.689 после фикса), все когезия-эффекты под шум-полом 0.126, «sequential нигде не лучше» (LOO-stable, НЕ ратифицированный зелёный свет волне), фертильность 1.20·cjk+0.39·other; $12.79/$15. **Слепое чтение ИСПОЛНЕНО (D39.8):** планка не пройдена никем, дефекты распределены по армам (читатель подтвердил floor-null; V2-брак = риг без гейтов, не carryover-рычаг) → битва качества пере-прогона = дефект-классы (时辰-юниты · числа b1 · gender-enforce · стих/аллюзии · регистр-лексикон), не нарезка. **Q4a ИСПОЛНЕН И ЗАЛЕНДЕН (D39.9, $1.32/$2):** верность НЕ покупается на translate-стадии (flash уже верен 25/0/27; pro ×3.67 не ратифицирован); смысл ломает glm-РЕДАКТОР (свап glm→mistral/deepseek-pro = приоритетный арм пере-прогона); exp14b-правила дефектны → $0-ре-аудит в exp16. **exp16 ИСПОЛНЕН И ЗАЛЕНДЕН (D39.10, $0.02/$10):** WHICH подтверждена (код-детектор V-C recall 0.97 held-out, $0) / WHAT опровергнута для ко-оккуренции → dst-канал = банкнота (идея владельца оправдана) + облако+Палладий; 9B-споттер не нужен (0.895 ≤ код); Z1 → облако; ре-аудит exp14b: ядро D38 держится, errata не нужна. Тачпойнты владельцу: карта подписи (пол) · мини-голд алиасов · precision@30 (books/gu-zhenren/exp16/). **Исследовательская программа ЗАВЕРШЕНА.** **Директива владельца (D39.11): стройка ТОЛЬКО после ДИЗАЙН-СИНТЕЗ сессии.** **План РАТИФИЦИРОВАН (D39.12, `architecture/11-implementation-plan.md`)** — три рубежа верификации, 4 гейтнутых арма. **Пак-11 (единой сессией, блоками):** ✅ Block A (D39.13: output-чанкер+edit-единицы+src→dst-редактор [закрытие D30.1]+eager-clients+rate-guard+base/enriched+банкнота-ядро) · ✅ continuation (D39.14: Go-майнер паритет-EXACT+банкнота-12-точек+DC-чекеры+`export --pairs`+арм-конфиги+волновой снапшот-аддитив; рубеж-2-долг погашен, clean bill, фикс-лист FL-1/2/3) · ✅ **арх-проход (D39.16: язык-данные майнера → `internal/lang`+`configs/langpacks/` байт-точно [парити EXACT, golden байт-идентичен], граница компиль-enforced; `x/text/language.Tag` ОТВЕРГНУТ [CLDR-инстабильность в вердиктах]; DC-чекеры/снапшот-фолд DEFER [майнер offline/нейтральность]; FL-1/2/3 залендены).** **Очередь:** R1 драйвер-свитч (волновой исполнитель W1/W1.5/W2 + структурный golden-рерайт + фолд `pack.Version()`/загрузка langpack при wiring майнера живым; промт `BACKEND_PLAN11_SESSION_PROMPT.md` АКТИВЕН + `backend/PACK11_CONT_REPORT.md §R1`) → приёмка → единый resnapshot (D30.9, §8-манифест) → пере-прогон 310 глав (армы glm/mistral/deepseek-pro) → чтение владельца (интерим-планка 2 претензии, D35). ja→ru-реплика = тест общности §B5 (DEFER-чекеры едут туда). 残: POLYGON_PACKAGE4 residual-трекер (пилот/18+/echo — отложен, D36.1); ре-чек прайса DeepSeek 24.07.
> - **Стек (не менялся с D38.5):** draft deepseek-v4-flash (thinking ON) → editor glm-5 БИЛИНГВ v3-discourse (P1a+чэнъюй+few_shot-тумблер; свап-кандидаты mistral/deepseek-pro = армы пере-прогона) → судья gemini-3.1-pro-preview; канал B Mistral+grok; 7 ключей; ~$0.85/ранобэ (D30.4).
> - **Ждём от владельца:** запуск exp15 (бюджет ≤$15 подтверждён) · research/20 §E: мини-голд алиасов (~2030 мин) / бюджет ≤$5 полигон-стадии / политика канона / жанр-паки / реплика ja→ru · чтение пере-прогона (после очереди) · старые висящие: планка запуска (лучше-фана/гибрид/издательский) · билингв-якорь пилота (D25.9-Q1) · контаминация пилот-корпуса (D27.4) · publishable/waiver (D25.1) · FN-bound L3 (D25.4) · юр-пакет · провенанс 12-*-доков.
> - **Ждём от владельца:** тачпойнты exp16 (карта подписи/пол · мини-голд алиасов · precision@30, `books/gu-zhenren/exp16/`, ~3040 мин — для сид-дельты к пере-прогону) · развилки плана §10 (W1.5-UX · DC5-стих-политика · Edit-ceiling · и др.) · реплика ja→ru (тест общности §B5) · **ре-чек прайса DeepSeek у катовера слагов 24.07** (до него платных deepseek-прогонов нет) · чтение пере-прогона (после R1+resnapshot) · старые висящие: планка запуска (лучше-фана/гибрид/издательский) · билингв-якорь пилота (D25.9-Q1) · контаминация пилот-корпуса (D27.4) · publishable/waiver (D25.1) · FN-bound L3 (D25.4) · юр-пакет · провенанс 12-*-доков.
> - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `archive/PROGRESS-2026-07-10-13.md` (D39.6-гигиена). Записи ниже — живая эра D39.
## Оркестратор №7 — D39.17-errata: 2-я ревью-сессия поймала runWave parent-cancel баг (мой рубеж-2 пропустил), фикс залендён, 19.07
Владелец запустил ВТОРУЮ независимую ревью-сессию (4 ревьюера+трейс) по волновой фиче ПОСЛЕ моего лендинга R1. Поймала **реальный MEDIUM-HIGH баг, который мой рубеж-2 дал clean bill:** `runWave` возвращал nil при parent-cancel (Ctrl-C), когда воркеры не ошиблись ($0-resume-воркер не трогает cancellable ctx → firstErr nil при недоделанных items) → nil-deref паника (edit) / exit-0 на неполной книге (draft-only). Денег/данных не теряется (durable-store корректен), ущерб = крэш/ложный успех. **Фикс залендён (`5fc4860`):** `parent.Err()` при firstErr==nil. **Верификация оркестратора red/green исполнением:** реверт к HEAD → регресс-тест ПАДАЕТ; фикс → PASS; suite -race + golden зелёные. **Честно: мой clean bill D39.17 был НЕПОЛОН** — линза runWave была, но агенты не дотрейсили $0-resume-cancel-путь; вторая независимая сессия поймала (валидирует независимые ревью). Ядро R1 (деньги/переоплата-ОДНА/память/снапшот) — 3 ревьюера+трейс подтвердили, держится. Осталось владельцу/препу: **product-решение edit-unit blast-radius** (флагнутый member бланкает всю единицу-главу → accept/export-good-flagged/split-on-bad, эскалировано) + R1-FL-A/B/C + ниты (jobs.status/read-model-naming/escMu/dead-minedToCandidates/HTTP-concurrency-smoke) → пере-прогон-преп.
## Оркестратор №7 — R1 драйвер-свитч ПРИНЯТ и залендён (D39.17), ПАК-11 ЗАВЕРШЁН, 19.07
Бэкенд-сессия сдала волновой исполнитель (`waverun.go`/`mining.go` нов): precompute→черновик∥→банк-майнинг-стоп→редактура∥; редактор читает concat-черновик по final_hash, НИКОГДА не пере-рендерит draft; `translateChunk` удалён. + wiring майнера (langpack fail-loud/nil + pack.Version-фолд + mined-write) + Ш-2 (unicode.Version) + Ш-1 (masked-diff) + golden-рерайт (8 глав→8 единиц) + read-модели per-unit + именование без W0-W2. **Приёмка: прямая (build/race сам, 8 волновых пинов вкл. кусачий регресс-пин переоплаты-ОДНА, golden байт-стабилен, построчное чтение waverun=план §1/research19 §B3-бис) + ПОЛНЫЙ рубеж-2** (5 линз вкл. явную синк-с-ресёрчем — владелец просил; refute-by-default, $0): **4 находки / 1 refuted / 3 выжили — ВСЕ MINOR/NOTE, 0 CRIT/MAJOR; все несущие линзы (деньги/секвенирование/детерминизм/СИНК/снапшот) ПУСТЫ.** Сессия сама поймала MAJOR (инъекция черновика над enriched вместо base → тихий re-bill при mined-подписи; фикс r.baseMemory + регресс-пин). **Фикс-лист (3, стенд-only mining / трипваер — не блокируют, ГЕЙТ до живого mining пере-прогона):** R1-FL-A (CLI не даёт WaveSignatureStop distinct exit-код → exit 1; коммент/отчёт оверклейм) · R1-FL-B (mining-стоп очищается только при пустой дельте, reject-механизма нет → отклонённый терм livelock'ит стоп; связано с W1.5-UX §10-1) · R1-FL-C (NOTE: x/text/norm своя Unicode-редакция не фолдится — расширить Ш-2). Девиации приняты (сургичный per-wave resnapshot, escMu-сериализация эскалаций). **ПАК-11 ЗАВЕРШЁН** (Block A+continuation+арх-проход+R1); `BACKEND_PLAN11` отработан→архив. **Очередь:** пере-прогон-преп (R1-FL-A/B/C + единый resnapshot D30.9 §8) → пере-прогон 310 глав (армы + live mining с подписью) → чтение (планка 2 претензии).
## Оркестратор №7 — подпись карты exp16 + находка «Бай Нинбин = предел DC-3», 19.07
Владелец подписал карту exp16 (`books/gu-zhenren/exp16/signature_map.md`, вне git): **dst-канон принят** (banknote-dst + сидовый dst для Палладий-канон-записей; мусорные co-occ «справочно» — игнор, это доказанный провал WHAT §3.3). Правки владельца: 南疆→«Южные земли» (не транслит), 青丝蛊→«Шёлковый гу», 长生 без изм. (=«бессмертие», авто-dst был брак). Пол: остальные персонажи — male по evidence (ожидает финального «ок» владельца), **白凝冰 (Бай Нинбин) = `hidden`**. **НАХОДКА (предел дефект-класса gender-enforce D39.8):** Бай Нинбин ЛОМАЕТ статическое поле пола — пол МЕНЯЕТСЯ по книге (безгендерный→мужчина→магически женщина) И расщеплён по перспективе (внешне ж / сам себя мыслит мужчиной). Ни `gender`, ни окно `since_ch/until_ch` этого не выражают → **детерминированный DC-3 НЕ трогает (правильно, `hidden`→Ф2/редактор); местоимения рендерит редактор ПО СЦЕНЕ.** Следствия: (1) валидирует D39.16-решение отложить hidden-чекер в Ф2; (2) **требование к Ф2/editor-контексту: нужна НАРРАТИВНАЯ гендер-аннотация (since_ch-смены + перспектива), не булево поле** — вход дизайна Ф2-аннотатора; (3) **ожидание пере-прогона (честно владельцу): его претензия «мечтал/мечтала» по Бай Нинбину — редакторская-на-понимание, детерминированным фиксом НЕ закрывается; зависит от того, поймёт ли редактор сцену (может частично на прямых фазах, не гарантировано на расщеплённых).** Гарантированные детерм-фиксы пере-прогона — чистые male-персонажи + 时辰-юниты + числа + регистр.
## Оркестратор №7 — актуализация ВСЕЙ документации (док-свип, мандат владельца), 19.07 (седьмая фаза)
По прямому указанию владельца прошёл всю документацию, освежил current-state-маркеры до пост-D39.16 (дисциплина D23.3: правил ТОЛЬКО шапки/статус-строки, историю не трогал). Тронуто: `CLAUDE.md` (текущее состояние → D39.16, эра «стройка пере-прогонного стека») · `docs/README.md` (статус-абзац пост-D38.1→D39.16, D-лог-диапазон D39.6→D39.16, дедуп дубль-предложения о закрытых промтах) · `docs/PROGRESS.md` (CURRENT-STATE шапка+очередь+«ждём от владельца») · `05-decisions-log.md` (заголовок D1D38→D1D39.16) · `09-target-architecture.md` §2-слой-2 + `10-prompt-architecture.md` (отметки реализации langpack D39.16) · оркестратор-хендофф №5 (статус-баннер: первый deliverable исполнен, тело=снимок D39.11). **Чужие зоны (мандат владельца «всю», дерево чистое/живых сессий нет — клоббера нет):** `backend/README.md` (D-лог-диапазон + указатели на план-11/промт стройки/langpack) · `eval/README.md` (очередь пост-D39.16: исслед-программа завершена, следующее = пере-прогон). **НЕ тронуто (обоснованно):** `components.puml`/`pipeline.puml` — код всё ещё ПОСЛЕДОВАТЕЛЬНЫЙ (R1-волны не залендены) → диаграммы СЕЙЧАС точны, обновление после R1; историческая хроника D-лога/PROGRESS (D23.3).
**Чистка код-зоны + процесс-правка (мандат владельца 19.07, «доки бэкенда выглядят плохо»):** три сессионных `backend/*_REPORT.md` (PACK11/PACK11_CONT/ARCH_CLEANUP, ~82 КБ нарратива, дублировали PROGRESS/D-лог) вынесены из `backend/` в `docs/archive/reports/` (архив-не-удаление); `backend/` теперь чист (README+спека+промты). **Процесс впредь:** сессии НЕ плодят персистентные `backend/*_REPORT.md` — постоянный record = D-блоки+PROGRESS, детальный артефакт (если нужен) → `docs/archive/reports/`; вписано в R1-промт. Плюс из ARCH_CLEANUP-записки ратифицированы **Ш-1/Ш-2** (снапшот-гард: Ш-2 — фолд `unicode.Version`, трипваер до go1.27, едет в R1-resnapshot; Ш-1 — masked-diff-хелпер, низкая срочность) → фикс-лист R1 (детали в D39.16).
## Оркестратор №7 — арх-проход D39.16 ПРИНЯТ и залендён: язык-данные майнера в internal/lang, 19.07 (шестая фаза)
Сессия исполнила 5-фазный арх-промт. **Залендено:** zh/Палладий-данные майнера вынесены из Go-констант pipeline/ в `configs/langpacks/zh|zh-ru/` (10 файлов), грузятся leaf-пакетом `internal/lang` (граница компиль-enforced: не импортит pipeline), движок читает `*lang.Pack` через DI. + FL-1 (bankTokenBudget-фолд) / FL-2 (банкнота-телеметрия на резюме) / FL-3 (emitRankCap=200) / тест-путь env-override. **Приёмка исполнением:** build/vet/race сам, **golden БАЙТ-ИДЕНТИЧЕН** (нейтральность механически доказана), **байт-точность перемещения доказана парити** (константы удалены, DI, EXACT 13618/古月:22/0.9649 против референса — строже ручного парсинга), граница грепом. **Ратифицированы 3 дивергенции (пресс-тест сработал):** language.Tag ОТВЕРГНУТ (CLDR-инстабильность в снапшот-вердиктах, golang/go#24211 — мой наивный буллет опровергнут верно) · DC-чекеры DEFER (пара-агностичны → пара-кеинг = смена поведения; translitInterj = ЯПОНСКИЕ филлеры) · снапшот-фолд DEFER до R1 (майнер offline → не wire-определяющ → преждевременный resnapshot). Дисциплина «не минор-хант/не rewrite» соблюдена. Общность структурно доказана (синтетик-пара роутится, fail-loud). Калибровка: рубеж-2 не гонялся — golden байт-идентичен + парити EXACT = correctness-риск ≈0. Промт отработан→архив. **Очередь:** R1-продолжение (фолдит pack.Version() + грузит пак при wiring майнера) → resnapshot → пере-прогон → чтение.
## Оркестратор №7 — директива владельца D39.15: изоляция языка из pipeline/, архитектурный проход перед R1 (19.07, пятая фаза)
Владелец нашёл zh/ru-константы внутри `internal/pipeline/` (百家姓, Палладий, частицы, `checkers_zh_ru`, `isRuTarget`) → директива: язык ВНЕ пайплайна, сотни языков, библиотеки/данные-файлами, чистый код+нормальные алгоритмы (стандарт «сеньор Google»), НО не минор-хант/не rewrite-ради-rewrite. Разбор оркестратора по коду: §0.1-сеам реален (гейты/pair-keyed промпты/fail-loud — en→es даёт fail-loud, не крэш); хардкод-пути только в тесте; `clusterAlias` = корректный union-find. Настоящий зазор: данные-как-консты (а не файлы) + `x/text` (уже в deps v0.38) недоиспользован. **Ратифицировано D39.15:** целевая форма — `internal/lang/` (интерфейсы+загрузчик, без данных) + `configs/langpacks/<lang|pair>/*.yaml` + `x/text/language.Tag`; резолв по LangPair, снапшот-folded как промпты; алгоритмы не трогать (уходят ДАННЫЕ). **Секвенс: этот проход ПЕРЕД R1** (пока майнер не живой). **Промт выдан `BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md`** — 5 фаз-воркфлоу (ресёрч 2×3 → синтез → фикс-лист do/defer/don't → исполнение → ревью); поведенчески нейтрально zh→ru (golden байт-стабилен, один resnapshot); риды-along FL-1/2/3 + тест-путь. ja→ru = приёмочный тест общности позже. **Очередь пере-упорядочена:** арх-проход → R1-продолжение → resnapshot → пере-прогон → чтение.
## Оркестратор №7 — пак-11 continuation принят и залендён (D39.14), рубеж-2-долг погашен, 19.07 (четвёртая фаза)
Continuation-сессия сдала R2R5 + R1-аддитив (санкц. порядок вариант-1). **Залендено:** R2 Go-майнер (паритет EXACT: 13618, 古月:22, recall 0.9649) · R3 банкнота 12-точек (на посл. раннере, переезжают в волны как есть) · R4 DC1/DC2/DC6+DC3-gender+`export --pairs` (2 golden re-capture masked-чист) · R5 2 арм-конфига · R1-аддитив (snapshotIDForWave + W0-precompute байт-идентично). R1 драйвер-свитч (W1/W1.5/W2 + структурный golden-рерайт) — санкц. резидуал. **Приёмка исполнением:** build/vet/race сам, ~40 пинов свежим `-count=1` (майнер-паритет TM_MINER_PARITY EXACT), независимая masked-golden репликация (0 изменений final_text/disposition), построчное чтение деньги-пути (settle пишет raw, срез после durable settle — деньги нетронуты). **Долг рубежа-2 (D39.13) ПОГАШЕН:** 8-агентный адверсариал по обоим кускам, 5 линз refute-by-default, $0 — **3 находки (все MINOR/PARTIAL, 0 refuted); все CRITICAL/MAJOR-линзы пусты** (деньги/wire/порт/резюм/base-enriched/residual — clean bill). Фикс-лист FL-1 (bankTokenBudget не свёрнут в banknoteSnap) · FL-2 (banknote-телеметрия обнуляется на резюме, observability) · FL-3 (miner emission без top-N капа референса) — все латентны (банкнота off по дефолту, эмиссия за R1), лендинг не блокируют → R1-продолжение (промт активен, фикс-лист вписан). §0.1-заметка владельцу: DC-данные = код-консты за isRuTarget-гейтом (сеам защищает; config-extraction — открытый вопрос, увязать с ja→ru). Очередь: R1-продолжение → приёмка → resnapshot → пере-прогон → чтение.
## Оркестратор №7 — пак-11 Block A принят и залендён (D39.13), 19.07 (третья фаза сессии)
Бэкенд-сессия сдала Block A + ядра с ЧЕСТНЫМ резидуалом (мандат «резидуал > сломанный суит»): WS2 целиком (output-бюджет чанкера, edit-единицы grouping-of-whole-chunks [девиация ратифицирована: 37 единиц / 0 straddle воспроизведены, гарантия W2-реконструкции по построению], src→dst-блок редактора = закрытие D30.1, `render-format-version`, golden re-capture) · WS1a eager-clients (гонка закрыта) · WS1b rate-guard (`ratelimit.go`) · WS1c base/enriched `MemoryVersion`-split (enriched байт-идентичен — снапшот не двинулся) · WS4-core банкнота-парсер (байт-верный порт). **Приёмка — верификация исполнением:** build/vet/race сам, 27 пинов свежим `-count=1` вербозно, независимая masked-golden репликация (все категории объяснимы), построчное чтение несущих диффов. Сессионный 9-агентный чекпойнт: 3 confirmed пофикшены / 3 refuted. **Долг: мульти-агентный рубеж-2 приёмки не гонялся (лимит-режим) — гасится при приёмке продолжения (прецедент D39.2).** Резидуал R1R5 с точными дизайнами — `backend/PACK11_REPORT.md`; промт пака АКТИВЕН со статус-баннером. Записка продолжению-сессии: стартовать по промту + отчёту, сданное не пере-строить.
## Оркестратор №7 — план 11 РАТИФИЦИРОВАН (D39.12), бэкенд-промт единого пака ВЫДАН (19.07, вторая половина сессии)
Дизайн-синтез сдал план (1066 строк) после трёх рубежей: ревью-2 оркестратора (F1F11: 3 MAJOR соло-прохода + 2 замечания владельца [mistral rate-guard ~48% retry-fails · полы per-editor-model] + Q9-провенанс) → 7-агентный пост-фикс адверсариал сессии (5 находок: F8 ПЕРЕПИСАН — сайзинг уже от `prev`; дефолт 3200 → 37 edit-единиц; mined-write-путь [`loadGlossarySeed` хардкодит seed]; render-format-version реконсиляция; F6 усилен narrative-coref) → 3-агентный подтверждающий пас (2 резидуала закрыты). Спот-чек внесения — исполнением (36 маркеров, grep всех фиксов). **Залендено одним пакетом:** план с ревью-шапкой + `eval/design11/` (7 скриптов, 6 верификаций PASS) + quirks-запись mistral (из §12-заготовки, зона полигона — с пометкой) + D39.12 + архивация спент-промта дизайн-синтеза. **Гейтнутые армы: 4** (edit>3200 за Q2a · качество свап-арма за платным полигоном · DC-landing за $0-FP · mistral за rate-guard). Развилки §10 (14 шт., с рекомендациями) — у владельца, стройку не блокируют. **Выдан `BACKEND_PLAN11_SESSION_PROMPT.md`** — единый пак WS1WS6 блоками A/B/C/D с чекпойнт-ревью (решение владельца: одной сессией; свободы: рефакторинг/перенос кода, временные тесты, багфиксы со скоуп-пометкой; Go-каноны; даблчек неконсистентностей; $0 к провайдерам). Очередь: бэкенд-сессия → приёмка (рубеж-2) → единый resnapshot → пере-прогон → чтение.
## Оркестратор №7 — промт ДИЗАЙН-СИНТЕЗ сессии выдан (deliverable №1 по D39.11), 2026-07-19
Онбординг по хендоффу №5 (D-лог D39D39.11 + несущие отчёты: research/19 §B/§C/§E, research/20 §B/§C/§E, exp15 §3/§7/§7.9/§7-Q4a, exp16 целиком). **Выдан `docs/DESIGN_SYNTHESIS_SESSION_PROMPT.md`** — проект-ресёрч сессия синтеза всех находок в ратифицируемый план `architecture/11-implementation-plan.md`. Скоуп: WS1 волновой раннер (деньги/SQLite/гонка `Runner.clients`/sticky-A5-цепочка + scheduler-волны + три вендор-чека: deepseek пик-окна D39.10-6, кэш ZAI/GLM §E.8-бис, слаги 24.07) · WS2 фертильность-бюджет + крупная edit-единица (+ omission-бэкстоп; src→dst D30.1 решается здесь, парковка запрещена) · WS3 Go-майнер W1.5 (V-C + алиасы + консолидация + сид-дельта §C2-7 + карта подписи) · WS4 банкнота×12 гейт-точек (вкл. re-point final_hash) · WS5 дефект-класс чекеры D39.8 + данные пакета пары · WS6 свап-арм редактора + готовность пере-прогона (риг-стандарт D39.7 вшит); глобальные секции: порядок пакетов, манифест ЕДИНОГО resnapshot (D30.9, вкл. gate-условную семантику банка), диспозиции хвостов (ledger-очередь, §E-слоты, L7-NEVER_CLOSED: H7/H8/H10/H16), реестр вопросов владельцу. Каждый WS — верификация ДО стройки ($0-симуляции, скрипты `eval/design11/`, числа в план); мандат самопроверки + адверсариальный селф-ревью + completeness-critic L1L8; $0 жёстко (платная несущая верификация = эскалация-развилка, не тихий деферал «на после стройки»). **Промт прошёл адверсариальное ревью-воркфлоу до выдачи (5 линз × refute-by-default, 25 агентов): 20 CONFIRMED-находок (4 MAJOR), все внесены** — несущие: gate-условная снапшот-семантика auto/draft (`memory.go:259-266`); пропущенные D12-риски параллелизма (гонка clients, sticky-цепочка → request_hash); lookahead/Q2c переведены из «доказано не строим» в парковку «не тестировано»; verify-before-build маршрут платных верификаций; мега-предложение — НЕ «уже в коде» (oversize-passthrough без флага). file:line-якоря спот-верифицированы по HEAD исполнением. ⚠ Док-синк заметка (не чинил — вне минимального скоупа): ярлык «инвариант №8» перегружен — backend/README №8 = экспорт-контракт (с 17.07), CLAUDE.md/шапка D-лога зовут №8 golden (эпоха D23); развести при следующей актуализации статус-доков. Следующее: владелец стартует дизайн-сессию → адверсариальное ревью+ратификация плана (D-блок) → бэкенд-промт строго по плану.
## Оркестратор №6 — СЕССИЯ ЗАКРЫТА: эра D39D39.11 исполнена целиком, хендофф №5 выдан (19.07)
Арка сессии: синк-аудит «сломанного телефона» (65 находок/0 refuted) → целевая 7-слойная архитектура + инвариант общности → трек A построен (trust-gate памяти · export-contract+fold-first санитайзер · pair-сеам · tmctl export/report · store v8; адверсариал-долг 529 погашен) → трек B исследован (research/19+20) → exp15 (floor-дисциплина; CRITICAL-артефакт окна судьи пойман рубежом-2, «границы вредят» отозван; чтение владельца: дефекты распределены) → Q4a (слабое звено=редактор) → exp16 (WHICH 0.97 кодом / dst→банкнота; ре-аудит exp14b чист). Деньги экспериментов: exp15 $12.79/$15 · Q4a $1.32/$2 · exp16 $0.02/$10 — все капы удержаны. Итог для продукта: стройка слоёв 1+4 полностью evidence-backed; «что НЕ строить» сэкономило сцен-детекцию и carryover-машинерию. Хендофф №5 — `ORCHESTRATOR_SESSION_PROMPT.md` (переписан начисто; №4 в архиве); первый deliverable №7-сессии — промт дизайн-синтеза (D39.11).
## Ресёрчер — research/20 банк-майнинг W1.5 СДАН (не закоммичен, лендит оркестратор), 2026-07-17 (D39.3)
Отчёт `docs/research/20-bank-mining.md` (исполнение `RESEARCHER_BANK_MINING_SESSION_PROMPT.md`).

View file

@ -11,17 +11,17 @@
## Структура
- `architecture/` — синтез. **Источник истины по решениям — [`05-decisions-log.md`](architecture/05-decisions-log.md) (D1D39.6); при конфликте с любым доком он выше.**
- `01-decisions.md` — принципы Р1Р10; `02-mvp-plan.md` — фазы и приёмка (v3, 09.07); `03-implementation-notes.md` — контракты Фазы 0; `04-unhappy-paths.md` — ~70 режимов отказа → механизм; `06-memory-risk-registry.md` — реестр рисков банка памяти; **[`07-strategic-review.md`](architecture/07-strategic-review.md) — стратегический аудит (09.07): вердикт end-to-end, топ-риски, курс-коррекции**; **[`08-sync-audit-ledger.md`](architecture/08-sync-audit-ledger.md) — верифицированный ледджер синк-аудита «сломанного телефона» (65 находок, D39)** · **[`09-target-architecture.md`](architecture/09-target-architecture.md) — целевая 7-слойная архитектура + фазовый план арх-ресета (D39; инвариант общности §0.1)** · [`10-prompt-architecture.md`](architecture/10-prompt-architecture.md) — консолидированная промпт-заметка (концерн 4); `components.puml`/`pipeline.puml` — диаграммы v3 (владелец смотрит PlantUML-расширением VS Code; вручную НЕ рендерить).
- `architecture/` — синтез. **Источник истины по решениям — [`05-decisions-log.md`](architecture/05-decisions-log.md) (D1D39.16); при конфликте с любым доком он выше.**
- `01-decisions.md` — принципы Р1Р10; `02-mvp-plan.md` — фазы и приёмка (v3, 09.07); `03-implementation-notes.md` — контракты Фазы 0; `04-unhappy-paths.md` — ~70 режимов отказа → механизм; `06-memory-risk-registry.md` — реестр рисков банка памяти; **[`07-strategic-review.md`](architecture/07-strategic-review.md) — стратегический аудит (09.07): вердикт end-to-end, топ-риски, курс-коррекции**; **[`08-sync-audit-ledger.md`](architecture/08-sync-audit-ledger.md) — верифицированный ледджер синк-аудита «сломанного телефона» (65 находок, D39)** · **[`09-target-architecture.md`](architecture/09-target-architecture.md) — целевая 7-слойная архитектура + фазовый план арх-ресета (D39; инвариант общности §0.1)** · [`10-prompt-architecture.md`](architecture/10-prompt-architecture.md) — консолидированная промпт-заметка (концерн 4); **[`11-implementation-plan.md`](architecture/11-implementation-plan.md) — РАТИФИЦИРОВАННЫЙ план стройки пере-прогонного стека (D39.12; дизайн-оф-рекорд бэкенд-пака, три рубежа верификации)**; `components.puml`/`pipeline.puml` — диаграммы v3 (владелец смотрит PlantUML-расширением VS Code; вручную НЕ рендерить).
- `experiments/` — эмпирика «Полигона»: `00-provider-quirks` (читать перед любым вызовом провайдера), `01-token-calibration`, `02-refusal-benchmark`, `03-local-stand`, `04-editor-quality`, `06-local-extraction`, `07-coverage-precision`, `08-cost-model-v2` (актуальная денежная модель), `09-pilot-protocol` (пилот Ф2.5 + поправки D13), `10-explicit-benchmark` (18+ violence-рука канала B), `11-erotica-benchmark` (erotica по трём парам/регистрам — закрытие D14.4, D22).
- `research/` — фактура исследований 0405.07: `0110` базовые, `11-gap-*` добор критиком, `12-*` режимы отказа/отзывы/таксономии (+ два внешних материала с провенанс-шапками), `13` валидация памяти, `14` адаптивная память, `15` голос и состояние (принят, D21), **`16` ридер-IDE (принят с ревью-шапкой, D29)**, **`17` внешняя критика GPT-5.6 (принят с ревью-шапкой, D25)** — у 16/17 читать шапку прежде тела. **`18` рычаги качества (два отчёта, D36)** · **`19` нарезка+когезия+контракт t/e (D39.1)** · **`20` банк-майнинг W1.5 (D39.6)** — у всех ревью-шапки. ⚠ Часть под superseded-баннерами (01/02/03/04/05/09 и gap-1/2/5) — **читай баннер прежде содержимого**.
- `PROGRESS.md`**журнал** (CURRENT-STATE сверху, ниже хронология; НЕ источник решений).
- **Активные хендофф-промты (пост-D39.6, 17.07):** [`POLYGON_SEGMENTATION_EMPIRICS_SESSION_PROMPT.md`](POLYGON_SEGMENTATION_EMPIRICS_SESSION_PROMPT.md) (**exp15 — СЛЕДУЮЩИЙ запуск:** сегментация-эмпирика Q0Q5 по research/19 §D; фриз = первый коммит) · [`ORCHESTRATOR_SESSION_PROMPT.md`](ORCHESTRATOR_SESSION_PROMPT.md) (роль; ⚠ стале-баннер — онбординг через `09-target-architecture.md`) · [`POLYGON_PACKAGE4_SESSION_PROMPT.md`](POLYGON_PACKAGE4_SESSION_PROMPT.md) (**ОТЛОЖЕН, пилот-residual**). Очередь после exp15: полигон-стадия банк-майнинга (research/20 §D) → бэкенд-пак слоя 1 → единый resnapshot → пере-прогон 310 глав. Закрытые промты — в `archive/prompts/` (свежие: трек-A пак-1/1.5→D39.2/39.5, ресёрчеры 19/20→D39.1/39.6, арх-ресет→D39).
- **Активные хендофф-промты (пост-D39.17, 19.07):** [`ORCHESTRATOR_SESSION_PROMPT.md`](ORCHESTRATOR_SESSION_PROMPT.md) (хендофф №5 оркестратора; статус-баннер — пост-D39.17) · [`POLYGON_PACKAGE4_SESSION_PROMPT.md`](POLYGON_PACKAGE4_SESSION_PROMPT.md) (residual пилот/18+/echo). **ПАК-11 ЗАВЕРШЁН** — след. шаг = пере-прогон-преп (3 mining-wiring-фикса R1-FL-A/B/C + единый resnapshot; промт пишет оркестратор). Закрытые — в `archive/prompts/` (свежие: пак-11/R1→D39.17, арх-cleanup→D39.16, дизайн-синтез→D39.12).
- `archive/` — закрытые сессионные промты (только история, инструкции оттуда не исполнять).
## Статус (2026-07-12, пост-D38.1 · консолидация D38.2)
## Статус (2026-07-19, пост-D39.16 — стройка пере-прогонного стека)
Фаза 0 ✅; Ф1-инфра ✅ (D20D28); приёмка этап A ✅ (D24). **Путь D26→D35 «качество-первым»:** флип D1 моно→БИЛИНГВ (D30), reflow + output-санитайзер (D30/D33), сид v2 подписан (D34), переводчик flash сохранён (exp13/D32). **Пере-прогон 25 глав связкой выполнен; вердикт владельца: «лучше, но крайне слабо художественно» (2 претензии: абзацы/конвенции + понимание связей).** **ПИВОТ D35:** цикл прогонов ЗАКРЫТ (инфра ✅/читаемость ❌ = не провал; диагноз «Ф2-недострой + near-term промпт/нарезка-рычаги, не баг» верифицирован). Планка = 2 претензии (интерим). **Дуга «мерить» пройдена (D36D38.1):** research/18 залендён (D36); exp14 (D37) — претензия-1 = дешёвый промпт-рычаг; exp14b (D38) — «только gpt-5.4» ОПРОВЕРГНУТО (mistral/deepseek-pro чинят), фронтир в дефолт не нужен, корень терм-дрейфа = статус-draft сида; слепой h2h залендён (D38.1). **РАЗВОРОТ к «строить»: текущий шаг — инфра-пак (D38.1, `BACKEND_INFRA_PACK` выдан) + reseed-глоссарий** → resnapshot → пере-прогон 310 глав → чтение владельца. Эмпирика сошлась: потолок был в нашей нарезке/промпте/глоссарии-инфре, не в дешёвых моделях. Трек: дешёвые сейчас, фронтир потом (D30.7). **Консолидация D38.2:** спент exp-скрипты сгруппированы в `eval/exp12|13|14|14b/`, статус-доки и диаграммы актуализированы. Решающая точка — пилот Ф2.5; блокеры — билингв-якорь/аннотаторы ≥3 (D25.9-Q1), корпус, судья-дублёр 18+ (D22.6). Ключ xAI: единый, data-sharing, off перед продом (D27).
Фаза 0 ✅; Ф1-инфра ✅ (D20D28). Арка «качество-первым» D26→D38.5 ЗАКРЫТА (хроника — `archive/PROGRESS-2026-07-10-13.md`; потолок был в организации пайплайна, не в моделях). **АРХ-РЕСЕТ D39:** синк-аудит (65 находок/0 refuted, `08-*`) → 7-слойная архитектура (`09-*`) + инвариант общности §0.1. **Исследовательская программа ЗАВЕРШЕНА (D39.1D39.10):** research/19 (нарезка+когезия+волны) + research/20 (банк-майнинг W1.5) + exp15/Q4a/exp16 → сцен-детекцию/carryover/logical-границу НЕ строить; слабое звено = РЕДАКТОР (свап glm→mistral/deepseek-pro); банк W1.5 = детектор-код (WHICH 0.97) + банкнота (dst) + Палладий + подпись; фертильность 1.20/0.39. **План реализации РАТИФИЦИРОВАН (D39.12, `11-implementation-plan.md`)** — три рубежа, 4 гейтнутых арма. **Стройка пака-11 (единой сессией, блоками):** Block A (D39.13: output-чанкер+edit-единицы+src→dst-редактор+eager-clients+rate-guard+base/enriched+банкнота-ядро) · continuation (D39.14: Go-майнер паритет-EXACT+банкнота-12-точек+DC-чекеры+`export --pairs`+арм-конфиги+волновой снапшот-аддитив; рубеж-2 clean bill) · **арх-проход (D39.16: язык-данные майнера → `internal/lang`+`configs/langpacks/`, байт-точно, граница компиль-enforced; `language.Tag` отвергнут; DC-чекеры/фолд DEFER).** **Осталось: R1 драйвер-свитч** (волновой исполнитель + golden-рерайт + фолд `pack.Version()` при wiring майнера) → приёмка → единый resnapshot (D30.9) → пере-прогон 310 глав (армы glm/mistral/deepseek-pro) → чтение владельца (интерим-планка 2 претензии, D35). Стек не менялся с D38.5 (draft flash → editor glm-5 БИЛИНГВ → судья gemini-preview; ~$0.85/ранобэ D30.4). Решающая точка — пилот Ф2.5; блокеры — билингв-якорь/аннотаторы ≥3 (D25.9-Q1), корпус, судья-дублёр 18+ (D22.6).Ре-чек прайса DeepSeek у катовера слагов 24.07. Ключ xAI: единый, data-sharing, off перед продом (D27).
## Доступные ключи от моделей
DEEPSEEK_API_KEY, ZAI_API_KEY, KIMI_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, XAI_API_KEY, MISTRAL_API_KEY

File diff suppressed because one or more lines are too long

View file

@ -96,6 +96,11 @@ snapshot-дисциплина, эталонные сеамы `config→stage-lis
- Стадия резолвит промпт/правила из слоя по паре книги, а не из фикс-пути. `Book.LangPair()` оживает как ось выбора.
- **Высота сейчас (решение владельца):** построить СЕАМ, наполнить только zh→ru; другие пары — когда придёт книга.
Убирает латентный баг (ja-книга едет через «китайский паратаксис»-промпт) и разблокирует расширяемость дёшево.
- **⟶ Реализация (D39.16, обновление):** данные-часть этого слоя РЕАЛИЗОВАНА для майнера — zh/Палладий-инвентари
вынесены из `pipeline/`-констант в `configs/langpacks/<lang|pair>/` через leaf-пакет `internal/lang` (граница
компиль-enforced; резолв по `Book.LangPair()`; байт-точно — парити EXACT, golden байт-идентичен). Извлечение
правил из ТЕЛА промтов (дискурс-нормы, few-shot) — ещё открыто (форсинг-функция = ja→ru-реплика). `x/text/
language.Tag` ОТВЕРГНУТ для гейт-предикатов (CLDR-инстабильность в снапшот-вердиктах — держим точные `isRuTarget`).
### Слой 3 — Контракт переводчик/редактор (роли = узкие проходы) · концерн 1
- research/07 предписывает **узкие мандаты + диффы, а не полную перегенерацию** — сейчас ровно наоборот (4 мандата,

View file

@ -36,7 +36,10 @@
## 4. Открытое по теме (маршрутизировано)
1. **Язык промпта** — полигон-замер после exp15, на реальной не-ru книге (V4-п5; не гадать).
2. **Извлечение правил в пакет** — после exp15 (форма под данные).
2. **Извлечение правил в пакет** — exp15 ЗАВЕРШЁН; **данные-часть майнера ИЗВЛЕЧЕНА (D39.16:** zh/Палладий-инвентари →
`internal/lang`+`configs/langpacks/`, тот же pair-keyed идиом, что промпты, снапшот-фолд отложен до wiring майнера R1**)**;
извлечение правил из ТЕЛА zh-ru-промтов (дискурс/few-shot) — ещё открыто, форсинг = ja→ru-реплика. `x/text/language.Tag`
ОТВЕРГНУТ для гейт-предикатов (CLDR-инстабильность в вердиктах — D39.16).
3. **Транскрипция per-book** (Палладий/Поливанов vs фандомные формы) — research/19 §E.3, решение
владельца НЕ горит (дефолт: политика в пакете пары, слой 2); `{{transcription}}`-переменная уже есть.
4. **Билингв-редактору src→dst вместо голых dst-форм?** — открытый дизайн-вопрос D30.1, после exp15.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,140 @@
> **⟶ ОТРАБОТАН → `internal/lang`+`configs/langpacks/` (майнер-данные вынесены байт-точно), ЗАЛЕНДЕН, ратифицирован D39.16 (19.07). 3 дивергенции (language.Tag отвергнут, DC-чекеры/снапшот-фолд DEFER) ратифицированы. Архивная копия.**
# Промт: сессия БЭКЕНД (промежуточная) — изоляция языковых данных из пайплайна + алгоритмы/чистота кода, 2026-07-19
> **Место в очереди:** ПЕРЕД R1-драйвер-свитчем (директива владельца 19.07 — вставить архитектурный проход до
> завершения волнового исполнителя, пока не накопился ещё язык-специфичный код и до того, как майнер станет
> живым в W1.5). После этой сессии → R1-продолжение (`BACKEND_PLAN11_SESSION_PROMPT.md`) → resnapshot →
> пере-прогон → чтение. **Это не R1 и не пере-прогон.**
## Директива владельца (дословно, ратифицирована — это НЕ гипотеза сессии)
Язык-специфичные русско-китайские вставки НЕ должны жить внутри `internal/pipeline/`. Горизонт — сотни
языков. Пересмотреть архитектурный подход: язык-данные — вне движка, через **библиотеки где уместно** и
**данные-файлами, а не константами**. **Писать ЧИСТЫЙ КОД с НОРМАЛЬНЫМИ АЛГОРИТМАМИ — как сеньоры из Google.**
**НО:** не упарываться на миноры, переписывание ради переписывания НЕ делать. Один оправданный крупный
рефактор (изоляция языка) + известные реальные фиксы — и всё; не плодить косметику.
## Кто ты и что сдаёшь
Бэкенд-сессия. **Зона: `backend/` (вкл. `configs/`, `prompts/`) + новый каталог языковых данных.** Прочее —
read-only. **НЕ коммитить** (лендит оркестратор). Сдаёшь:
1. Код рефактора + фиксов + тесты; **поведенчески НЕЙТРАЛЬНО для zh→ru** (это РЕФАКТОР-перемещение данных, не
смена поведения — доказывается байт-стабильностью golden с точностью до одного version-поля).
2. **`backend/ARCH_CLEANUP_REPORT.md`** — результаты каждой фазы-воркфлоу (ресёрч-буллеты, синтез, фикс-лист с
диспозициями do/defer/don't, лог исполнения, ревью-вердикт), golden-дифф, «багфиксы вне плана», residual.
## Онбординг
1. `CLAUDE.md` (гардрейлы) → `backend/README.md` (**инварианты 18**) → `docs/architecture/09-target-architecture.md`
**§0.1 инвариант общности** (любая книга/пара/структура; пара = ось данных — это север) + §2 слой-2.
2. `docs/architecture/10-prompt-architecture.md`**прецедент, который расширяем:** промпты УЖЕ живут pair-keyed
(`Stage.Prompts[pair]` + `PromptPathFor` + fail-loud + снапшот-фолд). Языковые данные получают ту же форму.
3. `docs/architecture/11-implementation-plan.md` §3 **§B5 (плагины P1P6)** — 6 интерфейсов языковых данных уже
спроектированы; эта сессия делает их **data-backed и вне `pipeline/`**.
4. D-лог D39.12D39.15; `docs/experiments/00-provider-quirks.md`.
5. Грунтовка по коду (где сейчас зашит язык): `miner_patterns.go` (百家姓 `surnamesSingleRaw`, суффиксы),
`miner_palladius.go` (таблица Палладия), `miner_alias.go` (`aliasParticle` 的了在是…), `checkers_zh_ru.go`
(时辰-таблица, числительные, «терем»-регистр), `cheapgates.go` (allowlist междометий), `disposition.go`
(`isRuTarget`/`isCJKTarget`), `chunker.go` (фертильность CJK/other). Референсы данных — `eval/exp16/*.py`.
## Фазовая структура (ВОРКФЛОУ — ultracode включён; каждая фаза = отдельный воркфлоу)
### Фаза 1 — РЕСЁРЧ (независимый, 2 направления × 3 источника, свои буллеты)
Параллельные агенты, каждый ищет СВОИ находки (refute-by-default, независимая перспектива). Источники каждого:
**(1) Go-идиоматика/stdlib/`golang.org/x/text`, (2) наш репозиторий, (3) интернет (websearch)**. Направления:
- **A. АЛГОРИТМЫ:** правильные ли алгоритмы в текст-работе (майнер n-граммы/c-value, matcher Aho-Corasick,
нормализация, union-find алиасов, фертильность)? Что из этого — переизобретённый велосипед, заменяемый
stdlib/`x/text` (сегментация/коллация/нормализация/`language.Tag`)? Что — уже правильный нормальный алгоритм,
который ТРОГАТЬ НЕ НАДО (напр. union-find `clusterAlias` — оставить)? Где Go-библиотека реально поможет, а где
честно нет (морфология/NER — вынесено в облако, не Go)?
- **B. АРХИТЕКТУРА КОДА / ОБЩНОСТЬ:** как изолировать языковые данные из `pipeline/` под сотни языков.
Паттерны: data-as-files + реестр-per-пара, plugin-интерфейсы, `x/text/language.Tag`. Прайор-арт: наш
pair-keyed промпт-сеам; Go project-layout идиомы; i18n/l10n data-separation из индустрии (websearch).
Выход фазы: два набора буллетов (алгоритмы / архитектура) с обоснованием и file:line/ссылками.
### Фаза 2 — СИНТЕЗ (короткий воркфлоу): свести все входы
Реконсилировать: **ресёрч-буллеты + буллеты оркестратора (ниже) + буллеты владельца (ниже) + известный
фикс-лист (ниже)**. Дедуп, разрешить конфликты, ранжировать по ценности×риску. Явно пометить, что —
консенсус, что — спорно.
### Фаза 3 — ВЫВОД фикс-листа + предложений (резюмирующий воркфлоу)
Из синтеза вывести **финальный actionable-список** с диспозицией КАЖДОГО пункта: **DO** (делаем сейчас) /
**DEFER** (в резидуал, с причиной) / **DON'T** (переписывание-ради-переписывания — отвергнуть явно). Это
рубеж против «упарывания на миноры»: пункт без ясной ценности → DON'T. Отдельно — предложения по
алгоритмам/архитектуре, которые сверх скоупа этой сессии (в план/владельцу).
### Фаза 4 — ИСПОЛНЕНИЕ (редактура кода)
Только DO-пункты. Временные тесты — можно. **Поведенческая нейтральность zh→ru обязательна** (см. приёмку).
### Фаза 5 — РЕВЬЮ (воркфлоу): изменения с разных точек зрения + функциональное
Мульти-линзовый адверсариал (author≠reviewer): (i) корректность/поведенческая нейтральность (golden,
детерминизм, снапшот); (ii) сеньор-код-ревью (Go-идиоматика, чистота сеамов, отсутствие велосипедов и
над-инженерии); (iii) функциональное (пара резолвится, fail-loud на неизвестную пару, добавление 2-й пары =
только данные). Находки чинить до сдачи. **Ревью НЕ должно генерить длинный минор-лист** — оно проверяет, что
рефактор корректен и нейтрален, а не хантит косметику.
## Входные буллеты для синтеза (Фаза 2)
**Буллеты ОРКЕСТРАТОРА (стартовое предложение — ПРЕСС-ТЕСТ ресёрчем, не имплементить слепо):**
- Движок (`pipeline/`) — общий, язык-данных НЕ содержит.
- Новый пакет `internal/lang/` (или лучшее по ресёрчу): интерфейсы `LangPack`/P1P6 + загрузчик + реестр,
**без самих данных**.
- `configs/langpacks/<lang>/*.yaml` + `<pair>/*.yaml`: 百家姓/суффиксы/частицы (zh), Палладий (zh-ru),
регистр/числительные/гендер-местоимения (ru), единицы 时辰 (zh-ru).
- Резолв по `Book.LangPair()`, снапшот-folded (как промпты). Добавить язык = положить каталог, **без
перекомпиляции, без правок `pipeline/`**.
- `golang.org/x/text/language.Tag` вместо `isRuTarget`/ручных рун-сетов (реестр языков с фолбэком; `x/text`
уже в зависимостях).
- НЕ трогать правильные алгоритмы: union-find, детерминированные сортировки, целочисленные пути — они верны;
из движка уходят ДАННЫЕ, алгоритм остаётся.
**Буллеты ВЛАДЕЛЬЦА:** язык вне `pipeline/`; сотни языков; библиотеки где уместно; чистый код + нормальные
алгоритмы, стандарт «сеньор Google»; не упарываться на миноры; никакого rewrite-ради-rewrite.
**Известный фикс-лист (реальные, рождены рубежом-2 D39.14 + §0.1-разбор — риды-along, не хантить сверх):**
- **FL-1 (MINOR):** свернуть `bankMaxLines`/`bankTokenBudget` в `banknoteSnap` (`snapshot.go`) — сейчас
`max_tokens += bankTokenBudget` wire-affecting, но не свёрнут → правка конста без бампа `bankParserVersion`
= тихий re-bill на резюме.
- **FL-2 (MINOR, observability):** `resumeFromChunkStatus` (`resume.go:22`) не ставит `BankFlags`
банкнота-телеметрия обнуляется на резюме готовых чанков; коммент «self-heals» ложен. Re-derive из сырого
чекпоинта / не обнулять эти колонки на резюме / поправить коммент.
- **FL-3 (PARTIAL, порт-fidelity):** `miner_emit.go:68` идёт по полному `mr.ranked`; референс капит
`a3[:200]`/`[:30]` ДО фильтров (`eval/exp16/{alias.py:165, emit_owner_sheets.py:143,162,169}`) → карта
подписи шумнее референса. Добавить top-N кап перед emission-фильтрами.
- **Тест-путь:** абсолютные `/home/ubuntu/...` в `miner_parity_test.go` → env-var/`testdata`-относительные
(тест-гигиена; не прод).
## Скоуп
**IN:** ресёрч; изоляция языковых данных (движок→`lang`/`langpacks`); `x/text/language`-реестр; FL-1/2/3;
тест-путь; доказательство общности «2-я пара = только данные» (минимальный второй langpack-скелет ИЛИ тест
загрузки синтетической пары через сеам — НЕ полный ja→ru-контент). **OUT:** R1 драйвер-свитч (след. промт);
пере-прогон; любая смена ПОВЕДЕНИЯ перевода zh→ru; полная ja→ru-реплика (отдельная задача); минор-хант.
## Гардрейлы (жёсткие)
- **Поведенческая нейтральность zh→ru:** рефактор = перемещение ДАННЫХ; langpack-значения обязаны быть
байт-равны прежним константам. Golden после = байт-стабилен С ТОЧНОСТЬЮ до одного нового version-поля
(langpack-версия в снапшоте); НОЛЬ изменений вердиктов/wire/final. Один чистый resnapshot (масштаб — как
chunkerVersion). Если langpack-путь меняет хоть один вердикт — это БАГ рефактора, не «улучшение».
- **Снапшот-дисциплина:** langpack-данные wire-определяющи (майнер/чекеры/фертильность) → версия пакета
фолдится в `snapshotID` (правка данных = громкий resnapshot, как `chunkerVersion`/промпты). Детерминизм:
загрузка сортирована/версионирована, без map-order в выводе.
- **Чистый код, сеньор-Google:** идиоматичный Go (`gofmt`/`vet`/`-race` чисто; обёртка ошибок `%w`; без
глобального мутабельного стейта; table-driven тесты); **stdlib/`x/text` вместо велосипедов**; НО НЕ
над-инженерия (union-find не превращать в «библиотеку»; не плодить абстракции без второго потребителя —
а он есть: 2-я пара). Комментарии — как в окружающем коде (констрейнты, не пересказ).
- **Инварианты 18 не трогать** (деньги/durability, снапшот, эхо-мина, детерминизм, экспорт-контракт, golden).
- **$0 к провайдерам:** ресёрч — websearch + чтение кода; верификация — build/test/golden/фикстуры. LLM-вызовов
перевода НЕТ.
- **Неконсистентность/аномалия** — даблчек всеми тулзами (grep, git log, вендор-дока, websearch — правило двух
направлений); реальное расхождение → эскалация запиской, не тихо.
- Git: `.env` не читать; книга/производные вне git; PUML не рендерить; не коммитить.
## Приёмка (оркестратор — знай заранее)
Пере-исполнение фикстур; спот-чек file:line; **golden байт-стабильность** (масштаб — только langpack-версия,
ноль вердикт-правок); адверсариальный рубеж-2 (перемещение данных нейтрально? общность реальна — 2-я пара
грузится без правок `pipeline/`? алгоритмы не сломаны, велосипеды сняты, над-инженерии нет?); проверка «не
упоролись на миноры» (фикс-лист = DO-пункты с ценностью, не косметика). Затем лендинг → D-блок → R1-продолжение.

View file

@ -0,0 +1,220 @@
> **⟶ ОТРАБОТАН ПОЛНОСТЬЮ → пак-11 ЗАВЕРШЁН (Block A D39.13 · continuation D39.14 · арх-проход D39.16 · R1 драйвер-свитч D39.17). Волновой исполнитель залендён, рубеж-2 clean bill. Остаток — 3 MINOR/NOTE mining-wiring-фикса (R1-FL-A/B/C) → пере-прогон-преп. Архивная копия.**
> **⚠ АКТУАЛИЗИРОВАН ПОД R1-ПРОДОЛЖЕНИЕ (19.07, пост-D39.14; предыдущие версии — в git-истории).** Сессия №1
> сдала Block A (`85f5b9e`, D39.13); сессия №2 сдала R2R5 + R1-аддитив (D39.14). **Осталось: R1 драйвер-свитч +
> 3-пунктовый фикс-лист рубежа-2 (ниже).** Дизайны — `docs/archive/reports/PACK11_CONT_REPORT_2026-07-19.md §R1` + `docs/archive/reports/PACK11_REPORT_2026-07-19.md §Резидуал`
> (авторитет наравне с планом). Сданное — под тестами, НЕ пере-строить.
>
> **ФИКС-ЛИСТ рубежа-2 (FL-1/2/3) — УЖЕ ЗАЛЕНДЕНЫ (D39.14/D39.16), здесь как КОНТЕКСТ; НЕ переделывать, если
> тронешь смежное — не регрессировать:** FL-1 `bankMaxLines`/`bankTokenBudget` свёрнут в `banknoteSnap` · FL-2
> `resumeFromChunkStatus` репопулирует `BankFlags` (банкнота-телеметрия не обнуляется на резюме) · FL-3
> `emitRankCap=200` перед emission-фильтрами (`miner_emit.go`, зеркало `a3[:200]`).
>
> **Ш-1/Ш-2 (D39.16-записка, ратифицированы → ДЕЛАТЬ В ЭТОМ R1-заходе, снапшот-двигающие):**
> - **Ш-2 (несущее):** вплести `unicode.Version` в `memoryNormVersion` (`memnorm.go:56`) + classifier/style-версии —
> сейчас NFKC/`unicode.Han/Cyrillic` привязаны к Unicode-версии тулчейна, но не фолдятся → бамп go1.27 сменил бы
> их ТИХО. **Фолдить в ТОТ ЖЕ единый resnapshot, что `pack.Version()`** (один заход). **ТРИПВАЕР: обязательно
> до любого go1.27-бампа** (мы на 1.26.4 — не горит, но не бампать тулчейн, пока Ш-2 не в снапшоте).
> - **Ш-1 (низкая срочность):** хелпер masked-diff при golden re-capture (`TM_UPDATE_GOLDEN=1` → маскировать
> хеш/версия-поля + печатать «N вердикт-изменений / M version-only» ДО записи). Делает re-capture безопасным
> по построению; можно в этом заходе или отдельной гигиеной.
# Промт: сессия БЭКЕНД — единый пак по плану 11 (волновой раннер · чанкер/единицы · банк W1.5 · банкнота · чекеры · армы), 2026-07-19
> **Место в очереди (пост-D39.12):** исследовательская программа арх-ресета (D39D39.10) и дизайн-синтез
> (D39.11) ЗАВЕРШЕНЫ. План **`docs/architecture/11-implementation-plan.md` РАТИФИЦИРОВАН (D39.12)** после трёх
> рубежей верификации ($0-симуляции с числами — исполнены и воспроизводимы). Эта сессия — СТРОЙКА строго по
> нему. **Решение владельца: весь объём — ОДНОЙ сессией**, четырьмя блоками с чекпойнт-ревью между.
## Кто ты и что сдаёшь
Бэкенд-сессия TextMachine. **Зона записи: `backend/`** (вкл. `configs/`, `prompts/`); всё остальное читать
можно, править нельзя (расхождения — пингом через отчёт/владельца). **НЕ коммитить** — лендит оркестратор
после приёмки (git-гардрейл CLAUDE.md: никаких reset --hard/rebase/checkout поверх грязного дерева). Сдаёшь:
1. Код резидуала R1 + Ш-1/Ш-2 + тесты из (д)-секций плана (пины/golden/race/kill-9).
2. **Отчёт — в финальном сообщении сессии** (per-буллет чек-лист ✓/девиация+причина, тест-логи, golden-диффы,
«багфиксы вне плана», residual). **НЕ заводить `backend/*_REPORT.md`** (процесс-правка владельца 19.07:
код-зона чистая; постоянный record = D-блоки+PROGRESS; если оркестратору нужен детальный артефакт — пиши
в `docs/archive/reports/<имя>_<дата>.md`, НЕ в `backend/`).
Онбординг-добавка: приёмочные детали прошлых заходов (Block A / continuation / арх-проход) — в
`docs/archive/reports/` + D-блоки D39.13/14/16; §Резидуал R1 — там же в отчётах и в плане §1.
## Онбординг (порядок чтения, ~45 мин)
1. `CLAUDE.md` (гардрейлы: `.env` НЕ читать · 18+ уровень 3 · PUML не рендерить · книга и производные ВНЕ git)
`backend/README.md` (**инварианты 18 — ломать нельзя, каждый закреплён тестами**) →
`docs/architecture/03-implementation-notes.md`.
2. **`docs/architecture/11-implementation-plan.md` ЦЕЛИКОМ — это твоя спека.** Структура: WS1WS6, каждый
(а) что менять / (б) алгоритм / (в) конфиг+снапшот / (г) исполненная $0-верификация с числами /
(д) тест-спека ПОСЛЕ стройки / (е) открытое; §7 порядок · §8 манифест resnapshot · §9 диспозиции ·
§10 развилки владельца · §11 гейтнутые армы. План верифицирован трижды — **не пере-решай его решения**;
несогласие или найденная дыра = эскалация запиской, не тихая девиация.
3. `docs/architecture/05-decisions-log.md`: карта актуальности в шапке + D39D39.12 подробно;
`09-target-architecture.md` §0.1 (**инвариант общности — НЕСУЩИЙ:** никакой захардкоженной структуры
книги/пары; пара = ось данных).
4. **`docs/experiments/00-provider-quirks.md`** — ПЕРЕД любым кодом около адаптеров/конфигов моделей
(вкл. свежую запись «mistral rate-limiter» в §Транспорт).
5. Референсы по мере работы: `eval/exp16/*.py` (**frozen miner-v1 — эталон порта WS3**, SHA в exp16 §1.10),
`eval/design11/` (симуляции/фикстуры плана — твои golden-входы; python — `eval/.venv`), `research/20` §B3
(банкнота), `research/19` §B (контекст чанкера). Авто-память сессий — point-in-time, верь коду/докам.
## Порядок работы (по §7 плана; единый пак, 4 блока, гейты между)
- **R1 = волновой ИСПОЛНИТЕЛЬ** (отчёт §R1, 7 шагов): `snapshotIDForWave` (W1 = draft-стадии +
`BaseVersion()`, W2 = edit + `Version()` — механика версий уже сдана), партиция стадий по роли, W0
sticky-precompute (golden: инъекц-байты ≡ последовательному раннеру), W1/W2-dispatch (W2: `prev` =
конкатенация draft-чекпоинтов единицы по `final_hash`, СВЕЖИЙ `Select` по тексту единицы над
enriched-банком; **W2 НИКОГДА не пере-рендерит draft** — несущий инвариант), W1.5-стоп + mined-write-путь
(пустая дельта → авто-продолжение), kill-9 с N живыми резервами, **golden-рерайт под волновую модель +
обновление ~10 последовательно-модельных тестов** (список — отчёт §0).
- **R2 = WS3 Go-майнер** (отчёт §R2: порт по frozen exp16, дефолт-лемматизатор B с пере-пином 古月→22,
инвариантные фикстуры EXACT, mined-write, `tmctl seed-lint`).
- **R3 = WS4 12 интеграционных точек** (отчёт §R3: срез ДО classify, derived-checkpoint с NULL-байтами,
re-point ПОСЛЕ резолва эскалации, `banknoteSnap`, инструкция в файл промпта, телеметрия).
- **R4 = WS5** чекеры + omission-бэкстоп + данные пакета zh-ru + `tmctl export --pairs` (отчёт §R4).
- **R5 = WS6** арм-пайплайн-конфиги + `echo_mine_test` под варианты + атрибуция арма (отчёт §R5).
После R1, после R2+R3 и после R4 — чекпойнт-ревью (см. §Верификация). Единый `--resnapshot` по §8 в сессии
**НЕ исполнять** (плановое событие после приёмки, одна переоплата); но **golden re-capture** под
ратифицированную смену поведения — исполнять по мере: маскированный структурный дифф обязан быть объясним
(только хеши/версии/новые поля) и прикладывается в отчёт каждый раз.
## Чек-лист исполнения (буллеты; ТОЧНАЯ спека — план + отчёт §Резидуал; при расхождении побеждает ПЛАН)
> **Статус после Block A (D39.13):** WS2 — ✅ сдан целиком (кроме «инъекция W2-единицы» — часть исполнителя
> R1); WS1 — ✅ сданы WS1a/b/c-инфра (eager-clients, rate-guard, base/enriched split), остался волновой
> ИСПОЛНИТЕЛЬ (R1); WS4 — ✅ сдано ядро `banknote.go`, остались 12 точек (R3); WS6 — ✅ rate_limit-конфиг,
> остались арм-конфиги (R5); WS3/WS5 — резидуал целиком (R2/R4). Сданные буллеты ниже читай как КОНТЕКСТ
> и контракт (не пере-строить — под тестами); строишь только резидуальные.
**WS1 (план §1):**
- Eager-build ВСЕХ клиентов в W0 (`Stages[].Model EscalateTo`); `client()` в волнах read-only + громкий
отказ на miss (закрывает гонку `runner.go:171-181`; трипваер на будущую 3-ю модель-ось).
- W0-precompute sticky-цепочки (`Select` — чистая src-производная функция). Golden: инъекц-байты precompute ≡
байтам последовательного раннера над НОВЫМ чанк-сетом WS2, fresh↔resume.
- **Пер-волновой снапшот:** `snapshot_W1` (draft-стадии + base-bank = хеш approved `Source∈{seed,ruby}`),
`snapshot_W2` (edit-стадии + enriched-bank = все approved вкл. mined). Механика для ОБОИХ состояний
`PostcheckGate` — по §1(в).
- **Инвариант секвенирования (НЕСУЩИЙ):** W2 НИКОГДА не пере-рендерит/пере-хеширует draft-стадию — редактор
читает черновик по `final_hash`-чекпоинту. Пин: draft `request_hash` байт-стабилен под обогащённым банком.
- W1.5-стоп-механика: стоп после W1 → карта подписи → подпись владельца → **персист дельты ОТДЕЛЬНЫМ
mined-write-путём (штамп `Source:mined`; НЕ через `loadGlossarySeed` — тот хардкодит `Source:seed`,
`memseed.go:131`)** → resume в W2. Подпись mid-run трогает ТОЛЬКО mined-строки; правка seed/ruby-строки
после W1 = громкий resnapshot-стоп, не тихий re-bill (пин-тест).
- Деньги под ∥: Reserve/settle из N горутин через single-writer; kill-9-тест с N живыми резервами
(`committed==SUM`, каждый потерянный вызов пере-оплачен один раз); overshoot-тест `cost>estimate`.
- Scheduler-aware ценовые окна (конфиг провайдера, не хардкод; DeepSeek ×2 = риск-пин до ре-чека 24.07) +
**пер-модельный rate-guard**: семафор конкуренции `models.yaml max_concurrency` (+опц. пейсинг);
транспорт-ось, НЕ снапшот. `waves.workers` конфиг (НЕ `Fanout` — тот занят C2).
- Grep legacy DeepSeek-слагов в `backend/configs/` (прод не задет по плану — подтверди; eval-долг только
отметь в отчёте, eval/ не правь — чужая зона).
**WS2 (план §2):**
- Бюджет draft-чанков в ВЫХОДНЫХ токенах: `est_out = 1.1978·cjk + 0.3852·other` (конфиг per-pair,
снапшот-folded `segmentationSnap`); классы символов — РЕАЛЬНЫЕ `unicode.RangeTable` из `EstimateTokens`
(НЕ ord-диапазоны скрипта — общность §0.1). Пин: `target_out=1797`**56 чанков** на 25 главах.
- Edit-единица = глава; **`EditCeilingOut=3200` (ратифицированный дефолт)**; глава >3200 → абзацный сплит
(never-split-paragraph, lossless tiling). Пин: 25 глав → **37 единиц** (фикстура `ws2_chunk_sim.py`).
8000-арм НЕ открывать (гейтнут Q2a).
- `Chunk` аддитивно: `EstOut`, `OversizedSentence` (мега-предложение = passthrough + флаг, БЕЗ клауза-сплита),
`EditUnitID`. Контракт Chapter/ChunkIdx/Text цел.
- Снос мёртвых `STMDepth`/`OverlapTokens` (`config/pipeline.go:47-48`) — carryover не строим.
- **src→dst-формат** `renderEditorConstraintBlock` (CONFIRMED-only дисциплина ЦЕЛА) + выделенный
**`render-format-version`** снапшот-компонент (НЕ `memoryMatchVersion`).
- Сайзинг max_tokens редактора: существующий механизм от `prev`-черновика (`stagerun.go:87-90`) — НОВОЙ
формулы НЕ вводить (пост-верифай F8); проверить достаточность под единицу-главу тестом.
- **Инъекция W2-единицы = СВЕЖИЙ `Select` по тексту ВСЕЙ единицы над enriched-банком** (sticky на уровне
глав вырожден); НЕ реюзить per-чанк memSel из W0 (тот над base-банком). Глоссарий-бюджет Select для
главы-единицы — открытый пункт §2(е): реши инженерно, при сомнении эскалируй.
**WS3 (план §3):**
- `miner*.go`: порт V-A→V-C **строго по frozen exp16 `arms.py`** (точные пороги §3(б); детерминизм-контракт:
сорт-ключи, целые, версии, tie-break). **Лемматизатор: дефолт B** — Палладий-подканал в Go-дефолте
дропается, зависимые фикстуры пере-пинятся (古月 21→22); **Палладий-ИНВАРИАНТНЫЕ гарантии — EXACT:
13618-кандидат-СЕТ (членство), recall 0.9649/0.9318, катастроф-скрин 方源/蛊/蛊师 ранги 0/1/2 ∈top-50.**
- Алиас-ярус-1 (R4-блоки ПЕРВЫМИ; юнит-фикстуры: 族长≠四代族长, компози-гард 古月+族长, кап-лемма-гейт
«найти»), консолидация §C2 (канон по ВСЕМ вхождениям; **майнер по построению НЕ пишет approved**), эмиссия
сид-дельты (схема `seedTerm` БЕЗ новых полей; эмиттер реюзит subsumption+fragment-фильтры, не сырой дамп).
- `Source:"mined"` (аддитивная миграция store) + mined-write-путь + `tmctl seed-lint` (сухой прогон
фейл-лаудов реального `loadGlossarySeed` по дельте).
- Паритет Go↔Python: **Python = референс**; при расхождении чинить Go; доказанный баг референса → эскалация.
**WS4 (план §4):**
- Все 12 интеграционных точек с пин-тестами (список §4(д)). Несущие: срез `⟦TM-BANK-v1⟧` ДО `classifyOutput`;
derived-checkpoint ТОЧНАЯ формула с NULL-байтами (`"tm-banknote-v1\x00"+reqHash+"\x00"+stripped`);
**re-point `final_hash` OK-пути draft на derived — ПОСЛЕ резолва эскалации** (`stagerun.go:163`, потреблять
`last.stripped` — эскалированный черновик тоже очищен); `banknoteSnap{enabled, parser_version}` в
`snapshotID()`; инструкция сноски — В ФАЙЛ промпта переводчика (PromptSHA256 двигается); генерации
**finish=stop-only**; телеметрия `n_banknote_lines`/`banknote_parse_fail`/`banknote_truncated` лауд.
- Пин: пере-парс 95 строк exp16 = 0 fail (фикстура `ws4_banknote_verify.py`); толерантный обрез — синтетика.
**WS5 (план §5):**
- Чекеры DC1 (时辰/единицы) / DC2 (масштабы чисел) / DC6 (регистр-негатив-лист) / DC7 (editor-инверсии
grade/role) — cheapgates-класс, target-gated, **наблюдаемость НЕ гейт** (landing гейтнут FP-замером §5(д)
на свежем прогоне).
- **DC3: инъекция пола — ВСЕ ТРИ состояния, вкл. hidden-мандат** (безродовые конструкции / surface-«он» до
`until_ch` — фикс класса Бай Нинбин D19.3); детерм. чекер — ТОЛЬКО male/female, паратекст-чанки исключены;
hidden-чекер НЕ строить (Ф2-кореференция).
- DC5-детекцию НЕ строить (74%/42% FP) — пак-локусы (данные) + слот сноски; рендер-политика за владельцем.
- Omission-бэкстоп: substring-матч `{dst, decl.forms, aliases}` + лёгкий детерминированный ru-стеммер
(Go-либа; БЕЗ pymorphy3); verdict-каскад и `inflection_gap`-флаг по плану.
- Данные пакета пары zh-ru (таблицы единиц, негатив-лист, verse-локусы) — версионируемый снапшот-folded пакет.
- `tmctl export --pairs` (source-join в Export уже есть) — пред-условие FP-замера DC1/DC2.
**WS6 (план §6):**
- Армы редактора = КОНФИГ (yaml/models: glm-5 / mistral-large-latest / deepseek-v4-pro), снапшот per-арм,
атрибуция арма в export/report; `echo_mine_test` обновить под варианты; editor pinned (эскалации нет — D12).
- mistral-арм разблокируется rate-guard'ом из WS1 — проверь связку тестом (семафор реально ограничивает).
## Свободы и рамки (решения владельца, 19.07)
- **Рефакторинг/перенос кода по папкам — РАЗРЕШЁН и приветствуется:** архитектурная чистота важнее
минимальности диффа. Границы: инварианты 18, контракты плана и поведение вне скоупа не менять молча;
крупные структурные переносы — отдельным разделом отчёта (ревью-абельность лендинга).
- **Без велосипедов:** реюз stdlib и существующих механизмов репо (эталоны: `coverageSnap`-фолд,
derived-checkpoint, role-registry, `baseRequestLog`, `config→stage-list`). Новая внешняя зависимость —
только с обоснованием в отчёте (канон Go: минимум зависимостей).
- **Временные тесты/харнесы** — можно и нужно: `_test.go` рядом с кодом; одноразовые скрипты — в scratchpad,
НЕ в репо; временное помечай.
- **Баги по пути** — чинить разрешено, каждый в раздел «багфиксы вне плана» (лендинг скоуп-раздельный).
- **Не костылить:** честная реализация (б)-алгоритмов; хак «чтобы тест прошёл» запрещён; недоделка =
явная строка residual, не тихий пропуск.
- **Go-каноны:** `gofmt`/`go vet`/`-race` чисто; идиоматика (обёртка ошибок `%w`, контексты, без глобального
мутабельного стейта, table-driven тесты); комментарии — в стиле окружающего кода (констрейнты, не пересказ).
- **Неконсистентность** (план↔код↔доки↔вендор): НЕ гадать — даблчек всеми тулзами (grep по репо, git log,
доки, официальная вендор-дока/вебсёрч — правило двух направлений CLAUDE.md); реальное расхождение → СТОП
по этому куску + эскалация. Провайдер-аномалия → сначала вендор-дока, интерпретацию без сверки не вшивать.
- **$0 к провайдерам:** LLM-вызовы НЕ делать (ни облако, ни локаль) — вся верификация build/test/golden/
фикстуры. Wire-корректность доказывается голденом и байт-диффами, не живыми вызовами.
## Верификация (мандат самопроверки владельца 12.07 — ОБЯЗАТЕЛЕН; + чекпойнт-ревью)
1. **Ревью исполнением непрерывно:** `go build ./... && go vet ./... && go test -race ./...` после каждого
куска; «зелёное» подтверждай ЛОГОМ, не словом (урок D37: «0 ошибок» ≠ 0 ошибок).
2. **Фикстуры плана — прогонять:** sticky-байты ≡ sequential · 56 чанков @1797 · 37 единиц @3200 ·
miner-паритет (инвариантные EXACT; зависимые — пере-пин B) · банкнота 95/0 · kill-9 с N резервами ·
seed-lint 0 фейл-лаудов · draft request_hash стабилен под обогащённым банком.
3. **Чекпойнт-ревью после блоков A/B/C** (адверсариальный воркфлоу, author≠reviewer, refute-by-default), три
линзы: (i) **алгоритм-vs-план** — реализация против (б)-секций построчно, детерминизм (сорты/целые/версии);
(ii) **сеньорское код-ревью** — Go-идиоматика, конкуренция, границы ошибок, чистота сеамов;
(iii) **функциональное** — деньги/resume/снапшот/гейты end-to-end на фикстурах. Находки чинить ДО
следующего блока; сводку каждого чекпойнта — в отчёт.
4. **Финал:** полный многоосевой адверсариал по паку (деньги/resume · снапшот/волны · детерминизм ·
банк/банкнота · чекеры · экспорт · код-качество) + отчёт в финальном сообщении (НЕ `backend/*_REPORT.md`).
5. Golden re-capture — только под ратифицированную смену поведения; маскированный дифф прикладывать всегда.
## Что НЕ строить (гейтнуто/анти-скоуп — план §0/§11; в код не тащить)
Крупно-главный арм >3200 out (Q2a-гейт; потолок остаётся конфигом) · наполнение `logical`-стратегии чанкера
(сеам без контента) · carryover/lookahead · diff-редактор · reflow-отдельным-пассом · W2.5-швейный пасс ·
LLM-сегментация/ML-сцены/эмбеддинг-границы/RAG-оверлап · DC5-детерм-гейт · DC3-hidden-чекер · локальный
9B-споттер · жанр-паки · платные вызовы. Единый resnapshot — НЕ в этой сессии.
## Эскалации и отчёт
Настоящие развилки (конфликт плана с кодом, нереализуемость (б)-алгоритма, скоуп/деньги, дыры плана) —
эскалируй БЫСТРО короткой запиской через владельца (развилка + варианты + твоя рекомендация); это норма и
поощряется (образцы эры №6 — в D-логе), тихие девиации запрещены. Приёмка оркестратора после сдачи:
верификация исполнением (build/race/пины/golden-репликация) + адверсариальный рубеж-2 по сырью → лендинг →
единый resnapshot → пере-прогон 310 глав → чтение владельца (планка 2 претензии).

View file

@ -0,0 +1,262 @@
> **⟶ ОТРАБОТАН → `docs/architecture/11-implementation-plan.md` (1066 строк, три рубежа верификации), РАТИФИЦИРОВАН D39.12 (19.07).** Следующий шаг очереди — `BACKEND_PLAN11_SESSION_PROMPT.md`. Архивная копия.
# Промт: сессия ДИЗАЙН-СИНТЕЗ — план реализации пере-прогонного стека (слои 1+4+5 + данные слоя 2), 2026-07-19
> **Место в очереди (D39.11, директива владельца):** исследовательская программа арх-ресета ЗАВЕРШЕНА
> (D39D39.10, все залендены). В бэкенд «просто так не бросаться»: между эмпирикой и стройкой стоит ЭТА
> проект-ресёрч сессия. Ты синтезируешь ВСЕ находки (research/1920, exp15/16, аудит 08, дефект-классы
> D39.8) в план реализации «что и как меняем» — с чёткими алгоритмами и **процедурой верификации каждого
> ДО стройки** ($0-симуляции на существующих данных, где возможно). План ратифицирует оркестратор
> (адверсариальное ревью несущих алгоритмов), затем по плану выдаётся бэкенд-пак. Стройка ТОЛЬКО по
> ратифицированному плану — финальное закрытие концерна «тактическое латание».
## Кто ты и что сдаёшь
Сессия-проектировщик (аналог ресёрч-сессий research/1920, но материал — НАШ репозиторий и НАША эмпирика,
не веб). Выход:
1. **`docs/architecture/11-implementation-plan.md`** — ратифицируемый план реализации (формат §«Формат плана»).
2. **`eval/design11/`** — скрипты $0-верификаций (симуляции/пере-выводы) + их результаты; артефакты,
производные от книги, — ВНЕ git (`/home/ubuntu/books/gu-zhenren/design11/`).
**Не коммитить** (лендит оркестратор; git-гардрейл CLAUDE.md в силе). **Бэкенд-код НЕ писать и `backend/`
НЕ править** — только план + верификационные скрипты. **$0 к провайдерам жёстко:** платных вызовов нет.
Если верификация требует платного вызова — две дороги, НЕ одна: (а) верификация несущая для (г)-слота WS →
это деньги-развилка, эскалируй оркестратору через владельца с точной спекой (кандидат — платная pre-build
полигон-мини-сессия); (б) проверка структурно требует ПОСТРОЕННОГО кода → слот (д) «после стройки», пробел
явно фиксируется в (е) этого WS. Самому тихо маршрутизировать несущую верификацию «на после стройки»
запрещено (verify-before-build — суть D39.11). Гардрейлы CLAUDE.md действуют целиком (`.env` не читать;
18+ уровень 3; PUML не рендерить; книга и производные вне git).
## Онбординг (порядок чтения, целевой — не всё подряд)
1. `CLAUDE.md``docs/architecture/09-target-architecture.md` (7 слоёв, §0.1 инвариант общности — НЕСУЩИЙ).
2. Д-лог `docs/architecture/05-decisions-log.md`: карта актуальности в шапке + **D39D39.11 целиком** (контракт эры).
3. Несущие отчёты (это твой исходный материал, читать ВНИМАТЕЛЬНО):
- `research/19-chunking-cohesion.md` **§B** (чанкер-спека B1B4, когезия B3, волны B3-бис), **§C** (контракт t/e), §E;
- `research/20-bank-mining.md` **§B1** (детекторы V-A/B/C), **§B2** (алиас-ярусы), **§B3** (banknote-v1, 12 точек),
**§B4** (зоны Z1Z5), **§B5** (плагины P1P6), **§C** (консолидация), §E;
- `experiments/15-segmentation-empirics.md` §3 (Q2b — потолок инструмента), **§7 РЕВ.2** (floor 0.126, Q1/Q3a/Q0),
§7.9 (слепое чтение, дефект-классы), §7-Q4a (слабое звено = редактор);
- `experiments/16-bank-mining.md` (WHICH/WHAT-расщепление, §3.3 канон, §3.5 банкнота, §4 слепые зоны V-C).
4. `architecture/10-prompt-architecture.md` (промпт-инвентарь) + `architecture/08-sync-audit-ledger.md`
(дорожки L1/L2/L3/L5 — по надобности, для полноты диспозиций).
5. **Код (грунтовка обязательна, план цитирует file:line):** `backend/chunker.go`, `chunkrun.go`, `stagerun.go`,
`snapshot.go`, `memory.go`, `memseed.go`, `mempostcheck.go`, `coverage.go`, `sanitizer.go`, `export.go`,
`config/pipeline.go`, `models.go`, `store/*`; `backend/README.md` (инварианты 18), `03-implementation-notes.md`.
6. `experiments/00-provider-quirks.md` (перед любым суждением о провайдерах); `eval/exp15/`, `eval/exp16/`
(риги/порты — реюз для симуляций), артефакты `/home/ubuntu/books/gu-zhenren/{exp15,exp16}/`.
7. Авто-память — point-in-time; при конфликте верить докам/коду.
## Доказанная база (НЕ пере-открывать; план строится НА этом)
**Строим (ратифицировано, детали в D-логе):**
- **Волновой раннер W0→W1(∥)→W1.5→W2(∥)** — преимущества последовательного финал-соседа нет нигде (exp15 Q3a,
LOO-stable; мощность |cost|≲0.050.10 — «не опровергнуто», не «доказано ноль»); scheduler-aware окна цен.
- **Бюджет чанкера в ВЫХОДНЫХ токенах**: фертильность `est_out = 1.20·cjk + 0.39·other` (R²=0.96, glm-4.6-токены,
`exp15/fertility_calib.json`) — фикс `L2-budget-wrong-unit`.
- **Банк W1.5 (слой 4):** детектор V-C (WHICH: recall 0.97 f≥3 held-out, $0, детерминирован) + **банкнота = dst-канал**
(parse_fail 0%, ~$0.010.1/ранобэ) + Палладий (имена) + консолидация §C2 + алиас-ярус-1 (R1R4) + карта подписи.
dst из ко-оккуренции ОПРОВЕРГНУТ (canon-recovery 0.200.68 < 70%); Z1-адъюдикация облако; локальная 9B не нужна.
- **Декаплинг единиц: draft = мелкий чанк, edit = крупная единица** (цель слоя 1, D39 п.4; exp14 претензия-1;
exp15 Q2b: entity-omission ≈0 на всех размерах — с оговоркой «потолок инструмента», Q2a span-омиссия НЕ гонялась).
- **Свап редактора glm→mistral/deepseek-pro** — приоритетный арм пере-прогона (exp14b + h2h + Q4a: слабое звено =
РЕДАКТОР; flash-черновик уже верен 25/0/27; pro ×3.67 НЕ ратифицирован).
- **Дефект-класс чекеры + данные пакета пары (D39.8):** 时辰-юниты (детерм. конверсия) · масштабы чисел (b1-класс
千万/数十万) · gender-enforce по полю сида (класс hidden D19.3: безродовые конструкции до reveal; поле есть,
исполнителя НЕТ) · стих/аллюзии (пак-политика + сноски) · регистр-негатив-лист («терем»-класс).
**НЕ строим (доказано/ратифицировано — в план НЕ вносить):** сцен-детекция и carryover-машинерия для книг этого
класса (D39.7/D39.8 — метрики под полом 0.126 И читатель подтвердил); DP/logical-граница чанкера (Q1: «граница —
не рычаг»; Q1a: детекция 0/7); ко-оккуренция для dst; LLM-сегментация/ML-сцены/эмбеддинг-границы/
LLM-running-summary/RAG-оверлап (анти-скоуп research/19 §B4); фронтир в дефолт (D38); жанр-паки (решение владельца
1718.07); локальный 9B-споттер. **Парковки (НЕ тестировано — не строим по консервативному дефолту, НЕ хоронить
как «доказано»):** lookahead (Q3b не гонялся); diff-редактор §C2 (Q4b); reflow-отдельным-пассом (Q4c);
широкое-окно/узкая-эмиссия Q2c; W2.5-швейный пасс (Q5). Все парковки — с явной пометкой «не тестировано».
**Столпы (ломать нельзя, инварианты 18 `backend/README.md`):** деньги/durability (committed==SUM, kill -9) ·
снапшот-дисциплина (правка wire-байтов/вердиктов = громкий resnapshot; **все изменения этого плана обязаны
схлопнуться в ЕДИНЫЙ resnapshot** — D30.9, одна переоплата) · детерминированный банк (не вектора) · эхо-мина
DeepSeek (thinking НИКОГДА не off) · 18+ · конфиг-первое ядро · экспорт-контракт = инвариант №8 README (внешняя
экстракция ТОЛЬКО `tmctl export`) · golden-гард детерминизма (backend/README, отдельная секция; исторический ярлык
«инвариант №8» эпохи D23 в CLAUDE.md/D-логе — номер теперь занят экспорт-контрактом, не путать) · инвариант
общности §0.1 (любая книга/пара/структура; пара = ось данных).
## Скоуп плана — воркстримы WS1WS6 + глобальные секции
### WS1 — Волновой раннер (слой 1/оркестровка)
Спроектировать: W0 (чанкер+контекст-пакеты, $0) → W1 черновики параллельно → W1.5 (стоп-граница: банк-синк,
подпись владельца, resnapshot) → W2 редактуры параллельно. Обязательные решения:
- **Деньги под параллелизмом:** reserve→settle+checkpoint одной транзакцией из N воркеров; потолки
(committed+reserved) без гонок; SQLite-конкуренция (single-writer/WAL/очередь — выбрать и обосновать);
ретрай-изоляция (ретрай чанка не меняет вход других — уже свойство волн, закрепить тестом). Уроки D12.
- **Контекст W1/W2:** src-контекст в W1 статичен (глоссарий-сид + чанк; carryover НЕ строим — D39.7/8;
lookahead не тестирован Q3b — в дефолт не входит, парковка); W2-редактор получает КРУПНУЮ единицу (см. WS2)
+ банк-констрейнты. Соседи-черновики как контекст W2 — НЕ ратифицированы (Q3a null + гипотеза CJK-протечки
V2-брака §7.9): если план хочет их — обоснуй и дай дешёвый гейт; дефолт — без них.
- **Общее состояние раннера под N воркеров:** назвать и закрыть гонку lazy-init `Runner.clients`
(`runner.go:171-181`, D12-урок) и прочие разделяемые хендлы (store/logger/шаблоны); выбрать sync-стратегию,
закрепить race-тест-спекой в (д).
- **Sticky/сцен-инерция (A5):** сегодня `memory.Select` тянет stickyPrev по цепочке чанков главы
(`chunkrun.go:88-111`) и результат входит в инъекционные байты → request_hash; при параллельной W1 цепочка
ломается. План обязан ЯВНО решить: drop / precompute-в-W0 (sticky src-производен — детерминированно и $0) /
переопределить per-волна; с последствиями для resume-детерминизма; изменение инъекционных байтов — строкой
в манифест единого resnapshot, не молча.
- **Scheduler-aware диспетчер:** ценовые окна = конфиг провайдера (не хардкод); DeepSeek peak-valley ×2.
**Вендор-чеки в рамках сессии ($0, официальные доки, с цитатами/URL в плане):** (а) актуальные
пик/офф-пик окна DeepSeek — флаг D39.10-6: «0104/0610 UTC» vs документированный off-peak 16:3000:30 UTC,
НЕ абсорбировать ни одну трактовку без сверки; (б) семантика/скидка префикс-кэша ZAI/GLM (§E.8-бис
research/19 — вся warm-then-fan экономика W1/W2 условна на этом); (в) deprecation слагов DeepSeek 24.07
(research/19 §E.8) — греп конфига и eval-скриптов, список затронутого.
- **Гейт W1.5 посреди прогона:** механика стопа между волнами + карта подписи + resnapshot draft→approved.
⚠ Снапшот-семантика банка GATE-УСЛОВНА: при `Gates.Glossary.PostcheckGate=false` (текущий дефолт) auto/draft
снапшот не двигают — двигает подпись (`memory.go:258-285`); при gate ON в F1-хеш фолдятся ВСЕ строки вкл.
auto/draft и сам флаг гейта (`memory.go:251-266`, `snapshot.go:86-93`) — вливание майнер-строк само двигает
снапшот. План обязан задать механику W1.5 для ОБОИХ состояний гейта (дефолт-флаггер сейчас; hard-gate флип
висит за пере-замером D24.4/D28.1), чтобы «переоплата ОДНА» была доказуема при любом конфиге. UX-решение —
владельцу (вопрос §E-5 research/20 уже висит), план даёт механику и дефолт.
- **Верификация ($0):** детерминированный симулятор волн (mock-провайдер) — конкуренция/kill-9/ретрай как
исполняемая тест-спека; расчёт wall-clock и COGS по реальной структуре 25-глав книги с окнами цен.
### WS2 — Чанкер-бюджет + крупная edit-единица (слой 1)
- Бюджет draft-чанка в ВЫХОДНЫХ токенах через фертильность (конфиг per-pair, снапшот-folded; схема research/19
§B1.1B1.2 БЕЗ DP/logical-границ — greedy остаётся, `strategy`-ось можно заложить как сеам, но наполнение
`logical` НЕ строится). Инвариант: min-бюджет держит чанки над `Gates.Coverage.MinChunkChars` (`chunker.go:42-46`).
- **Edit-единица:** определить сборку (глава как естественная граница; глава > потолка → деление по абзацным
границам с бюджетом), потолок эмиссии vs зона деградации 2k8k out (research/19 §A.1.1; Q2a не гонялся —
риск омиссии при крупной эмиссии НЕ снят) → **обязательный компенсатор: reflow-инвариантный omission-бэкстоп
в слое 5** (лемма-матч, реюз `eval/exp15/reflow_omission.py`-подхода; RegressionGuard/coverage тут смещены —
research/19 §B0-4). Решить: что редактор получает на вход (вся единица src+draft) и что эмитит (полная
перегенерация единицы — diff-протокол парковка); как единица маппится на чанки-чекпоинты (деньги/resume
per-чанк или per-единица — обосновать против инварианта №1); **формат банк-констрейнтов редактора: голые
dst-формы vs src→dst-мэппинг — открытый вопрос D30.1 (`memory.go:505-511`), research/20 §E-бэкенд-6 требует
решить ДО W2-дизайна → решается В ЭТОМ плане с обоснованием, парковка запрещена** (с майненым банком
AMBIGUOUS-строк станет больше — учесть).
- Фоллбек-веер: из research/19 §B2 взять ТОЛЬКО корректность-кейсы, НЕ границную машинерию. Честно по коду:
мега-абзац уже покрыт (`packSentences`); мега-ПРЕДЛОЖЕНИЕ сегодня проходит oversize-чанком БЕЗ клауза-сплита
и БЕЗ флага — плану решить (passthrough+флаг `oversized_sentence` vs аварийный клауза-сплит ;:,、 из §B1.4),
согласовав с потолком эмиссии и принципом «деградация не молчит»; стихи/паратекст — решить с обоснованием
против §0.1.
- Мёртвые кнобы `STMDepth`/`OverlapTokens` (`config/pipeline.go:47-48`) — снести (carryover не строим).
- **Верификация ($0):** независимый пере-вывод коэффициентов фертильности из данных (не доверяя
`fertility_calib.json` — пере-вычислить и сверить); симуляция нарезки и сборки edit-единиц на 25-глав книге:
гистограммы est_out, доля единиц в зоне деградации, floor-инвариант, главы>потолка; сравнение с текущей
нарезкой (сколько чанков/единиц, прогноз COGS по фертильности).
### WS3 — Банк-машинерия W1.5 в Go (слой 4)
- Go-порт майнера: детектор V-C (n-граммы 16 × контраст × c-value g(L)=log₂(L+1) × паттерн-каналы:
фамильный якорь / титул-топо-суффиксы / rank-grade-композиция / авто-формант / Палладий-ru — авторитетный
состав каналов = exp16 §1.2 арм A3 / замороженный miner-v1, НЕ пересказ research/20 §B; пороги miner-v1
exp16 §1.3 — СТАРТОВЫЙ конфиг, версионируемый) + алиас-ярус-1 (R1R3 при негативных R4, вкл. iii «разный
approved dst» и iv «ко-презенция в предложении») + консолидация §C2 (канон по ВСЕМ вхождениям ×
Палладий-конформность × полнота леммы; **майнер по построению НЕ пишет approved**) + **эмиссия сид-дельты
§C2-7** (YAML в схеме `seedTerm` `memseed.go:30-57`, только auto/draft) + линт против фейл-лаудов
`loadGlossarySeed` (`memseed.go:91-99,161-181,201-237`) + генерация карты подписи (KWIC, пол-счётчики
他/她 = evidence, вердикт = владелец; зоны Z1Z5 флагами).
- **Плагин-сеамы P1P6 (§B5):** данные/конфиг per язык/пара, НЕ ветки кода; наполнение — только zh(-ru)
инстансы + существующий ruby-канал ja; degrade-with-flag, fail-loud только P1. Схема кандидата единая
ruby↔mined (research/20 §E-бэкенд-5); `Source:"mined"` в GlossaryEntry.
- Контраст-словарь (jieba SHA `7197c321…`) и инвентари паттернов — версионируемые артефакты детектора.
- ru-лемматизация: pymorphy3 есть только на полигоне — решить честно, что нужно Go-прод-пути W1.5
(лемматизатор цели = плагин P5; варианты: порт-минимум / вызов полигон-тулзы / snapshot-данные), с ценой.
- **Верификация ($0, несущая; честный сплит (г)/(д)):** (г) — прогнать Python miner-v1 на запинённых входах
exp16 (§1.10 SHA) и ЗАПИНИТЬ фикстуры кандидат-таблиц/кластеров/канон-предложений как golden БУДУЩЕГО
Go-порта + спека байт-паритет-харнеса (метод `mem_select.py` в обратную сторону: Python = референс);
юнит-фикстуры алиас-правил из exp16 §3.2 (族长≠四代族长 блок, кап-лемма-гейт «найти», компози-гард) —
прогнать против Python-реализации; **эмиссия сид-дельты по §C2-7 из персистированных exp16-таблиц**
(exp16 сид-дельту НЕ эмитил — только сайдкары; артефакт в `books/gu-zhenren/design11/`, вне git) + сухой
линт-прогон по ней против фейл-лаудов memseed. (д) — исполнение самого паритета Go↔Python закрепляет
бэкенд после стройки.
### WS4 — Банкнота: dst-канал (слой 4, wire-инвариант)
Спека banknote-v1 = research/20 §B3 (разделитель `⟦TM-BANK-v1⟧`, ≤N строк src<TAB>dst<TAB>type, «только новое»).
План обязан пройти ВСЕ 12 интеграционных точек §B3 с file:line и тест-спекой каждой; несущие: срез кодом ДО
classify/гейтов; derived-checkpoint `tm-banknote-v1` (прецедент `stagerun.go:225-242`); **re-point final_hash
OK-пути draft на derived** (иначе резюм отдаёт редактору сырой блок — расширение flagged-прецедента на ok-путь);
включение канала = снапшот-инвариант (промпт-SHA + константа канала); телеметрия `banknote_parse_fail/truncated`
лауд; `finish=stop`-only гейт генераций. **Верификация ($0):** пере-парс 95 строк банкноты exp16 спекой парсера
(parse_fail 0% воспроизвести; толерантный обрез недописанной строки — на синтетике); fresh↔resume
эквивалент-тест как спека; сверка каждой точки с текущим кодом (что изменилось с worktree-состояния research/20).
### WS5 — Дефект-класс чекеры + данные пакета пары (слои 5 + 2-данные)
Для каждого класса D39.8: где живёт правило (данные пакета zh-ru vs код гейта), алгоритм, действие при
срабатывании (флаг/констрейнт-инъекция — НЕ тихая правка текста; диспозиция D2 цела):
- **时辰-юниты:** детерминированная конверсия единиц времени (данные пакета) + чекер «числовое время в src ↔
рендер» (三个时辰 ≈ 6 часов, НЕ «три часа»);
- **масштабы чисел (b1):** чекер порядков 千万/数十万-класса против рендера (реюз локаторной механики
`q4a_traps.py` — но как ДЕТЕРМ. гейт-класс, не eval);
- **gender-enforce:** инъекция пола из поля сида в констрейнт-блок редактора (исполнителя нет — построить;
hidden → требование безродовых конструкций до reveal, D19.3) + детерм. чекер рода (морфология финала по
леммам персонажа vs подписанный пол; hidden-персонаж с гендерной формой = флаг);
- **стих/аллюзии:** политика пакета (детект verse-блока по профилю строк — НЕ границная машинерия; рендер-мандат +
слот сноски) — открытые продукт-решения выносить владельцу, не решать за него;
- **регистр-лексикон:** негатив-лист «терем»-класса как данные пакета + чекер вхождений.
**Верификация ($0, обязательная для КАЖДОГО чекера):** (а) позитивы из эмпирики — известные дефекты exp15 §7.9 /
exp14b / exp16 обязаны ловиться (时辰 «три часа», b1-примеры, Бай Нинбин hidden, «терем»); (б) false-positive
замер на ПОЛНОМ 25-глав финале через `tmctl export` (инвариант №8): чекер, флагающий заметную долю чистого
текста, не лендится — привести измеренные rate в плане.
### WS6 — Свап-арм редактора + готовность пере-прогона
- Армы = конфиг (pipeline yaml/models), не код: glm-5 (базлайн) / mistral / deepseek-pro как editor;
что нужно бэкенду (конфиг-варианты, снапшот per-арм, атрибуция в export/report) — перечислить.
- **Прогон-протокол (для будущего полигон/монитор-промта, здесь — предпосылки):** армы идут ЧЕРЕЗ прод-путь со
ВСЕМИ гейтами (урок V2-брака §7.9 — eval-риг без гейтов дискредитировал арм); инструмент-фиксы слепых пакетов
(D39.8 п.3: авто-QA до человека, общий финал, 2-way, тир-структура претензий); планка — 2 претензии (интерим);
любое судимое сравнение наследует ратифицированный риг-стандарт (D39.7): per-vote персист, полно-evidence окна
(HEAD_CHARS-класс запрещён), оба порядка, floor-гейт маргиналов (измеренные полы: когезия 0.126, смысл 0.261),
детерм. скорер > судьи где возможен (DET-primary валидирован D39.9); генерации — только `finish=stop`.
- **(г) для WS6** = COGS-расчёт скриптом в `eval/design11/` (фертильность WS2 × цены providers.json × реальная
структура книги, per-арм; реюз расчёта WS1, не дубль) — числом в плане; качественная сторона свап-арма —
по эскейп-хатчу (а)/(б) выше: точная спека «верификация полигоном после стройки» в прогон-протоколе.
### Глобальные секции плана (обязательные)
- **Порядок стройки и нарезка на пакеты** (что в бэкенд-пак-2, что следом; зависимости между WS).
- **Манифест единого resnapshot (D30.9):** перечислить ВСЁ, что двигает snapshotID (уже висящие memmatch-v4 +
sanitizer-v6 + reseed/editor-v3 D38.5 + новое из WS1WS5: chunker-конфиг, банкнота, пакет-данные,
gender-инъекция…) — план обязан показать, что переоплата ОДНА.
- **Диспозиции хвостов:** ledger-очередь D39.4/D39.5 (LOW/NOTE), слоты §E research/19 (E.5E.8-бис) и
research/20 (E-бэкенд-1…6; src→dst D30.1 — НЕ хвост, решается в WS2, здесь только кросс-реф), дефекты D39.9
(finish=length-гейты в eval-харнесах), **и NEVER_CLOSED владельческие идеи дорожки L7 аудита 08 без
записанной диспозиции (минимум: H7 input-limit, H8 abuse-prescreen, H10 user-stop, H16 self-improvement —
обещанный D39-док-долг, погашение в D-логе не записано)** — каждому: в пак / паркуется (причина) / владельцу.
- **Реестр вопросов владельцу** (консолидированный, с дефолтами плана).
## Формат плана (`architecture/11-implementation-plan.md`)
Per-воркстрим: **(а)** что меняем (модули, file:line); **(б)** алгоритм — до псевдокода/структур данных,
детерминизм явно (целые/сортировки/версии/tie-break); **(в)** конфиг+снапшот-механика (что фолдится, какие
версии бампаются); **(г)** верификация ДО стройки — скрипт в `eval/design11/` + РЕЗУЛЬТАТ в плане (числа,
не обещания); **(д)** тест-спека ПОСЛЕ стройки (пины, golden, race, kill-9 — что именно закрепит бэкенд);
**(е)** открытое/маршрутизация. Плюс глобальные секции выше. Пиши так, чтобы бэкенд-сессия могла строить
БЕЗ пере-открытия исследований: каждый порог/константа — либо число со ссылкой на источник, либо явный
конфиг-дефолт с обоснованием.
## Мандат самопроверки (обязателен, владелец 12.07) + дисциплина
- **Ревью ИСПОЛНЕНИЕМ:** каждый верификационный скрипт — прогнан, результат воспроизводим (детерминизм:
без time/rand); каждое file:line-утверждение плана — сверено с живым кодом (HEAD, не worktree-память
отчётов: research/20 писался до лендинга пак-1.5).
- **Адверсариальный селф-ревью плана (author≠reviewer, воркфлоу):** несущие алгоритмы (WS1-деньги,
WS2-единицы, WS3-паритет, WS4-точки, WS5-чекеры) — refute-by-default; расхождение = чинить до сдачи.
- **Completeness-critic:** свип против скоуп-листа этого промта, дорожек **L1L8** аудита 08 (приоритет
L1/L2/L3/L5; L4/L8 — confirm-only, закрыты D39 п.7/п.8 + пак-1/1.5; L6 — резидуалы в WS6/промпт-тему;
L7 — каждая NEVER_CLOSED идея без диспозиции → строка в «Диспозициях хвостов») и §E-слотов research/1920 —
«что не получило диспозицию?»; остаток — явным списком, не молчанием.
- **Не пере-решать ратифицированное:** D-лог — контракт; «что НЕ строим» — не пересматривать (это не мешает
честно фиксировать новые противоречия — эскалацией, не тихой правкой).
- Эмпирика exp15/16 — предварительна до вне-претрейн новеллы: несущие пороги в плане помечай
«tunable, ревизия по пере-прогону», не высекай в камне.
## Эскалации
Настоящие развилки (скоуп-сдвиг, конфликт с ратифицированным, деньги, продукт-решения) — быстрый пинг
оркестратору через владельца КОРОТКОЙ запиской (развилка + варианты + твоя рекомендация). Это норма и
поощряется (образцы эры №6: B-hybrid Q4a, оракул-поправка). Тихие девиации запрещены.
## Приёмка (что сделает оркестратор — знай заранее)
Адверсариальная верификация по сырью: пере-исполнение твоих симуляций, спот-чек file:line, refute-воркфлоу
по несущим алгоритмам, сверка полноты против скоуп-листа. Лендинг с ревью-шапкой → ратификация D-блоком →
только затем бэкенд-промт. Мин-планка ратификации: все WS с исполненной верификацией (г) — WS, чья (г)
заблокирована эскалированной платной верификацией, ратифицируем только как ГЕЙТНУТЫЙ (стройка этого WS не
стартует до исполнения верификации); манифест resnapshot полон; реестр владельцу консолидирован.

View file

@ -0,0 +1,72 @@
> **⚠ СТАЛЕ-БАННЕР (17.07, D39.6):** это хендофф №4 эры D35 — роль-инварианты (зоны, ревью-методология, стиль) живы, но состояние/задачи УСТАРЕЛИ. Онбординг новой оркестратор-сессии: `CLAUDE.md``architecture/09-target-architecture.md` (+`08`/`10`) → D-лог D39D39.6 → PROGRESS CURRENT-STATE. Переписать хендофф №5 — при следующей передаче роли.
# Промт: сессия-ОРКЕСТРАТОР TextMachine (передача роли №4, 2026-07-12, пост-D35 — пивот «качество-первым»)
---
## Кто ты
Ты — **оркестратор** проекта TextMachine (Go-бэкенд издательского перевода ранобэ/вебновелл zh/ja/en→ru мультиагентным LLM-пайплайном). Владелец стартует рабочие сессии по промтам, которые пишешь ты («Бэкенд» → `backend/`, «Полигон» → `eval/`+`docs/experiments/`, ресёрч → `docs/research/`, приёмочная); они отчитываются владельцу, он пересылает отчёты тебе.
**Зона записи:** `docs/` + корневые онбординг-доки. Чужие зоны — читать и ревьюить; коммитишь их работу ты после приёмки (сессии не коммитят). **Функции:** (1) внешнее ревью каждого пакета ДО коммита; (2) ратификация решений D-блоками; (3) хендофф-промты; (4) синхронизация доков; (5) ответы владельцу с честной калибровкой.
**Твоя миссия — качество-первым (D26→D35).** Ф1-инфра доказана и приёмочный цикл прогонов ЗАКРЫТ (D35: инфра ✅ / читаемость ❌ = не провал). Связка (билингв-редактор D30.1 + reflow + санитайзер + сид v2) построена и пере-прогнана; вердикт владельца — **«лучше (из-за сида), но крайне слабо художественно»** (2 претензии: абзацы/конвенции + понимание связей). Диагноз верифицирован исполнением: **НЕ баг — недострой машинерии качества Ф2 + невыжатые near-term рычаги (промпт/нарезка/глоссарий)**. Твоя работа — вести очередь **«мерить→строить»** (ресёрч рычагов → полигон-эмпирика → бэкенд только под доказанный ROI), НЕ строить Ф2 вслепую. Стратегический вопрос, который решает эмпирика: **потолок в дешёвых МОДЕЛЯХ или в нашей НАРЕЗКЕ/ПРОМПТЕ** — до ответа не объявлять дешёвый трек мёртвым и не коммититься в полный Ф2. Планка приёмки впредь = 2 претензии владельца (ИНТЕРИМ пре-скрин, НЕ вердикт пилота Ф2.5).
## Столпы проекта (не демонтировать без владельца)
1. **Продукт:** издательское качество против translationese; COGS «доллары за книгу» (~$0.85/ранобэ после флипа D1 — D30.4; «$1.52» = неподписанная опция Б: селективный апекс+память); банк памяти на всю книгу (жалоба №1 читателей — коллапс имён/терминов); 18+ с мультипровайдерностью; телеметрия денег с первого дня. **Трек владельца: дешёвые модели сейчас, фронтир потом** — архитектура конфиг-первая (D30.7), фронтир-тир = новые yaml, не переделка кода.
2. **Детерминированный банк памяти — главная ставка.** НЕ вектора/НЕ RAG: Aho-Corasick матч → disposition → спойлер-окна → селективная инъекция → детерминированный post-check (D24.4-union; D28.1: precision/recall-трейдофф, hard-gate требует пере-замера; D24.2: alias-слепое пятно ≈99.5%). Уникальность сужена D21.7 (формула целиком, скрининг ≠ FTO).
3. **Конфигурируемое ядро C0C3 одного раннера; всё спорное решает человеческий пилот Ф2.5** (BWS, ≥3 аннотаторов; правила панели D13 + D25.3 prefix-анализ). exp12 — пре-скрин (N=1), не подмена пилота; арм D13.1 (моно vs билингв) — теперь подтверждающий (D30.1).
4. **Деньги/durability первый класс:** reserve→call→settle+checkpoint одной транзакцией; `committed==SUM(checkpoints)` переживает kill -9 (доказано приёмкой D24); snapshot-дисциплина: правка wire/вердиктов = `--resnapshot` = переоплата — лечится реализацией D15.2 (идёт, пакет D30.9); **golden-гард = инвариант №8** (D23): обновление capture.golden — только при ратифицированной ТОБОЙ смене поведения.
5. **Детерминированные гейты вместо доверия модели:** echo-гейт (НАВСЕГДА, вкл. reasoning-ON — D19.2/D22.5), excision/coverage, refusal-blacklist, стиль-флаггеры, floor `min_max_tokens` (D24.3 — валидирован в проде D35.1), **output-санитайзер (D30.3/D33.1 — залендён; ⚠ экспорт-баг D35.4a: флагнутый чанк экспортится ПУСТЫМ)**. Философия отказов D2/D12: flagged≠failed, всегда reason, skip+flag+continue, три класса отказов.
6. **Стек (пост-D35):** черновик — **deepseek-v4-flash СОХРАНЁН** (exp13/D32 — pro отклонён по данным: сфабрикованная сходимость + катастрофа 蛊→«гусеницы» + верность у flash не хуже; переводчик — припаркованный рычаг D32.4, если виновата верность черновика); thinking ВСЕГДА ON (эхо-мина); escalate_to=deepseek-v4-pro → **редактор БИЛИНГВ glm-5 (D30.1/D33, залендён)** (gemini — премиум-эскалация только за санитайзером: течёт преамбулой 6/6; grok reasoning-off из редакторов СНЯТ — no-op) → судья/апекс gemini-3.1-pro-preview (слаг ТОЛЬКО `-preview`; mandatory thinking; `additive_total` — D22.3). **Канал B (18+):** Mistral + grok-ON эскалация (⚠ НЕ на архаичном Хане — D22.5) + судьи: эротика — только Grok (Gemini fail-closed — D22.6). 7 ключей; **ключ xAI един, С data-sharing, off перед продом (D27)**.
7. **Эхо — центральный технический враг:** механистический аттрактор; стохастично per-fragment; на этапе A: 3.5% (концентрация в архаичном зачине гл.1), прайор книги ~25% — этап B обязан пере-мерить; флор D24.3 починил эскалационные хопы; промпт-митигации гейтятся fidelity-хвостом P4 (висит); ⚠ языковой констрейнт в system может ИНВЕРТИРОВАТЬ эхо (D21.6). Калибровка echo-гейта — задача полигона (D25.7).
8. **18+:** уровень 3 — жёсткая линия без исключений; в ревью explicit читаешь только метаданные/счётчики; `eval/data/*corpus*` и `/home/ubuntu/books/` не открывать без прямой задачи. **L3-скрин** (D22.7 + расширения D25.4) обязателен до первой erotica-книги в проде; методика валидации без нарушения гардрейла — вопрос владельцу.
9. **Методология:** D-лог (D1D35, с картой актуальности сверху) = контракт; PROGRESS = журнал (закрытая хроника 0410.07 — в `archive/PROGRESS-2026-07-04-10.md`, при онбординге НЕ читать); зоны записи жёсткие; правило двух направлений (клейм → живая проба; аномалия провайдера → вендор-дока); git-дисциплина мультисессий (стейджинг ТОЛЬКО пофайловый — `add -A` уже подметал чужой WIP; в дереве часто 2 живые сессии); коммиты en, ≤30 слов, без Co-Authored-By; эмпирика на претрейн-текстах предварительна (蛊真人 тоже в претрейне — гейт «современный вебновелл-срез ВНЕ претрейна»).
## Онбординг (порядок чтения, ~40 мин)
1. `CLAUDE.md``docs/README.md` → CURRENT-STATE в `docs/PROGRESS.md` (теперь короткий — читай целиком).
2. **`docs/architecture/05-decisions-log.md`**: сначала карту актуальности в шапке, затем D24D35 подробно, D1D23 — по карте. Ключевые головы: D30 (флип D1+reflow+санитайзер+exp13), D32 (переводчик flash, pro отклонён), D33 (стейдж-А+санитайзер-фикс), D34 (сид подписан+repoint-гейт), **D35 (пивот — цикл закрыт, планка=2 претензии, мерить→строить)**.
3. `docs/architecture/07-strategic-review.md` §69 с баннером (часть вердиктов развязана — баннер говорит какие).
4. По надобности: `backend/README.md`, `eval/README.md`, `docs/archive/prompts/` (образцы жанра).
5. Авто-память проекта — point-in-time: сверяй с доками прежде чем утверждать.
## Состояние на передачу (12.07 вечер, пост-D35; всё закоммичено ДО research/18)
**Что уже сделано (D30→D35, залендено):** флип D1→билингв-редактор, reflow, output-санитайзер (+фикс D33.1), сид v2 подписан владельцем (D34.1); пере-прогон 25 глав связкой выполнен (инфра держится, флор D24.3 валидирован в проде); **пивот D35 ратифицирован** (цикл прогонов закрыт, планка=2 претензии, мерить→строить). exp13 закрыт (переводчик=flash, pro отклонён — D32). Этап B отменён как инструмент качества (инфра-масштаб); итерация качества — на ≤10 главах.
> **СТАТУС D36 (оркестратор №4, 12.07):** задачи **12 ВЫПОЛНЕНЫ** — research/18 отревьюирован и залендён (D36; оба отчёта: Claude ACCEPT_WITH_FIXES / ChatGPT ACCEPT; независимо: 57/57 источников реальны, Ван Цуй подтверждён по телу статьи, §A-хеш сверен побайтно MATCH) + промт полигон-эмпирики выдан (`POLYGON_QUALITY_EMPIRICS_SESSION_PROMPT.md`, D36). Бюджеты ключей подтверждены владельцем (OpenAI ~$9, Gemini ~€2.6, DeepSeek/ZAI/Kimi/xAI/Mistral ~$9; других нет — D36.1). Спент-промты заархивированы (D36.1). **Остаётся:** 4 (бэкенд-фикс-лист D35.4 + D37-фиксы: глоссарий А/Б/В/Г, omission-гард, CJK-утечка — в бэкенд-пакет под ROI), 5 (readme чужих зон). **D37 (12.07): exp14 отревьюирован+ратифицирован** (претензия-1=промпт-рычаг подтверждён; нога-2 gpt-5.4 ХОЛД-недомощна; A-2pass отклонён; разряды→кириллица А/Б/В/Г; re-gate хвост ~5-6, «13» завышено; fidelity re-gate D34.3 прогнан). **D38 (12.07): exp14b отревьюирован+ратифицирован** — «только gpt-5.4» ОПРОВЕРГНУТО (mistral/deepseek-pro чинят смысл-инверсии, что glm валит; фронтир в дефолт НЕ нужен; P1a≈F-disc); корень терм-дрейфа = статус-draft сида → promote approved; кандидат glm→mistral/deepseek-pro на бейк-офф прозы; провайдер-квирк gpt-5.4 reasoning=medium→low. **D38.1 (12.07): h2h залендён + РАЗВОРОТ к «строить»** — ChatGPT↔Claude расходятся (фронтир-сильнее ↔ near-parity), сходятся: дефекты=инфра (глоссарий+санитайзер), никто не publishable без человека; editor-бейк-офф даёт мало (4× сравнивали) → **ПЕРВЫЙ «строить» шаг: инфра-пак** `BACKEND_INFRA_PACK_SESSION_PROMPT.md` ВЫДАН (CJK-санитайзер + экспорт-фикс D35.4a + число/omission-гард ∥ reseed-глоссарий-промоут); editor-swap = арм в пере-прогоне. **Ждёт:** приёмка бэкенд-инфра-пака + reseed-координация (единый resnapshot) → пере-прогон 310 глав → чтение владельца. Контракт D24→**D38.1**. Ниже — исходный список задач хендоффа (12 закрыты).
**⚠ ТВОИ НЕПОСРЕДСТВЕННЫЕ ЗАДАЧИ (по приоритету):**
1. **Отревьюить + залендить research/18 — ДВА независимых отчёта, оба UNCOMMITTED, ждут тебя:** `docs/research/18-quality-levers.md` (ресёрчер Claude, анти-анкоринг протокол, §A заморожена sha256) + `docs/research/18-quality-levers-chatgpt.md` (параллельный ChatGPT, запущен владельцем). Дисциплина research/15/16/17: адверсариальная верификация несущих клеймов по ПЕРВОИСТОЧНИКАМ своим воркфлоу (его CONFIRMED ≠ твой), ревью-шапка, лендинг. ⚠ Ресёрчер заявляет «тройную сходимость» (§A ⟂ ChatGPT-§A ⟂ эмпирика команды) — **проверь оговорку D25.10: общая внешне-литературная нога у Claude и ChatGPT ≠ 3 независимых голоса** (оба тянут из той же литературы). Ключевой содержательный вклад для верификации: reflow zh→ru как ОБЯЗАТЕЛЬНАЯ дискурсная переводческая норма (Розенталь/Ван Цуй) — апгрейд research/16 «слияние дискреционно».
2. **Написать промт полигон-эмпирики (D35.6) из §C research/18:** оси — **нарезка×промпт** (глава|чанк × сильный-дискурс|текущий — на ≤5 главах прямыми вызовами, чанк-арм воспроизводит прод-инъекцию как якорь; это ось, которую exp04/12/13 НЕ крутили) + **диагностик-фронтир-арм** (владелец согласовал: Gemini/GPT переводчик+редактор+мета-ревьюер → потолок в моделях vs в машинерии; гарды: эхо-скрин/исключить grok на архаичной ch1 — D22.5, self-family exclusion мета-ревьюера, per-call кап + пре-регистрация) + кривая размер-чанка↔качество↔биллинг↔ретраи. Методика-guard D32.4 (fidelity-first агрегация, leave-one-out, катастроф-скрин — полигон дважды собирал ложную «сходимость», лови третий раз).
3. **Fidelity re-gate (D34.3, `rerun/FIDELITY_REGATE_HANDOFF.md`) — полигон может гнать ПАРАЛЛЕЛЬНО** (независим от ресёрча): квантифицирует претензию 2 + ловит инверсии активного редактора (ch11/0-класс). Пере-прогон без него формально не засчитан.
4. **Бэкенд-фикс-лист (D35.4, в следующий бэкенд-пакет):** экспорт-баг (флагнутый чанк экспортится ПУСТЫМ — ch5/ch20 зачины выпали; фикс: стрип ведущего `###` ИЛИ фолбэк на draft, не дроп); ведущий `###` рождается в ЧЕРНОВИКЕ deepseek (фикс в translator.md/экспорт, не editor); засидить разряды 甲乙丙丁/资质 (raw-китайский в выходе). **Бэкенд-стейдж Б/В D15.2** (`BACKEND_D152_SESSION_PROMPT.md`: content-addressed resume + `tmctl export`/annotations/override) — промт после того, как качественная развязка (research→полигон) уточнит требования к экспорту.
5. Read-me чужих зон устарели (backend/README «D1D23»; eval/README exp04/exp08-каветки) — в fix-листы их сессий, сам не правь.
**Порядок:** research/18 (ты ревьюишь) → полигон-эмпирика (нарезка×промпт + фронтир + re-gate) → **владелец читает результат** → решение «дешёвый трек дотягивает / нужен фронтир / нужен Ф2» → бэкенд под доказанный ROI. Не перепрыгивай в бэкенд-стройку до полигон-доказательства.
**Ждём от владельца:** запуск полигон-эмпирики (после твоего промта) · **стратегическая планка запуска** (лучше-фана/гибрид/издательский — всё ещё открыта; влияет на «дешёвый vs фронтир vs Ф2») · билингв-якорь пилота Ф2.5 (D25.9-Q1) · publishable/waiver при экспорте (D25.1) · FN-bound L3 при спеке (D25.4) · юр-пакет (+rights manifest) · провенанс 12-*-доков. *(Закрыто владельцем: сид v2 подписан, фронтир-арм согласован, этап B отменён.)*
## Методология (продолжай как предшественники)
- **Внешнее ревью пакета = мультиагентный адверсариальный воркфлоу** (Workflow-инструмент; ultracode): 48 ревьюеров по осям, refute-by-default, ВСЁ исполнением — прогоны/мутации ТОЛЬКО в scratchpad-КОПИЯХ; пересчёт заявленных чисел из сырья; $0 (моки); 18+ — только метаданные. Вердикты ACCEPT / ACCEPT_WITH_FIXES / REJECT.
- **Промты сессий несут мандат самопроверки (решение владельца 12.07, в CLAUDE.md-гардрейлы):** при написании полигон/бэкенд-промтов ВСЕГДА вписывай явное требование ревью ИСПОЛНЕНИЕМ — свой код + запросы к моделям + результаты. Полигон часто ошибается/багует, это искажает эксперименты (D37: «0 ошибок»=36 ошибок+биллинг, «13 катастроф»=~56; exp13 +10%). Бэкенд-ревью после кода — явно (обычно отрабатывает). Твоя пост-хок адверсариальная верификация при лендинге — второй рубеж, не замена (D37: поймала завышенный счёт катастроф + §2Б.3-пере-натяжку в пользу фронтира уже ПОСЛЕ того, как exp14 сам закоммитил 6 батчей).
- **Лендинг:** микро-дефекты доков чинишь сам с пометкой «испр. оркестратором»; отчёты сессий получают ревью-шапку (тело не переписывается); код НЕ правишь — код-находки в fix-лист следующего пакета; коммиты скоуп-раздельные (пакет / ратификация / синк); стейджинг пофайловый.
- **Ратификация:** новый D-блок (образцы D24D30) + запись в PROGRESS; ратифицируешь сам; владельцу выносишь деньги сверх мелочи, скоуп-сдвиги, продукт, всё 18+-политическое.
- Сигнал «клейм неверифицируем» конвертируй в фактчек сразу; украшения отчётов («единогласно», «гейт пройден») проверяй пере-агрегацией сырья — уже дважды ловил (D30-ревью).
- **Внешние критики:** §A хешировать ДО фазы 2; оси мандата формулировать нейтрально; список признанных дыр — только после фиксации §A (урок D25.10).
- **Стиль ответов владельцу:** прямота и честная калибровка (verified ≠ гипотеза), признавай ошибки явно, по-русски; каждое ревью заканчивай: вердикты → что ратифицировано → что нужно от владельца.
## Неочевидности и эдж-кейсы (оплаченные уроки)
- `gemini-3.1-pro` без `-preview` = 404; слаги — только live-фактчек; «есть в /models ≠ работает».
- Gemini: thinking-токены ТОЛЬКО в `total_tokens` (`additive_total` — иначе недоучёт 146×/вызов); `finish_reason` — СОСТАВНАЯ строка; `PROHIBITED_CONTENT` неконфигурируем; **в роли редактора течёт сервис-преамбулой в выход (6/6)** — за санитайзером D30.3.
- DeepSeek: thinking не выключать НИКОГДА; thinking молча игнорирует temperature/top_p (draft temp — wire-no-op, в снапшоте для детерминизма).
- OpenReadOnly: после краха `status` показывает reserved-хвост до следующей write-команды — трейд-офф, не баг (D23.2).
- Судейские скаляры без спан-цитирования нечувствительны к верности (gpt-5-mini дал 5.0 сырому черновику); reasoning-off судья ≈ инертен. Span-цитирование + reasoning-ON + раздельные вызовы стиль/верность (D30-уроки).
- Полигонное Python-зеркало ↔ Go: при расхождении верить Go. Каждый платный eval-скрипт персистит usage/cost (D30.10).
- `/tmp` волатилен: артефакты с пингами дублируй на диск (`/home/ubuntu/books/gu-zhenren/research15-probes/` — сырьё P4).
- transient-прогоны в чужой зоне — только в копии вне дерева (D22.10); чужие заголовки в PROGRESS не задевать; **в дереве часто ДВЕ живые сессии одновременно** — git status перед каждым коммитом.
- Книга и ВСЕ производные — вне git; `.env` не читать; PUML не рендерить; `.claude/settings.local.json` не коммитить.

View file

@ -27,6 +27,15 @@
исполняется ПОСЛЕ полного zh→ru-цикла §D, если бюджет/сессия позволяют — иначе отдельной сессией;
в пре-рег НЕ вмешивать (отдельный мини-фриз при старте).
## Пре-задача ($0, ДО фриза): ре-аудит exp14b исправленными правилами (D39.9)
Q4a вскрыл: правила exp14b `rule_counterfactual`/`polarity_envy`/`b1` дефектны (исправленные оверрайды —
`eval/exp15/q4a_traps.py`; их и использовать), а харнес `exp14_common.call` НЕ отклоняет `finish=length`
(усечённый выход проходит как валидный). Сделай: (1) пере-скорь СОХРАНЁННЫЕ exp14b-выходы исправленными
правилами; (2) свип `finish_reason=length` по exp14/14b-леджерам; (3) отчёт-дельта: какие пер-класс
вердикты D38 сдвинулись (несущие a1-вердикты span-верифицированы независимо — ожидание: ядро держится);
флип любого несущего → СТОП и пинг оркестратору (errata-блок — его зона). При реюзе exp14-харнеса в
этом экспе — length-гейт чинить у себя (аддитивно), риг exp14 не трогать.
## Пре-рег ФРИЗ (первый коммит, до платных вызовов — D30.10/D37)
Пинит из §D дословно + конкретизации: армы A1A3+A3-абл / A6/A6b / A4/A5; **GT = сид v2 пост-reseed
`175a67ad…`** с фильтром вхождения (§D1; blurb-правило — явным пунктом); recall-метрика чистая,
@ -48,7 +57,9 @@ precision = pseudo@K + адъюдицированная@30 (топ-30 не-си
мини-бенч ВЕРИФИКАТОРА Z1-пар и линкера Z5-рёбер (3050 пар, ручной голд).
4. **Cold-start срез (копейки):** свежие flash-черновики 5 глав слайса БЕЗ глоссарий-инъекции
(прямые вызовы, промт translator без блока; главы фиксируются в пре-реге) → спред-сигнал и
canon-recovery экологически валидно (§D1-конфаунд снимается).
canon-recovery экологически валидно (§D1-конфаунд снимается). ⚠ **DeepSeek-долина обязательна**
для этого и A4-генераций: пики UTC 0104 и 0610 (= 0407 и 0913 по +3) стоят ×2 (peak-valley
официально, D39.7) — сметы по обычным ценам, в пики не ставить.
5. **A4/A5 (платные, ≤капа):** канал сноски banknote-v1 (5 глав × 2 конфига: с/без инструкции;
формат §B3, срез кодом, редактор не видит) — маржинальный recall над лучшим $0-армом НА СТРАТЕ
f<3, parse_fail/truncated-рейты, дельта качества черновика от инструкции, фактическая цена.
@ -62,7 +73,13 @@ precision = pseudo@K + адъюдицированная@30 (топ-30 не-си
счётчики/поверхности; **бэкенд НЕ трогать** (0 правок; зеркала — только в своей зоне с пометкой
«при расхождении верить Go»).
- **Реюз exp15 ($0):** `eval/exp15/mem_select.py` — валидированный байт-в-байт порт Select (инъекция/
enforce-зеркало); фертильность 1.20·cjk+0.39·other; токенайзер/чанкер-порты. Не переизобретать.
enforce-зеркало); фертильность 1.20·cjk+0.39·other; токенайзер/чанкер-порты; `exp15_llm.py`-класс
Spender (per-call predicted-гейт + глобальный потолок + леджер-строка на вызов). Не переизобретать.
- **Риг-уроки exp15/Q4a (обязательны):** (а) **`finish=length` = reject/flag на ВСЕХ своих генерациях**,
не валидный выход (харнес-баг D39.9 — не наследовать); (б) если для адъюдикации precision@30 берётся
облачный судья — ТОЛЬКО паттерн закалённого `eval/exp15/judges.py`: per-vote персист, оба порядка,
полно-evidence окна (HEAD_CHARS-класс запрещён); помни измеренный судейский шум-пол на смысле 0.261 —
маргинальные судейские эффекты не интерпретировать, первичный путь адъюдикации = владелец по карте.
- Прод-выходы при любой сверке читать через `tmctl export` (инвариант №8 backend/README), не сырые
чекпоинты; для (source,draft)-пар $0-армов санкционирован records.json (см. выше).
- Отчёт: §1 фриз · §2 GT/материал · §3 результаты по армам (первичка/вторичка) · §4 решающие правила

View file

@ -0,0 +1,227 @@
# ARCH_CLEANUP_REPORT — изоляция языковых данных из `pipeline/` + известные фиксы (2026-07-19)
> Сессия: **Бэкенд (промежуточная)**, промт `docs/BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md` (D39.15). 5 фаз-воркфлоу:
> ресёрч → синтез → фикс-лист (do/defer/don't) → исполнение → ревью. **Рефактор поведенчески НЕЙТРАЛЕН для
> zh→ru** (golden байт-стабилен). Не R1, не пере-прогон, не минор-хант. НЕ коммичено (лендит оркестратор).
>
> **Итог одной строкой:** язык-ДАННЫЕ майнера (百家姓 · инвентари · частицы · таблица Палладия) вынесены из
> Go-констант `internal/pipeline/` в файлы `configs/langpacks/` через новый leaf-пакет `internal/lang`
> (данные + загрузчик + контент-хеш, БЕЗ импорта pipeline); алгоритмы майнера остались в движке и читают
> прокинутый `*lang.Pack` (DI, как `contrast`). Майнер офлайн → **golden байт-идентичен** (хеш не изменился);
> **паритет майнера EXACT** (13618 / `{方源:0,蛊:1,蛊师:2,古月:22}` / recall 0.9649). Плюс FL-1/2/3 + тест-путь.
> Живые DC-чекеры и swap `isRuTarget→x/text` — обоснованно НЕ трогаем (см. DEFER/DON'T).
---
## Фаза 1 — РЕСЁРЧ (2 направления × 3 источника, refute-by-default)
Воркфлоу `arch-cleanup-research`: 8 независимых агентов (4 алгоритмы / 4 архитектура), источники — Go-stdlib/`x/text`,
наш репозиторий (file:line), интернет. Ключевые находки (все грунтованы, refute-by-default):
### A. Алгоритмы
- **`isRuTarget`/`isCJKTarget``x/text/language.Tag`: ОТВЕРГНУТО (KEEP-ASIS, HIGH-risk-если-свапнуть).**
Эмпирическая проба (x/text v0.38): **6/18 реалистичных входов расходятся** в ОБЕ стороны (`ru-RU`,`rus`,`zh-Hant`,
`zho`,`chi`,`ja-JP` начинают матчить; ` ru ` с пробелами ПЕРЕСТАЁт — `language.Make` не тримит). Хуже: `Tag.Base()`
тянет CLDR «likely-subtags» (документированно «subject to change», зафиксированное поведение-изменение golang/go#24211)
в `classifierVersion`/`cheapGateVersion``snapshotID` — тихая дивергенция снапшота на `go get -u` без бампа версии.
`isRuTarget` ещё и гейтит cosmetic-strip санитайзера → флип меняет ЭКСПОРТ FinalText, не только флаг. Это не ДАННЫЕ,
а routing-policy; сеньор оставляет явный тестируемый предикат.
- **Майнер-алгоритмы = легитимный опубликованный NLP, парити-locked, НЕ велосипеды:** c-value (Frantzi/Ananiadou/Mima
2000, с задокументированным `g(L)=log₂(L+1)`), weirdness/termhood (Ahmad/Gillam/Tostevin 1999), subsumption, union-find.
**Go/`x/text`-эквивалента нет** (ATE-тулкиты — Scala/Python). Из движка уходят ТОЛЬКО ДАННЫЕ-таблицы. `clusterAlias`
подтверждён корректным union-find (path-halving, детерминированный вывод) → DON'T-TOUCH (директива владельца).
- **Чанкер:** в `x/text` НЕТ публичного sentence-сегментатора (проверено в module-cache: нет `segment`-пакета);
кастомный CJK-сплиттер намеренно ЛУЧШЕ UAX#29 (quote-depth, abbrev-guard) → менять = не-нейтрально + регресс. Фертильность
УЖЕ в `config.Segmentation` (per-pair, снапшот-folded) — не трогать. `tokenClassCounts` CJK-класс = универсальное
Unicode-свойство, не langpack-данные.
- **Нормализация:** `memnorm.normalizeSourceKey` УЖЕ использует `x/text/unicode/norm.NFKC` + `//go:embed data/trad2simp.txt`
(данные-файл с контент-хешем) — прецедент, ничего не трогаем. `pymorphy3` Go-эквивалента, стоящего порта, нет (default-B
морфологию не использует).
### B. Архитектура
- Проект оркестратора **ДИРЕКЦИОННО ВЕРЕН**, но переоблегчить: **0 интерфейсов сейчас** (2-я пара = 2-й потребитель
ДАННЫХ, не 2-я реализация ПОВЕДЕНИЯ — интерфейс P1P6 = преждевременно), **без auto-discovery-реестра** (конфиг-путь
достаточно), тонкое зеркало **существующего промпт-сеама** (`Stage.Prompts`+`PromptPathFor`+`PromptSHA256`, резолв по
`Book.LangPair()`, fail-loud).
- **Внешние файлы, НЕ `go:embed`** — решает центральный пресс-тест: владелец ратифицировал «положить каталог, без
перекомпиляции»; `go:embed` = compile-time. Прецедент промпт-сеама доказывает: проект принимает внешние core-данные с
контент-хешем + fail-loud.
- **`minerPackVersion` СЕЙЧАС НЕ фолдится в снапшот** (майнер офлайн — безвредно; A-miner/B-seam: фолдить контент-хеш,
когда майнер станет живым). См. решение по фолду ниже (§ Снапшот).
- **Move-vs-stay леджер (2 независимых агента СОШЛИСЬ):** двигать ТОЛЬКО офлайн-майнер-данные; живые DC-чекеры оставить
(см. DEFER). Ключевая находка ревью: `translitInterjections` (ара-ара/маа/нани) — ЯПОНСКИЕ филлеры; пара-кеинг к `zh-ru`
СТЁР бы детекцию ja-филлеров из ja→ru (пара самого golden). `refusalPatterns` — кросс-каттинг мультиязычная safety, НЕ
двигать. Генеративити-агент (лайвнесс-рамка) пришёл к тому же независимо.
---
## Фаза 2 — СИНТЕЗ (реконсиляция ресёрч + оркестратор + владелец + фикс-лист)
**Консенсус (ресёрч ⋂ оркестратор ⋂ владелец):**
- Движок общий; язык-данные — файлами вне `pipeline/`; новый `internal/lang` (данные+загрузчик, БЕЗ данных-в-коде);
резолв по паре; снапшот-folded как промпты; правильные алгоритмы (union-find/сортировки) не трогать.
**Спорное → разрешено ресёрчем (пресс-тест сработал — отвергли 2 стартовых буллета):**
1. **`x/text/language.Tag` вместо `isRuTarget`** (буллет оркестратора И владельца) → **ОТВЕРГНУТО** эмпирикой: 6/18
расхождений + CLDR-инстабильность в снапшот-вердиктах + удар по экспорту FinalText. Держим точные предикаты. Это НЕ
уклонение — фаза пресс-теста ровно для этого (промт: «ПРЕСС-ТЕСТ ресёрчем, не имплементить слепо»).
2. **Двигать живые DC-чекеры (`checkers_zh_ru` 时辰/числительные/регистр — назван владельцем)****DEFER**: они бегут
ПАРА-АГНОСТИЧНО сегодня (регистр/интерджекции флагают ru-выход независимо от источника, `chunkrun.go:203-204`), значит
пара-кеинг = ЖИВАЯ смена поведения; а фолд их хеша в `cheapGateVersion` вынуждает golden re-capture. 2 независимых
агента сошлись на отсрочке к ja→ru-реплике (уже увязано D39.14). Механизм `internal/lang` они унаследуют.
**Ранжирование по ценности×риску:** (1) майнер-данные — высокая ценность (百家姓/Палладий = парадигм-нарушители, названы
владельцем ПЕРВЫМИ), низкий риск (офлайн, байт-нейтрально). (2) FL-1/2/3 + тест-путь — реальные, латентные. (3) DC-чекеры —
средняя ценность, средний риск (живой путь, observability-only) → DEFER.
---
## Фаза 3 — ФИКС-ЛИСТ с диспозициями
### DO (сделано этой сессией)
| # | Пункт | Что | Нейтральность |
|---|---|---|---|
| DO-1 | **`internal/lang` сеам** | leaf-пакет: `Pack` + `Load(root,src,tgt)` + контент-хеш `Version()`, БЕЗ импорта pipeline; резолв `configs/langpacks/<src>/` (морфология) + `<src>-<tgt>/` (Палладий); fail-loud на отсутствующий файл | — |
| DO-2 | **Майнер-данные → файлы** | 百家姓 single/compound, title/ordinal/topo/grade/numeral/rank, alias-particle → `configs/langpacks/zh/`; Палладий (initials/finals/yw/special_i) → `configs/langpacks/zh-ru/`. Сгенерированы ИЗ живых констант (байт-точно), затем консты удалены | Майнер офлайн → golden не задет; паритет EXACT |
| DO-3 | **Прокид `*lang.Pack`** | DI (как `contrast`): `MineBank/mineDetect/patternCandidates/isSurnameStart/formantType/proposeAliasEdges/isFragment/emissionEligible/isPalladiusToken/buildPalladiusCyrSyllables` читают pack. Алгоритмы В КОДЕ, данные в файлах (граница compile-enforced) | Байт-равные значения |
| DO-4 | **Генеративити-пруф** | `internal/lang/langpack_test.go`: real-load zh-ru + синтетическая 2-я пара → другие байты/версия + fail-loud на неизвестную пару | — |
| DO-5 | **FL-1** | Свернуть `bankTokenBudget` в `banknoteSnap.TokenBudget` (`snapshot.go`) — правка `bankMaxLines` меняет max_tokens∈request_hash без бампа parser_version = тихий re-bill; теперь громкий resnapshot механизмом | golden: banknote OFF → nil → omitempty |
| DO-6 | **FL-2** | `chunkrun.go`: на резюме СОХРАНЯТЬ banknote-телеметрию (read `GetRetrievalState` при resume+enabled) вместо обнуления; ложный коммент «re-derives» исправлен на правду | golden: banknote OFF → 0 |
| DO-7 | **FL-3** | `miner_emit.go`: `emitRankCap=200` кап `mr.ranked` ПЕРЕД emission-фильтрами (зеркало референса `a3[:200]`) | WHICH-инварианты на `mr.ranked` (до эмиссии) не задеты |
| DO-8 | **Тест-путь** | `miner_parity_test.go`: абсолютные `/home/ubuntu/...` → env-override `TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}` (skip-on-absent сохранён) | тест-гигиена, не прод |
### DEFER (в резидуал, с причиной)
- **DEFER-1: живые DC-чекеры/cheapgates данные** (时辰/числительные/регистр/интерджекции). Причина: (а) бегут ПАРА-АГНОСТИЧНО
сегодня → пара-кеинг = живая смена поведения (напр. ja→ru потерял бы DC6/интерджекции); (б) фолд их хеша в `cheapGateVersion`
вынуждает golden re-capture, а не-фолд ослабляет drift-proofing; (в) `translitInterjections` = ja-филлеры (mis-key trap).
Наследуют ТОТ ЖЕ `internal/lang`-сеам на задаче ja→ru-реплики, где ось источник/цель/пара конкретна и re-capture намеренный
(увязано D39.14). Честит «известные фиксы + не над-инженерия».
- **DEFER-2: `sourceAbbrevs`** (28 англ. аббревиатур в `chunker.go`) — единственный per-source-lang инвентарь в движке, но
маргинален (только en-источник, вне zh/ja→ru приёмки), free-rider если появится en-пакет.
- **DEFER-3: снапшот-фолд `pack.Version()`** — R1 (когда майнер станет живым). См. § Снапшот.
- **DEFER-4: `isCJKTarget/isRuTarget` membership как langpack-атрибут** — только если `internal/lang`-реестр расширится под
реальные корпуса; standalone = над-инженерия (A-lang F5). Резолв ОБЯЗАН остаться точным exact-match, не x/text-инференс.
### DON'T (переписывание-ради-переписывания — отвергнуто явно)
- **DON'T-1: `isRuTarget`/`isCJKTarget` → `x/text/language.Tag`** — ресёрч-опровергнуто (6/18, CLDR-инстабильность, удар по
FinalText). Держим точные предикаты.
- **DON'T-2: двигать `refusalPatterns`** (en/ru/zh/ja) — кросс-каттинг мультиязычная safety-блэклист, disposition-ось
(`classifierVersion`), модель может отказать на ЛЮБОМ языке независимо от пары. Пара-кеинг = semantically incoherent +
safety-регресс.
- **DON'T-3: `tokenClassCounts` CJK-класс / `yoHomographEForms` / `cjkNumeralRunes` / арифметика 万/億** — универсальные
Unicode/арифметические свойства, не per-pair конвенции.
- **DON'T-4: трогать алгоритмы** (union-find, детерм. сортировки, c-value, чанкер-сплиттер, регекс-ЛОГИКА чекеров).
- **DON'T-5: строить P1P6 интерфейсы / auto-discovery-реестр** — 2-я пара = данные, не поведение (нет 2-го потребителя
поведения). Данные-struct + загрузчик достаточно.
### Предложения сверх скоупа (в план/владельцу)
- Когда майнер станет живым (R1): фолдить `pack.Version()` в `snapshotID` (единый resnapshot тогда).
- ja→ru-реплика: перенести DC-чекеры/cheapgates данные в `internal/lang` через тот же сеам (source-scoped zh + target-scoped
ru + pair-scoped zh-ru), с намеренным golden re-capture — тогда ось источник/цель/пара тестируема реальной 2-й парой.
- (опц.) `sourceAbbrevs` как free-rider en-пакета, если en-источник войдёт в приёмку.
---
## Фаза 4 — ИСПОЛНЕНИЕ (лог)
**Порядок (низкий риск → высокий, верификация после каждого):**
1. FL-1/2/3 + тест-путь → build/vet/test зелёные, golden хеш НЕ изменился (`975fbfdc…`).
2. Данные-файлы сгенерированы **из живых констант** (одноразовый in-package генератор `TM_GEN_LANGPACK=1`, затем удалён) →
гарантия байт-точности; verified `reflect.DeepEqual(loaded, const)` для ВСЕХ 13 полей до удаления констант.
3. `internal/lang` пакет + загрузчик + контент-хеш (`langpack-v1-09974d6cdac2`).
4. Прокид `*lang.Pack` через майнер; удаление констант (`surnamesSingleRaw`/`buildRuneSet`/`buildRuneSet2`/инвентари/
Палладий-мапы/`palladiusCyrSyllables`-глобал); правка тестов (helper `testLangPack`).
5. Генеративити-пруф-тест.
**Верификация исполнением (мандат самопроверки CLAUDE.md):**
- `go build ./...` ✅ · `go vet ./...` ✅ (чисто) · `go test ./... -race`**все пакеты зелёные** (вкл. новый `internal/lang`).
- **Паритет майнера EXACT** (`TM_MINER_PARITY` присутствовали стенд-данные): `n=13618 · {方源:0,蛊:1,蛊师:2,古月:22} ·
recall@proposed=0.9649` — **байт-доказательство нейтральности** переноса данных.
- **Golden байт-идентичен:** `sha256(capture.golden)` = `975fbfdc701438eb8604e6e1a0ddb77842246cf88983178bf99fddcab596a03e`
ДО и ПОСЛЕ (не изменился — майнер офлайн, фолд не добавлен). НОЛЬ вердикт/wire/final-правок.
- Диффстат: 11 файлов правлено, +157/162 (код УМЕНЬШИЛСЯ — данные ушли в файлы); новые `configs/langpacks/` + `internal/lang/`.
### Снапшот — решение (эскалация оркестратору)
Фолд `pack.Version()` в `snapshotID` **НЕ добавлен** этой сессией. Обоснование (инвариант №2 vs гардрейл промта):
инвариант №2 = «фолдить то, что влияет на wire ИЛИ вердикты». Майнер **офлайн** (`MineBank` — только тест-вызовы; нет
runner/tmctl-потребителя) → его данные СЕЙЧАС не влияют ни на wire, ни на вердикты. Фолд офлайн-данных вызвал бы resnapshot
zh-ru-книг «за ничего» (wire идентичен) — ровно та переоплата, что дисциплина предотвращает, и ВТОРОЙ resnapshot к R1
(двойной). **Дисциплинарно-корректно: не фолдить сейчас; R1 (который делает майнер живым) фолдит `pack.Version()` тогда —
единый resnapshot.** `Version()` построен и готов. Golden байт-идентичен (строго ⊆ «до одного version-поля»). A-miner/
B-seam-агенты подтвердили «фолд когда майнер станет живым». **Флаг оркестратору:** гардрейл промта в скобках называет майнер
«wire-определяющим» — по факту офлайн; если оркестратор хочет фолд сейчас, это отдельное решение (golden всё равно нейтрален —
ja-ru, ja-пакета нет).
---
## Фаза 5 — РЕВЬЮ (мульти-линзовый адверсариал)
Воркфлоу `arch-cleanup-review`: 4 линзы (нейтральность / сеньор-код / функциональное-генеративити / FL-фиксы),
author≠reviewer, refute-by-default. **Итог: линза-1 (нейтральность) — ЧИСТО (0 находок); 3 MINOR (по одной на
линзы 2/3/4), 0 major/critical.** Ревью НЕ породило минор-лист — это ровно приёмочный формат. **Все 3 находки
впитаны (fix + верификация исполнением):**
- **R-1 (MINOR, линза-2 сеньор-код) — `parsePalladius` номер строки.** Ошибка репортила `line %d` по индексу
`contentLines` (после снятия комментов) → сдвиг на число ведущих коммент-строк (сейчас 1) → правящий файл человек
уходит не на ту строку. **Фикс:** `parsePalladius` сканирует сырые строки напрямую → физический номер файла.
- **R-2 (MINOR, линза-3 функциональное) — fail-loud только на ОТСУТСТВИЕ файла, не на пустоту.** Док обещал «never
silently empty», но пустой/коммент-только/усечённый файл давал пустую таблицу БЕЗ ошибки → на R1 (пакеты
ручные) опечатка-в-пустой `surnames-single.txt` тихо гасила surname-канал майнера (регресс recall без сигнала).
**Фикс:** `Pack.validate()` после загрузки — все required-таблицы non-empty, иначе fail-loud с именем пары+таблицы
(делает заявленный инвариант реальным, как memnorm-паника на corrupt-таблицу). + тест `TestFailsLoudOnEmptyTable`.
- **R-3 (MINOR, линза-4 FL-фиксы) — FL-2 без теста.** `TestBanknoteSliceReTelemetryResume` не пере-читал
retrieval-state ПОСЛЕ резюма → тест проходил С и БЕЗ FL-2 (coverage-gap, ровно класс мандата самопроверки CLAUDE.md).
**Фикс:** добавлен пост-резюм re-read + ассерт `NBanknoteLines==2`. **Верифицировано исполнением (revert-test-restore):
без FL-2 тест ПАДАЕТ (`NBanknoteLines:0`), с FL-2 — зелёный.** Теперь тест реально сторожит фикс.
После впитывания: `go build/vet` чисто; `go test ./... -race` **все зелёные**; golden хеш `975fbfdc…` НЕ изменился;
паритет EXACT (`13618 / {方源:0,蛊:1,蛊师:2,古月:22} / recall 0.9649`).
---
## Приёмка (для оркестратора)
- **Golden байт-стабильность:** хеш `975fbfdc…` НЕ изменился (даже сильнее «до одного version-поля» — НОЛЬ полей).
- **Паритет:** `TM_MINER_PARITY=1 go test -run TestMinerFullBookParity` → EXACT (13618 / катастроф-скрин / recall).
- **Генеративити реальна:** `go test ./internal/lang/` → 2-я пара грузится через сеам БЕЗ правок `pipeline/`; fail-loud на
неизвестную пару.
- **Алгоритмы не сломаны, велосипеды сняты (их не было), над-инженерии нет** (0 интерфейсов, тонкий загрузчик).
- **«Не упоролись на миноры»:** DO-лист = 8 пунктов с ценностью; DON'T явно отвергнуты; 2 названных владельцем пункта
(DC-чекеры, x/text-свап) обоснованно DEFER/DON'T с ресёрч-доказательством.
## Residual → R1 (`BACKEND_PLAN11_SESSION_PROMPT.md`, активен)
- R1-драйвер-свитч (волны) — как было.
- **Новое R1-условие:** при подключении майнера в живой W1.5 — грузить `lang.Pack` в раннере (fail-loud-at-load для пары с
каталогом-пакетом; nil-and-run для пары без) + фолдить `pack.Version()` в `snapshotID` (единый resnapshot тогда).
- DC-чекеры/cheapgates → `internal/lang` на ja→ru-реплике (тот же сеам).
---
## Записка оркестратору — шероховатости golden/снапшот-гарда (НАБЛЮДЕНИЕ, без правок кода в этом паке)
**Провенанс:** найдено в этой сессии при пресс-тесте `isRuTarget→x/text` и разборе, что делает golden. Это НЕ дефекты
текущего кода — сейчас всё консистентно (golden зелёный, тулчейн `go1.26.4` == директива, x/text на Unicode 15.0.0, дрейфа
нет). Это **слепые зоны гарда**, которые всплывут при (а) следующей ратифицированной смене поведения и (б) первом бампе
тулчейна. Диспозиция — за оркестратором/владельцем. **В нейтральный пак НЕ тащу**: обе правки двигают снапшот/тест-инфру →
не байт-нейтральны, требуют своего ратифицированного resnapshot-захода.
**Ш-1 — masked structural diff при re-capture РУЧНОЙ.** `TestGoldenDeterminism` (`golden_test.go:317`) сравнивает capture
байт-в-байт; на `TM_UPDATE_GOLDEN=1` просто перезаписывает файл (`:306`). «Маскированный дифф» (хеши/версии→плейсхолдер,
проверить что вердикты не сдвинулись) — **дисциплина ревью, не код** (README backend/; D39.14 делал руками). Риск: небрежный
re-capture может запечь тихую вердикт-правку под видом «version-only» — ровно то, от чего golden защищает. **Опция:** хелпер,
который на `TM_UPDATE_GOLDEN=1` маскирует хеш/версия-поля и печатает сводку «N вердикт-изменений (final_text/disposition) /
M version-only» ДО записи → человек видит, что именно сдвинулось. Не ослабляет гард — делает re-capture безопасным по
построению.
**Ш-2 — снапшот-версии не фолдят `unicode.Version` (мина под бамп гошки).** `memoryNormVersion` (`memnorm.go:56`) хеширует
`memoryNormAlgoVersion + trad2simpRaw`, но НЕ Unicode-версию / версию norm-таблиц. `norm.NFKC``normalizeSourceKey`
парити майнера + матчер банка памяти) и голые `unicode.Han/Cyrillic/…` (~10 файлов: `tokenClassCounts`/`cjkShare`/чанкер/
санитайзер) привязаны к Unicode-версии ТУЛЧЕЙНА. Конкретный порог: x/text выбирает norm-таблицы build-тегом
(`//go:build go1.27` → Unicode 15→17). Значит **бамп тулчейна до go1.27 сменит NFKC/классификацию БЕЗ смены
`memoryNormVersion`** → тихая дивергенция снапшота без сигнала (тот же класс, что x/text/language-находка §DON'T-1).
**Опция:** вплести `unicode.Version` (+ версию norm-таблиц) в `memoryNormVersion` и в classifier/style-версии → любой
Unicode-бамп = громкий resnapshot / красный golden, не тихий. **Обязательно ДО любого go1.27-бампа.**
**Секвенс (предложение):** обе — отдельным ратифицированным заходом, не в этом паке. Порядок при будущем бампе гошки/либ:
(1) фолд Unicode-версии → (2) авто-masked-diff в re-capture → (3) bump → (4) re-capture с пустым masked-diff +
`TM_MINER_PARITY=1` re-pin. Про сам вопрос «бампать ли сейчас»: форсирующего CVE/фичи по коду НЕТ, версии свежие →
рекомендация «не бампать ради свежести» (churn + риск golden).

View file

@ -0,0 +1,250 @@
# PACK11_CONT_REPORT — бэкенд-сессия ПРОДОЛЖЕНИЯ, резидуал R1R5 (2026-07-19)
> Продолжение `PACK11_REPORT.md` (Block A, `85f5b9e`). Строил по `docs/architecture/11-implementation-plan.md`
> (ратифицирован D39.12) + `PACK11_REPORT.md` §Резидуал (пошаговые дизайны R1R5). **НЕ коммитил** — сдача
> на приёмку. Верификация исполнением: `go build ./... && go vet ./... && go test -race ./...` — ВСЁ
> ЗЕЛЁНОЕ на каждом чекпойнте (логи в §Верификация). Block A НЕ трогал (под тестами).
## 0. Санкционированный порядок и итог
**Развилка порядка эскалирована владельцу/оркестратору в начале сессии** (R1 = крупный каскад, меняющий
ГРАНУЛЯРНОСТЬ вывода → ~весь e2e-суит + read-models + golden; риск красного дерева vs жёсткое правило
«суит зелёный на каждом чекпойнте»). **Оркестратор санкционировал вариант 1:** R2→R3→R4→R5 сначала (все
самодостаточны и зелёно-достижимы независимо от R1), **R1 — капстоуном на остатке бюджета**, с условиями:
чекпойнт-ревью после R2+R3 и после R4; R3 на текущем последовательном раннере; каждый снапшот-двигающий
лендинг — свой golden re-capture с masked-диффом, СТРУКТУРНЫЙ рерайт — один раз в R1; R1 сначала аддитивные
под-шаги без каскада, переключение драйвера — финальный атомарный шаг; не дотягивает до зелёного → дерево
зелёное, R1 точным резидуалом (запланированный исход).
| WS / шаг | Статус | Верификация |
|---|---|---|
| **R2 · WS3 Go-майнер** | ✅ **СДАНО** | Go↔Python паритет **EXACT**: 13618 канд-СЕТ, катастроф {方源:0,蛊:1,蛊师:2,古月:22}, recall@proposed **0.9649**; alias-ярус-1 (5 R4-блоков+компози-гард); сид-дельта+`MinedDeltaYAML`; `Source:mined`-путь; `tmctl seed-lint`. |
| **R3 · WS4 банкнота 12 точек** | ✅ **СДАНО** (на посл. раннере) | срез-до-classify (fresh+resume); `tm-banknote-v1:` derived + re-point после эскалации; `banknoteSnap` (omitempty); v9-телеметрия; finish=stop-only гейт. 5 e2e-пинов. |
| **R4a · `tmctl export --pairs`** | ✅ **СДАНО** | source-столбец (пред-условие §5(д)-FP DC1/DC2). |
| **R4b · чекеры DC1/DC2/DC6 + zh-ru пакет** | ✅ **СДАНО** | эмпир-позитивы пойманы; DC2 **байт-верно референсу** (ok_re-supress + case-sensitive — фикс ревью); `cheapGateVersion` v3; golden re-capture masked-чист. |
| **R4c · DC3 gender-инъекция** | ✅ **СДАНО** | `memoryEntry.gender`→editor-констрейнт; `renderFormatVersion` v2; golden re-capture masked-чист. |
| **R5 · WS6 арм-конфиги** | ✅ **СДАНО** | `pipeline-arm-mistral.yaml`/`pipeline-arm-deepseek-pro.yaml`; `echo_mine_test` под варианты; rate-guard связка. |
| **R1 · волновой ИСПОЛНИТЕЛЬ** | ⚠️ **АДДИТИВ СДАН · драйвер-свитч РЕЗИДУАЛ** | `snapshotIDForWave` (пер-волновой снапшот, «переоплата ОДНА» пиннута) + W0 sticky-precompute (**байт-идентично, golden-доказано**) — СДАНЫ, зелёные. Параллельный dispatch W1/W1.5/W2 — резидуал (§R1-residual, дизайн ниже). |
| **Чекпойнт-ревью** | ✅ ×3 | R2+R3 (4 minor/nit, фикс) · R4 (2 DC2-parity, фикс) · финал (см. §Ревью). Адверсариал-воркфлоу, author≠reviewer, refute-by-default. |
---
## R2 — WS3 Go-майнер (план §3) — ✅ СДАНО
Новый пакет `internal/pipeline/miner*.go` (substrate/detect/patterns/palladius/alias/emit) — **байт-верный
порт frozen exp16** (`exp16_common/detectors/patterns/arms/alias/palladius/emit_owner_sheets.py`),
**дефолт-лемматизатор B**. Per-буллет (§R2):
- ✅ **Порт V-A→V-C по ТОЧНЫМ порогам §3(б):** c-value `g(L)=log₂(L+1)` (не log₂L — пин
`TestMinerLengthMultNonDegenerate`/`CValueSingleCharNested`); subsumption α=0.80 (`TestMinerSubsumption`);
weirdness `p_book/max(word_rel,char_indep_rel,1e-9)`, N_book = полная пре-флор масса; n-граммы Han 16
overlapping, freq-floor 3; паттерн-каналы (surname/百家姓−凝, title-suffix/bare, ordinal_title, rank_grade,
topo, авто-формант over_rep≥15∧partners≥3 — `TestMinerFormantDetection`); arm A3 λ=0, bonus·pw,
subfloor `bonus+40·f·pw`. Детерминизм: все сорты `(-score,-freq,src)`, map-итерация только ПЕРЕД тотал-сортом.
- ✅ **Дефолт B — дроп Палладий-подканала V-C** (плюс `dst_variants`, `is_name_lemma`, spread λ, alias-R3,
canon-dst-recovery — все pymorphy3-зависимые; dst доставляет банкнота WS4). `is_palladius_token` порт как
**детерминированный** артефакт (Палладий-таблица, без pymorphy3) + тестируем (`TestMinerPalladiusToken`).
**Палладий-ЗАВИСИМЫЙ ранг 古月:21→22** — как предписано §10-14/§3(д); катастроф-скрин ∈top-50 PASS.
- ✅ **Палладий-ИНВАРИАНТНЫЕ гарантии — EXACT** (`TestMinerFullBookParity`, data-gated `TM_MINER_PARITY=1` на
стенд-данных: jieba-контраст + `rerun/records.json` + `guzhenren-seed-v2.yaml`): **n=13618** (SET-членство),
recall@proposed **0.9649** (55/57 GT), катастроф {方源:0,蛊:1,蛊师:2,古月:22}. Скипается при отсутствии данных
(как `TM_LIVE`). **Python = референс: byte-verified, расхождений 0 по инвариантам.**
- ✅ **Алиас-ярус-1 (R1R4, R4 hard-blocks ПЕРВЫМИ):** `TestMinerAliasRules` (5 сабтестов): R1-extension
(方源⊂古月方源), компози-гард (古月+族长=фраза-не-алиас, но 古月+方源=fullname-алиас), R4-iii/v (族长≠四代族长 по
разному approved-dst), R4-ii (разный пол блок, hidden не блокирует), R4-i/R2 (одна фамилия+разные имена→family).
**R3 (shared-ru-rendering) ОМИТ в дефолте B** (нужен `dst_lemmas`/pymorphy3) — задокументировано в коде.
- ✅ **Эмиссия сид-дельты §C2-7:** `MineBank``MinedTerm` с фильтрами `build_precision30` (тип∈{name,place,title},
freq≥5, ∉subsumed, len≥2, ∉фрагмент по PARTICLE) — НЕ сырой 13618-дамп; alias-кластеризация (репрезентант =
топ-ранг; кластер с seed-поверхностью = алиас-of-existing, не эмитится). `MinedDeltaYAML` → seedTerm-схема
БЕЗ новых полей. `TestMineBankDeterministicAndNonSeed`/`TestMinerEmissionFilters`.
- ✅ **`Source:"mined"` mined-write-путь:** `minedToCandidates` (штамп `Source:mined`, status:auto, no-dst,
зеркало `rubyToCandidates`, НЕ через `loadGlossarySeed`). `TestMinedToCandidatesStampsMinedSource` +
`TestMinedDeltaStaysBaseBankStable` (Source:mined НЕ двигает `BaseVersion` — «переоплата ОДНА» через реальный путь).
- ✅ **`tmctl seed-lint`:** `SeedLint(path)` = сухой прогон РЕАЛЬНОГО `loadGlossarySeed`+`approvedSharedKeyCollisions`;
CLI `seed-lint --seed <yaml>` (без `--config`). `seedlint_test.go`: чистый→OK; дефекты (approved-без-dst /
unknown-status / duplicate / shared-key) → фейл-лауд; **эмитированная дельта линтует чисто** (`TestSeedLintEmittedMinedDelta`).
**Артефакт-зависимость:** прод-майнер грузит jieba-контраст (5MB, SHA `7197c321…`, ВНЕ git) через
`LoadContrast(io.Reader)` — путь конфигурируется вызывающим. Тесты — синтетический малый контраст;
паритет — реальный jieba со стенда.
**Не-каскадная зона R2:** `MineBank`/`minedToCandidates`/`MinedDeltaYAML` — библиотека пак-3; ВЫЗОВ на
W1.5-границе (реseed через `ReplaceGlossary`) — часть волнового W1.5-стопа (R1-residual). Сдано как
самодостаточное, тестируемое ядро (санкция оркестратора: R2 самодостаточен, R1 вайрит).
---
## R3 — WS4 банкнота: 12 интеграционных точек (план §4) — ✅ СДАНО (на посл. раннере)
Ядро `banknote.go` (Block A) + интеграция. **Все точки пер-стадийные → в волновую модель переезжают КАК ЕСТЬ**
(применяются к W1/draft-стадии; условие оркестратора). Per-точка:
1. ✅ **Санитайзер:** банкнота-срез — ПЕРВЫЙ рубеж (`applyBanknote` до `classifyOutput`); trailing-note НЕ
реюзим (ревью-1 F3). Финал-бэкстоп = телеметрия п.10.
2. ✅ **cjk_leak:** src-колонка Han по построению → срез ДО санитайзера снимает ложь.
3. ✅ **classify/echo (п.3, ЖЁСТКИЙ порядок):** `applyBanknote` режет блок на ОБОИХ путях runAttempt (fresh-call
И checkpoint-hit/resume) ДО `classifyOutput` → echo/coverage/length видят ОЧИЩЕННЫЙ перевод, не Han-сноску.
Пин `TestBanknoteClassifyOverCleaned` (клин RU + Han-тяжёлый блок → OK, не cjk_artifact).
4. ✅ **Coverage:** числитель по очищенному (срез раньше coverageCheck).
5. ✅ **max_tokens += `bankTokenBudget`** (≤12×12) для translator при enabled (перевод первым, блок хвостом →
трункация бьёт блок). Пин: draft `max_tokens=656` (512+144) в e2e.
6. ✅ **request_hash/снапшот:** `banknoteSnap{enabled,parser_version}` в `snapshotID()`. **ДЕВИАЦИЯ-с-обоснованием:
omitempty-указатель (nil при off) вместо безусловного `{enabled:false}` зеркала sanitizerSnap** — включение
канала И bump парсера ВСЁ РАВНО громкий сдвиг (несущая loudness §4а-п6 сохранена), но ДОБАВЛЕНИЕ фичи НЕ
инвалидирует banknote-OFF книги (в т.ч. golden-фикстуру) → R3 golden БАЙТ-СТАБИЛЕН, re-capture не нужен.
Пин `TestBanknoteSnapshotFold`.
7. ✅ **Чекпоинт/резюм:** derived content-addressed → resume re-derives id бесплатно; сырой чекпоинт хранит
RAW-ответ, resume пере-режет.
8. ✅ **Re-point (главная правка):** OK-путь draft `final_hash``tm-banknote-v1:` derived-чекпоинт с ОЧИЩЕННЫМ
черновиком (`commitBanknoteExport`, формула §4б `sha256("tm-banknote-v1\x00"+reqHash+"\x00"+stripped)`); стоит
ПОСЛЕ резолва эскалации, потребляет `last.bankStripped` (эскалированный черновик тоже очищен). Пины
`TestBanknoteSliceReTelemetryResume` (final_hash=derived, derived=clean, resume $0) + `TestBanknoteEscalatedDraftStripped`.
9. ✅ **Пост-чек глоссария:** не затронут.
10. ✅ **Телеметрия (лауд):** store v9 (`n_banknote_lines`/`banknote_parse_fail`/`banknote_truncated`) в
retrieval_state; тред draft→translateChunk→persistRetrievalState.
11. ✅ **DeepSeek thinking-ON нетронут** (echoMineViolation).
12. ✅ **Экспорт:** final без сноски автоматически (срез раньше).
**§4б:** толерантный парсер (Block A) + **finish=stop-only гейт** — кандидаты ПАРСЯТСЯ только при `finish=="stop"`;
срез идёт РЕГАРДЛЕСС (echo/length classify всегда видят clean). Пин `TestBanknoteFinishStopOnlyGate`.
---
## R4 — WS5 чекеры + данные пакета + `export --pairs` (план §5) — ✅ ЯДРО СДАНО
- ✅ **R4a `tmctl export --pairs`:** `Export(pairs bool)` + `ChunkExport.Source` (omitempty, $0 из манифеста);
дефолт-экспорт target-only (обратно-совместим). CLI `--pairs`. `TestExportPairsIncludesSource`.
Пред-условие §5(д)-FP DC1/DC2 (src↔target). **Read-only проекция — снапшот/wire/деньги не трогает.**
- ✅ **R4b DC1/DC2/DC6 + zh-ru пакет** (`checkers_zh_ru.go`, порт `ws5_checkers_verify.py`): наблюдаемость
(НЕ диспозиция), в `runCheapGates`→retrieval_state; самогейтятся на контенте (0 на не-zh/чистом). **DC2 —
байт-верно референсу** (после ревью-фикса): ok_re-suppression гард (数十万 не фаерит при верном «сотни тысяч»)
+ case-SENSITIVE inner (референс без re.I). `TestDC1/DC2/DC6` (эмпир-позитивы + non-firing + parity-кейсы).
- ✅ **R4c DC3 gender-инъекция** (§5(б), «фикс Бай Нинбин»): `memoryEntry.gender``row.Gender`;
`genderConstraintNote` в `renderEditorConstraintBlock` (male/female жёсткие формы; hidden — мандат безродовых);
ДАННЫЕ пола уже в `MemoryVersion`, РЕНДЕР — в `renderFormatVersion` v2. `TestDC3GenderInjection`.
- **Пакет данных = код-консты, версия `cheapGateVersion`** (та же дисциплина, что существующие cheap-данные):
правка данных = громкий resnapshot. Конфиг-загружаемый per-pair пакет — чище, **резидуал** (§Резидуал).
**Golden re-capture ×2 (masked-чист, §Golden):** (1) `cheapGateVersion` v2→v3 — единственная payload-правка =
`style_check_version`, wire/вердикты байт-идентичны после hash-маскинга; (2) `renderFormatVersion` v1→v2 —
payload-версия + 7 editor-wire-тел получают gender-note (gender:m терм фикстуры), НИ ОДНОЙ вердикт/final-правки.
---
## R5 — WS6 арм-конфиги (план §6) — ✅ СДАНО
- ✅ **Армы = КОНФИГ:** `configs/pipeline-arm-mistral.yaml` (editor=mistral-large-2512) /
`pipeline-arm-deepseek-pro.yaml` (editor=deepseek-v4-pro, `few_shot:false`). Оба — копии боевого C1 с
ЕДИНСТВЕННОЙ правкой editor-model → другой `stageSnap.Model` → другой снапшот (арм-механика Block A). draft/
translator неизменны.
- ✅ **`echo_mine_test` под варианты** (`TestSwapArmConfigs`): каждый арм грузится; editor=арм-модель; НИ ОДИН не
вооружает эхо-мину (deepseek-v4-pro → ReasoningNone, thinking ON, `echoMineViolation` пуст на echo-prone
провайдере); few_shot (deepseek off / mistral nil=ON); **mistral-large-2512 несёт `rate_limit`** (WS6 пред-условие —
свап-арм за rate-guard WS1b).
- ✅ **Атрибуция арма:** editor-model уже в `chunk_status`/checkpoint `model_actual` → export/report несут её
БЕЗ новой кодовой правки (план: «уже в chunk_status»). Пере-прогон армов = отдельные джобы (разные снапшоты).
---
## R1 — волновой ИСПОЛНИТЕЛЬ: аддитив СДАН, драйвер-свитч РЕЗИДУАЛ
**Санкция оркестратора:** сначала аддитивные под-шаги без каскада; драйвер-свитч — финальный атомарный шаг;
не дотягивает → зелёное дерево + точный резидуал (**запланированный исход**). Сделано:
- ✅ **Пер-волновой снапшот (`snapshotIDForWave`, §1в — несущее):** рефактор `snapshotID()`→`buildSnapshotID(stages,
memVersion)` (**байт-идентичен** для all-stages+enriched → golden/последовательный драйвер НЕ двигаются).
`snapshotIDForWave(W1)` = translator-стадии + BASE-версия (excl mined); `(W2)` = editor-стадии + ENRICHED.
`waveStages` (партиция по роли), `baseMemoryVersion`. Пин `TestSnapshotIDForWave`: W1≠W2; **mined-approved
добавка двигает ТОЛЬКО W2, W1 стабилен** («переоплата ОДНА» на снапшот-уровне) + `TestWaveStagesPartition`.
- ✅ **W0 sticky-precompute (`wave.go precomputeSticky`, §1б):** sticky-цепочка — кросс-чанковая зависимость
(не пере-считать внутри параллельной волны) → предвычислена в W0 одним проходом (пер-глава reset, `Select`,
advance). **Последовательный драйвер ТЕПЕРЬ ЕЁ КОНСУМИТ** (`bookrun.go` W0-precompute; `translateChunk`
принимает готовый `memSel`, снят inline-`Select` + `activeIDs`-return). **БАЙТ-ИДЕНТИЧНО**`TestGolden`
зелёный без правок (несущий байт-паритет-пруф). Это ТОЧНЫЙ precompute, который консумит волновой исполнитель.
**РЕЗИДУАЛ R1 — драйвер-свитч (точный дизайн, `PACK11_REPORT.md §R1` + план §1):**
1. Партиция стадий по волне — **ГОТОВА** (`waveStages`); пер-волновой снапшот-апсерт (два UpsertSnapshot) —
рефактор `bookrun.go` W0-прелюдии (сейчас один `snapshotID`; заменить на `snapshotIDForWave(W1/W2)`, пинить
стадии волны к своему через `EnsureJob(snapshot_Wx)`).
2. W1-dispatch: N воркеров тянут draft-чанки, `runStage(draft)` под `snapshot_W1`, `memSel`=precompute[i] —
**ГОТОВЫ**: precompute, rate-guard (Block A), single-writer деньги (Block A), eager-clients (Block A).
3. W1.5-стоп: пустая mined-дельта → авто-продолжение; иначе `MineBank`→карта-подписи→подпись→`minedToCandidates`
`ReplaceGlossary`(reseed)→`snapshot_W2`**компоненты ГОТОВЫ** (R2), нужна оркестровка стопа/резюма.
4. W2-dispatch: N воркеров тянут edit-ЕДИНИЦЫ (`EditUnitID`, Block A), `prev`=конкатенация draft-чекпоинтов
единицы по `final_hash`, СВЕЖИЙ `Select` по тексту ВСЕЙ единицы над enriched-банком, `runStage(edit)` под
`snapshot_W2`. **W2 НИКОГДА не пере-рендерит draft** (несущий инвариант — редактор читает по `final_hash`).
5. **Каскад (почему не в этой сессии):** edit=единица меняет ГРАНУЛЯРНОСТЬ вывода (edit per-UNIT, адресуется
`(chapter, firstChunkIdx, "edit")`) → `ChunkOutcome`-форма + export/status/quality read-models + ~весь
e2e-суит (asserts per-chunk 2-стадии + call-counts) + СТРУКТУРНЫЙ golden-рерайт. Оркестратор явно
санкционировал структурный рерайт «один раз в R1»; аддитив-пруф (golden байт-стабилен) даёт уверенность,
что все несущие R2R5 компоненты на месте для атомарного свитча в отдельной сессии/окне.
6. kill-9 с N резервами: порт `store/kill9_test.go` на N воркеров (money-инвариант Block A уже single-writer-safe).
---
## Открытые эскалации / решения (флаг оркестратору при лендинге)
1. **Дефолт-B майнер** — ратифицированный §10-14 дроп pymorphy3-подканалов (spread λ / dst_variants /
is_name_lemma / Палладий-конфирм / alias-R3 / canon-dst). 古月:22 (не 21). Все Палладий-ИНВАРИАНТНЫЕ гарантии
воспроизведены EXACT. dst доставляет банкнота. **Faithful-A (полный pymorphy3-порт) — только при готовой
поддерживаемой Go-морфологии** (не строил).
2. **`banknoteSnap` omitempty** (не безусловный sanitizerSnap-зеркало) — де­виация-с-обоснованием (§R3-п6):
loudness сохранена, off-книги не re-bill-ятся, R3 golden байт-стабилен. **Требует подтверждения при лендинге.**
3. **DC-пакет данных = код-консты** (версия cheapGateVersion), не конфиг-загружаемый per-pair файл — конфиг-форма
резидуал (§Резидуал). Дисциплина resnapshot сохранена.
4. **R4 резидуал (честный, бюджет→R1):** DC7 grade/role-чекер (нужен promote 甲乙丙丁/四代族长 approved — owner-only,
§5-A2 UNVERIFIED); DC3 male/female-ЧЕКЕР (нужна coref-lite, §5(д)-FP-гейт); omission-бэкстоп (лёгкий Go-ru-стеммер
без pymorphy3 + лемма-матч по {dst,decl.forms,aliases}); DC2 word↔word дроби (q4a rule_b1/b2). Дизайны — план §5(б).
5. **R1 драйвер-свитч резидуал** (запланированный исход, санкция оркестратора) — дизайн §R1 выше.
6. **§5(д)-FP-замер DC1/2/3/6** — платный пере-прогон (не в этой сессии); `export --pairs` — пред-условие СДАНО.
⚠ FP-замер прогонять через **Go-код** (не `ws5_checkers_verify.py`), т.к. чекеры теперь байт-верны референсу,
но лендинг-решение должно отражать что реально шипается.
---
## Структурные переносы
- Новые файлы `internal/pipeline/miner_*.go` (9 шт — новый детектор-пакет, не рефактор), `checkers_zh_ru.go`,
`wave.go` (W0-precompute). Все — новые оси.
- `snapshotID()` → тонкая обёртка над извлечённым `buildSnapshotID(stages, memVersion)` (байт-идентично).
- `translateChunk` сигнатура: `stickyPrev map[...]``memSel memorySelection` (готовый из W0); снят
`activeIDs`-return (precompute владеет sticky-advance). Внутренний вызов, вне-скоуп поведение не затронуто.
- `Export()``Export(pairs bool)` (6 тест-вызовов обновлены на `Export(false)`).
## Багфиксы вне плана
- Нет вне-плановых. Правки по ревью: **R2+R3 (4):** derived-namespace `banknote-v1``tm-banknote-v1` (§4б EXACT-формула,
отдельная const от parser_version), мёртвая `rank`-карта в `MineBank`, doc-коммент, error-wrap
`commitBanknoteExport`. **R4 (2):** DC2 ok_re-suppression-гард + case-sensitive inner (байт-паритет референсу).
Все верифицированы адверсариально, пофикшены, суит зелёный.
## Golden re-capture (masked-структурный дифф — обязан быть объясним)
- **R4b (cheapGateVersion v2→v3):** masked-дифф = 4 строки, ЕДИНСТВЕННАя payload-правка `style_check_version`;
wire-тела + вердикт-строки БАЙТ-ИДЕНТИЧНЫ после hash-маскинга (verified скриптом).
- **R4c (renderFormatVersion v1→v2):** masked-дифф = 18 строк: 4 версия-payload + 7×(old/new) editor-wire-тел с
gender-note (gender:m терм). НИ ОДНОЙ вердикт/disposition/final-text правки (Counter: version/gender-note/wire только).
- **R3/R5/R1-аддитив:** golden НЕ двигается (banknote omitempty off; армы — отдельные файлы; snapshot-рефактор
байт-идентичен). СТРУКТУРНЫЙ golden-рерайт — событие драйвер-свитча (R1-residual), не этой сессии.
## Верификация (исполнением — логи, не слова)
```
$ go build ./... → OK
$ go vet ./... → OK (чисто)
$ go test -race ./... → ВСЁ ЗЕЛЁНОЕ:
ok cmd/tmctl · config · ledger · llm · pipeline (~28s -race) · store
$ gofmt -l <мои файлы> → пусто (репо несёт ПРЕ-существующий gofmt-долг в не-моих файлах — не трогал)
$ TM_MINER_PARITY=1 go test -run TestMinerFullBookParity
→ n=13618 · катастроф {方源:0,蛊:1,蛊师:2,古月:22} · recall@proposed=0.9649 (55/57 GT) — PASS
```
**Фикстуры плана исполнены:** miner-паритет (Палладий-инвариант EXACT, 古月:22 дефолт-B) · банкнота 12 точек
(5 e2e-пинов) · seed-lint 0 фейл-лаудов · DC-позитивы пойманы · пер-волновой снапшот «переоплата ОДНА» ·
W0 sticky байт-паритет (golden) · draft request_hash стабилен (golden).
## Чекпойнт-ревью (адверсариал-воркфлоу, author≠reviewer, refute-by-default, 3 линзы)
- **R2+R3:** 5 сырых → 4 confirmed (все minor/nit, 1 refuted): `tm-banknote-v1` namespace / мёртвая rank /
doc / error-wrap. **НИ ОДНОГО major correctness/wire/money.** Все пофикшены.
- **R4:** 2 confirmed (оба DC2 reference-divergence): ok_re-supress-гард (MAJOR — реальный FP-дивердж на
ch2/chunk0) + case-sensitivity (MINOR). Оба пофикшены — DC2 байт-верно референсу.
- **Финал (R5 + R1-аддитив + кросс-каттинг инвариантов, 2-мерный воркфлоу + verify):** **0 находок — ЧИСТЫЙ
вердикт.** Обе линзы (R1-аддитив байт-паритет/пер-волновой снапшот/precompute-эквивалентность; R5-армы +
кросс-каттинг: деньги/снапшот-дисциплина/детерминизм/store-v9) вернули пустой список. Ни одного invariant-
break, un-folded wire/verdict-change, byte-parity-break или map-order-в-выводе. `go test -race ./...` зелёный,
golden байт-стабилен, arm/wave-тесты проходят — clean bill.

View file

@ -0,0 +1,239 @@
# PACK11_REPORT — бэкенд-сессия по плану 11 (2026-07-19)
> Строил по `docs/architecture/11-implementation-plan.md` (ратифицирован D39.12). **НЕ коммитил**
> сдача на приёмку оркестратору. Верификация исполнением: `go build ./... && go vet ./... &&
> go test -race ./...` — зелёное (логи ниже). Все несущие изменения сверены с живым HEAD-кодом.
## 0. Честный статус-обзор (что сдано / что резидуал)
Пак 11 — это ФАКТИЧЕСКИ четыре пакета работ (§7 плана: пак-2 слой-1 + пак-3 слой-4 + пак-4 слой-5 +
конфиг-пак). За одну сессию доведено до **зелёного, оттестированного, лендабельного** состояния:
| WS | Компонент | Статус |
|---|---|---|
| **WS2** | чанкер output-бюджет + edit-единицы + oversized-флаг + src→dst редактор + снапшот-фолды + golden re-capture | ✅ **СДАНО** (тесты + golden) |
| **WS1a** | eager-build клиентов в W0 + read-only `client()` (гонка `runner.go` закрыта) | ✅ **СДАНО** (`-race`) |
| **WS1b** | пер-модельный rate-guard (семафор конкуренции + пейсинг) | ✅ **СДАНО** (concurrency-тест) |
| **WS1c-инфра** | base/enriched `MemoryVersion`-split (механика пер-волнового снапшота, «переоплата ОДНА») | ✅ **СДАНО** (unit-тест) |
| **WS4-core** | банкнота: pure `splitBanknote`/`parseBanknote` (`⟦TM-BANK-v1⟧`, толерантный сплит, Han-в-src, finish-truncation) | ✅ **СДАНО** (7 инвариант-тестов; Go↔Python паритет к `banknote.py`, пин 95/65/0/0 воспроизведён рефересом) |
| **WS6** | mistral rate_limit конфиг (свап-арм за rate-guard); арм-механика (editor-model = конфиг → снапшот per-арм) уже в коде | ✅ **частично** (конфиг-тай; арм-пайплайн-конфиги — резидуал) |
| **WS1c-executor** | параллельный волновой ИСПОЛНИТЕЛЬ W1/W1.5/W2 + пер-волновой снапшот-wiring + golden-рерайт + resume-across-waves + kill-9 N-резерв | ⚠️ **РЕЗИДУАЛ** (дизайн ниже, §Резидуал) |
| **WS4-integration** | 12 точек интеграции банкноты (срез ДО classify, derived-checkpoint, snapshot-фолд, телеметрия) | ⚠️ **РЕЗИДУАЛ** (ядро+дизайн готовы) |
| **WS3** | Go-майнер V-A→V-C + алиас + консолидация + сид-дельта + `tmctl seed-lint` + `Source:mined` | ⚠️ **РЕЗИДУАЛ** (Source-split инфра готова; порт — резидуал) |
| **WS5** | DC-чекеры + omission-бэкстоп + данные пакета пары + `tmctl export --pairs` | ⚠️ **РЕЗИДУАЛ** (дизайн ниже) |
**Почему не весь объём:** волновой ИСПОЛНИТЕЛЬ (WS1c) — это ре-архитектура модели исполнения
(последовательный per-chunk-all-stages → W1 draft-∥ / W1.5-стоп / W2 edit-∥-over-units), которая
инвалидирует golden + ~10 последовательно-модельных тестов (runner_memory/export/status/cheapgates/
quality/escalation_budget/maxtokens_floor) и добавляет двух-фазный CLI-флоу. Довести это до зелёного
за сессию БЕЗ поломки суита нереалистично. Решение (мандат «честный резидуал > фейк-комплит, не
сдавать сломанное»): сдать **несущую БЕЗОПАСНУЮ инфраструктуру волн** (eager-clients, rate-guard,
base/enriched split, edit-units) как лендабельную и зелёную, а параллельный исполнитель —
резидуалом с точным дизайном (§Резидуал). Ни один сданный кусок не оставляет суит красным.
---
## 1. WS2 — чанкер-бюджет + edit-единица (план §2) — ✅ СДАНО
Per-буллет чек-лист (буллеты промта):
- ✅ **Бюджет draft-чанков в ВЫХОДНЫХ токенах** `est_out = 1.1978·cjk + 0.3852·other`, конфиг
`config.Segmentation` (per-pair, снапшот-folded через `segmentationSnap`). Классы символов —
**РЕАЛЬНЫЕ `unicode.RangeTable`** (`Han|Hiragana|Katakana|Hangul`, `tokenClassCounts` = те же, что
`EstimateTokens`), НЕ ord-диапазоны (§0.1 общность). Пин `target_out=1797 → 56 чанков`
воспроизведён на пере-прогонном корпусе (см. §Верификация ниже).
- ✅ **Edit-единица = глава; дефолт `EditCeilingOut=3200`.** Реализовано как **greedy-группировка
ЦЕЛЫХ draft-чанков до потолка** (`assignEditUnits`). **ДЕВИАЦИЯ-с-обоснованием (эскалация-нота):**
план §2(б) описывает edit-единицы как paragraph-greedy-pack; я реализовал grouping-of-whole-chunks
потому что независимые нарезки (para-pack draft @1797 и para-pack units @3200) МОГУТ дать
straddle draft-чанка через границу единицы → сломанная реконструкция draft для W2. **Эмпирически
проверено на реальном 25-гл корпусе:** grouping-whole-chunks даёт ТЕ ЖЕ **37 единиц**, что
para-pack, при **0 straddle** (скрипт в scratchpad; вывод: `{draft:56, para_units:37,
grouped_units:37, straddle:0}`). Т.е. на этом корпусе оба определения СОВПАДАЮТ, а grouping —
робастный супермножество-инвариант (гарантирует чистую реконструкцию всегда). Пин 37 сохранён.
- ✅ **8000-арм НЕ открыт** — дефолт 3200; `edit_ceiling_out` остаётся config-tunable (потолок —
конфиг, арм гейтнут §11), валидация `EditCeilingOut ≥ DraftBudgetOut`.
- ✅ **`Chunk` аддитивно:** `EstOut float64`, `OversizedSentence bool`, `EditUnitID int`. Контракт
`Chapter/ChunkIdx/Text` цел (тесты `TestSplitChunks*` + fuzz lossless-tiling зелёные).
- ✅ **Мега-предложение** = passthrough + `OversizedSentence=true` (БЕЗ клауза-сплита). Пин
`TestSplitChunksOversizeSentenceIsOwnChunk`.
- ✅ **Снос мёртвых `STMDepth`/`OverlapTokens`** из `config.ContextAssembly` + `contextSnap` +
yaml-конфигов (carryover не строим). Структурная смена `contextSnap` → строка манифеста §8.
- ✅ **src→dst-формат `renderEditorConstraintBlock`** (`- 源термин → «dst»`), CONFIRMED-only ЦЕЛА,
дедуп по `(src,dst)` (омоним-widening). Обоснование — билингв-редактор + омонимичный dst (§2в
ревью-1 F4). **Выделенный `render-format-version`** снапшот-компонент (НЕ `memoryMatchVersion`).
Пины `TestRenderEditorConstraintBlock` + `TestRunnerMemoryInjectionAndPostcheck` обновлены.
- ✅ **Сайзинг max_tokens редактора**НЕ трогал (F8: существующий `stagerun.go:87-90` сайзит от
черновика `prev`; новой формулы не вводил — как и предписано).
- ⚠️ **Инъекция W2-единицы (СВЕЖИЙ Select над enriched-банком)** — часть волнового ИСПОЛНИТЕЛЯ
(резидуал WS1c). Механика src→dst-блока и enriched-версии готова; вызов свежего Select по тексту
единицы делается в W2-проходе (резидуал). **Whole-chapter Select-бюджет (§2е открытый)** — решать
в исполнителе (резидуал-нота).
Снапшот-фолды WS2 (§8): `chunkerVersion` v4→v5, `segmentationSnap`, `render_format_version`, снос
`stm_depth`/`overlap_tokens`. Golden re-captured — masked-дифф ниже (только versions/new-fields/
editor-src2dst; НИ одной несвязанной content-правки; фикстура НЕ ре-чанкается — 20 чанк-строк до/после).
## 2. WS1 — волновой раннер (план §1)
- ✅ **WS1a: Eager-build ВСЕХ клиентов в W0** (`buildClients` над `reachableModels()` =
`Stages[].Model EscalateTo`), вызван в `TranslateBook` W0-прелюдии ДО любой горутины. `client()`
теперь **READ-ONLY** — громкий отказ на miss (закрывает гонку `runner.go:171-181` без лока;
трипваер на будущую 3-ю модель-ось). `BuildClient` не требует ключа → безопасно на key-less resume.
`-race` чист.
- ✅ **WS1b: Пер-модельный rate-guard** (`ratelimit.go`): семафор конкуренции `models.yaml
rate_limit.max_concurrency` + опц. пейсинг `min_interval_ms`. **Транспорт-ось, НЕ снапшот** (wire
не трогает). Построен в W0 (`buildRateGuards`), read-only в волнах; обёрнут вокруг ОДНОГО
`client.Complete` в `runAttempt` (ctx-aware acquire/release, reservation освобождается при
ctx-cancel). Пины: `TestRateGuardLimitsConcurrency` (семафор РЕАЛЬНО ограничивает — cap=2/20
воркеров, observed-max ≤ 2), nil/unlimited no-op, ctx-cancel без leak, pacing. **`waves.workers`
конфиг** — вводится с исполнителем (резидуал).
- ✅ **WS1c-инфра: base/enriched `MemoryVersion`-split.** `MemoryBank.BaseVersion()` = хеш approved
`Source∈{seed,ruby,auto}` (БЕЗ mined); `Version()` (enriched) = все approved вкл. `Source:mined`.
`computeMemoryVersionScoped(excludeMined)`; enriched-путь **байт-идентичен** прежнему
`computeMemoryVersion` (домен-сепаратор только в base) → текущий снапшот не двигается от split-а.
Пин `TestBaseEnrichedMemoryVersionSplit`: добавление mined-approved строки двигает ТОЛЬКО enriched,
base стабилен (= «переоплата ОДНА» механизм); base≠enriched (домен-разделены). **`Source:mined`**
как значение enum — аддитивно (колонка `source` — свободный TEXT, миграция store не нужна;
подтверждено чтением `migrate.go`).
- ⚠️ **WS1c-executor (РЕЗИДУАЛ):** пер-волновой снапшот-wiring (`snapshot_W1`/`snapshot_W2` upsert),
W0 sticky-precompute, W1/W2 параллельный dispatch, W1.5-стоп + mined-write-путь, wave-sequencing
инвариант (W2 не пере-рендерит draft), money-под-∥ kill-9 N-резерв, scheduler-aware окна. Дизайн —
§Резидуал.
## 3. WS4-core — банкнота-парсер (план §4б) — ✅ СДАНО
`internal/pipeline/banknote.go` — байт-верный порт `exp16/banknote.py`:
- `splitBanknote(output)` → срез блока `⟦TM-BANK-v1⟧` (clean = rstrip до сепаратора, block = strip("\n"));
- `parseBanknote(block, truncated)` → толерантный сплит `\t| {2,}|\s*\|\s*`, тип∈{name,place,title,term,
nickname}+дефолт term, src ДОЛЖЕН нести Han `[㐀-鿿]` (точный диапазон U+3400U+9FFF), truncation-толерантность
на ПОСЛЕДНЕЙ строке (флаг `Truncated`, не parse_fail);
- `bankParserVersion="banknote-v1"` (для будущего `banknoteSnap{enabled,parser_version}`).
- Тесты (`banknote_test.go`): no-SEP→всё clean; срез блока (утечки нет); толерантный сплит tab/≥2проб/pipe;
non-Han src=bad; truncation True→флаг/False→parse_fail; пустой блок; SEP≠note-слово. **Все PASS.**
- **Паритет:** референс `ws4_banknote_verify.py` воспроизводит пин **95 строк / 65 distinct / 0 fail / 0
trunc** + truncation-поведение + инварианты — идентично моему Go-порту (сверено исполнением).
**WS4-integration (12 точек) — резидуал** (§Резидуал R3): требует правок `chunkrun.go`/`stagerun.go`/
`disposition.go`/`snapshot.go` (файлы под адверсариал-ревью этой сессии — не трогал во избежание дрейфа
цитат; ядро + точный дизайн готовы).
## 45. WS3 / WS5 — ⚠️ РЕЗИДУАЛ (дизайн в §Резидуал)
Не начаты в коде (кроме `Source:mined`-инфра из WS1c, которая — предусловие WS3-mined-write). Причина
— бюджет сессии ушёл в несущий каркас Block A + верификацию + WS4-ядро. Дизайны/пред-условия — §Резидуал.
---
## Багфиксы вне плана
Пока нет (изменения строго по плану). Правки по ревью — см. §Адверсариал-ревью ниже (2 doc-фикса + 1 gofmt).
## Структурные переносы
- Новый файл `internal/pipeline/ratelimit.go` (rate-guard) + `banknote.go` (WS4-ядро) — новые оси, не рефактор.
- `SegBudget` (resolved segmentation) живёт в `chunker.go`; резолвер `segBudget()` — в `runner.go`
(segmenter остаётся config-free). `renderFormatVersion` const — в `memory.go` рядом с рендерерами.
---
## Верификация (исполнением — логи, не слова)
```
$ go build ./... → OK
$ go vet ./... → OK (чисто)
$ go test -race ./... → ВСЁ ЗЕЛЁНОЕ:
ok cmd/tmctl 1.0s · config · ledger · llm · pipeline 19.4s · store
$ gofmt -l <мои файлы> → пусто (все чисты; репо несёт ПРЕ-существующий gofmt-долг в ~13
не-моих файлах под go1.26.4 — их не трогал)
```
**Фикстуры плана (исполнены рефересами `eval/design11/*` под `eval/.venv`):**
- WS2 фертильность/нарезка: `56 чанков @1797` / `37 edit-единиц @3200` / **`0 straddle`** —
подтверждено `ws2_straddle_check.py` (grouping-whole-chunks ≡ para-pack на корпусе).
- WS4 банкнота: `95 строк / 65 distinct / 0 fail / 0 trunc` (`ws4_banknote_verify.py`) — мой Go-парсер
воспроизводит контракт (7 инвариант-тестов PASS, идентичны демо `banknote.py`).
**Go-тесты, добавленные/обновлённые этой сессией (все PASS под `-race`):**
`TestSplitChunks*` (+ EstOut/OversizedSentence/EditUnitID пины + `TestSplitChunksEditUnitGroupsWholeChunks`),
`TestRateGuard*` (concurrency-cap реально ограничивает, ctx-cancel без leak, pacing, nil no-op),
`TestBaseEnrichedMemoryVersionSplit`, `TestRenderEditorConstraintBlock` (src→dst), `TestRunnerMemoryInjectionAndPostcheck`,
`TestSplitBanknote*`/`TestParseBanknote*`, `TestGoldenDeterminism` (re-captured).
## Адверсариал-ревью Block A (мандат: author≠reviewer, refute-by-default, 3 линзы) — ИСПОЛНЕН
9-агентный воркфлоу (3 ревью-линзы: алгоритм-vs-план / сеньор-код-ревью / функциональное, каждая
находка адверсариально верифицирована). **6 сырых находок → 3 подтверждено, 3 отвергнуто** (верификатор
refuted как not-a-bug/hypothetical):
- **CONFIRMED-1/2 (minor, doc):** комменты base/enriched говорили «equal until mined», но код
ДОМЕН-РАЗДЕЛЯЕТ их (base≠enriched всегда). Код/дизайн ВЕРНЫ; комменты — нет. **ПОФИКШЕНО**
(`memory.go:135`,`:179`).
- **CONFIRMED-3 (nit, style):** `subChunk` struct не gofmt-выровнен. **ПОФИКШЕНО** (`gofmt -w`).
- **REFUTED (верифицировано как не-баг):** (a) «edit-unit grouping диверджит от плана» → НЕ баг:
grouping-whole-chunks ТРЕБУЕТСЯ для W2-реконструкции, ≤3200 by construction, а т.к.
`chapterDraftChunks` НИКОГДА не режет абзац — каждая draft-граница = абзац-граница → удовлетворяет
И §2б И §1б (сильнее, чем я аргументировал); 37 — описательная статистика, не решающая ветка кода.
(b) rate-guard money-путь «нет интеграц-теста» → гипотетический будущий регресс, не дефект сданного
кода (release-on-cancel корректен). (c) job-status «running» до acquire → корректная семантика,
лог `calling model` маркирует queued-vs-wire. Диспозиции — в отчёте, тихих девиаций нет.
## Golden re-capture (WS2) — маскированный структурный дифф
18 изменённых masked-строк, ВСЕ объяснимы: `chunker_version` v4→v5; снятые `stm_depth`/`overlap_tokens`;
новое `segmentation:{1797,3200,1.1978,0.3852}`; новое `render_format_version:renderfmt-v1-editor-src2dst`;
редактор-блок `- «dst»``- src → «dst»` (7 editor-body строк). Фикстура НЕ ре-чанкается. Полный
masked-дифф — scratchpad `ws2_golden_masked_diff.txt`.
---
## РЕЗИДУАЛ — точные дизайны для следующей сессии
### R1. Волновой ИСПОЛНИТЕЛЬ (WS1c-executor) — несущий, самый крупный
Каркас готов (eager-clients, rate-guard, base/enriched split, edit-units на чанках). Осталось:
1. `snapshotIDForWave(wave)` — рефактор `snapshot.go`: fold ТОЛЬКО стадии волны (draft=translator-роль
→ W1; editor-роль → W2) + версию банка волны (`BaseVersion` для W1, `Version` для W2). Апсертить
ДВА снапшота.
2. Партиция стадий по роли; edit-единица addressable как `(chapter, firstChunkIdxOfUnit, "edit")` в
`chunk_status` (без миграции — units per-chapter, group-of-whole-chunks).
3. W0 sticky-precompute: реплицировать последовательную sticky-цепочку ($0, `Select` src-производна)
→ инъекц-байты draft ≡ последовательным (golden-пин).
4. W1 dispatch: N воркеров тянут draft-чанки, `runStage(draft)` под `snapshot_W1`. W2 dispatch: N
воркеров тянут edit-единицы, `prev` = конкатенация draft-выходов чанков единицы (из W1-чекпоинтов
по `final_hash`), СВЕЖИЙ `Select` по тексту ВСЕЙ единицы над enriched-банком, `runStage(edit)` под
`snapshot_W2`. **W2 НИКОГДА не пере-рендерит draft** (несущий инвариант §1в).
5. W1.5: при пустой mined-дельте (WS3 не построен) — авто-продолжение; иначе стоп + карта подписи +
mined-write-путь (штамп `Source:mined`, зеркало `rubyToCandidates`, НЕ через `loadGlossarySeed`).
6. Money-под-∥: воркеры-горутины через single-writer store (уже сериализует). kill-9 тест с N живыми
резервами (порт `store/kill9_test.go` на N). rate-guard уже интегрирован.
7. Golden-рерайт под волновую модель + обновление ~10 последовательно-модельных тестов.
### R2. WS3 майнер — референс `eval/exp16/{arms,alias,canon,patterns,spread,palladius,banknote}.py`
Новый пакет `internal/pipeline/miner*.go`. Порт V-A→V-C по ТОЧНЫМ порогам §3(б); дефолт-лемматизатор
**B** (дроп Палладий-подканала → `古月:21→22`, скрин PASS). Инвариантные гарантии (EXACT): 13618-канд-СЕТ,
recall 0.9649/0.9318, катастроф-скрин `方源/蛊/蛊师` ранги 0/1/2 ∈top-50. Реюз `memnorm.normalizeSourceKey`.
`tmctl seed-lint` (сухой прогон фейл-лаудов `loadGlossarySeed`). Go↔Python паритет (Python=референс).
`Source:mined`-инфра (base/enriched split) — **готова** (WS1c).
### R3. WS4 банкнота — референс `eval/exp16/banknote.py`, спека research/20 §B3
Pure `split_banknote`/`parse_banknote` (`⟦TM-BANK-v1⟧`, ≤12 строк src\tdst\ttype, толерантный сплит,
Han-в-src, finish=stop-only гейт). 12 точек: срез ДО `classifyOutput` (`chunkrun.go`); derived-checkpoint
`sha256("tm-banknote-v1\x00"+reqHash+"\x00"+stripped)` через `PutDerivedCheckpoint` (прецедент
`commitSanitizedExport`**уже есть**); re-point `final_hash` OK-пути draft на derived ПОСЛЕ резолва
эскалации (`last.stripped`); `banknoteSnap{enabled,parser_version}` в `snapshotID()` (фолд при enabled,
зеркало `sanitizerSnap`); инструкция сноски В ФАЙЛ промпта; телеметрия `n_banknote_lines`/`parse_fail`/
`truncated`. Пин: пере-парс 95 строк = 0 fail (`ws4_banknote_verify.py`).
### R4. WS5 DC-чекеры + omission-бэкстоп + данные пакета пары + `tmctl export --pairs`
Расширить `cheapgates.go` DC1/2/6/7 (детерм. флаггеры, target-gated, наблюдаемость не гейт); DC3
gender-инъекция (male/female/hidden-мандат) из seed-поля `Gender` в `renderEditorConstraintBlock`
(фолд через `render-format-version` + `MemoryVersion`); omission-бэкстоп (substring по
`{dst,decl.forms,aliases}` + лёгкий Go-стеммер, БЕЗ pymorphy3); данные пакета zh-ru (версионируемый
снапшот-folded пакет); `tmctl export --pairs` (source-join уже в `Export`) — пред-условие §5(д)-FP.
DC5-детекцию НЕ строить (74%/42% FP); hidden-ЧЕКЕР НЕ строить (Ф2-кореф). Референс `ws5_checkers_verify.py`.
### R5. WS6 арм-конфиги
Арм-механика (editor-model = конфиг → `stageSnap.Model` → снапшот per-арм) **уже работает**. Осталось:
арм-пайплайн-конфиги (editor=mistral-large-2512 / deepseek-v4-pro, `few_shot:false` для reasoning),
атрибуция арма в export/report (editor-model уже в chunk_status), `echo_mine_test` под арм-варианты.
mistral rate_limit — **сдан** (WS1b/WS6 тай).
### Открытые эскалации владельцу/оркестратору
- edit-единица grouping-of-whole-chunks vs para-pack — СОВПАДАЮТ на этом корпусе (0 straddle);
выбран grouping для гарантии реконструкции. Требует подтверждения при лендинге.
- Реестр §10 (W1.5-UX, DeepSeek ×2 ре-чек 24.07, whole-chapter Select-бюджет) — открыты как в плане.

View file

@ -0,0 +1,86 @@
# R1 драйвер-свитч — бэкенд-сессия, отчёт (2026-07-19)
Строил по `BACKEND_PLAN11_SESSION_PROMPT.md` + `PACK11_CONT_REPORT §R1` + план §1/§2. **НЕ коммитил** — сдача на приёмку. $0 к провайдерам (вся верификация build/test/golden/фикстуры). Верификация исполнением: `go build ./... && go vet ./... && go test -race ./...` — ВСЁ ЗЕЛЁНОЕ (логи ниже).
## Итог по задачам захода
| # | Задача | Статус |
|---|---|---|
| 1 | **R1 драйвер-свитч** (волновой исполнитель черновик(∥)→банк-майнинг-стоп→редактура(∥) + партиция стадий + dispatch + стоп/подпись/mined-write + kill-9) | ✅ СДАНО |
| 2 | **Wiring майнера в живой банк-майнинг-стоп** (грузить lang.Pack + фолд pack.Version() + mined-write Source:mined) | ✅ СДАНО |
| 3 | **Ш-2** (unicode.Version в memoryNormVersion + classifier/style + chunker/estimator) | ✅ СДАНО |
| 4 | **Ш-1** (masked-diff хелпер при golden re-capture) | ✅ СДАНО |
| 5 | **Структурный golden-рерайт + обновление последовательно-модельных тестов** | ✅ СДАНО (38 объяснимых wire/verdict-изменений) |
| + | **Именование** (директива владельца: без аббревиатур W0/W1/W1.5/W2 в коде/логах) | ✅ СДЕЛАНО (весь пакет) |
## Чек-лист исполнения (per-буллет)
### R1 — волновой ИСПОЛНИТЕЛЬ (waverun.go — новый; bookrun.go; escalation.go)
- ✅ **`translateBookWaves`** заменил последовательный цикл в `TranslateBook`. Партиция стадий по роли: `waveStagesIndexed(waveDraft/waveEdit)` (translator vs non-translator).
- ✅ **Пер-волновой снапшот-апсерт+пин:** черновик под `snapshotIDForWave(waveDraft)` (base-банк), редактура под `snapshotIDForWave(waveEdit)` (enriched); каждый апсертится + `EnsureJob` пинит джобы своей волны. «Переоплата ОДНА» — пиннута `snapshot_wave_test`/`memory_wave_test` (не регрессировали) + golden (base_memory_version отдельно от memory_version).
- ✅ **Черновик-dispatch (∥):** `runWave` (N-воркер пул, dependency-free) → `runDraftChunk``runStageSequence`. `memSel` = W0-precompute (base-банк). Деньги single-writer (Reserve/Settle), rate-guard, eager-clients — Block A, реюз.
- ✅ **Банк-майнинг-стоп (mining.go):** пустая дельта → авто-продолжение; непустая → `MineBank``MinedDeltaYAML`→signature-map файл→**СТОП** (`WaveSignatureStop`, CLI-сентинел). Гейт: `r.pack != nil && ContrastPath != ""` (иначе no-op). Не конфигурировано → авто-продолжение (все текущие фикстуры).
- ✅ **Редактура-dispatch (∥):** `runEditUnit` per edit-UNIT. `prev` = конкатенация draft-выводов членов (`unitJoinSeparator`="\n\n"); **СВЕЖИЙ `Select` по тексту ВСЕЙ единицы над ENRICHED-банком, sticky=nil**; `renderEditorConstraintBlock(editSel)`. **НИКОГДА не пере-рендерит draft** (несущий инвариант — golden доказывает: ch2-8 edit-wire байт-идентичны, только ch1 concat меняется; черновик-wire все байт-идентичны).
- ✅ **Flag+skip:** член-черновик флагнут → единица флагнута, редактура пропущена (`recordSkippedStages`), деньги хороших членов committed. Пин `TestWaveEditUnitFlaggedMemberDraft`.
- ✅ **BookResult = per edit-UNIT** (`ChunkOutcome{ChunkIdx=firstChunkIdx, Stages=[member drafts..., edit]}`). Форма struct не менялась → renderTranslate/большинство тестов выжили.
- ✅ **Эскалация под ∥:** `escMu` сериализует budget-admission fresh-хопа через settle (soft-cap точен: overshoot ≤ 1 хоп, не N-1). $0-replay lock-free. Пин `TestWaveEscalationBudgetSerializedUnderParallelism`.
- ✅ **kill-9 / деньги:** атомарность settle+checkpoint — store/kill9_test (SIGKILL, не менялся, model-уровень single-writer). N-воркер сохранность денег — `TestWaveParallelWorkersMoneyConserved` (committed==run-total, reserved==0). -race чист.
### Wiring майнера (runner.go; mining.go; seeding.go; snapshot.go; config)
- ✅ **`loadLangPack` в openRunner** (write+read пути — read-модели репродуцируют снапшот): пара С каталогом (`configs/langpacks/<src>-<tgt>/`) → `lang.Load` fail-loud; пара БЕЗ → nil-and-run.
- ✅ **`pack.Version()` фолд** в `buildSnapshotID` (`langpack_version`, omitempty при nil → книги-без-пака байт-стабильны).
- ✅ **mined-write путь:** `loadMinedDelta` (book `mined_delta`) грузит через `loadGlossarySeed` + пере-штампует `Source:mined` (НЕ seed) → enriched двигается, base стабилен. `seedGlossary` аппендит.
### Ш-2 (memnorm.go; disposition.go; cheapgates.go; render.go)
- ✅ `unicode.Version` вплетён в `memoryNormAlgoVersion`, `classifierVersion`, `cheapGateVersion` (директива) + **расширено на `chunkerVersion`, `estimatorVersion`** (тот же silent-drift класс — tokenClassCounts/EstimateTokens классифицируют unicode-таблицами → задают чанк-границы+max_tokens → wire; обосновано в коммент-note). ТРИПВАЕР: не бампать тулчейн до Ш-2 в снапшоте (на 1.26.4/Unicode 15.0.0).
- Резидуал: `x/text/norm` несёт СВОЮ Unicode-редакцию независимо от stdlib `unicode.Version` — standalone x/text-бамп = deliberate --resnapshot (как тулчейн-бамп); отмечено коммент-note (директива была `unicode.Version`).
### Ш-1 (golden_test.go)
- ✅ `goldenMaskedDiff` — маскирует hex-хеши (≥12) + snapshot-payload-строки + wire-body call-индекс (нормализует reorder волн), считает «N wire/verdict-изменений / M version-only» мультисетом (переживает вставки/удаления строк), печатает ДО записи в `TM_UPDATE_GOLDEN`-пути.
## Read-модели (export.go / status.go / quality.go) — волно-осведомлённые
- **export:** итерирует `outputUnits` (edit-единицы для edit-пайплайна, чанки для draft-only); одна строка на единицу (не фантом-pending для не-лидеров); `--pairs` source = joined member-source (байт-выровнено с `runEditUnit`). Drift — ОБЕ волны (партиция строк по стадии-волне, сравнение с per-wave проекцией).
- **status:** per-unit (expected = members·|draft|+|edit|); miss/style лукап по лидеру; флагнутая edit-единица = 1 в вердикте (не per-member); SnapshotDrift = >1 ВНУТРИ волны (2 волны — норма); ConfigDrift/Redrive-guard per-wave (иначе спурьёзный abort).
- **quality:** TotalChunks=units (bookChunks); агрегаты (KPI/misses/style/echo) корректны per-unit; per-chunk KPI-строки не-лидеров разрежены (draft-сигналы, 0 KPI) — наблюдаемость, не гейт.
## Golden re-capture (masked-дифф — объясним)
Фикстура: 8 глав → **8 единиц** (ch1 = 2 черновик-чанка → 1 edit-единица), **18 вызовов** (было 19: ch1 два edit → один concat). Masked-дифф: **38 wire/verdict-изменений — ВСЕ объяснимы:**
1. Хедер: `snapshot_id``snapshot_draft`+`snapshot_edit`+`base_memory_version`.
2. ch1: 9 чанков → 8 единиц (ch1/0+ch1/1 → 1 единица); ch1/1 edit chunk_status/request_log/wire удалены.
3. edit-skip detail переформулирован («upstream stage» → «member draft chunk of this edit unit»).
4. ch1 edit-wire: 2 per-chunk edit → 1 concat edit (`ЧЕРНОВИК\n\nЧЕРНОВИК`).
+ 200 version-only (Ш-2 версия-churn + per-wave/langpack хеши). **НИ ОДНОГО неожиданного verdict-изменения; черновик-wire + ch2-8 edit-wire байт-идентичны (несущий инвариант).**
## Чекпойнт-ревью после R1-ядра (адверсариал, author≠reviewer, refute-by-default, 4 линзы + verify)
4 линзы (алгоритм-vs-план · конкуренция/деньги · resume/снапшот · read-модели) → verify-пас (refute-by-default). **4 raw / 4 confirmed / 0 refuted.** Все находки ВПИТАНЫ + пофикшены + пиннуты:
- **MAJOR (3 линзы независимо — CONFIRMED):** **draft-инъекция считалась над ENRICHED-банком (вкл Source:mined), а снапшот черновика пинился к BASE-версии (excl mined).** База/enriched-сплит был реализован ТОЛЬКО на уровне версия-хеша (`computeMemoryVersionScoped`), НЕ на уровне выборки-инъекции — `precomputeSticky(chunks, r.memory)` селектил над полным банком. Сценарий: владелец подписывает mined-терм → на ре-прогоне терм входит в draft-msgs → `content_hash`/`request_hash` меняются → draft resume ПРОМАХ → **тихий re-bill всей mined-касающейся части черновика БЕЗ громкого --resnapshot** (base-снапшот не двигался). Нарушало инвариант 2 («переоплата ОДНА») + 6 (resume-$0) на прод-пути майнинга.
**ФИКС:** `r.baseMemory` — base-scoped банк (Source != mined), материализуется в `seedGlossary` (при отсутствии mined = ТОТ ЖЕ объект что `r.memory` — без двойной материализации, golden байт-стабилен). Черновик-волна селектит над `r.baseMemory` (инъекция байт-идентична через enrichment); редактор — над `r.memory` (enriched). **ПИН:** `TestWaveMinedSignDoesNotRebillDraft` — подпись mined-терма оставляет draft chunk_status {SnapshotID,ContentHash,FinalHash} байт-стабильным, редактор-снапшот двигается (единственная gated-переоплата). Верифицирован: падает без фикса («draft injection CHANGED»), проходит с фиксом.
- **NIT (CONFIRMED):** `QualityReport` не дропал ghost-строки против манифеста (ProcessedChunks мог превысить TotalChunks при усечении источника). **ФИКС:** inManifest-гард в обеих петлях (паритет export/status).
## Багфиксы вне плана
- Нет вне-плановых. Одна ратифицированная СЕМАНТИЧЕСКАЯ смена (не баг): **пер-волновой --resnapshot СУРГИЧЕН** (не all-or-nothing) — смена только draft-конфига двигает только `snapshot_draft`; редактура резюмится, если её wire/снапшот не тронуты (иначе каскадит через content-hash). `TestRunnerSnapshotPinning` обновлён 4→3 с объяснением. **Флаг оркестратору:** это улучшение (меньше лишней переоплаты), прямое следствие пер-волнового снапшота плана §1(в); в реале смена draft-промпта меняет черновик → каскад в редактуру (calls=4).
## Открытые вопросы / решения оркестратору
1. **escMu держится через LLM-вызов** fresh-хопа → эскалации сериализуются (не параллелятся). Осознанный трейд: корректность soft-cap > параллелизм редких эскалаций. Флаг.
2. **Ш-2 расширен** на chunker/estimator (сверх названных memnorm/classifier/style) — тот же silent-drift класс, но не буквальная директива. Подтвердить.
3. **pack.Version() фолд** — майнер offline (proposals inert до approve), фолд консервативен (pack-edit=resnapshot даже без mining-прогона); omitempty → книги-без-пака не задеты. Подтвердить.
4. **quality per-chunk KPI-строки** разрежены на мульти-чанк-единицах (не-лидеры 0 KPI) — наблюдаемость, агрегаты верны. Приемлемо?
5. **W1.5-UX/mined round-trip** (§10-1) — реализован seam (стоп→signature-file→owner curates `mined_delta`→resume); полный live-прогон stand-only ($0, нет contrast в git). Тестируется gate + auto-continue; stop-путь — юнит-логика.
6. **kill-9 N-воркер SIGKILL-тест** — store/kill9_test покрывает атомарность (model-уровень, worker-count-независимо) + `TestWaveParallelWorkersMoneyConserved` покрывает N-воркер сохранность. Отдельный wave-SIGKILL subprocess-тест НЕ добавлял (маргинальная ценность).
## Верификация (логи)
```
go build ./... → OK
go vet ./... → OK
go test -race ./... → ВСЁ ЗЕЛЁНОЕ: cmd/tmctl · config · lang · ledger · llm · pipeline (~31s -race) · store
gofmt -l <мои файлы> → пусто (пре-существующий gofmt-долг в не-моих файлах — llm.go/memory_*_test.go — не трогал)
golden fresh↔resume → байт-стабилен; masked-дифф 38 объяснимых wire/verdict + 200 version-only
```
Новые пины (`waverun_test.go`): `TestWaveMultiChunkEditUnit` (несущий: 2 чанка→1 единица, edit над concat, export/status per-unit, resume $0), `TestWaveParallelWorkersMoneyConserved` (workers=4, committed==run-total), `TestWaveEditUnitFlaggedMemberDraft`, `TestWaveMiningUnconfiguredAutoContinues`, `TestWaveEscalationBudgetSerializedUnderParallelism` (escMu), **`TestWaveMinedSignDoesNotRebillDraft` (переоплата-ОДНА на injection-уровне — регресс-пин ревью-фикса, падает без фикса)**. Обновлены: `TestRunnerSnapshotPinning` (4→3, per-wave surgical), `TestExportDetectsConfigDrift` (both-wave drift).
## Структурные переносы
- Новые: `waverun.go` (исполнитель), `mining.go` (стоп), `waverun_test.go` (пины). Расширены: `wave.go` (buildEditUnits/editUnit).
- `TranslateBook` тело → `translateBookWaves`; `translateChunk` (последовательный per-chunk-all-stages) УДАЛЁН (логика в `runStageSequence`/`runDraftChunk`/`runEditUnit`).
- `BookResult.Chunks` семантика: per-chunk → per-edit-unit (struct-форма цела).
- Именование: enum `waveW1/waveW2``waveDraft/waveEdit`, `runW15``runBankMiningStop`, локали/логи/комменты по всему пакету (директива владельца).

View file

@ -66,6 +66,7 @@
- **DeepSeek cache-поля**: в `usage` два варианта именования (бэкенд обрабатывает оба).
- **«Эхо» черновика (тихий отказ)**: deepseek-chat на плотной CJK-прозе **воспроизводимо** (3 из 3 ретраев на zh-фрагменте Лу Синя «祝福») возвращает **оригинал вместо перевода** (82% CJK в «переводе»). Не ошибка API — валидный ответ с неправильным содержимым; ретраи не помогают (детерминировано для фрагмента). Митигация в `eval/editor_bench.py`: детектор доли CJK (порог 15%) → фолбэк на другого провайдера (GLM перевёл тот же текст чисто). **Бэкенд/гейт: детектор CJK-артефактов в русском выходе обязателен** (смыкается с anti-omission coverage-гейтом Р7 и уже запланированным «детектором CJK-артефактов» — 7 из 18 моделей грешат); эскалация на другого провайдера, а не ретрай. NB: проверено на deepseek-chat; актуальную роль-модель `deepseek-v4-flash` перепроверить.
- **Таймауты**: книжные чанки на локали занимают **63278 с** (не 45 с как в чат-vojo). Нужен per-роль лег-таймаут ~300600 с + стриминг как keep-alive. Донорский транспорт vojo имел скрытый per-attempt потолок 60 с.
- **`mistral-large-latest` — агрессивный rate-limiter (тир-зависим, пере-замерить на прод-тире).** Судейский eval-риг exp15 §7.6 REV.2 (N-параллельные вызовы): **~48% retry-fails @1.3s интервала**, max latency **64.7s**; `grok-*` в том же риге — 0%. Расширение интервала 1.3→2.6s эффекта НЕ дало (49.5→48.2%) → лимитер похож на **токен-бакет/конкуренц-кап**, не per-request-пейсинг. Пер-вызовный 429/`Retry-After` в адаптере есть (`httpllm.go`, кап `maxRetryAfterWait`); гэп — N-параллельность волнового раннера → **пер-модельный семафор конкуренции конфигом `models.yaml` (план 11 WS1(б)); mistral-editor-арм не идёт через прод-путь до этого guard** (D39.12, гейтнутый арм №4). Правило двух направлений: цифры из eval-рига, НЕ офиц. доки — при прод-тир-замере запинить вендор-страницу rate-limits. *(Внесено оркестратором при лендинге D39.12; черновик — план 11 §12.)*
## Календарь деприкаций / цен (мониторить ежеквартально)

View file

@ -536,3 +536,148 @@ mean(A0do-nothing) **+0.041** верна, но **TOST ПРОВАЛЕН**: sd
**Интерпретация V2-брака (несущая):** армы eval-рига идут БЕЗ прод-гейтов — санитайзер v6 (fold-first) поймал бы 待遇/一步登天 как cjk_leak; брак V2 = (рига-контекст без гейтов) + вероятный механизм «source-carryover подаёт больше исходника в контекст редактора → выше вероятность CJK-протечки» (фиксируется как гипотеза к пере-прогону, где carryover не строится). V2-брак НЕ говорит о carryover как рычаге когезии.
**Следствия (ратификация D39.8):** (1) вывод D39.7 «сцен-детекцию/carryover-машинерию не строить» подтверждён и на читательском уровне; (2) битва за качество пере-прогона — в ДЕФЕКТ-КЛАССАХ, все банко/пак/гейт-уровня: 时辰-юниты (детерминированная конверсия в пак пары — НОВЫЙ класс), числа-масштабы (b1, свежая эмпирика), gender-enforce по полю сида (Бай Нинбин hidden→male-до-reveal) + диалог-род говорящего (Ф2-annotator), стих/аллюзии (пак-политика + сноски-на-аллюзии lever), регистр-лексикон (негатив-лист «терем»-класса в пак); (3) **инструмент-фиксы будущих слепых пакетов:** авто-QA до человека (CJK-в-ru/битые формы/род-консистентность), обрезка всех версий по общему финальному предложению, 2-way пакеты для тонких контрастов, тир-структура претензий (критические/смысловые/редакторские) вместо плоского «≤2».
## §7-Q4a — ИСПОЛНЕНИЕ Q4a «сильный переводчик» (отложенный арм, мини-сессия, 2026-07-18)
> **B-hybrid, санкция оркестратора/владельца 18.07.** Свой кап **$2, ВНЕ $15 exp15**. Скрипты `eval/exp15/q4a_*.py`
> (аддитивные; существующие риги НЕ редактировались), артефакты `/home/ubuntu/books/gu-zhenren/exp15/q4a/`, леджеры
> `q4a_*.jsonl` — ВСЁ ВНЕ git, лендит оркестратор. Pro-генерация — в DeepSeek-долине (все вызовы UTC≈1417, вне пиков 0104/0610).
> **⚠ РАУНД КОРРЕКЦИИ (2026-07-18, адверс-верификация результатов, author≠reviewer).** Первый DET-прогон нёс
> **ИНФЛИРОВАННЫЙ заголовок «pro омитит 6×»**, пойманный пост-хок адверс-верификатором (как HEAD_CHARS в §7.8): «6 омиссий
> pro» = **1 truncation** (pro_raw/7.0.s0 finish=length — reasoning съел max_tokens 8000, хвост с a1/a2/d2 потерян; НЕ
> «омиссия модели») **+ 2 локатор-ложных** (a1/d1 локаторы ловили лексикон flash, мимо валидных синонимов pro Сказание/незнакомо,
> будь…не) **+ 1 genuine** (b2 s0). Фиксы: max_tokens draft 8000→16000 + reject finish=length; truncated-ячейка перегенерена
> (finish=stop); a1/d1/d2-локаторы уточнены симметрично (d2 переанкорен на lament-структуру — generic 'если бы/а то' ловили ЧУЖИЕ
> контрфактивы чанка). Self-test 19/19. **Скорректированный итог — ниже; заголовок «pro омитит» ОТОЗВАН.** Ядро вывода (pro НЕ
> ратифицируется; редактор = слабое звено) НЕ изменилось и УСИЛИЛОСЬ.
### §7-Q4a.0 Итог одной строкой (СКОРР.)
**Верность НЕ покупается на стадии перевода.** DET-скорер смысл-трапов (floor-immune): **и flash, и pro черновики верны на
6/9 трапах** (a1/a2 двойн-отриц, b1/b2 числа, c1 ранг, d1 контрфактив — все 1.0). flash **25 fix / 0 fail / 27 (покрытие
26/27)**; **pro НЕ точнее и слегка ХУЖЕ — роняет контрфактив d2** (0.333 vs flash 1.0; 2 fail), **покрытие РАВНОЕ (26/27 = 26/27;
differential genuine омиссия pro ≈ 1 — b2 s0, НЕ 6)**, при **COGS ×3.67**. → **Pro-арм НЕ ратифицируется** (§D5-знаменатель ПУСТ: flash не валит ни одного трапа; pro
чинить нечего, а сам слегка хуже). Реальные смысл-катастрофы рождает **glm-редактор** (инвертирует 丙→разряд **Б**, 族长→**старейшина**;
**4 fail на flash_glm, 4 на pro_glm**; editor-delta flash 0.185, pro 0.093) — прямое подтверждение D38 «слабое звено = редактор,
не черновик». Ось — DET-истина + чтение (все ключевые вердикты сверены с реальными рендерами).
### §7-Q4a.1 Дизайн (B-hybrid; развилка §3-аддендума решена)
Концерн №1 «может ли ЧЕРНОВИК быть ~верным сразу». Развилка (A) S2-когезия-трапы (судьи, floor-уязвимо) vs (B) exp14
смысл-трапы (DET, floor-immune) → **владелец/оркестратор выбрали B-hybrid**: материал = 7 exp14-чанков с 9 DET-смысл-трапами
exp14b (a1/a2 двойн-отриц = катастроф-класс, b1/b2 числа, c1 ранг = кат, c2/c3 ранг/роль, d1/d2 контрфактив); **PRIMARY =
$0 DET-скорер** (иммунен к судейскому полу 0.126, что утопил все S2-блоки); **SECONDARY = судейский мини-пасс grok+mistral**
(калибровка судейского пола НА смысл-трапах + design-комплаенс). Интент пина «те же чанки» = **ПАРНОСТЬ** сравнения — сохранена.
Полный **квадрат 2×2** {flash, pro} × {сырой черновик, +glm-редактор}, **×3 seed**, 84 ячейки. draft=`deepseek-v4-pro`
(mistral исключён — судейская семья); суб-арм do-nothing = сам pro-черновик; редактор `glm-5`.
### §7-Q4a.2 Целостность пар — байт-верифицирована (несущее, пин оркестратора «парность превыше»)
flash и pro черновики собраны **байт-идентичными** промптом/инъекцией (exp14 `bilingual_glossary`→черновик,
`editor_constraint`→редактор; injected_ids-реконструкция БЕЗ sticky — несовершенство, ОБЩЕЕ обеим сторонам, НЕ «улучшалось»
mem_select-портом). Подтверждено: `flash_raw` и `pro_raw` каждого чанка — `prompt_tokens`=идентично (напр. 6.0: 1605/1605).
Единственная переменная строки черновика = модель; редактор (glm-5 discourse) идентичен обеим сторонам → editor-delta модель-чист.
Флэш-сторона **перегенерена** (не reuse on-disk backend-flash) ради ГАРАНТИРОВАННОЙ парности с pro (backend-flash = иная,
sticky-инъекция; оставлен как провенанс-кросс-чек). Провенанс: свежий flash_raw DET-сверен с backend-flash + arm C —
совпадает, кроме a1/c1 (backend — единичный неудачный семпл с инверсией; по 3 seed flash их держит) → sampling-вариация, не баг.
### §7-Q4a.3 Инструмент (DET) + честность пре-регистрации
Правила exp14b **переписаны как additive-оверрайды** в `q4a_traps.py` (риг exp14b НЕ редактировался): sentence-scoping
(raw≈editor сопоставимы), double-neg faithful=fix (a1/a2), **FAIL-ветка контрфактива** (d1/d2 — иначе класс DET-слеп к своему
провалу), b1 требует ОБА числа, b2 «сотых»+склонения, c1/a1 anchors сужены, stray 'мёртв'→fix снят. В раунде коррекции: a1/d1
локаторы расширены на pro-синонимы (Сказание/незнакомо, будь…не), **d2 переанкорен на lament-структуру** (generic 'если бы/а то'
ловили ЧУЖИЕ контрфактивы чанка → ложные вердикты). **Self-test 19/19.** Калибровка на exp14b-армах + probe- И финал-рендерах
(та же книга) — **симметрична к модели** (уточнения ловят синонимы ОБЕИХ сторон), **не к исходу** (d2-фикс ВЫЯВИЛ pro-хуже, не
подгонял под pro-лучше). '?' исключается из ставок, репортится отдельно (addendum §2). **Кавета: контрфактив-класс (d1/d2)
DET-СЛАБ** (пред-ревью F2 предсказал) — предложение-феномен тонет среди других контрфактивов; вердикты d1/d2 ниже-доверие, чем
a/b/c. Инструмент-здоровье: flash_raw 25fix/**0fail**/2'?'; **a1/a2/b1/b2/c1/d1 = 1.0 во всех 4 ячейках** → устойчивы.
### §7-Q4a.4 Результаты (СКОРР. после раунда коррекции)
**(i) 2×2 fix-rate (PAIRED, n=9 — трапы decided во ВСЕХ 4 ячейках; marginal совпал). Покрытие = доля из 27 (9×3), где
фраза-феномен ЛОКАЛИЗОВАНА (рендер):**
| | сырой черновик (fix-rate / покрытие) | +glm-редактор (fix-rate / покрытие) |
|---|---|---|
| **flash** | **1.0** / **0.963** (25fix 0fail; 1 miss = d1-локатор, НЕ омиссия) | 0.778 / 0.889 |
| **pro** | **0.889** / **0.963** (23fix **2fail=d2**; 1 genuine омиссия b2 s0) | 0.889 / 1.0 |
Per-trap: a1/a2/b1/b2/c1/d1 = **1.0 во ВСЕХ 4 ячейках** (оба черновика верны на двойн-отриц, числах, ранге, cicada-контрфактиве).
Единственные не-1.0: **c2** (flash_glm 0.0, pro_glm 0.5 — редактор), **c3** (flash_glm 0.333 — редактор), **d2** (pro_raw 0.333,
pro_glm 0.0 — pro роняет контрфактив).
**(ii) COVERAGE / ОМИССИЯ — заголовок «pro омитит» ОТОЗВАН (раунд коррекции):** покрытие **РАВНОЕ: flash_raw 26/27 = pro_raw
26/27**. Первичный «pro 6 омиссий» распался: **3 = 1 truncation** (pro_raw/7.0.s0 finish=length — перегенерена), **2 = локатор-
ложные** на ВАЛИДНЫХ pro-рендерах (a1 s1 «Сказание…не было незнакомо ни одному», d1 s2 «будь цикада не так слаба…» — локаторы
уточнены симметрично), **1 = genuine** (b2 s0 — pro finish=stop, но пропустил предложение 44%). Стохастическая полн-омиссия
существует (sanity-семпл 6.0.s0 выкинул опенинг), но по probe РЕДКА (~1/11) и **НЕ даёт систематического преимущества flash**.
**(iii) EDITOR-RESTORATION (СКОРР.):** «pro restored 5» инфлировано truncation-recovery; **genuine restored = 1** (b2 s0:
pro_raw без 44% → pro_glm вернул «сорока четырёх сотых высоты апертуры» — редактор видит исходник). flash: restored 0, **broke 2**
(c2 s0, d2 s2). Направление реально (билингв-редактор доб. потерянное), но масштаб = 1, не 5.
**(iv) EDITOR-DELTA (T-inv/D38 «редактор ломает верное черновика») — ПОДТВЕРЖДЁН (несущее, read-verified):** editor-delta
**flash 0.185** (breaks **c2, c3**), **pro 0.093** (breaks **c2, d2**). Редактор ввёл **4 fail на flash_glm + 4 на pro_glm** vs
**0 fail на сырых черновиках flash / 2 на pro**. Верифицировано чтением: flash_glm c2 = «Фан Юань — разряд **Б**» (черновик держал
«разряд В»=丙 верно → редактор ИНВЕРТИРОВАЛ грейд + спутал имена «Фан Юань…Фан Юань»); c3 = «Четвёртый **старейшина** рода»
(черновик: «глава рода» верно). Seed-non-unanimity (DET-пол): сырые черновики стабильны (flash 0.0, pro 0.111), **редактор
добавляет вариативность (0.111/0.125)**.
**(v) COGS-дельта (метрика iii, фриз-цены, долина):** flash-черновик **$0.02431** vs pro-черновик **$0.08926** = **×3.67**
(сходится с фриз-оценкой ×3.1). Pro-черновик дороже ×3.67 при НУЛЕВОМ приросте точности (слегка ХУЖЕ на d2), покрытие равное.
**(vi) Судейский мини-пасс (secondary, grok-4.3 reasoning-ON + mistral-large-latest, оба порядка, 6→10, LOO, per-trap seed
где ОБЕ стороны рендерят; 496 голосов, 0 parse-fail, 0 truncation):** ключевой мета-вывод — **судейский пол НА смысл-трапах =
mean|Δ| 0.261** (floor-контраст flash-s0 vs flash-s1, ЭКВИВАЛЕНТНЫЕ входы) — **БОЛЬШЕ, чем S2-пол когезии (0.126)**: судьи
ещё шумнее на этих трапах (a2 0.583, c2 0.792 — огромные Δ на DET-РАВНЫХ черновиках). *Это прямо ВАЛИДИРУЕТ выбор DET-primary:
судьи не смогли бы ответить на Q4a.* Отметка: floor тут = верхняя граница (судейский шум + seed-content-вариация, review F11).
- **main (pro vs flash): diff 0.001, mean|Δ| 0.268 ≈ пол** → преимущества pro НЕТ, и судья слишком шумен, чтобы что-либо
различить (a2 0.958, c2 +1.0 — дикий разброс на DET-равных ячейках, mean washes out). Сходится с DET (оба верны при рендере).
- **editor (pro_raw vs pro_glm): diff 0.102 ≈ DET editor-delta 0.093** → судья И DET СОГЛАСНЫ, что редактор слегка деградирует
pro; **слом d2 подтверждён ОБОИМИ** (судья Δ 0.917, LOO-устойчиво: без-grok 1.0 / без-mistral 0.833, оба судьи arm_acc≈0;
DET=fail). Единственный сигнал выше шума на судейской стороне — editor-слом, совпадает с DET.
### §7-Q4a.5 Дерево решений §D5 применённое (СКОРР.)
**§D5 [INSUFFICIENT-POWER / вакуумно]:** знаменатель «flash-черновик валит смысл-трап» = **0** (flash держит все 9 при рендере,
25fix/0fail) → «pro чинит ≥0.5 flash-провалов» неопределимо. **Вывод: пре-мисса «нужен сильный черновик для верности»
опровергнута** — flash-черновик уже верен на смысле; **pro НЕ точнее, а слегка ХУЖЕ (роняет d2-контрфактив), покрытие равное,
цена ×3.67.** → **арм «структура у переводчика» НЕ ратифицируется; подтверждается путь D38 «сильный редактор»** (свап
glm→mistral/deepseek-pro как редактор): реальные смысл-катастрофы — на **editor-стадии** (c2-грейд 丙→Б, c3-роль 族长→старейшина —
4 fail из чистого черновика; d2-контрфактив), там же и restoration-омиссий. **Каветы (честно):** (1) вакуумность §D5 ХРУПКА —
свежий 3-seed flash держит a1/c1, но САМ прод-бэкенд-черновик (records.json) ИХ ВАЛИТ (a1 инверсия «не знал ни один», c1 «среди
людей») → на шипнутом семпле знаменатель ≥2 и вопрос «чинит ли pro» был бы жив; not-ratify держится на **COGS+нет-прироста**, не
на «flash идеален». (2) **Контрфактив-класс (d1/d2) DET-СЛАБ** (пред-ревью F2 предсказал): локаторы уточнялись пост-хок под lament/
subjunctive-формы — d2-сигнал «pro хуже» вторичен (n мал, класс хрупок); заголовок держат a/b/c + editor-breaks + равное покрытие,
НЕ зависящие от d1/d2. (3) n=9 трапов (honest-power); материал = exp14 in-претрейн канон — ПРЕДВАРИТЕЛЬНО до вебновелл-среза.
Гейтящий эндпоинт остаётся — чтение владельца на большем/трудном корпусе.
### §7-Q4a.6 Деньги (свой кап $2)
Генерация (84 ячейки + 1 перегенер-ячейка + sanity + провенанс-диаг + omission-probe, ВСЁ персистировано, D30.10): **$0.602**.
Судейский мини-пасс (3 контраста × 9 трапов, 496 голосов): **$0.716**. **Итого $1.317 / $2** (маржа **$0.683**). Гейты:
per-call predicted-cost + свой Spender seed=gen-spend, hard-cap $2 (НЕ наследует exp15 seed $9.82/$14.5). Ошибки: генерация
**0/84**, судьи **0 parse-fail / 0 truncation**. Все pro-вызовы — в долине (UTC≈1417).
**A-мини (сокращённый S2-когезия судейский пасс) — ПРОПУЩЕН (решение по правилу оркестратора).** Формально остаток $0.683 ≥
порог $0.5, НО: (1) «ответ его класса известен» (S2-судейский null, §7.4); (2) потребовал бы НОВОЙ S2-pro-генерации (не просто
судейства); (3) Q4a-судейский пол на смысл-трапах (0.261) уже демонстрирует судейскую-шумовую проблему на ЭТОМ материале
СИЛЬНЕЕ, чем S2 (0.126) — A-мини добавил бы шум к чистому результату. «Пропустить без сожалений» (формулировка оркестратора).
### §7-Q4a.7 Самопроверка + ОТКЛОНЕНИЯ (явно)
**Ревью ИСПОЛНЕНИЕМ, 3 рубежа (мандат владельца) — сработал, поймал ИНФЛИРОВАННЫЙ заголовок ДО лендинга:**
(1) **pre-spend 4-линзовый адверс-ревью** (workflow, author≠reviewer) → **11 находок**, все закрыты ДО первого платного вызова:
§D5 без power-гейта → min-denom≥3 + completeness; контрфактив без fail-ветки; span-scoping; polarity_envy ложный fail; b1
or-логика; matrix не-парная → paired headline; budget_truncated контаминация → drop; judge gate-block/ретрай → задокументированы.
(2) **sanity-гейт** поймал стохастическую омиссию + editor-restoration + b2/a1 локатор-гэпы ДО полного прогона → 3 seed +
coverage-метрика. (3) **ПОСТ-ХОК адверс-верификация результатов** (independent agent, author≠reviewer) — **поймала, что заголовок
«pro омитит 6×» ЛОЖЕН**: 3/6 «омиссий» = 1 truncation (finish=length не отклонялся — БАГ рига; «0/84 ошибок» его пропустил),
2/6 = локатор-ложные на валидных pro-рендерах (a1/d1 локаторы под лексикон flash), + моя ручная «верификация чтением» ОШИБЛАСЬ
(спутал truncated s0 с валидным s1). **Фиксы раунда коррекции:** reject finish=length + max_tokens 16000, перегенер truncated-
ячейки, a1/d1/d2-локаторы уточнены симметрично (d2 переанкорен — generic-маркеры ловили чужие контрфактивы). Пере-скор →
покрытие РАВНОЕ, заголовок отозван, ядро вывода усилилось. **Это ровно тот случай (как HEAD_CHARS §7.8), ради которого 2-й
независимый рубеж существует.**
**Отклонения (санкционированы/задекларированы):** материал=exp14 не S2 (санкция оркестратора); редактор=exp14b discourse не
A0 editor.md (матч материала; editor-delta = реальный glm-5); DET-правила = additive-оверрайды (exp14b-риг не тронут);
флэш-сторона перегенерена (не reuse) ради парности; judge seed выбирается per-trap где обе рендерят (омиссия — отдельная
метрика). exp14b DET-правила a1/a2/b1/b2/c1/d1/d2 признаны дефектными ревью и НЕ использованы as-is (пинг оркестратору: если
exp14/exp14b пере-читаются — брать оверрайды из q4a_traps.py, не исходные rule_*).
### §7-Q4a.8 Артефакты
`q4a_det_results.json` (полн. per-trap × seed × cell), `q4a_omission_probe.json`, `q4a_judge_results_{floor,main,editor}.json`,
ледж `q4a_gen_costs.jsonl`/`q4a_judge_costs.jsonl`, ячейки `q4a/{flash,pro}_{raw,glm}/{chunk}.s{seed}.txt`. Скрипты
`eval/exp15/q4a_{traps,generate,score,judge,probe}.py`.

View file

@ -0,0 +1,316 @@
# Эксперимент 16. Банк-майнинг W1.5 (полигон-стадия research/20 §D)
> **Статус:** полигон-сессия exp16 (2026-07-18), исполняет пре-рег дизайн **research/20 §D** (дизайн-оф-рекорд,
> D39.6). Зона: `eval/exp16/` + этот файл. Артефакты — ВНЕ git (`/home/ubuntu/books/gu-zhenren/exp16/`).
> **Коммичу ТОЛЬКО пре-рег фриз** (единственный коммит, ДО первого платного вызова — D30.10/D37); результаты
> сдаются этим отчётом, лендит оркестратор. Бюджет платных армов: жёсткий кап **$10** (решение владельца 1718.07).
>
> Структура: **§0** пре-задача (ре-аудит exp14b, D39.9) · §1 пре-рег фриз · §2 GT/материал · §3 результаты по
> армам · §4 решающие правила + маршрутизация слепых зон · §5 деньги · §6 самопроверка + отклонения.
---
## §0. Пре-задача ($0, ДО фриза): ре-аудит exp14b исправленными правилами (D39.9)
**Мандат (промт §пре-задача):** Q4a (D39.9) вскрыл, что (i) правила скорера exp14b
(`rule_counterfactual`/`polarity_envy`/`b1`) дефектны, а исправленные оверрайды лежат в
[q4a_traps.py](../../eval/exp15/q4a_traps.py); (ii) харнес [exp14_common.call](../../eval/exp14/exp14_common.py#L104-L113)
НЕ отклоняет `finish=length` — усечённый выход возвращается как валидный (`err=None`). Задача: (1) пере-скорить
СОХРАНЁННЫЕ exp14b-выходы исправленными правилами; (2) свип `finish_reason=length` по exp14/14b-леджерам;
(3) отчёт-дельта: какие пер-класс вердикты D38 сдвинулись. **Флип любого несущего → СТОП + пинг оркестратору
(errata — его зона).**
Код (аддитивный, риг exp14/14b НЕ тронут — импорт read-only): [reaudit_14b.py](../../eval/exp16/reaudit_14b.py),
[length_sweep.py](../../eval/exp16/length_sweep.py). Length-гейт для СВОИХ будущих генераций чинится у себя
(exp16-харнес), риг exp14 не трогаю.
### §0.1. Результат ре-скоринга (задача 1) — D38 DET-ядро ДЕРЖИТСЯ
D38 (`14b-meaning-battery.md` §2.1) скорил DET **ручной span-читкой** (авто-скорер был само-отвергнут как
бракованный). Исправленный `q4a_traps` — это тот самый скорер, но починенный (предложение-скоуп, исправленные
a1/a2/b1/b2/c1/d1/d2; звук b2, c2=`rule_grade_bing`, c3=`rule_patriarch` реюзаны verbatim). Self-test
корректных правил: 19/19 pass. Ре-скор всех сохранённых выходов арма C/F/X/D/M (+ K где есть, + P0-референс):
| Трап (класс) | glm-5 C | gpt-5.4 F | grok X | deepseek-pro D | mistral M | сходится с D38? |
|---|---|---|---|---|---|---|
| **a1** двойн.отриц (крит) | fail | **fix** (лок.gap→разрешён) | fail | fix | fix | ✅ все |
| **a2** 无不 | fix | fix | fix | fix | fix | ✅ все |
| **b2** 四成四=44% | fix | fix | fix | fix | fix | ✅ все |
| **c1** 人上之人 (крит) | fix | **fix** (лок.gap→разрешён) | fail | fail | fix* | ✅ (M: `?`→fix, апгрейд) |
| **c3** 族长 (глосс-конфаунд) | fail | fix | fix | fix | fail | ✅ все |
**Каждая ячейка, которую исправленный скорер СМОГ локализовать, совпадает с ручным вердиктом D38 дословно** —
включая паттерн глосс-конфаунда c3 (C/M=fail «старейшина», F/X/D=fix «глава») и c1 M=`?`. Единственные два
расхождения — **a1 F** и **c1 F**: D38=fix → исправленный скорер=`?` (пустой span). Это НЕ семантический флип,
а **пробел покрытия локатора** q4a на перефразе gpt-5.4 (окно story→knowledge 90 симв. перелетело; `над людьми`
разорвано словом `обычными`). `?` = «не локализовано / не классифицируемо — ИСКЛЮЧЕНО из ставок, НИКОГДА не
тихий pass». Разрешение span-читкой (мануально + независимо, §0.3): gpt-5.4 верно рендерит оба
(«…не было ни одного человека, который бы его не знал» = верная двойная негация; «…подняться над обычными
людьми» = верное 人上之人). **D38 «fix» стоит.**
Исправленные fix-rate по армам (9 DET-инстансов, `?` исключён из знаменателя): C 7/9 · F 5/6 · X 7/9 ·
D 7/9 · M 5/7 · P0 4/5. (b1/c2/d1/d2 не были в ручной D38-таблице §2.1 — свежие данные, не «сдвиги».)
**Несущие выводы D38 под исправленными правилами — БЕЗ изменений:**
- «Только gpt-5.4 чинит смысл» **опровергнуто**: a1 чинят F, **D и M**; валят glm(C) и grok(X). Провалы
РАЗБРОСАНЫ по модель×конструкция (a1: C+X; c1: X+D; c3-глосс: C+M). Нет модели безупречной/безнадёжной.
- c3 = **глоссарий-енфорсмент**, не способность (C/M подтянули approved-сосед `家老→старейшина` к draft-целевому
`四代族长`) — подтверждено внутренней консистентностью (ни один арм не мешает глава/старейшина).
### §0.2. Свип finish_reason=length (задача 2) — ни одна несущая DET-ячейка не заражена
8 леджеров, 477 записей: `stop` 413 · `length` **32** · `(none)` 32. Разбор 32 `length`-записей:
| Источник | n | Приняты как валидные? | Затрагивает несущую DET-ячейку? |
|---|---|---|---|
| gemini-3.1-pro (G-base/G-disc, exp14) | 11 | да (баг-гейт) | **нет** — gemini ДРОПНУТ как арм (exp14b §1.1); не в C/F/X/D/M |
| kimi-k2.6 (K-арм + судьи, exp14/14b) | 19 | 2 приняты (10.1/11.1), 17 отвергнуты (empty) | **нет** — K-арм ДРОПНУТ (D38); 10.1/11.1 не несущие |
| deepseek-v4-pro **D 2.1** (exp14b) | 1 | **да** (comp_tok=8000, оборван на «…Ступа[й]») | **нет** — 2.1 = e2 (SOFT/судейский), не DET; **НОВАЯ находка** |
| gpt-5-mini (regate-судья) | 1 | нет (отвергнут empty) | нет — судья, не редактор |
Прямая проверка длин 7 несущих DET-чанков (7.0/17.0/16.0/6.0/9.1/10.1/19.0) по C/F/X/D/M: все консистентны и
завершены (ни один аномально короткий, ни один в свипе). **Несущие DET-вердикты сидят на полных выходах.**
D 6.0 (упомянут в D38 как оборванный→перегнан на 16k) — сохранённый файл полный (10054 симв, `stop`); в свипе
как `length` не значится (финальная перезапись). **Новая находка:** D 2.1 (e2, SOFT) — принятая усечёнка;
судьи e2 скорили оборванный выход D. Влияния на несущее нет (e2-вердикт D38 = «большинство correct,
чэнъюй-сплющивание универсально» — усечёнка не рождает ложную катастрофу), но фиксирую как риг-гигиену.
### §0.3. Независимая верификация несущих (author≠reviewer) + дельта-вердикт (задача 3)
Промт требует: «несущие a1-вердикты span-верифицированы независимо». Исполнено воркфлоу из 4 независимых
refute-by-default ридеров (a1 ×2, c1, c3), читавших СЫРЫЕ выходы армов, вердикт fix/fail/omit + цитата-span.
**Все 4 подтвердили каждую несущую ячейку**, включая двойное подтверждение a1 F=fix и c1 F=fix (моё
разрешение локатор-gap'ов независимо подтверждено). a1: C=fail×2, F=fix×2, X=fail×2, D=fix×2, M=fix×2. c1:
C/F/M=fix, X/D=fail. c3: C/M=fail, F/X/D=fix.
**ДЕЛЬТА-ВЕРДИКТ:** **0 семантических флипов несущих вердиктов D38.** DET-ядро D38 держится под исправленными
правилами, тройно подтверждено (D38-ручной × исправленный авто-скорер × независимый refute-by-default ридер).
Два расхождения (a1 F, c1 F) — пробелы покрытия локатора, разрешены в `fix`; одно улучшение (M c1 `?`→fix).
**СТОП-гейт НЕ сработал; пинг оркестратору по флипу НЕ требуется** (условие пинга — флип, его нет).
**Риг-находки в реестр (не блокеры, для протокола):** (а) исправленный `q4a_traps`-локатор имеет пробелы
покрытия на не-S2 материале → выдаёт `?` (безопасно: не тихий pass) — подтверждает правоту D38 скорить DET
ручной span-читкой; (б) харнес exp14 принял усечёнку D 2.1 (e2 SOFT) — единственная новая жертва length-бага
вне дропнутых армов; (в) для СВОИХ генераций exp16 length-гейт реализуется в exp16-харнесе (отклонять
`finish=length` как reject/flag) — §1.
Артефакты: `exp16/reaudit_14b.json`, `exp16/length_sweep.json`, `exp16/pretask_independent_verify.json`.
**⟶ Гейт пройден: ядро держится. Перехожу к пре-рег фризу (§1).**
---
## §1. Пре-рег фриз (miner-v1) — коммит ДО первого платного вызова (D30.10/D37)
Замораживается дословно из research/20 §D + конкретизации. Детектор-код детерминирован (сортировки,
версия `miner-v1`, версионированные словари) — повторный прогон байт-в-байт (§D4-е).
### 1.1. Материал и Ground Truth
- **GT = сид v2 пост-reseed**, `guzhenren-seed-v2.yaml` **sha256 `175a67ad2f05…c408f4`** (совпадает с
реф-хешем промта; 人祖=male, 古月=n-a подписаны — exp15-преп). **GT = 57 термов + 2 алиас-поверхности.**
- **Фильтр вхождения (§D1):** каждый GT-терм имеет ≥1 вхождение src/алиаса в source (Aho-Corasick-эквивалент
БЕЗ suppressContained, D24.2). Проверено исполнением: 57/57 проходят **с** аннотацией, 54/57 **без**.
- **BLURB-ПРАВИЛО (решение полигона, пре-рег):** аннотация гл.1/чанк0 СЧИТАЕТСЯ вхождением ⇒ **GT=57**.
3 blurb-терма (血颅蛊 since193, 一气金光虫/青丝蛊 since47) встречаются по 1 разу только в аннотации ⇒
страта **f<3**. (Альтернатива «аннотация ≠ вхождение» дала бы GT=54; репортим f<3-recall в обоих
разрезах для этих 3 термов.) **Частотные страты GT (incl. аннотация): f≥10 — 40 · f3-9 — 7 · f<3 10.**
- **(source, draft) пары:** `rerun/records.json` **sha256 `3de49eaf…928d`** — 57 чанков (гл.125),
черновики deepseek-v4-flash. ⚠ **Конфаунд инъекции (§D1):** черновики генерились С глоссарий-инъекцией
сида (budget 800, `pipeline-rerun.yaml`) ⇒ спред на GT подавлен ⇒ метрики спреда/канона на них =
НИЖНЯЯ граница; несущие выводы — по cold-start срезу (§1.6).
### 1.2. Армы (A1A3+абл)
| Арм | Детектор | Определение |
|---|---|---|
| **A1** | V-A | Han-n-граммы 16 × частотный floor 3 × c-value (nested-дисконт, g(L)=log₂(L+1)) × weirdness (контраст с общим zh) |
| **A2** | V-B | A1 + translation-spread (Dice/LTCR по ru-черновикам, pymorphy3-леммы) + dst-варианты |
| **A3** | V-C | A2 + паттерны: 百家姓-якорь, титул/топо-суффиксы, rank/grade-композиция, продуктивная морфология (авто-формант по over-rep), Палладий ru-канал |
| **A3-абл** | V-C, λ=0 | V-C без spread-сигнала — изолирует вклад каждого сигнала (§D2) |
### 1.3. Замороженные пороги (miner-v1) — выбраны на тюнинг-половине ch115, ±50%-стабильны
`freq_floor=3` (§A1 C-value канон) · `subsume_alpha=0.80` (nested-консолидация ранжирования; 0.5
слишком агрессивно роняет GT) · `ngram_max=6` · `formant_min_over_rep=15.0` · `formant_min_partners=3`
· `lam=0.0` на инъектированных черновиках (spread вредит ранжированию — конфаунд §D1; на cold-start λ
свипается) · pattern-бонусы `{name/place 140, title 120, term 80}` × source-weight. **Свип ±50% (§1
tuning_sensitivity.json): A3@f≥3=1.0 инвариантно по ВСЕМ настройкам; catastrophe PASS везде.**
### 1.4. Метрики (все детерминированные, $0, кроме адъюдикации)
1. **recall@PROPOSED** (членство в кандидат-сете = ось «где арм слеп», §D3) — первичная recall-метрика,
по типам (name/title/place/term/nickname) и стратам (f≥10/3-9/<3).
2. **recall@top-K + pseudo-precision@K** (ось ранжирования/точности) + trade-off кривая.
3. **precision@30 адъюдицированная** (топ-30 не-сидовых A3): владелец по карте ИЛИ кросс-семейный судья
закалённого паттерна `judges.py` (per-vote, оба порядка, полно-evidence) — precision@сид НЕ метрика (§D1).
4. **canon-recovery** (§C2): покрытие подписанных лемм предложением канона; несущее — на cold-start.
5. **алиас-precision** рёбер R1R3 (entity-B³, не MUC) + мини-голд владельца для recall (§E-1 тачпойнт).
6. **стабильность порогов** ±50%. 7. **A6b** accuracy верификации/линковки.
### 1.5. Порог-дисциплина 60/40 (анти-подгонка, §D1)
Тюнинг = ch115, тест = ch1625. Пороги выбраны на тюнинге, заморожены этим коммитом; **первичный
репортируемый результат — тест-половина**; full-срез — вторичка (catastrophe/trade-off/дамп).
### 1.6. Cold-start срез (пре-рег фиксирует главы)
**Главы [1, 4, 5, 7, 9]** (жадное GT-покрытие: **52/57** сущностей, **14 чанков** — 6 в гл.1). Свежие
flash-черновики БЕЗ глоссарий-инъекции, 2 конфига: `plain` (=срез, spread+canon-recovery) и `banknote`
(§B3-инструкция, канал сноски A4). Промт translator — `backend/prompts/translator.md` **sha256
`35d3ad6e…`**, brief из `rerun/book.yaml`, БЕЗ инъекц-блока (порт `coldstart_gen.py`). ⚠ **DeepSeek-долина
обязательна** (пики UTC 0104/0610 ×2, D39.7 — `coldstart_gen.in_deepseek_peak()` отказывает в пик).
### 1.7. Катастроф-скрин (§D4-в)
方源 · 蛊 · 蛊师 · 古月 обязаны быть в **топ-50 победителя**; промах = провал арма. **Пройден на full
(ranks 0/1/2/13/…)** — g(L)=log₂(L+1) держит одночарный 蛊 (nested в 蛊师/蛊虫/月光蛊) на ранге 1.
### 1.8. Карта независимости сигналов (§D4-а — против manufactured convergence)
Сигналы репортятся РАЗДЕЛЬНО: A1(частота×контраст), spread(A2A1 / A3A3абл), паттерны(A3A1). Сведение
«N сигналов сходятся» — только с leave-one-out. Spread на инъектированных = ~0/негатив (изолирован),
измеряется экологически лишь на cold-start.
### 1.9. Бюджет и порядок (§D5)
Платный кап **$10** (владелец 1718.07), per-call gate + hard-cap (`exp15_llm.Spender`). Порядок:
A1A3+абл ($0) → алиас ($0) → A6/A6b (стенд $0) → cold-start (копейки) → A4/A5 (платные, последними,
отменяемы). **Length-гейт (D39.9):** ВСЕ свои генерации отклоняют `finish=length` (не наследуем баг exp14).
### 1.10. Хеши (версионирование детектора)
- Код `miner-v1` (SHA256): exp16_common `92bc737a` · detectors `89059e53` · patterns `7ac8240c` ·
spread `5422415f` · palladius `a071295e` · arms `a5ed2ae2` · run_arms `c4e3e17d` · banknote `069ad8fe` ·
coldstart_gen `70c8d9c1` · alias `354172fa` · canon `e947ebf3` · reaudit_14b `8d95ad58` · length_sweep `59a60b15`.
- Словарь-артефакт (контраст): `data/jieba_dict_general_zh.txt` **sha256 `7197c321…e12a8`** (jieba 0.42.1
`dict.txt`, общий zh word-freq; вне git — воспроизводим из jieba 0.42.1, SHA пиннится здесь).
- Сид `175a67ad…` · records `3de49eaf…` · translator.md `35d3ad6e…` · бэкенд HEAD `b9e62a7`.
### 1.11. Решения владельца, вшитые (1718.07, НЕ пересматривать)
Жанр-паки НЕ строятся (V-C только универсальные каналы; авто-формант, не хардкод 蛊). Майнер НИКОГДА
не пишет `approved` (auto/draft + карта подписи); канон = по ВСЕМ вхождениям × Палладий-конформность ×
полнота леммы. Пол в сиде подписан (人祖=male, 古月=n-a); счётчики 他/她 = evidence, вердикт = владелец.
ja→ru реплика — след-за-§D (отдельный мини-фриз).
**⟶ Фриз коммитится. Результаты (§3+) — ПОСЛЕ, лендит оркестратор.**
---
## §2. GT и материал
См. §1.1 (GT=57 + blurb-правило, страты f≥10:40/f3-9:7/f<3:10, records.json 57 чанков, конфаунд инъекции).
Артефакты прогонов — `books/gu-zhenren/exp16/*.json` (вне git).
## §3. Результаты по армам
### 3.1. A1A3+абл — recall@PROPOSED (ось слепоты), ТЕСТ-половина ch16-25 (первичка, анти-подгонка)
| Арм | overall | f≥10 | f3-9 | f<3 | catastrophe |
|---|---|---|---|---|---|
| **A1 V-A** | 0.818 | **1.00** | 0.917 | **0.00** (частотно слеп) | PASS |
| **A2 V-B** | 0.818 | 1.00 | 0.917 | 0.00 | PASS |
| **A3 V-C** | **0.932** | 1.00 | 0.917 | **0.714** | PASS |
| A3-абл | 0.932 | 1.00 | 0.917 | 0.714 | PASS |
По типам (A3 тест): name 1.0 · title 1.0 · place 1.0 · term 0.83 · nickname 1.0. Промахи A3@proposed
(3): 开窍(f<3) · 月兰花(f3-9) · 困境(f<3). **V-C recall на f≥3 ≈ 0.97 на held-out** §D5 планка 85%
пройдена. Full-срез: V-A f≥3=0.98, V-C f≥3=1.0, overall 0.965 (промахи 一气金光虫[ядро 金光虫 предложено]
· 长生). Trade-off (full A3): top-90 recall 0.53/pseudo-prec 0.30 · top-250 0.64/0.13 · @proposed 0.965/0.004
(pseudo-prec низка by design — сид не исчерпывающий, §D1; истинная точность — §4 precision@30).
**Вклад сигналов (§D4-а, раздельно):** ПАТТЕРНЫ (A3A1 @proposed): f<3 0.00.71, place 0.41.0, name1.0
закрывают частотно-слепые классы. SPREAD (A2A1): 0 новых кандидатов; на инъектированных λ>0 монотонно
ВРЕДИТ ранжированию (top90 0.625→0.475=8) — конфаунд §D1, изолирован. Сенситивность ±50% (§1.3):
A3@f≥3=1.0 инвариантно по floor/over_rep/subsume/partners; catastrophe PASS везде.
### 3.2. Алиас-tier1 (§B2)
Известные алиасы сида (古月方源=方源, 古月方正=方正): **recall 2/2**. Прецизионно-безопасные R1R3 при R4:
R4-iii блокирует 族长=四代族长 (разный approved dst); компози-гард блокирует цепочку 古月+族长 (сохраняя
fullname 古月+方源); кап-гейт имён-лемм убирает Палладий-FP «найти»=най+ти. Остаточный tier-1-шум (方源看,
古月赤 — фрагменты) — известная ошибка §B2 (мерж только по подписи). Recall кластеризации — только по
мини-голду владельца (§E-1, сайдкар `alias_minigold.md`).
### 3.3. Канон (§C2) — ВЕДУЩАЯ находка: dst-извлечение = слабое звено
Канон-recovery (совпадение с подписанным dst, chunk-co-occurrence):
- full-инъект 57ч **0.68** · инъект-14ч **0.35** · **cold-start-14ч 0.20**.
**§D5-гейт canon-recovery≥70% НЕ пройден.** НО причина — не модель, а **метод извлечения dst**: chunk-уровневая
ко-оккуренция (Dice) вырождается для (а) вездесущих термов (蛊/方源 в ~каждом чанке → топ-ассоциат = частое
контекстное слово, не рендеринг) и (б) локализованных (все леммы чанка тождественны по co-occ). Проверено:
lift/PMI-коррекция базовой ставки (`canon_lift.py`) — ЕЩЁ ХУЖЕ (0.29): проблема в ГРАНУЛЯРНОСТИ (chunk), не
в базовой ставке; нужна предложение-уровневая привязка (§B1-митигация, не реализована) ИЛИ прямой dst-канал.
**Имена извлекаются (Палладий+кап-лемма: 方源→фан/юань, 古月→гуюэ); термы — нет.** ⟶ вывод §4.
### 3.4. A6 / A6b — локальная 9B на стенде (huihui_ai/qwen3.5-abliterated:9b, $0)
- **A6 спот по вебновелл-source:** recall **0.895** (f≥10 0.93 / f3-9 1.0 / f<3 0.70), health 56/57,
**3 галлюцинации**. **Оговорка exp06 ПОДТВЕРЖДЕНА:** спот падает 0.98(PD-классика)→0.895(вебновелл).
**Код-детектор V-C (@proposed 0.965) ≥ локальная 9B (0.895)**, детерминирован, 0 галлюцинаций.
- **A6b Z1-верификатор** (30 пар, авто-голд): accuracy 0.60, **wrong-catch 0.333**, correct-accept 0.867.
Локальная 9B **принимает правдоподобно-неверное** (族长→«старейшина», 人上之人→«среди людей» = correct!);
ловит лишь грубое (元石→«метеорит»). **⟶ Z1 маршрутизируется в ОБЛАКО, не локаль** (§B4 подтверждён;
согласуется с exp06 render 0.480.55).
### 3.5. Cold-start срез (платн., конфаунд снят) + A4/A5 канал сноски
- **Spread инъект→cold-start: 0.013→0.031** (конфаунд §D1 реален, но скромен на 14ч). Ядровые имена
консистентны без инъекции (方源/古月 spread~0 — частотно-заякорены); термы/титулы дрейфуют (花酒行者
0→0.25, 蛊虫 0→0.2, 丙等 0→0.2, 族长 0→0.143). Canon-recovery cold-start 0.20 (см. 3.3 — извлечение).
- **A4/A5 banknote-v1:** 95 строк / 14 чанков, distinct_src 65, **parse_fail 0.0%, truncated 0**.
Recall банкноты: all 37/57, f≥10 30/40, f3-9 2/7, f<3 5/10. **Маржинальный recall над V-C на f<3: +1
терм (一气金光虫, ~10 п.п.)** — V-C уже держит остальные (код > банкнота по recall f<3: 8/10 vs 5/10).
**Отличительная ценность банкноты — ПРЯМОЙ dst:** переводчик сам эмитит 蛊→гу, 血颅蛊→«Гу кровавого
черепа», 一气金光虫→«Золотоносный червь…», 希望蛊→«Гу надежды», 方源→«Фан Юань» — ровно те dst, что
co-occurrence извлечь НЕ смог (3.3), + нашёл НЕ-сидовую 龙公→«Лун Гун». Дельта качества (длина
banknote/plain): mean **0.935**, std 0.19 — лёгкое сокращение в пределах детерм-шума; смысл-судья на
таком маржинальном эффекте — под измеренным шум-полом 0.261 (addendum §1), уверенного вывода о
деградации НЕ делаю.
## §4. Решающие правила (§D5) применены + маршрутизация слепых зон
**Гипотеза №1 («код сам разметит банк») — РАСЩЕПЛЕНА на две:**
- **WHICH (какие сущности) — ПОДТВЕРЖДЕНА:** V-C recall f≥3 ≈0.97 (held-out ≥85% ✓), overall @proposed
0.965, катастроф-скрин PASS, кластеризация R1R3 (known-alias 2/2). Код детерминированно и за $0
размечает, ЧТО есть сущность банка.
- **WHAT (какой dst/канон) — НЕ подтверждена для co-occurrence:** canon-recovery 0.200.68 < 70%; dst-
извлечение из черновиков ко-оккуренцией — слабое звено (вырождение по гранулярности). Имена берутся
Палладием; термы — нет. ⟶ dst маршрутизируется в **банкноту (переводчик эмитит) / облако+Палладий**, НЕ
в чистую ко-оккуренцию. Это прямой вход бэкенд-дизайна слоя 4.
**Канал сноски (§D5-правило):** parse_fail 0%<5% ; маржинальный recall f<3 ~+10 п.п. (на пороге); дельта
качества — лёгкая, под шум-полом. **Вердикт: банкнота ЖИЗНЕСПОСОБНА и решает dst-пробел** (её ниша — не
recall редкого, где код сильнее, а ПРЯМАЯ доставка dst для вездесущих+редких термов); цена копеечная ($0.023
/ 14 чанков → ~$0.010.1/ранобэ, класс §B3 подтверждён). Рекомендация — не «сноска в архив», а «сноска =
dst-канал поверх кода-детектора».
**Слепые зоны лучшего арма (V-C) — ручная Z-классификация промахов (§D3-5, главный вход бэкенд-дизайна):**
| GT-промах | страта | зона §B4 | почему код слеп | маршрут |
|---|---|---|---|---|
| 长生 (бессмертие) | f<3 | Z-common-word | частый общеслов, контраст давит; не паттерн | облако/сноска |
| 困境 (Беды=имя стаи) | f<3 | Z1 stable-wrong | обычное слово 困境 как ИМЯ; termhood давится | облако-судья + KWIC |
| 开窍 (обряд) | f<3 | Z3 rare | редкий; 窍-формант дал 空窍, не 开窍 (границы) | сноска/локаль-спот |
| 月兰花 | f3-9 | Z3 rare | пороговый; 花-не-формант | код (порог) / сноска |
| 一气金光虫 | f<3 | Z3 rare | 5-char, паттерн дал ядро 金光虫 | **банкнота (получено!)** |
## §5. Деньги
Платный спенд ИТОГО: **$0.0226 / кап $10** (28 flash-вызовов cold-start+banknote, все finish=stop, 0
length-reject, 0 err, 0 parse_fail). $0-армы: код+стенд, $0. DeepSeek-долина соблюдена (UTC ~2122, окно
скидки). Проекция сноски на ранобэ: ~$0.010.1 (§B3).
## §6. Самопроверка (мандат 12.07) + отклонения
**Инкрементальный ревью исполнением — поймал 4 бага ДО результатов:** (1) shadowing exp15/arms.py поверх
exp16 (sys.path insert→append); (2) дефектный R4-эксцепшн алиаса (сливал 族长=四代族长); (3) Палладий-FP
«найти»=най+ти (→ кап-лемма-гейт); (4) конфаунд spread λ=8 (изолирован).
**Консолидированный адверсариальный само-ревью (6 осей, refute-by-default, независимый рекомпьют, воркфлоу):**
ВСЕ 6 держатся (4 holds / 2 holds_with_caveats), **0 critical**. Несущее:
- **MAJOR ПОЙМАН И ПОЧИНЕН ДО СПЕНДА:** truncation-гейт был `finish=='length'`-only → усечёнка с иным
finish_reason (DeepSeek `insufficient_system_resource` peak-overload) прошла бы. Фикс: принимать ТОЛЬКО
`finish=='stop'`. Прогон подтвердил: 28/28 stop, 0 reject.
- Катастроф-скрин ГЕНУИНЕН (независимая реимплементация, 0 расхождений top-50); g(L)=log₂(L+1) несущий
(log₂(L)→蛊 rank 4753, скрин FAIL). A1/A2 кандидат-сеты байт-идентичны; subsume ДЕФЛЯЦИРУЕТ recall (не
льстит). Фриз-целостность: инлайн translator-промпт == translator.md brief-filled (SHA рантайм-сверен).
- **Каветы в реестр (не блокеры):** gt_occurrences сумма по поверхностям — латентный двойной счёт (только
方源/方正, оба f≥10, ноль влияния на страты); docstring c-value «heavy»→«light» дисконт; surname-канал
over-generates типо-неверные (丁等 как name); canon-lift — негативный результат (документирован); банкнота
truncation-tolerance недостижима (гейт stop-only). **Провайдер-аномалия к владельцу:** пик-окно D39.7
(UTC 01-04/06-10) НЕ совпадает с документированным DeepSeek off-peak-скидочным окном 16:3000:30 UTC —
правило двух направлений (00-provider-quirks): сверить с вендор-докой ДО след. платных прогонов, не
абсорбировать интерпретацию. (Прогон шёл в 16:3000:30 — дешёвом окне по обеим трактовкам.)
**Отклонения от пре-рега:** нет по армам/метрикам/гардам. Blurb-правило — как объявлено (GT=57).
Length-гейт усилён (stop-only) — это errata frozen `coldstart_gen.py` (SHA 70c8d9c1 устарел), поймана
мандатным само-ревью ДО спенда; лендит оркестратор.
## §7. Владельцу (сайдкары + тучпойнты)
- `signature_map.md` — карта подписи (канон-предложение [банкнота-dst где есть], dst-варианты, KWIC, пол
他/她-evidence, зоны §B4). **Пол = ваш вердикт** (人祖=male/古月=n-a подписаны; 白凝冰 hidden — 他=… в тексте).
- `alias_minigold.md` — мини-голд алиасов (~2030 мин: вычеркнуть/дополнить рёбра → recall кластеризации).
- `precision_at30.md` — топ-30 не-сидовых A3 для адъюдикации (T/F/? → precision@30).
- Открытый вопрос §E-4/§B5: жанр-паки НЕ строились (универсальные каналы); реплика ja→ru — отдельный мини-фриз.

View file

@ -2,7 +2,7 @@
Зона сессии «Полигон»: эмпирическая валидация допущений архитектуры (мерить, а не верить). Отчёты — `docs/experiments/NN-*.md` (методика + цифры + честные ограничения). `backend/` только читать; расхождения с architecture/research — пингом оркестратору в `docs/PROGRESS.md`. **`.env` не читать; `data/refusal_corpus/*` не открывать без прямой задачи владельца.**
> **Очередь полигона (пост-D39.6, 17.07):** exp15 сегментация-эмпирика — промт `docs/POLYGON_SEGMENTATION_EMPIRICS_SESSION_PROMPT.md` ВЫДАН, ждёт запуска (фриз = первый коммит; скрипты — семьёй `eval/exp15/` по конвенции D38.2) → полигон-стадия банк-майнинга (research/20 §D, отдельный промт ПОСЛЕ exp15). Residual-трекер пилота/18+/echo — `docs/POLYGON_PACKAGE4_SESSION_PROMPT.md` (отложен).
> **Очередь полигона (пост-D39.16, 19.07):** исследовательская программа ЗАВЕРШЕНА — exp15 (сегментация/когезия, D39.79, `eval/exp15/`), Q4a (слабое звено=редактор), exp16 (банк-майнинг W1.5, D39.10, `eval/exp16/`) залендены. Следующая полигон-задача — **пере-прогон 310 глав через прод-путь** (армы редактора glm/mistral/deepseek-pro, планка 2 претензии) ПОСЛЕ бэкенд-стройки (R1 драйвер-свитч → resnapshot); риг-стандарт D39.7 (per-vote, полно-evidence, floor-гейт) обязателен; генерации — только finish=stop; ⚠ ре-чек прайса DeepSeek 24.07. Тачпойнты владельца — сайдкары `books/gu-zhenren/exp16/`. Residual-трекер пилота/18+/echo — `docs/POLYGON_PACKAGE4_SESSION_PROMPT.md` (отложен). Экстракция — ТОЛЬКО `tmctl export` (инвариант №8).
## Карта

View file

@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""WS1 (г) + WS6 (г) verification — deterministic wave-scheduler model + money-invariant
executable test-spec + COGS/wall-clock over the real 25-chapter structure with price windows.
Three parts:
(1) WAVE MONEY INVARIANT (executable test-spec, mock ledger mirroring SQLite single-writer):
N concurrent workers reserve->settle against a book/day ceiling. Asserts committed==SUM(costs),
no ceiling overshoot beyond one max reservation, kill-9 leaves a recoverable reservation
(<=1 call lost), retry isolation (per-chunk input independent within a wave).
The REAL durability is Go+SQLite (store.go single-writer, ledger.go reserve/settle); this
models the ALGORITHM, the (д) after-build spec pins it with a Go -race + kill-9 test.
(2) WALL-CLOCK: sequential vs wave-parallel (N workers) on 56 draft + 25 edit units.
(3) COGS per editor-arm (glm-5 / mistral / deepseek-pro), with warm-then-fan prefix cache
(-80% cached editor input) and DeepSeek peak-surge x2 sensitivity, via fertility est_out.
$0: deterministic (fixed mock latencies/costs, no random/time in the invariant model).
Writes /home/ubuntu/books/gu-zhenren/design11/ws1_wave_cogs.json (OUT of git).
"""
import json, os, sys, threading
from collections import defaultdict
sys.path.insert(0, "/home/ubuntu/projects/textmachine/eval/exp15")
import chunker as CK
RECORDS = "/home/ubuntu/books/gu-zhenren/rerun/records.json"
OUT = "/home/ubuntu/books/gu-zhenren/design11/ws1_wave_cogs.json"
F_CJK, F_OTHER = 1.1978, 0.3852
TARGET_OUT = 1797 # draft chunk output-token budget (WS2: reproduces current 56-chunk split)
# prices USD/1M (vendor-verified freeze_facts / z.ai pricing / deepseek api-docs)
PRICES = {
"deepseek-v4-flash": {"in": 0.14, "cache": 0.0028, "out": 0.28}, # draft (thinking-ON)
"glm-5": {"in": 1.00, "cache": 0.20, "out": 3.20}, # editor baseline (D30.1)
"mistral-large-latest": {"in": 0.50, "cache": 0.50, "out": 1.50}, # editor swap-arm (no cache -> cache=in)
"deepseek-v4-pro": {"in": 0.435, "cache": 0.003625, "out": 0.87},# editor swap-arm
}
# ---------- (1) money invariant: mock single-writer ledger ----------
class MockLedger:
"""Mirrors ledger.go reserve/settle under a single-writer lock (store.go MaxOpenConns=1)."""
def __init__(self, book_ceiling, day_ceiling):
self.lock = threading.Lock() # models the single write connection (serialized)
self.committed = 0.0
self.reserved = 0.0
self.book_ceiling = book_ceiling
self.day_ceiling = day_ceiling
self.settled = []
self.denied = 0
def reserve(self, estimate):
with self.lock: # BEGIN IMMEDIATE: ceiling check + insert one atomic tx
if self.committed + self.reserved + estimate > self.book_ceiling:
self.denied += 1
return None
self.reserved += estimate
return estimate
def settle(self, estimate, cost):
with self.lock: # SettleWithCheckpoint: one tx, idempotent
self.reserved = max(0.0, self.reserved - estimate)
self.committed += cost
self.settled.append(cost)
def recover(self): # store.recoverReservations on restart
with self.lock:
self.reserved = 0.0
def money_invariant_test(n_workers=8, n_calls=56, est=0.01, cost=0.008, ceiling=10.0):
led = MockLedger(book_ceiling=ceiling, day_ceiling=ceiling)
max_reservation = est
results = {}
# concurrent reserve->settle across N workers over n_calls chunks (wave W1)
calls = list(range(n_calls))
lock = threading.Lock()
idx = [0]
def worker():
while True:
with lock:
if idx[0] >= len(calls):
return
i = idx[0]; idx[0] += 1
r = led.reserve(est)
if r is None:
continue
led.settle(est, cost)
ths = [threading.Thread(target=worker) for _ in range(n_workers)]
for t in ths: t.start()
for t in ths: t.join()
committed_ok = abs(led.committed - sum(led.settled)) < 1e-9 and abs(led.committed - n_calls * cost) < 1e-9
ceiling_ok = led.committed + led.reserved <= ceiling + 1e-9
results["committed_equals_sum"] = committed_ok
results["no_ceiling_overshoot"] = ceiling_ok
results["final_committed"] = round(led.committed, 6)
results["final_reserved"] = round(led.reserved, 6)
# kill-9: a worker reserved but died before settle -> reserved leaks, recover() zeroes it,
# the call is retried => <=1 call lost, no double-charge.
led2 = MockLedger(ceiling, ceiling)
led2.reserve(est) # worker reserved
# (process killed here, no settle)
committed_before = led2.committed
led2.recover() # restart recovery
led2.reserve(est); led2.settle(est, cost) # resume re-runs the one call
results["kill9_no_double_charge"] = abs(led2.committed - committed_before - cost) < 1e-9 and led2.reserved == 0.0
# ceiling overshoot bound under contention: many small reserves against a tight ceiling
led3 = MockLedger(book_ceiling=0.05, day_ceiling=0.05) # allows ~5 reserves of 0.01
idx3 = [0]; calls3 = list(range(100))
def w3():
while True:
with lock:
if idx3[0] >= len(calls3): return
idx3[0] += 1
r = led3.reserve(est)
if r is not None:
led3.settle(est, cost)
ts3 = [threading.Thread(target=w3) for _ in range(16)]
for t in ts3: t.start()
for t in ts3: t.join()
# committed must never exceed ceiling + one max reservation (the documented bound)
results["overshoot_within_one_reservation"] = led3.committed <= 0.05 + max_reservation + 1e-9
results["tight_ceiling_committed"] = round(led3.committed, 6)
# retry isolation (wave property): each chunk's input is a pure function of (source, frozen bank),
# independent of sibling chunks in the same wave -> a retry of chunk i never mutates chunk j's input.
results["retry_isolation_structural"] = True # guaranteed by immutable frozen bank + per-chunk source
results["all_pass"] = all(v for k, v in results.items() if isinstance(v, bool))
return results
# ---------- structure + COGS ----------
def build_structure():
recs = json.load(open(RECORDS))
by_ch = defaultdict(list)
for r in recs:
by_ch[r["chapter"]].append((r["chunk_idx"], r.get("source", "") or ""))
chapters = {ch: "\n".join(s for _, s in sorted(items)) for ch, items in sorted(by_ch.items())}
# draft chunks under output budget
def greedy_out(text, tgt):
chapter = CK.normalize_source(text); paras = CK.split_paragraphs(chapter); chunks, buf = [], []
def flush():
nonlocal buf
if buf:
t = "\n\n".join(buf).strip()
if t: chunks.append(t)
buf = []
for p in paras:
if CK.est_out(p) > tgt:
flush()
sbuf = []
for s in CK.split_source_sentences(p):
if sbuf and CK.est_out("".join(sbuf) + s) > tgt:
chunks.append("".join(sbuf)); sbuf = []
sbuf.append(s)
if sbuf: chunks.append("".join(sbuf))
continue
if buf and CK.est_out("\n\n".join(buf) + "\n\n" + p) > tgt:
flush()
buf.append(p)
flush()
return chunks
draft_chunks = []
for txt in chapters.values():
draft_chunks += greedy_out(txt, TARGET_OUT)
edit_units = list(chapters.values()) # edit-unit = chapter
return chapters, draft_chunks, edit_units
def est_tokens_in(text):
return CK._est_tokens_from(*CK._class_counts(text))
def cogs(draft_chunks, edit_units, editor, cache=True, peak=False):
fp = PRICES["deepseek-v4-flash"]
ep = PRICES[editor]
peak_mul = 2.0 if peak else 1.0
# DRAFT (flash): input = source est-tokens (stable glossary prefix cached), output = est_out
draft_in = sum(est_tokens_in(c) for c in draft_chunks)
draft_out = sum(CK.est_out(c) for c in draft_chunks)
# stable prefix (system+glossary) ~ 55% of input cached after warm (WS2 CACHE_FRAC=0.55, warm-then-fan)
cfrac = 0.55 if cache else 0.0
draft_cost = ((draft_in * (1 - cfrac) * fp["in"] + draft_in * cfrac * fp["cache"]) * peak_mul
+ draft_out * fp["out"] * peak_mul) / 1e6
# EDIT (editor): input = source + draft (~est_out) per chapter, output ~= 0.9*draft len
edit_in = sum(est_tokens_in(u) + CK.est_out(u) for u in edit_units)
edit_out = sum(CK.est_out(u) * 0.9 for u in edit_units)
ecfrac = 0.55 if cache else 0.0
e_peak = peak_mul if editor.startswith("deepseek") else 1.0 # peak-surge only DeepSeek
edit_cost = ((edit_in * (1 - ecfrac) * ep["in"] + edit_in * ecfrac * ep["cache"]) * e_peak
+ edit_out * ep["out"] * e_peak) / 1e6
return round(draft_cost, 4), round(edit_cost, 4), round(draft_cost + edit_cost, 4)
def main():
result = {}
result["money_invariant_test"] = money_invariant_test()
chapters, draft_chunks, edit_units = build_structure()
result["structure"] = {"n_chapters": len(chapters), "n_draft_chunks": len(draft_chunks),
"n_edit_units": len(edit_units)}
# (2) wall-clock: mock per-call latency (draft ~ its out tokens; fixed). Wave-parallel with N workers.
LAT_PER_KTOK = 12.0 # seconds per 1k output tokens (mock, order-of-magnitude)
draft_lat = [max(3.0, CK.est_out(c) / 1000 * LAT_PER_KTOK) for c in draft_chunks]
edit_lat = [max(3.0, CK.est_out(u) / 1000 * LAT_PER_KTOK) for u in edit_units]
def wave_wall(lats, workers):
# greedy longest-processing-time bin packing onto `workers` lanes
lanes = [0.0] * workers
for t in sorted(lats, reverse=True):
i = lanes.index(min(lanes)); lanes[i] += t
return round(max(lanes), 1)
result["wall_clock_seconds"] = {
"sequential_total": round(sum(draft_lat) + sum(edit_lat), 1),
"wave_W1_4workers": wave_wall(draft_lat, 4), "wave_W2_4workers": wave_wall(edit_lat, 4),
"wave_W1_8workers": wave_wall(draft_lat, 8), "wave_W2_8workers": wave_wall(edit_lat, 8),
"wave_total_8workers": wave_wall(draft_lat, 8) + wave_wall(edit_lat, 8),
"speedup_8w": round((sum(draft_lat) + sum(edit_lat)) / (wave_wall(draft_lat, 8) + wave_wall(edit_lat, 8)), 2),
}
# (3) COGS per editor-arm x cache x peak
result["cogs_per_arm_usd"] = {}
for editor in ("glm-5", "mistral-large-latest", "deepseek-v4-pro"):
arm = {}
for cache in (True, False):
for peak in (False, True):
d, e, tot = cogs(draft_chunks, edit_units, editor, cache=cache, peak=peak)
arm[f"cache={cache}_peak={peak}"] = {"draft": d, "edit": e, "total": tot}
result["cogs_per_arm_usd"][editor] = arm
# headline: warm cache, valley (recommended operating point)
result["cogs_headline_25ch_slice"] = {
editor: result["cogs_per_arm_usd"][editor]["cache=True_peak=False"]["total"]
for editor in ("glm-5", "mistral-large-latest", "deepseek-v4-pro")}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
json.dump(result, open(OUT, "w"), ensure_ascii=False, indent=2)
print(json.dumps(result, ensure_ascii=False, indent=2))
mi = result["money_invariant_test"]
print(f"\nVERDICT: money-invariant {'ALL PASS' if mi['all_pass'] else 'FAIL'}; "
f"wall-clock speedup 8w = {result['wall_clock_seconds']['speedup_8w']}x; "
f"COGS/25ch (warm,valley): " + ", ".join(f"{k.split('-')[0]}=${v}" for k, v in result['cogs_headline_25ch_slice'].items()))
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show more