Conway's Game of Life
This is a demonstration of the game of life as an agent-based model.
We start with importing some libraries.
using AlgebraicRewriting, Catlab
import Catlab.Graphics: to_graphviz
using Catlab.Graphics.Graphviz: Attributes, Statement, Node, Edge, Digraph
using PrettyTables, Luxor
The game of life has two rules: one which turns living things dead, and one that brings dead things to life. We model the terrain as a symmetric graph: cells are vertices. Neighboring cells have edges between them.
Implementation wise, if we are going to update cells one at a time, we must keep track of two bits of information (the cell's living status for the current timestep and whether it will be alive in the next timestep). Thus we need helper rule to overwrite the "current" life status with the "next" life status at the end of each timestep.
Ontology
Defining an ontology is stating what data is required to specify a state of the simulation at some point in time. In AlgebraicJulia, this is done via declaring a Presentation
, i.e. a database schema. Objects (Ob
, or tables) are types of entities. Homs (Hom
, or foreign keys) are functional relationships between the aforementioned entities. AttrTypes are placeholders for Julia types, which are assigned to Ob
via attributes (Attr
).
The schema below extends the schema for directed symmetric graphs, which consists in two tables (E
and V
, for edges and vertices) and two homs (src
and tgt
, E→V
). Furthermore a hom inv: E→E
enforces that each edge is paired with its opposite-pointing edge.
The schema below says there are two more types of entities, Curr
and Next
. Think of these as little tokens that can be assigned to vertices to mark them as currently-alive or to-be-alive-in-the-next-timestep, respectively. Thus, we can also thinking of them as picking out subsets of V
via the maps curr
and next
.
@present SchLife <: SchSymmetricGraph begin
(Curr, Next)::Ob
curr::Hom(Curr, V)
next::Hom(Next, V)
end
@acset_type Life(SchLife, part_type=BitSetParts) <: AbstractSymmetricGraph
to_graphviz(SchLife; prog="dot")
We can further extend this schema with an additional attribute of (x,y) coordinates for every vertex. This is nice for visualization but is otherwise unnecessary when doing the actual agent-based modeling. So what we will do is build our model with the Life schema and then run our model with the LifeCoords schema.
@present SchLifeCoords <: SchLife begin
Coords::AttrType
coords::Attr(V, Coords)
end
@acset_type AbsLifeCoords(SchLifeCoords, part_type=BitSetParts) <: AbstractSymmetricGraph
const LifeCoords = AbsLifeCoords{Tuple{Int,Int}};
Data migration functors
We ought to be able to take a state of the world (with no coordinate information) and obtain a state of the world with coordinates (the canonical way to do this is to assign "variables" for the values of the coordinates).
F = Migrate(SchLifeCoords, LifeCoords; delta=false); # adds coordinates
F⁻¹ = DeltaMigration(FinFunctor(idₒ, idₘ, SchLife, SchLifeCoords)); # removes coordinates
Helper functions
Functions to help us create a grid.
function make_grid(curr::AbstractMatrix, next=nothing)
n, m = size(curr)
n == m || error("Must be square")
X, coords = LifeCoords(), Dict()
for i in 1:n
for j in 1:n
coords[i=>j] = add_vertex!(X; coords=(i, j))
if Bool(curr[i, j])
add_part!(X, :Curr, curr=coords[i=>j])
end
if !isnothing(next) && Bool(next[i, j])
add_part!(X, :Curr, curr=coords[i=>j])
end
end
end
for i in 1:n
for j in 1:n
if i < n
add_edge!(X, coords[i=>j], coords[i+1=>j])
end
if j < n
add_edge!(X, coords[i=>j], coords[i=>j+1])
end
if i < n && j < n
add_edge!(X, coords[i=>j], coords[i+1=>j+1])
end
if i < n && j > 1
add_edge!(X, coords[i=>j], coords[i+1=>j-1])
end
end
end
X
end
make_grid(n::Int, random=false) = make_grid((random ? rand : zeros)(Bool, (n, n)));
Functions to help us visualize a grid. Although we have no such constraint, we'll expect any LifeCoords
instance to be a regular grid (for the purposes of visualization). When that's the case, we can visualize the game state using plaintext.
function view_life(f::ACSetTransformation, pth=tempname())
v = collect(f[:V])
view_life(codom(f), pth; star=isempty(v) ? nothing : only(v))
end
function view_life(X::LifeCoords, pth=tempname(); star=nothing)
n = Int(sqrt(nparts(X, :V)))
coords = Dict([(i, j) => findfirst(==((i, j)), X[:coords])
for (i, j) in Iterators.product(1:n, 1:n)])
mat = pretty_table(String, reduce(hcat, map(1:n) do i
map(1:n) do j
c, x = [!isempty(incident(X, coords[(i, j)], x)) for x in [:curr, :next]]
res = c ? (x ? "O" : "o") : (x ? "X" : "x")
return res * ((star == coords[(i, j)]) ? "." : "")
end
end); show_header=false, tf=tf_markdown)
open(pth, "w") do io
write(io, mat)
end
return mat
end
init = make_grid(3, true)
view_life(init) |> println
| o | x | o |
| o | x | o |
| x | x | x |
We can also visualize a grid with a distinguished agent. Here an agent living in a game state X
is a map A → X
where A
is the shape of the agent. The only kind of agent we'll consider in this model is that of a lone vertex.
Note that A
below is defined without coordinates, whereas init
is an instance of LifeCoords
. So in order to relate them via a mapping (which requires them to share a schema) we promote A
to LifeCoords
using the data migration, F
.
A = Life(1)
view_life(homomorphism(F(A), init; any=true)) |> println
| o. | x | o |
| o | x | o |
| x | x | x |
We must also work with miniature game states that are not grids in order for us to define the dynamics, as they are what the patterns and replacements of rewrite rules are made of. In order to visualize these, we will use another visualization function.
function view_life_graph(X::Union{Life,LifeCoords}, pth=tempname(); star=nothing)
pg = PropertyGraph{Any}(; prog="neato", graph=Dict(),
node=Dict(:shape => "circle", :style => "filled", :margin => "0"),
edge=Dict(:dir => "none", :minlen => "1"))
add_vertices!(pg, nparts(X, :V))
for v in vertices(X)
set_vprop!(pg, v, :fillcolor, isempty(incident(X, v, :curr)) ? "red" : "green")
isempty(incident(X, v, :next)) || set_vprop!(pg, v, :penwidth, "4.0")
set_vprop!(pg, v, :label, star == v ? "*" : "")
end
for e in filter(e -> X[e, :inv] > e, edges(X))
add_edge!(pg, X[e, :src], X[e, :tgt])
end
G = to_graphviz(pg)
open(pth, "w") do io
show(io, "image/svg+xml", G)
end
G
end;
view_life_graph(init)
Now we make some helper functions to construct important ACSets and maps between them. We start with a single vertex which is marked as to-be-alive in the next time step.
Next() = @acset Life begin V = 1; Next = 1; next = 1 end;
view_life_graph(Next())
We also want to refer to a vertex which is alive in the current time step
Curr() = @acset Life begin V = 1; Curr = 1; curr = 1 end;
view_life_graph(Curr())
We also want these where we have a morphism incoming from a vertex.
to_next() = homomorphism(Life(1), Next());
to_curr() = homomorphism(Life(1), Curr());
We make a helper for cells connected to n
living neighbors
function living_neighbors(n::Int; alive=false)
X = Life(1)
alive && add_part!(X, :Curr, curr=1)
for _ in 1:n
v = add_part!(X, :V)
add_part!(X, :Curr, curr=v)
add_edge!(X, v, 1)
end
X
end
view_life_graph(living_neighbors(3))
We can control whether the central cell is itself alive or not
view_life_graph(living_neighbors(3; alive=true))
Rules
We have finished specifying what makes up a simulation state, and next is to define what sorts of transitions are possible. This is done by declaring rewrite rules.
A dead cell becomes alive iff exactly 3 living neighbors
BirthP1 = living_neighbors(3) # must have 3 neighbors
BirthN1 = living_neighbors(4) # forbid the cell to have 4 neighbors
BirthN2 = Curr() # forbid the cell to be alive (i.e. it's currently dead)
BP1, BN1, BN2 = homomorphism.(Ref(Life(1)), [BirthP1, BirthN1, BirthN2]; initial=(V=[1],))
bac = [PAC(BP1; monic=true), NAC.([BN1, BN2]; monic=true)...]
Birth = Rule(id(Life(1)), to_next(); ac=bac);
A living cell stays alive iff 2 or 3 living neighbors
PersistR = @acset Life begin
V = 1; Curr = 1; Next = 1; curr = 1; next = 1
end
PersistP1 = living_neighbors(2; alive=true)
PersistN1 = living_neighbors(4; alive=true)
DR, DP1, DN1 = homomorphism.(Ref(Curr()), [PersistR, PersistP1, PersistN1]; initial=(V=[1],))
pac = [PAC(DP1; monic=true), NAC(DN1; monic=true)]
Persist = Rule(id(Curr()), DR; ac=pac);
remove "Curr" status
ClearCurr = Rule(to_curr(), id(Life(1)));
remove "Next" status
ClearNext = Rule(to_next(), id(Life(1)));
Copy "Next" to "Curr"
CopyNext = Rule(to_next(), to_curr());
Assembling rules into a recipe
Now we can assemble our building blocks into a large wiring diagram characterizing the flow of the overall ABM simulation. In addition to the blue rewrite rule blocks, we have yellow Query
blocks which execute subroutines once per agent (the second output wire) before exiting (the first output wire).
Give symbolic names to the rewrite rules from before
rules = [:Birth => Birth, :Persist => Persist, :ClearCurr => ClearCurr,
:ClearNext => ClearNext, :CopyNext => CopyNext];
All rules have interface of a single distinguished cell, i.e. they are executed from the perspective an agent which is a particular distinguished vertex.
Normally we can consider branching possibilities depending on whether or not the rewrite is successful, but in this simulation we don't do this. tryrule
simply merges the two output wires from a rewrite rule box into a single output wire.
rBirth, rPersist, rClearCurr, rClearNext, rCopyNext =
[tryrule(RuleApp(n, r, Life(1))) for (n, r) in rules]
view_sched(rBirth)
The first for
loop is computing next
for all cells
update_next = agent(rBirth ⋅ rPersist, Life(1); n=:Cell)
view_sched(update_next)
The second for
loop is overwriting curr
with next
for all cells
next_step = agent(compose(rClearCurr, rCopyNext, rClearNext), Life(1); n=:Cell)
view_sched(next_step)
We then compose these together and wrap in an overall for
loop with a counter.
life(n::Int) = for_schedule(update_next ⋅ next_step, n) |> F
const L1 = life(1) # Game of life simulation that runs just one (global) timestep
view_sched(L1)
Running the simulation
Make an initial state
G = make_grid([1 0 1 0 1; 0 1 0 1 0; 0 1 0 1 0; 1 0 1 0 1; 1 0 1 0 1])
view_life_graph(G)
(or, viewed in plaintext)
view_life(G) |> println
| o | x | x | o | o |
| x | o | o | x | x |
| o | x | x | o | o |
| x | o | o | x | x |
| o | x | x | o | o |
Run the simulation
res = interpret(L1, G; maxstep=1000);
Look at the end state
res[end][1] |> codom |> view_life |> println
| x | o | o | o | x |
| o | o | o | x | x |
| o | x | x | o | x |
| o | o | o | x | x |
| x | o | o | o | x |
Visualize the results in the traj
folder
view_traj(L1, res[1:10], view_life; agent=true)