Freeze the phase-D harness before the en-axis purchases: pair provider, wired rig hooks, pinned unit manifests, pair terminologist prompts and the paid contamination path

This commit is contained in:
heaven 2026-08-14 14:17:42 +03:00
parent b048e0c4aa
commit 9df7944381
13 changed files with 993 additions and 67 deletions

View file

@ -55,6 +55,21 @@ def _load(name: str, path: Path):
MONEY = _load("money_d", ZONE / "money_d.py")
CT = _load("contam21", REPO / "eval" / "role_topology" / "contamination.py")
ROSTER = _load("roster_c", REPO / "eval" / "tenant_panel" / "roster.py")
ED = _load("editors_c", REPO / "eval" / "tenant_panel" / "editors.py")
# ⚠ БЮДЖЕТ ВЫВОДА. У DeepSeek размышление тарифицируется и считается ВНУТРИ бюджета, поэтому
# короткий ответ при тесном капе не помещается вовсе: прошлая редакция звала flash с 16000 и
# получала 046 знаков. Ответ здесь нужен в две строки, думать модель будет много — кап должен
# вмещать размышление ЦЕЛИКОМ, иначе пустота будет означать «не успел сказать», а не «не знаю».
MAX_OUT = 32000
# Глушить thinking у DeepSeek ЗАПРЕЩЕНО (эхо-мина, гардрейл проекта). Конфигурацию на модель
# даёт `roster.call_kwargs` — своих `reasoning_effort` здесь не ставим НИКОГДА.
SYS = ("Тебе дают отрывок художественного текста, в котором одно имя собственное заменено на "
"[???]. Ответь РОВНО двумя строками и ничем больше:\n"
"ПРОИЗВЕДЕНИЕ: <название и автор, либо НЕ ЗНАЮ>\n"
"ИМЯ: <что стоит вместо [???], либо НЕ ЗНАЮ>\n"
"Не пересказывай отрывок и не продолжай его.")
# Жильцы: те, кто реально работает на парах. Проба на каждом отдельно.
RESIDENTS = ("deepseek-v4-flash", "deepseek-v4-pro")
@ -75,6 +90,43 @@ BOOKS_D = {
}
# Книга → фаза кассы. Положительный контроль относим к ФД-B: он покупается один раз, а прогон
# Д6 возьмёт те же теги из кэша за $0.
PHASE = {"kristoff-en": "ФД-B", "jinpingmei": "ФД-B", "enkan-ja": "ФД-C"}
PREFIX = {"ФД-B": "dv-b", "ФД-C": "dv-c"}
def probe(res: str, book: str, dry: bool) -> dict:
"""Проба одного жильца на одной книге. Возвращает счёт узнавания и клоуза."""
m = BOOKS_D[book]
text = CT.read_book(m["path"])
names = CT.frequent_names(text, m["lang"])
ps = CT.passages(text, names, CT.N_PASSAGES)
ph = PHASE[book]
led = MONEY.Guarded(ph, default_expect=0.01)
rec_hits = cloze_hits = empty = 0
for i, (prime, gold) in enumerate(ps, 1):
tg = f"{PREFIX[ph]}-contam-{res.replace('.', '')}-{book}-{i}"
if dry:
continue
msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": prime}]
r = MONEY.purchase(led, tg, res, ED.client(res),
ROSTER.call_kwargs(res, msgs, max_out=MAX_OUT))
if r.get("skipped"):
raise SystemExit(f"⛔ ГАРД ОТКАЗАЛ на {tg} — СТОП и вопрос владельцу")
ans = r.get("content") or ""
if not ans.strip():
empty += 1
continue
work, name = CT.parse(ans)
if work and "НЕ ЗНАЮ" not in work.upper() and any(t.lower() in work.lower()
for t in m["titles"]):
rec_hits += 1
if name and "НЕ ЗНАЮ" not in name.upper() and gold and gold.lower() in name.lower():
cloze_hits += 1
return dict(recognition=rec_hits, cloze=cloze_hits, empty=empty, n=len(ps))
def main() -> int:
dry = "--dry" in sys.argv
print("КОНТАМИНАЦИОННАЯ ПРОБА ФАЗЫ Д — жильцами\n")
@ -85,12 +137,56 @@ def main() -> int:
print(f"\nжильцы: {' · '.join(RESIDENTS)}")
print("пороги (объявлены ДО данных, взяты из рига эксп-21): "
"узнавание ≥3/5 = знает о книге · клоуз ≥2/5 = знает конкретику")
only = [a.split("=", 1)[1] for a in sys.argv if a.startswith("--books=")]
books = only[0].split(",") if only else list(BOOKS_D)
print(f"\nкниги прогона: {' · '.join(books)}")
if dry:
print("\n--dry: покупок нет")
return 0
print("\n⚠ платный режим ещё не подключён к кассе фазы — см. TODO ниже")
print("--dry: покупок нет; каждая книга = 5 отрывков × 2 жильца")
return 0
res_tbl: dict[tuple[str, str], dict] = {}
for res in RESIDENTS:
for b in books:
res_tbl[(res, b)] = probe(res, b, dry=False)
print(f"\n{'жилец':20s}{'книга':14s}{'узнавание':>11s}{'клоуз':>8s}{'пусто':>7s} вердикт")
bad = 0
for (res, b), v in res_tbl.items():
known = v["recognition"] >= 3
concrete = v["cloze"] >= 2
verdict = ("ЗНАЕТ ТЕКСТ" if concrete else "знает о книге" if known else "чисто")
print(f" {res:18s}{b:14s}{v['recognition']:>7d}/{v['n']}{v['cloze']:>6d}/{v['n']}"
f"{v['empty']:>7d} {verdict}")
if b != "jinpingmei" and concrete:
bad += 1
print("\n=== ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ (без него нулевой результат неинтерпретируем) ===")
for res in RESIDENTS:
c = res_tbl.get((res, "jinpingmei"))
if not c:
print(f" {res}: контроль не гнался — прогон этого жильца НЕИНТЕРПРЕТИРУЕМ")
bad += 1
continue
ok = c["recognition"] >= 3
# ⚠ Пустота при поднятом капе — НЕ «книга чиста», а непригодность пробы на этом жильце.
if c["empty"] == c["n"]:
print(f" {res}: ПУСТЫЕ ОТВЕТЫ на контроле — проба на этом жильце НЕПРИГОДНА, "
"нули по остальным книгам не читать")
bad += 1
elif not ok:
print(f" {res}: контроль ПРОВАЛЕН ({c['recognition']}/{c['n']}) — прогон жильца "
"аннулируется целиком, а не отдельная книга")
bad += 1
else:
print(f" {res}: контроль сработал ({c['recognition']}/{c['n']}) — нули значимы")
if bad:
print("\n⛔ проба даёт СТОП: см. строки выше. Замену материала без слова владельца "
"не ищем (заказ Д3/Д6).")
else:
print("\n[OK ] материал фазы пригоден: жильцы конкретики книг не знают")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -50,6 +50,8 @@ PR = _load("prompts22", REPO / "eval" / "tenant_panel" / "prompts.py")
BANK = _load("bank22", REPO / "eval" / "tenant_panel" / "bank.py")
ROSTER = _load("roster22", REPO / "eval" / "tenant_panel" / "roster.py")
ED22 = _load("editors22", REPO / "eval" / "tenant_panel" / "editors.py")
PARA = _load("para", ZONE / "para.py")
B = PARA.B # тот же экземпляр рига, что видит провайдер пары
# ⚠⚠ КАССУ ПЕРЕ-НАСТРАИВАЕМ ПОСЛЕ ВСЕХ ЧУЖИХ ЗАГРУЗОК, И ЭТО НЕ ПЕДАНТИЗМ.
# Модуль `buy` — СИНГЛТОН в `sys.modules`, и побеждает тот, кто настроил его последним. `money_d`
@ -95,9 +97,44 @@ EXCLUDED = {
}
# ── ПАРА КАК ПАРАМЕТР ─────────────────────────────────────────────────────────────────────────
# ⚠ Заведено 14.08 по вычитке. Хуки в риге были построены, но НЕ ПОДКЛЮЧЕНЫ ни в одной точке
# входа: греп по `eval/` не находил ни одного вызова `battery.configure_pair`/`bank.configure`.
# Забытая проводка даёт не падение, а китайский разбор величин и палладий-линт на английском
# тексте — целевой класс «тихо неверное число». Здесь единственное место, где пара включается,
# и включается ЦЕЛИКОМ: риг, банк, бриф, письменность, теги и источник единиц разом.
_PAIR = None
_UNITS = None
def pair():
global _PAIR
if _PAIR is None:
_PAIR = PARA.ZH
return _PAIR
def configure(p, units_provider=None) -> None:
"""Перевести контур на пару `p`. Для zh — проверенный no-op (селфтест это стережёт)."""
global _PAIR, _UNITS
_PAIR, _UNITS = p, units_provider
B.configure_pair(SOURCE_VALUES_HOOK=p.values_in_source,
NAME_CONFORMANT_TEXT_HOOK=p.name_conformant_text,
NAME_CONFORMANT_BANK_HOOK=p.name_conformant,
SOURCE_SCRIPT_HOOK=p.source_script)
BANK.configure(p, material=lambda: "\n".join(u["source"] for u in units()))
def _bank() -> dict:
P = pair()
return BANK.PR.bank(None if P.key == "zh-ru" else P.bank_file)
# ── единицы: 16 эксп-22 + 16 эксп-23, база у каждой своя и уже куплена ────────────────────────
def units() -> list[dict]:
"""ВСЕ единицы среза, включая исключённые, — для учёта и кассы."""
if _UNITS is not None:
return _UNITS()
return P22.M.units_zh() + MAT.units_new()
@ -148,7 +185,7 @@ def canon_gaps(src: str, out: str) -> list[str]:
"""Термины банка, чья канонная форма В ВЫХОДЕ встречается реже, чем в исходнике."""
import re # noqa: PLC0415
gaps = []
for t, v in BANK.PR.bank().items():
for t, v in _bank().items():
n = src.count(t)
if not n:
continue
@ -205,7 +242,7 @@ def battery_flags(u: dict, draft: str) -> list[str]:
"""Нарушения батареи НА ТЕКСТЕ, каждое с адресом. Падение = дефект, НЕ глотаем."""
import battery as B # noqa: PLC0415
import re # noqa: PLC0415
bank = BANK.PR.bank()
bank = _bank()
unit = B.Unit(source=u["source"], draft=draft, final=draft,
bank_dst={v["dst"] for v in bank.values()},
term_pairs=[(t, v["dst"]) for t, v in bank.items() if u["source"].count(t)])
@ -222,7 +259,7 @@ def battery_flags(u: dict, draft: str) -> list[str]:
if addr_field:
addr = val.get(addr_field) or []
elif check == "cjk_leak":
addr = sorted(set(re.findall(r"[一-鿿]", draft)))[:12]
addr = sorted(set(pair().source_script.findall(draft)))[:12]
else:
addr = []
if addr_field and not addr: # признак есть, адреса нет — не место
@ -259,7 +296,11 @@ CRIT_HEAD = (
def fix_msgs(src: str, draft: str, places: list[str]) -> list[dict]:
law = PR.law_block(PR.terms_in(src))
# ⚠ `law_block` возвращает None, когда термов банка в единице нет, — интерполяция уносила в
# системный промт литерал «None». На 31 оплаченной китайской единице пустых не было, поэтому
# не выстрелило; на паре без банка выстрелило бы на КАЖДОЙ.
bf = None if pair().key == "zh-ru" else pair().bank_file
law = PR.law_block(PR.terms_in(src, bf), bf) or ""
body = (f"{FIX_HEAD}\n\n{law}\n\n---USER---\nИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}\n\n"
"МЕСТА, КОТОРЫЕ НАДО ПОЧИНИТЬ:\n" + "\n".join(f"- {p}" for p in places))
s, u = body.split("---USER---", 1)
@ -269,7 +310,11 @@ def fix_msgs(src: str, draft: str, places: list[str]) -> list[dict]:
def crit_msgs(src: str, draft: str) -> list[dict]:
# Критику банк-закон нужен не меньше фиксера: без него он назовёт «непоследовательным» то,
# что канону как раз отвечает, и наоборот. Банк-закон D39.104 — на ВСЕХ армах, без изъятий.
law = PR.law_block(PR.terms_in(src))
# ⚠ `law_block` возвращает None, когда термов банка в единице нет, — интерполяция уносила в
# системный промт литерал «None». На 31 оплаченной китайской единице пустых не было, поэтому
# не выстрелило; на паре без банка выстрелило бы на КАЖДОЙ.
bf = None if pair().key == "zh-ru" else pair().bank_file
law = PR.law_block(PR.terms_in(src, bf), bf) or ""
return [{"role": "system", "content": f"{CRIT_HEAD}\n\n{law}"},
{"role": "user", "content": f"ИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}"}]
@ -308,7 +353,7 @@ A2_SUBST = (
def a2_mandate() -> str:
"""Мандатная часть боевого редактора, переписанная под однопроходку. $0, детерминирована."""
core, _few, _user = PR.load_template(PR.BATTLE / "editor.md")
core = PR.render(core, PR.BRIEF_ZH, "", "")
core = PR.render(core, getattr(PR, pair().brief_key), "", "")
out = []
for ln in core.splitlines():
if any(ln.strip().startswith(d) for d in A2_DROP):
@ -321,14 +366,21 @@ def a2_mandate() -> str:
def a2_msgs(src: str) -> list[dict]:
"""Однопроходка: перевод исходника ОДНИМ вызовом, мандат уравнен со связкой, банк-закон на месте."""
m = PR.translator_msgs(src, PR.law_block(PR.terms_in(src)))
bf = None if pair().key == "zh-ru" else pair().bank_file
m = PR.translator_msgs(src, PR.law_block(PR.terms_in(src, bf), bf),
pair=pair().key)
m[0]["content"] = m[0]["content"] + "\n\n" + a2_mandate()
return m
def tag(arm: str, uid: str, part: str = "") -> str:
# ⚠ Тег несёт пару: без неё кэш `purchase` отдал бы оплаченную КИТАЙСКУЮ клетку под видом
# английской, за $0 и без предупреждения. У zh префикс пустой — теги уже купленных клеток
# обязаны остаться прежними.
P = pair()
pk = "" if P.key == "zh-ru" else P.key.split("-")[0] + "-"
ph = "f" if arm.startswith("A6") else "a"
return f"dv-{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}"
return f"dv-{pk}{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}"
def text_of(arm: str, uid: str) -> str:
@ -450,6 +502,16 @@ def cmd_selftest() -> int:
f"кап {PLACES_CAP}, максимум найденного "
f"{max((critic_places('A3', x['uid'])[1] for x in us), default=0)}")
# ⚠ Главный ассерт этого харнесса: кэш-чек и ЗАПИСЬ результата смотрят в ОДИН каталог.
_b0 = [(len(battery_flags(u, base_draft(u["uid"]))), len(canon_gaps(u["source"], base_draft(u["uid"]))))
for u in units_ok() if base_draft(u["uid"]).strip()]
_t0 = tag("A1B", "zzz")
configure(PARA.ZH)
_b1 = [(len(battery_flags(u, base_draft(u["uid"]))), len(canon_gaps(u["source"], base_draft(u["uid"]))))
for u in units_ok() if base_draft(u["uid"]).strip()]
ck("проводка пары zh — no-op: числа и теги закрытой оси не двигаются",
_b0 == _b1 and _t0 == tag("A1B", "zzz"),
f"было {sum(x for x, _ in _b0)}/{sum(y for _, y in _b0)}, "
f"стало {sum(x for x, _ in _b1)}/{sum(y for _, y in _b1)}")
ck("buy.OUT совпадает с кассой фазы", MONEY.M22.BUY.OUT == MONEY.OUT,
f"{MONEY.M22.BUY.OUT} против {MONEY.OUT}")
print(f"\n{'ХАРНЕСС ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}")

View file

@ -103,7 +103,10 @@ def read_family(root: Path) -> tuple[dict, list]:
for lab, meta in key.items():
sc = {}
for a in AX:
m = re.search(rf"^{lab}-{a}:\s*(\d+)", txt, re.M)
# ⚠ `[ \t]*`, а не `\s*`: `\s` включает перевод строки, и оценка
# могла считаться со СЛЕДУЮЩЕЙ строки. Тот же класс `absjudge.py:351`
# чинил у себя; здесь он оставался.
m = re.search(rf"^{lab}-{a}:[ \t]*(\d+)", txt, re.M)
if m:
sc[a] = int(m.group(1))
if len(sc) < 4:
@ -201,8 +204,10 @@ def three_axes(d: dict) -> None:
if n:
cov.append(k / n)
pr = []
# ⚠ Глоб по тегу арма захватывал и клетки КРИТИКА (`dv-a-a3-crit-*`), поэтому
# медиана «$/клетка» у A3 и A6 считалась по смеси двух ролей. Найдено приёмкой.
for f in S.C.OUT.glob(f"{S.C.tag(arm, '')}*.json"):
if any(x in f.name for x in (".ERROR", ".LENGTH", ".FLAGSV1")):
if any(x in f.name for x in (".ERROR", ".LENGTH", ".FLAGSV1", "-crit-")):
continue
r = _j.loads(f.read_text(encoding="utf-8"))
if r.get("cost_usd"):

View file

@ -121,6 +121,56 @@ def guardrail(cs: list[dict]) -> list[int]:
return hits
def select(cs: list[dict]) -> tuple[list[dict], set[int], list]:
"""Гейт прав и отбор единиц. Вынесено из `main()`, чтобы отбор был ВЫЗЫВАЕМ, а не только
печатаем: покупка и судейство обязаны брать ровно те единицы, что напечатал отчёт."""
dens = sorted(((len(EROTIC.findall(c["text"])) / max(1, len(c["text"])) * 1000, i)
for i, c in enumerate(cs)), reverse=True)
cut = max(1, len(cs) // 10)
dropped = {i for _, i in dens[:cut]}
pool = [c for i, c in enumerate(cs) if i not in dropped]
uniq: dict[str, dict] = {}
for c in pool:
u = uid_of(c["text"])
uniq.setdefault(u, dict(uid=u, source=c["text"], chapter=c["chapter"]))
ordered = sorted(uniq.values(), key=lambda x: (len(x["source"]), x["uid"]))
mid = len(ordered) // 2
lo = max(0, mid - N_UNITS // 2)
return ordered[lo:lo + N_UNITS], dropped, dens
def units() -> list[dict]:
"""Единицы среза Д6 для покупки и судейства — ТОЛЬКО из приколотого манифеста.
Сама не решает и не «выбирает по-новой». Гардрейл 18+ разрешается словом владельца, и
решение это разовое: пока манифеста нет, отбор не состоялся, и молча подставить набор
нельзя иначе библиотечный вызов обойдёт гардрейл, который CLI честно охраняет.
Расхождение приколотого uid с пересчитанным = источник сдвинулся под уже купленными
клетками; это СТОП, а не предупреждение (класс, пойманный докачкой глав на zh-срезе).
"""
if not MANIFEST.exists():
raise SystemExit(f"⛔ манифест {MANIFEST} не записан: отбор ja не заморожен. "
"Прогнать material_ja.py --units при слове владельца по гардрейлу.")
man = json.loads(MANIFEST.read_text(encoding="utf-8"))
want = man["ja"]["uids"]
# ⚠ ГАРДРЕЙЛ ПРИМЕНЯЕТСЯ И ЗДЕСЬ. `main()` при санкции владельца выбрасывает помеченные
# чанки ДО отбора, и манифест записан из отфильтрованного списка. Если бы `units()` считала
# по нефильтрованному, дециль и окно отбора сместились бы — приколотые uid не нашлись бы, и
# прибор дал бы вечный ложный СТОП. Сегодня это тождество (0 срабатываний из 48), но
# тождество СЛУЧАЙНОЕ, а не гарантированное.
cs = chunks()
bad = set(guardrail(cs))
if bad and not man.get("sanctioned_exclude"):
raise SystemExit(f"⛔ гардрейл 18+ помечает {len(bad)} чанков, а санкция владельца в "
f"манифесте не записана — материал не идёт в отбор, СТОП")
have = {u["uid"]: u for u in select([c for i, c in enumerate(cs) if i not in bad])[0]}
miss = [u for u in want if u not in have]
if miss:
raise SystemExit(f"⛔ ЕДИНИЦЫ РАЗЪЕХАЛИСЬ С МАНИФЕСТОМ ja: нет {miss[:3]}"
"источник изменился под купленными клетками, СТОП")
return [have[u] for u in want]
def main() -> int:
cs = chunks()
print(f"Д6 МАТЕРИАЛ ja→ru · источник {SRC.name} · знаков {len(text())} · чанков {len(cs)}")
@ -151,36 +201,26 @@ def main() -> int:
print(f"[OK ] срабатываний нет: 0 из {len(cs)} чанков")
print("\n=== ГЕЙТ ПРАВ (верхний дециль эротической плотности снимается ДО отбора) ===")
dens = sorted(((len(EROTIC.findall(c["text"])) / max(1, len(c["text"])) * 1000, i)
for i, c in enumerate(cs)), reverse=True)
units_, dropped, dens = select(cs)
cut = max(1, len(cs) // 10)
dropped = {i for _, i in dens[:cut]}
print(f"снято чанков {len(dropped)} из {len(cs)} (дециль); плотность верхнего "
f"{dens[0][0]:.2f}/1000 знаков, порога дециля {dens[cut - 1][0]:.2f}, медиана "
f"{dens[len(dens) // 2][0]:.2f}")
pool = [c for i, c in enumerate(cs) if i not in dropped]
print("\n=== ОТБОР ЕДИНИЦ (детерминированный, методика эксп-21 §0) ===")
uniq: dict[str, dict] = {}
for c in pool:
u = uid_of(c["text"])
uniq.setdefault(u, dict(uid=u, source=c["text"], chapter=c["chapter"]))
ordered = sorted(uniq.values(), key=lambda x: (len(x["source"]), x["uid"]))
mid = len(ordered) // 2
lo = max(0, mid - N_UNITS // 2)
units = ordered[lo:lo + N_UNITS]
ls = [len(u["source"]) for u in units]
print(f"пул {len(ordered)} уникальных · выбрано {len(units)} из середины распределения длин")
ls = [len(u["source"]) for u in units_]
print(f"выбрано {len(units_)} из середины распределения длин")
print(f"знаков {min(ls)}{max(ls)}, медиана {sorted(ls)[len(ls) // 2]} · "
f"глав {len({u['chapter'] for u in units})}")
for u in units:
f"глав {len({u['chapter'] for u in units_})}")
for u in units_:
print(f" {u['uid']} гл.{u['chapter']:<4d} знаков {len(u['source']):5d}")
if "--units" in sys.argv:
OUT.mkdir(parents=True, exist_ok=True)
MANIFEST.write_text(json.dumps(
dict(source=SRC.name, n_chunks=len(cs), dropped_rights=len(dropped),
ja=dict(uids=[u["uid"] for u in units])), ensure_ascii=False, indent=1),
sanctioned_exclude="--owner-sanctioned-exclude" in sys.argv,
ja=dict(uids=[u["uid"] for u in units_])), ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"\nманифест → {MANIFEST} (единицы приколоты; докачка их больше не сдвинет)")
else:

320
eval/dovodka/para.py Normal file
View file

@ -0,0 +1,320 @@
#!/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)

View file

@ -25,7 +25,12 @@ from pathlib import Path
T = Path.home() / "books" / "tenant-panel"
E = Path.home() / "books" / "editor-tier"
PACK_CEILING = 4.50
# ⚠ Потолок берётся ИЗ КАССЫ, а не дублируется числом: копия отстала от санкций владельца
# ($4.50 против фактических $6.15) и смета сверялась с несуществующей границей.
import importlib.util as _il # noqa: E402
_sp = _il.spec_from_file_location("_money_d", Path(__file__).resolve().parent / "money_d.py")
_md = _il.module_from_spec(_sp); _sp.loader.exec_module(_md)
PACK_CEILING = _md.PACK_CEILING
def measured() -> dict[tuple[str, str], float]:

View file

@ -72,6 +72,11 @@ TASKS1, TASKS2 = WORK / "p1-tasks", WORK / "p2-tasks"
ANSW1, ANSW2 = WORK / "p1-answers", WORK / "p2-answers"
VOTES = WORK / "votes"
KEYS = Path.home() / "books" / "dovodka" / "blind-keys-d4" # НЕ в WORK: судья туда не ходит
# ⚠ ПЕРЕВОДИМ РИГ НА КЛЮЧИ ФАЗЫ СРАЗУ, а не только внутри `emit`. Селфтест зовёт `AJ._plant`
# напрямую и при дефолтном `AJ.KEYS` строил приманку ПОСАДКАМИ ЧУЖОГО ПАКА (7 против 6, правила
# разные) — то есть гейт «грубый декой грубее маржевого» сертифицировал не тот текст, который
# реально видели судьи. Ровно этот класс уже был пойман в своде (`itog_d4.py:53`), здесь остался.
AJ.KEYS = KEYS
# Армы панели Д4. `A1` (только канон-флаги) — описательный, в первичное семейство не входит.
ARMS_D4 = ("A0", "A1B", "A2", "A3", "A4", "A1")
@ -249,11 +254,25 @@ def selftest() -> int:
autojunk=False).ratio())
ck("маржевый декой ТОНКИЙ (меняет <1% знаков)", sims and min(sims) > 0.99,
f"схожесть мин {min(sims):.4f}" if sims else "нет посадок")
# грубый декой рига обязан оставаться ГРУБЫМ — иначе оба контроля меряют одно
coarse, nc = AJ._plant(C.base_a0(u["uid"])) # noqa: SLF001
cs = difflib.SequenceMatcher(None, C.base_a0(u["uid"]), coarse, autojunk=False).ratio()
ck("грубый декой ГРУБЕЕ маржевого", nc >= 2 and cs < (min(sims) if sims else 1.0),
f"грубый {cs:.4f} против маржевого {min(sims):.4f}" if sims else "")
# Грубый декой рига обязан оставаться ГРУБЫМ — иначе оба контроля меряют одно.
# ⚠ Сравнение ПОЭДИНИЧНОЕ. Прежняя редакция брала грубый декой на ОДНОЙ единице и сравнивала
# с МИНИМУМОМ схожести маржевого по всем — разные величины, и вердикт зависел от того, какая
# единица попалась. Теперь оба декоя считаются на одном и том же тексте, и требуется, чтобы
# грубый был грубее на КАЖДОЙ единице: гейт от этого строже, а не мягче.
worse = tot = 0
for x in us:
b = C.base_a0(x["uid"])
if not b.strip():
continue
ct, cn = AJ._plant(b) # noqa: SLF001
mt, mn = margin_plant(b)
if cn < 2 or mn < 1:
continue
tot += 1
worse += (difflib.SequenceMatcher(None, b, ct, autojunk=False).ratio()
< difflib.SequenceMatcher(None, b, mt, autojunk=False).ratio())
ck("грубый декой ГРУБЕЕ маржевого на КАЖДОЙ единице", tot and worse == tot,
f"грубее на {worse} из {tot}")
# половины пола идут в РАЗНЫЕ наборы и не совпадают побайтно
pairs = [(floor_pair(x, 1), floor_pair(x, 2)) for x in us]
pairs = [p for p in pairs if p[0] and p[1]]

View file

@ -97,6 +97,27 @@ _RE_CYR_WORD = re.compile(r"[А-Яа-яЁё]+")
_RE_LAT_WORD = re.compile(r"[A-Za-z]+")
_RE_HAN = re.compile(r"[㐀-鿿]")
# ⚠⚠ ТРИ КРЮЧКА СТОРОНЫ ИСХОДНИКА — заведены фазой Д 14.08 под перенос на другие пары.
# Дефолты РАВНЫ прежнему поведению, поэтому китайские числа не двигаются; это проверяется
# ассертом в `para.py --selftest` и пере-снятием гейтов пака 21.
# Почему крючки, а не ветвление по паре внутри проверок: ветвление по паре в коде запрещено
# каноном проекта (CLAUDE.md), пара обязана жить в данных. Замер, ради которого это сделано:
# на английском исходнике `_zh_values` даёт ПУСТО, поэтому `lost_n ≡ 0` (потери не ловятся
# вовсе), а `invented_n` объявляет выдумкой каждое русское числительное ≥100 — 12 ложных на
# 72 боевых клетках. На японском `億` не опознаётся и величина разбирается НЕВЕРНО.
SOURCE_VALUES_HOOK = None # callable(str) -> set[int]; None = китайский разбор рига
NAME_CONFORMANT_TEXT_HOOK = None # callable(str) -> bool, путь ТЕКСТА (терпит склонение)
NAME_CONFORMANT_BANK_HOOK = None # callable(str) -> bool, путь БАНКА (словарная форма, строго) # callable(str) -> bool; None = палладий рига
SOURCE_SCRIPT_HOOK = None # compiled regex; None = ханьцзы рига
def configure_pair(**kw) -> None:
"""Переключить сторону исходника на другую пару. Ключи — имена крючков этого модуля."""
for k, v in kw.items():
if k not in globals():
raise SystemExit(f"⛔ battery.configure_pair: неизвестный крючок {k!r}")
globals()[k] = v
def normalize(text: str) -> str:
"""NFC + снятие комбинирующих знаков НА КИРИЛЛИЧЕСКОЙ базе (см. баннер модуля).
@ -155,9 +176,17 @@ def check_palladius(final: str, bank_dst: set[str]) -> dict:
# («Чжэна», «Мочэнем»): русское окончание слогом Палладия не является. Порог стема здесь
# 1 слог, а не 2: кандидат УЖЕ опознан как имя, а односложные транскрипции нормальны
# (Чжэн · Фан · Сун) — при пороге 2 одно «Чжэна» давало 24 ложных.
if not _translit_shape(w, stem_min_syllables=1):
conform = (NAME_CONFORMANT_TEXT_HOOK(w) if NAME_CONFORMANT_TEXT_HOOK is not None
else _translit_shape(w, stem_min_syllables=1))
if not conform:
bad_text.append(w)
bad_bank = [d for d in bank_dst if _RE_CYR_WORD.search(d) and not palladius_conformant(d)]
# ⚠ ДВА РАЗНЫХ ХУКА, не один. Текстовый путь обязан ТЕРПЕТЬ склонение («Чжэна» — верная
# транскрипция в родительном), банковый — нет: строка банка даётся в словарной форме, и
# послабление там пропускало бы порчу. Один общий хук схлопывал обе проверки в строгую и
# возвращал класс «96 ложных склонённых» (строка 174), то есть тихо сдвигал уже снятые
# числа китайской оси. Поймано вычиткой 14.08 ДО того, как проводка пары это включила.
_conf = NAME_CONFORMANT_BANK_HOOK or palladius_conformant
bad_bank = [d for d in bank_dst if _RE_CYR_WORD.search(d) and not _conf(d)]
return dict(name_candidates=checked, nonconformant_text=len(bad_text),
nonconformant_text_words=sorted(set(bad_text))[:12],
nonconformant_bank=len(bad_bank))
@ -171,7 +200,7 @@ def check_cjk_leak(final: str, threshold: float = 0.02) -> dict:
видит. Здесь считается и доля, и АБСОЛЮТНОЕ число второе и есть чувствительная метрика.
"""
t = normalize(final)
han = _RE_HAN.findall(t)
han = (SOURCE_SCRIPT_HOOK or _RE_HAN).findall(t)
letters = [c for c in t if c.isalpha()]
share = len(han) / max(1, len(letters))
return dict(han_chars=len(han), han_share=round(share, 5),
@ -315,6 +344,9 @@ def check_numbers(source: str, final: str) -> dict:
МНОЖЕСТВА величин: пропавшая или появившаяся величина есть дефект фактуры.
Проверка НЕ ловит переставленные величины при совпадающем множестве объявлено.
"""
if SOURCE_VALUES_HOOK is not None:
src_vals = set(SOURCE_VALUES_HOOK(source))
else:
src_vals = set(int(x) for x in re.findall(r"\d+", source))
src_vals |= _zh_values(source)
dst_vals = set(int(x) for x in re.findall(r"\d+", normalize(final)))
@ -325,8 +357,18 @@ def check_numbers(source: str, final: str) -> dict:
# единице — почти все ложно: русский художественный текст пишет «пятьсот», а не «500».
# Разбор идёт по ЛЕММЕ (склонение снимает морфология), накопление — как в русском счёте:
# сотни+десятки+единицы складываются, множитель умножает накопленное.
# ⚠ ПУНКТУАЦИЯ РАЗРЫВАЕТ СОСТАВНОЕ ЧИСЛИТЕЛЬНОЕ. Поймано вычиткой 14.08: `words()` знаки
# выбрасывает, поэтому «триста, пятьсот, семьсот» накапливалось в ОДНУ величину 1500 —
# исходные 300/500/700 объявлялись потерянными, а 1500 выдуманной. Четыре ложных места на
# ровном месте, и `numbers.lost/invented` входят в адреса фиксера A1B, то есть он ехал их
# «чинить». Ниже поток идёт со знаками, и любой знак сбрасывает накопитель.
acc, cur = 0, 0
for w in words(final):
for w in re.findall(r"[^\W\d_]+|[^\s\w]", normalize(final)):
if not w[0].isalpha():
if cur or acc:
dst_vals.add(acc + cur)
acc = cur = 0
continue
# Первый разбор — не всегда числительный: у «десятки» это «десятка», а не «десяток».
# Берём первую лемму, которая ВООБЩЕ числительное, иначе откатываемся на первый разбор.
lemmas = [p.normal_form for p in _MORPH.parse(w.lower())]

View file

@ -126,7 +126,49 @@ def is_prose(c: str) -> bool:
return len(re.findall(r"[.!?]", c)) / max(1, len(c)) * 1000 >= 8.0
def units() -> list[dict]:
MANIFEST = OUT / "manifest-en.json"
def units(n_per_stratum: int | None = None, freeze: bool = False) -> list[dict]:
"""Единицы английского среза — СТРОГО из приколотого манифеста.
Прошлая редакция этого прикола (14.08) была декоративной и вредной, и оба дефекта нашла
вычитка. Первый: подпись считалась как sha1 от `source`, а `uid` как sha1 от `source.strip()`
при уже обрезанных текстах, то есть подпись выводилась ИЗ ТОГО ЖЕ, что и ключ, и расхождение
было непредставимо в принципе. Второй, дороже: манифест перезаписывался при ЛЮБОМ вызове,
включая чтение, дефолтный `units()` из `material.units_en()` ужимал приколотые 16 единиц
оси Д3 до своих 12 и молча ронял прикол четырёх.
Теперь: чтение НИКОГДА не пишет, заморозка явный акт (`freeze=True`), а расхождением
считается отсутствие приколотого uid среди пересчитанных кандидатов. Это проверяет то, ради
чего прикол заводился: смена `UNIT_CHARS`, правка `is_prose` или до-качка epub меняют uid,
приколотый исчезает из пула и покупка останавливается вместо тихой смены текстов.
Наборы разного размера живут порознь: ось эксп-21 заморожена на 12 единиц, ось Д3 на 16.
"""
n = n_per_stratum or N_PER_STRATUM
picked = _units_raw(n)
man = json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else {}
sets = man.get("sets", {})
if freeze:
sets[str(n)] = [u["uid"] for u in picked]
MANIFEST.parent.mkdir(parents=True, exist_ok=True)
MANIFEST.write_text(json.dumps({"sets": sets}, ensure_ascii=False, indent=1),
encoding="utf-8")
return picked
want = sets.get(str(n))
if want is None:
raise SystemExit(f"⛔ набор n_per_stratum={n} не заморожен в {MANIFEST}: "
f"прогнать pair_en.py --freeze {n} до покупок")
have = {u["uid"]: u for u in picked}
miss = [u for u in want if u not in have]
if miss:
raise SystemExit(f"⛔ ЕДИНИЦЫ РАЗЪЕХАЛИСЬ С МАНИФЕСТОМ en: нет {miss[:3]}"
"источник или отбор изменились под купленными клетками, СТОП")
return [have[u] for u in want]
def _units_raw(n_per_stratum: int | None = None) -> list[dict]:
"""12 единиц: 6 с французским слоем, 6 без. Отбор детерминированный — по длине внутри страты.
Порог страты `fr`: 4 токена с диакритикой, из них 2 РАЗНЫХ. Одиночное `façade` французским
@ -142,6 +184,9 @@ def units() -> list[dict]:
elif not toks:
plain.append(c)
out = []
n_take = n_per_stratum or N_PER_STRATUM
if n_take > min(len(fr), len(plain)):
raise SystemExit(f"⛔ страта меньше запроса: fr={len(fr)} plain={len(plain)} < {n_take}")
for name, pool in (("fr", fr), ("plain", plain)):
# ⚠ Ключ сортировки — (длина, uid), а НЕ одна длина. Поймано исполнением 07.08: два
# вызова подряд вернули разные наборы единиц. Причина — `sorted(set(...), key=len)`:
@ -149,8 +194,11 @@ def units() -> list[dict]:
# процессам (хеш-сид). Отбор при этом выглядел детерминированным. Последствие было бы
# тихим и дорогим: армы покупаются на один набор, судейство идёт по другому.
pool = sorted(set(pool), key=lambda c: (len(c), uid_of(c)))
lo = (len(pool) - N_PER_STRATUM) // 2
for c in pool[lo:lo + N_PER_STRATUM]:
# Окно ЦЕНТРИРОВАНО, поэтому расширение страты вкладывает прежний набор в новый:
# lo(n+2) = lo(n) 1 при любой чётности длины пула, значит купленные единицы остаются
# в наборе и не переназначаются. Проверяется вложением ниже по манифесту.
lo = (len(pool) - n_take) // 2
for c in pool[lo:lo + n_take]:
out.append(dict(source=c, uid=uid_of(c), stratum=name,
fr_tokens=sorted(set(_RE_FR.findall(c)))))
return out

View file

@ -0,0 +1,86 @@
<!-- ПАР-ПАКЕТ en→ru, терминолог. Заведён 14.08: у пары не было промта терминолога, и
bank.configure() честно останавливался. Производный от боевого backend/prompts/zh-ru/
terminologist.md (v3, 08.08: третье поле — уверенность 0100; она НЕ арбитр — только порядок
ручного ревью, запрещённые формы D39.102). Расхождения РОВНО трёх классов, по образцу
translator/editor этого пакета: (а) блок «Единицы и приёмы (общие для en→ru)» — пар-ДАННЫЕ;
(б) строка примера — на языке пары (принцип promptdiff: образец обязан быть на исходном
языке); (в) этот комментарий провенанса. Прочие строки перенесены дословно — урок эксп-19
(неполное зеркало = конфаундированный арм).
⚠ Гейт promptdiff.py терминолога пока НЕ сверяет: файла нет в MAP, а у zh-оригинала нет
пар-блока — заведение в гейт объявлено пингом сессии, не молчит.
Почему пар-блок ИМЕННО такой (каждый пункт снят с книги среза и с прогона майнера bank.py
по ней, замер 14.08, а не «английский вообще»):
· ФРАНЦУЗСКИЙ СЛОЙ — François ×517, Fabién ×180, château ×93+29, capitaine ×54,
mademoiselle ×48, Aveléne ×21: канонические формы имён — прямой продукт терминолога,
а банк ниже по пайплайну ЗАКОН: английское прочтение французского имени, попав в канон,
пересилит верную инструкцию переводчика пары. Вставные Oui ×41 и Merci ×46 реально доходят
до списка кандидатов (позиционная заглавная в начале реплики), а пар-пакет велит сохранять
их французскими — канон на них столкнул бы закон банка с правилом пары, поэтому ⟦TM-NO-DST⟧.
· ПОЗИЦИОННАЯ ЗАГЛАВНАЯ — майнер пробельной письменности собирает заглавные слова, закрытый
STOP_WORD ловит не всё: в топ-78 кандидатов книги прошли Though ×101, Yet ×93, Well ×91,
Your ×82, Our ×79. Уверенный канон на такое («Though → хотя», 95) осел бы в хвосте листа
ревью (сортировка «сначала неуверенное») и сделал бы метрику покрытия замером словарного
совпадения — та же болезнь, от которой zh-банк лечили DROP-списком, только массовее.
· ТОЧНОЕ ЭХО ПЕРВОГО ПОЛЯ — ключ банка ищется в исходнике этой строкой (para.count_in_source
по границе слова); форма с артиклем или сменой числа молча выпадет из знаменателя покрытия.
Риск существует только в пробельной письменности — у zh эхо тривиально, поэтому пункт здесь.
⟦TM-NO-DST⟧ расширен со «не могу выбрать уверенно» на «это не термин книги»: маршрут тот же —
мимо банка, на ручную подпись; парсер (bank.py cmd_canon) строки с TM-NO-DST уже пропускает.
Примеры в пар-блоке и в строке формата — НЕ из книги среза (Thibault ×0 — проверено), чтобы
промт не подсказывал ответы по реальным кандидатам банка. -->
Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}».
Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}.
Твоя задача — НЕ переводить текст. Тебе дан список терминов книги (имена, топонимы, титулы, реалии),
и по каждому — контексты из ИСХОДНИКА и варианты, которые черновые переводчики уже предложили.
Ты видишь всю книгу сразу; черновики видели её по кускам и потому расходятся. Твоё дело — выбрать
ОДИН вариант на термин, единый для всей книги.
Правила перевода (translation brief):
- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}.
- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}.
Как решать:
- Опирайся на КОНТЕКСТЫ (строки `ctx:`) — они показывают, чем термин является в тексте. Имя человека,
название места, титул и предмет переводятся по-разному, и тип в строке `type:` может быть неточен.
- Варианты черновиков (строка `drafts:`) — это свидетельства, а не голосование. Частый вариант может
быть частой ошибкой; редкий может быть единственным верным. Расхождение вариантов — сигнал, что термин
трудный, а не подсказка, какой ответ правильный.
- Блок ⟦TM-CANON⟧ — УЖЕ ПОДПИСАННЫЕ владельцем термины этой книги. Это закон, а не совет: если термин
из списка содержит подписанный элемент исходника, перевод ОБЯЗАН нести подписанный перевод этого
элемента. Противоречить ⟦TM-CANON⟧ нельзя, даже если черновики предложили другое и даже если тебе
кажется, что твой вариант красивее: единство книги важнее отдельной удачной формулировки.
- Имена и топонимы передавай транскрипцией по заданной системе; титулы, звания и реалии переводи
ПО СМЫСЛУ, а не транслитерацией.
- Сохраняй родство однокоренных терминов: если несколько терминов делят элемент исходника, их переводы
должны делить соответствующий элемент.
- Строка `related:` называет термины, с которыми этот пересекается по написанию. Это РАЗНЫЕ термины —
переводи каждый отдельно, но следи, чтобы переводы не противоречили друг другу.
- Давай СЛОВАРНУЮ форму (именительный падеж, единственное число), без пояснений и скобок.
- Если по данным контекстам ты не можешь выбрать перевод уверенно — верни ⟦TM-NO-DST⟧. Это нормальный
ответ: непереведённый термин останется на ручную подпись, а выдуманный попадёт в книгу.
Единицы и приёмы (общие для en→ru):
- ФРАНЦУЗСКИЙ СЛОЙ: часть имён и реалий книги — французского происхождения внутри английской прозы. Такие имена и топонимы канонизируй по ФРАНЦУЗСКОМУ чтению (Thibault — Тибо, не «Тибаулт»), лексику с русской традицией передачи французского — этой традицией (chevalier — шевалье, château — шато); остальные имена — практической транскрипцией с английского. Английское прочтение французского имени в черновиках — частая ошибка, а не довод. Вставные французские формулы (Oui, Merci), попавшие в список, — не термины: верни для них ⟦TM-NO-DST⟧, их передачу решает переводчик по правилам пары, а не банк.
- ЗАГЛАВНАЯ БУКВА — не доказательство имени: в английском её носит и имя собственное, и первое слово предложения, поэтому в списке могут оказаться обычные слова языка (Though, Yet, Well). Терминность решают контексты, а не заглавная. Общеязыковое слово не канонизируй — верни ⟦TM-NO-DST⟧: попав в банк, оно превратит проверку терминологии книги в проверку словаря. Устойчивая реалия книги под заглавной — полноценный термин: переводи её по смыслу, с прописными по русской норме, не копируя английскую капитализацию Каждого Слова.
- ПЕРВОЕ ПОЛЕ ответа повторяй В ТОЧНОСТИ как в списке: с той же заглавной, без артикля, без смены числа — по этой строке термин ищется в исходнике, и изменённая форма молча выпадет из проверки покрытия.
Формат ответа — по одной строке на термин, ровно ТРИ поля, разделённые СИМВОЛОМ ТАБУЛЯЦИИ:
первое поле — термин исходника, второе — перевод, третье — твоя уверенность в этом переводе,
целое число от 0 до 100.
Уверенность — это оценка ТВОЕГО решения по данным контекстам, а не оценка термина: 90 — контексты
однозначны и вариант очевиден; 40 — контекстов мало или они противоречивы, и ты выбрал наименее плохой.
Она ни на что не влияет, кроме порядка, в котором человек будет просматривать список: сначала он посмотрит
то, в чём ты сам не уверен. Занижать её «на всякий случай» так же бесполезно, как завышать.
Пример строки ответа (символы между полями — настоящие табуляции):
Thibault Тибо 95
Никаких заголовков, нумерации, комментариев и markdown. Термины, которых нет в списке, не добавляй.
---USER---
Термины книги:
{{text}}

