lib/toolbox/random.ex

defmodule Toolbox.Random do
  @moduledoc """
  Functions for working with deterministic (pseudo)randomness in scenarios.

  In some use cases, scenarios need to behave “randomly”, for example, when
  a scenario serves as a demo events generator. Scenarios are by design
  deterministic which means that they can only behave randomly in the sense
  that they make certain decisions “on their own” (without the programmer
  explicitly choosing the outcome) but every time a scenario run is repeated,
  the same “random” decisions are made. To make this possible, the seed of
  the random number generator has to be always initialized to the same value
  and managed in the way that it is preserved throughout the run. And that
  is what this module helps with.

  The module wraps around Erlang `:rand` module for random number generation.
  Functions in the `:rand` module use process dictionary for storing the
  random number generator state (i.e. the seed). To be able to restore a run,
  the random number generator state is also kept in the state of a unit in
  an opaque field called `__rand_seed`.

  There are three steps (functions of this module) in order to use randomness
  in scenarios. First, you have to initialize the unit that uses randomness
  with `init/2`. And then, every time before you want to use randomness, you
  have to call `load/1` with the particular unit. And then store the new
  seed with `save/1`. An example is given below. See the documentation for
  each of the functions for more detailed information.

  ## Example

      defmodule Scenarios.MyScenario.MyTemplate
        @behaviour Runbox.Scenario.Template.StageBased

        alias Toolbox.Random

        def instances do
          # singleton unit
          [{ :unit,"generator", %{} }]
        end

        def init(_start_from, unit) do
          unit = Random.init(unit, 293732)
          {:ok, [], unit}
        end

        def handle_message(msg, unit) do
          Random.load(unit)
          ... do something that uses :rand calls
          ... (e.g. :rand.uniform/0 or Enum.random/1)
          {:noreply, oas, Random.save(unit)}
        end
      end
  """

  alias Runbox.Runtime.Stage.Unit
  alias Toolbox.Random

  @type input_seed :: term()
  @type seed :: :rand.seed()

  @algorithm :exsss

  @doc """
  Initializes a unit for random number generation.

  You need to provide an initial seed which may be any term, in particular
  any integer, string or atom. The value doesn't matter (it only affects the
  randomly generated values) as long as it is constant. Typically, you would
  use this function inside the `init/2` callback of your template.

  In case you are using a singleton unit, you may, for example, hardcode an
  integer here or use `__MODULE__` as the initial seed:

      def init(_start_from, unit) do
        unit = Random.init(unit, __MODULE__)
        {:ok, [], unit}
      end

  If you have multiple units (e.g. unit for each person), you may initialize
  each unit with a different seed (e.g. the ID of that person):

      def init(_start_from, unit) do
        unit = Random.init(unit, unit.attributes.id)
        {:ok, [], unit}
      end

  This function saves the initial seed into the unit's state under
  an opaque field called `__rand_seed` and return the modified unit.
  Therefore, to use randomness, the unit's state has to be a map.

  The function also sets the seed in the process dictionary so you
  can start using randomness right away. But you will probably want
  to do so later in the `handle_message/2` callback.
  """
  @spec init(Unit.t(), input_seed()) :: Unit.t()
  def init(unit, seed) when is_integer(seed) and is_map(unit.state) do
    set_seed(seed)
    put_seed(unit, seed)
  end

  def init(unit, seed) when is_map(unit.state) do
    integer_seed = :erlang.phash2(seed)
    init(unit, integer_seed)
  end

  def init(unit, _seed) when not is_map(unit.state) do
    raise "Toolbox.Random.init/2 called with a unit whose state is not a map" <>
            " (which is required for use with Toolbox.Random)."
  end

  @doc """
  Shortcut for `init/2` called with `__MODULE__` as the seed.

  It is a bit shorter, but on the other hand you have to `require Toolbox.Random`
  to be able to use this macro. Beware that the generated values will differ when
  you rename your template module.
  """
  defmacro init(unit) do
    quote do
      Random.init(unquote(unit), __MODULE__)
    end
  end

  @doc """
  Loads a seed from the unit's state into the process dictionary.

  Typically, you would call this function in the beginning of a `handle_message/2`
  callback that deals with randomness. It is sufficient to call it once per invocation
  of the callback.

  ## Example

      def handle_message(msg, unit) do
        Random.load(unit)

        person = Enum.random(unit.state.people)
        room = Enum.random(unit.state.rooms)
        # generate an event that $person went into $room

        {:noreply, oas, Random.save(unit)}
      end


  """
  @spec load(Unit.t()) :: :ok
  def load(%Unit{state: %{__rand_seed: seed}}) do
    set_seed(seed)
  end

  def load(_unit) do
    raise "Random seed not initialized in the given unit. Call Toolbox.Random.init/2 first."
  end

  @doc """
  Stores the random generator state in the unit's state.

  Typically, you would use this function after generating random values
  (and thus modifying the generator's seed) in the `handle_message/2` callback.

  The function returns the modified unit.
  """
  @spec save(Unit.t()) :: Unit.t()
  def save(unit) do
    {@algorithm, [seed1 | seed2]} = :rand.export_seed()
    # transform the internal algo state returned by :rand.export_seed_s/1
    # into a representation accepted by :rand.seed/2
    seed = [seed1, seed2]
    put_seed(unit, seed)
  end

  @spec put_seed(Unit.t(), seed()) :: Unit.t()
  defp put_seed(unit, seed) do
    put_in(unit.state[:__rand_seed], seed)
  end

  @spec set_seed(seed()) :: :ok
  defp set_seed(seed) do
    :rand.seed(@algorithm, seed)
    :ok
  end
end