SoFCheck
38 subscribers
64 links
Заметки по поводу разработки шахматного движка SoFCheck

Репозитории:
https://github.com/alex65536/sofcheck
https://github.com/alex65536/sofcheck-engine-tester
https://github.com/alex65536/sofcheck-weights-tuning
Download Telegram
Наконец, расскажу про include-what-you-use. Этот инструмент позволяет делать следующее:
- находить неиспользуемые include
- находить include'ы, которые стоит добавить. Например, мы в файле b.h пишем #include "a.h". А в некотором cpp-файле делаем #include "b.h" и используем как функции из b.h, так и функции из a.h. Тогда include-what-you-use предложит заинклудить a.h
- предлагать добавить forward declaration'ы вместо того, чтобы инклудить заголовки. Таким образом можно уменьшить время компиляции

Звучит довольно полезно, поэтому я поставил его из репозиториев Debian и решил запустить:
$ cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
$ make -j
$ iwyu_tool -p . --

include-what-you-use интегрирован с CMake, поэтому его можно сразу запускать при сборке:
$ CC=clang CXX=clang++ cmake -DCMAKE_CXX_INCLUDE_WHAT_YOU_USE=iwyu ..
$ make -j

Теперь коротко о том, какие результаты я получил. Было много действительно корректных срабатываний, но были и случаи, когда он вел себя неправильно

Во-первых, он хотел выкинуть config.h. В этом файле содержатся всякие макросы, которые затем с #ifdef'ами используются в коде

Во-вторых, иногда он предлагал использовать какие-то странные include'ы вроде #include <ext/alloc_traits.h>

В-третьих, он предлагал очень странные вещи, когда я подключал gtest/gtest.h в Google-тестах: ссылка

В-четвертых, он много ругался на код Dodecahedron'а. Этот код я не хочу исправлять, а как отключить проверку для некоторых файлов, я не нашел

В общем, ложных срабатываний в результатах получилось довольно много, поэтому я не стал добавлять include-what-you-use в CI. Просто буду его запускать время от времени и смотреть

Ну и наконец, ссылка на коммит, в котором я все это исправил
Пока я настраиваю эвристику нулевого хода и проверяю ее работоспособность на Battlefield'е (про него был пост выше), немного расскажу про то, как я оптимизировал Battlefield

Как-то раз я заметил, что на коротких играх (порядка 10 мс на ход) Battlefield потребляет очень много процессорного времени. Чуть ли не треть времени, которую работал процесс движка. Решил разобраться, в чем же дело

Battlefield написал на Pascal. К счастью, у компилятора FPC, которым он собирается, есть поддержка Valgrind: надо лишь добавить флаг -gv при сборке. После этого можно попрофилировать код инструментом Callgrind, который позволяет отследить, сколько времени работает каждая функция. Для визуализации этих данных можно воспользоваться, например, замечательной программой KCacheGrind

В результате я выявил следующее. Во-первых, много времени занимала функция PutChecks, которая решала для каждого хода, является ли он шахом или матом. Такая возможность полезна для преобразования хода в строку, но абсолютно не нужна в остальных случаях

Во-вторых, очень много раз вызывается генерация всех ходов. Стоит отметить, что генератор ходов в Battlefield довольно медленный. Он писался для Chess256, где скорость была не так уж и важна

Иногда генерация ходов вызывалась вообще без особого смысла, просто потому что ходы генерировались при каждом изменении позиции. К счастью, такое поведение легко отключается. Но были и случаи, когда генерация вызывалась, чтобы можно было проверить корректность хода. Тогда можно генерировать не все ходы, а только ходы заданной фигуры. Для этого уже пришлось поменять функцию GenerateMoves, чтобы так можно было делать

Главной проблемой было применить все эти оптимизации, не сломав имеющийся код: я не залазил в код Chess256 уже пару лет, и до конца не уверен, что там творится. Тестов, к сожалению, я не писал, поэтому пришлось подойти к изменениям довольно аккуратно

