jssm 5.144.8 → 5.145.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,10 +18,10 @@ Please edit the file it's derived from, instead: `./src/md/readme_base.md`
18
18
 
19
19
 
20
20
 
21
- * Generated for version 5.144.8 at 6/22/2026, 3:15:53 PM
21
+ * Generated for version 5.145.1 at 6/22/2026, 4:53:08 PM
22
22
 
23
23
  -->
24
- # jssm 5.144.8
24
+ # jssm 5.145.1
25
25
 
26
26
  [**Try the live editor**](https://stonecypher.github.io/jssm-viz-demo/graph_explorer.html) ·
27
27
  [Documentation](https://stonecypher.github.io/jssm/docs/) ·
@@ -312,7 +312,7 @@ That decision shows up everywhere downstream:
312
312
  or run `npm run benny` against your own machine.
313
313
 
314
314
  - **More thoroughly tested than any other JavaScript state-machine
315
- library.** 7,328 tests at 100.0% line coverage
315
+ library.** 7,389 tests at 100.0% line coverage
316
316
  ([report](https://coveralls.io/github/StoneCypher/jssm)), plus
317
317
  fuzz testing via `fast-check`, with parser test data across ten natural
318
318
  languages and Emoji.
@@ -445,11 +445,11 @@ If your contribution is missing here, please open an issue.
445
445
 
446
446
  <br/>
447
447
 
448
- ***7,328 tests***, run 82,370 times.
448
+ ***7,389 tests***, run 82,431 times.
449
449
 
450
- - 6,570 specs with 100.0% coverage
451
- - 758 fuzz tests with 73.9% coverage
452
- - 6,936 TypeScript lines - 1.1 tests per line, 11.9 generated tests per line
450
+ - 6,631 specs with 100.0% coverage
451
+ - 758 fuzz tests with 73.7% coverage
452
+ - 6,962 TypeScript lines - 1.1 tests per line, 11.8 generated tests per line
453
453
 
454
454
  [![Actions Status](https://github.com/StoneCypher/jssm/workflows/Node%20CI/badge.svg)](https://github.com/StoneCypher/jssm/actions)
455
455
  [![NPM version](https://img.shields.io/npm/v/jssm.svg)](https://www.npmjs.com/package/jssm)
@@ -23512,7 +23512,7 @@ var constants = /*#__PURE__*/Object.freeze({
23512
23512
  * Useful for runtime diagnostics and for embedding in serialized machine
23513
23513
  * snapshots so that deserializers can detect version-skew.
23514
23514
  */
23515
- const version = "5.144.8";
23515
+ const version = "5.145.1";
23516
23516
 
23517
23517
  // whargarbl lots of these return arrays could/should be sets
23518
23518
  const { state_name_chars, state_name_first_chars, action_label_chars } = constants;
@@ -24011,10 +24011,16 @@ class Machine {
24011
24011
  this._state_labels.set(key, labelled[0].value);
24012
24012
  }
24013
24013
  });
24014
- // O(1) duplicate-edge guard for the construction loop below: from -> Set<to>.
24015
- // Keyed by source state; mirrors each state's `to` array with constant-time
24016
- // membership so the dedup check is O(1) per edge rather than an O(out-degree)
24017
- // array scan (which made construction O(V*E) on dense graphs). #673
24014
+ // Duplicate-edge guard for the construction loop below, keyed
24015
+ // from -> (to -> Set<slot>). A "slot" distinguishes edges that share a
24016
+ // (from, to) pair: an action's name for an actioned edge, or '' for the one
24017
+ // permitted plain action-less edge. Multiple edges between the same pair
24018
+ // are allowed when they carry distinct actions (#325; the self-loop case is
24019
+ // #531), since they dispatch unambiguously through `action(name)`. A
24020
+ // probability-bearing action-less edge is exempt from the guard entirely,
24021
+ // so a weighted fan-out may name the same target more than once. The
24022
+ // nested Map+Set keeps the check O(1) per edge rather than an O(out-degree)
24023
+ // scan (which made construction O(V*E) on dense graphs). #673
24018
24024
  const seen_edges = new Map();
24019
24025
  // walk the transitions. single-lookup cursor fetches: each endpoint was
24020
24026
  // previously a get followed by a has on the same key (four hashes per
@@ -24038,23 +24044,35 @@ class Machine {
24038
24044
  cursor_to = { name: tr.to, from: [], to: [], complete: complete.includes(tr.to) };
24039
24045
  this._new_state(cursor_to);
24040
24046
  }
24041
- // guard against existing connections being re-added O(1) via the
24042
- // from -> Set<to> index instead of an O(out-degree) `cursor_from.to`
24043
- // array scan. Behaviour is identical: the same duplicate (from, to)
24044
- // pair throws the same JssmError. #673
24045
- let seen_to = seen_edges.get(tr.from);
24046
- if (seen_to === undefined) {
24047
- seen_to = new Set();
24048
- seen_edges.set(tr.from, seen_to);
24049
- }
24050
- if (seen_to.has(tr.to)) {
24051
- throw new JssmError(this, `already has ${JSON.stringify(tr.from)} to ${JSON.stringify(tr.to)}`);
24052
- }
24053
- else {
24054
- seen_to.add(tr.to);
24047
+ // record (from -> to) adjacency once per distinct target, even when
24048
+ // several edges connect the pair, so the `to`/`from` arrays stay sets of
24049
+ // state names. #673
24050
+ let to_slots = seen_edges.get(tr.from);
24051
+ if (to_slots === undefined) {
24052
+ to_slots = new Map();
24053
+ seen_edges.set(tr.from, to_slots);
24054
+ }
24055
+ let slots = to_slots.get(tr.to);
24056
+ if (slots === undefined) {
24057
+ slots = new Set();
24058
+ to_slots.set(tr.to, slots);
24055
24059
  cursor_from.to.push(tr.to);
24056
24060
  cursor_to.from.push(tr.from);
24057
24061
  }
24062
+ // duplicate-edge guard. A probability-bearing action-less edge is exempt
24063
+ // (a weighted fan-out may repeat a target); every other edge claims a slot
24064
+ // — its action name, or '' for the one plain action-less edge — and a
24065
+ // repeated slot throws. Distinct actions between the same pair coexist
24066
+ // (#325/#531).
24067
+ const edge_exempt = (!tr.action) && (tr.probability !== undefined);
24068
+ if (!edge_exempt) {
24069
+ const slot = tr.action ? tr.action : '';
24070
+ if (slots.has(slot)) {
24071
+ throw new JssmError(this, `already has ${JSON.stringify(tr.from)} to ${JSON.stringify(tr.to)}`
24072
+ + (tr.action ? ` on action ${JSON.stringify(tr.action)}` : ''));
24073
+ }
24074
+ slots.add(slot);
24075
+ }
24058
24076
  // add the edge; note its id
24059
24077
  this._edges.push(tr);
24060
24078
  const thisEdgeId = this._edges.length - 1;
@@ -24080,14 +24098,23 @@ class Machine {
24080
24098
  from_mapping = new Map();
24081
24099
  this._edge_map.set(tr.from, from_mapping);
24082
24100
  }
24083
- // const to_mapping = from_mapping.get(tr.to);
24084
- from_mapping.set(tr.to, thisEdgeId); // already checked that this mapping doesn't exist, above
24101
+ // first-declared wins: when several edges share a (from, to) pair (parallel
24102
+ // action edges, #325), lookup_transition_for resolves to the first one
24103
+ // declared, so it agrees with edges_between(...)[0].
24104
+ if (!from_mapping.has(tr.to)) {
24105
+ from_mapping.set(tr.to, thisEdgeId);
24106
+ }
24085
24107
  // numeric mirror of the (from, to) endpoint mapping. intern() rather
24086
24108
  // than id_of(): idempotent, and returns number (not number|undefined)
24087
24109
  // since both endpoints were just created above if missing.
24088
24110
  const from_id = this._state_interner.intern(tr.from);
24089
24111
  const to_id = this._state_interner.intern(tr.to);
24090
- this._edge_id_by_pair.set(pair_key(from_id, to_id), thisEdgeId);
24112
+ // first-declared wins (see _edge_map above): the transition fast-path that
24113
+ // reads this index resolves parallel (from, to) pairs to the first edge.
24114
+ const pair = pair_key(from_id, to_id);
24115
+ if (!this._edge_id_by_pair.has(pair)) {
24116
+ this._edge_id_by_pair.set(pair, thisEdgeId);
24117
+ }
24091
24118
  this._edge_to_ids[thisEdgeId] = to_id;
24092
24119
  // outbound adjacency: every edge originating at tr.from, regardless of action/target.
24093
24120
  // _edge_map above keys a single edge per (from, to) and overwrites on collision, which
package/dist/cdn/viz.js CHANGED
@@ -23537,7 +23537,7 @@ var constants = /*#__PURE__*/Object.freeze({
23537
23537
  * Useful for runtime diagnostics and for embedding in serialized machine
23538
23538
  * snapshots so that deserializers can detect version-skew.
23539
23539
  */
23540
- const version = "5.144.8";
23540
+ const version = "5.145.1";
23541
23541
 
23542
23542
  // whargarbl lots of these return arrays could/should be sets
23543
23543
  const { state_name_chars, state_name_first_chars, action_label_chars } = constants;
@@ -24036,10 +24036,16 @@ class Machine {
24036
24036
  this._state_labels.set(key, labelled[0].value);
24037
24037
  }
24038
24038
  });
24039
- // O(1) duplicate-edge guard for the construction loop below: from -> Set<to>.
24040
- // Keyed by source state; mirrors each state's `to` array with constant-time
24041
- // membership so the dedup check is O(1) per edge rather than an O(out-degree)
24042
- // array scan (which made construction O(V*E) on dense graphs). #673
24039
+ // Duplicate-edge guard for the construction loop below, keyed
24040
+ // from -> (to -> Set<slot>). A "slot" distinguishes edges that share a
24041
+ // (from, to) pair: an action's name for an actioned edge, or '' for the one
24042
+ // permitted plain action-less edge. Multiple edges between the same pair
24043
+ // are allowed when they carry distinct actions (#325; the self-loop case is
24044
+ // #531), since they dispatch unambiguously through `action(name)`. A
24045
+ // probability-bearing action-less edge is exempt from the guard entirely,
24046
+ // so a weighted fan-out may name the same target more than once. The
24047
+ // nested Map+Set keeps the check O(1) per edge rather than an O(out-degree)
24048
+ // scan (which made construction O(V*E) on dense graphs). #673
24043
24049
  const seen_edges = new Map();
24044
24050
  // walk the transitions. single-lookup cursor fetches: each endpoint was
24045
24051
  // previously a get followed by a has on the same key (four hashes per
@@ -24063,23 +24069,35 @@ class Machine {
24063
24069
  cursor_to = { name: tr.to, from: [], to: [], complete: complete.includes(tr.to) };
24064
24070
  this._new_state(cursor_to);
24065
24071
  }
24066
- // guard against existing connections being re-added O(1) via the
24067
- // from -> Set<to> index instead of an O(out-degree) `cursor_from.to`
24068
- // array scan. Behaviour is identical: the same duplicate (from, to)
24069
- // pair throws the same JssmError. #673
24070
- let seen_to = seen_edges.get(tr.from);
24071
- if (seen_to === undefined) {
24072
- seen_to = new Set();
24073
- seen_edges.set(tr.from, seen_to);
24074
- }
24075
- if (seen_to.has(tr.to)) {
24076
- throw new JssmError(this, `already has ${JSON.stringify(tr.from)} to ${JSON.stringify(tr.to)}`);
24077
- }
24078
- else {
24079
- seen_to.add(tr.to);
24072
+ // record (from -> to) adjacency once per distinct target, even when
24073
+ // several edges connect the pair, so the `to`/`from` arrays stay sets of
24074
+ // state names. #673
24075
+ let to_slots = seen_edges.get(tr.from);
24076
+ if (to_slots === undefined) {
24077
+ to_slots = new Map();
24078
+ seen_edges.set(tr.from, to_slots);
24079
+ }
24080
+ let slots = to_slots.get(tr.to);
24081
+ if (slots === undefined) {
24082
+ slots = new Set();
24083
+ to_slots.set(tr.to, slots);
24080
24084
  cursor_from.to.push(tr.to);
24081
24085
  cursor_to.from.push(tr.from);
24082
24086
  }
24087
+ // duplicate-edge guard. A probability-bearing action-less edge is exempt
24088
+ // (a weighted fan-out may repeat a target); every other edge claims a slot
24089
+ // — its action name, or '' for the one plain action-less edge — and a
24090
+ // repeated slot throws. Distinct actions between the same pair coexist
24091
+ // (#325/#531).
24092
+ const edge_exempt = (!tr.action) && (tr.probability !== undefined);
24093
+ if (!edge_exempt) {
24094
+ const slot = tr.action ? tr.action : '';
24095
+ if (slots.has(slot)) {
24096
+ throw new JssmError(this, `already has ${JSON.stringify(tr.from)} to ${JSON.stringify(tr.to)}`
24097
+ + (tr.action ? ` on action ${JSON.stringify(tr.action)}` : ''));
24098
+ }
24099
+ slots.add(slot);
24100
+ }
24083
24101
  // add the edge; note its id
24084
24102
  this._edges.push(tr);
24085
24103
  const thisEdgeId = this._edges.length - 1;
@@ -24105,14 +24123,23 @@ class Machine {
24105
24123
  from_mapping = new Map();
24106
24124
  this._edge_map.set(tr.from, from_mapping);
24107
24125
  }
24108
- // const to_mapping = from_mapping.get(tr.to);
24109
- from_mapping.set(tr.to, thisEdgeId); // already checked that this mapping doesn't exist, above
24126
+ // first-declared wins: when several edges share a (from, to) pair (parallel
24127
+ // action edges, #325), lookup_transition_for resolves to the first one
24128
+ // declared, so it agrees with edges_between(...)[0].
24129
+ if (!from_mapping.has(tr.to)) {
24130
+ from_mapping.set(tr.to, thisEdgeId);
24131
+ }
24110
24132
  // numeric mirror of the (from, to) endpoint mapping. intern() rather
24111
24133
  // than id_of(): idempotent, and returns number (not number|undefined)
24112
24134
  // since both endpoints were just created above if missing.
24113
24135
  const from_id = this._state_interner.intern(tr.from);
24114
24136
  const to_id = this._state_interner.intern(tr.to);
24115
- this._edge_id_by_pair.set(pair_key(from_id, to_id), thisEdgeId);
24137
+ // first-declared wins (see _edge_map above): the transition fast-path that
24138
+ // reads this index resolves parallel (from, to) pairs to the first edge.
24139
+ const pair = pair_key(from_id, to_id);
24140
+ if (!this._edge_id_by_pair.has(pair)) {
24141
+ this._edge_id_by_pair.set(pair, thisEdgeId);
24142
+ }
24116
24143
  this._edge_to_ids[thisEdgeId] = to_id;
24117
24144
  // outbound adjacency: every edge originating at tr.from, regardless of action/target.
24118
24145
  // _edge_map above keys a single edge per (from, to) and overwrites on collision, which
@@ -29256,37 +29283,55 @@ function colored_label(tr, which, color) {
29256
29283
  function states_to_edges_string(u_jssm, l_states, state_index, state_kinds) {
29257
29284
  const strike = new Set();
29258
29285
  const kind_of = (s) => { var _a; return (_a = state_kinds.get(s)) !== null && _a !== void 0 ? _a : 'base'; };
29286
+ // Render one solo directed edge `s -> ex` for transition `tr`.
29287
+ const solo_edge = (s, ex, tr) => {
29288
+ const ex_kind = kind_of(ex);
29289
+ const tailColor = text_color(ex_kind, '_solo');
29290
+ const labelInline = colored_label(tr, 'taillabel', tailColor);
29291
+ const label = transition_label(tr);
29292
+ const maybeLabel = label ? `taillabel="${doublequote(label)}";` : '';
29293
+ const arrowHead = arrow_for(tr);
29294
+ const edgeInline = `${maybeLabel}arrowhead=${arrowHead};color="${line_color(ex_kind, tr.kind, '_solo')}"`;
29295
+ return `${node_of(s, state_index)}->${node_of(ex, state_index)} [${labelInline}${edgeInline}];`;
29296
+ };
29259
29297
  return l_states.map((s) => u_jssm.list_exits(s).map((ex) => {
29260
29298
  if (strike.has(`${s}|${ex}`)) {
29261
29299
  return '';
29262
29300
  }
29263
- const edge_tr = u_jssm.lookup_transition_for(s, ex);
29264
- if (!edge_tr) {
29301
+ const forward = u_jssm.edges_between(s, ex);
29302
+ if (forward.length === 0) {
29265
29303
  return '';
29266
29304
  } // belt-and-suspenders; list_exits should always have a corresponding transition
29267
- const pair_tr = u_jssm.lookup_transition_for(ex, s);
29268
- const double = (pair_tr !== undefined) && (s !== ex);
29269
- const s_kind = kind_of(s);
29270
- const ex_kind = kind_of(ex);
29271
- // colored-HTML labels (per-direction, with text colors)
29272
- const headColor = text_color(s_kind, double ? '_1' : '_solo');
29273
- const tailColor = text_color(ex_kind, double ? '_2' : '_solo');
29274
- const labelInline = colored_label(double ? pair_tr : undefined, 'headlabel', headColor) +
29275
- colored_label(edge_tr, 'taillabel', tailColor);
29276
- // plain `headlabel="..."` / `taillabel="..."` fallback
29277
- const label = transition_label(edge_tr);
29278
- const rlabel = transition_label(pair_tr);
29279
- const maybeLabel = label ? `taillabel="${doublequote(label)}";` : '';
29280
- const maybeRLabel = rlabel ? `headlabel="${doublequote(rlabel)}";` : '';
29281
- const arrowHead = arrow_for(edge_tr);
29282
- const arrowTail = arrow_for(pair_tr);
29283
- const edgeInline = double
29284
- ? `${maybeLabel}${maybeRLabel}arrowhead=${arrowHead};arrowtail=${arrowTail};dir=both;color="${line_color(ex_kind, edge_tr.kind, '_1')}:${line_color(s_kind, (pair_tr !== null && pair_tr !== void 0 ? pair_tr : { kind: 'legal' }).kind, '_2')}"`
29285
- : `${maybeLabel}arrowhead=${arrowHead};color="${line_color(ex_kind, edge_tr.kind, '_solo')}"`;
29286
- if (pair_tr) {
29305
+ const reverse = (s !== ex) ? u_jssm.edges_between(ex, s) : [];
29306
+ // Bidirectional merge stays the default for the common case: exactly one
29307
+ // edge each way between two distinct states draws as a single `dir=both`
29308
+ // edge with head/tail labels. Parallel edges (#325) or self-loops fall
29309
+ // through to one directed line per edge.
29310
+ if (forward.length === 1 && reverse.length === 1) {
29311
+ const edge_tr = forward[0];
29312
+ const pair_tr = reverse[0];
29313
+ const s_kind = kind_of(s);
29314
+ const ex_kind = kind_of(ex);
29315
+ // colored-HTML labels (per-direction, with text colors)
29316
+ const headColor = text_color(s_kind, '_1');
29317
+ const tailColor = text_color(ex_kind, '_2');
29318
+ const labelInline = colored_label(pair_tr, 'headlabel', headColor) +
29319
+ colored_label(edge_tr, 'taillabel', tailColor);
29320
+ // plain `headlabel="..."` / `taillabel="..."` fallback
29321
+ const label = transition_label(edge_tr);
29322
+ const rlabel = transition_label(pair_tr);
29323
+ const maybeLabel = label ? `taillabel="${doublequote(label)}";` : '';
29324
+ const maybeRLabel = rlabel ? `headlabel="${doublequote(rlabel)}";` : '';
29325
+ const arrowHead = arrow_for(edge_tr);
29326
+ const arrowTail = arrow_for(pair_tr);
29327
+ const edgeInline = `${maybeLabel}${maybeRLabel}arrowhead=${arrowHead};arrowtail=${arrowTail};dir=both;color="${line_color(ex_kind, edge_tr.kind, '_1')}:${line_color(s_kind, pair_tr.kind, '_2')}"`;
29287
29328
  strike.add(`${ex}|${s}`);
29329
+ return `${node_of(s, state_index)}->${node_of(ex, state_index)} [${labelInline}${edgeInline}];`;
29288
29330
  }
29289
- return `${node_of(s, state_index)}->${node_of(ex, state_index)} [${labelInline}${edgeInline}];`;
29331
+ // one directed line per forward edge — parallel action edges (#325) and
29332
+ // self-loops (#531) draw each transition separately. The reverse edges,
29333
+ // if any, render when the loop reaches the (ex, s) pair (not struck here).
29334
+ return forward.map((tr) => solo_edge(s, ex, tr)).join(' ');
29290
29335
  }).join(' ')).join(' ');
29291
29336
  }
29292
29337
  /**
@@ -108,7 +108,7 @@ function parseFslArgs(argv, spec) {
108
108
  return { positional, flags, helpText };
109
109
  }
110
110
 
111
- const getVersion = () => "5.144.8";
111
+ const getVersion = () => "5.145.1";
112
112
  const SPEC = {
113
113
  flags: {
114
114
  help: { short: "h", boolean: true },