Land pack-13 release QA: mined-delta collision guard, langpack heading policy, percent and latin and broken-word checkers, gender to draft wire, style canon, dspro default editor, CostSource, loop guard

This commit is contained in:
Claude (backend session) 2026-07-24 03:19:08 +03:00
parent 7fe0a5bdee
commit 2f91b048e4
32 changed files with 1320 additions and 181 deletions

View file

@ -124,6 +124,23 @@ func renderReport(w io.Writer,
row.LatencyMS, row.FinishReason, row.TMHit, row.OK, errTail(row.Degraded, row.Err)) row.LatencyMS, row.FinishReason, row.TMHit, row.OK, errTail(row.Degraded, row.Err))
} }
// Estimated-spend legend (pack-13 point-9, research/21 §1.10): rows whose cost_usd is a reservation
// ESTIMATE (a billed decode failure, or a paid 2xx with zero usage), not a provider-reported cost.
// Surfacing the share keeps an estimate from looking like a real zero-token call; est_tokens is the
// display-only fertility output estimate. Prints only when there is at least one such row.
var estRows, estTokens int
var estUSD float64
for _, row := range rows {
if row.Estimated == 1 {
estRows++
estUSD += row.CostUSD
estTokens += row.EstTokens
}
}
if estRows > 0 {
fmt.Fprintf(w, "estimated-cost rows: %d ($%.6f settled at the reservation estimate, not provider-reported; ~%d est. output tokens via fertility, display-only)\n", estRows, estUSD, estTokens)
}
// Flag section (Milestone 2): every chunk×stage whose disposition ≠ ok — the // Flag section (Milestone 2): every chunk×stage whose disposition ≠ ok — the
// "flag for the editor" the plan requires (02-mvp Phase-1 acceptance allows N). // "flag for the editor" the plan requires (02-mvp Phase-1 acceptance allows N).
flags, err := fetchFlags() flags, err := fetchFlags()
@ -234,8 +251,8 @@ func renderQuality(w io.Writer, q *pipeline.QualityReport) error {
// Claim-1: choppy paragraphs. ≈1.0 sentences/paragraph = choppy (the owner's complaint); higher = merged prose. // Claim-1: choppy paragraphs. ≈1.0 sentences/paragraph = choppy (the owner's complaint); higher = merged prose.
fmt.Fprintf(w, "STRUCTURE (claim-1 «choppy paragraphs»): sentences/narrative-paragraph=%.2f (sentences=%d / narrative-paragraphs=%d)\n", fmt.Fprintf(w, "STRUCTURE (claim-1 «choppy paragraphs»): sentences/narrative-paragraph=%.2f (sentences=%d / narrative-paragraphs=%d)\n",
q.MeanSentPerNarrPara, q.NarrativeSentences, q.NarrativeParagraphs) q.MeanSentPerNarrPara, q.NarrativeSentences, q.NarrativeParagraphs)
fmt.Fprintf(w, "SIGNALS: dialogue-dash=%d · glossary-misses=%d · number-drift=%d · trust-gated(seed)=%d\n", fmt.Fprintf(w, "SIGNALS: dialogue-dash=%d · glossary-misses=%d · number-drift=%d · trust-gated(seed)=%d · degenerate-loops=%d\n",
q.DialogueDashFlags, q.GlossaryMisses, q.NumberDriftFlags, q.TrustGated) q.DialogueDashFlags, q.GlossaryMisses, q.NumberDriftFlags, q.TrustGated, q.DegenerateLoopRuns)
// Echo split (D39.18): draft = the translator echoed (incl. dropped c-lite members) / edit = the editor // Echo split (D39.18): draft = the translator echoed (incl. dropped c-lite members) / edit = the editor
// echoed in what was delivered. Cosmetic-strip is per unit (the sanitizer runs on the final stage). // echoed in what was delivered. Cosmetic-strip is per unit (the sanitizer runs on the final stage).
fmt.Fprintf(w, "STRIPS/ECHO: cosmetic-strip units=%d (%.1f%%, markdown+CJK) · echo draft=%d (%.1f%%) · echo edit=%d (%.1f%%)\n", fmt.Fprintf(w, "STRIPS/ECHO: cosmetic-strip units=%d (%.1f%%, markdown+CJK) · echo draft=%d (%.1f%%) · echo edit=%d (%.1f%%)\n",

View file

@ -0,0 +1,8 @@
# Chapter-heading rule for zh→ru. The model is not trusted with a chapter title (it rendered «Раздел 2»,
# «Первая глава», an orphaned « :» and dropped titles across models). The chunker detects a source header
# of the shape `<marker><numeral><unit>[separator]…`, strips the marker+numeral+unit from the model input,
# and renders the title deterministically from `template` (with {n} = the parsed section number).
# `key<TAB>value` per line.
marker 第
units 章节節回
template Глава {n}

View file

@ -0,0 +1,74 @@
# Резервный арм редактора: glm-5 БИЛИНГВ (D39.22). Копия боевого C1 (pipeline-c1.yaml) с ЕДИНСТВЕННЫМ
# изменением: editor deepseek-v4-pro → glm-5 (+ few_shot ON по дефолту). Арм = КОНФИГ (другой editor-model
# → другой edit-wave snapshot). Атрибуция едет в chunk_status.model_actual → export/report.
#
# После D39.22 дефолт-редактор прод-конфига — deepseek-v4-pro (интерим: судья №1, лучшая проза/глоссарий,
# ×2 дешевле). glm-5 держим ЖИВЫМ арм-файлом-резервом: пере-прогон rerun2 не короновал dspro-vs-glm
# (зазор ~шум), итерация №2 отложена как довесок к следующему платному прогону — отмена интерим-выбора
# одним этим конфигом. mistral исключён как несущий редактор (D39.20 — критические смысловые аварии).
#
# glm-5: thinking-выключатель в capabilities.reasoning (D3.1) — reasoning:off ⇒ {thinking:{type:disabled}}
# (эмпирика полигона: thinking GLM = таймауты ×3). Вход редактора — русский черновик, эхо-класс не грозит.
core: C1
version: 1
defaults:
max_output_ratio: 2.2
min_max_tokens: 2048
context:
glossary_injection: selective
glossary_token_budget: 800
cache_ttl: "5m"
segmentation:
draft_budget_out: 1797
edit_ceiling_out: 3200
fertility:
cjk: 1.1978
other: 0.3852
retries:
regenerate_before_escalate: 1
stages:
- name: draft
role: translator
model: deepseek-v4-flash
prompts:
zh-ru: ../prompts/translator.md
prompt_version: v1-reflow
temperature: 0.3
reasoning: "off"
escalate_to: deepseek-v4-pro
- name: edit
role: editor
# РЕЗЕРВ: glm-5 билингв-редактор (D30.1). Пиннут (editor не эскалирует — D12). few_shot ON (дефолт) —
# P1a-дискурс few-shot держится (glm не reasoning-модель, CoT-конфликта нет, в отличие от dspro-арма).
model: glm-5
prompts:
zh-ru: ../prompts/editor.md
prompt_version: v3-discourse-reflow
temperature: 0.4
reasoning: "off" # glm thinking:disabled — таймауты ×3 (полигон)
gates:
coverage:
enabled: false
len_ratio_bounds:
zh-ru: [2.2, 4.2]
ja-ru: [1.4, 2.6]
en-ru: [0.70, 1.4]
sent_cov_min: 0.75
min_chunk_chars: 500
sanitizer:
enabled: true
escalation:
chains:
default: [deepseek-v4-pro, glm-5.1, gemini-3.1-pro-preview]
adult: [grok-4.3]
budget_usd: 0
fanout:
candidates: 1

View file

@ -64,12 +64,16 @@ stages:
escalate_to: deepseek-v4-pro escalate_to: deepseek-v4-pro
- name: edit - name: edit
role: editor role: editor
# Дефолт-редактор — glm-5 БИЛИНГВ (флип D1→D30.1, supersede D17.в): моно-редактор # Дефолт-редактор — deepseek-v4-pro БИЛИНГВ, ИНТЕРИМ (D39.22): пере-прогон rerun2 не короновал
# пассивен и структурно слеп к искажениям черновика (exp12 §2.2/2.5); билингв видит # glm-vs-dspro (зазор ~шум), но dspro = судья №1 (0.679), лучшая проза+глоссарий, дефекты чинибельного
# исходник (editor.md получил {{text}}). glm-5 — кандидат value по exp12 §3 (судейский #1, # класса (заголовки/CJK-strip → пак-13) и ×2 дешевле glm → интерим-выбор владельца. glm-5 — резервный
# верность 5.0, 0 утечек преамбул; grok reasoning-off из редакторских ролей СНЯТ — no-op). # арм (configs/pipeline-arm-glm.yaml); mistral исключён (D39.20 — критические смысловые аварии).
# gemini-3.1-pro — премиум-эскалация редактора ТОЛЬКО за санитайзером (утечка преамбул 6/6). # Флип glm-5→deepseek-v4-pro отменяем одним арм-конфигом. gemini-3.1-pro — премиум-эскалация редактора
model: glm-5 # ТОЛЬКО за санитайзером (утечка преамбул 6/6); editor pinned (не эскалирует по цепочке — D12).
# ЭХО-МИНА (несущая): deepseek резолвится в ReasoningNone (нет reasoning-capability) → reasoning:off =
# NO-OP, thinking остаётся ON (эхо-мина НЕ вооружается); floor min_max_tokens 8000 (D24.3) — из
# models.yaml, НЕ переопределять. Вход редактора — русский черновик (не CJK), эхо-класс тут вырожден.
model: deepseek-v4-pro
# Слой 2 (D39): pair-keyed пакет конвенций (см. draft-стадию) — наполнена zh→ru. # Слой 2 (D39): pair-keyed пакет конвенций (см. draft-стадию) — наполнена zh→ru.
prompts: prompts:
zh-ru: ../prompts/editor.md zh-ru: ../prompts/editor.md
@ -79,7 +83,8 @@ stages:
# Билингв-каркас (D30.1) и omission-осторожность (D34.3) сохранены. Моно-вариант — prompts/editor-mono.md. # Билингв-каркас (D30.1) и omission-осторожность (D34.3) сохранены. Моно-вариант — prompts/editor-mono.md.
prompt_version: v3-discourse-reflow prompt_version: v3-discourse-reflow
temperature: 0.4 temperature: 0.4
reasoning: "off" reasoning: "off" # NO-OP на deepseek-v4-pro (ReasoningNone) → thinking ON; НЕ вооружает эхо-мину
few_shot: false # deepseek-thinking CoT сбивается ручными примерами (exp14 §2а) — как в ратифицированном dspro-арме
gates: gates:
coverage: coverage:

View file

@ -42,17 +42,18 @@ stages:
- name: edit - name: edit
role: editor role: editor
# C2 — неисполняемый скелет Фазы 0/1 (fan-out/select — механика Ф2). Модель/бамп следуют # C2 — неисполняемый скелет Фазы 0/1 (fan-out/select — механика Ф2). Модель/бамп следуют
# за C1: editor.md стал БИЛИНГВ (D30.1) + reflow (D30.2) — тот же общий файл. Дефолт-редактор # за C1: дефолт-редактор по D39.22 — deepseek-v4-pro ИНТЕРИМ (glm-5 — резерв, pipeline-arm-glm.yaml).
# по D30.1 — glm-5 билингв (grok reasoning-off из редакторов СНЯТ — no-op). (Стадию select # reasoning:off = NO-OP на dspro (ReasoningNone) → thinking ON; floor 8000 из models.yaml. (Стадию
# выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем.) # select выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем: это судья, не редактор.)
model: glm-5 model: deepseek-v4-pro
prompts: prompts:
zh-ru: ../prompts/editor.md zh-ru: ../prompts/editor.md
# Бамп v2-bilingual-reflow→v3-discourse-reflow (label-SHA дисциплина: тот же editor.md, что в C1 — # Бамп v2-bilingual-reflow→v3-discourse-reflow (label-SHA дисциплина: тот же editor.md, что в C1 —
# вшито P1a-ядро ДИСКУРС-ПЕРЕВЁРСТКИ, D37 §2а; лейбл обязан следовать за новым SHA файла). # вшито P1a-ядро ДИСКУРС-ПЕРЕВЁРСТКИ, D37 §2а; лейбл обязан следовать за новым SHA файла).
prompt_version: v3-discourse-reflow prompt_version: v3-discourse-reflow
temperature: 0.4 temperature: 0.4
reasoning: "off" reasoning: "off" # NO-OP на deepseek-v4-pro (ReasoningNone) → thinking ON; НЕ вооружает эхо-мину
few_shot: false # deepseek-thinking CoT сбивается ручными примерами (exp14 §2а) — как в C1/dspro-арме
gates: gates:
coverage: coverage:

View file

@ -165,14 +165,16 @@ func TestBoevoyConfigDeepSeekThinkingStaysOn(t *testing.T) {
} }
} }
// TestBoevoyConfigEditorGlm5AndGrokReasoning pins two invariants. (1) The D30.1 flip: the // TestBoevoyConfigEditorDsproAndGrokReasoning pins two invariants. (1) The D39.22 interim editor:
// default editor of both boevoy pipelines is glm-5 BILINGUAL (mono-editor was passive and // the default editor of both boevoy pipelines is deepseek-v4-pro BILINGUAL (supersedes the D30.1
// blind, exp12; grok reasoning-off is REMOVED from editor roles — a no-op) and its reasoning // glm-5 default; the pere-run did not crown glm-vs-dspro, dspro = judge #1 / cheaper / repairable
// is off. (2) grok-4.3 (still the channel-B tier / 18+ judge in models.yaml) resolves to an // defects → owner interim, glm-5 kept as pipeline-arm-glm.yaml reserve) and its reasoning is "off"
// EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT off-by-omission // — the LOAD-BEARING echo-mine semantic: on deepseek-v4-pro "off" resolves to ReasoningNone (a
// — grok's OMITTED default is "low" (it thinks, billed additively per xAI), and it must not // NO-OP), so thinking stays ON and the echo-mine is NOT armed. (2) grok-4.3 (still the channel-B
// trip the echo-mine guard. c2's select (judge) stage stays the glm-5 placeholder. // tier / 18+ judge in models.yaml) resolves to an EXPLICIT reasoning off-switch (ReasoningEffortField
func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) { // + OffEffort "none"), NOT off-by-omission — grok's OMITTED default is "low" (it thinks, billed
// additively per xAI), and it must not trip the echo-mine guard. c2's select (judge) stage stays glm-5.
func TestBoevoyConfigEditorDsproAndGrokReasoning(t *testing.T) {
m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml")) m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
if err != nil { if err != nil {
t.Fatalf("load boevoy models.yaml: %v", err) t.Fatalf("load boevoy models.yaml: %v", err)
@ -187,7 +189,9 @@ func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) {
if why := m.echoMineViolation(grok); why != "" { if why := m.echoMineViolation(grok); why != "" {
t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", grok, why) t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", grok, why)
} }
// (1) D30.1 editor flip: glm-5 bilingual editor, reasoning off, in both boevoy pipelines. // (1) D39.22 interim editor flip: deepseek-v4-pro bilingual editor, reasoning "off" (a ReasoningNone
// no-op — thinking stays ON), in both boevoy pipelines. The glm-5 default is superseded (kept as a
// reserve arm); the c2 select (judge) glm-5 slot is a DIFFERENT role and is unaffected.
for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} { for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} {
p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m) p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m)
if err != nil { if err != nil {
@ -202,11 +206,11 @@ func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) {
selectModel = st.Model selectModel = st.Model
} }
} }
if editModel != "glm-5" { if editModel != "deepseek-v4-pro" {
t.Errorf("%s edit stage model = %q, want glm-5 (D30.1: bilingual editor; grok reasoning-off REMOVED from editors — no-op)", pf, editModel) t.Errorf("%s edit stage model = %q, want deepseek-v4-pro (D39.22 interim editor; glm-5 is the reserve arm)", pf, editModel)
} }
if editReasoning != "off" { if editReasoning != "off" {
t.Errorf("%s edit stage reasoning = %q, want off (glm-5 thinking:disabled — timeouts ×3)", pf, editReasoning) t.Errorf("%s edit stage reasoning = %q, want \"off\" — the echo-mine semantic: on deepseek-v4-pro \"off\"=ReasoningNone (no-op) so thinking stays ON, never armed", pf, editReasoning)
} }
if pf == "pipeline-c2.yaml" && selectModel != "glm-5" { if pf == "pipeline-c2.yaml" && selectModel != "glm-5" {
t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot), got %q", pf, selectModel) t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot), got %q", pf, selectModel)
@ -234,6 +238,7 @@ func TestSwapArmConfigs(t *testing.T) {
}{ }{
{"pipeline-arm-mistral.yaml", "mistral-large-2512", false}, {"pipeline-arm-mistral.yaml", "mistral-large-2512", false},
{"pipeline-arm-deepseek-pro.yaml", "deepseek-v4-pro", true}, {"pipeline-arm-deepseek-pro.yaml", "deepseek-v4-pro", true},
{"pipeline-arm-glm.yaml", "glm-5", false}, // D39.22 glm-5 reserve arm (few-shot ON: glm is not a reasoning model)
} }
for _, a := range arms { for _, a := range arms {
t.Run(a.file, func(t *testing.T) { t.Run(a.file, func(t *testing.T) {

View file

@ -53,9 +53,26 @@ type Pack struct {
PalladiusYW map[string]string PalladiusYW map[string]string
PalladiusSpecialI map[string]string PalladiusSpecialI map[string]string
// Heading is the OPTIONAL chapter-heading rule (configs/langpacks/<pair>/heading.txt). nil when the pair
// carries no heading.txt — the chapter-title feature is then inert (the chunker keeps the source header
// as-is), so a pair that does not opt in is never re-billed for it. It is DATA only: the detect/strip/
// render ALGORITHM lives in the chunker (internal/pipeline), which reads this table — the same
// data/algorithm boundary the miner tables keep.
Heading *HeadingRule
version string version string
} }
// HeadingRule is the per-pair data for the chapter-title policy: instead of letting the model render a
// chapter heading (which drifted to «Раздел 2» / «Первая глава» / an orphaned « :» across models), the
// chunker detects a source header (Marker + a numeral + a Unit rune), strips it from the model input, and
// the read-models render Template deterministically instead. Data only; parsed from heading.txt.
type HeadingRule struct {
Marker string // the prefix rune(s) that open a numbered heading, e.g. "第"
Units map[rune]bool // the section-unit runes accepted right after the numeral (章 节 節 回)
Template string // the target rendering; the literal "{n}" is replaced by the parsed Arabic number
}
// Version is the content hash of the pack (packAlgoVersion + a sha256 of the authored file bytes). A pack // Version is the content hash of the pack (packAlgoVersion + a sha256 of the authored file bytes). A pack
// edit changes it, so the pipeline can fold it into the snapshot (a loud --resnapshot on any data edit). // edit changes it, so the pipeline can fold it into the snapshot (a loud --resnapshot on any data edit).
func (p *Pack) Version() string { return p.version } func (p *Pack) Version() string { return p.version }
@ -111,6 +128,22 @@ func Load(root, sourceLang, targetLang string) (*Pack, error) {
} }
} }
// Optional per-pair heading rule (pack-13 title policy). ABSENT → nil, the title feature is inert and
// the pack's Version() is byte-stable (a pair that does not opt in is never re-billed); PRESENT → its
// bytes fold into the content hash (a loud --resnapshot on any edit) and it is parsed; CORRUPT → fail
// loud, like the required-file path. Distinct from read(): a missing heading.txt is NOT an error.
if hb, ok, herr := readOptional(root, pair, "heading.txt"); herr != nil {
return nil, fmt.Errorf("langpack %q heading.txt: %w", pair, herr)
} else if ok {
h.Write([]byte("\x00" + pair + "/heading.txt\x00"))
h.Write(hb)
hr, perr := parseHeading(hb)
if perr != nil {
return nil, fmt.Errorf("langpack %q heading.txt: %w", pair, perr)
}
p.Heading = hr
}
if err := p.validate(); err != nil { if err := p.validate(); err != nil {
return nil, fmt.Errorf("langpack %q: %w", pair, err) return nil, fmt.Errorf("langpack %q: %w", pair, err)
} }
@ -189,6 +222,63 @@ func (p *Pack) assignPair(name string, b []byte) error {
return nil return nil
} }
// readOptional reads an OPTIONAL pack file. A missing file returns (nil, false, nil) — the feature it
// backs is simply inert — while any OTHER read error (permission, a directory) is a loud failure; a
// present file returns (bytes, true, nil). Used for the pack-13 heading rule, which a pair opts into.
func readOptional(root, dir, name string) ([]byte, bool, error) {
b, err := os.ReadFile(filepath.Join(root, dir, name))
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
return nil, false, err
}
return b, true, nil
}
// parseHeading reads heading.txt into a HeadingRule. Format: `key<TAB>value` per non-comment line, keys
// marker | units | template (all three required). `units` is a rune SET (each rune a member); `template`
// must contain the literal "{n}" placeholder (else it could never render a number). Fail-loud on a missing
// key / unknown key / empty value / a template without {n} — a malformed rule is a corrupt pack, not an
// intended silent no-op (mirrors the required-file "never silently empty" contract).
func parseHeading(b []byte) (*HeadingRule, error) {
hr := &HeadingRule{Units: map[rune]bool{}}
seen := map[string]bool{}
for i, raw := range strings.Split(string(b), "\n") {
t := strings.TrimRight(raw, "\r")
if strings.TrimSpace(t) == "" || strings.HasPrefix(strings.TrimSpace(t), "#") {
continue
}
f := strings.SplitN(t, "\t", 2)
if len(f) != 2 || strings.TrimSpace(f[1]) == "" {
return nil, fmt.Errorf("line %d: want `key<TAB>value` with a non-empty value (%q)", i+1, t)
}
key, val := strings.TrimSpace(f[0]), strings.TrimSpace(f[1])
seen[key] = true
switch key {
case "marker":
hr.Marker = val
case "units":
for _, r := range val {
if !isSpace(r) {
hr.Units[r] = true
}
}
case "template":
hr.Template = val
default:
return nil, fmt.Errorf("line %d: unknown key %q (want marker|units|template)", i+1, key)
}
}
if !seen["marker"] || !seen["units"] || !seen["template"] || len(hr.Units) == 0 {
return nil, fmt.Errorf("heading rule needs non-empty marker, units and template")
}
if !strings.Contains(hr.Template, "{n}") {
return nil, fmt.Errorf("template %q must contain the {n} number placeholder", hr.Template)
}
return hr, nil
}
// runeSet reads a rune SET: every non-whitespace rune of every non-comment line is a member (order-free). // runeSet reads a rune SET: every non-whitespace rune of every non-comment line is a member (order-free).
func runeSet(b []byte) map[rune]bool { func runeSet(b []byte) map[rune]bool {
m := map[rune]bool{} m := map[rune]bool{}

View file

@ -123,7 +123,7 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return nil, err return nil, err
} }
chunks := SplitChunks(doc.Chapters, r.segBudget()) chunks := SplitChunks(doc.Chapters, r.segBudget(), r.headingRule())
if len(chunks) == 0 { if len(chunks) == 0 {
return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile) return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile)
} }

