@turing-machine-js/machine 6.3.0 → 7.0.0-alpha.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/dist/index.cjs CHANGED
@@ -537,30 +537,40 @@ const movementDescriptionToLabel = {
537
537
  'do not move carer': 'S',
538
538
  };
539
539
  const symbolCommandDescriptionToLabel = {
540
- 'keep symbol command': '·',
541
- 'erase symbol command': '',
540
+ 'keep symbol command': 'K',
541
+ 'erase symbol command': 'E',
542
542
  };
543
543
  // Reserved characters in the encoded pattern string:
544
- // '*' per-cell ifOtherSymbol (matches any symbol on that tape)
545
- // '-' the tape's blank symbol
544
+ // '*' ASCII asterisk (U+002A) — per-cell ifOtherSymbol, matches any symbol
545
+ // on that tape. ASCII (not a fancier glyph like U+1F7B0) so it renders
546
+ // in every Mermaid environment and every monospace font. A literal `*`
547
+ // in the alphabet is unambiguous from the marker because it's quoted
548
+ // (`'*'`).
549
+ // 'B' the tape's blank symbol shorthand (in read patterns). A literal `B`
550
+ // in the alphabet is unambiguous from the marker because it's quoted
551
+ // (`'B'`).
546
552
  // ',' separates per-tape cells inside one pattern
547
553
  // '|' separates alternative patterns
