package membank import ( "encoding/json" "fmt" "sort" "strings" "textmachine/backend/internal/seed" "textmachine/backend/internal/store" "textmachine/backend/internal/text" ) // memvoice.go: the two D21 bank record types pack-19 added — the per-character VOICE profile and the // ordered ADDRESS pair — from seed file to store rows, plus the projection the flagger consumes. // // They are bank content and follow the bank's rules: curated only (no miner emits them), validated // fail-loud at load (a silently inert profile is the class this bank exists to close), windowed on the // SAME chapter axis as the spoiler window, and folded into the same memory_version. // // What they are NOT: matchable terms. A profile is keyed BY a character, it is not a surface to find in // the text — so they never enter the Aho-Corasick automaton or the glossary injection. That separation // is carried by the Go type system (BankInput.Rows is the only field Materialize matches over), not by a // runtime filter that a later edit could forget. // registerInformal / registerFormal are the ABSTRACT T/V vocabulary. They are typological category // names, not target words: which surfaces realise them is target data, so a target with no T/V // distinction ships no surfaces and the whole mechanism stays inert. const ( registerInformal = "informal" registerFormal = "formal" ) func validRegister(s string) bool { return s == registerInformal || s == registerFormal } // BankSeed is one parsed seed file: the terms plus the two pack-19 record types. type BankSeed struct { Terms []store.GlossaryEntry Voices []store.VoiceProfile Pairs []store.AddressPair } // LoadGlossarySeed parses the TERMS of a seed file — the pre-pack-19 entry point, kept for every caller // that has no use for the other two record types. func LoadGlossarySeed(path string) ([]store.GlossaryEntry, error) { s, err := LoadBankSeed(path) if err != nil { return nil, err } return s.Terms, nil } // loadVoiceSections validates and materializes the voices:/addresses: sections of a parsed seed file. // Fail-loud on anything that would be silently inert or would crash the UNIQUE constraint mid-run, which // is the same contract the terms half keeps. func loadVoiceSections(path string, sf *seed.File) ([]store.VoiceProfile, []store.AddressPair, error) { var problems []string var voices []store.VoiceProfile voiceKeys := map[[4]string]bool{} for i, v := range sf.Voices { src, sense := strings.TrimSpace(v.Src), strings.TrimSpace(v.Sense) if src == "" { problems = append(problems, fmt.Sprintf("voice %d: src is required (it names the glossary term this profile belongs to)", i)) continue } if d := strings.TrimSpace(v.AddressDefault); d != "" && !validRegister(d) { problems = append(problems, fmt.Sprintf("voice %q: address_default must be %s|%s, got %q", src, registerInformal, registerFormal, d)) continue } k := [4]string{src, sense, fmt.Sprint(v.SinceCh), fmt.Sprint(v.UntilCh)} if voiceKeys[k] { problems = append(problems, fmt.Sprintf("voice %q: duplicate (src, sense=%q, since_ch=%d, until_ch=%d) — a character has one profile per window", src, sense, v.SinceCh, v.UntilCh)) continue } voiceKeys[k] = true markers, err := cleanList(v.LexiconMarkers, "voice "+src+" lexicon_markers") if err != nil { problems = append(problems, err.Error()) continue } ng, err := cleanList(v.NGLexicon, "voice "+src+" ng_lexicon") if err != nil { problems = append(problems, err.Error()) continue } ex, err := cleanList(v.Exemplars, "voice "+src+" exemplars") if err != nil { problems = append(problems, err.Error()) continue } // A profile that states NOTHING is an authoring slip: it costs a bank row and a hash, and directs // nothing. Refused for the same reason an empty langpack table is (never silently empty). if strings.TrimSpace(v.Register) == "" && strings.TrimSpace(v.SelfRef) == "" && strings.TrimSpace(v.AddressDefault) == "" && len(markers) == 0 && len(ng) == 0 && len(ex) == 0 { problems = append(problems, fmt.Sprintf("voice %q: the profile states nothing (needs at least one of register|self_ref|address_default|lexicon_markers|ng_lexicon|exemplars)", src)) continue } voices = append(voices, store.VoiceProfile{ Src: src, Sense: sense, Register: strings.TrimSpace(v.Register), SelfRef: strings.TrimSpace(v.SelfRef), AddressDefault: strings.TrimSpace(v.AddressDefault), LexiconMarkers: encodeList(markers), NGLexicon: encodeList(ng), Exemplars: encodeList(ex), Brightness: strings.TrimSpace(v.Brightness), SinceCh: v.SinceCh, UntilCh: v.UntilCh, }) } // Two profiles of one character valid at the same chapter: the projection would have to pick, and it // has no basis to. Same rule, same reason, as two renderings of one term in one chapter. for i := range voices { for j := 0; j < i; j++ { a, b := voices[i], voices[j] if a.Src == b.Src && a.Sense == b.Sense && windowsOverlap(a.SinceCh, a.UntilCh, b.SinceCh, b.UntilCh) { problems = append(problems, fmt.Sprintf("voice %q: windows [%d,%d] and [%d,%d] overlap — a character has ONE profile per chapter", a.Src, b.SinceCh, b.UntilCh, a.SinceCh, a.UntilCh)) } } } var pairs []store.AddressPair pairKeys := map[[6]string]bool{} for i, p := range sf.Addresses { sp, spSense := strings.TrimSpace(p.Speaker), strings.TrimSpace(p.SpeakerSense) ad, adSense := strings.TrimSpace(p.Addressee), strings.TrimSpace(p.AddresseeSense) if sp == "" || ad == "" { problems = append(problems, fmt.Sprintf("address %d: speaker and addressee are both required", i)) continue } if sp == ad && spSense == adSense { problems = append(problems, fmt.Sprintf("address %q: speaker and addressee are the same character — a register is a fact about a PAIR", sp)) continue } if !validRegister(strings.TrimSpace(p.Register)) { problems = append(problems, fmt.Sprintf("address %q→%q: register must be %s|%s, got %q (the value is a typological category, never a target word)", sp, ad, registerInformal, registerFormal, p.Register)) continue } k := [6]string{sp, spSense, ad, adSense, fmt.Sprint(p.SinceCh), fmt.Sprint(p.UntilCh)} if pairKeys[k] { problems = append(problems, fmt.Sprintf("address %q→%q: duplicate (speaker, addressee, since_ch=%d, until_ch=%d)", sp, ad, p.SinceCh, p.UntilCh)) continue } pairKeys[k] = true pairs = append(pairs, store.AddressPair{ SpeakerSrc: sp, SpeakerSense: spSense, AddresseeSrc: ad, AddresseeSense: adSense, Register: strings.TrimSpace(p.Register), Form: strings.TrimSpace(p.Form), Closeness: strings.TrimSpace(p.Closeness), SinceCh: p.SinceCh, UntilCh: p.UntilCh, }) } // Contradictory registers for one ordered pair at one chapter. A ты↔вы switch is legitimate — as a // SECOND row with a non-overlapping window, which is what makes the table a journal. for i := range pairs { for j := 0; j < i; j++ { a, b := pairs[i], pairs[j] if a.SpeakerSrc != b.SpeakerSrc || a.SpeakerSense != b.SpeakerSense || a.AddresseeSrc != b.AddresseeSrc || a.AddresseeSense != b.AddresseeSense { continue } if a.Register != b.Register && windowsOverlap(a.SinceCh, a.UntilCh, b.SinceCh, b.UntilCh) { problems = append(problems, fmt.Sprintf("address %q→%q: registers %q [%d,%d] and %q [%d,%d] overlap — one pair has ONE register per chapter (a switch is a new window, not an overlap)", a.SpeakerSrc, a.AddresseeSrc, b.Register, b.SinceCh, b.UntilCh, a.Register, a.SinceCh, a.UntilCh)) } } } if len(problems) > 0 { return nil, nil, fmt.Errorf("glossary seed %s:\n - %s", path, strings.Join(problems, "\n - ")) } return voices, pairs, nil } // cleanList trims a seed string list and refuses an empty member: a blank exemplar or marker is an // authoring slip that would inject an empty line and count toward a budget. func cleanList(in []string, what string) ([]string, error) { var out []string for _, s := range in { t := strings.TrimSpace(s) if t == "" { return nil, fmt.Errorf("%s: an entry is empty", what) } out = append(out, t) } return out, nil } // encodeList renders a string list as the JSON stored in a column. Empty → "" (not "null"/"[]"), so an // unstated field costs no bytes in the row and none in the hash. func encodeList(in []string) string { if len(in) == 0 { return "" } b, err := json.Marshal(in) if err != nil { // a []string cannot fail to marshal return "" } return string(b) } // decodeList reads a stored JSON list back. A malformed blob yields nil rather than an error: the column // is written by encodeList alone, so a bad value is impossible without direct DB surgery, and the // flagger's honest answer to "no data" is to check nothing. func decodeList(s string) []string { if strings.TrimSpace(s) == "" { return nil } var out []string if json.Unmarshal([]byte(s), &out) != nil { return nil } return out } // UnknownVoiceCharacters reports voice/address rows whose (src, sense) names no term in the bank. Such a // row is silently inert — the character has no target surfaces, so nothing can ever attribute a reply to // it — which is exactly the A-class hole the bank exists to close, so the caller fails the run loud. // Checked over the FULL entry set (seed + ruby + mined + auto), because a character may legitimately be // signed in a delta rather than the base seed. Deterministic (input order). func UnknownVoiceCharacters(entries []store.GlossaryEntry, voices []store.VoiceProfile, pairs []store.AddressPair) []string { known := map[[2]string]bool{} for _, e := range entries { known[[2]string{e.Src, e.Sense}] = true } var out []string seen := map[string]bool{} add := func(msg string) { if !seen[msg] { seen[msg] = true out = append(out, msg) } } for _, v := range voices { if !known[[2]string{v.Src, v.Sense}] { add(fmt.Sprintf("voice profile %q (sense %q) names no term in the bank", v.Src, v.Sense)) } } for _, p := range pairs { if !known[[2]string{p.SpeakerSrc, p.SpeakerSense}] { add(fmt.Sprintf("address speaker %q (sense %q) names no term in the bank", p.SpeakerSrc, p.SpeakerSense)) } if !known[[2]string{p.AddresseeSrc, p.AddresseeSense}] { add(fmt.Sprintf("address addressee %q (sense %q) names no term in the bank", p.AddresseeSrc, p.AddresseeSense)) } } return out } // VoiceWindowGaps reports a character whose profiles leave a chapter UNCOVERED between two windows // (…until_ch=10 then since_ch=20: chapters 11–19 have no voice at all). Not an error — a deliberate gap // is legitimate — but it is invisible in a seed file and is far more often a typo, so the caller logs it. // The optional lint D39.55 asked for, applied where the data actually is. func VoiceWindowGaps(voices []store.VoiceProfile) []string { byChar := map[[2]string][]store.VoiceProfile{} var order [][2]string for _, v := range voices { k := [2]string{v.Src, v.Sense} if _, ok := byChar[k]; !ok { order = append(order, k) } byChar[k] = append(byChar[k], v) } var out []string for _, k := range order { win := byChar[k] if len(win) < 2 { continue } sort.Slice(win, func(i, j int) bool { return win[i].SinceCh < win[j].SinceCh }) for i := 1; i < len(win); i++ { prevUntil := win[i-1].UntilCh if prevUntil == 0 { // open-ended: nothing after it can be a gap continue } if win[i].SinceCh > prevUntil+1 { out = append(out, fmt.Sprintf("voice %q: chapters %d–%d have no profile (window ends at %d, next starts at %d)", k[0], prevUntil+1, win[i].SinceCh-1, prevUntil, win[i].SinceCh)) } } } return out } // CharacterView is one character as an OUTPUT-side checker needs to see it: an opaque identity, the // normalized target surfaces that name it, and the two profile fields the deterministic checks read. It // is deliberately free of store and checker types — membank owns the projection, the driver owns the // adaptation — so neither package has to know the other's vocabulary. type CharacterView struct { Key string // src\x1fsense — compared, never rendered into a message Name string // the approved rendering, for a human-readable detail line Forms []string SelfRef string NG []string } // AddressView is one ordered register record projected onto a chapter. type AddressView struct { Speaker, Addressee, Register string // Speaker/Addressee are CharacterView.Key } // VoiceProjection resolves the bank's voice/address content ONTO one chapter: the profiles and register // records whose window contains it, joined to the terms that give each character its target surfaces. // // It is a PROJECTION, never stored state — which is what keeps address_pairs a journal with one source // of truth (D21 п.2) instead of a second store that could disagree with it. A character whose term is // spoiler-blocked at this chapter is dropped entirely: it cannot be matched in the output, so a profile // for it could only produce noise. Pure and deterministic (bank order). func (b *Bank) VoiceProjection(chapter int) ([]CharacterView, []AddressView) { if b == nil { return nil, nil } // The character's surfaces come from the term row valid at this chapter (the same window rule the // injection uses), so a pre/post-reveal handoff projects the rendering the chapter is allowed to see. forms := func(src, sense string) (view CharacterView, ok bool) { for i := range b.entries { e := &b.entries[i] if e.src != src || e.sense != sense || spoilerBlocked(e, chapter) { continue } base := text.NormalizeTargetForm(e.dst) if base == "" { continue } view.Key = src + "\x1f" + sense view.Name = e.dst view.Forms = append([]string{base}, e.declForms...) return view, true } return view, false } byKey := map[string]*CharacterView{} var chars []CharacterView order := []string{} need := func(src, sense string) *CharacterView { key := src + "\x1f" + sense if v, ok := byKey[key]; ok { return v } v, ok := forms(src, sense) if !ok { byKey[key] = nil return nil } byKey[key] = &v order = append(order, key) return &v } for _, vp := range b.voices { if vp.SinceCh > 0 && chapter < vp.SinceCh { continue } if vp.UntilCh > 0 && chapter > vp.UntilCh { continue } v := need(vp.Src, vp.Sense) if v == nil { continue } v.SelfRef = text.NormalizeTargetForm(vp.SelfRef) for _, w := range decodeList(vp.NGLexicon) { if n := text.NormalizeTargetForm(w); n != "" { v.NG = append(v.NG, n) } } } var addrs []AddressView for _, p := range b.pairs { if p.SinceCh > 0 && chapter < p.SinceCh { continue } if p.UntilCh > 0 && chapter > p.UntilCh { continue } sp, ad := need(p.SpeakerSrc, p.SpeakerSense), need(p.AddresseeSrc, p.AddresseeSense) if sp == nil || ad == nil { continue } addrs = append(addrs, AddressView{Speaker: sp.Key, Addressee: ad.Key, Register: p.Register}) } for _, k := range order { chars = append(chars, *byKey[k]) } return chars, addrs }