@turing-machine-js/machine 5.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  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).
6
6
 
7
+ ## [6.0.0] - 2026-05-09
8
+
9
+ ### Changed (BREAKING)
10
+
11
+ - **`onPause` dispatch order rewritten** ([#119](https://github.com/mellonis/turing-machine-js/issues/119)). `onPause(after, K)` now fires on iter K's *own* yield, alongside `onPause(before, K)` and `onStep(K)` — instead of v5's behavior of firing on iter K+1's yield with a `prevYield` substitution. The set of dispatched calls per run and per-iter semantics are unchanged; only the cross-hook *sequence* differs. Per-iter lifecycle inside `run()` is now `before → step → after`. Consumers using both `onStep` and `onPause` and asserting cross-hook ordering must update; consumers treating the hooks as independent observers see no change.
12
+
13
+ Subordinate consequences:
14
+ - `onPause(after)` carries the iteration's own `MachineState` directly — no `prevYield` substitution dance.
15
+ - `runStepByStep` no longer tracks `pendingAfterFromPrev` across yields; `debugBreak.after` on a yield refers to *that* iter's after-arm.
16
+ - The v5 post-loop after-fire drain (#108 part 1) collapses — the halting iter's after rides along on its own yield like any other iter.
17
+ - The generator's return type narrows back from `Generator<MachineState, MachineState | null>` to `Generator<MachineState>`; `for..of` consumers (the canonical pattern) are unaffected.
18
+
19
+ ### Closes
20
+
21
+ - [#107](https://github.com/mellonis/turing-machine-js/issues/107) — "expose un-substituted `machineState` for after-break consumers" disappears as a problem: there is no substitution to expose around.
22
+
23
+ ### Migration
24
+
25
+ No call-site change to `run()`'s API:
26
+
27
+ ```ts
28
+ await machine.run({
29
+ initialState,
30
+ onStep: (m) => { /* unchanged */ },
31
+ onPause: (m) => {
32
+ if (m.debugBreak?.before) console.log('before:', m.state.name);
33
+ if (m.debugBreak?.after) console.log('after:', m.state.name);
34
+ },
35
+ });
36
+ ```
37
+
38
+ Tests asserting the v5 sequence (`pause(after K-1) → pause(before K) → step(K)`) need updating to the v6 sequence (`pause(before K) → step(K) → pause(after K)`).
39
+
7
40
  ## [5.0.0] - 2026-05-09
8
41
 
9
42
  ### Changed (BREAKING)
package/README.md CHANGED
@@ -46,12 +46,42 @@ await machine.run({
46
46
  [ifOtherSymbol]: {
47
47
  command: [{ movement: movements.right }],
48
48
  },
49
- }),
49
+ }, 'replaceB'),
50
50
  });
51
51
 
52
52
  console.log(tape.symbols.join('').trim()); // a*c*a
53
53
  ```
54
54
 
55
+ The state graph for the example above:
56
+
57
+ ```mermaid
58
+ flowchart LR
59
+ S(("**replaceB**"))
60
+ H((("**halt**")))
61
+ S -- "b → *, R" --> S
62
+ S -- "_ → keep, L" --> H
63
+ S -- "any other → keep, R" --> S
64
+ ```
65
+
66
+ *Reading the labels: `read → write, move`. `_` is the blank symbol.*
67
+
68
+ <details>
69
+ <summary>📊 Same diagram, generated by <code>toMermaid()</code> (the engine's actual output)</summary>
70
+
71
+ ```mermaid
72
+ flowchart TD
73
+ %% alphabets: [[" ","a","b","c","*"]]
74
+ s0(((halt)))
75
+ s1(("replaceB"))
76
+ s1 -- "b → */R" --> s1
77
+ s1 -- "- → ·/L" --> s0
78
+ s1 -- "* → ·/R" --> s1
79
+ ```
80
+
81
+ 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.
82
+
83
+ </details>
84
+
55
85
  A `State` is keyed by JS `Symbol`s returned from `tapeBlock.symbol(pattern)` — the pattern lists the expected symbol under each tape's head. `ifOtherSymbol` is the fallback key when nothing else matches; transitioning into `haltState` stops the run.
56
86
 
57
87
  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'`.
