Freeze phase D pre-reg erratum and harness repair: A2 arm built, battery wired with violation whitelist, per-contrast power with Student quantile, guard is a real stop

This commit is contained in:
heaven 2026-08-11 03:18:37 +03:00
parent 6594d3bd28
commit 8950352fa2
6 changed files with 761 additions and 79 deletions

View file

@ -894,3 +894,88 @@ A6 dspro-ЧЕРНОВИК + оба контура ← носител
Топология «черновик → редактор» (решена эксп-21) · банк как фактор · выбор жильца черновой роли Топология «черновик → редактор» (решена эксп-21) · банк как фактор · выбор жильца черновой роли
(решён эксп-22) · качество прозы `gemini` как продуктовый выбор — при $221 за книгу против $8 у (решён эксп-22) · качество прозы `gemini` как продуктовый выбор — при $221 за книгу против $8 у
боевого он невозможен ни при каком исходе, меряется только гипотеза «аутлаер прозы» эксп-04. боевого он невозможен ни при каком исходе, меряется только гипотеза «аутлаер прозы» эксп-04.
### Д0.11 ЭРРАТА ПРЕ-РЕГА (11.08, ДО докупок; история не переписывается)
Ревью 82 находок двумя направлениями плюс опровергатель на каждую находку (67 выжили) вскрыло, что
зафризенный Д0 расходится с кодом и что три арма куплены не теми, какими объявлены. Ниже — каждое
расхождение и его диспозиция. **Ни одно число выше НЕ правится задним числом**; правится код, а
таблицы Д0.3/Д0.5 читать через эту эррату.
**Э-1. Таблица Д0.3 напечатана ДО починки D-1 и коду не соответствует.** В отчёте `Д4 k=5 MDE 1.29`
и «Холм по пяти» (:805, :810); `power.py` держит `k=3` (первичное семейство по одному контрасту на
гипотезу). Ни одна версия кода числа 1.29 не даёт: это множитель Холма БЕЗ поправки ×1.23.
Действительна раскладка кода, а не таблицы отчёта.
**Э-2. ⛔ MDE считался по ОСИ при плановом n, а контрасты живут на РАЗНЫХ n.** `A6/A0` — носитель
H-1 — стоит на 16 единицах по построению: черновик dspro эксп-22 есть только у 16 старых единиц.
Мощность на него никто не считал. Это дословный класс провала en-оси эксп-22, ради недопущения
которого написан `power.py`. Введена поконтрастная таблица; статус объявляется по контрасту.
**Э-3. ⛔ Множитель мощности брал квантиль НОРМАЛИ, тогда как sd — ОЦЕНКА** (пол прошлого пака,
n=16, df=15). При честном t всё меняется: ось Д4 даёт MDE 1.64 против цели 1.50, а все три
первичных контраста — 1.67 · 1.67 · 2.32. **При заимствованном прайоре sd 2.12 ось Д4 ОПИСАТЕЛЬНАЯ.**
Несущей её делает только СВОЙ пол: при sd ≤ 1.91 контрасты `A1/A0` и `A3/A0` становятся несущими
(замеренный пол `tier` эксп-23 — 1.45, то есть шанс реален). `A6/A0` требует sd ≤ 1.37 и при n=16
не спасается ничем — H-1 остаётся описательной, пока база dspro не расширена на новые единицы.
**Порядок покупок изменён: СВОЙ ПОЛ ПЕРВЫМ.** Покупать армы, не зная пола, значит рисковать всей
сметой оси, которая может не дать вывода ни при каком исходе. Объявлено до покупки пола.
**Э-4. Арм A1 куплен БЕЗ батареи** (30 клеток, $0.2269). `battery.run_unit` получал `dict` вместо
`battery.Unit` и падал `AttributeError` на 32/32, ошибку глотал голый `except`. Флаги A1 = 100%
канон-гейт при объявленном «батарея + канон-гейт». Починено; при живой батарее мест 181 против 74.
⚠ Наивная починка была бы ХУЖЕ поломки: значения проверок — словари, и прежний фильтр
`isinstance(v,(int,float))` вымел бы в список мест ЗНАМЕНАТЕЛИ («проверка sentences дала 90»).
Введён явный whitelist полей-нарушений. **Старые 30 клеток пере-покупаются.**
**Э-5. Арм A2 не существовал.** `PR.translator_msgs(src)` звался с одним аргументом при сигнатуре
`(src, block, en=False)` → TypeError; сторож `hasattr` проверяет имя, а не арность, поэтому ветка
«арм пропущен, объявить в отчёте» была НЕДОСТИЖИМА. Вдобавок `block=None` снял бы банк-закон
D39.104. Собран уравненный мандат (строка 153 бэклога): промт переводчика ПЛЮС мандатные части
промта редактора, включая дискурс-перевёрстку и правило чэнъюй, плюс банк-закон.
**Э-6. «A6 = оба контура» — один арм двух контуров нести не может.** Куплённый `A6` есть СМЫСЛОВОЙ
контур; флаговый заводится армом `A6F` на той же базе.
**Э-7. `places[:40]` молча резал список критика.** A3 потерял 512 замечаний из 1491 (34%), A6 — 374
из 886; резался КОНЕЦ главы, не случайная выборка. Кап поднят до 200 (максимум найденного — 150),
в запись клетки добавлено поле `places_found` рядом с `places`.
**Э-8. Единица `63ab55ac5c` исключена из оси.** Её якорный черновик — эхо исходника; штатный гейт
проекта `bakeoff.draft_reject_reason` его бракует, но ветка новых 16 единиц (`PAN23.draft_b`) гейт
не применяла вовсе. Клетки A1/A3 на ней оплачены ($0.0231) и в судейскую пачку НЕ идут. n оси 31.
**Э-9. Две клетки A4 с `finish=length`** (41599f30df, f6da9f76b2) недопустимы в пачку по норме Д0.6
и пере-покупаются. ⚠ Поправка к промежуточному чтению: они же фабриковали 12 из 13 «новых
канон-потерь» арма A4 — без них A4 чинит 19 и заводит 1.
**Э-10. Проекционный гард не был СТОПом.** При отказе `buy.purchase` возвращает `skipped=True` и
файла не пишет, а `contour.py` возврат выбрасывал: цикл шёл дальше по единицам И по следующим
армам, прогон завершался кодом 0. Так A4 остался на 22 клетках из 32, а объявленная норма
«срабатывание гарда = СТОП и вопрос владельцу» существовала только в прозе. Механизирована.
**Э-11. Клетка «мест не нашлось» больше не выбрасывается.** Прежде единица молча выпадала, и A1
приходил на судейство с n=30 против 32 у A3 — контрасты переставали быть парными, причём выпадали
ровно те единицы, где контур не даёт ничего, то есть смещение шло В ПОЛЬЗУ арма. Теперь выход арма
на такой единице = его вход, клетка пишется с нулевой ценой и полем `free`.
**Э-12. Режим `--floor` был объявлен в шапке и не существовал** (разбор аргументов знал только
`--selftest` и `--run`, прочее молча уходило в `--plan` с кодом 0). Реализован парой независимых
генераций одного лечения на единицу, как требует норма Д0.6.
**Э-13. Смета промахнулась структурно.** `plan.py` кладёт фиксера в 0.60× переписа — замерено 2.41×;
критика в 2.14× — замерено 0.99× (он выдаёт короткий список, а не текст; множитель 2.14 из заказа
описывал стоимость ПОИСКА как задачи, а не длину ответа). Потолок фазы поднят владельцем 11.08 до
**$5.40**. ⚠ Резерв при этом $0.00, и это объявлено риском ВТОРОЙ раз: честный остаток $4.277 плюс
потраченные $1.140 дают $5.417. Д1 добирает **15 клеток gemini из 16** именно поэтому, а пре-рег
Д0.3 говорит, что несущей ось Д1 делают полные 16.
**Э-14. Гейт чужого пака `verify22.py` красный** (EXIT=1) — в хендоффе значился зелёным. Причина не
в отчёте: число «верхняя граница двойной оплаты» выводится из mtime файлов, а дерево `~/books`
пере-штамповано, поэтому граница считается нулевой. Чужая зона: не правлю, объявляю пингом.
**Что ревью ОПРОВЕРГЛО (снято, чтобы не осталось в обороте).** Канон-гейт `contour.py` подозревался
в том, что меряет не то: 96% его флагов — не пропажа термина, а меньшая частота. Прогнан контроль,
которого не было: срабатывание на выходе ЧУЖОЙ единицы 67.1% против 28.5% на своём — прибор
специфичен в 2.4 раза и нулевую модель проходит. Счётная сверка ПОКРЫТИЯ — дословно то, что
заказано (промт :91-94). Подозрение снято; остаётся мягкая правка формулировки флага.