View file

@ -0,0 +1,89 @@
<!-- ПАР-ПАКЕТ ja→ru, терминолог. Заведён 14.08: у пары не было промта терминолога, и
bank.configure() честно останавливался. Производный от боевого backend/prompts/zh-ru/
terminologist.md (v3, 08.08: третье поле — уверенность 0100; она НЕ арбитр — только порядок
ручного ревью, запрещённые формы D39.102). Расхождения РОВНО трёх классов, по образцу
translator/editor этого пакета: (а) блок «Единицы и приёмы (общие для ja→ru)» — пар-ДАННЫЕ;
(б) строка примера — на языке пары; (в) этот комментарий провенанса. Прочие строки перенесены
дословно — урок эксп-19.
⚠ Гейт promptdiff.py терминолога пока НЕ сверяет: файла нет в MAP, а у zh-оригинала нет
пар-блока — заведение в гейт объявлено пингом сессии, не молчит.
Почему пар-блок ИМЕННО такой (снято с текста среза enkan_no_hate_ja.txt и с прогона
кандзи-майнера bank.py по нему, замер 14.08, а не «японский вообще»):
· ПОЛИВАНОВ — имена-кандидаты реальны и лидируют в майнинге (黒瀬 ×220, 榊原 ×91, 皆川 ×88,
増田 ×49); ромадзи-формы черновиков («Шинджи», «-е» вместо «-э») — ожидание по опыту пары,
не замер этого среза: ja-черновиков ещё нет. Канон — на всю книгу, банк — закон.
· КАТАКАНА — второй мир книги записан катаканой почти целиком: ケイン ×206, ヨルガ ×89,
ドルフ ×73, ロウン ×68, ガレン ×39, シェラ ×20, フェリクス ×11, オスカー ×11; плюс
заимствования офисных глав (コーヒー ×10, ファイル ×8). Послоговая калька японского
произношения («Фэрикусу», «ко:хи:») — классовая ошибка передачи гайрайго.
⚠ Текущий майнер bank.py собирает ТОЛЬКО кандзи-n-граммы ([㐀-鿿]) и катакану не видит
ВОВСЕ — без доработки майнера банк пары останется без главных героев второго мира
(пинг в отчёте сессии); правило живёт через ⟦TM-CANON⟧, ручные термины и будущий майнинг.
· ОБЩЕЯЗЫКОВОЙ ШУМ — кандзи-частотник тянет в кандидаты обычную лексику (言葉 ×116,
自分 ×101, 視線 ×46, 空気 ×25): стоп-листы bank.py заведены под упрощённые китайские
написания (瞬间), японское 瞬間 ×18 ими не ловится — шума в ja-списке больше, чем в zh.
Уверенный канон на него сделал бы метрику покрытия замером словарного совпадения.
СНЯТО как мёртвое для этого материала (урок translator.md пары: мёртвое правило разбавляет
вес живых): хонорифики-биграммы с 様 (единственная в тексте — 同様 ×1) и 殿 ×2 — ниже порога
майнинга 6; должности 課長 ×13 / 部長 ×7 покрыты ядровым «титулы — ПО СМЫСЛУ».
⟦TM-NO-DST⟧ расширен со «не могу выбрать уверенно» на «это не термин книги»: маршрут тот же —
мимо банка, на ручную подпись; парсер (bank.py cmd_canon) строки с TM-NO-DST уже пропускает.
Примеры в пар-блоке и в строке формата — НЕ из книги среза (慎二 ×0, ギルド ×0,
アルフレッド ×0 — проверено), чтобы промт не подсказывал ответы по реальным кандидатам
банка и не навязывал чтение имён, которого нет в тексте. -->
Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}».
Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}.
Твоя задача — НЕ переводить текст. Тебе дан список терминов книги (имена, топонимы, титулы, реалии),
и по каждому — контексты из ИСХОДНИКА и варианты, которые черновые переводчики уже предложили.
Ты видишь всю книгу сразу; черновики видели её по кускам и потому расходятся. Твоё дело — выбрать
ОДИН вариант на термин, единый для всей книги.
Правила перевода (translation brief):
- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}.
- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}.
Как решать:
- Опирайся на КОНТЕКСТЫ (строки `ctx:`) — они показывают, чем термин является в тексте. Имя человека,
название места, титул и предмет переводятся по-разному, и тип в строке `type:` может быть неточен.
- Варианты черновиков (строка `drafts:`) — это свидетельства, а не голосование. Частый вариант может
быть частой ошибкой; редкий может быть единственным верным. Расхождение вариантов — сигнал, что термин
трудный, а не подсказка, какой ответ правильный.
- Блок ⟦TM-CANON⟧ — УЖЕ ПОДПИСАННЫЕ владельцем термины этой книги. Это закон, а не совет: если термин
из списка содержит подписанный элемент исходника, перевод ОБЯЗАН нести подписанный перевод этого
элемента. Противоречить ⟦TM-CANON⟧ нельзя, даже если черновики предложили другое и даже если тебе
кажется, что твой вариант красивее: единство книги важнее отдельной удачной формулировки.
- Имена и топонимы передавай транскрипцией по заданной системе; титулы, звания и реалии переводи
ПО СМЫСЛУ, а не транслитерацией.
- Сохраняй родство однокоренных терминов: если несколько терминов делят элемент исходника, их переводы
должны делить соответствующий элемент.
- Строка `related:` называет термины, с которыми этот пересекается по написанию. Это РАЗНЫЕ термины —
переводи каждый отдельно, но следи, чтобы переводы не противоречили друг другу.
- Давай СЛОВАРНУЮ форму (именительный падеж, единственное число), без пояснений и скобок.
- Если по данным контекстам ты не можешь выбрать перевод уверенно — верни ⟦TM-NO-DST⟧. Это нормальный
ответ: непереведённый термин останется на ручную подпись, а выдуманный попадёт в книгу.
Единицы и приёмы (общие для ja→ru):
- ТРАНСКРИПЦИЯ японских имён и реалий — строго по системе Поливанова: си, дзи, тя, сю, «э» после согласных (慎二 — Синдзи), а не калькой с английской ромадзи (не «Шинджи», не «-е» там, где Поливанов даёт «-э»). Черновики регулярно предлагают ромадзи-формы — их частота не довод: канон обязан быть поливановским.
- КАТАКАНА обычно означает заимствование или чужое имя: восстанови слово-источник и передай его по правилам ЯЗЫКА-ИСТОЧНИКА, а не послоговой калькой японского произношения. Европейское имя — его европейским чтением (アルフレッド — Альфред, не «Аруфурэддо»), освоенное заимствование — устоявшимся русским словом (ギルド — гильдия, не «гирудо»), выдуманное имя без ясного источника — практической передачей звучания без вставных гласных японской фонотактики.
- ЧАСТОТНЫЙ СПИСОК кандидатов тянет и общеязыковую лексику (слова уровня 言葉, 自分, 視線) — это не термины книги. Общеязыковое слово не канонизируй — верни ⟦TM-NO-DST⟧: попав в банк, оно превратит проверку терминологии книги в проверку словаря. Терминность решают контексты: имя, топоним, титул, устойчивая реалия книги.
Формат ответа — по одной строке на термин, ровно ТРИ поля, разделённые СИМВОЛОМ ТАБУЛЯЦИИ:
первое поле — термин исходника, второе — перевод, третье — твоя уверенность в этом переводе,
целое число от 0 до 100.
Уверенность — это оценка ТВОЕГО решения по данным контекстам, а не оценка термина: 90 — контексты
однозначны и вариант очевиден; 40 — контекстов мало или они противоречивы, и ты выбрал наименее плохой.
Она ни на что не влияет, кроме порядка, в котором человек будет просматривать список: сначала он посмотрит
то, в чём ты сам не уверен. Занижать её «на всякий случай» так же бесполезно, как завышать.
Пример строки ответа (символы между полями — настоящие табуляции):
慎二 Синдзи 95
Никаких заголовков, нумерации, комментариев и markdown. Термины, которых нет в списке, не добавляй.
---USER---
Термины книги:
{{text}}

