691 lines
51 KiB
Python
691 lines
51 KiB
Python
#!/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 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"[㐀-鿿]")
|
||
|
||
# ⚠⚠ ТРИ КРЮЧКА СТОРОНЫ ИСХОДНИКА — заведены фазой Д 14.08 под перенос на другие пары.
|
||
# Дефолты РАВНЫ прежнему поведению, поэтому китайские числа не двигаются; это проверяется
|
||
# ассертом в `para.py --selftest` и пере-снятием гейтов пака 21.
|
||
# Почему крючки, а не ветвление по паре внутри проверок: ветвление по паре в коде запрещено
|
||
# каноном проекта (CLAUDE.md), пара обязана жить в данных. Замер, ради которого это сделано:
|
||
# на английском исходнике `_zh_values` даёт ПУСТО, поэтому `lost_n ≡ 0` (потери не ловятся
|
||
# вовсе), а `invented_n` объявляет выдумкой каждое русское числительное ≥100 — 12 ложных на
|
||
# 72 боевых клетках. На японском `億` не опознаётся и величина разбирается НЕВЕРНО.
|
||
SOURCE_VALUES_HOOK = None # callable(str) -> set[int]; None = китайский разбор рига
|
||
NAME_CONFORMANT_TEXT_HOOK = None # callable(str) -> bool, путь ТЕКСТА (терпит склонение)
|
||
NAME_CONFORMANT_BANK_HOOK = None # callable(str) -> bool, путь БАНКА (словарная форма, строго) # callable(str) -> bool; None = палладий рига
|
||
SOURCE_SCRIPT_HOOK = None # compiled regex; None = ханьцзы рига
|
||
|
||
|
||
def configure_pair(**kw) -> None:
|
||
"""Переключить сторону исходника на другую пару. Ключи — имена крючков этого модуля."""
|
||
for k, v in kw.items():
|
||
if k not in globals():
|
||
raise SystemExit(f"⛔ battery.configure_pair: неизвестный крючок {k!r}")
|
||
globals()[k] = v
|
||
|
||
|
||
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 ложных.
|
||
conform = (NAME_CONFORMANT_TEXT_HOOK(w) if NAME_CONFORMANT_TEXT_HOOK is not None
|
||
else _translit_shape(w, stem_min_syllables=1))
|
||
if not conform:
|
||
bad_text.append(w)
|
||
# ⚠ ДВА РАЗНЫХ ХУКА, не один. Текстовый путь обязан ТЕРПЕТЬ склонение («Чжэна» — верная
|
||
# транскрипция в родительном), банковый — нет: строка банка даётся в словарной форме, и
|
||
# послабление там пропускало бы порчу. Один общий хук схлопывал обе проверки в строгую и
|
||
# возвращал класс «96 ложных склонённых» (строка 174), то есть тихо сдвигал уже снятые
|
||
# числа китайской оси. Поймано вычиткой 14.08 ДО того, как проводка пары это включила.
|
||
_conf = NAME_CONFORMANT_BANK_HOOK or palladius_conformant
|
||
bad_bank = [d for d in bank_dst if _RE_CYR_WORD.search(d) and not _conf(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 = (SOURCE_SCRIPT_HOOK or _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). Из перевода — арабские и русские числительные-множители. Сравниваются
|
||
МНОЖЕСТВА величин: пропавшая или появившаяся величина есть дефект фактуры.
|
||
⚠ Проверка НЕ ловит переставленные величины при совпадающем множестве — объявлено.
|
||
"""
|
||
if SOURCE_VALUES_HOOK is not None:
|
||
src_vals = set(SOURCE_VALUES_HOOK(source))
|
||
else:
|
||
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».
|
||
# Разбор идёт по ЛЕММЕ (склонение снимает морфология), накопление — как в русском счёте:
|
||
# сотни+десятки+единицы складываются, множитель умножает накопленное.
|
||
# ⚠ ПУНКТУАЦИЯ РАЗРЫВАЕТ СОСТАВНОЕ ЧИСЛИТЕЛЬНОЕ. Поймано вычиткой 14.08: `words()` знаки
|
||
# выбрасывает, поэтому «триста, пятьсот, семьсот» накапливалось в ОДНУ величину 1500 —
|
||
# исходные 300/500/700 объявлялись потерянными, а 1500 выдуманной. Четыре ложных места на
|
||
# ровном месте, и `numbers.lost/invented` входят в адреса фиксера A1B, то есть он ехал их
|
||
# «чинить». Ниже поток идёт со знаками, и любой знак сбрасывает накопитель.
|
||
acc, cur = 0, 0
|
||
for w in re.findall(r"[^\W\d_]+|[^\s\w]", normalize(final)):
|
||
if not w[0].isalpha():
|
||
if cur or acc:
|
||
dst_vals.add(acc + cur)
|
||
acc = cur = 0
|
||
continue
|
||
# Первый разбор — не всегда числительный: у «десятки» это «десятка», а не «десяток».
|
||
# Берём первую лемму, которая ВООБЩЕ числительное, иначе откатываемся на первый разбор.
|
||
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-детектор 成/万»).
|
||
# Цена объявлена: потеря величины 1–9 этой проверкой НЕ ловится.
|
||
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% спикер, адресат
|
||
на 10–15 пп хуже) и здесь честно НЕ делается. Меряется наблюдаемое детерминированно: смешение
|
||
обеих форм в ОДНОЙ единице. Это нижняя граница дефекта — смешение между разными собеседниками
|
||
легитимно, поэтому число читается как сигнал к просмотру, а не как счёт ошибок.
|
||
"""
|
||
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())
|