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 }