View file

@ -69,18 +69,59 @@ OUT = MONEY.OUT
ANCHOR = "deepseek-v4-flash" # якорный черновик ANCHOR = "deepseek-v4-flash" # якорный черновик
FIXER = "deepseek-v4-pro" # фиксер и критик — боевой редакторский движок FIXER = "deepseek-v4-pro" # фиксер и критик — боевой редакторский движок
DRAFT2 = "deepseek-v4-pro" # «качественный черновик» H-1 (арм D2 эксп-22) DRAFT2 = "deepseek-v4-pro" # «качественный черновик» H-1 (арм D2 эксп-22)
ARMS = ("A1", "A2", "A3", "A4", "A6") ARMS = ("A1", "A2", "A3", "A4", "A6", "A6F")
# ⚠ ПОПРАВКА ФАЗЫ Д (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, база у каждой своя и уже куплена ──────────────────────── # ── единицы: 16 эксп-22 + 16 эксп-23, база у каждой своя и уже куплена ────────────────────────
def units() -> list[dict]: def units() -> list[dict]:
"""ВСЕ единицы среза, включая исключённые, — для учёта и кассы."""
return P22.M.units_zh() + MAT.units_new() 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: def base_draft(uid: str) -> str:
"""Якорный черновик единицы. У старых — из сырья эксп-22, у новых — из фазы B эксп-23.""" """Якорный черновик единицы. У старых — из сырья эксп-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) t = P22.draft_text(ANCHOR, uid)
return t if t.strip() else PAN23.draft_b(uid) # новые единицы — фаза B пака 23 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: def base_a0(uid: str) -> str:
@ -108,20 +149,61 @@ def canon_gaps(src: str, out: str) -> list[str]:
return gaps 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]: def flags_of(u: dict, draft: str) -> list[str]:
"""Список мест для A1. Только ДЕТЕРМИНИРОВАННОЕ: канон-гейт + батарея, без судьи и без LLM.""" """Список мест для A1. Только ДЕТЕРМИНИРОВАННОЕ: канон-гейт + батарея, без судьи и без LLM."""
import battery as B # noqa: PLC0415 out = [f"КАНОН: термин {g} передан не канонной формой либо пропущен"
out = [] for g in canon_gaps(u["source"], draft)]
for g in canon_gaps(u["source"], draft): return out + battery_flags(u, draft)
out.append(f"КАНОН: термин {g} передан не канонной формой либо пропущен")
try:
r = B.run_unit(dict(uid=u["uid"], source=u["source"], text=draft))
for k, v in (r or {}).items():
if isinstance(v, (int, float)) and v and k not in ("uid", "len"):
out.append(f"БАТАРЕЯ: проверка «{k}» дала {v}")
except Exception: # noqa: BLE001, S110
pass # батарея не обязана знать наш вход
return out
# ── промты армов: данные, не код ────────────────────────────────────────────────────────────── # ── промты армов: данные, не код ──────────────────────────────────────────────────────────────
@ -158,8 +240,60 @@ def crit_msgs(src: str, draft: str) -> list[dict]:
{"role": "user", "content": f"ИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}"}] {"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: def tag(arm: str, uid: str, part: str = "") -> str:
ph = "f" if arm == "A6" else "a" ph = "f" if arm.startswith("A6") else "a"
return f"dv-{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}" return f"dv-{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}"
@ -175,9 +309,19 @@ def cmd_plan() -> None:
old = {u["uid"] for u in P22.M.units_zh()} old = {u["uid"] for u in P22.M.units_zh()}
print(f"единиц {len(us)} (старых {len(old)} + новых {len(us) - len(old)})") 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} 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: for a in ARMS:
n = len(old) if a == "A6" else len(us) n = len(old) if a.startswith("A6") else len(us)
print(f" {a}: куплено {have[a]:2d} из {n}") 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()) 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()) 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()) d2 = sum(1 for u in us if base_draft2(u["uid"]).strip())
@ -195,23 +339,31 @@ def cmd_selftest() -> int:
bad += not ok bad += not ok
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else "")) print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else ""))
us = units() us = units_ok()
ck("единиц ровно 32", len(us) == 32, str(len(us))) 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)) 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()] miss = [u["uid"] for u in us if not base_draft(u["uid"]).strip()]
ck("якорный черновик есть у всех", not miss, f"нет у {miss[:3]}") ck("якорный черновик есть у всех ДОПУСТИМЫХ", not miss, f"нет у {miss[:3]}")
miss0 = [u["uid"] for u in us if not base_a0(u["uid"]).strip()] miss0 = [u["uid"] for u in us if not base_a0(u["uid"]).strip()]
ck("арм A0 (боевой перепис) есть у всех", not miss0, f"нет у {miss0[:3]}") ck("арм A0 (боевой перепис) есть у всех", not miss0, f"нет у {miss0[:3]}")
old = P22.M.units_zh() old = P22.M.units_zh()
miss2 = [u["uid"] for u in old if not base_draft2(u["uid"]).strip()] miss2 = [u["uid"] for u in old if not base_draft2(u["uid"]).strip()]
ck("черновик-dspro для H-1 есть у всех 16 старых", not miss2, f"нет у {miss2[:3]}") ck("черновик-dspro для H-1 есть у всех 16 старых", not miss2, f"нет у {miss2[:3]}")
# ИЗОЛЯЦИЯ: у A1 и A3 вход ОДИН И ТОТ ЖЕ черновик; различаться обязан только список мест # ИЗОЛЯЦИЯ: у A1 и A3 вход ОДИН И ТОТ ЖЕ черновик; различаться обязан только список мест.
# ⚠ Прежняя редакция сверяла `fix_msgs(...)` САМА С СОБОЙ на одном и том же черновике —
# тавтология, которая не могла провалиться и потому ничего не сторожила. Теперь входы армов
# берутся ТЕМ ЖЕ кодом, что и в бою (`cmd_run`), и сверяются между собой.
u = us[0] u = us[0]
d = base_draft(u["uid"]) d = base_draft(u["uid"])
m1 = fix_msgs(u["source"], d, ["КАНОН: тест"]) m1 = fix_msgs(u["source"], d, flags_of(u, d) or ["КАНОН: тест"])
m3 = fix_msgs(u["source"], d, ["СМЫСЛ: тест"]) m3 = fix_msgs(u["source"], d, critic_places("A3", u["uid"])[0] or ["СМЫСЛ: тест"])
ck("A1 и A3 делят вход, различаясь только списком мест", ck("A1 и A3 делят вход, различаясь только списком мест",
m1[0]["content"] == m3[0]["content"] and m1[1]["content"] != m3[1]["content"]) m1[0]["content"] == m3[0]["content"] and m1[1]["content"] != m3[1]["content"])
ck("вход A4 — это A0, а не черновик (позиция «ПОСЛЕ редактора»)",
base_a0(u["uid"]).strip() != d.strip())
ck("в промте фиксера стоит запрет трогать остальное", ck("в промте фиксера стоит запрет трогать остальное",
"ПОБАЙТНО как есть" in m1[0]["content"]) "ПОБАЙТНО как есть" in m1[0]["content"])
# ⚠ Банк-закон D39.104 обязателен на ВСЕХ армах с переводом/редактурой. Проверяется ДОСЛОВНО # ⚠ Банк-закон D39.104 обязателен на ВСЕХ армах с переводом/редактурой. Проверяется ДОСЛОВНО
@ -220,12 +372,38 @@ def cmd_selftest() -> int:
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in m1[0]["content"], m1[0]["content"][-90:].replace("\n", " ")) "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in m1[0]["content"], m1[0]["content"][-90:].replace("\n", " "))
ck("банк-закон есть и в промте КРИТИКА — иначе он флагует канон не по банку", ck("банк-закон есть и в промте КРИТИКА — иначе он флагует канон не по банку",
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in crit_msgs(u["source"], d)[0]["content"]) "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in crit_msgs(u["source"], d)[0]["content"])
fl = flags_of(u, d) # ⚠ БАТАРЕЯ. Прежняя проверка `isinstance(fl, list)` не могла быть ложной: `flags_of` заводит
ck("канон-гейт выдаёт места (иначе A1 нечего чинить)", isinstance(fl, list), f"мест {len(fl)}") # `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} tags = {tag(a, u["uid"]) for a in ARMS}
ck("теги армов различны", len(tags) == len(ARMS)) ck("теги армов различны", len(tags) == len(ARMS), f"{len(tags)} против {len(ARMS)}")
ck("теги покрыты глобом кассы", all(t.startswith(("dv-a-", "dv-f-")) for t in tags)) 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, ck("buy.OUT совпадает с кассой фазы", MONEY.M22.BUY.OUT == MONEY.OUT,
f"{MONEY.M22.BUY.OUT} против {MONEY.OUT}") f"{MONEY.M22.BUY.OUT} против {MONEY.OUT}")
@ -233,58 +411,133 @@ def cmd_selftest() -> int:
return 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: def cmd_run(which: list[str]) -> None:
us = units() us = units_ok()
old = {u["uid"] for u in P22.M.units_zh()} old = {u["uid"] for u in P22.M.units_zh()}
for arm in which: for arm in which:
led = MONEY.Guarded("ФД-F" if arm == "A6" else "ФД-A") led = MONEY.Guarded("ФД-F" if arm.startswith("A6") else "ФД-A")
for u in us: for u in us:
uid, src = u["uid"], u["source"] uid, src = u["uid"], u["source"]
if arm == "A6" and uid not in old: if arm.startswith("A6") and uid not in old:
continue continue
if text_of(arm, uid).strip(): if text_of(arm, uid).strip():
continue continue
draft = base_draft2(uid) if arm == "A6" else base_draft(uid) draft = base_draft2(uid) if arm.startswith("A6") else base_draft(uid)
if not draft.strip(): if not draft.strip():
print(f" {uid}: базы нет — пропуск") print(f" {uid}: базы нет — пропуск")
continue continue
if arm == "A2": # однопроходка: редактора нет if arm == "A2": # однопроходка уравненного мандата
msgs = PR.translator_msgs(src) if hasattr(PR, "translator_msgs") else None _buy(led, tag(arm, uid), a2_msgs(src), role="onepass", uid=uid, arm=arm)
if msgs is None:
print(" A2: сборки однопроходки нет — арм пропущен, объявить в отчёте")
break
MONEY.purchase(led, tag(arm, uid), FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, msgs), role="onepass", uid=uid, arm=arm)
continue continue
if arm in ("A1", "A4"): if arm in ("A1", "A4", "A6F"): # детерминированные контуры
target = base_a0(uid) if arm == "A4" else draft target = base_a0(uid) if arm == "A4" else draft
places = [f"КАНОН: термин {g} передан не канонной формой либо пропущен" places = ([f"КАНОН: термин {g} передан не канонной формой либо пропущен"
for g in canon_gaps(src, target)] if arm == "A4" else flags_of(u, draft) for g in canon_gaps(src, target)] if arm == "A4"
else flags_of(u, target))
if not places: if not places:
print(f" {uid}/{arm}: мест нет — чинить нечего, клетка не покупается") _free_cell(tag(arm, uid), target, role="fixer", uid=uid, arm=arm, places=0)
continue continue
MONEY.purchase(led, tag(arm, uid), FIXER, ED22.client(FIXER), _buy(led, tag(arm, uid), fix_msgs(src, target, places),
ROSTER.call_kwargs(FIXER, fix_msgs(src, target, places)), role="fixer", uid=uid, arm=arm, places=len(places))
role="fixer", uid=uid, arm=arm, places=len(places))
continue continue
# A3 и A6: критик, затем фиксер по его списку # A3 и A6: смысловой критик, затем фиксер по его списку
ct = tag(arm, uid, "crit") ct = tag(arm, uid, "crit")
if not (OUT / f"{ct}.json").exists(): if not (OUT / f"{ct}.json").exists():
MONEY.purchase(led, ct, FIXER, ED22.client(FIXER), _buy(led, ct, crit_msgs(src, draft), role="critic", uid=uid, arm=arm)
ROSTER.call_kwargs(FIXER, crit_msgs(src, draft)), if not (OUT / f"{ct}.json").exists(): # оплачено, но файла нет
role="critic", uid=uid, arm=arm)
cf = OUT / f"{ct}.json"
if not cf.exists(): # оплачено, но файла нет — не падать
print(f" {uid}/{arm}: критик не отдал файл — арм пропущен, объявить в отчёте") print(f" {uid}/{arm}: критик не отдал файл — арм пропущен, объявить в отчёте")
continue continue
found = json.loads(cf.read_text(encoding="utf-8")).get("content") or "" places, found_n = critic_places(arm, uid)
places = [ln.strip("- ").strip() for ln in found.splitlines() if ln.strip().startswith("-")]
if not places: if not places:
print(f" {uid}/{arm}: критик не нашёл мест — фиксер не зовётся") _free_cell(tag(arm, uid), draft, role="fixer", uid=uid, arm=arm,
places=0, places_found=0)
continue continue
MONEY.purchase(led, tag(arm, uid), FIXER, ED22.client(FIXER), _buy(led, tag(arm, uid), fix_msgs(src, draft, places),
ROSTER.call_kwargs(FIXER, fix_msgs(src, draft, places[:40])), role="fixer", uid=uid, arm=arm, places=len(places), places_found=found_n)
role="fixer", uid=uid, arm=arm, places=len(places)) 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() MONEY.report()
@ -292,7 +545,11 @@ if __name__ == "__main__":
a = sys.argv[1:] or ["--plan"] a = sys.argv[1:] or ["--plan"]
if a[0] == "--selftest": if a[0] == "--selftest":
sys.exit(1 if cmd_selftest() else 0) sys.exit(1 if cmd_selftest() else 0)
if a[0] == "--run": elif a[0] == "--run":
cmd_run([x for x in a[1:] if x in ARMS] or list(ARMS)) cmd_run([x for x in a[1:] if x in ARMS] or list(ARMS))
else: elif a[0] == "--floor":
cmd_floor()
elif a[0] == "--plan":
cmd_plan() cmd_plan()
else:
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --plan --selftest --run --floor")

View file

@ -1,8 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Д6 — МАТЕРИАЛ ПАРЫ ja→ru: гардрейл, гейт прав, отбор единиц. $0. """Д6 — МАТЕРИАЛ ПАРЫ ja→ru: гардрейл, гейт прав, отбор единиц. $0.
Источник: `~/books/isekai_majutsushi_jp.txt` (файл владельца, вне git правило Р8). Площадка Источник: `~/books/enkan_no_hate_ja.txt` 円環の果てに 裏切られた魂二度目の生, `ncode.syosetu.com/n7233mn`,
`novel18.syosetu.com`, то есть срез ЗАВЕДОМО взрослый; аннотация книги сама называет 洗脳 и 暴力. первая публикация **2026-07-30**, 18 глав скачано из 56. Книга подобрана тем же механическим
критерием, что китайский срез эксп-22 (§0.2): площадка отдаёт текст главы БЕЗ JS; дата первой
публикации читается у самой площадки; жанр из продуктового семейства (異世界転生, преданный герой,
вторая жизнь тот же троп, что у 阴阳天帝诀).
**ДВА ФИЛЬТРА, КОТОРЫХ НЕ БЫЛО У ZH-СРЕЗА, и оба обязательны для ja.**
(1) **Метка `AI直接使用`.** Syosetu помечает работы, написанные ИИ, и среди свежих ранобэ таких
много: из первых трёх кандидатов по длине главы ВСЕ ТРИ оказались AI-сгенерированными. Мерить на
них перевод нельзя это не человеческая проза, и «невиданность» такого текста ничего не значит.
Кандидаты с ключами `AI直接使用`/`AI支援`/`生成AI` отбрасываются механически.
(2) **Прежний источник `isekai_majutsushi_jp` (novel18) СНЯТ:** гардрейл 18+ уровня 3 дал 3
срабатывания из 184 чанков, помеченное не читалось, обработка прекращена. Замена подобрана по
слову владельца 10.08.
ГАРДРЕЙЛ ПЕРВЫМ, ДО ЛЮБОГО ДРУГОГО ДЕЙСТВИЯ. Гардрейл владельца (вынесен в `eval/README.md`): ГАРДРЕЙЛ ПЕРВЫМ, ДО ЛЮБОГО ДРУГОГО ДЕЙСТВИЯ. Гардрейл владельца (вынесен в `eval/README.md`):
**18+ уровень 3 несовершеннолетние в сексуальном контексте не обрабатывать и не анализировать **18+ уровень 3 несовершеннолетние в сексуальном контексте не обрабатывать и не анализировать
@ -30,7 +42,7 @@ import re
import sys import sys
from pathlib import Path from pathlib import Path
SRC = Path.home() / "books" / "isekai_majutsushi_jp.txt" SRC = Path.home() / "books" / "enkan_no_hate_ja.txt"
OUT = Path.home() / "books" / "dovodka" OUT = Path.home() / "books" / "dovodka"
MANIFEST = OUT / "manifest-ja.json" MANIFEST = OUT / "manifest-ja.json"
TARGET_CHARS, MIN_CHARS, N_UNITS = 1100, 700, 9 # ja плотнее zh на знак: цель короче TARGET_CHARS, MIN_CHARS, N_UNITS = 1100, 700, 9 # ja плотнее zh на знак: цель короче
@ -53,13 +65,20 @@ def text() -> str:
def chapters() -> list[dict]: def chapters() -> list[dict]:
"""Главы по разделителю площадки; заголовок остаётся в теле — он часть материала.""" """Главы по разделителю площадки; заголовок остаётся в теле — он часть материала."""
# ⚠ Разделитель — заголовок вида `=== 7 · 身に覚えのない署名 ===`, который ставит качалка.
# Первая редакция резала по `\n=+\n` и НЕ находила его: весь файл становился ОДНОЙ главой,
# отбор давал девять соседних кусков одной сцены, и прирост мощности был бы мнимым. Ровно
# эту ошибку эксп-23 §11 ловил у себя («главы эксп-22 исключены ЦЕЛИКОМ, иначе новые единицы
# делили бы с ними сцену и лексику»). Поймано печатью «глав 1».
raw = text() raw = text()
parts = re.split(r"\n=+\n", raw)
out = [] out = []
for i, p in enumerate(parts): for m in re.finditer(r"^=== (\d+) · (.*?) ===$\n(.*?)(?=^=== \d+ · |\Z)",
p = p.strip() raw, re.M | re.S):
if len(p) >= MIN_CHARS: body = m.group(3).strip()
out.append(dict(idx=i, text=p)) if len(body) >= MIN_CHARS:
out.append(dict(idx=int(m.group(1)), text=body))
if not out:
raise SystemExit("⛔ ни одной главы не распознано — разделитель не тот, отбор НЕ делается")
return out return out

