Land memory bank v2 (glossary hot path, injection surface, F1 materialization, decl-aware post-check) with 8 self-review fixes

This commit is contained in:
Claude (backend session) 2026-07-05 08:13:04 +03:00
parent baf6ae31bd
commit aa00339abb
23 changed files with 3886 additions and 43 deletions

View file

@ -180,6 +180,40 @@ func report(cfgPath string) error {
f.Chapter, f.ChunkIdx, f.Stage, f.Disposition, f.FlagReason, f.Attempts, f.CostUSD, f.Detail)
}
// Memory section (шаг 4): the per-chunk retrieval-state, surfaced so silent glossary
// degradation is LOUD. The aggregate line always prints when a glossary is in use;
// each chunk with a post-check miss is listed (the flagger-mode signal — the model
// ignored an approved term, or we injected the wrong dst) with the offending src→dst.
states, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
if err != nil {
return err
}
if len(states) > 0 {
var injected, sticky, ambiguous, spoiler, evicted, misses int
for _, rs := range states {
injected += rs.NExactHits
sticky += rs.NSticky
ambiguous += rs.NAmbiguousFlagged
spoiler += rs.NSpoilerBlocked
evicted += rs.NEvicted
misses += rs.NPostcheckMiss
}
fmt.Printf("\n=== ПАМЯТЬ (retrieval-state) ===\n")
fmt.Printf("инъекций(точных)=%d sticky=%d ambiguous=%d спойлер-блок=%d вытеснено=%d post-check-промахов=%d\n",
injected, sticky, ambiguous, spoiler, evicted, misses)
printed := false
for _, rs := range states {
if rs.NPostcheckMiss == 0 {
continue
}
if !printed {
fmt.Printf("%-4s %-6s %6s %s\n", "ch", "chunk", "misses", "detail (src→dst не найден в выводе)")
printed = true
}
fmt.Printf("%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NPostcheckMiss, rs.PostcheckDetail)
}
}
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
if err != nil {
return err

View file

@ -40,7 +40,13 @@ type Book struct {
ModelsFile string `yaml:"models"`
SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк
ProjectDB string `yaml:"project_db"` // default: <book_id>.db рядом с book.yaml
Ceilings BookCap `yaml:"ceilings"`
// GlossarySeed is the optional path to the manual glossary seed YAML (memory v2,
// шаг 4). Its curated approved/draft terms + the classified ruby readings are the
// deterministic inputs the glossary is REPLACED from each run; editing it changes the
// materialized approved set → a loud --resnapshot (F1). Empty = ruby-seed only (or an
// empty glossary → the injection is inert, a safe no-op).
GlossarySeed string `yaml:"glossary_seed"`
Ceilings BookCap `yaml:"ceilings"`
}
// BookCap are the ledger admission limits (Р7: потолок $ на книгу/день).
@ -70,6 +76,7 @@ func LoadBook(path string) (*Book, error) {
b.Pipeline = resolve(b.Pipeline)
b.ModelsFile = resolve(b.ModelsFile)
b.SourceFile = resolve(b.SourceFile)
b.GlossarySeed = resolve(b.GlossarySeed)
if b.ProjectDB == "" {
b.ProjectDB = filepath.Join(dir, b.BookID+".db")
} else {
@ -98,6 +105,11 @@ func LoadBook(path string) (*Book, error) {
} else if _, err := os.Stat(b.SourceFile); err != nil {
bad("source_file %s is not readable: %v", b.SourceFile, err)
}
if b.GlossarySeed != "" {
if _, err := os.Stat(b.GlossarySeed); err != nil {
bad("glossary_seed %s is not readable: %v", b.GlossarySeed, err)
}
}
if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 {
bad("ceilings: at least one of book_usd/day_usd must be set (ledger без потолка запрещён Р7)")
}

View file

@ -81,6 +81,19 @@ type Stage struct {
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
type Gates struct {
Coverage CoverageGate `yaml:"coverage"`
Glossary GlossaryGate `yaml:"glossary"`
}
// GlossaryGate controls the memory-bank post-check (E1). The post-check ALWAYS runs
// (observability — it records misses into the retrieval-state), converting silent
// glossary drift into a loud, visible signal. PostcheckGate promotes a miss from a
// mere record to a DISPOSITION flag (the chunk is flagged, downstream stages skip):
// opt-in (default false = flagger), flipped on only AFTER the owner validates the
// false-flag rate on real chapters (E1) — exactly the coverage gate's opt-in discipline
// (D12 Q4). A naive/under-filled decl false-flags (research/14 §2), so a premature hard
// gate would flag-storm and train editors to ignore it.
type GlossaryGate struct {
PostcheckGate bool `yaml:"postcheck_gate"`
}
// CoverageGate v1 (Р7): метрика — символы без пробелов; нижняя граница —

View file

@ -0,0 +1,563 @@
# Traditional → Simplified Chinese character map (A4 normalization, memory bank v2).
#
# Format: one mapping per line, "<trad> <simp>" (whitespace-separated, single rune
# each). '#' comments and blank lines are ignored. Loaded once via go:embed and
# applied character-by-character in memnorm.go, SYMMETRICALLY to glossary keys and
# to chunk text — so a key written in Simplified still matches a Traditional source
# surface (and vice versa), closing the "тихо пусто" orthography hole (registry A4).
#
# PROVENANCE / COMPLETENESS (read before trusting): this is a curated HIGH-FREQUENCY
# set, NOT the full OpenCC TSCharacters table. It was authored offline (no OpenCC/
# Unihan data available on the stand) with accuracy prioritized over size — a WRONG
# trad→simp mapping silently corrupts matching, so only high-confidence single-char
# mappings are included; phrase-level OpenCC (one-to-many, e.g. 後→后 vs 后→后) is
# deliberately out of scope for v1. The table's content is versioned by
# memoryNormVersion (memnorm.go), folded into the snapshot via memoryVersion() — so
# COMPLETING it with the full OpenCC data is a data-file swap that fires a loud
# --resnapshot, never a silent behaviour change. The acceptance book is ja→ru, where
# trad→simp is not load-bearing (NFKC + kana-fold + ruby carry ja); this table matters
# for zh sources and is the one tracked data-completion item before a zh acceptance run.
# Known live-bug cases from research/14 (爺→爷 etc.) and every retrieval_bench trap
# character are covered and asserted in memnorm_test.go.
# --- referenced by the eval specs / research/14 live bugs (must stay covered) ---
魯 鲁
鎮 镇
趙 赵
蕭 萧
煉 炼
門 门
闆 板
莊 庄
錢 钱
陳 陈
萬 万
舊 旧
臉 脸
爺 爷
羅 罗
莊 庄
# --- 訁 (speech) radical ---
說 说
話 话
語 语
讀 读
誰 谁
課 课
談 谈
請 请
謝 谢
詩 诗
詞 词
試 试
記 记
訪 访
許 许
論 论
訴 诉
譯 译
議 议
護 护
譽 誉
讚 赞
變 变
讓 让
認 认
識 识
訊 讯
訂 订
計 计
訓 训
設 设
詳 详
誠 诚
語 语
誤 误
說 说
誦 诵
調 调
諒 谅
談 谈
謎 谜
講 讲
謙 谦
謠 谣
證 证
評 评
# --- 貝 (shell/money) radical ---
貝 贝
財 财
貨 货
貧 贫
貪 贪
責 责
貫 贯
貴 贵
買 买
費 费
貼 贴
賀 贺
貿 贸
資 资
賊 贼
賓 宾
賦 赋
賠 赔
賣 卖
賤 贱
賬 账
賭 赌
賴 赖
購 购
賽 赛
贈 赠
贏 赢
賺 赚
賞 赏
賢 贤
質 质
贖 赎
貸 贷
賃 赁
賄 贿
賂 赂
# --- 車 (vehicle) radical ---
車 车
輪 轮
軍 军
較 较
輕 轻
轉 转
輸 输
輩 辈
輝 辉
軟 软
軌 轨
轟 轰
輔 辅
輛 辆
載 载
轎 轿
# --- 門 (gate) radical ---
開 开
閉 闭
問 问
間 间
閃 闪
閒 闲
閑 闲
閣 阁
閩 闽
閱 阅
闊 阔
關 关
闖 闯
闕 阙
悶 闷
聞 闻
# --- 食 (food) radical ---
飛 飞
風 风
飯 饭
飲 饮
餓 饿
館 馆
養 养
餘 余
餅 饼
饑 饥
饞 馋
飄 飘
# --- 金 (metal) radical ---
鐘 钟
鐵 铁
銀 银
銅 铜
鋼 钢
錯 错
鎖 锁
鏡 镜
鑰 钥
鑽 钻
鍋 锅
鍵 键
錄 录
針 针
釘 钉
鈴 铃
鉛 铅
銷 销
鋒 锋
鏈 链
鑼 锣
鈔 钞
鑲 镶
# --- 頁 (head/page) radical ---
頁 页
頂 顶
項 项
順 顺
須 须
預 预
頑 顽
頓 顿
頗 颇
領 领
頸 颈
頻 频
顆 颗
題 题
額 额
顏 颜
願 愿
類 类
顧 顾
顯 显
顫 颤
# --- 馬 (horse) radical ---
馬 马
駕 驾
駛 驶
駐 驻
駝 驼
騎 骑
騙 骗
驕 骄
驗 验
驚 惊
驢 驴
駭 骇
駱 骆
騰 腾
# --- 鳥 (bird) radical ---
鳥 鸟
鴨 鸭
鵝 鹅
鴻 鸿
鵬 鹏
鶴 鹤
鷹 鹰
鷗 鸥
鴉 鸦
鳴 鸣
鴿 鸽
# --- 魚 (fish) radical ---
魚 鱼
鮮 鲜
鯨 鲸
鯉 鲤
鱗 鳞
# --- 糸 (silk/thread) radical ---
紀 纪
約 约
紅 红
紙 纸
級 级
納 纳
紛 纷
純 纯
紗 纱
綱 纲
網 网
綿 绵
線 线
練 练
組 组
細 细
終 终
結 结
絕 绝
給 给
絡 络
統 统
綠 绿
維 维
綜 综
緊 紧
緒 绪
編 编
緩 缓
締 缔
緣 缘
縣 县
縫 缝
縮 缩
績 绩
繩 绳
繫 系
繪 绘
繼 继
續 续
纏 缠
紋 纹
綁 绑
繳 缴
經 经
絲 丝
繡 绣
# --- water / fire / misc very-high-frequency ---
漢 汉
灣 湾
濟 济
潔 洁
澤 泽
濕 湿
灑 洒
滅 灭
溫 温
準 准
淚 泪
減 减
滾 滚
滿 满
漁 渔
漸 渐
潑 泼
濁 浊
瀉 泻
決 决
沒 没
沖 冲
況 况
淨 净
涼 凉
凍 冻
熱 热
煩 烦
燒 烧
燈 灯
燦 灿
爛 烂
營 营
爐 炉
煙 烟
熾 炽
燭 烛
氣 气
氫 氢
# --- 犭 (animal) radical ---
獨 独
獲 获
獸 兽
獻 献
獄 狱
猶 犹
狹 狭
獅 狮
獵 猎
豬 猪
# --- 阝 (mound/city) radical ---
陰 阴
陽 阳
階 阶
隊 队
隨 随
險 险
隱 隐
際 际
陸 陆
陝 陕
陣 阵
鄉 乡
鄰 邻
鄭 郑
鄧 邓
# --- 木 (wood) radical ---
條 条
極 极
樓 楼
標 标
樣 样
樹 树
橋 桥
機 机
檢 检
櫃 柜
欄 栏
權 权
檔 档
樸 朴
橫 横
檻 槛
櫥 橱
楊 杨
榮 荣
構 构
槍 枪
樑 梁
櫻 樱
樂 乐
業 业
東 东
# --- structural / very common standalone ---
國 国
學 学
會 会
對 对
時 时
見 见
這 这
們 们
個 个
來 来
為 为
麼 么
頭 头
過 过
還 还
進 进
種 种
應 应
關 关
點 点
動 动
於 于
實 实
發 发
現 现
長 长
兒 儿
東 东
與 与
舉 举
興 兴
覺 觉
觀 观
歡 欢
勸 劝
農 农
辦 办
務 务
勞 劳
勢 势
勝 胜
勵 励
單 单
嚴 严
嘗 尝
嘆 叹
嚇 吓
嚮 向
圖 图
團 团
圓 圆
園 园
圍 围
囑 嘱
專 专
將 将
尋 寻
導 导
屬 属
層 层
屢 屡
廣 广
廠 厂
廢 废
廟 庙
廚 厨
廈 厦
廳 厅
華 华
葉 叶
蓋 盖
蒼 苍
薦 荐
薩 萨
藍 蓝
藝 艺
藥 药
蘇 苏
蘭 兰
蘋 苹
蟲 虫
傷 伤
傳 传
債 债
傾 倾
僅 仅
僕 仆
僑 侨
價 价
儀 仪
億 亿
儉 俭
儲 储
償 偿
優 优
側 侧
偉 伟
偵 侦
師 师
帥 帅
幣 币
帳 帐
幫 帮
歲 岁
歸 归
歷 历
曆 历
歐 欧
殺 杀
毀 毁
愛 爱
節 节
範 范
築 筑
簡 简
籃 篮
籠 笼
籤 签
篤 笃
簾 帘
籌 筹
醫 医
產 产
麗 丽
麵 面
麥 麦
黃 黄
黨 党
龍 龙
鳳 凤
龜 龟
齒 齿
齊 齐
書 书
畫 画
邊 边
飛 飞
決 决
擊 击
擔 担
據 据
擁 拥
撲 扑
擴 扩
攔 拦
掃 扫
撿 捡
擋 挡
掛 挂
換 换
損 损
搶 抢
攜 携
擾 扰
撐 撑

View file

@ -72,6 +72,15 @@ const (
FlagExcisionSuspect FlagReason = "excision_suspect"
FlagHardBlock FlagReason = "hard_block"
FlagUpstreamNotOK FlagReason = "upstream_not_ok"
// FlagGlossaryMiss is the memory-bank post-check verdict (E1, шаг 4): an approved
// term's src fired in the chunk but no accepted dst form appears in the output — the
// model ignored the glossary, or we injected the wrong dst and it obeyed. Emitted
// ONLY when the opt-in glossary post-check gate is enabled (config); in the default
// flagger mode a miss is recorded in the retrieval-state, not a disposition. NOT
// retryable (a same-model retry re-produces the same rendering) and NOT auto-
// escalatable in v1 (the L3 targeted re-ask is a Phase-2 remedy, research/14 §9).
FlagGlossaryMiss FlagReason = "glossary_miss"
)
// classifierVersion versions the INTRINSIC classify() verdict logic — the refusal

View file

@ -0,0 +1,182 @@
package pipeline
import (
"crypto/sha256"
_ "embed"
"encoding/hex"
"fmt"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// memnorm.go: the memory-bank NORMALIZATION artifact (registry A4 — the most likely
// "тихо пусто" bug: a glossary key is present in the chunk but in a different
// orthographic form, so the record silently fails to match). Two pure, deterministic,
// VERSIONED normalizers, applied SYMMETRICALLY to both sides of every comparison:
//
// normalizeSourceKey — zh/ja source surfaces (glossary keys/aliases AND chunk text):
// NFKC (fold half/full-width, e.g. テ→テ, →Q) → trad→simp (per-char, the
// embedded table) → katakana→hiragana fold → Unicode lower. So a key stored in
// Simplified still matches a Traditional source surface (魯鎮 ↔ 鲁镇), a full-width
// matches a half-width Q, and スズキ matches すずき.
// normalizeTargetForm — Russian dst forms (stored decl forms AND model output) for
// the post-check substring test: NFC → Unicode lower → ё→е fold.
//
// The whole artifact is versioned by memoryNormVersion, which is FOLDED INTO the
// snapshot via memoryVersion() (runner.go): a change to the algorithm OR to the
// embedded trad→simp table is a loud --resnapshot, never a silent match-behaviour
// change on already-checkpointed chunks. Determinism is load-bearing — the injected
// glossary block is a pure function of (frozen glossary, normalized chunk), and it
// enters request_hash via the rendered messages.
//go:embed data/trad2simp.txt
var trad2simpRaw string
// memoryNormAlgoVersion tags the normalization ALGORITHM (the pipeline of steps and
// their order). Bump it on an algorithm change. The trad→simp TABLE content is
// versioned SEPARATELY by hashing trad2simpRaw into memoryNormVersion below, so
// editing/completing the data file also invalidates — drift-proof (D8): you cannot
// forget to bump a version when you change the table, because the table's bytes ARE
// the version.
const memoryNormAlgoVersion = "memnorm-v1-nfkc+trad+kana+lower/nfc+lower+yofold"
var (
// trad2simp is the parsed Traditional→Simplified single-char map (data/trad2simp.txt).
trad2simp map[rune]rune
// memoryNormVersion is the snapshot-load-bearing version of the entire
// normalization artifact: the algorithm tag + a content hash of the embedded
// trad→simp table. memoryVersion() (runner.go) folds it into the job snapshot.
memoryNormVersion string
)
func init() {
trad2simp = parseTradTable(trad2simpRaw)
h := sha256.Sum256([]byte(memoryNormAlgoVersion + "\x00" + trad2simpRaw))
memoryNormVersion = memoryNormAlgoVersion + "-" + hex.EncodeToString(h[:])[:12]
}
// parseTradTable parses the embedded "<trad> <simp>" table. Comment ('#') and blank
// lines are ignored; a non-empty content line that is NOT exactly two
// single-rune whitespace-separated fields is a corrupt table → panic at init (a
// loud build/test failure, caught the moment any test in the package runs, rather
// than a silently truncated map that would re-open the A4 hole). The table is
// authored in-repo and asserted by memnorm_test.go, so a panic here means the data
// file was corrupted, not bad user input.
func parseTradTable(raw string) map[rune]rune {
m := make(map[rune]rune, 1024)
for i, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) != 2 {
panic(fmt.Sprintf("pipeline: trad2simp table line %d: want 2 fields, got %d (%q)", i+1, len(fields), line))
}
tr := []rune(fields[0])
si := []rune(fields[1])
if len(tr) != 1 || len(si) != 1 {
panic(fmt.Sprintf("pipeline: trad2simp table line %d: each side must be a single rune (%q → %q)", i+1, fields[0], fields[1]))
}
// A later duplicate wins deterministically (the file may list a char under
// two families); assert the mapping is consistent to catch a typo where the
// same trad char is mapped to two different simp chars.
if prev, ok := m[tr[0]]; ok && prev != si[0] {
panic(fmt.Sprintf("pipeline: trad2simp table line %d: %q maps to both %q and %q", i+1, fields[0], string(prev), fields[1]))
}
m[tr[0]] = si[0]
}
return m
}
// normalizeSourceKey normalizes a zh/ja SOURCE surface (a glossary key/alias OR the
// chunk text) for exact matching. Pure and deterministic; the pipeline is
// NFKC → trad→simp (per rune) → katakana→hiragana → Unicode lower. Applied to BOTH
// keys and text so orthographic variants match (A4). Kana folding mirrors the eval
// spec (memory_hotpath.py): full-width katakana U+30A1..U+30F6 shift down 0x60 to
// hiragana; the prolonged-sound mark ー (U+30FC) and half-width forms are already
// handled (the latter by NFKC).
func normalizeSourceKey(s string) string {
s = norm.NFKC.String(s)
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if m, ok := trad2simp[r]; ok {
r = m
}
if r >= 0x30A1 && r <= 0x30F6 { // katakana → hiragana
r -= 0x60
}
b.WriteRune(unicode.ToLower(r))
}
return b.String()
}
// normalizeTargetForm normalizes a Russian TARGET form (a stored decl form OR the
// model output) for the post-check whole-word test. Applied SYMMETRICALLY to both
// sides so a correctly-rendered term is NEVER a false MISS (over-flagging is the
// failure mode research/14 §2 warns about). Steps, each closing a self-review finding:
// - NFC;
// - dash/hyphen variants (en-dash, NB-hyphen, minus, soft hyphen, …) → ASCII '-', so
// «А–кью»/«А‑кью» match a stored «А-кью» (self-review #8);
// - runs of ANY Unicode whitespace (newline, NBSP U+00A0, ideographic space, …) →
// one ASCII space, then trim, so a multi-word name wrapped across a line break
// («Бородатого\nВана») matches a stored «Бородатого Вана» (self-review #2);
// - Unicode lower + ё→е fold (the ёфикатор's е/ё variation).
// Pure and deterministic.
func normalizeTargetForm(s string) string {
s = norm.NFC.String(s)
var b strings.Builder
b.Grow(len(s))
prevSpace := false
for _, r := range s {
if isDashVariant(r) {
r = '-'
}
if unicode.IsSpace(r) {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
continue
}
prevSpace = false
r = unicode.ToLower(r)
if r == 'ё' {
r = 'е'
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
// isDashVariant reports whether r is a hyphen/dash/minus variant that should fold to
// ASCII '-' for the post-check (Unicode dash punctuation + the math minus + soft hyphen).
func isDashVariant(r rune) bool {
switch r {
case '', '', '', '', '—', '―', '', '­':
return true
}
return false
}
// significantLen counts the "significant" characters of a normalized key — CJK
// ideographs/kana plus letters (Latin/Cyrillic) — for the single-key ban (registry
// A3): a key whose significant length is below minKeyLen may not fire on its own
// (a lone Han/kana/letter like 炎/气/钱 matches unrelated text on the wrong sense).
// Digits and punctuation do not count (so "阿Q" = 2, but a bare "Q" = 1). Mirrors the
// eval spec's cjk_len.
func significantLen(normalized string) int {
n := 0
for _, r := range normalized {
switch {
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
n++
case unicode.IsLetter(r):
n++
}
}
return n
}

View file

@ -0,0 +1,116 @@
package pipeline
import (
"strings"
"testing"
)
// TestTradTableCoversKnownCases asserts the embedded trad→simp table covers every
// character referenced by the eval specs / research/14 live bugs. A mutation
// (deleting a mapping line) fails exactly here — the guard against silently
// regressing the A4 hole (爺→爷 was research/14's live "тихо пусто" bug).
func TestTradTableCoversKnownCases(t *testing.T) {
want := map[rune]rune{
'魯': '鲁', '鎮': '镇', '趙': '赵', '蕭': '萧', '煉': '炼',
'門': '门', '闆': '板', '莊': '庄', '錢': '钱', '陳': '陈',
'萬': '万', '舊': '旧', '臉': '脸', '爺': '爷', '羅': '罗',
}
for tr, si := range want {
if got, ok := trad2simp[tr]; !ok || got != si {
t.Errorf("trad2simp[%q] = %q, ok=%v; want %q", string(tr), string(got), ok, string(si))
}
}
if len(trad2simp) < 200 {
t.Errorf("trad2simp table has only %d entries — expected the curated high-frequency set (≥200)", len(trad2simp))
}
if memoryNormVersion == "" || !strings.HasPrefix(memoryNormVersion, memoryNormAlgoVersion) {
t.Errorf("memoryNormVersion=%q must start with the algo tag %q", memoryNormVersion, memoryNormAlgoVersion)
}
}
func TestNormalizeSourceKey(t *testing.T) {
cases := []struct{ name, in, want string }{
{"trad→simp two-char", "魯鎮", "鲁镇"},
{"trad→simp name", "趙太爺", "赵太爷"},
{"fullwidth latin lowered", "阿Q", "阿q"},
{"halfwidth latin lowered", "阿Q", "阿q"},
{"katakana→hiragana", "スズキ", "すずき"},
{"halfwidth katakana via NFKC", "ススキ", "すすき"}, // NFKC folds half→full katakana, then kana-fold to hiragana
{"plain han unchanged", "送灶", "送灶"},
{"mixed", "萧炎", "萧炎"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := normalizeSourceKey(c.in); got != c.want {
t.Errorf("normalizeSourceKey(%q) = %q; want %q", c.in, got, c.want)
}
})
}
}
// TestNormalizeSymmetry is the A4 invariant: a key written in one orthography
// matches a chunk written in another, because normalization is applied to BOTH.
func TestNormalizeSymmetry(t *testing.T) {
// glossary key in Simplified, chunk text in Traditional
key := normalizeSourceKey("鲁镇") // simplified
text := normalizeSourceKey("魯鎮的冬天很冷。") // traditional
if !strings.Contains(text, key) {
t.Errorf("normalized simplified key %q not found in normalized traditional text %q", key, text)
}
// katakana name key vs hiragana surface
k2 := normalizeSourceKey("スズキ")
t2 := normalizeSourceKey("すずきさんが来た。")
if !strings.Contains(t2, k2) {
t.Errorf("normalized katakana key %q not found in normalized hiragana text %q", k2, t2)
}
}
func TestNormalizeTargetForm(t *testing.T) {
cases := []struct{ in, want string }{
{"А-кью", "а-кью"},
{"Вэйчжуан", "вэйчжуан"},
{"Тёмный страж", "темный страж"}, // NFC → lower → ё→е fold
}
for _, c := range cases {
if got := normalizeTargetForm(c.in); got != c.want {
t.Errorf("normalizeTargetForm(%q) = %q; want %q", c.in, got, c.want)
}
}
// ё and е must fold to the SAME normalized form (symmetric — no false miss)
if normalizeTargetForm("сёстры") != normalizeTargetForm("сестры") {
t.Error("ё→е fold is not symmetric: сёстры vs сестры differ after normalization")
}
// self-review #2: internal whitespace runs (incl. newline/NBSP) collapse to one space.
for _, v := range []string{"Бородатого\nВана", "Бородатого Вана", "Бородатого Вана"} {
if got := normalizeTargetForm(v); got != "бородатого вана" {
t.Errorf("whitespace not canonicalized: %q → %q", v, got)
}
}
// self-review #8: dash/hyphen variants fold to ASCII '-'.
for _, v := range []string{"А–кью", "А‑кью", "А−кью"} {
if got := normalizeTargetForm(v); got != "а-кью" {
t.Errorf("dash variant not unified: %q → %q", v, got)
}
}
}
func TestSignificantLen(t *testing.T) {
cases := []struct {
in string
want int
}{
{"阿q", 2}, // han + latin letter
{"炎", 1}, // single han — banned as a standalone key
{"气", 1}, // single han
{"钱", 1}, // single han (research/14 homograph)
{"送灶", 2}, // bigram — allowed
{"すずき", 3}, // three kana
{"q", 1}, // bare latin letter
{"а-кью", 4}, // cyrillic letters, hyphen not counted: а к ь ю = 4
}
for _, c := range cases {
if got := significantLen(c.in); got != c.want {
t.Errorf("significantLen(%q) = %d; want %d", c.in, got, c.want)
}
}
}

View file

@ -0,0 +1,592 @@
package pipeline
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strconv"
"strings"
"unicode"
"textmachine/backend/internal/store"
)
// memory.go: the DETERMINISTIC hot path of the memory bank v2 (registry §Контракт
// горячего пути / research/13 Q3). $0, no LLM, no embeddings, no FTS5 — a multi-pattern
// exact match (Aho-Corasick) of glossary keys/aliases over the NORMALIZED chunk, with
// the single-key ban (A3), longest-match whole-entity replacement, the spoiler
// hard-reject window (C1), sticky scene-inertia (A5), a priority token budget with a
// logged eviction (F2), and the three-way injection disposition (A2). Pure and
// deterministic (no map-order output, no time/rand): the injected block is a pure
// function of (frozen bank, normalized chunk, chapter, sticky, budget), and it enters
// request_hash via the rendered messages — so a resumed chunk reproduces it for free.
//
// The registry's principle drives every choice here: "лучше ничего, чем мусор" and
// PRECISION over recall. A wrong injection (a homograph / short alias firing on the
// wrong sense) is the MAIN source of silent degradation (2510.00829) — worse than an
// empty match, which is a safe fallback to the model's base behaviour. So the matcher
// errs toward NOT firing, and the post-check (memcheck) makes what did fire observable.
// memoryMatchVersion versions the matcher + injection-selection ALGORITHM (min-key
// ban, longest-match containment, spoiler gate, sticky, budget priority order). Folded
// into memoryVersion() → a change is a loud --resnapshot (it shifts the injected bytes
// → the wire → a resumed checkpoint's content). Sibling of memoryNormVersion.
const memoryMatchVersion = "memmatch-v1-longest+minkey2+spoiler+sticky+budget+postcheck-declaware"
// defaultMinKeyLen is the minimum significant length (significantLen) a key must have
// to fire on its own (A3 single-key ban): a lone Han/kana/letter (炎/气/钱) matches
// unrelated text on the wrong sense — empirically confirmed (research/14). A per-entry
// allow_short overrides it (rare, guarded). Per-language tuning (registry min_key_len
// per-язык) is a later knob; v1 uses the eval spec's global MIN_KEY=2.
const defaultMinKeyLen = 2
// stickyDepth is the scene-inertia window (A5): entries exact-matched in the previous
// N chunks of the SAME chapter are carried into a pronominal chunk that names nobody.
// A code rule (Р2), reset at each chapter boundary (a new chapter is a scene change).
const stickyDepth = 2
// injectionDisposition is the three-way per-record decision (registry A2) — the core
// mechanism that converts silent degradation into loud. Distinct from the chunk×stage
// Disposition (disposition.go): that is ok|flagged|skipped for a completion; this is
// how much to TRUST an injected glossary record.
type injectionDisposition string
const (
memConfirmed injectionDisposition = "confirmed" // exact key/alias, approved, spoiler-valid → authoritative
memAmbiguous injectionDisposition = "ambiguous" // auto/draft term → inject "unverified" + forced post-check
memReject injectionDisposition = "reject" // spoiler-window violation → dropped and logged (C1 safety)
)
// declInfo is the parsed decl column: the dst declension forms the post-check accepts.
type declInfo struct {
Invariant bool `json:"invariant"`
Forms []string `json:"forms"`
}
// memoryEntry is one frozen, materialized glossary entry with its matchable keys
// pre-normalized. Immutable for a job.
type memoryEntry struct {
id string // stable unique id: src\x1f sense\x1f since\x1f until (= the UNIQUE key)
src string // raw source key
dst string // raw approved translation (may be "" for a ruby candidate)
status string // auto|draft|approved
sense string
sinceCh int
untilCh int
// normKeys are the normalized source surfaces (src + aliases) ELIGIBLE to fire
// (significantLen ≥ minKeyLen OR allow_short). A key too short is dropped here, so
// it is never in the automaton — the single-key ban is structural, not a runtime skip.
normKeys []string
// declForms are the normalized-target dst forms the post-check accepts. Empty →
// the post-check falls back to the single normalized dst (the naive base form that
// research/14 shows false-flags on inflection — the reason full decl matters).
declForms []string
declInvariant bool
}
// MemoryBank is a book's frozen glossary materialized for one job: the entries, the
// Aho-Corasick automaton over all eligible keys, and the F1 memoryVersion. Built once
// (materializeMemory) before the chunk loop and never mutated.
type MemoryBank struct {
entries []memoryEntry
ac *ahoCorasick
keyOwners map[string][]int // normalized key → indices of entries that contributed it
version string // memoryVersion(): hash of frozen APPROVED rows + algo versions (F1/D8)
}
// pickedEntry is one selected record for a chunk with its firing key and disposition.
type pickedEntry struct {
entry *memoryEntry
via string // the matching key, or "sticky"
disp injectionDisposition
}
// memorySelection is the hot path's output for one chunk.
type memorySelection struct {
injected []pickedEntry // in budget priority order (what the model sees)
rejected []pickedEntry // spoiler-window rejects (C1, logged)
evicted []pickedEntry // dropped by the token budget (F2, logged)
activeIDs map[string]bool // exact-matched ids (NOT sticky) → the next chunk's sticky_prev
}
func (b *MemoryBank) Version() string { return b.version }
// materializeMemory builds a MemoryBank from the book's stored glossary rows (ORDER
// BY-stable — GlossaryForBook). Pure and deterministic. It computes memoryVersion as a
// content hash of the frozen APPROVED rows (D8: content-hash, not a version-counter —
// drift-proof) plus the normalization + matcher algorithm versions, so ANY change to
// the approved glossary OR to the deterministic machinery is a loud --resnapshot (F1).
func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
b := &MemoryBank{keyOwners: map[string][]int{}}
var allKeys []string
seenKey := map[string]bool{}
for _, row := range rows {
e := memoryEntry{
id: row.Src + "\x1f" + row.Sense + "\x1f" + strconv.Itoa(row.SinceCh) + "\x1f" + strconv.Itoa(row.UntilCh),
src: row.Src,
dst: row.Dst,
status: row.Status,
sense: row.Sense,
sinceCh: row.SinceCh,
untilCh: row.UntilCh,
}
// Eligible source surfaces (src + aliases) under the single-key ban. A record
// with NO dst yet (a ruby auto-candidate) is NOT matchable: it renders nothing
// (renderGlossaryBlock/postcheck both skip empty dst), so admitting its keys would
// let it consume the token budget, evict a renderable line, and inflate the
// retrieval-state exact-hit count for a record the model never sees (self-review
// #1). It stays in the store for future promotion, just inert on the hot path.
var surfaces []string
if strings.TrimSpace(row.Dst) != "" {
surfaces = append(surfaces, row.Src)
for _, a := range row.Aliases {
surfaces = append(surfaces, a.Alias)
}
}
for _, s := range surfaces {
nk := normalizeSourceKey(s)
if nk == "" {
continue
}
if significantLen(nk) < defaultMinKeyLen && !row.AllowShort {
continue // A3: a lone Han/kana/letter never fires on its own
}
e.normKeys = append(e.normKeys, nk)
}
// Parse decl forms for the post-check (normalized target side).
if row.Decl != "" {
var d declInfo
if err := json.Unmarshal([]byte(row.Decl), &d); err == nil {
e.declInvariant = d.Invariant
for _, f := range d.Forms {
if nf := normalizeTargetForm(f); nf != "" {
e.declForms = append(e.declForms, nf)
}
}
}
}
idx := len(b.entries)
b.entries = append(b.entries, e)
for _, nk := range e.normKeys {
b.keyOwners[nk] = append(b.keyOwners[nk], idx)
if !seenKey[nk] {
seenKey[nk] = true
allKeys = append(allKeys, nk)
}
}
}
sort.Strings(allKeys) // deterministic automaton construction
b.ac = buildAC(allKeys)
b.version = computeMemoryVersion(rows, gateOn)
return b
}
// computeMemoryVersion is the F1 content-hash: the frozen rows (ORDER BY-stable) plus the
// normalization and matcher algorithm versions. Approved-only per D8/§8 when the post-check
// hard gate is OFF (auto/draft injected-content changes are caught at the per-chunk
// content_hash level; their decl changes affect only the recomputed retrieval-state).
// When the gate is ON, the resolved chunk disposition depends on the decl forms of ALL
// injected records (approved AND auto/draft), and those are in NEITHER content_hash (decl
// is not injected) NOR the approved-only hash — so a decl edit would silently flip a
// resumed chunk's disposition (self-review #4). So gateOn folds every row's content
// (incl. decl + status) into the hash, making any such edit a loud --resnapshot. The
// glossary.id autoincrement is deliberately EXCLUDED (fresh each replace); only content
// columns are hashed.
func computeMemoryVersion(rows []store.GlossaryEntry, gateOn bool) string {
h := sha256.New()
h.Write([]byte("tm-memory-v2\x00"))
h.Write([]byte(memoryNormVersion + "\x00" + memoryMatchVersion + "\x00"))
h.Write([]byte("gate:" + strconv.FormatBool(gateOn) + "\x00"))
for _, r := range rows {
if r.Status != "approved" && !gateOn {
continue
}
// A fixed, length-prefixed field layout so no content can forge a boundary.
writeField(h, r.Status)
writeField(h, r.Src)
writeField(h, r.Dst)
writeField(h, r.Sense)
writeField(h, r.Type)
writeField(h, r.Gender)
writeField(h, r.Decl)
writeField(h, strconv.Itoa(r.SinceCh))
writeField(h, strconv.Itoa(r.UntilCh))
writeField(h, strconv.FormatBool(r.AllowShort))
writeField(h, strconv.Itoa(len(r.Aliases)))
for _, a := range r.Aliases { // GlossaryForBook returns aliases ORDER BY alias
writeField(h, a.Alias)
writeField(h, a.AliasType)
}
}
return hex.EncodeToString(h.Sum(nil))
}
func writeField(h interface{ Write([]byte) (int, error) }, s string) {
var lb [8]byte
n := uint64(len(s))
for i := 0; i < 8; i++ {
lb[i] = byte(n >> (8 * i))
}
h.Write(lb[:])
h.Write([]byte(s))
}
// glossaryLineTokens is the injected token cost of one record's "src → dst" line — the
// unit the token budget (config glossary_token_budget) spends. It is FLOOR-FREE (the raw
// cjk + other/3 estimate, NOT EstimateTokens' 16-token minimum): the floor is for sizing
// a whole request's max_tokens, but applying it PER glossary line ~1.85×-overcounts a
// block of short name lines and needlessly evicts records that fit (self-review #7). The
// header's cost is small and constant; omitting it keeps the unit purely per-record.
// Shared by Select's budget and the eviction test so the two never diverge.
func glossaryLineTokens(e *memoryEntry) int {
cjk, other := 0, 0
for _, r := range e.src + " → " + e.dst {
switch {
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
cjk++
case unicode.IsSpace(r):
default:
other++
}
}
return cjk + other/3
}
// Select is the hot path for ONE chunk: match → spoiler-reject → disposition → sticky
// → priority token budget. Pure and deterministic. stickyPrev is the set of ids
// exact-matched in the prior chunk(s) of the same chapter (A5). budgetTokens ≤ 0 means
// unbounded; otherwise records are kept in priority order while the cumulative injected
// token cost stays within budget — the rest are EVICTED (dropped but logged, F2; the
// budget may be underfilled, "лучше ничего, чем мусор").
func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]bool, budgetTokens int) memorySelection {
ntext := []rune(normalizeSourceKey(chunk))
occ := b.ac.matches(ntext)
// Longest-match: drop a key fully inside a strictly-longer key's span — but ONLY when
// the longer key belongs to a spoiler-VALID entry at this chapter (self-review #6). A
// spoiler-blocked longer key must not suppress a valid shorter name nested inside it.
occ = b.suppressContained(occ, chapter)
// Map surviving key occurrences → entries, recording the LONGEST firing key per entry.
matchedVia := map[int]string{}
for _, m := range occ {
k := b.ac.keys[m.keyIdx]
for _, ei := range b.keyOwners[k] {
if cur, ok := matchedVia[ei]; !ok || len([]rune(k)) > len([]rune(cur)) {
matchedVia[ei] = k
}
}
}
sel := memorySelection{activeIDs: map[string]bool{}}
hits := map[string]pickedEntry{} // id → picked (exact)
// Iterate entries in their frozen (ORDER BY-stable) order — never map order.
for ei := range b.entries {
via, ok := matchedVia[ei]
if !ok {
continue
}
e := &b.entries[ei]
if blocked := spoilerBlocked(e, chapter); blocked {
sel.rejected = append(sel.rejected, pickedEntry{e, via, memReject})
continue
}
hits[e.id] = pickedEntry{e, via, dispositionFor(e)}
sel.activeIDs[e.id] = true // exact-matched ids feed the next chunk's sticky (NOT sticky-carried)
}
// Sticky scene-inertia: carry prior-chunk exact matches not re-matched here, unless
// the spoiler window blocks them (spoiler beats sticky).
for ei := range b.entries {
e := &b.entries[ei]
if !stickyPrev[e.id] {
continue
}
if _, already := hits[e.id]; already {
continue
}
if spoilerBlocked(e, chapter) {
continue
}
hits[e.id] = pickedEntry{e, "sticky", dispositionFor(e)}
}
// Deterministic priority order for the budget: confirmed>ambiguous, exact>sticky,
// approved>auto. Collect in frozen-entry order (NOT map order) then stable-sort, so
// ties keep the deterministic base order.
order := make([]pickedEntry, 0, len(hits))
for ei := range b.entries {
if p, ok := hits[b.entries[ei].id]; ok {
order = append(order, p)
}
}
sort.SliceStable(order, func(i, j int) bool {
return priorityRank(order[i]) < priorityRank(order[j])
})
if budgetTokens > 0 {
used, cut := 0, len(order)
for i := range order {
used += glossaryLineTokens(order[i].entry)
if used > budgetTokens {
cut = i // this record and everything after it overflow the budget
break
}
}
sel.injected = order[:cut]
sel.evicted = order[cut:] // F2: dropped, but LOGGED via the retrieval-state
} else {
sel.injected = order
}
return sel
}
// spoilerBlocked reports whether the entry's since_ch/until_ch window excludes this
// chapter (C1: a hard reject gate — a fact the current chapter must not know yet).
func spoilerBlocked(e *memoryEntry, chapter int) bool {
if e.sinceCh > 0 && chapter < e.sinceCh {
return true
}
if e.untilCh > 0 && chapter > e.untilCh {
return true
}
return false
}
// dispositionFor maps a term's status to its injection disposition (A2): approved →
// CONFIRMED (authoritative); auto/draft → AMBIGUOUS (inject "unverified" + forced
// post-check).
func dispositionFor(e *memoryEntry) injectionDisposition {
if e.status == "approved" {
return memConfirmed
}
return memAmbiguous
}
// priorityRank orders the budget: confirmed before ambiguous, exact before sticky,
// approved before auto/draft (F2 eviction keeps the most trustworthy records).
func priorityRank(p pickedEntry) int {
rank := 0
if p.disp != memConfirmed {
rank |= 1 << 2
}
if p.via == "sticky" {
rank |= 1 << 1
}
if p.entry.status != "approved" {
rank |= 1 << 0
}
return rank
}
// glossaryBlockHeader introduces the injected glossary block. The layout mirrors the
// SakuraLLM/GalTransl convention "src → dst", with unverified (ambiguous) records
// tagged so the model — and a human reviewer — see they are not authoritative (A2).
const glossaryBlockHeader = "ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):"
// renderGlossaryBlock serializes the selected records into the injection message
// (§C). Records with no dst yet (ruby candidates) are skipped — a "src → " line
// carries nothing. Returns "" when nothing renders, so an empty selection injects NO
// message at all ("лучше ничего, чем мусор"). Deterministic: the injected order is the
// budget priority order fixed by Select.
func renderGlossaryBlock(injected []pickedEntry) string {
var lines []string
for _, p := range injected {
if strings.TrimSpace(p.entry.dst) == "" {
continue
}
line := p.entry.src + " → " + p.entry.dst
if p.disp != memConfirmed {
line += " ⟨проверить⟩"
}
lines = append(lines, line)
}
if len(lines) == 0 {
return ""
}
return glossaryBlockHeader + "\n" + strings.Join(lines, "\n")
}
// --- Aho-Corasick multi-pattern automaton over runes ----------------------------
type acMatch struct {
keyIdx int
start, end int // rune indices [start, end)
}
type acNode struct {
next map[rune]int
fail int
out []int // key indices whose pattern ends at this node (incl. via fail links)
}
type ahoCorasick struct {
nodes []acNode
keys []string
klen []int // rune length of each key
}
// buildAC constructs the automaton from the (already sorted, unique) normalized keys.
// Deterministic in RESULT regardless of map iteration order: fail links and outputs are
// a function of the key set, not the BFS visit order.
func buildAC(keys []string) *ahoCorasick {
ac := &ahoCorasick{keys: keys, klen: make([]int, len(keys))}
ac.nodes = []acNode{{next: map[rune]int{}}} // root = node 0
for i, k := range keys {
kr := []rune(k)
ac.klen[i] = len(kr)
cur := 0
for _, r := range kr {
nxt, ok := ac.nodes[cur].next[r]
if !ok {
nxt = len(ac.nodes)
ac.nodes = append(ac.nodes, acNode{next: map[rune]int{}})
ac.nodes[cur].next[r] = nxt
}
cur = nxt
}
ac.nodes[cur].out = append(ac.nodes[cur].out, i)
}
// BFS to compute fail links; propagate outputs down fail chains one level (each
// node inherits its fail node's already-complete output set).
var queue []int
for _, c := range ac.nodes[0].next {
ac.nodes[c].fail = 0
queue = append(queue, c)
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for r, nxt := range ac.nodes[cur].next {
queue = append(queue, nxt)
f := ac.nodes[cur].fail
for f != 0 {
if _, ok := ac.nodes[f].next[r]; ok {
break
}
f = ac.nodes[f].fail
}
if fn, ok := ac.nodes[f].next[r]; ok && fn != nxt {
ac.nodes[nxt].fail = fn
} else {
ac.nodes[nxt].fail = 0
}
ac.nodes[nxt].out = append(ac.nodes[nxt].out, ac.nodes[ac.nodes[nxt].fail].out...)
}
}
return ac
}
// matches returns every key occurrence in text (rune indices), sorted deterministically.
func (ac *ahoCorasick) matches(text []rune) []acMatch {
var out []acMatch
cur := 0
for i, r := range text {
for cur != 0 {
if _, ok := ac.nodes[cur].next[r]; ok {
break
}
cur = ac.nodes[cur].fail
}
if nxt, ok := ac.nodes[cur].next[r]; ok {
cur = nxt
} else {
cur = 0
}
for _, ki := range ac.nodes[cur].out {
out = append(out, acMatch{keyIdx: ki, start: i - ac.klen[ki] + 1, end: i + 1})
}
}
sort.Slice(out, func(a, b int) bool {
if out[a].start != out[b].start {
return out[a].start < out[b].start
}
if out[a].end != out[b].end {
return out[a].end < out[b].end
}
return out[a].keyIdx < out[b].keyIdx
})
return out
}
// suppressContained drops an occurrence fully contained inside a STRICTLY longer one
// (longest-match, whole-entity replacement — A3), where the longer one belongs to a
// spoiler-VALID entry at this chapter. Gating the SUPPRESSOR on spoiler validity closes
// self-review #6: a spoiler-blocked longer key (e.g. 林动的父亲, until_ch=3, read at ch10)
// must not silently drop a valid shorter name nested inside it (林动) — the shorter name
// survives and injects, the longer one is separately spoiler-rejected+logged. Equal-length
// overlaps are both kept (genuine surface ambiguity, surfaced by the injectivity check).
// O(n²) over the few matches in a chunk.
func (b *MemoryBank) suppressContained(ms []acMatch, chapter int) []acMatch {
validSuppressor := func(m acMatch) bool {
for _, ei := range b.keyOwners[b.ac.keys[m.keyIdx]] {
if !spoilerBlocked(&b.entries[ei], chapter) {
return true
}
}
return false
}
var kept []acMatch
for i, m := range ms {
contained := false
for j, o := range ms {
if i == j {
continue
}
if o.start <= m.start && o.end >= m.end && (o.end-o.start) > (m.end-m.start) && validSuppressor(o) {
contained = true
break
}
}
if !contained {
kept = append(kept, m)
}
}
return kept
}
// injectivityCollisions reports approved dst collisions (B2): two distinct source
// terms mapped to the SAME dst (one Russian surface for two entities → the reader
// cannot tell them apart), returned as human-readable strings for a load-time warning.
// A pure diagnostic; it does not reject (some collisions are legitimate, e.g. a title
// shared by rank tiers), so the caller logs it, not aborts.
func injectivityCollisions(rows []store.GlossaryEntry) []string {
bySurface := map[string][]string{}
for _, r := range rows {
if r.Status != "approved" || strings.TrimSpace(r.Dst) == "" {
continue
}
key := normalizeTargetForm(r.Dst)
bySurface[key] = append(bySurface[key], r.Src)
}
var out []string
// Deterministic order: iterate a sorted key list, not the map.
keys := make([]string, 0, len(bySurface))
for k := range bySurface {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
srcs := bySurface[k]
if len(distinct(srcs)) > 1 {
sort.Strings(srcs)
out = append(out, "dst "+strconv.Quote(k)+" ← "+strings.Join(distinct(srcs), ", "))
}
}
return out
}
func distinct(ss []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range ss {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
sort.Strings(out)
return out
}

View file

@ -0,0 +1,177 @@
package pipeline
import (
"testing"
"textmachine/backend/internal/store"
)
// memory_e1_test.go: the E1 MEASUREMENT the onboarding contract requires BEFORE the
// post-check may be trusted as a hard gate (research/13 §2 "post-check в русском —
// несущий И невалидированный"; research/14 §2 quantified it at 1836% false flags for a
// naive matcher). It measures the decl-aware post-check's false-flag rate on REAL
// inflected Russian (the adaptive_levers/adaptive_reask canon: 阿Q ch.59, Rogov),
// empirically — not "примерно" (the agent's explicit ask).
//
// It answers two things the agent flagged:
// 1. Stored-decl WORKS when the forms are complete → 0 false flags on a correctly
// rendered, inflected chunk (the arm that lets the post-check be a real signal).
// 2. Stored-decl is NOT free-reliable: with only the base form (an under-filled decl,
// the OOV-translit risk — one cheap LLM call may miss cases) the SAME chunk yields
// false flags. This is why v1 keeps the post-check a FLAGGER, not a hard gate: if
// the seed under-fills decl, it must degrade to observability, not block chunks.
//
// The inflection cases are the exact ones research/14 measured a naive matcher losing:
// «Вэйчжуане» (loc, a SUFFIX inflection substring-matching handles), «Бородатого Вана»
// (a modifier that inflects INTERNALLY — substring of the base fails), «сюцая»
// (stem-final й→я — the base is not a substring), «Маленького Дэна» (internal).
// e1Entity is one canon entity with its full decl set.
type e1Entity struct {
src string
baseDst string
fullDecl []string
}
var e1Canon = []e1Entity{
{"阿Q", "А-кью", []string{"А-кью"}}, // hyphenated translit, invariant
{"未庄", "Вэйчжуан", []string{"Вэйчжуан", "Вэйчжуане", "Вэйчжуана", "Вэйчжуану", "Вэйчжуаном"}},
{"王胡", "Бородатый Ван", []string{"Бородатый Ван", "Бородатого Вана", "Бородатому Вану", "Бородатым Ваном", "Бородатом Ване"}},
{"秀才", "сюцай", []string{"сюцай", "сюцая", "сюцаю", "сюцае", "сюцаем"}},
{"小D", "Маленький Дэн", []string{"Маленький Дэн", "Маленького Дэна", "Маленькому Дэну", "Маленьким Дэном", "Маленьком Дэне"}},
{"邹七嫂", "тётушка Цзоу Седьмая", []string{"тётушка Цзоу Седьмая", "тётушки Цзоу Седьмой", "тётушку Цзоу Седьмую"}},
}
// e1Source contains every src key so all six entities are injected.
const e1Source = "阿Q在未庄遇见王胡和秀才又碰到小D邹七嫂在一旁看着。"
// e1CorrectOutput is a publisher-quality rendering: every entity present but INFLECTED
// (the case a boundary regexp / a base-only matcher false-flags).
const e1CorrectOutput = "В Вэйчжуане Бородатого Вана и сюцая знали все. " +
"А-кью повстречал Маленького Дэна у реки; тётушка Цзоу Седьмая молча прошла мимо."
func e1Glossary(useFullDecl bool) []store.GlossaryEntry {
var out []store.GlossaryEntry
for _, e := range e1Canon {
g := gl(e.src, e.baseDst, "", "approved")
if useFullDecl {
g.Decl = declJSON(false, e.fullDecl...)
} else {
g.Decl = declJSON(true, e.baseDst) // naive: only the base form
}
out = append(out, g)
}
return out
}
func TestE1PostcheckFalseFlagRate(t *testing.T) {
// Arm A — full decl: the post-check must NOT false-flag a correct inflected output.
bFull := bankFrom(e1Glossary(true))
selFull := bFull.Select(e1Source, 1, nil, 0)
if len(selFull.injected) != len(e1Canon) {
t.Fatalf("expected all %d canon entities injected, got %d", len(e1Canon), len(selFull.injected))
}
fullMiss := bFull.postcheck(selFull.injected, e1CorrectOutput)
if len(fullMiss) != 0 {
t.Errorf("STORED-DECL FALSE FLAGS on correct output: %d/%d — %v", len(fullMiss), len(selFull.injected), fullMiss)
}
// Arm B — base only (under-filled decl): the SAME correct output now false-flags.
bBase := bankFrom(e1Glossary(false))
selBase := bBase.Select(e1Source, 1, nil, 0)
baseMiss := bBase.postcheck(selBase.injected, e1CorrectOutput)
fullRate := float64(len(fullMiss)) / float64(len(selFull.injected))
baseRate := float64(len(baseMiss)) / float64(len(selBase.injected))
t.Logf("E1 false-flag rate on real inflected Russian: full-decl %.0f%% (%d/%d), base-only %.0f%% (%d/%d)",
fullRate*100, len(fullMiss), len(selFull.injected),
baseRate*100, len(baseMiss), len(selBase.injected))
// The measurement's verdict: decl-awareness is essential (full ≪ base), and the
// stored-decl matcher is trustworthy ONLY when complete. Base-only reproduces the
// research/14 failure — the reason the post-check stays a flagger until validated.
if len(baseMiss) == 0 {
t.Error("base-only decl produced 0 false flags — the E1 measurement lost its hard inflection cases; check the fixture")
}
if len(fullMiss) >= len(baseMiss) {
t.Errorf("full decl (%d) did not reduce false flags vs base (%d)", len(fullMiss), len(baseMiss))
}
// The specific research/14 internal-inflection cases MUST be the base-only misses.
for _, src := range []string{"王胡", "秀才", "小D"} {
if !hasMiss(baseMiss, src) {
t.Errorf("base-only decl did not false-flag %s (expected an internal-inflection false flag)", src)
}
}
}
// TestPostcheckWhitespaceAndDashRobust covers self-review #2 (multi-word name wrapped
// across a newline / doubled space / NBSP) and #8 (hyphen/dash variants): a correctly
// rendered term in ANY of these forms must NOT be false-flagged.
func TestPostcheckWhitespaceAndDashRobust(t *testing.T) {
wang := gl("王胡", "Бородатый Ван", "", "approved")
wang.Decl = declJSON(false, "Бородатый Ван", "Бородатого Вана")
ahq := gl("阿Q", "А-кью", "", "approved")
ahq.Decl = declJSON(true, "А-кью")
b := bankFrom([]store.GlossaryEntry{wang, ahq})
sel := b.Select("王胡和阿Q在场。", 1, nil, 0)
robust := []string{
"Бородатого Вана и А-кью видели.", // baseline
"Бородатого\nВана и А-кью видели.", // #2 newline between the name's words
"Бородатого Вана и А-кью видели.", // #2 doubled space
"Бородатого Вана и А-кью видели.", // #2 NBSP
"Бородатого Вана и А–кью видели.", // #8 en-dash in А-кью
"Бородатого Вана и А‑кью видели.", // #8 non-breaking hyphen
}
for _, out := range robust {
if m := b.postcheck(sel.injected, out); len(m) != 0 {
t.Errorf("false flag on a correctly-rendered variant %q: %v", out, m)
}
}
}
// TestPostcheckWholeWordNoMasking covers self-review #3: a DROPPED short translit name
// must not be masked by an unrelated common word that merely contains its letters.
func TestPostcheckWholeWordNoMasking(t *testing.T) {
van := gl("王", "Ван", "", "approved")
van.AllowShort = true // single-char src, allowed here to exercise the target-side check
van.Decl = declJSON(false, "Ван", "Вана", "Вану", "Ваном", "Ване")
b := bankFrom([]store.GlossaryEntry{van})
sel := b.Select("王走了。", 1, nil, 0)
if len(sel.injected) != 1 {
t.Fatalf("expected 王 injected, got %d", len(sel.injected))
}
// 王 is ABSENT from each output, yet a naive substring would find «ван» inside these.
for _, masked := range []string{"Мимо прошёл караван верблюдов.", "Он сел на диван и молчал.", "Иван Петрович кивнул."} {
if m := b.postcheck(sel.injected, masked); !hasMiss(m, "王") {
t.Errorf("post-check masked a dropped name by an unrelated word: %q → %v", masked, m)
}
}
// A genuine rendering (inflected) is found as a whole word.
if m := b.postcheck(sel.injected, "К нему подошёл Ван."); len(m) != 0 {
t.Errorf("false flag on a correct whole-word rendering: %v", m)
}
if m := b.postcheck(sel.injected, "Он говорил с Ваном о делах."); len(m) != 0 {
t.Errorf("false flag on a correct inflected rendering: %v", m)
}
}
// TestE1PostcheckCatchesPoisoning: with complete decl, a WRONG rendering (poisoning /
// the model ignoring the glossary) is still a true-positive flag — the post-check's
// actual job. Precision does not come for free: the decl forms are inflections of the
// FULL dst phrase, so a foreign name that merely shares the head noun does NOT slip past.
func TestE1PostcheckCatchesPoisoning(t *testing.T) {
b := bankFrom(e1Glossary(true))
sel := b.Select("王胡走过来。", 1, nil, 0) // only 王胡 fires
if len(sel.injected) != 1 {
t.Fatalf("expected 1 injected, got %d", len(sel.injected))
}
// Correct inflected rendering → no flag.
if m := b.postcheck(sel.injected, "К нему подошёл Бородатый Ван."); len(m) != 0 {
t.Errorf("false flag on correct rendering: %v", m)
}
// Poisoned rendering (a different name) → flagged (true positive).
if m := b.postcheck(sel.injected, "К нему подошёл Ван Ху."); !hasMiss(m, "王胡") {
t.Errorf("post-check missed the poisoning (Ван Ху ≠ Бородатый Ван): %v", m)
}
}

View file

@ -0,0 +1,99 @@
package pipeline
import (
"testing"
"textmachine/backend/internal/store"
)
// memory_f1_test.go covers the F1 content-hash (D8): memoryVersion is a pure function of
// the frozen APPROVED rows + the algorithm versions. A change to the approved glossary
// re-pins loudly; auto/draft churn does not; the row id never leaks in.
func TestMemoryVersionStableAndSensitive(t *testing.T) {
base := []store.GlossaryEntry{
gl("阿Q", "А-кью", "name", "approved"),
gl("鲁镇", "Лучжэнь", "", "approved"),
}
v0 := computeMemoryVersion(base, false)
// Identical rows → identical version (idempotent; drift-proof content hash).
if computeMemoryVersion(base, false) != v0 {
t.Fatal("memoryVersion is not stable for identical rows")
}
// Changing an APPROVED dst → different version (a loud --resnapshot).
changed := []store.GlossaryEntry{
gl("阿Q", "А-Кью (изменено)", "name", "approved"),
gl("鲁镇", "Лучжэнь", "", "approved"),
}
if computeMemoryVersion(changed, false) == v0 {
t.Error("changing an approved dst did not change memoryVersion — F1 would silently regress")
}
// Adding an alias to an approved row → different version (aliases affect matching).
withAlias := []store.GlossaryEntry{
func() store.GlossaryEntry { e := gl("阿Q", "А-кью", "name", "approved"); e.Aliases = []store.GlossaryAlias{alias("阿桂")}; return e }(),
gl("鲁镇", "Лучжэнь", "", "approved"),
}
if computeMemoryVersion(withAlias, false) == v0 {
t.Error("adding an alias to an approved row did not change memoryVersion")
}
// Changing an AUTO row → SAME version (approved-only, D8; auto changes are caught by
// the per-chunk content_hash instead).
withAuto := append(append([]store.GlossaryEntry{}, base...), gl("小D", "Малыш Дэ", "", "auto"))
if computeMemoryVersion(withAuto, false) != v0 {
t.Error("an auto row changed memoryVersion — it must hash only approved rows (D8)")
}
withAuto2 := append(append([]store.GlossaryEntry{}, base...), gl("小D", "Другой Дэ", "", "auto"))
if computeMemoryVersion(withAuto, false) != computeMemoryVersion(withAuto2, false) {
t.Error("editing an auto row changed memoryVersion — auto must not enter the F1 hash")
}
}
// TestMemoryVersionIgnoresID: the autoincrement glossary.id is fresh on every replace;
// it must NOT feed the hash (else a re-seed of identical content would false-resnapshot).
func TestMemoryVersionIgnoresID(t *testing.T) {
a := []store.GlossaryEntry{{BookID: "b", ID: 1, Src: "甲", Dst: "А", Status: "approved"}}
b := []store.GlossaryEntry{{BookID: "b", ID: 999, Src: "甲", Dst: "А", Status: "approved"}}
if computeMemoryVersion(a, false) != computeMemoryVersion(b, false) {
t.Error("memoryVersion depends on glossary.id — a re-seed would false-resnapshot")
}
}
// TestMaterializeMemoryVersionMatches: the bank's Version() equals computeMemoryVersion
// of its input rows (the runner hashes the same thing it matches on).
func TestMaterializeMemoryVersionMatches(t *testing.T) {
rows := specGlossary()
b := materializeMemory(rows, false)
if b.Version() != computeMemoryVersion(rows, false) {
t.Error("bank Version() diverges from computeMemoryVersion of its rows")
}
}
// TestMemoryVersionGateFoldsUnapprovedDecl covers self-review #4: when the post-check
// hard gate is ON, editing a DRAFT/auto row's decl (which changes the gate's resolved
// disposition but is in NEITHER content_hash NOR the approved-only hash) MUST change
// memoryVersion — else a $0 resume silently flips a chunk. Gate OFF: it must NOT change
// it (D8 approved-only holds; decl affects only the recomputed retrieval-state).
func TestMemoryVersionGateFoldsUnapprovedDecl(t *testing.T) {
mk := func(declForm string) []store.GlossaryEntry {
e := gl("鈴木", "Судзуки", "", "draft")
e.Decl = declJSON(false, declForm)
return []store.GlossaryEntry{gl("阿Q", "А-кью", "", "approved"), e}
}
a := mk("Судзуки")
b := mk("Судзуку") // only the DRAFT decl form changed (dst unchanged, status draft)
if computeMemoryVersion(a, false) != computeMemoryVersion(b, false) {
t.Error("gate OFF: a draft decl change must NOT move memoryVersion (D8 approved-only)")
}
if computeMemoryVersion(a, true) == computeMemoryVersion(b, true) {
t.Error("gate ON: a draft decl change MUST move memoryVersion (else a resume silently flips the chunk)")
}
// Toggling the gate itself also changes the version (a mode change is a loud re-pin).
if computeMemoryVersion(a, false) == computeMemoryVersion(a, true) {
t.Error("toggling the post-check gate must change memoryVersion")
}
}

View file

@ -0,0 +1,372 @@
package pipeline
import (
"encoding/json"
"strings"
"testing"
"textmachine/backend/internal/store"
)
// memory_test.go ports the executable spec eval/memory_hotpath.py (T1T10) to Go
// unit tests, per the onboarding contract — but NOT the accept-REGEXPS verbatim
// (research/14: a \b-regexp is the 1836% false-flag source). The selection tests
// (T1T8) are the same cases; the post-check tests (T9/T10) use the decl-aware
// matcher (memory_e1_test.go measures its false-flag rate on real Russian).
func gl(src, dst, sense, status string) store.GlossaryEntry {
return store.GlossaryEntry{BookID: "b", Src: src, Dst: dst, Sense: sense, Status: status, Source: "seed"}
}
func declJSON(invariant bool, forms ...string) string {
b, _ := json.Marshal(declInfo{Invariant: invariant, Forms: forms})
return string(b)
}
func alias(a string) store.GlossaryAlias { return store.GlossaryAlias{Alias: a, AliasType: "прозвище"} }
// specGlossary mirrors eval/memory_hotpath.py's G (with decl forms replacing the
// toy accept-regexps).
func specGlossary() []store.GlossaryEntry {
ahq := gl("阿Q", "А-кью", "name", "approved")
ahq.Decl = declJSON(true, "А-кью", "А кью")
sishu := gl("四叔", "Четвёртый дядюшка", "char", "approved")
sishu.Aliases = []store.GlossaryAlias{alias("鲁四老爷")}
sishu.Decl = declJSON(false, "дядюшка", "дядюшки", "дядюшке", "дядюшку", "дядюшкой")
luzhen := gl("鲁镇", "Лучжэнь", "", "approved")
luzhen.Decl = declJSON(false, "Лучжэнь", "Лучжэня", "Лучжэне")
songzao := gl("送灶", "проводы бога очага", "festival", "approved")
songzao.Decl = declJSON(true, "бога очага")
xiaoyan := gl("萧炎", "Сяо Янь", "name", "approved")
xiaoyan.Aliases = []store.GlossaryAlias{alias("炎哥")}
xiaoyan.Decl = declJSON(true, "Сяо Янь")
xiuwei := gl("修为", "уровень совершенствования", "cultivation", "approved")
xiuwei.Decl = declJSON(false, "совершенствования", "совершенствование")
shadow := gl("影卫", "Тёмный страж", "spoiler", "approved")
shadow.SinceCh = 200
shadow.Decl = declJSON(false, "Тёмный страж", "Тёмного стража")
newname := gl("小D", "Малыш Дэ", "", "auto")
newname.Decl = declJSON(false, "Малыш Дэ", "Малыша Дэ")
return []store.GlossaryEntry{ahq, sishu, luzhen, songzao, xiaoyan, xiuwei, shadow, newname}
}
func bankFrom(entries []store.GlossaryEntry) *MemoryBank { return materializeMemory(entries, false) }
// injMap maps injected src → disposition for assertions.
func injMap(sel memorySelection) map[string]injectionDisposition {
m := map[string]injectionDisposition{}
for _, p := range sel.injected {
m[p.entry.src] = p.disp
}
return m
}
func TestHotPathSpecT1toT10(t *testing.T) {
b := bankFrom(specGlossary())
const budget = 0
// T1: exact + alias select; alias 鲁四老爷 pulls 四叔.
sel := b.Select("阿Q走进鲁镇遇见鲁四老爷。", 1, nil, budget)
m := injMap(sel)
if m["阿Q"] != memConfirmed || m["鲁镇"] != memConfirmed || m["四叔"] != memConfirmed {
t.Errorf("T1 exact+alias: %v", m)
}
// T2: 送到灶 (literal) — 送灶 must NOT inject (bigram absent, 灶 single-key banned).
sel = b.Select("厨房里,老妈子把饭菜送到灶间去热一热。", 1, nil, budget)
if _, ok := injMap(sel)["送灶"]; ok {
t.Error("T2 wrong-sense 送灶 injected (A3 homograph)")
}
// T3: 炎势 (flame) — 萧炎 must NOT inject.
sel = b.Select("山火蔓延,炎势冲天,村民连夜逃走。", 1, nil, budget)
if _, ok := injMap(sel)["萧炎"]; ok {
t.Error("T3 wrong-sense 炎 injected")
}
// T4: 修好 (repair) — 修为 must NOT inject.
sel = b.Select("工匠把石桥修好了。", 1, nil, budget)
if _, ok := injMap(sel)["修为"]; ok {
t.Error("T4 wrong-sense 修 injected")
}
// T5: trad→simp normalization — 魯鎮 matches key 鲁镇 (A4).
sel = b.Select("魯鎮的冬天很冷。", 1, nil, budget)
if _, ok := injMap(sel)["鲁镇"]; !ok {
t.Error("T5 trad→simp: 魯鎮 did not match 鲁镇")
}
// T6: pronominal chunk — sticky carries 四叔 from the previous chunk.
sishuID := "四叔\x1fchar\x1f0\x1f0"
sel = b.Select("他慢慢站起来,叹了口气。", 2, map[string]bool{sishuID: true}, budget)
var stickySishu *pickedEntry
for i := range sel.injected {
if sel.injected[i].entry.src == "四叔" {
stickySishu = &sel.injected[i]
}
}
if stickySishu == nil || stickySishu.via != "sticky" {
t.Errorf("T6 sticky scene-inertia failed: %+v", injMap(sel))
}
// T7: spoiler — 影卫 (since_ch=200) in chapter 5 → hard reject.
sel = b.Select("影卫出现了。", 5, nil, budget)
if _, ok := injMap(sel)["影卫"]; ok {
t.Error("T7 spoiler 影卫 injected in ch5")
}
if len(sel.rejected) != 1 || sel.rejected[0].entry.src != "影卫" {
t.Errorf("T7 spoiler not rejected+logged: %+v", sel.rejected)
}
// T7b: same entry passes after the reveal chapter.
sel = b.Select("影卫出现了。", 250, nil, budget)
if _, ok := injMap(sel)["影卫"]; !ok {
t.Error("T7b spoiler entry did not pass at ch250")
}
// T8: auto term 小D → ambiguous disposition.
sel = b.Select("小D也来了。", 1, nil, budget)
if injMap(sel)["小D"] != memAmbiguous {
t.Errorf("T8 auto-term not ambiguous: %v", injMap(sel))
}
// T9: post-check catches a missing dst (leak) and passes a present one.
sel = b.Select("阿Q走进鲁镇。", 1, nil, budget)
bad := b.postcheck(sel.injected, "Некто вошёл в Лучжэнь.") // А-кью dropped
good := b.postcheck(sel.injected, "А-кью вошёл в Лучжэнь.")
if !hasMiss(bad, "阿Q") {
t.Errorf("T9 post-check missed the leak: %+v", bad)
}
if len(good) != 0 {
t.Errorf("T9 post-check false-flagged a correct output: %+v", good)
}
// T10: post-check catches poisoning — injected dst absent, output used a foreign name.
sel = b.Select("阿Q走进鲁镇。", 1, nil, budget)
poison := b.postcheck(sel.injected, "Линь Чун вошёл в деревню Вэйчжуан.")
if !hasMiss(poison, "阿Q") {
t.Errorf("T10 post-check missed the poisoning: %+v", poison)
}
}
func hasMiss(misses []postcheckMiss, src string) bool {
for _, m := range misses {
if m.Src == src {
return true
}
}
return false
}
// TestTrapCorpus ports the retrieval_bench wrong-sense traps: exact multi-char match
// must fire 0/N on homograph chunks that contain the KEY CHARACTER but not the term.
func TestTrapCorpus(t *testing.T) {
entries := []store.GlossaryEntry{
gl("送灶", "проводы бога очага", "festival", "approved"),
gl("修为", "уровень совершенствования", "cultivation", "approved"),
gl("萧炎", "Сяо Янь", "name", "approved"),
gl("斗气", "боевая ци", "term", "approved"),
gl("钱老爷", "господин Цянь", "name", "approved"), // multi-char; the bare 钱 homograph must not fire
}
b := bankFrom(entries)
traps := []struct{ name, text string }{
{"送到灶 literal", "厨房里冷了,老妈子把剩下的饭菜送到灶间去重新热一热。"},
{"修好 repair", "工匠们花了三天,终于把那条被山洪冲垮的石桥修好了。"},
{"炎势 flame", "山火蔓延,炎势冲天,浓烟滚滚,村民连夜收拾细软。"},
{"雾气 mist", "清晨的山谷里雾气很重,空气格外清新,露水打湿了草叶。"},
{"钱 money homograph", "他摸出几个铜钱,买了一碗热汤。"}, // 钱 present but 钱老爷 absent
{"Chekhov negative", "Дмитрий Гуров сидел в ялтинском павильоне и лениво пил чай."},
}
for _, tr := range traps {
t.Run(tr.name, func(t *testing.T) {
sel := b.Select(tr.text, 1, nil, 0)
if len(sel.injected) != 0 {
t.Errorf("trap fired %d injection(s), want 0: %v", len(sel.injected), injMap(sel))
}
})
}
}
// TestLongestMatchContainment: a shorter key fully inside a longer key's span is
// suppressed (longest-match / whole-entity replacement, A3), but the shorter entity
// still fires when it stands alone.
func TestLongestMatchContainment(t *testing.T) {
entries := []store.GlossaryEntry{
gl("阿Q", "А-кью", "name", "approved"),
gl("阿Q正传", "«Подлинная история А-кью»", "title", "approved"),
}
b := bankFrom(entries)
// Inside the title: only the title fires.
sel := b.Select("我读了《阿Q正传》。", 1, nil, 0)
m := injMap(sel)
if _, ok := m["阿Q正传"]; !ok {
t.Errorf("title did not fire: %v", m)
}
if _, ok := m["阿Q"]; ok {
t.Errorf("short key 阿Q fired inside the longer title span: %v", m)
}
// Standalone: the character name fires.
sel = b.Select("阿Q走进村子。", 1, nil, 0)
if _, ok := injMap(sel)["阿Q"]; !ok {
t.Error("阿Q did not fire standalone")
}
}
func TestSingleKeyBanAndAllowShort(t *testing.T) {
banned := gl("炎", "пламя-как-имя", "", "approved") // single Han → banned by default
b := bankFrom([]store.GlossaryEntry{banned})
if sel := b.Select("炎势冲天。", 1, nil, 0); len(sel.injected) != 0 {
t.Error("single-char key fired despite the A3 ban")
}
// allow_short escape hatch lets it fire.
banned.AllowShort = true
b = bankFrom([]store.GlossaryEntry{banned})
if sel := b.Select("炎势冲天。", 1, nil, 0); len(sel.injected) != 1 {
t.Error("allow_short did not re-enable the single-char key")
}
}
func TestBudgetEviction(t *testing.T) {
entries := []store.GlossaryEntry{
gl("甲甲", "Альфа", "", "approved"),
gl("乙乙", "Бета", "", "auto"), // ambiguous → lower priority
gl("丙丙", "Гамма", "", "approved"),
}
b := bankFrom(entries)
chunk := "甲甲、乙乙、丙丙都在。"
// Unbounded: all three fire, in priority order (the two approved before the auto).
all := b.Select(chunk, 1, nil, 0)
if len(all.injected) != 3 {
t.Fatalf("unbounded: want 3 injected, got %d", len(all.injected))
}
// A token budget that fits exactly the top TWO priority records (computed with the
// same cost helper Select uses, so the test never drifts from the implementation).
budget := glossaryLineTokens(all.injected[0].entry) + glossaryLineTokens(all.injected[1].entry)
sel := b.Select(chunk, 1, nil, budget)
if len(sel.injected) != 2 || len(sel.evicted) != 1 {
t.Fatalf("budget: injected=%d evicted=%d (budget=%d)", len(sel.injected), len(sel.evicted), budget)
}
// The auto (ambiguous) term is the one evicted (confirmed>ambiguous priority, F2).
if sel.evicted[0].entry.src != "乙乙" {
t.Errorf("eviction priority wrong: evicted %s (want the auto term 乙乙)", sel.evicted[0].entry.src)
}
}
// TestSelectDeterminism runs Select many times over a multi-hit chunk and asserts the
// injected order is byte-identical — guards against a map-iteration leak (the
// determinism invariant: the injected bytes enter request_hash).
func TestSelectDeterminism(t *testing.T) {
b := bankFrom(specGlossary())
chunk := "阿Q走进鲁镇遇见鲁四老爷谈起修为。"
first := selKey(b.Select(chunk, 1, nil, 0))
for i := 0; i < 200; i++ {
if got := selKey(b.Select(chunk, 1, nil, 0)); got != first {
t.Fatalf("non-deterministic Select: run %d gave %q, first was %q", i, got, first)
}
}
}
func selKey(sel memorySelection) string {
s := ""
for _, p := range sel.injected {
s += p.entry.src + ":" + string(p.disp) + "|"
}
return s
}
// TestEmptyDstNotMatchable covers self-review #1: a ruby candidate (dst="") must not be
// matchable — it renders nothing, so admitting it would waste budget, evict a real line,
// and inflate the exact-hit count.
func TestEmptyDstNotMatchable(t *testing.T) {
phantom := gl("甲甲", "", "", "auto") // ruby-style candidate, no dst
real := gl("乙乙", "Настоящий", "", "auto")
b := bankFrom([]store.GlossaryEntry{phantom, real})
// Tiny budget that would fit exactly one line: the phantom must not steal it.
budget := glossaryLineTokens(&memoryEntry{src: "乙乙", dst: "Настоящий"})
sel := b.Select("甲甲和乙乙都在场。", 1, nil, budget)
if len(sel.injected) != 1 || sel.injected[0].entry.src != "乙乙" {
t.Fatalf("empty-dst phantom polluted selection: injected=%v", injMap(sel))
}
if renderGlossaryBlock(sel.injected) == "" {
t.Error("a renderable line was evicted by an empty-dst phantom")
}
// Unbounded: the phantom never appears as an exact hit.
sel = b.Select("甲甲和乙乙都在场。", 1, nil, 0)
if _, ok := injMap(sel)["甲甲"]; ok {
t.Error("empty-dst phantom counted as an exact hit")
}
}
// TestSpoilerBeforeSuppression covers self-review #6: a spoiler-blocked LONGER key must
// not suppress a valid SHORTER name nested inside it.
func TestSpoilerBeforeSuppression(t *testing.T) {
short := gl("林动", "Линь Дун", "", "approved")
long := gl("林动的父亲", "отец Линь Дуна", "", "approved")
long.UntilCh = 3 // a spoiler: valid only through chapter 3
b := bankFrom([]store.GlossaryEntry{short, long})
// Chapter 10: the longer key is spoiler-blocked; the shorter valid name must survive.
sel := b.Select("林动的父亲缓缓转身。", 10, nil, 0)
if _, ok := injMap(sel)["林动"]; !ok {
t.Errorf("valid shorter name dropped by a spoiler-blocked longer key: %v", injMap(sel))
}
if _, ok := injMap(sel)["林动的父亲"]; ok {
t.Error("spoiler-blocked longer key was injected")
}
if len(sel.rejected) != 1 || sel.rejected[0].entry.src != "林动的父亲" {
t.Errorf("spoiler-blocked longer key not logged as rejected: %+v", sel.rejected)
}
// Chapter 2 (longer key in window): longest-match still prefers the longer entity.
sel = b.Select("林动的父亲缓缓转身。", 2, nil, 0)
if _, ok := injMap(sel)["林动"]; ok {
t.Error("longest-match failed when the longer key is spoiler-valid")
}
if _, ok := injMap(sel)["林动的父亲"]; !ok {
t.Error("spoiler-valid longer key was not injected at ch2")
}
}
// TestBudgetFloorFree covers self-review #7: the per-line budget cost is floor-free, so a
// block of short name lines is not ~1.85×-overcounted and needlessly evicted.
func TestBudgetFloorFree(t *testing.T) {
var entries []store.GlossaryEntry
srcs := []string{"甲甲", "乙乙", "丙丙", "丁丁", "戊戊", "己己", "庚庚", "辛辛"}
dsts := []string{"Ко", "Оцу", "Хэй", "Тэй", "Бо", "Ки", "Ко2", "Син"}
for i := range srcs {
entries = append(entries, gl(srcs[i], dsts[i], "", "approved"))
}
b := bankFrom(entries)
chunk := strings.Join(srcs, "、") + "都在场。"
all := b.Select(chunk, 1, nil, 0)
if len(all.injected) != 8 {
t.Fatalf("want 8 injected, got %d", len(all.injected))
}
// A budget equal to the EXACT sum of the (floor-free) line costs fits all 8.
budget := 0
for _, p := range all.injected {
budget += glossaryLineTokens(p.entry)
}
sel := b.Select(chunk, 1, nil, budget)
if len(sel.injected) != 8 || len(sel.evicted) != 0 {
t.Errorf("floor-free budget=%d should fit all 8: injected=%d evicted=%d", budget, len(sel.injected), len(sel.evicted))
}
}
func TestInjectivityCollision(t *testing.T) {
entries := []store.GlossaryEntry{
{BookID: "b", Src: "甲", Dst: "Мастер", Status: "approved"},
{BookID: "b", Src: "乙", Dst: "Мастер", Status: "approved"}, // same dst → collision (B2)
{BookID: "b", Src: "丙", Dst: "Ученик", Status: "approved"},
}
cols := injectivityCollisions(entries)
if len(cols) != 1 {
t.Fatalf("want 1 dst collision, got %d: %v", len(cols), cols)
}
}

View file

@ -0,0 +1,109 @@
package pipeline
import (
"strings"
"unicode"
)
// mempostcheck.go: the post-check (registry E1/E2 / research/13 Q5) — the main, almost
// free detector of silent degradation. For every injected record whose SRC actually
// fired in THIS chunk, it asks: did an accepted DST form appear in the output? A miss
// catches BOTH failure modes — "the model ignored the glossary" (soft-following loses
// 1736% of terms, 2310.05824) AND "we injected the wrong dst and the model obeyed it"
// (the owner's fear, 2510.00829). It is a post-CHECK, never a blind post-REPLACE (E2):
// forcing a dictionary form into an oblique Russian case breaks agreement (46% of
// constrained-model errors in en-cs are agreement, 2106.12398), so v1 only FLAGS.
//
// Decl-awareness is load-bearing (research/14 §2, quantified): a naive \b-regexp on the
// base form gives 1836% FALSE flags on a hard chunk — inflected forms rendered
// CORRECTLY ("Вэйчжуане", "Бородатого Вана", "Дэна", "сюцая") that the boundary regexp
// misses. So the check matches against the STORED decl forms (filled at term-commit),
// not the bare lemma. BUT stored-decl reliability is itself a function of decl
// COMPLETENESS — an OOV translit name whose forms were under-filled re-introduces the
// same false flags. That is why v1 keeps the post-check a FLAGGER (observable, in the
// retrieval-state), NOT a hard disposition gate, until the false-flag rate is MEASURED
// on real inflected Russian (E1 — memory_e1_test.go). A hard gate flips on only after
// the owner validates precision (mirrors the coverage gate's opt-in, D12 Q4).
//
// Sticky records are DELIBERATELY excluded: a sticky entry's src is NOT in this chunk
// (that is why scene-inertia carries it), so its dst is legitimately absent from the
// output — post-checking it would false-flag every pronominal chunk. Only records whose
// key exactly fired here are expected in the output.
// postcheckMiss is one flagged term: an injected record whose src fired but whose dst
// (any accepted form) is absent from the output.
type postcheckMiss struct {
Src string `json:"src"`
Dst string `json:"dst"`
Disp string `json:"disp"` // confirmed | ambiguous (A2: ambiguous injections force a post-check)
}
// postcheck runs the decl-aware check over the injected records against the model
// output. Pure and deterministic. Returns the misses (empty = all fired terms present).
func (b *MemoryBank) postcheck(injected []pickedEntry, output string) []postcheckMiss {
nout := normalizeTargetForm(output)
var misses []postcheckMiss
noutRunes := []rune(nout)
for _, p := range injected {
if p.via == "sticky" { // sticky context is not expected in the output
continue
}
if strings.TrimSpace(p.entry.dst) == "" { // a ruby candidate with no dst yet — nothing to check
continue
}
if !dstFormPresent(p.entry, noutRunes) {
misses = append(misses, postcheckMiss{Src: p.entry.src, Dst: p.entry.dst, Disp: string(p.disp)})
}
}
return misses
}
// dstFormPresent reports whether any accepted dst form of the entry appears in the
// normalized output as a WHOLE WORD (bounded by non-letters or string edges), not a bare
// substring. The whole-word rule closes self-review #3: a DROPPED short translit name
// («Ван») must not be masked by an unrelated common word that merely contains its letters
// («караВАН», «диВАН», «ИВАН» — a different person). Decl-aware (the research/14
// requirement): the stored decl forms carry the inflections, so precision and recall are
// both satisfied when decl is complete; the base-dst fallback (empty decl) is the naive
// case the E1 measurement quantifies. noutRunes is the normalized output pre-decomposed.
func dstFormPresent(e *memoryEntry, noutRunes []rune) bool {
forms := e.declForms
if len(forms) == 0 {
forms = []string{normalizeTargetForm(e.dst)}
}
for _, f := range forms {
if f != "" && containsWholeWord(noutRunes, []rune(f)) {
return true
}
}
return false
}
// containsWholeWord reports whether form occurs in text with a letter-boundary on both
// ends (the char before the match is not a letter or is the start; likewise after). Runs
// on runes so Cyrillic boundaries are correct. O(len(text)·len(form)) — fine, the
// post-check runs once per chunk over a handful of short forms.
func containsWholeWord(text, form []rune) bool {
n := len(form)
if n == 0 || n > len(text) {
return false
}
for i := 0; i+n <= len(text); i++ {
if !runesEqual(text[i:i+n], form) {
continue
}
if (i == 0 || !unicode.IsLetter(text[i-1])) && (i+n == len(text) || !unicode.IsLetter(text[i+n])) {
return true
}
}
return false
}
func runesEqual(a, b []rune) bool {
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -0,0 +1,253 @@
package pipeline
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"unicode"
"gopkg.in/yaml.v3"
"textmachine/backend/internal/store"
)
// memseed.go: the DETERMINISTIC seeding of the glossary from its two milestone inputs —
// a manual seed file (curated approved/draft terms) and the captured ruby readings
// (classified into auto candidates). No LLM (autopopulation/adjudication are a later
// milestone). The output is a []store.GlossaryEntry that seedGlossary REPLACES into the
// book (idempotent full-replace: same inputs → same rows, an edit converges every column).
// --- manual seed file (YAML) ----------------------------------------------------
type seedFile struct {
Terms []seedTerm `yaml:"terms"`
}
type seedTerm struct {
Src string `yaml:"src"`
Dst string `yaml:"dst"`
Type string `yaml:"type"`
Sense string `yaml:"sense"`
Gender string `yaml:"gender"`
Speech string `yaml:"speech"`
Decl *seedDecl `yaml:"decl"`
TranslitPolicy string `yaml:"translit_policy"`
FirstPerson string `yaml:"first_person"`
NicknameTranslation string `yaml:"nickname_translation"`
SinceCh int `yaml:"since_ch"`
UntilCh int `yaml:"until_ch"`
Status string `yaml:"status"`
AllowShort bool `yaml:"allow_short"`
Note string `yaml:"note"`
Aliases []seedAlias `yaml:"aliases"`
}
type seedDecl struct {
Invariant bool `yaml:"invariant"`
Forms []string `yaml:"forms"`
}
type seedAlias struct {
Alias string `yaml:"alias"`
Type string `yaml:"type"`
}
// loadGlossarySeed parses a glossary seed YAML into store entries. A manual seed is
// curated, so an empty status defaults to "approved" (the author downgrades explicitly
// with status: draft|auto). Validation is fail-loud: a term without src, or with an
// unknown status, is a config error (a silently-dropped term is exactly the A-class
// hole this bank exists to close).
func loadGlossarySeed(path string) ([]store.GlossaryEntry, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("pipeline: read glossary seed %s: %w", path, err)
}
var sf seedFile
if err := yaml.Unmarshal(raw, &sf); err != nil {
return nil, fmt.Errorf("pipeline: parse glossary seed %s: %w", path, err)
}
var out []store.GlossaryEntry
var problems []string
termKeys := map[[4]string]bool{} // (src,sense,since,until) → detect a duplicate term key
for i, t := range sf.Terms {
if strings.TrimSpace(t.Src) == "" {
problems = append(problems, fmt.Sprintf("term %d: src is required", i))
continue
}
// A duplicate (src, sense, window) would crash ReplaceGlossary on the store's
// UNIQUE constraint mid-run (aborting the whole book) — catch it here as a clear
// config error instead (self-review #5, sibling of the alias case below).
tk := [4]string{t.Src, t.Sense, fmt.Sprint(t.SinceCh), fmt.Sprint(t.UntilCh)}
if termKeys[tk] {
problems = append(problems, fmt.Sprintf("term %q: duplicate (src, sense=%q, since_ch=%d, until_ch=%d) — a term may appear once per spoiler window", t.Src, t.Sense, t.SinceCh, t.UntilCh))
continue
}
termKeys[tk] = true
status := t.Status
if status == "" {
status = "approved"
}
switch status {
case "auto", "draft", "approved":
default:
problems = append(problems, fmt.Sprintf("term %q: status must be auto|draft|approved, got %q", t.Src, status))
continue
}
decl := ""
if t.Decl != nil {
b, mErr := json.Marshal(declInfo{Invariant: t.Decl.Invariant, Forms: t.Decl.Forms})
if mErr != nil {
return nil, mErr
}
decl = string(b)
}
e := store.GlossaryEntry{
BookID: "", Src: t.Src, Dst: t.Dst, Type: t.Type, Sense: t.Sense,
Gender: t.Gender, Speech: t.Speech, Decl: decl,
TranslitPolicy: t.TranslitPolicy, FirstPerson: t.FirstPerson,
NicknameTranslation: t.NicknameTranslation,
SinceCh: t.SinceCh, UntilCh: t.UntilCh, Status: status,
AllowShort: t.AllowShort, Source: "seed", Note: t.Note,
}
// Dedup aliases within a term by surface (keep the first type). A repeated alias
// surface — a copy-paste, or the same name annotated with two types — would
// otherwise crash ReplaceGlossary on UNIQUE(book_id,term_id,alias) and abort the
// whole book run (self-review #5). Deduping is the forgiving fix: the surface is
// what matters for matching, and a redundant alias is a harmless authoring slip.
seenAlias := map[string]bool{}
for _, a := range t.Aliases {
if strings.TrimSpace(a.Alias) == "" {
problems = append(problems, fmt.Sprintf("term %q: an alias is empty", t.Src))
continue
}
if seenAlias[a.Alias] {
continue
}
seenAlias[a.Alias] = true
e.Aliases = append(e.Aliases, store.GlossaryAlias{Alias: a.Alias, AliasType: a.Type})
}
out = append(out, e)
}
if len(problems) > 0 {
return nil, fmt.Errorf("glossary seed %s:\n - %s", path, strings.Join(problems, "\n - "))
}
return out, nil
}
// --- ruby → auto candidates -----------------------------------------------------
// rubyToCandidates classifies the captured ruby readings into auto glossary candidates
// (registry §Ruby-seed / §8). One candidate per BASE (the dominant reading by occurrence,
// ties broken lexically) so variant readings never collide on UNIQUE(src,sense,window)
// — the (base,reading) source of truth stays in ruby_readings. A base already covered by
// the manual seed is skipped (the curated entry wins). Every candidate is status=auto
// with NO dst: the reading is the Polivanov bridge (B6, Phase 2), not a dst — so nothing
// is auto-injected, and a human/batch-judge promotes it (never a blind name-lock, which
// would be a mistranslation). The ruby_class is a deterministic HINT for that promotion.
func rubyToCandidates(readings []store.RubyReading, manualSrcs map[string]bool) []store.GlossaryEntry {
// Group by base → the dominant (base,reading).
type best struct {
reading string
occ int
firstCh int
}
byBase := map[string]best{}
order := []string{}
for _, rr := range readings {
if manualSrcs[rr.Base] {
continue // the curated seed already owns this src
}
cur, ok := byBase[rr.Base]
if !ok {
byBase[rr.Base] = best{rr.Reading, rr.Occurrences, rr.FirstChapter}
order = append(order, rr.Base)
continue
}
// Dominant reading: higher occurrences wins; tie → lexically smaller reading.
if rr.Occurrences > cur.occ || (rr.Occurrences == cur.occ && rr.Reading < cur.reading) {
cur.reading, cur.occ = rr.Reading, rr.Occurrences
}
if rr.FirstChapter < cur.firstCh { // earliest appearance across readings
cur.firstCh = rr.FirstChapter
}
byBase[rr.Base] = cur
}
sort.Strings(order) // deterministic output regardless of input order
var out []store.GlossaryEntry
for _, base := range order {
b := byBase[base]
class := classifyRubyReading(base, b.reading)
typ := ""
if class == rubyClassName {
typ = "name"
}
out = append(out, store.GlossaryEntry{
Src: base, Dst: "", Type: typ, Status: "auto", Source: "ruby",
RubyReading: b.reading, RubyClass: class, SinceCh: b.firstCh,
Confidence: b.occ,
Note: "ruby auto-candidate (" + class + "); reading is the Polivanov bridge, not a dst — promote before use",
})
}
return out
}
// ruby classifier classes (deterministic HINTS for human promotion).
const (
rubyClassName = "name_candidate" // all-Han base + all-kana reading: the furigana-name SHAPE
rubyClassGloss = "gloss_candidate" // reading carries Han: likely an author double-reading → footnote, NOT a lock
rubyClassAmbiguous = "ambiguous" // anything else → flag to a human
)
// classifyRubyReading is the deterministic clear-case rule (§8). It NEVER auto-locks:
// the all-Han+all-kana shape is a name-lock CANDIDATE (it could still be a double-reading
// like 強敵→とも "friend", indistinguishable without a yomi dictionary — Phase 2), so it is
// flagged for human confirmation, not locked. A reading that carries kanji is a clear
// author's double-reading/gloss → a footnote candidate. Over-flagging is safe; a blind
// lock is a mistranslation. Pure and deterministic.
func classifyRubyReading(base, reading string) string {
if runesAnyHan(reading) {
return rubyClassGloss // the "reading" is itself a word (semantic gloss / double-reading)
}
if runesAllHan(base) && runesAllKana(reading) {
return rubyClassName
}
return rubyClassAmbiguous
}
func runesAllHan(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !unicode.Is(unicode.Han, r) {
return false
}
}
return true
}
func runesAnyHan(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Han, r) {
return true
}
}
return false
}
func runesAllKana(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r == 'ー' || r == '・' { // prolonged-sound mark, middle dot — allowed within kana readings
continue
}
if !unicode.Is(unicode.Hiragana, r) && !unicode.Is(unicode.Katakana, r) {
return false
}
}
return true
}

