2. Nodes

Semantically a Tridash program is composed of a set of stateful components called nodes, each holding a particular value at a given moment in time.

Each node has a set of dependency nodes. A change in the value of at least one of the dependency nodes causes the node to recompute its own value. The node is said to be an observer of its dependency nodes, as it actively observes and responds to changes in their values. Similarly, each node has a set of observer nodes which it notifies whenever its own value changes.

This dependency — observer relation is referred to as a binding.

Glossary

dependency

A node a is said to be a dependency of node b if a change in the value of a triggers a change in the value of b.

observer

A node a is said to be an observer of node b if a change in the value of b triggers a change in the value of a.

binding

A binding is a relation between two nodes a and b, in which one node a is a dependency of the other b, and likewise the other node b is its observer.

ancestor

A node a is said to be an ancestor of a node b if a is a dependency of b or it is an ancestor of a dependency of b.

successor

A node a is said to be a successor of a node b if a is an observer of b or it is a successor of an observer of b.

2.1. Declaring Nodes

In the global scope, nodes are created on the first reference, that is when their identifier first appears in source code. This can either be a declaration consisting of the identifier itself or a functor node declaration of which the node is an argument.

[Caution]

The self identifier is reserved as an alias for the current meta-node, see Section 3, “Meta-Nodes”.

Examples. 

# Results in the creation of node `name`
name

# Results in the creation of nodes `a` and `b`
a -> b

# Results in the creation of nodes `x`, `y` and `f(x, y)`
f(x, y)

This allows for a relaxed ordering of declarations. A node’s definition need not be complete in order for it to be referenced as an argument.

Notice that in the last example, above, a node f(x, y) was created. This node corresponds to the functor node expression, thus functors, with the exception of a few special declarations, are nodes themselves. From now on a functor node expression refers only to functors in which the operator refers to a function. Functor expressions in which the operator does not refer to a function are referred to as special declarations.

[Note]

Operators which correspond to functions, such as f in the example above are referred to as meta-nodes and the functor expression as an instance of the meta-node. See Section 3, “Meta-Nodes”.

[Note]

Nodes are also created for functors written in infix form, e.g. for the functor a + b, the node +(a, b) is created.

A node corresponding to a -> b was not created. This is a bind declaration which is treated rather specially. The -> is not a meta-node, that is it does not compute a value, but is a special operator.

2.2. Declaring Bindings

A binding between two nodes is declared with the special bind operator ->.

a -> b

The above declares b an observer of a and likewise a a dependency of b. The result is that a change in the value of a will trigger a change in the value of b. This is an example of a simple binding, since the value of b is simply set to the value of a.

[Note]

In an explicit binding declaration the dependency, i.e. the left-hand side of the -> operator, is referred to as the source node and the observer, i.e. the right-hand side, is referred to as the target node.

[Note]

The bind operator is registered as an infix operator with precedence 10 and right associativity.

[Tip]

The -> operator is in the form of an arrow which indicates the direction of data-flow, from the node on the left to the node on the right.

Functional bindings involve a function of one or more argument nodes. Functional bindings are created implicitly in functor node expressions, with each argument node added as a dependency of the functor node. A change in the value of at least one argument node results in the value of the functor node being updated to the result of reevaluating the expression with the new values of the argument nodes.

Example. 

a + b

In the example, above, a functor node +(a, b) is created with the arguments a and b implicitly added as dependencies of +(a, b). A change in either a or b will result in the value of +(a, b) being recomputed.

2.3. Propagation of Changes

As emphasized in the previous sections, changes in the value of a node are propagated to its observer nodes. The new value is propagated to each of the observers simultaneously. Each observer then proceeds to recompute its own value in parallel with the other observers.

Example. 

a -> b
a + n -> c
a + 1 -> d

Node a has three observers: b, +(a, n), +(a, 1). Each of b, +(a, n) and +(a, 1) receives the new value of a and immediately begins computing its new value. There is no strict sequential ordering of the updating of the values of the observer nodes. The following orderings are all possible:

  • b, +(a, n), +(a, 1)
  • +(a, n), b, +(a, 1)
  • +(a, 1), +(a, n), b

