// Package membank is the book's deterministic memory bank: the frozen glossary materialized into a // matcher, the per-chunk retrieval that decides WHICH records a model sees, the injection renderers // that turn them into wire text, and the post-check that verifies the rendering came back. // // The hot path is deterministic by construction — an exact multi-pattern match (Aho-Corasick) over the // normalized chunk, no vectors, no FTS, no LLM, no clock — because the injected block is folded into // request_hash: the same chunk under the same bank must produce the same bytes on resume, or a paid // checkpoint is silently invalidated. Bank content is versioned (Version/BaseVersion) so a glossary // edit is a loud --resnapshot rather than a silent change of what the model was told. // // It owns the seed side of that contract too (loading, validating and colliding-checking the seed // YAML), and holds no run state: it takes rows and text, returns a selection. package membank import ( "crypto/sha256" "encoding/hex" "encoding/json" "maps" "slices" "sort" "strconv" "strings" "unicode" "textmachine/backend/internal/lang" "textmachine/backend/internal/store" "textmachine/backend/internal/text" ) // memory.go: the DETERMINISTIC hot path of the memory bank v2 (registry §Hot-path // Contract / 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: "better nothing than garbage" 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. // matchVersion 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 text.NormVersion(). // 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"). // v4 (D39 layer 4): the longest-match suppressor is now DISPOSITION-GATED (suppressContained) — // a longer but LOWER-trust key (draft/ambiguous) no longer deletes a nested HIGHER-trust key // (approved/confirmed), closing the term-drift code root the D38.5 seed-promote only patched by // data (L3-suppressor-disposition-blind). A refused suppression is recorded loudly (trustGated). // The injection SELECTION changes for a draft-longer-over-approved-shorter chunk (the approved term // now survives), so a loud --resnapshot + golden re-capture (invariant №8). const matchVersion = "memmatch-v4-perlang-minkey+collision-ambiguous+longest-trustgated+spoiler+sticky-inherit-disp+phonetic-srcboundary+budget+postcheck-declaware" // minKeyLenHan / minKeyLenPhonetic are the per-language single-key floors (A3, registry // "min_key_len per-language"). 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) && text.SignificantLen(normKey) <= minKeyLenPhonetic } // UnionSticky merges the recent chunks' exact-matched ids into one sticky_prev, carrying // each id's DISPOSITION (D16.2). When an id fired in several recent chunks, the MOST RECENT // firing wins (win is ordered oldest→newest), so a re-established CONFIRMED match overrides // an older collision-prone AMBIGUOUS one — and vice-versa, never silently upgrading. func UnionSticky(win []map[string]InjectionDisposition) map[string]InjectionDisposition { if len(win) == 0 { return nil } out := map[string]InjectionDisposition{} for _, s := range win { for id, disp := range s { out[id] = disp } } return out } // 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 ( Confirmed InjectionDisposition = "confirmed" // exact key/alias, approved, spoiler-valid → authoritative Ambiguous InjectionDisposition = "ambiguous" // auto/draft term → inject "unverified" + forced post-check Reject 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"` } // entry is one frozen, materialized glossary entry with its matchable keys // pre-normalized. Immutable for a job. type entry 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 gender string // male|female|hidden|"" (C2) — feeds the DC3 gender constraint on the editor block sinceCh int untilCh int // normKeys are the normalized source surfaces (src + aliases) ELIGIBLE to fire // (text.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 } // Bank 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 // (Materialize) before the chunk loop and never mutated. type Bank struct { entries []entry ac *ahoCorasick keyOwners map[string][]int // normalized key → indices of entries that contributed it // enrichedVersion folds ALL foldable rows incl Source:mined — the memory version of the EDIT // wave (edit-wave snapshot). baseVersion EXCLUDES Source:mined — the memory version of the DRAFT wave // (draft-wave snapshot), so a the bank-mining stop bank-enrichment (adding mined rows) moves ONLY edit-wave snapshot, keeping // draft-wave checkpoints valid (WS1 §1в, «re-paid ONCE»). The two are DOMAIN-SEPARATED (a "base-excl- // mined" tag in the base fold), so base ≠ enriched for EVERY row set — even a mined-free book (a // the draft wave job must never content-address to a the edit wave checkpoint). The load-bearing invariant is not // equality but STABILITY: base stays fixed as mined rows are added; only enriched moves. enrichedVersion string baseVersion string // voices/pairs are the pack-19 record types. They are bank CONTENT but never matchable SURFACES, so // they sit beside the automaton rather than inside it: nothing here can put them in keyOwners. voices []store.VoiceProfile pairs []store.AddressPair // stemmer is the target-language decl stemmer (bank-quality §3): it lets the post-check accept an oblique // case of a term against its nominative without the seed listing every form. The zero value is inert (a // target with no decl_suffix registry), so a book that ships none keeps exact-match behaviour. stemmer lang.TargetStemmer } // PickedEntry is one selected record for a chunk with its firing key and disposition. type PickedEntry struct { entry *entry // the bank row (opaque outside the bank) // Via is the normalized key this record fired on. It is EMPTY for a sticky carry — see Sticky, // which is the fact callers actually branch on. Via string // Sticky marks a scene-inertia carry (A5): the record did NOT fire in this chunk, it was carried // from the sticky window. It is a typed field rather than the old via=="sticky" sentinel, which was // indistinguishable from a real key literally spelled "sticky" and made every consumer re-know the // magic string (the post-check skips sticky records, the retrieval-state counts them, the budget // ranks them last). Sticky bool Disp InjectionDisposition // the trust the record was injected with } // valid reports whether this record actually carries a bank row. PickedEntry is exported WITH exported // fields, so a caller outside this package can construct a zero value whose private row is nil; every // consumer here would then nil-deref. Rather than document a landmine, the consumers skip such a record: // a record with no row has nothing to inject and nothing to post-check. Selection always produces rows, so // no real path changes (pack-16 tail). func (p PickedEntry) valid() bool { return p.entry != nil } // TrustGateEvent records a longest-match suppression the DISPOSITION-gate REFUSED (D39 layer 4): // a longer but LOWER-trust key (draft/ambiguous, e.g. draft 四代族长) that would have deleted a nested // HIGHER-trust key (approved/confirmed, e.g. approved 族长). The draft is editor-excluded (the block // is CONFIRMED-only), so suppressing the approved term would have left the reader with NOTHING — the // term-drift code root (L3). The nested higher-trust match survives and injects; this record turns // the near-silent drop into a loud, operator-visible signal (research/13 §7). A non-empty list means // the seed has a draft term nesting over an approved one — reconcile the seed so it stops recurring. type TrustGateEvent struct { Suppressor string `json:"suppressor"` // the longer, lower-trust key blocked from suppressing SuppressorDisp string `json:"suppressor_disp"` // its best injection disposition (ambiguous) Protected string `json:"protected"` // the nested, higher-trust key it would have eaten ProtectedDisp string `json:"protected_disp"` // its best injection disposition (confirmed) } // Selection is the hot path's output for one chunk. type Selection 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) // trustGated are the longest-match suppressions the disposition-gate refused (a lower-trust // longer key would have eaten a higher-trust nested one) — the term-drift code root, surfaced // loudly instead of a silent drop (research/13 §7). Deterministic (ms order, deduped by pair). TrustGated []TrustGateEvent // 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 } // Version is the ENRICHED memory version (all approved rows incl mined) — the edit-wave / whole-book // memory component. BaseVersion excludes Source:mined (the draft-wave component). They are // DOMAIN-SEPARATED — base ≠ enriched for ANY row set, including a mined-free book (never assert // equality) — but base stays STABLE when a the bank-mining stop pass adds mined rows, which is what keeps the draft wave // checkpoints valid («re-paid ONCE»). func (b *Bank) Version() string { return b.enrichedVersion } func (b *Bank) BaseVersion() string { return b.baseVersion } // BankInput is everything a book's bank is materialized and hashed from: the glossary rows plus the two // pack-19 record types. It exists so the fold has ONE argument that can grow, and so the separation that // matters is carried by the type system: only Rows ever reaches the matcher, so a voice profile cannot // become a matchable term by anyone forgetting a filter. type BankInput struct { Rows []store.GlossaryEntry Voices []store.VoiceProfile Pairs []store.AddressPair // InjectVoice reports whether the voice/address rows reach the WIRE on this run. It is the CONDITION // of their fold: while they only feed the $0 flagger (which recomputes every run and self-heals), // hashing them would re-bill a book for authoring a profile that changed no byte the model sees. // The moment they are injected, they must fold — the D39.42 п.3 class, in the other direction. InjectVoice bool // TargetStemmer is the decl-aware post-check stemmer (bank-quality §3). Zero value → inert (exact match // only), so a caller that supplies none keeps the pre-§3 behaviour byte-for-byte. TargetStemmer lang.TargetStemmer } // Materialize builds a Bank from the book's stored glossary rows alone — the pre-pack-19 form, kept for // every caller that has no voice/address content. func Materialize(rows []store.GlossaryEntry, gateOn bool) *Bank { return MaterializeBank(BankInput{Rows: rows}, gateOn) } // MaterializeBank builds a Bank from the book's stored bank content (ORDER BY-stable — GlossaryForBook / // VoiceProfilesForBook / AddressPairsForBook). 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 MaterializeBank(in BankInput, gateOn bool) *Bank { rows := in.Rows b := &Bank{keyOwners: map[string][]int{}, voices: in.Voices, pairs: in.Pairs, stemmer: in.TargetStemmer} var allKeys []string seenKey := map[string]bool{} for _, row := range rows { e := entry{ 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, gender: row.Gender, 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 := text.NormalizeSourceKey(s) if nk == "" { continue } if text.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 := text.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.enrichedVersion = ComputeVersionScopedIn(in, gateOn, false) // all approved incl mined (the edit wave) b.baseVersion = ComputeVersionScopedIn(in, gateOn, true) // excl Source:mined (the draft wave) return b } // Voices returns the book's voice profiles (frozen for the job). Read-only: callers project them onto a // chapter, never mutate them. func (b *Bank) Voices() []store.VoiceProfile { return b.voices } // Pairs returns the book's address-register journal entries (frozen for the job). func (b *Bank) Pairs() []store.AddressPair { return b.pairs } // ComputeVersion 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 ComputeVersion(rows []store.GlossaryEntry, gateOn bool) string { return ComputeVersionScoped(rows, gateOn, false) } // ComputeVersionScoped is ComputeVersion with an explicit Source-scope: excludeMined // drops every Source:"mined" row from the fold (WS1 §1в base-bank-version). The DRAFT-wave snapshot // (draft-wave snapshot) folds base (excludeMined=true) so a the bank-mining stop pass that ADDS mined rows moves ONLY the // enriched (edit-wave) version — keeping draft-wave checkpoints valid, «re-paid ONCE». Deterministic (a // Source filter over the same ORDER BY-stable rows). excludeMined=false is BYTE-IDENTICAL to the // pre-split fold (no extra field), so the enriched hash / existing snapshots do not move; the // excludeMined=true variant adds a domain separator so base and enriched never collide. func ComputeVersionScoped(rows []store.GlossaryEntry, gateOn, excludeMined bool) string { return ComputeVersionScopedIn(BankInput{Rows: rows}, gateOn, excludeMined) } // ComputeVersionScopedIn is ComputeVersionScoped over the whole bank input — the form that also folds the // pack-19 voice/address rows, CONDITIONALLY (BankInput.InjectVoice). // // The condition is the money contract of pack-19. While those rows only feed the $0 flagger they change // no byte the model sees, and a book must not re-pay a wave for authoring a profile; the flagger's // counters live in retrieval_state, which is recomputed every run and self-heals. The moment they are // injected they DO change the wire, and then not folding them would reopen exactly the class D39.42 п.3 // closed — a wire change the snapshot cannot see, so projectRebill projects $0 for a real re-payment. // A book with no voice rows, or a run with the injection off, hashes BYTE-IDENTICALLY to before this // existed: nothing is written at all. func ComputeVersionScopedIn(in BankInput, gateOn, excludeMined bool) string { rows := in.Rows h := sha256.New() h.Write([]byte("tm-memory-v2\x00")) h.Write([]byte(text.NormVersion() + "\x00" + matchVersion + "\x00")) h.Write([]byte("gate:" + strconv.FormatBool(gateOn) + "\x00")) hasUnverified := false hasNeuter := false // a neuter row now RENDERS a directive (bank-quality §3) where it rendered nothing before // inScope collects the characters this fold's ROW scope actually contains, so the voice/address fold // below can apply the SAME scope before deciding anything (see the loop at the end). inScope := map[[2]string]bool{} if excludeMined { h.Write([]byte("base-excl-mined\x00")) // domain separator: base ≠ enriched even over identical rows } for _, r := range rows { // EVERY row folds, whatever its status (pack-20 / D39.42 п.3). Before pack-20 an auto/draft row // was skipped unless the hard gate was on, on the reasoning that its injected content was caught // per chunk by content_hash. That reasoning had a hole the phase-1 sync measured: an unverified // row changes the rendered injection, hence content_hash, hence the resume fast-path — but NOT the // snapshot, and projectRebill projects re-payment from the SNAPSHOT alone, so the chunks it // silently re-translated never reached the Р6 consent contour. Folding every row closes that // class by construction rather than by a second guard: a bank edit of ANY status is now a loud // --resnapshot. A book whose bank is entirely approved hashes byte-identically to before, so // shipping this re-bills nobody who had nothing unverified. // if excludeMined && r.Source == "mined" { continue } if r.Status != "approved" { hasUnverified = true } if r.Gender == "neuter" || r.Gender == "n" { hasNeuter = true } inScope[[2]string{r.Src, r.Sense}] = true // 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) } } // The RENDER revision of the unverified section, folded only for a scope that actually HAS a // non-Confirmed row. Folding every row (above) closes the "which rows exist" hole, but not this one: // pack-20 changed how those rows are RENDERED to the editor (their own labelled section), and for a // book whose post-check gate was already ON the identical row set hashes identically — same version, // same snapshot, different wire bytes. The resume path would then re-translate on the content hash // while projectRebill, which skips a unit whose snapshot is unchanged (rebill.go), projects $0: a // silent re-payment outside the Р6 consent contour, the exact class folding every row was meant to // close. A book with nothing unverified renders no such section and hashes byte-identically to before, // so this re-bills nobody who had nothing to re-render — which a blanket RenderFormatVersion bump, // being un-re-pinnable (it is not a bank-only move), would have done to every book on earth. if hasUnverified { h.Write([]byte("editor-unverified-section-v1\x00")) } // The same scoped-render pattern for the §3 neuter directive: gender:neuter was a SILENT no-op before, // so a book carrying one renders new wire bytes on the same row set — the editor-unverified hole again. // Folded ONLY for a scope that HAS a neuter row, so a bank without one is byte-identical to before and // re-bills nobody; a neuter-bearing book takes a loud, RE-PINNABLE --resnapshot (bank-only move). if hasNeuter { h.Write([]byte("neuter-directive-v1\x00")) } // The pack-19 record types, appended AFTER everything above so a book without them is byte-identical // to the pre-pack-19 hash by construction, not by argument. // // The two loops copy the order the row loop above uses and for the same reason: the SCOPE exclusion is // applied FIRST, and only a row that survived it may set the tag condition. A profile whose character // is not in this scope (its term is Source:mined and this is the base/draft fold) describes somebody // the wave never sees — folding it would move the draft version when a mined-only character's profile // is edited, which is precisely the base/enriched separation the mined filter exists to keep. hasVoice := false for _, v := range in.Voices { if !in.InjectVoice || !inScope[[2]string{v.Src, v.Sense}] { continue } hasVoice = true for _, f := range []string{v.Src, v.Sense, v.Register, v.SelfRef, v.AddressDefault, v.LexiconMarkers, v.NGLexicon, v.Exemplars, v.Brightness, strconv.Itoa(v.SinceCh), strconv.Itoa(v.UntilCh)} { writeField(h, f) } } for _, p := range in.Pairs { if !in.InjectVoice || !inScope[[2]string{p.SpeakerSrc, p.SpeakerSense}] || !inScope[[2]string{p.AddresseeSrc, p.AddresseeSense}] { continue } hasVoice = true for _, f := range []string{p.SpeakerSrc, p.SpeakerSense, p.AddresseeSrc, p.AddresseeSense, p.Register, p.Form, p.Closeness, strconv.Itoa(p.SinceCh), strconv.Itoa(p.UntilCh)} { writeField(h, f) } } if hasVoice { h.Write([]byte("voice-address-v1\x00")) } 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 *entry) int { cjk, other := text.DenseSparseCounts(e.src + " → " + e.dst) // same sizing taxonomy as EstimateTokens 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, "better nothing than garbage"). func (b *Bank) Select(chunk string, chapter int, stickyPrev map[string]InjectionDisposition, budgetTokens int) Selection { // ZERO-VALUE GUARD (pack-16 tail): Bank is an exported type, so `var b membank.Bank` is constructible // outside this package, and its matcher would then be nil — a panic on the first non-empty chunk, in a // PAID run. A bank with no matcher has nothing to match, so the honest answer is an empty selection. // Materialize always builds the matcher, so this can never change the behaviour of a real bank. if b == nil || b.ac == nil { return Selection{} } ntext := []rune(text.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) AND is at least // as TRUSTWORTHY as the nested match it would delete (D39 layer 4 — a lower-trust longer key must // not eat a higher-trust nested one). Refused suppressions are returned for a loud record. var trustGated []TrustGateEvent occ, trustGated = 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 := Selection{ActiveIDs: map[string]InjectionDisposition{}, TrustGated: trustGated} 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{entry: e, Via: via, Disp: Reject}) continue } disp := dispositionFor(e, via) hits[e.id] = PickedEntry{entry: e, Via: via, Disp: 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) { // RECORDED, not dropped (pack-19): a window-blocked carry is a spoiler reject like any other, // and a silent drop makes n_spoiler_blocked under-count. Unreachable through the production // driver — precomputeSticky resets the window at every chapter boundary, so a carry always // arrives at the SAME chapter it fired in and cannot have become blocked — but Select is // exported and the invariant that protects it lives in another package. sel.Rejected = append(sel.Rejected, PickedEntry{entry: e, Sticky: true, Disp: Reject}) continue } hits[e.id] = PickedEntry{entry: e, Sticky: true, Disp: 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 *entry, 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. func dispositionFor(e *entry, via string) InjectionDisposition { if !e.allowShort && collisionProneKey(via) { return Ambiguous } if e.status == "approved" { return Confirmed } return Ambiguous } // 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 != Confirmed { rank |= 1 << 2 } if p.Sticky { rank |= 1 << 1 } if !p.valid() { return rank | 1<<0 // a row-less record ranks with the unapproved; it is skipped before rendering } if p.entry.status != "approved" { rank |= 1 << 0 } return rank } // 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 ("better nothing than garbage"). Deterministic: the injected order is the // budget priority order fixed by Select. tx is the TARGET-language wire-text (pair-14 §2): a // target with no injection texts (HasData()==false) injects nothing (a non-ru book gets no // Russian block), so the whole render is gated on it. func RenderGlossaryBlock(injected []PickedEntry, tx lang.InjectionTexts) string { if !tx.HasData() { return "" } var lines []string for _, p := range injected { if !p.valid() || strings.TrimSpace(p.entry.dst) == "" { continue } line := p.entry.src + " → " + p.entry.dst if p.Disp != Confirmed { line += tx.UnverifiedMarker } else { // pack-13 injection-completeness fix (D39.21 owner directive: «род должен доезжать»): the // gender of a CONFIRMED named term (蛊 Надежды = ж.р., a cicada gu, …) now reaches the DRAFT // wire too, not only the editor — so the TRANSLATOR renders the right родовые формы FIRST, // instead of leaving the editor to repair a wrong gender. "" for a genderless term (the common // case → byte-identical). Mirrors the editor block's confirmed-only gender (RenderEditorConstraintBlock). line += genderConstraintNote(p.entry.gender, tx) } lines = append(lines, line) } if len(lines) == 0 { return "" } return tx.GlossaryHeader + "\n" + strings.Join(lines, "\n") } // RenderFormatVersion versions the FORMAT of the role-injection renderers whose output is NOT // captured by matchVersion (a scope/matcher-ALGORITHM version, not a render-layout version): // the editor constraint block's src→dst layout (WS2 §2в) and the DC3 gender-constraint annotation // (WS5 §5(б)). A format change shifts the injected bytes → the wire, so it must be a loud --resnapshot; // folding it as a dedicated snapshot component (snapshot.go) keeps that loudness a MECHANISM, not // discipline — matchVersion (const memory.go) would not move on a render edit. // v2 (WS5 R4): the editor constraint line now carries a gender annotation for a gendered CONFIRMED // term (DC3 injection — the Bai Ninbing fix: the injection DIRECTS the editor, no coreference needed). // v3 (pack-13, D39.21 injection-completeness fix): the DRAFT glossary block (RenderGlossaryBlock) now // ALSO appends the confirmed-gender annotation, so a named term's gender reaches the TRANSLATOR wire, not // only the editor (the owner's «род должен доезжать» directive). A gendered confirmed term shifts the // draft injected bytes → the wire, so a loud --resnapshot; a genderless bank is byte-identical to v2. // (The bank-quality §3 neuter directive is NOT versioned here: a blanket bump is un-re-pinnable and would // re-snapshot every book on earth. It is folded SCOPED — like the editor-unverified section — so only a // book that actually carries a neuter row moves; see the neuter-directive tag in ComputeVersionScopedIn.) const RenderFormatVersion = "renderfmt-v3-draft-gender+editor-src2dst+dc3-gender" // The editor's canonical-constraint block header and the glossary header are TARGET-language wire-text // (lang.InjectionTexts, pair-14 §2) — they gave the BILINGUAL editor (D30.1) the approved src→dst bindings // as consistency constraints. Relocated out of the pipeline (a Russian header rendered for a →en book was a // leak); now gated on the target and sourced from embedded per-target data. // RenderEditorConstraintBlock serializes the selected records' CONFIRMED renderings into the // editor's injection as a src→dst MAPPING (WS2 §2в resolves the D30.1-open question): "源термин → // «dst»", like the translator block, NOT bare canonical Russian. Rationale (the single load-bearing // one, review-1 F4): the editor is BILINGUAL (editor.md feeds it the source), and binding the canon // to its SOURCE term disambiguates HOMONYMIC dst (one Russian surface for two entities) — bare // forms cannot. It stays CONFIRMED-only on purpose: an AMBIGUOUS record is a candidate the // translator MAY have legitimately rejected, so forcing the editor to rewrite toward it would // corrupt a correct translation (mirrors the gate's CONFIRMED-only discipline, external-review #1); // AMBIGUOUS/mined-draft rows never enter here. Deduped by (src,dst) — an alias and its main entry // share the pair — which only WIDENS the list (harmless), in the budget priority order fixed by // Select. Returns "" when nothing CONFIRMED renders → the editor gets NO injection (plain draft-only // layout). The block is a subset of the already budget-limited memSel.Injected, so it needs no // separate token budget. The src→dst FORMAT is snapshot-folded via RenderFormatVersion (a format // edit is a loud --resnapshot); the injection is a message, so it also enters request_hash // directly (no silent false-hit). // PACK-20 EXTENSION (D39.42 п.3). In the auto mode the bank legitimately holds renderings nobody has // signed — the terminologist's consolidation over the whole book — and the owner's decision is that those // reach the editor «С ПОМЕТКОЙ». They are rendered as a SECOND, separately headed section, never mixed // into the canon list: the canon is binding ("bring any divergence to this form"), the unverified section // is a working version the editor is explicitly free to reject. Mixing them would either forfeit the // canon's force or give an unsigned guess that force — the exact corruption the CONFIRMED-only rule was // protecting against, which is why the rule is preserved rather than relaxed. // // A book with no non-Confirmed rows renders the second section as "" and is byte-identical to before. func RenderEditorConstraintBlock(injected []PickedEntry, tx lang.InjectionTexts) string { if !tx.HasData() { return "" } canon := editorLines(injected, tx, true) if len(canon) == 0 && tx.EditorUnverifiedHeader == "" { return "" } var b strings.Builder if len(canon) > 0 { b.WriteString(tx.EditorHeader + "\n" + strings.Join(canon, "\n")) } if unverified := editorLines(injected, tx, false); len(unverified) > 0 && tx.EditorUnverifiedHeader != "" { if b.Len() > 0 { b.WriteString("\n\n") } b.WriteString(tx.EditorUnverifiedHeader + "\n" + strings.Join(unverified, "\n")) } return b.String() } // editorLines renders one section of the editor block: the SIGNED canon (signed=true) or the unverified // working versions (signed=false). Deduped by (src,dst) within the section, in the budget priority order // Select fixed. The gender directive stays CONFIRMED-only — an unsigned row's gender is as unverified as // its rendering, and directing the editor with it would smuggle an unverified datum in as an instruction. // // The section follows PROVENANCE (status), not the injection disposition, and the two answer different // questions (cold-run session, backlog 19 — the sibling of the banknote src rule on the same axis). // dispositionFor downgrades an APPROVED row to AMBIGUOUS when its firing key is collision-prone — short // and purely phonetic (memory.go:644) — which says "this key may have fired inside an unrelated word", // a statement about the MATCH. Routing on it put an owner-SIGNED rendering under the header that says // nobody has confirmed this, a statement about the AUTHORITY. On zh the branch cannot fire (Han keys // anchor at 2), so the misrouting was invisible; on a phonetic source it would hit exactly the short // signed names. Ambiguous still does its own job: the forced post-check and the ⟨проверить⟩ marker in the // TRANSLATOR block are unchanged. func editorLines(injected []PickedEntry, tx lang.InjectionTexts, signed bool) []string { var lines []string seen := map[[2]string]bool{} for _, p := range injected { if !p.valid() || (p.entry.status == "approved") != signed { continue } src := strings.TrimSpace(p.entry.src) dst := strings.TrimSpace(p.entry.dst) if dst == "" { continue } key := [2]string{src, dst} if seen[key] { continue } seen[key] = true line := "- " + src + " → «" + dst + "»" if signed { // The gender directive still keys on the MATCH being confirmed: on a collision-prone firing // the row may not be about the entity on the page, and a gender instruction is the one part of // the line that would then be actively wrong rather than merely redundant. if p.Disp == Confirmed { line += genderConstraintNote(p.entry.gender, tx) } } else { line += tx.UnverifiedMarker } lines = append(lines, line) } return lines } // genderConstraintNote is the DC3 gender directive appended to a CONFIRMED editor-constraint line (WS5 // §5(б)): the injection DIRECTS the editor to the character's grammatical gender, which is the fix for // the Bai Ninbing class — no coreference needed. male/female ⇒ hard gender forms; hidden ⇒ a mandate to // AVOID gender-marking constructions until the reveal (a masculine default when unavoidable, D19.3). "" // for a term with no gender datum (the common case → the line is unchanged, byte-identical to before). func genderConstraintNote(gender string, tx lang.InjectionTexts) string { switch gender { case "male", "m": return tx.GenderMale case "female", "f": return tx.GenderFemale case "neuter", "n": return tx.GenderNeuter case "hidden": return tx.GenderHidden } return "" } // --- 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 } // trustRank orders injection dispositions by how much the reader may TRUST the record: CONFIRMED // (approved, authoritative) outranks AMBIGUOUS (draft/auto/collision-prone), which outranks REJECT // (spoiler-blocked / unset). It decides the longest-match containment contest below. func trustRank(d InjectionDisposition) int { switch d { case Confirmed: return 2 case Ambiguous: return 1 default: // Reject / unset return 0 } } // matchTrust returns the BEST (highest-trust) disposition an occurrence of the given key could // inject with at this chapter — the max over its spoiler-VALID owner entries — and whether ANY // valid owner exists (this second value subsumes the old validSuppressor: no valid owner ⇒ the // occurrence injects nothing). CONFIRMED beats AMBIGUOUS. It is the trust the disposition-gate // decides a containment contest on (D39 layer 4). func (b *Bank) matchTrust(m acMatch, chapter int) (disp InjectionDisposition, hasValid bool) { key := b.ac.keys[m.keyIdx] for _, ei := range b.keyOwners[key] { e := &b.entries[ei] if spoilerBlocked(e, chapter) { continue } if d := dispositionFor(e, key); !hasValid || trustRank(d) > trustRank(disp) { disp, hasValid = d, true } } return disp, hasValid } // suppressContained applies longest-match / whole-entity replacement (A3), DISPOSITION-GATED (D39 // layer 4). It drops an occurrence fully contained inside a STRICTLY longer one, but ONLY when the // longer one is BOTH (a) owned by a spoiler-VALID entry at this chapter (self-review #6: a spoiler- // blocked longer key like 林动的父亲, until_ch=3, read at ch10, must not eat a valid nested 林动) AND // (b) at least as TRUSTWORTHY as the nested match it would delete. A longer but LOWER-trust key // (a draft/ambiguous 四代族长) must NOT suppress a nested HIGHER-trust key (an approved 族长): the draft // is editor-excluded (RenderEditorConstraintBlock is CONFIRMED-only), so eating the approved term // leaves the reader with nothing AND the drop is invisible to post-check/retrieval-state — the exact // term-drift code root the D38.5 seed-promote only patched by data (L3-recurrence). The whole-entity // longest-match is preserved when the longer key is ≥ trust (approved 四代族长 still beats approved // 族长; draft 四代族长 still eats a nested draft 族长). Each REFUSED suppression is recorded as a // TrustGateEvent so the near-silent drop is surfaced loudly (research/13 §7). Equal-length overlaps // are both kept (genuine surface ambiguity). Deterministic (ms is sorted; events deduped by pair in // ms order). O(n²) over the few matches in a chunk. func (b *Bank) suppressContained(ms []acMatch, chapter int) ([]acMatch, []TrustGateEvent) { var kept []acMatch var gated []TrustGateEvent for i, m := range ms { conDisp, conValid := b.matchTrust(m, chapter) contained := false var refused *TrustGateEvent // a lower-trust valid longer key that WANTED to suppress m for j, o := range ms { if i == j { continue } // o must STRICTLY contain m's span to be a longest-match suppressor. if !(o.start <= m.start && o.end >= m.end && (o.end-o.start) > (m.end-m.start)) { continue } supDisp, supValid := b.matchTrust(o, chapter) if !supValid { continue // spoiler-blocked longer key — never a valid suppressor (self-review #6) } if !conValid || trustRank(supDisp) >= trustRank(conDisp) { contained = true // a valid, ≥-trust container wins the whole-entity longest-match break } // supValid && conValid && supDisp < conDisp: a lower-trust longer key wants to eat a // higher-trust nested one — REFUSE (the L3 fix). Remember the FIRST such refusal for the // loud record; keep scanning in case a higher-trust container legitimately suppresses m. if refused == nil { refused = &TrustGateEvent{ Suppressor: b.ac.keys[o.keyIdx], SuppressorDisp: string(supDisp), Protected: b.ac.keys[m.keyIdx], ProtectedDisp: string(conDisp), } } } if contained { continue // m suppressed by the whole-entity longest-match; any refusal above was moot } kept = append(kept, m) if refused != nil { gated = append(gated, *refused) // m survived AND a lower-trust key tried to eat it — loud } } return kept, dedupeTrustGate(gated) } // dedupeTrustGate collapses identical (suppressor→protected) events to a distinct set, preserving // first-occurrence (ms) order so the count and detail are deterministic and count distinct term // collisions rather than raw occurrences. func dedupeTrustGate(evs []TrustGateEvent) []TrustGateEvent { if len(evs) < 2 { return evs } seen := map[[2]string]bool{} var out []TrustGateEvent for _, e := range evs { k := [2]string{e.Suppressor, e.Protected} if seen[k] { continue } seen[k] = true out = append(out, e) } return out } // 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 "non-letter or foreign-script". // 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 *Bank) 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 text.DenseScript(r): 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 := text.NormalizeTargetForm(r.Dst) bySurface[key] = append(bySurface[key], r.Src) } var out []string // Deterministic order: iterate a sorted key list, not the map. keys := slices.Sorted(maps.Keys(bySurface)) 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 }