lib/examples/bin_packing/search.ex

defmodule CPSolver.Examples.BinPacking.Search do
  alias CPSolver.Variable.Interface
  alias CPSolver.Search.Partition

  @doc """
  Complete decreasing best fit branching,
  roughly as per https://www.gecode.dev/doc-latest/MPG.pdf, chapter 20
  """

  def cdbf(item_weights, item_assignment_vars, bin_load_vars, capacity) do
    ## Create a list [{item_assignment_index,  item_weight}]
    ## (will be used for matching the item assignment variables with items' weights)
    ##
    ## Note: item weights are sorted in decreasing order
    item_assignment_map =
      Enum.zip(item_assignment_vars, item_weights)
      |> Map.new(fn {var, weight} -> {var.name, weight} end)

    fn
      :init, _, _ ->
        :ok

      :branch, variables, _data ->
        case get_item_variables(variables, item_assignment_map) do
          nil ->
            nil

          item_variables ->
            selected_variable = List.first(item_variables)

            {bins, slack, no_fit_bins} =
              value_branching(selected_variable, bin_load_vars, item_assignment_map, capacity)
            partitions(
              item_variables,
              bins,
              slack,
              no_fit_bins,
              bin_load_vars,
              item_assignment_map,
              capacity
            )
        end
    end
  end

  ## Get the (unfixed) variables with the largest item weight.
  defp get_item_variables(variables, item_assignment_map) do
    ## We rely on:
    ## - item weights sorted in descending order
    ## - the item assignment variables are adjacent to each other within the variable list;
    ## that is, all of them are located in the single block.
    Enum.reduce_while(variables, {nil, nil}, fn v, {last_item_weight, item_vars_acc} = acc ->
      ## Is variable an 'item assignment' variable?
      item_weight = Map.get(item_assignment_map, v.name)

      cond do
        is_nil(item_vars_acc) ->
          (item_weight && {:cont, {item_weight, [v]}}) || {:cont, {nil, nil}}

        true ->
          if item_weight do
            ## Found another item assignment var
            ## The weight is different?
            ## We are only interested in the vars with the identical weights
            if last_item_weight == item_weight do
              {:cont, {item_weight, [v | item_vars_acc]}}
            else
              {:halt, acc}
            end
          else
            ## The end of item assignment variables' block
            ## (these variables have to be adjacent in the list of all variables)
            {:halt, acc}
          end
      end
    end)
    |> then(fn {_, res} -> if res, do: Enum.reverse(res) end)
  end

  defp value_branching(var, bin_load_vars, item_assignment_map, capacity) do
    ## The variable is quaranteed to be unfixed `item assignment`,
    ## (see `choose_variable_fun`)

    item_weight = get_item_weight(var, item_assignment_map)
    ## Find bins with minimal slack
    ##

    {_bins, _bin_slack, _no_fit_bins} =
      Enum.reduce_while(Enum.with_index(bin_load_vars, 1), {[], nil, []}, fn
        {load_var, bin_idx}, {min_bins, min_slack, no_fit_bins} = slack_acc ->
          cond do
            Interface.contains?(var, bin_idx) ->
              slack = slack(item_weight, capacity, load_var)

              cond do
                Interface.fixed?(load_var) ->
                  ## The bin load has already been fixed,
                  ## so the item has to be there (no choice).
                  ## TODO: this is the case where further branching doesn't make sense.
                  ## The related issue: https://github.com/bokner/fixpoint/issues/96
                  {:halt, {[bin_idx], nil, []}}

                slack == 0 ->
                  ## Perfect fit
                  {:halt, {[bin_idx], 0, []}}

                slack < 0 ->
                  ## No fit
                  {:cont, {min_bins, min_slack, [bin_idx | no_fit_bins]}}

                slack < min_slack ->
                  ## Better fit
                  {:cont, {[bin_idx], slack, no_fit_bins}}

                true ->
                  ## Keep current min, add bin to the list of current min bins
                  {:cont, {[bin_idx | min_bins], min_slack, no_fit_bins}}
              end

            true ->
              {:cont, slack_acc}
          end
      end)

  end

  defp partitions(
         [selected_variable | other_item_variables] = _item_variables,
         bins,
         slack,
         no_fit_bins,
         bin_load_vars,
         item_assignment_map,
         capacity
       ) do

    cond do
      Enum.empty?(bins) ->
        if Enum.empty?(no_fit_bins) do
          ## all item variables are fixed
          nil
        else
          ## no bin is available for placement
          throw(:fail)
        end

      is_nil(slack)  ->
        # The bin for the item has already been fixed.
        ## We only need a single partition with the fixed value.
        selected_bin = List.first(bins)
        [
          Partition.fixed_value_partition(selected_variable, selected_bin)
        ]

      slack == 0 || Enum.empty?(no_fit_bins) ->
        ## Perfect fit or all bins are fitting and have the same slack
        selected_bin = List.first(bins)
        [
          Partition.fixed_value_partition(selected_variable, selected_bin),
          Partition.removed_value_partition(selected_variable, selected_bin)
        ]

      true ->
        ## As suggested by Gecode docs for 2-alternative branching
        ## (https://www.gecode.dev/doc-latest/MPG.pdf, chapter 20):
        ###
        ### – Not only prune bin b from the potential bins for item i,
        ##    but also prune all bins with the same slack as b
        ##    from the potential bins for all items with the same size as i
        ##
        ##
        ##  Note: at this point, all item variables are assocated with the same item size as the first item variable.
        ##
        ##  For each item variable, we will iterate over bin load vars to
        ##  identify the ones with the same slack as computed for the first variable.
        ##
        selected_bin = List.first(bins)
        ## We will also prune the selected variable, if there are no-fit bins
        selected_variable_prun_partition =
          Enum.empty?(no_fit_bins) &&
            Partition.removed_value_partition(selected_variable, selected_bin) ||
            Partition.remove_multiple_values_partition(selected_variable, no_fit_bins)

        prune_partition =
          Enum.reduce(other_item_variables, selected_variable_prun_partition,
            fn variable, acc ->
              w = get_item_weight(variable, item_assignment_map)

              {_idx, acc} =
                Enum.reduce(bin_load_vars, {1, acc}, fn load_var, {bin, acc2} ->
                  {bin + 1,
                   cond do
                     !Interface.contains?(variable, bin) ->
                       acc2

                     Interface.fixed?(load_var) ->
                       Map.put(acc2, variable.id, fn variable ->
                         Interface.fix(variable, bin)
                       end)

                     slack(w, capacity, load_var) == slack ->
                       Map.put(acc2, variable.id, fn variable ->
                         Interface.remove(variable, bin)
                       end)

                     true ->
                       acc2
                   end}
                end)

              acc
            end
          )

        [
          Partition.fixed_value_partition(selected_variable, selected_bin),
          prune_partition
        ]
    end

  end

  defp slack(item_weight, capacity, load_variable) do
    capacity - Interface.min(load_variable) - item_weight
  end

  defp get_item_weight(variable, item_assignment_map) do
    Map.get(item_assignment_map, variable.name)
  end
end