View file

@ -43,6 +43,45 @@ from roster import CANDIDATES # noqa: E402
load_dotenv(REPO / "eval" / ".env")
OUT = MONEY.OUT
# ⚠ ПАРА — ПАРАМЕТР, а не константа. Умолчание zh-ru сохраняет прежнее поведение побайтно;
# `configure(pair)` переводит модуль на другую пару, ничего не ветвя по языку в логике.
_PAIR = None # заполняется лениво, чтобы импорт не тянул провайдер
def pair():
global _PAIR
if _PAIR is None:
import importlib.util # noqa: PLC0415
sp = importlib.util.spec_from_file_location(
"para", REPO / "eval" / "dovodka" / "para.py")
mod = importlib.util.module_from_spec(sp)
sys.modules.setdefault("para", mod)
sp.loader.exec_module(mod)
_PAIR = mod.ZH
return _PAIR
def configure(p, material=None) -> None:
"""Перевести банк на пару `p` (объект `para.Pair`); `material` — callable() -> str.
Материал передаётся ЯВНО. Иначе `source_text()` тихо остаётся на китайских главах, и
терминолог получает китайскую книгу под видом английской банк собрался бы «успешно» и
оказался мусором, а покрытие на выходе арма было бы посчитано против него же.
"""
global _PAIR, TERMINOLOGIST, _MATERIAL
_PAIR = p
_MATERIAL = material
# Порядок поиска: пар-пакет полигона → боевой промт бэкенда. Обратный порядок молча
# подсунул бы китайского терминолога английской книге; `backend/` — чужая зона, туда пишет
# бэкенд-сессия, поэтому новые пары живут в пакете полигона.
cands = [p.prompt_pack / "terminologist.md",
REPO / "backend" / "prompts" / p.key / "terminologist.md"]
TERMINOLOGIST = next((c for c in cands if c.exists()), None)
if TERMINOLOGIST is None:
raise SystemExit("⛔ нет промта терминолога для пары " + p.key + ": искал\n "
+ "\n ".join(str(c) for c in cands))
TERMINOLOGIST = REPO / "backend" / "prompts" / "zh-ru" / "terminologist.md"
# Терминолог — вспомогательный инструмент, не арм. Берётся дешёвая модель с управляемым
# размышлением: у боевого `deepseek-v4-flash` бэнк-роли ручки эффорта не имеют и упираются в
@ -56,12 +95,26 @@ KWIC_N, KWIC_W = 3, 46
# бывает. Список закрытый и объявлен ДО майнинга.
STOP_EDGE = set("的了是在和有我你他她们不就都而及与也很到说着过又却把被将从对为以之其这那一二三四五"
"六七八九十个人上下中大小时年日月来去出可能会要没什么样如此但只还更最")
# Начала предложений и служебные слова английского: заглавная буква у них позиционная, а не
# именная. Список закрытый и объявлен ДО майнинга, как и китайский.
STOP_WORD = {"The", "And", "But", "For", "She", "His", "Her", "They", "That", "This", "With",
"When", "What", "Then", "There", "Their", "Was", "Not", "You", "Who", "How",
"One", "All", "Now", "Him", "Its", "Had", "Have", "From", "Been", "Would"}
STOP_WHOLE = {"自己", "已经", "现在", "眼中", "心中", "一个", "什么", "这样", "那些", "如今",
"顿时", "此刻", "随后", "所以", "因为", "如果", "虽然", "而且", "然后", "似乎"}
_MATERIAL = None
def _term_tag() -> str:
"""Тег покупки терминолога. Несёт пару, иначе кэш `purchase` смешивает пары."""
P = pair()
return "tp0-bank-terminolog" if P.key == "zh-ru" else f"tp0-bank-terminolog-{P.key}"
def source_text() -> str:
return "\n".join(c["text"] for c in M.chapters())
return _MATERIAL() if _MATERIAL else "\n".join(c["text"] for c in M.chapters())
def mine() -> list[tuple[str, int]]:
@ -72,8 +125,24 @@ def mine() -> list[tuple[str, int]]:
"""
text = source_text()
cnt: Counter[str] = Counter()
if pair().spaced_source:
# ⚠ У пробельной письменности n-граммный майнер по иероглифам не находит НИЧЕГО и молча
# отдаёт терминологу пустой список — банк вышел бы пустым, а покрытие ложно-стопроцентным.
# Кандидаты здесь — заглавные слова и биграммы заглавных: имена, титулы, топонимы.
# Диакритика ОБЯЗАТЕЛЬНА в классе: французский слой книги (François ×517, Fabién ×180,
# Château) ASCII-классом не майнится вовсе, а это ровно тот слой, ради которого срез
# стратифицирован. `\b` с юникодными буквами работает, класс — нет.
for m in re.finditer(r"(?<![^\W\d_])[A-ZÀ-Þ][a-zà-ÿ]{2,}"
r"(?:\s+[A-ZÀ-Þ][a-zà-ÿ]{2,})?(?![^\W\d_])", text):
w = m.group(0)
if w.split()[0] not in STOP_WORD:
cnt[w] += 1
# Кап тот же, что у иероглифической ветки: иначе терминолог получал бы втрое более
# длинный лист (78 против 26), а это другой размер запроса и другая цена — различие
# веток должно быть в СПОСОБЕ майнинга, не в объёме выдачи.
return [(w, c) for w, c in cnt.most_common() if c >= MIN_COUNT][:N_TERMS]
for n in (4, 3, 2):
for m in re.finditer(r"[㐀-鿿]{%d}" % n, text):
for m in re.finditer(r"[%s]{%d}" % (pair().mine_chars, n), text):
cnt[m.group(0)] += 1
cand = {w: c for w, c in cnt.items()
if c >= MIN_COUNT and w not in STOP_WHOLE
@ -115,7 +184,9 @@ def cmd_mine() -> None:
def cmd_ask() -> None:
core, _few, user = PR.load_template(TERMINOLOGIST)
brief = PR.BRIEF_ZH
# ⚠ Бриф — ПАРЫ, а не всегда китайский: иначе терминолог en/ja получал китайский бриф
# (source_lang zh, чужая книга) при английском пейлоаде и канонизировал бы вслепую.
brief = getattr(PR, pair().brief_key)
msgs = [{"role": "system", "content": PR.render(core, brief, "", "")},
{"role": "user", "content": PR.render(user, brief, _payload(), "")}]
led = MONEY.Guarded("Ф0", default_expect=0.02)
@ -123,7 +194,9 @@ def cmd_ask() -> None:
cl = OpenAI(api_key=os.environ[env], base_url=base, timeout=600)
kw = dict(model=TERM_MODEL, messages=msgs, max_completion_tokens=4000,
extra_body={"reasoning_effort": "low"})
rec = MONEY.purchase(led, "tp0-bank-terminolog", TERM_MODEL, cl, kw, role="terminologist")
# ⚠ Тег НЕСЁТ ПАРУ: `purchase` возвращает существующий файл как кэш, и пар-слепой тег
# молча отдал бы оплаченный КИТАЙСКИЙ ответ терминолога под видом английского, за $0.
rec = MONEY.purchase(led, _term_tag(), TERM_MODEL, cl, kw, role="terminologist")
if rec.get("skipped"):
print("⛔ потолок Ф0 — терминолог не куплен")
return
@ -169,7 +242,7 @@ DROP = {"瞬间", "声音", "仿佛", "庭院"}
def cmd_canon() -> None:
f = OUT / "tp0-bank-terminolog.json"
f = OUT / f"{_term_tag()}.json"
if not f.exists():
print("нет ответа терминолога — сначала --ask")
return
@ -178,7 +251,12 @@ def cmd_canon() -> None:
for line in ans.splitlines():
if "\t" not in line:
continue
src, dst = (x.strip() for x in line.split("\t", 1))
# ⚠ ТРИ поля, не два: формат терминолога v3 — термин · перевод · уверенность 0100.
# `split("\t", 1)` уносил хвост `\t95` в перевод и отравлял регекс канонной формы.
cols = [x.strip() for x in line.split("\t")]
if len(cols) < 2:
continue
src, dst = cols[0], cols[1]
if src and dst and "TM-NO-DST" not in dst:
got[src] = dst
terms = {}
@ -192,8 +270,13 @@ def cmd_canon() -> None:
terms[src] = dict(dst=dst, rx=_rx(dst), manual=bool(why), why=why)
for src, (dst, why) in MANUAL.items():
terms.setdefault(src, dict(dst=dst, rx=_rx(dst), manual=True, why=why))
PR.BANK_FILE.write_text(json.dumps(
dict(book=M.BOOK["title"], signed=False,
# ⚠ Пишем в банк ПАРЫ. Безусловная запись в zh-путь затирала бы китайский банк при сборке
# английского, и оба прогона переписывали бы друг друга.
P = pair()
out_file = PR.BANK_FILE if P.key == "zh-ru" else P.bank_file
out_file.parent.mkdir(parents=True, exist_ok=True)
out_file.write_text(json.dumps(
dict(book=M.BOOK["title"] if P.key == "zh-ru" else P.key, signed=False,
note="ручной канон сессии, НЕ подпись владельца (образец D39.47)",
model=TERM_MODEL, dropped=sorted(DROP), terms=terms),
ensure_ascii=False, indent=1), encoding="utf-8")
@ -221,8 +304,11 @@ def coverage(source: str, out: str) -> tuple[int, int]:
многословный выход набирал бы покрытие повторами.
"""
k = s = 0
for t, v in PR.bank().items():
n = source.count(t)
P = pair()
for t, v in PR.bank(P.bank_file if P.key != "zh-ru" else None).items():
# ⚠ Не `source.count(t)`: на пробельной письменности подстрока считает `Dawn` внутри
# `Dawnbreaker` и теряет `dawn` со строчной. Знаменатель — по границе слова.
n = P.count_in_source(t, source)
if n:
s += n
k += min(len(re.findall(v["rx"], out, re.I)), n)