View file

@ -7,13 +7,18 @@
сторож перед КАЖДЫМ вызовом по цене ИМЕННО ЭТОЙ модели, два независимых пути счёта. Копирование сторож перед КАЖДЫМ вызовом по цене ИМЕННО ЭТОЙ модели, два независимых пути счёта. Копирование
размножило бы будущие расхождения вместо того, чтобы их ловить. размножило бы будущие расхождения вместо того, чтобы их ловить.
ПОТОЛОК ФАЗЫ САНКЦИЯ ВЛАДЕЛЬЦА $4.50 (10.08). История цифры: промт называл $10.00, владелец при ПОТОЛОК ФАЗЫ САНКЦИЯ ВЛАДЕЛЬЦА $5.40 (11.08). История цифры: промт называл $10.00, владелец при
запуске сказал $4.00, затем поднял до $4.50, когда приёмка другого модельного семейства нашла, что запуске сказал $4.00, затем поднял до $4.50, когда приёмка другого модельного семейства нашла, что
дизайн не отвечает на H-1 и H-4, а починка стоит $0.48. дизайн не отвечает на H-1 и H-4, а починка стоит $0.48. 11.08 ревью 82 находок показало, что смета
занижала фиксера в 2.41× и фаза не влезает в $4.50 ни при каком порядке покупок; владелец выбрал
вариант «всё как заказано» и поднял потолок до $5.40.
РЕЗЕРВ ФАЗЫ $0.03 0.7% пакета, и это объявлено риском ДО покупок (Д0.8). Ре-гены эхо-мины и РЕЗЕРВ ФАЗЫ $0.00 и это объявлено риском ДО покупок ВТОРОЙ раз. При $4.50 резерв был $0.03,
ретраи в паках 22/23 съедали до +16% сверх сметы фазы. Сессия называла честным потолком $4.80; и первый же промах сметы остановил ось Д4 на 22 клетках из 32. При $5.40 остатка нет вовсе:
владелец выбрал $4.50. Поэтому правило исполнения жёсткое и записано здесь, а не в прозе: честная смета остатка $4.277 плюс потраченные $1.140 дают $5.417, то есть уже $0.017 сверх
санкции, и Д1 добирает 15 клеток вместо 16 именно поэтому. Резерв 8% на ре-гены (история паков
22/23 до +16%) потребовал бы $5.85. Сказано ДО покупок, а не после.
Поэтому правило исполнения жёсткое и записано здесь, а не в прозе:
**срабатывание проекционного гарда = СТОП и вопрос владельцу.** Резать ось, панель или кандидата **срабатывание проекционного гарда = СТОП и вопрос владельцу.** Резать ось, панель или кандидата
решением сессии ЗАПРЕЩЕНО заказом; поднимать потолок себе тем более (пак 23 так и сделал, и это решением сессии ЗАПРЕЩЕНО заказом; поднимать потолок себе тем более (пак 23 так и сделал, и это
стоило ему пункта CONFIRM/DENY). стоило ему пункта CONFIRM/DENY).
@ -64,9 +69,23 @@ TAGS = {
# пакетный потолок. Первая редакция округляла каждый вверх до цента и дала сумму 4.54 > 4.50 — # пакетный потолок. Первая редакция округляла каждый вверх до цента и дала сумму 4.54 > 4.50 —
# то есть фазовые гарды в совокупности разрешали пробить пакетный. Поймано селфтестом кассы # то есть фазовые гарды в совокупности разрешали пробить пакетный. Поймано селфтестом кассы
# ДО первой покупки; ровно этот класс («касса охраняет не ту границу») стоил эксп-21 денег трижды. # ДО первой покупки; ровно этот класс («касса охраняет не ту границу») стоил эксп-21 денег трижды.
CEILINGS = {"ФД-A": 0.885, "ФД-B": 0.560, "ФД-C": 0.155, "ФД-D": 0.055, #
"ФД-E": 2.355, "ФД-F": 0.283, "ФД-G": 0.202} # ⚠⚠ ПЕРЕ-РАСКЛАДКА 11.08 ПОД САНКЦИЮ $5.40. Прежняя раскладка стояла на смете, где фиксер положен
PACK_CEILING = 4.50 # в 0.60× переписа, а замерен в 2.41× (`plan.py` сверяет прогноз с фактом и печатает расхождение).
# Из-за этого ФД-A исчерпалась на 22 клетках арма A4 из 32, а арм A2 не куплен ни разу. Новая
# раскладка считана по ЗАМЕРЕННЫМ ценам 148 купленных клеток фазы, а не по прогнозу.
#
# ⚠ ПРИОРИТЕТ ВНУТРИ ФД-A ОБЪЯВЛЯЕТСЯ ЗДЕСЬ: ПЕРВЫМ покупается СВОЙ ШУМОВОЙ ПОЛ. Причина не
# бухгалтерская, а решающая: при заимствованном прайоре sd 2.12 и честном квантиле Стьюдента все
# три первичных контраста оси Д4 НЕДОМОЩНЫ (MDE 1.67…2.32 против цели 1.50). Несущей ось делает
# только СВОЙ пол — при sd ≤ 1.91 контрасты A1/A0 и A3/A0 становятся несущими. Покупать армы,
# не зная пола, значит рисковать всей сметой оси, которая может не дать вывода ни при каком исходе.
#
# ⚠ ЧТО НЕ ВЛЕЗЛО В $5.40 И ОБЪЯВЛЕНО ДО ПОКУПОК: Д1 добирает 15 клеток gemini из 16. Шестнадцатая
# стоит $0.147 и требует потолка $5.45. Пре-рег Д0.3 говорит прямо: несущей ось Д1 делают ПОЛНЫЕ 16.
CEILINGS = {"ФД-A": 1.640, "ФД-B": 0.620, "ФД-C": 0.160, "ФД-D": 0.055,
"ФД-E": 2.305, "ФД-F": 0.410, "ФД-G": 0.210}
PACK_CEILING = 5.40
M22.configure(ZONE=ZONE, OUT=OUT, TAGS=TAGS, CEILINGS=CEILINGS, M22.configure(ZONE=ZONE, OUT=OUT, TAGS=TAGS, CEILINGS=CEILINGS,
PACK_CEILING=PACK_CEILING, GLOB_ALL="dv-*.json", ROSTER=ROSTER) PACK_CEILING=PACK_CEILING, GLOB_ALL="dv-*.json", ROSTER=ROSTER)

View file

@ -79,6 +79,21 @@ PRIMARY = {
"Д3 en→ru несущая": ["E0/D0 — КОНТРОЛЬ: покупает ли редактор поверх черновика (H-3)"], "Д3 en→ru несущая": ["E0/D0 — КОНТРОЛЬ: покупает ли редактор поверх черновика (H-3)"],
"Д1 gemini добивка": ["R1/R0 — аутлаер ли проза gemini (гипотеза эксп-04)"], "Д1 gemini добивка": ["R1/R0 — аутлаер ли проза gemini (гипотеза эксп-04)"],
} }
# ⚠⚠ n У КАЖДОГО КОНТРАСТА СВОЙ, И ЭТО РЕШАЕТ. Прежняя редакция печатала MDE и статус ПО ОСИ при
# плановом n=32 — а члены первичного семейства живут на разных выборках, потому что база у них
# разная. `A6/A0` стоит на 16 единицах ПО ПОСТРОЕНИЮ: черновик dspro эксп-22 существует только у
# 16 старых единиц. При n=16 его MDE вдвое больше цели, то есть НОСИТЕЛЬ H-1 не мог дать несущего
# вывода никогда — и это было известно ДО денег, просто никто не считал мощность поконтрастно.
# Ровно этот класс («ось объявлена несущей, контраст недомощен») погубил en-ось эксп-22.
# Числа ниже — фактические выборки после исключения единицы с эхо-мином (contour.EXCLUDED).
CONTRAST_N = {
"A1/A0 — флаговый контур (H-2а)": 31,
"A3/A0 — смысловой контур (H-2б, ставка владельца)": 31,
"A6/A0 — качественный черновик + контур (H-1)": 16,
"E0/D0 — КОНТРОЛЬ: покупает ли редактор поверх черновика (H-3)": 16,
"R1/R0 — аутлаер ли проза gemini (гипотеза эксп-04)": 16,
}
SECONDARY = { SECONDARY = {
"Д4 edit-контур zh": ["A2/A0 — однопроходка уравненного мандата (строка 153 бэклога)", "Д4 edit-контур zh": ["A2/A0 — однопроходка уравненного мандата (строка 153 бэклога)",
"A4/A0 — канон-фиксер ПОСЛЕ переписа: его несущая ось — покрытие " "A4/A0 — канон-фиксер ПОСЛЕ переписа: его несущая ось — покрытие "
@ -114,18 +129,36 @@ def _z(p: float) -> float:
return statistics.NormalDist().inv_cdf(p) return statistics.NormalDist().inv_cdf(p)
def mde(sd: float, n: int, k: int = 1, between: bool = True) -> float: def _t(p: float, df: int) -> float:
"""Квантиль Стьюдента. ⚠ Первая редакция считала его самописной бисекцией по неполной бете —
и та молча возвращала НОЛЬ на половине входов, то есть обнуляла MDE и печатала «НЕСУЩАЯ» там,
где мощности нет вовсе. Поймано сверкой с табличными значениями ДО того, как на числе встали.
Мораль та же, что у всей фазы: свою арифметику сверяй с эталоном, а не с ожиданием.
"""
from scipy import stats # noqa: PLC0415
return float(stats.t.ppf(p, df))
# ⚠ ЧИСЛО СТЕПЕНЕЙ СВОБОДЫ ПРАЙОРА. sd берётся не из воздуха и не «известна»: она ОЦЕНЕНА на
# полу прошлого пака с n=16, то есть df=15. Прежняя редакция брала квантиль НОРМАЛИ, как если бы
# дисперсия была известна точно, и это давало оси Д4 MDE 1.49 против цели 1.50 — «несущая» с
# запасом 0.7%. При честном t тот же дизайн даёт 1.56, то есть ОПИСАТЕЛЬНУЮ ось. Разница между
# «несущая» и «описательная» здесь целиком сидела в подмене t на z; поправка внесена 11.08.
PRIOR_DF = 15
def mde(sd: float, n: int, k: int = 1, between: bool = True, df: int = PRIOR_DF) -> float:
"""Минимально различимый эффект при 80% мощности и поправке Холма по k контрастам. """Минимально различимый эффект при 80% мощности и поправке Холма по k контрастам.
КОНСТАНТА 2.8 ВЕРНА ТОЛЬКО ПРИ k=1, и первая редакция этого файла применяла её всегда. КОНСТАНТА 2.8 ВЕРНА ТОЛЬКО ПРИ k=1, и первая редакция этого файла применяла её всегда.
Поймано адверсариальным ревью снизу вверх. Решающее правило пака `p` ХОЛМА < 0.05, то есть Поймано адверсариальным ревью снизу вверх. Решающее правило пака `p` ХОЛМА < 0.05, то есть
на самый строгий контраст семейства приходится α/k; множитель становится на самый строгий контраст семейства приходится α/k; множитель становится
(z_{1α/2k} + z_{0.8}), при k=5 это 3.417 вместо 2.8. Пропущенная поправка ×1.221.36 БОЛЬШЕ (t_{1α/2k} + t_{0.8}), при k=5 это 3.6 вместо 2.8. Пропущенная поправка ×1.221.36 БОЛЬШЕ
той ×1.23, которую файл старательно вносил за межсессионную компоненту, и она переворачивала той ×1.23, которую файл старательно вносил за межсессионную компоненту, и она переворачивала
вердикт: Д4 при k=5 давала MDE 1.57 против цели 1.50, то есть ОПИСАТЕЛЬНУЮ ось, объявленную вердикт: Д4 при k=5 давала MDE 1.57 против цели 1.50, то есть ОПИСАТЕЛЬНУЮ ось, объявленную
несущей. Это буквально провал en-оси эксп-22, ради недопущения которого файл и написан. несущей. Это буквально провал en-оси эксп-22, ради недопущения которого файл и написан.
""" """
mult = _z(1 - 0.05 / (2 * max(1, k))) + _z(0.80) mult = _t(1 - 0.05 / (2 * max(1, k)), df) + _t(0.80, df)
return mult * sd / math.sqrt(n) * (BETWEEN if between else 1.0) return mult * sd / math.sqrt(n) * (BETWEEN if between else 1.0)
@ -155,6 +188,28 @@ def main() -> int:
print(f"{name:24s}{n:4d}{k:3d}{sd:6.2f}{m_true:7.2f}{target:7.2f}{p0:11.4f} {status}") print(f"{name:24s}{n:4d}{k:3d}{sd:6.2f}{m_true:7.2f}{target:7.2f}{p0:11.4f} {status}")
print(f"\nописательных осей: {desc} из {len(AXES)} — объявлено ДО покупок, " print(f"\nописательных осей: {desc} из {len(AXES)} — объявлено ДО покупок, "
"пере-классификации задним числом не будет") "пере-классификации задним числом не будет")
# ⚠ ПОКОНТРАСТНАЯ МОЩНОСТЬ — то, по чему решение принимается на самом деле.
print("\nПОКОНТРАСТНАЯ МОЩНОСТЬ ПЕРВИЧНЫХ СЕМЕЙСТВ (n у каждого СВОЙ — решает он, не ось):")
print(f" {'контраст':56s}{'n':>4s}{'MDE':>7s}{'ЦЕЛЬ':>7s} вердикт")
weak = []
for name, n_ax, k, sd, target, _w1, _w2 in AXES:
for c in PRIMARY.get(name, []):
n_c = CONTRAST_N.get(c, n_ax)
m = mde(sd, n_c, k)
ok = m <= target
if not ok:
weak.append((c, n_c, m, target))
print(f" {c[:56]:56s}{n_c:4d}{m:7.2f}{target:7.2f} "
f"{'НЕСУЩИЙ' if ok else '⛔ НЕДОМОЩЕН'}")
if weak:
print("\n ⛔ НЕДОМОЩНЫЕ КОНТРАСТЫ ПЕРВИЧНОГО СЕМЕЙСТВА — объявляются ОПИСАТЕЛЬНЫМИ:")
for c, n_c, m, t in weak:
need_sd = t * math.sqrt(n_c) / ((_t(1 - 0.05 / 6, PRIOR_DF) + _t(0.80, PRIOR_DF))
* BETWEEN)
need_n = math.ceil((((_t(1 - 0.05 / 6, PRIOR_DF) + _t(0.80, PRIOR_DF))
* 2.12 * BETWEEN) / t) ** 2)
print(f"{c}\n при n={n_c} MDE {m:.2f} > цели {t:.2f}; несущим станет "
f"либо при своём поле sd ≤ {need_sd:.2f}, либо при n ≥ {need_n}")
print("\nСЕМЕЙСТВА, объявленные ДО покупок:") print("\nСЕМЕЙСТВА, объявленные ДО покупок:")
for name in [a[0] for a in AXES]: for name in [a[0] for a in AXES]:
if name in PRIMARY: if name in PRIMARY:
@ -166,7 +221,7 @@ def main() -> int:
for c in SECONDARY[name]: for c in SECONDARY[name]:
print(f" · {c}") print(f" · {c}")
print("\nОБОСНОВАНИЯ (прайор шума · искомый эффект):") print("\nОБОСНОВАНИЯ (прайор шума · искомый эффект):")
for name, _n, _k, _sd, _t, w1, w2 in AXES: for name, _n, _k, _sd, _tg, w1, w2 in AXES:
print(f" {name}\n прайор: {w1}\n цель: {w2}") print(f" {name}\n прайор: {w1}\n цель: {w2}")
print("\nПРОСТРАНСТВО ДИЗАЙНА — честный MDE (с ×1.23) при разных n и разных полах:") print("\nПРОСТРАНСТВО ДИЗАЙНА — честный MDE (с ×1.23) при разных n и разных полах:")
print(" " + "".join(f"{f'sd {s:.2f}':>10s}" for s in GRID_SD)) print(" " + "".join(f"{f'sd {s:.2f}':>10s}" for s in GRID_SD))

247
eval/editor_tier/axiscal.py Normal file
View file

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""ОСЕВОЙ КАЛИБРАТОР СУДЕЙСКИХ СЕМЕЙСТВ. $0 — только чтение уже купленного сырья.
Отвечает на вопрос владельца «как синхронизировать судей разных семейств»: шкалу синхронизировать
нечем и не надо (риг уже нормирован контрастом внутри единицы, порогом из своего пола и декоем как
единицей измерения), а сверять надо ЧУВСТВИТЕЛЬНОСТЬ ПО ОСЯМ долю пойманного дефекта ИЗВЕСТНОГО
типа и правильность отнесения его на ось.
Прибор состоит из трёх замеров, и все три считаются здесь:
1. АТРИБУЦИЯ. Посадка декоя восстанавливается побайтно (сверка sha1 с ключом), каждый посаженный
дефект локализуется в тексте, и по цитатам судьи проверяется: процитирован ли он и назван ли
под ТОЙ осью, к которой правило посадки относится. Числа по осям для этого не годятся
прирост числа не доказывает, что судья увидел именно посаженное.
2. ДИНАМИЧЕСКИЙ ДИАПАЗОН. Уровень оси на боевых клетках и шум пола ПО ОСИ. Ось, у которой на
реальном тексте уровень ~0 и шум ровно 0, контраст армов нести не может, даже если посаженное
на ней ловится 16/16.
3. ЗАРАЖЕНИЕ. Ошибки в декой-клетке ВНЕ посаженных мест против той же клетки без порчи: сколько
семейство доначисляет тексту просто за то, что он уже пойман испорченным.
ЧЕГО ПРИБОР НЕ МЕРЯЕТ см. вывод `границы`: посадка даёт ГРУБЫЕ дефекты, а спор семейств живёт
в маргинальном режиме, и на грубых обе семьи упираются в потолок.
Запуск: eval/.venv/bin/python eval/editor_tier/axiscal.py [tier]
"""
from __future__ import annotations
import hashlib
import json
import math
import re
import statistics as st
import sys
from pathlib import Path
ZONE = Path(__file__).resolve().parent
sys.path.insert(0, str(ZONE))
import judge as J # noqa: E402
OUT = Path.home() / "books" / "editor-tier"
AXES = ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА")
# Ось, к которой ПО ЗАМЫСЛУ относится каждое правило `blind-keys/PLANTS.json`. Порядок правил —
# порядок файла; правило, которого в файле нет, сюда не попадает.
RULE_AXIS = {0: "ВЕРНОСТЬ", 1: "ВЕРНОСТЬ", 2: "ВЕРНОСТЬ", 3: "ЯЗЫК", 4: "ФОРМА", 5: "ФОРМА"}
CTX = 40 # окно контекста вокруг посаженного места, в котором цитата считается попавшей
def plants() -> list:
return json.loads((OUT / "blind-keys" / "PLANTS.json").read_text(encoding="utf-8"))
def trace(text: str) -> tuple[str, list[dict]]:
"""Посадка с отслеживанием ФИНАЛЬНЫХ смещений: каждая следующая замена сдвигает прежние.
Без пересчёта смещений метка «где посажено» указывает не туда, и атрибуция врёт."""
out, marks = text, []
for i, (pat, rep, _c) in enumerate(plants()):
m = re.search(pat, out)
if not m:
continue
new = re.sub(pat, rep, m.group(0), count=1)
delta = len(new) - len(m.group(0))
for mk in marks:
if mk["beg"] >= m.end():
mk["beg"] += delta
mk["end"] += delta
marks.append(dict(rule=i, beg=m.start(), end=m.start() + len(new)))
out = out[:m.start()] + new + out[m.end():]
return out, marks
def quotes(why: str) -> list[tuple[str, str]]:
"""(ось, цитата). Ось — последняя названная перед цитатой: так пишут обе семьи, и на этом же
стоит приёмка ответа (`solscore.votes`), поэтому разбор не вводит нового допущения."""
res, cur = [], None
for tk in re.finditer(r"(ВЕРНОСТЬ|ТЕРМИН|ЯЗЫК|ФОРМА)|[«\"“„](.+?)[»\"”]", why):
if tk.group(1):
cur = tk.group(1)
elif cur:
res.append((cur, tk.group(2)))
return res
def norm(s: str) -> str:
return re.sub(r"\s+", " ", s).strip().lower().replace("ё", "е")
def votes_claude(key: dict) -> dict:
out = {}
for f in sorted((OUT / "aj-tier-votes").glob("*.json")):
v = json.loads(f.read_text(encoding="utf-8"))
out[next(iter(v.values()))["uid"]] = {m["arm"]: m for m in v.values()}
return out
def votes_sol(key: dict) -> dict:
"""Разбор ответов внешнего судьи. Числа и ПОЧЕМУ читаются теми же выражениями, что в
`solscore.votes`; здесь дополнительно СОХРАНЯЕТСЯ разбивка по осям, которую тот схлопывает."""
out = {}
for f in sorted((OUT / "sol-tier").glob("*.txt")):
k = key.get(f.stem)
if not k:
continue
txt, unit = f.read_text(encoding="utf-8"), {}
for lab, meta in k.items():
ax = {}
for a in AXES:
m = re.search(rf"^{lab}-{a}[ \t]*:[ \t]*(\d+)[ \t]*$", txt, re.M)
if not m:
break
ax[a] = int(m.group(1))
if len(ax) != len(AXES):
break
wm = re.search(rf"^{lab}-ПОЧЕМУ[ \t]*:[ \t]*(.*?)(?=^\S+-(?:{'|'.join(AXES)}|ПОЧЕМУ)"
rf"[ \t]*:|\Z)", txt, re.M | re.S)
unit[meta["arm"]] = dict(meta, why=(wm.group(1) if wm else "").strip(), **ax)
if len(unit) == len(k):
out[next(iter(k.values()))["uid"]] = unit
return out
def attribute(data: dict, hook, units: dict) -> tuple[dict, dict]:
"""(uid, правило) -> поймано-с-верной-осью · плюс счётчики цитат вне посадки."""
hit, tally = {}, dict(quotes=0, on=0, off=0, nomatch=0, base=0, decoy=0)
for uid, d in data.items():
if "CTRLdecoy" not in d:
continue
donor = d["CTRLdecoy"].get("decoy_from") or "R0"
planted, marks = trace(hook(donor, units[uid]))
sig = hashlib.sha1(planted.encode()).hexdigest()[:12] # noqa: S324
if sig != d["CTRLdecoy"]["sig"]:
raise SystemExit(f"⛔ посадка не восстановлена для {uid}: {sig}{d['CTRLdecoy']['sig']}")
pn = norm(planted)
tally["base"] += sum(d[donor][a] for a in AXES)
tally["decoy"] += sum(d["CTRLdecoy"][a] for a in AXES)
spans = [(norm(planted[max(0, mk["beg"] - CTX):mk["end"] + CTX]), mk) for mk in marks]
qs = []
for ax, q in quotes(d["CTRLdecoy"]["why"]):
tally["quotes"] += 1
nq = norm(q)
i = pn.find(nq)
if i < 0:
tally["nomatch"] += 1
continue
qs.append((ax, i, i + len(nq)))
for ctx, mk in spans:
c0 = pn.find(ctx)
over = [ax for ax, b, e in qs if c0 >= 0 and b < c0 + len(ctx) and e > c0]
hit[(uid, mk["rule"])] = int(RULE_AXIS[mk["rule"]] in over)
for _ax, b, e in qs:
on = any(pn.find(ctx) >= 0 and b < pn.find(ctx) + len(ctx) and e > pn.find(ctx)
for ctx, _ in spans)
tally["on" if on else "off"] += 1
return hit, tally
def mcnemar(b: int, c: int) -> float:
from math import comb # noqa: PLC0415
n = b + c
if n == 0:
return 1.0
return min(1.0, 2 * sum(comb(n, i) for i in range(min(b, c) + 1)) / 2 ** n)
def need_pairs(p1: float, p2: float, alpha: float = 0.0125, power: float = 0.80) -> int:
"""Парная схема (один и тот же посаженный дефект читают оба семейства) — McNemar.
Независимость промахов между семьями даёт МАКСИМУМ доли расхождений, то есть оценка сверху."""
za = {0.05: 1.96, 0.025: 2.2414, 0.0167: 2.394, 0.0125: 2.4977}[alpha]
zb = {0.80: 0.8416, 0.90: 1.2816}[power]
p01, p10 = p1 * (1 - p2), (1 - p1) * p2
psi, d = p01 + p10, abs(p01 - p10)
if d <= 0:
return 10 ** 9
return math.ceil((za * math.sqrt(psi) + zb * math.sqrt(psi - d * d)) ** 2 / d ** 2)
def main() -> int:
pass_ = (sys.argv[1:] or ["tier"])[0]
J.setup(pass_)
key = json.loads((OUT / "blind-keys" / f"{pass_}-KEY.json").read_text(encoding="utf-8"))
hook, units = J.text_of(pass_), {u["uid"]: u for u in J.units(pass_)}
fam = {"Claude": votes_claude(key), "Sol 5-6": votes_sol(key)}
uids = sorted(set.intersection(*(set(v) for v in fam.values())))
print(f"проход {pass_} · единиц у обоих семейств {len(uids)} · правил посадки {len(plants())}")
print(f"⚠ на ось ТЕРМИН посадки НЕТ: {sorted(set(RULE_AXIS.values()))}")
H, T = {}, {}
for n, d in fam.items():
H[n], T[n] = attribute({u: d[u] for u in uids}, hook, units)
print("\n1. АТРИБУЦИЯ ПОСАЖЕННОГО (поймано И названо верной осью)")
print(f" {'правило':<36} {'ось':<9} {'n':>3} " + " ".join(f"{n:>8}" for n in fam))
for i, (_p, _r, cmt) in enumerate(plants()):
n_i = sum(1 for (u, r) in H[next(iter(fam))] if r == i)
if not n_i:
continue
cols = [sum(v for (u, r), v in H[n].items() if r == i) / n_i for n in fam]
print(f" R{i} {cmt:<32} {RULE_AXIS[i]:<9} {n_i:>3} " + " ".join(f"{c:>8.2f}" for c in cols))
print(f"\n {'ось':<9} {'дефектов':>8} " + " ".join(f"{n:>8}" for n in fam) + " McNemar p")
names = list(fam)
for a in AXES:
keys = [k for k in H[names[0]] if RULE_AXIS[k[1]] == a]
if not keys:
print(f" {a:<9} {'':>8} посадок нет — ось прибором НЕ ПОКРЫТА")
continue
b = sum(1 for k in keys if H[names[0]][k] and not H[names[1]][k])
c = sum(1 for k in keys if not H[names[0]][k] and H[names[1]][k])
cols = [sum(H[n][k] for k in keys) / len(keys) for n in names]
print(f" {a:<9} {len(keys):>8} " + " ".join(f"{x:>8.2f}" for x in cols)
+ f" {mcnemar(b, c):.3f} (расхождений {b}/{c})")
print("\n2. ДИНАМИЧЕСКИЙ ДИАПАЗОН ОСИ (уровень боевых клеток · шум пола · порог 2.8·sd/√n)")
for n, d in fam.items():
print(f" {n}:")
arms = [a for a in J.arms(pass_)]
for a in AXES:
lv = [d[u][x][a] for u in uids for x in arms if x in d[u]]
fl = [d[u]["CTRLfloorA"][a] - d[u]["CTRLfloorB"][a] for u in uids]
thr = 2.8 * st.pstdev(fl) / math.sqrt(len(fl))
dead = "ОСЬ МЁРТВАЯ: контраст армов нести не может" if st.pstdev(fl) == 0 else ""
print(f" {a:<9} уровень {st.mean(lv):6.2f}/клетку · шум пола sd {st.pstdev(fl):4.2f}"
f" · порог {thr:4.2f}{dead}")
print("\n3. ЗАРАЖЕНИЕ (цитаты в декой-клетке ВНЕ посадки против той же клетки без порчи)")
for n in fam:
t = T[n]
print(f" {n:<8} база {t['base']:>4} · декой {t['decoy']:>4} · цитат {t['quotes']:>4} "
f"(на посадке {t['on']:>3} · вне {t['off']:>3} · не найдено в тексте {t['nomatch']:>3})"
f" → заражение {t['off'] - t['base']:+d}")
print(f" сигнал/фон посадки: {(t['decoy'] - t['base']) / max(t['base'], 1):.2f}")
print("\n4. ГРАНИЦЫ ПРИБОРА ПРИ ТЕКУЩЕМ ОБЪЁМЕ (парный McNemar, α=0.0125 Холм, мощность 0.80)")
for a in AXES:
keys = [k for k in H[names[0]] if RULE_AXIS[k[1]] == a]
if not keys:
continue
best = min(((d / 100, p / 100) for d in range(1, 100) for p in range(1, 100 - d)
if need_pairs(p / 100 + d / 100, p / 100) <= len(keys)), default=None)
got = f"{best[0]:.2f}" if best else ">0.99"
print(f" {a:<9} дефектов {len(keys):>3} → различим разрыв долей ≥ {got}")
print(" надо дефектов на ось: " + " · ".join(
f"Δ{p1 - p2:.2f} ({p1:.2f}/{p2:.2f}) → {need_pairs(p1, p2)}"
for p1, p2 in ((0.90, 0.60), (0.90, 0.70), (0.90, 0.80))))
return 0
if __name__ == "__main__":
sys.exit(main())