Transformers

A transformer can be viewed as a function that, when called, transform an expression that it gets as an argument into another expression. It is similar to a computation graph. The reasons for transformers in Symbolica are twofold:

The delayed execution is explained in the pattern matching where the right-hand side should be transformed, but only after substitution of the wildcard.

High-performance execution in Symbolica can also be achieved even when running from Python. As is well known, Python generally does not achieve performance similar to Rust as it is not a compiled language. However, we can use transformers to build a program that is run entirely in Rust.

A simple example of a transformer is:

from symbolica import Transformer
t = Transformer().expand()
Tip

Transformer().expand() is similar to lambda x: x.expand()

A transformer can be applied to an expression by calling it, and the result is another expression. For example:

from symbolica import Transformer
t = Transformer().expand()
e = E('(1+x)^2')
r = t(e)

which yields x^2+2x+1.

Some functions take a transformer as input. An example is the map() function uses for term streaming and the replace_all() function.

Conditions

Conditions can be created with transformers. For example: T.contains(x) will test if the expression that will go through the transformer contains x. We can use the conditions in a if_then transformer:

T = Transformer() # abbreviation
t = T.map_terms(T.if_then(T.contains(x), T.print()))
t(x+y+4)

will print x. if_then has an optional else block as the third argument.

Comparison operators of Expression and Transformer classes are also overloaded to yield a Condition:

t = T.map_terms(T.if_then(T < 6, T.print()))
t(5)

will print 5.

You can also test for a match. For example:

t = T.if_then(T.matches(E('f(x_)')), T.print())
t(E('f(5)'))

yields f(5).

The arguments of the conditions can have transformers on the left, right, or both:

t = T.map_terms(T.if_then(E('f(x)').contains(T), T.print()))
t(x + y + 4)

yields x.

The input expression of the branches of the if is the same as the input of the if transformer. Thus, any modifications made in the condition are lost.

If changed

Sometimes we want to do something special if a transformer changed the expression. For example, imagine a list of simplification rules of increasing complexity. If the first one hits, we may not want to execute the others but start again at the top of the list instead. We can use if_changed:

t = T.map_terms(T.if_changed(T.replace_all(x, y), T.print()))
print(t(x + y + 4))

which prints x. The first argument is a transformer that is executed, and it is tracked whether it changes the expression. If so, the second argument is called with the result of the first transformer as input. Otherwise, the third argument is called (if present).

Control flow

Using the break_chain transformer, you can break the current chain and parent chains that contain an if (similar to break or continue in most languages):

t = T.map_terms(T.repeat(
    T.replace_all(y, 4),
    T.if_changed(T.replace_all(x, y),
                 T.break_chain()),
    T.print()  # print of y is never reached
))
t(x)

This prints 4 4, and not y, as the break_chain breaks the transformer chain.

Bound transformers

Transformers can also be bound to an expression, which sets the expression that the transformer will be applied to, without actually executing the transformer. This can be used for delayed execution. Here is an example with replace_all:

from symbolica import *
x, x_, f = S('x', 'x_', 'f')
e = f((x+1)**2)
e.replace_all(f(x_), f(x_.transform().expand()))

Here a bound transformer is created using x_.transform(). Before the transformer is executed, the x_ is replaced by its matched value.

A bound transformer can be executed by calling execute():

from symbolica import Transformer
e = Expression.parse('(x+1)^2')
e.transform().expand().execute()

Examples

For brevity we define T:

T = Transformer()

Below is an example in Python that uses the repeat transformer, which takes a list of transformers that it will repeat until there are no more changes between the input and output expression.

x_, f = S('x_', 'f')
e = E('f(100)')

T.repeat(
    T.expand(),
    T.replace_all(f(x_), f(x_ - 1) + f(x_ - 2), x_.req_gt(1))
)(e)

Transformers can be chained, so the program above can also be rewritten

x_, f = S('x_', 'f')
e = E('f(100)')

T.repeat(
    T.expand().replace_all(f(x_), 
        f(x_ - 1) + f(x_ - 2), x_.req_gt(1))
)(e)

Using transformers, helper functions can be written in Python that can be chained to compose complicated logic:

def simplify():
    return T.repeat(
        T.expand(),
        T.replace_all(gamma(x___,y_,y_,z___), D*gamma(x___,z___))
    )

def main_loop():
    return T.repeat(
        simplify(),
        T.replace_all(gamma(x___,g5,x_,z___), -gamma(x___,x_,g5_,z___))
    )

Multithreading

You can use the map_terms transformer to map other transformers over all the terms in an expression, in parallel:

e = E('(x+1)^30')
t = T.map_terms(T.replace_all(x, x + 1), n_cores=5)
t(e)

The new threads spawned by the transformer will have a stack size of 2MB by default. If your code aborts, you may have to increase the stack size using the environment variable RUST_MIN_STACK, which specifies the stack size of new threads in bytes.

Performance tips

For maximum performance, transformers should be created only once. For example, the transformer main_loop() can be created by storing it in a variable and using that:

m = main_loop()

for e in [E('gamma(1,2,g5,3,4)'), E('gamma(1,3,4)')]:
    m(e) # reused

This is especially important if your transformers create threads, as creating an removing threads is expensive and will increase memory consumption.