#!/usr/bin/env python3 """Полигон, пакет-7 (A2): проба ИЗМЕРИМОСТИ критериев рубрики «нормально переведённый термин». Рубрика без работающей измерялки — это мнение. Скрипт берёт три критерия, про которые правдоподобно, что они машинные, и проверяет их НА ПОДПИСАННОМ ВЛАДЕЛЬЦЕМ сиде — то есть на эталоне, а не на выдумке. Что сходится с эталоном, то в фазе B можно мерить бесплатно; что не сходится — честно уезжает к человеку. Критерии в пробе: M1 морфологическая пригодность — склоняется ли dst по-русски, устойчив ли род; сверка рукописных `decl.forms` сида с формами, которые порождает pymorphy3 (расхождение = либо ошибка сида, либо предел автомата — обе находки полезны); M2 транскрипционная норма — латиница/цифры внутри dst, недопустимые для кириллического таргета сочетания, а также согласие с фонотактикой Палладия из боевого langpack; M3 внутренняя однозначность банка — один src с несколькими dst и один dst у нескольких src (коллизии, которые терминологу придётся разрешать по D39.42 п.1). $0: pymorphy3 (локальный словарь) + stdlib. Ни одного сетевого вызова. """ from __future__ import annotations import argparse import json import re import sys from collections import defaultdict from pathlib import Path import yaml try: import pymorphy3 except ImportError: # pragma: no cover pymorphy3 = None CASES = ["gent", "datv", "accs", "ablt", "loct"] LATIN = re.compile(r"[A-Za-z]") DIGIT = re.compile(r"[0-9]") def head_index(morph, tokens: list[str], typ: str) -> int: """Индекс склоняемой вершины словосочетания. Правило РАЗНОЕ по типу терма, и это само по себе требование к рубрике: - имя/прозвище («Фан Юань», «Гуюэ Мочэнь», «матушка Шэнь»): вершина — ПОСЛЕДНИЙ компонент антропонима… кроме случая «нарицательное + оним» («матушка Шэнь»), где склоняется нарицательное, а оним стоит неизменным; - нарицательное словосочетание («деревня Гуюэ», «море истинной ци», «Четвёртый глава рода»): вершина — ПЕРВОЕ существительное, остальное — зависимое. Первая версия скрипта брала последний токен всегда и на «Монах Цветочного Вина» склоняла «Вина» — цифра согласия вышла заниженной по МОЕЙ вине, а не по вине сида. """ if not tokens: return 0 def is_noun(tok: str) -> bool: if not morph: return False p = morph.parse(tok)[0] return str(p.tag.POS) in ("NOUN", "ADJF", "PRTF") lower_common = [i for i, t in enumerate(tokens) if t[:1].islower()] if typ in ("name", "nickname"): # «матушка Шэнь» — нарицательное впереди строчной буквой ⇒ склоняем его if lower_common and lower_common[0] == 0: return 0 return len(tokens) - 1 for i, t in enumerate(tokens): if is_noun(t): return i return 0 def m1_morphology(morph, dst: str, declared: list[str], invariant: bool, typ: str = "") -> dict: """Склоняемость, род и сверка объявленных форм с порождёнными.""" tokens = dst.split() hi = head_index(morph, tokens, typ) head = tokens[hi] if tokens else dst parses = morph.parse(head) if morph else [] if not parses: return {"parsed": False, "head": head} p = parses[0] # Слово, которого нет в словаре, pymorphy3 разбирает предсказателем — вердикт по нему # держать наравне со словарным нельзя, поэтому факт помечается отдельно. guessed = not getattr(p.methods_stack[0][0], "__class__", type(None)).__name__.startswith("Dictionary") gen = {} for c in CASES: f = p.inflect({c}) if f: parts = list(tokens) parts[hi] = f.word gen[c] = " ".join(parts) declinable = len(set(gen.values())) > 1 dec_set = {d.strip().lower() for d in declared} gen_set = {g.lower() for g in gen.values()} return { "parsed": True, "head": head, "guessed": guessed, "pos": str(p.tag.POS), "gender": str(p.tag.gender), "animacy": str(p.tag.animacy), "declinable_by_morph": declinable, "declared_invariant": invariant, # Расхождение №1: сид говорит «склоняется», автомат не склоняет (или наоборот) "invariance_conflict": (invariant and declinable) or (not invariant and not declinable), "declared_forms": sorted(dec_set), "generated_forms": sorted(gen_set), "forms_agree": bool(dec_set) and dec_set == gen_set, "forms_declared_not_generated": sorted(dec_set - gen_set), "forms_generated_not_declared": sorted(gen_set - dec_set), } def m2_translit(dst: str, pack_dir: Path | None) -> dict: bad = [] if LATIN.search(dst): bad.append("латиница внутри dst") if DIGIT.search(dst): bad.append("цифра внутри dst") if pack_dir: pt = pack_dir / "palladius-phonotactics.txt" if pt.exists(): for line in pt.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): continue parts = line.split("\t") if len(parts) >= 2 and parts[0] and parts[0] in dst.lower(): bad.append(f"фонотактика Палладия: «{parts[0]}» → ожидается «{parts[1]}»") return {"violations": bad} def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--seed", required=True, type=Path) ap.add_argument("--langpack-pair", type=Path, help="каталог пары langpack (для Палладия)") ap.add_argument("--out", type=Path) a = ap.parse_args() if pymorphy3 is None: print("pymorphy3 не установлен — критерий M1 не измеряется", file=sys.stderr) morph = pymorphy3.MorphAnalyzer() if pymorphy3 else None doc = yaml.safe_load(a.seed.read_text(encoding="utf-8")) or {} terms = doc.get("terms") or [] rows, by_src, by_dst = [], defaultdict(set), defaultdict(set) for t in terms: src, dst = t.get("src", ""), (t.get("dst") or "") if not dst: continue decl = t.get("decl") or {} r = { "src": src, "dst": dst, "type": t.get("type", ""), "status": t.get("status", ""), "M1": m1_morphology(morph, dst, decl.get("forms") or [], bool(decl.get("invariant")), t.get("type", "")), "M2": m2_translit(dst, a.langpack_pair), } rows.append(r) by_src[src].add(dst) by_dst[dst.lower()].add(src) n = len(rows) parsed = [r for r in rows if r["M1"].get("parsed")] guessed = [r for r in rows if r["M1"].get("guessed")] with_forms = [r for r in rows if r["M1"].get("declared_forms")] agree = [r for r in with_forms if r["M1"]["forms_agree"]] # Вердикт по неизменяемости имеет силу ТОЛЬКО когда вершина словарная: на угаданной # («гу», «Чилянь», «ци») автомат выдумывает парадигму, и его «склоняется» ничего не значит. conflict = [r for r in rows if r["M1"].get("invariance_conflict") and not r["M1"].get("guessed")] conflict_guessed = [r for r in rows if r["M1"].get("invariance_conflict") and r["M1"].get("guessed")] m2bad = [r for r in rows if r["M2"]["violations"]] coll_src = {k: v for k, v in by_src.items() if len(v) > 1} coll_dst = {k: v for k, v in by_dst.items() if len(v) > 1} print(f"термов с dst: {n}") print(f"M1 разобрано pymorphy3: {len(parsed)}/{n}; из них ВЕРШИНА УГАДАНА " f"предсказателем (нет в словаре): {len(guessed)}") print(f"M1 объявлены формы в сиде: {len(with_forms)}; из них СОВПАЛИ с порождёнными: " f"{len(agree)} ({len(agree) / len(with_forms):.3f})" if with_forms else "M1 форм нет") print(f"M1 конфликт «invariant vs склоняемость», вершина СЛОВАРНАЯ (вердикт имеет силу): " f"{len(conflict)}") print(f"M1 то же, но вершина УГАДАНА (вердикт силы НЕ имеет, показан справочно): " f"{len(conflict_guessed)}") print(f"M2 нарушений (латиница/цифра/фонотактика): {len(m2bad)}") print(f"M3 один src → несколько dst: {len(coll_src)} {dict(list(coll_src.items())[:5])}") print(f"M3 один dst → несколько src: {len(coll_dst)} {dict(list(coll_dst.items())[:5])}") print("\nM1: где рукописные формы РАЗОШЛИСЬ с автоматом (первые 15):") for r in [r for r in with_forms if not r["M1"]["forms_agree"]][:15]: m = r["M1"] print(f" {r['src']} → {r['dst']} сид={m['forms_declared_not_generated']} " f"автомат={m['forms_generated_not_declared']}") print("\nM1: конфликт объявленной неизменяемости (первые 15):") for r in conflict[:15]: m = r["M1"] print(f" {r['src']} → {r['dst']} invariant={m['declared_invariant']} " f"склоняем_автоматом={m['declinable_by_morph']} pos={m['pos']} род={m['gender']}") if a.out: a.out.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") return 0 if __name__ == "__main__": raise SystemExit(main())