View file

@ -0,0 +1,162 @@
package pipeline
import (
"os"
"path/filepath"
"testing"
"textmachine/backend/internal/store"
)
func writeSeed(t *testing.T, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "glossary.yaml")
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestLoadGlossarySeed(t *testing.T) {
seed := writeSeed(t, `
terms:
- src: 阿Q
dst: А-кью
type: name
decl: { invariant: true, forms: ["А-кью"] }
aliases:
- { alias: 阿桂, type: прозвище }
- src: 王胡
dst: Бородатый Ван
status: draft
decl: { forms: ["Бородатый Ван", "Бородатого Вана"] }
`)
entries, err := loadGlossarySeed(seed)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("want 2 entries, got %d", len(entries))
}
// Empty status defaults to approved (a manual seed is curated).
if entries[0].Status != "approved" {
t.Errorf("default status = %q, want approved", entries[0].Status)
}
if entries[0].Source != "seed" {
t.Errorf("source = %q, want seed", entries[0].Source)
}
if len(entries[0].Aliases) != 1 || entries[0].Aliases[0].Alias != "阿桂" {
t.Errorf("alias not parsed: %+v", entries[0].Aliases)
}
if entries[0].Decl == "" {
t.Error("decl JSON not marshaled")
}
if entries[1].Status != "draft" {
t.Errorf("explicit status not honored: %q", entries[1].Status)
}
}
func TestLoadGlossarySeedRejectsBad(t *testing.T) {
// Missing src → fail loud (a silently-dropped term is the A-class hole).
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - dst: без ключа\n")); err == nil {
t.Error("expected error for a term without src")
}
// Unknown status → fail loud.
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - src: 甲\n status: bogus\n")); err == nil {
t.Error("expected error for an unknown status")
}
}
// TestSeedAliasDedupAndDuplicateTerm covers self-review #5: a duplicate alias within a
// term must be deduped (not crash the run on UNIQUE), and a duplicate (src,sense,window)
// term must fail loud (not crash mid-run).
func TestSeedAliasDedupAndDuplicateTerm(t *testing.T) {
// Duplicate alias surface (annotated with two types) → deduped, no error.
entries, err := loadGlossarySeed(writeSeed(t, `
terms:
- src: 四叔
dst: Четвёртый дядюшка
aliases:
- { alias: 鲁四老爷, type: имя }
- { alias: 鲁四老爷, type: титул }
`))
if err != nil {
t.Fatalf("duplicate alias should be deduped, not error: %v", err)
}
if len(entries) != 1 || len(entries[0].Aliases) != 1 {
t.Fatalf("alias not deduped: %+v", entries)
}
// Duplicate (src, sense, window) → fail loud.
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: , dst: путь }
- { src: , dst: дао }
`)); err == nil {
t.Error("duplicate (src,sense,window) term must fail loud")
}
// Same src, different SENSE → allowed (polysemy).
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: , dst: путь, sense: way }
- { src: , dst: дао, sense: dao }
`)); err != nil {
t.Errorf("distinct senses should be allowed: %v", err)
}
}
func TestClassifyRubyReading(t *testing.T) {
cases := []struct {
base, reading, want string
}{
{"鈴木", "すずき", rubyClassName}, // all-Han base + all-kana reading → name candidate
{"強敵", "とも", rubyClassName}, // SAME shape (a double-reading!) — deliberately NOT distinguishable offline → still a CANDIDATE, never auto-locked
{"強敵", "きょうてき", rubyClassName}, // real reading, same class
{"泣蟲", "泣き虫", rubyClassGloss}, // reading carries kanji → a semantic gloss/double-reading → footnote, not a lock
{"ゲーム", "gēmu", rubyClassAmbiguous}, // base is kana → not a plain furigana-name
}
for _, c := range cases {
if got := classifyRubyReading(c.base, c.reading); got != c.want {
t.Errorf("classifyRubyReading(%q,%q) = %q; want %q", c.base, c.reading, got, c.want)
}
}
}
func TestRubyToCandidates(t *testing.T) {
readings := []store.RubyReading{
{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 2, Occurrences: 9},
{BookID: "b", Base: "鈴木", Reading: "スズキ", FirstChapter: 5, Occurrences: 3}, // variant → deduped by base
{BookID: "b", Base: "田中", Reading: "たなか", FirstChapter: 1, Occurrences: 4},
{BookID: "b", Base: "図書館", Reading: "としょかん", FirstChapter: 1, Occurrences: 20},
}
// 図書館 is owned by the manual seed → its ruby candidate is skipped.
manual := map[string]bool{"図書館": true}
cands := rubyToCandidates(readings, manual)
if len(cands) != 2 {
t.Fatalf("want 2 candidates (鈴木, 田中; 図書館 skipped), got %d: %+v", len(cands), cands)
}
byBase := map[string]store.GlossaryEntry{}
for _, c := range cands {
byBase[c.Src] = c
if c.Status != "auto" || c.Source != "ruby" || c.Dst != "" {
t.Errorf("ruby candidate must be auto/ruby with no dst: %+v", c)
}
}
// 鈴木 deduped to the DOMINANT reading (すずき, 9 occ > スズキ, 3) with the earliest chapter.
suzuki := byBase["鈴木"]
if suzuki.RubyReading != "すずき" {
t.Errorf("dominant reading = %q, want すずき", suzuki.RubyReading)
}
if suzuki.SinceCh != 2 {
t.Errorf("since_ch = %d, want the earliest (2)", suzuki.SinceCh)
}
if suzuki.Confidence != 9 {
t.Errorf("confidence = %d, want 9 (occurrences of the dominant reading)", suzuki.Confidence)
}
if suzuki.RubyClass != rubyClassName {
t.Errorf("ruby_class = %q, want %q", suzuki.RubyClass, rubyClassName)
}
if _, skipped := byBase["図書館"]; skipped {
t.Error("図書館 should have been skipped (owned by the manual seed)")
}
}

