@turing-machine-js/machine 7.0.0-alpha.1 → 7.0.0-alpha.2

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
@@ -349,7 +349,7 @@ var __classPrivateFieldGet$2 = (undefined && undefined.__classPrivateFieldGet) |
349
349
  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");
350
350
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
351
351
  };
352
- var _a, _TapeBlock_symbolToPatternListMap, _TapeBlock_lock, _TapeBlock_tapes, _TapeBlock_generateSymbolHint, _TapeBlock_buildPatternList, _TapeBlock_getSymbolForPatternList, _TapeBlock_symbol;
352
+ var _a$1, _TapeBlock_symbolToPatternListMap, _TapeBlock_lock, _TapeBlock_tapes, _TapeBlock_generateSymbolHint, _TapeBlock_buildPatternList, _TapeBlock_getSymbolForPatternList, _TapeBlock_symbol;
353
353
  const symbolToPatternListMapSymbol = Symbol('symbol for symbolToPatternListMap setter');
354
354
  const lockSymbol = Symbol('capture symbol');
355
355
  class TapeBlock {
@@ -388,7 +388,7 @@ class TapeBlock {
388
388
  symbol = storedPatternListSymbol;
389
389
  }
390
390
  else {
391
- symbol = Symbol(__classPrivateFieldGet$2(_a, _a, "f", _TapeBlock_generateSymbolHint).call(_a, patternList));
391
+ symbol = Symbol(__classPrivateFieldGet$2(_a$1, _a$1, "f", _TapeBlock_generateSymbolHint).call(_a$1, patternList));
392
392
  __classPrivateFieldGet$2(this, _TapeBlock_symbolToPatternListMap, "f").set(symbol, patternList);
393
393
  }
394
394
  return symbol;
@@ -488,10 +488,10 @@ class TapeBlock {
488
488
  clone(cloneTapes = false) {
489
489
  let tapeBlock;
490
490
  if (cloneTapes) {
491
- tapeBlock = _a.fromTapes(this.tapes.map((tape) => new Tape(tape)));
491
+ tapeBlock = _a$1.fromTapes(this.tapes.map((tape) => new Tape(tape)));
492
492
  }
493
493
  else {
494
- tapeBlock = _a.fromAlphabets(this.alphabets);
494
+ tapeBlock = _a$1.fromAlphabets(this.alphabets);
495
495
  }
496
496
  tapeBlock[symbolToPatternListMapSymbol] = __classPrivateFieldGet$2(this, _TapeBlock_symbolToPatternListMap, "f");
497
497
  return tapeBlock;
@@ -520,12 +520,12 @@ class TapeBlock {
520
520
  }
521
521
  }
522
522
  }
523
- _a = TapeBlock;
523
+ _a$1 = TapeBlock;
524
524
  TapeBlock.fromAlphabets = (alphabets) => {
525
- return new _a({ alphabets });
525
+ return new _a$1({ alphabets });
526
526
  };
527
527
  TapeBlock.fromTapes = (tapes) => {
528
- return new _a({ tapes });
528
+ return new _a$1({ tapes });
529
529
  };
530
530
  _TapeBlock_generateSymbolHint = { value: (patternList) => JSON.stringify(patternList
531
531
  .map((pattern) => pattern
@@ -689,7 +689,7 @@ var __classPrivateFieldGet$1 = (undefined && undefined.__classPrivateFieldGet) |
689
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");
690
690
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
691
691
  };
692
- var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef;
692
+ var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _a, _State_wrapperCache, _State_id, _State_name, _State_overriddenHaltState, _State_bareState, _State_symbolToDataMap, _State_debugRef;
693
693
  const ifOtherSymbol = Symbol('other symbol');
694
694
  // Module-private symbol used by DebugConfig setters to call State's validator
695
695
  // without exposing the validator on the public surface.
@@ -762,7 +762,7 @@ class State {
762
762
  symbols.forEach((symbol) => {
763
763
  const { nextState } = stateDefinition[symbol];
764
764
  const nextStateLocal = nextState ?? this;
765
- if (!(nextStateLocal instanceof State) && !(nextStateLocal instanceof Reference)) {
765
+ if (!(nextStateLocal instanceof _a) && !(nextStateLocal instanceof Reference)) {
766
766
  throw new Error('invalid nextState');
767
767
  }
768
768
  let { command } = stateDefinition[symbol];
@@ -882,16 +882,40 @@ class State {
882
882
  throw new Error(`No nextState for symbol at state named ${__classPrivateFieldGet$1(this, _State_id, "f")}`);
883
883
  }
884
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");
891
- __classPrivateFieldSet$1(state, _State_symbolToDataMap, __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f"), "f");
885
+ // Unwrap `this` if it's itself a wrapper — the chain's inner overrides
886
+ // are dead at runtime anyway (only the outermost `.wohs()`'s override is
887
+ // pushed onto the halt-stack on entry; verified empirically). Composite
888
+ // name reflects runtime behavior, not construction history. See #176.
889
+ const bare = __classPrivateFieldGet$1(this, _State_bareState, "f") ?? this;
890
+ // Memoize by (bare, override) so identical args return the same instance
891
+ // (#175). The cache uses WeakMaps + WeakRefs so cached wrappers can be
892
+ // GC'd when nothing else holds them. Compounds with the chain-collapse
893
+ // above: `A.wohs(t1).wohs(t2)` keys as (A, t2) after the unwrap, hitting
894
+ // the same cache slot as a direct `A.wohs(t2)`.
895
+ let innerCache = __classPrivateFieldGet$1(_a, _a, "f", _State_wrapperCache).get(bare);
896
+ if (innerCache !== undefined) {
897
+ const ref = innerCache.get(overriddenHaltState);
898
+ if (ref !== undefined) {
899
+ const cached = ref.deref();
900
+ if (cached !== undefined) {
901
+ return cached;
902
+ }
903
+ }
904
+ }
905
+ else {
906
+ innerCache = new WeakMap();
907
+ __classPrivateFieldGet$1(_a, _a, "f", _State_wrapperCache).set(bare, innerCache);
908
+ }
909
+ // Cache miss — construct with no name, then overwrite #name directly
910
+ // (composed names contain `(` and `)` which the constructor's user-facing
911
+ // validation would reject; private-field access bypasses that).
912
+ const state = new _a();
913
+ __classPrivateFieldSet$1(state, _State_name, `${bare.name}(${overriddenHaltState.name})`, "f");
914
+ __classPrivateFieldSet$1(state, _State_symbolToDataMap, __classPrivateFieldGet$1(bare, _State_symbolToDataMap, "f"), "f");
892
915
  __classPrivateFieldSet$1(state, _State_overriddenHaltState, overriddenHaltState, "f");
893
- __classPrivateFieldSet$1(state, _State_debugRef, __classPrivateFieldGet$1(this, _State_debugRef, "f"), "f");
894
- __classPrivateFieldSet$1(state, _State_bareState, this, "f");
916
+ __classPrivateFieldSet$1(state, _State_debugRef, __classPrivateFieldGet$1(bare, _State_debugRef, "f"), "f");
917
+ __classPrivateFieldSet$1(state, _State_bareState, bare, "f");
918
+ innerCache.set(overriddenHaltState, new WeakRef(state));
895
919
  return state;
896
920
  }
897
921
  // Single-state introspection — no traversal, no tapeBlock required.
@@ -904,7 +928,7 @@ class State {
904
928
  for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
905
929
  let target = null;
906
930
  try {
907
- target = nextState instanceof State ? nextState : nextState.ref;
931
+ target = nextState instanceof _a ? nextState : nextState.ref;
908
932
  }
909
933
  catch {
910
934
  target = null; // unbound Reference
@@ -928,153 +952,86 @@ class State {
928
952
  transitions,
929
953
  };
930
954
  }
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.
955
+ // Walks the State graph and emits a `Graph` data structure. v7 callable-
956
+ // subtree emit shape (#174):
939
957
  //
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.
958
+ // Each `withOverriddenHaltState` wrapper produces TWO graph nodes:
959
+ // - A wrapper node (`isWrapper: true`, `[[composite-name]]` shape) — the
960
+ // call site. No transitions of its own. `bareStateId` points to the
961
+ // bare's GraphNode; `overriddenHaltStateId` points to the override
962
+ // target's GraphNode.
963
+ // - A bare node (`isWrapper: false`, regular shape) — the callable body.
964
+ // Has the bare's transitions. Shared across all wrappers that wrap
965
+ // this bare (no per-context duplication).
966
+ //
967
+ // Frames are computed via union-find on bare reachability: two bares whose
968
+ // forward-reachable sets overlap merge into one frame. Each frame contains
969
+ // its bares + body states + a single halt marker (id = `-frameId`). The
970
+ // canonical `frameId` is the smallest bare-id in the component.
971
+ //
972
+ // Halt-bound transitions of any in-frame state are retargeted to the
973
+ // frame's halt marker. The frame's `subtree -. return .-> wrapper` and
974
+ // `subtree -. halt .-> s0` arrows are demand-emitted by `toMermaid` from
975
+ // the frame structure; they're not stored as graph edges.
943
976
  static toGraph(initialState, tapeBlock) {
944
977
  const nodes = {};
945
978
  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
- }
979
+ // Pass 1: BFS-discover all reachable States; emit one GraphNode per State
980
+ // (wrapper or bare/regular). Wrappers and bares are separate nodes.
981
+ const visited = new Set();
982
+ const queue = [initialState];
983
+ const bareIds = new Set(); // ids referenced as a wrapper's bareStateId
963
984
  while (queue.length > 0) {
964
- const { state, wrapperContext } = queue.shift();
985
+ const state = queue.shift();
986
+ if (visited.has(__classPrivateFieldGet$1(state, _State_id, "f"))) {
987
+ continue;
988
+ }
989
+ visited.add(__classPrivateFieldGet$1(state, _State_id, "f"));
965
990
  if (state.isHalt) {
966
- // Real halt — always id 0, single node.
967
991
  if (!(0 in nodes)) {
968
992
  nodes[0] = {
969
993
  id: 0,
970
994
  name: __classPrivateFieldGet$1(state, _State_name, "f"),
971
995
  isHalt: true,
972
996
  isHaltMarker: false,
973
- isWrapped: false,
997
+ isWrapper: false,
998
+ bareStateId: null,
999
+ frameId: null,
974
1000
  transitions: [],
975
1001
  overriddenHaltStateId: null,
976
1002
  };
977
1003
  }
978
1004
  continue;
979
1005
  }
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"),
1006
+ // Wrapper? Emit wrapper node + queue bare and override target.
1007
+ if (__classPrivateFieldGet$1(state, _State_overriddenHaltState, "f") !== null && __classPrivateFieldGet$1(state, _State_bareState, "f") !== null) {
1008
+ const bareState = __classPrivateFieldGet$1(state, _State_bareState, "f");
1009
+ const overrideTarget = __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f");
1010
+ nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = {
1011
+ id: __classPrivateFieldGet$1(state, _State_id, "f"),
1012
+ name: __classPrivateFieldGet$1(state, _State_name, "f"), // composite name like "A(target)"
1011
1013
  isHalt: false,
1012
1014
  isHaltMarker: false,
1013
- isWrapped: true,
1015
+ isWrapper: true,
1016
+ bareStateId: __classPrivateFieldGet$1(bareState, _State_id, "f"),
1017
+ frameId: null,
1014
1018
  transitions: [],
1015
- overriddenHaltStateId: overrideGraphId,
1019
+ overriddenHaltStateId: __classPrivateFieldGet$1(overrideTarget, _State_id, "f"),
1016
1020
  };
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) {
1021
+ bareIds.add(__classPrivateFieldGet$1(bareState, _State_id, "f"));
1022
+ queue.push(bareState);
1023
+ queue.push(overrideTarget);
1070
1024
  continue;
1071
1025
  }
1026
+ // Regular (or bare) state — build node with transitions.
1072
1027
  const node = {
1073
1028
  id: __classPrivateFieldGet$1(state, _State_id, "f"),
1074
1029
  name: __classPrivateFieldGet$1(state, _State_name, "f"),
1075
1030
  isHalt: false,
1076
1031
  isHaltMarker: false,
1077
- isWrapped: false,
1032
+ isWrapper: false,
1033
+ bareStateId: null,
1034
+ frameId: null,
1078
1035
  transitions: [],
1079
1036
  overriddenHaltStateId: null,
1080
1037
  };
@@ -1083,49 +1040,185 @@ class State {
1083
1040
  for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
1084
1041
  let target;
1085
1042
  try {
1086
- target = nextState instanceof State ? nextState : nextState.ref;
1043
+ target = nextState instanceof _a ? nextState : nextState.ref;
1087
1044
  }
1088
1045
  catch {
1089
1046
  patternIx += 1;
1090
1047
  continue;
1091
1048
  }
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
- }
1102
1049
  node.transitions.push({
1103
1050
  pattern: decodePatternDescription(sym.description, alphabets),
1104
1051
  command: command.tapesCommands.map((tc) => ({
1105
1052
  symbol: decodeWriteSymbol(tc.symbol),
1106
1053
  movement: decodeMovement(tc.movement.description),
1107
1054
  })),
1108
- nextStateId,
1055
+ nextStateId: __classPrivateFieldGet$1(target, _State_id, "f"),
1109
1056
  id: `${__classPrivateFieldGet$1(state, _State_id, "f")}-${patternIx}`,
1110
1057
  });
1058
+ queue.push(target);
1111
1059
  patternIx += 1;
1112
1060
  }
1113
1061
  }
1114
- return { initialId, alphabets, nodes };
1062
+ // Always emit real halt as a sentinel, even if no transition targets it.
1063
+ // It anchors the `subtree -. halt .-> s0` frame-level arrow whenever a
1064
+ // frame demand-emits one, and it's the canonical machine-halt singleton.
1065
+ if (!(0 in nodes)) {
1066
+ nodes[0] = {
1067
+ id: 0,
1068
+ name: 'halt',
1069
+ isHalt: true,
1070
+ isHaltMarker: false,
1071
+ isWrapper: false,
1072
+ bareStateId: null,
1073
+ frameId: null,
1074
+ transitions: [],
1075
+ overriddenHaltStateId: null,
1076
+ };
1077
+ }
1078
+ // Pass 2: For each bare, compute its forward-reachable set (following
1079
+ // transitions; stopping at halt and at wrappers — both are frame
1080
+ // boundaries).
1081
+ const computeReach = (startId) => {
1082
+ const reach = new Set();
1083
+ const stack = [startId];
1084
+ while (stack.length > 0) {
1085
+ const id = stack.pop();
1086
+ if (reach.has(id)) {
1087
+ continue;
1088
+ }
1089
+ const node = nodes[id];
1090
+ if (!node || node.isHalt || node.isWrapper) {
1091
+ continue;
1092
+ }
1093
+ reach.add(id);
1094
+ for (const t of node.transitions) {
1095
+ const target = nodes[t.nextStateId];
1096
+ if (!target || target.isHalt || target.isWrapper) {
1097
+ continue;
1098
+ }
1099
+ stack.push(t.nextStateId);
1100
+ }
1101
+ }
1102
+ return reach;
1103
+ };
1104
+ const reachByBare = new Map();
1105
+ for (const bareId of bareIds) {
1106
+ reachByBare.set(bareId, computeReach(bareId));
1107
+ }
1108
+ // Pass 3: Union-find on bare overlaps. Two bares merge if their reach
1109
+ // sets share any state. Canonical representative = smallest bare-id in
1110
+ // the component.
1111
+ const ufParent = new Map();
1112
+ const ufFind = (id) => {
1113
+ if (!ufParent.has(id)) {
1114
+ ufParent.set(id, id);
1115
+ }
1116
+ let root = id;
1117
+ while (ufParent.get(root) !== root) {
1118
+ root = ufParent.get(root);
1119
+ }
1120
+ // Path compression
1121
+ let cur = id;
1122
+ while (ufParent.get(cur) !== root) {
1123
+ const next = ufParent.get(cur);
1124
+ ufParent.set(cur, root);
1125
+ cur = next;
1126
+ }
1127
+ return root;
1128
+ };
1129
+ const ufUnion = (a, b) => {
1130
+ const ra = ufFind(a);
1131
+ const rb = ufFind(b);
1132
+ if (ra === rb)
1133
+ return;
1134
+ if (ra < rb) {
1135
+ ufParent.set(rb, ra);
1136
+ }
1137
+ else {
1138
+ ufParent.set(ra, rb);
1139
+ }
1140
+ };
1141
+ for (const bareId of bareIds) {
1142
+ ufFind(bareId);
1143
+ }
1144
+ // For each state, collect the bares that reach it; union all bares that
1145
+ // share a state.
1146
+ const stateToReachingBares = new Map();
1147
+ for (const [bareId, reachSet] of reachByBare) {
1148
+ for (const stateId of reachSet) {
1149
+ let bares = stateToReachingBares.get(stateId);
1150
+ if (!bares) {
1151
+ bares = [];
1152
+ stateToReachingBares.set(stateId, bares);
1153
+ }
1154
+ bares.push(bareId);
1155
+ }
1156
+ }
1157
+ for (const bares of stateToReachingBares.values()) {
1158
+ for (let i = 1; i < bares.length; i += 1) {
1159
+ ufUnion(bares[0], bares[i]);
1160
+ }
1161
+ }
1162
+ // Assign frameId to each in-reach state.
1163
+ const frameIds = new Set();
1164
+ for (const [stateId, bares] of stateToReachingBares) {
1165
+ const frameId = ufFind(bares[0]);
1166
+ nodes[stateId].frameId = frameId;
1167
+ frameIds.add(frameId);
1168
+ }
1169
+ // Pass 4: Retarget halt-bound transitions for in-frame states to the
1170
+ // frame's halt marker. Out-of-frame states (top-level dispatcher, override
1171
+ // targets, etc.) keep their halt-bound transitions pointing at real halt.
1172
+ for (const node of Object.values(nodes)) {
1173
+ if (node.frameId === null) {
1174
+ continue;
1175
+ }
1176
+ const haltMarkerId = -node.frameId;
1177
+ for (const t of node.transitions) {
1178
+ const target = nodes[t.nextStateId];
1179
+ if (target && target.isHalt && !target.isHaltMarker) {
1180
+ t.nextStateId = haltMarkerId;
1181
+ }
1182
+ }
1183
+ }
1184
+ // Pass 5: Emit one halt marker per frame.
1185
+ for (const frameId of frameIds) {
1186
+ const haltMarkerId = -frameId;
1187
+ nodes[haltMarkerId] = {
1188
+ id: haltMarkerId,
1189
+ name: 'halt',
1190
+ isHalt: true,
1191
+ isHaltMarker: true,
1192
+ isWrapper: false,
1193
+ bareStateId: null,
1194
+ frameId,
1195
+ transitions: [],
1196
+ overriddenHaltStateId: null,
1197
+ };
1198
+ }
1199
+ return { initialId: __classPrivateFieldGet$1(initialState, _State_id, "f"), alphabets, nodes };
1115
1200
  }
1116
1201
  // Inverse of toGraph: rebuilds a State graph (and a fresh TapeBlock with the
1117
1202
  // graph's alphabets) from a serialized Graph. Round-trips with toGraph in
1118
1203
  // the sense that running the rebuilt machine on the same input gives the
1119
1204
  // same output, but the rebuilt State instances have *new* internal IDs.
1205
+ //
1206
+ // Under the v7 callable-subtree model (#174), graph nodes split into:
1207
+ // - Wrapper nodes (`isWrapper: true`, no transitions) — reconstructed via
1208
+ // `bareStates[bareStateId].withOverriddenHaltState(finalStates[overriddenHaltStateId])`.
1209
+ // - Bare/regular nodes — constructed as normal States with transitions.
1210
+ // - Halt + halt-marker nodes — collapse to the singleton `haltState`.
1120
1211
  static fromGraph(graph) {
1121
1212
  const alphabetObjs = graph.alphabets.map((syms) => new Alphabet(syms));
1122
1213
  const tapeBlock = TapeBlock.fromAlphabets(alphabetObjs);
1123
1214
  const ids = Object.keys(graph.nodes).map(Number);
1124
- // Pass 1: pre-create a Reference for each non-halt node so transitions can
1125
- // forward-declare their targets.
1215
+ // Pass 1: pre-create a Reference for each non-halt non-halt-marker node
1216
+ // (both wrappers and regulars). Halt and halt-marker nodes collapse to the
1217
+ // singleton `haltState` and need no ref.
1126
1218
  const refs = {};
1127
1219
  for (const nodeId of ids) {
1128
- if (!graph.nodes[nodeId].isHalt) {
1220
+ const node = graph.nodes[nodeId];
1221
+ if (!node.isHalt) {
1129
1222
  refs[nodeId] = new Reference();
1130
1223
  }
1131
1224
  }
@@ -1142,19 +1235,22 @@ class State {
1142
1235
  }
1143
1236
  return tapeBlock.symbol(flat);
1144
1237
  };
1145
- // Pass 2: build a "bare" State for each non-halt node (no override yet).
1146
- // nextState entries point at refs so cycles work; haltState is used directly.
1238
+ // Pass 2: build a State for each non-wrapper non-halt non-halt-marker
1239
+ // node. Transitions point at refs so cycles work; haltState (and halt
1240
+ // markers, which collapse to haltState) are used directly.
1147
1241
  const bareStates = {};
1148
1242
  for (const nodeId of ids) {
1149
1243
  const node = graph.nodes[nodeId];
1150
- if (node.isHalt) {
1244
+ if (node.isHalt || node.isWrapper) {
1151
1245
  continue;
1152
1246
  }
1153
1247
  const stateDefinition = {};
1154
1248
  for (const t of node.transitions) {
1155
1249
  const key = patternToKey(parsePatternString(t.pattern, graph.alphabets));
1156
1250
  const target = graph.nodes[t.nextStateId];
1157
- const nextState = target.isHalt ? haltState : refs[t.nextStateId];
1251
+ const nextState = !target || target.isHalt
1252
+ ? haltState
1253
+ : refs[t.nextStateId];
1158
1254
  stateDefinition[key] = {
1159
1255
  command: t.command.map((c) => ({
1160
1256
  symbol: parseWriteSymbolLabel(c.symbol),
@@ -1163,14 +1259,17 @@ class State {
1163
1259
  nextState,
1164
1260
  };
1165
1261
  }
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);
1262
+ // Graph-sourced names may contain `(` and `)` (composite wrapper names
1263
+ // although wrappers go through a separate path below, defensive
1264
+ // construction here keeps the bypass uniform). Construct without a name
1265
+ // and assign `#name` directly to skip user-facing name validation.
1266
+ const bare = new _a(stateDefinition);
1170
1267
  __classPrivateFieldSet$1(bare, _State_name, node.name, "f");
1171
1268
  bareStates[nodeId] = bare;
1172
1269
  }
1173
- // Pass 3: apply overrideHaltStates transitively.
1270
+ // Pass 3: resolve every node to its final State (memoized + cycle-safe).
1271
+ // Wrappers compose lazily via `withOverriddenHaltState` once their bare
1272
+ // and override are resolved.
1174
1273
  const finalStates = {};
1175
1274
  const inProgress = new Set();
1176
1275
  const getFinal = (nodeId) => {
@@ -1178,7 +1277,7 @@ class State {
1178
1277
  return finalStates[nodeId];
1179
1278
  }
1180
1279
  const node = graph.nodes[nodeId];
1181
- if (node.isHalt) {
1280
+ if (!node || node.isHalt) {
1182
1281
  finalStates[nodeId] = haltState;
1183
1282
  return haltState;
1184
1283
  }
@@ -1186,9 +1285,14 @@ class State {
1186
1285
  throw new Error(`override-halt cycle at state #${nodeId}`);
1187
1286
  }
1188
1287
  inProgress.add(nodeId);
1189
- let state = bareStates[nodeId];
1190
- if (node.overriddenHaltStateId !== null) {
1191
- state = bareStates[nodeId].withOverriddenHaltState(getFinal(node.overriddenHaltStateId));
1288
+ let state;
1289
+ if (node.isWrapper) {
1290
+ const bare = getFinal(node.bareStateId);
1291
+ const override = getFinal(node.overriddenHaltStateId);
1292
+ state = bare.withOverriddenHaltState(override);
1293
+ }
1294
+ else {
1295
+ state = bareStates[nodeId];
1192
1296
  }
1193
1297
  inProgress.delete(nodeId);
1194
1298
  finalStates[nodeId] = state;
@@ -1197,8 +1301,8 @@ class State {
1197
1301
  for (const nodeId of ids) {
1198
1302
  getFinal(nodeId);
1199
1303
  }
1200
- // Pass 4: bind each ref to the FINAL (possibly wrapped) state so transitions
1201
- // resolve to the version that has its override-halt set.
1304
+ // Pass 4: bind each ref to the resolved final State so cross-node
1305
+ // transitions land on the right instance.
1202
1306
  for (const nodeId of ids) {
1203
1307
  if (!graph.nodes[nodeId].isHalt) {
1204
1308
  refs[nodeId].bind(finalStates[nodeId]);
@@ -1211,6 +1315,13 @@ class State {
1211
1315
  };
1212
1316
  }
1213
1317
  }
1318
+ _a = State;
1319
+ // Memoization cache for `withOverriddenHaltState`. Keyed by
1320
+ // (bare, override) — same args return the same wrapper instance (#175).
1321
+ // Two-level WeakMap so the outer entry is GC'd when the bare is collected;
1322
+ // WeakRef values let wrappers themselves be GC'd when nothing else holds
1323
+ // them, with cache misses simply reconstructing fresh wrappers.
1324
+ _State_wrapperCache = { value: new WeakMap() };
1214
1325
  const haltState = new State(null);
1215
1326
 
1216
1327
  var __classPrivateFieldSet = (undefined && undefined.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
@@ -1354,19 +1465,25 @@ _TuringMachine_tapeBlock = new WeakMap(), _TuringMachine_stack = new WeakMap();
1354
1465
  // Currently only Mermaid flowchart syntax is supported. Future formats
1355
1466
  // (Graphviz, JSON-LD, custom DSL) belong here too.
1356
1467
  //
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.
1468
+ // v7 callable-subtree emit (#174):
1469
+ // - Each `withOverriddenHaltState` wrapper produces TWO graph nodes a
1470
+ // wrapper node (`[[composite-name]]`, OUTSIDE any subgraph) and a bare
1471
+ // node (regular shape, INSIDE its callable subtree subgraph).
1472
+ // - Subgraphs (one per frame): `subgraph w_${frameId}["callable subtree
1473
+ // of NAME"]` (single bare) or `["callable scope: A B"]` (union).
1474
+ // - Each frame has exactly one halt marker `c${frameId}(((halt)))` inside
1475
+ // its subgraph; halt-bound transitions from in-frame states retarget to
1476
+ // it. Always emitted (orphan signals dead wrapper).
1477
+ // - Arrow conventions:
1478
+ // solid `-->` regular transitions, including wrapper-to-override.
1479
+ // bold `==>` RESERVED for the wrapper-to-bare `call` arrow.
1480
+ // `&` ribbon collapses multi-wrapper-shares-bare.
1481
+ // dotted `-.->` frame-level dispatch (`return`, `halt`, `enter`).
1482
+ // - The `return` arrow (subgraph → wrapper) is demand-emitted iff the
1483
+ // frame's halt marker has at least one incoming edge AND the wrapper
1484
+ // calls into the frame. The `halt` arrow (subgraph → s0) is emitted
1485
+ // iff the halt marker has incoming AND there's at least one non-wrapper
1486
+ // entry into the frame (cross-subgraph solid arrow from outside).
1370
1487
  // Maps a graph node id to its Mermaid id.
1371
1488
  // - non-negative id N → "sN"
1372
1489
  // - negative id -N (halt marker) → "cN"
@@ -1380,108 +1497,182 @@ function parseMermaidId(s) {
1380
1497
  }
1381
1498
  return Number(s.slice(1));
1382
1499
  }
1500
+ function frameSubgraphId(frameId) {
1501
+ return `w_${frameId}`;
1502
+ }
1383
1503
  function toMermaid(graph) {
1384
1504
  const lines = [
1385
1505
  'flowchart TD',
1386
1506
  `%% alphabets: ${JSON.stringify(graph.alphabets)}`,
1387
1507
  ];
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).
1508
+ // Sort nodes by id ascending — real halt (0) first, then regulars by their
1509
+ // ids, then halt markers (negative) at the end. Deterministic emit lets
1510
+ // toMermaid → fromMermaid → toMermaid round-trip stably (#139).
1391
1511
  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.
1512
+ // Bucket nodes for emit order.
1513
+ const topLevelNodes = nodes.filter((n) => n.frameId === null && !n.isWrapper);
1514
+ const wrapperNodes = nodes.filter((n) => n.isWrapper);
1515
+ // Bares-and-bodies inside frames, grouped by frameId.
1516
+ const nodesByFrame = new Map();
1517
+ // Halt-marker per frame (kept separate so it always emits LAST inside the
1518
+ // subgraph for deterministic shape).
1519
+ const haltMarkerByFrame = new Map();
1406
1520
  for (const node of nodes) {
1407
- if (node.isWrapped || haltMarkerIds.has(node.id)) {
1521
+ if (node.frameId === null || node.isWrapper)
1408
1522
  continue;
1523
+ if (node.isHaltMarker) {
1524
+ haltMarkerByFrame.set(node.frameId, node);
1525
+ }
1526
+ else {
1527
+ let bucket = nodesByFrame.get(node.frameId);
1528
+ if (!bucket) {
1529
+ bucket = [];
1530
+ nodesByFrame.set(node.frameId, bucket);
1531
+ }
1532
+ bucket.push(node);
1409
1533
  }
1410
- const id = mermaidIdFor(node.id);
1534
+ }
1535
+ // 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame).
1536
+ for (const node of topLevelNodes) {
1537
+ const mid = mermaidIdFor(node.id);
1411
1538
  if (node.isHalt) {
1412
- lines.push(` ${id}(((halt)))`);
1539
+ lines.push(` ${mid}(((halt)))`);
1413
1540
  }
1414
1541
  else {
1415
- lines.push(` ${id}["${node.name}"]`);
1542
+ lines.push(` ${mid}["${node.name}"]`);
1416
1543
  }
1417
1544
  }
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.
1545
+ // 2. Emit wrappers at top level.
1546
+ for (const wrapper of wrapperNodes) {
1547
+ lines.push(` ${mermaidIdFor(wrapper.id)}[["${wrapper.name}"]]`);
1548
+ }
1549
+ // 3. `idle` sentinel.
1422
1550
  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)))`);
1551
+ // 4. Subgraph per frame.
1552
+ const frameIds = [...nodesByFrame.keys()].sort((a, b) => a - b);
1553
+ for (const frameId of frameIds) {
1554
+ const frameBares = (nodesByFrame.get(frameId) ?? []).filter((n) => isFrameBare(n, graph));
1555
+ const frameBareNames = frameBares
1556
+ .slice()
1557
+ .sort((a, b) => a.id - b.id)
1558
+ .map((n) => n.name);
1559
+ const label = frameBareNames.length > 1
1560
+ ? `callable scope: ${frameBareNames.join(' ∪ ')}`
1561
+ : `callable subtree of ${frameBareNames[0] ?? frameId}`;
1562
+ lines.push(` subgraph ${frameSubgraphId(frameId)}["${label}"]`);
1563
+ // Inner nodes — sort by id for determinism.
1564
+ for (const node of (nodesByFrame.get(frameId) ?? []).slice().sort((a, b) => a.id - b.id)) {
1565
+ lines.push(` ${mermaidIdFor(node.id)}["${node.name}"]`);
1566
+ }
1567
+ const haltMarker = haltMarkerByFrame.get(frameId);
1568
+ if (haltMarker) {
1569
+ lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`);
1432
1570
  }
1433
1571
  lines.push(' end');
1434
1572
  }
1435
- // Enter arrow: emitted after subgraphs so it visually points at the initial
1436
- // node (whether plain `[…]` or wrapped `[[…]]` inside a subgraph).
1573
+ // 5. Enter arrow.
1437
1574
  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).
1575
+ // 6. `call` arrows grouped by bare (multi-wrapper-shares-bare collapses
1576
+ // into a single `&` ribbon).
1577
+ const wrappersByBare = new Map();
1578
+ for (const wrapper of wrapperNodes) {
1579
+ if (wrapper.bareStateId === null)
1580
+ continue;
1581
+ let group = wrappersByBare.get(wrapper.bareStateId);
1582
+ if (!group) {
1583
+ group = [];
1584
+ wrappersByBare.set(wrapper.bareStateId, group);
1585
+ }
1586
+ group.push(wrapper);
1587
+ }
1588
+ const sortedBares = [...wrappersByBare.keys()].sort((a, b) => a - b);
1589
+ for (const bareId of sortedBares) {
1590
+ const wrappers = wrappersByBare.get(bareId).slice().sort((a, b) => a.id - b.id);
1591
+ const sources = wrappers.map((w) => mermaidIdFor(w.id)).join(' & ');
1592
+ lines.push(` ${sources} == "call" ==> ${mermaidIdFor(bareId)}`);
1593
+ }
1594
+ // 7. Demand-emit `return` and `halt` arrows per frame.
1595
+ // For each frame: check if its halt marker has incoming transitions.
1596
+ const haltMarkerHasIncoming = new Map();
1441
1597
  for (const node of nodes) {
1442
- if (node.isHalt && !node.isHaltMarker) {
1598
+ for (const t of node.transitions) {
1599
+ const target = graph.nodes[t.nextStateId];
1600
+ if (target && target.isHaltMarker && target.frameId !== null) {
1601
+ haltMarkerHasIncoming.set(target.frameId, true);
1602
+ }
1603
+ }
1604
+ }
1605
+ // For each frame: check if there's at least one non-wrapper entry (a solid
1606
+ // `-->` from OUTSIDE the frame into any node INSIDE).
1607
+ const hasNonWrapperEntry = new Map();
1608
+ for (const node of nodes) {
1609
+ if (node.isWrapper)
1443
1610
  continue;
1611
+ for (const t of node.transitions) {
1612
+ const target = graph.nodes[t.nextStateId];
1613
+ if (target
1614
+ && target.frameId !== null
1615
+ && node.frameId !== target.frameId) {
1616
+ hasNonWrapperEntry.set(target.frameId, true);
1617
+ }
1444
1618
  }
1619
+ }
1620
+ for (const frameId of frameIds) {
1621
+ if (!haltMarkerHasIncoming.get(frameId))
1622
+ continue;
1623
+ // Return arrow — collapsed `&` ribbon over all wrappers calling this frame.
1624
+ const callingWrappers = wrapperNodes.filter((w) => {
1625
+ if (w.bareStateId === null)
1626
+ return false;
1627
+ const bare = graph.nodes[w.bareStateId];
1628
+ return !!bare && bare.frameId === frameId;
1629
+ });
1630
+ if (callingWrappers.length > 0) {
1631
+ const targets = callingWrappers
1632
+ .slice()
1633
+ .sort((a, b) => a.id - b.id)
1634
+ .map((w) => mermaidIdFor(w.id))
1635
+ .join(' & ');
1636
+ lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`);
1637
+ }
1638
+ if (hasNonWrapperEntry.get(frameId)) {
1639
+ lines.push(` ${frameSubgraphId(frameId)} -. "halt" .-> s0`);
1640
+ }
1641
+ }
1642
+ // 8. Wrapper-to-override arrows (regular solid).
1643
+ for (const wrapper of wrapperNodes) {
1644
+ if (wrapper.overriddenHaltStateId === null)
1645
+ continue;
1646
+ lines.push(` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId)}`);
1647
+ }
1648
+ // 9. Regular transitions for non-wrapper non-halt-marker non-halt nodes.
1649
+ for (const node of nodes) {
1650
+ if (node.isHalt || node.isHaltMarker || node.isWrapper)
1651
+ continue;
1445
1652
  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
1653
  const alternatives = t.pattern.split('|');
1463
1654
  const reads = alternatives.map((alt) => `[${alt}]`).join('|');
1464
1655
  const writes = `[${t.command.map((c) => c.symbol).join(',')}]`;
1465
1656
  const moves = `[${t.command.map((c) => c.movement).join(',')}]`;
1466
1657
  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)}`);
1658
+ lines.push(` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`);
1481
1659
  }
1482
1660
  }
1483
1661
  return lines.join('\n');
1484
1662
  }
1663
+ // Helper: identify "the bare states" that anchor a frame's name. A bare is a
1664
+ // node referenced as some wrapper's `bareStateId`. Body states (also in-frame
1665
+ // but not bare) are excluded from the frame label.
1666
+ function isFrameBare(node, graph) {
1667
+ if (node.isWrapper || node.isHalt)
1668
+ return false;
1669
+ for (const other of Object.values(graph.nodes)) {
1670
+ if (other.isWrapper && other.bareStateId === node.id) {
1671
+ return true;
1672
+ }
1673
+ }
1674
+ return false;
1675
+ }
1485
1676
  // Inverse of toMermaid: parses the Mermaid output produced by toMermaid back
1486
1677
  // into a Graph. The parser is strict to the dialect toMermaid emits — it
1487
1678
  // recognises the specific node/edge shapes and the leading
@@ -1496,13 +1687,23 @@ function toMermaid(graph) {
1496
1687
  const haltNodeRegex = /^([sc]\d+)\(\(\(halt\)\)\)$/;
1497
1688
  const regularNodeRegex = /^(s\d+)\["([^"]*)"\]$/;
1498
1689
  const wrappedNodeRegex = /^(s\d+)\[\["([^"]*)"\]\]$/;
1499
- const subgraphStartRegex = /^subgraph\s+w_\d+\["([^"]*)"\]$/;
1690
+ const subgraphStartRegex = /^subgraph\s+w_(\d+)\["([^"]*)"\]$/;
1500
1691
  const subgraphEndRegex = /^end$/;
1501
1692
  const idleNodeRegex = /^idle\(\[idle\]\)$/;
1502
1693
  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+)$/;
1694
+ // Regular labeled transition (solid `-->`).
1695
+ const labeledTransitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/;
1696
+ // Wrapper override (unlabeled solid `-->`).
1697
+ const wrapperOverrideRegex = /^(s\d+)\s+-->\s+([sc]\d+)$/;
1698
+ // Call arrow (bold `==>`), with optional `&`-joined source ribbon.
1699
+ // Ribbon separator is fixed at " & " (single spaces around &) — toMermaid
1700
+ // emits exactly that form, so the parser is strict to it. The literal-space
1701
+ // form avoids CodeQL's polynomial-ReDoS flag on a `\s+&\s+` shape.
1702
+ const callArrowRegex = /^(s\d+(?: & s\d+)*)\s+==\s+"call"\s+==>\s+(s\d+)$/;
1703
+ // Return arrow (`w_N -. return .-> s_W` with optional `&` target ribbon).
1704
+ const returnArrowRegex = /^w_(\d+)\s+-\.\s+"return"\s+\.->\s+(s\d+(?: & s\d+)*)$/;
1705
+ // Halt arrow (`w_N -. halt .-> s0`).
1706
+ const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/;
1506
1707
  // First capture char anchored as \S to avoid polynomial backtracking between
1507
1708
  // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos.
1508
1709
  const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/;
@@ -1511,11 +1712,7 @@ function fromMermaid(text) {
1511
1712
  let alphabets = [];
1512
1713
  let initialId = null;
1513
1714
  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;
1715
+ let currentFrameId = null;
1519
1716
  const ensureNode = (id, opts = {}) => {
1520
1717
  if (!nodes[id]) {
1521
1718
  nodes[id] = {
@@ -1523,7 +1720,9 @@ function fromMermaid(text) {
1523
1720
  name: opts.name ?? mermaidIdFor(id),
1524
1721
  isHalt: opts.isHalt ?? false,
1525
1722
  isHaltMarker: opts.isHaltMarker ?? false,
1526
- isWrapped: opts.isWrapped ?? false,
1723
+ isWrapper: opts.isWrapper ?? false,
1724
+ bareStateId: opts.bareStateId ?? null,
1725
+ frameId: opts.frameId ?? null,
1527
1726
  transitions: [],
1528
1727
  overriddenHaltStateId: null,
1529
1728
  };
@@ -1535,72 +1734,100 @@ function fromMermaid(text) {
1535
1734
  nodes[id].isHalt = opts.isHalt;
1536
1735
  if (opts.isHaltMarker !== undefined)
1537
1736
  nodes[id].isHaltMarker = opts.isHaltMarker;
1538
- if (opts.isWrapped !== undefined)
1539
- nodes[id].isWrapped = opts.isWrapped;
1737
+ if (opts.isWrapper !== undefined)
1738
+ nodes[id].isWrapper = opts.isWrapper;
1739
+ if (opts.bareStateId !== undefined)
1740
+ nodes[id].bareStateId = opts.bareStateId;
1741
+ if (opts.frameId !== undefined)
1742
+ nodes[id].frameId = opts.frameId;
1540
1743
  }
1541
1744
  return nodes[id];
1542
1745
  };
1543
- // First pass: alphabets + nodes (track subgraph context to mark halt markers).
1746
+ // First pass: nodes + alphabets (track subgraph context for frameId).
1544
1747
  for (const line of lines) {
1545
- if (line === 'flowchart TD') {
1748
+ if (line === 'flowchart TD')
1546
1749
  continue;
1547
- }
1548
1750
  const am = line.match(alphabetsRegex);
1549
1751
  if (am) {
1550
1752
  alphabets = JSON.parse(am[1]);
1551
1753
  continue;
1552
1754
  }
1553
- if (subgraphStartRegex.test(line)) {
1554
- inSubgraph = true;
1755
+ const sgStart = line.match(subgraphStartRegex);
1756
+ if (sgStart) {
1757
+ currentFrameId = Number(sgStart[1]);
1555
1758
  continue;
1556
1759
  }
1557
1760
  if (subgraphEndRegex.test(line)) {
1558
- inSubgraph = false;
1761
+ currentFrameId = null;
1559
1762
  continue;
1560
1763
  }
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)) {
1764
+ if (idleNodeRegex.test(line))
1565
1765
  continue;
1566
- }
1567
1766
  const hm = line.match(haltNodeRegex);
1568
1767
  if (hm) {
1569
1768
  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
- }
1769
+ const isHaltMarker = currentFrameId !== null;
1770
+ ensureNode(id, {
1771
+ name: 'halt',
1772
+ isHalt: true,
1773
+ isHaltMarker,
1774
+ frameId: isHaltMarker ? currentFrameId : null,
1775
+ });
1575
1776
  continue;
1576
1777
  }
1577
1778
  const wm = line.match(wrappedNodeRegex);
1578
1779
  if (wm) {
1579
- ensureNode(parseMermaidId(wm[1]), { name: wm[2], isWrapped: true });
1780
+ ensureNode(parseMermaidId(wm[1]), {
1781
+ name: wm[2],
1782
+ isWrapper: true,
1783
+ });
1580
1784
  continue;
1581
1785
  }
1582
1786
  const rm = line.match(regularNodeRegex);
1583
1787
  if (rm) {
1584
- ensureNode(parseMermaidId(rm[1]), { name: rm[2] });
1788
+ ensureNode(parseMermaidId(rm[1]), {
1789
+ name: rm[2],
1790
+ frameId: currentFrameId,
1791
+ });
1585
1792
  continue;
1586
1793
  }
1587
1794
  }
1588
1795
  // Second pass: edges.
1589
1796
  for (const line of lines) {
1590
- // `idle -. enter .-> sN`: the sole source of initialId.
1591
1797
  const em = line.match(enterArrowRegex);
1592
1798
  if (em) {
1593
1799
  initialId = parseMermaidId(em[1]);
1594
1800
  continue;
1595
1801
  }
1596
- const om = line.match(onHaltRegex);
1597
- if (om) {
1598
- ensureNode(parseMermaidId(om[1])).overriddenHaltStateId = parseMermaidId(om[2]);
1802
+ // Return/halt arrows are derivable from frame structure at the next
1803
+ // toMermaid emit; consume but don't persist as graph data.
1804
+ if (returnArrowRegex.test(line) || haltArrowRegex.test(line)) {
1805
+ continue;
1806
+ }
1807
+ // `call` arrow — sets bareStateId on each source wrapper.
1808
+ const cm = line.match(callArrowRegex);
1809
+ if (cm) {
1810
+ const sources = cm[1].split(' & ');
1811
+ const bareId = parseMermaidId(cm[2]);
1812
+ for (const src of sources) {
1813
+ ensureNode(parseMermaidId(src), { isWrapper: true, bareStateId: bareId });
1814
+ }
1599
1815
  continue;
1600
1816
  }
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);
1817
+ // Wrapper override (unlabeled solid `-->`). Only fires if the source
1818
+ // node is a known wrapper (declared as `[[…]]`).
1819
+ const wo = line.match(wrapperOverrideRegex);
1820
+ if (wo) {
1821
+ const fromId = parseMermaidId(wo[1]);
1822
+ const toId = parseMermaidId(wo[2]);
1823
+ if (nodes[fromId] && nodes[fromId].isWrapper) {
1824
+ nodes[fromId].overriddenHaltStateId = toId;
1825
+ continue;
1826
+ }
1827
+ // Fall through — unlabeled solid from a non-wrapper is unexpected;
1828
+ // treated as a malformed line and ignored by the labeled-regex below.
1829
+ }
1830
+ const tm = line.match(labeledTransitionRegex);
1604
1831
  if (tm) {
1605
1832
  const fromId = parseMermaidId(tm[1]);
1606
1833
  const label = tm[2];
@@ -1609,31 +1836,13 @@ function fromMermaid(text) {
1609
1836
  if (arrowIx === -1) {
1610
1837
  throw new Error(`fromMermaid: malformed edge label: "${label}"`);
1611
1838
  }
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
1839
  const readLabel = label.slice(0, arrowIx);
1620
1840
  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
1841
  const stripBrackets = (s) => {
1631
1842
  if (!s.startsWith('[') || !s.endsWith(']')) {
1632
1843
  throw new Error(`fromMermaid: malformed bracketed list: "${s}"`);
1633
1844
  }
1634
1845
  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
1846
  let i = 0;
1638
1847
  while (i < inner.length) {
1639
1848
  if (inner[i] === '\\' && i + 1 < inner.length) {
@@ -1648,10 +1857,6 @@ function fromMermaid(text) {
1648
1857
  }
1649
1858
  return inner;
1650
1859
  };
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
1860
  const blockMatches = readLabel.match(/\[[^\]]*\]/g);
1656
1861
  if (!blockMatches || blockMatches.length === 0) {
1657
1862
  throw new Error(`fromMermaid: no bracketed read-list in label: "${label}"`);