Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcWeV2yiMWTjkeaQfSsYuL
203 lines
14 KiB
Bash
Executable file
203 lines
14 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# predpolet.sh — ПРЕД-ПОЛЁТ: один юнит × пять клеток, и всё, что обязано быть проверено ДО пачки.
|
||
#
|
||
# Порядок намеренный: сначала $0-ГАРД ОСИ на всех пяти копиях, и только потом деньги. Если ось
|
||
# схлопнулась, это видно ДО первой платной клетки, а не после ста.
|
||
#
|
||
# ⛔ --resnapshot НЕ ДАЁТСЯ НИКОГДА.
|
||
|
||
set -uo pipefail
|
||
|
||
B="${B:-$HOME/books/gu-zhenren/probe-4axes}"
|
||
REPO="${REPO:-/home/ubuntu/projects/textmachine}"
|
||
KEYS="${KEYS:-$REPO/backend/.env}"
|
||
PY="$REPO/eval/.venv/bin/python"
|
||
SEED=20260902
|
||
SANCTION=9.50
|
||
UNIT="${UNIT:-1}"
|
||
PIN="$REPO/eval/dovodka/prereg-4axes/prereg.json"
|
||
|
||
declare -A W=( [p7-off]=0.30 [p9-off]=0.30 [p7-low]=0.1666666667 [p9-low]=0.1666666667 [glm-off]=0.0666666667 )
|
||
|
||
ro() { sqlite3 "file:$1?mode=ro" "$2" 2>/dev/null; }
|
||
spend_of() { ro "$1" "select coalesce(sum(committed_usd+reserved_usd),0) from spend;" || echo 0; }
|
||
|
||
BASE_DB="${BASE_SNAPSHOT_DB:-$(ls -1t "$B"/backups/*.db 2>/dev/null | head -1)}"
|
||
BASE=$(spend_of "$BASE_DB")
|
||
echo "═══ ПРЕД-ПОЛЁТ · юнит $UNIT · база \$$BASE (из $BASE_DB) ═══"
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
# ЭТАП 1 — $0-ГАРД ОСИ. Потолок «база + $0.0001» отказывает ПЕРВОЙ ЖЕ редакторской резервации,
|
||
# но к этому моменту движок уже: разрешил черновики из чекпойнтов за $0, прогнал майнинг и
|
||
# терминолог по чекпойнтам за $0, ВЫЧИСЛИЛ edit-снапшот и завёл edit-джоб. То есть провенанс
|
||
# оси ложится в БД БЕЗ ЕДИНОГО ПЛАТНОГО ВЫЗОВА.
|
||
# ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
echo; echo "── ЭТАП 1: гард оси за \$0 (потолок отказывает первой edit-резервации)"
|
||
# ⛔ ПОТОЛОК ГАРДА — «база + 0.04», А НЕ «+0.0001». Куплено ошибкой: при +0.0001 леджер отказывал и
|
||
# ТЕРМИНОЛОГУ, а его отказ — не halt, а `break` с логом (terminologist.go:807), то есть банк «не
|
||
# двигался» лишь потому, что не на что было купить. Ложная неподвижная точка. 0.04 лежит НИЖЕ
|
||
# редакторской резервации (≈16000·3.96/1e6 + 6000·1.32/1e6 ≈ $0.071) и ВЫШЕ батча терминолога
|
||
# (≈$0.012): если точка ложная, терминолог купит — и сверка леджера это УВИДИТ.
|
||
TINY=$($PY -c "print(f'{$BASE + 0.04:.6f}')")
|
||
for arm in p7-off p7-low p9-off p9-low glm-off; do
|
||
# ⛔ ПАУЗА КУПЛЕНА ИСПОЛНЕНИЕМ, А НЕ ОСТОРОЖНОСТЬЮ: `preflightBackup` (cmd/tmctl/backup.go) пишет
|
||
# точку восстановления в <каталог project_db>/backups/<ts>.db с точностью до СЕКУНДЫ и ОТКАЗЫВАЕТ
|
||
# стартовать, если файл уже есть («refusing to overwrite a restore point»). Пять рук лежат в одном
|
||
# каталоге, и запуск двух в одну секунду роняет вторую кодом 1 ещё до всякой работы. Поймано на
|
||
# $0-гарде оси: три руки из пяти упали именно так. В пачке это остановило бы прогон на сотне клеток.
|
||
sleep 2
|
||
BEFORE=$(spend_of "$B/arms/$arm.db")
|
||
"$B/bin/tmctl" translate --config "$B/arms/$arm.book.yaml" --keys-file "$KEYS" \
|
||
--verify-bank --max-units 1 --ceiling-usd "$TINY" > "$B/logs/axis-$arm.log" 2>&1
|
||
rc=$?
|
||
AFTER=$(spend_of "$B/arms/$arm.db")
|
||
printf " %-8s rc=%-2s леджер %s → %s" "$arm" "$rc" "$BEFORE" "$AFTER"
|
||
if [ "$(printf '%.6f' "$BEFORE")" != "$(printf '%.6f' "$AFTER")" ]; then
|
||
echo " ⛔ ДЕНЬГИ ПОТРАЧЕНЫ НА ГАРДЕ — так быть не должно, СТОП"; exit 1
|
||
fi
|
||
echo " ✓ \$0"
|
||
if [ "$rc" != "4" ]; then
|
||
echo " ⛔ ожидался код 4 (денежный отказ), получен $rc:"
|
||
tail -2 "$B/logs/axis-$arm.log" | sed 's/^/ /' | cut -c1-200
|
||
exit 1
|
||
fi
|
||
# ⛔ ОТКАЗ ОБЯЗАН НАЗВАТЬ СТАДИЮ edit. Если он назовёт terminology, значит терминолог пере-предложил
|
||
# хвост и попросил СВЕЖИЙ батч — тогда банк в копии собрался бы иначе, чем в базе, и это надо
|
||
# понять ДО денег, а не после.
|
||
if ! grep -q 'denied estimate=.*\/edit)' "$B/logs/axis-$arm.log"; then
|
||
echo " ⛔ отказ НЕ на стадии edit:"; grep -o 'denied estimate=[^)]*)' "$B/logs/axis-$arm.log" | tail -1 | sed 's/^/ /'
|
||
exit 1
|
||
fi
|
||
done
|
||
|
||
# ⛔ УСТОЙЧИВОСТЬ ПРОГОН-К-ПРОГОНУ: весь цикл пачки — это 20 повторных прогонов каждой руки, и если
|
||
# второй прогон даёт другой edit-снапшот, цикл сломается на втором юните. Проверяется дважды на одной
|
||
# копии: тот же snapshot_id и ноль свежих платных строк.
|
||
echo; echo "── УСТОЙЧИВОСТЬ: второй прогон той же руки обязан дать ТО ЖЕ"
|
||
S1=$(sqlite3 "file:$B/arms/glm-off.db?mode=ro" "select distinct snapshot_id from jobs where stage='edit';")
|
||
N1=$(sqlite3 "file:$B/arms/glm-off.db?mode=ro" "select count(*) from request_log where tm_hit=0 and cost_usd>0;")
|
||
sleep 2
|
||
"$B/bin/tmctl" translate --config "$B/arms/glm-off.book.yaml" --keys-file "$KEYS" \
|
||
--verify-bank --max-units 1 --ceiling-usd "$TINY" > "$B/logs/axis-glm-off-2.log" 2>&1
|
||
S2=$(sqlite3 "file:$B/arms/glm-off.db?mode=ro" "select distinct snapshot_id from jobs where stage='edit';")
|
||
N2=$(sqlite3 "file:$B/arms/glm-off.db?mode=ro" "select count(*) from request_log where tm_hit=0 and cost_usd>0;")
|
||
D2=$(grep -c 'denied by a USD ceiling' "$B/logs/axis-glm-off-2.log")
|
||
echo " glm-off прогон 1: снапшот ${S1:0:12}… строк с ценой $N1"
|
||
echo " glm-off прогон 2: снапшот ${S2:0:12}… строк с ценой $N2 · denied=$D2"
|
||
if [ "$S1" != "$S2" ] || [ "$N1" != "$N2" ]; then
|
||
echo " ⛔ ПРОГОН-К-ПРОГОНУ НЕУСТОЙЧИВ — цикл пачки сломается на втором юните. СТОП."; exit 1
|
||
fi
|
||
[ "$D2" != "0" ] && echo " ⚠ в повторе есть denied by a USD ceiling — терминолог упёрся в потолок гарда"
|
||
echo " ✓ устойчиво"
|
||
|
||
echo; echo "── СВЕРКА ОСИ ПО СНАПШОТАМ (до единой платной клетки)"
|
||
$PY - "$B" "$PIN" <<'PYEOF'
|
||
import json, sqlite3, sys, pathlib
|
||
B, pin = pathlib.Path(sys.argv[1]), json.loads(pathlib.Path(sys.argv[2]).read_text())
|
||
SHA = pin["prompt_sha256"]
|
||
WANT = {"p7-off": (SHA["P7"], "off", "deepseek-v4-pro"), "p7-low": (SHA["P7"], "low", "deepseek-v4-pro"),
|
||
"p9-off": (SHA["P9"], "off", "deepseek-v4-pro"), "p9-low": (SHA["P9"], "low", "deepseek-v4-pro"),
|
||
"glm-off": (SHA["P9"], "off", "glm-5")}
|
||
edit_ids, draft_ids, bad = {}, {}, []
|
||
for arm, want in WANT.items():
|
||
con = sqlite3.connect(f"file:{B/'arms'/(arm+'.db')}?mode=ro", uri=True)
|
||
snaps = dict(con.execute("select snapshot_id, payload from snapshots"))
|
||
for stage, bucket in (("edit", edit_ids), ("draft", draft_ids)):
|
||
ids = {r[0] for r in con.execute("select snapshot_id from jobs where stage=?", (stage,))}
|
||
bucket[arm] = ids
|
||
con.close()
|
||
for sid in edit_ids[arm]:
|
||
st = next((s for s in json.loads(snaps[sid]).get("stages", []) if s["name"] == "edit"), None)
|
||
if st is None:
|
||
bad.append(f"{arm}: в снапшоте {sid[:12]} нет стадии edit"); continue
|
||
got = (st.get("prompt_sha256"), st.get("reasoning"), st.get("model"))
|
||
print(f" {arm:8s} edit {sid[:12]}… sha {got[0][:12]}… reasoning '{got[1]}' model {got[2]}")
|
||
if got != want:
|
||
bad.append(f"{arm}: на проводе {got}, а запинено {want}")
|
||
# ⛔ САМАЯ СИЛЬНАЯ ПРОВЕРКА: у edit-снапшотов пяти рук всё, КРОМЕ плана стадий, обязано совпадать
|
||
# побайтно. Иначе «пять разных snapshot_id» пройдёт, а руки будут различаться НЕ осью: например
|
||
# разным memory_version, если авто-банк собрался в копиях по-разному. Базовый прогон оставил банк
|
||
# ЧАСТИЧНО консолидированным (consolidated=51, unanswered=57, batches_dropped=5) — он константа
|
||
# только если в пяти копиях свернулся ОДИНАКОВО, и вот это здесь и проверяется.
|
||
rest = {}
|
||
for arm in WANT:
|
||
con = sqlite3.connect(f"file:{B/'arms'/(arm+'.db')}?mode=ro", uri=True)
|
||
snaps = dict(con.execute("select snapshot_id, payload from snapshots")); con.close()
|
||
for sid in edit_ids[arm]:
|
||
pl = json.loads(snaps[sid]); mv = pl.get("memory_version")
|
||
pl.pop("stages", None)
|
||
rest.setdefault(arm, []).append((json.dumps(pl, sort_keys=True), mv))
|
||
mvs = {arm: v[0][1] for arm, v in rest.items() if v}
|
||
print(" memory_version по рукам:", {a: (m or "")[:12] for a, m in mvs.items()})
|
||
if len(set(mvs.values())) != 1:
|
||
bad.append(f"memory_version РАЗНЫЙ у рук: {mvs} — банк в копиях свернулся по-разному, "
|
||
f"редактор получил РАЗНЫЙ вход, и это НЕ ось")
|
||
bodies = {arm: v[0][0] for arm, v in rest.items() if v}
|
||
if len(set(bodies.values())) != 1:
|
||
bad.append("edit-снапшоты различаются НЕ ТОЛЬКО планом стадий — сравни payload вручную")
|
||
else:
|
||
print(" ✓ всё, кроме плана стадий, у пяти edit-снапшотов побайтно одинаково")
|
||
|
||
alle = set().union(*edit_ids.values()) if edit_ids else set()
|
||
alld = set().union(*draft_ids.values()) if draft_ids else set()
|
||
print(f" различных EDIT-снапшотов: {len(alle)} (нужно 5)")
|
||
print(f" различных DRAFT-снапшотов: {len(alld)} (нужно РОВНО 1 — иначе черновики перекуплены)")
|
||
if len(alle) != 5: bad.append(f"edit-снапшотов {len(alle)} ≠ 5 — ОСЬ СХЛОПНУЛАСЬ")
|
||
if len(alld) != 1: bad.append(f"draft-снапшотов {len(alld)} ≠ 1 — черновики НЕ общие")
|
||
if bad:
|
||
print("\n⛔ ГАРД ОСИ НЕ ПРОШЁЛ:")
|
||
for b in bad: print(" ·", b)
|
||
sys.exit(1)
|
||
print(" ✓ ось на месте, черновики общие — и всё это установлено за $0")
|
||
PYEOF
|
||
[ $? -ne 0 ] && { echo "СТОП: платить за схлопнувшийся крест нельзя."; exit 1; }
|
||
|
||
if [ "${ONLY_AXIS:-0}" = "1" ]; then
|
||
echo; echo "ONLY_AXIS=1 — остановлено ПЕРЕД платным этапом. Ни одна клетка не куплена."
|
||
exit 0
|
||
fi
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
# ЭТАП 2 — ПЯТЬ ПЛАТНЫХ КЛЕТОК ОДНОГО ЮНИТА. С включённым каналом сырых тел запросов:
|
||
# это единственный раз, когда он нужен, и он даёт ПОБАЙТНУЮ улику того, что уехало на провод.
|
||
# ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
echo; echo "── ЭТАП 2: пять платных клеток юнита $UNIT (канал сырых тел ВКЛЮЧЁН)"
|
||
ORDER=$($PY "$REPO/eval/dovodka/poryadok.py" --unit "$UNIT" --seed "$SEED")
|
||
echo " порядок: $ORDER"
|
||
for arm in $ORDER; do
|
||
CEIL=$($PY -c "print(f'{$BASE + ${W[$arm]}*($SANCTION-$BASE):.4f}')")
|
||
BEFORE=$(spend_of "$B/arms/$arm.db")
|
||
sleep 2 # см. пояснение про preflightBackup выше
|
||
echo " → $arm (--ceiling-usd $CEIL) $(date -u +%H:%M:%SZ)"
|
||
LOG_LLM_BODIES=1 LOG_LEVEL=debug "$B/bin/tmctl" translate \
|
||
--config "$B/arms/$arm.book.yaml" --keys-file "$KEYS" \
|
||
--verify-bank --max-units 1 --ceiling-usd "$CEIL" \
|
||
> "$B/logs/pre-$arm.log" 2>&1
|
||
rc=$?
|
||
AFTER=$(spend_of "$B/arms/$arm.db")
|
||
D=$($PY -c "print(f'{$AFTER-$BEFORE:.6f}')")
|
||
echo " rc=$rc · клетка стоила \$$D · леджер руки \$$AFTER"
|
||
case $rc in
|
||
0|2) ;;
|
||
*) echo " ⛔ код $rc — СТОП, вопрос владельцу"; exit "$rc" ;;
|
||
esac
|
||
done
|
||
|
||
|
||
echo; echo "── АССЕРТЫ ПРЕД-ПОЛЁТА (исполняемые стоп-критерии §5.1, не осмотр глазами)"
|
||
$PY "$REPO/eval/dovodka/predpolet_proverka.py" "$B"
|
||
ASSERT_RC=$?
|
||
|
||
echo; echo "── АГРЕГАТ ПОСЛЕ ПРЕД-ПОЛЁТА"
|
||
TOT=$BASE
|
||
for arm in p7-off p7-low p9-off p9-low glm-off; do
|
||
S=$(spend_of "$B/arms/$arm.db")
|
||
TOT=$($PY -c "print(f'{$TOT + $S - $BASE:.6f}')")
|
||
done
|
||
echo " израсходовано: \$$TOT из \$$SANCTION"
|
||
echo
|
||
if [ "${ASSERT_RC:-1}" -ne 0 ]; then
|
||
echo "⛔ АССЕРТЫ ПРЕД-ПОЛЁТА НЕ ПРОШЛИ — ПАЧКУ НЕ НАЧИНАТЬ, вопрос владельцу."
|
||
exit 1
|
||
fi
|
||
echo "ДАЛЬШЕ: ФРИЗ №2 (пин сигнатуры) — и только потом пачка."
|