Other orderings, including interleaved orderings, are also possible or it may be that the values of all the observers are updated in parallel.

It is important to note the semantics when nodes share a common observer and the change in value of each node is triggered by a common ancestor node. A node is said to be dirtied if either its value has changed, or at least one of its dependency nodes has been dirtied. If a node is dirtied, all its observers are dirtied, and likewise their observers are dirtied and so on. A node with multiple dependencies will only recompute its value when it receives a value change notification from each of its dirtied dependency nodes. Thus there is no intermediate value where the node’s value is recomputed before all the dependency nodes have recomputed their values.

[Caution]

This is only the case when the changes in each of the dependency nodes are triggered by a change in a common ancestor node. These semantics do no apply when the changes in the dependency nodes are not triggered by a change in a common ancestor node but by multiple simultaneous changes in an ancestor of each dependency, unless the changes in each ancestor are the setting of the initial values, in which case it is treated as though they have been triggered by a single common ancestor. See Literal Bindings.

Example. 

a -> b
a + 1 -> c

b + c -> out

In the example, above, a is a common ancestor of dependency nodes b and c of node +(b, c). A change in a will dirty the following nodes:

  • a
  • b
  • +(a, 1)
  • c
  • +(b, c)
  • out.

The value of +(b, c) will only be recomputed when the values of both b and c have been recomputed.

If b and c did not have the common ancestor a, the value of +(b, c) would be computed on each change in the value of either b or c, regardless of whether the changes in values of b and c are triggered simultaneously or not.

2.4. Evaluation Strategy

The value of a node is not strictly evaluated. This means that a node’s value is only evaluated if it is actually used. In most cases the result of this is that nodes are evaluated lazily, that is they are evaluated on their first use. However if it can be statically determined that a node’s value will always be used it may be evaluated before its first use.

Example: Lazy Evaluation in If Conditions. 

a - b -> d1
b - a -> d2

if(a > b, d1, d2)

In the example, above, d1 is only evaluated if a > b evaluates to true. Likewise, d2 is only evaluated if a > b evaluates to false. a > b is always evaluated as its value is always used. In this example, this only results in a performance optimization since the values of node’s which are not used are not needlessly computed. However, if d1 or d2 were bound to a recursive meta-node call, see Section 3, “Meta-Nodes”, an infinite loop of recursive calls would result had d1 and d2 not been evaluated lazily.

A node’s value is evaluated at most once. Referencing the node’s value in more than one location will not cause it to be evaluated more than once. This applies to functor nodes as well as atom nodes.

Example: Multiple Usage of Nodes. 

# Node `f(x, y)` is used in 2 places however it will only be evaluated
# once.

f(x, y) + a -> node1
f(x, y) + b -> node2

2.5. Contexts

The function which computes a node’s value is controlled by the node’s context at that moment in time. The node context stores information about the function and which of the dependency nodes are operands to the function. Contexts are created whenever a binding between two nodes is established.

The most simple context function is the passthrough, created when a simple binding between two nodes is established. With this function, the node’s value is simply set to the value of its dependency node.

Passthrough Example. 

# `b` is set to the value of `a` whenever it changes

a -> b.

Contexts with more complex functions, of more than one operand, are created for each functor node expression. The created context has the operator as the context function and the arguments as the context operands.

Functor Node Example. 

# A functor node `+(a, b)` is created with a `+` context.
# `a` and `b` are added to the operands of the `+` context.

a + b

A node can have more than one context. A context is activated, meaning its function is evaluated to compute the node’s value, whenever the value of one of its operand nodes changes.

Multiple Context Example. 

a -> x
b -> x
c -> x

When the value of a changes, the a context of x is activated and the value of x is set to the value of a. Similarly when b or c's value changes, the b or c context is activated, respectively, and x's value is set to the value of b or c, respectively.

[Warning]

