137 lines
5.5 KiB
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
|
|
}
|