#!/usr/bin/env python3 """ВАХТА РЕЕСТРА 108 — пере-проба маппинга `reasoning_effort` у `deepseek-v4-pro`. Почему это первый платный шаг пака. Вся эмпирика экспов 18–20 стоит на квирке §3а («на pro ручка `low` не работает, маппится в `high`»). Реестр загейченных триггеров (строка 108 бэклога) поставил дату-триггер: вендор объявил смену маппинга эффорта у pro на «early August 2026», и триггер ПРОШЁЛ. Замер 05.08 попытался его снять, получил «диапазоны не пересекаются при n=3» — и был ОТОЗВАН адверсариальным ревью того же дня: нулевой контраст (два блока ДЕФОЛТА на побайтно одном запросе) разделялся так же (p=0.100, тот же размер эффекта ×1.47), то есть решающее правило срабатывало при ОТСУТСТВИИ вмешательства. Дизайн задан не мной, а хвостом того отзыва (`00-provider-quirks.md` §3б, «Что нужно»): интерливинг дефолт/`low` в ОДНОМ блоке (защита от временного дрейфа — 6 дефолтных розыгрышей одного запроса уехали 1952→15720 знаков размышления строго монотонно) и n≥10 на арм. ⚠ ОБЪЯВЛЕННОЕ ОТКЛОНЕНИЕ ОТ ПРОШЛОЙ ПРОБЫ: вход КОМПАКТНЕЕ (~800 токенов против 2124). Основание — арифметика потолка, посчитанная по сырью, а не на глаз: медиана вызова прошлой пробы $0.004573, и 24 вызова дали бы $0.11 при потолке вахты $0.05. Клейм под проверкой («вендорский маппинг эффорта») от размера входа не зависит, а quirks §7 показывает, что размышление сильно масштабируется длиной плотного ханьского входа ⇒ компактный вход даёт БОЛЬШЕ розыгрышей на доллар, то есть больше мощности. Сравнимость с отозванными числами прошлой пробы при этом теряется — и она не нужна: проба самодостаточна, оба арма едут на побайтно одном входе. Гардрейл: thinking у DeepSeek НЕ отключается ни при каких условиях (эхо-мина, CLAUDE.md). Здесь меняется только УРОВЕНЬ эффорта, тумблер не трогается. Запуск: eval/.venv/bin/python eval/role_topology/effort_watch.py --pilot # 2 вызова, проекция цены eval/.venv/bin/python eval/role_topology/effort_watch.py # полный блок """ from __future__ import annotations import hashlib import json import statistics import sys import time from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") # ⚠ ПОРЯДОК ВСТАВОК ОБРАТНЫЙ ЖЕЛАЕМОМУ ПРИОРИТЕТУ: `insert(0, …)` кладёт последний путь ПЕРВЫМ. # В `eval/editor_contract/` лежит СВОЙ `probe.py` без функции `render`, и при обратном порядке он # затеняет нужный `editor_harness/probe.py` — эту же ловушку документирует самопроверка эксп-20. sys.path.insert(0, str(REPO / "eval" / "editor_contract")) sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) sys.path.insert(0, str(REPO / "eval" / "editor_harness")) import editor_wire_probe as P # noqa: E402 import probe as Q # noqa: E402 только рендер промпта # ⚠ ВЫЗОВ ИДЁТ ЧЕРЕЗ `editor_wire_probe.call`, а НЕ через `editor_harness.probe.call`, и это не # вкусовщина: у второго нет параметра `extra`, а его `think_low` для DeepSeek — no-op (ручка там # реализована только для провайдеров с `control: effort_none`, `probe.py:170-180`). Первая # редакция этого файла звала именно его и упала бы на первом вызове; хуже того, при другой # сигнатуре она молча прогнала бы ОБА арма одинаково и «не нашла разницы». Поймано чтением кода # до запуска — ровно то, ради чего мандат самопроверки требует ревью ИСПОЛНЕНИЕМ. MODEL = "deepseek-v4-pro" N_PER_ARM = 12 CEILING_USD = 0.05 # потолок вахты из промта эксп-21 OUT = Path.home() / "books" / "role-topology" OUT.mkdir(parents=True, exist_ok=True) # ⚠ `P.RAW` подменяется НЕ на импорте, а в `main()` ПОСЛЕ сборки запроса: из того же каталога # читаются готовые черновики пробы 18 (`draft_of` → `inj-w*-B.json`), и ранняя подмена ломала # чтение. Тоже поймано до первого платного вызова. def build_request() -> list[dict]: """Побайтно ОДИН запрос для всех розыгрышей обоих армов. Реалистичный редакторский вызов. Берётся боевой редакторский промпт и КОРОТКОЕ окно — так вызов остаётся тем же классом работы, что в проде, но стоит втрое дешевле длинного. Текст книги в репозиторий не попадает: он читается из `~/books` тем же кодом, что и остальные пробы. """ from inject_probe import pick_windows # noqa: PLC0415 _, src, _ = pick_windows()[5] # самое короткое окно набора draft = P.draft_of(5) # Урезаем до первых ~1200 знаков источника и соответствующей доли черновика: цель — компактный, # но связный редакторский вход, а не полное окно. src, draft = src[:1200], draft[:1600] sys_msg, user = Q.render(Q.CONTRACTS["full"], src, draft) return [{"role": "system", "content": sys_msg}, {"role": "user", "content": user}] def main() -> None: pilot = "--pilot" in sys.argv msgs = build_request() body = json.dumps(msgs, ensure_ascii=False, sort_keys=True).encode("utf-8") sha = hashlib.sha256(body).hexdigest()[:12] approx_in = sum(len(m["content"]) for m in msgs) // 3 print(f"запрос sha {sha} · ~{approx_in} токенов входа (оценка по знакам)") print("арм A = вендор-дефолт (параметр НЕ шлётся) · арм B = reasoning_effort:'low'") print("порядок ИНТЕРЛИВНЫЙ (A,B,A,B,…) — защита от временного дрейфа, quirks §3б\n") P.RAW = OUT # см. комментарий у OUT: подмена только ПОСЛЕ сборки запроса P.slug_check() # гардрейл CLAUDE.md: слаг сверяется живым /models в день запуска cl = P.client() rows: list[dict] = [] # ⚠ Потолок считается от ВСЕГО уже купленного вахтой, а не от текущего запуска: иначе # контрольный прогон стартовал бы с нуля и пробил бы общий лимит, оставаясь «в потолке» # по своему локальному счёту. Леджер = сумма персистированных артефактов, а не память. spent = sum(json.loads(f.read_text(encoding="utf-8"))["cost_usd"] for f in OUT.glob("ew-*.json")) if spent: print(f"уже куплено вахтой ранее: ${spent:.6f} — потолок считается от этой суммы\n") # Контролю нужен ЗНАК, а не оценка величины ⇒ розыгрышей меньше и они ограничены остатком. n = 1 if pilot else (4 if "--control" in sys.argv else N_PER_ARM) stop = False # ⚠ ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ `xhigh` — без него отрицательный результат по `low` НЕ # интерпретируем: «вендор игнорирует ручку» и «риг не видит смену эффорта вообще» дают # одинаковую картину. Про `xhigh` quirks §3б установил обратное — он РАБОТАЕТ и виден # серверно (`prompt_tokens` 2203 против 2124). Если мой риг увидит xhigh и не увидит low, # нуль по low становится содержательным; если не увидит и xhigh — риг негоден, и это # честный исход. Та же логика, что у декоя D39.46(б), применённая к проводу. # n меньше, чем у основных армов: контролю нужен ЗНАК, а не оценка величины, а остаток # потолка после основного блока конечен. arms = (("default", None), ("low", "low")) if "--control" in sys.argv: arms = (("xhigh", "xhigh"),) for i in range(n): for arm, effort in arms: tag = f"ew-{arm}-r{i + 1}" f = OUT / f"{tag}.json" if f.exists(): # идемпотентность: пере-запуск не пере-покупает rec = json.loads(f.read_text(encoding="utf-8")) else: # ПРОЕКЦИОННЫЙ ГАРД: стоп ДО вызова, который потолок пробьёт, а не после. projected = spent + (statistics.mean(r["cost"] for r in rows) if rows else 0.005) if projected > CEILING_USD: print(f"\n⛔ ПРОЕКЦИЯ ${projected:.4f} пробила бы потолок ${CEILING_USD} — " f"стоп механизмом на {len(rows)} вызовах") stop = True break rec = P.call(cl, msgs, tag, model=MODEL, extra={"reasoning_effort": effort} if effort else None) spent += rec["cost_usd"] rows.append(dict(arm=arm, draw=i + 1, cost=rec["cost_usd"], prompt_tokens=rec["prompt_tokens"], completion_tokens=rec["completion_tokens"], reasoning_chars=rec["reasoning_chars"], finish=rec["finish"])) time.sleep(0.3) if stop: break if pilot: per = statistics.mean(r["cost"] for r in rows) proj = per * N_PER_ARM * 2 print(f"\nПИЛОТ: средняя цена вызова ${per:.6f} ⇒ проекция полного блока " f"({N_PER_ARM}×2 вызовов) = ${proj:.4f} при потолке ${CEILING_USD}") print("ВЛЕЗАЕТ — можно гнать полный блок" if proj <= CEILING_USD else "НЕ ВЛЕЗАЕТ — стоп и пинг, не резать n молча") return report(rows, spent) def report(rows: list[dict], spent: float) -> None: from scipy.stats import mannwhitneyu # noqa: PLC0415 # Отчёт читает ВСЕ персистированные артефакты вахты, а не только розыгрыши текущего запуска: # контрольный арм докупается отдельным прогоном, и сводка обязана видеть его вместе с основными. allr = [json.loads(f.read_text(encoding="utf-8")) for f in sorted(OUT.glob("ew-*.json"))] byarm: dict[str, list[dict]] = {} for r in allr: byarm.setdefault(r["tag"].split("-")[1], []).append(r) a = byarm.get("default", []) b = byarm.get("low", []) c = byarm.get("xhigh", []) total = sum(r["cost_usd"] for r in allr) print(f"\nn: дефолт {len(a)} · low {len(b)} · xhigh(контроль) {len(c)} · " f"куплено вахтой всего ${total:.6f}") if c: print("\nПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ xhigh против дефолта:") for key in ("completion_tokens", "reasoning_chars"): ma = statistics.median(r[key] for r in a) mc = statistics.median(r[key] for r in c) p = mannwhitneyu([r[key] for r in a], [r[key] for r in c], alternative="two-sided").pvalue print(f" {key:20s} дефолт {ma:7.0f} · xhigh {mc:7.0f} · " f"отношение {mc / ma:5.2f} · p={p:.4f}") pc = sorted({r["prompt_tokens"] for r in c}) pa0 = sorted({r["prompt_tokens"] for r in a}) print(f" prompt_tokens дефолт {pa0} · xhigh {pc}" + (" ⇒ СЕРВЕРНАЯ РЕАКЦИЯ ЕСТЬ" if pc != pa0 else " ⇒ реакции нет")) if len(a) < 3 or len(b) < 3: print("данных по основным армам мало для вывода") return print(f"\n{'метрика':22s}{'дефолт (медиана)':>19s}{'low (медиана)':>16s}" f"{'отношение':>11s}{'p (MW)':>9s}") print("-" * 77) for key in ("completion_tokens", "reasoning_chars"): ma = statistics.median(r[key] for r in a) mb = statistics.median(r[key] for r in b) p = mannwhitneyu([r[key] for r in a], [r[key] for r in b], alternative="two-sided").pvalue print(f"{key:22s}{ma:19.0f}{mb:16.0f}{(mb / ma if ma else 0):11.3f}{p:9.4f}") pa = {r["prompt_tokens"] for r in a} pb = {r["prompt_tokens"] for r in b} print(f"\nprompt_tokens: дефолт {sorted(pa)} · low {sorted(pb)}") print(" ⇒ серверная реакция на параметр" if pa != pb else " ⇒ серверной реакции на параметр НЕТ (как у отозванного замера 05.08)") (OUT / "effort-watch.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") print("\nЧтение: квирк §3а («low→high, ручка не работает») ОСТАЁТСЯ В СИЛЕ, если различий нет;\n" "снимается только при значимом различии обеих метрик И серверной реакции prompt_tokens.") if __name__ == "__main__": main()