@@ -143,6 +173,16 @@ tape.right(); // move head right; auto-extends with blanks at the edge
143
173
  tape.symbol = 'X'; // write the cell under head
144
174
  ```
145
175
 
176
+ For visualization-friendly UIs, `Tape` exposes a fixed-width viewport centered on the head:
177
+
178
+ ```javascript
179
+ const tape = new Tape({ alphabet, symbols: ['a', 'b', 'c'], viewportWidth: 7 });
180
+ tape.viewport; // 7-cell snapshot centered on the head, padded with blanks
181
+ tape.viewportWidth; // 7 (the constructor bumps even values to the next odd)
182
+ ```
183
+
184
+ `viewportWidth` defaults to `1` and must be ≥ 1; `tape.viewport` always has exactly `viewportWidth` cells regardless of how many symbols the tape actually holds. Useful for rendering a sliding window in a UI; ignore if you only need `tape.symbols` / `tape.position`.
185
+
146
186
  ### TapeBlock
147
187
 
148
188
  A bundle of one or more `Tape`s that the machine reads/writes together in lock-step. Construct via either factory:
@@ -186,8 +226,7 @@ A node in the transition graph. Construct with a definition object whose keys ar
186
226
  ```javascript
187
227
  const s = new State({
188
228
  [tapeBlock.symbol(['1'])]: { command: { symbol: '0', movement: movements.right } },
189
- [tapeBlock.symbol(['$'])]: { nextState: haltState },
190
- [ifOtherSymbol]: { command: { movement: movements.right } },
229
+ [tapeBlock.symbol(['$'])]: { command: { movement: movements.left }, nextState: haltState },
191
230
  }, 'name');
192
231
  ```
193
232
 
@@ -198,6 +237,32 @@ Notable members and statics:
198
237
  - **`State.toGraph(state, tapeBlock)`** — walks the reachable graph from `state` and returns a serializable `Graph` (states, transitions, alphabets).
199
238
  - **`State.fromGraph(graph)`** — inverse of `toGraph`: rebuilds `State` instances + a fresh `TapeBlock` from a `Graph`. Round-trips together with `toMermaid` / `fromMermaid`.
200
239
 
240
+ For visualization, pair `State.toGraph` with `toMermaid` to render the graph in any Mermaid-aware viewer (GitHub, VS Code, mermaid.live):
241
+
242
+ ```javascript
243
+ import { State, toMermaid } from '@turing-machine-js/machine';
244
+
245
+ const graph = State.toGraph(s, tapeBlock);
246
+ console.log(toMermaid(graph));
247
+ ```
248
+
249
+ The string `toMermaid` produces is a real Mermaid flowchart that renders in-place anywhere Mermaid is supported:
250
+
251
+ ```mermaid
252
+ flowchart TD
253
+ %% alphabets: [[" ","0","1","$"]]
254
+ s0(((halt)))
255
+ s1(("name"))
256
+ s1 -- "1 → 0/R" --> s1
257
+ s1 -- "$ → ·/L" --> s0
258
+ ```
259
+
260
+ *Edge labels are `read → write/move`. `·` is the keep-current-symbol marker (no write); `L` / `R` / `S` are head moves.*
261
+
262
+ > 💡 **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.
263
+
264
+ `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)).
265
+
201
266
  ### Reference
202
267
 
203
268
  A forward-declaration handle, used when a `State` needs to point at another `State` that doesn't exist yet (cyclic graphs). Construct unbound, pass as `nextState`, call `.bind(actualState)` once that state has been built.
@@ -209,7 +274,33 @@ const b = new State({ [symbol(['y'])]: { nextState: a } }, 'b');
209
274
  ref.bind(b); // a's transition now resolves to b at run time
