Commit exp14b harness scripts (arms, judge, monitor, score) to close the reproducibility gap now that the meaning-battery experiment is ratified and closed at D38

This commit is contained in:
Claude (backend session) 2026-07-12 20:55:45 +03:00
parent 2d760fdb4c
commit 0be308498c
4 changed files with 375 additions and 0 deletions

110
eval/exp14b_arms.py Normal file
View file

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""exp14b — армы батареи смысл-трапов + P1a↔F-disc. ПРОМПТ константен (дискурс), варьируем
editor-модель: C=glm-5 / F=gpt-5.4 / X=grok-4.3(reasoning-ON). Общий flash-черновик.
Реюз exp14-выходов где есть (не пере-оплата). Per-call кап ОБЯЗАТЕЛЕН. ЧЕСТНЫЙ учёт ошибок
(finish=length/empty/http/retry раздельно; НЕ «0 ошибок»). $ персист.
Использование: exp14b_arms.py [--dry]
"""
from __future__ import annotations
import argparse, json, shutil, sys, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp14_common as C
import exp14_prompts as P
RERUN = Path("/home/ubuntu/books/gu-zhenren/rerun")
RECS = {(r["chapter"], r["chunk_idx"]): r for r in json.load(open(RERUN / "records.json"))}
INJ = json.load(open(Path("/home/ubuntu/books/gu-zhenren/exp14") / "injection_blocks.json"))
EX14 = Path("/home/ubuntu/books/gu-zhenren/exp14/arms")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp14b"); OUT.mkdir(parents=True, exist_ok=True)
ARMS = OUT / "arms"; ARMS.mkdir(exist_ok=True)
# базовые (пре-рег §1) + добавленные семьи-редакторы (расширение панели floor-теста, прозрачно)
MODEL = {"C": "glm-5", "F": "gpt-5.4", "X": "grok-4.3",
"D": "deepseek-v4-pro", "K": "kimi-k2.6", "M": "mistral-large-2512"}
EX14_ARM = {"C": "P1a", "F": "F-disc", "X": "X-disc"} # соответствие для реюза (только базовые)
CAPS = {"glm-5": 0.08, "gpt-5.4": 0.40, "grok-4.3": 0.40,
"deepseek-v4-pro": 0.06, "kimi-k2.6": 0.20, "mistral-large-2512": 0.10}
# battery chunks + stage2 (для head-to-head C vs F)
BATTERY = ["7.0", "8.1", "11.1", "17.0", "19.0", "10.1", "6.0", "16.0", "2.1", "9.1"]
STAGE2 = ["17.0", "17.1", "18.0", "18.1", "18.2", "19.0", "19.1"]
# что каждому арму нужно покрыть
NEED = {
"C": sorted(set(BATTERY) | set(STAGE2)),
"F": sorted(set(BATTERY) | set(STAGE2)), # F на stage2 уже есть в exp14 → реюз
"X": sorted(set(BATTERY)),
"D": sorted(set(BATTERY)), # deepseek-v4-pro — только батарея (floor-тест)
"K": sorted(set(BATTERY)), # kimi-k2.6
"M": sorted(set(BATTERY)), # mistral-large-2512
}
def esys():
return P.editor_system("discourse")
def main():
ap = argparse.ArgumentParser(); ap.add_argument("--dry", action="store_true"); a = ap.parse_args()
sp = C.Spender(OUT / "costs.jsonl", CAPS)
errs = {"length": 0, "empty": 0, "http": 0, "content_filter": 0, "other": 0, "retried_ok": 0}
sysd = esys()
for arm, chunks in NEED.items():
d = ARMS / arm; d.mkdir(exist_ok=True)
model = MODEL[arm]
for cid in chunks:
outp = d / f"{cid}.txt"
if outp.exists():
continue
# РЕЮЗ exp14 (только базовые C/F/X — D/K/M новых семей в exp14 не было)
if arm in EX14_ARM:
ex = EX14 / EX14_ARM[arm] / f"{cid}.txt"
if ex.exists():
shutil.copy(ex, outp)
print(f" [reuse exp14] {arm} {cid}{EX14_ARM[arm]}")
continue
ch, ck = cid.split("."); ch = int(ch); ck = int(ck)
r = RECS.get((ch, ck))
if not r:
print(f" [MISS rec] {cid}"); continue
einj = INJ.get(f"{ch}/{ck}", {}).get("editor_constraint", "")
user = P.editor_user(r["source"], r["draft"])
msgs = C.messages_with_injection(sysd, einj, user)
fam = "deepseek" if model.startswith("deepseek") else "glm"
in_est = C.count_tokens(sysd + einj + user, fam)
s = C.spec(model, temp=0.4)
ok, pred = sp.guard(model, in_est, s["max_tokens"])
print(f" {arm} {cid}: in≈{in_est} predict=${pred:.4f} cap=${CAPS[model]} {'OK' if ok else 'OVER'}")
if a.dry or not ok:
continue
# до 2 ретраев на transient (honest error accounting)
text, err, usage, attempts = "", None, {}, 0
for attempt in range(3):
attempts += 1
text, err, usage = C.call(s, msgs, timeout=360)
sp.record({"arm": arm, "model": model, "keyenv": s["keyenv"], "id": cid,
"attempt": attempts, "err": err, "usage": usage})
if not err:
if attempt > 0: errs["retried_ok"] += 1
break
# классификация ошибки
if "length" in (err or ""): errs["length"] += 1
elif "empty" in (err or ""): errs["empty"] += 1
elif "http" in (err or ""): errs["http"] += 1
elif "content_filter" in (err or ""): errs["content_filter"] += 1
else: errs["other"] += 1
if "http" in (err or "") or "transport" in (err or ""):
time.sleep(3); continue # transient → retry
break # length/empty/filter → не ретраим слепо
if err:
print(f" ERR {err[:60]} (attempts={attempts})"); continue
outp.write_text(text, encoding="utf-8")
print(f" ok {len(text)}ch")
print(f"\n=== ЧЕСТНЫЙ учёт ошибок: {errs} ===")
print(f"=== spend by key: {dict((k,round(v,4)) for k,v in sp.by_key.items())} ===")
if __name__ == "__main__":
main()

105
eval/exp14b_judge.py Normal file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""exp14b — судейский проход по SOFT-классам (e чэнъюй, f кореференция) × армы C/F/X.
2 КРОСС-семейных span-цитирующих судьи, self-family exclusion (урок D37: exp14 фронтир
судил ОДИН судья). leave-one-out. Катастроф-скрин к победителю. $ персист, честный учёт.
Судьи: gpt-5-mini(openai) + kimi(moonshot) + mistral(mistral). Для F(gpt) gpt-5-mini исключён.
"""
from __future__ import annotations
import argparse, json, re, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp14_common as C
ARMS = Path("/home/ubuntu/books/gu-zhenren/exp14b/arms")
OUT = Path("/home/ubuntu/books/gu-zhenren/exp14b")
ARM_FAMILY = {"C": "zai", "F": "openai", "X": "xai",
"D": "deepseek", "K": "moonshot", "M": "mistral"} # K→kimi-судья excl; M→mistral-судья excl
JUDGE_FAMILY = {"gpt-5-mini": "openai", "kimi-k2.6": "moonshot", "mistral-large-2512": "mistral"}
ALL_ARMS = ("C", "F", "X", "D", "K", "M")
CAPS = {"gpt-5-mini": 0.05, "kimi-k2.6": 0.08, "mistral-large-2512": 0.05}
SOFT = [
("e1", "8.1", "物是人非", "контраст «вещи/места ПРЕЖНИЕ — люди уже НЕ ТЕ»; плоское «всё изменилось» теряет половину (物是)."),
("e2", "2.1", "韬光养晦", "«скрывать блеск, выжидать» — таиться/копить силы, не выпячиваться; не «развиваться/трудиться»."),
("f1", "16.0", "这促使它不得不外出,寻找野生的酒囊花", "субъект «它» = ВИННЫЙ ЧЕРВЬ (酒虫); это ОН вынужден выйти искать дикие酒囊花. Не спутать субъект."),
("f2", "11.1", "他们的身形隐没在阴影中…分不清男女", "двое (пол НЕЯСЕН) в тени; НЕ навязывать род/пол, НЕ выдумывать кто это."),
]
SYS = ("Ты — билингвальный эксперт zh→ru. Оцени, верно ли русский перевод передаёт смысл ОДНОГО "
"указанного китайского фрагмента. Ответь СТРОГО JSON без markdown: "
'{"verdict":"correct|partial|wrong","catastrophe":true|false,'
'"evidence":"дословная цитата из перевода ≤15 слов","note":"кратко"}. '
"catastrophe=true при подмене субъекта/участника или перевёрнутом смысле.")
def run():
ap = argparse.ArgumentParser(); ap.add_argument("--dry", action="store_true"); a = ap.parse_args()
sp = C.Spender(OUT / "judge_costs.jsonl", CAPS)
vpath = OUT / "soft_verdicts.jsonl"; out = []; done = set()
if vpath.exists():
for l in vpath.read_text(encoding="utf-8").splitlines():
if l.strip():
v = json.loads(l); out.append(v); done.add((v["trap"], v["arm"], v["judge"]))
tasks = []
for tid, cid, span, meaning in SOFT:
for arm in ALL_ARMS:
fp = ARMS / arm / f"{cid}.txt"
if not fp.exists(): continue
for jm in JUDGE_FAMILY:
if JUDGE_FAMILY[jm] == ARM_FAMILY[arm]: continue # self-family exclusion
if (tid, arm, jm) in done: continue
tasks.append((tid, cid, span, meaning, arm, jm, fp.read_text(encoding="utf-8")))
print(f"soft-judge tasks: {len(tasks)} new ({len(done)} done)")
if a.dry:
from collections import Counter; print(Counter(t[5] for t in tasks)); return
errs = {"empty": 0, "http": 0, "other": 0}
for tid, cid, span, meaning, arm, jm, text in tasks:
user = (f"КИТАЙСКИЙ ФРАГМЕНТ: {span}\nДОПУСТИМЫЙ СМЫСЛ: {meaning}\n\n"
f"РУССКИЙ ПЕРЕВОД ОТРЫВКА:\n{text}\n\nОцени передачу ТОЛЬКО этого фрагмента. JSON.")
s = C.spec(jm, max_tokens=(16000 if jm == "kimi-k2.6" else 6000))
in_est = C.count_tokens(SYS + user, "glm")
ok, pred = sp.guard(jm, in_est, s["max_tokens"])
if not ok:
print(f" OVER {tid} {arm} {jm}"); continue
t, err, usage = C.call(s, [{"role": "system", "content": SYS}, {"role": "user", "content": user}], timeout=300)
cst = sp.record({"model": jm, "judge": jm, "keyenv": s["keyenv"], "trap": tid, "arm": arm, "err": err, "usage": usage})
if err:
errs["empty" if "empty" in err else ("http" if "http" in err else "other")] += 1
v = parse(t) if not err else {"verdict": "ERR"}
v.update(trap=tid, arm=arm, judge=jm)
out.append(v)
with open(vpath, "a", encoding="utf-8") as f: f.write(json.dumps(v, ensure_ascii=False) + "\n")
print(f" {tid} {arm} {jm:<18}{v.get('verdict'):<8} cat={v.get('catastrophe')} ${cst:.4f}")
print(f"\n=== ошибки судей (честно): {errs} ===")
summarize(out)
def parse(text):
m = re.search(r"\{.*\}", text, re.S)
if not m: return {"verdict": "PARSE"}
try:
d = json.loads(m.group(0))
return {"verdict": str(d.get("verdict", "?")).lower(), "catastrophe": bool(d.get("catastrophe")),
"evidence": (d.get("evidence") or "")[:100], "note": (d.get("note") or "")[:100]}
except Exception:
vv = re.search(r'"verdict"\s*:\s*"(\w+)"', m.group(0))
return {"verdict": vv.group(1).lower() if vv else "PARSE", "catastrophe": "true" in m.group(0).lower()}
def summarize(out):
from collections import defaultdict
agg = defaultdict(lambda: defaultdict(list))
for v in out: agg[v["trap"]][v["arm"]].append(v)
print("\n=== SOFT-классы: вердикты (арм: судьи) ===")
for tid, cid, span, meaning in SOFT:
if tid not in agg: continue
print(f"\n{tid} ch{cid} [{span}]:")
for arm in ALL_ARMS:
vs = agg[tid].get(arm)
if not vs: continue
zv = [(v["judge"][:4], v["verdict"], "CAT" if v.get("catastrophe") else "") for v in vs]
print(f" {arm}: {zv}")
if __name__ == "__main__":
run()

