textmachine/eval/exp16/emit_owner_sheets.py

219 lines
10 KiB
Python
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.

#!/usr/bin/env python3
"""exp16 — owner-facing deliverables (research/20 §C2-8 карта подписи, §E-1 мини-голд, §D1 precision@30). $0.
Emits three markdown sheets for the owner (sidecars, NOT the seed schema — miner never writes approved):
1. signature_map.md — per proposed entity: canon dst-proposal (by ALL occurrences), dst-variants,
top-KWIC contexts (H15 lever), gender counters (他/她), zone flags Z1-Z5, weak edges.
2. alias_minigold.md — candidate entity-edges for the ~20-30 min owner touchpoint (strike/add).
3. precision_at30.md — top-30 NON-seed A3 candidates for owner adjudication (§D1 precision@30 primary path).
Uses cold-start drafts for canon/spread if present (ecological), else injected records.json (confounded).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import exp16_common as X
import spread as SP
import canon as CANON
import palladius as PAL
import arms as A
import alias as ALIAS
BOOK = Path("/home/ubuntu/books/gu-zhenren")
OUT = BOOK / "exp16"
COLD_PLAIN = OUT / "coldstart" / "plain"
_RE_HE, _RE_SHE = re.compile(""), re.compile("")
def kwic(term, chunks, window=18, maxn=3):
tn = X.norm(term)
out = []
for c in chunks:
s = c.source
for m in re.finditer(re.escape(term), s):
i = m.start()
ctx = s[max(0, i - window):i + len(term) + window].replace("\n", " ")
out.append(f"{ctx}")
if len(out) >= maxn:
return out
return out
def gender_counts(term, chunks, window=40):
"""他/她 counts within `window` chars of each occurrence — EVIDENCE ONLY (verdict = owner, D19.3)."""
he = she = 0
for c in chunks:
s = c.source
for m in re.finditer(re.escape(term), s):
seg = s[max(0, m.start() - window):m.end() + window]
he += len(_RE_HE.findall(seg))
she += len(_RE_SHE.findall(seg))
return he, she
def zone_flags(ent, cand, sm, chunks):
"""§B4 zone tags for the candidate (evidence, not verdict)."""
flags = []
sn = X.norm(ent.src if hasattr(ent, "src") else cand.src)
sp = sm.spread(sn)
variants, _ = sm.dst_variants(sn)
top_dst = variants[0][0] if variants else ""
# Z1 suspicious_stable: high score + spread~0 + dst not a Palladius name and not capitalized-name
is_name_dst = PAL.is_palladius_token(top_dst, 2) and sm.is_name_lemma(top_dst)
if cand and cand.score > 500 and sp < 0.05 and top_dst and not is_name_dst and \
(hasattr(ent, "typ") and ent.typ in ("term", "title")):
flags.append("Z1?suspicious_stable")
if sp >= 0.3:
flags.append("Z2?sense_split/spread")
f = X.gt_occurrences(ent, chunks) if hasattr(ent, "norm_surfaces") else cand.freq
if f < 3:
flags.append("Z3:rare")
return flags
def load_banknote_dst():
"""Aggregate translator-emitted src->dst from the cold-start banknote drafts (reliable dst; §3.3)."""
bdir = OUT / "coldstart" / "banknote"
dst = {}
for f in sorted(bdir.glob("*.banknote.json")):
for e in json.load(open(f))["entries"]:
sn = X.norm(e["src"])
dst.setdefault(sn, []).append(e["dst"])
# majority dst per src
from collections import Counter
return {sn: Counter(v).most_common(1)[0][0] for sn, v in dst.items()}
def build_signature_map(gt, chunks, sm, a3, tag, bank_dst=None):
bank_dst = bank_dst or {}
lines = [f"# Карта подписи exp16 (miner-v1, {tag}) — сайдкар владельцу",
"",
"> Майнер НИКОГДА не пишет `approved`. Ниже — предложения `draft` по кластерам: канон-предложение "
"(по ВСЕМ вхождениям × Палладий-конформность), dst-варианты, KWIC-контексты (детерминированный "
"H15-рычаг), счётчики пола (他/她 — evidence, вердикт ваш, D19.3), флаги зон §B4. "
"Владелец: approve / правка / оставить draft / расклеить.",
""]
a3_by = {c.src: c for c in a3}
# cover GT entities (the measurable set) + top non-GT name/place candidates
gt_by_norm = {X.norm(s): e for e in gt for s in e.surfaces}
seen = set()
for e in sorted(gt, key=lambda e: -X.gt_occurrences(e, chunks)):
sn = X.norm(e.src)
if sn in seen:
continue
seen.add(sn)
cand = a3_by.get(sn)
proposed, variants = CANON.canon_propose(sm, e)
vstr = ", ".join(f"{lm}({d})" for lm, d, _ in variants[:4])
he, she = gender_counts(e.src, chunks)
zf = zone_flags(e, cand, sm, chunks)
occ = X.gt_occurrences(e, chunks)
# dst proposal priority: banknote (translator-emitted, reliable) > Palladius-name canon > co-occ note
bank = bank_dst.get(sn)
if bank:
canon_str = f"«{bank}» (banknote-канал)"
elif proposed:
canon_str = f"«{' '.join(proposed)}» (Палладий-канон)"
else:
canon_str = "(dst — банкнота/облако: co-occ извлечение вырождено, §3.3)"
lines.append(f"### {e.src} — предл. dst: {canon_str} "
f"[{e.typ}, occ={occ}, статус сида={e.status}, подписанный dst=«{e.dst}»]")
lines.append(f"- co-occ dst-варианты (справочно): {vstr}")
if he or she:
lines.append(f"- пол-evidence: 他={he} 她={she} (вердикт — владелец)")
if zf:
lines.append(f"- зоны §B4: {', '.join(zf)}")
for k in kwic(e.src, chunks):
lines.append(f"- KWIC: `{k}`")
lines.append("")
return "\n".join(lines)
def build_minigold(gt, chunks, sm):
gt_by_norm = {X.norm(s): e for e in gt for s in e.surfaces}
ar = A.Arms(chunks, X.Contrast())
a3 = ar.arm_A3()
subsumed = ar.va.subsumed
gt_surf = {X.norm(s) for e in gt for s in e.surfaces}
PARTICLE = ALIAS.__dict__.get("PARTICLE", set())
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]
surface_srcs = list(gt_surf | set(name_cands))
surfaces = ALIAS.build_surfaces(surface_srcs, sm, gt_by_norm)
ident, family, weak = ALIAS.propose_edges(surfaces, chunks, sm, frozenset(gt_surf))
clusters = ALIAS.cluster(ident, list(surfaces))
lines = ["# Мини-голд алиасов exp16 — тачпойнт владельца (~2030 мин)",
"",
"> Код ПРЕДЛОЖИЛ рёбра сущностей слайса-25. Вычеркните ложные, допишите пропущенные. Только после "
"этого меряется recall кластеризации (сейчас — только precision предложенных). Тир-1 имеет "
"известную ошибку (§B2): спорное держим раздельным draft.", "",
"## Предложенные IDENTITY-кластеры (одна сущность):"]
for cl in clusters:
lines.append(f"- [ ] {{ {', '.join(cl)} }}")
lines += ["", "## Предложенные FAMILY-суперкластеры (общая фамилия, НЕ тождество):"]
fam_pairs = sorted({tuple(sorted((e['a'], e['b']))) for e in family})
for a, b in fam_pairs[:40]:
lines.append(f"- [ ] {a} ~ {b}")
lines += ["", "## Слабые/tier-2 рёбра (со-оккуренция, решения нет — предложения к связи):"]
for e in weak[:30]:
lines.append(f"- [ ] {e['a']} ? {e['b']} [{e.get('rule','')}]")
return "\n".join(lines)
def build_precision30(gt, chunks, sm, a3):
gt_surf = {X.norm(s) for e in gt for s in e.surfaces}
non_seed = [c for c in a3 if c.src not in gt_surf][:30]
lines = ["# precision@30 — топ-30 НЕ-сидовых кандидатов A3 (V-C) для адъюдикации владельца",
"",
"> Отметьте: T = валидная сущность банка (имя/титул/место/термин), F = мусор/фрагмент, "
"? = спорно. Это первичный путь precision@30 (§D1; precision@сид НЕ метрика — сид не исчерпывающий).",
"", "| # | кандидат | freq | типы | evidence | dst-намёк | T/F/? |",
"|---|---|---|---|---|---|---|"]
for i, c in enumerate(non_seed):
variants, _ = sm.dst_variants(c.src, top=2)
dsth = variants[0][0] if variants else ""
ev = ",".join(c.evidence[:2])
lines.append(f"| {i+1} | {c.src} | {c.freq} | {'/'.join(c.types) or '-'} | {ev} | {dsth} | |")
return "\n".join(lines)
def main():
gt = X.load_gt()
# prefer cold-start drafts (ecological) for canon/spread; fall back to injected
from coldstart_analyze import load_coldstart_chunks, COLD_CHAPTERS
cold = load_coldstart_chunks("plain")
if cold:
chunks_for_canon = cold
tag = f"cold-start ch{COLD_CHAPTERS}"
else:
chunks_for_canon = X.load_chunks()
tag = "injected records.json (confounded — cold-start refines)"
full = X.load_chunks()
sm = SP.SpreadModel(chunks_for_canon)
ar = A.Arms(full, X.Contrast())
a3 = ar.arm_A3()
# signature map over the canon-source chunks
gt_canon = [e for e in gt if X.gt_occurrences(e, chunks_for_canon) >= 1]
bank_dst = load_banknote_dst()
sig = build_signature_map(gt_canon, chunks_for_canon, sm, a3, tag, bank_dst=bank_dst)
(OUT / "signature_map.md").write_text(sig, encoding="utf-8")
sm_full = SP.SpreadModel(full)
mg = build_minigold(gt, full, sm_full)
(OUT / "alias_minigold.md").write_text(mg, encoding="utf-8")
p30 = build_precision30(gt, full, sm_full, a3)
(OUT / "precision_at30.md").write_text(p30, encoding="utf-8")
print(f"[written] {OUT}/signature_map.md ({len(gt_canon)} entities, canon source: {tag})")
print(f"[written] {OUT}/alias_minigold.md")
print(f"[written] {OUT}/precision_at30.md (top-30 non-seed A3)")
if __name__ == "__main__":
main()