statelyai / xstate

Actor-based state management & orchestration for complex app logic.
https://stately.ai/docs
MIT License
27.03k stars 1.24k forks source link

[QUESTION] Guards #10

Closed joshhornby closed 6 years ago

joshhornby commented 7 years ago

Hey @davidkpiano

Just watched your amazing talk at React Rally.

We currently use https://github.com/winzou/state-machine as a state machine in a php application, the package has a concept of guards, so if I try and call a transition it will first check to see if it passes the guard, if so continue and if not then stop execution. Is this something you could see being implemented in this package?

davidkpiano commented 7 years ago

The problem with guards as part of the state machine is that:

With that said, due to the binary nature of guards (true/false), and the idea that one would map arbitrary events (such as a button click) to discrete actions, guards simply become abstracted away:

function getClickAction() {
  const num = Math.random();
  let nextAction;

  // guard
  if (num <= 0.5) {
    nextAction = 'FOO';
  } else {
    nextAction = 'BAR';
  }

  return nextAction;
}

Then, you can have:

function handleClick() {
  // pretend there's a currentState and machine in outer scope
  currentState = machine.transition(currentState, getClickAction());
}

And still be fully deterministic, with guards.

With that said, there's definitely some ideas I have floating around about modeling decision trees in conjunction with hierarchical finite state machines. I just wish that JavaScript had a decent pattern matching abstraction (switch/case is inadequate) that ensures exhaustiveness. I think there's a proposal for that somewhere.

davidkpiano commented 6 years ago

Okay, I've reconsidered guards. Here's what they'd look like:

const lightMachine = Machine({
  key: 'light',
  initial: 'green',
  states: {
    green: {
      on: {
        TIMER: {
          state: 'yellow',
          guard: fullState => fullState.elapsed >= 30000
        }
      }
    },
    yellow: {
      on: {
        TIMER: {
          state: 'red',
          guard: fullState => fullState.elapsed >= 5000
        }
      }
    },
    red: {
      on: {
        TIMER: {
          state: 'green',
          guard: fullState => fullState.elapsed >= 30000
        }
      }
    }
  }
});

lightMachine.transition('green', 'TIMER', { elapsed: 10000 }).value;
// => 'green'

lightMachine.transition('green', 'TIMER', { elapsed: 30001 }).value;
// => 'yellow'

Eventually, I'd like to have a deterministic + declarative way of:

This is all possible because even though it might seem like guard conditions introduce the idea of "infinite" actions (e.g., TIMER + elapsed: 10, TIMER + elapsed: 11, etc.), it can still be classified as a finite set of actions per state transition. In the above example, for the out-bound green transition, actions can be classified into:

so it's essentially just two actions.

davidkpiano commented 6 years ago

Since we're providing the key to the machine, we can probably simplify the API too:

// xstate knows to read from the 'light' property because
// { key: 'light' } is specified in the machine definition
lightMachine.transition({ light: 'green', elapsed: 30001 }, 'TIMER');
// => 'yellow'

Yay or nay?

camwest commented 6 years ago

I think this should be called conditions instead of guards to be more in line with the state chart literature.

Also I think they should use natural language instead of functions. Then when you build the machine you should pass the state chart plus implementations of all the conditions.

A lot of time conditions in a state chart are complex policies which could have different implementations in different environments (e.g. simulation vs in product)

davidkpiano commented 6 years ago

@camwest What would that look like in the API, do you think? I'm all for the natural language approach, especially if these are meant to be consumed in different environments/languages.

Here is my revised version of what guards (and onEntry/onExit hooks) would look like:

function logEntry(message) {
  return console.log('Entry: ' + message);
}

function logExit(message) {
  return console.log('Exit: ' + message);
}

const lightMachine = Machine({
  key: 'light',
  initial: 'green',
  states: {
    green: {
      on: {
        TIMER: {
          // guard: action == TIMER and extended timer state is between 1000 and 2000
          greenYellow: ({ timer }) => timer >= 1000 && timer < 2000,
          yellow: ({ timer }) => timer >= 2000
        }
      },
      onEntry: logEntry,
      onExit: logExit
    },
    greenYellow: {
      onEntry: logEntry,
      onExit: logExit
    }
  }
});

const nextState = lightMachine.transition('green', 'TIMER', { timer: 1500 });
// State {
//   value: 'greenYellow',
//   previous: 'green',
//   entry: logEntry, // called with value
//   exit: logExit // called with previous
// }

// test assertions
assert.equal(nextState.value, 'greenYellow');
assert.equal(nextState.entry, logEntry);
assert.equal(nextState.exit, logExit);

// execute side effects
// this is the only side-effectful function in xstate and is not required
// the developer can execute the entry/exit functions themselves
Machine.exec(nextState);
// => 'Exit: green'
// => 'Entry: greenYellow'

I think this can easily be adapted to accept a string key for the guard, and then have the guard implementation able to be referenced in the second argument to Machine(config, options).

What do you think?

camwest commented 6 years ago

I think it's useful for names to be explicitly assigned to the guards. In certain domains these will be well known policy names.

For example. In Constructing the User Interface with Statecharts there is a CD Player example. The conditions (guards) they mention are "No CD in drawer", "CD in drawer", "end of CD".