52
eval/exp14b_monitor.py Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""exp14b §2 — слепой head-to-head пакет P1a(=C, glm-5 дискурс) ↔ F-disc(=F, gpt-5.4 дискурс)
на главах 17/18/19 (+ оригинал для верности). Метки U1/U2 перемешаны по главам, ключ отдельно.
Копирайт: ВНЕ git.
"""
from __future__ import annotations
import json, random
from pathlib import Path
import exp14_common as C
ARMS = Path("/home/ubuntu/books/gu-zhenren/exp14b/arms")
RERUN = Path("/home/ubuntu/books/gu-zhenren/rerun")
RECS = {(r["chapter"], r["chunk_idx"]): r for r in json.load(open(RERUN / "records.json"))}
OUT = Path("/home/ubuntu/books/gu-zhenren/exp14b/monitor"); OUT.mkdir(parents=True, exist_ok=True)
FINALISTS = {"cheap-P1a": "C", "frontier-F-disc": "F"}
CHAPTERS = [19, 17, 18]
CHUNKS = {19: [0, 1], 17: [0, 1], 18: [0, 1, 2]}
SALT = "exp14b-h2h-blind-v1"
def ch_text(arm, ch):
return "\n\n".join((ARMS / arm / f"{ch}.{ck}.txt").read_text(encoding="utf-8") for ck in CHUNKS[ch])
def ch_src(ch):
return "\n".join(RECS[(ch, ck)]["source"] for ck in CHUNKS[ch])
def main():
miss = [(f, ch, ck) for f, a in FINALISTS.items() for ch in CHAPTERS for ck in CHUNKS[ch]
if not (ARMS / a / f"{ch}.{ck}.txt").exists()]
if miss:
print("⚠ нет выходов:", miss[:8]); return
key = {}
md = ["# exp14b head-to-head — P1a(дешёвый дискурс) ↔ F-disc(фронтир). Слепо.\n",
"Две версии на главу (U1/U2, порядок в каждой главе СВОЙ). Сверху китайский оригинал. "
"Оцени РАЗДЕЛЬНО: (A) какая читается лучше как русская проза; (B) верность смыслу (сверяя с "
"оригиналом) — смысловые ошибки/инверсии со спанами. Насколько дешёвый близок к фронтиру? Ключ — `_КЛЮЧ.json`, ПОСЛЕ.\n"]
for ch in CHAPTERS:
order = list(FINALISTS); random.Random(f"{SALT}-{ch}").shuffle(order)
labels = {f"U{i+1}": order[i] for i in range(len(order))}
key[str(ch)] = {lab: {"finalist": fin, "arm": FINALISTS[fin]} for lab, fin in labels.items()}
md.append(f"\n\n{'='*60}\n## Глава {ch}\n{'='*60}\n\n### ОРИГИНАЛ (zh)\n\n```\n{ch_src(ch)}\n```\n")
for lab in ("U1", "U2"):
md.append(f"\n### {lab}\n\n{ch_text(FINALISTS[labels[lab]], ch)}\n")
(OUT / "h2h.md").write_text("\n".join(md), encoding="utf-8")
(OUT / "_КЛЮЧ.json").write_text(json.dumps({"salt": SALT, "finalists": FINALISTS, "per_chapter": key}, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"{OUT/'h2h.md'} (2 версии × 3 главы + оригинал)")
for ch in CHAPTERS:
print(f" гл.{ch}: " + ", ".join(f"{lab}={key[str(ch)][lab]['finalist']}" for lab in ("U1","U2")))
if __name__ == "__main__":
main()

108
eval/exp14b_score.py Normal file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""exp14b — детерминированный скоринг DET-классов (a/b/c/d): извлекает span-рендер трапа в
каждом арме (C/F/X) + применяет правило (полярность/число/ранг/направление) fix/fail/ambiguous
+ печатает span для верификации. $0. Реюз P0 для контраста где есть.
SOFT-классы (e/f) отдельным судейским проходом (exp14b_judge.py).
"""
from __future__ import annotations
import json, re
from pathlib import Path
ARMS = Path("/home/ubuntu/books/gu-zhenren/exp14b/arms")
EX14 = Path("/home/ubuntu/books/gu-zhenren/exp14/arms")
# DET-трапы: (id, chunk, anchor-regex для поиска строки, rule(line)->'fix'/'fail'/'?', описание)
def rule_polarity_all(line):
l = line.lower()
if re.search(r'ни один|никто|не знал ни|ни одного человека не', l): return 'fail'
if re.search(r'все |всем |каждый|мёртв|не было ни одного,? (кто|который)', l): return 'fix'
return '?'
def rule_polarity_envy(line):
l = line.lower()
if 'завист' not in l and 'зависти' not in l and 'зависть' not in l: return '?'
if re.search(r'никто не|не было зависти|без зависти', l): return 'fail'
return 'fix' # завист присутствует в утвердит. контексте
def rule_num_b1(line):
l = line.lower()
# 十二分之一 = 1/12 ; 三成 = 30%
has12 = bool(re.search(r'одну двенадцатую|двенадцатую часть|1/12|двенадцатой', l))
has30 = bool(re.search(r'тридцать процент|30\s*%|три десят|целых три десят', l))
bad = bool(re.search(r'\b3\s*%|\ри процент', l)) # 三成→3% = 10× ошибка
if bad and not has30: return 'fail'
if has30 or has12: return 'fix'
return '?'
def rule_num_b2(line):
l = line.lower()
if re.search(r'сорок четыре процент|44\s*%|сорока четырёх процент', l): return 'fix'
if re.search(r'4[.,]4|\b4\s*%|четыре процент', l): return 'fail'
return '?'
def rule_rank_above(line):
l = line.lower()
if re.search(r'среди людей|одним из', l): return 'fail'
if re.search(r'над людьми|выше (других|прочих|обычных)|возвыс|человеком над', l): return 'fix'
return '?'
def rule_grade_bing(line):
l = line.lower()
# 丙 = 3-й = В/C ; ошибка = Б/второй/A/B
if re.search(r'разряд[а-я]* в|класс[а-я]* в|«в»|третьего разряда|третий разряд', l): return 'fix'
if re.search(r'разряд[а-я]* б|класс[а-я]* б|«б»|второго разряда', l): return 'fail'
return '?'
def rule_patriarch(line):
l = line.lower()
if re.search(r'старейшин', l): return 'fail'
if re.search(r'глав[аеуы].{0,20}поколени|четвёртого поколения|патриарх|четвёртый глава', l): return 'fix'
return '?'
def rule_counterfactual(line):
l = line.lower()
if re.search(r'иначе|в противном случае|не то|если бы не', l): return 'fix'
return '?'
TRAPS = [
("a1", "7.0", r'Гуюэ.*(знал|извест|мёртв)|(знал|извест).*Гуюэ', rule_polarity_all, "все знали (двойн.отриц.)"),
("a2", "7.0", r'завист|зависти|зависть', rule_polarity_envy, "все полны зависти (无不)"),
("b1", "10.1", r'двенадцат|тридцать|десят|процент|祭', rule_num_b1, "1/12 & 30%"),
("b2", "6.0", r'сорок|44|процент|апертур', rule_num_b2, "44%"),
("c1", "17.0", r'людьми|людей|возвыс', rule_rank_above, "над людьми (人上之人)"),
("c2", "9.1", r'Фан Юань.*(разряд|класс)|(разряд|класс).*Фан', rule_grade_bing, "丙=разряд В"),
("c3", "16.0", r'族长|поколени|старейшин|глава клана|патриарх', rule_patriarch, "族长=глава, не старейшина"),
("d1", "19.0", r'иначе|счастью|повезло|противном', rule_counterfactual, "幸亏…否则 контрфактив"),
("d2", "7.0", r'иначе|противном|揭破|разоблач', rule_counterfactual, "否则 контрфактив"),
]
ARM_MODELS = {"C": "glm-5(P1a)", "F": "gpt-5.4(F-disc)", "X": "grok-4.3(X-disc)", "P0": "prod-baseline",
"D": "deepseek-v4-pro", "K": "kimi-k2.6", "M": "mistral-large"}
ALL_ARMS = ("C", "F", "X", "D", "K", "M")
def find_span(text, anchor):
for line in text.split("\n"):
if line.strip() and re.search(anchor, line):
return line.strip()
return ""
def main():
print("=== exp14b DET-скоринг (a/b/c/d) — fix/fail/? + span ===\n")
rates = {arm: {"fix": 0, "fail": 0, "?": 0} for arm in ALL_ARMS}
for tid, cid, anchor, rule, desc in TRAPS:
print(f"── {tid} ch{cid} [{desc}] ──")
for arm in ("P0",) + ALL_ARMS:
base = ARMS if arm in ALL_ARMS else EX14
fp = base / arm / f"{cid}.txt" if arm != "P0" else EX14 / "P0" / f"{cid}.txt"
if not fp.exists():
print(f" {arm:<3}"); continue
span = find_span(fp.read_text(encoding="utf-8"), anchor)
verd = rule(span) if span else '?'
if arm in rates: rates[arm][verd] += 1
mark = {"fix": "", "fail": "✗КАТ" if tid in ("a1","a2","c1") else "", "?": "?"}[verd]
print(f" {arm:<3} {mark:<5} {span[:88] if span else '(span не найден)'}")
print()
print("=== FIX-RATE по армам (DET, 9 инстансов) ===")
for arm in ALL_ARMS:
r = rates[arm]; n = sum(r.values())
print(f" {arm} {ARM_MODELS[arm]:<18}: fix {r['fix']}/{n}, fail {r['fail']}, ? {r['?']}")
json.dump(rates, open(Path("/home/ubuntu/books/gu-zhenren/exp14b")/"det_rates.json","w"), ensure_ascii=False, indent=1)
if __name__ == "__main__":
main()