lib/solver/core/solver.ex

defmodule CPSolver do
  @moduledoc """
  Solver API.
  """

  alias CPSolver.Space
  use GenServer

  require Logger

  @doc """

  """
  @spec solve(Model.t(), Keyword.t()) :: any()
  def solve(model, opts \\ []) do
    {:ok, _solver} = GenServer.start_link(CPSolver, [model, opts])
  end

  def statistics(solver) when is_pid(solver) do
    GenServer.call(solver, :get_stats)
  end

  def solutions(solver) when is_pid(solver) do
    GenServer.call(solver, :get_solutions)
  end

  ## GenServer callbacks

  @impl true
  def init([%{constraints: constraints, variables: variables} = _model, solver_opts]) do
    propagators =
      Enum.reduce(constraints, [], fn constraint, acc ->
        acc ++ constraint_to_propagators(constraint)
      end)

    stop_on = Keyword.get(solver_opts, :stop_on)

    {:ok,
     %{
       space: nil,
       variables: variables,
       propagators: propagators,
       solution_count: 0,
       failure_count: 0,
       node_count: 1,
       solutions: [],
       active_nodes: MapSet.new(),
       stop_on: stop_on,
       solver_opts: solver_opts
     }, {:continue, :solve}}
  end

  defp constraint_to_propagators(constraint) do
    [constraint_mod | args] = Tuple.to_list(constraint)
    constraint_mod.propagators(args)
  end

  @impl true
  def handle_continue(
        :solve,
        %{variables: variables, propagators: propagators, solver_opts: solver_opts} = state
      ) do
    {:ok, top_space} =
      Space.create(variables, propagators, Keyword.put(solver_opts, :solver, self()))

    {:noreply, Map.put(state, :space, top_space)}
  end

  @impl true
  def handle_info(event, state) do
    {:noreply, handle_event(event, state)}
  end

  defp handle_event(
         {:solution, new_solution},
         %{solution_count: count, solutions: solutions, stop_on: stop_on} = state
       ) do
    if check_for_stop(stop_on, new_solution, state) do
      stop_spaces(state)
    else
      %{state | solution_count: count + 1, solutions: [new_solution | solutions]}
    end

    ## TODO: check for stopping condition here.

    ## Q: spaces are async and handle solutions on their own,
    ## so even if stopping condition is handled here, how do (or should)
    ## we prevent spaces from emitting new solutions?
  end

  defp handle_event(:failure, %{failure_count: count} = state) do
    Logger.debug("Solver: space failure")
    %{state | failure_count: count + 1}
  end

  defp handle_event({:nodes, new_nodes}, %{node_count: count, active_nodes: nodes} = state) do
    new_nodes_set = MapSet.new(new_nodes)
    n = MapSet.size(new_nodes_set)
    Logger.debug("Solver: #{n} new node(s)")
    %{state | node_count: count + n, active_nodes: MapSet.union(nodes, new_nodes_set)}
  end

  defp handle_event({:shutdown_space, node}, %{active_nodes: nodes} = state) do
    %{state | active_nodes: MapSet.delete(nodes, node)}
  end

  defp handle_event(unexpected, state) do
    Logger.error("Solver: unexpected message #{inspect(unexpected)}")
    state
  end

  @impl true
  def handle_call(:get_stats, _from, state) do
    {:reply, get_stats(state), state}
  end

  def handle_call(:get_solutions, _from, state) do
    {:reply, get_solutions(state), state}
  end

  defp get_stats(state) do
    Map.take(state, [:solution_count, :failure_count, :node_count])
  end

  defp get_solutions(%{solutions: solutions} = _state) do
    ## Here we piggy-back on the fact that the variables are ordered by their refs
    ## in spaces, and the order there matches the order within solver state.
    ## This may likely change, we will probably use var names instead of refs.
    solutions
    |> Enum.map(fn solution ->
      solution
      |> Enum.sort_by(fn {ref, _value} -> ref end)
      |> Enum.map(fn {_ref, value} -> value end)
    end)
  end

  defp check_for_stop(nil, _solution, _data) do
    false
  end

  defp check_for_stop({:max_solutions, max}, _solution, data) do
    max == data.solution_count
  end

  defp check_for_stop(condition, solution, data) when is_function(condition, 2) do
    condition.(solution, data)
  end

  defp stop_spaces(%{active_nodes: spaces} = data) do
    Enum.each(spaces, fn s -> Process.alive?(s) && Process.exit(s, :kill) end)
    data
  end
end