From 650b4fea12419cb25a1eb02ca436b9681b7de9fa Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Thu, 20 Aug 2026 13:57:08 +0300 Subject: [PATCH] Freeze the buying code and owner-sanctioned ceilings for the critic-input presentation measurement --- eval/dovodka/money_d.py | 21 +- eval/dovodka/podacha.py | 722 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 740 insertions(+), 3 deletions(-) create mode 100644 eval/dovodka/podacha.py diff --git a/eval/dovodka/money_d.py b/eval/dovodka/money_d.py index 9ac8044d..e44264c6 100644 --- a/eval/dovodka/money_d.py +++ b/eval/dovodka/money_d.py @@ -79,6 +79,8 @@ TAGS = { "ФД-H": ("dv-h-*.json",), # H-4: японская нога — второй редактор glm-5 на ja "ФД-I": ("dv-i-*.json",), # Д17: заход под починенную рубрику, китайская пара "ФД-J": ("dv-j-*.json",), # Д17: заход под починенную рубрику, английская пара + "ФД-K": ("dv-k-*.json",), # Д22: подача входа критику, китайская пара + "ФД-L": ("dv-l-*.json",), # Д22: подача входа критику, английская пара } # ⚠ Потолки фаз = смета `plan.py` плюс МИНИМАЛЬНЫЙ запас, так чтобы их СУММА не превышала # пакетный потолок. Первая редакция округляла каждый вверх до цента и дала сумму 4.54 > 4.50 — @@ -215,10 +217,22 @@ TAGS = { # 1.792 + 0.043 = 1.835, но потолок обязан покрыть ОЖИДАНИЕ 0.117 → 1.95. # Спишется около $0.15 (офф-пик). Пять клеток — это то, без чего у китайской оси нет полного # шумового пола, а без пола нет порога различимости и ни одного вердикта. +# ⚠ 20.08, САНКЦИЯ ВЛАДЕЛЬЦА ДОСЛОВНО «Хорошо, поехали» В ОТВЕТ НА НАЗВАННОЕ ЧИСЛО $1.70. +# Заведены ФД-K и ФД-L — замер ПОДАЧИ ВХОДА КРИТИКУ (шаг 2 курса, гипотезы Г1в/Г1/Г2). +# Число названо владельцу ДО подъёма и с раскладкой: en 16 клеток × 2 модели, zh 8 × 2, цены взяты +# из ЗАМЕРЕННЫХ токенов уже купленных критических клеток и пере-оценены текущим пином — +# `deepseek-v4-pro` en $0.0418 / zh $0.0207, `glm-5` при том же выходе $0.0337 / $0.0165. +# Раскладка: ФД-L 0.669 + 0.539 = 1.21 → потолок 1.30; ФД-K 0.166 + 0.132 = 0.30 → потолок 0.40. +# ⚠ ДВЕ МОДЕЛИ, А НЕ ОДНА — ВОПРОС ВЛАДЕЛЬЦА 20.08 («это только дипсика касается?»). Сокращение +# выборки с 8 глав до 4 (его же решение) окупило вторую модель ровно: смета осталась в $1.70. +# ⚠ ПАКЕТНЫЙ ПОТОЛОК $12.10 → $13.80 РОВНО НА ДЕЛЬТУ новых фаз плюс 3 цента, чтобы инвариант +# «сумма фазовых ≤ пакетного» держался (13.77 ≤ 13.80). Ни одна прежняя фаза не двигается. +# ⚠ ЧТО ЗДЕСЬ ОЦЕНКА, А НЕ ФАКТ: половина сметы — `deepseek-v4-pro`, и он тарифицируется по +# времени суток. В офф-пик спишется около $1.09 вместо $1.51. Касса букирует ПИК осознанно. CEILINGS = {"ФД-A": 1.910, "ФД-B": 0.900, "ФД-C": 0.370, "ФД-D": 0.050, "ФД-E": 2.290, "ФД-F": 0.500, "ФД-G": 0.060, "ФД-H": 0.090, - "ФД-I": 3.950, "ФД-J": 1.950} -PACK_CEILING = 12.10 + "ФД-I": 3.950, "ФД-J": 1.950, "ФД-K": 0.400, "ФД-L": 1.300} +PACK_CEILING = 13.80 # ⚠ ТРЕТИЙ ПОДЪЁМ ФД-I — ЭТО МОЯ ОШИБКА ОЦЕНКИ, А НЕ РАСШИРЕНИЕ ЗАКАЗАННОГО, И ЗАПИСЫВАЮ ЕЁ ТАК. # Владелец санкционировал словом «Поднимай» ПЯТЬ клеток шумового пола. Три купились, две не # влезли: я считал китайскую клетку пола по $0.059, а фактическая цена оказалась $0.098 — @@ -241,7 +255,8 @@ def report() -> None: what = {"ФД-A": "Д4 edit-контур zh", "ФД-B": "Д3 en→ru несущая", "ФД-C": "Д6 ja разведка", "ФД-D": "Д7 самооценка", "ФД-E": "Д1 gemini", "ФД-F": "H-1 (A6)", "ФД-G": "H-4 (E6)", "ФД-H": "H-4 японская нога (glm-5 на ja)", - "ФД-I": "Д17 заход под новую рубрику, zh", "ФД-J": "Д17 заход под новую рубрику, en"} + "ФД-I": "Д17 заход под новую рубрику, zh", "ФД-J": "Д17 заход под новую рубрику, en", + "ФД-K": "Д22 подача входа критику, zh", "ФД-L": "Д22 подача входа критику, en"} for ph in TAGS: sp = Guarded(ph).spent() tot += sp diff --git a/eval/dovodka/podacha.py b/eval/dovodka/podacha.py new file mode 100644 index 00000000..20a6cb70 --- /dev/null +++ b/eval/dovodka/podacha.py @@ -0,0 +1,722 @@ +#!/usr/bin/env python3 +"""ПОДАЧА ВХОДА КРИТИКУ — что решает цену стадии: СЛОВА инструкции или ФОРМА подачи. Платный. + +⚠ ОТКУДА ВОПРОС. Разбор токенов по ролям (журнал §14.2) дал разрыв, который никто не заказывал: +китайский смысловой критик думает 1 084 токена, английский — 9 576 при вдвое КОРОТКОМ входе. +Объяснение нашлось замером структуры (§14.3): китайская вебновелла набрана ПО ПРЕДЛОЖЕНИЮ НА +СТРОКУ, и черновик её разбивку скопировал (77 строк исходника → 78 абзацев перевода, 0.96:1); +английский приходит сплошным блоком (1 строка → 8 абзацев, 0.12:1). То есть дорого не «сличать +два текста», а сличать два НЕВЫРОВНЕННЫХ текста. У DeepSeek размышление тарифицируется по цене +ВЫХОДА, поэтому 85% цены дорогой клетки — то, что модель надумала про себя. + +⚠⚠ ЭТО ОДНО НАБЛЮДЕНИЕ, И ОБЪЯСНИЛ ЕГО Я ЖЕ. Норма, заведённая разговором 17.08: прежде чем +строить схему поверх наблюдения, проверить НАБЛЮДЕНИЕ отдельным дешёвым замером. Здесь ничего не +выравнивается и не размечается — меняется только ПОДАЧА одного и того же материала. + +⛔⛔ НАХОДКА, СДЕЛАННАЯ ПРИ ПОСТРОЙКЕ ЭТОГО ЗАМЕРА И МЕНЯЮЩАЯ ЕГО ЦЕЛЬ. Английский исходник +приходит критику одной строкой НЕ ПОТОМУ, что так устроен английский или книга, а потому, что так +устроен НАШ ЭКСТРАКТОР: `pair_en.raw_text()` сносит ВСЕ теги регексом `<[^>]+>` → пробел, вместе +с `

`. Замер по самому epub: в книге **11 447 абзацев, медиана 62 знака**; наш плоский текст +несёт 1 720 строк вместо 13 243. На единицу в 1642 знака приходится ~26 авторских абзацев, а мы +подаём их одним блоком. То есть невыровненность английской пары — АРТЕФАКТ НАШЕЙ НАРЕЗКИ, и +чинится он бесплатно, у нас же. Проверено: текст всех 16 английских единиц находится в абзацной +версии побайтно после нормализации пробелов, значит структуру можно вернуть, НЕ трогая +зафризенный экстрактор закрытой оси (менять его нельзя — uid единиц есть функция его текста). + +ЧЕТЫРЕ ВАРИАНТА. Исходник и черновик во всех четырёх несут ОДИН И ТОТ ЖЕ текст; меняется ПОДАЧА: + + P0 БАЗА нынешний промт критика, исходник как есть (одним блоком) + PF ФРАЗА + правдивое описание параллельности текстов (идея владельца 17.08) + PA АБЗАЦЫ база + исходнику ВОЗВРАЩЕНЫ авторские абзацы из epub (перевод не трогаем) + PB ОБА фраза + абзацы + +⚠ ПОЧЕМУ НЕ «РАЗБИТЬ ПО ПРЕДЛОЖЕНИЮ НА СТРОКУ», КАК ПЛАНИРОВАЛОСЬ. Замечание владельца 20.08: +осмысленная разметка — это ГРУППИРОВКА по смыслу, а не более мелкое дробление; дробить мельче +значит увеличить число кусков, которые модель обязана сопоставить. Плюс замер: медиана авторского +абзаца у Кристоффа 62 знака, то есть примерно одно предложение, и синтетическая разбивка была бы +почти побайтным близнецом `PA` — второй оплаченной копией того же условия. Авторские абзацы и +честнее (это структура, которую написал автор), и продуктово осмысленнее (это то, что можно +починить в проде одной правкой экстрактора). + +⚠ У КИТАЙСКОЙ ПАРЫ ВАРИАНТОВ ДВА, И ЭТО НЕ ЭКОНОМИЯ. Её исходник УЖЕ разбит по предложению на +строку — свойство материала, замеренное: 78 строк по 24 знака на главу. Возвращать абзацы нечему, +`PA`/`PB` там были бы побайтной копией `P0`/`PF`, то есть двумя оплаченными близнецами. +Селфтест это ПРОВЕРЯЕТ на живом сырье, а не верит комментарию. +⚠ Японская пара сюда не входит, но замерена тем же счётом и ложится к китайской: 19 строк по +43 знака, черновик 19 абзацев — отношение 1.00:1, самое выровненное из трёх книг. Значит +«дорогой режим» есть у ОДНОЙ книги из трёх, и именно у той, которую испортили мы. + +⚠ ФОРМУЛИРОВКА ФРАЗЫ — ТОЛЬКО ПРАВДА, И ЭТО НЕ ЭТИКА, А МЕХАНИКА. «Предложение за предложением» +было бы ЛОЖЬЮ: наш же промт редактора обязывает сливать абзацы (замер: 77 строк исходника → 43 +абзаца отредактированного). Соврём — модель будет искать соответствие, не найдёт и потратит +БОЛЬШЕ, а не меньше. + +⚠⚠ РИСК, РАДИ КОТОРОГО ЗАВЕДЕНЫ ПОСАДКИ. Фраза про полное покрытие исходника может заставить +критика ПЕРЕСТАТЬ ИСКАТЬ ПРОПУСКИ — а потерянный кусок один из главных классов дефекта. Выигрыш +в токенах, купленный слепотой к целому классу, не выигрыш. Поэтому в каждый черновик садятся ДВА +дефекта с ИЗВЕСТНЫМ АДРЕСОМ (`plants.json`, пишется ДО первой покупки): + + ПРОПУСК из черновика удаляется целый абзац — тот самый класс, что под угрозой + ИНВЕРСИЯ снимается отрицание — смысл переворачивается, текст остаётся грамотным + +⇒ НИКТО НИЧЕГО НЕ РАЗМЕЧАЕТ РУКАМИ. Правильный ответ известен ДО прогона, потому что порчу внесли +мы сами; «нашёл или нет» считается детерминированно. Вопрос владельца 20.08 («а кто данные +разметит в таблицу? ты сам?») закрыт устройством замера, а не обещанием. + +⚠ И У ДЕТЕКТОРА ЕСТЬ НУЛЕВАЯ МОДЕЛЬ — норма Д0.6 требует от всякого классификатора печатать +частоту срабатывания на СЛУЧАЙНОМ входе. Здесь она бесплатна: те же главы уже отсужены критиком +по НЕиспорченному черновику (клетки `A3`/`E3`), и детектор гоняется по ним. Сколько раз он +«находит» посадку там, где её не было, — и есть его шансовый пол. + +⚠ ВАРИАНТЫ СТРЕЛЯЮТСЯ ВПЕРЕМЕШКУ ВНУТРИ ГЛАВЫ, И ЭТО НЕ АККУРАТНОСТЬ. Квирк-бюллетень (:110) +несёт замер: шесть дефолтных розыгрышей ОДНОГО запроса, разнесённых по времени, дали строго +растущий ряд размышления 1952 → 9104 → 10818 → 11157 → 13421 → 15720 знаков. Купи мы сначала все +`P0`, потом все `PF` — этот дрейф и стал бы «эффектом». Порядок вариантов внутри главы к тому же +перетасован детерминированно от соли, чтобы и позиция в очереди не доставалась одному варианту. + +⚠ ДВЕ МОДЕЛИ — вопрос владельца 20.08 «это только дипсика касается?». `deepseek-v4-pro` (жилец) и +`glm-5` (настоящая альтернатива редакторского тира: другой вендор, цены не поднимал). +⚠ `glm-5` идёт с ВКЛЮЧЁННЫМ размышлением, и это ОБЪЯВЛЕННОЕ отступление от боевой конфигурации: +`roster.THINK` гасит ему thinking (`extra_body {"thinking": {"type": "disabled"}}`), а мы меряем +ровно размышление — с выключенным мерить было бы нечего. Цена отступления: у glm thinking-ON +раньше не влезал в таймаут (квирки :52), и это может выстрелить. Выстрелит — печатается как +результат, а не прячется. +⚠ ГРОК И GEMINI ОТВЕРГНУТЫ ДО ПОКУПКИ, с основанием: у grok размышление гуляет ×163 на побайтно +одном входе (квирки :69 — 98 · 8000 · 14014 · 16000 на одном куске), при четырёх главах этот шум +съест любой эффект; Gemini на нашем материале массово падает в контент-фильтр (эксп-21 §1/§8). + +Запуск: podacha.py --dry | --selftest | --plants | --buy [zh|en] | --score +""" +from __future__ import annotations + +import hashlib +import importlib.util +import json +import re +import statistics as st +import sys +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +ZONE = Path(__file__).resolve().parent +for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"): + sys.path.insert(0, str(REPO / "eval" / d)) + + +def _load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +# ⚠ Контур берётся У ДРАЙВЕРА английской оси, а не грузится вторым экземпляром: `en_axis` держит +# СВОЙ `contour.py`, и `wire()` настраивает хуки именно в нём. Свой второй экземпляр остался бы с +# китайскими хуками, английские черновики читались бы китайским путём и возвращали пусто — +# английская ось молча стала бы пустой. Класс уже стоил фазе оплаченных клеток, поймано `--dry`. +EN = _load("en_axis_p", ZONE / "en_axis.py") +C = EN.C +MONEY, PR, ROSTER, ED22, PARA = C.MONEY, C.PR, C.ROSTER, C.ED22, C.PARA +OUT = MONEY.OUT +ZK = _load("zakhod_p", ZONE / "zakhod.py") # гейт перелома цены и пред-полётная проба + +EPUB_EN = Path.home() / "books" / "Kristoff_Jay_-_Empire_of_the_Vampire_3_-_Empire_of_the_Dawn.epub" + +SALT = "podacha-2026-08-20" # соль отбора глав и порядка вариантов; впечатана ДО покупок +N_UNITS = 4 # решение владельца 20.08: «восемь глав слишком, сократи до 4» +MODELS = ("deepseek-v4-pro", "glm-5") +MAX_OUT = 32000 # как в заходе: обрыв на потолке стоит денег и не даёт данных +TRUTH = OUT / "podacha-plants.json" # правильный ответ; пишется ДО первой покупки + +# ⚠ ПАРА ЖИВЁТ В ДАННЫХ. Ни одна ветка ниже не спрашивает «а это английский?»: возврат абзацев — +# такой же параметр пары, как её буква тега или касса. Пара, которой в репозитории ещё нет, +# заводится строкой здесь и не требует правки ни одной функции. +PAIRS = { + "zh": {"letter": "k", "phase": "ФД-K", "crit": "A3", "vars": ("P0", "PF"), + "wire": lambda: C.configure(PARA.ZH), "units": lambda: C.units_ok(), + "reflow": None}, # исходник уже по предложению на строку — возвращать нечего + "en": {"letter": "l", "phase": "ФД-L", "crit": "E3", "vars": ("P0", "PF", "PA", "PB"), + "wire": EN.wire, "units": EN.units, + "reflow": lambda src: restore_paragraphs(src, EPUB_EN)}, +} +ALL_VARS = ("P0", "PF", "PA", "PB") +WHAT = {"P0": "база — нынешний промт критика", + "PF": "+ ФРАЗА о параллельности текстов (идея владельца)", + "PA": "+ исходнику ВОЗВРАЩЕНЫ авторские абзацы", + "PB": "фраза И абзацы вместе"} + +# ── ФРАЗА. Каждое утверждение проверяемо, ни одного обещания сверх правды ───────────────────── +# «должен покрывать», а не «покрывает»: второе было бы обещанием, которого мы не даём, и оно же +# снимало бы с критика обязанность искать пропуски. Первое называет ТРЕБОВАНИЕ — то самое, из-за +# которого пропуск и есть ошибка. +PARALLEL_NOTE = ( + "\n\nКАК СООТНОСЯТСЯ ЭТИ ДВА ТЕКСТА. Они идут ПАРАЛЛЕЛЬНО: порядок изложения в переводе тот " + "же, что в исходнике, и перевод ДОЛЖЕН покрывать исходник целиком. При этом членение на " + "абзацы и предложения может отличаться — переводчику разрешено сливать и дробить, и это не " + "ошибка. Читай оба текста параллельно, сверяя ход за ходом, и НЕ ищи точных пар предложений.") + +# ── ВОЗВРАТ АВТОРСКИХ АБЗАЦЕВ ──────────────────────────────────────────────────────────────── +# ⚠ ЗАФРИЗЕННЫЙ ЭКСТРАКТОР ЗАКРЫТОЙ ОСИ НЕ ТРОГАЕТСЯ. `pair_en.raw_text()` определяет uid единиц +# (`pair_en.py:144` предупреждает прямо), и правка его пере-именовала бы всю английскую ось — +# то есть обнулила бы отсуженное. Поэтому абзацы восстанавливаются ЗДЕСЬ и только для подачи: +# книга читается второй раз, с сохранением границ `

`, ``, `
`; текст единицы ищется +# в ней по нормализованным пробелам, и возвращается тот же самый текст — но с переводами строк. +# ⚠ Ни знака не добавляется и не теряется: селфтест сверяет обе версии побайтно после снятия +# пробельных различий. Если единица не нашлась — возвращается исходная подача, и число ненайденных +# печатается; молча подменять вариант нельзя, иначе смета оплатит близнецов. +_WS = re.compile(r"\s+") +_para_cache: dict[str, tuple[str, str, list[int]]] = {} + + +def _epub_paragraphed(path: Path) -> tuple[str, str, list[int]]: + """(текст с абзацами, его нормализованная копия, карта позиций). Считается один раз.""" + key = str(path) + if key in _para_cache: + return _para_cache[key] + import html as _html # noqa: PLC0415 + import zipfile # noqa: PLC0415 + z = zipfile.ZipFile(path) + parts = [] + for n in sorted(x for x in z.namelist() if x.endswith((".xhtml", ".html", ".htm"))): + raw = z.read(n).decode("utf-8", "ignore") + raw = re.sub(r"(?i)", "\n", raw) + raw = re.sub(r"(?i)", "\n", raw) + parts.append(re.sub(r"[ \t]+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", raw)))) + text = "\n".join(parts) + norm, idx = [], [] + prev_ws = True + for i, ch in enumerate(text): + if ch.isspace(): + if not prev_ws: + norm.append(" ") + idx.append(i) + prev_ws = True + else: + norm.append(ch) + idx.append(i) + prev_ws = False + _para_cache[key] = (text, "".join(norm).strip(), idx) + return _para_cache[key] + + +def restore_paragraphs(src: str, epub: Path) -> str: + """Тот же текст единицы, но с авторскими границами абзацев. Пусто — если не нашёлся.""" + text, norm, idx = _epub_paragraphed(epub) + want = _WS.sub(" ", src).strip() + i = norm.find(want) + if i < 0: + return "" + a, b = idx[i], idx[min(i + len(want) - 1, len(idx) - 1)] + 1 + return "\n".join(x.strip() for x in text[a:b].split("\n") if x.strip()) + + +# ── ПОСАДКИ: ДВА ДЕФЕКТА С ИЗВЕСТНЫМ АДРЕСОМ ───────────────────────────────────────────────── +# ⚠ Классы выбраны не воображением. «Снятое отрицание» замерено на этом же сырье как ложащееся на +# 31 единицу из 31 (норма маржевого декоя, `sud.MARGIN_PLANTS`): меняется одно слово, текст +# остаётся связным и грамотным, ошибка ТОЛЬКО смысловая. «Удалённый абзац» — тот самый класс, +# которому угрожает фраза про полное покрытие; он и есть предмет страховки. +_NEG = re.compile(r"\bне\s+([а-яё]{3,}(?:л|ла|ло|ли|ет|ит|ют|ат|ят))\b") +MIN_PARA = 120 # абзац короче не удаляем: пропажу двух слов не заметит и человек +# ⚠ ВЕРХНЯЯ ГРАНИЦА ЗАВЕДЕНА ВЫЧИТКОЙ ПОСАДОК ПЕРЕД ПОКУПКОЙ, И ЭТО НЕ ПЕДАНТИЗМ. Первая редакция +# знала только нижний порог, и на английской единице `100b088f71` под удаление попал абзац в +# 1602 знака при черновике в 1642 — то есть посадка сносила ПОЧТИ ВЕСЬ текст. Критик увидел бы +# пустую клетку, «нашёл пропуск» стало бы тавтологией, а замер токенов на этой единице мерил бы +# другую задачу. Пропажа обязана быть НЕЗАМЕТНОЙ на глаз в объёме — иначе это не тонкая посадка, +# а второй грубый декой (ровно та ошибка, за которую в паке 23 контроль оказался пуст). +MAX_DROP_SHARE = 0.25 # удаляемый абзац — не больше четверти черновика + + +def plant(draft: str, uid: str) -> tuple[str, dict]: + """Испорченный черновик и ПРАВИЛЬНЫЙ ОТВЕТ к нему. Детерминированно от соли и uid. + + ⚠ Возвращает не только адреса, но и УЛИКИ — слова из удалённого абзаца и оба варианта фразы с + отрицанием. По ним детектор потом решает «нашёл или нет», и решает без единой ручной разметки. + """ + paras = [p for p in draft.split("\n") if p.strip()] + cand = [i for i, p in enumerate(paras) + if MIN_PARA <= len(p) <= len(draft) * MAX_DROP_SHARE] + truth: dict = {"uid": uid} + if cand: + # середина списка кандидатов, сдвинутая хешем: ни первый абзац (там имена и зачин), ни + # последний (там развязка) — оба слишком заметны и сделали бы посадку грубой + h = int(hashlib.sha256(f"{SALT}|{uid}|drop".encode()).hexdigest(), 16) + i = cand[len(cand) // 4 + h % max(1, len(cand) // 2)] + truth["dropped"] = paras[i] + paras = paras[:i] + paras[i + 1:] + out = "\n\n".join(paras) + m = _NEG.search(out) + if m: + truth["negation_before"] = m.group(0) + truth["negation_after"] = m.group(1) + out = out[:m.start()] + m.group(1) + out[m.end():] + return out, truth + + +def truth_all() -> dict: + return json.loads(TRUTH.read_text(encoding="utf-8")) if TRUTH.exists() else {} + + +def write_truth() -> dict: + """Правильный ответ на диск ДО первой покупки. Существующий НЕ переписывается. + + ⚠ Пере-запись после ответов означала бы, что «правильный ответ» стал функцией того, что модель + ответила. Ровно этот класс — раскладка меток, пере-эмитированная поверх отсуженной оси, — уже + стоил фазе аварии, и починка была в том, что ключ пишется один раз и до ответов. + """ + if TRUTH.exists(): + return truth_all() + key: dict = {} + for p, cfg in PAIRS.items(): + cfg["wire"]() + for u in chosen(p): + _t, tr = plant(C.base_draft(u["uid"]), u["uid"]) + key[f"{p}|{u['uid']}"] = tr + TRUTH.write_text(json.dumps(key, ensure_ascii=False, indent=1), encoding="utf-8") + print(f"правильный ответ записан ДО покупок: {TRUTH} ({len(key)} глав)") + return key + + +# ── ЗАПРОС ─────────────────────────────────────────────────────────────────────────────────── +def crit_msgs(p: str, src: str, draft: str, var: str) -> list[dict]: + """Запрос критика в заданном варианте подачи. + + ⚠ Системный промт берётся у БОЕВОГО критика (`contour.crit_msgs`) и только дополняется: если + собрать его здесь заново, замер сравнивал бы два разных промта, а не две подачи одного. + """ + if var not in ALL_VARS: + raise SystemExit(f"⛔ вариант {var!r} неизвестен; знаю {' '.join(ALL_VARS)}") + cfg = PAIRS[p] + cfg["wire"]() + shown = src + if var in ("PA", "PB"): + if not cfg["reflow"]: + raise SystemExit(f"⛔ у пары {p} возврат абзацев не заведён — вариант {var} невозможен") + shown = cfg["reflow"](src) + if not shown.strip(): + raise SystemExit(f"⛔ авторские абзацы для этой единицы не нашлись — вариант {var} " + "стал бы побайтным близнецом базы, покупка отказана") + m = C.crit_msgs(shown, draft) + if var in ("PF", "PB"): + m[0]["content"] = m[0]["content"].rstrip() + PARALLEL_NOTE + return m + + +def tag(p: str, var: str, model: str, uid: str) -> str: + short = {"deepseek-v4-pro": "ds", "glm-5": "gl"}[model] + return f"dv-{PAIRS[p]['letter']}-{var.lower()}{short}-{uid}" + + +def cell(tg: str) -> dict | None: + f = OUT / f"{tg}.json" + if not f.exists(): + return None + d = json.loads(f.read_text(encoding="utf-8")) + return d if d.get("finish") == "stop" and (d.get("content") or "").strip() else None + + +def call_kwargs(model: str, msgs: list[dict]) -> dict: + """Боевая конфигурация модели — КРОМЕ явно объявленного отступления по размышлению glm-5.""" + kw = ROSTER.call_kwargs(model, msgs, max_out=MAX_OUT) + if model == "glm-5": + kw.pop("extra_body", None) # вендор-дефолт = thinking ON; см. шапку файла + return kw + + +# ── ОТБОР ГЛАВ ─────────────────────────────────────────────────────────────────────────────── +def pool(p: str) -> list[dict]: + """Главы, где есть черновик, боевой перевод И ответ критика по НЕиспорченному входу. + + Последнее обязательно: эта клетка — нулевая модель детектора, и без неё замер не может + отличить «детектор сработал на посадке» от «детектор срабатывает всегда». + """ + cfg = PAIRS[p] + cfg["wire"]() + out = [] + for u in cfg["units"](): + d = C.base_draft(u["uid"]) + if not d.strip(): + continue + base = OUT / f"{C.tag(cfg['crit'], u['uid'], 'crit')}.json" + if not (base.exists() + and (json.loads(base.read_text(encoding="utf-8")).get("content") or "").strip()): + continue + # ⚠ ОБЕ ПОСАДКИ ОБЯЗАНЫ ЛОЖИТЬСЯ, И ЭТО УСЛОВИЕ ОТБОРА, А НЕ ПОСТ-ФИЛЬТР. Глава, куда + # тонкая пропажа не помещается (все абзацы либо короче порога, либо больше четверти + # текста), даёт клетку без правильного ответа — и «не нашёл» на ней означает «нечего было + # находить». Такие главы в замер не идут вовсе, а не молча портят долю. + after, tr = plant(d, u["uid"]) + if not tr.get("dropped") or not tr.get("negation_before"): + continue + # авторские абзацы обязаны находиться — иначе вариант станет близнецом базы + if cfg["reflow"] and not cfg["reflow"](u["source"]).strip(): + continue + out.append(u) + return out + + +def chosen(p: str) -> list[dict]: + """N_UNITS глав, отобранных ДЕТЕРМИНИРОВАННО от объявленной соли, а не по длине. + + Длина коррелирует с трудностью, трудность — с тем, сколько модель думает; отбор по длине + покупал бы часть ответа заранее. + """ + us = pool(p) + return sorted(us, key=lambda u: hashlib.sha256( + f"{SALT}|{p}|{u['uid']}".encode()).hexdigest())[:N_UNITS] + + +def var_order(p: str, uid: str, model: str) -> list[str]: + """Порядок вариантов ВНУТРИ главы — перетасован от соли. См. шапку про дрейф размышления.""" + vs = list(PAIRS[p]["vars"]) + return sorted(vs, key=lambda v: hashlib.sha256( + f"{SALT}|{p}|{uid}|{model}|{v}".encode()).hexdigest()) + + +# ── ПОКУПКА ────────────────────────────────────────────────────────────────────────────────── +def buy(p: str) -> None: + ZK.price_gate() + cfg = PAIRS[p] + cfg["wire"]() + write_truth() + ZK.preflight(list(MODELS), cfg["phase"], cfg["letter"]) + us = chosen(p) + key = truth_all() + led = MONEY.Guarded(cfg["phase"], default_expect=0.045) + print(f"\nпара {p} · глав {len(us)} · варианты {' '.join(cfg['vars'])} · " + f"модели {' '.join(MODELS)} · касса {cfg['phase']}") + for u in us: + uid, src = u["uid"], u["source"] + if f"{p}|{uid}" not in key: + raise SystemExit(f"⛔ у главы {uid} нет записанного правильного ответа — " + "покупка остановлена, посадки должны быть зафиксированы ДО неё") + draft, _tr = plant(C.base_draft(uid), uid) + for model in MODELS: + for var in var_order(p, uid, model): + tg = tag(p, var, model, uid) + if cell(tg): + continue + # ⚠ Гейт перелома цены — перед КАЖДОЙ клеткой, а не один раз на старте: прогон + # длится часами, и пересёкший 16:00 UTC цикл писал бы пост-переломные вызовы по + # июльскому прайсу. + ZK.price_gate(quiet=True) + rec = MONEY.purchase(led, tg, model, ED22.client(model), + call_kwargs(model, crit_msgs(p, src, draft, var)), + role="critic", uid=uid, arm=var, pair=p) + if rec.get("skipped"): + raise SystemExit( + f"⛔ ГАРД ОТКАЗАЛ на клетке {tg} — прогон ОСТАНОВЛЕН.\n" + " Резать панель или поднимать потолок решением сессии ЗАПРЕЩЕНО.\n" + " Это СТОП и вопрос владельцу: назвать недостающую сумму.") + if rec.get("finish") != "stop": + print(f" ⚠ {tg}: finish={rec.get('finish')} " + f"{str(rec.get('error'))[:120]} — клетка объявляется несобранной") + MONEY.report() + + +# ── РАЗБОР ─────────────────────────────────────────────────────────────────────────────────── +def findings(txt: str) -> list[str]: + """Строки-находки критика. Тот же разбор, что у боевого контура, плюс фильтр согласий.""" + lines = [ln.strip("-•* \t") for ln in txt.splitlines() if ln.strip().startswith(("-", "•", "*"))] + return [ln for ln in lines if not ZK._is_confirmation(ln)] # noqa: SLF001 + + +_WORD = re.compile(r"[А-Яа-яЁёA-Za-z]{4,}") + + +def _caught_drop(txt: str, tr: dict) -> bool: + """Заметил ли критик УДАЛЁННЫЙ абзац. Детерминированно, без ручной разметки. + + Правило: критик обязан сослаться на содержимое пропавшего куска — назвать хотя бы два редких + слова из него. Редкость меряется по самому тексту главы: слова, встречающиеся в удалённом + абзаце и НЕ встречающиеся в остальном черновике, — единственная улика, которая не могла + приехать из соседних абзацев. + """ + dropped = tr.get("dropped") or "" + uniq = tr.get("_uniq") or [] + if not dropped or not uniq: + return False + return sum(1 for w in uniq if w in txt.lower()) >= 2 + + +def _caught_neg(txt: str, tr: dict) -> bool: + """Заметил ли критик снятое отрицание: процитировал испорченную форму или назвал отрицание.""" + after = (tr.get("negation_after") or "").lower() + if not after: + return False + low = txt.lower() + return (after in low and ("отриц" in low or "не " + after in low)) or f"«{after}" in low + + +def _uniq_words(tr: dict, draft_after: str) -> list[str]: + """Редкие слова удалённого абзаца — считаются ЗДЕСЬ, а не хранятся: правильный ответ не должен + зависеть от того, как мы сегодня решили считать редкость.""" + d = (tr.get("dropped") or "").lower() + rest = draft_after.lower() + return sorted({w for w in _WORD.findall(d) if len(w) >= 5 and w not in rest})[:12] + + +def score() -> None: + key = truth_all() + if not key: + raise SystemExit("⛔ правильного ответа на диске нет — замер не разбирается") + for p, cfg in PAIRS.items(): + cfg["wire"]() + us = chosen(p) + print("\n" + "=" * 78) + print(f"ПАРА {p.upper()} · глав {len(us)} · варианты {' '.join(cfg['vars'])}") + print("=" * 78) + for model in MODELS: + rows: dict = {} + for u in us: + uid = u["uid"] + tr = dict(key.get(f"{p}|{uid}") or {}) + after, _ = plant(C.base_draft(uid), uid) + tr["_uniq"] = _uniq_words(tr, after) + for var in cfg["vars"]: + d = cell(tag(p, var, model, uid)) + if not d: + continue + txt = d.get("content") or "" + fs = findings(txt) + rows.setdefault(var, {})[uid] = dict( + think=d.get("reasoning_tokens") or 0, + billed=ROSTER.billed_output(model, d.get("completion_tokens") or 0, + d.get("reasoning_tokens") or 0, + d.get("total_tokens") or 0, + d.get("prompt_tokens") or 0), + inp=d.get("prompt_tokens") or 0, cost=d.get("cost_usd") or 0, + n=len(fs), drop=_caught_drop(txt, tr), neg=_caught_neg(txt, tr)) + if not rows: + print(f"\n {model}: клеток нет") + continue + print(f"\n === {model} ===") + print(f" {'вар':4s}{'кл':>3s}{'вход':>7s}{'РАЗМЫШЛ':>9s}{'оплач.вых':>10s}" + f"{'$/кл':>9s}{'находок':>9s}{'пропуск':>9s}{'отриц':>7s} что это") + for var in cfg["vars"]: + r = rows.get(var) or {} + if not r: + print(f" {var:4s} — клеток нет") + continue + med = lambda k: st.median([v[k] for v in r.values()]) # noqa: E731 + print(f" {var:4s}{len(r):3d}{med('inp'):7.0f}{med('think'):9.0f}" + f"{med('billed'):10.0f}{med('cost'):9.5f}{med('n'):9.1f}" + f"{sum(v['drop'] for v in r.values()):5d}/{len(r):<3d}" + f"{sum(v['neg'] for v in r.values()):4d}/{len(r):<2d} {WHAT[var]}") + base = rows.get("P0") or {} + if base: + print("\n ПАРНАЯ РАЗНИЦА ПРОТИВ БАЗЫ (одна и та же глава, одна и та же модель):") + for var in cfg["vars"]: + if var == "P0" or var not in rows: + continue + ds = [rows[var][u]["billed"] - base[u]["billed"] + for u in rows[var] if u in base] + dn = [rows[var][u]["n"] - base[u]["n"] for u in rows[var] if u in base] + if not ds: + continue + print(f" {var:4s} оплаченный выход {st.mean(ds):+8.0f} токенов " + f"({st.mean(ds) / max(1, st.mean([base[u]['billed'] for u in base])):+.0%}) " + f"· находок {st.mean(dn):+.1f} · единиц {len(ds)}") + # НУЛЕВАЯ МОДЕЛЬ ДЕТЕКТОРА — по клеткам критика, где посадки НЕ БЫЛО + print("\n НУЛЕВАЯ МОДЕЛЬ ДЕТЕКТОРА (клетки критика по НЕиспорченному черновику):") + fp_d = fp_n = tot = 0 + for u in us: + uid = u["uid"] + f = OUT / f"{C.tag(cfg['crit'], uid, 'crit')}.json" + if not f.exists(): + continue + txt = json.loads(f.read_text(encoding="utf-8")).get("content") or "" + tr = dict(key.get(f"{p}|{uid}") or {}) + after, _ = plant(C.base_draft(uid), uid) + tr["_uniq"] = _uniq_words(tr, after) + tot += 1 + fp_d += _caught_drop(txt, tr) + fp_n += _caught_neg(txt, tr) + print(f" ложных «нашёл пропуск» {fp_d}/{tot} · ложных «нашёл отрицание» {fp_n}/{tot}") + print(" ⇒ выше этого пола детектор ничего не доказывает") + + +# ── СМЕТА И ПРОВЕРКИ ───────────────────────────────────────────────────────────────────────── +def price_of(p: str, model: str) -> float: + """Ожидаемая цена клетки — из ЗАМЕРЕННЫХ токенов уже купленных клеток критика ЭТОЙ пары, + пере-оценённых ТЕКУЩИМ пином. Зашитых цен в файле нет: они врали вчетверо после пере-пина.""" + toks = [] + for f in OUT.glob(f"{C.tag(PAIRS[p]['crit'], '', 'crit')}*.json"): + if any(x in f.name for x in ("ERROR", "LENGTH", "ЭХО")): + continue + r = json.loads(f.read_text(encoding="utf-8")) + if r.get("finish") == "stop" and r.get("prompt_tokens"): + toks.append((r["prompt_tokens"], r.get("cached_tokens") or 0, + r.get("completion_tokens") or 0, r.get("reasoning_tokens") or 0, + r.get("total_tokens") or 0)) + if not toks: + return 0.045 + med = [st.median([x[i] for x in toks]) for i in range(5)] + return ROSTER.cost(model, int(med[0]), int(med[1]), int(med[2]), int(med[3]), int(med[4])) + + +def cmd_dry() -> None: + print(f"ПОДАЧА ВХОДА КРИТИКУ · соль {SALT} · глав на пару {N_UNITS}\n") + ZK.price_gate() + tot = 0.0 + for p, cfg in PAIRS.items(): + cfg["wire"]() + us, pl = chosen(p), pool(p) + print(f"\n=== пара {p} · пул {len(pl)} · взято {len(us)} · касса {cfg['phase']} ===") + sub = 0.0 + for model in MODELS: + pr = price_of(p, model) + need = sum(1 for u in us for v in cfg["vars"] + if not cell(tag(p, v, model, u["uid"]))) + sub += need * pr + print(f" {model:18s} надо {need:2d} клеток × ${pr:.5f} = ${need * pr:.4f}") + sp = MONEY.Guarded(cfg["phase"]).spent() + ceil = MONEY.CEILINGS[cfg["phase"]] + mark = "ВЛЕЗАЕТ" if sub <= ceil - sp else f"НЕ ВЛЕЗАЕТ (не хватает ${sub - (ceil - sp):.4f})" + print(f" ИТОГО ${sub:.4f} · потолок {cfg['phase']} ${ceil:.2f} · " + f"потрачено ${sp:.4f} → {mark}") + tot += sub + print(f"\nВЕСЬ ЗАМЕР: ${tot:.4f} по текущему (пиковому) пину ростера.") + + +def cmd_plants() -> None: + """Показать посадки глазами — обязательная вычитка ПЕРЕД покупкой.""" + key = write_truth() + for p, cfg in PAIRS.items(): + cfg["wire"]() + for u in chosen(p): + tr = key[f"{p}|{u['uid']}"] + after, _ = plant(C.base_draft(u["uid"]), u["uid"]) + print(f"\n--- {p}/{u['uid']} ---") + print(f" УДАЛЁН абзац ({len(tr.get('dropped') or '')} знаков): " + f"{(tr.get('dropped') or '—')[:160]}…") + print(f" редкие слова-улики: {' '.join(_uniq_words(tr, after)) or '⛔ НЕТ'}") + print(f" ОТРИЦАНИЕ: «{tr.get('negation_before', '—')}» → " + f"«{tr.get('negation_after', '—')}»") + + +def cmd_selftest() -> int: + bad = 0 + + def ck(n: str, ok: bool, d: str = "") -> None: + nonlocal bad + bad += not ok + print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else "")) + + for p, cfg in PAIRS.items(): + cfg["wire"]() + us = chosen(p) + ck(f"{p}: глав ровно {N_UNITS}", len(us) == N_UNITS, f"их {len(us)}") + ck(f"{p}: отбор ДЕТЕРМИНИРОВАН", [u["uid"] for u in chosen(p)] == [u["uid"] for u in us]) + u = us[0] + src, draft = u["source"], C.base_draft(u["uid"]) + # ⚠ ГЛАВНОЕ ОБОСНОВАНИЕ ПАНЕЛИ — ПРОВЕРЯЕТСЯ НА ЖИВОМ СЫРЬЕ, А НЕ ВЕРИТСЯ КОММЕНТАРИЮ. + # «У китайской пары исходник уже разбит» — если это перестанет быть правдой, панель из + # двух вариантов молча станет неполной. + per_line = st.median([len(x) for x in src.split("\n") if x.strip()]) + already = per_line < 60 + ck(f"{p}: исходник {'УЖЕ разбит' if already else 'сплошной'} — " + f"возврат абзацев {'НЕ нужен' if already else 'нужен'}, вариантов {2 if already else 4}", + len(cfg["vars"]) == (2 if already else 4) and bool(cfg["reflow"]) == (not already), + f"медиана строки {per_line:.0f} знаков") + if cfg["reflow"]: + # ⚠ ВОЗВРАТ АБЗАЦЕВ ПРОВЕРЯЕТСЯ НА ВСЕХ ВЗЯТЫХ ГЛАВАХ, а не на первой: единица, + # которая не нашлась в книге, сделала бы вариант близнецом базы — оплаченным дважды. + got = [cfg["reflow"](x["source"]) for x in us] + ck(f"{p}: авторские абзацы нашлись для ВСЕХ глав", all(g.strip() for g in got), + f"нашлось {sum(1 for g in got if g.strip())} из {len(got)}") + sp = got[0] + ck(f"{p}: возврат абзацев реально дробит исходник", + sp.count("\n") >= 5, f"строк {src.count(chr(10)) + 1} → {sp.count(chr(10)) + 1}") + ck(f"{p}: возврат абзацев НЕ теряет и НЕ добавляет знаков", + re.sub(r"\s+", "", sp) == re.sub(r"\s+", "", src), + f"{len(re.sub(r'[^А-Яа-яA-Za-z]', '', sp))} против " + f"{len(re.sub(r'[^А-Яа-яA-Za-z]', '', src))} букв") + # четыре варианта обязаны быть ПОПАРНО РАЗНЫМИ запросами, иначе часть сметы — близнецы + msgs = {v: json.dumps(crit_msgs(p, src, draft, v), ensure_ascii=False) + for v in cfg["vars"]} + ck(f"{p}: варианты попарно различны", len(set(msgs.values())) == len(msgs)) + # база обязана быть ПОБАЙТНО боевым критиком — иначе меряем два разных промта + ck(f"{p}: P0 — побайтно боевой критик", + msgs["P0"] == json.dumps(C.crit_msgs(src, draft), ensure_ascii=False)) + ck(f"{p}: ФРАЗА уходит в системный промт и только туда", + PARALLEL_NOTE.strip()[:40] in json.loads(msgs["PF"])[0]["content"] + and PARALLEL_NOTE.strip()[:40] not in json.loads(msgs["PF"])[1]["content"]) + ck(f"{p}: ФРАЗА не обещает построчного соответствия", + "предложение за предложением" not in PARALLEL_NOTE.lower() + and "НЕ ищи точных пар" in PARALLEL_NOTE) + ck(f"{p}: черновик во всех вариантах ОДИН И ТОТ ЖЕ", + len({json.loads(m)[1]["content"].split("ЧЕРНОВОЙ ПЕРЕВОД:")[-1] + for m in msgs.values()}) == 1) + # посадки: обе садятся, обе детерминированы, обе оставляют текст читаемым + after, tr = plant(draft, u["uid"]) + ck(f"{p}: посадка ДЕТЕРМИНИРОВАНА", plant(draft, u["uid"])[0] == after) + # ⚠ ПО ВСЕМ ВЗЯТЫМ ГЛАВАМ, А НЕ ПО ПЕРВОЙ. Вычитка перед покупкой поймала посадку в 1602 + # знака на четвёртой английской главе, тогда как на первой всё было в порядке: гейт, + # смотрящий на `us[0]`, сертифицировал бы негодный замер. + alld = [plant(C.base_draft(x["uid"]), x["uid"]) for x in us] + ck(f"{p}: абзац удалён на КАЖДОЙ главе", + all(t.get("dropped") and t["dropped"] not in a for a, t in alld), + " · ".join(str(len(t.get("dropped") or "")) for _a, t in alld) + " знаков") + ck(f"{p}: отрицание снято на КАЖДОЙ главе", + all(t.get("negation_before") and t["negation_before"] not in a for a, t in alld), + " · ".join(f"«{t.get('negation_before')}»" for _a, t in alld)) + ck(f"{p}: у пропажи есть редкие слова-улики на КАЖДОЙ главе", + all(len(_uniq_words(t, a)) >= 2 for a, t in alld), + " · ".join(str(len(_uniq_words(t, a))) for a, t in alld)) + ck(f"{p}: пропажа ТОНКАЯ — не больше четверти черновика НИ НА ОДНОЙ главе", + all(0 < len(C.base_draft(x["uid"])) - len(a) + <= len(C.base_draft(x["uid"])) * MAX_DROP_SHARE + for x, (a, _t) in zip(us, alld, strict=True)), + " · ".join(f"{(len(C.base_draft(x['uid'])) - len(a)) / max(1, len(C.base_draft(x['uid']))):.0%}" + for x, (a, _t) in zip(us, alld, strict=True))) + # ⚠ ДЕТЕКТОР ОБЯЗАН УМЕТЬ ПАДАТЬ. Гейт, который не может сказать «не нашёл», не сторожит + # ничего — норма, заведённая пустым контролем пака 23. + tr2 = dict(tr, _uniq=_uniq_words(tr, after)) + ck(f"{p}: детектор пропажи МОЛЧИТ на пустом ответе", not _caught_drop("", tr2)) + ck(f"{p}: детектор пропажи СРАБАТЫВАЕТ на тексте самого абзаца", + _caught_drop((tr.get("dropped") or "").lower(), tr2)) + ck(f"{p}: детектор отрицания МОЛЧИТ на пустом ответе", not _caught_neg("", tr)) + ck(f"{p}: детектор отрицания СРАБАТЫВАЕТ на прямой формулировке", + _caught_neg(f"«{tr.get('negation_after')} …» → снято отрицание", tr)) + # теги: покрыты глобом своей кассы, различны по варианту и по модели + tgs = {tag(p, v, m, u["uid"]) for v in cfg["vars"] for m in MODELS} + ck(f"{p}: теги различны по варианту и модели", + len(tgs) == len(cfg["vars"]) * len(MODELS)) + ck(f"{p}: теги покрыты глобом кассы {cfg['phase']}", + all(t.startswith(f"dv-{cfg['letter']}-") for t in tgs)) + ck(f"{p}: фаза заведена в кассе", cfg["phase"] in MONEY.CEILINGS) + ck(f"{p}: нулевая модель детектора обеспечена (клетки критика по чистому входу есть)", + all((OUT / f"{C.tag(cfg['crit'], x['uid'], 'crit')}.json").exists() for x in us)) + ck(f"{p}: порядок вариантов внутри главы перетасован, а не алфавитный", + len(cfg["vars"]) < 3 or any(var_order(p, x["uid"], MODELS[0]) != list(cfg["vars"]) + for x in us)) + ck("пары не делят буквы тегов", len({v["letter"] for v in PAIRS.values()}) == len(PAIRS)) + ck("glm-5 идёт с ВКЛЮЧЁННЫМ размышлением (объявленное отступление)", + "extra_body" not in call_kwargs("glm-5", [{"role": "user", "content": "x"}])) + ck("deepseek идёт в БОЕВОЙ конфигурации, без отступлений", + call_kwargs("deepseek-v4-pro", [{"role": "user", "content": "x"}]) + == ROSTER.call_kwargs("deepseek-v4-pro", [{"role": "user", "content": "x"}], + max_out=MAX_OUT)) + ck("правильный ответ пишется ДО покупок и не перезаписывается", + "if TRUTH.exists()" in Path(__file__).read_text(encoding="utf-8")) + print(f"\n{'ЗАМЕР ГОДЕН К ПОКУПКЕ' if not bad else f'ПРОВАЛОВ: {bad}'}") + return bad + + +if __name__ == "__main__": + a = sys.argv[1:] or ["--dry"] + if a[0] == "--selftest": + sys.exit(1 if cmd_selftest() else 0) + elif a[0] == "--dry": + cmd_dry() + elif a[0] == "--plants": + cmd_plants() + elif a[0] == "--score": + score() + elif a[0] == "--buy": + for x in ([y for y in a[1:] if y in PAIRS] or list(PAIRS)): + buy(x) + else: + raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; " + "знаю --dry --selftest --plants --buy --score")