#!/usr/bin/env python3
"""exp15/Q4a — meaning-trap definitions + CORRECTED deterministic scorer (B-hybrid, orchestrator-
ratified 2026-07-18). PRIMARY instrument; $0; floor-immune (unlike the S2' judge blocks).

The Q4a concern (#1): can a strong draft (deepseek-v4-pro) be faithful enough that the editor is
less load-bearing? Sharpest instrument = the exp14b DET meaning-trap battery (double-negation /
number-scale / rank / counterfactual), which is $0-scorable by rule and immune to the judge noise-
floor (0.126) that swamped every S2' block (addendum §1).

RULE CORRECTIONS (frozen here PRE-generation — no pro data exists yet, so this is legitimate pre-
registration refinement, driven by the pre-spend adversarial review, NOT by outcomes). We may NOT
edit the existing rig eval/exp14b/exp14b_score.py, so its flawed rules are OVERRIDDEN in this
additive layer; its sound rules (b2/c2/c3) are reused verbatim. Corrections:
  * SENTENCE-scoping (review F4): score the single SENTENCE carrying the phenomenon, not the whole
    matched line/paragraph — so a raw draft (short line) and a glm editor output (merged paragraph)
    are scored over comparable spans; an unrelated 'никто не' elsewhere in a glm paragraph can no
    longer flip a1/a2.
  * a1/a2 double-negation (review F5): a FAITHFUL double-negation ('не было ни одного, кто НЕ знал'
    / 'ни одного взгляда БЕЗ зависти' = everyone) must score FIX, not FAIL — checked before the
    polarity-inversion fail pattern.
  * a1/c1 anchors tightened; the stray 'мёртв'→fix branch of rule_polarity_all dropped (review F4).
  * counterfactual d1/d2 (review F2): a FAIL branch added (anchor sentence present but the
    counterfactual marker 否则→'иначе' dropped → fail) + 'а то'/'а не то' markers — else the class
    could never enter the §D5 flash-fail denominator.
  * b1 (review F7): FIX now requires BOTH numbers (1/12 AND 30%); a mangled fraction → fail.

Scoring: fix / fail / '?' (undecidable: sentence not found or unclassifiable — EXCLUDED from rates,
never a silent pass; addendum §2). Catastrophe class a1/a2/c1 = gross polarity/rank inversions."""
from __future__ import annotations
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "exp14b"))
import exp14b_score as S   # reuse SOUND rules only: rule_num_b2 (b2), rule_grade_bing (c2), rule_patriarch (c3)

CAT_CLASS = {"a1", "a2", "c1"}


# ── sentence scoping ────────────────────────────────────────────────────────────────
_ENDERS = ".!?…\n"


def sentence_with(text, locator):
    """Return the single sentence of `text` containing the FIRST match of `locator` (regex, re.I).
    '' if no match. Bounds on Russian/CJK sentence enders — makes raw vs editor spans comparable."""
    m = re.search(locator, text or "", re.I)
    if not m:
        return ""
    i = m.start()
    lo = max((text.rfind(p, 0, i) for p in _ENDERS), default=-1)
    hi_cands = [text.find(p, i) for p in _ENDERS if text.find(p, i) >= 0]
    hi = min(hi_cands) if hi_cands else len(text)
    return text[lo + 1:hi + 1].strip()


# ── corrected rules (operate on the scoped SENTENCE) ────────────────────────────────
def rule_a1(s):
    """没有一个不知道 = ВСЕ знали. fail = инверсия 'никто не знал'. Double-neg faithful form = fix."""
    l = s.lower()
    # faithful double-negation FIRST (so it isn't caught by the inversion pattern below).
    # covers: 'не было ни одного, кто не знал', 'никто не оставался в неведении',
    #         'не было незнакомо / неизвестно ни одному / никому' (было НЕ-незнакомо = знали все).
    if re.search(r"(не было|нет|не оставалось|не остал)\s+(ни одного|никого|ни единого|человека)[^.]*(кто|котор|не зна|не ведал|неведени)", l) \
       or re.search(r"(никто|ни один)[^.]*не\s+(оставал|остал|был)[^.]*неведени", l) \
       or re.search(r"не\s+был[оа]?\s+(не\s*)?(знаком|извест)[а-я]*[^.]*(ни одному|никому|ни для кого|ни один|ни одного)", l):
        return "fix"
    # polarity INVERSION (nobody knew) — bidirectional: 'ни один … не знал' OR 'не знал … ни один'
    if re.search(r"(никто|ни один|ни одного|ни единого)[^.]*не\s*(знал|ведал|слышал|слыхал)", l) \
       or re.search(r"не\s*(знал|ведал|слышал|слыхал)[^.]*(ни один|никто|ни одного|ни единого)", l) \
       or re.search(r"не\s+был[аои]?\s+извест", l):
        return "fail"
    # positive faithful (everyone knew)
    if re.search(r"(все|всякий|каждый|весь род|в роду|поголовно|любой|всем)[^.]*(знал|знали|ведал|известн)", l) \
       or re.search(r"(знал[аи]?|ведал[аи]?|известн[ао]?)[^.]*(все|всякий|каждый|весь|любой)", l):
        return "fix"
    return "?"


