Land the language-data isolation arch pass (D39.16): move miner zh/Palladius data to internal/lang and configs/langpacks byte-exactly, land FL-1/2/3, ratify the three press-tested divergences, archive the worked prompt

This commit is contained in:
Claude (backend session) 2026-07-19 17:47:14 +03:00
parent 3798643740
commit 91c400c8ae
28 changed files with 961 additions and 164 deletions

View file

@ -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-я реализация ПОВЕДЕНИЯ — интерфейс P1P6 = преждевременно), **без auto-discovery-реестра** (конфиг-путь
достаточно), тонкое зеркало **существующего промпт-сеама** (`Stage.Prompts`+`PromptPathFor`+`PromptSHA256`, резолв по
`Book.LangPair()`, fail-loud).
- **Внешние файлы, НЕ `go:embed`** — решает центральный пресс-тест: владелец ратифицировал «положить каталог, без
перекомпиляции»; `go:embed` = compile-time. Прецедент промпт-сеама доказывает: проект принимает внешние core-данные с
контент-хешем + fail-loud.
- **`minerPackVersion` СЕЙЧАС НЕ фолдится в снапшот** (майнер офлайн — безвредно; A-miner/B-seam: фолдить контент-хеш,
когда майнер станет живым). См. решение по фолду ниже (§ Снапшот).
- **Move-vs-stay леджер (2 независимых агента СОШЛИСЬ):** двигать ТОЛЬКО офлайн-майнер-данные; живые DC-чекеры оставить
(см. DEFER). Ключевая находка ревью: `translitInterjections` (ара-ара/маа/нани) — ЯПОНСКИЕ филлеры; пара-кеинг к `zh-ru`
СТЁР бы детекцию ja-филлеров из ja→ru (пара самого golden). `refusalPatterns` — кросс-каттинг мультиязычная safety, НЕ
двигать. Генеративити-агент (лайвнесс-рамка) пришёл к тому же независимо.
---
## Фаза 2 — СИНТЕЗ (реконсиляция ресёрч + оркестратор + владелец + фикс-лист)
**Консенсус (ресёрч ⋂ оркестратор ⋂ владелец):**
- Движок общий; язык-данные — файлами вне `pipeline/`; новый `internal/lang` (данные+загрузчик, БЕЗ данных-в-коде);
резолв по паре; снапшот-folded как промпты; правильные алгоритмы (union-find/сортировки) не трогать.
**Спорное → разрешено ресёрчем (пресс-тест сработал — отвергли 2 стартовых буллета):**
1. **`x/text/language.Tag` вместо `isRuTarget`** (буллет оркестратора И владельца) → **ОТВЕРГНУТО** эмпирикой: 6/18
расхождений + CLDR-инстабильность в снапшот-вердиктах + удар по экспорту FinalText. Держим точные предикаты. Это НЕ
уклонение — фаза пресс-теста ровно для этого (промт: «ПРЕСС-ТЕСТ ресёрчем, не имплементить слепо»).
2. **Двигать живые DC-чекеры (`checkers_zh_ru` 时辰/числительные/регистр — назван владельцем)****DEFER**: они бегут
ПАРА-АГНОСТИЧНО сегодня (регистр/интерджекции флагают ru-выход независимо от источника, `chunkrun.go:203-204`), значит
пара-кеинг = ЖИВАЯ смена поведения; а фолд их хеша в `cheapGateVersion` вынуждает golden re-capture. 2 независимых
агента сошлись на отсрочке к ja→ru-реплике (уже увязано D39.14). Механизм `internal/lang` они унаследуют.
**Ранжирование по ценности×риску:** (1) майнер-данные — высокая ценность (百家姓/Палладий = парадигм-нарушители, названы
владельцем ПЕРВЫМИ), низкий риск (офлайн, байт-нейтрально). (2) FL-1/2/3 + тест-путь — реальные, латентные. (3) DC-чекеры —
средняя ценность, средний риск (живой путь, observability-only) → DEFER.
---
## Фаза 3 — ФИКС-ЛИСТ с диспозициями
### DO (сделано этой сессией)
| # | Пункт | Что | Нейтральность |
|---|---|---|---|
| DO-1 | **`internal/lang` сеам** | leaf-пакет: `Pack` + `Load(root,src,tgt)` + контент-хеш `Version()`, БЕЗ импорта pipeline; резолв `configs/langpacks/<src>/` (морфология) + `<src>-<tgt>/` (Палладий); fail-loud на отсутствующий файл | — |
| DO-2 | **Майнер-данные → файлы** | 百家姓 single/compound, title/ordinal/topo/grade/numeral/rank, alias-particle → `configs/langpacks/zh/`; Палладий (initials/finals/yw/special_i) → `configs/langpacks/zh-ru/`. Сгенерированы ИЗ живых констант (байт-точно), затем консты удалены | Майнер офлайн → golden не задет; паритет EXACT |
| DO-3 | **Прокид `*lang.Pack`** | DI (как `contrast`): `MineBank/mineDetect/patternCandidates/isSurnameStart/formantType/proposeAliasEdges/isFragment/emissionEligible/isPalladiusToken/buildPalladiusCyrSyllables` читают pack. Алгоритмы В КОДЕ, данные в файлах (граница compile-enforced) | Байт-равные значения |
| DO-4 | **Генеративити-пруф** | `internal/lang/langpack_test.go`: real-load zh-ru + синтетическая 2-я пара → другие байты/версия + fail-loud на неизвестную пару | — |
| DO-5 | **FL-1** | Свернуть `bankTokenBudget` в `banknoteSnap.TokenBudget` (`snapshot.go`) — правка `bankMaxLines` меняет max_tokens∈request_hash без бампа parser_version = тихий re-bill; теперь громкий resnapshot механизмом | golden: banknote OFF → nil → omitempty |
| DO-6 | **FL-2** | `chunkrun.go`: на резюме СОХРАНЯТЬ banknote-телеметрию (read `GetRetrievalState` при resume+enabled) вместо обнуления; ложный коммент «re-derives» исправлен на правду | golden: banknote OFF → 0 |
| DO-7 | **FL-3** | `miner_emit.go`: `emitRankCap=200` кап `mr.ranked` ПЕРЕД emission-фильтрами (зеркало референса `a3[:200]`) | WHICH-инварианты на `mr.ranked` (до эмиссии) не задеты |
| DO-8 | **Тест-путь** | `miner_parity_test.go`: абсолютные `/home/ubuntu/...` → env-override `TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}` (skip-on-absent сохранён) | тест-гигиена, не прод |
### DEFER (в резидуал, с причиной)
- **DEFER-1: живые DC-чекеры/cheapgates данные** (时辰/числительные/регистр/интерджекции). Причина: (а) бегут ПАРА-АГНОСТИЧНО
сегодня → пара-кеинг = живая смена поведения (напр. ja→ru потерял бы DC6/интерджекции); (б) фолд их хеша в `cheapGateVersion`
вынуждает golden re-capture, а не-фолд ослабляет drift-proofing; (в) `translitInterjections` = ja-филлеры (mis-key trap).
Наследуют ТОТ ЖЕ `internal/lang`-сеам на задаче ja→ru-реплики, где ось источник/цель/пара конкретна и re-capture намеренный
(увязано D39.14). Честит «известные фиксы + не над-инженерия».
- **DEFER-2: `sourceAbbrevs`** (28 англ. аббревиатур в `chunker.go`) — единственный per-source-lang инвентарь в движке, но
маргинален (только en-источник, вне zh/ja→ru приёмки), free-rider если появится en-пакет.
- **DEFER-3: снапшот-фолд `pack.Version()`** — R1 (когда майнер станет живым). См. § Снапшот.
- **DEFER-4: `isCJKTarget/isRuTarget` membership как langpack-атрибут** — только если `internal/lang`-реестр расширится под
реальные корпуса; standalone = над-инженерия (A-lang F5). Резолв ОБЯЗАН остаться точным exact-match, не x/text-инференс.
### DON'T (переписывание-ради-переписывания — отвергнуто явно)
- **DON'T-1: `isRuTarget`/`isCJKTarget` → `x/text/language.Tag`** — ресёрч-опровергнуто (6/18, CLDR-инстабильность, удар по
FinalText). Держим точные предикаты.
- **DON'T-2: двигать `refusalPatterns`** (en/ru/zh/ja) — кросс-каттинг мультиязычная safety-блэклист, disposition-ось
(`classifierVersion`), модель может отказать на ЛЮБОМ языке независимо от пары. Пара-кеинг = semantically incoherent +
safety-регресс.
- **DON'T-3: `tokenClassCounts` CJK-класс / `yoHomographEForms` / `cjkNumeralRunes` / арифметика 万/億** — универсальные
Unicode/арифметические свойства, не per-pair конвенции.
- **DON'T-4: трогать алгоритмы** (union-find, детерм. сортировки, c-value, чанкер-сплиттер, регекс-ЛОГИКА чекеров).
- **DON'T-5: строить P1P6 интерфейсы / auto-discovery-реестр** — 2-я пара = данные, не поведение (нет 2-го потребителя
поведения). Данные-struct + загрузчик достаточно.
### Предложения сверх скоупа (в план/владельцу)
- Когда майнер станет живым (R1): фолдить `pack.Version()` в `snapshotID` (единый resnapshot тогда).
- ja→ru-реплика: перенести DC-чекеры/cheapgates данные в `internal/lang` через тот же сеам (source-scoped zh + target-scoped
ru + pair-scoped zh-ru), с намеренным golden re-capture — тогда ось источник/цель/пара тестируема реальной 2-й парой.
- (опц.) `sourceAbbrevs` как free-rider en-пакета, если en-источник войдёт в приёмку.
---
## Фаза 4 — ИСПОЛНЕНИЕ (лог)
**Порядок (низкий риск → высокий, верификация после каждого):**
1. FL-1/2/3 + тест-путь → build/vet/test зелёные, golden хеш НЕ изменился (`975fbfdc…`).
2. Данные-файлы сгенерированы **из живых констант** (одноразовый in-package генератор `TM_GEN_LANGPACK=1`, затем удалён) →
гарантия байт-точности; verified `reflect.DeepEqual(loaded, const)` для ВСЕХ 13 полей до удаления констант.
3. `internal/lang` пакет + загрузчик + контент-хеш (`langpack-v1-09974d6cdac2`).
4. Прокид `*lang.Pack` через майнер; удаление констант (`surnamesSingleRaw`/`buildRuneSet`/`buildRuneSet2`/инвентари/
Палладий-мапы/`palladiusCyrSyllables`-глобал); правка тестов (helper `testLangPack`).
5. Генеративити-пруф-тест.
**Верификация исполнением (мандат самопроверки CLAUDE.md):**
- `go build ./...` ✅ · `go vet ./...` ✅ (чисто) · `go test ./... -race`**все пакеты зелёные** (вкл. новый `internal/lang`).
- **Паритет майнера EXACT** (`TM_MINER_PARITY` присутствовали стенд-данные): `n=13618 · {方源:0,蛊:1,蛊师:2,古月:22} ·
recall@proposed=0.9649` — **байт-доказательство нейтральности** переноса данных.
- **Golden байт-идентичен:** `sha256(capture.golden)` = `975fbfdc701438eb8604e6e1a0ddb77842246cf88983178bf99fddcab596a03e`
ДО и ПОСЛЕ (не изменился — майнер офлайн, фолд не добавлен). НОЛЬ вердикт/wire/final-правок.
- Диффстат: 11 файлов правлено, +157/162 (код УМЕНЬШИЛСЯ — данные ушли в файлы); новые `configs/langpacks/` + `internal/lang/`.
### Снапшот — решение (эскалация оркестратору)
Фолд `pack.Version()` в `snapshotID` **НЕ добавлен** этой сессией. Обоснование (инвариант №2 vs гардрейл промта):
инвариант №2 = «фолдить то, что влияет на wire ИЛИ вердикты». Майнер **офлайн** (`MineBank` — только тест-вызовы; нет
runner/tmctl-потребителя) → его данные СЕЙЧАС не влияют ни на wire, ни на вердикты. Фолд офлайн-данных вызвал бы resnapshot
zh-ru-книг «за ничего» (wire идентичен) — ровно та переоплата, что дисциплина предотвращает, и ВТОРОЙ resnapshot к R1
(двойной). **Дисциплинарно-корректно: не фолдить сейчас; R1 (который делает майнер живым) фолдит `pack.Version()` тогда —
единый resnapshot.** `Version()` построен и готов. Golden байт-идентичен (строго ⊆ «до одного version-поля»). A-miner/
B-seam-агенты подтвердили «фолд когда майнер станет живым». **Флаг оркестратору:** гардрейл промта в скобках называет майнер
«wire-определяющим» — по факту офлайн; если оркестратор хочет фолд сейчас, это отдельное решение (golden всё равно нейтрален —
ja-ru, ja-пакета нет).
---
## Фаза 5 — РЕВЬЮ (мульти-линзовый адверсариал)
Воркфлоу `arch-cleanup-review`: 4 линзы (нейтральность / сеньор-код / функциональное-генеративити / FL-фиксы),
author≠reviewer, refute-by-default. **Итог: линза-1 (нейтральность) — ЧИСТО (0 находок); 3 MINOR (по одной на
линзы 2/3/4), 0 major/critical.** Ревью НЕ породило минор-лист — это ровно приёмочный формат. **Все 3 находки
впитаны (fix + верификация исполнением):**
- **R-1 (MINOR, линза-2 сеньор-код) — `parsePalladius` номер строки.** Ошибка репортила `line %d` по индексу
`contentLines` (после снятия комментов) → сдвиг на число ведущих коммент-строк (сейчас 1) → правящий файл человек
уходит не на ту строку. **Фикс:** `parsePalladius` сканирует сырые строки напрямую → физический номер файла.
- **R-2 (MINOR, линза-3 функциональное) — fail-loud только на ОТСУТСТВИЕ файла, не на пустоту.** Док обещал «never
silently empty», но пустой/коммент-только/усечённый файл давал пустую таблицу БЕЗ ошибки → на R1 (пакеты
ручные) опечатка-в-пустой `surnames-single.txt` тихо гасила surname-канал майнера (регресс recall без сигнала).
**Фикс:** `Pack.validate()` после загрузки — все required-таблицы non-empty, иначе fail-loud с именем пары+таблицы
(делает заявленный инвариант реальным, как memnorm-паника на corrupt-таблицу). + тест `TestFailsLoudOnEmptyTable`.
- **R-3 (MINOR, линза-4 FL-фиксы) — FL-2 без теста.** `TestBanknoteSliceReTelemetryResume` не пере-читал
retrieval-state ПОСЛЕ резюма → тест проходил С и БЕЗ FL-2 (coverage-gap, ровно класс мандата самопроверки CLAUDE.md).
**Фикс:** добавлен пост-резюм re-read + ассерт `NBanknoteLines==2`. **Верифицировано исполнением (revert-test-restore):
без FL-2 тест ПАДАЕТ (`NBanknoteLines:0`), с FL-2 — зелёный.** Теперь тест реально сторожит фикс.
После впитывания: `go build/vet` чисто; `go test ./... -race` **все зелёные**; golden хеш `975fbfdc…` НЕ изменился;
паритет EXACT (`13618 / {方源:0,蛊:1,蛊师:2,古月:22} / recall 0.9649`).
---
## Приёмка (для оркестратора)
- **Golden байт-стабильность:** хеш `975fbfdc…` НЕ изменился (даже сильнее «до одного version-поля» — НОЛЬ полей).
- **Паритет:** `TM_MINER_PARITY=1 go test -run TestMinerFullBookParity` → EXACT (13618 / катастроф-скрин / recall).
- **Генеративити реальна:** `go test ./internal/lang/` → 2-я пара грузится через сеам БЕЗ правок `pipeline/`; fail-loud на
неизвестную пару.
- **Алгоритмы не сломаны, велосипеды сняты (их не было), над-инженерии нет** (0 интерфейсов, тонкий загрузчик).
- **«Не упоролись на миноры»:** DO-лист = 8 пунктов с ценностью; DON'T явно отвергнуты; 2 названных владельцем пункта
(DC-чекеры, x/text-свап) обоснованно DEFER/DON'T с ресёрч-доказательством.
## Residual → R1 (`BACKEND_PLAN11_SESSION_PROMPT.md`, активен)
- R1-драйвер-свитч (волны) — как было.
- **Новое R1-условие:** при подключении майнера в живой W1.5 — грузить `lang.Pack` в раннере (fail-loud-at-load для пары с
каталогом-пакетом; nil-and-run для пары без) + фолдить `pack.Version()` в `snapshotID` (единый resnapshot тогда).
- DC-чекеры/cheapgates → `internal/lang` на ja→ru-реплике (тот же сеам).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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/<pair>/palladius.txt, loaded via internal/lang); this file keeps only the ALGORITHM —
// the syllable-inventory generator (over the table) and the greedy segmenter (D39.15: data as files,
// algorithm in code).
//
// In the ratified DEFAULT-B miner the ru-side name CONFIRMATION sub-channel is OFF (it also needs the
// pymorphy3 is_name_lemma gate to block the «найти»=най+ти false-positive class), so isPalladiusToken is
@ -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)

