Skip to content

Plugins

Plugins let you hook into the search loop without modifying SymbolicRegression.jl itself. A plugin is a small struct that opts into lifecycle hooks: observing mutations, biasing selection, injecting initial population members, or tracking statistics across generations.

How the search works

The search maintains multiple populations of candidate expressions, evolved in parallel. Each population runs on a worker (a thread or process); a single head node coordinates them.

A cycle is one round of evolution on a single population. Within a cycle, the engine runs many steps: each step picks a random member via tournament selection (sample a few members, keep the one with the lowest cost), mutates it, and decides whether to accept the result. The cost used in tournament selection combines the raw loss with a complexity penalty.

After a cycle finishes, the worker sends its updated population back to the head node. The head node merges results, updates the hall of fame, and dispatches the next cycle. Plugins can hook into any of these stages. Note: on_generation_end! fires on the head when a completed cycle is received (not per inner step), while on_cycle_end! fires on the worker at the end of its cycle.

Using a plugin

Pass plugin instances to Options via the plugins keyword:

julia
using SymbolicRegression

options = Options(;
    binary_operators=[+, -, *, /],
    unary_operators=[cos],
    plugins=(AdaptiveParsimonyPlugin(; tournament=true, mutation_acceptance=true),),
)

Multiple plugins compose. The engine iterates the tuple at each lifecycle point and dispatches the appropriate hook on each plugin type.

AdaptiveParsimonyPlugin ships with the package and is enabled by default. It biases tournament selection and mutation acceptance away from over-represented complexities, using a sliding window of recent equation frequencies.

AdaptiveMutationWeightsPlugin is also enabled by default. It learns relative mutation weights from successful search moves, with the learned multipliers regularized halfway toward the configured weights in log space. Pass plugins=(AdaptiveMutationWeightsPlugin(adaptation_strength=0),) to disable only mutation-weight adaptation, or default_plugins=() to disable all automatic plugins.

Writing a custom plugin

Define a struct that subtypes AbstractPlugin, then override whichever hooks you need. The struct holds immutable configuration; mutable runtime state lives in a separate object returned by init_plugin_state.

See the Writing a Custom Plugin tutorial for a complete walkthrough that builds a plugin from scratch.

Lifecycle hooks

Every hook dispatches on your plugin type. Default implementations are no-ops (or return 1.0 for multipliers, nothing for factories). Override only what you need.

Hooks fall into four categories:

CategoryName shapeContract
Observeron_X_start!, on_X_end!Engine fires, plugin reacts. Return value ignored.
MultiplierX_multiplierReturns a Real. Plugins compose multiplicatively.
Conditionercondition_X!Mutates a passed struct in place.
Factoryinit_XCalled once per (plugin, output) at startup.

Initialization and teardown

SymbolicRegression.CoreModule.PluginModule.init_plugin_state Function
julia
init_plugin_state(plugin::AbstractPlugin, options, dataset) -> state

Create the mutable per-output state for plugin. Called once per (plugin, output) pair at search start.

Override by dispatching on your plugin type:

julia
SymbolicRegression.init_plugin_state(p::MyPlugin, options, dataset) =
    MyPluginState(p.config)

Default returns nothing.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.fork_plugin_state Function
julia
fork_plugin_state(head_state, plugin, dataset) -> state

Build the worker-side plugin state for one population, given the head node's current plugin state for this output and the dataset the worker will operate on. The returned state persists across that population's worker dispatches.

Default returns deepcopy(head_state) (full snapshot).

Experimental

source
SymbolicRegression.CoreModule.PluginModule.refresh_worker_plugin_state Function
julia
refresh_worker_plugin_state(worker_state, latest_head_state, plugin, dataset) -> state

Refresh worker_state from latest_head_state before the worker's next dispatch. The default preserves worker_state. Plugins whose head-side hooks update worker data can return a merged or replaced worker state.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.on_search_start! Function
julia
on_search_start!(state, plugin, dataset, options, ropt)

Lifecycle hook called on the head node after initialization, before warmup and the main search loop. Called once per (plugin, output) pair.

Override by dispatching on your plugin type:

julia
SymbolicRegression.on_search_start!(s::MyPluginState, p::MyPlugin, dataset, options, ropt) = ...

Default is a no-op.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.on_search_end! Function
julia
on_search_end!(state, plugin, search_state, dataset, options, ropt)

Lifecycle hook called on the head node after the main search loop exits and before tearing down processes/threads. Multiprocessing cycles may still be running when this hook is called. Called once per (plugin, output) pair.

Override by dispatching on your plugin type. Default is a no-op.

