259 lines
20 KiB
Python
259 lines
20 KiB
Python
#!/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")
|