textmachine/backend/internal/miner/miner_alias.go

194 lines
7.1 KiB
Go

package miner
import (
"sort"
"strings"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/text"
)
// miner_alias.go: alias tier-1 clustering (WS3 — a Go port of exp16 alias.py, DEFAULT-B). Precision-safe
// PROP-layer rules over a set of src surfaces:
//
// R1 surface containment X ⊂ Y, |X|≥2 → 'extension' edge (方源 ⊂ 古月方源), COMPOSITIONAL guard
// (古月+族长 = a phrase, not an alias) blocks the clan↔title chaining
// R2 surname anchor+compose shared surname → FAMILY supercluster (NOT identity)
// R4 NEGATIVE hard-blocks (checked FIRST): (ii) different confirmed gender; (iii/v) different approved
// dst → different entities EVEN under containment (族长⊂四代族长, 古月⊂古月方源); (i) same surname +
// different given names → family, not identity; (iv) co-presence in one source sentence → not identity
//
// The R3 "shared ru rendering → identity" edge is OMITTED in default B (it needs the pymorphy3-backed
// dst-variant lemmas, which the ratified default drops). Precision is measured entity-level (B³, §A5).
// aliasSurface is one src surface with the seed metadata the rules read (alias.Surface, minus the
// default-B-dropped dst_lemmas).
type aliasSurface struct {
src string // normalized src surface
gender string
approvedDst string
typ string
}
// aliasEdge is one proposed edge between two surfaces.
type aliasEdge struct {
a, b string
rule string
kind string
}
// isFragment reports whether src is a known surface + a trailing particle (alias.frag): a boundary
// artifact, not an independent entity. seedSurfaces is the normalized seed src/alias set.
func isFragment(src string, seedSurfaces map[string]bool, p *lang.Pack) bool {
r := []rune(src)
if len(r) < 2 {
return false
}
return seedSurfaces[string(r[:len(r)-1])] && p.AliasParticle[r[len(r)-1]]
}
// cooccurSameSentence reports whether a and b appear in one source sentence anywhere (alias.
// cooccur_same_sentence: split on the pack's sentence terminators + \n).
func cooccurSameSentence(chunks []Chunk, aNorm, bNorm string, p *lang.Pack) bool {
for _, c := range chunks {
for _, sent := range splitMinerSentences(c.NSource, p) {
if strings.Contains(sent, aNorm) && strings.Contains(sent, bNorm) {
return true
}
}
}
return false
}
// splitMinerSentences splits on the pair's source sentence terminators (langpack DATA, pair-14) plus the
// structural newline (a layout mark, not a language one, so it stays in code).
func splitMinerSentences(s string, p *lang.Pack) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return r == '\n' || p.SentenceTerminator[r]
})
}
// proposeAliasEdges applies the tier-1 rules over the surfaces, returning identity, family and weak
// edges (alias.propose_edges, default B). seedSurfaces is the normalized seed src/alias set (the R1
// entity-vs-fragment guard). Deterministic: surfaces are iterated in a fixed sorted order.
func proposeAliasEdges(surfaces map[string]aliasSurface, chunks []Chunk, seedSurfaces map[string]bool, p *lang.Pack) (ident, family, weak []aliasEdge) {
keys := make([]string, 0, len(surfaces))
for k := range surfaces {
keys = append(keys, k)
}
sort.Strings(keys)
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
a, b := surfaces[keys[i]], surfaces[keys[j]]
// R4-ii different confirmed gender (hidden never blocks).
if a.gender != "" && b.gender != "" && a.gender != b.gender && a.gender != "hidden" && b.gender != "hidden" {
continue
}
// R4-iii/v different approved dst → different entities, even under containment.
if a.approvedDst != "" && b.approvedDst != "" && text.NormalizeSourceKey(a.approvedDst) != text.NormalizeSourceKey(b.approvedDst) {
continue
}
surA, surB := isSurnameStart([]rune(a.src), p), isSurnameStart([]rune(b.src), p)
// R1 containment → extension edge (same entity, fuller vs shorter surface).
if runeLen(a.src) >= 2 && runeLen(b.src) >= 2 && a.src != b.src && (strings.Contains(b.src, a.src) || strings.Contains(a.src, b.src)) {
short, long := a, b
if runeLen(b.src) < runeLen(a.src) {
short, long = b, a
}
shortIsEntity := short.typ != "" || seedSurfaces[short.src]
rest := containmentRest(short.src, long.src)
restEnt, restKnown := surfaces[rest]
compositional := rest != "" && seedSurfaces[rest] &&
(!restKnown || restEnt.typ == "title" || restEnt.typ == "place" || restEnt.typ == "term")
switch {
case compositional:
weak = append(weak, aliasEdge{a.src, b.src, "R1-compositional", "phrase_not_alias"})
case shortIsEntity:
ident = append(ident, aliasEdge{a.src, b.src, "R1", "extension"})
default:
weak = append(weak, aliasEdge{a.src, b.src, "R1-fragment?", "containment_weak"})
}
continue
}
// R2 surname anchor → family (NOT identity).
if surA != "" && surB != "" && surA == surB && a.src != b.src {
givenA := trimRunePrefix(a.src, surA)
givenB := trimRunePrefix(b.src, surB)
if givenA != givenB && givenA != "" && givenB != "" { // R4-i different given names → family only
if cooccurSameSentence(chunks, a.src, b.src, p) {
family = append(family, aliasEdge{a.src, b.src, "R2+R4iv", "family_copresent"})
} else {
family = append(family, aliasEdge{a.src, b.src, "R2", "family"})
}
continue
}
}
// R3 (shared ru rendering → identity) is OMITTED in default B.
}
}
return ident, family, weak
}
// containmentRest is alias.py's rest = long[len(short):] if long.startswith(short) else long[:-len(short)]
// (by character). short is contained in long.
func containmentRest(short, long string) string {
sr, lr := []rune(short), []rune(long)
if len(sr) > len(lr) {
return ""
}
if text.RunesEqual(lr[:len(sr)], sr) {
return string(lr[len(sr):])
}
return string(lr[:len(lr)-len(sr)])
}
// trimRunePrefix removes the leading prefix (a surname) from src, by character.
func trimRunePrefix(src, prefix string) string {
sr, pr := []rune(src), []rune(prefix)
if len(pr) <= len(sr) && text.RunesEqual(sr[:len(pr)], pr) {
return string(sr[len(pr):])
}
return src
}
// clusterAlias runs union-find over the identity edges and returns clusters of size > 1, each sorted
// (alias.cluster). allSurfaces bounds the universe.
func clusterAlias(ident []aliasEdge, allSurfaces []string) [][]string {
parent := make(map[string]string, len(allSurfaces))
for _, s := range allSurfaces {
parent[s] = s
}
find := func(x string) string {
for parent[x] != x {
parent[x] = parent[parent[x]]
x = parent[x]
}
return x
}
for _, e := range ident {
if _, ok := parent[e.a]; ok {
if _, ok := parent[e.b]; ok {
parent[find(e.a)] = find(e.b)
}
}
}
groups := map[string][]string{}
for _, s := range allSurfaces {
r := find(s)
groups[r] = append(groups[r], s)
}
var out [][]string
roots := make([]string, 0, len(groups))
for r := range groups {
roots = append(roots, r)
}
sort.Strings(roots)
for _, r := range roots {
if len(groups[r]) > 1 {
g := append([]string(nil), groups[r]...)
sort.Strings(g)
out = append(out, g)
}
}
return out
}