Experimental

source

Per-generation and per-cycle

SymbolicRegression.CoreModule.PluginModule.on_generation_end! Function
julia
on_generation_end!(state, plugin, search_state, dataset, options, ropt, returned_pop)

Lifecycle hook called on the head node after each cycle's result has been received from a worker. Runs serially; safe to mutate plugin state, update concept databases, drain feedback channels, etc. Called once per (plugin, output) pair per cycle. state is this output's state; returned_pop is the population the worker produced.

Override by dispatching on your plugin type. Default is a no-op.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.on_cycle_start! Function
julia
on_cycle_start!(state, plugin, cycle_idx, ncycles, options)

Observer hook fired at the start of each evolution cycle (1-based cycle_idx, ncycles total). Plugins update their own mutable state here based on the cycle position. For example, SimulatedAnnealingPlugin recomputes its temperature once per cycle and consumes it later in mutation_acceptance_multiplier and condition_mutation!.

Default is a no-op.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.on_cycle_end! Function
julia
on_cycle_end!(state, plugin, pop, dataset, hof, options)

Lifecycle hook called on the worker at the end of each evolution cycle, paired with on_cycle_start!. May run concurrently across workers. Use only worker-local state, or use Channel / RemoteChannel for cross-worker communication.

Override by dispatching on your plugin type. Default is a no-op.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.on_mutation_end! Function
julia
on_mutation_end!(state, plugin, mutation::AbstractMutation, event::MutationEvent, dataset, options)

Lifecycle hook called on the worker immediately before each return from next_generation, after the final accept/reject decision for a mutation. Called once per plugin per mutation. Plugins can dispatch on the mutation type (e.g. ::ConstantMutation) for type-specific handling, or ::AbstractMutation for a generic catch-all.

Default is a no-op.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.MutationEvent Type
julia
MutationEvent

Bundle of per-mutation observations passed to on_mutation_end! once the accept/reject decision has been made inside next_generation.

Fields

  • accepted::Bool: true if the mutation was accepted (via return_immediately or annealing/fitness acceptance); false if rejected (constraint failure, NaN loss, or annealing/frequency rejection).

  • before_cost::C: search cost of the parent member before mutation.

  • after_cost::Union{C,Nothing}: search cost after mutation. nothing if no valid evaluation occurred.

  • before_loss::L: raw loss of the parent member before mutation.

  • after_loss::Union{L,Nothing}: raw loss after mutation. nothing if no valid evaluation occurred.

  • mutation_idx::Int: index of the sampled mutation into options.mutations (and into the conditioned weights vector, which shares its order).

C and L are the cost and loss types.

The mutation kind itself is passed as a separate dispatch arg to on_mutation_end!, not stored on the event — that way plugin authors can write type-specific methods.

Experimental

source

Selection and acceptance biases

SymbolicRegression.CoreModule.PluginModule.tournament_cost_multiplier Function
julia
tournament_cost_multiplier(state, plugin, member, options) -> Real

Per-plugin multiplier applied to a candidate's member.cost during tournament selection in _best_of_sample. Plugins compose multiplicatively: the adjusted cost is member.cost * ∏ tournament_cost_multiplier(s, p, ...) across all plugins in tuple order. Default returns 1.0 (no adjustment).

The shipped AdaptiveParsimonyPlugin is the canonical example; it reads frequency statistics from its own state, not from an engine-passed arg.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.mutation_acceptance_multiplier Function
julia
mutation_acceptance_multiplier(state, plugin, ctx::MutationAcceptanceContext, options) -> Real

Per-plugin multiplicative contribution to the engine's per-mutation accept probability. The engine takes the product across all plugins and draws one rand against it — so multiple plugins compose without introducing independent rand draws. Default returns 1.0.

Plugins that need cycle-progress / temperature in their multiplier should maintain their own mutable state and update it in on_cycle_start!.

Experimental

source

Mutation conditioning

SymbolicRegression.CoreModule.PluginModule.prepare_mutation_context Function
julia
prepare_mutation_context(mutation::AbstractMutation)

Build a fresh per-call mutable context for mutation, called inside next_generation immediately after mutation sampling — so only the selected mutation pays the construction cost.

Default returns nothing (no context; the mutation runs directly from its immutable fields). Override for any mutation type that wants plugins to layer per-call configuration on top of its base values via condition_mutation!:

julia
mutable struct MyMutationContext
    strength::Float64
end
SymbolicRegression.prepare_mutation_context(m::MyMutation) = MyMutationContext(m.strength)

Experimental