Изменения получились такими. Через некоторое время я еще немного пооптимизировал
Какое ускорение это дало? Сам Battlefield стал работать минимум в 3-4 раза меньше. Но это не значит, что матчи стали завершаться в три раза быстрее: большую часть времени все равно работали движки, а не Battlefield. Тем не менее, прирост скорости получился довольно серьезным: время, за которое завершились игры, уменьшилось где-то 40% на коротких (~10мс на ход, уже точные цифры не помню) матчах. На более длинных (~100 мс на ход) время уменьшилось где-то на 10%. Довольно неплохо :)

Сейчас Battlefield занимает немного времени: примерно 2% от суммарного времени на матчах по 100мс на ход и примерно 6% времени — на матчах по 10мс на ход. Если запускать по 1мс на ход, то получится значительно больше — где-то 15% времени, но на таком времени я тестирую не очень много. По этой причине дальше оптимизировать не вижу особого смысла: ускорение получится небольшим, зато придется потратить больше времени и здо́рово увеличить вероятность багов. Есть и другая проблема, связанная со скоростью работы Battlefield'а: он ждет ответа от движка, засыпая на 1 миллисекунду в цикле и проверяя каждый раз ввод (код)

Можно, конечно, переписать Battlefield, но здесь стоит вспомнить знаменитый пост Джоэла Спольски про переписывание с нуля и успокоиться. Хотя, возможно, я когда-нибудь вернусь к теме оптимизации Battlefield, но это произойдет нескоро…
Вроде бы эвристика нулевого хода работает: после дебага мне удалось добиться улучшений. Пока она написана в форме null move reduction: мы не отсекаем ветку, а лишь сильно уменьшаем глубину. Но я продолжу эксперименты, и посмотрю насколько хорошо отработают другие варианты

Результаты против предыдущей версии пока что такие (200 игр, 500 мс на ход):
Wins: 91, Loses: 46, Draws: 63
Score: 122.5:77.5
Confidence interval:
p = 0.90: First wins
p = 0.95: First wins
p = 0.97: First wins
p = 0.99: First wins
Other stats:
LOS = 1.00
Elo difference = 79.53
Еще из интересного за сегодня: сделал так, чтобы информация о текущем коммите включалась в бинарник и отображалась в имени движка. Раньше оно было сделано не очень надежно: номер коммита в бинарнике не всегда соответствовал реальности. Сейчас эта инфа обновляется каждый раз, когда сдвигается HEAD

Чтобы этого добиться, я добавил add_custom_command(). Он конфигурирует файл с версией и зависит от файла .git/logs/HEAD. Когда этот файл обновляется (например, при коммите), то файл с версией обновляется

Впрочем, лучше посмотреть дифф, чем читать объяснение :)
После не очень продолжительного тестирования решил оставить null move reduction. Сравнивал силу игры против версии с null move pruning (т. е. когда мы полностью отсекаем ветку, а не понижаем глубину) и не получил значимых результатов. А поскольку они играют примерно на одинаковом уровне, я все-таки решил оставить null move reduction: он считает чуть больше позиций и относительно устойчив к цугцвангу

Сами изменения выглядят так: коммит. Кода не очень много, зато прирост большой :)

Я пока не тратил много времени на подбор коэффициентов. Сейчас лучше написать побольше эвристик и получить выгоду от всевозможных крупных улучшений, а потом уже заняться тонкой настройкой, от которой выгода меньше. К тому же, у меня не очень много времени на запуск большого числа партий, а без этого Battlefield не может задетектить пользу от небольших улучшений
Как-то давно я не занимался движком и не писал посты в канал. Пора бы исправить эту ошибку

