textmachine/eval/dovodka/chetyre.py

915 lines
58 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
# -*- coding: utf-8 -*-
"""chetyre.py — прибор контролируемой пробы редактора по четырём осям.
ЗАКОН: eval/dovodka/PLAN-01-09.md (пре-рег, фриз документа dc602e9). Этот файл — ИНСТРУМЕНТ,
он реализует правило решения §4, а НЕ выбирает его. Всякий порог здесь запинен ДО чисел.
⛔ ГЛАВНЫЙ ГАРД ФАЙЛА: прибор ОТКАЗЫВАЕТ (код 2), а не печатает «в пределах», когда знаменатель
пуст. «Зелень на пустоте» — способ, которым замер молча превращается в ничто.
────────────────────────────────────────────────────────────────────────────────────────────────
РЕКОНСТРУКЦИИ, НАЗВАННЫЕ ЯВНО (каждая — место, где прибор мог бы соврать)
1. НОМЕР ПОПЫТКИ. В `request_log` колонки `attempt` НЕТ (проверено `.schema`; положительный
контроль — она есть в `checkpoints`). Реконструкция: `request_log LEFT JOIN checkpoints ON
request_hash`. Проверено на холодном прогоне: 4 из 5 edit-строк находят чекпойнт, пятая —
`context canceled` с нулевой ценой и пустым request_hash. ⇒ строки БЕЗ чекпойнта считаются
вызовами-без-ответа: они входят в деньги и в счётчик, но не несут попытки и не дают клетки.
2. «ДОСТАВЛЕНО» (`delivered`). Меряется на ОТГРУЖАЕМОМ тексте, а не по имени флага.
Отгружаемый текст = `checkpoints.response_text` по `chunk_status.final_hash` (final_hash может
быть синтетическим `tm-sanitized-v1:<sha>` — он ТОЖЕ лежит в checkpoints, проверено).
delivered = 0 ⟺ диспозиция `skipped`, ИЛИ final_hash не резолвится, ИЛИ отгружаемый текст не
проходит $0-предикат доставки: доля кириллицы среди букв < 0.99 либо в тексте есть CJK.
Иначе delivered = длина отгружаемого текста в знаках.
⚠ ПОЧЕМУ НЕ «flagged ⇒ 0»: в холодном прогоне 1 из 3 редакторских юнитов — `sanitizer_stripped`
(CJK-утечка, санитайзер её вырезал и текст ОТГРУЖЕН, 6048 знаков). По правилу «flagged ⇒ 0»
гард «>20% пустых ⇒ прибор отказывает» сработал бы от санитайзера, а не от предмета замера.
Флаги при этом не теряются — они целиком идут в E5, разделённые по механизму.
⚠ ОБЪЯВЛЕНО ДО ЧИСЕЛ: строгий вариант (`flagged ⇒ delivered = 0`) считается ВСЕГДА и печатается
рядом как заранее объявленная проверка устойчивости. Выбор основного определения сделан до
первой платной клетки и после просмотра чисел не меняется.
3. РАЗМЫШЛЕНИЕ. Движок его не видит (`provider_openai.go` — `ReasoningTokens stays 0`; проверено
на базе холодного прогона: 0 строк из 68 с reasoning_tokens>0 при 38 строках с непустым
completion). Восстановление, коэффициент запинен пре-регом:
R̂ = completion_tokens 0.3644 · len(сырой response_text попытки)
⚠ Границы, названные планом ДО покупки: (1) любой CJK в выходе ломает оценку (иероглиф ≈1 токен)
⇒ R̂ завышается; (2) при R̂ < ~700 относительная ошибка 20-70%о величине таких клеток вывод не
делается; (3) берётся СЫРАЯ строка попытки, не `sanitized_export`; (4) типичная ошибка ~3%,
худшая клетка 5.3%.
У РЕФЕРЕНСА `glm-5` R̂ = 0 ПО ПОСТРОЕНИЮ, а не по формуле: у него другой токенизатор и
thinking выключен; формула напечатала бы «думает ±300 токенов» на модели, которая не думает.
4. ЦЕНЗУРА (правило §4, СИММЕТРИЧНО, записано до чисел). Клетка с `finish_reason='length'` на
попытке 0 цензурирована справа: её R̂ — НИЖНЯЯ граница. Знак пары (∅ против low):
· цензурирована ∅ — знак достоверен, пока R̂(∅) ≥ R̂(low); иначе НИЧЬЯ;
· цензурирована low — знак достоверен, только если R̂(∅) ≤ R̂(low); иначе НИЧЬЯ;
· цензурированы обе — НИЧЬЯ.
⚠ НИЧЬЯ ≠ ВЫБРОШЕНО: ничья = разность 0, и она СОХРАНЯЕТСЯ. Поэтому `zero_method='pratt'`
(умолчание `wilcox` нули ВЫБРАСЫВАЕТ — проверено исполнением на scipy 1.18.0).
5. КОНТРАСТ КРЕСТА. Главный эффект усилия на юните — среднее двух разностей:
d_u = ½[logR̂(P₇low)+logR̂(P₉low)] ½[logR̂(P₇∅)+logR̂(P₉∅)]
Именно у него sd = σ, а не σ√2 (план §4 «Мощность»): var(d) = 4·(σ²/4) = σ². Ось промпта (E3)
и взаимодействие (E4) — те же четыре клетки, другие знаки.
6. ДОВЕРИТЕЛЬНЫЙ ИНТЕРВАЛ ХОДЖЕСА-ЛЕМАНА — РУКАМИ. `scipy.stats.wilcoxon` интервала не отдаёт
(проверено исполнением: у результата поля statistic/pvalue/count/index, метода
confidence_interval нет). Реализовано по Уолшевым средним с рангово-точной отсечкой.
"""
import argparse
import json
import math
import os
import sqlite3
import sys
import unicodedata
from pathlib import Path
import numpy as np
from scipy.stats import wilcoxon, spearmanr, t as student_t
# ── ЗАПИНЕННОЕ ПРЕ-РЕГОМ (менять здесь = менять пре-рег; после чисел запрещено) ───────────────
COEF_VISIBLE = 0.3644 # токенов на знак видимого выхода pro-редактора zh→ru
ALPHA = 0.05 # двусторонне
SIGNATURE_TOLERANCE = 2 # ±2 токена от снятого пред-полётом значения
SIGNATURE_FAIL_SHARE = 0.20 # >20% юнитов вне допуска ⇒ проба аннулируется
EMPTY_REFUSE_SHARE = 0.20 # >20% пустых в руке КРЕСТА ⇒ прибор отказывает
SMALL_RHAT = 700 # ниже — о величине клетки вывод не делается
BAND = (0.8, 1.25) # описательная полоса; решения НЕ несёт
CROSS_ARMS = ["p7-off", "p7-low", "p9-off", "p9-low"]
REFERENCE_ARM = "glm-off"
ALL_ARMS = CROSS_ARMS + [REFERENCE_ARM]
OFF_ARMS = ["p7-off", "p9-off"]
LOW_ARMS = ["p7-low", "p9-low"]
P7_ARMS = ["p7-off", "p7-low"]
P9_ARMS = ["p9-off", "p9-low"]
# Ожидаемая подпись edit-стадии каждой руки — ГАРД ПРОТИВ СХЛОПЫВАНИЯ ОСИ.
# Хеши промптов запиниваются пре-регом; сюда они приходят из манифеста, а не из головы.
EXPECTED = {
"p7-off": {"prompt": "P7", "reasoning": "off", "model": "deepseek-v4-pro"},
"p7-low": {"prompt": "P7", "reasoning": "low", "model": "deepseek-v4-pro"},
"p9-off": {"prompt": "P9", "reasoning": "off", "model": "deepseek-v4-pro"},
"p9-low": {"prompt": "P9", "reasoning": "low", "model": "deepseek-v4-pro"},
"glm-off": {"prompt": "P9", "reasoning": "off", "model": "glm-5"},
}
# Пиковые цены за 1M токенов — из backend/configs/models.yaml. ⚠ Леджер букирует ПИК всегда,
# и число владельца тоже пиковое. Сверено с леджером холодного прогона до цента:
# строка pt=4412 cmpl=12011 → 4412·1.32/1e6 + 12011·3.96/1e6 = $0.053388 против $0.053387.
PRICES = {
"deepseek-v4-pro": {"in": 1.32, "cached": 0.044, "out": 3.96}, # models.yaml:198
"deepseek-v4-flash": {"in": 0.44, "cached": 0.014, "out": 1.32}, # models.yaml:183
"glm-5": {"in": 1.00, "cached": 0.200, "out": 3.20}, # models.yaml:223
}
REFUSAL = 2 # код выхода «прибор отказывает»
class Refuse(Exception):
"""Прибор отказывает: знаменатель пуст или гард дизайна не прошёл."""
def is_cjk(ch):
o = ord(ch)
return (0x4E00 <= o <= 0x9FFF or 0x3400 <= o <= 0x4DBF or 0xF900 <= o <= 0xFAFF
or 0x3040 <= o <= 0x30FF)
def delivery_predicate(text):
"""$0-предикат доставки. Возвращает (годен, доля_кириллицы, число_CJK)."""
if not text:
return False, 0.0, 0
cjk = sum(1 for c in text if is_cjk(c))
letters = [c for c in text if unicodedata.category(c).startswith("L")]
cyr = sum(1 for c in letters if "CYRILLIC" in unicodedata.name(c, ""))
share = cyr / len(letters) if letters else 0.0
return (share >= 0.99 and cjk == 0), share, cjk
# ── ЧТЕНИЕ БАЗ ───────────────────────────────────────────────────────────────────────────────
def open_db(path):
if not Path(path).exists():
raise Refuse(f"базы нет: {path} (положительный контроль: ls каталога рук)")
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
return con
def read_arm(path, arm):
"""Все edit-клетки одной руки. Ключ клетки — (chapter, chunk_idx)."""
con = open_db(path)
cells = {}
# Реконструкция №1: попытка приходит из checkpoints через request_hash.
rows = con.execute("""
select rl.id, rl.chapter, rl.chunk_idx, rl.model_requested, rl.model_actual,
rl.prompt_tokens, rl.cached_tokens, rl.completion_tokens, rl.reasoning_tokens,
rl.cost_usd, rl.finish_reason, rl.tm_hit, rl.ok, rl.err, rl.request_hash,
cp.attempt as attempt, cp.response_text as resp
from request_log rl left join checkpoints cp on cp.request_hash = rl.request_hash
where rl.stage = 'edit' order by rl.id
""").fetchall()
for r in rows:
key = (r["chapter"], r["chunk_idx"])
c = cells.setdefault(key, {"arm": arm, "unit": key, "calls": [], "orphan_calls": 0})
resp = r["resp"]
call = {
"id": r["id"], "attempt": r["attempt"], "model": r["model_requested"],
"prompt_tokens": r["prompt_tokens"], "cached_tokens": r["cached_tokens"],
"completion_tokens": r["completion_tokens"],
"reasoning_tokens_engine": r["reasoning_tokens"],
"cost_usd": r["cost_usd"], "finish": r["finish_reason"],
"tm_hit": r["tm_hit"], "ok": r["ok"], "err": r["err"],
"resp_len": len(resp) if resp is not None else None,
"resp": resp,
}
if r["attempt"] is None:
c["orphan_calls"] += 1
c["calls"].append(call)
# Диспозиция и отгружаемый текст
for r in con.execute("""
select cs.chapter, cs.chunk_idx, cs.disposition, cs.flag_reason, cs.first_flag_reason,
cs.attempts, cs.cost_usd, cs.final_hash, cs.detail, cs.snapshot_id,
(select c.response_text from checkpoints c where c.request_hash = cs.final_hash) as shipped
from chunk_status cs where cs.stage = 'edit'
"""):
key = (r["chapter"], r["chunk_idx"])
c = cells.setdefault(key, {"arm": arm, "unit": key, "calls": [], "orphan_calls": 0})
shipped = r["shipped"]
good, cyr, cjk = delivery_predicate(shipped or "")
c.update({
"disposition": r["disposition"], "flag_reason": r["flag_reason"],
"first_flag_reason": r["first_flag_reason"], "attempts": r["attempts"],
"unit_cost_usd": r["cost_usd"], "final_hash": r["final_hash"],
"detail": r["detail"], "unit_snapshot": r["snapshot_id"],
"shipped_len": len(shipped) if shipped else 0,
"cyr_share": cyr, "cjk_in_shipped": cjk,
"delivered": (len(shipped) if (good and r["disposition"] != "skipped") else 0),
# строгий вариант, объявленный ДО чисел
"delivered_strict": (len(shipped) if (good and r["disposition"] == "ok") else 0),
})
# Длина черновика по ЧАНКАМ. ⚠ Это ещё НЕ длина входа редакторского юнита: юнит редактуры
# охватывает несколько чанков черновика, и в этом стенде таких 15 из 20. Сборка юнита из чанков
# делается по манифесту движка — см. unit_draft_lengths().
draft_len = {}
for r in con.execute("""
select cs.chapter, cs.chunk_idx,
(select length(c.response_text) from checkpoints c where c.request_hash = cs.final_hash) as n
from chunk_status cs where cs.stage = 'draft'
"""):
draft_len[(r["chapter"], r["chunk_idx"])] = r["n"]
snaps = {r["snapshot_id"]: r["payload"] for r in con.execute("select snapshot_id, payload from snapshots")}
jobs = [dict(r) for r in con.execute("select chapter, stage, status, snapshot_id from jobs")]
spend = con.execute("select coalesce(sum(committed_usd+reserved_usd),0) from spend").fetchone()[0]
con.close()
return cells, draft_len, snaps, jobs, spend
def unit_draft_lengths(manifest_path, chunk_len):
"""Длина ВХОДА редакторского юнита = сумма его чанков черновика.
⛔ ЗАЧЕМ ОТДЕЛЬНАЯ ФУНКЦИЯ, А НЕ chunk_len НАПРЯМУЮ: юнит редактуры и чанк черновика — РАЗНЫЕ
единицы. Манифест этого стенда: 20 юнитов на 35 чанков, и у 15 юнитов из 20 `chunk_count > 1`.
Взяв длину только первого чанка (ключ (глава, chunk_idx) совпадает у обоих!), ковариата длины
занизилась бы у трёх четвертей выборки — и это НЕ дало бы ошибки, а тихо испортило бы проверку.
Соответствие: юнит (глава, k) есть k-й элемент `chapters[глава1].units`, а его чанки черновика —
`first_chunk_idx … first_chunk_idx + chunk_count 1` в той же главе.
"""
m = json.loads(Path(manifest_path).read_text())
out = {}
for ch in m.get("chapters", []):
num = ch["number"]
for k, u in enumerate(ch.get("units", [])):
total, missing = 0, 0
for ci in range(u["first_chunk_idx"], u["first_chunk_idx"] + u["chunk_count"]):
n = chunk_len.get((num, ci))
if n is None:
missing += 1
else:
total += n
out[(num, k)] = None if missing else total
return out
def rhat(call, arm):
"""Восстановленное размышление. У референса — 0 ПО ПОСТРОЕНИЮ (реконструкция №3)."""
if arm == REFERENCE_ARM:
return 0.0
if call["resp_len"] is None or call["completion_tokens"] is None:
return None
return call["completion_tokens"] - COEF_VISIBLE * call["resp_len"]
def attempt0(cell):
"""Попытка 0 клетки.
⚠ ЗАЩИТА, КУПЛЕННАЯ ЧТЕНИЕМ ДАННЫХ, А НЕ ДОГАДКОЙ: интерливинг гоняет каждую руку по 20 раз с
`--max-units 1`, и КАЖДЫЙ такой прогон дописывает в `request_log` строку-ПОВТОР уже оплаченной
клетки с `tm_hit=1`. Проверено на базе холодного прогона: у 29 строк с `tm_hit=1` ровно нули —
`prompt_tokens=0, completion_tokens=0, cost_usd=0` — а `request_hash` у них ТОТ ЖЕ, то есть они
соединяются с тем же чекпойнтом и несут `attempt=0`. Взять такую строку за попытку 0 значило бы
посчитать R̂ от нулей. Поэтому настоящий вызов (`tm_hit=0`) выбирается ЯВНО, а не по порядку id.
"""
hits = [c for c in cell.get("calls", []) if c["attempt"] == 0]
for c in hits:
if not c["tm_hit"]:
return c
return hits[0] if hits else None
# ── ГАРДЫ ДИЗАЙНА ────────────────────────────────────────────────────────────────────────────
def stage_of(payload, name):
p = json.loads(payload)
for s in p.get("stages", []):
if s.get("name") == name:
return s, p
return None, p
def gate_axis(arm_data, pins, out):
"""Не схлопнулась ли ось. Сильнее, чем «пять разных snapshot_id»: сверяем ПОЛЯ."""
out.append("## ГАРД 1 — ОСЬ НЕ СХЛОПНУЛАСЬ (edit-снапшот каждой руки)")
edit_ids, draft_ids, problems = {}, {}, []
for arm in ALL_ARMS:
snaps, jobs = arm_data[arm]["snaps"], arm_data[arm]["jobs"]
eids = {j["snapshot_id"] for j in jobs if j["stage"] == "edit"}
dids = {j["snapshot_id"] for j in jobs if j["stage"] == "draft"}
edit_ids[arm], draft_ids[arm] = eids, dids
seen = set()
for sid in eids:
st, _ = stage_of(snaps[sid], "edit")
if st is None:
problems.append(f"{arm}: в снапшоте {sid[:12]} нет стадии edit")
continue
got = (st.get("prompt_sha256"), st.get("reasoning"), st.get("model"))
seen.add(got)
want = EXPECTED[arm]
wsha = pins[want["prompt"]]
if got[0] != wsha:
problems.append(f"{arm}: prompt_sha256 {got[0][:12]}… ≠ запиненного {want['prompt']} {wsha[:12]}")
if got[1] != want["reasoning"]:
problems.append(f"{arm}: reasoning '{got[1]}''{want['reasoning']}'ОСЬ УСИЛИЯ НЕ УЕХАЛА НА ПРОВОД")
if got[2] != want["model"]:
problems.append(f"{arm}: model '{got[2]}''{want['model']}'")
for sha, reas, mdl in sorted(seen):
out.append(f" {arm:8s} edit: prompt_sha256 {sha[:16]}… reasoning '{reas}' model {mdl}")
all_edit = set().union(*edit_ids.values()) if edit_ids else set()
out.append(f" различных edit-снапшотов по пяти рукам: {len(all_edit)} (нужно ≥5)")
if len(all_edit) < 5:
problems.append(f"edit-снапшотов всего {len(all_edit)} — руки схлопнулись, платили за пять, мерили меньше")
all_draft = set().union(*draft_ids.values()) if draft_ids else set()
out.append(f" различных draft-снапшотов по пяти рукам: {len(all_draft)} (нужно РОВНО 1 — иначе черновики перекуплены)")
if len(all_draft) != 1:
problems.append(f"draft-снапшотов {len(all_draft)} ≠ 1: {sorted(s[:12] for s in all_draft)} — черновики НЕ общие")
return problems
def gate_signature(arm_data, pinned, out):
"""Сигнатура Δprompt_tokens(∅ low) по каждому промпту. Косвенная улика провода."""
out.append("\n## ГАРД 2 — СИГНАТУРА Δprompt_tokens (∅ low), попытка 0")
res, bad, total = {}, 0, 0
for label, (a_off, a_low) in (("P7", ("p7-off", "p7-low")), ("P9", ("p9-off", "p9-low"))):
deltas = {}
for unit in sorted(set(arm_data[a_off]["cells"]) & set(arm_data[a_low]["cells"])):
c0, c1 = attempt0(arm_data[a_off]["cells"][unit]), attempt0(arm_data[a_low]["cells"][unit])
if not c0 or not c1:
continue
deltas[unit] = c0["prompt_tokens"] - c1["prompt_tokens"]
res[label] = deltas
if not deltas:
out.append(f" {label}: нет ни одной пары попыток 0 — сигнатура НЕ СНЯТА")
continue
vals = list(deltas.values())
uniq = sorted(set(vals))
out.append(f" {label}: n={len(vals)} различных значений {uniq} медиана {int(np.median(vals))}")
pin = pinned.get(label)
if pin is None:
out.append(f" (значение НЕ ЗАПИНЕНО — это пред-полёт: оно и есть то, что пинится коммитом)")
else:
off = [u for u, v in deltas.items() if abs(v - pin) > SIGNATURE_TOLERANCE]
total += len(vals)
bad += len(off)
out.append(f" запинено {pin} ±{SIGNATURE_TOLERANCE} ⇒ вне допуска {len(off)}/{len(vals)}")
problems = []
if total and bad / total > SIGNATURE_FAIL_SHARE:
problems.append(f"сигнатура не воспроизвелась у {bad}/{total} = {bad/total:.0%} юнитов (>{SIGNATURE_FAIL_SHARE:.0%}) ⇒ ПРОБА АННУЛИРУЕТСЯ")
return res, problems
def gate_empty(arm_data, out):
"""⛔ Зелень на пустоте. Правило действует ТОЛЬКО на четыре руки креста (план §5.2)."""
out.append("\n## ГАРД 3 — ЗЕЛЕНЬ НА ПУСТОТЕ (delivered = 0)")
problems = []
for arm in ALL_ARMS:
cells = arm_data[arm]["cells"]
done = [c for c in cells.values() if "delivered" in c]
if not done:
out.append(f" {arm:8s}: ни одной завершённой клетки")
continue
empty = [c for c in done if c["delivered"] == 0]
empty_s = [c for c in done if c["delivered_strict"] == 0]
share = len(empty) / len(done)
mark = ""
if arm in CROSS_ARMS and share > EMPTY_REFUSE_SHARE:
problems.append(f"{arm}: пустых {len(empty)}/{len(done)} = {share:.0%} > {EMPTY_REFUSE_SHARE:.0%} ⇒ ПРИБОР ОТКАЗЫВАЕТ")
mark = ""
if arm == REFERENCE_ARM and share > EMPTY_REFUSE_SHARE:
mark = " (референс: в E0 не печатается, доля идёт строкой в E5 — план §5.2)"
out.append(f" {arm:8s}: пустых {len(empty)}/{len(done)} = {share:.0%}"
f" | строгий вариант {len(empty_s)}/{len(done)} = {len(empty_s)/len(done):.0%}{mark}")
return problems
# ── СТАТИСТИКА ───────────────────────────────────────────────────────────────────────────────
def hodges_lehmann(d):
"""Медиана Ходжеса-Лемана = медиана Уолшевых средних (d_i+d_j)/2, i ≤ j."""
d = np.asarray(d, dtype=float)
n = len(d)
if n == 0:
return None
w = np.array([(d[i] + d[j]) / 2 for i in range(n) for j in range(i, n)])
return float(np.median(w)), w
def hl_ci(d, alpha=ALPHA):
"""95% ДИ Ходжеса-Лемана. РУКАМИ: scipy.wilcoxon интервала не отдаёт (проверено).
Отсечка k по нормальному приближению знаково-рангового распределения с поправкой на
непрерывность. Для маленьких n это приближение — ЗАЯВЛЕННОЕ ограничение, а не скрытое.
"""
d = np.asarray(d, dtype=float)
n = len(d)
if n < 6:
return None
hl, w = hodges_lehmann(d)
w = np.sort(w)
m = len(w)
from scipy.stats import norm
z = norm.ppf(1 - alpha / 2)
se = math.sqrt(n * (n + 1) * (2 * n + 1) / 24.0)
k = int(math.floor(n * (n + 1) / 4.0 - z * se - 0.5))
if k < 0:
k = 0
if k >= m // 2:
return None
return float(w[k]), float(w[m - 1 - k])
def paired_contrast(arm_data, unit, off_arm, low_arm):
"""Разность log R̂(low) log R̂(∅) одной пары, с симметричным правилом цензуры §4.
Возвращает (значение, статус) где статус ∈ {ok, tie-censored, missing, small}.
"""
co = arm_data[off_arm]["cells"].get(unit)
cl = arm_data[low_arm]["cells"].get(unit)
if not co or not cl:
return None, "missing"
a0o, a0l = attempt0(co), attempt0(cl)
if not a0o or not a0l:
return None, "missing"
ro, rl = rhat(a0o, off_arm), rhat(a0l, low_arm)
if ro is None or rl is None or ro <= 0 or rl <= 0:
return None, "missing"
cens_o = a0o["finish"] == "length"
cens_l = a0l["finish"] == "length"
if cens_o and cens_l:
return 0.0, "tie-censored"
if cens_o and not (ro >= rl):
return 0.0, "tie-censored"
if cens_l and not (ro <= rl):
return 0.0, "tie-censored"
return math.log(rl) - math.log(ro), ("ok-censored" if (cens_o or cens_l) else "ok")
def endpoint_test(name, values, out, note=""):
"""Вилкоксон с pratt + величина Ходжеса-Лемана с ДИ. Печатает вывод при ЛЮБОМ исходе."""
out.append(f"\n### {name}")
if note:
out.append(f" {note}")
vals = [v for v in values if v is not None]
if len(vals) == 0:
raise Refuse(f"{name}: годных юнитов НОЛЬ — знаменатель пуст, прибор ОТКАЗЫВАЕТ")
out.append(f" юнитов в тесте: {len(vals)} (ничьих-цензур: {sum(1 for v in vals if v == 0)})")
if len(vals) < 6:
out.append(f" ⛔ n={len(vals)} — ниже разрешения любого рангового теста; величина не печатается")
return {"n": len(vals), "p": None, "hl": None, "ci": None}
res = wilcoxon(vals, zero_method="pratt") # ⛔ pratt пинится: умолчание wilcox нули ВЫБРАСЫВАЕТ
hl, _ = hodges_lehmann(vals)
ci = hl_ci(vals)
ratio = math.exp(hl)
out.append(f" Вилкоксон (zero_method='pratt'): W={res.statistic:.1f} p={res.pvalue:.4g}")
out.append(f" Ходжес-Леман: медиана log = {hl:+.4f} ⇒ отношение ×{ratio:.3f}")
if ci:
out.append(f" 95% ДИ (руками, Уолшевы средние): log [{ci[0]:+.4f}; {ci[1]:+.4f}]"
f"×[{math.exp(ci[0]):.3f}; {math.exp(ci[1]):.3f}]")
inband = BAND[0] <= math.exp(ci[0]) and math.exp(ci[1]) <= BAND[1]
out.append(f" описательная полоса [{BAND[0]};{BAND[1]}]: ДИ {'внутри' if inband else 'НЕ внутри'}"
f" — решения НЕ несёт (план §4)")
else:
out.append(" 95% ДИ: не определён при этом n (отсечка ушла за край) — заявлено, не скрыто")
if res.pvalue < ALPHA:
out.append(f" ⇒ ВЫВОД: эффект ЕСТЬ (p<{ALPHA}). Величина — выше.")
else:
out.append(f" ⇒ ВЫВОД: НИЖЕ РАЗРЕШЕНИЯ ЗАМЕРА (p={res.pvalue:.3g}{ALPHA}). Это НЕ «эффекта нет».")
return {"n": len(vals), "p": float(res.pvalue), "hl": hl, "ci": ci}
# ── ЭНДПОЙНТЫ ────────────────────────────────────────────────────────────────────────────────
def e0_decomposition(arm_data, out):
"""E0 — ПОЧЕМУ дорого: цена по статьям, сложенная по ВСЕМ попыткам."""
out.append("\n## E0 — РАЗЛОЖЕНИЕ ЦЕНЫ ПО СТАТЬЯМ (все попытки, $0)")
out.append(f" {'рука':8s} {'клеток':>6s} {'вход':>9s} {'кэш':>9s} {'вид.выход':>10s} {'размышл.':>10s} {'ретраи':>9s} {'ИТОГО':>9s}")
table = {}
for arm in ALL_ARMS:
cells = arm_data[arm]["cells"]
art = {"in": 0.0, "cache": 0.0, "vis": 0.0, "reas": 0.0, "retry": 0.0, "total": 0.0}
n = 0
for cell in cells.values():
if not cell.get("calls"):
continue
n += 1
for c in cell["calls"]:
if c["tm_hit"] or c["completion_tokens"] is None:
continue
pr = PRICES.get(c["model"])
if not pr:
continue
fresh_in = max(0, (c["prompt_tokens"] or 0) - (c["cached_tokens"] or 0))
art["in"] += fresh_in / 1e6 * pr["in"]
art["cache"] += (c["cached_tokens"] or 0) / 1e6 * pr["cached"]
r = rhat(c, arm)
vis_tok = (c["completion_tokens"] - r) if r is not None else c["completion_tokens"]
vis_tok = max(0.0, vis_tok)
r = max(0.0, r or 0.0)
art["vis"] += vis_tok / 1e6 * pr["out"]
art["reas"] += r / 1e6 * pr["out"]
art["total"] += c["cost_usd"] or 0.0
if (c["attempt"] or 0) >= 1:
art["retry"] += c["cost_usd"] or 0.0
table[arm] = art
if arm == REFERENCE_ARM:
done = [c for c in cells.values() if "delivered" in c]
empty = sum(1 for c in done if c["delivered"] == 0)
if done and empty / len(done) > EMPTY_REFUSE_SHARE:
out.append(f" {arm:8s}НЕ ПЕЧАТАЕТСЯ: пустых {empty}/{len(done)} > {EMPTY_REFUSE_SHARE:.0%}"
f" (план §5.2; доля идёт строкой в E5)")
continue
out.append(f" {arm:8s} {n:6d} {art['in']:9.5f} {art['cache']:9.5f} {art['vis']:10.5f}"
f" {art['reas']:10.5f} {art['retry']:9.5f} {art['total']:9.5f}")
out.append("\n ДОЛИ от суммы статей (вход+кэш+видимый+размышление):")
for arm, art in table.items():
s = art["in"] + art["cache"] + art["vis"] + art["reas"]
if s <= 0:
continue
out.append(f" {arm:8s} вход {art['in']/s:5.1%} кэш {art['cache']/s:5.1%}"
f" видимый выход {art['vis']/s:5.1%} РАЗМЫШЛЕНИЕ {art['reas']/s:5.1%}"
f" | ретраи {art['retry']/art['total']:5.1%} от цены руки" if art["total"] else "")
out.append(" ⚠ Одна статья невосстановима никакой пробой и объявлена заранее: что из подорожания")
out.append(" дали НОВЫЕ ВЕСА и смена вендор-дефолта — июльское состояние сервинга не вернуть.")
return table
def cell_cost_nocache(call, arm):
"""Цена клетки, посчитанная так, будто префикс-кэша НЕ БЫЛО (весь вход по полной ставке).
⛔ ЗАЧЕМ. Порядок клеток рандомизирован, но при ЗАПИНЕННОМ сиде выпавшая перестановка на n=20
несимметрична, и это посчитано ДО чисел: `p7-low` идёт раньше `p7-off` в 15 юнитах из 20
(у пары P9 — 9 из 20), первой клеткой юнита `p7-low` бывает 10 раз против 1 у `p7-off`.
Первая клетка юнита кэш не бьёт и потому дороже примерно на 6000·($1.32$0.044)/1e6 ≈ $0.0077.
⇒ E2 у пары P7 систематически наклонён ПРОТИВ `low`. Сид — часть пре-рега и НЕ трогается;
вместо этого печатается вторая, кэш-независимая версия E2, объявленная до просмотра чисел.
"""
pr = PRICES.get(call["model"])
if not pr or call["completion_tokens"] is None:
return None
return ((call["prompt_tokens"] or 0) / 1e6 * pr["in"]
+ (call["completion_tokens"] or 0) / 1e6 * pr["out"])
def e2_cost_per_delivered(arm_data, units, out):
"""E2 — цена доставленного знака, в четырёх заранее объявленных вариантах."""
out.append("\n## E2 — ЦЕНА ДОСТАВЛЕННОГО ЗНАКА")
out.append(" Четыре варианта объявлены ДО чисел: два по определению «доставлено» (мягкое ×")
out.append(" строгое) и два по цене (фактическая × кэш-независимая). Основной — первый.")
res = {}
VARIANTS = [
("попытка 0 · фактическая цена (ОСНОВНОЙ, равный потолок)", False, "delivered", False),
("итог юнита · фактическая цена (боевая цена, ретраи включены)", True, "delivered", False),
("попытка 0 · СТРОГОЕ delivered (flagged ⇒ 0)", False, "delivered_strict", False),
("попытка 0 · КЭШ-НЕЗАВИСИМАЯ цена (снимает перекос порядка)", False, "delivered", True),
]
for label, use_unit_cost, dkey, nocache in VARIANTS:
vals, skipped = [], 0
for u in units:
per = {}
for arm in CROSS_ARMS:
c = arm_data[arm]["cells"].get(u)
if not c or dkey not in c or c[dkey] == 0:
per = None
break
a0 = attempt0(c)
if use_unit_cost:
cost = c["unit_cost_usd"]
elif nocache:
cost = cell_cost_nocache(a0, arm) if a0 else None
else:
cost = a0["cost_usd"] if a0 else None
if not cost or cost <= 0:
per = None
break
per[arm] = math.log(cost / c[dkey])
if per is None:
skipped += 1
continue
vals.append(0.5 * (per["p7-low"] + per["p9-low"]) - 0.5 * (per["p7-off"] + per["p9-off"]))
primary = label.startswith("попытка 0 · фактическая")
if primary:
# ⛔ У ОСНОВНОГО варианта отказ прибора НЕ ПЕРЕХВАТЫВАЕТСЯ: пустой знаменатель здесь —
# это и есть «зелень на пустоте», ради которой гард существует.
res[label] = endpoint_test(f"E2 · {label}", vals, out,
note=f"пропущено юнитов (пустая доставка или нет цены): {skipped}")
else:
# У ЧУВСТВИТЕЛЬНОСТЕЙ пустота — законный исход (строгое delivered может обнулить всё),
# и она печатается как пустота, а не роняет весь отчёт.
try:
res[label] = endpoint_test(f"E2 · {label}", vals, out,
note=f"пропущено юнитов: {skipped}")
except Refuse as e:
out.append(f"\n### E2 · {label}\n ⛔ ЧУВСТВИТЕЛЬНОСТЬ НЕ СЧИТАЕТСЯ: {e}")
res[label] = None
return res
def e5_safety(arm_data, out):
"""E5 — безопасность. Блокирует любую рекомендацию, считается по механизму, не по имени.
⛔ ГЛАВНАЯ ПРАВКА ПОСЛЕ ВЕРИФИКАЦИИ: эхо считается по СЫРОМУ ответу попытки 0, а НЕ по
отгружаемому тексту. Санитайзер CJK ВЫРЕЗАЕТ, и эндпойнт, блокирующий любую рекомендацию, был
бы слеп ровно к своему предмету. Улика с холодного прогона, снята командой:
юнит (1,0), flag=sanitizer_stripped: CJK в отгруженном = 0, CJK в СЫРОМ ответе = 10.
Колонка по отгруженному остаётся ВТОРОЙ — она отвечает на другой вопрос («что увидел читатель»).
"""
out.append("\n## E5 — БЕЗОПАСНОСТЬ (эхо / CJK / обрыв по длине)")
out.append(" ⚠ «CJK@сырой» — по СЫРОМУ ответу попытки 0 (санитайзер эхо вырезает, и по")
out.append(" отгруженному тексту эхо не видно вовсе). «CJK@отгр» — что осталось читателю.")
out.append(f" {'рука':8s} {'клеток':>6s} {'CJK@сырой':>9s} {'CJK@отгр':>8s} {'кир<0.99':>8s}"
f" {'flagged':>7s} {'length@0':>8s} {'санитайз':>8s} {'малый R̂':>8s}")
summary = {}
for arm in ALL_ARMS:
cells = [c for c in arm_data[arm]["cells"].values() if "delivered" in c]
if not cells:
continue
cjk_ship = sum(1 for c in cells if c["cjk_in_shipped"] > 0)
lowcyr = sum(1 for c in cells if c["cyr_share"] < 0.99)
flagged = sum(1 for c in cells if c["disposition"] == "flagged")
sanit = sum(1 for c in cells if "sanitiz" in (c["flag_reason"] or ""))
length0 = cjk_raw = small = 0
for c in cells:
a0 = attempt0(c)
if not a0:
continue
if a0["finish"] == "length":
length0 += 1
_, _, ncjk = delivery_predicate(a0["resp"] or "")
if ncjk > 0:
cjk_raw += 1
r = rhat(a0, arm)
if arm != REFERENCE_ARM and r is not None and 0 < r < SMALL_RHAT:
small += 1
summary[arm] = {"n": len(cells), "cjk_raw": cjk_raw, "cjk_shipped": cjk_ship,
"lowcyr": lowcyr, "flagged": flagged, "length0": length0,
"sanitizer": sanit, "small_rhat": small}
out.append(f" {arm:8s} {len(cells):6d} {cjk_raw:9d} {cjk_ship:8d} {lowcyr:8d}"
f" {flagged:7d} {length0:8d} {sanit:8d} {small:8d}")
out.append(f" ⚠ «малый R̂» — клетки с R̂ < {SMALL_RHAT} токенов: у них относительная ошибка формулы")
out.append(" восстановления 20-70% (план §3), и о ВЕЛИЧИНЕ таких клеток вывод не делается.")
echo_off = sum(summary.get(a, {}).get("cjk_raw", 0) for a in OFF_ARMS)
echo_low = sum(summary.get(a, {}).get("cjk_raw", 0) for a in LOW_ARMS)
out.append(f"\n эхо-клеток: у{echo_off}, у low {echo_low}")
if echo_low - echo_off >= 3:
out.append(f" ⛔ РИСК ЭХА НА low: у low на {echo_low-echo_off} эхо-клеток больше — блокирует рекомендацию НЕЗАВИСИМО ОТ ЦЕНЫ")
else:
out.append(" риск эха на low по правилу §4 (≥3 сверх ∅) НЕ предъявлен")
out.append(" ⚠ Референс glm-5 идёт СТРОКОЙ ОТДЕЛЬНО: у него thinking ВЫКЛЮЧЕН, а эхо-мина")
out.append(" вооружается именно выключением — его доля эха к оси усилия не относится.")
return summary
def covariate_check(arm_data, units, contrasts, out, dl):
"""Ковариата длины входа — с ЗАЯВЛЕННОЙ находкой о том, почему модель плана вырождена."""
out.append("\n## КОВАРИАТА ДЛИНЫ ВХОДА")
out.append(" ⛔ НАХОДКА (предъявляется, а не правится молча): модель плана")
out.append(" `log R̂ ~ effort + prompt + log(len_draft) + (1|unit)` НЕ МОЖЕТ быть проверкой")
out.append(" устойчивости E1/E3. Длина черновика — величина УНИТ-УРОВНЕВАЯ: все четыре руки")
out.append(" редактируют ОДИН И ТОТ ЖЕ черновик, это и есть блокировка трудности. В")
out.append(" сбалансированном внутри-юнитном дизайне юнит-уровневая ковариата НЕ МЕНЯЕТ")
out.append(" оценок усилия и промпта ПО ПОСТРОЕНИЮ (её вклад целиком лежит в между-юнитной")
out.append(" части, которую съедает случайный перехват). Это свойство дизайна, а не нехватка")
out.append(" библиотеки; сам наклон длины при этом оценим — он ниже, пункт (2).")
out.append(" ⇒ Вместо «проверки устойчивости» считается то, что оценимо и отвечает на ту же")
out.append(" тревогу («не длиной ли входа объясняется эффект»): зависимость ЮНИТНОГО")
out.append(" КОНТРАСТА от длины черновика.")
xs, ys = [], []
for u, d in zip(units, contrasts):
n = dl.get(u)
if d is None or not n:
continue
xs.append(math.log(n))
ys.append(d)
if len(xs) < 6:
out.append(f" n={len(xs)} — недостаточно, проверка ОБЪЯВЛЕНА НЕИСПОЛНЕННОЙ")
return None
x, y = np.array(xs), np.array(ys)
rho, prho = spearmanr(x, y)
# OLS руками: наклон, стандартная ошибка, t-тест (statsmodels не нужен и не установлен)
xm, ym = x.mean(), y.mean()
sxx = ((x - xm) ** 2).sum()
b = ((x - xm) * (y - ym)).sum() / sxx
a = ym - b * xm
resid = y - (a + b * x)
dof = len(x) - 2
s2 = (resid ** 2).sum() / dof
seb = math.sqrt(s2 / sxx)
tstat = b / seb if seb > 0 else float("nan")
p = 2 * (1 - student_t.cdf(abs(tstat), dof))
out.append(f" (1) ВНУТРИ-ЮНИТНАЯ УСТОЙЧИВОСТЬ: зависит ли САМ контраст от длины входа")
out.append(f" n={len(x)} Спирмен ρ={rho:+.3f} (p={prho:.3g})")
out.append(f" МНК контраст ~ log(len_draft): наклон {b:+.4f} ± {seb:.4f}, t={tstat:+.2f}, p={p:.3g}, df={dof}")
out.append(f"{'контраст зависит от длины входа' if p < ALPHA else 'зависимости контраста от длины входа не предъявлено'}")
# (2) Между-юнитная часть — ЭТО И ЕСТЬ коэффициент длины из смешанной модели плана.
# В сбалансированном внутри-юнитном дизайне он равен МНК-наклону по СРЕДНИМ ПО ЮНИТУ
# значениям log R̂; считается точно и без statsmodels.
ux, uy = [], []
for u in units:
n = dl.get(u)
if not n:
continue
rs = []
for arm in CROSS_ARMS:
c = arm_data[arm]["cells"].get(u)
a0 = attempt0(c) if c else None
r = rhat(a0, arm) if a0 else None
if r and r > 0:
rs.append(math.log(r))
if len(rs) == len(CROSS_ARMS):
ux.append(math.log(n))
uy.append(float(np.mean(rs)))
out.append(f" (2) МЕЖДУ-ЮНИТНАЯ ЧАСТЬ — это и есть коэффициент длины из смешанной модели плана")
lenfit = None
if len(ux) >= 6:
X, Y = np.array(ux), np.array(uy)
Xm, Ym = X.mean(), Y.mean()
sxx2 = ((X - Xm) ** 2).sum()
b2 = ((X - Xm) * (Y - Ym)).sum() / sxx2
a2 = Ym - b2 * Xm
r2d = len(X) - 2
s22 = ((Y - (a2 + b2 * X)) ** 2).sum() / r2d
seb2 = math.sqrt(s22 / sxx2)
t2 = b2 / seb2 if seb2 > 0 else float("nan")
p2 = 2 * (1 - student_t.cdf(abs(t2), r2d))
out.append(f" n={len(X)} log R̂(среднее по юниту) ~ log(len_draft): наклон {b2:+.3f} ± {seb2:.3f},"
f" t={t2:+.2f}, p={p2:.3g}")
out.append(f" ⇒ при удвоении длины черновика размышление ×{2**b2:.2f}"
f" (для сравнения: exp19 намерил ×2.78 при ×3.24 входа ⇒ наклон ≈{math.log(2.78)/math.log(3.24):.2f})")
lenfit = {"slope": float(b2), "p": float(p2), "n": len(X)}
else:
out.append(f" n={len(ux)} — недостаточно, ОБЪЯВЛЕНО НЕИСПОЛНЕННЫМ")
out.append(" ⚠ statsmodels в eval/.venv НЕ УСТАНОВЛЕН и НЕ УСТАНАВЛИВАЛСЯ. Причина — НЕ «нет сети»")
out.append(" (сеть есть, колесо под cp314 существует — проверено): в сбалансированном внутри-юнитном")
out.append(" дизайне ОБЕ величины, которые дала бы смешанная модель, вычисляются точно руками —")
out.append(" (1) и (2) выше, — а правка общего venv при живых параллельных сессиях есть риск без")
out.append(" выигрыша. Решение объявлено словом, а не умолчано.")
return {"rho": float(rho), "slope": float(b), "p": float(p), "n": len(x), "length_effect": lenfit}
def money(arm_data, base_spend, out, sanction, outside=0.0):
"""§9.5 — агрегат вручную: механизма нет, база считается ОДИН раз.
⚠ `outside` — деньги, ушедшие провайдеру из БД, которая потом была пере-создана. Из леджера
копии они исчезают, из счёта — нет, и агрегат «база + Σ(копия база)» их не видит. Слагаемое
заведено исполнением: $0.018243 ушли в p7-low на терминолога до пере-копирования руки.
"""
out.append("\n## ДЕНЬГИ — АГРЕГАТ ВРУЧНУЮ (§9.5)")
out.append(f" база : ${base_spend:.6f}")
if outside:
out.append(f" потрачено вне баз : ${outside:.6f} ⚠ из леджеров не видно, но провайдеру ушло")
total = base_spend + outside
for arm in ALL_ARMS:
s = arm_data[arm]["spend"]
delta = s - base_spend
total += delta
out.append(f" {arm:8s} леджер ${s:.6f} база ⇒ ${delta:.6f}")
out.append(f" ─────────────────────────────────────────")
out.append(f" ИЗРАСХОДОВАНО (база + Σ(копия−база)) = ${total:.6f}")
out.append(f" САНКЦИЯ ВЛАДЕЛЬЦА (пиковые термины) = ${sanction:.2f}")
out.append(f" остаток = ${sanction-total:.6f}")
if total > sanction:
out.append(" ⛔ ПОТОЛОК ПРОБИТ — стоп и вопрос владельцу, добор запрещён")
return total
# ── MAIN ─────────────────────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="прибор пробы редактора по четырём осям")
ap.add_argument("--stand", default=str(Path.home() / "books/gu-zhenren/probe-4axes"))
ap.add_argument("--manifest", help="манифест пре-рега (sha256 промптов и запиненная сигнатура)")
ap.add_argument("--sanction", type=float, default=9.50)
ap.add_argument("--outside", type=float, default=0.018243,
help="потрачено вне текущих баз (пере-созданные БД); в агрегат входит")
ap.add_argument("--json", help="куда сложить машинный результат")
a = ap.parse_args()
B = Path(a.stand)
out, result = [], {}
manifest = {}
if a.manifest and Path(a.manifest).exists():
manifest = json.loads(Path(a.manifest).read_text())
pins = manifest.get("prompt_sha256", {})
pinned_sig = manifest.get("signature", {})
out.append("" * 96)
out.append("ПРОБА РЕДАКТОРА ПО ЧЕТЫРЁМ ОСЯМ — ПРИБОР (chetyre.py)")
out.append(f"стенд: {B}")
out.append("" * 96)
try:
if not pins:
raise Refuse("в манифесте нет запиненных prompt_sha256 — гард оси не может быть исполнен "
"(положительный контроль: --manifest указывает на файл пре-рега)")
arm_data = {}
for arm in ALL_ARMS:
path = B / "arms" / f"{arm}.db"
cells, dlen, snaps, jobs, spend = read_arm(path, arm)
arm_data[arm] = {"cells": cells, "draft_len": dlen, "snaps": snaps, "jobs": jobs, "spend": spend}
# ⚠ ДЛИНА ВХОДА ЮНИТА — только через манифест движка: юнит редактуры охватывает несколько
# чанков черновика (в этом стенде 15 юнитов из 20), и ключ (глава, индекс) у них СОВПАДАЕТ,
# то есть ошибка была бы ТИХОЙ.
mpath = B / "guzhenren-probe4.db.manifest.json"
if not mpath.exists():
raise Refuse(f"нет манифеста движка {mpath} — длину входа юнита взять неоткуда "
f"(положительный контроль: он пишется командой `tmctl manifest`, $0)")
unit_len = unit_draft_lengths(mpath, arm_data[CROSS_ARMS[0]]["draft_len"])
base_con = open_db(B / "guzhenren-probe4.db")
base_spend = base_con.execute("select coalesce(sum(committed_usd+reserved_usd),0) from spend").fetchone()[0]
base_con.close()
out.append("\n## ИНВЕНТАРЬ")
for arm in ALL_ARMS:
cells = arm_data[arm]["cells"]
done = [c for c in cells.values() if "delivered" in c]
orph = sum(c.get("orphan_calls", 0) for c in cells.values())
out.append(f" {arm:8s} клеток {len(cells):3d} завершённых {len(done):3d}"
f" вызовов без чекпойнта {orph} леджер ${arm_data[arm]['spend']:.6f}")
problems = gate_axis(arm_data, pins, out)
sig, sp = gate_signature(arm_data, pinned_sig, out)
problems += sp
problems += gate_empty(arm_data, out)
if problems:
out.append("\n⛔ ГАРДЫ НЕ ПРОШЛИ:")
for p in problems:
out.append(f" · {p}")
raise Refuse("гарды дизайна не прошли — эндпойнты не считаются, чтобы не выдать зелень на браке")
units = sorted(set.intersection(*[set(arm_data[x]["cells"]) for x in CROSS_ARMS]))
out.append(f"\n## ЮНИТЫ, ПОЛНЫЕ ПО ВСЕМ ЧЕТЫРЁМ РУКАМ КРЕСТА: {len(units)}")
if not units:
raise Refuse("полных юнитов НОЛЬ — знаменатель пуст")
e0 = e0_decomposition(arm_data, out)
# E1 — главный
d1, statuses, dropped = [], [], []
for u in units:
p7, s7 = paired_contrast(arm_data, u, "p7-off", "p7-low")
p9, s9 = paired_contrast(arm_data, u, "p9-off", "p9-low")
statuses += [s7, s9]
if p7 is None or p9 is None:
dropped.append((u, f"P7:{s7} P9:{s9}"))
d1.append(None)
else:
d1.append(0.5 * (p7 + p9))
cens = sum(1 for s in statuses if "censored" in s)
# ⛔ ВЫБРОШЕННЫЕ ЮНИТЫ НАЗЫВАЮТСЯ ПОИМЁННО И С ПРИЧИНОЙ: «юнитов в тесте: N» без этого
# читается как «столько и было», а разница между 20 и N есть отдельный факт о приборе.
note = f"пар с цензурой: {cens} из {len(statuses)}"
if cens:
note += " ⇒ ВЕЛИЧИНА ЕСТЬ НИЖНЯЯ ГРАНИЦА, читать со знаком «≥»"
if dropped:
note += f"\n ⛔ ВЫБРОШЕНО ЮНИТОВ: {len(dropped)} из {len(units)}" + \
"; ".join(f"({a},{b}) {r}" for (a, b), r in dropped[:8])
if len(dropped) > 8:
note += f" … и ещё {len(dropped)-8}"
else:
note += f"\n выброшенных юнитов нет: в тесте все {len(units)}"
e1 = endpoint_test("E1 — ГЛАВНЫЙ: усилие low против ∅, log R̂, попытка 0", d1, out, note=note)
# E3 — ось промпта
d3 = []
for u in units:
v = {}
for arm in CROSS_ARMS:
c = arm_data[arm]["cells"].get(u)
a0 = attempt0(c) if c else None
r = rhat(a0, arm) if a0 else None
v[arm] = math.log(r) if (r and r > 0) else None
d3.append(None if any(x is None for x in v.values())
else 0.5 * (v["p9-off"] + v["p9-low"]) - 0.5 * (v["p7-off"] + v["p7-low"]))
e3 = endpoint_test("E3 — ОСЬ ПРОМПТА (сегодняшний против июльского), log R̂", d3, out,
note="прогноз, калибрующий удивление (записан до чисел): |эффект| < ×1.25")
# E4 — взаимодействие
d4 = []
for u in units:
v = {}
for arm in CROSS_ARMS:
c = arm_data[arm]["cells"].get(u)
a0 = attempt0(c) if c else None
r = rhat(a0, arm) if a0 else None
v[arm] = math.log(r) if (r and r > 0) else None
d4.append(None if any(x is None for x in v.values())
else (v["p9-low"] - v["p9-off"]) - (v["p7-low"] - v["p7-off"]))
e4 = endpoint_test("E4 — ВЗАИМОДЕЙСТВИЕ промпт×усилие", d4, out,
note="ОПИСАТЕЛЬНЫЙ: sd контраста вдвое больше, MDE ≈×3.3 при n=32 — выводов не несёт")
e2 = e2_cost_per_delivered(arm_data, units, out)
e5 = e5_safety(arm_data, out)
cov = covariate_check(arm_data, units, d1, out, unit_len)
total = money(arm_data, base_spend, out, a.sanction, a.outside)
result = {"e0": {k: v for k, v in e0.items()}, "e1": e1, "e2": {k: v for k, v in e2.items()},
"e3": e3, "e4": e4, "e5": e5, "covariate": cov,
"units": len(units), "spent_usd": total, "signature": {k: {str(i): j for i, j in v.items()} for k, v in sig.items()}}
code = 0
except Refuse as e:
out.append(f"\n⛔⛔ ПРИБОР ОТКАЗЫВАЕТ (код {REFUSAL}): {e}")
out.append(" Это НЕ «в пределах» и НЕ «эффекта нет». Знаменатель пуст либо гард не прошёл.")
code = REFUSAL
text = "\n".join(out)
print(text)
if a.json:
Path(a.json).write_text(json.dumps({"ok": code == 0, "report": text, **result},
ensure_ascii=False, indent=2, default=str))
return code
if __name__ == "__main__":
sys.exit(main())