source
SymbolicRegression.CoreModule.PluginModule.condition_mutation! Function
julia
condition_mutation!(context, state, plugin, mutation, options)

In-place plugin modification of the per-call context produced by prepare_mutation_context. Fired only for the mutation selected this call, and only when its context is not nothing. Composes by sequential in-place mutation in plugin tuple order.

Default is a no-op.

Experimental

source

condition_mutation_weights! is a related hook that modifies the mutation weight vector before sampling. See the Customization page for its full docstring.

Operation defaults

Plugins may contribute weighted mutation and crossover defaults. Explicit entries in Options(; mutations=..., crossovers=...) take precedence.

SymbolicRegression.CoreModule.PluginModule.plugin_mutations Function
julia
plugin_mutations(plugin::AbstractPlugin) -> collection

Weighted custom mutations contributed by plugin, as AbstractMutation() => weight pairs. These are treated as plugin defaults: an explicit entry in Options(; mutations=...) of the same mutation type overrides the plugin contribution, while a plugin contribution overrides a built-in default. Passing default_mutations overrides all automatic defaults, including plugin contributions. The default is empty.

Experimental

source
SymbolicRegression.CoreModule.PluginModule.plugin_crossovers Function
julia
plugin_crossovers(plugin::AbstractPlugin) -> collection

Weighted custom crossovers contributed by plugin, as AbstractCrossover() => weight pairs. These are treated as plugin defaults: an explicit entry in Options(; crossovers=...) of the same crossover type overrides the plugin contribution, while a plugin contribution overrides a built-in default. Passing default_crossovers overrides all automatic defaults, including plugin contributions. The default is empty.

Experimental

source

Population seeding

SymbolicRegression.CoreModule.PluginModule.init_member Function
julia
init_member(state, plugin, dataset, options)

Called when initializing each population member's tree during initial population creation only. Every plugin is asked; at most one may return a non-nothing value, and two or more providers is an error. If all plugins return nothing, the engine falls through to gen_random_tree.

Override by dispatching on your plugin type. Default returns nothing.

State used

init_member is called with the head node's state instance, not a per-worker copy. In :multithreading mode, multiple population-creation tasks may call it concurrently — ensure your implementation is thread-safe or limit it to read-only access of the state.

Experimental

source

Thread and process safety

  • on_generation_end! runs serially on the head node. Safe to mutate state.

  • on_cycle_end! and on_mutation_end! run on workers against per-dispatch copies built by fork_plugin_state. Cross-worker communication requires Channel / RemoteChannel.

  • init_member reads head-node state. In multithreading mode, multiple population-creation tasks may call it concurrently, so keep it read-only or thread-safe.

Dispatching on mutation type

on_mutation_end! receives the mutation as a typed argument. You can write specific methods for individual mutation types:

julia
function SymbolicRegression.on_mutation_end!(
    state::MyState,
    ::MyPlugin,
    ::ConstantMutation,
    event::MutationEvent,
    dataset,
    options,
)
    # handle constant mutations specifically
end

Available mutation types: ConstantMutation, OperatorMutation, FeatureMutation, SwapOperandsMutation, AddNodeMutation, InsertNodeMutation, DeleteNodeMutation, FormConnectionMutation, BreakConnectionMutation, RotateTreeMutation, BacksolveMutation, SimplifyMutation, RandomizeMutation, OptimizeMutation, DoNothingMutation.

Built-in plugins

SymbolicRegression.AdaptiveParsimonyModule.AdaptiveParsimonyPlugin Type
julia
AdaptiveParsimonyPlugin <: AbstractPlugin

Frequency-weighted parsimony adjustments at two engine decision points:

  • tournament: when true, multiplies tournament-selection cost by exp(adaptive_parsimony_scaling * f), where f is the recent relative frequency of equations at this complexity. Biases selection against over-represented complexities.

  • mutation_acceptance: when true, multiplies the mutation acceptance probability by old_freq / new_freq. Biases mutation acceptance away from over-represented complexities.

Both default to true. Frequency statistics are tracked per output (per dataset in multi-target regression), so different outputs don't interfere with each other's complexity distributions. Equivalent to the legacy Options(; use_frequency_in_tournament=true, use_frequency=true) flags, which are auto-translated into this plugin during Options construction.

Experimental

Part of the experimental plugin interface.

source
SymbolicRegression.AdaptiveMutationWeightsModule.AdaptiveMutationWeightsPlugin Type
julia
AdaptiveMutationWeightsPlugin <: AbstractPlugin

