207 lines
11 KiB
Python
207 lines
11 KiB
Python
#!/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())
|