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) · банк как фактор · выбор жильца черновой роли
(решён эксп-22) · качество прозы `gemini` как продуктовый выбор — при $221 за книгу против $8 у
боевого он невозможен ни при каком исходе, меряется только гипотеза «аутлаер прозы» эксп-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" # якорный черновик
FIXER = "deepseek-v4-pro" # фиксер и критик — боевой редакторский движок
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, база у каждой своя и уже куплена ────────────────────────
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."""
"""Якорный черновик единицы. У старых — из сырья эксп-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)
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:
@ -108,20 +149,61 @@ def canon_gaps(src: str, out: str) -> list[str]:
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."""
import battery as B # noqa: PLC0415
out = []
for g in canon_gaps(u["source"], 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
out = [f"КАНОН: термин {g} передан не канонной формой либо пропущен"
for g in canon_gaps(u["source"], draft)]
return out + battery_flags(u, draft)
# ── промты армов: данные, не код ──────────────────────────────────────────────────────────────
@ -158,8 +240,60 @@ def crit_msgs(src: str, draft: str) -> list[dict]:
{"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 == "A6" else "a"
ph = "f" if arm.startswith("A6") else "a"
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()}
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 == "A6" else len(us)
print(f" {a}: куплено {have[a]:2d} из {n}")
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())
@ -195,23 +339,31 @@ def cmd_selftest() -> int:
bad += not ok
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else ""))
us = units()
ck("единиц ровно 32", len(us) == 32, str(len(us)))
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]}")
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 вход ОДИН И ТОТ ЖЕ черновик; различаться обязан только список мест
# ИЗОЛЯЦИЯ: у A1 и A3 вход ОДИН И ТОТ ЖЕ черновик; различаться обязан только список мест.
# ⚠ Прежняя редакция сверяла `fix_msgs(...)` САМА С СОБОЙ на одном и том же черновике —
# тавтология, которая не могла провалиться и потому ничего не сторожила. Теперь входы армов
# берутся ТЕМ ЖЕ кодом, что и в бою (`cmd_run`), и сверяются между собой.
u = us[0]
d = base_draft(u["uid"])
m1 = fix_msgs(u["source"], d, ["КАНОН: тест"])
m3 = fix_msgs(u["source"], d, ["СМЫСЛ: тест"])
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 обязателен на ВСЕХ армах с переводом/редактурой. Проверяется ДОСЛОВНО
@ -220,12 +372,38 @@ def cmd_selftest() -> int:
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in m1[0]["content"], m1[0]["content"][-90:].replace("\n", " "))
ck("банк-закон есть и в промте КРИТИКА — иначе он флагует канон не по банку",
"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in crit_msgs(u["source"], d)[0]["content"])
fl = flags_of(u, d)
ck("канон-гейт выдаёт места (иначе A1 нечего чинить)", isinstance(fl, list), f"мест {len(fl)}")
# ⚠ БАТАРЕЯ. Прежняя проверка `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))
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}")
@ -233,58 +411,133 @@ def cmd_selftest() -> int:
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()
us = units_ok()
old = {u["uid"] for u in P22.M.units_zh()}
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:
uid, src = u["uid"], u["source"]
if arm == "A6" and uid not in old:
if arm.startswith("A6") and uid not in old:
continue
if text_of(arm, uid).strip():
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():
print(f" {uid}: базы нет — пропуск")
continue
if arm == "A2": # однопроходка: редактора нет
msgs = PR.translator_msgs(src) if hasattr(PR, "translator_msgs") else None
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)
if arm == "A2": # однопроходка уравненного мандата
_buy(led, tag(arm, uid), a2_msgs(src), role="onepass", uid=uid, arm=arm)
continue
if arm in ("A1", "A4"):
if arm in ("A1", "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, draft)
places = ([f"КАНОН: термин {g} передан не канонной формой либо пропущен"
for g in canon_gaps(src, target)] if arm == "A4"
else flags_of(u, target))
if not places:
print(f" {uid}/{arm}: мест нет — чинить нечего, клетка не покупается")
_free_cell(tag(arm, uid), target, role="fixer", uid=uid, arm=arm, places=0)
continue
MONEY.purchase(led, tag(arm, uid), FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, fix_msgs(src, target, places)),
role="fixer", uid=uid, arm=arm, places=len(places))
_buy(led, tag(arm, uid), fix_msgs(src, target, places),
role="fixer", uid=uid, arm=arm, places=len(places))
continue
# A3 и A6: критик, затем фиксер по его списку
# A3 и A6: смысловой критик, затем фиксер по его списку
ct = tag(arm, uid, "crit")
if not (OUT / f"{ct}.json").exists():
MONEY.purchase(led, ct, FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, crit_msgs(src, draft)),
role="critic", uid=uid, arm=arm)
cf = OUT / f"{ct}.json"
if not cf.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
found = json.loads(cf.read_text(encoding="utf-8")).get("content") or ""
places = [ln.strip("- ").strip() for ln in found.splitlines() if ln.strip().startswith("-")]
places, found_n = critic_places(arm, uid)
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
MONEY.purchase(led, tag(arm, uid), FIXER, ED22.client(FIXER),
ROSTER.call_kwargs(FIXER, fix_msgs(src, draft, places[:40])),
role="fixer", uid=uid, arm=arm, places=len(places))
_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()
@ -292,7 +545,11 @@ if __name__ == "__main__":
a = sys.argv[1:] or ["--plan"]
if a[0] == "--selftest":
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))
else:
elif a[0] == "--floor":
cmd_floor()
elif a[0] == "--plan":
cmd_plan()
else:
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --plan --selftest --run --floor")

View file

@ -1,8 +1,20 @@
#!/usr/bin/env python3
"""Д6 — МАТЕРИАЛ ПАРЫ ja→ru: гардрейл, гейт прав, отбор единиц. $0.
Источник: `~/books/isekai_majutsushi_jp.txt` (файл владельца, вне git правило Р8). Площадка
`novel18.syosetu.com`, то есть срез ЗАВЕДОМО взрослый; аннотация книги сама называет 洗脳 и 暴力.
Источник: `~/books/enkan_no_hate_ja.txt` 円環の果てに 裏切られた魂二度目の生, `ncode.syosetu.com/n7233mn`,
первая публикация **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`):
**18+ уровень 3 несовершеннолетние в сексуальном контексте не обрабатывать и не анализировать
@ -30,7 +42,7 @@ import re
import sys
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"
MANIFEST = OUT / "manifest-ja.json"
TARGET_CHARS, MIN_CHARS, N_UNITS = 1100, 700, 9 # ja плотнее zh на знак: цель короче
@ -53,13 +65,20 @@ def text() -> str:
def chapters() -> list[dict]:
"""Главы по разделителю площадки; заголовок остаётся в теле — он часть материала."""
# ⚠ Разделитель — заголовок вида `=== 7 · 身に覚えのない署名 ===`, который ставит качалка.
# Первая редакция резала по `\n=+\n` и НЕ находила его: весь файл становился ОДНОЙ главой,
# отбор давал девять соседних кусков одной сцены, и прирост мощности был бы мнимым. Ровно
# эту ошибку эксп-23 §11 ловил у себя («главы эксп-22 исключены ЦЕЛИКОМ, иначе новые единицы
# делили бы с ними сцену и лексику»). Поймано печатью «глав 1».
raw = text()
parts = re.split(r"\n=+\n", raw)
out = []
for i, p in enumerate(parts):
p = p.strip()
if len(p) >= MIN_CHARS:
out.append(dict(idx=i, text=p))
for m in re.finditer(r"^=== (\d+) · (.*?) ===$\n(.*?)(?=^=== \d+ · |\Z)",
raw, re.M | re.S):
body = m.group(3).strip()
if len(body) >= MIN_CHARS:
out.append(dict(idx=int(m.group(1)), text=body))
if not out:
raise SystemExit("⛔ ни одной главы не распознано — разделитель не тот, отбор НЕ делается")
return out

View file

@ -7,13 +7,18 @@
сторож перед КАЖДЫМ вызовом по цене ИМЕННО ЭТОЙ модели, два независимых пути счёта. Копирование
размножило бы будущие расхождения вместо того, чтобы их ловить.
ПОТОЛОК ФАЗЫ САНКЦИЯ ВЛАДЕЛЬЦА $4.50 (10.08). История цифры: промт называл $10.00, владелец при
ПОТОЛОК ФАЗЫ САНКЦИЯ ВЛАДЕЛЬЦА $5.40 (11.08). История цифры: промт называл $10.00, владелец при
запуске сказал $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). Ре-гены эхо-мины и
ретраи в паках 22/23 съедали до +16% сверх сметы фазы. Сессия называла честным потолком $4.80;
владелец выбрал $4.50. Поэтому правило исполнения жёсткое и записано здесь, а не в прозе:
РЕЗЕРВ ФАЗЫ $0.00 и это объявлено риском ДО покупок ВТОРОЙ раз. При $4.50 резерв был $0.03,
и первый же промах сметы остановил ось Д4 на 22 клетках из 32. При $5.40 остатка нет вовсе:
честная смета остатка $4.277 плюс потраченные $1.140 дают $5.417, то есть уже $0.017 сверх
санкции, и Д1 добирает 15 клеток вместо 16 именно поэтому. Резерв 8% на ре-гены (история паков
22/23 до +16%) потребовал бы $5.85. Сказано ДО покупок, а не после.
Поэтому правило исполнения жёсткое и записано здесь, а не в прозе:
**срабатывание проекционного гарда = СТОП и вопрос владельцу.** Резать ось, панель или кандидата
решением сессии ЗАПРЕЩЕНО заказом; поднимать потолок себе тем более (пак 23 так и сделал, и это
стоило ему пункта CONFIRM/DENY).
@ -64,9 +69,23 @@ TAGS = {
# пакетный потолок. Первая редакция округляла каждый вверх до цента и дала сумму 4.54 > 4.50 —
# то есть фазовые гарды в совокупности разрешали пробить пакетный. Поймано селфтестом кассы
# ДО первой покупки; ровно этот класс («касса охраняет не ту границу») стоил эксп-21 денег трижды.
CEILINGS = {"ФД-A": 0.885, "ФД-B": 0.560, "ФД-C": 0.155, "ФД-D": 0.055,
"ФД-E": 2.355, "ФД-F": 0.283, "ФД-G": 0.202}
PACK_CEILING = 4.50
#
# ⚠⚠ ПЕРЕ-РАСКЛАДКА 11.08 ПОД САНКЦИЮ $5.40. Прежняя раскладка стояла на смете, где фиксер положен
# в 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,
PACK_CEILING=PACK_CEILING, GLOB_ALL="dv-*.json", ROSTER=ROSTER)

View file

@ -79,6 +79,21 @@ PRIMARY = {
"Д3 en→ru несущая": ["E0/D0 — КОНТРОЛЬ: покупает ли редактор поверх черновика (H-3)"],
"Д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 = {
"Д4 edit-контур zh": ["A2/A0 — однопроходка уравненного мандата (строка 153 бэклога)",
"A4/A0 — канон-фиксер ПОСЛЕ переписа: его несущая ось — покрытие "
@ -114,18 +129,36 @@ def _z(p: float) -> float:
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 контрастам.
КОНСТАНТА 2.8 ВЕРНА ТОЛЬКО ПРИ k=1, и первая редакция этого файла применяла её всегда.
Поймано адверсариальным ревью снизу вверх. Решающее правило пака `p` ХОЛМА < 0.05, то есть
на самый строгий контраст семейства приходится α/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, которую файл старательно вносил за межсессионную компоненту, и она переворачивала
вердикт: Д4 при k=5 давала MDE 1.57 против цели 1.50, то есть ОПИСАТЕЛЬНУЮ ось, объявленную
несущей. Это буквально провал 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)
@ -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"\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СЕМЕЙСТВА, объявленные ДО покупок:")
for name in [a[0] for a in AXES]:
if name in PRIMARY:
@ -166,7 +221,7 @@ def main() -> int:
for c in SECONDARY[name]:
print(f" · {c}")
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("\nПРОСТРАНСТВО ДИЗАЙНА — честный MDE (с ×1.23) при разных n и разных полах:")
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())