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