textmachine/eval/dovodka/contour.py

697 lines
50 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
"""Д4 — EDIT-КОНТУР НА zh. Покупает армы A1A4 и A6 на 32 единицах. Платный.
Вопрос фазы (ставка владельца H-2б): нужен ли ПОЛНЫЙ ПЕРЕПИС, или дешевле и лучше найти
проблемные места и починить точечно. Армы объявлены в пре-реге Д0.5 и здесь не меняются:
A0 боевой full-regen (deepseek-v4-pro поверх якорного черновика) КУПЛЕН обоими паками, $0
A1 черновик + ДЕТЕРМИНИРОВАННЫЕ флаги (батарея + канон-гейт) → точечный фиксер
A2 dspro-однопроходка УРАВНЕННОГО мандата (строка 153 бэклога) — редактора нет вовсе
A3 черновик + СМЫСЛОВОЙ LLM-критик («прочитай, пойми, найди всё проблемное») → фиксер
A4 A0 + канон-фиксер ПОСЛЕ (полировка: чинит потери канона боевого редактора)
A6 ДРУГАЯ БАЗА: черновик dspro эксп-22 + оба контура — прямой носитель H-1
⚠ ПОЧЕМУ ФИКСЕР ВИДИТ ТОЛЬКО МЕСТА, А НЕ ВЕСЬ ТЕКСТ. Если дать фиксеру полный мандат, он станет
редактором, и контраст «точечно против переписа» исчезнет. Поэтому вход фиксера — черновик плюс
СПИСОК МЕСТ, и в промте прямо запрещено трогать что-либо ещё. Это и есть измеряемая позиция
контура; нарушение её — не оптимизация, а подмена арма.
⚠ ЧТО ДЕЛАЕТ КАНОН-ГЕЙТ. Детерминированная сверка покрытия банка после КАЖДОГО арма: он и
флагует потери для фиксера (вход A1/A4), и печатается отдельной осью результата. Политика
владельца 10.08: потеря канона = дефект безусловно; художественность — отдельная ось.
Запуск: contour.py --plan | --selftest | --run [A1B A2 A3 A4 A6F] | --floor
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"):
sys.path.insert(0, str(REPO / "eval" / d))
def _load(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
MONEY = _load("money_d", ZONE / "money_d.py")
PAN23 = _load("panel23", REPO / "eval" / "editor_tier" / "panel.py")
P22, MAT = PAN23.P22, PAN23.MAT
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`
# ставил `BUY.OUT` на каталог фазы, а грузившийся следом `panel.py` пака 23 тянул его кассу и
# перебивал обратно на `~/books/editor-tier`. Итог был бесшумным и дорогим: кэш-чек, замок и
# `finally` смотрели в каталог фазы, а РЕЗУЛЬТАТ писался в каталог чужого пака. Семь оплаченных
# клеток ушли туда, касса фазы показывала $0, а цикл покупки 20 минут ждал появления файла,
# который в её каталоге не появился бы никогда. Найдено многоагентным разбирательством 10.08.
MONEY.M22.configure(ZONE=ZONE.parent / "dovodka", OUT=MONEY.OUT, TAGS=MONEY.TAGS,
CEILINGS=MONEY.CEILINGS, PACK_CEILING=MONEY.PACK_CEILING,
GLOB_ALL="dv-*.json", ROSTER=ROSTER)
if MONEY.M22.BUY.OUT != MONEY.OUT: # ассерт, а не надежда
raise SystemExit(f"⛔ buy.OUT={MONEY.M22.BUY.OUT} против кассы {MONEY.OUT}"
"покупки уедут в чужой каталог, СТОП")
OUT = MONEY.OUT
ANCHOR = "deepseek-v4-flash" # якорный черновик
FIXER = "deepseek-v4-pro" # фиксер и критик — боевой редакторский движок
DRAFT2 = "deepseek-v4-pro" # «качественный черновик» H-1 (арм D2 эксп-22)
ARMS = ("A1", "A1B", "A2", "A3", "A4", "A6", "A6F")
# ⚠ ДИСПЕТЧЕР ПО ВИДУ КОНТУРА, а не по имени арма. Пре-регистрация английской оси называет армы
# E0…E6, а механизмы у них те же, что у китайских A-армов. Копировать цикл покупки ради других
# букв значит копировать и будущие расхождения; поэтому имя арма отображается в ВИД, и цикл один.
ARM_KIND = {"A1": "flags", "A1B": "flags", "A6F": "flags", "A4": "canonfix",
"A2": "onepass", "A3": "critic", "A6": "critic",
# английская ось Д3 (имена — из пре-регистрации, механизмы — те же)
"E1": "flags", "E2": "onepass", "E3": "critic", "E4": "canonfix"}
def phase_of(arm: str) -> str:
"""Фаза кассы арма. Должна совпадать с буквой в `tag`, иначе трата уедет мимо потолка."""
P = pair()
if P.key == "zh-ru":
return "ФД-F" if arm.startswith("A6") else "ФД-A"
if arm == "E6":
return "ФД-G"
return {"en-ru": "ФД-B", "ja-ru": "ФД-C"}[P.key]
# ⚠ ПОЧЕМУ A1 И A1B — ДВА АРМА, А НЕ ПЕРЕ-ПОКУПКА ОДНОГО. Куплённые 30 клеток `A1` получили список
# мест ТОЛЬКО от канон-гейта: батарея падала на 32/32 и ошибку глотал голый except (эррата Э-4).
# Переименовать их значило бы разойтись с полем `tag` внутри самих записей, а править оплаченное
# сырьё нельзя. Поэтому арм, объявленный заказом («батарея + канон-гейт»), покупается под НОВЫМ
# тегом `A1B` и он же несёт H-2а в первичном семействе; `A1` остаётся как есть и объявляется
# ОПИСАТЕЛЬНЫМ армом «только канон-флаги». Побочная выгода честная: разница A1B/A1 меряет, что
# добавляет батарея к канон-гейту, и это ответ на ту же гипотезу с другой стороны.
# ⚠ ПОПРАВКА ФАЗЫ Д (11.08, после ревью 82 находок). Пре-рег Д0.5 называет A6 армом «dspro-черновик
# + ОБА контура». Один арм двух контуров нести не может: контур — это способ получить выход, и
# выходов у двух контуров два. Куплённый `A6` есть СМЫСЛОВОЙ контур; флаговый заводится отдельным
# армом `A6F` на той же базе. Эррата объявлена ДО покупки A6F, старые клетки не трогаются.
PLACES_CAP = 200 # было 40 — резало 34% находок критика (см. эрратy Д0.11)
# ⚠ ЕДИНИЦЫ, ИСКЛЮЧЁННЫЕ ИЗ ОСИ, — ПЕРЕЧИСЛЕНЫ ЗДЕСЬ С ПРИЧИНОЙ И ЦЕНОЙ.
# Исключение — не сокращение выборки решением сессии, а применение ШТАТНОГО гейта проекта, который
# на этой ветке не применялся вовсе (см. `base_draft`). Объявляется до судейства, а не после.
EXCLUDED = {
"63ab55ac5c": "якорный черновик — эхо исходника (гейт bakeoff.draft_reject_reason); "
"клетки A1/A3 на ней уже оплачены ($0.0231) и в судейскую пачку НЕ идут",
}
# ── ПАРА КАК ПАРАМЕТР ─────────────────────────────────────────────────────────────────────────
# ⚠ Заведено 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, base_draft_hook=None, base_a0_hook=None) -> None:
"""Перевести контур на пару `p`. Для zh — проверенный no-op (селфтест это стережёт)."""
global _PAIR, _UNITS, BASE_DRAFT_HOOK, BASE_A0_HOOK
BASE_DRAFT_HOOK, BASE_A0_HOOK = base_draft_hook, base_a0_hook
_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()
def units_ok() -> list[dict]:
"""Единицы, ДОПУСТИМЫЕ к покупке и судейству: без брака базы."""
return [u for u in units() if u["uid"] not in EXCLUDED]
def draft_defect(text: str) -> str:
"""Штатный гейт годности черновика проекта. Пусто = годен."""
import bakeoff as BO # noqa: PLC0415
return BO.draft_reject_reason(text or "")
# Источники баз: у zh — сырьё прошлых паков, у новой пары их даёт драйвер оси. Умолчания
# сохраняют китайское поведение побайтно (стережёт селфтест «проводка zh — no-op»).
BASE_DRAFT_HOOK = None
BASE_A0_HOOK = None
def base_draft(uid: str) -> str:
"""Якорный черновик единицы. У старых — из сырья эксп-22, у новых — из фазы B эксп-23.
⚠ ГЕЙТ ГОДНОСТИ НА ОБЕИХ ВЕТКАХ. Старая ветка `P22.draft_text` фильтрует брак сама
(`tenant_panel/panel.py:130` — `if c and not BO.draft_reject_reason(c)`), а `PAN23.draft_b`
для 16 НОВЫХ единиц не фильтрует ничего. Через эту щель в фазу Д прошла эхо-мина
`63ab55ac5c`: на ней куплены клетки армов A1 и A3 поверх черновика, который штатный гейт
проекта бракует. Здесь ветки уравнены; брак не возвращается молча, а называется вслух.
"""
if BASE_DRAFT_HOOK is not None:
return BASE_DRAFT_HOOK(uid)
t = P22.draft_text(ANCHOR, uid)
if t.strip():
return t
t = PAN23.draft_b(uid)
why = draft_defect(t)
if why:
print(f"{uid}: черновик БРАКОВАН гейтом проекта ({why}) — единица непригодна")
return ""
return t
def base_a0(uid: str) -> str:
"""Боевой full-regen над якорной базой — арм A0, куплен, $0."""
if BASE_A0_HOOK is not None:
return BASE_A0_HOOK(uid)
t = P22.edit_text(ANCHOR, uid)
return t if t.strip() else PAN23.text_b("R0", uid)
def base_draft2(uid: str) -> str:
"""«Качественный черновик» H-1: черновик dspro эксп-22. Есть только у 16 старых единиц."""
return P22.draft_text(DRAFT2, uid)
# ── флаги: детерминированные места, которые фиксер обязан починить ────────────────────────────
def canon_gaps(src: str, out: str) -> list[str]:
"""Термины банка, чья канонная форма В ВЫХОДЕ встречается реже, чем в исходнике."""
import re # noqa: PLC0415
gaps = []
for t, v in _bank().items():
n = src.count(t)
if not n:
continue
if min(len(re.findall(v["rx"], out, re.I)), n) < n:
gaps.append(f"«{v['dst']}» (в исходнике {t} встречается {n} раз)")
return gaps
# ⚠ ПОЛЯ БАТАРЕИ, КОТОРЫЕ ОЗНАЧАЮТ НАРУШЕНИЕ, — ПЕРЕЧИСЛЕНЫ ЯВНО, И ЭТО НЕ ПЕДАНТИЗМ.
# Первая редакция брала из `run_unit` всё, что `isinstance(v, (int, float)) and v`. Дефект был
# двойной и оба слоя молчали: (1) на вход шёл `dict`, а не `battery.Unit`, и `run_unit` падал
# `AttributeError: 'dict' object has no attribute 'final'` на 32/32 единицах — ошибку глотал голый
# `except`, так что ВСЕ 30 оплаченных клеток A1 куплены на одном канон-гейте, хотя арм объявлен
# «батарея + канон-гейт»; (2) значения проверок — СЛОВАРИ, и наивный фильтр, будь он даже
# достижим, вымел бы в список мест ЗНАМЕНАТЕЛИ: «проверка sentences дала 90», «words дала 1154».
# То есть починка одного типа сделала бы арм хуже, чем сломанный. Поэтому whitelist, а не эвристика.
# ⚠⚠ ВТОРАЯ ПОЧИНКА (11.08, после того как 14 клеток A1B из 31 вернули ПУСТОТУ на потолке вывода,
# $0.170 впустую). Первая редакция whitelist'а брала поля-СЧЁТЧИКИ, и два из них были ядовиты:
#
# * `palladius.nonconformant_bank` — это счёт строк САМОГО БАНКА, не прошедших палладиевскую
# проверку (`battery.py:159` — `for d in bank_dst`). К переводу он не относится ВООБЩЕ: число
# одинаково во всех 32 единицах, потому что банк один. Фиксеру говорилось «имена/термины
# переданы мимо банка (8 шт.)», и он искал в тексте то, чего в тексте нет. Рассуждение
# выросло вшестеро против A1 (40 694 знака против 7 400) и съело весь бюджет вывода.
# * `ty_vy.mixed_in_unit` — адреса не имеет и сам докстринг проверки (`battery.py:365-370`)
# называет её «сигналом к просмотру, а не счётом ошибок»: смешение между РАЗНЫМИ
# собеседниками легитимно. Инструкцией фиксеру такое быть не может.
#
# НОРМА, ЗАВЕДЁННАЯ ЭТИМ: **флаг без адреса — не место.** Контракт арма — «черновик плюс СПИСОК
# МЕСТ»; поле, которое не может назвать, ЧТО чинить, в список не идёт и остаётся описательным.
# Поэтому ниже у каждого поля указано, откуда берётся адрес, и поля без адреса исключены.
BATTERY_VIOLATIONS = {
# поле-признак поле-адрес (None = адрес находится по самому признаку)
"palladius": (("nonconformant_text", "nonconformant_text_words"),),
"cjk_leak": (("over_prod_threshold", None),),
"typography": (("ascii_quotes", None), ("hyphen_as_dash", None)),
"numbers": (("lost_n", "lost"), ("invented_n", "invented")),
"gender": (("mismatched", "cases"),),
}
BATTERY_SAYS = {
"palladius.nonconformant_text": "не-палладиевская транскрипция имени: {a}",
"cjk_leak.over_prod_threshold": "в переводе остались иероглифы исходника: {a}",
"typography.ascii_quotes": "прямые кавычки вместо «ёлочек», {v} шт.",
"typography.hyphen_as_dash": "дефис вместо тире, {v} шт.",
"numbers.lost_n": "величины исходника отсутствуют в переводе: {a}",
"numbers.invented_n": "в переводе есть величины, которых нет в исходнике: {a}",
"gender.mismatched": "род персонажа расходится с карточкой: {a}",
}
# ОПИСАТЕЛЬНЫЕ (печатаются как ось результата, фиксеру НЕ передаются — адреса нет):
# ty_vy.mixed_in_unit · palladius.nonconformant_bank · typography.violations (агрегат)
def battery_flags(u: dict, draft: str) -> list[str]:
"""Нарушения батареи НА ТЕКСТЕ, каждое с адресом. Падение = дефект, НЕ глотаем."""
import battery as B # noqa: PLC0415
import re # noqa: PLC0415
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)])
r = B.run_unit(unit)
out = []
for check, fields in BATTERY_VIOLATIONS.items():
val = r.get(check)
if not isinstance(val, dict):
continue
for sig, addr_field in fields:
v = val.get(sig)
if not (isinstance(v, (int, float, bool)) and v):
continue
if addr_field:
addr = val.get(addr_field) or []
elif check == "cjk_leak":
addr = sorted(set(pair().source_script.findall(draft)))[:12]
else:
addr = []
if addr_field and not addr: # признак есть, адреса нет — не место
continue
says = BATTERY_SAYS[f"{check}.{sig}"]
out.append("БАТАРЕЯ: " + says.format(
v=int(v), a="; ".join(f"«{x}»" for x in addr[:12])))
return out
def flags_of(u: dict, draft: str) -> list[str]:
"""Список мест для A1. Только ДЕТЕРМИНИРОВАННОЕ: канон-гейт + батарея, без судьи и без LLM."""
out = [f"КАНОН: термин {g} передан не канонной формой либо пропущен"
for g in canon_gaps(u["source"], draft)]
return out + battery_flags(u, draft)
# ── промты армов: данные, не код ──────────────────────────────────────────────────────────────
FIX_HEAD = (
"Ты — редактор-корректор. Тебе дан ИСХОДНИК, его ЧЕРНОВОЙ ПЕРЕВОД и СПИСОК МЕСТ, где перевод "
"неверен.\n\n"
"Почини ТОЛЬКО перечисленные места. Всё остальное оставь ПОБАЙТНО как есть: не переписывай "
"фразы, не улучшай стиль, не меняй вёрстку, не трогай пунктуацию там, где о ней не сказано. "
"Если место починить нельзя, не трогая соседнего текста, — почини минимально возможной "
"правкой.\n\nВыведи ТОЛЬКО полный текст перевода после правок, без преамбул и комментариев.")
CRIT_HEAD = (
"Ты — литературный редактор-эксперт. Тебе дан ИСХОДНИК и его ЧЕРНОВОЙ ПЕРЕВОД.\n\n"
"ПРОЧИТАЙ оба текста и найди ВСЁ проблемное в переводе: искажения и потери смысла, неверно "
"понятые реалии, кальки, канцелярит, несуществующие слова, рассогласование, сбитую вёрстку "
"реплик, непоследовательную передачу имён и терминов.\n\n"
"Выведи СПИСОК МЕСТ, по одному в строке, в формате:\n"
"- <цитата из перевода> → <в чём ошибка>\n\n"
"Только список. Не переписывай текст, не предлагай готовый перевод целиком.")
def fix_msgs(src: str, draft: str, places: list[str]) -> list[dict]:
# ⚠ `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)
return [{"role": "system", "content": s.strip()}, {"role": "user", "content": u.strip()}]
def crit_msgs(src: str, draft: str) -> list[dict]:
# Критику банк-закон нужен не меньше фиксера: без него он назовёт «непоследовательным» то,
# что канону как раз отвечает, и наоборот. Банк-закон D39.104 — на ВСЕХ армах, без изъятий.
# ⚠ `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}"}]
# ── A2: однопроходка УРАВНЕННОГО мандата (строка 153 бэклога, долг с эксп-21) ─────────────────
# ⚠ ЗАЧЕМ УРАВНИВАТЬ. Контраст «однопроходка против связки» в эксп-21 был загрязнён: у арма без
# редактора системный промт вдвое короче (×1.92), и замеренная разница могла быть разницей ОБЪЁМА
# ИНСТРУКЦИИ, а не топологии. Строка 153 бэклога держит этот долг с тех пор и здесь закрывается:
# A2 получает СУММУ мандатов боевой связки — промт переводчика ПЛЮС мандатные части промта
# редактора — в ОДИН вызов. Тогда `A2/A0` меряет топологию, а не количество указаний.
#
# ⚠ ПРЕЖНЯЯ РЕДАКЦИЯ ЭТОГО МЕСТА НЕ РАБОТАЛА ВОВСЕ: `PR.translator_msgs(src)` звался с одним
# аргументом при сигнатуре `(src, block, en=False)` → TypeError, а сторож `hasattr` проверяет
# существование имени, а не арность, поэтому ветка «арм пропущен, объявить в отчёте» была
# НЕДОСТИЖИМА. Арм не куплен ни разу; вдобавок `block=None` снял бы банк-закон D39.104, который
# заказ требует на ВСЕХ армах. Оба дефекта закрыты здесь.
#
# Преобразование промта редактора — ДАННЫЕ, не код: таблицы ниже читаются глазами и сторожатся
# гейтом `--selftest`. Строки, предполагающие наличие черновика, переписываются, а не выбрасываются:
# выбросить их значило бы снять мандат, ради уравнивания которого арм и заводится.
A2_DROP = (
"Ты — литературный редактор", # роль уже задана промтом переводчика
"Жанр книги:", # дубль строки переводчика
"Хонорифики:", # дубль строки переводчика
"Выведи ТОЛЬКО отредактированный", # у переводчика своя строка вывода
)
A2_SUBST = (
("Твой мандат — художественная редактура черновика со сверкой по исходнику:",
"Отдельного редактора после тебя НЕ БУДЕТ, поэтому мандат редактуры входит в твой:"),
("Сверяй смысл черновика с исходным текстом", "Сверяй смысл перевода с исходным текстом"),
("Сохраняй ПОЛНОТУ черновика", "Сохраняй ПОЛНОТУ исходника"),
("редактуры черновика", "редактуры перевода"),
)
def a2_mandate() -> str:
"""Мандатная часть боевого редактора, переписанная под однопроходку. $0, детерминирована."""
core, _few, _user = PR.load_template(PR.BATTLE / "editor.md")
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):
continue
for a, b in A2_SUBST:
ln = ln.replace(a, b)
out.append(ln)
return "\n".join(out).strip()
def a2_msgs(src: str) -> list[dict]:
"""Однопроходка: перевод исходника ОДНИМ вызовом, мандат уравнен со связкой, банк-закон на месте."""
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 префикс пустой — теги уже купленных клеток
# обязаны остаться прежними.
# Буква в теге — ФАЗА КАССЫ, а не пара: глоб `dv-b-*` покрывает ФД-B, `dv-a-*` — ФД-A.
# Пар-префикс, стоявший здесь до 14.08, давал `dv-en-…`, который не покрыт ни одним глобом,
# и покупка отверглась бы гейтом кассы — лучше так, чем мимо потолка, но работать нельзя.
P = pair()
if P.key == "zh-ru":
ph = "f" if arm.startswith("A6") else "a"
else:
ph = "g" if arm == "E6" else {"en-ru": "b", "ja-ru": "c"}[P.key]
return f"dv-{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}"
def text_of(arm: str, uid: str) -> str:
f = OUT / f"{tag(arm, uid)}.json"
if f.exists():
return json.loads(f.read_text(encoding="utf-8")).get("content") or ""
return ""
def cmd_plan() -> None:
us = units()
old = {u["uid"] for u in P22.M.units_zh()}
print(f"единиц {len(us)} (старых {len(old)} + новых {len(us) - len(old)})")
have = {a: sum(1 for u in us if text_of(a, u["uid"]).strip()) for a in ARMS}
# ⚠ Печатать «куплено N из M» без второй колонки — значит сертифицировать полноту арма его
# же счётчиком: клетка, записанная БЕСПЛАТНО (мест не нашлось), в первой колонке неотличима
# от оплаченной, а именно на этой неразличимости прошлая редакция объявила A1 «30/30 платных».
for a in ARMS:
n = len(old) if a.startswith("A6") else len(us)
free = sum(1 for u in us
if (OUT / f"{tag(a, u['uid'])}.json").exists()
and json.loads((OUT / f"{tag(a, u['uid'])}.json").read_text()).get("free"))
print(f" {a}: клеток {have[a]:2d} из {n}" + (f" (из них БЕСПЛАТНЫХ {free})" if free else ""))
flo = sum(1 for u in P22.M.units_zh() for h in ("flo1", "flo2")
if (OUT / f"dv-a-{h}-{u['uid']}.json").exists())
print(f" свой шумовой пол: клеток {flo} из {2 * len(old)} (ПАРАМИ, обе половины — "
"отдельные генерации)")
base = sum(1 for u in us if base_draft(u["uid"]).strip())
a0 = sum(1 for u in us if base_a0(u["uid"]).strip())
d2 = sum(1 for u in us if base_draft2(u["uid"]).strip())
print(f"базы (уже куплены, $0): черновик {base}/{len(us)} · A0 {a0}/{len(us)} · "
f"черновик-dspro {d2}/{len(old)}")
MONEY.report()
def cmd_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 ""))
us = units_ok()
ck("единиц ровно 32 минус исключённые", len(us) == 32 - len(EXCLUDED),
f"{len(us)} из 32; исключено {len(EXCLUDED)}")
for uid, why in EXCLUDED.items():
print(f" ⚠ ИСКЛЮЧЕНА {uid}: {why}")
ck("uid уникальны", len({u["uid"] for u in us}) == len(us))
miss = [u["uid"] for u in us if not base_draft(u["uid"]).strip()]
ck("якорный черновик есть у всех ДОПУСТИМЫХ", not miss, f"нет у {miss[:3]}")
miss0 = [u["uid"] for u in us if not base_a0(u["uid"]).strip()]
ck("арм A0 (боевой перепис) есть у всех", not miss0, f"нет у {miss0[:3]}")
old = P22.M.units_zh()
miss2 = [u["uid"] for u in old if not base_draft2(u["uid"]).strip()]
ck("черновик-dspro для H-1 есть у всех 16 старых", not miss2, f"нет у {miss2[:3]}")
# ИЗОЛЯЦИЯ: у A1 и A3 вход ОДИН И ТОТ ЖЕ черновик; различаться обязан только список мест.
# ⚠ Прежняя редакция сверяла `fix_msgs(...)` САМА С СОБОЙ на одном и том же черновике —
# тавтология, которая не могла провалиться и потому ничего не сторожила. Теперь входы армов
# берутся ТЕМ ЖЕ кодом, что и в бою (`cmd_run`), и сверяются между собой.
u = us[0]
d = base_draft(u["uid"])
m1 = fix_msgs(u["source"], d, flags_of(u, d) or ["КАНОН: тест"])
m3 = fix_msgs(u["source"], d, critic_places("A3", u["uid"])[0] or ["СМЫСЛ: тест"])
ck("A1 и A3 делят вход, различаясь только списком мест",
m1[0]["content"] == m3[0]["content"] and m1[1]["content"] != m3[1]["content"])
ck("вход A4 — это A0, а не черновик (позиция «ПОСЛЕ редактора»)",
base_a0(u["uid"]).strip() != d.strip())
ck("в промте фиксера стоит запрет трогать остальное",
"ПОБАЙТНО как есть" in m1[0]["content"])
# ⚠ Банк-закон D39.104 обязателен на ВСЕХ армах с переводом/редактурой. Проверяется ДОСЛОВНО
# по заголовку блока, а не по слову «закон»: эксп-22 отступил от него на 72 en-клетках.
ck("банк-закон D39.104 в промте фиксера есть",
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in m1[0]["content"], m1[0]["content"][-90:].replace("\n", " "))
ck("банк-закон есть и в промте КРИТИКА — иначе он флагует канон не по банку",
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in crit_msgs(u["source"], d)[0]["content"])
# ⚠ БАТАРЕЯ. Прежняя проверка `isinstance(fl, list)` не могла быть ложной: `flags_of` заводит
# `out = []` и имеет единственный выход `return out`. Она и пропустила смерть батареи на 32/32.
bf = battery_flags(u, d)
ck("батарея ЖИВА (падение = дефект, а не пустой список)", isinstance(bf, list))
tot_b = sum(len(battery_flags(x, base_draft(x["uid"]))) for x in us if base_draft(x["uid"]))
ck("батарея вносит места в флаговый контур, а не молчит", tot_b > 0, f"нарушений {tot_b}")
ck("в места батареи не попали ЗНАМЕНАТЕЛИ (sentences/words/lines)",
not any(w in f for f in bf for w in ("sentences", "words", "lines", "candidates")),
"; ".join(bf[:2]))
# ⚠ ФЛАГ БЕЗ АДРЕСА — НЕ МЕСТО. Норма заведена после того, как безадресный флаг (и притом
# относящийся к БАНКУ, а не к тексту) отправил фиксера в неограниченный перебор: 14 клеток
# A1B из 31 упёрлись в потолок вывода и вернули пустоту, $0.170 впустую. Гейт требует, чтобы
# каждое место батареи несло либо цитату в «ёлочках», либо счёт находимого по признаку класса.
allb = [f for x in us if base_draft(x["uid"]).strip()
for f in battery_flags(x, base_draft(x["uid"]))]
noaddr = [f for f in allb if "«" not in f and " шт." not in f]
ck("каждое место батареи несёт АДРЕС (цитату либо находимый класс)",
not noaddr, f"без адреса {len(noaddr)}: {noaddr[:2]}")
ck("батарея не флагует свойства БАНКА вместо свойств текста",
not any("мимо банка" in f for f in allb))
# ⚠ A2: арм, которого не существовало — вызов падал TypeError, ветка отказа была недостижима.
m2 = a2_msgs(u["source"])
ck("A2 собирается (однопроходка уравненного мандата)", len(m2) >= 2)
ck("банк-закон D39.104 есть и в A2", any("КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in x["content"] for x in m2))
man = a2_mandate()
ck("мандат A2 несёт дискурс-перевёрстку (иначе мандат НЕ уравнен)",
"ДИСКУРС-ПЕРЕВЁРСТКА" in man)
ck("мандат A2 несёт правило чэнъюй (смысловые атомы)", "чэнъюй" in man)
ck("в мандате A2 не осталось слова «черновик» — редактировать нечего, он переводит",
"черновик" not in man.lower(), man[:120].replace("\n", " "))
ck("в промте A2 не осталось нераскрытых плейсхолдеров",
not any("{{" in x["content"] for x in m2))
ck("A2 длиннее голой однопроходки — мандат реально добавлен",
len(m2[0]["content"]) > len(PR.translator_msgs(u["source"], None)[0]["content"]))
# теги армов не пересекаются между собой и с чужими паками
tags = {tag(a, u["uid"]) for a in ARMS}
ck("теги армов различны", len(tags) == len(ARMS), f"{len(tags)} против {len(ARMS)}")
ck("теги покрыты глобом кассы", all(t.startswith(("dv-a-", "dv-f-")) for t in tags))
ck("тег половины пола покрыт глобом кассы", f"dv-a-flo1-{u['uid']}".startswith("dv-a-"))
ck("обрезка мест не режет ответ критика ни на одной единице",
PLACES_CAP >= max((critic_places("A3", x["uid"])[1] for x in us), default=0),
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}'}")
return bad
def _buy(led, tg: str, msgs: list[dict], **extra) -> dict:
"""Покупка, у которой отказ гарда есть СТОП, а не пропуск.
⚠ Прежняя редакция выбрасывала возврат `MONEY.purchase`. Когда проекционный гард отказывал,
`buy.purchase` возвращал `skipped=True` и НЕ писал файла, а цикл спокойно шёл дальше — по
остальным единицам и по следующим армам с их собственными потолками. Так арм A4 оказался
куплен на 22 единицах из 32, и прогон завершился кодом 0, будто всё в порядке. Объявленная
норма фазы (`money_d.py` шапка, пре-рег Д0.8) звучит иначе: **срабатывание гарда = СТОП и
вопрос владельцу**. Здесь она наконец механизирована.
"""
rec = MONEY.purchase(led, tg, FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, msgs), **extra)
if rec.get("skipped"):
raise SystemExit(
f"⛔ ГАРД ОТКАЗАЛ на клетке {tg} — прогон ОСТАНОВЛЕН.\n"
f" Резать ось или поднимать потолок решением сессии ЗАПРЕЩЕНО заказом.\n"
f" Это СТОП и вопрос владельцу: назвать недостающую сумму и ждать санкции.")
return rec
def _free_cell(tg: str, text: str, **extra) -> None:
"""Клетка арма, полученная БЕЗ вызова модели, — записывается с нулевой ценой.
⚠ Зачем вообще. Если детерминированный контур не нашёл ни одного места, ответ арма на этой
единице — исходный текст БЕЗ ПРАВОК, и это законный выход арма, а не отсутствие данных.
Прежняя редакция такую единицу молча выбрасывала (`continue`), из-за чего A1 приходил на
судейство с n=30 против n=32 у A3: контрасты переставали быть парными, а из выборки выпадали
ровно те единицы, где контур не даёт ничего, — то есть смещение шло в пользу арма.
"""
p = OUT / f"{tg}.json"
if p.exists():
return
rec = dict(tag=tg, model="(без вызова)", finish="stop", content=text, cost_usd=0.0,
latency_s=0.0, free=True, **extra)
tmp = OUT / f"{tg}.json.tmp"
tmp.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(p)
print(f" {tg}: мест нет — выход арма = вход, клетка записана БЕСПЛАТНО (n сохранён)")
def critic_places(arm: str, uid: str) -> tuple[list[str], int]:
"""Места из ответа критика: (что уйдёт фиксеру, сколько нашлось всего)."""
cf = OUT / f"{tag(arm, uid, 'crit')}.json"
if not cf.exists():
return [], 0
found = json.loads(cf.read_text(encoding="utf-8")).get("content") or ""
pl = [ln.strip("- ").strip() for ln in found.splitlines() if ln.strip().startswith("-")]
return pl[:PLACES_CAP], len(pl)
def cmd_run(which: list[str]) -> None:
us = units_ok()
old = {u["uid"] for u in P22.M.units_zh()}
for arm in which:
led = MONEY.Guarded(phase_of(arm))
kind = ARM_KIND[arm]
for u in us:
uid, src = u["uid"], u["source"]
if arm.startswith("A6") and uid not in old:
continue
if text_of(arm, uid).strip():
continue
draft = base_draft2(uid) if arm.startswith("A6") else base_draft(uid)
if not draft.strip():
print(f" {uid}: базы нет — пропуск")
continue
if kind == "onepass": # однопроходка уравненного мандата
_buy(led, tag(arm, uid), a2_msgs(src), role="onepass", uid=uid, arm=arm)
continue
if kind in ("flags", "canonfix"): # детерминированные контуры
target = base_a0(uid) if kind == "canonfix" else draft
places = ([f"КАНОН: термин {g} передан не канонной формой либо пропущен"
for g in canon_gaps(src, target)] if kind == "canonfix"
else flags_of(u, target))
if not places:
_free_cell(tag(arm, uid), target, role="fixer", uid=uid, arm=arm, places=0)
continue
_buy(led, tag(arm, uid), fix_msgs(src, target, places),
role="fixer", uid=uid, arm=arm, places=len(places))
continue
# A3 и A6: смысловой критик, затем фиксер по его списку
ct = tag(arm, uid, "crit")
if not (OUT / f"{ct}.json").exists():
_buy(led, ct, crit_msgs(src, draft), role="critic", uid=uid, arm=arm)
if not (OUT / f"{ct}.json").exists(): # оплачено, но файла нет
print(f" {uid}/{arm}: критик не отдал файл — арм пропущен, объявить в отчёте")
continue
places, found_n = critic_places(arm, uid)
if not places:
_free_cell(tag(arm, uid), draft, role="fixer", uid=uid, arm=arm,
places=0, places_found=0)
continue
_buy(led, tag(arm, uid), fix_msgs(src, draft, places),
role="fixer", uid=uid, arm=arm, places=len(places), places_found=found_n)
MONEY.report()
def cmd_floor() -> None:
"""СВОЙ шумовой пол оси Д4 — ПАРОЙ независимых генераций одного и того же лечения.
⚠ Режим был объявлен в шапке файла и НЕ СУЩЕСТВОВАЛ: разбор аргументов знал только
`--selftest` и `--run`, всё прочее молча уходило в `--plan` с кодом 0. При этом ВСЯ несущесть
оси Д4 держится на ЗАИМСТВОВАННОМ прайоре sd 2.12 с запасом около 1%: при sd 2.13 ось уже
описательная. Пол — не украшение, а то, что этот прайор проверяет.
Устройство пола отвечает норме Д0.6, купленной дорого: обе половины — ОТДЕЛЬНЫЕ генерации
одного лечения (фиксер по одному и тому же списку мест над одной и той же базой), поэтому
судейская разница между ними есть ЧИСТЫЙ шум. Боевая клетка половиной пола не берётся — в
эксп-23 `CTRLfloorA` совпал с армом `T1` побайтно в 16/16, и контроль оказался пуст.
Половины судят РАЗНЫЕ сессии (это забота судейского рига, здесь — только генерация).
"""
led = MONEY.Guarded("ФД-A")
old = [u for u in P22.M.units_zh() if u["uid"] not in EXCLUDED]
for u in old:
uid, src = u["uid"], u["source"]
draft = base_draft(uid)
if not draft.strip():
continue
places, _ = critic_places("A3", uid)
if not places:
print(f" {uid}: мест нет — единица в пол не идёт")
continue
for half in ("flo1", "flo2"):
tg = f"dv-a-{half}-{uid}"
if (OUT / f"{tg}.json").exists():
continue
_buy(led, tg, fix_msgs(src, draft, places),
role="floor", uid=uid, arm=half, places=len(places))
MONEY.report()
if __name__ == "__main__":
a = sys.argv[1:] or ["--plan"]
if a[0] == "--selftest":
sys.exit(1 if cmd_selftest() else 0)
elif a[0] == "--run":
cmd_run([x for x in a[1:] if x in ARMS] or list(ARMS))
elif a[0] == "--floor":
cmd_floor()
elif a[0] == "--plan":
cmd_plan()
else:
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --plan --selftest --run --floor")