#!/usr/bin/env python3
"""WS5 (г) verification — deterministic defect-class checkers.

For each checker: (a) POSITIVES — the known empirical defects (exp15 §7.9 / §2 traps) must be
caught; (b) FALSE-POSITIVE rate — firing on the FULL 25-chapter clean ru final. A checker that
flags a notable share of clean text does not land.

Corpus: rerun/records.json {source, final} per chunk (25 chapters). The prod FP measurement
(after build) runs through `tmctl export` (invariant #8); the rerun store is schema v7 vs HEAD
v8 (read-only cannot migrate), and exportNormalize is a cosmetic width-fold that does not alter
the words these checkers key on, so records.json final is a faithful $0 pre-build FP corpus.

$0: deterministic, no LLM, no network. Reuses eval/exp15/q4a_traps rule_b1/rule_b2 machinery.
Writes /home/ubuntu/books/gu-zhenren/design11/ws5_checkers_verify.json (OUT of git).
"""
import json, os, sys, re

sys.path.insert(0, "/home/ubuntu/projects/textmachine/eval/exp15")
import q4a_traps as Q   # rule_b1, rule_b2, sentence_with, LOCATOR

RECORDS = "/home/ubuntu/books/gu-zhenren/rerun/records.json"
OUT = "/home/ubuntu/books/gu-zhenren/design11/ws5_checkers_verify.json"

# ---------- DC-1: 时辰 (double-hour) unit checker ----------
_CN_NUM = {"一":1,"二":2,"两":2,"三":3,"四":4,"五":5,"六":6,"七":7,"八":8,"九":9,"十":10}
def cn_num(s):
    if s.isdigit():
        return int(s)
    return _CN_NUM.get(s)
_SHICHEN = re.compile(r"([0-9一二两三四五六七八九十])\s*个?\s*时辰")
_RU_HOURS = re.compile(r"(\d+|один|два|двух|три|трёх|трех|четыре|пять|шесть)\s+час")
def shichen_checker(src, tgt):
    m = _SHICHEN.search(src)
    if not m:
        return None
    n = cn_num(m.group(1))
    if n is None:
        return None
    expected_hours = n * 2  # 1 时辰 = 2 modern hours
    # find an hours rendering in target; flag ONLY on an explicit mismatch (count rendered as hours).
    # A paraphrase with no explicit hours count is a valid rendering, NOT a defect (refinement: the
    # "no hours found" branch false-flagged ch3/chunk0 at 1.8% — dropped).
    hm = _RU_HOURS.search(tgt)
    if not hm:
        return {"flag": False}
    tok = hm.group(1)
    ru_num = {"один":1,"два":2,"двух":2,"три":3,"трёх":3,"трех":3,"четыре":4,"пять":5,"шесть":6}.get(tok)
    if tok.isdigit():
        ru_num = int(tok)
    if ru_num == n and ru_num != expected_hours:
        return {"flag": True, "reason": f"{n}个时辰 rendered as {ru_num} часов (count) instead of ~{expected_hours}"}
    return {"flag": False}

# ---------- DC-2: number-scale magnitude checker (千万 / 数十万 + b1/b2 fractions) ----------
_MAG = [("千万", 10_000_000, r"(десят\w* миллион|10\s*000\s*000|10000000)"),
        ("数十万", 100_000, r"(сотн\w* тысяч|нескольк\w* сот\w* тысяч|[1-9]00\s*000)")]
