215 lines
10 KiB
Python
215 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""exp16 — arm assembly A1/A2/A3/A3-abl + §D3 metrics (research/20 §D2-D4). miner-v1. $0.
|
||
|
||
A1 = V-A freq × contrast × c-value
|
||
A2 = V-B = A1 + spread translation-spread boost + dst-variants (confounded lower bound on records.json)
|
||
A3 = V-C = A2 + patterns surname/title/topo/formant/rank-grade + Palladius; PROPOSES sub-floor terms
|
||
A3-abl = V-C with λ=0 patterns WITHOUT the spread signal (isolates each signal's contribution)
|
||
|
||
Metric structure (§D3, §D4-а — signals reported SEPARATELY, no manufactured convergence):
|
||
• recall @PROPOSED (candidate-set membership) — the BLINDNESS axis: "where is each arm blind?"
|
||
A1/A2 share a candidate set (spread only re-ranks); A3/A3-abl share the pattern-extended set.
|
||
This is where V-A (freq-floor-blind on f<3) and V-C (patterns close f<3) differ.
|
||
• recall@top-K + pseudo-precision@K — the RANKING/precision axis + trade-off curve.
|
||
• catastrophe screen (top-50 of the WINNER), threshold ±50% sensitivity.
|
||
• spread's contribution (A2 vs A1, A3 vs A3-abl) is ISOLATED — on injected drafts it is ~0/negative
|
||
(confound §D1); measured ecologically only on the cold-start slice.
|
||
|
||
Thresholds chosen on TUNING half (ch1-15), frozen (FROZEN), TEST half (ch16-25) reported (§D1).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
|
||
import exp16_common as X
|
||
import detectors as D
|
||
import patterns as P
|
||
import spread as SP
|
||
import palladius as PAL
|
||
|
||
# ── frozen miner-v1 config (thresholds tuned on ch1-15; pinned in the freeze commit) ──────────────
|
||
FROZEN = dict(
|
||
freq_floor=X.FREQ_FLOOR, # 3
|
||
subsume_alpha=D.SUBSUME_ALPHA, # 0.80
|
||
ngram_max=X.NGRAM_MAX, # 6
|
||
lam=0.0, # V-B spread coefficient — 0 on injected drafts (confound §D1); >0 on cold-start
|
||
formant_min_partners=3,
|
||
formant_min_over_rep=15.0,
|
||
bonus=dict(name=140.0, place=140.0, title=120.0, term=80.0),
|
||
pattern_source_weight=dict(surname=1.0, ordinal_title=1.0, title_suffix=0.9, topo_suffix=0.9,
|
||
rank_grade=1.0, formant_suffix=0.6, formant_prefix=0.5,
|
||
title_bare=0.4, palladius=0.8),
|
||
subfloor_freq_scale=40.0,
|
||
spread_freq_min=5, # only compute spread for candidates with freq>=this (cheap; rare ill-defined)
|
||
top_k=90, # operating point for recall@K/precision reporting
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class ScoredCand:
|
||
src: str
|
||
score: float
|
||
freq: int
|
||
types: list = field(default_factory=list)
|
||
evidence: list = field(default_factory=list)
|
||
dst_variants: list = field(default_factory=list)
|
||
spread: float = 0.0
|
||
from_pattern: bool = False
|
||
|
||
|
||
class Arms:
|
||
def __init__(self, chunks, contrast, cfg=FROZEN, use_spread=True):
|
||
self.chunks = chunks
|
||
self.C = contrast
|
||
self.cfg = cfg
|
||
self.va = D.VA(chunks, contrast, freq_floor=cfg["freq_floor"],
|
||
subsume_alpha=cfg["subsume_alpha"]).build()
|
||
self.va_ranked = self.va.score_all() # A1
|
||
self.pats, self.formants = P.pattern_candidates(
|
||
chunks, self.va.cand_freq, contrast,
|
||
min_partners=cfg["formant_min_partners"], min_over_rep=cfg["formant_min_over_rep"])
|
||
self.sm = SP.SpreadModel(chunks) if use_spread else None
|
||
|
||
def _spread_of(self, src, freq):
|
||
if self.sm and freq >= self.cfg["spread_freq_min"]:
|
||
return self.sm.spread(src)
|
||
return 0.0
|
||
|
||
def _dst_of(self, src, freq):
|
||
if self.sm and freq >= self.cfg["spread_freq_min"]:
|
||
return self.sm.dst_variants(src)[0]
|
||
return []
|
||
|
||
def arm_A1(self):
|
||
return list(self.va_ranked)
|
||
|
||
def arm_A2(self, lam=None):
|
||
lam = self.cfg["lam"] if lam is None else lam
|
||
out = []
|
||
for c in self.va_ranked:
|
||
sp = self._spread_of(c.src, c.freq)
|
||
out.append(ScoredCand(src=c.src, score=c.score * (1 + lam * sp), freq=c.freq, spread=sp,
|
||
dst_variants=self._dst_of(c.src, c.freq)))
|
||
out.sort(key=lambda s: (-s.score, -s.freq, s.src))
|
||
return out
|
||
|
||
def arm_A3(self, lam=None):
|
||
lam = self.cfg["lam"] if lam is None else lam
|
||
cfg = self.cfg
|
||
merged: dict[str, ScoredCand] = {}
|
||
for c in self.va_ranked:
|
||
sp = self._spread_of(c.src, c.freq) if lam else 0.0
|
||
merged[c.src] = ScoredCand(src=c.src, score=c.score * (1 + lam * sp), freq=c.freq, spread=sp,
|
||
dst_variants=self._dst_of(c.src, c.freq))
|
||
for cand, info in self.pats.items():
|
||
pw = max((cfg["pattern_source_weight"].get(ev.split(":")[0], 0.3) for ev in info["evidence"]),
|
||
default=0.3)
|
||
typ = info["types"][0] if info["types"] else "term"
|
||
bonus = cfg["bonus"].get(typ, cfg["bonus"]["term"]) * pw
|
||
if cand in merged:
|
||
merged[cand].score += bonus
|
||
for t in info["types"]:
|
||
if t not in merged[cand].types:
|
||
merged[cand].types.append(t)
|
||
merged[cand].evidence += info["evidence"][:3]
|
||
merged[cand].from_pattern = True
|
||
else:
|
||
f = X.count_occurrences(cand, self.chunks)
|
||
merged[cand] = ScoredCand(src=cand, score=bonus + cfg["subfloor_freq_scale"] * f * pw,
|
||
freq=f, types=list(info["types"]), evidence=info["evidence"][:3],
|
||
dst_variants=self._dst_of(cand, f), from_pattern=True)
|
||
# Palladius ru-side confirmation (gated on capitalized-predominant name lemmas — blocks the
|
||
# 'найти'=най+ти class of false positives)
|
||
for sc in merged.values():
|
||
for lm, d, _ in sc.dst_variants[:2]:
|
||
if PAL.is_palladius_token(lm, min_syllables=2) and (self.sm and self.sm.is_name_lemma(lm)):
|
||
sc.score += cfg["bonus"]["name"] * cfg["pattern_source_weight"]["palladius"] * 0.5
|
||
if "name" not in sc.types:
|
||
sc.types.append("name")
|
||
sc.evidence.append(f"palladius:{lm}")
|
||
break
|
||
out = list(merged.values())
|
||
out.sort(key=lambda s: (-s.score, -s.freq, s.src))
|
||
return out
|
||
|
||
def arm_A3_abl(self):
|
||
return self.arm_A3(lam=0.0)
|
||
|
||
|
||
# ── metrics ───────────────────────────────────────────────────────────────────────────────────────
|
||
def candidate_set(ranked):
|
||
return {c.src for c in ranked}
|
||
|
||
|
||
def recall_by(ranked, gt, chunks, top_k=None, include_annotation=True):
|
||
"""recall by stratum & type. top_k=None => @PROPOSED (candidate-set membership = blindness axis)."""
|
||
if top_k is None:
|
||
cset = candidate_set(ranked)
|
||
def hit(e):
|
||
return any(s in cset for s in e.norm_surfaces)
|
||
else:
|
||
idx = {c.src: i for i, c in enumerate(ranked)}
|
||
def hit(e):
|
||
return any((idx.get(s) is not None and idx[s] < top_k) for s in e.norm_surfaces)
|
||
buckets = {k: [] for k in ("overall", "f>=10", "f3-9", "f<3",
|
||
"name", "title", "place", "term", "nickname")}
|
||
misses = {}
|
||
for e in gt:
|
||
f = X.gt_occurrences(e, chunks, include_annotation)
|
||
h = hit(e)
|
||
buckets["overall"].append(h)
|
||
buckets[X.freq_stratum(f)].append(h)
|
||
if e.typ in buckets:
|
||
buckets[e.typ].append(h)
|
||
if not h:
|
||
misses.setdefault("overall", []).append(f"{e.src}({X.freq_stratum(f)})")
|
||
return {k: (sum(v) / len(v) if v else None, len(v)) for k, v in buckets.items()}, misses
|
||
|
||
|
||
def pseudo_precision(ranked, gt, top_k):
|
||
gtsurf = {s for e in gt for s in e.norm_surfaces}
|
||
top = ranked[:top_k]
|
||
return (sum(1 for c in top if c.src in gtsurf) / len(top)) if top else 0.0
|
||
|
||
|
||
def catastrophe_screen(ranked, top_k=50):
|
||
idx = {c.src: i for i, c in enumerate(ranked)}
|
||
res = {s: idx.get(X.norm(s)) for s in X.CATASTROPHE}
|
||
return all(r is not None and r < top_k for r in res.values()), res
|
||
|
||
|
||
def gt_in_chapters(gt, chunks, chapters, include_annotation=True):
|
||
sub = [c for c in chunks if c.chapter in chapters]
|
||
return [e for e in gt if X.gt_occurrences(e, sub, include_annotation) >= 1]
|
||
|
||
|
||
def summarize(name, ranked, gt, chunks, K):
|
||
rp, miss_p = recall_by(ranked, gt, chunks, top_k=None) # @proposed
|
||
rk, _ = recall_by(ranked, gt, chunks, top_k=K) # @top-K
|
||
ok, res = catastrophe_screen(ranked, 50)
|
||
pp = pseudo_precision(ranked, gt, K)
|
||
return dict(name=name, n=len(ranked), catastrophe_ok=ok, catastrophe=res, pseudo_prec_at_K=round(pp, 3),
|
||
recall_proposed={k: (round(v[0], 3) if v[0] is not None else None, v[1]) for k, v in rp.items()},
|
||
recall_at_K={k: (round(v[0], 3) if v[0] is not None else None, v[1]) for k, v in rk.items()},
|
||
misses_proposed=miss_p.get("overall", []))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
chunks = X.load_chunks()
|
||
gt = X.load_gt()
|
||
C = X.Contrast()
|
||
arms = Arms(chunks, C)
|
||
A = {"A1": arms.arm_A1(), "A2": arms.arm_A2(lam=8.0), "A3": arms.arm_A3(lam=0.0),
|
||
"A3-abl": arms.arm_A3_abl()}
|
||
K = FROZEN["top_k"]
|
||
print(f"=== exp16 arms — FULL slice (57 chunks); K={K} ===")
|
||
print(f"detected formants: {sorted(arms.formants, key=lambda c: -arms.formants[c]['over_rep'])}\n")
|
||
for name, ranked in A.items():
|
||
s = summarize(name, ranked, gt, chunks, K)
|
||
rp, rk = s["recall_proposed"], s["recall_at_K"]
|
||
print(f"{name}: n={s['n']} catastrophe={'PASS' if s['catastrophe_ok'] else 'FAIL'} {s['catastrophe']}")
|
||
print(f" recall@PROPOSED: overall={rp['overall'][0]} f>=10={rp['f>=10'][0]} f3-9={rp['f3-9'][0]} f<3={rp['f<3'][0]}")
|
||
print(f" recall@top{K}: overall={rk['overall'][0]} f>=10={rk['f>=10'][0]} f3-9={rk['f3-9'][0]} f<3={rk['f<3'][0]} pseudo-prec={s['pseudo_prec_at_K']}")
|
||
print(f" by type @proposed: " + " ".join(f"{t}={rp[t][0]}({rp[t][1]})" for t in ("name","title","place","term","nickname") if rp[t][0] is not None))
|
||
print()
|