lib/examples/stable_marriage.ex

defmodule CPSolver.Examples.StableMarriage do
  @doc """
    Stable marriage problem.
    https://en.wikipedia.org/wiki/Stable_marriage_problem.

  """
  alias CPSolver.IntVariable, as: Variable
  alias CPSolver.Model
  alias CPSolver.Constraint
  alias CPSolver.Constraint.{ElementVar, Less}
  alias CPSolver.Constraint.Factory, as: ConstraintFactory
  alias CPSolver.Constraint.AllDifferent.DC, as: AllDifferent

  def instances() do
    %{
      van_hentenryck: %{
        rankWomen: [
          [1, 2, 4, 3, 5],
          [3, 5, 1, 2, 4],
          [5, 4, 2, 1, 3],
          [1, 3, 5, 4, 2],
          [4, 2, 3, 5, 1]
        ],
        rankMen: [
          [5, 1, 2, 4, 3],
          [4, 1, 3, 2, 5],
          [5, 3, 2, 4, 1],
          [1, 5, 4, 3, 2],
          [4, 3, 2, 1, 5]
        ]
      },
      # http://mathworld.wolfram.com/StableMarriageProblem.html
      mathworld: %{
        rankWomen: [
          [3, 1, 5, 2, 8, 7, 6, 9, 4],
          [9, 4, 8, 1, 7, 6, 3, 2, 5],
          [3, 1, 8, 9, 5, 4, 2, 6, 7],
          [8, 7, 5, 3, 2, 6, 4, 9, 1],
          [6, 9, 2, 5, 1, 4, 7, 3, 8],
          [2, 4, 5, 1, 6, 8, 3, 9, 7],
          [9, 3, 8, 2, 7, 5, 4, 6, 1],
          [6, 3, 2, 1, 8, 4, 5, 9, 7],
          [8, 2, 6, 4, 9, 1, 3, 7, 5]
        ],
        rankMen: [
          [7, 3, 8, 9, 6, 4, 2, 1, 5],
          [5, 4, 8, 3, 1, 2, 6, 7, 9],
          [4, 8, 3, 9, 7, 5, 6, 1, 2],
          [9, 7, 4, 2, 5, 8, 3, 1, 6],
          [2, 6, 4, 9, 8, 7, 5, 1, 3],
          [2, 7, 8, 6, 5, 3, 4, 1, 9],
          [1, 6, 2, 3, 8, 5, 4, 9, 7],
          [5, 6, 9, 1, 2, 8, 4, 3, 7],
          [6, 1, 4, 7, 5, 8, 3, 9, 2]
        ]
      },
      problem3: %{
        rankWomen: [
          [1, 2, 3, 4],
          [4, 3, 2, 1],
          [1, 2, 3, 4],
          [3, 4, 1, 2]
        ],
        rankMen: [
          [1, 2, 3, 4],
          [2, 1, 3, 4],
          [1, 4, 3, 2],
          [4, 3, 1, 2]
        ]
      },
      problem4: %{
        rankWomen: [
          [1, 5, 4, 6, 2, 3],
          [4, 1, 5, 2, 6, 3],
          [6, 4, 2, 1, 5, 3],
          [1, 5, 2, 4, 3, 6],
          [4, 2, 1, 5, 6, 3],
          [2, 6, 3, 5, 1, 4]
        ],
        rankMen: [
          [1, 4, 2, 5, 6, 3],
          [3, 4, 6, 1, 5, 2],
          [1, 6, 4, 2, 3, 5],
          [6, 5, 3, 4, 2, 1],
          [3, 1, 2, 4, 5, 6],
          [2, 3, 1, 6, 5, 4]
        ]
      }
    }
  end

  def solve(instance, opts \\ []) do
    {:ok, res} = CPSolver.solve(model(instance), opts)

    res.solutions
    |> Enum.each(fn solution ->
      Enum.zip(res.variables, solution)
      |> print(
        instance_dimension(
          instances()
          |> Map.get(instance)
        )
      )
    end)

    {:ok, res}
  end

  def model(instance) do
    data = Map.get(instances(), instance)
    dim = instance_dimension(data)
    range = 0..(dim - 1)
    wife = Enum.map(range, fn i -> Variable.new(range, name: "wife#{i + 1}") end)
    husband = Enum.map(range, fn i -> Variable.new(range, name: "husband#{i + 1}") end)
    ## Bijection (1-to-1) husband <-> wife
    bijections =
      for h <- range do
        # husband[wife[m]] = m
        ElementVar.new(husband, Enum.at(wife, h), h)
      end ++
        for w <- range do
          # [wife[husband[m]] = m
          ElementVar.new(wife, Enum.at(husband, w), w)
        end

    pref_constraints =
      for w <- range, h <- range, reduce: [] do
        constraints_acc ->
          rankMen_h = Map.get(data, :rankMen) |> Enum.at(h)
          rankMen_h_w = rankMen_h |> Enum.at(w)

          {rankMen_h_w_var, elementRankMen} =
            ConstraintFactory.element(rankMen_h, Enum.at(wife, h))

          rankWomen_w = Map.get(data, :rankWomen) |> Enum.at(w)
          rankWomen_w_h = rankWomen_w |> Enum.at(h)

          {rankWomen_w_h_var, elementRankWomen} =
            ConstraintFactory.element(rankWomen_w, Enum.at(husband, w))

          impl_submodel =
            ConstraintFactory.impl(
              Less.new([rankMen_h_w, rankMen_h_w_var]),
              Less.new([rankWomen_w_h_var, rankWomen_w_h])
            )

          constraints_acc ++
            impl_submodel.constraints ++
            [elementRankMen, elementRankWomen]
      end
      |> List.flatten()

    Model.new(
      wife ++ husband,
      bijections ++ pref_constraints ++ [Constraint.new(AllDifferent, husband), Constraint.new(AllDifferent, wife)]
    )
  end

  defp instance_dimension(data) do
    Map.get(data, :rankWomen) |> length
  end

  def print(solution, n) do
    IO.puts("\n")

    solution
    |> Enum.take(n)
    |> Enum.with_index(0)
    |> Enum.each(fn {{_wife_name, h}, w} ->
      IO.puts("\u2640:#{w + 1} #{IO.ANSI.red()}\u26ad#{IO.ANSI.reset()} #{h + 1}:\u2642")
    end)
  end

  @doc """
    Pseudocode for checking stability
    (https://stackoverflow.com/questions/58439880/algorithm-to-verify-stable-matching)

      for w in women:
          for m in [men w would prefer over current_partner(w)]:
              if m prefers w to current_partner(m) return false

      return true
  """
  def check_solution(solution, instance) do
    data = instances()[instance]
    women_prefs = Map.get(data, :rankWomen)
    men_prefs = Map.get(data, :rankMen)
    n = instance_dimension(data)
    {women_assignments, men_assignments} = Enum.take(solution, 2 * n) |> Enum.split(n)

    men_lookup =
      Enum.with_index(men_assignments, 0) |> Map.new(fn {partner, idx} -> {idx, partner} end)

    women_assignments
    |> Enum.with_index(0)
    |> Enum.take(1)
    |> Enum.all?(fn {current_partner, w} ->
      w_prefs = Enum.at(women_prefs, w) |> Enum.map(fn p -> p - 1 end)
      current_partner_rank = Enum.find_index(w_prefs, fn p -> p == current_partner end)
      ## Walk over candidates with higher ranks.
      ## If any of candidates prefers w over his current partner, stability doesn't hold
      Enum.take(w_prefs, current_partner_rank)
      |> Enum.all?(fn candidate ->
        candidate_prefs = Enum.at(men_prefs, candidate) |> Enum.map(fn p -> p - 1 end)
        candidate_current_partner = Map.get(men_lookup, candidate)

        candidate_current_partner_rank =
          Enum.find_index(candidate_prefs, fn p -> p == candidate_current_partner end)

        candidate_w_rank = Enum.find_index(candidate_prefs, fn p -> p == w end)
        ## Candidate prefers current partner to w
        candidate_w_rank < candidate_current_partner_rank
      end)
    end)
  end
end