Current section

Files

Jump to
macula_neuroevolution src meta_controller.erl
Raw

src/meta_controller.erl

%% @doc LTC-based meta-controller for adaptive hyperparameter optimization.
%%
%% This gen_server implements a meta-learning system that uses Liquid Time-Constant
%% (LTC) neural networks to dynamically control neuroevolution hyperparameters.
%%
%% == Architecture ==
%%
%% The meta-controller operates at a higher timescale than task networks.
%% It receives training metrics as inputs and outputs hyperparameters:
%% mutation_rate, mutation_strength, and selection_ratio.
%%
%% == LTC Advantage ==
%%
%% LTC neurons maintain internal state that evolves continuously.
%% This enables temporal memory of training dynamics, adaptive response
%% speed based on signal magnitude, and smooth parameter transitions.
%%
%% == Usage ==
%%
%% Create a config and start the meta-controller:
%%
%% ```erlang
%% Config = #meta_config{network_topology = {8, [16, 8], 4}},
%% {ok, Pid} = meta_controller:start_link(Config),
%% meta_controller:start_training(Pid),
%% NewParams = meta_controller:update(Pid, GenerationStats).
%% '''
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(meta_controller).
-behaviour(gen_server).
-include("neuroevolution.hrl").
-include("meta_controller.hrl").
%% API
-export([
start_link/1,
start_link/2,
start_training/1,
stop_training/1,
update/2,
get_state/1,
get_params/1,
reset/1
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2,
code_change/3
]).
%%% ============================================================================
%%% API Functions
%%% ============================================================================
%% @doc Start the meta-controller with given configuration.
-spec start_link(meta_config()) -> {ok, pid()} | {error, term()}.
start_link(Config) ->
start_link(Config, []).
%% @doc Start the meta-controller with configuration and options.
%%
%% Options:
%% - `{id, Id}' - Server identifier (default: make_ref())
%% - `{name, Name}' - Register with given name
-spec start_link(meta_config(), proplists:proplist()) -> {ok, pid()} | {error, term()}.
start_link(Config, Options) ->
Id = proplists:get_value(id, Options, make_ref()),
case proplists:get_value(name, Options) of
undefined ->
gen_server:start_link(?MODULE, {Id, Config}, []);
Name ->
gen_server:start_link(Name, ?MODULE, {Id, Config}, [])
end.
%% @doc Start the meta-learning training process.
-spec start_training(pid() | atom()) -> {ok, started | already_running}.
start_training(ServerRef) ->
gen_server:call(ServerRef, start_training).
%% @doc Stop the meta-learning training process.
-spec stop_training(pid() | atom()) -> ok.
stop_training(ServerRef) ->
gen_server:call(ServerRef, stop_training).
%% @doc Update the meta-controller with new generation stats.
%%
%% This is the main entry point called after each neuroevolution generation.
%% Returns new hyperparameters to use for the next generation.
-spec update(pid() | atom(), generation_stats() | map()) -> #{atom() => float()}.
update(ServerRef, GenerationStats) ->
gen_server:call(ServerRef, {update, GenerationStats}).
%% @doc Get current meta-controller state (for visualization).
-spec get_state(pid() | atom()) -> {ok, map()}.
get_state(ServerRef) ->
gen_server:call(ServerRef, get_state).
%% @doc Get current hyperparameter values.
-spec get_params(pid() | atom()) -> #{atom() => float()}.
get_params(ServerRef) ->
gen_server:call(ServerRef, get_params).
%% @doc Reset the meta-controller to initial state.
-spec reset(pid() | atom()) -> ok.
reset(ServerRef) ->
gen_server:call(ServerRef, reset).
%%% ============================================================================
%%% gen_server Callbacks
%%% ============================================================================
%% @private
init({Id, Config}) ->
error_logger:info_msg(
"[meta_controller] Initializing with topology ~p, tau=~p~n",
[Config#meta_config.network_topology, Config#meta_config.time_constant]
),
%% Initialize LTC network
{LtcWeights, LtcStates} = initialize_network(Config),
%% Initialize output mapping
OutputMapping = create_output_mapping(Config),
State = #meta_state{
id = Id,
config = Config,
ltc_weights = LtcWeights,
ltc_states = LtcStates,
current_params = default_params(Config),
param_momentum = #{
mutation_rate => 0.0,
mutation_strength => 0.0,
selection_ratio => 0.0
}
},
%% Store output mapping in process dictionary for efficiency
put(output_mapping, OutputMapping),
{ok, State}.
%% @private
handle_call(start_training, _From, State = #meta_state{running = true}) ->
{reply, {ok, already_running}, State};
handle_call(start_training, _From, State) ->
error_logger:info_msg("[meta_controller] Starting meta-training~n"),
NewState = State#meta_state{running = true},
{reply, {ok, started}, NewState};
handle_call(stop_training, _From, State) ->
NewState = State#meta_state{running = false},
{reply, ok, NewState};
handle_call({update, GenStats}, _From, State) ->
{NewParams, NewState} = process_generation(GenStats, State),
{reply, NewParams, NewState};
handle_call(get_state, _From, State) ->
StateMap = #{
generation => State#meta_state.generation,
current_params => State#meta_state.current_params,
cumulative_reward => State#meta_state.cumulative_reward,
best_fitness_ever => State#meta_state.best_fitness_ever,
stagnation_count => State#meta_state.stagnation_count,
running => State#meta_state.running,
metrics_history_length => length(State#meta_state.metrics_history),
ltc_states => State#meta_state.ltc_states
},
{reply, {ok, StateMap}, State};
handle_call(get_params, _From, State) ->
{reply, State#meta_state.current_params, State};
handle_call(reset, _From, State) ->
Config = State#meta_state.config,
{LtcWeights, LtcStates} = initialize_network(Config),
NewState = State#meta_state{
ltc_weights = LtcWeights,
ltc_states = LtcStates,
current_params = default_params(Config),
metrics_history = [],
generation = 0,
cumulative_reward = 0.0,
best_fitness_ever = 0.0,
stagnation_count = 0
},
{reply, ok, NewState};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
%% @private
handle_cast(_Msg, State) ->
{noreply, State}.
%% @private
handle_info(_Info, State) ->
{noreply, State}.
%% @private
terminate(_Reason, _State) ->
ok.
%% @private
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%% ============================================================================
%%% Internal Functions - Network Initialization
%%% ============================================================================
%% @private Initialize the LTC network structure.
initialize_network(Config) ->
{InputSize, HiddenLayers, OutputSize} = Config#meta_config.network_topology,
Tau = Config#meta_config.time_constant,
Bound = Config#meta_config.state_bound,
%% Build layer sizes: [InputSize | HiddenLayers] ++ [OutputSize]
LayerSizes = [InputSize | HiddenLayers] ++ [OutputSize],
%% Initialize weights and states for each layer connection
{Weights, States} = initialize_layers(LayerSizes, Tau, Bound, 1, #{}, #{}),
{Weights, States}.
%% @private Initialize weights between consecutive layers.
initialize_layers([_], _Tau, _Bound, _LayerIdx, Weights, States) ->
{Weights, States};
initialize_layers([FromSize, ToSize | Rest], Tau, Bound, LayerIdx, Weights, States) ->
%% Xavier initialization for weights
Scale = math:sqrt(2.0 / (FromSize + ToSize)),
%% Initialize neurons in this layer
{LayerWeights, LayerStates} = lists:foldl(
fun(NeuronIdx, {WAcc, SAcc}) ->
NeuronId = {LayerIdx, NeuronIdx},
%% Random weights for each input
InputWeights = [{I, random_weight(Scale)} || I <- lists:seq(1, FromSize)],
%% Random bias
Bias = random_weight(Scale * 0.1),
%% Random backbone/head weights for CfC
BackboneWeights = [random_weight(0.1) || _ <- lists:seq(1, 3)],
HeadWeights = [random_weight(0.1) || _ <- lists:seq(1, 3)],
Neuron = #meta_neuron{
id = NeuronId,
internal_state = 0.0,
time_constant = Tau,
state_bound = Bound,
input_weights = InputWeights,
bias = Bias,
backbone_weights = BackboneWeights,
head_weights = HeadWeights
},
{WAcc#{NeuronId => Neuron}, SAcc#{NeuronId => 0.0}}
end,
{Weights, States},
lists:seq(1, ToSize)
),
initialize_layers([ToSize | Rest], Tau, Bound, LayerIdx + 1, LayerWeights, LayerStates).
%% @private Generate random weight with given scale.
random_weight(Scale) ->
(rand:uniform() * 2.0 - 1.0) * Scale.
%% @private Create output mapping for parameters.
create_output_mapping(Config) ->
BaseParams = [mutation_rate, mutation_strength, selection_ratio],
Params = case Config#meta_config.control_population_size of
true -> BaseParams ++ [evaluations_per_individual];
false -> BaseParams
end,
IndexToParam = maps:from_list([{I, lists:nth(I, Params)} || I <- lists:seq(1, length(Params))]),
ParamToIndex = maps:from_list([{lists:nth(I, Params), I} || I <- lists:seq(1, length(Params))]),
%% All outputs use sigmoid activation for bounded parameters
OutputActivations = maps:from_list([{I, sigmoid} || I <- lists:seq(1, length(Params))]),
#output_mapping{
index_to_param = IndexToParam,
param_to_index = ParamToIndex,
output_activations = OutputActivations
}.
%% @private Default parameter values.
default_params(_Config) ->
#{
mutation_rate => 0.10,
mutation_strength => 0.30,
selection_ratio => 0.20
}.
%%% ============================================================================
%%% Internal Functions - Generation Processing
%%% ============================================================================
%% @private Process a completed generation and compute new parameters.
process_generation(GenStats, State) ->
Config = State#meta_state.config,
%% Convert generation stats to metrics
Metrics = stats_to_metrics(GenStats, State),
%% Update metrics history
NewHistory = update_history(Metrics, State#meta_state.metrics_history, Config),
%% Compute input features for LTC network
Inputs = compute_input_features(Metrics, NewHistory, State),
%% Forward pass through LTC network
{Outputs, NewLtcStates} = forward_pass(Inputs, State),
%% Convert outputs to parameter values
NewParams = outputs_to_params(Outputs, Config),
%% Compute reward for this generation
Reward = meta_reward:compute(Metrics, NewHistory, Config),
%% Update training (gradient estimate)
TrainingEvent = #meta_training_event{
generation = State#meta_state.generation + 1,
inputs = Inputs,
outputs = Outputs,
reward = Reward#meta_reward.total,
gradients = #{}
},
%% Apply momentum smoothing to parameter changes
SmoothedParams = apply_momentum(NewParams, State),
%% Update stagnation counter
NewStagnation = update_stagnation(Metrics, State),
NewState = State#meta_state{
generation = State#meta_state.generation + 1,
metrics_history = NewHistory,
ltc_states = NewLtcStates,
current_params = SmoothedParams,
cumulative_reward = State#meta_state.cumulative_reward + Reward#meta_reward.total,
best_fitness_ever = max(State#meta_state.best_fitness_ever, Metrics#generation_metrics.best_fitness),
stagnation_count = NewStagnation
},
%% Log progress periodically
maybe_log_progress(NewState, TrainingEvent),
{SmoothedParams, NewState}.
%% @private Convert generation stats to metrics record.
stats_to_metrics(GenStats, State) when is_record(GenStats, generation_stats) ->
PrevMetrics = case State#meta_state.metrics_history of
[] -> undefined;
[H | _] -> H
end,
BestFitness = GenStats#generation_stats.best_fitness,
AvgFitness = GenStats#generation_stats.avg_fitness,
WorstFitness = GenStats#generation_stats.worst_fitness,
FitnessDelta = case PrevMetrics of
undefined -> 0.0;
_ -> BestFitness - PrevMetrics#generation_metrics.best_fitness
end,
RelativeImprovement = case PrevMetrics of
undefined -> 0.0;
_ when PrevMetrics#generation_metrics.best_fitness > 0 ->
FitnessDelta / PrevMetrics#generation_metrics.best_fitness;
_ -> 0.0
end,
%% Compute fitness standard deviation
FitnessStdDev = compute_fitness_std_dev(BestFitness, AvgFitness, WorstFitness),
%% Compute structure metrics (simplified for now)
StructureMetrics = compute_structure_metrics(GenStats),
#generation_metrics{
generation = GenStats#generation_stats.generation,
best_fitness = BestFitness,
avg_fitness = AvgFitness,
worst_fitness = WorstFitness,
fitness_std_dev = FitnessStdDev,
fitness_delta = FitnessDelta,
relative_improvement = RelativeImprovement,
population_diversity = FitnessStdDev,
strategy_entropy = StructureMetrics#structure_metrics.strategy_entropy,
evaluations_used = 1, %% Placeholder
fitness_per_evaluation = BestFitness,
diversity_corridors = StructureMetrics#structure_metrics.diversity_corridors,
adaptation_readiness = StructureMetrics#structure_metrics.adaptation_readiness,
params_used = State#meta_state.current_params,
timestamp = erlang:timestamp()
};
%% Handle map-based stats (from Elixir)
stats_to_metrics(GenStatsMap, State) when is_map(GenStatsMap) ->
PrevMetrics = case State#meta_state.metrics_history of
[] -> undefined;
[H | _] -> H
end,
BestFitness = maps:get(best_fitness, GenStatsMap, 0.0),
AvgFitness = maps:get(avg_fitness, GenStatsMap, 0.0),
WorstFitness = maps:get(worst_fitness, GenStatsMap, 0.0),
Generation = maps:get(generation, GenStatsMap, 1),
FitnessDelta = case PrevMetrics of
undefined -> 0.0;
_ -> BestFitness - PrevMetrics#generation_metrics.best_fitness
end,
RelativeImprovement = case PrevMetrics of
undefined -> 0.0;
_ when PrevMetrics#generation_metrics.best_fitness > 0 ->
FitnessDelta / PrevMetrics#generation_metrics.best_fitness;
_ -> 0.0
end,
FitnessStdDev = compute_fitness_std_dev(BestFitness, AvgFitness, WorstFitness),
#generation_metrics{
generation = Generation,
best_fitness = BestFitness,
avg_fitness = AvgFitness,
worst_fitness = WorstFitness,
fitness_std_dev = FitnessStdDev,
fitness_delta = FitnessDelta,
relative_improvement = RelativeImprovement,
population_diversity = FitnessStdDev,
strategy_entropy = 0.5, %% Default
evaluations_used = 1,
fitness_per_evaluation = BestFitness,
diversity_corridors = 0.5,
adaptation_readiness = 0.5,
params_used = State#meta_state.current_params,
timestamp = erlang:timestamp()
}.
%% @private Estimate fitness standard deviation from summary stats.
compute_fitness_std_dev(Best, _Avg, Worst) ->
%% Simple approximation assuming roughly normal distribution
Range = Best - Worst,
%% Standard deviation is roughly range/4 for normal distribution
max(0.0, Range / 4.0).
%% @private Compute structure metrics for normative awareness.
compute_structure_metrics(_GenStats) ->
%% Simplified implementation - can be enhanced with actual population analysis
#structure_metrics{
diversity_corridors = 0.5,
adaptation_readiness = 0.5,
breakthrough_potential = 0.5,
strategy_entropy = 0.5
}.
%% @private Update metrics history (keep last N entries).
update_history(Metrics, History, Config) ->
WindowSize = Config#meta_config.history_window,
NewHistory = [Metrics | History],
lists:sublist(NewHistory, WindowSize).
%% @private Compute input features for the LTC network.
compute_input_features(Metrics, History, State) ->
%% Normalize all features to roughly [-1, 1] or [0, 1] range
%% Feature 1: Normalized best fitness
BestFitness = Metrics#generation_metrics.best_fitness,
MaxFitness = max(1.0, State#meta_state.best_fitness_ever),
NormBestFitness = BestFitness / MaxFitness,
%% Feature 2: Relative improvement (already normalized)
RelImprovement = clamp(Metrics#generation_metrics.relative_improvement, -1.0, 1.0),
%% Feature 3: Fitness variance (normalized by avg)
AvgFitness = max(1.0, Metrics#generation_metrics.avg_fitness),
NormVariance = clamp(Metrics#generation_metrics.fitness_std_dev / AvgFitness, 0.0, 1.0),
%% Feature 4: Stagnation signal (increases with stagnation)
StagnationSignal = sigmoid(State#meta_state.stagnation_count / 5.0),
%% Feature 5: Generation progress (normalized)
GenProgress = sigmoid(State#meta_state.generation / 100.0),
%% Feature 6: Moving average improvement (trend)
TrendSignal = compute_trend(History),
%% Feature 7: Population diversity
Diversity = clamp(Metrics#generation_metrics.population_diversity / MaxFitness, 0.0, 1.0),
%% Feature 8: Strategy entropy
Entropy = Metrics#generation_metrics.strategy_entropy,
[NormBestFitness, RelImprovement, NormVariance, StagnationSignal,
GenProgress, TrendSignal, Diversity, Entropy].
%% @private Compute improvement trend from history.
compute_trend([]) -> 0.0;
compute_trend([_]) -> 0.0;
compute_trend(History) ->
%% Simple linear regression on fitness improvements
Improvements = [M#generation_metrics.relative_improvement || M <- History],
case length(Improvements) of
N when N < 2 -> 0.0;
N ->
%% Weighted average with more weight on recent
Weights = [math:pow(0.9, I) || I <- lists:seq(0, N - 1)],
WeightSum = lists:sum(Weights),
WeightedSum = lists:sum([W * V || {W, V} <- lists:zip(Weights, Improvements)]),
clamp(WeightedSum / WeightSum, -1.0, 1.0)
end.
%%% ============================================================================
%%% Internal Functions - LTC Forward Pass
%%% ============================================================================
%% @private Forward pass through LTC network.
forward_pass(Inputs, State) ->
Config = State#meta_state.config,
{_InputSize, HiddenLayers, OutputSize} = Config#meta_config.network_topology,
NeuronType = Config#meta_config.neuron_type,
LtcWeights = State#meta_state.ltc_weights,
LtcStates = State#meta_state.ltc_states,
%% Process through hidden layers
{HiddenOutputs, NewStates1} = process_hidden_layers(
Inputs, HiddenLayers, LtcWeights, LtcStates, NeuronType, 1
),
%% Process output layer
NumHiddenLayers = length(HiddenLayers),
OutputLayerIdx = NumHiddenLayers + 1,
{Outputs, NewStates2} = process_output_layer(
HiddenOutputs, OutputSize, LtcWeights, NewStates1, NeuronType, OutputLayerIdx
),
{Outputs, NewStates2}.
%% @private Process hidden layers.
process_hidden_layers(Inputs, [], _Weights, States, _NeuronType, _LayerIdx) ->
{Inputs, States};
process_hidden_layers(Inputs, [LayerSize | Rest], Weights, States, NeuronType, LayerIdx) ->
%% Process each neuron in this layer
{LayerOutputs, NewStates} = lists:foldl(
fun(NeuronIdx, {OutAcc, StateAcc}) ->
NeuronId = {LayerIdx, NeuronIdx},
Neuron = maps:get(NeuronId, Weights),
OldState = maps:get(NeuronId, StateAcc, 0.0),
%% Compute weighted input sum
WeightedSum = compute_weighted_sum(Inputs, Neuron#meta_neuron.input_weights)
+ Neuron#meta_neuron.bias,
%% Apply LTC dynamics
{NewState, Output} = apply_ltc_dynamics(
NeuronType,
WeightedSum,
OldState,
Neuron#meta_neuron.time_constant,
Neuron#meta_neuron.state_bound,
Neuron#meta_neuron.backbone_weights,
Neuron#meta_neuron.head_weights
),
{OutAcc ++ [Output], StateAcc#{NeuronId => NewState}}
end,
{[], States},
lists:seq(1, LayerSize)
),
process_hidden_layers(LayerOutputs, Rest, Weights, NewStates, NeuronType, LayerIdx + 1).
%% @private Process output layer.
process_output_layer(Inputs, OutputSize, Weights, States, NeuronType, LayerIdx) ->
lists:foldl(
fun(NeuronIdx, {OutAcc, StateAcc}) ->
NeuronId = {LayerIdx, NeuronIdx},
Neuron = maps:get(NeuronId, Weights),
OldState = maps:get(NeuronId, StateAcc, 0.0),
WeightedSum = compute_weighted_sum(Inputs, Neuron#meta_neuron.input_weights)
+ Neuron#meta_neuron.bias,
{NewState, Output} = apply_ltc_dynamics(
NeuronType,
WeightedSum,
OldState,
Neuron#meta_neuron.time_constant,
Neuron#meta_neuron.state_bound,
Neuron#meta_neuron.backbone_weights,
Neuron#meta_neuron.head_weights
),
{OutAcc ++ [Output], StateAcc#{NeuronId => NewState}}
end,
{[], States},
lists:seq(1, OutputSize)
).
%% @private Compute weighted sum of inputs.
compute_weighted_sum(Inputs, InputWeights) ->
lists:foldl(
fun({{_InputIdx, Weight}, InputVal}, Acc) ->
Acc + Weight * InputVal;
({InputIdx, Weight}, Acc) when is_integer(InputIdx) ->
InputVal = lists:nth(InputIdx, Inputs),
Acc + Weight * InputVal
end,
0.0,
lists:zip(InputWeights, Inputs)
).
%% @private Apply LTC dynamics (CfC or ODE).
apply_ltc_dynamics(cfc, Input, State, Tau, Bound, BackboneWeights, HeadWeights) ->
Params = #{
backbone_weights => BackboneWeights,
head_weights => HeadWeights
},
ltc_dynamics:evaluate_cfc(Input, State, Tau, Bound, Params);
apply_ltc_dynamics(ltc, Input, State, Tau, Bound, _BackboneWeights, _HeadWeights) ->
Dt = 0.1, %% Time step for ODE mode
ltc_dynamics:evaluate_ode(Input, State, Tau, Bound, Dt).
%%% ============================================================================
%%% Internal Functions - Output Processing
%%% ============================================================================
%% @private Convert network outputs to parameter values.
outputs_to_params(Outputs, Config) ->
OutputMapping = get(output_mapping),
ParamBounds = Config#meta_config.param_bounds,
maps:fold(
fun(Index, ParamName, Acc) ->
RawOutput = lists:nth(Index, Outputs),
%% Apply sigmoid to bound output to [0, 1]
BoundedOutput = sigmoid(RawOutput),
%% Scale to parameter range
{Min, Max} = maps:get(ParamName, ParamBounds, {0.0, 1.0}),
ParamValue = Min + BoundedOutput * (Max - Min),
Acc#{ParamName => ParamValue}
end,
#{},
OutputMapping#output_mapping.index_to_param
).
%% @private Apply momentum smoothing to parameter changes.
apply_momentum(NewParams, State) ->
Momentum = (State#meta_state.config)#meta_config.momentum,
OldParams = State#meta_state.current_params,
OldMomentum = State#meta_state.param_momentum,
maps:fold(
fun(Param, NewVal, Acc) ->
OldVal = maps:get(Param, OldParams, NewVal),
OldMom = maps:get(Param, OldMomentum, 0.0),
%% Momentum update: v = momentum * v + (1 - momentum) * (new - old)
Delta = NewVal - OldVal,
NewMom = Momentum * OldMom + (1.0 - Momentum) * Delta,
%% Apply momentum-smoothed change
SmoothedVal = OldVal + NewMom,
Acc#{Param => SmoothedVal}
end,
#{},
NewParams
).
%% @private Update stagnation counter.
update_stagnation(Metrics, State) ->
case Metrics#generation_metrics.relative_improvement of
Imp when Imp > 0.01 ->
%% Significant improvement, reset counter
0;
_ ->
%% No improvement, increment counter
State#meta_state.stagnation_count + 1
end.
%% @private Log progress periodically.
maybe_log_progress(State, _TrainingEvent) ->
case State#meta_state.generation rem 10 of
0 ->
Params = State#meta_state.current_params,
error_logger:info_msg(
"[meta_controller] Gen ~p: mutation_rate=~.3f, mutation_strength=~.3f, "
"selection_ratio=~.3f, reward=~.2f, stagnation=~p~n",
[
State#meta_state.generation,
maps:get(mutation_rate, Params),
maps:get(mutation_strength, Params),
maps:get(selection_ratio, Params),
State#meta_state.cumulative_reward,
State#meta_state.stagnation_count
]
);
_ ->
ok
end.
%%% ============================================================================
%%% Utility Functions
%%% ============================================================================
%% @private Sigmoid function.
sigmoid(X) ->
V = clamp(X, -10.0, 10.0),
1.0 / (1.0 + math:exp(-V)).
%% @private Clamp value to range.
clamp(Val, Min, _Max) when Val < Min -> Min;
clamp(Val, _Min, Max) when Val > Max -> Max;
clamp(Val, _Min, _Max) -> Val.