Open Source Adventures: Episode 72: Crystal Z3 Solver for Dominosa Puzzle

Dominosa is a puzzle with simple rules:

  • there's a grid filled with numbers
  • you need to pair those numbers into dominos
  • each domino needs to be different

As usual, I recommend playing the puzzle a few times to get a good feel for it. It will make it easier to follow along.

I'll be using updated Crystal Z3 shard, as explained in the previous episode.

Plaintext input

Here's an example input:

77525629349
49071866039
82294832830
98723812246
50186000647
61556354899
56113420383
04114737281
06552340497
79171576958

Solver

#!/usr/bin/env crystal

require "z3"

class DominosaSolver
  @board : Array(Array(Int32))
  @xsize : Int32

  def initialize(path)
    @board = File.read_lines(path).map(&.chars.map(&.to_i))
    @ysize = @board.size
    @xsize = @board[0].size
    @solver = Z3::Solver.new
    @horizontal = {} of Tuple(Int32, Int32) => Z3::BoolExpr
    @vertical = {} of Tuple(Int32, Int32) => Z3::BoolExpr
    @dominos = {} of Tuple(Int32, Int32) => Array(Z3::BoolExpr)
  end

  def each_coord
    @xsize.times do |x|
      @ysize.times do |y|
        yield(x,y)
      end
    end
  end

  def connections(x,y)
    [
      @horizontal[{x,y}]?,
      @horizontal[{x-1,y}]?,
      @vertical[{x,y}]?,
      @vertical[{x,y-1}]?,
    ].compact
  end

  def add_domino(x1, y1, x2, y2, var)
    v1 = @board[y1][x1]
    v2 = @board[y2][x2]
    v1, v2 = [v1, v2].sort
    @dominos[{v1,v2}] ||= [] of Z3::BoolExpr
    @dominos[{v1,v2}].push var
  end

  def call
    # Setup horizontal variables
    each_coord do |x,y|
      next if x == @xsize - 1
      @horizontal[{x,y}] = var = Z3.bool("h #{x},#{y}")
      add_domino x, y, x+1, y, var
    end

    # Setup vertical variables
    each_coord do |x,y|
      next if y == @ysize - 1
      @vertical[{x,y}] = var = Z3.bool("v #{x},#{y}")
      add_domino x, y, x, y+1, var
    end

    # Each number belongs to exactly one domino
    each_coord do |x,y|
      @solver.assert 1 == Z3.add(connections(x,y).map{|v| v.ite(1,0)})
    end

    # Every type of domino has exactly one instance
    @dominos.each do |type, vars|
      @solver.assert 1 == Z3.add(vars.map{|v| v.ite(1,0)})
    end

    if @solver.satisfiable?
      model = @solver.model
      @ysize.times do |y|
        @xsize.times do |x|
          print @board[y][x]
          next if x == @xsize - 1
          print model[@horizontal[{x,y}]].to_b ? "*" : " "
        end
        print "\n"

        next if y == @ysize - 1

        @xsize.times do |x|
          print model[@vertical[{x,y}]].to_b ? "*" : " "
          next if x == @xsize - 1
          print " "
        end
        print "\n"
      end
    else
      puts "No solution"
    end
  end
end

DominosaSolver.new(ARGV[0]).call

We're definitely taking advantage of Z3.add and of properly typed Model#[] here.

The nontrivial parts of the solver are add_domino method, and printing. With Z3 solvers it's fairly common that input and output are more complex than solver logic.

Arguably all the next if x == @xsize - 1 blocks could be replaced by some properly named helpers, hopefully it's not too complicated.

Result

$ ./dominosa 1.txt
7*7 5*2 5 6*2 9*3 4*9
        *
4 9 0 7 1 8 6*6 0 3 9
* * * *   *     * * *
8 2 2 9 4 8 3*2 8 3 0
        *
9 8*7 2 3 8*1 2*2 4 6
*     *           * *
5 0*1 8 6*0 0*0 6 4 7
                *
6*1 5*5 6*3 5*4 8 9*9

5 6 1 1*3 4*2 0*3 8*3
* * *
0 4 1 1*4 7*3 7*2 8 1
                  * *
0 6*5 5 2 3 4 0*4 9 7
*     * * * *
7 9*1 7 1 5 7 6*9 5*8

Story so far

All the code is on GitHub.

Coming next

In the next episode I'll do one more puzzle solver, then it will be time to move on to different projects.