textmachine/backend/internal/pipeline/memory.go

744 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strconv"
"strings"
"unicode"
"textmachine/backend/internal/store"
)
// memory.go: the DETERMINISTIC hot path of the memory bank v2 (registry §Контракт
// горячего пути / research/13 Q3). $0, no LLM, no embeddings, no FTS5 — a multi-pattern
// exact match (Aho-Corasick) of glossary keys/aliases over the NORMALIZED chunk, with
// the single-key ban (A3), longest-match whole-entity replacement, the spoiler
// hard-reject window (C1), sticky scene-inertia (A5), a priority token budget with a
// logged eviction (F2), and the three-way injection disposition (A2). Pure and
// deterministic (no map-order output, no time/rand): the injected block is a pure
// function of (frozen bank, normalized chunk, chapter, sticky, budget), and it enters
// request_hash via the rendered messages — so a resumed chunk reproduces it for free.
//
// The registry's principle drives every choice here: "лучше ничего, чем мусор" and
// PRECISION over recall. A wrong injection (a homograph / short alias firing on the
// wrong sense) is the MAIN source of silent degradation (2510.00829) — worse than an
// empty match, which is a safe fallback to the model's base behaviour. So the matcher
// errs toward NOT firing, and the post-check (memcheck) makes what did fire observable.
// memoryMatchVersion versions the matcher + injection-selection ALGORITHM (min-key
// ban, longest-match containment, spoiler gate, sticky, budget priority order). Folded
// into memoryVersion() → a change is a loud --resnapshot (it shifts the injected bytes
// → the wire → a resumed checkpoint's content). Sibling of memoryNormVersion.
// v3 (D16.2/D16.3): sticky now INHERITS the original match's disposition instead of
// recomputing it status-based (a collision-prone AMBIGUOUS carry no longer silently
// upgrades to CONFIRMED), and a spaced-phonetic (Latin/Cyrillic) source key is validated
// against a word boundary (approved "rose" no longer fires inside "roseanne").
const memoryMatchVersion = "memmatch-v3-perlang-minkey+collision-ambiguous+longest+spoiler+sticky-inherit-disp+phonetic-srcboundary+budget+postcheck-declaware"
// 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.
// A code rule (Р2), reset at each chapter boundary (a new chapter is a scene change).
const stickyDepth = 2
// injectionDisposition is the three-way per-record decision (registry A2) — the core
// mechanism that converts silent degradation into loud. Distinct from the chunk×stage
// Disposition (disposition.go): that is ok|flagged|skipped for a completion; this is
// how much to TRUST an injected glossary record.
type injectionDisposition string
const (
memConfirmed injectionDisposition = "confirmed" // exact key/alias, approved, spoiler-valid → authoritative
memAmbiguous injectionDisposition = "ambiguous" // auto/draft term → inject "unverified" + forced post-check
memReject injectionDisposition = "reject" // spoiler-window violation → dropped and logged (C1 safety)
)
// declInfo is the parsed decl column: the dst declension forms the post-check accepts.
type declInfo struct {
Invariant bool `json:"invariant"`
Forms []string `json:"forms"`
}
// memoryEntry is one frozen, materialized glossary entry with its matchable keys
// pre-normalized. Immutable for a job.
type memoryEntry struct {
id string // stable unique id: src\x1f sense\x1f since\x1f until (= the UNIQUE key)
src string // raw source key
dst string // raw approved translation (may be "" for a ruby candidate)
status string // auto|draft|approved
sense string
sinceCh int
untilCh int
// normKeys are the normalized source surfaces (src + aliases) ELIGIBLE to fire
// (significantLen ≥ minKeyLen OR allow_short). A key too short is dropped here, so
// it is never in the automaton — the single-key ban is structural, not a runtime skip.
normKeys []string
// declForms are the normalized-target dst forms the post-check accepts. Empty →
// the post-check falls back to the single normalized dst (the naive base form that
// research/14 shows false-flags on inflection — the reason full decl matters).
declForms []string
declInvariant bool
// 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
// Aho-Corasick automaton over all eligible keys, and the F1 memoryVersion. Built once
// (materializeMemory) before the chunk loop and never mutated.
type MemoryBank struct {
entries []memoryEntry
ac *ahoCorasick
keyOwners map[string][]int // normalized key → indices of entries that contributed it
version string // memoryVersion(): hash of frozen APPROVED rows + algo versions (F1/D8)
}
// pickedEntry is one selected record for a chunk with its firing key and disposition.
type pickedEntry struct {
entry *memoryEntry
via string // the matching key, or "sticky"
disp injectionDisposition
}
// memorySelection is the hot path's output for one chunk.
type memorySelection struct {
injected []pickedEntry // in budget priority order (what the model sees)
rejected []pickedEntry // spoiler-window rejects (C1, logged)
evicted []pickedEntry // dropped by the token budget (F2, logged)
// activeIDs are the exact-matched ids (NOT sticky) → the next chunk's sticky_prev,
// each mapped to the DISPOSITION it fired with. Sticky carries this disposition
// forward instead of recomputing it (D16.2): a sticky carry has no firing key, so it
// cannot re-derive the collision-prone downgrade, and recomputing from status alone
// would silently upgrade a collision-prone AMBIGUOUS match to CONFIRMED.
activeIDs map[string]injectionDisposition
}
func (b *MemoryBank) Version() string { return b.version }
// materializeMemory builds a MemoryBank from the book's stored glossary rows (ORDER
// BY-stable — GlossaryForBook). Pure and deterministic. It computes memoryVersion as a
// content hash of the frozen APPROVED rows (D8: content-hash, not a version-counter —
// drift-proof) plus the normalization + matcher algorithm versions, so ANY change to
// the approved glossary OR to the deterministic machinery is a loud --resnapshot (F1).
func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
b := &MemoryBank{keyOwners: map[string][]int{}}
var allKeys []string
seenKey := map[string]bool{}
for _, row := range rows {
e := memoryEntry{
id: row.Src + "\x1f" + row.Sense + "\x1f" + strconv.Itoa(row.SinceCh) + "\x1f" + strconv.Itoa(row.UntilCh),
src: row.Src,
dst: row.Dst,
status: row.Status,
sense: row.Sense,
sinceCh: row.SinceCh,
untilCh: row.UntilCh,
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
// (renderGlossaryBlock/postcheck both skip empty dst), so admitting its keys would
// let it consume the token budget, evict a renderable line, and inflate the
// retrieval-state exact-hit count for a record the model never sees (self-review
// #1). It stays in the store for future promotion, just inert on the hot path.
var surfaces []string
if strings.TrimSpace(row.Dst) != "" {
surfaces = append(surfaces, row.Src)
for _, a := range row.Aliases {
surfaces = append(surfaces, a.Alias)
}
}
for _, s := range surfaces {
nk := normalizeSourceKey(s)
if nk == "" {
continue
}
if significantLen(nk) < minKeyLenFor(nk) && !row.AllowShort {
continue // A3: a too-short key never fires on its own (per-language floor)
}
e.normKeys = append(e.normKeys, nk)
}
// Parse decl forms for the post-check (normalized target side).
if row.Decl != "" {
var d declInfo
if err := json.Unmarshal([]byte(row.Decl), &d); err == nil {
e.declInvariant = d.Invariant
for _, f := range d.Forms {
if nf := normalizeTargetForm(f); nf != "" {
e.declForms = append(e.declForms, nf)
}
}
}
}
idx := len(b.entries)
b.entries = append(b.entries, e)
for _, nk := range e.normKeys {
b.keyOwners[nk] = append(b.keyOwners[nk], idx)
if !seenKey[nk] {
seenKey[nk] = true
allKeys = append(allKeys, nk)
}
}
}
sort.Strings(allKeys) // deterministic automaton construction
b.ac = buildAC(allKeys)
b.version = computeMemoryVersion(rows, gateOn)
return b
}
// computeMemoryVersion is the F1 content-hash: the frozen rows (ORDER BY-stable) plus the
// normalization and matcher algorithm versions. Approved-only per D8/§8 when the post-check
// hard gate is OFF (auto/draft injected-content changes are caught at the per-chunk
// content_hash level; their decl changes affect only the recomputed retrieval-state).
// When the gate is ON, the resolved chunk disposition depends on the decl forms of ALL
// injected records (approved AND auto/draft), and those are in NEITHER content_hash (decl
// is not injected) NOR the approved-only hash — so a decl edit would silently flip a
// resumed chunk's disposition (self-review #4). So gateOn folds every row's content
// (incl. decl + status) into the hash, making any such edit a loud --resnapshot. The
// glossary.id autoincrement is deliberately EXCLUDED (fresh each replace); only content
// columns are hashed.
func computeMemoryVersion(rows []store.GlossaryEntry, gateOn bool) string {
h := sha256.New()
h.Write([]byte("tm-memory-v2\x00"))
h.Write([]byte(memoryNormVersion + "\x00" + memoryMatchVersion + "\x00"))
h.Write([]byte("gate:" + strconv.FormatBool(gateOn) + "\x00"))
for _, r := range rows {
if r.Status != "approved" && !gateOn {
continue
}
// A fixed, length-prefixed field layout so no content can forge a boundary.
writeField(h, r.Status)
writeField(h, r.Src)
writeField(h, r.Dst)
writeField(h, r.Sense)
writeField(h, r.Type)
writeField(h, r.Gender)
writeField(h, r.Decl)
writeField(h, strconv.Itoa(r.SinceCh))
writeField(h, strconv.Itoa(r.UntilCh))
writeField(h, strconv.FormatBool(r.AllowShort))
writeField(h, strconv.Itoa(len(r.Aliases)))
for _, a := range r.Aliases { // GlossaryForBook returns aliases ORDER BY alias
writeField(h, a.Alias)
writeField(h, a.AliasType)
}
}
return hex.EncodeToString(h.Sum(nil))
}
func writeField(h interface{ Write([]byte) (int, error) }, s string) {
var lb [8]byte
n := uint64(len(s))
for i := 0; i < 8; i++ {
lb[i] = byte(n >> (8 * i))
}
h.Write(lb[:])
h.Write([]byte(s))
}
// glossaryLineTokens is the injected token cost of one record's "src → dst" line — the
// unit the token budget (config glossary_token_budget) spends. It is FLOOR-FREE (the raw
// cjk + other/3 estimate, NOT EstimateTokens' 16-token minimum): the floor is for sizing
// a whole request's max_tokens, but applying it PER glossary line ~1.85×-overcounts a
// block of short name lines and needlessly evicts records that fit (self-review #7). The
// header's cost is small and constant; omitting it keeps the unit purely per-record.
// Shared by Select's budget and the eviction test so the two never diverge.
func glossaryLineTokens(e *memoryEntry) int {
cjk, other := 0, 0
for _, r := range e.src + " → " + e.dst {
switch {
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
cjk++
case unicode.IsSpace(r):
default:
other++
}
}
return cjk + other/3
}
// Select is the hot path for ONE chunk: match → spoiler-reject → disposition → sticky
// → priority token budget. Pure and deterministic. stickyPrev is the set of ids
// exact-matched in the prior chunk(s) of the same chapter (A5). budgetTokens ≤ 0 means
// unbounded; otherwise records are kept in priority order while the cumulative injected
// token cost stays within budget — the rest are EVICTED (dropped but logged, F2; the
// budget may be underfilled, "лучше ничего, чем мусор").
func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]injectionDisposition, budgetTokens int) memorySelection {
ntext := []rune(normalizeSourceKey(chunk))
occ := b.ac.matches(ntext)
// Source word-boundary for spaced-phonetic keys (D16.3): drop an occurrence of a Latin/
// Cyrillic key that fired INSIDE a longer word of the same script ("rose" in "roseanne").
// Closes the source-vs-target boundary asymmetry (the target side already has
// containsWholeWord). Han/kana are NOT boundary-checked (no word segmentation) — deliberate.
occ = b.suppressUnboundedPhonetic(occ, ntext)
// Longest-match: drop a key fully inside a strictly-longer key's span — but ONLY when
// the longer key belongs to a spoiler-VALID entry at this chapter (self-review #6). A
// spoiler-blocked longer key must not suppress a valid shorter name nested inside it.
occ = b.suppressContained(occ, chapter)
// Map surviving key occurrences → entries, recording the LONGEST firing key per entry.
matchedVia := map[int]string{}
for _, m := range occ {
k := b.ac.keys[m.keyIdx]
for _, ei := range b.keyOwners[k] {
if cur, ok := matchedVia[ei]; !ok || len([]rune(k)) > len([]rune(cur)) {
matchedVia[ei] = k
}
}
}
sel := memorySelection{activeIDs: map[string]injectionDisposition{}}
hits := map[string]pickedEntry{} // id → picked (exact)
// Iterate entries in their frozen (ORDER BY-stable) order — never map order.
for ei := range b.entries {
via, ok := matchedVia[ei]
if !ok {
continue
}
e := &b.entries[ei]
if blocked := spoilerBlocked(e, chapter); blocked {
sel.rejected = append(sel.rejected, pickedEntry{e, via, memReject})
continue
}
disp := dispositionFor(e, via)
hits[e.id] = pickedEntry{e, via, disp}
sel.activeIDs[e.id] = disp // exact-matched ids feed the next chunk's sticky WITH the disposition they fired with (D16.2)
}
// Sticky scene-inertia: carry prior-chunk exact matches not re-matched here, unless
// the spoiler window blocks them (spoiler beats sticky). The carry INHERITS the prior
// match's disposition (D16.2) — a sticky carry has no firing key, so it cannot re-derive
// the collision-prone downgrade; recomputing status-based would silently upgrade a
// collision-prone AMBIGUOUS match to CONFIRMED, past the post-check, into the editor
// constraints. An AMBIGUOUS carry stays AMBIGUOUS (⟨проверить⟩, excluded from the editor).
for ei := range b.entries {
e := &b.entries[ei]
carryDisp, sticky := stickyPrev[e.id]
if !sticky {
continue
}
if _, already := hits[e.id]; already {
continue
}
if spoilerBlocked(e, chapter) {
continue
}
hits[e.id] = pickedEntry{e, "sticky", carryDisp}
}
// Deterministic priority order for the budget: confirmed>ambiguous, exact>sticky,
// approved>auto. Collect in frozen-entry order (NOT map order) then stable-sort, so
// ties keep the deterministic base order.
order := make([]pickedEntry, 0, len(hits))
for ei := range b.entries {
if p, ok := hits[b.entries[ei].id]; ok {
order = append(order, p)
}
}
sort.SliceStable(order, func(i, j int) bool {
return priorityRank(order[i]) < priorityRank(order[j])
})
if budgetTokens > 0 {
used, cut := 0, len(order)
for i := range order {
used += glossaryLineTokens(order[i].entry)
if used > budgetTokens {
cut = i // this record and everything after it overflow the budget
break
}
}
sel.injected = order[:cut]
sel.evicted = order[cut:] // F2: dropped, but LOGGED via the retrieval-state
} else {
sel.injected = order
}
return sel
}
// spoilerBlocked reports whether the entry's since_ch/until_ch window excludes this
// chapter (C1: a hard reject gate — a fact the current chapter must not know yet).
func spoilerBlocked(e *memoryEntry, chapter int) bool {
if e.sinceCh > 0 && chapter < e.sinceCh {
return true
}
if e.untilCh > 0 && chapter > e.untilCh {
return true
}
return false
}
// dispositionFor maps a term + 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.
// Called ONLY for an EXACT match (a real firing key): a sticky carry INHERITS its prior
// disposition instead (D16.2), never routing through here, so it cannot silently upgrade a
// collision-prone match. The via!="sticky" guard is kept defensive.
func dispositionFor(e *memoryEntry, via string) injectionDisposition {
if via != "sticky" && !e.allowShort && collisionProneKey(via) {
return memAmbiguous
}
if e.status == "approved" {
return memConfirmed
}
return memAmbiguous
}
// priorityRank orders the budget: confirmed before ambiguous, exact before sticky,
// approved before auto/draft (F2 eviction keeps the most trustworthy records).
func priorityRank(p pickedEntry) int {
rank := 0
if p.disp != memConfirmed {
rank |= 1 << 2
}
if p.via == "sticky" {
rank |= 1 << 1
}
if p.entry.status != "approved" {
rank |= 1 << 0
}
return rank
}
// glossaryBlockHeader introduces the injected glossary block. The layout mirrors the
// SakuraLLM/GalTransl convention "src → dst", with unverified (ambiguous) records
// tagged so the model — and a human reviewer — see they are not authoritative (A2).
const glossaryBlockHeader = "ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):"
// renderGlossaryBlock serializes the selected records into the injection message
// (§C). Records with no dst yet (ruby candidates) are skipped — a "src → " line
// carries nothing. Returns "" when nothing renders, so an empty selection injects NO
// message at all ("лучше ничего, чем мусор"). Deterministic: the injected order is the
// budget priority order fixed by Select.
func renderGlossaryBlock(injected []pickedEntry) string {
var lines []string
for _, p := range injected {
if strings.TrimSpace(p.entry.dst) == "" {
continue
}
line := p.entry.src + " → " + p.entry.dst
if p.disp != memConfirmed {
line += " ⟨проверить⟩"
}
lines = append(lines, line)
}
if len(lines) == 0 {
return ""
}
return glossaryBlockHeader + "\n" + strings.Join(lines, "\n")
}
// editorConstraintHeader introduces the monolingual editor's dst-constraint block (D1).
// The editor never sees the source, so it gets ONLY the approved target renderings as
// consistency constraints — it must normalise any divergent rendering in the draft to
// these canonical forms (inflecting for context) and touch nothing else.
const editorConstraintHeader = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):"
// renderEditorConstraintBlock serializes the selected records' CONFIRMED dst renderings into
// the monolingual editor's injection (D1). Unlike renderGlossaryBlock it emits the TARGET
// forms only — no "src → dst" (the editor never sees the source: decisions-log D1). It is
// CONFIRMED-only on purpose: an AMBIGUOUS record is a candidate the translator MAY have
// legitimately rejected, so forcing the editor to rewrite the draft toward it would corrupt
// a correct translation (mirrors the gate's CONFIRMED-only discipline, external-review #1).
// Deduped by dst (an alias and its main entry share a rendering), in the budget priority
// order fixed by Select. Returns "" when nothing CONFIRMED renders → the editor gets NO
// injection (the plain draft-only layout). The block is a subset of the already
// budget-limited memSel.injected, so it needs no separate token budget.
func renderEditorConstraintBlock(injected []pickedEntry) string {
var lines []string
seen := map[string]bool{}
for _, p := range injected {
if p.disp != memConfirmed {
continue
}
dst := strings.TrimSpace(p.entry.dst)
if dst == "" || seen[dst] {
continue
}
seen[dst] = true
lines = append(lines, "- «"+dst+"»")
}
if len(lines) == 0 {
return ""
}
return editorConstraintHeader + "\n" + strings.Join(lines, "\n")
}
// --- Aho-Corasick multi-pattern automaton over runes ----------------------------
type acMatch struct {
keyIdx int
start, end int // rune indices [start, end)
}
type acNode struct {
next map[rune]int
fail int
out []int // key indices whose pattern ends at this node (incl. via fail links)
}
type ahoCorasick struct {
nodes []acNode
keys []string
klen []int // rune length of each key
}
// buildAC constructs the automaton from the (already sorted, unique) normalized keys.
// Deterministic in RESULT regardless of map iteration order: fail links and outputs are
// a function of the key set, not the BFS visit order.
func buildAC(keys []string) *ahoCorasick {
ac := &ahoCorasick{keys: keys, klen: make([]int, len(keys))}
ac.nodes = []acNode{{next: map[rune]int{}}} // root = node 0
for i, k := range keys {
kr := []rune(k)
ac.klen[i] = len(kr)
cur := 0
for _, r := range kr {
nxt, ok := ac.nodes[cur].next[r]
if !ok {
nxt = len(ac.nodes)
ac.nodes = append(ac.nodes, acNode{next: map[rune]int{}})
ac.nodes[cur].next[r] = nxt
}
cur = nxt
}
ac.nodes[cur].out = append(ac.nodes[cur].out, i)
}
// BFS to compute fail links; propagate outputs down fail chains one level (each
// node inherits its fail node's already-complete output set).
var queue []int
for _, c := range ac.nodes[0].next {
ac.nodes[c].fail = 0
queue = append(queue, c)
}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for r, nxt := range ac.nodes[cur].next {
queue = append(queue, nxt)
f := ac.nodes[cur].fail
for f != 0 {
if _, ok := ac.nodes[f].next[r]; ok {
break
}
f = ac.nodes[f].fail
}
if fn, ok := ac.nodes[f].next[r]; ok && fn != nxt {
ac.nodes[nxt].fail = fn
} else {
ac.nodes[nxt].fail = 0
}
ac.nodes[nxt].out = append(ac.nodes[nxt].out, ac.nodes[ac.nodes[nxt].fail].out...)
}
}
return ac
}
// matches returns every key occurrence in text (rune indices), sorted deterministically.
func (ac *ahoCorasick) matches(text []rune) []acMatch {
var out []acMatch
cur := 0
for i, r := range text {
for cur != 0 {
if _, ok := ac.nodes[cur].next[r]; ok {
break
}
cur = ac.nodes[cur].fail
}
if nxt, ok := ac.nodes[cur].next[r]; ok {
cur = nxt
} else {
cur = 0
}
for _, ki := range ac.nodes[cur].out {
out = append(out, acMatch{keyIdx: ki, start: i - ac.klen[ki] + 1, end: i + 1})
}
}
sort.Slice(out, func(a, b int) bool {
if out[a].start != out[b].start {
return out[a].start < out[b].start
}
if out[a].end != out[b].end {
return out[a].end < out[b].end
}
return out[a].keyIdx < out[b].keyIdx
})
return out
}
// suppressContained drops an occurrence fully contained inside a STRICTLY longer one
// (longest-match, whole-entity replacement — A3), where the longer one belongs to a
// spoiler-VALID entry at this chapter. Gating the SUPPRESSOR on spoiler validity closes
// self-review #6: a spoiler-blocked longer key (e.g. 林动的父亲, until_ch=3, read at ch10)
// must not silently drop a valid shorter name nested inside it (林动) — the shorter name
// survives and injects, the longer one is separately spoiler-rejected+logged. Equal-length
// overlaps are both kept (genuine surface ambiguity, surfaced by the injectivity check).
// O(n²) over the few matches in a chunk.
func (b *MemoryBank) suppressContained(ms []acMatch, chapter int) []acMatch {
validSuppressor := func(m acMatch) bool {
for _, ei := range b.keyOwners[b.ac.keys[m.keyIdx]] {
if !spoilerBlocked(&b.entries[ei], chapter) {
return true
}
}
return false
}
var kept []acMatch
for i, m := range ms {
contained := false
for j, o := range ms {
if i == j {
continue
}
if o.start <= m.start && o.end >= m.end && (o.end-o.start) > (m.end-m.start) && validSuppressor(o) {
contained = true
break
}
}
if !contained {
kept = append(kept, m)
}
}
return kept
}
// suppressUnboundedPhonetic drops an occurrence of a SPACED-SCRIPT phonetic key (Latin or
// Cyrillic — scripts that delimit words with whitespace/non-letters, exactly like the
// target-side containsWholeWord) when a neighbouring rune is a letter of the SAME script:
// the key fired INSIDE a longer word ("rose" in "roseanne"/"roses"), not on the entity
// (D16.3). This closes the source-vs-target boundary asymmetry the ≤3 collision-downgrade
// does not (a ≥4 phonetic key fires CONFIRMED with no boundary today). A cross-script
// neighbour (a Latin name abutting a Han char in unspaced source) is a valid boundary, so
// only a SAME-script letter suppresses — mirroring "не-буквенных или чужескриптовых".
// KANA and HAN are deliberately NOT boundary-checked: they have no word segmentation, so a
// letter-boundary rule would false-negative the ubiquitous kana name+particle case (すずきは)
// or contradict the long-key-CONFIRMED contract — their ≥4 precision is deferred to the
// kana-precision measurement + the B6 tokenizer (D16; flagged to the orchestrator). ntext is
// the already-normalized chunk. O(n) over the few matches in a chunk.
func (b *MemoryBank) suppressUnboundedPhonetic(ms []acMatch, ntext []rune) []acMatch {
var kept []acMatch
for _, m := range ms {
script := spacedPhoneticScript(b.ac.keys[m.keyIdx])
if script != nil {
beforeBad := m.start > 0 && letterInScript(ntext[m.start-1], script)
afterBad := m.end < len(ntext) && letterInScript(ntext[m.end], script)
if beforeBad || afterBad {
continue // the key is inside a longer same-script word — not a whole-entity match
}
}
kept = append(kept, m)
}
return kept
}
// spacedPhoneticScript returns the word-delimited alphabetic script a normalized key belongs
// to for the source word-boundary check — unicode.Latin or unicode.Cyrillic — or nil when the
// key carries a Han ideograph or kana (no word segmentation) or mixes the two spaced scripts.
// Digits/punctuation don't set the script but don't disqualify (so "o'brien" is still Latin).
func spacedPhoneticScript(normKey string) *unicode.RangeTable {
var script *unicode.RangeTable
for _, r := range normKey {
switch {
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
return nil // an ideographic/kana anchor is never boundary-checked here
case unicode.In(r, unicode.Latin):
if script == unicode.Cyrillic {
return nil // mixed spaced scripts — do not boundary-check
}
script = unicode.Latin
case unicode.In(r, unicode.Cyrillic):
if script == unicode.Latin {
return nil
}
script = unicode.Cyrillic
}
}
return script
}
// letterInScript reports whether r is a LETTER of the given spaced script — the "same-script
// letter" that breaks a word boundary. A digit, punctuation, space, or a letter of another
// script is a valid boundary and returns false.
func letterInScript(r rune, script *unicode.RangeTable) bool {
return unicode.IsLetter(r) && unicode.In(r, script)
}
// injectivityCollisions reports approved dst collisions (B2): two distinct source
// terms mapped to the SAME dst (one Russian surface for two entities → the reader
// cannot tell them apart), returned as human-readable strings for a load-time warning.
// A pure diagnostic; it does not reject (some collisions are legitimate, e.g. a title
// shared by rank tiers), so the caller logs it, not aborts.
func injectivityCollisions(rows []store.GlossaryEntry) []string {
bySurface := map[string][]string{}
for _, r := range rows {
if r.Status != "approved" || strings.TrimSpace(r.Dst) == "" {
continue
}
key := normalizeTargetForm(r.Dst)
bySurface[key] = append(bySurface[key], r.Src)
}
var out []string
// Deterministic order: iterate a sorted key list, not the map.
keys := make([]string, 0, len(bySurface))
for k := range bySurface {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
srcs := bySurface[k]
if len(distinct(srcs)) > 1 {
sort.Strings(srcs)
out = append(out, "dst "+strconv.Quote(k)+" ← "+strings.Join(distinct(srcs), ", "))
}
}
return out
}
func distinct(ss []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range ss {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
sort.Strings(out)
return out
}