def magnitude_checker(src, tgt):
    flags = []
    for token, value, ok_re in _MAG:
        if token in src:
            if not re.search(ok_re, tgt, re.I):
                # positive iff a WRONG smaller magnitude is rendered
                if token == "千万" and re.search(r"тысяч", tgt) and not re.search(r"миллион", tgt):
                    flags.append(f"{token}=10M rendered as 'тысячи' (10000x under)")
                elif token == "数十万" and re.search(r"десятк\w* тысяч", tgt):
                    flags.append(f"{token}~several×100k rendered as 'десятки тысяч' (10x under)")
    # fraction/percent via q4a rules on the located sentence
    for cls, rule in (("b1", Q.rule_b1), ("b2", Q.rule_b2)):
        loc = Q.LOCATOR.get(cls)
        if loc:
            sent = Q.sentence_with(tgt, loc)
            if sent:
                v = rule(sent)
                if v == "fail":
                    flags.append(f"{cls}: numeric magnitude/fraction inverted in '{sent[:40]}...'")
    return {"flag": bool(flags), "reasons": flags} if flags else {"flag": False}

# ---------- DC-6: register-lexicon negative-list ----------
NEG_LIST = {"терем", "терема", "тереме", "теремом", "терему", "теремах"}  # сказочно-русский регистр в сянься
def register_checker(tgt):
    low = tgt.lower()
    hits = sorted({w for w in NEG_LIST if re.search(r"\b" + re.escape(w) + r"\b", low)})
    return {"flag": bool(hits), "hits": hits} if hits else {"flag": False}

# ---------- DC-5: verse / allusion detector (source-side, structural) ----------
# Poetic parallel lines: >=2 short clauses split by CJK comma with near-equal Han length, or
# known couplet markers; flag when target renders them as flat prose (no line breaks / all one sentence).
# A loose "short clauses" heuristic false-fires on 73.7% of prose (measured). Landable version
# requires BOTH structural regularity (>=3 clauses of EQUAL Han length 4-7) AND a poetic-register
# marker (classical imagery lexeme), so ordinary comma-listing prose does not trip it.
_VERSE_LEX = set("落日青丝暮雪朝明月清风山河湖海霜霞烟雨云天地花酒诗词赋")
def verse_checker(src, strict=True):
    for line in src.split("\n"):
        clauses = [c.strip() for c in re.split(r"[，、；]", line) if c.strip()]
        if len(clauses) < 3:
            continue
        hlens = [sum(1 for ch in c if "一" <= ch <= "鿿") for c in clauses]
        if not strict:
            if len([h for h in hlens if 3 <= h <= 8]) >= 2 and max(hlens) <= 8:
                return {"flag": True, "reason": f"loose: {len(clauses)} short clauses"}
            continue
        # strict: >=3 clauses of the SAME length in [4,7] (regular meter) AND a poetic lexeme present
        from collections import Counter
        common_len, cnt = Counter([h for h in hlens if 4 <= h <= 7]).most_common(1)[0] if any(4 <= h <= 7 for h in hlens) else (0, 0)
        regular = cnt >= 3
        lex = any(ch in _VERSE_LEX for ch in line)
        if regular and lex:
            return {"flag": True, "reason": f"strict verse: {cnt} clauses len={common_len} + poetic lexeme"}
    return {"flag": False}

# ---------- DC-3: gender-enforce (hidden character before reveal) ----------
# 白凝冰 = hidden; before until_ch the target must NOT use female surface forms for this character.
HIDDEN_NAME_RU = re.compile(r"Бай\s+Нинбин|Бай\s+Нин\s*Бин|白凝冰")
_FEMALE_FORM = re.compile(r"\b(она|её|ей|ею|неё)\b|\b\w+(ла|лась)\b")
def gender_hidden_checker(src, tgt):
    if "白凝冰" not in src and not HIDDEN_NAME_RU.search(tgt):
        return None
    # flag if a female surface form co-occurs near the hidden character (pre-reveal window)
    if HIDDEN_NAME_RU.search(tgt):
        window = tgt
        if re.search(r"\b(она|её|ей|неё)\b", window):
            return {"flag": True, "reason": "female pronoun for gender:hidden character before reveal"}
    return {"flag": False}

