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)
Example block output

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)
Example block output

(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)