I had to give this a couple tries before I just broke down and went
for what amounts to brute force. This solution can get slow pretty
quickly; I'm sure there are some easy speedups (i.e. narrow the search
space) that can be done, but I figured to put this up for now.

# Helpers
class Integer
   def even?
      (self % 2).zero?
   end
end

class Symbol
   def <=> other
      self.to_s <=> other.to_s
   end
end

# Constraint Solver class
class Problem
   def initialize(&block)
      @domain = {}
      @consts = Hash.new { [] }
      instance_eval(&block)
   end

   def variable(var, domain)
      raise ArgumentError, "Cannot specify variable #{var} more than
once." if @domain.has_key?(var)
      @domain[var] = domain.to_a
   end

   def constrain(*vars, &foo)
      raise ArgumentError, 'Constraint requires at least one
variable.' if vars.size.zero?
      vars.each do |var|
         raise ArgumentError, "Unknown variable: #{var}" unless
@domain.has_key?(var)
      end
      @consts[vars] = @consts[vars] << foo
   end

   def solve
      # Separate constraint keys into unary and non-unary.
      unary, multi = @consts.keys.partition{ |vars| vars.size == 1 }

      # Process unary constraints first to narrow variable domains.
      unary.each do |vars|
         a = vars.first
         @consts[vars].each do |foo|
            @domain[a] = @domain[a].select { |d| foo.call(d) }
         end
      end

      # Build fully-expanded domain (i.e. across all variables).
      full = @domain.keys.map do |var|
         @domain[var].map do |val|
            { var => val }
         end
      end.inject do |m, n|
         m.map do |a|
            n.map do |b|
               a.merge(b)
            end
         end.flatten
      end

      # Process non-unary constraints on full domain.
      full.select do |d|
         multi.all? do |vars|
            @consts[vars].all? do |foo|
               foo.call( vars.map { |v| d[v] } )
            end
         end
      end
   end
end


# A simple example
problem = Problem.new do
   variable(:a, 0..10)
   variable(:b, 0..10)
   variable(:c, 0..10)

   constrain(:a) { |a| a.even? }
   constrain(:a, :b) { |a, b| b == 2 * a }
   constrain(:b, :c) { |b, c| c == b - 3 }
end

puts "Simple example solutions:"
problem.solve.each { |sol| p sol }

# Calculate some primes... The constraint problem actually finds
# the non-primes, which we remove from our range afterward to get
# the primes.
problem = Problem.new do
   variable(:a, 2..25)
   variable(:b, 2..25)
   variable(:c, 2..50)

   constrain(:a, :b) { |a, b| a <= b }
   constrain(:a, :b, :c) { |a, b, c| a * b == c }
end

puts "The primes up to 50:"
puts ((2..50).to_a - problem.solve.map { |s| s[:c] }).join(", ")
puts