diff --git a/docs/experiments/23-editor-tier.md b/docs/experiments/23-editor-tier.md index 7d0b57d6..241b0e09 100644 --- a/docs/experiments/23-editor-tier.md +++ b/docs/experiments/23-editor-tier.md @@ -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). Подозрение снято; остаётся мягкая правка формулировки флага. diff --git a/eval/dovodka/contour.py b/eval/dovodka/contour.py index a40f4d29..173e1b93 100644 --- a/eval/dovodka/contour.py +++ b/eval/dovodka/contour.py @@ -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") diff --git a/eval/dovodka/material_ja.py b/eval/dovodka/material_ja.py index 07d55d68..532d2af0 100644 --- a/eval/dovodka/material_ja.py +++ b/eval/dovodka/material_ja.py @@ -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 diff --git a/eval/dovodka/money_d.py b/eval/dovodka/money_d.py index f13ef15d..9cd2d6f6 100644 --- a/eval/dovodka/money_d.py +++ b/eval/dovodka/money_d.py @@ -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) diff --git a/eval/dovodka/power.py b/eval/dovodka/power.py index ddc66937..3f04a1df 100644 --- a/eval/dovodka/power.py +++ b/eval/dovodka/power.py @@ -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.22–1.36 БОЛЬШЕ + (t_{1−α/2k} + t_{0.8}), при k=5 это ≈3.6 вместо 2.8. Пропущенная поправка ×1.22–1.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)) diff --git a/eval/editor_tier/axiscal.py b/eval/editor_tier/axiscal.py new file mode 100644 index 00000000..245456ab --- /dev/null +++ b/eval/editor_tier/axiscal.py @@ -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())