Я как раз решил добавить одну довольно мощную эвристику — Late move reductions. Смысл примерно такой. Как известно, ходы обычно рассматриваются в отсортированном порядке: сначала те, которые могут улучшить текущий результат, потом те, которые с большой вероятностью его не улучшают. Мы берем те ходы, которые стоят далеко в порядке сортировки и считаем их на уменьшенной глубине. Если на уменьшенной глубине они дают какое-то улучшение, то пересчитываем заново — уже на полной глубине

Эвристика довольно мощная и дает хороший прирост — ~100 Эло при игре на 500 мс против старой версии. Но результат очень сильно зависит от контроля времени: при маленьком количестве времени на ход улучшения почти незаметны, а при очень маленьком — незаметны совсем

Я пытался настраивать параметры для Late move reductions. К сожалению, для проверки, насколько хорошо работают параметры, требуется большое время. Я заметил это, когда проводил ухудшающий эксперимент: стал уменьшать глубину в этой эвристике не на 1, а на 100. Интересно, что серьезное ухудшение игры я заметил, только когда движки тратили секунду на ход. То есть, чтобы проверить новые параметры, мне необходимо запустить 500 игр (поскольку изменения могут быть не очень значительными) при 1 секунде на ход. Такая проверка занимает на моем ноутбуке примерно 1.5-2 часа, что довольно долго

Возможно, поможет такой способ тестирования. Надо запускать Late move reductions, но не отсекаться в действительности, а смотреть число попаданий: насколько часто эта эвристика отсекает реальное улучшение. Затем можно пытаться настроить эвристику так, чтобы процент ложноположительных срабатываний был как можно меньше. Я еще не проверял, что из этого получится, но когда-нибудь точно стоит попробовать :)

А пока что я руководствуюсь стратегией «забрать самые крупные улучшения, а потом уже докручивать более мелкие». Поэтому решил сильно не напрягаться подбором параметров и просто вкатил изменения в код :)

Стоит полюбоваться на само изменение: коммит
Заметил, что в коде анализа часто встречаются такие строчки, чтобы сделать ход:
const Evaluator::Tag newTag = tag.updated(board_, move);
const MovePersistence persistence = moveMake(board_, move);
DGN_ASSERT(newTag.isValid(board_));

А после того, как мы закончили анализировать этот ход, надо бы его отменить:
moveUnmake(board_, move, persistence);

В коде много мест, где есть return и continue, и можно случайно забыть сделать отмену. Забывчивость, скорее всего, не пройдет бесследно: код провалит тесты в режиме диагностики. Тем не менее, можно сделать лучше и красивее. На помощь приходит RAII: ссылка
Пока я не испытывал никаких новых эвристик, расскажу про clang-tidy и про то, как он используется в SoFCheck'е

Что такое clang-tidy? Это статический анализатор, входящий в состав компилятора Clang. Он может отлавливать проблемные места в коде. Еще он может проверять код на соответствие хорошим практикам и кодстайлу (например, проверять, что имена переменных названы в camelCase). То есть, инструмент довольно мощный. clang-tidy помогает писать более чистый код и предотвращать потенциальные ошибки в коде

Чтобы его настроить, достаточно добавить файл .clang-tidy в корень проекта. В этом файле необходимо указать все используемые проверки

Как запускать проверку? Можно, например, найти нужный пункт меню в IDE (у меня в KDevelop это Code > Analyze Current Project With). Есть и интеграция с CMake: достаточно при конфигурировании проекта указать флаг -DCMAKE_CXX_CLANG_TIDY=<путь до clang-tidy>, и он будет запускаться вместе со сборкой проекта. Третий вариант — запустить скрипт run-clang-tidy из папки со сборкой. Этот скрипт просто пробежится по всем файлам проекта. Но для этого нужно наличие файла compile_commands.json, поэтому стоит прописать в CMake флаг -DCMAKE_EXPORT_COMPILE_COMMANDS=ON, чтобы этот файл создавался
Конечно, clang-tidy хорош, но у него есть и недостатки:

Во-первых, у него имеется очень много всяких разных проверок различной степени строгости. Если включить все, то можно получить тонну нерелевантных предупреждений. Поэтому его надо тщательно конфигурировать

Во-вторых, скорость работы. Работает clang-tidy довольно медленно, точно в несколько раз медленнее, чем компилятор. В SoFCheck'е это не доставляет больших проблем (разве что пару лишних минут на сборку в CI), а в больших проектах может быть критично

В-третьих, он иногда плохо работает с используемыми библиотеками. Пару диагностик пришлось отключить, поскольку иначе возникали проблемы с тестами на Google Test и бенчмарками на Google Benchmark

В-четвертых, clang-tidy местами забагован. И это становится причиной многих отключенных диагностик и NOLINT. Бывают и довольно странные баги, например, такой. Или такая проблема. Или ложноположительные срабатывания с if constexpr

Тем не менее, для меня плюсы перевешивают минусы, поэтому clang-tidy успешно используется в SoFCheck'е

Из-за описанных выше проблем конфиг для clang-tidy получился довольно объемным: ссылка. Там же есть комментарии, которые объясняют, почему были отключены некоторые проверки
Решил попробовать и ради интереса переписать BattleField на Python (напоминаю, что это утилита для тестирования движков, подробнее здесь). Коротко расскажу о том, зачем это нужно и что получилось в итоге

Одно из направлений, в котором я хочу улучшить BattleField — это сделать его распределенным: чтобы часть партий проходили локально на ноутбуке, часть — в облаке на большом количестве ядер. Тестирование от этого станет значительно быстрее. А Pascal не очень хорошо дружит с сетевым программированием. Конечно, HTTP-клиент на нем написать можно, но гораздо приятнее это делать на других языках. Почему BattleField изначально написан на паскале, я уже объяснял здесь

Было решено переписать код на Python, потому что тогда 1) есть большие возможности по подключению в код всего, что угодно (например, всяких сетевых библиотек), 2) под него есть замечательная библиотека chess, которая реализует правила и взаимодействие с движками

BattleField — небольшой проект (~1200 строк, не считая часть из Chess256). После переписывания на Python бо́льшей части кода получилось примерно 700 строк. По этой причине работа много времени не заняла: всего-то 4-5 часов времени. Некоторые возможности, например, сохранение партий в PGN, пока не реализованы в версии на Python

Библиотека chess, хотя и написана на Python, реализована на битбоардах, а в BattleField используется наивная реализация правил. Я рассчитывал, что даже несмотря на медлительность Python'а, реализация в chess окажется быстрее моей
В итоге получилось так. В однопоточном режиме обе реализации работают примерно одинаково. А в многопоточном режиме из-за GIL производительность проседает очень сильно. Скорее всего, эта проблема поправима: потоки практически не пишут в общую память, поэтому их получится разнести по разным процессам. Но стоит учитывать, что у оригинального Battlefield гораздо бо́льший потенциал ускорения, поскольку там правила написаны неоптимально. А шансов исправить существующую, довольно неплохо написанную библиотеку у меня меньше, чем переписать свой явно неоптимальный код

Вторая проблема с реализацией на Python заключается в следующем. Сейчас BattleField — это один бинарник без всяких зависимостей, который запустится везде. А вариант на Python требует установки зависимостей. Я сейчас не уверен, что следует усложнять процесс установки BattleField'а

В общем, мне следует тщательно обдумать, какую из версий развивать в дальнейшем (или же рассмотреть какие-то еще другие языки?) А пока что можно посмотреть, как выглядит BattleField на Python: ссылка
Давно здесь не было новостей, поэтому расскажу о том, что я сделал сегодня. А сегодня я выкатил новый релиз Battlefield'а: ссылка. Теперь можно выставить не только фиксированное количество времени на ход, но и полноценный контроль времени, например: "одна минута на игру" или "15 минут на 40 ходов плюс 5 секунд каждый ход"

