458 lines
19 KiB
Go
458 lines
19 KiB
Go
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 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
|
||
}
|
||
|
||
// 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
|
||
}
|