Reactive, Data-Driven Finite State Machine

d-viz

TL;DR – This project is open source and the following description is rather long.  It is also largely a repeat of information found in the Readme file on Github.  So, if you are anxious for code, then fast-forward and check out the Github repo here.

This a follow-on to the developments in my Angular Dev Toolkit regarding data-driven application logic.  When discussing the expression-based decision tree, I mentioned that it would be possible to have a leaf node contain the name and initial state of a finite-state machine.  For that approach, the FSM must be data-driven and to fit nicely into the general Angular idiom, I also wanted a machine that was reactive.

The Decision Tree and Sequencing Engine are general-purpose derivatives of work previously implemented for NDA clients (I had to rewrite everything pretty much from memory).  As such, they will remain in the client-only Angular Dev Toolkit.  Subsequent private and online discussions led me to believe that the Finite State Machine was of suitable general interest to open-source the baseline version.

If you are interested in background, then here is one of my favorite online descriptions of a Finite State Machine and is closest to how I learned the topic ‘back in the day.’

The machine implementation in this repo is Mealy-style, although a state may be passed as data, so Moore-style machines can be used within this framework.

A FSM consists of a number of named states, and transitions from those states. One or more states may be defined as an acceptance state, meaning that after exercising the machine with a finite number of inputs, the machine may indicate acceptance of a certain criteria. This could be a valid string or that the number of zeros in a binary sequence is even. It is possible (and I’ve done this in C++) to define a rejection state with the convention that the machine may never transition out of this state. This is a way to catch and flag incorrect or other inputs.

The set of inputs to a FSM is called an alphabet.

The FiniteStateMachine class in this distribution has two possibly differentiating features from other implementations (particularly in Java, JS, and TS). One is that listening for specific state transitions is not performed by callback functions applied to those states. Instead, the machine is reactive. One or more (RxJs) Observers may subscribe to the machine and receive updates on all transitions that includes from and to states as well as relevant data.

API

The FiniteStateMachine class exposes the following models

// this is normally part of the Decision Tree library, but has been ripped out to make this distribution standalone
export interface IDecisionTreeAction
{
  success: boolean;                 // true if operation was successful

  node?: Object;                    // reference to the data Object in which an error was detected

  action: string;                   // action to take
}

/**
 * A state transition must have a 'from' and 'to' (named) state and may contain optional Object data
 */
export interface IStateTransition
{
  from: string;

  to: string;

  data?: Object;
}

/**
 * Output from a state transition is the 'to' state and optional data obtained from the transition function
 */
export interface IStateOutput
{
  to: string;

  data?: any;
}

/**
 * The transition function is Mealy-style, that is a transition to a new state is based on prior state and
 * input data.  Since state is optional in this interface, pass the state name as data and a Moore-style
 * machine can be implemented.
 */
export interface transFunction
{
  (data: any, state?: string): IStateOutput;
}

The public API of the FiniteStateMachine class is as follows.

public static create(data: Object, name?: string): FiniteStateMachine | null
public get numStates(): number
public get numTransitions(): number
public get currentState(): string
public get states(): IterableIterator
public get initialState(): string
public get initialData(): Object | null
public get isAcceptance(): boolean
public get alphabet(): Array | null
public fromJson(data: Object): IDecisionTreeAction
public addState(stateName: string, acceptance: boolean=false): void
public addTransition(from: string, to: transFunction): boolean
public addSubscriber(observer: Observer ): void
public next(input: any, initialState?: string): IStateOutput | null
public clear(): void

Usage

The FSM in this distribution may be used by directly assigning states and transition functions. The machine may also be described with Object data. These cases are best illustrated by example.

First, consider one of the machines discussed in the above introductions to FSM’s. This machine is designed to test sequences consisting of the letters a, b, c, and d, which is the machine’s alphabet.

The states are S1, S2, S3, and S4, the latter of which is the acceptance state. The machine’s initial state is S1 and letters are input one at a time. The machine is tested for being in the acceptance state after the final transition (refer to the above link for the state diagram).

This machine can be implemented with the following code (which is provided in the specs located in the test folder) which presumes a string input Array, str, i.e.

const str: Array = [‘a’, ‘a’, ‘a’, ‘a’, ‘c’, ‘d’];
Note that the transition functions are defined statically in code, which allows them to use stronger typing than is described in the transFunction interface.