It is an error for two or more contexts of a single node to be activated at the same time. This occurs when either both contexts have a common operand or an operand from one context has a common ancestor with an operand from the other context.

Example 1. 

# Node `a` is a dependency of `b`
# Node `a` is a dependency of `+(a, c)`
# Both `b` and `+(a, c)` are dependencies of `x`

a -> b
b -> x

a + c -> x

In the example, above, node a is a dependency node of b which is an operand of the b context of x. However, node a is also a dependency of node +(a, c) (a + c), which is an operand of the +(a, c) context of x. A change in the value of a would trigger a change in the value of both b and +(a, c) thus the value to which b should be set is ambiguous.

Structure checking is performed at compile-time, thus the above example, and all such scenarios, will result in a compilation error along the lines: Semantic Error: Node x has multiple contexts activated by a single common ancestor.

Two-Way Bindings

A dependency of a node may also be an observer of the same node. This allows for a two-way binding in which data may flow from either direction. In this case only the observer nodes which are not also operands of the node’s current context are notified of a change in the node’s value.

Example. 

# A two-way binding is established between `a` and `b`
a -> b
b -> a

a -> c

d -> a

In the above example, both b and c, which are observers of a, will be notified of a change in the value of a triggered by a change in the value of d. This will trigger a change in the value of b however a will not be notified of this change as the change was triggered by a, itself.

In the case of a change in the value of a triggered by a change in the value of b, only the observer c of a will be notified of the change.

Cyclic Bindings

Cyclic bindings are bindings between a set of nodes, such that there is a path, via bindings, from a node to itself, consisting of at least three nodes. The resulting value of the node contains a cyclic reference to itself.

[Important]

A cycle comprising just a pair of nodes is interpreted as a two-way binding rather than a cyclic binding.

Example. 

cons(a, y) -> x
cons(b, x) -> y

The cycle in this example involves the nodes:

  • x
  • cons(b, x)
  • y
  • cons(a, y)

In this example the value function of x is effectively:

cons(a, cons(b, x))

where x, is substituted with a reference to the result of the evaluation of the expression, itself. This is well-formed since the arguments to the cons meta-node are evaluated lazily.

[Caution]

In order for a cyclic binding to have a meaningful result, the cyclic reference must be evaluated lazily. The following will result in an infinite loop, as the cyclic reference to i, in i + 1, is strictly evaluated. Currently there is no compiler warning or error.

i + 1 -> i

Literal Bindings

A binding in which the dependency is a literal value, is interpreted as setting the initial value of a node. A special init context is created, which has no operands and has the literal value as its function.

Initial values are set on the launch of the application, and are treated as an ordinary value change to the initial value. The initial active context of the node is the init context. If a node is not given an initial value, its initial value is a failure value of type No-Value, see Section 2.6, “Failures”.

Examples. 

0 -> counter
"hello" -> message
10.5 -> threshold

[Important]

The setting of the initial values of each node, is treated as having been triggered by a single common ancestor node. See Section 2.3, “Propagation of Changes” for the implications of this.

Explicit Contexts

The context to which a binding is established can be set explicitly with the special /context operator.

Syntax. 

/context(node, context-id)

The effect of this expression, when it appears as the target of a binding, is that the binding to node will be established in the context with identifier context-id. The identifier can be a symbol or a functor.

Example. 

# Context `my-context` of b has a passthrough value function to the
# value of the dependency `a`.

a -> /context(b, my-context)

When a /context declaration appears in source position it is equivalent to an ordinary reference to the node.

Multiple bindings to the same explicit context can be established. The function of the context then selects the value of the first dependency, ordered by the declaration order in the source file, which does not fail to evaluate to a value, see Section 2.6, “Failures”.

Example. 

a -> /context(node, ctx)
b -> /context(node, ctx)
c -> /context(node, ctx)

node evaluates to:

  • The value of a if a evaluates to a value.
  • The value of b if a fails to evaluate to a value.
  • The value of c if both a and b fail to evaluate to a value.

