#!/usr/bin/env python3 """exp16 — alias tier-1 clustering (research/20 §B2). miner-v1. $0. PROP-layer, precision-safe rules over a set of src surfaces + their dominant ru rendering: R1 surface containment X ⊂ Y, |X|>=2 -> 'extension' edge (方源 ⊂ 古月方源) R2 surname anchor+compose shared surname -> FAMILY supercluster edge (NOT identity) R3 shared ru rendering same dominant ru lemma-set -> strong IDENTITY edge (古月方源,方源 -> Фан Юань) R4 NEGATIVE constraints (block a merge): (i) same surname + DIFFERENT given names (方源 vs 方正) -> not identity (ii) different confirmed gender (iii)different approved dst (iv) co-presence in one source sentence/dialogue turn -> not the same entity (v) title-nesting with different seed dst (族长 ⊂ 四代族长) -> different entities Output: identity clusters (R1/R3 minus R4), family superclusters (R2), and tier-2 weak-link proposals. Precision measured entity-level (B³ style, §A5 — NOT MUC link metric). Recall needs the owner mini-gold (§E-1): this module also emits a candidate-edge sheet for the ~20-30 min owner touchpoint. """ from __future__ import annotations import json from collections import defaultdict from dataclasses import dataclass, field import exp16_common as X import spread as SP import palladius as PAL SURNAMES = None # lazy import to avoid cycle def _surname_of(src_norm: str): import patterns as P return P.is_surname_start(src_norm) @dataclass class Surface: src: str # normalized src surface dst_lemmas: list = field(default_factory=list) # dominant ru rendering lemma(s) gender: str = "" approved_dst: str = "" typ: str = "" def dominant_dst(sm: SP.SpreadModel, src_norm: str, top=3): variants, _ = sm.dst_variants(src_norm, top=top) # keep the ru lemmas that look like a NAME rendering (Palladius) OR the very top associates return [lm for lm, d, _ in variants] def cooccur_same_sentence(chunks, a_norm: str, b_norm: str) -> bool: """R4-iv: do a and b co-occur within one source sentence anywhere?""" import regex for c in chunks: for sent in regex.split(r"[。!?\n]", c.nsource): if a_norm in sent and b_norm in sent: return True return False def build_surfaces(surface_srcs, sm, gt_by_norm) -> dict[str, Surface]: out = {} for s in surface_srcs: sn = X.norm(s) if not sn: continue ent = gt_by_norm.get(sn) out[sn] = Surface(src=sn, dst_lemmas=dominant_dst(sm, sn), gender=(ent.gender if ent else ""), approved_dst=(ent.dst if ent and ent.status == "approved" else ""), typ=(ent.typ if ent else "")) return out def propose_edges(surfaces: dict[str, Surface], chunks, sm, gt_norm_surfaces=frozenset()): _SM = sm ident, family, weak = [], [], [] keys = list(surfaces) for i in range(len(keys)): for j in range(i + 1, len(keys)): a, b = surfaces[keys[i]], surfaces[keys[j]] # ── R4 HARD BLOCKS (checked first; no exceptions) ── # R4-ii different confirmed gender if a.gender and b.gender and a.gender != b.gender and "hidden" not in (a.gender, b.gender): continue # R4-iii/v different approved dst -> different entities, EVEN IF one contains the other # (this is the 族长⊂四代族长 lesson D38 §3 and the clan⊂person case 古月⊂古月方源). if a.approved_dst and b.approved_dst and X.norm(a.approved_dst) != X.norm(b.approved_dst): continue sur_a, sur_b = _surname_of(a.src), _surname_of(b.src) # R1 containment -> extension edge (a and b are the SAME entity, fuller vs shorter surface). # Guard against boundary fragments: the SHORTER surface must itself be a GT surface or a # multi-char pattern-typed entity (not a longer±particle artifact). if len(a.src) >= 2 and len(b.src) >= 2 and (a.src in b.src or b.src in a.src) and a.src != b.src: short, long_ = (a, b) if len(a.src) < len(b.src) else (b, a) short_is_entity = bool(short.typ) or short.src in gt_norm_surfaces # COMPOSITIONAL guard: long = short + Y (or Y + short) where Y is ANOTHER known entity of # type title/place/term => long is a PHRASE (古月+族长 = clan-head phrase), not an alias. # clan/surname + NAME stays a fullname alias (古月+方源). Blocks the 古月↔族长 chaining. rest = long_.src[len(short.src):] if long_.src.startswith(short.src) else long_.src[:-len(short.src)] rest_ent = surfaces.get(rest) compositional = bool(rest) and rest in gt_norm_surfaces and \ (rest_ent is None or rest_ent.typ in ("title", "place", "term")) if compositional: weak.append(dict(a=a.src, b=b.src, rule="R1-compositional", kind="phrase_not_alias", part=rest)) elif short_is_entity: ident.append(dict(a=a.src, b=b.src, rule="R1", kind="extension")) else: weak.append(dict(a=a.src, b=b.src, rule="R1-fragment?", kind="containment_weak")) continue # R2 surname anchor -> family (NOT identity) if sur_a and sur_b and sur_a == sur_b and a.src != b.src: # R4-i same surname + different given names -> NOT identity, family only given_a, given_b = a.src[len(sur_a):], b.src[len(sur_b):] if given_a != given_b and given_a and given_b: if cooccur_same_sentence(chunks, a.src, b.src): family.append(dict(a=a.src, b=b.src, rule="R2+R4iv", kind="family_copresent")) else: family.append(dict(a=a.src, b=b.src, rule="R2", kind="family")) continue # R3 shared ru rendering -> identity (strong), unless R4-iv co-presence. # Palladius tokens must ALSO be capitalized-predominant name lemmas (blocks 'найти'=най+ти FP). palla = [lm for lm in a.dst_lemmas[:2] if PAL.is_palladius_token(lm, 2) and _SM.is_name_lemma(lm)] pallb = [lm for lm in b.dst_lemmas[:2] if PAL.is_palladius_token(lm, 2) and _SM.is_name_lemma(lm)] if palla and pallb and set(palla) & set(pallb): if cooccur_same_sentence(chunks, a.src, b.src): weak.append(dict(a=a.src, b=b.src, rule="R3-blockedR4iv", kind="shared_render_copresent")) else: ident.append(dict(a=a.src, b=b.src, rule="R3", kind="shared_render", render=list(set(palla) & set(pallb)))) return ident, family, weak def cluster(ident_edges, all_surfaces): """Union-find over identity edges -> entity clusters.""" parent = {s: s for s in all_surfaces} def find(x): while parent[x] != x: parent[x] = parent[parent[x]]; x = parent[x] return x for e in ident_edges: if e["a"] in parent and e["b"] in parent: parent[find(e["a"])] = find(e["b"]) clusters = defaultdict(list) for s in all_surfaces: clusters[find(s)].append(s) return [sorted(v) for v in clusters.values() if len(v) > 1] if __name__ == "__main__": chunks = X.load_chunks(); gt = X.load_gt() sm = SP.SpreadModel(chunks) gt_by_norm = {X.norm(s): e for e in gt for s in e.surfaces} # surface set: GT surfaces (measure precision vs the 2 known aliases) + high-conf name candidates import arms as A ar = A.Arms(chunks, X.Contrast()) a3 = ar.arm_A3() # input = GT surfaces + genuine name/place/title candidates (freq>=5, typed, NOT boundary fragments). # Exclude GT-surface+trailing-particle artifacts (方源在/方源心 from the surname window). subsumed = ar.va.subsumed gt_surf_norm = {X.norm(s) for e in gt for s in e.surfaces} PARTICLE = set("的了在是和就也都不心面前后上下今少多大小和与之其") def frag(src): return len(src) >= 2 and src[:-1] in gt_surf_norm and src[-1] in PARTICLE name_cands = [c.src for c in a3[:200] if any(t in c.types for t in ("name", "place", "title")) and c.freq >= 5 and c.src not in subsumed and len(c.src) >= 2 and not frag(c.src)] gt_norm_surfaces = frozenset(X.norm(s) for e in gt for s in e.surfaces) surface_srcs = list(gt_norm_surfaces | set(name_cands)) surfaces = build_surfaces(surface_srcs, sm, gt_by_norm) ident, family, weak = propose_edges(surfaces, chunks, sm, gt_norm_surfaces) clusters = cluster(ident, list(surfaces)) print(f"surfaces: {len(surfaces)} identity-edges: {len(ident)} family-edges: {len(family)} weak: {len(weak)}") print("\n== identity edges (R1/R3) ==") for e in ident: print(f" {e['a']} = {e['b']} [{e['rule']}/{e['kind']}] {e.get('render','')}") print("\n== family superclusters (R2, NOT identity) ==") for e in family[:20]: print(f" {e['a']} ~ {e['b']} [{e['rule']}/{e['kind']}]") print(f"\n== identity clusters (>1) ==") for cl in clusters: print(f" {{ {', '.join(cl)} }}") # precision vs known seed aliases (古月方源=方源, 古月方正=方正) known = {frozenset([X.norm("古月方源"), X.norm("方源")]), frozenset([X.norm("古月方正"), X.norm("方正")])} id_pairs = {frozenset([e["a"], e["b"]]) for e in ident if e["kind"] in ("extension", "shared_render")} tp = len(id_pairs & known) print(f"\nknown-alias recall: {tp}/{len(known)}; identity-edge count {len(id_pairs)} " f"(precision vs seed not meaningful — seed alias set is thin, needs owner mini-gold §E-1)")