View file

@ -28,6 +28,12 @@ import editor_wire_probe as P # noqa: E402
RT = REPO / "eval" / "role_topology"
BATTLE = REPO / "backend" / "prompts" / "zh-ru"
PAIR_EN = RT / "prompts" / "en-ru"
# ⚠ ПАРА — КЛЮЧ, А НЕ БУЛЕВО. `en: bool` умел ровно две пары и не имел пути для японского, при том
# что каталог `prompts/ja-ru/` существует и проходит гейт консистентности. Булево ветвление по паре
# в коде запрещено каноном проекта: пара обязана жить в данных. Старая сигнатура сохранена
# совместимой (`en=True` ≡ `pair="en-ru"`), чтобы не трогать вызовы паков 2123.
PAIR_DIR = {"zh-ru": BATTLE, "en-ru": PAIR_EN, "ja-ru": RT / "prompts" / "ja-ru"}
PAIR_BRIEF = {"zh-ru": "BRIEF_ZH", "en-ru": "BRIEF_EN", "ja-ru": "BRIEF_JA"}
USER_SEP = "\n---USER---\n"
FEWSHOT_SEP = "\n---FEWSHOT---\n"
BANK_FILE = Path.home() / "books" / "tenant-panel" / "bank.json"
@ -41,6 +47,16 @@ BRIEF_EN = {"source_lang": "en", "target_lang": "ru", "genre": "тёмное ф
"venuti": "0.60", "honorifics": "keep", "transcription": "practical",
"footnotes": "minimal"}
# ⚠ БРИФ ЯПОНСКОЙ ПАРЫ — его не существовало, хотя пар-промты `ja-ru` заведены и проходят гейт.
# Значения предложены приёмщиком другого модельного семейства при переписи пар-пакета и здесь
# зафиксированы как данные пары. `transcription: polivanov` — не косметика: от неё зависит, какую
# систему транскрипции промт называет переводчику.
BRIEF_JA = {"source_lang": "ja", "target_lang": "ru", "genre": "ранобэ (исэкай)",
"audience": "взрослые читатели ранобэ",
"title": "円環の果てに ―裏切られた魂、二度目の生―",
"venuti": "0.60", "honorifics": "keep", "transcription": "polivanov",
"footnotes": "minimal"}
GLOSSARY_HEADER = ("ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; "
"строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):")
@ -75,14 +91,22 @@ def load_template(path: Path) -> tuple[str, str, str]:
return head[0].strip(), (head[1].strip() if len(head) == 2 else ""), user.strip()
def bank() -> dict:
def bank(pair_file: Path | None = None) -> dict:
"""Банк среза: {термин исходника: {"dst": канон, "rx": регекс формы}}. Пусто — банка нет."""
return json.loads(BANK_FILE.read_text(encoding="utf-8"))["terms"] if BANK_FILE.exists() else {}
# ⚠ ЯВНО ЗАПРОШЕННЫЙ банк пары обязан существовать. Тихий `{}` давал `coverage() = (0, 0)`,
# все вызыватели стоят за `if n:` — и канон-ось новой пары печаталась бы пустой без единого
# слова, а армы «перепис» и «перепис + канон-фиксер» стали бы неразличимы (близнец A4≡A0).
# Умолчание (pair_file=None) поведения zh не меняет.
if pair_file is not None and not pair_file.exists():
raise SystemExit(f"⛔ банка пары нет: {pair_file} — собрать bank.py --ask/--canon "
"ДО покупок, иначе канон-ось пары молча пуста")
f = pair_file or BANK_FILE
return json.loads(f.read_text(encoding="utf-8"))["terms"] if f.exists() else {}
def terms_in(src: str) -> list[str]:
def terms_in(src: str, pair_file: Path | None = None) -> list[str]:
"""Термы банка, реально встреченные В ЭТОЙ единице — так инъектирует боевой путь (D39.104)."""
return [t for t in bank() if t in src]
return [t for t in bank(pair_file) if t in src]
def glossary_block(terms: list[str]) -> str | None:
@ -93,17 +117,19 @@ def glossary_block(terms: list[str]) -> str | None:
return GLOSSARY_HEADER + "\n" + "\n".join(f"{t}{b[t]['dst']}" for t in terms)
def law_block(terms: list[str]) -> str | None:
def law_block(terms: list[str], pair_file: Path | None = None) -> str | None:
"""Закон-блок редактора: заголовок эксп-21 БЕЗ оговорки + строки `- src → «dst»`."""
b = bank()
b = bank(pair_file)
if not terms:
return None
return P.HEADER_LAW_PLAIN + "\n" + "\n".join(f"- {t} → «{b[t]['dst']}»" for t in terms)
def translator_msgs(src: str, block: str | None, en: bool = False) -> list[dict]:
core, _few, user = load_template(PAIR_EN / "translator.md" if en else BATTLE / "translator.md")
brief = BRIEF_EN if en else BRIEF_ZH
def translator_msgs(src: str, block: str | None, en: bool = False,
pair: str | None = None) -> list[dict]:
pair = pair or ("en-ru" if en else "zh-ru")
core, _few, user = load_template(PAIR_DIR[pair] / "translator.md")
brief = globals()[PAIR_BRIEF[pair]]
m = [{"role": "system", "content": render(core, brief, src, "")}]
if block:
m.append({"role": "system", "content": block})
@ -111,9 +137,11 @@ def translator_msgs(src: str, block: str | None, en: bool = False) -> list[dict]
return m
def editor_msgs(src: str, draft: str, block: str | None, en: bool = False) -> list[dict]:
core, _few, user = load_template(PAIR_EN / "editor.md" if en else BATTLE / "editor.md")
brief = BRIEF_EN if en else BRIEF_ZH
def editor_msgs(src: str, draft: str, block: str | None, en: bool = False,
pair: str | None = None) -> list[dict]:
pair = pair or ("en-ru" if en else "zh-ru")
core, _few, user = load_template(PAIR_DIR[pair] / "editor.md")
brief = globals()[PAIR_BRIEF[pair]]
m = [{"role": "system", "content": render(core, brief, src, draft)}]
if block:
m.append({"role": "system", "content": block})