const machine = generateMachine(statechart, (state, is, on) => {
  on("startup", () => cdPlayer.closeDrawer());

  state("NO CD Loaded", () => {
    state("CD Drawer Closed", () => {
      on("Eject", () => cdPlayer.openDrawer());
    });

    state("CD Drawer Open", () => {
      on("Eject", () => cdPlayer.closeDrawer());
    });

    state("Closing CD Drawer", () => {
      is("No CD in drawer", config => cdPlayer.cdLoaded === false);
      is("CD in drawer", config => cdPlayer.cdLoaded === true);
    });
  });

  state("CD Loaded", () => {
    is("end of CD", config => cdPlayer.cdLoaded === true);

    on("Eject", () => {
      cdPlayer.stop();
      cdPlayer.openDrawer();
    });

    state("CD Stopped", () => {
      on("Play", () => cdPlayer.play());
    });

    state("CD Playing", () => {
      on("Pause", () => cdPlayer.pause());
      on("Stop", () => cdPlayer.stop());
    });

    state("CD Paused", () => {
      on("Pause", () => cdPlayer.play());
      on("Play", () => cdPlayer.play());
    });
  });
});

This is just a rough pseudocode so let me know what you think....

davidkpiano commented 6 years ago

@camwest Thanks for this! I'm going to make guard conditions, onEntry onExit and onTransition functions able to take in a string as well, because you're right, that's an important use-case (especially for RPC applications).

So, with string identifiers, you would get back a state that looks like this:

const config = {
  initial: 'Start',
  states: {
    CDDrawer: {
      initial: 'Closed',
      states: {
        Closed: { on: { EJECT: 'Opening' } },
        Opening: {
          on: { OPEN_DRAWER: 'Open' } },
          onEnter: 'openDrawer'
        },
        Open: {}
      }
    }
  }
}

const cdMachine = Machine(config);

const nextState = cdMachine.transition('CDDrawer.Closed', 'EJECT');
// State {
//   value: {
//     CDDrawer: 'Opening'
//   },
//   effects: [
//     'openDrawer'
//   ]
// }

Then, you can execute the side effects with your implementation:

const nextState = ... // same as above

// dispatch events using Redux (assume dispatch is available)
const reduxEffects = {
  openDrawer: () => dispatch({ type: 'OPEN_DRAWER' }),
  closeDrawer: () => dispatch({ type: 'CLOSE_DRAWER' })
};

// Execute effects in the order specified by the returned state instance
if (nextState.effects.length) {
  nextState.effects.forEach(effectName => {
    const effect = reduxEffects[effectName];
    if (effect) effect(); // execute effect
  }
}
camwest commented 6 years ago

Why are the transitions targeted at states? Shouldn’t the “action” be global in a state chart?

davidkpiano commented 6 years ago

They're not targeted. They're global to the entire state chart. The actions specified on states just inform how the state should transition when:

davidkpiano commented 6 years ago

Also @camwest I'd like to avoid function syntax because functions are not (easily) serializable, and it makes it more difficult to statically analyze a state machine.

camwest commented 6 years ago

@davidkpiano totally I really don't like the function syntax either =)

lmatteis commented 6 years ago

I'm confused about guards. Shouldn't that logic exist outside of the statechart? For instance, trigger a specific event only given a condition.

If we implement guards this way (inside the statechart) then why not have the logic for "triggering events" also in the statechart? I'm not sure where to draw the line.

davidkpiano commented 6 years ago

Shouldn't that logic exist outside of the statechart?

Yes, so I'll make it so that guards can be constant strings as well, just like onEntry and onExit.

But "trigger a specific event only given a condition" is actually an important part of statecharts, if you view "actions with guards" as separate, distinct types of actions. For example, let's say you had a FSM that handles the filling of the cup with states filling and full, and external state volume: number where volume is from 0 (empty) to 100 (full). You can have transitions from empty/filling to:

That looks like ad-hoc logic, but you're actually creating distinct subsets of the external state volume paired with the FILL action, so that you can think about it as having FILL_NOT_FULL and FILL_TO_FULL actions, respectively.

I do think the implementation of guards/events should be outside the statechart though, yes.

lmatteis commented 6 years ago

Wouldn't you simply trigger a FULL event when volume is >= 100 outside the statechart?

I'm really not convinced about guards at the statechart level (at the actual visual level I mean). Even the original Harel paper doesn't seem to mentioned them, right? But again, I'm kinda new to this space so I may be over-simplifying things.

EDIT actually my bad, they are part of the paper:

We thus enrich the transition labelling to be of the form a(P)/S, where a is the event triggering the transition, P the condition that guards the transition from being taken when it is false, and S the action (or output in automata-theoretic terms) to be carried out upon transition. (We can actually allow Boolean combinations in each component, but we shall not get into a detailed syntax here.)

davidkpiano commented 6 years ago

Wouldn't you simply trigger a FULL event when volume is >= 100 outside the statechart?

No. Actions should never be aware of extended state - they should simply be a stimulus executed in response to an event.

actually my bad, they are part of the paper:

Yep, which is why I included them :)

lmatteis commented 6 years ago

Gah, sorry I'm confused. How is FILL an action? I don't think actions are capable of triggering transitions... I mean they do trigger transitions in other orthogonal states. So how do you define events and actions in xstate?

davidkpiano commented 6 years ago

Events are any stimulus that occurs in a program.

Actions are the distinct "categorization" of those events to be accepted by the machine.

You map events to actions yourself as a developer. As a trivial example (in React):

<button onClick={() => this.dispatch('CLICK')}>
  Click me!
</button>

Above we're mapping the click events of the above button to the distinct CLICK action, which is an action that our machine (presumably) understands.

lmatteis commented 6 years ago

Interesting, however I'm not sure that's the standard formalism defined in Harel's paper. I'll open a separate issue to help us grasp the differences.

davidkpiano commented 6 years ago

Guards are now in v3! http://davidkpiano.github.io/xstate/docs/#/guides/guards