#!/usr/bin/env python3 """Ф0.4 детектор №1 — «ВЫДУМАННОЕ СЛОВО» (класс k3 размеченного набора пакета-6). Зачем он существует. Эксп-20 §3.3 намерил, что точечная починка по флагу снимает дефект за $0.0002 — но мерил при ИДЕАЛЬНОЙ детекции: флаги брались из посадок, а не от детектора. Промт эксп-21 (строка 13) это и называет главной оговоркой: «детекторов „выдуманное слово“ и „смысловая инверсия“ не существует». Арм C фазы 2б без такого детектора неизмерим — его «реальный» режим некому питать. Здесь строится прототип ПОЛИГОНА (не движковое решение: пин строки 46 «Hunspell в движок не строить» не задет — это Python-зонд, как весь eval/). Земля. `~/books/gu-zhenren/labels/labels/k3.jsonl` — 2177 размеченных словотипов из шести реальных прогонов, 10 из них помечены `defect`. Разметка сделана ЧУЖОЙ сессией (пакет-6, 26.07) до и вне этого экспа ⇒ второй контур по отношению ко мне выполнен построением, а не моей рукой. БАЗЛАЙН, который надо бить, лежит там же в `metrics.json`: боевые чекеры дают на k3 **precision 1.0, recall 0.1** (ловят 1 дефект из 10). Устройство. Слои проверяются НАКОПИТЕЛЬНО, и каждый печатается отдельной строкой — норма D39.46(а) («у каждого измеряемого фактора обязан быть арм БЕЗ него») применена к слоям детектора: вклад каждого виден, а не заявлен. С0 нет в словаре pymorphy3 — сырой сигнал С1 + банк/канон книги OK — подписанное имя и его склонённые формы не выдуманы С2 + палладиевская транскрипция OK — транскрипция ВНЕ банка тоже не выдуманное слово С3 + декомпозиция OK — продуктивная деривация/сложение (высоко+рангового) Разделяющий принцип, на котором стоит С3. Выдуманные слова этого класса — ПОРЧА одного реального слова (вперди←впереди, подамлю←подавлю): они не разбираются на известные части. Легитимные незнакомые словарю слова — наоборот, СЛОЖЕНИЯ и приставочные производные известных частей. То есть разделяет не редкость слова, а разложимость. Этот принцип пар-нейтрален: приставки и соединительные гласные — данные русской ЦЕЛИ (`internal/lang/data/target-ru.txt` держит ту же категорию фактов), а не факт о китайской книге ⇒ ревью-вопрос §0.1 («заработает ли на паре, которой в репо нет») отвечается «да»: сменится файл цели, не код. Источники С1/С2 тоже данные: банк книги и пар-таблица Палладия (`configs/langpacks/zh-ru/palladius.txt`). ⚠ Первая редакция С3 была ЧИСТЫМ УБЫТКОМ и это поймано исполнением, а не рассуждением: жадный цикл «известное начало + любой хвост» гасил 6 настоящих дефектов из 10 (силённый → «сил»+«ённый»), уводя recall 0.90→0.30. Цикл снят; снятие приставки требует, чтобы ХВОСТ был известным словом. Запуск (офлайн, $0): eval/.venv/bin/python eval/role_topology/detect_word.py """ from __future__ import annotations import json import re import sys from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") sys.path.insert(0, str(REPO / "eval" / "exp16")) import pymorphy3 # noqa: E402 import yaml # noqa: E402 from palladius import is_palladius_token # noqa: E402 LABELS = Path.home() / "books" / "gu-zhenren" / "labels" / "labels" / "k3.jsonl" _MORPH = pymorphy3.MorphAnalyzer() # Русские приставки и первые части сложений. Данные ЦЕЛИ (ru), не книги и не пары: для →de/→en # сменится этот список, Go-кода в этом прототипе нет вовсе. Порядок — длинные первыми (жадный съём). PREFIXES = sorted([ "высоко", "низко", "средне", "полу", "сверх", "около", "противо", "лже", "псевдо", "квази", "анти", "супер", "ультра", "архи", "экстра", "много", "мало", "перво", "едино", "обще", "само", "взаимо", "равно", "разно", "все", "одно", "двух", "трёх", "трех", "четырёх", "четырех", "пяти", "шести", "семи", "восьми", "девяти", "десяти", "пере", "пред", "через", "чрез", "около", "недо", "небез", "небес", "меж", "между", "сопро", "разо", "воз", "вос", "низ", "нис", "без", "бес", "раз", "рас", "из", "ис", "над", "под", "при", "про", "пре", "трудно", "легко", "быстро", "медленно", "древне", "ново", "старо", "прото", "не", "по", "за", "на", "об", "от", "до", "вы", "в", "с", "у", "о", ], key=len, reverse=True) # Продуктивные словообразовательные суффиксы русского. ЗАКРЫТЫЙ список — в этом его смысл: # произвольный хвост превращает слой в «известное начало + что угодно», а это ровно та редакция, # которая гасила настоящие дефекты. Данные ЦЕЛИ, не пары и не книги. SUFFIXES = ("ов", "ев", "ск", "лив", "чив", "ист", "оват", "еват", "аст", "аль", "ичн", "иан", "енн", "онн", "ическ", "ическ", "инск", "овск", "евск", "ушк", "юшк", "оньк", "еньк") def known(word: str) -> bool: """Слово есть в морфологическом словаре (любой разбор — словарный, а не предсказанный).""" return any(p.is_known for p in _MORPH.parse(word)) def translit_shape(word: str, stem_min_syllables: int = 2) -> bool: """Форма палладиевской транскрипции — с учётом РУССКОГО СКЛОНЕНИЯ поверх неё. Голая проверка на слоги ломается на «цунях»/«чжэнем»: русское окончание не является палладиевским слогом. Поэтому пробуем и сам токен, и его усечения на 1–3 знака с конца — ровно длина русской флексии. Усечённый стем обязан остаться ≥3 знаков, иначе от слова остаётся огрызок, который сегментируется чем угодно. `stem_min_syllables` — сколько слогов требовать от УСЕЧЁННОГО стема, и это не косметика: 2 (по умолчанию) — для суждения о ПРОИЗВОЛЬНОМ слове. Порог 1 там слишком щедр: замер на k3 показал, что он всасывает выдуманные слова («подамлю»). 1 — для кандидата, о котором УЖЕ известно, что он имя (прописная + нет в словаре). Односложные транскрипции — норма ономастики (Чжэн · Фан · Сун), и порог 2 давал 24 ложных срабатывания на одном «Чжэна» в палладий-линте батареи. Разные пороги для разных вопросов — осознанное решение, а не расхождение. """ if is_palladius_token(word, min_syllables=1): return True w = word.lower() return any(len(w) - k >= 3 and is_palladius_token(w[:-k], min_syllables=stem_min_syllables) for k in (1, 2, 3)) def decomposes(word: str) -> bool: """Слово = продуктивная приставка/первая часть + ИЗВЕСТНОЕ СЛОВО. Требование «хвост известен» — несущее, а не косметическое: без него слой гасит настоящие дефекты (см. баннер). Хвост берётся длиной ≥4, чтобы «си»+«лённый» и подобные обрезки не считались разбором. Соединительная гласная (о/е в «высок-О-ранговый») и орфографический ъ/ь на шве («трёх-Ъ-ярусном») снимаются одной попыткой каждая — иначе «женьшеневого» и «первокамень» не собираются. Рекурсия не заводится: цепочки приставок в этом классе нет, а каждая лишняя степень свободы стоит recall. """ w = word.lower().replace("ё", "е") for p in PREFIXES: if not w.startswith(p): continue tail = w[len(p):] for t in (tail, tail.lstrip("ъь"), "о" + tail, "е" + tail): if len(t) >= 4 and known(t): return True # Суффиксальная деривация от известного корня: «женьшен-евого» ← женьшень. В отличие от снятой # первой редакции хвост здесь не произвольный, а из закрытого списка продуктивных суффиксов — # именно это не даёт слою съесть «сил-ённый». for cut in range(len(w) - 3, 3, -1): head, suf = w[:cut], w[cut:] if not any(suf.startswith(s) for s in SUFFIXES): continue if known(head) or known(head + "ь") or known(head + "й") or known(head + "а"): return True return False def verdict(word: str, bank: frozenset[str]) -> tuple[bool, str]: """(флаг «выдуманное», причина-непропуска). Причина печатается — вердикт обязан быть читаемым.""" # Составное через дефис считается выдуманным, только если ОБЕ части незнакомы: «тёмно-зелёный» # словарь целиком не знает, но обе половины законны. Проверять по частям — не послабление, # а единственный способ не флагать нормальное словообразование русского. if "-" in word: parts = [p for p in word.split("-") if len(p) >= 2] if parts and all(known(p) or translit_shape(p) or decomposes(p) for p in parts): return False, "составное из известных частей" if known(word): return False, "в словаре" if word.lower() in bank: return False, "строка банка/канона книги" if translit_shape(word): return False, "палладиевская транскрипция" if decomposes(word): return False, "разложимо на известные части" return True, "-" def score(rows: list[dict], layers: int, bank: frozenset[str]) -> dict: """Прогон детектора при УРЕЗАННОМ числе слоёв — так меряется вклад каждого слоя, а не заявляется.""" tp = fp = fn = tn = 0 fp_words, fn_words = [], [] for r in rows: w = r["word"] flag = not known(w) if flag and layers >= 1 and w.lower() in bank: flag = False if flag and layers >= 2 and translit_shape(w): flag = False if flag and layers >= 3 and decomposes(w): flag = False gold = r["label"] == "defect" if flag and gold: tp += 1 elif flag and not gold: fp += 1 fp_words.append(w) elif not flag and gold: fn += 1 fn_words.append(w) else: tn += 1 return dict(tp=tp, fp=fp, fn=fn, tn=tn, fp_words=fp_words, fn_words=fn_words, precision=(tp / (tp + fp) if tp + fp else None), recall=(tp / (tp + fn) if tp + fn else None)) def load_bank() -> frozenset[str]: """Вайтлист имён/термов книги: dst и склонённые формы из сида + подписанные строки глоссария. Берутся ОТДЕЛЬНЫЕ кириллические слова, а не строки целиком: банк хранит «Фан Юаня», а детектор судит словотип. `decl.forms` сида закрывает падежи там, где они выписаны; чего в сиде нет, подхватывает слой С2 (форма транскрипции). """ words: set[str] = set() def eat(s: str) -> None: for w in re.findall(r"[А-Яа-яЁё]+", s or ""): if len(w) >= 2: words.add(w.lower()) seed = Path.home() / "books" / "gu-zhenren" / "guzhenren-seed-v2.yaml" if seed.exists(): data = yaml.safe_load(seed.read_text(encoding="utf-8")) or {} for t in data.get("terms", []) or []: eat(t.get("dst", "")) for f in ((t.get("decl") or {}).get("forms") or []): eat(f) k6 = LABELS.parent / "k6.jsonl" if k6.exists(): for line in k6.read_text(encoding="utf-8").splitlines(): if line.strip(): r = json.loads(line) if r.get("label") == "ok": eat(r.get("dst", "")) return frozenset(words) def main() -> None: rows = [json.loads(l) for l in LABELS.read_text(encoding="utf-8").splitlines() if l.strip()] pos = sum(1 for r in rows if r["label"] == "defect") bank = load_bank() print(f"земля: {len(rows)} словотипов, дефектов {pos}, чистых {len(rows) - pos}") print(f"вайтлист банка/канона: {len(bank)} словоформ") print("базлайн боевых чекеров (labels/metrics.json): precision 1.0000 recall 0.1000 fp 0\n") print(f"{'слой':38s}{'tp':>4s}{'fp':>5s}{'fn':>4s}{'precision':>11s}{'recall':>9s}") print("-" * 71) names = ["С0 нет в словаре", "С1 + банк/канон", "С2 + палладий", "С3 + декомпозиция"] last = None for i, nm in enumerate(names): s = score(rows, i, bank) p = f"{s['precision']:.4f}" if s["precision"] is not None else " — " r = f"{s['recall']:.4f}" if s["recall"] is not None else " — " print(f"{nm:38s}{s['tp']:4d}{s['fp']:5d}{s['fn']:4d}{p:>11s}{r:>9s}") last = s print("\nвыжившие ложные срабатывания:", ", ".join(sorted(last["fp_words"])) or "нет") print("пропущенные дефекты:", ", ".join(sorted(last["fn_words"])) or "нет") # Ущерб от ложного флага — ОТДЕЛЬНОЕ число, как требует промт: ремонт по ложному флагу правит # здоровый текст, то есть портит. Считаем в терминах арма C: сколько починок будет ложными. n_flag = last["tp"] + last["fp"] if n_flag: print(f"\nущерб: из {n_flag} флагов ЛОЖНЫХ {last['fp']} " f"({last['fp'] / n_flag:.0%}) — столько починок арма C правили бы здоровое слово") # ВЫДЕЛЕННАЯ ВАЛИДАЦИЯ. Слои выше настраивались, глядя на те же 2177 строк, на которых и # меряются, — это подгонка, и одних in-sample чисел мало. Посадки k1 пробы 18 построены ЧУЖОЙ # сессией под другую задачу и в настройке не участвовали ⇒ годятся как held-out. # ⚠ Набор ЛЕГЧЕ живого: посадки сконструированы заведомо не-словами, а реальные ошибки моделей # бывают ближе к настоящему слову. Читать как верхнюю границу, не как оценку прода. sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) import editor_wire_probe as WP # noqa: E402,PLC0415 plants = [w for rows in WP.DEFECTS.values() for cls, old, new, _, _ in rows if cls == "k1" for w in new.split() if w not in old.split()] hit = sum(1 for w in plants if verdict(w, bank)[0]) print(f"\nвыделенная валидация (посадки k1 пробы 18, в настройке не участвовали): " f"{hit}/{len(plants)} = {hit / len(plants):.2f}") out = Path.home() / "books" / "role-topology" out.mkdir(parents=True, exist_ok=True) (out / "detect-word-k3.json").write_text( json.dumps({**last, "holdout_plants": [hit, len(plants)]}, ensure_ascii=False, indent=1), encoding="utf-8") if __name__ == "__main__": main()