textmachine/eval/dovodka/para.py

320 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"))
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)