320 lines
22 KiB
Python
320 lines
22 KiB
Python
#!/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)
|