lib/examples/quadratic_assignment.ex

defmodule CPSolver.Examples.QAP do
  @doc """
  The Quadratic Assignment problem.
  Given:
  - a set of n facilities;
  - a set of n locations;
  - for each pair of locations, a distance between them;
  - for each pair of facilities, a weight of the edge (e.g., the amount of supplies transported) between them.

  Assign all facilities to different locations, such that
  the sum of products d(i,j ) * w(i, j)

    , where d(i,j) is a distance between locations i and j
      and w(i, j) is a weight of edge (i, j)

    is minimized.

  <a href="https://en.wikipedia.org/wiki/Quadratic_assignment_problem">Wikipedia</a>.

  """
  alias CPSolver.IntVariable, as: Variable
  alias CPSolver.Variable.Interface
  alias CPSolver.Model
  alias CPSolver.Constraint.AllDifferent.DC, as: AllDifferent
  alias CPSolver.Objective
  alias CPSolver.Constraint
  import CPSolver.Constraint.Factory

  import CPSolver.Variable.View.Factory

  alias CPSolver.Search.VariableSelector, as: Strategy

  require Logger

  @checkmark_symbol "\u2713"
  @failure_symbol "\u1D350"

  def run(instance, opts \\ []) do
    model = model(instance)

    opts =
      Keyword.merge(
        [
          search: search(model),
          solution_handler: solution_handler(model),
          space_threads: 8,
          timeout: :timer.seconds(30)
        ],
        opts
      )

    {:ok, _res} = CPSolver.solve(model, opts)
  end

  ## Read and compile data from instance file
  def model(data) when is_binary(data) do
    {_n, distances, weights} = parse_instance(data)
    model(distances, weights)
  end

  def model(distances, weights) do
    ## TODO: for now we are forced to use 0..n-1 for domains of assignment vars,
    ## as they will represent 0-based indices (i.e., x and y args) in element2d constraint.
    ## Not a big deal, but maybe think of supplying the index base for element2d as an option
    ##
    ## Note: we assume the distance and weight matrices are symmetrical,
    ## i.e., distances[i, j] = distances[j, i] and weights[i, j] == weights[j, i]
    ##

    n = length(distances)

    assignments =
      Enum.map(0..(n - 1), fn i -> Variable.new(0..(n - 1), name: "location_#{i}") end)

    ## Build "weighted distance" views and element2d constraints
    {weighted_distances, element2d_constraints} =
      for i <- 0..(n - 2), j <- (i + 1)..(n - 1), reduce: {[], []} do
        {weighted_distances_acc, constraints_acc} = acc ->
          weight = Enum.at(weights, i) |> Enum.at(j)

          if weight == 0 do
            acc
          else
            {z, element2d_constraint} =
              element2d(distances, Enum.at(assignments, i), Enum.at(assignments, j))

            w_distance = mul(z, weight)
            {[w_distance | weighted_distances_acc], [element2d_constraint | constraints_acc]}
          end
      end

    {total_cost, sum_constraint} = sum(weighted_distances)

    Model.new(
      assignments,
      [
        Constraint.new(AllDifferent, assignments),
        sum_constraint
      ] ++ element2d_constraints,
      objective: Objective.minimize(total_cost),
      extra: %{
        n: n,
        distances: distances,
        weights: weights,
        total_cost_var_id: Interface.id(total_cost)
      }
    )
  end

  def search(model) do
    _location_var_names =
      Enum.take(model.variables, model.extra.n)
      |> Enum.reduce(
        MapSet.new(),
        fn v, acc -> MapSet.put(acc, v.name) end
      )

    {
      Strategy.mixed([:most_constrained, :dom_deg, :first_fail]),
      # :dom_deg,
      :indomain_max
    }
  end

  def solution_handler(model) do
    fn solution ->
      solution
      |> Enum.at(model.extra.n)
      |> tap(fn {_ref, total} ->
        ans_str = inspect({"total", total})

        (check_solution(
           Enum.map(solution, fn {_, val} -> val end),
           model.extra.distances,
           model.extra.weights
         ) &&
           Logger.warning("#{@checkmark_symbol} #{ans_str}")) ||
          Logger.error("#{@failure_symbol} #{ans_str}" <> ": wrong -((")
      end)
    end
  end

  def check_solution(solution, distances, weights) do
    n = length(distances)
    assignments = Enum.take(solution, n)
    ## In the solution, total cost follows assignments
    total_cost = Enum.at(solution, n)

    sum =
      for i <- 0..(n - 2), j <- (i + 1)..(n - 1), reduce: 0 do
        acc ->
          assignment_i = Enum.at(assignments, i)
          assignment_j = Enum.at(assignments, j)

          d = distances |> Enum.at(assignment_i) |> Enum.at(assignment_j)
          w = weights |> Enum.at(i) |> Enum.at(j)
          acc + d * w
      end

    total_cost == sum
  end

  def parse_instance(filename) do
    filename
    |> File.read!()
    |> String.split("\n", trim: true)
    |> then(fn [n_str | lines] ->
      n = String.to_integer(String.trim(n_str))

      weights =
        lines
        |> Enum.take(n)
        |> parse_matrix()

      distances =
        lines
        |> Enum.take(-n)
        |> parse_matrix()

      {n, distances, weights}
    end)
  end

  defp parse_matrix(lines) do
    Enum.map(lines, fn line ->
      line
      |> String.replace("\t", " ")
      |> String.split(" ", trim: true)
      |> Enum.map(fn num_str -> String.to_integer(String.trim(num_str)) end)
    end)
  end
end