textmachine/eval/design11/ws5_checkers_verify.py

187 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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