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

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.
@@ -1,331 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { readFileSync } from 'node:fs';
3
- import { resolve, dirname } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { applyHighlight, applyIndicator } from './applyHighlight';
6
- import { indexGraph } from './graphIndexes';
7
- import { recordingOps, type RecordedOp } from './highlightOps';
8
- import type { GraphHighlight } from './types';
9
- import type { Graph } from '@turing-machine-js/machine';
10
-
11
- /**
12
- * Rule tests for `applyHighlight`. Section numbers mirror
13
- * `docs/graph-highlight-and-breakpoints.md` — any rule change must
14
- * update both the doc and the matching `describe` block here.
15
- *
16
- * Fixtures come from `tests/fixtures/graphs/`, which are committed
17
- * snapshots of `State.toGraph` output for each bundled example
18
- * (regenerable via `REGEN_FIXTURES=1 npm test`). Using real engine output
19
- * means these tests also catch any drift between engine emit and
20
- * rule expectations.
21
- *
22
- * Helpful fixture ids for `turing-callable-subtree`:
23
- * bare walkToBlank = 3 (frameId 3)
24
- * writeMarker (override) = 4
25
- * wrapper walkToBlank(writeMarker) = 5 (bareStateId 3, overriddenHaltStateId 4)
26
- * halt singleton = 0
27
- * frame halt marker = -3 (id = -frameId)
28
- */
29
-
30
- const __filename = fileURLToPath(import.meta.url);
31
- const __dirname = dirname(__filename);
32
- const FIXTURE_DIR = resolve(__dirname, './fixtures/graphs');
33
-
34
- function loadGraph(name: string): Graph {
35
- return JSON.parse(readFileSync(resolve(FIXTURE_DIR, `${name}.json`), 'utf-8'));
36
- }
37
-
38
- /** Run `applyHighlight` with a recording ops impl, return the ordered ops list. */
39
- function run(highlight: GraphHighlight | null, graph: Graph, prev: number | 'idle' | null = null): {
40
- ops: RecordedOp[];
41
- next: number | 'idle' | null;
42
- } {
43
- const indexes = indexGraph(graph);
44
- const { highlight: opsImpl, record } = recordingOps();
45
- const { nextPrevStrongId } = applyHighlight(highlight, graph, indexes, prev, opsImpl);
46
- return { ops: record, next: nextPrevStrongId };
47
- }
48
-
49
- describe('applyHighlight', () => {
50
- describe('null/empty highlight', () => {
51
- it('returns no-op with nextPrev=null when highlight is null', () => {
52
- const g = loadGraph('turing-callable-subtree');
53
- const { ops, next } = run(null, g, 3);
54
- expect(ops).toEqual([]);
55
- expect(next).toBeNull();
56
- });
57
- });
58
-
59
- describe('§5 halt-target retargeting', () => {
60
- it('rewrites toId=0 to -frameId when fromId is in a frame', () => {
61
- const g = loadGraph('turing-callable-subtree');
62
- // Bare walkToBlank (id 3, frameId 3) halts → engine reports toId=0,
63
- // should retarget to -3 (frame halt marker).
64
- const { ops } = run({ fromId: 3, toId: 0, strong: 'from', paused: false }, g);
65
- const classOps = ops.filter((o) => o.op === 'addNodeClass');
66
- // halt marker -3 gets highlight-to; halt singleton 0 does not.
67
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: -3, cls: 'mg-highlight-to' });
68
- expect(classOps).not.toContainEqual({ op: 'addNodeClass', id: 0, cls: 'mg-highlight-to' });
69
- });
70
-
71
- it('does NOT retarget when fromId is outside any frame', () => {
72
- const g = loadGraph('turing-callable-subtree');
73
- // writeMarker (id 4) is the wrapper's override-target; sits outside
74
- // the frame (frameId: null), so its halt→halt-singleton stays as id 0.
75
- const { ops } = run({ fromId: 4, toId: 0, strong: 'from', paused: false }, g);
76
- const classOps = ops.filter((o) => o.op === 'addNodeClass');
77
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 0, cls: 'mg-highlight-to' });
78
- });
79
- });
80
-
81
- describe('§2 + §3 wrapper/bare equivalence (asymmetric)', () => {
82
- it('wrapper-strong expands to [wrapper, bare] — both get highlight-to + strong', () => {
83
- const g = loadGraph('turing-callable-subtree');
84
- // Wrapper-entry pause: idle → wrapper(5), pauseBefore strong=to.
85
- const { ops } = run({ fromId: 'idle', toId: 5, strong: 'to', paused: true }, g);
86
- const classOps = ops.filter((o) => o.op === 'addNodeClass');
87
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 5, cls: 'mg-highlight-to' });
88
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 5, cls: 'mg-highlight-strong' });
89
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 3, cls: 'mg-highlight-to' });
90
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 3, cls: 'mg-highlight-strong' });
91
- });
92
-
93
- it('bare-strong stays bare only — does NOT light up wrapper', () => {
94
- const g = loadGraph('turing-callable-subtree');
95
- // Bare-loop pause: prev=bare(3), to=bare(3), pauseBefore strong=to.
96
- const { ops } = run({ fromId: 3, toId: 3, strong: 'to', paused: true }, g);
97
- const classOps = ops.filter((o) => o.op === 'addNodeClass');
98
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 3, cls: 'mg-highlight-to' });
99
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 3, cls: 'mg-highlight-strong' });
100
- // Wrapper 5 gets NOTHING from the to-side expansion.
101
- expect(classOps).not.toContainEqual({ op: 'addNodeClass', id: 5, cls: 'mg-highlight-to' });
102
- expect(classOps).not.toContainEqual({ op: 'addNodeClass', id: 5, cls: 'mg-highlight-strong' });
103
- });
104
- });
105
-
106
- describe('§6 source return chain (toId < 0)', () => {
107
- it('lights up return arrow + wrapper + override edge + override target', () => {
108
- const g = loadGraph('turing-callable-subtree');
109
- // Just-fired halt-bound transition: from=bare(3), to=halt-marker(-3),
110
- // strong=from (pause-after-style).
111
- const { ops } = run({ fromId: 3, toId: -3, strong: 'from', paused: true }, g);
112
- const classOps = ops.filter((o) => o.op === 'addNodeClass');
113
- const edgeOps = ops.filter((o) => o.op === 'highlightEdge');
114
- // Return arrow w_3 → wrapper(5).
115
- expect(edgeOps).toContainEqual({ op: 'highlightEdge', fromKey: 'w_3', toKey: 's5' });
116
- // Wrapper gets highlight-to.
117
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 5, cls: 'mg-highlight-to' });
118
- // Wrapper → override edge.
119
- expect(edgeOps).toContainEqual({ op: 'highlightEdge', fromKey: 's5', toKey: 's4' });
120
- // Override (writeMarker) gets highlight-to.
121
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 4, cls: 'mg-highlight-to' });
122
- });
123
- });
124
-
125
- describe('§7 destination return chain (paused at override after pop)', () => {
126
- it('lights the full path bare → halt-marker → return → wrapper → override + frame', () => {
127
- const g = loadGraph('turing-callable-subtree');
128
- // Paused-before writeMarker: prev=bare(3) (last yield was bare's halt),
129
- // current=writeMarker(4). strong=to. Bare is in frame 3; wrapper 5's
130
- // override is 4 → matches.
131
- const { ops } = run({ fromId: 3, toId: 4, strong: 'to', paused: true }, g);
132
- const classOps = ops.filter((o) => o.op === 'addNodeClass');
133
- const edgeOps = ops.filter((o) => o.op === 'highlightEdge');
134
- const frameOps = ops.filter((o) => o.op === 'markFrameActive');
135
-
136
- // Halt marker lit.
137
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: -3, cls: 'mg-highlight-to' });
138
- // bare → halt-marker edge.
139
- expect(edgeOps).toContainEqual({ op: 'highlightEdge', fromKey: 's3', toKey: 'c3' });
140
- // Return arrow.
141
- expect(edgeOps).toContainEqual({ op: 'highlightEdge', fromKey: 'w_3', toKey: 's5' });
142
- // Wrapper highlight-to.
143
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 5, cls: 'mg-highlight-to' });
144
- // Wrapper → override edge.
145
- expect(edgeOps).toContainEqual({ op: 'highlightEdge', fromKey: 's5', toKey: 's4' });
146
- // Frame active (override sits outside frame, but the chain fired so
147
- // we still mark the frame to show "we came out of THIS subtree").
148
- expect(frameOps).toContainEqual({ op: 'markFrameActive', frameId: 3 });
149
- });
150
-
151
- it('does NOT fire when fromId is not in any frame', () => {
152
- const g = loadGraph('turing-callable-subtree');
153
- // From idle → writeMarker(4): no frame on the from side, no chain.
154
- const { ops } = run({ fromId: 'idle', toId: 4, strong: 'to', paused: true }, g);
155
- const edgeOps = ops.filter((o) => o.op === 'highlightEdge');
156
- expect(edgeOps).not.toContainEqual({ op: 'highlightEdge', fromKey: 's3', toKey: 'c3' });
157
- expect(edgeOps).not.toContainEqual({ op: 'highlightEdge', fromKey: 'w_3', toKey: 's5' });
158
- });
159
- });
160
-
161
- describe('§8 halt singleton (toId === 0, no retarget)', () => {
162
- it('marks the halt singleton with highlight-to + strong', () => {
163
- const g = loadGraph('turing-callable-subtree');
164
- // writeMarker (id 4, no frame) → halt singleton (0). strong=from
165
- // (pause-after applying writeMarker's command).
166
- const { ops } = run({ fromId: 4, toId: 0, strong: 'from', paused: true }, g);
167
- const classOps = ops.filter((o) => o.op === 'addNodeClass');
168
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: 0, cls: 'mg-highlight-to' });
169
- // strong is 'from', so halt singleton is not strong.
170
- expect(classOps).not.toContainEqual({ op: 'addNodeClass', id: 0, cls: 'mg-highlight-strong' });
171
- });
172
- });
173
-
174
- describe('§9 frame-active', () => {
175
- it('marks the frame when canonical strong is inside it (wrapper case)', () => {
176
- const g = loadGraph('turing-callable-subtree');
177
- // Wrapper-entry pause: strong=wrapper(5). bareIdOf(5)=3. nodeFrameMap[3]=3.
178
- const { ops } = run({ fromId: 'idle', toId: 5, strong: 'to', paused: true }, g);
179
- const frameOps = ops.filter((o) => o.op === 'markFrameActive');
180
- expect(frameOps).toContainEqual({ op: 'markFrameActive', frameId: 3 });
181
- });
182
-
183
- it('marks the frame when bare is strong', () => {
184
- const g = loadGraph('turing-callable-subtree');
185
- const { ops } = run({ fromId: 3, toId: 3, strong: 'to', paused: true }, g);
186
- const frameOps = ops.filter((o) => o.op === 'markFrameActive');
187
- expect(frameOps).toContainEqual({ op: 'markFrameActive', frameId: 3 });
188
- });
189
-
190
- it('does NOT mark a frame when strong is outside any frame', () => {
191
- const g = loadGraph('turing-callable-subtree');
192
- // writeMarker (4, no frame) is strong.
193
- const { ops } = run({ fromId: 4, toId: 0, strong: 'from', paused: true }, g);
194
- const frameOps = ops.filter((o) => o.op === 'markFrameActive');
195
- expect(frameOps).toEqual([]);
196
- });
197
- });
198
-
199
- describe('§10 wrapper-entry call edge', () => {
200
- it('highlights the wrapper → bare call edge when to-side expanded to both', () => {
201
- const g = loadGraph('turing-callable-subtree');
202
- const { ops } = run({ fromId: 'idle', toId: 5, strong: 'to', paused: true }, g);
203
- const edgeOps = ops.filter((o) => o.op === 'highlightEdge');
204
- expect(edgeOps).toContainEqual({ op: 'highlightEdge', fromKey: 's5', toKey: 's3' });
205
- });
206
-
207
- it('does NOT highlight a call edge when to-side is bare only', () => {
208
- const g = loadGraph('turing-callable-subtree');
209
- const { ops } = run({ fromId: 3, toId: 3, strong: 'to', paused: true }, g);
210
- const edgeOps = ops.filter((o) => o.op === 'highlightEdge');
211
- // No s5→s3 call edge (bare doesn't expand to include wrapper).
212
- expect(edgeOps).not.toContainEqual({ op: 'highlightEdge', fromKey: 's5', toKey: 's3' });
213
- });
214
- });
215
-
216
- describe('§11 pause-revisit pulse (raw, not canonical)', () => {
217
- it('does NOT pulse on wrapper-pause → bare-pause transition', () => {
218
- const g = loadGraph('turing-callable-subtree');
219
- // First pause: wrapper-entry. prev=null → no pulse. nextPrev=5 (wrapper).
220
- const r1 = run({ fromId: 'idle', toId: 5, strong: 'to', paused: true }, g, null);
221
- expect(r1.ops.find((o) => o.op === 'pulse')).toBeUndefined();
222
- expect(r1.next).toBe(5);
223
- // Second pause: bare loop iter. prev=5 (wrapper), strong=3 (bare).
224
- // 5 !== 3 → NO pulse, even though they share #debugRef.
225
- const r2 = run({ fromId: 3, toId: 3, strong: 'to', paused: true }, g, r1.next);
226
- expect(r2.ops.find((o) => o.op === 'pulse')).toBeUndefined();
227
- expect(r2.next).toBe(3);
228
- });
229
-
230
- it('pulses on same-state revisit (bare → bare)', () => {
231
- const g = loadGraph('turing-callable-subtree');
232
- const r1 = run({ fromId: 3, toId: 3, strong: 'to', paused: true }, g, null);
233
- expect(r1.ops.find((o) => o.op === 'pulse')).toBeUndefined();
234
- const r2 = run({ fromId: 3, toId: 3, strong: 'to', paused: true }, g, r1.next);
235
- expect(r2.ops).toContainEqual({ op: 'pulse', id: 3 });
236
- });
237
-
238
- it('does NOT update prev on non-paused (idle / RUNNING_AUTO) events', () => {
239
- const g = loadGraph('turing-callable-subtree');
240
- const r = run({ fromId: 3, toId: 3, strong: 'from', paused: false }, g, 5);
241
- expect(r.next).toBe(5); // unchanged
242
- });
243
-
244
- it('resets prev to null on null highlight', () => {
245
- const g = loadGraph('turing-callable-subtree');
246
- const r = run(null, g, 3);
247
- expect(r.next).toBeNull();
248
- });
249
- });
250
-
251
- describe('scroll-into-view', () => {
252
- it('scrolls to the strong node', () => {
253
- const g = loadGraph('turing-callable-subtree');
254
- const { ops } = run({ fromId: 3, toId: 4, strong: 'to', paused: true }, g);
255
- expect(ops).toContainEqual({ op: 'scrollIntoView', id: 4 });
256
- });
257
-
258
- it('does not scroll when strong is null (no highlight)', () => {
259
- const g = loadGraph('turing-callable-subtree');
260
- const { ops } = run(null, g);
261
- expect(ops.find((o) => o.op === 'scrollIntoView')).toBeUndefined();
262
- });
263
- });
264
-
265
- describe('applyIndicator (§2 canonical breakpoint + §3 indicator class)', () => {
266
- it('marks every member of the equivalence class when canonical id is in set', () => {
267
- const g = loadGraph('turing-callable-subtree');
268
- const { indicator, record } = recordingOps();
269
- // BP set has the bare id (3). Both wrapper (5) and bare (3) should turn on.
270
- applyIndicator(new Set([3]), g, [3, 4, 5, -3, 0, 'idle'], indicator);
271
- const onIds = record
272
- .filter((r): r is Extract<RecordedOp, { op: 'setBreakpoint' }> => r.op === 'setBreakpoint' && r.on)
273
- .map((r) => r.id);
274
- expect(onIds).toContain(3);
275
- expect(onIds).toContain(5);
276
- // writeMarker (4), halt-marker (-3), halt (0), idle: never on.
277
- expect(onIds).not.toContain(4);
278
- expect(onIds).not.toContain(-3);
279
- expect(onIds).not.toContain(0);
280
- expect(onIds).not.toContain('idle');
281
- });
282
-
283
- it('clears every node when the set is empty', () => {
284
- const g = loadGraph('turing-callable-subtree');
285
- const { indicator, record } = recordingOps();
286
- applyIndicator(new Set(), g, [3, 4, 5, 'idle'], indicator);
287
- const onCount = record.filter((r) => r.op === 'setBreakpoint' && r.on).length;
288
- expect(onCount).toBe(0);
289
- // Every node still gets an explicit off call (idempotent clears).
290
- expect(record.length).toBe(4);
291
- });
292
-
293
- // machines-demo#37 — halt singleton (id 0) is a valid breakpoint
294
- // target (engine-wide haltState). The set stores canonical id 0;
295
- // both the halt singleton AND every halt marker (negative ids; one
296
- // per frame) collapse to that class via `bareIdOf(-N, g) === 0`.
297
- it('marks halt singleton AND every halt marker when 0 is in the set', () => {
298
- const g = loadGraph('turing-callable-subtree');
299
- const { indicator, record } = recordingOps();
300
- applyIndicator(new Set([0]), g, [3, 4, 5, -3, 0, 'idle'], indicator);
301
- const onIds = record
302
- .filter((r): r is Extract<RecordedOp, { op: 'setBreakpoint' }> => r.op === 'setBreakpoint' && r.on)
303
- .map((r) => r.id);
304
- expect(onIds).toContain(0); // halt singleton
305
- expect(onIds).toContain(-3); // halt marker for frame 3
306
- // Regular and wrapper / bare nodes: never on.
307
- expect(onIds).not.toContain(3);
308
- expect(onIds).not.toContain(4);
309
- expect(onIds).not.toContain(5);
310
- expect(onIds).not.toContain('idle');
311
- });
312
- });
313
-
314
- describe('regression: simple machines (no callable subtree)', () => {
315
- it('lights from + to + strong + edge for a plain RUNNING_AUTO tick', () => {
316
- const g = loadGraph('turing-replace-b');
317
- // Pick any two ids that have a transition between them.
318
- const nodes = Object.values(g.nodes).filter((n) => !n.isHalt && !n.isHaltMarker);
319
- const src = nodes.find((n) => n.transitions.length > 0)!;
320
- const transition = src.transitions[0];
321
- const dst = transition.nextStateId;
322
- const { ops } = run(
323
- { fromId: src.id, toId: dst, strong: 'from', paused: false },
324
- g,
325
- );
326
- const classOps = ops.filter((o) => o.op === 'addNodeClass');
327
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: src.id, cls: 'mg-highlight-from' });
328
- expect(classOps).toContainEqual({ op: 'addNodeClass', id: src.id, cls: 'mg-highlight-strong' });
329
- });
330
- });
331
- });
@@ -1,217 +0,0 @@
1
- import type { GraphHighlight } from './types';
2
- import type { Graph } from '@turing-machine-js/machine';
3
- import type { GraphIndexes } from './graphIndexes';
4
- import type { HighlightOps, IndicatorOps, NodeKey } from './highlightOps';
5
- import { bareIdOf, highlightExpand } from './graphUtils';
6
-
7
- /**
8
- * Pure highlight-rule evaluator. Given the current `highlight` (from
9
- * `MachineView`'s `$derived`), the engine `graph`, derived `indexes`,
10
- * and the previous strong-id (for pause-revisit pulse detection), emit
11
- * a sequence of `ops` calls describing the resulting visual state.
12
- *
13
- * Strictly additive — the caller is expected to clear previously-applied
14
- * highlight classes / edge marks / cluster activations BEFORE invoking
15
- * this function. The function never reads back from the consumer.
16
- *
17
- * Returns the new prev-strong-id to thread into the next call. Pulse
18
- * comparison uses the RAW strong id (not canonical), so wrapper-pause
19
- * and bare-pause register as different positions and don't pulse each
20
- * other. Updates only when `highlight.paused === true`; non-paused
21
- * events (idle / RUNNING_AUTO ticks) leave it untouched. Null highlight
22
- * resets it to null.
23
- *
24
- * See `docs/graph-highlight-and-breakpoints.md` for the 16 rules
25
- * enumerated.
26
- */
27
- export function applyHighlight(
28
- highlight: GraphHighlight | null,
29
- graph: Graph | null,
30
- indexes: GraphIndexes,
31
- prevStrongId: NodeKey | null,
32
- ops: HighlightOps,
33
- ): { nextPrevStrongId: NodeKey | null } {
34
- if (!highlight || !graph) {
35
- return { nextPrevStrongId: null };
36
- }
37
-
38
- // §5 Halt-target retargeting: real halt (id 0) reached from an in-frame
39
- // state retargets to the frame's halt marker (id = -frameId), so the
40
- // visible edge lands inside the cluster.
41
- let toId: number | null = highlight.toId;
42
- if (toId === 0 && typeof highlight.fromId === 'number') {
43
- const fromFrameId = indexes.nodeFrameMap.get(highlight.fromId);
44
- if (fromFrameId !== undefined) toId = -fromFrameId;
45
- }
46
-
47
- // §2 Equivalence-class expansion (asymmetric, via highlightExpand):
48
- // wrapper → [wrapper, bare] (joined visual pair for wrapper-entry pause)
49
- // bare → [bare] (engine genuinely on the bare; no wrapper sync)
50
- // From-side expansion only fires for positive numeric ids; the 'idle'
51
- // sentinel is handled directly below. Halt markers / singleton fall
52
- // through the direct-lookup branches.
53
- const fromEqIds = typeof highlight.fromId === 'number'
54
- ? highlightExpand(highlight.fromId, graph)
55
- : [];
56
- const toEqIds = toId !== null && toId > 0
57
- ? highlightExpand(toId, graph)
58
- : [];
59
-
60
- // §3 Class application — from side.
61
- if (highlight.fromId === 'idle') {
62
- ops.addNodeClass('idle', 'mg-highlight-from');
63
- if (highlight.strong === 'from') ops.addNodeClass('idle', 'mg-highlight-strong');
64
- }
65
- for (const id of fromEqIds) {
66
- ops.addNodeClass(id, 'mg-highlight-from');
67
- if (highlight.strong === 'from') ops.addNodeClass(id, 'mg-highlight-strong');
68
- }
69
-
70
- // §3 + §8 Class application — to side. Halt markers (toId < 0) and the
71
- // real halt singleton (toId === 0; only possible when §5 didn't retarget)
72
- // bypass the equivalence-class expansion via direct lookup.
73
- if (toId !== null && toId <= 0) {
74
- ops.addNodeClass(toId, 'mg-highlight-to');
75
- if (highlight.strong === 'to') ops.addNodeClass(toId, 'mg-highlight-strong');
76
- }
77
- for (const id of toEqIds) {
78
- ops.addNodeClass(id, 'mg-highlight-to');
79
- if (highlight.strong === 'to') ops.addNodeClass(id, 'mg-highlight-strong');
80
- }
81
-
82
- // Edge highlight: the data-id token form mermaid emits.
83
- const fromKey = highlight.fromId === 'idle' ? 'idle' : `s${highlight.fromId}`;
84
- const toKey =
85
- toId === null ? null
86
- : toId < 0 ? `c${-toId}` // halt marker
87
- : `s${toId}`;
88
- if (toKey !== null) ops.highlightEdge(fromKey, toKey);
89
-
90
- // §10 Wrapper-entry "call" edge: when to-side expanded to [wrapper, bare],
91
- // light up the wrapper→bare connector so the joined pair has a visible link.
92
- if (toEqIds.length > 1) {
93
- const wrapperId = toEqIds.find((id) => graph.nodes[id]?.isWrapper);
94
- const bareId = toEqIds.find((id) => !graph.nodes[id]?.isWrapper);
95
- if (wrapperId !== undefined && bareId !== undefined) {
96
- ops.highlightEdge(`s${wrapperId}`, `s${bareId}`);
97
- }
98
- }
99
-
100
- // §6 Source return chain: just-fired transition landed on a frame's
101
- // halt marker. Light up the post-pop trajectory before the next iter
102
- // moves the strong node.
103
- if (toId !== null && toId < 0) {
104
- const frameId = -toId;
105
- const wrappers = indexes.frameWrappersMap.get(frameId) ?? [];
106
- for (const { wrapperId, overrideId } of wrappers) {
107
- ops.highlightEdge(`w_${frameId}`, `s${wrapperId}`);
108
- ops.addNodeClass(wrapperId, 'mg-highlight-to');
109
- if (overrideId !== null) {
110
- ops.highlightEdge(`s${wrapperId}`, `s${overrideId}`);
111
- ops.addNodeClass(overrideId, 'mg-highlight-to');
112
- }
113
- }
114
- }
115
-
116
- // §7 Destination return chain: paused at a positive toId that's some
117
- // wrapper W's override AND fromId is in W's frame — the engine just
118
- // popped. The straight bare→override edge doesn't exist in the graph;
119
- // light up the actual visible path bare → halt-marker → return →
120
- // wrapper → override, plus the frame cluster.
121
- if (typeof highlight.fromId === 'number' && toId !== null && toId > 0) {
122
- const fromFrameId = indexes.nodeFrameMap.get(highlight.fromId);
123
- if (fromFrameId !== undefined) {
124
- const wrappers = indexes.frameWrappersMap.get(fromFrameId) ?? [];
125
- const matching = wrappers.filter((w) => w.overrideId === toId);
126
- if (matching.length > 0) {
127
- ops.addNodeClass(-fromFrameId, 'mg-highlight-to');
128
- ops.highlightEdge(`s${highlight.fromId}`, `c${fromFrameId}`);
129
- for (const { wrapperId } of matching) {
130
- ops.highlightEdge(`w_${fromFrameId}`, `s${wrapperId}`);
131
- ops.addNodeClass(wrapperId, 'mg-highlight-to');
132
- ops.highlightEdge(`s${wrapperId}`, `s${toId}`);
133
- }
134
- ops.markFrameActive(fromFrameId);
135
- }
136
- }
137
- }
138
-
139
- // §9 Frame-active for the strong node. Wrappers are outside any frame
140
- // so canonicalize via bareIdOf so the wrapper-entry pause still lights
141
- // up the bare's enclosing cluster.
142
- const strongId = highlight.strong === 'from' ? highlight.fromId : highlight.toId;
143
- const strongIdCanonical = typeof strongId === 'number'
144
- ? bareIdOf(strongId, graph)
145
- : strongId;
146
- if (typeof strongIdCanonical === 'number') {
147
- const frameId = indexes.nodeFrameMap.get(strongIdCanonical);
148
- if (frameId !== undefined) ops.markFrameActive(frameId);
149
- }
150
-
151
- // §11 Pulse on same-state revisit. Uses RAW strongId — wrapper-pause
152
- // and bare-pause are visually distinct positions even though they
153
- // share #debugRef; pausing at wrapper then continuing into bare must
154
- // not pulse. Idles never pulse and never update prevStrongId.
155
- if (
156
- highlight.paused
157
- && strongId !== null
158
- && strongId === prevStrongId
159
- && strongId !== undefined
160
- ) {
161
- ops.pulse(strongId);
162
- }
163
-
164
- // Scroll-into-view target: for wrapper-entry pauses, scroll to the
165
- // BARE (not the wrapper) so the focus matches the displayed state
166
- // name. The worker's `resolveDisplayName` returns the bare's name
167
- // for wrapper iters (so the log reads "paused at walkToBlank ..."),
168
- // but `toId` is the wrapper's id and `highlightExpand` lights up
169
- // both nodes as strong. Without this canonicalization the scroll
170
- // lands on the wrapper while the log line and user's mental focus
171
- // are on the bare. Halt-related ids (≤ 0) are scrolled to as-is —
172
- // `bareIdOf` would collapse them all to the halt singleton, which
173
- // is structurally separate from the in-frame halt marker the user
174
- // is paused near.
175
- if (strongId !== null) {
176
- let scrollTarget: NodeKey = strongId;
177
- if (typeof strongId === 'number' && strongId > 0) {
178
- const node = graph.nodes[strongId];
179
- if (node?.isWrapper && node.bareStateId !== null) {
180
- scrollTarget = node.bareStateId;
181
- }
182
- }
183
- ops.scrollIntoView(scrollTarget);
184
- }
185
-
186
- const nextPrevStrongId = highlight.paused ? strongId : prevStrongId;
187
- return { nextPrevStrongId };
188
- }
189
-
190
- /**
191
- * Pure breakpoint-indicator rule evaluator. For each cached node key,
192
- * emit `ops.setBreakpoint(key, on)` reflecting whether the node's
193
- * canonical bare-id is in the `breakpoints` set.
194
- *
195
- * The 'idle' string sentinel never carries a breakpoint. All numeric
196
- * keys are valid BP-class members:
197
- * - positive id → regular state; canonical via bareIdOf (wrappers
198
- * collapse to bare)
199
- * - 0 → haltState singleton (engine-wide; canonical = 0)
200
- * - negative id → halt marker (per-frame visualization sentinel;
201
- * bareIdOf maps to 0 — same class as the singleton)
202
- * Consumers pass their iterable of cached node keys (e.g. `nodeCache.keys()`).
203
- */
204
- export function applyIndicator(
205
- breakpoints: ReadonlySet<number>,
206
- graph: Graph | null,
207
- nodeIds: Iterable<NodeKey>,
208
- ops: IndicatorOps,
209
- ): void {
210
- for (const key of nodeIds) {
211
- const on =
212
- typeof key === 'number'
213
- && graph !== null
214
- && breakpoints.has(bareIdOf(key, graph));
215
- ops.setBreakpoint(key, on);
216
- }
217
- }
@@ -1,108 +0,0 @@
1
- {
2
- "initialId": 6,
3
- "alphabets": [
4
- [
5
- "␣",
6
- "•"
7
- ]
8
- ],
9
- "nodes": {
10
- "0": {
11
- "id": 0,
12
- "name": "id:0",
13
- "isHalt": true,
14
- "isHaltMarker": false,
15
- "isWrapper": false,
16
- "bareStateId": null,
17
- "frameId": null,
18
- "transitions": [],
19
- "overriddenHaltStateId": null,
20
- "tags": []
21
- },
22
- "6": {
23
- "id": 6,
24
- "name": "10",
25
- "isHalt": false,
26
- "isHaltMarker": false,
27
- "isWrapper": false,
28
- "bareStateId": null,
29
- "frameId": null,
30
- "transitions": [
31
- {
32
- "pattern": "'•'",
33
- "command": [
34
- {
35
- "symbol": "K",
36
- "movement": "S"
37
- }
38
- ],
39
- "nextStateId": 7,
40
- "id": "6.0"
41
- },
42
- {
43
- "pattern": "B",
44
- "command": [
45
- {
46
- "symbol": "K",
47
- "movement": "S"
48
- }
49
- ],
50
- "nextStateId": 8,
51
- "id": "6.1"
52
- }
53
- ],
54
- "overriddenHaltStateId": null,
55
- "tags": [
56
- "main"
57
- ]
58
- },
59
- "7": {
60
- "id": 7,
61
- "name": "20",
62
- "isHalt": false,
63
- "isHaltMarker": false,
64
- "isWrapper": false,
65
- "bareStateId": null,
66
- "frameId": null,
67
- "transitions": [
68
- {
69
- "pattern": "*",
70
- "command": [
71
- {
72
- "symbol": "K",
73
- "movement": "R"
74
- }
75
- ],
76
- "nextStateId": 6,
77
- "id": "7.0"
78
- }
79
- ],
80
- "overriddenHaltStateId": null,
81
- "tags": []
82
- },
83
- "8": {
84
- "id": 8,
85
- "name": "30",
86
- "isHalt": false,
87
- "isHaltMarker": false,
88
- "isWrapper": false,
89
- "bareStateId": null,
90
- "frameId": null,
91
- "transitions": [
92
- {
93
- "pattern": "*",
94
- "command": [
95
- {
96
- "symbol": "'•'",
97
- "movement": "S"
98
- }
99
- ],
100
- "nextStateId": 0,
101
- "id": "8.0"
102
- }
103
- ],
104
- "overriddenHaltStateId": null,
105
- "tags": []
106
- }
107
- }
108
- }