If a, b and c all fail to evaluate to a value, node evaluates to the failure value of c.

[Tip]

The @ macro from the core module, which is a shorthand for the /context operator, is the preferred way of establishing bindings to explicit contexts in source code.

2.6. Failures

Failures are a special type of value which represents the absence of a value or the failure to compute a value. Failures can either be created by conditional bindings, in which the condition node evaluates to false, or by the fail meta-node, from the builtin module.

Functions which expect an argument node to evaluate to a value will fail if at least one argument fails. In formal terms, if the result of a function requires that the value of an argument, which fails to evaluate to a value, be evaluated, the entire function fails to evaluate to a value. The following are examples of functions which fail if at least one of argument fails: +, -, *, /.

If the result of a function is a dictionary, and a dictionary entry fails to evaluate to a value, it is only that dictionary entry that fails, the function still returns a dictionary.

Conditional Bindings

A binding declaration a -> b can, itself, be treated as a node, to which an explicit binding can be established with the binding node as the target.

c -> (a -> b)

The result of this declaration is that the binding a -> b is only active if the condition node c evaluates to true, the value of the builtin True node,. If c evaluates to false, the value of the builtin False node, b is not set to the value of a but is set to a failure value of type No-Value.

A binding declaration, with a binding node as the target, changes the function of the context of the binding to return a failure value if the value of the condition node is false. The binding node a -> b (->(a, b) in prefix notation), is added as a dependency of b and as an operand of the context corresponding to the binding a -> b. The binding node is itself an observer of c with a simple passthrough function. This allows you to reference the status of the binding by referencing the binding node, a -> b.

Example: Simple Validation. 

# Validate that `i` has a value > 0
# Propagate value of `i` to `j`

i > 0 -> (i -> j)

# Perform some computation with `j` which is guaranteed to either be a
# numeric value greater than zero or a failure.
...

[Tip]

The bind -> operator has right associativity, thus the parenthesis in c -> (a -> b) can be omitted: c -> a -> b.

Conditional bindings to an explicit context can also be established, see Explicit Contexts. If a condition node evaluates to false, it is treated as though the corresponding dependency node has failed to evaluate to a value. The context’s function then evaluates to the next dependency which does not fail to evaluate to a value. If all condition nodes evaluate to false, the node fails to evaluate to a value.

Example: Conditional Bindings and Explicit Contexts. 

cond1 -> (a -> /context(node, ctx))
cond2 -> (b -> /context(node, ctx))
c -> /context(node, ctx)

  • If cond1 evaluates to false, it is treated as though a has failed to evaluate to a value.
  • If cond2 evaluates to false, it is treated as though b has failed to evaluate to a value.

The net result is that node evaluates to:

  • a if cond1 evaluates to true.
  • b if cond2 evaluates to true.
  • c if neither cond1 not cond2 evaluate to true, or both a and b fail to evaluate to a value.

Explicit Failures and Failure Types

Failure values can also be created explicitly with the fail meta-node, from the core module. This meta-node takes one optional argument: a value indicating the failure type. If the failure type is not provided, the failure returned does not have a type.

Example: Explicit Failure with Type. 

# Bind `b` to `a` if `c` is true
c -> (a -> /context(b, ctx))

# If `c` is false set `b` to an explicit failure
fail("my-type") -> /context(b, ctx)

The failure type of a failure value can be retrieved with the fail-type meta-node. This meta-node takes a single argument, which if it evaluates to a failure, returns the failure type associated with the failure. If the argument does not fail to evaluate to a value, or the failure has no type associated with it, fail-type returns a failure.

Example: Querying Failure Type. 

# Compare failure type of `b`, to "my-type" from example above

fail-type(c) = "my-type" -> c-fails?

The failure type is useful to identify the cause of a failure, since failures are used to represent many classes of errors, such as type errors, out of range errors, no value errors, as well as representing special classes of values.

Conditionally Active Bindings based on Failure Type