View file

@ -45,7 +45,14 @@ import (
// requires (a rule/pack edit shifts recorded counts). They fire 0 on a non-zh source / non-register text. // requires (a rule/pack edit shifts recorded counts). They fire 0 on a non-zh source / non-register text.
// Ш-2 (see memnorm.go): the cheap style/DC checkers classify via unicode predicates + NFC folding, so a // Ш-2 (see memnorm.go): the cheap style/DC checkers classify via unicode predicates + NFC folding, so a
// toolchain Unicode bump that shifts a class is a loud --resnapshot rather than a silent count change. // toolchain Unicode bump that shifts a class is a loud --resnapshot rather than a silent count change.
const cheapGateVersion = "cheapgate-v3-dc-checkers+u" + unicode.Version //
// v4 (pack-13) adds three GENERAL observability checkers (never a disposition, tuned precision over
// recall): a 成-percent scale checker (六成六=66% mis-rendered as a decimal fraction), a Latin-residue
// checker (a whole Latin word left in the Russian output), and a broken-word checker (a Russian word
// ending in the impossible «-йть»). All are language-general — no book-specific word lists. Editing a rule
// shifts the recorded counts, so the version bump is a loud --resnapshot; they fire 0 on a non-zh source /
// clean Russian output (the golden fixture stays style_flags=0).
const cheapGateVersion = "cheapgate-v4-percent-latin-brokenword+u" + unicode.Version
// cheapGateConfig carries the brief-derived knobs: the ё-policy and the per-project allowlist of // cheapGateConfig carries the brief-derived knobs: the ё-policy and the per-project allowlist of
// surfaces that look like a blocklisted interjection but are legitimate here (e.g. a character // surfaces that look like a blocklisted interjection but are legitimate here (e.g. a character
@ -76,12 +83,17 @@ type cheapGateResult struct {
DC1TimeUnits int `json:"dc1_time_units,omitempty"` DC1TimeUnits int `json:"dc1_time_units,omitempty"`
DC2Magnitude int `json:"dc2_magnitude,omitempty"` DC2Magnitude int `json:"dc2_magnitude,omitempty"`
DC6Register int `json:"dc6_register,omitempty"` DC6Register int `json:"dc6_register,omitempty"`
// PercentScale / LatinResidue / BrokenWord are the pack-13 general checkers (checkers_zh_ru.go),
// observability like the others. Zero on a clean Russian output / non-zh source.
PercentScale int `json:"percent_scale,omitempty"`
LatinResidue int `json:"latin_residue,omitempty"`
BrokenWord int `json:"broken_word,omitempty"`
Detail []string `json:"detail,omitempty"` Detail []string `json:"detail,omitempty"`
} }
func (c cheapGateResult) total() int { func (c cheapGateResult) total() int {
return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude + c.LengthCollapse + c.NumberDrift + return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude + c.LengthCollapse + c.NumberDrift +
c.DC1TimeUnits + c.DC2Magnitude + c.DC6Register c.DC1TimeUnits + c.DC2Magnitude + c.DC6Register + c.PercentScale + c.LatinResidue + c.BrokenWord
} }
// runCheapGates runs the four always-on style flaggers over one chunk's source and FINAL text, plus // runCheapGates runs the four always-on style flaggers over one chunk's source and FINAL text, plus
@ -110,6 +122,16 @@ func runCheapGates(source, draft, final string, cfg cheapGateConfig) cheapGateRe
n, det = lintRegisterLexicon(final) n, det = lintRegisterLexicon(final)
r.DC6Register = n r.DC6Register = n
r.Detail = append(r.Detail, det...) r.Detail = append(r.Detail, det...)
// pack-13 general checkers (checkers_zh_ru.go) — src↔target / Russian-side observability flaggers.
n, det = lintPercentScale(source, final)
r.PercentScale = n
r.Detail = append(r.Detail, det...)
n, det = lintLatinResidue(final, cfg.allowlist)
r.LatinResidue = n
r.Detail = append(r.Detail, det...)
n, det = lintBrokenWord(final)
r.BrokenWord = n
r.Detail = append(r.Detail, det...)
if cfg.regressionEnabled { if cfg.regressionEnabled {
rg := runRegressionGuard(draft, final) rg := runRegressionGuard(draft, final)
r.LengthCollapse = rg.LengthCollapse r.LengthCollapse = rg.LengthCollapse

View file

@ -0,0 +1,65 @@
package pipeline
import "testing"
// TestPack13Checkers pins the pack-13 GENERAL checkers against representative inputs (no book-specific
// word lists): each defect shape fires and a clean counterpart / golden-shape Russian output stays 0
// (precision over recall). The 时辰/千万 cases confirm the pre-existing DC1/DC2 still catch the corpus shapes.
func TestPack13Checkers(t *testing.T) {
// Percent scale (成 = tenths): a decimal-fraction rendering fires; a correct percent is suppressed.
if n, _ := lintPercentScale("море истинной ци 六成六 …", "море истинной ци — шесть десятых и шесть сотых"); n != 1 {
t.Errorf("percent: «шесть десятых и шесть сотых» should fire, got %d", n)
}
if n, _ := lintPercentScale("六成六", "море истинной ци — шесть и шесть десятых"); n != 1 {
t.Errorf("percent: «шесть и шесть десятых» should fire, got %d", n)
}
if n, _ := lintPercentScale("六成六", "заполнено на шестьдесят шесть процентов"); n != 0 {
t.Errorf("percent: a correct «процентов» rendering must be suppressed, got %d", n)
}
if n, _ := lintPercentScale("нет числа", "шесть десятых чего-то"); n != 0 {
t.Errorf("percent: no 成 in source must not fire, got %d", n)
}
// Latin residue: a lowercase leaked word fires; a hash / Cyrillic / Roman numeral / a brand or proper
// noun (any capital) does not — the precision guard the adversarial review's iPhone case exposed.
if n, _ := lintLatinResidue("открыл их again, горестно вздохнув", nil); n != 1 {
t.Errorf("latin: «again» should fire, got %d", n)
}
for _, clean := range []string{
"ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 5abc35ddfb65. Судзуки шёл по коридорам.", // a body-hash id (has digits)
"Классы таланта А, Б, В и Г — от высшей к низшей.", // Cyrillic class letters
"Глава II начинается.", // a Roman numeral (uppercase)
"Он держал в руках iPhone и MacBook.", // brands — any capital → skipped
"Судзуки шёл в Академию.", // Cyrillic proper noun
} {
if n, det := lintLatinResidue(clean, nil); n != 0 {
t.Errorf("latin: clean text must not fire (%q): %d %v", clean, n, det)
}
}
// The per-project allowlist exempts an intentional lowercase Latin surface.
if n, _ := lintLatinResidue("сказал again снова", map[string]bool{"again": true}); n != 0 {
t.Errorf("latin: an allowlisted surface must not fire")
}
// Broken word — the general «-йть» rule only (no book-specific lists): «войть» fires, valid words do not.
if n, _ := lintBrokenWord("хотел тихонько войть и закрыть"); n != 1 {
t.Errorf("broken: «войть» (-йть) should fire")
}
for _, clean := range []string{
"он решил войти и закрыть окно", // valid войти
"глава клана Гуюэ поклонился", // valid prose (no -йть)
"впереди идёт Фан Юань", // valid впереди
} {
if n, det := lintBrokenWord(clean); n != 0 {
t.Errorf("broken: clean text must not fire (%q): %d %v", clean, n, det)
}
}
// The pre-existing DC1/DC2 still catch the corpus shapes (general Chinese units/idioms).
if n, _ := lintTimeUnits("僵持了三个时辰", "прошло три часа"); n != 1 {
t.Errorf("time-unit: 三个时辰→«три часа» should fire")
}
if n, _ := lintMagnitudeScale("千万生灵", "погубил тысячи жизней"); n != 1 {
t.Errorf("magnitude: 千万→«тысячи» should fire")
}
}

View file

@ -166,3 +166,130 @@ func lintRegisterLexicon(final string) (int, []string) {
// isCyrLetter reports whether r is a Cyrillic letter (the word boundary for the register match). // isCyrLetter reports whether r is a Cyrillic letter (the word boundary for the register match).
func isCyrLetter(r rune) bool { return unicode.IsLetter(r) && unicode.Is(unicode.Cyrillic, r) } func isCyrLetter(r rune) bool { return unicode.IsLetter(r) && unicode.Is(unicode.Cyrillic, r) }
// --- percent-scale checker (成 = tenths) -----------------------------------------------------------
//
// In Chinese, 成 is one tenth: 六成 = 60%, 六成六 = 66%. A common translation error renders this as a
// decimal FRACTION instead of a percentage — «шесть десятых и шесть сотых» (0.66) or «шесть и шесть
// десятых» (6.6) for 六成六 — a ~100× scale error. This flags that. Precision over recall: it fires only
// when the source has «<count>成[<count>]», the output has NO percent form («процент»/«%»), AND the output
// carries a decimal-fraction cue (a «десятых»/«сотых» ordinal or a «N,N» number). So an output that renders
// the percent correctly is suppressed, and an output without a fraction cue (a paraphrase) stays silent.
// A general zh→ru unit convention, not tied to any book.
// chengPercentRE matches a 成-percent expression: a count (CJK or Arabic), 成, and an optional second count.
var chengPercentRE = regexp.MustCompile(`([0-9一二三四五六七八九十])成([0-9一二三四五六七八九]?)`)
// decimalFractionRE is the error cue: the tenths rendered as a Russian fraction ordinal or a decimal number.
var decimalFractionRE = regexp.MustCompile(`десят(?:ая|ых|ой|ые)|сот(?:ая|ых|ой|ые)|\d+[.,]\d`)
// lintPercentScale flags a 成-percent rendered as a decimal fraction instead of a percentage.
func lintPercentScale(source, final string) (int, []string) {
m := chengPercentRE.FindStringSubmatch(source)
if m == nil {
return 0, nil
}
low := strings.ToLower(final)
if strings.Contains(low, "процент") || strings.Contains(final, "%") {
return 0, nil // the output uses a percent form — the scale is handled correctly
}
if !decimalFractionRE.MatchString(low) {
return 0, nil // no fraction cue — the magnitude was paraphrased, not mis-scaled
}
tens, _ := dcParseCount(m[1])
pct := tens * 10
if m[2] != "" {
if ones, ok := dcParseCount(m[2]); ok {
pct += ones
}
}
return 1, []string{fmt.Sprintf("成-percent: %s成%s = %d%% rendered as a decimal fraction instead of a percentage (~%d%%)", m[1], m[2], pct, pct)}
}
// --- Latin residue in the Russian output -----------------------------------------------------------
//
// A whole Latin WORD left untranslated in the Russian output (e.g. «открыл их again, …»). It splits the
// output into maximal alphanumeric tokens and flags a token that is worth reporting as leaked prose. The
// target is a lowercase mid-sentence English word, so the guards keep precision high:
// - the token must be ALL LOWERCASE Latin letters. Leaked prose is lowercase; a token with any capital
// is a proper noun / brand / acronym (iPhone, Google, Suzuki, «II») — legitimate in Russian text, and
// the sanitizer already treats capitals as a brand signal, so we skip them here too.
// - it must have NO digit: an id / hash like «5abc35ddfb65» is an alphanumeric token, not a word.
// - at least minLatinResidueLen letters, not a Roman numeral, and not on the per-project allowlist.
// A foreignizing brief that keeps intentional Latin (a motto, a scientific name) puts those on the
// allowlist. Tuned for precision over recall: a leaked word that is Capitalized, or a bare URL host
// («example.com» → «example»/«com»), is not caught — accepted for a low-noise observability signal.
const minLatinResidueLen = 3
func lintLatinResidue(final string, allow map[string]bool) (int, []string) {
hits := map[string]bool{}
rs := []rune(final)
for i := 0; i < len(rs); {
if !isLatinLetterOrDigit(rs[i]) {
i++
continue
}
j := i
reject := false // set on any digit or uppercase letter — not a lowercase leaked word
for j < len(rs) && isLatinLetterOrDigit(rs[j]) {
if (rs[j] >= '0' && rs[j] <= '9') || (rs[j] >= 'A' && rs[j] <= 'Z') {
reject = true
}
j++
}
tok := string(rs[i:j])
i = j
if !reject && len([]rune(tok)) >= minLatinResidueLen && !isRomanNumeral(tok) && !allow[tok] {
hits[tok] = true
}
}
if len(hits) == 0 {
return 0, nil
}
surfaces := make([]string, 0, len(hits))
for s := range hits {
surfaces = append(surfaces, s)
}
sort.Strings(surfaces)
return len(surfaces), []string{"Latin word left untranslated in the Russian output: " + strings.Join(surfaces, ", ")}
}
// isLatinLetterOrDigit reports whether r is an ASCII Latin letter or digit (the alphanumeric-token alphabet).
func isLatinLetterOrDigit(r rune) bool {
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
}
// isRomanNumeral reports whether a lowercase token is a Roman numeral (all chars in ivxlcdm) — «iii» reads
// as a numeral, not a leaked word; excluded to hold precision. (Uppercase «II» is already skipped as a cap.)
func isRomanNumeral(tok string) bool {
for _, r := range tok {
switch r {
case 'i', 'v', 'x', 'l', 'c', 'd', 'm':
default:
return false
}
}
return true
}
// --- broken Russian word forms ---------------------------------------------------------------------
//
// Deliberately GENERAL, with NO dictionary and NO book-specific word lists: it flags a Russian word ending
// in «-йть», which no well-formed Russian word does — the shape of a mangled infinitive (e.g. «войть» for
// «войти»). It is zero false-positive and applies to any book. Malformations WITHOUT such a structural
// signature — a plausible misspelling («Вперди» for «Впереди») or a case-agreement error («глава клан»
// for «главы клана») — are NOT detectable deterministically without a morphology/dictionary pass, which is
// out of scope here; the report states this recall limit honestly. The existing sanitizer broken-word class
// (invalid soft/hard-sign bigrams, script-mixed homoglyph tokens) is orthogonal and still runs.
func lintBrokenWord(final string) (int, []string) {
seen := map[string]bool{}
var det []string
for _, w := range tokenizeCyrillic(final) {
if len([]rune(w)) >= 4 && strings.HasSuffix(w, "йть") && !seen[w] {
seen[w] = true
det = append(det, "malformed word ending in «-йть» (no valid Russian word does): "+w)
}
}
sort.Strings(det)
return len(det), det
}

View file

@ -1,8 +1,11 @@
package pipeline package pipeline
import ( import (
"strconv"
"strings" "strings"
"unicode" "unicode"
"textmachine/backend/internal/lang"
) )
// chunker.go: the step-3a SOURCE segmenter. It turns the per-chapter normalized // chunker.go: the step-3a SOURCE segmenter. It turns the per-chapter normalized
@ -46,6 +49,14 @@ type Chunk struct {
// boundary machinery, anti-scope), and the flag makes the un-budgetable unit OBSERVABLE // boundary machinery, anti-scope), and the flag makes the un-budgetable unit OBSERVABLE
// ("degradation is not silent") rather than silently oversized. // ("degradation is not silent") rather than silently oversized.
OversizedSentence bool OversizedSentence bool
// Heading is the deterministic chapter title, non-empty ONLY on a chapter's FIRST chunk (ChunkIdx 0)
// when the chapter opened with a source structural header (detected via the pair's lang.HeadingRule)
// AND a rule is configured. The chunker STRIPS the source marker from ch.Text (so the model never
// renders its own «Раздел 2»/«Первая глава»/an orphaned « :») and renders this from the rule's template
// («Глава N»); the read-models PREPEND it to the chapter's first output unit. "" for every non-first
// chunk, every chapter without a header, and every book without a heading rule (in which case the source
// header stays in ch.Text and chunking is byte-identical to before the feature existed).
Heading string
// EditUnitID is the 0-based book-global id of the coarse EDIT unit this draft chunk belongs to // EditUnitID is the 0-based book-global id of the coarse EDIT unit this draft chunk belongs to
// (WS2 decoupling: draft = small chunk, edit = large unit). An edit unit is a greedy grouping of // (WS2 decoupling: draft = small chunk, edit = large unit). An edit unit is a greedy grouping of
// WHOLE draft chunks up to EditCeilingOut (per chapter), so the edit wave's editor reads a unit's draft as // WHOLE draft chunks up to EditCeilingOut (per chapter), so the edit wave's editor reads a unit's draft as
@ -81,23 +92,217 @@ func (s SegBudget) estOut(cjk, other int) float64 {
// real content (Phase-0 backward compat). Fully deterministic. Each chunk carries its // real content (Phase-0 backward compat). Fully deterministic. Each chunk carries its
// EstOut + OversizedSentence flag + EditUnitID (WS2); the edit-unit id is monotone across // EstOut + OversizedSentence flag + EditUnitID (WS2); the edit-unit id is monotone across
// the book so a the edit wave unit is uniquely addressable. // the book so a the edit wave unit is uniquely addressable.
func SplitChunks(chapters []string, seg SegBudget) []Chunk { func SplitChunks(chapters []string, seg SegBudget, heading *lang.HeadingRule) []Chunk {
var out []Chunk var out []Chunk
chapterNo := 0 chapterNo := 0
editUnitID := 0 editUnitID := 0
for _, chapText := range chapters { for _, chapText := range chapters {
// Title policy (pack-13): detect a leading structural header, render it deterministically and
// STRIP its marker from the text the model sees. A nil rule (no pack / no heading.txt) or a chapter
// with no header is a NO-OP — headingText is "" and chapText is unchanged, so a book that does not
// opt in produces byte-identical chunks. Runs BEFORE splitParagraphs so the stripped subtitle is
// re-paragraphed normally.
headingText, chapText := stripHeading(chapText, heading)
paras := splitParagraphs(chapText) paras := splitParagraphs(chapText)
if len(paras) == 0 { if len(paras) == 0 {
continue // an empty chapter does not consume a chapter number continue // an empty chapter does not consume a chapter number
} }
chapterNo++ chapterNo++
chapterChunks := chapterDraftChunks(chapterNo, paras, seg) chapterChunks := chapterDraftChunks(chapterNo, paras, seg)
if headingText != "" && len(chapterChunks) > 0 {
chapterChunks[0].Heading = headingText // the chapter's first chunk carries the deterministic title
}
assignEditUnits(chapterChunks, seg, &editUnitID) assignEditUnits(chapterChunks, seg, &editUnitID)
out = append(out, chapterChunks...) out = append(out, chapterChunks...)
} }
return out return out
} }
// stripHeading detects a chapter-leading structural header via the pair's HeadingRule, returning the
// DETERMINISTIC rendered title and the chapter text with the source marker removed (pack-13 title policy).
// A nil rule or a first line that is not a header is a NO-OP: it returns ("", chapter) unchanged. The
// marker+numeral+unit prefix is stripped and the SUBTITLE (if any) is kept as ordinary body — so «第一节:
// 纵身亡魔心仍不悔» yields ("Глава 1", "纵身亡魔心仍不悔\n…"): the reader gets a uniform «Глава N» PLUS the
// translated subtitle, and the model never renders the chapter number. Only the FIRST line is inspected
// (a header is a chapter's opening line; a chapter with a preamble before its header is a rare edge left
// to the pre-pack behaviour, noted in the pack report). Deterministic and pure.
func stripHeading(chapter string, hr *lang.HeadingRule) (heading, stripped string) {
if hr == nil {
return "", chapter
}
parts := strings.SplitN(chapter, "\n", 2)
n, subtitle, ok := matchHeaderLine(parts[0], hr)
if !ok {
return "", chapter
}
heading = strings.ReplaceAll(hr.Template, "{n}", strconv.Itoa(n))
rest := ""
if len(parts) == 2 {
rest = parts[1]
}
switch {
case subtitle == "":
return heading, strings.TrimLeft(rest, "\n") // the whole header line + its trailing blank go away
case rest == "":
return heading, subtitle
default:
return heading, subtitle + "\n" + rest
}
}
// applyHeading prepends a chapter's DETERMINISTIC title to its first output unit's final text (pack-13
// title policy). It is a PURE, deterministic projection applied at assembly time (waverun outcome +
// export), NEVER stored in a checkpoint — so a resume re-derives it for free from the manifest re-chunk,
// and a book with no heading rule (heading=="") is byte-identical. It prepends ONLY to a NON-EMPTY final
// text: a fully-flagged/empty unit keeps "" (the "not translated" export semantics), and the title
// reappears when the unit is redriven ok. The separator is a blank line so «Глава N» reads as a heading
// above the (subtitle + body) prose.
func applyHeading(heading, finalText string) string {
if heading == "" || finalText == "" {
return finalText
}
return heading + "\n\n" + finalText
}
// matchHeaderLine reports whether a single line is a structural header under the rule and returns the
// parsed section number + the trimmed subtitle after the marker. Shape: <marker><numeral-run><unit-rune>,
// then EITHER end-of-line OR a SEPARATOR (not a content glyph) — mirroring ingest.isCJKChapterHeader's
// precision guard so «第一回见面» (回 a measure word glued to the content 见) is NOT a header, while
// «第一节:…» / «第1章 …» is. Leading whitespace is tolerated; the subtitle is the remainder with any
// leading separators (: 、,.。- —  ·) trimmed. Deterministic.
func matchHeaderLine(line string, hr *lang.HeadingRule) (n int, subtitle string, ok bool) {
t := strings.TrimSpace(line)
if hr.Marker == "" || !strings.HasPrefix(t, hr.Marker) {
return 0, "", false
}
rs := []rune(t[len(hr.Marker):])
i := 0
for i < len(rs) && isHeadingNumeral(rs[i]) {
i++
}
if i == 0 || i >= len(rs) { // need at least one numeral AND a unit rune after it
return 0, "", false
}
if !hr.Units[rs[i]] {
return 0, "", false
}
after := rs[i+1:]
if len(after) > 0 && isHeaderContentRune(after[0]) {
return 0, "", false // a content glyph glued to the unit → a measure word in prose, not a header
}
num, parsed := parseSectionNumeral(string(rs[:i]))
if !parsed {
return 0, "", false
}
return num, strings.TrimSpace(strings.TrimLeftFunc(string(after), isHeadingSeparator)), true
}
// isHeadingNumeral reports whether a rune can be part of a chapter-number run: an Arabic digit
// (half/fullwidth) or a CJK numeral character.
func isHeadingNumeral(r rune) bool {
switch {
case r >= '0' && r <= '9', r >= '' && r <= '':
return true
}
return strings.ContainsRune("〇零一二三四五六七八九十百千两兩", r)
}
// isHeaderContentRune reports whether a rune is CONTENT (a letter/digit/ideograph/kana) rather than a
// separator — the guard that keeps a glued measure word from reading as a header (ingest parity).
func isHeaderContentRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) ||
unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r)
}
// isHeadingSeparator reports whether a rune separates the marker from the subtitle (trimmed off the head
// of the subtitle): CJK/ASCII colons, commas, periods, dashes, the ideographic space and middle dots.
func isHeadingSeparator(r rune) bool {
switch r {
case '', ':', '、', '', ',', '.', '。', '-', '—', '', ' ', '\t', ' ', '·', '・':
return true
}
return false
}
// parseSectionNumeral parses a chapter-number run into an int (Arabic half/fullwidth OR standard CJK
// numerals up to the thousands — well past any chapter count). A digit accumulates positionally; a small
// unit (十百千) flushes the pending coefficient (十 alone = 10). Returns ok=false on an unparseable rune
// or a non-positive result (conservative — an unparseable header simply is not stripped/rendered).
func parseSectionNumeral(s string) (int, bool) {
section, num := 0, 0
any := false
for _, r := range s {
switch {
case r >= '0' && r <= '9':
num = num*10 + int(r-'0')
any = true
case r >= '' && r <= '':
num = num*10 + int(r-'')
any = true
case r == '' || r == '零':
num = num * 10
any = true
default:
if d, isDigit := cjkSectionDigit(r); isDigit {
num = num*10 + d
any = true
continue
}
if u, isUnit := cjkSectionUnit(r); isUnit {
if num == 0 {
num = 1
}
section += num * u
num = 0
any = true
continue
}
return 0, false
}
}
v := section + num
if !any || v <= 0 {
return 0, false
}
return v, true
}
func cjkSectionDigit(r rune) (int, bool) {
switch r {
case '一':
return 1, true
case '二', '两', '兩':
return 2, true
case '三':
return 3, true
case '四':
return 4, true
case '五':
return 5, true
case '六':
return 6, true
case '七':
return 7, true
case '八':
return 8, true
case '九':
return 9, true
}
return 0, false
}
func cjkSectionUnit(r rune) (int, bool) {
switch r {
case '十':
return 10, true
case '百':
return 100, true
case '千':
return 1000, true
}
return 0, false
}
// chapterDraftChunks packs one chapter's paragraphs into DRAFT chunks (the fine tiling). Rule: // chapterDraftChunks packs one chapter's paragraphs into DRAFT chunks (the fine tiling). Rule:
// pack whole paragraphs while the running OUTPUT-token estimate stays within DraftBudgetOut // pack whole paragraphs while the running OUTPUT-token estimate stays within DraftBudgetOut
// (prefer paragraph boundaries); a paragraph that alone exceeds the budget is flushed on its own // (prefer paragraph boundaries); a paragraph that alone exceeds the budget is flushed on its own

View file

@ -0,0 +1,110 @@
package pipeline
import (
"testing"
"textmachine/backend/internal/lang"
)
// zhRuHeadingRule mirrors configs/langpacks/zh-ru/heading.txt (第 · 章节節回 · «Глава {n}»).
func zhRuHeadingRule() *lang.HeadingRule {
return &lang.HeadingRule{
Marker: "第",
Units: map[rune]bool{'章': true, '节': true, '節': true, '回': true},
Template: "Глава {n}",
}
}
// TestStripHeadingRerun2 pins the pack-13 title policy on the LIVE rerun2 header shapes (第N节subtitle):
// the marker is stripped from the model input, the subtitle is kept, and the deterministic «Глава N» is
// rendered — the fix for the cross-arm chaos («Раздел 2» / «Первая глава» / orphaned « :»).
func TestStripHeadingRerun2(t *testing.T) {
hr := zhRuHeadingRule()
cases := []struct {
chapter string
wantHeading string
wantStripped string
}{
{"第一节:纵身亡魔心仍不悔\n\n正文第一段。", "Глава 1", "纵身亡魔心仍不悔\n\n正文第一段。"},
{"第二节:逆光阴五百年觉悟\n\n身体。", "Глава 2", "逆光阴五百年觉悟\n\n身体。"},
{"第四节:古月方源!\n\n夜。", "Глава 4", "古月方源!\n\n夜。"},
{"第五节:人祖三蛊,希望开窍\n\n春。", "Глава 5", "人祖三蛊,希望开窍\n\n春。"},
{"第一章 図書館の秘密\n\n本文。", "Глава 1", "図書館の秘密\n\n本文。"}, // fullwidth-space separator (ja-shape)
{"第十二章:标题\n\n正文。", "Глава 12", "标题\n\n正文。"}, // multi-digit CJK numeral
{"第1章 Title\n\nbody.", "Глава 1", "Title\n\nbody."}, // Arabic numeral + space
{"第一节\n\n正文。", "Глава 1", "正文。"}, // header with NO subtitle → whole line drops
}
for _, c := range cases {
gotH, gotS := stripHeading(c.chapter, hr)
if gotH != c.wantHeading || gotS != c.wantStripped {
t.Errorf("stripHeading(%q):\n heading = %q, want %q\n stripped = %q, want %q",
c.chapter, gotH, c.wantHeading, gotS, c.wantStripped)
}
}
}
// TestStripHeadingNegatives asserts the precision guards: a nil rule is inert; a glued measure word is not
// a header; prose that merely opens with 第 is untouched.
func TestStripHeadingNegatives(t *testing.T) {
hr := zhRuHeadingRule()
negatives := []string{
"第一回见面时,他笑了。\n\n正文。", // 回 measure word glued to content 见 → NOT a header (ingest parity)
"这是第三节的内容。\n\n正文。", // 第 not at line start
"普通的一段话。\n\n第二段。", // no marker at all
"第节:无数字\n\n正文。", // no numeral between marker and unit
}
for _, chap := range negatives {
if gotH, gotS := stripHeading(chap, hr); gotH != "" || gotS != chap {
t.Errorf("stripHeading(%q) should be a no-op, got heading=%q stripped=%q", chap, gotH, gotS)
}
}
// A nil rule is always a no-op (a book with no heading.txt).
if gotH, gotS := stripHeading("第一节:标题\n\n正文。", nil); gotH != "" || gotS != "第一节:标题\n\n正文。" {
t.Errorf("nil rule must be inert, got heading=%q", gotH)
}
}
// TestSplitChunksHeadingCarried asserts the deterministic title lands on the chapter's FIRST chunk only,
// the source marker is stripped from ch.Text, and applyHeading prepends it correctly.
func TestSplitChunksHeadingCarried(t *testing.T) {
hr := zhRuHeadingRule()
chapters := []string{
"第一节:纵身亡魔心仍不悔\n\n古月方源站着。",
"第二节:逆光阴\n\n夜色降临。",
}
chunks := SplitChunks(chapters, testSeg(), hr)
if len(chunks) < 2 {
t.Fatalf("want ≥2 chunks, got %d", len(chunks))
}
// Chapter 1, chunk 0 carries «Глава 1»; its text no longer contains the marker.
if chunks[0].Heading != "Глава 1" {
t.Errorf("chunk0 heading = %q, want «Глава 1»", chunks[0].Heading)
}
if containsRune(chunks[0].Text, '第') {
t.Errorf("chunk0 text still carries the source marker: %q", chunks[0].Text)
}
// applyHeading prepends only to a non-empty final and is a no-op on an empty/no-heading unit.
if got := applyHeading(chunks[0].Heading, "перевод."); got != "Глава 1\n\nперевод." {
t.Errorf("applyHeading = %q", got)
}
if got := applyHeading(chunks[0].Heading, ""); got != "" {
t.Errorf("applyHeading on empty final must stay empty, got %q", got)
}
if got := applyHeading("", "перевод."); got != "перевод." {
t.Errorf("applyHeading with no heading must be a no-op, got %q", got)
}
// A book with NO heading rule keeps the source header verbatim (byte-identical to pre-pack).
plain := SplitChunks(chapters, testSeg(), nil)
if !containsRune(plain[0].Text, '第') || plain[0].Heading != "" {
t.Errorf("nil-rule chunk0 must keep the marker and carry no heading: text=%q heading=%q", plain[0].Text, plain[0].Heading)
}
}
func containsRune(s string, r rune) bool {
for _, c := range s {
if c == r {
return true
}
}
return false
}

View file

@ -24,7 +24,7 @@ func stripMeta(cs []Chunk) []Chunk {
} }
func TestSplitChunksSingleParagraph(t *testing.T) { func TestSplitChunksSingleParagraph(t *testing.T) {
got := SplitChunks([]string{"静かな図書館の朝。"}, testSeg()) got := SplitChunks([]string{"静かな図書館の朝。"}, testSeg(), nil)
want := []Chunk{{Chapter: 1, ChunkIdx: 0, Text: "静かな図書館の朝。"}} want := []Chunk{{Chapter: 1, ChunkIdx: 0, Text: "静かな図書館の朝。"}}
if !reflect.DeepEqual(stripMeta(got), want) { if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("single paragraph = %+v, want %+v", got, want) t.Fatalf("single paragraph = %+v, want %+v", got, want)
@ -36,7 +36,7 @@ func TestSplitChunksSingleParagraph(t *testing.T) {
} }
func TestSplitChunksChapters(t *testing.T) { func TestSplitChunksChapters(t *testing.T) {
got := SplitChunks([]string{"Глава один.", "Глава два.", "Глава три."}, testSeg()) got := SplitChunks([]string{"Глава один.", "Глава два.", "Глава три."}, testSeg(), nil)
want := []Chunk{ want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: "Глава один."}, {Chapter: 1, ChunkIdx: 0, Text: "Глава один."},
{Chapter: 2, ChunkIdx: 0, Text: "Глава два."}, {Chapter: 2, ChunkIdx: 0, Text: "Глава два."},
@ -58,7 +58,7 @@ func TestSplitChunksPacksToTarget(t *testing.T) {
// cyrillic: 3000 chars × 0.3852) must split into two draft chunks, each kept whole. // cyrillic: 3000 chars × 0.3852) must split into two draft chunks, each kept whole.
p1 := strings.Repeat("а", 3000) p1 := strings.Repeat("а", 3000)
p2 := strings.Repeat("б", 3000) p2 := strings.Repeat("б", 3000)
got := SplitChunks([]string{p1 + "\n\n" + p2}, testSeg()) got := SplitChunks([]string{p1 + "\n\n" + p2}, testSeg(), nil)
want := []Chunk{ want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: p1}, {Chapter: 1, ChunkIdx: 0, Text: p1},
{Chapter: 1, ChunkIdx: 1, Text: p2}, {Chapter: 1, ChunkIdx: 1, Text: p2},
@ -73,7 +73,7 @@ func TestSplitChunksPacksToTarget(t *testing.T) {
} }
func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) { func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) {
got := SplitChunks([]string{"Абзац один.\n\nАбзац два.\n\nАбзац три."}, testSeg()) got := SplitChunks([]string{"Абзац один.\n\nАбзац два.\n\nАбзац три."}, testSeg(), nil)
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("small paragraphs must pack into one chunk, got %d: %+v", len(got), summarize(got)) t.Fatalf("small paragraphs must pack into one chunk, got %d: %+v", len(got), summarize(got))
} }
@ -93,7 +93,7 @@ func TestSplitChunksOversizeParagraphSplitsAtSentences(t *testing.T) {
sb.WriteByte(' ') sb.WriteByte(' ')
} }
para := strings.TrimSpace(sb.String()) para := strings.TrimSpace(sb.String())
got := SplitChunks([]string{para}, testSeg()) got := SplitChunks([]string{para}, testSeg(), nil)
if len(got) < 2 { if len(got) < 2 {
t.Fatalf("oversize paragraph must split into >1 chunk, got %d", len(got)) t.Fatalf("oversize paragraph must split into >1 chunk, got %d", len(got))
} }
@ -115,7 +115,7 @@ func TestSplitChunksShortLinesPackByTrueTokenBudget(t *testing.T) {
for i := 0; i < 150; i++ { for i := 0; i < 150; i++ {
paras = append(paras, "「はい。」") paras = append(paras, "「はい。」")
} }
got := SplitChunks([]string{strings.Join(paras, "\n\n")}, testSeg()) got := SplitChunks([]string{strings.Join(paras, "\n\n")}, testSeg(), nil)
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("short lines must pack by true token budget into 1 chunk, got %d chunks", len(got)) t.Fatalf("short lines must pack by true token budget into 1 chunk, got %d chunks", len(got))
} }
@ -128,7 +128,7 @@ func TestSplitChunksOversizeSentenceIsOwnChunk(t *testing.T) {
// A single sentence (no internal terminator) larger than the budget can only be its own // A single sentence (no internal terminator) larger than the budget can only be its own
// chunk — never split — and is flagged OversizedSentence (passthrough, WS2 §2б). // chunk — never split — and is flagged OversizedSentence (passthrough, WS2 §2б).
big := strings.Repeat("ы", 6000) // ~2311 out-tokens, one un-terminated run big := strings.Repeat("ы", 6000) // ~2311 out-tokens, one un-terminated run
got := SplitChunks([]string{big}, testSeg()) got := SplitChunks([]string{big}, testSeg(), nil)
if len(got) != 1 || got[0].Text != big { if len(got) != 1 || got[0].Text != big {
t.Fatalf("an oversize sentence must be one un-split chunk, got %+v", summarize(got)) t.Fatalf("an oversize sentence must be one un-split chunk, got %+v", summarize(got))
} }
@ -139,7 +139,7 @@ func TestSplitChunksOversizeSentenceIsOwnChunk(t *testing.T) {
func TestSplitChunksDropsEmpties(t *testing.T) { func TestSplitChunksDropsEmpties(t *testing.T) {
// A blank/whitespace-only chapter vanishes and does NOT consume a chapter number. // A blank/whitespace-only chapter vanishes and does NOT consume a chapter number.
got := SplitChunks([]string{"A", " \n\n ", "B"}, testSeg()) got := SplitChunks([]string{"A", " \n\n ", "B"}, testSeg(), nil)
want := []Chunk{ want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: "A"}, {Chapter: 1, ChunkIdx: 0, Text: "A"},
{Chapter: 2, ChunkIdx: 0, Text: "B"}, {Chapter: 2, ChunkIdx: 0, Text: "B"},
@ -154,7 +154,7 @@ func TestSplitChunksDeterministic(t *testing.T) {
"Абзац.\n\nЕщё абзац.", "Абзац.\n\nЕщё абзац.",
strings.Repeat("слово ", 400) + "конец.", strings.Repeat("слово ", 400) + "конец.",
} }
a, b := SplitChunks(chapters, testSeg()), SplitChunks(chapters, testSeg()) a, b := SplitChunks(chapters, testSeg(), nil), SplitChunks(chapters, testSeg(), nil)
if !reflect.DeepEqual(a, b) { if !reflect.DeepEqual(a, b) {
t.Fatal("SplitChunks must be deterministic") t.Fatal("SplitChunks must be deterministic")
} }
@ -164,7 +164,7 @@ func TestSplitChunksNeverSplitsAbbreviation(t *testing.T) {
// Two long en paragraphs each ending mid-flow with "Mr. Smith" must not chunk // Two long en paragraphs each ending mid-flow with "Mr. Smith" must not chunk
// between "Mr." and "Smith" — the abbreviation guard keeps the sentence whole. // between "Mr." and "Smith" — the abbreviation guard keeps the sentence whole.
p := strings.Repeat("The road went on and on. ", 100) + "It was Mr. Smith who arrived." p := strings.Repeat("The road went on and on. ", 100) + "It was Mr. Smith who arrived."
got := SplitChunks([]string{p}, testSeg()) got := SplitChunks([]string{p}, testSeg(), nil)
for _, c := range got { for _, c := range got {
if strings.HasSuffix(strings.TrimSpace(c.Text), "Mr.") { if strings.HasSuffix(strings.TrimSpace(c.Text), "Mr.") {
t.Fatalf("chunk ended at an abbreviation 'Mr.' — a sentence was cut: %q", c.Text) t.Fatalf("chunk ended at an abbreviation 'Mr.' — a sentence was cut: %q", c.Text)
@ -182,7 +182,7 @@ func TestSplitChunksEditUnitGroupsWholeChunks(t *testing.T) {
p := strings.Repeat("а", 3000) p := strings.Repeat("а", 3000)
q := strings.Repeat("б", 3000) q := strings.Repeat("б", 3000)
r := strings.Repeat("в", 3000) r := strings.Repeat("в", 3000)
got := SplitChunks([]string{p + "\n\n" + q + "\n\n" + r}, testSeg()) got := SplitChunks([]string{p + "\n\n" + q + "\n\n" + r}, testSeg(), nil)
if len(got) != 3 { if len(got) != 3 {
t.Fatalf("want 3 draft chunks, got %d", len(got)) t.Fatalf("want 3 draft chunks, got %d", len(got))
} }
@ -319,7 +319,7 @@ func FuzzSplitChunksPreservesContent(f *testing.F) {
norm := NormalizeSource(s) norm := NormalizeSource(s)
want := countNonSpace(norm) want := countNonSpace(norm)
got := 0 got := 0
for _, c := range SplitChunks([]string{norm}, testSeg()) { for _, c := range SplitChunks([]string{norm}, testSeg(), nil) {
got += countNonSpace(c.Text) got += countNonSpace(c.Text)
} }
if got != want { if got != want {

View file

@ -173,6 +173,13 @@ func (r *Runner) Export(pairs bool) (*BookExport, error) {
} }
} }
} }
// Title policy (pack-13): prepend the chapter's deterministic «Глава N» to its FIRST unit's non-empty
// export text — the SAME projection waverun's outcome assembly applies, both derived from the
// deterministic chunker (u.Members[0] is the leader; only a chapter-opening chunk carries a Heading),
// so `tmctl export` and `tmctl translate` ship byte-identical text. The --pairs Source column is left
// as the (heading-stripped) source manifest — the DC FP-measure aligns src↔target on the body prose,
// not the deterministic title (which no checker inspects).
ce.FinalText = applyHeading(u.Members[0].Heading, ce.FinalText)
if pairs { if pairs {
ce.Source = u.sourceText() // the unit src↔target column for the DC1/DC2 FP-measure (--pairs) ce.Source = u.sourceText() // the unit src↔target column for the DC1/DC2 FP-measure (--pairs)
if droppedAny { if droppedAny {

View file

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

View file

@ -0,0 +1,72 @@
package pipeline
import (
"crypto/sha256"
"encoding/hex"
"strings"
)
// loopguard.go: a degenerate-loop observability check. When a model gets stuck it emits the SAME output
// for consecutive units; this flags a run of identical consecutive translated segments. The signature is
// a whitespace-normalized hash of each segment, so cosmetic spacing differences don't hide a loop. It is
// pure OBSERVABILITY — like the cheap style gates it is never a disposition and never touches the wire —
// and it is surfaced in the read-only quality report, so a resume re-derives the identical count. Pure and
// deterministic (no time/rand).
// segmentLoopMinRun is the run length that triggers a flag. Three consecutive units with byte-identical
// (whitespace-normalized) output is already a strong signal for prose, where every unit's source — and so
// its translation — differs. Precision over recall: a rare false flag on legitimately repeated boilerplate
// is better than dropping to a noisy threshold.
const segmentLoopMinRun = 3
// segmentLoopRun is one maximal run of identical consecutive non-empty segments.
type segmentLoopRun struct {
Signature string // the normalized SHA-256 prefix of the repeated segment
Start int // 0-based index of the first segment of the run (in reading/manifest order)
Count int // number of consecutive identical segments (≥ minRun)
}
// segmentLoopRuns scans segments in reading order for maximal runs of ≥ minRun consecutive segments with
// the same normalized signature, returning one segmentLoopRun per such run. EMPTY / whitespace-only
// segments never join a run (a pending/flagged unit exports "", which must not read as a loop) and break
// any run in progress. Deterministic: the signature is a pure function of the segment bytes.
func segmentLoopRuns(segments []string, minRun int) []segmentLoopRun {
if minRun < 2 {
minRun = 2
}
var runs []segmentLoopRun
runStart, runSig, runLen := -1, "", 0
flush := func() {
if runLen >= minRun {
runs = append(runs, segmentLoopRun{Signature: runSig, Start: runStart, Count: runLen})
}
runStart, runSig, runLen = -1, "", 0
}
for i, seg := range segments {
sig, empty := loopSignature(seg)
if empty {
flush() // an empty export cannot be part of a loop and breaks the run
continue
}
if runLen > 0 && sig == runSig {
runLen++
continue
}
flush()
runStart, runSig, runLen = i, sig, 1
}
flush()
return runs
}
// loopSignature returns a segment's whitespace-normalized SHA-256 prefix and whether it is empty. All
// runs of whitespace collapse to a single space and the ends are trimmed, so cosmetic whitespace between
// two otherwise-identical outputs does not hide a loop; an empty / whitespace-only segment reports empty.
func loopSignature(seg string) (sig string, empty bool) {
norm := strings.Join(strings.Fields(seg), " ")
if norm == "" {
return "", true
}
sum := sha256.Sum256([]byte(norm))
return hex.EncodeToString(sum[:])[:16], false
}

View file

@ -0,0 +1,32 @@
package pipeline
import "testing"
// TestSegmentLoopRuns pins the pack-13 point-10 degenerate-loop guard: a run of ≥3 identical consecutive
// (whitespace-normalized) segments is a loop; distinct prose is not; empty exports break a run and never
// count (a pending/flagged unit exports "").
func TestSegmentLoopRuns(t *testing.T) {
cases := []struct {
name string
segs []string
wantRuns int
}{
{"healthy prose", []string{"Абзац один.", "Абзац два.", "Абзац три.", "Абзац четыре."}, 0},
{"loop of 3", []string{"уникальный", "ПОВТОР", "ПОВТОР", "ПОВТОР", "конец"}, 1},
{"loop of 2 below threshold", []string{"a", "ПОВТОР", "ПОВТОР", "b"}, 0},
{"whitespace-insensitive loop", []string{"один два", "один два", "один\tдва", "один два"}, 1},
{"empty breaks the run", []string{"ПОВТОР", "ПОВТОР", "", "ПОВТОР", "ПОВТОР"}, 0},
{"empties never loop", []string{"", "", "", "", ""}, 0},
{"two separate loops", []string{"A", "A", "A", "x", "B", "B", "B"}, 2},
}
for _, c := range cases {
if got := len(segmentLoopRuns(c.segs, segmentLoopMinRun)); got != c.wantRuns {
t.Errorf("%s: segmentLoopRuns = %d runs, want %d", c.name, got, c.wantRuns)
}
}
// The run carries the correct start + count.
runs := segmentLoopRuns([]string{"a", "R", "R", "R", "R"}, 3)
if len(runs) != 1 || runs[0].Start != 1 || runs[0].Count != 4 {
t.Errorf("run metadata wrong: %+v", runs)
}
}

View file

@ -517,6 +517,13 @@ func renderGlossaryBlock(injected []pickedEntry) string {
line := p.entry.src + " → " + p.entry.dst line := p.entry.src + " → " + p.entry.dst
if p.disp != memConfirmed { if p.disp != memConfirmed {
line += " ⟨проверить⟩" line += " ⟨проверить⟩"
} else {
// pack-13 injection-completeness fix (D39.21 owner directive: «род должен доезжать»): the
// gender of a CONFIRMED named term (蛊 Надежды = ж.р., a cicada gu, …) now reaches the DRAFT
// wire too, not only the editor — so the TRANSLATOR renders the right родовые формы FIRST,
// instead of leaving the editor to repair a wrong gender. "" for a genderless term (the common
// case → byte-identical). Mirrors the editor block's confirmed-only gender (renderEditorConstraintBlock).
line += genderConstraintNote(p.entry.gender)
} }
lines = append(lines, line) lines = append(lines, line)
} }
@ -534,7 +541,11 @@ func renderGlossaryBlock(injected []pickedEntry) string {
// discipline — memoryMatchVersion (const memory.go) would not move on a render edit. // discipline — memoryMatchVersion (const memory.go) would not move on a render edit.
// v2 (WS5 R4): the editor constraint line now carries a gender annotation for a gendered CONFIRMED // v2 (WS5 R4): the editor constraint line now carries a gender annotation for a gendered CONFIRMED
// term (DC3 injection — the Bai Ninbing fix: the injection DIRECTS the editor, no coreference needed). // term (DC3 injection — the Bai Ninbing fix: the injection DIRECTS the editor, no coreference needed).
const renderFormatVersion = "renderfmt-v2-editor-src2dst+dc3-gender" // v3 (pack-13, D39.21 injection-completeness fix): the DRAFT glossary block (renderGlossaryBlock) now
// ALSO appends the confirmed-gender annotation, so a named term's gender reaches the TRANSLATOR wire, not
// only the editor (the owner's «род должен доезжать» directive). A gendered confirmed term shifts the
// draft injected bytes → the wire, so a loud --resnapshot; a genderless bank is byte-identical to v2.
const renderFormatVersion = "renderfmt-v3-draft-gender+editor-src2dst+dc3-gender"
// editorConstraintHeader introduces the editor's canonical-constraint block. It gives the BILINGUAL // editorConstraintHeader introduces the editor's canonical-constraint block. It gives the BILINGUAL
// editor (D30.1) the approved src→dst bindings as consistency constraints — it must render each // editor (D30.1) the approved src→dst bindings as consistency constraints — it must render each

View file

@ -0,0 +1,44 @@
package pipeline
import (
"strings"
"testing"
"textmachine/backend/internal/store"
)
// TestMinedDeltaSeedCollisions covers both paths of the D39.20 deviation-#1 crash guard: a mined-delta
// term whose UNIQUE key (src, sense, since_ch, until_ch) already exists in the seed must be reported
// (would crash ReplaceGlossary), while a disjoint delta reports nothing. It also asserts a SAME-dst
// duplicate is caught (approvedSharedKeyCollisions skips it, so this guard is the one that must fire).
func TestMinedDeltaSeedCollisions(t *testing.T) {
seed := []store.GlossaryEntry{
{Src: "元", Dst: "первооснова", Sense: "корень", Status: "approved"},
{Src: "赤城", Dst: "Чичэн", Status: "approved"},
}
// Path A — a mined-delta term duplicating the seed's UNIQUE key (different dst) → loud.
minedDiff := []store.GlossaryEntry{{Src: "元", Dst: "юань", Sense: "корень", Status: "approved", Source: "mined"}}
if dups := minedDeltaSeedCollisions(seed, minedDiff); len(dups) != 1 {
t.Fatalf("different-dst duplicate: want 1 collision, got %d (%v)", len(dups), dups)
} else if !strings.Contains(dups[0], "元") {
t.Fatalf("collision message should name the duplicated src: %q", dups[0])
}
// Path A' — a SAME-dst duplicate (approvedSharedKeyCollisions skips these; this guard must still fire,
// since the UNIQUE INSERT crashes regardless of dst).
minedSame := []store.GlossaryEntry{{Src: "元", Dst: "первооснова", Sense: "корень", Status: "approved", Source: "mined"}}
if dups := minedDeltaSeedCollisions(seed, minedSame); len(dups) != 1 {
t.Fatalf("same-dst duplicate: want 1 collision (the INSERT crashes regardless of dst), got %d", len(dups))
}
// Path B — a disjoint delta (a genuinely new term, or the same src in a DIFFERENT spoiler window) →
// nothing to report; the append is safe.
minedOK := []store.GlossaryEntry{
{Src: "春秋蝉", Dst: "Весенне-осенняя цикада", Status: "approved", Source: "mined"},
{Src: "元", Dst: "юань (валюта)", Sense: "деньги", Status: "approved", Source: "mined"}, // different sense → different UNIQUE key
}
if dups := minedDeltaSeedCollisions(seed, minedOK); len(dups) != 0 {
t.Fatalf("disjoint delta: want 0 collisions, got %d (%v)", len(dups), dups)
}
}

View file

@ -59,6 +59,15 @@ type QualityReport struct {
// is NOT a CJK leak — F6, D39.4: the old cjk_leak_rate counted every sanitizer_stripped unit). // is NOT a CJK leak — F6, D39.4: the old cjk_leak_rate counted every sanitizer_stripped unit).
CosmeticStripRate float64 `json:"cosmetic_strip_rate"` CosmeticStripRate float64 `json:"cosmetic_strip_rate"`
// DegenerateLoopRuns counts runs of ≥ segmentLoopMinRun consecutive units whose exported MODEL text is
// byte-identical after whitespace normalization — a degenerate translation loop (pack-13 point-10,
// research/21; complements echoMineViolation on the degenerate path research/15). Observability only,
// never a disposition or a wire touch; 0 on a healthy book (every unit's source, and so its translation,
// differs). The signature is over the MODEL output (exportNormalize, BEFORE the deterministic title is
// prepended), so a per-chapter «Глава N» never masks a body loop. omitempty keeps a loop-free run's
// report byte-identical to before this field existed.
DegenerateLoopRuns int `json:"degenerate_loop_runs,omitempty"`
Chunks []ChunkQuality `json:"chunks,omitempty"` Chunks []ChunkQuality `json:"chunks,omitempty"`
} }
@ -220,6 +229,7 @@ func (r *Runner) QualityReport() (*QualityReport, error) {
if n := len(r.Pipeline.Stages); n > 0 { if n := len(r.Pipeline.Stages); n > 0 {
lastStage = r.Pipeline.Stages[n-1].Name lastStage = r.Pipeline.Stages[n-1].Name
} }
loopText := map[chunkKey]string{} // per-unit exported MODEL text (no title), for the pack-13 point-10 loop scan
for _, cs := range statuses { for _, cs := range statuses {
k := chunkKey{cs.Chapter, cs.ChunkIdx} k := chunkKey{cs.Chapter, cs.ChunkIdx}
if cs.Stage != lastStage || !inManifest[k] { if cs.Stage != lastStage || !inManifest[k] {
@ -248,7 +258,9 @@ func (r *Runner) QualityReport() (*QualityReport, error) {
if cp == nil || strings.TrimSpace(cp.ResponseText) == "" { if cp == nil || strings.TrimSpace(cp.ResponseText) == "" {
continue continue
} }
sent, para := narrativeStructure(exportNormalize(cp.ResponseText)) normText := exportNormalize(cp.ResponseText)
loopText[k] = normText
sent, para := narrativeStructure(normText)
q := chunkOf(k) q := chunkOf(k)
q.NarrativeSentences, q.NarrativeParagraphs = sent, para q.NarrativeSentences, q.NarrativeParagraphs = sent, para
rep.NarrativeSentences += sent rep.NarrativeSentences += sent
@ -256,6 +268,14 @@ func (r *Runner) QualityReport() (*QualityReport, error) {
rep.TextUnits++ rep.TextUnits++
} }
// Degenerate-loop guard (pack-13 point-10): scan the exported MODEL texts in reading (manifest/unit)
// order for runs of identical consecutive units — observability, never a gate. Deterministic.
ordered := make([]string, 0, len(units))
for _, u := range units {
ordered = append(ordered, loopText[chunkKey{u.Chapter, u.FirstChunkIdx}])
}
rep.DegenerateLoopRuns = len(segmentLoopRuns(ordered, segmentLoopMinRun))
if rep.NarrativeParagraphs > 0 { if rep.NarrativeParagraphs > 0 {
rep.MeanSentPerNarrPara = float64(rep.NarrativeSentences) / float64(rep.NarrativeParagraphs) rep.MeanSentPerNarrPara = float64(rep.NarrativeSentences) / float64(rep.NarrativeParagraphs)
} }

View file

@ -242,6 +242,27 @@ func (r *Runner) loadTemplates() error {
return nil return nil
} }
// headingRule is the book's deterministic chapter-heading rule (pack-13 title policy), or nil when the
// book declares no langpack, its pair has no catalog, or the pair ships no heading.txt. The chunker uses
// it to detect+strip a source header and render «Глава N»; nil ⇒ the feature is inert (byte-identical
// chunks). Its bytes ride pack.Version() (folded into the snapshot via LangpackVersion), so a rule edit is
// a loud --resnapshot and a book without a rule is never re-billed for a feature it does not use.
func (r *Runner) headingRule() *lang.HeadingRule {
if r.pack != nil {
return r.pack.Heading
}
return nil
}
// estOutTokens is the DISPLAY-ONLY fertility estimate of a text's ru output tokens (est_out = fertility
// over the source char-classes, research/20 — NOT the char/4 EstimateTokens whose CJK undercount is the
// «CJK-mine»). Used ONLY to annotate a settle-estimate telemetry row (pack-13 point-9): it feeds NOTHING
// on the money path (not the reservation, not CostUSD), so it can never shift a verdict or a wire byte.
func (r *Runner) estOutTokens(text string) int {
cjk, other := tokenClassCounts(text)
return int(r.segBudget().estOut(cjk, other))
}
// segBudget resolves the pipeline's config.Segmentation into the pipeline-package SegBudget the // segBudget resolves the pipeline's config.Segmentation into the pipeline-package SegBudget the
// chunker consumes (WS2). Kept in the runner (not chunker.go) so the segmenter stays config-free. // chunker consumes (WS2). Kept in the runner (not chunker.go) so the segmenter stays config-free.
func (r *Runner) segBudget() SegBudget { func (r *Runner) segBudget() SegBudget {

View file

@ -52,6 +52,17 @@ func (r *Runner) seedGlossary(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
// D39.20 deviation-#1 fix: a mined-delta term whose UNIQUE key (src, sense, since_ch, until_ch —
// store/migrate.go glossary UNIQUE) already exists in the SIGNED seed makes the flat INSERT in
// ReplaceGlossary crash on that constraint and abort the whole paid run. approvedSharedKeyCollisions
// below does NOT catch it (it skips a SAME-dst duplicate, and keys on the firing surface, not the
// UNIQUE tuple). Fail LOUD here with the duplicate list + a fix hint (edit the seed OR drop it from
// the delta) — NEVER a silent merge over the signed seed. Checked BEFORE the append so the delta rows
// are still separable. Deterministic (seed order).
if dups := minedDeltaSeedCollisions(entries, minedDelta); len(dups) > 0 {
return fmt.Errorf("pipeline: mined-delta %s duplicates term(s) already in the signed seed (would crash ReplaceGlossary on the glossary UNIQUE(book_id,src,sense,since_ch,until_ch)):\n - %s\n fix: correct the term in the seed, or remove it from the mined-delta file — never both (no silent merge over the signed seed)",
r.Book.MinedDelta, strings.Join(dups, "\n - "))
}
entries = append(entries, minedDelta...) entries = append(entries, minedDelta...)
for i := range entries { for i := range entries {
entries[i].BookID = r.Book.BookID entries[i].BookID = r.Book.BookID
@ -98,6 +109,31 @@ func (r *Runner) seedGlossary(ctx context.Context) error {
return nil return nil
} }
// minedDeltaSeedCollisions returns a human message for every mined-delta entry whose store UNIQUE key
// (src, sense, since_ch, until_ch) already exists among the seed/ruby entries — the D39.20 deviation-#1
// crash class. It keys on the FULL uniqueness tuple (not the firing surface, unlike
// approvedSharedKeyCollisions, and regardless of dst — a SAME-dst duplicate crashes the INSERT just the
// same, yet approvedSharedKeyCollisions deliberately skips it). Pure and deterministic (seed order); an
// empty result means the delta is disjoint from the seed on the uniqueness axis and can be appended safely.
func minedDeltaSeedCollisions(seedEntries, mined []store.GlossaryEntry) []string {
type key struct {
src, sense string
since, until int
}
seen := map[key]store.GlossaryEntry{}
for _, e := range seedEntries {
seen[key{e.Src, e.Sense, e.SinceCh, e.UntilCh}] = e
}
var out []string
for _, m := range mined {
if prior, ok := seen[key{m.Src, m.Sense, m.SinceCh, m.UntilCh}]; ok {
out = append(out, fmt.Sprintf("src %q (sense %q, window [%d,%d]) is in both the seed (%q→%q) and the mined-delta (%q→%q)",
m.Src, m.Sense, m.SinceCh, m.UntilCh, prior.Src, prior.Dst, m.Src, m.Dst))
}
}
return out
}
// persistRuby aggregates the ingested ruby occurrences into one row per // persistRuby aggregates the ingested ruby occurrences into one row per
// (base, reading) — first_chapter = MIN, occurrences = full-book count — and // (base, reading) — first_chapter = MIN, occurrences = full-book count — and
// REPLACES the book's whole ruby set (store.ReplaceRubyReadings). Idempotent: the // REPLACES the book's whole ruby set (store.ReplaceRubyReadings). Idempotent: the

View file

@ -433,6 +433,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
rl := r.baseRequestLog(st, ch, model, reqHash) rl := r.baseRequestLog(st, ch, model, reqHash)
rl.CostUSD, rl.LatencyMS, rl.FinishReason = estimate, att.latency, decodeErrorFinish rl.CostUSD, rl.LatencyMS, rl.FinishReason = estimate, att.latency, decodeErrorFinish
rl.Degraded, rl.Err, rl.OK = "billed_2xx_decode_failed", err.Error(), false rl.Degraded, rl.Err, rl.OK = "billed_2xx_decode_failed", err.Error(), false
rl.Estimated, rl.EstTokens = true, r.estOutTokens(ch.Text) // cost_usd is the reservation estimate (pack-13 point-9)
r.Store.LogRequest(ctx, r.Log, rl) r.Store.LogRequest(ctx, r.Log, rl)
r.setJobStatus(ctx, job.ID, "done") r.setJobStatus(ctx, job.ID, "done")
return att, nil return att, nil
@ -466,10 +467,12 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
// A paid model (InputPerM>0) returned 2xx with zero usage — $0 would blind the // A paid model (InputPerM>0) returned 2xx with zero usage — $0 would blind the
// ceiling: take a conservative estimate. For local ($0 price) zero usage is // ceiling: take a conservative estimate. For local ($0 price) zero usage is
// normal — it stays $0. // normal — it stays $0.
estimatedCost := false
if cost == 0 && price.InputPerM > 0 && resp.Usage.PromptTokens == 0 && resp.Usage.CompletionTokens == 0 { if cost == 0 && price.InputPerM > 0 && resp.Usage.PromptTokens == 0 && resp.Usage.CompletionTokens == 0 {
r.Log.WarnContext(ctx, "paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest", r.Log.WarnContext(ctx, "paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest",
"stage", st.Name, "model", modelActual, "estimate_usd", fmt.Sprintf("%.6f", estimate)) "stage", st.Name, "model", modelActual, "estimate_usd", fmt.Sprintf("%.6f", estimate))
cost = estimate cost = estimate
estimatedCost = true // cost_usd is the reservation estimate, not provider-reported (pack-13 point-9)
} }
usageJSON, err := json.Marshal(resp.Usage) usageJSON, err := json.Marshal(resp.Usage)
if err != nil { if err != nil {
@ -510,6 +513,9 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
rl.CompletionTokens, rl.ReasoningTokens = resp.Usage.CompletionTokens, resp.Usage.ReasoningTokens rl.CompletionTokens, rl.ReasoningTokens = resp.Usage.CompletionTokens, resp.Usage.ReasoningTokens
rl.CostUSD, rl.LatencyMS, rl.FinishReason = cost, att.latency, resp.FinishReason rl.CostUSD, rl.LatencyMS, rl.FinishReason = cost, att.latency, resp.FinishReason
rl.OK, rl.Degraded = att.cls.ok(), degradedTag(att.cls) rl.OK, rl.Degraded = att.cls.ok(), degradedTag(att.cls)
if estimatedCost {
rl.Estimated, rl.EstTokens = true, r.estOutTokens(ch.Text) // paid 2xx zero-usage → cost is the estimate (pack-13 point-9)
}
r.Store.LogRequest(ctx, r.Log, rl) r.Store.LogRequest(ctx, r.Log, rl)
r.setJobStatus(ctx, job.ID, "done") r.setJobStatus(ctx, job.ID, "done")
r.Log.InfoContext(ctx, "attempt completed", "stage", st.Name, "attempt", attempt, "model", modelActual, r.Log.InfoContext(ctx, "attempt completed", "stage", st.Name, "attempt", attempt, "model", modelActual,

View file

@ -141,7 +141,7 @@ func (r *Runner) bookChunks() ([]Chunk, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return SplitChunks(doc.Chapters, r.segBudget()), nil return SplitChunks(doc.Chapters, r.segBudget(), r.headingRule()), nil
} }
// chunkKey identifies a chunk positionally. // chunkKey identifies a chunk positionally.

File diff suppressed because one or more lines are too long

View file

@ -494,6 +494,11 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit
default: default:
out.FinalText = exportNormalize(finalText) out.FinalText = exportNormalize(finalText)
} }
// Title policy (pack-13): prepend the chapter's deterministic «Глава N» to its FIRST unit's non-empty
// export text. unit.Members[0] is the unit's leader chunk; only a chapter's opening chunk (ChunkIdx 0)
// carries a Heading, so non-leader units are a no-op. Applied to the export projection identically in
// export.go — both derive the heading from the same deterministic chunker, so translate and export agree.
out.FinalText = applyHeading(unit.Members[0].Heading, out.FinalText)
return out, nil return out, nil
} }
@ -565,5 +570,6 @@ func draftOnlyOutcome(ch Chunk, d stageSeqResult) ChunkOutcome {
} else { } else {
oc.FinalText = exportNormalize(d.finalText) oc.FinalText = exportNormalize(d.finalText)
} }
oc.FinalText = applyHeading(ch.Heading, oc.FinalText) // pack-13 title policy (draft-only shipping path)
return oc return oc
} }

View file

@ -292,6 +292,17 @@ var migrations = []string{
ALTER TABLE retrieval_state ADD COLUMN banknote_parse_fail INTEGER NOT NULL DEFAULT 0; ALTER TABLE retrieval_state ADD COLUMN banknote_parse_fail INTEGER NOT NULL DEFAULT 0;
ALTER TABLE retrieval_state ADD COLUMN banknote_truncated INTEGER NOT NULL DEFAULT 0; ALTER TABLE retrieval_state ADD COLUMN banknote_truncated INTEGER NOT NULL DEFAULT 0;
`, `,
// v10 (pack-13 point-9, research/21 §1.10): the CostSource marker on the telemetry row. When a paid
// 2xx settles at the RESERVATION ESTIMATE (a billed decode failure, or a paid-but-zero-usage 2xx) the
// row's cost_usd is an estimate, not provider-reported. Without a flag those rows are indistinguishable
// from real zero-token calls and skew token-based COGS analytics. estimated=1 makes the estimated share
// queryable; est_tokens is a DISPLAY-ONLY fertility (est_out) estimate of the output tokens (never the
// char/4 CJK-mine). Additive columns, default 0 → every prior row and the WIRE are unchanged (the pack
// invariant); an estimated row keeps its real settled cost_usd — the flag/estimate are display-only.
`
ALTER TABLE request_log ADD COLUMN estimated INTEGER NOT NULL DEFAULT 0;
ALTER TABLE request_log ADD COLUMN est_tokens INTEGER NOT NULL DEFAULT 0;
`,
} }
// DESIGN NOTE (D21.10 — reserved memory-bank v2 record types; Phase 2, NOT a migration, NOT code). // DESIGN NOTE (D21.10 — reserved memory-bank v2 record types; Phase 2, NOT a migration, NOT code).

View file

@ -34,6 +34,17 @@ type RequestLog struct {
Degraded string Degraded string
Err string Err string
OK bool OK bool
// Estimated marks a row whose CostUSD is a RESERVATION ESTIMATE, not a provider-reported cost (a billed
// decode failure, or a paid 2xx with zero usage — pack-13 point-9 / research/21 §1.10). DISPLAY-ONLY: it
// never re-derives money (CostUSD is untouched); it makes the estimated share of spend queryable so an
// estimate row is not mistaken for a real zero-token call in token-based COGS analytics.
Estimated bool
// EstTokens is the DISPLAY-ONLY fertility estimate of the row's output tokens (est_out = 1.20·cjk +
// 0.39·other over the SOURCE, research/20 — NOT the char/4 EstimateTokens, whose CJK undercount is the
// «CJK-mine» pack-13 point-9 warns of). Set only on an Estimated row (0 otherwise); it feeds NOTHING on
// the money path (not the reservation, not CostUSD) — purely a report number so an estimate's magnitude
// is legible.
EstTokens int
} }
// InsertRequestLog writes one telemetry row synchronously (a CLI pipeline has // InsertRequestLog writes one telemetry row synchronously (a CLI pipeline has
@ -49,13 +60,13 @@ func (s *Store) InsertRequestLog(rl RequestLog) error {
model_requested, model_actual, request_hash, model_requested, model_actual, request_hash,
prompt_tokens, cached_tokens, cache_creation_tokens, prompt_tokens, cached_tokens, cache_creation_tokens,
completion_tokens, reasoning_tokens, completion_tokens, reasoning_tokens,
cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok, estimated, est_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
rl.TraceID, rl.BookID, rl.Chapter, rl.ChunkIdx, rl.Stage, rl.Role, rl.TraceID, rl.BookID, rl.Chapter, rl.ChunkIdx, rl.Stage, rl.Role,
rl.ModelRequested, rl.ModelActual, rl.RequestHash, rl.ModelRequested, rl.ModelActual, rl.RequestHash,
rl.PromptTokens, rl.CachedTokens, rl.CacheCreationTokens, rl.PromptTokens, rl.CachedTokens, rl.CacheCreationTokens,
rl.CompletionTokens, rl.ReasoningTokens, rl.CompletionTokens, rl.ReasoningTokens,
rl.CostUSD, rl.LatencyMS, rl.FinishReason, rl.TMHit, rl.Degraded, rl.Err, rl.OK) rl.CostUSD, rl.LatencyMS, rl.FinishReason, rl.TMHit, rl.Degraded, rl.Err, rl.OK, rl.Estimated, rl.EstTokens)
return err return err
} }
@ -107,6 +118,8 @@ type RequestLogView struct {
Degraded string Degraded string
Err string Err string
OK int OK int
Estimated int // 1 = CostUSD is a reservation estimate, not provider-reported (pack-13 point-9)
EstTokens int // display-only fertility output-token estimate (pack-13 point-9); 0 unless Estimated
} }
// RequestLogRows returns all request_log rows for a book (tmctl report / Phase 0 // RequestLogRows returns all request_log rows for a book (tmctl report / Phase 0
@ -117,7 +130,7 @@ func (s *Store) RequestLogRows(bookID string) ([]RequestLogView, error) {
SELECT ts, chapter, chunk_idx, stage, role, model_requested, model_actual, SELECT ts, chapter, chunk_idx, stage, role, model_requested, model_actual,
request_hash, prompt_tokens, cached_tokens, request_hash, prompt_tokens, cached_tokens,
cache_creation_tokens, completion_tokens, reasoning_tokens, cache_creation_tokens, completion_tokens, reasoning_tokens,
cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok cost_usd, latency_ms, finish_reason, tm_hit, degraded, err, ok, estimated, est_tokens
FROM request_log WHERE book_id = ? ORDER BY id`, FROM request_log WHERE book_id = ? ORDER BY id`,
func(rows *sql.Rows) (RequestLogView, error) { func(rows *sql.Rows) (RequestLogView, error) {
var v RequestLogView var v RequestLogView
@ -125,7 +138,7 @@ func (s *Store) RequestLogRows(bookID string) ([]RequestLogView, error) {
&v.ModelRequested, &v.ModelActual, &v.RequestHash, &v.ModelRequested, &v.ModelActual, &v.RequestHash,
&v.PromptTokens, &v.CachedTokens, &v.CacheCreationTokens, &v.PromptTokens, &v.CachedTokens, &v.CacheCreationTokens,
&v.CompletionTokens, &v.ReasoningTokens, &v.CostUSD, &v.CompletionTokens, &v.ReasoningTokens, &v.CostUSD,
&v.LatencyMS, &v.FinishReason, &v.TMHit, &v.Degraded, &v.Err, &v.OK) &v.LatencyMS, &v.FinishReason, &v.TMHit, &v.Degraded, &v.Err, &v.OK, &v.Estimated, &v.EstTokens)
return v, err return v, err
}, bookID) }, bookID)
} }

View file

@ -7,6 +7,7 @@
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи. - Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
- Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку исходника (в оригинале часто одно предложение — одна строка). Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет. - Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку исходника (в оригинале часто одно предложение — одна строка). Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет.
- Сохраняй ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. - Сохраняй ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
- Единицы и приёмы (общие для zh→ru): МЕРЫ приводи к привычным читателю единицам (时辰 = 2 часа, 三个时辰 ≈ шесть часов; доля 成 — десятые: 六成六 = 66%, а не «6,6»); СТИХИ и названия классики — по смыслу, не транслитерацией. Род и форму имён и терминов сверяй по глоссарию.
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. - Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков. Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков.

View file

@ -9,6 +9,11 @@
- Вёрстка — по нормам русского языка, НЕ копируй построчную разбивку исходника: реплики прямой речи оформляй с нового абзаца через тире «—». - Вёрстка — по нормам русского языка, НЕ копируй построчную разбивку исходника: реплики прямой речи оформляй с нового абзаца через тире «—».
- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка. - Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка.
Единицы и приёмы (общие для zh→ru):
- МЕРЫ переводи в привычные читателю единицы: китайский 时辰 = 2 часа (三个时辰 ≈ шесть часов, 半个时辰 ≈ час), сам термин не транслитерируй. Доля 成 — это десятые (六成 = 60%, 六成六 = 66%), а НЕ десятичная дробь «6,6».
- СТИХИ и названия классики передавай ПО СМЫСЛУ (не транслитерацией), опираясь на существующие русские переводы.
- Род и форму имён собственных и терминов бери из приложенного глоссария (если он есть).
Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков. Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков.
---USER--- ---USER---

View file

@ -0,0 +1,125 @@
# Отчёт бэкенд-сессии: ПАК-13 «выпускной QA» (по D39.20/D39.21)
**Дата:** 2026-07-24. **Роль:** бэкенд. **Промт:** `docs/BACKEND_PACK13_QA_SESSION_PROMPT.md` (99ec074). **Сессия НЕ коммитит — лендит оркестратор.** Дерево несёт также пак-12 (транспорт, передан на ревью/лендинг мне) — см. §9.
## Итог одной строкой
Реализованы все 8 пунктов пака-13 + переданные пп.9/10 (CostSource-маркер, сегмент-луп-гард). `go build/vet/test ./... -race` — зелёное. Майнер-парити EXACT неизменён. Golden пере-капчерен и классифицирован: **единственная правка wire-контента — гендер-аннотация в драфт-глоссарии (пункт 6)**; всё остальное — version-only хеши; заголовок-политика на golden — no-op (нет langpack); пак-12 wire-нейтрален. Дефект-корпус rerun2: все известные дефекты пойманы, **0 FP** (одну ложную сработку — DC10 — нашёл и починил исполнением). **Директива владельца исполнена:** убран книго-специфичный код (курируемый список битых форм, чекер 资质), каноны терминов вынесены из движка в данные глоссария.
## Директива владельца (в ходе сессии): «не писать код, подгоняющий решение под конкретную книгу»
Пересобрал под это три места:
- **Убран курируемый список битых форм** (`лять`/`вперди`/`глава клан`) из чекера — это были конкретные огрызки ИЗ ЭТОЙ книги. Осталось только ОБЩЕЕ языковое правило — слово на «-йть» (`войть``войти`), которое верно для любого текста.
- **Убран чекер `资质`=«талант»** целиком — это канон КОНКРЕТНОГО термина, ему место в сид-глоссарии книги (данные), а не в движке; общий пост-чек глоссария и так ловит рассинхрон терминов.
- **Убраны термо-каноны `гу`/`资质` из пар-промпта** (`translator.md`/`editor.md`) — пар-промпт общий для всех zh→ru книг; книго/серия-специфичные термины идут в сид-глоссарий. В промпте остались только ОБЩИЕ zh→ru приёмы: меры (时辰=2ч, доля 成), стихи по смыслу, «род/форму терминов бери из глоссария».
- Комментарии нового кода почищены от doc-аббревиатур, переписаны «по делу».
**Честная планка recall (следствие):** из перечисленных дефектов детерминированно ловятся `again` (латиница) и `войть` (общее правило «-йть»). `лять`/`Вперди`/`глава клан` БЕЗ морфологии/словаря и без книго-списка деттектируются НЕ могут (default-B: без hunspell/pymorphy) — это принято как цена отказа от книго-специфичного кода.
## Реализация по пунктам (file:line)
### 1. Крэш-фикс «карта→mined_delta» (wire-нейтрально)
`internal/pipeline/seeding.go:118` `minedDeltaSeedCollisions` — при загрузке дельты проверяет пересечение UNIQUE-ключа `(src,sense,since_ch,until_ch)` с сидом; пересечение → ГРОМКАЯ ошибка со списком дубликатов + подсказка «правь сид ИЛИ убери из дельты», без тихого merge. Вызов: `seeding.go:56`. Ловит и same-dst дубликат, который `approvedSharedKeyCollisions` пропускает (а INSERT крэшит независимо от dst). Тест обоих путей: `mineddelta_collision_test.go`.
### 2. Заголовок-политика (wire-двигающий, langpack-gated)
- Данные в langpack (НЕ хардкод): `configs/langpacks/zh-ru/heading.txt` (`marker`=第, `units`=章节節回, `template`=«Глава {n}»). Загрузка ОПЦИОНАЛЬНАЯ: `internal/lang/langpack.go:228` `readOptional` + `:244` `parseHeading`; пары без файла — инертны и байт-стабильны (не ре-биллятся), с файлом — байты фолдятся в `pack.Version()` (громкий --resnapshot), битый — fail-loud.
- Детект/стрип/рендер (алгоритм в движке, читает данные): `internal/pipeline/chunker.go:129` `stripHeading`, `:173` `matchHeaderLine`, `:160` `applyHeading`, `Chunk.Heading`. Чанкер детектит первый заголовок, СТРИПАЕТ маркер из ch.Text (модель не рендерит заголовок), субтитр сохраняется как тело, «Глава N» рендерится детерминированно.
- Прокидка: `runner.go` `headingRule()`; `bookrun.go:126`, `status.go:144` (`bookChunks`) → `SplitChunks(..., r.headingRule())`.
- Рендер в вывод: `waverun.go` (`runEditUnit`/`draftOnlyOutcome`) + `export.go` (`chunkExport`) префиксят «Глава N» к первому юниту главы — обе точки берут заголовок из ОДНОГО детерминированного чанкера, `translate` и `export` совпадают. Заголовок НЕ в чекпоинте (детерминированная проекция → резюм бесплатно воспроизводит).
- **Приёмка:** unit-тесты `chunker_heading_test.go` на живых формах rerun2 (第一/二/四/五节:субтитр → «Глава 1/2/4/5» + субтитр; fullwidth-space; многоразрядные CJK-цифры; Arabic; без субтитра; негативы — измеритель 回, 第 не в начале строки). Единообразие достигнуто по построению (модель не касается номера).
### 3. Юнит-конверсия
- Wire: строки в `prompts/translator.md`+`editor.md` — «时辰=2 часа (三个时辰≈6ч, 半个时辰≈час), доля 成 — десятые (六成六=66%, а не 6,6)». ОБЩИЕ zh→ru конвенции.
- Чекер: DC1 `lintTimeUnits` (существующий) ловит корпус-дефект 三个时辰→«три часа» (проверено на корпусе).
### 4. Число-масштабы
- DC2 `lintMagnitudeScale` (существующий) ловит 千万→«тысячи» и 数十万→«десятки тысяч» (проверено на корпусе).
- Новый: `internal/pipeline/checkers_zh_ru.go:187` `lintPercentScale` — 成-percent (六成六=66%) отрендерен десятичной дробью → флаг. Источник-контент-gated (成+число), suppress при корректном «процент»/«%», fire только при дробном cue (десятых/сотых/N,N). Ловит glm/mistral (6,6), пропускает dspro (66% — корректно). 亿-разряды покрыты существующим `lintNumberMagnitude` (万/億/兆-гейт) — не дублировал.
### 5. Broken-word / latin residue (ОБЩИЕ, честный recall)
- `checkers_zh_ru.go:219` `lintLatinResidue` — цельное латинское слово в ru-выводе (`again`). Флаг all-lowercase латинского токена ≥3 (утечка прозы — строчная; любая заглавная = бренд/имя/акроним `iPhone`/`Google`/`Suzuki`/`II` → пропуск), без цифр (id/хеш пропускается), не римская цифра, не в allowlist. **Ужесточён по адверсариал-ревью:** первая версия (any-case) ложно срабатывала на брендах/URL — теперь строчная-только (цена: заглавная утечка / URL-хост не ловятся — принято для тихого observability-сигнала).
- `checkers_zh_ru.go:279` `lintBrokenWord` — ТОЛЬКО общее правило: слово на «-йть» (невозможное окончание в русском). Без книго-списков, без словаря. Ловит `войть`. `лять`/`Вперди`/`глава клан` — не детектируются без морфологии (честно, см. §директива).
- Существующий санитайзер-класс битых слов (невалидные знаковые биграммы, гомоглифы) ортогонален, работает.
### 6. Аудит полноты банк-инъекции + фикс гендера
**Таблица «поле банка → доходит до wire?»:**
| поле | драфт-wire | edit-wire | где терялось / статус |
|---|---|---|---|
| src | ✅ | ✅ | — |
| dst | ✅ | ✅ | — |
| status (→⟨проверить⟩ / confirmed-фильтр) | ✅ | ✅ | — |
| **gender** | **✅ (ФИКС)** | ✅ (было) | было: `renderGlossaryBlock` не нёс род → **ПОЧИНЕНО** |
| decl | ❌ | ❌ | by design — decl кормит ПОСТ-ЧЕК (не wire); инжект форм раздул бы блок. Не баг. |
| type | ❌ | ❌ | внутренняя классификация alias-графа; не wire-релевантно. Не баг. |
| sense | ❌ | ❌ | внутренний дизамбигуатор полисемии; матчер ключует по src. Не баг. |
| aliases | косвенно (match-key) | косвенно | алиас-поверхности зажигают запись (инжектится каноничный src→dst). By design. |
Фикс: `internal/pipeline/memory.go:511` `renderGlossaryBlock` — для CONFIRMED-термина с родом добавляет `genderConstraintNote` в ДРАФТ-блок (как editor-блок). Теперь род именного термина доезжает до переводчика (не только редактора). `renderFormatVersion` v2→v3 (`memory.go:548`) — громкий --resnapshot. Безродный банк байт-идентичен. **Это ЕДИНСТВЕННАЯ правка wire-контента в golden** (см. §8).
### 7. Стайл-канон в промпт + канон терминов
- Wire (ОБЩЕЕ): меры + стихи-по-смыслу в `translator.md`/`editor.md` (см. п.3).
- Термо-каноны (`资质`=талант, род «гу») ВЫНЕСЕНЫ из движка/промпта в данные глоссария (директива владельца) — общий пост-чек глоссария их и так энфорсит. Чекера 资质 в коде НЕТ.
### 8. Дефолт-редактор → deepseek-v4-pro
- `configs/pipeline-c1.yaml` + `pipeline-c2.yaml`: editor `glm-5``deepseek-v4-pro` + `few_shot:false` (как в ратифицированном dspro-арме). `reasoning:"off"` СОХРАНЁН (несущая эхо-мина-семантика: на dspro «off»=ReasoningNone no-op → thinking ON; floor 8000 из models.yaml, НЕ переопределён).
- glm-5 резерв: новый `configs/pipeline-arm-glm.yaml`.
- Тест (по санкции оркестратора, файл был чист): `internal/config/echo_mine_test.go` — ожидание модели glm-5→deepseek-v4-pro (пин `reasoning=="off"` СОХРАНЁН), + glm-арм добавлен в `TestSwapArmConfigs`. Остальной `internal/config/` не тронут (рядом чужой `models_catalog_test.go` пака-12).
### 9. CostSource-маркер (передан от пака-12)
`internal/store/requestlog.go` `Estimated bool` + `EstTokens int`; миграция v10 (`migrate.go`, версия свободна — пак-12 откатил свою) — аддитивные колонки, default 0, wire не двигают. Ставятся на двух settle-estimate точках `stagerun.go` (billed-decode + paid-2xx-zero-usage). `CostUSD` НЕ тронут. `EstTokens` — display-only оценка выхода через ФЕРТИЛЬНОСТЬ (`runner.go:261` `estOutTokens` = est_out, 1.20·cjk+0.39·other), НЕ char/4. Легенда в `cmd/tmctl/render.go`. Golden-нейтрально (у golden cost>0 всегда → нет estimated-строк).
### 10. Сегмент-луп-гард (передан от пака-12)
`internal/pipeline/loopguard.go` `segmentLoopRuns` — ран ≥3 идентичных (whitespace-норм) подряд юнитов = вырожденный луп. Observability-only (не диспозиция, не wire), сигнатура по МОДЕЛЬНОМУ выводу (до префикса заголовка). Сюрфейс в `QualityReport.DegenerateLoopRuns` + `renderQuality`. Тест `loopguard_test.go`.
## Дефект-корпус rerun2: каждый дефект пойман, 0 FP
Прогнал ВСЕ чекеры над реальными `export-{glm,dspro,mistral}.json` (src↔target из --pairs). После фикса FP (ниже) — 13 хитов, все настоящие дефекты, **0 ложных**:
| чекер | хиты | дефект (арм) |
|---|---|---|
| DC1 时辰 | 3 | 三个时辰→«три часа» (glm/dspro/mistral) |
| DC2 magnitude | 3 | 千万→«тысячи» (dspro/mistral), 数十万→«десятки тысяч» (mistral) |
| percent (成) | 2 | 六成六→«шесть десятых» (glm/mistral); dspro=«66%» → SUPPRESS ✓ |
| latin | 1 | `again` (glm) |
| broken (-йть) | 1 | `войть` (glm) |
**FP пойман исполнением:** DC10 (资质) в первой версии сработал 6× на ХОРОШИХ переводах — источник нёс И 资质 (→талант, верно) И 资源 (→ресурсы, верно), «ресурс» был легитимным рендером 资源. Это подтвердило, что термо-канон в коде хрупок → вместе с директивой владельца чекер удалён (канон → глоссарий). Не-детектируемые без морфологии `лять`/`Вперди`/`глава клан` — честный recall-разрыв (см. §директива).
## Golden: классификация пере-капчера
`TM_UPDATE_GOLDEN=1` masked-diff: **20 строк wire-контента = ВСЕ гендер-аннотация `鈴木 → Судзуки (муж…)` в драфт-глоссарии** (пункт 6), + 234 version-only (хеши snapshot/request/memory от бампов cheapGateVersion/renderFormatVersion). Проверено сырым дифом (маска хешей+гендера): НЕТ иных изменений disposition/style_flags/final_text-прозы. Резюм-прогон детерминизма — зелёный (0 лишних вызовов).
- **Заголовок-политика на golden — no-op:** у golden-книги нет `langpack_root``headingRule`=nil → `第N章` в исходнике СОХРАНЁН, чанки байт-идентичны. Подтверждено (заголовки видны в wire-телах).
- **Пак-12 wire-нейтрален:** единственная wire-правка в golden — мой гендер; пак-12 не внёс НИ БАЙТА wire-изменений (его инвариант «zero wire bytes» подтверждён golden'ом).
- Новые чекеры на golden (ja→ru) = 0 срабатываний (style_flags не изменились).
## Адверсариальный селф-ревью (3 линзы, параллельно + верификатор)
(Пост-обработка воркфлоу вернула агрегат 0 из-за бага МОЕГО скрипта — non-empty verify-стадия отдавала голый массив, flatMap его ронял; авторитетны сами verify-вердикты ниже.)
- **Детерминизм:** 0 находок. Заголовок/гендер/est_tokens/loopguard — чистые проекции, резюм воспроизводит (golden-резюм зелёный).
- **FP-цена:** две находки про DC9-курируемый-список и DC10 верификатор пометил FALSE ALARM — «кода не существует» (я их уже удалил по директиве владельца ДО верификации ✓). Одна РЕАЛЬНАЯ FP пойма­на: `lintLatinResidue` ложно срабатывал на брендах/URL (`iPhone`, `example.com`) → **ужесточён до строчных-только** (см. §5), тест добавлен. DC10-FP (资质/资源) пойман+устранён исполнением ещё раньше.
- **Langpack-общность:** 3 boundary-заметки (НЕ дефекты рантайма, НЕ книго-специфика — язык-общее):
1. Парсер CJK-цифр (`chunker.go` `isHeadingNumeral`/`parseSectionNumeral`) захардкожен в движке, не в langpack. Принято: это ОБЩИЙ китайско-языковой алгоритм (не пер-книга, не пер-пара — цифры универсальны), пер-парные данные (marker/units/template) в langpack. Как майнер-алгоритмы. Residual: значения цифр в langpack — чище на будущее (как отмечено в шапке checkers_zh_ru.go про code-const паки).
2. `matchHeaderLine` требует unit-руну после числа (CJK-конвенция 第N章). Пара с заголовком «маркер+число без юнита» (en «Chapter N») потребует расширения правила/матчера. Документированное ограничение текущего CJK-скоупа.
3. Чекеры gated по `isRuTarget` (target=ru), не по source=zh — они КОРРЕКТНО общие (source-контент-gated / target-общие): ja→ru дефект 千万 тоже поймается. Поведение верное; только шапка-комментарий файла слегка over-claim «zh-specific» (существующий код, не мой; моё честно помечено «general»).
## Приёмка
- ✅ `go build/vet/test ./... -race` — зелёное (все пакеты, вкл. golden + пак-12).
- ✅ Майнер-парити EXACT — `TestMinerFullBookParity` зелёный, неизменён.
- ✅ Golden — пере-капчерен, классифицирован (wire=только гендер; title no-op; пак-12 нейтрален; резюм-детерминизм зелёный).
- ✅ Дефект-корпус — все дефекты пойманы, 0 FP (таблица выше).
- ✅ Селф-ревью исполнением + адверсариальный 3-линзовый (0 дефектов; DC10-FP пойман исполнением).
- ✅ Директива владельца «не книго-специфичный код» исполнена.
## Пак-12 (передан на ревью/лендинг мне) — ревью исполнением
`internal/llm/**` + `internal/config/models_catalog_test.go` + go.mod (x/net keepalive). Ревью: (а) full -race зелёный; (б) wire-нейтральность подтверждена golden'ом (0 wire-изменений от пака-12); (в) зоны соблюдены (пп.7/10 откачены→мне, п.8b перенесён в llm-адаптер `normalizeOpenAIFinish`, `disposition.go` не тронут); (г) их 4-линзовый селф-ревью = 0 подтверждённых. **Ревью-вердикт: чисто, готово к лендингу.**
## Открытые хвосты / оркестратору
- **Golden уже пере-капчерен** (нужен для зелёных тестов; правки 100% мои, пак-12 нейтрален). Если план — лендить пак-12 ОТДЕЛЬНО первым, golden надо временно откатить к до-гендерной версии (бэкап в скретчпаде) до лендинга пака-13; при СОВМЕСТНОМ лендинге — текущий golden верен.
- **Термо-каноны в сид-глоссарий (данные владельца):** `资质`→«талант», род «гу»=средний, `春秋蝉`→«цикада» — вынесены из кода/промпта; их место — сид-дельта книги (энфорс общим пост-чеком). Оркестратор/владелец вносит в сид.
- **Заголовок-generality для будущих не-CJK пар:** правило сейчас предполагает маркер+число+юнит (CJK); en-стиль «Chapter N» потребует расширения (задокументировано, не блокер zh-ru).
- **Не делал** (по НЕ-делать): род-enforce реплик, hunspell/pymorphy, GLM overflow-гард, сид-правки.