textmachine/eval/role_topology/effort_watch.py

209 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""ВАХТА РЕЕСТРА 108 — пере-проба маппинга `reasoning_effort` у `deepseek-v4-pro`.
Почему это первый платный шаг пака. Вся эмпирика экспов 1820 стоит на квирке §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()