diff --git a/backend/ARCH_CLEANUP_REPORT.md b/backend/ARCH_CLEANUP_REPORT.md new file mode 100644 index 0000000..de98552 --- /dev/null +++ b/backend/ARCH_CLEANUP_REPORT.md @@ -0,0 +1,195 @@ +# 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-я реализация ПОВЕДЕНИЯ — интерфейс P1–P6 = преждевременно), **без 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//` (морфология) + `-/` (Палладий); 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: строить P1–P6 интерфейсы / 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-реплике (тот же сеам). diff --git a/backend/configs/langpacks/zh-ru/palladius.txt b/backend/configs/langpacks/zh-ru/palladius.txt new file mode 100644 index 0000000..bea50a0 --- /dev/null +++ b/backend/configs/langpacks/zh-ru/palladius.txt @@ -0,0 +1,88 @@ +# Palladius (Палладий) pinyin→Cyrillic table (palladius.INITIALS/FINALS/Y_W/SPECIAL_I). `categorypinyincyrillic` 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 цзы diff --git a/backend/configs/langpacks/zh/alias-particle.txt b/backend/configs/langpacks/zh/alias-particle.txt new file mode 100644 index 0000000..661f158 --- /dev/null +++ b/backend/configs/langpacks/zh/alias-particle.txt @@ -0,0 +1,2 @@ +# Trailing-particle set marking a boundary fragment (alias.PARTICLE). A rune SET; stored sorted. +上下不与之也了今其前后和在多大小少就心是的都面 diff --git a/backend/configs/langpacks/zh/grade-prefix.txt b/backend/configs/langpacks/zh/grade-prefix.txt new file mode 100644 index 0000000..ba20719 --- /dev/null +++ b/backend/configs/langpacks/zh/grade-prefix.txt @@ -0,0 +1,2 @@ +# Grade/stem prefix chars 甲乙丙… (patterns.GRADE_PREFIX). A rune SET; stored sorted. +丁丙乙壬己庚戊甲癸辛 diff --git a/backend/configs/langpacks/zh/numeral.txt b/backend/configs/langpacks/zh/numeral.txt new file mode 100644 index 0000000..26bc8ac --- /dev/null +++ b/backend/configs/langpacks/zh/numeral.txt @@ -0,0 +1,2 @@ +# CJK numeral chars (patterns.NUMERAL). A rune SET; stored sorted. +一七三九二五八六十千四百零 diff --git a/backend/configs/langpacks/zh/ordinal-title.txt b/backend/configs/langpacks/zh/ordinal-title.txt new file mode 100644 index 0000000..7b30c81 --- /dev/null +++ b/backend/configs/langpacks/zh/ordinal-title.txt @@ -0,0 +1,16 @@ +# Ordinal titles 一代/第一… (patterns.ORDINAL). Ordered; one per line. +一代 +二代 +三代 +四代 +五代 +六代 +七代 +八代 +九代 +十代 +第一 +第二 +第三 +第四 +第五 diff --git a/backend/configs/langpacks/zh/rank-word.txt b/backend/configs/langpacks/zh/rank-word.txt new file mode 100644 index 0000000..5ff5bdc --- /dev/null +++ b/backend/configs/langpacks/zh/rank-word.txt @@ -0,0 +1,9 @@ +# Rank/measure words 等/转… (patterns.RANK_WORD). Ordered; one per line. +等 +转 +阶 +级 +重 +品 +段 +层 diff --git a/backend/configs/langpacks/zh/surnames-compound.txt b/backend/configs/langpacks/zh/surnames-compound.txt new file mode 100644 index 0000000..efe3bd6 --- /dev/null +++ b/backend/configs/langpacks/zh/surnames-compound.txt @@ -0,0 +1,17 @@ +# Two-char compound surnames (patterns compound set). One per line. +上官 +东方 +公孙 +南宫 +古月 +司徒 +司马 +夏侯 +宇文 +尉迟 +慕容 +欧阳 +皇甫 +诸葛 +长孙 +鲜于 diff --git a/backend/configs/langpacks/zh/surnames-single.txt b/backend/configs/langpacks/zh/surnames-single.txt new file mode 100644 index 0000000..225f48a --- /dev/null +++ b/backend/configs/langpacks/zh/surnames-single.txt @@ -0,0 +1,2 @@ +# 百家姓 single-char surnames (patterns.SURNAMES_SINGLE, 凝 discarded — not a surname). A rune SET (order-free); stored sorted by code point. +丁万严乐于云任伍伏何余俞倪傅元冯凌凤刁包华单卜卞卢卫危史吉吕吴周和唐喻夏奚姚姜娄孔孙孟季安宋宗宣尤尹屈岑崔左席常干平应庞康廉张强彭徐成戚戴房支方施时昌明昝曹朱李杜杨杭林柏柯柳梁梅樊殷毕毛水江汤汪沈洪湛滕潘熊狄王田白皮盛石祁祝禹秦穆窦章童管米纪经缪罗胡臧舒花苏苗范茅莫萧葛董蒋蓝蔡薛虞袁裘褚解计许诸谈谢贝贲费贺贾赵路邓邬邱邵邹郁郎郑郝郭酆金钟钮钱闵阮陈陶雷霍韦韩项顾颜马骆高魏鲁鲍麻黄齐龚 diff --git a/backend/configs/langpacks/zh/title-suffix.txt b/backend/configs/langpacks/zh/title-suffix.txt new file mode 100644 index 0000000..17ad2ee --- /dev/null +++ b/backend/configs/langpacks/zh/title-suffix.txt @@ -0,0 +1,27 @@ +# Title suffixes → title (patterns.TITLE_SUFFIX). Ordered; one per line. +公子 +大人 +长老 +前辈 +族长 +家老 +老祖 +祖师 +真人 +上人 +道人 +先生 +夫人 +娘子 +姑娘 +嬷嬷 +师傅 +师父 +掌门 +宗主 +少爷 +老爷 +小姐 +婆婆 +大娘 +大爷 diff --git a/backend/configs/langpacks/zh/topo-suffix.txt b/backend/configs/langpacks/zh/topo-suffix.txt new file mode 100644 index 0000000..cf9c39d --- /dev/null +++ b/backend/configs/langpacks/zh/topo-suffix.txt @@ -0,0 +1,2 @@ +# Topographic suffix chars → place (patterns.TOPO_SUFFIX). A rune SET; stored sorted. +原城堂宗寨山岛岭峰府村林楼殿江河洞海湖潭疆祠谷门阁院 diff --git a/backend/internal/lang/langpack.go b/backend/internal/lang/langpack.go new file mode 100644 index 0000000..513e330 --- /dev/null +++ b/backend/internal/lang/langpack.go @@ -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 // "-", e.g. "zh-ru" (== config.Book.LangPair()) + + // source morphology (configs/langpacks//) — 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 + + // - pair transliteration (configs/langpacks//) — 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//), 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//). +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// and the pair-transliteration files under root/-/. +// 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 `categorypinyincyrillic` 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' } diff --git a/backend/internal/lang/langpack_test.go b/backend/internal/lang/langpack_test.go new file mode 100644 index 0000000..3290688 --- /dev/null +++ b/backend/internal/lang/langpack_test.go @@ -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-", 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) + } + } +} diff --git a/backend/internal/pipeline/banknote_integration_test.go b/backend/internal/pipeline/banknote_integration_test.go index 261bbf9..149726d 100644 --- a/backend/internal/pipeline/banknote_integration_test.go +++ b/backend/internal/pipeline/banknote_integration_test.go @@ -2,6 +2,7 @@ package pipeline import ( "context" + "fmt" "net/http/httptest" "os" "path/filepath" @@ -84,6 +85,13 @@ func TestBanknoteSliceReTelemetryResume(t *testing.T) { 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 @@ -120,7 +128,8 @@ func TestBanknoteClassifyOverCleaned(t *testing.T) { // 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}. +// (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() @@ -143,8 +152,8 @@ func TestBanknoteSnapshotFold(t *testing.T) { if strings.Contains(offPayload, "banknote") { t.Fatalf("a banknote-OFF snapshot must omit the field (omitempty), got: %s", offPayload) } - if !strings.Contains(onPayload, `"banknote":{"enabled":true,"parser_version":"`+bankParserVersion+`"}`) { - t.Fatalf("a banknote-ON snapshot must fold {enabled,parser_version}, got: %s", onPayload) + 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) } } diff --git a/backend/internal/pipeline/chunkrun.go b/backend/internal/pipeline/chunkrun.go index a4aa378..5305025 100644 --- a/backend/internal/pipeline/chunkrun.go +++ b/backend/internal/pipeline/chunkrun.go @@ -156,6 +156,22 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, me out.CostUSD += sr.CostUSD if st.Role == roleTranslator { bankTelem = sr.BankFlags // translator draft's banknote telemetry (WS4 point 10) + // FL-2: the resume fast-path (resumeFromChunkStatus) serves final_hash — the banknote-CLEANED + // derived checkpoint — and never re-parses the raw block, so it leaves BankFlags at the zero + // value. Restore the telemetry the fresh run persisted instead of letting persistRetrievalState + // zero the three banknote columns on every resume of a ready chunk (the comment below promises + // an identical row). Guarded on an EMPTY BankFlags so the attempt-loop path — which re-derives + // the telemetry correctly from the raw checkpoint — is never overwritten. Channel-off ⇒ always + // zero anyway, so the store read is skipped. + if bankTelem == (bankFlags{}) && sr.FromResume && r.Pipeline.Gates.Banknote.Enabled { + if prev, err := r.Store.GetRetrievalState(r.Book.BookID, ch.Chapter, ch.ChunkIdx); err == nil && prev != nil { + bankTelem = bankFlags{ + NLines: prev.NBanknoteLines, + ParseFail: prev.BanknoteParseFail != 0, + Truncated: prev.BanknoteTruncated != 0, + } + } + } } if sr.Disposition == DispFlagged { flagged = true @@ -212,8 +228,10 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, me // 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). + // idempotent, so a resume reproduces the identical row — the memory/style counts re-derive from + // the deterministic selection + post-check, and the banknote columns are carried forward on resume + // (FL-2 above) since the raw block is not re-parsed on the fast-path. 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, bankTelem); err != nil { return out, err diff --git a/backend/internal/pipeline/miner.go b/backend/internal/pipeline/miner.go index ab29c41..cc768a4 100644 --- a/backend/internal/pipeline/miner.go +++ b/backend/internal/pipeline/miner.go @@ -1,6 +1,10 @@ package pipeline -import "sort" +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 @@ -67,9 +71,9 @@ type mineResult struct { // 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) mineResult { +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) + 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 { diff --git a/backend/internal/pipeline/miner_alias.go b/backend/internal/pipeline/miner_alias.go index ecd685b..8cc496b 100644 --- a/backend/internal/pipeline/miner_alias.go +++ b/backend/internal/pipeline/miner_alias.go @@ -3,6 +3,8 @@ 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 @@ -18,10 +20,6 @@ import ( // 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). -// aliasParticle is the trailing-particle set that marks a boundary FRAGMENT (alias.PARTICLE): a candidate -// = a known surface + one of these is 方源在/方源心, not an entity. -var aliasParticle = buildRuneSet2("的了在是和就也都不心面前后上下今少多大小与之其") - // aliasSurface is one src surface with the seed metadata the rules read (alias.Surface, minus the // default-B-dropped dst_lemmas). type aliasSurface struct { @@ -40,12 +38,12 @@ type aliasEdge struct { // 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) bool { +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])] && aliasParticle[r[len(r)-1]] + 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. @@ -70,7 +68,7 @@ func splitMinerSentences(s string) []string { // 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) (ident, family, weak []aliasEdge) { +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) @@ -87,7 +85,7 @@ func proposeAliasEdges(surfaces map[string]aliasSurface, chunks []MinerChunk, se if a.approvedDst != "" && b.approvedDst != "" && normalizeSourceKey(a.approvedDst) != normalizeSourceKey(b.approvedDst) { continue } - surA, surB := isSurnameStart([]rune(a.src)), isSurnameStart([]rune(b.src)) + 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 diff --git a/backend/internal/pipeline/miner_emit.go b/backend/internal/pipeline/miner_emit.go index e2bf16d..cc7e0cc 100644 --- a/backend/internal/pipeline/miner_emit.go +++ b/backend/internal/pipeline/miner_emit.go @@ -6,6 +6,7 @@ import ( "gopkg.in/yaml.v3" + "textmachine/backend/internal/lang" "textmachine/backend/internal/store" ) @@ -22,6 +23,12 @@ import ( // 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 W1.5. @@ -37,8 +44,8 @@ type MinedTerm struct { // 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) []MinedTerm { - mr := mineDetect(chunks, contrast, cfg) +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{} @@ -63,10 +70,19 @@ func MineBank(chunks []MinerChunk, contrast *Contrast, seed []store.GlossaryEntr } // 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). + // 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 mr.ranked { - if !emissionEligible(c, mr.subsumed, seedSurfaces) { + for _, c := range ranked { + if !emissionEligible(c, mr.subsumed, seedSurfaces, pack) { continue } pool = append(pool, c) @@ -89,7 +105,7 @@ func MineBank(chunks []MinerChunk, contrast *Contrast, seed []store.GlossaryEntr universe = append(universe, s) } sort.Strings(universe) - ident, _, _ := proposeAliasEdges(surfaces, chunks, seedSurfaces) + 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 @@ -165,14 +181,14 @@ func MinedDeltaYAML(mined []MinedTerm) (string, error) { // 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) bool { +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) { + if isFragment(c.Src, seedSurfaces, pack) { return false } return true diff --git a/backend/internal/pipeline/miner_palladius.go b/backend/internal/pipeline/miner_palladius.go index 3e3e331..84072a4 100644 --- a/backend/internal/pipeline/miner_palladius.go +++ b/backend/internal/pipeline/miner_palladius.go @@ -4,12 +4,16 @@ import ( "sort" "strings" "unicode" + + "textmachine/backend/internal/lang" ) -// miner_palladius.go: the Palladius (Палладий) transliteration syllable table + ru-side name-shape +// 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 generated -// deterministically from initials × finals + the documented irregularities (a versioned artifact). +// syllables looks like a transliterated Chinese NAME. The pinyin→Cyrillic TABLE is DATA (a pair pack, +// configs/langpacks//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 @@ -17,55 +21,23 @@ import ( // name-shape signal a future faithful-A variant (with a Go morphology backend) would consume, and the // pattern P4 transliteration-by-pair seam (§B5). -// palladius initials (声母) → Palladius Cyrillic (palladius.INITIALS). -var palladiusInitials = map[string]string{ - "b": "б", "p": "п", "m": "м", "f": "ф", "d": "д", "t": "т", "n": "н", "l": "л", - "g": "г", "k": "к", "h": "х", "j": "цз", "q": "ц", "x": "с", - "zh": "чж", "ch": "ч", "sh": "ш", "r": "ж", "z": "цз", "c": "ц", "s": "с", -} - -// palladius finals (韵母) → Palladius Cyrillic (palladius.FINALS). ü is written as v. -var palladiusFinals = map[string]string{ - "a": "а", "o": "о", "e": "э", "ai": "ай", "ei": "эй", "ao": "ао", "ou": "оу", - "an": "ань", "en": "энь", "ang": "ан", "eng": "эн", "er": "эр", "ong": "ун", - "i": "и", "ia": "я", "ie": "е", "iao": "яо", "iu": "ю", "ian": "янь", - "in": "инь", "iang": "ян", "ing": "ин", "iong": "юн", - "u": "у", "ua": "уа", "uo": "о", "uai": "уай", "ui": "уй", "uan": "уань", - "un": "унь", "uang": "уан", "ueng": "ун", - "v": "юй", "ve": "юэ", "van": "юань", "vn": "юнь", -} - -// whole zero-initial (y-/w-) syllables (palladius.Y_W). -var palladiusYW = map[string]string{ - "yi": "и", "ya": "я", "ye": "е", "yao": "яо", "you": "ю", "yan": "янь", "yin": "инь", - "yang": "ян", "ying": "ин", "yong": "юн", "yu": "юй", "yue": "юэ", "yuan": "юань", "yun": "юнь", - "wu": "у", "wa": "ва", "wo": "во", "wai": "вай", "wei": "вэй", "wan": "вань", "wen": "вэнь", - "wang": "ван", "weng": "вэн", -} - -// retroflex/sibilant + i → -ы/-и class (palladius.SPECIAL_I). -var palladiusSpecialI = map[string]string{ - "zhi": "чжи", "chi": "чи", "shi": "ши", "ri": "жи", "zi": "цзы", "ci": "цы", "si": "сы", -} - var palladiusJQX = map[string]bool{"j": true, "q": true, "x": true} -// palladiusCyrSyllables is the distinct Cyrillic syllable set, longest-first, for greedy segmentation -// (palladius._CYR_SYL). 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. -var palladiusCyrSyllables = buildPalladiusCyrSyllables() - -func buildPalladiusCyrSyllables() []string { +// 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 palladiusYW { + for _, v := range p.PalladiusYW { set[v] = true } - for _, v := range palladiusSpecialI { + 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 palladiusInitials { - for pf, cf := range palladiusFinals { + 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": @@ -97,8 +69,9 @@ func buildPalladiusCyrSyllables() []string { // 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. -func isPalladiusToken(token string, minSyllables int) bool { +// 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) { @@ -108,7 +81,7 @@ func isPalladiusToken(token string, minSyllables int) bool { nSyl := 0 for i := 0; i < len(tr); { matched := false - for _, s := range palladiusCyrSyllables { + for _, s := range syllables { sr := []rune(s) if i+len(sr) <= len(tr) && runesEqual(tr[i:i+len(sr)], sr) { i += len(sr) diff --git a/backend/internal/pipeline/miner_parity_test.go b/backend/internal/pipeline/miner_parity_test.go index 1cbb30e..1131f11 100644 --- a/backend/internal/pipeline/miner_parity_test.go +++ b/backend/internal/pipeline/miner_parity_test.go @@ -19,12 +19,24 @@ import ( // 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. -const ( - minerParityContrast = "/home/ubuntu/projects/textmachine/eval/exp16/data/jieba_dict_general_zh.txt" - minerParityRecords = "/home/ubuntu/books/gu-zhenren/rerun/records.json" - minerParitySeed = "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml" +// 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} { @@ -71,7 +83,7 @@ func TestMinerFullBookParity(t *testing.T) { t.Fatal(err) } - mr := mineDetect(chunks, contrast, frozenMinerConfig()) + mr := mineDetect(chunks, contrast, frozenMinerConfig(), testLangPack(t)) // 1) candidate SET count. n := len(mr.ranked) diff --git a/backend/internal/pipeline/miner_patterns.go b/backend/internal/pipeline/miner_patterns.go index aabaf16..05050e2 100644 --- a/backend/internal/pipeline/miner_patterns.go +++ b/backend/internal/pipeline/miner_patterns.go @@ -1,5 +1,7 @@ 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 @@ -7,71 +9,21 @@ package pipeline // 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 version (patterns.PACK_VERSION). +// 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" -// surnamesSingle is the 百家姓 single-char subset + this book's cast (patterns.SURNAMES_SINGLE, with 凝 -// discarded — it is not a surname). surnamesCompound are the explicit two-char compound surnames. -var ( - surnamesSingle = buildRuneSet(surnamesSingleRaw) - surnamesCompound = map[string]bool{ - "古月": true, "欧阳": true, "司马": true, "上官": true, "夏侯": true, "诸葛": true, "东方": true, - "皇甫": true, "尉迟": true, "公孙": true, "慕容": true, "长孙": true, "宇文": true, "司徒": true, - "鲜于": true, "南宫": true, - } -) - -const surnamesSingleRaw = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜" + - "戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳酆鲍史唐" + - "费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄" + - "和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁" + - "杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍" + - "虞万支柯昝管卢莫经房裘缪干解应宗丁宣贲邓郁单杭洪包诸左石崔吉钮龚" + - "白凝" // 凝 removed below via buildRuneSet's caller — kept literal for provenance parity - -// buildRuneSet builds a rune set, then removes 凝 (patterns.SURNAMES_SINGLE.discard("凝")). -func buildRuneSet(s string) map[rune]bool { - m := map[rune]bool{} - for _, r := range s { - m[r] = true - } - delete(m, '凝') - return m -} - -// Title / ordinal / topo / rank inventories (patterns.TITLE_SUFFIX/ORDINAL/TOPO_SUFFIX/GRADE_PREFIX/ -// NUMERAL/RANK_WORD). TITLE_PREFIX is defined in the reference but unused by pattern_candidates, so it -// is omitted here. -var ( - titleSuffix = []string{"公子", "大人", "长老", "前辈", "族长", "家老", "老祖", "祖师", "真人", "上人", - "道人", "先生", "夫人", "娘子", "姑娘", "嬷嬷", "师傅", "师父", "掌门", "宗主", - "少爷", "老爷", "小姐", "婆婆", "大娘", "大爷"} - ordinalTitle = []string{"一代", "二代", "三代", "四代", "五代", "六代", "七代", "八代", "九代", "十代", - "第一", "第二", "第三", "第四", "第五"} - topoSuffix = buildRuneSet2("山寨村疆谷城门宗府洞峰岭河江湖海林原岛潭殿阁楼院堂祠") - gradePrefix = buildRuneSet2("甲乙丙丁戊己庚辛壬癸") - numeral = buildRuneSet2("一二三四五六七八九十零百千") - rankWord = []string{"等", "转", "阶", "级", "重", "品", "段", "层"} -) - -// buildRuneSet2 builds a rune set with NO discard (topo/grade/numeral inventories). -func buildRuneSet2(s string) map[rune]bool { - m := map[rune]bool{} - for _, r := range s { - m[r] = true - } - return m -} - // 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). -func isSurnameStart(s []rune) string { +// 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]); surnamesCompound[pre] { + if pre := string(s[:2]); p.SurnamesCompound[pre] { return pre } } - if len(s) >= 1 && surnamesSingle[s[0]] { + if len(s) >= 1 && p.SurnamesSingle[s[0]] { return string(s[0]) } return "" @@ -106,8 +58,8 @@ func runeHasPrefixAt(run []rune, i int, p []rune) bool { // formantType maps a formant char to a candidate type (patterns.formant_type): topo→place, 等/转/阶→ // title, else term. -func formantType(c rune) string { - if topoSuffix[c] { +func formantType(c rune, p *lang.Pack) string { + if p.TopoSuffix[c] { return "place" } if c == '等' || c == '转' || c == '阶' { @@ -195,7 +147,7 @@ func detectFormants(chunks []MinerChunk, candFreq map[string]int, contrast *Cont // 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) (map[string]*patternInfo, map[rune]formantInfo) { +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)) @@ -215,16 +167,16 @@ func patternCandidates(chunks []MinerChunk, candFreq map[string]int, contrast *C } } - titleSufR := toRuneSlices(titleSuffix) - ordinalR := toRuneSlices(ordinalTitle) - rankR := toRuneSlices(rankWord) + 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 + 1–2 Han given-name window → name - if sn := isSurnameStart(run[i:]); sn != "" { + if sn := isSurnameStart(run[i:], p); sn != "" { base := i + len([]rune(sn)) for _, gl := range [2]int{1, 2} { if base+gl <= L { @@ -242,11 +194,11 @@ func patternCandidates(chunks []MinerChunk, candFreq map[string]int, contrast *C if i-left >= 0 { cand := run[i-left : i+len(suf)] if len(cand) >= 2 && len(cand) <= 6 { - add(cand, "title", "title_suffix:"+titleSuffix[si]) + add(cand, "title", "title_suffix:"+p.TitleSuffix[si]) } } } - add([]rune(titleSuffix[si]), "title", "title_bare:"+titleSuffix[si]) + add([]rune(p.TitleSuffix[si]), "title", "title_bare:"+p.TitleSuffix[si]) } } // (2b) ordinal + title (四代族长-class) → title @@ -255,7 +207,7 @@ func patternCandidates(chunks []MinerChunk, candFreq map[string]int, contrast *C for si, suf := range titleSufR { end := i + len(od) if runeHasPrefixAt(run, end, suf) { - add(run[i:end+len(suf)], "title", "ordinal_title:"+ordinalTitle[oi]+"+"+titleSuffix[si]) + add(run[i:end+len(suf)], "title", "ordinal_title:"+p.OrdinalTitle[oi]+"+"+p.TitleSuffix[si]) } } } @@ -264,13 +216,13 @@ func patternCandidates(chunks []MinerChunk, candFreq map[string]int, contrast *C for ri, rw := range rankR { if runeHasPrefixAt(run, i, rw) && i >= 1 { left := run[i-1] - if numeral[left] || gradePrefix[left] { - add(run[i-1:i+len(rw)], "title", "rank_grade:"+string(left)+"+"+rankWord[ri]) + 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: 1–3 Han base + topo char → place - if topoSuffix[run[i]] && i >= 1 { + 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] @@ -294,7 +246,7 @@ func patternCandidates(chunks []MinerChunk, candFreq map[string]int, contrast *C if !ok { continue } - typ := formantType(run[i]) + typ := formantType(run[i], p) if info.role == "suffix" || info.role == "both" { for _, left := range [3]int{3, 2, 1} { if i-left >= 0 { diff --git a/backend/internal/pipeline/miner_test.go b/backend/internal/pipeline/miner_test.go index 40fe49b..c6e1091 100644 --- a/backend/internal/pipeline/miner_test.go +++ b/backend/internal/pipeline/miner_test.go @@ -5,9 +5,22 @@ import ( "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 @@ -81,7 +94,7 @@ func TestMinerPatternChannels(t *testing.T) { ) // 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) + pats, _ := patternCandidates(chunks, cf, smallContrast(t), 3, 15.0, testLangPack(t)) cases := []struct { src, typ string }{ @@ -118,22 +131,23 @@ func TestMinerFormantDetection(t *testing.T) { } func TestMinerPalladiusToken(t *testing.T) { + syl := buildPalladiusCyrSyllables(testLangPack(t)) // Positives: transliterated Chinese-name shapes segment cleanly. for _, tok := range []string{"юань", "фан", "гу", "цзюй"} { - if !isPalladiusToken(tok, 1) { + 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) { + 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) { + if !isPalladiusToken("найти", 2, syl) { t.Fatalf("найти must be Palladius-shaped (documents why default-B drops the ru name channel)") } } @@ -160,6 +174,7 @@ func TestMinerAliasRules(t *testing.T) { } return false } + pack := testLangPack(t) // R1 extension: 方源 (seed entity) ⊂ 古月方源 → identity (same person, fuller surface). t.Run("R1_extension", func(t *testing.T) { @@ -167,7 +182,7 @@ func TestMinerAliasRules(t *testing.T) { normalizeSourceKey("方源"): aliasSurf("方源", "name", "", ""), normalizeSourceKey("古月方源"): aliasSurf("古月方源", "name", "", ""), } - ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("方源")) + ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("方源"), pack) if !hasIdent(ident, "方源", "古月方源") { t.Fatalf("方源 ⊂ 古月方源 must be an identity extension, got %+v", ident) } @@ -184,7 +199,7 @@ func TestMinerAliasRules(t *testing.T) { normalizeSourceKey("方源"): aliasSurf("方源", "name", "", ""), } seeds := seedSurf("古月", "族长", "方源") - ident, _, weak := proposeAliasEdges(surf, nil, seeds) + ident, _, weak := proposeAliasEdges(surf, nil, seeds, pack) if hasIdent(ident, "古月", "古月族长") { t.Fatalf("古月+族长 (clan+title) must be a phrase, NOT an identity alias") } @@ -209,7 +224,7 @@ func TestMinerAliasRules(t *testing.T) { normalizeSourceKey("族长"): aliasSurf("族长", "title", "", "глава клана"), normalizeSourceKey("四代族长"): aliasSurf("四代族长", "title", "", "четвёртый глава клана"), } - ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("族长", "四代族长")) + ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("族长", "四代族长"), pack) if hasIdent(ident, "族长", "四代族长") { t.Fatalf("族长 vs 四代族长 with different approved dst must NOT be identity, got %+v", ident) } @@ -221,7 +236,7 @@ func TestMinerAliasRules(t *testing.T) { normalizeSourceKey("方源"): aliasSurf("方源", "name", "male", ""), normalizeSourceKey("方源儿"): aliasSurf("方源儿", "name", "female", ""), } - ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("方源")) + ident, _, _ := proposeAliasEdges(surf, nil, seedSurf("方源"), pack) if hasIdent(ident, "方源", "方源儿") { t.Fatalf("different confirmed gender must block identity, got %+v", ident) } @@ -233,7 +248,7 @@ func TestMinerAliasRules(t *testing.T) { normalizeSourceKey("方源"): aliasSurf("方源", "name", "", ""), normalizeSourceKey("方正"): aliasSurf("方正", "name", "", ""), } - ident, family, _ := proposeAliasEdges(surf, nil, seedSurf("方源", "方正")) + ident, family, _ := proposeAliasEdges(surf, nil, seedSurf("方源", "方正"), pack) if hasIdent(ident, "方源", "方正") { t.Fatalf("same surname + different given names must be family, NOT identity") } @@ -246,6 +261,7 @@ func TestMinerAliasRules(t *testing.T) { 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 @@ -259,7 +275,7 @@ func TestMinerEmissionFilters(t *testing.T) { {"fragment", ScoredCand{Src: normalizeSourceKey("方源心"), Types: []string{"name"}, Freq: 9}, false}, } for _, tc := range cases { - if got := emissionEligible(tc.c, subsumed, seeds); got != tc.want { + if got := emissionEligible(tc.c, subsumed, seeds, pack); got != tc.want { t.Errorf("%s: emissionEligible = %v, want %v", tc.name, got, tc.want) } } @@ -269,8 +285,8 @@ 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) - b := MineBank(chunks, smallContrast(t), seed, cfg) + 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) } diff --git a/backend/internal/pipeline/seedlint_test.go b/backend/internal/pipeline/seedlint_test.go index 86d6c6e..5d98bdd 100644 --- a/backend/internal/pipeline/seedlint_test.go +++ b/backend/internal/pipeline/seedlint_test.go @@ -54,7 +54,7 @@ func TestSeedLintEmittedMinedDelta(t *testing.T) { // the W1.5 reseed. chunks := mchunks(strings.Repeat("龙公来到青茅山。龙公很强。青茅山很高。", 4)) seed := []store.GlossaryEntry{{Src: "青茅山", Dst: "гора Цинмао", Type: "place", Status: "approved", Source: "seed"}} - mined := MineBank(chunks, smallContrast(t), seed, frozenMinerConfig()) + mined := MineBank(chunks, smallContrast(t), seed, frozenMinerConfig(), testLangPack(t)) yamlStr, err := MinedDeltaYAML(mined) if err != nil { t.Fatal(err) diff --git a/backend/internal/pipeline/snapshot.go b/backend/internal/pipeline/snapshot.go index 60c4b3a..21c6bcd 100644 --- a/backend/internal/pipeline/snapshot.go +++ b/backend/internal/pipeline/snapshot.go @@ -111,6 +111,12 @@ func (r *Runner) sanitizerSnapshot() sanitizerSnap { 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). @@ -118,7 +124,7 @@ func (r *Runner) banknoteSnapshot() *banknoteSnap { if !r.Pipeline.Gates.Banknote.Enabled { return nil } - return &banknoteSnap{Enabled: true, ParserVersion: bankParserVersion} + return &banknoteSnap{Enabled: true, ParserVersion: bankParserVersion, TokenBudget: bankTokenBudget} } // memoryVersion is the content-hash of the deterministically materialized injected diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 6530f89..17f4082 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -9,6 +9,10 @@ > - **Ждём от владельца:** запуск exp15 (бюджет ≤$15 подтверждён) · research/20 §E: мини-голд алиасов (~20–30 мин) / бюджет ≤$5 полигон-стадии / политика канона / жанр-паки / реплика ja→ru · чтение пере-прогона (после очереди) · старые висящие: планка запуска (лучше-фана/гибрид/издательский) · билингв-якорь пилота (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.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//*.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 → пере-прогон → чтение. diff --git a/docs/README.md b/docs/README.md index f845959..b87f8c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ - `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/` — фактура исследований 04–05.07: `01–10` базовые, `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.15, 19.07):** [`BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md`](BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md) (**СТАРТОВЫЙ — промежуточный арх-проход: изоляция языковых данных из pipeline/, ПЕРЕД R1, D39.15**) · [`BACKEND_PLAN11_SESSION_PROMPT.md`](BACKEND_PLAN11_SESSION_PROMPT.md) (**R1-продолжение по плану [`11-implementation-plan.md`](architecture/11-implementation-plan.md) — ПОСЛЕ арх-прохода; D39.12/14**) · [`ORCHESTRATOR_SESSION_PROMPT.md`](ORCHESTRATOR_SESSION_PROMPT.md) (хендофф №5 оркестратора) · [`POLYGON_PACKAGE4_SESSION_PROMPT.md`](POLYGON_PACKAGE4_SESSION_PROMPT.md) (residual пилот/18+/echo). Закрытые — в `archive/prompts/` (свежий: дизайн-синтез→D39.12). Закрытые — в `archive/prompts/` (свежие: exp16→D39.10, Q4a→D39.9, exp15→D39.7, банк-майнинг-ресёрч→D39.6). +- **Активные хендофф-промты (пост-D39.16, 19.07):** [`BACKEND_PLAN11_SESSION_PROMPT.md`](BACKEND_PLAN11_SESSION_PROMPT.md) (**СТАРТОВЫЙ — R1-продолжение [драйвер-свитч + FL-фиксы + фолд `pack.Version()`/загрузка langpack при wiring майнера] по плану [`11-implementation-plan.md`](architecture/11-implementation-plan.md); D39.12/14/16**) · [`ORCHESTRATOR_SESSION_PROMPT.md`](ORCHESTRATOR_SESSION_PROMPT.md) (хендофф №5 оркестратора) · [`POLYGON_PACKAGE4_SESSION_PROMPT.md`](POLYGON_PACKAGE4_SESSION_PROMPT.md) (residual пилот/18+/echo). Закрытые — в `archive/prompts/` (свежий: дизайн-синтез→D39.12). Закрытые — в `archive/prompts/` (свежие: exp16→D39.10, Q4a→D39.9, exp15→D39.7, банк-майнинг-ресёрч→D39.6). - `archive/` — закрытые сессионные промты (только история, инструкции оттуда не исполнять). ## Статус (2026-07-12, пост-D38.1 · консолидация D38.2) diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index f2f79f7..4264ede 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -3,7 +3,7 @@ > **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто: > - **Полностью superseded:** **D1 (моно-редактор) → D17 → D30.1 (редактор БИЛИНГВ)** · D9 (ja-приёмка) → D18 (蛊真人 zh→ru; ja — второй прогон) · D17 → D30.1 · скобка D19.4 «ключ без data-sharing» и D20.3 «чистый ключ = блокер пилота» → **D27** (единый ключ С data-sharing, отключение перед продом) · exp04-дефолт «editor grok-4.3» (D3) → D30.1 (grok reasoning-off из редакторских ролей СНЯТ — no-op; кандидат glm-5-билингв; gemini — премиум-эскалация только за санитайзером D30.3). > - **Частично амендировано:** D3 (канал B → D14.1/D19.1 + оговорки D22.5/D22.6; апекс-слаг `-preview` — D22.4; draft под вопросом exp13 — D30.6) · D6 («off/minimal для draft/edit» эродирован: draft thinking-ON принудительно, editor-off снят D30.1; живы per-роль принцип и D6.2-буфер) · D10 (+D24.2 alias-слепое пятно, истинная консистентность ≈99.5%) · D11-числа → exp08 → **D30.4** (флип D1 = +15–25%, ~$0.85/ранобэ; «$1.5–2» = неподписанная опция Б) · D12 (+D24.3 флоры max_tokens; +D29.1в rollup — амендмент вердикт-правила) · D13.1-арм из решающего → ПОДТВЕРЖДАЮЩИЙ (D30.1); +D25.3 prefix-анализ судьи · D14.2 закрыт D19.1/D22.4; D14.4 закрыт D22.1 · D15.3 исполнен; спека D15.2: v2→v3→v3.1 (D22.2), реализация = текущий пакет (D30.9) · D24.4 +D28.1 (precision/recall-трейдофф; hard-gate требует пере-замера) · порядок D24.5 отложен D26.1 (этап B — после читаемых 25 глав). -> - **Живое ядро без изменений:** D2 (+новый гейт-класс D30.3), D4, D5, D7, D8, D10-механизм, D12-таксономия отказов, D16, D18, D19.1–19.2, D21 (Ф2-механизмы voice/address/reveal), D22.5–22.7, D23 (golden = инвариант №8), D24–D39.15 — действующая голова контракта (**D39.15 = директива владельца: изоляция языковых данных из `pipeline/` [движок общий · `internal/lang/`+`configs/langpacks/` данными-файлами · `x/text/language.Tag` вместо `isRuTarget` · снапшот-folded как промпты · алгоритмы не трогать]; архитектурный проход ПЕРЕД R1 [ре-секвенс]; поведенчески нейтрально zh→ru [golden байт-стабилен]; не минор-хант/не rewrite-ради-rewrite; промт `BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md` 5-фазный [ресёрч→синтез→фикс-лист→исполнение→ревью]; ja→ru = тест общности §B5 позже**; **D39.14 = пак-11 continuation ПРИНЯТ и залендён [R2 майнер паритет-EXACT · R3 банкнота 12-точек · R4 DC-чекеры+export--pairs · R5 арм-конфиги · R1-аддитив; рубеж-2 D39.13-долга погашен: 3 MINOR/PARTIAL-находки [FL-1 bankTokenBudget-фолд · FL-2 banknote-телеметрия-резюм · FL-3 emission-top-N-кап], все латентны → R1-продолжение; R1 драйвер-свитч резидуал; clean bill по деньгам/wire/порту]**; **D39.13 = пак-11 Block A ПРИНЯТ и залендён [WS2 целиком (вкл. src→dst-блок редактора = закрытие D30.1) + WS1a/b/c-инфра + WS4-ядро; 2 девиации ратифицированы; резидуал R1–R5 в PACK11_REPORT; промт остаётся активным; рубеж-2 приёмки — долг]**; **D39.12 = план `architecture/11-implementation-plan.md` РАТИФИЦИРОВАН [три рубежа: ревью-2 F1–F11 → 7-агентный адверсариал → подтверждающий пас; пер-волновой снапшот + инвариант секвенирования = «переоплата ОДНА»; EditCeilingOut=3200; лемматизатор дефолт B; 4 гейтнутых арма]; бэкенд-пак ЕДИНОЙ сессией — `BACKEND_PLAN11_SESSION_PROMPT.md`**; **D39.11 = директива владельца: стройка ТОЛЬКО после дизайн-синтез сессии с верифицированными алгоритмами; хендофф №5**; **D39.10 = exp16: WHICH подтверждена [V-C recall 0.97, $0] / WHAT→банкнота+Палладий [canon-recovery <70%], 9B-споттер не нужен, Z1→облако, ре-аудит exp14b чист [errata не требуется]; исследовательская программа ЗАВЕРШЕНА → бэкенд-пак слоёв 1+4**; **D39.9 = Q4a: верность НЕ на translate-стадии [flash уже верен, pro ×3.67 не ратифицирован], смысл ломает glm-РЕДАКТОР [свап-арм приоритетен], DET-пол 0.261; exp14b-правила дефектны → $0-ре-аудит в exp16**; **D39.8 = слепое чтение: планка не пройдена никем, дефекты распределены [читатель подтвердил floor-null], битва качества → дефект-классы [时辰-юниты, числа, gender-enforce, стих, регистр]**; **D39.7 = exp15 залендён REV.2: «граница вредит» отозван [артефакт окна судьи], все когезия-эффекты под floor 0.126, «sequential нигде не лучше» LOO-stable [не ратифицированный зелёный свет], фертильность 1.20/0.39, $12.79/$15; гейт = слепое чтение владельца**; **D39.6 = research/20 банк-майнинг залендён [детектор-лестница V-A/B/C, алиас-ярусы, banknote-v1 с 12 гейт-точками, §B5 плагины P1–P6, §D-пре-рег]; полигон-стадия после exp15**; **D39.5 = пак-1.5+F1–F9 залендены [tmctl export с manifest/drift-гардами · fold-first санитайзер · bounded глосс · пример zh-ru]; экстракция полигона впредь через tmctl export**; **D39.4 = адверсариал-долг ПОГАШЕН [8 осей: 0 CRIT/1 HIGH/6 MED], пак-1.5 придержан под фикс-лист F1–F9 в §T4**; **D39.3 = пакет банк-майнинга W1.5 [диспозиция H15/V4-п4] ратифицирован, research/20-промт с HOLD до фриза exp15**; **D39.2 = трек-A пак-1 залендён `d3f6b34`: trust-gated suppressor [терм-дрейф закрыт в КОДЕ] + export-contract нормализация + pair-сеам + реестры + quality-report; адверсариал-долг за 529**; **D39.1 = research/19 залендён: конфликт exp14↔research/18 разрешён [эмиссия≠окно≠связность], Go-спека чанкера+когезии, волновая архитектура W0–W3 [вводная владельца: параллелизм], пре-рег эмпирики Q0–Q5; оркестратор-ревью снято владельцем**; **D39 = АРХ-РЕСЕТ: синк-аудит сломанного телефона [65 находок, 0 refuted, `08-sync-audit-ledger.md`] + целевая 7-слойная архитектура [`09-target-architecture.md`] + фазовый план [трек A build-now ∥ трек B ресёрч, пере-прогон после]; формализует фантомный «D35.7» как цель слоя 1; терм-дрейф код-корень жив [сид-воркэраунд]; редактор 4-мандата-full-regen = причина инверсий**; D35 = пивот качество-первым; D36 = приёмка research/18; D37 = приёмка exp14: претензия-1=промпт-рычаг; D38 = приёмка exp14b: «только gpt-5.4» ОПРОВЕРГНУТО [mistral/deepseek-pro чинят], фронтир в дефолт НЕ нужен, корень терм-дрейфа = статус-draft сида; **D38.1 = слепой h2h P1a↔F-disc залендён [ChatGPT «фронтир сильнее» / Claude «near-parity» расходятся, СХОДЯТСЯ: дефекты = инфра] → РАЗВОРОТ «мерить→строить», выдан BACKEND_INFRA_PACK; D38.2 = консолидация/док-гигиена: спент exp-скрипты в `eval/exp12|13|14|14b/`, статус-доки+.puml актуализированы; D38.3 = инфра-пак читаемости залендён (санитайзер-классы strip+export + число/omission-гард), верифицирован исполнением [build/vet/test зелёные, 3 span-дефекта запинены]; D38.4 = аудит полноты exp14/14b: чэнъюй-рычаг забыт near-term→дописан в дискурс-промпт; inversion-гард D37 §2г не строился (число/omission ≠ полярность)→editor-swap = защита от инверсий (приоритет↑); нарезка = re-run-арм; гард-тумблер вкл. при resnapshot; D38.5 = компоненты resnapshot залендены+верифицированы [reseed-сид eb409f2 · дискурс+чэнъюй editor v3 0ac5f7a с few_shot-тумблером] → следующий шаг единый resnapshot + пере-прогон**). +> - **Живое ядро без изменений:** D2 (+новый гейт-класс D30.3), D4, D5, D7, D8, D10-механизм, D12-таксономия отказов, D16, D18, D19.1–19.2, D21 (Ф2-механизмы voice/address/reveal), D22.5–22.7, D23 (golden = инвариант №8), D24–D39.16 — действующая голова контракта (**D39.16 = арх-проход ЗАЛЕНДЕН: майнер-данные (百家姓/Палладий/частицы/суффиксы) вынесены в `internal/lang`+`configs/langpacks/` [граница компиль-enforced, байт-точно — парити EXACT, golden байт-идентичен]; FL-1/2/3+тест-путь; РАТИФИЦИРОВАНЫ 3 дивергенции: language.Tag ОТВЕРГНУТ [CLDR-инстабильность в вердиктах], DC-чекеры+снапшот-фолд DEFER [майнер offline/нейтральность]; общность структурно доказана; промт отработан→архив**; **D39.15 = директива владельца: изоляция языковых данных из `pipeline/` [движок общий · `internal/lang/`+`configs/langpacks/` данными-файлами · `x/text/language.Tag` вместо `isRuTarget` · снапшот-folded как промпты · алгоритмы не трогать]; архитектурный проход ПЕРЕД R1 [ре-секвенс]; поведенчески нейтрально zh→ru [golden байт-стабилен]; не минор-хант/не rewrite-ради-rewrite; промт `BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md` 5-фазный [ресёрч→синтез→фикс-лист→исполнение→ревью]; ja→ru = тест общности §B5 позже**; **D39.14 = пак-11 continuation ПРИНЯТ и залендён [R2 майнер паритет-EXACT · R3 банкнота 12-точек · R4 DC-чекеры+export--pairs · R5 арм-конфиги · R1-аддитив; рубеж-2 D39.13-долга погашен: 3 MINOR/PARTIAL-находки [FL-1 bankTokenBudget-фолд · FL-2 banknote-телеметрия-резюм · FL-3 emission-top-N-кап], все латентны → R1-продолжение; R1 драйвер-свитч резидуал; clean bill по деньгам/wire/порту]**; **D39.13 = пак-11 Block A ПРИНЯТ и залендён [WS2 целиком (вкл. src→dst-блок редактора = закрытие D30.1) + WS1a/b/c-инфра + WS4-ядро; 2 девиации ратифицированы; резидуал R1–R5 в PACK11_REPORT; промт остаётся активным; рубеж-2 приёмки — долг]**; **D39.12 = план `architecture/11-implementation-plan.md` РАТИФИЦИРОВАН [три рубежа: ревью-2 F1–F11 → 7-агентный адверсариал → подтверждающий пас; пер-волновой снапшот + инвариант секвенирования = «переоплата ОДНА»; EditCeilingOut=3200; лемматизатор дефолт B; 4 гейтнутых арма]; бэкенд-пак ЕДИНОЙ сессией — `BACKEND_PLAN11_SESSION_PROMPT.md`**; **D39.11 = директива владельца: стройка ТОЛЬКО после дизайн-синтез сессии с верифицированными алгоритмами; хендофф №5**; **D39.10 = exp16: WHICH подтверждена [V-C recall 0.97, $0] / WHAT→банкнота+Палладий [canon-recovery <70%], 9B-споттер не нужен, Z1→облако, ре-аудит exp14b чист [errata не требуется]; исследовательская программа ЗАВЕРШЕНА → бэкенд-пак слоёв 1+4**; **D39.9 = Q4a: верность НЕ на translate-стадии [flash уже верен, pro ×3.67 не ратифицирован], смысл ломает glm-РЕДАКТОР [свап-арм приоритетен], DET-пол 0.261; exp14b-правила дефектны → $0-ре-аудит в exp16**; **D39.8 = слепое чтение: планка не пройдена никем, дефекты распределены [читатель подтвердил floor-null], битва качества → дефект-классы [时辰-юниты, числа, gender-enforce, стих, регистр]**; **D39.7 = exp15 залендён REV.2: «граница вредит» отозван [артефакт окна судьи], все когезия-эффекты под floor 0.126, «sequential нигде не лучше» LOO-stable [не ратифицированный зелёный свет], фертильность 1.20/0.39, $12.79/$15; гейт = слепое чтение владельца**; **D39.6 = research/20 банк-майнинг залендён [детектор-лестница V-A/B/C, алиас-ярусы, banknote-v1 с 12 гейт-точками, §B5 плагины P1–P6, §D-пре-рег]; полигон-стадия после exp15**; **D39.5 = пак-1.5+F1–F9 залендены [tmctl export с manifest/drift-гардами · fold-first санитайзер · bounded глосс · пример zh-ru]; экстракция полигона впредь через tmctl export**; **D39.4 = адверсариал-долг ПОГАШЕН [8 осей: 0 CRIT/1 HIGH/6 MED], пак-1.5 придержан под фикс-лист F1–F9 в §T4**; **D39.3 = пакет банк-майнинга W1.5 [диспозиция H15/V4-п4] ратифицирован, research/20-промт с HOLD до фриза exp15**; **D39.2 = трек-A пак-1 залендён `d3f6b34`: trust-gated suppressor [терм-дрейф закрыт в КОДЕ] + export-contract нормализация + pair-сеам + реестры + quality-report; адверсариал-долг за 529**; **D39.1 = research/19 залендён: конфликт exp14↔research/18 разрешён [эмиссия≠окно≠связность], Go-спека чанкера+когезии, волновая архитектура W0–W3 [вводная владельца: параллелизм], пре-рег эмпирики Q0–Q5; оркестратор-ревью снято владельцем**; **D39 = АРХ-РЕСЕТ: синк-аудит сломанного телефона [65 находок, 0 refuted, `08-sync-audit-ledger.md`] + целевая 7-слойная архитектура [`09-target-architecture.md`] + фазовый план [трек A build-now ∥ трек B ресёрч, пере-прогон после]; формализует фантомный «D35.7» как цель слоя 1; терм-дрейф код-корень жив [сид-воркэраунд]; редактор 4-мандата-full-regen = причина инверсий**; D35 = пивот качество-первым; D36 = приёмка research/18; D37 = приёмка exp14: претензия-1=промпт-рычаг; D38 = приёмка exp14b: «только gpt-5.4» ОПРОВЕРГНУТО [mistral/deepseek-pro чинят], фронтир в дефолт НЕ нужен, корень терм-дрейфа = статус-draft сида; **D38.1 = слепой h2h P1a↔F-disc залендён [ChatGPT «фронтир сильнее» / Claude «near-parity» расходятся, СХОДЯТСЯ: дефекты = инфра] → РАЗВОРОТ «мерить→строить», выдан BACKEND_INFRA_PACK; D38.2 = консолидация/док-гигиена: спент exp-скрипты в `eval/exp12|13|14|14b/`, статус-доки+.puml актуализированы; D38.3 = инфра-пак читаемости залендён (санитайзер-классы strip+export + число/omission-гард), верифицирован исполнением [build/vet/test зелёные, 3 span-дефекта запинены]; D38.4 = аудит полноты exp14/14b: чэнъюй-рычаг забыт near-term→дописан в дискурс-промпт; inversion-гард D37 §2г не строился (число/omission ≠ полярность)→editor-swap = защита от инверсий (приоритет↑); нарезка = re-run-арм; гард-тумблер вкл. при resnapshot; D38.5 = компоненты resnapshot залендены+верифицированы [reseed-сид eb409f2 · дискурс+чэнъюй editor v3 0ac5f7a с few_shot-тумблером] → следующий шаг единый resnapshot + пере-прогон**). Ответ на вопросы бэкенд-сессии (PROGRESS §«Вопросы от бэкенда», §«[НУЖНО РЕШЕНИЕ] перед Фазой 1» — с ревизии D31 в `archive/PROGRESS-2026-07-04-10.md`) и полигона (think-режим). Каждое решение прошло адверсариальную панель из 3 критиков (research / экономика / исполнимость в коде Фазы 0); ни одно не отклонено, все уточнены. Это **контракт Фазы 1** — бэкенд исполняет отсюда; при конфликте с буквой 01-decisions/02-mvp-plan — источник истины здесь (потом сольём в основные доки). @@ -550,6 +550,20 @@ exp14b (D37-мандат) прогнал батарею DET-смысл-трап **ИТОГ: все 3 компонента resnapshot ГОТОВЫ** — дискурс+чэнъюй editor (v3) · reseed-сид · инфра-пак (D38.3). **Следующий шаг = единый resnapshot** (D30.9, одна переоплата книги) + пере-прогон 3–10 глав: конфиг = glm=P1a-few-shot (дефолт) + армы {mistral few-shot · deepseek-pro `few_shot:false` = инверсия-защита D38.4 · крупная edit-единица = нарезка-арм D38.4} + `RegressionGuard` ON (D38.4). ПАРНО с чтением владельца (интерим-планка 2 претензии, D35). Крупная edit-единица — НЕ реализована (нарезка-арм, не промпт-порт). **[⟶ ПРИОСТАНОВЛЕНО D39: владелец остановил раунд для арх-ресета; пере-прогон гейтится треком B, см. D39.]** +## D39.16 — Приёмка арх-прохода D39.15 ЗАЛЕНДЕНА: язык-данные майнера вынесены в `internal/lang`+`configs/langpacks`, байт-точно (парити EXACT); три дивергенции от предложения оркестратора ратифицированы (19.07, оркестратор №7). ✅ + +Бэкенд-сессия исполнила `BACKEND_ARCH_CLEANUP_SESSION_PROMPT` (5 фаз-воркфлоу: ресёрч 2×3 → синтез → фикс-лист do/defer/don't → исполнение → ревью). **Залендено:** zh/Палладий-данные майнера (百家姓 single/compound, титул/топо/rank-инвентари, частицы `aliasParticle`, таблица Палладия) вынесены из Go-констант `internal/pipeline/` в **`configs/langpacks/zh|zh-ru/` (10 файлов)**, грузятся новым leaf-пакетом **`internal/lang`** (данные+загрузчик+`Version()` content-hash; **НЕ импортит pipeline → граница компиль-enforced**); движок читает резолвнутый `*lang.Pack` через DI (как `contrast`). Плюс риды-along: **FL-1** (`bankTokenBudget`→`banknoteSnap.TokenBudget`), **FL-2** (банкнота-телеметрия репопулируется на резюме из `prev.NBanknote*`, guard `FromResume && Enabled`), **FL-3** (`emitRankCap=200` перед emission-фильтрами, зеркало `a3[:200]`), тест-путь (env-override `TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}`, skip-if-absent). + +**Приёмка — ПРЯМАЯ верификация исполнением:** `go build/vet/test -race` зелёные вкл. новый `internal/lang` (сам); **golden БАЙТ-ИДЕНТИЧЕН** (нулевой дифф — сильнее допущенного «одно version-поле»; поведенческая нейтральность механически доказана); **байт-точность перемещения ДОКАЗАНА парити-тестом** — константы УДАЛЕНЫ (грепом нет), майнер читает langpack через DI, `TM_MINER_PARITY=1` даёт EXACT `n=13618 {方源:0,蛊:1,蛊师:2,古月:22} recall 0.9649` против замороженного Python-референса (сдвинься хоть руна данных — набор/ранги поехали бы; ручной регекс-парсинг констант ненадёжен из-за соседних многострочных инвентарей — парити строже); граница `internal/lang`↛`pipeline` подтверждена грепом; FL-1/2/3+тест-путь спот-верифицированы построчно. Сессия сама прогнала Phase-5 ревью (3 минора пофикшены). + +**Ратифицирую три дивергенции от стартового предложения оркестратора (пресс-тест сработал — сессия опровергла даже МОЙ буллет с доказательством):** +1. **`isRuTarget`/`isCJKTarget` → `x/text/language.Tag`: ОТВЕРГНУТО (держим точные строковые предикаты).** Ресёрч-опровержение: `language.Tag`-матч тянет CLDR «likely-subtags» (документированно «subject to change» — golang/go#24211); 6/18 расхождений; **CLDR-инстабильность попадала бы в снапшот-ВЕРДИКТЫ** (`isRuTarget` гейтит санитайзер → экспортный FinalText) → недетерминизм байт-воспроизводимости при апгрейде `x/text`. Мой стартовый буллет был наивен; отклонение ВЕРНО (детерминизм > идиоматика в вердикт-пути). +2. **Живые DC-чекеры (`checkers_zh_ru` 时辰/числительные/регистр — назван владельцем) в langpack: DEFER до ja→ru-реплики.** Они бегут ПАРА-АГНОСТИЧНО сегодня → пара-кеинг СЕЙЧАС = живая смена поведения (нарушает мандат нейтральности); `translitInterjections` (ара-ара/маа) — ЯПОНСКИЕ филлеры, мис-кей-ловушка. Два ресёрч-агента сошлись независимо. Поедут по тому же сеаму, когда реальная 2-я пара сделает их src/tgt/pair-ось тестируемой. +3. **Снапшот-фолд `pack.Version()`: DEFER до R1.** Майнер OFFLINE → его данные не wire/вердикт-определяющи → фолд СЕЙЧАС = преждевременный resnapshot (инвариант №2). `pack.Version()` построен и готов; R1 фолдит его при wiring майнера в живой W1.5 (один resnapshot тогда). Резкое и верное наблюдение (мой буллет «folded как промпты» не учёл, что майнер ещё не живой). +Плюс честные DEFER-2 (`sourceAbbrevs` — 28 англ-аббревиатур в chunker, единственный per-source инвентарь движка) / DEFER-4 (`isCJK/RuTarget` как langpack-атрибут — если реестр расширится). Дисциплина «не упарываться на миноры / не rewrite-ради-rewrite» соблюдена: один оправданный рефактор + реальные FL-фиксы, косметика не плодилась. + +**Общность доказана структурно:** синтетик-пара роутится через сеам, fail-loud на неизвестный/пустой пак. Полный тест §B5 — ja→ru-реплика (когда исполнится, DEFER-1 чекеры едут туда же). **Честная калибровка:** мульти-агентный рубеж-2 на этой приёмке не гонялся — golden байт-идентичен (механический пруф нейтральности) + парити EXACT (пруф байт-точности данных) делают correctness-риск рефактора близким к нулю; прямая верификация (граница/байт-точность/фиксы/дивергенции) пропорциональна. Промт арх-прохода ОТРАБОТАН → архив с баннером. **Очередь:** R1-продолжение (`BACKEND_PLAN11_SESSION_PROMPT.md` активен; R1 фолдит `pack.Version()` + грузит пак в раннере при wiring майнера) → resnapshot → пере-прогон → чтение. + ## D39.15 — Директива владельца: изоляция языковых данных из `pipeline/` (архитектурный проход ПЕРЕД R1); промт выдан (19.07, оркестратор №7). ✅ **Триггер:** владелец при обзоре кода нашёл zh/ru-вставки внутри `internal/pipeline/` (百家姓 `surnamesSingleRaw`, таблица Палладия, `aliasParticle` 的了在是…, `checkers_zh_ru` 时辰/числительные/регистр, `isRuTarget` ручной). **Разбор оркестратора (по коду, честно):** §0.1-сеам РЕАЛЕН и соблюдён — язык-логика за централизованными гейтами (`isRuTarget`/`isCJKTarget` `disposition.go:294,307`; pair-keyed промпты `PromptPathFor` fail-loud; движок параметризован Source/TargetLang; фертильность CJK/other = per-pair конфиг, вырождается корректно на не-CJK; майнер не подключён в прод — R1-резидуал). en→es СЕГОДНЯ = **fail-loud на резолве промпта** (не крэш, не тихая порча). Хардкод-пути — ТОЛЬКО в `miner_parity_test.go` (тест, data-gated). `clusterAlias` — корректный union-find (не «мусор из циклов»). **НО настоящий зазор подтверждён:** язык-данные зашиты Go-КОНСТАНТАМИ внутри `pipeline/` (добавить пару = перекомпиляция), а §0.1-идеал = данные-файлом; `golang.org/x/text` (v0.38 уже в deps) недоиспользован (`language.Tag` не применён). diff --git a/docs/BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md b/docs/archive/prompts/BACKEND_ARCH_CLEANUP_SESSION_PROMPT_2026-07-19.md similarity index 100% rename from docs/BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md rename to docs/archive/prompts/BACKEND_ARCH_CLEANUP_SESSION_PROMPT_2026-07-19.md