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.
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
.
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
.
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.
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
.
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
.
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.
The |
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.
Operators which correspond to functions, such as |
Nodes are also created for functors written in infix form,
e.g. for the functor |
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.
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
.
In an explicit binding declaration the dependency, i.e. the
left-hand side of the |
The bind operator is registered as an infix operator with precedence 10 and right associativity. |
The |
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.
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.
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.
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
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.
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 Structure checking is performed at compile-time, thus the above
example, and all such scenarios, will result in a compilation error
along the lines: |
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 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.
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.
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 + 1 -> i |
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
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. |
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:
a
if a
evaluates to a value.
b
if a
fails to evaluate to a value.
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
.
The |
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.
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. ...
The bind |
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)
cond1
evaluates to false, it is treated as though a
has
failed to evaluate to a value.
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.
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.
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.
The |
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)
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. |
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)
The |
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:
|
The |
The input
attribute has already been introduced. The following is a
list of some attributes and a summary of their effect:
input
coalescable
removable
public-name
macro
target-node
target-transform
Examples.
/attribute(a, input, True) /attribute(a, public-name, "app-input")
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>
The |
The |
Example.
string-concat( person.first-name, person.last-name ) -> full-name
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.
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.
When a |
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.
Any value change of the node |
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)
The |
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.
A change in the value of |
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
.
The 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 |