Signals: The Core of Reactivity
At the heart of Cortex.jl's reactivity system lies the Signal
.
Concept
Think of a Signal
as a container for a value that can change over time. The key idea is that other parts of your system can depend on a signal. When the signal's value changes, anything that depends on it (its listeners) is notified, allowing the system to react on changes and recompute values.
Imagine a spreadsheet cell. When you change the value in one cell (like A1
), other cells that use A1
in their formulas (like B1 = A1 * 2
) automatically update. A Signal
is like that cell – it holds a value, and changes can trigger updates elsewhere.
More technically, Signal
s form a directed graph (potentially cyclic). Each Signal
node stores a value, an optional type
identifier (UInt8
), optional metadata
, and maintains lists of its dependencies and listeners. When a signal is updated via set_value!
, it propagates a notification to its direct listeners, potentially marking them as 'pending'. This 'pending' state indicates that the signal's value might be stale and needs recomputation. The actual recomputation logic is defined externally via the compute!
function. A signal may become 'pending' if all its dependencies meet the criteria: weak dependencies are computed, and strong dependencies are computed and fresh (i.e., have new, unused values). This also means that a signal never recomputes its own value, as it must be done externally.
Key Features
- Value Storage: Holds the current value.
- Type Identifier: Stores an optional
UInt8
type (get_type
), defaulting to0x00
. This might be particularly useful for choosing different computation strategies for different types of signals within thecompute!
function. - Optional Metadata: Can store arbitrary metadata (
get_metadata
), defaulting toUndefMetadata()
. - Dependency Tracking: Knows which other signals it depends on (
dependencies
) and which signals depend on it (seeget_listeners
). - Notification: When updated via
set_value!
, it notifies its active listeners. - Pending State: Can be marked as
is_pending
if its dependencies have updated appropriately, signaling a need for recomputation viacompute!
. - External Computation: Relies on the
compute!
function and a provided strategy to update its value based on dependencies. - Weak Dependencies: Supports 'weak' dependencies, which influence the pending state based only on whether they are computed (
is_computed
), not their 'fresh' status in the same way as strong dependencies. Seeadd_dependency!
for more details. - Controlled Listening: Allows dependencies to be added without automatically listening to their updates (
listen=false
inadd_dependency!
).
Cortex.Signal
— TypeSignal()
Signal(value; type::UInt8 = 0x00, metadata::Any = UndefMetadata())
A reactive signal that holds a value and tracks dependencies as well as notifies listeners when the value changes. If created without an initial value, the signal is initialized with UndefValue()
. The metadata
field can be used to store arbitrary metadata about the signal. Default value is UndefMetadata()
.
A signal is said to be 'pending' if it is ready for potential recomputation (due to updated dependencies). However, a signal is not recomputed immediately when it becomes pending. Moreover, a Signal does not know how to recompute itself. The recomputation logic is defined separately with the compute!
function.
Signals form a directed graph where edges represent dependencies. When a signal's value is updated via set_value!
, it notifies its active listeners.
A signal may become 'pending' if all its dependencies meet the following criteria:
- all its weak dependencies have computed values, AND
- all its strong dependencies have computed values and are older than the listener.
A signal can depend on another signal without listening to it, see add_dependency!
for more details.
The type
field is an optional UInt8
type identifier. It might be useful to choose different computation strategies for different types of signals within the compute!
function.
See also: add_dependency!
, set_value!
, compute!
, process_dependencies!
Cortex.UndefValue
— TypeUndefValue
A singleton type used to represent an undefined or uninitialized state within a Signal
. This indicates that the signal has not yet been computed or has been invalidated.
Cortex.UndefMetadata
— TypeUndefMetadata
A singleton type used to represent an undefined or uninitialized state within a Signal
. This indicates that the signal has no metadata.
Core Operations
Here are some basic examples demonstrating how to use signals.
Creating Signals and Checking Properties
Signals can be created with or without an initial value. You can optionally specify a type
identifier and metadata
.
import Cortex
# Create signals
s1 = Cortex.Signal(10)
Signal(value=10, pending=false)
# Initial value, computed=true
s2 = Cortex.Signal(5)
Signal(value=5, pending=false)
# No initial value, computed=false
s3 = Cortex.Signal()
Signal(value=#undef, pending=false)
# Signal with type and metadata
s4 = Cortex.Signal(true; type=0x01, metadata=Dict(:info => "flag"))
Signal(value=true, pending=false, type=0x01, metadata=Dict(:info => "flag"))
Checking Properties of Signals
Cortex.get_value(s1) # 10
10
Cortex.get_value(s3) # Cortex.UndefValue()
Cortex.UndefValue()
Cortex.is_computed(s1) # true
true
Cortex.is_computed(s3) # false
false
Cortex.get_type(s1) # 0x00 (default)
0x00
Cortex.get_type(s4) # 0x01
0x01
Cortex.get_metadata(s1) # UndefMetadata() (default)
Cortex.UndefMetadata()
Cortex.get_metadata(s4) # Dict{Symbol, String}(:info => "flag")
Dict{Symbol, String} with 1 entry:
:info => "flag"
Cortex.get_value
— Methodget_value(s::Signal)
Get the current value of the signal s
.
Cortex.get_type
— Methodget_type(s::Signal) -> UInt8
Get the type identifier (UInt8) of the signal s
. Defaults to 0x00
if not specified during construction.
Cortex.get_metadata
— Methodget_metadata(s::Signal)
Get the metadata associated with the signal s
.
Cortex.is_computed
— Methodis_computed(s::Signal) -> Bool
Check if the signal s
has been computed (i.e., its value is not equal to UndefValue()
). See also: set_value!
.
Cortex.is_pending
— Methodis_pending(s::Signal) -> Bool
Check if the signal s
is marked as pending. This usually indicates that the signal's value is stale and needs recomputation. See also: compute!
, process_dependencies!
.
Adding Dependencies
Signals can depend on other signals. Use add_dependency!
to create these links. This populates the dependencies
list of the dependent signal and the listeners
list of the dependency.
source_1 = Cortex.Signal(1)
source_2 = Cortex.Signal(2)
derived = Cortex.Signal() # A signal that will depend on s1 and s2
Cortex.add_dependency!(derived, source_1)
Cortex.add_dependency!(derived, source_2)
length(Cortex.get_dependencies(derived)) # 2
2
length(Cortex.get_listeners(source_1)) # 1
1
length(Cortex.get_listeners(source_2)) # 1
1
Types of Dependencies
Cortex supports different types of dependencies through options in add_dependency!
:
Strong vs. Weak Dependencies
By default, dependencies are "strong," meaning they must be both computed and fresh (recently updated) for a signal to become pending. With weak=true
, a dependency only needs to be computed (not necessarily fresh) to contribute to the pending state.
weak_dependency = Cortex.Signal(1)
strong_dependency = Cortex.Signal(2)
derived = Cortex.Signal(3)
Cortex.add_dependency!(derived, weak_dependency; weak=true)
Cortex.add_dependency!(derived, strong_dependency)
Cortex.is_pending(derived) # false
false
Cortex.set_value!(strong_dependency, 10)
Cortex.is_pending(derived) # true
true
Here, even though weak_dependency
has not been updated, derived
is still in the pending state because it only needs strong_dependency
to be updated and weak_dependency
only needs to be computed once (or set via constructor).
Listening vs. Non-listening Dependencies
With listen=true
(default), a signal is notified when its dependency changes. Setting listen=false
creates a dependency relationship without automatic notifications, useful when you want to manually control when a signal responds to changes.
s_source = Cortex.Signal()
s_non_listener = Cortex.Signal()
Cortex.add_dependency!(s_non_listener, s_source; listen=false)
Cortex.is_pending(s_non_listener) # false
false
# Update s_source. s_non_listener is NOT notified.
Cortex.set_value!(s_source, 6)
Cortex.is_pending(s_non_listener) # false
false
Intermediate Dependencies
Setting intermediate=true
marks a dependency as intermediate, which affects how process_dependencies!
traverses the dependency graph. This is useful for complex dependency trees where some nodes serve as connectors between different parts of the graph.
Cortex.add_dependency!
— Methodadd_dependency!(signal::Signal, dependency::Signal; weak::Bool = false, listen::Bool = true, intermediate::Bool = false)
Add dependency
to the list of dependencies for signal signal
. Also adds signal
to the list of listeners for dependency
.
Arguments:
signal::Signal
: The signal to add a dependency to.dependency::Signal
: The signal to be added as a dependency.
Keyword Arguments:
intermediate::Bool = false
: Iftrue
, marks the dependency as intermediate. Intermediate dependencies
have an effect on the process_dependencies!
function. See the documentation of process_dependencies!
for more details. By default, the added dependency is not intermediate.
weak::Bool = false
: Iftrue
, marks the dependency as weak. Weak dependencies only requireis_computed
to be true (not necessarily older) for the dependent signalsignal
to potentially become pending.listen::Bool = true
: Iftrue
,signal
will be notified whendependency
is updated. Iffalse
,dependency
is added, butsignal
will not automatically be notified of updates todependency
.check_computed::Bool = true
: Iftrue
, the function will check ifdependency
is already computed.
If so, it will notify signal
immediately. Note that if listen
is set to false, further updates to dependency
will not trigger notifications to signal
.
The same dependency should not be added multiple times. Doing so will result in wrong notification behaviour and likely will lead to incorrect results. Note that this function does nothing if signal === dependency
.
Cortex.get_dependencies
— Methodget_dependencies(s::Signal) -> Vector{Signal}
Get the list of signals that the signal s
depends on.
Cortex.get_listeners
— Methodget_listeners(s::Signal) -> Vector{Signal}
Get the list of signals that listen to the signal s
(i.e., signals that depend on s
).
Pending State
A signal becomes pending (is_pending
returns true
) when its dependencies are updated in a way that satisfies the pending criteria (all weak dependencies are computed, and all strong dependencies are fresh and computed). Adding a computed dependency can also immediately mark a signal as pending.
Updating a dependency can mark listeners as pending:
source_1 = Cortex.Signal(1)
source_2 = Cortex.Signal(2)
derived = Cortex.Signal() # A signal that will depend on s1 and s2
Cortex.add_dependency!(derived, source_1)
Cortex.add_dependency!(derived, source_2)
Cortex.is_pending(derived) # true
true
derived
is pending because both source_1
and source_2
have been computed and are considered fresh with respect to derived
at the time of dependency addition. Let's try a different example:
uncomputed_source = Cortex.Signal()
derived = Cortex.Signal()
Cortex.add_dependency!(derived, uncomputed_source)
Cortex.is_pending(derived) # false
false
Cortex.set_value!(uncomputed_source, 1)
Cortex.is_pending(derived) # true
true
After setting the value of uncomputed_source
, derived
becomes pending because uncomputed_source
is now computed and fresh with respect to derived
.
Setting Values
Use set_value!
to update a signal's value. It also consumes the 'freshness' of its own dependencies and updates the 'fresh' and 'computed' status for its listeners, potentially marking them as pending.
Normally, it is implied that set_value!
must be called only on signals that are pending. Also see the compute!
function for a more general way to update signal values.
source = Cortex.Signal(1)
derived = Cortex.Signal()
Cortex.add_dependency!(derived, source)
Cortex.is_pending(derived), Cortex.is_computed(derived)
(true, false)
Cortex.set_value!(derived, 99.0)
Cortex.get_value(derived) # 99.0
99.0
Cortex.is_pending(derived), Cortex.is_computed(derived) # false, true
(false, true)
Cortex.set_value!
— Methodset_value!(s::Signal, value::Any)
Set the value
of the signal s
. Notifies all the active listeners of the signal.
This function is not a part of the public API. Additionally, it is implied that set_value!
must be called only on signals that are pending. Use compute!
for a more general way to update signal values.
Computing Signal Values
To compute a signal, use the compute!
function, providing a strategy (often a simple function) to calculate the new value based on dependencies. Computing a signal typically clears its pending state.
By default, compute!
throws an ArgumentError
if called on a signal that is not pending (is_pending
returns false
). You can override this check using the force=true
keyword argument.
signal_1 = Cortex.Signal(1)
signal_2 = Cortex.Signal(41)
signal_to_be_computed = Cortex.Signal()
Cortex.add_dependency!(signal_to_be_computed, signal_1)
Cortex.add_dependency!(signal_to_be_computed, signal_2)
Cortex.is_pending(signal_to_be_computed) # true
true
# Define a strategy (a function) to compute the value
compute_sum = (signal, deps) -> sum(Cortex.get_value, deps)
# Apply the strategy using compute!
Cortex.compute!(compute_sum, signal_to_be_computed)
Cortex.get_value(signal_to_be_computed) # 42
42
Cortex.is_pending(signal_to_be_computed) # false
false
# This would normally throw an error:
# compute!(compute_sum, signal_to_be_computed)
# But we can force it:
Cortex.compute!(compute_sum, signal_to_be_computed; force=true)
Cortex.get_value(signal_to_be_computed) # 42
42
Custom Compute Strategies
You can define custom types and methods to implement more complex computation logic beyond simple functions. This allows strategies to hold their own state or parameters.
First, define a struct for your strategy:
struct CustomStrategy
multiplier::Int
end
Then, implement the compute_value!
method for your strategy type:
function Cortex.compute_value!(strategy::CustomStrategy, signal::Cortex.Signal, dependencies)
# Example: Use signal's metadata if available
meta = Cortex.get_metadata(signal)
base_sum = sum(Cortex.get_value, dependencies)
offset = meta isa Dict && haskey(meta, :offset) ? meta[:offset] : 0
return strategy.multiplier * base_sum + offset
end
Now, you can use this strategy with your signals:
strategy = CustomStrategy(2)
signal_with_meta = Cortex.Signal(metadata=Dict(:offset => 10))
Cortex.add_dependency!(signal_with_meta, signal_1)
Cortex.add_dependency!(signal_with_meta, signal_2)
Cortex.compute!(strategy, signal_with_meta)
Cortex.get_value(signal_with_meta) # 94
94
Cortex.compute!(CustomStrategy(3), signal_with_meta; force=true)
Cortex.get_value(signal_with_meta) # 136
136
Cortex.compute!
— Methodcompute!(s::Signal, strategy; force::Bool = false, skip_if_no_listeners::Bool = true)
Compute the value of the signal s
using the given strategy
. The strategy must implement compute_value!
method. If the strategy is a function, it is assumed to be a function that takes the signal and a vector of signal's dependencies as arguments and returns a value. Be sure to call compute!
only on signals that are pending. Calling compute!
on a non-pending signal will result in an error.
When skip_if_no_listeners
is set to true
, the function will not compute the signal if it has no listeners. If skip_if_no_listeners
is set to false
, the function will compute the signal even if it has no listeners. This is useful for signals that are computed on demand with no listeners.
Keyword Arguments:
force::Bool = false
: Iftrue
, the signal will be computed even if it is not pending.skip_if_no_listeners::Bool = true
: Iftrue
, the function will not compute the signal if it has no listeners.
Cortex.compute_value!
— Methodcompute_value!(strategy, signal, dependencies)
Compute the value of the signal signal
using the given strategy
. The strategy must implement this method. See also compute!
.
Processing Dependency Trees of Signals with process_dependencies!
The process_dependencies!
function provides a powerful way to traverse and operate on the dependency graph of a signal. It's particularly useful for scenarios where you need more control over how dependencies are evaluated or when implementing custom update schedulers.
The function recursively applies a user-defined function f
to each dependency. f
should return true
if it considers the dependency "processed" by its own logic, and false
otherwise. process_dependencies!
propagates this status and can optionally retry processing an intermediate dependency if its own sub-dependencies were processed.
Use Cases:
- Implementing custom evaluation orders for signals.
- Performing actions on dependencies before computing a parent signal.
- Debugging or inspecting the state of a signal's dependency graph.
Conceptual Example:
Imagine you want to traverse the dependency graph of a signal and you want to log which ones were already computed and which ones are not.
signal1 = Cortex.Signal(1; metadata = :signal1) # computed
signal2 = Cortex.Signal(; metadata = :signal2) # not computed
signal3 = Cortex.Signal(3; metadata = :signal3) # computed
intermediate_signal = Cortex.Signal(metadata = :intermediate_signal)
Cortex.add_dependency!(intermediate_signal, signal2)
Cortex.add_dependency!(intermediate_signal, signal3)
derived = Cortex.Signal(metadata = :derived)
Cortex.add_dependency!(derived, signal1)
Cortex.add_dependency!(derived, intermediate_signal; intermediate=true)
function my_processing_function(dependency_signal::Cortex.Signal)
if !Cortex.is_computed(dependency_signal)
println("The dependency signal: ", Cortex.get_metadata(dependency_signal), " is not computed")
else
println("The dependency signal: ", Cortex.get_metadata(dependency_signal), " is computed. The value is: ", Cortex.get_value(dependency_signal))
end
# always return false to process all the dependencies
return false
end
Cortex.process_dependencies!(my_processing_function, derived)
The dependency signal: signal1 is computed. The value is: 1
The dependency signal: intermediate_signal is not computed
The dependency signal: signal2 is not computed
The dependency signal: signal3 is computed. The value is: 3
This example illustrates how you can inject custom logic into the dependency traversal. The actual computation or state change would happen within my_processing_function
. For example, here how can we compute!
the signal if it is not computed:
signal1 = Cortex.Signal(1; metadata = :signal1)
signal2 = Cortex.Signal(2; metadata = :signal2)
signal3 = Cortex.Signal(3; metadata = :signal3)
intermediate_signal = Cortex.Signal(metadata = :intermediate_signal)
Cortex.add_dependency!(intermediate_signal, signal2)
Cortex.add_dependency!(intermediate_signal, signal3)
derived = Cortex.Signal(metadata = :derived)
Cortex.add_dependency!(derived, signal1)
Cortex.add_dependency!(derived, intermediate_signal; intermediate=true)
function compute_if_not_computed(signal::Cortex.Signal)
if Cortex.is_pending(signal)
println("Computing the signal: ", Cortex.get_metadata(signal))
Cortex.compute!((signal, deps) -> sum(Cortex.get_value, deps), signal)
return true
end
return false
end
Cortex.process_dependencies!(compute_if_not_computed, derived; retry = true)
Computing the signal: intermediate_signal
Now, since we processed and computed all the dependencies, the derived signal should be in the pending state:
Cortex.is_pending(derived) # true
true
Which we can also compute using the same function:
compute_if_not_computed(derived)
Cortex.get_value(derived) # 6
6
Cortex.process_dependencies!
— Methodprocess_dependencies!(f::F, signal::Signal; retry::Bool = false) where {F}
Recursively processes the dependencies of a signal
using a provided function f
.
The function f
is applied to each direct dependency of signal
. If a dependency is marked as intermediate
and f
returns false
for it (indicating it was not processed by f
according to its own criteria), process_dependencies!
will then be called recursively on that intermediate dependency.
Arguments:
f::F
: A function (or callable object) that takes aSignal
(a dependency) as an argument and returns aBool
. It should returntrue
if it considered the dependency processed, andfalse
otherwise. The specific logic for this determination (e.g., checking if a dependency is pending before processing) is up tof
.signal::Signal
: The signal whose dependencies are to be processed.
Keyword Arguments:
retry::Bool = false
: Iftrue
, and an intermediate dependency's own sub-dependencies were processed (i.e., the recursive call toprocess_dependencies!
for the intermediate dependency returnedtrue
becausef
returnedtrue
for at least one sub-dependency), then the functionf
will be called again on the intermediate dependency itself. This allows for a second attempt byf
to process the intermediate dependency after its own prerequisites might have been met by processing its sub-dependencies.
Returns:
Bool
:true
if the functionf
returnedtrue
for at least one dependency encountered (either directly or recursively through an intermediate one). Returnsfalse
iff
returnedfalse
for all dependencies it was applied to.
Behavior Details:
- For each dependency of
signal
:f(dependency)
is called.- If
f(dependency)
returnstrue
, this dependency is considered processed byf
. - If
f(dependency)
returnsfalse
AND the dependency is marked asintermediate
: a.process_dependencies!(f, dependency; retry=retry)
is called recursively. b. If this recursive call returnstrue
(meaningf
processed at least one sub-dependency of the intermediate one) ANDretry
istrue
, thenf(dependency)
is called again.
- The function tracks whether
f
returnedtrue
for any dependency it was applied to, at any level of recursion (for intermediate dependencies) or direct application, and returns this aggregated result.
Internal Mechanics (For Developers)
The details in this section are primarily for developers working on or extending Cortex.jl's core reactivity. Regular users do not typically need to interact with these internal components directly.
The efficient tracking of dependency states (intermediate, weak, computed, and fresh) is managed internally by a structure associated with each signal, SignalDependenciesProps
.
SignalDependenciesProps
: Packed Dependency Information
To minimize overhead, the properties for each dependency are bit-packed into 4-bit "nibbles" within UInt64
chunks. This allows a single UInt64
to hold status information for 16 dependencies. The bits are assigned as follows (from LSB to MSB):
- Bit 1 (
0x1
):IsIntermediate
: True if the dependency is an intermediate one for processing logic (seeprocess_dependencies!
). - Bit 2 (
0x2
):IsWeak
: True if the dependency is weak. - Bit 3 (
0x4
):IsComputed
: True if the dependency itself holds a computed value. - Bit 4 (
0x8
):IsFresh
: True if the dependency has provided a new value that has not yet been consumed by the current signal's computation.
Determining Pending State
The is_pending(signal)
function relies on an internal check (currently is_meeting_pending_criteria
) that operates on these packed properties. A signal is considered to meet the criteria to become pending if, for every one of its dependencies:
(IsComputed AND (IsWeak OR IsFresh))
This means:
- A weak dependency must simply be
IsComputed
. - A strong (non-weak) dependency must be
IsComputed
ANDIsFresh
.
When set_value!
is called on a signal:
- The
IsFresh
flags for all its own dependencies are cleared (as their values have now been "used"). - For each of its listeners, the original signal (which just got a new value) is marked as
IsComputed
andIsFresh
in that listener's dependency properties. This, in turn, can cause the listener to become pending.
This bit-packed approach allows for efficient batch updates and checks across many dependencies.