textmachine/eval/dovodka/adjud.py

271 lines
16 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
"""АДЪЮДИКАЦИЯ НАХОДОК — слепая проверка судейских претензий. $0.
Заведена правилом П-1 (Д0.13) и включается ровно в одном случае: контраст перешёл СВОЙ порог у
одного семейства и не перешёл у другого. Спор счётов между детекторами с разными точками
отсечения неразрешим в принципе; разрешим только спор НАХОДОК, и адъюдикация — единственная
форма, в которой второй взгляд добавляет информацию, а не вторую выборку того же судьи.
Что видит адъюдикатор: ИСХОДНИК фрагмента, ЦИТАТУ из перевода с окружением и СУТЬ ПРЕТЕНЗИИ.
Чего он НЕ видит: какой это арм, какое семейство претензию выдвинуло и сколько всего претензий
к этому арму. Слепота здесь не ритуал: зная арм, проверяющий начнёт подтверждать претензии к
тому, кто «должен» быть хуже.
⚠⚠ ДВА КОНТРОЛЯ, БЕЗ КОТОРЫХ АДЪЮДИКАЦИЯ ПУСТА. Без них доверие просто съезжает на уровень ниже:
проверяющий, склонный соглашаться с предъявленным, подтвердит всё что угодно.
ЛОЖНАЯ ПРЕТЕНЗИЯ — цитата из места, которому судья поставил по этой оси НОЛЬ, с придуманной
претензией. Верный ответ: «не ошибка». Меряет СГОВОРЧИВОСТЬ. Доля подтверждённых ложных
претензий есть прямая оценка того, сколько в основном пуле подтверждено зря.
ПОСАЖЕННЫЙ ДЕФЕКТ — цитата из маржевого декоя ровно в месте посадки, где истина известна
побайтно. Верный ответ: «ошибка». Меряет ЧУВСТВИТЕЛЬНОСТЬ.
Адъюдикатор, подтвердивший больше 20% ложных претензий ЛИБО опознавший меньше 60% посаженных,
аннулируется целиком — как судья, не поймавший декой.
КАК СЧИТАЕТСЯ ПОПРАВКА. Выборочно: по каждому арму берётся доля верифицированных претензий, и
сырой счёт ошибок умножается на неё. Правило П-1 говорит именно о ВЫБОРКЕ цитат, а не о сплошной
проверке, поэтому это исполнение буквы, а не упрощение. Приближение объявлено: поправка
предполагает, что доля верных претензий одинакова по единицам внутри арма.
Запуск: adjud.py --emit | --score
"""
from __future__ import annotations
import importlib.util
import json
import random
import re
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"):
sys.path.insert(0, str(REPO / "eval" / d))
def _load(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
S = _load("sud", ZONE / "sud.py")
C = S.C
BO = sys.modules.get("bakeoff") or _load("bakeoff", REPO / "eval" / "role_topology" / "bakeoff.py")
KEYS = Path.home() / "books" / "dovodka" / "blind-keys-d4"
WORK = Path.home() / "adjud-d4" # изолирован: рядом ни ключей, ни сырья
TASKS, ANSW = WORK / "tasks", WORK / "answers"
AKEY = Path.home() / "books" / "dovodka" / "adjud-key.json" # истина — ОТДЕЛЬНО от заданий
AX = ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА")
ARMS = ("A3", "A0") # контраст, ушедший в эскалацию: A3/A0 (H-2б)
PER_CELL = 2 # претензий на (единица, арм)
N_FALSE = 15 # контроль сговорчивости
N_PLANT = 15 # контроль чувствительности
PER_TASK = 24
SEED = 20260814 # фиксирован: раскладка воспроизводима
_norm = re.compile(r"[\s«»„“”\"'`.,!?;:—–-]+")
def norm(t: str) -> str:
return _norm.sub(" ", (t or "").lower()).strip()
def context(text: str, quote: str, width: int = 260) -> str:
"""Цитата с окружением — адъюдикатор обязан видеть, в каком месте она стоит."""
n, q = norm(text), norm(quote)
i = n.find(q)
if i < 0:
return quote
lo, hi = max(0, i - width // 2), min(len(n), i + len(q) + width // 2)
return ("" if lo else "") + n[lo:hi] + ("" if hi < len(n) else "")
def collect() -> tuple[list, dict]:
"""Претензии семейства claude по армам контраста + два контроля. Возвращает (items, key)."""
us = {x["uid"]: x for x in S.units()}
rnd = random.Random(SEED)
real, false_pool, plant_pool = [], [], []
for half, hi in (("p1", 1), ("p2", 2)):
key_all = json.loads((KEYS / f"d4{half}-KEY.json").read_text(encoding="utf-8"))
for f in sorted((Path.home() / "sud-d4" / f"{half}-answers").glob("*.txt")):
if ".ANNULLED" in f.name:
continue
key = key_all.get(f.stem)
if not key:
continue
txt = f.read_text(encoding="utf-8", errors="replace")
for lab, meta in key.items():
arm, uid = meta["arm"], meta["uid"]
u = us.get(uid)
if not u:
continue
var = (C.base_a0(uid) if arm == "A0"
else S.text_of(arm, u) if not arm.startswith("CTRL")
else S.floor_pair(u, hi) if arm == "CTRLfloor"
else "")
if arm == "CTRLmargin":
var, _ = S.margin_plant(C.base_a0(uid))
if not var.strip():
continue
w = re.search(rf"^{lab}-ПОЧЕМУ:\s*(.*)$", txt, re.M)
why = w.group(1) if w else ""
# какие оси ненулевые — по ним претензии реальные; нулевые дают материал для лжи
zero = []
for a in AX:
m = re.search(rf"^{lab}-{a}:\s*(\d+)", txt, re.M)
if m and int(m.group(1)) == 0:
zero.append(a)
for seg in re.split(r"(?=[А-ЯЁ]{4,}:)", why):
m = re.match(r"([А-ЯЁ]{4,}):(.*)", seg.strip(), re.S)
if not m:
continue
ax = m.group(1)
for q in re.findall(r"«([^»]{8,})»", m.group(2)):
if norm(q) not in norm(var):
continue
rec = dict(uid=uid, arm=arm, axis=ax, quote=q,
ctx=context(var, q), src=u["source"])
if arm in ARMS:
real.append(rec)
if arm == "CTRLmargin":
plant_pool.append(rec)
# ложные претензии строятся из мест, где судья поставил НОЛЬ
if zero and arm in ARMS:
sent = [x for x in re.split(r"(?<=[.!?])\s+", var) if 40 < len(x) < 200]
if sent:
false_pool.append(dict(uid=uid, arm=arm, axis=rnd.choice(zero),
quote=rnd.choice(sent), ctx="", src=u["source"],
fake=True))
# стратифицированная выборка: не более PER_CELL претензий на (единица, арм)
by = {}
for r in real:
by.setdefault((r["uid"], r["arm"]), []).append(r)
sample = []
for k in sorted(by):
v = by[k]
rnd.shuffle(v)
sample += v[:PER_CELL]
for r in false_pool[:0] or rnd.sample(false_pool, min(N_FALSE, len(false_pool))):
r["ctx"] = context(r["quote"], r["quote"])
sample.append(r)
for r in rnd.sample(plant_pool, min(N_PLANT, len(plant_pool))):
r = dict(r, planted=True)
sample.append(r)
rnd.shuffle(sample)
key = {}
for i, r in enumerate(sample, 1):
key[f"П{i:03d}"] = {k: r.get(k) for k in ("uid", "arm", "axis", "quote", "fake", "planted")}
return sample, key
HEAD = """Ты — эксперт по художественному переводу с китайского на русский.
Тебе дан список ПРЕТЕНЗИЙ к переводам. Каждая претензия говорит, что в приведённом месте перевода
есть ошибка определённого типа. Твоя работа — по каждой сказать, ПРАВДА ЭТО ИЛИ НЕТ.
Типы ошибок:
ВЕРНОСТЬ — сказано не то, что в исходнике: искажение, инверсия, выдумка, пропуск факта.
ТЕРМИН — имя или термин передан не так, как в других местах того же текста.
ЯЗЫК — то, чего носитель не напишет: калька, канцелярит, несуществующее слово,
кривой порядок слов, рассогласование.
ФОРМА — вёрстка и пунктуация: оформление реплик, кавычки, тире, абзацы.
⚠ ВАЖНО: часть претензий ЛОЖНЫЕ — они указывают на места, где ошибки нет. Твоя задача не
подтвердить предъявленное, а проверить его. Соглашаться со всем подряд — значит не сделать работу.
Отвечай «НЕТ» так же спокойно, как «ДА».
Ответ ЗАПИШИ ФАЙЛОМ в {path} — строго по одной строке на претензию, ничего больше:
{skeleton}
где вместо <да/нет> стоит ДА (ошибка действительно есть) или НЕТ (ошибки нет), а после — короткое
обоснование одной фразой.
"""
def emit() -> None:
sample, key = collect()
for d in (TASKS, ANSW):
d.mkdir(parents=True, exist_ok=True)
AKEY.write_text(json.dumps(key, ensure_ascii=False, indent=1), encoding="utf-8")
labs = list(key)
for i in range(0, len(labs), PER_TASK):
grp = labs[i:i + PER_TASK]
tok = f"adj-{i // PER_TASK + 1:02d}"
skel = "\n".join(f"{l}: <да/нет> — <обоснование>" for l in grp)
body = [HEAD.format(path=ANSW / f"{tok}.txt", skeleton=skel)]
for l in grp:
r = sample[labs.index(l)]
body += ["", "=" * 60, f"{l}",
"ИСХОДНИК (китайский):", r["src"][:1800],
"", f"МЕСТО В ПЕРЕВОДЕ: {r['ctx'] or r['quote']}",
f"ПРЕТЕНЗИЯ: здесь ошибка типа {r['axis']} — в цитате «{r['quote']}»"]
(TASKS / f"{tok}.txt").write_text("\n".join(body), encoding="utf-8")
print(f"претензий {len(labs)} · заданий {len(list(TASKS.glob('*.txt')))}{TASKS}")
print(f"ключ истины → {AKEY} (адъюдикатору НЕ давать)")
n_f = sum(1 for v in key.values() if v.get("fake"))
n_p = sum(1 for v in key.values() if v.get("planted"))
print(f"из них контролей: ложных претензий {n_f} · посаженных дефектов {n_p}")
def score() -> int:
if not AKEY.exists():
print("ключа нет — сначала --emit")
return 1
key = json.loads(AKEY.read_text(encoding="utf-8"))
ans = {}
for f in sorted(ANSW.glob("*.txt")):
for line in f.read_text(encoding="utf-8", errors="replace").splitlines():
m = re.match(r"\s*(П\d{3})\s*:\s*(ДА|НЕТ)", line.strip(), re.I)
if m:
ans[m.group(1)] = m.group(2).upper() == "ДА"
print(f"ответов получено: {len(ans)} из {len(key)}")
if not ans:
return 1
fake = [(k, v) for k, v in key.items() if v.get("fake") and k in ans]
plant = [(k, v) for k, v in key.items() if v.get("planted") and k in ans]
fp = sum(1 for k, _ in fake if ans[k]) / max(1, len(fake))
tp = sum(1 for k, _ in plant if ans[k]) / max(1, len(plant))
print("\nКОНТРОЛИ АДЪЮДИКАТОРА")
print(f" сговорчивость: подтвердил {fp:.0%} ЛОЖНЫХ претензий (n={len(fake)}) "
f"{'ГОДЕН' if fp <= 0.20 else '⛔ АННУЛИРОВАН'}")
print(f" чувствительность: опознал {tp:.0%} ПОСАЖЕННЫХ дефектов (n={len(plant)}) "
f"{'ГОДЕН' if tp >= 0.60 else '⛔ АННУЛИРОВАН'}")
if fp > 0.20 or tp < 0.60:
print("\n⛔ адъюдикация не засчитывается — контроли не пройдены")
return 1
# ⚠ ТОЛЬКО НЕСУЩИЕ ОСИ. Контраст считается по ВЕРНОСТЬ+ЯЗЫК (эррата П-5): ТЕРМИН отдан
# детерминированному канон-гейту, ФОРМА исключена. Претензии по этим двум осям в поправку
# НЕ идут, иначе она правила бы не ту величину.
# ⚠ И ОТДЕЛЬНО ПРО ФОРМУ — ДЕФЕКТ ПРИБОРА, НАЙДЕННЫЙ САМИМ АДЪЮДИКАТОРОМ: окно «МЕСТО В
# ПЕРЕВОДЕ» подаётся через `norm()`, то есть БЕЗ пунктуации и в нижнем регистре. Судить
# вёрстку и пунктуацию по такому окну нельзя в принципе — то, что проверяется, стёрто
# нормализацией. Ось ФОРМА исключается дважды: и как невходящая в сумму, и как непроверяемая.
print("\nДОЛЯ ВЕРИФИЦИРОВАННЫХ ПРЕТЕНЗИЙ ПО АРМАМ (только несущие оси ВЕРНОСТЬ+ЯЗЫК)")
rate = {}
for arm in ARMS:
ks = [k for k, v in key.items() if v.get("arm") == arm and not v.get("fake")
and not v.get("planted") and k in ans
and v.get("axis") in ("ВЕРНОСТЬ", "ЯЗЫК")]
if ks:
rate[arm] = sum(1 for k in ks if ans[k]) / len(ks)
print(f" {arm}: {rate[arm]:.0%} подтверждено (n={len(ks)})")
if len(rate) == 2 and all(r >= 0.80 for r in rate.values()):
print("\n ≥80% у обоих армов → условие П-1 по доле выполнено")
else:
print("\n ⛔ условие П-1 «≥80% выборки верифицируются» НЕ выполнено")
return 0
if __name__ == "__main__":
a = sys.argv[1:] or ["--emit"]
if a[0] == "--emit":
emit()
elif a[0] == "--score":
sys.exit(score())
else:
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}")