Еще попробовал поиграть с ifrit'ом. У этого движка ~2400 рейтинга. Раньше с ним не удавалось сыграть по простой причине: ifrit нормально не поддерживал фиксированное время на ход. Если его попросить думать определенное количество времени, то он воспринимает этот лимит как нижнюю границу, и может начать думать больше. Battlefield'у это, естественно, не нравится; в таком случае он просто убивает процесс и засчитывает поражение. А с контролем времени ifrit играет как раз хорошо. Вот результаты на 100 играх, при 60 секундах на игру:
Wins: 31, Loses: 59, Draws: 10
Score: 36.0:64.0
Confidence interval:
p = 0.90: Second wins
p = 0.95: Second wins
p = 0.97: Second wins
p = 0.99: Second wins
Other stats:
LOS = 0.00
Elo difference = -99.95

Т.е. SoFCheck сливает ~100 Эло, что соотносится с моим представлением о его рейтинге примерно в 2300 Эло
Небольшое замечание. Я иногда сам поглядываю в код ifrit'а, чтобы посмотреть, как реализована та или иная эвристика. Этот движок, как и мой, написан на C++. Код там во многом довольно понятный и хорошо прокомментирован. Но я бы не назвал код ifrit'а хорошим, поскольку в нем много дублирующегося кода. Анализ для белых и для черных написан в двух отдельных функциях, которые очень похожи друг на друга. Интересно, конечно, почему автор не воспользовался NegaMax'ом
Еще во время сегодняшнего тестирования на Battlefield'е я обнаружил, что на маленьком контроле времени (менее 60 секунд на игру) SoFCheck может проиграть из-за просрочки по времени. Надо разбираться, почему так
Что-то я очень долго не писал ничего в код SoFCheck'а и в этот канал. Сегодня у меня наконец-то появилось немного свободного времени, чтобы всем этим заняться

Я решил по-нормальному парсить командную строку во всяких разных утилитах, написанных на C++. Сейчас там написана какая-то кастомная логика, которая смотрит на argc и argv напрямую. Я скоро собираюсь улучшать утилиту для сборки датасетов и добавить в нее больше флагов командной строки, но тогда кастомная логика станет довольно сложной

По этой причине мне понадобилась библиотека для парсинга командной строки. Требования примерно такие:
- легко встроить в проект, не должно быть внешних зависимостей
- нормальная работа при сборке с флагом -fno-exceptions (предполагается, что SoFCheck должен с ним нормально собираться)
- красивое отображение справки: если описание параметра или программы слишком длинное, его надо разбивать по строкам

Я посмотрел на имеющиеся библиотеки на awesome-cpp и выбрал для себя три:
1) https://github.com/bfgroup/Lyra: довольно неплохо работает, вообще не использует исключений, но не умеет переносить строки (см. issue)
2) https://github.com/taywee/args: хоть там и есть опция ARGS_NOEXCEPT, но библиотека работает с багами при ее использовании. Завел им issue по этому поводу
3) https://github.com/jarro2783/cxxopts: работает, умеет собираться без исключений (в этом случае при ошибке в параметрах командной строки программа просто завершается), но не умеет переносить описание программы, если оно слишком длинное (завел issue про это)

В итоге я выбрал 3), а проблему с переносом строк решил уже своими костылями и обертками. После этого осталось только убрать кастомную логику и немного пропатчить код