View file

@ -150,11 +150,28 @@ func snippetStr(s string) string {
return s
}
// Messages builds the wire-neutral message list for a stage. Layout по Р5:
// system (стабильный префикс, кэш-граница на последнем стабильном блоке) →
// user (волатильный хвост: чанк/черновик). Инъекция глоссария/STM встанет
// между ними в Фазе 1, не меняя схемы.
// Messages builds the wire-neutral message list for a stage with no memory injection
// (the Веха-0/2 behaviour). Layout по Р5: system (стабильный префикс, кэш-граница) →
// user (волатильный хвост). Kept as the zero-injection convenience over
// MessagesWithInjection so existing callers/tests are untouched.
func Messages(tpl *PromptTemplate, v RenderVars) ([]llm.Message, error) {
return MessagesWithInjection(tpl, v, "")
}
// MessagesWithInjection builds the stage messages with the memory bank v2 injection
// surface (шаг 4 §C, orchestrator variant "а" — a code-assembled message, NOT a new
// RenderVars placeholder, so the fixed template stays stable). Layout по Р5/A6:
//
// system(стабильный префикс, CacheBoundary) → [injection: глоссарий/STM] → user(волатильный хвост)
//
// The injection is its OWN message placed AFTER the cache boundary (which stays on the
// stable system prefix — the DeepSeek byte-prefix cache still hits; Anthropic's removed,
// no cache_control concern). It is NOT part of ch.Text, so it never leaks into the
// coverage len_ratio or the {{text}} placeholder (the 3a ch.Text contract). Because the
// injection is a message, it is folded into request_hash/content_hash automatically — a
// changed injection is a changed request (a resumed chunk reproduces it deterministically
// from the frozen bank). An empty injection yields the exact 2-message list of Messages.
func MessagesWithInjection(tpl *PromptTemplate, v RenderVars, injection string) ([]llm.Message, error) {
sys, err := Render(tpl.System, v)
if err != nil {
return nil, err
@ -163,10 +180,13 @@ func Messages(tpl *PromptTemplate, v RenderVars) ([]llm.Message, error) {
if err != nil {
return nil, err
}
return []llm.Message{
{Role: "system", Content: sys, CacheBoundary: true},
{Role: "user", Content: user},
}, nil
msgs := make([]llm.Message, 0, 3)
msgs = append(msgs, llm.Message{Role: "system", Content: sys, CacheBoundary: true})
if strings.TrimSpace(injection) != "" {
msgs = append(msgs, llm.Message{Role: "system", Content: injection, CacheBoundary: false})
}
msgs = append(msgs, llm.Message{Role: "user", Content: user})
return msgs, nil
}
// RequestHash is the checkpoint/resume key (§3.1): a stable hash of everything

View file

@ -0,0 +1,93 @@
package pipeline
import (
"strings"
"testing"
)
func TestMessagesInjectionLayout(t *testing.T) {
tpl := writeTemplate(t, "Стабильный системный префикс.\n---USER---\nПереведи: {{text}}")
v := RenderVars{Book: testBook(), Text: "исходный чанк"}
injection := "ГЛОССАРИЙ:\n阿Q → А-кью"
msgs, err := MessagesWithInjection(tpl, v, injection)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 3 {
t.Fatalf("want 3 messages (system, injection, user), got %d", len(msgs))
}
// [0] stable system prefix carries the cache boundary.
if msgs[0].Role != "system" || !msgs[0].CacheBoundary {
t.Errorf("msg[0] must be the system prefix with CacheBoundary=true: %+v", msgs[0])
}
// [1] injection sits AFTER the boundary (volatile → CacheBoundary=false).
if msgs[1].Role != "system" || msgs[1].CacheBoundary || msgs[1].Content != injection {
t.Errorf("msg[1] must be the injection with CacheBoundary=false: %+v", msgs[1])
}
// [2] the volatile user chunk. It must contain ONLY the rendered chunk, never the
// glossary (ch.Text stays clean — the 3a contract; the coverage gate reads ch.Text).
if msgs[2].Role != "user" {
t.Errorf("msg[2] must be user: %+v", msgs[2])
}
if strings.Contains(msgs[2].Content, "ГЛОССАРИЙ") || strings.Contains(msgs[2].Content, "阿Q") {
t.Errorf("glossary leaked into the user (ch.Text) message: %q", msgs[2].Content)
}
if !strings.Contains(msgs[2].Content, "исходный чанк") {
t.Errorf("user message lost the chunk: %q", msgs[2].Content)
}
}
func TestMessagesEmptyInjectionIdentical(t *testing.T) {
tpl := writeTemplate(t, "Системный префикс.\n---USER---\nТекст: {{text}}")
v := RenderVars{Book: testBook(), Text: "чанк"}
plain, _ := Messages(tpl, v)
withEmpty, _ := MessagesWithInjection(tpl, v, " ") // whitespace-only → no injection
if len(plain) != 2 || len(withEmpty) != 2 {
t.Fatalf("empty injection must yield 2 messages: %d vs %d", len(plain), len(withEmpty))
}
for i := range plain {
if plain[i] != withEmpty[i] {
t.Errorf("empty-injection message %d differs from plain: %+v vs %+v", i, withEmpty[i], plain[i])
}
}
}
// TestInjectionChangesRequestHash: the injection is folded into request_hash via the
// messages, so a different glossary is a different request (a changed approved term
// re-derives the chunk, never serves a stale checkpoint).
func TestInjectionChangesRequestHash(t *testing.T) {
tpl := writeTemplate(t, "Префикс.\n---USER---\n{{text}}")
v := RenderVars{Book: testBook(), Text: "чанк"}
none, _ := Messages(tpl, v)
inj, _ := MessagesWithInjection(tpl, v, "ГЛОССАРИЙ:\n阿Q → А-кью")
hNone := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", none)
hInj := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", inj)
if hNone == hInj {
t.Error("injection did not change request_hash — a glossary change would silently hit a stale checkpoint")
}
}
func TestRenderGlossaryBlock(t *testing.T) {
confirmed := pickedEntry{entry: &memoryEntry{src: "阿Q", dst: "А-кью", status: "approved"}, disp: memConfirmed}
ambiguous := pickedEntry{entry: &memoryEntry{src: "小D", dst: "Малыш Дэ", status: "auto"}, disp: memAmbiguous}
emptyDst := pickedEntry{entry: &memoryEntry{src: "鈴木", dst: "", status: "auto"}, disp: memAmbiguous}
block := renderGlossaryBlock([]pickedEntry{confirmed, ambiguous, emptyDst})
if !strings.Contains(block, "阿Q → А-кью") {
t.Errorf("confirmed line missing: %q", block)
}
if !strings.Contains(block, "小D → Малыш Дэ ⟨проверить⟩") {
t.Errorf("ambiguous line missing its unverified tag: %q", block)
}
if strings.Contains(block, "鈴木") {
t.Errorf("empty-dst candidate should be skipped (nothing to inject): %q", block)
}
// Nothing to inject → empty block (лучше ничего).
if renderGlossaryBlock(nil) != "" {
t.Error("empty selection must render an empty block")
}
if renderGlossaryBlock([]pickedEntry{emptyDst}) != "" {
t.Error("only-empty-dst selection must render an empty block")
}
}

View file

@ -56,6 +56,13 @@ type Runner struct {
clients map[string]llm.LLMClient
templates map[string]*PromptTemplate
// memory is the book's glossary FROZEN for this job (materialized once in
// TranslateBook, after seeding, before snapshotID). Its Version() is the F1
// content-hash folded into the snapshot; its Select drives per-chunk injection.
// nil until materialized (report path / no glossary) → memoryVersion() falls back
// to the empty-materialization hash, a stable constant.
memory *MemoryBank
}
// NewRunner loads all three configs rooted at book.yaml and opens the project
@ -220,19 +227,21 @@ func (r *Runner) escalationBudgetRemains() (bool, error) {
return spent < budget, nil
}
// memoryVersion is the content-hash of the deterministically materialized
// injected memory (approved glossary + summaries + series-bible), the memory
// component of the snapshot (D5.2/D8). Until the memory bank v2 migration lands
// the materialization is empty, so the hash is a stable constant; once memory
// enters msgs this changes and the resnapshot-gate fires loudly instead of a
// stale checkpoint re-paying a diverged translation. STM is excluded (rebuilt
// from checkpoints, §3.2).
// memoryVersion is the content-hash of the deterministically materialized injected
// memory (the frozen APPROVED glossary rows + the normalization/matcher algorithm
// versions), the memory component of the snapshot (D5.2/D8, F1 CLOSED). It is the
// Version() of the bank materialized once before the loop, so a change to the approved
// glossary — or to the deterministic machinery (trad→simp table, matcher) — fires the
// resnapshot gate LOUDLY instead of a stale checkpoint re-paying a diverged translation.
// nil bank (report path / a book with no glossary) → the empty-materialization hash, a
// stable constant. STM is excluded (rebuilt from checkpoints, §3.2). Auto/draft rows are
// excluded per D8 (their injected-content changes are caught at the per-chunk
// content_hash level; a future autopopulation milestone revisits this).
func (r *Runner) memoryVersion() string {
h := sha256.New()
// Domain tag + empty materialization; the store query lands with the v2
// memory schema and feeds ORDER BY-stable rows here.
h.Write([]byte("tm-memory-v1\x00"))
return hex.EncodeToString(h.Sum(nil))
if r.memory != nil {
return r.memory.Version()
}
return computeMemoryVersion(nil, r.Pipeline.Gates.Glossary.PostcheckGate)
}
// snapshotID materializes the job-context snapshot (§3.2): brief_hash, chunker
@ -324,6 +333,12 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
// хэш стабилен; когда память войдёт в msgs, поле сменится и resnapshot-
// гейт сработает громко. STM сюда НЕ входит (пересобирается из чекпоинтов).
MemoryVersion string `json:"memory_version"`
// PostcheckGate — the memory-bank post-check gate (E1). When true a post-check
// miss flips the chunk to flagged; folding its on/off state makes toggling it a
// loud --resnapshot (it changes a checkpoint's RESOLVED disposition), not a silent
// re-verdict on resume (same class as coverage/classifier). The post-check
// ALGORITHM version is already inside MemoryVersion (memoryMatchVersion).
PostcheckGate bool `json:"postcheck_gate"`
// Coverage — excision QA-gate config (D12 Q3). It does not touch the wire,
// but it determines a checkpoint's RESOLVED verdict, so a gate change must be
// a loud --resnapshot, not a silent re-verdict on resume (coverageSnapshot).
@ -346,6 +361,7 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
CacheTTL: r.Pipeline.Context.CacheTTL,
},
MemoryVersion: r.memoryVersion(),
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
Coverage: r.coverageSnapshot(),
}
for _, st := range r.Pipeline.Stages {
@ -480,8 +496,23 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return nil, err
}
// Persist captured ruby readings (idempotent; consumed by seedGlossary below into
// auto glossary candidates — шаг 4). Never injected into a prompt here (§7d); a
// resume re-persists the same rows at $0.
if err := r.persistRuby(doc.Ruby); err != nil {
return nil, fmt.Errorf("pipeline: persist ruby readings: %w", err)
}
// Seed + materialize the glossary BEFORE the snapshot: the frozen approved rows feed
// memoryVersion() → the snapshot (F1), so the snapshot must be computed with the bank
// already materialized. Deterministic and $0 (seed file + classified ruby, no LLM).
if err := r.seedGlossary(ctx); err != nil {
return nil, err
}
// Snapshot is book-level (brief + stage plan + memory), computed once and
// upserted before the loop; every job pins to it.
// upserted before the loop; every job pins to it. memoryVersion() now reflects the
// materialized bank, so a changed approved glossary re-pins loudly (F1).
snapID, snapPayload, err := r.snapshotID()
if err != nil {
return nil, err
@ -495,17 +526,19 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile)
}
// Persist captured ruby readings (idempotent; consumed by memory v2 for a
// glossary name-lock — шаг 4). Never injected into a prompt here (§7d), so it
// touches neither the snapshot nor request_hash; a resume re-persists the same
// rows at $0.
if err := r.persistRuby(doc.Ruby); err != nil {
return nil, fmt.Errorf("pipeline: persist ruby readings: %w", err)
}
res := &BookResult{BookID: r.Book.BookID}
// Sticky scene-inertia state (A5): the exact-matched ids of the recent chunks in
// the CURRENT chapter, reset at a chapter boundary (a new chapter is a scene change).
// Rebuilt deterministically each run because translateChunk recomputes the ($0)
// selection for EVERY chunk, resumed ones included — so sticky is resume-stable.
var stickyWin []map[string]bool
prevChapter := 0
for _, ch := range chunks {
outcome, err := r.translateChunk(ctx, snapID, ch)
if ch.Chapter != prevChapter {
stickyWin = nil
prevChapter = ch.Chapter
}
outcome, activeIDs, err := r.translateChunk(ctx, snapID, ch, unionSticky(stickyWin))
if err != nil {
// Infra failure: abort. Chunks already committed are checkpointed;
// resume continues from here at $0 for the done work.
@ -516,10 +549,73 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
if outcome.Disposition == DispFlagged {
res.Flagged++
}
// Advance the sticky window (keep the last stickyDepth chunks' exact matches).
stickyWin = append(stickyWin, activeIDs)
if len(stickyWin) > stickyDepth {
stickyWin = stickyWin[len(stickyWin)-stickyDepth:]
}
}
return res, nil
}
// unionSticky merges the recent chunks' exact-matched id sets into one sticky_prev.
func unionSticky(win []map[string]bool) map[string]bool {
if len(win) == 0 {
return nil
}
out := map[string]bool{}
for _, s := range win {
for id := range s {
out[id] = true
}
}
return out
}
// seedGlossary REPLACES the book's glossary from its deterministic inputs — the manual
// seed file (curated approved/draft terms) and the captured ruby readings (classified
// into auto candidates) — then MATERIALIZES the frozen bank for this job (r.memory). Run
// once before snapshotID: the frozen APPROVED rows are hashed into memoryVersion (F1), so
// editing the seed is a loud --resnapshot. Idempotent (full replace), $0, no LLM. B2
// approved dst-collisions are logged (not fatal — some collisions are legitimate).
func (r *Runner) seedGlossary(ctx context.Context) error {
var entries []store.GlossaryEntry
if r.Book.GlossarySeed != "" {
seed, err := loadGlossarySeed(r.Book.GlossarySeed)
if err != nil {
return err
}
entries = append(entries, seed...)
}
manualSrcs := map[string]bool{}
for _, e := range entries {
manualSrcs[e.Src] = true
}
ruby, err := r.Store.RubyReadingsForBook(r.Book.BookID)
if err != nil {
return err
}
entries = append(entries, rubyToCandidates(ruby, manualSrcs)...)
for i := range entries {
entries[i].BookID = r.Book.BookID
}
if err := r.Store.ReplaceGlossary(r.Book.BookID, entries); err != nil {
return err
}
rows, err := r.Store.GlossaryForBook(r.Book.BookID)
if err != nil {
return err
}
r.memory = materializeMemory(rows, r.Pipeline.Gates.Glossary.PostcheckGate)
if cols := injectivityCollisions(rows); len(cols) > 0 {
r.Log.WarnContext(ctx, "glossary approved dst-collisions (B2: two source terms share one Russian surface — the reader cannot tell them apart)",
"collisions", strings.Join(cols, "; "))
}
r.Log.InfoContext(ctx, "glossary materialized", "book", r.Book.BookID,
"entries", len(rows), "memory_version", r.memory.Version()[:12])
return nil
}
// persistRuby aggregates the ingested ruby occurrences into one row per
// (base, reading) — first_chapter = MIN, occurrences = full-book count — and
// REPLACES the book's whole ruby set (store.ReplaceRubyReadings). Idempotent: the
@ -569,14 +665,30 @@ func (r *Runner) persistRuby(readings []RubyReading) error {
// translateChunk drives ONE chunk through the stage list. Stages run in order,
// each feeding the next; the FIRST flagged stage stops the chunk — later stages
// are recorded `skipped` (no paid edit over a garbage draft, D2). Returns an
// error only on an infra failure.
func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk) (*ChunkOutcome, error) {
// are recorded `skipped` (no paid edit over a garbage draft, D2). It also runs the
// memory bank v2 hot path: it Selects the glossary injection once ($0, deterministic),
// injects it into the TRANSLATOR stage, post-checks the translator output (E1), and
// persists the per-chunk retrieval-state (observability). Returns the chunk outcome, the
// exact-matched entity ids (the next chunk's sticky_prev, A5), and an error only on an
// infra failure. stickyPrev is the prior chunks' exact matches in this chapter.
func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, stickyPrev map[string]bool) (*ChunkOutcome, map[string]bool, error) {
out := &ChunkOutcome{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Disposition: DispOK}
prev := ""
flagged := false
var flagReason FlagReason
// Hot path: select the glossary records for this chunk ONCE (deterministic, $0), and
// serialize the translator injection block. Recomputed on every run (resumed chunks
// included) so the sticky window and the injected bytes are resume-stable.
var memSel memorySelection
var translatorInjection string
activeIDs := map[string]bool{}
if r.memory != nil {
memSel = r.memory.Select(ch.Text, ch.Chapter, stickyPrev, r.Pipeline.Context.GlossaryTokenBudget)
translatorInjection = renderGlossaryBlock(memSel.injected)
activeIDs = memSel.activeIDs
}
for stageIdx, st := range r.Pipeline.Stages {
if flagged {
// An earlier stage flagged → this stage is not attempted or billed.
@ -586,7 +698,7 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk) (*
SnapshotID: snapID, Disposition: string(DispSkipped), FlagReason: string(flagReason),
Detail: detail,
}); err != nil {
return out, fmt.Errorf("pipeline: record skipped chunk_status: %w", err)
return out, activeIDs, fmt.Errorf("pipeline: record skipped chunk_status: %w", err)
}
out.Stages = append(out.Stages, StageResult{
Stage: st.Name, Role: st.Role, Model: st.Model,
@ -595,9 +707,17 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk) (*
continue
}
sr, err := r.runStage(ctx, st, stageIdx, snapID, ch, prev)
// Inject the glossary ONLY into the translator (шаг 4 scope): its src→dst records
// are matched against the source chunk. Other stages (the monolingual editor) get
// no injection in v1 (a dst-constraint injection for the editor is a fast-follow on
// this same surface). Empty injection → the plain 2-message layout.
injection := ""
if st.Role == roleTranslator {
injection = translatorInjection
}
sr, err := r.runStage(ctx, st, stageIdx, snapID, ch, prev, injection)
if err != nil {
return out, err
return out, activeIDs, err
}
out.Stages = append(out.Stages, *sr)
out.CostUSD += sr.CostUSD
@ -609,14 +729,82 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk) (*
prev = sr.Text
}
// Post-check the FINAL, exported output (E1): the reader sees the last stage's text
// (the editor's), not the translator draft — the monolingual editor can drift an
// approved term the translator got right, so checking the draft alone would miss it.
// Runs on the fresh OR fully-resumed chunk (both carry the final text through `prev`),
// so it is resume-reproducible. In the default FLAGGER mode a miss is recorded only in
// the retrieval-state (observability); with the opt-in gate a miss flags the chunk
// (glossary_miss). Skipped when a stage already flagged (no usable output to check).
var postMisses []postcheckMiss
outputChecked := false
if r.memory != nil && !flagged && prev != "" {
outputChecked = true
postMisses = r.memory.postcheck(memSel.injected, prev)
if r.Pipeline.Gates.Glossary.PostcheckGate && len(postMisses) > 0 {
flagged = true
flagReason = FlagGlossaryMiss
r.Log.WarnContext(ctx, "glossary post-check gate flagged the chunk",
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "misses", len(postMisses))
}
}
// Persist the per-chunk retrieval-state (registry gate #4: convert silent memory
// degradation into a loud, visible record). Deterministic + idempotent, so a resume
// re-derives the identical row. Only when a glossary is materialized.
if r.memory != nil {
if err := r.persistRetrievalState(snapID, ch, memSel, postMisses, outputChecked); err != nil {
return out, activeIDs, err
}
}
if flagged {
out.Disposition = DispFlagged
out.FlagReason = flagReason
out.FinalText = "" // garbage/refusal never propagates to the next chunk or export
out.FinalText = "" // garbage/refusal/glossary-miss never propagates to the next chunk or export
} else {
out.FinalText = prev
}
return out, nil
return out, activeIDs, nil
}
// persistRetrievalState writes the per-chunk observability record from the deterministic
// selection + the post-check result. n_exact_hits/n_sticky/n_ambiguous count the INJECTED
// records (what the model saw); spoiler/eviction are the dropped-and-logged totals;
// post-check misses are recorded only when the translator actually produced text.
func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelection, misses []postcheckMiss, outputChecked bool) error {
rs := store.RetrievalState{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, SnapshotID: snapID,
}
for _, p := range sel.injected {
if p.via == "sticky" {
rs.NSticky++
} else {
rs.NExactHits++
}
if p.disp == memAmbiguous {
rs.NAmbiguousFlagged++
}
}
rs.NSpoilerBlocked = len(sel.rejected)
rs.NEvicted = len(sel.evicted)
if outputChecked {
rs.NPostcheckMiss = len(misses)
if len(misses) > 0 {
if b, err := json.Marshal(misses); err == nil {
rs.PostcheckDetail = string(b)
}
}
}
ids := make([]string, 0, len(sel.activeIDs))
for id := range sel.activeIDs {
ids = append(ids, id)
}
sort.Strings(ids)
if b, err := json.Marshal(ids); err == nil {
rs.InjectedIDs = string(b)
}
return r.Store.UpsertRetrievalState(rs)
}
// runStage runs ONE stage of ONE chunk to a terminal disposition. It first
@ -626,7 +814,7 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk) (*
// subset with a doubled budget up to the regenerate cap, then flag. Returns an
// error ONLY on an infra failure; a bad completion is a disposition, never an
// error.
func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch Chunk, prev string) (*StageResult, error) {
func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch Chunk, prev, injection string) (*StageResult, error) {
job, err := r.Store.EnsureJob(r.Book.BookID, ch.Chapter, st.Name, snapID)
if err != nil {
return nil, err
@ -649,7 +837,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
// fast-path be CONTENT-VERIFIED: the source bytes are not in the snapshot, so
// a positional chunk_status row must be checked against the current rendered
// content, else an edited source would serve a stale, divergent translation.
msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: ch.Text, Draft: prev})
msgs, err := MessagesWithInjection(r.templates[st.Name], RenderVars{Book: r.Book, Text: ch.Text, Draft: prev}, injection)
if err != nil {
return nil, err
}