The special /context operator takes an optional third argument which is a test function that is evaluated prior to activating the binding after the previous binding fails. The test function is applied on a single argument, the failure type of the previous binding. If the function returns true the binding is activated otherwise this binding fails with the same failure type as the preceding binding.

[Tip]

The @ macro, from the core module, contains a shorthand syntax for establishing a binding to an explicit context with a test function that compares the failure type to a given value.

2.7. Input Nodes

Input nodes are the nodes which receive the application input, which could be the value entered in a text field of the user interface (UI), data received from the network, etc. Input nodes do not have any dependencies and have a special input context, which does not have a value computation function. Instead the value of the node is meant to be set explicitly through some external event.

Input nodes have to be explicitly designated as such by setting the input attribute to true. See Section 2.8, “Attributes” for more information about node attributes.

Example: Setting Input Attribute. 

a -> b

# Designate `a` as an input node
/attribute(a, input, True)

[Caution]

A compilation error is triggered if a node has a dependency that is not reachable from any input node, however has at least one dependency that is reachable from an input node. The error is not signalled if all of the node’s dependencies are unreachable from all the input nodes.

2.8. Attributes

Attributes are arbitrary key value pairs associated with a node, which control various compilation options. These are set using the special /attribute operator.

The first argument is the node of which to set the attribute, the second argument is the attribute key (not interpreted as a node) and the last argument is the value, which is interpreted as a literal value, not a node reference.

/attribute declarations may only appear at top-level and may not appear in binding declarations or as arguments in functor nodes.

Attribute Declaration Syntax. 

/attribute(node, attribute, value)

[Note]

The attribute key need not be a string, it may simply be an identifier as it is not interpreted as a node.

[Note]

Attribute keys are case insensitive. Additionally a string attribute key and an equivalent identifier key both refer to the same attribute. Thus the following keys all refer to the same attribute: key, Key, "key", "KEY".

[Important]

The value is treated as a literal value, not a reference to the value of a node, since attributes do not form part of the runtime node’s state.

The input attribute has already been introduced. The following is a list of some attributes and a summary of their effect:

input
When set to true, designates a node as an input node. See Section 2.7, “Input Nodes”.
coalescable
When set to false, prevents the node from being coalesced into other nodes. See Section 6.1, “Coalescing”.
removable
When set to false, prevents the node from being removed.
public-name
The name with which the runtime node can be referenced from non-Tridash code.
macro
When set to true, indicates that a meta-node is a macro and should be invoked at compile-time. See Section 3.6, “Macro Nodes”.
target-node
Sets the name of a meta-node to use as the value function, in the contexts of the bindings of the meta-node instance (as the source node) to its arguments (as the target node). See Section 3.7, “Instances as Targets”.
target-transform
The name of a meta-node to invoke if the meta-node, of which the attribute is set, appears as the target of a binding. See Section 3.7, “Instances as Targets”.

Examples. 

/attribute(a, input, True)
/attribute(a, public-name, "app-input")

2.9. Subnodes

Subnodes are nodes which reference a value, with a particular key, out of a dictionary of values stored in another node, referred to as the parent node.

Subnodes are referenced using the special . operator, which is also an infix operator. The parent node appears on the left-hand side and the key on the right-hand side. The key is treated as a literal identifier.

Syntax. 

<parent node>.<key identifier>

[Note]

The . operator is lexically special in that spaces are not required to separate it from its operand.

[Note]

The . infix operator has precedence 1000 and left associativity.

Example. 

string-concat(
    person.first-name, 1
    person.last-name   2
) -> full-name

1

References the first-name subnode of node person.

2

References the last-name subnode of node person.

An implicit two-way binding is established between the subnode and parent node. The binding in the direction parent -> subnode has a value function which extracts the subnode key from the dictionary stored in parent. The binding in the reverse direction, subnode -> parent, has a function which creates a dictionary with an entry which has the subnode key as the key and the value of subnode as the value. This allows a dictionary to be created in the parent node by establishing an explicit binding with subnode as the target. Multiple such bindings, with different subnodes of parent, will result in a dictionary being created with an entry for each subnode.