View file

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

View file

@ -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 + 12 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: 13 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 {

View file

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

View file

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

View file

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

View file

@ -9,6 +9,10 @@
> - **Ждём от владельца:** запуск exp15 (бюджет ≤$15 подтверждён) · research/20 §E: мини-голд алиасов (~2030 мин) / бюджет ≤$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/<lang|pair>/*.yaml` + `x/text/language.Tag`; резолв по LangPair, снапшот-folded как промпты; алгоритмы не трогать (уходят ДАННЫЕ). **Секвенс: этот проход ПЕРЕД R1** (пока майнер не живой). **Промт выдан `BACKEND_ARCH_CLEANUP_SESSION_PROMPT.md`** — 5 фаз-воркфлоу (ресёрч 2×3 → синтез → фикс-лист do/defer/don't → исполнение → ревью); поведенчески нейтрально zh→ru (golden байт-стабилен, один resnapshot); риды-along FL-1/2/3 + тест-путь. ja→ru = приёмочный тест общности позже. **Очередь пере-упорядочена:** арх-проход → R1-продолжение → resnapshot → пере-прогон → чтение.

View file

@ -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/` — фактура исследований 0405.07: `0110` базовые, `11-gap-*` добор критиком, `12-*` режимы отказа/отзывы/таксономии (+ два внешних материала с провенанс-шапками), `13` валидация памяти, `14` адаптивная память, `15` голос и состояние (принят, D21), **`16` ридер-IDE (принят с ревью-шапкой, D29)**, **`17` внешняя критика GPT-5.6 (принят с ревью-шапкой, D25)** — у 16/17 читать шапку прежде тела. **`18` рычаги качества (два отчёта, D36)** · **`19` нарезка+когезия+контракт t/e (D39.1)** · **`20` банк-майнинг W1.5 (D39.6)** — у всех ревью-шапки. ⚠ Часть под superseded-баннерами (01/02/03/04/05/09 и gap-1/2/5) — **читай баннер прежде содержимого**.
- `PROGRESS.md`**журнал** (CURRENT-STATE сверху, ниже хронология; НЕ источник решений).
- **Активные хендофф-промты (пост-D39.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)

File diff suppressed because one or more lines are too long