lib/datadog/sketch/store/dense.ex

defmodule Datadog.Sketch.Store.Dense do
  @moduledoc """
  The Dense store is a dynamically growing contiguous (non-sparse) store. The
  number of bins are bound only by the size of the `:array` that can be allocated.
  """

  @behaviour Datadog.Sketch.Store

  alias Datadog.Sketch.Store

  @array_length_overhead 64
  @array_length_growth_increment 0.1

  defstruct bins: :array.new(0, [{:fixed, true}, {:default, 0}]),
            count: 0.0,
            offset: 0,
            # Max 32, bit integer Erlang supports. Forces a resize when adding
            # the first number.
            min_index: 134_217_728,
            max_index: -134_217_729

  @type t :: %__MODULE__{
          bins: :array.array(),
          count: float(),
          offset: integer(),
          min_index: integer(),
          max_index: integer()
        }

  @doc """
  Creates a new dense store.

  ## Examples

        iex> Dense.new()
        %Dense{}

  """
  @spec new :: t()
  def new do
    %__MODULE__{}
  end

  @doc """
  Adds a number to the store.

  ## Examples

        iex> %Dense{} = Dense.add(Dense.new(), 100)

  """
  @impl Datadog.Sketch.Store
  @spec add(t(), integer()) :: t()
  def add(store, index) do
    add_with_count(store, index, 1.0)
  end

  @doc """
  Adds a bin type to the store.

  ## Examples

        iex> %Dense{} = Dense.add_bin(Dense.new(), %{index: 100, count: 13.13})

  """
  @impl Datadog.Sketch.Store
  @spec add_bin(t(), Store.bin()) :: t()
  def add_bin(store, %{count: 0.0}), do: store

  def add_bin(store, %{count: count, index: index}) do
    add_with_count(store, index, count)
  end

  @doc """
  Adds multiple bin types to the store.

  ## Examples

        iex> %Dense{} = Dense.add_bins(Dense.new(), [
        ...>   %{index: 100, count: 13.13},
        ...>   %{index: 20, count: 2342.4}
        ...> ])

  """
  @impl Datadog.Sketch.Store
  @spec add_bins(t(), [Store.bin()]) :: t()
  def add_bins(store, bins) when is_list(bins),
    do: Enum.reduce(bins, store, &add_bin(&2, &1))

  @doc """
  Adds a number to the store `count` number of times.

  ## Examples

        iex> %Dense{} = Dense.add_with_count(Dense.new(), 100, 13.13)

        iex> %Dense{} = Dense.add_with_count(Dense.new(), 987, 8.3e12)

  """
  @impl Datadog.Sketch.Store
  @spec add_with_count(t(), integer(), float()) :: t()
  def add_with_count(store, _index, 0), do: store

  def add_with_count(store, index, count) do
    {store, array_index} = normalize(store, index)

    current_value = safe_get(store.bins, array_index)
    new_bins = :array.set(array_index, current_value + count, store.bins)

    %{store | bins: new_bins, count: store.count + count}
  end

  @spec safe_get(:array.array(), integer()) :: integer()
  defp safe_get(array, index) do
    :array.get(index, array)
  rescue
    ArgumentError -> 0
  end

  @spec normalize(t(), integer()) :: {t(), integer()}
  defp normalize(store, index) do
    if index < store.min_index or index > store.max_index do
      store = extend_range(store, index, index)
      {store, index - store.offset}
    else
      {store, index - store.offset}
    end
  end

  @spec get_new_length(integer(), integer()) :: integer()
  defp get_new_length(new_min_index, new_max_index) do
    desired_length = new_max_index - new_min_index + 1

    round(
      ((desired_length + @array_length_overhead - 1) / @array_length_growth_increment + 1) *
        @array_length_growth_increment
    )
  end

  @spec extend_range(t(), integer(), integer()) :: t()
  defp extend_range(store, new_min_index, new_max_index) do
    new_min_index = min(new_min_index, store.min_index)
    new_max_index = max(new_max_index, store.max_index)

    cond do
      empty?(store) ->
        initial_length = get_new_length(new_min_index, new_max_index)

        store = %{
          store
          | bins: :array.new(initial_length, [{:fixed, true}, {:default, 0}]),
            offset: new_min_index,
            min_index: new_min_index,
            max_index: new_max_index
        }

        adjust(store, new_min_index, new_max_index)

      new_min_index >= store.offset and new_max_index < store.offset + :array.size(store.bins) ->
        %{store | min_index: new_min_index, max_index: new_max_index}

      true ->
        # To avoid shifting too often when nearing the capacity of the array
        # we may grow it before we actually reach the capacity
        new_length = get_new_length(new_min_index, new_max_index)

        if new_length > :array.size(store.bins) do
          store = %{store | bins: :array.resize(new_length, store.bins)}
          adjust(store, new_min_index, new_max_index)
        else
          adjust(store, new_min_index, new_max_index)
        end
    end
  end

  @spec adjust(t(), integer(), integer()) :: t()
  defp adjust(store, new_min_index, new_max_index) do
    center_counts(store, new_min_index, new_max_index)
  end

  @spec center_counts(t(), integer(), integer()) :: t()
  defp center_counts(store, new_min_index, new_max_index) do
    mid_index = new_min_index + div(new_max_index - new_min_index + 1, 2)
    store = shift_counts(store, store.offset + div(:array.size(store.bins), 2) - mid_index)
    %{store | min_index: new_min_index, max_index: new_max_index}
  end

  @spec shift_counts(t(), integer()) :: t()
  defp shift_counts(store, shift) do
    new_array =
      store.bins
      |> :array.size()
      |> :array.new([{:fixed, true}, {:default, 0}])

    new_array =
      store.bins
      |> :array.sparse_to_orddict()
      |> Enum.reduce(new_array, fn {k, v}, new_array ->
        :array.set(k + shift, v, new_array)
      end)

    %{store | bins: new_array, offset: store.offset - shift}
  end

  @doc """
  Checks if the store has any information in it.

  ## Examples

        iex> store = Dense.new()
        ...> Dense.empty?(store)
        true

        iex> store = Dense.add(Dense.new(), 754)
        ...> Dense.empty?(store)
        false

  """
  @impl Datadog.Sketch.Store
  @spec empty?(t()) :: bool()
  def empty?(%{count: 0.0}), do: true
  def empty?(_store), do: false

  @doc """
  Returns the total amount of counts stored.

  ## Examples

        iex> store = Dense.new()
        ...> Dense.total_count(store)
        0.0

        iex> store = Dense.add(Dense.new(), 754)
        ...> Dense.total_count(store)
        1.0

        iex> store = Dense.add_with_count(Dense.new(), 754, 42.42)
        ...> Dense.total_count(store)
        42.42

        # Verified against golang implementation
        iex> store = Dense.add_bins(Dense.new(), [
        ...>   %{index: 4, count: 12.48},
        ...>   %{index: 65, count: 12.48},
        ...>   %{index: 37, count: 847.4}
        ...> ])
        ...> Dense.total_count(store)
        872.36

  """
  @impl Datadog.Sketch.Store
  @spec total_count(t()) :: float()
  def total_count(%{count: count}), do: count

  @doc """
  Returns the minimum index of the store.

  ## Examples

        iex> store = Dense.new()
        ...> Dense.min_index(store)
        0

        # Verified against golang implementation
        iex> store = Dense.add_bins(Dense.new(), [
        ...>   %{index: 4, count: 12.48},
        ...>   %{index: 65, count: 12.48},
        ...>   %{index: 37, count: 847.4}
        ...> ])
        ...> Dense.min_index(store)
        4

  """
  @impl Datadog.Sketch.Store
  @spec min_index(t()) :: integer()
  def min_index(%{count: 0.0}), do: 0
  def min_index(%{min_index: min_index}), do: min_index

  @doc """
  Returns the maximum index of the store.

  ## Examples

        iex> store = Dense.new()
        ...> Dense.max_index(store)
        0

        iex> store = Dense.add(Dense.new(), 128)
        ...> Dense.max_index(store)
        128

        # Verified against golang implementation
        iex> store = Dense.add_bins(Dense.new(), [
        ...>   %{index: 4, count: 12.48},
        ...>   %{index: 65, count: 12.48},
        ...>   %{index: 37, count: 847.4}
        ...> ])
        ...> Dense.max_index(store)
        65

  """
  @impl Datadog.Sketch.Store
  @spec max_index(t()) :: integer()
  def max_index(%{count: 0.0}), do: 0
  def max_index(%{max_index: max_index}), do: max_index

  @doc """
  Return the key for the value at rank.

  ## Examples

        # Matches the golang implementation when no data is present.
        iex> store = Dense.new()
        ...> Dense.key_at_rank(store, 0.0)
        -134_217_729

        # Verified matching to golang implementation
        iex> store = Dense.add(Dense.new(), 128)
        ...> Dense.key_at_rank(store, 0.0)
        128

        # Verified matching to golang implementation
        iex> store = Dense.add_bins(Dense.new(), [
        ...>   %{index: 12, count: 423.43},
        ...>   %{index: 244, count: 1238.123},
        ...>   %{index: 124, count: 2184.124}
        ...> ])
        ...> Dense.key_at_rank(store, 64.0)
        12

  """
  @impl Datadog.Sketch.Store
  @spec key_at_rank(t(), float()) :: integer()
  def key_at_rank(store, rank) when rank < 0.0,
    do: key_at_rank(store, 0.0)

  def key_at_rank(store, rank) do
    {step, result} =
      store.bins
      |> :array.sparse_to_orddict()
      |> Enum.reduce_while({:not_end, 0.0}, fn {i, b}, {step, n} ->
        n = n + b

        if n > rank do
          {:halt, {:end, i + store.offset}}
        else
          {:cont, {step, n}}
        end
      end)

    case step do
      :end -> result
      :not_end -> store.max_index
    end
  end

  @doc """
  Returns a struct for Protobuf encoding. Used for sending data to
  Datadog.

  ## Examples

        # Verified matching to golang implementation
        iex> store = Dense.add(Dense.new(), 4)
        ...> Dense.to_proto(store)
        %Datadog.Sketch.Protobuf.Store{
          binCounts: %{},
          contiguousBinCounts: [1.0],
          contiguousBinIndexOffset: 4
        }

        # Verified matching to golang implementation
        iex> store = Dense.add_bins(Dense.new(), [
        ...>   %{index: 4, count: 12.48},
        ...>   %{index: 65, count: 12.48},
        ...>   %{index: 37, count: 847.4}
        ...> ])
        ...> Dense.to_proto(store)
        %Datadog.Sketch.Protobuf.Store{
          binCounts: %{},
          contiguousBinCounts: [12.48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 847.4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 12.48],
          contiguousBinIndexOffset: 4
        }

  """
  @impl Datadog.Sketch.Store
  @spec to_proto(t()) :: Datadog.Sketch.Protobuf.Store.t()
  def to_proto(%{count: 0.0}), do: %Datadog.Sketch.Protobuf.Store{contiguousBinCounts: nil}

  def to_proto(store) do
    new_length = store.max_index - store.min_index + 1
    new_array = :array.new(new_length, [{:fixed, true}, {:default, 0}])

    new_array =
      store.bins
      |> :array.sparse_to_orddict()
      |> Enum.reduce(new_array, fn {k, v}, new_array ->
        :array.set(k + store.offset - store.min_index, v, new_array)
      end)

    %Datadog.Sketch.Protobuf.Store{
      contiguousBinCounts: :array.to_list(new_array),
      contiguousBinIndexOffset: store.min_index
    }
  end

  @doc """
  Maps over all values and multiplies by the given weight.

  ## Examples

        # Verified matching to golang implementation
        iex> store = Dense.add_bins(Dense.new(), [
        ...>   %{index: 4, count: 10.0},
        ...>   %{index: 2, count: 20.0},
        ...>   %{index: 6, count: 30.0}
        ...> ])
        ...> store = Dense.reweight(store, 2)
        ...> Dense.total_count(store)
        120.0

  """
  @impl Datadog.Sketch.Store
  @spec reweight(t(), float()) :: t()
  def reweight(store, weight) do
    new_bins = :array.sparse_map(fn _i, v -> v * weight end, store.bins)
    %{store | bins: new_bins, count: store.count * weight}
  end
end

defimpl Enumerable, for: Datadog.Sketch.Store.Dense do
  def count(store) do
    length =
      store.bins
      |> :array.sparse_to_orddict()
      |> length()

    {:ok, length}
  end

  def member?(_store, _value), do: {:error, __MODULE__}
  def slice(_store), do: {:error, __MODULE__}

  def reduce(store, acc, fun) do
    store.bins
    |> :array.sparse_to_orddict()
    |> Enum.map(fn {k, v} -> {k + store.offset, v} end)
    |> Enumerable.List.reduce(acc, fun)
  end
end