Signals: The Core of Reactivity
At the heart of Cortex.jl's reactivity system lies the Signal
. Signals are being used extensively in the inference to track the necessary or stale computations.
Concept
Think of a Signal
as a container for a value that can change over time. When this change will happen is not known beforehand. 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.
An every day example would be 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 variant
, and maintains lists of its dependencies and listeners. When a signal is updated via set_value!
or compute!
, 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 (or
UndefValue()
if the value is not set). - Variant: Can store an arbitrary variant (
get_variant
), defaulting toUndefVariant()
. Variants can be used to choose different computation strategies for different types of signals within thecompute!
function and provide type-safe signal classification. - Dependency Tracking: Knows which other signals it depends on (see
get_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!
. Read more about the pending state in the Pending State section. - External Computation: Relies on the
compute!
function and a provided strategy to update its value based on dependencies. - Traversal of Dependencies: The
process_dependencies!
function can be used to traverse the dependency graph and apply a custom logic to the 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!
). - GraphViz Support: Signals can be visualized using the
GraphViz.jl
package. Cortex automatically loads the visualization extension whenGraphViz.jl
package is loaded in the current Julia session. Read more about it in the Visualization section.
Cortex.Signal
— TypeSignal()
Signal(value; variant::Any = UndefVariant())
Signal(::Type{D}, ::Type{V}, value::D, variant::V) where {D, V}
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 variant
field can be used to store arbitrary variant information about the signal. Default value is UndefVariant()
.
A signal has two type parameters:
D
is the type of the value, which can be stored in the signalV
is the type of the variant of the signal
Note, that the signal can have dependencies and listeners only on signals that have the same type parameters.
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.
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.UndefVariant
— TypeUndefVariant
A singleton type used to represent an undefined or uninitialized variant within a Signal
. This indicates that the signal has no variant defined.
Cortex.value_type
— Functionvalue_type(::Signal{D}) where {D}
Get the type of specified in the signal's type parameter D
. Note, that this is not the same as the type of the value stored in the signal. For example, the value_type
of a signal with type parameter Any
is Any
, but the value of the signal can be Int
. The value_type
indicates all possible types of the value that can be stored in the signal.
Cortex.variant_type
— Functionvariant_type(::Signal{D, V}) where {D, V}
Get the type of specified in the signal's type parameter V
. Note, that this is not the same as the type of the variant stored in the signal. For example, the variant_type
of a signal with type parameter Any
is Any
, but the variant of the signal can be Int
. The variant_type
indicates all possible types of the variant that can be stored in the signal.
Core Operations
Before we proceed, we load the package itslef and we also load the GraphViz.jl
package in order to enable the visualization extension.
using GraphViz # Enable the visualization extension
To get the SVG representation of the signal graph, we can use the GraphViz.load
function.
Here are some basic examples demonstrating how to use signals.
Creating Signals and Checking Properties
Signal can be created using the Cortex.Signal
constructor
a_signal_with_no_value = Cortex.Signal()
Signal(value=#undef, pending=false)
By default, the signal has no value. We can check this by calling the Cortex.get_value
function.
Cortex.get_value(a_signal_with_no_value)
Cortex.UndefValue()
We can also create a signal with an initial value.
a_signal_with_value = Cortex.Signal(10)
Signal(value=10, pending=false)
We can check the value of the signal by calling the Cortex.get_value
function.
Cortex.get_value(a_signal_with_value)
10
Additionally, we can check if the signal is computed with the Cortex.is_computed
function.
Cortex.is_computed(a_signal_with_no_value)
false
Cortex.is_computed(a_signal_with_value)
true
Signals themselves do not know how to compute their value. This is done externally via the compute!
function. However, in order to update the value of a signal, we need to know if the signal is pending. If signal is pending, it means that it needs to be recomputed. On the contrary, if the signal is not pending, it means that the value is up to date and we can use the value without recomputing it. Normally, the signal becomes pending automatically when its dependencies are updated.
We can check if a signal is pending with the Cortex.is_pending
function.
Cortex.is_pending(a_signal_with_value)
false
Since our signals does not have any dependencies, it is not pending. We will talk more about the dependencies and the pending state in the Adding Dependencies section.
We can manually update a signal's value with set_value!
. The set_value!
function doesn't check if the signal is pending or not. It only updates the value of the signal. Read the Updating Signal Values section for a more structured way to update a signal's value.
some_signal = Cortex.Signal()
Cortex.get_value(some_signal)
Cortex.UndefValue()
Cortex.set_value!(some_signal, 99.0)
Cortex.get_value(some_signal)
99.0
Important: 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.
Signal Variant
You can optionally specify a variant
. Specifying a variant is useful when you want to choose different computation strategies for different types of signals within the compute!
function.
# Signal with variant
signal_with_variant = Cortex.Signal(10, variant=Dict(:info => "flag", :some_value => 10))
Signal(value=10, pending=false, variant=Dict{Symbol, Any}(:some_value => 10, :info => "flag"))
The variant can be accessed with the Cortex.get_variant
function and modified with the Cortex.set_variant!
function.
Cortex.get_variant(signal_with_variant)
Dict{Symbol, Any} with 2 entries:
:some_value => 10
:info => "flag"
Cortex.set_variant!(signal_with_variant, "hello world!")
Cortex.get_variant(signal_with_variant)
"hello world!"
The variant can be any object, including complex algebraic data types for type-safe signal classification.
signal_with_simple_variant = Cortex.Signal(variant="simple_string_variant")
Signal(value=#undef, pending=false, variant="simple_string_variant")
API Reference
Cortex.get_value
— Methodget_value(s::Signal)
Get the current value of the signal s
.
Cortex.get_variant
— Methodget_variant(s::Signal)
Get the variant of the signal s
.
Cortex.set_variant!
— Methodset_variant!(s::Signal, variant)
Set the variant of the signal s
to the given variant
.
Cortex.isa_variant
— Functionisa_variant(s::Signal, T)
Check if the currently set variant of the signal s
is of type T
.
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!
.
Cortex.set_value!
— Methodset_value!(s::Signal, value)
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.
Adding Dependencies
Signals can depend on other signals. This is particularly useful when you want to compute a signal's value based on the values of other signals. To add a new dependency, use add_dependency!
. 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 source_1 and source_2
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
Here we can also use the GraphViz.jl
package to visualize the dependency graph.
GraphViz.load(derived)
By default, the visualization uses different colors and styles to distinguish between different types of dependencies as well as their pending states. Read more about it in the Visualization section.
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
GraphViz.load(derived)
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:
some_source = Cortex.Signal()
derived = Cortex.Signal()
Cortex.add_dependency!(derived, some_source)
Cortex.is_pending(derived) # false
false
GraphViz.load(derived)
Cortex.set_value!(some_source, 1)
Cortex.is_pending(derived) # true
true
GraphViz.load(derived)
After setting the value of some_source
, derived
becomes pending because some_source
is now computed and fresh with respect to derived
.
Here, we can compute a new value for the derived signal based on the new value of some_source
:
Cortex.set_value!(derived, 2 * Cortex.get_value(some_source))
Cortex.get_value(derived) # 2
2
GraphViz.load(derived)
As we can see, the derived signal is no longer in the pending state after calling the set_value!
function. It is implied that the set_value!
function is only called on signals that are pending. See the Computing Signal Values section for a more structured way to compute a signal's value.
API Reference
Cortex.add_dependency!
— Methodadd_dependency!(signal::Signal, dependency::Signal; weak::Bool = false, listen::Bool = true, intermediate::Bool = false, check_computed::Bool = true)
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)
Get the list of signals that the signal s
depends on.
Cortex.get_listeners
— Methodget_listeners(s::Signal)
Get the list of signals that listen to the signal s
(i.e., signals that depend on s
).
Types of Dependencies
Signals might have different types of dependencies through options in add_dependency!
. Different types of dependencies have different purpose and behavior. Most importantly, they affect the way the signal becomes pending.
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
GraphViz.load(derived)
Cortex.set_value!(strong_dependency, 10)
Cortex.is_pending(derived) # true
true
GraphViz.load(derived)
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).
We can still update the weak dependency:
Cortex.set_value!(weak_dependency, 10)
Cortex.is_pending(derived) # true
true
GraphViz.load(derived)
As we can see, the derived signal remains in the pending state, but now it can also use the fresh value of the weak dependency.
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 within signals where some signals serve as connectors between other signals. This might be exploited to create a reduction operation on a collection of signals and use its result as a dependency for another signal.
some_dependency_1 = Cortex.Signal()
some_dependency_2 = Cortex.Signal()
intermediate_dependency = Cortex.Signal()
derived = Cortex.Signal()
Cortex.add_dependency!(derived, intermediate_dependency; intermediate=true)
Cortex.add_dependency!(intermediate_dependency, some_dependency_1)
Cortex.add_dependency!(intermediate_dependency, some_dependency_2)
GraphViz.load(derived)
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()
s_listener = Cortex.Signal()
Cortex.add_dependency!(s_non_listener, s_source; listen=false)
Cortex.add_dependency!(s_listener, s_source)
Cortex.is_pending(s_non_listener) # false
false
GraphViz.load(s_source)
Now, normally, if we update the value of s_source
, all listeners should be notified and become pending. However, since we set listen=false
for the s_non_listener
, it is not notified and is not changing its pending state.
# Update s_source. s_non_listener is NOT notified.
Cortex.set_value!(s_source, 6)
Cortex.is_pending(s_non_listener) # false
false
GraphViz.load(s_source)
Computing Signal Values
We demonstrated how can we set a signal's value manually with set_value!
. Cortex provides a more structured and safer way to compute a signal's value with the compute!
function. The compute!
function takes a strategy (often a simple function) to calculate the new value based on dependencies. Computing a signal typically clears its pending state.
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
GraphViz.load(signal_to_be_computed)
# 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
GraphViz.load(signal_to_be_computed)
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.
# 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 variant if available
variant = Cortex.get_variant(signal)
base_sum = sum(Cortex.get_value, dependencies)
offset = variant isa Dict && haskey(variant, :offset) ? variant[:offset] : 0
return strategy.multiplier * base_sum + offset
end
Now, you can use this strategy with your signals:
strategy = CustomStrategy(2)
signal_with_variant = Cortex.Signal(variant=Dict(:offset => 10))
Cortex.add_dependency!(signal_with_variant, signal_1)
Cortex.add_dependency!(signal_with_variant, signal_2)
Cortex.compute!(strategy, signal_with_variant)
Cortex.get_value(signal_with_variant) # 94
94
Cortex.compute!(CustomStrategy(3), signal_with_variant; force=true)
Cortex.get_value(signal_with_variant) # 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/Traversal of 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; variant = :signal1) # computed
signal2 = Cortex.Signal(; variant = :signal2) # not computed
signal3 = Cortex.Signal(3; variant = :signal3) # computed
intermediate_signal = Cortex.Signal(variant = :intermediate_signal)
Cortex.add_dependency!(intermediate_signal, signal2)
Cortex.add_dependency!(intermediate_signal, signal3)
derived = Cortex.Signal(variant = :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_variant(dependency_signal), " is not computed")
else
println("The dependency signal: ", Cortex.get_variant(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
Let's see if that actually the case by visualizing the dependency graph:
GraphViz.load(derived)
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; variant = :signal1)
signal2 = Cortex.Signal(2; variant = :signal2)
signal3 = Cortex.Signal(3; variant = :signal3)
intermediate_signal = Cortex.Signal(variant = :intermediate_signal)
Cortex.add_dependency!(intermediate_signal, signal2)
Cortex.add_dependency!(intermediate_signal, signal3)
derived = Cortex.Signal(variant = :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_variant(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
GraphViz.load(derived)
Which we can also compute using the same function:
compute_if_not_computed(derived)
Cortex.get_value(derived) # 6
6
GraphViz.load(derived)
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.
Signal Visualization
The Cortex.jl
package provides a function to visualize the dependency graph of a signal. This function becomes available when the GraphViz.jl
package is installed in your environment. When visualized, the signal is represented as a node, as well as its dependencies and listeners. The colors of the nodes depend on their state. The connections between the nodes depend on the type of the dependency. To demonstrate this, let's consider the following example:
using GraphViz # enables visualization
# The main signal to visualize
s = Cortex.Signal()
# Direct dependencies of `s`
dep1 = Cortex.Signal()
dep2 = Cortex.Signal(; variant = "dep2")
Cortex.add_dependency!(s, dep1; intermediate = true, weak = true)
Cortex.add_dependency!(s, dep2; weak = true)
# Dependencies of `dep1`
dep1_for_dep1 = Cortex.Signal(3)
dep1_for_dep2 = Cortex.Signal(4)
Cortex.add_dependency!(dep1, dep1_for_dep1)
Cortex.add_dependency!(dep1, dep1_for_dep2; intermediate = true, weak = true)
# Dependencies of `dep2`
dep1_for_dep2 = Cortex.Signal()
Cortex.add_dependency!(dep2, dep1_for_dep2)
# Deep dependency of `dep1_for_dep2`
dep_for_dep1_for_dep2 = Cortex.Signal()
Cortex.add_dependency!(dep1_for_dep2, dep_for_dep1_for_dep2)
# Listeners of `s`
listener1_of_s = Cortex.Signal()
listener2_of_s = Cortex.Signal()
Cortex.add_dependency!(listener1_of_s, s)
Cortex.add_dependency!(listener2_of_s, s; listen = false)
# Listeners of `dep1`, but it won't be displayed for `s`
listener1_of_dep1 = Cortex.Signal()
Cortex.add_dependency!(listener1_of_dep1, dep1)
GraphViz.load(s)
GraphViz.load(s::Signal;
max_depth::Int = 2,
max_dependencies::Int = 10,
max_listeners::Int = 10,
variant_to_string_fn = string,
show_value::Bool = true,
show_variant::Bool = true,
show_listeners::Bool = true
) -> GraphViz.Graph
Creates a GraphViz visualization of a Signal and its dependency graph.
The visualization includes:
- The signal's value and variant (if present)
- Dependencies and their relationships
- Listeners and their states
- Visual indicators for pending, computed, and intermediate states
Arguments
s::Signal
: The signal to visualize
Keyword Arguments
max_depth::Int = 2
: Maximum depth of the dependency tree to displaymax_dependencies::Int = 10
: Maximum number of dependencies to show per signalmax_listeners::Int = 10
: Maximum number of listeners to show per signalvariant_to_string_fn = string
: Function to convert signal variant to stringshow_value::Bool = true
: Whether to display signal valuesshow_variant::Bool = true
: Whether to display signal variantsshow_listeners::Bool = true
: Whether to display signal listeners
Visual Styling
Nodes use different colors and styles to indicate their state:
- Computed nodes: Light yellow background with bold text
- Pending nodes: Light blue background with bold text
- Regular nodes: White background
Edges have different styles based on dependency properties:
- Weak dependencies: Dashed lines
- Intermediate dependencies: Gray color
- Fresh dependencies: Blue color
- Fresh and intermediate: Cadet blue color
Listener edges:
- Active listeners: Solid black lines
- Inactive listeners: Dotted gray lines
Returns
A GraphViz.Graph
object representing the signal's dependency graph.
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.