defmodule GrowthBook.Helpers do
@moduledoc """
GrowthBook internal helper functions.
A collection of helper functions for use internally inside the `GrowthBook` library. You should
not (have to) use any of these functions in your own application. They are documented for
library developers only. Breaking changes in this module will not be considered breaking
changes in the library's public API (or cause a minor/major semver update).
"""
alias GrowthBook.{Hash, BucketRange}
@doc """
This checks if a userId is within an experiment namespace or not.
"""
@spec in_namespace?(String.t(), GrowthBook.namespace() | nil) :: boolean()
def in_namespace?(_, nil) do
true
end
def in_namespace?(user_id, {namespace, min, max}) do
hash = Hash.hash("__#{namespace}", user_id, 1)
hash >= min and hash < max
end
@doc """
Determines if a number n is within the provided range.
"""
@spec in_range?(number(), BucketRange.t()) :: boolean()
def in_range?(n, {min, max}), do: n >= min and n < max
@doc """
Determines if the user is part of a gradual feature rollout.
"""
@spec included_in_rollout?(map(), String.t(), String.t(), BucketRange.t(), number(), integer()) ::
boolean()
def included_in_rollout?(_attributes, _seed, _hash_attribute, nil, nil, _hash_version), do: true
def included_in_rollout?(attributes, seed, hash_attribute, range, coverage, hash_version) do
hash_attribute = coalesce(hash_attribute, "id")
hash_value = attributes[hash_attribute] || ""
case hash_value do
"" ->
false
_ ->
n = Hash.hash(seed, hash_value, hash_version || 1)
case {range, coverage} do
{nil, coverage} -> n <= coverage
{range, _} -> in_range?(n, range)
end
end
end
@spec coalesce([any()]) :: any()
@spec coalesce(any(), any()) :: any()
def coalesce(v1, v2), do: coalesce([v1, v2])
def coalesce([last]), do: last
def coalesce([nil | next]), do: coalesce(next)
def coalesce(["" | next]), do: coalesce(next)
def coalesce([v | _]), do: v
@doc """
Returns an list of floats with `count` items that are all equal and sum to 1.
## Examples
iex> GrowthBook.Helpers.get_equal_weights(2)
[0.5, 0.5]
"""
@spec get_equal_weights(integer()) :: [float()]
def get_equal_weights(count) when count < 1, do: []
def get_equal_weights(count), do: List.duplicate(1.0 / count, count)
@doc """
Converts and experiment's coverage and variation weights into a list of bucket ranges.
## Examples
iex> GrowthBook.Helpers.get_bucket_ranges(2, 1, [0.5, 0.5])
[{0.0, 0.5}, {0.5, 1.0}]
iex> GrowthBook.Helpers.get_bucket_ranges(2, 0.5, [0.4, 0.6])
[{0.0, 0.2}, {0.4, 0.7}]
"""
@spec get_bucket_ranges(integer(), float(), [float()] | nil) :: [GrowthBook.bucket_range()]
def get_bucket_ranges(count, coverage, nil), do: get_bucket_ranges(count, coverage, [])
def get_bucket_ranges(count, coverage, weights) do
coverage = max(min(coverage, 1.0), 0.0)
# Default to equal weights if the number of weights is not equal to count,
# or if the sum isn't close to 1.0
weights =
if abs(1 - Enum.sum(weights)) < 0.01 and length(weights) == count,
do: weights,
else: get_equal_weights(count)
{ranges, _acc} =
Enum.map_reduce(weights, 0.0, fn weight, acc ->
{{acc, acc + coverage * weight}, acc + weight}
end)
ranges
end
@doc """
Given a hash and bucket ranges, assign one of the bucket ranges.
"""
@spec choose_variation(float(), [GrowthBook.bucket_range()]) :: integer()
def choose_variation(hash, bucket_ranges) do
Enum.find_index(bucket_ranges, fn {min, max} -> hash >= min and hash < max end) || -1
end
@doc """
Checks if an experiment variation is being forced via a URL query string.
## Examples
iex> GrowthBook.Helpers.get_query_string_override("my-test", "http://localhost/?my-test=1", 2)
1
iex> GrowthBook.Helpers.get_query_string_override("my-test", "not valid", 2)
nil
"""
@spec get_query_string_override(String.t(), String.t(), integer()) :: integer() | nil
def get_query_string_override(experiment_id, url, count) do
with {:ok, %URI{query: query}} <- URI.new(url),
%{^experiment_id => variation} <- URI.decode_query(query || ""),
{index, ""} when index >= 0 and index < count <- Integer.parse(variation) do
index
else
_missing_or_parse_error -> nil
end
end
@doc false
@spec cast_boolish(term()) :: boolean()
def cast_boolish("off"), do: false
def cast_boolish(""), do: false
def cast_boolish(0), do: false
def cast_boolish(false), do: false
def cast_boolish(nil), do: false
def cast_boolish(:undefined), do: false
def cast_boolish(_truthy), do: true
end