526 lines
32 KiB
Python
526 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
"""АДЪЮДИКАЦИЯ НАХОДОК — слепая проверка судейских претензий. $0.
|
||
|
||
Заведена правилом П-1 (Д0.13) и включается ровно в одном случае: контраст перешёл СВОЙ порог у
|
||
одного семейства и не перешёл у другого. Спор счётов между детекторами с разными точками
|
||
отсечения неразрешим в принципе; разрешим только спор НАХОДОК, и адъюдикация — единственная
|
||
форма, в которой второй взгляд добавляет информацию, а не вторую выборку того же судьи.
|
||
|
||
Что видит адъюдикатор: ИСХОДНИК фрагмента, ЦИТАТУ из перевода с окружением и СУТЬ ПРЕТЕНЗИИ.
|
||
Чего он НЕ видит: какой это арм, какое семейство претензию выдвинуло и сколько всего претензий
|
||
к этому арму. Слепота здесь не ритуал: зная арм, проверяющий начнёт подтверждать претензии к
|
||
тому, кто «должен» быть хуже.
|
||
|
||
⚠⚠ ДВА КОНТРОЛЯ, БЕЗ КОТОРЫХ АДЪЮДИКАЦИЯ ПУСТА. Без них доверие просто съезжает на уровень ниже:
|
||
проверяющий, склонный соглашаться с предъявленным, подтвердит всё что угодно.
|
||
|
||
ЛОЖНАЯ ПРЕТЕНЗИЯ — цитата из места, которому судья поставил по этой оси НОЛЬ, с придуманной
|
||
претензией. Верный ответ: «не ошибка». Меряет СГОВОРЧИВОСТЬ. Доля подтверждённых ложных
|
||
претензий есть прямая оценка того, сколько в основном пуле подтверждено зря.
|
||
ПОСАЖЕННЫЙ ДЕФЕКТ — цитата из маржевого декоя ровно в месте посадки, где истина известна
|
||
побайтно. Верный ответ: «ошибка». Меряет ЧУВСТВИТЕЛЬНОСТЬ.
|
||
|
||
Адъюдикатор, подтвердивший больше 20% ложных претензий ЛИБО опознавший меньше 60% посаженных,
|
||
аннулируется целиком — как судья, не поймавший декой.
|
||
|
||
⚠ КАК СЧИТАЕТСЯ ПОПРАВКА. Выборочно: по каждому арму берётся доля верифицированных претензий, и
|
||
сырой счёт ошибок умножается на неё. Правило П-1 говорит именно о ВЫБОРКЕ цитат, а не о сплошной
|
||
проверке, поэтому это исполнение буквы, а не упрощение. Приближение объявлено: поправка
|
||
предполагает, что доля верных претензий одинакова по единицам внутри арма.
|
||
|
||
Запуск: adjud.py --emit | --controls | --score | --selftest
|
||
"""
|
||
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() / "books" / "judging" / "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«»„“”\"'`.,!?;:—–-]+")
|
||
|
||
# ⚠ Имена осей — ЯВНЫМ списком, а не `[А-ЯЁ]{4,}`: см. починку дефекта Г-3 ниже по файлу.
|
||
AX_SPLIT = re.compile(r"(?=\b(?:ВЕРНОСТЬ|ТЕРМИН|ЯЗЫК|ФОРМА)\s*[:—–-])")
|
||
AX_HEAD = re.compile(r"(ВЕРНОСТЬ|ТЕРМИН|ЯЗЫК|ФОРМА)\s*[:—–-](.*)", re.DOTALL)
|
||
|
||
def _fragment(var: str, rnd: random.Random, target: int) -> str:
|
||
"""Фрагмент варианта ДЛИНОЙ ОКОЛО `target` — материал для ЛОЖНОЙ претензии.
|
||
|
||
⚠ Дефект Г-2, вторая половина. Мало построить ложной претензии такое же окно: если её цитата
|
||
систематически короче настоящих (18 знаков против 51 в первой починке), карточка выдаёт себя
|
||
длиной. Целевая длина берётся из ЭМПИРИЧЕСКОГО распределения настоящих цитат этого же прогона.
|
||
"""
|
||
words = [w for w in re.split(r"\s+", var.strip()) if w]
|
||
if len(words) < 3:
|
||
return ""
|
||
best, best_d = "", 10 ** 9
|
||
for _ in range(60):
|
||
i = rnd.randrange(0, len(words))
|
||
frag = ""
|
||
for j in range(i + 1, min(len(words) + 1, i + 25)):
|
||
frag = " ".join(words[i:j])
|
||
d = abs(len(frag) - target)
|
||
if d < best_d:
|
||
best, best_d = frag, d
|
||
if len(frag) > target + 15:
|
||
break
|
||
if best_d <= 4:
|
||
break
|
||
return best if len(best) >= 8 else ""
|
||
|
||
|
||
def norm(t: str) -> str:
|
||
return _norm.sub(" ", (t or "").lower()).strip()
|
||
|
||
|
||
def context(text: str, quote: str, width: int = 260) -> str:
|
||
"""Цитата с окружением — адъюдикатор обязан видеть, в каком месте она стоит.
|
||
|
||
⚠ ПОЧИНКА 15.08 (дефект Г-4). Прежняя редакция и ИСКАЛА, и РЕЗАЛА по `norm()`, то есть
|
||
отдавала адъюдикатору окно без пунктуации и в нижнем регистре. По такому окну нельзя судить
|
||
ни ФОРМУ (она стёрта), ни ЯЗЫК (регистр и пунктуация — часть языковой ошибки), а цитата в
|
||
самой претензии при этом шла в исходном виде: окно и цитата выглядели как разные тексты.
|
||
Теперь поиск идёт по нормализованному, а РЕЗ — по сырому тексту через карту позиций.
|
||
"""
|
||
span = locate(text, quote)
|
||
if span is None:
|
||
return quote
|
||
lo, hi = span
|
||
lo = max(0, lo - width // 2)
|
||
hi = min(len(text), hi + width // 2)
|
||
return ("…" if lo > 0 else "") + text[lo:hi] + ("…" if hi < len(text) else "")
|
||
|
||
|
||
def _normmap(text: str) -> tuple[str, list[int]]:
|
||
"""Нормализованный текст + карта «позиция в нём → позиция в СЫРОМ тексте»."""
|
||
buf, raw_pos, prev_space = [], [], True
|
||
for i, ch in enumerate(text):
|
||
if _norm.match(ch):
|
||
if prev_space:
|
||
continue
|
||
buf.append(" ")
|
||
raw_pos.append(i)
|
||
prev_space = True
|
||
else:
|
||
buf.append(ch.lower())
|
||
raw_pos.append(i)
|
||
prev_space = False
|
||
return "".join(buf), raw_pos
|
||
|
||
|
||
def locate(text: str, quote: str) -> tuple[int, int] | None:
|
||
"""Границы цитаты в СЫРОМ тексте; поиск ведётся по нормализованным формам."""
|
||
if not text or not quote:
|
||
return None
|
||
n, raw_pos = _normmap(text)
|
||
q = norm(quote)
|
||
i = n.find(q)
|
||
if i < 0 or not q:
|
||
return None
|
||
j = min(i + len(q), len(raw_pos)) - 1
|
||
return raw_pos[i], raw_pos[j] + 1
|
||
|
||
|
||
def margin_spans(text: str) -> tuple[str, list[tuple[int, int]]]:
|
||
"""То же лечение, что `sud.margin_plant`, но с ПОЗИЦИЯМИ посадок в получившемся тексте.
|
||
|
||
⚠ Зачем (дефект Г-1). Прежде «посаженной» считалась ЛЮБАЯ претензия к клетке маржевого декоя,
|
||
а судья цитировал в ней и обычные места: из 15 «посадок» реальными были 4. Контроль
|
||
чувствительности мерил не то — согласие с претензией к декою, а не опознание известной порчи.
|
||
"""
|
||
out, spans = text, []
|
||
for pat, rep, _c in S.MARGIN_PLANTS:
|
||
m = re.search(pat, out)
|
||
if not m:
|
||
continue
|
||
frag = m.expand(rep)
|
||
start, end_old = m.start(), m.end()
|
||
out = out[:start] + frag + out[end_old:]
|
||
delta = len(frag) - (end_old - start)
|
||
spans = [(a + delta, b + delta) if a >= end_old else (a, b) for a, b in spans]
|
||
spans.append((start, start + len(frag)))
|
||
return out, spans
|
||
|
||
|
||
def hits_planted(var: str, quote: str, spans: list[tuple[int, int]]) -> bool:
|
||
"""Накрывает ли цитата хоть одну посадку.
|
||
|
||
Строго ПЕРЕСЕЧЕНИЕМ отрезков, без запаса «где-то рядом»: судья, процитировавший соседнее
|
||
предложение, посадку НЕ опознал, и засчитывать это как чувствительность — тот же дефект Г-1
|
||
в более слабой форме.
|
||
"""
|
||
q = locate(var, quote)
|
||
if q is None:
|
||
return False
|
||
return any(q[0] < b and a < q[1] for a, b in spans)
|
||
|
||
|
||
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() / "books" / "judging" / "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.MULTILINE)
|
||
why = w.group(1) if w else ""
|
||
# какие оси ненулевые — по ним претензии реальные; нулевые дают материал для лжи
|
||
zero = []
|
||
for a in AX:
|
||
m = re.search(rf"^{lab}-{a}:\s*(\d+)", txt, re.MULTILINE)
|
||
if m and int(m.group(1)) == 0:
|
||
zero.append(a)
|
||
# ⚠ ПОЧИНКА 15.08 (дефект Г-3). Прежде разбор осей шёл через `[А-ЯЁ]{4,}:`, и
|
||
# `re.split` с таким lookahead режет ВНУТРИ имени оси: «ВЕРНОСТЬ:» даёт точки
|
||
# разреза на В, Е, Р, Н, О, и уцелевает хвост «ОСТЬ». В ключе прошлого прогона
|
||
# это видно прямо: ОСТЬ 45 · ОРМА 22 · РМИН 18 против ВЕРНОСТЬ 1 · ФОРМА 4.
|
||
# Следствие тяжелее самой опечатки: фильтр «несущие оси» считал одну ось из двух,
|
||
# а ложные и настоящие претензии стали различимы по форме имени оси.
|
||
for seg in AX_SPLIT.split(why):
|
||
m = AX_HEAD.match(seg.strip())
|
||
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":
|
||
# ⚠ дефект Г-1: посадкой считается только цитата, реально накрывшая
|
||
# подменённое место, а не любая претензия к клетке декоя
|
||
_, spans = margin_spans(C.base_a0(uid))
|
||
if hits_planted(var, q, spans):
|
||
plant_pool.append(rec)
|
||
# ⚠ ЛОЖНЫЕ ПРЕТЕНЗИИ — ПОЧИНКА 15.08 (дефект Г-2). Прежде окно ложной претензии
|
||
# строилось как `context(quote, quote)`, то есть текстом служила сама цитата:
|
||
# окружения не было и многоточий тоже, тогда как у настоящих претензий окно
|
||
# всегда шло из полного варианта и почти всегда с «…». Плюс ложная цитата была
|
||
# ЦЕЛЫМ предложением 40–200 знаков против коротких фрагментов у настоящих.
|
||
# Адъюдикатору не нужно было судить перевод — хватало формы карточки.
|
||
# Теперь ложная цитата берётся ФРАГМЕНТОМ той же длины, что настоящие, из того же
|
||
# варианта, и окно строится тем же `context(var, …)`.
|
||
if zero and arm in ARMS:
|
||
# цитата подбирается ПОСЛЕ прохода, когда известно распределение длин
|
||
# настоящих цитат этого прогона; здесь копится только материал
|
||
false_pool.append(dict(uid=uid, arm=arm, axis=rnd.choice(zero),
|
||
var=var, 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]
|
||
# ⚠ Длины ложных цитат берутся из ЭМПИРИЧЕСКОГО распределения настоящих (дефект Г-2).
|
||
real_len = [len(r["quote"]) for r in sample] or [40]
|
||
for r in rnd.sample(false_pool, min(N_FALSE, len(false_pool))):
|
||
var = r["var"]
|
||
frag = _fragment(var, rnd, rnd.choice(real_len))
|
||
if not frag:
|
||
continue
|
||
r = {k: v for k, v in r.items() if k != "var"}
|
||
r["quote"], r["ctx"] = frag, context(var, frag)
|
||
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],
|
||
"", "МЕСТО В ПЕРЕВОДЕ:", "<<<", 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 controls() -> int:
|
||
"""НЕРАЗЛИЧИМОСТЬ КЛАССОВ ПО ФОРМЕ КАРТОЧКИ — гейт, без которого контроли фиктивны.
|
||
|
||
Прежняя редакция проваливала его вчистую: ложная претензия шла без окружения и целым
|
||
предложением, то есть адъюдикатору хватало формы, чтобы отличить контроль от боевой претензии.
|
||
Здесь это меряется числом, а не обещается словом.
|
||
"""
|
||
if not AKEY.exists():
|
||
print("ключа нет — сначала --emit")
|
||
return 1
|
||
key = json.loads(AKEY.read_text(encoding="utf-8"))
|
||
cards = {}
|
||
for f in sorted(TASKS.glob("*.txt")):
|
||
for blk in f.read_text(encoding="utf-8").split("=" * 60)[1:]:
|
||
m = re.match(r"\s*(П\d{3})", blk)
|
||
if not m:
|
||
continue
|
||
w = re.search(r"<<<\n(.*?)\n>>>", blk, re.DOTALL)
|
||
q = re.search(r"в цитате «([^»]*)»", blk)
|
||
cards[m.group(1)] = (w.group(1) if w else "", q.group(1) if q else "")
|
||
if not cards:
|
||
print("заданий нет — сначала --emit")
|
||
return 1
|
||
groups = {
|
||
"настоящие": [k for k, v in key.items() if not v.get("fake") and not v.get("planted")],
|
||
"ЛОЖНЫЕ": [k for k, v in key.items() if v.get("fake")],
|
||
"посадки": [k for k, v in key.items() if v.get("planted")],
|
||
}
|
||
import statistics as _st
|
||
rows = {}
|
||
print(f"{'класс':10s} {'n':>4s} {'цитата':>7s} {'окно':>7s} {'с «…»':>6s} {'многостр':>9s}")
|
||
for name, sel in groups.items():
|
||
sel = [k for k in sel if k in cards]
|
||
if not sel:
|
||
print(f"{name:10s} пусто")
|
||
continue
|
||
ql = _st.median([len(cards[k][1]) for k in sel])
|
||
wl = _st.median([len(cards[k][0]) for k in sel])
|
||
el = sum(cards[k][0].startswith("…") or cards[k][0].endswith("…") for k in sel) / len(sel)
|
||
ml = sum("\n" in cards[k][0] for k in sel) / len(sel)
|
||
rows[name] = (ql, wl, el, ml)
|
||
print(f"{name:10s} {len(sel):4d} {ql:7.1f} {wl:7.1f} {el:5.0%} {ml:8.0%}")
|
||
rc = 0
|
||
base = rows.get("настоящие")
|
||
if base:
|
||
for name, r in rows.items():
|
||
if name == "настоящие":
|
||
continue
|
||
# медианы цитаты и окна не должны расходиться больше чем в полтора раза,
|
||
# а доля окон с многоточием — больше чем на четверть
|
||
if not (0.55 <= r[0] / max(base[0], 1) <= 1.8) or \
|
||
not (0.55 <= r[1] / max(base[1], 1) <= 1.8) or abs(r[2] - base[2]) > 0.25:
|
||
print(f"\n⛔ класс «{name}» ОТЛИЧИМ от настоящих претензий по форме карточки")
|
||
rc = 1
|
||
if not rc:
|
||
print("\nформа карточки классы не выдаёт — контроли пригодны")
|
||
return rc
|
||
|
||
|
||
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.IGNORECASE)
|
||
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
|
||
|
||
|
||
def selftest() -> int:
|
||
"""Проверки ПОЧИНЕННЫХ контролей. Без них адъюдикация переносит доверие на уровень ниже."""
|
||
fails: list[str] = []
|
||
|
||
def ok(cond: bool, msg: str) -> None:
|
||
if not cond:
|
||
fails.append(msg)
|
||
|
||
# Г-3: имя оси не режется
|
||
for name in ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА"):
|
||
got = [AX_HEAD.match(s.strip()).group(1)
|
||
for s in AX_SPLIT.split(f"{name}: «цитата» — пояснение") if AX_HEAD.match(s.strip())]
|
||
ok(got == [name], f"Г-3: ось {name} разобралась как {got}")
|
||
multi = "ВЕРНОСТЬ: «а» | «б». ЯЗЫК: «в». ФОРМА: «г»."
|
||
got = [AX_HEAD.match(s.strip()).group(1)
|
||
for s in AX_SPLIT.split(multi) if AX_HEAD.match(s.strip())]
|
||
ok(got == ["ВЕРНОСТЬ", "ЯЗЫК", "ФОРМА"], f"Г-3: разбор трёх осей дал {got}")
|
||
|
||
# Г-4: окно режется по СЫРОМУ тексту — пунктуация и регистр на месте
|
||
raw = 'Он сказал: «Стрела уже слетела с тетивы!» И замолчал, глядя вдаль.'
|
||
win = context(raw, "Стрела уже слетела", width=40)
|
||
ok("«" in win or "!" in win or "С" in win,
|
||
f"Г-4: окно потеряло пунктуацию и регистр: {win!r}")
|
||
ok(win.strip("…") in raw, f"Г-4: окно не является подстрокой сырого текста: {win!r}")
|
||
|
||
# Г-1: посадки имеют позиции, и цитата вне них не считается посаженной
|
||
base = "Он не поверил своим глазам. Их было три. Обычное объяснение не помогло."
|
||
planted, spans = margin_spans(base)
|
||
ok(bool(spans), "Г-1: посадок не найдено — контроль чувствительности будет пуст")
|
||
ok(planted != base, "Г-1: текст декоя не отличается от эталона")
|
||
if spans:
|
||
a, b = spans[0]
|
||
ok(hits_planted(planted, planted[a:b], spans), "Г-1: цитата ПО посадке не опознана")
|
||
if spans:
|
||
# цитата из области, ЗАВЕДОМО не пересекающей ни одну посадку
|
||
busy = sorted(spans)
|
||
gap = next(((busy[i][1], busy[i + 1][0]) for i in range(len(busy) - 1)
|
||
if busy[i + 1][0] - busy[i][1] >= 12), None)
|
||
outside = planted[gap[0]:gap[1]].strip(" .,!?") if gap else ""
|
||
ok(not outside or not hits_planted(planted, outside, spans),
|
||
f"Г-1: цитата ВНЕ посадки ({outside!r}) засчитана как посаженная")
|
||
|
||
# Г-2: ложная цитата неотличима от настоящей по длине
|
||
rnd = random.Random(SEED)
|
||
long = " ".join(f"слово{i}" for i in range(40))
|
||
for target in (20, 45, 80):
|
||
frag = _fragment(long, rnd, target)
|
||
ok(bool(frag) and abs(len(frag) - target) <= 8,
|
||
f"Г-2: фрагмент под цель {target} вышел {len(frag)}: {frag!r}")
|
||
|
||
# Г-2 на живом ключе, если он есть: форма карточки не должна выдавать ложную
|
||
if AKEY.exists():
|
||
key = json.loads(AKEY.read_text(encoding="utf-8"))
|
||
bad = [k for k, v in key.items() if v.get("axis") not in
|
||
("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА")]
|
||
ok(not bad, f"Г-3: в ключе {len(bad)} претензий с обрезанным именем оси "
|
||
f"(пример {bad[:3]}) — ключ снят ДО починки, требуется пере-эмиссия")
|
||
|
||
for m in fails:
|
||
print("ПРОВАЛ:", m)
|
||
print(f"селфтест адъюдикации: провалов {len(fails)}")
|
||
return 1 if fails else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
a = sys.argv[1:] or ["--emit"]
|
||
if a[0] == "--emit":
|
||
emit()
|
||
elif a[0] == "--score":
|
||
sys.exit(score())
|
||
elif a[0] == "--selftest":
|
||
sys.exit(selftest())
|
||
elif a[0] == "--controls":
|
||
sys.exit(controls())
|
||
else:
|
||
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}")
|