Наконец, покажу реализацию функции wordWrap() для разбиения длинных строк: ссылка. Код, конечно, не самый тривиальный, зато оптимальный и работает всегда за O(n)
У меня развалился GitHub Actions со странной ошибкой на git clone, которая проявляется время от времени:
$ git clone --branch v1.5.5 https://github.com/google/benchmark/
Cloning into 'benchmark'...
error: RPC failed; curl 56 OpenSSL SSL_read: Connection was reset, errno 10054
error: 5898 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output
Вот еще одно падение, на этот раз при клонировании Google Test:
$ git clone --branch release-1.10.0 https://github.com/google/googletest/
Cloning into 'googletest'...
error: RPC failed; curl 56 OpenSSL SSL_read: Connection was reset, errno 10054
error: 1885 bytes of body are still expected
fetch-pack: unexpected disconnect while reading sideband packet
fatal: early EOF
fatal: fetch-pack: invalid index-pack output
Надеюсь, что GitHub скоро поправит эту проблему, а то у меня весь CI сегодня красный :(
Сегодня расскажу немного деталей про то, как я генерировал данные для обучения

Начну издалека. На высоком уровне шахматный движок устроен просто: перебор с огромным количеством отсечений. Но перебирать до конца игры слишком долго, поэтому надо остановиться на какой-то глубине и сказать, насколько хороша позиция. Для этих целей нужна оценочная функция

При написании оценочной функции есть большой простор для творчества. Как это происходит в SoFCheck, я уже писал ранее: раз, два. Еще упомяну, что здесь можно использовать нейросети, как это сделано, например, в Stockfish, но я пока не готов к такому в своем движке

В оценочной функции надо подбирать коэффициенты. Для этого нужно обучение на большом датасете. Как я уже писал ранее, для этого я гонял много партий SoFCheck с самим собой на Android-планшете в Termux. Почему именно на планшете?
1) почему бы и нет? :)
2) параллельно хочется на ноутбуке экспериментировать с движком, а не ждать, пока сгенерируется набор данных
3) современные мобильные процессоры довольно быстрые и ненамного уступают десктопным (на ноутбуке SoFCheck успевает анализировать 8 млн позиций в секунду, на планшете — 4.5 млн), при этом они еще и многоядерные

Теперь расскажу про технические проблемы, которые возникли на этом пути. Основная — надо скомпилировать свой код под ARM и Android. К счастью, в Termux можно поставить clang, cmake и скомпилить движок прямо на планшете

С Battlefield сложнее. Дело в том, что он по историческим причинам написан на Free Pascal, а среди пакетов Termux паскаля нет. К счастью, у Free Pascal есть довольно неплохая кросс-компиляция. Через fpcupdeluxe можно поставить все необходимое: Free Pascal и Lazarus (IDE под Free Pascal + система сборки) любой версии, а также необходимые кросс-компиляторы

Ради интереса я решил глянуть с исходники самого fpcupdeluxe на GitHub. Код там местами написан ужасно. Вот вам, например, функция на 1025 (!) строк: ссылка. Зато работает :)

После кросс-компиляции Battlefield, к сожалению, не запустился и выдавал ошибку при запуске:
bash: ./battlefield: No such file or directory

Проблема оказалась в том, что в бинарнике по какой-то причине неправильно выставлялся путь до динамического линкера. Решается проблема просто:
$ patchelf --set-interpreter /system/bin/linker64 battlefield
После этого Battlefield прекрасно работает :)

Наконец, надо как-то забрать с планшета полученный датасет. Для этого можно, например, поднять в Termux сервер ssh и подключиться к нему по SFTP. Это в целом полезная штука, которая позволяет гонять данные между устройствами
Наконец, расскажу, что я хочу сделать с обучением дальше. Я почитал обучалку от движка zurichess, которая использует похожие идеи

