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