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