textmachine/backend/internal/pipeline/miner_alias.go

194 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"sort"
"strings"
)
// 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).
// aliasParticle is the trailing-particle set that marks a boundary FRAGMENT (alias.PARTICLE): a candidate
// = a known surface + one of these is 方源在/方源心, not an entity.
var aliasParticle = buildRuneSet2("的了在是和就也都不心面前后上下今少多大小与之其")
// 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) bool {
r := []rune(src)
if len(r) < 2 {
return false
}
return seedSurfaces[string(r[:len(r)-1])] && aliasParticle[r[len(r)-1]]
}
// cooccurSameSentence reports whether a and b appear in one source sentence anywhere (alias.
// cooccur_same_sentence: split on 。!?\n).
func cooccurSameSentence(chunks []MinerChunk, aNorm, bNorm string) bool {
for _, c := range chunks {
for _, sent := range splitMinerSentences(c.NSource) {
if strings.Contains(sent, aNorm) && strings.Contains(sent, bNorm) {
return true
}
}
}
return false
}
func splitMinerSentences(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return r == '。' || r == '' || r == '' || r == '\n'
})
}
// 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 []MinerChunk, seedSurfaces map[string]bool) (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 != "" && normalizeSourceKey(a.approvedDst) != normalizeSourceKey(b.approvedDst) {
continue
}
surA, surB := isSurnameStart([]rune(a.src)), isSurnameStart([]rune(b.src))
// 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) {
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 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) && 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
}
var find func(string) string
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
}