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 }