textmachine/eval/dovodka/adjud.py

526 lines
32 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 | --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)`, то есть текстом служила сама цитата:
# окружения не было и многоточий тоже, тогда как у настоящих претензий окно
# всегда шло из полного варианта и почти всегда с «…». Плюс ложная цитата была
# ЦЕЛЫМ предложением 40200 знаков против коротких фрагментов у настоящих.
# Адъюдикатору не нужно было судить перевод — хватало формы карточки.
# Теперь ложная цитата берётся ФРАГМЕНТОМ той же длины, что настоящие, из того же
# варианта, и окно строится тем же `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}")