textmachine/backend/internal/membank/memvoice.go

379 lines
15 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 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 1119 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
}