#!/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/.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 (пин сигнатуры) — и только потом пачка."