def rule_a2(s):
    """无不…羡慕嫉妒 = ВСЕ полны зависти. fail = 'без зависти/никто не завидовал'. Double-neg = fix."""
    l = s.lower()
    if not re.search(r"завист|зависти|зависть|ревнос|завидова|зави́д", l):
        return "?"
    # faithful double-negation ('не было ни одного … без зависти')
    if re.search(r"(не было|нет|ни один|ни одного|никого)[^.]*без\s+завист", l):
        return "fix"
    # inversion (no envy)
    if re.search(r"без\s+завист|не было завист|(никто|ни один)[^.]*не\s*завид", l):
        return "fail"
    return "fix"   # envy present, affirmative context


def rule_b1(s):
    """十二分之一 (1/12) AND 三成 (30%). FIX requires BOTH; mangled fraction or 30→3 = fail."""
    l = s.lower()
    has12 = bool(re.search(r"одн[ауой]{1,2} двенадцат|двенадцат[ауой]{1,2} (часть|долю)|1\s*/\s*12|двенадцатой", l))
    has30 = bool(re.search(r"тридцать процент|30\s*%|три десят|три десятых|0[.,]3\b", l))
    frac_bad = bool(re.search(r"одн[ауой]{1,2} втор|1\s*/\s*2|половин|одн[ауой]{1,2} трет|1\s*/\s*3|одн[ауой]{1,2} десят", l))
    pct_bad = bool(re.search(r"\b3\s*%|\bтри процент|три с полов", l)) and not has30
    if pct_bad or frac_bad:
        return "fail"
    if has12 and has30:
        return "fix"
    return "?"


def rule_b2(s):
    """四成四 = 44% = 44 сотых. FIX: 'сорок(а) четыр[её]х процент/сотых', '44%'. FAIL: 4,4% / 4% (10x low).
    Overrides exp14b rule_num_b2, which knew only the 'процент' surface form and missed the 'сотых'
    rendering + declined 'сорока' (review: repeated b2 '?' on valid pro renderings)."""
    l = s.lower()
    if re.search(r"сорок\w*\s+четыр\w*\s*(процент|сот|%)|\b44\s*(процент|%|сот)|0[.,]44", l):
        return "fix"
    if re.search(r"\b4[.,]4\s*(процент|%)|четыре\s+процент|\b4\s*%|четыре\s+сот\w*\s+высот", l):
        return "fail"
    return "?"


def rule_c1(s):
    """人上之人 = человек НАД людьми. fail = 'среди людей/один из людей'."""
    l = s.lower()
    if re.search(r"среди людей|одним из людей|как все|наравне с людьми|обычным человеком|таким же, как", l):
        return "fail"
    if re.search(r"над людьми|над (простыми|обычными|прочими|остальными)? ?людьми|выше (простых|обычных|других|прочих|остальных|всех)? ?людей|возвыс|человек[а-я]* выше|над смертными|вознес", l):
        return "fix"
    return "?"


def _counterfactual(s):
    """幸亏…否则 / 否则 contrafactual. Marker present = fix; anchor sentence but marker dropped = fail.
    Markers include the SUBJUNCTIVE forms that render 否则 without the literal 'иначе' (review of pro
    renderings: 'будь цикада не так слаба…', 'не будь…', 'если бы не…' = faithful counterfactual)."""
    l = s.lower()
    if re.search(r"иначе|в противном случае|не то бы|а не то|\bа то\b|иным образом|в ином случае|"
                 r"если бы(?:\s+\w+){0,3}\s+не|\bне будь\b|\bбудь\b(?:\s+\w+){0,4}\s+не|в противном разе", l):
        return "fix"
    return "fail"   # the locator already selected the counterfactual sentence; no marker => modality lost


# rule dispatch: corrected rules override the flawed exp14b ones; sound exp14b rules reused verbatim.
RULES = {"a1": rule_a1, "a2": rule_a2, "b1": rule_b1, "b2": rule_b2,
         "c1": rule_c1, "c2": S.rule_grade_bing, "c3": S.rule_patriarch,
         "d1": _counterfactual, "d2": _counterfactual}

