Current section

Files

Jump to
macula_neuroevolution src silos task_silo task_l0_morphology.erl
Raw

src/silos/task_silo/task_l0_morphology.erl

%% @doc Task Silo L0 Morphology - TWEANN sensor/actuator definitions.
%%
%% Part of the Liquid Conglomerate v2 architecture. Defines the neural network
%% morphology for the Task Silo's L0 hyperparameter controller.
%%
%% == Architecture ==
%%
%% L0 is a TWEANN (Topology and Weight Evolving Artificial Neural Network) that:
%% - Takes 21 sensor inputs (16 evolution + 5 self-play archive)
%% - Produces 20 actuator outputs (12 evolution + 4 layer-specific + 4 archive)
%% - Has 12 hyperparameters that L1 can tune (8 evolution + 4 archive)
%% - Has 7 L1 hyperparameters that L2 can tune
%%
%% == Time Constant ==
%%
%% tau_L0 = 1000 evaluations (adaptation rate for evolution control)
%%
%% Note: The system uses evaluation-centric timing, not discrete generations.
%% All time constants are expressed in evaluation counts.
%%
%% @author Macula.io
%% @copyright 2025 Macula.io
-module(task_l0_morphology).
-export([
%% Morphology definitions
sensor_count/0,
actuator_count/0,
sensor_names/0,
actuator_names/0,
sensor_spec/1,
actuator_spec/1,
%% Hyperparameter definitions
l0_hyperparameters/0,
l0_hyperparameter_spec/1,
l1_hyperparameters/0,
l1_hyperparameter_spec/1,
%% Bounds and defaults
get_l0_defaults/0,
get_l0_bounds/0,
get_l1_defaults/0,
get_l1_bounds/0,
%% Time constants
tau_l0/0,
tau_l1/0,
tau_l2/0
]).
%%% ============================================================================
%%% Time Constants (in evaluations, not generations)
%%% ============================================================================
%% @doc L0 time constant - 1000 evaluations for hyperparameter adaptation.
%% With population ~100, this is roughly 10 evaluation cycles.
-spec tau_l0() -> pos_integer().
tau_l0() -> 1000. % evaluations
%% @doc L1 time constant - 5000 evaluations for tactical adaptation.
%% With population ~100, this is roughly 50 evaluation cycles.
-spec tau_l1() -> pos_integer().
tau_l1() -> 5000. % evaluations
%% @doc L2 time constant - 10000 evaluations for strategic learning.
%% With population ~100, this is roughly 100 evaluation cycles.
-spec tau_l2() -> pos_integer().
tau_l2() -> 10000. % evaluations
%%% ============================================================================
%%% Sensor Definitions (21 inputs: 16 evolution + 5 self-play archive)
%%% ============================================================================
%% @doc Number of sensors (neural network inputs).
-spec sensor_count() -> pos_integer().
sensor_count() -> 21.
%% @doc Ordered list of sensor names.
-spec sensor_names() -> [atom()].
sensor_names() ->
[
%% Evolution sensors (1-16)
best_fitness, % 1. Current best fitness
avg_fitness, % 2. Population average
fitness_variance, % 3. Population diversity
improvement_velocity, % 4. Rate of improvement
stagnation_severity, % 5. How stagnant
diversity_index, % 6. Genetic diversity
species_count_ratio, % 7. Speciation level
avg_network_complexity, % 8. Topology complexity
complexity_velocity, % 9. Bloat detection
elite_dominance, % 10. How dominant elites are
crossover_success_rate, % 11. Crossover effectiveness
mutation_impact, % 12. Mutation effectiveness
resource_pressure_signal, % 13. Cross-silo: Resource Silo pressure
evaluation_progress, % 14. Training progress (evaluations done)
entropy, % 15. Population entropy
convergence_trend, % 16. Convergence direction
%% Self-play archive sensors (17-21)
archive_fill_ratio, % 17. Archive size / max_size
archive_fitness_mean, % 18. Average fitness in archive
archive_fitness_variance, % 19. Fitness variance in archive
archive_staleness, % 20. Average age of archive entries
population_vs_archive_ratio% 21. Population fitness vs archive fitness
].
%% @doc Get specification for a sensor.
-spec sensor_spec(atom()) -> map() | undefined.
sensor_spec(best_fitness) ->
#{
name => best_fitness,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Best fitness in population (normalized)">>
};
sensor_spec(avg_fitness) ->
#{
name => avg_fitness,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Average fitness of population (normalized)">>
};
sensor_spec(fitness_variance) ->
#{
name => fitness_variance,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Fitness variance (normalized, 0=uniform, 1=diverse)">>
};
sensor_spec(improvement_velocity) ->
#{
name => improvement_velocity,
range => {-1.0, 1.0},
source => computed,
description => <<"Rate of fitness improvement per evaluation (-1=declining, +1=improving fast)">>
};
sensor_spec(stagnation_severity) ->
#{
name => stagnation_severity,
range => {0.0, 1.0},
source => computed,
description => <<"How stagnant evolution is (0=progressing, 1=stuck)">>
};
sensor_spec(diversity_index) ->
#{
name => diversity_index,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Genetic diversity measure (0=homogeneous, 1=diverse)">>
};
sensor_spec(species_count_ratio) ->
#{
name => species_count_ratio,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Current species / max species">>
};
sensor_spec(avg_network_complexity) ->
#{
name => avg_network_complexity,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Average network complexity (normalized)">>
};
sensor_spec(complexity_velocity) ->
#{
name => complexity_velocity,
range => {-1.0, 1.0},
source => computed,
description => <<"Rate of complexity change (-1=simplifying, +1=bloating)">>
};
sensor_spec(elite_dominance) ->
#{
name => elite_dominance,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Elite fitness / average fitness (normalized)">>
};
sensor_spec(crossover_success_rate) ->
#{
name => crossover_success_rate,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Fraction of crossovers producing fitter offspring">>
};
sensor_spec(mutation_impact) ->
#{
name => mutation_impact,
range => {0.0, 1.0},
source => evolution_stats,
description => <<"Average fitness change from mutations (normalized)">>
};
sensor_spec(resource_pressure_signal) ->
#{
name => resource_pressure_signal,
range => {0.0, 1.0},
source => cross_silo,
description => <<"Resource Silo's pressure signal (0=healthy, 1=critical)">>
};
sensor_spec(evaluation_progress) ->
#{
name => evaluation_progress,
range => {0.0, 1.0},
source => internal,
description => <<"Current evaluations / max evaluations">>
};
sensor_spec(entropy) ->
#{
name => entropy,
range => {0.0, 1.0},
source => computed,
description => <<"Population information entropy (normalized)">>
};
sensor_spec(convergence_trend) ->
#{
name => convergence_trend,
range => {-1.0, 1.0},
source => computed,
description => <<"Convergence direction (-1=diverging, +1=converging)">>
};
%% Self-play archive sensors
sensor_spec(archive_fill_ratio) ->
#{
name => archive_fill_ratio,
range => {0.0, 1.0},
source => opponent_archive,
description => <<"Archive size / max_size (0=empty, 1=full)">>
};
sensor_spec(archive_fitness_mean) ->
#{
name => archive_fitness_mean,
range => {0.0, 1.0},
source => opponent_archive,
description => <<"Average fitness of archived opponents (normalized)">>
};
sensor_spec(archive_fitness_variance) ->
#{
name => archive_fitness_variance,
range => {0.0, 1.0},
source => opponent_archive,
description => <<"Fitness variance in archive (0=homogeneous, 1=diverse)">>
};
sensor_spec(archive_staleness) ->
#{
name => archive_staleness,
range => {0.0, 1.0},
source => opponent_archive,
description => <<"Average age of archive entries (0=fresh, 1=stale)">>
};
sensor_spec(population_vs_archive_ratio) ->
#{
name => population_vs_archive_ratio,
range => {0.0, 1.0},
source => computed,
description => <<"Population fitness / archive fitness (arms race progress)">>
};
sensor_spec(_) ->
undefined.
%%% ============================================================================
%%% Actuator Definitions (20 outputs: 12 evolution + 4 layer-specific + 4 archive)
%%% ============================================================================
%% @doc Number of actuators (neural network outputs).
%% 12 evolution + 4 layer-specific mutation + 4 self-play archive = 20
-spec actuator_count() -> pos_integer().
actuator_count() -> 20.
%% @doc Ordered list of actuator names.
-spec actuator_names() -> [atom()].
actuator_names() ->
[
%% Evolution actuators (1-12)
mutation_rate, % 1. Per-gene mutation probability (fallback)
mutation_strength, % 2. Gaussian std dev for weights (fallback)
selection_ratio, % 3. Fraction surviving
add_node_rate, % 4. Node addition probability
add_connection_rate, % 5. Connection addition probability
delete_connection_rate, % 6. Connection deletion probability
weight_perturb_vs_replace, % 7. Perturb vs replace ratio
crossover_rate, % 8. Probability of crossover
interspecies_crossover_rate,% 9. Cross-species breeding
elitism_count, % 10. Guaranteed survivors
population_size_delta, % 11. Grow/shrink population
compatibility_threshold_delta,% 12. Species separation
%% Layer-specific mutation actuators (13-16)
%% These enable reservoir/readout training strategies
%% See guides/training-strategies.md for rationale
reservoir_mutation_rate, % 13. Hidden layer mutation rate
reservoir_mutation_strength,% 14. Hidden layer mutation strength
readout_mutation_rate, % 15. Output layer mutation rate
readout_mutation_strength, % 16. Output layer mutation strength
%% Self-play archive actuators (17-20)
archive_threshold_percentile, % 17. Entry threshold
archive_sampling_temperature, % 18. Fitness-weighted sampling
archive_prune_ratio, % 19. Keep top X%
archive_max_size_delta % 20. Grow/shrink max archive size
].
%% @doc Get specification for an actuator.
-spec actuator_spec(atom()) -> map() | undefined.
actuator_spec(mutation_rate) ->
#{
name => mutation_rate,
range => {0.01, 0.50},
target => evolution_params,
description => <<"Per-gene mutation probability">>
};
actuator_spec(mutation_strength) ->
#{
name => mutation_strength,
range => {0.05, 1.0},
target => evolution_params,
description => <<"Gaussian std dev for weight perturbation">>
};
actuator_spec(selection_ratio) ->
#{
name => selection_ratio,
range => {0.05, 0.50},
target => evolution_params,
description => <<"Fraction of population surviving selection">>
};
actuator_spec(add_node_rate) ->
#{
name => add_node_rate,
range => {0.0, 0.15},
target => topology_mutation,
description => <<"Probability of adding a new node">>
};
actuator_spec(add_connection_rate) ->
#{
name => add_connection_rate,
range => {0.0, 0.25},
target => topology_mutation,
description => <<"Probability of adding a new connection">>
};
actuator_spec(delete_connection_rate) ->
#{
name => delete_connection_rate,
range => {0.0, 0.10},
target => topology_mutation,
description => <<"Probability of deleting a connection">>
};
actuator_spec(weight_perturb_vs_replace) ->
#{
name => weight_perturb_vs_replace,
range => {0.5, 1.0},
target => weight_mutation,
description => <<"Ratio of perturb (1.0) vs replace (0.5)">>
};
actuator_spec(crossover_rate) ->
#{
name => crossover_rate,
range => {0.0, 0.9},
target => breeding,
description => <<"Probability of using crossover vs cloning">>
};
actuator_spec(interspecies_crossover_rate) ->
#{
name => interspecies_crossover_rate,
range => {0.0, 0.3},
target => speciation,
description => <<"Probability of cross-species breeding">>
};
actuator_spec(elitism_count) ->
#{
name => elitism_count,
range => {1, 10},
target => selection,
description => <<"Number of elite individuals guaranteed to survive">>
};
actuator_spec(population_size_delta) ->
#{
name => population_size_delta,
range => {-10, 10},
target => population,
description => <<"Change to population size (-10 to +10)">>
};
actuator_spec(compatibility_threshold_delta) ->
#{
name => compatibility_threshold_delta,
range => {-0.5, 0.5},
target => speciation,
description => <<"Change to species compatibility threshold">>
};
%% Layer-specific mutation actuators
%% These enable reservoir/readout training strategies where hidden layers
%% (reservoir) are mutated conservatively while output layers (readout)
%% can adapt faster. See guides/training-strategies.md for rationale.
actuator_spec(reservoir_mutation_rate) ->
#{
name => reservoir_mutation_rate,
range => {0.01, 0.50},
target => layer_mutation,
description => <<"Hidden layer mutation probability">>
};
actuator_spec(reservoir_mutation_strength) ->
#{
name => reservoir_mutation_strength,
range => {0.05, 1.0},
target => layer_mutation,
description => <<"Hidden layer weight perturbation strength">>
};
actuator_spec(readout_mutation_rate) ->
#{
name => readout_mutation_rate,
range => {0.01, 0.50},
target => layer_mutation,
description => <<"Output layer mutation probability">>
};
actuator_spec(readout_mutation_strength) ->
#{
name => readout_mutation_strength,
range => {0.05, 1.0},
target => layer_mutation,
description => <<"Output layer weight perturbation strength">>
};
%% Self-play archive actuators
actuator_spec(archive_threshold_percentile) ->
#{
name => archive_threshold_percentile,
range => {0.3, 0.95},
target => opponent_archive,
description => <<"Minimum fitness percentile for archive entry">>
};
actuator_spec(archive_sampling_temperature) ->
#{
name => archive_sampling_temperature,
range => {0.0, 1.0},
target => opponent_archive,
description => <<"Sampling temperature: 0=uniform, 1=fitness-weighted">>
};
actuator_spec(archive_prune_ratio) ->
#{
name => archive_prune_ratio,
range => {0.5, 1.0},
target => opponent_archive,
description => <<"Keep top X% when pruning archive">>
};
actuator_spec(archive_max_size_delta) ->
#{
name => archive_max_size_delta,
range => {-5, 5},
target => opponent_archive,
description => <<"Change to maximum archive size">>
};
actuator_spec(_) ->
undefined.
%%% ============================================================================
%%% L0 Hyperparameters (12 params: 8 evolution + 4 archive, tuned by L1)
%%% ============================================================================
%% @doc List of L0 hyperparameter names.
-spec l0_hyperparameters() -> [atom()].
l0_hyperparameters() ->
[
%% Evolution hyperparameters (1-8)
mutation_rate_min, % 1. Floor for mutation rate
mutation_rate_max, % 2. Ceiling for mutation rate
topology_mutation_boost, % 3. Multiplier when stagnating
exploitation_vs_exploration, % 4. Balance factor
stagnation_velocity_threshold, % 5. When to consider stagnant
complexity_penalty_weight, % 6. Penalize bloat
diversity_bonus_weight, % 7. Reward diversity
resource_sensitivity, % 8. How much to heed Resource Silo
%% Self-play archive hyperparameters (9-12)
archive_threshold_min, % 9. Floor for archive entry threshold
archive_threshold_max, % 10. Ceiling for archive entry threshold
archive_diversity_weight, % 11. Fitness vs diversity in sampling
archive_recency_decay % 12. Age decay factor per generation
].
%% @doc Get specification for an L0 hyperparameter.
-spec l0_hyperparameter_spec(atom()) -> map() | undefined.
l0_hyperparameter_spec(mutation_rate_min) ->
#{
name => mutation_rate_min,
default => 0.01,
range => {0.001, 0.05},
description => <<"Minimum allowed mutation rate">>
};
l0_hyperparameter_spec(mutation_rate_max) ->
#{
name => mutation_rate_max,
default => 0.50,
range => {0.3, 0.8},
description => <<"Maximum allowed mutation rate">>
};
l0_hyperparameter_spec(topology_mutation_boost) ->
#{
name => topology_mutation_boost,
default => 1.5,
range => {1.0, 3.0},
description => <<"Topology mutation multiplier when stagnating">>
};
l0_hyperparameter_spec(exploitation_vs_exploration) ->
#{
name => exploitation_vs_exploration,
default => 0.5,
range => {0.0, 1.0},
description => <<"Balance: 0=explore, 1=exploit">>
};
l0_hyperparameter_spec(stagnation_velocity_threshold) ->
#{
name => stagnation_velocity_threshold,
default => 0.001,
range => {0.0001, 0.01},
description => <<"Velocity below which evolution is stagnant">>
};
l0_hyperparameter_spec(complexity_penalty_weight) ->
#{
name => complexity_penalty_weight,
default => 0.1,
range => {0.0, 0.5},
description => <<"Weight for complexity penalty in fitness">>
};
l0_hyperparameter_spec(diversity_bonus_weight) ->
#{
name => diversity_bonus_weight,
default => 0.1,
range => {0.0, 0.5},
description => <<"Weight for diversity bonus in fitness">>
};
l0_hyperparameter_spec(resource_sensitivity) ->
#{
name => resource_sensitivity,
default => 0.5,
range => {0.0, 1.0},
description => <<"How much to respond to Resource Silo signals">>
};
%% Self-play archive hyperparameters
l0_hyperparameter_spec(archive_threshold_min) ->
#{
name => archive_threshold_min,
default => 0.3,
range => {0.1, 0.5},
description => <<"Minimum fitness percentile for archive entry">>
};
l0_hyperparameter_spec(archive_threshold_max) ->
#{
name => archive_threshold_max,
default => 0.95,
range => {0.8, 0.99},
description => <<"Maximum fitness percentile for archive entry">>
};
l0_hyperparameter_spec(archive_diversity_weight) ->
#{
name => archive_diversity_weight,
default => 0.3,
range => {0.0, 0.7},
description => <<"Balance: 0=fitness-only sampling, 0.7=diversity-weighted">>
};
l0_hyperparameter_spec(archive_recency_decay) ->
#{
name => archive_recency_decay,
default => 0.95,
range => {0.8, 0.99},
description => <<"Age decay factor per generation (higher=slower decay)">>
};
l0_hyperparameter_spec(_) ->
undefined.
%% @doc Get default values for L0 hyperparameters.
-spec get_l0_defaults() -> map().
get_l0_defaults() ->
#{
%% Evolution hyperparameters
mutation_rate_min => 0.01,
mutation_rate_max => 0.50,
topology_mutation_boost => 1.5,
exploitation_vs_exploration => 0.5,
stagnation_velocity_threshold => 0.001,
complexity_penalty_weight => 0.1,
diversity_bonus_weight => 0.1,
resource_sensitivity => 0.5,
%% Self-play archive hyperparameters
archive_threshold_min => 0.3,
archive_threshold_max => 0.95,
archive_diversity_weight => 0.3,
archive_recency_decay => 0.95
}.
%% @doc Get bounds for L0 hyperparameters.
-spec get_l0_bounds() -> map().
get_l0_bounds() ->
#{
%% Evolution hyperparameters
mutation_rate_min => {0.001, 0.05},
mutation_rate_max => {0.3, 0.8},
topology_mutation_boost => {1.0, 3.0},
exploitation_vs_exploration => {0.0, 1.0},
stagnation_velocity_threshold => {0.0001, 0.01},
complexity_penalty_weight => {0.0, 0.5},
diversity_bonus_weight => {0.0, 0.5},
resource_sensitivity => {0.0, 1.0},
%% Self-play archive hyperparameters
archive_threshold_min => {0.1, 0.5},
archive_threshold_max => {0.8, 0.99},
archive_diversity_weight => {0.0, 0.7},
archive_recency_decay => {0.8, 0.99}
}.
%%% ============================================================================
%%% L1 Hyperparameters (7 params, tuned by L2)
%%% ============================================================================
%% @doc List of L1 hyperparameter names.
%%
%% Note: These are called "meta-parameters" from L1's perspective,
%% but "hyperparameters" from L2's perspective.
-spec l1_hyperparameters() -> [atom()].
l1_hyperparameters() ->
[
aggression_factor, % 1. Response intensity to stagnation
exploration_step, % 2. Max boost per tau cycle
stagnation_sensitivity, % 3. Detection threshold
topology_aggression, % 4. Topology mutation boost
exploitation_weight, % 5. Explore/exploit balance
adaptation_momentum, % 6. Smooth L1 adjustments
improvement_patience % 7. Tau cycles before escalating
].
%% @doc Get specification for an L1 hyperparameter.
-spec l1_hyperparameter_spec(atom()) -> map() | undefined.
l1_hyperparameter_spec(aggression_factor) ->
#{
name => aggression_factor,
default => 0.5,
range => {0.0, 2.0},
description => <<"L1's response intensity to detected stagnation">>
};
l1_hyperparameter_spec(exploration_step) ->
#{
name => exploration_step,
default => 0.1,
range => {0.05, 0.5},
description => <<"Maximum parameter boost L1 can apply per tau cycle">>
};
l1_hyperparameter_spec(stagnation_sensitivity) ->
#{
name => stagnation_sensitivity,
default => 0.001,
range => {0.0001, 0.01},
description => <<"L1's threshold for detecting stagnation">>
};
l1_hyperparameter_spec(topology_aggression) ->
#{
name => topology_aggression,
default => 1.5,
range => {1.0, 3.0},
description => <<"L1's topology mutation boost factor">>
};
l1_hyperparameter_spec(exploitation_weight) ->
#{
name => exploitation_weight,
default => 0.5,
range => {0.2, 0.8},
description => <<"L1's preference: 0.2=explore, 0.8=exploit">>
};
l1_hyperparameter_spec(adaptation_momentum) ->
#{
name => adaptation_momentum,
default => 0.3,
range => {0.0, 0.9},
description => <<"Momentum for smoothing L1's adjustments">>
};
l1_hyperparameter_spec(improvement_patience) ->
#{
name => improvement_patience,
default => 5,
range => {1, 20},
description => <<"Tau cycles L1 waits before escalating response">>
};
l1_hyperparameter_spec(_) ->
undefined.
%% @doc Get default values for L1 hyperparameters.
-spec get_l1_defaults() -> map().
get_l1_defaults() ->
#{
aggression_factor => 0.5,
exploration_step => 0.1,
stagnation_sensitivity => 0.001,
topology_aggression => 1.5,
exploitation_weight => 0.5,
adaptation_momentum => 0.3,
improvement_patience => 5
}.
%% @doc Get bounds for L1 hyperparameters.
-spec get_l1_bounds() -> map().
get_l1_bounds() ->
#{
aggression_factor => {0.0, 2.0},
exploration_step => {0.05, 0.5},
stagnation_sensitivity => {0.0001, 0.01},
topology_aggression => {1.0, 3.0},
exploitation_weight => {0.2, 0.8},
adaptation_momentum => {0.0, 0.9},
improvement_patience => {1, 20}
}.