104 KiB
Отчёт бэкенд-пака «денежный стоп перестаёт быть аварией» (05.09)
Промт:
docs/BACKEND_MONEYSTOP_SESSION_PROMPT.md. Зона записи:backend/+ своя секцияdocs/PROGRESS.md. Дерево НЕ закоммичено, лендинг за оркестратором.⚠ В рабочем дереве лежат ЧУЖИЕ правки — вся
platform/(параллельный платформенный пакtextmachine-main-63: бэкапы, дверь входа, рантбук деплоя). Я их не трогала ни байтом; всё, что ниже, — только проbackend/.⛔ Платного прогона НЕТ. Ни один вызов не ушёл ни к одному провайдеру. Все три оси ревью предъявлены за $0 — фейк-провайдер на
httptest, голдены, каталог мутаций.
0. Что стало с деревом — одним абзацем
Отказ резервации больше не убивает книгу. Он защёлкивает волну (новые элементы не начинаются),
допущенные вызовы дорабатываются и сеттлятся, а сам отказ не окончателен, пока в полёте есть чужая
резервация: settle возвращает оценка − факт, запас растёт, и тот же резерв переспрашивается. Прогон
уходит штатно, с частичным результатом, с exit 4/paused и — впервые — называет, сколько не
хватило. Отдельно и первым пунктом: пользовательская правка банка больше не может писать свои строки
внутрь системного сообщения платного вызова. И движок теперь публикует цену книги ДО покупки —
по юниту, по главе и по книге, плюс самый крупный неделимый резерв, из-за которого стена и стояла.
1. Пункт 1 (строка 271) — правки банка перестали быть инструкциями. ЗАКРЫТ
Что было. Инъекция банка — отдельное сообщение роли system; тело собиралось конкатенацией
src + " → " + dst; dst приходит с пользовательской двери POST /books/{id}/bank/corrections и
получал ровно один TrimSpace. Ни предела длины, ни запрета управляющих. ⇒ dst с переводом строки
дописывает свои строки внутрь системного блока каждого платного вызова книги.
Что построено — одна граница, ТРИ точки исполнения (backend/internal/membank/wirefence.go):
| точка | что делает | почему там |
|---|---|---|
дверь (decisions.go/resolveDecision) |
ОТКАЗЫВАЕТ с индексом решения | человек узнаёт, что его термин не поедет, вместо того чтобы получить молча искажённый |
загрузчик сида (memseed.go) |
ОТКАЗЫВАЕТ загрузку | файл оператора — его же и чинить; дроп на пути к проводу оставил бы термин, который он считает действующим, отсутствующим в каждом запросе |
рендереры (RenderGlossaryBlock, editorLines) |
ПРОПУСКАЕТ строку | единственная точка, которую нельзя обойти писателем, добавленным позже |
Что именно запрещено и почему НЕ больше. Управляющие (Cc: сюда попадают LF, CR, TAB, VT, FF, NEL),
разделители строки/абзаца U+2028/U+2029 и bidi-переопределения/изоляты U+202A–U+202E, U+2066–U+2069
(класс Trojan Source, CVE-2021-42574 — они не рвут строку, а переставляют её, и человек, подписывающий
банк глазами, читает не то, что получает модель). ⚠ Bidi-МАРКИ U+200E/U+200F и ZWJ/ZWNJ РАЗРЕШЕНЫ:
это обычное содержимое иврита, арабского, деванагари и фарси, а движок обязан работать для пары,
которой в репозитории нет (§0.1). Предел длины — 200 рун (не байт: байтовый предел был бы разным
пределом для разных письменностей). Довод числа: собственная структурная строка движка — заголовок главы
с подзаголовком — ограничена 60 рунами (chunk.chapterHeaderMaxRunes), а при штатном
glossary_token_budget: 800 одна 200-рунная плотная строка уже забирает четверть всего бюджета
инъекции на чанк.
⚠ ТРЕТЬЕ ЛЕЧЕНИЕ ПРОМТА — «обрамление как ДАННЫХ» — В ТЕКСТОВОЙ ФОРМЕ НЕВОЗМОЖНО, и это находка, а не
пропуск. Заголовки блоков — целевые wire-тексты и живут только в
backend/internal/lang/data/injection.txt. Этот файл побайтно входит в lang.EmbeddedVersion()
(embedded.go:22,79), который едет в cutInputs.Embedded → cutTag() → внутрь каждого unit.id
(manifest.go:168-170,201) и в manifestKey и в снапшот волны. ⇒ добавление ОДНОЙ строки пере-чеканило
бы unit.id всех существующих книг и потребовало --resnapshot сквозь консент-гейт пере-оплаты — то
есть ровно то, что тот же промт §4(6) запрещает. Обрамление сделано структурно: значение, которое не
может покинуть свою строку и не может превысить длину термина, не в состоянии выдать себя ни за что,
кроме правой части своего же отображения.
Снапшот: чистые книги не двигаются, отравленная двигается. Фолд wire-fence-v1 — СКОУПНЫЙ, по
образцу neuter-directive-v1: пишется только когда в скоупе ЕСТЬ негодная строка. Предъявлено
значением, а не доводом: хеш чистого банка снят сборкой коммита 32be78a (до фикса) и запинен
константой в тесте.
Артефакт приёмки — КРАСНЫЙ до, ЗЕЛЁНЫЙ после. Предъявлено исполнением на дереве HEAD:
$ cd <копия HEAD 32be78a>/backend && go test ./internal/membank/ -run 'TestAUserTerm|TestTheDoor|TestTheSeedLoader' -v
--- FAIL: TestAUserTermCannotWriteItsOwnLinesIntoTheSystemBlock
draft glossary block: a user-written term put an INSTRUCTION into the system message
draft glossary block: the value ended its own line and wrote a new one
editor constraint block: a user-written term put an INSTRUCTION into the system message
editor constraint block: the value ended its own line and wrote a new one
--- FAIL: TestTheDoorRefusesATermThatCannotBeRenderedAsData
want exactly the SECOND decision refused, got []
--- FAIL: TestTheSeedLoaderRefusesATermThatCannotBeRenderedAsData
a seed term whose dst ends its own line must not load
На рабочем дереве все шесть тестов файла зелёные.
2. Пункт 2 (строка 277) — отказ перестал гасить допущенное. ЗАКРЫТ
Форма защёлки (waverun.go): канал latched, не флаг. Причина механическая: кормилец БЛОКИРУЕТСЯ
на idxCh <- i, когда все воркеры заняты, — а это ровно то состояние, в котором достигается денежный
стоп; флаг был бы прочитан после отправки, которую он должен предотвратить.
⚠ И защёлка спрашивается НЕБЛОКИРУЮЩЕ ПЕРЕД трёхветочным select. Go выбирает среди готовых ветвей
select равномерно случайно, так что при закрытом latched и свободном воркере трёхветочный
select раздавал бы индекс примерно в половине случаев — до одного лишнего элемента на простаивающего
воркера. Тот же изъян давно есть у ctx.Done() в этой же строке; на денежном пути это разница между
«ничего нового не начато» и «начато ещё несколько».
Где ждать settle — в точке резерва (stagerun.go), потому что там известна оценка и туда же
возвращается повтор. Общая на прогон структура — reserveGate (reservegate.go).
⚠ Reserve исполняется ПОД мьютексом шлюза, и это требование корректности, а не аккуратность. С
допуском и счётчиком в двух критических секциях воркер X мог закоммитить резервацию и быть вытеснен до
записи себя «в полёте»; воркер Y, которому отказала резервация X, прочитал бы ноль в полёте и объявил
книгу законченной, пока вызов X идёт и вот-вот вернёт деньги. Стор и так сериализует резервации
единственным пишущим пулом — под ещё одним локом не теряется ничего.
done() — defer СРАЗУ после выхода из цикла допуска, и нигде больше. Резервация освобождается и
сеттлится на дюжине путей выхода; парная привязка счётчика к ним оставила бы inFlight > 0 до конца
прогона при одном пропущенном, и каждый последующий отказ ждал бы settle, который не придёт. Счёт до
возврата функции строго консервативен: ждущий ждёт мгновение дольше, чем должен, и никогда не
останавливается там, где должен был ждать.
Только ОБЯЗАТЕЛЬНЫЕ вызовы ждут — новый параметр mandatory у runAttempt, переданный явно (а не
выведенный), потому что вывести его нельзя: escalation=true стоит только у хопа, а репайр
(repair.go:421) и терминолог (terminologist.go:687) передают false, хотя тоже деградируют. Ждущий
опциональный вызов превратил бы «не начинаем новое» в «опциональное съедает то, что нужно
обязательному», а хоп держит escMu всю свою жизнь и заблокировал бы эскалации всех соседей.
Ожидание бессмысленно, когда его нечем купить — settleCannotHelp (⚠ это ОПТИМИЗАЦИЯ, не несущая гарантия: состояние, в котором она решает, почти недостижимо в однородной волне — §9 п.6): settle возвращает не больше
оценка − факт и никогда не уменьшает УЖЕ СПИСАННОЕ, так что при committed + estimate > ceiling ждать
нечего. Без этой проверки воркер держал бы волну открытой весь самый долгий провайдерский таймаут, чтобы
прийти к отказу, который у него уже был. ⚠ Спрашивается только у КНИЖНОГО потолка: дневной суммирует
все книги стора, и движковые цифры — не его; там ждём консервативно (ожидание всё равно ограничено —
оно кончается, как только полёт пуст).
Штатное завершение с леджером. Денежный стоп возвращает (res, halt) — TranslateBook уже отдавал
обе половины, а tmctl теперь ПЕЧАТАЕТ частичный леджер (раньше при ошибке результат не рендерился
вовсе, и оператор узнавал только что прогон встал).
2-бис. Пять рисков зоны — каждый предъявлен
| риск | предъявление |
|---|---|
| (а) защёлка на уровне волны, не у воркера | посадка MS-latch-is-a-cancel (замена защёлки на cancel()) — цвет в §6; тест TestUnderACeilingTheWaveFinishesWhatItAdmitted |
(б) учёт объёма (bound()/reconcile) при частичной волне |
⛔ НАЙДЕНО И ЗАКРЫТО ОТКАЗОМ ОТ ОТЧЁТА: res.Volume при денежном стопе НЕ прикладывается — см. ниже |
(в) событие ceiling и exit 4/paused не задержались |
TestTheStoppedRunTellsAConsumerHowMuchWasMissing читает журнал СО СТОРОНЫ ПОТРЕБИТЕЛЯ (байты NDJSON), TestACeilingHaltAnnouncesItselfAndNamesWhichCeiling держит позицию кадра и outcome: ceiling |
| (г) дневной потолок делит ветку с книжным | TestSettleCannotHelpRefusesToWaitForMoneyThatIsAlreadySpent — day-скоуп не смеет объявить отказ окончательным по цифрам одной книги; TestTheShortfallIs… — day-скоуп не публикует недостачу; TestACeilingHaltAnnounces…/the day ceiling — поле опущено на проводе |
| (д) эскалационный хоп не опирается на отмену | проверено чтением: escalation.go:163 ловит errors.Is(err, errReserveCeiling), а CeilingHalt.Unwrap его отдаёт — отмена в этой цепи не участвует. Более того, ЧУЖОЙ cancel() хопу МЕШАЛ: rateGuard.acquire(ctx)/Complete(ctx) падали ctx-ошибкой, которая не матчит сентинел. Снятие отмены хоп только чистит. Пин: TestRunnerEscalationCeilingDegrades (зелёный), TestWaveEscalationBudgetSerializedUnderParallelism (зелёный) |
⛔ Риск (б) оказался настоящим, и лечение — молчание, а не исправление счётчиков. Семь счётчиков
VolumeStop — это ПЛАН: planVolume классифицирует юниты до волн, reconcile правит только те исходы,
которые есть. Денежный стоп обрывает работу, которую план считал сделанной, ⇒ допущенные и не доделанные
юниты остаются посчитанными как Delivered/Reworked/Carried/Free, а LeftFresh/LeftRework
занижены ровно на них; при Left() == 0 прогон, вставший посреди книги, объявил бы «дошёл до конца
книги и ничего не придержал» и не пригласил бы докупить. Восьмого бакета «допущен, не доделан» в семи
полях нет, поэтому денежный стоп получил СВОЙ леджер — Finished.Money, считанный по тому, что прогон
РАЗРЕШИЛ, а не по тому, что намеревался.
3. Пункт 3 (строка 278, событие) — «сколько не хватило» доезжает. ЗАКРЫТ, с ЗАМЕНОЙ ЧИСЛА
⚠ РАСХОЖДЕНИЕ С ПРОМТОМ, взятое осознанно и согласованное с оркестратором. §4(3) называет число:
«Число у тебя уже есть и печатается (denied estimate=$…)» — и следующей фразой запрещает его класс:
«цены моделей, стадий и вызовов не выходят». denied estimate — это цена одного вызова, то есть
ровно запрещённое. Наружу едет недостача committed + reserved + estimate − ceiling, поле
shortfall_micro_usd (строка 278 называла next_step_micro_usd — то же противоречие). Недостача
отвечает на настоящий вопрос платформы («пополни на столько — и вызов пройдёт») и не раскрывает
стоимость ни одной стадии: это расстояние между лимитом, который платформа сама поставила, и суммой,
которую она уже авторизовала. Оркестратор ответ принял и пере-снимет строку 278 под построенное имя.
Две честные оговорки, обе в доке поля:
- Это ВЕРХНЯЯ граница, а не минимум.
reservedвключает ещё идущие вызовы, а settle заменяет резервацию стоимостью, которая никогда не больше. «Пополни на столько — и отказанный вызов пройдёт» верно всегда; «меньше не хватит» — нет. При пустом полёте (обычный терминальный отказ) они совпадают. - Только book-скоуп. Дневной потолок суммируется по ВСЕМ книгам стора (
ledger.go:WHERE date = ?без фильтра по книге), а доступные движку цифры — одной книги. Уверенное число там было бы про другую сумму. Поле опускается; отсутствие значит «не сказано», не «ничего не не хватало».
⚠ ЧЕСТНЫЙ ОСТАТОК, названный оркестратором и РАСШИРЕННЫЙ мной: недостача ВОССТАНОВИМА в цену
отказанного вызова. estimate = shortfall + ceiling − committed − reserved. Потолок платформа знает —
сама поставила; committed знает — движок публикует его кумулятивно кадром spend; а reserved на
обычном терминальном отказе по построению почти нулевой, потому что стоп туда приходит через
waitNothingInFlight — то есть при ПУСТОМ полёте. ⇒ обратный счёт работает не только на первом вызове
книги, как было записано изначально, а практически на любом стопе ЭТОГО пути.
⚠ И границу самого этого утверждения тоже надо назвать, иначе оно шире замера (поправка оркестратора,
купленная его же сегодняшней ошибкой: выборка, предъявленная как доказательство, доказывает ровно себя).
«reserved пуст» показано для ОБЯЗАТЕЛЬНОГО пути, который останавливается через waitNothingInFlight.
Для стопа через settleCannotHelp полёт может быть любым: при пустом этот стоп неотличим от основного
случая (reserved ≈ 0, оценка восстановима), при непустом обратный счёт даёт ВЕРХНЮЮ границу. То есть
утечка на этом пути НЕ БОЛЬШЕ, чем на основном — «не больше», а не «строго слабее», как я написала
сначала. Для НЕОБЯЗАТЕЛЬНЫХ вызовов вопрос не встаёт вовсе: они на
потолке деградируют, а не останавливают книгу, и кадра не рождают. Вывод от этого не меняется, а
утверждение перестаёт быть шире того, что проверено.
Вывод из этого — не «убрать поле», а назвать границу точнее, и три довода проверяемы: (1) тот же пак
РАТИФИЦИРОВАННО публикует цену вызова на том же шве — step_max_usd в manifest --json это буквально
«самый крупный неделимый резерв», заказанный строкой 278; значит это ТА ЖЕ дверь, а не новая; (2) ПТ-33
охраняет СТРУКТУРУ расходов — цену модели, стоимость стадии, разложение вызова, — и ни недостача, ни
step_max_usd её не дают; (3) пользовательская поверхность не затронута: кадр идёт по внутреннему
проводу, а платформенная сессия письменно подтвердила, что показывать его не будет и не может.
⚠ Ратификационную ноту об этом пишет ОРКЕСТРАТОР — сужение ратифицированной фразы не работа зоны.
Версия шва: StreamVersion 1.2 → 1.3 по правилу минора (добавлены поле и структура). Прибито
тестом, читающим hello из журнала, и посадкой на откат константы.
Событие пишется В МОМЕНТ ОТКАЗА, а не из терминального обработчика. Причина — PD-113: он закрыт
двумя каналами, которые падают НЕЗАВИСИМО. Раз потолок больше не отменяет соседей, один из них может
упасть по своей причине позже и обогнать halt на выходе — и событие, выведенное только из возвращённой
ошибки, промолчало бы о деньгах, которые уже кончились. Идемпотентно (emitter.ceilingSaid).
Событие не может выстрелить от ОПЦИОНАЛЬНОГО отказа — проверено чтением обеих ветвей. Finished.Money
и кадр ceiling условны на ceilingSaid, который выставляется только из fail() волны. Отказ хопа
гасится в maybeEscalate (escalation.go:163), репайра — repair.go:307-311 (break, не return),
терминолога — terminologist.go:857-860 (break). Ни один до fail() не доходит ⇒ прогон, который лишь
деградировал опциональный проход, не публикует ни денежного леджера, ни стопа.
Ранжирование ошибок: паника > инфра-ошибка > потолок, и инфра-ошибка с потолком НЕ джойнится. Паника
джойнится, потому что у неё ЕСТЬ ТИП, который exitCode и terminal() проверяют ПЕРВЫМ, — ранг
переживает джойн. У инфра-ошибки типа нет: errors.As(join, &CeilingHalt) был бы истинен, сломавшийся
прогон ушёл бы с exit 4, платформа записала бы paused, и оператору вручили бы неверное лекарство
(«добавьте денег»). Пин: TestAnInfraFailureAfterAMoneyStopDoesNotDepartAsPaused.
4. Пункт 4 (строка 278, проекция) — цена книги ДО покупки. ЗАКРЫТ
⚠ РЕШЕНИЯ, КОТОРЫЕ ПРОМТ ОТДАЛ СЕССИИ — с доводами.
(а) expected_usd считается по СОБСТВЕННОЙ фертильности и формулой СПИСАНИЯ, а step_max_usd — формулой
РЕЗЕРВАЦИИ. Это не две оценки одного, это два разных вопроса.
Публиковать одно число за оба было бы неверно в обе стороны сразу. Резервация worst-case по построению
(весь промпт мимо кеша или В кеш, генерация до max_tokens), так что проекция из резерваций завысила бы
счёт в разы и заставила платформу отказывать в заказах, которые она легко тянет; проекция ожидаемого
списания занизила бы ровно ту величину, которая отказывает вызовам, — а это и есть путь, которым сервис
пришёл к «книгу нельзя перевести никакими деньгами». ⇒ expected_usd = ledger.CostUSD над
фертильностью пары (те же коэффициенты est_out, которыми чанкер уже режет книгу); step_max_usd =
ledger.EstimateUSD при размере, который считает сам исполнитель.
(б) По отрендеренному промпту или по сырому тексту — по СЫРОМУ ТЕКСТУ ПЛЮС ШАБЛОН, и вот почему.
Отрендерить настоящий промпт стадии k>0 нельзя по построению: её вход — ЧЕРНОВИК, которого до прогона
нет, а вопрос задаётся именно до покупки. Зато шаблон загружен (openRunner зовёт loadTemplates() на
обоих путях, включая read-only) и он НЕ мал — редакторский промпт c1 больше тысячи токенов, и проекция,
которая его игнорирует, занижает промптовую сторону каждого юнита книги на одну и ту же величину: это
смещение, а не шум. Инъекция банка берётся ПО ВЕРХНЕЙ границе (её бюджет), потому что банка на момент
построения манифеста ещё нет.
⚠ И два уточнения, которых в первой редакции НЕ БЫЛО — оба найдены адверсариальным проходом и оба
двигали число на проценты, а не на копейки (находки 14 и 15 в §6.3):
· Исходник едет на КАЖДУЮ стадию, чей шаблон его просит. Исполнитель рендерит все стадии через
RenderVars{Text, Draft}, а редактор билингвален (D30.1). Проекция спрашивает ШАБЛОН — {{text}}, тот же
sourcePlaceholder, по которому движок уже считает echo-exposure, — а не позицию стадии: монолингвальное
плечо ценится без исходника теми же строками, и пар-специфики в этом нет.
· Few-shot считается по ТОГЛУ стадии, через tpl.SystemFor(fewShotEnabled(st)) — функции самого
исполнителя. У боевого c1-редактора тогл выключен, а блок в шаблоне лежит.
(в) Какие стадии входят и НА КАКОЙ ГРАНУЛЯРНОСТИ. В expected_usd — все стадии конвейера, по одной
попытке. НЕ входят: ретраи, эскалационный хоп, репайр. Каждый из них УСЛОВЕН, и вложить лекарства плохого
прогона в заголовочную цену значит выдать исключение за правило.
⚠ И считается это на ТОЙ ЖЕ гранулярности, на какой исполнитель делает вызовы (находка 22): черновые
стадии — ПО ЧЛЕНАМ юнита (runDraftChunk — один рендер и один вызов на чанк), остальные — ПО ЮНИТУ
(runEditUnit). Иначе смещение идёт сразу в две стороны: шаблон с инъекцией недосчитываются, а
step_max_usd завышается в число членов.
(г) Ретраи и эскалации — в step_max_usd входят РЕТРАИ и не входит всё остальное, и различитель —
деградирует ли отказ. maxTokensForAttempt УДВАИВАЕТ выходной бюджет на каждой регенерации, а отказ
РЕТРАЯ не деградирует — он останавливает книгу. Потолок, посчитанный по attempt 0, допускает первый
вызов юнита и навсегда отказывает его лекарству. Хоп, репайр и терминология ловят errReserveCeiling и
продолжают — потолок, малый для них, стоит качества, а не движения, и включать их значило бы поднять
минимальную покупку ради работы, от которой прогон готов отказаться.
(д) book_once_usd — это ГРАНИЦА, а не прогноз, и назвать её иначе было бы ложью. Вход терминолога
и его классификатора — список кандидатов, который производит майнер ИЗ ЧЕРНОВИКОВ, а черновиков на момент
публикации числа нет. Честно движок может сказать только то, чего он не превысит: обе стадии гейтятся
собственными бюджетами, и план обрезается под них до первого вызова. Для вопроса «какой потолок нужен
этой книге» — а он и есть вопрос — граница и является полезной цифрой. Ноль, когда стадии не настроены.
(е) structure — по ПРОВЕНАНСУ, с одним исключением, которое тоже про провенанс.
declared — границу нарисовал ФОРМАТ (spine EPUB; перевод страницы U+000C в txt — ASCII-разрыв
страницы, столь же явный). detected — движок ВЫВЕЛ её, сопоставив строки-заголовки в прозе
(splitTextChapters); он прав на корпусе, ради которого построен, и он догадка: абзац, открывающийся
«第三节…» в пределах длины, неотличим от заголовка. none — одна глава: границу, которую никто не
пересёк, никто и не проверял, каким бы путём она ни получилась.
⚠ И ЧЕСТНАЯ ГРАНИЦА, которую я сначала записала как достоинство и исправляю: detected СЕГОДНЯ
CJK-ONLY. Сопоставление заголовков идёт по lang.DefaultCJKSection() — это КОНСТАНТА (章节節回 плюс
класс CJK-числительных), а не данные пары. ⇒ книга в любой другой письменности вернёт none, как бы
явно её главы ни были размечены. Асимметрию я не вношу — она уже есть и уже названа: движок читает
данные пары для РЕНДЕРА заголовка (configs/langpacks/*/heading.txt), а РАЗРЕЗ на главы остаётся
захардкоженным CJK, и строка бэклога 283 называет именно этот раскол работой. Что добавляет поле — у
асимметрии появляется ИМЯ на проводе вместо того, чтобы прятаться в числе глав: не-CJK книга, сказавшая
none, говорит правду о том, что этот движок умеет, и потребитель откажется предлагать заказ в главах
вместо того, чтобы предложить его против разреза, которого никто не делал. Сама классификация
пар-агностична — она спрашивает, КАКОЙ ПУТЬ провёл границы, — и скажет detected без единой правки Go в
тот день, когда разрез научится читать данные пары.
(ж) Форма леджера в finished. Finished.Money{units_resolved, units_deferred}, считанный из тех же
по-волновых счётчиков, из которых публикуется progress, — так текущий канал и терминальный не могут
назвать разные числа. ⚠ resolved, а НЕ delivered: счётчик инкрементируется на юните, который
дошёл до вердикта, включая флагованный с пустым текстом. Назвать это «доставлено» — ровно та ложь, ради
прекращения которой объёмный леджер разделили на Delivered/Flagged, и ложь, по которой покупатель
стал бы действовать.
⛔ И ЧИТАТЕЛЬ С ПИСАТЕЛЕМ ПРИБИТЫ ОДНИМ АКТОМ — по норме, которую оркестратор передал в ходе пака. Две пары:
- Размер вызова.
baseMaxTokensForвынесен изrunStageв ОДНУ функцию, которой пользуются и исполнитель, и проекция. Проекция, считающая размер по-своему, разъехалась бы с резервациями, которые прогон реально делает, — и разъезд был бы невидим, обе стороны выглядят как числа. - Цена на двух поверхностях. ⛔ Полная батарея поймала у меня ровно этот класс:
TestManifestServesTheReadModelsIdenticallyпокраснел —statusотвечал ценой при наличии сайдкара иnilбез него. Манифест — АКСЕЛЕРАТОР, а акселератор, меняющий ОТВЕТ, это второй источник истины. Лечение:readModelPriceотвечает на обоих путях, и обе ветки кончаются в однойprojectBook.
5. Секция, которую требует §5-тер(а): ГДЕ ПРОМТ НАПИСАН НЕ ТАК
Читаю промт последней и называю расхождения — не как вежливость, а как заказ владельца.
- ⛔ §4(3) внутренне противоречив. Он называет число, которое должно ехать наружу
(
denied estimate=$…), и следующей фразой запрещает его класс («цены моделей, стадий и вызовов не выходят»). Исполнить дословно значило бы нарушить соседнее предложение. Разрешено заменой на недостачу; согласовано. - ⛔ §4(1) заказывает «обрамление как ДАННЫХ», которое в текстовой форме нельзя выполнить, не нарушив
§4(6). Единственный дом для такой фразы —
lang/data/injection.txt, а он побайтно входит вEmbeddedVersion()→cutTag→unit.idкаждой книги. Промт этого не знал; сделано структурно. - ⚠ §3 п.1 карты чтения указывает на секцию, которой уже нет — но это НЕ дефект промта, и правку
вношу по указанию оркестратора. «
15-money-path.md§«ОТКРЫТАЯ РАЗВИЛКА 04.09»» переименована в «РАЗБОР 04.09: КНИГУ НЕЛЬЗЯ ДОЧИТАТЬ…» — и переименовал её оркестратор УЖЕ ПОСЛЕ выдачи промта. То есть на момент выдачи карта была верна, а сломал её последующий редакторский акт. Разница не формальная: «промт написан не так» лечится редактурой промтов, а «карта сломана после выдачи» — дисциплиной того, кто правит доки под работающей сессией. Секцию нашла грепом по заголовкам. - ⚠ §4(2) «защёлка на уровне ВОЛНЫ» обоснована доводом, который больше не работает. Промт пишет:
«Без защёлки W воркеров дадут W путей ошибки вместо одной остановки». Довод снят самой конструкцией:
haltErr— отдельный слот с первым-побеждает, путь ошибки один при любом числе воркеров. Защёлка ОСТАВЛЕНА (оркестратор подтвердил другим доводом — продуктовым: книгу читают подряд, и непрерывный префикс из шести глав лучше решета из восьми), но её цена названа: она читает «этот ОДИН элемент не влез» как «денег нет ни на что», а при разбросе резервов внутри волны 2.976× (замер разбора 04.09) и удвоении бюджета на ретрае это ложно — юниты, которые ВЛЕЗАЛИ, не начинаются. Заведено строкой бэклога. - ⚠ §4-тер риск (б) назван мягче, чем он есть. «частичная волна под потолком — НОВАЯ форма для этих счётчиков» — на деле счётчики не просто новую форму получают, они СОВРУТ по шести полям из семи, и лечение не в поправке, а в отказе их публиковать (см. §2-бис).
- ⚠ §5 «модель называй имени» — опечатка («по имени»). Названо: помощник — Fable 5, один агент, долгий разговор, два круга.
- ✅ §4(6) с
StreamVersionисправлен верно — прежняя редакция запрещала бамп и заказывала поле; в выданной мне редакции противоречие снято, и это тот случай, когда правка промта сработала.
6. Самопроверка ИСПОЛНЕНИЕМ
6.1. Батарея — ЗЕЛЁНАЯ
$ cd backend && make battery
0 issues. # golangci-lint
ok textmachine/backend/cmd/tmctl 42.759s
ok textmachine/backend/internal/archguard 78.355s
ok textmachine/backend/internal/pipeline 272.408s
ok textmachine/backend/internal/store 67.691s
…19 пакетов, ни одного FAIL…
MAKE_EXIT=0
--- did NOT run (no stand data; see battery-stand) ---
--- SKIP: TestMinerFullBookParity · TestHelperEventsRun · TestHelperKillLoop
19 пакетов ok · 0 упавших тестов · 0 issues линтера · 3 скипа (все — стендовые, требуют данных вне
репозитория; ровно те же три, что и до пака). Сюда входит go vet ./..., go vet -tags live, оба
прохода через СОБСТВЕННЫЙ анализатор зоны (bin/tmvet) и gofmt -l — именно tmvet и поймал у меня
хенд-билд config.Stage (§6.3 находка 23).
⚠ ГОНЯЛОСЬ ЦИКЛОМ ДО ЧИСТОТЫ, А НЕ ОДНИМ ПРОХОДОМ (D39.197: утверждение о проверке живёт ровно до
следующей правки), и число прогонов само по себе факт: ШЕСТЬ. Первые пять были остановлены или красны:
MAKE_EXIT=2 на собственном анализаторе зоны · --- FAIL под -race на моём же тесте ожидания (он
полагался на везение, а не на состояние) · три прогона убиты мной, когда очередной собственный проход
находил дефект и делал результат утверждением о дереве, которого больше нет. Шестой — на замороженном
дереве, после того как ВСЕ находки первого полного прогона каталога были закрыты.
⚠ internal/pipeline шёл 272 с (в пятом прогоне — 450 с) против ~95 с на тихой машине: параллельные
сессии зоны на той же машине, разброс, о котором предупреждает сам Makefile. Ни одного упавшего
утверждения ни в одном из зелёных прогонов.
6.2. Дифф ^func Test — ИСПОЛНЕНИЕМ, не по памяти
$ git diff -U0 -- backend | grep -E '^[+-]func Test' # пусто: ни одна функция не удалена и не переименована
$ git grep -h '^func Test' HEAD -- backend | wc -l # 1172
$ grep -rh '^func Test' backend --include=*_test.go | wc -l # 1208
+36 тестовых функций, все в трёх новых файлах (wirefence_test.go 6 · moneystop_test.go 16 ·
priceprojection_test.go 14). Ни одна существующая функция не удалена и не переименована — вывод грепа
пуст, а два протухших теста починены ВНУТРИ тела (§7).
6.3. Что я нашла В СВОЕЙ ЖЕ РАБОТЕ (адверсариальный проход, помощник Fable 5)
Плотность находок на собственной работе не ниже, чем на чужой (D39.197) — подтвердилось:
| # | находка | статус |
|---|---|---|
| 1 | ⛔ waitForSettle возвращал false и на «нечего ждать», и на ctx.Done(), а вызывающий обе трактовал как ФИНАЛЬНЫЙ отказ ⇒ прогон, остановленный Ctrl-C или отменённый инфра-падением соседа, публиковал ceiling с недостачей, и платформа записывала paused, требуя денег за стоп, к деньгам отношения не имевший |
ЗАКРЫТО: три исхода (waitSettled/waitNothingInFlight/waitAborted), пин TestACancelledWaitIsNotAMoneyStop, посадка MS-cancelled-wait-is-not-money |
| 2 | ⛔ Новая ветка terminal() («ошибка + потолок был») стояла ВЫШЕ sigStop и context.Canceled ⇒ банк-стоп на книге, коснувшейся потолка, отчитывался failed при exit 3, а пойманный SIGTERM — failed при exit 5. Два канала противоречат друг другу об одном прогоне |
ЗАКРЫТО: ветка перенесена последней, порядок объявлен утверждением в комментарии |
| 3 | ⛔ Гонка «Reserve закоммичен — enter() ещё нет» давала ЛОЖНЫЙ финальный стоп |
ЗАКРЫТО: Reserve под мьютексом шлюза |
| 4 | ⛔ Денежный стоп в ЧЕРНОВОЙ волне пускал волну РЕДАКТУРЫ: draftByKey строится по ВСЕМ чанкам, поэтому не начатый член находится (ok == true) с нулевым результатом — не флагован, текст пуст — и редактор ОПЛАЧИВАЛСЯ за юнит с дырой, который потом отгружался |
ЗАКРЫТО: ранний возврат + drafted []bool, пин TestAMoneyStopInTheDraftWaveNeverStartsTheEditWave, посадка MS-draft-stop-does-not-edit |
| 5 | ⛔ Кормилец продолжал раздавать индексы после защёлки примерно в половине случаев (равномерный выбор select среди готовых ветвей) |
ЗАКРЫТО: неблокирующая проверка latched перед select |
| 6 | ⛔ MoneyLedger.UnitsDelivered считал РАЗРЕШЁННЫЕ юниты, включая флагованные с пустым текстом, а док поля обещал «книга, которую покупатель может читать сейчас» |
ЗАКРЫТО до выхода 1.3 на провод: поле переименовано в units_resolved, ложь снята |
| 7 | ⛔ Мой собственный тест фолда версии был ВЫРОЖДЕННЫМ: сравнивал два разных набора строк и доказывал только что строки разные — посадка WF-version-fold-scope его ПЕРЕЖИЛА |
ЗАКРЫТО: тест сделан ВРЕМЕННЫМ (пины хешей, снятых сборкой 32be78a), посадка краснеет |
| 8 | ⛔ Моя посадка WF-length-bound не собиралась (1 << 30 рун в strings.Repeat съедал память) — каталог показал NOTHING, а не RED |
ЗАКРЫТО: посадка снимает ПРОВЕРКУ, а не двигает константу |
| 9 | ⛔ waitForGate звал t.Fatalf ИЗ ПОРОЖДЁННОЙ ГОРУТИНЫ — в Go это runtime.Goexit не на той горутине: утверждение теряется, а прогон висит до таймаута пакета. Под посадкой я получила бы необъяснимый таймаут вместо КРАСНОГО |
ЗАКРЫТО: возврат bool, проверка в тестовой горутине |
| 10 | ⛔ Посадка PP-step-max-ignores-retries пережила бы ВХОЛОСТУЮ: единственное утверждение про step_max сравнивало его с резервом attempt 0 |
ЗАКРЫТО: TestStepMaxCountsTheRetryThatDoesNotDegrade |
| 11 | ⛔ Полная батарея: status отвечал ценой при наличии сайдкара и nil без него |
ЗАКРЫТО: readModelPrice отвечает на обоих путях, обе ветки кончаются в одной projectBook |
| 12 | ⛔ Проекция цены считала цену юнита ДВАЖДЫ — в цикле манифеста и заново внутри книжной свёртки. Две деривации одного числа, и семь проходов по тексту книги там, где хватает трёх; на 23-мегабайтной книге манифест — акселератор, купленный секундами | ЗАКРЫТО: один проход projectBook, unitPrice несёт PromptTokens |
| 13 | ⛔ WireUnfit материализовал []rune(s) на КАЖДОЙ строке банка КАЖДОЙ инъекции КАЖДОГО вызова — аллокация на горячем пути ради вопроса, которому она не нужна |
ЗАКРЫТО: проход по строке со счётчиком рун |
| 14 | ⛔ Проекция роняла ИСХОДНИК из промпта каждой стадии после первой. Исполнитель рендерит ВСЕ стадии через RenderVars{Text: ch.Text, Draft: prev} (stagerun.go:73), а редактор БИЛИНГВАЛЕН по ратифицированному решению (D30.1) — он получает исходник рядом с черновиком, и это единственная причина, по которой он умеет привязывать канон к исходному термину. Проекция давала поздним стадиям только черновик ⇒ ≈ ⅙ ожидаемого счёта юнита, в сторону ЗАНИЖЕНИЯ — той самой, в которой платформа продаёт книгу дешевле себестоимости |
ЗАКРЫТО: promptTokens спрашивает ШАБЛОН ({{text}}, тот же sourcePlaceholder, по которому движок уже считает echo-exposure), а не позицию стадии — монолингвальное плечо ценится без исходника теми же строками. Пин TestTheProjectionChargesTheSourceToEveryStageThatAsksForIt, две посадки |
| 15 | ⛔ Проекция считала few-shot безусловно, а исполнитель шлёт его по тоглу. SystemFor(fewShotEnabled(st)) (render.go:159-170) добавляет блок только когда few_shot включён, а у боевого c1-редактора он ВЫКЛЮЧЕН при том, что блок в шаблоне лежит — больше тысячи байт. Сумма полей завышала КАЖДЫЙ edit-вызов КАЖДОЙ книги на этом плече, и невидимо: цена на 20 % выше выглядит ровно как цена |
ЗАКРЫТО: спрашиваются функции исполнителя. Пин TestTheProjectionChargesTheFewShotBlockOnlyWhenTheStageSendsIt, посадка |
| 16 | ⛔ TestWaitingForHeadroomDeliversTheWholeBook доказывал не то, что заявлял — предъявлено ИСПОЛНЕНИЕМ. В логе -v оба черновика допущены ОДНОВРЕМЕННО, строки «reservation refused … waiting» нет ни одной: книга доставлена потолком, который ни разу не отказал. То есть положительная половина строки 277 не исполнялась ни одним тестом, и посадка MS-wait-for-settle этот тест бы пережила |
ЗАКРЫТО: у шлюза появился счётчик СРАБАТЫВАНИЙ (waitsSoFar), тест требует waits > 0 ДО всего остального, потолок пере-подобран (1.85 оценки). Предъявлено: waits_this_run=1 в логе |
| 17 | ⛔ Фикстурный потолок был завышен ×1.114: callCeilingUSD клал в промпт plan.inject = 800 токенов, а безглоссарная фикстура инъекцию не шлёт вовсе. Из-за этого множитель 1.9 давал ≥ 2 резерваций — окно «по одному за раз» превращалось в «влезают оба», что и породило находку 16 |
ЗАКРЫТО: аллоуанс убран, оценка теперь совпадает с реальной побайтно (0.007016 в логе исполнителя и в фикстуре) |
| 18 | ⛔ Два утверждения TestResumeAfterAMoneyStopRePaysNothing НЕ МОГЛИ упасть: «committed упал» невозможно (committed монотонен), а secondPass >= firstPass + rec.count() арифметически недостижимо, поскольку secondPass := rec.count() - firstPass. Оба читались как проверки и ни одна не краснела |
ЗАКРЫТО: утверждение о ЧИСЛЕ вызовов через оба прогона (3 юнита × 2 стадии == 6) — пере-покупка ловится именно им |
| 19 | Лог-строка ожидания печаталась ПОСЛЕ пробуждения и читала счётчики тогда: оператор видел слова «still in flight … waiting» рядом с in_flight=0 queued=0 — фраза, противоречащая собственным числам |
ЗАКРЫТО: строка говорит «settle освободил запас, переспрашиваем», счётчики названы *_now |
| 20 | Якорь посадки PP-price-reaches-status умер, когда status.go перешёл на readModelPrice — каталог дал бы ROTTED, то есть громко, но запись бы ничего не утверждала |
ЗАКРЫТО: якорь пере-навешен на нынешнее выражение того же свойства |
| 21 | Пере-утверждение в собственном комментарии и отчёте: «structure пар-агностичен по построению». На деле detected сегодня CJK-only (lang.DefaultCJKSection() — КОНСТАНТА), и это известная асимметрия строки бэклога 283 |
ЗАКРЫТО: формулировка исправлена в обоих местах, граница названа прямо |
| 22 | ⛔ Проекция ценила ЧЕРНОВИК ПО ЮНИТУ, а исполнитель зовёт его ПО ЧАНКУ (runDraftChunk — один рендер и один вызов на члена; runEditUnit — на юнит). Смещение сразу в две стороны: шаблон и инъекция едут в КАЖДОМ черновом вызове, значит счёт занижался; а выходной бюджет черновика сайзится от ОДНОГО чанка, значит step_max_usd — число, по которому платформа ставит минимальную покупку, — завышался в число членов. На боевом плече второе прячет модельный флор, первое — проценты; на плече без флора не прячется ничто |
ЗАКРЫТО: projectUnit и stepMaxForUnit ходят по членам для черновых стадий и по юниту для остальных. Пин TestTheDraftIsPricedPerMemberChunkAndTheEditPerUnit (та же книга одним чанком и двумя: цена ОБЯЗАНА вырасти, шаг ОБЯЗАН не вырасти), посадка |
| 23 | ⛔ Мой новый тест строил config.Stage литералом — и собственный vet-анализатор проекта его отказал (archguard.StageSeam, D39.87: литерал молча роняет reasoning и temperature). В списке исключений есть файл того же класса, и добавить туда свой было бы ослаблением гейта ради удобства |
ЗАКРЫТО БЕЗ ПРАВКИ ГЕЙТА: фикстура строит стадию через сам шов config.InternalCall{…}.Stage(), а few_shot ставится после — это и есть поле под тестом, которое шов намеренно оставляет нулевым |
| 24 | ⛔ Черновые стадии не ЦЕПЛЯЛИСЬ друг за друга. runStageSequence прогоняет чанк через КАЖДУЮ черновую стадию по порядку, каждая читает выход предыдущей; ничто в загрузчике не запрещает вторую translator-стадию (escalate_to ограничен ролью, число стадий — нет). Плоской суммой по стадиям вторая ценилась бы против ИСХОДНИКА, а черновик юнита вышел бы ВДВОЕ больше правды — и редактор получил бы вход, которого не бывает |
ЗАКРЫТО: член — внешний цикл, стадии цепляются внутри него, как в runStageSequence |
| 25 | ⛔ ЦЕЛЫЙ СПИСОК УТВЕРЖДЕНИЙ ОТЧЁТА НЕ ПОДКРЕПЛЯЛСЯ НИ ОДНИМ АРТЕФАКТОМ — четвёртый круг адверсариального прохода искал именно это, и нашёл двенадцать. Главные: (а) всё поведение параметра mandatory было доводом в комментарии — каждая фикстура, где отказывали опциональному вызову, последовательна, поэтому inFlight == 0 давал мгновенный стоп при ЛЮБОМ значении флага, и переворот всех трёх опциональных сайтов в true оставил бы батарею зелёной; (б) порядок ветвей terminal() — регрессия, случившаяся В ЭТОЙ СМЕНЕ, — держался комментарием; (в) идемпотентность кадра ceiling не проверял никто (единственный тест, читающий журнал при стопе, шёл на одном воркере, то есть на ОДНОМ отказе); (г) проводка settleCannotHelp (предикат запинен, if — нет; ⚠ НЕ ЗАКРЫТА — см. §9 п.7, состояние почти недостижимо и я это доказываю, а не обхожу); (д) book_once_usd проверялся только на собственном нуле — терминология выключена во всех фикстурах, return 0 пережил бы всё |
ЗАКРЫТО ПЯТЬЮ НОВЫМИ ТЕСТАМИ И ШЕСТЬЮ ПОСАДКАМИ. Правило «кто может ждать» ПЕРЕЕХАЛО из if у вызывающего В САМ ШЛЮЗ (waitForSettle(ctx, mandatory), четвёртый исход waitNotAllowed, счётчик optionalTurnedAway) — именно чтобы оно оставляло после себя ФАКТ, а не комментарий |
| 26 | ⛔ Черновые стадии не цеплялись и фолбэк «нет translator-стадии» врал по выходу. Две translator-стадии структурно загружаются (валидация проверяет РОЛИ и не считает их); исполнитель ведёт чанк через все по очереди, каждая читает выход предыдущей. Плоской суммой вторая ценилась бы против ИСХОДНИКА, а черновик юнита вышел бы вдвое. Отдельно: конвейер БЕЗ translator-стадии — его первая стадия сама переводит, значит её выход это фертильность пары, а не число токенов исходника (занижение на весь коэффициент) | ЗАКРЫТО: walkDraftCalls — ОДИН обход, которым пользуются обе арифметики; два разных фолбэка для входа и выхода. Пин TestAPipelineWithNoTranslatorStagePricesItsFirstStageAsTheTranslation (та же книга, две калибровки фертильности — цена ОБЯЗАНА разойтись), посадка |
| 27 | ⛔ И ПРОДОЛЖЕНИЕ НАХОДКИ 16, которое стоит отдельной строки: после того как утверждение о механизме появилось, тест стал ПАДАТЬ — под -race. Он полагался на то, что два воркера успеют зарезервировать ОДНОВРЕМЕННО, а это не состояние, а везение: в логе батареи chunk 0 сеттлился раньше, чем chunk 1 вообще запрашивал допуск, — потолок не может отказать в том, о чём не спросили параллельно. ⚠ Это ровно тот класс «краснота держится на постороннем обстоятельстве», который мне было велено искать у себя, — и поймала его НЕ я, а батарея под -race, потому что мои точечные прогоны шли без него. Отдельно стоит сказать, что утверждение сработало как задумано: тест УПАЛ громко вместо того, чтобы пройти вхолостую |
ЗАКРЫТО: столкновение сделано СОСТОЯНИЕМ — первый вызов удерживается на фейк-провайдере, пока шлюз не покажет очередь, и только потом отпускается. Предъявлено: -race -count=3, три прохода |
| 28 | ⛔ ШЕСТАЯ ЛОЖНАЯ ЗЕЛЕНЬ, И ЭТУ Я НАШЛА САМА, ЗАПОДОЗРИВ АНОМАЛИЮ ВРЕМЕНИ. Мой тест «футильный отказ не держит волну открытой» проходил за 5,6 с там, где по его собственной арифметике должен был упереться в 30-секундный таймаут. Разбор трассы: удерживаемый на фейк-провайдере вызов НЕ висел — он упирался в attempt_timeout, транспорт РЕТРАИЛ, и ретрай отвечал мгновенно (latency_ms=5389 у вызова, который тест считал замороженным). Прогон кончался потому, что вызов завершился, три отказанных воркера УСПЕЛИ подождать и проснуться, а утверждение было про ситуацию, которой фикстура не строила |
ТЕСТ УДАЛЁН, а не подкручен. Артефакт, проверяющий не то, что заявляет, ХУЖЕ отсутствующего: он читается как покрытие. На его месте — абзац, объясняющий, почему состояние почти недостижимо, и запись в §9 |
| 29 | ⛔ ДВА МОИХ ЯКОРЯ ПРОТУХЛИ ОТ МОИХ ЖЕ ПОЗДНИХ РЕФАКТОРОВ — MS-wait-for-settle (условие ожидания переехало из if в шлюз) и PP-draft-priced-per-unit-not-per-chunk (цикл по членам уехал в walkDraftCalls). Каталог дал бы ROTTED: громко, но запись при этом не утверждает НИЧЕГО, а список посадок читается как покрытие. Ровно тот класс, который приёмка прошлого пака поймала у четырёх записей |
ЗАКРЫТО ПРОВЕРКОЙ, А НЕ ГЛАЗОМ: скриптом сверила все 33 своих якоря на «резолвится РОВНО ОДИН раз» в текущем дереве — два не резолвились, пере-навешены на нынешние выражения тех же свойств, остальные 31 чисты. Проверку прогнала ПОСЛЕ каждого последующего касания кода |
| 30 | ⛔ ПЕРВЫЙ ПОЛНЫЙ ПРОГОН КАТАЛОГА ВЕРНУЛ ЧЕТЫРЕ НЕОЖИДАННЫХ ИСХОДА, И ВСЕ ЧЕТЫРЕ — МОИ. 183 RED · 3 SURVIVED · 1 NOTHING · 0 ROTTED. Среди выживших — MS-latch-is-a-cancel, красноту которой промт назвал ОБЯЗАТЕЛЬНЫМ УСЛОВИЕМ ПРИЁМКИ. Скажи я «сдано» по зелёной батарее, пак ушёл бы в лендинг с невыполненным named-артефактом |
ЗАКРЫТО ЧЕТЫРЬМЯ РАЗНЫМИ ПОЧИНКАМИ, каждая воспроизведена руками, а не принята с вывода инструмента — разбор ниже, строки 31–34 |
| 31 | ⛔ MS-latch-is-a-cancel выжила, потому что мой тест открывал ОКНО, КОТОРОГО В СЛОМАННОМ МИРЕ НЕ СУЩЕСТВУЕТ. Он отпускал удерживаемый вызов, когда воркер ВСТАЛ В ОЧЕРЕДЬ, — но в очередь встают только в ПОЧИНЕННОМ мире; в сломанном отказ убивает волну сразу и в очередь не встаёт никто, так что триггер просто не срабатывал и утверждение читалось в обоих мирах одинаково. ⚠ И сама посадка была неверной: починка из ДВУХ частей требует посадки из ДВУХ частей. С ожиданием на месте одинокий cancel() почти безвреден — к моменту защёлки в полёте уже никого, — то есть посадка воспроизводила не до-паковый мир, а третий, которого никогда не было |
ЗАКРЫТО: триггер перевешен на РЕФУЗ (refusals ≥ 1 && inFlight ≥ 1) — окно, существующее в ОБОИХ мирах, различает которые то, что прогон с ним ДЕЛАЕТ; посадка стала ПАРОЙ (снять ожидание И вернуть cancel), то есть буквально до-паковым поведением |
| 32 | ⛔ MS-volume-ledger-on-a-money-stop выжила ДВАЖДЫ, по двум разным причинам, и вторая тоньше первой. (а) Утверждение было ВЫРОЖДЕННЫМ: фикстура не давала объёмного гранта вовсе, поэтому res.Volume был nil при любом коде. (б) Даже с грантом посадка выжила СНОВА: res.Volume прикладывается МЕЖДУ волнами (waverun.go:200-201), а мой потолок останавливал прогон внутри ЧЕРНОВОЙ волны — путь, который его вообще не прикладывает, так что удержание было no-op и там |
ЗАКРЫТО: отдельный тест TestASpendStopReportsNoVolumeLedger с НАСТОЯЩИМ грантом (MaxUnits = 3), одним воркером и потолком, рассчитанным оплатить ВСЕ черновики гранта и кончиться на ПЕРВОЙ редактуре. Предъявлено исполнением: под посадкой тест падает текстом «stopped on the VOLUME ceiling … 3 NEW unit(s) delivered» — леджер заявляет ТРИ доставленных для прогона, у которого третья редактура отказана |
| 33 | ⛔ PP-no-translator-stage-completion выжила — и ВЫЖИВАНИЕ БЫЛО НАХОДКОЙ: она нашла МЁРТВЫЙ КОД. walkDraftCalls возвращал размер черновика ДАЖЕ когда ни одной черновой стадии не отработало ⇒ фолбэк для конвейера без translator-стадии был недостижим, и первая стадия такого конвейера ценилась с входом-черновиком, которого она не получает. Мутация не могла изменить то, до чего не доходят, — и именно это её выживание и означало. Каталог сработал не как гейт, а как ПРИБОР |
ЗАКРЫТО: walkDraftCalls возвращает 0, когда ни одна стадия не отработала; ветка стала живой |
| 34 | MS-optional-call-queues-for-headroom дала NOTHING — посадка удаляла блок целиком, mandatory становился неиспользуемым, сборка падала |
ЗАКРЫТО: правка на компилирующуюся форму (if false && !mandatory) |
| 35 | ⛔ И дедлок, который я сама себе устроила и сама сняла — одна нить с находкой 28, а не отдельный эпизод. Ветка r.Context().Done() у фейк-провайдера и была причиной того, что «удерживаемый» вызов падал по attempt_timeout, а его ретрай отвечал мгновенно. Сняв её, я получила зависание srv.Close(), который ЖДЁТ незавершённые запросы: defer-ы LIFO, и close(release) стоял в очереди ЗА Close(). Пакет ВИС вместо того чтобы упасть — снова отказ в форме таймаута |
ЗАКРЫТО: defer close(release) объявлен ПОСЛЕ defer srv.Close(), чтобы выполниться ПЕРЕД ним; причина записана в комментарий теста |
| 36 | Именной тест TestUnderACeilingTheWaveFinishesWhatItAdmitted под ПАРНОЙ посадкой падает текстом the stop must be a ceiling halt, got … context canceled — то есть ровно симптомом до-пакового дефекта: допущенный вызов убит отменой. Проверено прямым исполнением, а не по строке вывода инструмента |
ПРЕДЪЯВЛЕНО |
| 37 | ⛔ MS-optional-call-queues-for-headroom дала NOTHING ВТОРОЙ раз, и теперь по другой причине: посадка краснела ЗАВИСАНИЕМ. Первая причина была сборкой; после починки посадка собиралась, но под ней опциональный вызов ПАРКОВАЛСЯ на settle, которого в юнит-тесте не бывает, — пакет упирался в таймаут, и каталог печатал «the planting did not build, or the run died», что неотличимо от нагрузки машины. Восьмой случай того же класса за смену, и снова мой |
ЗАКРЫТО: у ожидания появилась ГРАНИЦА (context.WithTimeout 2 с). Предъявлено: под посадкой тест падает за 2,00 с с текстом об опциональном вызове, занявшем деньги обязательного, — вместо таймаута пакета |
| 38 | Течь резервации на ветке json.Marshal(resp.Usage) (возврат без release и без settle) |
ПОЧИНЕНО — см. §8 |
6.4. ⛔ ЧЕТВЁРТЫЙ КЛАСС ЛОЖНОЙ ЗЕЛЕНИ, найденный в этой смене
Выношу отдельно, потому что он не про мой пак, а про инструмент, и следующая смена наступит на него так же.
Посадка, чей отказ выглядит как ТАЙМАУТ, неотличима от нагрузки — и потому не является посадкой.
⚠ SURVIVED — НЕ ОДИН ВЕРДИКТ, А ТРИ РАЗНЫХ, и различать их обязательно (разведение оркестратора,
которое моя формулировка «выживание ЕСТЬ сообщение о недостижимости» и позволила):
- посадка эквивалентна оригиналу — дефект ПОСАДКИ, гарантия цела;
- посадка не воспроизводит прежний мир ЦЕЛИКОМ — починка из двух частей, а вернули одну (см. 31);
- посадка недостижима, потому что код МЁРТВ — находка о КОДЕ, добытая прибором (см. 33). Только третий род приносит дефект; первые два его прячут. Читать зелёное как «гарантия держит» нельзя ни в одном из трёх.
⚠ И шестая форма, найденная последним прогоном: ТРИГГЕР, СУЩЕСТВУЮЩИЙ ТОЛЬКО В ПОЧИНЕННОМ МИРЕ. Тест,
который ждёт состояния, чтобы что-то проверить, обязан ждать состояния, ДОСТИЖИМОГО И В СЛОМАННОМ МИРЕ —
иначе он не отличает миры, а просто не запускается в одном из них. Различать их должно то, что прогон с
этим состоянием ДЕЛАЕТ. Подкласс того же: починка из двух частей требует посадки из двух частей —
вернув одну половину, воспроизводишь не прежний мир, а третий, которого никогда не было. ⚠ Этот подкласс
сформулирован ОРКЕСТРАТОРОМ 04.09 на его собственной ошибке («oldest wins») и ПОДТВЕРЖДЁН здесь вторым
независимым случаем — MS-latch-is-a-cancel. Атрибуция важна не для заслуг: со вторым случаем это
перестало быть анекдотом и стало классом, и следующий читатель должен видеть, сколькими случаями норма
куплена.
Механика: t.Fatalf, вызванный из ПОРОЖДЁННОЙ горутины, делает runtime.Goexit не на той горутине.
Утверждение не записывается, помощник молча умирает, а прогон, который он должен был разблокировать,
висит до таймаута ПАКЕТА. Мой waitForGate был написан именно так. Под посадкой, делающей ожидаемое
состояние недостижимым (MS-wait-for-settle, MS-latch-is-a-cancel), каталог получил бы не RED, а
NOTHING или таймаут — и в этой смене я списала бы его на нагрузку машины с полным основанием: Makefile
зоны сам документирует разброс internal/pipeline от ~95 с на тихом хосте до ~560 с при трёх параллельных
агент-сессиях, и таймауты здесь уже были средой.
⚠ И его близнец, пойманный в этой же смене на этом же тесте: посадка, чья КРАСНОТА держится на
везении. Стоп-утверждение «ожидание сработало» я добавила, и тест стал зелёным — на моих точечных
прогонах без -race. Батарея под -race его уронила: два воркера успевали резервировать одновременно не
всегда, а потолок не может отказать в том, о чём не спросили параллельно. Утверждение отработало верно
(упало громко), но фикстура строила НАДЕЖДУ, а не состояние. Лечение то же, что и у первой находки:
удерживать вызов на фейк-провайдере, пока шлюз не покажет очередь.
⚠ И ПЯТАЯ ФОРМА, найденная в этой же смене у меня же: посадка, чей ЯКОРЬ не резолвится.
ROTTED — вердикт громкий, но он звучит только когда каталог прогнан ЦЕЛИКОМ. До этого запись
существует и не утверждает ничего, а ДЛИНА СПИСКА посадок читается как покрытие — то есть дефект
прячется ровно там, куда смотрят, оценивая полноту. У меня протухли два якоря, и оба сломала я сама
поздними рефакторами. Норма, которую это купило: посадка, чей якорь не резолвится, — не красная
посадка, а отсутствующая; проверка «каждый якорь резолвится РОВНО ОДИН раз» гоняется после КАЖДОГО
касания кода, а не перед сдачей. Это уже третий носитель класса: приёмка прошлого пака нашла тем же
вопросом четыре протухшие записи.
Это рядом с тремя уже известными формами: упавшая сборка (WF-length-bound в первой редакции: константа
1 << 30 съедала память в strings.Repeat тестовой фикстуры — каталог сказал NOTHING), вырожденное
утверждение (WF-version-fold-scope: тест сравнивал два разных набора строк и доказывал лишь, что они
разные) и эквивалентная посадка. Общее у всех четырёх: цвет каталога перестаёт быть свидетельством о
коде.
Практическое следствие, которое стоит держать: прежде чем поверить RED, стоит проверить, что тест,
который покраснел, краснеет ПО ТОЙ ПРИЧИНЕ; прежде чем поверить SURVIVED — что посадка вообще меняет
поведение; а NOTHING и таймаут читать как «инструмент не ответил», а не как «дефекта нет».
6.5. Каталог мутаций — ПРОГНАН ЦЕЛИКОМ И СОШЁЛСЯ
$ cp -a backend /tmp/mut/backend && cd /tmp/mut/backend
$ GOFLAGS=-buildvcs=false go run ./cmd/tmmutate -root .
191 mutation(s) run, 0 unexpected outcome(s)
MUT_EXIT=0
191 запись · 187 RED · 0 SURVIVED · 0 NOTHING · 0 ROTTED · 0 неожиданных исходов.
187 + 4 записанных survives = 191, арифметика сходится. Записанные выжившие — G-byte-gate и
WB62-innocent-const-must-not-be-accused (чужие, аргументированы прежними паками) плюс мои два:
· MS-reserve-under-the-gate — гонка «Reserve закоммичен, счётчик ещё нет» структурна, но
воспроизводима только с хуком в планировщик, которого в репозитории нет; в why записано ПРЯМО, чего
эта зелень НЕ значит: «не «гарантия держит», а «инструментом не проверяемо»».
· MS-reservation-released-on-marshal-failure — ветка недостижима по контракту encoding/json
(структура из int не может не смаршалиться), и покраснить её нечем без подмены стандартной
библиотеки.
Моих записей 33, из них 31 RED и 2 аргументированных выживших.
⚠ И ЭТО ВТОРОЙ ПОЛНЫЙ ПРОГОН. ПЕРВЫЙ БЫЛ ЧЕСТНЫМ, ПОЛНЫМ — И НЕ СОШЁЛСЯ: 183 RED · 3 SURVIVED · 1 NOTHING, четыре неожиданных исхода, все мои, включая посадку, красноту которой промт назвал
ОБЯЗАТЕЛЬНЫМ УСЛОВИЕМ ПРИЁМКИ (записи 30–34, 37 в §6.3). Разница между этими двумя прогонами — и есть
норма, которую смена купила: «каталог прогнан целиком» и «каталог прогнан целиком И СОШЁЛСЯ» — разные
утверждения, и первое звучит как второе.
6.6. Файлы
Новые: internal/membank/{wirefence,wirefence_test}.go · internal/pipeline/{reservegate,priceprojection,priceprojection_test,moneystop_test}.go · docs/MONEYSTOP_REPORT.md.
Изменены: internal/membank/{memory,decisions,memseed}.go · internal/chunk/ingest.go ·
internal/pipeline/{waverun,stagerun,escalation,repair,terminologist,events,manifest,status,runner,bookrun}.go ·
internal/runevents/runevents.go · cmd/tmctl/{main,render}.go · cmd/tmmutate/mutations.json (158 → 191) ·
тесты internal/pipeline/{runevents_test,wavepanic_test,volumepanic_test,contractblockers_test}.go.
6.7. Команды приёмки — каждое число выше получено одной из них
cd backend && make battery
go test ./internal/membank/ -run 'TestAUserTerm|TestTheDoor|TestTheSeedLoader|TestTheLengthBound|TestTheFenceStops|TestTheFenceMoves' -v
go test ./internal/pipeline/ -run 'TestUnderACeiling|TestARefusalWithNothing|TestTheGateWaits|TestSettleCannotHelp|TestTheShortfallIs|TestTheStoppedRunTells|TestResumeAfter|TestAMoneyStopInTheDraft|TestNothingLeaks|TestWaitingForHeadroom|TestACancelledWait|TestAnInfraFailure' -v
go test ./internal/pipeline/ -run 'TestTheManifestPrices|TestTheProjection|TestTheCutSays|TestTheStoredManifest|TestARunRewrites|TestStepMaxCounts|TestManifestServesTheReadModels' -v
go test ./internal/pipeline/ -run 'TestACeilingHaltAnnounces|TestWaveWorkerPanic|TestACrashOverACaughtCeiling|TestRunWaveSurfacesParentCancellation|TestRunnerEscalationCeilingDegrades|TestWaveParallelWorkersMoneyConserved' -v
# КРАСНЫЙ до починки — на копии дерева HEAD 32be78a (пункт 1)
git archive HEAD backend | tar -x -C /tmp/headtree && cp backend/internal/membank/wirefence_test.go /tmp/headtree/backend/internal/membank/
cd /tmp/headtree/backend && GOFLAGS=-buildvcs=false go test ./internal/membank/ -run 'TestAUserTerm|TestTheDoor|TestTheSeedLoader' -v
# каталог мутаций ЦЕЛИКОМ, на копии
cp -a backend /tmp/mut/backend && cd /tmp/mut/backend && GOFLAGS=-buildvcs=false go run ./cmd/tmmutate -root .
# дифф тестов ИСПОЛНЕНИЕМ
git diff -U0 -- backend | grep -E '^[+-]func Test' # пусто: ничего не удалено и не переименовано
git grep -h '^func Test' HEAD -- backend | wc -l # 1172
grep -rh '^func Test' backend --include=*_test.go | wc -l # 1208 (+36)
python3 -c "import json;print(len(json.load(open('backend/cmd/tmmutate/mutations.json'))))" # 191 (158 + 33)
7. Правки существующих тестов — объявление по D39.183
Все три вызваны сменой поведения, ЗАКАЗАННОЙ паком, ни одна не сделана «чтобы прошло».
(1) runevents_test.go — пин «the ceiling event must carry the fact and no figures».
· Что изменилось в поведении: кадр ceiling теперь несёт shortfall_micro_usd (заказ §4(3) и строки 278).
· Что описывал тест: ратифицированную норму «деньги не выходят из движка иначе как в spend»
(D39.106 §2/ПТ-33).
· Куда уехала гарантия: она СУЖЕНА, не снята. Кадр проверяется по БЕЛОМУ СПИСКУ ключей
(halted|scope|shortfall_micro_usd) — любое новое денежное поле краснеет; запрещён любой отрендеренный
$; и добавлено новое утверждение, которого не было: book-стоп ОБЯЗАН назвать недостачу, day-стоп
обязан её НЕ называть. ⚠ Оркестратор берёт ноту о сужении на себя (акт приёмки): сужение
ратифицированной фразы — ратификационное действие, а не обслуживание.
(2) wavepanic_test.go и (3) volumepanic_test.go — оба ВИСЛИ.
· Предъявление, а не утверждение: panic: test timed out after 1m30s, FAIL … 90.048s.
· Что изменилось: оба парковали падающий воркер на <-ctx.Done() и рассчитывали, что отмену вызовет
ПОТОЛОК. Потолок больше не отменяет — это и есть заказ пака §4(2).
· Куда уехала гарантия: никуда. Утверждения не ослаблены — краш и потолок по-прежнему в РАЗНЫХ слотах
и оба выходят из волны; ушёл только ритуал упорядочивания, который с двумя слотами ничего не решает.
Упорядочивание, которое ещё решает — краш против ИНФРА-ошибки, она отменяет, — пинится там же через
ctx.Done() и предъявлено КРАСНЫМ посадкой MS-crash-outranks-the-wave-error (§6.5).
· Попутно: при первой переписке volumepanic тест ПРОВАЛИЛСЯ, потому что под защёлкой кормилец не
успевал раздать второй элемент — падающий воркер обязан ДЕРЖАТЬ свой элемент до возврата halt. Это
поведение защёлки, а не дефект; в тест внесена явная синхронизация и комментарий.
8. Правка ВНЕ ЗАКАЗА, взятая по своему усмотрению
Течь резервации на ветке json.Marshal(resp.Usage) (stagerun.go): ветка возвращалась без
releaseReservation и без settle, оставляя оценку в reserved_usd до следующего store.Open.
· Довод: это денежный путь, и у него появился ВТОРОЙ читатель — утёкшая резервация завышает недостачу,
которую прогон теперь публикует, то есть движок попросил бы у человека больше, чем ему нужно.
· Честная граница: ветка практически недостижима — json.Marshal структуры из int не может упасть.
Поэтому посадка MS-reservation-released-on-marshal-failure занесена в каталог как аргументированный
выживший, а не как находка: покраснить её без подмены стандартной библиотеки нечем, и я не стала
выдумывать доказательство. Недостижимость — то, что перестаёт быть правдой от одной правки соседа.
9. Obstacle reporting — что НЕ удалось, что не проверено, что осталось гипотезой
- ⛔ Клин юнита НЕ починен, и это признанный долг, а не забытое. Отказ РЕЗЕРВАЦИИ на ретрае не
деградирует И не оставляет следа: он уходит наверх из цикла попыток (
stagerun.go:164-167) РАНЬШЕUpsertChunkStatus(stagerun.go:285), так что чекпойнт attempt 0 есть (реплей за $0), а строкиchunk_statusнет — читающие модели видятpending. Если потолок покупки не вмещает attempt 1 с удвоенным бюджетом, юнит не доделается НИКОГДА, а прогон уходит exit 4 при полностью доставленных купленных юнитах. Мойstep_max_usdэто НАЗЫВАЕТ (ретраи в нём именно поэтому),Finished.Moneyназывает доставленное и отложенное — но клин остаётся. Лечение: (а) деградация отказа ретрая во флаг attempt 0 (смена D2.3) либо (б) минимальная покупка платформы ≥step_max_usd. Выбор не мой и не оркестратора; строка бэклога с ОБОИМИ путями — в моей секцииdocs/PROGRESS.md. - ⚠ ⭐-уточнение оркестратора («защёлка на свежем, продолжение на ретрае») ОТКЛОНЕНО, и не по цене.
Его посылка — «текст у юнита ЕСТЬ, он лишь флагован» — ложна: см. п. 1, строки
chunk_statusнет, для читателя это такой же ПРОБЕЛ. Различитель дёшев (номер попытки есть прямо в точке отказа), но покупает не то, ради чего предлагался. - ⚠ Цена защёлки НЕ ЗАМЕРЕНА, только выведена. Что юниты, которые влезали, не начинаются — следует из удвоения бюджета ретрая и из замеренного разбором 04.09 разброса резервов 2.976× внутри волны. На реальной книге я это не мерила: пак не включает платного прогона.
- ⚠ Числа проекции не сверены с фактом на реальной паре.
expected_usdстроится из фертильностиzh-ruи ценmodels.yaml; насколько он попадает в настоящий счёт — вопрос замера, а не чтения. То, что предъявлено: он не константа, растёт с длиной главы, катится в сумму по главе и книге, иstep_max_usdне меньше резервации, которую исполнитель делает для первого вызова. - ⚠ Порог «сеттлить ли поздно отменённый вызов оценкой» НЕ выставлен, и это сознательный отказ. Промт разрешал латентность кандидатом при условии «порог обязан быть обоснован ЗАМЕРОМ, а не назначен». Замера нет, платный прогон паком не выдан ⇒ порог назначить нечем, и я его не назначала.
- ⛔ ПРОВОДКА
settleCannotHelpНЕ ПРОВЕРЕНА, и я показываю, ПОЧЕМУ она почти ничего не решает, а не отговариваюсь. Предикат запинен своим тестом;if, который его спрашивает, — нет. ⚠ И здесь надо развести ДОСТИЖИМОСТЬ и ЭФФЕКТ — первую редакцию этого абзаца я написала неверно, и поправил меня оркестратор по строке кода. Предикат стоит ПЕРЕД входом в ожидание (stagerun.go:530-531), поэтому когда он ИСТИНЕН, полёт не спрашивают вовсе: путь достижим при любом состоянии полёта, включая пустое. Утверждать «достижим только при непустом» — значит утверждать недостижимость состояния, которого код не держит, и следующая сессия построила бы на этом пин. Верно другое: он что-то МЕНЯЕТ только при непустом полёте — при пустом книгу и без него останавливаетwaitNothingInFlight. А состояние «отказ, который никакой settle не поднимет, ПРИ непустом полёте» почти недостижимо. Но вызов в полёте сам был допущен при том жеcommittedи сопоставимой оценке — если место нашлось ему, оно найдётся и этому: два условия противоречат друг другу, пока оценки не разойдутся существенно (разнородные размеры чанков, либо черновик и редактура в одной волне — а они не встречаются никогда). А когда полёт пустеет, книгу останавливаетwaitNothingInFlightи без предиката. ⇒ он ОПТИМИЗАЦИЯ (не держать волну открытой весь провайдерский таймаут ради известного ответа), а не несущая гарантия, и в §2 он теперь описан так. - ⛔ ЧТО ТРИ ОПЦИОНАЛЬНЫХ САЙТА ПЕРЕДАЮТ ИМЕННО
false— не прибито ничем, и я это НАЗЫВАЮ, а не заявляю. Правило «кто может ждать» теперь живёт в шлюзе и запинено тестом с обеих сторон (опциональный отворачивается со счётчиком, обязательный ждёт и просыпается), плюс посадкой. Но АРГУМЕНТ, которыйescalation.go,repair.goиterminologist.goпередают в этот шлюз, проверяется только компилятором и комментарием у каждого вызова. Недостающий артефакт назван точно: интеграционная фикстура, в которой ОПЦИОНАЛЬНЫЙ вызов отказан, ПОКА сосед в полёте. Собрать её дёшево не вышло: у эскалации хоп случается после сеттла своего же черновика, а потолок, впускающий два параллельных черновика, впускает и хоп; репайр живёт внутри воркера редакторской волны и требует дефекта; терминолог идёт между волнами, где в полёте по построению никого. Выдумывать доказательство черезsleepя не стала — назвать дыру честнее. - ⚠ Гонка
Reserve-вне-мьютекса структурна, но не воспроизводима. ПосадкаMS-reserve-under-the-gate— см. §6.5; если она пережила, это НЕ значит, что дефекта нет, а значит, что без хука в планировщик его нечем поймать. Выдумывать «доказательство» черезsleepя не стала. - ⚠ Платформенная сторона не проверена ни на чём. Читает ли платформа
shortfall_micro_usd, не считает ли её sweep живой юнит уpaused-прогона аномалией (что убило бы «доделать допущенное» на её стороне) — чужая зона, я туда не ходила. Передано пингом. - ⚠
statusтеперь читает манифест ДВАЖДЫ, и я это не лечу.readModelChunksзагружает сайдкар, иreadModelPriceзагружает его снова — аloadManifestвалидирует ключ черезsourceSHA256по ВСЕМУ исходнику, то есть на 23-мегабайтной книге это второе полное чтение файла и второй хеш на каждыйstatus. Не ingest (те 1.4 с), но и не $0. Лечение — вернуть загруженный документ изreadModelChunksвместо второй загрузки — трогает сигнатуру, которой пользуются четыре вызывающих, и шире этого пака. ⚠ Туда же уходит TOCTOU между двумя загрузками: конкурентныйtranslate, записавший сайдкар между ними, даёт согласованный ответ по СОВПАДЕНИЮ источника, а не по построению. - ⚠ Две величины в
expected_usdНЕ смоделированы, и обе названы в доке, а не спрятаны. (а) Доля РАЗМЫШЛЕНИЯ в completion: на провайдере с subset-биллингом рассуждение входит в completion, но датума о его доле в конфиге нет — считается текст без него, то есть в сторону занижения. Именно из-за этого у редакторской модели существует флор 16000 (models.yaml), так что величина не мала. (б) На стадии k>0 размер входа — токены провайдера (est_out) противEstimateTokens(знаки/3) у исполнителя; совпадают только при ~3 знаках на токен. На боевом плече обе глушит модельный флор; на плече без флора — нет. - ⚠ Взаимодействие с
--max-unitsпод денежным стопом проверено только чтением.res.Volumeпри денежном стопе не прикладывается,scope.reconcileне зовётся; что объёмный грант и денежный потолок в одном прогоне ведут себя корректно во ВСЕХ комбинациях — не предъявлено прогоном. - ⚠ Каталог мутаций гонялся ЦЕЛИКОМ только на финальном дереве. Промежуточные прогоны я дважды останавливала САМА, обнаружив, что копия устарела от собственных правок: результат о дереве, которого больше нет, — это ровно тот класс, за который смена расплачивалась («утверждение о дереве живёт до следующего коммита»). Цвета в §6.5 — с последнего, замороженного прогона.