209 lines
15 KiB
Python
209 lines
15 KiB
Python
#!/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()
|