defmodule Rsh26pool.Voting.HungarianTest do use ExUnit.Case, async: true alias Rsh26pool.Voting.Hungarian describe "min_cost_assignment/1" do test "empty matrix yields no cost and no assignment" do assert Hungarian.min_cost_assignment([]) == {0, []} end test "raises when there are more rows than columns" do assert_raise ArgumentError, fn -> Hungarian.min_cost_assignment([[1, 2], [3, 4], [5, 6]]) end end test "square matrix matches the brute-force optimum" do cost = [[4, 1, 3], [2, 0, 5], [3, 2, 2]] {total, assignment} = Hungarian.min_cost_assignment(cost) {brute_total, _} = brute_force(cost) assert total == brute_total assert valid_assignment?(assignment, 3, 3) assert picked_sum(cost, assignment) == total end test "rectangular matrix (rows < cols) matches the brute-force optimum" do cost = [[3, 1, 2, 4], [4, 2, 1, 0]] {total, assignment} = Hungarian.min_cost_assignment(cost) {brute_total, _} = brute_force(cost) assert total == brute_total assert valid_assignment?(assignment, 2, 4) assert picked_sum(cost, assignment) == total end test "matches the brute-force optimum across many random small matrices" do # Deterministic: fixed seed, and the failing matrix is printed for repro. :rand.seed(:exsss, {13, 57, 911}) for _ <- 1..250 do n = Enum.random(1..4) m = Enum.random(n..5) cost = for _ <- 1..n, do: for(_ <- 1..m, do: Enum.random(0..9)) {total, assignment} = Hungarian.min_cost_assignment(cost) {brute_total, _} = brute_force(cost) assert total == brute_total, "cost=#{inspect(cost)} gave total #{total}, brute force found #{brute_total}" assert valid_assignment?(assignment, n, m), "cost=#{inspect(cost)} gave invalid assignment #{inspect(assignment)}" assert picked_sum(cost, assignment) == total, "cost=#{inspect(cost)}: reported total #{total} != summed picks for #{inspect(assignment)}" end end end # An assignment must be an injective mapping row -> column: length n, every # value a distinct column in range. defp valid_assignment?(assignment, n, m) do length(assignment) == n and Enum.all?(assignment, &(&1 >= 0 and &1 < m)) and length(Enum.uniq(assignment)) == n end defp picked_sum(cost, assignment) do assignment |> Enum.with_index() |> Enum.reduce(0, fn {col, row}, acc -> acc + (cost |> Enum.at(row) |> Enum.at(col)) end) end # Exhaustive optimum over every injective row->column mapping. defp brute_force(cost) do n = length(cost) m = length(hd(cost)) cols = Enum.to_list(0..(m - 1)) cols |> injective_maps(n) |> Enum.map(fn assignment -> {picked_sum(cost, assignment), assignment} end) |> Enum.min_by(&elem(&1, 0)) end defp injective_maps(_cols, 0), do: [[]] defp injective_maps(cols, k) do for col <- cols, rest <- injective_maps(cols -- [col], k - 1), do: [col | rest] end end