lib/solver/space/propagation.ex

defmodule CPSolver.Space.Propagation do
  alias CPSolver.Propagator.ConstraintGraph
  alias CPSolver.Propagator
  import CPSolver.Common

  require Logger

  def run(constraint_graph, changes \\ %{})

  def run(constraint_graph, changes) do
    constraint_graph
    |> ConstraintGraph.get_propagators()
    |> then(fn propagators ->
      run_impl(
        propagators,
        constraint_graph,
        propagator_changes(constraint_graph, changes),
        reset?: true
      )
      |> finalize(changes)
    end)
  end



  defp run_impl(propagators, constraint_graph, domain_changes, opts) do
    case propagate(propagators, constraint_graph, domain_changes, opts) do
      {:fail, propagator_id} ->
        {:fail, propagator_id}

      {scheduled_propagators, reduced_graph, new_domain_changes} ->
        if MapSet.size(scheduled_propagators) == 0 do
          reduced_graph
        else
          run_impl(scheduled_propagators, reduced_graph, new_domain_changes, reset?: false)
        end
    end
  end

  def propagate(propagators, graph) do
    propagate(propagators, graph, [])
  end

  def propagate(propagators, graph, opts) do
    propagate(propagators, graph, Map.new(), opts)
  end

  @spec propagate(map(), Graph.t(), map(), Keyword.t()) ::
          {:fail, reference()} | {map(), Graph.t(), map()}
  @doc """
  A single pass of propagation.
  Produces the list (up to implementation) of propagators scheduled for the next pass.
  Side effect: modifies the constraint graph.
  The graph will be modified on every individual Propagator.filter/1, if the latter results in any domain changes.
  """
  def propagate(propagators, graph, domain_changes, opts) when is_list(propagators) do
    propagators
    |> Map.new(fn p -> {p.id, p} end)
    |> propagate(graph, domain_changes, opts)
  end

  def propagate(%MapSet{} = propagator_ids, graph, propagator_changes, opts) do
    Map.new(propagator_ids, fn p_id -> {p_id, ConstraintGraph.get_propagator(graph, p_id)} end)
    |> propagate(graph, propagator_changes, opts)
  end

  def propagate(propagators, graph, propagator_changes, opts) when is_map(propagators) do
    propagators
    |> reorder()
    |> Enum.reduce_while(
      {MapSet.new(), graph, Map.new()},
      fn
        {p_id, p}, {scheduled_acc, g_acc, changes_acc} = _acc ->
          reset? = opts[:reset?]

          p = (reset? && Propagator.bind(p, g_acc, :domain)) || p
          g_acc = ConstraintGraph.update_propagator(g_acc, p_id, p)
          |> tap(fn _ -> p_id != p.id && Logger.error("Propagator mismatch: #{inspect p_id} != #{inspect p.id}") end)

          res =
            Propagator.filter(p,
              reset?: reset?,
              changes: Map.get(propagator_changes, p_id),
              constraint_graph: g_acc
            )

          case res do
            {:filter_error, error} ->
              throw({:error, {:filter_error, error}})

            :fail ->
              {:halt, {:fail, p_id}}

            %{changes: no_changes, active?: active?} when no_changes in [nil, %{}] ->
              {:cont,
               {unschedule(scheduled_acc, p_id), maybe_remove_propagator(g_acc, p_id, active?),
                changes_acc}}

            %{changes: new_changes, active?: active?, state: state} ->
              {updated_graph, updated_scheduled, updated_changes} =
                update_schedule(
                  scheduled_acc,
                  changes_acc,
                  new_changes,
                  maybe_remove_propagator(g_acc, p_id, active?)
                )

              {:cont,
               {updated_scheduled |> unschedule(p_id),
                ConstraintGraph.update_propagator(updated_graph, p_id, Map.put(p, :state, state)),
                updated_changes}}
          end
      end
    )
  end

  ## Note: we do not reschedule a propagator that was the source of domain changes,
  ## as we assume idempotence (that is, running a propagator for the second time wouldn't change domains).
  ## We will probably introduce the option to be used in propagator implementations
  ## to signify that the propagator is not idempotent.
  ##
  defp update_schedule(current_schedule, current_changes, new_domain_changes, graph) do
    {updated_graph, scheduled_propagators, cumulative_domain_changes} =
      new_domain_changes
      |> Enum.reduce(
        {graph, current_schedule, current_changes},
        fn {var_id, domain_change} = change, {g_acc, propagators_acc, changes_acc} ->
          propagator_ids =
            ConstraintGraph.get_propagator_ids(g_acc, var_id, domain_change)

          {maybe_remove_variable(g_acc, var_id, domain_change),
           MapSet.union(
             propagators_acc,
             MapSet.new(Map.keys(propagator_ids))
           ), propagator_changes(propagator_ids, change, changes_acc)}
        end
      )

    {updated_graph, scheduled_propagators, cumulative_domain_changes}
  end

  ## Remove passive propagator
  defp maybe_remove_propagator(graph, propagator_id, active?) do
    (active? && graph) || ConstraintGraph.remove_propagator(graph, propagator_id)
  end

  defp finalize({:fail, _propagator_id} = failure, _changes) do
    failure
  end

  ## At this point, the space is either solved or stable.
  defp finalize(residual_graph, changes) do
    if Enum.empty?(ConstraintGraph.edges(residual_graph)) do
      :solved
    else
      residual_graph
      |> remove_fixed_variables(changes)
      |> then(fn g ->
        if Enum.empty?(ConstraintGraph.edges(g)) do
          :solved
        else
          {:stable, g}
        end
      end)
    end
  end

  defp maybe_remove_variable(graph, var_id, :fixed) do
    ConstraintGraph.disconnect_variable(graph, var_id)
  end

  defp maybe_remove_variable(graph, _var_id, _domain_change) do
    graph
  end

  defp unschedule(scheduled_propagators, p_id) do
    MapSet.delete(scheduled_propagators, p_id)
  end

  defp remove_fixed_variables(graph, changes) do
    Enum.reduce(changes, graph, fn {var_id, domain_change}, g_acc ->
      maybe_remove_variable(g_acc, var_id, domain_change)
    end)
  end

  ## TODO: possible reordering strategy
  ## for the next pass.
  ## Ideas:
  ## - Put to-be-entailed propagators first,
  ## so if they fail, it'd be early.
  ## - (extension of ^^) Order by the number of fixed variables
  ##
  defp reorder(propagators) do
    propagators
  end

  defp propagator_changes(graph, domain_changes) when is_map(domain_changes) do
    Enum.reduce(domain_changes, Map.new(), fn {var_id, domain_change} = change, changes_acc ->
      graph
      |> ConstraintGraph.get_propagator_ids(var_id, domain_change)
      |> propagator_changes(change, changes_acc)
    end)
  end

  defp propagator_changes(propagator_ids, {var_id, domain_change} = _change, changes_acc) do
    Enum.reduce(
      propagator_ids,
      changes_acc,
      fn {p_id, _p_data}, acc ->
        Map.update(acc, p_id, Map.new(%{var_id => domain_change}), fn var_map ->
          current_var_change = Map.get(var_map, var_id)

          Map.put(
            var_map,
            var_id,
            stronger_domain_change(current_var_change, domain_change)
          )
        end)
      end
    )
  end
end