Online-adapt per-mutation weights from the search's own success statistics. For each mutation kind, the plugin tracks attempts and strictly improving successes in the configured reward metric and adjusts a multiplicative factor applied to that mutation's base weight, updated each mutation via an EMA over the smoothed success-ratio with a floor clamp. Only the sampled mutation's multiplier is updated, then active multipliers are normalized to unit mean.

Statistics persist independently for each population.

Fields

  • smoothing::Float64 = 0.02: EMA factor for the multiplier update.

  • floor::Float64 = 0.05: clamp range for the sampled mutation's target ratio ([floor, 1/floor]) before the EMA update and mean normalization.

  • reward::Symbol = :cost: objective used to count improvements. Supported values are :cost and :loss.

  • adaptation_strength::Float64 = 0.5: strength of the learned multiplier in log space. Zero preserves the original mutation weights, while one applies the learned multipliers without regularization.

Mutation kinds excluded from accounting are declared by dispatch on skip_in_adaptive_weights; by default SimplifyMutation and DoNothingMutation are skipped. To add your own:

julia
SymbolicRegression.AdaptiveMutationWeightsModule.skip_in_adaptive_weights(::MyMutation) = true

Experimental

source
SymbolicRegression.SimulatedAnnealingModule.SimulatedAnnealingPlugin Type
julia
SimulatedAnnealingPlugin(; alpha=0.1)

Couple the search's mutation pipeline to a temperature schedule that sweeps linearly from 1.0 at the first cycle of an iteration to 0.0 at the last. The plugin uses its current temperature for two things:

  1. Constant-perturbation magnitude: via condition_mutation!, the plugin multiplies ConstantMutationContext.scale by the current temperature.

  2. Mutation acceptance: via mutation_acceptance_multiplier, the plugin contributes exp(-(after_cost - before_cost) / (T * alpha)) to the engine's combined accept probability — multiple plugins' multipliers compose against a single rand draw so the legacy annealing × frequency_parsimony semantics are preserved.

The "temperature" concept is entirely local to this plugin's mutable state; nothing else in the engine references it.

Experimental

source
SymbolicRegression.MutationBurstModule.MutationBurstPlugin Type
julia
MutationBurstPlugin(; retry_attempts=4, compound_probability=0.25, compound_max_steps=2)

Per-cycle local-search extensions to the basic single-mutation loop:

  • Retry (outer): if the engine rejects a mutation, re-run next_generation against the original parent up to retry_attempts total times. Break on the first accepted result.

  • Compound burst (inner): after an accepted mutation, with probability compound_probability chain another mutation step on the result, up to compound_max_steps total accepted mutations.

retry_attempts = 1 disables retry; compound_probability = 0 disables compound bursts; the combination reproduces the upstream single-mutation loop.

Like all hooks, wrap_mutation_step composes across plugins in options.plugins tuple order: earlier plugins wrap outside later ones. The retry-around-compound nesting above is internal to this plugin's own wrap_mutation_step implementation and is not affected by tuple order.

Extra experimental

The retry/compound mechanisms and their composition were validated on a single benchmark suite — they may change behavior, defaults, or config-knob names in minor releases until exercised more broadly.

source

Abstract type

SymbolicRegression.CoreModule.PluginModule.AbstractPlugin Type
julia
AbstractPlugin

Abstract type for a plugin's configuration. A plugin instance is an immutable struct whose fields are the user-tunable settings of the plugin. Mutable runtime data is held separately in a state object returned by init_plugin_state.

A search may have any number of plugins active simultaneously, supplied via the plugins = (Plugin1(), Plugin2(), ...) keyword on Options. The engine iterates the tuple at each lifecycle point and dispatches the appropriate hook on the plugin type.

Plugin state

State is duck-typed: it can be any object, including a NamedTuple, a Dict, or a mutable struct of your own. Every hook dispatches on the plugin type, so the state needs no particular supertype.

Thread / Multiprocessing Safety:

  • on_generation_end! runs serially on the head node — safe to mutate.

  • on_cycle_end! and on_mutation_end! run on workers, against per-population states built by fork_plugin_state before the first dispatch and retained across later dispatches. Cross-worker communication must use Channel / RemoteChannel.

  • init_member reads the head node's per-output state during initial population creation. In multithreading mode, multiple population-creation tasks may call it concurrently — keep it read-only or thread-safe.

  • In multiprocessing mode, plugin config is serialized to workers via options.plugins, and state is forked on the head by fork_plugin_state and shipped with the dispatch. Workers never construct their own state.

Experimental

The plugin interface is experimental. Hook signatures may change in minor releases until validated by multiple in-tree plugins.

source