View file

@ -0,0 +1,245 @@
package pipeline
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// runner_memory_test.go: the шаг-4 acceptance — the memory bank v2 mechanism end-to-end
// on a seeded glossary against the mock provider (injection → translate → post-check →
// retrieval-state → resume $0 → resnapshot on an approved change). Per §8, acceptance is
// "the mechanism is correct on a seeded glossary", not "≥98% on a real book".
const suzukiSeed = `
terms:
- src: 鈴木
dst: Судзуки
type: name
status: approved
decl: { invariant: true, forms: ["Судзуки"] }
`
// suzukiSource is one ja chunk that names 鈴木 (so the glossary fires).
const suzukiSource = "鈴木は静かな図書館へ行った。"
type wireMsg struct {
Role string
Content string
}
func bodyMessages(t *testing.T, body string) []wireMsg {
t.Helper()
var req struct {
Messages []wireMsg `json:"messages"`
}
if err := json.Unmarshal([]byte(body), &req); err != nil {
t.Fatalf("bad request body: %v\n%s", err, body)
}
return req.Messages
}
// translatorBody returns the recorded draft(translator) request body.
func translatorBody(t *testing.T, rec *reqRec) string {
t.Helper()
for _, b := range rec.all() {
if !isEditBody(b) {
return b
}
}
t.Fatal("no translator request recorded")
return ""
}
func TestRunnerMemoryInjectionAndPostcheck(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД про Судзуки.", "stop"
}
return "Судзуки пошёл в тихую библиотеку.", "stop" // renders the approved dst
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed})
ctx := context.Background()
r := newRunner(t, bookPath)
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if res.Flagged != 0 {
t.Fatalf("flagger default must not flag a correct render: flagged=%d", res.Flagged)
}
// The TRANSLATOR request carries the injection as its own system message between the
// stable prefix and the user chunk; the dst appears only via injection (not in the ja
// source), so its presence proves the injection landed on the wire.
tb := translatorBody(t, rec)
msgs := bodyMessages(t, tb)
if len(msgs) != 3 {
t.Fatalf("translator must have 3 messages (system, injection, user), got %d: %+v", len(msgs), msgs)
}
if msgs[1].Role != "system" || !strings.Contains(msgs[1].Content, "Судзуки") || !strings.Contains(msgs[1].Content, "ГЛОССАРИЙ") {
t.Errorf("injection message wrong: %+v", msgs[1])
}
if strings.Contains(msgs[2].Content, "ГЛОССАРИЙ") {
t.Errorf("glossary leaked into the user (ch.Text) message: %q", msgs[2].Content)
}
// The EDITOR gets no injection in v1.
for _, b := range rec.all() {
if isEditBody(b) && strings.Contains(b, "ГЛОССАРИЙ") {
t.Error("editor received a glossary injection (not in v1 scope)")
}
}
// retrieval-state: one exact hit, post-check ran with 0 misses.
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
if err != nil || rs == nil {
t.Fatalf("retrieval-state missing: %v", err)
}
if rs.NExactHits != 1 || rs.NPostcheckMiss != 0 {
t.Errorf("retrieval-state wrong: exact=%d miss=%d", rs.NExactHits, rs.NPostcheckMiss)
}
callsRun1 := rec.count()
r.Close()
// Resume: no new provider calls, $0 (the deterministic memory path re-derives for free).
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != callsRun1 || res2.TotalUSD != 0 {
t.Fatalf("resume must be $0 with no new calls: calls=%d (run1=%d) usd=%v", rec.count(), callsRun1, res2.TotalUSD)
}
// retrieval-state re-derived identically.
rs2, _ := r2.Store.GetRetrievalState("test-book", 1, 0)
if rs2 == nil || rs2.NExactHits != 1 || rs2.NPostcheckMiss != 0 {
t.Errorf("retrieval-state not stable on resume: %+v", rs2)
}
}
func TestRunnerMemoryFlaggerRecordsMissWithoutFlagging(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ.", "stop"
}
return "Некто пошёл в библиотеку.", "stop" // DROPS the approved name → a post-check miss
})
defer srv.Close()
// Default flagger mode (postcheckGate: false).
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
// Flagger mode: the chunk is NOT flagged (translation still exported), but the miss IS
// recorded (silent degradation converted to a loud, visible record).
if res.Flagged != 0 {
t.Errorf("flagger mode must not flag the chunk: flagged=%d", res.Flagged)
}
rs, _ := r.Store.GetRetrievalState("test-book", 1, 0)
if rs == nil || rs.NPostcheckMiss != 1 {
t.Fatalf("post-check miss not recorded: %+v", rs)
}
if !strings.Contains(rs.PostcheckDetail, "鈴木") {
t.Errorf("post-check detail should name the missed src: %q", rs.PostcheckDetail)
}
}
func TestRunnerMemoryPostcheckGateFlags(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ.", "stop"
}
return "Некто пошёл в библиотеку.", "stop" // drops the name
})
defer srv.Close()
// Opt-in hard gate.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if res.Flagged != 1 {
t.Fatalf("gate mode must flag the missed chunk: flagged=%d", res.Flagged)
}
if res.Chunks[0].FlagReason != FlagGlossaryMiss {
t.Errorf("flag reason = %q, want glossary_miss", res.Chunks[0].FlagReason)
}
// The post-check runs on the FINAL exported output, so both stages ran (each ok in
// chunk_status), but the CHUNK is flagged and its text is not exported.
if res.Chunks[0].FinalText != "" {
t.Errorf("a glossary-flagged chunk must not export text, got %q", res.Chunks[0].FinalText)
}
var editorRan bool
for _, st := range res.Chunks[0].Stages {
if st.Role == "editor" && st.Disposition == DispOK {
editorRan = true
}
}
if !editorRan {
t.Error("editor stage should have run (post-check validates the final/editor output, not the draft)")
}
}
func TestRunnerMemoryResnapshotOnApprovedChange(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ про Судзуки.", "stop"
}
return "Судзуки пошёл в библиотеку.", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed})
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count()
// Change the APPROVED dst in the seed → the frozen materialization changes → the
// snapshot changes → a resume without --resnapshot must fail loud (F1: no silent
// divergent re-pay).
seedPath := filepath.Join(filepath.Dir(bookPath), "glossary-seed.yaml")
if err := os.WriteFile(seedPath, []byte(strings.Replace(suzukiSeed, "Судзуки", "Сузуки", -1)), 0o644); err != nil {
t.Fatal(err)
}
r2 := newRunner(t, bookPath)
_, err := r2.TranslateBook(ctx)
r2.Close()
if err == nil || !strings.Contains(err.Error(), "resnapshot") {
t.Fatalf("changed approved glossary must fail loud mentioning --resnapshot, got: %v", err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("denied resume must not call the provider: calls=%d (after run1=%d)", rec.count(), callsAfterRun1)
}
// With --resnapshot the book re-translates under the new approved glossary.
r3 := newRunner(t, bookPath)
defer r3.Close()
r3.Resnapshot = true
if _, err := r3.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
if rec.count() <= callsAfterRun1 {
t.Fatal("resnapshot run must re-call the provider for the re-translation")
}
}

