lib/benchee/conversion/scale.ex

defmodule Benchee.Conversion.Scale do
  @moduledoc """
  Functions for scaling values to other units. Different domains handle
  this task differently, for example durations and counts.

  See `Benchee.Conversion.Count` and `Benchee.Conversion.Duration` for examples
  """

  alias Benchee.Conversion.Unit

  @type unit :: Unit.t()
  @type unit_atom :: atom
  @type any_unit :: unit | unit_atom
  @type scaled_number :: {number, unit}
  @type scaling_strategy :: :best | :largest | :smallest | :none

  @doc """
  Scales a number in a domain's base unit to an equivalent value in the best
  fit unit. Results are a `{number, unit}` tuple. See `Benchee.Conversion.Count` and
  `Benchee.Conversion.Duration` for examples.
  """
  @callback scale(number) :: scaled_number

  @doc """
  Scales a number in a domain's base unit to an equivalent value in the
  specified unit.
  See `Benchee.Conversion.Count` and `Benchee.Conversion.Duration` for examples.
  """
  @callback scale(number, any_unit) :: number

  @doc """
  Finds the best fit unit for a list of numbers in a domain's base unit.
  "Best fit" is the most common unit, or (in case of tie) the largest of the
  most common units.
  """
  @callback best(list, keyword) :: unit

  @doc """
  Returns the base_unit in which Benchee takes its measurements, which in
  general is the smallest supported unit.
  """
  @callback base_unit :: unit

  @doc """
  Given the atom representation of a unit (`:hour`) return the appropriate
  `Benchee.Conversion.Unit` struct.
  """
  @callback unit_for(any_unit) :: unit

  @doc """
  List of all the units supported by this type.
  """
  @callback units :: [unit]

  @doc """
  Takes a tuple of a number and a unit and a unit to be converted to, returning
  the the number scaled to the new unit and the new unit.
  """
  @callback convert({number, any_unit}, any_unit) :: scaled_number

  # Generic scaling functions

  @doc """
  Used internally by implemented units to handle their scaling with units and
  without.

  ## Examples

      iex> scale(12345, :thousand, Benchee.Conversion.Count)
      12.345
  """
  def scale(value, unit = %Unit{}, _module) do
    scale(value, unit)
  end

  def scale(value, unit_atom, module) do
    scale(value, module.unit_for(unit_atom))
  end

  @doc """
  Used internally for scaling but only supports scaling with actual units.

  ## Examples

      iex> unit = %Benchee.Conversion.Unit{magnitude: 1000}
      iex> scale 12345, unit
      12.345
  """
  def scale(value, %Unit{magnitude: magnitude}) do
    value / magnitude
  end

  @doc """
  Lookup a unit by its `atom` presentation for the representation of supported
  units. Used by `Benchee.Conversion.Duration` and `Benchee.Conversion.Count`.
  """
  def unit_for(_units, unit = %Unit{}), do: unit
  def unit_for(units, unit), do: Map.fetch!(units, unit)

  @doc """
  Used internally to implement scaling in the modules without duplication.
  """
  def convert({value, current_unit}, desired_unit, module) do
    current_unit = module.unit_for(current_unit)
    desired_unit = module.unit_for(desired_unit)
    do_convert({value, current_unit}, desired_unit)
  end

  defp do_convert(
         {value, %Unit{magnitude: current_magnitude}},
         desired_unit = %Unit{magnitude: desired_magnitude}
       ) do
    multiplier = current_magnitude / desired_magnitude
    {value * multiplier, desired_unit}
  end

  @doc """
  Given a `list` of number values and a `module` describing the domain of the
  values (e.g. Duration, Count), finds the "best fit" unit for the list as a
  whole.

  The best fit unit for a given value is the smallest unit in the domain for
  which the scaled value is at least 1. For example, the best fit unit for a
  count of 1_000_000 would be `:million`.

  The best fit unit for the list as a whole depends on the `:strategy` passed
  in `opts`:

  * `:best`     - the most frequent best fit unit. In case of tie, the
  largest of the most frequent units
  * `:largest`  - the largest best fit unit
  * `:smallest` - the smallest best fit unit
  * `:none`     - the domain's base (unscaled) unit

  ## Examples

      iex> list = [1, 101, 1_001, 10_001, 100_001, 1_000_001]
      iex> best_unit(list, Benchee.Conversion.Count, strategy: :best).name
      :thousand

      iex> list = [1, 101, 1_001, 10_001, 100_001, 1_000_001]
      iex> best_unit(list, Benchee.Conversion.Count, strategy: :smallest).name
      :one

      iex> list = [1, 101, 1_001, 10_001, 100_001, 1_000_001]
      iex> best_unit(list, Benchee.Conversion.Count, strategy: :largest).name
      :million

      iex> list = []
      iex> best_unit(list, Benchee.Conversion.Count, strategy: :best).name
      :one

      iex> list = [nil]
      iex> best_unit(list, Benchee.Conversion.Count, strategy: :best).name
      :one

      iex> list = [nil, nil, nil, nil]
      iex> best_unit(list, Benchee.Conversion.Count, strategy: :best).name
      :one

      iex> list = [nil, nil, nil, nil, 2_000]
      iex> best_unit(list, Benchee.Conversion.Count, strategy: :best).name
      :thousand
  """
  def best_unit(measurements, module, options) do
    do_best_unit(Enum.reject(measurements, &is_nil/1), module, options)
  end

  defp do_best_unit([], module, _) do
    module.base_unit
  end

  defp do_best_unit(list, module, opts) do
    case Keyword.get(opts, :strategy, :best) do
      :best -> best_unit(list, module)
      :largest -> largest_unit(list, module)
      :smallest -> smallest_unit(list, module)
      :none -> module.base_unit
    end
  end

  # Finds the most common unit in the list. In case of tie, chooses the
  # largest of the most common
  defp best_unit(list, module) do
    list
    |> Enum.map(fn n -> scale_unit(n, module) end)
    |> Enum.group_by(fn unit -> unit end)
    |> Enum.map(fn {unit, occurrences} -> {unit, length(occurrences)} end)
    |> Enum.sort(fn unit, freq -> by_frequency_and_magnitude(unit, freq) end)
    |> hd
    |> elem(0)
  end

  # Finds the smallest unit in the list
  defp smallest_unit(list, module) do
    list
    |> Enum.map(fn n -> scale_unit(n, module) end)
    |> Enum.min_by(fn unit -> magnitude(unit) end)
  end

  # Finds the largest unit in the list
  defp largest_unit(list, module) do
    list
    |> Enum.map(fn n -> scale_unit(n, module) end)
    |> Enum.max_by(fn unit -> magnitude(unit) end)
  end

  defp scale_unit(count, module) do
    {_, unit} = module.scale(count)
    unit
  end

  # Fetches the magnitude for the given unit
  defp magnitude(%Unit{magnitude: magnitude}) do
    magnitude
  end

  # Sorts two elements first by total, then by magnitude of the unit in case
  # of tie
  defp by_frequency_and_magnitude({unit_a, frequency}, {unit_b, frequency}) do
    magnitude(unit_a) > magnitude(unit_b)
  end

  defp by_frequency_and_magnitude({_, frequency_a}, {_, frequency_b}) do
    frequency_a > frequency_b
  end
end