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 `keyscript…`, 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_affixscripttypeprefix|suffixmin_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 `%sscriptn`, 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 }