View file

@ -108,6 +108,8 @@ type projectOpts struct {
minMaxTokens int
bookUSD float64
gatesYAML string // optional gates:/... block appended to pipeline.yaml
glossarySeed string // optional glossary seed YAML content; "" = no glossary
postcheckGate bool // enable the memory post-check hard gate (assumes gatesYAML is empty)
}
func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string {
@ -142,15 +144,20 @@ models:
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02"), providerURL))
gatesBlock := o.gatesYAML
if o.postcheckGate {
gatesBlock += "\ngates:\n glossary:\n postcheck_gate: true\n"
}
writeFile(t, filepath.Join(dir, "pipeline.yaml"), fmt.Sprintf(`
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: %d }
retries: { regenerate_before_escalate: %d }
context: { glossary_injection: selective, glossary_token_budget: 800 }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off" }
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
%s`, o.minMaxTokens, o.regenerate, o.gatesYAML))
%s`, o.minMaxTokens, o.regenerate, gatesBlock))
sourceName := "source.txt"
if len(o.epub) > 0 {
@ -159,6 +166,11 @@ stages:
} else {
writeFile(t, filepath.Join(dir, "source.txt"), o.source)
}
glossaryLine := ""
if o.glossarySeed != "" {
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), o.glossarySeed)
glossaryLine = "glossary_seed: glossary-seed.yaml"
}
writeFile(t, filepath.Join(dir, "book.yaml"), fmt.Sprintf(`
book_id: test-book
title: Тест
@ -173,8 +185,9 @@ footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: %s
%s
ceilings: { book_usd: %g, day_usd: 2.0 }
`, sourceName, o.bookUSD))
`, sourceName, glossaryLine, o.bookUSD))
return filepath.Join(dir, "book.yaml")
}