548
- // '\\' escape prefix to represent any of '*', '-', ',', '|', or '\\' as a
549
- // *literal* alphabet symbol, prefix it with '\\' (e.g. '\\*' for literal '*').
554
+ // "'" surrounds a literal alphabet symbol e.g. `'0'` for literal `0`,
555
+ // `'X'` for literal `X`. The quoting is what visually separates literal
556
+ // symbols from the convention markers `*` / `B` and from the write
557
+ // commands `K` / `E`.
558
+ // '\\' escape prefix — to represent any of '*', 'B', ',', '|', "'", or '\\'
559
+ // as a *literal* alphabet symbol *inside* the quotes (e.g. `'\''` for
560
+ // a literal apostrophe).
561
+ const IF_OTHER_MARKER = '*';
562
+ const BLANK_MARKER = 'B';
550
563
  function escapeAlphabetSymbol(s) {
551
564
  return s
552
565
  .replace(/\\/g, '\\\\')
553
- .replace(/\*/g, '\\*')
554
- .replace(/-/g, '\\-')
555
- .replace(/,/g, '\\,')
556
- .replace(/\|/g, '\\|');
566
+ .replace(/'/g, "\\'");
557
567
  }
558
568
  function decodePatternDescription(description, alphabets) {
559
569
  if (!description) {
560
570
  return '?';
561
571
  }
562
572
  if (description === 'other symbol') {
563
- return '*';
573
+ return IF_OTHER_MARKER;
564
574
  }
565
575
  try {
566
576
  const patternList = JSON.parse(description);
@@ -568,12 +578,12 @@ function decodePatternDescription(description, alphabets) {
568
578
  .map((pattern) => pattern
569
579
  .map((s, tapeIx) => {
570
580
  if (s === null) {
571
- return '*';
581
+ return IF_OTHER_MARKER;
572
582
  }
573
583
  if (s === alphabets[tapeIx]?.[0]) {
574
- return '-';
584
+ return BLANK_MARKER;
575
585
  }
576
- return escapeAlphabetSymbol(s);
586
+ return `'${escapeAlphabetSymbol(s)}'`;
577
587
  })
578
588
  .join(','))
579
589
  .join('|');
@@ -611,19 +621,24 @@ function splitUnescaped(s, sep) {
611
621
  return parts;
612
622
  }
613
623
  function parsePatternString(s, alphabets) {
614
- if (s === '*') {
624
+ if (s === IF_OTHER_MARKER) {
615
625
  return null;
616
626
  }
617
627
  const alternatives = splitUnescaped(s, '|');
618
628
  return alternatives.map((alt) => {
619
629
  const cells = splitUnescaped(alt, ',');
620
630
  return cells.map((cell, tapeIx) => {
621
- if (cell === '*') {
631
+ if (cell === IF_OTHER_MARKER) {
622
632
  return null;
623
633
  }
624
- if (cell === '-') {
634
+ if (cell === BLANK_MARKER) {
625
635
  return alphabets[tapeIx]?.[0] ?? cell;
626
636
  }
637
+ // Literal alphabet symbols are wrapped in single quotes by
638
+ // `decodePatternDescription` — strip them on the way back.
639
+ if (cell.length >= 2 && cell.startsWith("'") && cell.endsWith("'")) {
640
+ return cell.slice(1, -1);
641
+ }
627
642
  return cell;
628
643
  });
629
644
  });
@@ -641,12 +656,17 @@ function parseMovementLabel(label) {
641
656
  return m;
642
657
  }
643
658
  function parseWriteSymbolLabel(label) {
644
- if (label === '·') {
659
+ if (label === 'K') {
645
660
  return symbolCommands.keep;
646
661
  }
647
- if (label === '') {
662
+ if (label === 'E') {
648
663
  return symbolCommands.erase;
649
664
  }
665
+ // Literal alphabet symbols are wrapped in single quotes by
666
+ // `decodeWriteSymbol` — strip them on the way back.
667
+ if (label.length >= 2 && label.startsWith("'") && label.endsWith("'")) {
668
+ return label.slice(1, -1);
669
+ }
650
670
  return label;
651
671
  }
652
672
  function decodeWriteSymbol(symbol) {
@@ -654,7 +674,7 @@ function decodeWriteSymbol(symbol) {
654
674
  const description = symbol.description ?? '?';
655
675
  return symbolCommandDescriptionToLabel[description] ?? description;
656
676
  }
657
- return symbol;
677
+ return `'${symbol}'`;
658
678
  }
659
679
  // Format converters (toMermaid / fromMermaid) live in ./graphFormats.
660
680
 
@@ -669,7 +689,7 @@ var __classPrivateFieldGet$1 = (undefined && undefined.__classPrivateFieldGet) |
669
689
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
670
690
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
671
691
  };
672
- var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _State_id, _State_name, _State_overrodeHaltState, _State_symbolToDataMap, _State_debugRef;
692
+ var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef;
673
693
  const ifOtherSymbol = Symbol('other symbol');
674
694
  // Module-private symbol used by DebugConfig setters to call State's validator
675
695
  // without exposing the validator on the public surface.
@@ -714,10 +734,18 @@ _DebugConfig_ownerState = new WeakMap(), _DebugConfig_before = new WeakMap(), _D
714
734
  class State {
715
735
  constructor(stateDefinition = null, name) {
716
736
  _State_id.set(this, id(this));
737
+ // Not `readonly` because `withOverriddenHaltState` and `fromGraph` set the
738
+ // composed name on a no-arg `new State()` to bypass the constructor's
739
+ // user-facing name validation (composite names contain `(` and `)`).
717
740
  _State_name.set(this, void 0);
718
- _State_overrodeHaltState.set(this, null);
741
+ _State_overriddenHaltState.set(this, null);
742
+ // For wrapper states (produced by `withOverriddenHaltState`), points at the
743
+ // State whose transition map was wrapped. `null` on bare/atomic states.
744
+ // Used by `toGraph` to collapse the wrapper-and-its-bare pair into a single
745
+ // "wrapped bare" graph node — see the v7 emit redesign for #138.
746
+ _State_bareState.set(this, null);
719
747
  _State_symbolToDataMap.set(this, new Map());
720
- // Shared mutable cell — withOverrodeHaltState wrappers reference the same
748
+ // Shared mutable cell — withOverriddenHaltState wrappers reference the same
721
749
  // object so that `state.debug = ...` (and nullings) propagate across them.
722
750
  // Note: toGraph / fromGraph deliberately do not serialize debug — debug is
723
751
  // a runtime concern, not part of the structural graph.
@@ -763,6 +791,9 @@ class State {
763
791
  });
764
792
  });
765
793
  }
794
+ if (name !== undefined && /[()]/.test(name)) {
795
+ throw new Error(`invalid state name "${name}": must not contain '(' or ')' (reserved as wrapper-composition delimiters in withOverriddenHaltState)`);
796
+ }
766
797
  __classPrivateFieldSet$1(this, _State_name, name ?? `id:${__classPrivateFieldGet$1(this, _State_id, "f")}`, "f");
767
798
  }
768
799
  get id() {
@@ -774,8 +805,8 @@ class State {
774
805
  get isHalt() {
775
806
  return __classPrivateFieldGet$1(this, _State_id, "f") === 0;
776
807
  }
777
- get overrodeHaltState() {
778
- return __classPrivateFieldGet$1(this, _State_overrodeHaltState, "f");
808
+ get overriddenHaltState() {
809
+ return __classPrivateFieldGet$1(this, _State_overriddenHaltState, "f");
779
810
  }
780
811
  get ref() {
781
812
  return this;
@@ -803,7 +834,7 @@ class State {
803
834
  __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, value);
804
835
  }
805
836
  /** @internal — invoked by DebugConfig setters via module-private symbol. */
806
- [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overrodeHaltState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), validateDebugFilter)](fieldName, filter) {
837
+ [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), validateDebugFilter)](fieldName, filter) {
807
838
  if (filter === undefined)
808
839
  return;
809
840
  // #108 part 2: `.after` on haltState has no semantic anchor — halt is
@@ -850,11 +881,17 @@ class State {
850
881
  }
851
882
  throw new Error(`No nextState for symbol at state named ${__classPrivateFieldGet$1(this, _State_id, "f")}`);
852
883
  }
853
- withOverrodeHaltState(overrodeHaltState) {
854
- const state = new State(null, `${this.name}>${overrodeHaltState.name}`);
884
+ withOverriddenHaltState(overriddenHaltState) {
885
+ // Construct with no name, then overwrite #name directly — the composed
886
+ // name contains `(` and `)` by design, which the constructor's user-facing
887
+ // validation would reject. Internal composition bypasses validation via
888
+ // private-field access (legal within the same class).
889
+ const state = new State();
890
+ __classPrivateFieldSet$1(state, _State_name, `${this.name}(${overriddenHaltState.name})`, "f");
855
891
  __classPrivateFieldSet$1(state, _State_symbolToDataMap, __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f"), "f");
856
- __classPrivateFieldSet$1(state, _State_overrodeHaltState, overrodeHaltState, "f");
892
+ __classPrivateFieldSet$1(state, _State_overriddenHaltState, overriddenHaltState, "f");
857
893
  __classPrivateFieldSet$1(state, _State_debugRef, __classPrivateFieldGet$1(this, _State_debugRef, "f"), "f");
894
+ __classPrivateFieldSet$1(state, _State_bareState, this, "f");
858
895
  return state;
859
896
  }
860
897
  // Single-state introspection — no traversal, no tapeBlock required.
@@ -885,52 +922,196 @@ class State {
885
922
  id: __classPrivateFieldGet$1(state, _State_id, "f"),
886
923
  name: __classPrivateFieldGet$1(state, _State_name, "f"),
887
924
  isHalt: state.isHalt,
888
- overrodeHaltState: __classPrivateFieldGet$1(state, _State_overrodeHaltState, "f")
889
- ? { id: __classPrivateFieldGet$1(state, _State_overrodeHaltState, "f").id, name: __classPrivateFieldGet$1(state, _State_overrodeHaltState, "f").name }
925
+ overriddenHaltState: __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f")
926
+ ? { id: __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f").id, name: __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f").name }
890
927
  : null,
891
928
  transitions,
892
929
  };
893
930
  }
931
+ // Walks the State graph and emits a `Graph` data structure. v7 emit shape:
932
+ // wrapper-States (those with `#overriddenHaltState !== null`) are collapsed
933
+ // onto their bare's representation in the graph, with the wrapper's own `#id`
934
+ // used as the graph node id, `isWrapped: true`, and `overriddenHaltStateId`
935
+ // set to the override's collapsed id. A per-wrapper "halt marker" graph node
936
+ // (id = negative-of-the-wrapper-id, `isHalt: true, isHaltMarker: true`) is
937
+ // synthesized; the bare's halt-bound transitions are rewritten to target the
938
+ // halt marker instead of the real one.
939
+ //
940
+ // Halt-marker node ids use the negation of the wrapper's id so they sit in a
941
+ // disjoint integer range from real ids (which are always non-negative). Real
942
+ // halt is always id 0.
894
943
  static toGraph(initialState, tapeBlock) {
895
944
  const nodes = {};
896
- const queue = [initialState];
897
945
  const alphabets = tapeBlock.alphabets.map((alphabet) => alphabet.symbols);
946
+ // Map from a wrapper-State to the "collapsed" graph node id used to refer
947
+ // to it in transitions. Same as the wrapper's `#id`, recorded for clarity
948
+ // when rewriting transition targets.
949
+ const wrapperGraphId = (s) => __classPrivateFieldGet$1(s, _State_id, "f");
950
+ const haltMarkerIdFor = (wrapper) => -__classPrivateFieldGet$1(wrapper, _State_id, "f");
951
+ // The `initialId` is the user-passed start. If it's a wrapper, the
952
+ // collapsed graph node uses its `#id`; otherwise its own `#id`.
953
+ const initialId = __classPrivateFieldGet$1(initialState, _State_id, "f");
954
+ const queue = [];
955
+ // Decide how to enqueue the start: if it's a wrapper, enqueue its bare with
956
+ // the wrapper as context; otherwise enqueue the state itself.
957
+ if (__classPrivateFieldGet$1(initialState, _State_overriddenHaltState, "f") && __classPrivateFieldGet$1(initialState, _State_bareState, "f")) {
958
+ queue.push({ state: __classPrivateFieldGet$1(initialState, _State_bareState, "f"), wrapperContext: initialState });
959
+ }
960
+ else {
961
+ queue.push({ state: initialState, wrapperContext: null });
962
+ }
898
963
  while (queue.length > 0) {
899
- const current = queue.shift();
900
- if (__classPrivateFieldGet$1(current, _State_id, "f") in nodes) {
964
+ const { state, wrapperContext } = queue.shift();
965
+ if (state.isHalt) {
966
+ // Real halt — always id 0, single node.
967
+ if (!(0 in nodes)) {
968
+ nodes[0] = {
969
+ id: 0,
970
+ name: __classPrivateFieldGet$1(state, _State_name, "f"),
971
+ isHalt: true,
972
+ isHaltMarker: false,
973
+ isWrapped: false,
974
+ transitions: [],
975
+ overriddenHaltStateId: null,
976
+ };
977
+ }
978
+ continue;
979
+ }
980
+ if (wrapperContext !== null) {
981
+ // Process `state` (the bare) collapsed under `wrapperContext` (the
982
+ // wrapper). Graph node id = wrapper's id.
983
+ const collapsedId = wrapperGraphId(wrapperContext);
984
+ if (collapsedId in nodes) {
985
+ continue;
986
+ }
987
+ const haltMarkerId = haltMarkerIdFor(wrapperContext);
988
+ const overrideTarget = __classPrivateFieldGet$1(wrapperContext, _State_overriddenHaltState, "f");
989
+ // The override target's collapsed id: if the override is itself a
990
+ // wrapper, its graph node id is `overrideTarget.#id` (its own wrapper
991
+ // id); otherwise its own bare id.
992
+ const overrideGraphId = __classPrivateFieldGet$1(overrideTarget, _State_overriddenHaltState, "f")
993
+ ? wrapperGraphId(overrideTarget)
994
+ : __classPrivateFieldGet$1(overrideTarget, _State_id, "f");
995
+ // Emit the halt-marker node if not already present (one per wrapper).
996
+ if (!(haltMarkerId in nodes)) {
997
+ nodes[haltMarkerId] = {
998
+ id: haltMarkerId,
999
+ name: 'halt',
1000
+ isHalt: true,
1001
+ isHaltMarker: true,
1002
+ isWrapped: false,
1003
+ transitions: [],
1004
+ overriddenHaltStateId: null,
1005
+ };
1006
+ }
1007
+ // Build the collapsed node.
1008
+ const collapsedNode = {
1009
+ id: collapsedId,
1010
+ name: __classPrivateFieldGet$1(state, _State_name, "f"),
1011
+ isHalt: false,
1012
+ isHaltMarker: false,
1013
+ isWrapped: true,
1014
+ transitions: [],
1015
+ overriddenHaltStateId: overrideGraphId,
1016
+ };
1017
+ nodes[collapsedId] = collapsedNode;
1018
+ let patternIx = 0;
1019
+ for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
1020
+ let target;
1021
+ try {
1022
+ target = nextState instanceof State ? nextState : nextState.ref;
1023
+ }
1024
+ catch {
1025
+ patternIx += 1;
1026
+ continue;
1027
+ }
1028
+ // Retarget transitions per Variant X conventions:
1029
+ // - target == haltState → halt marker (stays inside the subgraph)
1030
+ // - target == bare (self-loop) → the collapsed wrapper id
1031
+ // - target is itself a wrapper → that wrapper's collapsed id
1032
+ // - else → target's own id
1033
+ let nextStateId;
1034
+ if (target.isHalt) {
1035
+ nextStateId = haltMarkerId;
1036
+ }
1037
+ else if (target === state) {
1038
+ nextStateId = collapsedId;
1039
+ }
1040
+ else if (__classPrivateFieldGet$1(target, _State_overriddenHaltState, "f") && __classPrivateFieldGet$1(target, _State_bareState, "f")) {
1041
+ nextStateId = wrapperGraphId(target);
1042
+ queue.push({ state: __classPrivateFieldGet$1(target, _State_bareState, "f"), wrapperContext: target });
1043
+ }
1044
+ else {
1045
+ nextStateId = __classPrivateFieldGet$1(target, _State_id, "f");
1046
+ queue.push({ state: target, wrapperContext: null });
1047
+ }
1048
+ collapsedNode.transitions.push({
1049
+ pattern: decodePatternDescription(sym.description, alphabets),
1050
+ command: command.tapesCommands.map((tc) => ({
1051
+ symbol: decodeWriteSymbol(tc.symbol),
1052
+ movement: decodeMovement(tc.movement.description),
1053
+ })),
1054
+ nextStateId,
1055
+ id: `${collapsedId}-${patternIx}`,
1056
+ });
1057
+ patternIx += 1;
1058
+ }
1059
+ // Enqueue the override target so its own node is emitted.
1060
+ if (__classPrivateFieldGet$1(overrideTarget, _State_overriddenHaltState, "f") && __classPrivateFieldGet$1(overrideTarget, _State_bareState, "f")) {
1061
+ queue.push({ state: __classPrivateFieldGet$1(overrideTarget, _State_bareState, "f"), wrapperContext: overrideTarget });
1062
+ }
1063
+ else {
1064
+ queue.push({ state: overrideTarget, wrapperContext: null });
1065
+ }
1066
+ continue;
1067
+ }
1068
+ // Non-wrapper context: emit `state` as a regular node.
1069
+ if (__classPrivateFieldGet$1(state, _State_id, "f") in nodes) {
901
1070
  continue;
902
1071
  }
903
1072
  const node = {
904
- id: __classPrivateFieldGet$1(current, _State_id, "f"),
905
- name: __classPrivateFieldGet$1(current, _State_name, "f"),
906
- isHalt: current.isHalt,
1073
+ id: __classPrivateFieldGet$1(state, _State_id, "f"),
1074
+ name: __classPrivateFieldGet$1(state, _State_name, "f"),
1075
+ isHalt: false,
1076
+ isHaltMarker: false,
1077
+ isWrapped: false,
907
1078
  transitions: [],
908
- overrodeHaltStateId: __classPrivateFieldGet$1(current, _State_overrodeHaltState, "f")?.id ?? null,
1079
+ overriddenHaltStateId: null,
909
1080
  };
910
- nodes[__classPrivateFieldGet$1(current, _State_id, "f")] = node;
911
- if (__classPrivateFieldGet$1(current, _State_overrodeHaltState, "f")) {
912
- queue.push(__classPrivateFieldGet$1(current, _State_overrodeHaltState, "f"));
913
- }
914
- for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(current, _State_symbolToDataMap, "f")) {
1081
+ nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = node;
1082
+ let patternIx = 0;
1083
+ for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
915
1084
  let target;
916
1085
  try {
917
1086
  target = nextState instanceof State ? nextState : nextState.ref;
918
1087
  }
919
1088
  catch {
1089
+ patternIx += 1;
920
1090
  continue;
921
1091
  }
1092
+ let nextStateId;
1093
+ if (__classPrivateFieldGet$1(target, _State_overriddenHaltState, "f") && __classPrivateFieldGet$1(target, _State_bareState, "f")) {
1094
+ // Transition into a wrapper — use its collapsed id.
1095
+ nextStateId = wrapperGraphId(target);
1096
+ queue.push({ state: __classPrivateFieldGet$1(target, _State_bareState, "f"), wrapperContext: target });
1097
+ }
1098
+ else {
1099
+ nextStateId = __classPrivateFieldGet$1(target, _State_id, "f");
1100
+ queue.push({ state: target, wrapperContext: null });
1101
+ }
922
1102
  node.transitions.push({
923
1103
  pattern: decodePatternDescription(sym.description, alphabets),
924
1104
  command: command.tapesCommands.map((tc) => ({
925
1105
  symbol: decodeWriteSymbol(tc.symbol),
926
1106
  movement: decodeMovement(tc.movement.description),
927
1107
  })),
928
- nextStateId: target.id,
1108
+ nextStateId,
1109
+ id: `${__classPrivateFieldGet$1(state, _State_id, "f")}-${patternIx}`,
929
1110
  });
930
- queue.push(target);
1111
+ patternIx += 1;
931
1112
  }
932
1113
  }
933
- return { initialId: __classPrivateFieldGet$1(initialState, _State_id, "f"), alphabets, nodes };
1114
+ return { initialId, alphabets, nodes };
934
1115
  }
935
1116
  // Inverse of toGraph: rebuilds a State graph (and a fresh TapeBlock with the
936
1117
  // graph's alphabets) from a serialized Graph. Round-trips with toGraph in
@@ -982,7 +1163,12 @@ class State {
982
1163
  nextState,
983
1164
  };
984
1165
  }
985
- bareStates[nodeId] = new State(stateDefinition, node.name);
1166
+ // Graph-sourced names may contain `(` and `)` (composite wrapper names
1167
+ // emitted by toGraph). Bypass the constructor's user-facing name
1168
+ // validation by constructing without a name and assigning #name directly.
1169
+ const bare = new State(stateDefinition);
1170
+ __classPrivateFieldSet$1(bare, _State_name, node.name, "f");
1171
+ bareStates[nodeId] = bare;
986
1172
  }
987
1173
  // Pass 3: apply overrideHaltStates transitively.
988
1174
  const finalStates = {};
@@ -1001,8 +1187,8 @@ class State {
1001
1187
  }
1002
1188
  inProgress.add(nodeId);
1003
1189
  let state = bareStates[nodeId];
1004
- if (node.overrodeHaltStateId !== null) {
1005
- state = bareStates[nodeId].withOverrodeHaltState(getFinal(node.overrodeHaltStateId));
1190
+ if (node.overriddenHaltStateId !== null) {
1191
+ state = bareStates[nodeId].withOverriddenHaltState(getFinal(node.overriddenHaltStateId));
1006
1192
  }
1007
1193
  inProgress.delete(nodeId);
1008
1194
  finalStates[nodeId] = state;
@@ -1060,7 +1246,7 @@ class TuringMachine {
1060
1246
  get tapeBlock() {
1061
1247
  return __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f");
1062
1248
  }
1063
- async run({ initialState, stepsLimit = 1e5, onStep, onPause, debug = true, }) {
1249
+ async run({ initialState, stepsLimit = 1e5, onStep, onPause, onIter, debug = true, }) {
1064
1250
  const generator = this.runStepByStep({ initialState, stepsLimit });
1065
1251
  for (const machineState of generator) {
1066
1252
  // Per-iter lifecycle: before → step → after. All three operate on the
@@ -1069,12 +1255,15 @@ class TuringMachine {
1069
1255
  if (debug && machineState.debugBreak?.before && onPause) {
1070
1256
  await onPause({ ...machineState, debugBreak: { before: true } });
1071
1257
  }
1072
- if (onStep instanceof Function) {
1258
+ if (onStep) {
1073
1259
  onStep(machineState);
1074
1260
  }
1075
1261
  if (debug && machineState.debugBreak?.after && onPause) {
1076
1262
  await onPause({ ...machineState, debugBreak: { after: true } });
1077
1263
  }
1264
+ if (onIter) {
1265
+ await onIter(machineState);
1266
+ }
1078
1267
  }
1079
1268
  }
1080
1269
  *runStepByStep({ initialState, stepsLimit = 1e5 }) {
@@ -1084,8 +1273,8 @@ class TuringMachine {
1084
1273
  __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f")[lockSymbol].lock(executionSymbol);
1085
1274
  const stack = __classPrivateFieldGet(this, _TuringMachine_stack, "f");
1086
1275
  let state = initialState;
1087
- if (state.overrodeHaltState) {
1088
- stack.push(state.overrodeHaltState);
1276
+ if (state.overriddenHaltState) {
1277
+ stack.push(state.overriddenHaltState);
1089
1278
  }
1090
1279
  let i = 0;
1091
1280
  while (!state.isHalt) {
@@ -1139,8 +1328,8 @@ class TuringMachine {
1139
1328
  if (nextState.isHalt && stack.length) {
1140
1329
  nextState = stack.pop();
1141
1330
  }
1142
- if (state !== nextState && nextState.overrodeHaltState) {
1143
- stack.push(nextState.overrodeHaltState);
1331
+ if (state !== nextState && nextState.overriddenHaltState) {
1332
+ stack.push(nextState.overriddenHaltState);
1144
1333
  }
1145
1334
  state = nextState;
1146
1335
  }
@@ -1164,32 +1353,131 @@ _TuringMachine_tapeBlock = new WeakMap(), _TuringMachine_stack = new WeakMap();
1164
1353
  //
1165
1354
  // Currently only Mermaid flowchart syntax is supported. Future formats
1166
1355
  // (Graphviz, JSON-LD, custom DSL) belong here too.
1356
+ //
1357
+ // v7 emit shape (#138/#139):
1358
+ // - Each wrapper-State collapses onto its bare's representation. The collapsed
1359
+ // graph node has `isWrapped: true` and is emitted as Mermaid `[[…]]`
1360
+ // (subroutine / double-walled-rectangle) shape, inside a `subgraph
1361
+ // w_${id}["halt frame"] … end` block. A synthesized "halt marker" graph
1362
+ // node (with `isHalt: true, isHaltMarker: true`, id = -wrapperId in graph
1363
+ // data) sits inside the subgraph and serves as the local landing point for
1364
+ // the bare's halt-bound transitions. The dotted onHalt edge runs from the
1365
+ // `[[bare]]` directly to the override target, crossing the subgraph border.
1366
+ // - Real halt (id 0) is emitted as `s0(((halt)))` outside any subgraph.
1367
+ // - Halt marker nodes use the Mermaid id `c${absId}` (where `absId = -id`)
1368
+ // since Mermaid IDs must match /[A-Za-z][A-Za-z0-9_]*/ — negative numbers
1369
+ // are not legal syntax.
1370
+ // Maps a graph node id to its Mermaid id.
1371
+ // - non-negative id N → "sN"
1372
+ // - negative id -N (halt marker) → "cN"
1373
+ function mermaidIdFor(id) {
1374
+ return id < 0 ? `c${-id}` : `s${id}`;
1375
+ }
1376
+ // Inverse of mermaidIdFor.
1377
+ function parseMermaidId(s) {
1378
+ if (s.startsWith('c')) {
1379
+ return -Number(s.slice(1));
1380
+ }
1381
+ return Number(s.slice(1));
1382
+ }
1167
1383
  function toMermaid(graph) {
1168
1384
  const lines = [
1169
1385
  'flowchart TD',
1170
1386
  `%% alphabets: ${JSON.stringify(graph.alphabets)}`,
1171
1387
  ];
1172
- for (const node of Object.values(graph.nodes)) {
1173
- const id = `s${node.id}`;
1388
+ // Sort nodes by id (ascending — real halt first at 0, regular states next,
1389
+ // negative-id halt markers last). Deterministic emit lets `toMermaid` →
1390
+ // `fromMermaid` → `toMermaid` round-trip stably (regression for #139).
1391
+ const nodes = Object.values(graph.nodes).slice().sort((a, b) => a.id - b.id);
1392
+ const wrappedNodes = nodes.filter((n) => n.isWrapped);
1393
+ // Convention: wrapped node id N → halt marker id -N.
1394
+ const haltMarkerIdFor = (wrappedId) => -wrappedId;
1395
+ // Set of halt-marker ids that belong to some wrapper (= are inside a subgraph).
1396
+ const haltMarkerIds = new Set();
1397
+ for (const w of wrappedNodes) {
1398
+ const haltMarkerId = haltMarkerIdFor(w.id);
1399
+ if (haltMarkerId in graph.nodes) {
1400
+ haltMarkerIds.add(haltMarkerId);
1401
+ }
1402
+ }
1403
+ // Emit non-subgraph nodes first: real halt + regular non-wrapped nodes.
1404
+ // No special round-shape `((…))` for the initial — the `idle -. enter .->`
1405
+ // arrow emitted below is the sole "start here" signal.
1406
+ for (const node of nodes) {
1407
+ if (node.isWrapped || haltMarkerIds.has(node.id)) {
1408
+ continue;
1409
+ }
1410
+ const id = mermaidIdFor(node.id);
1174
1411
  if (node.isHalt) {
1175
1412
  lines.push(` ${id}(((halt)))`);
1176
1413
  }
1177
- else if (node.id === graph.initialId) {
1178
- lines.push(` ${id}(("${node.name}"))`);
1179
- }
1180
1414
  else {
1181
1415
  lines.push(` ${id}["${node.name}"]`);
1182
1416
  }
1183
1417
  }
1184
- for (const node of Object.values(graph.nodes)) {
1185
- for (const t of node.transitions) {
1186
- // Per-tape commands separated with ',' to mirror the pattern syntax.
1187
- const cmd = t.command.map((c) => `${c.symbol}/${c.movement}`).join(',');
1188
- const label = `${t.pattern} → ${cmd}`;
1189
- lines.push(` s${node.id} -- "${label}" --> s${t.nextStateId}`);
1418
+ // `idle` sentinel = pre-execution marker for the machine. Always emitted,
1419
+ // with a labeled dotted arrow `idle -. enter .-> sN` to the initial state.
1420
+ // Symmetric with the `onHalt` dotted convention used by wrapper redirects.
1421
+ // Visual-only `idle` is not a graph node.
1422
+ lines.push(' idle([idle])');
1423
+ // Emit one subgraph per wrapper, in sorted wrapped-id order.
1424
+ for (const wrapped of wrappedNodes) {
1425
+ const wrappedMid = mermaidIdFor(wrapped.id);
1426
+ const haltMarkerId = haltMarkerIdFor(wrapped.id);
1427
+ const haltMarkerMid = mermaidIdFor(haltMarkerId);
1428
+ lines.push(` subgraph w_${wrapped.id}["halt frame"]`);
1429
+ lines.push(` ${wrappedMid}[["${wrapped.name}"]]`);
1430
+ if (haltMarkerId in graph.nodes) {
1431
+ lines.push(` ${haltMarkerMid}(((halt)))`);
1432
+ }
1433
+ lines.push(' end');
1434
+ }
1435
+ // Enter arrow: emitted after subgraphs so it visually points at the initial
1436
+ // node (whether plain `[…]` or wrapped `[[…]]` inside a subgraph).
1437
+ lines.push(` idle -. enter .-> ${mermaidIdFor(graph.initialId)}`);
1438
+ // Emit transitions per-node in sorted node-id order. Within a node,
1439
+ // transitions emit in their stored array order (which mirrors the source
1440
+ // state's symbol-map insertion order — stable per State instance).
1441
+ for (const node of nodes) {
1442
+ if (node.isHalt && !node.isHaltMarker) {
1443
+ continue;
1190
1444
  }
1191
- if (node.overrodeHaltStateId !== null) {
1192
- lines.push(` s${node.id} -. onHalt .-> s${node.overrodeHaltStateId}`);
1445
+ for (const t of node.transitions) {
1446
+ // Bracketed-tape-block format (v7): each role-list read alternatives,
1447
+ // writes, movements — wraps in `[…]` to mark "this is a tape-block
1448
+ // reading". Brackets stay even for single-tape machines; the `[…]` is
1449
+ // the tape-block concept indicator.
1450
+ //
1451
+ // Single-tape: ['X'] → [K]/[R]
1452
+ // Single-tape + alternation: ['^']|['1']|['0'] → [K]/[S]
1453
+ // Two-tape: ['0','a'] → [K,'1']/[R,S]
1454
+ // Two-tape + alternation: ['0','a']|['1','b'] → [K,K]/[R,L]
1455
+ //
1456
+ // Alternation is ALWAYS per-pattern-bracket — one full bracketed list
1457
+ // per alternative — regardless of tape count. Pedagogically each
1458
+ // alternative is its own drawn transition; a compact in-bracket form
1459
+ // (`['^'|'1']`) would read as cross-product semantics in multi-tape
1460
+ // (`['0'|'1','a'|'b']` = 4 combos, not 2 paired alternatives), so we
1461
+ // avoid introducing it for the single-tape case too.
1462
+ const alternatives = t.pattern.split('|');
1463
+ const reads = alternatives.map((alt) => `[${alt}]`).join('|');
1464
+ const writes = `[${t.command.map((c) => c.symbol).join(',')}]`;
1465
+ const moves = `[${t.command.map((c) => c.movement).join(',')}]`;
1466
+ const label = `${reads} → ${writes}/${moves}`;
1467
+ // Thicker `==>` arrow when the transition crosses INTO a wrapper —
1468
+ // signals "this transition pushes that wrapper's override onto the
1469
+ // runtime stack" (per `TuringMachine.run` line ~220's
1470
+ // `if (state !== nextState && nextState.overriddenHaltState) push(...)`).
1471
+ // Self-loops (state === nextState) don't push at runtime — keep the
1472
+ // regular `-->` for those even when the target is wrapped.
1473
+ const targetNode = graph.nodes[t.nextStateId];
1474
+ const isEnteringWrapper = targetNode && targetNode.isWrapped && t.nextStateId !== node.id;
1475
+ const lineSegment = isEnteringWrapper ? '==' : '--';
1476
+ const arrowTip = isEnteringWrapper ? '==>' : '-->';
1477
+ lines.push(` ${mermaidIdFor(node.id)} ${lineSegment} "${label}" ${arrowTip} ${mermaidIdFor(t.nextStateId)}`);
1478
+ }
1479
+ if (node.overriddenHaltStateId !== null) {
1480
+ lines.push(` ${mermaidIdFor(node.id)} -. onHalt .-> ${mermaidIdFor(node.overriddenHaltStateId)}`);
1193
1481
  }
1194
1482
  }
1195
1483
  return lines.join('\n');
@@ -1205,11 +1493,16 @@ function toMermaid(graph) {
1205
1493
  // per-tape segments are split on ','. If your alphabet contains '/' or ','
1206
1494
  // as literal symbols, the parser cannot disambiguate. Stick to alphabets
1207
1495
  // without those characters when round-tripping through Mermaid.
1208
- const haltNodeRegex = /^s(\d+)\(\(\(halt\)\)\)$/;
1209
- const initialNodeRegex = /^s(\d+)\(\("([^"]*)"\)\)$/;
1210
- const regularNodeRegex = /^s(\d+)\["([^"]*)"\]$/;
1211
- const transitionRegex = /^s(\d+)\s+--\s+"(.*)"\s+-->\s+s(\d+)$/;
1212
- const onHaltRegex = /^s(\d+)\s+-\.\s+onHalt\s+\.->\s+s(\d+)$/;
1496
+ const haltNodeRegex = /^([sc]\d+)\(\(\(halt\)\)\)$/;
1497
+ const regularNodeRegex = /^(s\d+)\["([^"]*)"\]$/;
1498
+ const wrappedNodeRegex = /^(s\d+)\[\["([^"]*)"\]\]$/;
1499
+ const subgraphStartRegex = /^subgraph\s+w_\d+\["([^"]*)"\]$/;
1500
+ const subgraphEndRegex = /^end$/;
1501
+ const idleNodeRegex = /^idle\(\[idle\]\)$/;
1502
+ const enterArrowRegex = /^idle\s+-\.\s+enter\s+\.->\s+(s\d+)$/;
1503
+ const transitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/;
1504
+ const thickTransitionRegex = /^([sc]\d+)\s+==\s+"(.*)"\s+==>\s+([sc]\d+)$/;
1505
+ const onHaltRegex = /^([sc]\d+)\s+-\.\s+onHalt\s+\.->\s+([sc]\d+)$/;
1213
1506
  // First capture char anchored as \S to avoid polynomial backtracking between
1214
1507
  // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos.
1215
1508
  const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/;
@@ -1218,27 +1511,36 @@ function fromMermaid(text) {
1218
1511
  let alphabets = [];
1219
1512
  let initialId = null;
1220
1513
  const nodes = {};
1514
+ // Track the halt-marker ids that appeared inside a subgraph — they should be
1515
+ // marked `isHaltMarker: true` even though they share the `(((halt)))` shape
1516
+ // with the real halt at the top level.
1517
+ const haltMarkerIds = new Set();
1518
+ let inSubgraph = false;
1221
1519
  const ensureNode = (id, opts = {}) => {
1222
1520
  if (!nodes[id]) {
1223
1521
  nodes[id] = {
1224
1522
  id,
1225
- name: opts.name ?? `s${id}`,
1523
+ name: opts.name ?? mermaidIdFor(id),
1226
1524
  isHalt: opts.isHalt ?? false,
1525
+ isHaltMarker: opts.isHaltMarker ?? false,
1526
+ isWrapped: opts.isWrapped ?? false,
1227
1527
  transitions: [],
1228
- overrodeHaltStateId: null,
1528
+ overriddenHaltStateId: null,
1229
1529
  };
1230
1530
  }
1231
1531
  else {
1232
- if (opts.name !== undefined) {
1532
+ if (opts.name !== undefined)
1233
1533
  nodes[id].name = opts.name;
1234
- }
1235
- if (opts.isHalt !== undefined) {
1534
+ if (opts.isHalt !== undefined)
1236
1535
  nodes[id].isHalt = opts.isHalt;
1237
- }
1536
+ if (opts.isHaltMarker !== undefined)
1537
+ nodes[id].isHaltMarker = opts.isHaltMarker;
1538
+ if (opts.isWrapped !== undefined)
1539
+ nodes[id].isWrapped = opts.isWrapped;
1238
1540
  }
1239
1541
  return nodes[id];
1240
1542
  };
1241
- // First pass: alphabets + nodes.
1543
+ // First pass: alphabets + nodes (track subgraph context to mark halt markers).
1242
1544
  for (const line of lines) {
1243
1545
  if (line === 'flowchart TD') {
1244
1546
  continue;
@@ -1248,69 +1550,154 @@ function fromMermaid(text) {
1248
1550
  alphabets = JSON.parse(am[1]);
1249
1551
  continue;
1250
1552
  }
1553
+ if (subgraphStartRegex.test(line)) {
1554
+ inSubgraph = true;
1555
+ continue;
1556
+ }
1557
+ if (subgraphEndRegex.test(line)) {
1558
+ inSubgraph = false;
1559
+ continue;
1560
+ }
1561
+ // `idle([idle])` sentinel: a visual pre-execution marker. Not a graph
1562
+ // node — skip declaration, parse the `idle -. enter .-> sN` arrow in the
1563
+ // edge pass to set initialId.
1564
+ if (idleNodeRegex.test(line)) {
1565
+ continue;
1566
+ }
1251
1567
  const hm = line.match(haltNodeRegex);
1252
1568
  if (hm) {
1253
- ensureNode(Number(hm[1]), { name: 'halt', isHalt: true });
1569
+ const id = parseMermaidId(hm[1]);
1570
+ const isHaltMarker = inSubgraph || id < 0;
1571
+ ensureNode(id, { name: 'halt', isHalt: true, isHaltMarker });
1572
+ if (isHaltMarker) {
1573
+ haltMarkerIds.add(id);
1574
+ }
1254
1575
  continue;
1255
1576
  }
1256
- const im = line.match(initialNodeRegex);
1257
- if (im) {
1258
- const id = Number(im[1]);
1259
- initialId = id;
1260
- ensureNode(id, { name: im[2] });
1577
+ const wm = line.match(wrappedNodeRegex);
1578
+ if (wm) {
1579
+ ensureNode(parseMermaidId(wm[1]), { name: wm[2], isWrapped: true });
1261
1580
  continue;
1262
1581
  }
1263
1582
  const rm = line.match(regularNodeRegex);
1264
1583
  if (rm) {
1265
- ensureNode(Number(rm[1]), { name: rm[2] });
1584
+ ensureNode(parseMermaidId(rm[1]), { name: rm[2] });
1266
1585
  continue;
1267
1586
  }
1268
1587
  }
1269
1588
  // Second pass: edges.
1270
1589
  for (const line of lines) {
1590
+ // `idle -. enter .-> sN`: the sole source of initialId.
1591
+ const em = line.match(enterArrowRegex);
1592
+ if (em) {
1593
+ initialId = parseMermaidId(em[1]);
1594
+ continue;
1595
+ }
1271
1596
  const om = line.match(onHaltRegex);
1272
1597
  if (om) {
1273
- ensureNode(Number(om[1])).overrodeHaltStateId = Number(om[2]);
1598
+ ensureNode(parseMermaidId(om[1])).overriddenHaltStateId = parseMermaidId(om[2]);
1274
1599
  continue;
1275
1600
  }
1276
- const tm = line.match(transitionRegex);
1601
+ // Thick transition (`==> `) and regular transition (`-->`) share the same
1602
+ // semantics — only the visual differs. Parse both via the same code path.
1603
+ const tm = line.match(transitionRegex) ?? line.match(thickTransitionRegex);
1277
1604
  if (tm) {
1278
- const fromId = Number(tm[1]);
1605
+ const fromId = parseMermaidId(tm[1]);
1279
1606
  const label = tm[2];
1280
- const toId = Number(tm[3]);
1607
+ const toId = parseMermaidId(tm[3]);
1281
1608
  const arrowIx = label.indexOf(' → ');
1282
1609
  if (arrowIx === -1) {
1283
1610
  throw new Error(`fromMermaid: malformed edge label: "${label}"`);
1284
1611
  }
1285
- const pattern = label.slice(0, arrowIx);
1286
- const commandStr = label.slice(arrowIx + ' '.length);
1287
- const command = commandStr.split(',').map((part) => {
1288
- const slashIx = part.lastIndexOf('/');
1289
- if (slashIx === -1) {
1290
- throw new Error(`fromMermaid: malformed command part: "${part}"`);
1612
+ // Bracketed-tape-block format (v7):
1613
+ // [<read-cells>]|[<read-cells>]...[<write-cells>]/[<move-cells>]
1614
+ // Each bracketed list is a tape-block reading; the outer `|` separates
1615
+ // alternative read patterns. For single-tape machines with alternation,
1616
+ // the compact form `[<alt1>|<alt2>|...]` (one bracket, alternatives
1617
+ // inside) is also accepted; both forms decode to the same pattern
1618
+ // string.
1619
+ const readLabel = label.slice(0, arrowIx);
1620
+ const cmdLabel = label.slice(arrowIx + ' → '.length);
1621
+ // Strict per-pattern bracket form: `|` only between bracketed lists,
1622
+ // never inside. The compact `['^'|'1']` form is rejected by design —
1623
+ // every alternative must be its own bracketed pattern (`['^']|['1']`).
1624
+ // Pedagogically: each transition is drawn explicitly; the compact form
1625
+ // would read as cross-product semantics in multi-tape and confuse
1626
+ // readers (`['0'|'1','a'|'b']` could mean 4 combos, not 2 paired alts).
1627
+ // The rule applies to all bracketed lists — read alternatives, writes,
1628
+ // and movements — because commands and movements have no alternation
1629
+ // semantic either.
1630
+ const stripBrackets = (s) => {
1631
+ if (!s.startsWith('[') || !s.endsWith(']')) {
1632
+ throw new Error(`fromMermaid: malformed bracketed list: "${s}"`);
1291
1633
  }
1292
- return {
1293
- symbol: part.slice(0, slashIx),
1294
- movement: part.slice(slashIx + 1),
1295
- };
1634
+ const inner = s.slice(1, -1);
1635
+ // Walk the inner content; backslash escapes the next char (so `\|`
1636
+ // inside a cell is a literal pipe, not the alternation separator).
1637
+ let i = 0;
1638
+ while (i < inner.length) {
1639
+ if (inner[i] === '\\' && i + 1 < inner.length) {
1640
+ i += 2;
1641
+ continue;
1642
+ }
1643
+ if (inner[i] === '|') {
1644
+ throw new Error(`fromMermaid: compact in-bracket alternation "${s}" is not supported — `
1645
+ + 'each alternative must be its own bracketed pattern (e.g. "[\'^\']|[\'1\']").');
1646
+ }
1647
+ i += 1;
1648
+ }
1649
+ return inner;
1650
+ };
1651
+ // Match `[…]` blocks in the read label. Inner content is a tape-block
1652
+ // reading (possibly with `|` for compact single-tape alternation).
1653
+ // `[^\]]*` is the simple non-greedy match — works because cell content
1654
+ // doesn't typically contain literal `]`.
1655
+ const blockMatches = readLabel.match(/\[[^\]]*\]/g);
1656
+ if (!blockMatches || blockMatches.length === 0) {
1657
+ throw new Error(`fromMermaid: no bracketed read-list in label: "${label}"`);
1658
+ }
1659
+ const pattern = blockMatches.map(stripBrackets).join('|');
1660
+ const slashIx = cmdLabel.indexOf(']/[');
1661
+ if (slashIx === -1) {
1662
+ throw new Error(`fromMermaid: malformed command label (expected \`[…]/[…]\`): "${cmdLabel}"`);
1663
+ }
1664
+ const writesPart = stripBrackets(cmdLabel.slice(0, slashIx + 1));
1665
+ const movesPart = stripBrackets(cmdLabel.slice(slashIx + 2));
1666
+ const writes = writesPart.split(',');
1667
+ const moves = movesPart.split(',');
1668
+ if (writes.length !== moves.length) {
1669
+ throw new Error(`fromMermaid: write-cells (${writes.length}) and move-cells (${moves.length}) mismatch: "${cmdLabel}"`);
1670
+ }
1671
+ const command = writes.map((symbol, i) => ({ symbol, movement: moves[i] }));
1672
+ const fromNode = ensureNode(fromId);
1673
+ const transitionIx = fromNode.transitions.length;
1674
+ fromNode.transitions.push({
1675
+ pattern,
1676
+ command,
1677
+ nextStateId: toId,
1678
+ id: `${fromId}-${transitionIx}`,
1296
1679
  });
1297
- ensureNode(fromId).transitions.push({ pattern, command, nextStateId: toId });
1298
1680
  }
1299
1681
  }
1300
1682
  if (initialId === null) {
1301
- throw new Error('fromMermaid: no initial state (double-paren node) found');
1683
+ throw new Error('fromMermaid: no `idle -. enter .-> sN` arrow found');
1302
1684
  }
1303
1685
  return { initialId, alphabets, nodes };
1304
1686
  }
1305
1687
 
1306
1688
  function summarizeGraph(graph) {
1307
1689
  const nodes = Object.values(graph.nodes);
1690
+ // `isHaltMarker` nodes are visualization sentinels — one per wrapper context,
1691
+ // all corresponding to the singleton `haltState` at runtime. They don't
1692
+ // count as distinct runtime states; matches the per-algorithm header in
1693
+ // `library-binary-numbers/states.md`.
1694
+ const runtimeStateCount = nodes.filter((n) => !n.isHaltMarker).length;
1308
1695
  let transitionCount = 0;
1309
1696
  let compositionEdgeCount = 0;
1310
1697
  let selfLoopCount = 0;
1311
1698
  for (const node of nodes) {
1312
1699
  transitionCount += node.transitions.length;
1313
- if (node.overrodeHaltStateId !== null) {
1700
+ if (node.overriddenHaltStateId !== null) {
1314
1701
  compositionEdgeCount += 1;
1315
1702
  }
1316
1703
  for (const t of node.transitions) {
@@ -1319,7 +1706,7 @@ function summarizeGraph(graph) {
1319
1706
  }
1320
1707
  }
1321
1708
  }
1322
- // Longest withOverrodeHaltState chain. Walks node → overrodeHaltState recursively;
1709
+ // Longest withOverriddenHaltState chain. Walks node → overriddenHaltState recursively;
1323
1710
  // a Set guards against cycles in the override graph (which throw at construction
1324
1711
  // time anyway, but being defensive costs little).
1325
1712
  const overrideDepthFrom = (id, visited) => {
@@ -1328,10 +1715,10 @@ function summarizeGraph(graph) {
1328
1715
  }
1329
1716
  visited.add(id);
1330
1717
  const node = graph.nodes[id];
1331
- if (!node || node.overrodeHaltStateId === null) {
1718
+ if (!node || node.overriddenHaltStateId === null) {
1332
1719
  return 0;
1333
1720
  }
1334
- return 1 + overrideDepthFrom(node.overrodeHaltStateId, visited);
1721
+ return 1 + overrideDepthFrom(node.overriddenHaltStateId, visited);
1335
1722
  };
1336
1723
  const maxCompositionDepth = nodes.reduce((max, node) => Math.max(max, overrideDepthFrom(node.id, new Set())), 0);
1337
1724
  // Cycle detection: tri-color DFS over the transition graph.
@@ -1374,7 +1761,7 @@ function summarizeGraph(graph) {
1374
1761
  visit(node.id);
1375
1762
  }
1376
1763
  return {
1377
- stateCount: nodes.length,
1764
+ stateCount: runtimeStateCount,
1378
1765
  transitionCount,
1379
1766
  compositionEdgeCount,
1380
1767
  maxCompositionDepth,