Sunday, October 3, 2010

Life in N Dimensions

   Sometimes we all feel as though life has gotten a bit out of hand.  It's certainly been that way for me lately.  As you may have noticed, I haven't updated this blog in quite some time, despite the way I was obviously trying to do it weekly.  Was this just yet another case of someone up and walking away from their blog?  No, not me!  Really!  I promise!  (Well, at least this time it wasn't....)

   So what's been keeping me so busy?  My life has been very busy in several dimensions.  I'll spare you most of it, but one was Ruby DCamp, an "unconference" centered around Ruby.  The first day featured a "code retreat".  Not an official one with Corey Haines himself present, but at least an event of that style.  Apparently, the canonical problem to tackle in these is Conway's Game of Life.

   Since each round is only 45 minutes, I didn't manage to get all the way through a successful implementation.  And since it's so simple, I won't bother you with the one I did later.

   BUT...

   Evan Light, the organizer of Ruby DCamp, sort of threw down a gauntlet about extending Life from two dimensions to... unlimited.  So, of course I then spent much of my spare time figuring that out.

   The really hard part was to figure out how to display the state of the world. Eventually I settled on a sort of recursive concept:

  • An individual cell is just output as-is.
  • A row of cells (which I represented internally as a String) is output as-is, again.
  • A two-dimensional world was output as a grid, as usual.
  • For any higher dimensions, though:
    • If it's odd, it's output as a row of blocks from the next dimension down, with a border between each and around the whole set.
    • If it's even, it's output as a block of rows from the next dimension down, with a border between each and around the whole set.

   Yeah, I know, that sounds kinda complex.  But take a look at it in action, and you'll see what I mean.  For instance, a three-dimensional world would look like this:

|* *| * |  *|
|***| **|*  |
|** | * |*  |
a four-dimensional world would look like this:

+---+---+---+
|* *| * |  *|
|***| **|*  |
|** | * |*  |
+---+---+---+
|* *| **|  *|
| **| **|* *|
|** |  *|*  |
+---+---+---+
|* *| * |  *|
|* *| **|***|
|** |** |* *|
+---+---+---+
a five-dimensional world would look like this:

|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| * |  *|||* *| * |  *|||* *| * |  *||
||***| **|*  |||***| **|*  |||***| **|*  ||
||** | * |*  |||** | * |*  |||** | * |*  ||
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| **|  *|||* *| **|  *|||* *| **|  *||
|| **| **|* *||| **| **|* *||| **| **|* *||
||** |  *|*  |||** |  *|*  |||** |  *|*  ||
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| * |  *|||* *| * |  *|||* *| * |  *||
||* *| **|***|||* *| **|***|||* *| **|***||
||** |** |* *|||** |** |* *|||** |** |* *||
|+---+---+---+|+---+---+---+|+---+---+---+|
a six-dimensional world would look like this:

+-------------+-------------+-------------+
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| * |  *|||* *| * |  *|||* *| * |  *||
||***| **|*  |||***| **|*  |||***| **|*  ||
||** | * |*  |||** | * |*  |||** | * |*  ||
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| **|  *|||* *| **|  *|||* *| **|  *||
|| **| **|* *||| **| **|* *||| **| **|* *||
||** |  *|*  |||** |  *|*  |||** |  *|*  ||
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| * |  *|||* *| * |  *|||* *| * |  *||
||* *| **|***|||* *| **|***|||* *| **|***||
||** |** |* *|||** |** |* *|||** |** |* *||
|+---+---+---+|+---+---+---+|+---+---+---+|
+-------------+-------------+-------------+
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| * |  *|||* *| * |  *|||* *| * |  *||
||***| **|*  |||***| **|*  |||***| **|*  ||
||** | * |*  |||** | * |*  |||** | * |*  ||
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| **|  *|||* *| **|  *|||* *| **|  *||
|| **| **|* *||| **| **|* *||| **| **|* *||
||** |  *|*  |||** |  *|*  |||** |  *|*  ||
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| * |  *|||* *| * |  *|||* *| * |  *||
||* *| **|***|||* *| **|***|||* *| **|***||
||** |** |* *|||** |** |* *|||** |** |* *||
|+---+---+---+|+---+---+---+|+---+---+---+|
+-------------+-------------+-------------+
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| * |  *|||* *| * |  *|||* *| * |  *||
||***| **|*  |||***| **|*  |||***| **|*  ||
||** | * |*  |||** | * |*  |||** | * |*  ||
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| **|  *|||* *| **|  *|||* *| **|  *||
|| **| **|* *||| **| **|* *||| **| **|* *||
||** |  *|*  |||** |  *|*  |||** |  *|*  ||
|+---+---+---+|+---+---+---+|+---+---+---+|
||* *| * |  *|||* *| * |  *|||* *| * |  *||
||* *| **|***|||* *| **|***|||* *| **|***||
||** |** |* *|||** |** |* *|||** |** |* *||
|+---+---+---+|+---+---+---+|+---+---+---+|
+-------------+-------------+-------------+
and so on.

   And now, what you've all been waiting for... the code:

#!/usr/bin/ruby


# first some general utility stuff


# combine several blocks of text, as columns.
# might be some more elegant way to do this with recursive
# calls to Array#zip or something like that....
def combine_columns blocks, separator = '|', border = true
  exploded = blocks.map { |blk| blk.split "\n" }
  num_rows = exploded[0].length
  result = []
  num_rows.times do |row_num|
    row = exploded.map { |blk| blk[row_num] }
    if border then
      row.push ''
      row.unshift ''
    end
    result << row.join(separator)
  end
  result.join "\n"
end


# combine several rows of text, with separators made of a given char, using an
# alternate char wherever another certain char appears in the top or bottom of
# the row, assuming that they all start (and end) with the same thing as the
# start of the first.
def combine_rows rows, norm_char = '-', except = '|', response = '+', border = true
  first_line = rows[0]
  len = first_line.index("\n")
  sep = norm_char * len
  start = 0
  while not (start.nil? or start >= len)
    start = first_line.index except, start
    if not start.nil?
      sep[start] = response
      start += 1
    end
  end
  tmp = rows.join("\n#{sep}\n")
  tmp = "#{sep}\n#{tmp}\n#{sep}" if border
end


# now the stuff specifically for this app:


class World


  # apply the basic rules of life and death
  # TODO MAYBE: Speed this up.  This is where the profiler says it spends
  # the second most time out of all the parts I wrote.
  def apply_rule cur, nbrs
    if cur == '*' then
      (nbrs >= @min_stay  and nbrs <= @max_stay ) ? '*' : ' '
    else
      (nbrs >= @min_birth and nbrs <= @max_birth) ? '*' : ' '
    end
  end


  # public only for external testing purposes
  def count_neighbors coords
    count_neighbors_helper [], coords, coords, @cells
  end


  def initialize opts
    @cells = opts[:cells]
    @num_dims = get_num_dims_helper @cells
    # Note, these formulas are rather ad-hoc.  Just my SWAG at how to adjust
    # for varying number of dimensions, and give the "standard" results in 2d.
    base = 2 ** @num_dims
    @min_stay  = opts.fetch :min_stay,  base / 2
    @max_stay  = opts.fetch :max_stay,  base     - 1
    @min_birth = opts.fetch :min_birth, base / 2 + 1
    @max_birth = opts.fetch :max_birth, base     - 1
    @max_stay = @min_stay if @max_stay < @min_stay
    @max_birth = @min_birth if @max_birth < @min_birth
  end


  # convert current state to a string, for display purposes
  def to_s
    to_s_helper @cells, @num_dims
  end


  # let the world turn....
  def turn
    @cells = turn_helper @cells, []
  end


private


  # see what's in a particular subset of cells neighboring a given center
  # TODO MAYBE: Speed this up.  This is where the profiler says it spends
  # the most time out of all the parts I wrote.
  def count_neighbors_helper coords_done, coords_todo, center, cells
    todo_dup = coords_todo.dup
    count = 0
    next_coord = todo_dup.shift
    ((next_coord - 1) .. (next_coord + 1)).each do |idx|
      next if idx < 0 or idx >= cells.length
      done_dup = coords_done.dup
      done_dup << idx
      if cells.class == Array
        count += count_neighbors_helper done_dup, todo_dup, center, cells[idx]
      elsif done_dup != center and cells[idx..idx] == '*'
        count += 1
      end
    end
    count
  end


  # figure out how many dimensions are in this world.
  # (need this in order to set default rule parameters.)
  def get_num_dims_helper cells
    if cells.class == Array: get_num_dims_helper(cells[0]) + 1
    elsif cells.class == String: 1
    else
      puts "ERROR: cells must be of type [Array of ...] String"
      puts "(i.e., String, Array of String, Array of Array of String, etc.)"
      # TODO: throw an exception?
      exit 1
    end
  end


  # turn a chunk of the world into a string,
  # by recursing if it's too big to do at once (3+ dimensions).
  # 1d is a simple string (should only happen in a total 1d world),
  # 2d is a block of strings printed atop each other,
  # higher odd dimensions are rows of blocks,
  # higher even dimensions are blocks of rows.
  def to_s_helper cells, num_dims
    if num_dims == 1: cells
    elsif num_dims == 2: cells.join("\n")
    elsif num_dims % 2 == 0 then
      rows = cells.map { |row| to_s_helper(row, num_dims - 1) }
      combine_rows rows
    else
      blocks = cells.map { |blk| to_s_helper blk, num_dims-1 }
      combine_columns blocks
    end
  end


  # turn the world a piece at a time, drilling down into subsections as needed,
  # accumulating results as we go
  def turn_helper cells, coords
    results = []
    cells.length.times do |idx|
      coords_dup = coords.dup
      coords_dup << idx
      if cells.class == Array: results << turn_helper(cells[idx], coords_dup)
      else
        results << apply_rule(cells[idx..idx], count_neighbors(coords_dup))
      end
    end
    results = results.join if cells.class == String
    results
  end


end

   But wait!  There's more!  You get a special bonus: the tests used to develop this program.  Sorry they're not complete, but as you can probably guess, the ones I've left blank would be horribly tedious to fill in.

#! /usr/bin/ruby

require 'nd_life'
require 'profile'

describe 'nd_life' do

  it 'should apply altered rules correctly' do
    w = World.new :cells => '',
                  :min_stay  => 4,
                  :max_stay  => 6,
                  :min_birth => 2,
                  :max_birth => 5
    w.apply_rule('*', 3).should == ' '
    w.apply_rule('*', 4).should == '*'
    w.apply_rule('*', 6).should == '*'
    w.apply_rule('*', 7).should == ' '
    w.apply_rule(' ', 1).should == ' '
    w.apply_rule(' ', 2).should == '*'
    w.apply_rule(' ', 5).should == '*'
    w.apply_rule(' ', 6).should == ' '
  end

  it 'should apply default 1d rules correctly' do
    w = World.new :cells => ''
    w.apply_rule('*', 0).should == ' '
    w.apply_rule('*', 1).should == '*'
    w.apply_rule('*', 2).should == ' '
    w.apply_rule(' ', 1).should == ' '
    w.apply_rule(' ', 2).should == '*'
    w.apply_rule(' ', 3).should == ' '
  end

  it 'should apply default 2d rules correctly' do
    w = World.new :cells => ['']
    w.apply_rule('*', 1).should == ' '
    w.apply_rule('*', 2).should == '*'
    w.apply_rule('*', 3).should == '*'
    w.apply_rule('*', 4).should == ' '
    w.apply_rule(' ', 2).should == ' '
    w.apply_rule(' ', 3).should == '*'
    w.apply_rule(' ', 4).should == ' '
  end

  it 'should apply default 3d rules correctly' do
    w = World.new :cells => [['']]
    w.apply_rule('*', 3).should == ' '
    w.apply_rule('*', 4).should == '*'
    w.apply_rule('*', 7).should == '*'
    w.apply_rule('*', 8).should == ' '
    w.apply_rule(' ', 4).should == ' '
    w.apply_rule(' ', 5).should == '*'
    w.apply_rule(' ', 7).should == '*'
    w.apply_rule(' ', 8).should == ' '
  end

  it 'should apply default 4d rules correctly' do
    w = World.new :cells => [[['']]]
    w.apply_rule('*',  7).should == ' '
    w.apply_rule('*',  8).should == '*'
    w.apply_rule('*', 15).should == '*'
    w.apply_rule('*', 16).should == ' '
    w.apply_rule(' ',  8).should == ' '
    w.apply_rule(' ',  9).should == '*'
    w.apply_rule(' ', 15).should == '*'
    w.apply_rule(' ', 16).should == ' '
  end

  it 'should count 1d neighbors correctly' do
    w = World.new :cells => '* ** '
    w.count_neighbors([0]).should == 0
    w.count_neighbors([1]).should == 2
    w.count_neighbors([2]).should == 1
    w.count_neighbors([3]).should == 1
    w.count_neighbors([4]).should == 1
  end

  it 'should count 2d neighbors correctly' do
    w = World.new :cells => ['*  ',
                             ' * ',
                             ' **']
    w.count_neighbors([0,0]).should == 1
    w.count_neighbors([0,1]).should == 2
    w.count_neighbors([0,2]).should == 1
    w.count_neighbors([1,0]).should == 3
    w.count_neighbors([1,1]).should == 3
    w.count_neighbors([1,2]).should == 3
    w.count_neighbors([2,0]).should == 2
    w.count_neighbors([2,1]).should == 2
    w.count_neighbors([2,2]).should == 2
  end

  it 'should count 3d neighbors correctly' do
    c = [['* ',
          '  '],
         [' *',
          '**'],
         ['* ',
          ' *']]
    w = World.new :cells => c
    w.count_neighbors([0,0,0]).should == 3
    w.count_neighbors([0,0,1]).should == 4
    w.count_neighbors([0,1,0]).should == 4
    w.count_neighbors([0,1,1]).should == 4
    w.count_neighbors([1,0,0]).should == 6
    w.count_neighbors([1,0,1]).should == 5
    w.count_neighbors([1,1,0]).should == 5
    w.count_neighbors([1,1,1]).should == 5
    w.count_neighbors([2,0,0]).should == 4
    w.count_neighbors([2,0,1]).should == 5
    w.count_neighbors([2,1,0]).should == 5
    w.count_neighbors([2,1,1]).should == 4
  end

  it 'should count 4d neighbors correctly'

  it 'should generate the next setup correctly in 1d' do
    w = World.new :cells => '* ** '
    c_str = w.to_s
    neighbor_counts = {}
    c_str.length.times { |idx| neighbor_counts[idx] = w.count_neighbors [idx] }
    w.turn
    c2_str = w.to_s
    c_str.length.times do |idx|
      old = c_str[idx..idx]
      new = c2_str[idx..idx]
      nbrs = neighbor_counts[idx]
      new.should == w.apply_rule(old, nbrs)
    end
  end

  it 'should generate the next setup correctly in 2d' do
    c = ['*  ',
         ' * ',
         ' **']
    w = World.new :cells => c
    c_str = w.to_s
    neighbor_counts = {}
    c.length.times do |row_idx|
      c[row_idx].length.times do |col_idx|
        neighbor_counts["#{row_idx}_#{col_idx}"] = w.count_neighbors [row_idx, col_idx]
      end
    end
    w.turn
    c2_str = w.to_s
    c.length.times do |row_idx|
      cur_row = c[row_idx]
      cur_row.length.times do |col_idx|
        idx = row_idx * (cur_row.length + 1) + col_idx
        old = c_str[idx..idx]
        new = c2_str[idx..idx]
        nbrs = neighbor_counts["#{row_idx}_#{col_idx}"]
        new.should == w.apply_rule(old, nbrs)
      end
    end
  end

  it 'should generate the next setup correctly in 3d'

  it 'should generate the next setup correctly in 4d'

  it 'should store a 1d setup correctly' do
    c = '*  '
    w = World.new :cells => c
    w.to_s.should == c
  end

  it 'should store a 2d setup correctly' do
    c = ['*  ', ' * ', ' **']
    w = World.new :cells => c
    w.to_s.should == c.join("\n")
  end

  it 'should store a 3d setup correctly' do
    c = [['*  ',
          '*  ',
          ' * '],
         [' **',
          ' * ',
          '* *'],
         ['** ',
          '** ',
          ' **']]
    w = World.new :cells => c
    w.to_s.should == "|*  | **|** |\n"\
                     "|*  | * |** |\n"\
                     "| * |* *| **|"
  end

  it 'should store a 4d setup correctly' do
    c = [[['*  ', '*  ', ' * '],
          [' **', ' * ', '* *'],
          ['** ', '** ', ' **']],
         [['*  ', '*  ', ' * '],
          [' **', ' * ', '* *'],
          ['** ', '** ', ' **']],
         [['*  ', '*  ', ' * '],
          [' **', ' * ', '* *'],
          ['** ', '** ', ' **']]]
    w = World.new :cells => c
    w.to_s.should == "+---+---+---+\n"\
                     "|*  | **|** |\n"\
                     "|*  | * |** |\n"\
                     "| * |* *| **|\n"\
                     "+---+---+---+\n"\
                     "|*  | **|** |\n"\
                     "|*  | * |** |\n"\
                     "| * |* *| **|\n"\
                     "+---+---+---+\n"\
                     "|*  | **|** |\n"\
                     "|*  | * |** |\n"\
                     "| * |* *| **|\n"\
                     "+---+---+---+"
  end
                     
  it 'should store a 5d setup correctly'

  it 'should store a 6d setup correctly'

end

    If you're especially observant (no I don't mean Orthodox!), you may have noticed that my previous tests were done with Test::Unit, and these are RSpec.  Yes, I finally learned RSpec, at Ruby DCamp.  Also note the profiler; that helped me spot the bottlenecks.  (You may think you know where the bottlenecks in your code are, but chances are, you're wrong.  That's just one of the things humans generally aren't very good at.  Let the machine do the work and tell you where it hurts.)

   And!  If you order by midnight tonight, you get a special second bonus: a test driver, so you can watch this thing run, in six whopping dimensions!  Just look:

#!/usr/bin/ruby

require 'nd_life'

# six dimensions
cells = [[[[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]],
          [[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]],
          [[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]]],
         [[[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]],
          [[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]],
          [[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]]],
         [[[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]],
          [[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]],
          [[['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']],
           [['   ','   ','   '],['   ','   ','   '],['   ','   ','   ']]]]]


def randomize cells
  if cells.class == Array then
    cells.map { |part| randomize part }
  else
    cells.length.times { |i| cells[i..i] = rand(2) == 1 ? '*' : ' ' }
  end
end

randomize cells
w = World.new :cells => cells

gen_num = 0

prev_worlds = []
stable = false

rep = w.to_s
while not stable do
  gen_num += 1
  puts "Generation #{gen_num}:"
  puts rep
  prev_worlds << rep
  w.turn
  rep = w.to_s
  idx = prev_worlds.rindex rep  # use rindex since later most likely to match
  if not idx.nil?
    stable = true
    puts "Next state is same as at Generation #{idx + 1}"
  end
end

   Now how much would you pay?  (crickets . . . crickets . . .)  Well that's okay, because it's not $999, not $99, not even $9, but free, though as always I would appreciate you donating the time to tell me how I'm Doin' It Wrong.

   Seriously.  This thing is pretty slow, at about seven seconds per generation, on my machine.  (On the other claw, it is pretty ancient, a Powerbook, and yes I mean Powerbook not Macbook.)  Let's hear some ideas for speeding it up.  The profiler says the main timesink is the counting of neighbors, specifically count_neighbors_helper.  Any better, or at least other, ideas?  Why would you do it your way?

   As for other assorted improvements, I've already thought of, but decided not to do quite yet:

  • Telling it only how many dimensions it has, and how big it is, letting it assume that all dimensions are the same size, and generate random contents.
  • Telling it only how many dimensions it has, and how big each is, letting it generate random contents.
  • Having it detect when stability has been reached.  (Actually I had that in earlier, just detecting when a new generation was the same as the old, but since so many forms of stability are cyclic, I took it out, in favor of moving that functionality to the driver.)

4 comments:

  1. What, no comments? I know from the stats that Google gives me, that about forty people have viewed this.

    There's a fairly obvious speedup I chose to leave out. (Actually, I was going to do it, but kinda forgot before posting it. It also would interfere with some of the other extension plans I had, so even before forgetting, I dithered a bit....)

    Hint: it's not a big overall macro algorithmic speedup, just a tiny little thing... that gets done a gazillion times.

    ReplyDelete
  2. Anybody? Bueller?

    I'm referring to the conversion between chars and booleans. I created another version that does c->b only once (creating the initial world, using Arrays of true/false values instead of Strings), and b-> only for to_s. Generating the next, uh, generation, without such conversions, takes about 20% less time.

    The new version, with additional Rubification tweaks (like using .each_index instead of .length.times), better var names, and lots more comments (more enterprisey-like), is on github at http://github.com/davearonson/N-Dimensional-Life/. I'd appreciate commentary.

    ReplyDelete
  3. Thanks for the GitHub link. If I'm speaking for anyone but myself (or the 40+ others), I'm glad to look at the code and see what we can add. Cheers on the post and great job cataloguing the spirit of the 'code retreat'. I dont know how may times in the past few weeks that I've found myself wanting to return to the woods with a bunch of hackers to work on whatever problem I'm currently dealing with... I think it would be a great way to organize a company. Hang out in the woods, eat burgers and hot dogs all the time, and breathe code.

    I can't wait to go back next year. And for those 'impurists' wanting to have it at a hotel -- frankly, F@CK y'all. We should have it in some remote jungle somewhere... it's in the spirit of the camp, of course. Cheers, all. ;-)

    ReplyDelete
  4. Thanks for your comments.

    BTW, I accidentally sent the wrong version to Github. Sorry, I'm new to both git and Github. I've corrected it just now (and after doing some more work on it as well).

    As for hotel versus park, the main thing is to keep Evan well-supplied with goats to sacrifice to the weather gods. He's done GREAT with that so far, but you know how persnickety gods can be. ;-)

    ReplyDelete

Note: Only a member of this blog may post a comment.