Forester. The dsl above trees. Part II. Higher order trees.

Boris Zhguchev
4 min readJul 22, 2023

--

Forester

Intro

The behavior tree is a math model that is used for the execution of a complex flow. Forester is an orchestration framework that operates above the behavior trees. It provides a clean and simple way to run the tasks that implement the conception of the behavior trees out of the box.

And although the main concept under the hood is the trees as mentioned earlier, the framework provides the DSL, which is called f-tree, that afterwards compiles into the trees.

This article is supposed to shed a bit of light on why and how f-tree can be useful, especially in the aspect of code deduplicating.

The language itself is pretty simple and can be explored very quickly.

I just enlist a couple of the features for the sake of what the language has been created:

  • Imports allow breaking big scripts into separate files
  • Definitions allow isolating and deduplicating some parts of logic
  • Messages allow passing some data between invocations
  • Higher-order definitions allow the creation of the so-called abstract layer to handle some complex logic.

And the last point can be alluring. Let’s contemplate an example. The examples in the article are very synthetic but their target is to demonstrate the point and reasoning for the specific components of the language.

Example

Let’s try to model a case when the AMR should do some job but before it needs to maintain its level of charging consistently above the minimum.

Therefore, the process that should fulfil a set of conditions:

  • checks the level and if it is low head off to the charging station
  • and waits for the obstacles or any other hazards nearby.
  • contains the specific flag to stop everything
  • starts charging since arriving

So, there is little to do. But, still, the steps require checking out a set of different conditions on any steps, some of them are duplicating and besides. Besides, the common logic needs to fit into the interaction with the external events like stop_flag etc.

F-tree pursues to solve the problem, introducing, so-called higher-order tree definitions, the tree definitions that can accept other tree definition instances as arguments and then execute them.

The syntax is the following:

// the message type is tree
sequence some_tree(action:tree){
// the form name(..) denotes the invocation of the tree
action(..)
}

Therefore, we can create a set of high-level abstractions that will conceal the low-level logic inside. That has the vital advantages:

  • The tree definition can be changed but the interface will remain the same
  • The tree definition can be documented and becomes more readable
  • The tree definition can provide a set of commonly used components across all projects

On the other hand, it needs to be supported and maintained. Everything is relative.

In our case, we can introduce the following definitions:

// The component takes target action 
// and tries to execute it at least the number of attempts
// making some delay between attempts


r_fallback retry_with_delay( attempts:num,trg:tree){
retry(attempts) fallback {
trg(..)
wait()
}
// this wait is needed if we want to wait in any case.
wait()
}
// Simple if else
// Since the fallback takes the condition an sort of inverted form
// we swap else and then trees
r_sequence if_else(test:tree, then:tree, else:tree){
r_fallback{
test(..)
else(..)
}
then(..)
}

// wrapping with this definitions we can check the stop flag
// and avoid executing the given action if the flag is true (success)
r_fallback with_flag(action:tree){
stop_flag()
action(..)
}

Having these small ho tree definitions we can concoct a simple script for charging:

// every component is reactive to keep the conditions checking every time 
// on the new ticks

// charger_station is a json with coordinates
r_fallback to_charger(charger_station:object){
// try 10 times
retry_with_delay(10,
// check for hazards and wait if any nearby
// otherwise head off to the station
r_sequence {
if_else(
is_hazard_nearby(),
wait(),
retry_with_delay(10, move_to(charger_station))
)
// recheck that we are at the right place here
// and if no, go to the place
if_else(
on_target(charger_station),
charge(),
retry_with_delay(10, move_to(charger_station))
)
}
)
// if we come here we need to reload it again
running()
}

The main definition:

root main r_fallback {
// if the batteries are low, go and charge them otherwise do the job.
if_else(
test = battery_low(),
then = with_flag(to_charger({"x":10,"y":10})),
else = do_job()
)
}

Sources

import "std::actions"

cond is_hazard_nearby();
cond stop_flag();
cond battery_low();
cond on_target(target:object);

impl do_job();
impl wait();
impl move_to(target:object);
impl charge();

r_fallback retry_with_delay( attempts:num,trg:tree){
retry(attempts) fallback {
trg(..)
wait()
fail_empty()
}
wait()
}

r_sequence if_else(test:tree, then:tree, else:tree){
r_fallback{
test(..)
else(..)
}
then(..)
}


r_fallback with_flag(action:tree){
stop_flag()
action(..)
}


r_fallback to_charger(charger_station:object){
retry_with_delay(10,
r_sequence {
if_else(
is_hazard_nearby(),
wait(),
retry_with_delay(10, move_to(charger_station))
)
if_else(
on_target(charger_station),
charge(),
retry_with_delay(10, move_to(charger_station))
)
}
)
running()
}


root main r_fallback {
if_else(
test = battery_low(),
// don't forget about the common stop flag
then = with_flag(to_charger({"x":10,"y":10})),
else = do_job()
)
}

Visualization

Conclusion

The approach is simple but can give some benefits in terms of quality and readability (or may not, time will show). I find it worth trying and believe that it can alleviate the creation of complex trees.

Links

--

--

Boris Zhguchev

Rust and Robotics Enthusiast. Especially fond with BT in AI and Robotics.