@turing-machine-js/visuals 7.0.0-alpha.6

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +674 -0
  3. package/README.md +21 -0
  4. package/dist/applyHighlight.d.ts +42 -0
  5. package/dist/applyHighlight.js +191 -0
  6. package/dist/format.d.ts +22 -0
  7. package/dist/format.js +46 -0
  8. package/dist/graphIndexes.d.ts +31 -0
  9. package/dist/graphIndexes.js +58 -0
  10. package/dist/graphUtils.d.ts +37 -0
  11. package/dist/graphUtils.js +76 -0
  12. package/dist/highlightOps.d.ts +81 -0
  13. package/dist/highlightOps.js +36 -0
  14. package/dist/index.cjs +514 -0
  15. package/dist/index.d.ts +9 -0
  16. package/dist/index.js +6 -0
  17. package/dist/index.mjs +503 -0
  18. package/dist/recordSnippet.d.ts +35 -0
  19. package/dist/recordSnippet.js +92 -0
  20. package/dist/types.d.ts +92 -0
  21. package/dist/types.js +1 -0
  22. package/docs/graph-highlight-and-breakpoints.md +272 -0
  23. package/package.json +46 -0
  24. package/src/applyHighlight.spec.ts +331 -0
  25. package/src/applyHighlight.ts +217 -0
  26. package/src/fixtures/graphs/post-walk-mark.json +108 -0
  27. package/src/fixtures/graphs/turing-callable-subtree.json +108 -0
  28. package/src/fixtures/graphs/turing-copy-two-tapes.json +87 -0
  29. package/src/fixtures/graphs/turing-replace-b.json +72 -0
  30. package/src/format.spec.ts +100 -0
  31. package/src/format.ts +51 -0
  32. package/src/graphIndexes.ts +84 -0
  33. package/src/graphUtils.spec.ts +112 -0
  34. package/src/graphUtils.ts +74 -0
  35. package/src/highlightOps.ts +94 -0
  36. package/src/index.ts +10 -0
  37. package/src/recordSnippet.spec.ts +275 -0
  38. package/src/recordSnippet.ts +141 -0
  39. package/src/types.ts +96 -0
  40. package/tsconfig.build.json +11 -0
  41. package/tsconfig.build.tsbuildinfo +1 -0
  42. package/tsconfig.json +10 -0
