Add the span-join between the judge and deterministic axes, and blind adjudication with agreeableness and sensitivity controls
This commit is contained in:
parent
e630f97733
commit
d1f42c5470
2 changed files with 392 additions and 0 deletions
263
eval/dovodka/adjud.py
Normal file
263
eval/dovodka/adjud.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/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
|
||||
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]
|
||||
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}")
|
||||
129
eval/dovodka/spanjoin.py
Normal file
129
eval/dovodka/spanjoin.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env python3
|
||||
"""СПАН-ДЖОЙН: судейская ось против детерминированной. $0.
|
||||
|
||||
Единственная форма, в которой обе оси говорят об ОДНОМ. У детерминированных флагов есть МЕСТА
|
||||
(термин банка, слово-нарушитель, потерянная величина), у судейских находок — ЦИТАТЫ. Пересечение
|
||||
даёт число, прямо решающее спор H-2а против H-2б:
|
||||
|
||||
доля подтверждённых ошибок, лежащих ВНЕ покрытия детерминированных флагов.
|
||||
|
||||
Близко к нулю — «флагами всё отслеживается», флагового контура достаточно и смысловой критик
|
||||
не нужен. Велика — нужен критик, и известно НАСКОЛЬКО. Прибор заведён приёмкой другого модельного
|
||||
семейства (Д0.13 П-8) и стоит $0: цитаты уже обязательны, места уже вычисляются.
|
||||
|
||||
⚠ ЧТО ЭТО НЕ МЕРЯЕТ. Не «правильно ли судья счёл это ошибкой» — для этого адъюдикация. Здесь
|
||||
берётся то, что судья НАЗВАЛ ошибкой и что подтверждено присутствием цитаты в своём варианте,
|
||||
и спрашивается только одно: попадала ли эта ошибка в поле зрения детерминированного гейта.
|
||||
|
||||
⚠ ГРАНИЦА ИЗМЕРЕНИЯ. Флаг называет ТЕРМИН или СЛОВО, а цитата судьи — фразу. Совпадением
|
||||
считается вхождение флагованного слова в цитату. Это НИЖНЯЯ оценка покрытия: флаг мог указать на
|
||||
место, которое судья процитировал другими словами. Значит истинная доля «вне флагов» не больше
|
||||
печатаемой, и вывод «флагов не хватает» от этой границы только консервативнее.
|
||||
|
||||
Запуск: eval/.venv/bin/python eval/dovodka/spanjoin.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import statistics as st
|
||||
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
|
||||
KEYS = Path.home() / "books" / "dovodka" / "blind-keys-d4"
|
||||
FAMILIES = {"claude": Path.home() / "sud-d4", "sol": Path.home() / "sol-d4-work"}
|
||||
_norm = re.compile(r"[\s«»„“”\"'`.,!?;:—–-]+")
|
||||
|
||||
|
||||
def norm(t: str) -> str:
|
||||
return _norm.sub(" ", (t or "").lower()).strip()
|
||||
|
||||
|
||||
def flag_words(u: dict, text: str) -> set[str]:
|
||||
"""Слова, на которые указывает детерминированный гейт: канон-термины + адреса батареи."""
|
||||
out: set[str] = set()
|
||||
for g in C.canon_gaps(u["source"], text):
|
||||
m = re.search(r"«([^»]+)»", g)
|
||||
if m:
|
||||
out.add(norm(m.group(1)))
|
||||
for f in C.battery_flags(u, text):
|
||||
for m in re.finditer(r"«([^»]+)»", f):
|
||||
out.add(norm(m.group(1)))
|
||||
return {w for w in out if len(w) >= 3}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
us = {x["uid"]: x for x in S.units()}
|
||||
print("СПАН-ДЖОЙН — попадают ли судейские находки в поле зрения флагов\n")
|
||||
for fam, root in FAMILIES.items():
|
||||
if not root.exists():
|
||||
continue
|
||||
per_arm: dict[str, list[float]] = {}
|
||||
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((root / 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"]
|
||||
if arm.startswith("CTRL"):
|
||||
continue
|
||||
u = us.get(uid)
|
||||
if not u:
|
||||
continue
|
||||
var = S.text_of(arm, u) if arm != "A0" else C.base_a0(uid)
|
||||
if not var.strip():
|
||||
continue
|
||||
# флаги считаются на ВХОДЕ того арма, чей контур мы проверяем
|
||||
base = (C.base_a0(uid) if arm == "A4"
|
||||
else C.base_draft2(uid) if arm.startswith("A6")
|
||||
else C.base_draft(uid))
|
||||
if not base.strip():
|
||||
continue
|
||||
fw = flag_words(u, base)
|
||||
if not fw:
|
||||
continue
|
||||
w = re.search(rf"^{lab}-ПОЧЕМУ:\s*(.*)$", txt, re.M)
|
||||
if not w:
|
||||
continue
|
||||
qs = [norm(q) for q in re.findall(r"«([^»]{6,})»", w.group(1))]
|
||||
qs = [q for q in qs if q and q in norm(var)] # только подтверждённые
|
||||
if not qs:
|
||||
continue
|
||||
outside = sum(1 for q in qs if not any(x in q for x in fw))
|
||||
per_arm.setdefault(arm, []).append(outside / len(qs))
|
||||
print(f"### {fam.upper()}")
|
||||
print(f" {'арм':6s}{'единиц':>8s}{'ошибок ВНЕ покрытия флагов':>30s}")
|
||||
for arm in ("A0", "A1", "A1B", "A2", "A3", "A4", "A6", "A6F"):
|
||||
v = per_arm.get(arm)
|
||||
if v:
|
||||
print(f" {arm:6s}{len(v):8d}{st.mean(v):29.0%}")
|
||||
print()
|
||||
print("⚠ Это НИЖНЯЯ оценка доли «вне флагов»: флаг называет слово, судья цитирует фразу,")
|
||||
print(" и совпадение засчитывается по вхождению слова в цитату. Истинная доля не меньше.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Reference in a new issue