260 lines
11 KiB
Go
260 lines
11 KiB
Go
package lang
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// bankdata.go: the BANK-ASSEMBLY data plane — language data read ONLY when the terminologist assembles its
|
|
// batches, and by nothing that a wave touches.
|
|
//
|
|
// WHY A PLANE OF ITS OWN, and why it is NOT in data/ beside script-series.txt. Both existing data planes
|
|
// fold into the WAVE snapshot: the embedded one through EmbeddedVersion, the pack one through
|
|
// Pack.Version(). That is right for bytes that ride the wire or resolve a verdict — editing them must be a
|
|
// loud --resnapshot. Family morphology does neither: it decides only WHICH candidates the terminologist
|
|
// shows in ONE call. Folding it into the wave would re-bill a whole book's draft for a knob that cannot
|
|
// change a single byte of what that draft bought — and it would contradict the ratified stance that the
|
|
// terminology axis stays OFF the snapshot (config.TerminologyGate, terminologyVersion,
|
|
// TestTerminologyIsNotSnapshotFolded).
|
|
//
|
|
// It is not unversioned, though: an edit here changes a batch's CONTENT, which is inside the bank-role
|
|
// RequestHash, so the affected batches re-pay by mechanism (cents), and BankDataVersion() is logged with
|
|
// the run so a signature map stays attributable to the data that produced it.
|
|
//
|
|
// ⚠ THAT LAST SENTENCE IS TRUE OF THE FILES THAT SHAPE A REQUEST, AND THE PLANE NOW HOLDS ONE THAT DOES
|
|
// NOT. family-morphology decides which candidates travel together, so editing it re-packs batches and the
|
|
// re-payment follows by mechanism. decline-phrases.txt is read AFTER the answer comes back: it cannot move
|
|
// one byte of any request, so nothing re-pays — what moves is the bank's CONTENT (a declined term leaves
|
|
// it) and with it memory_version, which re-bills the edit wave under the ordinary consent gate. Both
|
|
// belong here for the same reason (neither may fold into the wave snapshot); their money paths differ, and
|
|
// a reader who took the sentence above to cover the whole plane would expect a re-payment that never comes.
|
|
|
|
//go:embed bankdata/family-morphology.txt bankdata/decline-phrases.txt
|
|
var bankFS embed.FS
|
|
|
|
// bankDataAlgoVersion tags the bank plane's hashing RECIPE (like embedAlgoVersion tags the embedded one).
|
|
// Bump it only when the framing or the file set changes; a DATA edit already moves the hash via the bytes.
|
|
// v2: the decline-phrase registry joins the plane (a file-set change; the DATA edit inside a file moves
|
|
// the hash by its bytes on its own).
|
|
const bankDataAlgoVersion = "bankdata-v2"
|
|
|
|
// bankDataFiles are the plane's files, in a FIXED order (the order is folded into the hash).
|
|
var bankDataFiles = []string{"bankdata/family-morphology.txt", "bankdata/decline-phrases.txt"}
|
|
|
|
// FamilyAffix is one engine type's family rule: which side of the surface carries the shared root morpheme
|
|
// and how many runes of it must be shared before two surfaces count as one family.
|
|
type FamilyAffix struct {
|
|
Suffix bool // the root is the TRAILING morpheme (head-final realia); false → the LEADING one (names)
|
|
MinRunes int // ≤0 → this type forms no families
|
|
}
|
|
|
|
// FamilyMorph is a source language's resolved family-channel data. The zero value is INERT — a language
|
|
// written in no dense script, or in a dense script with no rows, co-batches exactly as it did before this
|
|
// channel existed.
|
|
type FamilyMorph struct {
|
|
Affix map[string]FamilyAffix // engine type (name|place|title|term) → its rule
|
|
MinMembers int // smallest set that counts as a family
|
|
MaxMembers int // largest batch unit a family merge may produce (0 = unbounded)
|
|
ContainmentRunes int // shortest candidate that may anchor the compositional channel
|
|
}
|
|
|
|
// Enabled reports whether the language declares enough to form a family at all.
|
|
func (f FamilyMorph) Enabled() bool {
|
|
return f.MinMembers > 0 && (len(f.Affix) > 0 || f.ContainmentRunes > 0)
|
|
}
|
|
|
|
// scriptFamily is one script's authored rows, before a language merges its dense scripts.
|
|
type scriptFamily struct {
|
|
affix map[string]FamilyAffix
|
|
minMembers, maxMembers, containmentRunes int
|
|
}
|
|
|
|
var (
|
|
familyOnce sync.Once
|
|
familyByScript map[string]*scriptFamily
|
|
bankDataOnce sync.Once
|
|
bankDataVal string
|
|
)
|
|
|
|
func loadFamilyMorphology() {
|
|
familyOnce.Do(func() {
|
|
m, err := parseFamilyMorphology(mustBankData(bankDataFiles[0]))
|
|
if err != nil {
|
|
panic(fmt.Sprintf("lang: embedded %s is corrupt: %v", bankDataFiles[0], err))
|
|
}
|
|
familyByScript = m
|
|
})
|
|
}
|
|
|
|
// BankDataVersion is the content hash of the bank-assembly plane (recipe tag + each file's path and bytes,
|
|
// same framing as EmbeddedVersion). It is deliberately NOT in the wave snapshot — see the file comment —
|
|
// and is logged with the run instead, so "which data produced this signature map" stays answerable.
|
|
func BankDataVersion() string {
|
|
bankDataOnce.Do(func() {
|
|
h := sha256.New()
|
|
h.Write([]byte(bankDataAlgoVersion))
|
|
for _, name := range bankDataFiles {
|
|
h.Write([]byte("\x00" + name + "\x00"))
|
|
h.Write(mustBankData(name))
|
|
}
|
|
bankDataVal = bankDataAlgoVersion + "-" + hex.EncodeToString(h.Sum(nil))[:12]
|
|
})
|
|
return bankDataVal
|
|
}
|
|
|
|
// FamilyMorphology resolves the family-channel data for a SOURCE language by unioning the rows of its
|
|
// DENSE scripts (the same gate the series channel uses: one rune ≈ one morpheme is what makes a shared
|
|
// affix a shared MORPHEME rather than a coincidence). Merge rules, all on the conservative side:
|
|
// - two dense scripts declaring the same type with DIFFERENT sides disagree about the language's
|
|
// morphology, so that type forms no families rather than one guessed direction;
|
|
// - otherwise the STRICTER rule wins (the longer required root), the strictest min_members, and the
|
|
// SMALLEST max_members, so adding a script never loosens what another script asked for.
|
|
func FamilyMorphology(lng string) FamilyMorph {
|
|
loadLangScripts()
|
|
loadFamilyMorphology()
|
|
var out FamilyMorph
|
|
names := make([]string, 0, 4)
|
|
for name := range langScriptBy[strings.ToLower(strings.TrimSpace(lng))] {
|
|
if cjkScriptNames[name] {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names) // deterministic merge order (the rules are order-free, the iteration must be too)
|
|
conflict := map[string]bool{}
|
|
for _, name := range names {
|
|
sf := familyByScript[name]
|
|
if sf == nil {
|
|
continue
|
|
}
|
|
if out.Affix == nil {
|
|
out.Affix = map[string]FamilyAffix{}
|
|
}
|
|
for typ, a := range sf.affix {
|
|
cur, had := out.Affix[typ]
|
|
switch {
|
|
case !had:
|
|
out.Affix[typ] = a
|
|
case cur.Suffix != a.Suffix:
|
|
conflict[typ] = true
|
|
case a.MinRunes > cur.MinRunes:
|
|
out.Affix[typ] = a
|
|
}
|
|
}
|
|
if sf.minMembers > out.MinMembers {
|
|
out.MinMembers = sf.minMembers
|
|
}
|
|
if sf.containmentRunes > out.ContainmentRunes {
|
|
out.ContainmentRunes = sf.containmentRunes
|
|
}
|
|
if sf.maxMembers > 0 && (out.MaxMembers == 0 || sf.maxMembers < out.MaxMembers) {
|
|
out.MaxMembers = sf.maxMembers
|
|
}
|
|
}
|
|
for typ := range conflict {
|
|
delete(out.Affix, typ)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseFamilyMorphology reads the plane's rows into script → rules. Pure, so a test can drive it with a
|
|
// synthetic table and prove the channel is DATA. Fail-loud on an unknown key / side / non-positive integer:
|
|
// a malformed row is a corrupt asset, and a silently-inert family channel is exactly the drift this
|
|
// codebase refuses (the Palladius typo precedent).
|
|
func parseFamilyMorphology(b []byte) (map[string]*scriptFamily, error) {
|
|
out := map[string]*scriptFamily{}
|
|
at := func(script string) *scriptFamily {
|
|
if out[script] == nil {
|
|
out[script] = &scriptFamily{affix: map[string]FamilyAffix{}}
|
|
}
|
|
return out[script]
|
|
}
|
|
posInt := func(i int, key, raw string) (int, error) {
|
|
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
|
if err != nil || n <= 0 {
|
|
return 0, fmt.Errorf("line %d: %s must be a positive integer (%q)", i+1, key, raw)
|
|
}
|
|
return n, nil
|
|
}
|
|
line := 0
|
|
for i, raw := range strings.Split(string(b), "\n") {
|
|
t := strings.TrimSpace(strings.TrimRight(raw, "\r"))
|
|
if t == "" || strings.HasPrefix(t, "#") {
|
|
continue
|
|
}
|
|
line = i
|
|
f := strings.Split(t, "\t")
|
|
for j := range f {
|
|
f[j] = strings.TrimSpace(f[j])
|
|
}
|
|
if len(f) < 3 || f[1] == "" {
|
|
return nil, fmt.Errorf("line %d: want `key<TAB>script<TAB>…`, got %q", i+1, t)
|
|
}
|
|
// The SCRIPT column is checked against the range registry. A typo parses fine and then simply never
|
|
// matches a language's declared scripts — the channel goes silently inert for the pair that asked for
|
|
// it, which is the exact silent-empty-table class the pack loader refuses everywhere else.
|
|
if _, known := scriptRanges[f[1]]; !known {
|
|
return nil, fmt.Errorf("line %d: unknown script %q — it resolves to no Unicode range, so every row naming it would be silently ignored (add it to scriptRanges, or fix the spelling)", i+1, f[1])
|
|
}
|
|
switch f[0] {
|
|
case "family_affix":
|
|
if len(f) != 5 || f[2] == "" {
|
|
return nil, fmt.Errorf("line %d: family_affix wants `family_affix<TAB>script<TAB>type<TAB>prefix|suffix<TAB>min_root_runes`, got %q", i+1, t)
|
|
}
|
|
n, err := posInt(i, "min_root_runes", f[4])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch f[3] {
|
|
case "prefix":
|
|
at(f[1]).affix[f[2]] = FamilyAffix{MinRunes: n}
|
|
case "suffix":
|
|
at(f[1]).affix[f[2]] = FamilyAffix{Suffix: true, MinRunes: n}
|
|
default:
|
|
return nil, fmt.Errorf("line %d: side must be prefix|suffix, got %q", i+1, f[3])
|
|
}
|
|
case "family_min_members", "family_max_members", "family_containment_runes":
|
|
if len(f) != 3 {
|
|
return nil, fmt.Errorf("line %d: %s wants `%s<TAB>script<TAB>n`, got %q", i+1, f[0], f[0], t)
|
|
}
|
|
n, err := posInt(i, f[0], f[2])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch f[0] {
|
|
case "family_min_members":
|
|
at(f[1]).minMembers = n
|
|
case "family_max_members":
|
|
at(f[1]).maxMembers = n
|
|
default:
|
|
at(f[1]).containmentRunes = n
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("line %d: unknown key %q (want family_affix|family_min_members|family_max_members|family_containment_runes)", i+1, f[0])
|
|
}
|
|
}
|
|
// A HALF table is a corrupt one, for the same reason: family_affix with no family_min_members leaves
|
|
// Enabled() false, so the rows are read, parsed, and then quietly do nothing.
|
|
for name, sf := range out {
|
|
switch {
|
|
case sf.minMembers == 0:
|
|
return nil, fmt.Errorf("script %q declares family rules but no family_min_members — the channel would load and stay inert (line %d region)", name, line+1)
|
|
case len(sf.affix) == 0 && sf.containmentRunes == 0:
|
|
return nil, fmt.Errorf("script %q declares family_min_members but neither family_affix nor family_containment_runes — nothing can form a family", name)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func mustBankData(name string) []byte {
|
|
b, err := bankFS.ReadFile(name)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("lang: missing bank-data asset %q: %v", name, err))
|
|
}
|
|
return b
|
|
}
|