%% @doc Task Silo - Evolution optimization controller for neuroevolution. %% %% Part of the Liquid Conglomerate v2 architecture. The Task Silo optimizes %% evolution hyperparameters to maximize fitness improvement. %% %% == Hierarchical Levels == %% %% - L0 (Defaults): Safe starting parameters - ALWAYS ACTIVE %% - L1 (Tactical): Per-generation adjustments based on recent fitness %% - L2 (Strategic): Multi-generation learning via LTC network (future) %% %% == Time Constant == %% %% τ = 50 (slow adaptation for stable evolution) %% %% == Usage == %% %% %% Start task silo %% {ok, Pid} = task_silo:start_link(#{ %% enabled_levels => [l0, l1], %% stagnation_threshold => 5 %% }), %% %% %% Get recommended parameters after a generation %% Stats = #{best_fitness => 0.85, improvement => 0.02, stagnation => 0}, %% #{mutation_rate := MR} = task_silo:get_recommendations(Pid, Stats). %% %% @author Macula.io %% @copyright 2025 Macula.io -module(task_silo). -behaviour(gen_server). -include("meta_controller.hrl"). %% API -export([ start_link/0, start_link/1, get_recommendations/1, get_recommendations/2, update_stats/2, get_state/1, set_enabled_levels/2, set_l2_guidance/2, reset/1 ]). %% gen_server callbacks -export([ init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3 ]). -define(SERVER, ?MODULE). -define(TIME_CONSTANT, 50). % tau = 50 generations (slow) -record(state, { %% Configuration enabled_levels :: [l0 | l1 | l2], realm :: binary(), % Realm for event publishing %% L2 Configuration %% When true, task_silo queries meta_controller for L2 guidance l2_enabled :: boolean(), %% Current parameters (output) current_params :: map(), %% L1 Tactical state (evaluation-centric) total_evaluations :: non_neg_integer(), %% PRIMARY: Total evaluations (replaces generation) cohort :: non_neg_integer(), %% SECONDARY: Cohort number for lineage (formerly generation) fitness_history :: [float()], improvement_history :: [float()], history_size :: pos_integer(), %% Velocity-based stagnation detection (replaces counter-based) velocity_state :: velocity_state(), %% L1 Tactical tuning factors exploration_boost :: float(), % Based on stagnation_severity from velocity exploitation_boost :: float(), % Increased when improving %% Previous state for change detection prev_exploration_boost :: float(), prev_stagnation_severity :: float(), %% L2 Strategic guidance (controls L1 behavior) %% When L2 is enabled, these values come from meta_controller %% When L2 is disabled, defaults from ?L2_GUIDANCE_DEFAULTS are used l2_guidance :: l2_guidance() }). %%% ============================================================================ %%% API Functions %%% ============================================================================ %% @doc Start the task silo with default configuration. -spec start_link() -> {ok, pid()} | ignore | {error, term()}. start_link() -> start_link(#{}). %% @doc Start the task silo with custom configuration. %% %% Options: %% - enabled_levels: [l0, l1, l2] (default: [l0, l1]) %% - stagnation_threshold: Generations without improvement before boosting (default: 5) %% - history_size: Number of generations to track (default: 20) -spec start_link(map()) -> {ok, pid()} | ignore | {error, term()}. start_link(Config) -> gen_server:start_link({local, ?SERVER}, ?MODULE, Config, []). %% @doc Get recommended parameters based on current evolution state. %% %% Stats should include: %% - best_fitness: Current best fitness %% - avg_fitness: Average population fitness %% - improvement: Fitness improvement from last generation %% - generation: Current generation number %% %% Returns a map of recommended hyperparameters. -spec get_recommendations(pid(), map()) -> map(). get_recommendations(Pid, Stats) -> gen_server:call(Pid, {get_recommendations, Stats}). %% @doc Get recommendations from the registered server using cached state. -spec get_recommendations(pid()) -> map(). get_recommendations(Pid) -> gen_server:call(Pid, get_recommendations). %% @doc Update internal state with generation statistics. -spec update_stats(pid(), map()) -> ok. update_stats(Pid, Stats) -> gen_server:cast(Pid, {update_stats, Stats}). %% @doc Get current silo state for debugging/monitoring. -spec get_state(pid()) -> map(). get_state(Pid) -> gen_server:call(Pid, get_state). %% @doc Set which levels are enabled. -spec set_enabled_levels(pid(), [l0 | l1 | l2]) -> ok. set_enabled_levels(Pid, Levels) -> gen_server:cast(Pid, {set_enabled_levels, Levels}). %% @doc Set L2 guidance from meta_controller. %% %% Called by meta_controller to update the L1 control parameters. %% This is the L2→L1 communication channel. -spec set_l2_guidance(pid(), l2_guidance()) -> ok. set_l2_guidance(Pid, Guidance) when is_record(Guidance, l2_guidance) -> gen_server:cast(Pid, {set_l2_guidance, Guidance}). %% @doc Reset the silo state (for new training session). -spec reset(pid()) -> ok. reset(Pid) -> gen_server:cast(Pid, reset). %%% ============================================================================ %%% gen_server Callbacks %%% ============================================================================ init(Config) -> EnabledLevels = maps:get(enabled_levels, Config, [l0, l1]), HistorySize = maps:get(history_size, Config, 20), Realm = maps:get(realm, Config, <<"default">>), L2Enabled = maps:get(l2_enabled, Config, false), %% Velocity-based stagnation configuration VelocityThreshold = maps:get(velocity_threshold, Config, 0.001), VelocityWindowSize = maps:get(velocity_window_size, Config, 10), %% Start with L0 defaults DefaultParams = task_l0_defaults:get_defaults(), %% Initialize L2 guidance with defaults %% When L2 is enabled, these will be updated by querying meta_controller L2Guidance = ?L2_GUIDANCE_DEFAULTS, %% Initialize velocity state for evaluation-centric stagnation detection VelocityState = #velocity_state{ improvement_window = [], window_size = VelocityWindowSize, current_velocity = 0.0, velocity_threshold = VelocityThreshold, fitness_checkpoints = [], stagnation_severity = 0.0, last_total_evaluations = 0 }, State = #state{ enabled_levels = EnabledLevels, realm = Realm, l2_enabled = L2Enabled, current_params = DefaultParams, total_evaluations = 0, cohort = 0, fitness_history = [], improvement_history = [], history_size = HistorySize, velocity_state = VelocityState, exploration_boost = 0.0, exploitation_boost = 0.0, prev_exploration_boost = 0.0, prev_stagnation_severity = 0.0, l2_guidance = L2Guidance }, error_logger:info_msg("[task_silo] Started with levels=~p, velocity_threshold=~.4f, l2_enabled=~p~n", [EnabledLevels, VelocityThreshold, L2Enabled]), {ok, State}. handle_call({get_recommendations, Stats}, _From, State) -> %% Query L2 for guidance if enabled StateWithL2 = maybe_query_l2_guidance(Stats, State), %% Update state with new stats and compute recommendations NewState = update_internal_state(Stats, StateWithL2), Params = compute_recommendations(NewState), {reply, Params, NewState#state{current_params = Params}}; handle_call(get_recommendations, _From, State) -> {reply, State#state.current_params, State}; handle_call(get_state, _From, State) -> L2Guidance = State#state.l2_guidance, VelocityState = State#state.velocity_state, StateMap = #{ enabled_levels => State#state.enabled_levels, current_params => State#state.current_params, %% PRIMARY: Evaluation-centric metrics total_evaluations => State#state.total_evaluations, %% SECONDARY: Cohort number for backward compatibility cohort => State#state.cohort, generation => State#state.cohort, %% Deprecated alias %% Velocity-based stagnation metrics improvement_velocity => VelocityState#velocity_state.current_velocity, stagnation_severity => VelocityState#velocity_state.stagnation_severity, velocity_threshold => VelocityState#velocity_state.velocity_threshold, %% L1 tactical state exploration_boost => State#state.exploration_boost, exploitation_boost => State#state.exploitation_boost, fitness_history_size => length(State#state.fitness_history), %% L2 guidance for visualization l2_guidance => #{ aggression_factor => L2Guidance#l2_guidance.aggression_factor, exploration_step => L2Guidance#l2_guidance.exploration_step, stagnation_sensitivity => L2Guidance#l2_guidance.stagnation_sensitivity, topology_aggression => L2Guidance#l2_guidance.topology_aggression, exploitation_weight => L2Guidance#l2_guidance.exploitation_weight } }, {reply, StateMap, State}; handle_call(_Request, _From, State) -> {reply, {error, unknown_request}, State}. handle_cast({update_stats, Stats}, State) -> NewState = update_internal_state(Stats, State), {noreply, NewState}; handle_cast({set_enabled_levels, Levels}, State) -> error_logger:info_msg("[task_silo] Enabled levels changed to ~p~n", [Levels]), {noreply, State#state{enabled_levels = Levels}}; handle_cast({set_l2_guidance, Guidance}, State) -> %% Log significant changes in L2 guidance OldGuidance = State#state.l2_guidance, case significant_guidance_change(OldGuidance, Guidance) of true -> error_logger:info_msg( "[task_silo] L2 guidance updated: aggression=~.2f, exploration_step=~.2f, " "topology_aggression=~.2f~n", [Guidance#l2_guidance.aggression_factor, Guidance#l2_guidance.exploration_step, Guidance#l2_guidance.topology_aggression] ); false -> ok end, {noreply, State#state{l2_guidance = Guidance}}; handle_cast(reset, State) -> DefaultParams = task_l0_defaults:get_defaults(), %% Reset velocity state but preserve threshold configuration OldVelocityState = State#state.velocity_state, NewVelocityState = #velocity_state{ improvement_window = [], window_size = OldVelocityState#velocity_state.window_size, current_velocity = 0.0, velocity_threshold = OldVelocityState#velocity_state.velocity_threshold, fitness_checkpoints = [], stagnation_severity = 0.0, last_total_evaluations = 0 }, NewState = State#state{ current_params = DefaultParams, total_evaluations = 0, cohort = 0, fitness_history = [], improvement_history = [], velocity_state = NewVelocityState, exploration_boost = 0.0, exploitation_boost = 0.0, prev_exploration_boost = 0.0, prev_stagnation_severity = 0.0, l2_guidance = ?L2_GUIDANCE_DEFAULTS }, error_logger:info_msg("[task_silo] Reset to initial state~n"), {noreply, NewState}; handle_cast(_Msg, State) -> {noreply, State}. handle_info(_Info, State) -> {noreply, State}. terminate(_Reason, _State) -> ok. code_change(_OldVsn, State, _Extra) -> {ok, State}. %%% ============================================================================ %%% Internal Functions - State Updates %%% ============================================================================ %% @private Update internal state with evaluation-centric statistics. %% %% Stats should include: %% - best_fitness: Current best fitness %% - total_evaluations: Total evaluations so far (PRIMARY) %% - generation/cohort: Current cohort number (SECONDARY, for lineage) %% - improvement: Fitness improvement from previous (optional) update_internal_state(Stats, State) -> BestFitness = maps:get(best_fitness, Stats, 0.0), Improvement = maps:get(improvement, Stats, 0.0), %% PRIMARY: Total evaluations (evaluation-centric progress) TotalEvaluations = maps:get(total_evaluations, Stats, State#state.total_evaluations), %% SECONDARY: Cohort number (accept both 'cohort' and 'generation' for backward compat) Cohort = maps:get(cohort, Stats, maps:get(generation, Stats, State#state.cohort + 1)), %% Update fitness history FitnessHistory = lists:sublist( [BestFitness | State#state.fitness_history], State#state.history_size ), %% Update improvement history ImprovementHistory = lists:sublist( [Improvement | State#state.improvement_history], State#state.history_size ), %% Update velocity state for stagnation detection NewVelocityState = update_velocity_state( BestFitness, TotalEvaluations, State#state.velocity_state ), %% Get L2 guidance (controls boost intensity) L2Guidance = State#state.l2_guidance, %% Compute L1 tactical boosts from stagnation severity (continuous 0.0-1.0) StagnationSeverity = NewVelocityState#velocity_state.stagnation_severity, {ExplorationBoost, ExploitationBoost} = compute_l1_boosts_from_severity( StagnationSeverity, ImprovementHistory, L2Guidance ), %% Emit intervention events when stagnation_severity changes significantly maybe_emit_velocity_intervention_event( State#state.realm, TotalEvaluations, Cohort, State#state.prev_stagnation_severity, StagnationSeverity, NewVelocityState#velocity_state.current_velocity ), State#state{ total_evaluations = TotalEvaluations, cohort = Cohort, fitness_history = FitnessHistory, improvement_history = ImprovementHistory, velocity_state = NewVelocityState, exploration_boost = ExplorationBoost, exploitation_boost = ExploitationBoost, prev_exploration_boost = ExplorationBoost, prev_stagnation_severity = StagnationSeverity }. %% @private Update velocity state with new fitness checkpoint. %% %% Computes improvement velocity as: (delta_fitness / delta_evals) * 1000 %% Then calculates stagnation_severity from velocity vs threshold. update_velocity_state(BestFitness, TotalEvaluations, VelocityState) -> #velocity_state{ fitness_checkpoints = OldCheckpoints, improvement_window = OldWindow, window_size = WindowSize, velocity_threshold = VelocityThreshold, last_total_evaluations = LastEvaluations } = VelocityState, %% Skip if no evaluations since last update case TotalEvaluations > LastEvaluations of false -> VelocityState; true -> %% Add new checkpoint NewCheckpoint = {TotalEvaluations, BestFitness}, Checkpoints = lists:sublist([NewCheckpoint | OldCheckpoints], WindowSize + 1), %% Calculate velocity from previous checkpoint Velocity = case OldCheckpoints of [{PrevEvaluations, PrevFitness} | _] when TotalEvaluations > PrevEvaluations -> DeltaFitness = BestFitness - PrevFitness, DeltaEvaluations = TotalEvaluations - PrevEvaluations, %% Velocity = fitness improvement per 1000 evaluations (DeltaFitness / DeltaEvaluations) * 1000; _ -> 0.0 end, %% Update rolling window of velocities NewWindow = lists:sublist([Velocity | OldWindow], WindowSize), %% Calculate average velocity AvgVelocity = case NewWindow of [] -> 0.0; Velocities -> lists:sum(Velocities) / length(Velocities) end, %% Calculate stagnation severity: 0.0 = healthy, 1.0 = critical %% severity = clamp((threshold - avg_velocity) / threshold, 0.0, 1.0) Severity = case VelocityThreshold > 0 of true -> RawSeverity = (VelocityThreshold - AvgVelocity) / VelocityThreshold, max(0.0, min(1.0, RawSeverity)); false -> 0.0 end, VelocityState#velocity_state{ fitness_checkpoints = Checkpoints, improvement_window = NewWindow, current_velocity = Velocity, stagnation_severity = Severity, last_total_evaluations = TotalEvaluations } end. %% @private Compute L1 tactical boosts from stagnation severity. %% %% Uses continuous stagnation_severity (0.0-1.0) instead of binary counter threshold. %% L2 guidance controls the exploration_step (intensity of response). compute_l1_boosts_from_severity(StagnationSeverity, ImprovementHistory, L2Guidance) -> %% Get L2-controlled exploration step (default: 0.1, range: 0.05-0.5) ExplorationStep = L2Guidance#l2_guidance.exploration_step, %% Exploration boost: proportional to stagnation severity %% At severity=0.5, boost is 0.5 * exploration_step %% At severity=1.0, boost is exploration_step (max response) ExplorationBoost = StagnationSeverity * ExplorationStep, %% Exploitation boost: increases when consistently improving ExploitationBoost = case length(ImprovementHistory) >= 3 of true -> RecentImprovements = lists:sublist(ImprovementHistory, 3), AvgImprovement = lists:sum(RecentImprovements) / 3, case AvgImprovement > 0.01 of true -> min(1.0, AvgImprovement * 10); false -> 0.0 end; false -> 0.0 end, {ExplorationBoost, ExploitationBoost}. %%% ============================================================================ %%% Internal Functions - Recommendations %%% ============================================================================ %% @private Compute recommended parameters based on current state. compute_recommendations(State) -> #state{enabled_levels = Levels} = State, %% Start with L0 defaults L0Params = task_l0_defaults:get_defaults(), %% Apply L1 tactical adjustments if enabled L1Params = case lists:member(l1, Levels) of true -> apply_l1_adjustments(L0Params, State); false -> L0Params end, %% L2 would apply strategic adjustments here (future) %% L2Params = case lists:member(l2, Levels) of %% true -> apply_l2_adjustments(L1Params, State); %% false -> L1Params %% end, %% Always apply bounds from L0 (safety net) task_l0_defaults:apply_bounds(L1Params). %% @private Apply L1 tactical adjustments. %% %% L1 adjusts parameters based on recent evolution dynamics: %% - Stagnation -> increase mutation rate, add_node_rate (exploration) %% - Improvement -> decrease mutation rate (exploitation) %% %% L2 guidance controls HOW AGGRESSIVE these adjustments are: %% - aggression_factor: Multiplier for mutation adjustments (0.0-2.0) %% - exploitation_weight: Balance between explore/exploit (0.2-0.8) %% - topology_aggression: Multiplier for add_node_rate boost (1.0-3.0) apply_l1_adjustments(Params, State) -> #state{ exploration_boost = ExplorationBoost, exploitation_boost = ExploitationBoost, l2_guidance = L2Guidance } = State, %% Get L2 guidance factors Aggression = L2Guidance#l2_guidance.aggression_factor, ExploitWeight = L2Guidance#l2_guidance.exploitation_weight, TopologyAggression = L2Guidance#l2_guidance.topology_aggression, %% Net adjustment factor: positive = explore, negative = exploit %% L2 controls the exploitation weight (how much to value exploitation) NetFactor = ExplorationBoost - (ExploitationBoost * ExploitWeight), %% Adjust mutation rate using L2's aggression factor %% With Aggression=2.0 and NetFactor=1.0: BaseMR * (1 + 2.0) = BaseMR * 3.0 (+200%) %% With Aggression=0.5 and NetFactor=1.0: BaseMR * (1 + 0.5) = BaseMR * 1.5 (+50%) BaseMR = maps:get(mutation_rate, Params, 0.10), AdjustedMR = BaseMR * (1.0 + NetFactor * Aggression), %% Adjust mutation strength (slightly less aggressive than mutation rate) BaseMS = maps:get(mutation_strength, Params, 0.30), AdjustedMS = BaseMS * (1.0 + NetFactor * Aggression * 0.6), %% Adjust topology mutation using L2's topology aggression %% With TopologyAggression=3.0: BaseANR * 3.0 (+200% when heavily stagnating) BaseANR = maps:get(add_node_rate, Params, 0.03), AdjustedANR = case ExplorationBoost > 0.5 of true -> BaseANR * TopologyAggression; % L2-controlled boost false -> BaseANR end, %% Adjust selection ratio (tighter when exploiting) BaseSR = maps:get(selection_ratio, Params, 0.20), AdjustedSR = BaseSR * (1.0 - ExploitationBoost * 0.2), Params#{ mutation_rate => AdjustedMR, mutation_strength => AdjustedMS, add_node_rate => AdjustedANR, selection_ratio => AdjustedSR }. %%% ============================================================================ %%% Internal Functions - Intervention Events (Velocity-Based) %%% ============================================================================ %% @private Emit velocity-based intervention events. %% %% Uses stagnation_severity (0.0-1.0) instead of counter-based threshold. %% Events trigger on severity threshold crossings for clearer state transitions. %% %% Thresholds: %% - severity < 0.3: Healthy (no intervention) %% - severity >= 0.3: Warning (mild intervention) %% - severity >= 0.6: Active intervention %% - severity >= 0.9: Critical intervention maybe_emit_velocity_intervention_event(_Realm, _TotalEvaluations, _Cohort, Prev, Current, _Velocity) when abs(Prev - Current) < 0.1 -> %% No significant change in severity ok; maybe_emit_velocity_intervention_event(Realm, TotalEvaluations, Cohort, PrevSeverity, CurrentSeverity, Velocity) when PrevSeverity < 0.3, CurrentSeverity >= 0.3 -> %% Crossed into warning territory Event = {task_silo_stagnation_warning, #{ realm => Realm, total_evaluations => TotalEvaluations, cohort => Cohort, stagnation_severity => CurrentSeverity, improvement_velocity => Velocity, message => <<"Improvement velocity declining, monitoring closely">> }}, Topic = neuroevolution_events:topic_intervention(Realm), neuroevolution_events:publish(Topic, Event), error_logger:info_msg( "[task_silo] Evals ~p: STAGNATION WARNING - severity=~.2f, velocity=~.4f~n", [TotalEvaluations, CurrentSeverity, Velocity] ); maybe_emit_velocity_intervention_event(Realm, TotalEvaluations, Cohort, PrevSeverity, CurrentSeverity, Velocity) when PrevSeverity < 0.6, CurrentSeverity >= 0.6 -> %% Crossed into active intervention Event = {task_silo_intervention_started, #{ realm => Realm, total_evaluations => TotalEvaluations, cohort => Cohort, stagnation_severity => CurrentSeverity, improvement_velocity => Velocity, message => <<"Stagnation detected, boosting exploration">> }}, Topic = neuroevolution_events:topic_intervention(Realm), neuroevolution_events:publish(Topic, Event), error_logger:info_msg( "[task_silo] Evals ~p: INTERVENTION STARTED - severity=~.2f, velocity=~.4f~n", [TotalEvaluations, CurrentSeverity, Velocity] ); maybe_emit_velocity_intervention_event(Realm, TotalEvaluations, Cohort, PrevSeverity, CurrentSeverity, Velocity) when PrevSeverity < 0.9, CurrentSeverity >= 0.9 -> %% Crossed into critical intervention Event = {task_silo_intervention_critical, #{ realm => Realm, total_evaluations => TotalEvaluations, cohort => Cohort, stagnation_severity => CurrentSeverity, improvement_velocity => Velocity, message => <<"Critical stagnation, maximum exploration boost">> }}, Topic = neuroevolution_events:topic_intervention(Realm), neuroevolution_events:publish(Topic, Event), error_logger:warning_msg( "[task_silo] Evals ~p: CRITICAL INTERVENTION - severity=~.2f, velocity=~.4f~n", [TotalEvaluations, CurrentSeverity, Velocity] ); maybe_emit_velocity_intervention_event(Realm, TotalEvaluations, Cohort, PrevSeverity, CurrentSeverity, Velocity) when PrevSeverity >= 0.3, CurrentSeverity < 0.3 -> %% Recovered to healthy Event = {task_silo_intervention_ended, #{ realm => Realm, total_evaluations => TotalEvaluations, cohort => Cohort, stagnation_severity => CurrentSeverity, improvement_velocity => Velocity, message => <<"Recovered from stagnation, normal operation resumed">> }}, Topic = neuroevolution_events:topic_intervention(Realm), neuroevolution_events:publish(Topic, Event), error_logger:info_msg( "[task_silo] Evals ~p: INTERVENTION ENDED - severity=~.2f, velocity=~.4f~n", [TotalEvaluations, CurrentSeverity, Velocity] ); maybe_emit_velocity_intervention_event(_Realm, _TotalEvaluations, _Cohort, _Prev, _Current, _Velocity) -> %% Other changes (severity changed but no threshold crossing) ok. %%% ============================================================================ %%% Internal Functions - L2 Guidance Helpers %%% ============================================================================ %% @private Query meta_controller for L2 guidance if L2 is enabled. %% %% When L2 is enabled: %% 1. Check if meta_controller is registered and alive %% 2. Call meta_controller:get_l1_guidance/2 with generation stats %% 3. Update state with new L2 guidance %% %% When L2 is disabled or meta_controller unavailable: %% Return state unchanged (using existing defaults) maybe_query_l2_guidance(_Stats, #state{l2_enabled = false} = State) -> State; maybe_query_l2_guidance(Stats, #state{l2_enabled = true, l2_guidance = OldGuidance} = State) -> case whereis(meta_controller) of undefined -> %% meta_controller not running, use defaults error_logger:warning_msg("[task_silo] L2 enabled but meta_controller not found, using defaults~n"), State; Pid when is_pid(Pid) -> try NewGuidance = meta_controller:get_l1_guidance(Pid, Stats), %% Log significant changes case significant_guidance_change(OldGuidance, NewGuidance) of true -> error_logger:info_msg("[task_silo] L2 guidance updated: aggression=~.2f, exploration_step=~.2f~n", [NewGuidance#l2_guidance.aggression_factor, NewGuidance#l2_guidance.exploration_step]); false -> ok end, State#state{l2_guidance = NewGuidance} catch _:Reason -> error_logger:warning_msg("[task_silo] Failed to query L2 guidance: ~p~n", [Reason]), State end end. %% @private Check if L2 guidance changed significantly (for logging). significant_guidance_change(Old, New) -> %% Check if any parameter changed by more than 10% abs(Old#l2_guidance.aggression_factor - New#l2_guidance.aggression_factor) > 0.1 orelse abs(Old#l2_guidance.exploration_step - New#l2_guidance.exploration_step) > 0.05 orelse abs(Old#l2_guidance.topology_aggression - New#l2_guidance.topology_aggression) > 0.2.