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