Понял следующее: мне нужно сгенерировать бо́льший датасет (100'000 игр, например), но при этом не использовать его полностью (иначе все данные просто не поместятся в оперативную память), а рандомно выбрать из датасета сколько-то позиций (10^6, например). Тогда обучение теоретически увидит больше разных ситуаций и построит более сбалансированную оценку. Но это еще предстоит узнать :)

Во-вторых, мне нужно более оптимально хранить датасеты. Сейчас они хранятся, по сути, в виде списка всех позиций в формате FEN. Например:
game B 1
board rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
board rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
board rnbqkbnr/pppppp1p/6p1/8/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2
board rnbqkbnr/pppppp1p/6p1/8/3PP3/8/PPP2PPP/RNBQKBNR b KQkq d3 0 2
Хочется хранить вместо этого список ходов:
game B 1
moves e2e4 e7e5 d2d4
Я мог бы хранить партии просто как PGN, но там не очень приятный для парсинга формат ходов (и SoFCheck не умеет их парсить). Куда проще подавать ходы в формате UCI, который понимает любой шахматный движок

Эксперименты показывают, что экономия в таком случае получается существенной: примерно в 10 раз. Что интересно, изменение формата сильно уменьшает размер, даже если хранить датасеты в сжатом виде. Пусть old.txt — файл в старом формате, а new.txt — в новом. При сжатии с помощью bzip2 получается, что old.txt.bz2 больше в 10 (!) раз, чем new.txt.bz2, а при использовании lzma на максимальной степени сжатия — в 3.5 раза. Хотя, казалось бы, соседние позиции довольно похожи, а запись хода буквально кодирует, откуда и куда переместилась фигура

Стоит отметить, что сжатие само по себе существенно уменьшает размер файла: old.txt больше, чем old.tar.bz2 примерно в 9 раз. Может быть, такая высокая степень сжатия возникает из-за того, что в датасете много повторяющихся партий (или почти повторяющихся, когда долго совпадают первые ходы, а потом уже партии различаются). Это еще одна гипотеза, которую я собираюсь проверить
Про тесты в SoFCheck

Чтобы тестировать движок, я использую разные стратегии тестирования. Во-первых, юнит-тесты с помощью фреймворка Google Test: пример. Таким образом тестируется не весь код, а только часть, в надежности которой я не уверен, и не могу нормально протестировать другими методами. Мне лень писать юнит-тесты на весь код :)

В реализации правил, например, юнит-тестов нет вообще. Но корректность правил тоже проверяется, просто куда более мощным методом под названием selftest. Эта штука запускает генерацию ходов и проверки на шахи на куче разных позиций. Затем selftest сравнивает результаты генерации с тем, что выдает на тех же позициях мой более старый движок, Dodecahedron. В нем абсолютно другая реализация правил, и шансы дважды допустить одну и ту же ошибку стремятся к нулю :) Попутно на всех этих позициях SoFCheck проверяет разные инварианты: что отмена хода работает корректно, что если загрузить и сохранить доску из FEN, то получится тот же результат, и т.д. Такой набор проверок позволяет быть уверенным в том, что правила с большой вероятностью написаны без багов. Еще selftest сделан так, что на нем можно тестировать не только SoFCheck, а любую реализацию правил на C++. Есть даже гайд про это

(Насчет Dodecahedron: он в свое время проверялся похожим методом на еще более старой реализации правил, поэтому ошибка будет незамеченной, только если она допущена трижды в одном и том же месте)

Как генерируются позиции для selftest'а? Генератор выглядит вот так. Если кратко, то там 100 случайных игр, несколько добавленных вручную партий и несколько добавленных вручную позиций (всякие крайние случаи)

Есть тесты в static_assert, которые гоняются прямо во время компиляции: ссылка

Есть интеграционные тесты на UCI, которые вводят команды в фиктивный движок, а потом проверяют, что вывод этого движка совпал с тем, что ожидалось

Наконец, есть smoke-тест. Движок запускается на наборе позиций. Тест пройден, если движок думает 4 секунды над позицией и при этом не падает. Для этого теста код компилируется с дополнительными проверками, которые убеждаются, что никакие инварианты не нарушены (а если нарушены — программа падает). Поскольку эти проверки замедляют код, то они используются только в тестах, а в обычной сборке отключены

Конечно же, все эти запускаются в CI на каждый коммит. Самые долгие — selftest (около 4-5 минут) и smoke test (около 40 секунд), остальное работает быстрее