lib/datadog/sketch/index_mapping/logarithmic.ex

defmodule Datadog.Sketch.IndexMapping.Logarithmic do
  @moduledoc """
  LogarithmicMapping is an IndexMapping that is memory-optimal, that is to say
  that given a targeted relative accuracy, it requires the least number of
  indices to cover a given range of values. This is done by logarithmically
  mapping floating-point values to integers.

  Note, since Erlang (and therefor Elixir) do math pretty differently than
  golang, this module does not contain a min or max indexable value. This
  follows in line with the Erlang philosophy of "let it crash" and simplifies
  our logic handling.
  """

  @behaviour Datadog.Sketch.IndexMapping

  alias Datadog.Sketch.IndexMapping

  defstruct gamma: 0.0,
            index_offset: 0.0,
            multiplier: 0.0

  @type t :: Datadog.Sketch.IndexMapping.t()

  @doc """
  Creates a new Logarithmic index mapping with the given accuracy.

  ## Examples

      iex> Logarithmic.new(-1)
      ** (ArgumentError) The relative accuracy must be between 0, and 1.

      iex> Logarithmic.new(0.01)
      %Logarithmic{gamma: 1.02020202020202, multiplier: 49.99833328888678}

      iex> Logarithmic.new(0.0001)
      %Logarithmic{gamma: 1.0002000200020003, multiplier: 4999.999983331928}

  """
  @spec new(float()) :: t() | no_return()
  def new(relative_accuracy) when relative_accuracy < 0 or relative_accuracy > 1,
    do: raise(ArgumentError, message: "The relative accuracy must be between 0, and 1.")

  def new(relative_accuracy) do
    gamma = (1 + relative_accuracy) / (1 - relative_accuracy)
    new(gamma, 0.0)
  end

  @doc """
  Creates a new Logarithmic index mapping with the given gamma
  and index offset.

      iex> Logarithmic.new(0.5, 0.0)
      ** (ArgumentError) Gamma must be greater than 1.

      iex> Logarithmic.new(1.0002000200020003, 0.0)
      %Logarithmic{gamma: 1.0002000200020003, multiplier: 4999.999983331928}

      iex> Logarithmic.new(1.0002000200020003, 1.5)
      %Logarithmic{gamma: 1.0002000200020003, index_offset: 1.5, multiplier: 4999.999983331928}

  """
  @spec new(float(), float()) :: t() | no_return()
  def new(gamma, _index_offset) when gamma <= 1,
    do: raise(ArgumentError, message: "Gamma must be greater than 1.")

  def new(gamma, index_offset) do
    multiplier = 1 / :math.log(gamma)

    %__MODULE__{
      gamma: gamma,
      index_offset: index_offset,
      multiplier: multiplier
    }
  end

  @doc """
  Checks if an index mapping matches another index mapping.

      iex> implementation_one = Logarithmic.new(0.0000000000001)
      ...> implementation_two = Logarithmic.new(0.0000000000002)
      ...> Logarithmic.equals(implementation_one, implementation_two)
      true

      iex> implementation_one = Logarithmic.new(0.01)
      ...> implementation_two = Logarithmic.new(0.00001)
      ...> Logarithmic.equals(implementation_one, implementation_two)
      false

  """
  @impl Datadog.Sketch.IndexMapping
  @spec equals(t(), t()) :: boolean()
  def equals(%{gamma: sgamma, index_offset: sindex_offset}, %{
        gamma: ogamma,
        index_offset: oindex_offset
      }) do
    tol = 1.0e-12

    IndexMapping.within_tolerance(sgamma, ogamma, tol) and
      IndexMapping.within_tolerance(sindex_offset, oindex_offset, tol)
  end

  @doc """
  Returns value after mapping via logarithmic equation.

      iex> index_mapping = Logarithmic.new(0.01)
      ...> Logarithmic.index(index_mapping, 115)
      237

      iex> index_mapping = Logarithmic.new(0.001)
      ...> Logarithmic.index(index_mapping, 12345678901234567890)
      21979

  """
  @impl Datadog.Sketch.IndexMapping
  @spec index(t(), float()) :: integer()
  def index(%{index_offset: index_offset, multiplier: multiplier}, value) do
    index = :math.log(value) * multiplier + index_offset

    if index >= 0 do
      trunc(index)
    else
      trunc(index) - 1
    end
  end

  @doc """
  Takes a mapped value and returns the original value within the set accuracy.

      iex> index_mapping = Logarithmic.new(0.01)
      ...> Logarithmic.value(index_mapping, 237)
      115.59680764552533

      iex> index_mapping = Logarithmic.new(0.001)
      ...> Logarithmic.value(index_mapping, 21979)
      1.23355147396003e19

  """
  @impl Datadog.Sketch.IndexMapping
  @spec value(t(), integer()) :: float()
  def value(self, index) do
    lower_bound(self, index) * (1 + relative_accuracy(self))
  end

  @doc """
  Returns the lower bound the mapping can contain.

      iex> index_mapping = Logarithmic.new(0.01)
      ...> Logarithmic.lower_bound(index_mapping, 0)
      1.0

      iex> index_mapping = Logarithmic.new(0.01)
      ...> Logarithmic.lower_bound(index_mapping, 10)
      1.2214109013609646

  """
  @impl Datadog.Sketch.IndexMapping
  @spec lower_bound(t(), integer()) :: float()
  def lower_bound(%{index_offset: index_offset, multiplier: multiplier}, index) do
    :math.exp((index - index_offset) / multiplier)
  end

  @doc """
  Returns the lower bound the mapping can contain.

      iex> index_mapping = Logarithmic.new(0.01)
      ...> Logarithmic.relative_accuracy(index_mapping)
      0.009999999999999898

  """
  @impl Datadog.Sketch.IndexMapping
  @spec relative_accuracy(t()) :: float()
  def relative_accuracy(%{gamma: gamma}) do
    1 - 2 / (1 + gamma)
  end

  @doc """
  Returns a Protobuf-able struct for the index mapping., Used for
  sending data to Datadog.

      iex> index_mapping = Logarithmic.new(0.01)
      ...> Logarithmic.to_proto(index_mapping)
      %Datadog.Sketch.Protobuf.IndexMapping{gamma: 1.02020202020202, interpolation: :NONE}

  """
  @impl Datadog.Sketch.IndexMapping
  @spec to_proto(t()) :: struct()
  def to_proto(self) do
    %Datadog.Sketch.Protobuf.IndexMapping{
      gamma: self.gamma,
      indexOffset: self.index_offset,
      interpolation: :NONE
    }
  end
end