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. const memoryMatchVersion = "memmatch-v2-perlang-minkey+collision-ambiguous+longest+spoiler+sticky+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 map[string]bool // exact-matched ids (NOT sticky) → the next chunk's sticky_prev } 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]bool, budgetTokens int) memorySelection { ntext := []rune(normalizeSourceKey(chunk)) occ := b.ac.matches(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]bool{}} 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 } 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) } // Sticky scene-inertia: carry prior-chunk exact matches not re-matched here, unless // the spoiler window blocks them (spoiler beats sticky). for ei := range b.entries { e := &b.entries[ei] if !stickyPrev[e.id] { continue } if _, already := hits[e.id]; already { continue } if spoilerBlocked(e, chapter) { continue } hits[e.id] = pickedEntry{e, "sticky", dispositionFor(e, "sticky")} } // 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. // 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 } 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") } // --- 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 } // 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 }