#!/usr/bin/env python3 """ПАР-ПРОВАЙДЕР: всё, что харнесс знает о конкретной языковой ПАРЕ. $0. Канон владельца: движок ОДИН и общий, пара-специфика живёт в ДАННЫХ. Ревью-вопрос по умолчанию — «заработает ли пара, которой в репо ещё НЕТ, без правки кода?». До этого файла ответ был «нет»: пара сидела модульными глобалями в шести местах, и три из них давали не падение, а ТИХО НЕВЕРНОЕ ЧИСЛО. Здесь пара становится объектом, который передаётся аргументом. ⚠⚠ ГЛАВНАЯ НОРМА ЭТОГО ФАЙЛА, И ОНА НЕ КОСМЕТИЧЕСКАЯ. **Пара, у которой класс детерминированного флага НЕ РЕАЛИЗОВАН, обязана СНЯТЬ его вслух.** Молчаливый ноль мест недопустим, потому что арм `A1B` объявлен как «черновик + детерминированные флаги», и его содержание ЕСТЬ список этих флагов. Замер на zh: 63% мест дал канон-гейт, 27% разбор чисел, 10% палладий. Если на другой паре канон молча даёт ноль, а палладий молча даёт ложь — `A1B` на двух парах суть РАЗНЫЕ АРМЫ под одним именем, и любой кросс-парный вывод (цель №2 канона владельца) оказывается артефактом. Поэтому у каждой пары есть `absent` — перечень снятых классов, который печатается в отчёте рядом с числами. ⚠ ЧТО БЫЛО БЫ БЕЗ ЭТОГО ФАЙЛА (замерено на английском исходнике, не предположено): * банк китайский ⇒ `terms_in(en_source)` = 0 ⇒ канон-гейт даёт 0 мест на КАЖДОЙ единице ⇒ арм `A4` уходит в «мест нет» и его клетка становится ПОБАЙТНО равна `A0`. Судья получает два одинаковых текста под разными метками, контраст ровно 0 по построению — тот самый «близнец», из-за которого контроль пака 23 был пуст; * `law_block(terms_in(en))` возвращает `None`, и в системный промт критика уезжает строковый литерал `None` — проверено исполнением; * разбор величин исходника знает только китайскую запись ⇒ потери чисел не ловятся ВОВСЕ, а каждое русское числительное ≥100 в выходе объявляется выдумкой; * палладий-линт — система транскрипции С КИТАЙСКОГО — применяется к французским именам Кристоффа и флагует их как дефект, причём С ЦИТАТОЙ, то есть проходит гейт «флаг без адреса». Запуск: para.py сводка по пáрам para.py --selftest обязателен до первой покупки новой пары """ from __future__ import annotations import importlib.util import re import sys from dataclasses import dataclass, field from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"): sys.path.insert(0, str(REPO / "eval" / d)) import battery as B # noqa: E402 # ── числа исходника: по одной реализации на письменность ────────────────────────────────────── _RE_LAT_NUM = re.compile(r"\b[a-z][a-z-]*\b", re.I) _EN_NUM = {"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, "twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, "seventy": 70, "eighty": 80, "ninety": 90, # собирательные — зеркало русских «десяток/сотня/дюжина» в `battery._RU_NUM` "dozen": 12, "score": 20} _EN_MULT = {"hundred": 100, "thousand": 1_000, "million": 1_000_000, "billion": 1_000_000_000} # ⚠ Японские разряды: 億 отличается от китайского 亿 НАЧЕРТАНИЕМ, и `battery._ZH_DIGITS` его не # знает. Замер: `_zh_values("一億")` даёт {1} вместо {100000000}, `"三億五千万"` даёт {3, 5}. # То есть на ja разбор не просто молчит, он выдаёт РАЗОБРАННЫЕ НЕВЕРНО величины. _JA_EXTRA = {"億": 100_000_000, "〇": 0} def values_en(source: str) -> set[int]: """Величины английского исходника: арабские плюс числительные СЛОВАМИ. ⚠ Слова обязательны. Замер по 6 боевым единицам Кристоффа: цифрами 0 величин, словами 9. Без разбора слов сторона исходника пуста, и тогда `check_numbers` не ловит потерь ВООБЩЕ, а всякое русское числительное в выходе объявляет выдумкой. """ # ⚠ ЗНАК ПРЕПИНАНИЯ РАЗРЫВАЕТ составное числительное — иначе «three hundred, five hundred» # копится в ОДНУ величину 3050700, и безупречный перевод получает потери плюс выдумку. # Тот же дефект был в русском разборе `battery.check_numbers`; чинится симметрично. out: set[int] = {int(x) for x in re.findall(r"\d+", source)} acc = cur = 0 for w in re.findall(r"[a-z]+(?:-[a-z]+)*|[^\s\w]", source.lower()): if not w[0].isalpha(): if cur or acc: out.add(acc + cur) acc = cur = 0 continue for part in w.split("-"): # twenty-three if part in _EN_NUM: cur += _EN_NUM[part] elif part == "hundred": cur = max(cur, 1) * 100 elif part in _EN_MULT: acc += max(cur, 1) * _EN_MULT[part] cur = 0 elif part in ("and", "a", "an"): continue else: if cur or acc: out.add(acc + cur) acc = cur = 0 if cur or acc: out.add(acc + cur) return {v for v in out if v} def values_ja(source: str) -> set[int]: """Величины японского исходника. ⚠ ПОЧЕМУ НЕ ДЕЛЕГИРУЕТСЯ В `battery._zh_values`, хотя запись иероглифическая и общая. Тот гасит закрытый список КИТАЙСКИХ идиом (`battery._ZH_IDIOM`), и в нём есть `千万`: по-китайски это и «десять миллионов», и «ни в коем случае», риг выбрал подавлять. По-японски `五千万` — обычное число пятьдесят миллионов. Делегирование давало `三億五千万` → 300000005 вместо 350000000, то есть НЕВЕРНО РАЗОБРАННУЮ величину, а не пропуск. Поймано селфтестом этого файла. Плюс `億` отличается от китайского `亿` начертанием и `_ZH_DIGITS` его не знает. """ digits = {"零": 0, "〇": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} src = source # японских идиом-омонимов разряда в отобранном срезе нет; список пуст ОСОЗНАННО и объявлен out: set[int] = set() for m in re.finditer(r"[零〇一二三四五六七八九十百千万億]+", src): run, total, section, cur = m.group(0), 0, 0, 0 if run == "十": continue for ch in run: if ch in digits: cur = digits[ch] elif ch == "十": section += (cur or 1) * 10 cur = 0 elif ch == "百": section += (cur or 1) * 100 cur = 0 elif ch == "千": section += (cur or 1) * 1_000 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) wide = src.translate(str.maketrans("0123456789", "0123456789")) out |= {int(x) for x in re.findall(r"[0-9]+", wide)} return {v for v in out if v} def values_zh(source: str) -> set[int]: """Величины китайского исходника — поведение рига БЕЗ изменений (дефолт `battery`).""" out = {int(x) for x in re.findall(r"\d+", source)} return out | B._zh_values(source) # noqa: SLF001 # ── конформность имени: чем проверяется транскрипция ────────────────────────────────────────── _POLIVANOV_SYL = ("ka ki ku кэ ko sa si su se so ta ti tu te to na ni nu ne no ha hi hu he ho " "ma mi mu me mo ya yu yo ra ri ru re ro wa n").split() def name_conformant_zh_text(word: str) -> bool: """Путь ТЕКСТА: кандидат уже опознан как имя и может стоять в косвенном падеже. Ровно то, что риг делает по умолчанию. Держится отдельно от банковой проверки: подстановка строгой на этот путь возвращает 96 ложных склонённых на 2192 (battery.py:174). """ return B._translit_shape(word, stem_min_syllables=1) def name_conformant_zh(word: str) -> bool: """Палладий — конформность транскрипции С КИТАЙСКОГО. Дефолт рига, не меняется.""" return B.palladius_conformant(word) def name_conformant_none(_word: str) -> bool: """Пара, для которой системы транскрипции НЕТ: класс флага снят, ложных мест не даём. ⚠ Возвращать `True` («всё конформно») — не заглушка, а единственный честный ответ: проверки нет, значит нарушения не устанавливаются. Снятие класса объявляется в `Pair.absent` и печатается в отчёте, чтобы `A1B` на этой паре не выдавался за `A1B` на китайской. """ return True @dataclass(frozen=True) class Pair: """Всё, что харнесс знает о паре. Ни одно поле не имеет умолчания «как у китайского».""" key: str # "zh-ru" lang_name: str # «китайском» — как язык исходника называется судье bank_file: Path # банк терминов пары prompt_pack: Path # каталог пар-промтов brief_key: str # какой бриф подставлять в шаблон source_script: re.Pattern # письменность исходника: детектор эха и адрес флага spaced_source: bool # пробельная письменность → счёт термов по границе слова values_in_source: object # callable(str) -> set[int] name_conformant: object # callable(str) -> bool, путь БАНКА (словарная форма) name_conformant_text: object # callable(str) -> bool, путь ТЕКСТА (терпит склонение) mine_chars: str = "㐀-鿿" # класс знаков для n-граммного майнера непробельных письменностей absent: tuple = field(default=()) # СНЯТЫЕ классы флагов — печатаются в отчёте def terms_in(self, source: str, bank: dict) -> list[str]: """Термины банка, встречающиеся в исходнике. ⚠ Для пробельной письменности `source.count(term)` систематически ЗАНИЖАЕТ знаменатель: `"Dawn"` встречается один раз, а `Dawn/dawn/dawning` — три. Поэтому здесь граница слова и регистро-независимость, а для иероглифики — подстрока, как было. """ if not self.spaced_source: return [t for t in bank if t in source] return [t for t in bank if re.search(rf"\b{re.escape(t)}\b", source, re.I)] def count_in_source(self, term: str, source: str) -> int: if not self.spaced_source: return source.count(term) return len(re.findall(rf"\b{re.escape(term)}\b", source, re.I)) HOME = Path.home() ZH = Pair(key="zh-ru", lang_name="китайском", bank_file=HOME / "books" / "tenant-panel" / "bank.json", prompt_pack=REPO / "backend" / "prompts" / "zh-ru", brief_key="BRIEF_ZH", source_script=re.compile(r"[㐀-鿿]"), spaced_source=False, values_in_source=values_zh, name_conformant=name_conformant_zh, name_conformant_text=name_conformant_zh_text) EN = Pair(key="en-ru", lang_name="английском", bank_file=HOME / "books" / "role-topology" / "bank-en.json", prompt_pack=REPO / "eval" / "role_topology" / "prompts" / "en-ru", brief_key="BRIEF_EN", source_script=re.compile(r"[A-Za-z]"), spaced_source=True, values_in_source=values_en, name_conformant=name_conformant_none, name_conformant_text=name_conformant_none, # ⚠ СНЯТО И ОБЪЯВЛЕНО: палладий — транскрипция с КИТАЙСКОГО. Замер агента на 72 боевых # en-клетках: 219 ложных флагов, 3.04 на клетку («Хоакин», «Фабьена», «Огюстен»). # Флаг несёт цитату, поэтому гейт «флаг без адреса» его пропускает, и фиксер чинил бы # правильные имена. Для en системы транскрипции в проекте нет — класс снят. absent=("палладий: системы транскрипции для en в проекте нет",)) JA = Pair(key="ja-ru", lang_name="японском", bank_file=HOME / "books" / "dovodka" / "bank-ja.json", prompt_pack=REPO / "eval" / "role_topology" / "prompts" / "ja-ru", brief_key="BRIEF_JA", source_script=re.compile(r"[㐀-鿿ぁ-ゟ゠-ヿ]"), spaced_source=False, # ⚠ КАТАКАНА ОБЯЗАТЕЛЬНА в майнинге: кандзи-класс её не видит, а второй мир книги # записан катаканой почти целиком (ケイン ×206 — главный герой после перерождения, # ヨルガ ×89). С одним кандзи-классом банк пары остался бы без главных имён, и # канон-ось Д6 мерила бы покрытие мимо всего, что важно. mine_chars="㐀-鿿ァ-ヺー", values_in_source=values_ja, name_conformant=name_conformant_none, name_conformant_text=name_conformant_none, # ⚠ СНЯТО И ОБЪЯВЛЕНО: поливановской проверки в проекте нет, а палладиевская флагует # корректные японские имена («Рэнтаро», «Хёго» — проверено). Заводить её надо отдельной # работой; до тех пор класс снят, а не подменён китайским. absent=("палладий: для ja нужна поливановская проверка, её в проекте нет",)) PAIRS = {p.key: p for p in (ZH, EN, JA)} def selftest() -> int: bad = 0 def ck(n: str, ok: bool, d: str = "") -> None: nonlocal bad bad += not ok print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else "")) # ⚠ ГЛАВНЫЙ АССЕРТ: китайское поведение НЕ ДОЛЖНО СДВИНУТЬСЯ. Пар-провайдер вводится ради # переносимости, и если он меняет числа уже снятой оси, вводить его нельзя. zh_src = "秦风走了三千里,见到了一万人。还有两千三百个。" ck("zh: величины исходника те же, что у рига", ZH.values_in_source(zh_src) == ({int(x) for x in re.findall(r'\d+', zh_src)} | B._zh_values(zh_src)), # noqa: SLF001 str(sorted(ZH.values_in_source(zh_src)))) ck("zh: конформность имени — палладий рига", ZH.name_conformant("Цинь") == B.palladius_conformant("Цинь")) ck("zh: ТЕКСТОВАЯ проверка = дефолту рига (иначе сдвинет снятую ось)", all(ZH.name_conformant_text(w) == B._translit_shape(w, stem_min_syllables=1) for w in ("Чжэна", "Мочэнем", "Цинь", "Фэну", "Сюэ"))) ck("zh: текстовая ТЕРПИТ склонение, банковая — НЕТ", ZH.name_conformant_text("Чжэна") and not ZH.name_conformant("Чжэна")) ck("zh: письменность ловит иероглифы", bool(ZH.source_script.search("秦风"))) ck("zh: счёт терма подстрокой (как было)", ZH.count_in_source("秦风", zh_src) == 1) en_src = "He walked three thousand miles and met five hundred men. Twenty-three of them died." v = EN.values_in_source(en_src) ck("en: числительные СЛОВАМИ разобраны", {3000, 500, 23} <= v, str(sorted(v))) ck("en: счёт терма по границе слова и без регистра", EN.count_in_source("dawn", "Dawn came; the dawn was red; dawning.") == 2, str(EN.count_in_source("dawn", "Dawn came; the dawn was red; dawning."))) ck("en: палладий СНЯТ, ложных мест не даёт", EN.name_conformant("Хоакин") is True) ck("en: снятие класса ОБЪЯВЛЕНО", bool(EN.absent)) ck("en: письменность — латиница", bool(EN.source_script.search("Dawn")) and not EN.source_script.search("秦风")) ja_src = "三億五千万円。一億の民。7つの村。" vj = JA.values_in_source(ja_src) ck("ja: 億 разобран верно (не как 1)", 350_000_000 in vj and 100_000_000 in vj, str(sorted(vj))) ck("ja: полноширинные цифры разобраны", 7 in vj) ck("ja: письменность ловит кану (эхо-мина каной)", bool(JA.source_script.search("これはテスト"))) ck("ja: палладий СНЯТ и объявлен", JA.name_conformant("Рэнтаро") is True and bool(JA.absent)) ck("банк пары СУЩЕСТВУЕТ (иначе канон-ось пуста, а A4 неотличим от A0)", all(p.bank_file.exists() for p in PAIRS.values()), "нет: " + ", ".join(p.key for p in PAIRS.values() if not p.bank_file.exists())) ck("у каждой пары свой банк", len({p.bank_file for p in PAIRS.values()}) == len(PAIRS)) ck("у каждой пары свой каталог промтов", len({p.prompt_pack for p in PAIRS.values()}) == len(PAIRS)) print(f"\n{'ПАР-ПРОВАЙДЕР ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}") return bad def main() -> int: if "--selftest" in sys.argv: return selftest() print(f"{'пара':8s}{'язык':14s}{'письменность':>14s}{'банк':>34s} снятые классы") for k, p in PAIRS.items(): print(f"{k:8s}{p.lang_name:14s}{('пробельная' if p.spaced_source else 'иероглифика'):>14s}" f"{('есть' if p.bank_file.exists() else '⛔ НЕТ'):>34s} " f"{'; '.join(p.absent) or '—'}") return 0 if __name__ == "__main__": sys.exit(1 if main() else 0)