lib/solver/search/strategy/variable/shared/chb.ex

defmodule CPSolver.Search.VariableSelector.CHB do
  @moduledoc """
  Conflict-history based  variable selector
  (https://www.gecode.org/doc-latest/MPG.pdf, p.8.5.4)
  """
  use CPSolver.Search.VariableSelector
  alias CPSolver.Space
  alias CPSolver.Shared
  alias CPSolver.Variable.Interface
  alias CPSolver.Utils

  @default_q_score 0.05

  @impl true
  def select(variables, data, opts) do
    select_impl(variables, data, opts[:mode])
    |> Enum.map(fn {var, _chb} -> var end)
  end

  defp select_impl(variables, data, :chb_min) do
    Utils.minimals(
      variable_chbs(variables, Space.get_shared(data)),
      fn {_var, %{q_score: q_score} = _chb} -> q_score end
    )
  end

  defp select_impl(variables, data, :chb_max) do
    Utils.maximals(
      variable_chbs(variables, Space.get_shared(data)),
      fn {_var, %{q_score: q_score} = _chb} -> q_score end
    )
  end

  defp select_impl(variables, data, :chb_size_min) do
    Utils.minimals(
      variable_chbs(variables, Space.get_shared(data)),
      fn {var, %{q_score: q_score} = _chb} ->
        q_score / Interface.size(var)
      end
    )
  end

  defp select_impl(variables, data, :chb_size_max) do
    Utils.maximals(
      variable_chbs(variables, Space.get_shared(data)),
      fn {var, %{q_score: q_score} = _chb} ->
        q_score / Interface.size(var)
      end
    )
  end

  def default_q_score() do
    @default_q_score
  end

  @doc """
  Initialize CHB data
  """
  @impl true
  def initialize(%{variables: variables} = space_data, opts) do
    shared = Space.get_shared(space_data)

    Shared.get_auxillary(shared, :chb) ||
      (
        chb_table = Shared.create_shared_ets_table(shared)
        init_variable_chbs(variables, chb_table, opts[:q_score] || @default_q_score)
        Shared.put_auxillary(shared, :chb, %{variable_chbs: chb_table})
        Shared.add_handler(shared, :on_space_finalized,
        fn solver,  %{variables: variables} = _space_data, reason ->
          Shared.complete?(solver) ||
          update_chbs(variables, reason == :failure, solver)
        end)
      )

  end

  defp init_variable_chbs(variables, chb_table, q_score) do
    Enum.each(variables, fn var ->
      :ets.insert(chb_table, {Interface.id(var), chb_record(q_score, 0)})
    end)
  end

  defp chb_record(q_score, last_failure) do
    %{q_score: q_score, last_failure: last_failure}
  end

  @doc """
  Compute chbs of variables in one pass
  """
  def variable_chbs(variables, shared) do
    chb_data = Shared.get_auxillary(shared, :chb)

    if chb_data do
      %{variable_chbs: chb_table} = chb_data

      chbs =
        :ets.select(
          chb_table,
          for(var <- variables, do: {{Interface.id(var), :_}, [], [:"$_"]})
        )
        |> Map.new()

      Enum.map(variables, fn var ->
        var_id = Interface.id(var)
        {var, Map.get(chbs, var_id, chb_record(@default_q_score, 0))}
      end)
    else
      Enum.map(variables, fn var -> {var, chb_record(@default_q_score, 0)} end)
    end
  end

  def update_chbs(variables, failure?, shared) do
    case Shared.get_auxillary(shared, :chb) do
      nil -> :ok
      %{variable_chbs: chb_table} ->
        Enum.reduce_while(variables, :ok,
        fn var, _acc ->
          Shared.complete?(shared) && {:halt, :ok} ||
          (
          update_variable_chb(var, chb_table, failure?, shared)
          {:cont, :ok}
          )
        end)
      end
  end

  ## Update chb for individual variable in 'shared'
  defp update_variable_chb(%{id: variable_id} = variable, chb_table, failure?, shared) do
    case Shared.get_failure_count(shared) do
      global_failure_count when is_integer(global_failure_count) ->

      cond do
        pruned?(variable) ->
          chb = get_chb(chb_table, variable_id)

          updated_chb =
            %{chb | q_score: q_score(chb, failure?, global_failure_count)}
            |> then(fn rec -> (failure? && %{rec | last_failure: max(chb.last_failure, global_failure_count)}) || rec end)

          :ets.insert(chb_table, {variable_id, updated_chb})

        true ->
          :ignore
      end

      _ -> :ok
    end
  end

  defp pruned?(%{initial_size: initial_size} = variable) do
    current_size =
      try do
        Interface.size(variable)
      catch
        :fail ->
          0
      end

    initial_size > current_size
  end

  defp q_score(
         %{q_score: current_qs, last_failure: last_failure} = _current_chb_record,
         failure?,
         global_failure_count
       ) do
    alpha = step_size(global_failure_count)

    reward =
      ((failure? && 1) || 0.9) / (max(global_failure_count - last_failure, 0) + 1)

    (1 - alpha) * current_qs + alpha * reward
  end

  def step_size(failure_count) do
    max(0.06, 0.4 - failure_count * 1.0e-6)
  end

  defp get_chb(table, variable_id)
       when is_reference(table) and is_reference(variable_id) do
    table
    |> :ets.lookup(variable_id)
    |> then(fn rec ->
      (!Enum.empty?(rec) && elem(hd(rec), 1)) ||
        chb_record(@default_q_score, 0)
    end)
  end
end