Skip to content

State machine: shrinking

Падающая CommandSequence может состоять из десятков команд. Shrinking находит кратчайшую, простейшую последовательность, всё ещё воспроизводящую падение, тем же двухфазным подходом, из которого собран Command::shrinks(): сначала удалить команды, потом упростить оставшиеся.

Фаза 1: удаление блоков

CommandSequenceArbitrary шринкает длину раньше всего остального, удаляя смежные блоки команд, а не по одной:

blockSize = count, count/2, count/4, ..., 1
для каждого blockSize:
  для каждого offset, где offset + blockSize <= count:
    выдать последовательность с удалёнными commands[offset .. offset+blockSize)

Сначала пробуется наибольший блок (вся последовательность), затем половины, затем четверти, вплоть до удаления одной команды. Именно это позволяет шринкеру изолировать падающий шаг в середине длинной последовательности: удаление только префиксом (отбросить первую половину, затем первую половину оставшегося) никогда не может прийти к «оставить всё, кроме команды #7» — скольжение окна размером в одну команду по всем offset'ам может. Удаление блока, которое опустило бы последовательность ниже minLength, пропускается, поэтому Gen::commands(..., minLen: 3, ...) никогда не шринкает меньше трёх шагов.

Фаза 2: упрощение по каждой команде

Когда удаление блоков перестаёт находить меньшую падающую последовательность, шринкер фиксирует длину и обходит собственное shrink-дерево каждой оставшейся команды ($command->shrinks()), подставляя по одной упрощённой команде за раз. Push(87) шринкается к Push(0) через дерево IntArbitrary точно так же, как если бы это был обычный параметр — команды шринкаются как любое Shrinkable-значение, потому что они им и являются.

Без ревалидации на реплее — намеренно

Ни одна из фаз не перепроверяет Command::preCondition() при построении кандидатов. Удаление команды #3 может инвалидировать precondition команды #7 (которая зависела от состояния, установленного командой #3), и шринкер об этом не знает — он просто предлагает более короткую последовательность.

Гарантия корректности живёт в раннере, а не в arbitrary: StateMachine::check() перепроверяет preCondition() против бегущей модели для каждой команды в реплеенной последовательности и пропускает любую команду, чья precondition больше не выполняется, вместо того чтобы упасть или бросить исключение:

php
foreach ($sequence->commands as $command) {
    if (!$command->preCondition($model)) {
        continue; // shrinking инвалидировал этот шаг — пропустить, не падать
    }
    // ... выполнить, проверить postCondition, продвинуть модель ...
}

Это осознанный контракт, а не пробел, который нужно закрыть: ревалидация каждого кандидата внутри arbitrary означала бы повторный прогон команд против реальной модели на каждой попытке шринка (не только принятых шагах) — а это ровно та дорогая работа с side-эффектами, которую shrinking должен избегать до тех пор, пока кандидат не подтверждён как всё ещё падающий. Skip-on-replay держит каждый shrink-кандидат дешёвым в предложении и корректным в исполнении — последовательность в строке Shrunk: всегда та, которую StateMachine::check() реально прогнал команда за командой, а не та, что незаметно пропустилась в «прошла».

Как читать shrunk-трейс

Property falsified after 7 successful run(s); seed=42
  Original: sequence=[Push(3), Pop(), Push(5), Push(1), Pop(), Pop()]
  Shrunk:   sequence=[Push(0), Push(1), Pop()] (9 shrink step(s))
  Failure:  Postcondition failed at step 3 for command Pop(); sequence: [Push(0), Push(1), Pop()]

Шесть команд сжались до трёх: удаление блоков урезало с шести до трёх (отбросив Pop(); Push(5)), затем упрощение по командам свело Push(3) к Push(0). step 3 в строке failure считает только реально выполненные команды — шаг, пропущенный из-за инвалидированной precondition, не двигает счётчик шага, поэтому он остаётся согласован с тем, что вы посчитаете, читая список sequence: слева направо.

См. State machine: концепцииCommand, Gen::commands() и полный разобранный пример.