From cc57c7bc02c002a7d1313e69e82666bb17af01bb Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 5 Jul 2026 14:04:45 +0300 Subject: [PATCH] Close external-review findings: gate on confirmed misses only, per-language min-key with collision-prone AMBIGUOUS downgrade, and fail-loud seed guards --- backend/internal/pipeline/memory.go | 67 ++++++++++++++----- backend/internal/pipeline/memory_test.go | 56 ++++++++++++++++ backend/internal/pipeline/mempostcheck.go | 16 +++++ backend/internal/pipeline/memseed.go | 48 +++++++++++++ backend/internal/pipeline/memseed_test.go | 33 +++++++++ backend/internal/pipeline/runner.go | 12 +++- .../internal/pipeline/runner_memory_test.go | 48 +++++++++++++ docs/PROGRESS.md | 18 +++++ 8 files changed, 278 insertions(+), 20 deletions(-) diff --git a/backend/internal/pipeline/memory.go b/backend/internal/pipeline/memory.go index 669e029..3c4744f 100644 --- a/backend/internal/pipeline/memory.go +++ b/backend/internal/pipeline/memory.go @@ -32,14 +32,36 @@ import ( // ban, longest-match containment, spoiler gate, sticky, budget priority order). Folded // into memoryVersion() → a change is a loud --resnapshot (it shifts the injected bytes // → the wire → a resumed checkpoint's content). Sibling of memoryNormVersion. -const memoryMatchVersion = "memmatch-v1-longest+minkey2+spoiler+sticky+budget+postcheck-declaware" +const memoryMatchVersion = "memmatch-v2-perlang-minkey+collision-ambiguous+longest+spoiler+sticky+budget+postcheck-declaware" -// defaultMinKeyLen is the minimum significant length (significantLen) a key must have -// to fire on its own (A3 single-key ban): a lone Han/kana/letter (炎/气/钱) matches -// unrelated text on the wrong sense — empirically confirmed (research/14). A per-entry -// allow_short overrides it (rare, guarded). Per-language tuning (registry min_key_len -// per-язык) is a later knob; v1 uses the eval spec's global MIN_KEY=2. -const defaultMinKeyLen = 2 +// minKeyLenHan / minKeyLenPhonetic are the per-language single-key floors (A3, registry +// "min_key_len per-язык"). An ideographic (Han) key is semantically distinct at 2 chars +// (empirically precision-1.0 on the zh retrieval_bench homograph traps). A PURELY PHONETIC +// key (kana/latin/cyrillic, no Han anchor) is collision-prone at 2 — short phonetic +// sequences appear INSIDE ordinary words (リン in リンゴ/apple, AI in RAID) — so it needs ≥3. +// external-review major #3: the MIN=2 measurement was on zh-Han and does NOT transfer to +// ja-kana; 3 is a conservative default pending a ja-kana precision measurement (an E1- +// protocol extension). A per-entry allow_short overrides both (rare, guarded). +const ( + minKeyLenHan = 2 + minKeyLenPhonetic = 3 +) + +// minKeyLenFor is the floor for a normalized key: any Han ideograph anchors it (2), an +// all-phonetic key needs 3. +func minKeyLenFor(normKey string) int { + if runesAnyHan(normKey) { + return minKeyLenHan + } + return minKeyLenPhonetic +} + +// collisionProneKey reports whether a key is short AND purely phonetic (no Han anchor), +// so it is likely to fire inside an unrelated word — the A2 "surface collision" case, +// injected as AMBIGUOUS (unverified) rather than authoritatively CONFIRMED. +func collisionProneKey(normKey string) bool { + return !runesAnyHan(normKey) && significantLen(normKey) <= minKeyLenPhonetic +} // stickyDepth is the scene-inertia window (A5): entries exact-matched in the previous // N chunks of the SAME chapter are carried into a pronominal chunk that names nobody. @@ -83,6 +105,9 @@ type memoryEntry struct { // research/14 shows false-flags on inflection — the reason full decl matters). declForms []string declInvariant bool + // allowShort is the author's explicit override of the single-key ban AND the + // collision-prone disposition downgrade (full trust in a short key). + allowShort bool } // MemoryBank is a book's frozen glossary materialized for one job: the entries, the @@ -129,8 +154,9 @@ func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank { dst: row.Dst, status: row.Status, sense: row.Sense, - sinceCh: row.SinceCh, - untilCh: row.UntilCh, + sinceCh: row.SinceCh, + untilCh: row.UntilCh, + allowShort: row.AllowShort, } // Eligible source surfaces (src + aliases) under the single-key ban. A record // with NO dst yet (a ruby auto-candidate) is NOT matchable: it renders nothing @@ -150,8 +176,8 @@ func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank { if nk == "" { continue } - if significantLen(nk) < defaultMinKeyLen && !row.AllowShort { - continue // A3: a lone Han/kana/letter never fires on its own + if significantLen(nk) < minKeyLenFor(nk) && !row.AllowShort { + continue // A3: a too-short key never fires on its own (per-language floor) } e.normKeys = append(e.normKeys, nk) } @@ -293,7 +319,7 @@ func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]boo sel.rejected = append(sel.rejected, pickedEntry{e, via, memReject}) continue } - hits[e.id] = pickedEntry{e, via, dispositionFor(e)} + hits[e.id] = pickedEntry{e, via, dispositionFor(e, via)} sel.activeIDs[e.id] = true // exact-matched ids feed the next chunk's sticky (NOT sticky-carried) } @@ -310,7 +336,7 @@ func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]boo if spoilerBlocked(e, chapter) { continue } - hits[e.id] = pickedEntry{e, "sticky", dispositionFor(e)} + hits[e.id] = pickedEntry{e, "sticky", dispositionFor(e, "sticky")} } // Deterministic priority order for the budget: confirmed>ambiguous, exact>sticky, @@ -355,10 +381,17 @@ func spoilerBlocked(e *memoryEntry, chapter int) bool { return false } -// dispositionFor maps a term's status to its injection disposition (A2): approved → -// CONFIRMED (authoritative); auto/draft → AMBIGUOUS (inject "unverified" + forced -// post-check). -func dispositionFor(e *memoryEntry) injectionDisposition { +// dispositionFor maps a term + its firing key to an injection disposition (A2). approved → +// CONFIRMED (authoritative); auto/draft → AMBIGUOUS. AND: even an approved entry matched by +// a COLLISION-PRONE key (short + purely phonetic — リン in リンゴ, AI in RAID) is downgraded to +// AMBIGUOUS (unverified + forced post-check), because such a key may have fired inside an +// unrelated word rather than on the entity (external-review major #2 — the A2 +// "surface-collision → AMBIGUOUS" branch). allow_short is the author's explicit override. +// A sticky carry (via=="sticky") has no firing key, so it keeps its status-based disposition. +func dispositionFor(e *memoryEntry, via string) injectionDisposition { + if via != "sticky" && !e.allowShort && collisionProneKey(via) { + return memAmbiguous + } if e.status == "approved" { return memConfirmed } diff --git a/backend/internal/pipeline/memory_test.go b/backend/internal/pipeline/memory_test.go index a746d90..d1c7379 100644 --- a/backend/internal/pipeline/memory_test.go +++ b/backend/internal/pipeline/memory_test.go @@ -359,6 +359,62 @@ func TestBudgetFloorFree(t *testing.T) { } } +// TestPerLanguageMinKeyAndCollisionDisposition covers external-review major #2/#3: a +// purely-phonetic key needs ≥3 chars (a 2-char kana/latin key collides inside ordinary +// words), and a short (≤3) phonetic key injects as AMBIGUOUS even when approved. +func TestPerLanguageMinKeyAndCollisionDisposition(t *testing.T) { + entries := []store.GlossaryEntry{ + gl("鈴木", "Судзуки", "", "approved"), // 2 Han → fires, CONFIRMED (ideographic anchor) + gl("すずき", "Судзуки-х", "s2", "approved"), // 3 kana → fires but AMBIGUOUS (collision-prone) + gl("ながいなまえ", "Длинное имя", "", "approved"), // 6 kana → fires, CONFIRMED (long enough) + gl("リン", "Рин", "", "approved"), // 2 kana → BANNED (phonetic min 3) + gl("ai", "ИИ", "", "approved"), // 2 latin → BANNED (phonetic min 3) + } + b := bankFrom(entries) + sel := b.Select("鈴木とすずきとながいなまえが会った。", 1, nil, 0) + m := injMap(sel) + if m["鈴木"] != memConfirmed { + t.Errorf("Han key must be CONFIRMED: %v", m) + } + if m["すずき"] != memAmbiguous { + t.Errorf("short phonetic key must be AMBIGUOUS (A2 collision): %v", m) + } + if m["ながいなまえ"] != memConfirmed { + t.Errorf("long phonetic key must be CONFIRMED: %v", m) + } + // The collision cases external-review named: a short phonetic key must NOT fire inside + // an ordinary word (リン in リンゴ/apple, ai in raid). + if s := b.Select("リンゴを食べた。", 1, nil, 0); len(s.injected) != 0 { + t.Errorf("2-kana key fired inside リンゴ (collision): %v", injMap(s)) + } + if s := b.Select("raidアレイを組んだ。", 1, nil, 0); len(s.injected) != 0 { + t.Errorf("2-latin key fired inside raid (collision): %v", injMap(s)) + } + // allow_short is the author's explicit override: the short key fires AND as CONFIRMED. + rin := gl("リン", "Рин", "", "approved") + rin.AllowShort = true + b2 := bankFrom([]store.GlossaryEntry{rin}) + if s := b2.Select("リンさんが来た。", 1, nil, 0); injMap(s)["リン"] != memConfirmed { + t.Errorf("allow_short must let リン fire as CONFIRMED: %v", injMap(s)) + } +} + +// TestOverlappingNonNestedKeysBothFire documents the eval-review low finding: two keys +// that OVERLAP without nesting both fire (both surfaces are genuinely present in the +// text). Accepted behavior (precision-side), asserted so a future change is deliberate. +func TestOverlappingNonNestedKeysBothFire(t *testing.T) { + entries := []store.GlossaryEntry{ + gl("甲乙", "Пара-А", "", "approved"), + gl("乙丙", "Пара-Б", "", "approved"), + } + b := bankFrom(entries) + sel := b.Select("甲乙丙都在。", 1, nil, 0) // 甲乙 at [0,2), 乙丙 at [1,3) — overlap, neither contains the other + m := injMap(sel) + if m["甲乙"] != memConfirmed || m["乙丙"] != memConfirmed { + t.Errorf("overlapping non-nested keys should both fire: %v", m) + } +} + func TestInjectivityCollision(t *testing.T) { entries := []store.GlossaryEntry{ {BookID: "b", Src: "甲", Dst: "Мастер", Status: "approved"}, diff --git a/backend/internal/pipeline/mempostcheck.go b/backend/internal/pipeline/mempostcheck.go index e8e0e9c..e4ec5d3 100644 --- a/backend/internal/pipeline/mempostcheck.go +++ b/backend/internal/pipeline/mempostcheck.go @@ -38,6 +38,22 @@ type postcheckMiss struct { Disp string `json:"disp"` // confirmed | ambiguous (A2: ambiguous injections force a post-check) } +// countConfirmedMisses counts misses of CONFIRMED (approved) records only. AMBIGUOUS +// misses (an auto/draft candidate the model was ENTITLED to reject) must never flip a +// disposition or count as a consistency failure: doing so would invert the contract — +// punishing a model that correctly rejected an unverified suggestion while passing one +// that obeyed a wrong CONFIRMED injection (external-review major #1). The AMBIGUOUS misses +// still travel in the detail (A2 forced-post-check observability), just not as the count. +func countConfirmedMisses(misses []postcheckMiss) int { + n := 0 + for _, m := range misses { + if m.Disp == string(memConfirmed) { + n++ + } + } + return n +} + // postcheck runs the decl-aware check over the injected records against the model // output. Pure and deterministic. Returns the misses (empty = all fired terms present). func (b *MemoryBank) postcheck(injected []pickedEntry, output string) []postcheckMiss { diff --git a/backend/internal/pipeline/memseed.go b/backend/internal/pipeline/memseed.go index 84fe3d3..e1fa6d8 100644 --- a/backend/internal/pipeline/memseed.go +++ b/backend/internal/pipeline/memseed.go @@ -95,6 +95,13 @@ func loadGlossarySeed(path string) ([]store.GlossaryEntry, error) { problems = append(problems, fmt.Sprintf("term %q: status must be auto|draft|approved, got %q", t.Src, status)) continue } + // An approved OR draft term with no dst is silently inert (not matchable — it renders + // nothing) yet reads as an intended rendering: fail loud (external-review minor #5). + // Only status=auto (a raw candidate — e.g. a ruby reading awaiting a dst) may be empty. + if (status == "approved" || status == "draft") && strings.TrimSpace(t.Dst) == "" { + problems = append(problems, fmt.Sprintf("term %q: a %s term must have a non-empty dst (an empty one is silently inert; only status=auto may lack a dst)", t.Src, status)) + continue + } decl := "" if t.Decl != nil { b, mErr := json.Marshal(declInfo{Invariant: t.Decl.Invariant, Forms: t.Decl.Forms}) @@ -130,12 +137,53 @@ func loadGlossarySeed(path string) ([]store.GlossaryEntry, error) { } out = append(out, e) } + // Overlapping spoiler windows for the SAME (src,sense) with DIFFERENT dst inject two + // contradictory renderings at a chapter in the overlap — silently (external-review + // minor #4). Fail loud. UNIQUE(src,sense,window) already blocks identical windows, so + // this catches the partial-overlap case. Iterate `out` in order (deterministic message). + for i := range out { + for j := 0; j < i; j++ { + a, b := out[i], out[j] + if a.Src == b.Src && a.Sense == b.Sense && a.Dst != b.Dst && windowsOverlap(a.SinceCh, a.UntilCh, b.SinceCh, b.UntilCh) { + problems = append(problems, fmt.Sprintf("term %q (sense %q): spoiler windows [%d,%d] and [%d,%d] overlap with different dst (%q vs %q) — a term has ONE rendering per chapter", + a.Src, a.Sense, b.SinceCh, b.UntilCh, a.SinceCh, a.UntilCh, b.Dst, a.Dst)) + } + } + } if len(problems) > 0 { return nil, fmt.Errorf("glossary seed %s:\n - %s", path, strings.Join(problems, "\n - ")) } return out, nil } +// windowsOverlap reports whether two spoiler windows share any chapter. since_ch 0 means +// "from chapter 1"; until_ch 0 means "no end". +func windowsOverlap(since1, until1, since2, until2 int) bool { + const inf = 1 << 62 + s1, s2 := since1, since2 + if s1 == 0 { + s1 = 1 + } + if s2 == 0 { + s2 = 1 + } + u1, u2 := until1, until2 + if u1 == 0 { + u1 = inf + } + if u2 == 0 { + u2 = inf + } + lo, hi := s1, u1 + if s2 > lo { + lo = s2 + } + if u2 < hi { + hi = u2 + } + return lo <= hi +} + // --- ruby → auto candidates ----------------------------------------------------- // rubyToCandidates classifies the captured ruby readings into auto glossary candidates diff --git a/backend/internal/pipeline/memseed_test.go b/backend/internal/pipeline/memseed_test.go index 6fa6f30..15a4c16 100644 --- a/backend/internal/pipeline/memseed_test.go +++ b/backend/internal/pipeline/memseed_test.go @@ -104,6 +104,39 @@ terms: } } +// TestSeedApprovedEmptyDstFailsLoud covers external-review minor #5. +func TestSeedApprovedEmptyDstFailsLoud(t *testing.T) { + for _, status := range []string{"approved", "draft"} { + if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: 甲, status: "+status+" }\n")); err == nil { + t.Errorf("a %s term with an empty dst must fail loud (silently inert otherwise)", status) + } + } + // Only status=auto (a raw candidate — e.g. a ruby reading) may have an empty dst. + if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: 甲, status: auto }\n")); err != nil { + t.Errorf("an auto candidate may have an empty dst: %v", err) + } +} + +// TestSeedOverlappingSpoilerWindowsFailLoud covers external-review minor #4. +func TestSeedOverlappingSpoilerWindowsFailLoud(t *testing.T) { + // Same src+sense, overlapping windows [0,5] and [3,10], DIFFERENT dst → contradiction. + if _, err := loadGlossarySeed(writeSeed(t, ` +terms: + - { src: 影卫, dst: страж, until_ch: 5 } + - { src: 影卫, dst: тень, since_ch: 3, until_ch: 10 } +`)); err == nil { + t.Error("overlapping spoiler windows with different dst must fail loud") + } + // Non-overlapping windows (a rendering that changes after a spoiler boundary) → allowed. + if _, err := loadGlossarySeed(writeSeed(t, ` +terms: + - { src: 影卫, dst: страж, until_ch: 5 } + - { src: 影卫, dst: предатель, since_ch: 6 } +`)); err != nil { + t.Errorf("non-overlapping windows should be allowed: %v", err) + } +} + func TestClassifyRubyReading(t *testing.T) { cases := []struct { base, reading, want string diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index d4a2ac5..152c1a0 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -741,11 +741,14 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st if r.memory != nil && !flagged && prev != "" { outputChecked = true postMisses = r.memory.postcheck(memSel.injected, prev) - if r.Pipeline.Gates.Glossary.PostcheckGate && len(postMisses) > 0 { + // The gate flips ONLY on CONFIRMED (approved) misses — an AMBIGUOUS miss is an + // unverified candidate the model may legitimately reject, so it must not discard a + // correct translation (external-review major #1). + if r.Pipeline.Gates.Glossary.PostcheckGate && countConfirmedMisses(postMisses) > 0 { flagged = true flagReason = FlagGlossaryMiss r.Log.WarnContext(ctx, "glossary post-check gate flagged the chunk", - "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "misses", len(postMisses)) + "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "confirmed_misses", countConfirmedMisses(postMisses)) } } @@ -789,7 +792,10 @@ func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelect rs.NSpoilerBlocked = len(sel.rejected) rs.NEvicted = len(sel.evicted) if outputChecked { - rs.NPostcheckMiss = len(misses) + // n_postcheck_miss is the CONFIRMED-miss count (the actionable consistency-failure + // signal, external-review #1); the detail carries ALL misses (confirmed AND the + // ambiguous forced-post-check ones, each disp-tagged) for the human. + rs.NPostcheckMiss = countConfirmedMisses(misses) if len(misses) > 0 { if b, err := json.Marshal(misses); err == nil { rs.PostcheckDetail = string(b) diff --git a/backend/internal/pipeline/runner_memory_test.go b/backend/internal/pipeline/runner_memory_test.go index 16ddab0..c97b5fc 100644 --- a/backend/internal/pipeline/runner_memory_test.go +++ b/backend/internal/pipeline/runner_memory_test.go @@ -196,6 +196,54 @@ func TestRunnerMemoryPostcheckGateFlags(t *testing.T) { } } +// TestRunnerGateIgnoresAmbiguousMiss covers external-review major #1: with the hard gate +// ON, a model that renders the APPROVED term but drops an unverified DRAFT candidate must +// NOT have its (correct) translation discarded — only a CONFIRMED miss flags. +const suzukiPlusDraftSeed = ` +terms: + - src: 鈴木 + dst: Судзуки + status: approved + decl: { invariant: true, forms: ["Судзуки"] } + - src: 田中 + dst: Танака + status: draft + decl: { invariant: true, forms: ["Танака"] } +` + +func TestRunnerGateIgnoresAmbiguousMiss(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if isEditBody(body) { + return "ОТРЕДАКТИРОВАННЫЙ про Судзуки.", "stop" + } + // Renders the APPROVED name, DROPS the draft candidate (its right to reject it). + return "Судзуки пошёл в библиотеку, а спутник задержался.", "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, + source: "鈴木と田中が図書館へ行った。", glossarySeed: suzukiPlusDraftSeed, postcheckGate: true}) + + r := newRunner(t, bookPath) + defer r.Close() + res, err := r.TranslateBook(context.Background()) + if err != nil { + t.Fatal(err) + } + // The chunk is NOT flagged: the only miss (田中/Танака) is an AMBIGUOUS candidate. + if res.Flagged != 0 { + t.Fatalf("gate flipped on an ambiguous-only miss (inverts the contract): flagged=%d", res.Flagged) + } + rs, _ := r.Store.GetRetrievalState("test-book", 1, 0) + if rs == nil || rs.NPostcheckMiss != 0 { + t.Fatalf("confirmed-miss count should be 0 (only an ambiguous miss occurred): %+v", rs) + } + // The ambiguous miss is still visible in the detail (A2 forced-post-check observability). + if rs.PostcheckDetail == "" || !strings.Contains(rs.PostcheckDetail, "田中") { + t.Errorf("ambiguous miss should be recorded in the detail: %q", rs.PostcheckDetail) + } +} + func TestRunnerMemoryResnapshotOnApprovedChange(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index ea11520..71036da 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -465,6 +465,24 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор > **Протокол включения гейта (важно):** твой E1-замер меряет false-flag **approved**-терминов на русской морфологии — но находки 1/2/3 вскрывают **другие оси** false-flag (ambiguous-decline, коллизионные короткие ключи), которые E1-протокол НЕ покрывает. Значит либо чинить 3 major ДО флипа (что убирает эти оси), либо расширить протокол валидации на них. Рекомендую первое. > **Приоритет:** #1 (one-liner, немедленно) → #2+#3 (коллизия/kana, до ja-kana или флипа гейта) → #4/#5 (дешёвые гарды). Ничего не блокирует дефолтный конфиг; всё блокирует флип гейта и прод ja-kana. +> **Внешнее ревью eval банка памяти v2 (05.07): ПРИНЯТО, 0 блокеров, 0 баг-находок.** Прогнал независимо на `aa00339` (сборка/тесты `-race` зелёные, E1 воспроизведён full-decl 0%/base-only 67%, трад-таблица 508 маппингов 爺→爷 на месте, изоляция инъекции/sticky-resume/F1/схема — проверил сам). 4 low/scope-заметки: (a) перекрывающиеся НЕвложенные ключи оба срабатывают (precision-сторона, обе поверхности реально есть); (b) редактор глоссарий не получает — защита только post-check финала (fast-follow: dst-констрейнт-инъекция в редактор — приоритет след. вехи); (c) gate-режим выкидывает весь чанк (opt-in, агрессивно — задокументировать до флипа); (d) 2-й system-месседж не на живом проводе (только моки — live-conformance кейс follow-up). + +### 2026-07-05 — Шаг 4: закрытие внешних ревью (6 находок оркестратора + 4 low eval) + +Все находки закрыты в этой же сессии, каждый фикс **мутационно-проверен** (реверт → падает именно его тест) + полный набор зелёный `go build/vet/test ./... -race`, `tmctl report` $0. Ядро (F1/нормализация/спойлер/поверхность) оба ревью подтвердили чистым — трогал только зону находок. `memoryMatchVersion` бампнут **v1→v2** (смена семантики матча/disposition = осознанная инвалидация → громкий `--resnapshot`). + +**Major:** +- **#1 — gate по CONFIRMED-only.** `PostcheckGate` и `n_postcheck_miss` считают только `Disp==confirmed` (`countConfirmedMisses`); AMBIGUOUS-miss (кандидат, который модель вправе отклонить) больше не роняет корректный перевод — только в `postcheck_detail` (A2-наблюдаемость). Инверсия контракта устранена. +- **#2 — A2 коллизия→AMBIGUOUS.** `dispositionFor(entry, via)`: короткий чисто-фонетический ключ (kana/latin без Han-якоря, ≤3 знака) даунгрейдится в AMBIGUOUS даже у approved (мог сработать внутри чужого слова). `allow_short` — явный оверрайд автора. Sticky-кэрри сохраняет status-based (нет firing-ключа). +- **#3 — min_key_len per-язык.** `minKeyLenFor`: Han-якорь → 2 (эмпирика zh-Han precision-1.0); чисто-фонетический → 3 (リン в リンゴ, ai в raid больше не фичат). **Kana=3 — консервативный дефолт до ja-kana precision-замера (расширение E1-протокола)** — свежие глаза показали, что zh-Han-замер MIN=2 на kana не переносится. +- **Протокол флипа гейта:** оси false-flag из #1/#2/#3 (ambiguous-decline, коллизионные короткие ключи) E1-замер не покрывал → закрыты в коде, поэтому расширять протокол не нужно; флип гейта разблокирован после этих фиксов + owner-валидации precision. + +**Minor:** +- **#4 — overlap спойлер-окон fail-loud.** `loadGlossarySeed`: same (src,sense) с пересекающимися окнами и РАЗНЫМ dst → громкая ошибка (`windowsOverlap`; молчаливая противоречивая инъекция устранена). +- **#5 — approved/draft пустой dst fail-loud.** Только `status=auto` (сырой кандидат, ruby-reading без dst) может быть пустым; approved/draft с пустым dst — ошибка (тихая потеря recall). + +**eval low:** #a — тест `TestOverlappingNonNestedKeysBothFire` фиксирует принятое поведение (обе срабатывают); #b/#c/#d — задокументированы (редактор-инъекция = приоритет след. вехи; gate-агрессивность — `FlagGlossaryMiss` не-retryable/не-escalatable, флагает для человека; live-wire кейс — follow-up на `live_conformance`-харнесс). Регресс-тесты: per-lang-min+collision-disposition, gate-ignores-ambiguous (e2e), overlap-windows/empty-dst fail-loud, overlapping-non-nested. Оба вердикта: **ПРИНЯТО**; дефолтный конфиг чист, флип гейта и прод ja-kana разблокированы. + ## Полигон (секция параллельной сессии — записи добавлять сюда)