textmachine/backend/internal/terminology/series.go

137 lines
5.5 KiB
Go

package terminology
import "sort"
// series.go: the SERIES channel (bank-quality §1, D39.68). A grade/rank series — 甲等/乙等/丙等, 一转…九转 —
// shares one generic HEAD and varies only on a modifier. Measured (D39.65 row 21): the drafts disagree on the
// head across batch boundaries (甲等→«класс», 乙丙丁等→«ранг»), because Merge orders candidates by key and the
// shared head sorts by its DIFFERING prefix. The fix (live probe) is co-batching — shown the set at once the
// model picks ONE head; the first-occurrence test (§7.7) showed order/frequency tricks cannot do it, only
// co-batching. This file detects the series; the batcher keeps each one whole in a single call.
// SeriesParams is the pair-data that governs the channel. It arrives as a value (the package stays pure and
// pair-agnostic): the pipeline resolves it from the source language's declared morphology.
type SeriesParams struct {
// Enabled gates the whole channel. It is true only for DENSE scripts where one rune ≈ one morpheme, so a
// single-rune difference is a real minimal pair; in an alphabetic source care/core differ in one letter by
// coincidence, not by morphology, so the channel is off and Batch behaves exactly as before.
Enabled bool
// HeadFinal says the shared generic word is the TRAILING rune(s) (the CJK modifier-head norm). The differing
// modifier rune is then non-final; with HeadFinal false the head is leading and the modifier is non-initial.
HeadFinal bool
// MinMembers is the smallest set that counts as a series (≤0 → 3). Below three a "series" is just a pair that
// happens to share a character — too weak to override the key order for.
MinMembers int
}
// DetectSeries returns key → series id (>0) for every candidate that belongs to a head-aware series; a key in
// no series is absent from the map. Empty when the channel is off.
//
// A series is ≥MinMembers surfaces of EQUAL rune length that are identical except in ONE rune position, and
// that position is NOT the head (the last rune for HeadFinal, the first otherwise). So the members share a
// generic head and vary only on the modifier — 甲等/乙等/丙等 (grades of 等). 元石/元海 differ IN the head
// (石/海): different entities, the type step's job (§2), never one series. There is no transitive closure:
// membership is by an exact shared skeleton, so 元气~酒气 (a shared head-rune 气 on two families) never chains
// into a blob the way a naive "differ in one position" rule did (measured: an 11-surface blob mixing three
// families and the protagonist's name).
func DetectSeries(cands []Candidate, p SeriesParams) map[string]int {
min := p.MinMembers
if min <= 0 {
min = 3
}
if !p.Enabled || len(cands) < min {
return nil
}
// group[skeleton] = the distinct keys that reduce to it by blanking their one modifier position.
type gkey struct {
n, pos int
skel string
}
groups := map[gkey][]string{}
seenInGroup := map[gkey]map[string]bool{}
var order []gkey // first-seen group order, for deterministic assignment
for _, c := range cands {
rs := []rune(c.Key)
n := len(rs)
if n < 2 { // a one-rune surface is all head, no modifier to vary
continue
}
for pos := 0; pos < n; pos++ {
if p.HeadFinal && pos == n-1 { // the last rune is (part of) the head
continue
}
if !p.HeadFinal && pos == 0 {
continue
}
g := gkey{n, pos, blankAt(rs, pos)}
if seenInGroup[g] == nil {
seenInGroup[g] = map[string]bool{}
order = append(order, g)
}
if !seenInGroup[g][c.Key] {
seenInGroup[g][c.Key] = true
groups[g] = append(groups[g], c.Key)
}
}
}
// A key can satisfy several groups (varying at more than one modifier position). Assign biggest-first so
// the strongest series claims its members; a group left below min after its members were claimed elsewhere
// simply does not form. Deterministic: sort by descending size, then by first-seen order.
sort.SliceStable(order, func(i, j int) bool { return len(groups[order[i]]) > len(groups[order[j]]) })
out := map[string]int{}
next := 1
for _, g := range order {
var fresh []string
for _, k := range groups[g] {
if out[k] == 0 {
fresh = append(fresh, k)
}
}
if len(fresh) >= min {
for _, k := range fresh {
out[k] = next
}
next++
}
}
return out
}
// blankAt returns the key with the rune at pos replaced by a byte no source key contains, so surfaces that
// agree everywhere except pos share a skeleton and nothing else collides onto it.
func blankAt(rs []rune, pos int) string {
out := make([]rune, len(rs))
copy(out, rs)
out[pos] = 0
return string(out)
}
// orderBySeries returns the candidates with every series' members made contiguous, anchored at the position
// of the series' first member in the input order; non-series candidates keep their place. The input is
// already key-sorted, so the result is deterministic and disturbs the key order minimally.
func orderBySeries(cands []Candidate, seriesID map[string]int) []Candidate {
if len(seriesID) == 0 {
return cands
}
byID := map[int][]Candidate{}
for _, c := range cands {
if id := seriesID[c.Key]; id != 0 {
byID[id] = append(byID[id], c)
}
}
out := make([]Candidate, 0, len(cands))
emitted := map[int]bool{}
for _, c := range cands {
id := seriesID[c.Key]
if id == 0 {
out = append(out, c)
continue
}
if emitted[id] {
continue // a later member of an already-emitted series
}
out = append(out, byID[id]...)
emitted[id] = true
}
return out
}