#!/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()
