textmachine/eval/dovodka/proverka_pribora.py

351 lines
22 KiB
Python
Raw 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 -*-
"""proverka_pribora.py — ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ для chetyre.py.
Зачем существует. Правило проекта: «отрицательный результат предъявляется с ПОЛОЖИТЕЛЬНЫМ
КОНТРОЛЕМ» — лекарство от одиннадцати ошибок предыдущей сессии, каждая из которых была
«показания инструмента приняты за факт о мире». Если прибор напечатает «ниже разрешения замера»,
читатель обязан иметь улику, что он вообще СПОСОБЕН напечатать «эффект есть».
Что делает: строит СИНТЕТИЧЕСКИЕ проектные базы точно той же схемы, что у движка, с ЗАРАНЕЕ
ИЗВЕСТНЫМ ответом, и прогоняет по ним настоящий chetyre.py. Ни одного платного вызова.
⛔ Это НЕ подгонка теста под зелень: сценарии написаны так, чтобы прибор мог их ПРОВАЛИТЬ, и
два из шести ожидают от него именно ОТКАЗА.
"""
import json
import math
import os
import random
import shutil
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
CHETYRE = HERE / "chetyre.py"
PY = HERE.parent / ".venv/bin/python"
ARMS = ["p7-off", "p7-low", "p9-off", "p9-low", "glm-off"]
SHA_P7 = "2b84dee34b0861637a055fee60372d2553f85ffa4c1b535c1e781f5167e8c3a1"
SHA_P9 = "1ad4544e564a9b4049231e79e961a9cbb85bc39de07c5ce72cdbc8d92cc68999"
SCHEMA = """
create table request_log (
id integer primary key, ts text default '', trace_id text default '', book_id text not null,
chapter integer default 0, chunk_idx integer default 0, stage text default '', role text default '',
model_requested text default '', model_actual text default '', request_hash text default '',
prompt_tokens integer default 0, cached_tokens integer default 0, cache_creation_tokens integer default 0,
completion_tokens integer default 0, reasoning_tokens integer default 0, cost_usd real default 0,
latency_ms integer default 0, finish_reason text default '', tm_hit integer default 0,
degraded text default '', err text default '', ok integer default 1,
estimated integer default 0, est_tokens integer default 0);
create table checkpoints (
request_hash text primary key, job_id integer not null, chunk_idx integer not null,
attempt integer default 0, stage text not null, role text not null, model_requested text not null,
model_actual text not null, response_text text not null, usage_json text not null,
cost_usd real not null, finish_reason text default '', provider_request_id text default '',
created_at text default '', escalation integer default 0);
create table chunk_status (
book_id text not null, chapter integer not null, chunk_idx integer not null, stage text not null,
snapshot_id text not null, content_hash text default '', disposition text not null,
flag_reason text default '', attempts integer default 0, final_hash text default '',
cost_usd real default 0, detail text default '', updated_at text default '',
escalated integer default 0, escalation_model text default '', first_flag_reason text default '',
primary key (book_id, chapter, chunk_idx, stage));
create table snapshots (snapshot_id text primary key, brief_hash text not null, payload text not null, created_at text default '');
create table jobs (id integer primary key, book_id text not null, chapter integer not null, stage text not null,
status text default 'done', snapshot_id text not null, created_at text default '', updated_at text default '');
create table spend (book_id text not null, date text not null, committed_usd real default 0, reserved_usd real default 0);
"""
RU = "Мальчик поднял голову и посмотрел на далёкие горы, где клубился утренний туман. "
DRAFT = "Черновой русский текст главы, достаточно длинный чтобы служить ковариатой. " * 20
def snap_payload(sha, reasoning, model, memver="m1"):
return json.dumps({
"brief_hash": "b", "chunker_version": "c", "estimator_version": "e",
"max_tokens_policy": "p", "classifier_version": "cl", "pipeline_core": "C1",
"max_output_ratio": 2.2, "min_max_tokens": 2048, "memory_version": memver,
"stages": [{"name": "edit", "role": "editor", "model": model, "prompt_version": "v3-discourse-reflow",
"prompt_sha256": sha, "temperature": 0.4, "reasoning": reasoning, "few_shot": False}],
}, sort_keys=True)
def draft_snap_payload():
return json.dumps({"brief_hash": "b", "memory_version": "base",
"stages": [{"name": "draft", "role": "translator", "model": "deepseek-v4-flash",
"prompt_version": "v1", "prompt_sha256": "d" * 64,
"temperature": 0.3, "reasoning": "low"}]}, sort_keys=True)
def build(stand, n_units, effect, *, censor_units=(), empty_arms=(), collapse=False, seed=7,
replays=0, sig_break=(), sanitizer_units=()):
"""Строит стенд. effect — во сколько раз low режет размышление (1.0 = эффекта нет)."""
rnd = random.Random(seed)
stand.mkdir(parents=True, exist_ok=True)
(stand / "arms").mkdir(exist_ok=True)
base_spend = 0.42
# база
bcon = sqlite3.connect(stand / "guzhenren-probe4.db")
bcon.executescript(SCHEMA)
bcon.execute("insert into spend values ('guzhenren-probe4','2026-09-02',?,0)", (base_spend,))
bcon.commit(); bcon.close()
draft_sid = "d" * 64
# ⚠ ВСЕ РУКИ ДЕЛЯТ ОДИН черновой снапшот — так и должно быть в настоящем прогоне
for arm in ARMS:
sha = SHA_P9 if (collapse or arm.startswith("p9") or arm == "glm-off") else SHA_P7
reasoning = "low" if arm.endswith("low") else "off"
model = "glm-5" if arm == "glm-off" else "deepseek-v4-pro"
edit_sid = f"{arm:<64}".replace(" ", "x")[:64]
con = sqlite3.connect(stand / "arms" / f"{arm}.db")
con.executescript(SCHEMA)
con.execute("insert into snapshots values (?,?,?,'')", (draft_sid, "b", draft_snap_payload()))
con.execute("insert into snapshots values (?,?,?,'')", (edit_sid, "b", snap_payload(sha, reasoning, model)))
spend = base_spend
rid = 0
for u in range(1, n_units + 1):
con.execute("insert into jobs values (?,?,?,?,?,?,'','')", (u, "guzhenren-probe4", u, "draft", "done", draft_sid))
con.execute("insert into jobs values (?,?,?,?,?,?,'','')", (1000 + u, "guzhenren-probe4", u, "edit", "done", edit_sid))
# черновик — ОДИН И ТОТ ЖЕ во всех руках (блокировка трудности)
dl = 400 + (u * 137) % 3000
dtext = DRAFT[:dl] if dl <= len(DRAFT) else DRAFT * (dl // len(DRAFT) + 1)
dtext = dtext[:dl]
dh = f"draft-{u}"
con.execute("insert into checkpoints values (?,?,?,?,?,?,?,?,?,?,?,?,'','',0)",
(dh, u, 0, 0, "draft", "translator", "deepseek-v4-flash", "deepseek-v4-flash",
dtext, "{}", 0.001, "stop", ))
con.execute("insert into chunk_status values (?,?,?,?,?,'',?,'',1,?,?,'','',0,'','')",
("guzhenren-probe4", u, 0, "draft", draft_sid, "ok", dh, 0.001))
dh2 = f"draft-{u}-b"
con.execute("insert into checkpoints values (?,?,?,?,?,?,?,?,?,?,?,?,'','',0)",
(dh2, u, 1, 0, "draft", "translator", "deepseek-v4-flash", "deepseek-v4-flash",
dtext[: max(50, dl // 2)], "{}", 0.001, "stop"))
con.execute("insert into chunk_status values (?,?,?,?,?,'',?,'',1,?,?,'','',0,'','')",
("guzhenren-probe4", u, 1, "draft", draft_sid, "ok", dh2, 0.001))
# размышление: базовый уровень юнита (общий для рук — блокировка) + шум руки
base_r = math.exp(rnd.gauss(math.log(9000), 0.55))
arm_noise = math.exp(rnd.gauss(0, 0.30))
r = base_r * arm_noise
if arm.endswith("low"):
r /= effect
if arm == "glm-off":
r = 0.0
vis_chars = 4000 + (u * 311) % 3000
vis_tok = 0.3644 * vis_chars
cmpl = int(round(r + vis_tok))
fin = "stop"
if u in censor_units and arm in ("p7-off", "p9-off"):
cmpl, fin = 16000, "length"
pt = 4400 + (u * 53) % 2000
if arm.endswith("low"):
# сигнатура: ∅ low = 79; у юнитов из sig_break она НАРУШЕНА (допуск ±2)
pt -= 79 if u not in sig_break else 40
price = {"in": 1.0, "out": 3.2} if arm == "glm-off" else {"in": 1.32, "out": 3.96}
cost = pt / 1e6 * price["in"] + cmpl / 1e6 * price["out"]
rid += 1
rh = f"{arm}-{u}-a0"
text = "" if arm in empty_arms else (RU * (vis_chars // len(RU) + 1))[:vis_chars]
con.execute("""insert into request_log
(id,book_id,chapter,chunk_idx,stage,role,model_requested,model_actual,request_hash,
prompt_tokens,cached_tokens,completion_tokens,reasoning_tokens,cost_usd,finish_reason,tm_hit,ok)
values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(rid, "guzhenren-probe4", u, 0, "edit", "editor",
"glm-5" if arm == "glm-off" else "deepseek-v4-pro",
"glm-5" if arm == "glm-off" else "deepseek-v4-pro",
rh, pt, 0, cmpl, 0, cost, fin, 0, 1))
con.execute("insert into checkpoints values (?,?,?,?,?,?,?,?,?,?,?,?,'','',0)",
(rh, 1000 + u, 0, 0, "edit", "editor",
"glm-5" if arm == "glm-off" else "deepseek-v4-pro",
"glm-5" if arm == "glm-off" else "deepseek-v4-pro",
text, "{}", cost, fin))
# ⚠ СТРОКИ-ПОВТОРЫ: интерливинг гоняет руку N раз с --max-units 1, и каждый прогон
# дописывает уже оплаченную клетку с tm_hit=1 и НУЛЯМИ во всех токенах, но с тем же
# request_hash ⇒ тем же attempt=0. Прибор обязан взять НАСТОЯЩИЙ вызов, а не повтор.
for k in range(replays):
rid += 1
con.execute("""insert into request_log
(id,book_id,chapter,chunk_idx,stage,role,model_requested,model_actual,request_hash,
prompt_tokens,cached_tokens,completion_tokens,reasoning_tokens,cost_usd,finish_reason,tm_hit,ok)
values (?,?,?,?,?,?,?,?,?,0,0,0,0,0,?,1,1)""",
(rid, "guzhenren-probe4", u, 0, "edit", "editor",
"glm-5" if arm == "glm-off" else "deepseek-v4-pro",
"glm-5" if arm == "glm-off" else "deepseek-v4-pro", rh, fin))
spend += cost
disp = "ok" if text else "flagged"
flag = "" if text else "empty_output"
final = rh
if u in sanitizer_units and text:
# СЫРОЙ ответ несёт эхо-иероглифы, а ОТГРУЖАЕМЫЙ — уже вычищен санитайзером.
# Именно эта клетка делала E5 слепым: по отгруженному CJK = 0.
con.execute("update checkpoints set response_text=? where request_hash=?",
(text + "即便身亡魔心依旧不悔", rh))
final = f"tm-sanitized-v1:{arm}:{u}"
con.execute("insert into checkpoints values (?,?,?,?,?,?,?,?,?,?,?,?,'','',0)",
(final, 1000 + u, 0, 0, "edit", "editor",
"glm-5" if arm == "glm-off" else "deepseek-v4-pro",
"glm-5" if arm == "glm-off" else "deepseek-v4-pro",
text, "{}", 0.0, fin))
disp, flag = "flagged", "sanitizer_stripped"
con.execute("insert into chunk_status values (?,?,?,?,?,'',?,?,1,?,?,'','',0,'','')",
("guzhenren-probe4", u, 0, "edit", edit_sid, disp, flag, final, cost))
con.execute("insert into spend values ('guzhenren-probe4','2026-09-02',?,0)", (spend,))
con.commit(); con.close()
# МАНИФЕСТ ДВИЖКА: у каждого юнита ДВА чанка черновика — как в настоящем стенде (15 из 20),
# чтобы прибор реально суммировал их, а не брал первый.
(stand / "guzhenren-probe4.db.manifest.json").write_text(json.dumps({
"manifest_version": "tm-manifest-v2", "book_id": "guzhenren-probe4",
"chapters_total": n_units, "units_total": n_units, "chunks_total": n_units * 2,
"chapters": [{"number": u, "heading": f"Глава {u}", "units_total": 1, "chunks_total": 2,
"units": [{"id": f"u{u}", "first_chunk_idx": 0, "chunk_count": 2,
"edit_unit_id": u - 1}]} for u in range(1, n_units + 1)],
}, ensure_ascii=False))
(stand / "manifest.json").write_text(json.dumps({
"prompt_sha256": {"P7": SHA_P7, "P9": SHA_P9},
"signature": {"P7": 79, "P9": 79},
}, ensure_ascii=False))
return stand
def run(stand):
p = subprocess.run([str(PY), str(CHETYRE), "--stand", str(stand),
"--manifest", str(stand / "manifest.json")],
capture_output=True, text=True)
return p.returncode, p.stdout + p.stderr
def main():
tmp = Path(tempfile.mkdtemp(prefix="pribor-"))
ok = True # ⚠ меняется и в case(), и напрямую в сценарии 8
def case(name, stand, want_code, want_in=(), want_not_in=()):
nonlocal ok
code, txt = run(stand)
good = code == want_code
for w in want_in:
if w not in txt:
good = False
print(f" ⛔ не найдено в выводе: {w!r}")
for w in want_not_in:
if w in txt:
good = False
print(f" ⛔ найдено лишнее: {w!r}")
print(("" if good else "") + f"{name} (код {code}, ждали {want_code})")
if not good:
print("\n".join(" | " + l for l in txt.splitlines()[-30:]))
ok = ok and good
print("ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ ПРИБОРА chetyre.py\n")
# 0. ПРЯМОЙ ТЕСТ СБОРКИ ДЛИНЫ ВХОДА ЮНИТА ИЗ ЧАНКОВ.
# Дефект, который он ловит, ТИХИЙ: ключ (глава, индекс) у юнита редактуры и у чанка черновика
# СОВПАДАЕТ, поэтому «взять первый чанк вместо суммы» не даёт ни ошибки, ни пустоты — только
# заниженную ковариату. В настоящем стенде так занижены 15 юнитов из 20.
print("0. СБОРКА ДЛИНЫ ВХОДА ЮНИТА ИЗ ЧАНКОВ (прямой тест функции)")
sys.path.insert(0, str(HERE))
import chetyre
man = tmp / "m.json"
man.write_text(json.dumps({"chapters": [
{"number": 1, "units": [{"first_chunk_idx": 0, "chunk_count": 2}]}, # 100+250
{"number": 2, "units": [{"first_chunk_idx": 0, "chunk_count": 1},
{"first_chunk_idx": 1, "chunk_count": 2}]}, # 7 | 11+13
{"number": 3, "units": [{"first_chunk_idx": 0, "chunk_count": 2}]}, # чанка нет
]}))
got = chetyre.unit_draft_lengths(man, {(1, 0): 100, (1, 1): 250,
(2, 0): 7, (2, 1): 11, (2, 2): 13,
(3, 0): 5})
want = {(1, 0): 350, (2, 0): 7, (2, 1): 24, (3, 0): None}
for k in want:
mark = "" if got.get(k) == want[k] else ""
print(f" {mark} юнит {k}: получено {got.get(k)}, ждали {want[k]}"
+ (" ← неполный юнит обязан стать None, а не полусуммой" if want[k] is None else ""))
ok = ok and got.get(k) == want[k]
print(f" (наивная реализация «первый чанк» дала бы юниту (1,0) 100 вместо 350 — молча)")
print("1. ЭФФЕКТ ЕСТЬ И ОН ИЗВЕСТЕН: low режет размышление ровно ×2 на 24 юнитах")
s = build(tmp / "effect", 24, effect=2.0)
case("прибор НАХОДИТ известный эффект и называет величину", s, 0,
want_in=["ВЫВОД: эффект ЕСТЬ", "E1 — ГЛАВНЫЙ"])
code, txt = run(s)
for line in txt.splitlines():
if "Ходжес-Леман" in line and "отношение" in line:
print("", line.strip(), " (истина: ×0.500)")
break
print("\n2. ЭФФЕКТА НЕТ: те же данные, effect=1.0")
s = build(tmp / "noeffect", 24, effect=1.0, seed=11)
case("прибор НЕ находит эффекта и говорит «ниже разрешения», а не «эффекта нет»", s, 0,
want_in=["НИЖЕ РАЗРЕШЕНИЯ ЗАМЕРА"], want_not_in=["эффекта нет."])
print("\n3. ⛔ ЗЕЛЕНЬ НА ПУСТОТЕ: в руке p9-low все клетки пустые")
s = build(tmp / "empty", 24, effect=2.0, empty_arms=("p9-low",), seed=3)
case("прибор ОТКАЗЫВАЕТ кодом 2, а не печатает «в пределах»", s, 2,
want_in=["ПРИБОР ОТКАЗЫВАЕТ", "ПРИБОР ОТКАЗЫВАЕТ"])
print("\n4. ⛔ ОСЬ ПРОМПТА СХЛОПНУЛАСЬ: у P7-рук уехал промпт P9")
s = build(tmp / "collapse", 24, effect=2.0, collapse=True, seed=5)
case("прибор ловит схлопывание оси и отказывает", s, 2,
want_in=["prompt_sha256", "ПРИБОР ОТКАЗЫВАЕТ"])
print("\n5. ЦЕНЗУРА: у трети юнитов ∅ обрывается по длине")
s = build(tmp / "censor", 24, effect=2.0, censor_units=tuple(range(1, 9)), seed=9)
case("прибор считает и объявляет цензуру нижней границей", s, 0,
want_in=["пар с цензурой", "НИЖНЯЯ ГРАНИЦА"])
print("\n6. СТРОКИ-ПОВТОРЫ: у каждой клетки 19 записей tm_hit=1 с нулями (как в реальном интерливинге)")
s = build(tmp / "replay", 24, effect=2.0, replays=19, seed=2)
case("прибор берёт НАСТОЯЩИЙ вызов, а не повтор, и находит тот же эффект", s, 0,
want_in=["ВЫВОД: эффект ЕСТЬ"])
code, txt = run(s)
for line in txt.splitlines():
if "Ходжес-Леман" in line and "отношение" in line:
print("", line.strip(), " (истина: ×0.500; на нулях повтора вышло бы NaN или мусор)")
break
print("\n7. ⛔ СИГНАТУРА НЕ ВОСПРОИЗВЕЛАСЬ у 40% юнитов (запинено 79, приходит 40)")
s = build(tmp / "sig", 24, effect=2.0, sig_break=tuple(range(1, 11)), seed=4)
case("прибор аннулирует пробу, а не считает эндпойнты на браке", s, 2,
want_in=["сигнатура не воспроизвелась", "ПРИБОР ОТКАЗЫВАЕТ"])
print("\n8. ЭХО ЗА САНИТАЙЗЕРОМ: сырой ответ несёт CJK, отгружаемый вычищен")
s = build(tmp / "sanit", 24, effect=2.0, sanitizer_units=tuple(range(1, 5)), seed=6)
code, txt = run(s)
# ⛔ ПРОВЕРЯЕМ ЧИСЛА, А НЕ ЗАГОЛОВКИ: заголовок «CJK@сырой» напечатается и при слепом приборе.
lines = txt.splitlines()
try:
i = next(k for k, l in enumerate(lines) if "CJK@сырой" in l)
row = next(l for l in lines[i + 1:] if l.strip().startswith("p7-off"))
f = row.split()
raw_cjk, ship_cjk, sanit = int(f[2]), int(f[3]), int(f[7])
good = raw_cjk == 4 and ship_cjk == 0 and sanit == 4
print(f" p7-off: CJK@сырой={raw_cjk} (ждали 4) CJK@отгр={ship_cjk} (ждали 0) санитайз={sanit} (ждали 4)")
except (StopIteration, ValueError, IndexError) as e:
good, raw_cjk = False, None
print(f" ⛔ не удалось разобрать строку E5: {e}")
print(("" if good else "") + "E5 ВИДИТ эхо по СЫРОМУ ответу, хотя по отгруженному его НЕТ"
+ f" (код {code})")
ok = ok and good and code == 0
print("\n9. ⛔ ПУСТОЙ СТЕНД: баз нет вовсе")
(tmp / "nothing").mkdir()
(tmp / "nothing" / "manifest.json").write_text(json.dumps(
{"prompt_sha256": {"P7": SHA_P7, "P9": SHA_P9}, "signature": {}}))
case("прибор отказывает, а не печатает нули", tmp / "nothing", 2,
want_in=["ПРИБОР ОТКАЗЫВАЕТ"])
shutil.rmtree(tmp, ignore_errors=True)
print("\n" + ("ПРИБОР ГОДЕН" if ok else "⛔ ПРИБОР НЕ ГОДЕН"))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())