Песочница для симуляций эволюции в браузере.
Приспособленность здесь нигде не записана формулой. Существа ищут еду, растут, охотятся друг на друга, стареют и оставляют потомство — а признаки популяции меняются сами, потому что больше потомков оставляет тот, кто съел больше. Скорость, размер, чутьё, сроки размножения и даже характер наследуются и подбираются отбором, и ни одно из равновесий не задано заранее.
TypeScript + Vite + Canvas, без единой зависимости во время работы. Около тысячи строк, из них девятьсот — сам мир.
npm installnpm run devОткроется http://localhost:5173. Пробел — пуск/пауза, S — один шаг, R — сброс.
| Элемент | Что означает |
|---|---|
| Зелёные точки | трава |
| Круги | существа: радиус — нынешний размер тела |
| Цвет круга | характер: зелёный тянется к траве, красный к добыче |
| Полупрозрачные | дети, ещё не доросшие до взрослого размера |
| Бледные кольца | радиус чутья |
| Графики справа | летопись: население и средние значения признаков |
На графиках пунктиром отмечено стартовое значение признака — сразу видно, вырос он относительно исходной единицы или упал. Шкала всегда от нуля до круглого числа, а не подогнана вплотную к данным: иначе признак, изменившийся на сотые, рисовался бы такой же бурной кривой, как изменившийся вдвое.
Панель справа снизу — 30 ползунков, которые крутятся прямо во время прогона.
Существа ищут еду, тратят энергию на жизнь, набравшие достаточно — делятся надвое. Приспособленность нигде не записана формулой: больше потомков оставляет тот, кто съел больше. Всё остальное — следствие.
Кроме травы есть охота: крупный может съесть того, кто мельче. Отдельных видов при этом нет — хищник это просто тот, кто больше. Мелкий, заметив угрозу, бросает кормёжку и убегает, и убежать реально: догоняет только тот, кто быстрее.
Поимка вероятностная: шанс растёт с превосходством в размере, промах возможен всегда и стоит сил. Если шанс ниже 5%, охота вообще не начинается — иначе существа непрерывно бросаются на почти равных и жгут энергию впустую.
Ещё существа стареют: опасность гибели растёт с возрастом и удваивается за 1200 тактов. Смерти «от старости» как события нет — есть накопленная ветхость, из-за которой любая случайность всё вероятнее.
Размер в генах — это размер взрослого. Рождается существо вдвое меньше и дорастает за свой счёт: тело строится постепенно, из съеденного. Пока не дорос — ты дешевле в содержании, но у тебя короче захват, ты не можешь размножаться, и съесть тебя может почти кто угодно.
Трава сеется не ровным ковром, а пятнами, которые медленно кочуют. Есть богатые места и пустые — а значит, есть разные образы жизни. Поставьте «Пятен травы» в ноль, чтобы вернуть однородный мир и сравнить.
Переключатель в разделе «Размножение». При половом нужен взрослый партнёр в пределах чутья, а гены потомка собираются из двух наборов одноточечным кроссинговером: до случайной точки признаки берутся у одного родителя, после — у другого. Так возникает сцепление — соседние гены чаще путешествуют вместе, чем расходятся.
Цену секса при этом видно прямо в панели: счётчик «роды сорвались без пары» за прогон набирает сотни тысяч. Партнёра надо ещё найти.
Раньше поведение было жёстким правилом, которое написал автор: сначала спасайся, потом охоться, потом ешь. Теперь правила нет. Существо каждый такт оценивает все доступные соблазны — трава, добыча, спасение, — умножает выгоду каждого на свой наследуемый вес и идёт туда, где вышло больше.
| Признак характера | Что значит |
|---|---|
| Азарт | во сколько раз добыча заманчивее травы |
| Страх | во сколько существо оценивает собственную шкуру |
Тяга к траве принята за единицу и не меняется. Это не лень, а необходимость: в сравнении соблазнов важны только их отношения, поэтому если сделать наследуемыми все три веса, они начнут свободно дрейфовать по общему масштабу и сползут в пол все разом, ничего не меняя в поведении. Нужна точка отсчёта.
Но саму формулу сравнения всё равно написал автор. Поэтому есть второй режим — переключатель «Чем думает». В режиме «нейросетью» формулы нет вовсе: куда повернуть, решает наследуемая сеть из 22 весов. На входе — три тройки «насколько близко и в какой стороне» (трава, угроза, добыча), собственная сытость и смещение; на выходе — вектор направления в системе отсчёта самого существа.
Скрытого слоя намеренно нет: с ним весов было бы под сотню, а популяция тут около сотни особей с редкими мутациями — такую сеть отбор не успеет нащупать. Линейный слой при этом далеко не пуст: каждый источник входит парой «синус-косинус», так что веса задают матрицу 2×2 — она умеет притягивать, отталкивать и заворачивать вбок, то есть кружить вокруг цели.
Сеть начинается со случайных чисел, никакого поведения в неё не заложено. Работает она не хуже формулы: население 111–193 против 74–236, и при этом даёт самый широкий размах размеров (0,49–2,23) и больше всего охоты — до 31% энергии.
Опасность спадает с расстоянием круче, чем выгода от еды (1/d² против 1/d):
хищник в двух шагах — это сейчас, а хищник на другом краю поля — почти ничего.
При одинаковом законе спадания страх дорастал до вечной паники, и популяция
вымирала от голода посреди поля, полного травы.
Три признака отвечают на вопрос «как добыть еду»:
| Признак | Что даёт | Чем платит |
|---|---|---|
| Скорость | быстрее добираешься до еды и убегаешь от погони | квадрат скорости |
| Размер | можешь охотиться, и тебя труднее съесть | содержание тела и, главное, перевозка собственной массы |
| Чутьё | раньше видишь и еду, и опасность | линейно |
Расход за такт складывается из пяти слагаемых, и каждое нужно:
BASE + размер³ + скорость² + размер³ × скорость² + чутьё
│ │ │ │ │
│ │ │ │ └ держать чувства открытыми
│ │ │ └ разгонять и тормозить массу
│ │ └ само движение: координация, трение
│ └ содержать тело
└ обмен веществ: платишь, даже если лежишь
Четвёртое слагаемое — то самое «крупный и быстрый разоряется». Но связка
не может быть единственной платой за скорость: при усыхании множитель размер³
обнулял бы и цену скорости, и та уезжала бы в потолок (проверено — так и было).
Поэтому рядом стоит независимое скорость², которое не исчезает ни при каком размере.
Деление тоже не бесплатно: потомку надо построить тело, и стоит оно как объём
(BUILD_COST × размер³). Не потянул — деления не будет, придётся ещё копить.
Раньше здесь был плоский процент, и это скрывало дыру: потомок наследовал размер
родителя, но за само тело никто не платил, только за содержание. Крупным быть
выходило дешевле, чем должно.
Мутации редкие: чаще всего потомок — точная копия, но с вероятностью 12% один из признаков заметно съезжает. Раньше при каждом рождении менялись все признаки понемногу — популяция была гладким облаком, плавно стекающим в оптимум, и эволюция превращалась в градиентный спуск.
До появления охоты у размера не было пользы, кроме ширины захвата, — а она почти ничего не даёт существу, которое прицельно идёт к ягоде. Размер падал в пол при любых коэффициентах, и приходилось подпирать его костылём: вероятность случайной гибели делилась на квадрат размера. С настоящей охотой костыль убран — размер держится сам.
Ещё два признака про другое — «что с добытым делать»:
| Признак | Что даёт | Чем платит |
|---|---|---|
| Порог деления | делишься поздно — потомки крепкие | делишься редко, можно не дожить |
| Доля потомку | отдал больше — потомок живучее | остался ни с чем сам |
Им не нужна выдуманная цена, размен возникает сам. Но порог деления работает только потому,
что в мире есть случайная гибель (ACCIDENT) — смерть не от голода, а просто так.
Без неё копить энергию было бы бесплатно и совершенно безопасно, тянуть с делением
всегда выгодно, и порог уезжал бы в потолок (проверено: за 300 000 тактов доходил
до 4,3 из максимума 5). Смертность, не зависящая от сытости, — это то, из-за чего
откладывать потомство становится ставкой.
Доля потомку интересна тем, что пока она ровно ½, это не размножение, а деление: две одинаковые половинки, родителя и потомка не различить. Как только она перестаёт быть ½, появляется асимметрия — один отдаёт, другой получает. Та самая граница, которую жизнь однажды перешла: желток в яйце, молоко, забота о потомстве.
Все начинают одинаковыми (по 1,0, доля потомку ½), разнообразие появляется само из мутаций при делении. Вот к чему приходит мир за 600 000 тактов:
| Признак | Куда пришёл |
|---|---|
| Размер | 1,3 – 1,5, отдельные особи до 2,2 |
| Скорость | 1,3 – 1,7 |
| Чутьё | 0,8 – 1,2 |
| Порог деления | 1,8 – 3,5 |
| Доля потомку | 0,45 – 0,7 |
| Азарт | 0,05 – 1,0 |
| Страх | сильно гуляет, от 1 до 15 |
Население 114–127 в трёх прогонах по 600–800 тысяч тактов. Энергии популяция берёт примерно 93% с травы и 7% с охоты.
Охота втрое увеличила размер тела — до неё он держался на 0,52. Заодно упала скорость: когда еды вдоволь, гнаться наперегонки незачем.
Любопытный побочный эффект платы за постройку тела: она подняла не только цену крупного потомка, но и порог деления — раз размножение стоит дорого, копить надо дольше. А раз копишь дольше, то к моменту деления энергии много, и крупное тело оказывается по карману. Признаки потянули друг друга.
Порог деления пришлось выпустить за прежний потолок 5 — он в него упирался. Правильный ход при упоре в границу: поднять границу и посмотреть, где признак остановится сам, а не принимать упор за равновесие.
Расслоения на два вида, впрочем, не вышло. Распределение размеров осталось одногорбым с длинным правым хвостом: не «хищники и жертвы», а единая популяция, где крупные едят мелких из своего же числа. В природе так и живут многие рыбы.
Доля потомку блуждает сильнее всех признаков и в среднем держится ниже ½ — родитель обычно оставляет себе больше половины. Похоже, отбор давит на неё слабо: вокруг ½ приспособленность почти не меняется, и решает уже дрейф. Получается, дрейф видно прямо внутри мира с отбором — на жёлтом графике.
Каждое из равновесий этого мира пришлось нащупывать прогонами, и почти всякий раз причина оказывалась не там, где казалась. Ниже — находки, которые стоили дороже всего и лучше всего объясняют, как устроена модель.
Самая поучительная ошибка. Поимка срабатывала от одного касания: мирно пасущийся взрослый съедал подвернувшегося младенца, вовсе не собираясь охотиться. Пока все рождались сразу взрослыми, это почти не было заметно. Стоило добавить детство — и популяция начала выедать собственный молодняк, вымирая за 20–50 тысяч тактов.
Теперь бросок засчитывается, только если добыча в пределах досягаемости и примерно по курсу: надо действительно повернуть к ней.
Побочный вывод, стоящий отдельного упоминания: до этой правки режим «нейросетью» выглядел успешнее формулы — население 109–118 против вымирания. Оказалось, сеть просто кормилась дырой: 39% её энергии шло от случайного поедания младенцев. Как только охота стала намеренной, эта версия сети умерла за тысячу тактов. Вывод «нейросеть работает лучше» был артефактом бага, и без проверки он бы так и остался в выводах.
Когда детство только появилось, мир начал вымирать, и первым подозреваемым были пятна травы: казалось, скученность у кормушек ведёт к взаимному истреблению. Разделение показало обратное — виноват был слишком медленный рост.
| Что менялось | 300 000 тактов, 2 прогона |
|---|---|
| Пятна выключены, детства нет | 104 и 118 |
| Пятна включены, детства нет | вымирание ×2 (при медленном росте) |
| Детство есть, рост 0,004 | вымирание ×2 |
| Детство есть, рост 0,02 | 135 и 89 |
| То же плюс 4 крупных пятна | 127 и 156 |
| То же плюс 20 мелких пятен | 108 и 153 |
При росте 0,004 путь до взрослости занимал 137 тактов при средней жизни втрое короче — большинство просто не доживало до размножения. С ускоренным ростом неоднородность перестала быть проблемой и заработала как задумано: при крупных пятнах размах взрослых размеров вырос до 0,32–2,50, а доля энергии от охоты — до 22–28%.
Самая дорогая ошибка за всё время настройки. Питательность добычи (meat) стояла
30 за единицу объёма, а постройка того же тела стоила 6. Разницу мир получал
из ниоткуда: охота работала вечным двигателем. Внешне это выглядело не как
изобилие, а как необъяснимое вымирание — популяция раскачивалась и гибла
на 50–500 тысячах тактов, и никакая подстройка травы не помогала.
| Питательность добычи | Что вышло за 600 000 тактов (2 прогона) |
|---|---|
| 0 | население 127 и 115 |
| 8 | 32 и вымирание |
| 15 | вымирание и 88 |
| 30 | вымирание в обоих |
| 30, но шанс поимки 20% | 113 и 136 |
Отсюда правило: meat имеет смысл сравнивать не с питательностью травы,
а со стоимостью постройки тела. По умолчанию ноль — победителю достаётся только
запас жертвы, а энергия, вложенная когда-то в её тело, считается рассеявшейся.
Охота при этом никуда не девается: за прогон случается 50–70 тысяч удачных
нападений, просто она перестала быть источником дармовой энергии.
Последняя строка таблицы показывает, что дело не в самой добыче, а в общем
потоке: при том же meat = 30, но вчетверо меньшем шансе поимки мир снова живёт.
Самое неочевидное в настройке: охота работает только при изобилии травы. Пришлось поднять прирост еды с 2,8 до 6 за такт. Вот что даёт перебор на 300 000 тактов:
| Еды за такт | Население | Размер | Съедено сородичей |
|---|---|---|---|
| 2,8 | 56 – 87 | 0,64 | 4 900 |
| 6 | 105 – 141 | ~1,0 | 18 400 |
| 10 | 68 – 173, шатает | 1,4 – 1,9 | 27 700 |
При скудной траве хищничество почти не заводится, а если его усилить насильно — население падает до 15–30 и мир балансирует на грани вымирания. Логика та же, что в настоящей экологии: хищников кормит не добыча, а трава под добычей.
Справа от поля — летопись: население и средние значения признаков. По ней отбор и виден. Она никогда не обрезает начало прогона: когда буфер заполняется, точки прореживаются вдвое и запись идёт вдвое реже, поэтому весь путь от нулевого такта виден целиком при любой длине прогона.
На поле цвет означает скорость (голубые медленные, оранжевые быстрые), радиус круга — размер, бледное кольцо — чутьё.
Всё устройство мира — 23 ползунка в панели справа: прирост и питательность травы, цена каждого слагаемого расхода, смертность, старение, сила мутаций, выгода и риск охоты, размеры мира. Меняются прямо во время прогона; те немногие, что перестраивают мир целиком, применяются по отпусканию ползунка. Кнопка внизу возвращает всё к исходному.
Что попробовать в первую очередь: прирост травы. От изобилия зависит и население,
и то, окупается ли охота, и во что выгодно вкладывать тело. И Цена собственной жизни — при нуле бегать перестанут вовсе.
Честная в механизме, произвольная в содержании.
Что настоящее. Приспособленности нигде нет — нет строчки fitness = f(гены),
есть энергия, голод и смерть, а кто оставит больше потомков, выясняется по ходу.
Мутации ненаправленные. Отбор зависит от плотности и состава популяции: выгодный
размер определяется тем, кто вокруг. Дрейф не подавлен и виден.
И один настоящий признак адекватности: модель воспроизвела предсказание, которого
в неё не закладывали. Из r/K-теории следует, что чем выше внешняя смертность, тем
раньше надо размножаться. В переборе по ACCIDENT вышло ровно это: 0,0003 → порог
~2,5; 0,0006 → 1,5–2,6; 0,0015 → ~1,3.
Что выдумано. Формула расхода и все её коэффициенты подобраны руками — и подобраны так, чтобы картинка получилась интересной, без упоров в границы. Значит, «скорость выросла, размер упал» — это не открытие про эволюцию, а прочтение вслух моих коэффициентов.
Чего структурно нет.
- Сеть без скрытого слоя. Линейный слой выразителен, но всё же не любое поведение. Со скрытым слоем весов было бы под сотню, и популяции в сотню особей их не нащупать — нужен мир на тысячи существ, а это упирается в скорость.
- Сцепление есть, но геном плоский. Кроссинговер одноточечный, порядок генов задан руками и не эволюционирует. Ни хромосом, ни диплоидности, ни доминирования.
- Пятна травы — единственная неоднородность. Нет ни времён года, ни рельефа, ни разных типов пищи.
- Расхождения на виды по-прежнему нет. Размах размеров вырос (0,3–2,5), но распределение остаётся одногорбым: единая популяция, где крупные едят мелких из своего же числа.
Короткий итог: как инструмент для интуиции («что отбор делает с разменом») работает. Как предсказание — нет, потому что предсказывает свойства формулы, которую мы сами и написали.
| Файл | Что делает |
|---|---|
| src/worlds/ | Миры. Всё содержательное живёт здесь. |
| src/worlds/index.ts | Что такое мир, список готовых и список запланированных. |
| src/worlds/selection.ts | Естественный отбор. |
| src/worlds/empty.ts | Пустой мир. Образец, с которого удобно списывать новый. |
| src/app.ts | Выпадающий список, кнопки, счётчик шагов, цикл времени, холст. Про модель не знает ничего. |
| src/styles.css | Внешний вид. |
Мир — это объект с четырьмя функциями, app.ts вызывает только их:
create() // каким мир рождается
step(state) // что происходит за один шаг времени
draw(ctx, state, …) // как это выглядит
stats(state) // какие числа показать в панели справаСкопировать empty.ts, поменять id, title и содержимое четырёх функций,
после чего дописать одну строку в src/worlds/index.ts:
export const WORLDS: World[] = [selectionWorld, emptyWorld, мойМир]Он сразу появится в выпадающем списке. Ни app.ts, ни разметку трогать не нужно.
Настройки мира приходится подбирать, а на глаз этого не сделать: надо знать, не вымирает ли популяция и не упирается ли признак в границу. Для этого удобно прогнать мир вхолостую, без браузера:
npx esbuild harness.ts --bundle --platform=node --format=esm --outfile=harness.mjs && node harness.mjsгде harness.ts — несколько строк: create(), потом step() в цикле, и печать
stats() каждые несколько тысяч тактов. Так за секунду проверяется то, что
в браузере пришлось бы разглядывать минуты.
В выпадающем списке под заголовком «Дальше» перечислено то, что собираемся написать.
Выбрать эти пункты пока нельзя — это план на виду. Как только мир написан,
он переезжает из PLANNED в WORLDS.