View file

@ -0,0 +1,322 @@
package store
import (
"database/sql"
"errors"
)
// glossary.go: durable storage for the memory bank v2 glossary (schema v5). Dumb
// storage, mirroring ruby.go/chunkstatus.go: the pipeline owns aggregation,
// classification and matching; this layer only replaces and reads back. A book's
// glossary is a deterministic function of its inputs (seed file + classified ruby),
// so ReplaceGlossary rewrites the WHOLE book set in one transaction — re-seeding the
// identical inputs rewrites identical rows, a seed edit converges every column, and a
// removed term disappears instead of lingering (the same full-replace idempotency the
// ruby step adopted after external-review #6).
// GlossaryEntry is one term row plus its aliases. Strings are stored raw; the
// pipeline normalizes for matching (§3.6). Decl is a JSON blob the post-check reads.
type GlossaryEntry struct {
ID int64
BookID string
Src string
Dst string
Type string
Sense string
Gender string
Speech string
Decl string // JSON {"invariant":bool,"forms":[...]}
TranslitPolicy string
FirstPerson string
NicknameTranslation string
SinceCh int
UntilCh int
Status string // auto|draft|approved
AllowShort bool
Source string // seed|ruby|auto
RubyReading string
RubyClass string
Confidence int
Note string
Aliases []GlossaryAlias
}
// GlossaryAlias is one alias-graph edge (an alternative source surface + its type).
type GlossaryAlias struct {
Alias string
AliasType string
}
// GlossaryRevision is one append-only editorial-time record (B1).
type GlossaryRevision struct {
Src string
Sense string
OldDst string
NewDst string
EditorialTS string
Reason string
}
// RetrievalState is one per-chunk observability record (registry gate #4).
type RetrievalState struct {
BookID string
Chapter int
ChunkIdx int
SnapshotID string
NExactHits int
NSticky int
NAmbiguousFlagged int
NSpoilerBlocked int
NEvicted int
EmbeddingTierUsed int
NPostcheckMiss int
PostcheckDetail string // JSON
InjectedIDs string // JSON
}
// ReplaceGlossary replaces a book's ENTIRE glossary (rows + aliases) with the given
// entries in ONE transaction, and appends a B1 revision record for every term whose
// approved rendering (dst) changed since the previous set. Scoped to book_id, so other
// books are untouched. Each entry's BookID must equal bookID. term_id linkage is
// established within the tx: a glossary row is inserted, its rowid taken, then its
// aliases inserted against that id (ids are fresh each replace — never hashed).
func (s *Store) ReplaceGlossary(bookID string, entries []GlossaryEntry) error {
ctx, cancel := opContext()
defer cancel()
tx, err := s.w.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Snapshot the prior (src,sense,window)→dst to detect editorial-time changes (B1).
prior := map[[4]any]string{}
rows, err := tx.QueryContext(ctx, `SELECT src, sense, since_ch, until_ch, dst FROM glossary WHERE book_id = ?`, bookID)
if err != nil {
return err
}
for rows.Next() {
var src, sense, dst string
var since, until int
if err := rows.Scan(&src, &sense, &since, &until, &dst); err != nil {
rows.Close()
return err
}
prior[[4]any{src, sense, since, until}] = dst
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
if _, err := tx.ExecContext(ctx, `DELETE FROM glossary_aliases WHERE book_id = ?`, bookID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM glossary WHERE book_id = ?`, bookID); err != nil {
return err
}
for _, e := range entries {
res, err := tx.ExecContext(ctx, `
INSERT INTO glossary (
book_id, src, dst, type, sense, gender, speech, decl,
translit_policy, first_person, nickname_translation,
since_ch, until_ch, status, allow_short, source,
ruby_reading, ruby_class, confidence, note
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
e.BookID, e.Src, e.Dst, e.Type, e.Sense, e.Gender, e.Speech, e.Decl,
e.TranslitPolicy, e.FirstPerson, e.NicknameTranslation,
e.SinceCh, e.UntilCh, e.Status, boolToInt(e.AllowShort), e.Source,
e.RubyReading, e.RubyClass, e.Confidence, e.Note)
if err != nil {
return err
}
termID, err := res.LastInsertId()
if err != nil {
return err
}
for _, a := range e.Aliases {
if _, err := tx.ExecContext(ctx, `
INSERT INTO glossary_aliases (book_id, term_id, alias, alias_type)
VALUES (?, ?, ?, ?)`, e.BookID, termID, a.Alias, a.AliasType); err != nil {
return err
}
}
// B1: a term that existed with a non-empty dst and now renders differently
// (also non-empty) is an editorial change → append the revision journal row.
if old, ok := prior[[4]any{e.Src, e.Sense, e.SinceCh, e.UntilCh}]; ok && old != "" && e.Dst != "" && old != e.Dst {
if _, err := tx.ExecContext(ctx, `
INSERT INTO glossary_revisions (book_id, src, sense, old_dst, new_dst, reason)
VALUES (?, ?, ?, ?, ?, ?)`, e.BookID, e.Src, e.Sense, old, e.Dst, "seed-replace"); err != nil {
return err
}
}
}
return tx.Commit()
}
// GlossaryForBook returns every glossary entry for a book with its aliases, in a
// DETERMINISTIC order (src, sense, since_ch, until_ch, status, dst) with aliases sorted
// — the stable materialization the hot-path matcher and the F1 memoryVersion() consume.
func (s *Store) GlossaryForBook(bookID string) ([]GlossaryEntry, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.r.QueryContext(ctx, `
SELECT id, src, dst, type, sense, gender, speech, decl,
translit_policy, first_person, nickname_translation,
since_ch, until_ch, status, allow_short, source,
ruby_reading, ruby_class, confidence, note
FROM glossary WHERE book_id = ?
ORDER BY src, sense, since_ch, until_ch, status, dst`, bookID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []GlossaryEntry
byID := map[int64]int{} // glossary.id → index in out
for rows.Next() {
e := GlossaryEntry{BookID: bookID}
var allowShort int
if err := rows.Scan(&e.ID, &e.Src, &e.Dst, &e.Type, &e.Sense, &e.Gender, &e.Speech, &e.Decl,
&e.TranslitPolicy, &e.FirstPerson, &e.NicknameTranslation,
&e.SinceCh, &e.UntilCh, &e.Status, &allowShort, &e.Source,
&e.RubyReading, &e.RubyClass, &e.Confidence, &e.Note); err != nil {
return nil, err
}
e.AllowShort = allowShort != 0
byID[e.ID] = len(out)
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Aliases in a second pass, ordered so each entry's alias list is deterministic.
arows, err := s.r.QueryContext(ctx, `
SELECT term_id, alias, alias_type FROM glossary_aliases
WHERE book_id = ? ORDER BY term_id, alias`, bookID)
if err != nil {
return nil, err
}
defer arows.Close()
for arows.Next() {
var termID int64
var a GlossaryAlias
if err := arows.Scan(&termID, &a.Alias, &a.AliasType); err != nil {
return nil, err
}
if idx, ok := byID[termID]; ok {
out[idx].Aliases = append(out[idx].Aliases, a)
}
}
return out, arows.Err()
}
// GlossaryRevisionsForBook returns the editorial-time journal (B1) for a book,
// newest first, for the report / a human audit.
func (s *Store) GlossaryRevisionsForBook(bookID string) ([]GlossaryRevision, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.r.QueryContext(ctx, `
SELECT src, sense, old_dst, new_dst, editorial_ts, reason
FROM glossary_revisions WHERE book_id = ?
ORDER BY id DESC`, bookID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []GlossaryRevision
for rows.Next() {
var r GlossaryRevision
if err := rows.Scan(&r.Src, &r.Sense, &r.OldDst, &r.NewDst, &r.EditorialTS, &r.Reason); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// UpsertRetrievalState writes (or overwrites) the per-chunk retrieval-state record.
// Overwrite is the resolve semantics: recomputing the deterministic matcher over the
// same chunk reproduces the same row (self-heals on resume).
func (s *Store) UpsertRetrievalState(rs RetrievalState) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx, `
INSERT INTO retrieval_state (
book_id, chapter, chunk_idx, snapshot_id,
n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked, n_evicted,
embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
ON CONFLICT (book_id, chapter, chunk_idx) DO UPDATE SET
snapshot_id = excluded.snapshot_id,
n_exact_hits = excluded.n_exact_hits,
n_sticky = excluded.n_sticky,
n_ambiguous_flagged = excluded.n_ambiguous_flagged,
n_spoiler_blocked = excluded.n_spoiler_blocked,
n_evicted = excluded.n_evicted,
embedding_tier_used = excluded.embedding_tier_used,
n_postcheck_miss = excluded.n_postcheck_miss,
postcheck_detail = excluded.postcheck_detail,
injected_ids = excluded.injected_ids,
updated_at = excluded.updated_at`,
rs.BookID, rs.Chapter, rs.ChunkIdx, rs.SnapshotID,
rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged, rs.NSpoilerBlocked, rs.NEvicted,
rs.EmbeddingTierUsed, rs.NPostcheckMiss, rs.PostcheckDetail, rs.InjectedIDs)
return err
}
// GetRetrievalState returns the record for a chunk, or nil if none.
func (s *Store) GetRetrievalState(bookID string, chapter, chunkIdx int) (*RetrievalState, error) {
ctx, cancel := opContext()
defer cancel()
rs := RetrievalState{BookID: bookID, Chapter: chapter, ChunkIdx: chunkIdx}
err := s.r.QueryRowContext(ctx, `
SELECT snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked,
n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids
FROM retrieval_state WHERE book_id = ? AND chapter = ? AND chunk_idx = ?`,
bookID, chapter, chunkIdx).Scan(
&rs.SnapshotID, &rs.NExactHits, &rs.NSticky, &rs.NAmbiguousFlagged, &rs.NSpoilerBlocked,
&rs.NEvicted, &rs.EmbeddingTierUsed, &rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &rs, nil
}
// RetrievalStatesForBook returns every retrieval-state record for a book, ordered for
// a stable report (the memory section of tmctl report).
func (s *Store) RetrievalStatesForBook(bookID string) ([]RetrievalState, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.r.QueryContext(ctx, `
SELECT chapter, chunk_idx, snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged,
n_spoiler_blocked, n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids
FROM retrieval_state WHERE book_id = ?
ORDER BY chapter, chunk_idx`, bookID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RetrievalState
for rows.Next() {
rs := RetrievalState{BookID: bookID}
if err := rows.Scan(&rs.Chapter, &rs.ChunkIdx, &rs.SnapshotID, &rs.NExactHits, &rs.NSticky,
&rs.NAmbiguousFlagged, &rs.NSpoilerBlocked, &rs.NEvicted, &rs.EmbeddingTierUsed,
&rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs); err != nil {
return nil, err
}
out = append(out, rs)
}
return out, rows.Err()
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View file

@ -0,0 +1,153 @@
package store
import "testing"
func gEntry(src, dst, status string, aliases ...string) GlossaryEntry {
e := GlossaryEntry{BookID: "book", Src: src, Dst: dst, Status: status, Source: "seed"}
for _, a := range aliases {
e.Aliases = append(e.Aliases, GlossaryAlias{Alias: a, AliasType: "прозвище"})
}
return e
}
func TestReplaceGlossaryIdempotentAndScoped(t *testing.T) {
s, _ := openTemp(t)
entries := []GlossaryEntry{
gEntry("阿Q", "А-кью", "approved"),
gEntry("四叔", "Четвёртый дядюшка", "approved", "鲁四老爷"),
}
if err := s.ReplaceGlossary("book", entries); err != nil {
t.Fatal(err)
}
// Another book is untouched.
if err := s.ReplaceGlossary("other", []GlossaryEntry{{BookID: "other", Src: "x", Dst: "икс", Status: "approved"}}); err != nil {
t.Fatal(err)
}
got1, err := s.GlossaryForBook("book")
if err != nil {
t.Fatal(err)
}
if len(got1) != 2 {
t.Fatalf("want 2 entries, got %d", len(got1))
}
// Re-replacing the identical set yields the identical content (idempotent).
if err := s.ReplaceGlossary("book", entries); err != nil {
t.Fatal(err)
}
got2, err := s.GlossaryForBook("book")
if err != nil {
t.Fatal(err)
}
if len(got2) != 2 {
t.Fatalf("re-replace changed count: %d", len(got2))
}
// The alias survived the replace and attached to the right term.
var sishu *GlossaryEntry
for i := range got2 {
if got2[i].Src == "四叔" {
sishu = &got2[i]
}
}
if sishu == nil || len(sishu.Aliases) != 1 || sishu.Aliases[0].Alias != "鲁四老爷" {
t.Fatalf("alias not preserved: %+v", sishu)
}
// other book still has its one row.
other, _ := s.GlossaryForBook("other")
if len(other) != 1 {
t.Fatalf("scope leak: other book has %d rows", len(other))
}
}
func TestReplaceGlossaryRemovesDroppedRows(t *testing.T) {
s, _ := openTemp(t)
if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("阿Q", "А-кью", "approved"), gEntry("四叔", "дядюшка", "approved")}); err != nil {
t.Fatal(err)
}
// Drop one term: full replace must make it disappear (no phantom).
if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("阿Q", "А-кью", "approved")}); err != nil {
t.Fatal(err)
}
got, _ := s.GlossaryForBook("book")
if len(got) != 1 || got[0].Src != "阿Q" {
t.Fatalf("dropped term still present: %+v", got)
}
}
func TestGlossaryRevisionLogged(t *testing.T) {
s, _ := openTemp(t)
if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("王胡", "Бородатый Ван", "approved")}); err != nil {
t.Fatal(err)
}
// Change the approved rendering: a B1 editorial revision must be journaled.
if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("王胡", "Ван Ху", "approved")}); err != nil {
t.Fatal(err)
}
revs, err := s.GlossaryRevisionsForBook("book")
if err != nil {
t.Fatal(err)
}
if len(revs) != 1 || revs[0].OldDst != "Бородатый Ван" || revs[0].NewDst != "Ван Ху" {
t.Fatalf("revision not logged correctly: %+v", revs)
}
// Re-replacing identical does NOT add a second revision.
if err := s.ReplaceGlossary("book", []GlossaryEntry{gEntry("王胡", "Ван Ху", "approved")}); err != nil {
t.Fatal(err)
}
revs, _ = s.GlossaryRevisionsForBook("book")
if len(revs) != 1 {
t.Fatalf("idempotent replace added a spurious revision: %d", len(revs))
}
}
func TestUniqueSrcSenseWindow(t *testing.T) {
s, _ := openTemp(t)
// Same src+sense+window twice → UNIQUE violation (B2).
dup := []GlossaryEntry{gEntry("道", "путь", "approved"), gEntry("道", "дао", "approved")}
if err := s.ReplaceGlossary("book", dup); err == nil {
t.Fatal("expected UNIQUE(src,sense,window) violation for duplicate src+sense+window")
}
// Same src, different SENSE → allowed (polysemy, A3).
ok := []GlossaryEntry{
{BookID: "book", Src: "道", Dst: "путь", Sense: "way", Status: "approved"},
{BookID: "book", Src: "道", Dst: "дао", Sense: "dao", Status: "approved"},
}
if err := s.ReplaceGlossary("book", ok); err != nil {
t.Fatalf("distinct senses should be allowed: %v", err)
}
got, _ := s.GlossaryForBook("book")
if len(got) != 2 {
t.Fatalf("want 2 polysemy rows, got %d", len(got))
}
}
func TestRetrievalStateUpsert(t *testing.T) {
s, _ := openTemp(t)
rs := RetrievalState{BookID: "book", Chapter: 1, ChunkIdx: 0, SnapshotID: "snap",
NExactHits: 3, NAmbiguousFlagged: 1, NSpoilerBlocked: 1, NPostcheckMiss: 1,
PostcheckDetail: `[{"src":"阿Q","dst":"А-кью"}]`, InjectedIDs: `["ah_q"]`}
if err := s.UpsertRetrievalState(rs); err != nil {
t.Fatal(err)
}
got, err := s.GetRetrievalState("book", 1, 0)
if err != nil || got == nil {
t.Fatalf("get: %v %v", got, err)
}
if got.NExactHits != 3 || got.NSpoilerBlocked != 1 || got.NPostcheckMiss != 1 {
t.Fatalf("roundtrip mismatch: %+v", got)
}
// Overwrite (resolve semantics on resume): new counts win.
rs.NExactHits = 5
rs.NPostcheckMiss = 0
if err := s.UpsertRetrievalState(rs); err != nil {
t.Fatal(err)
}
got, _ = s.GetRetrievalState("book", 1, 0)
if got.NExactHits != 5 || got.NPostcheckMiss != 0 {
t.Fatalf("overwrite failed: %+v", got)
}
all, _ := s.RetrievalStatesForBook("book")
if len(all) != 1 {
t.Fatalf("want 1 retrieval-state row, got %d", len(all))
}
}

