← Все записи

Зачем я написал детерминированный симулятор экосистемы на Rust

World 2.0 — симуляция на Rust без единой зависимости, где растения, травоядные и хищники живут на торе, а животными управляет MLP с 59 входами, обученный имитацией и генетическим алгоритмом. Зачем это всё и что я понял по дороге.

5 мин чтения

Эта запись есть и на английском: Why I built a deterministic ecosystem simulator in Rust

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

Это ограничение определило почти всю архитектуру. Получился World 2.0: ядро симуляции на Rust без внешних крейтов, побитово детерминированная функция шага и браузерный вьюер, который рисует мир как вращающуюся сферу. Посмотреть можно в живом демо.

Сначала детерминизм

Каждый прогон — чистая функция от u64-сида. Один PRNG (SplitMix64), один порядок вызовов, никаких потоков внутри шага, никакого системного времени, никакой итерации по HashMap там, где порядок важен. Запусти --seed 7 сегодня и через год — получишь ту же кривую популяции до последнего тика.

Звучит как приятная мелочь. На самом деле это и есть смысл. Подбор баланса экосистемы — поиск в очень узком окне устойчивости; фраза «травоядные вымерли на тике 8412» полезна, только если я могу вернуться на тик 8411 с тем же состоянием. Детерминизм превращает каждый крах в воспроизводимый тест-кейс. И делает честным оптимизатор: два конфига, оценённые на одних сидах, сравниваются на одном мире, а не на везении.

Цена — дисциплина. Плавающая точка на одной машине детерминирована, но я всё равно избегаю всего, что зависит от порядка обхода сущностей: spatial hash grid отдаёт соседей в стабильном порядке, а каждая ничья в «выбери ближайшего» разрешается по id. Это не сложно, просто нельзя расслабляться.

Ноль зависимостей

В Cargo.toml нет секции [dependencies]. PRNG, пространственная сетка, читалка YAML-подобного конфига, WebSocket-сервер, который стримит кадры во вьюер, бинарный формат обученных мозгов, крошечная нейросеть с backprop — всё лежит в дереве.

Это не пуризм. Просто cargo build --release за 30 секунд с чистого чекаута, с lto = "thin" и codegen-units = 1, — то, что позволило мне сотни раз переписывать горячий цикл. И ещё: каждый крейт, который я бы подтянул «только ради сети» или «только ради рандома», приносит своё мнение о порядке float-операций, а мне хотелось владеть всеми этими мнениями самому.

Сама функция шага намеренно скучная: пройти по видам, ощутить, решить, сдвинуться, разрулить столкновения силой разделения, поесть, попить, размножиться, умереть, потом дать растениям отрасти из неиспользованного притока энергии. Энергия — бухгалтерия, которая держит мир честным: земля отдаёт фиксированный приток, растения его конвертируют, травоядные едят растения, хищники — травоядных, у каждого перехода КПД меньше единицы, и каждое животное платит базовый метаболизм за тик. Популяции не растут бесплатно.

Тор, который стал сферой

Топологически мир — двумерный тор: ушёл за правый край — вернулся слева. Это самый дешёвый способ получить мир без границ и без краевых эффектов у стен. Вьюер же натягивает тор на сферу в three.js. Это враньё — геометрия не совпадает, — но враньё убедительное, и смотреть на вращающуюся планету, по которой ходят стада и стаи, куда приятнее, чем на скроллящийся прямоугольник. Вьюер подключается к ядру по голому WebSocket и получает бинарные кадры; ядру всё равно, рисуют его или нет.

Два вида мозгов

Поведение каждого животного определяет один из двух контроллеров.

Первый — написанный руками, я зову его болванчик. Это utility-дерево решений: бегство важнее жажды, жажда важнее поиска пары, пара важнее еды, еда важнее блуждания. Навигация — Bug2: идти прямо на цель, а упёршись в препятствие, обходить его вдоль стены, пока снова нельзя идти прямо. Видит он только сенсорами: лучи на препятствия и угрозы, память о последней увиденной еде и воде, несколько «крошек» — где он уже был. Никакого вида сверху, никакого поиска пути по карте. Болванчик скучный, но он проходит все мои тестовые стенды (376 на момент написания) и держит восемь видов живыми вместе тысячи тиков.

Второй — нейросеть. Она намеренно крошечная: 59 входов (лучи сенсоров, расстояния и направления к еде, воде, угрозе, стаду, партнёру, one-hot вида и несколько подсказок из памяти), четыре рекуррентных регистра, приклеенных к входу, 96 скрытых tanh-нейронов и выходная голова, которая выдаёт не сырой вектор движения. Сеть выбирает одну из 17 стрелок — «к еде», «от угрозы», «к стаду», «текущий курс ± 45°» и так далее — как softmax-классификацию, плюс скаляры: скорость, есть, пить, спариваться, спать. Трюк со стрелками принципиален: когда правильный ход — «обойти камень слева ИЛИ справа», регрессия усредняет оба в «идти в камень», а argmax коммитит одну сторону.

Обучение: имитировать, потом эволюционировать

Градиентный спуск с нуля на этой задаче не работал, а генетический алгоритм с нуля был безнадёжно медленным. Сработала комбинация.

Этап 1, имитация. Прогнать болванчика по всем стендам и по множеству случайных вариаций каждого, записывая пары (сенсоры → действие) на каждом тике. Обучить MLP: cross-entropy на голове стрелок, MSE на скалярах. Потом, в духе DAgger, дать сети порулить, из этого состояния вернуть руль болванчику и записать восстановление — чтобы учитель размечал те ситуации, в которые ученик реально попадает, а не только свои идеальные траектории. Так получается стартовый мозг, который уже ведёт себя как вменяемое животное.

Этап 2, эволюция. Взять популяцию таких мозгов и запустить генетический алгоритм. Фитнес — доля решённых стендов с весом по трудности и бонусом за скорость. Лучшие мутируют и скрещиваются, остальные выбрасываются. Поскольку GA не нужен градиент, рекуррентные регистры эволюционируют без backprop через время — именно поэтому память здесь дешёвая. Фитнес меряется на отложенных вариациях стендов, разделённых по хэшу раскладки, так что я вижу «train 97% / val 98%» и понимаю, что мозг не заучил геометрию. Экзамен идёт на всех ядрах через thread::scope; поколение ускорилось с девяти минут до примерно одной.

Текущий мозг хищника решает 97% базовых стендов и 98% валидационных. Травоядные отстают, и честное положение дел такое: восемь видов, сосуществующих на нейро-контроллере, — всё ещё открытая задача. На болванчике они сосуществуют, сеть близко, но «близко» в экосистеме означает медленное вымирание.

Что я сказал бы себе в начале

  • Сделай мир детерминированным до того, как сделаешь его интересным. Всё остальное — оптимизатор, обучение, стенды — держится на возможности прокрутить заново.
  • Сначала напиши глупый контроллер и сделай его хорошим. Это твой оракул для тестов, твои обучающие данные и твой бейзлайн в каждом сравнении.
  • Направления классифицируй, а не регрессируй.
  • Тестируй на вариациях, а не на базовой раскладке. Первый GA, который я запустил, выучил карту.
  • Вьюер — не роскошь. Половину багов я нашёл, полминуты наблюдая, как одно животное делает глупость.

Исходники, логи обучения и набор стендов лежат в проекте; в презентации есть картинки, а в демо — планета. Буду писать сюда дальше, по мере того как травоядные учатся.