210
275
  ```
211
276
 
212
- `reference.ref` returns the bound state and throws if the reference is still unbound when the machine runs.
277
+ The resulting cycle:
278
+
279
+ ```mermaid
280
+ flowchart LR
281
+ a(("**a**"))
282
+ b(("**b**"))
283
+ a -- "x" --> b
284
+ b -- "y" --> a
285
+ ```
286
+
287
+ <details>
288
+ <summary>📊 Same diagram, generated by <code>toMermaid()</code></summary>
289
+
290
+ ```mermaid
291
+ flowchart TD
292
+ %% alphabets: [[" ","x","y"]]
293
+ s1(("a"))
294
+ s2["b"]
295
+ s1 -- "x → ·/S" --> s2
296
+ s2 -- "y → ·/S" --> s1
297
+ ```
298
+
299
+ `a` is round (the initial state passed to `toGraph`); `b` is square (reachable from `a`).
300
+
301
+ </details>
302
+
303
+ `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.
213
304
 
214
305
  ### TuringMachine
215
306
 
@@ -227,8 +318,118 @@ for (const step of machine.runStepByStep({ initialState })) {
227
318
  }
228
319
  ```
229
320
 
321
+ Each yielded `step` (`MachineState`) has these fields:
322
+
323
+ | Field | Type | Meaning |
324
+ |---|---|---|
325
+ | `step` | `number` | 1-indexed iteration number |
326
+ | `state` | `State` | the state about to execute |
327
+ | `currentSymbols` | `string[]` | per-tape head symbols, before the command applies |
328
+ | `nextSymbols` | `string[]` | per-tape symbols that will be written |
329
+ | `movements` | `symbol[]` | per-tape head moves (`movements.left/right/stay`) |
330
+ | `nextState` | `State` | the state that will execute next |
331
+ | `debugBreak?` | `{ before?: true, after?: true }` | only set when `state.debug` matched on this iter — see *Debugging breakpoints* below |
332
+
230
333
  `stepsLimit` (default `1e5`) guards against runaway loops — exceeding it throws.
231
334
 
335
+ ## Subroutine composition with `withOverrodeHaltState`
336
+
337
+ `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.
338
+
339
+ ```javascript
340
+ import { Alphabet, State, TapeBlock, TuringMachine, Tape, haltState, ifOtherSymbol, movements, symbolCommands } from '@turing-machine-js/machine';
341
+
342
+ const alphabet = new Alphabet([' ', 'a', 'b', 'X']);
343
+ const tapeBlock = TapeBlock.fromAlphabets([alphabet]);
344
+ const { symbol } = tapeBlock;
345
+
346
+ // Reusable subroutine 1: walk right until 'X', halt on it.
347
+ const scanToX = new State({
348
+ [symbol(['X'])]: { nextState: haltState },
349
+ [ifOtherSymbol]: { command: { movement: movements.right } },
350
+ }, 'scanToX');
351
+
352
+ // Reusable subroutine 2: erase the head cell, halt.
353
+ const eraseHere = new State({
354
+ [ifOtherSymbol]: { command: { symbol: symbolCommands.erase }, nextState: haltState },
355
+ }, 'eraseHere');
356
+
357
+ // Compose: scan to X, then ERASE it. scanToX is unmodified.
358
+ const scanThenErase = scanToX.withOverrodeHaltState(eraseHere);
359
+
360
+ const tape = new Tape({ alphabet, symbols: ['a', 'b', 'X', 'b', 'a'] });
361
+ tapeBlock.replaceTape(tape);
362
+ await new TuringMachine({ tapeBlock }).run({ initialState: scanThenErase });
363
+
364
+ console.log(tape.symbols.join('')); // "ab ba" — the X at index 2 is gone, head landed there.
365
+ ```
366
+
367
+ What changes between *running `scanToX` standalone* and *running the composed wrapper*:
368
+
369
+ ```mermaid
370
+ flowchart LR
371
+ subgraph standalone["scanToX (standalone) — halts at X"]
372
+ direction LR
373
+ a1(("scanToX"))
374
+ h1(((halt)))
375
+ a1 -- "X → keep, S" --> h1
376
+ a1 -- "any other → keep, R" --> a1
377
+ end
378
+ subgraph composed["scanToX.withOverrodeHaltState(eraseHere) — halt is intercepted"]
379
+ direction LR
380
+ a2(("scanToX"))
381
+ b2(("eraseHere"))
382
+ h2(((halt)))
383
+ a2 -. "X → keep, S<br/>intercepted" .-> b2
384
+ a2 -- "any other → keep, R" --> a2
385
+ b2 -- "any → erase, S" --> h2
386
+ end
387
+ ```
388
+
389
+ <details>
390
+ <summary>📊 Both diagrams, generated by <code>toMermaid()</code></summary>
391
+
392
+ `toMermaid(toGraph(scanToX, tapeBlock))` — the standalone subroutine:
393
+
394
+ ```mermaid
395
+ flowchart TD
396
+ %% alphabets: [[" ","a","b","X"]]
397
+ s0(((halt)))
398
+ s1(("scanToX"))
399
+ s1 -- "X → ·/S" --> s0
400
+ s1 -- "* → ·/R" --> s1
401
+ ```
402
+
403
+ `toMermaid(toGraph(scanThenErase, tapeBlock))` — the wrapped composition:
404
+
405
+ ```mermaid
406
+ flowchart TD
407
+ %% alphabets: [[" ","a","b","X"]]
408
+ s0(((halt)))
409
+ s1["scanToX"]
410
+ s2["eraseHere"]
411
+ s3(("scanToX>eraseHere"))
412
+ s1 -- "X → ·/S" --> s0
413
+ s1 -- "* → ·/R" --> s1
414
+ s2 -- "* → ⌫/S" --> s0
415
+ s3 -- "X → ·/S" --> s0
416
+ s3 -- "* → ·/R" --> s1
417
+ s3 -. onHalt .-> s2
418
+ ```
419
+
420
+ **Reading guide** — the wrapped diagram is denser than the simplified hand-drawn version above. To parse it:
421
+
422
+ 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.
423
+ 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.
424
+ 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`."
425
+ 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.
426
+
427
+ > 💡 **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.
428
+
429
+ </details>
430
+
431
+ 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.
432
+
232
433
  ## Debugging breakpoints (v4+)
233
434
 
234
435
  Any `State` can carry a runtime-mutable `debug` config that pauses execution at chosen points.
@@ -254,7 +455,9 @@ haltState.debug = { before: true };
254
455
  myState.debug = null;
255
456
  ```
