textmachine/backend/internal/membank/memvoice.go

458 lines
19 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
// Dropped names the `src` of every row this loader REMOVED instead of refusing the document — an
// engine-written document only (ParseEngineBankSeed); for an operator's seed it is always empty,
// because their document is refused whole and nothing is silently mended.
//
// ⛔ IT EXISTS SO THAT A DROP IS NOT SILENT. Trading a loud death for a quiet disappearance would be
// the worse half of the fix: a term the model mined vanishes from every request, and an operator
// asking why has nothing to read. The loader has no logger and should not grow one, so it REPORTS
// and the caller with the log speaks (mining.go).
Dropped []string
}
// 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
}
// LoadEngineGlossarySeed is LoadGlossarySeed for a document THIS ENGINE WROTE — the mined delta and the
// auto-bank. The difference is one rule, and it is about whose artifact it is: see
// membank.ParseEngineBankSeed.
//
// ⚠ IT RETURNS WHAT IT DROPPED, and a caller that throws that away makes the drop silent — which is the
// half of this fix that would be worth less than the defect it replaced (BankSeed.Dropped).
func LoadEngineGlossarySeed(path string) (terms []store.GlossaryEntry, dropped []string, err error) {
s, err := LoadEngineBankSeed(path)
if err != nil {
return nil, nil, err
}
return s.Terms, s.Dropped, 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 problemList
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.addKeyed("voice: src is required", 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.add(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.add(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.add(err.Error())
continue
}
ng, err := cleanList(v.NGLexicon, "voice "+src+" ng_lexicon")
if err != nil {
problems.add(err.Error())
continue
}
ex, err := cleanList(v.Exemplars, "voice "+src+" exemplars")
if err != nil {
problems.add(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.add(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.add(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.addKeyed("address: speaker and addressee are both required", fmt.Sprintf("address %d: speaker and addressee are both required", i))
continue
}
if sp == ad && spSense == adSense {
problems.add(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.add(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.add(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.add(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, SeedProblems{Path: path, Problems: problems}
}
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
}
// TermWindowGaps reports a bank SURFACE whose rows leave a chapter uncovered between two windows — a term
// signed «until chapter 3» whose next row starts at chapter 5, so chapter 4 has no law for it at all.
//
// ⛔ WHY IT EXISTS WHEN THE LOADER ALREADY CHECKS WINDOWS. The loader refuses an OVERLAP (two rows claiming
// one chapter) and polysemy collisions; the opposite shape — a HOLE — passes every check there is. It is
// not a schema violation and it cannot be: the UNIQUE key admits the two rows, membank.windowsOverlap
// permits them, and the spoiler rule then picks neither. The term simply stops being law for those
// chapters, the editor is shown nothing about it, and the drafts of chapter 4 render it however they like —
// inside a book whose whole point is one word per term.
//
// ⚠ IT REPORTS AND NEVER REFUSES, and the guarantee is taken from the exemplar next to it rather than
// improvised: VoiceWindowGaps says a deliberate gap is legitimate, and the same is true here — a term that
// genuinely does not apply between two reveals is a real editorial shape. The difference is that a gap is
// invisible in a seed file and is far more often a typo, so it is said out loud and left to the owner.
//
// ⚠ AND IT IS KEYED ON (src, sense), NOT ON src ALONE. Two senses of one surface are two laws by design
// (the A3 disambiguator is part of the uniqueness key), so comparing their windows against each other would
// report a gap for every polysemous term in the book — a warning that fires on correct data is one an
// operator learns to ignore, which costs more than the check earns.
//
// Deterministic: surfaces are reported in first-seen order and the rows of one surface are sorted by the
// chapter their window opens at; nothing here iterates a map for output.
func TermWindowGaps(rows []store.GlossaryEntry) []string {
byTerm := map[[2]string][]store.GlossaryEntry{}
var order [][2]string
for _, e := range rows {
if strings.TrimSpace(e.Src) == "" {
continue
}
k := [2]string{e.Src, e.Sense}
if _, seen := byTerm[k]; !seen {
order = append(order, k)
}
byTerm[k] = append(byTerm[k], e)
}
var out []string
for _, k := range order {
win := byTerm[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 {
continue // open-ended: nothing after it can be a gap
}
if win[i].SinceCh > prevUntil+1 {
out = append(out, fmt.Sprintf("term %q (sense %q): chapters %d%d have no bank row (window ends at %d, next starts at %d)",
k[0], k[1], prevUntil+1, win[i].SinceCh-1, prevUntil, win[i].SinceCh))
}
}
}
return out
}