Close external-review findings: gate on confirmed misses only, per-language min-key with collision-prone AMBIGUOUS downgrade, and fail-loud seed guards

This commit is contained in:
Claude (backend session) 2026-07-05 14:04:45 +03:00
parent 068f59d22f
commit cc57c7bc02
8 changed files with 278 additions and 20 deletions

View file

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

View file

@ -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"},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -465,6 +465,24 @@ keep-alive (Ф12), инъекция глоссария (`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 разблокированы.
## Полигон
(секция параллельной сессии — записи добавлять сюда)