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