256
457
 
257
- The `debug` field is mutabletoggle 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.
458
+ > ⚠️ **`haltState.debug.after` throws.** Halt is terminalthere 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.
459
+
460
+ 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. Plain-object input (`state.debug = { before: true }`) is wrapped in a `DebugConfig` instance automatically; the wrapper's per-property setters validate and freeze the stored array, so `state.debug.before.push(...)` throws `TypeError`.
258
461
 
259
462
  `run()` is async and accepts an `onPause` hook:
260
463
 
@@ -270,7 +473,7 @@ await machine.run({
270
473
  });
271
474
  ```
272
475
 
273
- For `after` calls, `m` is the previous yield's snapshot `m.state` is the state whose `after` filter fired. For `before` calls, `m` is the current iteration. `onStep` always sees the original (un-substituted) yield.
476
+ Both `before` and `after` for the same iteration fire on the iteration's own yield, in the order **before → step → after**. `m.state` is always the iteration's own state; the `m.debugBreak` flag (`{before: true}` or `{after: true}`) tells the consumer which timing fired.
274
477
 
275
478
  If `onPause` is not provided, breaks fire-and-resume invisibly — the trajectory is identical to running without `debug` set.
276
479
 
@@ -12,13 +12,15 @@ export type MachineState = {
12
12
  movements: symbol[];
13
13
  nextState: State;
14
14
  /**
15
- * Set only when this iteration boundary is a debug break.
15
+ * Set only when this iteration is a debug break point.
16
16
  * Field is OMITTED entirely when no break fires (no `debugBreak: undefined`).
17
17
  * At least one of `before` / `after` is `true` when the field is present.
18
18
  *
19
- * For consumers of the `runStepByStep` generator the `state` field reflects
20
- * the current iteration regardless of timing; `run()` substitutes the prior
21
- * yield's snapshot for `after` calls so consumers see the source state.
19
+ * Both flags refer to THIS iter — `before` means the iter's `state.debug.before`
20
+ * matched, `after` means the iter's `state.debug.after` matched. `run()`
21
+ * dispatches the two timings as separate `onPause` calls (before-call has
22
+ * `debugBreak: {before: true}` only; after-call has `debugBreak: {after: true}`
23
+ * only) so consumers can distinguish without ambiguity.
22
24
  */
23
25
  debugBreak?: {
24
26
  before?: true;
@@ -31,7 +33,7 @@ export default class TuringMachine {
31
33
  tapeBlock?: TapeBlock;
32
34
  });
33
35
  get tapeBlock(): TapeBlock;
34
- run({ initialState, stepsLimit, onStep, onPause, }: RunParameter & {
36
+ run({ initialState, stepsLimit, onStep, onPause, debug, }: RunParameter & {
35
37
  /**
36
38
  * Sync, ~free hook fired on every iteration. Use for logging/tracing —
37
39
  * the hot loop runs this without a microtask boundary, so it must not
@@ -39,16 +41,31 @@ export default class TuringMachine {
39
41
  */
40
42
  onStep?: (machineState: MachineState) => void;
41
43
  /**
42
- * Async hook fired only when `state.debug[when]` matches at the current
44
+ * Async hook fired when `state.debug[when]` matches at the current
43
45
  * iteration. The promise is awaited inline, so the consumer can suspend
44
46
  * execution by deferring its resolution. Use for pause-capable inspection
45
47
  * (debugger UIs, conditional breakpoints in tests).
46
48
  *
47
- * Renamed from `onDebugBreak` in v5.0.0. The `m.debugBreak` payload field
48
- * keeps its name (it describes the engine's reason for pausing).
49
+ * Renamed from `onDebugBreak` in v5.0.0. In v6.0.0 the dispatch order
50
+ * was changed so that `before` and `after` for the SAME iter fire on the
51
+ * same yield (per-iter lifecycle: before → step → after); previously the
52
+ * `after` of iter K fired on iter K+1's tick with a substituted source
53
+ * view. The `m.debugBreak` payload field keeps its name (it describes the
54
+ * engine's reason for pausing).
49
55
  */
50
56
  onPause?: (machineState: MachineState) => void | Promise<void>;
57
+ /**
58
+ * Master switch for `onPause` dispatch. When `false`, suppresses all
59
+ * pause-fires (before and after) regardless of `state.debug` assignments.
60
+ * `onStep` is unaffected. Defaults to `true`.
61
+ *
62
+ * The `m.debugBreak` field is still populated on yields by the underlying
63
+ * generator (it's a property of the iteration, not of the consumer); only
64
+ * `run()`'s hook dispatch is gated. Direct `runStepByStep` consumers see
65
+ * the metadata regardless.
66
+ */
67
+ debug?: boolean;
51
68
  }): Promise<void>;
52
- runStepByStep({ initialState, stepsLimit }: RunParameter): Generator<MachineState, MachineState | null>;
69
+ runStepByStep({ initialState, stepsLimit }: RunParameter): Generator<MachineState>;
53
70
  }
54
71
  export {};
package/dist/index.cjs CHANGED
@@ -790,10 +790,21 @@ class State {
790
790
  }
791
791
  /** @internal — invoked by DebugConfig setters via module-private symbol. */
792
792
  [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overrodeHaltState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), validateDebugFilter)](fieldName, filter) {
793
- if (filter === undefined || filter === true)
793
+ if (filter === undefined)
794
794
  return;
795
- // haltState has no own transitions; symbol-list filters on it are silent
796
- // no-ops at the engine level (spec §8.6), so accept any list shape here.
795
+ // #108 part 2: `.after` on haltState has no semantic anchor halt is
796
+ // terminal, so there is no iteration-after-halt for an after-fire to
797
+ // attach to. Reject any truthy assignment (true OR list) at write time
798
+ // so misuse surfaces immediately rather than silently no-op'ing.
799
+ if (this.isHalt && fieldName === 'after') {
800
+ throw new Error('haltState.debug.after is not supported: halt is terminal, so there is '
801
+ + 'no iteration-after-halt for an after-fire to anchor on. Use '
802
+ + '{ before: true } to pause on halt entry.');
803
+ }
804
+ if (filter === true)
805
+ return;
806
+ // haltState has no own transitions; symbol-list filters on `before` are
807
+ // silent no-ops at the engine level (spec §8.6), so accept any list shape.
797
808
  if (this.isHalt)
798
809
  return;
799
810
  for (const sym of filter) {
@@ -1035,22 +1046,21 @@ class TuringMachine {
1035
1046
  get tapeBlock() {
1036
1047
  return __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f");
1037
1048
  }
1038
- async run({ initialState, stepsLimit = 1e5, onStep, onDebugBreak, }) {
1049
+ async run({ initialState, stepsLimit = 1e5, onStep, onPause, debug = true, }) {
1039
1050
  const generator = this.runStepByStep({ initialState, stepsLimit });
1040
- let prevYield = null;
1041
1051
  for (const machineState of generator) {
1042
- // 'after' (from prev step) fire FIRST, with prev yield substituted as the source view.
1043
- if (machineState.debugBreak?.after && onDebugBreak && prevYield) {
1044
- await onDebugBreak({ ...prevYield, debugBreak: { after: true } });
1045
- }
1046
- // 'before' (current step) pass current machineState with only the before flag.
1047
- if (machineState.debugBreak?.before && onDebugBreak) {
1048
- await onDebugBreak({ ...machineState, debugBreak: { before: true } });
1052
+ // Per-iter lifecycle: before step after. All three operate on the
1053
+ // same yielded MachineState, so the consumer sees a coherent ordering
1054
+ // within each iteration without cross-tick coordination.
1055
+ if (debug && machineState.debugBreak?.before && onPause) {
1056
+ await onPause({ ...machineState, debugBreak: { before: true } });
1049
1057
  }
1050
1058
  if (onStep instanceof Function) {
1051
1059
  onStep(machineState);
1052
1060
  }
1053
- prevYield = machineState;
1061
+ if (debug && machineState.debugBreak?.after && onPause) {
1062
+ await onPause({ ...machineState, debugBreak: { after: true } });
1063
+ }
1054
1064
  }
1055
1065
  }
1056
1066
  *runStepByStep({ initialState, stepsLimit = 1e5 }) {
@@ -1064,7 +1074,6 @@ class TuringMachine {
1064
1074
  stack.push(state.overrodeHaltState);
1065
1075
  }
1066
1076
  let i = 0;
1067
- let pendingAfterFromPrev = false;
1068
1077
  while (!state.isHalt) {
1069
1078
  if (i === stepsLimit) {
1070
1079
  throw new Error('Long execution');
@@ -1074,8 +1083,12 @@ class TuringMachine {
1074
1083
  const command = state.getCommand(symbol);
1075
1084
  let nextState = state.getNextState(symbol).ref;
1076
1085
  try {
1086
+ // Both before and after refer to THIS iter (#119 / v6.0.0).
1087
+ // The halting iter's after-fire just rides along on the iter's
1088
+ // own yield — no post-loop drain needed.
1077
1089
  const beforeMatch = matchFilter(state.debug?.before, symbol)
1078
1090
  || (nextState.isHalt && nextState.debug?.before === true);
1091
+ const afterMatch = matchFilter(state.debug?.after, symbol);
1079
1092
  const nextStateForYield = nextState.isHalt && stack.length
1080
1093
  ? stack.slice(-1)[0]
1081
1094
  : nextState;
@@ -1099,17 +1112,15 @@ class TuringMachine {
1099
1112
  movements: command.tapesCommands.map((tapeCommand) => tapeCommand.movement),
1100
1113
  nextState: nextStateForYield,
1101
1114
  };
1102
- if (pendingAfterFromPrev || beforeMatch) {
1115
+ if (beforeMatch || afterMatch) {
1103
1116
  const dbg = {};
1104
- if (pendingAfterFromPrev)
1105
- dbg.after = true;
1106
1117
  if (beforeMatch)
1107
1118
  dbg.before = true;
1119
+ if (afterMatch)
1120
+ dbg.after = true;
1108
1121
  yielded.debugBreak = dbg;
1109
1122
  }
1110
1123
  yield yielded;
1111
- // Re-evaluate 'after' for THIS visit, to fire on the NEXT yield.
1112
- pendingAfterFromPrev = matchFilter(state.debug?.after, symbol);
1113
1124
  __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f").applyCommand(command, executionSymbol);
1114
1125
  if (nextState.isHalt && stack.length) {
1115
1126
  nextState = stack.pop();
package/dist/index.mjs CHANGED
@@ -788,10 +788,21 @@ class State {
788
788
  }
789
789
  /** @internal — invoked by DebugConfig setters via module-private symbol. */
790
790
  [(_State_id = new WeakMap(), _State_name = new WeakMap(), _State_overrodeHaltState = new WeakMap(), _State_symbolToDataMap = new WeakMap(), _State_debugRef = new WeakMap(), validateDebugFilter)](fieldName, filter) {
791
- if (filter === undefined || filter === true)
791
+ if (filter === undefined)
792
792
  return;
793
- // haltState has no own transitions; symbol-list filters on it are silent
794
- // no-ops at the engine level (spec §8.6), so accept any list shape here.
793
+ // #108 part 2: `.after` on haltState has no semantic anchor halt is
794
+ // terminal, so there is no iteration-after-halt for an after-fire to
795
+ // attach to. Reject any truthy assignment (true OR list) at write time
796
+ // so misuse surfaces immediately rather than silently no-op'ing.
797
+ if (this.isHalt && fieldName === 'after') {
798
+ throw new Error('haltState.debug.after is not supported: halt is terminal, so there is '
799
+ + 'no iteration-after-halt for an after-fire to anchor on. Use '
800
+ + '{ before: true } to pause on halt entry.');
801
+ }
802
+ if (filter === true)
803
+ return;
804
+ // haltState has no own transitions; symbol-list filters on `before` are
805
+ // silent no-ops at the engine level (spec §8.6), so accept any list shape.
795
806
  if (this.isHalt)
796
807
  return;
797
808
  for (const sym of filter) {
@@ -1033,22 +1044,21 @@ class TuringMachine {
1033
1044
  get tapeBlock() {
1034
1045
  return __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f");
1035
1046
  }
1036
- async run({ initialState, stepsLimit = 1e5, onStep, onDebugBreak, }) {
1047
+ async run({ initialState, stepsLimit = 1e5, onStep, onPause, debug = true, }) {
1037
1048
  const generator = this.runStepByStep({ initialState, stepsLimit });
1038
- let prevYield = null;
1039
1049
  for (const machineState of generator) {
1040
- // 'after' (from prev step) fire FIRST, with prev yield substituted as the source view.
1041
- if (machineState.debugBreak?.after && onDebugBreak && prevYield) {
1042
- await onDebugBreak({ ...prevYield, debugBreak: { after: true } });
1043
- }
1044
- // 'before' (current step) pass current machineState with only the before flag.
1045
- if (machineState.debugBreak?.before && onDebugBreak) {
1046
- await onDebugBreak({ ...machineState, debugBreak: { before: true } });
1050
+ // Per-iter lifecycle: before step after. All three operate on the
1051
+ // same yielded MachineState, so the consumer sees a coherent ordering
1052
+ // within each iteration without cross-tick coordination.
1053
+ if (debug && machineState.debugBreak?.before && onPause) {
1054
+ await onPause({ ...machineState, debugBreak: { before: true } });
1047
1055
  }
1048
1056
  if (onStep instanceof Function) {
1049
1057
  onStep(machineState);
1050
1058
  }
1051
- prevYield = machineState;
1059
+ if (debug && machineState.debugBreak?.after && onPause) {
1060
+ await onPause({ ...machineState, debugBreak: { after: true } });
1061
+ }
1052
1062
  }
1053
1063
  }
1054
1064
  *runStepByStep({ initialState, stepsLimit = 1e5 }) {
@@ -1062,7 +1072,6 @@ class TuringMachine {
1062
1072
  stack.push(state.overrodeHaltState);
1063
1073
  }
1064
1074
  let i = 0;
1065
- let pendingAfterFromPrev = false;
1066
1075
  while (!state.isHalt) {
1067
1076
  if (i === stepsLimit) {
1068
1077
  throw new Error('Long execution');
@@ -1072,8 +1081,12 @@ class TuringMachine {
1072
1081
  const command = state.getCommand(symbol);
1073
1082
  let nextState = state.getNextState(symbol).ref;
1074
1083
  try {
1084
+ // Both before and after refer to THIS iter (#119 / v6.0.0).
1085
+ // The halting iter's after-fire just rides along on the iter's
1086
+ // own yield — no post-loop drain needed.
1075
1087
  const beforeMatch = matchFilter(state.debug?.before, symbol)
1076
1088
  || (nextState.isHalt && nextState.debug?.before === true);
1089
+ const afterMatch = matchFilter(state.debug?.after, symbol);
1077
1090
  const nextStateForYield = nextState.isHalt && stack.length
1078
1091
  ? stack.slice(-1)[0]
1079
1092
  : nextState;
@@ -1097,17 +1110,15 @@ class TuringMachine {
1097
1110
  movements: command.tapesCommands.map((tapeCommand) => tapeCommand.movement),
1098
1111
  nextState: nextStateForYield,
1099
1112
  };
1100
- if (pendingAfterFromPrev || beforeMatch) {
1113
+ if (beforeMatch || afterMatch) {
1101
1114
  const dbg = {};
1102
- if (pendingAfterFromPrev)
1103
- dbg.after = true;
1104
1115
  if (beforeMatch)
1105
1116
  dbg.before = true;
1117
+ if (afterMatch)
1118
+ dbg.after = true;
1106
1119
  yielded.debugBreak = dbg;
1107
1120
  }
1108
1121
  yield yielded;
1109
- // Re-evaluate 'after' for THIS visit, to fire on the NEXT yield.
1110
- pendingAfterFromPrev = matchFilter(state.debug?.after, symbol);
1111
1122
  __classPrivateFieldGet(this, _TuringMachine_tapeBlock, "f").applyCommand(command, executionSymbol);
1112
1123
  if (nextState.isHalt && stack.length) {
1113
1124
  nextState = stack.pop();
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@turing-machine-js/machine",
3
- "version": "5.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "A convenient Turing machine",
5
5
  "engines": {
6
6
  "npm": ">=7.0.0"
7
7
  },
8
- "author": "Ruslan Gilmullin <mellonis14@gmain.com>",
8
+ "author": "Ruslan Gilmullin <mellonis@yandex.ru>",
9
9
  "homepage": "https://github.com/mellonis/turing-machine-js#readme",
10
10
  "license": "GPL-3.0-or-later",
11
11
  "publishConfig": {
@@ -34,5 +34,5 @@
34
34
  "default": "./dist/index.mjs"
35
35
  }
36
36
  },
37
- "gitHead": "ccf9a4743965c2a4ea6340ce1de8f6596e094251"
37
+ "gitHead": "d7ebca4e8379a548e3ca97eb83e96387de2cda32"
38
38
  }