lib/minitest/find_minimal_combination.rb



#!/usr/bin/ruby -w

##
# Finds the minimal combination of a collection of items that satisfy
# +test+.

class ComboFinder
  ##
  # Find the minimal combination of a collection of items that satisfy
  # +test+.
  #
  # If you think of the collection as a binary tree, this algorithm
  # does a breadth first search of the combinations that satisfy
  # +test+.
  #--
  #  level   collection
  #
  #  0          A
  #  1      B       C
  #  2    D   E   F   G
  #  3   1 2 3 4 5 6 7 8
  #
  # This assumes that A has already been tested and you're now trying
  # to reduce the match. Starting at level 1, test B & C separately.
  # If either test positive, reduce the search space accordingly. If
  # not, step down to level 2 and search w/ finer granularity (ie, DF,
  # DG, EF--DE and FG were already tested as B & C). Repeat until a
  # minimal combination is found.

  def find_minimal_combination ary
    level, n_combos = 1, 1
    seen = {}

    d "Total number of culprits: #{ary.size}"

    loop do
      size = 2 ** (Math.log(ary.size) / Math.log(2)).round
      divs = 2 ** level
      done = divs >= size
      divs = size if done

      subsections = ary.each_slice(size/divs).to_a.combination(n_combos)

      d
      d "# new round!"
      d "#   of subsections in this round: #{subsections.to_a.size}"
      d

      found = subsections.find { |a|
        b = a.flatten

        next if seen[b]

        d "#   trying #{b.size} at level #{level} / combo #{n_combos}"
        cache_result yield(b), b, seen
      }

      if found then
        ary = found.flatten
        break if done

        seen.delete ary

        d "#   FOUND!"
        d "#     search space size = #{ary.size}"
        d "#     resetting level and n_combos to 1"

        level = n_combos = 1
      else
        if done then
          n_combos += 1
          d "#   increasing n_combos to #{n_combos}"
          break if n_combos > size
        else
          level += 1
          n_combos = level
          d "#   setting level to #{level} and n_combos to #{n_combos}"
        end
      end
    end

    ary
  end

  def d s = "" # :nodoc:
    warn s if ENV["MTB_DEBUG"]
  end

  def cache_result result, data, cache # :nodoc:
    cache[data] = true

    return result if result

    unless result or data.size > 128 then
      max = data.size
      subdiv = 2
      until subdiv >= max do
        data.each_slice(max / subdiv) do |sub_data|
          cache[sub_data] = true
        end
        subdiv *= 2
      end
    end

    result
  end
end

class Array # :nodoc:
  ##
  # Find the minimal combination of a collection of items that satisfy +test+.

  def find_minimal_combination &test
    ComboFinder.new.find_minimal_combination(self, &test)
  end

  def find_minimal_combination_and_count
    count = 0

    found = self.find_minimal_combination do |ary|
      count += 1
      yield ary
    end

    return found, count
  end
end