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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -349,7 +349,7 @@ var __classPrivateFieldGet$2 = (undefined && undefined.__classPrivateFieldGet) |
349
349
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
350
350
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
351
351
  };
352
- var _a, _TapeBlock_symbolToPatternListMap, _TapeBlock_lock, _TapeBlock_tapes, _TapeBlock_generateSymbolHint, _TapeBlock_buildPatternList, _TapeBlock_getSymbolForPatternList, _TapeBlock_symbol;
352
+ var _a$1, _TapeBlock_symbolToPatternListMap, _TapeBlock_lock, _TapeBlock_tapes, _TapeBlock_generateSymbolHint, _TapeBlock_buildPatternList, _TapeBlock_getSymbolForPatternList, _TapeBlock_symbol;
353
353
  const symbolToPatternListMapSymbol = Symbol('symbol for symbolToPatternListMap setter');
354
354
  const lockSymbol = Symbol('capture symbol');
355
355
  class TapeBlock {
@@ -388,7 +388,7 @@ class TapeBlock {
388
388
  symbol = storedPatternListSymbol;
389
389
  }
390
390
  else {
391
- symbol = Symbol(__classPrivateFieldGet$2(_a, _a, "f", _TapeBlock_generateSymbolHint).call(_a, patternList));
391
+ symbol = Symbol(__classPrivateFieldGet$2(_a$1, _a$1, "f", _TapeBlock_generateSymbolHint).call(_a$1, patternList));
392
392
  __classPrivateFieldGet$2(this, _TapeBlock_symbolToPatternListMap, "f").set(symbol, patternList);
393
393
  }
394
394
  return symbol;
@@ -488,10 +488,10 @@ class TapeBlock {
488
488
  clone(cloneTapes = false) {
489
489
  let tapeBlock;
490
490
  if (cloneTapes) {
491
- tapeBlock = _a.fromTapes(this.tapes.map((tape) => new Tape(tape)));
491
+ tapeBlock = _a$1.fromTapes(this.tapes.map((tape) => new Tape(tape)));
492
492
  }
493
493
  else {
494
- tapeBlock = _a.fromAlphabets(this.alphabets);
494
+ tapeBlock = _a$1.fromAlphabets(this.alphabets);
495
495
  }
496
496
  tapeBlock[symbolToPatternListMapSymbol] = __classPrivateFieldGet$2(this, _TapeBlock_symbolToPatternListMap, "f");
497
497
  return tapeBlock;
@@ -520,12 +520,12 @@ class TapeBlock {
520
520
  }
521
521
  }
522
522
  }
523
- _a = TapeBlock;
523
+ _a$1 = TapeBlock;
524
524
  TapeBlock.fromAlphabets = (alphabets) => {
525
- return new _a({ alphabets });
525
+ return new _a$1({ alphabets });
526
526
  };
527
527
  TapeBlock.fromTapes = (tapes) => {
528
- return new _a({ tapes });
528
+ return new _a$1({ tapes });
529
529
  };
530
530
  _TapeBlock_generateSymbolHint = { value: (patternList) => JSON.stringify(patternList
531
531
  .map((pattern) => pattern
@@ -537,30 +537,40 @@ const movementDescriptionToLabel = {
537
537
  'do not move carer': 'S',
538
538
  };
539
539
  const symbolCommandDescriptionToLabel = {
540
- 'keep symbol command': '·',
541
- 'erase symbol command': '',
540
+ 'keep symbol command': 'K',
541
+ 'erase symbol command': 'E',
542
542
  };
543
543
  // Reserved characters in the encoded pattern string:
544
- // '*' per-cell ifOtherSymbol (matches any symbol on that tape)
545
- // '-' the tape's blank symbol
544
+ // '*' ASCII asterisk (U+002A) — per-cell ifOtherSymbol, matches any symbol
545
+ // on that tape. ASCII (not a fancier glyph like U+1F7B0) so it renders
546
+ // in every Mermaid environment and every monospace font. A literal `*`
547
+ // in the alphabet is unambiguous from the marker because it's quoted
548
+ // (`'*'`).
549
+ // 'B' the tape's blank symbol shorthand (in read patterns). A literal `B`
550
+ // in the alphabet is unambiguous from the marker because it's quoted
551
+ // (`'B'`).
546
552
  // ',' separates per-tape cells inside one pattern
547
553
  // '|' separates alternative patterns
548
- // '\\' escape prefix to represent any of '*', '-', ',', '|', or '\\' as a
549
- // *literal* alphabet symbol, prefix it with '\\' (e.g. '\\*' for literal '*').
554
+ // "'" surrounds a literal alphabet symbol e.g. `'0'` for literal `0`,
555
+ // `'X'` for literal `X`. The quoting is what visually separates literal
556
+ // symbols from the convention markers `*` / `B` and from the write
557
+ // commands `K` / `E`.
558
+ // '\\' escape prefix — to represent any of '*', 'B', ',', '|', "'", or '\\'
559
+ // as a *literal* alphabet symbol *inside* the quotes (e.g. `'\''` for
560
+ // a literal apostrophe).
561
+ const IF_OTHER_MARKER = '*';
562
+ const BLANK_MARKER = 'B';
550
563
  function escapeAlphabetSymbol(s) {
551
564
  return s
552
565
  .replace(/\\/g, '\\\\')
553
- .replace(/\*/g, '\\*')
554
- .replace(/-/g, '\\-')
555
- .replace(/,/g, '\\,')
556
- .replace(/\|/g, '\\|');
566
+ .replace(/'/g, "\\'");
557
567
  }
558
568
  function decodePatternDescription(description, alphabets) {
559
569
  if (!description) {
560
570
  return '?';
561
571
  }
562
572
  if (description === 'other symbol') {
563
- return '*';
573
+ return IF_OTHER_MARKER;
564
574
  }
565
575
  try {
566
576
  const patternList = JSON.parse(description);
@@ -568,12 +578,12 @@ function decodePatternDescription(description, alphabets) {
568
578
  .map((pattern) => pattern
569
579
  .map((s, tapeIx) => {
570
580
  if (s === null) {
571
- return '*';
581
+ return IF_OTHER_MARKER;
572
582
  }
573
583
  if (s === alphabets[tapeIx]?.[0]) {
574
- return '-';
584
+ return BLANK_MARKER;
575
585
  }
576
- return escapeAlphabetSymbol(s);
586
+ return `'${escapeAlphabetSymbol(s)}'`;
577
587
  })
578
588
  .join(','))
579
589
  .join('|');
@@ -611,19 +621,24 @@ function splitUnescaped(s, sep) {
611
621
  return parts;
612
622
  }
613
623
  function parsePatternString(s, alphabets) {
614
- if (s === '*') {
624
+ if (s === IF_OTHER_MARKER) {
615
625
  return null;
616
626
  }
617
627
  const alternatives = splitUnescaped(s, '|');
618
628
  return alternatives.map((alt) => {
619
629
  const cells = splitUnescaped(alt, ',');
620
630
  return cells.map((cell, tapeIx) => {
621
- if (cell === '*') {
631
+ if (cell === IF_OTHER_MARKER) {
622
632
  return null;
623
633
  }
624
- if (cell === '-') {
634
+ if (cell === BLANK_MARKER) {
625
635
  return alphabets[tapeIx]?.[0] ?? cell;
626
636
  }
637
+ // Literal alphabet symbols are wrapped in single quotes by
638
+ // `decodePatternDescription` — strip them on the way back.
639
+ if (cell.length >= 2 && cell.startsWith("'") && cell.endsWith("'")) {
640
+ return cell.slice(1, -1);
641
+ }
627
642
  return cell;
628
643
  });
629
644
  });
@@ -641,12 +656,17 @@ function parseMovementLabel(label) {
641
656
  return m;
642
657
  }
643
658
  function parseWriteSymbolLabel(label) {
644
- if (label === '·') {
659
+ if (label === 'K') {
645
660
  return symbolCommands.keep;
646
661
  }
647
- if (label === '') {
662
+ if (label === 'E') {
648
663
  return symbolCommands.erase;
649
664
  }
665
+ // Literal alphabet symbols are wrapped in single quotes by
666
+ // `decodeWriteSymbol` — strip them on the way back.
667
+ if (label.length >= 2 && label.startsWith("'") && label.endsWith("'")) {
668
+ return label.slice(1, -1);
669
+ }
650
670
  return label;
651
671
  }
652
672
  function decodeWriteSymbol(symbol) {
@@ -654,7 +674,7 @@ function decodeWriteSymbol(symbol) {
654
674
  const description = symbol.description ?? '?';
655
675
  return symbolCommandDescriptionToLabel[description] ?? description;
656
676
  }
657
- return symbol;
677
+ return `'${symbol}'`;
658
678
  }
659
679
  // Format converters (toMermaid / fromMermaid) live in ./graphFormats.
660
680
 
@@ -669,7 +689,7 @@ var __classPrivateFieldGet$1 = (undefined && undefined.__classPrivateFieldGet) |
669
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");
670
690
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
671
691
  };
672
- var _DebugConfig_ownerState, _DebugConfig_before, _DebugConfig_after, _State_id, _State_name, _State_overrodeHaltState, _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;
673
693
  const ifOtherSymbol = Symbol('other symbol');
674
694
  // Module-private symbol used by DebugConfig setters to call State's validator
675
695
  // without exposing the validator on the public surface.
@@ -714,10 +734,18 @@ _DebugConfig_ownerState = new WeakMap(), _DebugConfig_before = new WeakMap(), _D
714
734
  class State {
715
735
  constructor(stateDefinition = null, name) {
716
736
  _State_id.set(this, id(this));
737
+ // Not `readonly` because `withOverriddenHaltState` and `fromGraph` set the
738
+ // composed name on a no-arg `new State()` to bypass the constructor's
739
+ // user-facing name validation (composite names contain `(` and `)`).
717
740
  _State_name.set(this, void 0);
718
- _State_overrodeHaltState.set(this, null);
741
+ _State_overriddenHaltState.set(this, null);
742
+ // For wrapper states (produced by `withOverriddenHaltState`), points at the
743
+ // State whose transition map was wrapped. `null` on bare/atomic states.
744
+ // Used by `toGraph` to collapse the wrapper-and-its-bare pair into a single
745
+ // "wrapped bare" graph node — see the v7 emit redesign for #138.
746
+ _State_bareState.set(this, null);
719
747
  _State_symbolToDataMap.set(this, new Map());
720
- // Shared mutable cell — withOverrodeHaltState wrappers reference the same
748
+ // Shared mutable cell — withOverriddenHaltState wrappers reference the same
721
749
  // object so that `state.debug = ...` (and nullings) propagate across them.
722
750
  // Note: toGraph / fromGraph deliberately do not serialize debug — debug is
723
751
  // a runtime concern, not part of the structural graph.
@@ -734,7 +762,7 @@ class State {
734
762
  symbols.forEach((symbol) => {
735
763
  const { nextState } = stateDefinition[symbol];
736
764
  const nextStateLocal = nextState ?? this;
737
- if (!(nextStateLocal instanceof State) && !(nextStateLocal instanceof Reference)) {
765
+ if (!(nextStateLocal instanceof _a) && !(nextStateLocal instanceof Reference)) {
738
766
  throw new Error('invalid nextState');
739
767
  }
740
768
  let { command } = stateDefinition[symbol];
@@ -763,6 +791,9 @@ class State {
763
791
  });
764
792
  });
765
793
  }
794
+ if (name !== undefined && /[()]/.test(name)) {
795
+ throw new Error(`invalid state name "${name}": must not contain '(' or ')' (reserved as wrapper-composition delimiters in withOverriddenHaltState)`);
796
+ }
766
797
  __classPrivateFieldSet$1(this, _State_name, name ?? `id:${__classPrivateFieldGet$1(this, _State_id, "f")}`, "f");
767
798
  }
768
799
  get id() {
@@ -774,8 +805,8 @@ class State {
774
805
  get isHalt() {
775
806
  return __classPrivateFieldGet$1(this, _State_id, "f") === 0;
776
807
  }
777
- get overrodeHaltState() {
778
- return __classPrivateFieldGet$1(this, _State_overrodeHaltState, "f");
808
+ get overriddenHaltState() {
809
+ return __classPrivateFieldGet$1(this, _State_overriddenHaltState, "f");
779
810
  }
780
811
  get ref() {
781
812
  return this;
@@ -803,7 +834,7 @@ class State {
803
834
  __classPrivateFieldGet$1(this, _State_debugRef, "f").current = new DebugConfig(this, value);
804
835
  }
805
836
  /** @internal — invoked by DebugConfig setters via module-private symbol. */
806
- [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overrodeHaltState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), validateDebugFilter)](fieldName, filter) {
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) {
807
838
  if (filter === undefined)
808
839
  return;
809
840
  // #108 part 2: `.after` on haltState has no semantic anchor — halt is
@@ -850,11 +881,41 @@ class State {
850
881
  }
851
882
  throw new Error(`No nextState for symbol at state named ${__classPrivateFieldGet$1(this, _State_id, "f")}`);
852
883
  }
853
- withOverrodeHaltState(overrodeHaltState) {
854
- const state = new State(null, `${this.name}>${overrodeHaltState.name}`);
855
- __classPrivateFieldSet$1(state, _State_symbolToDataMap, __classPrivateFieldGet$1(this, _State_symbolToDataMap, "f"), "f");
856
- __classPrivateFieldSet$1(state, _State_overrodeHaltState, overrodeHaltState, "f");
857
- __classPrivateFieldSet$1(state, _State_debugRef, __classPrivateFieldGet$1(this, _State_debugRef, "f"), "f");
884
+ withOverriddenHaltState(overriddenHaltState) {
885
+ // Unwrap `this` if it's itself a wrapper — the chain's inner overrides
886
+ // are dead at runtime anyway (only the outermost `.wohs()`'s override is
887
+ // pushed onto the halt-stack on entry; verified empirically). Composite
888
+ // name reflects runtime behavior, not construction history. See #176.
889
+ const bare = __classPrivateFieldGet$1(this, _State_bareState, "f") ?? this;
890
+ // Memoize by (bare, override) so identical args return the same instance
891
+ // (#175). The cache uses WeakMaps + WeakRefs so cached wrappers can be
892
+ // GC'd when nothing else holds them. Compounds with the chain-collapse
893
+ // above: `A.wohs(t1).wohs(t2)` keys as (A, t2) after the unwrap, hitting
894
+ // the same cache slot as a direct `A.wohs(t2)`.
895
+ let innerCache = __classPrivateFieldGet$1(_a, _a, "f", _State_wrapperCache).get(bare);
896
+ if (innerCache !== undefined) {
897
+ const ref = innerCache.get(overriddenHaltState);
898
+ if (ref !== undefined) {
899
+ const cached = ref.deref();
900
+ if (cached !== undefined) {
901
+ return cached;
902
+ }
903
+ }
904
+ }
905
+ else {
906
+ innerCache = new WeakMap();
907
+ __classPrivateFieldGet$1(_a, _a, "f", _State_wrapperCache).set(bare, innerCache);
908
+ }
909
+ // Cache miss — construct with no name, then overwrite #name directly
910
+ // (composed names contain `(` and `)` which the constructor's user-facing
911
+ // validation would reject; private-field access bypasses that).
912
+ const state = new _a();
913
+ __classPrivateFieldSet$1(state, _State_name, `${bare.name}(${overriddenHaltState.name})`, "f");
914
+ __classPrivateFieldSet$1(state, _State_symbolToDataMap, __classPrivateFieldGet$1(bare, _State_symbolToDataMap, "f"), "f");
915
+ __classPrivateFieldSet$1(state, _State_overriddenHaltState, overriddenHaltState, "f");
916
+ __classPrivateFieldSet$1(state, _State_debugRef, __classPrivateFieldGet$1(bare, _State_debugRef, "f"), "f");
917
+ __classPrivateFieldSet$1(state, _State_bareState, bare, "f");
918
+ innerCache.set(overriddenHaltState, new WeakRef(state));
858
919
  return state;
859
920
  }
860
921
  // Single-state introspection — no traversal, no tapeBlock required.
@@ -867,7 +928,7 @@ class State {
867
928
  for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
868
929
  let target = null;
869
930
  try {
870
- target = nextState instanceof State ? nextState : nextState.ref;
931
+ target = nextState instanceof _a ? nextState : nextState.ref;
871
932
  }
872
933
  catch {
873
934
  target = null; // unbound Reference
@@ -885,38 +946,104 @@ class State {
885
946
  id: __classPrivateFieldGet$1(state, _State_id, "f"),
886
947
  name: __classPrivateFieldGet$1(state, _State_name, "f"),
887
948
  isHalt: state.isHalt,
888
- overrodeHaltState: __classPrivateFieldGet$1(state, _State_overrodeHaltState, "f")
889
- ? { id: __classPrivateFieldGet$1(state, _State_overrodeHaltState, "f").id, name: __classPrivateFieldGet$1(state, _State_overrodeHaltState, "f").name }
949
+ overriddenHaltState: __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f")
950
+ ? { id: __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f").id, name: __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f").name }
890
951
  : null,
891
952
  transitions,
892
953
  };
893
954
  }
955
+ // Walks the State graph and emits a `Graph` data structure. v7 callable-
956
+ // subtree emit shape (#174):
957
+ //
958
+ // Each `withOverriddenHaltState` wrapper produces TWO graph nodes:
959
+ // - A wrapper node (`isWrapper: true`, `[[composite-name]]` shape) — the
960
+ // call site. No transitions of its own. `bareStateId` points to the
961
+ // bare's GraphNode; `overriddenHaltStateId` points to the override
962
+ // target's GraphNode.
963
+ // - A bare node (`isWrapper: false`, regular shape) — the callable body.
964
+ // Has the bare's transitions. Shared across all wrappers that wrap
965
+ // this bare (no per-context duplication).
966
+ //
967
+ // Frames are computed via union-find on bare reachability: two bares whose
968
+ // forward-reachable sets overlap merge into one frame. Each frame contains
969
+ // its bares + body states + a single halt marker (id = `-frameId`). The
970
+ // canonical `frameId` is the smallest bare-id in the component.
971
+ //
972
+ // Halt-bound transitions of any in-frame state are retargeted to the
973
+ // frame's halt marker. The frame's `subtree -. return .-> wrapper` and
974
+ // `subtree -. halt .-> s0` arrows are demand-emitted by `toMermaid` from
975
+ // the frame structure; they're not stored as graph edges.
894
976
  static toGraph(initialState, tapeBlock) {
895
977
  const nodes = {};
896
- const queue = [initialState];
897
978
  const alphabets = tapeBlock.alphabets.map((alphabet) => alphabet.symbols);
979
+ // Pass 1: BFS-discover all reachable States; emit one GraphNode per State
980
+ // (wrapper or bare/regular). Wrappers and bares are separate nodes.
981
+ const visited = new Set();
982
+ const queue = [initialState];
983
+ const bareIds = new Set(); // ids referenced as a wrapper's bareStateId
898
984
  while (queue.length > 0) {
899
- const current = queue.shift();
900
- if (__classPrivateFieldGet$1(current, _State_id, "f") in nodes) {
985
+ const state = queue.shift();
986
+ if (visited.has(__classPrivateFieldGet$1(state, _State_id, "f"))) {
987
+ continue;
988
+ }
989
+ visited.add(__classPrivateFieldGet$1(state, _State_id, "f"));
990
+ if (state.isHalt) {
991
+ if (!(0 in nodes)) {
992
+ nodes[0] = {
993
+ id: 0,
994
+ name: __classPrivateFieldGet$1(state, _State_name, "f"),
995
+ isHalt: true,
996
+ isHaltMarker: false,
997
+ isWrapper: false,
998
+ bareStateId: null,
999
+ frameId: null,
1000
+ transitions: [],
1001
+ overriddenHaltStateId: null,
1002
+ };
1003
+ }
1004
+ continue;
1005
+ }
1006
+ // Wrapper? Emit wrapper node + queue bare and override target.
1007
+ if (__classPrivateFieldGet$1(state, _State_overriddenHaltState, "f") !== null && __classPrivateFieldGet$1(state, _State_bareState, "f") !== null) {
1008
+ const bareState = __classPrivateFieldGet$1(state, _State_bareState, "f");
1009
+ const overrideTarget = __classPrivateFieldGet$1(state, _State_overriddenHaltState, "f");
1010
+ nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = {
1011
+ id: __classPrivateFieldGet$1(state, _State_id, "f"),
1012
+ name: __classPrivateFieldGet$1(state, _State_name, "f"), // composite name like "A(target)"
1013
+ isHalt: false,
1014
+ isHaltMarker: false,
1015
+ isWrapper: true,
1016
+ bareStateId: __classPrivateFieldGet$1(bareState, _State_id, "f"),
1017
+ frameId: null,
1018
+ transitions: [],
1019
+ overriddenHaltStateId: __classPrivateFieldGet$1(overrideTarget, _State_id, "f"),
1020
+ };
1021
+ bareIds.add(__classPrivateFieldGet$1(bareState, _State_id, "f"));
1022
+ queue.push(bareState);
1023
+ queue.push(overrideTarget);
901
1024
  continue;
902
1025
  }
1026
+ // Regular (or bare) state — build node with transitions.
903
1027
  const node = {
904
- id: __classPrivateFieldGet$1(current, _State_id, "f"),
905
- name: __classPrivateFieldGet$1(current, _State_name, "f"),
906
- isHalt: current.isHalt,
1028
+ id: __classPrivateFieldGet$1(state, _State_id, "f"),
1029
+ name: __classPrivateFieldGet$1(state, _State_name, "f"),
1030
+ isHalt: false,
1031
+ isHaltMarker: false,
1032
+ isWrapper: false,
1033
+ bareStateId: null,
1034
+ frameId: null,
907
1035
  transitions: [],
908
- overrodeHaltStateId: __classPrivateFieldGet$1(current, _State_overrodeHaltState, "f")?.id ?? null,
1036
+ overriddenHaltStateId: null,
909
1037
  };
910
- nodes[__classPrivateFieldGet$1(current, _State_id, "f")] = node;
911
- if (__classPrivateFieldGet$1(current, _State_overrodeHaltState, "f")) {
912
- queue.push(__classPrivateFieldGet$1(current, _State_overrodeHaltState, "f"));
913
- }
914
- for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(current, _State_symbolToDataMap, "f")) {
1038
+ nodes[__classPrivateFieldGet$1(state, _State_id, "f")] = node;
1039
+ let patternIx = 0;
1040
+ for (const [sym, { command, nextState }] of __classPrivateFieldGet$1(state, _State_symbolToDataMap, "f")) {
915
1041
  let target;
916
1042
  try {
917
- target = nextState instanceof State ? nextState : nextState.ref;
1043
+ target = nextState instanceof _a ? nextState : nextState.ref;
918
1044
  }
919
1045
  catch {
1046
+ patternIx += 1;
920
1047
  continue;
921
1048
  }
922
1049
  node.transitions.push({
@@ -925,26 +1052,173 @@ class State {
925
1052
  symbol: decodeWriteSymbol(tc.symbol),
926
1053
  movement: decodeMovement(tc.movement.description),
927
1054
  })),
928
- nextStateId: target.id,
1055
+ nextStateId: __classPrivateFieldGet$1(target, _State_id, "f"),
1056
+ id: `${__classPrivateFieldGet$1(state, _State_id, "f")}-${patternIx}`,
929
1057
  });
930
1058
  queue.push(target);
1059
+ patternIx += 1;
931
1060
  }
932
1061
  }
1062
+ // Always emit real halt as a sentinel, even if no transition targets it.
1063
+ // It anchors the `subtree -. halt .-> s0` frame-level arrow whenever a
1064
+ // frame demand-emits one, and it's the canonical machine-halt singleton.
1065
+ if (!(0 in nodes)) {
1066
+ nodes[0] = {
1067
+ id: 0,
1068
+ name: 'halt',
1069
+ isHalt: true,
1070
+ isHaltMarker: false,
1071
+ isWrapper: false,
1072
+ bareStateId: null,
1073
+ frameId: null,
1074
+ transitions: [],
1075
+ overriddenHaltStateId: null,
1076
+ };
1077
+ }
1078
+ // Pass 2: For each bare, compute its forward-reachable set (following
1079
+ // transitions; stopping at halt and at wrappers — both are frame
1080
+ // boundaries).
1081
+ const computeReach = (startId) => {
1082
+ const reach = new Set();
1083
+ const stack = [startId];
1084
+ while (stack.length > 0) {
1085
+ const id = stack.pop();
1086
+ if (reach.has(id)) {
1087
+ continue;
1088
+ }
1089
+ const node = nodes[id];
1090
+ if (!node || node.isHalt || node.isWrapper) {
1091
+ continue;
1092
+ }
1093
+ reach.add(id);
1094
+ for (const t of node.transitions) {
1095
+ const target = nodes[t.nextStateId];
1096
+ if (!target || target.isHalt || target.isWrapper) {
1097
+ continue;
1098
+ }
1099
+ stack.push(t.nextStateId);
1100
+ }
1101
+ }
1102
+ return reach;
1103
+ };
1104
+ const reachByBare = new Map();
1105
+ for (const bareId of bareIds) {
1106
+ reachByBare.set(bareId, computeReach(bareId));
1107
+ }
1108
+ // Pass 3: Union-find on bare overlaps. Two bares merge if their reach
1109
+ // sets share any state. Canonical representative = smallest bare-id in
1110
+ // the component.
1111
+ const ufParent = new Map();
1112
+ const ufFind = (id) => {
1113
+ if (!ufParent.has(id)) {
1114
+ ufParent.set(id, id);
1115
+ }
1116
+ let root = id;
1117
+ while (ufParent.get(root) !== root) {
1118
+ root = ufParent.get(root);
1119
+ }
1120
+ // Path compression
1121
+ let cur = id;
1122
+ while (ufParent.get(cur) !== root) {
1123
+ const next = ufParent.get(cur);
1124
+ ufParent.set(cur, root);
1125
+ cur = next;
1126
+ }
1127
+ return root;
1128
+ };
1129
+ const ufUnion = (a, b) => {
1130
+ const ra = ufFind(a);
1131
+ const rb = ufFind(b);
1132
+ if (ra === rb)
1133
+ return;
1134
+ if (ra < rb) {
1135
+ ufParent.set(rb, ra);
1136
+ }
1137
+ else {
1138
+ ufParent.set(ra, rb);
1139
+ }
1140
+ };
1141
+ for (const bareId of bareIds) {
1142
+ ufFind(bareId);
1143
+ }
1144
+ // For each state, collect the bares that reach it; union all bares that
1145
+ // share a state.
1146
+ const stateToReachingBares = new Map();
1147
+ for (const [bareId, reachSet] of reachByBare) {
1148
+ for (const stateId of reachSet) {
1149
+ let bares = stateToReachingBares.get(stateId);
1150
+ if (!bares) {
1151
+ bares = [];
1152
+ stateToReachingBares.set(stateId, bares);
1153
+ }
1154
+ bares.push(bareId);
1155
+ }
1156
+ }
1157
+ for (const bares of stateToReachingBares.values()) {
1158
+ for (let i = 1; i < bares.length; i += 1) {
1159
+ ufUnion(bares[0], bares[i]);
1160
+ }
1161
+ }
1162
+ // Assign frameId to each in-reach state.
1163
+ const frameIds = new Set();
1164
+ for (const [stateId, bares] of stateToReachingBares) {
1165
+ const frameId = ufFind(bares[0]);
1166
+ nodes[stateId].frameId = frameId;
1167
+ frameIds.add(frameId);
1168
+ }
1169
+ // Pass 4: Retarget halt-bound transitions for in-frame states to the
1170
+ // frame's halt marker. Out-of-frame states (top-level dispatcher, override
1171
+ // targets, etc.) keep their halt-bound transitions pointing at real halt.
1172
+ for (const node of Object.values(nodes)) {
1173
+ if (node.frameId === null) {
1174
+ continue;
1175
+ }
1176
+ const haltMarkerId = -node.frameId;
1177
+ for (const t of node.transitions) {
1178
+ const target = nodes[t.nextStateId];
1179
+ if (target && target.isHalt && !target.isHaltMarker) {
1180
+ t.nextStateId = haltMarkerId;
1181
+ }
1182
+ }
1183
+ }
1184
+ // Pass 5: Emit one halt marker per frame.
1185
+ for (const frameId of frameIds) {
1186
+ const haltMarkerId = -frameId;
1187
+ nodes[haltMarkerId] = {
1188
+ id: haltMarkerId,
1189
+ name: 'halt',
1190
+ isHalt: true,
1191
+ isHaltMarker: true,
1192
+ isWrapper: false,
1193
+ bareStateId: null,
1194
+ frameId,
1195
+ transitions: [],
1196
+ overriddenHaltStateId: null,
1197
+ };
1198
+ }
933
1199
  return { initialId: __classPrivateFieldGet$1(initialState, _State_id, "f"), alphabets, nodes };
934
1200
  }
935
1201
  // Inverse of toGraph: rebuilds a State graph (and a fresh TapeBlock with the
936
1202
  // graph's alphabets) from a serialized Graph. Round-trips with toGraph in
937
1203
  // the sense that running the rebuilt machine on the same input gives the
938
1204
  // same output, but the rebuilt State instances have *new* internal IDs.
1205
+ //
1206
+ // Under the v7 callable-subtree model (#174), graph nodes split into:
1207
+ // - Wrapper nodes (`isWrapper: true`, no transitions) — reconstructed via
1208
+ // `bareStates[bareStateId].withOverriddenHaltState(finalStates[overriddenHaltStateId])`.
1209
+ // - Bare/regular nodes — constructed as normal States with transitions.
1210
+ // - Halt + halt-marker nodes — collapse to the singleton `haltState`.
939
1211
  static fromGraph(graph) {
940
1212
  const alphabetObjs = graph.alphabets.map((syms) => new Alphabet(syms));
941
1213
  const tapeBlock = TapeBlock.fromAlphabets(alphabetObjs);
942
1214
  const ids = Object.keys(graph.nodes).map(Number);
943
- // Pass 1: pre-create a Reference for each non-halt node so transitions can
944
- // forward-declare their targets.
1215
+ // Pass 1: pre-create a Reference for each non-halt non-halt-marker node
1216
+ // (both wrappers and regulars). Halt and halt-marker nodes collapse to the
1217
+ // singleton `haltState` and need no ref.
945
1218
  const refs = {};
946
1219
  for (const nodeId of ids) {
947
- if (!graph.nodes[nodeId].isHalt) {
1220
+ const node = graph.nodes[nodeId];
1221
+ if (!node.isHalt) {
948
1222
  refs[nodeId] = new Reference();
949
1223
  }
950
1224
  }
@@ -961,19 +1235,22 @@ class State {
961
1235
  }
962
1236
  return tapeBlock.symbol(flat);
963
1237
  };
964
- // Pass 2: build a "bare" State for each non-halt node (no override yet).
965
- // nextState entries point at refs so cycles work; haltState is used directly.
1238
+ // Pass 2: build a State for each non-wrapper non-halt non-halt-marker
1239
+ // node. Transitions point at refs so cycles work; haltState (and halt
1240
+ // markers, which collapse to haltState) are used directly.
966
1241
  const bareStates = {};
967
1242
  for (const nodeId of ids) {
968
1243
  const node = graph.nodes[nodeId];
969
- if (node.isHalt) {
1244
+ if (node.isHalt || node.isWrapper) {
970
1245
  continue;
971
1246
  }
972
1247
  const stateDefinition = {};
973
1248
  for (const t of node.transitions) {
974
1249
  const key = patternToKey(parsePatternString(t.pattern, graph.alphabets));
975
1250
  const target = graph.nodes[t.nextStateId];
976
- const nextState = target.isHalt ? haltState : refs[t.nextStateId];
1251
+ const nextState = !target || target.isHalt
1252
+ ? haltState
1253
+ : refs[t.nextStateId];
977
1254
  stateDefinition[key] = {
978
1255
  command: t.command.map((c) => ({
979
1256
  symbol: parseWriteSymbolLabel(c.symbol),
@@ -982,9 +1259,17 @@ class State {
982
1259
  nextState,
983
1260
  };
984
1261
  }
985
- bareStates[nodeId] = new State(stateDefinition, node.name);
986
- }
987
- // Pass 3: apply overrideHaltStates transitively.
1262
+ // Graph-sourced names may contain `(` and `)` (composite wrapper names —
1263
+ // although wrappers go through a separate path below, defensive
1264
+ // construction here keeps the bypass uniform). Construct without a name
1265
+ // and assign `#name` directly to skip user-facing name validation.
1266
+ const bare = new _a(stateDefinition);
1267
+ __classPrivateFieldSet$1(bare, _State_name, node.name, "f");
1268
+ bareStates[nodeId] = bare;
1269
+ }
1270
+ // Pass 3: resolve every node to its final State (memoized + cycle-safe).
1271
+ // Wrappers compose lazily via `withOverriddenHaltState` once their bare
1272
+ // and override are resolved.
988
1273
  const finalStates = {};
989
1274
  const inProgress = new Set();
990
1275
  const getFinal = (nodeId) => {
@@ -992,7 +1277,7 @@ class State {
992
1277
  return finalStates[nodeId];
993
1278
  }
994
1279
  const node = graph.nodes[nodeId];
995
- if (node.isHalt) {
1280
+ if (!node || node.isHalt) {
996
1281
  finalStates[nodeId] = haltState;
997
1282
  return haltState;
998
1283
  }
@@ -1000,9 +1285,14 @@ class State {
1000
1285
  throw new Error(`override-halt cycle at state #${nodeId}`);
1001
1286
  }
1002
1287
  inProgress.add(nodeId);
1003
- let state = bareStates[nodeId];
1004
- if (node.overrodeHaltStateId !== null) {
1005
- state = bareStates[nodeId].withOverrodeHaltState(getFinal(node.overrodeHaltStateId));
1288
+ let state;
1289
+ if (node.isWrapper) {
1290
+ const bare = getFinal(node.bareStateId);
1291
+ const override = getFinal(node.overriddenHaltStateId);
1292
+ state = bare.withOverriddenHaltState(override);
1293
+ }
1294
+ else {
1295
+ state = bareStates[nodeId];
1006
1296
  }
1007
1297
  inProgress.delete(nodeId);
1008
1298
  finalStates[nodeId] = state;
@@ -1011,8 +1301,8 @@ class State {
1011
1301
  for (const nodeId of ids) {
1012
1302
  getFinal(nodeId);
1013
1303
  }
1014
- // Pass 4: bind each ref to the FINAL (possibly wrapped) state so transitions
1015
- // resolve to the version that has its override-halt set.
1304
+ // Pass 4: bind each ref to the resolved final State so cross-node
1305
+ // transitions land on the right instance.
1016
1306
  for (const nodeId of ids) {
1017
1307
  if (!graph.nodes[nodeId].isHalt) {
1018
1308
  refs[nodeId].bind(finalStates[nodeId]);
@@ -1025,6 +1315,13 @@ class State {
1025
1315
  };
1026
1316
  }
1027
1317
  }
1318
+ _a = State;
1319
+ // Memoization cache for `withOverriddenHaltState`. Keyed by
1320
+ // (bare, override) — same args return the same wrapper instance (#175).
1321
+ // Two-level WeakMap so the outer entry is GC'd when the bare is collected;
1322
+ // WeakRef values let wrappers themselves be GC'd when nothing else holds
1323
+ // them, with cache misses simply reconstructing fresh wrappers.
1324
+ _State_wrapperCache = { value: new WeakMap() };
1028
1325
  const haltState = new State(null);
1029
1326
 
1030
1327
  var __classPrivateFieldSet = (undefined && undefined.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
@@ -1087,8 +1384,8 @@ class TuringMachine {
1087
1384
  __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f")[lockSymbol].lock(executionSymbol);
1088
1385
  const stack = __classPrivateFieldGet(this, _TuringMachine_stack, "f");
1089
1386
  let state = initialState;
1090
- if (state.overrodeHaltState) {
1091
- stack.push(state.overrodeHaltState);
1387
+ if (state.overriddenHaltState) {
1388
+ stack.push(state.overriddenHaltState);
1092
1389
  }
1093
1390
  let i = 0;
1094
1391
  while (!state.isHalt) {
@@ -1142,8 +1439,8 @@ class TuringMachine {
1142
1439
  if (nextState.isHalt && stack.length) {
1143
1440
  nextState = stack.pop();
1144
1441
  }
1145
- if (state !== nextState && nextState.overrodeHaltState) {
1146
- stack.push(nextState.overrodeHaltState);
1442
+ if (state !== nextState && nextState.overriddenHaltState) {
1443
+ stack.push(nextState.overriddenHaltState);
1147
1444
  }
1148
1445
  state = nextState;
1149
1446
  }
@@ -1167,36 +1464,215 @@ _TuringMachine_tapeBlock = new WeakMap(), _TuringMachine_stack = new WeakMap();
1167
1464
  //
1168
1465
  // Currently only Mermaid flowchart syntax is supported. Future formats
1169
1466
  // (Graphviz, JSON-LD, custom DSL) belong here too.
1467
+ //
1468
+ // v7 callable-subtree emit (#174):
1469
+ // - Each `withOverriddenHaltState` wrapper produces TWO graph nodes — a
1470
+ // wrapper node (`[[composite-name]]`, OUTSIDE any subgraph) and a bare
1471
+ // node (regular shape, INSIDE its callable subtree subgraph).
1472
+ // - Subgraphs (one per frame): `subgraph w_${frameId}["callable subtree
1473
+ // of NAME"]` (single bare) or `["callable scope: A ∪ B"]` (union).
1474
+ // - Each frame has exactly one halt marker `c${frameId}(((halt)))` inside
1475
+ // its subgraph; halt-bound transitions from in-frame states retarget to
1476
+ // it. Always emitted (orphan signals dead wrapper).
1477
+ // - Arrow conventions:
1478
+ // solid `-->` regular transitions, including wrapper-to-override.
1479
+ // bold `==>` RESERVED for the wrapper-to-bare `call` arrow.
1480
+ // `&` ribbon collapses multi-wrapper-shares-bare.
1481
+ // dotted `-.->` frame-level dispatch (`return`, `halt`, `enter`).
1482
+ // - The `return` arrow (subgraph → wrapper) is demand-emitted iff the
1483
+ // frame's halt marker has at least one incoming edge AND the wrapper
1484
+ // calls into the frame. The `halt` arrow (subgraph → s0) is emitted
1485
+ // iff the halt marker has incoming AND there's at least one non-wrapper
1486
+ // entry into the frame (cross-subgraph solid arrow from outside).
1487
+ // Maps a graph node id to its Mermaid id.
1488
+ // - non-negative id N → "sN"
1489
+ // - negative id -N (halt marker) → "cN"
1490
+ function mermaidIdFor(id) {
1491
+ return id < 0 ? `c${-id}` : `s${id}`;
1492
+ }
1493
+ // Inverse of mermaidIdFor.
1494
+ function parseMermaidId(s) {
1495
+ if (s.startsWith('c')) {
1496
+ return -Number(s.slice(1));
1497
+ }
1498
+ return Number(s.slice(1));
1499
+ }
1500
+ function frameSubgraphId(frameId) {
1501
+ return `w_${frameId}`;
1502
+ }
1170
1503
  function toMermaid(graph) {
1171
1504
  const lines = [
1172
1505
  'flowchart TD',
1173
1506
  `%% alphabets: ${JSON.stringify(graph.alphabets)}`,
1174
1507
  ];
1175
- for (const node of Object.values(graph.nodes)) {
1176
- const id = `s${node.id}`;
1177
- if (node.isHalt) {
1178
- lines.push(` ${id}(((halt)))`);
1508
+ // Sort nodes by id ascending — real halt (0) first, then regulars by their
1509
+ // ids, then halt markers (negative) at the end. Deterministic emit lets
1510
+ // toMermaid → fromMermaid → toMermaid round-trip stably (#139).
1511
+ const nodes = Object.values(graph.nodes).slice().sort((a, b) => a.id - b.id);
1512
+ // Bucket nodes for emit order.
1513
+ const topLevelNodes = nodes.filter((n) => n.frameId === null && !n.isWrapper);
1514
+ const wrapperNodes = nodes.filter((n) => n.isWrapper);
1515
+ // Bares-and-bodies inside frames, grouped by frameId.
1516
+ const nodesByFrame = new Map();
1517
+ // Halt-marker per frame (kept separate so it always emits LAST inside the
1518
+ // subgraph for deterministic shape).
1519
+ const haltMarkerByFrame = new Map();
1520
+ for (const node of nodes) {
1521
+ if (node.frameId === null || node.isWrapper)
1522
+ continue;
1523
+ if (node.isHaltMarker) {
1524
+ haltMarkerByFrame.set(node.frameId, node);
1179
1525
  }
1180
- else if (node.id === graph.initialId) {
1181
- lines.push(` ${id}(("${node.name}"))`);
1526
+ else {
1527
+ let bucket = nodesByFrame.get(node.frameId);
1528
+ if (!bucket) {
1529
+ bucket = [];
1530
+ nodesByFrame.set(node.frameId, bucket);
1531
+ }
1532
+ bucket.push(node);
1533
+ }
1534
+ }
1535
+ // 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame).
1536
+ for (const node of topLevelNodes) {
1537
+ const mid = mermaidIdFor(node.id);
1538
+ if (node.isHalt) {
1539
+ lines.push(` ${mid}(((halt)))`);
1182
1540
  }
1183
1541
  else {
1184
- lines.push(` ${id}["${node.name}"]`);
1542
+ lines.push(` ${mid}["${node.name}"]`);
1543
+ }
1544
+ }
1545
+ // 2. Emit wrappers at top level.
1546
+ for (const wrapper of wrapperNodes) {
1547
+ lines.push(` ${mermaidIdFor(wrapper.id)}[["${wrapper.name}"]]`);
1548
+ }
1549
+ // 3. `idle` sentinel.
1550
+ lines.push(' idle([idle])');
1551
+ // 4. Subgraph per frame.
1552
+ const frameIds = [...nodesByFrame.keys()].sort((a, b) => a - b);
1553
+ for (const frameId of frameIds) {
1554
+ const frameBares = (nodesByFrame.get(frameId) ?? []).filter((n) => isFrameBare(n, graph));
1555
+ const frameBareNames = frameBares
1556
+ .slice()
1557
+ .sort((a, b) => a.id - b.id)
1558
+ .map((n) => n.name);
1559
+ const label = frameBareNames.length > 1
1560
+ ? `callable scope: ${frameBareNames.join(' ∪ ')}`
1561
+ : `callable subtree of ${frameBareNames[0] ?? frameId}`;
1562
+ lines.push(` subgraph ${frameSubgraphId(frameId)}["${label}"]`);
1563
+ // Inner nodes — sort by id for determinism.
1564
+ for (const node of (nodesByFrame.get(frameId) ?? []).slice().sort((a, b) => a.id - b.id)) {
1565
+ lines.push(` ${mermaidIdFor(node.id)}["${node.name}"]`);
1566
+ }
1567
+ const haltMarker = haltMarkerByFrame.get(frameId);
1568
+ if (haltMarker) {
1569
+ lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`);
1570
+ }
1571
+ lines.push(' end');
1572
+ }
1573
+ // 5. Enter arrow.
1574
+ lines.push(` idle -. enter .-> ${mermaidIdFor(graph.initialId)}`);
1575
+ // 6. `call` arrows — grouped by bare (multi-wrapper-shares-bare collapses
1576
+ // into a single `&` ribbon).
1577
+ const wrappersByBare = new Map();
1578
+ for (const wrapper of wrapperNodes) {
1579
+ if (wrapper.bareStateId === null)
1580
+ continue;
1581
+ let group = wrappersByBare.get(wrapper.bareStateId);
1582
+ if (!group) {
1583
+ group = [];
1584
+ wrappersByBare.set(wrapper.bareStateId, group);
1585
+ }
1586
+ group.push(wrapper);
1587
+ }
1588
+ const sortedBares = [...wrappersByBare.keys()].sort((a, b) => a - b);
1589
+ for (const bareId of sortedBares) {
1590
+ const wrappers = wrappersByBare.get(bareId).slice().sort((a, b) => a.id - b.id);
1591
+ const sources = wrappers.map((w) => mermaidIdFor(w.id)).join(' & ');
1592
+ lines.push(` ${sources} == "call" ==> ${mermaidIdFor(bareId)}`);
1593
+ }
1594
+ // 7. Demand-emit `return` and `halt` arrows per frame.
1595
+ // For each frame: check if its halt marker has incoming transitions.
1596
+ const haltMarkerHasIncoming = new Map();
1597
+ for (const node of nodes) {
1598
+ for (const t of node.transitions) {
1599
+ const target = graph.nodes[t.nextStateId];
1600
+ if (target && target.isHaltMarker && target.frameId !== null) {
1601
+ haltMarkerHasIncoming.set(target.frameId, true);
1602
+ }
1185
1603
  }
1186
1604
  }
1187
- for (const node of Object.values(graph.nodes)) {
1605
+ // For each frame: check if there's at least one non-wrapper entry (a solid
1606
+ // `-->` from OUTSIDE the frame into any node INSIDE).
1607
+ const hasNonWrapperEntry = new Map();
1608
+ for (const node of nodes) {
1609
+ if (node.isWrapper)
1610
+ continue;
1188
1611
  for (const t of node.transitions) {
1189
- // Per-tape commands separated with ',' to mirror the pattern syntax.
1190
- const cmd = t.command.map((c) => `${c.symbol}/${c.movement}`).join(',');
1191
- const label = `${t.pattern} ${cmd}`;
1192
- lines.push(` s${node.id} -- "${label}" --> s${t.nextStateId}`);
1612
+ const target = graph.nodes[t.nextStateId];
1613
+ if (target
1614
+ && target.frameId !== null
1615
+ && node.frameId !== target.frameId) {
1616
+ hasNonWrapperEntry.set(target.frameId, true);
1617
+ }
1618
+ }
1619
+ }
1620
+ for (const frameId of frameIds) {
1621
+ if (!haltMarkerHasIncoming.get(frameId))
1622
+ continue;
1623
+ // Return arrow — collapsed `&` ribbon over all wrappers calling this frame.
1624
+ const callingWrappers = wrapperNodes.filter((w) => {
1625
+ if (w.bareStateId === null)
1626
+ return false;
1627
+ const bare = graph.nodes[w.bareStateId];
1628
+ return !!bare && bare.frameId === frameId;
1629
+ });
1630
+ if (callingWrappers.length > 0) {
1631
+ const targets = callingWrappers
1632
+ .slice()
1633
+ .sort((a, b) => a.id - b.id)
1634
+ .map((w) => mermaidIdFor(w.id))
1635
+ .join(' & ');
1636
+ lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`);
1193
1637
  }
1194
- if (node.overrodeHaltStateId !== null) {
1195
- lines.push(` s${node.id} -. onHalt .-> s${node.overrodeHaltStateId}`);
1638
+ if (hasNonWrapperEntry.get(frameId)) {
1639
+ lines.push(` ${frameSubgraphId(frameId)} -. "halt" .-> s0`);
1640
+ }
1641
+ }
1642
+ // 8. Wrapper-to-override arrows (regular solid).
1643
+ for (const wrapper of wrapperNodes) {
1644
+ if (wrapper.overriddenHaltStateId === null)
1645
+ continue;
1646
+ lines.push(` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId)}`);
1647
+ }
1648
+ // 9. Regular transitions for non-wrapper non-halt-marker non-halt nodes.
1649
+ for (const node of nodes) {
1650
+ if (node.isHalt || node.isHaltMarker || node.isWrapper)
1651
+ continue;
1652
+ for (const t of node.transitions) {
1653
+ const alternatives = t.pattern.split('|');
1654
+ const reads = alternatives.map((alt) => `[${alt}]`).join('|');
1655
+ const writes = `[${t.command.map((c) => c.symbol).join(',')}]`;
1656
+ const moves = `[${t.command.map((c) => c.movement).join(',')}]`;
1657
+ const label = `${reads} → ${writes}/${moves}`;
1658
+ lines.push(` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`);
1196
1659
  }
1197
1660
  }
1198
1661
  return lines.join('\n');
1199
1662
  }
1663
+ // Helper: identify "the bare states" that anchor a frame's name. A bare is a
1664
+ // node referenced as some wrapper's `bareStateId`. Body states (also in-frame
1665
+ // but not bare) are excluded from the frame label.
1666
+ function isFrameBare(node, graph) {
1667
+ if (node.isWrapper || node.isHalt)
1668
+ return false;
1669
+ for (const other of Object.values(graph.nodes)) {
1670
+ if (other.isWrapper && other.bareStateId === node.id) {
1671
+ return true;
1672
+ }
1673
+ }
1674
+ return false;
1675
+ }
1200
1676
  // Inverse of toMermaid: parses the Mermaid output produced by toMermaid back
1201
1677
  // into a Graph. The parser is strict to the dialect toMermaid emits — it
1202
1678
  // recognises the specific node/edge shapes and the leading
@@ -1208,11 +1684,26 @@ function toMermaid(graph) {
1208
1684
  // per-tape segments are split on ','. If your alphabet contains '/' or ','
1209
1685
  // as literal symbols, the parser cannot disambiguate. Stick to alphabets
1210
1686
  // without those characters when round-tripping through Mermaid.
1211
- const haltNodeRegex = /^s(\d+)\(\(\(halt\)\)\)$/;
1212
- const initialNodeRegex = /^s(\d+)\(\("([^"]*)"\)\)$/;
1213
- const regularNodeRegex = /^s(\d+)\["([^"]*)"\]$/;
1214
- const transitionRegex = /^s(\d+)\s+--\s+"(.*)"\s+-->\s+s(\d+)$/;
1215
- const onHaltRegex = /^s(\d+)\s+-\.\s+onHalt\s+\.->\s+s(\d+)$/;
1687
+ const haltNodeRegex = /^([sc]\d+)\(\(\(halt\)\)\)$/;
1688
+ const regularNodeRegex = /^(s\d+)\["([^"]*)"\]$/;
1689
+ const wrappedNodeRegex = /^(s\d+)\[\["([^"]*)"\]\]$/;
1690
+ const subgraphStartRegex = /^subgraph\s+w_(\d+)\["([^"]*)"\]$/;
1691
+ const subgraphEndRegex = /^end$/;
1692
+ const idleNodeRegex = /^idle\(\[idle\]\)$/;
1693
+ const enterArrowRegex = /^idle\s+-\.\s+enter\s+\.->\s+(s\d+)$/;
1694
+ // Regular labeled transition (solid `-->`).
1695
+ const labeledTransitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/;
1696
+ // Wrapper → override (unlabeled solid `-->`).
1697
+ const wrapperOverrideRegex = /^(s\d+)\s+-->\s+([sc]\d+)$/;
1698
+ // Call arrow (bold `==>`), with optional `&`-joined source ribbon.
1699
+ // Ribbon separator is fixed at " & " (single spaces around &) — toMermaid
1700
+ // emits exactly that form, so the parser is strict to it. The literal-space
1701
+ // form avoids CodeQL's polynomial-ReDoS flag on a `\s+&\s+` shape.
1702
+ const callArrowRegex = /^(s\d+(?: & s\d+)*)\s+==\s+"call"\s+==>\s+(s\d+)$/;
1703
+ // Return arrow (`w_N -. return .-> s_W` with optional `&` target ribbon).
1704
+ const returnArrowRegex = /^w_(\d+)\s+-\.\s+"return"\s+\.->\s+(s\d+(?: & s\d+)*)$/;
1705
+ // Halt arrow (`w_N -. halt .-> s0`).
1706
+ const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/;
1216
1707
  // First capture char anchored as \S to avoid polynomial backtracking between
1217
1708
  // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos.
1218
1709
  const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/;
@@ -1221,99 +1712,197 @@ function fromMermaid(text) {
1221
1712
  let alphabets = [];
1222
1713
  let initialId = null;
1223
1714
  const nodes = {};
1715
+ let currentFrameId = null;
1224
1716
  const ensureNode = (id, opts = {}) => {
1225
1717
  if (!nodes[id]) {
1226
1718
  nodes[id] = {
1227
1719
  id,
1228
- name: opts.name ?? `s${id}`,
1720
+ name: opts.name ?? mermaidIdFor(id),
1229
1721
  isHalt: opts.isHalt ?? false,
1722
+ isHaltMarker: opts.isHaltMarker ?? false,
1723
+ isWrapper: opts.isWrapper ?? false,
1724
+ bareStateId: opts.bareStateId ?? null,
1725
+ frameId: opts.frameId ?? null,
1230
1726
  transitions: [],
1231
- overrodeHaltStateId: null,
1727
+ overriddenHaltStateId: null,
1232
1728
  };
1233
1729
  }
1234
1730
  else {
1235
- if (opts.name !== undefined) {
1731
+ if (opts.name !== undefined)
1236
1732
  nodes[id].name = opts.name;
1237
- }
1238
- if (opts.isHalt !== undefined) {
1733
+ if (opts.isHalt !== undefined)
1239
1734
  nodes[id].isHalt = opts.isHalt;
1240
- }
1735
+ if (opts.isHaltMarker !== undefined)
1736
+ nodes[id].isHaltMarker = opts.isHaltMarker;
1737
+ if (opts.isWrapper !== undefined)
1738
+ nodes[id].isWrapper = opts.isWrapper;
1739
+ if (opts.bareStateId !== undefined)
1740
+ nodes[id].bareStateId = opts.bareStateId;
1741
+ if (opts.frameId !== undefined)
1742
+ nodes[id].frameId = opts.frameId;
1241
1743
  }
1242
1744
  return nodes[id];
1243
1745
  };
1244
- // First pass: alphabets + nodes.
1746
+ // First pass: nodes + alphabets (track subgraph context for frameId).
1245
1747
  for (const line of lines) {
1246
- if (line === 'flowchart TD') {
1748
+ if (line === 'flowchart TD')
1247
1749
  continue;
1248
- }
1249
1750
  const am = line.match(alphabetsRegex);
1250
1751
  if (am) {
1251
1752
  alphabets = JSON.parse(am[1]);
1252
1753
  continue;
1253
1754
  }
1755
+ const sgStart = line.match(subgraphStartRegex);
1756
+ if (sgStart) {
1757
+ currentFrameId = Number(sgStart[1]);
1758
+ continue;
1759
+ }
1760
+ if (subgraphEndRegex.test(line)) {
1761
+ currentFrameId = null;
1762
+ continue;
1763
+ }
1764
+ if (idleNodeRegex.test(line))
1765
+ continue;
1254
1766
  const hm = line.match(haltNodeRegex);
1255
1767
  if (hm) {
1256
- ensureNode(Number(hm[1]), { name: 'halt', isHalt: true });
1768
+ const id = parseMermaidId(hm[1]);
1769
+ const isHaltMarker = currentFrameId !== null;
1770
+ ensureNode(id, {
1771
+ name: 'halt',
1772
+ isHalt: true,
1773
+ isHaltMarker,
1774
+ frameId: isHaltMarker ? currentFrameId : null,
1775
+ });
1257
1776
  continue;
1258
1777
  }
1259
- const im = line.match(initialNodeRegex);
1260
- if (im) {
1261
- const id = Number(im[1]);
1262
- initialId = id;
1263
- ensureNode(id, { name: im[2] });
1778
+ const wm = line.match(wrappedNodeRegex);
1779
+ if (wm) {
1780
+ ensureNode(parseMermaidId(wm[1]), {
1781
+ name: wm[2],
1782
+ isWrapper: true,
1783
+ });
1264
1784
  continue;
1265
1785
  }
1266
1786
  const rm = line.match(regularNodeRegex);
1267
1787
  if (rm) {
1268
- ensureNode(Number(rm[1]), { name: rm[2] });
1788
+ ensureNode(parseMermaidId(rm[1]), {
1789
+ name: rm[2],
1790
+ frameId: currentFrameId,
1791
+ });
1269
1792
  continue;
1270
1793
  }
1271
1794
  }
1272
1795
  // Second pass: edges.
1273
1796
  for (const line of lines) {
1274
- const om = line.match(onHaltRegex);
1275
- if (om) {
1276
- ensureNode(Number(om[1])).overrodeHaltStateId = Number(om[2]);
1797
+ const em = line.match(enterArrowRegex);
1798
+ if (em) {
1799
+ initialId = parseMermaidId(em[1]);
1800
+ continue;
1801
+ }
1802
+ // Return/halt arrows are derivable from frame structure at the next
1803
+ // toMermaid emit; consume but don't persist as graph data.
1804
+ if (returnArrowRegex.test(line) || haltArrowRegex.test(line)) {
1277
1805
  continue;
1278
1806
  }
1279
- const tm = line.match(transitionRegex);
1807
+ // `call` arrow — sets bareStateId on each source wrapper.
1808
+ const cm = line.match(callArrowRegex);
1809
+ if (cm) {
1810
+ const sources = cm[1].split(' & ');
1811
+ const bareId = parseMermaidId(cm[2]);
1812
+ for (const src of sources) {
1813
+ ensureNode(parseMermaidId(src), { isWrapper: true, bareStateId: bareId });
1814
+ }
1815
+ continue;
1816
+ }
1817
+ // Wrapper → override (unlabeled solid `-->`). Only fires if the source
1818
+ // node is a known wrapper (declared as `[[…]]`).
1819
+ const wo = line.match(wrapperOverrideRegex);
1820
+ if (wo) {
1821
+ const fromId = parseMermaidId(wo[1]);
1822
+ const toId = parseMermaidId(wo[2]);
1823
+ if (nodes[fromId] && nodes[fromId].isWrapper) {
1824
+ nodes[fromId].overriddenHaltStateId = toId;
1825
+ continue;
1826
+ }
1827
+ // Fall through — unlabeled solid from a non-wrapper is unexpected;
1828
+ // treated as a malformed line and ignored by the labeled-regex below.
1829
+ }
1830
+ const tm = line.match(labeledTransitionRegex);
1280
1831
  if (tm) {
1281
- const fromId = Number(tm[1]);
1832
+ const fromId = parseMermaidId(tm[1]);
1282
1833
  const label = tm[2];
1283
- const toId = Number(tm[3]);
1834
+ const toId = parseMermaidId(tm[3]);
1284
1835
  const arrowIx = label.indexOf(' → ');
1285
1836
  if (arrowIx === -1) {
1286
1837
  throw new Error(`fromMermaid: malformed edge label: "${label}"`);
1287
1838
  }
1288
- const pattern = label.slice(0, arrowIx);
1289
- const commandStr = label.slice(arrowIx + ' → '.length);
1290
- const command = commandStr.split(',').map((part) => {
1291
- const slashIx = part.lastIndexOf('/');
1292
- if (slashIx === -1) {
1293
- throw new Error(`fromMermaid: malformed command part: "${part}"`);
1839
+ const readLabel = label.slice(0, arrowIx);
1840
+ const cmdLabel = label.slice(arrowIx + ' → '.length);
1841
+ const stripBrackets = (s) => {
1842
+ if (!s.startsWith('[') || !s.endsWith(']')) {
1843
+ throw new Error(`fromMermaid: malformed bracketed list: "${s}"`);
1294
1844
  }
1295
- return {
1296
- symbol: part.slice(0, slashIx),
1297
- movement: part.slice(slashIx + 1),
1298
- };
1845
+ const inner = s.slice(1, -1);
1846
+ let i = 0;
1847
+ while (i < inner.length) {
1848
+ if (inner[i] === '\\' && i + 1 < inner.length) {
1849
+ i += 2;
1850
+ continue;
1851
+ }
1852
+ if (inner[i] === '|') {
1853
+ throw new Error(`fromMermaid: compact in-bracket alternation "${s}" is not supported — `
1854
+ + 'each alternative must be its own bracketed pattern (e.g. "[\'^\']|[\'1\']").');
1855
+ }
1856
+ i += 1;
1857
+ }
1858
+ return inner;
1859
+ };
1860
+ const blockMatches = readLabel.match(/\[[^\]]*\]/g);
1861
+ if (!blockMatches || blockMatches.length === 0) {
1862
+ throw new Error(`fromMermaid: no bracketed read-list in label: "${label}"`);
1863
+ }
1864
+ const pattern = blockMatches.map(stripBrackets).join('|');
1865
+ const slashIx = cmdLabel.indexOf(']/[');
1866
+ if (slashIx === -1) {
1867
+ throw new Error(`fromMermaid: malformed command label (expected \`[…]/[…]\`): "${cmdLabel}"`);
1868
+ }
1869
+ const writesPart = stripBrackets(cmdLabel.slice(0, slashIx + 1));
1870
+ const movesPart = stripBrackets(cmdLabel.slice(slashIx + 2));
1871
+ const writes = writesPart.split(',');
1872
+ const moves = movesPart.split(',');
1873
+ if (writes.length !== moves.length) {
1874
+ throw new Error(`fromMermaid: write-cells (${writes.length}) and move-cells (${moves.length}) mismatch: "${cmdLabel}"`);
1875
+ }
1876
+ const command = writes.map((symbol, i) => ({ symbol, movement: moves[i] }));
1877
+ const fromNode = ensureNode(fromId);
1878
+ const transitionIx = fromNode.transitions.length;
1879
+ fromNode.transitions.push({
1880
+ pattern,
1881
+ command,
1882
+ nextStateId: toId,
1883
+ id: `${fromId}-${transitionIx}`,
1299
1884
  });
1300
- ensureNode(fromId).transitions.push({ pattern, command, nextStateId: toId });
1301
1885
  }
1302
1886
  }
1303
1887
  if (initialId === null) {
1304
- throw new Error('fromMermaid: no initial state (double-paren node) found');
1888
+ throw new Error('fromMermaid: no `idle -. enter .-> sN` arrow found');
1305
1889
  }
1306
1890
  return { initialId, alphabets, nodes };
1307
1891
  }
1308
1892
 
1309
1893
  function summarizeGraph(graph) {
1310
1894
  const nodes = Object.values(graph.nodes);
1895
+ // `isHaltMarker` nodes are visualization sentinels — one per wrapper context,
1896
+ // all corresponding to the singleton `haltState` at runtime. They don't
1897
+ // count as distinct runtime states; matches the per-algorithm header in
1898
+ // `library-binary-numbers/states.md`.
1899
+ const runtimeStateCount = nodes.filter((n) => !n.isHaltMarker).length;
1311
1900
  let transitionCount = 0;
1312
1901
  let compositionEdgeCount = 0;
1313
1902
  let selfLoopCount = 0;
1314
1903
  for (const node of nodes) {
1315
1904
  transitionCount += node.transitions.length;
1316
- if (node.overrodeHaltStateId !== null) {
1905
+ if (node.overriddenHaltStateId !== null) {
1317
1906
  compositionEdgeCount += 1;
1318
1907
  }
1319
1908
  for (const t of node.transitions) {
@@ -1322,7 +1911,7 @@ function summarizeGraph(graph) {
1322
1911
  }
1323
1912
  }
1324
1913
  }
1325
- // Longest withOverrodeHaltState chain. Walks node → overrodeHaltState recursively;
1914
+ // Longest withOverriddenHaltState chain. Walks node → overriddenHaltState recursively;
1326
1915
  // a Set guards against cycles in the override graph (which throw at construction
1327
1916
  // time anyway, but being defensive costs little).
1328
1917
  const overrideDepthFrom = (id, visited) => {
@@ -1331,10 +1920,10 @@ function summarizeGraph(graph) {
1331
1920
  }
1332
1921
  visited.add(id);
1333
1922
  const node = graph.nodes[id];
1334
- if (!node || node.overrodeHaltStateId === null) {
1923
+ if (!node || node.overriddenHaltStateId === null) {
1335
1924
  return 0;
1336
1925
  }
1337
- return 1 + overrideDepthFrom(node.overrodeHaltStateId, visited);
1926
+ return 1 + overrideDepthFrom(node.overriddenHaltStateId, visited);
1338
1927
  };
1339
1928
  const maxCompositionDepth = nodes.reduce((max, node) => Math.max(max, overrideDepthFrom(node.id, new Set())), 0);
1340
1929
  // Cycle detection: tri-color DFS over the transition graph.
@@ -1377,7 +1966,7 @@ function summarizeGraph(graph) {
1377
1966
  visit(node.id);
1378
1967
  }
1379
1968
  return {
1380
- stateCount: nodes.length,
1969
+ stateCount: runtimeStateCount,
1381
1970
  transitionCount,
1382
1971
  compositionEdgeCount,
1383
1972
  maxCompositionDepth,