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:
parent
068f59d22f
commit
cc57c7bc02
8 changed files with 278 additions and 20 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 разблокированы.
|
||||
|
||||
## Полигон
|
||||
(секция параллельной сессии — записи добавлять сюда)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue