textmachine/eval/dovodka/contour.py

563 lines
40 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")
# ⚠⚠ КАССУ ПЕРЕ-НАСТРАИВАЕМ ПОСЛЕ ВСЕХ ЧУЖИХ ЗАГРУЗОК, И ЭТО НЕ ПЕДАНТИЗМ.
# Модуль `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")
# ⚠ ПОЧЕМУ 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) и в судейскую пачку НЕ идут",
}
# ── единицы: 16 эксп-22 + 16 эксп-23, база у каждой своя и уже куплена ────────────────────────
def units() -> list[dict]:
"""ВСЕ единицы среза, включая исключённые, — для учёта и кассы."""
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 "")
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 поверх черновика, который штатный гейт
проекта бракует. Здесь ветки уравнены; брак не возвращается молча, а называется вслух.
"""
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."""
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.PR.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, а не эвристика.
BATTERY_VIOLATIONS = {
"palladius": ("nonconformant_bank",),
"cjk_leak": ("over_probe_threshold", "over_prod_threshold"),
"typography": ("violations", "ascii_quotes", "hyphen_as_dash"),
"numbers": ("lost_n", "invented_n"),
"ty_vy": ("mixed_in_unit",),
"gender": ("mismatched",),
}
BATTERY_SAYS = {
"palladius.nonconformant_bank": "имена/термины переданы мимо банка ({v} шт.)",
"cjk_leak.over_probe_threshold": "в переводе остались иероглифы исходника",
"cjk_leak.over_prod_threshold": "в переводе остались иероглифы исходника (сверх прод-порога)",
"typography.violations": "нарушения типографики ({v} шт.)",
"typography.ascii_quotes": "прямые кавычки вместо «ёлочек» ({v} шт.)",
"typography.hyphen_as_dash": "дефис вместо тире ({v} шт.)",
"numbers.lost_n": "числа исходника потеряны в переводе ({v} шт.)",
"numbers.invented_n": "в переводе появились числа, которых нет в исходнике ({v} шт.)",
"ty_vy.mixed_in_unit": "внутри единицы смешаны «ты» и «вы»",
"gender.mismatched": "род персонажа расходится с карточкой ({v} шт.)",
}
def battery_flags(u: dict, draft: str) -> list[str]:
"""Нарушения батареи на тексте. Пустой список — законный ответ; ИСКЛЮЧЕНИЕ — не законный."""
import battery as B # noqa: PLC0415
bank = BANK.PR.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 f in fields:
v = val.get(f)
if isinstance(v, (int, float, bool)) and v:
out.append("БАТАРЕЯ: " + BATTERY_SAYS[f"{check}.{f}"].format(v=int(v)))
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 = PR.law_block(PR.terms_in(src))
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 = PR.law_block(PR.terms_in(src))
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, PR.BRIEF_ZH, "", "")
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]:
"""Однопроходка: перевод исходника ОДНИМ вызовом, мандат уравнен со связкой, банк-закон на месте."""
m = PR.translator_msgs(src, PR.law_block(PR.terms_in(src)))
m[0]["content"] = m[0]["content"] + "\n\n" + a2_mandate()
return m
def tag(arm: str, uid: str, part: str = "") -> str:
ph = "f" if arm.startswith("A6") else "a"
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]))
# ⚠ 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)}")
# ⚠ Главный ассерт этого харнесса: кэш-чек и ЗАПИСЬ результата смотрят в ОДИН каталог.
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("ФД-F" if arm.startswith("A6") else "ФД-A")
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 arm == "A2": # однопроходка уравненного мандата
_buy(led, tag(arm, uid), a2_msgs(src), role="onepass", uid=uid, arm=arm)
continue
if arm in ("A1", "A1B", "A4", "A6F"): # детерминированные контуры
target = base_a0(uid) if arm == "A4" else draft
places = ([f"КАНОН: термин {g} передан не канонной формой либо пропущен"
for g in canon_gaps(src, target)] if arm == "A4"
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")