#!/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}")