feca776077
Two defects in the deck, both of which reach him as a broken answer.
An optional placeholder had no rule. net_empty carries {tail} for the case
where a scan stopped short of the whole range, and a scan that finished has
nothing to put there — so the answer went out with the braces in it, or with
nothing at all if the variant was all placeholder. The picker now narrows to
the variants this call can actually fill, and prefers, among those, the ones
using the most of what the caller supplied, so a caveat he was given is never
dropped for a shorter wording. Nothing fillable still says the line, because a
visible placeholder beats silence.
The floor literals lived in one global map keyed by bare entry name, and two
families both define an entry called query_unknown: the query answers, where
she looked and found nothing, and the phrasing fallbacks, where she failed to
say an answer she had. Whichever registered last answered for both, so the
distinction those two files exist for disappeared exactly when a file failed to
load. Each family now carries its own map, and an unloadable file leaves a
floor-only deck behind instead of a nil one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
236 lines
7.1 KiB
Go
236 lines
7.1 KiB
Go
package phraser
|
|
|
|
// deck — the mechanics every family of hand-written Russian lines shares.
|
|
//
|
|
// A family is one embedded JSON file: schema-versioned, several variants per
|
|
// entry, never the same variant twice running, and a hard floor of Go literals
|
|
// under it so a broken file cannot take her words away. fallbacks.go was the
|
|
// first family (Vikunja #501) and acks.go the second, at which point copying
|
|
// eighty lines of loader per family stopped being defensible.
|
|
//
|
|
// What stays per family: the file, the keys, the floor literals, the accessor
|
|
// names, and any validation only that family can state.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/rand"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// deckEntry — one line she can say, in as many wordings as the file gives.
|
|
type deckEntry struct {
|
|
// Fixed — one variant, never picked between. For wording that must not
|
|
// drift from turn to turn, like a phrase naming one specific gap.
|
|
Fixed bool `json:"fixed"`
|
|
Variants []string `json:"variants"`
|
|
}
|
|
|
|
type deckFile struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Name string `json:"name"`
|
|
Notes []string `json:"notes"`
|
|
Entries map[string]deckEntry `json:"entries"`
|
|
}
|
|
|
|
// deck picks a line. Safe for concurrent use. A deck with no entries answers
|
|
// from the floor, which is what an unloadable file leaves behind (floorDeck).
|
|
type deck struct {
|
|
mu sync.Mutex
|
|
rnd *rand.Rand
|
|
last map[string]string
|
|
file deckFile
|
|
keys []string
|
|
floor map[string]string
|
|
}
|
|
|
|
// loadDeck parses raw, checks the version and every required key, and seeds the
|
|
// picker. Pass a source to make the picking reproducible in tests; nil seeds
|
|
// from the clock.
|
|
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) {
|
|
var f deckFile
|
|
if err := json.Unmarshal(raw, &f); err != nil {
|
|
return nil, fmt.Errorf("parse: %w", err)
|
|
}
|
|
if f.SchemaVersion != version {
|
|
return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version)
|
|
}
|
|
for _, k := range keys {
|
|
e, ok := f.Entries[k]
|
|
if !ok || len(e.Variants) == 0 {
|
|
return nil, fmt.Errorf("entry %q is missing or empty", k)
|
|
}
|
|
if e.Fixed && len(e.Variants) != 1 {
|
|
return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants))
|
|
}
|
|
}
|
|
if src == nil {
|
|
src = rand.NewSource(time.Now().UnixNano())
|
|
}
|
|
return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
|
|
}
|
|
|
|
// requirePlaceholder fails the load when a variant of key does not use ph. For
|
|
// an entry whose whole job is to read something back, a variant without the
|
|
// placeholder silently drops it.
|
|
func (d *deck) requirePlaceholder(key, ph string) error {
|
|
for _, v := range d.file.Entries[key].Variants {
|
|
if !strings.Contains(v, ph) {
|
|
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// text returns one variant for key with the placeholders filled in. A nil
|
|
// receiver answers from the floor, so no caller checks whether the file loaded.
|
|
func (d *deck) text(key string, vars map[string]string) string {
|
|
tmpl := ""
|
|
if d != nil {
|
|
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
|
|
tmpl = d.pick(key, fillable(e.Variants, vars))
|
|
}
|
|
}
|
|
if tmpl == "" {
|
|
tmpl = floorOf(d, key)
|
|
}
|
|
return fill(tmpl, vars)
|
|
}
|
|
|
|
// matches reports whether text is a line key could have produced. A caller that
|
|
// has to recognise one of these lines cannot compare against a literal any more.
|
|
func (d *deck) matches(key string, vars map[string]string, text string) bool {
|
|
if fill(floorOf(d, key), vars) == text {
|
|
return true
|
|
}
|
|
if d == nil {
|
|
return false
|
|
}
|
|
for _, v := range d.file.Entries[key].Variants {
|
|
if fill(v, vars) == text {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// variants returns every line the file can produce, in key order, for the
|
|
// persona scorer. Stable order so a failure names the same variant twice.
|
|
func (d *deck) variants() []string {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, k := range d.keys {
|
|
out = append(out, d.file.Entries[k].Variants...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fillable narrows variants to the ones this call can actually say, which is
|
|
// the rule an optional placeholder needs: a caller with nothing to put in
|
|
// {tail} must not be handed a variant that has one. Two passes, because both
|
|
// halves matter. The first keeps only variants whose every placeholder has a
|
|
// non-empty value, so an absent optional never reaches him as braces. The
|
|
// second prefers, among those, the variants using the most of what the caller
|
|
// supplied, so a caveat he was given is not dropped for a shorter wording.
|
|
// Nothing fillable leaves the list alone, and the unfilled placeholder shows
|
|
// up in the answer rather than turning it into silence.
|
|
func fillable(variants []string, vars map[string]string) []string {
|
|
if len(variants) < 2 {
|
|
return variants
|
|
}
|
|
best, bestUsed := make([]string, 0, len(variants)), -1
|
|
for _, v := range variants {
|
|
used := 0
|
|
ok := true
|
|
for _, ph := range placeholders(v) {
|
|
if vars[ph] == "" {
|
|
ok = false
|
|
break
|
|
}
|
|
used++
|
|
}
|
|
if !ok || used < bestUsed {
|
|
continue
|
|
}
|
|
if used > bestUsed {
|
|
best, bestUsed = best[:0], used
|
|
}
|
|
best = append(best, v)
|
|
}
|
|
if len(best) == 0 {
|
|
return variants
|
|
}
|
|
return best
|
|
}
|
|
|
|
// placeholders lists the {name}s in tmpl, in order.
|
|
func placeholders(tmpl string) []string {
|
|
var out []string
|
|
for {
|
|
i := strings.IndexByte(tmpl, '{')
|
|
if i < 0 {
|
|
return out
|
|
}
|
|
j := strings.IndexByte(tmpl[i:], '}')
|
|
if j < 0 {
|
|
return out
|
|
}
|
|
out = append(out, tmpl[i+1:i+j])
|
|
tmpl = tmpl[i+j+1:]
|
|
}
|
|
}
|
|
|
|
// pick chooses at random, skipping whatever this entry said last time.
|
|
func (d *deck) pick(key string, variants []string) string {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
choices := variants
|
|
if len(choices) > 1 {
|
|
fresh := make([]string, 0, len(choices))
|
|
for _, v := range choices {
|
|
if v != d.last[key] {
|
|
fresh = append(fresh, v)
|
|
}
|
|
}
|
|
if len(fresh) > 0 {
|
|
choices = fresh
|
|
}
|
|
}
|
|
got := choices[d.rnd.Intn(len(choices))]
|
|
d.last[key] = got
|
|
return got
|
|
}
|
|
|
|
// floorOf reads the Go literal behind key. Every deck carries its own family's
|
|
// map, including the floor-only deck an unloadable file leaves behind, so no
|
|
// lookup ever crosses families. It used to go through one global map keyed by
|
|
// bare entry name, which two families both calling an entry query_unknown
|
|
// silently shared: whichever registered last answered for both (Vikunja #521).
|
|
func floorOf(d *deck, key string) string {
|
|
if d == nil {
|
|
return ""
|
|
}
|
|
return d.floor[key]
|
|
}
|
|
|
|
// floorDeck — the deck a family falls back to when its file will not load. It
|
|
// has no entries, so every read drops through to the floor literals, and it is
|
|
// a real *deck so no accessor has to know which case it is in.
|
|
func floorDeck(floor map[string]string) *deck {
|
|
return &deck{last: map[string]string{}, floor: floor}
|
|
}
|
|
|
|
// fill substitutes {name} for each var. A placeholder with no value is left
|
|
// alone rather than blanked, so a missing value is visible instead of silent.
|
|
func fill(tmpl string, vars map[string]string) string {
|
|
for k, v := range vars {
|
|
tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v)
|
|
}
|
|
return tmpl
|
|
}
|