# locator regex to find the phenomenon SENTENCE (broader than the classifier; tightened for a1/c1).
LOCATOR = {
    # a1: the LEGEND (典故 of the 4th clan head & Huajiu Xingzhe) that "everyone/no-one-didn't" knew.
    # story-words incl. сказани/быль; knowledge-words incl. знаком (не было НЕзнакомо = знали все).
    "a1": r"(притч|легенд|истори|предани|типичн|наследи|сказани|быль|молв)[^.]{0,90}(знал|извест|ведал|неведени|знаком)|"
          r"(знал|извест|ведал|неведени|знаком)[^.]{0,90}(притч|легенд|Монах|Цветочн|Четвёрт|сказани|быль)",
    "a2": r"завист|зависти|зависть|ревнос|завидова",
    "b1": r"двенадцат|(истинн\w* ци|真元)[^.]{0,60}(потрач|истрач|消耗)",   # 蛊虫祭炼1/12, 真元耗三成
    "b2": r"сорок\w*\s+четыр|\b44\b|четыр\w*\s+сот|четыре десят|четырёх десят|половин\w*\s+высот|высот\w*\s+апертур",  # 四成四 = 44%
    "c1": r"стать человеком|человеком (над|среди|выше)|над людьми|среди людей|одним из людей|"
          r"выше .{0,15}людей|над смертными|наравне с людьми",
    "c2": r"Фан Юань[^.]{0,60}(разряд|класс|ранг|степен|丙)|(третьего|перв|втор) разряд|разряда [вбаг]|丙",
    "c3": r"族长|поколени|старейшин|глав[аеуы].{0,20}(рода|клана|поколени)|патриарх|засад|подстро|ловушк",
    # d1: 幸亏春秋蝉虚弱…否则. locate via 'к счастью'/'幸亏' OR a subjunctive counterfactual. NOT the cicada
    # entity (in the chapter TITLE) and NOT bare 'повезло' (matches success-context 'наконец повезло').
    "d1": r"счастью|благо, что|хорошо, что|幸亏|庆幸|\bне будь\b|\bбудь\b\s+\w+\s+не|если бы\s+\w+\s+не",
    # d2: 只恨我没有早生几百年，否则…揭破他的丑恶嘴脸. Anchor on the SPECIFIC lament ('жаль/досадно … родился …
    # раньше') — generic counterfactual markers (иначе/а то/если бы) collide with OTHER counterfactual and
    # villain sentences in this chunk (verified: they grab the wrong span). Fallback: the expose-face content.
    "d2": r"(жаль|досадно|обидно|горько|сожал)[^.!?]{0,55}(родил|рожд|появил|на свет)[^.!?]{0,45}"
          r"(раньше|ране|столет|сотен лет|веками|прежде|давно)|揭破|丑恶嘴脸|разоблач\w* его|сорв\w* (маск|личин)",
}

ANNOT = {
    "a1": dict(src_zone="四代族长和花酒行者的典故，古月族人没有一个不知道的。",
               phenomenon="Двойное отрицание 没有一个不知道 ('нет НИ ОДНОГО, кто НЕ знал бы' = знали ВСЕ). "
                          "Провал = инверсия полярности ('никто не знал').",
               expected="Все/каждый в клане знали эту легенду; никто не был в неведении."),
    "a2": dict(src_zone="许多人不由自主地看向第一排正襟危坐的古月方正，这可是甲等资质啊，目光中无不充满了羡慕嫉妒的情感。",
               phenomenon="无不 (двойное отрицание) — 目光中无不充满羡慕嫉妒 = ВСЕ взгляды полны зависти. "
                          "Провал = 'без зависти / никто не завидовал'.",
               expected="Все взгляды полны зависти и ревности (无不 = поголовно)."),
    "b1": dict(src_zone="蛊虫只祭炼了十二分之一，而我的真元却消耗了整整三成。",
               phenomenon="十二分之一 = 1/12 (очищено гу); 三成 = 30% (истрачено ци). Провал = искажение дробей.",
               expected="Одна двенадцатая (1/12) очищено, истрачено целых тридцать процентов (30%)."),
    "b2": dict(src_zone="海面不到空窍的一半高度，只有四成四。",
               phenomenon="四成四 = 4,4 десятых = 44%. Провал = '4,4%' или '4%' (занижение ~10×).",
               expected="Около сорока четырёх процентов (44%)."),
    "c1": dict(src_zone="蛊师能拥有超越凡人的力量，成为人上之人，但是这其中付出的代价，也是高昂的。",
               phenomenon="人上之人 = человек НАД людьми. Провал = 'один из людей / среди людей'.",
               expected="Стать человеком ВЫШЕ прочих / возвыситься над обычными людьми."),
    "c2": dict(src_zone="方源只是个丙等，方正可是甲等资质。",
               phenomenon="丙等 = разряд В / третий (ранг Фан Юаня). Провал = разряд Б / второй / A.",
               expected="Разряд В (третий, 丙), в противопоставление 甲 (А) у брата."),
    "c3": dict(src_zone="当年，四代族长暗算花酒行者不成，战败后又偷袭，虽然击退了后者，但是他也因此身亡。",
               phenomenon="四代族长 = Четвёртый ГЛАВА рода/клана (族长). Провал = 'старейшина' (家老).",
               expected="Глава рода/клана (族长), а не старейшина."),
    "d1": dict(src_zone="庆幸的是，幸亏春秋蝉虚弱到这种程度，否则自己麻烦就大了！",
               phenomenon="幸亏…否则 = контрфактив ('к счастью …, ИНАЧЕ была бы беда'). Провал = потеря 'иначе'.",
               expected="К счастью (幸亏) …, иначе / в противном случае (否则) была бы беда."),
    "d2": dict(src_zone="“只恨我没有早生几百年，否则见到那个魔头，定要拼死揭破他的丑恶嘴脸。”",
               phenomenon="否则 = контрфактив ('жаль, что не родился раньше, ИНАЧЕ разоблачил бы демона'). "
                          "Провал = потеря 'иначе/否则'.",
               expected="… иначе / в противном случае (否则) я бы разоблачил его."),
}

