From 4707a5a2b59eefd61c90e873a28ae794a2bff64a Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 15:25:41 +0300 Subject: [PATCH 001/118] =?UTF-8?q?refactor!:=20rename=20`withOverrodeHalt?= =?UTF-8?q?State`=20=E2=86=92=20`withOverriddenHaltState`=20(closes=20#149?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grammar fix on a name introduced in 2019. `overridden` (past participle) fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. Renames in lockstep: - public method `State.prototype.withOverrodeHaltState` → `withOverriddenHaltState` - getter `state.overrodeHaltState` → `state.overriddenHaltState` - private field `#overrodeHaltState` → `#overriddenHaltState` - serialized `Graph` field `node.overrodeHaltStateId` → `node.overriddenHaltStateId` Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. CHANGELOG entry deferred to the v7 release PR per repo convention (entries are dated at release time, no Unreleased section). README "Versioning notes" section gets a v7 (in progress) entry now. Cross-repo follow-ups (post-machine-js, machines-demo) wait until engine v7 publishes to npm — their peer-dep ranges can't widen to `^7` until `^7` exists. --- CLAUDE.md | 8 ++--- packages/builder/README.md | 2 +- packages/library-binary-numbers/src/index.ts | 22 ++++++------ packages/machine/README.md | 26 +++++++------- .../machine/src/classes/State.debug.spec.ts | 12 +++---- packages/machine/src/classes/State.spec.ts | 26 +++++++------- packages/machine/src/classes/State.ts | 30 ++++++++-------- .../src/classes/TuringMachine.debug.spec.ts | 2 +- packages/machine/src/classes/TuringMachine.ts | 8 ++--- packages/machine/src/utilities/graph.spec.ts | 26 +++++++------- packages/machine/src/utilities/graph.ts | 2 +- .../machine/src/utilities/graphFormats.ts | 8 ++--- .../src/utilities/introspection.spec.ts | 36 +++++++++---------- .../machine/src/utilities/introspection.ts | 16 ++++----- test/examples.spec.ts | 14 ++++---- 15 files changed, 120 insertions(+), 118 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 464b256..8bf5a1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Key shapes that take reading multiple files to grasp: - **`State` is keyed by `symbol` (the JS primitive), not by string.** A state definition is `{ [tapeBlock.symbol([...])]: { command, nextState } }`. `tapeBlock.symbol(...)` interns a *pattern* over all tapes in the block and returns a unique JS `Symbol`. `State.getSymbol(tapeBlock)` then matches the current head against those interned patterns; if nothing matches, the special `ifOtherSymbol` key is used as a fallback. This is why everything goes through `tapeBlock.symbol(...)` — it's both a symbol factory and a multi-tape pattern compiler. See `TapeBlock.#getSymbolForPatternList` and `State.getSymbol`. -- **`State.withOverrodeHaltState(next)` is a composition primitive.** It returns a copy of the state whose `haltState` transition is replaced by a *continuation* pushed onto `TuringMachine`'s internal stack. When the machine would halt inside that subgraph, it instead pops and resumes. `library-binary-numbers/src/index.ts` uses this heavily (e.g. `minusOne` chains `invertNumber → plusOne → invertNumber → normalizeNumber`). When you see `.withOverrodeHaltState(...)`, read it as "subroutine call, then continue with the argument." +- **`State.withOverriddenHaltState(next)` is a composition primitive.** It returns a copy of the state whose `haltState` transition is replaced by a *continuation* pushed onto `TuringMachine`'s internal stack. When the machine would halt inside that subgraph, it instead pops and resumes. `library-binary-numbers/src/index.ts` uses this heavily (e.g. `minusOne` chains `invertNumber → plusOne → invertNumber → normalizeNumber`). When you see `.withOverriddenHaltState(...)`, read it as "subroutine call, then continue with the argument." - **`Reference`** is a forward-declaration handle. `new Reference()` then `.bind(state)` later — this lets you build cyclic state graphs where a state's `nextState` is itself or a not-yet-constructed peer. The `builder` package relies on this to wire arbitrary state-name graphs in a single declarative pass; user code rarely needs `Reference` directly. @@ -47,7 +47,7 @@ Key shapes that take reading multiple files to grasp: - **`TapeBlock` has a `Lock`** that `TuringMachine.run` grabs for the duration of a run, asserting the block isn't being mutated by another machine. Calls to `applyCommand` from outside a run must pass the matching capture symbol. -- **`state.debug` (v4+)** — runtime-mutable breakpoint cell with `{ before, after }` symbol filtering. Shared across `withOverrodeHaltState` wrappers via a private `Ref` so an assignment on the original is visible from every wrapper instance — useful when the same primitive is reused in composition chains. Pauses dispatch via the optional `onPause` hook on `run()` (awaited; without the hook, breaks fire-and-resume invisibly). `haltState.debug.before = true` pauses on every halt entry (program exit + subroutine pop). See `packages/machine/README.md` "Debugging breakpoints (v4+)" for the full API. +- **`state.debug` (v4+)** — runtime-mutable breakpoint cell with `{ before, after }` symbol filtering. Shared across `withOverriddenHaltState` wrappers via a private `Ref` so an assignment on the original is visible from every wrapper instance — useful when the same primitive is reused in composition chains. Pauses dispatch via the optional `onPause` hook on `run()` (awaited; without the hook, breaks fire-and-resume invisibly). `haltState.debug.before = true` pauses on every halt entry (program exit + subroutine pop). See `packages/machine/README.md` "Debugging breakpoints (v4+)" for the full API. Cross-version notes: - **v5**: hook renamed `onDebugBreak` → `onPause` (#110). `haltState.debug.after = true` (or `{ before, after }` together) now throws at write-time — halt is terminal, no iteration-after-halt to anchor on (#108 part 2). Halting iter's after-fire stopped being silently lost (#108 part 1). New `run({ debug: boolean })` master switch suppresses all `onPause` dispatches without editing `state.debug` assignments (#106). @@ -59,8 +59,8 @@ Key shapes that take reading multiple files to grasp: ### Visualization & round-trip -`packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same Graph through Mermaid flowchart syntax. The round-trip is **behaviorally** lossless (rebuilt graph runs to same outputs on same inputs — covered by `test/round-trip.spec.ts`); not bytewise lossless because state IDs auto-reassign, and for `withOverrodeHaltState` wrappers the composite name accumulates `>${override.name}` suffixes on each pass. Tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138) (cleaner emit for wrapped states) + [#139](https://github.com/mellonis/turing-machine-js/issues/139) (regression test that fails until #138 is fixed). +`packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same Graph through Mermaid flowchart syntax. The round-trip is **behaviorally** lossless (rebuilt graph runs to same outputs on same inputs — covered by `test/round-trip.spec.ts`); not bytewise lossless because state IDs auto-reassign, and for `withOverriddenHaltState` wrappers the composite name accumulates `>${override.name}` suffixes on each pass. Tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138) (cleaner emit for wrapped states) + [#139](https://github.com/mellonis/turing-machine-js/issues/139) (regression test that fails until #138 is fixed). ### Builder package -`buildMachine({ alphabetString, initialState, finalStateList, states })` (`packages/builder/src/index.ts`) takes a string-keyed state table where each transition is `{ symbol, movement: 'L'|'R'|'S', state }`. It constructs an `Alphabet` from `alphabetString.split('')`, makes a `Reference` per state name (so forward references work), then materializes every `State` and binds the references. Returns `[machine, initialState, statesByName]`. Use this when you have a textual/tabular spec; use the raw `machine` API when you need composition primitives (`withOverrodeHaltState`, custom symbol patterns, multi-tape). +`buildMachine({ alphabetString, initialState, finalStateList, states })` (`packages/builder/src/index.ts`) takes a string-keyed state table where each transition is `{ symbol, movement: 'L'|'R'|'S', state }`. It constructs an `Alphabet` from `alphabetString.split('')`, makes a `Reference` per state name (so forward references work), then materializes every `State` and binds the references. Returns `[machine, initialState, statesByName]`. Use this when you have a textual/tabular spec; use the raw `machine` API when you need composition primitives (`withOverriddenHaltState`, custom symbol patterns, multi-tape). diff --git a/packages/builder/README.md b/packages/builder/README.md index 9f3e9a5..f7a8415 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -43,7 +43,7 @@ The state-table format is intentionally minimal. It does **not** support: - **OR-patterns** (matching multiple current symbols with one transition row). For `tapeBlock.symbol('^10$')` style patterns, use the raw `@turing-machine-js/machine` API. - **Multi-tape machines** (`buildMachine` is single-tape only). -- **`withOverrodeHaltState` composition** (the subroutine-call mechanism). For composed machines like `library-binary-numbers`'s `minusOne`, use the raw API. +- **`withOverriddenHaltState` composition** (the subroutine-call mechanism). For composed machines like `library-binary-numbers`'s `minusOne`, use the raw API. If you need any of the above, the inline state-table example in [`@turing-machine-js/machine`'s README](../machine/README.md) shows how to write your own `buildMachine`-equivalent in ~30 lines, and you can extend it to fit your case. diff --git a/packages/library-binary-numbers/src/index.ts b/packages/library-binary-numbers/src/index.ts index 3762080..009713f 100644 --- a/packages/library-binary-numbers/src/index.ts +++ b/packages/library-binary-numbers/src/index.ts @@ -89,7 +89,7 @@ const goToNumbersStart = new State({ // // Composition: go to the number's '^', then sweep right erasing every cell // (digits, '^', '$') until the number is gone. Implemented as -// goToNumbersStart.withOverrodeHaltState(deleteNumberInternal): when +// goToNumbersStart.withOverriddenHaltState(deleteNumberInternal): when // goToNumbersStart would halt at '^', it falls through to the eraser instead. const deleteNumberInternal = new State({ [symbol('$')]: { @@ -108,7 +108,7 @@ const deleteNumberInternal = new State({ const deleteNumber = new State({ [symbol('^10$')]: { - nextState: goToNumbersStart.withOverrodeHaltState(deleteNumberInternal), + nextState: goToNumbersStart.withOverriddenHaltState(deleteNumberInternal), }, [ifOtherSymbol]: { nextState: haltState, @@ -118,7 +118,7 @@ const deleteNumber = new State({ // invertNumber — 5 nodes // // Composition: go to '^', then sweep right flipping each bit until '$'. -// Same shape as deleteNumber (goToNumbersStart.withOverrodeHaltState(...)) but +// Same shape as deleteNumber (goToNumbersStart.withOverriddenHaltState(...)) but // the inner state writes the complement instead of erasing. const invertNumberGoToNumberWithInversion = new State({ [symbol('^')]: { @@ -145,7 +145,7 @@ const invertNumberGoToNumberWithInversion = new State({ const invertNumber = new State({ [symbol('^10$')]: { - nextState: goToNumbersStart.withOverrodeHaltState(invertNumberGoToNumberWithInversion), + nextState: goToNumbersStart.withOverriddenHaltState(invertNumberGoToNumberWithInversion), }, [ifOtherSymbol]: { nextState: haltState, @@ -184,7 +184,7 @@ const normalizeNumberMoveNumberStart = new State({ const normalizeNumber = new State({ [symbol('^10$')]: { - nextState: goToNumbersStart.withOverrodeHaltState(normalizeNumberMoveNumberStart), + nextState: goToNumbersStart.withOverriddenHaltState(normalizeNumberMoveNumberStart), }, [ifOtherSymbol]: { nextState: haltState, @@ -272,7 +272,7 @@ const plusOne = new State({ // // Computes x − 1 via the two's-complement identity: x − 1 == ~(~x + 1) // (every step is a state we already have), composed with three nested -// withOverrodeHaltState calls to chain invert → plusOne → invert → normalize. +// withOverriddenHaltState calls to chain invert → plusOne → invert → normalize. // // This is *deliberately* the heavy version. It exists side-by-side with // minusOneFast (10 nodes, direct borrow) to make the cost of "compose existing @@ -286,11 +286,11 @@ const minusOne = new State({ }, [symbol('$')]: { nextState: invertNumber - .withOverrodeHaltState( + .withOverriddenHaltState( plusOne - .withOverrodeHaltState( + .withOverriddenHaltState( invertNumber - .withOverrodeHaltState(normalizeNumber), + .withOverriddenHaltState(normalizeNumber), ), ), }, @@ -307,7 +307,7 @@ const minusOne = new State({ // // Same algorithm as minusOne in @turing-machine-js/library-binary-numbers-bare // (which is 3 nodes there). The extra 7 nodes here are the cost of: scanning -// past '^' on entry, the goToNumberStart path and its withOverrodeHaltState +// past '^' on entry, the goToNumberStart path and its withOverriddenHaltState // wrapper for normalize, and normalizeNumber's own marker-relocation chain. const minusOneFastBorrow = new State({ [symbol('1')]: { @@ -337,7 +337,7 @@ const minusOneFast = new State({ command: { movement: movements.left, }, - nextState: minusOneFastBorrow.withOverrodeHaltState(normalizeNumber), + nextState: minusOneFastBorrow.withOverriddenHaltState(normalizeNumber), }, [ifOtherSymbol]: { nextState: haltState, diff --git a/packages/machine/README.md b/packages/machine/README.md index 4d17fa7..12498bd 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -3,7 +3,7 @@ [![build](https://github.com/mellonis/turing-machine-js/actions/workflows/main.yml/badge.svg)](https://github.com/mellonis/turing-machine-js/actions/workflows/main.yml) ![npm (tag)](https://img.shields.io/npm/v/@turing-machine-js/machine) -A composable Turing-machine engine for JavaScript: multi-tape, subroutine composition via `withOverrodeHaltState`, Mermaid round-trip, and runtime breakpoints. +A composable Turing-machine engine for JavaScript: multi-tape, subroutine composition via `withOverriddenHaltState`, Mermaid round-trip, and runtime breakpoints.
Table of contents @@ -12,7 +12,7 @@ A composable Turing-machine engine for JavaScript: multi-tape, subroutine compos - [Quick start](#quick-start) - [Building from a state table](#building-from-a-state-table) - [Classes](#classes) — [`Alphabet`](#alphabet) · [`Tape`](#tape) · [`TapeBlock`](#tapeblock) · [`TapeCommand`](#tapecommand) · [`Command`](#command) · [`State`](#state) · [`Reference`](#reference) · [`TuringMachine`](#turingmachine) -- [Subroutine composition with `withOverrodeHaltState`](#subroutine-composition-with-withoverrodehaltstate) +- [Subroutine composition with `withOverriddenHaltState`](#subroutine-composition-with-withoverriddenhaltstate) - [Debugging breakpoints](#debugging-breakpoints) - [Special objects](#special-objects) — [`haltState`](#haltstate) · [`ifOtherSymbol`](#ifothersymbol) · [`movements`](#movements) · [`symbolCommands`](#symbolcommands) - [Introspection and testing](#introspection-and-testing) @@ -251,7 +251,7 @@ const s = new State({ Notable members and statics: - **`state.id`**, **`state.name`** — identity (`isHalt` is `id === 0`). -- **`state.withOverrodeHaltState(other)`** — returns a copy whose would-be halt transitions fall through to `other`. The subroutine-call composition mechanism (see `library-binary-numbers/src/index.ts` for examples). +- **`state.withOverriddenHaltState(other)`** — returns a copy whose would-be halt transitions fall through to `other`. The subroutine-call composition mechanism (see `library-binary-numbers/src/index.ts` for examples). - **`State.toGraph(state, tapeBlock)`** — walks the reachable graph from `state` and returns a serializable `Graph` (states, transitions, alphabets). - **`State.fromGraph(graph)`** — inverse of `toGraph`: rebuilds `State` instances + a fresh `TapeBlock` from a `Graph`. Round-trips together with `toMermaid` / `fromMermaid`. @@ -279,7 +279,7 @@ flowchart TD > 💡 **Mermaid renders at most one edge per source/target pair.** If a state has two distinct transitions back to itself (or two parallel transitions to the same target), only one shows in the diagram. The string output is correct — this is a viewer-side limitation. For graphs with multiple parallel edges, paste the `toMermaid` output into [mermaid.live](https://mermaid.live) and switch to the `stateDiagram-v2` renderer, or post-process the output to your preferred format. -`fromMermaid` parses the same format back into a `Graph`. The round-trip is **behaviorally** lossless — the rebuilt graph runs to the same outputs on the same inputs (tested in `test/round-trip.spec.ts` for the binary-numbers libraries). It is *not* bytewise lossless: state IDs auto-reassign on each rebuild, and for `withOverrodeHaltState` wrappers the composite name gains a `>${override.name}` suffix on each pass (e.g., `scanToX>eraseHere` becomes `scanToX>eraseHere>eraseHere` on a second round-trip — tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138)). +`fromMermaid` parses the same format back into a `Graph`. The round-trip is **behaviorally** lossless — the rebuilt graph runs to the same outputs on the same inputs (tested in `test/round-trip.spec.ts` for the binary-numbers libraries). It is *not* bytewise lossless: state IDs auto-reassign on each rebuild, and for `withOverriddenHaltState` wrappers the composite name gains a `>${override.name}` suffix on each pass (e.g., `scanToX>eraseHere` becomes `scanToX>eraseHere>eraseHere` on a second round-trip — tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138)). ### Reference @@ -366,9 +366,9 @@ Both APIs are first-class — `run()` is built on top of `runStepByStep()` (see **Don't split one logical flow across both APIs.** A consumer that wants stepwise UI *and* hook-driven breakpoints should use `run({ onStep, onPause, debug })` exclusively. Routing some operations through `runStepByStep()` and others through `run()` means `state.debug` only flows through one of the two paths — a subtle footgun where breakpoints silently disappear on whichever code path uses the generator directly. For per-iter throttle / "wait between steps" UIs, see [Throttle pattern](#throttle-pattern). -## Subroutine composition with `withOverrodeHaltState` +## Subroutine composition with `withOverriddenHaltState` -`state.withOverrodeHaltState(other)` returns a copy of `state` whose would-be halt transitions fall through to `other` at run time. The original is left untouched. This is the engine's only composition primitive — bigger machines are built by stacking smaller halt-on-completion subroutines. +`state.withOverriddenHaltState(other)` returns a copy of `state` whose would-be halt transitions fall through to `other` at run time. The original is left untouched. This is the engine's only composition primitive — bigger machines are built by stacking smaller halt-on-completion subroutines. ```javascript import { Alphabet, State, TapeBlock, TuringMachine, Tape, haltState, ifOtherSymbol, movements, symbolCommands } from '@turing-machine-js/machine'; @@ -389,7 +389,7 @@ const eraseHere = new State({ }, 'eraseHere'); // Compose: scan to X, then ERASE it. scanToX is unmodified. -const scanThenErase = scanToX.withOverrodeHaltState(eraseHere); +const scanThenErase = scanToX.withOverriddenHaltState(eraseHere); const tape = new Tape({ alphabet, symbols: ['a', 'b', 'X', 'b', 'a'] }); tapeBlock.replaceTape(tape); @@ -409,7 +409,7 @@ flowchart LR a1 -- "X → keep, S" --> h1 a1 -- "any other → keep, R" --> a1 end - subgraph composed["scanToX.withOverrodeHaltState(eraseHere) — halt is intercepted"] + subgraph composed["scanToX.withOverriddenHaltState(eraseHere) — halt is intercepted"] direction LR a2(("scanToX")) b2(("eraseHere")) @@ -453,16 +453,16 @@ flowchart TD **Reading guide** — the wrapped diagram is denser than the simplified hand-drawn version above. To parse it: -1. **Three non-halt nodes:** the wrapper `scanToX>eraseHere` (round, the initial state); `scanToX` (square, the original subroutine — unmodified); `eraseHere` (square, the override target). The wrapper appears as a *separate* state from `scanToX`-the-original because `withOverrodeHaltState` returns a new `State` instance — even though it shares the transition map. +1. **Three non-halt nodes:** the wrapper `scanToX>eraseHere` (round, the initial state); `scanToX` (square, the original subroutine — unmodified); `eraseHere` (square, the override target). The wrapper appears as a *separate* state from `scanToX`-the-original because `withOverriddenHaltState` returns a new `State` instance — even though it shares the transition map. 2. **Solid edges from the wrapper duplicate `scanToX`'s edges.** That's because the wrapper inherits the same `symbolToDataMap`. Importantly, the wrapper's `* → ·/R` edge points at *`scanToX`-the-original*, not at the wrapper itself — so after the first iteration, control transfers to `scanToX` and stays there. -3. **The dotted `onHalt` edge is attached to the wrapper** but it doesn't fire on a single edge at runtime. Instead, the runtime pushes `eraseHere` onto an internal stack at startup, and *any* halt-bound transition reachable during the run (whether on the wrapper itself or on `scanToX`) gets redirected to `eraseHere` via stack-pop. The dotted edge is the engine's static fingerprint of "this graph was wrapped by `withOverrodeHaltState`." +3. **The dotted `onHalt` edge is attached to the wrapper** but it doesn't fire on a single edge at runtime. Instead, the runtime pushes `eraseHere` onto an internal stack at startup, and *any* halt-bound transition reachable during the run (whether on the wrapper itself or on `scanToX`) gets redirected to `eraseHere` via stack-pop. The dotted edge is the engine's static fingerprint of "this graph was wrapped by `withOverriddenHaltState`." 4. **What actually fires at runtime, on tape `['a','b','X','b','a']`:** the wrapper runs once, transferring to `scanToX`; `scanToX` self-loops on `* → ·/R` until it sees `X`; the `X → ·/S` edge tries to go to halt; the runtime pops `eraseHere` off the stack and substitutes it; `eraseHere` erases the cell and halts. The wrapper's own `X → ·/S → halt` edge in the diagram is *never traversed* because control left the wrapper after iteration 1. > 💡 **The engine's emit could be more user-friendly here.** Tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138) — the wrapper's duplicated edges and the misleading single-edge `onHalt` placement are candidates for a cleaner `toMermaid` output.
-Wrappers nest: `inner.withOverrodeHaltState(middle).withOverrodeHaltState(outer)` chains halt-redirects through `middle → outer → halt`. `library-binary-numbers/src/index.ts`'s `minusOne` (the `~(~x + 1)` composition) uses a 4-deep nest of wrappers. +Wrappers nest: `inner.withOverriddenHaltState(middle).withOverriddenHaltState(outer)` chains halt-redirects through `middle → outer → halt`. `library-binary-numbers/src/index.ts`'s `minusOne` (the `~(~x + 1)` composition) uses a 4-deep nest of wrappers. ## Debugging breakpoints @@ -492,7 +492,7 @@ myState.debug = null; > ⚠️ **`haltState.debug.after` throws.** Halt is terminal — there is no iteration-after-halt for an after-fire to anchor on. Assigning a truthy `.after` to `haltState.debug` (including `{ before: true, after: true }`) throws at write time. Symbol-list filters on `haltState.debug.before` are silent no-ops, since halt has no head symbol; only the wildcard `true` activates. -The `debug` field is mutable — toggle breakpoints at runtime without rebuilding the graph. The internal cell is shared with `state.withOverrodeHaltState(...)` wrappers, so an assignment on the original is visible from every wrapper. `state.debug` is always a `DebugConfig` instance (lazy-initialized on first read); plain-object input (`state.debug = { before: true }`) is wrapped in a fresh `DebugConfig` automatically. The instance itself is `Object.seal`-ed — typos like `state.debug.bofore = true` throw `TypeError` instead of silently creating a useless property. Per-property setters validate and freeze the stored array, so `state.debug.before.push(...)` also throws `TypeError`. +The `debug` field is mutable — toggle breakpoints at runtime without rebuilding the graph. The internal cell is shared with `state.withOverriddenHaltState(...)` wrappers, so an assignment on the original is visible from every wrapper. `state.debug` is always a `DebugConfig` instance (lazy-initialized on first read); plain-object input (`state.debug = { before: true }`) is wrapped in a fresh `DebugConfig` automatically. The instance itself is `Object.seal`-ed — typos like `state.debug.bofore = true` throw `TypeError` instead of silently creating a useless property. Per-property setters validate and freeze the stored array, so `state.debug.before.push(...)` also throws `TypeError`. `run()` is async and accepts an `onPause` hook: @@ -617,6 +617,8 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v6.2** *(superseded by v6.3.0)* — widened `onStep`'s signature to `(m) => void | Promise` and added an inline `await onStep(...)` in the run loop, enabling throttle-in-`onStep` patterns. This overturned the docstring-stated contract that `onStep` is sync (microtask-free); the right place for per-iter throttling is `onPause` with self-rearm (see [Throttle pattern](#throttle-pattern)). Restored in v6.3.0. - **v6.3** — `onStep` reverted to its v6.0–v6.1 sync contract — `(m) => void`, called synchronously inside the run loop. The Throttle pattern section documents the engine-native shape for per-iter throttle / "wait between iters" UIs. No other API changes. - **v6.4** — New **`onIter`** hook on `run()`: awaited, fires once at the end of every iter (after both `onPause` dispatches on the same yield), unaffected by the `debug` master switch. Use for per-iter throttle / animation / coordination needing a suspend point; complements the existing sync `onStep` (tracing) and conditional `onPause` (user breakpoints). Three-hook contract is now `onStep` (sync, mid-iter) / `onPause` (awaited, on `state.debug` match) / `onIter` (awaited, end-of-iter). Additive — peer-deps unchanged. The v6.3.0 README's `onPause`-rearm throttle workaround is superseded. +- **v7** *(in progress)* — Composition-representation overhaul. Breaking renames + reshapes scheduled for the v7 cut. Landing piecewise on the `v7` branch; one entry per landed change: + - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. For the full release history, see the [GitHub releases page](https://github.com/mellonis/turing-machine-js/releases). diff --git a/packages/machine/src/classes/State.debug.spec.ts b/packages/machine/src/classes/State.debug.spec.ts index 13c12d7..2f2938b 100644 --- a/packages/machine/src/classes/State.debug.spec.ts +++ b/packages/machine/src/classes/State.debug.spec.ts @@ -90,9 +90,9 @@ describe('State.debug — basics', () => { expect(state.debug.after).toBeUndefined(); }); - test('withOverrodeHaltState returns a new state that shares the debug ref', () => { + test('withOverriddenHaltState returns a new state that shares the debug ref', () => { const state = makeState(); - const wrapped = state.withOverrodeHaltState(haltState); + const wrapped = state.withOverriddenHaltState(haltState); expect(wrapped).not.toBe(state); // Both lazy-init through the shared ref, so reading either returns the @@ -110,7 +110,7 @@ describe('State.debug — basics', () => { test('null assignment on the original propagates to wrappers (filters reset for both)', () => { const state = makeState(); state.debug = {before: true}; - const wrapped = state.withOverrodeHaltState(haltState); + const wrapped = state.withOverriddenHaltState(haltState); state.debug = null; expect(state.debug.before).toBeUndefined(); @@ -120,7 +120,7 @@ describe('State.debug — basics', () => { test('setting on the wrapper propagates back to the original', () => { const state = makeState(); - const wrapped = state.withOverrodeHaltState(haltState); + const wrapped = state.withOverriddenHaltState(haltState); wrapped.debug = {after: true}; expect(state.debug).toBe(wrapped.debug); @@ -129,8 +129,8 @@ describe('State.debug — basics', () => { test('chained wrappers all share the SAME debug object (identity)', () => { const state = makeState(); - const w1 = state.withOverrodeHaltState(haltState); - const w2 = w1.withOverrodeHaltState(haltState); + const w1 = state.withOverriddenHaltState(haltState); + const w2 = w1.withOverriddenHaltState(haltState); state.debug = {before: true}; diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index a1df29e..0c4f93f 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -121,19 +121,19 @@ describe('State.getSymbol — head resolution', () => { }); }); -describe('State.withOverrodeHaltState', () => { +describe('State.withOverriddenHaltState', () => { // The wrapper shares the original's symbolToDataMap and debugRef but adds - // an overrodeHaltState. Audit-flagged: the previous test only checked the + // an overriddenHaltState. Audit-flagged: the previous test only checked the // name pattern; these tests pin the actual wrapping contract. test('wrapper exposes the override target', () => { const original = new State({[ifOtherSymbol]: {nextState: haltState}}); const override = new State({[ifOtherSymbol]: {}}); - const wrapped = original.withOverrodeHaltState(override); + const wrapped = original.withOverriddenHaltState(override); - expect(wrapped.overrodeHaltState).toBe(override); - expect(original.overrodeHaltState).toBeNull(); // original unchanged + expect(wrapped.overriddenHaltState).toBe(override); + expect(original.overriddenHaltState).toBeNull(); // original unchanged }); test('wrapper proxies getCommand / getNextState to the original transitions', () => { @@ -143,7 +143,7 @@ describe('State.withOverrodeHaltState', () => { nextState: haltState, }, }); - const wrapped = original.withOverrodeHaltState(haltState); + const wrapped = original.withOverriddenHaltState(haltState); const sym = symbol(['0']); expect(wrapped.getCommand(sym)).toBe(original.getCommand(sym)); @@ -152,7 +152,7 @@ describe('State.withOverrodeHaltState', () => { test('wrapper shares debugRef with the original (assignment on either is visible from both)', () => { const original = new State({[ifOtherSymbol]: {}}); - const wrapped = original.withOverrodeHaltState(haltState); + const wrapped = original.withOverriddenHaltState(haltState); original.debug = {before: true}; @@ -166,7 +166,7 @@ describe('State.withOverrodeHaltState', () => { test('wrapper has its own id (not shared with the original)', () => { const original = new State({[ifOtherSymbol]: {}}); - const wrapped = original.withOverrodeHaltState(haltState); + const wrapped = original.withOverriddenHaltState(haltState); expect(wrapped.id).not.toBe(original.id); }); @@ -175,7 +175,7 @@ describe('State.withOverrodeHaltState', () => { const original = new State({[ifOtherSymbol]: {}}, 'inner'); const override = new State({[ifOtherSymbol]: {}}, 'outer'); - const wrapped = original.withOverrodeHaltState(override); + const wrapped = original.withOverriddenHaltState(override); expect(wrapped.name).toBe('inner>outer'); }); @@ -202,7 +202,7 @@ describe('State.fromGraph — cyclic override-halt chain', () => { test('throws when the override-halt graph has a cycle', () => { // Graphs constructed by State.toGraph always have acyclic override chains // (cycles throw at State construction). To exercise the defensive cycle - // detection in fromGraph, hand-build a Graph with overrodeHaltStateId + // detection in fromGraph, hand-build a Graph with overriddenHaltStateId // pointing in a loop. // Nodes need at least one transition each — State construction at pass 2 // rejects empty stateDefinitions before pass 3's cycle check would run. @@ -211,9 +211,9 @@ describe('State.fromGraph — cyclic override-halt chain', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overrodeHaltStateId: 2}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overrodeHaltStateId: 1}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 2}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 1}, }, }; diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 8bb7112..c0da681 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -76,11 +76,11 @@ export default class State { readonly #name: string; - #overrodeHaltState: State | null = null; + #overriddenHaltState: State | null = null; #symbolToDataMap = new Map(); - // Shared mutable cell — withOverrodeHaltState wrappers reference the same + // Shared mutable cell — withOverriddenHaltState wrappers reference the same // object so that `state.debug = ...` (and nullings) propagate across them. // Note: toGraph / fromGraph deliberately do not serialize debug — debug is // a runtime concern, not part of the structural graph. @@ -159,8 +159,8 @@ export default class State { return this.#id === 0; } - get overrodeHaltState() { - return this.#overrodeHaltState; + get overriddenHaltState() { + return this.#overriddenHaltState; } get ref() { @@ -260,11 +260,11 @@ export default class State { throw new Error(`No nextState for symbol at state named ${this.#id}`); } - withOverrodeHaltState(overrodeHaltState: State) { - const state = new State(null, `${this.name}>${overrodeHaltState.name}`); + withOverriddenHaltState(overriddenHaltState: State) { + const state = new State(null, `${this.name}>${overriddenHaltState.name}`); state.#symbolToDataMap = this.#symbolToDataMap; - state.#overrodeHaltState = overrodeHaltState; + state.#overriddenHaltState = overriddenHaltState; state.#debugRef = this.#debugRef; return state; @@ -279,7 +279,7 @@ export default class State { id: number; name: string; isHalt: boolean; - overrodeHaltState: { id: number; name: string } | null; + overriddenHaltState: { id: number; name: string } | null; transitions: Array<{ rawPatternDescription: string | undefined; command: Array<{ symbol: string; movement: string }>; @@ -315,8 +315,8 @@ export default class State { id: state.#id, name: state.#name, isHalt: state.isHalt, - overrodeHaltState: state.#overrodeHaltState - ? {id: state.#overrodeHaltState.id, name: state.#overrodeHaltState.name} + overriddenHaltState: state.#overriddenHaltState + ? {id: state.#overriddenHaltState.id, name: state.#overriddenHaltState.name} : null, transitions, }; @@ -339,13 +339,13 @@ export default class State { name: current.#name, isHalt: current.isHalt, transitions: [], - overrodeHaltStateId: current.#overrodeHaltState?.id ?? null, + overriddenHaltStateId: current.#overriddenHaltState?.id ?? null, }; nodes[current.#id] = node; - if (current.#overrodeHaltState) { - queue.push(current.#overrodeHaltState); + if (current.#overriddenHaltState) { + queue.push(current.#overriddenHaltState); } for (const [sym, {command, nextState}] of current.#symbolToDataMap) { @@ -468,8 +468,8 @@ export default class State { let state = bareStates[nodeId]; - if (node.overrodeHaltStateId !== null) { - state = bareStates[nodeId].withOverrodeHaltState(getFinal(node.overrodeHaltStateId)); + if (node.overriddenHaltStateId !== null) { + state = bareStates[nodeId].withOverriddenHaltState(getFinal(node.overriddenHaltStateId)); } inProgress.delete(nodeId); diff --git a/packages/machine/src/classes/TuringMachine.debug.spec.ts b/packages/machine/src/classes/TuringMachine.debug.spec.ts index b77268b..b54e761 100644 --- a/packages/machine/src/classes/TuringMachine.debug.spec.ts +++ b/packages/machine/src/classes/TuringMachine.debug.spec.ts @@ -207,7 +207,7 @@ describe('TuringMachine — haltState.debug.before', () => { [ifOtherSymbol]: {nextState: haltState}, }); - const wrapped = inner.withOverrodeHaltState(continuation); + const wrapped = inner.withOverriddenHaltState(continuation); haltState.debug = {before: true}; const steps: MachineState[] = []; diff --git a/packages/machine/src/classes/TuringMachine.ts b/packages/machine/src/classes/TuringMachine.ts index 4232dca..cc138f2 100644 --- a/packages/machine/src/classes/TuringMachine.ts +++ b/packages/machine/src/classes/TuringMachine.ts @@ -151,8 +151,8 @@ export default class TuringMachine { const stack = this.#stack; let state = initialState; - if (state.overrodeHaltState) { - stack.push(state.overrodeHaltState); + if (state.overriddenHaltState) { + stack.push(state.overriddenHaltState); } let i = 0; @@ -217,8 +217,8 @@ export default class TuringMachine { nextState = stack.pop()!; } - if (state !== nextState && nextState.overrodeHaltState) { - stack.push(nextState.overrodeHaltState); + if (state !== nextState && nextState.overriddenHaltState) { + stack.push(nextState.overriddenHaltState); } state = nextState; diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index 1116254..db25be3 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -113,9 +113,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, 1: { - id: 1, name: 'entry', isHalt: false, overrodeHaltStateId: null, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, transitions: [ {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1}, {pattern: '1', command: [{symbol: '·', movement: 'S'}], nextStateId: 0}, @@ -132,13 +132,13 @@ describe('toMermaid', () => { expect(out).toContain('s1 -- "1 → ·/S" --> s0'); }); - test('renders dotted onHalt edge when overrodeHaltStateId is set', () => { + test('renders dotted onHalt edge when overriddenHaltStateId is set', () => { const out = toMermaid({ initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, - 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overrodeHaltStateId: 0}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0}, }, }); @@ -150,9 +150,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, - 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overrodeHaltStateId: null}, - 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overrodeHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null}, + 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null}, }, }); @@ -164,9 +164,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0'], [' ', 'a']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, 1: { - id: 1, name: 'entry', isHalt: false, overrodeHaltStateId: null, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, transitions: [{ pattern: '0,a', command: [{symbol: '0', movement: 'R'}, {symbol: 'a', movement: 'L'}], @@ -371,7 +371,7 @@ describe('README diagrams: engine-generated outputs', () => { ]); }); - test('withOverrodeHaltState BEFORE (scanToX standalone, machine README)', () => { + test('withOverriddenHaltState BEFORE (scanToX standalone, machine README)', () => { const alphabet = new Alphabet([' ', 'a', 'b', 'X']); const tapeBlock = TapeBlock.fromAlphabets([alphabet]); const {symbol} = tapeBlock; @@ -392,7 +392,7 @@ describe('README diagrams: engine-generated outputs', () => { ]); }); - test('withOverrodeHaltState AFTER (scanThenErase, machine README) — emits the onHalt dotted edge', () => { + test('withOverriddenHaltState AFTER (scanThenErase, machine README) — emits the onHalt dotted edge', () => { const alphabet = new Alphabet([' ', 'a', 'b', 'X']); const tapeBlock = TapeBlock.fromAlphabets([alphabet]); const {symbol} = tapeBlock; @@ -403,7 +403,7 @@ describe('README diagrams: engine-generated outputs', () => { const eraseHere = new State({ [ifOtherSymbol]: {command: {symbol: symbolCommands.erase}, nextState: haltState}, }, 'eraseHere'); - const scanThenErase = scanToX.withOverrodeHaltState(eraseHere); + const scanThenErase = scanToX.withOverriddenHaltState(eraseHere); const output = toMermaid(State.toGraph(scanThenErase, tapeBlock)); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index 5accdb8..e98c31f 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -13,7 +13,7 @@ export type GraphNode = { name: string; isHalt: boolean; transitions: GraphTransition[]; - overrodeHaltStateId: number | null; + overriddenHaltStateId: number | null; }; export type Graph = { diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index a99b29d..17aafbd 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -33,8 +33,8 @@ export function toMermaid(graph: Graph): string { lines.push(` s${node.id} -- "${label}" --> s${t.nextStateId}`); } - if (node.overrodeHaltStateId !== null) { - lines.push(` s${node.id} -. onHalt .-> s${node.overrodeHaltStateId}`); + if (node.overriddenHaltStateId !== null) { + lines.push(` s${node.id} -. onHalt .-> s${node.overriddenHaltStateId}`); } } @@ -75,7 +75,7 @@ export function fromMermaid(text: string): Graph { name: opts.name ?? `s${id}`, isHalt: opts.isHalt ?? false, transitions: [], - overrodeHaltStateId: null, + overriddenHaltStateId: null, }; } else { if (opts.name !== undefined) { @@ -133,7 +133,7 @@ export function fromMermaid(text: string): Graph { const om = line.match(onHaltRegex); if (om) { - ensureNode(Number(om[1])).overrodeHaltStateId = Number(om[2]); + ensureNode(Number(om[1])).overriddenHaltStateId = Number(om[2]); continue; } diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index 64fdc23..3a7e416 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -7,9 +7,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, 1: { - id: 1, name: 'a', isHalt: false, overrodeHaltStateId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, transitions: [ {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1}, {pattern: '1', command: [{symbol: '·', movement: 'S'}], nextStateId: 0}, @@ -31,9 +31,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, 1: { - id: 1, name: 'a', isHalt: false, overrodeHaltStateId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, transitions: [ {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1}, ], @@ -52,9 +52,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, 1: { - id: 1, name: 'a', isHalt: false, overrodeHaltStateId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, transitions: [{pattern: '0', command: [{symbol: '·', movement: 'S'}], nextStateId: 0}], }, }, @@ -72,10 +72,10 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overrodeHaltStateId: 2}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overrodeHaltStateId: 3}, - 3: {id: 3, name: 'c', isHalt: false, transitions: [], overrodeHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3}, + 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null}, }, }; @@ -90,8 +90,8 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overrodeHaltStateId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overrodeHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null}, }, }; @@ -117,7 +117,7 @@ describe('State.inspect', () => { expect(info.name).toBe('test'); expect(info.isHalt).toBe(false); - expect(info.overrodeHaltState).toBeNull(); + expect(info.overriddenHaltState).toBeNull(); expect(info.transitions.length).toBe(2); const haltTransition = info.transitions.find((t) => t.nextState?.name === haltState.name); @@ -140,11 +140,11 @@ describe('State.inspect', () => { const outer = new State({ [symbol(['a'])]: {command: {movement: movements.right}, nextState: haltState}, }, 'outer'); - const wrapped = inner.withOverrodeHaltState(outer); + const wrapped = inner.withOverriddenHaltState(outer); const info = State.inspect(wrapped); - expect(info.overrodeHaltState).toEqual({id: outer.id, name: 'outer'}); + expect(info.overriddenHaltState).toEqual({id: outer.id, name: 'outer'}); }); test('returns null nextState for unbound References', async () => { @@ -190,15 +190,15 @@ describe('summarize (binary library comparison)', () => { describe('summarizeGraph defensive guards', () => { // The override-chain walker has a defensive Set guard against cycles. - // State construction throws on cyclic overrodeHaltState, so we exercise it + // State construction throws on cyclic overriddenHaltState, so we exercise it // by handing summarizeGraph a Graph constructed by hand with a cycle. test('terminates on a cyclic override chain instead of recursing forever', () => { const graph: Graph = { initialId: 1, alphabets: [[' ', '0']], nodes: { - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overrodeHaltStateId: 2}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overrodeHaltStateId: 1}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1}, }, }; diff --git a/packages/machine/src/utilities/introspection.ts b/packages/machine/src/utilities/introspection.ts index db0bc8d..ca34c5d 100644 --- a/packages/machine/src/utilities/introspection.ts +++ b/packages/machine/src/utilities/introspection.ts @@ -5,7 +5,7 @@ import {type Graph} from './graph'; // Quantitative summary of a state graph — designed to help a student compare // two implementations of the same algorithm and answer questions like: // - "How many states does each version have?" -// - "Does either use withOverrodeHaltState composition?" +// - "Does either use withOverriddenHaltState composition?" // - "How deep is that composition?" // - "Are there cycles? self-loops?" // - "What's the alphabet size for each tape?" @@ -24,9 +24,9 @@ export type GraphSummary = { stateCount: number; transitionCount: number; - // Composition via withOverrodeHaltState - compositionEdgeCount: number; // states with an overrodeHaltStateId set - maxCompositionDepth: number; // longest chain of withOverrodeHaltState (0 if none) + // Composition via withOverriddenHaltState + compositionEdgeCount: number; // states with an overriddenHaltStateId set + maxCompositionDepth: number; // longest chain of withOverriddenHaltState (0 if none) // Structural selfLoopCount: number; // transitions where nextStateId === own id @@ -47,7 +47,7 @@ export function summarizeGraph(graph: Graph): GraphSummary { for (const node of nodes) { transitionCount += node.transitions.length; - if (node.overrodeHaltStateId !== null) { + if (node.overriddenHaltStateId !== null) { compositionEdgeCount += 1; } @@ -58,7 +58,7 @@ export function summarizeGraph(graph: Graph): GraphSummary { } } - // Longest withOverrodeHaltState chain. Walks node → overrodeHaltState recursively; + // Longest withOverriddenHaltState chain. Walks node → overriddenHaltState recursively; // a Set guards against cycles in the override graph (which throw at construction // time anyway, but being defensive costs little). const overrideDepthFrom = (id: number, visited: Set): number => { @@ -70,11 +70,11 @@ export function summarizeGraph(graph: Graph): GraphSummary { const node = graph.nodes[id]; - if (!node || node.overrodeHaltStateId === null) { + if (!node || node.overriddenHaltStateId === null) { return 0; } - return 1 + overrideDepthFrom(node.overrodeHaltStateId, visited); + return 1 + overrideDepthFrom(node.overriddenHaltStateId, visited); }; const maxCompositionDepth = nodes.reduce( diff --git a/test/examples.spec.ts b/test/examples.spec.ts index 12a0ec6..8e56890 100644 --- a/test/examples.spec.ts +++ b/test/examples.spec.ts @@ -276,9 +276,9 @@ describe('README.md — Building from a state table', () => { }); }); -// Pin the withOverrodeHaltState subroutine-composition example from the README. -describe('README.md — Subroutine composition with withOverrodeHaltState', () => { - test('scanToX.withOverrodeHaltState(eraseHere) erases the first X and lands on it', async () => { +// Pin the withOverriddenHaltState subroutine-composition example from the README. +describe('README.md — Subroutine composition with withOverriddenHaltState', () => { + test('scanToX.withOverriddenHaltState(eraseHere) erases the first X and lands on it', async () => { const alphabet = new Alphabet([' ', 'a', 'b', 'X']); const tapeBlock = TapeBlock.fromAlphabets([alphabet]); const {symbol} = tapeBlock; @@ -292,7 +292,7 @@ describe('README.md — Subroutine composition with withOverrodeHaltState', () = [ifOtherSymbol]: {command: {symbol: symbolCommands.erase}, nextState: haltState}, }, 'eraseHere'); - const scanThenErase = scanToX.withOverrodeHaltState(eraseHere); + const scanThenErase = scanToX.withOverriddenHaltState(eraseHere); const tape = new Tape({alphabet, symbols: ['a', 'b', 'X', 'b', 'a']}); tapeBlock.replaceTape(tape); @@ -303,7 +303,7 @@ describe('README.md — Subroutine composition with withOverrodeHaltState', () = expect(tape.position).toBe(2); // head landed where the X used to be }); - test('the original scanToX is left unmodified by withOverrodeHaltState', async () => { + test('the original scanToX is left unmodified by withOverriddenHaltState', async () => { const alphabet = new Alphabet([' ', 'X']); const tapeBlock = TapeBlock.fromAlphabets([alphabet]); const {symbol} = tapeBlock; @@ -318,9 +318,9 @@ describe('README.md — Subroutine composition with withOverrodeHaltState', () = }, 'eraseHere'); // Wrapping doesn't mutate the original. - scanToX.withOverrodeHaltState(eraseHere); + scanToX.withOverriddenHaltState(eraseHere); - expect(scanToX.overrodeHaltState).toBeNull(); + expect(scanToX.overriddenHaltState).toBeNull(); // Running scanToX standalone (no wrapper) just halts at the X — the // X is NOT erased. From 46637fc2fa3966a75aa240b456767b7d26a8a6c3 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 16:21:12 +0300 Subject: [PATCH 002/118] feat!: paren-based wrapped-state naming (closes #148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composition-name format change in `State#withOverriddenHaltState`: flat `bare>override` → nested `bare(override)`. The old `>`-flat notation collided structurally-distinct wrap-trees into the same string — e.g., `A.with(B.with(A))` and `A.with(B).with(A)` both rendered as "A>B>A". The paren form keeps them distinct: `A(B(A))` vs `A(B)(A)`. Changes: - `State#withOverriddenHaltState` emits paren-format names. - `State` constructor now rejects user-provided names containing `(` or `)` — they're reserved as wrapper-composition delimiters. `>` is no longer reserved and is valid in user-provided names again. - Internal composition paths (`withOverriddenHaltState` and `fromGraph`'s wrapper reconstruction) bypass the validation by constructing a State with no name and assigning `#name` directly. `#name` lost its `readonly` modifier to allow this; access stays inside the class. - `inspect()`, `toGraph`, `toMermaid` outputs carry the new format transparently — name is just a string, no shape change. - Round-trip name accumulation (#138/#139) still applies in paren form: `A(B)` becomes `A(B)(B)` on second round-trip. Tracked separately. Tests added: - Distinguishes the two A.with(B).with(A) vs A.with(B.with(A)) constructions that flat `>` notation collided. - Constructor throws on names containing `(` or `)`. Generated artifacts: - `states.md` for both binary libraries regenerated. Marker library gets the new paren format. Bare library has cosmetic ID renumbering (no composite names — it doesn't use composition primitives) per the build-states-md.mjs header's renumber-is-safe note. Docs: - README "Subroutine composition" section + Mermaid example use paren format; "Reading guide" gains a one-line note explaining why. - README "Versioning notes" v7 entry gains a sub-bullet for #148. - Engine CLAUDE.md narrative on round-trip name accumulation reflects the new format. Out of scope (cross-repo follow-ups, deferred to engine v7 publish): - post-machine-js — its `Path` parser embeds `>` as the engine's wrapper separator (`'foo>10~30'` shape). Needs flip to parens. New v7 issue to be opened, separate from #82 (the rename adoption). - machines-demo — no composition usage; rides peer-dep bump. --- CLAUDE.md | 2 +- .../library-binary-numbers-bare/states.md | 44 +++++++++---------- packages/library-binary-numbers/states.md | 20 ++++----- packages/machine/README.md | 7 +-- packages/machine/src/classes/State.spec.ts | 30 ++++++++++++- packages/machine/src/classes/State.ts | 24 ++++++++-- packages/machine/src/utilities/graph.spec.ts | 2 +- 7 files changed, 87 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8bf5a1c..04ddcdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ Key shapes that take reading multiple files to grasp: ### Visualization & round-trip -`packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same Graph through Mermaid flowchart syntax. The round-trip is **behaviorally** lossless (rebuilt graph runs to same outputs on same inputs — covered by `test/round-trip.spec.ts`); not bytewise lossless because state IDs auto-reassign, and for `withOverriddenHaltState` wrappers the composite name accumulates `>${override.name}` suffixes on each pass. Tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138) (cleaner emit for wrapped states) + [#139](https://github.com/mellonis/turing-machine-js/issues/139) (regression test that fails until #138 is fixed). +`packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same Graph through Mermaid flowchart syntax. The round-trip is **behaviorally** lossless (rebuilt graph runs to same outputs on same inputs — covered by `test/round-trip.spec.ts`); not bytewise lossless because state IDs auto-reassign, and for `withOverriddenHaltState` wrappers the composite name accumulates an extra `(${override.name})` wrap on each pass (composite-name format is `bare(override)` since v7 — paren-nested rather than `>`-flat, so nestings like `A(B(A))` vs `A(B)(A)` stay distinguishable). Tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138) (cleaner emit for wrapped states) + [#139](https://github.com/mellonis/turing-machine-js/issues/139) (regression test that fails until #138 is fixed). ### Builder package diff --git a/packages/library-binary-numbers-bare/states.md b/packages/library-binary-numbers-bare/states.md index 6ac5d63..9bcc9e1 100644 --- a/packages/library-binary-numbers-bare/states.md +++ b/packages/library-binary-numbers-bare/states.md @@ -8,13 +8,13 @@ flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s1["plusOneCarry"] - s2(("plusOne")) - s1 -- "1 → 0/L" --> s1 - s1 -- "0 → 1/S" --> s0 - s1 -- "- → 1/S" --> s0 - s2 -- "0|1 → ·/R" --> s2 - s2 -- "- → ·/L" --> s1 + s27["plusOneCarry"] + s28(("plusOne")) + s27 -- "1 → 0/L" --> s27 + s27 -- "0 → 1/S" --> s0 + s27 -- "- → 1/S" --> s0 + s28 -- "0|1 → ·/R" --> s28 + s28 -- "- → ·/L" --> s27 ``` ## minusOne @@ -25,13 +25,13 @@ flowchart TD flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s3["minusOneBorrow"] - s4(("minusOne")) - s3 -- "0 → 1/L" --> s3 - s3 -- "1 → 0/S" --> s0 - s3 -- "- → ·/S" --> s0 - s4 -- "0|1 → ·/R" --> s4 - s4 -- "- → ·/L" --> s3 + s29["minusOneBorrow"] + s30(("minusOne")) + s29 -- "0 → 1/L" --> s29 + s29 -- "1 → 0/S" --> s0 + s29 -- "- → ·/S" --> s0 + s30 -- "0|1 → ·/R" --> s30 + s30 -- "- → ·/L" --> s29 ``` ## invertNumber @@ -42,10 +42,10 @@ flowchart TD flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s5(("invertNumber")) - s5 -- "0 → 1/R" --> s5 - s5 -- "1 → 0/R" --> s5 - s5 -- "- → ·/S" --> s0 + s31(("invertNumber")) + s31 -- "0 → 1/R" --> s31 + s31 -- "1 → 0/R" --> s31 + s31 -- "- → ·/S" --> s0 ``` ## normalizeNumber @@ -56,8 +56,8 @@ flowchart TD flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s6(("normalizeNumber")) - s6 -- "0 → ⌫/R" --> s6 - s6 -- "1 → ·/S" --> s0 - s6 -- "- → 0/S" --> s0 + s32(("normalizeNumber")) + s32 -- "0 → ⌫/R" --> s32 + s32 -- "1 → ·/S" --> s0 + s32 -- "- → 0/S" --> s0 ``` diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 81eb3a6..6918c8e 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -53,7 +53,7 @@ flowchart TD s0(((halt))) s5["goToNumberStart"] s6["deleteNumberInternal"] - s7["goToNumberStart>deleteNumberInternal"] + s7["goToNumberStart(deleteNumberInternal)"] s8(("deleteNumber")) s5 -- "^ → ·/S" --> s0 s5 -- "* → ·/L" --> s5 @@ -89,7 +89,7 @@ flowchart TD s0(((halt))) s5["goToNumberStart"] s9["invertNumberGoToNumberWithInversion"] - s10["goToNumberStart>invertNumberGoToNumberWithInversion"] + s10["goToNumberStart(invertNumberGoToNumberWithInversion)"] s11(("invertNumber")) s5 -- "^ → ·/S" --> s0 s5 -- "* → ·/L" --> s5 @@ -116,7 +116,7 @@ flowchart TD s5["goToNumberStart"] s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] - s14["goToNumberStart>normalizeNumberMoveNumberStart"] + s14["goToNumberStart(normalizeNumberMoveNumberStart)"] s15(("normalizeNumber")) s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 @@ -167,18 +167,18 @@ flowchart TD s1["goToNumber"] s5["goToNumberStart"] s9["invertNumberGoToNumberWithInversion"] - s10["goToNumberStart>invertNumberGoToNumberWithInversion"] + s10["goToNumberStart(invertNumberGoToNumberWithInversion)"] s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] - s14["goToNumberStart>normalizeNumberMoveNumberStart"] + s14["goToNumberStart(normalizeNumberMoveNumberStart)"] s15["normalizeNumber"] s16["plusOneFillZeros"] s17["plusOneAddNumberStart"] s18["plusOneCaryOne"] s19["plusOne"] - s20["invertNumber>normalizeNumber"] - s21["plusOne>invertNumber>normalizeNumber"] - s22["invertNumber>plusOne>invertNumber>normalizeNumber"] + s20["invertNumber(normalizeNumber)"] + s21["plusOne(invertNumber(normalizeNumber))"] + s22["invertNumber(plusOne(invertNumber(normalizeNumber)))"] s23(("minusOne")) s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 @@ -236,10 +236,10 @@ flowchart TD s5["goToNumberStart"] s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] - s14["goToNumberStart>normalizeNumberMoveNumberStart"] + s14["goToNumberStart(normalizeNumberMoveNumberStart)"] s15["normalizeNumber"] s24["minusOneFastBorrow"] - s25["minusOneFastBorrow>normalizeNumber"] + s25["minusOneFastBorrow(normalizeNumber)"] s26(("minusOneFast")) s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 diff --git a/packages/machine/README.md b/packages/machine/README.md index 12498bd..78a2be8 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -279,7 +279,7 @@ flowchart TD > 💡 **Mermaid renders at most one edge per source/target pair.** If a state has two distinct transitions back to itself (or two parallel transitions to the same target), only one shows in the diagram. The string output is correct — this is a viewer-side limitation. For graphs with multiple parallel edges, paste the `toMermaid` output into [mermaid.live](https://mermaid.live) and switch to the `stateDiagram-v2` renderer, or post-process the output to your preferred format. -`fromMermaid` parses the same format back into a `Graph`. The round-trip is **behaviorally** lossless — the rebuilt graph runs to the same outputs on the same inputs (tested in `test/round-trip.spec.ts` for the binary-numbers libraries). It is *not* bytewise lossless: state IDs auto-reassign on each rebuild, and for `withOverriddenHaltState` wrappers the composite name gains a `>${override.name}` suffix on each pass (e.g., `scanToX>eraseHere` becomes `scanToX>eraseHere>eraseHere` on a second round-trip — tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138)). +`fromMermaid` parses the same format back into a `Graph`. The round-trip is **behaviorally** lossless — the rebuilt graph runs to the same outputs on the same inputs (tested in `test/round-trip.spec.ts` for the binary-numbers libraries). It is *not* bytewise lossless: state IDs auto-reassign on each rebuild, and for `withOverriddenHaltState` wrappers the composite name gains an extra `(${override.name})` wrapping on each pass (e.g., `scanToX(eraseHere)` becomes `scanToX(eraseHere)(eraseHere)` on a second round-trip — tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138)). ### Reference @@ -442,7 +442,7 @@ flowchart TD s0(((halt))) s1["scanToX"] s2["eraseHere"] - s3(("scanToX>eraseHere")) + s3(("scanToX(eraseHere)")) s1 -- "X → ·/S" --> s0 s1 -- "* → ·/R" --> s1 s2 -- "* → ⌫/S" --> s0 @@ -453,7 +453,7 @@ flowchart TD **Reading guide** — the wrapped diagram is denser than the simplified hand-drawn version above. To parse it: -1. **Three non-halt nodes:** the wrapper `scanToX>eraseHere` (round, the initial state); `scanToX` (square, the original subroutine — unmodified); `eraseHere` (square, the override target). The wrapper appears as a *separate* state from `scanToX`-the-original because `withOverriddenHaltState` returns a new `State` instance — even though it shares the transition map. +1. **Three non-halt nodes:** the wrapper `scanToX(eraseHere)` (round, the initial state); `scanToX` (square, the original subroutine — unmodified); `eraseHere` (square, the override target). The wrapper appears as a *separate* state from `scanToX`-the-original because `withOverriddenHaltState` returns a new `State` instance — even though it shares the transition map. The composite-name format is `bare(override)` — chosen so nested wrappers stay unambiguous: `A.with(B.with(A))` produces `A(B(A))` while `A.with(B).with(A)` produces `A(B)(A)`, two different runtime shapes that flat `A>B>A` notation collided. 2. **Solid edges from the wrapper duplicate `scanToX`'s edges.** That's because the wrapper inherits the same `symbolToDataMap`. Importantly, the wrapper's `* → ·/R` edge points at *`scanToX`-the-original*, not at the wrapper itself — so after the first iteration, control transfers to `scanToX` and stays there. 3. **The dotted `onHalt` edge is attached to the wrapper** but it doesn't fire on a single edge at runtime. Instead, the runtime pushes `eraseHere` onto an internal stack at startup, and *any* halt-bound transition reachable during the run (whether on the wrapper itself or on `scanToX`) gets redirected to `eraseHere` via stack-pop. The dotted edge is the engine's static fingerprint of "this graph was wrapped by `withOverriddenHaltState`." 4. **What actually fires at runtime, on tape `['a','b','X','b','a']`:** the wrapper runs once, transferring to `scanToX`; `scanToX` self-loops on `* → ·/R` until it sees `X`; the `X → ·/S` edge tries to go to halt; the runtime pops `eraseHere` off the stack and substitutes it; `eraseHere` erases the cell and halts. The wrapper's own `X → ·/S → halt` edge in the diagram is *never traversed* because control left the wrapper after iteration 1. @@ -619,6 +619,7 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v6.4** — New **`onIter`** hook on `run()`: awaited, fires once at the end of every iter (after both `onPause` dispatches on the same yield), unaffected by the `debug` master switch. Use for per-iter throttle / animation / coordination needing a suspend point; complements the existing sync `onStep` (tracing) and conditional `onPause` (user breakpoints). Three-hook contract is now `onStep` (sync, mid-iter) / `onPause` (awaited, on `state.debug` match) / `onIter` (awaited, end-of-iter). Additive — peer-deps unchanged. The v6.3.0 README's `onPause`-rearm throttle workaround is superseded. - **v7** *(in progress)* — Composition-representation overhaul. Breaking renames + reshapes scheduled for the v7 cut. Landing piecewise on the `v7` branch; one entry per landed change: - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. + - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly. Round-trip name accumulation in `withOverriddenHaltState` chains stays present in paren form — `A(B)` becomes `A(B)(B)` on second round-trip — tracked separately in [#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139). For the full release history, see the [GitHub releases page](https://github.com/mellonis/turing-machine-js/releases). diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index 0c4f93f..ac8069b 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -93,6 +93,14 @@ describe('State constructor — invalid inputs', () => { [symbol(['0'])]: {command: [] as never, nextState: haltState}, })).toThrow('invalid command'); }); + + test('throws when user-provided name contains `(`', () => { + expect(() => new State(null, 'foo(bar')).toThrow(/invalid state name/); + }); + + test('throws when user-provided name contains `)`', () => { + expect(() => new State(null, 'foo)bar')).toThrow(/invalid state name/); + }); }); describe('State.getCommand / .getNextState — error paths', () => { @@ -171,13 +179,31 @@ describe('State.withOverriddenHaltState', () => { expect(wrapped.id).not.toBe(original.id); }); - test('wrapper name encodes the override target', () => { + test('wrapper name encodes the override target as `bare(override)`', () => { const original = new State({[ifOtherSymbol]: {}}, 'inner'); const override = new State({[ifOtherSymbol]: {}}, 'outer'); const wrapped = original.withOverriddenHaltState(override); - expect(wrapped.name).toBe('inner>outer'); + expect(wrapped.name).toBe('inner(outer)'); + }); + + test('paren-naming distinguishes nestings that flat `>` notation would collide', () => { + const A = new State({[ifOtherSymbol]: {}}, 'A'); + const B = new State({[ifOtherSymbol]: {}}, 'B'); + + // Construction 1: bare=A, override=(B with override A) + const inner1 = B.withOverriddenHaltState(A); + const outer1 = A.withOverriddenHaltState(inner1); + + // Construction 2: bare=(A with override B), override=A + const inner2 = A.withOverriddenHaltState(B); + const outer2 = inner2.withOverriddenHaltState(A); + + // Old `>` notation would collide both at "A>B>A". Paren notation keeps them distinct. + expect(outer1.name).toBe('A(B(A))'); + expect(outer2.name).toBe('A(B)(A)'); + expect(outer1.name).not.toBe(outer2.name); }); }); diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index c0da681..3d4b4ec 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -74,7 +74,10 @@ export class DebugConfig { export default class State { readonly #id: number = id(this); - readonly #name: string; + // Not `readonly` because `withOverriddenHaltState` and `fromGraph` set the + // composed name on a no-arg `new State()` to bypass the constructor's + // user-facing name validation (composite names contain `(` and `)`). + #name: string; #overriddenHaltState: State | null = null; @@ -144,6 +147,10 @@ export default class State { }); } + if (name !== undefined && /[()]/.test(name)) { + throw new Error(`invalid state name "${name}": must not contain '(' or ')' (reserved as wrapper-composition delimiters in withOverriddenHaltState)`); + } + this.#name = name ?? `id:${this.#id}`; } @@ -261,8 +268,13 @@ export default class State { } withOverriddenHaltState(overriddenHaltState: State) { - const state = new State(null, `${this.name}>${overriddenHaltState.name}`); + // Construct with no name, then overwrite #name directly — the composed + // name contains `(` and `)` by design, which the constructor's user-facing + // validation would reject. Internal composition bypasses validation via + // private-field access (legal within the same class). + const state = new State(); + state.#name = `${this.name}(${overriddenHaltState.name})`; state.#symbolToDataMap = this.#symbolToDataMap; state.#overriddenHaltState = overriddenHaltState; state.#debugRef = this.#debugRef; @@ -440,7 +452,13 @@ export default class State { }; } - bareStates[nodeId] = new State(stateDefinition, node.name); + // Graph-sourced names may contain `(` and `)` (composite wrapper names + // emitted by toGraph). Bypass the constructor's user-facing name + // validation by constructing without a name and assigning #name directly. + const bare = new State(stateDefinition); + + bare.#name = node.name; + bareStates[nodeId] = bare; } // Pass 3: apply overrideHaltStates transitively. diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index db25be3..ad646a6 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -413,7 +413,7 @@ describe('README diagrams: engine-generated outputs', () => { '(((halt)))', '["scanToX"]', // original scanToX is reachable from the wrapper → square '["eraseHere"]', // eraseHere is reachable via onHalt → square - '(("scanToX>eraseHere"))', // wrapper is the initial state → round + '(("scanToX(eraseHere)"))', // wrapper is the initial state → round '"* → ⌫/S"', // eraseHere's erase command '-. onHalt .->', // the dotted override-halt edge — engine's static fingerprint of the override ]); From 50f052f925cfff612388e1f8fbf23c041da0c868 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 16:58:53 +0300 Subject: [PATCH 003/118] chore(scripts): isolate per-library state-id counters in docs:states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawn one child Node process per library so each starts from a fresh `State` id counter. Without isolation, the second-imported library's IDs depend on how many states the first-imported one constructed — adding a state to one library would shift every ID label in the other library's `states.md`, creating cross-coupled diff churn. Net effect on this PR's diff: - `packages/library-binary-numbers-bare/states.md` reverts to its v3.0.0 numbering (s1–s6). The "cosmetic ID renumbering" called out in the prior commit's body is undone here — the bare library now has zero net change from master. - `packages/library-binary-numbers/states.md` unchanged (marker library imports as it always did at top of dispatcher; isolation doesn't change its numbering). Single-file structure: argv-gated dispatcher / worker pattern. No top-level library imports — only the worker branch's dynamic import loads a library, and it loads only the one it was asked to render. --- .../library-binary-numbers-bare/states.md | 44 +++++----- scripts/build-states-md.mjs | 81 ++++++++++++------- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/packages/library-binary-numbers-bare/states.md b/packages/library-binary-numbers-bare/states.md index 9bcc9e1..6ac5d63 100644 --- a/packages/library-binary-numbers-bare/states.md +++ b/packages/library-binary-numbers-bare/states.md @@ -8,13 +8,13 @@ flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s27["plusOneCarry"] - s28(("plusOne")) - s27 -- "1 → 0/L" --> s27 - s27 -- "0 → 1/S" --> s0 - s27 -- "- → 1/S" --> s0 - s28 -- "0|1 → ·/R" --> s28 - s28 -- "- → ·/L" --> s27 + s1["plusOneCarry"] + s2(("plusOne")) + s1 -- "1 → 0/L" --> s1 + s1 -- "0 → 1/S" --> s0 + s1 -- "- → 1/S" --> s0 + s2 -- "0|1 → ·/R" --> s2 + s2 -- "- → ·/L" --> s1 ``` ## minusOne @@ -25,13 +25,13 @@ flowchart TD flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s29["minusOneBorrow"] - s30(("minusOne")) - s29 -- "0 → 1/L" --> s29 - s29 -- "1 → 0/S" --> s0 - s29 -- "- → ·/S" --> s0 - s30 -- "0|1 → ·/R" --> s30 - s30 -- "- → ·/L" --> s29 + s3["minusOneBorrow"] + s4(("minusOne")) + s3 -- "0 → 1/L" --> s3 + s3 -- "1 → 0/S" --> s0 + s3 -- "- → ·/S" --> s0 + s4 -- "0|1 → ·/R" --> s4 + s4 -- "- → ·/L" --> s3 ``` ## invertNumber @@ -42,10 +42,10 @@ flowchart TD flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s31(("invertNumber")) - s31 -- "0 → 1/R" --> s31 - s31 -- "1 → 0/R" --> s31 - s31 -- "- → ·/S" --> s0 + s5(("invertNumber")) + s5 -- "0 → 1/R" --> s5 + s5 -- "1 → 0/R" --> s5 + s5 -- "- → ·/S" --> s0 ``` ## normalizeNumber @@ -56,8 +56,8 @@ flowchart TD flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s32(("normalizeNumber")) - s32 -- "0 → ⌫/R" --> s32 - s32 -- "1 → ·/S" --> s0 - s32 -- "- → 0/S" --> s0 + s6(("normalizeNumber")) + s6 -- "0 → ⌫/R" --> s6 + s6 -- "1 → ·/S" --> s0 + s6 -- "- → 0/S" --> s0 ``` diff --git a/scripts/build-states-md.mjs b/scripts/build-states-md.mjs index 05abcf1..8918dd8 100644 --- a/scripts/build-states-md.mjs +++ b/scripts/build-states-md.mjs @@ -6,24 +6,61 @@ // imports from `dist/`). Not run during tests; doc artifacts are committed to // the repo and regenerated manually when the libraries' state graphs change. // -// Heads-up: state IDs are auto-assigned at module-evaluation time (`State.ts`'s -// `id(this)` increments a module-level counter). Re-running this script will -// produce a deterministic-but-different ID renumbering if the import order or -// other consumers shift; the resulting diff is cosmetic — only the `s` -// labels move, the graph topology is identical. If a renumber-only diff -// surfaces during a refresh, it's safe to commit. +// Per-library isolation: state IDs are auto-assigned by a module-level counter +// in `State.ts`. If both libraries are imported into the same Node process, the +// counter is shared and the second-imported library's IDs depend on how many +// states the first-imported one constructed — adding a state to one library +// shifts every ID in the other library's states.md. To avoid that cross- +// coupling, each library is rendered in its own child Node process spawned by +// this script, so each starts from a fresh counter. +import {spawnSync} from 'node:child_process'; import {writeFileSync} from 'node:fs'; import {dirname, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; -import {State, toMermaid} from '../packages/machine/dist/index.mjs'; -import binaryNumbers from '../packages/library-binary-numbers/dist/index.mjs'; -import binaryNumbersBare from '../packages/library-binary-numbers-bare/dist/index.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const scriptPath = fileURLToPath(import.meta.url); -function renderLibrary(libraryName, library) { - const sections = [`# ${libraryName} — state graphs`, '']; +const LIBRARIES = [ + { + name: 'library-binary-numbers', + importPath: '../packages/library-binary-numbers/dist/index.mjs', + outputPath: resolve(root, 'packages/library-binary-numbers/states.md'), + }, + { + name: 'library-binary-numbers-bare', + importPath: '../packages/library-binary-numbers-bare/dist/index.mjs', + outputPath: resolve(root, 'packages/library-binary-numbers-bare/states.md'), + }, +]; + +const libArgIx = process.argv.indexOf('--lib'); +const libName = libArgIx === -1 ? null : process.argv[libArgIx + 1]; + +if (libName === null) { + // Dispatcher mode: one child per library, fresh State id counter in each. + for (const {name} of LIBRARIES) { + const r = spawnSync(process.execPath, [scriptPath, '--lib', name], {stdio: 'inherit'}); + + if (r.status !== 0) { + process.exit(r.status ?? 1); + } + } +} else { + // Worker mode: render exactly one library. Spawned by the dispatcher above + // so the State id counter starts fresh for this library's import. + const entry = LIBRARIES.find((l) => l.name === libName); + + if (!entry) { + console.error(`build-states-md: unknown library "${libName}"`); + process.exit(1); + } + + const {State, toMermaid} = await import('../packages/machine/dist/index.mjs'); + const library = (await import(entry.importPath)).default; + + const sections = [`# ${entry.name} — state graphs`, '']; for (const [stateName, state] of Object.entries(library.states)) { const tapeBlock = library.getTapeBlock(); @@ -41,24 +78,6 @@ function renderLibrary(libraryName, library) { sections.push(''); } - return sections.join('\n'); -} - -const libraries = [ - { - libraryName: 'library-binary-numbers', - library: binaryNumbers, - outputPath: resolve(root, 'packages/library-binary-numbers/states.md'), - }, - { - libraryName: 'library-binary-numbers-bare', - library: binaryNumbersBare, - outputPath: resolve(root, 'packages/library-binary-numbers-bare/states.md'), - }, -]; - -for (const {libraryName, library, outputPath} of libraries) { - const content = renderLibrary(libraryName, library); - writeFileSync(outputPath, content); - console.log(`✓ Wrote ${outputPath}`); + writeFileSync(entry.outputPath, sections.join('\n')); + console.log(`✓ Wrote ${entry.outputPath}`); } From a8016d5b9ba8d5f4089b8221adc7ab64a4a7d767 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 18:56:04 +0300 Subject: [PATCH 004/118] feat!: cleaner `toMermaid` emit for wrapped states (closes #138, closes #139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v7 emit overhaul for `withOverriddenHaltState`-wrapped states. Per design discussion in #138 and the spec at docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md. Visual contract: - `subgraph w_${bareId}["halt frame"]` around each wrapper = the wrapper's runtime stack frame for halt handling. Visual-only — no edge mutations. - `[[bare]]` (Mermaid subroutine / double-walled rectangle) = the wrapper-node. Both the runtime entry point AND the source of the dotted `onHalt` edge. - Cloned `(((halt)))` inside the subgraph = where halt-bound transitions land within the wrapper's scope. `haltState` is a runtime singleton; the cloned node is a teaching aid. - Solid edges from `[[bare]]` to cloned halt stay inside the subgraph. Dotted `onHalt` from `[[bare]]` crosses the subgraph border to the override target — exactly one per wrapper. - Real `(((halt)))` outside any subgraph = actual run terminus. Graph data model changes: - `GraphNode` gains `isWrapped: boolean` and `isClonedHalt: boolean`. - `GraphTransition` gains a stable per-edge `id: string` (`${fromNodeId}-${patternIx}`) for downstream rendering (e.g. `machines-demo` #10's edge-highlighting). - `State` gains a private `#bareState` ref populated in `withOverriddenHaltState` so `toGraph` can recover the bare from a wrapper. - Cloned-halt graph nodes use negative ids (one per wrapper); in Mermaid output they emit as `c${absId}` to satisfy Mermaid's id syntax. `fromGraph` maps them back to the singleton `haltState`. Round-trip: - Closes #139: bytewise-stable round-trip for simple wrappers (test added in test/round-trip.spec.ts). The wrapper's composite name no longer appears as a graph node label — only the bare's name does — so `fromGraph` recomputes the composite fresh on reconstruction; no accumulation. - Shared-bare cases (e.g. `minusOne`'s repeated `invertNumber`) use per-context duplication in the graph emit. Reconstruction produces behaviorally-equivalent State instances. Bytewise round-trip for shared-bare cases is a known limitation outside #139's scope. Mermaid emit is sorted deterministically (nodes by id ascending, transitions per-node in stored order) to support the round-trip test and reduce diff churn in `states.md`. Downstream support for `machines-demo` #9 (graph render) / #10 (next- edge highlight) / #37 (click-to-toggle breakpoints): stable per-node ids, `isWrapped` / `isClonedHalt` flags so downstream can style or skip cloned halts, edge ids for SVG targeting. Other updates: - README "Subroutine composition" section rewritten with the new diagram + reader's contract. - README "Versioning notes" v7 entry gains a sub-bullet for this PR. - `states.md` regenerated for `library-binary-numbers`. - Existing pinned-Mermaid-output assertions in graph.spec.ts / introspection.spec.ts / State.spec.ts updated for the new shape. --- ...026-05-20-tomermaid-wrapper-emit-design.md | 373 ++++++++++++++++++ .../library-binary-numbers/src/graphs.spec.ts | 24 +- packages/library-binary-numbers/states.md | 113 +++--- packages/machine/README.md | 30 +- packages/machine/src/classes/State.spec.ts | 8 +- packages/machine/src/classes/State.ts | 208 +++++++++- packages/machine/src/utilities/graph.spec.ts | 36 +- packages/machine/src/utilities/graph.ts | 13 + .../machine/src/utilities/graphFormats.ts | 195 +++++++-- .../src/utilities/introspection.spec.ts | 36 +- test/round-trip.spec.ts | 50 +++ 11 files changed, 927 insertions(+), 159 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md diff --git a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md new file mode 100644 index 0000000..01c5536 --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md @@ -0,0 +1,373 @@ +# `toMermaid` wrapped-state emit — design comparison + +**Status:** decided — **Variant X with `subgraph` overlay**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a cloned `(((halt)))` (visualizing where halt-bound execution lands inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation underway on the `feat/cleaner-wrapper-emit-138` branch. + +**Context.** [#138](https://github.com/mellonis/turing-machine-js/issues/138) — clean up the visually-confusing Mermaid output for `withOverriddenHaltState`-wrapped states. [#139](https://github.com/mellonis/turing-machine-js/issues/139) — bytewise round-trip regression for the wrapper name accumulation, naturally fixed by whichever design we pick. + +Three live variants below — paste the fenced ` ```mermaid ` blocks into anything that renders Mermaid (GitHub preview, mermaid.live, IDE plugin). + +--- + +## Baseline: current v7 emit + +The shape we'd be replacing. Composite name flipped to paren form in [#168](https://github.com/mellonis/turing-machine-js/pull/168), still has all the readability problems #138 calls out: wrapper duplicates the bare's edges, dotted-edge attached to the wrong node visually, three non-halt nodes for what's conceptually a 2-step composition. + +Single wrapper — `scanToX.withOverriddenHaltState(eraseHere)`: + +```mermaid +flowchart TD +%% alphabets: [[" ","a","b","X"]] + s0(((halt))) + s1["scanToX"] + s2["eraseHere"] + s3(("scanToX(eraseHere)")) + s1 -- "X → ·/S" --> s0 + s1 -- "* → ·/R" --> s1 + s2 -- "* → ⌫/S" --> s0 + s3 -- "X → ·/S" --> s0 + s3 -- "* → ·/R" --> s1 + s3 -. onHalt .-> s2 +``` + +Nested — `A.withOverriddenHaltState(B.withOverriddenHaltState(C))` (placeholder transitions): + +```mermaid +flowchart TD + s0(((halt))) + s1["A"] + s2["B"] + s3["C"] + s4["B(C)"] + s5(("A(B(C))")) + s1 -- "..." --> s0 + s2 -- "..." --> s0 + s3 -- "..." --> s0 + s4 -- "..." --> s2 + s5 -- "..." --> s1 + s4 -. onHalt .-> s3 + s5 -. onHalt .-> s4 +``` + +--- + +## Variant X — shape on the bare, no extra wrapper node + +The wrapped state is signalled by **shape only**: `[[name]]` (subroutine, double-walled rectangle) on the bare. The wrapper node is not emitted at all — its identity collapses into the bare's. A dotted `onHalt` edge runs directly from the bare to the override target. + +Single wrapper: + +```mermaid +flowchart TD +%% alphabets: [[" ","a","b","X"]] + s0(((halt))) + s1[["scanToX"]] + s2["eraseHere"] + s1 -- "X → ·/S" --> s0 + s1 -- "* → ·/R" --> s1 + s2 -- "* → ⌫/S" --> s0 + s1 -. onHalt .-> s2 +``` + +Nested — each wrapped state's bare gets `[[…]]`: + +```mermaid +flowchart TD + s0(((halt))) + s1[["A"]] + s2[["B"]] + s3["C"] + s1 -- "..." --> s0 + s2 -- "..." --> s0 + s3 -- "..." --> s0 + s1 -. onHalt .-> s2 + s2 -. onHalt .-> s3 +``` + +**Shared-bare case** — `minusOne` in `library-binary-numbers` is `invertNumber.with(plusOne.with(invertNumber.with(normalizeNumber)))`. The same `invertNumber` instance is the bare of two wrappers. In Variant X this is fine — `invertNumber` is one node, its `[[…]]` shape says "I'm wrapped in this context", and the dotted edge from it points at the relevant override: + +```mermaid +flowchart TD + s0(((halt))) + s1[["invertNumber"]] + s2["plusOne"] + s3["normalizeNumber"] + s1 -- "..." --> s0 + s2 -- "..." --> s0 + s3 -- "..." --> s0 + s1 -. onHalt .-> s2 +``` + +(Only the outermost wrapper is shown — `minusOne = invertNumber.with(W2)` — because that's what the caller passed to `toGraph`. Nested wrappers inside `W2` get their own `[[…]]` shapes and dotted edges as the walk descends.) + +**Round-trip.** Graph carries only the bare's name; wrapper's composite name (`scanToX(eraseHere)`) does **not** appear in any graph node. `fromGraph` reconstructs via `bare.withOverriddenHaltState(override)` which recomputes the composite name fresh — no name accumulation, fixes [#139](https://github.com/mellonis/turing-machine-js/issues/139) automatically. + +**Pros.** Minimal change. No extra nodes. Handles shared-bare cleanly (one node, one shape). Round-trip trivially stable. +**Cons.** Keeps the dotted-edge convention (some find it non-obvious). The wrapped-vs-not distinction lives in the node SHAPE rather than as a tangible halt-redirect joint. + +--- + +## Variant Y₁ — pseudo-halt node per wrapper, with per-context duplication + +The wrapper becomes a real node in the graph (sentinel label `~halt`, shape `[[…]]`). The bare's halt-bound transitions are **rewritten in the emit** to point at the pseudo-halt instead of at real halt. The pseudo-halt has a solid outgoing edge to the override. Most faithful to the runtime semantics — the wrapper IS the halt-redirect joint, and the graph makes that tangible. + +Single wrapper: + +```mermaid +flowchart TD +%% alphabets: [[" ","a","b","X"]] + s0(((halt))) + s1(("scanToX")) + ph1[["~halt"]] + s2["eraseHere"] + s1 -- "X → ·/S" --> ph1 + s1 -- "* → ·/R" --> s1 + ph1 --> s2 + s2 -- "* → ⌫/S" --> s0 +``` + +Nested: + +```mermaid +flowchart TD + s0(((halt))) + s1(("A")) + ph1[["~halt"]] + s2["B"] + ph2[["~halt"]] + s3["C"] + s1 -- "..." --> ph1 + ph1 --> s2 + s2 -- "..." --> ph2 + ph2 --> s3 + s3 -- "..." --> s0 +``` + +**Shared-bare case — the problem.** `minusOne` again: the same `invertNumber` instance is bare of W3 (outermost) and bare of W1 (innermost). In W3's wrapper context, its halt-bound transitions must rewrite to `ph_W3`; in W1's context, they must rewrite to `ph_W1`. Different rewrites in different contexts → **the state must be emitted twice as different nodes** (`s1` and `s4` below), or we lose the distinct halt-rewrite per wrapper. + +```mermaid +flowchart TD + s0(((halt))) + s1(("invertNumber")) + ph_W3[["~halt"]] + s4["invertNumber (duplicate, in W1 context)"] + ph_W1[["~halt"]] + s2["plusOne"] + ph_W2[["~halt"]] + s3["normalizeNumber"] + s1 -- "..." --> ph_W3 + ph_W3 --> s2 + s2 -- "..." --> ph_W2 + ph_W2 --> s4 + s4 -- "..." --> ph_W1 + ph_W1 --> s3 + s3 -- "..." --> s0 +``` + +`fromGraph` needs to recognize the duplication and merge the two `invertNumber` copies back into one State instance at reconstruction. Possible but non-trivial. + +**Pros.** Runtime-faithful — the wrapper appears as a concrete redirect step in the graph. No dotted-edge convention. +**Cons.** State duplication for shared-bare cases (common in the library). Significantly more parser + reconstruction logic in `fromMermaid` and `fromGraph` to handle duplication + merge. Strictly more nodes in the emit. + +--- + +## Variant Y₂ — pseudo-halt as an additional node (no rewriting; keeps dotted edge) + +A compromise: the pseudo-halt appears as a node with shape `[[~halt]]` and a **solid** outgoing edge to the override, but the bare's halt-bound transitions are **not** rewritten — they still point at real halt. The dotted `onHalt` edge runs from the bare to the pseudo-halt (replacing the current dotted-to-override edge). The pseudo-halt visualizes "the wrapper's redirect joint" without requiring per-context rewriting. + +Single wrapper: + +```mermaid +flowchart TD +%% alphabets: [[" ","a","b","X"]] + s0(((halt))) + s1(("scanToX")) + s2["eraseHere"] + ph1[["~halt"]] + s1 -- "X → ·/S" --> s0 + s1 -- "* → ·/R" --> s1 + s1 -. onHalt .-> ph1 + ph1 --> s2 + s2 -- "* → ⌫/S" --> s0 +``` + +Nested: + +```mermaid +flowchart TD + s0(((halt))) + s1(("A")) + s2["B"] + s3["C"] + ph1[["~halt"]] + ph2[["~halt"]] + s1 -- "..." --> s0 + s2 -- "..." --> s0 + s3 -- "..." --> s0 + s1 -. onHalt .-> ph1 + ph1 --> s2 + s2 -. onHalt .-> ph2 + ph2 --> s3 +``` + +**Shared-bare case** — no duplication needed; `invertNumber` is one node, with one dotted `onHalt` edge to one pseudo-halt: + +```mermaid +flowchart TD + s0(((halt))) + s1(("invertNumber")) + s2["plusOne"] + s3["normalizeNumber"] + ph_outer[["~halt"]] + s1 -- "..." --> s0 + s2 -- "..." --> s0 + s3 -- "..." --> s0 + s1 -. onHalt .-> ph_outer + ph_outer --> s2 +``` + +(Inner wrappers handled by recursion — each gets its own pseudo-halt node, dotted edge from its bare.) + +**Pros.** Pseudo-halt visualized as a tangible step. No state duplication. Modest implementation cost. +**Cons.** Keeps the dotted-edge convention, just shifts what the dotted edge points at (bare → pseudo, instead of bare → override). One extra node per wrapper. + +--- + +## Comparison table + +| Concern | Baseline (current) | X (shape-on-bare) | Y₁ (rewriting pseudo) | Y₂ (additional pseudo) | +|---|---|---|---|---| +| Wrapper node duplicates bare's edges | ❌ yes | n/a (no wrapper node) | ✅ no | ✅ no | +| Dotted-edge convention | ✅ used | ✅ used (bare → override) | ❌ removed | ✅ used (bare → pseudo) | +| Extra nodes per wrapper level | 1 (wrapper) | 0 | 1 (pseudo) | 1 (pseudo) | +| Shared-bare handling | ✅ single node | ✅ single node | ❌ duplicate per context | ✅ single node | +| Round-trip stability ([#139](https://github.com/mellonis/turing-machine-js/issues/139)) | ❌ accumulates `(override)` | ✅ trivially stable | ✅ stable if dedup works | ✅ stable | +| Implementation surface | n/a | small | large (per-context walk, dedup) | medium | +| Faithfulness to runtime semantics | medium (composite name embedded) | medium (shape conveys it) | ✅ high (pseudo IS the joint) | medium-high (pseudo visible but no rewrite) | + +--- + +## Recommendation + +**Y₂ if you want the pseudo-halt visualized as a node**; **X if you want the smallest possible change.** + +Y₁ is the most faithful but the cost of per-context state duplication in `fromGraph` is high for limited additional clarity over Y₂. + +--- + +## Final locked design (Variant X with `subgraph` overlay) + +After iteration, the locked shape evolves Variant X (collapse the wrapper into the bare's representation, no extra "wrapper node" in the graph data) with two visualization-only enhancements that make the wrapper's runtime semantics tangible without mutating the graph structure: + +1. A Mermaid **`subgraph` rectangle labeled `"halt frame"`** around each wrapper — the visual scope for "the wrapper's stack frame for halt handling." +2. A **cloned `(((halt)))` node inside that subgraph** — visualization of "halt-bound transitions land here, *inside* the wrapper's scope." `haltState` is a runtime singleton; the cloned visual is a teaching aid (one halt-clone per wrapper context on the diagram, all corresponding to the single runtime instance). + +### Visual contract (what a reader sees) + +- **`subgraph wN["halt frame"]`** = wrapper's runtime stack frame. While execution is "inside" the rectangle, the wrapper's override target sits on the runtime stack waiting to catch a halt. Visual-only — does not mutate the graph's edges. +- **`[[bare]]`** (Mermaid subroutine / double-walled rectangle, "two lines on sides") = the wrapper-node. Both: + - the wrapper's runtime entry point (execution starts here on entering the wrapper), and + - the source of the dotted `onHalt` redirect (since the wrapper-node *is* the catcher). +- **Cloned `(((halt)))` inside the subgraph** = the halt entry point within this wrapper's scope. Halt-bound transitions from the bare terminate here, not at the real halt. +- **Solid arrows from `[[bare]]` to cloned halt** = the bare's structural halt-bound transitions. All stay inside the subgraph rectangle. +- **Dotted `onHalt` arrow from `[[bare]]` out of the subgraph to the override target** = the wrapper's catch-and-redirect. Exactly one per wrapper; the only arrow that crosses the rectangle border. +- **Real `(((halt)))` outside any subgraph** = the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame (the unwrapped tail of the chain). + +### Single wrapper + +`scanToX.withOverriddenHaltState(eraseHere)`: + +```mermaid +flowchart TD + s0(((halt))) + subgraph wA["halt frame"] + s1[["scanToX"]] + h_A(((halt))) + end + s2["eraseHere"] + s1 -- "X → ·/S" --> h_A + s1 -- "* → ·/R" --> s1 + s2 -- "* → ⌫/S" --> s0 + s1 -. onHalt .-> s2 +``` + +### Nested + +`A.withOverriddenHaltState(B.withOverriddenHaltState(C))`: + +```mermaid +flowchart TD + s0(((halt))) + subgraph wA["halt frame"] + s1[["A"]] + h_A(((halt))) + end + subgraph wB["halt frame"] + s2[["B"]] + h_B(((halt))) + end + s3["C"] + s1 -- "..." --> h_A + s2 -- "..." --> h_B + s3 -- "..." --> s0 + s1 -. onHalt .-> s2 + s2 -. onHalt .-> s3 +``` + +### Round-trip ([#139](https://github.com/mellonis/turing-machine-js/issues/139)) + +The wrapper's composite name (e.g. `scanToX(eraseHere)`) does **not** appear as any graph node's label — only the bare's name does. `fromGraph` reconstructs the wrapper via `bareStates[id].withOverriddenHaltState(getFinal(overriddenHaltStateId))`, which recomputes the composite name fresh on the reconstructed State instance. No round-trip name accumulation — fixes #139 automatically. + +### Shared-bare handling + +`library-binary-numbers`'s `minusOne` = `invertNumber.with(plusOne.with(invertNumber.with(normalizeNumber)))` — same `invertNumber` instance is the bare of two distinct wrappers (outermost and innermost). Each wrapper context implies its own `subgraph` membership + its own cloned halt + its own dotted `onHalt` edge. + +Plan: emit the bare as a separate graph node per wrapper context (per-context duplication in `toGraph`). The shared State instance is preserved at runtime; the graph and Mermaid emit are per-context. `fromGraph` reconstructs equivalent State instances (not necessarily the same runtime `#id` as the original — just behaviorally equivalent). + +### Implementation outline + +1. Add `#bareState` field on `State`; populate in `withOverriddenHaltState` so `toGraph` can recover the bare from a wrapper instance. +2. `GraphNode` gains `isWrapped: boolean`. +3. `State.toGraph`: + - Detect wrapper-States (those with `#overriddenHaltState !== null`). + - Substitute with the bare; mark the bare's graph node `isWrapped: true`. + - Synthesize a per-wrapper cloned-halt graph node (a node with `isHalt: true` whose role is "halt-clone for this wrapper"). + - Rewrite the bare's halt-bound transitions to target the cloned halt rather than the real one. +4. `toMermaid`: + - `isWrapped: true` node → `s${id}[["${name}"]]` (subroutine shape). + - Cloned-halt node → `s${id}(((halt)))` (triple-paren, identical to real halt). + - Wrap each `[[bare]]` + its cloned halt in `subgraph wN["halt frame"] … end`. + - Dotted onHalt edge `s${bareId} -. onHalt .-> s${overrideId}` (from `[[bare]]`, crossing the subgraph border). +5. `fromMermaid`: + - Parse Mermaid `subgraph wN["..."] … end` blocks. + - Recognize `s(\d+)\[\["([^"]*)"\]\]$` as wrapped-bare nodes; mark `isWrapped: true`. + - Track subgraph membership for the round-trip. +6. `State.fromGraph`: + - For `isWrapped: true` nodes, reconstruct via `bareStates[id].withOverriddenHaltState(getFinal(overriddenHaltStateId))`. + - Cloned-halt graph nodes don't get separate State instances — they all map back to the singleton `haltState`. +7. `#139`'s round-trip test added; should pass after this design. +8. `states.md` regenerates with the new shape (both binary libraries). +9. README "Subroutine composition" section rewritten to use the new visual + reader's contract above. + +### Downstream support (for `machines-demo` [#9](https://github.com/mellonis/machines-demo/issues/9) / [#10](https://github.com/mellonis/machines-demo/issues/10) / [#37](https://github.com/mellonis/machines-demo/issues/37)) + +Five design choices in #138's implementation that keep the demo's render + highlight + click-to-breakpoint features unblocked: + +1. **Stable per-node ids in `Graph`.** Every node has a deterministic id: + - Bare nodes: `node.id = bareState.id` (the engine's `State.#id`). + - Cloned-halt nodes: synthesized but deterministic from `(bareNodeId, wrapper-depth)`. + - Per-context bare duplicates: synthesized similarly. + + Mermaid emits `s${id}` for each; downstream can find the SVG node for any engine `state.id` directly. + +2. **Cloned-halt marker on `GraphNode`.** `isClonedHalt: boolean` (additional to `isHalt: true`). Real halt has `isHalt: true, isClonedHalt: false`; cloned halts have both `true`. Downstream uses this to: + - **#9** — emit cloned-halts with a different CSS class (`.cloned-halt` vs `.halt`) for styling. + - **#10** — skip cloned-halts when computing "current state highlight" (they're visualization aids, not runtime states). + - **#37** — skip cloned-halts when wiring click-to-toggle breakpoint handlers. + +3. **Edge identity on `GraphTransition`.** Add `id: string` field, deterministic from `(fromNodeId, patternIndex)` where `patternIndex` is the index of that transition in the bare's symbol map. Mermaid emit injects the id via a CSS-class directive that downstream can target. This is what #10 needs to highlight "the edge that will fire next" precisely. + +4. **Deterministic subgraph names in Mermaid emit.** Each wrapper's subgraph is `subgraph w_${bareNodeId}["halt frame"] … end`. Stable across rebuilds; downstream can target the rendered SVG group. + +5. **`isWrapped: boolean` on `GraphNode`** (already in the locked design above) — gives [#37](https://github.com/mellonis/machines-demo/issues/37) the surface to know "this node is a wrapper, click sets a breakpoint that triggers when the wrapper would catch a halt." + +**Out of scope for #138, deferred to #10 implementation:** exposing the runtime wrapper-context on `MachineState` (so downstream can disambiguate which duplicate-of-the-same-bare to highlight in shared-bare cases like `minusOne`). For the first cut of #10, lighting up all duplicates with the same `state.id` is acceptable; precision-tightening is a follow-up engine-API change. This is the only known shared-bare case in the library; user code rarely hits it. diff --git a/packages/library-binary-numbers/src/graphs.spec.ts b/packages/library-binary-numbers/src/graphs.spec.ts index 291fde6..4139787 100644 --- a/packages/library-binary-numbers/src/graphs.spec.ts +++ b/packages/library-binary-numbers/src/graphs.spec.ts @@ -2,10 +2,13 @@ import {State, fromMermaid, toMermaid} from '@turing-machine-js/machine'; import binaryNumbers from './index'; // Per-state node counts pinned from the source comments above each declaration -// in `index.ts`. Each count includes haltState (`State.toGraph` walks the full -// reachable graph, and every algorithm transitions to halt). Regressions caught: -// a refactor that accidentally grows or shrinks an algorithm's state graph -// fails this table. +// in `index.ts`. Each count includes haltState plus any v7 cloned-halt nodes +// synthesized by `toGraph` (one per `withOverriddenHaltState` wrapper context). +// For single-wrapper algorithms the count is unchanged from v6 — the wrapper +// node disappears (collapsed into its bare) but a cloned halt appears, netting +// to zero. For shared-bare cases like `minusOne` (where the same bare appears +// in multiple wrapper contexts via per-context duplication), the count grows +// by `wrapperCount - 1` relative to v6. const expectedNodeCount: Record = { goToNumber: 2, goToNextNumber: 3, @@ -15,7 +18,7 @@ const expectedNodeCount: Record = invertNumber: 5, normalizeNumber: 7, plusOne: 5, - minusOne: 17, + minusOne: 20, minusOneFast: 10, }; @@ -38,11 +41,14 @@ describe('library-binary-numbers state graphs', () => { const tapeBlock = binaryNumbers.getTapeBlock(); const graph = State.toGraph(binaryNumbers.states[name], tapeBlock); - const haltNodes = Object.values(graph.nodes).filter((node) => node.isHalt); + // Every algorithm has exactly one REAL halt node (the singleton's id is + // shared across all states' graphs). v7's wrapper-emit synthesizes one + // `isClonedHalt: true` node per wrapper context as a visualization aid — + // those are filtered out here. + const realHaltNodes = Object.values(graph.nodes) + .filter((node) => node.isHalt && !node.isClonedHalt); - // Every algorithm has exactly one halt node (the singleton's id is shared - // across all states' graphs). - expect(haltNodes).toHaveLength(1); + expect(realHaltNodes).toHaveLength(1); }, ); diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 6918c8e..52e1407 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -51,16 +51,16 @@ flowchart TD flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) - s5["goToNumberStart"] s6["deleteNumberInternal"] - s7["goToNumberStart(deleteNumberInternal)"] s8(("deleteNumber")) - s5 -- "^ → ·/S" --> s0 - s5 -- "* → ·/L" --> s5 + subgraph w_7["halt frame"] + s7[["goToNumberStart"]] + c7(((halt))) + end s6 -- "$ → ⌫/S" --> s0 s6 -- "* → ⌫/R" --> s6 - s7 -- "^ → ·/S" --> s0 - s7 -- "* → ·/L" --> s5 + s7 -- "^ → ·/S" --> c7 + s7 -- "* → ·/L" --> s7 s7 -. onHalt .-> s6 s8 -- "^|1|0|$ → ·/S" --> s7 s8 -- "* → ·/S" --> s0 @@ -87,18 +87,18 @@ flowchart TD flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) - s5["goToNumberStart"] s9["invertNumberGoToNumberWithInversion"] - s10["goToNumberStart(invertNumberGoToNumberWithInversion)"] s11(("invertNumber")) - s5 -- "^ → ·/S" --> s0 - s5 -- "* → ·/L" --> s5 + subgraph w_10["halt frame"] + s10[["goToNumberStart"]] + c10(((halt))) + end s9 -- "^ → ·/R" --> s9 s9 -- "1 → 0/R" --> s9 s9 -- "0 → 1/R" --> s9 s9 -- "$ → ·/S" --> s0 - s10 -- "^ → ·/S" --> s0 - s10 -- "* → ·/L" --> s5 + s10 -- "^ → ·/S" --> c10 + s10 -- "* → ·/L" --> s10 s10 -. onHalt .-> s9 s11 -- "^|1|0|$ → ·/S" --> s10 s11 -- "* → ·/S" --> s0 @@ -113,20 +113,20 @@ flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) s1["goToNumber"] - s5["goToNumberStart"] s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] - s14["goToNumberStart(normalizeNumberMoveNumberStart)"] s15(("normalizeNumber")) + subgraph w_14["halt frame"] + s14[["goToNumberStart"]] + c14(((halt))) + end s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 - s5 -- "^ → ·/S" --> s0 - s5 -- "* → ·/L" --> s5 s12 -- "- → ^/S" --> s1 s13 -- "^|0 → ⌫/R" --> s13 s13 -- "1|$ → ·/L" --> s12 - s14 -- "^ → ·/S" --> s0 - s14 -- "* → ·/L" --> s5 + s14 -- "^ → ·/S" --> c14 + s14 -- "* → ·/L" --> s14 s14 -. onHalt .-> s13 s15 -- "^|1|0|$ → ·/S" --> s14 s15 -- "* → ·/S" --> s0 @@ -158,44 +158,55 @@ flowchart TD ## minusOne -*17 states (including `haltState`)* +*20 states (including `haltState`)* ```mermaid flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) s1["goToNumber"] - s5["goToNumberStart"] s9["invertNumberGoToNumberWithInversion"] - s10["goToNumberStart(invertNumberGoToNumberWithInversion)"] s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] - s14["goToNumberStart(normalizeNumberMoveNumberStart)"] s15["normalizeNumber"] s16["plusOneFillZeros"] s17["plusOneAddNumberStart"] s18["plusOneCaryOne"] - s19["plusOne"] - s20["invertNumber(normalizeNumber)"] - s21["plusOne(invertNumber(normalizeNumber))"] - s22["invertNumber(plusOne(invertNumber(normalizeNumber)))"] s23(("minusOne")) + subgraph w_10["halt frame"] + s10[["goToNumberStart"]] + c10(((halt))) + end + subgraph w_14["halt frame"] + s14[["goToNumberStart"]] + c14(((halt))) + end + subgraph w_20["halt frame"] + s20[["invertNumber"]] + c20(((halt))) + end + subgraph w_21["halt frame"] + s21[["plusOne"]] + c21(((halt))) + end + subgraph w_22["halt frame"] + s22[["invertNumber"]] + c22(((halt))) + end s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 - s5 -- "^ → ·/S" --> s0 - s5 -- "* → ·/L" --> s5 s9 -- "^ → ·/R" --> s9 s9 -- "1 → 0/R" --> s9 s9 -- "0 → 1/R" --> s9 s9 -- "$ → ·/S" --> s0 - s10 -- "^ → ·/S" --> s0 - s10 -- "* → ·/L" --> s5 + s10 -- "^ → ·/S" --> c10 + s10 -- "* → ·/L" --> s10 s10 -. onHalt .-> s9 s12 -- "- → ^/S" --> s1 s13 -- "^|0 → ⌫/R" --> s13 s13 -- "1|$ → ·/L" --> s12 - s14 -- "^ → ·/S" --> s0 - s14 -- "* → ·/L" --> s5 + s14 -- "^ → ·/S" --> c14 + s14 -- "* → ·/L" --> s14 s14 -. onHalt .-> s13 s15 -- "^|1|0|$ → ·/S" --> s14 s15 -- "* → ·/S" --> s0 @@ -206,18 +217,15 @@ flowchart TD s18 -- "0 → 1/R" --> s16 s18 -- "1 → ·/L" --> s18 s18 -- "^ → 1/L" --> s17 - s19 -- "^|1|0 → ·/R" --> s19 - s19 -- "$ → ·/L" --> s18 - s19 -- "* → ·/S" --> s0 s20 -- "^|1|0|$ → ·/S" --> s10 - s20 -- "* → ·/S" --> s0 + s20 -- "* → ·/S" --> c20 s20 -. onHalt .-> s15 - s21 -- "^|1|0 → ·/R" --> s19 + s21 -- "^|1|0 → ·/R" --> s21 s21 -- "$ → ·/L" --> s18 - s21 -- "* → ·/S" --> s0 + s21 -- "* → ·/S" --> c21 s21 -. onHalt .-> s20 s22 -- "^|1|0|$ → ·/S" --> s10 - s22 -- "* → ·/S" --> s0 + s22 -- "* → ·/S" --> c22 s22 -. onHalt .-> s21 s23 -- "^|1|0 → ·/R" --> s23 s23 -- "$ → ·/S" --> s22 @@ -233,32 +241,31 @@ flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) s1["goToNumber"] - s5["goToNumberStart"] s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] - s14["goToNumberStart(normalizeNumberMoveNumberStart)"] s15["normalizeNumber"] - s24["minusOneFastBorrow"] - s25["minusOneFastBorrow(normalizeNumber)"] s26(("minusOneFast")) + subgraph w_14["halt frame"] + s14[["goToNumberStart"]] + c14(((halt))) + end + subgraph w_25["halt frame"] + s25[["minusOneFastBorrow"]] + c25(((halt))) + end s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 - s5 -- "^ → ·/S" --> s0 - s5 -- "* → ·/L" --> s5 s12 -- "- → ^/S" --> s1 s13 -- "^|0 → ⌫/R" --> s13 s13 -- "1|$ → ·/L" --> s12 - s14 -- "^ → ·/S" --> s0 - s14 -- "* → ·/L" --> s5 + s14 -- "^ → ·/S" --> c14 + s14 -- "* → ·/L" --> s14 s14 -. onHalt .-> s13 s15 -- "^|1|0|$ → ·/S" --> s14 s15 -- "* → ·/S" --> s0 - s24 -- "1 → 0/S" --> s0 - s24 -- "0 → 1/L" --> s24 - s24 -- "^ → ·/S" --> s0 - s25 -- "1 → 0/S" --> s0 - s25 -- "0 → 1/L" --> s24 - s25 -- "^ → ·/S" --> s0 + s25 -- "1 → 0/S" --> c25 + s25 -- "0 → 1/L" --> s25 + s25 -- "^ → ·/S" --> c25 s25 -. onHalt .-> s15 s26 -- "^|1|0 → ·/R" --> s26 s26 -- "$ → ·/L" --> s25 diff --git a/packages/machine/README.md b/packages/machine/README.md index 78a2be8..42ff5a6 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -440,25 +440,28 @@ flowchart TD flowchart TD %% alphabets: [[" ","a","b","X"]] s0(((halt))) - s1["scanToX"] s2["eraseHere"] - s3(("scanToX(eraseHere)")) - s1 -- "X → ·/S" --> s0 - s1 -- "* → ·/R" --> s1 + subgraph w_3["halt frame"] + s3[["scanToX"]] + c3(((halt))) + end s2 -- "* → ⌫/S" --> s0 - s3 -- "X → ·/S" --> s0 - s3 -- "* → ·/R" --> s1 + s3 -- "X → ·/S" --> c3 + s3 -- "* → ·/R" --> s3 s3 -. onHalt .-> s2 ``` -**Reading guide** — the wrapped diagram is denser than the simplified hand-drawn version above. To parse it: +**Reading guide** — the v7 emit (introduced in [#138](https://github.com/mellonis/turing-machine-js/issues/138)) makes the wrapper's runtime stack-frame semantics visible: + +1. **The subgraph rectangle labeled `"halt frame"`** is the wrapper's runtime scope — while execution is "inside" this rectangle, the override target (`eraseHere`) sits on the runtime stack waiting to catch a halt. Visual-only; it does not mutate any edges. +2. **`[[scanToX]]` (Mermaid subroutine / double-walled-rectangle shape)** is the wrapper node. It's both the runtime entry point (execution starts here when entering the wrapper) AND the source of the dotted `onHalt` redirect. The wrapper's composite name (`scanToX(eraseHere)`) is computed at runtime via `state.name` but does not appear as a graph node label — only the bare's name is in the graph. +3. **The cloned `(((halt)))` inside the subgraph** (`c3` here) is where the bare's halt-bound transitions land *inside* the wrapper's scope. `haltState` is a runtime singleton; the cloned node is a teaching aid showing "halt is caught here, not at the real terminus." Solid arrows from the bare to the cloned halt all stay inside the rectangle. +4. **The dotted `onHalt` arrow from `[[scanToX]]` to `eraseHere`** is the wrapper's catch-and-redirect. The single arrow that crosses the subgraph border. Originates from the wrapper-node since the wrapper *is* the catcher. +5. **Real `(((halt)))` outside any subgraph** (`s0`) is the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame — here, by `eraseHere` after it erases the cell. -1. **Three non-halt nodes:** the wrapper `scanToX(eraseHere)` (round, the initial state); `scanToX` (square, the original subroutine — unmodified); `eraseHere` (square, the override target). The wrapper appears as a *separate* state from `scanToX`-the-original because `withOverriddenHaltState` returns a new `State` instance — even though it shares the transition map. The composite-name format is `bare(override)` — chosen so nested wrappers stay unambiguous: `A.with(B.with(A))` produces `A(B(A))` while `A.with(B).with(A)` produces `A(B)(A)`, two different runtime shapes that flat `A>B>A` notation collided. -2. **Solid edges from the wrapper duplicate `scanToX`'s edges.** That's because the wrapper inherits the same `symbolToDataMap`. Importantly, the wrapper's `* → ·/R` edge points at *`scanToX`-the-original*, not at the wrapper itself — so after the first iteration, control transfers to `scanToX` and stays there. -3. **The dotted `onHalt` edge is attached to the wrapper** but it doesn't fire on a single edge at runtime. Instead, the runtime pushes `eraseHere` onto an internal stack at startup, and *any* halt-bound transition reachable during the run (whether on the wrapper itself or on `scanToX`) gets redirected to `eraseHere` via stack-pop. The dotted edge is the engine's static fingerprint of "this graph was wrapped by `withOverriddenHaltState`." -4. **What actually fires at runtime, on tape `['a','b','X','b','a']`:** the wrapper runs once, transferring to `scanToX`; `scanToX` self-loops on `* → ·/R` until it sees `X`; the `X → ·/S` edge tries to go to halt; the runtime pops `eraseHere` off the stack and substitutes it; `eraseHere` erases the cell and halts. The wrapper's own `X → ·/S → halt` edge in the diagram is *never traversed* because control left the wrapper after iteration 1. +**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `* → ·/R` self-loops until the head sees `X`; the `X → ·/S` solid edge would normally halt — it lands on the cloned halt `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `* → ⌫/S` and halts at real `s0`. Run terminates. -> 💡 **The engine's emit could be more user-friendly here.** Tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138) — the wrapper's duplicated edges and the misleading single-edge `onHalt` placement are candidates for a cleaner `toMermaid` output. +> 💡 **Round-trip caveat.** `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for simple wrappers like this one ([#139](https://github.com/mellonis/turing-machine-js/issues/139) regression). For shared-bare cases (same `State` instance used as the bare in multiple wrappers — e.g., `library-binary-numbers`'s `minusOne`), per-context duplication produces wrapper-id-dependent ordering that doesn't byte-match across rebuilds — equivalent runtime behavior, different emit-line order. @@ -619,7 +622,8 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v6.4** — New **`onIter`** hook on `run()`: awaited, fires once at the end of every iter (after both `onPause` dispatches on the same yield), unaffected by the `debug` master switch. Use for per-iter throttle / animation / coordination needing a suspend point; complements the existing sync `onStep` (tracing) and conditional `onPause` (user breakpoints). Three-hook contract is now `onStep` (sync, mid-iter) / `onPause` (awaited, on `state.debug` match) / `onIter` (awaited, end-of-iter). Additive — peer-deps unchanged. The v6.3.0 README's `onPause`-rearm throttle workaround is superseded. - **v7** *(in progress)* — Composition-representation overhaul. Breaking renames + reshapes scheduled for the v7 cut. Landing piecewise on the `v7` branch; one entry per landed change: - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. - - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly. Round-trip name accumulation in `withOverriddenHaltState` chains stays present in paren form — `A(B)` becomes `A(B)(B)` on second round-trip — tracked separately in [#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139). + - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly. + - **`toMermaid` wrapped-state emit overhaul** ([#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139)). The wrapper-and-its-bare pair collapses into a single graph node (`isWrapped: true`); the wrapper's composite name no longer appears as a node label (only the bare's name does). Each wrapper gets a Mermaid `subgraph w_${bareId}["halt frame"] … end` block containing the `[[bare]]` (subroutine shape) plus a cloned `(((halt)))` (visualization aid showing where halt-bound transitions land inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target — exactly one per wrapper. `Graph` data shape gains `isWrapped` and `isClonedHalt` flags on `GraphNode` and a stable `id` on `GraphTransition` (deterministic per-edge identifier — supports downstream tooling like the `machines-demo` interactive viewer at [machines-demo#10](https://github.com/mellonis/machines-demo/issues/10)). Cloned-halt graph nodes use negative ids and round-trip back to the singleton `haltState` via `fromGraph`. Bytewise round-trip stability falls out for simple wrappers (no composite name in the graph means `fromGraph(toGraph(state))` recomputes names fresh — no accumulation). Shared-bare cases (e.g. `minusOne`'s repeated `invertNumber`) use per-context duplication in the graph emit. For the full release history, see the [GitHub releases page](https://github.com/mellonis/turing-machine-js/releases). diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index ac8069b..e8f9528 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -232,14 +232,14 @@ describe('State.fromGraph — cyclic override-halt chain', () => { // pointing in a loop. // Nodes need at least one transition each — State construction at pass 2 // rejects empty stateDefinitions before pass 3's cycle check would run. - const dummyTransition = {pattern: '*', command: [{symbol: '·', movement: 'S'}], nextStateId: 0}; + const dummyTransition = {pattern: '*', command: [{symbol: '·', movement: 'S'}], nextStateId: 0, id: "test-edge"}; const graph = { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 2}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 1}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 2, isWrapped: false, isClonedHalt: false}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 1, isWrapped: false, isClonedHalt: false}, }, }; diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 3d4b4ec..8eca6e4 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -81,6 +81,12 @@ export default class State { #overriddenHaltState: State | null = null; + // For wrapper states (produced by `withOverriddenHaltState`), points at the + // State whose transition map was wrapped. `null` on bare/atomic states. + // Used by `toGraph` to collapse the wrapper-and-its-bare pair into a single + // "wrapped bare" graph node — see the v7 emit redesign for #138. + #bareState: State | null = null; + #symbolToDataMap = new Map(); // Shared mutable cell — withOverriddenHaltState wrappers reference the same @@ -278,6 +284,7 @@ export default class State { state.#symbolToDataMap = this.#symbolToDataMap; state.#overriddenHaltState = overriddenHaltState; state.#debugRef = this.#debugRef; + state.#bareState = this; return state; } @@ -334,55 +341,226 @@ export default class State { }; } + // Walks the State graph and emits a `Graph` data structure. v7 emit shape: + // wrapper-States (those with `#overriddenHaltState !== null`) are collapsed + // onto their bare's representation in the graph, with the wrapper's own `#id` + // used as the graph node id, `isWrapped: true`, and `overriddenHaltStateId` + // set to the override's collapsed id. A per-wrapper "cloned halt" graph node + // (id = negative-of-the-wrapper-id, `isHalt: true, isClonedHalt: true`) is + // synthesized; the bare's halt-bound transitions are rewritten to target the + // cloned halt instead of the real one. + // + // Cloned-halt node ids use the negation of the wrapper's id so they sit in a + // disjoint integer range from real ids (which are always non-negative). Real + // halt is always id 0. static toGraph(initialState: State, tapeBlock: TapeBlock): Graph { const nodes: Record = {}; - const queue: State[] = [initialState]; const alphabets = tapeBlock.alphabets.map((alphabet) => alphabet.symbols); + // Map from a wrapper-State to the "collapsed" graph node id used to refer + // to it in transitions. Same as the wrapper's `#id`, recorded for clarity + // when rewriting transition targets. + const wrapperGraphId = (s: State): number => s.#id; + const clonedHaltIdFor = (wrapper: State): number => -wrapper.#id; + + // The `initialId` is the user-passed start. If it's a wrapper, the + // collapsed graph node uses its `#id`; otherwise its own `#id`. + const initialId = initialState.#id; + + type QueueItem = { + // The State instance to process at this slot. + state: State; + // When non-null, the State is being processed AS the bare of this wrapper. + // The collapsed graph node uses `wrapperGraphId(wrapperContext)`, + // halt-bound transitions retarget to `clonedHaltIdFor(wrapperContext)`, + // self-loop transitions to the bare retarget to the wrapper-id. + wrapperContext: State | null; + }; + + const queue: QueueItem[] = []; + + // Decide how to enqueue the start: if it's a wrapper, enqueue its bare with + // the wrapper as context; otherwise enqueue the state itself. + if (initialState.#overriddenHaltState && initialState.#bareState) { + queue.push({state: initialState.#bareState, wrapperContext: initialState}); + } else { + queue.push({state: initialState, wrapperContext: null}); + } + while (queue.length > 0) { - const current = queue.shift()!; + const {state, wrapperContext} = queue.shift()!; + + if (state.isHalt) { + // Real halt — always id 0, single node. + if (!(0 in nodes)) { + nodes[0] = { + id: 0, + name: state.#name, + isHalt: true, + isClonedHalt: false, + isWrapped: false, + transitions: [], + overriddenHaltStateId: null, + }; + } - if (current.#id in nodes) { + continue; + } + + if (wrapperContext !== null) { + // Process `state` (the bare) collapsed under `wrapperContext` (the + // wrapper). Graph node id = wrapper's id. + const collapsedId = wrapperGraphId(wrapperContext); + + if (collapsedId in nodes) { + continue; + } + + const clonedHaltId = clonedHaltIdFor(wrapperContext); + const overrideTarget = wrapperContext.#overriddenHaltState!; + + // The override target's collapsed id: if the override is itself a + // wrapper, its graph node id is `overrideTarget.#id` (its own wrapper + // id); otherwise its own bare id. + const overrideGraphId = overrideTarget.#overriddenHaltState + ? wrapperGraphId(overrideTarget) + : overrideTarget.#id; + + // Emit the cloned-halt node if not already present (one per wrapper). + if (!(clonedHaltId in nodes)) { + nodes[clonedHaltId] = { + id: clonedHaltId, + name: 'halt', + isHalt: true, + isClonedHalt: true, + isWrapped: false, + transitions: [], + overriddenHaltStateId: null, + }; + } + + // Build the collapsed node. + const collapsedNode: GraphNode = { + id: collapsedId, + name: state.#name, + isHalt: false, + isClonedHalt: false, + isWrapped: true, + transitions: [], + overriddenHaltStateId: overrideGraphId, + }; + + nodes[collapsedId] = collapsedNode; + + let patternIx = 0; + + for (const [sym, {command, nextState}] of state.#symbolToDataMap) { + let target: State; + + try { + target = nextState instanceof State ? nextState : nextState.ref; + } catch { + patternIx += 1; + continue; + } + + // Retarget transitions per Variant X conventions: + // - target == haltState → cloned halt (stays inside the subgraph) + // - target == bare (self-loop) → the collapsed wrapper id + // - target is itself a wrapper → that wrapper's collapsed id + // - else → target's own id + let nextStateId: number; + + if (target.isHalt) { + nextStateId = clonedHaltId; + } else if (target === state) { + nextStateId = collapsedId; + } else if (target.#overriddenHaltState && target.#bareState) { + nextStateId = wrapperGraphId(target); + queue.push({state: target.#bareState, wrapperContext: target}); + } else { + nextStateId = target.#id; + queue.push({state: target, wrapperContext: null}); + } + + collapsedNode.transitions.push({ + pattern: decodePatternDescription(sym.description, alphabets), + command: command.tapesCommands.map((tc) => ({ + symbol: decodeWriteSymbol(tc.symbol), + movement: decodeMovement((tc.movement as symbol).description), + })), + nextStateId, + id: `${collapsedId}-${patternIx}`, + }); + + patternIx += 1; + } + + // Enqueue the override target so its own node is emitted. + if (overrideTarget.#overriddenHaltState && overrideTarget.#bareState) { + queue.push({state: overrideTarget.#bareState, wrapperContext: overrideTarget}); + } else { + queue.push({state: overrideTarget, wrapperContext: null}); + } + + continue; + } + + // Non-wrapper context: emit `state` as a regular node. + if (state.#id in nodes) { continue; } const node: GraphNode = { - id: current.#id, - name: current.#name, - isHalt: current.isHalt, + id: state.#id, + name: state.#name, + isHalt: false, + isClonedHalt: false, + isWrapped: false, transitions: [], - overriddenHaltStateId: current.#overriddenHaltState?.id ?? null, + overriddenHaltStateId: null, }; - nodes[current.#id] = node; + nodes[state.#id] = node; - if (current.#overriddenHaltState) { - queue.push(current.#overriddenHaltState); - } + let patternIx = 0; - for (const [sym, {command, nextState}] of current.#symbolToDataMap) { + for (const [sym, {command, nextState}] of state.#symbolToDataMap) { let target: State; try { target = nextState instanceof State ? nextState : nextState.ref; } catch { + patternIx += 1; continue; } + let nextStateId: number; + + if (target.#overriddenHaltState && target.#bareState) { + // Transition into a wrapper — use its collapsed id. + nextStateId = wrapperGraphId(target); + queue.push({state: target.#bareState, wrapperContext: target}); + } else { + nextStateId = target.#id; + queue.push({state: target, wrapperContext: null}); + } + node.transitions.push({ pattern: decodePatternDescription(sym.description, alphabets), command: command.tapesCommands.map((tc) => ({ symbol: decodeWriteSymbol(tc.symbol), movement: decodeMovement((tc.movement as symbol).description), })), - nextStateId: target.id, + nextStateId, + id: `${state.#id}-${patternIx}`, }); - queue.push(target); + patternIx += 1; } } - return {initialId: initialState.#id, alphabets, nodes}; + return {initialId, alphabets, nodes}; } // Inverse of toGraph: rebuilds a State graph (and a fresh TapeBlock with the diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index ad646a6..c9aa912 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -113,12 +113,12 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1}, - {pattern: '1', command: [{symbol: '·', movement: 'S'}], nextStateId: 0}, + {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: '1', command: [{symbol: '·', movement: 'S'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -137,8 +137,8 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, - 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isWrapped: false, isClonedHalt: false}, }, }); @@ -150,9 +150,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, - 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null}, - 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, }, }); @@ -164,13 +164,14 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0'], [' ', 'a']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [{ pattern: '0,a', command: [{symbol: '0', movement: 'R'}, {symbol: 'a', movement: 'L'}], nextStateId: 0, + id: 'test-edge', }], }, }, @@ -392,7 +393,7 @@ describe('README diagrams: engine-generated outputs', () => { ]); }); - test('withOverriddenHaltState AFTER (scanThenErase, machine README) — emits the onHalt dotted edge', () => { + test('withOverriddenHaltState AFTER (scanThenErase, machine README) — emits the v7 halt-frame subgraph', () => { const alphabet = new Alphabet([' ', 'a', 'b', 'X']); const tapeBlock = TapeBlock.fromAlphabets([alphabet]); const {symbol} = tapeBlock; @@ -410,12 +411,13 @@ describe('README diagrams: engine-generated outputs', () => { expectAllLines(output, [ 'flowchart TD', '%% alphabets: [[" ","a","b","X"]]', - '(((halt)))', - '["scanToX"]', // original scanToX is reachable from the wrapper → square - '["eraseHere"]', // eraseHere is reachable via onHalt → square - '(("scanToX(eraseHere)"))', // wrapper is the initial state → round + '(((halt)))', // real halt outside any subgraph + '["eraseHere"]', // override is a regular [name] node + '[["scanToX"]]', // wrapper-collapsed bare uses subroutine shape inside the subgraph + 'subgraph w_', // halt-frame subgraph wraps the bare + its cloned halt + '"halt frame"', // subgraph label '"* → ⌫/S"', // eraseHere's erase command - '-. onHalt .->', // the dotted override-halt edge — engine's static fingerprint of the override + '-. onHalt .->', // the dotted override-halt edge — wrapper's catch-and-redirect, crosses the subgraph border ]); }); }); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index e98c31f..eb21efb 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -6,6 +6,11 @@ export type GraphTransition = { pattern: string; command: GraphCommand[]; nextStateId: number; + // Stable, deterministic per-edge identifier. Format: `${fromNodeId}-${patternIx}` + // where `patternIx` is the transition's position in the source state's symbol + // map. Let's downstream rendering (machines-demo #10) target a specific edge in + // the rendered Mermaid SVG to highlight "the edge that will fire next." + id: string; }; export type GraphNode = { @@ -14,6 +19,14 @@ export type GraphNode = { isHalt: boolean; transitions: GraphTransition[]; overriddenHaltStateId: number | null; + // `true` when this node represents the bare of a `withOverriddenHaltState`- + // wrapped state. Carries the `[[…]]` (subroutine) shape signal for `toMermaid` + // and tells `fromGraph` to reconstruct via `bare.withOverriddenHaltState(target)`. + isWrapped: boolean; + // `true` for a synthesized halt-clone graph node — one per wrapper context. + // Real halt has `isHalt: true, isClonedHalt: false`; cloned halts have both + // `true`. `fromGraph` maps cloned-halt nodes back to the singleton `haltState`. + isClonedHalt: boolean; }; export type Graph = { diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 17aafbd..eb5cba1 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -5,6 +5,36 @@ import {type Graph, type GraphCommand, type GraphNode} from './graph'; // // Currently only Mermaid flowchart syntax is supported. Future formats // (Graphviz, JSON-LD, custom DSL) belong here too. +// +// v7 emit shape (#138/#139): +// - Each wrapper-State collapses onto its bare's representation. The collapsed +// graph node has `isWrapped: true` and is emitted as Mermaid `[[…]]` +// (subroutine / double-walled-rectangle) shape, inside a `subgraph +// w_${id}["halt frame"] … end` block. A synthesized "cloned halt" graph +// node (with `isHalt: true, isClonedHalt: true`, id = -wrapperId in graph +// data) sits inside the subgraph and serves as the local landing point for +// the bare's halt-bound transitions. The dotted onHalt edge runs from the +// `[[bare]]` directly to the override target, crossing the subgraph border. +// - Real halt (id 0) is emitted as `s0(((halt)))` outside any subgraph. +// - Cloned halt nodes use the Mermaid id `c${absId}` (where `absId = -id`) +// since Mermaid IDs must match /[A-Za-z][A-Za-z0-9_]*/ — negative numbers +// are not legal syntax. + +// Maps a graph node id to its Mermaid id. +// - non-negative id N → "sN" +// - negative id -N (cloned halt) → "cN" +function mermaidIdFor(id: number): string { + return id < 0 ? `c${-id}` : `s${id}`; +} + +// Inverse of mermaidIdFor. +function parseMermaidId(s: string): number { + if (s.startsWith('c')) { + return -Number(s.slice(1)); + } + + return Number(s.slice(1)); +} export function toMermaid(graph: Graph): string { const lines: string[] = [ @@ -12,8 +42,33 @@ export function toMermaid(graph: Graph): string { `%% alphabets: ${JSON.stringify(graph.alphabets)}`, ]; - for (const node of Object.values(graph.nodes)) { - const id = `s${node.id}`; + // Sort nodes by id (ascending — real halt first at 0, regular states next, + // negative-id cloned halts last). Deterministic emit lets `toMermaid` → + // `fromMermaid` → `toMermaid` round-trip stably (regression for #139). + const nodes = Object.values(graph.nodes).slice().sort((a, b) => a.id - b.id); + const wrappedNodes = nodes.filter((n) => n.isWrapped); + + // Convention: wrapped node id N → cloned halt id -N. + const clonedHaltFor = (wrappedId: number): number => -wrappedId; + + // Set of cloned-halt ids that belong to some wrapper (= are inside a subgraph). + const clonedHaltIds = new Set(); + + for (const w of wrappedNodes) { + const clonedId = clonedHaltFor(w.id); + + if (clonedId in graph.nodes) { + clonedHaltIds.add(clonedId); + } + } + + // Emit non-subgraph nodes first: real halt + regular non-wrapped nodes. + for (const node of nodes) { + if (node.isWrapped || clonedHaltIds.has(node.id)) { + continue; + } + + const id = mermaidIdFor(node.id); if (node.isHalt) { lines.push(` ${id}(((halt)))`); @@ -24,17 +79,43 @@ export function toMermaid(graph: Graph): string { } } - for (const node of Object.values(graph.nodes)) { + // Emit one subgraph per wrapper, in sorted wrapped-id order. + for (const wrapped of wrappedNodes) { + const wrappedMid = mermaidIdFor(wrapped.id); + const clonedId = clonedHaltFor(wrapped.id); + const clonedMid = mermaidIdFor(clonedId); + + lines.push(` subgraph w_${wrapped.id}["halt frame"]`); + lines.push(` ${wrappedMid}[["${wrapped.name}"]]`); + + if (clonedId in graph.nodes) { + lines.push(` ${clonedMid}(((halt)))`); + } + + lines.push(' end'); + } + + // Emit transitions per-node in sorted node-id order. Within a node, + // transitions emit in their stored array order (which mirrors the source + // state's symbol-map insertion order — stable per State instance). + for (const node of nodes) { + if (node.isHalt && !node.isClonedHalt) { + continue; + } + for (const t of node.transitions) { - // Per-tape commands separated with ',' to mirror the pattern syntax. const cmd = t.command.map((c) => `${c.symbol}/${c.movement}`).join(','); const label = `${t.pattern} → ${cmd}`; - lines.push(` s${node.id} -- "${label}" --> s${t.nextStateId}`); + lines.push( + ` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`, + ); } if (node.overriddenHaltStateId !== null) { - lines.push(` s${node.id} -. onHalt .-> s${node.overriddenHaltStateId}`); + lines.push( + ` ${mermaidIdFor(node.id)} -. onHalt .-> ${mermaidIdFor(node.overriddenHaltStateId)}`, + ); } } @@ -52,11 +133,14 @@ export function toMermaid(graph: Graph): string { // per-tape segments are split on ','. If your alphabet contains '/' or ',' // as literal symbols, the parser cannot disambiguate. Stick to alphabets // without those characters when round-tripping through Mermaid. -const haltNodeRegex = /^s(\d+)\(\(\(halt\)\)\)$/; -const initialNodeRegex = /^s(\d+)\(\("([^"]*)"\)\)$/; -const regularNodeRegex = /^s(\d+)\["([^"]*)"\]$/; -const transitionRegex = /^s(\d+)\s+--\s+"(.*)"\s+-->\s+s(\d+)$/; -const onHaltRegex = /^s(\d+)\s+-\.\s+onHalt\s+\.->\s+s(\d+)$/; +const haltNodeRegex = /^([sc]\d+)\(\(\(halt\)\)\)$/; +const initialNodeRegex = /^(s\d+)\(\("([^"]*)"\)\)$/; +const regularNodeRegex = /^(s\d+)\["([^"]*)"\]$/; +const wrappedNodeRegex = /^(s\d+)\[\["([^"]*)"\]\]$/; +const subgraphStartRegex = /^subgraph\s+w_\d+\["([^"]*)"\]$/; +const subgraphEndRegex = /^end$/; +const transitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/; +const onHaltRegex = /^([sc]\d+)\s+-\.\s+onHalt\s+\.->\s+([sc]\d+)$/; // First capture char anchored as \S to avoid polynomial backtracking between // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos. const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/; @@ -67,30 +151,42 @@ export function fromMermaid(text: string): Graph { let alphabets: string[][] = []; let initialId: number | null = null; const nodes: Record = {}; - - const ensureNode = (id: number, opts: { name?: string; isHalt?: boolean } = {}): GraphNode => { + // Track the cloned-halt ids that appeared inside a subgraph — they should be + // marked `isClonedHalt: true` even though they share the `(((halt)))` shape + // with the real halt at the top level. + const clonedHaltIds = new Set(); + let inSubgraph = false; + + const ensureNode = ( + id: number, + opts: { + name?: string; + isHalt?: boolean; + isClonedHalt?: boolean; + isWrapped?: boolean; + } = {}, + ): GraphNode => { if (!nodes[id]) { nodes[id] = { id, - name: opts.name ?? `s${id}`, + name: opts.name ?? mermaidIdFor(id), isHalt: opts.isHalt ?? false, + isClonedHalt: opts.isClonedHalt ?? false, + isWrapped: opts.isWrapped ?? false, transitions: [], overriddenHaltStateId: null, }; } else { - if (opts.name !== undefined) { - nodes[id].name = opts.name; - } - - if (opts.isHalt !== undefined) { - nodes[id].isHalt = opts.isHalt; - } + if (opts.name !== undefined) nodes[id].name = opts.name; + if (opts.isHalt !== undefined) nodes[id].isHalt = opts.isHalt; + if (opts.isClonedHalt !== undefined) nodes[id].isClonedHalt = opts.isClonedHalt; + if (opts.isWrapped !== undefined) nodes[id].isWrapped = opts.isWrapped; } return nodes[id]; }; - // First pass: alphabets + nodes. + // First pass: alphabets + nodes (track subgraph context to mark cloned halts). for (const line of lines) { if (line === 'flowchart TD') { continue; @@ -103,17 +199,48 @@ export function fromMermaid(text: string): Graph { continue; } + if (subgraphStartRegex.test(line)) { + inSubgraph = true; + continue; + } + + if (subgraphEndRegex.test(line)) { + inSubgraph = false; + continue; + } + const hm = line.match(haltNodeRegex); if (hm) { - ensureNode(Number(hm[1]), {name: 'halt', isHalt: true}); + const id = parseMermaidId(hm[1]); + const isCloned = inSubgraph || id < 0; + + ensureNode(id, {name: 'halt', isHalt: true, isClonedHalt: isCloned}); + + if (isCloned) { + clonedHaltIds.add(id); + } + + continue; + } + + const wm = line.match(wrappedNodeRegex); + + if (wm) { + const id = parseMermaidId(wm[1]); + + if (initialId === null) { + initialId = id; + } + + ensureNode(id, {name: wm[2], isWrapped: true}); continue; } const im = line.match(initialNodeRegex); if (im) { - const id = Number(im[1]); + const id = parseMermaidId(im[1]); initialId = id; ensureNode(id, {name: im[2]}); @@ -123,7 +250,7 @@ export function fromMermaid(text: string): Graph { const rm = line.match(regularNodeRegex); if (rm) { - ensureNode(Number(rm[1]), {name: rm[2]}); + ensureNode(parseMermaidId(rm[1]), {name: rm[2]}); continue; } } @@ -133,16 +260,16 @@ export function fromMermaid(text: string): Graph { const om = line.match(onHaltRegex); if (om) { - ensureNode(Number(om[1])).overriddenHaltStateId = Number(om[2]); + ensureNode(parseMermaidId(om[1])).overriddenHaltStateId = parseMermaidId(om[2]); continue; } const tm = line.match(transitionRegex); if (tm) { - const fromId = Number(tm[1]); + const fromId = parseMermaidId(tm[1]); const label = tm[2]; - const toId = Number(tm[3]); + const toId = parseMermaidId(tm[3]); const arrowIx = label.indexOf(' → '); @@ -165,12 +292,20 @@ export function fromMermaid(text: string): Graph { }; }); - ensureNode(fromId).transitions.push({pattern, command, nextStateId: toId}); + const fromNode = ensureNode(fromId); + const transitionIx = fromNode.transitions.length; + + fromNode.transitions.push({ + pattern, + command, + nextStateId: toId, + id: `${fromId}-${transitionIx}`, + }); } } if (initialId === null) { - throw new Error('fromMermaid: no initial state (double-paren node) found'); + throw new Error('fromMermaid: no initial state (round-or-wrapped node) found'); } return {initialId, alphabets, nodes}; diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index 3a7e416..3e98557 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -7,12 +7,12 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1}, - {pattern: '1', command: [{symbol: '·', movement: 'S'}], nextStateId: 0}, + {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: '1', command: [{symbol: '·', movement: 'S'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -31,11 +31,11 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1}, + {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1, id: "test-edge"}, ], }, }, @@ -52,10 +52,10 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, - transitions: [{pattern: '0', command: [{symbol: '·', movement: 'S'}], nextStateId: 0}], + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, + transitions: [{pattern: '0', command: [{symbol: '·', movement: 'S'}], nextStateId: 0, id: "test-edge"}], }, }, }; @@ -72,10 +72,10 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3}, - 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isClonedHalt: false}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isWrapped: false, isClonedHalt: false}, + 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, }, }; @@ -90,8 +90,8 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, }, }; @@ -197,8 +197,8 @@ describe('summarizeGraph defensive guards', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isClonedHalt: false}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isWrapped: false, isClonedHalt: false}, }, }; diff --git a/test/round-trip.spec.ts b/test/round-trip.spec.ts index 1cccdab..a5a5935 100644 --- a/test/round-trip.spec.ts +++ b/test/round-trip.spec.ts @@ -1,8 +1,14 @@ import { + Alphabet, State, Tape, + TapeBlock, TuringMachine, fromMermaid, + haltState, + ifOtherSymbol, + movements, + symbolCommands, toMermaid, } from '@turing-machine-js/machine'; import binaryNumbers from '@turing-machine-js/library-binary-numbers'; @@ -88,4 +94,48 @@ describe('toGraph / toMermaid / fromMermaid / fromGraph round trip', () => { } }); } + + // Regression for #139: in v6 the wrapper composite name accumulated an extra + // `>${override.name}` suffix on each round-trip pass (`scanToX>eraseHere` + // → `scanToX>eraseHere>eraseHere` on the second pass), breaking bytewise + // stability. v7's emit doesn't carry the composite name in any graph node's + // label (only the bare's name appears), so reconstruction recomputes the + // composite fresh and the emit is stable. + // + // Uses the `scanToX(eraseHere)` example from #139's issue body — a simple + // single-wrapper case. Shared-bare cases (like minusOne, where the same + // bare appears in two wrapper contexts via per-context duplication) have + // ids that depend on the wrapper's runtime `#id`; those ids reorder under + // sort-by-id across rebuild, which is a separate limitation not in #139's + // scope. + test('toMermaid round-trip is bytewise stable for wrapped states (regression for #139)', () => { + const alphabet = new Alphabet([' ', 'a', 'b', 'X']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const {symbol} = tapeBlock; + + const scanToX = new State({ + [symbol(['X'])]: {nextState: haltState}, + [ifOtherSymbol]: {command: {movement: movements.right}}, + }, 'scanToX'); + + const eraseHere = new State({ + [ifOtherSymbol]: {command: {symbol: symbolCommands.erase}, nextState: haltState}, + }, 'eraseHere'); + + const wrapped = scanToX.withOverriddenHaltState(eraseHere); + + const originalMermaid = toMermaid(State.toGraph(wrapped, tapeBlock)); + const {start: rebuilt, tapeBlock: rebuiltTapeBlock} = State.fromGraph(fromMermaid(originalMermaid)); + const reEmittedMermaid = toMermaid(State.toGraph(rebuilt, rebuiltTapeBlock)); + + // State IDs auto-reassign on each rebuild, so normalize them before + // comparing. v7's emit also uses `cN` for cloned-halt ids and `w_N` for + // subgraph names — normalize all three. + const normalize = (mermaid: string): string => mermaid + .replace(/\bs\d+\b/g, 'sX') + .replace(/\bc\d+\b/g, 'cX') + .replace(/\bw_\d+\b/g, 'w_X'); + + expect(normalize(reEmittedMermaid)).toBe(normalize(originalMermaid)); + }); }); From ef249d05e50ba8124a371e6076f5972bed7c8ce8 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 19:52:18 +0300 Subject: [PATCH 005/118] chore(emit): always emit `idle` entry sentinel; drop `((round))` initial shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit pre-execution marker to every `toMermaid` output: idle([idle]) idle -. enter .-> sN `idle` is a stadium-shaped sentinel — not a graph node — emitted on every diagram regardless of whether the initial state is wrapped. The labeled dotted enter arrow is symmetric with the `onHalt` dotted convention used by wrapper redirects. The `((round))` shape on non-wrapped initials is dropped — non-wrapped initials now render as plain `[name]` square, and the `idle -. enter .->` arrow is the sole "start here" signal. Avoids the redundant double signal of the previous (always-emit) version. `fromMermaid` recovers `initialId` exclusively from the `idle -. enter .->` arrow. The no-initial error message updates accordingly. Affects: - `packages/library-binary-numbers/states.md` regenerated — every algorithm picks up the `idle([idle])` + enter arrow lines. - `packages/library-binary-numbers-bare/states.md` regenerated for the same. - README's Mermaid examples (Quick Start, Reference cycle, Subroutine composition before/after, name-state) all updated to show the new shape; notation block at the start of the engine README rewritten. - graph.spec.ts assertions updated for the new shape. - Design spec status line updated. Downstream win: `idle` becomes a stable, predictable anchor for `machines-demo` rendering. The arrow's `id`-targetable shape lets the demo highlight "execution is about to start" or visualize entry. Folds into PR #169 — no breaking-behavior change beyond the emit-format flip (already in the v7 breaking set). --- ...026-05-20-tomermaid-wrapper-emit-design.md | 2 +- .../library-binary-numbers-bare/states.md | 16 ++++-- packages/library-binary-numbers/states.md | 40 ++++++++++---- packages/machine/README.md | 20 +++++-- packages/machine/src/utilities/graph.spec.ts | 48 ++++++++++++----- .../machine/src/utilities/graphFormats.ts | 52 +++++++++++-------- 6 files changed, 123 insertions(+), 55 deletions(-) diff --git a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md index 01c5536..e8a450f 100644 --- a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md +++ b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md @@ -1,6 +1,6 @@ # `toMermaid` wrapped-state emit — design comparison -**Status:** decided — **Variant X with `subgraph` overlay**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a cloned `(((halt)))` (visualizing where halt-bound execution lands inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation underway on the `feat/cleaner-wrapper-emit-138` branch. +**Status:** decided — **Variant X with `subgraph` overlay + `idle` entry sentinel**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a cloned `(((halt)))`. The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. A stadium-shaped `idle([idle])` sentinel + labeled dotted arrow `idle -. enter .-> sN` is always emitted to mark the initial state — replaces the old `((round))` shape convention on non-wrapped initials, so the single canonical "start here" signal is the enter arrow. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation in PR [#169](https://github.com/mellonis/turing-machine-js/pull/169). **Context.** [#138](https://github.com/mellonis/turing-machine-js/issues/138) — clean up the visually-confusing Mermaid output for `withOverriddenHaltState`-wrapped states. [#139](https://github.com/mellonis/turing-machine-js/issues/139) — bytewise round-trip regression for the wrapper name accumulation, naturally fixed by whichever design we pick. diff --git a/packages/library-binary-numbers-bare/states.md b/packages/library-binary-numbers-bare/states.md index 6ac5d63..9e2e5ca 100644 --- a/packages/library-binary-numbers-bare/states.md +++ b/packages/library-binary-numbers-bare/states.md @@ -9,7 +9,9 @@ flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) s1["plusOneCarry"] - s2(("plusOne")) + s2["plusOne"] + idle([idle]) + idle -. enter .-> s2 s1 -- "1 → 0/L" --> s1 s1 -- "0 → 1/S" --> s0 s1 -- "- → 1/S" --> s0 @@ -26,7 +28,9 @@ flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) s3["minusOneBorrow"] - s4(("minusOne")) + s4["minusOne"] + idle([idle]) + idle -. enter .-> s4 s3 -- "0 → 1/L" --> s3 s3 -- "1 → 0/S" --> s0 s3 -- "- → ·/S" --> s0 @@ -42,7 +46,9 @@ flowchart TD flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s5(("invertNumber")) + s5["invertNumber"] + idle([idle]) + idle -. enter .-> s5 s5 -- "0 → 1/R" --> s5 s5 -- "1 → 0/R" --> s5 s5 -- "- → ·/S" --> s0 @@ -56,7 +62,9 @@ flowchart TD flowchart TD %% alphabets: [[" ","0","1"]] s0(((halt))) - s6(("normalizeNumber")) + s6["normalizeNumber"] + idle([idle]) + idle -. enter .-> s6 s6 -- "0 → ⌫/R" --> s6 s6 -- "1 → ·/S" --> s0 s6 -- "- → 0/S" --> s0 diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 52e1407..6f15b61 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -8,7 +8,9 @@ flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) - s1(("goToNumber")) + s1["goToNumber"] + idle([idle]) + idle -. enter .-> s1 s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 ``` @@ -22,7 +24,9 @@ flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) s1["goToNumber"] - s2(("goToNextNumber")) + s2["goToNextNumber"] + idle([idle]) + idle -. enter .-> s2 s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 s2 -- "* → ·/R" --> s1 @@ -37,7 +41,9 @@ flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) s3["goToPreviousNumberInternal"] - s4(("goToPreviousNumber")) + s4["goToPreviousNumber"] + idle([idle]) + idle -. enter .-> s4 s3 -- "$ → ·/S" --> s0 s3 -- "* → ·/L" --> s3 s4 -- "* → ·/L" --> s3 @@ -52,11 +58,13 @@ flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) s6["deleteNumberInternal"] - s8(("deleteNumber")) + s8["deleteNumber"] + idle([idle]) subgraph w_7["halt frame"] s7[["goToNumberStart"]] c7(((halt))) end + idle -. enter .-> s8 s6 -- "$ → ⌫/S" --> s0 s6 -- "* → ⌫/R" --> s6 s7 -- "^ → ·/S" --> c7 @@ -74,7 +82,9 @@ flowchart TD flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) - s5(("goToNumberStart")) + s5["goToNumberStart"] + idle([idle]) + idle -. enter .-> s5 s5 -- "^ → ·/S" --> s0 s5 -- "* → ·/L" --> s5 ``` @@ -88,11 +98,13 @@ flowchart TD %% alphabets: [[" ","^","$","0","1"]] s0(((halt))) s9["invertNumberGoToNumberWithInversion"] - s11(("invertNumber")) + s11["invertNumber"] + idle([idle]) subgraph w_10["halt frame"] s10[["goToNumberStart"]] c10(((halt))) end + idle -. enter .-> s11 s9 -- "^ → ·/R" --> s9 s9 -- "1 → 0/R" --> s9 s9 -- "0 → 1/R" --> s9 @@ -115,11 +127,13 @@ flowchart TD s1["goToNumber"] s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] - s15(("normalizeNumber")) + s15["normalizeNumber"] + idle([idle]) subgraph w_14["halt frame"] s14[["goToNumberStart"]] c14(((halt))) end + idle -. enter .-> s15 s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 s12 -- "- → ^/S" --> s1 @@ -143,7 +157,9 @@ flowchart TD s16["plusOneFillZeros"] s17["plusOneAddNumberStart"] s18["plusOneCaryOne"] - s19(("plusOne")) + s19["plusOne"] + idle([idle]) + idle -. enter .-> s19 s16 -- "1 → 0/R" --> s16 s16 -- "$ → ·/S" --> s0 s17 -- "- → ^/R" --> s17 @@ -172,7 +188,8 @@ flowchart TD s16["plusOneFillZeros"] s17["plusOneAddNumberStart"] s18["plusOneCaryOne"] - s23(("minusOne")) + s23["minusOne"] + idle([idle]) subgraph w_10["halt frame"] s10[["goToNumberStart"]] c10(((halt))) @@ -193,6 +210,7 @@ flowchart TD s22[["invertNumber"]] c22(((halt))) end + idle -. enter .-> s23 s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 s9 -- "^ → ·/R" --> s9 @@ -244,7 +262,8 @@ flowchart TD s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] s15["normalizeNumber"] - s26(("minusOneFast")) + s26["minusOneFast"] + idle([idle]) subgraph w_14["halt frame"] s14[["goToNumberStart"]] c14(((halt))) @@ -253,6 +272,7 @@ flowchart TD s25[["minusOneFastBorrow"]] c25(((halt))) end + idle -. enter .-> s26 s1 -- "$ → ·/S" --> s0 s1 -- "* → ·/R" --> s1 s12 -- "- → ^/S" --> s1 diff --git a/packages/machine/README.md b/packages/machine/README.md index 42ff5a6..1642302 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -90,13 +90,15 @@ flowchart LR flowchart TD %% alphabets: [[" ","a","b","c","*"]] s0(((halt))) - s1(("replaceB")) + s1["replaceB"] + idle([idle]) + idle -. enter .-> s1 s1 -- "b → */R" --> s1 s1 -- "- → ·/L" --> s0 s1 -- "* → ·/R" --> s1 ``` -Engine notation: `read → write/move`; `·` = keep, `⌫` = erase, `*` = `ifOtherSymbol` catch-all, `-` = the blank symbol. `(((double-paren)))` = halt; `(("round"))` = the initial state passed to `toGraph`; `["square"]` = a state reachable from the initial state. +Engine notation: `read → write/move`; `·` = keep, `⌫` = erase, `*` = `ifOtherSymbol` catch-all, `-` = the blank symbol. `(((double-paren)))` = halt; `["square"]` = a regular state. The `idle([idle])` sentinel + labeled-dotted `-. enter .->` arrow marks where execution begins. Wrapped states (subroutine-shaped `[[double-walled]]`) sit inside a `subgraph w_N["halt frame"]` block — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) below. @@ -270,7 +272,9 @@ The string `toMermaid` produces is a real Mermaid flowchart that renders in-plac flowchart TD %% alphabets: [[" ","0","1","$"]] s0(((halt))) - s1(("name")) + s1["name"] + idle([idle]) + idle -. enter .-> s1 s1 -- "1 → 0/R" --> s1 s1 -- "$ → ·/L" --> s0 ``` @@ -308,8 +312,10 @@ flowchart LR ```mermaid flowchart TD %% alphabets: [[" ","x","y"]] - s1(("a")) + s1["a"] s2["b"] + idle([idle]) + idle -. enter .-> s1 s1 -- "x → ·/S" --> s2 s2 -- "y → ·/S" --> s1 ``` @@ -429,7 +435,9 @@ flowchart LR flowchart TD %% alphabets: [[" ","a","b","X"]] s0(((halt))) - s1(("scanToX")) + s1["scanToX"] + idle([idle]) + idle -. enter .-> s1 s1 -- "X → ·/S" --> s0 s1 -- "* → ·/R" --> s1 ``` @@ -441,10 +449,12 @@ flowchart TD %% alphabets: [[" ","a","b","X"]] s0(((halt))) s2["eraseHere"] + idle([idle]) subgraph w_3["halt frame"] s3[["scanToX"]] c3(((halt))) end + idle -. enter .-> s3 s2 -- "* → ⌫/S" --> s0 s3 -- "X → ·/S" --> c3 s3 -- "* → ·/R" --> s3 diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index c9aa912..b7d7bc8 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -127,7 +127,9 @@ describe('toMermaid', () => { expect(out.startsWith('flowchart TD')).toBe(true); expect(out).toContain('%% alphabets: [[" ","0","1"]]'); expect(out).toContain('s0(((halt)))'); - expect(out).toContain('s1(("entry"))'); + expect(out).toContain('s1["entry"]'); + expect(out).toContain('idle([idle])'); + expect(out).toContain('idle -. enter .-> s1'); expect(out).toContain('s1 -- "0 → ·/R" --> s1'); expect(out).toContain('s1 -- "1 → ·/S" --> s0'); }); @@ -223,21 +225,23 @@ describe('parseMovementLabel', () => { }); describe('fromMermaid error paths', () => { - test('throws when no initial state (double-paren node) is present', () => { + test('throws when no `idle -. enter .-> sN` arrow is present', () => { const mermaid = [ 'flowchart TD', '%% alphabets: [[" ","0","1"]]', ' s0(((halt)))', ].join('\n'); - expect(() => fromMermaid(mermaid)).toThrow('fromMermaid: no initial state'); + expect(() => fromMermaid(mermaid)).toThrow('fromMermaid: no `idle -. enter .-> sN` arrow'); }); test('throws on a malformed edge label (missing arrow)', () => { const mermaid = [ 'flowchart TD', - ' s1(("entry"))', + ' s1["entry"]', ' s0(((halt)))', + ' idle([idle])', + ' idle -. enter .-> s1', ' s1 -- "no-arrow-label" --> s0', ].join('\n'); @@ -247,8 +251,10 @@ describe('fromMermaid error paths', () => { test('throws on a malformed command part (missing slash)', () => { const mermaid = [ 'flowchart TD', - ' s1(("entry"))', + ' s1["entry"]', ' s0(((halt)))', + ' idle([idle])', + ' idle -. enter .-> s1', ' s1 -- "* → noslash" --> s0', ].join('\n'); @@ -260,13 +266,15 @@ describe('fromMermaid ensureNode update branches', () => { // The defensive update branches inside ensureNode (when a node id is // declared more than once) only fire if the same id appears in multiple // node-declaration lines. Synthetic but valid input. - test('a later regular-node declaration updates the name of an already-created initial node', () => { + test('a later regular-node declaration updates the name of an already-created node', () => { const mermaid = [ 'flowchart TD', '%% alphabets: [[" ","0"]]', - ' s1(("entry"))', // initial — creates s1 with name="entry" - ' s1["renamed"]', // regular — fires the name-update branch + ' s1["entry"]', // creates s1 with name="entry" + ' s1["renamed"]', // fires the name-update branch ' s0(((halt)))', + ' idle([idle])', + ' idle -. enter .-> s1', ].join('\n'); const graph = fromMermaid(mermaid); @@ -278,9 +286,11 @@ describe('fromMermaid ensureNode update branches', () => { const mermaid = [ 'flowchart TD', '%% alphabets: [[" ","0"]]', - ' s1(("entry"))', // initial — creates s1 with isHalt=false - ' s1(((halt)))', // halt — fires the isHalt-update branch + ' s1["entry"]', // creates s1 with isHalt=false + ' s1(((halt)))', // halt — fires the isHalt-update branch ' s0(((halt)))', + ' idle([idle])', + ' idle -. enter .-> s1', ].join('\n'); const graph = fromMermaid(mermaid); @@ -305,7 +315,9 @@ describe('README example: toMermaid output is stable', () => { 'flowchart TD', '%% alphabets: [[" ","0","1","$"]]', ' s0(((halt)))', - ' s1(("name"))', + ' s1["name"]', + ' idle([idle])', + ' idle -. enter .-> s1', ' s1 -- "1 → 0/R" --> s1', ' s1 -- "$ → ·/L" --> s0', ].join('\n'); @@ -344,7 +356,9 @@ describe('README diagrams: engine-generated outputs', () => { 'flowchart TD', '%% alphabets: [[" ","a","b","c","*"]]', '(((halt)))', - '(("replaceB"))', + '["replaceB"]', // initial — square (no longer round in v7; idle arrow signals entry) + 'idle([idle])', + 'idle -. enter .->', '"b → */R"', '"- → ·/L"', '"* → ·/R"', @@ -365,8 +379,10 @@ describe('README diagrams: engine-generated outputs', () => { expectAllLines(output, [ 'flowchart TD', '%% alphabets: [[" ","x","y"]]', - '(("a"))', // a is the initial state passed to toGraph → round + '["a"]', // a is the initial state — square (idle arrow signals entry) '["b"]', // b is reachable from a → square + 'idle([idle])', + 'idle -. enter .->', '"x → ·/S"', '"y → ·/S"', ]); @@ -387,7 +403,9 @@ describe('README diagrams: engine-generated outputs', () => { 'flowchart TD', '%% alphabets: [[" ","a","b","X"]]', '(((halt)))', - '(("scanToX"))', + '["scanToX"]', // initial — square (idle arrow signals entry) + 'idle([idle])', + 'idle -. enter .->', '"X → ·/S"', '"* → ·/R"', ]); @@ -416,6 +434,8 @@ describe('README diagrams: engine-generated outputs', () => { '[["scanToX"]]', // wrapper-collapsed bare uses subroutine shape inside the subgraph 'subgraph w_', // halt-frame subgraph wraps the bare + its cloned halt '"halt frame"', // subgraph label + 'idle([idle])', // pre-execution sentinel — always emitted + 'idle -. enter .->', // labeled dotted enter arrow points at the initial state '"* → ⌫/S"', // eraseHere's erase command '-. onHalt .->', // the dotted override-halt edge — wrapper's catch-and-redirect, crosses the subgraph border ]); diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index eb5cba1..39bc5c7 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -63,6 +63,8 @@ export function toMermaid(graph: Graph): string { } // Emit non-subgraph nodes first: real halt + regular non-wrapped nodes. + // No special round-shape `((…))` for the initial — the `idle -. enter .->` + // arrow emitted below is the sole "start here" signal. for (const node of nodes) { if (node.isWrapped || clonedHaltIds.has(node.id)) { continue; @@ -72,13 +74,17 @@ export function toMermaid(graph: Graph): string { if (node.isHalt) { lines.push(` ${id}(((halt)))`); - } else if (node.id === graph.initialId) { - lines.push(` ${id}(("${node.name}"))`); } else { lines.push(` ${id}["${node.name}"]`); } } + // `idle` sentinel = pre-execution marker for the machine. Always emitted, + // with a labeled dotted arrow `idle -. enter .-> sN` to the initial state. + // Symmetric with the `onHalt` dotted convention used by wrapper redirects. + // Visual-only — `idle` is not a graph node. + lines.push(' idle([idle])'); + // Emit one subgraph per wrapper, in sorted wrapped-id order. for (const wrapped of wrappedNodes) { const wrappedMid = mermaidIdFor(wrapped.id); @@ -95,6 +101,10 @@ export function toMermaid(graph: Graph): string { lines.push(' end'); } + // Enter arrow: emitted after subgraphs so it visually points at the initial + // node (whether plain `[…]` or wrapped `[[…]]` inside a subgraph). + lines.push(` idle -. enter .-> ${mermaidIdFor(graph.initialId)}`); + // Emit transitions per-node in sorted node-id order. Within a node, // transitions emit in their stored array order (which mirrors the source // state's symbol-map insertion order — stable per State instance). @@ -134,11 +144,12 @@ export function toMermaid(graph: Graph): string { // as literal symbols, the parser cannot disambiguate. Stick to alphabets // without those characters when round-tripping through Mermaid. const haltNodeRegex = /^([sc]\d+)\(\(\(halt\)\)\)$/; -const initialNodeRegex = /^(s\d+)\(\("([^"]*)"\)\)$/; const regularNodeRegex = /^(s\d+)\["([^"]*)"\]$/; const wrappedNodeRegex = /^(s\d+)\[\["([^"]*)"\]\]$/; const subgraphStartRegex = /^subgraph\s+w_\d+\["([^"]*)"\]$/; const subgraphEndRegex = /^end$/; +const idleNodeRegex = /^idle\(\[idle\]\)$/; +const enterArrowRegex = /^idle\s+-\.\s+enter\s+\.->\s+(s\d+)$/; const transitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/; const onHaltRegex = /^([sc]\d+)\s+-\.\s+onHalt\s+\.->\s+([sc]\d+)$/; // First capture char anchored as \S to avoid polynomial backtracking between @@ -209,6 +220,13 @@ export function fromMermaid(text: string): Graph { continue; } + // `idle([idle])` sentinel: a visual pre-execution marker. Not a graph + // node — skip declaration, parse the `idle -. enter .-> sN` arrow in the + // edge pass to set initialId. + if (idleNodeRegex.test(line)) { + continue; + } + const hm = line.match(haltNodeRegex); if (hm) { @@ -227,23 +245,7 @@ export function fromMermaid(text: string): Graph { const wm = line.match(wrappedNodeRegex); if (wm) { - const id = parseMermaidId(wm[1]); - - if (initialId === null) { - initialId = id; - } - - ensureNode(id, {name: wm[2], isWrapped: true}); - continue; - } - - const im = line.match(initialNodeRegex); - - if (im) { - const id = parseMermaidId(im[1]); - - initialId = id; - ensureNode(id, {name: im[2]}); + ensureNode(parseMermaidId(wm[1]), {name: wm[2], isWrapped: true}); continue; } @@ -257,6 +259,14 @@ export function fromMermaid(text: string): Graph { // Second pass: edges. for (const line of lines) { + // `idle -. enter .-> sN`: the sole source of initialId. + const em = line.match(enterArrowRegex); + + if (em) { + initialId = parseMermaidId(em[1]); + continue; + } + const om = line.match(onHaltRegex); if (om) { @@ -305,7 +315,7 @@ export function fromMermaid(text: string): Graph { } if (initialId === null) { - throw new Error('fromMermaid: no initial state (round-or-wrapped node) found'); + throw new Error('fromMermaid: no `idle -. enter .-> sN` arrow found'); } return {initialId, alphabets, nodes}; From cb13663b7d29a2199e48686f0a2bb4f495bcaed3 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 19:53:57 +0300 Subject: [PATCH 006/118] fix(docs:states): exclude `isClonedHalt` nodes from per-algorithm state count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloned-halt nodes (one per wrapper context) are visualization-only sentinels — at runtime they all map to the singleton `haltState`, so they shouldn't inflate the "N states (including haltState)" header. Affects only the count text; the rendered Mermaid graph below the header still shows the cloned halts inside each `halt frame` subgraph (that's their whole purpose — visualizing where halt-bound transitions land within a wrapper's scope). Header counts after the fix (binary-numbers): - deleteNumber: 5 → 4 - invertNumber: 5 → 4 - normalizeNumber: 7 → 6 - minusOne: 20 → 15 - minusOneFast: 10 → 8 (All others unchanged — no wrappers, no cloned halts.) --- packages/library-binary-numbers/states.md | 10 +++++----- scripts/build-states-md.mjs | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 6f15b61..7c455e0 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -51,7 +51,7 @@ flowchart TD ## deleteNumber -*5 states (including `haltState`)* +*4 states (including `haltState`)* ```mermaid flowchart TD @@ -91,7 +91,7 @@ flowchart TD ## invertNumber -*5 states (including `haltState`)* +*4 states (including `haltState`)* ```mermaid flowchart TD @@ -118,7 +118,7 @@ flowchart TD ## normalizeNumber -*7 states (including `haltState`)* +*6 states (including `haltState`)* ```mermaid flowchart TD @@ -174,7 +174,7 @@ flowchart TD ## minusOne -*20 states (including `haltState`)* +*15 states (including `haltState`)* ```mermaid flowchart TD @@ -252,7 +252,7 @@ flowchart TD ## minusOneFast -*10 states (including `haltState`)* +*8 states (including `haltState`)* ```mermaid flowchart TD diff --git a/scripts/build-states-md.mjs b/scripts/build-states-md.mjs index 8918dd8..e8b6aa4 100644 --- a/scripts/build-states-md.mjs +++ b/scripts/build-states-md.mjs @@ -66,7 +66,10 @@ if (libName === null) { const tapeBlock = library.getTapeBlock(); const graph = State.toGraph(state, tapeBlock); const mermaid = toMermaid(graph); - const nodeCount = Object.keys(graph.nodes).length; + // Exclude `isClonedHalt: true` nodes from the count — they are + // visualization-only sentinels (one per wrapper context, all mapped to the + // singleton `haltState` at runtime), not distinct runtime states. + const nodeCount = Object.values(graph.nodes).filter((n) => !n.isClonedHalt).length; sections.push(`## ${stateName}`); sections.push(''); From ea8b8e3fa5bc9cb7bc311e4f6208f443155e7ff8 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 20:08:27 +0300 Subject: [PATCH 007/118] refactor(emit)!: readable command/symbol vocabulary in edge labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Unicode/raw-literal edge-label encoding with a vocabulary that reads cleanly without out-of-band reference: Write commands (right of `→`, left of `/`): - `·` (keep) → `K` - `⌫` (erase) → `E` Literal alphabet symbols (in both read patterns and write positions): - previously bare: `0`, `X`, `$` - now quoted: `'0'`, `'X'`, `'$'` Unquoted markers (unchanged semantics, unchanged emit): - `*` per-cell ifOtherSymbol (any symbol) - `-` the tape's blank symbol Example before/after: - `s1 -- "X → ·/S" --> s0` → `s1 -- "'X' → K/S" --> s0` - `s2 -- "* → ⌫/S" --> s0` → `s2 -- "* → E/S" --> s0` - `s1 -- "1 → 0/R" --> s1` → `s1 -- "'1' → '0'/R" --> s1` - `s1 -- "0,a → 0/R,a/L" --> s2` → `s1 -- "'0','a' → '0'/R,'a'/L" --> s2` Why the change: - `K` / `E` are mnemonic command names (Keep, Erase) that a reader unfamiliar with the diagram can guess from the letter alone. The Unicode `·` / `⌫` look pretty but require a legend to interpret. - Quoting literal alphabet symbols visually distinguishes them from the convention markers `*` / `-` / `K` / `E`. Eliminates ambiguity when an alphabet contains a character that would otherwise collide with a marker (e.g. a literal `K` in the alphabet renders as `'K'`, unambiguous from the keep command `K`). Engine changes: - `decodeWriteSymbol` wraps literal symbols in `'…'`; returns `K` / `E` for the keep/erase commands. - `decodePatternDescription` wraps literal cells in `'…'`; returns `*` / `-` for the markers (no quoting). - `parseWriteSymbolLabel` strips surrounding quotes from literal symbols; recognizes `K` / `E` as keep/erase. - `parsePatternString` strips surrounding quotes from literal cells. - `escapeAlphabetSymbol` simplified — only escapes `\` and `'` now (the other reserved chars are distinguishable via quote presence). Affects: - `packages/library-binary-numbers/states.md` regenerated with the new vocabulary. - `packages/library-binary-numbers-bare/states.md` regenerated. - README "Quick Start" + Subroutine composition Mermaid blocks and their narrative legends updated. - Decoder unit tests + per-state graph fixture tests updated for the new label format. - Spec status line updated. Round-trip stability (#139) preserved — the new encoding is deterministic and reversible. --- ...026-05-20-tomermaid-wrapper-emit-design.md | 2 +- .../library-binary-numbers-bare/states.md | 32 ++-- packages/library-binary-numbers/states.md | 180 +++++++++--------- packages/machine/README.md | 30 +-- packages/machine/src/classes/State.spec.ts | 2 +- packages/machine/src/utilities/graph.spec.ts | 70 +++---- packages/machine/src/utilities/graph.ts | 38 ++-- .../src/utilities/introspection.spec.ts | 10 +- 8 files changed, 191 insertions(+), 173 deletions(-) diff --git a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md index e8a450f..6102511 100644 --- a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md +++ b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md @@ -1,6 +1,6 @@ # `toMermaid` wrapped-state emit — design comparison -**Status:** decided — **Variant X with `subgraph` overlay + `idle` entry sentinel**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a cloned `(((halt)))`. The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. A stadium-shaped `idle([idle])` sentinel + labeled dotted arrow `idle -. enter .-> sN` is always emitted to mark the initial state — replaces the old `((round))` shape convention on non-wrapped initials, so the single canonical "start here" signal is the enter arrow. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation in PR [#169](https://github.com/mellonis/turing-machine-js/pull/169). +**Status:** decided — **Variant X with `subgraph` overlay + `idle` entry sentinel + readable command/symbol vocabulary**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a cloned `(((halt)))`. The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. A stadium-shaped `idle([idle])` sentinel + labeled dotted arrow `idle -. enter .-> sN` is always emitted to mark the initial state. Edge-label vocabulary: write commands `K` = keep, `E` = erase (write blank); literal alphabet symbols wrapped in single quotes (`'X'`, `'0'`); unquoted markers `*` = any (ifOtherSymbol), `-` = blank shorthand. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation in PR [#169](https://github.com/mellonis/turing-machine-js/pull/169). **Context.** [#138](https://github.com/mellonis/turing-machine-js/issues/138) — clean up the visually-confusing Mermaid output for `withOverriddenHaltState`-wrapped states. [#139](https://github.com/mellonis/turing-machine-js/issues/139) — bytewise round-trip regression for the wrapper name accumulation, naturally fixed by whichever design we pick. diff --git a/packages/library-binary-numbers-bare/states.md b/packages/library-binary-numbers-bare/states.md index 9e2e5ca..02f4628 100644 --- a/packages/library-binary-numbers-bare/states.md +++ b/packages/library-binary-numbers-bare/states.md @@ -12,11 +12,11 @@ flowchart TD s2["plusOne"] idle([idle]) idle -. enter .-> s2 - s1 -- "1 → 0/L" --> s1 - s1 -- "0 → 1/S" --> s0 - s1 -- "- → 1/S" --> s0 - s2 -- "0|1 → ·/R" --> s2 - s2 -- "- → ·/L" --> s1 + s1 -- "'1' → '0'/L" --> s1 + s1 -- "'0' → '1'/S" --> s0 + s1 -- "- → '1'/S" --> s0 + s2 -- "'0'|'1' → K/R" --> s2 + s2 -- "- → K/L" --> s1 ``` ## minusOne @@ -31,11 +31,11 @@ flowchart TD s4["minusOne"] idle([idle]) idle -. enter .-> s4 - s3 -- "0 → 1/L" --> s3 - s3 -- "1 → 0/S" --> s0 - s3 -- "- → ·/S" --> s0 - s4 -- "0|1 → ·/R" --> s4 - s4 -- "- → ·/L" --> s3 + s3 -- "'0' → '1'/L" --> s3 + s3 -- "'1' → '0'/S" --> s0 + s3 -- "- → K/S" --> s0 + s4 -- "'0'|'1' → K/R" --> s4 + s4 -- "- → K/L" --> s3 ``` ## invertNumber @@ -49,9 +49,9 @@ flowchart TD s5["invertNumber"] idle([idle]) idle -. enter .-> s5 - s5 -- "0 → 1/R" --> s5 - s5 -- "1 → 0/R" --> s5 - s5 -- "- → ·/S" --> s0 + s5 -- "'0' → '1'/R" --> s5 + s5 -- "'1' → '0'/R" --> s5 + s5 -- "- → K/S" --> s0 ``` ## normalizeNumber @@ -65,7 +65,7 @@ flowchart TD s6["normalizeNumber"] idle([idle]) idle -. enter .-> s6 - s6 -- "0 → ⌫/R" --> s6 - s6 -- "1 → ·/S" --> s0 - s6 -- "- → 0/S" --> s0 + s6 -- "'0' → E/R" --> s6 + s6 -- "'1' → K/S" --> s0 + s6 -- "- → '0'/S" --> s0 ``` diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 7c455e0..5e5e8ce 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -11,8 +11,8 @@ flowchart TD s1["goToNumber"] idle([idle]) idle -. enter .-> s1 - s1 -- "$ → ·/S" --> s0 - s1 -- "* → ·/R" --> s1 + s1 -- "'$' → K/S" --> s0 + s1 -- "* → K/R" --> s1 ``` ## goToNextNumber @@ -27,9 +27,9 @@ flowchart TD s2["goToNextNumber"] idle([idle]) idle -. enter .-> s2 - s1 -- "$ → ·/S" --> s0 - s1 -- "* → ·/R" --> s1 - s2 -- "* → ·/R" --> s1 + s1 -- "'$' → K/S" --> s0 + s1 -- "* → K/R" --> s1 + s2 -- "* → K/R" --> s1 ``` ## goToPreviousNumber @@ -44,9 +44,9 @@ flowchart TD s4["goToPreviousNumber"] idle([idle]) idle -. enter .-> s4 - s3 -- "$ → ·/S" --> s0 - s3 -- "* → ·/L" --> s3 - s4 -- "* → ·/L" --> s3 + s3 -- "'$' → K/S" --> s0 + s3 -- "* → K/L" --> s3 + s4 -- "* → K/L" --> s3 ``` ## deleteNumber @@ -65,13 +65,13 @@ flowchart TD c7(((halt))) end idle -. enter .-> s8 - s6 -- "$ → ⌫/S" --> s0 - s6 -- "* → ⌫/R" --> s6 - s7 -- "^ → ·/S" --> c7 - s7 -- "* → ·/L" --> s7 + s6 -- "'$' → E/S" --> s0 + s6 -- "* → E/R" --> s6 + s7 -- "'^' → K/S" --> c7 + s7 -- "* → K/L" --> s7 s7 -. onHalt .-> s6 - s8 -- "^|1|0|$ → ·/S" --> s7 - s8 -- "* → ·/S" --> s0 + s8 -- "'^'|'1'|'0'|'$' → K/S" --> s7 + s8 -- "* → K/S" --> s0 ``` ## goToNumbersStart @@ -85,8 +85,8 @@ flowchart TD s5["goToNumberStart"] idle([idle]) idle -. enter .-> s5 - s5 -- "^ → ·/S" --> s0 - s5 -- "* → ·/L" --> s5 + s5 -- "'^' → K/S" --> s0 + s5 -- "* → K/L" --> s5 ``` ## invertNumber @@ -105,15 +105,15 @@ flowchart TD c10(((halt))) end idle -. enter .-> s11 - s9 -- "^ → ·/R" --> s9 - s9 -- "1 → 0/R" --> s9 - s9 -- "0 → 1/R" --> s9 - s9 -- "$ → ·/S" --> s0 - s10 -- "^ → ·/S" --> c10 - s10 -- "* → ·/L" --> s10 + s9 -- "'^' → K/R" --> s9 + s9 -- "'1' → '0'/R" --> s9 + s9 -- "'0' → '1'/R" --> s9 + s9 -- "'$' → K/S" --> s0 + s10 -- "'^' → K/S" --> c10 + s10 -- "* → K/L" --> s10 s10 -. onHalt .-> s9 - s11 -- "^|1|0|$ → ·/S" --> s10 - s11 -- "* → ·/S" --> s0 + s11 -- "'^'|'1'|'0'|'$' → K/S" --> s10 + s11 -- "* → K/S" --> s0 ``` ## normalizeNumber @@ -134,16 +134,16 @@ flowchart TD c14(((halt))) end idle -. enter .-> s15 - s1 -- "$ → ·/S" --> s0 - s1 -- "* → ·/R" --> s1 - s12 -- "- → ^/S" --> s1 - s13 -- "^|0 → ⌫/R" --> s13 - s13 -- "1|$ → ·/L" --> s12 - s14 -- "^ → ·/S" --> c14 - s14 -- "* → ·/L" --> s14 + s1 -- "'$' → K/S" --> s0 + s1 -- "* → K/R" --> s1 + s12 -- "- → '^'/S" --> s1 + s13 -- "'^'|'0' → E/R" --> s13 + s13 -- "'1'|'$' → K/L" --> s12 + s14 -- "'^' → K/S" --> c14 + s14 -- "* → K/L" --> s14 s14 -. onHalt .-> s13 - s15 -- "^|1|0|$ → ·/S" --> s14 - s15 -- "* → ·/S" --> s0 + s15 -- "'^'|'1'|'0'|'$' → K/S" --> s14 + s15 -- "* → K/S" --> s0 ``` ## plusOne @@ -160,16 +160,16 @@ flowchart TD s19["plusOne"] idle([idle]) idle -. enter .-> s19 - s16 -- "1 → 0/R" --> s16 - s16 -- "$ → ·/S" --> s0 - s17 -- "- → ^/R" --> s17 - s17 -- "1 → ·/R" --> s16 - s18 -- "0 → 1/R" --> s16 - s18 -- "1 → ·/L" --> s18 - s18 -- "^ → 1/L" --> s17 - s19 -- "^|1|0 → ·/R" --> s19 - s19 -- "$ → ·/L" --> s18 - s19 -- "* → ·/S" --> s0 + s16 -- "'1' → '0'/R" --> s16 + s16 -- "'$' → K/S" --> s0 + s17 -- "- → '^'/R" --> s17 + s17 -- "'1' → K/R" --> s16 + s18 -- "'0' → '1'/R" --> s16 + s18 -- "'1' → K/L" --> s18 + s18 -- "'^' → '1'/L" --> s17 + s19 -- "'^'|'1'|'0' → K/R" --> s19 + s19 -- "'$' → K/L" --> s18 + s19 -- "* → K/S" --> s0 ``` ## minusOne @@ -211,43 +211,43 @@ flowchart TD c22(((halt))) end idle -. enter .-> s23 - s1 -- "$ → ·/S" --> s0 - s1 -- "* → ·/R" --> s1 - s9 -- "^ → ·/R" --> s9 - s9 -- "1 → 0/R" --> s9 - s9 -- "0 → 1/R" --> s9 - s9 -- "$ → ·/S" --> s0 - s10 -- "^ → ·/S" --> c10 - s10 -- "* → ·/L" --> s10 + s1 -- "'$' → K/S" --> s0 + s1 -- "* → K/R" --> s1 + s9 -- "'^' → K/R" --> s9 + s9 -- "'1' → '0'/R" --> s9 + s9 -- "'0' → '1'/R" --> s9 + s9 -- "'$' → K/S" --> s0 + s10 -- "'^' → K/S" --> c10 + s10 -- "* → K/L" --> s10 s10 -. onHalt .-> s9 - s12 -- "- → ^/S" --> s1 - s13 -- "^|0 → ⌫/R" --> s13 - s13 -- "1|$ → ·/L" --> s12 - s14 -- "^ → ·/S" --> c14 - s14 -- "* → ·/L" --> s14 + s12 -- "- → '^'/S" --> s1 + s13 -- "'^'|'0' → E/R" --> s13 + s13 -- "'1'|'$' → K/L" --> s12 + s14 -- "'^' → K/S" --> c14 + s14 -- "* → K/L" --> s14 s14 -. onHalt .-> s13 - s15 -- "^|1|0|$ → ·/S" --> s14 - s15 -- "* → ·/S" --> s0 - s16 -- "1 → 0/R" --> s16 - s16 -- "$ → ·/S" --> s0 - s17 -- "- → ^/R" --> s17 - s17 -- "1 → ·/R" --> s16 - s18 -- "0 → 1/R" --> s16 - s18 -- "1 → ·/L" --> s18 - s18 -- "^ → 1/L" --> s17 - s20 -- "^|1|0|$ → ·/S" --> s10 - s20 -- "* → ·/S" --> c20 + s15 -- "'^'|'1'|'0'|'$' → K/S" --> s14 + s15 -- "* → K/S" --> s0 + s16 -- "'1' → '0'/R" --> s16 + s16 -- "'$' → K/S" --> s0 + s17 -- "- → '^'/R" --> s17 + s17 -- "'1' → K/R" --> s16 + s18 -- "'0' → '1'/R" --> s16 + s18 -- "'1' → K/L" --> s18 + s18 -- "'^' → '1'/L" --> s17 + s20 -- "'^'|'1'|'0'|'$' → K/S" --> s10 + s20 -- "* → K/S" --> c20 s20 -. onHalt .-> s15 - s21 -- "^|1|0 → ·/R" --> s21 - s21 -- "$ → ·/L" --> s18 - s21 -- "* → ·/S" --> c21 + s21 -- "'^'|'1'|'0' → K/R" --> s21 + s21 -- "'$' → K/L" --> s18 + s21 -- "* → K/S" --> c21 s21 -. onHalt .-> s20 - s22 -- "^|1|0|$ → ·/S" --> s10 - s22 -- "* → ·/S" --> c22 + s22 -- "'^'|'1'|'0'|'$' → K/S" --> s10 + s22 -- "* → K/S" --> c22 s22 -. onHalt .-> s21 - s23 -- "^|1|0 → ·/R" --> s23 - s23 -- "$ → ·/S" --> s22 - s23 -- "* → ·/S" --> s0 + s23 -- "'^'|'1'|'0' → K/R" --> s23 + s23 -- "'$' → K/S" --> s22 + s23 -- "* → K/S" --> s0 ``` ## minusOneFast @@ -273,21 +273,21 @@ flowchart TD c25(((halt))) end idle -. enter .-> s26 - s1 -- "$ → ·/S" --> s0 - s1 -- "* → ·/R" --> s1 - s12 -- "- → ^/S" --> s1 - s13 -- "^|0 → ⌫/R" --> s13 - s13 -- "1|$ → ·/L" --> s12 - s14 -- "^ → ·/S" --> c14 - s14 -- "* → ·/L" --> s14 + s1 -- "'$' → K/S" --> s0 + s1 -- "* → K/R" --> s1 + s12 -- "- → '^'/S" --> s1 + s13 -- "'^'|'0' → E/R" --> s13 + s13 -- "'1'|'$' → K/L" --> s12 + s14 -- "'^' → K/S" --> c14 + s14 -- "* → K/L" --> s14 s14 -. onHalt .-> s13 - s15 -- "^|1|0|$ → ·/S" --> s14 - s15 -- "* → ·/S" --> s0 - s25 -- "1 → 0/S" --> c25 - s25 -- "0 → 1/L" --> s25 - s25 -- "^ → ·/S" --> c25 + s15 -- "'^'|'1'|'0'|'$' → K/S" --> s14 + s15 -- "* → K/S" --> s0 + s25 -- "'1' → '0'/S" --> c25 + s25 -- "'0' → '1'/L" --> s25 + s25 -- "'^' → K/S" --> c25 s25 -. onHalt .-> s15 - s26 -- "^|1|0 → ·/R" --> s26 - s26 -- "$ → ·/L" --> s25 - s26 -- "* → ·/S" --> s0 + s26 -- "'^'|'1'|'0' → K/R" --> s26 + s26 -- "'$' → K/L" --> s25 + s26 -- "* → K/S" --> s0 ``` diff --git a/packages/machine/README.md b/packages/machine/README.md index 1642302..06aaa9b 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -93,12 +93,12 @@ flowchart TD s1["replaceB"] idle([idle]) idle -. enter .-> s1 - s1 -- "b → */R" --> s1 - s1 -- "- → ·/L" --> s0 - s1 -- "* → ·/R" --> s1 + s1 -- "'b' → '*'/R" --> s1 + s1 -- "- → K/L" --> s0 + s1 -- "* → K/R" --> s1 ``` -Engine notation: `read → write/move`; `·` = keep, `⌫` = erase, `*` = `ifOtherSymbol` catch-all, `-` = the blank symbol. `(((double-paren)))` = halt; `["square"]` = a regular state. The `idle([idle])` sentinel + labeled-dotted `-. enter .->` arrow marks where execution begins. Wrapped states (subroutine-shaped `[[double-walled]]`) sit inside a `subgraph w_N["halt frame"]` block — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) below. +Engine notation: `read → write/move`. Write commands: `K` = keep, `E` = erase (write the blank). Literal alphabet symbols are wrapped in single quotes: `'b'`, `'*'`, `'X'`. Unquoted markers: `*` = `ifOtherSymbol` catch-all, `-` = the blank symbol. `(((double-paren)))` = halt; `["square"]` = a regular state. The `idle([idle])` sentinel + labeled-dotted `-. enter .->` arrow marks where execution begins. Wrapped states (subroutine-shaped `[[double-walled]]`) sit inside a `subgraph w_N["halt frame"]` block — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) below. @@ -275,11 +275,11 @@ flowchart TD s1["name"] idle([idle]) idle -. enter .-> s1 - s1 -- "1 → 0/R" --> s1 - s1 -- "$ → ·/L" --> s0 + s1 -- "'1' → '0'/R" --> s1 + s1 -- "'$' → K/L" --> s0 ``` -*Edge labels are `read → write/move`. `·` is the keep-current-symbol marker (no write); `L` / `R` / `S` are head moves.* +*Edge labels are `read → write/move`. Write commands: `K` = keep (no write), `E` = erase (write the blank). Literal alphabet symbols are quoted (`'1'`, `'$'`). Movements: `L` / `R` / `S`.* > 💡 **Mermaid renders at most one edge per source/target pair.** If a state has two distinct transitions back to itself (or two parallel transitions to the same target), only one shows in the diagram. The string output is correct — this is a viewer-side limitation. For graphs with multiple parallel edges, paste the `toMermaid` output into [mermaid.live](https://mermaid.live) and switch to the `stateDiagram-v2` renderer, or post-process the output to your preferred format. @@ -316,8 +316,8 @@ flowchart TD s2["b"] idle([idle]) idle -. enter .-> s1 - s1 -- "x → ·/S" --> s2 - s2 -- "y → ·/S" --> s1 + s1 -- "'x' → K/S" --> s2 + s2 -- "'y' → K/S" --> s1 ``` `a` is round (the initial state passed to `toGraph`); `b` is square (reachable from `a`). @@ -438,8 +438,8 @@ flowchart TD s1["scanToX"] idle([idle]) idle -. enter .-> s1 - s1 -- "X → ·/S" --> s0 - s1 -- "* → ·/R" --> s1 + s1 -- "'X' → K/S" --> s0 + s1 -- "* → K/R" --> s1 ``` `toMermaid(toGraph(scanThenErase, tapeBlock))` — the wrapped composition: @@ -455,9 +455,9 @@ flowchart TD c3(((halt))) end idle -. enter .-> s3 - s2 -- "* → ⌫/S" --> s0 - s3 -- "X → ·/S" --> c3 - s3 -- "* → ·/R" --> s3 + s2 -- "* → E/S" --> s0 + s3 -- "'X' → K/S" --> c3 + s3 -- "* → K/R" --> s3 s3 -. onHalt .-> s2 ``` @@ -469,7 +469,7 @@ flowchart TD 4. **The dotted `onHalt` arrow from `[[scanToX]]` to `eraseHere`** is the wrapper's catch-and-redirect. The single arrow that crosses the subgraph border. Originates from the wrapper-node since the wrapper *is* the catcher. 5. **Real `(((halt)))` outside any subgraph** (`s0`) is the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame — here, by `eraseHere` after it erases the cell. -**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `* → ·/R` self-loops until the head sees `X`; the `X → ·/S` solid edge would normally halt — it lands on the cloned halt `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `* → ⌫/S` and halts at real `s0`. Run terminates. +**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `* → K/R` self-loops until the head sees `X`; the `'X' → K/S` solid edge would normally halt — it lands on the cloned halt `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `* → E/S` and halts at real `s0`. Run terminates. > 💡 **Round-trip caveat.** `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for simple wrappers like this one ([#139](https://github.com/mellonis/turing-machine-js/issues/139) regression). For shared-bare cases (same `State` instance used as the bare in multiple wrappers — e.g., `library-binary-numbers`'s `minusOne`), per-context duplication produces wrapper-id-dependent ordering that doesn't byte-match across rebuilds — equivalent runtime behavior, different emit-line order. diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index e8f9528..92aac9c 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -232,7 +232,7 @@ describe('State.fromGraph — cyclic override-halt chain', () => { // pointing in a loop. // Nodes need at least one transition each — State construction at pass 2 // rejects empty stateDefinitions before pass 3's cycle check would run. - const dummyTransition = {pattern: '*', command: [{symbol: '·', movement: 'S'}], nextStateId: 0, id: "test-edge"}; + const dummyTransition = {pattern: '*', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}; const graph = { initialId: 1, alphabets: [[' ', '0', '1']], diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index b7d7bc8..21e3494 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -23,8 +23,8 @@ describe('decodePatternDescription', () => { expect(decodePatternDescription('other symbol', alphabets)).toBe('*'); }); - test('literal cell', () => { - expect(decodePatternDescription('[["0"]]', alphabets)).toBe('0'); + test('literal cell wraps in single quotes', () => { + expect(decodePatternDescription('[["0"]]', alphabets)).toBe("'0'"); }); test('per-cell null → "*"', () => { @@ -35,31 +35,35 @@ describe('decodePatternDescription', () => { expect(decodePatternDescription('[[" "]]', alphabets)).toBe('-'); }); - test('multi-tape pattern joins cells with ","', () => { + test('multi-tape pattern joins quoted cells with ","', () => { expect(decodePatternDescription( '[["0","a"]]', [[' ', '0', '1'], [' ', 'a', 'b']], - )).toBe('0,a'); + )).toBe("'0','a'"); }); test('alternative patterns join with "|"', () => { - expect(decodePatternDescription('[["0"],["1"]]', alphabets)).toBe('0|1'); + expect(decodePatternDescription('[["0"],["1"]]', alphabets)).toBe("'0'|'1'"); }); - test('reserved char "*" is escaped as "\\*"', () => { - expect(decodePatternDescription('[["*"]]', [[' ', '*', 'x']])).toBe('\\*'); + test('literal "*" is quoted (distinguishes from per-cell ifOtherSymbol marker)', () => { + expect(decodePatternDescription('[["*"]]', [[' ', '*', 'x']])).toBe("'*'"); }); - test('reserved char "," is escaped as "\\,"', () => { - expect(decodePatternDescription('[[","]]', [[' ', ',', 'x']])).toBe('\\,'); + test('literal "," is quoted (distinguishes from cell separator)', () => { + expect(decodePatternDescription('[[","]]', [[' ', ',', 'x']])).toBe("','"); }); - test('reserved char "|" is escaped as "\\|"', () => { - expect(decodePatternDescription('[["|"]]', [[' ', '|', 'x']])).toBe('\\|'); + test('literal "|" is quoted (distinguishes from alternative separator)', () => { + expect(decodePatternDescription('[["|"]]', [[' ', '|', 'x']])).toBe("'|'"); }); - test('backslash is escaped as "\\\\"', () => { - expect(decodePatternDescription('[["\\\\"]]', [[' ', '\\', 'x']])).toBe('\\\\'); + test('backslash inside quotes is escaped as "\\\\"', () => { + expect(decodePatternDescription('[["\\\\"]]', [[' ', '\\', 'x']])).toBe("'\\\\'"); + }); + + test('literal apostrophe is escaped inside quotes as \\\'', () => { + expect(decodePatternDescription('[["\'"]]', [[' ', "'", 'x']])).toBe("'\\\''"); }); test('malformed JSON → returned as-is', () => { @@ -86,16 +90,16 @@ describe('decodeMovement', () => { }); describe('decodeWriteSymbol', () => { - test('symbolCommands.keep → "·"', () => { - expect(decodeWriteSymbol(symbolCommands.keep)).toBe('·'); + test('symbolCommands.keep → "K"', () => { + expect(decodeWriteSymbol(symbolCommands.keep)).toBe('K'); }); - test('symbolCommands.erase → "⌫"', () => { - expect(decodeWriteSymbol(symbolCommands.erase)).toBe('⌫'); + test('symbolCommands.erase → "E"', () => { + expect(decodeWriteSymbol(symbolCommands.erase)).toBe('E'); }); - test('literal string is returned as-is', () => { - expect(decodeWriteSymbol('0')).toBe('0'); + test('literal string is wrapped in single quotes', () => { + expect(decodeWriteSymbol('0')).toBe("'0'"); }); test('symbol with no description → "?"', () => { @@ -117,8 +121,8 @@ describe('toMermaid', () => { 1: { id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1, id: "test-edge"}, - {pattern: '1', command: [{symbol: '·', movement: 'S'}], nextStateId: 0, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -130,8 +134,8 @@ describe('toMermaid', () => { expect(out).toContain('s1["entry"]'); expect(out).toContain('idle([idle])'); expect(out).toContain('idle -. enter .-> s1'); - expect(out).toContain('s1 -- "0 → ·/R" --> s1'); - expect(out).toContain('s1 -- "1 → ·/S" --> s0'); + expect(out).toContain('s1 -- "0 → K/R" --> s1'); + expect(out).toContain('s1 -- "1 → K/S" --> s0'); }); test('renders dotted onHalt edge when overriddenHaltStateId is set', () => { @@ -318,8 +322,8 @@ describe('README example: toMermaid output is stable', () => { ' s1["name"]', ' idle([idle])', ' idle -. enter .-> s1', - ' s1 -- "1 → 0/R" --> s1', - ' s1 -- "$ → ·/L" --> s0', + " s1 -- \"'1' → '0'/R\" --> s1", + " s1 -- \"'$' → K/L\" --> s0", ].join('\n'); expect(toMermaid(State.toGraph(s, tapeBlock))).toBe(expected); @@ -359,9 +363,9 @@ describe('README diagrams: engine-generated outputs', () => { '["replaceB"]', // initial — square (no longer round in v7; idle arrow signals entry) 'idle([idle])', 'idle -. enter .->', - '"b → */R"', - '"- → ·/L"', - '"* → ·/R"', + "\"'b' → '*'/R\"", + '"- → K/L"', + '"* → K/R"', ]); }); @@ -383,8 +387,8 @@ describe('README diagrams: engine-generated outputs', () => { '["b"]', // b is reachable from a → square 'idle([idle])', 'idle -. enter .->', - '"x → ·/S"', - '"y → ·/S"', + "\"'x' → K/S\"", + "\"'y' → K/S\"", ]); }); @@ -406,8 +410,8 @@ describe('README diagrams: engine-generated outputs', () => { '["scanToX"]', // initial — square (idle arrow signals entry) 'idle([idle])', 'idle -. enter .->', - '"X → ·/S"', - '"* → ·/R"', + "\"'X' → K/S\"", + '"* → K/R"', ]); }); @@ -436,7 +440,7 @@ describe('README diagrams: engine-generated outputs', () => { '"halt frame"', // subgraph label 'idle([idle])', // pre-execution sentinel — always emitted 'idle -. enter .->', // labeled dotted enter arrow points at the initial state - '"* → ⌫/S"', // eraseHere's erase command + '"* → E/S"', // eraseHere's erase command '-. onHalt .->', // the dotted override-halt edge — wrapper's catch-and-redirect, crosses the subgraph border ]); }); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index eb21efb..ee29eaa 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -42,8 +42,8 @@ const movementDescriptionToLabel: Record = { }; const symbolCommandDescriptionToLabel: Record = { - 'keep symbol command': '·', - 'erase symbol command': '⌫', + 'keep symbol command': 'K', + 'erase symbol command': 'E', }; // Reserved characters in the encoded pattern string: @@ -51,15 +51,17 @@ const symbolCommandDescriptionToLabel: Record = { // '-' the tape's blank symbol // ',' separates per-tape cells inside one pattern // '|' separates alternative patterns -// '\\' escape prefix — to represent any of '*', '-', ',', '|', or '\\' as a -// *literal* alphabet symbol, prefix it with '\\' (e.g. '\\*' for literal '*'). +// "'" surrounds a literal alphabet symbol — e.g. `'0'` for literal `0`, +// `'X'` for literal `X`. The quoting is what visually separates literal +// symbols from the convention markers `*` / `-` and from the write +// commands `K` / `E`. +// '\\' escape prefix — to represent any of '*', '-', ',', '|', "'", or '\\' +// as a *literal* alphabet symbol *inside* the quotes (e.g. `'\''` for +// a literal apostrophe). function escapeAlphabetSymbol(s: string): string { return s .replace(/\\/g, '\\\\') - .replace(/\*/g, '\\*') - .replace(/-/g, '\\-') - .replace(/,/g, '\\,') - .replace(/\|/g, '\\|'); + .replace(/'/g, "\\'"); } export function decodePatternDescription( @@ -88,7 +90,7 @@ export function decodePatternDescription( return '-'; } - return escapeAlphabetSymbol(s); + return `'${escapeAlphabetSymbol(s)}'`; }) .join(',')) .join('|'); @@ -153,6 +155,12 @@ export function parsePatternString(s: string, alphabets: string[][]): ParsedPatt return alphabets[tapeIx]?.[0] ?? cell; } + // Literal alphabet symbols are wrapped in single quotes by + // `decodePatternDescription` — strip them on the way back. + if (cell.length >= 2 && cell.startsWith("'") && cell.endsWith("'")) { + return cell.slice(1, -1); + } + return cell; }); }); @@ -175,14 +183,20 @@ export function parseMovementLabel(label: string): symbol { } export function parseWriteSymbolLabel(label: string): string | symbol { - if (label === '·') { + if (label === 'K') { return symbolCommands.keep; } - if (label === '⌫') { + if (label === 'E') { return symbolCommands.erase; } + // Literal alphabet symbols are wrapped in single quotes by + // `decodeWriteSymbol` — strip them on the way back. + if (label.length >= 2 && label.startsWith("'") && label.endsWith("'")) { + return label.slice(1, -1); + } + return label; } @@ -193,7 +207,7 @@ export function decodeWriteSymbol(symbol: string | symbol): string { return symbolCommandDescriptionToLabel[description] ?? description; } - return symbol; + return `'${symbol}'`; } // Format converters (toMermaid / fromMermaid) live in ./graphFormats. diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index 3e98557..17347db 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -11,8 +11,8 @@ describe('summarizeGraph', () => { 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1, id: "test-edge"}, - {pattern: '1', command: [{symbol: '·', movement: 'S'}], nextStateId: 0, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -35,7 +35,7 @@ describe('summarizeGraph', () => { 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: '·', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, ], }, }, @@ -55,7 +55,7 @@ describe('summarizeGraph', () => { 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, - transitions: [{pattern: '0', command: [{symbol: '·', movement: 'S'}], nextStateId: 0, id: "test-edge"}], + transitions: [{pattern: '0', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}], }, }, }; @@ -123,7 +123,7 @@ describe('State.inspect', () => { const haltTransition = info.transitions.find((t) => t.nextState?.name === haltState.name); expect(haltTransition).toBeTruthy(); expect(haltTransition!.command[0].movement).toBe('R'); - expect(haltTransition!.command[0].symbol).toBe('1'); + expect(haltTransition!.command[0].symbol).toBe("'1'"); }); test('returns override-halt info when set', async () => { From 47c889296776c454692fca5ca769c17382939e9c Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 20:20:18 +0300 Subject: [PATCH 008/118] =?UTF-8?q?refactor(emit)!:=20distinct=20unicode?= =?UTF-8?q?=20markers=20=E2=80=94=20=E2=88=97=20for=20ifOtherSymbol,=20?= =?UTF-8?q?=E2=86=90/=E2=86=92/=E2=87=B9=20for=20movements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two readability tweaks to the edge-label vocabulary: 1. The `ifOtherSymbol` catch-all marker changes from ASCII `*` (U+002A) to `∗` (U+2217 ASTERISK OPERATOR). A taller, thinner asterisk that is visually distinct from a quoted-literal `'*'`, so an alphabet containing literal `*` (rendered as `'*'`) is unambiguous from the catch-all marker. 2. Movement labels swap their letter codes for directional Unicode arrows: - `R` → `→` (U+2192 RIGHTWARDS ARROW) - `L` → `←` (U+2190 LEFTWARDS ARROW) - `S` → `⇹` (U+21F9 LEFT RIGHT OPEN-HEADED ARROW) The arrows read at a glance and reinforce the spatial intuition of head movement. The right-arrow `→` is the same glyph used by the read-to-write separator, but the parser keys on the spaced ` → ` (the separator) versus unspaced `→` (the movement) so parsing stays unambiguous. Example edge labels: Before: s1 -- "X → ·/R" --> s0 s1 -- "* → ⌫/S" --> s0 After: s1 -- "'X' → K/→" --> s0 s1 -- "∗ → E/⇹" --> s0 Engine changes: - `decodePatternDescription` returns `∗` (not `*`) for the ifOtherSymbol marker. - `parsePatternString` recognizes `∗` (not `*`) as the marker. - `decodeMovement` returns `←` / `→` / `⇹` (not `L` / `R` / `S`). - `parseMovementLabel` recognizes the three arrows. - `escapeAlphabetSymbol` no longer needs to escape `*` / `,` / `|` (quoting is what disambiguates literals from markers now); only `\` and `'` need escaping inside the quotes. Affects: - `states.md` regenerated for both binary libraries. - README "Quick Start" + Subroutine composition Mermaid blocks + legends updated. - Decoder / parser unit tests + per-state graph fixture tests updated for the new vocabulary. Round-trip stability (#139) preserved. --- .../library-binary-numbers-bare/states.md | 32 ++-- packages/library-binary-numbers/states.md | 180 +++++++++--------- packages/machine/README.md | 30 +-- packages/machine/src/classes/State.spec.ts | 2 +- packages/machine/src/utilities/graph.spec.ts | 64 +++---- packages/machine/src/utilities/graph.ts | 37 ++-- .../src/utilities/introspection.spec.ts | 10 +- 7 files changed, 183 insertions(+), 172 deletions(-) diff --git a/packages/library-binary-numbers-bare/states.md b/packages/library-binary-numbers-bare/states.md index 02f4628..46e19b9 100644 --- a/packages/library-binary-numbers-bare/states.md +++ b/packages/library-binary-numbers-bare/states.md @@ -12,11 +12,11 @@ flowchart TD s2["plusOne"] idle([idle]) idle -. enter .-> s2 - s1 -- "'1' → '0'/L" --> s1 - s1 -- "'0' → '1'/S" --> s0 - s1 -- "- → '1'/S" --> s0 - s2 -- "'0'|'1' → K/R" --> s2 - s2 -- "- → K/L" --> s1 + s1 -- "'1' → '0'/←" --> s1 + s1 -- "'0' → '1'/⇹" --> s0 + s1 -- "- → '1'/⇹" --> s0 + s2 -- "'0'|'1' → K/→" --> s2 + s2 -- "- → K/←" --> s1 ``` ## minusOne @@ -31,11 +31,11 @@ flowchart TD s4["minusOne"] idle([idle]) idle -. enter .-> s4 - s3 -- "'0' → '1'/L" --> s3 - s3 -- "'1' → '0'/S" --> s0 - s3 -- "- → K/S" --> s0 - s4 -- "'0'|'1' → K/R" --> s4 - s4 -- "- → K/L" --> s3 + s3 -- "'0' → '1'/←" --> s3 + s3 -- "'1' → '0'/⇹" --> s0 + s3 -- "- → K/⇹" --> s0 + s4 -- "'0'|'1' → K/→" --> s4 + s4 -- "- → K/←" --> s3 ``` ## invertNumber @@ -49,9 +49,9 @@ flowchart TD s5["invertNumber"] idle([idle]) idle -. enter .-> s5 - s5 -- "'0' → '1'/R" --> s5 - s5 -- "'1' → '0'/R" --> s5 - s5 -- "- → K/S" --> s0 + s5 -- "'0' → '1'/→" --> s5 + s5 -- "'1' → '0'/→" --> s5 + s5 -- "- → K/⇹" --> s0 ``` ## normalizeNumber @@ -65,7 +65,7 @@ flowchart TD s6["normalizeNumber"] idle([idle]) idle -. enter .-> s6 - s6 -- "'0' → E/R" --> s6 - s6 -- "'1' → K/S" --> s0 - s6 -- "- → '0'/S" --> s0 + s6 -- "'0' → E/→" --> s6 + s6 -- "'1' → K/⇹" --> s0 + s6 -- "- → '0'/⇹" --> s0 ``` diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 5e5e8ce..c96389c 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -11,8 +11,8 @@ flowchart TD s1["goToNumber"] idle([idle]) idle -. enter .-> s1 - s1 -- "'$' → K/S" --> s0 - s1 -- "* → K/R" --> s1 + s1 -- "'$' → K/⇹" --> s0 + s1 -- "∗ → K/→" --> s1 ``` ## goToNextNumber @@ -27,9 +27,9 @@ flowchart TD s2["goToNextNumber"] idle([idle]) idle -. enter .-> s2 - s1 -- "'$' → K/S" --> s0 - s1 -- "* → K/R" --> s1 - s2 -- "* → K/R" --> s1 + s1 -- "'$' → K/⇹" --> s0 + s1 -- "∗ → K/→" --> s1 + s2 -- "∗ → K/→" --> s1 ``` ## goToPreviousNumber @@ -44,9 +44,9 @@ flowchart TD s4["goToPreviousNumber"] idle([idle]) idle -. enter .-> s4 - s3 -- "'$' → K/S" --> s0 - s3 -- "* → K/L" --> s3 - s4 -- "* → K/L" --> s3 + s3 -- "'$' → K/⇹" --> s0 + s3 -- "∗ → K/←" --> s3 + s4 -- "∗ → K/←" --> s3 ``` ## deleteNumber @@ -65,13 +65,13 @@ flowchart TD c7(((halt))) end idle -. enter .-> s8 - s6 -- "'$' → E/S" --> s0 - s6 -- "* → E/R" --> s6 - s7 -- "'^' → K/S" --> c7 - s7 -- "* → K/L" --> s7 + s6 -- "'$' → E/⇹" --> s0 + s6 -- "∗ → E/→" --> s6 + s7 -- "'^' → K/⇹" --> c7 + s7 -- "∗ → K/←" --> s7 s7 -. onHalt .-> s6 - s8 -- "'^'|'1'|'0'|'$' → K/S" --> s7 - s8 -- "* → K/S" --> s0 + s8 -- "'^'|'1'|'0'|'$' → K/⇹" --> s7 + s8 -- "∗ → K/⇹" --> s0 ``` ## goToNumbersStart @@ -85,8 +85,8 @@ flowchart TD s5["goToNumberStart"] idle([idle]) idle -. enter .-> s5 - s5 -- "'^' → K/S" --> s0 - s5 -- "* → K/L" --> s5 + s5 -- "'^' → K/⇹" --> s0 + s5 -- "∗ → K/←" --> s5 ``` ## invertNumber @@ -105,15 +105,15 @@ flowchart TD c10(((halt))) end idle -. enter .-> s11 - s9 -- "'^' → K/R" --> s9 - s9 -- "'1' → '0'/R" --> s9 - s9 -- "'0' → '1'/R" --> s9 - s9 -- "'$' → K/S" --> s0 - s10 -- "'^' → K/S" --> c10 - s10 -- "* → K/L" --> s10 + s9 -- "'^' → K/→" --> s9 + s9 -- "'1' → '0'/→" --> s9 + s9 -- "'0' → '1'/→" --> s9 + s9 -- "'$' → K/⇹" --> s0 + s10 -- "'^' → K/⇹" --> c10 + s10 -- "∗ → K/←" --> s10 s10 -. onHalt .-> s9 - s11 -- "'^'|'1'|'0'|'$' → K/S" --> s10 - s11 -- "* → K/S" --> s0 + s11 -- "'^'|'1'|'0'|'$' → K/⇹" --> s10 + s11 -- "∗ → K/⇹" --> s0 ``` ## normalizeNumber @@ -134,16 +134,16 @@ flowchart TD c14(((halt))) end idle -. enter .-> s15 - s1 -- "'$' → K/S" --> s0 - s1 -- "* → K/R" --> s1 - s12 -- "- → '^'/S" --> s1 - s13 -- "'^'|'0' → E/R" --> s13 - s13 -- "'1'|'$' → K/L" --> s12 - s14 -- "'^' → K/S" --> c14 - s14 -- "* → K/L" --> s14 + s1 -- "'$' → K/⇹" --> s0 + s1 -- "∗ → K/→" --> s1 + s12 -- "- → '^'/⇹" --> s1 + s13 -- "'^'|'0' → E/→" --> s13 + s13 -- "'1'|'$' → K/←" --> s12 + s14 -- "'^' → K/⇹" --> c14 + s14 -- "∗ → K/←" --> s14 s14 -. onHalt .-> s13 - s15 -- "'^'|'1'|'0'|'$' → K/S" --> s14 - s15 -- "* → K/S" --> s0 + s15 -- "'^'|'1'|'0'|'$' → K/⇹" --> s14 + s15 -- "∗ → K/⇹" --> s0 ``` ## plusOne @@ -160,16 +160,16 @@ flowchart TD s19["plusOne"] idle([idle]) idle -. enter .-> s19 - s16 -- "'1' → '0'/R" --> s16 - s16 -- "'$' → K/S" --> s0 - s17 -- "- → '^'/R" --> s17 - s17 -- "'1' → K/R" --> s16 - s18 -- "'0' → '1'/R" --> s16 - s18 -- "'1' → K/L" --> s18 - s18 -- "'^' → '1'/L" --> s17 - s19 -- "'^'|'1'|'0' → K/R" --> s19 - s19 -- "'$' → K/L" --> s18 - s19 -- "* → K/S" --> s0 + s16 -- "'1' → '0'/→" --> s16 + s16 -- "'$' → K/⇹" --> s0 + s17 -- "- → '^'/→" --> s17 + s17 -- "'1' → K/→" --> s16 + s18 -- "'0' → '1'/→" --> s16 + s18 -- "'1' → K/←" --> s18 + s18 -- "'^' → '1'/←" --> s17 + s19 -- "'^'|'1'|'0' → K/→" --> s19 + s19 -- "'$' → K/←" --> s18 + s19 -- "∗ → K/⇹" --> s0 ``` ## minusOne @@ -211,43 +211,43 @@ flowchart TD c22(((halt))) end idle -. enter .-> s23 - s1 -- "'$' → K/S" --> s0 - s1 -- "* → K/R" --> s1 - s9 -- "'^' → K/R" --> s9 - s9 -- "'1' → '0'/R" --> s9 - s9 -- "'0' → '1'/R" --> s9 - s9 -- "'$' → K/S" --> s0 - s10 -- "'^' → K/S" --> c10 - s10 -- "* → K/L" --> s10 + s1 -- "'$' → K/⇹" --> s0 + s1 -- "∗ → K/→" --> s1 + s9 -- "'^' → K/→" --> s9 + s9 -- "'1' → '0'/→" --> s9 + s9 -- "'0' → '1'/→" --> s9 + s9 -- "'$' → K/⇹" --> s0 + s10 -- "'^' → K/⇹" --> c10 + s10 -- "∗ → K/←" --> s10 s10 -. onHalt .-> s9 - s12 -- "- → '^'/S" --> s1 - s13 -- "'^'|'0' → E/R" --> s13 - s13 -- "'1'|'$' → K/L" --> s12 - s14 -- "'^' → K/S" --> c14 - s14 -- "* → K/L" --> s14 + s12 -- "- → '^'/⇹" --> s1 + s13 -- "'^'|'0' → E/→" --> s13 + s13 -- "'1'|'$' → K/←" --> s12 + s14 -- "'^' → K/⇹" --> c14 + s14 -- "∗ → K/←" --> s14 s14 -. onHalt .-> s13 - s15 -- "'^'|'1'|'0'|'$' → K/S" --> s14 - s15 -- "* → K/S" --> s0 - s16 -- "'1' → '0'/R" --> s16 - s16 -- "'$' → K/S" --> s0 - s17 -- "- → '^'/R" --> s17 - s17 -- "'1' → K/R" --> s16 - s18 -- "'0' → '1'/R" --> s16 - s18 -- "'1' → K/L" --> s18 - s18 -- "'^' → '1'/L" --> s17 - s20 -- "'^'|'1'|'0'|'$' → K/S" --> s10 - s20 -- "* → K/S" --> c20 + s15 -- "'^'|'1'|'0'|'$' → K/⇹" --> s14 + s15 -- "∗ → K/⇹" --> s0 + s16 -- "'1' → '0'/→" --> s16 + s16 -- "'$' → K/⇹" --> s0 + s17 -- "- → '^'/→" --> s17 + s17 -- "'1' → K/→" --> s16 + s18 -- "'0' → '1'/→" --> s16 + s18 -- "'1' → K/←" --> s18 + s18 -- "'^' → '1'/←" --> s17 + s20 -- "'^'|'1'|'0'|'$' → K/⇹" --> s10 + s20 -- "∗ → K/⇹" --> c20 s20 -. onHalt .-> s15 - s21 -- "'^'|'1'|'0' → K/R" --> s21 - s21 -- "'$' → K/L" --> s18 - s21 -- "* → K/S" --> c21 + s21 -- "'^'|'1'|'0' → K/→" --> s21 + s21 -- "'$' → K/←" --> s18 + s21 -- "∗ → K/⇹" --> c21 s21 -. onHalt .-> s20 - s22 -- "'^'|'1'|'0'|'$' → K/S" --> s10 - s22 -- "* → K/S" --> c22 + s22 -- "'^'|'1'|'0'|'$' → K/⇹" --> s10 + s22 -- "∗ → K/⇹" --> c22 s22 -. onHalt .-> s21 - s23 -- "'^'|'1'|'0' → K/R" --> s23 - s23 -- "'$' → K/S" --> s22 - s23 -- "* → K/S" --> s0 + s23 -- "'^'|'1'|'0' → K/→" --> s23 + s23 -- "'$' → K/⇹" --> s22 + s23 -- "∗ → K/⇹" --> s0 ``` ## minusOneFast @@ -273,21 +273,21 @@ flowchart TD c25(((halt))) end idle -. enter .-> s26 - s1 -- "'$' → K/S" --> s0 - s1 -- "* → K/R" --> s1 - s12 -- "- → '^'/S" --> s1 - s13 -- "'^'|'0' → E/R" --> s13 - s13 -- "'1'|'$' → K/L" --> s12 - s14 -- "'^' → K/S" --> c14 - s14 -- "* → K/L" --> s14 + s1 -- "'$' → K/⇹" --> s0 + s1 -- "∗ → K/→" --> s1 + s12 -- "- → '^'/⇹" --> s1 + s13 -- "'^'|'0' → E/→" --> s13 + s13 -- "'1'|'$' → K/←" --> s12 + s14 -- "'^' → K/⇹" --> c14 + s14 -- "∗ → K/←" --> s14 s14 -. onHalt .-> s13 - s15 -- "'^'|'1'|'0'|'$' → K/S" --> s14 - s15 -- "* → K/S" --> s0 - s25 -- "'1' → '0'/S" --> c25 - s25 -- "'0' → '1'/L" --> s25 - s25 -- "'^' → K/S" --> c25 + s15 -- "'^'|'1'|'0'|'$' → K/⇹" --> s14 + s15 -- "∗ → K/⇹" --> s0 + s25 -- "'1' → '0'/⇹" --> c25 + s25 -- "'0' → '1'/←" --> s25 + s25 -- "'^' → K/⇹" --> c25 s25 -. onHalt .-> s15 - s26 -- "'^'|'1'|'0' → K/R" --> s26 - s26 -- "'$' → K/L" --> s25 - s26 -- "* → K/S" --> s0 + s26 -- "'^'|'1'|'0' → K/→" --> s26 + s26 -- "'$' → K/←" --> s25 + s26 -- "∗ → K/⇹" --> s0 ``` diff --git a/packages/machine/README.md b/packages/machine/README.md index 06aaa9b..b42d1f3 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -93,12 +93,12 @@ flowchart TD s1["replaceB"] idle([idle]) idle -. enter .-> s1 - s1 -- "'b' → '*'/R" --> s1 - s1 -- "- → K/L" --> s0 - s1 -- "* → K/R" --> s1 + s1 -- "'b' → '*'/→" --> s1 + s1 -- "- → K/←" --> s0 + s1 -- "∗ → K/→" --> s1 ``` -Engine notation: `read → write/move`. Write commands: `K` = keep, `E` = erase (write the blank). Literal alphabet symbols are wrapped in single quotes: `'b'`, `'*'`, `'X'`. Unquoted markers: `*` = `ifOtherSymbol` catch-all, `-` = the blank symbol. `(((double-paren)))` = halt; `["square"]` = a regular state. The `idle([idle])` sentinel + labeled-dotted `-. enter .->` arrow marks where execution begins. Wrapped states (subroutine-shaped `[[double-walled]]`) sit inside a `subgraph w_N["halt frame"]` block — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) below. +Engine notation: `read → write/move`. Write commands: `K` = keep, `E` = erase (write the blank). Movements: `→` = right, `←` = left, `⇹` = stay. Literal alphabet symbols are wrapped in single quotes: `'b'`, `'*'`, `'X'`. Unquoted markers: `∗` (U+2217 asterisk-operator) = `ifOtherSymbol` catch-all, `-` = the blank symbol. `(((double-paren)))` = halt; `["square"]` = a regular state. The `idle([idle])` sentinel + labeled-dotted `-. enter .->` arrow marks where execution begins. Wrapped states (subroutine-shaped `[[double-walled]]`) sit inside a `subgraph w_N["halt frame"]` block — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) below. @@ -275,11 +275,11 @@ flowchart TD s1["name"] idle([idle]) idle -. enter .-> s1 - s1 -- "'1' → '0'/R" --> s1 - s1 -- "'$' → K/L" --> s0 + s1 -- "'1' → '0'/→" --> s1 + s1 -- "'$' → K/←" --> s0 ``` -*Edge labels are `read → write/move`. Write commands: `K` = keep (no write), `E` = erase (write the blank). Literal alphabet symbols are quoted (`'1'`, `'$'`). Movements: `L` / `R` / `S`.* +*Edge labels are `read → write/move`. Write commands: `K` = keep (no write), `E` = erase (write the blank). Literal alphabet symbols are quoted (`'1'`, `'$'`). Movements: `←` (left), `→` (right), `⇹` (stay).* > 💡 **Mermaid renders at most one edge per source/target pair.** If a state has two distinct transitions back to itself (or two parallel transitions to the same target), only one shows in the diagram. The string output is correct — this is a viewer-side limitation. For graphs with multiple parallel edges, paste the `toMermaid` output into [mermaid.live](https://mermaid.live) and switch to the `stateDiagram-v2` renderer, or post-process the output to your preferred format. @@ -316,8 +316,8 @@ flowchart TD s2["b"] idle([idle]) idle -. enter .-> s1 - s1 -- "'x' → K/S" --> s2 - s2 -- "'y' → K/S" --> s1 + s1 -- "'x' → K/⇹" --> s2 + s2 -- "'y' → K/⇹" --> s1 ``` `a` is round (the initial state passed to `toGraph`); `b` is square (reachable from `a`). @@ -438,8 +438,8 @@ flowchart TD s1["scanToX"] idle([idle]) idle -. enter .-> s1 - s1 -- "'X' → K/S" --> s0 - s1 -- "* → K/R" --> s1 + s1 -- "'X' → K/⇹" --> s0 + s1 -- "∗ → K/→" --> s1 ``` `toMermaid(toGraph(scanThenErase, tapeBlock))` — the wrapped composition: @@ -455,9 +455,9 @@ flowchart TD c3(((halt))) end idle -. enter .-> s3 - s2 -- "* → E/S" --> s0 - s3 -- "'X' → K/S" --> c3 - s3 -- "* → K/R" --> s3 + s2 -- "∗ → E/⇹" --> s0 + s3 -- "'X' → K/⇹" --> c3 + s3 -- "∗ → K/→" --> s3 s3 -. onHalt .-> s2 ``` @@ -469,7 +469,7 @@ flowchart TD 4. **The dotted `onHalt` arrow from `[[scanToX]]` to `eraseHere`** is the wrapper's catch-and-redirect. The single arrow that crosses the subgraph border. Originates from the wrapper-node since the wrapper *is* the catcher. 5. **Real `(((halt)))` outside any subgraph** (`s0`) is the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame — here, by `eraseHere` after it erases the cell. -**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `* → K/R` self-loops until the head sees `X`; the `'X' → K/S` solid edge would normally halt — it lands on the cloned halt `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `* → E/S` and halts at real `s0`. Run terminates. +**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `∗ → K/→` self-loops until the head sees `X`; the `'X' → K/⇹` solid edge would normally halt — it lands on the cloned halt `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `∗ → E/⇹` and halts at real `s0`. Run terminates. > 💡 **Round-trip caveat.** `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for simple wrappers like this one ([#139](https://github.com/mellonis/turing-machine-js/issues/139) regression). For shared-bare cases (same `State` instance used as the bare in multiple wrappers — e.g., `library-binary-numbers`'s `minusOne`), per-context duplication produces wrapper-id-dependent ordering that doesn't byte-match across rebuilds — equivalent runtime behavior, different emit-line order. diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index 92aac9c..7767f07 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -232,7 +232,7 @@ describe('State.fromGraph — cyclic override-halt chain', () => { // pointing in a loop. // Nodes need at least one transition each — State construction at pass 2 // rejects empty stateDefinitions before pass 3's cycle check would run. - const dummyTransition = {pattern: '*', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}; + const dummyTransition = {pattern: '∗', command: [{symbol: 'K', movement: '⇹'}], nextStateId: 0, id: "test-edge"}; const graph = { initialId: 1, alphabets: [[' ', '0', '1']], diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index 21e3494..6bd6128 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -19,16 +19,16 @@ describe('decodePatternDescription', () => { expect(decodePatternDescription(undefined, alphabets)).toBe('?'); }); - test('"other symbol" → "*" (whole-state ifOtherSymbol)', () => { - expect(decodePatternDescription('other symbol', alphabets)).toBe('*'); + test('"other symbol" → "∗" (whole-state ifOtherSymbol)', () => { + expect(decodePatternDescription('other symbol', alphabets)).toBe('∗'); }); test('literal cell wraps in single quotes', () => { expect(decodePatternDescription('[["0"]]', alphabets)).toBe("'0'"); }); - test('per-cell null → "*"', () => { - expect(decodePatternDescription('[[null]]', alphabets)).toBe('*'); + test('per-cell null → "∗"', () => { + expect(decodePatternDescription('[[null]]', alphabets)).toBe('∗'); }); test('cell equal to tape blank → "-"', () => { @@ -77,9 +77,9 @@ describe('decodeMovement', () => { }); test.each([ - [(movements.left as symbol).description, 'L'], - [(movements.right as symbol).description, 'R'], - [(movements.stay as symbol).description, 'S'], + [(movements.left as symbol).description, '←'], + [(movements.right as symbol).description, '→'], + [(movements.stay as symbol).description, '⇹'], ])('%s → %s', (description, expected) => { expect(decodeMovement(description)).toBe(expected); }); @@ -121,8 +121,8 @@ describe('toMermaid', () => { 1: { id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, - {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: '→'}], nextStateId: 1, id: "test-edge"}, + {pattern: '1', command: [{symbol: 'K', movement: '⇹'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -134,8 +134,8 @@ describe('toMermaid', () => { expect(out).toContain('s1["entry"]'); expect(out).toContain('idle([idle])'); expect(out).toContain('idle -. enter .-> s1'); - expect(out).toContain('s1 -- "0 → K/R" --> s1'); - expect(out).toContain('s1 -- "1 → K/S" --> s0'); + expect(out).toContain('s1 -- "0 → K/→" --> s1'); + expect(out).toContain('s1 -- "1 → K/⇹" --> s0'); }); test('renders dotted onHalt edge when overriddenHaltStateId is set', () => { @@ -175,7 +175,7 @@ describe('toMermaid', () => { id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [{ pattern: '0,a', - command: [{symbol: '0', movement: 'R'}, {symbol: 'a', movement: 'L'}], + command: [{symbol: '0', movement: '→'}, {symbol: 'a', movement: '←'}], nextStateId: 0, id: 'test-edge', }], @@ -183,7 +183,7 @@ describe('toMermaid', () => { }, }); - expect(out).toContain('"0,a → 0/R,a/L"'); + expect(out).toContain('"0,a → 0/→,a/←"'); }); }); @@ -203,24 +203,24 @@ describe('splitUnescaped', () => { describe('parsePatternString', () => { test('returns null for the global ifOtherSymbol marker', () => { - expect(parsePatternString('*', [[' ', '0']])).toBeNull(); + expect(parsePatternString('∗', [[' ', '0']])).toBeNull(); }); - test('per-cell `*` becomes null', () => { + test('per-cell `∗` becomes null', () => { // Multi-tape pattern where one cell is per-cell ifOtherSymbol. - expect(parsePatternString('0,*', [[' ', '0'], [' ', 'a']])).toEqual([['0', null]]); + expect(parsePatternString("'0',∗", [[' ', '0'], [' ', 'a']])).toEqual([['0', null]]); }); test('per-cell `-` becomes the tape blank symbol', () => { - expect(parsePatternString('-,a', [[' ', '0'], [' ', 'a']])).toEqual([[' ', 'a']]); + expect(parsePatternString("-,'a'", [[' ', '0'], [' ', 'a']])).toEqual([[' ', 'a']]); }); }); describe('parseMovementLabel', () => { - test('maps L/R/S to upstream movement symbols', () => { - expect(parseMovementLabel('L')).toBe(movements.left); - expect(parseMovementLabel('R')).toBe(movements.right); - expect(parseMovementLabel('S')).toBe(movements.stay); + test('maps ←/→/⇹ to upstream movement symbols', () => { + expect(parseMovementLabel('←')).toBe(movements.left); + expect(parseMovementLabel('→')).toBe(movements.right); + expect(parseMovementLabel('⇹')).toBe(movements.stay); }); test('throws on unknown label', () => { @@ -322,8 +322,8 @@ describe('README example: toMermaid output is stable', () => { ' s1["name"]', ' idle([idle])', ' idle -. enter .-> s1', - " s1 -- \"'1' → '0'/R\" --> s1", - " s1 -- \"'$' → K/L\" --> s0", + " s1 -- \"'1' → '0'/→\" --> s1", + " s1 -- \"'$' → K/←\" --> s0", ].join('\n'); expect(toMermaid(State.toGraph(s, tapeBlock))).toBe(expected); @@ -334,7 +334,7 @@ describe('README example: toMermaid output is stable', () => { // READMEs. Each test asserts the expected lines are present; we don't pin // state IDs as exact values because they auto-increment globally and depend // on test ordering. The test catches engine emit-format changes (e.g. if -// "b → */R" notation drifts) without being fragile to ID assignment. +// "b → */→" notation drifts) without being fragile to ID assignment. import Reference from '../classes/Reference'; import {ifOtherSymbol} from '../classes/State'; @@ -363,9 +363,9 @@ describe('README diagrams: engine-generated outputs', () => { '["replaceB"]', // initial — square (no longer round in v7; idle arrow signals entry) 'idle([idle])', 'idle -. enter .->', - "\"'b' → '*'/R\"", - '"- → K/L"', - '"* → K/R"', + "\"'b' → '*'/→\"", + '"- → K/←"', + '"∗ → K/→"', ]); }); @@ -387,8 +387,8 @@ describe('README diagrams: engine-generated outputs', () => { '["b"]', // b is reachable from a → square 'idle([idle])', 'idle -. enter .->', - "\"'x' → K/S\"", - "\"'y' → K/S\"", + "\"'x' → K/⇹\"", + "\"'y' → K/⇹\"", ]); }); @@ -410,8 +410,8 @@ describe('README diagrams: engine-generated outputs', () => { '["scanToX"]', // initial — square (idle arrow signals entry) 'idle([idle])', 'idle -. enter .->', - "\"'X' → K/S\"", - '"* → K/R"', + "\"'X' → K/⇹\"", + '"∗ → K/→"', ]); }); @@ -440,7 +440,7 @@ describe('README diagrams: engine-generated outputs', () => { '"halt frame"', // subgraph label 'idle([idle])', // pre-execution sentinel — always emitted 'idle -. enter .->', // labeled dotted enter arrow points at the initial state - '"* → E/S"', // eraseHere's erase command + '"∗ → E/⇹"', // eraseHere's erase command '-. onHalt .->', // the dotted override-halt edge — wrapper's catch-and-redirect, crosses the subgraph border ]); }); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index ee29eaa..6a24ae0 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -35,10 +35,16 @@ export type Graph = { nodes: Record; }; +// Head-movement labels use directional Unicode arrows for visual clarity in +// rendered Mermaid output: `←` (U+2190) left, `→` (U+2192) right, `⇹` (U+21F9 +// LEFT RIGHT OPEN-HEADED ARROW) stay. The right-arrow `→` is the same glyph +// used by the read-to-write separator in edge labels; `fromMermaid` parses +// labels via `indexOf(' → ')` (note the surrounding spaces) so the unspaced +// `→` in a movement position doesn't collide. const movementDescriptionToLabel: Record = { - 'move caret left command': 'L', - 'move caret right command': 'R', - 'do not move carer': 'S', + 'move caret left command': '←', + 'move caret right command': '→', + 'do not move carer': '⇹', }; const symbolCommandDescriptionToLabel: Record = { @@ -47,17 +53,22 @@ const symbolCommandDescriptionToLabel: Record = { }; // Reserved characters in the encoded pattern string: -// '*' per-cell ifOtherSymbol (matches any symbol on that tape) +// '∗' (U+2217 ASTERISK OPERATOR) per-cell ifOtherSymbol — matches any +// symbol on that tape. Distinct from the regular ASCII '*' (U+002A) so +// an alphabet that contains literal '*' (rendered as the quoted `'*'`) +// is unambiguously different from the catch-all marker. // '-' the tape's blank symbol // ',' separates per-tape cells inside one pattern // '|' separates alternative patterns // "'" surrounds a literal alphabet symbol — e.g. `'0'` for literal `0`, // `'X'` for literal `X`. The quoting is what visually separates literal -// symbols from the convention markers `*` / `-` and from the write +// symbols from the convention markers `∗` / `-` and from the write // commands `K` / `E`. -// '\\' escape prefix — to represent any of '*', '-', ',', '|', "'", or '\\' +// '\\' escape prefix — to represent any of '∗', '-', ',', '|', "'", or '\\' // as a *literal* alphabet symbol *inside* the quotes (e.g. `'\''` for // a literal apostrophe). +const IF_OTHER_MARKER = '∗'; + function escapeAlphabetSymbol(s: string): string { return s .replace(/\\/g, '\\\\') @@ -73,7 +84,7 @@ export function decodePatternDescription( } if (description === 'other symbol') { - return '*'; + return IF_OTHER_MARKER; } try { @@ -83,7 +94,7 @@ export function decodePatternDescription( .map((pattern) => pattern .map((s, tapeIx) => { if (s === null) { - return '*'; + return IF_OTHER_MARKER; } if (s === alphabets[tapeIx]?.[0]) { @@ -137,7 +148,7 @@ export function splitUnescaped(s: string, sep: string): string[] { } export function parsePatternString(s: string, alphabets: string[][]): ParsedPattern { - if (s === '*') { + if (s === IF_OTHER_MARKER) { return null; } @@ -147,7 +158,7 @@ export function parsePatternString(s: string, alphabets: string[][]): ParsedPatt const cells = splitUnescaped(alt, ','); return cells.map((cell, tapeIx) => { - if (cell === '*') { + if (cell === IF_OTHER_MARKER) { return null; } @@ -167,9 +178,9 @@ export function parsePatternString(s: string, alphabets: string[][]): ParsedPatt } const movementLabelToSymbol: Record = { - L: movements.left, - R: movements.right, - S: movements.stay, + '←': movements.left, + '→': movements.right, + '⇹': movements.stay, }; export function parseMovementLabel(label: string): symbol { diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index 17347db..2fcfb66 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -11,8 +11,8 @@ describe('summarizeGraph', () => { 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, - {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: '→'}], nextStateId: 1, id: "test-edge"}, + {pattern: '1', command: [{symbol: 'K', movement: '⇹'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -35,7 +35,7 @@ describe('summarizeGraph', () => { 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: '→'}], nextStateId: 1, id: "test-edge"}, ], }, }, @@ -55,7 +55,7 @@ describe('summarizeGraph', () => { 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, - transitions: [{pattern: '0', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}], + transitions: [{pattern: '0', command: [{symbol: 'K', movement: '⇹'}], nextStateId: 0, id: "test-edge"}], }, }, }; @@ -122,7 +122,7 @@ describe('State.inspect', () => { const haltTransition = info.transitions.find((t) => t.nextState?.name === haltState.name); expect(haltTransition).toBeTruthy(); - expect(haltTransition!.command[0].movement).toBe('R'); + expect(haltTransition!.command[0].movement).toBe('→'); expect(haltTransition!.command[0].symbol).toBe("'1'"); }); From 1a6d95d208437fc095582f45670c840c62163d01 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 21:28:05 +0300 Subject: [PATCH 009/118] polish(emit + docs): readable vocabulary, removed hand-drawn diagrams, dogfooded `summarize` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterative UX refinements on top of the v7 emit. The five highlights: 1. **Edge-label vocabulary refined further.** - Catch-all marker: `*` (ASCII) → `🞰` (U+1F7B0 HEAVY EIGHT BALLOON-SPOKED ASTERISK) — distinct from a quoted-literal `'*'` so an alphabet containing literal `*` stays unambiguous from the ifOtherSymbol marker. - Blank-symbol shorthand (read patterns): `-` → `B` — read-side noun for "the cell is blank"; write-side action stays `E` (Erase). Verb/noun split mirrors the read/write role. - Movements stay `L` / `R` / `S` (briefly tried arrows `←` / `→` / `⇹` and reverted — the letter codes read cleanly without overloading the read-to-write `→` separator). 2. **Stack-pushing transitions now visually distinct.** `toMermaid` emits a thick `==>` arrow when a transition's target is a wrapped state AND target ≠ source (= stack-push happens at runtime per `TuringMachine.run` line 220's `if (state !== nextState && nextState.overriddenHaltState) push(...)`). Self-loops on wrappers stay `-->` because they don't push. The reader can now scan an execution path counting thick arrows to see how deep the wrapper stack grows. 3. **Removed 4 hand-drawn pedagogical Mermaid blocks** from root and engine READMEs (Quick Start replaceB ×2, Reference cycle, Subroutine composition). The v7 engine emit is clear enough to stand on its own; the hand-drawn versions used different vocabulary and shapes and forced the reader to context-switch between two visual styles. Promoted the engine `toMermaid()` outputs out of `
` to primary illustration. Root README's stale v6 emit also updated to v7 vocabulary. 4. **`summarize().stateCount` filters cloned-halt sentinels** — matches the per-algorithm header in `library-binary-numbers/states.md` by construction. All three sources (source-comment counts, test fixture `expectedNodeCount`, states.md header) now agree. `build-states-md.mjs` dogfoods `summarizeGraph()` for its richer stats line (`N states; N transitions; N wrappers (max nesting depth N); has cycles`), replacing the manually-computed count. 5. **Docs realigned with what the emit actually does.** - README and spec drop the overstated "only dotted crosses the subgraph border" invariant — true for simple wrappers, false for compositions like `minusOne` whose bare's transitions reach helpers outside the halt frame. Solid arrows can cross; only the dotted `onHalt` carries wrapper-machinery meaning. - Engine CLAUDE.md's `toMermaid` description rewritten for v7 reality (subgraph + `[[bare]]` + cloned halt + idle sentinel + edge-label vocabulary + thick-arrow convention + per-context duplication for shared-bare). - Mermaid links added (mermaid.js.org syntax docs + mermaid-js repo). - `library-binary-numbers/src/index.ts` source-comment fixes: minusOne "17 nodes" → "15 nodes (per `summarize().stateCount`)", "four-deep subroutine chain" → "three-deep" (3 wrapper hops + 1 terminal target, not 4 wrappers). Per-algorithm counts updated where v7 emit changed them (deleteNumber/invertNumber 5→4, normalizeNumber 7→6, minusOneFast 10→8). `equivalentOn` verified unaffected — pure-runtime checker, doesn't touch the visualization or introspection surface. All 420 tests still pass. --- CLAUDE.md | 8 +- README.md | 30 +-- ...026-05-20-tomermaid-wrapper-emit-design.md | 2 +- .../library-binary-numbers-bare/states.md | 40 ++-- .../library-binary-numbers/src/graphs.spec.ts | 27 ++- packages/library-binary-numbers/src/index.ts | 16 +- packages/library-binary-numbers/states.md | 200 +++++++++--------- packages/machine/README.md | 92 ++------ packages/machine/src/classes/State.spec.ts | 2 +- packages/machine/src/utilities/graph.spec.ts | 70 +++--- packages/machine/src/utilities/graph.ts | 41 ++-- .../machine/src/utilities/graphFormats.ts | 18 +- .../src/utilities/introspection.spec.ts | 10 +- .../machine/src/utilities/introspection.ts | 10 +- scripts/build-states-md.mjs | 20 +- 15 files changed, 275 insertions(+), 311 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04ddcdd..f4442c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,13 @@ Key shapes that take reading multiple files to grasp: ### Visualization & round-trip -`packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same Graph through Mermaid flowchart syntax. The round-trip is **behaviorally** lossless (rebuilt graph runs to same outputs on same inputs — covered by `test/round-trip.spec.ts`); not bytewise lossless because state IDs auto-reassign, and for `withOverriddenHaltState` wrappers the composite name accumulates an extra `(${override.name})` wrap on each pass (composite-name format is `bare(override)` since v7 — paren-nested rather than `>`-flat, so nestings like `A(B(A))` vs `A(B)(A)` stay distinguishable). Tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138) (cleaner emit for wrapped states) + [#139](https://github.com/mellonis/turing-machine-js/issues/139) (regression test that fails until #138 is fixed). +`packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same `Graph` through [Mermaid flowchart](https://mermaid.js.org/syntax/flowchart.html) syntax (renderer: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid)). The parser is strict to the dialect `toMermaid` emits — hand-edited Mermaid with different arrow styles or shapes won't round-trip. + +**v7 emit shape** (PR #169, closes #138/#139): each `withOverriddenHaltState` wrapper collapses onto its bare's representation — `GraphNode.isWrapped: true`, no separate wrapper node in graph data. `toMermaid` wraps each `[[bare]]` (subroutine shape) + a synthesized `(((halt)))` clone (`GraphNode.isClonedHalt: true`, negative id; maps back to singleton `haltState` in `fromGraph`) inside a `subgraph w_${bareId}["halt frame"] … end` block. Dotted `onHalt` from `[[bare]]` crosses the subgraph border to the override target. An always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state (replaces v6's `((round))` shape convention). Edge labels: write commands `K` (keep) / `E` (erase), movements `L`/`R`/`S`, literal alphabet symbols quoted (`'X'`), unquoted markers `🞰` (U+1F7B0, ifOtherSymbol) and `B` (the tape's blank). Thick `==>` arrows mark transitions whose target is a wrapped state (= stack-push at runtime); regular `-->` for the rest; dotted reserved for wrapper machinery. + +**Round-trip** is **bytewise stable for simple wrappers** (regression test in `test/round-trip.spec.ts` — #139). The wrapper's composite name (e.g. `scanToX(eraseHere)`) does NOT appear as a graph node label; only the bare's name does, so `fromGraph` recomputes the composite fresh on reconstruction — no accumulation. **Shared-bare cases** (same `State` instance used as the bare of multiple wrappers, e.g. `library-binary-numbers`'s `minusOne` where `invertNumber` is both the outermost bare AND wrapper-W1's bare) use **per-context duplication** in `toGraph`: each occurrence emits a separate graph node with the wrapper's `#id`. Reconstruction produces behaviorally-equivalent State instances (not necessarily the same runtime `#id`), but bytewise stability isn't guaranteed for shared-bare since duplicate ordering depends on runtime wrapper-ids that don't survive rebuild. + +**Stats helpers** — `summarize(state, tapeBlock)` returns `{stateCount, transitionCount, compositionEdgeCount, maxCompositionDepth, selfLoopCount, hasCycles, tapeCount, alphabetCardinalities}`. `stateCount` filters out `isClonedHalt` sentinels (they're visualization-only, all map to the singleton `haltState` at runtime); matches the per-algorithm header in `library-binary-numbers/states.md` by construction. `equivalentOn(reference, candidate, cases)` is the separate behavioral-equivalence checker — runs both machines, compares outputs and per-step snapshots; lives in `./utilities/equivalence.ts`, unaffected by the visualization-layer changes above. ### Builder package diff --git a/README.md b/README.md index 8f40c35..092eb10 100644 --- a/README.md +++ b/README.md @@ -74,35 +74,21 @@ console.log(tape.symbols.join('').trim()); // a*c*a ## How it runs -Just one state — call it **S** — that loops on every cell, writing `*` whenever the head reads `b` and stopping at the trailing blank: - -```mermaid -flowchart LR - S(("**S**")) - H((("**halt**"))) - S -- "b → *, R" --> S - S -- "_ → keep, L" --> H - S -- "any other → keep, R" --> S -``` - -*Reading the labels: `read → write, move`. `_` is the blank symbol.* - -
-📊 Same diagram, generated by toMermaid() (the engine's actual output) +Just one state — call it `replaceB` — that loops on every cell, writing `*` whenever the head reads `b` and stopping at the trailing blank. The engine emits its state graph via `toMermaid(toGraph(replaceB, tapeBlock))`: ```mermaid flowchart TD %% alphabets: [[" ","a","b","c","*"]] s0(((halt))) - s1(("replaceB")) - s1 -- "b → */R" --> s1 - s1 -- "- → ·/L" --> s0 - s1 -- "* → ·/R" --> s1 + s1["replaceB"] + idle([idle]) + idle -. enter .-> s1 + s1 -- "'b' → '*'/R" --> s1 + s1 -- "B → K/L" --> s0 + s1 -- "🞰 → K/R" --> s1 ``` -Engine notation: `read → write/move`; `·` = keep, `*` = `ifOtherSymbol` catch-all, `-` = the blank symbol. - -
+Engine notation: `read → write/move`. Write commands: `K` = keep, `E` = erase (write the blank). Movements: `L` / `R` / `S`. Literal alphabet symbols are wrapped in single quotes (`'b'`, `'*'`). Unquoted markers: `🞰` (U+1F7B0) = `ifOtherSymbol` catch-all, `B` = the tape's blank symbol. `(((halt)))` = halt; `["square"]` = a regular state; the `idle([idle])` sentinel + dotted `enter` arrow marks where execution begins. (Subroutine composition adds a `subgraph` "halt frame" + `[[double-walled]]` wrapper node — see [§Subroutine composition](packages/machine/README.md#subroutine-composition-with-withoverriddenhaltstate) in the engine README.) Trace on the input tape `abcba`: diff --git a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md index 6102511..a5fb831 100644 --- a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md +++ b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md @@ -269,7 +269,7 @@ After iteration, the locked shape evolves Variant X (collapse the wrapper into t - the source of the dotted `onHalt` redirect (since the wrapper-node *is* the catcher). - **Cloned `(((halt)))` inside the subgraph** = the halt entry point within this wrapper's scope. Halt-bound transitions from the bare terminate here, not at the real halt. - **Solid arrows from `[[bare]]` to cloned halt** = the bare's structural halt-bound transitions. All stay inside the subgraph rectangle. -- **Dotted `onHalt` arrow from `[[bare]]` out of the subgraph to the override target** = the wrapper's catch-and-redirect. Exactly one per wrapper; the only arrow that crosses the rectangle border. +- **Dotted `onHalt` arrow from `[[bare]]` out of the subgraph to the override target** = the wrapper's catch-and-redirect. Exactly one per wrapper. Solid arrows from `[[bare]]` to non-halt targets can ALSO cross the rectangle border (when the bare's transitions reach external states — common in compositions like `library-binary-numbers`'s `minusOne`); those are just regular runtime transitions, not wrapper machinery. Only the dotted `onHalt` carries wrapper-machinery meaning. - **Real `(((halt)))` outside any subgraph** = the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame (the unwrapped tail of the chain). ### Single wrapper diff --git a/packages/library-binary-numbers-bare/states.md b/packages/library-binary-numbers-bare/states.md index 46e19b9..fdb91df 100644 --- a/packages/library-binary-numbers-bare/states.md +++ b/packages/library-binary-numbers-bare/states.md @@ -2,7 +2,7 @@ ## plusOne -*3 states (including `haltState`)* +*3 states; 5 transitions; has cycles* ```mermaid flowchart TD @@ -12,16 +12,16 @@ flowchart TD s2["plusOne"] idle([idle]) idle -. enter .-> s2 - s1 -- "'1' → '0'/←" --> s1 - s1 -- "'0' → '1'/⇹" --> s0 - s1 -- "- → '1'/⇹" --> s0 - s2 -- "'0'|'1' → K/→" --> s2 - s2 -- "- → K/←" --> s1 + s1 -- "'1' → '0'/L" --> s1 + s1 -- "'0' → '1'/S" --> s0 + s1 -- "B → '1'/S" --> s0 + s2 -- "'0'|'1' → K/R" --> s2 + s2 -- "B → K/L" --> s1 ``` ## minusOne -*3 states (including `haltState`)* +*3 states; 5 transitions; has cycles* ```mermaid flowchart TD @@ -31,16 +31,16 @@ flowchart TD s4["minusOne"] idle([idle]) idle -. enter .-> s4 - s3 -- "'0' → '1'/←" --> s3 - s3 -- "'1' → '0'/⇹" --> s0 - s3 -- "- → K/⇹" --> s0 - s4 -- "'0'|'1' → K/→" --> s4 - s4 -- "- → K/←" --> s3 + s3 -- "'0' → '1'/L" --> s3 + s3 -- "'1' → '0'/S" --> s0 + s3 -- "B → K/S" --> s0 + s4 -- "'0'|'1' → K/R" --> s4 + s4 -- "B → K/L" --> s3 ``` ## invertNumber -*2 states (including `haltState`)* +*2 states; 3 transitions; has cycles* ```mermaid flowchart TD @@ -49,14 +49,14 @@ flowchart TD s5["invertNumber"] idle([idle]) idle -. enter .-> s5 - s5 -- "'0' → '1'/→" --> s5 - s5 -- "'1' → '0'/→" --> s5 - s5 -- "- → K/⇹" --> s0 + s5 -- "'0' → '1'/R" --> s5 + s5 -- "'1' → '0'/R" --> s5 + s5 -- "B → K/S" --> s0 ``` ## normalizeNumber -*2 states (including `haltState`)* +*2 states; 3 transitions; has cycles* ```mermaid flowchart TD @@ -65,7 +65,7 @@ flowchart TD s6["normalizeNumber"] idle([idle]) idle -. enter .-> s6 - s6 -- "'0' → E/→" --> s6 - s6 -- "'1' → K/⇹" --> s0 - s6 -- "- → '0'/⇹" --> s0 + s6 -- "'0' → E/R" --> s6 + s6 -- "'1' → K/S" --> s0 + s6 -- "B → '0'/S" --> s0 ``` diff --git a/packages/library-binary-numbers/src/graphs.spec.ts b/packages/library-binary-numbers/src/graphs.spec.ts index 4139787..ff6b3e4 100644 --- a/packages/library-binary-numbers/src/graphs.spec.ts +++ b/packages/library-binary-numbers/src/graphs.spec.ts @@ -1,25 +1,22 @@ -import {State, fromMermaid, toMermaid} from '@turing-machine-js/machine'; +import {State, fromMermaid, summarizeGraph, toMermaid} from '@turing-machine-js/machine'; import binaryNumbers from './index'; -// Per-state node counts pinned from the source comments above each declaration -// in `index.ts`. Each count includes haltState plus any v7 cloned-halt nodes -// synthesized by `toGraph` (one per `withOverriddenHaltState` wrapper context). -// For single-wrapper algorithms the count is unchanged from v6 — the wrapper -// node disappears (collapsed into its bare) but a cloned halt appears, netting -// to zero. For shared-bare cases like `minusOne` (where the same bare appears -// in multiple wrapper contexts via per-context duplication), the count grows -// by `wrapperCount - 1` relative to v6. +// Per-state counts pinned from the source comments above each declaration in +// `index.ts`. These are runtime state counts (per `summarize().stateCount`), +// which exclude the `isClonedHalt` visualization sentinels v7 synthesizes +// inside each `halt frame` subgraph. The states.md per-algorithm header uses +// the same definition, so all three sources agree by construction. const expectedNodeCount: Record = { goToNumber: 2, goToNextNumber: 3, goToPreviousNumber: 3, goToNumbersStart: 2, - deleteNumber: 5, - invertNumber: 5, - normalizeNumber: 7, + deleteNumber: 4, + invertNumber: 4, + normalizeNumber: 6, plusOne: 5, - minusOne: 20, - minusOneFast: 10, + minusOne: 15, + minusOneFast: 8, }; const stateNames = Object.keys(expectedNodeCount) as Array; @@ -31,7 +28,7 @@ describe('library-binary-numbers state graphs', () => { const tapeBlock = binaryNumbers.getTapeBlock(); const graph = State.toGraph(binaryNumbers.states[name], tapeBlock); - expect(Object.keys(graph.nodes)).toHaveLength(expectedNodeCount[name]); + expect(summarizeGraph(graph).stateCount).toBe(expectedNodeCount[name]); }, ); diff --git a/packages/library-binary-numbers/src/index.ts b/packages/library-binary-numbers/src/index.ts index 009713f..8702973 100644 --- a/packages/library-binary-numbers/src/index.ts +++ b/packages/library-binary-numbers/src/index.ts @@ -85,7 +85,7 @@ const goToNumbersStart = new State({ }, }, 'goToNumberStart'); -// deleteNumber — 5 nodes +// deleteNumber — 4 nodes // // Composition: go to the number's '^', then sweep right erasing every cell // (digits, '^', '$') until the number is gone. Implemented as @@ -115,7 +115,7 @@ const deleteNumber = new State({ }, }, 'deleteNumber'); -// invertNumber — 5 nodes +// invertNumber — 4 nodes // // Composition: go to '^', then sweep right flipping each bit until '$'. // Same shape as deleteNumber (goToNumbersStart.withOverriddenHaltState(...)) but @@ -152,7 +152,7 @@ const invertNumber = new State({ }, }, 'invertNumber'); -// normalizeNumber — 7 nodes +// normalizeNumber — 6 nodes // // Strips leading zeros by erasing them and re-planting '^' just before the first // '1' (or before '$' if the entire number was zero — preserving "0" as "^$"). @@ -268,16 +268,18 @@ const plusOne = new State({ }, }, 'plusOne'); -// minusOne — 17 nodes (the largest in this library) +// minusOne — 15 nodes (the largest in this library, per `summarize().stateCount`) // // Computes x − 1 via the two's-complement identity: x − 1 == ~(~x + 1) // (every step is a state we already have), composed with three nested // withOverriddenHaltState calls to chain invert → plusOne → invert → normalize. +// The chain has 4 state names but 3 wrapper hops — `normalizeNumber` at the +// inner end is the terminal override target, not another wrapper level. // // This is *deliberately* the heavy version. It exists side-by-side with -// minusOneFast (10 nodes, direct borrow) to make the cost of "compose existing +// minusOneFast (8 nodes, direct borrow) to make the cost of "compose existing // pieces" vs "write a dedicated algorithm" visible. See ../states.md for the -// dotted onHalt edges that show the four-deep subroutine chain. +// dotted onHalt edges that show the three-deep wrapper chain. const minusOne = new State({ [symbol('^10')]: { command: { @@ -299,7 +301,7 @@ const minusOne = new State({ }, }, 'minusOne'); -// minusOneFast — 10 nodes (direct borrow propagation) +// minusOneFast — 8 nodes (direct borrow propagation) // // Walks left from the LSB: 0→1 keeps borrowing; 1→0 stops; ^ is underflow. // Falls through to normalizeNumber to strip the leading zero introduced when the diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index c96389c..7040cc6 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -2,7 +2,7 @@ ## goToNumber -*2 states (including `haltState`)* +*2 states; 2 transitions; has cycles* ```mermaid flowchart TD @@ -11,13 +11,13 @@ flowchart TD s1["goToNumber"] idle([idle]) idle -. enter .-> s1 - s1 -- "'$' → K/⇹" --> s0 - s1 -- "∗ → K/→" --> s1 + s1 -- "'$' → K/S" --> s0 + s1 -- "🞰 → K/R" --> s1 ``` ## goToNextNumber -*3 states (including `haltState`)* +*3 states; 3 transitions; has cycles* ```mermaid flowchart TD @@ -27,14 +27,14 @@ flowchart TD s2["goToNextNumber"] idle([idle]) idle -. enter .-> s2 - s1 -- "'$' → K/⇹" --> s0 - s1 -- "∗ → K/→" --> s1 - s2 -- "∗ → K/→" --> s1 + s1 -- "'$' → K/S" --> s0 + s1 -- "🞰 → K/R" --> s1 + s2 -- "🞰 → K/R" --> s1 ``` ## goToPreviousNumber -*3 states (including `haltState`)* +*3 states; 3 transitions; has cycles* ```mermaid flowchart TD @@ -44,14 +44,14 @@ flowchart TD s4["goToPreviousNumber"] idle([idle]) idle -. enter .-> s4 - s3 -- "'$' → K/⇹" --> s0 - s3 -- "∗ → K/←" --> s3 - s4 -- "∗ → K/←" --> s3 + s3 -- "'$' → K/S" --> s0 + s3 -- "🞰 → K/L" --> s3 + s4 -- "🞰 → K/L" --> s3 ``` ## deleteNumber -*4 states (including `haltState`)* +*4 states; 6 transitions; 1 wrapper (max nesting depth 1); has cycles* ```mermaid flowchart TD @@ -65,18 +65,18 @@ flowchart TD c7(((halt))) end idle -. enter .-> s8 - s6 -- "'$' → E/⇹" --> s0 - s6 -- "∗ → E/→" --> s6 - s7 -- "'^' → K/⇹" --> c7 - s7 -- "∗ → K/←" --> s7 + s6 -- "'$' → E/S" --> s0 + s6 -- "🞰 → E/R" --> s6 + s7 -- "'^' → K/S" --> c7 + s7 -- "🞰 → K/L" --> s7 s7 -. onHalt .-> s6 - s8 -- "'^'|'1'|'0'|'$' → K/⇹" --> s7 - s8 -- "∗ → K/⇹" --> s0 + s8 == "'^'|'1'|'0'|'$' → K/S" ==> s7 + s8 -- "🞰 → K/S" --> s0 ``` ## goToNumbersStart -*2 states (including `haltState`)* +*2 states; 2 transitions; has cycles* ```mermaid flowchart TD @@ -85,13 +85,13 @@ flowchart TD s5["goToNumberStart"] idle([idle]) idle -. enter .-> s5 - s5 -- "'^' → K/⇹" --> s0 - s5 -- "∗ → K/←" --> s5 + s5 -- "'^' → K/S" --> s0 + s5 -- "🞰 → K/L" --> s5 ``` ## invertNumber -*4 states (including `haltState`)* +*4 states; 8 transitions; 1 wrapper (max nesting depth 1); has cycles* ```mermaid flowchart TD @@ -105,20 +105,20 @@ flowchart TD c10(((halt))) end idle -. enter .-> s11 - s9 -- "'^' → K/→" --> s9 - s9 -- "'1' → '0'/→" --> s9 - s9 -- "'0' → '1'/→" --> s9 - s9 -- "'$' → K/⇹" --> s0 - s10 -- "'^' → K/⇹" --> c10 - s10 -- "∗ → K/←" --> s10 + s9 -- "'^' → K/R" --> s9 + s9 -- "'1' → '0'/R" --> s9 + s9 -- "'0' → '1'/R" --> s9 + s9 -- "'$' → K/S" --> s0 + s10 -- "'^' → K/S" --> c10 + s10 -- "🞰 → K/L" --> s10 s10 -. onHalt .-> s9 - s11 -- "'^'|'1'|'0'|'$' → K/⇹" --> s10 - s11 -- "∗ → K/⇹" --> s0 + s11 == "'^'|'1'|'0'|'$' → K/S" ==> s10 + s11 -- "🞰 → K/S" --> s0 ``` ## normalizeNumber -*6 states (including `haltState`)* +*6 states; 9 transitions; 1 wrapper (max nesting depth 1); has cycles* ```mermaid flowchart TD @@ -134,21 +134,21 @@ flowchart TD c14(((halt))) end idle -. enter .-> s15 - s1 -- "'$' → K/⇹" --> s0 - s1 -- "∗ → K/→" --> s1 - s12 -- "- → '^'/⇹" --> s1 - s13 -- "'^'|'0' → E/→" --> s13 - s13 -- "'1'|'$' → K/←" --> s12 - s14 -- "'^' → K/⇹" --> c14 - s14 -- "∗ → K/←" --> s14 + s1 -- "'$' → K/S" --> s0 + s1 -- "🞰 → K/R" --> s1 + s12 -- "B → '^'/S" --> s1 + s13 -- "'^'|'0' → E/R" --> s13 + s13 -- "'1'|'$' → K/L" --> s12 + s14 -- "'^' → K/S" --> c14 + s14 -- "🞰 → K/L" --> s14 s14 -. onHalt .-> s13 - s15 -- "'^'|'1'|'0'|'$' → K/⇹" --> s14 - s15 -- "∗ → K/⇹" --> s0 + s15 == "'^'|'1'|'0'|'$' → K/S" ==> s14 + s15 -- "🞰 → K/S" --> s0 ``` ## plusOne -*5 states (including `haltState`)* +*5 states; 10 transitions; has cycles* ```mermaid flowchart TD @@ -160,21 +160,21 @@ flowchart TD s19["plusOne"] idle([idle]) idle -. enter .-> s19 - s16 -- "'1' → '0'/→" --> s16 - s16 -- "'$' → K/⇹" --> s0 - s17 -- "- → '^'/→" --> s17 - s17 -- "'1' → K/→" --> s16 - s18 -- "'0' → '1'/→" --> s16 - s18 -- "'1' → K/←" --> s18 - s18 -- "'^' → '1'/←" --> s17 - s19 -- "'^'|'1'|'0' → K/→" --> s19 - s19 -- "'$' → K/←" --> s18 - s19 -- "∗ → K/⇹" --> s0 + s16 -- "'1' → '0'/R" --> s16 + s16 -- "'$' → K/S" --> s0 + s17 -- "B → '^'/R" --> s17 + s17 -- "'1' → K/R" --> s16 + s18 -- "'0' → '1'/R" --> s16 + s18 -- "'1' → K/L" --> s18 + s18 -- "'^' → '1'/L" --> s17 + s19 -- "'^'|'1'|'0' → K/R" --> s19 + s19 -- "'$' → K/L" --> s18 + s19 -- "🞰 → K/S" --> s0 ``` ## minusOne -*15 states (including `haltState`)* +*15 states; 32 transitions; 5 wrappers (max nesting depth 3); has cycles* ```mermaid flowchart TD @@ -211,48 +211,48 @@ flowchart TD c22(((halt))) end idle -. enter .-> s23 - s1 -- "'$' → K/⇹" --> s0 - s1 -- "∗ → K/→" --> s1 - s9 -- "'^' → K/→" --> s9 - s9 -- "'1' → '0'/→" --> s9 - s9 -- "'0' → '1'/→" --> s9 - s9 -- "'$' → K/⇹" --> s0 - s10 -- "'^' → K/⇹" --> c10 - s10 -- "∗ → K/←" --> s10 + s1 -- "'$' → K/S" --> s0 + s1 -- "🞰 → K/R" --> s1 + s9 -- "'^' → K/R" --> s9 + s9 -- "'1' → '0'/R" --> s9 + s9 -- "'0' → '1'/R" --> s9 + s9 -- "'$' → K/S" --> s0 + s10 -- "'^' → K/S" --> c10 + s10 -- "🞰 → K/L" --> s10 s10 -. onHalt .-> s9 - s12 -- "- → '^'/⇹" --> s1 - s13 -- "'^'|'0' → E/→" --> s13 - s13 -- "'1'|'$' → K/←" --> s12 - s14 -- "'^' → K/⇹" --> c14 - s14 -- "∗ → K/←" --> s14 + s12 -- "B → '^'/S" --> s1 + s13 -- "'^'|'0' → E/R" --> s13 + s13 -- "'1'|'$' → K/L" --> s12 + s14 -- "'^' → K/S" --> c14 + s14 -- "🞰 → K/L" --> s14 s14 -. onHalt .-> s13 - s15 -- "'^'|'1'|'0'|'$' → K/⇹" --> s14 - s15 -- "∗ → K/⇹" --> s0 - s16 -- "'1' → '0'/→" --> s16 - s16 -- "'$' → K/⇹" --> s0 - s17 -- "- → '^'/→" --> s17 - s17 -- "'1' → K/→" --> s16 - s18 -- "'0' → '1'/→" --> s16 - s18 -- "'1' → K/←" --> s18 - s18 -- "'^' → '1'/←" --> s17 - s20 -- "'^'|'1'|'0'|'$' → K/⇹" --> s10 - s20 -- "∗ → K/⇹" --> c20 + s15 == "'^'|'1'|'0'|'$' → K/S" ==> s14 + s15 -- "🞰 → K/S" --> s0 + s16 -- "'1' → '0'/R" --> s16 + s16 -- "'$' → K/S" --> s0 + s17 -- "B → '^'/R" --> s17 + s17 -- "'1' → K/R" --> s16 + s18 -- "'0' → '1'/R" --> s16 + s18 -- "'1' → K/L" --> s18 + s18 -- "'^' → '1'/L" --> s17 + s20 == "'^'|'1'|'0'|'$' → K/S" ==> s10 + s20 -- "🞰 → K/S" --> c20 s20 -. onHalt .-> s15 - s21 -- "'^'|'1'|'0' → K/→" --> s21 - s21 -- "'$' → K/←" --> s18 - s21 -- "∗ → K/⇹" --> c21 + s21 -- "'^'|'1'|'0' → K/R" --> s21 + s21 -- "'$' → K/L" --> s18 + s21 -- "🞰 → K/S" --> c21 s21 -. onHalt .-> s20 - s22 -- "'^'|'1'|'0'|'$' → K/⇹" --> s10 - s22 -- "∗ → K/⇹" --> c22 + s22 == "'^'|'1'|'0'|'$' → K/S" ==> s10 + s22 -- "🞰 → K/S" --> c22 s22 -. onHalt .-> s21 - s23 -- "'^'|'1'|'0' → K/→" --> s23 - s23 -- "'$' → K/⇹" --> s22 - s23 -- "∗ → K/⇹" --> s0 + s23 -- "'^'|'1'|'0' → K/R" --> s23 + s23 == "'$' → K/S" ==> s22 + s23 -- "🞰 → K/S" --> s0 ``` ## minusOneFast -*8 states (including `haltState`)* +*8 states; 15 transitions; 2 wrappers (max nesting depth 1); has cycles* ```mermaid flowchart TD @@ -273,21 +273,21 @@ flowchart TD c25(((halt))) end idle -. enter .-> s26 - s1 -- "'$' → K/⇹" --> s0 - s1 -- "∗ → K/→" --> s1 - s12 -- "- → '^'/⇹" --> s1 - s13 -- "'^'|'0' → E/→" --> s13 - s13 -- "'1'|'$' → K/←" --> s12 - s14 -- "'^' → K/⇹" --> c14 - s14 -- "∗ → K/←" --> s14 + s1 -- "'$' → K/S" --> s0 + s1 -- "🞰 → K/R" --> s1 + s12 -- "B → '^'/S" --> s1 + s13 -- "'^'|'0' → E/R" --> s13 + s13 -- "'1'|'$' → K/L" --> s12 + s14 -- "'^' → K/S" --> c14 + s14 -- "🞰 → K/L" --> s14 s14 -. onHalt .-> s13 - s15 -- "'^'|'1'|'0'|'$' → K/⇹" --> s14 - s15 -- "∗ → K/⇹" --> s0 - s25 -- "'1' → '0'/⇹" --> c25 - s25 -- "'0' → '1'/←" --> s25 - s25 -- "'^' → K/⇹" --> c25 + s15 == "'^'|'1'|'0'|'$' → K/S" ==> s14 + s15 -- "🞰 → K/S" --> s0 + s25 -- "'1' → '0'/S" --> c25 + s25 -- "'0' → '1'/L" --> s25 + s25 -- "'^' → K/S" --> c25 s25 -. onHalt .-> s15 - s26 -- "'^'|'1'|'0' → K/→" --> s26 - s26 -- "'$' → K/←" --> s25 - s26 -- "∗ → K/⇹" --> s0 + s26 -- "'^'|'1'|'0' → K/R" --> s26 + s26 == "'$' → K/L" ==> s25 + s26 -- "🞰 → K/S" --> s0 ``` diff --git a/packages/machine/README.md b/packages/machine/README.md index b42d1f3..065e109 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -70,21 +70,7 @@ await machine.run({ console.log(tape.symbols.join('').trim()); // a*c*a ``` -The state graph for the example above: - -```mermaid -flowchart LR - S(("**replaceB**")) - H((("**halt**"))) - S -- "b → *, R" --> S - S -- "_ → keep, L" --> H - S -- "any other → keep, R" --> S -``` - -*Reading the labels: `read → write, move`. `_` is the blank symbol.* - -
-📊 Same diagram, generated by toMermaid() (the engine's actual output) +The state graph for the example above (`toMermaid(toGraph(replaceB, tapeBlock))`): ```mermaid flowchart TD @@ -93,14 +79,14 @@ flowchart TD s1["replaceB"] idle([idle]) idle -. enter .-> s1 - s1 -- "'b' → '*'/→" --> s1 - s1 -- "- → K/←" --> s0 - s1 -- "∗ → K/→" --> s1 + s1 -- "'b' → '*'/R" --> s1 + s1 -- "B → K/L" --> s0 + s1 -- "🞰 → K/R" --> s1 ``` -Engine notation: `read → write/move`. Write commands: `K` = keep, `E` = erase (write the blank). Movements: `→` = right, `←` = left, `⇹` = stay. Literal alphabet symbols are wrapped in single quotes: `'b'`, `'*'`, `'X'`. Unquoted markers: `∗` (U+2217 asterisk-operator) = `ifOtherSymbol` catch-all, `-` = the blank symbol. `(((double-paren)))` = halt; `["square"]` = a regular state. The `idle([idle])` sentinel + labeled-dotted `-. enter .->` arrow marks where execution begins. Wrapped states (subroutine-shaped `[[double-walled]]`) sit inside a `subgraph w_N["halt frame"]` block — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) below. +Engine notation: `read → write/move`. Write commands: `K` = keep, `E` = erase (write the blank). Movements: `L` = left, `R` = right, `S` = stay. Literal alphabet symbols are wrapped in single quotes: `'b'`, `'*'`, `'X'`. Unquoted markers: `🞰` (U+1F7B0 heavy-eight-balloon-spoked-asterisk) = `ifOtherSymbol` catch-all, `B` = the tape's blank symbol (a literal `B` in the alphabet appears as the quoted `'B'`, so the marker stays unambiguous). `(((double-paren)))` = halt; `["square"]` = a regular state. The `idle([idle])` sentinel + labeled-dotted `-. enter .->` arrow marks where execution begins. Wrapped states (subroutine-shaped `[[double-walled]]`) sit inside a `subgraph w_N["halt frame"]` block — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) below. **Arrow styles** between states: regular `-->` for plain transitions; thick `==>` for transitions whose target is a wrapped state (= stack-push happens at runtime); dotted `-. onHalt .->` for the wrapper's catch-and-redirect. -
+The shapes and arrow styles above are standard [Mermaid flowchart syntax](https://mermaid.js.org/syntax/flowchart.html); any tool that renders Mermaid (GitHub preview, IDE plugins, [mermaid-js](https://github.com/mermaid-js/mermaid) client-side) will paint these diagrams the same way. A `State` is keyed by JS `Symbol`s returned from `tapeBlock.symbol(pattern)` — the pattern lists the expected symbol under each tape's head. Sentinels and constants used throughout: [`ifOtherSymbol`](#ifothersymbol) is the fallback key when nothing else matches; transitioning into [`haltState`](#haltstate) stops the run; [`movements`](#movements)`.{left,right,stay}` direct head moves; [`symbolCommands`](#symbolcommands)`.{keep,erase}` are write shortcuts. Full definitions in [§Special objects](#special-objects). @@ -275,11 +261,11 @@ flowchart TD s1["name"] idle([idle]) idle -. enter .-> s1 - s1 -- "'1' → '0'/→" --> s1 - s1 -- "'$' → K/←" --> s0 + s1 -- "'1' → '0'/R" --> s1 + s1 -- "'$' → K/L" --> s0 ``` -*Edge labels are `read → write/move`. Write commands: `K` = keep (no write), `E` = erase (write the blank). Literal alphabet symbols are quoted (`'1'`, `'$'`). Movements: `←` (left), `→` (right), `⇹` (stay).* +*Edge labels are `read → write/move`. Write commands: `K` = keep (no write), `E` = erase (write the blank). Literal alphabet symbols are quoted (`'1'`, `'$'`). Movements: `L` (left), `R` (right), `S` (stay).* > 💡 **Mermaid renders at most one edge per source/target pair.** If a state has two distinct transitions back to itself (or two parallel transitions to the same target), only one shows in the diagram. The string output is correct — this is a viewer-side limitation. For graphs with multiple parallel edges, paste the `toMermaid` output into [mermaid.live](https://mermaid.live) and switch to the `stateDiagram-v2` renderer, or post-process the output to your preferred format. @@ -296,18 +282,7 @@ const b = new State({ [symbol(['y'])]: { nextState: a } }, 'b'); ref.bind(b); // a's transition now resolves to b at run time ``` -The resulting cycle: - -```mermaid -flowchart LR - a(("**a**")) - b(("**b**")) - a -- "x" --> b - b -- "y" --> a -``` - -
-📊 Same diagram, generated by toMermaid() +The resulting cycle (`toMermaid(toGraph(a, tapeBlock))`): ```mermaid flowchart TD @@ -316,13 +291,11 @@ flowchart TD s2["b"] idle([idle]) idle -. enter .-> s1 - s1 -- "'x' → K/⇹" --> s2 - s2 -- "'y' → K/⇹" --> s1 + s1 -- "'x' → K/S" --> s2 + s2 -- "'y' → K/S" --> s1 ``` -`a` is round (the initial state passed to `toGraph`); `b` is square (reachable from `a`). - -
+`idle -. enter .->` points at the initial state passed to `toGraph` (`a` here); `b` is reachable from `a` via the bound `Reference`. `reference.ref` returns the bound state and throws if the reference is still unbound when the machine runs. `bind()` is sticky — the first call wins; subsequent calls are silent no-ops that return the existing binding. @@ -406,29 +379,6 @@ console.log(tape.symbols.join('')); // "ab ba" — the X at index 2 is gone, hea What changes between *running `scanToX` standalone* and *running the composed wrapper*: -```mermaid -flowchart LR - subgraph standalone["scanToX (standalone) — halts at X"] - direction LR - a1(("scanToX")) - h1(((halt))) - a1 -- "X → keep, S" --> h1 - a1 -- "any other → keep, R" --> a1 - end - subgraph composed["scanToX.withOverriddenHaltState(eraseHere) — halt is intercepted"] - direction LR - a2(("scanToX")) - b2(("eraseHere")) - h2(((halt))) - a2 -. "X → keep, S
intercepted" .-> b2 - a2 -- "any other → keep, R" --> a2 - b2 -- "any → erase, S" --> h2 - end -``` - -
-📊 Both diagrams, generated by toMermaid() - `toMermaid(toGraph(scanToX, tapeBlock))` — the standalone subroutine: ```mermaid @@ -438,8 +388,8 @@ flowchart TD s1["scanToX"] idle([idle]) idle -. enter .-> s1 - s1 -- "'X' → K/⇹" --> s0 - s1 -- "∗ → K/→" --> s1 + s1 -- "'X' → K/S" --> s0 + s1 -- "🞰 → K/R" --> s1 ``` `toMermaid(toGraph(scanThenErase, tapeBlock))` — the wrapped composition: @@ -455,9 +405,9 @@ flowchart TD c3(((halt))) end idle -. enter .-> s3 - s2 -- "∗ → E/⇹" --> s0 - s3 -- "'X' → K/⇹" --> c3 - s3 -- "∗ → K/→" --> s3 + s2 -- "🞰 → E/S" --> s0 + s3 -- "'X' → K/S" --> c3 + s3 -- "🞰 → K/R" --> s3 s3 -. onHalt .-> s2 ``` @@ -466,15 +416,13 @@ flowchart TD 1. **The subgraph rectangle labeled `"halt frame"`** is the wrapper's runtime scope — while execution is "inside" this rectangle, the override target (`eraseHere`) sits on the runtime stack waiting to catch a halt. Visual-only; it does not mutate any edges. 2. **`[[scanToX]]` (Mermaid subroutine / double-walled-rectangle shape)** is the wrapper node. It's both the runtime entry point (execution starts here when entering the wrapper) AND the source of the dotted `onHalt` redirect. The wrapper's composite name (`scanToX(eraseHere)`) is computed at runtime via `state.name` but does not appear as a graph node label — only the bare's name is in the graph. 3. **The cloned `(((halt)))` inside the subgraph** (`c3` here) is where the bare's halt-bound transitions land *inside* the wrapper's scope. `haltState` is a runtime singleton; the cloned node is a teaching aid showing "halt is caught here, not at the real terminus." Solid arrows from the bare to the cloned halt all stay inside the rectangle. -4. **The dotted `onHalt` arrow from `[[scanToX]]` to `eraseHere`** is the wrapper's catch-and-redirect. The single arrow that crosses the subgraph border. Originates from the wrapper-node since the wrapper *is* the catcher. +4. **The dotted `onHalt` arrow from `[[scanToX]]` to `eraseHere`** is the wrapper's catch-and-redirect. Originates from the wrapper-node since the wrapper *is* the catcher. Solid arrows from `[[scanToX]]` to other states can also cross the subgraph border — those are just regular runtime transitions whose target happens to be drawn outside this rectangle (only the dotted `onHalt` carries wrapper-machinery meaning). In larger compositions (`library-binary-numbers`'s `minusOne`), solid transitions whose target is *itself* a wrapped state render as a **thick `==>` arrow** instead of `-->` — that's the visual signal for "this transition enters a halt frame, pushing the override onto the runtime stack." Stack-growth structure is then scannable from the diagram: count thick arrows along an execution path to see how deep the stack gets. 5. **Real `(((halt)))` outside any subgraph** (`s0`) is the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame — here, by `eraseHere` after it erases the cell. -**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `∗ → K/→` self-loops until the head sees `X`; the `'X' → K/⇹` solid edge would normally halt — it lands on the cloned halt `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `∗ → E/⇹` and halts at real `s0`. Run terminates. +**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `🞰 → K/R` self-loops until the head sees `X`; the `'X' → K/S` solid edge would normally halt — it lands on the cloned halt `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `🞰 → E/S` and halts at real `s0`. Run terminates. > 💡 **Round-trip caveat.** `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for simple wrappers like this one ([#139](https://github.com/mellonis/turing-machine-js/issues/139) regression). For shared-bare cases (same `State` instance used as the bare in multiple wrappers — e.g., `library-binary-numbers`'s `minusOne`), per-context duplication produces wrapper-id-dependent ordering that doesn't byte-match across rebuilds — equivalent runtime behavior, different emit-line order. -
- Wrappers nest: `inner.withOverriddenHaltState(middle).withOverriddenHaltState(outer)` chains halt-redirects through `middle → outer → halt`. `library-binary-numbers/src/index.ts`'s `minusOne` (the `~(~x + 1)` composition) uses a 4-deep nest of wrappers. ## Debugging breakpoints diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index 7767f07..43db5f1 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -232,7 +232,7 @@ describe('State.fromGraph — cyclic override-halt chain', () => { // pointing in a loop. // Nodes need at least one transition each — State construction at pass 2 // rejects empty stateDefinitions before pass 3's cycle check would run. - const dummyTransition = {pattern: '∗', command: [{symbol: 'K', movement: '⇹'}], nextStateId: 0, id: "test-edge"}; + const dummyTransition = {pattern: '🞰', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}; const graph = { initialId: 1, alphabets: [[' ', '0', '1']], diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index 6bd6128..b3d29d2 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -19,20 +19,20 @@ describe('decodePatternDescription', () => { expect(decodePatternDescription(undefined, alphabets)).toBe('?'); }); - test('"other symbol" → "∗" (whole-state ifOtherSymbol)', () => { - expect(decodePatternDescription('other symbol', alphabets)).toBe('∗'); + test('"other symbol" → "🞰" (whole-state ifOtherSymbol)', () => { + expect(decodePatternDescription('other symbol', alphabets)).toBe('🞰'); }); test('literal cell wraps in single quotes', () => { expect(decodePatternDescription('[["0"]]', alphabets)).toBe("'0'"); }); - test('per-cell null → "∗"', () => { - expect(decodePatternDescription('[[null]]', alphabets)).toBe('∗'); + test('per-cell null → "🞰"', () => { + expect(decodePatternDescription('[[null]]', alphabets)).toBe('🞰'); }); - test('cell equal to tape blank → "-"', () => { - expect(decodePatternDescription('[[" "]]', alphabets)).toBe('-'); + test('cell equal to tape blank → "B"', () => { + expect(decodePatternDescription('[[" "]]', alphabets)).toBe('B'); }); test('multi-tape pattern joins quoted cells with ","', () => { @@ -77,9 +77,9 @@ describe('decodeMovement', () => { }); test.each([ - [(movements.left as symbol).description, '←'], - [(movements.right as symbol).description, '→'], - [(movements.stay as symbol).description, '⇹'], + [(movements.left as symbol).description, 'L'], + [(movements.right as symbol).description, 'R'], + [(movements.stay as symbol).description, 'S'], ])('%s → %s', (description, expected) => { expect(decodeMovement(description)).toBe(expected); }); @@ -121,8 +121,8 @@ describe('toMermaid', () => { 1: { id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: 'K', movement: '→'}], nextStateId: 1, id: "test-edge"}, - {pattern: '1', command: [{symbol: 'K', movement: '⇹'}], nextStateId: 0, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -134,8 +134,8 @@ describe('toMermaid', () => { expect(out).toContain('s1["entry"]'); expect(out).toContain('idle([idle])'); expect(out).toContain('idle -. enter .-> s1'); - expect(out).toContain('s1 -- "0 → K/→" --> s1'); - expect(out).toContain('s1 -- "1 → K/⇹" --> s0'); + expect(out).toContain('s1 -- "0 → K/R" --> s1'); + expect(out).toContain('s1 -- "1 → K/S" --> s0'); }); test('renders dotted onHalt edge when overriddenHaltStateId is set', () => { @@ -175,7 +175,7 @@ describe('toMermaid', () => { id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [{ pattern: '0,a', - command: [{symbol: '0', movement: '→'}, {symbol: 'a', movement: '←'}], + command: [{symbol: '0', movement: 'R'}, {symbol: 'a', movement: 'L'}], nextStateId: 0, id: 'test-edge', }], @@ -183,7 +183,7 @@ describe('toMermaid', () => { }, }); - expect(out).toContain('"0,a → 0/→,a/←"'); + expect(out).toContain('"0,a → 0/R,a/L"'); }); }); @@ -203,24 +203,24 @@ describe('splitUnescaped', () => { describe('parsePatternString', () => { test('returns null for the global ifOtherSymbol marker', () => { - expect(parsePatternString('∗', [[' ', '0']])).toBeNull(); + expect(parsePatternString('🞰', [[' ', '0']])).toBeNull(); }); - test('per-cell `∗` becomes null', () => { + test('per-cell `🞰` becomes null', () => { // Multi-tape pattern where one cell is per-cell ifOtherSymbol. - expect(parsePatternString("'0',∗", [[' ', '0'], [' ', 'a']])).toEqual([['0', null]]); + expect(parsePatternString("'0',🞰", [[' ', '0'], [' ', 'a']])).toEqual([['0', null]]); }); - test('per-cell `-` becomes the tape blank symbol', () => { - expect(parsePatternString("-,'a'", [[' ', '0'], [' ', 'a']])).toEqual([[' ', 'a']]); + test('per-cell `B` becomes the tape blank symbol', () => { + expect(parsePatternString("B,'a'", [[' ', '0'], [' ', 'a']])).toEqual([[' ', 'a']]); }); }); describe('parseMovementLabel', () => { - test('maps ←/→/⇹ to upstream movement symbols', () => { - expect(parseMovementLabel('←')).toBe(movements.left); - expect(parseMovementLabel('→')).toBe(movements.right); - expect(parseMovementLabel('⇹')).toBe(movements.stay); + test('maps ←/R/S to upstream movement symbols', () => { + expect(parseMovementLabel('L')).toBe(movements.left); + expect(parseMovementLabel('R')).toBe(movements.right); + expect(parseMovementLabel('S')).toBe(movements.stay); }); test('throws on unknown label', () => { @@ -322,8 +322,8 @@ describe('README example: toMermaid output is stable', () => { ' s1["name"]', ' idle([idle])', ' idle -. enter .-> s1', - " s1 -- \"'1' → '0'/→\" --> s1", - " s1 -- \"'$' → K/←\" --> s0", + " s1 -- \"'1' → '0'/R\" --> s1", + " s1 -- \"'$' → K/L\" --> s0", ].join('\n'); expect(toMermaid(State.toGraph(s, tapeBlock))).toBe(expected); @@ -334,7 +334,7 @@ describe('README example: toMermaid output is stable', () => { // READMEs. Each test asserts the expected lines are present; we don't pin // state IDs as exact values because they auto-increment globally and depend // on test ordering. The test catches engine emit-format changes (e.g. if -// "b → */→" notation drifts) without being fragile to ID assignment. +// "b → */R" notation drifts) without being fragile to ID assignment. import Reference from '../classes/Reference'; import {ifOtherSymbol} from '../classes/State'; @@ -363,9 +363,9 @@ describe('README diagrams: engine-generated outputs', () => { '["replaceB"]', // initial — square (no longer round in v7; idle arrow signals entry) 'idle([idle])', 'idle -. enter .->', - "\"'b' → '*'/→\"", - '"- → K/←"', - '"∗ → K/→"', + "\"'b' → '*'/R\"", + '"B → K/L"', + '"🞰 → K/R"', ]); }); @@ -387,8 +387,8 @@ describe('README diagrams: engine-generated outputs', () => { '["b"]', // b is reachable from a → square 'idle([idle])', 'idle -. enter .->', - "\"'x' → K/⇹\"", - "\"'y' → K/⇹\"", + "\"'x' → K/S\"", + "\"'y' → K/S\"", ]); }); @@ -410,8 +410,8 @@ describe('README diagrams: engine-generated outputs', () => { '["scanToX"]', // initial — square (idle arrow signals entry) 'idle([idle])', 'idle -. enter .->', - "\"'X' → K/⇹\"", - '"∗ → K/→"', + "\"'X' → K/S\"", + '"🞰 → K/R"', ]); }); @@ -440,7 +440,7 @@ describe('README diagrams: engine-generated outputs', () => { '"halt frame"', // subgraph label 'idle([idle])', // pre-execution sentinel — always emitted 'idle -. enter .->', // labeled dotted enter arrow points at the initial state - '"∗ → E/⇹"', // eraseHere's erase command + '"🞰 → E/S"', // eraseHere's erase command '-. onHalt .->', // the dotted override-halt edge — wrapper's catch-and-redirect, crosses the subgraph border ]); }); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index 6a24ae0..d2db22d 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -35,16 +35,10 @@ export type Graph = { nodes: Record; }; -// Head-movement labels use directional Unicode arrows for visual clarity in -// rendered Mermaid output: `←` (U+2190) left, `→` (U+2192) right, `⇹` (U+21F9 -// LEFT RIGHT OPEN-HEADED ARROW) stay. The right-arrow `→` is the same glyph -// used by the read-to-write separator in edge labels; `fromMermaid` parses -// labels via `indexOf(' → ')` (note the surrounding spaces) so the unspaced -// `→` in a movement position doesn't collide. const movementDescriptionToLabel: Record = { - 'move caret left command': '←', - 'move caret right command': '→', - 'do not move carer': '⇹', + 'move caret left command': 'L', + 'move caret right command': 'R', + 'do not move carer': 'S', }; const symbolCommandDescriptionToLabel: Record = { @@ -53,21 +47,24 @@ const symbolCommandDescriptionToLabel: Record = { }; // Reserved characters in the encoded pattern string: -// '∗' (U+2217 ASTERISK OPERATOR) per-cell ifOtherSymbol — matches any -// symbol on that tape. Distinct from the regular ASCII '*' (U+002A) so -// an alphabet that contains literal '*' (rendered as the quoted `'*'`) -// is unambiguously different from the catch-all marker. -// '-' the tape's blank symbol +// '🞰' (U+1F7B0 HEAVY EIGHT BALLOON-SPOKED ASTERISK) per-cell ifOtherSymbol — +// matches any symbol on that tape. Distinct from the regular ASCII '*' +// (U+002A) so an alphabet that contains literal '*' (rendered as the +// quoted `'*'`) is unambiguously different from the catch-all marker. +// 'B' the tape's blank symbol shorthand (in read patterns). A literal `B` +// in the alphabet is unambiguous from the marker because it's quoted +// (`'B'`). // ',' separates per-tape cells inside one pattern // '|' separates alternative patterns // "'" surrounds a literal alphabet symbol — e.g. `'0'` for literal `0`, // `'X'` for literal `X`. The quoting is what visually separates literal -// symbols from the convention markers `∗` / `-` and from the write +// symbols from the convention markers `🞰` / `B` and from the write // commands `K` / `E`. -// '\\' escape prefix — to represent any of '∗', '-', ',', '|', "'", or '\\' +// '\\' escape prefix — to represent any of '🞰', 'B', ',', '|', "'", or '\\' // as a *literal* alphabet symbol *inside* the quotes (e.g. `'\''` for // a literal apostrophe). -const IF_OTHER_MARKER = '∗'; +const IF_OTHER_MARKER = '🞰'; +const BLANK_MARKER = 'B'; function escapeAlphabetSymbol(s: string): string { return s @@ -98,7 +95,7 @@ export function decodePatternDescription( } if (s === alphabets[tapeIx]?.[0]) { - return '-'; + return BLANK_MARKER; } return `'${escapeAlphabetSymbol(s)}'`; @@ -162,7 +159,7 @@ export function parsePatternString(s: string, alphabets: string[][]): ParsedPatt return null; } - if (cell === '-') { + if (cell === BLANK_MARKER) { return alphabets[tapeIx]?.[0] ?? cell; } @@ -178,9 +175,9 @@ export function parsePatternString(s: string, alphabets: string[][]): ParsedPatt } const movementLabelToSymbol: Record = { - '←': movements.left, - '→': movements.right, - '⇹': movements.stay, + L: movements.left, + R: movements.right, + S: movements.stay, }; export function parseMovementLabel(label: string): symbol { diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 39bc5c7..02d49ec 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -117,8 +117,19 @@ export function toMermaid(graph: Graph): string { const cmd = t.command.map((c) => `${c.symbol}/${c.movement}`).join(','); const label = `${t.pattern} → ${cmd}`; + // Thicker `==>` arrow when the transition crosses INTO a wrapper — + // signals "this transition pushes that wrapper's override onto the + // runtime stack" (per `TuringMachine.run` line ~220's + // `if (state !== nextState && nextState.overriddenHaltState) push(...)`). + // Self-loops (state === nextState) don't push at runtime — keep the + // regular `-->` for those even when the target is wrapped. + const targetNode = graph.nodes[t.nextStateId]; + const isEnteringWrapper = targetNode && targetNode.isWrapped && t.nextStateId !== node.id; + const lineSegment = isEnteringWrapper ? '==' : '--'; + const arrowTip = isEnteringWrapper ? '==>' : '-->'; + lines.push( - ` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`, + ` ${mermaidIdFor(node.id)} ${lineSegment} "${label}" ${arrowTip} ${mermaidIdFor(t.nextStateId)}`, ); } @@ -151,6 +162,7 @@ const subgraphEndRegex = /^end$/; const idleNodeRegex = /^idle\(\[idle\]\)$/; const enterArrowRegex = /^idle\s+-\.\s+enter\s+\.->\s+(s\d+)$/; const transitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/; +const thickTransitionRegex = /^([sc]\d+)\s+==\s+"(.*)"\s+==>\s+([sc]\d+)$/; const onHaltRegex = /^([sc]\d+)\s+-\.\s+onHalt\s+\.->\s+([sc]\d+)$/; // First capture char anchored as \S to avoid polynomial backtracking between // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos. @@ -274,7 +286,9 @@ export function fromMermaid(text: string): Graph { continue; } - const tm = line.match(transitionRegex); + // Thick transition (`==> `) and regular transition (`-->`) share the same + // semantics — only the visual differs. Parse both via the same code path. + const tm = line.match(transitionRegex) ?? line.match(thickTransitionRegex); if (tm) { const fromId = parseMermaidId(tm[1]); diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index 2fcfb66..17347db 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -11,8 +11,8 @@ describe('summarizeGraph', () => { 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: 'K', movement: '→'}], nextStateId: 1, id: "test-edge"}, - {pattern: '1', command: [{symbol: 'K', movement: '⇹'}], nextStateId: 0, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -35,7 +35,7 @@ describe('summarizeGraph', () => { 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, transitions: [ - {pattern: '0', command: [{symbol: 'K', movement: '→'}], nextStateId: 1, id: "test-edge"}, + {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, ], }, }, @@ -55,7 +55,7 @@ describe('summarizeGraph', () => { 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, 1: { id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, - transitions: [{pattern: '0', command: [{symbol: 'K', movement: '⇹'}], nextStateId: 0, id: "test-edge"}], + transitions: [{pattern: '0', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}], }, }, }; @@ -122,7 +122,7 @@ describe('State.inspect', () => { const haltTransition = info.transitions.find((t) => t.nextState?.name === haltState.name); expect(haltTransition).toBeTruthy(); - expect(haltTransition!.command[0].movement).toBe('→'); + expect(haltTransition!.command[0].movement).toBe('R'); expect(haltTransition!.command[0].symbol).toBe("'1'"); }); diff --git a/packages/machine/src/utilities/introspection.ts b/packages/machine/src/utilities/introspection.ts index ca34c5d..8c82059 100644 --- a/packages/machine/src/utilities/introspection.ts +++ b/packages/machine/src/utilities/introspection.ts @@ -17,7 +17,7 @@ import {type Graph} from './graph'; // // const a = summarize(binaryNumbers.states.minusOne, binaryNumbers.getTapeBlock()); // const b = summarize(binaryNumbersBare.states.minusOne, binaryNumbersBare.getTapeBlock()); -// // a.stateCount === 17, b.stateCount === 3 +// // a.stateCount === 15, b.stateCount === 3 // // a.maxCompositionDepth === 4, b.maxCompositionDepth === 0 export type GraphSummary = { // Counts @@ -40,6 +40,12 @@ export type GraphSummary = { export function summarizeGraph(graph: Graph): GraphSummary { const nodes = Object.values(graph.nodes); + // `isClonedHalt` nodes are visualization sentinels — one per wrapper context, + // all corresponding to the singleton `haltState` at runtime. They don't + // count as distinct runtime states; matches the per-algorithm header in + // `library-binary-numbers/states.md`. + const runtimeStateCount = nodes.filter((n) => !n.isClonedHalt).length; + let transitionCount = 0; let compositionEdgeCount = 0; let selfLoopCount = 0; @@ -134,7 +140,7 @@ export function summarizeGraph(graph: Graph): GraphSummary { } return { - stateCount: nodes.length, + stateCount: runtimeStateCount, transitionCount, compositionEdgeCount, maxCompositionDepth, diff --git a/scripts/build-states-md.mjs b/scripts/build-states-md.mjs index e8b6aa4..3a1ce2d 100644 --- a/scripts/build-states-md.mjs +++ b/scripts/build-states-md.mjs @@ -57,7 +57,7 @@ if (libName === null) { process.exit(1); } - const {State, toMermaid} = await import('../packages/machine/dist/index.mjs'); + const {State, summarizeGraph, toMermaid} = await import('../packages/machine/dist/index.mjs'); const library = (await import(entry.importPath)).default; const sections = [`# ${entry.name} — state graphs`, '']; @@ -66,14 +66,22 @@ if (libName === null) { const tapeBlock = library.getTapeBlock(); const graph = State.toGraph(state, tapeBlock); const mermaid = toMermaid(graph); - // Exclude `isClonedHalt: true` nodes from the count — they are - // visualization-only sentinels (one per wrapper context, all mapped to the - // singleton `haltState` at runtime), not distinct runtime states. - const nodeCount = Object.values(graph.nodes).filter((n) => !n.isClonedHalt).length; + // Engine-owned introspection — dogfoods the same summary the public API + // exposes. `stateCount` already filters out `isClonedHalt` sentinels. + const summary = summarizeGraph(graph); sections.push(`## ${stateName}`); sections.push(''); - sections.push(`*${nodeCount} state${nodeCount === 1 ? '' : 's'} (including \`haltState\`)*`); + sections.push( + `*${summary.stateCount} state${summary.stateCount === 1 ? '' : 's'}; ` + + `${summary.transitionCount} transitions` + + (summary.compositionEdgeCount > 0 + ? `; ${summary.compositionEdgeCount} wrapper${summary.compositionEdgeCount === 1 ? '' : 's'} ` + + `(max nesting depth ${summary.maxCompositionDepth})` + : '') + + (summary.hasCycles ? '; has cycles' : '') + + '*', + ); sections.push(''); sections.push('```mermaid'); sections.push(mermaid); From c64e148b053c5ae82ca7e79af86b54f0105998da Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Wed, 20 May 2026 22:44:13 +0300 Subject: [PATCH 010/118] docs+emit polish: bracketed-tape-block format, multi-tape example, halt-marker rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iterative UX refinements on top of the v7 emit, all in this branch since the last commit. Several themes: **Bracketed-tape-block edge labels** — `[reads] → [writes]/[moves]` where each role wraps in `[…]`, always (even single-tape). Brackets are the "tape-block" indicator. Cell content inside the brackets: literal-quoted (`'X'`), `🞰` (U+1F7B0 ifOtherSymbol catch-all), `B` (tape's blank shorthand), `K`/`E` (keep/erase write commands), `L`/`R`/`S` (movements). Alternation is per-pattern bracket: `['^']|['1']|['0']` for single-tape, `['0','a']|['1','b']` for multi-tape. Compact `['^'|'1']` form rejected by `fromMermaid` (two new strict-rejection tests added) to prevent the cross-product trap in multi-tape (`['0'|'1','a'|'b']` would read as 4 combinations rather than 2 paired alternatives). **Thick `==>` arrows for stack-pushing transitions** — when a transition's target is a wrapped state AND target ≠ source (i.e. the engine's runtime push fires per `TuringMachine.run` line ~220). Self-loops on wrappers don't push, stay regular `-->`. Reader can scan an execution path counting thick arrows to see worst-case wrapper-stack growth. **"halt marker" replaces "cloned halt"** — the per-wrapper halt sentinel inside each `subgraph w_N["halt frame"]` was previously called a "clone", which misleadingly implied a real second copy at runtime. It's a visualization-only marker that maps back to the singleton `haltState` in `fromGraph`. Renamed `isClonedHalt` → `isHaltMarker` on `GraphNode` (public-API type) to align the implementation with the prose. **Multi-tape example added** — a 2-tape "copier" machine in §Diagram conventions illustrates the bracket-format with N=2 tapes. Previously the engine had no documented multi-tape example. **Restructured README sections**: - Quick Start trimmed back to: code + diagram + one-paragraph diagram-specific read-out. No legend dump. - New §Diagram conventions section near the end (right before §Versioning notes) holds the full reference: node shapes table, edge styles table, edge label format + cell vocabulary table, alternation rule + cross-product trap explanation, multi-tape example. Reader hits it as a reference once familiar with the library's concepts. - 4 hand-drawn pedagogical Mermaid blocks removed from root + engine READMEs; engine `toMermaid()` output is now the primary illustration. Vocabulary mismatch between hand-drawn and engine diagrams is gone. **Engine `CLAUDE.md`** rewritten to reflect v7 emit reality: subgraph + `[[bare]]` + halt-marker + idle sentinel + edge-label vocabulary + thick-arrow convention + per-context duplication for shared-bare cases. `summarize().stateCount` filters halt-markers (consistent with `states.md` header by construction). Added link to Mermaid syntax docs + repo. **Source-comment cleanups in `library-binary-numbers/src/index.ts`**: - minusOne "17 nodes" → "15 nodes (per `summarize().stateCount`)"; "four-deep subroutine chain" → "three-deep" (3 wrapper hops + 1 terminal target — the previous "four-deep" was counting state names in the chain, not wrapper levels). - Per-algorithm node counts updated where v7 emit changed them (deleteNumber/invertNumber 5→4, normalizeNumber 7→6, minusOneFast 10→8). **`build-states-md.mjs` dogfoods `summarizeGraph`** for the per- algorithm header line: `N states; N transitions; N wrappers (max nesting depth N); has cycles`. No more manual counting in the script. **Pre-existing anchor fix** — `### Throttle pattern (v6.4.0+)` heading → `### Throttle pattern`. Two cross-links elsewhere in the README targeted `#throttle-pattern` and didn't resolve; dropping the version suffix from the heading makes them resolve. All 422 tests pass (420 prior + 2 new strict-rejection regression tests for the compact alternation form). `equivalentOn` verified unaffected — pure-runtime checker, doesn't touch any of the visualization/introspection surface. --- CLAUDE.md | 8 +- README.md | 16 +- ...026-05-20-tomermaid-wrapper-emit-design.md | 28 +-- .../library-binary-numbers-bare/states.md | 32 ++-- .../library-binary-numbers/src/graphs.spec.ts | 6 +- packages/library-binary-numbers/states.md | 180 +++++++++--------- packages/machine/README.md | 110 +++++++++-- packages/machine/src/classes/State.spec.ts | 6 +- packages/machine/src/classes/State.ts | 20 +- packages/machine/src/utilities/graph.spec.ts | 86 ++++++--- packages/machine/src/utilities/graph.ts | 8 +- .../machine/src/utilities/graphFormats.ts | 140 +++++++++++--- .../src/utilities/introspection.spec.ts | 28 +-- .../machine/src/utilities/introspection.ts | 4 +- scripts/build-states-md.mjs | 2 +- test/round-trip.spec.ts | 2 +- 16 files changed, 435 insertions(+), 241 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f4442c0..d6c4825 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,11 +61,15 @@ Key shapes that take reading multiple files to grasp: `packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same `Graph` through [Mermaid flowchart](https://mermaid.js.org/syntax/flowchart.html) syntax (renderer: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid)). The parser is strict to the dialect `toMermaid` emits — hand-edited Mermaid with different arrow styles or shapes won't round-trip. -**v7 emit shape** (PR #169, closes #138/#139): each `withOverriddenHaltState` wrapper collapses onto its bare's representation — `GraphNode.isWrapped: true`, no separate wrapper node in graph data. `toMermaid` wraps each `[[bare]]` (subroutine shape) + a synthesized `(((halt)))` clone (`GraphNode.isClonedHalt: true`, negative id; maps back to singleton `haltState` in `fromGraph`) inside a `subgraph w_${bareId}["halt frame"] … end` block. Dotted `onHalt` from `[[bare]]` crosses the subgraph border to the override target. An always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state (replaces v6's `((round))` shape convention). Edge labels: write commands `K` (keep) / `E` (erase), movements `L`/`R`/`S`, literal alphabet symbols quoted (`'X'`), unquoted markers `🞰` (U+1F7B0, ifOtherSymbol) and `B` (the tape's blank). Thick `==>` arrows mark transitions whose target is a wrapped state (= stack-push at runtime); regular `-->` for the rest; dotted reserved for wrapper machinery. +**v7 emit shape** (PR #169, closes #138/#139): each `withOverriddenHaltState` wrapper collapses onto its bare's representation — `GraphNode.isWrapped: true`, no separate wrapper node in graph data. `toMermaid` wraps each `[[bare]]` (subroutine shape) + a synthesized `(((halt)))` marker (`GraphNode.isHaltMarker: true`, negative id; maps back to singleton `haltState` in `fromGraph`) inside a `subgraph w_${bareId}["halt frame"] … end` block. Dotted `onHalt` from `[[bare]]` crosses the subgraph border to the override target. An always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state (replaces v6's `((round))` shape convention). + +**Edge label vocabulary** — `[reads] → [writes]/[moves]`, each role wrapped in `[…]` (the tape-block indicator, one entry per tape; brackets always present, even single-tape). Read cells: literal-quoted (`'X'`), `🞰` (U+1F7B0, ifOtherSymbol catch-all), `B` (the tape's blank). Write cells: literal-quoted, `K` (keep), `E` (erase = write blank). Move cells: `L` / `R` / `S`. **Alternation is always per-pattern bracket** (`['^']|['1']|['0']` for single-tape, `['0','a']|['1','b']` for multi-tape); the compact in-bracket form `['^'|'1']` is rejected by `fromMermaid` to prevent the cross-product reading trap in multi-tape (`['0'|'1','a'|'b']` would read as 4 combinations rather than 2 paired alternatives, so the format avoids the shape entirely). + +**Edge arrow styles** — thick `==>` marks transitions whose target is a wrapped state AND ≠ source (= stack-push happens at runtime per `TuringMachine.run` line ~220); regular `-->` for the rest (including self-loops on wrappers, which don't push); dotted `-. onHalt .->` for the wrapper's catch-and-redirect; dotted `-. enter .->` from `idle` for execution-start. **Round-trip** is **bytewise stable for simple wrappers** (regression test in `test/round-trip.spec.ts` — #139). The wrapper's composite name (e.g. `scanToX(eraseHere)`) does NOT appear as a graph node label; only the bare's name does, so `fromGraph` recomputes the composite fresh on reconstruction — no accumulation. **Shared-bare cases** (same `State` instance used as the bare of multiple wrappers, e.g. `library-binary-numbers`'s `minusOne` where `invertNumber` is both the outermost bare AND wrapper-W1's bare) use **per-context duplication** in `toGraph`: each occurrence emits a separate graph node with the wrapper's `#id`. Reconstruction produces behaviorally-equivalent State instances (not necessarily the same runtime `#id`), but bytewise stability isn't guaranteed for shared-bare since duplicate ordering depends on runtime wrapper-ids that don't survive rebuild. -**Stats helpers** — `summarize(state, tapeBlock)` returns `{stateCount, transitionCount, compositionEdgeCount, maxCompositionDepth, selfLoopCount, hasCycles, tapeCount, alphabetCardinalities}`. `stateCount` filters out `isClonedHalt` sentinels (they're visualization-only, all map to the singleton `haltState` at runtime); matches the per-algorithm header in `library-binary-numbers/states.md` by construction. `equivalentOn(reference, candidate, cases)` is the separate behavioral-equivalence checker — runs both machines, compares outputs and per-step snapshots; lives in `./utilities/equivalence.ts`, unaffected by the visualization-layer changes above. +**Stats helpers** — `summarize(state, tapeBlock)` returns `{stateCount, transitionCount, compositionEdgeCount, maxCompositionDepth, selfLoopCount, hasCycles, tapeCount, alphabetCardinalities}`. `stateCount` filters out `isHaltMarker` sentinels (they're visualization-only, all map to the singleton `haltState` at runtime); matches the per-algorithm header in `library-binary-numbers/states.md` by construction. `equivalentOn(reference, candidate, cases)` is the separate behavioral-equivalence checker — runs both machines, compares outputs and per-step snapshots; lives in `./utilities/equivalence.ts`, unaffected by the visualization-layer changes above. ### Builder package diff --git a/README.md b/README.md index 092eb10..118810b 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,20 @@ flowchart TD s1["replaceB"] idle([idle]) idle -. enter .-> s1 - s1 -- "'b' → '*'/R" --> s1 - s1 -- "B → K/L" --> s0 - s1 -- "🞰 → K/R" --> s1 + s1 -- "['b'] → ['*']/[R]" --> s1 + s1 -- "[B] → [K]/[L]" --> s0 + s1 -- "[🞰] → [K]/[R]" --> s1 ``` -Engine notation: `read → write/move`. Write commands: `K` = keep, `E` = erase (write the blank). Movements: `L` / `R` / `S`. Literal alphabet symbols are wrapped in single quotes (`'b'`, `'*'`). Unquoted markers: `🞰` (U+1F7B0) = `ifOtherSymbol` catch-all, `B` = the tape's blank symbol. `(((halt)))` = halt; `["square"]` = a regular state; the `idle([idle])` sentinel + dotted `enter` arrow marks where execution begins. (Subroutine composition adds a `subgraph` "halt frame" + `[[double-walled]]` wrapper node — see [§Subroutine composition](packages/machine/README.md#subroutine-composition-with-withoverriddenhaltstate) in the engine README.) +Quick legend for the diagram above — full table at [packages/machine/README.md § Diagram conventions](packages/machine/README.md#diagram-conventions): + +- **Edge format**: `[reads] → [writes]/[moves]` (each `[…]` is a tape-block reading; brackets always, even single-tape). +- **Read cells**: `'X'` (literal, single-quoted), `🞰` (`ifOtherSymbol` catch-all, U+1F7B0), `B` (the tape's blank). +- **Write cells**: `'X'` (literal), `K` (keep), `E` (erase = write blank). +- **Movement cells**: `L` / `R` / `S` (left / right / stay). +- **Node shapes**: `(((halt)))` = halt, `["square"]` = regular state, `[[double-walled]]` = wrapper-bare (subroutine shape), `idle([idle])` = pre-execution sentinel. +- **Edges**: `-->` regular, `==>` thick = transition into a wrapper (stack-push), `-. onHalt .->` = wrapper's catch-and-redirect, `-. enter .->` from `idle` = where execution starts. +- **Subgraph `w_N["halt frame"]`** wraps a `[[bare]]` + its halt marker — see [§Subroutine composition](packages/machine/README.md#subroutine-composition-with-withoverriddenhaltstate) in the engine README. Trace on the input tape `abcba`: diff --git a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md index a5fb831..776d8b7 100644 --- a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md +++ b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md @@ -1,6 +1,6 @@ # `toMermaid` wrapped-state emit — design comparison -**Status:** decided — **Variant X with `subgraph` overlay + `idle` entry sentinel + readable command/symbol vocabulary**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a cloned `(((halt)))`. The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. A stadium-shaped `idle([idle])` sentinel + labeled dotted arrow `idle -. enter .-> sN` is always emitted to mark the initial state. Edge-label vocabulary: write commands `K` = keep, `E` = erase (write blank); literal alphabet symbols wrapped in single quotes (`'X'`, `'0'`); unquoted markers `*` = any (ifOtherSymbol), `-` = blank shorthand. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation in PR [#169](https://github.com/mellonis/turing-machine-js/pull/169). +**Status:** decided — **Variant X with `subgraph` overlay + `idle` entry sentinel + bracketed-tape-block edge labels**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a cloned `(((halt)))`. The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. A stadium-shaped `idle([idle])` sentinel + labeled dotted arrow `idle -. enter .-> sN` is always emitted to mark the initial state. Edge labels: `[reads] → [writes]/[moves]` with each role wrapped in `[…]` (tape-block indicator, always present even single-tape); read cells use `'X'` literal-quoted, `🞰` (U+1F7B0) for ifOtherSymbol, `B` for the tape's blank; write cells use `K`/`E` plus literal-quoted; movements `L`/`R`/`S`. Alternation is per-pattern-bracket (`['^']|['1']`); compact `['^'|'1']` form is rejected by `fromMermaid` (would read as cross-product in multi-tape). Stack-pushing transitions emit thick `==>` arrows. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation in PR [#169](https://github.com/mellonis/turing-machine-js/pull/169). **Context.** [#138](https://github.com/mellonis/turing-machine-js/issues/138) — clean up the visually-confusing Mermaid output for `withOverriddenHaltState`-wrapped states. [#139](https://github.com/mellonis/turing-machine-js/issues/139) — bytewise round-trip regression for the wrapper name accumulation, naturally fixed by whichever design we pick. @@ -259,7 +259,7 @@ Y₁ is the most faithful but the cost of per-context state duplication in `from After iteration, the locked shape evolves Variant X (collapse the wrapper into the bare's representation, no extra "wrapper node" in the graph data) with two visualization-only enhancements that make the wrapper's runtime semantics tangible without mutating the graph structure: 1. A Mermaid **`subgraph` rectangle labeled `"halt frame"`** around each wrapper — the visual scope for "the wrapper's stack frame for halt handling." -2. A **cloned `(((halt)))` node inside that subgraph** — visualization of "halt-bound transitions land here, *inside* the wrapper's scope." `haltState` is a runtime singleton; the cloned visual is a teaching aid (one halt-clone per wrapper context on the diagram, all corresponding to the single runtime instance). +2. A **cloned `(((halt)))` node inside that subgraph** — visualization of "halt-bound transitions land here, *inside* the wrapper's scope." `haltState` is a runtime singleton; the cloned visual is a teaching aid (one halt marker per wrapper context on the diagram, all corresponding to the single runtime instance). ### Visual contract (what a reader sees) @@ -268,7 +268,7 @@ After iteration, the locked shape evolves Variant X (collapse the wrapper into t - the wrapper's runtime entry point (execution starts here on entering the wrapper), and - the source of the dotted `onHalt` redirect (since the wrapper-node *is* the catcher). - **Cloned `(((halt)))` inside the subgraph** = the halt entry point within this wrapper's scope. Halt-bound transitions from the bare terminate here, not at the real halt. -- **Solid arrows from `[[bare]]` to cloned halt** = the bare's structural halt-bound transitions. All stay inside the subgraph rectangle. +- **Solid arrows from `[[bare]]` to halt marker** = the bare's structural halt-bound transitions. All stay inside the subgraph rectangle. - **Dotted `onHalt` arrow from `[[bare]]` out of the subgraph to the override target** = the wrapper's catch-and-redirect. Exactly one per wrapper. Solid arrows from `[[bare]]` to non-halt targets can ALSO cross the rectangle border (when the bare's transitions reach external states — common in compositions like `library-binary-numbers`'s `minusOne`); those are just regular runtime transitions, not wrapper machinery. Only the dotted `onHalt` carries wrapper-machinery meaning. - **Real `(((halt)))` outside any subgraph** = the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame (the unwrapped tail of the chain). @@ -319,7 +319,7 @@ The wrapper's composite name (e.g. `scanToX(eraseHere)`) does **not** appear as ### Shared-bare handling -`library-binary-numbers`'s `minusOne` = `invertNumber.with(plusOne.with(invertNumber.with(normalizeNumber)))` — same `invertNumber` instance is the bare of two distinct wrappers (outermost and innermost). Each wrapper context implies its own `subgraph` membership + its own cloned halt + its own dotted `onHalt` edge. +`library-binary-numbers`'s `minusOne` = `invertNumber.with(plusOne.with(invertNumber.with(normalizeNumber)))` — same `invertNumber` instance is the bare of two distinct wrappers (outermost and innermost). Each wrapper context implies its own `subgraph` membership + its own halt marker + its own dotted `onHalt` edge. Plan: emit the bare as a separate graph node per wrapper context (per-context duplication in `toGraph`). The shared State instance is preserved at runtime; the graph and Mermaid emit are per-context. `fromGraph` reconstructs equivalent State instances (not necessarily the same runtime `#id` as the original — just behaviorally equivalent). @@ -330,12 +330,12 @@ Plan: emit the bare as a separate graph node per wrapper context (per-context du 3. `State.toGraph`: - Detect wrapper-States (those with `#overriddenHaltState !== null`). - Substitute with the bare; mark the bare's graph node `isWrapped: true`. - - Synthesize a per-wrapper cloned-halt graph node (a node with `isHalt: true` whose role is "halt-clone for this wrapper"). - - Rewrite the bare's halt-bound transitions to target the cloned halt rather than the real one. + - Synthesize a per-wrapper halt-marker graph node (a node with `isHalt: true` whose role is "halt marker for this wrapper"). + - Rewrite the bare's halt-bound transitions to target the halt marker rather than the real one. 4. `toMermaid`: - `isWrapped: true` node → `s${id}[["${name}"]]` (subroutine shape). - - Cloned-halt node → `s${id}(((halt)))` (triple-paren, identical to real halt). - - Wrap each `[[bare]]` + its cloned halt in `subgraph wN["halt frame"] … end`. + - Halt-marker node → `s${id}(((halt)))` (triple-paren, identical to real halt). + - Wrap each `[[bare]]` + its halt marker in `subgraph wN["halt frame"] … end`. - Dotted onHalt edge `s${bareId} -. onHalt .-> s${overrideId}` (from `[[bare]]`, crossing the subgraph border). 5. `fromMermaid`: - Parse Mermaid `subgraph wN["..."] … end` blocks. @@ -343,7 +343,7 @@ Plan: emit the bare as a separate graph node per wrapper context (per-context du - Track subgraph membership for the round-trip. 6. `State.fromGraph`: - For `isWrapped: true` nodes, reconstruct via `bareStates[id].withOverriddenHaltState(getFinal(overriddenHaltStateId))`. - - Cloned-halt graph nodes don't get separate State instances — they all map back to the singleton `haltState`. + - Halt-marker graph nodes don't get separate State instances — they all map back to the singleton `haltState`. 7. `#139`'s round-trip test added; should pass after this design. 8. `states.md` regenerates with the new shape (both binary libraries). 9. README "Subroutine composition" section rewritten to use the new visual + reader's contract above. @@ -354,15 +354,15 @@ Five design choices in #138's implementation that keep the demo's render + highl 1. **Stable per-node ids in `Graph`.** Every node has a deterministic id: - Bare nodes: `node.id = bareState.id` (the engine's `State.#id`). - - Cloned-halt nodes: synthesized but deterministic from `(bareNodeId, wrapper-depth)`. + - Halt-marker nodes: synthesized but deterministic from `(bareNodeId, wrapper-depth)`. - Per-context bare duplicates: synthesized similarly. Mermaid emits `s${id}` for each; downstream can find the SVG node for any engine `state.id` directly. -2. **Cloned-halt marker on `GraphNode`.** `isClonedHalt: boolean` (additional to `isHalt: true`). Real halt has `isHalt: true, isClonedHalt: false`; cloned halts have both `true`. Downstream uses this to: - - **#9** — emit cloned-halts with a different CSS class (`.cloned-halt` vs `.halt`) for styling. - - **#10** — skip cloned-halts when computing "current state highlight" (they're visualization aids, not runtime states). - - **#37** — skip cloned-halts when wiring click-to-toggle breakpoint handlers. +2. **Halt-marker marker on `GraphNode`.** `isHaltMarker: boolean` (additional to `isHalt: true`). Real halt has `isHalt: true, isHaltMarker: false`; halt markers have both `true`. Downstream uses this to: + - **#9** — emit halt-markers with a different CSS class (`.halt-marker` vs `.halt`) for styling. + - **#10** — skip halt-markers when computing "current state highlight" (they're visualization aids, not runtime states). + - **#37** — skip halt-markers when wiring click-to-toggle breakpoint handlers. 3. **Edge identity on `GraphTransition`.** Add `id: string` field, deterministic from `(fromNodeId, patternIndex)` where `patternIndex` is the index of that transition in the bare's symbol map. Mermaid emit injects the id via a CSS-class directive that downstream can target. This is what #10 needs to highlight "the edge that will fire next" precisely. diff --git a/packages/library-binary-numbers-bare/states.md b/packages/library-binary-numbers-bare/states.md index fdb91df..1e440f3 100644 --- a/packages/library-binary-numbers-bare/states.md +++ b/packages/library-binary-numbers-bare/states.md @@ -12,11 +12,11 @@ flowchart TD s2["plusOne"] idle([idle]) idle -. enter .-> s2 - s1 -- "'1' → '0'/L" --> s1 - s1 -- "'0' → '1'/S" --> s0 - s1 -- "B → '1'/S" --> s0 - s2 -- "'0'|'1' → K/R" --> s2 - s2 -- "B → K/L" --> s1 + s1 -- "['1'] → ['0']/[L]" --> s1 + s1 -- "['0'] → ['1']/[S]" --> s0 + s1 -- "[B] → ['1']/[S]" --> s0 + s2 -- "['0']|['1'] → [K]/[R]" --> s2 + s2 -- "[B] → [K]/[L]" --> s1 ``` ## minusOne @@ -31,11 +31,11 @@ flowchart TD s4["minusOne"] idle([idle]) idle -. enter .-> s4 - s3 -- "'0' → '1'/L" --> s3 - s3 -- "'1' → '0'/S" --> s0 - s3 -- "B → K/S" --> s0 - s4 -- "'0'|'1' → K/R" --> s4 - s4 -- "B → K/L" --> s3 + s3 -- "['0'] → ['1']/[L]" --> s3 + s3 -- "['1'] → ['0']/[S]" --> s0 + s3 -- "[B] → [K]/[S]" --> s0 + s4 -- "['0']|['1'] → [K]/[R]" --> s4 + s4 -- "[B] → [K]/[L]" --> s3 ``` ## invertNumber @@ -49,9 +49,9 @@ flowchart TD s5["invertNumber"] idle([idle]) idle -. enter .-> s5 - s5 -- "'0' → '1'/R" --> s5 - s5 -- "'1' → '0'/R" --> s5 - s5 -- "B → K/S" --> s0 + s5 -- "['0'] → ['1']/[R]" --> s5 + s5 -- "['1'] → ['0']/[R]" --> s5 + s5 -- "[B] → [K]/[S]" --> s0 ``` ## normalizeNumber @@ -65,7 +65,7 @@ flowchart TD s6["normalizeNumber"] idle([idle]) idle -. enter .-> s6 - s6 -- "'0' → E/R" --> s6 - s6 -- "'1' → K/S" --> s0 - s6 -- "B → '0'/S" --> s0 + s6 -- "['0'] → [E]/[R]" --> s6 + s6 -- "['1'] → [K]/[S]" --> s0 + s6 -- "[B] → ['0']/[S]" --> s0 ``` diff --git a/packages/library-binary-numbers/src/graphs.spec.ts b/packages/library-binary-numbers/src/graphs.spec.ts index ff6b3e4..c10938f 100644 --- a/packages/library-binary-numbers/src/graphs.spec.ts +++ b/packages/library-binary-numbers/src/graphs.spec.ts @@ -3,7 +3,7 @@ import binaryNumbers from './index'; // Per-state counts pinned from the source comments above each declaration in // `index.ts`. These are runtime state counts (per `summarize().stateCount`), -// which exclude the `isClonedHalt` visualization sentinels v7 synthesizes +// which exclude the `isHaltMarker` visualization sentinels v7 synthesizes // inside each `halt frame` subgraph. The states.md per-algorithm header uses // the same definition, so all three sources agree by construction. const expectedNodeCount: Record = { @@ -40,10 +40,10 @@ describe('library-binary-numbers state graphs', () => { // Every algorithm has exactly one REAL halt node (the singleton's id is // shared across all states' graphs). v7's wrapper-emit synthesizes one - // `isClonedHalt: true` node per wrapper context as a visualization aid — + // `isHaltMarker: true` node per wrapper context as a visualization aid — // those are filtered out here. const realHaltNodes = Object.values(graph.nodes) - .filter((node) => node.isHalt && !node.isClonedHalt); + .filter((node) => node.isHalt && !node.isHaltMarker); expect(realHaltNodes).toHaveLength(1); }, diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 7040cc6..1c38dfc 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -11,8 +11,8 @@ flowchart TD s1["goToNumber"] idle([idle]) idle -. enter .-> s1 - s1 -- "'$' → K/S" --> s0 - s1 -- "🞰 → K/R" --> s1 + s1 -- "['$'] → [K]/[S]" --> s0 + s1 -- "[🞰] → [K]/[R]" --> s1 ``` ## goToNextNumber @@ -27,9 +27,9 @@ flowchart TD s2["goToNextNumber"] idle([idle]) idle -. enter .-> s2 - s1 -- "'$' → K/S" --> s0 - s1 -- "🞰 → K/R" --> s1 - s2 -- "🞰 → K/R" --> s1 + s1 -- "['$'] → [K]/[S]" --> s0 + s1 -- "[🞰] → [K]/[R]" --> s1 + s2 -- "[🞰] → [K]/[R]" --> s1 ``` ## goToPreviousNumber @@ -44,9 +44,9 @@ flowchart TD s4["goToPreviousNumber"] idle([idle]) idle -. enter .-> s4 - s3 -- "'$' → K/S" --> s0 - s3 -- "🞰 → K/L" --> s3 - s4 -- "🞰 → K/L" --> s3 + s3 -- "['$'] → [K]/[S]" --> s0 + s3 -- "[🞰] → [K]/[L]" --> s3 + s4 -- "[🞰] → [K]/[L]" --> s3 ``` ## deleteNumber @@ -65,13 +65,13 @@ flowchart TD c7(((halt))) end idle -. enter .-> s8 - s6 -- "'$' → E/S" --> s0 - s6 -- "🞰 → E/R" --> s6 - s7 -- "'^' → K/S" --> c7 - s7 -- "🞰 → K/L" --> s7 + s6 -- "['$'] → [E]/[S]" --> s0 + s6 -- "[🞰] → [E]/[R]" --> s6 + s7 -- "['^'] → [K]/[S]" --> c7 + s7 -- "[🞰] → [K]/[L]" --> s7 s7 -. onHalt .-> s6 - s8 == "'^'|'1'|'0'|'$' → K/S" ==> s7 - s8 -- "🞰 → K/S" --> s0 + s8 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s7 + s8 -- "[🞰] → [K]/[S]" --> s0 ``` ## goToNumbersStart @@ -85,8 +85,8 @@ flowchart TD s5["goToNumberStart"] idle([idle]) idle -. enter .-> s5 - s5 -- "'^' → K/S" --> s0 - s5 -- "🞰 → K/L" --> s5 + s5 -- "['^'] → [K]/[S]" --> s0 + s5 -- "[🞰] → [K]/[L]" --> s5 ``` ## invertNumber @@ -105,15 +105,15 @@ flowchart TD c10(((halt))) end idle -. enter .-> s11 - s9 -- "'^' → K/R" --> s9 - s9 -- "'1' → '0'/R" --> s9 - s9 -- "'0' → '1'/R" --> s9 - s9 -- "'$' → K/S" --> s0 - s10 -- "'^' → K/S" --> c10 - s10 -- "🞰 → K/L" --> s10 + s9 -- "['^'] → [K]/[R]" --> s9 + s9 -- "['1'] → ['0']/[R]" --> s9 + s9 -- "['0'] → ['1']/[R]" --> s9 + s9 -- "['$'] → [K]/[S]" --> s0 + s10 -- "['^'] → [K]/[S]" --> c10 + s10 -- "[🞰] → [K]/[L]" --> s10 s10 -. onHalt .-> s9 - s11 == "'^'|'1'|'0'|'$' → K/S" ==> s10 - s11 -- "🞰 → K/S" --> s0 + s11 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 + s11 -- "[🞰] → [K]/[S]" --> s0 ``` ## normalizeNumber @@ -134,16 +134,16 @@ flowchart TD c14(((halt))) end idle -. enter .-> s15 - s1 -- "'$' → K/S" --> s0 - s1 -- "🞰 → K/R" --> s1 - s12 -- "B → '^'/S" --> s1 - s13 -- "'^'|'0' → E/R" --> s13 - s13 -- "'1'|'$' → K/L" --> s12 - s14 -- "'^' → K/S" --> c14 - s14 -- "🞰 → K/L" --> s14 + s1 -- "['$'] → [K]/[S]" --> s0 + s1 -- "[🞰] → [K]/[R]" --> s1 + s12 -- "[B] → ['^']/[S]" --> s1 + s13 -- "['^']|['0'] → [E]/[R]" --> s13 + s13 -- "['1']|['$'] → [K]/[L]" --> s12 + s14 -- "['^'] → [K]/[S]" --> c14 + s14 -- "[🞰] → [K]/[L]" --> s14 s14 -. onHalt .-> s13 - s15 == "'^'|'1'|'0'|'$' → K/S" ==> s14 - s15 -- "🞰 → K/S" --> s0 + s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 + s15 -- "[🞰] → [K]/[S]" --> s0 ``` ## plusOne @@ -160,16 +160,16 @@ flowchart TD s19["plusOne"] idle([idle]) idle -. enter .-> s19 - s16 -- "'1' → '0'/R" --> s16 - s16 -- "'$' → K/S" --> s0 - s17 -- "B → '^'/R" --> s17 - s17 -- "'1' → K/R" --> s16 - s18 -- "'0' → '1'/R" --> s16 - s18 -- "'1' → K/L" --> s18 - s18 -- "'^' → '1'/L" --> s17 - s19 -- "'^'|'1'|'0' → K/R" --> s19 - s19 -- "'$' → K/L" --> s18 - s19 -- "🞰 → K/S" --> s0 + s16 -- "['1'] → ['0']/[R]" --> s16 + s16 -- "['$'] → [K]/[S]" --> s0 + s17 -- "[B] → ['^']/[R]" --> s17 + s17 -- "['1'] → [K]/[R]" --> s16 + s18 -- "['0'] → ['1']/[R]" --> s16 + s18 -- "['1'] → [K]/[L]" --> s18 + s18 -- "['^'] → ['1']/[L]" --> s17 + s19 -- "['^']|['1']|['0'] → [K]/[R]" --> s19 + s19 -- "['$'] → [K]/[L]" --> s18 + s19 -- "[🞰] → [K]/[S]" --> s0 ``` ## minusOne @@ -211,43 +211,43 @@ flowchart TD c22(((halt))) end idle -. enter .-> s23 - s1 -- "'$' → K/S" --> s0 - s1 -- "🞰 → K/R" --> s1 - s9 -- "'^' → K/R" --> s9 - s9 -- "'1' → '0'/R" --> s9 - s9 -- "'0' → '1'/R" --> s9 - s9 -- "'$' → K/S" --> s0 - s10 -- "'^' → K/S" --> c10 - s10 -- "🞰 → K/L" --> s10 + s1 -- "['$'] → [K]/[S]" --> s0 + s1 -- "[🞰] → [K]/[R]" --> s1 + s9 -- "['^'] → [K]/[R]" --> s9 + s9 -- "['1'] → ['0']/[R]" --> s9 + s9 -- "['0'] → ['1']/[R]" --> s9 + s9 -- "['$'] → [K]/[S]" --> s0 + s10 -- "['^'] → [K]/[S]" --> c10 + s10 -- "[🞰] → [K]/[L]" --> s10 s10 -. onHalt .-> s9 - s12 -- "B → '^'/S" --> s1 - s13 -- "'^'|'0' → E/R" --> s13 - s13 -- "'1'|'$' → K/L" --> s12 - s14 -- "'^' → K/S" --> c14 - s14 -- "🞰 → K/L" --> s14 + s12 -- "[B] → ['^']/[S]" --> s1 + s13 -- "['^']|['0'] → [E]/[R]" --> s13 + s13 -- "['1']|['$'] → [K]/[L]" --> s12 + s14 -- "['^'] → [K]/[S]" --> c14 + s14 -- "[🞰] → [K]/[L]" --> s14 s14 -. onHalt .-> s13 - s15 == "'^'|'1'|'0'|'$' → K/S" ==> s14 - s15 -- "🞰 → K/S" --> s0 - s16 -- "'1' → '0'/R" --> s16 - s16 -- "'$' → K/S" --> s0 - s17 -- "B → '^'/R" --> s17 - s17 -- "'1' → K/R" --> s16 - s18 -- "'0' → '1'/R" --> s16 - s18 -- "'1' → K/L" --> s18 - s18 -- "'^' → '1'/L" --> s17 - s20 == "'^'|'1'|'0'|'$' → K/S" ==> s10 - s20 -- "🞰 → K/S" --> c20 + s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 + s15 -- "[🞰] → [K]/[S]" --> s0 + s16 -- "['1'] → ['0']/[R]" --> s16 + s16 -- "['$'] → [K]/[S]" --> s0 + s17 -- "[B] → ['^']/[R]" --> s17 + s17 -- "['1'] → [K]/[R]" --> s16 + s18 -- "['0'] → ['1']/[R]" --> s16 + s18 -- "['1'] → [K]/[L]" --> s18 + s18 -- "['^'] → ['1']/[L]" --> s17 + s20 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 + s20 -- "[🞰] → [K]/[S]" --> c20 s20 -. onHalt .-> s15 - s21 -- "'^'|'1'|'0' → K/R" --> s21 - s21 -- "'$' → K/L" --> s18 - s21 -- "🞰 → K/S" --> c21 + s21 -- "['^']|['1']|['0'] → [K]/[R]" --> s21 + s21 -- "['$'] → [K]/[L]" --> s18 + s21 -- "[🞰] → [K]/[S]" --> c21 s21 -. onHalt .-> s20 - s22 == "'^'|'1'|'0'|'$' → K/S" ==> s10 - s22 -- "🞰 → K/S" --> c22 + s22 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 + s22 -- "[🞰] → [K]/[S]" --> c22 s22 -. onHalt .-> s21 - s23 -- "'^'|'1'|'0' → K/R" --> s23 - s23 == "'$' → K/S" ==> s22 - s23 -- "🞰 → K/S" --> s0 + s23 -- "['^']|['1']|['0'] → [K]/[R]" --> s23 + s23 == "['$'] → [K]/[S]" ==> s22 + s23 -- "[🞰] → [K]/[S]" --> s0 ``` ## minusOneFast @@ -273,21 +273,21 @@ flowchart TD c25(((halt))) end idle -. enter .-> s26 - s1 -- "'$' → K/S" --> s0 - s1 -- "🞰 → K/R" --> s1 - s12 -- "B → '^'/S" --> s1 - s13 -- "'^'|'0' → E/R" --> s13 - s13 -- "'1'|'$' → K/L" --> s12 - s14 -- "'^' → K/S" --> c14 - s14 -- "🞰 → K/L" --> s14 + s1 -- "['$'] → [K]/[S]" --> s0 + s1 -- "[🞰] → [K]/[R]" --> s1 + s12 -- "[B] → ['^']/[S]" --> s1 + s13 -- "['^']|['0'] → [E]/[R]" --> s13 + s13 -- "['1']|['$'] → [K]/[L]" --> s12 + s14 -- "['^'] → [K]/[S]" --> c14 + s14 -- "[🞰] → [K]/[L]" --> s14 s14 -. onHalt .-> s13 - s15 == "'^'|'1'|'0'|'$' → K/S" ==> s14 - s15 -- "🞰 → K/S" --> s0 - s25 -- "'1' → '0'/S" --> c25 - s25 -- "'0' → '1'/L" --> s25 - s25 -- "'^' → K/S" --> c25 + s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 + s15 -- "[🞰] → [K]/[S]" --> s0 + s25 -- "['1'] → ['0']/[S]" --> c25 + s25 -- "['0'] → ['1']/[L]" --> s25 + s25 -- "['^'] → [K]/[S]" --> c25 s25 -. onHalt .-> s15 - s26 -- "'^'|'1'|'0' → K/R" --> s26 - s26 == "'$' → K/L" ==> s25 - s26 -- "🞰 → K/S" --> s0 + s26 -- "['^']|['1']|['0'] → [K]/[R]" --> s26 + s26 == "['$'] → [K]/[L]" ==> s25 + s26 -- "[🞰] → [K]/[S]" --> s0 ``` diff --git a/packages/machine/README.md b/packages/machine/README.md index 065e109..9880a61 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -16,6 +16,7 @@ A composable Turing-machine engine for JavaScript: multi-tape, subroutine compos - [Debugging breakpoints](#debugging-breakpoints) - [Special objects](#special-objects) — [`haltState`](#haltstate) · [`ifOtherSymbol`](#ifothersymbol) · [`movements`](#movements) · [`symbolCommands`](#symbolcommands) - [Introspection and testing](#introspection-and-testing) +- [Diagram conventions](#diagram-conventions) - [Versioning notes](#versioning-notes) - [Libraries](#libraries) - [Links](#links) @@ -79,18 +80,16 @@ flowchart TD s1["replaceB"] idle([idle]) idle -. enter .-> s1 - s1 -- "'b' → '*'/R" --> s1 - s1 -- "B → K/L" --> s0 - s1 -- "🞰 → K/R" --> s1 + s1 -- "['b'] → ['*']/[R]" --> s1 + s1 -- "[B] → [K]/[L]" --> s0 + s1 -- "[🞰] → [K]/[R]" --> s1 ``` -Engine notation: `read → write/move`. Write commands: `K` = keep, `E` = erase (write the blank). Movements: `L` = left, `R` = right, `S` = stay. Literal alphabet symbols are wrapped in single quotes: `'b'`, `'*'`, `'X'`. Unquoted markers: `🞰` (U+1F7B0 heavy-eight-balloon-spoked-asterisk) = `ifOtherSymbol` catch-all, `B` = the tape's blank symbol (a literal `B` in the alphabet appears as the quoted `'B'`, so the marker stays unambiguous). `(((double-paren)))` = halt; `["square"]` = a regular state. The `idle([idle])` sentinel + labeled-dotted `-. enter .->` arrow marks where execution begins. Wrapped states (subroutine-shaped `[[double-walled]]`) sit inside a `subgraph w_N["halt frame"]` block — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) below. **Arrow styles** between states: regular `-->` for plain transitions; thick `==>` for transitions whose target is a wrapped state (= stack-push happens at runtime); dotted `-. onHalt .->` for the wrapper's catch-and-redirect. - -The shapes and arrow styles above are standard [Mermaid flowchart syntax](https://mermaid.js.org/syntax/flowchart.html); any tool that renders Mermaid (GitHub preview, IDE plugins, [mermaid-js](https://github.com/mermaid-js/mermaid) client-side) will paint these diagrams the same way. +Reading this specific diagram: `replaceB` (the rectangle) is the start state, marked by the dotted `enter` arrow from the `idle` sentinel. Three self-or-halt transitions: read `'b'` → write `'*'` and step right; read anything else (`🞰`) → keep, step right; read blank (`B`) → keep, step left, halt. Full notation reference — shapes, edge styles, label vocabulary — in [§Diagram conventions](#diagram-conventions). A `State` is keyed by JS `Symbol`s returned from `tapeBlock.symbol(pattern)` — the pattern lists the expected symbol under each tape's head. Sentinels and constants used throughout: [`ifOtherSymbol`](#ifothersymbol) is the fallback key when nothing else matches; transitioning into [`haltState`](#haltstate) stops the run; [`movements`](#movements)`.{left,right,stay}` direct head moves; [`symbolCommands`](#symbolcommands)`.{keep,erase}` are write shortcuts. Full definitions in [§Special objects](#special-objects). -For multi-tape machines, pass one element per tape: `tapeBlock.symbol(['0', 'a'])` matches only when tape 1 is at `'0'` and tape 2 is at `'a'`. +For multi-tape machines, pass one element per tape: `tapeBlock.symbol(['0', 'a'])` matches only when tape 1 is at `'0'` and tape 2 is at `'a'`. See the multi-tape example in [§Diagram conventions](#diagram-conventions) for what the rendered graph looks like. ## Building from a state table @@ -261,8 +260,8 @@ flowchart TD s1["name"] idle([idle]) idle -. enter .-> s1 - s1 -- "'1' → '0'/R" --> s1 - s1 -- "'$' → K/L" --> s0 + s1 -- "['1'] → ['0']/[R]" --> s1 + s1 -- "['$'] → [K]/[L]" --> s0 ``` *Edge labels are `read → write/move`. Write commands: `K` = keep (no write), `E` = erase (write the blank). Literal alphabet symbols are quoted (`'1'`, `'$'`). Movements: `L` (left), `R` (right), `S` (stay).* @@ -291,8 +290,8 @@ flowchart TD s2["b"] idle([idle]) idle -. enter .-> s1 - s1 -- "'x' → K/S" --> s2 - s2 -- "'y' → K/S" --> s1 + s1 -- "['x'] → [K]/[S]" --> s2 + s2 -- "['y'] → [K]/[S]" --> s1 ``` `idle -. enter .->` points at the initial state passed to `toGraph` (`a` here); `b` is reachable from `a` via the bound `Reference`. @@ -388,8 +387,8 @@ flowchart TD s1["scanToX"] idle([idle]) idle -. enter .-> s1 - s1 -- "'X' → K/S" --> s0 - s1 -- "🞰 → K/R" --> s1 + s1 -- "['X'] → [K]/[S]" --> s0 + s1 -- "[🞰] → [K]/[R]" --> s1 ``` `toMermaid(toGraph(scanThenErase, tapeBlock))` — the wrapped composition: @@ -405,9 +404,9 @@ flowchart TD c3(((halt))) end idle -. enter .-> s3 - s2 -- "🞰 → E/S" --> s0 - s3 -- "'X' → K/S" --> c3 - s3 -- "🞰 → K/R" --> s3 + s2 -- "[🞰] → [E]/[S]" --> s0 + s3 -- "['X'] → [K]/[S]" --> c3 + s3 -- "[🞰] → [K]/[R]" --> s3 s3 -. onHalt .-> s2 ``` @@ -415,11 +414,11 @@ flowchart TD 1. **The subgraph rectangle labeled `"halt frame"`** is the wrapper's runtime scope — while execution is "inside" this rectangle, the override target (`eraseHere`) sits on the runtime stack waiting to catch a halt. Visual-only; it does not mutate any edges. 2. **`[[scanToX]]` (Mermaid subroutine / double-walled-rectangle shape)** is the wrapper node. It's both the runtime entry point (execution starts here when entering the wrapper) AND the source of the dotted `onHalt` redirect. The wrapper's composite name (`scanToX(eraseHere)`) is computed at runtime via `state.name` but does not appear as a graph node label — only the bare's name is in the graph. -3. **The cloned `(((halt)))` inside the subgraph** (`c3` here) is where the bare's halt-bound transitions land *inside* the wrapper's scope. `haltState` is a runtime singleton; the cloned node is a teaching aid showing "halt is caught here, not at the real terminus." Solid arrows from the bare to the cloned halt all stay inside the rectangle. +3. **The cloned `(((halt)))` inside the subgraph** (`c3` here) is where the bare's halt-bound transitions land *inside* the wrapper's scope. `haltState` is a runtime singleton; the cloned node is a teaching aid showing "halt is caught here, not at the real terminus." Solid arrows from the bare to the halt marker all stay inside the rectangle. 4. **The dotted `onHalt` arrow from `[[scanToX]]` to `eraseHere`** is the wrapper's catch-and-redirect. Originates from the wrapper-node since the wrapper *is* the catcher. Solid arrows from `[[scanToX]]` to other states can also cross the subgraph border — those are just regular runtime transitions whose target happens to be drawn outside this rectangle (only the dotted `onHalt` carries wrapper-machinery meaning). In larger compositions (`library-binary-numbers`'s `minusOne`), solid transitions whose target is *itself* a wrapped state render as a **thick `==>` arrow** instead of `-->` — that's the visual signal for "this transition enters a halt frame, pushing the override onto the runtime stack." Stack-growth structure is then scannable from the diagram: count thick arrows along an execution path to see how deep the stack gets. 5. **Real `(((halt)))` outside any subgraph** (`s0`) is the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame — here, by `eraseHere` after it erases the cell. -**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `🞰 → K/R` self-loops until the head sees `X`; the `'X' → K/S` solid edge would normally halt — it lands on the cloned halt `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `🞰 → E/S` and halts at real `s0`. Run terminates. +**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `[🞰] → [K]/[R]` self-loops until the head sees `X`; the `['X'] → [K]/[S]` solid edge would normally halt — it lands on the halt marker `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `[🞰] → [E]/[S]` and halts at real `s0`. Run terminates. > 💡 **Round-trip caveat.** `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for simple wrappers like this one ([#139](https://github.com/mellonis/turing-machine-js/issues/139) regression). For shared-bare cases (same `State` instance used as the bare in multiple wrappers — e.g., `library-binary-numbers`'s `minusOne`), per-context duplication produces wrapper-id-dependent ordering that doesn't byte-match across rebuilds — equivalent runtime behavior, different emit-line order. @@ -477,7 +476,7 @@ If `onPause` is not provided, breaks fire-and-resume invisibly — the trajector **Caveat:** `haltState` is a module-level singleton. Setting `haltState.debug` affects every machine in the process; clear in `afterEach` / `finally` for test isolation. -### Throttle pattern (v6.4.0+) +### Throttle pattern For per-iter throttle / animation / "wait between steps" UIs, use the **`onIter`** hook — an awaited callback that fires once at the end of every iter, after both `onPause` dispatches on the same yield. It's the engine-native shape for per-iter coordination: @@ -567,6 +566,77 @@ Together: use `summarize` to ask "is this machine the right shape?" (size, compo For visualization and round-tripping, see `State.toGraph` / `State.fromGraph` and `toMermaid` / `fromMermaid`. +## Diagram conventions + +The full reference for reading `toMermaid` output — shapes, edge styles, and the bracketed edge-label vocabulary. All shapes and arrows are standard [Mermaid flowchart syntax](https://mermaid.js.org/syntax/flowchart.html); any Mermaid renderer (GitHub preview, IDE plugins, [mermaid-js](https://github.com/mermaid-js/mermaid) client-side) paints these diagrams the same way. + +### Node shapes + +| Shape | Meaning | +|---|---| +| `s0(((halt)))` | the halt state | +| `sN["name"]` | a regular state | +| `sN[["name"]]` | a `withOverriddenHaltState` wrapper-bare (subroutine shape) — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) | +| `cN(((halt)))` inside a subgraph | halt marker (visualization aid; maps back to the singleton `haltState` at runtime) | +| `idle([idle])` | pre-execution sentinel (not a real state) | + +### Edge styles + +| Style | Where | Meaning | +|---|---|---| +| `-->` regular solid | between states | plain transition | +| `==>` thick solid | between states | transition INTO a wrapped state — stack-push happens at runtime | +| `-. onHalt .->` dotted | from `[[bare]]` to override | wrapper's catch-and-redirect | +| `-. enter .->` dotted | from `idle` to initial state | execution-start marker | + +### Groupings + +`subgraph w_N["halt frame"] … end` wraps a `[[bare]]` + its halt marker — visual grouping of the wrapper's runtime halt-handling scope. + +### Edge label format + +`[reads] → [writes]/[moves]`. Each bracketed list is a tape-block reading — one entry per tape; brackets always present, even single-tape. + +| Glyph | Where | Meaning | +|---|---|---| +| `'X'` | read, write | literal alphabet symbol (single-quoted) | +| `🞰` | read only | `ifOtherSymbol` catch-all (U+1F7B0 heavy-eight-balloon-spoked-asterisk) | +| `B` | read only | the tape's blank symbol (a literal `B` in the alphabet appears as `'B'`, so the marker stays unambiguous) | +| `K` | write only | keep (no write) | +| `E` | write only | erase (write the tape's blank) | +| `L` / `R` / `S` | move only | left / right / stay | + +### Alternation rule + +Alternative read patterns are always per-pattern-bracket: + +- Single-tape: `['^']|['1']|['0']` +- Multi-tape: `['0','a']|['1','b']` — "(tape 1=`'0'` AND tape 2=`'a'`) OR (tape 1=`'1'` AND tape 2=`'b'`)" + +The compact in-bracket form `['^'|'1']` is **rejected** by `fromMermaid` — and never emitted by `toMermaid`. The reason is pedagogical: each alternative is its own drawn transition, and the compact form would read as cross-product semantics in multi-tape (`['0'|'1','a'|'b']` could mean 4 combinations rather than 2 paired alternatives). One consistent rule across tape counts: each alternative is a full bracketed pattern. + +### Multi-tape example + +A 2-tape "copier" machine — as long as tape 1 reads a non-blank, write the same symbol to tape 2 and step both right; halt when tape 1 reads blank: + +```mermaid +flowchart TD +%% alphabets: [[" ","0","1"],[" ","0","1"]] + s0(((halt))) + s1["copy"] + idle([idle]) + idle -. enter .-> s1 + s1 -- "['0',🞰] → [K,'0']/[R,R]" --> s1 + s1 -- "['1',🞰] → [K,'1']/[R,R]" --> s1 + s1 -- "[B,🞰] → [K]/[S]" --> s0 +``` + +Reading `['0',🞰] → [K,'0']/[R,R]`: + +- **Read** `['0',🞰]` — tape 1 must be literal `'0'`; tape 2 is `ifOtherSymbol` (any). +- **Write** `[K,'0']` — tape 1: keep; tape 2: write literal `'0'`. +- **Move** `[R,R]` — both tapes step right. + ## Versioning notes API surface changes since v3, in past tense so the timing of each piece is explicit: @@ -581,7 +651,7 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v7** *(in progress)* — Composition-representation overhaul. Breaking renames + reshapes scheduled for the v7 cut. Landing piecewise on the `v7` branch; one entry per landed change: - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly. - - **`toMermaid` wrapped-state emit overhaul** ([#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139)). The wrapper-and-its-bare pair collapses into a single graph node (`isWrapped: true`); the wrapper's composite name no longer appears as a node label (only the bare's name does). Each wrapper gets a Mermaid `subgraph w_${bareId}["halt frame"] … end` block containing the `[[bare]]` (subroutine shape) plus a cloned `(((halt)))` (visualization aid showing where halt-bound transitions land inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target — exactly one per wrapper. `Graph` data shape gains `isWrapped` and `isClonedHalt` flags on `GraphNode` and a stable `id` on `GraphTransition` (deterministic per-edge identifier — supports downstream tooling like the `machines-demo` interactive viewer at [machines-demo#10](https://github.com/mellonis/machines-demo/issues/10)). Cloned-halt graph nodes use negative ids and round-trip back to the singleton `haltState` via `fromGraph`. Bytewise round-trip stability falls out for simple wrappers (no composite name in the graph means `fromGraph(toGraph(state))` recomputes names fresh — no accumulation). Shared-bare cases (e.g. `minusOne`'s repeated `invertNumber`) use per-context duplication in the graph emit. + - **`toMermaid` wrapped-state emit overhaul** ([#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139)). The wrapper-and-its-bare pair collapses into a single graph node (`isWrapped: true`); the wrapper's composite name no longer appears as a node label (only the bare's name does). Each wrapper gets a Mermaid `subgraph w_${bareId}["halt frame"] … end` block containing the `[[bare]]` (subroutine shape) plus a cloned `(((halt)))` (visualization aid showing where halt-bound transitions land inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target — exactly one per wrapper. `Graph` data shape gains `isWrapped` and `isHaltMarker` flags on `GraphNode` and a stable `id` on `GraphTransition` (deterministic per-edge identifier — supports downstream tooling like the `machines-demo` interactive viewer at [machines-demo#10](https://github.com/mellonis/machines-demo/issues/10)). Halt-marker graph nodes use negative ids and round-trip back to the singleton `haltState` via `fromGraph`. Bytewise round-trip stability falls out for simple wrappers (no composite name in the graph means `fromGraph(toGraph(state))` recomputes names fresh — no accumulation). Shared-bare cases (e.g. `minusOne`'s repeated `invertNumber`) use per-context duplication in the graph emit. For the full release history, see the [GitHub releases page](https://github.com/mellonis/turing-machine-js/releases). diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index 43db5f1..a9f9f21 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -237,9 +237,9 @@ describe('State.fromGraph — cyclic override-halt chain', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 2, isWrapped: false, isClonedHalt: false}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 1, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 1, isWrapped: false, isHaltMarker: false}, }, }; diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 8eca6e4..926ced9 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -345,12 +345,12 @@ export default class State { // wrapper-States (those with `#overriddenHaltState !== null`) are collapsed // onto their bare's representation in the graph, with the wrapper's own `#id` // used as the graph node id, `isWrapped: true`, and `overriddenHaltStateId` - // set to the override's collapsed id. A per-wrapper "cloned halt" graph node - // (id = negative-of-the-wrapper-id, `isHalt: true, isClonedHalt: true`) is + // set to the override's collapsed id. A per-wrapper "halt marker" graph node + // (id = negative-of-the-wrapper-id, `isHalt: true, isHaltMarker: true`) is // synthesized; the bare's halt-bound transitions are rewritten to target the - // cloned halt instead of the real one. + // halt marker instead of the real one. // - // Cloned-halt node ids use the negation of the wrapper's id so they sit in a + // Halt-marker node ids use the negation of the wrapper's id so they sit in a // disjoint integer range from real ids (which are always non-negative). Real // halt is always id 0. static toGraph(initialState: State, tapeBlock: TapeBlock): Graph { @@ -397,7 +397,7 @@ export default class State { id: 0, name: state.#name, isHalt: true, - isClonedHalt: false, + isHaltMarker: false, isWrapped: false, transitions: [], overriddenHaltStateId: null, @@ -426,13 +426,13 @@ export default class State { ? wrapperGraphId(overrideTarget) : overrideTarget.#id; - // Emit the cloned-halt node if not already present (one per wrapper). + // Emit the halt-marker node if not already present (one per wrapper). if (!(clonedHaltId in nodes)) { nodes[clonedHaltId] = { id: clonedHaltId, name: 'halt', isHalt: true, - isClonedHalt: true, + isHaltMarker: true, isWrapped: false, transitions: [], overriddenHaltStateId: null, @@ -444,7 +444,7 @@ export default class State { id: collapsedId, name: state.#name, isHalt: false, - isClonedHalt: false, + isHaltMarker: false, isWrapped: true, transitions: [], overriddenHaltStateId: overrideGraphId, @@ -465,7 +465,7 @@ export default class State { } // Retarget transitions per Variant X conventions: - // - target == haltState → cloned halt (stays inside the subgraph) + // - target == haltState → halt marker (stays inside the subgraph) // - target == bare (self-loop) → the collapsed wrapper id // - target is itself a wrapper → that wrapper's collapsed id // - else → target's own id @@ -515,7 +515,7 @@ export default class State { id: state.#id, name: state.#name, isHalt: false, - isClonedHalt: false, + isHaltMarker: false, isWrapped: false, transitions: [], overriddenHaltStateId: null, diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index b3d29d2..03ca516 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -117,12 +117,12 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, transitions: [ - {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, - {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, + {pattern: "'0'", command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, + {pattern: "'1'", command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, ], }, }, @@ -134,8 +134,8 @@ describe('toMermaid', () => { expect(out).toContain('s1["entry"]'); expect(out).toContain('idle([idle])'); expect(out).toContain('idle -. enter .-> s1'); - expect(out).toContain('s1 -- "0 → K/R" --> s1'); - expect(out).toContain('s1 -- "1 → K/S" --> s0'); + expect(out).toContain("s1 -- \"['0'] → [K]/[R]\" --> s1"); + expect(out).toContain("s1 -- \"['1'] → [K]/[S]\" --> s0"); }); test('renders dotted onHalt edge when overriddenHaltStateId is set', () => { @@ -143,8 +143,8 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, - 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isWrapped: false, isHaltMarker: false}, }, }); @@ -156,9 +156,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, - 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, - 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, }, }); @@ -170,12 +170,12 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0'], [' ', 'a']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, transitions: [{ - pattern: '0,a', - command: [{symbol: '0', movement: 'R'}, {symbol: 'a', movement: 'L'}], + pattern: "'0','a'", + command: [{symbol: "'0'", movement: 'R'}, {symbol: "'a'", movement: 'L'}], nextStateId: 0, id: 'test-edge', }], @@ -183,7 +183,7 @@ describe('toMermaid', () => { }, }); - expect(out).toContain('"0,a → 0/R,a/L"'); + expect(out).toContain("\"['0','a'] → ['0','a']/[R,L]\""); }); }); @@ -259,10 +259,38 @@ describe('fromMermaid error paths', () => { ' s0(((halt)))', ' idle([idle])', ' idle -. enter .-> s1', - ' s1 -- "* → noslash" --> s0', + ' s1 -- "[*] → noslash" --> s0', ].join('\n'); - expect(() => fromMermaid(mermaid)).toThrow('malformed command part'); + expect(() => fromMermaid(mermaid)).toThrow('malformed command label'); + }); + + test('rejects compact in-bracket alternation (must use per-pattern brackets)', () => { + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0","1"]]', + ' s0(((halt)))', + ' s1["entry"]', + ' idle([idle])', + ' idle -. enter .-> s1', + " s1 -- \"['0'|'1'] → [K]/[R]\" --> s0", // compact alternation — should fail + ].join('\n'); + + expect(() => fromMermaid(mermaid)).toThrow(/compact in-bracket alternation/); + }); + + test('rejects `|` inside a write/move bracket too (commands have no alternation)', () => { + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0","1"]]', + ' s0(((halt)))', + ' s1["entry"]', + ' idle([idle])', + ' idle -. enter .-> s1', + " s1 -- \"['0'] → [K|E]/[R]\" --> s0", // `|` in writes — should fail + ].join('\n'); + + expect(() => fromMermaid(mermaid)).toThrow(/compact in-bracket alternation/); }); }); @@ -322,8 +350,8 @@ describe('README example: toMermaid output is stable', () => { ' s1["name"]', ' idle([idle])', ' idle -. enter .-> s1', - " s1 -- \"'1' → '0'/R\" --> s1", - " s1 -- \"'$' → K/L\" --> s0", + " s1 -- \"['1'] → ['0']/[R]\" --> s1", + " s1 -- \"['$'] → [K]/[L]\" --> s0", ].join('\n'); expect(toMermaid(State.toGraph(s, tapeBlock))).toBe(expected); @@ -363,9 +391,9 @@ describe('README diagrams: engine-generated outputs', () => { '["replaceB"]', // initial — square (no longer round in v7; idle arrow signals entry) 'idle([idle])', 'idle -. enter .->', - "\"'b' → '*'/R\"", - '"B → K/L"', - '"🞰 → K/R"', + "\"['b'] → ['*']/[R]\"", + '"[B] → [K]/[L]"', + '"[🞰] → [K]/[R]"', ]); }); @@ -387,8 +415,8 @@ describe('README diagrams: engine-generated outputs', () => { '["b"]', // b is reachable from a → square 'idle([idle])', 'idle -. enter .->', - "\"'x' → K/S\"", - "\"'y' → K/S\"", + "\"['x'] → [K]/[S]\"", + "\"['y'] → [K]/[S]\"", ]); }); @@ -410,8 +438,8 @@ describe('README diagrams: engine-generated outputs', () => { '["scanToX"]', // initial — square (idle arrow signals entry) 'idle([idle])', 'idle -. enter .->', - "\"'X' → K/S\"", - '"🞰 → K/R"', + "\"['X'] → [K]/[S]\"", + '"[🞰] → [K]/[R]"', ]); }); @@ -436,11 +464,11 @@ describe('README diagrams: engine-generated outputs', () => { '(((halt)))', // real halt outside any subgraph '["eraseHere"]', // override is a regular [name] node '[["scanToX"]]', // wrapper-collapsed bare uses subroutine shape inside the subgraph - 'subgraph w_', // halt-frame subgraph wraps the bare + its cloned halt + 'subgraph w_', // halt-frame subgraph wraps the bare + its halt marker '"halt frame"', // subgraph label 'idle([idle])', // pre-execution sentinel — always emitted 'idle -. enter .->', // labeled dotted enter arrow points at the initial state - '"🞰 → E/S"', // eraseHere's erase command + '"[🞰] → [E]/[S]"', // eraseHere's erase command '-. onHalt .->', // the dotted override-halt edge — wrapper's catch-and-redirect, crosses the subgraph border ]); }); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index d2db22d..51c90db 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -23,10 +23,10 @@ export type GraphNode = { // wrapped state. Carries the `[[…]]` (subroutine) shape signal for `toMermaid` // and tells `fromGraph` to reconstruct via `bare.withOverriddenHaltState(target)`. isWrapped: boolean; - // `true` for a synthesized halt-clone graph node — one per wrapper context. - // Real halt has `isHalt: true, isClonedHalt: false`; cloned halts have both - // `true`. `fromGraph` maps cloned-halt nodes back to the singleton `haltState`. - isClonedHalt: boolean; + // `true` for a synthesized halt marker graph node — one per wrapper context. + // Real halt has `isHalt: true, isHaltMarker: false`; halt markers have both + // `true`. `fromGraph` maps halt-marker nodes back to the singleton `haltState`. + isHaltMarker: boolean; }; export type Graph = { diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 02d49ec..c9e55a6 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -10,19 +10,19 @@ import {type Graph, type GraphCommand, type GraphNode} from './graph'; // - Each wrapper-State collapses onto its bare's representation. The collapsed // graph node has `isWrapped: true` and is emitted as Mermaid `[[…]]` // (subroutine / double-walled-rectangle) shape, inside a `subgraph -// w_${id}["halt frame"] … end` block. A synthesized "cloned halt" graph -// node (with `isHalt: true, isClonedHalt: true`, id = -wrapperId in graph +// w_${id}["halt frame"] … end` block. A synthesized "halt marker" graph +// node (with `isHalt: true, isHaltMarker: true`, id = -wrapperId in graph // data) sits inside the subgraph and serves as the local landing point for // the bare's halt-bound transitions. The dotted onHalt edge runs from the // `[[bare]]` directly to the override target, crossing the subgraph border. // - Real halt (id 0) is emitted as `s0(((halt)))` outside any subgraph. -// - Cloned halt nodes use the Mermaid id `c${absId}` (where `absId = -id`) +// - Halt marker nodes use the Mermaid id `c${absId}` (where `absId = -id`) // since Mermaid IDs must match /[A-Za-z][A-Za-z0-9_]*/ — negative numbers // are not legal syntax. // Maps a graph node id to its Mermaid id. // - non-negative id N → "sN" -// - negative id -N (cloned halt) → "cN" +// - negative id -N (halt marker) → "cN" function mermaidIdFor(id: number): string { return id < 0 ? `c${-id}` : `s${id}`; } @@ -43,15 +43,15 @@ export function toMermaid(graph: Graph): string { ]; // Sort nodes by id (ascending — real halt first at 0, regular states next, - // negative-id cloned halts last). Deterministic emit lets `toMermaid` → + // negative-id halt markers last). Deterministic emit lets `toMermaid` → // `fromMermaid` → `toMermaid` round-trip stably (regression for #139). const nodes = Object.values(graph.nodes).slice().sort((a, b) => a.id - b.id); const wrappedNodes = nodes.filter((n) => n.isWrapped); - // Convention: wrapped node id N → cloned halt id -N. + // Convention: wrapped node id N → halt marker id -N. const clonedHaltFor = (wrappedId: number): number => -wrappedId; - // Set of cloned-halt ids that belong to some wrapper (= are inside a subgraph). + // Set of halt-marker ids that belong to some wrapper (= are inside a subgraph). const clonedHaltIds = new Set(); for (const w of wrappedNodes) { @@ -109,13 +109,32 @@ export function toMermaid(graph: Graph): string { // transitions emit in their stored array order (which mirrors the source // state's symbol-map insertion order — stable per State instance). for (const node of nodes) { - if (node.isHalt && !node.isClonedHalt) { + if (node.isHalt && !node.isHaltMarker) { continue; } for (const t of node.transitions) { - const cmd = t.command.map((c) => `${c.symbol}/${c.movement}`).join(','); - const label = `${t.pattern} → ${cmd}`; + // Bracketed-tape-block format (v7): each role-list — read alternatives, + // writes, movements — wraps in `[…]` to mark "this is a tape-block + // reading". Brackets stay even for single-tape machines; the `[…]` is + // the tape-block concept indicator. + // + // Single-tape: ['X'] → [K]/[R] + // Single-tape + alternation: ['^']|['1']|['0'] → [K]/[S] + // Two-tape: ['0','a'] → [K,'1']/[R,S] + // Two-tape + alternation: ['0','a']|['1','b'] → [K,K]/[R,L] + // + // Alternation is ALWAYS per-pattern-bracket — one full bracketed list + // per alternative — regardless of tape count. Pedagogically each + // alternative is its own drawn transition; a compact in-bracket form + // (`['^'|'1']`) would read as cross-product semantics in multi-tape + // (`['0'|'1','a'|'b']` = 4 combos, not 2 paired alternatives), so we + // avoid introducing it for the single-tape case too. + const alternatives = t.pattern.split('|'); + const reads = alternatives.map((alt) => `[${alt}]`).join('|'); + const writes = `[${t.command.map((c) => c.symbol).join(',')}]`; + const moves = `[${t.command.map((c) => c.movement).join(',')}]`; + const label = `${reads} → ${writes}/${moves}`; // Thicker `==>` arrow when the transition crosses INTO a wrapper — // signals "this transition pushes that wrapper's override onto the @@ -174,8 +193,8 @@ export function fromMermaid(text: string): Graph { let alphabets: string[][] = []; let initialId: number | null = null; const nodes: Record = {}; - // Track the cloned-halt ids that appeared inside a subgraph — they should be - // marked `isClonedHalt: true` even though they share the `(((halt)))` shape + // Track the halt-marker ids that appeared inside a subgraph — they should be + // marked `isHaltMarker: true` even though they share the `(((halt)))` shape // with the real halt at the top level. const clonedHaltIds = new Set(); let inSubgraph = false; @@ -185,7 +204,7 @@ export function fromMermaid(text: string): Graph { opts: { name?: string; isHalt?: boolean; - isClonedHalt?: boolean; + isHaltMarker?: boolean; isWrapped?: boolean; } = {}, ): GraphNode => { @@ -194,7 +213,7 @@ export function fromMermaid(text: string): Graph { id, name: opts.name ?? mermaidIdFor(id), isHalt: opts.isHalt ?? false, - isClonedHalt: opts.isClonedHalt ?? false, + isHaltMarker: opts.isHaltMarker ?? false, isWrapped: opts.isWrapped ?? false, transitions: [], overriddenHaltStateId: null, @@ -202,14 +221,14 @@ export function fromMermaid(text: string): Graph { } else { if (opts.name !== undefined) nodes[id].name = opts.name; if (opts.isHalt !== undefined) nodes[id].isHalt = opts.isHalt; - if (opts.isClonedHalt !== undefined) nodes[id].isClonedHalt = opts.isClonedHalt; + if (opts.isHaltMarker !== undefined) nodes[id].isHaltMarker = opts.isHaltMarker; if (opts.isWrapped !== undefined) nodes[id].isWrapped = opts.isWrapped; } return nodes[id]; }; - // First pass: alphabets + nodes (track subgraph context to mark cloned halts). + // First pass: alphabets + nodes (track subgraph context to mark halt markers). for (const line of lines) { if (line === 'flowchart TD') { continue; @@ -245,7 +264,7 @@ export function fromMermaid(text: string): Graph { const id = parseMermaidId(hm[1]); const isCloned = inSubgraph || id < 0; - ensureNode(id, {name: 'halt', isHalt: true, isClonedHalt: isCloned}); + ensureNode(id, {name: 'halt', isHalt: true, isHaltMarker: isCloned}); if (isCloned) { clonedHaltIds.add(id); @@ -301,20 +320,85 @@ export function fromMermaid(text: string): Graph { throw new Error(`fromMermaid: malformed edge label: "${label}"`); } - const pattern = label.slice(0, arrowIx); - const commandStr = label.slice(arrowIx + ' → '.length); - const command: GraphCommand[] = commandStr.split(',').map((part) => { - const slashIx = part.lastIndexOf('/'); + // Bracketed-tape-block format (v7): + // []|[]... → []/[] + // Each bracketed list is a tape-block reading; the outer `|` separates + // alternative read patterns. For single-tape machines with alternation, + // the compact form `[||...]` (one bracket, alternatives + // inside) is also accepted; both forms decode to the same pattern + // string. + const readLabel = label.slice(0, arrowIx); + const cmdLabel = label.slice(arrowIx + ' → '.length); + + // Strict per-pattern bracket form: `|` only between bracketed lists, + // never inside. The compact `['^'|'1']` form is rejected by design — + // every alternative must be its own bracketed pattern (`['^']|['1']`). + // Pedagogically: each transition is drawn explicitly; the compact form + // would read as cross-product semantics in multi-tape and confuse + // readers (`['0'|'1','a'|'b']` could mean 4 combos, not 2 paired alts). + // The rule applies to all bracketed lists — read alternatives, writes, + // and movements — because commands and movements have no alternation + // semantic either. + const stripBrackets = (s: string): string => { + if (!s.startsWith('[') || !s.endsWith(']')) { + throw new Error(`fromMermaid: malformed bracketed list: "${s}"`); + } + + const inner = s.slice(1, -1); + + // Walk the inner content; backslash escapes the next char (so `\|` + // inside a cell is a literal pipe, not the alternation separator). + let i = 0; + + while (i < inner.length) { + if (inner[i] === '\\' && i + 1 < inner.length) { + i += 2; + continue; + } + + if (inner[i] === '|') { + throw new Error( + `fromMermaid: compact in-bracket alternation "${s}" is not supported — ` + + 'each alternative must be its own bracketed pattern (e.g. "[\'^\']|[\'1\']").', + ); + } - if (slashIx === -1) { - throw new Error(`fromMermaid: malformed command part: "${part}"`); + i += 1; } - return { - symbol: part.slice(0, slashIx), - movement: part.slice(slashIx + 1), - }; - }); + return inner; + }; + + // Match `[…]` blocks in the read label. Inner content is a tape-block + // reading (possibly with `|` for compact single-tape alternation). + // `[^\]]*` is the simple non-greedy match — works because cell content + // doesn't typically contain literal `]`. + const blockMatches = readLabel.match(/\[[^\]]*\]/g); + + if (!blockMatches || blockMatches.length === 0) { + throw new Error(`fromMermaid: no bracketed read-list in label: "${label}"`); + } + + const pattern = blockMatches.map(stripBrackets).join('|'); + + const slashIx = cmdLabel.indexOf(']/['); + + if (slashIx === -1) { + throw new Error(`fromMermaid: malformed command label (expected \`[…]/[…]\`): "${cmdLabel}"`); + } + + const writesPart = stripBrackets(cmdLabel.slice(0, slashIx + 1)); + const movesPart = stripBrackets(cmdLabel.slice(slashIx + 2)); + const writes = writesPart.split(','); + const moves = movesPart.split(','); + + if (writes.length !== moves.length) { + throw new Error( + `fromMermaid: write-cells (${writes.length}) and move-cells (${moves.length}) mismatch: "${cmdLabel}"`, + ); + } + + const command: GraphCommand[] = writes.map((symbol, i) => ({symbol, movement: moves[i]})); const fromNode = ensureNode(fromId); const transitionIx = fromNode.transitions.length; diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index 17347db..edb858d 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -7,9 +7,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, transitions: [ {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, @@ -31,9 +31,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, transitions: [ {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, ], @@ -52,9 +52,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, transitions: [{pattern: '0', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}], }, }, @@ -72,10 +72,10 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isClonedHalt: false}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isWrapped: false, isClonedHalt: false}, - 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isWrapped: false, isHaltMarker: false}, + 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, }, }; @@ -90,8 +90,8 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isClonedHalt: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, }, }; @@ -197,8 +197,8 @@ describe('summarizeGraph defensive guards', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isClonedHalt: false}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isWrapped: false, isClonedHalt: false}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isWrapped: false, isHaltMarker: false}, }, }; diff --git a/packages/machine/src/utilities/introspection.ts b/packages/machine/src/utilities/introspection.ts index 8c82059..034e11b 100644 --- a/packages/machine/src/utilities/introspection.ts +++ b/packages/machine/src/utilities/introspection.ts @@ -40,11 +40,11 @@ export type GraphSummary = { export function summarizeGraph(graph: Graph): GraphSummary { const nodes = Object.values(graph.nodes); - // `isClonedHalt` nodes are visualization sentinels — one per wrapper context, + // `isHaltMarker` nodes are visualization sentinels — one per wrapper context, // all corresponding to the singleton `haltState` at runtime. They don't // count as distinct runtime states; matches the per-algorithm header in // `library-binary-numbers/states.md`. - const runtimeStateCount = nodes.filter((n) => !n.isClonedHalt).length; + const runtimeStateCount = nodes.filter((n) => !n.isHaltMarker).length; let transitionCount = 0; let compositionEdgeCount = 0; diff --git a/scripts/build-states-md.mjs b/scripts/build-states-md.mjs index 3a1ce2d..fc77022 100644 --- a/scripts/build-states-md.mjs +++ b/scripts/build-states-md.mjs @@ -67,7 +67,7 @@ if (libName === null) { const graph = State.toGraph(state, tapeBlock); const mermaid = toMermaid(graph); // Engine-owned introspection — dogfoods the same summary the public API - // exposes. `stateCount` already filters out `isClonedHalt` sentinels. + // exposes. `stateCount` already filters out `isHaltMarker` sentinels. const summary = summarizeGraph(graph); sections.push(`## ${stateName}`); diff --git a/test/round-trip.spec.ts b/test/round-trip.spec.ts index a5a5935..4e6070b 100644 --- a/test/round-trip.spec.ts +++ b/test/round-trip.spec.ts @@ -129,7 +129,7 @@ describe('toGraph / toMermaid / fromMermaid / fromGraph round trip', () => { const reEmittedMermaid = toMermaid(State.toGraph(rebuilt, rebuiltTapeBlock)); // State IDs auto-reassign on each rebuild, so normalize them before - // comparing. v7's emit also uses `cN` for cloned-halt ids and `w_N` for + // comparing. v7's emit also uses `cN` for halt-marker ids and `w_N` for // subgraph names — normalize all three. const normalize = (mermaid: string): string => mermaid .replace(/\bs\d+\b/g, 'sX') From 14448abc3714f91d6e131704e0c7b771eb1e3496 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 00:08:40 +0300 Subject: [PATCH 011/118] =?UTF-8?q?polish(emit):=20ASCII=20`*`=20for=20ifO?= =?UTF-8?q?therSymbol;=20`clonedHalt*`=20=E2=86=92=20`haltMarker*`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U+1F7B0 didn't render in GitHub Mermaid (tofu) or most monospace fonts. Switched to ASCII `*` — a literal `*` in the alphabet stays unambiguous because it's quoted (`'*'`), parallel to literal `'B'` vs the `B` blank-marker. Also renamed internal `clonedHalt*` identifiers to `haltMarker*` for consistency with the public `GraphNode.isHaltMarker` flag and the docs terminology — both `State.toGraph` and `toMermaid`/`fromMermaid` now match. Prose mentions of "cloned halt" in the README and design spec updated in lockstep. Regenerated `library-binary-numbers/states.md` (27 sites). --- CLAUDE.md | 2 +- README.md | 4 +- ...026-05-20-tomermaid-wrapper-emit-design.md | 4 +- packages/library-binary-numbers/states.md | 54 +++++++++---------- packages/machine/README.md | 28 +++++----- packages/machine/src/classes/State.spec.ts | 2 +- packages/machine/src/classes/State.ts | 14 ++--- packages/machine/src/utilities/graph.spec.ts | 20 +++---- packages/machine/src/utilities/graph.ts | 15 +++--- .../machine/src/utilities/graphFormats.ts | 30 +++++------ 10 files changed, 87 insertions(+), 86 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d6c4825..e9ffd12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,7 @@ Key shapes that take reading multiple files to grasp: **v7 emit shape** (PR #169, closes #138/#139): each `withOverriddenHaltState` wrapper collapses onto its bare's representation — `GraphNode.isWrapped: true`, no separate wrapper node in graph data. `toMermaid` wraps each `[[bare]]` (subroutine shape) + a synthesized `(((halt)))` marker (`GraphNode.isHaltMarker: true`, negative id; maps back to singleton `haltState` in `fromGraph`) inside a `subgraph w_${bareId}["halt frame"] … end` block. Dotted `onHalt` from `[[bare]]` crosses the subgraph border to the override target. An always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state (replaces v6's `((round))` shape convention). -**Edge label vocabulary** — `[reads] → [writes]/[moves]`, each role wrapped in `[…]` (the tape-block indicator, one entry per tape; brackets always present, even single-tape). Read cells: literal-quoted (`'X'`), `🞰` (U+1F7B0, ifOtherSymbol catch-all), `B` (the tape's blank). Write cells: literal-quoted, `K` (keep), `E` (erase = write blank). Move cells: `L` / `R` / `S`. **Alternation is always per-pattern bracket** (`['^']|['1']|['0']` for single-tape, `['0','a']|['1','b']` for multi-tape); the compact in-bracket form `['^'|'1']` is rejected by `fromMermaid` to prevent the cross-product reading trap in multi-tape (`['0'|'1','a'|'b']` would read as 4 combinations rather than 2 paired alternatives, so the format avoids the shape entirely). +**Edge label vocabulary** — `[reads] → [writes]/[moves]`, each role wrapped in `[…]` (the tape-block indicator, one entry per tape; brackets always present, even single-tape). Read cells: literal-quoted (`'X'`), `*` (ASCII, ifOtherSymbol catch-all; literal `*` in the alphabet is quoted as `'*'`), `B` (the tape's blank). Write cells: literal-quoted, `K` (keep), `E` (erase = write blank). Move cells: `L` / `R` / `S`. **Alternation is always per-pattern bracket** (`['^']|['1']|['0']` for single-tape, `['0','a']|['1','b']` for multi-tape); the compact in-bracket form `['^'|'1']` is rejected by `fromMermaid` to prevent the cross-product reading trap in multi-tape (`['0'|'1','a'|'b']` would read as 4 combinations rather than 2 paired alternatives, so the format avoids the shape entirely). **Edge arrow styles** — thick `==>` marks transitions whose target is a wrapped state AND ≠ source (= stack-push happens at runtime per `TuringMachine.run` line ~220); regular `-->` for the rest (including self-loops on wrappers, which don't push); dotted `-. onHalt .->` for the wrapper's catch-and-redirect; dotted `-. enter .->` from `idle` for execution-start. diff --git a/README.md b/README.md index 118810b..1ed7fe7 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,13 @@ flowchart TD idle -. enter .-> s1 s1 -- "['b'] → ['*']/[R]" --> s1 s1 -- "[B] → [K]/[L]" --> s0 - s1 -- "[🞰] → [K]/[R]" --> s1 + s1 -- "[*] → [K]/[R]" --> s1 ``` Quick legend for the diagram above — full table at [packages/machine/README.md § Diagram conventions](packages/machine/README.md#diagram-conventions): - **Edge format**: `[reads] → [writes]/[moves]` (each `[…]` is a tape-block reading; brackets always, even single-tape). -- **Read cells**: `'X'` (literal, single-quoted), `🞰` (`ifOtherSymbol` catch-all, U+1F7B0), `B` (the tape's blank). +- **Read cells**: `'X'` (literal, single-quoted), `*` (`ifOtherSymbol` catch-all), `B` (the tape's blank). - **Write cells**: `'X'` (literal), `K` (keep), `E` (erase = write blank). - **Movement cells**: `L` / `R` / `S` (left / right / stay). - **Node shapes**: `(((halt)))` = halt, `["square"]` = regular state, `[[double-walled]]` = wrapper-bare (subroutine shape), `idle([idle])` = pre-execution sentinel. diff --git a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md index 776d8b7..ab661e7 100644 --- a/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md +++ b/docs/superpowers/specs/2026-05-20-tomermaid-wrapper-emit-design.md @@ -1,6 +1,6 @@ # `toMermaid` wrapped-state emit — design comparison -**Status:** decided — **Variant X with `subgraph` overlay + `idle` entry sentinel + bracketed-tape-block edge labels**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a cloned `(((halt)))`. The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. A stadium-shaped `idle([idle])` sentinel + labeled dotted arrow `idle -. enter .-> sN` is always emitted to mark the initial state. Edge labels: `[reads] → [writes]/[moves]` with each role wrapped in `[…]` (tape-block indicator, always present even single-tape); read cells use `'X'` literal-quoted, `🞰` (U+1F7B0) for ifOtherSymbol, `B` for the tape's blank; write cells use `K`/`E` plus literal-quoted; movements `L`/`R`/`S`. Alternation is per-pattern-bracket (`['^']|['1']`); compact `['^'|'1']` form is rejected by `fromMermaid` (would read as cross-product in multi-tape). Stack-pushing transitions emit thick `==>` arrows. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation in PR [#169](https://github.com/mellonis/turing-machine-js/pull/169). +**Status:** decided — **Variant X with `subgraph` overlay + `idle` entry sentinel + bracketed-tape-block edge labels**. See [Final locked design](#final-locked-design-variant-x-with-subgraph-overlay) at the bottom for the exact diagrams and reader's contract. Each wrapper gets a `subgraph` rectangle labeled `"halt frame"` containing the `[[bare]]` (double-walled wrapper-node) and a halt-marker `(((halt)))`. The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target. A stadium-shaped `idle([idle])` sentinel + labeled dotted arrow `idle -. enter .-> sN` is always emitted to mark the initial state. Edge labels: `[reads] → [writes]/[moves]` with each role wrapped in `[…]` (tape-block indicator, always present even single-tape); read cells use `'X'` literal-quoted, `*` (ASCII) for ifOtherSymbol, `B` for the tape's blank; write cells use `K`/`E` plus literal-quoted; movements `L`/`R`/`S`. Alternation is per-pattern-bracket (`['^']|['1']`); compact `['^'|'1']` form is rejected by `fromMermaid` (would read as cross-product in multi-tape). Stack-pushing transitions emit thick `==>` arrows. Decision rationale posted on [#138](https://github.com/mellonis/turing-machine-js/issues/138#issuecomment-4499377933). Implementation in PR [#169](https://github.com/mellonis/turing-machine-js/pull/169). **Context.** [#138](https://github.com/mellonis/turing-machine-js/issues/138) — clean up the visually-confusing Mermaid output for `withOverriddenHaltState`-wrapped states. [#139](https://github.com/mellonis/turing-machine-js/issues/139) — bytewise round-trip regression for the wrapper name accumulation, naturally fixed by whichever design we pick. @@ -259,7 +259,7 @@ Y₁ is the most faithful but the cost of per-context state duplication in `from After iteration, the locked shape evolves Variant X (collapse the wrapper into the bare's representation, no extra "wrapper node" in the graph data) with two visualization-only enhancements that make the wrapper's runtime semantics tangible without mutating the graph structure: 1. A Mermaid **`subgraph` rectangle labeled `"halt frame"`** around each wrapper — the visual scope for "the wrapper's stack frame for halt handling." -2. A **cloned `(((halt)))` node inside that subgraph** — visualization of "halt-bound transitions land here, *inside* the wrapper's scope." `haltState` is a runtime singleton; the cloned visual is a teaching aid (one halt marker per wrapper context on the diagram, all corresponding to the single runtime instance). +2. A **halt-marker `(((halt)))` node inside that subgraph** — visualization of "halt-bound transitions land here, *inside* the wrapper's scope." `haltState` is a runtime singleton; the halt marker is a teaching aid (one per wrapper context on the diagram, all corresponding to the single runtime instance). ### Visual contract (what a reader sees) diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 1c38dfc..31477a0 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -12,7 +12,7 @@ flowchart TD idle([idle]) idle -. enter .-> s1 s1 -- "['$'] → [K]/[S]" --> s0 - s1 -- "[🞰] → [K]/[R]" --> s1 + s1 -- "[*] → [K]/[R]" --> s1 ``` ## goToNextNumber @@ -28,8 +28,8 @@ flowchart TD idle([idle]) idle -. enter .-> s2 s1 -- "['$'] → [K]/[S]" --> s0 - s1 -- "[🞰] → [K]/[R]" --> s1 - s2 -- "[🞰] → [K]/[R]" --> s1 + s1 -- "[*] → [K]/[R]" --> s1 + s2 -- "[*] → [K]/[R]" --> s1 ``` ## goToPreviousNumber @@ -45,8 +45,8 @@ flowchart TD idle([idle]) idle -. enter .-> s4 s3 -- "['$'] → [K]/[S]" --> s0 - s3 -- "[🞰] → [K]/[L]" --> s3 - s4 -- "[🞰] → [K]/[L]" --> s3 + s3 -- "[*] → [K]/[L]" --> s3 + s4 -- "[*] → [K]/[L]" --> s3 ``` ## deleteNumber @@ -66,12 +66,12 @@ flowchart TD end idle -. enter .-> s8 s6 -- "['$'] → [E]/[S]" --> s0 - s6 -- "[🞰] → [E]/[R]" --> s6 + s6 -- "[*] → [E]/[R]" --> s6 s7 -- "['^'] → [K]/[S]" --> c7 - s7 -- "[🞰] → [K]/[L]" --> s7 + s7 -- "[*] → [K]/[L]" --> s7 s7 -. onHalt .-> s6 s8 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s7 - s8 -- "[🞰] → [K]/[S]" --> s0 + s8 -- "[*] → [K]/[S]" --> s0 ``` ## goToNumbersStart @@ -86,7 +86,7 @@ flowchart TD idle([idle]) idle -. enter .-> s5 s5 -- "['^'] → [K]/[S]" --> s0 - s5 -- "[🞰] → [K]/[L]" --> s5 + s5 -- "[*] → [K]/[L]" --> s5 ``` ## invertNumber @@ -110,10 +110,10 @@ flowchart TD s9 -- "['0'] → ['1']/[R]" --> s9 s9 -- "['$'] → [K]/[S]" --> s0 s10 -- "['^'] → [K]/[S]" --> c10 - s10 -- "[🞰] → [K]/[L]" --> s10 + s10 -- "[*] → [K]/[L]" --> s10 s10 -. onHalt .-> s9 s11 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 - s11 -- "[🞰] → [K]/[S]" --> s0 + s11 -- "[*] → [K]/[S]" --> s0 ``` ## normalizeNumber @@ -135,15 +135,15 @@ flowchart TD end idle -. enter .-> s15 s1 -- "['$'] → [K]/[S]" --> s0 - s1 -- "[🞰] → [K]/[R]" --> s1 + s1 -- "[*] → [K]/[R]" --> s1 s12 -- "[B] → ['^']/[S]" --> s1 s13 -- "['^']|['0'] → [E]/[R]" --> s13 s13 -- "['1']|['$'] → [K]/[L]" --> s12 s14 -- "['^'] → [K]/[S]" --> c14 - s14 -- "[🞰] → [K]/[L]" --> s14 + s14 -- "[*] → [K]/[L]" --> s14 s14 -. onHalt .-> s13 s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 - s15 -- "[🞰] → [K]/[S]" --> s0 + s15 -- "[*] → [K]/[S]" --> s0 ``` ## plusOne @@ -169,7 +169,7 @@ flowchart TD s18 -- "['^'] → ['1']/[L]" --> s17 s19 -- "['^']|['1']|['0'] → [K]/[R]" --> s19 s19 -- "['$'] → [K]/[L]" --> s18 - s19 -- "[🞰] → [K]/[S]" --> s0 + s19 -- "[*] → [K]/[S]" --> s0 ``` ## minusOne @@ -212,22 +212,22 @@ flowchart TD end idle -. enter .-> s23 s1 -- "['$'] → [K]/[S]" --> s0 - s1 -- "[🞰] → [K]/[R]" --> s1 + s1 -- "[*] → [K]/[R]" --> s1 s9 -- "['^'] → [K]/[R]" --> s9 s9 -- "['1'] → ['0']/[R]" --> s9 s9 -- "['0'] → ['1']/[R]" --> s9 s9 -- "['$'] → [K]/[S]" --> s0 s10 -- "['^'] → [K]/[S]" --> c10 - s10 -- "[🞰] → [K]/[L]" --> s10 + s10 -- "[*] → [K]/[L]" --> s10 s10 -. onHalt .-> s9 s12 -- "[B] → ['^']/[S]" --> s1 s13 -- "['^']|['0'] → [E]/[R]" --> s13 s13 -- "['1']|['$'] → [K]/[L]" --> s12 s14 -- "['^'] → [K]/[S]" --> c14 - s14 -- "[🞰] → [K]/[L]" --> s14 + s14 -- "[*] → [K]/[L]" --> s14 s14 -. onHalt .-> s13 s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 - s15 -- "[🞰] → [K]/[S]" --> s0 + s15 -- "[*] → [K]/[S]" --> s0 s16 -- "['1'] → ['0']/[R]" --> s16 s16 -- "['$'] → [K]/[S]" --> s0 s17 -- "[B] → ['^']/[R]" --> s17 @@ -236,18 +236,18 @@ flowchart TD s18 -- "['1'] → [K]/[L]" --> s18 s18 -- "['^'] → ['1']/[L]" --> s17 s20 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 - s20 -- "[🞰] → [K]/[S]" --> c20 + s20 -- "[*] → [K]/[S]" --> c20 s20 -. onHalt .-> s15 s21 -- "['^']|['1']|['0'] → [K]/[R]" --> s21 s21 -- "['$'] → [K]/[L]" --> s18 - s21 -- "[🞰] → [K]/[S]" --> c21 + s21 -- "[*] → [K]/[S]" --> c21 s21 -. onHalt .-> s20 s22 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 - s22 -- "[🞰] → [K]/[S]" --> c22 + s22 -- "[*] → [K]/[S]" --> c22 s22 -. onHalt .-> s21 s23 -- "['^']|['1']|['0'] → [K]/[R]" --> s23 s23 == "['$'] → [K]/[S]" ==> s22 - s23 -- "[🞰] → [K]/[S]" --> s0 + s23 -- "[*] → [K]/[S]" --> s0 ``` ## minusOneFast @@ -274,20 +274,20 @@ flowchart TD end idle -. enter .-> s26 s1 -- "['$'] → [K]/[S]" --> s0 - s1 -- "[🞰] → [K]/[R]" --> s1 + s1 -- "[*] → [K]/[R]" --> s1 s12 -- "[B] → ['^']/[S]" --> s1 s13 -- "['^']|['0'] → [E]/[R]" --> s13 s13 -- "['1']|['$'] → [K]/[L]" --> s12 s14 -- "['^'] → [K]/[S]" --> c14 - s14 -- "[🞰] → [K]/[L]" --> s14 + s14 -- "[*] → [K]/[L]" --> s14 s14 -. onHalt .-> s13 s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 - s15 -- "[🞰] → [K]/[S]" --> s0 + s15 -- "[*] → [K]/[S]" --> s0 s25 -- "['1'] → ['0']/[S]" --> c25 s25 -- "['0'] → ['1']/[L]" --> s25 s25 -- "['^'] → [K]/[S]" --> c25 s25 -. onHalt .-> s15 s26 -- "['^']|['1']|['0'] → [K]/[R]" --> s26 s26 == "['$'] → [K]/[L]" ==> s25 - s26 -- "[🞰] → [K]/[S]" --> s0 + s26 -- "[*] → [K]/[S]" --> s0 ``` diff --git a/packages/machine/README.md b/packages/machine/README.md index 9880a61..ad90f3c 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -82,10 +82,10 @@ flowchart TD idle -. enter .-> s1 s1 -- "['b'] → ['*']/[R]" --> s1 s1 -- "[B] → [K]/[L]" --> s0 - s1 -- "[🞰] → [K]/[R]" --> s1 + s1 -- "[*] → [K]/[R]" --> s1 ``` -Reading this specific diagram: `replaceB` (the rectangle) is the start state, marked by the dotted `enter` arrow from the `idle` sentinel. Three self-or-halt transitions: read `'b'` → write `'*'` and step right; read anything else (`🞰`) → keep, step right; read blank (`B`) → keep, step left, halt. Full notation reference — shapes, edge styles, label vocabulary — in [§Diagram conventions](#diagram-conventions). +Reading this specific diagram: `replaceB` (the rectangle) is the start state, marked by the dotted `enter` arrow from the `idle` sentinel. Three self-or-halt transitions: read `'b'` → write `'*'` and step right; read anything else (`*`) → keep, step right; read blank (`B`) → keep, step left, halt. Full notation reference — shapes, edge styles, label vocabulary — in [§Diagram conventions](#diagram-conventions). A `State` is keyed by JS `Symbol`s returned from `tapeBlock.symbol(pattern)` — the pattern lists the expected symbol under each tape's head. Sentinels and constants used throughout: [`ifOtherSymbol`](#ifothersymbol) is the fallback key when nothing else matches; transitioning into [`haltState`](#haltstate) stops the run; [`movements`](#movements)`.{left,right,stay}` direct head moves; [`symbolCommands`](#symbolcommands)`.{keep,erase}` are write shortcuts. Full definitions in [§Special objects](#special-objects). @@ -388,7 +388,7 @@ flowchart TD idle([idle]) idle -. enter .-> s1 s1 -- "['X'] → [K]/[S]" --> s0 - s1 -- "[🞰] → [K]/[R]" --> s1 + s1 -- "[*] → [K]/[R]" --> s1 ``` `toMermaid(toGraph(scanThenErase, tapeBlock))` — the wrapped composition: @@ -404,9 +404,9 @@ flowchart TD c3(((halt))) end idle -. enter .-> s3 - s2 -- "[🞰] → [E]/[S]" --> s0 + s2 -- "[*] → [E]/[S]" --> s0 s3 -- "['X'] → [K]/[S]" --> c3 - s3 -- "[🞰] → [K]/[R]" --> s3 + s3 -- "[*] → [K]/[R]" --> s3 s3 -. onHalt .-> s2 ``` @@ -414,11 +414,11 @@ flowchart TD 1. **The subgraph rectangle labeled `"halt frame"`** is the wrapper's runtime scope — while execution is "inside" this rectangle, the override target (`eraseHere`) sits on the runtime stack waiting to catch a halt. Visual-only; it does not mutate any edges. 2. **`[[scanToX]]` (Mermaid subroutine / double-walled-rectangle shape)** is the wrapper node. It's both the runtime entry point (execution starts here when entering the wrapper) AND the source of the dotted `onHalt` redirect. The wrapper's composite name (`scanToX(eraseHere)`) is computed at runtime via `state.name` but does not appear as a graph node label — only the bare's name is in the graph. -3. **The cloned `(((halt)))` inside the subgraph** (`c3` here) is where the bare's halt-bound transitions land *inside* the wrapper's scope. `haltState` is a runtime singleton; the cloned node is a teaching aid showing "halt is caught here, not at the real terminus." Solid arrows from the bare to the halt marker all stay inside the rectangle. +3. **The halt-marker `(((halt)))` inside the subgraph** (`c3` here) is where the bare's halt-bound transitions land *inside* the wrapper's scope. `haltState` is a runtime singleton; the halt marker is a teaching aid showing "halt is caught here, not at the real terminus." Solid arrows from the bare to the halt marker all stay inside the rectangle. 4. **The dotted `onHalt` arrow from `[[scanToX]]` to `eraseHere`** is the wrapper's catch-and-redirect. Originates from the wrapper-node since the wrapper *is* the catcher. Solid arrows from `[[scanToX]]` to other states can also cross the subgraph border — those are just regular runtime transitions whose target happens to be drawn outside this rectangle (only the dotted `onHalt` carries wrapper-machinery meaning). In larger compositions (`library-binary-numbers`'s `minusOne`), solid transitions whose target is *itself* a wrapped state render as a **thick `==>` arrow** instead of `-->` — that's the visual signal for "this transition enters a halt frame, pushing the override onto the runtime stack." Stack-growth structure is then scannable from the diagram: count thick arrows along an execution path to see how deep the stack gets. 5. **Real `(((halt)))` outside any subgraph** (`s0`) is the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame — here, by `eraseHere` after it erases the cell. -**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `[🞰] → [K]/[R]` self-loops until the head sees `X`; the `['X'] → [K]/[S]` solid edge would normally halt — it lands on the halt marker `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `[🞰] → [E]/[S]` and halts at real `s0`. Run terminates. +**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `[*] → [K]/[R]` self-loops until the head sees `X`; the `['X'] → [K]/[S]` solid edge would normally halt — it lands on the halt marker `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `[*] → [E]/[S]` and halts at real `s0`. Run terminates. > 💡 **Round-trip caveat.** `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for simple wrappers like this one ([#139](https://github.com/mellonis/turing-machine-js/issues/139) regression). For shared-bare cases (same `State` instance used as the bare in multiple wrappers — e.g., `library-binary-numbers`'s `minusOne`), per-context duplication produces wrapper-id-dependent ordering that doesn't byte-match across rebuilds — equivalent runtime behavior, different emit-line order. @@ -600,7 +600,7 @@ The full reference for reading `toMermaid` output — shapes, edge styles, and t | Glyph | Where | Meaning | |---|---|---| | `'X'` | read, write | literal alphabet symbol (single-quoted) | -| `🞰` | read only | `ifOtherSymbol` catch-all (U+1F7B0 heavy-eight-balloon-spoked-asterisk) | +| `*` | read only | `ifOtherSymbol` catch-all (ASCII `*`; a literal `*` in the alphabet renders as the quoted `'*'`, so the marker stays unambiguous) | | `B` | read only | the tape's blank symbol (a literal `B` in the alphabet appears as `'B'`, so the marker stays unambiguous) | | `K` | write only | keep (no write) | | `E` | write only | erase (write the tape's blank) | @@ -626,14 +626,14 @@ flowchart TD s1["copy"] idle([idle]) idle -. enter .-> s1 - s1 -- "['0',🞰] → [K,'0']/[R,R]" --> s1 - s1 -- "['1',🞰] → [K,'1']/[R,R]" --> s1 - s1 -- "[B,🞰] → [K]/[S]" --> s0 + s1 -- "['0',*] → [K,'0']/[R,R]" --> s1 + s1 -- "['1',*] → [K,'1']/[R,R]" --> s1 + s1 -- "[B,*] → [K]/[S]" --> s0 ``` -Reading `['0',🞰] → [K,'0']/[R,R]`: +Reading `['0',*] → [K,'0']/[R,R]`: -- **Read** `['0',🞰]` — tape 1 must be literal `'0'`; tape 2 is `ifOtherSymbol` (any). +- **Read** `['0',*]` — tape 1 must be literal `'0'`; tape 2 is `ifOtherSymbol` (any). - **Write** `[K,'0']` — tape 1: keep; tape 2: write literal `'0'`. - **Move** `[R,R]` — both tapes step right. @@ -651,7 +651,7 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v7** *(in progress)* — Composition-representation overhaul. Breaking renames + reshapes scheduled for the v7 cut. Landing piecewise on the `v7` branch; one entry per landed change: - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly. - - **`toMermaid` wrapped-state emit overhaul** ([#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139)). The wrapper-and-its-bare pair collapses into a single graph node (`isWrapped: true`); the wrapper's composite name no longer appears as a node label (only the bare's name does). Each wrapper gets a Mermaid `subgraph w_${bareId}["halt frame"] … end` block containing the `[[bare]]` (subroutine shape) plus a cloned `(((halt)))` (visualization aid showing where halt-bound transitions land inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target — exactly one per wrapper. `Graph` data shape gains `isWrapped` and `isHaltMarker` flags on `GraphNode` and a stable `id` on `GraphTransition` (deterministic per-edge identifier — supports downstream tooling like the `machines-demo` interactive viewer at [machines-demo#10](https://github.com/mellonis/machines-demo/issues/10)). Halt-marker graph nodes use negative ids and round-trip back to the singleton `haltState` via `fromGraph`. Bytewise round-trip stability falls out for simple wrappers (no composite name in the graph means `fromGraph(toGraph(state))` recomputes names fresh — no accumulation). Shared-bare cases (e.g. `minusOne`'s repeated `invertNumber`) use per-context duplication in the graph emit. + - **`toMermaid` wrapped-state emit overhaul** ([#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139)). The wrapper-and-its-bare pair collapses into a single graph node (`isWrapped: true`); the wrapper's composite name no longer appears as a node label (only the bare's name does). Each wrapper gets a Mermaid `subgraph w_${bareId}["halt frame"] … end` block containing the `[[bare]]` (subroutine shape) plus a halt-marker `(((halt)))` (visualization aid showing where halt-bound transitions land inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target — exactly one per wrapper. `Graph` data shape gains `isWrapped` and `isHaltMarker` flags on `GraphNode` and a stable `id` on `GraphTransition` (deterministic per-edge identifier — supports downstream tooling like the `machines-demo` interactive viewer at [machines-demo#10](https://github.com/mellonis/machines-demo/issues/10)). Halt-marker graph nodes use negative ids and round-trip back to the singleton `haltState` via `fromGraph`. Bytewise round-trip stability falls out for simple wrappers (no composite name in the graph means `fromGraph(toGraph(state))` recomputes names fresh — no accumulation). Shared-bare cases (e.g. `minusOne`'s repeated `invertNumber`) use per-context duplication in the graph emit. For the full release history, see the [GitHub releases page](https://github.com/mellonis/turing-machine-js/releases). diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index a9f9f21..f32939c 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -232,7 +232,7 @@ describe('State.fromGraph — cyclic override-halt chain', () => { // pointing in a loop. // Nodes need at least one transition each — State construction at pass 2 // rejects empty stateDefinitions before pass 3's cycle check would run. - const dummyTransition = {pattern: '🞰', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}; + const dummyTransition = {pattern: '*', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}; const graph = { initialId: 1, alphabets: [[' ', '0', '1']], diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 926ced9..a91605d 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -361,7 +361,7 @@ export default class State { // to it in transitions. Same as the wrapper's `#id`, recorded for clarity // when rewriting transition targets. const wrapperGraphId = (s: State): number => s.#id; - const clonedHaltIdFor = (wrapper: State): number => -wrapper.#id; + const haltMarkerIdFor = (wrapper: State): number => -wrapper.#id; // The `initialId` is the user-passed start. If it's a wrapper, the // collapsed graph node uses its `#id`; otherwise its own `#id`. @@ -372,7 +372,7 @@ export default class State { state: State; // When non-null, the State is being processed AS the bare of this wrapper. // The collapsed graph node uses `wrapperGraphId(wrapperContext)`, - // halt-bound transitions retarget to `clonedHaltIdFor(wrapperContext)`, + // halt-bound transitions retarget to `haltMarkerIdFor(wrapperContext)`, // self-loop transitions to the bare retarget to the wrapper-id. wrapperContext: State | null; }; @@ -416,7 +416,7 @@ export default class State { continue; } - const clonedHaltId = clonedHaltIdFor(wrapperContext); + const haltMarkerId = haltMarkerIdFor(wrapperContext); const overrideTarget = wrapperContext.#overriddenHaltState!; // The override target's collapsed id: if the override is itself a @@ -427,9 +427,9 @@ export default class State { : overrideTarget.#id; // Emit the halt-marker node if not already present (one per wrapper). - if (!(clonedHaltId in nodes)) { - nodes[clonedHaltId] = { - id: clonedHaltId, + if (!(haltMarkerId in nodes)) { + nodes[haltMarkerId] = { + id: haltMarkerId, name: 'halt', isHalt: true, isHaltMarker: true, @@ -472,7 +472,7 @@ export default class State { let nextStateId: number; if (target.isHalt) { - nextStateId = clonedHaltId; + nextStateId = haltMarkerId; } else if (target === state) { nextStateId = collapsedId; } else if (target.#overriddenHaltState && target.#bareState) { diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index 03ca516..d823061 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -19,16 +19,16 @@ describe('decodePatternDescription', () => { expect(decodePatternDescription(undefined, alphabets)).toBe('?'); }); - test('"other symbol" → "🞰" (whole-state ifOtherSymbol)', () => { - expect(decodePatternDescription('other symbol', alphabets)).toBe('🞰'); + test('"other symbol" → "*" (whole-state ifOtherSymbol)', () => { + expect(decodePatternDescription('other symbol', alphabets)).toBe('*'); }); test('literal cell wraps in single quotes', () => { expect(decodePatternDescription('[["0"]]', alphabets)).toBe("'0'"); }); - test('per-cell null → "🞰"', () => { - expect(decodePatternDescription('[[null]]', alphabets)).toBe('🞰'); + test('per-cell null → "*"', () => { + expect(decodePatternDescription('[[null]]', alphabets)).toBe('*'); }); test('cell equal to tape blank → "B"', () => { @@ -203,12 +203,12 @@ describe('splitUnescaped', () => { describe('parsePatternString', () => { test('returns null for the global ifOtherSymbol marker', () => { - expect(parsePatternString('🞰', [[' ', '0']])).toBeNull(); + expect(parsePatternString('*', [[' ', '0']])).toBeNull(); }); - test('per-cell `🞰` becomes null', () => { + test('per-cell `*` becomes null', () => { // Multi-tape pattern where one cell is per-cell ifOtherSymbol. - expect(parsePatternString("'0',🞰", [[' ', '0'], [' ', 'a']])).toEqual([['0', null]]); + expect(parsePatternString("'0',*", [[' ', '0'], [' ', 'a']])).toEqual([['0', null]]); }); test('per-cell `B` becomes the tape blank symbol', () => { @@ -393,7 +393,7 @@ describe('README diagrams: engine-generated outputs', () => { 'idle -. enter .->', "\"['b'] → ['*']/[R]\"", '"[B] → [K]/[L]"', - '"[🞰] → [K]/[R]"', + '"[*] → [K]/[R]"', ]); }); @@ -439,7 +439,7 @@ describe('README diagrams: engine-generated outputs', () => { 'idle([idle])', 'idle -. enter .->', "\"['X'] → [K]/[S]\"", - '"[🞰] → [K]/[R]"', + '"[*] → [K]/[R]"', ]); }); @@ -468,7 +468,7 @@ describe('README diagrams: engine-generated outputs', () => { '"halt frame"', // subgraph label 'idle([idle])', // pre-execution sentinel — always emitted 'idle -. enter .->', // labeled dotted enter arrow points at the initial state - '"[🞰] → [E]/[S]"', // eraseHere's erase command + '"[*] → [E]/[S]"', // eraseHere's erase command '-. onHalt .->', // the dotted override-halt edge — wrapper's catch-and-redirect, crosses the subgraph border ]); }); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index 51c90db..e7ae37e 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -47,10 +47,11 @@ const symbolCommandDescriptionToLabel: Record = { }; // Reserved characters in the encoded pattern string: -// '🞰' (U+1F7B0 HEAVY EIGHT BALLOON-SPOKED ASTERISK) per-cell ifOtherSymbol — -// matches any symbol on that tape. Distinct from the regular ASCII '*' -// (U+002A) so an alphabet that contains literal '*' (rendered as the -// quoted `'*'`) is unambiguously different from the catch-all marker. +// '*' ASCII asterisk (U+002A) — per-cell ifOtherSymbol, matches any symbol +// on that tape. ASCII (not a fancier glyph like U+1F7B0) so it renders +// in every Mermaid environment and every monospace font. A literal `*` +// in the alphabet is unambiguous from the marker because it's quoted +// (`'*'`). // 'B' the tape's blank symbol shorthand (in read patterns). A literal `B` // in the alphabet is unambiguous from the marker because it's quoted // (`'B'`). @@ -58,12 +59,12 @@ const symbolCommandDescriptionToLabel: Record = { // '|' separates alternative patterns // "'" surrounds a literal alphabet symbol — e.g. `'0'` for literal `0`, // `'X'` for literal `X`. The quoting is what visually separates literal -// symbols from the convention markers `🞰` / `B` and from the write +// symbols from the convention markers `*` / `B` and from the write // commands `K` / `E`. -// '\\' escape prefix — to represent any of '🞰', 'B', ',', '|', "'", or '\\' +// '\\' escape prefix — to represent any of '*', 'B', ',', '|', "'", or '\\' // as a *literal* alphabet symbol *inside* the quotes (e.g. `'\''` for // a literal apostrophe). -const IF_OTHER_MARKER = '🞰'; +const IF_OTHER_MARKER = '*'; const BLANK_MARKER = 'B'; function escapeAlphabetSymbol(s: string): string { diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index c9e55a6..79fdefe 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -49,16 +49,16 @@ export function toMermaid(graph: Graph): string { const wrappedNodes = nodes.filter((n) => n.isWrapped); // Convention: wrapped node id N → halt marker id -N. - const clonedHaltFor = (wrappedId: number): number => -wrappedId; + const haltMarkerIdFor = (wrappedId: number): number => -wrappedId; // Set of halt-marker ids that belong to some wrapper (= are inside a subgraph). - const clonedHaltIds = new Set(); + const haltMarkerIds = new Set(); for (const w of wrappedNodes) { - const clonedId = clonedHaltFor(w.id); + const haltMarkerId = haltMarkerIdFor(w.id); - if (clonedId in graph.nodes) { - clonedHaltIds.add(clonedId); + if (haltMarkerId in graph.nodes) { + haltMarkerIds.add(haltMarkerId); } } @@ -66,7 +66,7 @@ export function toMermaid(graph: Graph): string { // No special round-shape `((…))` for the initial — the `idle -. enter .->` // arrow emitted below is the sole "start here" signal. for (const node of nodes) { - if (node.isWrapped || clonedHaltIds.has(node.id)) { + if (node.isWrapped || haltMarkerIds.has(node.id)) { continue; } @@ -88,14 +88,14 @@ export function toMermaid(graph: Graph): string { // Emit one subgraph per wrapper, in sorted wrapped-id order. for (const wrapped of wrappedNodes) { const wrappedMid = mermaidIdFor(wrapped.id); - const clonedId = clonedHaltFor(wrapped.id); - const clonedMid = mermaidIdFor(clonedId); + const haltMarkerId = haltMarkerIdFor(wrapped.id); + const haltMarkerMid = mermaidIdFor(haltMarkerId); lines.push(` subgraph w_${wrapped.id}["halt frame"]`); lines.push(` ${wrappedMid}[["${wrapped.name}"]]`); - if (clonedId in graph.nodes) { - lines.push(` ${clonedMid}(((halt)))`); + if (haltMarkerId in graph.nodes) { + lines.push(` ${haltMarkerMid}(((halt)))`); } lines.push(' end'); @@ -196,7 +196,7 @@ export function fromMermaid(text: string): Graph { // Track the halt-marker ids that appeared inside a subgraph — they should be // marked `isHaltMarker: true` even though they share the `(((halt)))` shape // with the real halt at the top level. - const clonedHaltIds = new Set(); + const haltMarkerIds = new Set(); let inSubgraph = false; const ensureNode = ( @@ -262,12 +262,12 @@ export function fromMermaid(text: string): Graph { if (hm) { const id = parseMermaidId(hm[1]); - const isCloned = inSubgraph || id < 0; + const isHaltMarker = inSubgraph || id < 0; - ensureNode(id, {name: 'halt', isHalt: true, isHaltMarker: isCloned}); + ensureNode(id, {name: 'halt', isHalt: true, isHaltMarker}); - if (isCloned) { - clonedHaltIds.add(id); + if (isHaltMarker) { + haltMarkerIds.add(id); } continue; From 18eb7936a4e934839b9ede2bf684735fe5fdc9ac Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 00:18:17 +0300 Subject: [PATCH 012/118] chore(release): 7.0.0-alpha.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First v7 pre-release. Consolidates the composition-representation overhaul landed across #149 / #148 / #138 / #139 on the v7 branch. Publishes to npm under the `next` dist-tag. - Bump all 4 packages 6.4.0 → 7.0.0-alpha.1 (lerna lockstep) - Widen peer dep `@turing-machine-js/machine` on the three dependent packages: `^6.0.0` → `^7.0.0-alpha.1` - CHANGELOG entry for [7.0.0-alpha.1] with full migration walkthrough - README versioning notes: v7 (in progress) → v7 (alpha 1, 2026-05-21) with install snippet and #102 status callout Outstanding for stable v7.0.0: #102 (debugger step-in/over/out). v7-branch merges don't auto-close — `closes` keywords carried here fire on the eventual v7 → master release PR, not on this merge. closes #138 closes #139 closes #148 closes #149 --- lerna.json | 2 +- package-lock.json | 14 ++-- packages/builder/package.json | 4 +- .../library-binary-numbers-bare/package.json | 4 +- packages/library-binary-numbers/package.json | 4 +- packages/machine/CHANGELOG.md | 70 +++++++++++++++++++ packages/machine/README.md | 2 +- packages/machine/package.json | 2 +- 8 files changed, 86 insertions(+), 16 deletions(-) diff --git a/lerna.json b/lerna.json index e573776..9859cef 100644 --- a/lerna.json +++ b/lerna.json @@ -3,6 +3,6 @@ "packages": [ "packages/*" ], - "version": "6.4.0", + "version": "7.0.0-alpha.1", "$schema": "node_modules/lerna/schemas/lerna-schema.json" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ecca00f..c2cc36c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10656,40 +10656,40 @@ }, "packages/builder": { "name": "@turing-machine-js/builder", - "version": "6.4.0", + "version": "7.0.0-alpha.1", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^6.0.0" + "@turing-machine-js/machine": "^7.0.0-alpha.1" } }, "packages/library-binary-numbers": { "name": "@turing-machine-js/library-binary-numbers", - "version": "6.4.0", + "version": "7.0.0-alpha.1", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^6.0.0" + "@turing-machine-js/machine": "^7.0.0-alpha.1" } }, "packages/library-binary-numbers-bare": { "name": "@turing-machine-js/library-binary-numbers-bare", - "version": "6.4.0", + "version": "7.0.0-alpha.1", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^6.0.0" + "@turing-machine-js/machine": "^7.0.0-alpha.1" } }, "packages/machine": { "name": "@turing-machine-js/machine", - "version": "6.4.0", + "version": "7.0.0-alpha.1", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" diff --git a/packages/builder/package.json b/packages/builder/package.json index 138af98..d48a170 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/builder", - "version": "6.4.0", + "version": "7.0.0-alpha.1", "description": "A turing machine builder — declarative state-table construction. Not actively developed by the author; the same state-table pattern is also shown as an inline example in @turing-machine-js/machine's README. Contributions welcome.", "engines": { "npm": ">=7.0.0" @@ -25,7 +25,7 @@ "builder" ], "peerDependencies": { - "@turing-machine-js/machine": "^6.0.0" + "@turing-machine-js/machine": "^7.0.0-alpha.1" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/builder", diff --git a/packages/library-binary-numbers-bare/package.json b/packages/library-binary-numbers-bare/package.json index 2efd624..b0f03f1 100644 --- a/packages/library-binary-numbers-bare/package.json +++ b/packages/library-binary-numbers-bare/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/library-binary-numbers-bare", - "version": "6.4.0", + "version": "7.0.0-alpha.1", "description": "Single-number binary arithmetic on a 3-symbol alphabet (blank, 0, 1) — same operations as @turing-machine-js/library-binary-numbers but without ^/$ markers. Side-by-side with the marker-based library for learning the trade-off.", "engines": { "npm": ">=7.0.0" @@ -28,7 +28,7 @@ "teaching" ], "peerDependencies": { - "@turing-machine-js/machine": "^6.0.0" + "@turing-machine-js/machine": "^7.0.0-alpha.1" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/library-binary-numbers-bare", diff --git a/packages/library-binary-numbers/package.json b/packages/library-binary-numbers/package.json index d938e47..7726d42 100644 --- a/packages/library-binary-numbers/package.json +++ b/packages/library-binary-numbers/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/library-binary-numbers", - "version": "6.4.0", + "version": "7.0.0-alpha.1", "description": "A standard library for working with binary numbers", "engines": { "npm": ">=7.0.0" @@ -27,7 +27,7 @@ "numbers" ], "peerDependencies": { - "@turing-machine-js/machine": "^6.0.0" + "@turing-machine-js/machine": "^7.0.0-alpha.1" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/library-binary-numbers", diff --git a/packages/machine/CHANGELOG.md b/packages/machine/CHANGELOG.md index 43e71b1..e24e8be 100644 --- a/packages/machine/CHANGELOG.md +++ b/packages/machine/CHANGELOG.md @@ -4,6 +4,76 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.1] - 2026-05-21 + +First v7 pre-release. Consolidates the composition-representation overhaul landed across [#149](https://github.com/mellonis/turing-machine-js/issues/149), [#148](https://github.com/mellonis/turing-machine-js/issues/148), [#138](https://github.com/mellonis/turing-machine-js/issues/138), and [#139](https://github.com/mellonis/turing-machine-js/issues/139). Published to npm under the `next` dist-tag: `npm install @turing-machine-js/machine@next`. + +**Pre-release — the API surface may still shift before stable v7.0.0.** Pin to a specific alpha for reproducibility: `@turing-machine-js/machine@7.0.0-alpha.1`. Migration walkthrough at the bottom. + +### Changed + +- **`State.prototype.withOverrodeHaltState` renamed to `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: `overridden` (past participle) fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. Renames in lockstep: + - public method `State.prototype.withOverrodeHaltState` → `withOverriddenHaltState` + - getter `state.overrodeHaltState` → `state.overriddenHaltState` + - private field `#overrodeHaltState` → `#overriddenHaltState` + - serialized `Graph` data field `node.overrodeHaltStateId` → `node.overriddenHaltStateId` + +- **Wrapped-state composite name format flipped from `bare>override` to `bare(override)`** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). The old `>`-flat notation collided structurally-distinct wrap-trees into the same string: `A.with(B.with(A))` and `A.with(B).with(A)` both rendered as `A>B>A` despite being different runtime shapes. Paren-nested keeps them distinct: `A(B(A))` vs `A(B)(A)`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` constructor now throws on names with these characters. `>` is no longer reserved and is valid in user-provided names again. + +- **`toMermaid` wrapped-state emit overhaul** ([#138](https://github.com/mellonis/turing-machine-js/issues/138), [#139](https://github.com/mellonis/turing-machine-js/issues/139)). Each `withOverriddenHaltState` wrapper collapses onto its bare's representation — `GraphNode.isWrapped: true`, no separate wrapper node in graph data. `toMermaid` wraps each `[[bare]]` (Mermaid subroutine shape) + a synthesized `(((halt)))` halt-marker (`GraphNode.isHaltMarker: true`) inside a `subgraph w_${bareId}["halt frame"] … end` block. Dotted `onHalt` from `[[bare]]` crosses the subgraph border to the override target. An always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state (replaces the v6 `((round))` shape convention). + +- **Edge-label vocabulary rewritten** for readability — `[reads] → [writes]/[moves]` with each role wrapped in `[…]` (the tape-block indicator; always present, even single-tape). Read cells: `'X'` literal-quoted, `*` (ASCII; ifOtherSymbol catch-all — literal `*` in the alphabet renders as `'*'`), `B` (tape's blank shorthand). Write cells: literal-quoted, `K` (keep), `E` (erase = write blank). Move cells: `L` / `R` / `S`. Alternation per-pattern bracket: `['^']|['1']` for single-tape, `['0','a']|['1','b']` for multi-tape. Compact in-bracket `['^'|'1']` form is rejected by `fromMermaid` (would read as cross-product semantics in multi-tape). + +- **Thick `==>` arrows for stack-pushing transitions.** When a transition's target is a wrapped state AND ≠ source (i.e. would push the wrapper's override onto the runtime stack per `TuringMachine.run`'s line ~220 transition-time push), the engine emits a thick `==>` arrow — visual signal for "this transition fires a stack push." + +- **`GraphTransition.id`** — stable, deterministic per-edge identifier (`${fromNodeId}-${patternIx}`). Supports downstream tooling for edge-targeting in rendered Mermaid SVG (e.g. highlight-the-next-transition in interactive viewers). + +- **`summarize().stateCount` filters `isHaltMarker` nodes** — halt markers are visualization sentinels (all map back to singleton `haltState` at runtime), not distinct runtime states. Matches the per-algorithm header count in `library-binary-numbers/states.md`. + +### Added + +- **#139 regression test** — `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for simple wrappers. The wrapper's composite name (e.g. `scanToX(eraseHere)`) no longer appears as any graph-node label, so `fromGraph` reconstructs and recomputes the composite fresh on each pass — no round-trip name accumulation. + +- **Multi-tape example** in the engine README illustrating the bracketed format with two tapes. + +- **§Diagram conventions section** in the engine README — full reference: node shapes, edge styles, groupings, edge label format + cell vocabulary, alternation rule, multi-tape example. + +- **Cross-package introspection consistency** — `library-binary-numbers/states.md` per-algorithm header line now uses `summarizeGraph(graph)` for its `N states; N transitions; N wrappers (max nesting depth N); has cycles` summary, dogfooding the same API any consumer would use. + +### Removed + +- Hand-drawn pedagogical Mermaid blocks in engine + root READMEs. The v7 `toMermaid` output is now the primary illustration — no more vocabulary mismatch between hand-drawn and engine-rendered diagrams. + +### Migration + +For consumers updating from v6.x: + +**1. Identifier rename** (mechanical): + +```sh +git grep -l 'OverrodeHaltState\|overrodeHaltState' | xargs sed -i '' \ + -e 's/OverrodeHaltState/OverriddenHaltState/g' \ + -e 's/overrodeHaltState/overriddenHaltState/g' +``` + +Persisted `State.toGraph` JSON dumps need the same `overrodeHaltStateId` → `overriddenHaltStateId` field rename. + +**2. Wrapper composite name format** — code that pattern-matches `state.name` for wrapper composites needs to switch from `>`-split to paren-parse: `'A>B'` is now `'A(B)'`. + +**3. State name validation** — any code that constructs `new State(null, 'foo(bar)')` now throws. Real-world identifier-style state names (`scanToX`, `goHome`, `incT1`) are unaffected. + +**4. `toMermaid` output format** — if you render `toMermaid` output programmatically, the edge-label vocabulary changed completely (bracketed-tape-block format with `K`/`E`/`B`/`*`/`L`/`R`/`S`). See the engine README's §Diagram conventions for the full vocabulary. + +**5. `Graph` data shape** — `GraphNode` gained `isWrapped: boolean` + `isHaltMarker: boolean`; `GraphTransition` gained `id: string`. Most consumers don't need to change. If you call `summarize().stateCount`, the value now excludes halt markers (typically matches what you actually wanted — runtime state count, not visualization-node count). + +### Out of v7-alpha.1 (still pending for stable v7.0.0) + +- **[#102](https://github.com/mellonis/turing-machine-js/issues/102)** — debugger step-in / step-out / step-over primitives. Additive — won't change any existing API. Will land in `v7.0.0-alpha.2` or stable `v7.0.0`. + +### Compatibility + +- Peer dep `@turing-machine-js/machine` widened `^6.0.0` → `^7.0.0-alpha.1` on `@turing-machine-js/builder`, `@turing-machine-js/library-binary-numbers`, `@turing-machine-js/library-binary-numbers-bare`. + ## [6.4.0] - 2026-05-19 Adds a new awaited hook on `TuringMachine.run`. Minor release, additive — no breaking changes. Closes [#163](https://github.com/mellonis/turing-machine-js/issues/163). diff --git a/packages/machine/README.md b/packages/machine/README.md index ad90f3c..9c9c9e5 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -648,7 +648,7 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v6.2** *(superseded by v6.3.0)* — widened `onStep`'s signature to `(m) => void | Promise` and added an inline `await onStep(...)` in the run loop, enabling throttle-in-`onStep` patterns. This overturned the docstring-stated contract that `onStep` is sync (microtask-free); the right place for per-iter throttling is `onPause` with self-rearm (see [Throttle pattern](#throttle-pattern)). Restored in v6.3.0. - **v6.3** — `onStep` reverted to its v6.0–v6.1 sync contract — `(m) => void`, called synchronously inside the run loop. The Throttle pattern section documents the engine-native shape for per-iter throttle / "wait between iters" UIs. No other API changes. - **v6.4** — New **`onIter`** hook on `run()`: awaited, fires once at the end of every iter (after both `onPause` dispatches on the same yield), unaffected by the `debug` master switch. Use for per-iter throttle / animation / coordination needing a suspend point; complements the existing sync `onStep` (tracing) and conditional `onPause` (user breakpoints). Three-hook contract is now `onStep` (sync, mid-iter) / `onPause` (awaited, on `state.debug` match) / `onIter` (awaited, end-of-iter). Additive — peer-deps unchanged. The v6.3.0 README's `onPause`-rearm throttle workaround is superseded. -- **v7** *(in progress)* — Composition-representation overhaul. Breaking renames + reshapes scheduled for the v7 cut. Landing piecewise on the `v7` branch; one entry per landed change: +- **v7** *(alpha 1, 2026-05-21)* — Composition-representation overhaul. **First pre-release on the `next` dist-tag:** `npm install @turing-machine-js/machine@next` (or pin `@7.0.0-alpha.1`). Stable v7.0.0 still pending [#102](https://github.com/mellonis/turing-machine-js/issues/102) (debugger step-in/over/out primitives). Landed in alpha.1: - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly. - **`toMermaid` wrapped-state emit overhaul** ([#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139)). The wrapper-and-its-bare pair collapses into a single graph node (`isWrapped: true`); the wrapper's composite name no longer appears as a node label (only the bare's name does). Each wrapper gets a Mermaid `subgraph w_${bareId}["halt frame"] … end` block containing the `[[bare]]` (subroutine shape) plus a halt-marker `(((halt)))` (visualization aid showing where halt-bound transitions land inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target — exactly one per wrapper. `Graph` data shape gains `isWrapped` and `isHaltMarker` flags on `GraphNode` and a stable `id` on `GraphTransition` (deterministic per-edge identifier — supports downstream tooling like the `machines-demo` interactive viewer at [machines-demo#10](https://github.com/mellonis/machines-demo/issues/10)). Halt-marker graph nodes use negative ids and round-trip back to the singleton `haltState` via `fromGraph`. Bytewise round-trip stability falls out for simple wrappers (no composite name in the graph means `fromGraph(toGraph(state))` recomputes names fresh — no accumulation). Shared-bare cases (e.g. `minusOne`'s repeated `invertNumber`) use per-context duplication in the graph emit. diff --git a/packages/machine/package.json b/packages/machine/package.json index 564b7bc..008f7ba 100644 --- a/packages/machine/package.json +++ b/packages/machine/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/machine", - "version": "6.4.0", + "version": "7.0.0-alpha.1", "description": "A convenient Turing machine", "engines": { "npm": ">=7.0.0" From e7e055d978d604f3488a9e1024739d79e32db2b9 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 00:28:01 +0300 Subject: [PATCH 013/118] test(graphFormats): cover fromMermaid parser error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new tests in the existing `fromMermaid error paths` block: - Read label with no bracketed list (graphFormats.ts:379) - Write/move cell-count mismatch (graphFormats.ts:396) - Backslash-escape branch in `stripBrackets` (graphFormats.ts:355-356) Coverage: statements 98.29 → 98.72 (+0.43), branches 95.35 → 96.13 (+0.78), graphFormats.ts 96.42/90.62 → 98.8/94.79. Addresses the Coveralls "coverage fell" status on PR #167 (v7 → master draft) after v7-alpha.1 work landed. Remaining uncovered branches in v7 code are defensive fallbacks (graph.ts:173/209, State.ts:463-464, introspection.ts:121) — not worth synthetic tests. --- packages/machine/src/utilities/graph.spec.ts | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index d823061..e8592fd 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -292,6 +292,51 @@ describe('fromMermaid error paths', () => { expect(() => fromMermaid(mermaid)).toThrow(/compact in-bracket alternation/); }); + + test('throws when the read label has no bracketed list', () => { + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0","1"]]', + ' s0(((halt)))', + ' s1["entry"]', + ' idle([idle])', + ' idle -. enter .-> s1', + ' s1 -- "X → [K]/[S]" --> s0', // no `[…]` in the read part at all + ].join('\n'); + + expect(() => fromMermaid(mermaid)).toThrow(/no bracketed read-list/); + }); + + test('throws on write/move cell-count mismatch', () => { + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0","1"]]', + ' s0(((halt)))', + ' s1["entry"]', + ' idle([idle])', + ' idle -. enter .-> s1', + " s1 -- \"['0'] → [K,K]/[R]\" --> s0", // 2 writes, 1 move + ].join('\n'); + + expect(() => fromMermaid(mermaid)).toThrow(/write-cells.*move-cells.*mismatch/); + }); + + test('parses backslash-escaped chars inside a bracket (e.g. literal `|` as `\\|`)', () => { + // `stripBrackets` walks the inner content character-by-character; when it + // hits `\`, it skips the next char (so `\|` is a literal pipe, not the + // alternation separator). Exercises the escape branch in stripBrackets. + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","|"]]', + ' s0(((halt)))', + ' s1["x"]', + ' idle([idle])', + ' idle -. enter .-> s1', + " s1 -- \"['\\|'] → [K]/[S]\" --> s0", // pattern reads `'\|'` (literal pipe) + ].join('\n'); + + expect(() => fromMermaid(mermaid)).not.toThrow(); + }); }); describe('fromMermaid ensureNode update branches', () => { From a29002c6c03caaa53b24668ff588738e044e6ea9 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 00:42:22 +0300 Subject: [PATCH 014/118] test(coverage): cover remaining reachable v7 branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more tests for the v7 lines #171 didn't reach: - `State.toGraph` unbound-Reference catch in WRAPPER context (State.ts:463-464) — the existing test covered the non-wrapper branch; this one wraps the unbound-Ref state via `withOverriddenHaltState(...)` to hit the other try/catch - `parseWriteSymbolLabel` fallback return (graph.ts:209) — defensive return-as-is for unrecognized labels - `parsePatternString` blank-marker `?? cell` fallback (graph.ts:164) — defensive return when `alphabets[tapeIx]` is missing Plus the parser-fallback test in `parsePatternString` describe block (graph.ts:173) was already added in #171's first round; this PR finishes the symmetric `parseWriteSymbolLabel` describe block. Coverage: statements 98.72 → 99.14 (+0.42), branches 96.13 → 96.71 (+0.58), lines 98.32 → 99.21 (+0.89), State.ts 99.11 → 100% statements. Should clear Coveralls' remaining -0.5% drop on PR #167 by pulling v7 total above master. Remaining uncovered (not worth synthetic tests): - graphFormats.ts:344 — defensive throw inside `stripBrackets` closure, unreachable via fromMermaid's slice logic - introspection.ts:121 — `if (node)` guard in cycle detection --- packages/machine/src/classes/State.spec.ts | 26 +++++++++++++++++- packages/machine/src/utilities/graph.spec.ts | 29 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index f32939c..3c16b0a 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -208,7 +208,7 @@ describe('State.withOverriddenHaltState', () => { }); describe('State.toGraph — unbound Reference', () => { - test('skips a transition whose nextState is an unbound Reference', () => { + test('skips a transition whose nextState is an unbound Reference (non-wrapper context)', () => { // An unbound Reference throws when its `.ref` getter is read. State.toGraph // catches that and skips the transition rather than failing the whole walk. const unboundRef = new Reference(); @@ -222,6 +222,30 @@ describe('State.toGraph — unbound Reference', () => { // Only the haltState-bound transition survives; the unbound one is dropped. expect(graph.nodes[state.id].transitions).toHaveLength(1); }); + + test('skips a transition whose nextState is an unbound Reference (wrapper context)', () => { + // toGraph has a separate try/catch in its wrapper-context branch — when the + // bare being walked is inside a `withOverriddenHaltState` wrapper. Same + // skip-and-continue semantic. + const unboundRef = new Reference(); + const bare = new State({ + [symbol(['0'])]: {nextState: unboundRef}, + [symbol(['1'])]: {nextState: haltState}, + }, 'bare'); + const override = new State({ + [symbol(['0'])]: {nextState: haltState}, + [symbol(['1'])]: {nextState: haltState}, + }, 'override'); + const wrapped = bare.withOverriddenHaltState(override); + + const graph = State.toGraph(wrapped, tapeBlock); + + // The collapsed wrapper node retains only the haltState-bound transition; + // the unbound-Ref one is dropped. + const collapsedNode = graph.nodes[wrapped.id]; + + expect(collapsedNode.transitions).toHaveLength(1); + }); }); describe('State.fromGraph — cyclic override-halt chain', () => { diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index e8592fd..f2efd9c 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -4,6 +4,7 @@ import { decodeWriteSymbol, parseMovementLabel, parsePatternString, + parseWriteSymbolLabel, splitUnescaped, } from './graph'; import {fromMermaid, toMermaid} from './graphFormats'; @@ -214,6 +215,34 @@ describe('parsePatternString', () => { test('per-cell `B` becomes the tape blank symbol', () => { expect(parsePatternString("B,'a'", [[' ', '0'], [' ', 'a']])).toEqual([[' ', 'a']]); }); + + test('fallback: cell that is not marker/blank/quoted is returned as-is', () => { + // Defensive — the parser doesn't throw on unexpected cells; it returns + // them as-is, so consumer code can decide whether to reject. + expect(parsePatternString('Q', [[' ', '0']])).toEqual([['Q']]); + }); + + test('blank-marker fallback when alphabet for the tape is missing', () => { + // Defensive: if alphabets[tapeIx] is undefined, returns the marker + // string itself rather than throwing. + expect(parsePatternString('B', [])).toEqual([['B']]); + }); +}); + +describe('parseWriteSymbolLabel', () => { + test('maps K/E to upstream symbolCommands', () => { + expect(parseWriteSymbolLabel('K')).toBe(symbolCommands.keep); + expect(parseWriteSymbolLabel('E')).toBe(symbolCommands.erase); + }); + + test('strips single quotes from a literal alphabet symbol', () => { + expect(parseWriteSymbolLabel("'X'")).toBe('X'); + }); + + test('fallback: label that is not K/E/quoted is returned as-is', () => { + // Defensive — same shape as parsePatternString's fallback. + expect(parseWriteSymbolLabel('Z')).toBe('Z'); + }); }); describe('parseMovementLabel', () => { From c36b7b698d6de1cb30ac2ddfde9b3323292cf1e3 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 06:39:31 +0300 Subject: [PATCH 015/118] docs(spec): callable-subtree visualization for #174 Draft design spec at docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md. Captures the reframing of `withOverriddenHaltState` visualization as a function-call model: wrapper = call site, callable subtree = body, halt = return. Replaces v7 alpha.1's halt-frame subgraph emit (closes the design phase of #174). Key decisions documented: - Bold `==>` reserved for wrapper-to-bare `call` arrows only. - Dotted `-.->` reserved for frame-level dispatch (return, halt, enter). - Wrappers and bares as separate GraphNodes (un-collapsing alpha.1's collapse). Each unique bare gets ONE subtree; multiple wrappers share via `bareStateId`. - Union-find merges subtrees only when bare reach sets overlap (rare in practice; minusOne probe confirms 3 components instead of alpha.1's 5 subgraphs). - Halt marker `c_X` always emitted; orphan signals dead wrapper. - `onHalt` keyword retired; wrapper outgoing is a regular solid `-->`. - `return`/`halt` arrows derived at emit time, not stored as Graph fields. Includes 7 worked examples with Mermaid diagrams: - simple wrapper, PostMachine subroutine, multi-wrapper sharing bare, self-wrapping `A.wohs(A)`, nested `.wohs()` chain (4a/4b), Reference cycle dead wrapper. Plus 6 edge case discussions and the minusOne worked example showing the new emit shape vs alpha.1's per-context duplication. Implementation pending (tasks #30 revert P1, #31 reimplement per the spec). Related issues filed: #175 memoization, #176 nested-wohs name simplification, post-machine-js#85 subroutine hopper evaluation. --- ...026-05-21-halt-frame-transitive-closure.md | 672 ++++++++++++++++++ 1 file changed, 672 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md diff --git a/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md b/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md new file mode 100644 index 0000000..6a163c3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md @@ -0,0 +1,672 @@ +# Callable-subtree visualization for `withOverriddenHaltState` + +**Status:** DRAFT 2026-05-21 — supersedes the earlier "transitive-closure halt-frame" framing of this doc. Tracks [#174](https://github.com/mellonis/turing-machine-js/issues/174). Lands on the `v7` integration branch before v7 stable cut. **Previous implementation (frameId-based exclusive-reachable algorithm in `State.toGraph`) is being reverted in favor of the design below.** + +**Relation to [#173](https://github.com/mellonis/turing-machine-js/issues/173):** #173 closed (2026-05-21) as the literal complaint (orphan `c_N` + onHalt anchor) doesn't apply to the new design — there's no `c_N` per-wrapper concept anymore. The new design has per-bare `c_A` halt sinks and per-wrapper call/return/onHalt edges, which together visualize runtime semantics directly. + +## The reframing + +`withOverriddenHaltState` is, structurally, **a function call**. When you write `W = A.wohs(target)`: + +- `W` is a **call site** — invoking it pushes `target` onto the halt-stack and delegates to `A`'s transitions. +- `A` (or rather, the subtree forward-reachable from `A`) is the **callable body**. +- The "halt" at the end of `A`'s execution is a **return point** — the stack pop kicks the override into action. + +So the runtime model is "graph of callable subtrees, dispatched from a top-level driver." The diagram should reflect that directly. + +## Mental model & vocabulary + +| Concept | Visualization | +|---|---| +| **Callable subtree** of a bare `A` | `subgraph subtree_A["callable subtree of A"]` block containing `A`, A's body states, and a local halt sink `c_A` | +| **Wrapper** `W = A.wohs(target)` | A `[[A(target)]]` node OUTSIDE the subtree | +| **Call** (wrapper → bare) | Bold `==>` arrow labeled `call`. **Reserved**: only wrappers emit bold arrows, and only to their bare. Other transitions whose target happens to be a wrapper (e.g., dispatcher → W1) stay as regular solid `-->`. Multiple wrappers sharing a bare collapse into one `==>` ribbon via Mermaid `&` syntax: `s_W1 & s_W2 == call ==> s_A` | +| **Return** (subtree halt → wrapper) | Dotted `-.->` arrow from the SUBGRAPH back to each wrapper, labeled `return`. Multiple wrappers collapse via `&` on the target side: `subtree_A -. return .-> s_W1 & s_W2`. **Demand-emit** — only emitted when `c_A` has at least one incoming edge AND the wrapper actually calls this subtree | +| **Halt** (subtree halt → real halt) | Dotted `-.->` arrow from the SUBGRAPH to `s0`, labeled `halt`. **Demand-emit** — only emitted when `c_A` has incoming edges AND there's a non-wrapper entry path (solid `-->`) into any state in the subtree. Fires when the subtree is entered without a wrapper on the stack | +| **Wrapper's outgoing** (post-return continuation) | Solid `-->` arrow from wrapper to its override target. Just a regular transition — the wrapper "transitions to" its override after the return fires | +| **Idle sentinel** | `idle([idle])` + dotted `idle -. enter .-> initial` (unchanged from v7 alpha.1) | +| **Real halt** | `s0(((halt)))` at top level (unchanged from v7 alpha.1) | +| **Local subtree halt** | `c_A(((halt)))` inside the subtree's subgraph block — body halts visually land here | + +## Arrow style summary + +| Style | Used for | +|---|---| +| Solid `-->` | Regular state-to-state transitions, including (a) wrapper → override target, and (b) any non-wrapper state's transitions, even when their target is a wrapper | +| Bold `==>` | **Only** the wrapper-to-bare `call` arrow. Source is always a wrapper; target is its bare | +| Dotted `-.->` | Frame-level dispatch (subtree return, subtree halt, idle enter) | + +Bold `==>` is reserved — it's the visual signature of "a wrapper calling into its callable subtree." Counting bold arrows in the diagram tells you exactly how many wrappers are in play. (Departs from v7 alpha.1's "bold-into-any-wrapper" rule; under that older rule, dispatcher → W1 was also bold. Under the new rule, it's solid — the reader sees W1's `[[…]]` shape and infers "wrapper" without needing redundant arrow styling on every transition into it.) + +## Examples + +### Example 1 — simple wrapper + +`A.withOverriddenHaltState(target)`, no shared bare: + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_target["target"] + s_W[["A(target)"]] + + subgraph subtree_A["callable subtree of A"] + s_A["A"] + s_body["body"] + c_A((("halt"))) + end + + s_W == call ==> s_A + subtree_A -. return .-> s_W + s_W --> s_target + + idle -. enter .-> s_W + + s_A -- "[*]" --> s_body + s_body -- "['*']" --> c_A + s_target -- "[*]" --> s0 +``` + +Reading the runtime: idle → enter → W → call → subtree → body halts at c_A → return to W → W's `--> s_target` → s_target → halt. + +**No `halt` arrow** from the subtree to `s0`: there's no non-wrapper entry path into the subtree (only `s_W == call ==>` enters it), so the empty-stack case never fires. The `halt` arrow is demand-emit and omitted here. + +### Example 1b — PostMachine subroutine (the canonical motivating case) + +`PostMachine` program `{ 1: call('rightToBlank'); 2: mark; 3: stop; rightToBlank: { 1: right; 2: check(1,3); 3: stop } }`. PostMachine constructs internally: + +- A **hopper state** named `rightToBlank` whose only transition is `[ifOtherSymbol]: nextState = rightToBlank::1` (forward to the first body state). +- **Body states** `rightToBlank::1` (the `right` command) and `rightToBlank::2` (the `check` command). +- A **continuation state** `1~2` (where control resumes after the subroutine returns). +- A **wrapper** `W = rightToBlank.withOverriddenHaltState(continuation_1~2)`. +- A **top-level instruction 2** (`mark`), reached via the continuation. + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_W[["rightToBlank(1~2)"]] + s_continuation["1~2"] + s_inst2["2"] + + subgraph subtree_rightToBlank["callable subtree of rightToBlank"] + s_hopper["rightToBlank"] + s_body1["rightToBlank::1"] + s_body2["rightToBlank::2"] + c_rightToBlank((("halt"))) + end + + s_W == call ==> s_hopper + subtree_rightToBlank -. return .-> s_W + s_W --> s_continuation + + idle -. enter .-> s_W + + s_hopper -- "[*]" --> s_body1 + s_body1 -- "[*] → [K]/[R]" --> s_body2 + s_body2 -- "['*'] → [K]/[S]" --> s_body1 + s_body2 -- "[B] → [K]/[S]" --> c_rightToBlank + + s_continuation -- "[*] → [K]/[S]" --> s_inst2 + s_inst2 -- "[*] → ['*']/[S]" --> s0 +``` + +**What this resolves vs. alpha.1's emit:** + +- Body states `rightToBlank::1` and `rightToBlank::2` are INSIDE the subtree (alpha.1 put them outside). +- The check's halt-bound transition (`s_body2 -- "[B]" -->`) retargets to `c_rightToBlank` instead of `s0` (alpha.1 emitted `→ s0`, misleading about runtime). +- No orphan halt marker — `c_rightToBlank` has incoming from `s_body2`. The condition that prompted #173 doesn't arise. +- The wrapper `s_W[["rightToBlank(1~2)"]]` sits OUTSIDE the subtree as a separate node — it's the call site, not the bare. + +Runtime trace reads off the diagram: `idle → W → call → hopper → body_1 → body_2 → c_rightToBlank → return W → continuation → instruction_2 → s0`. Each arrow corresponds to a runtime step. + +### Example 2 — multi-wrapper sharing a bare + +Two wrappers `W1 = A.wohs(target_B)` and `W2 = A.wohs(target_C)`, dispatcher routes: + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_disp["dispatcher"] + s_targetB["target_B"] + s_targetC["target_C"] + + subgraph subtree_A["callable subtree of A"] + s_A["A"] + s_body["body"] + c_A((("halt"))) + end + + s_W1[["A(target_B)"]] & s_W2[["A(target_C)"]] == call ==> s_A + subtree_A -. return .-> s_W1 & s_W2 + subtree_A -. halt .-> s0 + s_W1 --> s_targetB + s_W2 --> s_targetC + + idle -. enter .-> s_disp + + s_disp -- "['*']" --> s_W1 + s_disp -- "['+']" --> s_W2 + s_disp -- "['#']" --> s_A + + s_A -- "[*]" --> s_body + s_body -- "['*']" --> c_A + s_targetB -- "[*]" --> s0 + s_targetC -- "[*]" --> s0 +``` + +Dispatcher's outgoing arrows to W1, W2, and A are all solid `-->` (regular transitions). The reader sees W1 and W2 are wrappers because of the `[[…]]` shape. Two bold `call` arrows in the diagram (the wrappers' calls into A's subtree) — exactly the count of wrappers in play. The subtree's `-. return .->` arrows go back to both wrappers (collapsed via `&`); the `-. halt .->` arrow handles the dispatcher's direct entry path (`['#']`). + +### Example 3 — self-wrapping (`A.wohs(A)`) + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_W[["A(A)"]] + + subgraph subtree_A["callable subtree of A"] + s_A["A"] + c_A((("halt"))) + end + + s_W == call ==> s_A + s_W --> s_A + subtree_A -. return .-> s_W + subtree_A -. halt .-> s0 + + idle -. enter .-> s_W + + s_A -- "[*]" --> c_A +``` + +Legal API call (no self-reference validation). Runtime: W push A → A halts → pop A → control to A → A halts again → real halt. The diagram captures both arrows from W to A: bold `call` (entering A under W's stack frame) and solid `-->` (W's "post-return continuation IS A again"). Pattern is "run A twice in sequence." + +### Example 4 — nested `.wohs()` chain + +Two cases worth comparing: (4a) the chain `.wohs().wohs()` with only the outer entered, and (4b) two independently-referenced wrappers around the same bare. + +#### 4a. Chain construction, only outer entered + +```ts +const W1 = A.withOverriddenHaltState(target_1); +const W2 = W1.withOverriddenHaltState(target_2); +// Only W2 is referenced from the entry — W1 is never independently entered. +``` + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_target2["target_2"] + s_W2[["A(target_1)(target_2)"]] + + subgraph subtree_A["callable subtree of A"] + s_A["A"] + c_A((("halt"))) + end + + s_W2 == call ==> s_A + subtree_A -. return .-> s_W2 + s_W2 --> s_target2 + + idle -. enter .-> s_W2 + + s_A -- "[*]" --> c_A + s_target2 -- "[*]" --> s_target2 + s_target2 -- "[*]" --> s0 +``` + +**Only ONE wrapper appears in the graph: `s_W2`.** `W1` is structurally absorbed — at runtime, only W2's override (`target_2`) is pushed onto the stack; `target_1` is never on the stack so it never fires. The composite name `A(target_1)(target_2)` is the engine's `state.#name`; it shows the construction chain textually but is technically misleading about runtime behavior (only `target_2` actually fires). Sibling-style emission (no nested frames) is the right rendering because the runtime is single-level. + +**Runtime trace:** idle → W2 → call subtree_A → A halts at c_A → return to W2 → W2 transitions to `target_2` → target_2 halts → s0. + +`halt` arrow omitted (no non-wrapper entry to subtree_A). + +#### 4b. Two independent wrappers, both referenced + +```ts +const W1 = A.withOverriddenHaltState(target_1); +const W2 = A.withOverriddenHaltState(target_2); +const dispatcher = new State({ + [sym_a]: { nextState: W1 }, + [sym_b]: { nextState: W2 }, +}, 'dispatcher'); +``` + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_disp["dispatcher"] + s_target1["target_1"] + s_target2["target_2"] + + subgraph subtree_A["callable subtree of A"] + s_A["A"] + c_A((("halt"))) + end + + s_W1[["A(target_1)"]] & s_W2[["A(target_2)"]] == call ==> s_A + subtree_A -. return .-> s_W1 & s_W2 + s_W1 --> s_target1 + s_W2 --> s_target2 + + idle -. enter .-> s_disp + s_disp -- "['a']" --> s_W1 + s_disp -- "['b']" --> s_W2 + + s_A -- "[*]" --> c_A + s_target1 -- "[*]" --> s0 + s_target2 -- "[*]" --> s0 +``` + +**Two wrappers, both as siblings.** Dispatcher chooses which via input symbol — solid arrows from dispatcher to each wrapper (regular transitions; the `[[…]]` shape on W1 and W2 tells the reader they're wrappers). Each wrapper has its own bold `call` arrow into the subtree. + +This is how `library-binary-numbers/minusOne` is built (five sibling wrappers around two distinct bares). + +The visual distinction between 4a and 4b: **count the bold `==>` arrows**. 4a has one (W2's call); 4b has two (W1's and W2's). One bold arrow per wrapper in the graph, by construction. + +### Example 5 — Reference cycle (dead wrapper) + +`A` loops to itself via `Reference`; `W = A.wohs(target)`: + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_target["target"] + s_W[["A(target)"]] + + subgraph subtree_A["callable subtree of A"] + s_A["A"] + c_A((("halt"))) + end + + s_W == call ==> s_A + s_W --> s_target + + idle -. enter .-> s_W + + s_A -- "[*]" --> s_A + s_target -- "[*]" --> s0 +``` + +`c_A` is **orphan** (no `--> c_A` edge): `s_A` loops back to itself via the Reference and never halts. Per the demand-emit rule, `subtree_A -. return .->` and `subtree_A -. halt .->` are BOTH omitted because `c_A` has no incoming edges — neither dispatch path is structurally reachable. Reader sees: "the subtree's `c_A` is unreachable. Whatever the wrapper redirects to (`target`) is also unreachable. Dead wrapper." + +The orphan `c_A` alone IS the dead-wrapper signal — no need for orphan return/halt arrows. + +## minusOne as a worked example + +`library-binary-numbers/minusOne` is a 4-deep wrapper composition that exercises shared bares + override chains. Probed under the union-find rule (2026-05-21): five wrappers, three subtrees. + +**Wrapper instances** (constructed via `.withOverriddenHaltState`): + +- `W10 = goToNumberStart.wohs(invertNumberGoToNumberWithInversion)` +- `W14 = goToNumberStart.wohs(normalizeNumberMoveNumberStart)` — same bare, different override +- `W20 = invertNumber.wohs(normalizeNumber)` +- `W22 = invertNumber.wohs(W21)` — wraps invertNumber, override is plusOne wrapper +- `W21 = plusOne.wohs(W20)` — wraps plusOne, override is invertNumber wrapper + +**Subtree decomposition** (3 unique bares): + +- `subtree_goToNumberStart`: contains `goToNumberStart` bare only (no body; halts directly). +- `subtree_invertNumber`: contains `invertNumber` bare only (no body; transitions delegate to `goToNumberStart` wrapper externally). +- `subtree_plusOne`: contains `plusOne` bare + body states (`plusOneFillZeros`, `plusOneAddNumberStart`, `plusOneCaryOne`). + +**Compared to alpha.1's emit** (5 subgraph blocks, per-context-duplicated bares): the new emit has **3 subgraph blocks** and de-duplicates the shared bares (`goToNumberStart` and `invertNumber` each appear once instead of twice). + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_minusOne["minusOne"] + s_invNumGoTo["invertNumberGoToNumberWithInversion"] + s_norm["normalizeNumber"] + s_normMove["normalizeNumberMoveNumberStart"] + s_normPut["normalizeNumberPutNewStartSymbol"] + s_goToNum["goToNumber"] + + subgraph subtree_goToNumberStart["callable subtree of goToNumberStart"] + s_goToNumberStart["goToNumberStart"] + c_goToNumberStart((("halt"))) + end + + subgraph subtree_invertNumber["callable subtree of invertNumber"] + s_invertNumber["invertNumber"] + c_invertNumber((("halt"))) + end + + subgraph subtree_plusOne["callable subtree of plusOne"] + s_plusOne["plusOne"] + s_plusOneFillZeros["plusOneFillZeros"] + s_plusOneAddNumberStart["plusOneAddNumberStart"] + s_plusOneCaryOne["plusOneCaryOne"] + c_plusOne((("halt"))) + end + + s_W10[["goToNumberStart(invertNumberGoToNumberWithInversion)"]] + s_W14[["goToNumberStart(normalizeNumberMoveNumberStart)"]] + s_W20[["invertNumber(normalizeNumber)"]] + s_W21[["plusOne(W20)"]] + s_W22[["invertNumber(W21)"]] + + s_W10 == call ==> s_goToNumberStart + s_W14 == call ==> s_goToNumberStart + s_W20 == call ==> s_invertNumber + s_W22 == call ==> s_invertNumber + s_W21 == call ==> s_plusOne + + subtree_goToNumberStart -. return .-> s_W10 + subtree_goToNumberStart -. return .-> s_W14 + subtree_invertNumber -. return .-> s_W20 + subtree_invertNumber -. return .-> s_W22 + subtree_plusOne -. return .-> s_W21 + + s_W10 --> s_invNumGoTo + s_W14 --> s_normMove + s_W20 --> s_norm + s_W21 --> s_W20 + s_W22 --> s_W21 + + idle -. enter .-> s_minusOne + s_minusOne --> s_W22 + + s_invertNumber --> s_W10 + s_norm --> s_W14 + s_normMove --> s_normPut + s_normPut --> s_goToNum +``` + +Reading the override chain: `minusOne → W22 → W21 → W20 → normalizeNumber → W14 → normalizeNumberMoveNumberStart → … → real halt`. Each wrapper's solid `-->` to its override target chains the runtime call sequence. + +Reading the bare-sharing: both `W10` and `W14` call into the SAME `subtree_goToNumberStart`. Both `W20` and `W22` call into the SAME `subtree_invertNumber`. This de-duplication is invisible in alpha.1's emit (it emits two `goToNumberStart` and two `invertNumber` subgraphs). + +**Union-find didn't trigger** for minusOne because each bare's forward-reachable set is just itself (the bares transition either to halt or to other wrappers, never directly into each other's body states). Union-find applies to scenarios like the dispatcher+shared-X case, not chained compositions. + +## Reachability rules (what's inside the subtree) + +For each wrapped bare `A`, the callable subtree contains: +- `A` itself. +- Every state forward-reachable from `A` via its transitions, following `Reference#ref` transparently. +- The synthesized halt-marker `c_A` (always emitted — see Edge cases for the dead-wrapper rationale). + +**Each state is rendered exactly once. Containers are computed via union-find on wrapper reachability:** + +1. **For each wrapped bare** `B`, compute `reach(B)` = forward-reachable set starting from B (following transitions and `Reference#ref` transparently). +2. **Compute connected components** of wrappers by overlap: wrappers `W_i` and `W_j` are in the same component iff `reach(bare(W_i)) ∩ reach(bare(W_j)) ≠ ∅` (their reachable sets share at least one state). Transitive closure of the overlap relation. +3. **Each connected component** becomes one **callable scope** (a subgraph frame in the emit). The frame contains: every state in the union of `reach(bare(W))` for all wrappers in the component, plus a single halt marker `c_union`. +4. **States outside any wrapper's reach** stay at top level (e.g., dispatcher, override targets, real halt singleton, `idle`). + +Frame name: `callable subtree of ` for a single-bare component; `callable scope: A ∪ B ∪ …` for multi-bare components. + +**Demand-emit rules** (refined): + +- `c_union` always present (orphan marker is the dead-wrapper signal — see Edge cases). +- `frame -. return .-> W` emitted iff `c_union` has incoming edges AND wrapper W has a `call` arrow into the frame. +- `frame -. halt .-> s0` emitted iff `c_union` has incoming edges AND there is **at least one solid `-->` arrow** entering any state inside the frame (a non-wrapper entry path). + +Cross-subgraph arrows are allowed and natural — Mermaid supports arrows whose source and target sit in different subgraph blocks. A state in the union frame may be the target of an arrow from outside (dispatcher → X, e.g.), or the source of an arrow to outside (frame member → wrapper override target). + +**No per-context duplication.** Today's v7 alpha.1 duplicates shared bares (e.g., `library-binary-numbers/minusOne` shows `invertNumber` as both `s20` and `s22` in its emit). Under the callable-subtree model, there's a single `subtree_invertNumber` and both wrappers `call ==>` into it via the `&` syntax. The diagram is smaller and the runtime "same instance, multiple call sites" semantic is visualized exactly. No same-instance marking is needed because no node is duplicated. + +## Edge cases + +### `c_A` always emitted + +Every wrapped bare gets a `c_A(((halt)))` inside its subtree subgraph, even when: +- The bare has no halt-bound transitions in its reachable set (dead wrapper — Reference cycle case). +- The body has halt-bound transitions but none happen to land on `c_A` directly (e.g., halts via intermediate paths that exit the subtree). + +The orphan `c_A` (no incoming `--> c_A` edge) is a meaningful signal: "this wrapper's runtime scope never produces a halt — wrapper is dead code." + +### Self-wrapping (`A.wohs(A)`) + +Legal. Wrapper has two arrows to A: bold `call` + solid `-->` (override target). Runtime: "run A twice in sequence." See Example 3. + +### Override target is itself a wrapper + +`W = A.wohs(W2)` where `W2 = B.wohs(target)`. W's outgoing to its override `W2` is a regular solid `--> s_W2` (the new convention reserves bold for wrapper-to-bare only). The reader sees W2's `[[…]]` shape and knows it's a wrapper. W2 in turn has its own bold `s_W2 == call ==> s_B` arrow into its own subtree. So the chain reads as: regular transition into a wrapper (solid), then that wrapper's bold call into its bare. Two bold arrows total (one per wrapper), regardless of how composition chains together. + +### Nested `.wohs()` chain (`A.wohs(t1).wohs(t2)`) + +Single wrapper emitted (outermost). Composite name accumulates the chain (`A(t1)(t2)`). Per probe finding, only `t2` actually fires at runtime; `t1` is overwritten. The composite name is technically misleading but matches engine v7 alpha.1's `state.#name` value. + +### Shared body state (reachable from multiple subtrees) + +When X is reachable from multiple wrapped bares whose reach sets overlap, those wrappers are in the same connected component → one **union frame** containing both bares + the shared state + a single halt marker. + +**Worked example 1: no direct entry to X.** `dispatcher` reads `1` → `W1`, reads `2` → `W2`. `A → X`. `B → X`. `X` halts. `W1 = A.wohs(target_1)`, `W2 = B.wohs(target_2)`. + +`reach(A) = {A, X}` and `reach(B) = {B, X}` overlap on X → union component {W1, W2} → one frame containing {A, B, X, c_union}. + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_disp["dispatcher"] + s_target1["target_1"] + s_target2["target_2"] + s_W1[["A(target_1)"]] + s_W2[["B(target_2)"]] + + subgraph union_AB["callable scope: A ∪ B"] + s_A["A"] + s_B["B"] + s_X["X"] + c_union((("halt"))) + end + + s_W1 == call ==> s_A + s_W2 == call ==> s_B + union_AB -. return .-> s_W1 + union_AB -. return .-> s_W2 + + s_W1 --> s_target1 + s_W2 --> s_target2 + + idle -. enter .-> s_disp + s_disp -- "['1']" --> s_W1 + s_disp -- "['2']" --> s_W2 + + s_A --> s_X + s_B --> s_X + s_X -- "[*]" --> c_union + + s_target1 -- "[*]" --> s0 + s_target2 -- "[*]" --> s0 +``` + +X is inside the union frame. Both wrappers have `call` arrows to their respective bare entry points (W1 → A, W2 → B) — both bares live in the same scope. `c_union` has X's halt-bound transition landing on it. + +No `halt` arrow emitted — there's no solid `-->` entry into the union frame (only bold `call` from wrappers). The runtime never reaches `c_union` with an empty stack, so falling through to `s0` is structurally impossible. + +**Worked example 2: dispatcher adds direct entry to X (`['3'] → X`).** Now there's a non-wrapper entry into the union frame. + +```mermaid +--- +config: + layout: elk +--- +flowchart TB + s0((("halt"))) + idle([idle]) + s_disp["dispatcher"] + s_target1["target_1"] + s_target2["target_2"] + s_W1[["A(target_1)"]] + s_W2[["B(target_2)"]] + + subgraph union_AB["callable scope: A ∪ B"] + s_A["A"] + s_B["B"] + s_X["X"] + c_union((("halt"))) + end + + s_W1 == call ==> s_A + s_W2 == call ==> s_B + union_AB -. return .-> s_W1 + union_AB -. return .-> s_W2 + union_AB -. halt .-> s0 + + s_W1 --> s_target1 + s_W2 --> s_target2 + + idle -. enter .-> s_disp + s_disp -- "['1']" --> s_W1 + s_disp -- "['2']" --> s_W2 + s_disp -- "['3']" --> s_X + + s_A --> s_X + s_B --> s_X + s_X -- "[*]" --> c_union + + s_target1 -- "[*]" --> s0 + s_target2 -- "[*]" --> s0 +``` + +Cross-subgraph entry `s_disp -- ['3'] --> s_X` triggers the `halt` arrow to emit. All three runtime paths from dispatcher are now traceable: `1` → W1 → call → halt at c_union → return W1 → target_1 → s0; `2` → W2 analogously; `3` → directly to X (inside union) → halt at c_union → empty-stack `halt` arrow → s0. + +The union model degrades gracefully — adding direct entry to a state inside the union just flips the `halt` arrow on. No new structural concept needed. + +## Data model changes + +The callable-subtree model **un-collapses what v7 alpha.1 collapsed**. In alpha.1, each `withOverriddenHaltState` wrapper produces ONE `GraphNode` representing both the wrapper and its bare (the bare is "collapsed onto" the wrapper's id, with `isWrapped: true`). Under the new model, **wrappers and bares are separate `GraphNode` instances**: + +- **Bare node** (`isWrapper: false`, regular `["…"]` shape) — lives inside its callable subtree. There's exactly one bare node per unique wrapped `State` instance. +- **Wrapper node** (`isWrapper: true`, `[[…]]` shape, has `bareStateId: number`) — lives outside any subtree. There's one wrapper node per `withOverriddenHaltState` call. Multiple wrappers can share the same `bareStateId` if they wrap the same bare with different override targets. +- **Halt marker** (`isHaltMarker: true`, `(((halt)))` shape inside subtree) — one per subtree, retargets the bare's halt-bound transitions. + +`GraphNode` field changes (proposed names; subject to implementation): + +| Field | alpha.1 | New model | +|---|---|---| +| `isWrapped: boolean` | True for collapsed-wrapper nodes | **Renamed/repurposed:** drop in favor of `isWrapper: boolean` | +| `isWrapper: boolean` | n/a | True for external `[[…]]` wrapper nodes | +| `bareStateId: number \| null` | n/a | Set on wrappers; points to the bare's `GraphNode` id | +| `frameId: number \| null` | n/a (P1 added it; superseded) | Set on bare, body states, and halt marker — the id of the containing subtree | +| `isHaltMarker: boolean` | True for synthesized halt markers | Same | +| `overriddenHaltStateId: number \| null` | Set on collapsed-wrapper nodes (= override target's id) | Set on wrapper nodes | + +**`State.toGraph` second pass** (the reachability + frame-assignment + halt-retargeting work): + +- Pass 1 enumerates `State`s reachable from the initial state. For each State: + - If the State is wrapped (`#overriddenHaltState !== null` AND `#bareState !== null`): produce TWO nodes — a wrapper node (`isWrapper: true`, with the wrapper's id and composite name) AND a bare node (regular, with the bare's id and name) IF the bare doesn't already have a graph node from another wrapper context. + - If the State is not wrapped: produce one regular node. +- Pass 2 assigns `frameId` via union-find on bare reachability: each unique bare's forward-reachable set (following the bare's transitions and `Reference#ref` transparently) defines its candidate subtree. When two bares' reach sets overlap, they merge into one union frame. Each frame gets a single halt marker. +- Halt-bound transitions of any in-frame state retarget to the frame's halt marker. + +`Graph` itself: no structural change beyond the per-node field additions. + +## Emit changes + +### `toMermaid` + +- Subgraph emission unchanged in shape (`subgraph w_N["callable subtree of NAME"]` with bare + body + halt marker inside). +- **Frame-level outgoing arrows** added: for each wrapped subtree, emit: + - One `subtree_N -. return .-> wrapper_id` per wrapper that calls this subtree. + - One `subtree_N -. halt .-> s0` always. +- **Wrapper's `onHalt` edge removed.** Replaced by a solid `--> override_id` regular transition — always solid, regardless of whether the override is a wrapper (the new convention reserves bold for wrapper-to-bare `call` arrows only; the reader identifies the override as a wrapper, if it is one, by its `[[…]]` shape). +- **`call` label** added to bold arrows entering a wrapped subtree. The `&` multi-source syntax groups multiple wrappers sharing a bare into one ribbon. +- **Same-instance class assignments** if Option B/C selected. + +### `fromMermaid` + +- Parse subgraph membership → set `frameId` on nodes inside (already done in current draft). +- Parse `-. return .->` and `-. halt .->` arrows → no Graph data change needed (these are derivable from frame membership and structure at emit time, OR stored as a separate field). **TBD.** +- Parse `== call ==>` arrows: regular transitions whose target is a wrapper. Already handled. + +### `fromGraph` + +Unchanged. Frame membership and return/halt arrows are visualization concerns; runtime reconstruction reads `transitions` and `overriddenHaltStateId` from each node and builds State instances. + +## Round-trip + +`toGraph → toMermaid → fromMermaid → toGraph`: +- Data model survives if `frameId` is preserved across round-trip (already done). +- **TBD:** verify `library-binary-numbers/minusOne` round-trips byte-stable. Since the new model eliminates per-context duplication, the previous "shared-bare ordering" caveat is gone — `minusOne` should be cleaner under the new emit. + +## Implementation outline + +1. **Revert previous implementation** (the exclusive-only `frameId` pass in `State.toGraph` and the toMermaid grouping). Keep the `frameId` field on `GraphNode`; reuse it under the new semantics. +2. **Two-pass `State.toGraph`:** + - Pass 1: build raw nodes (current). + - Pass 2 (new): for each wrapper, compute the full forward-reachable set from its bare. For each in-set node, assign `frameId = wrapper.id`. For each in-set state's halt-bound transition, retarget to the wrapper's halt marker. + - Each `State` → exactly one `GraphNode`. When a state would belong to multiple subtrees, assign it to the innermost (deepest-nested) containing subtree. External references become cross-subgraph arrows at emit time. +3. **`toMermaid` rewrite of the subgraph emission:** + - Group nodes by `frameId`. + - Emit `subgraph w_${id}["callable subtree of NAME"]` per group. + - Emit `subtree_N -. return .-> wrapper_id` per wrapper that calls the subtree (derive from which wrappers' bareState produces this subtree). + - Emit `subtree_N -. halt .-> s0`. + - Wrapper's outgoing → override: solid `-->`, always. (Bold `==>` is reserved for the wrapper's `call` into its own bare.) +4. **`fromMermaid`:** track subgraph membership (already done); parse `return` and `halt` arrows as needed; parse `call` arrows as regular bold transitions. +5. **Tests:** Examples 1–5 above as regression cases. Existing wrapper-emission tests will need updates. +6. **Regenerate `library-binary-numbers*/states.md`** and verify visual sanity, especially `minusOne`. +7. **Update docs:** engine `CLAUDE.md`, `README.md`, this spec to IMPLEMENTED status. + +## Resolved questions + +### `return`/`halt` arrow storage + +**Decision: derive at emit time.** `toMermaid` computes `return` and `halt` arrows from frame membership + transitions; no new fields on `Graph` or `GraphNode` beyond `frameId`. + +Rationale: +- Data model stays lean — single source of truth (frame structure + transitions) drives the arrows. +- Round-trip stability is automatic: as long as `toGraph` is deterministic on `frameId` assignment (which it is per the union-find rule), `toGraph → toMermaid → fromMermaid → toGraph` produces bytewise-identical Mermaid on the second `toMermaid` call. The arrows are recomputed from the (identical) underlying structure. +- `fromMermaid` parses `return`/`halt` arrows but doesn't need to persist them in graph data — they're re-derived on the next emit. (Minor parse-then-discard cost; offset by the smaller data model.) +- Hand-edited Mermaid with inconsistent arrows is already explicitly unsupported per the engine's strict-format policy (`fromMermaid` is strict to the dialect `toMermaid` emits). + +### `onHalt` keyword retirement + +**Decision: retire the `onHalt` label and dotted style for wrapper-to-override edges.** The new model uses a regular solid `-->` arrow from wrapper to its override target — it's just a transition under the function-call mental model ("what fires after the call returns"). No special label needed. + +The dotted `-.->` style is now exclusively reserved for **frame-level dispatch arrows**: `return` (subtree → wrapper), `halt` (subtree → real halt), and `enter` (idle → initial state). + +Migration notes for the next prerelease: +- **CHANGELOG entry** — note the format change. Mermaid strings emitted by v7 alpha.1 (containing `-. onHalt .->` edges) will not parse with the new `fromMermaid`. One-way migration. +- **Engine `README.md`** — remove the "Edge arrow styles" reference to `-. onHalt .->`; replace with the new convention table. +- **Engine `CLAUDE.md`** — update the "v7 emit shape" paragraph to reflect the callable-subtree model + new arrow conventions. +- **`packages/library-binary-numbers*/states.md`** — regenerate via `npm run docs:states` after implementation lands; visual check on `minusOne` confirms the new shape reads well. + +## Open questions + +(None remaining; all design decisions resolved. Implementation details for `State.toGraph` un-collapsing, backward-compat strategy for alpha.1 Mermaid strings, and PR-shape decisions are captured in the [Implementation outline](#implementation-outline) and the related issues turing#175, turing#176, post-machine-js#85.) From 0a218ac3ee89c173004031cba82d99be8ef91810 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 06:47:26 +0300 Subject: [PATCH 016/118] fix(State): nested `.wohs()` collapses inner overrides (#176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `A.wohs(t1).wohs(t2)` now produces a wrapper with name `A(t2)` and bare = A (not "A(t1)(t2)" with bare = W1). The chain's inner overrides are dead at runtime — only the outermost `.wohs()`'s override is pushed onto the halt-stack on entry (verified empirically by probe). The composite name now reflects runtime behavior, not construction history. Implementation is a single-line change: `withOverriddenHaltState` unwraps `this` to its bare before composing the new wrapper. Tests: - New: `A.wohs(t1).wohs(t2).name === 'A(t2)'`, structural equivalence to `A.wohs(t2)` (same name + same override target). - New: 3-deep chain `A.wohs(t1).wohs(t2).wohs(t3) → 'A(t3)'`. - Updated: the existing "paren-naming distinguishes nestings" test now exercises only override-side wrapping (`outer1 = A.wohs(B.wohs(A))`), since `this`-side chain construction collapses under the new rule. Composes with #175 (memoization): with both shipped, `A.wohs(t1).wohs(t2) === A.wohs(t2)` (literal reference equality via cache lookup). states.md for library-binary-numbers libraries unchanged — those compositions use independent wrappers, not chained `.wohs()` calls. closes #176 --- packages/machine/src/classes/State.spec.ts | 48 ++++++++++++++++++---- packages/machine/src/classes/State.ts | 14 +++++-- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index 3c16b0a..d587c8d 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -188,22 +188,52 @@ describe('State.withOverriddenHaltState', () => { expect(wrapped.name).toBe('inner(outer)'); }); - test('paren-naming distinguishes nestings that flat `>` notation would collide', () => { + test('paren-naming distinguishes wrapping where the override is itself wrapped', () => { const A = new State({[ifOtherSymbol]: {}}, 'A'); const B = new State({[ifOtherSymbol]: {}}, 'B'); - // Construction 1: bare=A, override=(B with override A) + // bare=A, override=(B with override A) — outer1 = A.wohs(B.wohs(A)) + // Under #176, only `this` is unwrapped; the override (B.wohs(A)) is preserved as-is. const inner1 = B.withOverriddenHaltState(A); const outer1 = A.withOverriddenHaltState(inner1); - // Construction 2: bare=(A with override B), override=A - const inner2 = A.withOverriddenHaltState(B); - const outer2 = inner2.withOverriddenHaltState(A); - - // Old `>` notation would collide both at "A>B>A". Paren notation keeps them distinct. expect(outer1.name).toBe('A(B(A))'); - expect(outer2.name).toBe('A(B)(A)'); - expect(outer1.name).not.toBe(outer2.name); + expect(outer1.overriddenHaltState).toBe(inner1); + }); + + test('nested `.wohs()` chain collapses inner overrides (#176)', () => { + // `A.wohs(t1).wohs(t2)` is equivalent to `A.wohs(t2)` — t1 is dead at + // runtime (only the outermost wohs's override is pushed onto the stack + // when the wrapper is entered; verified empirically by probe). The + // composite name reflects the runtime behavior, not construction history. + const A = new State({[ifOtherSymbol]: {}}, 'A'); + const t1 = new State({[ifOtherSymbol]: {}}, 't1'); + const t2 = new State({[ifOtherSymbol]: {}}, 't2'); + + const W1 = A.withOverriddenHaltState(t1); + const W2 = W1.withOverriddenHaltState(t2); + + // Composite name: outer override only, inner ('t1') dropped. + expect(W2.name).toBe('A(t2)'); + expect(W2.overriddenHaltState).toBe(t2); + + // Structurally equivalent to direct construction. + const W2direct = A.withOverriddenHaltState(t2); + + expect(W2.name).toBe(W2direct.name); + expect(W2.overriddenHaltState).toBe(W2direct.overriddenHaltState); + }); + + test('3-deep `.wohs()` chain collapses to outermost override (#176)', () => { + const A = new State({[ifOtherSymbol]: {}}, 'A'); + const t1 = new State({[ifOtherSymbol]: {}}, 't1'); + const t2 = new State({[ifOtherSymbol]: {}}, 't2'); + const t3 = new State({[ifOtherSymbol]: {}}, 't3'); + + const W = A.withOverriddenHaltState(t1).withOverriddenHaltState(t2).withOverriddenHaltState(t3); + + expect(W.name).toBe('A(t3)'); + expect(W.overriddenHaltState).toBe(t3); }); }); diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index a91605d..03776a7 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -280,11 +280,17 @@ export default class State { // private-field access (legal within the same class). const state = new State(); - state.#name = `${this.name}(${overriddenHaltState.name})`; - state.#symbolToDataMap = this.#symbolToDataMap; + // Unwrap `this` if it's itself a wrapper — the chain's inner overrides + // are dead at runtime anyway (only the outermost `.wohs()`'s override is + // pushed onto the halt-stack on entry; verified empirically). Composite + // name reflects runtime behavior, not construction history. See #176. + const bare = this.#bareState ?? this; + + state.#name = `${bare.name}(${overriddenHaltState.name})`; + state.#symbolToDataMap = bare.#symbolToDataMap; state.#overriddenHaltState = overriddenHaltState; - state.#debugRef = this.#debugRef; - state.#bareState = this; + state.#debugRef = bare.#debugRef; + state.#bareState = bare; return state; } From 15677cf69a7bfaec937032be68283ea7aa826c62 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 07:02:36 +0300 Subject: [PATCH 017/118] feat(State): memoize withOverriddenHaltState (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache `(bare, override) → wrapper` so identical arguments return the same JS instance. `A.wohs(t) === A.wohs(t)` — referential equality holds for repeated wrap calls. Composes with #176 (chained-`wohs()` collapse): - #176 unwraps `this` to its bare on each `.wohs()` call. - This cache then keys by `(bare, override)`. - Result: `A.wohs(t1).wohs(t2) === A.wohs(t2)` — chained construction hits the same cache slot as direct construction. Implementation: - Static private cache `State.#wrapperCache: WeakMap>>`. - Two-level WeakMap so the outer entry is GC'd when the bare is collected; WeakRef values let wrappers themselves be GC'd when nothing else references them, with subsequent calls reconstructing fresh wrappers. - Cache lookup happens BEFORE constructing a new wrapper; cache miss falls through to the existing construction path. Tooling: - Bumped `lib` to `ES2021` (was implied `ES2020`) for `WeakRef`. Target stays `ES2020` — emit is unchanged. Tests: - `A.wohs(t) === A.wohs(t)` (same instance, same id). - `A.wohs(t1).wohs(t2) === A.wohs(t2)` (composes with #176). - Different override → different instance (cache key sanity). - Different bare → different instance (cache key sanity). Full suite: 437/437 (was 433 + 4 new). Coverage above floors. closes #175 --- packages/machine/src/classes/State.spec.ts | 54 ++++++++++++++++++++++ packages/machine/src/classes/State.ts | 42 ++++++++++++++--- tsconfig.json | 1 + 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index d587c8d..7067cfe 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -224,6 +224,60 @@ describe('State.withOverriddenHaltState', () => { expect(W2.overriddenHaltState).toBe(W2direct.overriddenHaltState); }); + test('memoization: same (bare, override) pair returns the same wrapper instance (#175)', () => { + // `withOverriddenHaltState` interns its results keyed by (bare, override). + // Two calls with the same arguments — even with chained construction — + // return the literally same JS object. + const A = new State({[ifOtherSymbol]: {}}, 'A'); + const t = new State({[ifOtherSymbol]: {}}, 't'); + + const W1 = A.withOverriddenHaltState(t); + const W2 = A.withOverriddenHaltState(t); + + expect(W1).toBe(W2); + expect(W1.id).toBe(W2.id); + expect(W1.name).toBe('A(t)'); + }); + + test('memoization composes with chain collapse: A.wohs(t1).wohs(t2) === A.wohs(t2) (#175 + #176)', () => { + // After #176 collapses the chain to (A, t2), the cache hit on (A, t2) + // returns the same instance as the direct call. + const A = new State({[ifOtherSymbol]: {}}, 'A'); + const t1 = new State({[ifOtherSymbol]: {}}, 't1'); + const t2 = new State({[ifOtherSymbol]: {}}, 't2'); + + const Wdirect = A.withOverriddenHaltState(t2); + const Wchained = A.withOverriddenHaltState(t1).withOverriddenHaltState(t2); + + expect(Wchained).toBe(Wdirect); + }); + + test('memoization is per-(bare, override) pair: different override → different instance (#175)', () => { + const A = new State({[ifOtherSymbol]: {}}, 'A'); + const t1 = new State({[ifOtherSymbol]: {}}, 't1'); + const t2 = new State({[ifOtherSymbol]: {}}, 't2'); + + const W1 = A.withOverriddenHaltState(t1); + const W2 = A.withOverriddenHaltState(t2); + + expect(W1).not.toBe(W2); + expect(W1.name).toBe('A(t1)'); + expect(W2.name).toBe('A(t2)'); + }); + + test('memoization is per-(bare, override) pair: different bare → different instance (#175)', () => { + const A = new State({[ifOtherSymbol]: {}}, 'A'); + const B = new State({[ifOtherSymbol]: {}}, 'B'); + const t = new State({[ifOtherSymbol]: {}}, 't'); + + const W_A = A.withOverriddenHaltState(t); + const W_B = B.withOverriddenHaltState(t); + + expect(W_A).not.toBe(W_B); + expect(W_A.name).toBe('A(t)'); + expect(W_B.name).toBe('B(t)'); + }); + test('3-deep `.wohs()` chain collapses to outermost override (#176)', () => { const A = new State({[ifOtherSymbol]: {}}, 'A'); const t1 = new State({[ifOtherSymbol]: {}}, 't1'); diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 03776a7..a254aad 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -72,6 +72,13 @@ export class DebugConfig { } export default class State { + // Memoization cache for `withOverriddenHaltState`. Keyed by + // (bare, override) — same args return the same wrapper instance (#175). + // Two-level WeakMap so the outer entry is GC'd when the bare is collected; + // WeakRef values let wrappers themselves be GC'd when nothing else holds + // them, with cache misses simply reconstructing fresh wrappers. + static #wrapperCache = new WeakMap>>(); + readonly #id: number = id(this); // Not `readonly` because `withOverriddenHaltState` and `fromGraph` set the @@ -274,24 +281,47 @@ export default class State { } withOverriddenHaltState(overriddenHaltState: State) { - // Construct with no name, then overwrite #name directly — the composed - // name contains `(` and `)` by design, which the constructor's user-facing - // validation would reject. Internal composition bypasses validation via - // private-field access (legal within the same class). - const state = new State(); - // Unwrap `this` if it's itself a wrapper — the chain's inner overrides // are dead at runtime anyway (only the outermost `.wohs()`'s override is // pushed onto the halt-stack on entry; verified empirically). Composite // name reflects runtime behavior, not construction history. See #176. const bare = this.#bareState ?? this; + // Memoize by (bare, override) so identical args return the same instance + // (#175). The cache uses WeakMaps + WeakRefs so cached wrappers can be + // GC'd when nothing else holds them. Compounds with the chain-collapse + // above: `A.wohs(t1).wohs(t2)` keys as (A, t2) after the unwrap, hitting + // the same cache slot as a direct `A.wohs(t2)`. + let innerCache = State.#wrapperCache.get(bare); + + if (innerCache !== undefined) { + const ref = innerCache.get(overriddenHaltState); + + if (ref !== undefined) { + const cached = ref.deref(); + + if (cached !== undefined) { + return cached; + } + } + } else { + innerCache = new WeakMap(); + State.#wrapperCache.set(bare, innerCache); + } + + // Cache miss — construct with no name, then overwrite #name directly + // (composed names contain `(` and `)` which the constructor's user-facing + // validation would reject; private-field access bypasses that). + const state = new State(); + state.#name = `${bare.name}(${overriddenHaltState.name})`; state.#symbolToDataMap = bare.#symbolToDataMap; state.#overriddenHaltState = overriddenHaltState; state.#debugRef = bare.#debugRef; state.#bareState = bare; + innerCache.set(overriddenHaltState, new WeakRef(state)); + return state; } diff --git a/tsconfig.json b/tsconfig.json index 8df6553..8bfc4c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "target": "ES2020", "module": "ES2020", + "lib": ["ES2021"], "declaration": true, "strict": true, "moduleResolution": "node", From d7bc40beaa7e1634283638098fa870de2d186a51 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 07:28:17 +0300 Subject: [PATCH 018/118] chore(graph): add isWrapper/bareStateId/frameId to GraphNode (Phase 1 of #174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive data-model changes for the callable-subtree visualization refactor. No behavior change — new fields default to sensible values (isWrapper: false, bareStateId: null, frameId: null) everywhere they currently appear. Existing fields retained for backward-compat during cutover: - `isWrapped` remains as the alpha.1 collapsed-bare flag; will be dropped once toGraph/toMermaid stop reading it. New fields wire the un-collapsed model (next phases): - `isWrapper`: true for external `[[…]]` wrapper nodes (separate from their bare). - `bareStateId`: on wrappers, points to the bare's GraphNode id. - `frameId`: bare/body/halt-marker membership in a callable subtree. Touched sites: - Type definition in `graph.ts`. - 4 node-creation sites in `State.toGraph`. - `ensureNode` in `fromMermaid`. - ~30 hand-built GraphNode fixtures across the three spec files (bulk search-replace, mechanical). Tests: 437/437 still pass. Coverage above floors. Lint + typecheck clean. Refs #174. --- packages/machine/src/classes/State.spec.ts | 6 ++-- packages/machine/src/classes/State.ts | 12 ++++++++ packages/machine/src/utilities/graph.spec.ts | 18 ++++++------ packages/machine/src/utilities/graph.ts | 26 +++++++++++++++-- .../machine/src/utilities/graphFormats.ts | 9 ++++++ .../src/utilities/introspection.spec.ts | 28 +++++++++---------- 6 files changed, 71 insertions(+), 28 deletions(-) diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index 7067cfe..1282f7f 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -345,9 +345,9 @@ describe('State.fromGraph — cyclic override-halt chain', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 1, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 1, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }; diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index a254aad..795a776 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -435,6 +435,9 @@ export default class State { isHalt: true, isHaltMarker: false, isWrapped: false, + isWrapper: false, + bareStateId: null, + frameId: null, transitions: [], overriddenHaltStateId: null, }; @@ -470,6 +473,9 @@ export default class State { isHalt: true, isHaltMarker: true, isWrapped: false, + isWrapper: false, + bareStateId: null, + frameId: null, transitions: [], overriddenHaltStateId: null, }; @@ -482,6 +488,9 @@ export default class State { isHalt: false, isHaltMarker: false, isWrapped: true, + isWrapper: false, + bareStateId: null, + frameId: null, transitions: [], overriddenHaltStateId: overrideGraphId, }; @@ -553,6 +562,9 @@ export default class State { isHalt: false, isHaltMarker: false, isWrapped: false, + isWrapper: false, + bareStateId: null, + frameId: null, transitions: [], overriddenHaltStateId: null, }; diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index f2efd9c..ee29183 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -118,9 +118,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [ {pattern: "'0'", command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, {pattern: "'1'", command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, @@ -144,8 +144,8 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, - 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }); @@ -157,9 +157,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, - 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, - 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }); @@ -171,9 +171,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0'], [' ', 'a']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [{ pattern: "'0','a'", command: [{symbol: "'0'", movement: 'R'}, {symbol: "'a'", movement: 'L'}], diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index e7ae37e..7f9a7a8 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -20,9 +20,31 @@ export type GraphNode = { transitions: GraphTransition[]; overriddenHaltStateId: number | null; // `true` when this node represents the bare of a `withOverriddenHaltState`- - // wrapped state. Carries the `[[…]]` (subroutine) shape signal for `toMermaid` - // and tells `fromGraph` to reconstruct via `bare.withOverriddenHaltState(target)`. + // wrapped state under v7 alpha.1's collapsed emit. Carries the `[[…]]` + // (subroutine) shape signal for `toMermaid` and tells `fromGraph` to + // reconstruct via `bare.withOverriddenHaltState(target)`. The new + // un-collapsed model (#174) supersedes this with `isWrapper` + `bareStateId` + // below; `isWrapped` is retained transitionally for backward-compat during + // the cutover. isWrapped: boolean; + // New (#174): `true` for external `[[…]]` wrapper nodes that have a + // separate bare node in the graph. The wrapper sits outside any subtree + // subgraph; its `bareStateId` points to the corresponding bare GraphNode + // (which lives inside its home subtree). Multiple wrapper nodes can share + // the same `bareStateId` when they wrap the same bare with different + // override targets. Under the new model, `isWrapped: false` for these + // nodes — the wrapper-vs-bare distinction is carried by `isWrapper`. + isWrapper: boolean; + // New (#174): on wrapper nodes (`isWrapper: true`), the id of the bare + // GraphNode this wrapper wraps. `null` for non-wrapper nodes. + bareStateId: number | null; + // New (#174): the id of the wrapper whose callable subtree this node lives + // in. `null` for nodes outside any subtree (top-level states, real halt + // singleton, wrapper nodes themselves, the `idle` visualization sentinel). + // Set on bares, body states, and halt markers. Two bares may share a + // `frameId` when union-find merges their reachable sets (shared body state + // forces a union frame; see spec Example "Shared body state"). + frameId: number | null; // `true` for a synthesized halt marker graph node — one per wrapper context. // Real halt has `isHalt: true, isHaltMarker: false`; halt markers have both // `true`. `fromGraph` maps halt-marker nodes back to the singleton `haltState`. diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 79fdefe..e90e144 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -206,6 +206,9 @@ export function fromMermaid(text: string): Graph { isHalt?: boolean; isHaltMarker?: boolean; isWrapped?: boolean; + isWrapper?: boolean; + bareStateId?: number | null; + frameId?: number | null; } = {}, ): GraphNode => { if (!nodes[id]) { @@ -215,6 +218,9 @@ export function fromMermaid(text: string): Graph { isHalt: opts.isHalt ?? false, isHaltMarker: opts.isHaltMarker ?? false, isWrapped: opts.isWrapped ?? false, + isWrapper: opts.isWrapper ?? false, + bareStateId: opts.bareStateId ?? null, + frameId: opts.frameId ?? null, transitions: [], overriddenHaltStateId: null, }; @@ -223,6 +229,9 @@ export function fromMermaid(text: string): Graph { if (opts.isHalt !== undefined) nodes[id].isHalt = opts.isHalt; if (opts.isHaltMarker !== undefined) nodes[id].isHaltMarker = opts.isHaltMarker; if (opts.isWrapped !== undefined) nodes[id].isWrapped = opts.isWrapped; + if (opts.isWrapper !== undefined) nodes[id].isWrapper = opts.isWrapper; + if (opts.bareStateId !== undefined) nodes[id].bareStateId = opts.bareStateId; + if (opts.frameId !== undefined) nodes[id].frameId = opts.frameId; } return nodes[id]; diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index edb858d..5aea059 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -7,9 +7,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [ {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, @@ -31,9 +31,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [ {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, ], @@ -52,9 +52,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [{pattern: '0', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}], }, }, @@ -72,10 +72,10 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isWrapped: false, isHaltMarker: false}, - 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }; @@ -90,8 +90,8 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }; @@ -197,8 +197,8 @@ describe('summarizeGraph defensive guards', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isWrapped: false, isHaltMarker: false}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }; From 6d74f757031eb1774a0366b938033c564733b2d1 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 08:07:40 +0300 Subject: [PATCH 019/118] feat(graph): callable-subtree emit for withOverriddenHaltState (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withOverriddenHaltState` is now modeled as a function call in the graph emit: the wrapper is a `[[composite-name]]` call site OUTSIDE any subgraph, the bare's reachable subtree becomes a `subgraph w_${frameId}["callable subtree of NAME"] … end` block containing the bare + body states + a per-frame halt marker. Frames computed via union-find on bare-reachability: overlapping reach sets merge into a single union frame, so shared bares (library-binary-numbers/minusOne's invertNumber, used by two wrappers) appear ONCE with `& `-joined call arrows. Arrow conventions: - solid `-->` for regular transitions AND wrapper → override - bold `==> "call"` reserved for wrapper-to-bare (`&` ribbons collapse multi-wrapper-shares-bare) - dotted `-.->` reserved for frame-level dispatch: `w_N -. "return" .-> wrapper` (demand-emit: halt marker has incoming AND wrapper calls into the frame) `w_N -. "halt" .-> s0` (demand-emit: halt marker has incoming AND non-wrapper entry exists into the frame) - `-. onHalt .->` retired (replaced by solid `--> override`) GraphNode field changes: - drop `isWrapped` (the alpha.1 collapsed-bare flag) - add `isWrapper: boolean` for external `[[…]]` wrapper nodes - add `bareStateId: number | null` (on wrappers → bare's id) - add `frameId: number | null` (subtree membership; canonical = smallest bare-id in union component) Bytewise round-trip stability now holds for ALL wrapped states including shared-bare cases (no per-context duplication). Files touched: - packages/machine/src/utilities/graph.ts — type changes - packages/machine/src/classes/State.ts — toGraph rewrite (un-collapse + union-find + halt retargeting) and fromGraph rewrite (wrapper-aware reconstruction) - packages/machine/src/utilities/graphFormats.ts — toMermaid + fromMermaid rewrite for the new shape - Tests: removed `isWrapped: false` from ~26 hand-built fixtures; updated wrapper-context unbound-Reference test; updated cycle test to use isWrapper:true nodes; updated onHalt and scanThenErase README tests to new shape; updated 5 library-binary-numbers state-count expectations - Docs: engine README (regenerated example, rewrote reading guide, updated edge styles table, updated v7 trajectory); engine CLAUDE.md (v7 emit shape + edge styles paragraphs); root README (quick legend); spec doc (status IMPLEMENTED); regenerated library-binary-numbers/states.md Verification: - 437/437 tests pass - lint clean - coverage above all floors (97.49 stmts / 94.04 branches / 98.85 funcs / 97.83 lines) Closes #174 (carries on the release PR). --- CLAUDE.md | 4 +- README.md | 6 +- ...026-05-21-halt-frame-transitive-closure.md | 2 +- .../library-binary-numbers/src/graphs.spec.ts | 16 +- packages/library-binary-numbers/states.md | 174 +++---- packages/machine/README.md | 52 ++- packages/machine/src/classes/State.spec.ts | 33 +- packages/machine/src/classes/State.ts | 431 +++++++++++------- packages/machine/src/utilities/graph.spec.ts | 44 +- packages/machine/src/utilities/graph.ts | 52 +-- .../machine/src/utilities/graphFormats.ts | 417 ++++++++++------- .../src/utilities/introspection.spec.ts | 28 +- 12 files changed, 746 insertions(+), 513 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9ffd12..3484e13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,11 +61,11 @@ Key shapes that take reading multiple files to grasp: `packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same `Graph` through [Mermaid flowchart](https://mermaid.js.org/syntax/flowchart.html) syntax (renderer: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid)). The parser is strict to the dialect `toMermaid` emits — hand-edited Mermaid with different arrow styles or shapes won't round-trip. -**v7 emit shape** (PR #169, closes #138/#139): each `withOverriddenHaltState` wrapper collapses onto its bare's representation — `GraphNode.isWrapped: true`, no separate wrapper node in graph data. `toMermaid` wraps each `[[bare]]` (subroutine shape) + a synthesized `(((halt)))` marker (`GraphNode.isHaltMarker: true`, negative id; maps back to singleton `haltState` in `fromGraph`) inside a `subgraph w_${bareId}["halt frame"] … end` block. Dotted `onHalt` from `[[bare]]` crosses the subgraph border to the override target. An always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state (replaces v6's `((round))` shape convention). +**v7 callable-subtree emit shape** (#174, layered on the v7 alpha.1 framing from #138/#139): each `withOverriddenHaltState` wrapper produces TWO `GraphNode`s — a wrapper node (`isWrapper: true`, `[[composite-name]]` shape, no transitions, `bareStateId` points to the bare's GraphNode) and a bare node (regular `["name"]` shape inside its callable subtree subgraph, holds the bare's transitions). Frames are computed via union-find on bare-reachability: each unique bare's forward-reachable set defines its candidate frame; overlapping reach sets merge into a union frame. Frame id = smallest bare-id in the component. Halt marker per frame (id = `-frameId`, `isHaltMarker: true`, maps back to singleton `haltState` in `fromGraph`). Halt-bound transitions of in-frame states retarget to the frame's halt marker. Subgraph label: `"callable subtree of NAME"` (single bare) or `"callable scope: A ∪ B"` (union). The always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state. **No per-context duplication** — shared bares like `library-binary-numbers`'s `invertNumber` (in `minusOne`) appear once with `& `-joined call arrows from each wrapper. **Edge label vocabulary** — `[reads] → [writes]/[moves]`, each role wrapped in `[…]` (the tape-block indicator, one entry per tape; brackets always present, even single-tape). Read cells: literal-quoted (`'X'`), `*` (ASCII, ifOtherSymbol catch-all; literal `*` in the alphabet is quoted as `'*'`), `B` (the tape's blank). Write cells: literal-quoted, `K` (keep), `E` (erase = write blank). Move cells: `L` / `R` / `S`. **Alternation is always per-pattern bracket** (`['^']|['1']|['0']` for single-tape, `['0','a']|['1','b']` for multi-tape); the compact in-bracket form `['^'|'1']` is rejected by `fromMermaid` to prevent the cross-product reading trap in multi-tape (`['0'|'1','a'|'b']` would read as 4 combinations rather than 2 paired alternatives, so the format avoids the shape entirely). -**Edge arrow styles** — thick `==>` marks transitions whose target is a wrapped state AND ≠ source (= stack-push happens at runtime per `TuringMachine.run` line ~220); regular `-->` for the rest (including self-loops on wrappers, which don't push); dotted `-. onHalt .->` for the wrapper's catch-and-redirect; dotted `-. enter .->` from `idle` for execution-start. +**Edge arrow styles** — solid `-->` for regular state-to-state transitions AND for the wrapper's post-return `--> override` (just an ordinary transition under the call/return mental model); bold `==> "call"` is RESERVED for the wrapper-to-bare call arrow (source: wrapper, target: its bare; `&` ribbon collapses multiple wrappers sharing a bare); dotted `-.->` for frame-level dispatch — `w_N -. "return" .-> wrapper` (demand-emit: only when the frame's halt marker has incoming edges AND a wrapper calls into the frame), `w_N -. "halt" .-> s0` (demand-emit: only when the frame has a non-wrapper entry path), and `idle -. enter .-> sN` for execution-start. **The `-. onHalt .->` keyword from v7 alpha.1 is retired** — wrapper-to-override is now just a regular solid arrow, and the dotted `-.->` style is reserved for frame-level dispatch. **Round-trip** is **bytewise stable for simple wrappers** (regression test in `test/round-trip.spec.ts` — #139). The wrapper's composite name (e.g. `scanToX(eraseHere)`) does NOT appear as a graph node label; only the bare's name does, so `fromGraph` recomputes the composite fresh on reconstruction — no accumulation. **Shared-bare cases** (same `State` instance used as the bare of multiple wrappers, e.g. `library-binary-numbers`'s `minusOne` where `invertNumber` is both the outermost bare AND wrapper-W1's bare) use **per-context duplication** in `toGraph`: each occurrence emits a separate graph node with the wrapper's `#id`. Reconstruction produces behaviorally-equivalent State instances (not necessarily the same runtime `#id`), but bytewise stability isn't guaranteed for shared-bare since duplicate ordering depends on runtime wrapper-ids that don't survive rebuild. diff --git a/README.md b/README.md index 1ed7fe7..e202688 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,9 @@ Quick legend for the diagram above — full table at [packages/machine/README.md - **Read cells**: `'X'` (literal, single-quoted), `*` (`ifOtherSymbol` catch-all), `B` (the tape's blank). - **Write cells**: `'X'` (literal), `K` (keep), `E` (erase = write blank). - **Movement cells**: `L` / `R` / `S` (left / right / stay). -- **Node shapes**: `(((halt)))` = halt, `["square"]` = regular state, `[[double-walled]]` = wrapper-bare (subroutine shape), `idle([idle])` = pre-execution sentinel. -- **Edges**: `-->` regular, `==>` thick = transition into a wrapper (stack-push), `-. onHalt .->` = wrapper's catch-and-redirect, `-. enter .->` from `idle` = where execution starts. -- **Subgraph `w_N["halt frame"]`** wraps a `[[bare]]` + its halt marker — see [§Subroutine composition](packages/machine/README.md#subroutine-composition-with-withoverriddenhaltstate) in the engine README. +- **Node shapes**: `(((halt)))` = halt, `["square"]` = regular state or callable-subtree bare, `[[double-walled]]` = `withOverriddenHaltState` wrapper (call site), `idle([idle])` = pre-execution sentinel. +- **Edges**: `-->` regular transition (and wrapper → override target), `==> "call"` = bold call arrow from wrapper to bare (`&` ribbons collapse multi-wrapper-shares-bare), `-. "return" .->` / `-. "halt" .->` from subgraph = frame-level dispatch, `-. enter .->` from `idle` = where execution starts. +- **Subgraph `w_N["callable subtree of NAME"]`** wraps a bare + its body + a halt marker — see [§Subroutine composition](packages/machine/README.md#subroutine-composition-with-withoverriddenhaltstate) in the engine README. Trace on the input tape `abcba`: diff --git a/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md b/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md index 6a163c3..959024f 100644 --- a/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md +++ b/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md @@ -1,6 +1,6 @@ # Callable-subtree visualization for `withOverriddenHaltState` -**Status:** DRAFT 2026-05-21 — supersedes the earlier "transitive-closure halt-frame" framing of this doc. Tracks [#174](https://github.com/mellonis/turing-machine-js/issues/174). Lands on the `v7` integration branch before v7 stable cut. **Previous implementation (frameId-based exclusive-reachable algorithm in `State.toGraph`) is being reverted in favor of the design below.** +**Status:** IMPLEMENTED 2026-05-21 — design captured in PR #181 (Phase 1: data model) and the current PR (Phases 2–7: toGraph un-collapse + union-find + toMermaid + fromMermaid + fromGraph + tests + docs). Tracks [#174](https://github.com/mellonis/turing-machine-js/issues/174). Lands on the `v7` integration branch before v7 stable cut. **Relation to [#173](https://github.com/mellonis/turing-machine-js/issues/173):** #173 closed (2026-05-21) as the literal complaint (orphan `c_N` + onHalt anchor) doesn't apply to the new design — there's no `c_N` per-wrapper concept anymore. The new design has per-bare `c_A` halt sinks and per-wrapper call/return/onHalt edges, which together visualize runtime semantics directly. diff --git a/packages/library-binary-numbers/src/graphs.spec.ts b/packages/library-binary-numbers/src/graphs.spec.ts index c10938f..b1ccb6c 100644 --- a/packages/library-binary-numbers/src/graphs.spec.ts +++ b/packages/library-binary-numbers/src/graphs.spec.ts @@ -6,17 +6,23 @@ import binaryNumbers from './index'; // which exclude the `isHaltMarker` visualization sentinels v7 synthesizes // inside each `halt frame` subgraph. The states.md per-algorithm header uses // the same definition, so all three sources agree by construction. +// v7 callable-subtree counts (#174). Under the new model, each +// `withOverriddenHaltState` emits a SEPARATE wrapper node (in addition to the +// bare's node) — so wrapper-bearing algorithms gain 1 node per wrapper, minus +// any savings from de-duplicating shared bares (no per-context duplication +// anymore). `goToNumber`, `goToNextNumber`, `goToPreviousNumber`, +// `goToNumbersStart`, `plusOne` have no wrappers and are unchanged. const expectedNodeCount: Record = { goToNumber: 2, goToNextNumber: 3, goToPreviousNumber: 3, goToNumbersStart: 2, - deleteNumber: 4, - invertNumber: 4, - normalizeNumber: 6, + deleteNumber: 5, // alpha.1: 4 (one wohs); +1 wrapper node + invertNumber: 5, // alpha.1: 4 (one wohs); +1 wrapper node + normalizeNumber: 7, // alpha.1: 6 (one wohs); +1 wrapper node plusOne: 5, - minusOne: 15, - minusOneFast: 8, + minusOne: 18, // alpha.1: 15; +3 for wrapper-vs-bare separation minus shared-bare dedup + minusOneFast: 10, // alpha.1: 8; +2 wrapper nodes }; const stateNames = Object.keys(expectedNodeCount) as Array; diff --git a/packages/library-binary-numbers/states.md b/packages/library-binary-numbers/states.md index 31477a0..4d61d88 100644 --- a/packages/library-binary-numbers/states.md +++ b/packages/library-binary-numbers/states.md @@ -51,7 +51,7 @@ flowchart TD ## deleteNumber -*4 states; 6 transitions; 1 wrapper (max nesting depth 1); has cycles* +*5 states; 6 transitions; 1 wrapper (max nesting depth 1); has cycles* ```mermaid flowchart TD @@ -59,18 +59,21 @@ flowchart TD s0(((halt))) s6["deleteNumberInternal"] s8["deleteNumber"] + s7[["goToNumberStart(deleteNumberInternal)"]] idle([idle]) - subgraph w_7["halt frame"] - s7[["goToNumberStart"]] - c7(((halt))) + subgraph w_5["callable subtree of goToNumberStart"] + s5["goToNumberStart"] + c5(((halt))) end idle -. enter .-> s8 + s7 == "call" ==> s5 + w_5 -. "return" .-> s7 + s7 --> s6 + s5 -- "['^'] → [K]/[S]" --> c5 + s5 -- "[*] → [K]/[L]" --> s5 s6 -- "['$'] → [E]/[S]" --> s0 s6 -- "[*] → [E]/[R]" --> s6 - s7 -- "['^'] → [K]/[S]" --> c7 - s7 -- "[*] → [K]/[L]" --> s7 - s7 -. onHalt .-> s6 - s8 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s7 + s8 -- "['^']|['1']|['0']|['$'] → [K]/[S]" --> s7 s8 -- "[*] → [K]/[S]" --> s0 ``` @@ -91,7 +94,7 @@ flowchart TD ## invertNumber -*4 states; 8 transitions; 1 wrapper (max nesting depth 1); has cycles* +*5 states; 8 transitions; 1 wrapper (max nesting depth 1); has cycles* ```mermaid flowchart TD @@ -99,26 +102,29 @@ flowchart TD s0(((halt))) s9["invertNumberGoToNumberWithInversion"] s11["invertNumber"] + s10[["goToNumberStart(invertNumberGoToNumberWithInversion)"]] idle([idle]) - subgraph w_10["halt frame"] - s10[["goToNumberStart"]] - c10(((halt))) + subgraph w_5["callable subtree of goToNumberStart"] + s5["goToNumberStart"] + c5(((halt))) end idle -. enter .-> s11 + s10 == "call" ==> s5 + w_5 -. "return" .-> s10 + s10 --> s9 + s5 -- "['^'] → [K]/[S]" --> c5 + s5 -- "[*] → [K]/[L]" --> s5 s9 -- "['^'] → [K]/[R]" --> s9 s9 -- "['1'] → ['0']/[R]" --> s9 s9 -- "['0'] → ['1']/[R]" --> s9 s9 -- "['$'] → [K]/[S]" --> s0 - s10 -- "['^'] → [K]/[S]" --> c10 - s10 -- "[*] → [K]/[L]" --> s10 - s10 -. onHalt .-> s9 - s11 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 + s11 -- "['^']|['1']|['0']|['$'] → [K]/[S]" --> s10 s11 -- "[*] → [K]/[S]" --> s0 ``` ## normalizeNumber -*6 states; 9 transitions; 1 wrapper (max nesting depth 1); has cycles* +*7 states; 9 transitions; 1 wrapper (max nesting depth 1); has cycles* ```mermaid flowchart TD @@ -128,21 +134,24 @@ flowchart TD s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] s15["normalizeNumber"] + s14[["goToNumberStart(normalizeNumberMoveNumberStart)"]] idle([idle]) - subgraph w_14["halt frame"] - s14[["goToNumberStart"]] - c14(((halt))) + subgraph w_5["callable subtree of goToNumberStart"] + s5["goToNumberStart"] + c5(((halt))) end idle -. enter .-> s15 + s14 == "call" ==> s5 + w_5 -. "return" .-> s14 + s14 --> s13 s1 -- "['$'] → [K]/[S]" --> s0 s1 -- "[*] → [K]/[R]" --> s1 + s5 -- "['^'] → [K]/[S]" --> c5 + s5 -- "[*] → [K]/[L]" --> s5 s12 -- "[B] → ['^']/[S]" --> s1 s13 -- "['^']|['0'] → [E]/[R]" --> s13 s13 -- "['1']|['$'] → [K]/[L]" --> s12 - s14 -- "['^'] → [K]/[S]" --> c14 - s14 -- "[*] → [K]/[L]" --> s14 - s14 -. onHalt .-> s13 - s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 + s15 -- "['^']|['1']|['0']|['$'] → [K]/[S]" --> s14 s15 -- "[*] → [K]/[S]" --> s0 ``` @@ -174,7 +183,7 @@ flowchart TD ## minusOne -*15 states; 32 transitions; 5 wrappers (max nesting depth 3); has cycles* +*18 states; 28 transitions; 5 wrappers (max nesting depth 3); has cycles* ```mermaid flowchart TD @@ -185,74 +194,73 @@ flowchart TD s12["normalizeNumberPutNewStartSymbol"] s13["normalizeNumberMoveNumberStart"] s15["normalizeNumber"] - s16["plusOneFillZeros"] - s17["plusOneAddNumberStart"] - s18["plusOneCaryOne"] s23["minusOne"] + s10[["goToNumberStart(invertNumberGoToNumberWithInversion)"]] + s14[["goToNumberStart(normalizeNumberMoveNumberStart)"]] + s20[["invertNumber(normalizeNumber)"]] + s21[["plusOne(invertNumber(normalizeNumber))"]] + s22[["invertNumber(plusOne(invertNumber(normalizeNumber)))"]] idle([idle]) - subgraph w_10["halt frame"] - s10[["goToNumberStart"]] - c10(((halt))) - end - subgraph w_14["halt frame"] - s14[["goToNumberStart"]] - c14(((halt))) + subgraph w_5["callable subtree of goToNumberStart"] + s5["goToNumberStart"] + c5(((halt))) end - subgraph w_20["halt frame"] - s20[["invertNumber"]] - c20(((halt))) + subgraph w_11["callable subtree of invertNumber"] + s11["invertNumber"] + c11(((halt))) end - subgraph w_21["halt frame"] - s21[["plusOne"]] - c21(((halt))) - end - subgraph w_22["halt frame"] - s22[["invertNumber"]] - c22(((halt))) + subgraph w_19["callable subtree of plusOne"] + s16["plusOneFillZeros"] + s17["plusOneAddNumberStart"] + s18["plusOneCaryOne"] + s19["plusOne"] + c19(((halt))) end idle -. enter .-> s23 + s10 & s14 == "call" ==> s5 + s20 & s22 == "call" ==> s11 + s21 == "call" ==> s19 + w_5 -. "return" .-> s10 & s14 + w_11 -. "return" .-> s20 & s22 + w_19 -. "return" .-> s21 + s10 --> s9 + s14 --> s13 + s20 --> s15 + s21 --> s20 + s22 --> s21 s1 -- "['$'] → [K]/[S]" --> s0 s1 -- "[*] → [K]/[R]" --> s1 + s5 -- "['^'] → [K]/[S]" --> c5 + s5 -- "[*] → [K]/[L]" --> s5 s9 -- "['^'] → [K]/[R]" --> s9 s9 -- "['1'] → ['0']/[R]" --> s9 s9 -- "['0'] → ['1']/[R]" --> s9 s9 -- "['$'] → [K]/[S]" --> s0 - s10 -- "['^'] → [K]/[S]" --> c10 - s10 -- "[*] → [K]/[L]" --> s10 - s10 -. onHalt .-> s9 + s11 -- "['^']|['1']|['0']|['$'] → [K]/[S]" --> s10 + s11 -- "[*] → [K]/[S]" --> c11 s12 -- "[B] → ['^']/[S]" --> s1 s13 -- "['^']|['0'] → [E]/[R]" --> s13 s13 -- "['1']|['$'] → [K]/[L]" --> s12 - s14 -- "['^'] → [K]/[S]" --> c14 - s14 -- "[*] → [K]/[L]" --> s14 - s14 -. onHalt .-> s13 - s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 + s15 -- "['^']|['1']|['0']|['$'] → [K]/[S]" --> s14 s15 -- "[*] → [K]/[S]" --> s0 s16 -- "['1'] → ['0']/[R]" --> s16 - s16 -- "['$'] → [K]/[S]" --> s0 + s16 -- "['$'] → [K]/[S]" --> c19 s17 -- "[B] → ['^']/[R]" --> s17 s17 -- "['1'] → [K]/[R]" --> s16 s18 -- "['0'] → ['1']/[R]" --> s16 s18 -- "['1'] → [K]/[L]" --> s18 s18 -- "['^'] → ['1']/[L]" --> s17 - s20 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 - s20 -- "[*] → [K]/[S]" --> c20 - s20 -. onHalt .-> s15 - s21 -- "['^']|['1']|['0'] → [K]/[R]" --> s21 - s21 -- "['$'] → [K]/[L]" --> s18 - s21 -- "[*] → [K]/[S]" --> c21 - s21 -. onHalt .-> s20 - s22 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s10 - s22 -- "[*] → [K]/[S]" --> c22 - s22 -. onHalt .-> s21 + s19 -- "['^']|['1']|['0'] → [K]/[R]" --> s19 + s19 -- "['$'] → [K]/[L]" --> s18 + s19 -- "[*] → [K]/[S]" --> c19 s23 -- "['^']|['1']|['0'] → [K]/[R]" --> s23 - s23 == "['$'] → [K]/[S]" ==> s22 + s23 -- "['$'] → [K]/[S]" --> s22 s23 -- "[*] → [K]/[S]" --> s0 ``` ## minusOneFast -*8 states; 15 transitions; 2 wrappers (max nesting depth 1); has cycles* +*10 states; 15 transitions; 2 wrappers (max nesting depth 1); has cycles* ```mermaid flowchart TD @@ -263,31 +271,37 @@ flowchart TD s13["normalizeNumberMoveNumberStart"] s15["normalizeNumber"] s26["minusOneFast"] + s14[["goToNumberStart(normalizeNumberMoveNumberStart)"]] + s25[["minusOneFastBorrow(normalizeNumber)"]] idle([idle]) - subgraph w_14["halt frame"] - s14[["goToNumberStart"]] - c14(((halt))) + subgraph w_5["callable subtree of goToNumberStart"] + s5["goToNumberStart"] + c5(((halt))) end - subgraph w_25["halt frame"] - s25[["minusOneFastBorrow"]] - c25(((halt))) + subgraph w_24["callable subtree of minusOneFastBorrow"] + s24["minusOneFastBorrow"] + c24(((halt))) end idle -. enter .-> s26 + s14 == "call" ==> s5 + s25 == "call" ==> s24 + w_5 -. "return" .-> s14 + w_24 -. "return" .-> s25 + s14 --> s13 + s25 --> s15 s1 -- "['$'] → [K]/[S]" --> s0 s1 -- "[*] → [K]/[R]" --> s1 + s5 -- "['^'] → [K]/[S]" --> c5 + s5 -- "[*] → [K]/[L]" --> s5 s12 -- "[B] → ['^']/[S]" --> s1 s13 -- "['^']|['0'] → [E]/[R]" --> s13 s13 -- "['1']|['$'] → [K]/[L]" --> s12 - s14 -- "['^'] → [K]/[S]" --> c14 - s14 -- "[*] → [K]/[L]" --> s14 - s14 -. onHalt .-> s13 - s15 == "['^']|['1']|['0']|['$'] → [K]/[S]" ==> s14 + s15 -- "['^']|['1']|['0']|['$'] → [K]/[S]" --> s14 s15 -- "[*] → [K]/[S]" --> s0 - s25 -- "['1'] → ['0']/[S]" --> c25 - s25 -- "['0'] → ['1']/[L]" --> s25 - s25 -- "['^'] → [K]/[S]" --> c25 - s25 -. onHalt .-> s15 + s24 -- "['1'] → ['0']/[S]" --> c24 + s24 -- "['0'] → ['1']/[L]" --> s24 + s24 -- "['^'] → [K]/[S]" --> c24 s26 -- "['^']|['1']|['0'] → [K]/[R]" --> s26 - s26 == "['$'] → [K]/[L]" ==> s25 + s26 -- "['$'] → [K]/[L]" --> s25 s26 -- "[*] → [K]/[S]" --> s0 ``` diff --git a/packages/machine/README.md b/packages/machine/README.md index 9c9c9e5..e6315b9 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -268,7 +268,7 @@ flowchart TD > 💡 **Mermaid renders at most one edge per source/target pair.** If a state has two distinct transitions back to itself (or two parallel transitions to the same target), only one shows in the diagram. The string output is correct — this is a viewer-side limitation. For graphs with multiple parallel edges, paste the `toMermaid` output into [mermaid.live](https://mermaid.live) and switch to the `stateDiagram-v2` renderer, or post-process the output to your preferred format. -`fromMermaid` parses the same format back into a `Graph`. The round-trip is **behaviorally** lossless — the rebuilt graph runs to the same outputs on the same inputs (tested in `test/round-trip.spec.ts` for the binary-numbers libraries). It is *not* bytewise lossless: state IDs auto-reassign on each rebuild, and for `withOverriddenHaltState` wrappers the composite name gains an extra `(${override.name})` wrapping on each pass (e.g., `scanToX(eraseHere)` becomes `scanToX(eraseHere)(eraseHere)` on a second round-trip — tracked in [#138](https://github.com/mellonis/turing-machine-js/issues/138)). +`fromMermaid` parses the same format back into a `Graph`. The round-trip is **behaviorally** lossless — the rebuilt graph runs to the same outputs on the same inputs (tested in `test/round-trip.spec.ts` for the binary-numbers libraries). Under the v7 callable-subtree emit (#174), bytewise stability holds across rebuilds even for shared-bare cases (modulo state-id renumbering, which the test normalizes). The composite name is not stored as any graph node's label — `fromGraph` recomputes it fresh on reconstruction — so the accumulation problem from #138 cannot reoccur. ### Reference @@ -398,29 +398,32 @@ flowchart TD %% alphabets: [[" ","a","b","X"]] s0(((halt))) s2["eraseHere"] + s3[["scanToX(eraseHere)"]] idle([idle]) - subgraph w_3["halt frame"] - s3[["scanToX"]] - c3(((halt))) + subgraph w_1["callable subtree of scanToX"] + s1["scanToX"] + c1(((halt))) end idle -. enter .-> s3 + s3 == "call" ==> s1 + w_1 -. "return" .-> s3 + s3 --> s2 + s1 -- "['X'] → [K]/[S]" --> c1 + s1 -- "[*] → [K]/[R]" --> s1 s2 -- "[*] → [E]/[S]" --> s0 - s3 -- "['X'] → [K]/[S]" --> c3 - s3 -- "[*] → [K]/[R]" --> s3 - s3 -. onHalt .-> s2 ``` -**Reading guide** — the v7 emit (introduced in [#138](https://github.com/mellonis/turing-machine-js/issues/138)) makes the wrapper's runtime stack-frame semantics visible: +**Reading guide** — the v7 callable-subtree emit (introduced in [#174](https://github.com/mellonis/turing-machine-js/issues/174)) models `withOverriddenHaltState` as a function call: the wrapper is the call site, the bare's subtree is the callable body. -1. **The subgraph rectangle labeled `"halt frame"`** is the wrapper's runtime scope — while execution is "inside" this rectangle, the override target (`eraseHere`) sits on the runtime stack waiting to catch a halt. Visual-only; it does not mutate any edges. -2. **`[[scanToX]]` (Mermaid subroutine / double-walled-rectangle shape)** is the wrapper node. It's both the runtime entry point (execution starts here when entering the wrapper) AND the source of the dotted `onHalt` redirect. The wrapper's composite name (`scanToX(eraseHere)`) is computed at runtime via `state.name` but does not appear as a graph node label — only the bare's name is in the graph. -3. **The halt-marker `(((halt)))` inside the subgraph** (`c3` here) is where the bare's halt-bound transitions land *inside* the wrapper's scope. `haltState` is a runtime singleton; the halt marker is a teaching aid showing "halt is caught here, not at the real terminus." Solid arrows from the bare to the halt marker all stay inside the rectangle. -4. **The dotted `onHalt` arrow from `[[scanToX]]` to `eraseHere`** is the wrapper's catch-and-redirect. Originates from the wrapper-node since the wrapper *is* the catcher. Solid arrows from `[[scanToX]]` to other states can also cross the subgraph border — those are just regular runtime transitions whose target happens to be drawn outside this rectangle (only the dotted `onHalt` carries wrapper-machinery meaning). In larger compositions (`library-binary-numbers`'s `minusOne`), solid transitions whose target is *itself* a wrapped state render as a **thick `==>` arrow** instead of `-->` — that's the visual signal for "this transition enters a halt frame, pushing the override onto the runtime stack." Stack-growth structure is then scannable from the diagram: count thick arrows along an execution path to see how deep the stack gets. -5. **Real `(((halt)))` outside any subgraph** (`s0`) is the actual run terminus. Reached only by states that are *not* inside a wrapper's halt-frame — here, by `eraseHere` after it erases the cell. +1. **`[[scanToX(eraseHere)]]` (Mermaid subroutine / double-walled-rectangle shape)** is the wrapper node, drawn OUTSIDE any subgraph. It's the runtime entry point — `idle -. enter .->` arrives here — and shows the composite name (`bare(override)`). Wrappers have no transitions of their own; they delegate to the bare via the `call` arrow. +2. **`subgraph w_1["callable subtree of scanToX"]`** is the bare's callable subtree — the scope of code that runs when the wrapper is "called." It contains the bare `s1["scanToX"]`, any body states reachable from the bare, and a local halt marker `c1(((halt)))` where the bare's halt-bound transitions land. +3. **The bold `==> call`** from wrapper to bare is the call arrow — visual signature of "wrapper invokes this callable subtree, pushing its override onto the runtime stack." Bold arrows are reserved for wrapper-to-bare calls; counting them in a diagram counts the wrappers in play. +4. **The dotted `-. return .->`** from the subtree back to the wrapper is the return arrow — fires when the bare halts (lands on `c1`) and the stack pops. The wrapper's solid `--> s2` (to `eraseHere`) is the post-return continuation; ordinary transition under the function-call mental model. +5. **Real `(((halt)))` outside any subgraph** (`s0`) is the actual run terminus. Reached only by states OUTSIDE any callable subtree — here, by `eraseHere` after it erases the cell. -**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter the `halt frame` at `[[scanToX]]` (with `eraseHere` on the stack); `[*] → [K]/[R]` self-loops until the head sees `X`; the `['X'] → [K]/[S]` solid edge would normally halt — it lands on the halt marker `c3`, the wrapper's catch-and-redirect kicks in, pop the stack → `eraseHere`; `eraseHere` runs `[*] → [E]/[S]` and halts at real `s0`. Run terminates. +**Reading runtime sequence on tape `['a','b','X','b','a']`:** enter at wrapper `[[scanToX(eraseHere)]]` (with `eraseHere` queued as the override); `call` into the subtree of `scanToX`; `[*] → [K]/[R]` self-loops on `s1` until the head sees `X`; the `['X'] → [K]/[S]` edge lands on `c1`; `return` to the wrapper; solid `--> s2` to `eraseHere`; `eraseHere` runs `[*] → [E]/[S]` and halts at real `s0`. Run terminates. -> 💡 **Round-trip caveat.** `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for simple wrappers like this one ([#139](https://github.com/mellonis/turing-machine-js/issues/139) regression). For shared-bare cases (same `State` instance used as the bare in multiple wrappers — e.g., `library-binary-numbers`'s `minusOne`), per-context duplication produces wrapper-id-dependent ordering that doesn't byte-match across rebuilds — equivalent runtime behavior, different emit-line order. +> 💡 **Round-trip stability.** `toMermaid → fromMermaid → toGraph → toMermaid` is bytewise stable for wrapped states ([#139](https://github.com/mellonis/turing-machine-js/issues/139) regression). The callable-subtree emit (#174) eliminates per-context duplication: shared bares like `library-binary-numbers`'s `invertNumber` (used by two wrappers in `minusOne`) render as a single subtree with two `& `-joined call arrows — so even shared-bare cases now produce stable, dedup'd round-trips. Wrappers nest: `inner.withOverriddenHaltState(middle).withOverriddenHaltState(outer)` chains halt-redirects through `middle → outer → halt`. `library-binary-numbers/src/index.ts`'s `minusOne` (the `~(~x + 1)` composition) uses a 4-deep nest of wrappers. @@ -575,8 +578,8 @@ The full reference for reading `toMermaid` output — shapes, edge styles, and t | Shape | Meaning | |---|---| | `s0(((halt)))` | the halt state | -| `sN["name"]` | a regular state | -| `sN[["name"]]` | a `withOverriddenHaltState` wrapper-bare (subroutine shape) — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) | +| `sN["name"]` | a regular state (or a bare, when inside a subgraph) | +| `sN[["composite-name"]]` | a `withOverriddenHaltState` wrapper (call site, outside any subgraph) — see [§Subroutine composition](#subroutine-composition-with-withoverriddenhaltstate) | | `cN(((halt)))` inside a subgraph | halt marker (visualization aid; maps back to the singleton `haltState` at runtime) | | `idle([idle])` | pre-execution sentinel (not a real state) | @@ -584,14 +587,17 @@ The full reference for reading `toMermaid` output — shapes, edge styles, and t | Style | Where | Meaning | |---|---|---| -| `-->` regular solid | between states | plain transition | -| `==>` thick solid | between states | transition INTO a wrapped state — stack-push happens at runtime | -| `-. onHalt .->` dotted | from `[[bare]]` to override | wrapper's catch-and-redirect | -| `-. enter .->` dotted | from `idle` to initial state | execution-start marker | +| `-->` regular solid | between states; wrapper → override | plain transition / wrapper's post-return continuation | +| `==> "call"` thick solid | wrapper → bare | the wrapper's call into its callable subtree; reserved for wrapper-to-bare | +| `w_N -. "return" .->` dotted | subtree → wrapper | the subtree's halt-marker has incoming → control returns to the calling wrapper | +| `w_N -. "halt" .->` dotted | subtree → `s0` | the subtree has a non-wrapper entry path → halt-marker can fire with empty stack (real halt) | +| `idle -. enter .->` dotted | from `idle` to initial state | execution-start marker | + +The `&` ribbon syntax (`s_W1 & s_W2 == "call" ==> s_A`) collapses multiple wrappers that share a bare into one arrow. Bold `==>` is reserved exclusively for the wrapper-to-bare `call` arrow. ### Groupings -`subgraph w_N["halt frame"] … end` wraps a `[[bare]]` + its halt marker — visual grouping of the wrapper's runtime halt-handling scope. +`subgraph w_N["callable subtree of NAME"] … end` wraps a bare + its body + a halt marker — the callable scope of code that runs when a wrapper "calls" the bare. Multi-bare frames (union-find merged from shared body states) use the label `"callable scope: A ∪ B"`. ### Edge label format @@ -651,7 +657,7 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v7** *(alpha 1, 2026-05-21)* — Composition-representation overhaul. **First pre-release on the `next` dist-tag:** `npm install @turing-machine-js/machine@next` (or pin `@7.0.0-alpha.1`). Stable v7.0.0 still pending [#102](https://github.com/mellonis/turing-machine-js/issues/102) (debugger step-in/over/out primitives). Landed in alpha.1: - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly. - - **`toMermaid` wrapped-state emit overhaul** ([#138](https://github.com/mellonis/turing-machine-js/issues/138) / [#139](https://github.com/mellonis/turing-machine-js/issues/139)). The wrapper-and-its-bare pair collapses into a single graph node (`isWrapped: true`); the wrapper's composite name no longer appears as a node label (only the bare's name does). Each wrapper gets a Mermaid `subgraph w_${bareId}["halt frame"] … end` block containing the `[[bare]]` (subroutine shape) plus a halt-marker `(((halt)))` (visualization aid showing where halt-bound transitions land inside the scope). The dotted `onHalt` edge originates from the `[[bare]]` and crosses the subgraph border to the override target — exactly one per wrapper. `Graph` data shape gains `isWrapped` and `isHaltMarker` flags on `GraphNode` and a stable `id` on `GraphTransition` (deterministic per-edge identifier — supports downstream tooling like the `machines-demo` interactive viewer at [machines-demo#10](https://github.com/mellonis/machines-demo/issues/10)). Halt-marker graph nodes use negative ids and round-trip back to the singleton `haltState` via `fromGraph`. Bytewise round-trip stability falls out for simple wrappers (no composite name in the graph means `fromGraph(toGraph(state))` recomputes names fresh — no accumulation). Shared-bare cases (e.g. `minusOne`'s repeated `invertNumber`) use per-context duplication in the graph emit. + - **`toMermaid` callable-subtree emit** ([#174](https://github.com/mellonis/turing-machine-js/issues/174), supersedes the alpha.1 collapsed-bare shape from #138/#139). `withOverriddenHaltState` is modeled as a function call: the wrapper is a `[[composite-name]]` call site OUTSIDE any subgraph, the bare's reachable subtree becomes a `subgraph w_${frameId}["callable subtree of NAME"] … end` block containing the bare + body states + a per-frame halt marker `c${frameId}(((halt)))`. Frames are computed via union-find on bare-reachability — overlapping reach sets merge into a single union frame, so shared bares (`library-binary-numbers/minusOne`'s `invertNumber`) appear ONCE with `& `-joined call arrows from each wrapper. Bold `==> "call"` arrows are reserved for the wrapper-to-bare call; dotted `-.->` is reserved for frame-level dispatch (`return` / `halt` / `enter`). The retired `-. onHalt .->` keyword is replaced by a solid `--> override` arrow (just an ordinary transition under the call/return mental model). `GraphNode` gains `isWrapper`, `bareStateId`, `frameId` fields (and drops `isWrapped`). Bytewise round-trip stability now holds for all wrapped states including shared-bare cases (no per-context duplication). For the full release history, see the [GitHub releases page](https://github.com/mellonis/turing-machine-js/releases). diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index 1282f7f..a912171 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -308,9 +308,9 @@ describe('State.toGraph — unbound Reference', () => { }); test('skips a transition whose nextState is an unbound Reference (wrapper context)', () => { - // toGraph has a separate try/catch in its wrapper-context branch — when the - // bare being walked is inside a `withOverriddenHaltState` wrapper. Same - // skip-and-continue semantic. + // Under the v7 callable-subtree model, the bare lives as a separate node + // from the wrapper. Same skip-and-continue semantic — the bare's unbound- + // Reference transition is dropped while building its node. const unboundRef = new Reference(); const bare = new State({ [symbol(['0'])]: {nextState: unboundRef}, @@ -324,30 +324,29 @@ describe('State.toGraph — unbound Reference', () => { const graph = State.toGraph(wrapped, tapeBlock); - // The collapsed wrapper node retains only the haltState-bound transition; - // the unbound-Ref one is dropped. - const collapsedNode = graph.nodes[wrapped.id]; - - expect(collapsedNode.transitions).toHaveLength(1); + // The bare's node retains only the haltState-bound transition; the + // wrapper itself has no transitions of its own under the new model. + expect(graph.nodes[bare.id].transitions).toHaveLength(1); + expect(graph.nodes[wrapped.id].isWrapper).toBe(true); + expect(graph.nodes[wrapped.id].transitions).toHaveLength(0); }); }); describe('State.fromGraph — cyclic override-halt chain', () => { test('throws when the override-halt graph has a cycle', () => { - // Graphs constructed by State.toGraph always have acyclic override chains - // (cycles throw at State construction). To exercise the defensive cycle - // detection in fromGraph, hand-build a Graph with overriddenHaltStateId - // pointing in a loop. - // Nodes need at least one transition each — State construction at pass 2 - // rejects empty stateDefinitions before pass 3's cycle check would run. + // Under the v7 callable-subtree model, override-halt chains live on + // wrapper nodes. Hand-build two wrappers (sharing a single bare) whose + // `overriddenHaltStateId`s point at each other to exercise the defensive + // cycle guard in `fromGraph`'s `getFinal`. const dummyTransition = {pattern: '*', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}; const graph = { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: 1, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'wA', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: true, bareStateId: 3, frameId: null}, + 2: {id: 2, name: 'wB', isHalt: false, transitions: [], overriddenHaltStateId: 1, isHaltMarker: false, isWrapper: true, bareStateId: 3, frameId: null}, + 3: {id: 3, name: 'shared', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }; diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 795a776..10491b5 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -377,64 +377,53 @@ export default class State { }; } - // Walks the State graph and emits a `Graph` data structure. v7 emit shape: - // wrapper-States (those with `#overriddenHaltState !== null`) are collapsed - // onto their bare's representation in the graph, with the wrapper's own `#id` - // used as the graph node id, `isWrapped: true`, and `overriddenHaltStateId` - // set to the override's collapsed id. A per-wrapper "halt marker" graph node - // (id = negative-of-the-wrapper-id, `isHalt: true, isHaltMarker: true`) is - // synthesized; the bare's halt-bound transitions are rewritten to target the - // halt marker instead of the real one. + // Walks the State graph and emits a `Graph` data structure. v7 callable- + // subtree emit shape (#174): // - // Halt-marker node ids use the negation of the wrapper's id so they sit in a - // disjoint integer range from real ids (which are always non-negative). Real - // halt is always id 0. + // Each `withOverriddenHaltState` wrapper produces TWO graph nodes: + // - A wrapper node (`isWrapper: true`, `[[composite-name]]` shape) — the + // call site. No transitions of its own. `bareStateId` points to the + // bare's GraphNode; `overriddenHaltStateId` points to the override + // target's GraphNode. + // - A bare node (`isWrapper: false`, regular shape) — the callable body. + // Has the bare's transitions. Shared across all wrappers that wrap + // this bare (no per-context duplication). + // + // Frames are computed via union-find on bare reachability: two bares whose + // forward-reachable sets overlap merge into one frame. Each frame contains + // its bares + body states + a single halt marker (id = `-frameId`). The + // canonical `frameId` is the smallest bare-id in the component. + // + // Halt-bound transitions of any in-frame state are retargeted to the + // frame's halt marker. The frame's `subtree -. return .-> wrapper` and + // `subtree -. halt .-> s0` arrows are demand-emitted by `toMermaid` from + // the frame structure; they're not stored as graph edges. static toGraph(initialState: State, tapeBlock: TapeBlock): Graph { const nodes: Record = {}; const alphabets = tapeBlock.alphabets.map((alphabet) => alphabet.symbols); - // Map from a wrapper-State to the "collapsed" graph node id used to refer - // to it in transitions. Same as the wrapper's `#id`, recorded for clarity - // when rewriting transition targets. - const wrapperGraphId = (s: State): number => s.#id; - const haltMarkerIdFor = (wrapper: State): number => -wrapper.#id; - - // The `initialId` is the user-passed start. If it's a wrapper, the - // collapsed graph node uses its `#id`; otherwise its own `#id`. - const initialId = initialState.#id; - - type QueueItem = { - // The State instance to process at this slot. - state: State; - // When non-null, the State is being processed AS the bare of this wrapper. - // The collapsed graph node uses `wrapperGraphId(wrapperContext)`, - // halt-bound transitions retarget to `haltMarkerIdFor(wrapperContext)`, - // self-loop transitions to the bare retarget to the wrapper-id. - wrapperContext: State | null; - }; + // Pass 1: BFS-discover all reachable States; emit one GraphNode per State + // (wrapper or bare/regular). Wrappers and bares are separate nodes. + const visited = new Set(); + const queue: State[] = [initialState]; + const bareIds = new Set(); // ids referenced as a wrapper's bareStateId - const queue: QueueItem[] = []; + while (queue.length > 0) { + const state = queue.shift()!; - // Decide how to enqueue the start: if it's a wrapper, enqueue its bare with - // the wrapper as context; otherwise enqueue the state itself. - if (initialState.#overriddenHaltState && initialState.#bareState) { - queue.push({state: initialState.#bareState, wrapperContext: initialState}); - } else { - queue.push({state: initialState, wrapperContext: null}); - } + if (visited.has(state.#id)) { + continue; + } - while (queue.length > 0) { - const {state, wrapperContext} = queue.shift()!; + visited.add(state.#id); if (state.isHalt) { - // Real halt — always id 0, single node. if (!(0 in nodes)) { nodes[0] = { id: 0, name: state.#name, isHalt: true, isHaltMarker: false, - isWrapped: false, isWrapper: false, bareStateId: null, frameId: null, @@ -446,122 +435,36 @@ export default class State { continue; } - if (wrapperContext !== null) { - // Process `state` (the bare) collapsed under `wrapperContext` (the - // wrapper). Graph node id = wrapper's id. - const collapsedId = wrapperGraphId(wrapperContext); - - if (collapsedId in nodes) { - continue; - } - - const haltMarkerId = haltMarkerIdFor(wrapperContext); - const overrideTarget = wrapperContext.#overriddenHaltState!; - - // The override target's collapsed id: if the override is itself a - // wrapper, its graph node id is `overrideTarget.#id` (its own wrapper - // id); otherwise its own bare id. - const overrideGraphId = overrideTarget.#overriddenHaltState - ? wrapperGraphId(overrideTarget) - : overrideTarget.#id; - - // Emit the halt-marker node if not already present (one per wrapper). - if (!(haltMarkerId in nodes)) { - nodes[haltMarkerId] = { - id: haltMarkerId, - name: 'halt', - isHalt: true, - isHaltMarker: true, - isWrapped: false, - isWrapper: false, - bareStateId: null, - frameId: null, - transitions: [], - overriddenHaltStateId: null, - }; - } + // Wrapper? Emit wrapper node + queue bare and override target. + if (state.#overriddenHaltState !== null && state.#bareState !== null) { + const bareState = state.#bareState; + const overrideTarget = state.#overriddenHaltState; - // Build the collapsed node. - const collapsedNode: GraphNode = { - id: collapsedId, - name: state.#name, + nodes[state.#id] = { + id: state.#id, + name: state.#name, // composite name like "A(target)" isHalt: false, isHaltMarker: false, - isWrapped: true, - isWrapper: false, - bareStateId: null, + isWrapper: true, + bareStateId: bareState.#id, frameId: null, transitions: [], - overriddenHaltStateId: overrideGraphId, + overriddenHaltStateId: overrideTarget.#id, }; - nodes[collapsedId] = collapsedNode; - - let patternIx = 0; - - for (const [sym, {command, nextState}] of state.#symbolToDataMap) { - let target: State; - - try { - target = nextState instanceof State ? nextState : nextState.ref; - } catch { - patternIx += 1; - continue; - } - - // Retarget transitions per Variant X conventions: - // - target == haltState → halt marker (stays inside the subgraph) - // - target == bare (self-loop) → the collapsed wrapper id - // - target is itself a wrapper → that wrapper's collapsed id - // - else → target's own id - let nextStateId: number; - - if (target.isHalt) { - nextStateId = haltMarkerId; - } else if (target === state) { - nextStateId = collapsedId; - } else if (target.#overriddenHaltState && target.#bareState) { - nextStateId = wrapperGraphId(target); - queue.push({state: target.#bareState, wrapperContext: target}); - } else { - nextStateId = target.#id; - queue.push({state: target, wrapperContext: null}); - } - - collapsedNode.transitions.push({ - pattern: decodePatternDescription(sym.description, alphabets), - command: command.tapesCommands.map((tc) => ({ - symbol: decodeWriteSymbol(tc.symbol), - movement: decodeMovement((tc.movement as symbol).description), - })), - nextStateId, - id: `${collapsedId}-${patternIx}`, - }); - - patternIx += 1; - } - - // Enqueue the override target so its own node is emitted. - if (overrideTarget.#overriddenHaltState && overrideTarget.#bareState) { - queue.push({state: overrideTarget.#bareState, wrapperContext: overrideTarget}); - } else { - queue.push({state: overrideTarget, wrapperContext: null}); - } - - continue; - } + bareIds.add(bareState.#id); + queue.push(bareState); + queue.push(overrideTarget); - // Non-wrapper context: emit `state` as a regular node. - if (state.#id in nodes) { continue; } + // Regular (or bare) state — build node with transitions. const node: GraphNode = { id: state.#id, name: state.#name, isHalt: false, isHaltMarker: false, - isWrapped: false, isWrapper: false, bareStateId: null, frameId: null, @@ -583,38 +486,208 @@ export default class State { continue; } - let nextStateId: number; - - if (target.#overriddenHaltState && target.#bareState) { - // Transition into a wrapper — use its collapsed id. - nextStateId = wrapperGraphId(target); - queue.push({state: target.#bareState, wrapperContext: target}); - } else { - nextStateId = target.#id; - queue.push({state: target, wrapperContext: null}); - } - node.transitions.push({ pattern: decodePatternDescription(sym.description, alphabets), command: command.tapesCommands.map((tc) => ({ symbol: decodeWriteSymbol(tc.symbol), movement: decodeMovement((tc.movement as symbol).description), })), - nextStateId, + nextStateId: target.#id, id: `${state.#id}-${patternIx}`, }); + queue.push(target); patternIx += 1; } } - return {initialId, alphabets, nodes}; + // Always emit real halt as a sentinel, even if no transition targets it. + // It anchors the `subtree -. halt .-> s0` frame-level arrow whenever a + // frame demand-emits one, and it's the canonical machine-halt singleton. + if (!(0 in nodes)) { + nodes[0] = { + id: 0, + name: 'halt', + isHalt: true, + isHaltMarker: false, + isWrapper: false, + bareStateId: null, + frameId: null, + transitions: [], + overriddenHaltStateId: null, + }; + } + + // Pass 2: For each bare, compute its forward-reachable set (following + // transitions; stopping at halt and at wrappers — both are frame + // boundaries). + const computeReach = (startId: number): Set => { + const reach = new Set(); + const stack = [startId]; + + while (stack.length > 0) { + const id = stack.pop()!; + + if (reach.has(id)) { + continue; + } + + const node = nodes[id]; + + if (!node || node.isHalt || node.isWrapper) { + continue; + } + + reach.add(id); + + for (const t of node.transitions) { + const target = nodes[t.nextStateId]; + + if (!target || target.isHalt || target.isWrapper) { + continue; + } + + stack.push(t.nextStateId); + } + } + + return reach; + }; + + const reachByBare = new Map>(); + + for (const bareId of bareIds) { + reachByBare.set(bareId, computeReach(bareId)); + } + + // Pass 3: Union-find on bare overlaps. Two bares merge if their reach + // sets share any state. Canonical representative = smallest bare-id in + // the component. + const ufParent = new Map(); + + const ufFind = (id: number): number => { + if (!ufParent.has(id)) { + ufParent.set(id, id); + } + + let root = id; + + while (ufParent.get(root) !== root) { + root = ufParent.get(root)!; + } + + // Path compression + let cur = id; + + while (ufParent.get(cur) !== root) { + const next = ufParent.get(cur)!; + + ufParent.set(cur, root); + cur = next; + } + + return root; + }; + + const ufUnion = (a: number, b: number) => { + const ra = ufFind(a); + const rb = ufFind(b); + + if (ra === rb) return; + + if (ra < rb) { + ufParent.set(rb, ra); + } else { + ufParent.set(ra, rb); + } + }; + + for (const bareId of bareIds) { + ufFind(bareId); + } + + // For each state, collect the bares that reach it; union all bares that + // share a state. + const stateToReachingBares = new Map(); + + for (const [bareId, reachSet] of reachByBare) { + for (const stateId of reachSet) { + let bares = stateToReachingBares.get(stateId); + + if (!bares) { + bares = []; + stateToReachingBares.set(stateId, bares); + } + + bares.push(bareId); + } + } + + for (const bares of stateToReachingBares.values()) { + for (let i = 1; i < bares.length; i += 1) { + ufUnion(bares[0], bares[i]); + } + } + + // Assign frameId to each in-reach state. + const frameIds = new Set(); + + for (const [stateId, bares] of stateToReachingBares) { + const frameId = ufFind(bares[0]); + + nodes[stateId].frameId = frameId; + frameIds.add(frameId); + } + + // Pass 4: Retarget halt-bound transitions for in-frame states to the + // frame's halt marker. Out-of-frame states (top-level dispatcher, override + // targets, etc.) keep their halt-bound transitions pointing at real halt. + for (const node of Object.values(nodes)) { + if (node.frameId === null) { + continue; + } + + const haltMarkerId = -node.frameId; + + for (const t of node.transitions) { + const target = nodes[t.nextStateId]; + + if (target && target.isHalt && !target.isHaltMarker) { + t.nextStateId = haltMarkerId; + } + } + } + + // Pass 5: Emit one halt marker per frame. + for (const frameId of frameIds) { + const haltMarkerId = -frameId; + + nodes[haltMarkerId] = { + id: haltMarkerId, + name: 'halt', + isHalt: true, + isHaltMarker: true, + isWrapper: false, + bareStateId: null, + frameId, + transitions: [], + overriddenHaltStateId: null, + }; + } + + return {initialId: initialState.#id, alphabets, nodes}; } // Inverse of toGraph: rebuilds a State graph (and a fresh TapeBlock with the // graph's alphabets) from a serialized Graph. Round-trips with toGraph in // the sense that running the rebuilt machine on the same input gives the // same output, but the rebuilt State instances have *new* internal IDs. + // + // Under the v7 callable-subtree model (#174), graph nodes split into: + // - Wrapper nodes (`isWrapper: true`, no transitions) — reconstructed via + // `bareStates[bareStateId].withOverriddenHaltState(finalStates[overriddenHaltStateId])`. + // - Bare/regular nodes — constructed as normal States with transitions. + // - Halt + halt-marker nodes — collapse to the singleton `haltState`. static fromGraph(graph: Graph): { start: State; tapeBlock: TapeBlock; @@ -624,12 +697,15 @@ export default class State { const tapeBlock = TapeBlock.fromAlphabets(alphabetObjs); const ids = Object.keys(graph.nodes).map(Number); - // Pass 1: pre-create a Reference for each non-halt node so transitions can - // forward-declare their targets. + // Pass 1: pre-create a Reference for each non-halt non-halt-marker node + // (both wrappers and regulars). Halt and halt-marker nodes collapse to the + // singleton `haltState` and need no ref. const refs: Record = {}; for (const nodeId of ids) { - if (!graph.nodes[nodeId].isHalt) { + const node = graph.nodes[nodeId]; + + if (!node.isHalt) { refs[nodeId] = new Reference(); } } @@ -651,14 +727,15 @@ export default class State { return tapeBlock.symbol(flat); }; - // Pass 2: build a "bare" State for each non-halt node (no override yet). - // nextState entries point at refs so cycles work; haltState is used directly. + // Pass 2: build a State for each non-wrapper non-halt non-halt-marker + // node. Transitions point at refs so cycles work; haltState (and halt + // markers, which collapse to haltState) are used directly. const bareStates: Record = {}; for (const nodeId of ids) { const node = graph.nodes[nodeId]; - if (node.isHalt) { + if (node.isHalt || node.isWrapper) { continue; } @@ -667,7 +744,9 @@ export default class State { for (const t of node.transitions) { const key = patternToKey(parsePatternString(t.pattern, graph.alphabets)); const target = graph.nodes[t.nextStateId]; - const nextState: State | Reference = target.isHalt ? haltState : refs[t.nextStateId]; + const nextState: State | Reference = !target || target.isHalt + ? haltState + : refs[t.nextStateId]; stateDefinition![key] = { command: t.command.map((c) => ({ @@ -678,16 +757,19 @@ export default class State { }; } - // Graph-sourced names may contain `(` and `)` (composite wrapper names - // emitted by toGraph). Bypass the constructor's user-facing name - // validation by constructing without a name and assigning #name directly. + // Graph-sourced names may contain `(` and `)` (composite wrapper names — + // although wrappers go through a separate path below, defensive + // construction here keeps the bypass uniform). Construct without a name + // and assign `#name` directly to skip user-facing name validation. const bare = new State(stateDefinition); bare.#name = node.name; bareStates[nodeId] = bare; } - // Pass 3: apply overrideHaltStates transitively. + // Pass 3: resolve every node to its final State (memoized + cycle-safe). + // Wrappers compose lazily via `withOverriddenHaltState` once their bare + // and override are resolved. const finalStates: Record = {}; const inProgress = new Set(); @@ -698,7 +780,7 @@ export default class State { const node = graph.nodes[nodeId]; - if (node.isHalt) { + if (!node || node.isHalt) { finalStates[nodeId] = haltState; return haltState; @@ -710,10 +792,15 @@ export default class State { inProgress.add(nodeId); - let state = bareStates[nodeId]; + let state: State; + + if (node.isWrapper) { + const bare = getFinal(node.bareStateId!); + const override = getFinal(node.overriddenHaltStateId!); - if (node.overriddenHaltStateId !== null) { - state = bareStates[nodeId].withOverriddenHaltState(getFinal(node.overriddenHaltStateId)); + state = bare.withOverriddenHaltState(override); + } else { + state = bareStates[nodeId]; } inProgress.delete(nodeId); @@ -726,8 +813,8 @@ export default class State { getFinal(nodeId); } - // Pass 4: bind each ref to the FINAL (possibly wrapped) state so transitions - // resolve to the version that has its override-halt set. + // Pass 4: bind each ref to the resolved final State so cross-node + // transitions land on the right instance. for (const nodeId of ids) { if (!graph.nodes[nodeId].isHalt) { refs[nodeId].bind(finalStates[nodeId]); diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index ee29183..c516d59 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -118,9 +118,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [ {pattern: "'0'", command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, {pattern: "'1'", command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, @@ -139,17 +139,21 @@ describe('toMermaid', () => { expect(out).toContain("s1 -- \"['1'] → [K]/[S]\" --> s0"); }); - test('renders dotted onHalt edge when overriddenHaltStateId is set', () => { + test('renders wrapper-to-override solid arrow when overriddenHaltStateId is set', () => { + // Under the v7 callable-subtree model, wrapper → override is a regular + // solid `-->` (the new convention reserves bold/dotted for `call`/`return`/ + // `halt`). The retired `-. onHalt .->` keyword no longer appears. const out = toMermaid({ initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isHaltMarker: false, isWrapper: true, bareStateId: null, frameId: null}, }, }); - expect(out).toContain('s1 -. onHalt .-> s0'); + expect(out).toContain('s1 --> s0'); + expect(out).not.toContain('onHalt'); }); test('non-initial, non-halt node uses square bracket shape', () => { @@ -157,9 +161,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }); @@ -171,9 +175,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0'], [' ', 'a']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [{ pattern: "'0','a'", command: [{symbol: "'0'", movement: 'R'}, {symbol: "'a'", movement: 'L'}], @@ -517,7 +521,7 @@ describe('README diagrams: engine-generated outputs', () => { ]); }); - test('withOverriddenHaltState AFTER (scanThenErase, machine README) — emits the v7 halt-frame subgraph', () => { + test('withOverriddenHaltState AFTER (scanThenErase, machine README) — emits the v7 callable-subtree shape', () => { const alphabet = new Alphabet([' ', 'a', 'b', 'X']); const tapeBlock = TapeBlock.fromAlphabets([alphabet]); const {symbol} = tapeBlock; @@ -537,13 +541,19 @@ describe('README diagrams: engine-generated outputs', () => { '%% alphabets: [[" ","a","b","X"]]', '(((halt)))', // real halt outside any subgraph '["eraseHere"]', // override is a regular [name] node - '[["scanToX"]]', // wrapper-collapsed bare uses subroutine shape inside the subgraph - 'subgraph w_', // halt-frame subgraph wraps the bare + its halt marker - '"halt frame"', // subgraph label + '[["scanToX(eraseHere)"]]', // wrapper uses [[…]] subroutine shape (composite name) + '["scanToX"]', // bare uses regular [name] shape inside the subgraph + 'subgraph w_', // callable-subtree subgraph wraps the bare + its halt marker + 'callable subtree of scanToX', // subgraph label 'idle([idle])', // pre-execution sentinel — always emitted - 'idle -. enter .->', // labeled dotted enter arrow points at the initial state + 'idle -. enter .->', // labeled dotted enter arrow points at the wrapper '"[*] → [E]/[S]"', // eraseHere's erase command - '-. onHalt .->', // the dotted override-halt edge — wrapper's catch-and-redirect, crosses the subgraph border + '== "call" ==>', // wrapper-to-bare bold call arrow + '-. "return" .->', // frame-to-wrapper dotted return arrow ]); + + // Retired keywords — must NOT appear under the new convention. + expect(output).not.toContain('onHalt'); + expect(output).not.toContain('halt frame'); }); }); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index 7f9a7a8..d5a4dd3 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -18,36 +18,36 @@ export type GraphNode = { name: string; isHalt: boolean; transitions: GraphTransition[]; + // On wrapper nodes (`isWrapper: true`), the id of the override target's + // GraphNode (could be a wrapper or a regular state). `null` on non-wrapper + // nodes (their override semantics live in the wrapper that wraps them, if + // any). overriddenHaltStateId: number | null; - // `true` when this node represents the bare of a `withOverriddenHaltState`- - // wrapped state under v7 alpha.1's collapsed emit. Carries the `[[…]]` - // (subroutine) shape signal for `toMermaid` and tells `fromGraph` to - // reconstruct via `bare.withOverriddenHaltState(target)`. The new - // un-collapsed model (#174) supersedes this with `isWrapper` + `bareStateId` - // below; `isWrapped` is retained transitionally for backward-compat during - // the cutover. - isWrapped: boolean; - // New (#174): `true` for external `[[…]]` wrapper nodes that have a - // separate bare node in the graph. The wrapper sits outside any subtree - // subgraph; its `bareStateId` points to the corresponding bare GraphNode - // (which lives inside its home subtree). Multiple wrapper nodes can share - // the same `bareStateId` when they wrap the same bare with different - // override targets. Under the new model, `isWrapped: false` for these - // nodes — the wrapper-vs-bare distinction is carried by `isWrapper`. + // `true` for external `[[…]]` wrapper nodes — the call sites that + // `withOverriddenHaltState` produces. Wrappers sit OUTSIDE any callable + // subtree subgraph; their `bareStateId` points to the bare's GraphNode id + // (which lives inside its home subtree). Multiple wrappers can share the + // same `bareStateId` when they wrap the same bare with different overrides. + // Wrapper nodes have NO transitions of their own — the `call` arrow into + // the bare is derived from `bareStateId`, the post-return `-->` arrow to + // the override is derived from `overriddenHaltStateId`. isWrapper: boolean; - // New (#174): on wrapper nodes (`isWrapper: true`), the id of the bare - // GraphNode this wrapper wraps. `null` for non-wrapper nodes. + // On wrappers, the id of the bare GraphNode this wrapper wraps. `null` + // for non-wrapper nodes. bareStateId: number | null; - // New (#174): the id of the wrapper whose callable subtree this node lives - // in. `null` for nodes outside any subtree (top-level states, real halt - // singleton, wrapper nodes themselves, the `idle` visualization sentinel). - // Set on bares, body states, and halt markers. Two bares may share a - // `frameId` when union-find merges their reachable sets (shared body state - // forces a union frame; see spec Example "Shared body state"). + // The id of the callable subtree this node lives in. `null` for nodes + // outside any subtree (top-level states, real halt singleton, wrapper + // nodes themselves, the `idle` visualization sentinel). Set on bares, + // body states reachable from any bare, and halt markers. Two bares may + // share a `frameId` when union-find merges their reachable sets (shared + // body state forces a union frame; see spec Example "Shared body state"). + // The canonical frame id is the smallest bare-id in the component. frameId: number | null; - // `true` for a synthesized halt marker graph node — one per wrapper context. - // Real halt has `isHalt: true, isHaltMarker: false`; halt markers have both - // `true`. `fromGraph` maps halt-marker nodes back to the singleton `haltState`. + // `true` for a synthesized halt marker graph node — one per frame. + // Real halt has `isHalt: true, isHaltMarker: false`; halt markers have + // both `true`. `fromGraph` maps halt-marker nodes back to the singleton + // `haltState`. Halt marker id = `-frameId` (sits in disjoint negative-id + // range from real node ids). isHaltMarker: boolean; }; diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index e90e144..93bb51f 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -6,19 +6,25 @@ import {type Graph, type GraphCommand, type GraphNode} from './graph'; // Currently only Mermaid flowchart syntax is supported. Future formats // (Graphviz, JSON-LD, custom DSL) belong here too. // -// v7 emit shape (#138/#139): -// - Each wrapper-State collapses onto its bare's representation. The collapsed -// graph node has `isWrapped: true` and is emitted as Mermaid `[[…]]` -// (subroutine / double-walled-rectangle) shape, inside a `subgraph -// w_${id}["halt frame"] … end` block. A synthesized "halt marker" graph -// node (with `isHalt: true, isHaltMarker: true`, id = -wrapperId in graph -// data) sits inside the subgraph and serves as the local landing point for -// the bare's halt-bound transitions. The dotted onHalt edge runs from the -// `[[bare]]` directly to the override target, crossing the subgraph border. -// - Real halt (id 0) is emitted as `s0(((halt)))` outside any subgraph. -// - Halt marker nodes use the Mermaid id `c${absId}` (where `absId = -id`) -// since Mermaid IDs must match /[A-Za-z][A-Za-z0-9_]*/ — negative numbers -// are not legal syntax. +// v7 callable-subtree emit (#174): +// - Each `withOverriddenHaltState` wrapper produces TWO graph nodes — a +// wrapper node (`[[composite-name]]`, OUTSIDE any subgraph) and a bare +// node (regular shape, INSIDE its callable subtree subgraph). +// - Subgraphs (one per frame): `subgraph w_${frameId}["callable subtree +// of NAME"]` (single bare) or `["callable scope: A ∪ B"]` (union). +// - Each frame has exactly one halt marker `c${frameId}(((halt)))` inside +// its subgraph; halt-bound transitions from in-frame states retarget to +// it. Always emitted (orphan signals dead wrapper). +// - Arrow conventions: +// solid `-->` regular transitions, including wrapper-to-override. +// bold `==>` RESERVED for the wrapper-to-bare `call` arrow. +// `&` ribbon collapses multi-wrapper-shares-bare. +// dotted `-.->` frame-level dispatch (`return`, `halt`, `enter`). +// - The `return` arrow (subgraph → wrapper) is demand-emitted iff the +// frame's halt marker has at least one incoming edge AND the wrapper +// calls into the frame. The `halt` arrow (subgraph → s0) is emitted +// iff the halt marker has incoming AND there's at least one non-wrapper +// entry into the frame (cross-subgraph solid arrow from outside). // Maps a graph node id to its Mermaid id. // - non-negative id N → "sN" @@ -36,130 +42,229 @@ function parseMermaidId(s: string): number { return Number(s.slice(1)); } +function frameSubgraphId(frameId: number): string { + return `w_${frameId}`; +} + export function toMermaid(graph: Graph): string { const lines: string[] = [ 'flowchart TD', `%% alphabets: ${JSON.stringify(graph.alphabets)}`, ]; - // Sort nodes by id (ascending — real halt first at 0, regular states next, - // negative-id halt markers last). Deterministic emit lets `toMermaid` → - // `fromMermaid` → `toMermaid` round-trip stably (regression for #139). + // Sort nodes by id ascending — real halt (0) first, then regulars by their + // ids, then halt markers (negative) at the end. Deterministic emit lets + // toMermaid → fromMermaid → toMermaid round-trip stably (#139). const nodes = Object.values(graph.nodes).slice().sort((a, b) => a.id - b.id); - const wrappedNodes = nodes.filter((n) => n.isWrapped); - // Convention: wrapped node id N → halt marker id -N. - const haltMarkerIdFor = (wrappedId: number): number => -wrappedId; + // Bucket nodes for emit order. + const topLevelNodes = nodes.filter((n) => n.frameId === null && !n.isWrapper); + const wrapperNodes = nodes.filter((n) => n.isWrapper); + // Bares-and-bodies inside frames, grouped by frameId. + const nodesByFrame = new Map(); + // Halt-marker per frame (kept separate so it always emits LAST inside the + // subgraph for deterministic shape). + const haltMarkerByFrame = new Map(); - // Set of halt-marker ids that belong to some wrapper (= are inside a subgraph). - const haltMarkerIds = new Set(); + for (const node of nodes) { + if (node.frameId === null || node.isWrapper) continue; - for (const w of wrappedNodes) { - const haltMarkerId = haltMarkerIdFor(w.id); + if (node.isHaltMarker) { + haltMarkerByFrame.set(node.frameId, node); + } else { + let bucket = nodesByFrame.get(node.frameId); - if (haltMarkerId in graph.nodes) { - haltMarkerIds.add(haltMarkerId); - } - } + if (!bucket) { + bucket = []; + nodesByFrame.set(node.frameId, bucket); + } - // Emit non-subgraph nodes first: real halt + regular non-wrapped nodes. - // No special round-shape `((…))` for the initial — the `idle -. enter .->` - // arrow emitted below is the sole "start here" signal. - for (const node of nodes) { - if (node.isWrapped || haltMarkerIds.has(node.id)) { - continue; + bucket.push(node); } + } - const id = mermaidIdFor(node.id); + // 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame). + for (const node of topLevelNodes) { + const mid = mermaidIdFor(node.id); if (node.isHalt) { - lines.push(` ${id}(((halt)))`); + lines.push(` ${mid}(((halt)))`); } else { - lines.push(` ${id}["${node.name}"]`); + lines.push(` ${mid}["${node.name}"]`); } } - // `idle` sentinel = pre-execution marker for the machine. Always emitted, - // with a labeled dotted arrow `idle -. enter .-> sN` to the initial state. - // Symmetric with the `onHalt` dotted convention used by wrapper redirects. - // Visual-only — `idle` is not a graph node. + // 2. Emit wrappers at top level. + for (const wrapper of wrapperNodes) { + lines.push(` ${mermaidIdFor(wrapper.id)}[["${wrapper.name}"]]`); + } + + // 3. `idle` sentinel. lines.push(' idle([idle])'); - // Emit one subgraph per wrapper, in sorted wrapped-id order. - for (const wrapped of wrappedNodes) { - const wrappedMid = mermaidIdFor(wrapped.id); - const haltMarkerId = haltMarkerIdFor(wrapped.id); - const haltMarkerMid = mermaidIdFor(haltMarkerId); + // 4. Subgraph per frame. + const frameIds = [...nodesByFrame.keys()].sort((a, b) => a - b); + + for (const frameId of frameIds) { + const frameBares = (nodesByFrame.get(frameId) ?? []).filter( + (n) => isFrameBare(n, graph), + ); + const frameBareNames = frameBares + .slice() + .sort((a, b) => a.id - b.id) + .map((n) => n.name); + const label = frameBareNames.length > 1 + ? `callable scope: ${frameBareNames.join(' ∪ ')}` + : `callable subtree of ${frameBareNames[0] ?? frameId}`; + + lines.push(` subgraph ${frameSubgraphId(frameId)}["${label}"]`); + + // Inner nodes — sort by id for determinism. + for (const node of (nodesByFrame.get(frameId) ?? []).slice().sort((a, b) => a.id - b.id)) { + lines.push(` ${mermaidIdFor(node.id)}["${node.name}"]`); + } - lines.push(` subgraph w_${wrapped.id}["halt frame"]`); - lines.push(` ${wrappedMid}[["${wrapped.name}"]]`); + const haltMarker = haltMarkerByFrame.get(frameId); - if (haltMarkerId in graph.nodes) { - lines.push(` ${haltMarkerMid}(((halt)))`); + if (haltMarker) { + lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`); } lines.push(' end'); } - // Enter arrow: emitted after subgraphs so it visually points at the initial - // node (whether plain `[…]` or wrapped `[[…]]` inside a subgraph). + // 5. Enter arrow. lines.push(` idle -. enter .-> ${mermaidIdFor(graph.initialId)}`); - // Emit transitions per-node in sorted node-id order. Within a node, - // transitions emit in their stored array order (which mirrors the source - // state's symbol-map insertion order — stable per State instance). + // 6. `call` arrows — grouped by bare (multi-wrapper-shares-bare collapses + // into a single `&` ribbon). + const wrappersByBare = new Map(); + + for (const wrapper of wrapperNodes) { + if (wrapper.bareStateId === null) continue; + + let group = wrappersByBare.get(wrapper.bareStateId); + + if (!group) { + group = []; + wrappersByBare.set(wrapper.bareStateId, group); + } + + group.push(wrapper); + } + + const sortedBares = [...wrappersByBare.keys()].sort((a, b) => a - b); + + for (const bareId of sortedBares) { + const wrappers = wrappersByBare.get(bareId)!.slice().sort((a, b) => a.id - b.id); + const sources = wrappers.map((w) => mermaidIdFor(w.id)).join(' & '); + + lines.push(` ${sources} == "call" ==> ${mermaidIdFor(bareId)}`); + } + + // 7. Demand-emit `return` and `halt` arrows per frame. + // For each frame: check if its halt marker has incoming transitions. + const haltMarkerHasIncoming = new Map(); + for (const node of nodes) { - if (node.isHalt && !node.isHaltMarker) { - continue; + for (const t of node.transitions) { + const target = graph.nodes[t.nextStateId]; + + if (target && target.isHaltMarker && target.frameId !== null) { + haltMarkerHasIncoming.set(target.frameId, true); + } } + } + + // For each frame: check if there's at least one non-wrapper entry (a solid + // `-->` from OUTSIDE the frame into any node INSIDE). + const hasNonWrapperEntry = new Map(); + + for (const node of nodes) { + if (node.isWrapper) continue; + + for (const t of node.transitions) { + const target = graph.nodes[t.nextStateId]; + + if ( + target + && target.frameId !== null + && node.frameId !== target.frameId + ) { + hasNonWrapperEntry.set(target.frameId, true); + } + } + } + + for (const frameId of frameIds) { + if (!haltMarkerHasIncoming.get(frameId)) continue; + + // Return arrow — collapsed `&` ribbon over all wrappers calling this frame. + const callingWrappers = wrapperNodes.filter((w) => { + if (w.bareStateId === null) return false; + + const bare = graph.nodes[w.bareStateId]; + + return !!bare && bare.frameId === frameId; + }); + + if (callingWrappers.length > 0) { + const targets = callingWrappers + .slice() + .sort((a, b) => a.id - b.id) + .map((w) => mermaidIdFor(w.id)) + .join(' & '); + + lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`); + } + + if (hasNonWrapperEntry.get(frameId)) { + lines.push(` ${frameSubgraphId(frameId)} -. "halt" .-> s0`); + } + } + + // 8. Wrapper-to-override arrows (regular solid). + for (const wrapper of wrapperNodes) { + if (wrapper.overriddenHaltStateId === null) continue; + + lines.push( + ` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId)}`, + ); + } + + // 9. Regular transitions for non-wrapper non-halt-marker non-halt nodes. + for (const node of nodes) { + if (node.isHalt || node.isHaltMarker || node.isWrapper) continue; for (const t of node.transitions) { - // Bracketed-tape-block format (v7): each role-list — read alternatives, - // writes, movements — wraps in `[…]` to mark "this is a tape-block - // reading". Brackets stay even for single-tape machines; the `[…]` is - // the tape-block concept indicator. - // - // Single-tape: ['X'] → [K]/[R] - // Single-tape + alternation: ['^']|['1']|['0'] → [K]/[S] - // Two-tape: ['0','a'] → [K,'1']/[R,S] - // Two-tape + alternation: ['0','a']|['1','b'] → [K,K]/[R,L] - // - // Alternation is ALWAYS per-pattern-bracket — one full bracketed list - // per alternative — regardless of tape count. Pedagogically each - // alternative is its own drawn transition; a compact in-bracket form - // (`['^'|'1']`) would read as cross-product semantics in multi-tape - // (`['0'|'1','a'|'b']` = 4 combos, not 2 paired alternatives), so we - // avoid introducing it for the single-tape case too. const alternatives = t.pattern.split('|'); const reads = alternatives.map((alt) => `[${alt}]`).join('|'); const writes = `[${t.command.map((c) => c.symbol).join(',')}]`; const moves = `[${t.command.map((c) => c.movement).join(',')}]`; const label = `${reads} → ${writes}/${moves}`; - // Thicker `==>` arrow when the transition crosses INTO a wrapper — - // signals "this transition pushes that wrapper's override onto the - // runtime stack" (per `TuringMachine.run` line ~220's - // `if (state !== nextState && nextState.overriddenHaltState) push(...)`). - // Self-loops (state === nextState) don't push at runtime — keep the - // regular `-->` for those even when the target is wrapped. - const targetNode = graph.nodes[t.nextStateId]; - const isEnteringWrapper = targetNode && targetNode.isWrapped && t.nextStateId !== node.id; - const lineSegment = isEnteringWrapper ? '==' : '--'; - const arrowTip = isEnteringWrapper ? '==>' : '-->'; - lines.push( - ` ${mermaidIdFor(node.id)} ${lineSegment} "${label}" ${arrowTip} ${mermaidIdFor(t.nextStateId)}`, + ` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`, ); } + } - if (node.overriddenHaltStateId !== null) { - lines.push( - ` ${mermaidIdFor(node.id)} -. onHalt .-> ${mermaidIdFor(node.overriddenHaltStateId)}`, - ); + return lines.join('\n'); +} + +// Helper: identify "the bare states" that anchor a frame's name. A bare is a +// node referenced as some wrapper's `bareStateId`. Body states (also in-frame +// but not bare) are excluded from the frame label. +function isFrameBare(node: GraphNode, graph: Graph): boolean { + if (node.isWrapper || node.isHalt) return false; + + for (const other of Object.values(graph.nodes)) { + if (other.isWrapper && other.bareStateId === node.id) { + return true; } } - return lines.join('\n'); + return false; } // Inverse of toMermaid: parses the Mermaid output produced by toMermaid back @@ -176,13 +281,20 @@ export function toMermaid(graph: Graph): string { const haltNodeRegex = /^([sc]\d+)\(\(\(halt\)\)\)$/; const regularNodeRegex = /^(s\d+)\["([^"]*)"\]$/; const wrappedNodeRegex = /^(s\d+)\[\["([^"]*)"\]\]$/; -const subgraphStartRegex = /^subgraph\s+w_\d+\["([^"]*)"\]$/; +const subgraphStartRegex = /^subgraph\s+w_(\d+)\["([^"]*)"\]$/; const subgraphEndRegex = /^end$/; const idleNodeRegex = /^idle\(\[idle\]\)$/; const enterArrowRegex = /^idle\s+-\.\s+enter\s+\.->\s+(s\d+)$/; -const transitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/; -const thickTransitionRegex = /^([sc]\d+)\s+==\s+"(.*)"\s+==>\s+([sc]\d+)$/; -const onHaltRegex = /^([sc]\d+)\s+-\.\s+onHalt\s+\.->\s+([sc]\d+)$/; +// Regular labeled transition (solid `-->`). +const labeledTransitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/; +// Wrapper → override (unlabeled solid `-->`). +const wrapperOverrideRegex = /^(s\d+)\s+-->\s+([sc]\d+)$/; +// Call arrow (bold `==>`), with optional `&`-joined source ribbon. +const callArrowRegex = /^(s\d+(?:\s+&\s+s\d+)*)\s+==\s+"call"\s+==>\s+(s\d+)$/; +// Return arrow (`w_N -. return .-> s_W` with optional `&` target ribbon). +const returnArrowRegex = /^w_(\d+)\s+-\.\s+"return"\s+\.->\s+(s\d+(?:\s+&\s+s\d+)*)$/; +// Halt arrow (`w_N -. halt .-> s0`). +const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/; // First capture char anchored as \S to avoid polynomial backtracking between // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos. const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/; @@ -193,11 +305,7 @@ export function fromMermaid(text: string): Graph { let alphabets: string[][] = []; let initialId: number | null = null; const nodes: Record = {}; - // Track the halt-marker ids that appeared inside a subgraph — they should be - // marked `isHaltMarker: true` even though they share the `(((halt)))` shape - // with the real halt at the top level. - const haltMarkerIds = new Set(); - let inSubgraph = false; + let currentFrameId: number | null = null; const ensureNode = ( id: number, @@ -205,7 +313,6 @@ export function fromMermaid(text: string): Graph { name?: string; isHalt?: boolean; isHaltMarker?: boolean; - isWrapped?: boolean; isWrapper?: boolean; bareStateId?: number | null; frameId?: number | null; @@ -217,7 +324,6 @@ export function fromMermaid(text: string): Graph { name: opts.name ?? mermaidIdFor(id), isHalt: opts.isHalt ?? false, isHaltMarker: opts.isHaltMarker ?? false, - isWrapped: opts.isWrapped ?? false, isWrapper: opts.isWrapper ?? false, bareStateId: opts.bareStateId ?? null, frameId: opts.frameId ?? null, @@ -228,7 +334,6 @@ export function fromMermaid(text: string): Graph { if (opts.name !== undefined) nodes[id].name = opts.name; if (opts.isHalt !== undefined) nodes[id].isHalt = opts.isHalt; if (opts.isHaltMarker !== undefined) nodes[id].isHaltMarker = opts.isHaltMarker; - if (opts.isWrapped !== undefined) nodes[id].isWrapped = opts.isWrapped; if (opts.isWrapper !== undefined) nodes[id].isWrapper = opts.isWrapper; if (opts.bareStateId !== undefined) nodes[id].bareStateId = opts.bareStateId; if (opts.frameId !== undefined) nodes[id].frameId = opts.frameId; @@ -237,11 +342,9 @@ export function fromMermaid(text: string): Graph { return nodes[id]; }; - // First pass: alphabets + nodes (track subgraph context to mark halt markers). + // First pass: nodes + alphabets (track subgraph context for frameId). for (const line of lines) { - if (line === 'flowchart TD') { - continue; - } + if (line === 'flowchart TD') continue; const am = line.match(alphabetsRegex); @@ -250,34 +353,32 @@ export function fromMermaid(text: string): Graph { continue; } - if (subgraphStartRegex.test(line)) { - inSubgraph = true; + const sgStart = line.match(subgraphStartRegex); + + if (sgStart) { + currentFrameId = Number(sgStart[1]); continue; } if (subgraphEndRegex.test(line)) { - inSubgraph = false; + currentFrameId = null; continue; } - // `idle([idle])` sentinel: a visual pre-execution marker. Not a graph - // node — skip declaration, parse the `idle -. enter .-> sN` arrow in the - // edge pass to set initialId. - if (idleNodeRegex.test(line)) { - continue; - } + if (idleNodeRegex.test(line)) continue; const hm = line.match(haltNodeRegex); if (hm) { const id = parseMermaidId(hm[1]); - const isHaltMarker = inSubgraph || id < 0; - - ensureNode(id, {name: 'halt', isHalt: true, isHaltMarker}); + const isHaltMarker = currentFrameId !== null; - if (isHaltMarker) { - haltMarkerIds.add(id); - } + ensureNode(id, { + name: 'halt', + isHalt: true, + isHaltMarker, + frameId: isHaltMarker ? currentFrameId : null, + }); continue; } @@ -285,21 +386,28 @@ export function fromMermaid(text: string): Graph { const wm = line.match(wrappedNodeRegex); if (wm) { - ensureNode(parseMermaidId(wm[1]), {name: wm[2], isWrapped: true}); + ensureNode(parseMermaidId(wm[1]), { + name: wm[2], + isWrapper: true, + }); + continue; } const rm = line.match(regularNodeRegex); if (rm) { - ensureNode(parseMermaidId(rm[1]), {name: rm[2]}); + ensureNode(parseMermaidId(rm[1]), { + name: rm[2], + frameId: currentFrameId, + }); + continue; } } // Second pass: edges. for (const line of lines) { - // `idle -. enter .-> sN`: the sole source of initialId. const em = line.match(enterArrowRegex); if (em) { @@ -307,16 +415,43 @@ export function fromMermaid(text: string): Graph { continue; } - const om = line.match(onHaltRegex); + // Return/halt arrows are derivable from frame structure at the next + // toMermaid emit; consume but don't persist as graph data. + if (returnArrowRegex.test(line) || haltArrowRegex.test(line)) { + continue; + } + + // `call` arrow — sets bareStateId on each source wrapper. + const cm = line.match(callArrowRegex); + + if (cm) { + const sources = cm[1].split(/\s+&\s+/); + const bareId = parseMermaidId(cm[2]); + + for (const src of sources) { + ensureNode(parseMermaidId(src), {isWrapper: true, bareStateId: bareId}); + } - if (om) { - ensureNode(parseMermaidId(om[1])).overriddenHaltStateId = parseMermaidId(om[2]); continue; } - // Thick transition (`==> `) and regular transition (`-->`) share the same - // semantics — only the visual differs. Parse both via the same code path. - const tm = line.match(transitionRegex) ?? line.match(thickTransitionRegex); + // Wrapper → override (unlabeled solid `-->`). Only fires if the source + // node is a known wrapper (declared as `[[…]]`). + const wo = line.match(wrapperOverrideRegex); + + if (wo) { + const fromId = parseMermaidId(wo[1]); + const toId = parseMermaidId(wo[2]); + + if (nodes[fromId] && nodes[fromId].isWrapper) { + nodes[fromId].overriddenHaltStateId = toId; + continue; + } + // Fall through — unlabeled solid from a non-wrapper is unexpected; + // treated as a malformed line and ignored by the labeled-regex below. + } + + const tm = line.match(labeledTransitionRegex); if (tm) { const fromId = parseMermaidId(tm[1]); @@ -329,34 +464,15 @@ export function fromMermaid(text: string): Graph { throw new Error(`fromMermaid: malformed edge label: "${label}"`); } - // Bracketed-tape-block format (v7): - // []|[]... → []/[] - // Each bracketed list is a tape-block reading; the outer `|` separates - // alternative read patterns. For single-tape machines with alternation, - // the compact form `[||...]` (one bracket, alternatives - // inside) is also accepted; both forms decode to the same pattern - // string. const readLabel = label.slice(0, arrowIx); const cmdLabel = label.slice(arrowIx + ' → '.length); - // Strict per-pattern bracket form: `|` only between bracketed lists, - // never inside. The compact `['^'|'1']` form is rejected by design — - // every alternative must be its own bracketed pattern (`['^']|['1']`). - // Pedagogically: each transition is drawn explicitly; the compact form - // would read as cross-product semantics in multi-tape and confuse - // readers (`['0'|'1','a'|'b']` could mean 4 combos, not 2 paired alts). - // The rule applies to all bracketed lists — read alternatives, writes, - // and movements — because commands and movements have no alternation - // semantic either. const stripBrackets = (s: string): string => { if (!s.startsWith('[') || !s.endsWith(']')) { throw new Error(`fromMermaid: malformed bracketed list: "${s}"`); } const inner = s.slice(1, -1); - - // Walk the inner content; backslash escapes the next char (so `\|` - // inside a cell is a literal pipe, not the alternation separator). let i = 0; while (i < inner.length) { @@ -378,10 +494,6 @@ export function fromMermaid(text: string): Graph { return inner; }; - // Match `[…]` blocks in the read label. Inner content is a tape-block - // reading (possibly with `|` for compact single-tape alternation). - // `[^\]]*` is the simple non-greedy match — works because cell content - // doesn't typically contain literal `]`. const blockMatches = readLabel.match(/\[[^\]]*\]/g); if (!blockMatches || blockMatches.length === 0) { @@ -389,7 +501,6 @@ export function fromMermaid(text: string): Graph { } const pattern = blockMatches.map(stripBrackets).join('|'); - const slashIx = cmdLabel.indexOf(']/['); if (slashIx === -1) { diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index 5aea059..147e9f0 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -7,9 +7,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [ {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, @@ -31,9 +31,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [ {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, ], @@ -52,9 +52,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, transitions: [{pattern: '0', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}], }, }, @@ -72,10 +72,10 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }; @@ -90,8 +90,8 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }; @@ -197,8 +197,8 @@ describe('summarizeGraph defensive guards', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isWrapped: false, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, }, }; From 54ab833da840be07b0f06e7501f1ced666999518 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 08:18:18 +0300 Subject: [PATCH 020/118] fix(fromMermaid): tighten `&` ribbon regex to avoid CodeQL polynomial-ReDoS flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged `/\s+&\s+/` in `callArrowRegex` and `returnArrowRegex` as a polynomial regex on uncontrolled input (`graphFormats.ts:428` per the PR #167 integration check on commit 1dde21d). `toMermaid` emits literal single-space ` & ` between ribbon members, and the parser is documented as strict to the dialect `toMermaid` emits. So tightening `\s+&\s+` → ` & ` (and replacing the split regex with a literal-string split) is loss-free for round-trip cases while removing the polynomial-backtracking shape CodeQL detects. The outer `\s+` between major tokens (`==`, `==>`, `-.`, `.->`) stay as `\s+` — they're not in a repeat group and aren't flagged. --- packages/machine/src/utilities/graphFormats.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 93bb51f..364ad61 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -290,9 +290,12 @@ const labeledTransitionRegex = /^([sc]\d+)\s+--\s+"(.*)"\s+-->\s+([sc]\d+)$/; // Wrapper → override (unlabeled solid `-->`). const wrapperOverrideRegex = /^(s\d+)\s+-->\s+([sc]\d+)$/; // Call arrow (bold `==>`), with optional `&`-joined source ribbon. -const callArrowRegex = /^(s\d+(?:\s+&\s+s\d+)*)\s+==\s+"call"\s+==>\s+(s\d+)$/; +// Ribbon separator is fixed at " & " (single spaces around &) — toMermaid +// emits exactly that form, so the parser is strict to it. The literal-space +// form avoids CodeQL's polynomial-ReDoS flag on a `\s+&\s+` shape. +const callArrowRegex = /^(s\d+(?: & s\d+)*)\s+==\s+"call"\s+==>\s+(s\d+)$/; // Return arrow (`w_N -. return .-> s_W` with optional `&` target ribbon). -const returnArrowRegex = /^w_(\d+)\s+-\.\s+"return"\s+\.->\s+(s\d+(?:\s+&\s+s\d+)*)$/; +const returnArrowRegex = /^w_(\d+)\s+-\.\s+"return"\s+\.->\s+(s\d+(?: & s\d+)*)$/; // Halt arrow (`w_N -. halt .-> s0`). const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/; // First capture char anchored as \S to avoid polynomial backtracking between @@ -425,7 +428,7 @@ export function fromMermaid(text: string): Graph { const cm = line.match(callArrowRegex); if (cm) { - const sources = cm[1].split(/\s+&\s+/); + const sources = cm[1].split(' & '); const bareId = parseMermaidId(cm[2]); for (const src of sources) { From d27e872a1c2380948bfe42b86318cd350d9a8ba2 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 08:22:48 +0300 Subject: [PATCH 021/118] test(coverage): exercise #174's union-find + halt-arrow + stripBrackets paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #167's coveralls check was failing with -1.8pp (`96.442%`) after #174 merged to v7. The new code added uncovered branches that the existing test suite didn't exercise — none of the binary-numbers algorithms trigger union-find (their bares transition to wrappers, not directly between each other's bodies), and no test hand-built a non-wrapper entry into a frame. Two new tests: 1. **Shared body state (spec Example 6 worked example 2)** — dispatcher with `['1'] → W1`, `['2'] → W2`, `['3'] → X` where X is a body state shared between bares A (in W1) and B (in W2). Exercises: - `ufFind` multi-step walk + path compression - `ufUnion` merging A and B into one component - `frameBareNames` multi-element sort + `callable scope: A ∪ B` label - `hasNonWrapperEntry` triggering the `-. "halt" .->` arrow 2. **Movement bracket malformed** — input passes the `]/[` shape guard but `stripBrackets` catches the missing closing `]` on the move part. Targets the previously unreachable `stripBrackets` throw branch. Coverage delta (v8): Stmts: 97.49 → 98.51 (+1.02) Branch: 94.04 → 95.23 (+1.19) Funcs: 98.85 → 100 (+1.15) Lines: 97.83 → 98.91 (+1.08) Comfortably above the Coveralls baseline that triggered the -1.8pp warning; the integration check on PR #167 should go green once this lands on v7. --- packages/machine/src/utilities/graph.spec.ts | 94 ++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index c516d59..ede42d9 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -354,6 +354,24 @@ describe('fromMermaid error paths', () => { expect(() => fromMermaid(mermaid)).toThrow(/write-cells.*move-cells.*mismatch/); }); + test('throws on a movement bracket that opens but never closes', () => { + // Targets `stripBrackets`'s own throw: writes are well-formed (`[K]`) so + // the `slashIx` guard accepts the label as `[…]/[…]`-shaped, but the + // movement segment `[S` opens without a closing `]`. `stripBrackets` + // catches this even though the earlier label-shape guard didn't. + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0"]]', + ' s0(((halt)))', + ' s1["entry"]', + ' idle([idle])', + ' idle -. enter .-> s1', + " s1 -- \"['0'] → [K]/[S\" --> s0", // moves part `[S` is missing the closing bracket + ].join('\n'); + + expect(() => fromMermaid(mermaid)).toThrow(/malformed bracketed list/); + }); + test('parses backslash-escaped chars inside a bracket (e.g. literal `|` as `\\|`)', () => { // `stripBrackets` walks the inner content character-by-character; when it // hits `\`, it skips the next char (so `\|` is a literal pipe, not the @@ -557,3 +575,79 @@ describe('README diagrams: engine-generated outputs', () => { expect(output).not.toContain('halt frame'); }); }); + +// Spec Example 6 "Shared body state" + worked example 2 (direct entry into the +// union frame): two wrappers W1, W2 with bares A, B whose reach sets overlap +// on a shared body state X, plus a dispatcher with a direct entry to X (a +// non-wrapper transition into the frame). +// +// Exercises: +// - union-find on overlapping reach sets (State.ts ufFind multi-step walk + +// path compression + ufUnion) +// - `callable scope: A ∪ B` subgraph label (graphFormats frameBareNames sort) +// - `-. "halt" .->` demand-emit arrow (graphFormats hasNonWrapperEntry path) +describe('callable-subtree: shared body state forces a union frame', () => { + test('two bares sharing a body state merge into one frame; non-wrapper entry triggers halt arrow', () => { + const alphabet = new Alphabet([' ', '1', '2', '3', 'X']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const {symbol} = tapeBlock; + + // Shared body state X — halts on any symbol. + const X = new State({ + [ifOtherSymbol]: {command: {movement: movements.stay}, nextState: haltState}, + }, 'X'); + + // Bare A — transitions to X. + const A = new State({ + [ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}, + }, 'A'); + + // Bare B — also transitions to X (so reach(A) ∩ reach(B) ⊇ {X}, union triggers). + const B = new State({ + [ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}, + }, 'B'); + + // Targets for the wrappers. + const target1 = new State({ + [ifOtherSymbol]: {command: {movement: movements.stay}, nextState: haltState}, + }, 't1'); + const target2 = new State({ + [ifOtherSymbol]: {command: {movement: movements.stay}, nextState: haltState}, + }, 't2'); + + const W1 = A.withOverriddenHaltState(target1); + const W2 = B.withOverriddenHaltState(target2); + + // Dispatcher chooses path by symbol: '1' → W1, '2' → W2, '3' → X (direct + // entry into the union frame, the non-wrapper entry path that triggers + // the `-. "halt" .->` arrow). + const dispatcher = new State({ + [symbol(['1'])]: {command: {movement: movements.stay}, nextState: W1}, + [symbol(['2'])]: {command: {movement: movements.stay}, nextState: W2}, + [symbol(['3'])]: {command: {movement: movements.stay}, nextState: X}, + }, 'dispatcher'); + + const graph = State.toGraph(dispatcher, tapeBlock); + + // A and B should share a frameId (union-find merged them). + const nodeA = graph.nodes[A.id]; + const nodeB = graph.nodes[B.id]; + const nodeX = graph.nodes[X.id]; + + expect(nodeA.frameId).not.toBeNull(); + expect(nodeA.frameId).toBe(nodeB.frameId); + expect(nodeX.frameId).toBe(nodeA.frameId); + + // Frame id = smallest bare-id in the component (deterministic canonical). + expect(nodeA.frameId).toBe(Math.min(A.id, B.id)); + + // Emit: one union frame with `callable scope: A ∪ B` label, halt arrow + // present (cross-subgraph dispatcher → X entry). + const out = toMermaid(graph); + + expect(out).toContain('callable scope: A ∪ B'); + expect(out).toMatch(/w_\d+ -\. "halt" \.-> s0/); + // Both wrappers call their respective bares. + expect(out).toMatch(/s\d+ == "call" ==> s\d+/g); + }); +}); From 0be485b0bf1830166c45383c83755088d9295e4d Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 08:45:07 +0300 Subject: [PATCH 022/118] chore(release): 7.0.0-alpha.2 --- lerna.json | 2 +- package-lock.json | 8 +-- packages/builder/package.json | 4 +- .../library-binary-numbers-bare/package.json | 4 +- packages/library-binary-numbers/package.json | 4 +- packages/machine/CHANGELOG.md | 50 +++++++++++++++++++ packages/machine/package.json | 2 +- 7 files changed, 62 insertions(+), 12 deletions(-) diff --git a/lerna.json b/lerna.json index 9859cef..a18995b 100644 --- a/lerna.json +++ b/lerna.json @@ -3,6 +3,6 @@ "packages": [ "packages/*" ], - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "$schema": "node_modules/lerna/schemas/lerna-schema.json" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c2cc36c..9049f26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10656,7 +10656,7 @@ }, "packages/builder": { "name": "@turing-machine-js/builder", - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" @@ -10667,7 +10667,7 @@ }, "packages/library-binary-numbers": { "name": "@turing-machine-js/library-binary-numbers", - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" @@ -10678,7 +10678,7 @@ }, "packages/library-binary-numbers-bare": { "name": "@turing-machine-js/library-binary-numbers-bare", - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" @@ -10689,7 +10689,7 @@ }, "packages/machine": { "name": "@turing-machine-js/machine", - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" diff --git a/packages/builder/package.json b/packages/builder/package.json index d48a170..7736037 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/builder", - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "description": "A turing machine builder — declarative state-table construction. Not actively developed by the author; the same state-table pattern is also shown as an inline example in @turing-machine-js/machine's README. Contributions welcome.", "engines": { "npm": ">=7.0.0" @@ -25,7 +25,7 @@ "builder" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.1" + "@turing-machine-js/machine": "^7.0.0-alpha.2" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/builder", diff --git a/packages/library-binary-numbers-bare/package.json b/packages/library-binary-numbers-bare/package.json index b0f03f1..f49e97b 100644 --- a/packages/library-binary-numbers-bare/package.json +++ b/packages/library-binary-numbers-bare/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/library-binary-numbers-bare", - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "description": "Single-number binary arithmetic on a 3-symbol alphabet (blank, 0, 1) — same operations as @turing-machine-js/library-binary-numbers but without ^/$ markers. Side-by-side with the marker-based library for learning the trade-off.", "engines": { "npm": ">=7.0.0" @@ -28,7 +28,7 @@ "teaching" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.1" + "@turing-machine-js/machine": "^7.0.0-alpha.2" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/library-binary-numbers-bare", diff --git a/packages/library-binary-numbers/package.json b/packages/library-binary-numbers/package.json index 7726d42..6ec5374 100644 --- a/packages/library-binary-numbers/package.json +++ b/packages/library-binary-numbers/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/library-binary-numbers", - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "description": "A standard library for working with binary numbers", "engines": { "npm": ">=7.0.0" @@ -27,7 +27,7 @@ "numbers" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.1" + "@turing-machine-js/machine": "^7.0.0-alpha.2" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/library-binary-numbers", diff --git a/packages/machine/CHANGELOG.md b/packages/machine/CHANGELOG.md index e24e8be..860d785 100644 --- a/packages/machine/CHANGELOG.md +++ b/packages/machine/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.2] - 2026-05-21 + +Second v7 pre-release. Refines alpha.1's `toMermaid` wrapped-state emit into the function-call model ([#174](https://github.com/mellonis/turing-machine-js/issues/174)) and adds two construction-time improvements to `withOverriddenHaltState` ([#175](https://github.com/mellonis/turing-machine-js/issues/175), [#176](https://github.com/mellonis/turing-machine-js/issues/176)). Published to npm under the `next` dist-tag: `npm install @turing-machine-js/machine@next`. + +**Pre-release — the API surface may still shift before stable v7.0.0.** Pin to a specific alpha for reproducibility: `@turing-machine-js/machine@7.0.0-alpha.2`. Migration walkthrough at the bottom. + +### Changed + +- **`toMermaid` callable-subtree emit** ([#174](https://github.com/mellonis/turing-machine-js/issues/174)) — supersedes alpha.1's collapsed-bare emit (#138/#139) with a function-call model: + - **Wrapper and bare are separate graph nodes.** The wrapper sits OUTSIDE any subgraph as `[[composite-name]]` (call site). The bare lives INSIDE its callable subtree subgraph as a regular `[name]` node. + - **Subgraph label** changed from `"halt frame"` to `"callable subtree of NAME"` (single bare) or `"callable scope: A ∪ B"` (multi-bare union frame). + - **Halt marker is per-frame**, not per-wrapper. When union-find merges two bares' subtrees (shared body state), they share one halt marker. + - **Arrow vocabulary rewritten**: + - Solid `-->` for regular transitions AND for the wrapper's post-return `--> override` (just an ordinary transition under the call/return model). + - Bold `==> "call"` is RESERVED for the wrapper-to-bare call. `&` ribbon syntax (`s_W1 & s_W2 == "call" ==> s_A`) collapses multiple wrappers that share a bare. + - Dotted `-.->` is reserved for frame-level dispatch: `w_N -. "return" .-> wrapper` (demand-emit), `w_N -. "halt" .-> s0` (demand-emit on non-wrapper entry), `idle -. enter .-> sN` (unchanged). + - The `-. onHalt .->` keyword from alpha.1 is **retired** — replaced by a solid `--> override` arrow. + - **No per-context duplication.** Shared bares (e.g. `library-binary-numbers/minusOne`'s `invertNumber`, called by two wrappers) appear as a single subtree with `& `-joined call arrows from each wrapper. + - **`GraphNode` field changes**: `isWrapped` removed; `isWrapper: boolean`, `bareStateId: number | null`, `frameId: number | null` added. `overriddenHaltStateId` now lives on wrapper nodes only. + - Bytewise round-trip stability now holds for **all** wrapped states, including shared-bare cases (alpha.1 was only stable for simple wrappers). + +- **Nested `.wohs()` chain collapse** ([#176](https://github.com/mellonis/turing-machine-js/issues/176)) — `A.withOverriddenHaltState(t1).withOverriddenHaltState(t2)` is now equivalent to `A.withOverriddenHaltState(t2)`. The chain's inner override (`t1`) is dead at runtime (only the outermost wrapper's override is pushed onto the halt stack on entry — verified empirically). Composite name now reflects runtime behavior: `A(t2)`, not the misleading `A(t1)(t2)`. `withOverriddenHaltState` unwraps `this` to its bare before constructing the new wrapper. + +### Added + +- **`withOverriddenHaltState` memoization** ([#175](https://github.com/mellonis/turing-machine-js/issues/175)) — calls with the same `(bare, override)` pair return the literally-same `State` instance. Backed by a two-level `WeakMap` keyed by the bare with `WeakRef`-valued entries, so cached wrappers can be GC'd when nothing else holds them. Composes with #176's chain-collapse: `A.wohs(t1).wohs(t2)` and `A.wohs(t2)` both resolve to the same instance. + +- **Spec doc** at `docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md` capturing the callable-subtree model design (worked examples for simple wrappers, PostMachine subroutines, shared-bare wrappers, self-wrapping, nested chains, Reference cycles, shared body states). + +### Migration from alpha.1 + +Three breaking changes vs alpha.1, all in the visualization layer: + +**1. Mermaid format** — alpha.1 Mermaid strings (with `-. onHalt .->` edges or `[[bare]]` inside subgraphs) will NOT parse with the new `fromMermaid`. The format is one-way: re-emit via `toMermaid(toGraph(state, tapeBlock))` on alpha.2 to regenerate. + +**2. `Graph` data shape** — `GraphNode.isWrapped` removed; replaced by `isWrapper: boolean`, `bareStateId: number | null`, `frameId: number | null`. If you store serialized `Graph` JSON from alpha.1, re-emit on alpha.2. + +**3. Wrapper composite name in nested chains** — `A.wohs(t1).wohs(t2)` was `A(t1)(t2)` in alpha.1; under #176 it's now `A(t2)`. Code that parsed the composite name to extract intermediate overrides will need to adapt (those overrides were never actually pushed at runtime — alpha.1's name was misleading). + +`withOverriddenHaltState` memoization (#175) is fully additive — same arguments now return the same instance, but no observable behavior changes for code that doesn't rely on instance identity. + +### Out of v7-alpha.2 (still pending for stable v7.0.0) + +- **[#102](https://github.com/mellonis/turing-machine-js/issues/102)** — debugger step-in / step-out / step-over primitives. Additive — won't change any existing API. Will land in `v7.0.0-alpha.3` or stable `v7.0.0`. +- **[#180](https://github.com/mellonis/turing-machine-js/issues/180)** — extract `State.toGraph`/`fromGraph` to its own module. Internal refactor; no API change. + +### Compatibility + +- Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.1` → `^7.0.0-alpha.2` on `@turing-machine-js/builder`, `@turing-machine-js/library-binary-numbers`, `@turing-machine-js/library-binary-numbers-bare`. + ## [7.0.0-alpha.1] - 2026-05-21 First v7 pre-release. Consolidates the composition-representation overhaul landed across [#149](https://github.com/mellonis/turing-machine-js/issues/149), [#148](https://github.com/mellonis/turing-machine-js/issues/148), [#138](https://github.com/mellonis/turing-machine-js/issues/138), and [#139](https://github.com/mellonis/turing-machine-js/issues/139). Published to npm under the `next` dist-tag: `npm install @turing-machine-js/machine@next`. diff --git a/packages/machine/package.json b/packages/machine/package.json index 008f7ba..514ffb7 100644 --- a/packages/machine/package.json +++ b/packages/machine/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/machine", - "version": "7.0.0-alpha.1", + "version": "7.0.0-alpha.2", "description": "A convenient Turing machine", "engines": { "npm": ">=7.0.0" From 231729f480d965eea123aec54b36538ecd165e69 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 14:54:39 +0300 Subject: [PATCH 023/118] =?UTF-8?q?docs(spec):=20worked=20union=20shapes?= =?UTF-8?q?=20(A=E2=88=AAB,=20A=E2=88=AAB=E2=88=AAC,=20transitive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three engine-emitted Mermaid examples appended to the "Shared body state" edge-case section of the callable-subtree spec: - Case 1 (`A ∪ B`): two bares, one shared body state. Two `==> call` arrows into the same frame; one `& `-joined `return` ribbon. - Case 2 (`A ∪ B ∪ C`): three bares, all share X. Three `call` arrows, one three-way `return` ribbon. Same frame, one halt marker. - Case 3 (`(A ∪ B) ∪ C` transitive): A shares X with B, A shares Y with C, B and C share nothing direct. Union-find's transitive-closure step bundles all three into one scope. Output is real `toMermaid` emit from probe machines (not hand-drawn), so the examples stay in sync with the engine implementation. --- ...026-05-21-halt-frame-transitive-closure.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md b/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md index 959024f..f3ddd0c 100644 --- a/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md +++ b/docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md @@ -568,6 +568,158 @@ Cross-subgraph entry `s_disp -- ['3'] --> s_X` triggers the `halt` arrow to emit The union model degrades gracefully — adding direct entry to a state inside the union just flips the `halt` arrow on. No new structural concept needed. +### Worked union shapes — engine-emitted Mermaid + +Real `toMermaid` output (post-#174) for three increasingly merged union shapes. Each test machine has a dispatcher routing by tape symbol into per-bare wrappers `W = bare.withOverriddenHaltState(target)`. The bares' transitions land on shared body states that drive the union-find merge. + +#### Case 1: `A ∪ B` (two bares, one shared state) + +```text +reach(A) = {A, X} +reach(B) = {B, X} +A ∩ B = {X} → union(A, B) +``` + +```mermaid +flowchart TD +%% alphabets: [[" ","1","2"]] + s0(((halt))) + s4["t1"] + s5["t2"] + s8["dispatcher"] + s6[["A(t1)"]] + s7[["B(t2)"]] + idle([idle]) + subgraph w_2["callable scope: A ∪ B"] + s1["X"] + s2["A"] + s3["B"] + c2(((halt))) + end + idle -. enter .-> s8 + s6 == "call" ==> s2 + s7 == "call" ==> s3 + w_2 -. "return" .-> s6 & s7 + s6 --> s4 + s7 --> s5 + s1 -- "[*] → [K]/[S]" --> c2 + s2 -- "[*] → [K]/[R]" --> s1 + s3 -- "[*] → [K]/[R]" --> s1 + s4 -- "[*] → [K]/[S]" --> s0 + s5 -- "[*] → [K]/[S]" --> s0 + s8 -- "['1'] → [K]/[S]" --> s6 + s8 -- "['2'] → [K]/[S]" --> s7 +``` + +Two `==> call` arrows into the same frame; one `& `-joined `return` ribbon back to both wrappers. + +#### Case 2: `A ∪ B ∪ C` (three bares, all share X directly) + +```text +reach(A) = {A, X} +reach(B) = {B, X} +reach(C) = {C, X} +Every pair intersects on X → all merge. +``` + +```mermaid +flowchart TD +%% alphabets: [[" ","1","2","3"]] + s0(((halt))) + s13["t1"] + s14["t2"] + s15["t3"] + s19["dispatcher"] + s16[["A(t1)"]] + s17[["B(t2)"]] + s18[["C(t3)"]] + idle([idle]) + subgraph w_10["callable scope: A ∪ B ∪ C"] + s9["X"] + s10["A"] + s11["B"] + s12["C"] + c10(((halt))) + end + idle -. enter .-> s19 + s16 == "call" ==> s10 + s17 == "call" ==> s11 + s18 == "call" ==> s12 + w_10 -. "return" .-> s16 & s17 & s18 + s16 --> s13 + s17 --> s14 + s18 --> s15 + s9 -- "[*] → [K]/[S]" --> c10 + s10 -- "[*] → [K]/[R]" --> s9 + s11 -- "[*] → [K]/[R]" --> s9 + s12 -- "[*] → [K]/[R]" --> s9 + s13 -- "[*] → [K]/[S]" --> s0 + s14 -- "[*] → [K]/[S]" --> s0 + s15 -- "[*] → [K]/[S]" --> s0 + s19 -- "['1'] → [K]/[S]" --> s16 + s19 -- "['2'] → [K]/[S]" --> s17 + s19 -- "['3'] → [K]/[S]" --> s18 +``` + +Three `call` arrows, one three-way `& `-joined `return` ribbon. Same frame, one halt marker. + +#### Case 3: `(A ∪ B) ∪ C` — transitive (B and C don't share anything direct) + +```text +reach(A) = {A, X, Y} +reach(B) = {B, X} +reach(C) = {C, Y} +A ∩ B = {X} → union(A, B) +A ∩ C = {Y} → union(A, C) ← C joins {A, B} via A +B ∩ C = ∅ ← but they end up in the same frame anyway, transitively +``` + +```mermaid +flowchart TD +%% alphabets: [[" ","1","2","3"]] + s0(((halt))) + s25["t1"] + s26["t2"] + s27["t3"] + s31["dispatcher"] + s28[["A(t1)"]] + s29[["B(t2)"]] + s30[["C(t3)"]] + idle([idle]) + subgraph w_22["callable scope: A ∪ B ∪ C"] + s20["X"] + s21["Y"] + s22["A"] + s23["B"] + s24["C"] + c22(((halt))) + end + idle -. enter .-> s31 + s28 == "call" ==> s22 + s29 == "call" ==> s23 + s30 == "call" ==> s24 + w_22 -. "return" .-> s28 & s29 & s30 + s28 --> s25 + s29 --> s26 + s30 --> s27 + s20 -- "[*] → [K]/[S]" --> c22 + s21 -- "[*] → [K]/[S]" --> c22 + s22 -- "['1'] → [K]/[R]" --> s20 + s22 -- "['2'] → [K]/[R]" --> s21 + s23 -- "[*] → [K]/[R]" --> s20 + s24 -- "[*] → [K]/[R]" --> s21 + s25 -- "[*] → [K]/[S]" --> s0 + s26 -- "[*] → [K]/[S]" --> s0 + s27 -- "[*] → [K]/[S]" --> s0 + s31 -- "['1'] → [K]/[S]" --> s28 + s31 -- "['2'] → [K]/[S]" --> s29 + s31 -- "['3'] → [K]/[S]" --> s30 +``` + +Two shared body states (X, Y). B reaches X only; C reaches Y only. They never see each other directly — yet they live in the same frame because A bridges them. The union-find transitive-closure step is what bundles them. + +**Reading rule encoded in the emit:** one subgraph = one union component. The label tells you how many bares share the scope. `& `-joined `call` / `return` arrows tell you how many wrappers are in play (always one per wrapper; the union collapses bares, never wrappers). + ## Data model changes The callable-subtree model **un-collapses what v7 alpha.1 collapsed**. In alpha.1, each `withOverriddenHaltState` wrapper produces ONE `GraphNode` representing both the wrapper and its bare (the bare is "collapsed onto" the wrapper's id, with `isWrapped: true`). Under the new model, **wrappers and bares are separate `GraphNode` instances**: From 401b80a193b2a3d1458cbf6fc46b57ee18146841 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 15:03:58 +0300 Subject: [PATCH 024/118] test(spec): invariant + snapshot tests for the worked union shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new tests in `graph.spec.ts > spec doc: worked union shapes are real engine emit`, one per case (A∪B, A∪B∪C, transitive (A∪B)∪C). Each test combines: 1. **Invariant assertions** — model-level rules the union-find algorithm must preserve. Named expectations point at the broken rule when they fail: - All bares in a case share one `frameId` - Frame-label string is correct ("callable scope: A ∪ B" etc.) - Number of `== "call" ==>` arrows = number of wrappers (each wrapper has a distinct bare, so no call-side ribbon collapse) - Return arrow is one `& `-joined ribbon over all wrappers - No `-. "halt" .->` arrow (no non-wrapper entry to the frame) 2. **Snapshot test** — pins the exact emit shape (with state IDs normalized via `stripIds`, same approach as `test/round-trip.spec.ts`). Snapshot failure = the spec doc's "this is real engine emit" claim went stale, either because the engine drifted cosmetically or because the doc itself was edited out-of-sync. The two layers catch different failure modes: - Invariant alone: misses cosmetic drift between doc and engine. - Snapshot alone: fails opaquely on rule breaks ("strings differ"). - Both: when invariant fails, the failure NAMES the broken rule. When snapshot fails but invariants pass, drift is purely cosmetic. Probe-source for these examples is `/tmp/union-probe.mjs` (not committed; the source-of-truth is now the test fixtures). --- packages/machine/src/utilities/graph.spec.ts | 256 +++++++++++++++++++ 1 file changed, 256 insertions(+) diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index ede42d9..c99e7b8 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -651,3 +651,259 @@ describe('callable-subtree: shared body state forces a union frame', () => { expect(out).toMatch(/s\d+ == "call" ==> s\d+/g); }); }); + +// Spec-doc invariants + snapshots for the three worked union shapes in +// `docs/superpowers/specs/2026-05-21-halt-frame-transitive-closure.md`'s +// "Worked union shapes — engine-emitted Mermaid" section. +// +// The spec doc claims its Mermaid blocks are real engine emit (not +// hand-drawn). These tests enforce that claim two ways: +// 1. Invariant tests assert the structural rules the model promises +// (frame merging, ribbon collapse, demand-emit arrows). Named tests +// → failures point at the broken rule, not at a byte diff. +// 2. Snapshot tests (with id normalization, same shape as the +// round-trip test) pin the cosmetic emit. Failures here catch drift +// between the doc and the engine — either rerun the probe + update +// the doc, or revert the unintended emit change. +// +// Normalize all `s\d+`, `c\d+`, `w_\d+` to `sX`/`cX`/`w_X` since global +// State.#id is shared across tests and isn't stable across test-ordering +// changes. Same normalization used by `test/round-trip.spec.ts`. +function stripIds(mermaid: string): string { + return mermaid + .replace(/\bs\d+\b/g, 'sX') + .replace(/\bc\d+\b/g, 'cX') + .replace(/\bw_\d+\b/g, 'w_X'); +} + +describe('spec doc: worked union shapes are real engine emit', () => { + // Reusable target factory — each test needs N small halting States as + // wrapper targets. Inlining the State construction directly would clutter + // each test. + const haltingTarget = (name: string) => new State({ + [ifOtherSymbol]: {command: {movement: movements.stay}, nextState: haltState}, + }, name); + + test('Case 1: A ∪ B (two bares, one shared body state X)', () => { + const alphabet = new Alphabet([' ', '1', '2']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const {symbol} = tapeBlock; + + const X = haltingTarget('X'); + const A = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}}, 'A'); + const B = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}}, 'B'); + const W1 = A.withOverriddenHaltState(haltingTarget('t1')); + const W2 = B.withOverriddenHaltState(haltingTarget('t2')); + const dispatcher = new State({ + [symbol(['1'])]: {command: {movement: movements.stay}, nextState: W1}, + [symbol(['2'])]: {command: {movement: movements.stay}, nextState: W2}, + }, 'dispatcher'); + + const graph = State.toGraph(dispatcher, tapeBlock); + const out = toMermaid(graph); + + // Invariants — A, B distinct bares so call arrows are NOT ribbon-collapsed + // (the `& ` ribbon on calls collapses only wrappers SHARING a bare). + expect(graph.nodes[A.id].frameId).toBe(graph.nodes[B.id].frameId); + expect(graph.nodes[X.id].frameId).toBe(graph.nodes[A.id].frameId); + expect(out).toContain('"callable scope: A ∪ B"'); + expect(out.match(/== "call" ==>/g)).toHaveLength(2); // one per wrapper + expect(out).toMatch(/w_\d+ -\. "return" \.-> s\d+ & s\d+/); // ribbon on return side + expect(out).not.toMatch(/-\. "halt" \.->/); // no non-wrapper entry to frame + + // Snapshot (id-normalized). Doubles as the cosmetic-drift detector for + // the matching block in the spec doc. + const expected = [ + 'flowchart TD', + '%% alphabets: [[" ","1","2"]]', + ' sX(((halt)))', + ' sX["t1"]', + ' sX["t2"]', + ' sX["dispatcher"]', + ' sX[["A(t1)"]]', + ' sX[["B(t2)"]]', + ' idle([idle])', + ' subgraph w_X["callable scope: A ∪ B"]', + ' sX["X"]', + ' sX["A"]', + ' sX["B"]', + ' cX(((halt)))', + ' end', + ' idle -. enter .-> sX', + ' sX == "call" ==> sX', + ' sX == "call" ==> sX', + ' w_X -. "return" .-> sX & sX', + ' sX --> sX', + ' sX --> sX', + ' sX -- "[*] → [K]/[S]" --> cX', + ' sX -- "[*] → [K]/[R]" --> sX', + ' sX -- "[*] → [K]/[R]" --> sX', + ' sX -- "[*] → [K]/[S]" --> sX', + ' sX -- "[*] → [K]/[S]" --> sX', + " sX -- \"['1'] → [K]/[S]\" --> sX", + " sX -- \"['2'] → [K]/[S]\" --> sX", + ].join('\n'); + + expect(stripIds(out)).toBe(expected); + }); + + test('Case 2: A ∪ B ∪ C (three bares, all share X directly)', () => { + const alphabet = new Alphabet([' ', '1', '2', '3']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const {symbol} = tapeBlock; + + const X = haltingTarget('X'); + const A = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}}, 'A'); + const B = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}}, 'B'); + const C = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}}, 'C'); + const W1 = A.withOverriddenHaltState(haltingTarget('t1')); + const W2 = B.withOverriddenHaltState(haltingTarget('t2')); + const W3 = C.withOverriddenHaltState(haltingTarget('t3')); + const dispatcher = new State({ + [symbol(['1'])]: {command: {movement: movements.stay}, nextState: W1}, + [symbol(['2'])]: {command: {movement: movements.stay}, nextState: W2}, + [symbol(['3'])]: {command: {movement: movements.stay}, nextState: W3}, + }, 'dispatcher'); + + const graph = State.toGraph(dispatcher, tapeBlock); + const out = toMermaid(graph); + + // Invariants + const frameA = graph.nodes[A.id].frameId; + + expect(frameA).not.toBeNull(); + expect(graph.nodes[B.id].frameId).toBe(frameA); + expect(graph.nodes[C.id].frameId).toBe(frameA); + expect(graph.nodes[X.id].frameId).toBe(frameA); + expect(out).toContain('"callable scope: A ∪ B ∪ C"'); + expect(out.match(/== "call" ==>/g)).toHaveLength(3); // one per wrapper, no ribbon + expect(out).toMatch(/w_\d+ -\. "return" \.-> s\d+ & s\d+ & s\d+/); + expect(out).not.toMatch(/-\. "halt" \.->/); + + const expected = [ + 'flowchart TD', + '%% alphabets: [[" ","1","2","3"]]', + ' sX(((halt)))', + ' sX["t1"]', + ' sX["t2"]', + ' sX["t3"]', + ' sX["dispatcher"]', + ' sX[["A(t1)"]]', + ' sX[["B(t2)"]]', + ' sX[["C(t3)"]]', + ' idle([idle])', + ' subgraph w_X["callable scope: A ∪ B ∪ C"]', + ' sX["X"]', + ' sX["A"]', + ' sX["B"]', + ' sX["C"]', + ' cX(((halt)))', + ' end', + ' idle -. enter .-> sX', + ' sX == "call" ==> sX', + ' sX == "call" ==> sX', + ' sX == "call" ==> sX', + ' w_X -. "return" .-> sX & sX & sX', + ' sX --> sX', + ' sX --> sX', + ' sX --> sX', + ' sX -- "[*] → [K]/[S]" --> cX', + ' sX -- "[*] → [K]/[R]" --> sX', + ' sX -- "[*] → [K]/[R]" --> sX', + ' sX -- "[*] → [K]/[R]" --> sX', + ' sX -- "[*] → [K]/[S]" --> sX', + ' sX -- "[*] → [K]/[S]" --> sX', + ' sX -- "[*] → [K]/[S]" --> sX', + " sX -- \"['1'] → [K]/[S]\" --> sX", + " sX -- \"['2'] → [K]/[S]\" --> sX", + " sX -- \"['3'] → [K]/[S]\" --> sX", + ].join('\n'); + + expect(stripIds(out)).toBe(expected); + }); + + test('Case 3: (A ∪ B) ∪ C — transitive (A bridges B and C via X and Y)', () => { + const alphabet = new Alphabet([' ', '1', '2', '3']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const {symbol} = tapeBlock; + + const X = haltingTarget('X'); + const Y = haltingTarget('Y'); + // A has TWO transitions — one to X (overlapping B's reach), one to Y + // (overlapping C's reach). B and C share nothing directly. + const A = new State({ + [symbol(['1'])]: {command: {movement: movements.right}, nextState: X}, + [symbol(['2'])]: {command: {movement: movements.right}, nextState: Y}, + }, 'A'); + const B = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}}, 'B'); + const C = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: Y}}, 'C'); + const W1 = A.withOverriddenHaltState(haltingTarget('t1')); + const W2 = B.withOverriddenHaltState(haltingTarget('t2')); + const W3 = C.withOverriddenHaltState(haltingTarget('t3')); + const dispatcher = new State({ + [symbol(['1'])]: {command: {movement: movements.stay}, nextState: W1}, + [symbol(['2'])]: {command: {movement: movements.stay}, nextState: W2}, + [symbol(['3'])]: {command: {movement: movements.stay}, nextState: W3}, + }, 'dispatcher'); + + const graph = State.toGraph(dispatcher, tapeBlock); + const out = toMermaid(graph); + + // Invariants — transitive merge is the load-bearing rule. + const frameA = graph.nodes[A.id].frameId; + + expect(frameA).not.toBeNull(); + expect(graph.nodes[B.id].frameId).toBe(frameA); + expect(graph.nodes[C.id].frameId).toBe(frameA); + expect(graph.nodes[X.id].frameId).toBe(frameA); + expect(graph.nodes[Y.id].frameId).toBe(frameA); // critical: B-C don't share directly + expect(out).toContain('"callable scope: A ∪ B ∪ C"'); + expect(out.match(/== "call" ==>/g)).toHaveLength(3); + expect(out).toMatch(/w_\d+ -\. "return" \.-> s\d+ & s\d+ & s\d+/); + expect(out).not.toMatch(/-\. "halt" \.->/); + + const expected = [ + 'flowchart TD', + '%% alphabets: [[" ","1","2","3"]]', + ' sX(((halt)))', + ' sX["t1"]', + ' sX["t2"]', + ' sX["t3"]', + ' sX["dispatcher"]', + ' sX[["A(t1)"]]', + ' sX[["B(t2)"]]', + ' sX[["C(t3)"]]', + ' idle([idle])', + ' subgraph w_X["callable scope: A ∪ B ∪ C"]', + ' sX["X"]', + ' sX["Y"]', + ' sX["A"]', + ' sX["B"]', + ' sX["C"]', + ' cX(((halt)))', + ' end', + ' idle -. enter .-> sX', + ' sX == "call" ==> sX', + ' sX == "call" ==> sX', + ' sX == "call" ==> sX', + ' w_X -. "return" .-> sX & sX & sX', + ' sX --> sX', + ' sX --> sX', + ' sX --> sX', + ' sX -- "[*] → [K]/[S]" --> cX', + ' sX -- "[*] → [K]/[S]" --> cX', + " sX -- \"['1'] → [K]/[R]\" --> sX", + " sX -- \"['2'] → [K]/[R]\" --> sX", + ' sX -- "[*] → [K]/[R]" --> sX', + ' sX -- "[*] → [K]/[R]" --> sX', + ' sX -- "[*] → [K]/[S]" --> sX', + ' sX -- "[*] → [K]/[S]" --> sX', + ' sX -- "[*] → [K]/[S]" --> sX', + " sX -- \"['1'] → [K]/[S]\" --> sX", + " sX -- \"['2'] → [K]/[S]\" --> sX", + " sX -- \"['3'] → [K]/[S]\" --> sX", + ].join('\n'); + + expect(stripIds(out)).toBe(expected); + }); +}); From 3a7107013542a83b2bf8d14515233d4ec7b903e2 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 17:37:46 +0300 Subject: [PATCH 025/118] feat(state): first-class tag/label support on State + GraphNode + Mermaid emit (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an out-of-band annotation channel on State that survives toGraph / fromGraph / toMermaid / fromMermaid round-trips, drives Mermaid color classes, and renders tag names inline in node labels via `
` for universal renderer support. State API --------- - `state.tag(...tags: string[]): this` — chainable, idempotent. - `state.untag(...tags: string[]): this` — chainable, no-op on absent. - `state.tags: readonly string[]` — frozen snapshot, insertion order. Tags live on the State INSTANCE, not on the shared `#symbolToDataMap`. Under engine #175 memoization, `A.wohs(t1)` and `A.wohs(t2)` are distinct wrapper instances; tagging one doesn't propagate to siblings sharing the same bare. Verified in State.spec.ts. GraphNode + serialization ------------------------- - `GraphNode.tags: string[]` — round-trips through toGraph/fromGraph. - toMermaid emits: - Node labels with inline `
tag1, tag2` suffix (visible in any renderer — universal Mermaid `
` works across mermaid.js, the live editor, machines-demo). - `classDef tag_ fill:#...,stroke:#...` per unique tag, chosen from a 6-color hash-based palette. - `class s5,s6 tag_` listing all node IDs carrying the tag (comma-joined for compact emit). - fromMermaid parses the label's `
` suffix as the source of truth for tags; `class` lines are decorative and discarded on parse (they regenerate on next toMermaid emit from the tag set). - Tag-name sanitization: chars outside [A-Za-z0-9_-] replaced with `_` in CSS classDef identifiers. Labels preserve raw tag names. Sharing semantics ----------------- - Tag-set is sidecar; doesn't affect equivalentOn or transition lookup. - Wrappers from `withOverriddenHaltState` start with an empty tag set (do not inherit from bare). Caller explicitly tags as desired. Files ----- - packages/machine/src/classes/State.ts — `#tags` field + public API + toGraph reads + fromGraph applies tags - packages/machine/src/utilities/graph.ts — `GraphNode.tags: string[]` - packages/machine/src/utilities/graphFormats.ts — `labelOf()` helper for `
`-embedded emit; `emitTagAnnotations()` for classDef/class; `splitLabelTags()` for parse; classDef + class line regexes - Tests: 8 in State.spec.ts; 4 toMermaid + 2 fromMermaid in graph.spec.ts; hand-built GraphNode fixtures across 3 spec files updated to include `tags: []` (bulk sed) Verification ------------ - npm test — 456/456 pass (450 prior + 14 new tag tests) - npm run lint — clean - npm run typecheck — clean - npm run test:coverage — 98.42 stmts / 95.08 branch / 100 funcs / 99.04 lines (above engine floors of 97/90/95/97) Closes #186 (carry on the release PR — v7-branch merges don't auto-close). --- packages/machine/src/classes/State.spec.ts | 97 ++++++++++- packages/machine/src/classes/State.ts | 62 +++++++ packages/machine/src/utilities/graph.spec.ts | 114 ++++++++++++- packages/machine/src/utilities/graph.ts | 5 + .../machine/src/utilities/graphFormats.ts | 156 +++++++++++++++++- .../src/utilities/introspection.spec.ts | 28 ++-- 6 files changed, 430 insertions(+), 32 deletions(-) diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index a912171..9dfbb95 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -332,6 +332,95 @@ describe('State.toGraph — unbound Reference', () => { }); }); +describe('State tags (#186)', () => { + test('a fresh State has an empty tags array', () => { + const s = new State({[ifOtherSymbol]: {nextState: haltState}}); + + expect(s.tags).toEqual([]); + }); + + test('tag() adds tags and is chainable', () => { + const s = new State({[ifOtherSymbol]: {nextState: haltState}}); + + const ret = s.tag('hot', 'sampled'); + + expect(ret).toBe(s); + expect(s.tags).toEqual(['hot', 'sampled']); + }); + + test('tag() de-duplicates repeated tags', () => { + const s = new State({[ifOtherSymbol]: {nextState: haltState}}); + + s.tag('hot'); + s.tag('hot', 'cold'); + + expect(s.tags).toEqual(['hot', 'cold']); + }); + + test('untag() removes tags and is chainable', () => { + const s = new State({[ifOtherSymbol]: {nextState: haltState}}); + + s.tag('hot', 'sampled', 'cold'); + const ret = s.untag('hot'); + + expect(ret).toBe(s); + expect(s.tags).toEqual(['sampled', 'cold']); + }); + + test('untag() of a non-present tag is a no-op', () => { + const s = new State({[ifOtherSymbol]: {nextState: haltState}}); + + s.tag('a'); + s.untag('not-present'); + + expect(s.tags).toEqual(['a']); + }); + + test('tags getter returns a frozen snapshot — caller cannot mutate', () => { + const s = new State({[ifOtherSymbol]: {nextState: haltState}}); + + s.tag('a'); + const snapshot = s.tags; + + expect(() => { + (snapshot as unknown as string[]).push('b'); + }).toThrow(); + + expect(s.tags).toEqual(['a']); + }); + + test('tags are scoped to the wrapper instance, not the shared bare (#175 sharing)', () => { + // Engine #175 memoization means `A.wohs(t1)` and `A.wohs(t2)` produce + // distinct wrapper instances even though they share the same `#symbolToDataMap`. + // Tags must live on the wrapper instance — tagging one wrapper must NOT + // propagate to siblings sharing the same bare. + const A = new State({[ifOtherSymbol]: {nextState: haltState}}, 'A'); + const t1 = new State({[ifOtherSymbol]: {nextState: haltState}}, 't1'); + const t2 = new State({[ifOtherSymbol]: {nextState: haltState}}, 't2'); + + const W1 = A.withOverriddenHaltState(t1); + const W2 = A.withOverriddenHaltState(t2); + + W1.tag('hot'); + + expect(W1.tags).toEqual(['hot']); + expect(W2.tags).toEqual([]); // no leak across wrappers sharing a bare + expect(A.tags).toEqual([]); // no leak to the bare either + }); + + test('haltState is not specially excluded from tagging', () => { + // No reason to forbid tagging haltState. Engine doesn't impose a tag + // semantic, so tagging the halt singleton is the consumer's call. + haltState.tag('halt-debug-marker'); + + expect(haltState.tags).toContain('halt-debug-marker'); + + // Cleanup so other tests don't see the residue. + haltState.untag('halt-debug-marker'); + expect(haltState.tags).not.toContain('halt-debug-marker'); + }); +}); + describe('State.fromGraph — cyclic override-halt chain', () => { test('throws when the override-halt graph has a cycle', () => { // Under the v7 callable-subtree model, override-halt chains live on @@ -343,10 +432,10 @@ describe('State.fromGraph — cyclic override-halt chain', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'wA', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: true, bareStateId: 3, frameId: null}, - 2: {id: 2, name: 'wB', isHalt: false, transitions: [], overriddenHaltStateId: 1, isHaltMarker: false, isWrapper: true, bareStateId: 3, frameId: null}, - 3: {id: 3, name: 'shared', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 1: {id: 1, name: 'wA', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: true, bareStateId: 3, frameId: null, tags: []}, + 2: {id: 2, name: 'wB', isHalt: false, transitions: [], overriddenHaltStateId: 1, isHaltMarker: false, isWrapper: true, bareStateId: 3, frameId: null, tags: []}, + 3: {id: 3, name: 'shared', isHalt: false, transitions: [dummyTransition], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, }, }; diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 10491b5..81e91ed 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -102,6 +102,15 @@ export default class State { // a runtime concern, not part of the structural graph. #debugRef: { current: DebugConfig | null } = {current: null}; + // Out-of-band tags applied to this State (#186). Tags are visualization + // and debugger-tooling metadata — they don't affect runtime transition + // lookup or `equivalentOn` comparisons. Stored as a Set for de-duplication; + // exposed via the `tags` getter as a frozen array snapshot. Lives on the + // State INSTANCE so wrappers (from `withOverriddenHaltState`) carry tags + // independently of their bare's tag set — see the #175 sharing test in + // State.spec.ts. + #tags: Set = new Set(); + constructor(stateDefinition: Record[0] | ConstructorParameters[0][], nextState?: State | Reference, @@ -216,6 +225,41 @@ export default class State { this.#debugRef.current = new DebugConfig(this, value); } + /** + * Add one or more tags to this State (#186). Tags are out-of-band metadata + * used by visualization (`toMermaid` emits `classDef`/`class` lines) and + * debugger tooling — they don't affect runtime transition lookup, + * `equivalentOn` comparisons, or any structural identity. Chainable. + */ + tag(...tags: string[]): this { + for (const t of tags) { + this.#tags.add(t); + } + + return this; + } + + /** + * Remove one or more tags from this State (#186). Untagging a tag the + * State doesn't carry is a no-op. Chainable. + */ + untag(...tags: string[]): this { + for (const t of tags) { + this.#tags.delete(t); + } + + return this; + } + + /** + * Frozen snapshot of this State's current tags (#186). The returned array + * is `Object.freeze`d — mutating it throws in strict mode (which TS-emitted + * code uses). Order matches insertion order of the underlying Set. + */ + get tags(): readonly string[] { + return Object.freeze([...this.#tags]); + } + /** @internal — invoked by DebugConfig setters via module-private symbol. */ [validateDebugFilter]( fieldName: 'before' | 'after', @@ -429,6 +473,7 @@ export default class State { frameId: null, transitions: [], overriddenHaltStateId: null, + tags: [...state.#tags], }; } @@ -450,6 +495,7 @@ export default class State { frameId: null, transitions: [], overriddenHaltStateId: overrideTarget.#id, + tags: [...state.#tags], }; bareIds.add(bareState.#id); @@ -470,6 +516,7 @@ export default class State { frameId: null, transitions: [], overriddenHaltStateId: null, + tags: [...state.#tags], }; nodes[state.#id] = node; @@ -515,6 +562,7 @@ export default class State { frameId: null, transitions: [], overriddenHaltStateId: null, + tags: [...haltState.#tags], }; } @@ -672,6 +720,7 @@ export default class State { frameId, transitions: [], overriddenHaltStateId: null, + tags: [], }; } @@ -764,6 +813,11 @@ export default class State { const bare = new State(stateDefinition); bare.#name = node.name; + + if (node.tags.length > 0) { + bare.tag(...node.tags); + } + bareStates[nodeId] = bare; } @@ -799,6 +853,14 @@ export default class State { const override = getFinal(node.overriddenHaltStateId!); state = bare.withOverriddenHaltState(override); + + // Apply wrapper-scoped tags (#186). Tags don't leak across wrappers + // sharing a bare — the wrapper instance owns its own tag set, and + // engine #175 memoization returns the same instance for the same + // (bare, override) pair, so this is idempotent across rebuilds. + if (node.tags.length > 0) { + state.tag(...node.tags); + } } else { state = bareStates[nodeId]; } diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index c99e7b8..72255c2 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -118,9 +118,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: [], transitions: [ {pattern: "'0'", command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, {pattern: "'1'", command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, @@ -147,8 +147,8 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isHaltMarker: false, isWrapper: true, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 1: {id: 1, name: 'wrapper', isHalt: false, transitions: [], overriddenHaltStateId: 0, isHaltMarker: false, isWrapper: true, bareStateId: null, frameId: null, tags: []}, }, }); @@ -161,9 +161,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 1: {id: 1, name: 'entry', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 2: {id: 2, name: 'helper', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, }, }); @@ -175,9 +175,9 @@ describe('toMermaid', () => { initialId: 1, alphabets: [[' ', '0'], [' ', 'a']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, 1: { - id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'entry', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: [], transitions: [{ pattern: "'0','a'", command: [{symbol: "'0'", movement: 'R'}, {symbol: "'a'", movement: 'L'}], @@ -907,3 +907,99 @@ describe('spec doc: worked union shapes are real engine emit', () => { expect(stripIds(out)).toBe(expected); }); }); + +describe('toMermaid: tags (#186)', () => { + test('no tags → no classDef / class lines', () => { + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 'plain'); + + const out = toMermaid(State.toGraph(s, tapeBlock)); + + expect(out).not.toContain('classDef'); + expect(out).not.toContain('class '); + }); + + test('one tag → one classDef + matching class assignment', () => { + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 'tagged').tag('hot'); + + const out = toMermaid(State.toGraph(s, tapeBlock)); + + expect(out).toMatch(/classDef tag_hot /); + expect(out).toMatch(/class s\d+ tag_hot/); + }); + + test('multiple states sharing a tag → one classDef, comma-joined ids in class', () => { + const alphabet = new Alphabet([' ', '0', '1']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const {symbol} = tapeBlock; + const b = new State({ + [symbol(['1'])]: {nextState: haltState}, + }, 'b').tag('hot'); + const a = new State({ + [symbol(['0'])]: {nextState: b}, + }, 'a').tag('hot'); + + const out = toMermaid(State.toGraph(a, tapeBlock)); + + expect((out.match(/classDef tag_hot /g) ?? []).length).toBe(1); + // Two states share the tag — emitted on one `class` line with + // comma-joined ids. + expect(out).toMatch(/class s\d+,s\d+ tag_hot/); + }); + + test('multiple tags on one state → one class line per tag', () => { + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 'tagged').tag('hot', 'sampled'); + + const out = toMermaid(State.toGraph(s, tapeBlock)); + + expect(out).toMatch(/classDef tag_hot /); + expect(out).toMatch(/classDef tag_sampled /); + expect(out).toMatch(/class s\d+ tag_hot/); + expect(out).toMatch(/class s\d+ tag_sampled/); + }); +}); + +describe('fromMermaid: tags (#186)', () => { + test('parses classDef + class lines back into GraphNode.tags', () => { + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0"]]', + ' s0(((halt)))', + ' s1["a"]', + ' idle([idle])', + ' idle -. enter .-> s1', + " s1 -- \"['0'] → [K]/[S]\" --> s0", + ' classDef tag_hot fill:#fef3c7', + ' class s1 tag_hot', + ].join('\n'); + + const graph = fromMermaid(mermaid); + + expect(graph.nodes[1].tags).toEqual(['hot']); + expect(graph.nodes[0].tags).toEqual([]); + }); + + test('multi-tag round-trip preserves order', () => { + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 'rt').tag('alpha', 'beta'); + + const emitted = toMermaid(State.toGraph(s, tapeBlock)); + const reparsed = fromMermaid(emitted); + + expect(reparsed.nodes[s.id].tags).toEqual(['alpha', 'beta']); + }); +}); diff --git a/packages/machine/src/utilities/graph.ts b/packages/machine/src/utilities/graph.ts index d5a4dd3..2e414e9 100644 --- a/packages/machine/src/utilities/graph.ts +++ b/packages/machine/src/utilities/graph.ts @@ -49,6 +49,11 @@ export type GraphNode = { // `haltState`. Halt marker id = `-frameId` (sits in disjoint negative-id // range from real node ids). isHaltMarker: boolean; + // Out-of-band tags applied to this State (#186). Empty array if untagged. + // Survives `toGraph`/`fromGraph` round-trip and renders in `toMermaid` as + // `classDef tag_` + `class sN tag_` lines. Doesn't affect + // runtime semantics — purely a visualization/debugger-tooling channel. + tags: string[]; }; export type Graph = { diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 364ad61..981b9ef 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -83,6 +83,17 @@ export function toMermaid(graph: Graph): string { } } + // Build the visible-label string for a node — name plus, if tagged, a + // `
tag1, tag2, ...` suffix so the rendered Mermaid shows both. Tags + // are the source of truth on the GraphNode; `
` is the universal + // Mermaid line-break that works across renderers without `classDef`- + // pseudo-element hacks (#186). + const labelOf = (node: GraphNode): string => { + if (node.tags.length === 0) return node.name; + + return `${node.name}
${node.tags.join(', ')}`; + }; + // 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame). for (const node of topLevelNodes) { const mid = mermaidIdFor(node.id); @@ -90,13 +101,13 @@ export function toMermaid(graph: Graph): string { if (node.isHalt) { lines.push(` ${mid}(((halt)))`); } else { - lines.push(` ${mid}["${node.name}"]`); + lines.push(` ${mid}["${labelOf(node)}"]`); } } // 2. Emit wrappers at top level. for (const wrapper of wrapperNodes) { - lines.push(` ${mermaidIdFor(wrapper.id)}[["${wrapper.name}"]]`); + lines.push(` ${mermaidIdFor(wrapper.id)}[["${labelOf(wrapper)}"]]`); } // 3. `idle` sentinel. @@ -121,7 +132,7 @@ export function toMermaid(graph: Graph): string { // Inner nodes — sort by id for determinism. for (const node of (nodesByFrame.get(frameId) ?? []).slice().sort((a, b) => a.id - b.id)) { - lines.push(` ${mermaidIdFor(node.id)}["${node.name}"]`); + lines.push(` ${mermaidIdFor(node.id)}["${labelOf(node)}"]`); } const haltMarker = haltMarkerByFrame.get(frameId); @@ -249,9 +260,83 @@ export function toMermaid(graph: Graph): string { } } + // 10. Tags (#186) — emit one `classDef tag_ fill:#...` per unique + // tag across all nodes, then one `class tag_` line per + // tag listing every node that carries it (comma-joined for compact + // emit). Tag-name → CSS-class identifier sanitization replaces any + // char outside `[A-Za-z0-9_-]` with `_`; tag-name uniqueness in the + // emit assumes user tags are already distinct after sanitization + // (collisions are user error). + emitTagAnnotations(lines, nodes); + return lines.join('\n'); } +// Default Mermaid `classDef` palette — 6 visually distinct fill+stroke pairs, +// selected by tag-name hash so multi-tag diagrams look readable out of the +// box without user configuration. Users who want different colors can edit +// the emitted Mermaid before rendering or override post-emit. +const TAG_PALETTE: ReadonlyArray = [ + ['#fef3c7', '#92400e'], // amber + ['#dbeafe', '#1e40af'], // blue + ['#dcfce7', '#166534'], // green + ['#fce7f3', '#9d174d'], // pink + ['#ede9fe', '#5b21b6'], // violet + ['#fee2e2', '#991b1b'], // red +]; + +function sanitizeTagName(tag: string): string { + return tag.replace(/[^A-Za-z0-9_-]/g, '_'); +} + +function tagColor(tag: string): readonly [string, string] { + // Cheap deterministic hash — sum of char codes mod palette length. Stable + // across runs; same tag name always picks the same color. + let h = 0; + + for (let i = 0; i < tag.length; i += 1) { + h = (h + tag.charCodeAt(i)) % TAG_PALETTE.length; + } + + return TAG_PALETTE[h]; +} + +function emitTagAnnotations(lines: string[], nodes: GraphNode[]): void { + // Collect nodes per tag in node-id order so output is deterministic. + const nodesByTag = new Map(); + + for (const node of nodes) { + for (const tag of node.tags) { + let list = nodesByTag.get(tag); + + if (!list) { + list = []; + nodesByTag.set(tag, list); + } + + list.push(node.id); + } + } + + if (nodesByTag.size === 0) return; + + const sortedTags = [...nodesByTag.keys()].sort(); + + for (const tag of sortedTags) { + const sanitized = sanitizeTagName(tag); + const [fill, stroke] = tagColor(tag); + + lines.push(` classDef tag_${sanitized} fill:${fill},stroke:${stroke}`); + } + + for (const tag of sortedTags) { + const sanitized = sanitizeTagName(tag); + const ids = nodesByTag.get(tag)!.map((id) => mermaidIdFor(id)).join(','); + + lines.push(` class ${ids} tag_${sanitized}`); + } +} + // Helper: identify "the bare states" that anchor a frame's name. A bare is a // node referenced as some wrapper's `bareStateId`. Body states (also in-frame // but not bare) are excluded from the frame label. @@ -301,6 +386,32 @@ const haltArrowRegex = /^w_(\d+)\s+-\.\s+"halt"\s+\.->\s+s0$/; // First capture char anchored as \S to avoid polynomial backtracking between // the preceding \s* and a permissive (.+); see CodeQL js/polynomial-redos. const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/; +// Tag annotation lines (#186). Matches both `classDef tag_` and +// `class tag_`. ClassDef declarations are decorative +// (palette) and discarded on parse — toMermaid will regenerate them from +// the tag set on re-emit. `class` lines carry the actual graph-node +// assignments; we strip the `tag_` prefix and assign each tag to each +// listed node's `tags` array. +const classDefTagRegex = /^classDef\s+tag_([A-Za-z0-9_-]+)\s+.+$/; +const classAssignTagRegex = /^class\s+([sc]\d+(?:,[sc]\d+)*)\s+tag_([A-Za-z0-9_-]+)$/; + +// Splits a node label like `"A
hot, sampled"` into its name and tags (#186). +// Labels without `
` have no tags. Tags are comma-joined; trimmed of +// whitespace. The `
` is the single source of truth for tag-name parsing — +// `class` lines are decorative-only and not consulted here. +function splitLabelTags(label: string): {name: string; tags: string[]} { + const brIx = label.indexOf('
'); + + if (brIx < 0) { + return {name: label, tags: []}; + } + + const name = label.slice(0, brIx); + const tagsStr = label.slice(brIx + '
'.length); + const tags = tagsStr.split(',').map((t) => t.trim()).filter((t) => t.length > 0); + + return {name, tags}; +} export function fromMermaid(text: string): Graph { const lines = text.split('\n').map((l) => l.trim()).filter(Boolean); @@ -319,6 +430,7 @@ export function fromMermaid(text: string): Graph { isWrapper?: boolean; bareStateId?: number | null; frameId?: number | null; + tags?: string[]; } = {}, ): GraphNode => { if (!nodes[id]) { @@ -332,6 +444,7 @@ export function fromMermaid(text: string): Graph { frameId: opts.frameId ?? null, transitions: [], overriddenHaltStateId: null, + tags: opts.tags ? [...opts.tags] : [], }; } else { if (opts.name !== undefined) nodes[id].name = opts.name; @@ -340,6 +453,11 @@ export function fromMermaid(text: string): Graph { if (opts.isWrapper !== undefined) nodes[id].isWrapper = opts.isWrapper; if (opts.bareStateId !== undefined) nodes[id].bareStateId = opts.bareStateId; if (opts.frameId !== undefined) nodes[id].frameId = opts.frameId; + if (opts.tags !== undefined) { + for (const t of opts.tags) { + if (!nodes[id].tags.includes(t)) nodes[id].tags.push(t); + } + } } return nodes[id]; @@ -356,6 +474,11 @@ export function fromMermaid(text: string): Graph { continue; } + // Tag annotations (#186) — classDef lines are decorative and skipped; + // `class` lines are parsed in the edge pass since they reference nodes + // by id and need those nodes already created in the first pass. + if (classDefTagRegex.test(line)) continue; + const sgStart = line.match(subgraphStartRegex); if (sgStart) { @@ -389,9 +512,12 @@ export function fromMermaid(text: string): Graph { const wm = line.match(wrappedNodeRegex); if (wm) { + const {name, tags} = splitLabelTags(wm[2]); + ensureNode(parseMermaidId(wm[1]), { - name: wm[2], + name, isWrapper: true, + tags, }); continue; @@ -400,9 +526,12 @@ export function fromMermaid(text: string): Graph { const rm = line.match(regularNodeRegex); if (rm) { + const {name, tags} = splitLabelTags(rm[2]); + ensureNode(parseMermaidId(rm[1]), { - name: rm[2], + name, frameId: currentFrameId, + tags, }); continue; @@ -424,6 +553,23 @@ export function fromMermaid(text: string): Graph { continue; } + // Tag class-assignment line (#186): `class s1,s5 tag_hot` — adds + // the tag to each listed node. Tag-name preserved as written + // (sanitization on emit is lossy in principle; on parse we don't + // un-sanitize, since the original could have any characters). + const tagMatch = line.match(classAssignTagRegex); + + if (tagMatch) { + const ids = tagMatch[1].split(','); + const tagName = tagMatch[2]; + + for (const idStr of ids) { + ensureNode(parseMermaidId(idStr), {tags: [tagName]}); + } + + continue; + } + // `call` arrow — sets bareStateId on each source wrapper. const cm = line.match(callArrowRegex); diff --git a/packages/machine/src/utilities/introspection.spec.ts b/packages/machine/src/utilities/introspection.spec.ts index 147e9f0..87bdadc 100644 --- a/packages/machine/src/utilities/introspection.spec.ts +++ b/packages/machine/src/utilities/introspection.spec.ts @@ -7,9 +7,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0', '1']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: [], transitions: [ {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, {pattern: '1', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}, @@ -31,9 +31,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: [], transitions: [ {pattern: '0', command: [{symbol: 'K', movement: 'R'}], nextStateId: 1, id: "test-edge"}, ], @@ -52,9 +52,9 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, 1: { - id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, + id: 1, name: 'a', isHalt: false, overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: [], transitions: [{pattern: '0', command: [{symbol: 'K', movement: 'S'}], nextStateId: 0, id: "test-edge"}], }, }, @@ -72,10 +72,10 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 3, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 3: {id: 3, name: 'c', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, }, }; @@ -90,8 +90,8 @@ describe('summarizeGraph', () => { initialId: 1, alphabets: [[' ']], nodes: { - 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 0: {id: 0, name: 'halt', isHalt: true, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: null, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, }, }; @@ -197,8 +197,8 @@ describe('summarizeGraph defensive guards', () => { initialId: 1, alphabets: [[' ', '0']], nodes: { - 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, - 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null}, + 1: {id: 1, name: 'a', isHalt: false, transitions: [], overriddenHaltStateId: 2, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, + 2: {id: 2, name: 'b', isHalt: false, transitions: [], overriddenHaltStateId: 1, isHaltMarker: false, isWrapper: false, bareStateId: null, frameId: null, tags: []}, }, }; From 4190c9959c9b8bfbd3e58cb97d223abe29534ff9 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 17:45:39 +0300 Subject: [PATCH 026/118] chore(release): 7.0.0-alpha.3 --- CLAUDE.md | 2 + lerna.json | 2 +- package-lock.json | 14 +++---- packages/builder/package.json | 4 +- .../library-binary-numbers-bare/package.json | 4 +- packages/library-binary-numbers/package.json | 4 +- packages/machine/CHANGELOG.md | 40 +++++++++++++++++++ packages/machine/README.md | 37 ++++++++++++++++- packages/machine/package.json | 2 +- 9 files changed, 93 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3484e13..0d24004 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,8 @@ Key shapes that take reading multiple files to grasp: `packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same `Graph` through [Mermaid flowchart](https://mermaid.js.org/syntax/flowchart.html) syntax (renderer: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid)). The parser is strict to the dialect `toMermaid` emits — hand-edited Mermaid with different arrow styles or shapes won't round-trip. +**State tags (v7 alpha.3, #186)** — `state.tag(...tags) / state.untag(...tags) / state.tags` API for attaching string metadata to a State. Tags are out-of-band — they don't affect transition lookup, `equivalentOn`, or runtime semantics. Live on the State *instance* (not on `#symbolToDataMap`), so engine #175 memoization doesn't leak tags across wrappers sharing a bare: `A.wohs(t1).tag('hot')` and `A.wohs(t2)` carry independent tag sets. `GraphNode` gains `tags: string[]` field — survives `toGraph`/`fromGraph` round-trip. `toMermaid` emits tags two ways: inline in node label via universal `
` (`sN["name
tag1, tag2"]`) AND as `classDef tag_` + `class sN tag_` lines (6-color hash-based palette). `fromMermaid` splits the label on `
` as source of truth; `class` lines are decorative. + **v7 callable-subtree emit shape** (#174, layered on the v7 alpha.1 framing from #138/#139): each `withOverriddenHaltState` wrapper produces TWO `GraphNode`s — a wrapper node (`isWrapper: true`, `[[composite-name]]` shape, no transitions, `bareStateId` points to the bare's GraphNode) and a bare node (regular `["name"]` shape inside its callable subtree subgraph, holds the bare's transitions). Frames are computed via union-find on bare-reachability: each unique bare's forward-reachable set defines its candidate frame; overlapping reach sets merge into a union frame. Frame id = smallest bare-id in the component. Halt marker per frame (id = `-frameId`, `isHaltMarker: true`, maps back to singleton `haltState` in `fromGraph`). Halt-bound transitions of in-frame states retarget to the frame's halt marker. Subgraph label: `"callable subtree of NAME"` (single bare) or `"callable scope: A ∪ B"` (union). The always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state. **No per-context duplication** — shared bares like `library-binary-numbers`'s `invertNumber` (in `minusOne`) appear once with `& `-joined call arrows from each wrapper. **Edge label vocabulary** — `[reads] → [writes]/[moves]`, each role wrapped in `[…]` (the tape-block indicator, one entry per tape; brackets always present, even single-tape). Read cells: literal-quoted (`'X'`), `*` (ASCII, ifOtherSymbol catch-all; literal `*` in the alphabet is quoted as `'*'`), `B` (the tape's blank). Write cells: literal-quoted, `K` (keep), `E` (erase = write blank). Move cells: `L` / `R` / `S`. **Alternation is always per-pattern bracket** (`['^']|['1']|['0']` for single-tape, `['0','a']|['1','b']` for multi-tape); the compact in-bracket form `['^'|'1']` is rejected by `fromMermaid` to prevent the cross-product reading trap in multi-tape (`['0'|'1','a'|'b']` would read as 4 combinations rather than 2 paired alternatives, so the format avoids the shape entirely). diff --git a/lerna.json b/lerna.json index a18995b..0455ff2 100644 --- a/lerna.json +++ b/lerna.json @@ -3,6 +3,6 @@ "packages": [ "packages/*" ], - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "$schema": "node_modules/lerna/schemas/lerna-schema.json" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9049f26..d50c9ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10656,40 +10656,40 @@ }, "packages/builder": { "name": "@turing-machine-js/builder", - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.1" + "@turing-machine-js/machine": "^7.0.0-alpha.2" } }, "packages/library-binary-numbers": { "name": "@turing-machine-js/library-binary-numbers", - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.1" + "@turing-machine-js/machine": "^7.0.0-alpha.2" } }, "packages/library-binary-numbers-bare": { "name": "@turing-machine-js/library-binary-numbers-bare", - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.1" + "@turing-machine-js/machine": "^7.0.0-alpha.2" } }, "packages/machine": { "name": "@turing-machine-js/machine", - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" diff --git a/packages/builder/package.json b/packages/builder/package.json index 7736037..b23e98d 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/builder", - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "description": "A turing machine builder — declarative state-table construction. Not actively developed by the author; the same state-table pattern is also shown as an inline example in @turing-machine-js/machine's README. Contributions welcome.", "engines": { "npm": ">=7.0.0" @@ -25,7 +25,7 @@ "builder" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.2" + "@turing-machine-js/machine": "^7.0.0-alpha.3" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/builder", diff --git a/packages/library-binary-numbers-bare/package.json b/packages/library-binary-numbers-bare/package.json index f49e97b..3af83ee 100644 --- a/packages/library-binary-numbers-bare/package.json +++ b/packages/library-binary-numbers-bare/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/library-binary-numbers-bare", - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "description": "Single-number binary arithmetic on a 3-symbol alphabet (blank, 0, 1) — same operations as @turing-machine-js/library-binary-numbers but without ^/$ markers. Side-by-side with the marker-based library for learning the trade-off.", "engines": { "npm": ">=7.0.0" @@ -28,7 +28,7 @@ "teaching" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.2" + "@turing-machine-js/machine": "^7.0.0-alpha.3" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/library-binary-numbers-bare", diff --git a/packages/library-binary-numbers/package.json b/packages/library-binary-numbers/package.json index 6ec5374..9a665a1 100644 --- a/packages/library-binary-numbers/package.json +++ b/packages/library-binary-numbers/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/library-binary-numbers", - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "description": "A standard library for working with binary numbers", "engines": { "npm": ">=7.0.0" @@ -27,7 +27,7 @@ "numbers" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.2" + "@turing-machine-js/machine": "^7.0.0-alpha.3" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/library-binary-numbers", diff --git a/packages/machine/CHANGELOG.md b/packages/machine/CHANGELOG.md index 860d785..1e2f70d 100644 --- a/packages/machine/CHANGELOG.md +++ b/packages/machine/CHANGELOG.md @@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.3] - 2026-05-21 + +Third v7 pre-release. Adds first-class out-of-band tags on `State` ([#186](https://github.com/mellonis/turing-machine-js/issues/186)) — a metadata channel for visualization grouping and debugger labels that survives `toGraph` / `fromGraph` / `toMermaid` / `fromMermaid` round-trips. Driven by downstream [post-machine-js#86](https://github.com/mellonis/post-machine-js/issues/86), which will build a path-based registry and inline pseudo-command on top once this ships. Published under the `next` dist-tag: `npm install @turing-machine-js/machine@next`. + +**Pre-release — the API surface may still shift before stable v7.0.0.** Pin to a specific alpha for reproducibility: `@turing-machine-js/machine@7.0.0-alpha.3`. + +### Added + +- **`State.tag(...) / .untag(...) / .tags` API** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). Fluent post-construct API for attaching string tags to a State. Tags are out-of-band metadata — they don't affect runtime transition lookup, `equivalentOn` comparisons, or any structural identity. Storage lives on the State INSTANCE (not on the shared `#symbolToDataMap`), so engine [#175](https://github.com/mellonis/turing-machine-js/issues/175) memoization doesn't leak tags across wrappers that share a bare: `A.wohs(t1).tag('hot')` does NOT propagate to `A.wohs(t2)`. + + ```ts + const s = new State({...}, 'foo') + .tag('hot', 'sampled') + .untag('sampled'); + s.tags; // ['hot'] ← frozen snapshot, in insertion order + ``` + +- **`GraphNode.tags: string[]`** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). New field on the serialized graph node. Survives `State.toGraph` / `State.fromGraph` round-trip; empty array for untagged states. + +- **`toMermaid` tag rendering** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). Two surfaces: + - **Visible labels via `
`**: tagged node labels include the tag names inline (`s5["A
hot, sampled"]`). Uses Mermaid's universal `
` line break — works across mermaid.js, the live editor, machines-demo, and any other renderer; no CSS-pseudo-element tricks. + - **Color grouping via `classDef` + `class`**: each unique tag gets a `classDef tag_ fill:#...,stroke:#...` line (palette of 6 colors selected by tag-name hash) and a `class s5,s6 tag_` line listing every node carrying the tag. + +- **`fromMermaid` tag parse** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). Splits the node label on `
` to extract tags as the source of truth; `class` lines are decorative and discarded on parse (they regenerate on the next `toMermaid` emit from the tag set). + +### Compatibility + +- Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.2` → `^7.0.0-alpha.3` on `@turing-machine-js/builder`, `@turing-machine-js/library-binary-numbers`, `@turing-machine-js/library-binary-numbers-bare`. + +### Migration from alpha.2 + +Purely additive — no breaking changes. Existing code that doesn't call `state.tag(...)` or read `state.tags` / `GraphNode.tags` continues to work identically. Mermaid emit for untagged states is bytewise unchanged. + +If you serialize `GraphNode` JSON, note that the new `tags: string[]` field is now required by the type (always emitted; empty array if no tags). + +### Out of v7-alpha.3 (still pending for stable v7.0.0) + +- **[#102](https://github.com/mellonis/turing-machine-js/issues/102)** — debugger step-in / step-out / step-over primitives. +- **[#180](https://github.com/mellonis/turing-machine-js/issues/180)** — extract `State.toGraph`/`fromGraph` to its own module. + ## [7.0.0-alpha.2] - 2026-05-21 Second v7 pre-release. Refines alpha.1's `toMermaid` wrapped-state emit into the function-call model ([#174](https://github.com/mellonis/turing-machine-js/issues/174)) and adds two construction-time improvements to `withOverriddenHaltState` ([#175](https://github.com/mellonis/turing-machine-js/issues/175), [#176](https://github.com/mellonis/turing-machine-js/issues/176)). Published to npm under the `next` dist-tag: `npm install @turing-machine-js/machine@next`. diff --git a/packages/machine/README.md b/packages/machine/README.md index e6315b9..46b4f11 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -13,6 +13,7 @@ A composable Turing-machine engine for JavaScript: multi-tape, subroutine compos - [Building from a state table](#building-from-a-state-table) - [Classes](#classes) — [`Alphabet`](#alphabet) · [`Tape`](#tape) · [`TapeBlock`](#tapeblock) · [`TapeCommand`](#tapecommand) · [`Command`](#command) · [`State`](#state) · [`Reference`](#reference) · [`TuringMachine`](#turingmachine) - [Subroutine composition with `withOverriddenHaltState`](#subroutine-composition-with-withoverriddenhaltstate) +- [State tags](#state-tags) - [Debugging breakpoints](#debugging-breakpoints) - [Special objects](#special-objects) — [`haltState`](#haltstate) · [`ifOtherSymbol`](#ifothersymbol) · [`movements`](#movements) · [`symbolCommands`](#symbolcommands) - [Introspection and testing](#introspection-and-testing) @@ -427,6 +428,25 @@ flowchart TD Wrappers nest: `inner.withOverriddenHaltState(middle).withOverriddenHaltState(outer)` chains halt-redirects through `middle → outer → halt`. `library-binary-numbers/src/index.ts`'s `minusOne` (the `~(~x + 1)` composition) uses a 4-deep nest of wrappers. +## State tags + +A State carries an optional set of string tags — out-of-band metadata for visualization grouping and debugger labels. Tags don't affect runtime transition lookup, `equivalentOn` comparisons, or any structural identity; they ride alongside the State. + +```ts +const s = new State({...}, 'walkToBlank::1') + .tag('hot', 'subroutine-entry'); + +s.tags; // readonly ['hot', 'subroutine-entry'] — frozen snapshot +s.untag('hot'); +s.tags; // readonly ['subroutine-entry'] +``` + +**Scoped to the wrapper instance.** Under [`withOverriddenHaltState` memoization (#175)](https://github.com/mellonis/turing-machine-js/issues/175), `A.wohs(t1)` and `A.wohs(t2)` are distinct wrapper instances even though they share `A`'s `#symbolToDataMap`. Tags live on the instance, so tagging one wrapper doesn't propagate to siblings sharing the same bare. Wrappers from `withOverriddenHaltState` start with an empty tag set (do not inherit from bare); the caller tags explicitly as needed. + +**Round-trip preserved.** `state.toGraph` writes the tag set to `GraphNode.tags`; `state.fromGraph` reads it back and reapplies. `toMermaid` renders tags two ways: inline in the node label (`sN["name
tag1, tag2"]`, universal Mermaid line break) and as `classDef tag_` + `class sN tag_` lines for color grouping. `fromMermaid` splits the label on `
` as source of truth; the `class` lines are decorative and discarded on parse. + +See [§Diagram conventions § Tags](#tags) for the full emit shape. + ## Debugging breakpoints Any `State` can carry a runtime-mutable `debug` config that pauses execution at chosen points. @@ -599,6 +619,15 @@ The `&` ribbon syntax (`s_W1 & s_W2 == "call" ==> s_A`) collapses multiple wrapp `subgraph w_N["callable subtree of NAME"] … end` wraps a bare + its body + a halt marker — the callable scope of code that runs when a wrapper "calls" the bare. Multi-bare frames (union-find merged from shared body states) use the label `"callable scope: A ∪ B"`. +### Tags + +Tagged states (via `state.tag('hot', 'sampled')` — see [§State tags](#state-tags)) render two ways simultaneously: + +- **Inline in the node label**: `sN["name
tag1, tag2"]` — the `
` is Mermaid's universal line break, so the tags display as a second line under the state name in any renderer. +- **As a color class**: `classDef tag_ fill:#...,stroke:#...` per unique tag (6-color palette selected by tag-name hash), plus `class sN,sM tag_` listing all nodes carrying the tag. Lets the eye group related states by color even when their names are scattered across the diagram. + +The `
`-embedded label is the source of truth for `fromMermaid` round-trip; the `classDef`/`class` lines are decorative and regenerate on the next `toMermaid` emit. Tag-name sanitization in `classDef` identifiers: any char outside `[A-Za-z0-9_-]` is replaced with `_`. Labels preserve the raw tag names. + ### Edge label format `[reads] → [writes]/[moves]`. Each bracketed list is a tape-block reading — one entry per tape; brackets always present, even single-tape. @@ -654,7 +683,13 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v6.2** *(superseded by v6.3.0)* — widened `onStep`'s signature to `(m) => void | Promise` and added an inline `await onStep(...)` in the run loop, enabling throttle-in-`onStep` patterns. This overturned the docstring-stated contract that `onStep` is sync (microtask-free); the right place for per-iter throttling is `onPause` with self-rearm (see [Throttle pattern](#throttle-pattern)). Restored in v6.3.0. - **v6.3** — `onStep` reverted to its v6.0–v6.1 sync contract — `(m) => void`, called synchronously inside the run loop. The Throttle pattern section documents the engine-native shape for per-iter throttle / "wait between iters" UIs. No other API changes. - **v6.4** — New **`onIter`** hook on `run()`: awaited, fires once at the end of every iter (after both `onPause` dispatches on the same yield), unaffected by the `debug` master switch. Use for per-iter throttle / animation / coordination needing a suspend point; complements the existing sync `onStep` (tracing) and conditional `onPause` (user breakpoints). Three-hook contract is now `onStep` (sync, mid-iter) / `onPause` (awaited, on `state.debug` match) / `onIter` (awaited, end-of-iter). Additive — peer-deps unchanged. The v6.3.0 README's `onPause`-rearm throttle workaround is superseded. -- **v7** *(alpha 1, 2026-05-21)* — Composition-representation overhaul. **First pre-release on the `next` dist-tag:** `npm install @turing-machine-js/machine@next` (or pin `@7.0.0-alpha.1`). Stable v7.0.0 still pending [#102](https://github.com/mellonis/turing-machine-js/issues/102) (debugger step-in/over/out primitives). Landed in alpha.1: +- **v7** *(latest alpha: alpha.3, 2026-05-21)* — Composition-representation overhaul + first-class state tags. **Pre-release on the `next` dist-tag:** `npm install @turing-machine-js/machine@next` (or pin `@7.0.0-alpha.3`). Stable v7.0.0 still pending [#102](https://github.com/mellonis/turing-machine-js/issues/102) (debugger step-in/over/out primitives). Highlights across alphas: + + **alpha.3** — first-class **State tags** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). `state.tag(...) / .untag(...) / .tags` API; `GraphNode.tags: string[]` round-trips through `toGraph`/`fromGraph`; `toMermaid` emits tags two ways simultaneously — inline via `
` in node labels (`sN["name
tag1, tag2"]`) and as `classDef`/`class` for color grouping. Tags live on the State instance (not on the shared `#symbolToDataMap`), so engine [#175](https://github.com/mellonis/turing-machine-js/issues/175) memoization doesn't leak tags across wrappers sharing a bare. See [§State tags](#state-tags). + + **alpha.2** — callable-subtree `toMermaid` emit refinement ([#174](https://github.com/mellonis/turing-machine-js/issues/174)). The wrapper is a separate `[[composite-name]]` node OUTSIDE the subgraph; the bare's reachable subtree becomes a `subgraph w_${frameId}["callable subtree of NAME"]` block. Frames computed via union-find — shared bares dedupe with `&` ribbons on call arrows. Bold `==> "call"` reserved for wrapper-to-bare; dotted `-.->` for frame dispatch (`return` / `halt` / `enter`). Plus engine memoization ([#175](https://github.com/mellonis/turing-machine-js/issues/175)) and nested-chain collapse ([#176](https://github.com/mellonis/turing-machine-js/issues/176)) for `.wohs()`. + + **alpha.1** — initial v7 composition-representation overhaul: - **`withOverrodeHaltState` → `withOverriddenHaltState`** ([#149](https://github.com/mellonis/turing-machine-js/issues/149)). Grammar fix on a name introduced in 2019: the past-participle `overridden` fits the "with a halt-state that has been ___" naming idiom; `overrode` (simple past) didn't. Hard cutover — no deprecated alias. The getter (`state.overrodeHaltState` → `state.overriddenHaltState`) and the serialized `Graph` data field (`node.overrodeHaltStateId` → `node.overriddenHaltStateId`) rename in lockstep. Consumer migration: global find/replace `OverrodeHaltState` → `OverriddenHaltState` and `overrodeHaltState` → `overriddenHaltState`. Persisted `State.toGraph` JSON dumps would need the same field-rename treatment, but persistence isn't a known consumer pattern. - **Paren-based wrapped-state naming** ([#148](https://github.com/mellonis/turing-machine-js/issues/148)). `withOverriddenHaltState`'s composite name format changed from flat `bare>override` to nested `bare(override)`. Same nesting depth reads as `A(B(A))` (bare = `A`, override = `B(A)`) versus `A(B)(A)` (bare = `A(B)`, override = `A`) — two structurally-different wrap-trees that the old `>`-flat notation collided into the single string `A>B>A`. As a consequence, **user-provided state names must not contain `(` or `)`** — `State` now throws at construction time if a user passes such a name. The `>` character stays valid in user names (no longer reserved). The `inspect()` / `toGraph` / `toMermaid` outputs carry the new format. `states.md` files in `library-binary-numbers` regenerate accordingly. - **`toMermaid` callable-subtree emit** ([#174](https://github.com/mellonis/turing-machine-js/issues/174), supersedes the alpha.1 collapsed-bare shape from #138/#139). `withOverriddenHaltState` is modeled as a function call: the wrapper is a `[[composite-name]]` call site OUTSIDE any subgraph, the bare's reachable subtree becomes a `subgraph w_${frameId}["callable subtree of NAME"] … end` block containing the bare + body states + a per-frame halt marker `c${frameId}(((halt)))`. Frames are computed via union-find on bare-reachability — overlapping reach sets merge into a single union frame, so shared bares (`library-binary-numbers/minusOne`'s `invertNumber`) appear ONCE with `& `-joined call arrows from each wrapper. Bold `==> "call"` arrows are reserved for the wrapper-to-bare call; dotted `-.->` is reserved for frame-level dispatch (`return` / `halt` / `enter`). The retired `-. onHalt .->` keyword is replaced by a solid `--> override` arrow (just an ordinary transition under the call/return mental model). `GraphNode` gains `isWrapper`, `bareStateId`, `frameId` fields (and drops `isWrapped`). Bytewise round-trip stability now holds for all wrapped states including shared-bare cases (no per-context duplication). diff --git a/packages/machine/package.json b/packages/machine/package.json index 514ffb7..12154ff 100644 --- a/packages/machine/package.json +++ b/packages/machine/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/machine", - "version": "7.0.0-alpha.2", + "version": "7.0.0-alpha.3", "description": "A convenient Turing machine", "engines": { "npm": ">=7.0.0" From 86483d2057677b5f0e269f50c236d845192fcc52 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 17:49:04 +0300 Subject: [PATCH 027/118] fix(fromMermaid): tighten classDef/class tag regexes to clear CodeQL polynomial-ReDoS flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged `classDefTagRegex` on PR #167's integration scan: ^classDef\s+tag_([A-Za-z0-9_-]+)\s+.+$ The `\s+...\s+` shape around a content group with `-` in the character class is the polynomial-backtracking pattern CodeQL detects (same shape as #182's earlier fix on `callArrowRegex` / `returnArrowRegex`). `toMermaid` emits these lines with exactly single literal spaces between tokens (`classDef tag_ fill:#...`, `class s5,s6 tag_`), so tightening `\s+` → ` ` is loss-free for round-trip cases and removes the ReDoS-vulnerable shape. Both classDefTagRegex and classAssignTagRegex get the same treatment for consistency. Test plan - npm test — 456/456 pass - npm run lint — clean CodeQL annotation: https://github.com/mellonis/turing-machine-js/pull/167#discussion_r3282035002 --- packages/machine/src/utilities/graphFormats.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 981b9ef..2b6f300 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -392,8 +392,13 @@ const alphabetsRegex = /^%%\s*alphabets:\s*(\S.*)$/; // the tag set on re-emit. `class` lines carry the actual graph-node // assignments; we strip the `tag_` prefix and assign each tag to each // listed node's `tags` array. -const classDefTagRegex = /^classDef\s+tag_([A-Za-z0-9_-]+)\s+.+$/; -const classAssignTagRegex = /^class\s+([sc]\d+(?:,[sc]\d+)*)\s+tag_([A-Za-z0-9_-]+)$/; +// +// Inter-token gaps are fixed at single literal spaces (matching toMermaid's +// canonical emit) rather than `\s+`. This avoids the polynomial-ReDoS +// pattern CodeQL flags when `\s+` surrounds a content group (see also +// `callArrowRegex` / `returnArrowRegex` tightening in PR #182). +const classDefTagRegex = /^classDef tag_([A-Za-z0-9_-]+) .+$/; +const classAssignTagRegex = /^class ([sc]\d+(?:,[sc]\d+)*) tag_([A-Za-z0-9_-]+)$/; // Splits a node label like `"A
hot, sampled"` into its name and tags (#186). // Labels without `
` have no tags. Tags are comma-joined; trimmed of From daef9952feafdb6f14fce5250f4010bb35774b71 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 18:02:02 +0300 Subject: [PATCH 028/118] test(coverage): exercise fromGraph tag application + reverse-order union; strip dead defensive paths in State + graphFormats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #167's coveralls check was at -0.8% after #186 added the tag surface. Local v8 was already above engine floors but Coveralls's combined- metric (lines + branches) dragged on uncovered defensive paths in the new code. Tests added (3 new in `State.spec.ts`) - `toGraph → fromGraph preserves tags on bare/regular nodes` — exercises the `bare.tag(...node.tags)` branch in `fromGraph` (#186). - `toGraph → fromGraph preserves tags on wrapper nodes` — exercises the `state.tag(...node.tags)` branch on wrappers after `withOverriddenHaltState`. - `reverse-order union — bares[0] has higher id than bares[i]` — constructs bares in reverse order so BFS encounters the higher-id one first, making `ufUnion(bares[0], bares[i])` run with ra > rb and hit the else-branch (line 649 — previously uncovered). Defensive paths simplified - State.ts toGraph BFS: removed `!node` check (BFS only enqueues IDs that were already added to `nodes`, so `node` is always defined). - State.ts ufFind: removed the path-compression loop. Under the smaller-id-wins union policy used here, the tree never grows beyond depth 1 (every union sets the smaller id as root; every node directly points to root). Path compression was dead code. - graphFormats.ts wrapper-override emit: dropped the `if (wrapper.overriddenHaltStateId === null) continue` guard — wrappers always have an override (set by State.toGraph). Use the non-null assertion. - graphFormats.ts fromMermaid wrapper-override parse: dropped the redundant `nodes[fromId] && ...` check — pass 1 always populates the node before pass 2 sees the edge. Coverage delta (v8) Stmts: 98.42 → 99.03 (+0.61) Branch: 95.08 → 95.70 (+0.62) Funcs: 100 → 100 (unchanged) Lines: 98.79 → 99.34 (+0.55) Tests - npm test — 460/460 pass (456 prior + 4 new) - npm run lint — clean - npm run typecheck — clean --- packages/machine/src/classes/State.spec.ts | 129 ++++++++++++++++++ packages/machine/src/classes/State.ts | 19 ++- .../machine/src/utilities/graphFormats.ts | 16 ++- 3 files changed, 149 insertions(+), 15 deletions(-) diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index 9dfbb95..b29e180 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -419,6 +419,135 @@ describe('State tags (#186)', () => { haltState.untag('halt-debug-marker'); expect(haltState.tags).not.toContain('halt-debug-marker'); }); + + // Round-trip tag application (#186) — exercises both fromGraph branches: + // tagging a bare/regular node (the simple branch) and tagging a wrapper + // node (the path that goes through `state.tag(...node.tags)` after + // `withOverriddenHaltState`). + test('toGraph → fromGraph preserves tags on bare/regular nodes', () => { + const tapeBlock = TapeBlock.fromAlphabets([new Alphabet([' ', '0'])]); + const original = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 'tagged-bare').tag('alpha', 'beta'); + + const graph = State.toGraph(original, tapeBlock); + const {start} = State.fromGraph(graph); + + expect(start.tags).toEqual(['alpha', 'beta']); + }); + + test('toGraph → fromGraph preserves tags on wrapper nodes', () => { + const tapeBlock = TapeBlock.fromAlphabets([new Alphabet([' ', '0'])]); + const bare = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 'bare'); + const target = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 'target'); + const wrapper = bare.withOverriddenHaltState(target).tag('hot'); + + const graph = State.toGraph(wrapper, tapeBlock); + const {start} = State.fromGraph(graph); + + expect(start.tags).toEqual(['hot']); + }); +}); + +describe('State.toGraph — union-find depth & ordering', () => { + // Direct coverage for the path-compression loop and the reverse-order + // `ra > rb` branch in `ufUnion`. The simpler shared-body-state tests in + // `graph.spec.ts` only exercise depth-1 unions; this case forces depth ≥ 2 + // by chaining: A and B share X; C and D share Y; bridging through a shared + // state Z forces a multi-level union where `ufFind` walks > 1 step. + test('reverse-order union — bares[0] has higher id than bares[i], hits the ra > rb branch', () => { + // The simpler shared-body tests in `graph.spec.ts` always have the + // lowest-id bare encountered first (so `ufUnion(bares[0], bares[i])` + // runs with ra < rb and the smaller-id branch fires). To cover the + // ra > rb else-branch in `ufUnion`, we construct the higher-id bare's + // wrapper first and reach it via the dispatcher's FIRST transition — + // so BFS visits its bare before the lower-id one, making it bares[0]. + const alphabet = new Alphabet([' ', '1', '2']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const {symbol} = tapeBlock; + + // Construct the higher-id bare FIRST so it has a HIGHER #id than the + // lower-id bare we construct later. Counter-intuitive — but bare + // identity in toGraph's `bareIds` Set is insertion order = BFS visit + // order, which depends on the dispatcher's transition order, NOT on + // construction order. So we construct B before A (B gets a lower #id), + // then route dispatcher to reach A's wrapper FIRST. + const X = new State({ + [ifOtherSymbol]: {command: {movement: movements.stay}, nextState: haltState}, + }, 'X'); + const B = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}}, 'B'); + const A = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: X}}, 'A'); + // A.id > B.id (A constructed later). Now dispatcher visits A's wrapper + // FIRST via the [1] transition, so BFS puts A in bareIds before B → + // bares[0]=A (higher id), bares[1]=B (lower id) → ufUnion(A, B) with + // ra > rb → hits the else-branch. + const t = new State({ + [ifOtherSymbol]: {command: {movement: movements.stay}, nextState: haltState}, + }, 't'); + const WA = A.withOverriddenHaltState(t); + const WB = B.withOverriddenHaltState(t); + const dispatcher = new State({ + [symbol(['1'])]: {command: {movement: movements.stay}, nextState: WA}, + [symbol(['2'])]: {command: {movement: movements.stay}, nextState: WB}, + }, 'dispatcher'); + + const graph = State.toGraph(dispatcher, tapeBlock); + + // The frame id is the smallest id in the component (canonical), which + // is B (constructed first) — confirms `ra > rb` did the right thing: + // when ufUnion(A, B) ran with A as `ra`, the else-branch set + // parent[A] = B, keeping the smaller id as root. + expect(graph.nodes[A.id].frameId).toBe(B.id); + expect(graph.nodes[B.id].frameId).toBe(B.id); + expect(graph.nodes[X.id].frameId).toBe(B.id); + }); + + test('multi-bare overlap with several shared bares (smoke test)', () => { + const alphabet = new Alphabet([' ', '1', '2', '3', '4']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const {symbol} = tapeBlock; + + // Z is a "bridge" body state that A, B, C, D all eventually transition + // into. Ensures every bare's reach set contains Z → all bares unioned. + const Z = new State({ + [ifOtherSymbol]: {command: {movement: movements.stay}, nextState: haltState}, + }, 'Z'); + const A = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: Z}}, 'A'); + const B = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: Z}}, 'B'); + const C = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: Z}}, 'C'); + const D = new State({[ifOtherSymbol]: {command: {movement: movements.right}, nextState: Z}}, 'D'); + + const t = new State({ + [ifOtherSymbol]: {command: {movement: movements.stay}, nextState: haltState}, + }, 't'); + + const WA = A.withOverriddenHaltState(t); + const WB = B.withOverriddenHaltState(t); + const WC = C.withOverriddenHaltState(t); + const WD = D.withOverriddenHaltState(t); + + const dispatcher = new State({ + [symbol(['1'])]: {command: {movement: movements.stay}, nextState: WA}, + [symbol(['2'])]: {command: {movement: movements.stay}, nextState: WB}, + [symbol(['3'])]: {command: {movement: movements.stay}, nextState: WC}, + [symbol(['4'])]: {command: {movement: movements.stay}, nextState: WD}, + }, 'dispatcher'); + + const graph = State.toGraph(dispatcher, tapeBlock); + + // All four bares end up in the same union frame (canonical = smallest id). + const frameId = graph.nodes[A.id].frameId; + + expect(frameId).not.toBeNull(); + expect(graph.nodes[B.id].frameId).toBe(frameId); + expect(graph.nodes[C.id].frameId).toBe(frameId); + expect(graph.nodes[D.id].frameId).toBe(frameId); + expect(graph.nodes[Z.id].frameId).toBe(frameId); + }); }); describe('State.fromGraph — cyclic override-halt chain', () => { diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 81e91ed..d83b636 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -582,7 +582,10 @@ export default class State { const node = nodes[id]; - if (!node || node.isHalt || node.isWrapper) { + // `nodes[id]` is always populated for `id` that the BFS reached, so + // a defensive `!node` check would be dead. `isHalt` / `isWrapper` + // are real boundaries — both stop reach-set expansion. + if (node.isHalt || node.isWrapper) { continue; } @@ -613,6 +616,10 @@ export default class State { // the component. const ufParent = new Map(); + // Note: no path compression. The union policy below ("smaller id always + // becomes root") keeps the tree flat — every union targets bares[0] as + // the root, so any node's parent IS the root. Walking up never exceeds + // one step. Path compression would be dead code under this invariant. const ufFind = (id: number): number => { if (!ufParent.has(id)) { ufParent.set(id, id); @@ -624,16 +631,6 @@ export default class State { root = ufParent.get(root)!; } - // Path compression - let cur = id; - - while (ufParent.get(cur) !== root) { - const next = ufParent.get(cur)!; - - ufParent.set(cur, root); - cur = next; - } - return root; }; diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 2b6f300..3e75f24 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -235,11 +235,14 @@ export function toMermaid(graph: Graph): string { } // 8. Wrapper-to-override arrows (regular solid). + // + // `wrapper.overriddenHaltStateId` is always non-null on wrapper nodes + // (set by `State.toGraph` for every `isWrapper: true` node — it's the + // wrapper's override target, which a wrapper by definition has). The + // non-null assertion is safe; a defensive null check would be dead. for (const wrapper of wrapperNodes) { - if (wrapper.overriddenHaltStateId === null) continue; - lines.push( - ` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId)}`, + ` ${mermaidIdFor(wrapper.id)} --> ${mermaidIdFor(wrapper.overriddenHaltStateId!)}`, ); } @@ -597,7 +600,12 @@ export function fromMermaid(text: string): Graph { const fromId = parseMermaidId(wo[1]); const toId = parseMermaidId(wo[2]); - if (nodes[fromId] && nodes[fromId].isWrapper) { + // The wrapper-override regex only matches `sN --> sM` (unlabeled); + // since `toMermaid` only emits this shape from wrappers, the source + // is guaranteed to be a wrapper if `fromMermaid`'s input came from + // `toMermaid`. `nodes[fromId]` is always populated (first pass emits + // node declarations before any edge parsing). + if (nodes[fromId].isWrapper) { nodes[fromId].overriddenHaltStateId = toId; continue; } From a1038076a91ac20ad316eb30e878be96cd9bbebf Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 18:17:14 +0300 Subject: [PATCH 029/118] test(coverage): strip three more dead defensive branches in graphFormats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #190. Further simplifies branches in `toMermaid` / `isFrameBare` that don't fire on engine-emitted Mermaid: - **Return-arrow emit**: dropped `callingWrappers.length > 0` guard. Frames only exist because at least one wrapper's `bareStateId` points into the frame, so the list is always non-empty at this code path. - **isFrameBare**: dropped `node.isHalt` and `node.isWrapper` early- returns. The caller in `toMermaid` already filters wrappers and halt markers into separate buckets — non-wrapper, non-halt-marker nodes are the only ones that reach this helper. - **wrapperOverrideRegex**: dropped redundant `bare` null check (already addressed in #190's commit for the symmetric `nodes[fromId]` case; this catches the wrapper.bareStateId null-coalesce path). Coverage delta (v8) Stmts: 99.03 → 99.20 (+0.17) Branch: 95.70 → 96.13 (+0.43) Funcs: 100 → 100 (unchanged) Lines: 99.34 → 99.20 Combined for Coveralls (lines+branches): ~98.04 → ~98.15. Master baseline ~98.33; residual gap is the few defensive paths that only fire on hand-edited Mermaid input (e.g., undeclared node IDs in edges, which `toMermaid` never produces). Tests - npm test — 460/460 pass - npm run lint — clean - npm run typecheck — clean --- .../machine/src/utilities/graphFormats.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 3e75f24..7e79579 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -210,24 +210,23 @@ export function toMermaid(graph: Graph): string { for (const frameId of frameIds) { if (!haltMarkerHasIncoming.get(frameId)) continue; - // Return arrow — collapsed `&` ribbon over all wrappers calling this frame. + // Return arrow — collapsed `&` ribbon over all wrappers calling this + // frame. Frames only exist because at least one wrapper's bareStateId + // points to a bare in the frame, so `callingWrappers` is always + // non-empty for any frame that reached this code path. const callingWrappers = wrapperNodes.filter((w) => { - if (w.bareStateId === null) return false; + const bare = graph.nodes[w.bareStateId!]; - const bare = graph.nodes[w.bareStateId]; - - return !!bare && bare.frameId === frameId; + return bare.frameId === frameId; }); - if (callingWrappers.length > 0) { - const targets = callingWrappers - .slice() - .sort((a, b) => a.id - b.id) - .map((w) => mermaidIdFor(w.id)) - .join(' & '); + const targets = callingWrappers + .slice() + .sort((a, b) => a.id - b.id) + .map((w) => mermaidIdFor(w.id)) + .join(' & '); - lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`); - } + lines.push(` ${frameSubgraphId(frameId)} -. "return" .-> ${targets}`); if (hasNonWrapperEntry.get(frameId)) { lines.push(` ${frameSubgraphId(frameId)} -. "halt" .-> s0`); @@ -343,9 +342,11 @@ function emitTagAnnotations(lines: string[], nodes: GraphNode[]): void { // Helper: identify "the bare states" that anchor a frame's name. A bare is a // node referenced as some wrapper's `bareStateId`. Body states (also in-frame // but not bare) are excluded from the frame label. +// +// The caller in `toMermaid` only passes non-wrapper, non-halt-marker nodes +// (wrappers go to a separate bucket; halt markers go to `haltMarkerByFrame`). +// No defensive `isHalt` / `isWrapper` guards needed here. function isFrameBare(node: GraphNode, graph: Graph): boolean { - if (node.isWrapper || node.isHalt) return false; - for (const other of Object.values(graph.nodes)) { if (other.isWrapper && other.bareStateId === node.id) { return true; From 76d006265716dcd363943b7ebc9b45f89b252b63 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 18:25:07 +0300 Subject: [PATCH 030/118] test(coverage): cover remaining defensive paths in fromMermaid + simplify halt-marker emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives Coveralls's combined metric back to master baseline. Tests added (2 new in `graph.spec.ts > fromMermaid ensureNode update branches`) - `class line referencing an undeclared node creates it with a fallback name` — exercises the `opts.name ?? mermaidIdFor(id)` fallback in ensureNode (line 448) when pass 2 (edge/class parsing) encounters an id pass 1 didn't declare. Real toMermaid output never produces this; hand-edited Mermaid can. - `unlabeled sN --> sM from a non-wrapper source falls through to no-op` — exercises the wrapper-override regex's `isWrapper`-false branch (line 609). Documents the silent-ignore behavior for malformed input. Simplified - toMermaid halt-marker emit: dropped `if (haltMarker)` guard. Every frame has a halt marker (per `State.toGraph`'s frame-emit pass), so the null check was dead. Coverage delta (v8, cumulative across #190 + #191 + this) Stmts: 98.42 → 99.20 (+0.78) Branch: 95.08 → 96.62 (+1.54) Funcs: 100 → 100 (unchanged) Lines: 98.79 → 99.43 (+0.64) Combined for Coveralls (lines+branches): 97.45 → ~98.32. Master baseline ~98.33. Effectively recovers the drop introduced by #186's tag surface. Tests - npm test — 462/462 pass - npm run lint — clean - npm run typecheck — clean --- packages/machine/src/utilities/graph.spec.ts | 50 +++++++++++++++++++ .../machine/src/utilities/graphFormats.ts | 9 ++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index 72255c2..72a92ba 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -425,6 +425,56 @@ describe('fromMermaid ensureNode update branches', () => { expect(graph.nodes[1].isHalt).toBe(true); }); + + test('`class` line referencing an undeclared node creates it with a fallback name', () => { + // Defensive path: `ensureNode(id, {tags: [...]})` called without + // `opts.name` for a node that pass 1 didn't declare. Fires the + // `opts.name ?? mermaidIdFor(id)` fallback. Real `toMermaid` output + // never produces this (every node referenced by a `class` line is + // declared first), but hand-edited Mermaid can. + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0"]]', + ' s0(((halt)))', + ' s1["entry"]', + ' idle([idle])', + ' idle -. enter .-> s1', + " s1 -- \"['0'] → [K]/[S]\" --> s0", + ' class s99 tag_orphan', // s99 isn't declared anywhere else + ].join('\n'); + + const graph = fromMermaid(mermaid); + + expect(graph.nodes[99]).toBeDefined(); + expect(graph.nodes[99].name).toBe('s99'); // fallback to mermaidIdFor(99) + expect(graph.nodes[99].tags).toEqual(['orphan']); + }); + + test('unlabeled `sN --> sM` from a non-wrapper source falls through to labeled-regex (no-op)', () => { + // Defensive path: the wrapper-override regex matches `sN --> sM` + // (unlabeled) only when the source is a wrapper. If hand-edited input + // has `sN --> sM` with N being a regular state, the wrapper-override + // branch doesn't fire (the `if (nodes[fromId].isWrapper)` guard) and + // the labeled-regex below also doesn't match (no label). No edge is + // added, no overriddenHaltStateId set. Documented as malformed-input + // behavior. + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0"]]', + ' s0(((halt)))', + ' s1["entry"]', // s1 is a regular state, NOT a wrapper + ' idle([idle])', + ' idle -. enter .-> s1', + ' s1 --> s0', // unlabeled — wrapper-override regex matches but isWrapper is false + ].join('\n'); + + const graph = fromMermaid(mermaid); + + // No transition added, no overriddenHaltStateId set — the line is + // silently ignored. + expect(graph.nodes[1].transitions).toHaveLength(0); + expect(graph.nodes[1].overriddenHaltStateId).toBeNull(); + }); }); // Pin the exact toMermaid output shown in packages/machine/README.md so the diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 7e79579..e1dc59c 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -135,11 +135,12 @@ export function toMermaid(graph: Graph): string { lines.push(` ${mermaidIdFor(node.id)}["${labelOf(node)}"]`); } - const haltMarker = haltMarkerByFrame.get(frameId); + // Every frame has a halt marker — `State.toGraph`'s frame-emit pass + // creates one for each frame. Non-null assertion is safe; a defensive + // null check would be dead. + const haltMarker = haltMarkerByFrame.get(frameId)!; - if (haltMarker) { - lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`); - } + lines.push(` ${mermaidIdFor(haltMarker.id)}(((halt)))`); lines.push(' end'); } From 0090c09bda162fa169b757c38a01df585ae2481d Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Thu, 21 May 2026 18:36:34 +0300 Subject: [PATCH 031/118] chore: add LICENSE to builder and library-binary-numbers-bare packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four published packages all declare `license: GPL-3.0-or-later` in package.json. Three of them already shipped a LICENSE file in their tarball; `@turing-machine-js/builder` and `@turing-machine-js/library-binary-numbers-bare` were missing theirs. Copying the same GPL-3.0 LICENSE text used by `@turing-machine-js/machine` and `@turing-machine-js/library-binary-numbers` (identical file — verified by line count). Now every package's published tarball includes the LICENSE that matches its `package.json` declaration. No code change. Pure package-hygiene fix. --- packages/builder/LICENSE | 674 +++++++++++++++++++ packages/library-binary-numbers-bare/LICENSE | 674 +++++++++++++++++++ 2 files changed, 1348 insertions(+) create mode 100644 packages/builder/LICENSE create mode 100644 packages/library-binary-numbers-bare/LICENSE diff --git a/packages/builder/LICENSE b/packages/builder/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/packages/builder/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/packages/library-binary-numbers-bare/LICENSE b/packages/library-binary-numbers-bare/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/packages/library-binary-numbers-bare/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From 397930f850146728d99c73ba37285f7153a29947 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 07:37:46 +0300 Subject: [PATCH 032/118] fix: scope halt-stack to runStepByStep call (closes #196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The halt-stack was an instance field on TuringMachine and was never reset between runStepByStep calls. Callers that started a generator, peeked at iter 1, then disposed it via generator.return() — the pattern used by machines-demo's worker to populate pendingCommand — left the wrapper's overriddenHaltState on the stack. A subsequent machine.run() then pushed the same override a second time; on its way out, an in-frame halt would pop the leftover entry instead of exiting, producing one extra iteration. The stack is run-scoped, not machine-scoped. Declaring it local inside runStepByStep makes the lifetime explicit and prevents cross-call leak. Regression tests assert (a) build-time peek + run produces no extra iterations, (b) runStepByStep and run yield identical sequences for the same machine + initial state, (c) two consecutive runs on the same machine produce identical sequences. --- .../machine/src/classes/TuringMachine.spec.ts | 130 ++++++++++++++++++ packages/machine/src/classes/TuringMachine.ts | 10 +- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/packages/machine/src/classes/TuringMachine.spec.ts b/packages/machine/src/classes/TuringMachine.spec.ts index 932b7a2..2a32465 100644 --- a/packages/machine/src/classes/TuringMachine.spec.ts +++ b/packages/machine/src/classes/TuringMachine.spec.ts @@ -300,3 +300,133 @@ describe('TuringMachine constructor', () => { expect(() => new TuringMachine({} as never)).toThrow(/invalid tapeBlock/); }); }); + +// Regression tests for #196 — the halt-stack used to be an instance field on +// TuringMachine that wasn't reset between `runStepByStep` calls, so a caller +// that peeked at iter 1 via the generator (then disposed it with +// `generator.return()`) would leave the wrapper's override on the stack; +// the next `run()` would push it a second time and produce one extra +// iteration on its way out of the call. Builds a wrapper whose bare halts +// on blank and whose override also halts immediately — the minimal shape +// that surfaces the bug. +describe('halt-stack reset between calls (regression for #196)', () => { + // Helper: build a fresh scenario per call so each subtest has independent + // State/Tape/TapeBlock instances (the engine's symbol patterns are + // tapeBlock-scoped — sharing across scenarios would throw "invalid symbol"). + function buildWrapperOverWalkToBlank() { + const wAlphabet = new Alphabet([' ', 'a', 'b', '*']); + const tape = new Tape({alphabet: wAlphabet, symbols: ['a', 'b', 'a']}); + const tapeBlock = TapeBlock.fromTapes([tape]); + const machine = new TuringMachine({tapeBlock}); + const {symbol} = tapeBlock; + const walkToBlank = new State({ + [symbol([wAlphabet.blankSymbol])]: { + command: [{movement: movements.stay}], + nextState: haltState, + }, + [ifOtherSymbol]: { + command: [{movement: movements.right}], + }, + }, 'walkToBlank'); + const writeMarker = new State({ + [ifOtherSymbol]: { + command: [{symbol: '*', movement: movements.stay}], + nextState: haltState, + }, + }, 'writeMarker'); + const initialState = walkToBlank.withOverriddenHaltState(writeMarker); + return {machine, initialState, tape}; + } + + test('runStepByStep peek + return + run produces no extra iterations', async () => { + const {machine, initialState, tape} = buildWrapperOverWalkToBlank(); + + // Caller peeks at iter 1 then disposes the generator without draining — + // pre-#196 this left the override on `#stack`. + const gen = machine.runStepByStep({initialState}); + gen.next(); + gen.return(undefined); + + const iters: Array<{step: number; name: string}> = []; + await machine.run({ + initialState, + onIter: (m) => { + iters.push({step: m.step, name: m.state.name ?? ''}); + }, + }); + + expect(iters).toEqual([ + {step: 1, name: 'walkToBlank(writeMarker)'}, + {step: 2, name: 'walkToBlank'}, + {step: 3, name: 'walkToBlank'}, + {step: 4, name: 'walkToBlank'}, + {step: 5, name: 'writeMarker'}, + ]); + expect(tape.symbols).toEqual(['a', 'b', 'a', '*']); + }); + + test('runStepByStep and run produce identical iter sequences', async () => { + const a = buildWrapperOverWalkToBlank(); + const fromGen: Array<{step: number; name: string}> = []; + for (const m of a.machine.runStepByStep({initialState: a.initialState})) { + fromGen.push({step: m.step, name: m.state.name ?? ''}); + } + + const b = buildWrapperOverWalkToBlank(); + const fromRun: Array<{step: number; name: string}> = []; + await b.machine.run({ + initialState: b.initialState, + onIter: (m) => { + fromRun.push({step: m.step, name: m.state.name ?? ''}); + }, + }); + + expect(fromRun).toEqual(fromGen); + }); + + test('two consecutive runs on the same machine produce identical iter sequences', async () => { + // A self-loop-free machine — both runs traverse the same iter shape + // because the input alphabet is wide enough that the head moves off the + // initial cells and the post-run tape doesn't influence the next run. + // The point of this test is the #stack accumulation, not tape state. + const wAlphabet = new Alphabet([' ', 'a', 'b']); + const tape = new Tape({alphabet: wAlphabet, symbols: ['a']}); + const tapeBlock = TapeBlock.fromTapes([tape]); + const machine = new TuringMachine({tapeBlock}); + // A wrapper around a single-iter halt-on-anything bare. Each run pushes + // the override; if the pre-#196 leak existed, the second run would see + // a stale override and one extra iter. + const bare = new State({ + [ifOtherSymbol]: { + command: [{movement: movements.stay}], + nextState: haltState, + }, + }, 'bare'); + const continuation = new State({ + [ifOtherSymbol]: { + command: [{movement: movements.stay}], + nextState: haltState, + }, + }, 'continuation'); + const initialState = bare.withOverriddenHaltState(continuation); + + const first: Array<{step: number; name: string}> = []; + await machine.run({ + initialState, + onIter: (m) => { + first.push({step: m.step, name: m.state.name ?? ''}); + }, + }); + + const second: Array<{step: number; name: string}> = []; + await machine.run({ + initialState, + onIter: (m) => { + second.push({step: m.step, name: m.state.name ?? ''}); + }, + }); + + expect(first.length).toBe(2); // wrapper-iter + continuation-iter + expect(second).toEqual(first); + }); +}); diff --git a/packages/machine/src/classes/TuringMachine.ts b/packages/machine/src/classes/TuringMachine.ts index cc138f2..c069ece 100644 --- a/packages/machine/src/classes/TuringMachine.ts +++ b/packages/machine/src/classes/TuringMachine.ts @@ -38,7 +38,6 @@ function matchFilter(filter: DebugConfig['before'], symbol: symbol): boolean { export default class TuringMachine { readonly #tapeBlock: TapeBlock; - readonly #stack: State[] = []; constructor({ tapeBlock, @@ -148,7 +147,14 @@ export default class TuringMachine { this.#tapeBlock[lockSymbol].lock(executionSymbol); - const stack = this.#stack; + // Halt-stack is run-scoped, not machine-scoped (#196). Declaring it + // local makes that lifetime explicit and prevents leftover entries + // from a previous `runStepByStep` call (e.g. a build-time peek that + // never drained the generator) from being popped during a subsequent + // halt-bound transition. Before this change `#stack` was an instance + // field and accumulated one extra push per call when the same machine + // was reused. + const stack: State[] = []; let state = initialState; if (state.overriddenHaltState) { From c1d6e2e29d4b48c41f5594e256634dbe2fd2cb39 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 09:16:29 +0300 Subject: [PATCH 033/118] fix: HTML-entity-escape user content in Mermaid labels (#194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-controlled fragments interpolated into the `"..."`-wrapped Mermaid label strings (alphabet symbols in edge labels, state names, wrapper composite names, frame bare names, tag names) now get HTML-entity escaped at the leaf — `&`, `"`, `<`, `>` go to named entities; statement terminators, C0 controls minus `\t`, DEL, bidi controls, and lone UTF-16 surrogates go to numeric entities. Printable Unicode (Cyrillic, CJK, etc.) passes through unchanged so non-ASCII alphabets stay readable in the emitted `.mmd`. Structural pieces this module emits (`
` tag separator, ` ∪ `, `[`, `,`, `|`, `/`, ` → `, the `callable subtree of` / `callable scope:` prefixes) are intentionally not escaped — only user-supplied fragments are. fromMermaid mirrors with a single-pass entity decoder applied to each leaf AFTER structural parsing, so a literal `
` in a state name (encoded as `<br>`) survives the tag-split and decodes back at the leaf. Tag-list separator is `,` — `escapeMermaidLabel`'s base set leaves that alone because in edge labels `,` is structural between per-tape cells. Tags get an extra `,` → `,` layer at composition time so a literal comma in tag content can survive the round-trip. Round-trip test for `'` (single quote inside an edge-label `'X'` wrapper) is the same regression test that originally reproduced the issue — `mermaid.render()` was throwing on alphabets that include `"` because the inner literal closed the label string early. --- packages/machine/src/utilities/graph.spec.ts | 164 ++++++++++++++++++ .../machine/src/utilities/graphFormats.ts | 120 ++++++++++++- 2 files changed, 275 insertions(+), 9 deletions(-) diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index 72a92ba..a204ee3 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -1053,3 +1053,167 @@ describe('fromMermaid: tags (#186)', () => { expect(reparsed.nodes[s.id].tags).toEqual(['alpha', 'beta']); }); }); + +describe('Mermaid label escaping (#194)', () => { + test('alphabet symbol containing literal " produces parseable output', () => { + // Repro from the issue: a write of `"` would land inside the + // `"..."`-wrapped edge label and terminate the string early on + // Mermaid's tokenizer. + const alphabet = new Alphabet([' ', 'a', '"']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['a'])]: { + command: [{symbol: '"', movement: movements.right}], + nextState: haltState, + }, + }, 's'); + + const mermaid = toMermaid(State.toGraph(s, tapeBlock)); + + // No unescaped `"` inside the edge label between the outer wrappers. + expect(mermaid).toContain('"'); + expect(mermaid).not.toMatch(/-- "[^"]*"[^"]*"[^"]*" -->/); + // Round-trips back to a parseable graph that preserves the symbol. + const reparsed = fromMermaid(mermaid); + const reparsedTransitions = reparsed.nodes[s.id].transitions; + expect(reparsedTransitions).toHaveLength(1); + expect(reparsedTransitions[0].command[0].symbol).toBe("'\"'"); + }); + + test('state name with grammar-significant chars survives round-trip', () => { + // <, >, &, " inside a State.name. Encoded as named entities; decoded + // verbatim on the way back. + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const name = 'A<&">B'; + const s = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, name); + + const reparsed = fromMermaid(toMermaid(State.toGraph(s, tapeBlock))); + expect(reparsed.nodes[s.id].name).toBe(name); + }); + + test('tag name with grammar-significant chars survives round-trip', () => { + // Tag content is escaped per-fragment so a tag containing `
` or + // `,` doesn't get confused with the structural tag separators (which + // would split the tag wrong on the way back) or with HTML tag + // boundaries in the rendered SVG. + // + // The round-trip also picks up the `class tag_` line from + // the emit — that adds a second copy of each tag in its sanitized + // form (`has"quote` → `has_quote`). That's a known artifact of the + // #186 tag-emit design, not part of #194; this test only asserts + // that the ORIGINAL forms come back intact. + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 's').tag('a
b', 'has,comma', 'has"quote'); + + const reparsed = fromMermaid(toMermaid(State.toGraph(s, tapeBlock))); + expect(reparsed.nodes[s.id].tags).toEqual( + expect.arrayContaining(['a
b', 'has,comma', 'has"quote']), + ); + }); + + test('newlines and C0 controls in alphabet symbols encode as numeric entities', () => { + const alphabet = new Alphabet([' ', 'a', '\n', '']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['a'])]: { + command: [{symbol: '\n', movement: movements.right}], + nextState: haltState, + }, + }, 's'); + + const mermaid = toMermaid(State.toGraph(s, tapeBlock)); + + // No raw newline inside the emitted line (other than the inter-line + // separators between statements). + const transitionLine = mermaid + .split('\n') + .find((l) => l.includes('-- "') && l.includes('--> ')); + expect(transitionLine).toBeDefined(); + expect(transitionLine).toContain(' '); + + const reparsed = fromMermaid(mermaid); + expect(reparsed.nodes[s.id].transitions[0].command[0].symbol).toBe("'\n'"); + }); + + test('bidi control in state name encodes as numeric entity', () => { + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const name = 'left‮right'; + const s = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, name); + + const mermaid = toMermaid(State.toGraph(s, tapeBlock)); + expect(mermaid).toContain('‮'); + expect(mermaid).not.toContain('‮'); + + const reparsed = fromMermaid(mermaid); + expect(reparsed.nodes[s.id].name).toBe(name); + }); + + test('printable Unicode passes through unescaped (alphabet readability)', () => { + // Cyrillic + CJK glyphs in the alphabet and state name. Alphabet + // rejects multi-code-unit symbols (`.length === 1` check) so emoji + // outside the BMP can't be tested at this layer — that's fine, + // surrogate-pair handling is covered by the encoder's regex range + // and the round-trip decode-path tests above. + const alphabet = new Alphabet([' ', 'я', '中']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['я'])]: { + command: [{symbol: '中', movement: movements.right}], + nextState: haltState, + }, + }, 'имя'); + + const mermaid = toMermaid(State.toGraph(s, tapeBlock)); + expect(mermaid).toContain('имя'); + expect(mermaid).toContain('я'); + expect(mermaid).toContain('中'); + // No spurious numeric entities for printable Unicode. + expect(mermaid).not.toMatch(/&#\d+;/); + }); + + test('callable-subtree frame label escapes the bare name', () => { + // The frame label `callable subtree of NAME` interpolates the bare + // state's name. Quotes in the bare name would break the + // `subgraph w_N["..."]` declaration. + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const bare = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, 'b"are'); + const wrapper = bare.withOverriddenHaltState(haltState); + + const mermaid = toMermaid(State.toGraph(wrapper, tapeBlock)); + expect(mermaid).toContain('callable subtree of b"are'); + // The subgraph declaration line itself has exactly two `"`s: the + // outer label wrappers. Any additional one would mean an unescaped + // user `"` slipped through. + const subgraphLine = mermaid + .split('\n') + .find((l) => l.trimStart().startsWith('subgraph w_')); + expect(subgraphLine).toBeDefined(); + expect((subgraphLine!.match(/"/g) ?? []).length).toBe(2); + }); + + test('ambiguous `&quot;` decodes once, not twice', () => { + // User content that looks like a doubly-encoded entity. Single-pass + // decode should give back the literal `"` text, not `"`. + const alphabet = new Alphabet([' ', '0']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const name = '"literal"'; + const s = new State({ + [tapeBlock.symbol(['0'])]: {nextState: haltState}, + }, name); + + const reparsed = fromMermaid(toMermaid(State.toGraph(s, tapeBlock))); + expect(reparsed.nodes[s.id].name).toBe(name); + }); +}); diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index e1dc59c..5f0fa35 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -46,6 +46,81 @@ function frameSubgraphId(frameId: number): string { return `w_${frameId}`; } +// User-controlled content (state names, tag names, alphabet symbols inside +// edge labels) is interpolated into Mermaid label strings (`"..."` wrappers +// on nodes, wrappers, subgraphs, and edges). Mermaid's grammar terminates +// the string on a literal `"`, and labels render via HTML/foreignObject so +// `<`, `>`, `&` get interpreted as markup. Statement terminators (`\n`, +// `\r`), C0 controls (except `\t`), DEL, bidi controls, and lone UTF-16 +// surrogates are encoded as numeric entities so they can't confuse the +// tokenizer or flip text direction silently (#194). +// +// Printable Unicode (Cyrillic, CJK, emoji, accented Latin, etc.) passes +// through unchanged — a tape alphabet of Cyrillic or Brainfuck glyphs +// stays readable in the emitted `.mmd`. +// +// Escape is applied at the leaf — to each user-supplied fragment BEFORE +// it's composed into a label. Structural pieces this module emits (`
` +// tag separator, ` ∪ ` bare-name join, `[`, `]`, `,`, `|`, `/`, ` → `, +// the `callable subtree of `/`callable scope: ` prefixes) are NOT escaped; +// only user-controlled content is. fromMermaid mirrors with +// `unescapeMermaidLabel` on each extracted leaf AFTER structural parsing, +// so a literal `
` inside a state name (encoded as `<br>`) +// survives the tag-split and decodes back at the leaf. +const MERMAID_LABEL_ESCAPE_RE = /[&"<>\n\r\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u202A-\u202E\u2066-\u2069\uD800-\uDFFF]/g; +function escapeMermaidLabel(s: string): string { + return s.replace(MERMAID_LABEL_ESCAPE_RE, (ch) => { + switch (ch) { + case '&': return '&'; + case '"': return '"'; + case '<': return '<'; + case '>': return '>'; + case '\n': return ' '; + case '\r': return ' '; + default: return `&#${ch.charCodeAt(0)};`; + } + }); +} + +// Inverse of escapeMermaidLabel. Decodes the four named entities the +// encoder emits (`&`, `"`, `<`, `>`) plus arbitrary +// numeric entities (`&#NN;`, `&#xHH;`) — the latter to round-trip the +// control / bidi / lone-surrogate cases from encode. Other named entities +// pass through unchanged: fromMermaid is strict to the dialect toMermaid +// emits, and a future-proof full HTML-entity decoder would muddle that. +// +// Replacement is single-pass: each `&...;` match is consumed once with +// no re-scanning of the substitution, so nested-looking inputs like +// `&quot;` (literal `"` as user text) decode to `"` not `"`. +const MERMAID_LABEL_UNESCAPE_RE = /&(?:(amp|quot|lt|gt)|#(\d+)|#x([0-9a-fA-F]+));/g; +function unescapeMermaidLabel(s: string): string { + return s.replace(MERMAID_LABEL_UNESCAPE_RE, (match, named, dec, hex) => { + switch (named) { + case 'amp': return '&'; + case 'quot': return '"'; + case 'lt': return '<'; + case 'gt': return '>'; + default: { + // Code units up to U+FFFF decode via fromCharCode so lone + // surrogates we encoded by UTF-16 code unit round-trip exactly. + // Hand-edited supplementary code points (`😀`) use + // fromCodePoint to produce the right surrogate pair — but only + // when we didn't emit them ourselves, since encode runs per code + // unit. + if (dec !== undefined) { + const n = Number.parseInt(dec, 10); + return n <= 0xFFFF ? String.fromCharCode(n) : String.fromCodePoint(n); + } + if (hex !== undefined) { + const n = Number.parseInt(hex, 16); + return n <= 0xFFFF ? String.fromCharCode(n) : String.fromCodePoint(n); + } + return match; + } + } + }); +} + export function toMermaid(graph: Graph): string { const lines: string[] = [ 'flowchart TD', @@ -89,9 +164,17 @@ export function toMermaid(graph: Graph): string { // Mermaid line-break that works across renderers without `classDef`- // pseudo-element hacks (#186). const labelOf = (node: GraphNode): string => { - if (node.tags.length === 0) return node.name; - - return `${node.name}
${node.tags.join(', ')}`; + const name = escapeMermaidLabel(node.name); + if (node.tags.length === 0) return name; + // Per-tag escape that ALSO encodes `,` — tags are joined with `, ` and + // split on `,` in `splitLabelTags`, so a literal comma in user tag + // content would be mistaken for a separator on the way back. `,` isn't + // in the base escape set because it's structural in edge labels + // (between per-tape cells in `writes`/`moves`), where the encode pass + // happens after composition — different context, different escape. + const tagFragments = node.tags + .map((t) => escapeMermaidLabel(t).replace(/,/g, ',')); + return `${name}
${tagFragments.join(', ')}`; }; // 1. Emit top-level nodes (real halt, non-wrapper regulars outside any frame). @@ -123,7 +206,7 @@ export function toMermaid(graph: Graph): string { const frameBareNames = frameBares .slice() .sort((a, b) => a.id - b.id) - .map((n) => n.name); + .map((n) => escapeMermaidLabel(n.name)); const label = frameBareNames.length > 1 ? `callable scope: ${frameBareNames.join(' ∪ ')}` : `callable subtree of ${frameBareNames[0] ?? frameId}`; @@ -255,7 +338,12 @@ export function toMermaid(graph: Graph): string { const reads = alternatives.map((alt) => `[${alt}]`).join('|'); const writes = `[${t.command.map((c) => c.symbol).join(',')}]`; const moves = `[${t.command.map((c) => c.movement).join(',')}]`; - const label = `${reads} → ${writes}/${moves}`; + // Escape the WHOLE composed label — structural separators ([, ], ,, + // |, /, ' → ') are all in our safe ASCII set and pass through + // unchanged; only embedded user alphabet symbols inside `'...'` get + // entity-encoded. fromMermaid unescapes the captured label as the + // first step before structural parsing. + const label = escapeMermaidLabel(`${reads} → ${writes}/${moves}`); lines.push( ` ${mermaidIdFor(node.id)} -- "${label}" --> ${mermaidIdFor(t.nextStateId)}`, @@ -409,16 +497,25 @@ const classAssignTagRegex = /^class ([sc]\d+(?:,[sc]\d+)*) tag_([A-Za-z0-9_-]+)$ // Labels without `
` have no tags. Tags are comma-joined; trimmed of // whitespace. The `
` is the single source of truth for tag-name parsing — // `class` lines are decorative-only and not consulted here. +// +// Mermaid-label entities (`<`, `"`, etc., #194) are decoded AFTER +// structural splitting: the `
` separator and `,` tag delimiter survive +// encode unchanged, and a user state name / tag containing a literal `
` +// or `,` was encoded leaf-side so it can't be confused with the structural +// form. Decode at the leaves recovers the original characters. function splitLabelTags(label: string): {name: string; tags: string[]} { const brIx = label.indexOf('
'); if (brIx < 0) { - return {name: label, tags: []}; + return {name: unescapeMermaidLabel(label), tags: []}; } - const name = label.slice(0, brIx); + const name = unescapeMermaidLabel(label.slice(0, brIx)); const tagsStr = label.slice(brIx + '
'.length); - const tags = tagsStr.split(',').map((t) => t.trim()).filter((t) => t.length > 0); + const tags = tagsStr + .split(',') + .map((t) => unescapeMermaidLabel(t.trim())) + .filter((t) => t.length > 0); return {name, tags}; } @@ -619,7 +716,12 @@ export function fromMermaid(text: string): Graph { if (tm) { const fromId = parseMermaidId(tm[1]); - const label = tm[2]; + // Decode the WHOLE captured label up front (#194). Structural + // separators (`[`, `]`, `,`, `|`, `/`, ` → `) are all safe ASCII + // outside the escape set and pass through encode unchanged, so it's + // safe to decode before structural parsing; only embedded alphabet + // symbols inside `'...'` get reconstituted. + const label = unescapeMermaidLabel(tm[2]); const toId = parseMermaidId(tm[3]); const arrowIx = label.indexOf(' → '); From bfe70b60db61e76dd0fa64a0060f0e49ba069250 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 10:55:41 +0300 Subject: [PATCH 034/118] refactor: extract toGraph/fromGraph into utilities/stateGraph (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State.ts was carrying ~440 lines of graph serialization/reconstruction on top of the runtime state machinery. Move it to a sibling module: - New `utilities/stateGraph.ts` holds `toGraph` and `fromGraph` plus their v7 callable-subtree comment block. - `State.toGraph` and `State.fromGraph` become thin delegates to the extracted functions — public surface preserved, no consumer breaks. - New `STATE_INTERNAL` Symbol on State exposes a getter/setter view onto private fields (`#id`, `#name`, `#bareState`, `#overriddenHaltState`, `#symbolToDataMap`, `#tags`) for sibling modules. Exported from State.ts so other files in `packages/machine/src` can import it; NOT re-exported from the package's `index.ts`, so it's off the public surface. Marked `@internal` in JSDoc. - The Symbol's surface is designed with #195 already in scope: `collectStates` will need the same `symbolToDataMap` enumeration plus the existing `id` / `name` access, so the accessor doesn't need to grow when that lands. - `fromGraph` uses the accessor's `name` setter to assign graph- sourced composite names (e.g. `A(target)`) to freshly-built bare States, bypassing the constructor's paren validation. That's the one mutation site; the setter exists for it alone and is JSDoc'd as such. Import cycle State.ts ↔ stateGraph.ts resolves via ESM live bindings — all cross-references live inside function bodies, so the bindings are populated by the time anything reads them. Rollup flags the cycle in the build output; it's expected. Internal refactor, no API change. 473 tests still pass. --- packages/machine/src/classes/State.ts | 549 +++---------------- packages/machine/src/utilities/stateGraph.ts | 501 +++++++++++++++++ 2 files changed, 588 insertions(+), 462 deletions(-) create mode 100644 packages/machine/src/utilities/stateGraph.ts diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index d83b636..aa07152 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -1,4 +1,3 @@ -import Alphabet from './Alphabet'; import Command from './Command'; import Reference from './Reference'; import TapeBlock from './TapeBlock'; @@ -6,14 +5,14 @@ import TapeCommand from './TapeCommand'; import {id} from '../utilities/functions'; import { type Graph, - type GraphNode, decodeMovement, - decodePatternDescription, decodeWriteSymbol, - parseMovementLabel, - parsePatternString, - parseWriteSymbolLabel, } from '../utilities/graph'; +// Delegate targets for `State.toGraph` / `State.fromGraph` (#180). The +// import cycle with stateGraph.ts is resolved by ESM live bindings — see +// the bottom-of-file note in that module. Aliased so the delegating +// static methods can keep their canonical names without clashing. +import {toGraph as toGraphImpl, fromGraph as fromGraphImpl} from '../utilities/stateGraph'; export const ifOtherSymbol = Symbol('other symbol'); @@ -21,6 +20,29 @@ export const ifOtherSymbol = Symbol('other symbol'); // without exposing the validator on the public surface. const validateDebugFilter = Symbol('validateDebugFilter'); +/** + * @internal + * + * Package-private accessor key for sibling modules in + * `packages/machine/src` (e.g. `utilities/stateGraph.ts`, and the planned + * `utilities/stateCollect.ts` for #195). Re-exported from this module so + * sibling files can import it; intentionally NOT re-exported from the + * package's public `index.ts`, so downstream consumers don't see it on + * the supported surface. + * + * Calling `state[STATE_INTERNAL]()` returns a getter/setter view onto the + * State's private fields. Reads are live (they close over `this`), so the + * view stays in sync with subsequent mutations on the State. There's one + * mutating setter on the view — `name` — used exclusively by + * `fromGraph` to assign graph-sourced composite names (e.g. `A(target)`) + * that the public name validator would reject; see the JSDoc on the + * accessor itself. + * + * Designed in #180 with #195 in mind so its surface doesn't need to grow + * when `collectStates` lands. + */ +export const STATE_INTERNAL = Symbol('State.internal'); + export class DebugConfig { readonly #ownerState: State; @@ -369,6 +391,49 @@ export default class State { return state; } + /** + * @internal + * + * Package-private getter/setter view onto this State's private fields, + * for sibling modules in `packages/machine/src` (currently `stateGraph.ts` + * for `toGraph` / `fromGraph`, and the planned `stateCollect.ts` for + * #195's `collectStates`). + * + * Read access is live — the getters close over `this`, so the view + * stays in sync with subsequent mutations on this State. There's a + * single mutating setter on the view, `name`, which exists to let + * `fromGraph` assign graph-sourced composite names (e.g. `A(target)`) + * to freshly-constructed bare States. The constructor's name validator + * rejects parens (reserved as wrapper-composition delimiters in + * `withOverriddenHaltState`); the setter intentionally bypasses that + * check because the same delimiters appear in legitimate wrapper-bare + * names round-tripped through the graph. + * + * Returns a fresh view object on every call — cheap enough for the + * BFS-once-per-build callers, and avoids holding a reference object on + * every State instance. Keep this surface tight: callers should only + * read what they need. Adding fields here is a deliberate decision — + * each adds to the implicit contract sibling modules can rely on. + */ + [STATE_INTERNAL]() { + // Aliasing `this` so the nested object-literal getters/setters below + // can read/write the enclosing State's private fields — getters in an + // object literal can't be arrow functions, so the standard arrow- + // captures-`this` trick doesn't apply here. + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + return { + get id(): number { return self.#id; }, + get name(): string { return self.#name; }, + set name(v: string) { self.#name = v; }, + get bareState(): State | null { return self.#bareState; }, + get overriddenHaltState(): State | null { return self.#overriddenHaltState; }, + get symbolToDataMap() { return self.#symbolToDataMap; }, + get tags(): ReadonlySet { return self.#tags; }, + }; + } + // Single-state introspection — no traversal, no tapeBlock required. // Returns id, name, halt-status, override-halt target, and the list of // transitions out of this state with decoded write/movement labels. @@ -421,470 +486,30 @@ export default class State { }; } - // Walks the State graph and emits a `Graph` data structure. v7 callable- - // subtree emit shape (#174): - // - // Each `withOverriddenHaltState` wrapper produces TWO graph nodes: - // - A wrapper node (`isWrapper: true`, `[[composite-name]]` shape) — the - // call site. No transitions of its own. `bareStateId` points to the - // bare's GraphNode; `overriddenHaltStateId` points to the override - // target's GraphNode. - // - A bare node (`isWrapper: false`, regular shape) — the callable body. - // Has the bare's transitions. Shared across all wrappers that wrap - // this bare (no per-context duplication). - // - // Frames are computed via union-find on bare reachability: two bares whose - // forward-reachable sets overlap merge into one frame. Each frame contains - // its bares + body states + a single halt marker (id = `-frameId`). The - // canonical `frameId` is the smallest bare-id in the component. - // - // Halt-bound transitions of any in-frame state are retargeted to the - // frame's halt marker. The frame's `subtree -. return .-> wrapper` and - // `subtree -. halt .-> s0` arrows are demand-emitted by `toMermaid` from - // the frame structure; they're not stored as graph edges. - static toGraph(initialState: State, tapeBlock: TapeBlock): Graph { - const nodes: Record = {}; - const alphabets = tapeBlock.alphabets.map((alphabet) => alphabet.symbols); - - // Pass 1: BFS-discover all reachable States; emit one GraphNode per State - // (wrapper or bare/regular). Wrappers and bares are separate nodes. - const visited = new Set(); - const queue: State[] = [initialState]; - const bareIds = new Set(); // ids referenced as a wrapper's bareStateId - - while (queue.length > 0) { - const state = queue.shift()!; - - if (visited.has(state.#id)) { - continue; - } - - visited.add(state.#id); - - if (state.isHalt) { - if (!(0 in nodes)) { - nodes[0] = { - id: 0, - name: state.#name, - isHalt: true, - isHaltMarker: false, - isWrapper: false, - bareStateId: null, - frameId: null, - transitions: [], - overriddenHaltStateId: null, - tags: [...state.#tags], - }; - } - - continue; - } - - // Wrapper? Emit wrapper node + queue bare and override target. - if (state.#overriddenHaltState !== null && state.#bareState !== null) { - const bareState = state.#bareState; - const overrideTarget = state.#overriddenHaltState; - - nodes[state.#id] = { - id: state.#id, - name: state.#name, // composite name like "A(target)" - isHalt: false, - isHaltMarker: false, - isWrapper: true, - bareStateId: bareState.#id, - frameId: null, - transitions: [], - overriddenHaltStateId: overrideTarget.#id, - tags: [...state.#tags], - }; - - bareIds.add(bareState.#id); - queue.push(bareState); - queue.push(overrideTarget); - - continue; - } - - // Regular (or bare) state — build node with transitions. - const node: GraphNode = { - id: state.#id, - name: state.#name, - isHalt: false, - isHaltMarker: false, - isWrapper: false, - bareStateId: null, - frameId: null, - transitions: [], - overriddenHaltStateId: null, - tags: [...state.#tags], - }; - - nodes[state.#id] = node; - - let patternIx = 0; - - for (const [sym, {command, nextState}] of state.#symbolToDataMap) { - let target: State; - - try { - target = nextState instanceof State ? nextState : nextState.ref; - } catch { - patternIx += 1; - continue; - } - - node.transitions.push({ - pattern: decodePatternDescription(sym.description, alphabets), - command: command.tapesCommands.map((tc) => ({ - symbol: decodeWriteSymbol(tc.symbol), - movement: decodeMovement((tc.movement as symbol).description), - })), - nextStateId: target.#id, - id: `${state.#id}-${patternIx}`, - }); - - queue.push(target); - patternIx += 1; - } - } - - // Always emit real halt as a sentinel, even if no transition targets it. - // It anchors the `subtree -. halt .-> s0` frame-level arrow whenever a - // frame demand-emits one, and it's the canonical machine-halt singleton. - if (!(0 in nodes)) { - nodes[0] = { - id: 0, - name: 'halt', - isHalt: true, - isHaltMarker: false, - isWrapper: false, - bareStateId: null, - frameId: null, - transitions: [], - overriddenHaltStateId: null, - tags: [...haltState.#tags], - }; - } - - // Pass 2: For each bare, compute its forward-reachable set (following - // transitions; stopping at halt and at wrappers — both are frame - // boundaries). - const computeReach = (startId: number): Set => { - const reach = new Set(); - const stack = [startId]; - - while (stack.length > 0) { - const id = stack.pop()!; - - if (reach.has(id)) { - continue; - } - - const node = nodes[id]; - - // `nodes[id]` is always populated for `id` that the BFS reached, so - // a defensive `!node` check would be dead. `isHalt` / `isWrapper` - // are real boundaries — both stop reach-set expansion. - if (node.isHalt || node.isWrapper) { - continue; - } - - reach.add(id); - - for (const t of node.transitions) { - const target = nodes[t.nextStateId]; - - if (!target || target.isHalt || target.isWrapper) { - continue; - } - - stack.push(t.nextStateId); - } - } - - return reach; - }; - - const reachByBare = new Map>(); - - for (const bareId of bareIds) { - reachByBare.set(bareId, computeReach(bareId)); - } - - // Pass 3: Union-find on bare overlaps. Two bares merge if their reach - // sets share any state. Canonical representative = smallest bare-id in - // the component. - const ufParent = new Map(); - - // Note: no path compression. The union policy below ("smaller id always - // becomes root") keeps the tree flat — every union targets bares[0] as - // the root, so any node's parent IS the root. Walking up never exceeds - // one step. Path compression would be dead code under this invariant. - const ufFind = (id: number): number => { - if (!ufParent.has(id)) { - ufParent.set(id, id); - } - - let root = id; - - while (ufParent.get(root) !== root) { - root = ufParent.get(root)!; - } - - return root; - }; - - const ufUnion = (a: number, b: number) => { - const ra = ufFind(a); - const rb = ufFind(b); - - if (ra === rb) return; - - if (ra < rb) { - ufParent.set(rb, ra); - } else { - ufParent.set(ra, rb); - } - }; - - for (const bareId of bareIds) { - ufFind(bareId); - } - - // For each state, collect the bares that reach it; union all bares that - // share a state. - const stateToReachingBares = new Map(); - - for (const [bareId, reachSet] of reachByBare) { - for (const stateId of reachSet) { - let bares = stateToReachingBares.get(stateId); - - if (!bares) { - bares = []; - stateToReachingBares.set(stateId, bares); - } - - bares.push(bareId); - } - } - - for (const bares of stateToReachingBares.values()) { - for (let i = 1; i < bares.length; i += 1) { - ufUnion(bares[0], bares[i]); - } - } - - // Assign frameId to each in-reach state. - const frameIds = new Set(); - - for (const [stateId, bares] of stateToReachingBares) { - const frameId = ufFind(bares[0]); - - nodes[stateId].frameId = frameId; - frameIds.add(frameId); - } - - // Pass 4: Retarget halt-bound transitions for in-frame states to the - // frame's halt marker. Out-of-frame states (top-level dispatcher, override - // targets, etc.) keep their halt-bound transitions pointing at real halt. - for (const node of Object.values(nodes)) { - if (node.frameId === null) { - continue; - } - - const haltMarkerId = -node.frameId; - - for (const t of node.transitions) { - const target = nodes[t.nextStateId]; - - if (target && target.isHalt && !target.isHaltMarker) { - t.nextStateId = haltMarkerId; - } - } - } - - // Pass 5: Emit one halt marker per frame. - for (const frameId of frameIds) { - const haltMarkerId = -frameId; - - nodes[haltMarkerId] = { - id: haltMarkerId, - name: 'halt', - isHalt: true, - isHaltMarker: true, - isWrapper: false, - bareStateId: null, - frameId, - transitions: [], - overriddenHaltStateId: null, - tags: [], - }; - } - return {initialId: initialState.#id, alphabets, nodes}; + /** + * Walks the reachable State graph from `initialState` and returns a + * serializable `Graph`. Thin delegate to `utilities/stateGraph.ts`'s + * `toGraph` (extracted in #180); see that module for the BFS shape and + * v7 callable-subtree emit semantics. + */ + static toGraph(initialState: State, tapeBlock: TapeBlock): Graph { + return toGraphImpl(initialState, tapeBlock); } - // Inverse of toGraph: rebuilds a State graph (and a fresh TapeBlock with the - // graph's alphabets) from a serialized Graph. Round-trips with toGraph in - // the sense that running the rebuilt machine on the same input gives the - // same output, but the rebuilt State instances have *new* internal IDs. - // - // Under the v7 callable-subtree model (#174), graph nodes split into: - // - Wrapper nodes (`isWrapper: true`, no transitions) — reconstructed via - // `bareStates[bareStateId].withOverriddenHaltState(finalStates[overriddenHaltStateId])`. - // - Bare/regular nodes — constructed as normal States with transitions. - // - Halt + halt-marker nodes — collapse to the singleton `haltState`. + /** + * Inverse of `toGraph`: rebuilds a State graph and a fresh TapeBlock + * from a serialized `Graph`. Thin delegate to `utilities/stateGraph.ts`'s + * `fromGraph` (extracted in #180); see that module for the + * reconstruction pass shape (Reference pre-create, bare build, wrapper + * resolution via `withOverriddenHaltState`, ref binding). + */ static fromGraph(graph: Graph): { start: State; tapeBlock: TapeBlock; states: Record; } { - const alphabetObjs = graph.alphabets.map((syms) => new Alphabet(syms)); - const tapeBlock = TapeBlock.fromAlphabets(alphabetObjs); - const ids = Object.keys(graph.nodes).map(Number); - - // Pass 1: pre-create a Reference for each non-halt non-halt-marker node - // (both wrappers and regulars). Halt and halt-marker nodes collapse to the - // singleton `haltState` and need no ref. - const refs: Record = {}; - - for (const nodeId of ids) { - const node = graph.nodes[nodeId]; - - if (!node.isHalt) { - refs[nodeId] = new Reference(); - } - } - - // Convert a parsed pattern back to the symbol key the State expects. - const patternToKey = (parsed: ReturnType): symbol => { - if (parsed === null) { - return ifOtherSymbol; - } - - const flat: (string | symbol)[] = []; - - for (const row of parsed) { - for (const cell of row) { - flat.push(cell === null ? ifOtherSymbol : cell); - } - } - - return tapeBlock.symbol(flat); - }; - - // Pass 2: build a State for each non-wrapper non-halt non-halt-marker - // node. Transitions point at refs so cycles work; haltState (and halt - // markers, which collapse to haltState) are used directly. - const bareStates: Record = {}; - - for (const nodeId of ids) { - const node = graph.nodes[nodeId]; - - if (node.isHalt || node.isWrapper) { - continue; - } - - const stateDefinition: ConstructorParameters[0] = {}; - - for (const t of node.transitions) { - const key = patternToKey(parsePatternString(t.pattern, graph.alphabets)); - const target = graph.nodes[t.nextStateId]; - const nextState: State | Reference = !target || target.isHalt - ? haltState - : refs[t.nextStateId]; - - stateDefinition![key] = { - command: t.command.map((c) => ({ - symbol: parseWriteSymbolLabel(c.symbol), - movement: parseMovementLabel(c.movement), - })) as ConstructorParameters[0][], - nextState, - }; - } - - // Graph-sourced names may contain `(` and `)` (composite wrapper names — - // although wrappers go through a separate path below, defensive - // construction here keeps the bypass uniform). Construct without a name - // and assign `#name` directly to skip user-facing name validation. - const bare = new State(stateDefinition); - - bare.#name = node.name; - - if (node.tags.length > 0) { - bare.tag(...node.tags); - } - - bareStates[nodeId] = bare; - } - - // Pass 3: resolve every node to its final State (memoized + cycle-safe). - // Wrappers compose lazily via `withOverriddenHaltState` once their bare - // and override are resolved. - const finalStates: Record = {}; - const inProgress = new Set(); - - const getFinal = (nodeId: number): State => { - if (finalStates[nodeId]) { - return finalStates[nodeId]; - } - - const node = graph.nodes[nodeId]; - - if (!node || node.isHalt) { - finalStates[nodeId] = haltState; - - return haltState; - } - - if (inProgress.has(nodeId)) { - throw new Error(`override-halt cycle at state #${nodeId}`); - } - - inProgress.add(nodeId); - - let state: State; - - if (node.isWrapper) { - const bare = getFinal(node.bareStateId!); - const override = getFinal(node.overriddenHaltStateId!); - - state = bare.withOverriddenHaltState(override); - - // Apply wrapper-scoped tags (#186). Tags don't leak across wrappers - // sharing a bare — the wrapper instance owns its own tag set, and - // engine #175 memoization returns the same instance for the same - // (bare, override) pair, so this is idempotent across rebuilds. - if (node.tags.length > 0) { - state.tag(...node.tags); - } - } else { - state = bareStates[nodeId]; - } - - inProgress.delete(nodeId); - finalStates[nodeId] = state; - - return state; - }; - - for (const nodeId of ids) { - getFinal(nodeId); - } - - // Pass 4: bind each ref to the resolved final State so cross-node - // transitions land on the right instance. - for (const nodeId of ids) { - if (!graph.nodes[nodeId].isHalt) { - refs[nodeId].bind(finalStates[nodeId]); - } - } - - return { - start: finalStates[graph.initialId], - tapeBlock, - states: finalStates, - }; + return fromGraphImpl(graph); } } diff --git a/packages/machine/src/utilities/stateGraph.ts b/packages/machine/src/utilities/stateGraph.ts new file mode 100644 index 0000000..e55cf05 --- /dev/null +++ b/packages/machine/src/utilities/stateGraph.ts @@ -0,0 +1,501 @@ +import Alphabet from '../classes/Alphabet'; +import Reference from '../classes/Reference'; +import State, {STATE_INTERNAL, haltState, ifOtherSymbol} from '../classes/State'; +import TapeBlock from '../classes/TapeBlock'; +import type TapeCommand from '../classes/TapeCommand'; +import { + type Graph, + type GraphNode, + decodeMovement, + decodePatternDescription, + decodeWriteSymbol, + parseMovementLabel, + parsePatternString, + parseWriteSymbolLabel, +} from './graph'; + +// Graph serialization/reconstruction for State graphs. Extracted from +// `classes/State.ts` (#180) so the State class stays focused on the runtime +// machinery (transitions, debug, halt-stack composition). Sibling-module +// private access to State's internals goes through the `STATE_INTERNAL` +// Symbol re-exported from State.ts — see the @internal JSDoc there. +// +// Public surface is preserved: `State.toGraph` and `State.fromGraph` static +// methods continue to exist as thin delegates to the functions in this +// module. New consumers (e.g. #195's planned `collectStates`) will live +// here too and share the BFS-walk shape with `toGraph`. + +/** + * Walks the reachable graph from `initialState` and returns a serializable + * `Graph`. The walk is a BFS that visits each State exactly once (keyed by + * the State's internal id) and emits one `GraphNode` per State plus + * synthetic halt-marker nodes per callable-subtree frame. + * + * Round-trips losslessly with `fromGraph` in the sense that running the + * rebuilt machine on the same input produces the same output — but State + * instance identities are NOT preserved across the cycle. + * + * See `classes/State.ts` for the runtime model these graph nodes describe; + * see `utilities/graphFormats.ts` for the Mermaid-flavored serialization + * built on top of `Graph`. + */ +export function toGraph(initialState: State, tapeBlock: TapeBlock): Graph { + const nodes: Record = {}; + const alphabets = tapeBlock.alphabets.map((alphabet) => alphabet.symbols); + + // Pass 1: BFS-discover all reachable States; emit one GraphNode per State + // (wrapper or bare/regular). Wrappers and bares are separate nodes. + const visited = new Set(); + const queue: State[] = [initialState]; + const bareIds = new Set(); // ids referenced as a wrapper's bareStateId + + while (queue.length > 0) { + const state = queue.shift()!; + const stateInternal = state[STATE_INTERNAL](); + + if (visited.has(stateInternal.id)) { + continue; + } + + visited.add(stateInternal.id); + + if (state.isHalt) { + if (!(0 in nodes)) { + nodes[0] = { + id: 0, + name: stateInternal.name, + isHalt: true, + isHaltMarker: false, + isWrapper: false, + bareStateId: null, + frameId: null, + transitions: [], + overriddenHaltStateId: null, + tags: [...stateInternal.tags], + }; + } + + continue; + } + + // Wrapper? Emit wrapper node + queue bare and override target. + if (stateInternal.overriddenHaltState !== null && stateInternal.bareState !== null) { + const bareState = stateInternal.bareState; + const overrideTarget = stateInternal.overriddenHaltState; + const bareInternal = bareState[STATE_INTERNAL](); + const overrideInternal = overrideTarget[STATE_INTERNAL](); + + nodes[stateInternal.id] = { + id: stateInternal.id, + name: stateInternal.name, // composite name like "A(target)" + isHalt: false, + isHaltMarker: false, + isWrapper: true, + bareStateId: bareInternal.id, + frameId: null, + transitions: [], + overriddenHaltStateId: overrideInternal.id, + tags: [...stateInternal.tags], + }; + + bareIds.add(bareInternal.id); + queue.push(bareState); + queue.push(overrideTarget); + + continue; + } + + // Regular (or bare) state — build node with transitions. + const node: GraphNode = { + id: stateInternal.id, + name: stateInternal.name, + isHalt: false, + isHaltMarker: false, + isWrapper: false, + bareStateId: null, + frameId: null, + transitions: [], + overriddenHaltStateId: null, + tags: [...stateInternal.tags], + }; + + nodes[stateInternal.id] = node; + + let patternIx = 0; + + for (const [sym, {command, nextState}] of stateInternal.symbolToDataMap) { + let target: State; + + try { + target = nextState instanceof State ? nextState : nextState.ref; + } catch { + patternIx += 1; + continue; + } + + const targetInternal = target[STATE_INTERNAL](); + + node.transitions.push({ + pattern: decodePatternDescription(sym.description, alphabets), + command: command.tapesCommands.map((tc) => ({ + symbol: decodeWriteSymbol(tc.symbol), + movement: decodeMovement((tc.movement as symbol).description), + })), + nextStateId: targetInternal.id, + id: `${stateInternal.id}-${patternIx}`, + }); + + queue.push(target); + patternIx += 1; + } + } + + // Always emit real halt as a sentinel, even if no transition targets it. + // It anchors the `subtree -. halt .-> s0` frame-level arrow whenever a + // frame demand-emits one, and it's the canonical machine-halt singleton. + if (!(0 in nodes)) { + nodes[0] = { + id: 0, + name: 'halt', + isHalt: true, + isHaltMarker: false, + isWrapper: false, + bareStateId: null, + frameId: null, + transitions: [], + overriddenHaltStateId: null, + tags: [...haltState[STATE_INTERNAL]().tags], + }; + } + + // Pass 2: For each bare, compute its forward-reachable set (following + // transitions; stopping at halt and at wrappers — both are frame + // boundaries). + const computeReach = (startId: number): Set => { + const reach = new Set(); + const stack = [startId]; + + while (stack.length > 0) { + const id = stack.pop()!; + + if (reach.has(id)) { + continue; + } + + const node = nodes[id]; + + // `nodes[id]` is always populated for `id` that the BFS reached, so + // a defensive `!node` check would be dead. `isHalt` / `isWrapper` + // are real boundaries — both stop reach-set expansion. + if (node.isHalt || node.isWrapper) { + continue; + } + + reach.add(id); + + for (const t of node.transitions) { + const target = nodes[t.nextStateId]; + + if (!target || target.isHalt || target.isWrapper) { + continue; + } + + stack.push(t.nextStateId); + } + } + + return reach; + }; + + const reachByBare = new Map>(); + + for (const bareId of bareIds) { + reachByBare.set(bareId, computeReach(bareId)); + } + + // Pass 3: Union-find on bare overlaps. Two bares merge if their reach + // sets share any state. Canonical representative = smallest bare-id in + // the component. + const ufParent = new Map(); + + // Note: no path compression. The union policy below ("smaller id always + // becomes root") keeps the tree flat — every union targets bares[0] as + // the root, so any node's parent IS the root. Walking up never exceeds + // one step. Path compression would be dead code under this invariant. + const ufFind = (id: number): number => { + if (!ufParent.has(id)) { + ufParent.set(id, id); + } + + let root = id; + + while (ufParent.get(root) !== root) { + root = ufParent.get(root)!; + } + + return root; + }; + + const ufUnion = (a: number, b: number) => { + const ra = ufFind(a); + const rb = ufFind(b); + + if (ra === rb) return; + + if (ra < rb) { + ufParent.set(rb, ra); + } else { + ufParent.set(ra, rb); + } + }; + + for (const bareId of bareIds) { + ufFind(bareId); + } + + // For each state, collect the bares that reach it; union all bares that + // share a state. + const stateToReachingBares = new Map(); + + for (const [bareId, reachSet] of reachByBare) { + for (const stateId of reachSet) { + let bares = stateToReachingBares.get(stateId); + + if (!bares) { + bares = []; + stateToReachingBares.set(stateId, bares); + } + + bares.push(bareId); + } + } + + for (const bares of stateToReachingBares.values()) { + for (let i = 1; i < bares.length; i += 1) { + ufUnion(bares[0], bares[i]); + } + } + + // Assign frameId to each in-reach state. + const frameIds = new Set(); + + for (const [stateId, bares] of stateToReachingBares) { + const frameId = ufFind(bares[0]); + + nodes[stateId].frameId = frameId; + frameIds.add(frameId); + } + + // Pass 4: Retarget halt-bound transitions for in-frame states to the + // frame's halt marker. Out-of-frame states (top-level dispatcher, override + // targets, etc.) keep their halt-bound transitions pointing at real halt. + for (const node of Object.values(nodes)) { + if (node.frameId === null) { + continue; + } + + const haltMarkerId = -node.frameId; + + for (const t of node.transitions) { + const target = nodes[t.nextStateId]; + + if (target && target.isHalt && !target.isHaltMarker) { + t.nextStateId = haltMarkerId; + } + } + } + + // Pass 5: Emit one halt marker per frame. + for (const frameId of frameIds) { + const haltMarkerId = -frameId; + + nodes[haltMarkerId] = { + id: haltMarkerId, + name: 'halt', + isHalt: true, + isHaltMarker: true, + isWrapper: false, + bareStateId: null, + frameId, + transitions: [], + overriddenHaltStateId: null, + tags: [], + }; + } + + return {initialId: initialState[STATE_INTERNAL]().id, alphabets, nodes}; +} + +/** + * Inverse of `toGraph`: rebuilds a State graph (and a fresh TapeBlock with + * the graph's alphabets) from a serialized Graph. Round-trips with `toGraph` + * in the sense that running the rebuilt machine on the same input gives the + * same output, but the rebuilt State instances have *new* internal IDs. + * + * Under the v7 callable-subtree model (#174), graph nodes split into: + * - Wrapper nodes (`isWrapper: true`, no transitions) — reconstructed via + * `bareStates[bareStateId].withOverriddenHaltState(finalStates[overriddenHaltStateId])`. + * - Bare/regular nodes — constructed as normal States with transitions. + * - Halt + halt-marker nodes — collapse to the singleton `haltState`. + */ +export function fromGraph(graph: Graph): { + start: State; + tapeBlock: TapeBlock; + states: Record; +} { + const alphabetObjs = graph.alphabets.map((syms) => new Alphabet(syms)); + const tapeBlock = TapeBlock.fromAlphabets(alphabetObjs); + const ids = Object.keys(graph.nodes).map(Number); + + // Pass 1: pre-create a Reference for each non-halt non-halt-marker node + // (both wrappers and regulars). Halt and halt-marker nodes collapse to the + // singleton `haltState` and need no ref. + const refs: Record = {}; + + for (const nodeId of ids) { + const node = graph.nodes[nodeId]; + + if (!node.isHalt) { + refs[nodeId] = new Reference(); + } + } + + // Convert a parsed pattern back to the symbol key the State expects. + const patternToKey = (parsed: ReturnType): symbol => { + if (parsed === null) { + return ifOtherSymbol; + } + + const flat: (string | symbol)[] = []; + + for (const row of parsed) { + for (const cell of row) { + flat.push(cell === null ? ifOtherSymbol : cell); + } + } + + return tapeBlock.symbol(flat); + }; + + // Pass 2: build a State for each non-wrapper non-halt non-halt-marker + // node. Transitions point at refs so cycles work; haltState (and halt + // markers, which collapse to haltState) are used directly. + const bareStates: Record = {}; + + for (const nodeId of ids) { + const node = graph.nodes[nodeId]; + + if (node.isHalt || node.isWrapper) { + continue; + } + + const stateDefinition: ConstructorParameters[0] = {}; + + for (const t of node.transitions) { + const key = patternToKey(parsePatternString(t.pattern, graph.alphabets)); + const target = graph.nodes[t.nextStateId]; + const nextState: State | Reference = !target || target.isHalt + ? haltState + : refs[t.nextStateId]; + + stateDefinition![key] = { + command: t.command.map((c) => ({ + symbol: parseWriteSymbolLabel(c.symbol), + movement: parseMovementLabel(c.movement), + })) as ConstructorParameters[0][], + nextState, + }; + } + + // Graph-sourced names may contain `(` and `)` (composite wrapper names — + // although wrappers go through a separate path below, defensive + // construction here keeps the bypass uniform). Construct without a name + // and assign `name` directly through the internal accessor's setter to + // skip the constructor's user-facing name validation. + const bare = new State(stateDefinition); + + bare[STATE_INTERNAL]().name = node.name; + + if (node.tags.length > 0) { + bare.tag(...node.tags); + } + + bareStates[nodeId] = bare; + } + + // Pass 3: resolve every node to its final State (memoized + cycle-safe). + // Wrappers compose lazily via `withOverriddenHaltState` once their bare + // and override are resolved. + const finalStates: Record = {}; + const inProgress = new Set(); + + const getFinal = (nodeId: number): State => { + if (finalStates[nodeId]) { + return finalStates[nodeId]; + } + + const node = graph.nodes[nodeId]; + + if (!node || node.isHalt) { + finalStates[nodeId] = haltState; + + return haltState; + } + + if (inProgress.has(nodeId)) { + throw new Error(`override-halt cycle at state #${nodeId}`); + } + + inProgress.add(nodeId); + + let state: State; + + if (node.isWrapper) { + const bare = getFinal(node.bareStateId!); + const override = getFinal(node.overriddenHaltStateId!); + + state = bare.withOverriddenHaltState(override); + + // Apply wrapper-scoped tags (#186). Tags don't leak across wrappers + // sharing a bare — the wrapper instance owns its own tag set, and + // engine #175 memoization returns the same instance for the same + // (bare, override) pair, so this is idempotent across rebuilds. + if (node.tags.length > 0) { + state.tag(...node.tags); + } + } else { + state = bareStates[nodeId]; + } + + inProgress.delete(nodeId); + finalStates[nodeId] = state; + + return state; + }; + + for (const nodeId of ids) { + getFinal(nodeId); + } + + // Pass 4: bind each ref to the resolved final State so cross-node + // transitions land on the right instance. + for (const nodeId of ids) { + if (!graph.nodes[nodeId].isHalt) { + refs[nodeId].bind(finalStates[nodeId]); + } + } + + return { + start: finalStates[graph.initialId], + tapeBlock, + states: finalStates, + }; +} + +// Note on the import cycle with `State.ts`: stateGraph.ts value-imports +// `State`, `STATE_INTERNAL`, `haltState`, and `ifOtherSymbol`; State.ts +// value-imports `toGraph` and `fromGraph` for its static-method delegates. +// ESM resolves cycles via live bindings — both modules see each other's +// exports as long as nothing at module-load reads a binding before its +// source module finishes evaluating. All references here live inside +// function bodies, so the cycle is safe. From ded0b41581167a12bb99854a183d6c4257015d96 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 11:00:22 +0300 Subject: [PATCH 035/118] test: STATE_INTERNAL accessor coverage (#180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six direct tests for the new accessor: - shape: id, name, bareState, overriddenHaltState, symbolToDataMap, tags all reachable from the view object - live read: a `name` set via the accessor is visible through `state.name` (the public getter), pinning the closes-over-this behavior fromGraph relies on - name setter bypasses the constructor's paren validation — the explicit reason for the setter existing in the first place - symbolToDataMap iterates in insertion order, the contract #195's `transitionSymbols[patternIx]` will lean on - haltState's accessor works (toGraph uses it for halt-node tags) - each call returns a fresh view object — not load-bearing, but pinned so we notice if it changes Tests use the existing @internal STATE_INTERNAL Symbol re-exported from State.ts. --- packages/machine/src/classes/State.spec.ts | 94 +++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index b29e180..b1eb68f 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -1,6 +1,6 @@ import Alphabet from './Alphabet'; import Reference from './Reference'; -import State, {haltState, ifOtherSymbol} from './State'; +import State, {STATE_INTERNAL, haltState, ifOtherSymbol} from './State'; import TapeBlock from './TapeBlock'; import {movements, symbolCommands} from './TapeCommand'; @@ -571,3 +571,95 @@ describe('State.fromGraph — cyclic override-halt chain', () => { expect(() => State.fromGraph(graph)).toThrow(/^override-halt cycle at state #/); }); }); + +describe('STATE_INTERNAL accessor (#180)', () => { + test('exposes id, name, bareState, overriddenHaltState, symbolToDataMap, tags', () => { + const bare = new State({ + [symbol(['0'])]: {nextState: haltState}, + }, 'bare'); + const wrapper = bare.withOverriddenHaltState(haltState); + wrapper.tag('hot'); + + const wrapperView = wrapper[STATE_INTERNAL](); + + expect(wrapperView.id).toBe(wrapper.id); + expect(wrapperView.name).toBe(wrapper.name); + expect(wrapperView.bareState).toBe(bare); + expect(wrapperView.overriddenHaltState).toBe(haltState); + expect(wrapperView.symbolToDataMap).toBeInstanceOf(Map); + expect([...wrapperView.tags]).toEqual(['hot']); + }); + + test('read access is live — name setter is reflected by subsequent public reads', () => { + // The accessor closes over `this`, so reading via `state.name` after + // the setter mutates `#name` must see the new value. fromGraph relies + // on this when assigning graph-sourced composite names to freshly- + // constructed bares. + const s = new State({ + [symbol(['0'])]: {nextState: haltState}, + }, 'before'); + + expect(s.name).toBe('before'); + + s[STATE_INTERNAL]().name = 'after(set)'; + + expect(s.name).toBe('after(set)'); + }); + + test('name setter bypasses the constructor\'s paren validation', () => { + // The constructor rejects `(` / `)` in names because those are + // reserved as wrapper-composition delimiters. The setter intentionally + // skips that check — wrappers' composite names round-tripped through + // a serialized graph legitimately contain parens, and fromGraph needs + // to restore them. + expect(() => new State(null, 'with(parens)')).toThrow(/must not contain/); + + const s = new State(null, 'plain'); + s[STATE_INTERNAL]().name = 'with(parens)'; + expect(s.name).toBe('with(parens)'); + }); + + test('symbolToDataMap exposes the live Map for sibling-module enumeration', () => { + // #195 will enumerate this Map's keys to expose per-transition + // pattern Symbols by patternIx. The accessor returns the same + // instance the State holds, in insertion order — not a copy. + const sym0 = symbol(['0']); + const sym1 = symbol(['1']); + const s = new State({ + [sym0]: {nextState: haltState}, + [sym1]: {nextState: haltState}, + }, 's'); + + const view = s[STATE_INTERNAL](); + const keys = [...view.symbolToDataMap.keys()]; + + expect(keys).toContain(sym0); + expect(keys).toContain(sym1); + // Order matches construction order — the contract that #195's + // `transitionSymbols[patternIx]` will lean on. + expect(keys.indexOf(sym0)).toBeLessThan(keys.indexOf(sym1)); + }); + + test('haltState accessor works (used by toGraph for halt-node tags)', () => { + // toGraph reads `haltState[STATE_INTERNAL]().tags` when emitting the + // halt node. The halt singleton is a regular State — its accessor + // must work the same as any other State's. Name is the default + // `id:0` from the no-name constructor at the bottom of State.ts; + // toGraph maps it to the literal `'halt'` separately in its halt- + // node emit path. + const view = haltState[STATE_INTERNAL](); + + expect(view.id).toBe(0); + expect(view.name).toBe('id:0'); + expect([...view.tags]).toEqual([]); + }); + + test('returns a fresh view object per call', () => { + // Not part of the documented contract — but worth pinning so we + // don't accidentally start sharing state across calls. Each + // invocation should produce an independent object literal; only the + // closed-over State instance is shared. + const s = new State(null, 's'); + expect(s[STATE_INTERNAL]()).not.toBe(s[STATE_INTERNAL]()); + }); +}); From ce43e43998b44917ad31115729f857f4f2370f00 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 11:37:02 +0300 Subject: [PATCH 036/118] =?UTF-8?q?feat:=20State.collectStates=20=E2=80=94?= =?UTF-8?q?=20id=20->=20{state,=20transitionSymbols}=20(#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `State.collectStates(initialState, tapeBlock)` static (and the underlying `collectStates` in `utilities/stateGraph.ts`) that returns a `Map` keyed by engine `GraphNode.id`. Lets downstream tooling mutate `state.debug` on a specific State by numeric id and set per-pattern breakpoints by `GraphTransition.id`, neither of which was reachable from outside the engine before. Coverage: - Regular / bare states: `transitionSymbols` is `[...#symbolToDataMap.keys()]` verbatim — every key in insertion order including `ifOtherSymbol`. The K-th entry is positionally aligned with the GraphTransition whose id is `${stateId}-${K}`. - Wrapper states: entry exists with empty `transitionSymbols`. - Halt singleton (id 0): entry points at the engine-wide `haltState`, empty `transitionSymbols`. JSDoc warns toggling its debug is global. - Halt markers (synthetic, id = -frameId): excluded — they collapse to haltState at runtime, and the named consumer (machines-demo#37) surfaces halt-pause via a separate UI control. Additive extension available later if a future consumer needs uniform-by-id lookup. Implementation anchors on toGraph: one call gives the authoritative id set + halt-marker flags. A lighter BFS over the State graph maps id → State instance. The result is built by iterating `graph.nodes` and dispatching on node kind. The patternIx alignment can't drift because both surfaces enumerate `#symbolToDataMap` in the same insertion order. Builds on #180's `STATE_INTERNAL` accessor — no further internal-access surface growth needed. Tests: alignment contract over a realistic state, insertion-order pin, `ifOtherSymbol`-at-natural-slot, wrapper-entry shape, halt-singleton identity, halt-markers-excluded, unbound-`Reference`-slot, and a static-delegate-equivalence sanity check. --- packages/machine/src/classes/State.ts | 20 +- packages/machine/src/index.ts | 1 + .../machine/src/utilities/stateGraph.spec.ts | 189 ++++++++++++++++++ packages/machine/src/utilities/stateGraph.ts | 148 ++++++++++++++ 4 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 packages/machine/src/utilities/stateGraph.spec.ts diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index aa07152..b6e38be 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -12,7 +12,12 @@ import { // import cycle with stateGraph.ts is resolved by ESM live bindings — see // the bottom-of-file note in that module. Aliased so the delegating // static methods can keep their canonical names without clashing. -import {toGraph as toGraphImpl, fromGraph as fromGraphImpl} from '../utilities/stateGraph'; +import { + type StateMap, + collectStates as collectStatesImpl, + fromGraph as fromGraphImpl, + toGraph as toGraphImpl, +} from '../utilities/stateGraph'; export const ifOtherSymbol = Symbol('other symbol'); @@ -511,6 +516,19 @@ export default class State { } { return fromGraphImpl(graph); } + + /** + * Returns a `Map` keyed by engine + * `GraphNode.id`, exposing the live `State` instance + per-pattern + * Symbol references for each node so downstream tooling can mutate + * `state.debug` by numeric id and set per-pattern breakpoints by + * `GraphTransition.id` (#195). Thin delegate to + * `utilities/stateGraph.ts`'s `collectStates`; see that module for + * the alignment contract, coverage rules, and halt-singleton warning. + */ + static collectStates(initialState: State, tapeBlock: TapeBlock): StateMap { + return collectStatesImpl(initialState, tapeBlock); + } } export const haltState = new State(null); diff --git a/packages/machine/src/index.ts b/packages/machine/src/index.ts index 72298a3..79386c3 100644 --- a/packages/machine/src/index.ts +++ b/packages/machine/src/index.ts @@ -7,6 +7,7 @@ export { default as TapeBlock } from './classes/TapeBlock'; export { default as TapeCommand, movements, symbolCommands } from './classes/TapeCommand'; export { default as TuringMachine, type MachineState } from './classes/TuringMachine'; export { type Graph, type GraphNode, type GraphTransition, type GraphCommand } from './utilities/graph'; +export { type StateMap, type StateMapEntry } from './utilities/stateGraph'; export { toMermaid, fromMermaid } from './utilities/graphFormats'; export { summarize, summarizeGraph, type GraphSummary } from './utilities/introspection'; export { diff --git a/packages/machine/src/utilities/stateGraph.spec.ts b/packages/machine/src/utilities/stateGraph.spec.ts new file mode 100644 index 0000000..0432052 --- /dev/null +++ b/packages/machine/src/utilities/stateGraph.spec.ts @@ -0,0 +1,189 @@ +import Alphabet from '../classes/Alphabet'; +import Reference from '../classes/Reference'; +import State, {haltState, ifOtherSymbol} from '../classes/State'; +import TapeBlock from '../classes/TapeBlock'; +import {movements} from '../classes/TapeCommand'; +import {collectStates, toGraph} from './stateGraph'; + +const alphabet = new Alphabet(' 01'.split('')); +const tapeBlock = TapeBlock.fromAlphabets([alphabet]); +const {symbol} = tapeBlock; + +describe('collectStates (#195)', () => { + test('alignment contract: every GraphTransition.id maps to transitionSymbols[K]', () => { + // For every transition emitted by toGraph, the (stateId, patternIx) + // pair extracted from its id should index back to the firing Symbol + // via stateMap.get(stateId).transitionSymbols[patternIx]. This is + // the load-bearing primitive the issue identifies — a single test + // pinning it across the whole machine catches any drift between + // toGraph's patternIx counter and collectStates' Map.keys() walk. + const sym0 = symbol(['0']); + const sym1 = symbol(['1']); + const branch = new State({ + [sym0]: {nextState: haltState}, + [sym1]: {command: [{symbol: '0', movement: movements.right}], nextState: haltState}, + }, 'branch'); + + const graph = toGraph(branch, tapeBlock); + const stateMap = collectStates(branch, tapeBlock); + + let assertions = 0; + for (const node of Object.values(graph.nodes)) { + for (const t of node.transitions) { + const [nStr, kStr] = t.id.split('-'); + const n = Number(nStr); + const k = Number(kStr); + const entry = stateMap.get(n); + expect(entry).toBeDefined(); + // Positional alignment by index, identity equality of the + // Symbol (not structural). Symbols are interned per-pattern by + // tapeBlock.symbol — re-creating the pattern returns the same + // Symbol, so we can compare against the recreated keys here. + expect(entry!.transitionSymbols[k]).toBeDefined(); + assertions += 1; + } + } + // Sanity: the test would pass vacuously if no transitions existed. + expect(assertions).toBeGreaterThan(0); + }); + + test('transitionSymbols matches #symbolToDataMap insertion order', () => { + // Construction order: sym0 first, then sym1. collectStates surfaces + // the same order as transitionSymbols[0], [1]. The contract is by + // reference equality. + const sym0 = symbol(['0']); + const sym1 = symbol(['1']); + const s = new State({ + [sym0]: {nextState: haltState}, + [sym1]: {command: [{symbol: '0', movement: movements.right}], nextState: haltState}, + }, 's'); + + const entry = collectStates(s, tapeBlock).get(s.id)!; + + expect(entry.transitionSymbols[0]).toBe(sym0); + expect(entry.transitionSymbols[1]).toBe(sym1); + }); + + test('ifOtherSymbol is included at its natural slot', () => { + // A state that wrote ifOtherSymbol BETWEEN two literal-pattern + // entries — the catch-all sits at index 1 in #symbolToDataMap, so + // transitionSymbols[1] === ifOtherSymbol by reference. + const sym0 = symbol(['0']); + const sym1 = symbol(['1']); + const s = new State({ + [sym0]: {nextState: haltState}, + [ifOtherSymbol]: {nextState: haltState}, + [sym1]: {command: [{symbol: '0', movement: movements.right}], nextState: haltState}, + }, 's'); + + const entry = collectStates(s, tapeBlock).get(s.id)!; + + expect(entry.transitionSymbols).toHaveLength(3); + expect(entry.transitionSymbols[0]).toBe(sym0); + expect(entry.transitionSymbols[1]).toBe(ifOtherSymbol); + expect(entry.transitionSymbols[2]).toBe(sym1); + }); + + test('wrapper entries have empty transitionSymbols and point at the wrapper instance', () => { + const bare = new State({ + [symbol(['0'])]: {nextState: haltState}, + }, 'bare'); + const wrapper = bare.withOverriddenHaltState(haltState); + + const stateMap = collectStates(wrapper, tapeBlock); + const wrapperEntry = stateMap.get(wrapper.id); + + expect(wrapperEntry).toBeDefined(); + expect(wrapperEntry!.state).toBe(wrapper); + expect(wrapperEntry!.transitionSymbols).toEqual([]); + }); + + test('halt singleton entry at id 0 points at haltState with empty transitionSymbols', () => { + const s = new State({ + [symbol(['0'])]: {nextState: haltState}, + }, 's'); + + const haltEntry = collectStates(s, tapeBlock).get(0)!; + + expect(haltEntry.state).toBe(haltState); + expect(haltEntry.transitionSymbols).toEqual([]); + }); + + test('halt markers (negative ids) are excluded from the map', () => { + // A wrapper produces a callable-subtree frame, which gets a synthetic + // halt marker with id = -frameId. collectStates must skip it — the + // marker is visualization-only. + const bare = new State({ + [symbol(['0'])]: {nextState: haltState}, + }, 'bare'); + const wrapper = bare.withOverriddenHaltState(haltState); + + const graph = toGraph(wrapper, tapeBlock); + const stateMap = collectStates(wrapper, tapeBlock); + + const haltMarkerIds = Object.keys(graph.nodes) + .map(Number) + .filter((id) => graph.nodes[id].isHaltMarker); + + // Sanity: the test would be vacuous if the graph had no halt marker. + expect(haltMarkerIds.length).toBeGreaterThan(0); + + for (const id of haltMarkerIds) { + expect(stateMap.has(id)).toBe(false); + } + + // Map coverage: every non-halt-marker GraphNode has a stateMap entry. + const expectedIds = Object.keys(graph.nodes) + .map(Number) + .filter((id) => !graph.nodes[id].isHaltMarker); + expect(stateMap.size).toBe(expectedIds.length); + }); + + test('unbound Reference: transitionSymbols[K] is defined but no GraphTransition matches', () => { + // Construct a state whose first pattern points at an unbound + // Reference. toGraph's BFS catches the unbound-ref error and + // `continue`s — patternIx still advances, so the slot exists in + // #symbolToDataMap.keys() but no GraphTransition is emitted for it. + const danglingRef = new Reference(); + const sym0 = symbol(['0']); + const sym1 = symbol(['1']); + const s = new State({ + [sym0]: {nextState: danglingRef}, // unbound — no GraphTransition emitted + [sym1]: {command: [{symbol: '0', movement: movements.right}], nextState: haltState}, + }, 's'); + + const graph = toGraph(s, tapeBlock); + const stateMap = collectStates(s, tapeBlock); + const entry = stateMap.get(s.id)!; + + // Both Map keys present in transitionSymbols, in insertion order. + expect(entry.transitionSymbols).toHaveLength(2); + expect(entry.transitionSymbols[0]).toBe(sym0); + expect(entry.transitionSymbols[1]).toBe(sym1); + + // No GraphTransition with id `${s.id}-0` (the unbound-ref slot); + // the one for slot 1 (the bound transition) DOES exist. + const node = graph.nodes[s.id]; + const ids = node.transitions.map((t) => t.id); + expect(ids).not.toContain(`${s.id}-0`); + expect(ids).toContain(`${s.id}-1`); + }); +}); + +describe('State.collectStates (#195) — static delegate', () => { + test('returns the same shape as the module function', () => { + const s = new State({ + [symbol(['0'])]: {nextState: haltState}, + }, 's'); + + const fromStatic = State.collectStates(s, tapeBlock); + const fromModule = collectStates(s, tapeBlock); + + expect(fromStatic.size).toBe(fromModule.size); + for (const [id, entry] of fromModule) { + const staticEntry = fromStatic.get(id)!; + expect(staticEntry.state).toBe(entry.state); + expect(staticEntry.transitionSymbols).toEqual(entry.transitionSymbols); + } + }); +}); diff --git a/packages/machine/src/utilities/stateGraph.ts b/packages/machine/src/utilities/stateGraph.ts index e55cf05..7187161 100644 --- a/packages/machine/src/utilities/stateGraph.ts +++ b/packages/machine/src/utilities/stateGraph.ts @@ -492,6 +492,154 @@ export function fromGraph(graph: Graph): { }; } +/** + * One entry in the `StateMap` returned by `collectStates` (#195). + * + * - `state`: the live `State` instance for this Graph node. For the halt + * singleton at id `0`, this is the engine-wide `haltState` — toggling + * `state.debug` on that entry affects every machine in the process. + * - `transitionSymbols`: per-pattern Symbols in `#symbolToDataMap` insertion + * order, aligned positionally with `GraphTransition.id` patternIx. For + * wrappers and the halt singleton this is `[]` (no own transitions). + */ +export type StateMapEntry = { + state: State; + transitionSymbols: symbol[]; +}; + +/** + * Numeric `GraphNode.id` → `StateMapEntry`. Returned by `collectStates` + * (#195). Halt markers (synthetic nodes with `id = -frameId`) are NOT + * included — they're visualization-only and all collapse to the + * `haltState` singleton already exposed at id `0`. + */ +export type StateMap = Map; + +/** + * Returns a `Map` keyed by engine + * `GraphNode.id`, giving downstream tooling direct access to the `State` + * instance + per-pattern Symbol references for breakpoint setup (#195). + * + * **Positional alignment contract.** For any `GraphTransition` whose id + * is `${N}-${K}`, `result.get(N)!.transitionSymbols[K]` is the Symbol + * the transition fires on (reference equality, not structural). The K-th + * entry is the K-th key from the source State's `#symbolToDataMap` in + * insertion order, including `ifOtherSymbol` when the user wrote one. + * Consumers filtering the catch-all path identity-compare against the + * engine-exported `ifOtherSymbol`. + * + * **Unbound-`Reference` slots.** `toGraph` increments `patternIx` even + * when a transition's `nextState` is an unresolved `Reference` (it + * `continue`s without pushing the GraphTransition). In that case + * `transitionSymbols[K]` is still set to the K-th Map key, but no + * `Graph.nodes[N].transitions` entry exists with id `${N}-${K}`. Sparse + * on the Graph side, dense on the `transitionSymbols` side — same + * indexing. + * + * **Coverage.** Map keys are the State-backed subset of `graph.nodes`: + * regulars + bares + wrappers + the halt singleton (id `0`). Synthetic + * halt markers (id `-frameId`) are excluded — they all reach the same + * `haltState` object at runtime, and the named consumer + * ([machines-demo#37](https://github.com/mellonis/machines-demo/issues/37)) + * surfaces halt-pause via a separate UI control, not via clicks on + * halt glyphs. If a future consumer needs uniform-by-id lookup, the + * helper can be extended additively. + * + * **Halt-singleton warning.** `result.get(0)!.state === haltState` — the + * process-wide halt. Toggling `.debug` on that entry affects every + * machine in the runtime, not just the one this map was built from. + */ +export function collectStates(initialState: State, tapeBlock: TapeBlock): StateMap { + // Anchor on toGraph's authoritative id set — it knows the canonical + // ordering of wrapper/bare/regular emission and which nodes are + // synthetic halt markers we have to skip. Building our own BFS would + // duplicate that logic; reusing the Graph guarantees collectStates' + // id keys never drift from toGraph's GraphTransition ids. + const graph = toGraph(initialState, tapeBlock); + + // Walk the State graph to associate each State instance with its + // engine id. The shape mirrors toGraph's Pass 1 — visit by id, branch + // on halt / wrapper / regular — but only collects the (id → State) + // mapping. Lighter than re-running the union-find passes; no + // GraphNode construction. + const stateById = new Map(); + const visited = new Set(); + const queue: State[] = [initialState]; + + while (queue.length > 0) { + const state = queue.shift()!; + const internal = state[STATE_INTERNAL](); + + if (visited.has(internal.id)) continue; + visited.add(internal.id); + + stateById.set(internal.id, state); + + if (state.isHalt) continue; + + if (internal.bareState !== null && internal.overriddenHaltState !== null) { + queue.push(internal.bareState); + queue.push(internal.overriddenHaltState); + continue; + } + + for (const {nextState} of internal.symbolToDataMap.values()) { + let target: State; + + try { + target = nextState instanceof State ? nextState : nextState.ref; + } catch { + continue; // unbound Reference — skip silently, matches toGraph + } + + queue.push(target); + } + } + + // Build the result by iterating graph.nodes — the authoritative id set + // minus halt markers — and dispatching on node kind. The halt singleton + // entry's `state` reads from `stateById` (the BFS visited haltState if + // any path reached it) but falls back to the module-level singleton + // for graphs whose only halt presence is the always-emitted sentinel. + const result: StateMap = new Map(); + + for (const idStr of Object.keys(graph.nodes)) { + const id = Number(idStr); + const node = graph.nodes[id]; + + if (node.isHaltMarker) continue; // synthetic; collapses to haltState at id 0 + + if (node.isHalt) { + // The real halt — always the engine-wide singleton. Prefer the + // BFS-visited instance for identity-equality with whatever the + // caller has; fall back to the module singleton when the BFS + // didn't reach haltState (toGraph emits id 0 unconditionally). + result.set(id, { + state: stateById.get(0) ?? haltState, + transitionSymbols: [], + }); + continue; + } + + if (node.isWrapper) { + result.set(id, { + state: stateById.get(id)!, + transitionSymbols: [], + }); + continue; + } + + // Regular or bare State — enumerate `#symbolToDataMap.keys()` for + // the patternIx alignment. The K-th key is the Symbol that + // `${id}-${K}` GraphTransition fires on (positional contract). + const state = stateById.get(id)!; + const transitionSymbols = [...state[STATE_INTERNAL]().symbolToDataMap.keys()]; + result.set(id, {state, transitionSymbols}); + } + + return result; +} + // Note on the import cycle with `State.ts`: stateGraph.ts value-imports // `State`, `STATE_INTERNAL`, `haltState`, and `ifOtherSymbol`; State.ts // value-imports `toGraph` and `fromGraph` for its static-method delegates. From c1d85ec584dfb2a9e013db330ad50775bb9c68b6 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 12:05:39 +0300 Subject: [PATCH 037/118] docs+test: README section for collectStates + halt-fallback coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: add `State.collectStates` to the static-API list (next to toGraph/fromGraph) and a "Setting breakpoints by graph id" subsection inside Debugging breakpoints showing the by-id and by-transition-id patterns + the halt-singleton warning. - One more spec: halt singleton falls back to the module-level `haltState` when the BFS never reaches it (initial state with a self-loop and no halt-bound transitions). Closes the last branch in collectStates that the existing tests left uncovered — `stateById` hits id 0 only when some transition actually targets haltState; the `?? haltState` fallback fires for graphs whose only halt presence is toGraph's unconditional sentinel. Coverage on stateGraph.ts: 94.31% → 95.45% branches. --- packages/machine/README.md | 31 +++++++++++++++++++ .../machine/src/utilities/stateGraph.spec.ts | 17 ++++++++++ 2 files changed, 48 insertions(+) diff --git a/packages/machine/README.md b/packages/machine/README.md index 46b4f11..c526a42 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -242,6 +242,7 @@ Notable members and statics: - **`state.withOverriddenHaltState(other)`** — returns a copy whose would-be halt transitions fall through to `other`. The subroutine-call composition mechanism (see `library-binary-numbers/src/index.ts` for examples). - **`State.toGraph(state, tapeBlock)`** — walks the reachable graph from `state` and returns a serializable `Graph` (states, transitions, alphabets). - **`State.fromGraph(graph)`** — inverse of `toGraph`: rebuilds `State` instances + a fresh `TapeBlock` from a `Graph`. Round-trips together with `toMermaid` / `fromMermaid`. +- **`State.collectStates(state, tapeBlock)`** — walks the same graph and returns a `Map` keyed by `GraphNode.id`. Use when downstream tooling holds a numeric id (e.g. a clicked node in a rendered graph) and needs the live `State` instance or the per-pattern `Symbol` for breakpoint setup. See [Setting breakpoints by graph id](#setting-breakpoints-by-graph-id). For visualization, pair `State.toGraph` with `toMermaid` to render the graph in any Mermaid-aware viewer (GitHub, VS Code, mermaid.live): @@ -499,6 +500,36 @@ If `onPause` is not provided, breaks fire-and-resume invisibly — the trajector **Caveat:** `haltState` is a module-level singleton. Setting `haltState.debug` affects every machine in the process; clear in `afterEach` / `finally` for test isolation. +### Setting breakpoints by graph id + +Downstream UIs (graph renderers, debugger panels) often have only a numeric `GraphNode.id` — the user clicked a state node, or a transition edge in a rendered SVG. `State.collectStates(initial, tapeBlock)` returns a `Map` keyed by that numeric id, with the live `State` instance and the per-pattern `Symbol` array as its value: + +```ts +import { State, ifOtherSymbol } from '@turing-machine-js/machine'; + +const stateMap = State.collectStates(initial, tapeBlock); + +// Toggle a state-level breakpoint by id (any pattern triggers). +const entry = stateMap.get(clickedStateId); +if (entry) { + entry.state.debug.before = true; +} + +// Per-pattern breakpoint by GraphTransition.id — the contract is +// positional: `transitionSymbols[K]` is the Symbol that the +// `${stateId}-${K}` GraphTransition fires on. +const [n, k] = clickedEdgeId.split('-').map(Number); +const e = stateMap.get(n); +const sym = e?.transitionSymbols[k]; +if (e && sym) { + e.state.debug.before = [sym]; +} +``` + +**Coverage rules:** regular / bare states get the full `[...#symbolToDataMap.keys()]` including `ifOtherSymbol` at its natural slot; wrappers and the halt singleton get empty `transitionSymbols`; synthetic halt markers (Graph nodes with `id = -frameId`, one per callable-subtree frame) are excluded from the map. See `State.collectStates` JSDoc for the full contract. + +> ⚠️ `stateMap.get(0)!.state === haltState` — the entry at id `0` is the process-wide halt singleton. Toggling its `debug` affects every machine in the runtime, same caveat as direct `haltState.debug` writes. + ### Throttle pattern For per-iter throttle / animation / "wait between steps" UIs, use the **`onIter`** hook — an awaited callback that fires once at the end of every iter, after both `onPause` dispatches on the same yield. It's the engine-native shape for per-iter coordination: diff --git a/packages/machine/src/utilities/stateGraph.spec.ts b/packages/machine/src/utilities/stateGraph.spec.ts index 0432052..0472dd0 100644 --- a/packages/machine/src/utilities/stateGraph.spec.ts +++ b/packages/machine/src/utilities/stateGraph.spec.ts @@ -109,6 +109,23 @@ describe('collectStates (#195)', () => { expect(haltEntry.transitionSymbols).toEqual([]); }); + test('halt singleton falls back to the module singleton when BFS never reaches haltState', () => { + // No transition targets haltState — the BFS visits only `looping`, + // so `stateById` has no entry at id 0. `toGraph` still emits the + // halt sentinel unconditionally (it anchors `subtree -. halt .-> s0` + // edges), so id 0 IS in `graph.nodes`. collectStates must fall back + // to the module-level `haltState` for the entry's `.state` field — + // pinning that fallback path here. + const looping = new State({ + [symbol(['0'])]: {}, // nextState defaults to self → self-loop, no halt reached + }, 'loop'); + + const entry = collectStates(looping, tapeBlock).get(0)!; + + expect(entry.state).toBe(haltState); + expect(entry.transitionSymbols).toEqual([]); + }); + test('halt markers (negative ids) are excluded from the map', () => { // A wrapper produces a callable-subtree frame, which gets a synthetic // halt marker with id = -frameId. collectStates must skip it — the From 202c3418e4dab83586c0cbb0a31d52783c74b643 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 12:09:20 +0300 Subject: [PATCH 038/118] =?UTF-8?q?docs:=20CLAUDE.md=20=E2=80=94=20collect?= =?UTF-8?q?States=20+=20STATE=5FINTERNAL=20accessor=20pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two paragraphs to the engine CLAUDE.md's "Visualization & round-trip" section: - `State.collectStates` API and its alignment contract (the load-bearing positional pairing with GraphTransition.id patternIx). - `STATE_INTERNAL` Symbol accessor pattern — what it exposes, who can use it (sibling modules under `packages/machine/src` only, not re-exported from `index.ts`), and the single-site `name` setter rationale for `fromGraph`'s composite-name assignment. Mentions that toGraph/fromGraph/collectStates all live in `utilities/stateGraph.ts` post-#180 and that the static methods on `State` are thin delegates. --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0d24004..a338203 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,10 @@ Key shapes that take reading multiple files to grasp: `packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same `Graph` through [Mermaid flowchart](https://mermaid.js.org/syntax/flowchart.html) syntax (renderer: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid)). The parser is strict to the dialect `toMermaid` emits — hand-edited Mermaid with different arrow styles or shapes won't round-trip. +**`State.collectStates(state, tapeBlock)`** (v7, #195) — returns a `Map` keyed by engine `GraphNode.id`. For each id: the live `State` instance and the per-pattern `Symbol[]` from `#symbolToDataMap` in insertion order. The K-th `transitionSymbols` slot is positionally aligned with the GraphTransition whose id is `${stateId}-${K}`, so consumers holding `(stateId, patternIx)` from a rendered graph reach the firing Symbol with no walk. `ifOtherSymbol` is included at its natural slot. Wrappers and the halt singleton get empty `transitionSymbols`; synthetic halt markers (`isHaltMarker: true`, id `= -frameId`) are excluded — they all collapse to `haltState` at runtime. Halt-singleton entry at id `0` is the process-wide singleton — JSDoc warns toggling its `debug` affects every machine in the runtime. + +**`STATE_INTERNAL` Symbol accessor** (v7, #180) — `state[STATE_INTERNAL]()` returns a getter/setter view onto `State`'s private fields (`id`, `name`, `bareState`, `overriddenHaltState`, `symbolToDataMap`, `tags`). `@internal`-marked. Exported from `classes/State.ts` for sibling-module use in `packages/machine/src` (currently `utilities/stateGraph.ts`); NOT re-exported from the package's public `index.ts`. The `name` setter is the one mutation site, used by `fromGraph` to assign graph-sourced composite names (e.g. `A(target)`) that the constructor's paren-validator would reject. New utility modules in `packages/machine/src` that need private-field access route through this accessor rather than growing per-module bypasses. `toGraph`/`fromGraph`/`collectStates` all live in `utilities/stateGraph.ts` (extracted from `classes/State.ts` in #180); `State.toGraph` / `.fromGraph` / `.collectStates` statics remain as thin delegates for backwards compat. + **State tags (v7 alpha.3, #186)** — `state.tag(...tags) / state.untag(...tags) / state.tags` API for attaching string metadata to a State. Tags are out-of-band — they don't affect transition lookup, `equivalentOn`, or runtime semantics. Live on the State *instance* (not on `#symbolToDataMap`), so engine #175 memoization doesn't leak tags across wrappers sharing a bare: `A.wohs(t1).tag('hot')` and `A.wohs(t2)` carry independent tag sets. `GraphNode` gains `tags: string[]` field — survives `toGraph`/`fromGraph` round-trip. `toMermaid` emits tags two ways: inline in node label via universal `
` (`sN["name
tag1, tag2"]`) AND as `classDef tag_` + `class sN tag_` lines (6-color hash-based palette). `fromMermaid` splits the label on `
` as source of truth; `class` lines are decorative. **v7 callable-subtree emit shape** (#174, layered on the v7 alpha.1 framing from #138/#139): each `withOverriddenHaltState` wrapper produces TWO `GraphNode`s — a wrapper node (`isWrapper: true`, `[[composite-name]]` shape, no transitions, `bareStateId` points to the bare's GraphNode) and a bare node (regular `["name"]` shape inside its callable subtree subgraph, holds the bare's transitions). Frames are computed via union-find on bare-reachability: each unique bare's forward-reachable set defines its candidate frame; overlapping reach sets merge into a union frame. Frame id = smallest bare-id in the component. Halt marker per frame (id = `-frameId`, `isHaltMarker: true`, maps back to singleton `haltState` in `fromGraph`). Halt-bound transitions of in-frame states retarget to the frame's halt marker. Subgraph label: `"callable subtree of NAME"` (single bare) or `"callable scope: A ∪ B"` (union). The always-emitted `idle([idle])` stadium sentinel + `idle -. enter .-> sN` arrow marks the initial state. **No per-context duplication** — shared bares like `library-binary-numbers`'s `invertNumber` (in `minusOne`) appear once with `& `-joined call arrows from each wrapper. From 7d726c1cc9a54df82b3af13fb9a46ccc7574e508 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 12:29:34 +0300 Subject: [PATCH 039/118] chore: backfill v7-alpha CHANGELOG entries for side packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@turing-machine-js/builder`, `@turing-machine-js/library-binary-numbers`, and `@turing-machine-js/library-binary-numbers-bare` shipped at every v7 alpha in version-lockstep with `@turing-machine-js/machine`, but their CHANGELOGs were never updated past 6.4.0 — three alpha tarballs went out without a CHANGELOG entry each. CHANGELOG entries don't land retroactively on already-published npm versions; this fills the gap so the NEXT published tarball (alpha.4) will carry the prior alpha history alongside its own entry. Without this, alpha.4's CHANGELOG would jump from 6.4.0 directly to 7.0.0-alpha.4 on consumers' offline view (`npm view`, IDE extensions, etc.). Entries are the standard lockstep boilerplate — no source or behavior change in these packages, plus the per-alpha peer-dep widening that did occur (verified against the alpha.1/alpha.2/alpha.3 release commits): - alpha.1: peer dep `^6.0.0` → `^7.0.0-alpha.1` - alpha.2: peer dep `^7.0.0-alpha.1` → `^7.0.0-alpha.2` - alpha.3: peer dep `^7.0.0-alpha.2` → `^7.0.0-alpha.3` The engine's own CHANGELOG already covered these alphas; only the three dependent packages were behind. --- packages/builder/CHANGELOG.md | 12 ++++++++++++ packages/library-binary-numbers-bare/CHANGELOG.md | 12 ++++++++++++ packages/library-binary-numbers/CHANGELOG.md | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/packages/builder/CHANGELOG.md b/packages/builder/CHANGELOG.md index 3ef8afc..5230e38 100644 --- a/packages/builder/CHANGELOG.md +++ b/packages/builder/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.3] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.3 — first-class out-of-band State tags ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.2` → `^7.0.0-alpha.3`. + +## [7.0.0-alpha.2] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.2 — `toMermaid` callable-subtree emit refinement ([#174](https://github.com/mellonis/turing-machine-js/issues/174)), `withOverriddenHaltState` memoization ([#175](https://github.com/mellonis/turing-machine-js/issues/175)), nested `.wohs()` chain collapse ([#176](https://github.com/mellonis/turing-machine-js/issues/176)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.1` → `^7.0.0-alpha.2`. + +## [7.0.0-alpha.1] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.1 — composition-representation overhaul: `withOverrodeHaltState` → `withOverriddenHaltState` ([#149](https://github.com/mellonis/turing-machine-js/issues/149)), paren-based wrapped-state naming `A(B)` ([#148](https://github.com/mellonis/turing-machine-js/issues/148)), `toMermaid` callable-subtree emit alpha.1 collapsed-bare shape ([#138](https://github.com/mellonis/turing-machine-js/issues/138), [#139](https://github.com/mellonis/turing-machine-js/issues/139)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^6.0.0` → `^7.0.0-alpha.1`. + ## [6.4.0] - 2026-05-19 Released in lockstep with `@turing-machine-js/machine` 6.4.0. No source or behavior changes in this package. diff --git a/packages/library-binary-numbers-bare/CHANGELOG.md b/packages/library-binary-numbers-bare/CHANGELOG.md index 28c4dc6..cebb6a8 100644 --- a/packages/library-binary-numbers-bare/CHANGELOG.md +++ b/packages/library-binary-numbers-bare/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.3] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.3 — first-class out-of-band State tags ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.2` → `^7.0.0-alpha.3`. + +## [7.0.0-alpha.2] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.2 — `toMermaid` callable-subtree emit refinement ([#174](https://github.com/mellonis/turing-machine-js/issues/174)), `withOverriddenHaltState` memoization ([#175](https://github.com/mellonis/turing-machine-js/issues/175)), nested `.wohs()` chain collapse ([#176](https://github.com/mellonis/turing-machine-js/issues/176)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.1` → `^7.0.0-alpha.2`. + +## [7.0.0-alpha.1] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.1 — composition-representation overhaul: `withOverrodeHaltState` → `withOverriddenHaltState` ([#149](https://github.com/mellonis/turing-machine-js/issues/149)), paren-based wrapped-state naming `A(B)` ([#148](https://github.com/mellonis/turing-machine-js/issues/148)), `toMermaid` callable-subtree emit alpha.1 collapsed-bare shape ([#138](https://github.com/mellonis/turing-machine-js/issues/138), [#139](https://github.com/mellonis/turing-machine-js/issues/139)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^6.0.0` → `^7.0.0-alpha.1`. + ## [6.4.0] - 2026-05-19 Released in lockstep with `@turing-machine-js/machine` 6.4.0. No source or behavior changes in this package. diff --git a/packages/library-binary-numbers/CHANGELOG.md b/packages/library-binary-numbers/CHANGELOG.md index 1310a02..1efaaf5 100644 --- a/packages/library-binary-numbers/CHANGELOG.md +++ b/packages/library-binary-numbers/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.3] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.3 — first-class out-of-band State tags ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.2` → `^7.0.0-alpha.3`. + +## [7.0.0-alpha.2] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.2 — `toMermaid` callable-subtree emit refinement ([#174](https://github.com/mellonis/turing-machine-js/issues/174)), `withOverriddenHaltState` memoization ([#175](https://github.com/mellonis/turing-machine-js/issues/175)), nested `.wohs()` chain collapse ([#176](https://github.com/mellonis/turing-machine-js/issues/176)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.1` → `^7.0.0-alpha.2`. + +## [7.0.0-alpha.1] - 2026-05-21 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.1 — composition-representation overhaul: `withOverrodeHaltState` → `withOverriddenHaltState` ([#149](https://github.com/mellonis/turing-machine-js/issues/149)), paren-based wrapped-state naming `A(B)` ([#148](https://github.com/mellonis/turing-machine-js/issues/148)), `toMermaid` callable-subtree emit alpha.1 collapsed-bare shape ([#138](https://github.com/mellonis/turing-machine-js/issues/138), [#139](https://github.com/mellonis/turing-machine-js/issues/139)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^6.0.0` → `^7.0.0-alpha.1`. + ## [6.4.0] - 2026-05-19 Released in lockstep with `@turing-machine-js/machine` 6.4.0. No source or behavior changes in this package. From 910f09b39cb674b5917baacfccf07261824a7b6e Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 12:40:15 +0300 Subject: [PATCH 040/118] chore(release): 7.0.0-alpha.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth v7 pre-release. Lockstep version bump across all four packages from 7.0.0-alpha.3 → 7.0.0-alpha.4. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.3` → `^7.0.0-alpha.4` on the three side packages. Shipping: - Added: State.collectStates(initial, tapeBlock) returning Map keyed by GraphNode.id, for id-based debugger lookup (#195, PR #200). - Changed: State.toGraph / State.fromGraph extracted to utilities/stateGraph.ts with a Symbol-keyed STATE_INTERNAL accessor on State; public surface preserved via thin static delegates (#180, PR #199). - Fixed: toMermaid HTML-entity-escapes user content in labels — fixes edge-label parse error when alphabets contain `"`, `<`, etc. (#194, PR #198). - Fixed: runStepByStep halt stack scoped to the call rather than the TuringMachine instance — fixes memory leak / ghost-iteration on consecutive runStepByStep calls (#196, PR #197). CHANGELOG entry added to each of the four packages. README versioning-notes section updated to reflect alpha.4 + new highlights paragraph. Pre-release stays on the `next` dist-tag at publish time. --- lerna.json | 2 +- package-lock.json | 14 +++--- packages/builder/CHANGELOG.md | 4 ++ packages/builder/package.json | 4 +- .../library-binary-numbers-bare/CHANGELOG.md | 4 ++ .../library-binary-numbers-bare/package.json | 4 +- packages/library-binary-numbers/CHANGELOG.md | 4 ++ packages/library-binary-numbers/package.json | 4 +- packages/machine/CHANGELOG.md | 50 +++++++++++++++++++ packages/machine/README.md | 4 +- packages/machine/package.json | 2 +- 11 files changed, 80 insertions(+), 16 deletions(-) diff --git a/lerna.json b/lerna.json index 0455ff2..32f00eb 100644 --- a/lerna.json +++ b/lerna.json @@ -3,6 +3,6 @@ "packages": [ "packages/*" ], - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "$schema": "node_modules/lerna/schemas/lerna-schema.json" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d50c9ae..44832f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10656,40 +10656,40 @@ }, "packages/builder": { "name": "@turing-machine-js/builder", - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.2" + "@turing-machine-js/machine": "^7.0.0-alpha.3" } }, "packages/library-binary-numbers": { "name": "@turing-machine-js/library-binary-numbers", - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.2" + "@turing-machine-js/machine": "^7.0.0-alpha.3" } }, "packages/library-binary-numbers-bare": { "name": "@turing-machine-js/library-binary-numbers-bare", - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" }, "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.2" + "@turing-machine-js/machine": "^7.0.0-alpha.3" } }, "packages/machine": { "name": "@turing-machine-js/machine", - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "license": "GPL-3.0-or-later", "engines": { "npm": ">=7.0.0" diff --git a/packages/builder/CHANGELOG.md b/packages/builder/CHANGELOG.md index 5230e38..e0b4d57 100644 --- a/packages/builder/CHANGELOG.md +++ b/packages/builder/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.4] - 2026-05-23 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.4 — adds `State.collectStates` ([#195](https://github.com/mellonis/turing-machine-js/issues/195)), extracts graph serialization into `utilities/stateGraph.ts` ([#180](https://github.com/mellonis/turing-machine-js/issues/180)), fixes `toMermaid` label escape ([#194](https://github.com/mellonis/turing-machine-js/issues/194)) and `runStepByStep` halt-stack scope ([#196](https://github.com/mellonis/turing-machine-js/issues/196)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.3` → `^7.0.0-alpha.4`. + ## [7.0.0-alpha.3] - 2026-05-21 Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.3 — first-class out-of-band State tags ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.2` → `^7.0.0-alpha.3`. diff --git a/packages/builder/package.json b/packages/builder/package.json index b23e98d..043928b 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/builder", - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "description": "A turing machine builder — declarative state-table construction. Not actively developed by the author; the same state-table pattern is also shown as an inline example in @turing-machine-js/machine's README. Contributions welcome.", "engines": { "npm": ">=7.0.0" @@ -25,7 +25,7 @@ "builder" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.3" + "@turing-machine-js/machine": "^7.0.0-alpha.4" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/builder", diff --git a/packages/library-binary-numbers-bare/CHANGELOG.md b/packages/library-binary-numbers-bare/CHANGELOG.md index cebb6a8..c7ff61e 100644 --- a/packages/library-binary-numbers-bare/CHANGELOG.md +++ b/packages/library-binary-numbers-bare/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.4] - 2026-05-23 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.4 — adds `State.collectStates` ([#195](https://github.com/mellonis/turing-machine-js/issues/195)), extracts graph serialization into `utilities/stateGraph.ts` ([#180](https://github.com/mellonis/turing-machine-js/issues/180)), fixes `toMermaid` label escape ([#194](https://github.com/mellonis/turing-machine-js/issues/194)) and `runStepByStep` halt-stack scope ([#196](https://github.com/mellonis/turing-machine-js/issues/196)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.3` → `^7.0.0-alpha.4`. + ## [7.0.0-alpha.3] - 2026-05-21 Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.3 — first-class out-of-band State tags ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.2` → `^7.0.0-alpha.3`. diff --git a/packages/library-binary-numbers-bare/package.json b/packages/library-binary-numbers-bare/package.json index 3af83ee..9bb71a5 100644 --- a/packages/library-binary-numbers-bare/package.json +++ b/packages/library-binary-numbers-bare/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/library-binary-numbers-bare", - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "description": "Single-number binary arithmetic on a 3-symbol alphabet (blank, 0, 1) — same operations as @turing-machine-js/library-binary-numbers but without ^/$ markers. Side-by-side with the marker-based library for learning the trade-off.", "engines": { "npm": ">=7.0.0" @@ -28,7 +28,7 @@ "teaching" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.3" + "@turing-machine-js/machine": "^7.0.0-alpha.4" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/library-binary-numbers-bare", diff --git a/packages/library-binary-numbers/CHANGELOG.md b/packages/library-binary-numbers/CHANGELOG.md index 1efaaf5..1855a54 100644 --- a/packages/library-binary-numbers/CHANGELOG.md +++ b/packages/library-binary-numbers/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.4] - 2026-05-23 + +Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.4 — adds `State.collectStates` ([#195](https://github.com/mellonis/turing-machine-js/issues/195)), extracts graph serialization into `utilities/stateGraph.ts` ([#180](https://github.com/mellonis/turing-machine-js/issues/180)), fixes `toMermaid` label escape ([#194](https://github.com/mellonis/turing-machine-js/issues/194)) and `runStepByStep` halt-stack scope ([#196](https://github.com/mellonis/turing-machine-js/issues/196)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.3` → `^7.0.0-alpha.4`. + ## [7.0.0-alpha.3] - 2026-05-21 Released in lockstep with `@turing-machine-js/machine` 7.0.0-alpha.3 — first-class out-of-band State tags ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). No source or behavior changes in this package. Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.2` → `^7.0.0-alpha.3`. diff --git a/packages/library-binary-numbers/package.json b/packages/library-binary-numbers/package.json index 9a665a1..4e0c508 100644 --- a/packages/library-binary-numbers/package.json +++ b/packages/library-binary-numbers/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/library-binary-numbers", - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "description": "A standard library for working with binary numbers", "engines": { "npm": ">=7.0.0" @@ -27,7 +27,7 @@ "numbers" ], "peerDependencies": { - "@turing-machine-js/machine": "^7.0.0-alpha.3" + "@turing-machine-js/machine": "^7.0.0-alpha.4" }, "scripts": { "build": "tsc --build --verbose tsconfig.build.json && node ../../scripts/build-node-entries.mjs --package=@turing-machine-js/library-binary-numbers", diff --git a/packages/machine/CHANGELOG.md b/packages/machine/CHANGELOG.md index 1e2f70d..3803c70 100644 --- a/packages/machine/CHANGELOG.md +++ b/packages/machine/CHANGELOG.md @@ -4,6 +4,56 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.0.0-alpha.4] - 2026-05-23 + +Fourth v7 pre-release. Adds an id-keyed lookup helper for the State graph ([#195](https://github.com/mellonis/turing-machine-js/issues/195)), fixes two upstream issues surfaced while wiring the new helper into downstream tooling — a `toMermaid` label-grammar bug ([#194](https://github.com/mellonis/turing-machine-js/issues/194)) and a halt-stack lifetime bug in `runStepByStep` ([#196](https://github.com/mellonis/turing-machine-js/issues/196)) — and extracts graph serialization into its own module without API change ([#180](https://github.com/mellonis/turing-machine-js/issues/180)). Published under the `next` dist-tag: `npm install @turing-machine-js/machine@next`. + +**Pre-release — the API surface may still shift before stable v7.0.0.** Pin to a specific alpha for reproducibility: `@turing-machine-js/machine@7.0.0-alpha.4`. + +### Added + +- **`State.collectStates(initialState, tapeBlock)`** ([#195](https://github.com/mellonis/turing-machine-js/issues/195)). Static helper that returns a `Map` keyed by engine `GraphNode.id`. Lets downstream tooling (graph renderers, debugger panels) mutate `state.debug` on a specific State by numeric id, and set per-pattern breakpoints by `GraphTransition.id`. The K-th `transitionSymbols` slot is positionally aligned with the GraphTransition whose id is `${stateId}-${K}`, so a consumer holding `(stateId, patternIx)` from the rendered graph reaches the firing Symbol with no walk. + + ```ts + const stateMap = State.collectStates(initial, tapeBlock); + + // State-level breakpoint by id (any pattern fires). + stateMap.get(clickedStateId)!.state.debug.before = true; + + // Per-pattern breakpoint by GraphTransition.id ("${stateId}-${patternIx}"). + const [n, k] = clickedEdgeId.split('-').map(Number); + const entry = stateMap.get(n)!; + entry.state.debug.before = [entry.transitionSymbols[k]!]; + ``` + + Coverage: regular / bare states get the full `[...#symbolToDataMap.keys()]` including `ifOtherSymbol` at its natural slot; wrappers and the halt singleton get empty `transitionSymbols`; synthetic halt markers (`isHaltMarker: true`, id `= -frameId`) are excluded — they all collapse to `haltState` at runtime, and the named consumer surfaces halt-pause via a separate UI control, not via clicks on halt glyphs. The halt singleton entry at id `0` is the process-wide `haltState` — toggling its `.debug` affects every machine in the runtime, same caveat as direct `haltState.debug` writes. + +- **`StateMap` and `StateMapEntry` types** ([#195](https://github.com/mellonis/turing-machine-js/issues/195)). Exported from the package's public surface so TypeScript consumers can annotate `collectStates` results without re-deriving the shape. + +### Changed + +- **Graph serialization extracted to `utilities/stateGraph.ts`** ([#180](https://github.com/mellonis/turing-machine-js/issues/180)). `State.toGraph` and `State.fromGraph` move out of the State class into a sibling module, alongside the new `collectStates`. Public surface preserved — `State.toGraph` / `State.fromGraph` remain as thin static delegates. State.ts shrank ~440 lines and now focuses on the runtime machinery (transitions, debug, halt-stack composition) rather than mixing in serialization concerns. A new `@internal` `STATE_INTERNAL` Symbol-keyed accessor on `State` gives sibling modules in `packages/machine/src` getter/setter access to private fields (`id`, `name`, `bareState`, `overriddenHaltState`, `symbolToDataMap`, `tags`); not re-exported from the public `index.ts`, so external consumers can't observe it. + +### Fixed + +- **`toMermaid` edge labels containing literal `"` now parse correctly** ([#194](https://github.com/mellonis/turing-machine-js/issues/194)). Before: an alphabet that includes printable ASCII as literal symbols (e.g. a Brainfuck-flavored UTM whose data alphabet covers U+0020–U+007E) would emit an edge label like `s1 -- "['a'] → ['"']/[R]" --> s0`; Mermaid's parser terminated the string early on the inner `"`. After: user-supplied content (alphabet symbols, state names, tag names, frame bare names) is HTML-entity-escaped at the leaf — `&`, `"`, `<`, `>` to named entities; statement terminators (`\n`, `\r`), C0 controls minus `\t`, DEL, bidi controls, and lone UTF-16 surrogates to numeric entities. Printable Unicode (Cyrillic, CJK, accented Latin, etc.) passes through unchanged so non-ASCII alphabets stay readable in the emitted `.mmd`. `fromMermaid` mirrors with a single-pass entity decoder applied at the leaf, after structural parsing. + +- **`runStepByStep` halt-stack is now run-scoped, not machine-scoped** ([#196](https://github.com/mellonis/turing-machine-js/issues/196)). Before: the `#stack` field on `TuringMachine` was an instance field; a build-time peek that didn't drain the generator (e.g. graph-construction utilities that ask for one yield to inspect the initial state) left leftover entries in the stack that were popped during the NEXT halt-bound transition, producing a "ghost iteration" and silently leaking memory across consecutive `runStepByStep` calls on the same machine. After: the halt stack is a local `const stack: State[] = []` declared inside `runStepByStep`, so each generator call starts with a clean stack and entries can't survive into the next call. + +### Compatibility + +- Peer dep `@turing-machine-js/machine` widened `^7.0.0-alpha.3` → `^7.0.0-alpha.4` on `@turing-machine-js/builder`, `@turing-machine-js/library-binary-numbers`, `@turing-machine-js/library-binary-numbers-bare`. + +### Migration from alpha.3 + +Purely additive — no breaking changes. Existing code that doesn't call `State.collectStates` continues to work identically. `State.toGraph` / `State.fromGraph` behave identically (the delegate-to-stateGraph wiring is internal); no consumer changes needed. + +The `STATE_INTERNAL` accessor is `@internal` and not part of the supported surface; ignore it unless you're authoring a sibling module inside `packages/machine/src`. + +### Out of v7-alpha.4 (still pending for stable v7.0.0) + +- **[#102](https://github.com/mellonis/turing-machine-js/issues/102)** — debugger step-in / step-out / step-over primitives. + ## [7.0.0-alpha.3] - 2026-05-21 Third v7 pre-release. Adds first-class out-of-band tags on `State` ([#186](https://github.com/mellonis/turing-machine-js/issues/186)) — a metadata channel for visualization grouping and debugger labels that survives `toGraph` / `fromGraph` / `toMermaid` / `fromMermaid` round-trips. Driven by downstream [post-machine-js#86](https://github.com/mellonis/post-machine-js/issues/86), which will build a path-based registry and inline pseudo-command on top once this ships. Published under the `next` dist-tag: `npm install @turing-machine-js/machine@next`. diff --git a/packages/machine/README.md b/packages/machine/README.md index c526a42..580e3d8 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -714,7 +714,9 @@ API surface changes since v3, in past tense so the timing of each piece is expli - **v6.2** *(superseded by v6.3.0)* — widened `onStep`'s signature to `(m) => void | Promise` and added an inline `await onStep(...)` in the run loop, enabling throttle-in-`onStep` patterns. This overturned the docstring-stated contract that `onStep` is sync (microtask-free); the right place for per-iter throttling is `onPause` with self-rearm (see [Throttle pattern](#throttle-pattern)). Restored in v6.3.0. - **v6.3** — `onStep` reverted to its v6.0–v6.1 sync contract — `(m) => void`, called synchronously inside the run loop. The Throttle pattern section documents the engine-native shape for per-iter throttle / "wait between iters" UIs. No other API changes. - **v6.4** — New **`onIter`** hook on `run()`: awaited, fires once at the end of every iter (after both `onPause` dispatches on the same yield), unaffected by the `debug` master switch. Use for per-iter throttle / animation / coordination needing a suspend point; complements the existing sync `onStep` (tracing) and conditional `onPause` (user breakpoints). Three-hook contract is now `onStep` (sync, mid-iter) / `onPause` (awaited, on `state.debug` match) / `onIter` (awaited, end-of-iter). Additive — peer-deps unchanged. The v6.3.0 README's `onPause`-rearm throttle workaround is superseded. -- **v7** *(latest alpha: alpha.3, 2026-05-21)* — Composition-representation overhaul + first-class state tags. **Pre-release on the `next` dist-tag:** `npm install @turing-machine-js/machine@next` (or pin `@7.0.0-alpha.3`). Stable v7.0.0 still pending [#102](https://github.com/mellonis/turing-machine-js/issues/102) (debugger step-in/over/out primitives). Highlights across alphas: +- **v7** *(latest alpha: alpha.4, 2026-05-23)* — Composition-representation overhaul + first-class state tags + id-keyed `State.collectStates` lookup. **Pre-release on the `next` dist-tag:** `npm install @turing-machine-js/machine@next` (or pin `@7.0.0-alpha.4`). Stable v7.0.0 still pending [#102](https://github.com/mellonis/turing-machine-js/issues/102) (debugger step-in/over/out primitives). Highlights across alphas: + + **alpha.4** — **`State.collectStates(initial, tapeBlock)`** ([#195](https://github.com/mellonis/turing-machine-js/issues/195)) returns a `Map` keyed by `GraphNode.id` so downstream tooling can mutate `state.debug` by numeric id and set per-pattern breakpoints by `GraphTransition.id`. Graph serialization extracted to `utilities/stateGraph.ts` with a Symbol-keyed `@internal` accessor on `State` ([#180](https://github.com/mellonis/turing-machine-js/issues/180); no public-API change — the `State.toGraph` / `.fromGraph` statics remain as thin delegates). Two upstream fixes: `toMermaid` HTML-entity-escapes user content in labels so alphabets containing `"`, `<`, etc. parse correctly ([#194](https://github.com/mellonis/turing-machine-js/issues/194)); `runStepByStep`'s halt stack is now run-scoped, fixing a memory leak / ghost-iteration when the same `TuringMachine` instance is reused across calls ([#196](https://github.com/mellonis/turing-machine-js/issues/196)). See [§Setting breakpoints by graph id](#setting-breakpoints-by-graph-id). **alpha.3** — first-class **State tags** ([#186](https://github.com/mellonis/turing-machine-js/issues/186)). `state.tag(...) / .untag(...) / .tags` API; `GraphNode.tags: string[]` round-trips through `toGraph`/`fromGraph`; `toMermaid` emits tags two ways simultaneously — inline via `
` in node labels (`sN["name
tag1, tag2"]`) and as `classDef`/`class` for color grouping. Tags live on the State instance (not on the shared `#symbolToDataMap`), so engine [#175](https://github.com/mellonis/turing-machine-js/issues/175) memoization doesn't leak tags across wrappers sharing a bare. See [§State tags](#state-tags). diff --git a/packages/machine/package.json b/packages/machine/package.json index 12154ff..505d29f 100644 --- a/packages/machine/package.json +++ b/packages/machine/package.json @@ -1,6 +1,6 @@ { "name": "@turing-machine-js/machine", - "version": "7.0.0-alpha.3", + "version": "7.0.0-alpha.4", "description": "A convenient Turing machine", "engines": { "npm": ">=7.0.0" From 6fac7ad6274f94f084330977c8484bbb6545ddd3 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sat, 23 May 2026 13:23:11 +0300 Subject: [PATCH 041/118] test: raise v7 coverage to cover graphFormats + stateGraph gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovers the 0.5% Coveralls drop flagged on PR #167 (v7 → master integration). Master sits at 98.291%; before this PR v7 was at 97.805%. After: stmts 99.33% / branches 96.49% / funcs 100% / lines 99.47% — comfortably above master and above the 97/90/95/97 floors. Three changes: 1. `\r` escape test (graphFormats.spec via graph.spec.ts). Pins the carriage-return case in `escapeMermaidLabel` — the existing `\n` test only covered one of the two statement-terminator cases. 2. Hex numeric entity decode test (graph.spec.ts). `toMermaid` only emits decimal entities, but `fromMermaid` accepts hex (`&#xHH;`) for hand-edited `.mmd` files. Defensive path now exercised by a constructed-Mermaid-input test. 3. `c8 ignore` annotations on two genuinely unreachable branches: - `graphFormats.ts` line 118: the `return match;` fallback in `unescapeMermaidLabel`'s switch — the regex shape guarantees one of `named` / `dec` / `hex` is always set, so the default branch is dead. - `stateGraph.ts` line 191: the `if (node.isHalt || node.isWrapper) continue;` check in `computeReach` — the push site below already filters halt/wrapper targets, and the initial push is always a bare, so this branch can't fire in practice. (Same branch existed uncovered on master pre-#180, where the code lived in State.ts.) Net result on the Coveralls delta: +0.49% statements, +0.77% branches, +0.52% lines vs the pre-fix v7 state. Brings v7 above master's 98.291%, clearing the failing `coverage/coveralls` status on PR #167. --- packages/machine/src/utilities/graph.spec.ts | 40 +++++++++++++++++++ .../machine/src/utilities/graphFormats.ts | 2 + packages/machine/src/utilities/stateGraph.ts | 3 ++ 3 files changed, 45 insertions(+) diff --git a/packages/machine/src/utilities/graph.spec.ts b/packages/machine/src/utilities/graph.spec.ts index a204ee3..a3aeee7 100644 --- a/packages/machine/src/utilities/graph.spec.ts +++ b/packages/machine/src/utilities/graph.spec.ts @@ -1203,6 +1203,46 @@ describe('Mermaid label escaping (#194)', () => { expect((subgraphLine!.match(/"/g) ?? []).length).toBe(2); }); + test('carriage return in alphabet symbol encodes as ', () => { + // Sibling of the `\n` test above — pins the second statement-terminator + // branch in escapeMermaidLabel so coverage doesn't drift if someone + // adds another escape category and forgets the `\r` case. + const alphabet = new Alphabet([' ', 'a', '\r']); + const tapeBlock = TapeBlock.fromAlphabets([alphabet]); + const s = new State({ + [tapeBlock.symbol(['a'])]: { + command: [{symbol: '\r', movement: movements.right}], + nextState: haltState, + }, + }, 's'); + + const mermaid = toMermaid(State.toGraph(s, tapeBlock)); + expect(mermaid).toContain(' '); + + const reparsed = fromMermaid(mermaid); + expect(reparsed.nodes[s.id].transitions[0].command[0].symbol).toBe("'\r'"); + }); + + test('hex numeric entity `&#xHH;` decodes (hand-edited Mermaid support)', () => { + // `toMermaid` only emits decimal numeric entities (`&#NN;`), but + // `fromMermaid` accepts hex too for hand-edited `.mmd` files where a + // user might write `"` instead of `"`. Pin the hex-decode + // branch in unescapeMermaidLabel by constructing a minimal Mermaid + // graph that uses a hex entity in a node name. + const mermaid = [ + 'flowchart TD', + '%% alphabets: [[" ","0"]]', + ' s0(((halt)))', + ' s1["a"b"]', + ' idle([idle])', + ' idle -. enter .-> s1', + ' s1 -- "[\'0\'] → [K]/[S]" --> s0', + ].join('\n'); + + const graph = fromMermaid(mermaid); + expect(graph.nodes[1].name).toBe('a"b'); + }); + test('ambiguous `&quot;` decodes once, not twice', () => { // User content that looks like a doubly-encoded entity. Single-pass // decode should give back the literal `"` text, not `"`. diff --git a/packages/machine/src/utilities/graphFormats.ts b/packages/machine/src/utilities/graphFormats.ts index 5f0fa35..d2231e4 100644 --- a/packages/machine/src/utilities/graphFormats.ts +++ b/packages/machine/src/utilities/graphFormats.ts @@ -115,6 +115,8 @@ function unescapeMermaidLabel(s: string): string { const n = Number.parseInt(hex, 16); return n <= 0xFFFF ? String.fromCharCode(n) : String.fromCodePoint(n); } + /* c8 ignore next 2 — defensive: the regex shape guarantees one of + named / dec / hex is always set, so this fallback is unreachable. */ return match; } } diff --git a/packages/machine/src/utilities/stateGraph.ts b/packages/machine/src/utilities/stateGraph.ts index 7187161..86569a0 100644 --- a/packages/machine/src/utilities/stateGraph.ts +++ b/packages/machine/src/utilities/stateGraph.ts @@ -187,6 +187,9 @@ export function toGraph(initialState: State, tapeBlock: TapeBlock): Graph { // `nodes[id]` is always populated for `id` that the BFS reached, so // a defensive `!node` check would be dead. `isHalt` / `isWrapper` // are real boundaries — both stop reach-set expansion. + /* c8 ignore next 3 — defensive: the push site below already filters + halt/wrapper targets, and the initial push is always a bare, so + this branch is unreachable in practice. */ if (node.isHalt || node.isWrapper) { continue; } From dbe9b143d54d48ac868759e31554bacb36ad4d21 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sun, 24 May 2026 22:10:06 +0300 Subject: [PATCH 042/118] feat: MachineState.matchedTransition + .-separated transition ids (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine already resolves which transition fired (state.getNextState walks #symbolToDataMap), then discards that info. Expose it on every yielded MachineState so visualizations, log formatters, and coverage maps don't re-derive an ambiguous (source, nextState) and don't parse the pattern strings emitted by toGraph. matchedTransition: { id: string; matchKinds: ('wildcard' | 'literal')[] } - id is resolvable in toGraph: graph.nodes[…].transitions has a matching id. - For wrapper-entry iters (source produced by withOverriddenHaltState), id references the BARE's transition — the wrapper's own transitions array is empty post-#138 and the pattern lives on the bare. - matchKinds is per-tape, length = tape count, 'wildcard' iff the winning alternative had ifOtherSymbol at that position. Breaking: toGraph transition id format changed \`\${stateId}-\${ix}\` → \`\${stateId}.\${ix}\` so matchedTransition.id and GraphTransition.id share one shape. The `.` separator also avoids the hyphen reading as a minus sign next to negative halt-marker ids in adjacent contexts. New accessors carrying the data: - State.getMatchedTransition(symbol) — same lookup as getNextState, also returns matchedSymbol and ix. - TapeBlock.patternKinds(symbol, currentSymbols?) — winning-alternative per-tape kind for a given Symbol. --- packages/machine/src/classes/State.ts | 41 ++++ packages/machine/src/classes/TapeBlock.ts | 56 +++++ .../TuringMachine.matchedTransition.spec.ts | 197 ++++++++++++++++++ .../machine/src/classes/TuringMachine.spec.ts | 19 ++ packages/machine/src/classes/TuringMachine.ts | 43 +++- .../machine/src/utilities/stateGraph.spec.ts | 9 +- packages/machine/src/utilities/stateGraph.ts | 16 +- 7 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 packages/machine/src/classes/TuringMachine.matchedTransition.spec.ts diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index b6e38be..45a35fc 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -351,6 +351,47 @@ export default class State { throw new Error(`No nextState for symbol at state named ${this.#id}`); } + /** + * Like `getNextState`, but also returns the matched Symbol and its index + * in this State's transition declaration order (= the `K` in `toGraph`'s + * `${stateId}.${K}` transition ids). Used by `TuringMachine.runStepByStep` + * to populate `MachineState.matchedTransition` for #205 — exposes which + * transition fired so consumers (UIs, log tools, coverage maps) can + * resolve the firing edge without re-deriving from `(source, nextState)`, + * which is ambiguous when multiple transitions on the same source go to + * the same destination. + * + * Throws (matching `getNextState`) when no entry exists for the symbol. + * For wrappers (states produced by `withOverriddenHaltState`): the + * symbol-to-data map is shared with the bare via `bareState`, so the + * returned `ix` is a valid position into BOTH the wrapper's and the + * bare's transition iteration order — they're the same map. + */ + getMatchedTransition(symbol: symbol): { + nextState: State | Reference, + matchedSymbol: symbol, + ix: number, + } { + const entry = this.#symbolToDataMap.get(symbol); + + if (entry === undefined) { + throw new Error(`No nextState for symbol at state named ${this.#id}`); + } + + // Iteration order on a Map is insertion order; index lookup is O(N), + // acceptable since this fires at most once per iter and N (transitions + // per state) is typically tiny. If hot-path measurement ever flags it, + // cache as `#symbolToIxMap` mirror. + let ix = 0; + + for (const key of this.#symbolToDataMap.keys()) { + if (key === symbol) break; + ix += 1; + } + + return {nextState: entry.nextState, matchedSymbol: symbol, ix}; + } + withOverriddenHaltState(overriddenHaltState: State) { // Unwrap `this` if it's itself a wrapper — the chain's inner overrides // are dead at runtime anyway (only the outermost `.wohs()`'s override is diff --git a/packages/machine/src/classes/TapeBlock.ts b/packages/machine/src/classes/TapeBlock.ts index be54155..a6d4041 100644 --- a/packages/machine/src/classes/TapeBlock.ts +++ b/packages/machine/src/classes/TapeBlock.ts @@ -153,6 +153,62 @@ export default class TapeBlock { )) ?? false; } + /** + * For a Symbol returned by `this.symbol([...])` (or the catch-all + * `ifOtherSymbol`), returns the per-tape match kind for the + * **alternative that actually matched** given `currentSymbols`: + * `'wildcard'` if that tape position was `ifOtherSymbol` in the winning + * alternative, `'literal'` otherwise. Length always equals the tape + * count. + * + * Used by `TuringMachine.runStepByStep` to populate + * `MachineState.matchedTransition.matchKinds` for #205. The "winning + * alternative" disambiguation matters for alternations like + * `[[ifOtherSymbol, 'c'], ['a', 'b']]` — different alternatives can + * have different per-tape kinds, and only the alternative that matched + * the current head symbols is meaningful. + * + * - `ifOtherSymbol` (the State's catch-all transition fired): all + * positions are `'wildcard'`. + * - Symbol with patternList: find the first alternative that matches + * `currentSymbols` (same predicate as `isMatched`), return its + * per-position kinds. + * - Symbol with no winning alternative under the given `currentSymbols` + * (defensive — shouldn't happen if the caller resolved the Symbol via + * the State's normal matching): fall back to all `'literal'`. + */ + patternKinds( + symbol: symbol, + currentSymbols: string[] = this.currentSymbols, + ): ('wildcard' | 'literal')[] { + const tapeCount = this.#tapes.length; + + if (symbol === ifOtherSymbol) { + return Array.from({length: tapeCount}, () => 'wildcard' as const); + } + + const patternList = this.#symbolToPatternListMap.get(symbol); + + if (patternList === undefined) { + return Array.from({length: tapeCount}, () => 'literal' as const); + } + + const winning = patternList.find((pattern) => ( + pattern.every((everySymbol, ix) => ( + everySymbol === ifOtherSymbol + || everySymbol === currentSymbols[ix] + )) + )); + + if (winning === undefined) { + return Array.from({length: tapeCount}, () => 'literal' as const); + } + + return winning.map((everySymbol) => ( + everySymbol === ifOtherSymbol ? 'wildcard' as const : 'literal' as const + )); + } + replaceTape(tape: Tape, tapeIx = 0) { if (this.#tapes[tapeIx] == null) { throw new Error('invalid tapeIx'); diff --git a/packages/machine/src/classes/TuringMachine.matchedTransition.spec.ts b/packages/machine/src/classes/TuringMachine.matchedTransition.spec.ts new file mode 100644 index 0000000..b94b050 --- /dev/null +++ b/packages/machine/src/classes/TuringMachine.matchedTransition.spec.ts @@ -0,0 +1,197 @@ +import Alphabet from './Alphabet'; +import State, {haltState, ifOtherSymbol} from './State'; +import Tape from './Tape'; +import TapeBlock from './TapeBlock'; +import TuringMachine, {MachineState} from './TuringMachine'; +import {movements} from './TapeCommand'; +import {toGraph} from '../utilities/stateGraph'; + +/** + * Per-iter `matchedTransition` (#205). For every yielded `MachineState`: + * + * matchedTransition = { + * id: string; // resolvable in toGraph + * matchKinds: ('wildcard' | 'literal')[]; // per-tape, length = tape count + * } + * + * Cases covered here: + * - Literal match (specific-symbol pattern fired): kind 'literal'. + * - Wildcard match (`ifOtherSymbol` fired): kind 'wildcard'. + * - Multi-tape with mixed per-position kinds. + * - Wrapper-entry iter: id references the BARE's transition, not the wrapper. + * - Halt-bound transitions get a resolvable id. + * - id resolves in `toGraph`'s output (round-trip). + * + * Note: `nextStateId === 0` indicates the real halt singleton in toGraph; in-frame + * halts (wrapped) have `nextStateId === -frameId`. Both flavors still get a valid + * `matchedTransition.id` — the engine reports the source-anchored transition id + * regardless of where the transition points. + */ + +const alphabet = new Alphabet([' ', 'a', 'b']); + +describe('MachineState.matchedTransition (#205)', () => { + test('literal match: id format `${stateId}.${ix}`, matchKinds = literal', async () => { + const tape = new Tape({alphabet, symbols: ['a']}); + const tapeBlock = TapeBlock.fromTapes([tape]); + const {symbol} = tapeBlock; + + const state = new State({ + [symbol(['a'])]: {nextState: haltState}, + }, 'matchOnA'); + + const machine = new TuringMachine({tapeBlock}); + const yields: MachineState[] = []; + await machine.run({initialState: state, onStep: (m) => { yields.push(m); }}); + + expect(yields).toHaveLength(1); + expect(yields[0].matchedTransition).toEqual({ + id: `${state.id}.0`, + matchKinds: ['literal'], + }); + }); + + test('wildcard match: ifOtherSymbol fires when no specific pattern matches', async () => { + const tape = new Tape({alphabet, symbols: ['a']}); + const tapeBlock = TapeBlock.fromTapes([tape]); + const {symbol} = tapeBlock; + + const state = new State({ + [symbol(['b'])]: {nextState: haltState}, // ix 0: specific 'b', won't fire on 'a' + [ifOtherSymbol]: {nextState: haltState}, // ix 1: catch-all, fires + }, 'catchAll'); + + const machine = new TuringMachine({tapeBlock}); + const yields: MachineState[] = []; + await machine.run({initialState: state, onStep: (m) => { yields.push(m); }}); + + expect(yields).toHaveLength(1); + expect(yields[0].matchedTransition).toEqual({ + id: `${state.id}.1`, + matchKinds: ['wildcard'], + }); + }); + + test('multi-tape: per-tape matchKinds reflect the winning alternative', async () => { + // Two tapes. Pattern `[ifOtherSymbol, 'b']` matches tape0=anything, tape1='b'. + // Per-tape kinds for this match: ['wildcard', 'literal']. + const tapeA = new Tape({alphabet, symbols: ['a']}); + const tapeB = new Tape({alphabet, symbols: ['b']}); + const tapeBlock = TapeBlock.fromTapes([tapeA, tapeB]); + const {symbol} = tapeBlock; + + const state = new State({ + [symbol([ifOtherSymbol, 'b'])]: { + command: [{movement: movements.stay}, {movement: movements.stay}], + nextState: haltState, + }, + }, 'wildcardThenLiteral'); + + const machine = new TuringMachine({tapeBlock}); + const yields: MachineState[] = []; + await machine.run({initialState: state, onStep: (m) => { yields.push(m); }}); + + expect(yields).toHaveLength(1); + expect(yields[0].matchedTransition.matchKinds).toEqual(['wildcard', 'literal']); + }); + + test('multi-tape: matchKinds length always equals tape count', async () => { + const tapeA = new Tape({alphabet, symbols: ['a']}); + const tapeB = new Tape({alphabet, symbols: ['a']}); + const tapeBlock = TapeBlock.fromTapes([tapeA, tapeB]); + const {symbol} = tapeBlock; + + const state = new State({ + // Both positions specific: + [symbol(['a', 'a'])]: { + command: [{movement: movements.stay}, {movement: movements.stay}], + nextState: haltState, + }, + }); + + const machine = new TuringMachine({tapeBlock}); + const yields: MachineState[] = []; + await machine.run({initialState: state, onStep: (m) => { yields.push(m); }}); + + expect(yields[0].matchedTransition.matchKinds).toEqual(['literal', 'literal']); + expect(yields[0].matchedTransition.matchKinds).toHaveLength(2); + }); + + test('wrapper-entry iter: id references the bare\'s transition, not the wrapper', async () => { + // walkToBlank.withOverriddenHaltState(writeMarker): iter 1 starts at the + // wrapper (composite source), but the wrapper's transitions in toGraph + // are empty. The matched transition's id must reference the BARE's + // transition id so consumers can resolve it via + // `graph.nodes[bare.id].transitions`. + const tape = new Tape({alphabet, symbols: ['a']}); + const tapeBlock = TapeBlock.fromTapes([tape]); + const {symbol} = tapeBlock; + + const bare = new State({ + [symbol([alphabet.blankSymbol])]: {nextState: haltState}, // ix 0: specific blank + [ifOtherSymbol]: {command: [{movement: movements.right}]}, // ix 1: catch-all loop + }, 'walkToBlank'); + const writeMarker = new State({ + [ifOtherSymbol]: {nextState: haltState}, + }, 'writeMarker'); + const wrapper = bare.withOverriddenHaltState(writeMarker); + + const machine = new TuringMachine({tapeBlock}); + const yields: MachineState[] = []; + await machine.run({initialState: wrapper, onStep: (m) => { yields.push(m); }}); + + // Iter 1 source = wrapper (delegates to bare's transitions). + expect(yields[0].state).toBe(wrapper); + // matchedTransition.id uses the BARE's stateId, not the wrapper's. + expect(yields[0].matchedTransition.id).toBe(`${bare.id}.1`); // ifOtherSymbol slot + // Distinguishable: id's prefix doesn't match m.state.id when wrapper delegated. + expect(yields[0].matchedTransition.id.split('.')[0]).not.toBe(String(wrapper.id)); + }); + + test('halt-bound transitions still get a resolvable id', async () => { + const tape = new Tape({alphabet, symbols: [alphabet.blankSymbol]}); + const tapeBlock = TapeBlock.fromTapes([tape]); + const {symbol} = tapeBlock; + + const state = new State({ + [symbol([alphabet.blankSymbol])]: {nextState: haltState}, // halt-bound + }, 'haltOnBlank'); + + const machine = new TuringMachine({tapeBlock}); + const yields: MachineState[] = []; + await machine.run({initialState: state, onStep: (m) => { yields.push(m); }}); + + expect(yields).toHaveLength(1); + expect(yields[0].matchedTransition).toEqual({ + id: `${state.id}.0`, + matchKinds: ['literal'], + }); + expect(yields[0].nextState).toBe(haltState); + }); + + test('id round-trips through toGraph: graph.nodes[…].transitions has a matching id', async () => { + const tape = new Tape({alphabet, symbols: ['a', 'b']}); + const tapeBlock = TapeBlock.fromTapes([tape]); + const {symbol} = tapeBlock; + + const state = new State({ + [symbol(['a'])]: {command: [{movement: movements.right}]}, // ix 0 + [symbol(['b'])]: {nextState: haltState}, // ix 1 + }); + + const machine = new TuringMachine({tapeBlock}); + const yields: MachineState[] = []; + await machine.run({initialState: state, onStep: (m) => { yields.push(m); }}); + + const graph = toGraph(state, tapeBlock); + + // Every observed matchedTransition.id is findable in the graph. + for (const m of yields) { + const sourceId = Number(m.matchedTransition.id.split('.')[0]); + const node = graph.nodes[sourceId]; + expect(node).toBeDefined(); + const found = node.transitions.find((t) => t.id === m.matchedTransition.id); + expect(found).toBeDefined(); + } + }); +}); diff --git a/packages/machine/src/classes/TuringMachine.spec.ts b/packages/machine/src/classes/TuringMachine.spec.ts index 2a32465..23f81b3 100644 --- a/packages/machine/src/classes/TuringMachine.spec.ts +++ b/packages/machine/src/classes/TuringMachine.spec.ts @@ -45,6 +45,21 @@ describe('run tests', () => { }, }); + // #205 matchedTransition. Transition declaration order on + // `initialState`: + // ix 0 → `[symbol(symbolList)]` (specific symbol-list pattern) + // ix 1 → `[ifOtherSymbol]` (catch-all) + // Iters 1-3 read concrete alphabet symbols matched by ix 0 (literal). + // Iter 4 reads blank, falls through to ix 1 (wildcard). + const transitionListMatch = { + id: `${initialState.id}.0`, + matchKinds: ['literal' as const], + }; + const transitionWildcardMatch = { + id: `${initialState.id}.1`, + matchKinds: ['wildcard' as const], + }; + expectedSteps = [ { step: 1, @@ -53,6 +68,7 @@ describe('run tests', () => { nextSymbols: [alphabet.blankSymbol], movements: [movements.right], nextState: initialState, + matchedTransition: transitionListMatch, }, { step: 2, @@ -61,6 +77,7 @@ describe('run tests', () => { nextSymbols: [alphabet.blankSymbol], movements: [movements.right], nextState: initialState, + matchedTransition: transitionListMatch, }, { step: 3, @@ -69,6 +86,7 @@ describe('run tests', () => { nextSymbols: [alphabet.blankSymbol], movements: [movements.right], nextState: initialState, + matchedTransition: transitionListMatch, }, { step: 4, @@ -77,6 +95,7 @@ describe('run tests', () => { nextSymbols: [alphabet.blankSymbol], movements: [movements.stay], nextState: haltState, + matchedTransition: transitionWildcardMatch, }, ]; }); diff --git a/packages/machine/src/classes/TuringMachine.ts b/packages/machine/src/classes/TuringMachine.ts index c069ece..b36f83b 100644 --- a/packages/machine/src/classes/TuringMachine.ts +++ b/packages/machine/src/classes/TuringMachine.ts @@ -1,4 +1,4 @@ -import State, {haltState, type DebugConfig} from './State'; +import State, {haltState, STATE_INTERNAL, type DebugConfig} from './State'; import TapeBlock, {lockSymbol} from './TapeBlock'; import {symbolCommands} from './TapeCommand'; @@ -26,6 +26,29 @@ export type MachineState = { before?: true; after?: true; }; + /** + * The transition the engine picked for this iter (#205). Always present + * — `runStepByStep` resolves it at the very start of every iter via + * `state.getMatchedTransition(symbol)`, well before any callback fires. + * + * - `id` — resolvable in `toGraph`'s output: `graph.nodes[…].transitions` + * contains a `GraphTransition` whose `.id` equals this value. Format is + * `${stateId}.${transitionIx}`. **For wrapper-entry iters (`state` is + * produced by `withOverriddenHaltState`): the wrapper's own + * `transitions` array in `toGraph` is empty because wrappers delegate + * to the bare; this field carries the BARE's transition id, where the + * pattern actually lives.** Consumers can detect this case by + * comparing `id.split('.')[0]` against `state.id` — different = wrapper + * delegation. + * - `matchKinds` — per-tape match kind for the picked transition's + * pattern at each tape position. `'wildcard'` if the matched + * alternative had `ifOtherSymbol` at that position, `'literal'` + * otherwise. Length equals tape count. + */ + matchedTransition: { + id: string; + matchKinds: ('wildcard' | 'literal')[]; + }; }; // True iff `filter` matches `symbol` per the DebugConfig semantics. @@ -172,7 +195,22 @@ export default class TuringMachine { const symbol = state.getSymbol(this.#tapeBlock); const command = state.getCommand(symbol); - let nextState = state.getNextState(symbol).ref; + const matched = state.getMatchedTransition(symbol); + let nextState = matched.nextState.ref; + // For wrapper-entry iters, the wrapper's transitions in `toGraph` + // are empty (wrappers delegate to the bare via shared + // `#symbolToDataMap`); the resolvable transition id lives under + // the bare's stateId. `bareState` is non-null only when `state` + // is a wrapper produced by `withOverriddenHaltState`. Accessed + // via the STATE_INTERNAL package-private view (same pattern + // `utilities/stateGraph.ts` uses) to avoid widening the public + // State API for this internal need. + const stateInternal = state[STATE_INTERNAL](); + const resolvableStateId = stateInternal.bareState?.id ?? state.id; + const matchedTransition: MachineState['matchedTransition'] = { + id: `${resolvableStateId}.${matched.ix}`, + matchKinds: this.#tapeBlock.patternKinds(matched.matchedSymbol), + }; try { // Both before and after refer to THIS iter (#119 / v6.0.0). @@ -206,6 +244,7 @@ export default class TuringMachine { }), movements: command.tapesCommands.map((tapeCommand) => tapeCommand.movement), nextState: nextStateForYield, + matchedTransition, }; if (beforeMatch || afterMatch) { diff --git a/packages/machine/src/utilities/stateGraph.spec.ts b/packages/machine/src/utilities/stateGraph.spec.ts index 0472dd0..7b3fdcd 100644 --- a/packages/machine/src/utilities/stateGraph.spec.ts +++ b/packages/machine/src/utilities/stateGraph.spec.ts @@ -30,7 +30,7 @@ describe('collectStates (#195)', () => { let assertions = 0; for (const node of Object.values(graph.nodes)) { for (const t of node.transitions) { - const [nStr, kStr] = t.id.split('-'); + const [nStr, kStr] = t.id.split('.'); const n = Number(nStr); const k = Number(kStr); const entry = stateMap.get(n); @@ -178,12 +178,13 @@ describe('collectStates (#195)', () => { expect(entry.transitionSymbols[0]).toBe(sym0); expect(entry.transitionSymbols[1]).toBe(sym1); - // No GraphTransition with id `${s.id}-0` (the unbound-ref slot); + // No GraphTransition with id `${s.id}.0` (the unbound-ref slot); // the one for slot 1 (the bound transition) DOES exist. + // (#205 changed separator from `-` to `.`.) const node = graph.nodes[s.id]; const ids = node.transitions.map((t) => t.id); - expect(ids).not.toContain(`${s.id}-0`); - expect(ids).toContain(`${s.id}-1`); + expect(ids).not.toContain(`${s.id}.0`); + expect(ids).toContain(`${s.id}.1`); }); }); diff --git a/packages/machine/src/utilities/stateGraph.ts b/packages/machine/src/utilities/stateGraph.ts index 7187161..0fba329 100644 --- a/packages/machine/src/utilities/stateGraph.ts +++ b/packages/machine/src/utilities/stateGraph.ts @@ -142,7 +142,14 @@ export function toGraph(initialState: State, tapeBlock: TapeBlock): Graph { movement: decodeMovement((tc.movement as symbol).description), })), nextStateId: targetInternal.id, - id: `${stateInternal.id}-${patternIx}`, + // Transition id format: `${stateId}.${transitionIx}` (#205). + // Matches `TuringMachine.runStepByStep`'s `MachineState. + // matchedTransition.id` so consumers can do + // `graph.nodes[stateId].transitions.find(t => t.id === id)`. + // Was `${stateId}-${ix}` pre-#205 — the `.` separator avoids + // the hyphen reading as a minus sign next to negative halt- + // marker ids in adjacent contexts. + id: `${stateInternal.id}.${patternIx}`, }); queue.push(target); @@ -521,7 +528,8 @@ export type StateMap = Map; * instance + per-pattern Symbol references for breakpoint setup (#195). * * **Positional alignment contract.** For any `GraphTransition` whose id - * is `${N}-${K}`, `result.get(N)!.transitionSymbols[K]` is the Symbol + * is `${N}.${K}` (#205 changed the separator from `-` to `.`), + * `result.get(N)!.transitionSymbols[K]` is the Symbol * the transition fires on (reference equality, not structural). The K-th * entry is the K-th key from the source State's `#symbolToDataMap` in * insertion order, including `ifOtherSymbol` when the user wrote one. @@ -532,7 +540,7 @@ export type StateMap = Map; * when a transition's `nextState` is an unresolved `Reference` (it * `continue`s without pushing the GraphTransition). In that case * `transitionSymbols[K]` is still set to the K-th Map key, but no - * `Graph.nodes[N].transitions` entry exists with id `${N}-${K}`. Sparse + * `Graph.nodes[N].transitions` entry exists with id `${N}.${K}`. Sparse * on the Graph side, dense on the `transitionSymbols` side — same * indexing. * @@ -631,7 +639,7 @@ export function collectStates(initialState: State, tapeBlock: TapeBlock): StateM // Regular or bare State — enumerate `#symbolToDataMap.keys()` for // the patternIx alignment. The K-th key is the Symbol that - // `${id}-${K}` GraphTransition fires on (positional contract). + // `${id}.${K}` GraphTransition fires on (positional contract). const state = stateById.get(id)!; const transitionSymbols = [...state[STATE_INTERNAL]().symbolToDataMap.keys()]; result.set(id, {state, transitionSymbols}); From aa15f6905496d4aaf38081db35d0bf703393d4e3 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sun, 24 May 2026 22:28:12 +0300 Subject: [PATCH 043/118] docs(machine): document MachineState.matchedTransition (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README gains a "Matched transition" subsection covering the `id` format, wrapper-entry → bare delegation rule, per-tape `matchKinds` semantics, and a usage example. CLAUDE.md gets a paragraph on the field and notes the `toGraph` transition-id separator change from `-` (alpha.4) to `.`. --- CLAUDE.md | 4 +++- packages/machine/README.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index a338203..cf36779 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,9 @@ Key shapes that take reading multiple files to grasp: `packages/machine` ships `State.toGraph(state, tapeBlock)` → `Graph` and `State.fromGraph(graph)` → `{start, tapeBlock, states}` for serialization. `toMermaid(graph)` and `fromMermaid(text)` round-trip the same `Graph` through [Mermaid flowchart](https://mermaid.js.org/syntax/flowchart.html) syntax (renderer: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid)). The parser is strict to the dialect `toMermaid` emits — hand-edited Mermaid with different arrow styles or shapes won't round-trip. -**`State.collectStates(state, tapeBlock)`** (v7, #195) — returns a `Map` keyed by engine `GraphNode.id`. For each id: the live `State` instance and the per-pattern `Symbol[]` from `#symbolToDataMap` in insertion order. The K-th `transitionSymbols` slot is positionally aligned with the GraphTransition whose id is `${stateId}-${K}`, so consumers holding `(stateId, patternIx)` from a rendered graph reach the firing Symbol with no walk. `ifOtherSymbol` is included at its natural slot. Wrappers and the halt singleton get empty `transitionSymbols`; synthetic halt markers (`isHaltMarker: true`, id `= -frameId`) are excluded — they all collapse to `haltState` at runtime. Halt-singleton entry at id `0` is the process-wide singleton — JSDoc warns toggling its `debug` affects every machine in the runtime. +**`State.collectStates(state, tapeBlock)`** (v7, #195) — returns a `Map` keyed by engine `GraphNode.id`. For each id: the live `State` instance and the per-pattern `Symbol[]` from `#symbolToDataMap` in insertion order. The K-th `transitionSymbols` slot is positionally aligned with the GraphTransition whose id is `${stateId}.${K}` (separator was `-` in alpha.4; #205 changes it to `.` for v7), so consumers holding `(stateId, patternIx)` from a rendered graph reach the firing Symbol with no walk. `ifOtherSymbol` is included at its natural slot. Wrappers and the halt singleton get empty `transitionSymbols`; synthetic halt markers (`isHaltMarker: true`, id `= -frameId`) are excluded — they all collapse to `haltState` at runtime. Halt-singleton entry at id `0` is the process-wide singleton — JSDoc warns toggling its `debug` affects every machine in the runtime. + +**Per-iter `matchedTransition`** (v7, [#205](https://github.com/mellonis/turing-machine-js/issues/205) / [PR #206](https://github.com/mellonis/turing-machine-js/pull/206)) — every `MachineState` yielded by `run` / `runStepByStep` carries `matchedTransition: { id: string, matchKinds: ('wildcard'|'literal')[] }`. `id` is `${stateId}.${transitionIx}` and resolves in `toGraph`'s output via `graph.nodes[stateId].transitions.find(t => t.id === ...)`. For wrapper-entry iters (composite source from `withOverriddenHaltState`), `id` references the **bare's** transition — wrappers' own `transitions` arrays are empty because the pattern lives on the bare; consumers detect delegation by comparing `id.split('.')[0]` against `state.id`. `matchKinds` is per-tape, length = tape count; `'wildcard'` iff the winning alternative held `ifOtherSymbol` at that position (catch-all), `'literal'` otherwise. Halt-bound transitions still get a valid id (both real halt `nextStateId === 0` and in-frame halt markers `nextStateId === -frameId`). The field eliminates ambiguity in `(source, nextState)` resolution when multiple transitions on the same source share a destination, and lets consumers do exact-edge highlighting / per-transition coverage maps / wildcard-aware log formatting without re-parsing pattern strings from `toGraph`. The separator change from `-` (alpha.4) to `.` propagates to `toGraph`'s `GraphTransition.id` — see `collectStates` note above. **`STATE_INTERNAL` Symbol accessor** (v7, #180) — `state[STATE_INTERNAL]()` returns a getter/setter view onto `State`'s private fields (`id`, `name`, `bareState`, `overriddenHaltState`, `symbolToDataMap`, `tags`). `@internal`-marked. Exported from `classes/State.ts` for sibling-module use in `packages/machine/src` (currently `utilities/stateGraph.ts`); NOT re-exported from the package's public `index.ts`. The `name` setter is the one mutation site, used by `fromGraph` to assign graph-sourced composite names (e.g. `A(target)`) that the constructor's paren-validator would reject. New utility modules in `packages/machine/src` that need private-field access route through this accessor rather than growing per-module bypasses. `toGraph`/`fromGraph`/`collectStates` all live in `utilities/stateGraph.ts` (extracted from `classes/State.ts` in #180); `State.toGraph` / `.fromGraph` / `.collectStates` statics remain as thin delegates for backwards compat. diff --git a/packages/machine/README.md b/packages/machine/README.md index 580e3d8..a40737d 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -327,6 +327,7 @@ Each yielded `step` (`MachineState`) has these fields: | `movements` | `symbol[]` | per-tape head moves (`movements.left/right/stay`) | | `nextState` | `State` | the state that will execute next | | `debugBreak?` | `{ before?: true, after?: true }` | only set when `state.debug` matched on this iter — see *Debugging breakpoints* below | +| `matchedTransition` | `{ id: string, matchKinds: ('wildcard'\|'literal')[] }` | the transition the engine picked for this iter — see *Matched transition* below | `stepsLimit` (default `1e5`) guards against runaway loops — exceeding it throws. @@ -346,6 +347,35 @@ Both APIs are first-class — `run()` is built on top of `runStepByStep()` (see **Don't split one logical flow across both APIs.** A consumer that wants stepwise UI *and* hook-driven breakpoints should use `run({ onStep, onPause, debug })` exclusively. Routing some operations through `runStepByStep()` and others through `run()` means `state.debug` only flows through one of the two paths — a subtle footgun where breakpoints silently disappear on whichever code path uses the generator directly. For per-iter throttle / "wait between steps" UIs, see [Throttle pattern](#throttle-pattern). +### Matched transition + +Every yielded `MachineState` carries a `matchedTransition` describing which transition the engine picked for that iter. The engine already resolves this via `state.getNextState(symbol)` internally; this field exposes the resolution to consumers so visualizations, log formatters, and coverage maps don't have to re-derive an ambiguous `(source, nextState)` pair (which collides when multiple transitions on the same source share a destination) or parse pattern strings from `toGraph`. + +```ts +matchedTransition: { + id: string; // resolvable in toGraph + matchKinds: ('wildcard' | 'literal')[]; // per-tape, length = tape count +} +``` + +- **`id`** — `${stateId}.${transitionIx}`. Resolvable in `toGraph`'s output: `graph.nodes[stateId].transitions` has an entry with the matching `id`. For wrapper-entry iters (source is a wrapper produced by `withOverriddenHaltState`), `id` references the **bare's** transition — the wrapper's own `transitions` array in `toGraph` is empty because wrappers delegate, and the pattern actually lives on the bare. Detect by comparing `id.split('.')[0]` against `state.id`: different → wrapper delegation. + +- **`matchKinds`** — per-tape match kind for the matched alternative's selector at each tape position. `'wildcard'` if the position held `ifOtherSymbol` (catch-all) in the winning alternative; `'literal'` if it held a specific symbol or symbol-list. Length always equals tape count. + +Example use: + +```javascript +await machine.run({ + initialState, + onStep: (m) => { + const wildcardPositions = m.matchedTransition.matchKinds // per-tape, e.g. ['wildcard', 'literal'] + .map((k, i) => k === 'wildcard' ? i : -1) + .filter((i) => i >= 0); + console.log(`step ${m.step}: fired transition ${m.matchedTransition.id} (wildcards at tapes: ${wildcardPositions.join(',') || 'none'})`); + }, +}); +``` + ## Subroutine composition with `withOverriddenHaltState` `state.withOverriddenHaltState(other)` returns a copy of `state` whose would-be halt transitions fall through to `other` at run time. The original is left untouched. This is the engine's only composition primitive — bigger machines are built by stacking smaller halt-on-completion subroutines. From 5012f4e149d2b0bd56dd16128efef70c835266b2 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Sun, 24 May 2026 23:34:53 +0300 Subject: [PATCH 044/118] refactor(State): single #getEntry helper for command/nextState/matchedTransition lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before #206 each accessor did its own `#symbolToDataMap.has() + .get()!` double-lookup with a slightly different error string ("No command for…" / "No nextState for…"). Extracted to a private `#getEntry(symbol)` helper (one map-get, unified throw "No transition for symbol at state named …"). `getCommand`, `getNextState`, and `getMatchedTransition` are now thin accessors over it — same public API, single lookup site, single error message. --- packages/machine/src/classes/State.spec.ts | 19 ++++++++--- packages/machine/src/classes/State.ts | 38 ++++++++++++---------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/packages/machine/src/classes/State.spec.ts b/packages/machine/src/classes/State.spec.ts index b1eb68f..9b33f1a 100644 --- a/packages/machine/src/classes/State.spec.ts +++ b/packages/machine/src/classes/State.spec.ts @@ -103,17 +103,26 @@ describe('State constructor — invalid inputs', () => { }); }); -describe('State.getCommand / .getNextState — error paths', () => { +describe('State.getCommand / .getNextState / .getMatchedTransition — error paths', () => { // Default-constructed State has an empty symbolToDataMap; any lookup throws. + // #206 unified the message across all three methods: the prior per-method + // wording ("No command for…" / "No nextState for…") conveyed the same root + // cause ("no transition for this symbol") and the public methods now share + // a single `#getEntry` helper. - test('getCommand on an unmapped symbol throws "No command for symbol at state named …"', () => { + test('getCommand on an unmapped symbol throws "No transition for symbol at state named …"', () => { expect(() => new State().getCommand(ifOtherSymbol)) - .toThrow(/^No command for symbol at state named/); + .toThrow(/^No transition for symbol at state named/); }); - test('getNextState on an unmapped symbol throws "No nextState for symbol at state named …"', () => { + test('getNextState on an unmapped symbol throws "No transition for symbol at state named …"', () => { expect(() => new State().getNextState(ifOtherSymbol)) - .toThrow(/^No nextState for symbol at state named/); + .toThrow(/^No transition for symbol at state named/); + }); + + test('getMatchedTransition on an unmapped symbol throws "No transition for symbol at state named …"', () => { + expect(() => new State().getMatchedTransition(ifOtherSymbol)) + .toThrow(/^No transition for symbol at state named/); }); }); diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 45a35fc..0d20547 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -335,20 +335,28 @@ export default class State { return ifOtherSymbol; } - getCommand(symbol: symbol) { - if (this.#symbolToDataMap.has(symbol)) { - return this.#symbolToDataMap.get(symbol)!.command; + // Single lookup + throw site shared by `getCommand`, `getNextState`, and + // `getMatchedTransition`. Returns the symbol's entry `{command, nextState}` + // (one map-get, no `.has()` pre-check); throws a unified message when no + // entry exists. Before #206, each public method did its own `.has() + .get()!` + // double-lookup with a slightly different error string — same root cause + // ("no transition for this symbol"), so the message is unified. + #getEntry(symbol: symbol) { + const entry = this.#symbolToDataMap.get(symbol); + + if (entry === undefined) { + throw new Error(`No transition for symbol at state named ${this.#name}`); } - throw new Error(`No command for symbol at state named ${this.#name}`); + return entry; } - getNextState(symbol: symbol) { - if (this.#symbolToDataMap.has(symbol)) { - return this.#symbolToDataMap.get(symbol)!.nextState; - } + getCommand(symbol: symbol) { + return this.#getEntry(symbol).command; + } - throw new Error(`No nextState for symbol at state named ${this.#id}`); + getNextState(symbol: symbol) { + return this.#getEntry(symbol).nextState; } /** @@ -361,9 +369,9 @@ export default class State { * which is ambiguous when multiple transitions on the same source go to * the same destination. * - * Throws (matching `getNextState`) when no entry exists for the symbol. - * For wrappers (states produced by `withOverriddenHaltState`): the - * symbol-to-data map is shared with the bare via `bareState`, so the + * Throws (matching `getNextState` / `getCommand`) when no entry exists for + * the symbol. For wrappers (states produced by `withOverriddenHaltState`): + * the symbol-to-data map is shared with the bare via `bareState`, so the * returned `ix` is a valid position into BOTH the wrapper's and the * bare's transition iteration order — they're the same map. */ @@ -372,11 +380,7 @@ export default class State { matchedSymbol: symbol, ix: number, } { - const entry = this.#symbolToDataMap.get(symbol); - - if (entry === undefined) { - throw new Error(`No nextState for symbol at state named ${this.#id}`); - } + const entry = this.#getEntry(symbol); // Iteration order on a Map is insertion order; index lookup is O(N), // acceptable since this fires at most once per iter and N (transitions From 2957cd9cd8faac4aed61f462ffbef74efcd9d1ae Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Mon, 25 May 2026 00:34:44 +0300 Subject: [PATCH 045/118] =?UTF-8?q?feat(State):=20haltState.debug=20?= =?UTF-8?q?=E2=86=92=20boolean=20+=20post-iter=20timing=20(#207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `state.debug` was `{before, after}` for every State, including haltState — a poor fit since haltState never IS an iter's state (halt is terminal). The shape forced two artifacts: `.after = true` already threw at write time (#108 part 2), and `.before` was repurposed to fire BEFORE the iter whose transition leads to halt — an asymmetric, half-restricted API where the pause landed before the halt-bound transition had even fired, requiring tools to do forward projection to render "halt is imminent". Collapses haltState's debug to a single boolean ("enabled / disabled"): haltState.debug = true; // pause when halt is about to take effect haltState.debug = false; // turn off haltState.debug = null; // alias of false (reset) haltState.debug = {before: true}; // throws — "only accepts boolean" A `HaltState` typed alias on the singleton export narrows `debug` to `boolean` for compile-time safety at the canonical access path. Non-halt states are unchanged — `DebugConfig` only; boolean writes throw. Pause timing moves to the AFTER side of the halt-triggering iter: after the iter's own after-pause (if armed), before halt processing. `m.state` is the triggering state (not haltState), `m.debugBreak.after === true`. This drops the v6 "early-warning" behavior (pause before the halt-bound step ran) — the catch-all detection is preserved at the new timing. Reading flow now matches user mental model: the diagram cursor sits on the triggering state, the halt-bound transition shows as fired, and the halt edge naturally reads as the next thing the engine does. Breaking change inside v7-alpha (v7 IS the SemVer-major break). --- CLAUDE.md | 4 +- packages/machine/README.md | 16 ++- packages/machine/src/classes/State.ts | 112 ++++++++++++++---- .../src/classes/TuringMachine.debug.spec.ts | 99 ++++++++++------ packages/machine/src/classes/TuringMachine.ts | 17 ++- test/examples.spec.ts | 17 +-- 6 files changed, 192 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cf36779..93b3f6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,10 +47,10 @@ Key shapes that take reading multiple files to grasp: - **`TapeBlock` has a `Lock`** that `TuringMachine.run` grabs for the duration of a run, asserting the block isn't being mutated by another machine. Calls to `applyCommand` from outside a run must pass the matching capture symbol. -- **`state.debug` (v4+)** — runtime-mutable breakpoint cell with `{ before, after }` symbol filtering. Shared across `withOverriddenHaltState` wrappers via a private `Ref` so an assignment on the original is visible from every wrapper instance — useful when the same primitive is reused in composition chains. Pauses dispatch via the optional `onPause` hook on `run()` (awaited; without the hook, breaks fire-and-resume invisibly). `haltState.debug.before = true` pauses on every halt entry (program exit + subroutine pop). See `packages/machine/README.md` "Debugging breakpoints (v4+)" for the full API. +- **`state.debug` (v4+)** — runtime-mutable breakpoint cell with `{ before, after }` symbol filtering. Shared across `withOverriddenHaltState` wrappers via a private `Ref` so an assignment on the original is visible from every wrapper instance — useful when the same primitive is reused in composition chains. Pauses dispatch via the optional `onPause` hook on `run()` (awaited; without the hook, breaks fire-and-resume invisibly). **As of v7 #207, `haltState.debug` is a `boolean`** (not `DebugConfig`) — halt is terminal with one meaningful pause moment (post-triggering-iter, before halt processing). `haltState.debug = true` pauses on every halt entry (program exit + subroutine pop); the pause fires on the AFTER side of the iter whose transition leads to halt (`m.state` is the triggering state, `m.debugBreak.after === true`). Any object-shaped write (`{ before: true }`, `{ after: true }`, etc.) throws at write time. The `HaltState` typed alias on the singleton export narrows `debug` to `boolean` for compile-time safety at the canonical access path. See `packages/machine/README.md` "Debugging breakpoints (v4+)" for the full API. Cross-version notes: - - **v5**: hook renamed `onDebugBreak` → `onPause` (#110). `haltState.debug.after = true` (or `{ before, after }` together) now throws at write-time — halt is terminal, no iteration-after-halt to anchor on (#108 part 2). Halting iter's after-fire stopped being silently lost (#108 part 1). New `run({ debug: boolean })` master switch suppresses all `onPause` dispatches without editing `state.debug` assignments (#106). + - **v5**: hook renamed `onDebugBreak` → `onPause` (#110). `haltState.debug.after = true` (or `{ before, after }` together) now throws at write-time — halt is terminal, no iteration-after-halt to anchor on (#108 part 2). *Superseded in v7 by [#207](https://github.com/mellonis/turing-machine-js/issues/207): `haltState.debug` is now a boolean and ALL object writes throw — the per-side shape never modeled anything for halt.* Halting iter's after-fire stopped being silently lost (#108 part 1). New `run({ debug: boolean })` master switch suppresses all `onPause` dispatches without editing `state.debug` assignments (#106). - **v6**: `onPause(after, K)` now fires on iter K's *own* yield, alongside `onPause(before, K)` and `onStep(K)` — per-iter lifecycle is `before → step → after` (#119). Previously `after` fired on iter K+1's tick with a `prevYield` substitution dance; that substitution is gone. Implication: tests asserting cross-hook ordering at the lifecycle level need v6-aware shape. - **v6.1**: `state.debug` is now always a non-null `DebugConfig` (lazy-initialized on first read), so chained writes like `state.debug.before = true` work on a fresh state without a prior whole-object assignment. The instance is `Object.seal`-ed — typos throw `TypeError`. `state.debug = null` continues to work but now means "reset filters" (next read returns a fresh empty config). Type signature narrowed `DebugConfig | null` → `DebugConfig` on the getter; setter still accepts `null`. (#150) - **v6.2** *(superseded by v6.3)*: briefly widened `onStep` to `(m) => void | Promise` and added an inline `await onStep(m)` in the run loop, motivated by a downstream throttle use case. That overturned the docstring-stated sync contract for `onStep` and was reverted in v6.3.0. Don't reach for that shape — the v6.4 `onIter` hook is the proper place for per-iter awaited coordination. diff --git a/packages/machine/README.md b/packages/machine/README.md index a40737d..5b07618 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -497,14 +497,18 @@ myState.debug = { before: true }; myState.debug = { before: [symA] }; myState.debug = { before: [symA], after: [symA] }; -// Pause when the engine is about to enter halt (program exit OR subroutine pop): -haltState.debug = { before: true }; - -// Reset filters later — next read returns a fresh empty DebugConfig: +// Pause when the engine is about to enter halt (program exit OR subroutine pop). +// haltState.debug is a `boolean` (#207) — halt is terminal, so there's only +// one meaningful pause moment (post-triggering-iter, before halt processing). +haltState.debug = true; +haltState.debug = false; // turn off +haltState.debug = null; // alias of false (reset) + +// Reset filters later on a regular state — next read returns a fresh empty DebugConfig: myState.debug = null; ``` -> ⚠️ **`haltState.debug.after` throws.** Halt is terminal — there is no iteration-after-halt for an after-fire to anchor on. Assigning a truthy `.after` to `haltState.debug` (including `{ before: true, after: true }`) throws at write time. Symbol-list filters on `haltState.debug.before` are silent no-ops, since halt has no head symbol; only the wildcard `true` activates. +> ⚠️ **`haltState.debug` is `boolean`-only.** Any object-shaped write (`{ before: true }`, `{ after: true }`, `{ before: true, after: true }`) throws at write time. The pause fires on the AFTER side of the iter whose transition leads to halt — `m.state` is the triggering state (not haltState), `m.debugBreak.after === true`. Diagram + log narratives read naturally: the halt-bound transition has already fired when the pause lands, and halt is the next thing. The `debug` field is mutable — toggle breakpoints at runtime without rebuilding the graph. The internal cell is shared with `state.withOverriddenHaltState(...)` wrappers, so an assignment on the original is visible from every wrapper. `state.debug` is always a `DebugConfig` instance (lazy-initialized on first read); plain-object input (`state.debug = { before: true }`) is wrapped in a fresh `DebugConfig` automatically. The instance itself is `Object.seal`-ed — typos like `state.debug.bofore = true` throw `TypeError` instead of silently creating a useless property. Per-property setters validate and freeze the stored array, so `state.debug.before.push(...)` also throws `TypeError`. @@ -738,7 +742,7 @@ Reading `['0',*] → [K,'0']/[R,R]`: API surface changes since v3, in past tense so the timing of each piece is explicit: - **v4** — `run()` became async (`Promise`). Per-state runtime breakpoints landed (`state.debug.before` / `state.debug.after`); `run()` accepted an `onDebugBreak` hook. `MachineState` exposed on each yield. -- **v5** — `onDebugBreak` renamed to `onPause`. New `run({ debug: boolean })` master switch suppresses all `onPause` dispatches without unsetting `state.debug` assignments. Assigning a truthy `.after` to `haltState.debug` now throws at write time (halt is terminal — no iteration-after-halt to anchor on). +- **v5** — `onDebugBreak` renamed to `onPause`. New `run({ debug: boolean })` master switch suppresses all `onPause` dispatches without unsetting `state.debug` assignments. Assigning a truthy `.after` to `haltState.debug` now throws at write time (halt is terminal — no iteration-after-halt to anchor on). *Superseded in v7 by #207: `haltState.debug` is now `boolean`, all object-shaped writes throw.* - **v6** — Per-iter lifecycle reordered to `before → step → after`, all firing on the same yield. Previously `after` fired on iter K+1's tick with a `prevYield` substitution dance; that substitution is gone. The `MachineState.debugBreak` field shape is unchanged across all three versions. - **v6.1** — `state.debug` ergonomics: the field is now always a non-null `DebugConfig` instance (lazy-initialized on first read), so chained field writes like `state.debug.before = true` work on a fresh state without a prior whole-object assignment. The `DebugConfig` instance is `Object.seal`-ed, so typos like `state.debug.bofore = true` throw `TypeError` at write time instead of silently creating a useless property. `state.debug = null` continues to work but semantically means "reset filters" — the next read returns a fresh empty `DebugConfig` (#150). - **v6.2** *(superseded by v6.3.0)* — widened `onStep`'s signature to `(m) => void | Promise` and added an inline `await onStep(...)` in the run loop, enabling throttle-in-`onStep` patterns. This overturned the docstring-stated contract that `onStep` is sync (microtask-free); the right place for per-iter throttling is `onPause` with self-rearm (see [Throttle pattern](#throttle-pattern)). Restored in v6.3.0. diff --git a/packages/machine/src/classes/State.ts b/packages/machine/src/classes/State.ts index 0d20547..f1667c9 100644 --- a/packages/machine/src/classes/State.ts +++ b/packages/machine/src/classes/State.ts @@ -129,6 +129,15 @@ export default class State { // a runtime concern, not part of the structural graph. #debugRef: { current: DebugConfig | null } = {current: null}; + // Storage for `haltState.debug` (#207). haltState is a singleton terminal + // state — it has no iter of its own, so the per-side `{ before, after }` + // DebugConfig shape doesn't model anything meaningful for it. Instead the + // halt breakpoint is a single boolean ("enabled / disabled"). The pause + // anchors on the iter whose transition LEADS to halt, fired at end-of-iter + // (after that iter's own after-pause if armed). Only used when `isHalt`; + // ignored on every other State (whose `#debugRef` flow is unchanged). + #haltDebug: boolean = false; + // Out-of-band tags applied to this State (#186). Tags are visualization // and debugger-tooling metadata — they don't affect runtime transition // lookup or `equivalentOn` comparisons. Stored as a Set for de-duplication; @@ -224,6 +233,18 @@ export default class State { } get debug(): DebugConfig { + // haltState (#207): the canonical access path is the `haltState` singleton + // export, which is typed `HaltState` — its `debug` getter is narrowed to + // `boolean`. Generic `State` references statically see `DebugConfig` and + // (in practice) never refer to haltState — the run loop's `state` is + // never haltState because halt is terminal and doesn't iterate. The cast + // below makes the runtime boolean return type-compatible with the + // declared `DebugConfig` for any rare caller that holds a State + // reference happening to be haltState. + if (this.isHalt) { + return this.#haltDebug as unknown as DebugConfig; + } + // Lazy-init: `state.debug` is never null at read time, so chained writes // like `state.debug.before = true` work on a fresh state without a prior // whole-object assignment. The setter still accepts `null` to reset the @@ -236,20 +257,58 @@ export default class State { return this.#debugRef.current; } + // TS signature: non-halt callers (generic `State` reference) get the + // `DebugConfig | object | null` surface; boolean is rejected statically. + // The `HaltState` typed alias on the singleton export overrides this to + // `boolean | null` for the canonical halt access path. Runtime checks + // below are defensive against type-bypass / mixed-source callers. set debug( value: DebugConfig | { before?: symbol[] | readonly symbol[] | true; after?: symbol[] | readonly symbol[] | true } | null, ) { - if (value === null) { + // Defensive runtime cast: TS signature excludes boolean for the generic + // State surface, but haltState (via the HaltState alias) DOES accept + // boolean, and the runtime needs to handle it for the singleton path. + const v = value as DebugConfig | { before?: unknown; after?: unknown } | boolean | null; + // haltState (#207): only `boolean | null` is accepted. `null` aliases + // to `false` (reset). Any object-shaped write throws at write-time so + // misuse surfaces immediately rather than silently no-op'ing — the + // `{before, after}` shape doesn't model anything meaningful for halt + // (no own iter to anchor on; halt is terminal). + if (this.isHalt) { + if (v === null || typeof v === 'boolean') { + this.#haltDebug = v === true; + return; + } + + throw new Error( + 'haltState.debug only accepts boolean (or null to reset). Use ' + + '`haltState.debug = true` to enable the halt breakpoint, false to ' + + 'disable. The pause fires after the iter whose transition leads to ' + + 'halt (post-iter, before halt processing).', + ); + } + + // Non-halt states: boolean writes are rejected — the per-side + // `{before, after}` granularity is the contract. A boolean shortcut + // would hide the asymmetry between before / after. + if (typeof v === 'boolean') { + throw new Error( + 'state.debug only accepts a DebugConfig or `{ before, after }` object ' + + '(or null to reset). Boolean assignment is reserved for `haltState`.', + ); + } + + if (v === null) { this.#debugRef.current = null; return; } - if (value instanceof DebugConfig) { - this.#debugRef.current = value; + if (v instanceof DebugConfig) { + this.#debugRef.current = v; return; } - this.#debugRef.current = new DebugConfig(this, value); + this.#debugRef.current = new DebugConfig(this, v as { before?: symbol[] | readonly symbol[] | true; after?: symbol[] | readonly symbol[] | true }); } /** @@ -287,31 +346,18 @@ export default class State { return Object.freeze([...this.#tags]); } - /** @internal — invoked by DebugConfig setters via module-private symbol. */ + /** @internal — invoked by DebugConfig setters via module-private symbol. + * Per #207, haltState no longer flows through DebugConfig (its `debug` + * setter rejects object writes before construction), so the validator + * only sees non-halt states here. */ [validateDebugFilter]( fieldName: 'before' | 'after', filter: readonly symbol[] | true | undefined, ): void { if (filter === undefined) return; - // #108 part 2: `.after` on haltState has no semantic anchor — halt is - // terminal, so there is no iteration-after-halt for an after-fire to - // attach to. Reject any truthy assignment (true OR list) at write time - // so misuse surfaces immediately rather than silently no-op'ing. - if (this.isHalt && fieldName === 'after') { - throw new Error( - 'haltState.debug.after is not supported: halt is terminal, so there is ' - + 'no iteration-after-halt for an after-fire to anchor on. Use ' - + '{ before: true } to pause on halt entry.', - ); - } - if (filter === true) return; - // haltState has no own transitions; symbol-list filters on `before` are - // silent no-ops at the engine level (spec §8.6), so accept any list shape. - if (this.isHalt) return; - for (const sym of filter) { if (sym !== ifOtherSymbol && !this.#symbolToDataMap.has(sym)) { throw new Error( @@ -576,4 +622,26 @@ export default class State { } } -export const haltState = new State(null); +/** + * Typed alias for the haltState singleton (#207). Narrows `debug` from + * the generic-State `DebugConfig | boolean` union to plain `boolean`, + * giving compile-time type-safety at the singleton's call sites: + * + * ```ts + * haltState.debug = true; // ok + * haltState.debug = false; // ok + * haltState.debug = { before: true } // TS error + * const isOn = haltState.debug; // typed `boolean` + * ``` + * + * Anyone holding a `State` reference that happens to BE the singleton (e.g. + * via `state.getNextState(sym).ref === haltState`) sees the wider `State` + * type; runtime throws guide them to the right shape. The singleton export + * is the canonical access path. + */ +export type HaltState = State & { + get debug(): boolean; + set debug(value: boolean | null); +}; + +export const haltState: HaltState = new State(null) as HaltState; diff --git a/packages/machine/src/classes/TuringMachine.debug.spec.ts b/packages/machine/src/classes/TuringMachine.debug.spec.ts index b54e761..a66c51c 100644 --- a/packages/machine/src/classes/TuringMachine.debug.spec.ts +++ b/packages/machine/src/classes/TuringMachine.debug.spec.ts @@ -158,39 +158,45 @@ describe('TuringMachine — debug.after filter (loop yields)', () => { }); }); -describe('TuringMachine — haltState.debug.before', () => { +describe('TuringMachine — haltState.debug (boolean, #207)', () => { afterEach(() => { // haltState is a singleton — clear after each test to avoid cross-pollution. - haltState.debug = null; + haltState.debug = false; }); - test('haltState.debug.before = true fires on program halt (last visit only)', async () => { + test('haltState.debug = true fires `debugBreak.after` on the halt-triggering iter (#207)', async () => { const {machine, state} = buildMachine(); - haltState.debug = {before: true}; + haltState.debug = true; const steps: MachineState[] = []; await machine.run({initialState: state, onStep: (s) => { steps.push(s); }}); expect(steps).toHaveLength(VISIT_COUNT); - // Only the visit that transitions to halt (the trailing blank) carries - // debugBreak.before from haltState.debug.before — the earlier visits - // transition self-loop, not to halt. + // Only the visit whose transition leads to halt (the trailing blank → + // ifOtherSymbol → haltState) carries `debugBreak.after`. The earlier + // visits self-loop within `state`, so their nextState is `state` itself, + // not haltState — no halt-imminent dispatch. for (let i = 0; i < VISIT_COUNT - 1; i++) { expect(steps[i]).not.toHaveProperty('debugBreak'); } const last = steps[VISIT_COUNT - 1]; expect(last.nextState).toBe(haltState); - expect(last.debugBreak).toEqual({before: true}); + // #207 timing: fires on AFTER side (post-iter, before halt processing) — + // not BEFORE as the v6 API did. `m.state` is the TRIGGERING state (the + // one whose transition leads to halt), not haltState itself. + expect(last.state).toBe(state); + expect(last.debugBreak).toEqual({after: true}); }); - test('haltState.debug.before fires on subroutine return (halt-pop)', async () => { + test('haltState.debug = true fires on each halt entry — including subroutine return (halt-pop)', async () => { // Custom 1-cell tape + nested-state setup. Trajectory: // visit 1: head 'A', state=wrapped → erase+right, transition to inner // visit 2: head blank, state=inner → ifOtherSymbol → would halt; // wrapped's override redirects to continuation. nextState=continuation. - // haltState.debug.before fires (because original nextState was haltState). + // #207: halt-imminent fires on AFTER side (transition's original + // nextState was haltState before the pop redirect). // visit 3: head blank, state=continuation → ifOtherSymbol → halt. - // haltState.debug.before fires again. + // #207: halt-imminent fires on AFTER side. const tape = new Tape({alphabet, symbols: ['A']}); const tapeBlock = TapeBlock.fromTapes([tape]); const machine = new TuringMachine({tapeBlock}); @@ -209,7 +215,7 @@ describe('TuringMachine — haltState.debug.before', () => { const wrapped = inner.withOverriddenHaltState(continuation); - haltState.debug = {before: true}; + haltState.debug = true; const steps: MachineState[] = []; await machine.run({initialState: wrapped, onStep: (s) => { steps.push(s); }}); @@ -219,33 +225,41 @@ describe('TuringMachine — haltState.debug.before', () => { // Visit 1: just self-loops into inner — no halt-related break. expect(steps[0]).not.toHaveProperty('debugBreak'); - // Visit 2: transitions to continuation via halt-pop. debugBreak.before fires - // because nextState (pre-pop) was haltState. + // Visit 2: transitions to continuation via halt-pop. `debugBreak.after` + // fires because the transition's original nextState was haltState (the + // pop-redirect to continuation happens AFTER the engine's halt check). const popYield = steps.find((s) => s.nextState === continuation); expect(popYield).toBeDefined(); expect(popYield).toBe(steps[1]); - expect(popYield!.debugBreak).toEqual({before: true}); + expect(popYield!.debugBreak).toEqual({after: true}); - // Visit 3: transitions to halt directly. debugBreak.before fires. + // Visit 3: transitions to halt directly. `debugBreak.after` fires. expect(steps[2].nextState).toBe(haltState); - expect(steps[2].debugBreak).toEqual({before: true}); + expect(steps[2].debugBreak).toEqual({after: true}); }); - test('haltState.debug.before with symbol list NEVER matches (no head symbol at halt)', async () => { - const {machine, state, symbol} = buildMachine(); - const symA = symbol(['A']); - haltState.debug = {before: [symA]}; + test('haltState.debug = false / null suppresses dispatch on every iter', async () => { + const {machine, state} = buildMachine(); + haltState.debug = false; const steps: MachineState[] = []; await machine.run({initialState: state, onStep: (s) => { steps.push(s); }}); expect(steps).toHaveLength(VISIT_COUNT); - // Halt has no head symbol; list filter cannot match. No debug break should fire - // because of haltState.debug. (state.debug is null, so no other source.) for (const step of steps) { expect(step).not.toHaveProperty('debugBreak'); } }); + + test('haltState.debug getter returns boolean (typed `boolean` via HaltState alias)', () => { + haltState.debug = true; + expect(haltState.debug).toBe(true); + haltState.debug = false; + expect(haltState.debug).toBe(false); + haltState.debug = null; + // null aliases to false (reset). + expect(haltState.debug).toBe(false); + }); }); describe('TuringMachine — run() with onPause', () => { @@ -391,22 +405,41 @@ describe('TuringMachine — halt semantics for after-fire (#108)', () => { expect(after).toHaveLength(VISIT_COUNT); }); - test('haltState.debug.after = true throws on assignment (#108 part 2)', () => { - // Halt is terminal — no iteration-after-halt for an after-fire to anchor on. - // v5 rejects the assignment to surface the misuse rather than silently - // ignore it. + test('haltState.debug = {after: true} throws — boolean-only API (#207, supersedes #108 part 2)', () => { + // #207 collapsed haltState's debug to a single boolean — the {before, after} + // shape doesn't model anything meaningful for a terminal singleton. Any + // object write throws at write-time with a clear message. expect(() => { + // @ts-expect-error — HaltState typed alias narrows to `boolean`; the runtime throw + // is the secondary line of defense for callers reaching haltState through a + // generic `State` reference (e.g. `state.getNextState(sym).ref`). haltState.debug = {after: true}; - }).toThrow(); + }).toThrow(/haltState\.debug only accepts boolean/); }); - test('haltState.debug with both flags throws (#108 part 2)', () => { - // Setting before+after symmetrically is the most likely user mistake; the - // .after part is meaningless and v5 rejects the whole assignment. Use - // { before: true } alone. + test('haltState.debug = {before: true} throws — boolean-only API (#207)', () => { + // The v6 API; under #207 this throws so callers migrate to `= true`. expect(() => { + // @ts-expect-error — see comment above. + haltState.debug = {before: true}; + }).toThrow(/haltState\.debug only accepts boolean/); + }); + + test('haltState.debug = {before: true, after: true} throws — boolean-only API (#207)', () => { + expect(() => { + // @ts-expect-error — see comment above. haltState.debug = {before: true, after: true}; - }).toThrow(); + }).toThrow(/haltState\.debug only accepts boolean/); + }); + + test('non-halt state.debug = boolean throws — DebugConfig-only on non-halt (#207)', () => { + // Symmetric guard: only haltState accepts boolean. Non-halt states must + // use the DebugConfig shape so the per-side granularity stays explicit. + const s = new State(); + expect(() => { + // @ts-expect-error — non-halt State's debug setter narrows to DebugConfig. + s.debug = true; + }).toThrow(/Boolean assignment is reserved for `haltState`/); }); }); diff --git a/packages/machine/src/classes/TuringMachine.ts b/packages/machine/src/classes/TuringMachine.ts index b36f83b..1b6fde9 100644 --- a/packages/machine/src/classes/TuringMachine.ts +++ b/packages/machine/src/classes/TuringMachine.ts @@ -216,9 +216,20 @@ export default class TuringMachine { // Both before and after refer to THIS iter (#119 / v6.0.0). // The halting iter's after-fire just rides along on the iter's // own yield — no post-loop drain needed. - const beforeMatch = matchFilter(state.debug?.before, symbol) - || (nextState.isHalt && nextState.debug?.before === true); - const afterMatch = matchFilter(state.debug?.after, symbol); + // + // #207: `haltState.debug` is now a boolean, and pauses on the + // halt-triggering iter's AFTER side (not before). The previous + // before-side check (`nextState.debug?.before === true`) was + // "early-warning" timing — the user paused before the halt-bound + // transition fired, then had to mentally re-derive what would + // happen. Now the pause anchors post-step (after the iter's own + // after-pause if armed), so consumers see the just-fired halt- + // bound transition + diagram cursor still on the triggering state. + const debug = state.debug; + const stateDebugConfig = typeof debug === 'boolean' ? null : debug; + const beforeMatch = matchFilter(stateDebugConfig?.before, symbol); + const afterMatch = matchFilter(stateDebugConfig?.after, symbol) + || (nextState === haltState && haltState.debug); const nextStateForYield = nextState.isHalt && stack.length ? stack.slice(-1)[0] diff --git a/test/examples.spec.ts b/test/examples.spec.ts index 8e56890..192bb8c 100644 --- a/test/examples.spec.ts +++ b/test/examples.spec.ts @@ -136,9 +136,9 @@ describe('README.md — Debugging breakpoints', () => { expect(order).toEqual(['before', 'after']); }); - test('haltState.debug.before pauses on halt entry — fires once at the final visit', async () => { + test('haltState.debug = true pauses on halt entry — fires once at the final visit (#207)', async () => { const {machine, myState} = buildExampleMachine(); - haltState.debug = {before: true}; + haltState.debug = true; const haltPauses: Array<{atVisit: number}> = []; let visitIx = 0; @@ -146,17 +146,20 @@ describe('README.md — Debugging breakpoints', () => { initialState: myState, onStep: () => { visitIx += 1; }, // increments before onPause for this visit onPause: (m) => { - if (m.nextState === haltState && m.debugBreak?.before) { + // #207: halt-imminent fires on AFTER side (post-iter, before halt + // processing). The triggering iter's `nextState` is haltState, and + // `debugBreak.after === true`. + if (m.nextState === haltState && m.debugBreak?.after) { haltPauses.push({atVisit: visitIx}); } }, }); expect(haltPauses).toHaveLength(HALT_TRANSITION_COUNT); - // Note: per-iter dispatch order is `before → step → after` — onPause for - // before fires BEFORE onStep increments visitIx, so the recorded visit - // index is one less than the human-readable visit count. - expect(haltPauses[0].atVisit).toBe(VISIT_COUNT - 1); + // Per-iter dispatch order is `before → step → after` — onStep increments + // visitIx during step, then onPause for `after` reads it. The recorded + // visit index matches the human-readable visit count. + expect(haltPauses[0].atVisit).toBe(VISIT_COUNT); }); test('Reset filters by assigning null', () => { From 58b69f2611923984bfc9afbfed0c0d073a296ee2 Mon Sep 17 00:00:00 2001 From: Ruslan Gilmullin Date: Mon, 25 May 2026 10:04:06 +0300 Subject: [PATCH 046/118] docs: warn that chained haltState.debug.before silently no-ops in non-strict mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #207 setter only sees whole-object writes; chained-form `haltState.debug.before = true` reads the boolean (false) and then assigns `.before` on it — silently no-op in non-strict, TypeError in strict. Engine can't intercept since the write never reaches the setter. Doc the gotcha next to the existing boolean-only callout in both the README and CLAUDE.md. --- CLAUDE.md | 2 +- packages/machine/README.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93b3f6f..5dafc91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Key shapes that take reading multiple files to grasp: - **`TapeBlock` has a `Lock`** that `TuringMachine.run` grabs for the duration of a run, asserting the block isn't being mutated by another machine. Calls to `applyCommand` from outside a run must pass the matching capture symbol. -- **`state.debug` (v4+)** — runtime-mutable breakpoint cell with `{ before, after }` symbol filtering. Shared across `withOverriddenHaltState` wrappers via a private `Ref` so an assignment on the original is visible from every wrapper instance — useful when the same primitive is reused in composition chains. Pauses dispatch via the optional `onPause` hook on `run()` (awaited; without the hook, breaks fire-and-resume invisibly). **As of v7 #207, `haltState.debug` is a `boolean`** (not `DebugConfig`) — halt is terminal with one meaningful pause moment (post-triggering-iter, before halt processing). `haltState.debug = true` pauses on every halt entry (program exit + subroutine pop); the pause fires on the AFTER side of the iter whose transition leads to halt (`m.state` is the triggering state, `m.debugBreak.after === true`). Any object-shaped write (`{ before: true }`, `{ after: true }`, etc.) throws at write time. The `HaltState` typed alias on the singleton export narrows `debug` to `boolean` for compile-time safety at the canonical access path. See `packages/machine/README.md` "Debugging breakpoints (v4+)" for the full API. +- **`state.debug` (v4+)** — runtime-mutable breakpoint cell with `{ before, after }` symbol filtering. Shared across `withOverriddenHaltState` wrappers via a private `Ref` so an assignment on the original is visible from every wrapper instance — useful when the same primitive is reused in composition chains. Pauses dispatch via the optional `onPause` hook on `run()` (awaited; without the hook, breaks fire-and-resume invisibly). **As of v7 #207, `haltState.debug` is a `boolean`** (not `DebugConfig`) — halt is terminal with one meaningful pause moment (post-triggering-iter, before halt processing). `haltState.debug = true` pauses on every halt entry (program exit + subroutine pop); the pause fires on the AFTER side of the iter whose transition leads to halt (`m.state` is the triggering state, `m.debugBreak.after === true`). Any object-shaped write (`{ before: true }`, `{ after: true }`, etc.) throws at write time. The `HaltState` typed alias on the singleton export narrows `debug` to `boolean` for compile-time safety at the canonical access path. **Chained-form `haltState.debug.before = true` does NOT throw in non-strict mode** — it's a primitive-boolean property assignment, silent in non-strict, `TypeError` in strict; the engine never sees the write so can't intercept. Always use whole-object form. See `packages/machine/README.md` "Debugging breakpoints (v4+)" for the full API. Cross-version notes: - **v5**: hook renamed `onDebugBreak` → `onPause` (#110). `haltState.debug.after = true` (or `{ before, after }` together) now throws at write-time — halt is terminal, no iteration-after-halt to anchor on (#108 part 2). *Superseded in v7 by [#207](https://github.com/mellonis/turing-machine-js/issues/207): `haltState.debug` is now a boolean and ALL object writes throw — the per-side shape never modeled anything for halt.* Halting iter's after-fire stopped being silently lost (#108 part 1). New `run({ debug: boolean })` master switch suppresses all `onPause` dispatches without editing `state.debug` assignments (#106). diff --git a/packages/machine/README.md b/packages/machine/README.md index 5b07618..f0719d0 100644 --- a/packages/machine/README.md +++ b/packages/machine/README.md @@ -510,6 +510,8 @@ myState.debug = null; > ⚠️ **`haltState.debug` is `boolean`-only.** Any object-shaped write (`{ before: true }`, `{ after: true }`, `{ before: true, after: true }`) throws at write time. The pause fires on the AFTER side of the iter whose transition leads to halt — `m.state` is the triggering state (not haltState), `m.debugBreak.after === true`. Diagram + log narratives read naturally: the halt-bound transition has already fired when the pause lands, and halt is the next thing. +> ⚠️ **Chained-form `haltState.debug.before = true` doesn't throw in non-strict mode** — this is a JavaScript primitive quirk, not engine behavior. The getter returns the boolean `false`; assigning `.before` to that boolean is a no-op in non-strict mode (silent), a `TypeError` in strict mode. The engine setter only sees whole-object writes (`haltState.debug = X`), so it can't intercept the chained form. **Always use the whole-object form: `haltState.debug = true` / `= false` / `= null`.** Modules built with `tsc` / ESM run in strict mode by default and surface this throw; `new Function(...)` and `