For compactness, testing the return value from adding a transition is not included in the example.

const f12: transFunction = (data: string) => {
  return data == 'a' ? {to: 'S2'} : {to: 'S1'}
};

const f22: transFunction = (data: string) => {
  return data == 'a' ? {to: 'S2'} : (data == 'b' ? {to: 'S1'} : (data == 'c' ? {to: 'S4'} : {to: 'S2'}))
};

const f32: transFunction = (data: string) => {
  return data == 'a' ? {to: 'S1'} : (data == 'b' ? {to: 'S4'} : {to: 'S3'})
};

const f42: transFunction = (data: string) => {
  return data == 'd' ? {to: 'S3'} : {to: 'S4'}
};
.
.
.
const __machine: FiniteStateMachine = new FiniteStateMachine();

__machine.addState('S1');
__machine.addState('S2');
__machine.addState('S3');
__machine.addState('S4', true);

__machine.addTransition('S1', f12);
__machine.addTransition('S2', f22);
__machine.addTransition('S3', f32);
__machine.addTransition('S4', f42);

const n: number = str.length;
let i: number;

let state: IStateOutput = __machine.next(str[0], 'S1');  // set the initial state and first input
for (i = 1; i < n; ++i) {
  state = __machine.next(str[i]);
}

Based on the machine’s construction, we would expect __machine.isAcceptance to be false. This is also the answer to the quiz question posed on the site from which the example was taken :)

Many algorithms can be expressed as a FSM and Regex is equivalent to FSM (Kleene’s Theorem), for example. Whether or not one should implement an algorithm using a FSM is another topic. One example that often does not seem to fit a FSM architecture is the ‘change machine’ problem (this was a lab exercise in college).

Consider a machine that accepts coins (penny, nickel, dime, quarter) for an amount less than a dollar. After each coin is deposited, the machine updates the remaining balance, indicates if sufficient payment has been made, and computes any necessary change.

Alphabet and machine states may be considered equivalent in this machine, so we define states p, n, d, q, and c for the coin denominations and a ‘complete’ state. There is no transition out of the complete state, which is also the acceptance state for this machine.

Code to implement this machine is provided in the specs.

The most potentially useful feature of this FSM is the ability to describe a machine in data. This allows multiple machines to be defined in metadata and then dynamically applied based on real-time conditions in an application.

The string-test machine shown above can be described in the following data Object

const machine1: Object = {
  name: 'StringTest',
  initialState: "S1",
  alphabet: [
    'a',
    'b',
    'c',
    'd'
  ],
  states: [
    {
      name: 'S1',
      isAcceptance: false,
      transition: "return data == 'a' ? {to: 'S2'} : {to: 'S1'}"
    },
    {
      name: 'S2',
      isAcceptance: false,
      transition: "return data == 'a' ? {to: 'S2'} : (data == 'b' ? {to: 'S1'} : (data == 'c' ? {to: 'S4'} : {to: 'S2'}))"
    },
    {
      name: 'S3',
      isAcceptance: false,
      transition: "return data == 'a' ? {to: 'S1'} : (data == 'b' ? {to: 'S4'} : {to: 'S3'})"
    },
    {
      name: 'S4',
      isAcceptance: true,
      transition: "return data == 'd' ? {to: 'S3'} : {to: 'S4'}"
    },
  ]
};

Transition functions defined in data will be called with arguments that strictly match the transFunction interface. They must adhere to the following conventions.

  • Only the function body need be defined in a string.
  • The variable names data and state are arguments. Use them as such.
  • Transition functions must be pure.
  • The self-referential pointer (this) is bound to the global context due to Function constructor invocation. Do not use this inside the function body.
  • Do not reference any variables that are not defined in the function body as we can not be ‘loose’ regarding execution context of these functions.

In a typical application, the above Object description is input external to the application. Implementation of the machine reduces to

let result: IDecisionTreeAction = __machine.fromJson(machine1);

let str: Array = ['a', 'b', 'a', 'c', 'd', 'a', 'a', 'c'];
let n: number  = str.length;
let i: number;
let state: IStateOutput;

for (i = 0; i < n; ++i) {
  state = __machine.next(str[i]);
}

We would expect __machine.isAcceptance to be true.

Additional examples are provided in the specs that accompany the Github repo.

Comments are closed.