Example: Creating Dictionaries. 

"John" -> person.first-name
"Smith" -> person.last-name

The value of a subnode is only evaluated when the value of its dictionary entry is referenced. A subnode is not evaluated when only the value of its parent node, which evaluates to the dictionary, is referenced. See Section 2.4, “Evaluation Strategy”. If a subnode fails to evaluate to a value, it does not cause the parent node to fail to evaluate to value. The parent node evaluates to a dictionary however the dictionary entry, corresponding to the subnode, evaluates to a failure. See Section 2.6, “Failures”.

Accessing a non-existent entry, or accessing a subnode of a parent node which does not evaluate to a dictionary will result in a failure.

2.10. Node States

A binding can be established between the same node, as both the source and target of the binding, however in different states. The binding thus acts as a state transition function which computes the value of the node in its current state given its value in the previous state.

The special /state operator allows a binding to a particular state of a node to be established.

Syntax. 

/state(node, state-identifier)

where node is the node and state-identifier is a symbol identifying the state.

When a /state expression appears as the target of a binding, the binding will only take effect when node switches to the state with identifier state-identifier. This allows node to appear in the source of the binding either directly or as part of a node expression, in which case the current value of node is referenced to compute its value in the new state.

[Important]

When a /state expression appears as the source of a binding, it reduces to a reference to the value of node. It is not a reference to the value of node in a particular state.

The /state operator may also take an additional third argument, in which case the arguments are interpreted as follows:

Syntax: With Explicit From and To States. 

/state(node, from-state, to-state)

This specifies that the binding, of which the /state expression is a target, is only active when the state of node switches from the state with identifier from-state to the state with identifier to-state.

The state of a node n is determined by the value of the special node /state(n), to which bindings can be established. The value of /state(n) must be a symbol which is interpreted as a state identifier. The symbol may name a node state to which no bindings are established.

[Important]

Any value change of the node /state(n) is considered a change in the state of the node, even if the new state is identical to the previous state.

Multiple bindings, to the same node state, can be declared however bindings declared later in the source file take priority over bindings declared earlier in the file.

Example 1: Simple Counter. 

counter + 1 -> /state(counter, increment)

if (increment, '(increment), '(default)) -> /state(counter)

[Tip]

The ' operator is a macro which returns its argument as a literal symbol, see Literal Symbols.

The first declaration establishes the binding counter + 1 -> counter, which is active only when counter switches to the increment state. When counter switches to the increment state, its value is updated to its current value incremented by one.

The second declaration establishes a binding to the node /state(counter), the value of which determines the state of counter. When the node increment changes to true, the value of /state(counter), and thus the state of counter, is increment resulting in the value of counter being incremented by one. When increment is false, the state of counter is default and thus the binding established by the first declaration has no effect.

[Important]

A change in the value of increment from true to true will result in a change in the state of counter from increment to increment. Even though the new state is identical to the previous state, it is still considered a change to the increment state and thus the value of counter is incremented. To avoid this use the three argument form of the /state operator which specifies both a from and to state.

Example 2: Simple Counter with Explicit From and To States. 

counter + 1 -> /state(counter, default, increment)

if (increment, '(increment), '(default)) -> /state(counter)

Using the three argument form of the /state operator, the first declaration establishes a binding, between counter + 1 and counter, which is only active when the state of counter changes from default to increment. This differs from the previous example, in which the binding is active when the state of counter changes from any state to increment.

In this example, the value of counter is not incremented if the state of counter changes from increment to increment, as the binding counter + 1 -> counter is only active when the state changes from default to increment.

[Tip]

The :: macro from the core module, which is a shorthand for the /state operator, is the preferred way of establishing bindings to explicit node states. The shorthand for the two argument form is node :: state and the shorthand for the three argument form is node :: from => to.

The first declarations of the previous examples can thus be rewritten as follows:

# First Declaration of Example 1
counter + 1 -> counter :: increment

# First Declaration of Example 2
counter + 1 -> counter :: default => increment