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, Signals 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 to UndefVariant(). Variants can be used to choose different computation strategies for different types of signals within the compute! 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 (see get_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 via compute!. 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. See add_dependency! for more details.
  • Controlled Listening: Allows dependencies to be added without automatically listening to their updates (listen=false in add_dependency!).
  • GraphViz Support: Signals can be visualized using the GraphViz.jl package. Cortex automatically loads the visualization extension when GraphViz.jl package is loaded in the current Julia session. Read more about it in the Visualization section.
Cortex.SignalType
Signal()
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 signal
  • V 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!

source
Cortex.UndefValueType
UndefValue

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.

source
Cortex.UndefVariantType
UndefVariant

A singleton type used to represent an undefined or uninitialized variant within a Signal. This indicates that the signal has no variant defined.

source
Cortex.value_typeFunction
value_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.

source
Cortex.variant_typeFunction
variant_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.

source

Core Operations

Note

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
Warning

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.isa_variantFunction
isa_variant(s::Signal, T)

Check if the currently set variant of the signal s is of type T.

source
Cortex.set_value!Method
set_value!(s::Signal, value)

Set the value of the signal s. Notifies all the active listeners of the signal.

Note

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.

source

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!Method
add_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: If true, 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: If true, marks the dependency as weak. Weak dependencies only require is_computed to be true (not necessarily older) for the dependent signal signal to potentially become pending.
  • listen::Bool = true: If true, signal will be notified when dependency is updated. If false, dependency is added, but signal will not automatically be notified of updates to dependency.
  • check_computed::Bool = true: If true, the function will check if dependency 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.

Note

Signals can only depend on other signals with the same type parameters {D, V}. Attempting to add a dependency with different type parameters will result in an error.

source
Cortex.get_listenersMethod
get_listeners(s::Signal)

Get the list of signals that listen to the signal s (i.e., signals that depend on s).

source

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)
Note

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!Method
compute!(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: If true, the signal will be computed even if it is not pending.
  • skip_if_no_listeners::Bool = true: If true, the function will not compute the signal if it has no listeners.
source
Cortex.compute_value!Method
compute_value!(strategy, signal, dependencies)

Compute the value of the signal signal using the given strategy. The strategy must implement this method. See also compute!.

source

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)
Example block output

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)
Example block output

Which we can also compute using the same function:

compute_if_not_computed(derived)

Cortex.get_value(derived) # 6
6
GraphViz.load(derived)
Example block output
Cortex.process_dependencies!Method
process_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 a Signal (a dependency) as an argument and returns a Bool. It should return true if it considered the dependency processed, and false otherwise. The specific logic for this determination (e.g., checking if a dependency is pending before processing) is up to f.
  • signal::Signal: The signal whose dependencies are to be processed.

Keyword Arguments:

  • retry::Bool = false: If true, and an intermediate dependency's own sub-dependencies were processed (i.e., the recursive call to process_dependencies! for the intermediate dependency returned true because f returned true for at least one sub-dependency), then the function f will be called again on the intermediate dependency itself. This allows for a second attempt by f to process the intermediate dependency after its own prerequisites might have been met by processing its sub-dependencies.

Returns:

  • Bool: true if the function f returned true for at least one dependency encountered (either directly or recursively through an intermediate one). Returns false if f returned false for all dependencies it was applied to.

Behavior Details:

  • For each dependency of signal:
    1. f(dependency) is called.
    2. If f(dependency) returns true, this dependency is considered processed by f.
    3. If f(dependency) returns false AND the dependency is marked as intermediate: a. process_dependencies!(f, dependency; retry=retry) is called recursively. b. If this recursive call returns true (meaning f processed at least one sub-dependency of the intermediate one) AND retry is true, then f(dependency) is called again.
  • The function tracks whether f returned true for any dependency it was applied to, at any level of recursion (for intermediate dependencies) or direct application, and returns this aggregated result.
source

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)
Example block output
Documentation for GraphViz.jl extension
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 display
  • max_dependencies::Int = 10: Maximum number of dependencies to show per signal
  • max_listeners::Int = 10: Maximum number of listeners to show per signal
  • variant_to_string_fn = string: Function to convert signal variant to string
  • show_value::Bool = true: Whether to display signal values
  • show_variant::Bool = true: Whether to display signal variants
  • show_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)

Advanced Topic

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 (see process_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 AND IsFresh.

When set_value! is called on a signal:

  1. The IsFresh flags for all its own dependencies are cleared (as their values have now been "used").
  2. For each of its listeners, the original signal (which just got a new value) is marked as IsComputed and IsFresh 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.