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