Angular Dev Toolkit Decision Tree

The Angular Dev Toolkit is my client-only Angular/Typescript library.  This library of Angular components/directives/validators and Typescript code is installed for all my contract clients and is part of my overall value proposition to organizations.

I’ve worked on two projects for NDA clients in the recent past in which it was necessary to clean up architecture and programming issues created by another developer who ‘left the project.’  Both cases had two aspects in common.  The first was a myriad of problems created by stateful components and shared mutable state.  These can be alleviated by refactoring the application to use a Redux-style architecture (I prefer @ngrx/store).

The second issue was more subtle and involved a complex nest of if-then-else-else-if-else blocks that were in the process of going as far as four levels deep at the time I was introduced to the project.  These are difficult to debug (and test) and difficult for another developer to follow.  This increases the complexity of both initial development and downstream maintenance.  Interestingly, the actions that resulted from the rat’s nest of logic were quite simple; one was imperative routing (the final action was the route name and params).  The second set of actions pertained to which components inside a particular page were made visible to the user (the action was to set a number of booleans that controlled *ngIf directives).

I was able to replace this logic with simple JSON and a decision tree in which each node contains an expression that evaluates to a boolean.  While I no longer have access to these specific codes, the two low-level constituents to the solution are already in my Typescript Math Toolkit (an expression engine and a general Tree class).  I recently sat down and created a Decision Tree for the Angular Dev Toolkit that provides a more production-worthy and reusable implementation of the above approach.

Let’s look at a trivial example.  Suppose we have a block of logic that depends on a single, independent variable, x.  An action is taken based on whether or not x is less than, equal to, or greater than zero.  Of course, this is rather trivial to code, but it can be represented as the following decision tree.

tree1

The tree may be represented in the following format:

{
  id: '0', priority: 1, expression: '',
  children: [
    {
      id: '1', priority: 1, expression: 'x < 0',
      children: [
        {id: '4', priority: 1, expression: '', action: 'A1'}
      ]
    },
    {
      id: '2', priority: 2, expression: 'x = 0',
      children: [
        {id: '5', priority: 2, expression: '', action: 'A2'}
      ]
    },
    {
      id: '3', priority: 3, expression: 'x < 5',
      children: [
        {id: '6', priority: 3, expression: '', action: 'A3'}
      ]
    }
  ]
}

This seems like a lot of work to describe something that is a fundamentally simple code block, but the power of the approach is evident as the logic increases in complexity or the logic needs to change frequently, or alter during interaction with the user.

Note that the root node has no expression to evaluate and this is an indication that the tree is to be fully evaluated every time.  Consider a change request that adds the following conditions:

1 – The decision process should only be fully evaluated if the variable, x, is in the interval [lower, upper] where ‘lower’ and ‘upper’ are two new independent variables.

2 – For the middle path, x = 0, introduce a new variable, y.  Take action ‘A2-1′ if y is less than zero and ‘A2-2′ if y is greater than zero.  This requirement may be visualized as the tree,

tree2

and represented in data as,

{
  id: '0', priority: 1, expression: '(x >= lower) && (x <= upper)',
  children: [
    {
      id: '1', priority: 1, expression: 'x < 0',
      children: [
        {id: '4', priority: 1, expression: '', action: 'A1'}
      ]
    },
    {
      id: '2', priority: 2, expression: 'x = 0',
      children: [
        {
          id: '5', priority: 2, expression: 'y < 0',           
          children: [{id: '8', priority: 1, expression: '', action: 'A2-1'}           
          ]         
        },         
        {id: '6', priority: 3, expression: 'y > 0',
          children: [
            {id: '9', priority: 1, expression: '', action: 'A2-2'}
          ]
        }
      ]
    },
    {
      id: '3', priority: 3, expression: 'x > 0',
      children: [
        {id: '7', priority: 3, expression: '', action: 'A3'}
      ]
    }
  ]
}

The expression on the root node is called a ‘guard expression.’ It must pass during the first step of evaluation in order for the remainder of the tree to be processed.  In Angular parlance, it plays a similar role to a route guard.

Actions defined at leaf nodes are simply strings that serve either as raw data or symbolic codes for some other action to be taken.  They could be anything from the name of a route to a symbolic code to lookup in a function table to determine which function to execute.  Or, as will be important in the sequel, they could be the name of another tree to evaluate.

Again, for small examples with almost no likelihood of change, this approach is over-engineering.  The value of the decision tree is realized as logic grows in complexity and/or value is gained from expressing decisions in a data format that can be server-generated, changed dynamically, or one of many trees to evaluate may be chosen imperatively at runtime.

Node expressions may be any expression allowed by the Typescript Math Toolkit expression engine.  Independent variables are extracted by the Decision Tree engine dynamically as each node is processed.  These variables are assigned to the expression engine and then the expression is evaluated.  Although variable values may be of type string, number, or boolean, the expression must evaluate to a boolean.  If a node’s expression evaluates to true, then the node’s children are processed in the order in which they are defined.  Child nodes are presumed ordered by decreasing priority, left-to-right.  Tree processing continues until a leaf node is encountered with a named action, and this action serves as the return of an evaluation.

Here is a screen shot of some of the code from the internal __evaluateTree() method.  Click on the image to see the full-size version.

dt-screen

Usage of the Decision Tree is relatively straightforward. The tree is created and then initialized from an data object,

const tree: DecisionTree          = new DecisionTree();
const result: IDecisionTreeAction = tree.fromJson( {...} );

The result variable contains a success code indicating whether or not parsing of the tree structure was successful. In the case of failure, the offending node is available in the return to aid in debugging.

Values of the independent variables are taken from another data object, where the Object must have the variable names as keys.  Corresponding values are used as the actual values of the independent variables from which an expression is evaluated, i.e.

let action: IDecisionTreeAction = tree.evaluate({lower: -10, upper: 10, x: -20, y: 4});

In most applications, such Objects already exist as part of the application’s state.

Work is also finished on the Sequencing Engine, which manages a Map of Decision Trees.  I will also add a data-driven Finite State Machine as the output of a tree evaluation may be considered as a starting state in such as machine.

I’m rather excited about this suite of tools as it opens up a wide variety of possibilities for complex, interactive applications.  And, as always, since the code is developed in Typescript, it may be used on BOTH the front- and back-end with Node/Express.

I hope you are equally excited and if you wish to contract with me (which means you automatically obtain a copy of the Angular Dev Toolkit), please contact me at theAlgorithmist [at] gmail [dot] com.

Thank you!

Comments are closed.