# chunk + short label per trap (from exp14b TRAPS)
CHUNK = {tid: cid for tid, cid, *_ in S.TRAPS}
DESC = {tid: desc for tid, _c, _a, _r, desc in S.TRAPS}


def traps():
    out = []
    for tid in ("a1", "a2", "b1", "b2", "c1", "c2", "c3", "d1", "d2"):
        out.append(dict(id=tid, chunk=CHUNK[tid], locator=LOCATOR[tid], rule=RULES[tid],
                        desc=DESC[tid], catastrophe_class=tid in CAT_CLASS, **ANNOT[tid]))
    return out


def det_score(text, trap):
    """Sentence-scoped fix/fail/? . '?' = phenomenon sentence not found OR rule unclassifiable
    (never a silent pass). Returns (verdict, scoped_sentence)."""
    sent = sentence_with(text, trap["locator"])
    if not sent:
        return "?", ""
    return trap["rule"](sent), sent


def judge_trap(trap):
    return {"id": trap["id"], "type": "T-inv" if trap["id"][0] in "acd" else "T-num",
            "src_zone": trap["src_zone"], "phenomenon": trap["phenomenon"], "expected": trap["expected"]}


if __name__ == "__main__":
    # rule self-test on HAND-WRITTEN fix/fail strings (validates the corrected rules pre-generation)
    cases = [
        ("a1", "Эту легенду в роду Гуюэ знал каждый.", "fix"),
        ("a1", "О легенде не было ни одного человека, кто не знал бы её.", "fix"),
        ("a1", "О легенде про главу рода в роду Гуюэ не знал ни один человек.", "fail"),
        ("a2", "Во всех взглядах читались зависть и ревность.", "fix"),
        ("a2", "Не было ни одного взгляда без зависти.", "fix"),
        ("a2", "В их взглядах не было зависти.", "fail"),
        ("b1", "Гу-червь очищен лишь на одну двенадцатую, а истинная ци потрачена на целых тридцать процентов.", "fix"),
        ("b1", "Гу-червь очищен наполовину, а истинная ци потрачена на три процента.", "fail"),
        ("b2", "Вода в море достигает лишь сорока четырёх сотых высоты апертуры.", "fix"),
        ("b2", "Уровень воды — только сорок четыре процента высоты апертуры.", "fix"),
        ("b2", "Уровень воды — лишь четыре процента высоты апертуры.", "fail"),
        ("c1", "Заклинатель гу становится человеком выше прочих людей.", "fix"),
        ("c1", "Заклинатель гу остаётся одним из людей.", "fail"),
        ("a1", "Сказание о Четвёртом главе рода не было незнакомо ни одному человеку из рода Гуюэ.", "fix"),
        ("d1", "К счастью, Цикада так слаба, иначе была бы большая беда.", "fix"),
        ("d1", "Будь цикада не так чудовищно слаба, у него самого были бы огромные проблемы.", "fix"),
        ("d1", "К счастью, Цикада оказалась совсем слабой.", "fail"),
        ("d2", "Жаль, что не родился раньше, а то разоблачил бы того демона.", "fix"),
        ("d2", "Жаль, что не родился раньше; я бы разоблачил того демона.", "fail"),
    ]
    tby = {t["id"]: t for t in traps()}
    ok = 0
    for tid, txt, want in cases:
        got, sent = det_score(txt, tby[tid])
        flag = "OK" if got == want else "**FAIL**"
        ok += got == want
        print(f"  {tid} want={want:<5} got={got:<5} {flag}   [{sent[:50]}]")
    print(f"\nrule self-test: {ok}/{len(cases)} pass")
