From d1f42c5470a7d054c58aff79e026a88f0d72061f Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 14 Aug 2026 02:00:17 +0300 Subject: [PATCH] Add the span-join between the judge and deterministic axes, and blind adjudication with agreeableness and sensitivity controls --- eval/dovodka/adjud.py | 263 +++++++++++++++++++++++++++++++++++++++ eval/dovodka/spanjoin.py | 129 +++++++++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 eval/dovodka/adjud.py create mode 100644 eval/dovodka/spanjoin.py diff --git a/eval/dovodka/adjud.py b/eval/dovodka/adjud.py new file mode 100644 index 00000000..5c170b0e --- /dev/null +++ b/eval/dovodka/adjud.py @@ -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}") diff --git a/eval/dovodka/spanjoin.py b/eval/dovodka/spanjoin.py new file mode 100644 index 00000000..6a055f0b --- /dev/null +++ b/eval/dovodka/spanjoin.py @@ -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())