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