def main():
    recs = json.load(open(RECORDS))
    n_chunks = len(recs)

    # ---- (a) POSITIVES: synthetic trap fixtures from exp15 §7.9 / §2 (checker must catch) ----
    positives = {
        "DC1_shichen": shichen_checker("他闭关了三个时辰。", "Он затворился на три часа."),         # want flag: 三时辰=6h, rendered "три часа"
        "DC2_qianwan": magnitude_checker("有千万条蛊虫。", "Там были тысячи гу-червей."),           # want flag: 千万=10M as "тысячи"
        "DC2_shushiwan": magnitude_checker("聚集了数十万人。", "Собрались десятки тысяч человек."),  # want flag: 数十万 as "десятки тысяч"
        "DC6_terem": register_checker("Он вошёл в высокий терем."),                                 # want flag: терем
        # DC5: the loose line-profile detector CATCHES the trap, but at 74% FP (measured below) it is
        # NOT landable as a gate; the strict version drops FP to 42% but LOSES this positive =>
        # deterministic verse detection is unviable -> DC5 is a pack-curated locus + owner decision.
        "DC5_verse": verse_checker("青山落日，长河渐隐，朝如青丝暮成雪。", strict=False),           # caught (loose only)
        "DC3_gender": gender_hidden_checker("白凝冰走了过来。", "Бай Нинбин подошла, её взгляд был холоден."),  # want flag: female pre-reveal
    }
    positives_caught = {k: (v is not None and v.get("flag", False)) for k, v in positives.items()}

    # ---- (b) FALSE-POSITIVE rate on the 25-ch clean final ----
    fp = {c: 0 for c in ("DC1_shichen", "DC2_magnitude", "DC6_register", "DC5_verse_strict", "DC5_verse_loose", "DC3_gender")}
    fp_detail = {c: [] for c in fp}
    for r in recs:
        src = r.get("source", "") or ""
        fin = r.get("final", "") or ""
        ch, ci = r.get("chapter"), r.get("chunk_idx")
        for name, fn, arg in (
            ("DC1_shichen", shichen_checker, (src, fin)),
            ("DC2_magnitude", magnitude_checker, (src, fin)),
            ("DC6_register", register_checker, (fin,)),
            ("DC5_verse_strict", lambda s: verse_checker(s, strict=True), (src,)),
            ("DC5_verse_loose", lambda s: verse_checker(s, strict=False), (src,)),
            ("DC3_gender", gender_hidden_checker, (src, fin)),
        ):
            res = fn(*arg)
            if res is not None and res.get("flag"):
                fp[name] += 1
                if len(fp_detail[name]) < 5:
                    fp_detail[name].append({"ch": ch, "chunk": ci, "why": res})

    report = {
        "n_chunks": n_chunks,
        "positives_caught": positives_caught,
        "positives_all_caught": all(positives_caught.values()),
        "fp_fire_counts_on_25ch_final": fp,
        "fp_fire_rate_pct": {k: round(100 * v / n_chunks, 1) for k, v in fp.items()},
        "fp_detail_sample": fp_detail,
        "note": ("FP fires are inspected: DC5 verse and DC6 register are the FP-prone target-side "
                 "checkers; DC1/DC2/DC3 are src<->target conversions with structurally low FP. "
                 "A checker flagging a notable share of clean text is refined before landing."),
    }
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    json.dump(report, open(OUT, "w"), ensure_ascii=False, indent=2)
    print(json.dumps({k: report[k] for k in ("n_chunks", "positives_caught", "positives_all_caught",
                                             "fp_fire_counts_on_25ch_final", "fp_fire_rate_pct")}, ensure_ascii=False, indent=2))
    print("\nFP detail sample:")
    for k, v in report["fp_detail_sample"].items():
        if v:
            print(f"  {k}: {v[:2]}")
    print(f"\nVERDICT: positives {'ALL CAUGHT' if report['positives_all_caught'] else 'MISSED SOME'}; "
          f"FP rates on 25-ch final: " + ", ".join(f"{k}={report['fp_fire_rate_pct'][k]}%" for k in fp))

if __name__ == "__main__":
    main()
