oneshot
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
defmodule Rsh26pool.Voting.Hungarian do
|
||||
@moduledoc """
|
||||
Solves the assignment problem — a minimum-cost perfect matching in a weighted
|
||||
bipartite graph — with the Hungarian (Kuhn–Munkres) algorithm in O(n^3).
|
||||
|
||||
This is the algorithm the proposal calls for when placing members into groups
|
||||
in grouping mode: given a cost of assigning each member to each group slot, it
|
||||
finds the assignment with the lowest total cost.
|
||||
|
||||
`min_cost_assignment/1` takes an `n x m` cost matrix (a list of `n` rows, each
|
||||
a list of `m` numbers) with `n <= m`, and returns `{total_cost, assignment}`
|
||||
where `assignment` is a list of length `n` and `Enum.at(assignment, i)` is the
|
||||
0-based column matched to row `i`.
|
||||
"""
|
||||
|
||||
@inf 1_000_000_000
|
||||
|
||||
@spec min_cost_assignment([[number()]]) :: {number(), [non_neg_integer()]}
|
||||
def min_cost_assignment([]), do: {0, []}
|
||||
|
||||
def min_cost_assignment(cost) when is_list(cost) do
|
||||
n = length(cost)
|
||||
m = length(hd(cost))
|
||||
|
||||
if n > m do
|
||||
raise ArgumentError, "cost matrix must have rows <= cols (got #{n} x #{m})"
|
||||
end
|
||||
|
||||
# 1-indexed access into an immutable tuple-of-tuples.
|
||||
c = cost |> Enum.map(&List.to_tuple/1) |> List.to_tuple()
|
||||
cost_at = fn i, j -> c |> elem(i - 1) |> elem(j - 1) end
|
||||
|
||||
u = fill(n + 1, 0)
|
||||
v = fill(m + 1, 0)
|
||||
p = fill(m + 1, 0)
|
||||
way = fill(m + 1, 0)
|
||||
|
||||
{_u, _v, p, _way} =
|
||||
Enum.reduce(1..n, {u, v, p, way}, fn i, {u, v, p, way} ->
|
||||
phase(i, m, cost_at, u, v, p, way)
|
||||
end)
|
||||
|
||||
# p[j] holds the row matched to column j; invert to get column-per-row.
|
||||
row_to_col =
|
||||
Enum.reduce(1..m, %{}, fn j, acc ->
|
||||
row = elem(p, j)
|
||||
if row >= 1 and row <= n, do: Map.put(acc, row, j - 1), else: acc
|
||||
end)
|
||||
|
||||
assignment = Enum.map(1..n, &Map.fetch!(row_to_col, &1))
|
||||
|
||||
total =
|
||||
assignment
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.reduce(0, fn {col0, i}, sum -> sum + cost_at.(i, col0 + 1) end)
|
||||
|
||||
{total, assignment}
|
||||
end
|
||||
|
||||
# Augmenting phase for row `i` (see the classic e-maxx Hungarian description).
|
||||
defp phase(i, m, cost_at, u, v, p, way) do
|
||||
p = put_elem(p, 0, i)
|
||||
minv = fill(m + 1, @inf)
|
||||
used = fill(m + 1, false)
|
||||
walk(0, m, cost_at, u, v, p, way, minv, used)
|
||||
end
|
||||
|
||||
defp walk(j0, m, cost_at, u, v, p, way, minv, used) do
|
||||
used = put_elem(used, j0, true)
|
||||
i0 = elem(p, j0)
|
||||
ui0 = elem(u, i0)
|
||||
|
||||
{delta, j1, minv, way} =
|
||||
Enum.reduce(1..m, {@inf, -1, minv, way}, fn j, {delta, j1, minv, way} ->
|
||||
if elem(used, j) do
|
||||
{delta, j1, minv, way}
|
||||
else
|
||||
cur = cost_at.(i0, j) - ui0 - elem(v, j)
|
||||
|
||||
{minv, way} =
|
||||
if cur < elem(minv, j),
|
||||
do: {put_elem(minv, j, cur), put_elem(way, j, j0)},
|
||||
else: {minv, way}
|
||||
|
||||
mvj = elem(minv, j)
|
||||
if mvj < delta, do: {mvj, j, minv, way}, else: {delta, j1, minv, way}
|
||||
end
|
||||
end)
|
||||
|
||||
{u, v, minv} =
|
||||
Enum.reduce(0..m, {u, v, minv}, fn j, {u, v, minv} ->
|
||||
if elem(used, j) do
|
||||
pj = elem(p, j)
|
||||
{put_elem(u, pj, elem(u, pj) + delta), put_elem(v, j, elem(v, j) - delta), minv}
|
||||
else
|
||||
{u, v, put_elem(minv, j, elem(minv, j) - delta)}
|
||||
end
|
||||
end)
|
||||
|
||||
if elem(p, j1) == 0 do
|
||||
{u, v, reconstruct(j1, p, way), way}
|
||||
else
|
||||
walk(j1, m, cost_at, u, v, p, way, minv, used)
|
||||
end
|
||||
end
|
||||
|
||||
defp reconstruct(j0, p, way) do
|
||||
j1 = elem(way, j0)
|
||||
p = put_elem(p, j0, elem(p, j1))
|
||||
if j1 == 0, do: p, else: reconstruct(j1, p, way)
|
||||
end
|
||||
|
||||
defp fill(size, value), do: value |> List.duplicate(size) |> List.to_tuple()
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
defmodule Rsh26pool.Voting.Idea do
|
||||
@moduledoc """
|
||||
A voting option. In normal mode it is an idea posted by a participant; in
|
||||
grouping mode it is the participant's opinion (one per participant).
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Rsh26pool.Voting.{Participant, Room, Vote}
|
||||
|
||||
schema "ideas" do
|
||||
field :text, :string
|
||||
|
||||
belongs_to :room, Room
|
||||
belongs_to :participant, Participant
|
||||
has_many :votes, Vote
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(idea, attrs) do
|
||||
idea
|
||||
|> cast(attrs, [:text])
|
||||
|> validate_required([:text], message: "内容を入力してください")
|
||||
|> validate_length(:text, max: 200, message: "200文字以内で入力してください")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
defmodule Rsh26pool.Voting.Participant do
|
||||
@moduledoc """
|
||||
A user taking part in a room. Identified across requests by a random `token`
|
||||
stored in the session cookie. Owners have `role == "owner"`.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Rsh26pool.Voting.Room
|
||||
|
||||
schema "participants" do
|
||||
field :token, :string
|
||||
field :name, :string
|
||||
field :role, :string, default: "member"
|
||||
|
||||
# Grouping-mode results, filled in when voting closes.
|
||||
field :is_leader, :boolean, default: false
|
||||
|
||||
belongs_to :room, Room
|
||||
belongs_to :assigned_leader, __MODULE__, foreign_key: :assigned_leader_id
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc "Changeset for the user-supplied fields (their display name)."
|
||||
def changeset(participant, attrs) do
|
||||
participant
|
||||
|> cast(attrs, [:name])
|
||||
|> validate_required([:name], message: "名前を入力してください")
|
||||
|> validate_length(:name, max: 40, message: "名前は40文字以内で入力してください")
|
||||
|> unique_constraint(:token)
|
||||
end
|
||||
|
||||
@doc "Owner? convenience predicate."
|
||||
def owner?(%__MODULE__{role: "owner"}), do: true
|
||||
def owner?(%__MODULE__{}), do: false
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
defmodule Rsh26pool.Voting.Room do
|
||||
@moduledoc """
|
||||
A voting room. Identified publicly by a short `room_number` used in URLs.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Rsh26pool.Voting.{Idea, Participant}
|
||||
|
||||
schema "rooms" do
|
||||
field :room_number, :string
|
||||
field :name, :string
|
||||
field :votes_per_user, :integer, default: 3
|
||||
field :status, :string, default: "open"
|
||||
field :grouping_mode, :boolean, default: false
|
||||
field :leader_threshold, :integer, default: 2
|
||||
field :closes_at, :utc_datetime
|
||||
field :grouped_at, :utc_datetime
|
||||
|
||||
# Virtual field backing the `datetime-local` input (wall-clock JST).
|
||||
field :closes_at_local, :string, virtual: true
|
||||
|
||||
has_many :participants, Participant
|
||||
has_many :ideas, Idea
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc "Changeset used when the owner creates a new room."
|
||||
def create_changeset(room, attrs) do
|
||||
room
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:votes_per_user,
|
||||
:grouping_mode,
|
||||
:leader_threshold,
|
||||
:closes_at_local
|
||||
])
|
||||
|> validate_required([:name, :votes_per_user], message: "入力してください")
|
||||
|> validate_length(:name, max: 80, message: "80文字以内で入力してください")
|
||||
|> validate_number(:votes_per_user,
|
||||
greater_than_or_equal_to: 1,
|
||||
less_than_or_equal_to: 50,
|
||||
message: "1〜50の数値を入力してください"
|
||||
)
|
||||
|> validate_number(:leader_threshold,
|
||||
greater_than_or_equal_to: 1,
|
||||
less_than_or_equal_to: 50,
|
||||
message: "1〜50の数値を入力してください"
|
||||
)
|
||||
|> put_closes_at()
|
||||
end
|
||||
|
||||
@doc "Changeset for owner-editable settings on the manage page."
|
||||
def settings_changeset(room, attrs) do
|
||||
room
|
||||
|> cast(attrs, [:name])
|
||||
|> validate_required([:name], message: "入力してください")
|
||||
|> validate_length(:name, max: 80, message: "80文字以内で入力してください")
|
||||
end
|
||||
|
||||
defp put_closes_at(changeset) do
|
||||
case get_field(changeset, :closes_at_local) do
|
||||
blank when blank in [nil, ""] ->
|
||||
put_change(changeset, :closes_at, nil)
|
||||
|
||||
value ->
|
||||
case Rsh26pool.JST.parse_local(value) do
|
||||
{:ok, dt} ->
|
||||
if DateTime.compare(dt, DateTime.utc_now()) == :gt do
|
||||
put_change(changeset, :closes_at, dt)
|
||||
else
|
||||
add_error(changeset, :closes_at_local, "未来の日時を指定してください")
|
||||
end
|
||||
|
||||
:error ->
|
||||
add_error(changeset, :closes_at_local, "日時の形式が正しくありません")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
defmodule Rsh26pool.Voting.Vote do
|
||||
@moduledoc """
|
||||
A single vote cast by a participant for an idea. A participant may vote for a
|
||||
given idea at most once (enforced by a unique index).
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Rsh26pool.Voting.{Idea, Participant}
|
||||
|
||||
schema "votes" do
|
||||
belongs_to :idea, Idea
|
||||
belongs_to :participant, Participant
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(vote, attrs) do
|
||||
vote
|
||||
|> cast(attrs, [:idea_id, :participant_id])
|
||||
|> validate_required([:idea_id, :participant_id])
|
||||
|> unique_constraint([:idea_id, :participant_id],
|
||||
name: :votes_idea_id_participant_id_index,
|
||||
message: "は既に投票済みです"
|
||||
)
|
||||
|> foreign_key_constraint(:idea_id)
|
||||
|> foreign_key_constraint(:participant_id)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user