textmachine/eval/role_topology/battery.py

649 lines
47 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.5 — ДЕТЕРМИНИРОВАННАЯ БАТАРЕЯ. Первичные метрики бейк-оффа там, где судья не нужен.
Зачем она первична. Эксп-20 §5.4 намерил, что судья-LLM согласен сам с собой на ИДЕНТИЧНОМ входе
в 56% клеток (33% на несущих осях) и разниц меньше ~2:1 не разрешает. Значит любой вывод бейк-оффа,
опирающийся только на судью, обречён быть «неопределённо». Детерминированные метрики шума не имеют
вовсе: их повтор даёт то же число. Поэтому топология сравнивается сначала ими, а судья добирает
только те оси, которые кодом не берутся (художественность прозы).
Что меряется и откуда взяты правила. Каждая проверка ниже — реализация уже написанной проектом
спецификации, а не моё изобретение: `docs/research/12-failure-modes-ru-target.md` перечисляет
12 режимов отказа русской цели с разделом «Детекция» у каждого, и оттуда взяты и признаки, и
границы применимости. Ссылки стоят у каждой функции.
Генеральность. Русско-специфичные факты (междометия-транслит, кавычки-ёлочки, ты/вы, приставки)
собраны в СЛОВАРЬ `RU` внизу как ДАННЫЕ ЦЕЛИ. Код по паре не ветвится: для →de/→en меняется таблица,
а не функции. Пар-специфика источника (нормализация 万/亿, палладиевские слоги) живёт отдельно и
берётся из langpack движка. Ревью-вопрос §0.1 «заработает ли пара, которой в репо нет, без правки
Go» здесь беспредметен (Go нет вовсе), но структурная аналогия соблюдена сознательно.
⚠ НОРМАЛИЗАЦИЯ ДО ТОКЕНИЗАЦИИ обязательна и найдена исполнением. Комбинирующий знак ударения
(U+0301) в «бо́льшим» разрезает слово для наивного токенизатора — именно так в размеченном наборе
пакета-6 появились фантомные словотипы «льшим»/«льшую». Боевой путь от этого НЕ страдает (проверено
Go-оверлеем: `ExportNormalize` знак снимает, а `SanitizeOutput` на тексте с ударением и без даёт
одинаковый вердикт), но полигонный код обязан снимать его сам.
Запуск (офлайн, $0):
eval/.venv/bin/python eval/role_topology/battery.py --selftest # проверка правил
eval/.venv/bin/python eval/role_topology/battery.py --corpus # прогон по corpus.jsonl
"""
from __future__ import annotations
import json
import re
import statistics
import sys
import unicodedata
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "exp16"))
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
import pymorphy3 # noqa: E402
import yaml # noqa: E402
from detect_word import decomposes as _decomposes # noqa: E402
from detect_word import translit_shape as _translit_shape # noqa: E402
from palladius import is_palladius_token, palladius_conformant # noqa: E402
_MORPH = pymorphy3.MorphAnalyzer()
# ─────────────────────────── ДАННЫЕ ЦЕЛИ (ru). Не код — таблица. ────────────────────────────
RU = {
# research/12 §9: транслит английских/японских междометий — маркер машинного перевода.
# Единичное вхождение легитимно (для YA «вау» нормален), меряется ЧАСТОТА, не факт.
"interjection_calques": ["ауч", "упс", "воу", "вау", "угх", "хах", "ара", "ох ты боже",
"оу", "йес", "хмпф", "аргх", "эйч", "уупс"],
# research/12 §10: русская норма — реплика с абзаца вводится ТИРЕ, кавычки для реплик не
# используются. Дефис вместо тире и прописная после атрибуции — английская схема.
"dialogue_dash": "",
"quote_open": "«",
"quote_close": "»",
# research/12 §11: омографы, где ё обязательна при ЛЮБОЙ политике проекта.
"yo_homographs": [("все", "всё"), ("узнаем", "узнаём"), ("совершенный", "совершённый"),
("небо", "нёбо"), ("осел", "осёл"), ("падеж", "падёж")],
# research/12 §12: маркированные неофеминитивы — регистровый сбой вне современной прозы.
"marked_feminitives": ["авторка", "докторка", "блогерка", "редакторка", "директорка",
"профессорка", "лекторка"],
}
# Источник zh: множители величин. Ошибка здесь = ошибка в фактуре, самый дорогой класс.
ZH_MAGNITUDE = {"": 10_000, "亿": 100_000_000, "": 1_000, "": 100}
_ZH_DIGITS = {"": 0, "": 1, "": 2, "": 2, "": 3, "": 4, "": 5,
"": 6, "": 7, "": 8, "": 9, "": 10}
# Русские числительные (леммы) — данные ЦЕЛИ. Нужны, чтобы «пятьсот» не читалось как потеря
# величины 500: художественный русский пишет числа словами, а исходник — цифрами и иероглифами.
_RU_NUM = {"ноль": 0, "один": 1, "два": 2, "две": 2, "три": 3, "четыре": 4, "пять": 5,
"шесть": 6, "семь": 7, "восемь": 8, "девять": 9, "десять": 10,
"одиннадцать": 11, "двенадцать": 12, "тринадцать": 13, "четырнадцать": 14,
"пятнадцать": 15, "шестнадцать": 16, "семнадцать": 17, "восемнадцать": 18,
"девятнадцать": 19, "двадцать": 20, "тридцать": 30, "сорок": 40, "пятьдесят": 50,
"шестьдесят": 60, "семьдесят": 70, "восемьдесят": 80, "девяносто": 90,
"сто": 100, "двести": 200, "триста": 300, "четыреста": 400, "пятьсот": 500,
"шестьсот": 600, "семьсот": 700, "восемьсот": 800, "девятьсот": 900,
# СОБИРАТЕЛЬНЫЕ (добор 07.08 по вскрытым ложным срабатываниям). Русский переводит
# 数万人 как «десятки тысяч», 一百多位 как «более сотни» — обе формы верны, а без этих
# лемм разбор давал потерю 10000/100 и выдумку 1000 на безупречном тексте.
"десяток": 10, "сотня": 100, "дюжина": 12}
_RU_MULT = {"тысяча": 1_000, "миллион": 1_000_000, "миллиард": 1_000_000_000}
# Пол фактонесущей величины. ВЫБРАН ЗАМЕРОМ (свип 8 конфигураций на 91 единице): при поле 10
# ложных «появившихся» 1.64 на единицу, при 100 — 0.27 при тех же 0.25 потерянных. Малые
# числительные в русском живут местоимениями («один из них»), которым в 一个 нет величины.
MIN_VALUE = 100
_RE_CYR_WORD = re.compile(r"[А-Яа-яЁё]+")
_RE_LAT_WORD = re.compile(r"[A-Za-z]+")
_RE_HAN = re.compile(r"[㐀-鿿]")
def normalize(text: str) -> str:
"""NFC + снятие комбинирующих знаков НА КИРИЛЛИЧЕСКОЙ базе (см. баннер модуля).
Зеркалит боевой `stripCombiningOnCyrillic` (`checks/sanitizer.go:746`): знак на латинской
базе (é во французском имени) сохраняется, на кириллической — снимается.
"""
out, prev_cyr = [], False
for ch in unicodedata.normalize("NFC", text):
if unicodedata.category(ch) == "Mn":
if prev_cyr:
continue
out.append(ch)
continue
out.append(ch)
prev_cyr = bool(_RE_CYR_WORD.match(ch))
return "".join(out)
# ⚠ ДЕФИС ВНУТРИ СЛОВА — ЧАСТЬ СЛОВА. Первая редакция резала по нему, и первые половины составных
# («тёмно-зелёный», «точь-в-точь», «перво-наперво») приходили в детектор отдельными токенами,
# которых в словаре нет по построению. На прогоне армов это дало 15 ложных флагов «выдуманное
# слово» из 15 и завысило долю флагнутых единиц, от которой считается вся экономика маршрутизации.
# Поймано осмотром флагов, а не чтением кода: числа выглядели правдоподобно.
_RE_CYR_WORD_HYPH = re.compile(r"[А-Яа-яЁё]+(?:-[А-Яа-яЁё]+)*")
def words(text: str) -> list[str]:
return _RE_CYR_WORD_HYPH.findall(normalize(text))
# ──────────────────────────────── проверки батареи ────────────────────────────────
def check_palladius(final: str, bank_dst: set[str]) -> dict:
"""1. Палладий-линт. research/12 не покрывает транскрипцию — источник D39.46 Z-B3 и exp16.
Кандидат в имена: слово с ПРОПИСНОЙ в середине предложения, незнакомое морфологии. Если оно
не сегментируется палладиевскими слогами — это либо не-палладиевская транскрипция (нарушение
конвенции), либо порча. Строки банка проверяются отдельно: `palladius_conformant` требует
конформности КАЖДОГО кириллического слова строки (Фан Юань — обе части).
"""
txt = normalize(final)
bad_text, checked = [], 0
for m in re.finditer(r"(?<![.!?…]\s)(?<!^)\b([А-ЯЁ][а-яё]{2,})", txt, re.M):
w = m.group(1)
if any(p.is_known for p in _MORPH.parse(w.lower())):
continue
# ⚠ ПЕРЕВЕДЁННОЕ имя — не нарушение конвенции, а другой класс. «Первопредок», «Виноцвет»,
# «Кровавокрылая» палладиевскими быть не обязаны: их перевели, а не транскрибировали.
# Разложимость на русские части и есть признак перевода — этот фильтр снял 6 из 13
# выживших срабатываний, и все шесть были ложными.
if _decomposes(w.lower()):
continue
checked += 1
# ⚠ Голая проверка на слоги давала 96 ложных из 2192 — все склонённые транскрипции
# («Чжэна», «Мочэнем»): русское окончание слогом Палладия не является. Порог стема здесь
# 1 слог, а не 2: кандидат УЖЕ опознан как имя, а односложные транскрипции нормальны
# (Чжэн · Фан · Сун) — при пороге 2 одно «Чжэна» давало 24 ложных.
if not _translit_shape(w, stem_min_syllables=1):
bad_text.append(w)
bad_bank = [d for d in bank_dst if _RE_CYR_WORD.search(d) and not palladius_conformant(d)]
return dict(name_candidates=checked, nonconformant_text=len(bad_text),
nonconformant_text_words=sorted(set(bad_text))[:12],
nonconformant_bank=len(bad_bank))
def check_cjk_leak(final: str, threshold: float = 0.02) -> dict:
"""7. Утечка ханьцзы. Порог НИЖЕ боевого 0.15 — это прямое требование промта Ф0.5.
Боевой `classify` флагает эхо при доле исходной письменности >0.15 (`disposition.go:224`), а
эксп-20 §5.4 нашёл живую МИКРО-утечку 19 знаков на окно (доля ~0.005), которую этот порог не
видит. Здесь считается и доля, и АБСОЛЮТНОЕ число — второе и есть чувствительная метрика.
"""
t = normalize(final)
han = _RE_HAN.findall(t)
letters = [c for c in t if c.isalpha()]
share = len(han) / max(1, len(letters))
return dict(han_chars=len(han), han_share=round(share, 5),
over_probe_threshold=share > threshold,
over_prod_threshold=share > 0.15)
def check_typography(final: str) -> dict:
"""8. Русская издательская типографика. research/12 §10 (диалоги) + §11 (ё).
Считаются НАРУШЕНИЯ, а не наличие: реплика, введённая кавычкой или дефисом вместо тире;
прямые ASCII-кавычки; дефис на месте тире между пробелами. Признаки взяты дословно из
раздела «Детекция» §10 («запрет " и “ в диалогах, em-dash + пробел в начале реплики»).
"""
t = normalize(final)
lines = [l.strip() for l in t.split("\n") if l.strip()]
ascii_quotes = t.count('"') + t.count("") + t.count("")
hyphen_as_dash = len(re.findall(r"(?<= )-(?= )", t))
dash_openers = sum(1 for l in lines if l.startswith(RU["dialogue_dash"]))
# ⚠ Ёлочка в начале строки НЕ нарушение и из счёта убрана: 228 «нарушений» на 91 единице
# оказались легитимными цитатами и внутренней речью, для которых «…» — русская норма.
# Нарушение — только АНГЛИЙСКАЯ кавычка как ввод реплики (research/12 §10 «запрет " и “»).
ascii_openers = sum(1 for l in lines if l[:1] in ('"', ""))
guillemet_openers = sum(1 for l in lines if l[:1] == RU["quote_open"])
# Схема Розенталя: авторские слова после реплики идут со СТРОЧНОЙ («— …, — сказал он»).
# ⚠ Прописная сама по себе нарушением не является — имя собственное обязано быть с прописной
# (119 срабатываний на корпусе были в основном именами). Флагается только случай, когда с
# прописной стоит ИЗВЕСТНЫЙ ГЛАГОЛ РЕЧИ/ДЕЙСТВИЯ, то есть заведомо не имя.
upper_after_attr = 0
for m in re.finditer(r"[,!?…]\s+—\s+([А-ЯЁ][а-яё]+)", t):
w = m.group(1).lower()
if any(p.tag.POS == "VERB" for p in _MORPH.parse(w) if p.is_known):
upper_after_attr += 1
return dict(lines=len(lines), dash_openers=dash_openers,
guillemet_openers=guillemet_openers, ascii_openers=ascii_openers,
ascii_quotes=ascii_quotes, hyphen_as_dash=hyphen_as_dash,
upper_after_attribution=upper_after_attr,
violations=ascii_openers + ascii_quotes + hyphen_as_dash + upper_after_attr)
def check_yo(final: str) -> dict:
"""11. Консистентность ё. research/12 §11: смесь хуже любой из двух политик.
Меряются две разные вещи: (а) СМЕСЬ — доля ё-содержащих слов, где ё вообще возможна;
(б) ОМОГРАФЫ без ё, где она обязательна при любой политике (все/всё, узнаем/узнаём).
"""
ws = [w.lower() for w in words(final)]
with_yo = sum(1 for w in ws if "ё" in w)
# ⚠ Сырой счёт омографов без ё был ложной метрикой (152 на 91 единице): «все» без ё чаще
# всего ПРАВИЛЬНО — это множественное «все», а не «всё». Различить их без разбора контекста
# нельзя, поэтому меряется не омограф, а ПОЛИТИКА: текст, который использует ё где-то, но не
# везде, непоследователен (research/12 §11: «смесь хуже любой из двух политик»). Омограф
# считается нарушением ТОЛЬКО в таком тексте — там ё уже объявлена автором как употребляемая.
# ⚠ СЧЁТ ОМОГРАФОВ СНЯТ С РОЛИ НАРУШЕНИЯ — замером, а не рассуждением: из 152 срабатываний на
# 91 единице 130 дало обычное множественное «все» и 22 — «небо» в прямом значении. То есть
# правило почти целиком ложное, и различить «все/всё» без разбора контекста нельзя.
# Остаётся то, что детерминированно ИЗМЕРИМО: политика ё и её единообразие между единицами
# (research/12 §11: «смесь хуже любой из двух политик»). Омографы печатаются справочно.
homograph_seen = sum(1 for w in ws if any(w == bare for bare, _ in RU["yo_homographs"]))
return dict(words=len(ws), words_with_yo=with_yo,
yo_policy="есть" if with_yo else "нет", homographs_seen=homograph_seen)
def check_translationese(final: str) -> dict:
"""9. Translationese-прокси. research/12 §9 (междометия-транслит) + §12 (неофеминитивы).
⚠ ГРАНИЦА, объявленная заранее: «длины предложений против НАТИВНОГО ру-корпуса» промт просит,
но нативного русского референс-корпуса в дереве нет, и подставлять чужую константу нельзя
(тот же класс ошибки, что снятый жанровый словарь D39.47). Поэтому распределение длин
печатается КАК ЕСТЬ и сравнивается МЕЖДУ АРМАМИ — для бейк-оффа этого достаточно, для
абсолютного вердикта «это translationese» — нет, и так и читается.
"""
# ⚠ Матчинг ТОЛЬКО по границам слова. Подстрочный `in` давал 77 «ара» (внутри «барабан»,
# «характер») и 13 «ауч» (внутри «паучьей») на 91 единице — то есть метрика мерила бы шум.
toks = Counter(w.lower() for w in words(final))
calques = {c: toks[c] for c in RU["interjection_calques"] if toks[c]}
fem = {f: toks[f] for f in RU["marked_feminitives"] if toks[f]}
sents = [s for s in re.split(r"(?<=[.!?…])\s+", normalize(final)) if s.strip()]
lens = [len(s.split()) for s in sents] or [0]
return dict(sentences=len(sents), sent_len_median=statistics.median(lens),
sent_len_p90=sorted(lens)[int(0.9 * (len(lens) - 1))],
calque_interjections=calques, marked_feminitives=fem)
# Неколичественные обороты: иероглиф величины стоит, величины нет. Список закрытый и короткий —
# добирается замером на корпусе, а не на глаз (метод тот же, что дал MIN_VALUE).
_ZH_IDIOM = ("百分之", "千分之", "万分之", # доли: 百分之一 = одна сотая, не «сто»
"十分", "十足", "万一", "万分", "千万", "百般", "万物", "万象", "百姓", "千秋")
_RE_ZH_RUN = re.compile(r"[零一二两三四五六七八九十百千万亿]+")
def _zh_values(source: str) -> set[int]:
"""Величины китайской записи. Разбор идёт по СВЯЗНОМУ ПРОБЕГУ, а не по паре «цифры+разряд».
⚠ Это починка дефекта, найденного адверсариальным ревью 07.08 и воспроизведённого исполнением.
Прежняя версия брала разряды поодиночке, тогда как русская сторона накапливает составное
числительное целиком: 两千三百 давало {2000, 300}, а «две тысячи триста» — {2300}, и на
БЕЗУПРЕЧНОМ переводе проверка печатала две потери и одну выдумку. Проверено на трёх парах
(两千三百 · 三千五百 · 一百二十) — все три давали ложное срабатывание.
Голый разряд теперь берётся (万里 = 10000), потому что русская сторона берёт голый множитель
(«десять тысяч ли»): несимметричность правил и была источником ложных выдумок. Цена симметрии —
неколичественные обороты, они гасятся закрытым списком `_ZH_IDIOM`.
"""
out: set[int] = set()
clean = source
for idiom in _ZH_IDIOM:
clean = clean.replace(idiom, " ")
for m in _RE_ZH_RUN.finditer(clean):
run = m.group(0)
if run == "":
continue # голое 十 счётно почти никогда; 百年/千年/万里 — счётны
total = section = cur = 0
for ch in run:
if ch in _ZH_DIGITS and ch != "": # 十 живёт в обоих словарях; здесь он РАЗРЯД
cur = _ZH_DIGITS[ch]
elif ch == "":
section += (cur or 1) * 10
cur = 0
elif ch in ("", ""):
section += (cur or 1) * ZH_MAGNITUDE[ch]
cur = 0
elif ch == "":
total += (section + cur or 1) * 10_000
section = cur = 0
elif ch == "亿":
total = (total + section + cur or 1) * 100_000_000
section = cur = 0
v = total + section + cur
if v:
out.add(v)
return out
def check_numbers(source: str, final: str) -> dict:
"""6. Перенос чисел с нормализацией 万/亿. research/12 не покрывает; источник — бэклог 12
(«HARD-value-детектор 成/万») и D39.79.
Из источника извлекаются величины в двух записях — арабской и китайской иероглифической
(三万 = 30000). Из перевода — арабские и русские числительные-множители. Сравниваются
МНОЖЕСТВА величин: пропавшая или появившаяся величина есть дефект фактуры.
⚠ Проверка НЕ ловит переставленные величины при совпадающем множестве — объявлено.
"""
src_vals = set(int(x) for x in re.findall(r"\d+", source))
src_vals |= _zh_values(source)
dst_vals = set(int(x) for x in re.findall(r"\d+", normalize(final)))
for m in re.finditer(r"(\d+)\s*(тысяч\w*|миллион\w*)", normalize(final), re.I):
mult = 1_000 if m.group(2).lower().startswith("тысяч") else 1_000_000
dst_vals.add(int(m.group(1)) * mult)
# ⚠ РУССКИЕ ЧИСЛИТЕЛЬНЫЕ СЛОВАМИ. Без них проверка объявляла потерянными 64 величины на 91
# единице — почти все ложно: русский художественный текст пишет «пятьсот», а не «500».
# Разбор идёт по ЛЕММЕ (склонение снимает морфология), накопление — как в русском счёте:
# сотни+десятки+единицы складываются, множитель умножает накопленное.
acc, cur = 0, 0
for w in words(final):
# Первый разбор — не всегда числительный: у «десятки» это «десятка», а не «десяток».
# Берём первую лемму, которая ВООБЩЕ числительное, иначе откатываемся на первый разбор.
lemmas = [p.normal_form for p in _MORPH.parse(w.lower())]
lemma = next((x for x in lemmas if x in _RU_NUM or x in _RU_MULT), lemmas[0])
if lemma in _RU_NUM:
cur += _RU_NUM[lemma]
continue
if lemma in _RU_MULT:
acc += max(cur, 1) * _RU_MULT[lemma]
cur = 0
continue
if lemma == "и" and (cur or acc):
continue # «тысяча И одна ночь» — союз внутри составного, не разрыв
if cur or acc:
dst_vals.add(acc + cur)
acc = cur = 0
if cur or acc:
dst_vals.add(acc + cur)
# ⚠ ПОЛ ВЕЛИЧИНЫ. Без него метрика мерила шум: «выдуманными» выходили 1·2·3·4·5 — это русские
# числительные в местоименной роли («один из них»), которым в 一个 источника не соответствует
# никакая величина. Замер на 91 единице: 82 случая «1», 65 «2», 51 «3». Порог 10 оставляет
# ФАКТОНЕСУЩИЕ величины (ранги, расстояния, счёт камней) — ровно тот класс, ради которого
# заведена строка 12 бэклога («HARD-value-детектор 成/万»).
# Цена объявлена: потеря величины 19 этой проверкой НЕ ловится.
src_big = {v for v in src_vals if v >= MIN_VALUE}
dst_big = {v for v in dst_vals if v >= MIN_VALUE}
return dict(src_values=len(src_big), dst_values=len(dst_big),
lost=sorted(src_big - dst_big)[:8], invented=sorted(dst_big - src_big)[:8],
lost_n=len(src_big - dst_big), invented_n=len(dst_big - src_big))
def check_ty_vy(final: str) -> dict:
"""5. Ты/вы. research/12 §2: переход ты↔вы внутри сцены — СОБЫТИЕ, а не шум.
Полная атрибуция пар «кто→кому» требует speaker-ID (research/15: на zh 85% спикер, адресат
на 1015 пп хуже) и здесь честно НЕ делается. Меряется наблюдаемое детерминированно: смешение
обеих форм в ОДНОЙ единице. Это нижняя граница дефекта — смешение между разными собеседниками
легитимно, поэтому число читается как сигнал к просмотру, а не как счёт ошибок.
"""
ws = [w.lower() for w in words(final)]
ty = sum(1 for w in ws if w in ("ты", "тебя", "тебе", "тобой", "твой", "твоя", "твоё",
"твои", "твоего", "твоей"))
vy = sum(1 for w in ws if w in ("вы", "вас", "вам", "вами", "ваш", "ваша", "ваше",
"ваши", "вашего", "вашей"))
return dict(ty=ty, vy=vy, mixed_in_unit=bool(ty and vy))
def check_gender(final: str, cards: dict[str, str]) -> dict:
"""4. Род глагола прош. вр. против карточек. research/12 §1 — «жалоба №1 читателей MTL».
⚠ ПАДЕЖ ИМЕНИ (починка по ревью 07.08). Карточки намеренно несут склонённые формы, иначе
«Фан Юаня» не сопоставится с «Фан Юань». Но косвенная форма имени по определению НЕ подлежащее,
и стоящий рядом глагол согласован с кем-то другим: «Воля Фан Юаня БЫЛА тверда» давала
рассогласование male/female на безупречном тексте. Все 6 «рассогласований» прошлого прогона
были этим классом. Кандидатом теперь считается только форма, у которой есть именительный разбор.
Карточка = {имя: 'male'|'female'}. Ищется «Имя + глагол прош. вр.» и сверяется род. Работает
только по ЯВНОЙ близости имени и глагола (окно 3 слова): местоименные и удалённые согласования
требуют кореференции и объявлены вне охвата. Без карточек проверка возвращает нули — это
вход строки 82 бэклога, а не заглушка.
"""
if not cards:
return dict(checked=0, mismatched=0, cases=[])
t = normalize(final)
checked, bad = 0, []
for name, gender in cards.items():
for m in re.finditer(re.escape(name) + r"((?:\s+\S+){0,3})", t):
if not _is_nominative(name):
continue
for w in _RE_CYR_WORD.findall(m.group(1)):
for p in _MORPH.parse(w.lower()):
if p.tag.POS == "VERB" and "past" in str(p.tag):
g = "female" if "femn" in str(p.tag) else (
"male" if "masc" in str(p.tag) else None)
if g:
checked += 1
if g != gender:
bad.append((name, w, gender, g))
break
return dict(checked=checked, mismatched=len(bad), cases=bad[:8])
def _is_nominative(name: str) -> bool:
"""Есть ли у ПОСЛЕДНЕГО токена имени именительный разбор. Кэшируется: вызовов много."""
if name in _NOMN_CACHE:
return _NOMN_CACHE[name]
toks = _RE_CYR_WORD.findall(name)
ok = True
if toks:
parses = _MORPH.parse(toks[-1].lower())
# Неизвестное морфологии имя пропускаем: отсечь по незнанию хуже, чем проверить.
known = [p for p in parses if p.tag.case]
ok = (not known) or any(p.tag.case == "nomn" for p in known)
_NOMN_CACHE[name] = ok
return ok
_NOMN_CACHE: dict[str, bool] = {}
def load_cards() -> dict[str, str]:
"""4-бис. КАРТОЧКИ ПЕРСОНАЖЕЙ (имя → пол) — вход проверки рода.
⚠ Пробел, найденный владельцем при сверке трекера: Ф0.3 промта заказывала карточки как ДАННЫЕ
для этой проверки, а я их не построил. Следствие было тихим и потому опасным: `check_gender`
принимает карточки аргументом и без них возвращает нули, то есть одна из девяти проверок
батареи всё время была инертна, а батарея отчитывалась как целое. Проверка, которая молча
ничего не проверяет, хуже отсутствующей — отсутствующую видно.
Карточки берутся из ПОДПИСАННОГО сида книги (поле `gender`), а не составляются мной: пол
персонажа — факт о книге, и выдумывать его полигон не вправе. Склонённые формы из `decl.forms`
добавляются как отдельные ключи, иначе «Фан Юаня» не сопоставится с «Фан Юань».
"""
seed = Path.home() / "books" / "gu-zhenren" / "guzhenren-seed-v2.yaml"
if not seed.exists():
return {}
data = yaml.safe_load(seed.read_text(encoding="utf-8")) or {}
cards: dict[str, str] = {}
for t in data.get("terms", []) or []:
g = t.get("gender")
if g not in ("male", "female"):
continue
for name in [t.get("dst", "")] + list((t.get("decl") or {}).get("forms") or []):
if name and _RE_CYR_WORD.search(name):
cards[name] = g
return cards
def check_repeat_consistency(units: list[dict]) -> dict:
"""2+3. Повтор-консистентность и коллизии, КРОСС-ЕДИНИЧНО.
Единственная метрика батареи, которая работает не на одной единице, а на всём прогоне —
и ровно она отвечает на продуктовую цель «консистентные термины на всю книгу». Считается по
строкам банка: сколько РАЗНЫХ русских строк встретилось для одного исходного терма (расхождение)
и сколько разных исходных термов схлопнулось в одну русскую строку (слипание).
"""
src2dst: dict[str, Counter] = defaultdict(Counter)
for u in units:
for src, dst in u.get("term_pairs", []):
src2dst[src][dst] += 1
divergent = {s: dict(c) for s, c in src2dst.items() if len(c) > 1}
dst2src: dict[str, set] = defaultdict(set)
for s, c in src2dst.items():
for d in c:
dst2src[d].add(s)
merged = {d: sorted(ss) for d, ss in dst2src.items() if len(ss) > 1}
return dict(terms=len(src2dst), divergent=len(divergent), merged=len(merged),
divergent_examples=dict(list(divergent.items())[:5]),
merged_examples=dict(list(merged.items())[:5]))
def check_noop(draft: str, final: str) -> dict:
"""10. No-op метрика (стандарт APE): что второй проход НЕ ТРОНУЛ и что он изменил.
Эксп-20 §4 намерил, что редактор переписывает 65% слов черновика. Само по себе это ни хорошо,
ни плохо — важно, покупает ли изменение качество. Поэтому дельта качества обязана считаться
ОТДЕЛЬНО на изменённых и неизменённых сегментах (иначе выигрыш на одном сегменте маскируется
порчей на другом). Здесь считается разметка сегментов; сама дельта — в связке с батареей.
"""
import difflib # noqa: PLC0415
from align import split_ru # noqa: PLC0415
a, b = split_ru(normalize(draft)), split_ru(normalize(final))
sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
kept = sum(bl.size for bl in sm.get_matching_blocks())
dw, fw = normalize(draft).split(), normalize(final).split()
smw = difflib.SequenceMatcher(None, dw, fw, autojunk=False)
kept_w = sum(bl.size for bl in smw.get_matching_blocks())
return dict(draft_sentences=len(a), final_sentences=len(b), sentences_untouched=kept,
sentence_untouched_share=round(kept / max(1, len(a)), 4),
word_untouched_share=round(kept_w / max(1, len(dw)), 4))
# ──────────────────────────────── драйвер ────────────────────────────────
@dataclass
class Unit:
source: str
draft: str
final: str
cards: dict = field(default_factory=dict)
bank_dst: set = field(default_factory=set)
term_pairs: list = field(default_factory=list)
def run_unit(u: Unit) -> dict:
return {
"palladius": check_palladius(u.final, u.bank_dst),
"cjk_leak": check_cjk_leak(u.final),
"typography": check_typography(u.final),
"yo": check_yo(u.final),
"translationese": check_translationese(u.final),
"numbers": check_numbers(u.source, u.final),
"ty_vy": check_ty_vy(u.final),
"gender": check_gender(u.final, u.cards),
"noop": check_noop(u.draft, u.final) if u.draft else None,
}
def selftest() -> int:
"""Проверка ПРАВИЛ на синтетике с известным ответом. Правило без такого теста — это мнение."""
fails = 0
def ck(name: str, ok: bool, got: object = "") -> None:
nonlocal fails
if not ok:
fails += 1
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + ("" if ok else f"{got}"))
ck("нормализация: ударение на кириллице снимается",
normalize("бо́льшим") == "большим", normalize("бо́льшим"))
ck("нормализация: диакритика на латинице сохраняется",
"́" in normalize("Amélie") or "é" in normalize("Amélie"))
ck("токенизация: слово с ударением НЕ разрезано",
words("с бо́льшим напором") == ["с", "большим", "напором"], words("с бо́льшим напором"))
t = check_typography('«Я устала», - сказала она.\n"Пойдём домой."')
ck("типографика: прямые кавычки и дефис-вместо-тире пойманы",
t["ascii_quotes"] >= 2 and t["hyphen_as_dash"] == 1, t)
t2 = check_typography("— Я устала, — сказала она.\n— Пойдём домой.")
ck("типографика: правильный диалог нарушений не даёт",
t2["violations"] == 0 and t2["dash_openers"] == 2, t2)
# ⚠ Первая редакция этого теста утверждала ПОТЕРЮ на «三万 → тридцать тысяч» и проходила,
# пока числительные словами не разбирались. Это была проверка бага, а не правила: русский
# художественный текст пишет число словом, и это НЕ потеря. Тест переписан.
n = check_numbers("他有三万块灵石,还有 250 枚。", "У него тридцать тысяч камней и 250 штук.")
ck("числа: 三万 сходится с «тридцать тысяч», ложной потери нет",
n["lost_n"] == 0 and n["invented_n"] == 0, n)
n2 = check_numbers("他有 250 枚。", "У него 250 штук.")
ck("числа: совпадение не даёт ложных", n2["lost_n"] == 0 and n2["invented_n"] == 0, n2)
n3 = check_numbers("他有三万块灵石。", "У него было немного камней.")
ck("числа: НАСТОЯЩАЯ потеря величины поймана", n3["lost"] == [30000], n3)
n4 = check_numbers("他有一些石头。", "У него было пятьсот камней.")
ck("числа: выдуманная величина поймана", n4["invented"] == [500], n4)
c = check_cjk_leak("Обычный русский текст с одним 蛊 знаком.")
ck("утечка: один иероглиф виден абсолютным счётом, но не боевым порогом",
c["han_chars"] == 1 and not c["over_prod_threshold"], c)
tr = check_translationese("Ауч! Это было больно. Упс.")
ck("translationese: транслит-междометия найдены",
set(tr["calque_interjections"]) >= {"ауч", "упс"}, tr)
g = check_gender("Фан Юань подошёл к двери. Мо Янь сказала тихо.",
{"Фан Юань": "male", "Мо Янь": "female"})
ck("род: согласованные формы не флагаются", g["checked"] >= 2 and g["mismatched"] == 0, g)
g2 = check_gender("Мо Янь подошёл к двери.", {"Мо Янь": "female"})
ck("род: рассогласование поймано", g2["mismatched"] == 1, g2)
v = check_ty_vy("— Ты придёшь? — Вы ошибаетесь.")
ck("ты/вы: смешение в одной единице замечено", v["mixed_in_unit"], v)
r = check_repeat_consistency([
{"term_pairs": [("", "гу"), ("方源", "Фан Юань")]},
{"term_pairs": [("", "червь"), ("方正", "Фан Юань")]},
])
ck("консистентность: расхождение и слипание найдены",
r["divergent"] == 1 and r["merged"] == 1, r)
nn = check_noop("Он ушёл. Она осталась.", "Он ушёл. Она стояла на месте.")
ck("no-op: нетронутое предложение посчитано",
nn["sentences_untouched"] == 1 and nn["draft_sentences"] == 2, nn)
print(f"\n{'ПРАВИЛА БАТАРЕИ ЧИСТЫ' if not fails else f'ПРОВАЛОВ: {fails}'}")
return fails
def run_corpus() -> None:
"""Прогон по 91 реальной единице пакета-6 — проверка, что батарея не падает и не флагает всё."""
corp = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
units = [json.loads(l) for l in corp.read_text(encoding="utf-8").splitlines() if l.strip()]
agg: Counter = Counter()
per_unit = []
for u in units:
if not (u.get("final") or "").strip():
continue
res = run_unit(Unit(source=u.get("source") or "", draft=u.get("draft") or "",
final=u["final"]))
per_unit.append(res)
agg["units"] += 1
agg["typo_violations"] += res["typography"]["violations"]
agg["han_chars"] += res["cjk_leak"]["han_chars"]
agg["han_units"] += bool(res["cjk_leak"]["han_chars"])
agg["pal_nonconf"] += res["palladius"]["nonconformant_text"]
agg["pal_candidates"] += res["palladius"]["name_candidates"]
agg["num_lost"] += res["numbers"]["lost_n"]
agg["num_invented"] += res["numbers"]["invented_n"]
agg["calque_units"] += bool(res["translationese"]["calque_interjections"])
agg["tyvy_mixed"] += res["ty_vy"]["mixed_in_unit"]
agg["yo_mixed_policy"] += (res["yo"]["yo_policy"] == "есть")
print(f"единиц с финалом: {agg['units']}\n")
print(f"{'метрика':40s}{'всего':>9s}{'на единицу':>12s}")
print("-" * 61)
for label, key in (("нарушений типографики", "typo_violations"),
("ханьцзы в финале (знаков)", "han_chars"),
("единиц с ханьцзы", "han_units"),
("кандидатов в имена проверено", "pal_candidates"),
("не-палладиевских среди них", "pal_nonconf"),
("потерянных величин", "num_lost"),
("появившихся величин", "num_invented"),
("единиц с транслит-междометиями", "calque_units"),
("единиц со смешением ты/вы", "tyvy_mixed"),
("единиц с политикой ё", "yo_mixed_policy")):
print(f"{label:40s}{agg[key]:9d}{agg[key] / max(1, agg['units']):12.2f}")
noops = [r["noop"]["word_untouched_share"] for r in per_unit if r["noop"]]
if noops:
print(f"\nno-op: медиана доли слов черновика, доживших до финала = "
f"{statistics.median(noops):.3f} (n={len(noops)})")
out = Path.home() / "books" / "role-topology"
out.mkdir(parents=True, exist_ok=True)
(out / "battery-corpus.json").write_text(json.dumps(dict(agg), ensure_ascii=False, indent=1),
encoding="utf-8")
if __name__ == "__main__":
if "--corpus" in sys.argv:
run_corpus()
else:
sys.exit(selftest())