Current section
Files
Jump to
Current section
Files
src/neuron_ltc.erl
%% @doc Liquid Time-Constant (LTC) neural processing unit for TWEANN networks.
%%
%% This module implements LTC neurons that have input-dependent time constants,
%% enabling adaptive temporal processing for time-series data and real-time
%% control tasks.
%%
%% == LTC vs Standard Neurons ==
%%
%% Standard neurons: output = f(sum(w_i * x_i) + bias)
%% LTC neurons: maintain internal state x(t) that evolves according to:
%% dx/dt = -[1/tau + f(x,I,theta)] * x + f(x,I,theta) * A
%%
%% CfC neurons use a fast closed-form approximation:
%% x_new = sigma(-f) * x_old + (1 - sigma(-f)) * h
%%
%% == Neuron Lifecycle ==
%%
%% 1. Spawned by cortex with initial state (including LTC parameters)
%% 2. Waits for signals from input connections
%% 3. Aggregates all inputs when complete
%% 4. Evaluates LTC dynamics (CfC or ODE)
%% 5. Forwards output to all output connections
%% 6. Maintains internal_state between evaluations
%% 7. Repeats from step 2
%%
%% == State Persistence ==
%%
%% Unlike standard neurons which are stateless between evaluations,
%% LTC neurons maintain persistent internal_state that carries information
%% across timesteps. This enables temporal memory and adaptive dynamics.
%%
%% == References ==
%%
%% [1] Hasani, R., Lechner, M., et al. (2021). "Liquid Time-constant Networks."
%% Proceedings of the AAAI Conference on Artificial Intelligence.
%%
%% [2] Hasani, R., Lechner, M., et al. (2022). "Closed-form Continuous-time
%% Neural Networks." Nature Machine Intelligence.
%%
%% @author Macula.io
%% @copyright 2025 Macula.io, Apache-2.0
-module(neuron_ltc).
-export([
start_link/1,
init/1,
forward/3,
backup/1,
reset_state/1,
get_state/1
]).
-record(state, {
id :: term(),
cortex_pid :: pid(),
neuron_type :: ltc | cfc, %% LTC mode (ODE or CfC)
time_constant :: float(), %% tau
state_bound :: float(), %% A
ltc_backbone_weights :: [float()], %% f() backbone weights
ltc_head_weights :: [float()], %% h() head weights
internal_state :: float(), %% x(t)
dt :: float(), %% time step for ODE mode
input_pids :: [pid()],
output_pids :: [pid()],
ro_pids :: [pid()], %% recurrent output PIDs
input_weights :: #{pid() => [{float(), float(), float(), list()}]},
bias :: float(),
acc_input :: #{pid() => [float()]},
expected_inputs :: non_neg_integer(),
input_timeout :: pos_integer()
}).
%% Default timeout for waiting on neuron inputs (10 seconds)
-define(DEFAULT_INPUT_TIMEOUT, 10000).
%% Default time step for ODE integration
-define(DEFAULT_DT, 0.1).
%% @doc Start an LTC neuron process.
%%
%% Options (in addition to standard neuron options):
%% - `neuron_type' - ltc (ODE) or cfc (closed-form), default: cfc
%% - `time_constant' - Base time constant tau, default: 1.0
%% - `state_bound' - State bound A, default: 1.0
%% - `ltc_backbone_weights' - Backbone network weights, default: []
%% - `ltc_head_weights' - Head network weights, default: []
%% - `internal_state' - Initial internal state, default: 0.0
%% - `dt' - Time step for ODE mode, default: 0.1
-spec start_link(map()) -> {ok, pid()}.
start_link(Opts) ->
Pid = spawn_link(?MODULE, init, [Opts]),
{ok, Pid}.
%% @doc Initialize the LTC neuron and enter the main loop.
-spec init(map()) -> no_return().
init(Opts) ->
Id = maps:get(id, Opts),
CortexPid = maps:get(cortex_pid, Opts),
NeuronType = maps:get(neuron_type, Opts, cfc),
TimeConstant = maps:get(time_constant, Opts, 1.0),
StateBound = maps:get(state_bound, Opts, 1.0),
BackboneWeights = maps:get(ltc_backbone_weights, Opts, []),
HeadWeights = maps:get(ltc_head_weights, Opts, []),
InternalState = maps:get(internal_state, Opts, 0.0),
Dt = maps:get(dt, Opts, ?DEFAULT_DT),
InputPids = maps:get(input_pids, Opts, []),
OutputPids = maps:get(output_pids, Opts, []),
RoPids = maps:get(ro_pids, Opts, []),
InputWeights = maps:get(input_weights, Opts, #{}),
Bias = maps:get(bias, Opts, 0.0),
InputTimeout = maps:get(input_timeout, Opts, ?DEFAULT_INPUT_TIMEOUT),
State = #state{
id = Id,
cortex_pid = CortexPid,
neuron_type = NeuronType,
time_constant = TimeConstant,
state_bound = StateBound,
ltc_backbone_weights = BackboneWeights,
ltc_head_weights = HeadWeights,
internal_state = InternalState,
dt = Dt,
input_pids = InputPids,
output_pids = OutputPids,
ro_pids = RoPids,
input_weights = InputWeights,
bias = Bias,
acc_input = #{},
expected_inputs = length(InputPids),
input_timeout = InputTimeout
},
loop(State).
%% @doc Send a signal to an LTC neuron.
%%
%% Called by sensors or other neurons to forward their output.
-spec forward(pid(), pid(), [float()]) -> ok.
forward(NeuronPid, FromPid, Signal) ->
NeuronPid ! {forward, FromPid, Signal},
ok.
%% @doc Request the neuron to backup its current weights and state.
%%
%% The neuron will send its weights and LTC parameters to the cortex.
-spec backup(pid()) -> ok.
backup(NeuronPid) ->
NeuronPid ! backup,
ok.
%% @doc Reset the internal state of the LTC neuron to zero.
%%
%% Useful when starting a new episode or sequence.
-spec reset_state(pid()) -> ok.
reset_state(NeuronPid) ->
NeuronPid ! reset_state,
ok.
%% @doc Get the current internal state of the LTC neuron.
%%
%% Returns the internal state asynchronously via message.
-spec get_state(pid()) -> ok.
get_state(NeuronPid) ->
NeuronPid ! {get_state, self()},
ok.
%% Internal functions
loop(State) ->
Timeout = State#state.input_timeout,
receive
{forward, FromPid, Signal} ->
NewState = handle_forward(FromPid, Signal, State),
loop(NewState);
backup ->
_ = handle_backup(State),
loop(State);
reset_state ->
loop(State#state{internal_state = 0.0});
{get_state, FromPid} ->
FromPid ! {ltc_state, State#state.id, State#state.internal_state},
loop(State);
{cortex, terminate} ->
ok;
{update_weights, NewWeights, NewBias} ->
NewState = State#state{
input_weights = NewWeights,
bias = NewBias
},
loop(NewState);
{update_ltc_params, Params} ->
NewState = update_ltc_params(State, Params),
loop(NewState);
%% Dynamic linking from constructor
{link, input_pids, InputPids} ->
NewState = State#state{
input_pids = InputPids,
expected_inputs = length(InputPids)
},
loop(NewState);
{link, output_pids, OutputPids} ->
loop(State#state{output_pids = OutputPids});
{link, ro_pids, RoPids} ->
loop(State#state{ro_pids = RoPids});
{link, input_weights, InputWeights} ->
loop(State#state{input_weights = InputWeights})
after Timeout ->
handle_input_timeout(State)
end.
%% @private Update LTC-specific parameters
update_ltc_params(State, Params) ->
State#state{
time_constant = maps:get(time_constant, Params, State#state.time_constant),
state_bound = maps:get(state_bound, Params, State#state.state_bound),
ltc_backbone_weights = maps:get(ltc_backbone_weights, Params,
State#state.ltc_backbone_weights),
ltc_head_weights = maps:get(ltc_head_weights, Params,
State#state.ltc_head_weights),
dt = maps:get(dt, Params, State#state.dt)
}.
%% @private Handle input timeout
handle_input_timeout(State) ->
MissingInputs = State#state.expected_inputs - maps:size(State#state.acc_input),
tweann_logger:warning("LTC Neuron ~p input timeout after ~pms, missing ~p inputs",
[State#state.id, State#state.input_timeout, MissingInputs]),
%% Continue waiting - neuron stays alive but logs the issue
loop(State).
handle_forward(FromPid, Signal, State) ->
#state{
acc_input = AccInput,
expected_inputs = ExpectedInputs
} = State,
%% Accumulate the signal
NewAccInput = maps:put(FromPid, Signal, AccInput),
ReceivedCount = maps:size(NewAccInput),
%% Check if we have all inputs
case ReceivedCount >= ExpectedInputs of
true ->
process_and_forward(State#state{acc_input = NewAccInput});
false ->
State#state{acc_input = NewAccInput}
end.
process_and_forward(State) ->
#state{
neuron_type = NeuronType,
time_constant = TimeConstant,
state_bound = StateBound,
ltc_backbone_weights = BackboneWeights,
ltc_head_weights = HeadWeights,
internal_state = InternalState,
dt = Dt,
output_pids = OutputPids,
ro_pids = RoPids,
input_weights = InputWeights,
bias = Bias,
acc_input = AccInput,
input_pids = InputPids
} = State,
%% Build input list and aggregate weighted signals
Inputs = build_inputs(InputPids, AccInput),
Weights = build_weights(InputPids, InputWeights),
%% Compute weighted input sum (standard aggregation)
Aggregated = aggregate_weighted_inputs(Inputs, Weights) + Bias,
%% Apply LTC dynamics
{NewInternalState, Output} = evaluate_ltc(
NeuronType,
Aggregated,
InternalState,
TimeConstant,
StateBound,
BackboneWeights,
HeadWeights,
Dt
),
%% Forward to all output connections
lists:foreach(
fun(OutputPid) ->
OutputPid ! {forward, self(), [Output]}
end,
OutputPids
),
%% Forward to recurrent outputs
lists:foreach(
fun(RoPid) ->
RoPid ! {forward, self(), [Output]}
end,
RoPids
),
%% Reset accumulated inputs but preserve internal state
State#state{
acc_input = #{},
internal_state = NewInternalState
}.
%% @private Evaluate LTC dynamics based on neuron type
evaluate_ltc(cfc, Input, InternalState, Tau, Bound, BackboneW, HeadW, _Dt) ->
Params = #{
backbone_weights => BackboneW,
head_weights => HeadW
},
ltc_dynamics:evaluate_cfc(Input, InternalState, Tau, Bound, Params);
evaluate_ltc(ltc, Input, InternalState, Tau, Bound, _BackboneW, _HeadW, Dt) ->
%% ODE mode - uses liquid_weights from backbone if present
ltc_dynamics:evaluate_ode(Input, InternalState, Tau, Bound, Dt).
build_inputs(InputPids, AccInput) ->
[{Pid, maps:get(Pid, AccInput, [0.0])} || Pid <- InputPids].
build_weights(InputPids, InputWeights) ->
[{Pid, maps:get(Pid, InputWeights, [{1.0, 0.0, 0.1, []}])} || Pid <- InputPids].
%% @private Aggregate weighted inputs using dot product
aggregate_weighted_inputs(Inputs, Weights) ->
signal_aggregator:dot_product(Inputs, Weights).
handle_backup(State) ->
#state{
id = Id,
cortex_pid = CortexPid,
input_weights = InputWeights,
bias = Bias,
time_constant = TimeConstant,
state_bound = StateBound,
ltc_backbone_weights = BackboneWeights,
ltc_head_weights = HeadWeights,
internal_state = InternalState
} = State,
%% Include LTC parameters in backup
LtcParams = #{
time_constant => TimeConstant,
state_bound => StateBound,
ltc_backbone_weights => BackboneWeights,
ltc_head_weights => HeadWeights,
internal_state => InternalState
},
CortexPid ! {backup, Id, InputWeights, Bias, LtcParams}.