jssm
Version:
A Javascript state machine with a simple API. Well tested, and typed with Flowtype. MIT License.
771 lines (524 loc) • 23 kB
JavaScript
// whargarbl lots of these return arrays could/should be sets
// @flow
import type {
JssmGenericState, JssmGenericConfig,
JssmTransition, JssmTransitionList,
JssmMachineInternalState,
JssmParseTree,
JssmCompileSe, JssmCompileSeStart, JssmCompileRule,
JssmArrow, JssmArrowDirection, JssmArrowKind,
JssmLayout
} from './jssm-types';
import { seq, weighted_rand_select, weighted_sample_select, histograph, weighted_histo_key } from './jssm-util.js';
const parse : <NT, DT>(string) => JssmParseTree<NT> = require('./jssm-dot.js').parse; // eslint-disable-line flowtype/no-weak-types // todo whargarbl remove any
const version : null = null; // replaced from package.js in build
function arrow_direction(arrow : JssmArrow) : JssmArrowDirection {
switch ( String(arrow) ) {
case '->' : case '=>' : case '~>' :
return 'right';
case '<-' : case '<=' : case '<~' :
return 'left';
case '<->': case '<-=>': case '<-~>':
case '<=>': case '<=->': case '<=~>':
case '<~>': case '<~->': case '<~=>':
return 'both';
default:
throw new Error(`arrow_direction: unknown arrow type ${arrow}`);
}
}
function arrow_left_kind(arrow : JssmArrow) : JssmArrowKind {
switch ( String(arrow) ) {
case '->': case '=>' : case '~>':
return 'none';
case '<-': case '<->': case '<-=>': case '<-~>':
return 'legal';
case '<=': case '<=>': case '<=->': case '<=~>':
return 'main';
case '<~': case '<~>': case '<~->': case '<~=>':
return 'forced';
default:
throw new Error(`arrow_direction: unknown arrow type ${arrow}`);
}
}
function arrow_right_kind(arrow : JssmArrow) : JssmArrowKind {
switch ( String(arrow) ) {
case '<-': case '<=' : case '<~':
return 'none';
case '->': case '<->': case '<=->': case '<~->':
return 'legal';
case '=>': case '<=>': case '<-=>': case '<~=>':
return 'main';
case '~>': case '<~>': case '<-~>': case '<=~>':
return 'forced';
default:
throw new Error(`arrow_direction: unknown arrow type ${arrow}`);
}
}
function compile_rule_transition_step<mNT, mDT>(
acc : Array< JssmTransition<mNT, mDT> >,
from : mNT,
to : mNT,
this_se : JssmCompileSe<mNT>,
next_se : JssmCompileSe<mNT>
) : Array< JssmTransition<mNT, mDT> > { // todo flow describe the parser representation of a transition step extension
const edges : Array< JssmTransition<mNT, mDT> > = [];
const uFrom : Array< mNT > = (Array.isArray(from)? from : [from]),
uTo : Array< mNT > = (Array.isArray(to)? to : [to] );
uFrom.map( (f:mNT) => {
uTo.map( (t:mNT) => {
const rk : JssmArrowKind = arrow_right_kind(this_se.kind),
lk : JssmArrowKind = arrow_left_kind(this_se.kind);
const right : JssmTransition<mNT, mDT> = {
from : f,
to : t,
kind : rk,
forced_only : rk === 'forced',
main_path : rk === 'main'
};
if (this_se.r_action) { right.action = this_se.r_action; }
if (this_se.r_probability) { right.probability = this_se.r_probability; }
if (right.kind !== 'none') { edges.push(right); }
const left : JssmTransition<mNT, mDT> = {
from : t,
to : f,
kind : lk,
forced_only : lk === 'forced',
main_path : lk === 'main'
};
if (this_se.l_action) { left.action = this_se.l_action; }
if (this_se.l_probability) { left.probability = this_se.l_probability; }
if (left.kind !== 'none') { edges.push(left); }
});
});
const new_acc : Array< JssmTransition<mNT, mDT> > = acc.concat(edges);
if (next_se) {
return compile_rule_transition_step(new_acc, to, next_se.to, next_se, next_se.se);
} else {
return new_acc;
}
}
function compile_rule_handle_transition<mNT>(rule : JssmCompileSeStart<mNT>) : mixed { // todo flow describe the parser representation of a transition
return compile_rule_transition_step([], rule.from, rule.se.to, rule.se, rule.se.se);
}
function compile_rule_handler<mNT>(rule : JssmCompileSeStart<mNT>) : JssmCompileRule { // todo flow describe the output of the parser
if (rule.key === 'transition') { return { agg_as: 'transition', val: compile_rule_handle_transition(rule) }; }
const tautologies : Array<string> = [
'graph_layout', 'start_states', 'end_states', 'machine_name', 'machine_version',
'machine_comment', 'machine_author', 'machine_contributor', 'machine_definition',
'machine_reference', 'machine_license', 'fsl_version'
];
if (tautologies.includes(rule.key)) {
return { agg_as: rule.key, val: rule.value };
}
throw new Error(`compile_rule_handler: Unknown rule: ${JSON.stringify(rule)}`);
}
function compile<mNT, mDT>(tree : JssmParseTree<mNT>) : JssmGenericConfig<mNT, mDT> { // todo flow describe the output of the parser
const results : {
graph_layout : Array< JssmLayout >,
transition : Array< JssmTransition<mNT, mDT> >,
start_states : Array< mNT >,
end_states : Array< mNT >,
machine_name : Array< string >,
machine_version : Array< string > // semver
} = {
graph_layout : [],
transition : [],
start_states : [],
end_states : [],
machine_name : [],
machine_version : []
};
tree.map( (tr : JssmCompileSeStart<mNT>) => {
const rule : JssmCompileRule = compile_rule_handler(tr),
agg_as : string = rule.agg_as,
val : mixed = rule.val; // todo better types
results[agg_as] = results[agg_as].concat(val);
});
['graph_layout', 'machine_name', 'machine_version'].map( (oneOnlyKey : string) => {
if (results[oneOnlyKey].length > 1) {
throw new Error(`May only have one ${oneOnlyKey} statement maximum: ${JSON.stringify(results[oneOnlyKey])}`);
}
});
const assembled_transitions : Array< JssmTransition<mNT, mDT> > = [].concat(... results['transition']);
const result_cfg : JssmGenericConfig<mNT, mDT> = {
// whargarbl should be initial_state : results.initial_state[0],
start_states : results.start_states.length? results.start_states : [assembled_transitions[0].from],
transitions : assembled_transitions
};
if (results.graph_layout.length) { result_cfg.layout = results.graph_layout[0]; }
return result_cfg;
}
function make<mNT, mDT>(plan : string) : JssmGenericConfig<mNT, mDT> {
return compile(parse(plan));
}
class Machine<mNT, mDT> {
_state : mNT;
_states : Map<mNT, JssmGenericState<mNT>>;
_edges : Array<JssmTransition<mNT, mDT>>;
_edge_map : Map<mNT, Map<mNT, number>>;
_named_transitions : Map<mNT, number>;
_actions : Map<mNT, Map<mNT, number>>;
_reverse_actions : Map<mNT, Map<mNT, number>>;
_reverse_action_targets : Map<mNT, Map<mNT, number>>;
_layout : JssmLayout;
// whargarbl this badly needs to be broken up, monolith master
constructor({ start_states, complete=[], transitions, layout = 'dot' } : JssmGenericConfig<mNT, mDT>) {
this._state = start_states[0];
this._states = new Map();
this._edges = [];
this._edge_map = new Map();
this._named_transitions = new Map();
this._actions = new Map();
this._reverse_actions = new Map();
this._reverse_action_targets = new Map(); // todo
this._layout = layout;
transitions.map( (tr:JssmTransition<mNT, mDT>) => {
if (tr.from === undefined) { throw new Error(`transition must define 'from': ${JSON.stringify(tr)}`); }
if (tr.to === undefined) { throw new Error(`transition must define 'to': ${ JSON.stringify(tr)}`); }
// get the cursors. what a mess
const cursor_from : JssmGenericState<mNT>
= this._states.get(tr.from)
|| { name: tr.from, from: [], to: [], complete: complete.includes(tr.from) };
if (!(this._states.has(tr.from))) {
this._new_state(cursor_from);
}
const cursor_to : JssmGenericState<mNT>
= this._states.get(tr.to)
|| {name: tr.to, from: [], to: [], complete: complete.includes(tr.to) };
if (!(this._states.has(tr.to))) {
this._new_state(cursor_to);
}
// guard against existing connections being re-added
if (cursor_from.to.includes(tr.to)) {
throw new Error(`already has ${JSON.stringify(tr.from)} to ${JSON.stringify(tr.to)}`);
} else {
cursor_from.to.push(tr.to);
cursor_to.from.push(tr.from);
}
// add the edge; note its id
this._edges.push(tr);
const thisEdgeId : number = this._edges.length - 1;
// guard against repeating a transition name
if (tr.name) {
if (this._named_transitions.has(tr.name)) {
throw new Error(`named transition "${JSON.stringify(tr.name)}" already created`);
} else {
this._named_transitions.set(tr.name, thisEdgeId);
}
}
// set up the mapping, so that edges can be looked up by endpoint pairs
const from_mapping : Map<mNT, number> = this._edge_map.get(tr.from) || new Map();
if (!(this._edge_map.has(tr.from))) {
this._edge_map.set(tr.from, from_mapping);
}
// const to_mapping = from_mapping.get(tr.to);
from_mapping.set(tr.to, thisEdgeId); // already checked that this mapping doesn't exist, above
// set up the action mapping, so that actions can be looked up by origin
if (tr.action) {
// forward mapping first by action name
let actionMap : ?Map<mNT, number> = this._actions.get(tr.action);
if (!(actionMap)) {
actionMap = new Map();
this._actions.set(tr.action, actionMap);
}
if (actionMap.has(tr.from)) {
throw new Error(`action ${JSON.stringify(tr.action)} already attached to origin ${JSON.stringify(tr.from)}`);
} else {
actionMap.set(tr.from, thisEdgeId);
}
// reverse mapping first by state origin name
let rActionMap : ?Map<mNT, number> = this._reverse_actions.get(tr.from);
if (!(rActionMap)) {
rActionMap = new Map();
this._reverse_actions.set(tr.from, rActionMap);
}
// no need to test for reverse mapping pre-presence;
// forward mapping already covers collisions
rActionMap.set(tr.action, thisEdgeId);
// reverse mapping first by state target name
if (!(this._reverse_action_targets.has(tr.to))) {
this._reverse_action_targets.set(tr.to, new Map());
}
/* todo comeback
fundamental problem is roActionMap needs to be a multimap
const roActionMap = this._reverse_action_targets.get(tr.to); // wasteful - already did has - refactor
if (roActionMap) {
if (roActionMap.has(tr.action)) {
throw new Error(`ro-action ${tr.to} already attached to action ${tr.action}`);
} else {
roActionMap.set(tr.action, thisEdgeId);
}
} else {
throw new Error('should be impossible - flow doesn\'t know .set precedes .get yet again. severe error?');
}
*/
}
});
}
_new_state(state_config : JssmGenericState<mNT>) : mNT { // whargarbl get that state_config any under control
if (this._states.has(state_config.name)) {
throw new Error(`state ${JSON.stringify(state_config.name)} already exists`);
}
this._states.set(state_config.name, state_config);
return state_config.name;
}
state() : mNT {
return this._state;
}
/* whargarbl todo major
when we reimplement this, reintroduce this change to the is_final call
is_changing() : boolean {
return true; // todo whargarbl
}
*/
state_is_final(whichState : mNT) : boolean {
return ( (this.state_is_terminal(whichState)) && (this.state_is_complete(whichState)) );
}
is_final() : boolean {
// return ((!this.is_changing()) && this.state_is_final(this.state()));
return this.state_is_final(this.state());
}
layout() : string {
return String(this._layout);
}
machine_state() : JssmMachineInternalState<mNT, mDT> {
return {
internal_state_impl_version : 1,
actions : this._actions,
edge_map : this._edge_map,
edges : this._edges,
named_transitions : this._named_transitions,
reverse_actions : this._reverse_actions,
// reverse_action_targets : this._reverse_action_targets,
state : this._state,
states : this._states
};
}
/*
load_machine_state() : boolean {
return false; // todo whargarbl
}
*/
states() : Array<mNT> {
return [... this._states.keys()];
}
state_for(whichState : mNT) : JssmGenericState<mNT> {
const state : ?JssmGenericState<mNT> = this._states.get(whichState);
if (state) { return state; }
else { throw new Error(`no such state ${JSON.stringify(state)}`); }
}
list_edges() : Array< JssmTransition<mNT, mDT> > {
return this._edges;
}
list_named_transitions() : Map<mNT, number> {
return this._named_transitions;
}
list_actions() : Array<mNT> {
return [... this._actions.keys()];
}
get_transition_by_state_names(from: mNT, to: mNT) : ?number {
const emg : ?Map<mNT, number> = this._edge_map.get(from);
if (emg) {
return emg.get(to);
} else {
return undefined;
}
}
lookup_transition_for(from: mNT, to: mNT) : ?JssmTransition<mNT, mDT> {
const id : ?number = this.get_transition_by_state_names(from, to);
return ((id === undefined) || (id === null))? undefined : this._edges[id];
}
list_transitions(whichState : mNT = this.state()) : JssmTransitionList<mNT> {
return {entrances: this.list_entrances(whichState), exits: this.list_exits(whichState)};
}
list_entrances(whichState : mNT = this.state()) : Array<mNT> {
return (this._states.get(whichState) || {}).from || [];
}
list_exits(whichState : mNT = this.state()) : Array<mNT> {
return (this._states.get(whichState) || {}).to || [];
}
probable_exits_for(whichState : mNT) : Array< JssmTransition<mNT, mDT> > {
const wstate : ?JssmGenericState<mNT> = this._states.get(whichState);
if (!(wstate)) { throw new Error(`No such state ${JSON.stringify(whichState)} in probable_exits_for`); }
const wstate_to : Array< mNT > = wstate.to,
wtf : Array< JssmTransition<mNT, mDT> >
= wstate_to
.map( (ws) : ?JssmTransition<mNT, mDT> => this.lookup_transition_for(this.state(), ws))
.filter(Boolean);
return wtf;
}
probabilistic_transition() : boolean {
const selected : JssmTransition<mNT, mDT> = weighted_rand_select(this.probable_exits_for(this.state()));
return this.transition( selected.to );
}
probabilistic_walk(n : number) : Array<mNT> {
return seq(n)
.map(() : mNT => {
const state_was : mNT = this.state();
this.probabilistic_transition();
return state_was;
})
.concat([this.state()]);
}
probabilistic_histo_walk(n : number) : Map<mNT, number> {
return histograph(this.probabilistic_walk(n));
}
actions(whichState : mNT = this.state() ) : Array<mNT> {
const wstate : ?Map<mNT, number> = this._reverse_actions.get(whichState);
if (wstate) { return [... wstate.keys()]; }
else { throw new Error(`No such state ${JSON.stringify(whichState)}`); }
}
list_states_having_action(whichState : mNT) : Array<mNT> {
const wstate : ?Map<mNT, number> = this._actions.get(whichState);
if (wstate) { return [... wstate.keys()]; }
else { throw new Error(`No such state ${JSON.stringify(whichState)}`); }
}
// comeback
/*
list_entrance_actions(whichState : mNT = this.state() ) : Array<mNT> {
return [... (this._reverse_action_targets.get(whichState) || new Map()).values()] // wasteful
.map( (edgeId:any) => (this._edges[edgeId] : any)) // whargarbl burn out any
.filter( (o:any) => o.to === whichState)
.map( filtered => filtered.from );
}
*/
list_exit_actions(whichState : mNT = this.state() ) : Array<?mNT> { // these are mNT, not ?mNT
const ra_base : ?Map<mNT, number> = this._reverse_actions.get(whichState);
if (!(ra_base)) { throw new Error(`No such state ${JSON.stringify(whichState)}`); }
return [... ra_base.values()]
.map ( (edgeId:number) : JssmTransition<mNT, mDT> => this._edges[edgeId] )
.filter ( (o:JssmTransition<mNT, mDT>) : boolean => o.from === whichState )
.map ( (filtered : JssmTransition<mNT, mDT>) : ?mNT => filtered.action );
}
probable_action_exits(whichState : mNT = this.state() ) : Array<mixed> { // these are mNT
const ra_base : ?Map<mNT, number> = this._reverse_actions.get(whichState);
if (!(ra_base)) { throw new Error(`No such state ${JSON.stringify(whichState)}`); }
return [... ra_base.values()]
.map ( (edgeId:number) : JssmTransition<mNT, mDT> => this._edges[edgeId] )
.filter ( (o:JssmTransition<mNT, mDT>) : boolean => o.from === whichState )
.map ( (filtered) : mixed => ( { action : filtered.action,
probability : filtered.probability } )
);
}
is_unenterable(whichState : mNT) : boolean {
// whargarbl should throw on unknown state
return this.list_entrances(whichState).length === 0;
}
has_unenterables() : boolean {
return this.states().some( (x) : boolean => this.is_unenterable(x));
}
is_terminal() : boolean {
return this.state_is_terminal(this.state());
}
state_is_terminal(whichState : mNT) : boolean {
// whargarbl should throw on unknown state
return this.list_exits(whichState).length === 0;
}
has_terminals() : boolean {
return this.states().some( (x) : boolean => this.state_is_terminal(x));
}
is_complete() : boolean {
return this.state_is_complete(this.state());
}
state_is_complete(whichState : mNT) : boolean {
const wstate : ?JssmGenericState<mNT> = this._states.get(whichState);
if (wstate) { return wstate.complete; }
else { throw new Error(`No such state ${JSON.stringify(whichState)}`); }
}
has_completes() : boolean {
return this.states().some( (x) : boolean => this.state_is_complete(x) );
}
action(name : mNT, newData? : mDT) : boolean {
// todo whargarbl implement hooks
// todo whargarbl implement data stuff
// todo major incomplete whargarbl comeback
if (this.valid_action(name, newData)) {
const edge : JssmTransition<mNT, mDT> = this.current_action_edge_for(name);
this._state = edge.to;
return true;
} else {
return false;
}
}
transition(newState : mNT, newData? : mDT) : boolean {
// todo whargarbl implement hooks
// todo whargarbl implement data stuff
// todo major incomplete whargarbl comeback
if (this.valid_transition(newState, newData)) {
this._state = newState;
return true;
} else {
return false;
}
}
// can leave machine in inconsistent state. generally do not use
force_transition(newState : mNT, newData? : mDT) : boolean {
// todo whargarbl implement hooks
// todo whargarbl implement data stuff
// todo major incomplete whargarbl comeback
if (this.valid_force_transition(newState, newData)) {
this._state = newState;
return true;
} else {
return false;
}
}
current_action_for(action : mNT) : number | void {
const action_base : ?Map<mNT, number> = this._actions.get(action);
return action_base? action_base.get(this.state()) : undefined;
}
current_action_edge_for(action : mNT) : JssmTransition<mNT, mDT> {
const idx : ?number = this.current_action_for(action);
if ((idx === undefined) || (idx === null)) { throw new Error(`No such action ${JSON.stringify(action)}`); }
return this._edges[idx];
}
valid_action(action : mNT, _newData? : mDT) : boolean { // todo comeback unignore newData
// todo whargarbl implement hooks
// todo whargarbl implement data stuff
// todo major incomplete whargarbl comeback
return this.current_action_for(action) !== undefined;
}
valid_transition(newState : mNT, _newData? : mDT) : boolean { // todo comeback unignore newData
// todo whargarbl implement hooks
// todo whargarbl implement data stuff
// todo major incomplete whargarbl comeback
const transition_for : ?JssmTransition<mNT, mDT> = this.lookup_transition_for(this.state(), newState);
if (!(transition_for)) { return false; }
if (transition_for.forced_only) { return false; }
return true;
}
valid_force_transition(newState : mNT, _newData? : mDT) : boolean { // todo comeback unignore newData
// todo whargarbl implement hooks
// todo whargarbl implement data stuff
// todo major incomplete whargarbl comeback
return (this.lookup_transition_for(this.state(), newState) !== undefined);
}
}
function sm<mNT, mDT>(template_strings : Array<string> /* , arguments */) : Machine<mNT, mDT> {
// foo`a${1}b${2}c` will come in as (['a','b','c'],1,2)
// this includes when a and c are empty strings
// therefore template_strings will always have one more el than template_args
// therefore map the smaller container and toss the last one on on the way out
return new Machine(make(template_strings.reduce(
// in general avoiding `arguments` is smart. however with the template
// string notation, as designed, it's not really worth the hassle
/* eslint-disable fp/no-arguments */
/* eslint-disable prefer-rest-params */
(acc, val, idx) : string => `${acc}${arguments[idx]}${val}` // arguments[0] is never loaded, so args doesn't need to be gated
/* eslint-enable prefer-rest-params */
/* eslint-enable fp/no-arguments */
)));
}
export {
version,
Machine,
make,
parse,
compile,
sm,
arrow_direction,
arrow_left_kind,
arrow_right_kind,
// todo whargarbl these should be exported to a utility library
seq, weighted_rand_select, histograph, weighted_sample_select, weighted_histo_key
};