package/dist/index.mjs ADDED
@@ -0,0 +1,503 @@
1
+ import { movements, symbolCommands, haltState } from '@turing-machine-js/machine';
2
+
3
+ /**
4
+ * Contract between the pure highlight logic (`applyHighlight`,
5
+ * `applyIndicator`) and any consumer that actually renders the graph
6
+ * (Svelte component, vanilla embed, server-side snapshot, etc.).
7
+ *
8
+ * The pure functions decide *what* should happen (which node gets a class,
9
+ * which edge lights up, where to pulse); the consumer's `HighlightOps`
10
+ * implementation decides *how* (DOM mutation, recording for tests, etc.).
11
+ *
12
+ * See `docs/graph-highlight-and-breakpoints.md` for the rules each
13
+ * implementation must respect.
14
+ */
15
+ /**
16
+ * Build a recording `HighlightOps` + `IndicatorOps` pair plus the shared
17
+ * `record` array of calls in invocation order. Used by tests to assert
18
+ * what the pure logic would have done without running a real DOM.
19
+ *
20
+ * Snapshot-friendly: the record contains only plain JSON-serializable
21
+ * values (no DOM nodes, no function refs).
22
+ */
23
+ function recordingOps() {
24
+ const record = [];
25
+ return {
26
+ record,
27
+ highlight: {
28
+ addNodeClass(id, cls) { record.push({ op: 'addNodeClass', id, cls }); },
29
+ highlightEdge(fromKey, toKey) { record.push({ op: 'highlightEdge', fromKey, toKey }); },
30
+ markFrameActive(frameId) { record.push({ op: 'markFrameActive', frameId }); },
31
+ pulse(id) { record.push({ op: 'pulse', id }); },
32
+ scrollIntoView(id) { record.push({ op: 'scrollIntoView', id }); },
33
+ },
34
+ indicator: {
35
+ setBreakpoint(id, on) { record.push({ op: 'setBreakpoint', id, on }); },
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Normalize an engine `GraphNode.id` to its canonical representative for
42
+ * breakpoint-class lookups (machines-demo#37). Wrappers produced by
43
+ * `State.withOverriddenHaltState` share `#debugRef` with their bare state
44
+ * engine-side (turing-machine-js v7 `State.ts`: `state.#debugRef =
45
+ * bare.#debugRef`), so they form a single breakpoint from the user's POV.
46
+ * This collapses any wrapper id to its bare's id; non-wrapper ids return
47
+ * self. Used so the demo can store ONE canonical id per equivalence class
48
+ * in its breakpoint set, and expand to all class members for indicator
49
+ * rendering — keeping the worker-side toggle count to one per class
50
+ * (multiple toggles on the shared ref would double-flip).
51
+ */
52
+ function bareIdOf(id, graph) {
53
+ if (!graph)
54
+ return id;
55
+ // Halt markers (negative ids, one per frame) are visualization sentinels;
56
+ // at runtime they all collapse to the haltState singleton (id 0). For
57
+ // breakpoint purposes they're a single class — setting BP on any
58
+ // halt-related node sets it on the global haltState.
59
+ if (id < 0)
60
+ return 0;
61
+ const node = graph.nodes[id];
62
+ if (node && node.isWrapper && node.bareStateId !== null) {
63
+ return node.bareStateId;
64
+ }
65
+ return id;
66
+ }
67
+ /**
68
+ * Asymmetric expansion for the highlight effect (machines-demo#37).
69
+ * Wrapper → `[wrapper, bare]` (the wrapper-entry pause is visually joined
70
+ * to its bare, since the user thinks of them as one call site).
71
+ * Bare → `[bare]` only (when the engine is genuinely on the bare — e.g. a
72
+ * loop iter — the wrapper is not the "active" state and shouldn't get the
73
+ * strong highlight).
74
+ * Non-wrapper / non-bare ids return `[id]`.
75
+ */
76
+ function highlightExpand(id, graph) {
77
+ if (!graph)
78
+ return [id];
79
+ const node = graph.nodes[id];
80
+ if (node?.isWrapper && node.bareStateId !== null) {
81
+ return [id, node.bareStateId];
82
+ }
83
+ return [id];
84
+ }
85
+ /**
86
+ * All GraphNode ids in the same breakpoint equivalence class as `id`.
87
+ * Symmetric — gives consumers the full list of nodes that share an engine
88
+ * breakpoint, regardless of which class member is the input. Used by the
89
+ * context-menu's "Shared with" info line so the user can see at a glance
90
+ * which other nodes flip together.
91
+ *
92
+ * Halt class (canonical id 0): the halt singleton + every halt marker in
93
+ * the graph. Wrapper/bare class: the bare + every wrapper pointing at it.
94
+ * Singleton classes (regular states, idle sentinel proxies) return just
95
+ * the input id.
96
+ */
97
+ function equivalentIds(id, graph) {
98
+ if (!graph)
99
+ return [id];
100
+ const canonical = bareIdOf(id, graph);
101
+ if (canonical === 0) {
102
+ const ids = [0];
103
+ for (const node of Object.values(graph.nodes)) {
104
+ if (node.isHaltMarker)
105
+ ids.push(node.id);
106
+ }
107
+ return ids;
108
+ }
109
+ const result = new Set([canonical]);
110
+ for (const node of Object.values(graph.nodes)) {
111
+ if (node.isWrapper && node.bareStateId === canonical)
112
+ result.add(node.id);
113
+ }
114
+ return [...result];
115
+ }
116
+
117
+ /**
118
+ * Walk the engine graph once and build all derived lookups. Cheap;
119
+ * intended to run on every Build (graph identity changes per build).
120
+ */
121
+ function indexGraph(graph) {
122
+ const nodeFrameMap = new Map();
123
+ const frameWrappersMap = new Map();
124
+ const frameLabelToId = new Map();
125
+ if (!graph)
126
+ return { nodeFrameMap, frameWrappersMap, frameLabelToId };
127
+ for (const node of Object.values(graph.nodes)) {
128
+ if (node.frameId !== null)
129
+ nodeFrameMap.set(node.id, node.frameId);
130
+ }
131
+ // For each wrapper, append to its bare's frame entry. Multiple wrappers
132
+ // can share the same bare with different overrides; we record them all
133
+ // so the return-chain passes can highlight every candidate.
134
+ for (const node of Object.values(graph.nodes)) {
135
+ if (!node.isWrapper || node.bareStateId === null)
136
+ continue;
137
+ const bare = graph.nodes[node.bareStateId];
138
+ if (!bare || bare.frameId === null)
139
+ continue;
140
+ const entry = { wrapperId: node.id, overrideId: node.overriddenHaltStateId };
141
+ const arr = frameWrappersMap.get(bare.frameId);
142
+ if (arr)
143
+ arr.push(entry);
144
+ else
145
+ frameWrappersMap.set(bare.frameId, [entry]);
146
+ }
147
+ // Cluster label reconstruction: mirrors the engine's `toMermaid` emit
148
+ // (`callable subtree of NAME` for single-bare frames, `callable scope:
149
+ // A ∪ B ∪ …` for union frames; bare names sorted by id). Consumers
150
+ // need this to map mermaid's rendered cluster (whose own SVG id is the
151
+ // useless literal `[object Object]`) back to a frameId.
152
+ const bareIds = new Set();
153
+ for (const n of Object.values(graph.nodes)) {
154
+ if (n.isWrapper && n.bareStateId !== null)
155
+ bareIds.add(n.bareStateId);
156
+ }
157
+ const frameToBareNames = new Map();
158
+ for (const n of Object.values(graph.nodes).sort((a, b) => a.id - b.id)) {
159
+ if (n.isWrapper || n.isHaltMarker || n.frameId === null)
160
+ continue;
161
+ if (!bareIds.has(n.id))
162
+ continue;
163
+ const arr = frameToBareNames.get(n.frameId) ?? [];
164
+ arr.push(n.name);
165
+ frameToBareNames.set(n.frameId, arr);
166
+ }
167
+ for (const [frameId, names] of frameToBareNames) {
168
+ const label = names.length > 1
169
+ ? `callable scope: ${names.join(' ∪ ')}`
170
+ : `callable subtree of ${names[0] ?? frameId}`;
171
+ frameLabelToId.set(label, frameId);
172
+ }
173
+ return { nodeFrameMap, frameWrappersMap, frameLabelToId };
174
+ }
175
+
176
+ /**
177
+ * Pure highlight-rule evaluator. Given the current `highlight` (from
178
+ * `MachineView`'s `$derived`), the engine `graph`, derived `indexes`,
179
+ * and the previous strong-id (for pause-revisit pulse detection), emit
180
+ * a sequence of `ops` calls describing the resulting visual state.
181
+ *
182
+ * Strictly additive — the caller is expected to clear previously-applied
183
+ * highlight classes / edge marks / cluster activations BEFORE invoking
184
+ * this function. The function never reads back from the consumer.
185
+ *
186
+ * Returns the new prev-strong-id to thread into the next call. Pulse
187
+ * comparison uses the RAW strong id (not canonical), so wrapper-pause
188
+ * and bare-pause register as different positions and don't pulse each
189
+ * other. Updates only when `highlight.paused === true`; non-paused
190
+ * events (idle / RUNNING_AUTO ticks) leave it untouched. Null highlight
191
+ * resets it to null.
192
+ *
193
+ * See `docs/graph-highlight-and-breakpoints.md` for the 16 rules
194
+ * enumerated.
195
+ */
196
+ function applyHighlight(highlight, graph, indexes, prevStrongId, ops) {
197
+ if (!highlight || !graph) {
198
+ return { nextPrevStrongId: null };
199
+ }
200
+ // §5 Halt-target retargeting: real halt (id 0) reached from an in-frame
201
+ // state retargets to the frame's halt marker (id = -frameId), so the
202
+ // visible edge lands inside the cluster.
203
+ let toId = highlight.toId;
204
+ if (toId === 0 && typeof highlight.fromId === 'number') {
205
+ const fromFrameId = indexes.nodeFrameMap.get(highlight.fromId);
206
+ if (fromFrameId !== undefined)
207
+ toId = -fromFrameId;
208
+ }
209
+ // §2 Equivalence-class expansion (asymmetric, via highlightExpand):
210
+ // wrapper → [wrapper, bare] (joined visual pair for wrapper-entry pause)
211
+ // bare → [bare] (engine genuinely on the bare; no wrapper sync)
212
+ // From-side expansion only fires for positive numeric ids; the 'idle'
213
+ // sentinel is handled directly below. Halt markers / singleton fall
214
+ // through the direct-lookup branches.
215
+ const fromEqIds = typeof highlight.fromId === 'number'
216
+ ? highlightExpand(highlight.fromId, graph)
217
+ : [];
218
+ const toEqIds = toId !== null && toId > 0
219
+ ? highlightExpand(toId, graph)
220
+ : [];
221
+ // §3 Class application — from side.
222
+ if (highlight.fromId === 'idle') {
223
+ ops.addNodeClass('idle', 'mg-highlight-from');
224
+ if (highlight.strong === 'from')
225
+ ops.addNodeClass('idle', 'mg-highlight-strong');
226
+ }
227
+ for (const id of fromEqIds) {
228
+ ops.addNodeClass(id, 'mg-highlight-from');
229
+ if (highlight.strong === 'from')
230
+ ops.addNodeClass(id, 'mg-highlight-strong');
231
+ }
232
+ // §3 + §8 Class application — to side. Halt markers (toId < 0) and the
233
+ // real halt singleton (toId === 0; only possible when §5 didn't retarget)
234
+ // bypass the equivalence-class expansion via direct lookup.
235
+ if (toId !== null && toId <= 0) {
236
+ ops.addNodeClass(toId, 'mg-highlight-to');
237
+ if (highlight.strong === 'to')
238
+ ops.addNodeClass(toId, 'mg-highlight-strong');
239
+ }
240
+ for (const id of toEqIds) {
241
+ ops.addNodeClass(id, 'mg-highlight-to');
242
+ if (highlight.strong === 'to')
243
+ ops.addNodeClass(id, 'mg-highlight-strong');
244
+ }
245
+ // Edge highlight: the data-id token form mermaid emits.
246
+ const fromKey = highlight.fromId === 'idle' ? 'idle' : `s${highlight.fromId}`;
247
+ const toKey = toId === null ? null
248
+ : toId < 0 ? `c${-toId}` // halt marker
249
+ : `s${toId}`;
250
+ if (toKey !== null)
251
+ ops.highlightEdge(fromKey, toKey);
252
+ // §10 Wrapper-entry "call" edge: when to-side expanded to [wrapper, bare],
253
+ // light up the wrapper→bare connector so the joined pair has a visible link.
254
+ if (toEqIds.length > 1) {
255
+ const wrapperId = toEqIds.find((id) => graph.nodes[id]?.isWrapper);
256
+ const bareId = toEqIds.find((id) => !graph.nodes[id]?.isWrapper);
257
+ if (wrapperId !== undefined && bareId !== undefined) {
258
+ ops.highlightEdge(`s${wrapperId}`, `s${bareId}`);
259
+ }
260
+ }
261
+ // §6 Source return chain: just-fired transition landed on a frame's
262
+ // halt marker. Light up the post-pop trajectory before the next iter
263
+ // moves the strong node.
264
+ if (toId !== null && toId < 0) {
265
+ const frameId = -toId;
266
+ const wrappers = indexes.frameWrappersMap.get(frameId) ?? [];
267
+ for (const { wrapperId, overrideId } of wrappers) {
268
+ ops.highlightEdge(`w_${frameId}`, `s${wrapperId}`);
269
+ ops.addNodeClass(wrapperId, 'mg-highlight-to');
270
+ if (overrideId !== null) {
271
+ ops.highlightEdge(`s${wrapperId}`, `s${overrideId}`);
272
+ ops.addNodeClass(overrideId, 'mg-highlight-to');
273
+ }
274
+ }
275
+ }
276
+ // §7 Destination return chain: paused at a positive toId that's some
277
+ // wrapper W's override AND fromId is in W's frame — the engine just
278
+ // popped. The straight bare→override edge doesn't exist in the graph;
279
+ // light up the actual visible path bare → halt-marker → return →
280
+ // wrapper → override, plus the frame cluster.
281
+ if (typeof highlight.fromId === 'number' && toId !== null && toId > 0) {
282
+ const fromFrameId = indexes.nodeFrameMap.get(highlight.fromId);
283
+ if (fromFrameId !== undefined) {
284
+ const wrappers = indexes.frameWrappersMap.get(fromFrameId) ?? [];
285
+ const matching = wrappers.filter((w) => w.overrideId === toId);
286
+ if (matching.length > 0) {
287
+ ops.addNodeClass(-fromFrameId, 'mg-highlight-to');
288
+ ops.highlightEdge(`s${highlight.fromId}`, `c${fromFrameId}`);
289
+ for (const { wrapperId } of matching) {
290
+ ops.highlightEdge(`w_${fromFrameId}`, `s${wrapperId}`);
291
+ ops.addNodeClass(wrapperId, 'mg-highlight-to');
292
+ ops.highlightEdge(`s${wrapperId}`, `s${toId}`);
293
+ }
294
+ ops.markFrameActive(fromFrameId);
295
+ }
296
+ }
297
+ }
298
+ // §9 Frame-active for the strong node. Wrappers are outside any frame
299
+ // so canonicalize via bareIdOf so the wrapper-entry pause still lights
300
+ // up the bare's enclosing cluster.
301
+ const strongId = highlight.strong === 'from' ? highlight.fromId : highlight.toId;
302
+ const strongIdCanonical = typeof strongId === 'number'
303
+ ? bareIdOf(strongId, graph)
304
+ : strongId;
305
+ if (typeof strongIdCanonical === 'number') {
306
+ const frameId = indexes.nodeFrameMap.get(strongIdCanonical);
307
+ if (frameId !== undefined)
308
+ ops.markFrameActive(frameId);
309
+ }
310
+ // §11 Pulse on same-state revisit. Uses RAW strongId — wrapper-pause
311
+ // and bare-pause are visually distinct positions even though they
312
+ // share #debugRef; pausing at wrapper then continuing into bare must
313
+ // not pulse. Idles never pulse and never update prevStrongId.
314
+ if (highlight.paused
315
+ && strongId !== null
316
+ && strongId === prevStrongId
317
+ && strongId !== undefined) {
318
+ ops.pulse(strongId);
319
+ }
320
+ // Scroll-into-view target: for wrapper-entry pauses, scroll to the
321
+ // BARE (not the wrapper) so the focus matches the displayed state
322
+ // name. The worker's `resolveDisplayName` returns the bare's name
323
+ // for wrapper iters (so the log reads "paused at walkToBlank ..."),
324
+ // but `toId` is the wrapper's id and `highlightExpand` lights up
325
+ // both nodes as strong. Without this canonicalization the scroll
326
+ // lands on the wrapper while the log line and user's mental focus
327
+ // are on the bare. Halt-related ids (≤ 0) are scrolled to as-is —
328
+ // `bareIdOf` would collapse them all to the halt singleton, which
329
+ // is structurally separate from the in-frame halt marker the user
330
+ // is paused near.
331
+ if (strongId !== null) {
332
+ let scrollTarget = strongId;
333
+ if (typeof strongId === 'number' && strongId > 0) {
334
+ const node = graph.nodes[strongId];
335
+ if (node?.isWrapper && node.bareStateId !== null) {
336
+ scrollTarget = node.bareStateId;
337
+ }
338
+ }
339
+ ops.scrollIntoView(scrollTarget);
340
+ }
341
+ const nextPrevStrongId = highlight.paused ? strongId : prevStrongId;
342
+ return { nextPrevStrongId };
343
+ }
344
+ /**
345
+ * Pure breakpoint-indicator rule evaluator. For each cached node key,
346
+ * emit `ops.setBreakpoint(key, on)` reflecting whether the node's
347
+ * canonical bare-id is in the `breakpoints` set.
348
+ *
349
+ * The 'idle' string sentinel never carries a breakpoint. All numeric
350
+ * keys are valid BP-class members:
351
+ * - positive id → regular state; canonical via bareIdOf (wrappers
352
+ * collapse to bare)
353
+ * - 0 → haltState singleton (engine-wide; canonical = 0)
354
+ * - negative id → halt marker (per-frame visualization sentinel;
355
+ * bareIdOf maps to 0 — same class as the singleton)
356
+ * Consumers pass their iterable of cached node keys (e.g. `nodeCache.keys()`).
357
+ */
358
+ function applyIndicator(breakpoints, graph, nodeIds, ops) {
359
+ for (const key of nodeIds) {
360
+ const on = typeof key === 'number'
361
+ && graph !== null
362
+ && breakpoints.has(bareIdOf(key, graph));
363
+ ops.setBreakpoint(key, on);
364
+ }
365
+ }
366
+
367
+ const MOVEMENT_LETTER = new Map([
368
+ [movements.left, 'L'],
369
+ [movements.right, 'R'],
370
+ [movements.stay, 'S'],
371
+ ]);
372
+ /**
373
+ * Render a single tape command in `WRITE/MOVE` form.
374
+ * - Write: `'X'` (literal symbol) | `K` (keep) | `E` (erase = write blank).
375
+ * - Move: `L` / `R` / `S` from `movements.*`.
376
+ *
377
+ * Matches the engine's edge-label vocabulary so formatted commands line up
378
+ * with the write/move cells in `toMermaid`-emitted edge labels.
379
+ */
380
+ function formatCommand(tapeCommand) {
381
+ let write;
382
+ if (tapeCommand.symbol === symbolCommands.keep) {
383
+ write = 'K';
384
+ }
385
+ else if (tapeCommand.symbol === symbolCommands.erase) {
386
+ write = 'E';
387
+ }
388
+ else {
389
+ write = `'${tapeCommand.symbol}'`;
390
+ }
391
+ const move = MOVEMENT_LETTER.get(tapeCommand.movement) ?? '?';
392
+ return `${write}/${move}`;
393
+ }
394
+ /**
395
+ * Render one step's edge-label notation: `[reads] → [writes]/[moves]`.
396
+ * Each role is wrapped in a single `[…]`; multi-tape entries are
397
+ * comma-separated inside the brackets.
398
+ *
399
+ * Matches the engine's `toMermaid` emit so logged steps line up with
400
+ * graph edge labels. Note: `nextSymbols` in `MachineState` is already
401
+ * resolved (keep → current symbol, erase → blank) — `K` is inferred
402
+ * by comparing `nextSymbols[i] === currentSymbols[i]`.
403
+ */
404
+ function formatStep(m) {
405
+ const reads = m.currentSymbols.map((s) => `'${s}'`).join(',');
406
+ const writes = m.nextSymbols
407
+ .map((s, i) => (s === m.currentSymbols[i] ? 'K' : `'${s}'`))
408
+ .join(',');
409
+ const moves = m.movements.map((mv) => MOVEMENT_LETTER.get(mv) ?? '?').join(',');
410
+ return `[${reads}] → [${writes}]/[${moves}]`;
411
+ }
412
+
413
+ const DEFAULT_MAX_STEPS = 1000;
414
+ function snapshotTapes(machine) {
415
+ return machine.tapeBlock.tapes.map((t) => ({
416
+ symbols: [...t.symbols],
417
+ position: t.position,
418
+ }));
419
+ }
420
+ function deriveCommands(m) {
421
+ return m.movements.map((mv, i) => ({
422
+ movement: MOVEMENT_LETTER.get(mv) ?? 'S',
423
+ read: m.currentSymbols[i],
424
+ // nextSymbols is already resolved (keep → current symbol, erase → blank);
425
+ // when write === read the command was a keep (UI suppresses the flash).
426
+ write: m.nextSymbols[i],
427
+ }));
428
+ }
429
+ function deriveHighlight(m, graph) {
430
+ return {
431
+ fromId: bareIdOf(m.state.id, graph),
432
+ toId: m.nextState === haltState ? 0 : m.nextState.id,
433
+ strong: 'from',
434
+ paused: false,
435
+ };
436
+ }
437
+ /**
438
+ * Record a full machine run into a `Snippet` — a self-contained playback
439
+ * artifact suitable for embeds, articles, or landing-page panels.
440
+ *
441
+ * The returned snippet contains one frame per iteration plus a frame-0
442
+ * initial-state snapshot. Recording stops when the machine halts or when
443
+ * `maxSteps` iterations have been consumed (default 1000).
444
+ *
445
+ * Tape-timing note: `runStepByStep` yields BEFORE applying its command
446
+ * (the command is applied after the yield resumes). The recorder uses a
447
+ * one-step-delayed snapshot so each frame's `tape` reflects the
448
+ * post-command state for that frame's iter.
449
+ */
450
+ function recordSnippet(opts) {
451
+ const { machine, initialState, graph, alphabets, name, maxSteps = DEFAULT_MAX_STEPS, log, } = opts;
452
+ const frames = [
453
+ { step: 0, tape: snapshotTapes(machine), highlight: null },
454
+ ];
455
+ // pending holds everything for the frame whose tape snapshot is not yet
456
+ // available (because applyCommand hasn't fired yet). It is flushed at the
457
+ // start of the NEXT iter (when the tape reflects the previous command) and
458
+ // after the loop (when the final command has been applied).
459
+ let pending = null;
460
+ let prev = null;
461
+ try {
462
+ for (const m of machine.runStepByStep({ initialState, stepsLimit: maxSteps })) {
463
+ // At this point applyCommand for the PREVIOUS iter has already run
464
+ // (the generator called applyCommand before looping back to yield).
465
+ // So the current tape state = post-command of the previous iter.
466
+ if (pending !== null) {
467
+ frames.push({ ...pending, tape: snapshotTapes(machine) });
468
+ }
469
+ const commands = deriveCommands(m);
470
+ const highlight = deriveHighlight(m, graph);
471
+ const logLine = log ? log(m, prev) : undefined;
472
+ pending = {
473
+ step: m.step,
474
+ commands,
475
+ highlight,
476
+ ...(logLine !== undefined ? { log: logLine } : {}),
477
+ };
478
+ prev = m;
479
+ }
480
+ }
481
+ catch (e) {
482
+ // runStepByStep throws 'Long execution' when stepsLimit is hit.
483
+ // At that point applyCommand for the last yielded iter has run, so the
484
+ // tape is in the post-command state we want for the pending frame.
485
+ if (!(e instanceof Error) || e.message !== 'Long execution') {
486
+ throw e;
487
+ }
488
+ }
489
+ // Flush the last pending frame. After the loop (or after the catch), the
490
+ // tape reflects the post-command state of the final yielded iter.
491
+ if (pending !== null) {
492
+ frames.push({ ...pending, tape: snapshotTapes(machine) });
493
+ }
494
+ return {
495
+ version: 1,
496
+ ...(name !== undefined ? { name } : {}),
497
+ graph,
498
+ alphabets,
499
+ frames,
500
+ };
501
+ }
502
+
503
+ export { applyHighlight, applyIndicator, bareIdOf, equivalentIds, formatCommand, formatStep, highlightExpand, indexGraph, recordSnippet, recordingOps };
@@ -0,0 +1,35 @@
1
+ import { type Graph, type MachineState, type State, type TuringMachine } from '@turing-machine-js/machine';
2
+ import type { Snippet } from './types';
3
+ export type RecordSnippetOptions = {
4
+ machine: TuringMachine;
5
+ initialState: State;
6
+ graph: Graph;
7
+ alphabets: string[][];
8
+ name?: string;
9
+ /**
10
+ * Maximum number of iteration steps to record. Defaults to 1000.
11
+ * If the machine hasn't halted after `maxSteps` iters, recording stops
12
+ * and the snippet contains `maxSteps + 1` frames (frame 0 plus one per iter).
13
+ */
14
+ maxSteps?: number;
15
+ /**
16
+ * Optional per-frame log formatter. Called with the current and previous
17
+ * `MachineState`; return a string to attach as `frame.log`, or `undefined`
18
+ * to omit. Not called for frame 0 (initial state — no transition has fired).
19
+ */
20
+ log?: (m: MachineState, prev: MachineState | null) => string | undefined;
21
+ };
22
+ /**
23
+ * Record a full machine run into a `Snippet` — a self-contained playback
24
+ * artifact suitable for embeds, articles, or landing-page panels.
25
+ *
26
+ * The returned snippet contains one frame per iteration plus a frame-0
27
+ * initial-state snapshot. Recording stops when the machine halts or when
28
+ * `maxSteps` iterations have been consumed (default 1000).
29
+ *
30
+ * Tape-timing note: `runStepByStep` yields BEFORE applying its command
31
+ * (the command is applied after the yield resumes). The recorder uses a
32
+ * one-step-delayed snapshot so each frame's `tape` reflects the
33
+ * post-command state for that frame's iter.
34
+ */
35
+ export declare function recordSnippet(opts: RecordSnippetOptions): Snippet;
@@ -0,0 +1,92 @@
1
+ import { haltState, } from '@turing-machine-js/machine';
2
+ import { MOVEMENT_LETTER } from './format';
3
+ import { bareIdOf } from './graphUtils';
4
+ const DEFAULT_MAX_STEPS = 1000;
5
+ function snapshotTapes(machine) {
6
+ return machine.tapeBlock.tapes.map((t) => ({
7
+ symbols: [...t.symbols],
8
+ position: t.position,
9
+ }));
10
+ }
11
+ function deriveCommands(m) {
12
+ return m.movements.map((mv, i) => ({
13
+ movement: MOVEMENT_LETTER.get(mv) ?? 'S',
14
+ read: m.currentSymbols[i],
15
+ // nextSymbols is already resolved (keep → current symbol, erase → blank);
16
+ // when write === read the command was a keep (UI suppresses the flash).
17
+ write: m.nextSymbols[i],
18
+ }));
19
+ }
20
+ function deriveHighlight(m, graph) {
21
+ return {
22
+ fromId: bareIdOf(m.state.id, graph),
23
+ toId: m.nextState === haltState ? 0 : m.nextState.id,
24
+ strong: 'from',
25
+ paused: false,
26
+ };
27
+ }
28
+ /**
29
+ * Record a full machine run into a `Snippet` — a self-contained playback
30
+ * artifact suitable for embeds, articles, or landing-page panels.
31
+ *
32
+ * The returned snippet contains one frame per iteration plus a frame-0
33
+ * initial-state snapshot. Recording stops when the machine halts or when
34
+ * `maxSteps` iterations have been consumed (default 1000).
35
+ *
36
+ * Tape-timing note: `runStepByStep` yields BEFORE applying its command
37
+ * (the command is applied after the yield resumes). The recorder uses a
38
+ * one-step-delayed snapshot so each frame's `tape` reflects the
39
+ * post-command state for that frame's iter.
40
+ */
41
+ export function recordSnippet(opts) {
42
+ const { machine, initialState, graph, alphabets, name, maxSteps = DEFAULT_MAX_STEPS, log, } = opts;
43
+ const frames = [
44
+ { step: 0, tape: snapshotTapes(machine), highlight: null },
45
+ ];
46
+ // pending holds everything for the frame whose tape snapshot is not yet
47
+ // available (because applyCommand hasn't fired yet). It is flushed at the
48
+ // start of the NEXT iter (when the tape reflects the previous command) and
49
+ // after the loop (when the final command has been applied).
50
+ let pending = null;
51
+ let prev = null;
52
+ try {
53
+ for (const m of machine.runStepByStep({ initialState, stepsLimit: maxSteps })) {
54
+ // At this point applyCommand for the PREVIOUS iter has already run
55
+ // (the generator called applyCommand before looping back to yield).
56
+ // So the current tape state = post-command of the previous iter.
57
+ if (pending !== null) {
58
+ frames.push({ ...pending, tape: snapshotTapes(machine) });
59
+ }
60
+ const commands = deriveCommands(m);
61
+ const highlight = deriveHighlight(m, graph);
62
+ const logLine = log ? log(m, prev) : undefined;
63
+ pending = {
64
+ step: m.step,
65
+ commands,
66
+ highlight,
67
+ ...(logLine !== undefined ? { log: logLine } : {}),
68
+ };
69
+ prev = m;
70
+ }
71
+ }
72
+ catch (e) {
73
+ // runStepByStep throws 'Long execution' when stepsLimit is hit.
74
+ // At that point applyCommand for the last yielded iter has run, so the
75
+ // tape is in the post-command state we want for the pending frame.
76
+ if (!(e instanceof Error) || e.message !== 'Long execution') {
77
+ throw e;
78
+ }
79
+ }
80
+ // Flush the last pending frame. After the loop (or after the catch), the
81
+ // tape reflects the post-command state of the final yielded iter.
82
+ if (pending !== null) {
83
+ frames.push({ ...pending, tape: snapshotTapes(machine) });
84
+ }
85
+ return {
86
+ version: 1,
87
+ ...(name !== undefined ? { name } : {}),
88
+ graph,
89
+ alphabets,
90
+ frames,
91
+ };
92
+ }