#!/usr/bin/env python3 """kana-precision measurement (D16-tail / E1-protocol extension; POLYGON handoff Task 5). QUESTION (memory.go:37-48): minKeyLenPhonetic=3 is a "conservative default pending a ja-kana precision measurement". This quantifies matcher PRECISION on kana name-keys at MIN ∈ {2,3,4}, so the prod-ja decision (confirm 3 / raise / split by kana type) is grounded, not asserted. The failure mode: a short kana name-key (glossary key) firing as a FALSE POSITIVE inside an unrelated common Japanese word — the kana analog of a Han homograph trap. Two distinct causes, tagged in kana_traps.json (the agent partitioned them, and they behave DIFFERENTLY under a floor): - substring: the name hides inside a LONGER unrelated word (リン ⊂ リンゴ apple). Killed by a high-enough MIN floor. - homograph: the name IS a whole common noun (ひかる=光る shine, あさひ=morning sun, ひまわり= sunflower). LENGTH-INVARIANT — a MIN floor cannot filter it; it is a full substring at every length. Needs POS / known-word gating, not a longer key. Matcher variants measured: - substring: the CURRENT Go source-side matcher (Aho-Corasick = pure substring, no boundary). - script_boundary: emulates the D16.3 fix ("script/word-boundary on the source side for phonetic keys"). For kana this means "the match must be flanked by NON-kana (kanji/latin/ punct) or a string edge". This is the critical ja finding: unlike Latin (rose⊂roseanne, where spaces/letters bound words), Japanese has NO word spaces and names take kana PARTICLES (メロスは…, 名前が…), so a kana-run boundary rule SUPPRESSES real names followed by a particle → recall collapse. Measured on both so the orchestrator sees the trade, not just the win. Both operate on the NORMALIZED (kana-folded) text — imported from the memory_eval Go-mirror so this measures the SAME normalization the hot path uses (katakana→hiragana, NFKC, ignorables). Usage: eval/.venv/bin/python eval/pilot/kana_precision.py """ from __future__ import annotations import json from pathlib import Path import memory_eval as me # reuse the Go-mirror normalization (norm_src, significant_len, any_han) ROOT = Path(__file__).resolve().parent TRAPS = json.loads((ROOT / "kana_traps.json").read_text(encoding="utf-8")) def is_kana(c: str) -> bool: """Hiragana/katakana (incl. prolonged mark ー). After norm_src katakana folds to hiragana, so the flanking test operates on a hiragana run; ー (U+30FC) is not folded, still kana.""" return 0x3040 <= ord(c) <= 0x30FF def eligible(name_norm: str, min_phonetic: int) -> bool: """A kana key (no Han) is eligible only if significantLen >= the phonetic floor under test (min_key_len_for with minKeyLenPhonetic := min_phonetic). Han keys are out of scope here.""" floor = 2 if me.any_han(name_norm) else min_phonetic return me.significant_len(name_norm) >= floor def collision_prone_at(name_norm: str, min_phonetic: int) -> bool: """Mirror of collisionProneKey with the floor == the const under test: a fired key of significantLen <= min_phonetic is AMBIGUOUS (softened + forced post-check); a longer key fires CONFIRMED (authoritative). The dangerous FP is the CONFIRMED one.""" return (not me.any_han(name_norm)) and me.significant_len(name_norm) <= min_phonetic def fires_substring(name_norm: str, text_norm: str) -> bool: return name_norm in text_norm def fires_script_boundary(name_norm: str, text_norm: str) -> bool: """D16.3 emulation for kana: a substring hit is valid only if flanked by non-kana or edge.""" i = text_norm.find(name_norm) n = len(name_norm) while i != -1: left_ok = i == 0 or not is_kana(text_norm[i - 1]) j = i + n right_ok = j == len(text_norm) or not is_kana(text_norm[j]) if left_ok and right_ok: return True i = text_norm.find(name_norm, i + 1) return False def measure(matcher, min_phonetic: int) -> dict: """Precision over the trap set at one floor with one matcher. FP from collision_traps, TP from true_positive_contexts. Partition FP by cause (substring/homograph) and by disposition (ambiguous=softened / confirmed=authoritative).""" fp = {"substring": 0, "homograph": 0, "confirmed": 0, "ambiguous": 0, "banned": 0, "items": []} for t in TRAPS["collision_traps"]: nk = me.norm_src(t["name_kana"]) cw = me.norm_src(t["containing_word_kana"]) if not eligible(nk, min_phonetic): fp["banned"] += 1 continue if matcher(nk, cw): fp[t["type"]] += 1 disp = "ambiguous" if collision_prone_at(nk, min_phonetic) else "confirmed" fp[disp] += 1 fp["items"].append(f'{t["name_kana"]}({t["len"]})⊂{t["containing_word_kana"]}[{t["type"]},{disp}]') tp = {"fired": 0, "banned": 0, "missed_boundary": 0} for c in TRAPS["true_positive_contexts"]: nk = me.norm_src(c["name_kana"]) sent = me.norm_src(c["sentence_kana"]) if not eligible(nk, min_phonetic): tp["banned"] += 1 continue if matcher(nk, sent): tp["fired"] += 1 else: tp["missed_boundary"] += 1 # eligible but the boundary rule suppressed a REAL name n_fp = fp["substring"] + fp["homograph"] prec = round(tp["fired"] / (tp["fired"] + n_fp), 3) if (tp["fired"] + n_fp) else None # CONFIRMED-precision: among AUTHORITATIVE firings only (AMBIGUOUS is softened by ⟨проверить⟩) tp_conf = sum(1 for c in TRAPS["true_positive_contexts"] if eligible(me.norm_src(c["name_kana"]), min_phonetic) and not collision_prone_at(me.norm_src(c["name_kana"]), min_phonetic) and matcher(me.norm_src(c["name_kana"]), me.norm_src(c["sentence_kana"]))) conf_prec = round(tp_conf / (tp_conf + fp["confirmed"]), 3) if (tp_conf + fp["confirmed"]) else None return {"min": min_phonetic, "tp_fired": tp["fired"], "tp_banned": tp["banned"], "tp_missed_boundary": tp["missed_boundary"], "fp_total": n_fp, "fp_substring": fp["substring"], "fp_homograph": fp["homograph"], "fp_confirmed": fp["confirmed"], "fp_ambiguous": fp["ambiguous"], "fp_banned": fp["banned"], "precision": prec, "confirmed_precision": conf_prec, "fp_items": fp["items"]} def main(): print("kana-precision (D16-tail). Trap set: " f'{len(TRAPS["collision_traps"])} collision pairs, ' f'{len(TRAPS["true_positive_contexts"])} true-positive contexts. ' "FP by cause; disposition confirmed=authoritative / ambiguous=softened.\n") for label, matcher in (("substring (CURRENT Go)", fires_substring), ("script_boundary (D16.3 emulation)", fires_script_boundary)): print(f"### matcher = {label}") print("MIN | TP_fire | TP_missed(bound) | FP_tot | FP_substr | FP_homog | FP_CONFIRMED | FP_ambig | precision | conf_prec") rows = [measure(matcher, m) for m in (2, 3, 4)] for r in rows: print(f" {r['min']} | {r['tp_fired']:2} | {r['tp_missed_boundary']:2} |" f" {r['fp_total']:2} | {r['fp_substring']:2} | {r['fp_homograph']:2} |" f" {r['fp_confirmed']:2} | {r['fp_ambiguous']:2} | " f"{r['precision'] if r['precision'] is not None else ' -'} | " f"{r['confirmed_precision'] if r['confirmed_precision'] is not None else ' -'}") print() # dump machine-readable for the report / re-run comparison out = {"substring": [measure(fires_substring, m) for m in (2, 3, 4)], "script_boundary": [measure(fires_script_boundary, m) for m in (2, 3, 4)]} outp = ROOT.parent / "data" / "pilot" / "kana_precision.json" outp.parent.mkdir(parents=True, exist_ok=True) outp.write_text(json.dumps(out, ensure_ascii=False, indent=2)) print(f"wrote {outp}") # headline read s3 = out["substring"][1] print(f"\nHEADLINE (substring, MIN=3): precision={s3['precision']} conf_prec={s3['confirmed_precision']} " f"| FP_confirmed(len>MIN)={s3['fp_confirmed']} are {[i for i in s3['fp_items'] if 'confirmed' in i]}") if __name__ == "__main__": main()