lib/solver/constraints/propagators/maximum.ex

defmodule CPSolver.Propagator.Maximum do
  use CPSolver.Propagator

  @moduledoc """
  The propagator for Maximum constraint.
  maximum(y, x) constrains y to be a maximum of variables in the list x.
  """
  @spec new(Common.variable_or_view(), [Common.variable_or_view()]) :: Propagator.t()
  def new(max_var, vars) do
    new([max_var | vars])
  end

  @impl true
  def arguments(args) do
    Arrays.new(args, implementation: Aja.Vector)
  end

  @impl true
  def variables(vars) do
    Enum.map(vars, fn var -> set_propagate_on(var, :bound_change) end)
  end

  @impl true
  def filter(vars, state, changes) do
    if state do
      reduce_state(state, vars, changes)
    else
      initial_state(vars)
    end
    |> finalize(vars)
  end

  defp initial_state(vars) do
    array_length = Propagator.arg_size(vars) - 1
    %{
      active_var_indices: MapSet.new(1..array_length)
    }
    ## Initialize reduction by the 'max' variable change
    |> reduce_state(vars, %{0 => :bound_change})
  end

  defp finalize(state, vars) do
    if exists_fixed_to_max(state, vars) do
        :passive
    else
      {:state, state}
    end
  end

  defp exists_fixed_to_max(%{active_var_indices: active_var_indices} = _state, vars) do
    max_var = vars[0]
    if fixed?(max_var) do
      fixed_max = min(max_var)
      Enum.any?(active_var_indices, fn idx ->
        var = vars[idx]
        fixed?(var) && min(var) == fixed_max
      end)
    end
  end

  defp no_support?(_max_var, active_var_indices, _vars) do
    Enum.empty?(active_var_indices)
  end

  defp reduce_state(
    %{
      active_var_indices: active_var_indices,
      } = state, vars,
      _changes) do

    max_var = vars[0]

    min_max = min(max_var)
    max_max = max(max_var)

    {lb, ub, active_var_indices} =
      ## Try to reduce "array" variables

      Enum.reduce(active_var_indices, {nil, nil, active_var_indices}, fn idx, {min_acc, max_acc, active_acc} = _acc ->
        x_var = vars[idx]

        removeAbove(x_var, max_max)
        active_acc = if max(x_var) < min_max do
          ## The domain of the element is disjoint with domain of "max" variable.
          ## Hence we will ignore them
          MapSet.delete(active_acc, idx)
        else
          active_acc
        end

        x_min = min(x_var)
        x_max = max(x_var)

        {
          Kernel.max(min_acc || x_min, x_min),
          Kernel.max(max_acc || x_max, x_max),
          active_acc
        }
      end)

    ## If no "active" array variables (sucn that max(X) >= max(y)),
    ## then there is no support for y => failure
    if no_support?(max_var, active_var_indices, vars) do
      fail()
    end

    ## Reduce 'max' var
    removeAbove(max_var, ub)
    removeBelow(max_var, lb)

    ## Special case: if there is a unique element X
    ## that:
    ## a) has a support for min(max_var)
    ## b) min(X) < min(max_var)
    ##
    ##, then we can reduce it below min(max_var)
    ##
    try_reduce_x(max_var, vars, active_var_indices)

    state
    |> Map.put(:active_var_indices, active_var_indices)
  end

  defp try_reduce_x(max_var, vars, indices) do
    min_max_value = min(max_var)

    case Enum.reduce_while(indices, {false, nil}, fn idx, {found_support?, _supporting_idx} = acc ->
      if contains?(vars[idx], min_max_value) do
        if found_support? do
          ## More than one such element
          {:halt, {false, nil}}
        else
          ## First element
          {:cont, {true, idx}}
        end
      else
        {:cont, acc}
      end
    end) do
      {false, nil} -> false
      {true, supporting_idx} ->
        :no_change != removeBelow(vars[supporting_idx], min_max_value)
    end

  end

  defp fail() do
    throw(:fail)
  end

end