194 lines
7.1 KiB
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
|
|
}
|