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

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, _State_tags;
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.
@@ -750,6 +750,14 @@ class State {
750
750
  // Note: toGraph / fromGraph deliberately do not serialize debug — debug is
751
751
  // a runtime concern, not part of the structural graph.
752
752
  _State_debugRef.set(this, { current: null });
753
+ // Out-of-band tags applied to this State (#186). Tags are visualization
754
+ // and debugger-tooling metadata — they don't affect runtime transition
755
+ // lookup or `equivalentOn` comparisons. Stored as a Set for de-duplication;
756
+ // exposed via the `tags` getter as a frozen array snapshot. Lives on the
757
+ // State INSTANCE so wrappers (from `withOverriddenHaltState`) carry tags
758
+ // independently of their bare's tag set — see the #175 sharing test in
759
+ // State.spec.ts.
760
+ _State_tags.set(this, new Set());
753
761
  if (stateDefinition) {
754
762
  const keys = Object.getOwnPropertyNames(stateDefinition);
755
763
  if (keys.length) {
@@ -762,7 +770,7 @@ class State {
762
770
  symbols.forEach((symbol) => {
763
771
  const { nextState } = stateDefinition[symbol];
764
772
  const nextStateLocal = nextState ?? this;
765
- if (!(nextStateLocal instanceof State) && !(nextStateLocal instanceof Reference)) {
773
+ if (!(nextStateLocal instanceof _a) && !(nextStateLocal instanceof Reference)) {
766
774
  throw new Error('invalid nextState');
767
775
  }
768
776
  let { command } = stateDefinition[symbol];
@@ -833,8 +841,38 @@ class State {
833
841
  }
834
842
  __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, value);
835
843
  }
844
+ /**
845
+ * Add one or more tags to this State (#186). Tags are out-of-band metadata
846
+ * used by visualization (`toMermaid` emits `classDef`/`class` lines) and
847
+ * debugger tooling — they don't affect runtime transition lookup,
848
+ * `equivalentOn` comparisons, or any structural identity. Chainable.
849
+ */
850
+ tag(...tags) {
851
+ for (const t of tags) {
852
+ __classPrivateFieldGet$1(this, _State_tags, "f").add(t);
853
+ }
854
+ return this;
855
+ }
856
+ /**
857
+ * Remove one or more tags from this State (#186). Untagging a tag the
858
+ * State doesn't carry is a no-op. Chainable.
859
+ */
860
+ untag(...tags) {
861
+ for (const t of tags) {
862
+ __classPrivateFieldGet$1(this, _State_tags, "f").delete(t);
863
+ }
864
+ return this;
865
+ }
866
+ /**
867
+ * Frozen snapshot of this State's current tags (#186). The returned array
868
+ * is `Object.freeze`d — mutating it throws in strict mode (which TS-emitted
869
+ * code uses). Order matches insertion order of the underlying Set.
870
+ */
871
+ get tags() {
872
+ return Object.freeze([...__classPrivateFieldGet$1(this, _State_tags, "f")]);
873
+ }
836
874
  /** @internal — invoked by DebugConfig setters via module-private symbol. */
837
- [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), validateDebugFilter)](fieldName, filter) {
875
+ [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overriddenHaltState = new WeakMap(), _State_bareState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), _State_tags = new WeakMap(), validateDebugFilter)](fieldName, filter) {
838
876
  if (filter === undefined)
839
877
  return;
840
878
  // #108 part 2: `.after` on haltState has no semantic anchor — halt is
@@ -882,16 +920,40 @@ class State {
882
920
  throw new Error(`No nextState for symbol at state named ${__classPrivateFieldGet$1(this, _State_id, "f")}`);
883
921
  }
884
922
  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");
923
+ // Unwrap `this` if it's itself a wrapper — the chain's inner overrides
924
+ // are dead at runtime anyway (only the outermost `.wohs()`'s override is
925
+ // pushed onto the halt-stack on entry; verified empirically). Composite
926
+ // name reflects runtime behavior, not construction history. See #176.
927
+ const bare = __classPrivateFieldGet$1(this, _State_bareState, "f") ?? this;
928
+ // Memoize by (bare, override) so identical args return the same instance
929
+ // (#175). The cache uses WeakMaps + WeakRefs so cached wrappers can be
930
+ // GC'd when nothing else holds them. Compounds with the chain-collapse
931
+ // above: `A.wohs(t1).wohs(t2)` keys as (A, t2) after the unwrap, hitting
932
+ // the same cache slot as a direct `A.wohs(t2)`.
933
+ let innerCache = __classPrivateFieldGet$1(_a, _a, "f", _State_wrapperCache).get(bare);
934
+ if (innerCache !== undefined) {
935
+ const ref = innerCache.get(overriddenHaltState);
936
+ if (ref !== undefined) {
937
+ const cached = ref.deref();
938
+ if (cached !== undefined) {
939
+ return cached;
940
+ }
941
+ }
942
+ }
943
+ else {
944
+ innerCache = new WeakMap();
945
+ __classPrivateFieldGet$1(_a, _a, "f", _State_wrapperCache).set(bare, innerCache);
946
+ }
947
+ // Cache miss — construct with no name, then overwrite #name directly
948
+ // (composed names contain `(` and `)` which the constructor's user-facing
949
+ // validation would reject; private-field access bypasses that).
950
+ const state = new _a();
951
+ __classPrivateFieldSet$1(state, _State_name, `${bare.name}(${overriddenHaltState.name})`, "f");
952
+ __classPrivateFieldSet$1(state, _State_symbolToDataMap, __classPrivateFieldGet$1(bare, _State_symbolToDataMap, "f"), "f");
892
953
  __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");
954
+ __classPrivateFieldSet$1(state, _State_debugRef, __classPrivateFieldGet$1(bare, _State_debugRef, "f"), "f");
955
+ __classPrivateFieldSet$1(state, _State_bareState, bare, "f");
956
+ innerCache.set(overriddenHaltState, new WeakRef(state));
895
957
  return state;
896
958
  }
897
959
  // Single-state introspection — no traversal, no tapeBlock required.
@@ -904,7 +966,7 @@ class State {
904
966
  for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
905
967
  let target = null;
906
968
  try {
907
- target = nextState instanceof State ? nextState : nextState.ref;
969
+ target = nextState instanceof _a ? nextState : nextState.ref;
908
970
  }
909
971
  catch {
910
972
  target = null; // unbound Reference
@@ -928,204 +990,278 @@ class State {
928
990
  transitions,
929
991
  };
930
992
  }
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.
993
+ // Walks the State graph and emits a `Graph` data structure. v7 callable-
994
+ // subtree emit shape (#174):
995
+ //
996
+ // Each `withOverriddenHaltState` wrapper produces TWO graph nodes:
997
+ // - A wrapper node (`isWrapper: true`, `[[composite-name]]` shape) the
998
+ // call site. No transitions of its own. `bareStateId` points to the
999
+ // bare's GraphNode; `overriddenHaltStateId` points to the override
1000
+ // target's GraphNode.
1001
+ // - A bare node (`isWrapper: false`, regular shape) — the callable body.
1002
+ // Has the bare's transitions. Shared across all wrappers that wrap
1003
+ // this bare (no per-context duplication).
939
1004
  //
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.
1005
+ // Frames are computed via union-find on bare reachability: two bares whose
1006
+ // forward-reachable sets overlap merge into one frame. Each frame contains
1007
+ // its bares + body states + a single halt marker (id = `-frameId`). The
1008
+ // canonical `frameId` is the smallest bare-id in the component.
1009
+ //
1010
+ // Halt-bound transitions of any in-frame state are retargeted to the
1011
+ // frame's halt marker. The frame's `subtree -. return .-> wrapper` and
1012
+ // `subtree -. halt .-> s0` arrows are demand-emitted by `toMermaid` from
1013
+ // the frame structure; they're not stored as graph edges.
943
1014
  static toGraph(initialState, tapeBlock) {
944
1015
  const nodes = {};
945
1016
  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
- }
1017
+ // Pass 1: BFS-discover all reachable States; emit one GraphNode per State
1018
+ // (wrapper or bare/regular). Wrappers and bares are separate nodes.
1019
+ const visited = new Set();
1020
+ const queue = [initialState];
1021
+ const bareIds = new Set(); // ids referenced as a wrapper's bareStateId
963
1022
  while (queue.length > 0) {
964
- const { state, wrapperContext } = queue.shift();
1023
+ const state = queue.shift();
1024
+ if (visited.has(__classPrivateFieldGet$1(state, _State_id, "f"))) {
1025
+ continue;
1026
+ }
1027
+ visited.add(__classPrivateFieldGet$1(state, _State_id, "f"));
965
1028
  if (state.isHalt) {
966
- // Real halt — always id 0, single node.
967
1029
  if (!(0 in nodes)) {
968
1030
  nodes[0] = {
969
1031
  id: 0,
970
1032
  name: __classPrivateFieldGet$1(state, _State_name, "f"),
971
1033
  isHalt: true,
972
1034
  isHaltMarker: false,
973
- isWrapped: false,
1035
+ isWrapper: false,
1036
+ bareStateId: null,
1037
+ frameId: null,
974
1038
  transitions: [],
975
1039
  overriddenHaltStateId: null,
1040
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
976
1041
  };
977
1042
  }
978
1043
  continue;
979
1044
  }
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"),
1045
+ // Wrapper? Emit wrapper node + queue bare and override target.
1046
+ if (__classPrivateFieldGet$1(state, _State_overriddenHaltState, "f") !== null && __classPrivateFieldGet$1(state, _State_bareState, "f") !== null) {
1047
+ const bareState = __classPrivateFieldGet$1(state, _State_bareState, "f");
1048
+ const overrideTarget = __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f");
1049
+ nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = {
1050
+ id: __classPrivateFieldGet$1(state, _State_id, "f"),
1051
+ name: __classPrivateFieldGet$1(state, _State_name, "f"), // composite name like "A(target)"
1011
1052
  isHalt: false,
1012
1053
  isHaltMarker: false,
1013
- isWrapped: true,
1054
+ isWrapper: true,
1055
+ bareStateId: __classPrivateFieldGet$1(bareState, _State_id, "f"),
1056
+ frameId: null,
1014
1057
  transitions: [],
1015
- overriddenHaltStateId: overrideGraphId,
1058
+ overriddenHaltStateId: __classPrivateFieldGet$1(overrideTarget, _State_id, "f"),
1059
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
1016
1060
  };
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) {
1061
+ bareIds.add(__classPrivateFieldGet$1(bareState, _State_id, "f"));
1062
+ queue.push(bareState);
1063
+ queue.push(overrideTarget);
1070
1064
  continue;
1071
1065
  }
1066
+ // Regular (or bare) state — build node with transitions.
1072
1067
  const node = {
1073
1068
  id: __classPrivateFieldGet$1(state, _State_id, "f"),
1074
1069
  name: __classPrivateFieldGet$1(state, _State_name, "f"),
1075
1070
  isHalt: false,
1076
1071
  isHaltMarker: false,
1077
- isWrapped: false,
1072
+ isWrapper: false,
1073
+ bareStateId: null,
1074
+ frameId: null,
1078
1075
  transitions: [],
1079
1076
  overriddenHaltStateId: null,
1077
+ tags: [...__classPrivateFieldGet$1(state, _State_tags, "f")],
1080
1078
  };
1081
1079
  nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = node;
1082
1080
  let patternIx = 0;
1083
1081
  for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
1084
1082
  let target;
1085
1083
  try {
1086
- target = nextState instanceof State ? nextState : nextState.ref;
1084
+ target = nextState instanceof _a ? nextState : nextState.ref;
1087
1085
  }
1088
1086
  catch {
1089
1087
  patternIx += 1;
1090
1088
  continue;
1091
1089
  }
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
1090
  node.transitions.push({
1103
1091
  pattern: decodePatternDescription(sym.description, alphabets),
1104
1092
  command: command.tapesCommands.map((tc) => ({
1105
1093
  symbol: decodeWriteSymbol(tc.symbol),
1106
1094
  movement: decodeMovement(tc.movement.description),
1107
1095
  })),
1108
- nextStateId,
1096
+ nextStateId: __classPrivateFieldGet$1(target, _State_id, "f"),
1109
1097
  id: `${__classPrivateFieldGet$1(state, _State_id, "f")}-${patternIx}`,
1110
1098
  });
1099
+ queue.push(target);
1111
1100
  patternIx += 1;
1112
1101
  }
1113
1102
  }
1114
- return { initialId, alphabets, nodes };
1103
+ // Always emit real halt as a sentinel, even if no transition targets it.
1104
+ // It anchors the `subtree -. halt .-> s0` frame-level arrow whenever a
1105
+ // frame demand-emits one, and it's the canonical machine-halt singleton.
1106
+ if (!(0 in nodes)) {
1107
+ nodes[0] = {
1108
+ id: 0,
1109
+ name: 'halt',
1110
+ isHalt: true,
1111
+ isHaltMarker: false,
1112
+ isWrapper: false,
1113
+ bareStateId: null,
1114
+ frameId: null,
1115
+ transitions: [],
1116
+ overriddenHaltStateId: null,
1117
+ tags: [...__classPrivateFieldGet$1(haltState, _State_tags, "f")],
1118
+ };
1119
+ }
1120
+ // Pass 2: For each bare, compute its forward-reachable set (following
1121
+ // transitions; stopping at halt and at wrappers — both are frame
1122
+ // boundaries).
1123
+ const computeReach = (startId) => {
1124
+ const reach = new Set();
1125
+ const stack = [startId];
1126
+ while (stack.length > 0) {
1127
+ const id = stack.pop();
1128
+ if (reach.has(id)) {
1129
+ continue;
1130
+ }
1131
+ const node = nodes[id];
1132
+ // `nodes[id]` is always populated for `id` that the BFS reached, so
1133
+ // a defensive `!node` check would be dead. `isHalt` / `isWrapper`
1134
+ // are real boundaries — both stop reach-set expansion.
1135
+ if (node.isHalt || node.isWrapper) {
1136
+ continue;
1137
+ }
1138
+ reach.add(id);
1139
+ for (const t of node.transitions) {
1140
+ const target = nodes[t.nextStateId];
1141
+ if (!target || target.isHalt || target.isWrapper) {
1142
+ continue;
1143
+ }
1144
+ stack.push(t.nextStateId);
1145
+ }
1146
+ }
1147
+ return reach;
1148
+ };
1149
+ const reachByBare = new Map();
1150
+ for (const bareId of bareIds) {
1151
+ reachByBare.set(bareId, computeReach(bareId));
1152
+ }
1153
+ // Pass 3: Union-find on bare overlaps. Two bares merge if their reach
1154
+ // sets share any state. Canonical representative = smallest bare-id in
1155
+ // the component.
1156
+ const ufParent = new Map();
1157
+ // Note: no path compression. The union policy below ("smaller id always
1158
+ // becomes root") keeps the tree flat — every union targets bares[0] as
1159
+ // the root, so any node's parent IS the root. Walking up never exceeds
1160
+ // one step. Path compression would be dead code under this invariant.
1161
+ const ufFind = (id) => {
1162
+ if (!ufParent.has(id)) {
1163
+ ufParent.set(id, id);
1164
+ }
1165
+ let root = id;
1166
+ while (ufParent.get(root) !== root) {
1167
+ root = ufParent.get(root);
1168
+ }
1169
+ return root;
1170
+ };
1171
+ const ufUnion = (a, b) => {
1172
+ const ra = ufFind(a);
1173
+ const rb = ufFind(b);
1174
+ if (ra === rb)
1175
+ return;
1176
+ if (ra < rb) {
1177
+ ufParent.set(rb, ra);
1178
+ }
1179
+ else {
1180
+ ufParent.set(ra, rb);
1181
+ }
1182
+ };
1183
+ for (const bareId of bareIds) {
1184
+ ufFind(bareId);
1185
+ }
1186
+ // For each state, collect the bares that reach it; union all bares that
1187
+ // share a state.
1188
+ const stateToReachingBares = new Map();
1189
+ for (const [bareId, reachSet] of reachByBare) {
1190
+ for (const stateId of reachSet) {
1191
+ let bares = stateToReachingBares.get(stateId);
1192
+ if (!bares) {
1193
+ bares = [];
1194
+ stateToReachingBares.set(stateId, bares);
1195
+ }
1196
+ bares.push(bareId);
1197
+ }
1198
+ }
1199
+ for (const bares of stateToReachingBares.values()) {
1200
+ for (let i = 1; i < bares.length; i += 1) {
1201
+ ufUnion(bares[0], bares[i]);
1202
+ }
1203
+ }
1204
+ // Assign frameId to each in-reach state.
1205
+ const frameIds = new Set();
1206
+ for (const [stateId, bares] of stateToReachingBares) {
1207
+ const frameId = ufFind(bares[0]);
1208
+ nodes[stateId].frameId = frameId;
1209
+ frameIds.add(frameId);
1210
+ }
1211
+ // Pass 4: Retarget halt-bound transitions for in-frame states to the
1212
+ // frame's halt marker. Out-of-frame states (top-level dispatcher, override
1213
+ // targets, etc.) keep their halt-bound transitions pointing at real halt.
1214
+ for (const node of Object.values(nodes)) {
1215
+ if (node.frameId === null) {
1216
+ continue;
1217
+ }
1218
+ const haltMarkerId = -node.frameId;
1219
+ for (const t of node.transitions) {
1220
+ const target = nodes[t.nextStateId];
1221
+ if (target && target.isHalt && !target.isHaltMarker) {
1222
+ t.nextStateId = haltMarkerId;
1223
+ }
1224
+ }
1225
+ }
1226
+ // Pass 5: Emit one halt marker per frame.
1227
+ for (const frameId of frameIds) {
1228
+ const haltMarkerId = -frameId;
1229
+ nodes[haltMarkerId] = {
1230
+ id: haltMarkerId,
1231
+ name: 'halt',
1232
+ isHalt: true,
1233
+ isHaltMarker: true,
1234
+ isWrapper: false,
1235
+ bareStateId: null,
1236
+ frameId,
1237
+ transitions: [],
1238
+ overriddenHaltStateId: null,
1239
+ tags: [],
1240
+ };
1241
+ }
1242
+ return { initialId: __classPrivateFieldGet$1(initialState, _State_id, "f"), alphabets, nodes };
1115
1243
  }
1116
1244
  // Inverse of toGraph: rebuilds a State graph (and a fresh TapeBlock with the
1117
1245
  // graph's alphabets) from a serialized Graph. Round-trips with toGraph in
1118
1246
  // the sense that running the rebuilt machine on the same input gives the
1119
1247
  // same output, but the rebuilt State instances have *new* internal IDs.
1248
+ //
1249
+ // Under the v7 callable-subtree model (#174), graph nodes split into:
1250
+ // - Wrapper nodes (`isWrapper: true`, no transitions) — reconstructed via
1251
+ // `bareStates[bareStateId].withOverriddenHaltState(finalStates[overriddenHaltStateId])`.
1252
+ // - Bare/regular nodes — constructed as normal States with transitions.
1253
+ // - Halt + halt-marker nodes — collapse to the singleton `haltState`.
1120
1254
  static fromGraph(graph) {
1121
1255
  const alphabetObjs = graph.alphabets.map((syms) => new Alphabet(syms));
1122
1256
  const tapeBlock = TapeBlock.fromAlphabets(alphabetObjs);
1123
1257
  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.
1258
+ // Pass 1: pre-create a Reference for each non-halt non-halt-marker node
1259
+ // (both wrappers and regulars). Halt and halt-marker nodes collapse to the
1260
+ // singleton `haltState` and need no ref.
1126
1261
  const refs = {};
1127
1262
  for (const nodeId of ids) {
1128
- if (!graph.nodes[nodeId].isHalt) {
1263
+ const node = graph.nodes[nodeId];
1264
+ if (!node.isHalt) {
1129
1265
  refs[nodeId] = new Reference();
1130
1266
  }
1131
1267
  }
@@ -1142,19 +1278,22 @@ class State {
1142
1278
  }
1143
1279
  return tapeBlock.symbol(flat);
1144
1280
  };
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.
1281
+ // Pass 2: build a State for each non-wrapper non-halt non-halt-marker
1282
+ // node. Transitions point at refs so cycles work; haltState (and halt
1283
+ // markers, which collapse to haltState) are used directly.
1147
1284
  const bareStates = {};
1148
1285
  for (const nodeId of ids) {
1149
1286
  const node = graph.nodes[nodeId];
1150
- if (node.isHalt) {
1287
+ if (node.isHalt || node.isWrapper) {
1151
1288
  continue;
1152
1289
  }
1153
1290
  const stateDefinition = {};
1154
1291
  for (const t of node.transitions) {
1155
1292
  const key = patternToKey(parsePatternString(t.pattern, graph.alphabets));
1156
1293
  const target = graph.nodes[t.nextStateId];
1157
- const nextState = target.isHalt ? haltState : refs[t.nextStateId];
1294
+ const nextState = !target || target.isHalt
1295
+ ? haltState
1296
+ : refs[t.nextStateId];
1158
1297
  stateDefinition[key] = {
1159
1298
  command: t.command.map((c) => ({
1160
1299
  symbol: parseWriteSymbolLabel(c.symbol),
@@ -1163,14 +1302,20 @@ class State {
1163
1302
  nextState,
1164
1303
  };
1165
1304
  }
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);
1305
+ // Graph-sourced names may contain `(` and `)` (composite wrapper names
1306
+ // although wrappers go through a separate path below, defensive
1307
+ // construction here keeps the bypass uniform). Construct without a name
1308
+ // and assign `#name` directly to skip user-facing name validation.
1309
+ const bare = new _a(stateDefinition);
1170
1310
  __classPrivateFieldSet$1(bare, _State_name, node.name, "f");
1311
+ if (node.tags.length > 0) {
1312
+ bare.tag(...node.tags);
1313
+ }
1171
1314
  bareStates[nodeId] = bare;
1172
1315
  }
1173
- // Pass 3: apply overrideHaltStates transitively.
1316
+ // Pass 3: resolve every node to its final State (memoized + cycle-safe).
1317
+ // Wrappers compose lazily via `withOverriddenHaltState` once their bare
1318
+ // and override are resolved.
1174
1319
  const finalStates = {};
1175
1320
  const inProgress = new Set();
1176
1321
  const getFinal = (nodeId) => {
@@ -1178,7 +1323,7 @@ class State {
1178
1323
  return finalStates[nodeId];
1179
1324
  }
1180
1325
  const node = graph.nodes[nodeId];
1181
- if (node.isHalt) {
1326
+ if (!node || node.isHalt) {
1182
1327
  finalStates[nodeId] = haltState;
1183
1328
  return haltState;
1184
1329
  }
@@ -1186,9 +1331,21 @@ class State {
1186
1331
  throw new Error(`override-halt cycle at state #${nodeId}`);
1187
1332
  }
1188
1333
  inProgress.add(nodeId);
1189
- let state = bareStates[nodeId];
1190
- if (node.overriddenHaltStateId !== null) {
1191
- state = bareStates[nodeId].withOverriddenHaltState(getFinal(node.overriddenHaltStateId));
1334
+ let state;
1335
+ if (node.isWrapper) {
1336
+ const bare = getFinal(node.bareStateId);
1337
+ const override = getFinal(node.overriddenHaltStateId);
1338
+ state = bare.withOverriddenHaltState(override);
1339
+ // Apply wrapper-scoped tags (#186). Tags don't leak across wrappers
1340
+ // sharing a bare — the wrapper instance owns its own tag set, and
1341
+ // engine #175 memoization returns the same instance for the same
1342
+ // (bare, override) pair, so this is idempotent across rebuilds.
1343
+ if (node.tags.length > 0) {
1344
+ state.tag(...node.tags);
1345
+ }
1346
+ }
1347
+ else {
1348
+ state = bareStates[nodeId];
1192
1349
  }
1193
1350
  inProgress.delete(nodeId);
1194
1351
  finalStates[nodeId] = state;
@@ -1197,8 +1354,8 @@ class State {
1197
1354
  for (const nodeId of ids) {
1198
1355
  getFinal(nodeId);
1199
1356
  }
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.
1357
+ // Pass 4: bind each ref to the resolved final State so cross-node
1358
+ // transitions land on the right instance.
1202
1359
  for (const nodeId of ids) {
1203
1360
  if (!graph.nodes[nodeId].isHalt) {
1204
1361
  refs[nodeId].bind(finalStates[nodeId]);
@@ -1211,6 +1368,13 @@ class State {
1211
1368
  };
1212
1369
  }
1213
1370
  }
1371
+ _a = State;
1372
+ // Memoization cache for `withOverriddenHaltState`. Keyed by
1373
+ // (bare, override) — same args return the same wrapper instance (#175).
1374
+ // Two-level WeakMap so the outer entry is GC'd when the bare is collected;
1375
+ // WeakRef values let wrappers themselves be GC'd when nothing else holds
1376
+ // them, with cache misses simply reconstructing fresh wrappers.
1377
+ _State_wrapperCache = { value: new WeakMap() };
1214
1378
  const haltState = new State(null);
1215
1379
 
1216
1380
  var __classPrivateFieldSet = (undefined && undefined.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
@@ -1354,19 +1518,25 @@ _TuringMachine_tapeBlock = new WeakMap(), _TuringMachine_stack = new WeakMap();
1354
1518
  // Currently only Mermaid flowchart syntax is supported. Future formats
1355
1519
  // (Graphviz, JSON-LD, custom DSL) belong here too.
1356
1520
  //
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.
1521
+ // v7 callable-subtree emit (#174):
1522
+ // - Each `withOverriddenHaltState` wrapper produces TWO graph nodes a
1523
+ // wrapper node (`[[composite-name]]`, OUTSIDE any subgraph) and a bare
1524
+ // node (regular shape, INSIDE its callable subtree subgraph).
1525
+ // - Subgraphs (one per frame): `subgraph w_${frameId}["callable subtree
1526
+ // of NAME"]` (single bare) or `["callable scope: A B"]` (union).
1527
+ // - Each frame has exactly one halt marker `c${frameId}(((halt)))` inside
1528
+ // its subgraph; halt-bound transitions from in-frame states retarget to
1529
+ // it. Always emitted (orphan signals dead wrapper).
1530
+ // - Arrow conventions:
1531
+ // solid `-->` regular transitions, including wrapper-to-override.
1532
+ // bold `==>` RESERVED for the wrapper-to-bare `call` arrow.
1533
+ // `&` ribbon collapses multi-wrapper-shares-bare.
1534
+ // dotted `-.->` frame-level dispatch (`return`, `halt`, `enter`).
1535
+ // - The `return` arrow (subgraph → wrapper) is demand-emitted iff the
1536
+ // frame's halt marker has at least one incoming edge AND the wrapper
1537
+ // calls into the frame. The `halt` arrow (subgraph → s0) is emitted
1538
+ // iff the halt marker has incoming AND there's at least one non-wrapper
1539
+ // entry into the frame (cross-subgraph solid arrow from outside).
1370
1540
  // Maps a graph node id to its Mermaid id.
1371
1541
  // - non-negative id N → "sN"
1372
1542
  // - negative id -N (halt marker) → "cN"
@@ -1380,108 +1550,256 @@ function parseMermaidId(s) {
1380
1550
  }
1381
1551
  return Number(s.slice(1));
1382
1552
  }
1553
+ function frameSubgraphId(frameId) {
1554
+ return `w_${frameId}`;
1555
+ }
1383
1556
  function toMermaid(graph) {
1384
1557
  const lines = [
1385
1558
  'flowchart TD',
1386
1559
  `%% alphabets: ${JSON.stringify(graph.alphabets)}`,
1387
1560
  ];
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).
1561
+ // Sort nodes by id ascending — real halt (0) first, then regulars by their
1562
+ // ids, then halt markers (negative) at the end. Deterministic emit lets
1563
+ // toMermaid → fromMermaid → toMermaid round-trip stably (#139).
1391
1564
  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.
1565
+ // Bucket nodes for emit order.
1566
+ const topLevelNodes = nodes.filter((n) => n.frameId === null && !n.isWrapper);
1567
+ const wrapperNodes = nodes.filter((n) => n.isWrapper);
1568
+ // Bares-and-bodies inside frames, grouped by frameId.
1569
+ const nodesByFrame = new Map();
1570
+ // Halt-marker per frame (kept separate so it always emits LAST inside the
1571
+ // subgraph for deterministic shape).
1572
+ const haltMarkerByFrame = new Map();
1406
1573
  for (const node of nodes) {
1407
- if (node.isWrapped || haltMarkerIds.has(node.id)) {
1574
+ if (node.frameId === null || node.isWrapper)
1408
1575
  continue;
1576
+ if (node.isHaltMarker) {
1577
+ haltMarkerByFrame.set(node.frameId, node);
1409
1578
  }
1410
- const id = mermaidIdFor(node.id);
1579
+ else {
1580
+ let bucket = nodesByFrame.get(node.frameId);
1581
+ if (!bucket) {
1582
+ bucket = [];
1583
+ nodesByFrame.set(node.frameId, bucket);
1584
+ }
1585
+ bucket.push(node);
1586
+ }
1587
+ }
1588
+ // Build the visible-label string for a node — name plus, if tagged, a
1589
+ // `<br>tag1, tag2, ...` suffix so the rendered Mermaid shows both. Tags
1590
+ // are the source of truth on the GraphNode; `<br>` is the universal
1591
+ // Mermaid line-break that works across renderers without `classDef`-
1592
+ // pseudo-element hacks (#186).
1593
+ const labelOf = (node) => {
1594
+ if (node.tags.length === 0)
1595
+ return node.name;
1596
+ return `${node.name}<br>${node.tags.join(', ')}`;
1597
+ };
1598
+ // 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame).
1599
+ for (const node of topLevelNodes) {
1600
+ const mid = mermaidIdFor(node.id);
1411
1601
  if (node.isHalt) {
1412
- lines.push(` ${id}(((halt)))`);
1602
+ lines.push(` ${mid}(((halt)))`);
1413
1603
  }
1414
1604
  else {
1415
- lines.push(` ${id}["${node.name}"]`);
1605
+ lines.push(` ${mid}["${labelOf(node)}"]`);
1416
1606
  }
1417
1607
  }
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.
1608
+ // 2. Emit wrappers at top level.
1609
+ for (const wrapper of wrapperNodes) {
1610
+ lines.push(` ${mermaidIdFor(wrapper.id)}[["${labelOf(wrapper)}"]]`);
1611
+ }
1612
+ // 3. `idle` sentinel.
1422
1613
  lines.push(' idle([idle])');
1423
- // Emit one subgraph per wrapper, in sorted wrapped-id order.
1424
- for (const wrapped of wrappedNodes) {
1425
- const wrappedMid = mermaidIdFor(wrapped.id);
1426
- const haltMarkerId = haltMarkerIdFor(wrapped.id);
1427
- const haltMarkerMid = mermaidIdFor(haltMarkerId);
1428
- lines.push(` subgraph w_${wrapped.id}["halt frame"]`);
1429
- lines.push(` ${wrappedMid}[["${wrapped.name}"]]`);
1430
- if (haltMarkerId in graph.nodes) {
1431
- lines.push(` ${haltMarkerMid}(((halt)))`);
1432
- }
1614
+ // 4. Subgraph per frame.
1615
+ const frameIds = [...nodesByFrame.keys()].sort((a, b) => a - b);
1616
+ for (const frameId of frameIds) {
1617
+ const frameBares = (nodesByFrame.get(frameId) ?? []).filter((n) => isFrameBare(n, graph));
1618
+ const frameBareNames = frameBares
1619
+ .slice()
1620
+ .sort((a, b) => a.id - b.id)
1621
+ .map((n) => n.name);
1622
+ const label = frameBareNames.length > 1
1623
+ ? `callable scope: ${frameBareNames.join(' ∪ ')}`
1624
+ : `callable subtree of ${frameBareNames[0] ?? frameId}`;
1625
+ lines.push(` subgraph ${frameSubgraphId(frameId)}["${label}"]`);
1626
+ // Inner nodes — sort by id for determinism.
1627
+ for (const node of (nodesByFrame.get(frameId) ?? []).slice().sort((a, b) => a.id - b.id)) {
1628
+ lines.push(` ${mermaidIdFor(node.id)}["${labelOf(node)}"]`);
1629
+ }
1630
+ // Every frame has a halt marker — `State.toGraph`'s frame-emit pass
1631
+ // creates one for each frame. Non-null assertion is safe; a defensive
1632
+ // null check would be dead.
1633
+ const haltMarker = haltMarkerByFrame.get(frameId);
1634
+ lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`);
1433
1635
  lines.push(' end');
1434
1636
  }
1435
- // Enter arrow: emitted after subgraphs so it visually points at the initial
1436
- // node (whether plain `[…]` or wrapped `[[…]]` inside a subgraph).
1637
+ // 5. Enter arrow.
1437
1638
  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).
1639
+ // 6. `call` arrows grouped by bare (multi-wrapper-shares-bare collapses
1640
+ // into a single `&` ribbon).
1641
+ const wrappersByBare = new Map();
1642
+ for (const wrapper of wrapperNodes) {
1643
+ if (wrapper.bareStateId === null)
1644
+ continue;
1645
+ let group = wrappersByBare.get(wrapper.bareStateId);
1646
+ if (!group) {
1647
+ group = [];
1648
+ wrappersByBare.set(wrapper.bareStateId, group);
1649
+ }
1650
+ group.push(wrapper);
1651
+ }
1652
+ const sortedBares = [...wrappersByBare.keys()].sort((a, b) => a - b);
1653
+ for (const bareId of sortedBares) {
1654
+ const wrappers = wrappersByBare.get(bareId).slice().sort((a, b) => a.id - b.id);
1655
+ const sources = wrappers.map((w) => mermaidIdFor(w.id)).join(' & ');
1656
+ lines.push(` ${sources} == "call" ==> ${mermaidIdFor(bareId)}`);
1657
+ }
1658
+ // 7. Demand-emit `return` and `halt` arrows per frame.
1659
+ // For each frame: check if its halt marker has incoming transitions.
1660
+ const haltMarkerHasIncoming = new Map();
1441
1661
  for (const node of nodes) {
1442
- if (node.isHalt && !node.isHaltMarker) {
1662
+ for (const t of node.transitions) {
1663
+ const target = graph.nodes[t.nextStateId];
1664
+ if (target && target.isHaltMarker && target.frameId !== null) {
1665
+ haltMarkerHasIncoming.set(target.frameId, true);
1666
+ }
1667
+ }
1668
+ }
1669
+ // For each frame: check if there's at least one non-wrapper entry (a solid
1670
+ // `-->` from OUTSIDE the frame into any node INSIDE).
1671
+ const hasNonWrapperEntry = new Map();
1672
+ for (const node of nodes) {
1673
+ if (node.isWrapper)
1674
+ continue;
1675
+ for (const t of node.transitions) {
1676
+ const target = graph.nodes[t.nextStateId];
1677
+ if (target
1678
+ && target.frameId !== null
1679
+ && node.frameId !== target.frameId) {
1680
+ hasNonWrapperEntry.set(target.frameId, true);
1681
+ }
1682
+ }
1683
+ }
1684
+ for (const frameId of frameIds) {
1685
+ if (!haltMarkerHasIncoming.get(frameId))
1443
1686
  continue;
1687
+ // Return arrow — collapsed `&` ribbon over all wrappers calling this
1688
+ // frame. Frames only exist because at least one wrapper's bareStateId
1689
+ // points to a bare in the frame, so `callingWrappers` is always
1690
+ // non-empty for any frame that reached this code path.
1691
+ const callingWrappers = wrapperNodes.filter((w) => {
1692
+ const bare = graph.nodes[w.bareStateId];
1693
+ return bare.frameId === frameId;
1694
+ });
1695
+ const targets = callingWrappers
1696
+ .slice()
1697
+ .sort((a, b) => a.id - b.id)
1698
+ .map((w) => mermaidIdFor(w.id))
1699
+ .join(' & ');
1700
+ lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`);
1701
+ if (hasNonWrapperEntry.get(frameId)) {
1702
+ lines.push(` ${frameSubgraphId(frameId)} -. "halt" .-> s0`);
1444
1703
  }
1704
+ }
1705
+ // 8. Wrapper-to-override arrows (regular solid).
1706
+ //
1707
+ // `wrapper.overriddenHaltStateId` is always non-null on wrapper nodes
1708
+ // (set by `State.toGraph` for every `isWrapper: true` node — it's the
1709
+ // wrapper's override target, which a wrapper by definition has). The
1710
+ // non-null assertion is safe; a defensive null check would be dead.
1711
+ for (const wrapper of wrapperNodes) {
1712
+ lines.push(` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId)}`);
1713
+ }
1714
+ // 9. Regular transitions for non-wrapper non-halt-marker non-halt nodes.
1715
+ for (const node of nodes) {
1716
+ if (node.isHalt || node.isHaltMarker || node.isWrapper)
1717
+ continue;
1445
1718
  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
1719
  const alternatives = t.pattern.split('|');
1463
1720
  const reads = alternatives.map((alt) => `[${alt}]`).join('|');
1464
1721
  const writes = `[${t.command.map((c) => c.symbol).join(',')}]`;
1465
1722
  const moves = `[${t.command.map((c) => c.movement).join(',')}]`;
1466
1723
  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)}`);
1724
+ lines.push(` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`);
1481
1725
  }
1482
1726
  }
1727
+ // 10. Tags (#186) — emit one `classDef tag_<name> fill:#...` per unique
1728
+ // tag across all nodes, then one `class <ids> tag_<name>` line per
1729
+ // tag listing every node that carries it (comma-joined for compact
1730
+ // emit). Tag-name → CSS-class identifier sanitization replaces any
1731
+ // char outside `[A-Za-z0-9_-]` with `_`; tag-name uniqueness in the
1732
+ // emit assumes user tags are already distinct after sanitization
1733
+ // (collisions are user error).
1734
+ emitTagAnnotations(lines, nodes);
1483
1735
  return lines.join('\n');
1484
1736
  }
1737
+ // Default Mermaid `classDef` palette — 6 visually distinct fill+stroke pairs,
1738
+ // selected by tag-name hash so multi-tag diagrams look readable out of the
1739
+ // box without user configuration. Users who want different colors can edit
1740
+ // the emitted Mermaid before rendering or override post-emit.
1741
+ const TAG_PALETTE = [
1742
+ ['#fef3c7', '#92400e'], // amber
1743
+ ['#dbeafe', '#1e40af'], // blue
1744
+ ['#dcfce7', '#166534'], // green
1745
+ ['#fce7f3', '#9d174d'], // pink
1746
+ ['#ede9fe', '#5b21b6'], // violet
1747
+ ['#fee2e2', '#991b1b'], // red
1748
+ ];
1749
+ function sanitizeTagName(tag) {
1750
+ return tag.replace(/[^A-Za-z0-9_-]/g, '_');
1751
+ }
1752
+ function tagColor(tag) {
1753
+ // Cheap deterministic hash — sum of char codes mod palette length. Stable
1754
+ // across runs; same tag name always picks the same color.
1755
+ let h = 0;
1756
+ for (let i = 0; i < tag.length; i += 1) {
1757
+ h = (h + tag.charCodeAt(i)) % TAG_PALETTE.length;
1758
+ }
1759
+ return TAG_PALETTE[h];
1760
+ }
1761
+ function emitTagAnnotations(lines, nodes) {
1762
+ // Collect nodes per tag in node-id order so output is deterministic.
1763
+ const nodesByTag = new Map();
1764
+ for (const node of nodes) {
1765
+ for (const tag of node.tags) {
1766
+ let list = nodesByTag.get(tag);
1767
+ if (!list) {
1768
+ list = [];
1769
+ nodesByTag.set(tag, list);
1770
+ }
1771
+ list.push(node.id);
1772
+ }
1773
+ }
1774
+ if (nodesByTag.size === 0)
1775
+ return;
1776
+ const sortedTags = [...nodesByTag.keys()].sort();
1777
+ for (const tag of sortedTags) {
1778
+ const sanitized = sanitizeTagName(tag);
1779
+ const [fill, stroke] = tagColor(tag);
1780
+ lines.push(` classDef tag_${sanitized} fill:${fill},stroke:${stroke}`);
1781
+ }
1782
+ for (const tag of sortedTags) {
1783
+ const sanitized = sanitizeTagName(tag);
1784
+ const ids = nodesByTag.get(tag).map((id) => mermaidIdFor(id)).join(',');
1785
+ lines.push(` class ${ids} tag_${sanitized}`);
1786
+ }
1787
+ }
1788
+ // Helper: identify "the bare states" that anchor a frame's name. A bare is a
1789
+ // node referenced as some wrapper's `bareStateId`. Body states (also in-frame
1790
+ // but not bare) are excluded from the frame label.
1791
+ //
1792
+ // The caller in `toMermaid` only passes non-wrapper, non-halt-marker nodes
1793
+ // (wrappers go to a separate bucket; halt markers go to `haltMarkerByFrame`).
1794
+ // No defensive `isHalt` / `isWrapper` guards needed here.
1795
+ function isFrameBare(node, graph) {
1796
+ for (const other of Object.values(graph.nodes)) {
1797
+ if (other.isWrapper && other.bareStateId === node.id) {
1798
+ return true;
1799
+ }
1800
+ }
1801
+ return false;
1802
+ }
1485
1803
  // Inverse of toMermaid: parses the Mermaid output produced by toMermaid back
1486
1804
  // into a Graph. The parser is strict to the dialect toMermaid emits — it
1487
1805
  // recognises the specific node/edge shapes and the leading
@@ -1496,26 +1814,59 @@ function toMermaid(graph) {
1496
1814
  const haltNodeRegex = /^([sc]\d+)\(\(\(halt\)\)\)$/;
1497
1815
  const regularNodeRegex = /^(s\d+)\["([^"]*)"\]$/;
1498
1816
  const wrappedNodeRegex = /^(s\d+)\[\["([^"]*)"\]\]$/;
1499
- const subgraphStartRegex = /^subgraph\s+w_\d+\["([^"]*)"\]$/;
1817
+ const subgraphStartRegex = /^subgraph\s+w_(\d+)\["([^"]*)"\]$/;
1500
1818
  const subgraphEndRegex = /^end$/;
1501
1819
  const idleNodeRegex = /^idle\(\[idle\]\)$/;
1502
1820
  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+)$/;
1821
+ // Regular labeled transition (solid `-->`).
1822
+ const labeledTransitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/;
1823
+ // Wrapper override (unlabeled solid `-->`).
1824
+ const wrapperOverrideRegex = /^(s\d+)\s+-->\s+([sc]\d+)$/;
1825
+ // Call arrow (bold `==>`), with optional `&`-joined source ribbon.
1826
+ // Ribbon separator is fixed at " & " (single spaces around &) — toMermaid
1827
+ // emits exactly that form, so the parser is strict to it. The literal-space
1828
+ // form avoids CodeQL's polynomial-ReDoS flag on a `\s+&\s+` shape.
1829
+ const callArrowRegex = /^(s\d+(?: & s\d+)*)\s+==\s+"call"\s+==>\s+(s\d+)$/;
1830
+ // Return arrow (`w_N -. return .-> s_W` with optional `&` target ribbon).
1831
+ const returnArrowRegex = /^w_(\d+)\s+-\.\s+"return"\s+\.->\s+(s\d+(?: & s\d+)*)$/;
1832
+ // Halt arrow (`w_N -. halt .-> s0`).
1833
+ const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/;
1506
1834
  // First capture char anchored as \S to avoid polynomial backtracking between
1507
1835
  // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos.
1508
1836
  const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/;
1837
+ // Tag annotation lines (#186). Matches both `classDef tag_<sanitized>` and
1838
+ // `class <id-list> tag_<sanitized>`. ClassDef declarations are decorative
1839
+ // (palette) and discarded on parse — toMermaid will regenerate them from
1840
+ // the tag set on re-emit. `class` lines carry the actual graph-node
1841
+ // assignments; we strip the `tag_` prefix and assign each tag to each
1842
+ // listed node's `tags` array.
1843
+ //
1844
+ // Inter-token gaps are fixed at single literal spaces (matching toMermaid's
1845
+ // canonical emit) rather than `\s+`. This avoids the polynomial-ReDoS
1846
+ // pattern CodeQL flags when `\s+` surrounds a content group (see also
1847
+ // `callArrowRegex` / `returnArrowRegex` tightening in PR #182).
1848
+ const classDefTagRegex = /^classDef tag_([A-Za-z0-9_-]+) .+$/;
1849
+ const classAssignTagRegex = /^class ([sc]\d+(?:,[sc]\d+)*) tag_([A-Za-z0-9_-]+)$/;
1850
+ // Splits a node label like `"A<br>hot, sampled"` into its name and tags (#186).
1851
+ // Labels without `<br>` have no tags. Tags are comma-joined; trimmed of
1852
+ // whitespace. The `<br>` is the single source of truth for tag-name parsing —
1853
+ // `class` lines are decorative-only and not consulted here.
1854
+ function splitLabelTags(label) {
1855
+ const brIx = label.indexOf('<br>');
1856
+ if (brIx < 0) {
1857
+ return { name: label, tags: [] };
1858
+ }
1859
+ const name = label.slice(0, brIx);
1860
+ const tagsStr = label.slice(brIx + '<br>'.length);
1861
+ const tags = tagsStr.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
1862
+ return { name, tags };
1863
+ }
1509
1864
  function fromMermaid(text) {
1510
1865
  const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
1511
1866
  let alphabets = [];
1512
1867
  let initialId = null;
1513
1868
  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;
1869
+ let currentFrameId = null;
1519
1870
  const ensureNode = (id, opts = {}) => {
1520
1871
  if (!nodes[id]) {
1521
1872
  nodes[id] = {
@@ -1523,9 +1874,12 @@ function fromMermaid(text) {
1523
1874
  name: opts.name ?? mermaidIdFor(id),
1524
1875
  isHalt: opts.isHalt ?? false,
1525
1876
  isHaltMarker: opts.isHaltMarker ?? false,
1526
- isWrapped: opts.isWrapped ?? false,
1877
+ isWrapper: opts.isWrapper ?? false,
1878
+ bareStateId: opts.bareStateId ?? null,
1879
+ frameId: opts.frameId ?? null,
1527
1880
  transitions: [],
1528
1881
  overriddenHaltStateId: null,
1882
+ tags: opts.tags ? [...opts.tags] : [],
1529
1883
  };
1530
1884
  }
1531
1885
  else {
@@ -1535,72 +1889,133 @@ function fromMermaid(text) {
1535
1889
  nodes[id].isHalt = opts.isHalt;
1536
1890
  if (opts.isHaltMarker !== undefined)
1537
1891
  nodes[id].isHaltMarker = opts.isHaltMarker;
1538
- if (opts.isWrapped !== undefined)
1539
- nodes[id].isWrapped = opts.isWrapped;
1892
+ if (opts.isWrapper !== undefined)
1893
+ nodes[id].isWrapper = opts.isWrapper;
1894
+ if (opts.bareStateId !== undefined)
1895
+ nodes[id].bareStateId = opts.bareStateId;
1896
+ if (opts.frameId !== undefined)
1897
+ nodes[id].frameId = opts.frameId;
1898
+ if (opts.tags !== undefined) {
1899
+ for (const t of opts.tags) {
1900
+ if (!nodes[id].tags.includes(t))
1901
+ nodes[id].tags.push(t);
1902
+ }
1903
+ }
1540
1904
  }
1541
1905
  return nodes[id];
1542
1906
  };
1543
- // First pass: alphabets + nodes (track subgraph context to mark halt markers).
1907
+ // First pass: nodes + alphabets (track subgraph context for frameId).
1544
1908
  for (const line of lines) {
1545
- if (line === 'flowchart TD') {
1909
+ if (line === 'flowchart TD')
1546
1910
  continue;
1547
- }
1548
1911
  const am = line.match(alphabetsRegex);
1549
1912
  if (am) {
1550
1913
  alphabets = JSON.parse(am[1]);
1551
1914
  continue;
1552
1915
  }
1553
- if (subgraphStartRegex.test(line)) {
1554
- inSubgraph = true;
1916
+ // Tag annotations (#186) — classDef lines are decorative and skipped;
1917
+ // `class` lines are parsed in the edge pass since they reference nodes
1918
+ // by id and need those nodes already created in the first pass.
1919
+ if (classDefTagRegex.test(line))
1920
+ continue;
1921
+ const sgStart = line.match(subgraphStartRegex);
1922
+ if (sgStart) {
1923
+ currentFrameId = Number(sgStart[1]);
1555
1924
  continue;
1556
1925
  }
1557
1926
  if (subgraphEndRegex.test(line)) {
1558
- inSubgraph = false;
1927
+ currentFrameId = null;
1559
1928
  continue;
1560
1929
  }
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)) {
1930
+ if (idleNodeRegex.test(line))
1565
1931
  continue;
1566
- }
1567
1932
  const hm = line.match(haltNodeRegex);
1568
1933
  if (hm) {
1569
1934
  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
- }
1935
+ const isHaltMarker = currentFrameId !== null;
1936
+ ensureNode(id, {
1937
+ name: 'halt',
1938
+ isHalt: true,
1939
+ isHaltMarker,
1940
+ frameId: isHaltMarker ? currentFrameId : null,
1941
+ });
1575
1942
  continue;
1576
1943
  }
1577
1944
  const wm = line.match(wrappedNodeRegex);
1578
1945
  if (wm) {
1579
- ensureNode(parseMermaidId(wm[1]), { name: wm[2], isWrapped: true });
1946
+ const { name, tags } = splitLabelTags(wm[2]);
1947
+ ensureNode(parseMermaidId(wm[1]), {
1948
+ name,
1949
+ isWrapper: true,
1950
+ tags,
1951
+ });
1580
1952
  continue;
1581
1953
  }
1582
1954
  const rm = line.match(regularNodeRegex);
1583
1955
  if (rm) {
1584
- ensureNode(parseMermaidId(rm[1]), { name: rm[2] });
1956
+ const { name, tags } = splitLabelTags(rm[2]);
1957
+ ensureNode(parseMermaidId(rm[1]), {
1958
+ name,
1959
+ frameId: currentFrameId,
1960
+ tags,
1961
+ });
1585
1962
  continue;
1586
1963
  }
1587
1964
  }
1588
1965
  // Second pass: edges.
1589
1966
  for (const line of lines) {
1590
- // `idle -. enter .-> sN`: the sole source of initialId.
1591
1967
  const em = line.match(enterArrowRegex);
1592
1968
  if (em) {
1593
1969
  initialId = parseMermaidId(em[1]);
1594
1970
  continue;
1595
1971
  }
1596
- const om = line.match(onHaltRegex);
1597
- if (om) {
1598
- ensureNode(parseMermaidId(om[1])).overriddenHaltStateId = parseMermaidId(om[2]);
1972
+ // Return/halt arrows are derivable from frame structure at the next
1973
+ // toMermaid emit; consume but don't persist as graph data.
1974
+ if (returnArrowRegex.test(line) || haltArrowRegex.test(line)) {
1599
1975
  continue;
1600
1976
  }
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);
1977
+ // Tag class-assignment line (#186): `class s1,s5 tag_hot` adds
1978
+ // the tag to each listed node. Tag-name preserved as written
1979
+ // (sanitization on emit is lossy in principle; on parse we don't
1980
+ // un-sanitize, since the original could have any characters).
1981
+ const tagMatch = line.match(classAssignTagRegex);
1982
+ if (tagMatch) {
1983
+ const ids = tagMatch[1].split(',');
1984
+ const tagName = tagMatch[2];
1985
+ for (const idStr of ids) {
1986
+ ensureNode(parseMermaidId(idStr), { tags: [tagName] });
1987
+ }
1988
+ continue;
1989
+ }
1990
+ // `call` arrow — sets bareStateId on each source wrapper.
1991
+ const cm = line.match(callArrowRegex);
1992
+ if (cm) {
1993
+ const sources = cm[1].split(' & ');
1994
+ const bareId = parseMermaidId(cm[2]);
1995
+ for (const src of sources) {
1996
+ ensureNode(parseMermaidId(src), { isWrapper: true, bareStateId: bareId });
1997
+ }
1998
+ continue;
1999
+ }
2000
+ // Wrapper → override (unlabeled solid `-->`). Only fires if the source
2001
+ // node is a known wrapper (declared as `[[…]]`).
2002
+ const wo = line.match(wrapperOverrideRegex);
2003
+ if (wo) {
2004
+ const fromId = parseMermaidId(wo[1]);
2005
+ const toId = parseMermaidId(wo[2]);
2006
+ // The wrapper-override regex only matches `sN --> sM` (unlabeled);
2007
+ // since `toMermaid` only emits this shape from wrappers, the source
2008
+ // is guaranteed to be a wrapper if `fromMermaid`'s input came from
2009
+ // `toMermaid`. `nodes[fromId]` is always populated (first pass emits
2010
+ // node declarations before any edge parsing).
2011
+ if (nodes[fromId].isWrapper) {
2012
+ nodes[fromId].overriddenHaltStateId = toId;
2013
+ continue;
2014
+ }
2015
+ // Fall through — unlabeled solid from a non-wrapper is unexpected;
2016
+ // treated as a malformed line and ignored by the labeled-regex below.
2017
+ }
2018
+ const tm = line.match(labeledTransitionRegex);
1604
2019
  if (tm) {
1605
2020
  const fromId = parseMermaidId(tm[1]);
1606
2021
  const label = tm[2];
@@ -1609,31 +2024,13 @@ function fromMermaid(text) {
1609
2024
  if (arrowIx === -1) {
1610
2025
  throw new Error(`fromMermaid: malformed edge label: "${label}"`);
1611
2026
  }
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
2027
  const readLabel = label.slice(0, arrowIx);
1620
2028
  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
2029
  const stripBrackets = (s) => {
1631
2030
  if (!s.startsWith('[') || !s.endsWith(']')) {
1632
2031
  throw new Error(`fromMermaid: malformed bracketed list: "${s}"`);
1633
2032
  }
1634
2033
  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
2034
  let i = 0;
1638
2035
  while (i < inner.length) {
1639
2036
  if (inner[i] === '\\' && i + 1 < inner.length) {
@@ -1648,10 +2045,6 @@ function fromMermaid(text) {
1648
2045
  }
1649
2046
  return inner;
1650
2047
  };
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
2048
  const blockMatches = readLabel.match(/\[[^\]]*\]/g);
1656
2049
  if (!blockMatches || blockMatches.length === 0) {
1657
2050
  throw new Error(`fromMermaid: no bracketed read-list in label: "${label}"`);