View file

@ -161,6 +161,98 @@ var migrations = []string{
);
CREATE INDEX IF NOT EXISTS ruby_readings_book_idx ON ruby_readings (book_id);
`,
// v5: the memory bank v2 glossary (шаг 4, registry architecture/06 §Гейты + D7).
// The DETERMINISTIC substrate the hot-path matcher, the injection surface and the
// post-check are built on. Rows are the frozen-at-job-start injectable pool whose
// approved subset the F1 memoryVersion() hashes into the snapshot. Like ruby_readings
// this is "dumb storage" (aggregation/classification live in the pipeline); the
// pipeline REPLACES a book's whole glossary from its deterministic inputs (seed file
// + classified ruby), so re-seeding is idempotent and a seed edit converges every
// column. No cross-table FK (mirrors the ruby/chunkstatus dumb-storage style): both
// child tables are scoped by book_id and replaced in the same transaction, term_id is
// a logical link (glossary.id is a fresh autoincrement each replace and is NEVER hashed
// into memoryVersion — only content columns are, ORDER BY-stable).
`
CREATE TABLE IF NOT EXISTS glossary (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
src TEXT NOT NULL, -- source key (raw; normalized in Go for matching, §3.6)
dst TEXT NOT NULL DEFAULT '', -- approved Russian translation (LEMMA/base form); '' for a ruby candidate with no dst yet
type TEXT NOT NULL DEFAULT '', -- name|term|title| alias-graph node type (B3)
sense TEXT NOT NULL DEFAULT '', -- A3 polysemy disambiguator (part of the uniqueness key)
gender TEXT NOT NULL DEFAULT '', -- male|female|hidden|'' (C2; hidden = gender-avoidant rendering until reveal)
speech TEXT NOT NULL DEFAULT '', -- ты/вы register note (series-bible-lite; the transition JOURNAL is deferred)
decl TEXT NOT NULL DEFAULT '', -- JSON {"invariant":bool,"forms":[...]}: the dst declension forms the post-check accepts (E1/E2 safety, decl = grammaticality mechanism, not a market feature)
translit_policy TEXT NOT NULL DEFAULT '', -- B5 western-name-via-katakana (field only in v1; validator = Phase 2)
first_person TEXT NOT NULL DEFAULT '', -- D7 first_person field (field only)
nickname_translation TEXT NOT NULL DEFAULT '', -- D7 (keep in v1: a column + a "translate once" lock)
since_ch INTEGER NOT NULL DEFAULT 0, -- spoiler window start; 0 = valid from the beginning (C1 hard reject-gate below since_ch)
until_ch INTEGER NOT NULL DEFAULT 0, -- spoiler window end; 0 = no end (valid forever)
status TEXT NOT NULL DEFAULT 'auto',-- auto|draft|approved (term status machine; only approved is CONFIRMED-injected)
allow_short INTEGER NOT NULL DEFAULT 0, -- rare escape hatch: permit a below-min_key_len key (A3 single-key ban override)
source TEXT NOT NULL DEFAULT '', -- provenance: seed|ruby|auto (who created the row)
ruby_reading TEXT NOT NULL DEFAULT '', -- ruby-seeded rows: the furigana reading (bridge to Polivanov dst validation, B6 Phase 2 NOT a dst)
ruby_class TEXT NOT NULL DEFAULT '', -- name|gloss|ambiguous (ruby classifier §8: phonetic-name name-lock candidate, unrelated reading gloss/footnote, else flag)
confidence INTEGER NOT NULL DEFAULT 0, -- ruby occurrences count = confidence prior (NOT a direct promotion to approved)
note TEXT NOT NULL DEFAULT '',
-- B2: at most one row per (source key, sense, spoiler window). since_ch/until_ch are
-- NOT NULL with default 0, so the SQLite "NULLs are distinct in UNIQUE" gotcha cannot
-- silently admit a duplicate. A term whose approved rendering changes across a spoiler
-- boundary is two rows with different windows (legitimately distinct), not a collision.
UNIQUE (book_id, src, sense, since_ch, until_ch)
);
CREATE INDEX IF NOT EXISTS glossary_book_idx ON glossary (book_id, status);
CREATE TABLE IF NOT EXISTS glossary_aliases (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
term_id INTEGER NOT NULL, -- logical link to glossary.id within the SAME replace tx (no hard FK dumb storage, scoped+replaced by book_id)
alias TEXT NOT NULL, -- an alternative source surface (raw; normalized in Go for matching)
alias_type TEXT NOT NULL DEFAULT '', -- имя|цзы|хао|прозвище|титул (B3 alias-graph node type)
UNIQUE (book_id, term_id, alias)
);
CREATE INDEX IF NOT EXISTS glossary_aliases_term_idx ON glossary_aliases (term_id);
CREATE INDEX IF NOT EXISTS glossary_aliases_book_idx ON glossary_aliases (book_id);
-- B1 editorial-time axis, ORTHOGONAL to the spoiler window (since_ch/until_ch is the
-- STORY timeline; this is the EDITORIAL timeline). Append-only journal of approved-dst
-- changes so a stale first-translation-wins rendering is DETECTABLE (DelTA PNR generates
-- stale/drift; this is the substrate that catches it). Not injected not in any hash;
-- datetime('now') here is fine (telemetry, like request_log), never feeds memoryVersion.
CREATE TABLE IF NOT EXISTS glossary_revisions (
id INTEGER PRIMARY KEY,
book_id TEXT NOT NULL,
src TEXT NOT NULL,
sense TEXT NOT NULL DEFAULT '',
old_dst TEXT NOT NULL DEFAULT '',
new_dst TEXT NOT NULL,
editorial_ts TEXT NOT NULL DEFAULT (datetime('now')),
reason TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS glossary_revisions_book_idx ON glossary_revisions (book_id, src);
-- Per-chunk retrieval-state (registry gate #4 / A1/F2/G2/E1): observability of memory
-- degradation FROM DAY ONE the mechanism that converts silent degradation into loud.
-- Recomputed deterministically each run (the matcher is $0), so it self-heals on resume;
-- NOT wire-load-bearing (it records what the injection did, it does not change the request).
CREATE TABLE IF NOT EXISTS retrieval_state (
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL,
chunk_idx INTEGER NOT NULL,
snapshot_id TEXT NOT NULL,
n_exact_hits INTEGER NOT NULL DEFAULT 0, -- entries matched by an exact key/alias in this chunk
n_sticky INTEGER NOT NULL DEFAULT 0, -- entries carried by scene-inertia (A5), not re-matched here
n_ambiguous_flagged INTEGER NOT NULL DEFAULT 0, -- injected AMBIGUOUS (auto/draft) "unverified" + forced post-check (A2)
n_spoiler_blocked INTEGER NOT NULL DEFAULT 0, -- matched but hard-rejected by the since_ch/until_ch window (C1)
n_evicted INTEGER NOT NULL DEFAULT 0, -- dropped by the token budget (F2 logged, never silent)
embedding_tier_used INTEGER NOT NULL DEFAULT 0, -- always 0 in v1 (no embeddings on the hot path, Р3)
n_postcheck_miss INTEGER NOT NULL DEFAULT 0, -- E1 flags: a confirmed src matched but no accepted dst form is in the output
postcheck_detail TEXT NOT NULL DEFAULT '', -- JSON [{src,dst}] of the misses, for the report / human
injected_ids TEXT NOT NULL DEFAULT '', -- JSON of exact-matched entity keys (audit + sticky reconstruction)
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, chapter, chunk_idx)
);
`,
}
// migrate runs all pending migrations on the write pool, one transaction per

View file

@ -430,6 +430,30 @@ keep-alive (Ф12), инъекция глоссария (`selective`), пор
Отсеянная находка (oversize-хвост <500) осталась документированной границей §7b. Дальше банк памяти v2 (шаг 4): реестр рисков `architecture/06` + ruby-контракт готовы (F1-materialization + горячий путь + post-check). Онбординг-промт след. сессии `docs/BACKEND_SESSION_PROMPT_MEMORY.md`.
### 2026-07-05 — Шаг 4: банк памяти v2 (глоссарий + горячий путь + инъекция + F1 + post-check)
**Главный блокер качества (консистентность имён/терминов) реализован — веха AE на seeded-глоссарии** (граница вехи подтверждена оркестратором §8: механизм на ручном+ruby-сиде; автопопуляция/адъюдикация/резюме/series-bible — отдельные вехи). Всё зелёное `go build/vet/test ./... -race` (125 тестов в pipeline), `tmctl report` $0. Ратифицированный порядок реестра `architecture/06` §Гейты соблюдён.
**A4 нормализация (`memnorm.go` + `data/trad2simp.txt`):** NFKC + trad→simp (вендорённая таблица, **версия = хеш содержимого таблицы** → дополнение = громкий `--resnapshot`, не тихая мини-карта) + катакана→хирагана + lower для источника; NFC + lower + ё→е + **схлопывание whitespace + унификация тире** для ru-цели. Симметрично ключ↔текст. Полный OpenCC оффлайн недоступен (нет данных на стенде) → курированный высокочастотный набор, все referenced/live-bug кейсы (爺→爷 и др.) покрыты и заассерчены; **завершение полного OpenCC — единственный отслеживаемый data-drop перед zh-приёмкой** (приёмочная книга D9 — ja→ru, где trad→simp не несущий).
**A схема (миграция v5):** `glossary` (D7 + `sense`/ось редакционного времени `glossary_revisions`/`UNIQUE(src,sense,window)`/`min_key_len`-запрет одиночных ключей/`allow_short`) + `glossary_aliases` (alias-граф) + `glossary_revisions` (B1 append-only) + `retrieval_state` (гейт #4). Idempotent full-replace (как ruby). `store/glossary.go`.
**B горячий путь (`memory.go`):** frozen `MemoryBank` из store-строк; **Aho-Corasick** по нормализованным ключам; запрет одиночных ключей (A3); longest-match containment; спойлер-hard-reject (C1, `since_ch/until_ch`, 0=безграничен); sticky scene-inertia (A5); токен-бюджет с логируемым вытеснением (F2); трёхсторонний disposition (A2 confirmed/ambiguous/reject). Детерминизм: 0 map-order в выводе, T1T10 портированы (**не accept-регэкспы**), ловушки `retrieval_bench` 0/N, 200× determinism. **НЕ FTS5/вектор/эмбеддинги** (Р3).
**C инъекция (`render.go`):** `MessagesWithInjection` — код-собранный `system`-месседж глоссария МЕЖДУ стабильным префиксом (кэш-граница) и user-чанком (вариант «а» оркестратора, шаблон не трогаем); НЕ в `ch.Text` (coverage/`{{text}}` чисты); свёрнут в `request_hash`/`content_hash`.
**D закрыт F1 (`runner.go`):** `memoryVersion()` = content-hash замороженных **approved**-строк + версии норм/матч-алгоритмов (D8, не version-counter). Смена approved → громкий `--resnapshot` (e2e доказал `1e7b…``552c…`). id не хешируется; auto/draft вне хеша (их инъекция ловится `content_hash`).
**E post-check (`mempostcheck.go`) + E1-замер:** decl-осознанный (**наивный регэксп = 1836% ложных, research/14**), whole-word матч по записанным `decl`-формам, sticky исключён, слепой post-replace запрещён (E2). **Флаггер по умолчанию** (миссы → `retrieval_state`, видны в `tmctl report`), **жёсткий гейт opt-in** (`gates.glossary.postcheck_gate`, как coverage — флип после валидации владельцем). **E1 измерен как Go-тест на реальном склонённом русском (канон Рогова): full-decl 0% ложных, base-only 67%** — эмпирическое подтверждение, что stored-decl надёжен ТОЛЬКО при полноте (ответ на развилку stored-decl-в-Go vs pymorphy-сайдкар Ф2: держим флаггером, сайдкар — валидированный фолбэк если реальные книги зашумят).
**Ruby→глоссарий (`memseed.go`):** классификатор `name_candidate`/`gloss_candidate`/`ambiguous`**никогда не авто-лочит** (блайнд-лок = mistranslation; 強敵→とも неотличим от имени оффлайн → кандидат человеку). Ruby → auto-кандидаты (src=base, reading=мост к Поливанову B6, без dst), дедуп по base, скип manual-src.
**Интеграция + config:** `TranslateBook`: ingest→persistRuby→seedGlossary(replace+materialize)→snapshot→loop с sticky-окном (ресет на границе главы, пересобирается детерминированно на resume). `book.yaml: glossary_seed`. `tmctl report` — секция ПАМЯТЬ (агрегаты + пост-check миссы). Пост-check валидирует **финальный (редакторский) вывод** — то, что экспортируется, не только черновик.
**Агентское адверсариальное селфревью (§6, многоагентный Workflow):** 7 дименсий → 10 находок → независимая refute-by-default верификация с прогоном реального кода → **8 CONFIRMED (все воспроизведены), 2 REFUTED** (документированные размены: min-key-2 гомографы; редакторский drift — закрыт пост-check'ом финала). Все 8 исправлены + регресс-тесты **мутационно-проверены** (реверт → падает именно его тест): #1 empty-dst не матчится; #2/#8 whitespace-wrap/тире в post-check; #3 whole-word матч (дропнутый «Ван» не маскируется «караван»); #4 gate-mode `memoryVersion` сворачивает draft-decl (иначе resume молча флипал чанк); #5 дубль-alias в сиде дедупится (не крашит книгу); #6 спойлер-фильтр перед longest-match (спойлер-блокнутый длинный ключ не глотает валидное короткое имя); #7 floor-free токен-бюджет.
**Заложено/отложено (реестр §5, поля/место есть, реализация — Ф2/веха+):** Палладий/Поливанов B6 (reading как мост заложен), гейт фактичности резюме D1, эмбеддинг-слой A7/D2, морфо-гейт глагольного рода C3 (pymorphy-сайдкар), бэкап файла F4, автопопуляция/адъюдикация G1/F5, series-bible ты/вы-журнал, инъекция глоссария в редактор (dst-констрейнты) — fast-follow на той же поверхности. Ставка (селективная инъекция vs длинный контекст) гейтится in-house eval'ом пилота Ф2.5 (параллельно, не блокер). Правки только в коде `backend/` + этот журнал; `architecture/06`/`research/*` не трогались.
## Полигон
(секция параллельной сессии — записи добавлять сюда)