@typeonce/effect-machine 0.5.1 → 0.6.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.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/package.json +8 -8
  3. package/src/Machine.ts +6873 -0
  4. package/src/index.ts +1 -0
  5. package/src/internal/machine/activities.ts +108 -0
  6. package/src/internal/machine/atom.ts +636 -0
  7. package/src/internal/machine/cluster.ts +394 -0
  8. package/src/internal/machine/command.ts +58 -0
  9. package/src/internal/machine/commandRuntime.ts +43 -0
  10. package/src/internal/machine/configuration.ts +1331 -0
  11. package/src/internal/machine/errors.ts +87 -0
  12. package/src/internal/machine/executionPlan.ts +996 -0
  13. package/src/internal/machine/invocation.ts +119 -0
  14. package/src/internal/machine/machine.ts +1747 -0
  15. package/src/internal/machine/planner.ts +1933 -0
  16. package/src/internal/machine/process.ts +906 -0
  17. package/src/internal/machine/protocol.ts +322 -0
  18. package/src/internal/machine/readiness.ts +10 -0
  19. package/src/internal/machine/runtime.ts +2512 -0
  20. package/src/internal/machine/serialization.ts +498 -0
  21. package/src/internal/machine/stateDefinition.ts +270 -0
  22. package/src/internal/machine/symbols.ts +2 -0
  23. package/src/internal/machine/topology.ts +479 -0
  24. package/src/internal/testing/machine/arbitrary.ts +102 -0
  25. package/src/internal/testing/machine/exploration.ts +331 -0
  26. package/src/internal/testing/machine/finiteModel.ts +1498 -0
  27. package/src/internal/testing/machine/invariant.ts +372 -0
  28. package/src/internal/testing/machine/probe.ts +79 -0
  29. package/src/internal/testing/machine/referenceModel.ts +1505 -0
  30. package/src/internal/testing/machine/runtime.ts +1710 -0
  31. package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
  32. package/src/internal/testing/machine/trace.ts +150 -0
  33. package/src/internal/testing/machine/verification.ts +1890 -0
  34. package/src/testing/MachineTest.ts +2067 -0
  35. package/src/testing/index.ts +7 -0
  36. package/src/unstable/cluster/ClusterMachine.ts +390 -0
  37. package/src/unstable/cluster/index.ts +1 -0
  38. package/src/unstable/reactivity/AtomMachine.ts +649 -0
  39. package/src/unstable/reactivity/index.ts +1 -0
@@ -0,0 +1,1505 @@
1
+ /**
2
+ * Independent hierarchical, parallel, history, choice, and automatic-
3
+ * transition statechart semantics for finite test models.
4
+ *
5
+ * This module deliberately knows nothing about `Machine`, its snapshots, the
6
+ * finite-model compiler, target builders, or planner internals. The actual
7
+ * planner trace is treated as opaque data and projected structurally only at
8
+ * the comparison boundary.
9
+ *
10
+ * @internal
11
+ */
12
+
13
+ import * as Data from "effect/Data"
14
+ import * as Effect from "effect/Effect"
15
+ import type {
16
+ FiniteCompoundState,
17
+ FiniteHistoryState,
18
+ FiniteModel,
19
+ FiniteState,
20
+ FiniteTransition,
21
+ FiniteTransitionTrigger
22
+ } from "./finiteModel.js"
23
+
24
+ /**
25
+ * The deterministic value assigned to one active finite-model state.
26
+ *
27
+ * @category models
28
+ * @since 0.4.0
29
+ */
30
+ export interface ReferenceStateValue {
31
+ readonly _tag: string
32
+ readonly value: number
33
+ }
34
+
35
+ /**
36
+ * One output retained for an actively completed state.
37
+ *
38
+ * @category models
39
+ * @since 0.4.0
40
+ */
41
+ export interface ReferenceCompletion {
42
+ readonly path: string
43
+ readonly output: string
44
+ }
45
+
46
+ /**
47
+ * One independently captured shallow or deep history register.
48
+ *
49
+ * @category models
50
+ * @since 0.4.0
51
+ */
52
+ export interface ReferenceHistoryRecord {
53
+ readonly mode: "shallow" | "deep"
54
+ readonly active: ReadonlyArray<string>
55
+ readonly values: Readonly<Record<string, ReferenceStateValue>>
56
+ }
57
+
58
+ /**
59
+ * An independently interpreted finite-model configuration.
60
+ *
61
+ * @category models
62
+ * @since 0.4.0
63
+ */
64
+ export interface ReferenceState {
65
+ /** Active ancestors and leaf in state-definition order. */
66
+ readonly activePaths: ReadonlyArray<string>
67
+ /** Deterministic state values keyed by active state path. */
68
+ readonly values: Readonly<Record<string, ReferenceStateValue>>
69
+ /** Completed final and compound paths in completion order. */
70
+ readonly completions: ReadonlyArray<ReferenceCompletion>
71
+ /** Logical history registers keyed by history pseudo-state path. */
72
+ readonly history: Readonly<Record<string, ReferenceHistoryRecord>>
73
+ readonly status: "active" | "done"
74
+ readonly output: string | undefined
75
+ }
76
+
77
+ /**
78
+ * One transition retained by the independent reference step.
79
+ *
80
+ * @category models
81
+ * @since 0.4.0
82
+ */
83
+ export interface ReferenceTransition {
84
+ readonly source: string
85
+ readonly trigger:
86
+ | { readonly type: "event"; readonly event: string }
87
+ | { readonly type: "always" }
88
+ | { readonly type: "done" }
89
+ | { readonly type: "choice" }
90
+ readonly reenter: boolean
91
+ readonly target: string | undefined
92
+ readonly resolvedTarget: string | undefined
93
+ }
94
+
95
+ /**
96
+ * The independently calculated planner microstep for one selected event.
97
+ *
98
+ * @category models
99
+ * @since 0.4.0
100
+ */
101
+ export interface ReferenceMicrostep {
102
+ readonly next: ReferenceState
103
+ readonly event: string
104
+ readonly transitions: ReadonlyArray<ReferenceTransition>
105
+ readonly exitPaths: ReadonlyArray<string>
106
+ readonly entryPaths: ReadonlyArray<string>
107
+ readonly changed: boolean
108
+ }
109
+
110
+ /**
111
+ * Startup semantics calculated without executing the real machine.
112
+ *
113
+ * @category models
114
+ * @since 0.4.0
115
+ */
116
+ export interface ReferenceInitialStep {
117
+ readonly startingState: ReferenceState
118
+ readonly initialEntryPaths: ReadonlyArray<string>
119
+ readonly state: ReferenceState
120
+ readonly microsteps: ReadonlyArray<ReferenceMicrostep>
121
+ readonly done: boolean
122
+ readonly output: string | undefined
123
+ }
124
+
125
+ /**
126
+ * One public event interpreted against a reference configuration.
127
+ *
128
+ * @category models
129
+ * @since 0.4.0
130
+ */
131
+ export interface ReferenceStep {
132
+ readonly index: number
133
+ readonly event: string
134
+ readonly before: ReferenceState
135
+ readonly microsteps: ReadonlyArray<ReferenceMicrostep>
136
+ readonly after: ReferenceState
137
+ readonly done: boolean
138
+ readonly output: string | undefined
139
+ }
140
+
141
+ /**
142
+ * A complete, pure interpretation of a finite model and event sequence.
143
+ *
144
+ * @category models
145
+ * @since 0.4.0
146
+ */
147
+ export interface ReferenceTrace {
148
+ readonly events: ReadonlyArray<string>
149
+ readonly initial: ReferenceInitialStep
150
+ readonly steps: ReadonlyArray<ReferenceStep>
151
+ readonly final: ReferenceState
152
+ }
153
+
154
+ /**
155
+ * Location of one planner/reference disagreement.
156
+ *
157
+ * @category models
158
+ * @since 0.4.0
159
+ */
160
+ export interface ModelVerificationLocation {
161
+ readonly phase: "initial" | "event" | "final"
162
+ readonly eventIndex?: number
163
+ readonly microstepIndex?: number
164
+ }
165
+
166
+ type ModelStateField =
167
+ | "initial.startingState"
168
+ | "initial.plan.startingState"
169
+ | "initial.plan.state"
170
+ | "step.before"
171
+ | "microstep.next"
172
+ | "step.plan.next"
173
+ | "step.after"
174
+ | "trace.final"
175
+
176
+ /**
177
+ * Stable semantic observation compared by the finite-model oracle.
178
+ *
179
+ * @category models
180
+ * @since 0.4.0
181
+ */
182
+ export type ModelVerificationField =
183
+ | `${ModelStateField}.${"activePaths" | "values" | "completions" | "history"}`
184
+ | "event.tag"
185
+ | "initial.startingConfiguration"
186
+ | "initial.initialEntryPaths"
187
+ | "initial.plan.initialEntryPaths"
188
+ | "initial.configuration"
189
+ | "initial.plan.microsteps"
190
+ | "initial.plan.done"
191
+ | "initial.plan.output"
192
+ | "trace.steps.length"
193
+ | "step.index"
194
+ | "step.event"
195
+ | "step.beforeConfiguration"
196
+ | "step.plan.microsteps.length"
197
+ | "microstep.event"
198
+ | "microstep.transitions"
199
+ | "microstep.exitPaths"
200
+ | "microstep.entryPaths"
201
+ | "microstep.changed"
202
+ | "step.afterConfiguration"
203
+ | "step.plan.done"
204
+ | "step.plan.output"
205
+ | "trace.finalConfiguration"
206
+
207
+ /**
208
+ * One structured semantic disagreement with the independent interpreter.
209
+ *
210
+ * @category models
211
+ * @since 0.4.0
212
+ */
213
+ export interface ModelVerificationMismatch {
214
+ readonly location: ModelVerificationLocation
215
+ /** Stable dotted field identifying the compared observation. */
216
+ readonly field: ModelVerificationField
217
+ readonly expected: unknown
218
+ readonly actual: unknown
219
+ readonly message: string
220
+ }
221
+
222
+ /**
223
+ * All semantic disagreements found for one finite-model trace.
224
+ *
225
+ * @category errors
226
+ * @since 0.4.0
227
+ */
228
+ export class ModelVerificationError extends Data.TaggedError("MachineTestModelVerificationError")<{
229
+ readonly mismatches: ReadonlyArray<ModelVerificationMismatch>
230
+ }> {}
231
+
232
+ interface IndexedState {
233
+ readonly node: FiniteState
234
+ readonly path: string
235
+ readonly parent: string | undefined
236
+ readonly root: string
237
+ readonly depth: number
238
+ readonly order: number
239
+ readonly children: ReadonlyArray<string>
240
+ }
241
+
242
+ interface ModelIndex {
243
+ readonly ordered: ReadonlyArray<IndexedState>
244
+ readonly byPath: ReadonlyMap<string, IndexedState>
245
+ readonly transitions: ReadonlyMap<string, FiniteTransition>
246
+ readonly histories: ReadonlyArray<IndexedState & { readonly node: FiniteHistoryState }>
247
+ }
248
+
249
+ const triggerKey = (trigger: FiniteTransitionTrigger): string =>
250
+ trigger.type === "event" ? `event:${trigger.event}` : trigger.type
251
+
252
+ const reenters = (transition: FiniteTransition): boolean => "reenter" in transition && transition.reenter
253
+
254
+ const ControlOrder: unique symbol = Symbol("MachineTestReferenceControlOrder")
255
+
256
+ type InternalReferenceState = ReferenceState & {
257
+ readonly [ControlOrder]: ReadonlyArray<string>
258
+ }
259
+
260
+ const withControlOrder = (
261
+ state: ReferenceState,
262
+ order: ReadonlyArray<string>
263
+ ): InternalReferenceState => {
264
+ Object.defineProperty(state, ControlOrder, { value: order.slice(), enumerable: false })
265
+ return state as InternalReferenceState
266
+ }
267
+
268
+ const controlOrder = (state: ReferenceState): ReadonlyArray<string> =>
269
+ ControlOrder in state ? (state as InternalReferenceState)[ControlOrder] : state.activePaths
270
+
271
+ const stateTag = (path: string): string => `State_${path.replaceAll(".", "_")}`
272
+
273
+ /**
274
+ * Builds the oracle's own state index. This traversal intentionally duplicates
275
+ * the structural work performed by the compiler instead of importing it.
276
+ */
277
+ const indexModel = (model: FiniteModel): ModelIndex => {
278
+ const ordered: Array<IndexedState> = []
279
+ const visit = (
280
+ states: ReadonlyArray<FiniteState>,
281
+ parent: string | undefined,
282
+ root: string | undefined,
283
+ depth: number
284
+ ): void => {
285
+ for (const node of states) {
286
+ const path = parent === undefined ? node.key : `${parent}.${node.key}`
287
+ const nodeRoot = root ?? path
288
+ ordered.push({
289
+ node,
290
+ path,
291
+ parent,
292
+ root: nodeRoot,
293
+ depth,
294
+ order: ordered.length,
295
+ children: node._tag === "Compound" || node._tag === "Parallel"
296
+ ? node.states.filter((child) => child._tag !== "History" && child._tag !== "Choice").map((child) =>
297
+ `${path}.${child.key}`
298
+ )
299
+ : []
300
+ })
301
+ if (node._tag === "Compound" || node._tag === "Parallel") {
302
+ visit(node.states, path, nodeRoot, depth + 1)
303
+ }
304
+ }
305
+ }
306
+ visit(model.roots, undefined, undefined, 1)
307
+ const byPath = new Map(ordered.map((state) => [state.path, state]))
308
+ const transitions = new Map(
309
+ model.transitions.map((transition) => [`${transition.source}\u0000${triggerKey(transition.trigger)}`, transition])
310
+ )
311
+ const histories = ordered.filter(
312
+ (state): state is IndexedState & { readonly node: FiniteHistoryState } => state.node._tag === "History"
313
+ )
314
+ return { ordered, byPath, transitions, histories }
315
+ }
316
+
317
+ const getState = (index: ModelIndex, path: string): IndexedState => {
318
+ const state = index.byPath.get(path)
319
+ if (state === undefined) {
320
+ throw new Error(`MachineTest.interpretModel received unknown state path "${path}"`)
321
+ }
322
+ return state
323
+ }
324
+
325
+ const activeValue = (state: IndexedState, value?: number): ReferenceStateValue => {
326
+ if (state.node._tag === "History" || state.node._tag === "Choice") {
327
+ throw new Error(`MachineTest.interpretModel cannot activate pseudo-state "${state.path}"`)
328
+ }
329
+ return { _tag: stateTag(state.path), value: value ?? state.node.value }
330
+ }
331
+
332
+ const applyTargetValue = (
333
+ index: ModelIndex,
334
+ state: ReferenceState,
335
+ transition: FiniteTransition
336
+ ): ReferenceState => {
337
+ if (transition.targetValue === undefined || transition.target === undefined) return state
338
+ const target = getState(index, transition.target)
339
+ if (target.node._tag === "History" || !state.activePaths.includes(target.path)) return state
340
+ return withControlOrder({
341
+ ...state,
342
+ values: { ...state.values, [target.path]: activeValue(target, transition.targetValue) }
343
+ }, controlOrder(state))
344
+ }
345
+
346
+ const pathsToRoot = (index: ModelIndex, path: string): ReadonlyArray<string> => {
347
+ const paths: Array<string> = []
348
+ let current: IndexedState | undefined = getState(index, path)
349
+ while (current !== undefined) {
350
+ paths.unshift(current.path)
351
+ current = current.parent === undefined ? undefined : getState(index, current.parent)
352
+ }
353
+ return paths
354
+ }
355
+
356
+ const initialChildPath = (state: IndexedState): string => {
357
+ const node = state.node as FiniteCompoundState
358
+ const child = node.states.find((candidate) => candidate.key === node.initial)
359
+ if (child === undefined) {
360
+ throw new Error(`MachineTest.interpretModel received unknown initial child "${node.initial}" for "${state.path}"`)
361
+ }
362
+ return `${state.path}.${child.key}`
363
+ }
364
+
365
+ const resolveChoicePath = (index: ModelIndex, path: string): string => {
366
+ let current = getState(index, path)
367
+ const seen = new Set<string>()
368
+ while (current.node._tag === "Choice") {
369
+ if (seen.has(current.path)) {
370
+ throw new Error(`MachineTest.interpretModel received infinite choice loop at "${current.path}"`)
371
+ }
372
+ seen.add(current.path)
373
+ current = getState(index, current.node.selected)
374
+ }
375
+ return current.path
376
+ }
377
+
378
+ const expandInitial = (index: ModelIndex, path: string): ReadonlyArray<string> => {
379
+ const current = getState(index, path)
380
+ if (current.node._tag === "Choice") return expandInitial(index, resolveChoicePath(index, current.path))
381
+ if (current.node._tag === "Compound") {
382
+ return [current.path, ...expandInitial(index, initialChildPath(current))]
383
+ }
384
+ if (current.node._tag === "Parallel") {
385
+ return [
386
+ current.path,
387
+ ...current.children.flatMap((child) => expandInitial(index, child))
388
+ ]
389
+ }
390
+ return [current.path]
391
+ }
392
+
393
+ const makeUnsettledState = (index: ModelIndex, target: string): ReferenceState => {
394
+ const activePaths = [...pathsToRoot(index, target).slice(0, -1), ...expandInitial(index, target)]
395
+ const values: Record<string, ReferenceStateValue> = {}
396
+ for (const path of activePaths) {
397
+ values[path] = activeValue(getState(index, path))
398
+ }
399
+ return withControlOrder({
400
+ activePaths,
401
+ values,
402
+ completions: [],
403
+ history: {},
404
+ status: "active",
405
+ output: undefined
406
+ }, activePaths)
407
+ }
408
+
409
+ const directActiveChild = (
410
+ index: ModelIndex,
411
+ state: ReferenceState,
412
+ parent: string
413
+ ): IndexedState | undefined => {
414
+ const parentState = getState(index, parent)
415
+ return parentState.children
416
+ .map((path) => getState(index, path))
417
+ .find((child) => state.activePaths.includes(child.path))
418
+ }
419
+
420
+ const settleCompletions = (index: ModelIndex, state: ReferenceState): ReferenceState => {
421
+ const completions = [...state.completions]
422
+ const outputs = new Map(completions.map((completion) => [completion.path, completion.output]))
423
+ const complete = (path: string): string | undefined => {
424
+ if (outputs.has(path)) return outputs.get(path)
425
+ const current = getState(index, path)
426
+ let output: string | undefined
427
+ if (current.node._tag === "Final") {
428
+ output = current.node.output
429
+ } else if (current.node._tag === "Compound") {
430
+ const child = directActiveChild(index, state, current.path)
431
+ // A compound completes only when its direct active child is final. A
432
+ // completed compound child instead enables that child's `done`
433
+ // transition; completion propagates when it transitions to a final
434
+ // sibling of the parent.
435
+ if (child?.node._tag !== "Final") return undefined
436
+ output = complete(child.path)
437
+ } else if (current.node._tag === "Parallel") {
438
+ for (const childPath of current.children) {
439
+ if (!state.activePaths.includes(childPath) || complete(childPath) === undefined) {
440
+ return undefined
441
+ }
442
+ }
443
+ output = current.node.output
444
+ }
445
+ if (output !== undefined) {
446
+ outputs.set(path, output)
447
+ completions.push({ path, output })
448
+ }
449
+ return output
450
+ }
451
+ const deepestFirst = state.activePaths.slice().sort((left, right) => {
452
+ const leftState = getState(index, left)
453
+ const rightState = getState(index, right)
454
+ return rightState.depth - leftState.depth || leftState.order - rightState.order
455
+ })
456
+
457
+ for (const path of deepestFirst) {
458
+ complete(path)
459
+ }
460
+
461
+ const root = state.activePaths.find((path) => getState(index, path).parent === undefined)
462
+ const output = root === undefined ? undefined : outputs.get(root)
463
+ return withControlOrder({
464
+ ...state,
465
+ completions,
466
+ status: output === undefined ? "active" : "done",
467
+ output
468
+ }, controlOrder(state))
469
+ }
470
+
471
+ const samePaths = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
472
+ left.length === right.length && left.every((path, index) => path === right[index])
473
+
474
+ const leastCommonAncestor = (index: ModelIndex, left: string, right: string): string | undefined => {
475
+ const leftPaths = pathsToRoot(index, left)
476
+ const rightPaths = pathsToRoot(index, right)
477
+ let result: string | undefined
478
+ for (let position = 0; position < Math.min(leftPaths.length, rightPaths.length); position++) {
479
+ if (leftPaths[position] !== rightPaths[position]) break
480
+ result = leftPaths[position]
481
+ }
482
+ return result
483
+ }
484
+
485
+ const isDescendant = (index: ModelIndex, path: string, ancestor: string): boolean => {
486
+ let parent = getState(index, path).parent
487
+ while (parent !== undefined) {
488
+ if (parent === ancestor) return true
489
+ parent = getState(index, parent).parent
490
+ }
491
+ return false
492
+ }
493
+
494
+ const broadenBoundary = (
495
+ index: ModelIndex,
496
+ natural: string | undefined,
497
+ reentry: string | undefined
498
+ ): string | undefined => {
499
+ if (natural === undefined || reentry === undefined) return undefined
500
+ return natural === reentry || isDescendant(index, natural, reentry) ? reentry : natural
501
+ }
502
+
503
+ const lifecyclePaths = (
504
+ index: ModelIndex,
505
+ activePaths: ReadonlyArray<string>,
506
+ boundary: string | undefined,
507
+ direction: "entry" | "exit"
508
+ ): ReadonlyArray<string> =>
509
+ activePaths
510
+ .filter((path) => boundary === undefined || isDescendant(index, path, boundary))
511
+ .sort((left, right) => {
512
+ const leftState = getState(index, left)
513
+ const rightState = getState(index, right)
514
+ const depth = direction === "entry"
515
+ ? leftState.depth - rightState.depth
516
+ : rightState.depth - leftState.depth
517
+ if (depth !== 0) return depth
518
+ return direction === "entry"
519
+ ? leftState.order - rightState.order
520
+ : rightState.order - leftState.order
521
+ })
522
+
523
+ interface SelectedTransition {
524
+ readonly transition: FiniteTransition
525
+ readonly leaf: string
526
+ }
527
+
528
+ interface EvaluatedTransition extends SelectedTransition {
529
+ readonly next: ReferenceState
530
+ readonly targetPath: string | undefined
531
+ readonly changed: boolean
532
+ readonly exitPaths: ReadonlyArray<string>
533
+ readonly entryPaths: ReadonlyArray<string>
534
+ }
535
+
536
+ const activeLeaves = (index: ModelIndex, state: ReferenceState): ReadonlyArray<string> =>
537
+ state.activePaths.filter((path) => {
538
+ const current = getState(index, path)
539
+ return current.children.every((child) => !state.activePaths.includes(child))
540
+ })
541
+
542
+ const selectTransitions = (
543
+ index: ModelIndex,
544
+ state: ReferenceState,
545
+ trigger: FiniteTransitionTrigger
546
+ ): ReadonlyArray<SelectedTransition> => {
547
+ const selections: Array<SelectedTransition> = []
548
+ const selectedSources = new Set<string>()
549
+ for (const leaf of activeLeaves(index, state)) {
550
+ const candidates = pathsToRoot(index, leaf).slice().reverse()
551
+ for (const source of candidates) {
552
+ const transition = index.transitions.get(`${source}\u0000${triggerKey(trigger)}`)
553
+ if (transition === undefined) continue
554
+ if (!selectedSources.has(source)) {
555
+ selectedSources.add(source)
556
+ selections.push({ transition, leaf })
557
+ }
558
+ break
559
+ }
560
+ }
561
+ return selections.filter(({ transition }) =>
562
+ !selections.some(({ transition: other }) =>
563
+ other.source !== transition.source && isDescendant(index, other.source, transition.source)
564
+ )
565
+ )
566
+ }
567
+
568
+ const runtimeTargetPath = (index: ModelIndex, transition: FiniteTransition): string | undefined => {
569
+ if (transition.target === undefined) return undefined
570
+ const source = getState(index, transition.source)
571
+ const target = getState(index, transition.target)
572
+ if (target.node._tag === "History") return target.parent
573
+ if (target.node._tag === "Choice") {
574
+ return runtimeTargetPath(index, { ...transition, target: resolveChoicePath(index, target.path) })
575
+ }
576
+ // A same-root branch builder identifies the concrete initialized leaf. A
577
+ // full builder replaces the root with a complete snapshot and identifies
578
+ // that snapshot's root even when it contains initialized descendants.
579
+ if (source.root !== target.root) return target.path
580
+
581
+ const initial = (path: string): string => {
582
+ const current = getState(index, path)
583
+ if (current.node._tag === "Parallel") {
584
+ if (transition.source !== current.path && !transition.source.startsWith(`${current.path}.`)) {
585
+ return current.path
586
+ }
587
+ const child = transition.source === current.path
588
+ ? current.children[0]!
589
+ : current.children.find((candidate) => isPathInSubtree(transition.source, candidate))!
590
+ return initial(child)
591
+ }
592
+ return current.node._tag === "Compound" ? initial(initialChildPath(current)) : current.path
593
+ }
594
+ const inspect = (path: string): string => {
595
+ const current = getState(index, path)
596
+ if (
597
+ current.node._tag === "Parallel" && transition.source !== current.path &&
598
+ !transition.source.startsWith(`${current.path}.`)
599
+ ) {
600
+ return current.path
601
+ }
602
+ if (current.path === target.path) return initial(current.path)
603
+ const next = target.path.slice(current.path.length + 1).split(".")[0]!
604
+ return inspect(`${current.path}.${next}`)
605
+ }
606
+ return inspect(source.root)
607
+ }
608
+
609
+ const entryChoicePath = (index: ModelIndex, path: string): string | undefined => {
610
+ const state = getState(index, path)
611
+ if (state.node._tag === "Choice") return state.path
612
+ if (state.node._tag === "Compound") {
613
+ return entryChoicePath(index, `${state.path}.${state.node.initial}`)
614
+ }
615
+ if (state.node._tag === "Parallel") {
616
+ for (const child of state.children) {
617
+ const choice = entryChoicePath(index, child)
618
+ if (choice !== undefined) return choice
619
+ }
620
+ }
621
+ return undefined
622
+ }
623
+
624
+ const choiceResolvedTargetPath = (index: ModelIndex, path: string): string => {
625
+ const choice = getState(index, path)
626
+ if (choice.node._tag !== "Choice") return choice.path
627
+ const selected = getState(index, choice.node.selected)
628
+ if (selected.node._tag === "Choice") return choiceResolvedTargetPath(index, selected.path)
629
+ return runtimeTargetPath(index, {
630
+ source: choice.path,
631
+ trigger: { type: "always" },
632
+ target: selected.path
633
+ })!
634
+ }
635
+
636
+ const choiceChainResolvedTargetPath = (index: ModelIndex, path: string): string => {
637
+ let current = getState(index, path)
638
+ const seen = new Set<string>()
639
+ while (current.node._tag === "Choice") {
640
+ if (seen.has(current.path)) return choiceResolvedTargetPath(index, current.path)
641
+ seen.add(current.path)
642
+ const selected = getState(index, current.node.selected)
643
+ const nestedChoice = entryChoicePath(index, selected.path)
644
+ if (nestedChoice === undefined) return choiceResolvedTargetPath(index, current.path)
645
+ current = getState(index, nestedChoice)
646
+ }
647
+ return current.path
648
+ }
649
+
650
+ const isPathInSubtree = (path: string, root: string): boolean => path === root || path.startsWith(`${root}.`)
651
+
652
+ const expandSelection = (
653
+ index: ModelIndex,
654
+ path: string,
655
+ requested: string
656
+ ): ReadonlyArray<string> => {
657
+ const current = getState(index, path)
658
+ if (current.node._tag === "History") return []
659
+ if (current.node._tag === "Compound") {
660
+ const selected = current.children.find((child) => isPathInSubtree(requested, child)) ?? initialChildPath(current)
661
+ return [current.path, ...expandSelection(index, selected, requested)]
662
+ }
663
+ if (current.node._tag === "Parallel") {
664
+ return [
665
+ current.path,
666
+ ...current.children.flatMap((child) =>
667
+ expandSelection(index, child, isPathInSubtree(requested, child) ? requested : child)
668
+ )
669
+ ]
670
+ }
671
+ return [current.path]
672
+ }
673
+
674
+ const makeHistoryConfiguration = (
675
+ index: ModelIndex,
676
+ current: ReferenceState,
677
+ owner: string,
678
+ activePaths: ReadonlyArray<string>,
679
+ rememberedValues: Readonly<Record<string, ReferenceStateValue>>,
680
+ history: Readonly<Record<string, ReferenceHistoryRecord>>
681
+ ): ReferenceState => {
682
+ const active = new Set(activePaths)
683
+ const ownerAncestors = pathsToRoot(index, owner)
684
+ const ownerAncestry = new Set(ownerAncestors)
685
+
686
+ // Parallel ancestors outside the owner retain active sibling regions. When
687
+ // the owner belongs to an inactive root, those regions follow initial entry.
688
+ for (const ancestorPath of ownerAncestors) {
689
+ const ancestor = getState(index, ancestorPath)
690
+ if (ancestor.node._tag !== "Parallel") continue
691
+ const selectedRegion = ancestor.children.find((child) => ownerAncestry.has(child))
692
+ for (const region of ancestor.children) {
693
+ if (region === selectedRegion || active.has(region)) continue
694
+ const retained = current.activePaths.filter((path) => isPathInSubtree(path, region))
695
+ for (const path of retained.length === 0 ? expandInitial(index, region) : retained) active.add(path)
696
+ }
697
+ }
698
+
699
+ const ordered = index.ordered
700
+ .filter(({ node, path }) => node._tag !== "History" && node._tag !== "Choice" && active.has(path))
701
+ .map(({ path }) => path)
702
+ const values: Record<string, ReferenceStateValue> = {}
703
+ for (const path of ordered) {
704
+ values[path] = rememberedValues[path] ?? current.values[path] ?? activeValue(getState(index, path))
705
+ }
706
+ const completions = current.completions.filter(({ path }) => active.has(path) && !isPathInSubtree(path, owner))
707
+ return withControlOrder({
708
+ activePaths: ordered,
709
+ values,
710
+ completions,
711
+ history,
712
+ status: "active",
713
+ output: undefined
714
+ }, ordered)
715
+ }
716
+
717
+ const captureHistory = (
718
+ index: ModelIndex,
719
+ current: ReferenceState,
720
+ next: ReferenceState,
721
+ exitPaths: ReadonlyArray<string>
722
+ ): ReferenceState => {
723
+ if (exitPaths.length === 0) return next
724
+ const exited = new Set(exitPaths)
725
+ const history: Record<string, ReferenceHistoryRecord> = { ...next.history }
726
+ for (const state of index.histories) {
727
+ const owner = state.parent!
728
+ if (!exited.has(owner)) continue
729
+ const active = current.activePaths.filter((path) =>
730
+ pathsToRoot(index, owner).includes(path) || path === owner ||
731
+ (state.node.history === "deep"
732
+ ? isPathInSubtree(path, owner)
733
+ : getState(index, path).parent === owner)
734
+ )
735
+ const values: Record<string, ReferenceStateValue> = {}
736
+ for (const path of active) values[path] = current.values[path]!
737
+ history[state.path] = {
738
+ mode: state.node.history,
739
+ active,
740
+ values
741
+ }
742
+ }
743
+ return withControlOrder({ ...next, history }, controlOrder(next))
744
+ }
745
+
746
+ const restoreHistory = (
747
+ index: ModelIndex,
748
+ current: ReferenceState,
749
+ historyState: IndexedState & { readonly node: FiniteHistoryState }
750
+ ): ReferenceState => {
751
+ const owner = historyState.parent!
752
+ const record = current.history[historyState.path]
753
+ if (record === undefined) {
754
+ const active = [
755
+ ...pathsToRoot(index, owner).slice(0, -1),
756
+ ...expandSelection(index, owner, historyState.node.fallback)
757
+ ]
758
+ const values: Record<string, ReferenceStateValue> = {}
759
+ for (const path of active) values[path] = activeValue(getState(index, path))
760
+ return makeHistoryConfiguration(index, current, owner, active, values, current.history)
761
+ }
762
+
763
+ const active = new Set(record.active)
764
+ if (record.mode === "shallow") {
765
+ for (const child of getState(index, owner).children) {
766
+ if (!active.has(child)) continue
767
+ for (const path of expandInitial(index, child).slice(1)) active.add(path)
768
+ }
769
+ }
770
+ return makeHistoryConfiguration(index, current, owner, Array.from(active), record.values, current.history)
771
+ }
772
+
773
+ const resolveHistoryState = (
774
+ index: ModelIndex,
775
+ before: ReferenceState,
776
+ transition: FiniteTransition,
777
+ leaf: string
778
+ ): ReferenceState => {
779
+ const historyState = getState(index, transition.target!) as IndexedState & { readonly node: FiniteHistoryState }
780
+ const owner = historyState.parent!
781
+ const reenteredOwner = reenters(transition) && before.activePaths.includes(owner)
782
+ const provisionalBoundary = reenters(transition)
783
+ ? getState(index, transition.source).parent
784
+ : leastCommonAncestor(index, leaf, owner)
785
+ const provisionalExitPaths = reenteredOwner
786
+ ? lifecyclePaths(
787
+ index,
788
+ before.activePaths.filter((path) => isPathInSubtree(path, owner)),
789
+ undefined,
790
+ "exit"
791
+ )
792
+ : lifecyclePaths(index, before.activePaths, provisionalBoundary, "exit")
793
+ const stateAtResolution = provisionalExitPaths.includes(owner)
794
+ ? captureHistory(index, before, before, provisionalExitPaths)
795
+ : before
796
+ return restoreHistory(index, stateAtResolution, historyState)
797
+ }
798
+
799
+ const targetState = (
800
+ index: ModelIndex,
801
+ before: ReferenceState,
802
+ transition: FiniteTransition,
803
+ leaf: string = transition.source
804
+ ): ReferenceState => {
805
+ if (transition.target === undefined) return before
806
+ const source = getState(index, transition.source)
807
+ const target = getState(index, transition.target)
808
+ if (target.node._tag === "Choice") {
809
+ return targetState(index, before, { ...transition, target: resolveChoicePath(index, target.path) }, leaf)
810
+ }
811
+ if (target.node._tag === "History") return resolveHistoryState(index, before, transition, leaf)
812
+ if (source.root !== target.root) {
813
+ const unsettled = makeUnsettledState(index, target.path)
814
+ return applyTargetValue(
815
+ index,
816
+ withControlOrder({ ...unsettled, history: before.history }, controlOrder(unsettled)),
817
+ transition
818
+ )
819
+ }
820
+
821
+ const actualTarget = runtimeTargetPath(index, transition)!
822
+ const actualNode = getState(index, actualTarget)
823
+ // A returned parallel target is an upper bound carrying a complete nested
824
+ // snapshot: retain the more specific declared descendant for that snapshot.
825
+ // Conversely, a branch target may resolve a declared compound/parallel
826
+ // ancestor to the concrete initialized leaf selected inside the source
827
+ // region. In both cases the deeper path describes the control change.
828
+ const configurationTarget = actualNode.depth >= target.depth ? actualTarget : target.path
829
+ const active = new Set<string>([
830
+ ...pathsToRoot(index, configurationTarget).slice(0, -1),
831
+ ...expandInitial(index, configurationTarget)
832
+ ])
833
+ const retainedValuePaths = new Set(
834
+ pathsToRoot(index, configurationTarget)
835
+ .slice(0, -1)
836
+ .filter((path) => before.activePaths.includes(path))
837
+ )
838
+ const retainedCompletionPaths: Array<string> = []
839
+ const order: Array<string> = actualNode.node._tag === "Parallel"
840
+ ? [
841
+ ...index.ordered
842
+ .filter(({ path }) => active.has(path) && isPathInSubtree(path, actualTarget))
843
+ .map(({ path }) => path),
844
+ ...pathsToRoot(index, actualTarget).slice(0, -1)
845
+ ]
846
+ : pathsToRoot(index, actualTarget).slice()
847
+ const targetAncestors = pathsToRoot(index, configurationTarget)
848
+ for (const ancestorPath of targetAncestors) {
849
+ const ancestor = getState(index, ancestorPath)
850
+ if (ancestor.node._tag !== "Parallel") continue
851
+ const selectedRegion = ancestor.children.find((child) => isPathInSubtree(configurationTarget, child))
852
+ const sourceInside = transition.source === ancestor.path || transition.source.startsWith(`${ancestor.path}.`)
853
+ for (const region of ancestor.children) {
854
+ if (region === selectedRegion) continue
855
+ if (sourceInside && before.activePaths.includes(region)) {
856
+ for (const path of controlOrder(before)) {
857
+ if (isPathInSubtree(path, region)) {
858
+ active.add(path)
859
+ retainedValuePaths.add(path)
860
+ if (!order.includes(path)) order.push(path)
861
+ if (
862
+ before.completions.some((completion) => completion.path === path) &&
863
+ !retainedCompletionPaths.includes(path)
864
+ ) {
865
+ retainedCompletionPaths.push(path)
866
+ }
867
+ }
868
+ }
869
+ } else {
870
+ for (const path of expandInitial(index, region)) {
871
+ active.add(path)
872
+ if (!order.includes(path)) order.push(path)
873
+ }
874
+ }
875
+ }
876
+ }
877
+ if (actualNode.node._tag === "Parallel") {
878
+ const subtreeOrder = index.ordered
879
+ .filter(({ path }) => active.has(path) && isPathInSubtree(path, actualTarget))
880
+ .map(({ path }) => path)
881
+ const outsideOrder = order.filter((path) => !isPathInSubtree(path, actualTarget))
882
+ order.splice(0, order.length, ...subtreeOrder, ...outsideOrder)
883
+ }
884
+
885
+ const activePaths = index.ordered.filter(({ path }) => active.has(path)).map(({ path }) => path)
886
+ const values: Record<string, ReferenceStateValue> = {}
887
+ for (const path of activePaths) {
888
+ values[path] = retainedValuePaths.has(path) && before.values[path] !== undefined
889
+ ? before.values[path]
890
+ : activeValue(getState(index, path))
891
+ }
892
+ return applyTargetValue(
893
+ index,
894
+ withControlOrder({
895
+ activePaths,
896
+ values,
897
+ completions: retainedCompletionPaths.flatMap((path) => {
898
+ const completion = before.completions.find((candidate) => candidate.path === path)
899
+ return completion === undefined ? [] : [completion]
900
+ }),
901
+ history: before.history,
902
+ status: "active",
903
+ output: undefined
904
+ }, order),
905
+ transition
906
+ )
907
+ }
908
+
909
+ const transitionRecord = (index: ModelIndex, transition: FiniteTransition): ReferenceTransition => ({
910
+ source: transition.source,
911
+ trigger: transition.trigger,
912
+ reenter: reenters(transition),
913
+ // Retain the target identity returned by the compiler's selected builder;
914
+ // the finite AST keeps the broader declared bound separately.
915
+ target: transition.target !== undefined && getState(index, transition.target).node._tag === "History"
916
+ ? transition.target
917
+ : transition.target === undefined
918
+ ? runtimeTargetPath(index, transition)
919
+ : getState(index, transition.source).root !== getState(index, transition.target).root
920
+ ? transition.target
921
+ : entryChoicePath(index, transition.target) ?? runtimeTargetPath(index, transition),
922
+ resolvedTarget: transition.target === undefined
923
+ ? runtimeTargetPath(index, transition)
924
+ : entryChoicePath(index, transition.target) === undefined
925
+ ? runtimeTargetPath(index, transition)
926
+ : choiceChainResolvedTargetPath(index, entryChoicePath(index, transition.target)!)
927
+ })
928
+
929
+ const choiceTransitionRecords = (index: ModelIndex, path: string): ReadonlyArray<ReferenceTransition> => {
930
+ const records: Array<ReferenceTransition> = []
931
+ let current = getState(index, path)
932
+ const seen = new Set<string>()
933
+ while (current.node._tag === "Choice") {
934
+ if (seen.has(current.path)) break
935
+ seen.add(current.path)
936
+ const selected = getState(index, current.node.selected)
937
+ const nestedChoice = entryChoicePath(index, selected.path)
938
+ const selectedTarget = nestedChoice ?? choiceResolvedTargetPath(index, current.path)
939
+ records.push({
940
+ source: current.path,
941
+ trigger: { type: "choice" },
942
+ reenter: false,
943
+ target: selectedTarget,
944
+ resolvedTarget: choiceResolvedTargetPath(index, current.path)
945
+ })
946
+ if (nestedChoice === undefined) break
947
+ current = getState(index, nestedChoice)
948
+ }
949
+ return records
950
+ }
951
+
952
+ const initialChoiceTransitionRecords = (index: ModelIndex, path: string): ReadonlyArray<ReferenceTransition> => {
953
+ const current = getState(index, path)
954
+ if (current.node._tag === "Choice") {
955
+ return choiceTransitionRecords(index, current.path)
956
+ }
957
+ if (current.node._tag === "Compound") {
958
+ return initialChoiceTransitionRecords(index, initialChildPath(current))
959
+ }
960
+ if (current.node._tag === "Parallel") {
961
+ return current.children.flatMap((child) => initialChoiceTransitionRecords(index, child))
962
+ }
963
+ return []
964
+ }
965
+
966
+ const evaluateTransition = (
967
+ index: ModelIndex,
968
+ before: ReferenceState,
969
+ selection: SelectedTransition
970
+ ): EvaluatedTransition => {
971
+ const { transition } = selection
972
+ const targetPath = runtimeTargetPath(index, transition)
973
+ const next = targetState(index, before, transition, selection.leaf)
974
+ const changed = reenters(transition) || !samePaths(before.activePaths, next.activePaths)
975
+ if (!changed) {
976
+ return { ...selection, next, targetPath, changed, exitPaths: [], entryPaths: [] }
977
+ }
978
+ const naturalBoundary = targetPath === undefined
979
+ ? getState(index, transition.source).parent
980
+ : leastCommonAncestor(index, selection.leaf, targetPath)
981
+ const choiceEntry = transition.target === undefined ? undefined : entryChoicePath(index, transition.target)
982
+ const choiceResolvesToActiveAncestor = choiceEntry !== undefined &&
983
+ isPathInSubtree(transition.source, resolveChoicePath(index, choiceEntry))
984
+ const boundary = reenters(transition)
985
+ ? !choiceResolvesToActiveAncestor
986
+ ? broadenBoundary(index, naturalBoundary, getState(index, transition.source).parent)
987
+ : getState(index, transition.source).parent
988
+ : naturalBoundary
989
+ const historyTarget = transition.target === undefined ? undefined : getState(index, transition.target)
990
+ const reenteredHistoryOwner = historyTarget?.node._tag === "History" && reenters(transition) &&
991
+ historyTarget.parent !== undefined && before.activePaths.includes(historyTarget.parent)
992
+ return {
993
+ ...selection,
994
+ next,
995
+ targetPath,
996
+ changed,
997
+ exitPaths: reenteredHistoryOwner
998
+ ? lifecyclePaths(
999
+ index,
1000
+ before.activePaths.filter((path) => isPathInSubtree(path, historyTarget.parent!)),
1001
+ undefined,
1002
+ "exit"
1003
+ )
1004
+ : lifecyclePaths(index, before.activePaths, boundary, "exit"),
1005
+ entryPaths: reenteredHistoryOwner
1006
+ ? lifecyclePaths(
1007
+ index,
1008
+ next.activePaths.filter((path) => isPathInSubtree(path, historyTarget.parent!)),
1009
+ undefined,
1010
+ "entry"
1011
+ )
1012
+ : lifecyclePaths(index, next.activePaths, boundary, "entry")
1013
+ }
1014
+ }
1015
+
1016
+ const hasPathIntersection = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
1017
+ left.some((path) => right.includes(path))
1018
+
1019
+ const sortByDocumentOrder = <A extends { readonly transition: FiniteTransition }>(
1020
+ index: ModelIndex,
1021
+ transitions: Iterable<A>
1022
+ ): ReadonlyArray<A> =>
1023
+ Array.from(transitions).sort((left, right) =>
1024
+ getState(index, left.transition.source).order - getState(index, right.transition.source).order
1025
+ )
1026
+
1027
+ const removeConflicts = (
1028
+ index: ModelIndex,
1029
+ transitions: ReadonlyArray<EvaluatedTransition>
1030
+ ): ReadonlyArray<EvaluatedTransition> => {
1031
+ const retained: Array<EvaluatedTransition> = []
1032
+ for (const transition of sortByDocumentOrder(index, transitions)) {
1033
+ let preempted = false
1034
+ const remove = new Set<EvaluatedTransition>()
1035
+ for (const other of retained) {
1036
+ if (!hasPathIntersection(transition.exitPaths, other.exitPaths)) continue
1037
+ if (isDescendant(index, transition.transition.source, other.transition.source)) {
1038
+ remove.add(other)
1039
+ } else {
1040
+ preempted = true
1041
+ break
1042
+ }
1043
+ }
1044
+ if (preempted) continue
1045
+ for (const other of remove) retained.splice(retained.indexOf(other), 1)
1046
+ retained.push(transition)
1047
+ }
1048
+ return retained
1049
+ }
1050
+
1051
+ const applySelections = (
1052
+ index: ModelIndex,
1053
+ before: ReferenceState,
1054
+ selected: ReadonlyArray<SelectedTransition>,
1055
+ event: string
1056
+ ): { readonly microstep: ReferenceMicrostep; readonly after: ReferenceState } => {
1057
+ const retained = removeConflicts(index, selected.map((selection) => evaluateTransition(index, before, selection)))
1058
+ const sorted = sortByDocumentOrder(index, retained)
1059
+ let next = before
1060
+ const applicationOrder = [
1061
+ ...sorted.filter((transition) => !transition.changed),
1062
+ ...sorted.filter((transition) => transition.changed)
1063
+ ]
1064
+ for (const evaluated of applicationOrder) {
1065
+ next = targetState(index, next, evaluated.transition, evaluated.leaf)
1066
+ }
1067
+ const changed = sorted.some((transition) => transition.changed)
1068
+ const exitPaths = lifecyclePaths(index, sorted.flatMap((transition) => transition.exitPaths), undefined, "exit")
1069
+ const entryPaths = lifecyclePaths(index, sorted.flatMap((transition) => transition.entryPaths), undefined, "entry")
1070
+ next = captureHistory(index, before, next, exitPaths)
1071
+ const enteredChoice = sorted.some(({ transition }) =>
1072
+ transition.target !== undefined && entryChoicePath(index, transition.target) !== undefined
1073
+ )
1074
+ if (enteredChoice) next = withControlOrder({ ...next, completions: [] }, controlOrder(next))
1075
+
1076
+ return {
1077
+ microstep: {
1078
+ next,
1079
+ event,
1080
+ transitions: sorted.flatMap(({ transition }) => {
1081
+ const choice = transition.target === undefined ? undefined : entryChoicePath(index, transition.target)
1082
+ return [
1083
+ transitionRecord(index, transition),
1084
+ ...(choice === undefined ? [] : choiceTransitionRecords(index, choice))
1085
+ ]
1086
+ }),
1087
+ exitPaths,
1088
+ entryPaths,
1089
+ changed
1090
+ },
1091
+ after: settleCompletions(index, next)
1092
+ }
1093
+ }
1094
+
1095
+ const automaticTransitions = (
1096
+ index: ModelIndex,
1097
+ initial: ReferenceState,
1098
+ event: string,
1099
+ completedBefore: ReadonlyArray<ReferenceCompletion>,
1100
+ refreshedByExit: ReadonlyArray<string> = []
1101
+ ): { readonly state: ReferenceState; readonly microsteps: ReadonlyArray<ReferenceMicrostep> } => {
1102
+ const microsteps: Array<ReferenceMicrostep> = []
1103
+ let current = initial
1104
+ let previousCompletions = completedBefore
1105
+ const newlyCompleted = (
1106
+ state: ReferenceState,
1107
+ previous: ReadonlyArray<ReferenceCompletion>,
1108
+ exited: ReadonlyArray<string>
1109
+ ): ReadonlyArray<ReferenceCompletion> =>
1110
+ state.completions.filter((completion) =>
1111
+ (!previous.some(({ path }) => path === completion.path) || exited.includes(completion.path)) &&
1112
+ index.transitions.has(`${completion.path}\u0000${triggerKey({ type: "done" })}`)
1113
+ )
1114
+ const pendingDone: Array<ReferenceCompletion> = [
1115
+ ...newlyCompleted(current, previousCompletions, refreshedByExit)
1116
+ ]
1117
+ let shouldRunAlways = true
1118
+ let iterations = 0
1119
+
1120
+ while (true) {
1121
+ iterations += 1
1122
+ if (iterations > 1000) {
1123
+ throw new Error("MachineTest.interpretModel detected an infinite automatic-transition cycle")
1124
+ }
1125
+
1126
+ const completion = pendingDone.shift()
1127
+ if (completion !== undefined && current.activePaths.includes(completion.path)) {
1128
+ const transition = index.transitions.get(`${completion.path}\u0000${triggerKey({ type: "done" })}`)!
1129
+ const applied = applySelections(index, current, [{ transition, leaf: completion.path }], event)
1130
+ microsteps.push(applied.microstep)
1131
+ previousCompletions = current.completions
1132
+ current = applied.after
1133
+ pendingDone.push(...newlyCompleted(current, previousCompletions, applied.microstep.exitPaths))
1134
+ shouldRunAlways = applied.microstep.changed
1135
+ continue
1136
+ }
1137
+
1138
+ if (current.status === "done") break
1139
+ const always = shouldRunAlways ? selectTransitions(index, current, { type: "always" }) : []
1140
+ if (always.length === 0) break
1141
+ const applied = applySelections(index, current, always, event)
1142
+ microsteps.push(applied.microstep)
1143
+ previousCompletions = current.completions
1144
+ current = applied.after
1145
+ pendingDone.push(...newlyCompleted(current, previousCompletions, applied.microstep.exitPaths))
1146
+ shouldRunAlways = applied.microstep.changed
1147
+ }
1148
+
1149
+ return { state: current, microsteps }
1150
+ }
1151
+
1152
+ const stepModel = (
1153
+ index: ModelIndex,
1154
+ before: ReferenceState,
1155
+ event: string,
1156
+ stepIndex: number
1157
+ ): ReferenceStep => {
1158
+ // Public events delivered after terminal completion are observed but cannot
1159
+ // select another transition.
1160
+ if (before.status === "done") {
1161
+ return {
1162
+ index: stepIndex,
1163
+ event,
1164
+ before,
1165
+ microsteps: [],
1166
+ after: before,
1167
+ done: true,
1168
+ output: before.output
1169
+ }
1170
+ }
1171
+
1172
+ const selected = selectTransitions(index, before, { type: "event", event })
1173
+ if (selected.length === 0) {
1174
+ return {
1175
+ index: stepIndex,
1176
+ event,
1177
+ before,
1178
+ microsteps: [],
1179
+ after: before,
1180
+ done: false,
1181
+ output: undefined
1182
+ }
1183
+ }
1184
+
1185
+ const applied = applySelections(index, before, selected, event)
1186
+ const stabilized = automaticTransitions(
1187
+ index,
1188
+ applied.after,
1189
+ event,
1190
+ before.completions,
1191
+ applied.microstep.exitPaths
1192
+ )
1193
+ return {
1194
+ index: stepIndex,
1195
+ event,
1196
+ before,
1197
+ microsteps: [applied.microstep, ...stabilized.microsteps],
1198
+ after: stabilized.state,
1199
+ done: stabilized.state.status === "done",
1200
+ output: stabilized.state.output
1201
+ }
1202
+ }
1203
+
1204
+ /**
1205
+ * Purely interprets a hierarchical finite model without compiling or
1206
+ * executing a `Machine`.
1207
+ *
1208
+ * @category verification
1209
+ * @since 0.4.0
1210
+ */
1211
+ export const interpretModel = (
1212
+ model: FiniteModel,
1213
+ events: ReadonlyArray<string>
1214
+ ): ReferenceTrace => {
1215
+ const index = indexModel(model)
1216
+ const initialRoot = getState(index, model.initial)
1217
+ if (initialRoot.parent !== undefined) {
1218
+ throw new Error(`MachineTest.interpretModel expected initial state "${model.initial}" to be a root`)
1219
+ }
1220
+ const startingState = makeUnsettledState(index, model.initial)
1221
+ const initialState = settleCompletions(index, startingState)
1222
+ const initialChoiceTransitions = initialChoiceTransitionRecords(index, model.initial)
1223
+ const stabilizedInitial = automaticTransitions(index, initialState, "InitialEvent", [])
1224
+ const initial: ReferenceInitialStep = {
1225
+ startingState,
1226
+ initialEntryPaths: startingState.activePaths,
1227
+ state: stabilizedInitial.state,
1228
+ microsteps: [
1229
+ ...(initialChoiceTransitions.length === 0 ? [] : [{
1230
+ next: startingState,
1231
+ event: "InitialEvent",
1232
+ transitions: initialChoiceTransitions,
1233
+ exitPaths: [],
1234
+ entryPaths: [],
1235
+ changed: false
1236
+ }]),
1237
+ ...stabilizedInitial.microsteps
1238
+ ],
1239
+ done: stabilizedInitial.state.status === "done",
1240
+ output: stabilizedInitial.state.output
1241
+ }
1242
+ const steps: Array<ReferenceStep> = []
1243
+ let current = stabilizedInitial.state
1244
+ for (let eventIndex = 0; eventIndex < events.length; eventIndex++) {
1245
+ const step = stepModel(index, current, events[eventIndex]!, eventIndex)
1246
+ steps.push(step)
1247
+ current = step.after
1248
+ }
1249
+ return {
1250
+ events: events.slice(),
1251
+ initial,
1252
+ steps,
1253
+ final: current
1254
+ }
1255
+ }
1256
+
1257
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
1258
+ typeof value === "object" && value !== null && !Array.isArray(value)
1259
+
1260
+ interface ActualStateProjection {
1261
+ readonly activePaths: ReadonlyArray<string>
1262
+ readonly values: Readonly<Record<string, unknown>>
1263
+ readonly completions: ReadonlyArray<unknown>
1264
+ readonly history: unknown
1265
+ }
1266
+
1267
+ const projectState = (snapshot: unknown): ActualStateProjection => {
1268
+ const activePaths: Array<string> = []
1269
+ const values: Record<string, unknown> = {}
1270
+ const visit = (current: unknown): void => {
1271
+ if (!isRecord(current)) return
1272
+ if (typeof current.path === "string") {
1273
+ activePaths.push(current.path)
1274
+ values[current.path] = current.value
1275
+ }
1276
+ if (current.state !== undefined) visit(current.state)
1277
+ if (isRecord(current.states)) {
1278
+ for (const child of Object.values(current.states)) visit(child)
1279
+ }
1280
+ }
1281
+ visit(snapshot)
1282
+ return {
1283
+ activePaths,
1284
+ values,
1285
+ completions: isRecord(snapshot) && Array.isArray(snapshot.completed) ? snapshot.completed : [],
1286
+ history: isRecord(snapshot) && isRecord(snapshot.history) ? snapshot.history : {}
1287
+ }
1288
+ }
1289
+
1290
+ const canonicalize = (value: unknown): unknown => {
1291
+ if (value === undefined) return { $undefined: true }
1292
+ if (Array.isArray(value)) return value.map(canonicalize)
1293
+ if (!isRecord(value)) return value
1294
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]))
1295
+ }
1296
+
1297
+ const equal = (left: unknown, right: unknown): boolean =>
1298
+ JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right))
1299
+
1300
+ const get = (value: unknown, key: string): unknown => isRecord(value) ? value[key] : undefined
1301
+
1302
+ const array = (value: unknown): ReadonlyArray<unknown> => Array.isArray(value) ? value : []
1303
+
1304
+ const eventTag = (value: unknown): string | undefined => {
1305
+ if (typeof value === "string") return value
1306
+ const tag = get(value, "_tag")
1307
+ return typeof tag === "string" ? tag : undefined
1308
+ }
1309
+
1310
+ const completionOrderIndependent = (values: ReadonlyArray<unknown>): ReadonlyArray<unknown> =>
1311
+ values.slice().sort((left, right) => {
1312
+ const leftPath = get(left, "path")
1313
+ const rightPath = get(right, "path")
1314
+ return String(leftPath).localeCompare(String(rightPath))
1315
+ })
1316
+
1317
+ const historyOrderIndependent = (value: unknown): unknown => {
1318
+ if (!isRecord(value)) return value
1319
+ return Object.fromEntries(
1320
+ Object.keys(value).sort().map((path) => {
1321
+ const record = value[path]
1322
+ if (!isRecord(record)) return [path, record]
1323
+ const active = array(record.active).slice().sort((left, right) => String(left).localeCompare(String(right)))
1324
+ return [path, { mode: record.mode, active, values: record.values }]
1325
+ })
1326
+ )
1327
+ }
1328
+
1329
+ const projectTransition = (value: unknown): unknown => {
1330
+ if (!isRecord(value)) return value
1331
+ const trigger = get(value, "trigger")
1332
+ return {
1333
+ source: value.source,
1334
+ trigger: isRecord(trigger)
1335
+ ? { type: trigger.type, ...(trigger.type === "event" ? { event: trigger.event } : {}) }
1336
+ : trigger,
1337
+ reenter: value.reenter,
1338
+ target: value.target,
1339
+ resolvedTarget: value.resolvedTarget
1340
+ }
1341
+ }
1342
+
1343
+ /**
1344
+ * Compares an opaque executable planner trace with the independent finite
1345
+ * reference interpreter. The function accumulates every mismatch so shrunk
1346
+ * counterexamples preserve the full semantic difference.
1347
+ *
1348
+ * @internal The public wrapper narrows `actualTrace` to `MachineTest.Trace`.
1349
+ */
1350
+ export const verifyModelTrace = (
1351
+ model: FiniteModel,
1352
+ actualTrace: unknown
1353
+ ): Effect.Effect<void, ModelVerificationError> => {
1354
+ const mismatches: Array<ModelVerificationMismatch> = []
1355
+ const add = (
1356
+ location: ModelVerificationLocation,
1357
+ field: ModelVerificationField,
1358
+ expected: unknown,
1359
+ actual: unknown
1360
+ ): void => {
1361
+ if (equal(expected, actual)) return
1362
+ mismatches.push({
1363
+ location,
1364
+ field,
1365
+ expected,
1366
+ actual,
1367
+ message: `${field} differs from the independent finite-model interpretation`
1368
+ })
1369
+ }
1370
+
1371
+ const scenario = get(actualTrace, "scenario")
1372
+ const actualEvents = array(get(scenario, "events"))
1373
+ const tags = actualEvents.map(eventTag)
1374
+ for (let index = 0; index < tags.length; index++) {
1375
+ if (tags[index] === undefined) {
1376
+ add({ phase: "event", eventIndex: index }, "event.tag", "a string _tag", tags[index])
1377
+ }
1378
+ }
1379
+ const reference = interpretModel(model, tags.map((tag) => tag ?? "<invalid-event>"))
1380
+ const actualInitial = get(actualTrace, "initial")
1381
+ const actualInitialPlan = get(actualInitial, "plan")
1382
+ const initialLocation: ModelVerificationLocation = { phase: "initial" }
1383
+
1384
+ const compareState = (
1385
+ location: ModelVerificationLocation,
1386
+ field: ModelStateField,
1387
+ expected: ReferenceState,
1388
+ actual: unknown
1389
+ ): void => {
1390
+ const projected = projectState(actual)
1391
+ add(location, `${field}.activePaths`, expected.activePaths, projected.activePaths)
1392
+ add(location, `${field}.values`, expected.values, projected.values)
1393
+ // Completion records are a logical cache. Their array insertion order can
1394
+ // change when an unaffected parallel region is copied through a target,
1395
+ // but path/output membership is the observable semantic contract.
1396
+ add(
1397
+ location,
1398
+ `${field}.completions`,
1399
+ completionOrderIndependent(expected.completions),
1400
+ completionOrderIndependent(projected.completions)
1401
+ )
1402
+ add(
1403
+ location,
1404
+ `${field}.history`,
1405
+ historyOrderIndependent(expected.history),
1406
+ historyOrderIndependent(projected.history)
1407
+ )
1408
+ }
1409
+
1410
+ compareState(
1411
+ initialLocation,
1412
+ "initial.startingState",
1413
+ reference.initial.startingState,
1414
+ get(actualInitial, "startingState")
1415
+ )
1416
+ compareState(
1417
+ initialLocation,
1418
+ "initial.plan.startingState",
1419
+ reference.initial.startingState,
1420
+ get(actualInitialPlan, "startingState")
1421
+ )
1422
+ add(
1423
+ initialLocation,
1424
+ "initial.startingConfiguration",
1425
+ reference.initial.startingState.activePaths,
1426
+ get(actualInitial, "startingConfiguration")
1427
+ )
1428
+ add(
1429
+ initialLocation,
1430
+ "initial.initialEntryPaths",
1431
+ reference.initial.initialEntryPaths,
1432
+ get(actualInitial, "initialEntryPaths")
1433
+ )
1434
+ add(
1435
+ initialLocation,
1436
+ "initial.plan.initialEntryPaths",
1437
+ reference.initial.initialEntryPaths,
1438
+ get(actualInitialPlan, "initialEntryPaths")
1439
+ )
1440
+ compareState(initialLocation, "initial.plan.state", reference.initial.state, get(actualInitialPlan, "state"))
1441
+ add(
1442
+ initialLocation,
1443
+ "initial.configuration",
1444
+ reference.initial.state.activePaths,
1445
+ get(actualInitial, "configuration")
1446
+ )
1447
+ add(
1448
+ initialLocation,
1449
+ "initial.plan.microsteps",
1450
+ reference.initial.microsteps.length,
1451
+ array(get(actualInitialPlan, "microsteps")).length
1452
+ )
1453
+ add(initialLocation, "initial.plan.done", reference.initial.done, get(actualInitialPlan, "done"))
1454
+ add(initialLocation, "initial.plan.output", reference.initial.output, get(actualInitialPlan, "output"))
1455
+
1456
+ const actualSteps = array(get(actualTrace, "steps"))
1457
+ add({ phase: "final" }, "trace.steps.length", reference.steps.length, actualSteps.length)
1458
+ for (let stepIndex = 0; stepIndex < reference.steps.length; stepIndex++) {
1459
+ const expected = reference.steps[stepIndex]!
1460
+ const actual = actualSteps[stepIndex]
1461
+ const location: ModelVerificationLocation = { phase: "event", eventIndex: stepIndex }
1462
+ const actualPlan = get(actual, "plan")
1463
+ add(location, "step.index", expected.index, get(actual, "index"))
1464
+ add(location, "step.event", expected.event, eventTag(get(actual, "event")))
1465
+ compareState(location, "step.before", expected.before, get(actual, "before"))
1466
+ add(location, "step.beforeConfiguration", expected.before.activePaths, get(actual, "beforeConfiguration"))
1467
+
1468
+ const actualMicrosteps = array(get(actualPlan, "microsteps"))
1469
+ add(location, "step.plan.microsteps.length", expected.microsteps.length, actualMicrosteps.length)
1470
+ for (let microstepIndex = 0; microstepIndex < expected.microsteps.length; microstepIndex++) {
1471
+ const expectedMicrostep = expected.microsteps[microstepIndex]!
1472
+ const actualMicrostep = actualMicrosteps[microstepIndex]
1473
+ const microstepLocation: ModelVerificationLocation = {
1474
+ phase: "event",
1475
+ eventIndex: stepIndex,
1476
+ microstepIndex
1477
+ }
1478
+ compareState(microstepLocation, "microstep.next", expectedMicrostep.next, get(actualMicrostep, "next"))
1479
+ add(microstepLocation, "microstep.event", expectedMicrostep.event, eventTag(get(actualMicrostep, "event")))
1480
+ add(
1481
+ microstepLocation,
1482
+ "microstep.transitions",
1483
+ expectedMicrostep.transitions,
1484
+ array(get(actualMicrostep, "transitions")).map(projectTransition)
1485
+ )
1486
+ add(microstepLocation, "microstep.exitPaths", expectedMicrostep.exitPaths, get(actualMicrostep, "exitPaths"))
1487
+ add(microstepLocation, "microstep.entryPaths", expectedMicrostep.entryPaths, get(actualMicrostep, "entryPaths"))
1488
+ add(microstepLocation, "microstep.changed", expectedMicrostep.changed, get(actualMicrostep, "changed"))
1489
+ }
1490
+
1491
+ compareState(location, "step.plan.next", expected.after, get(actualPlan, "next"))
1492
+ compareState(location, "step.after", expected.after, get(actual, "after"))
1493
+ add(location, "step.afterConfiguration", expected.after.activePaths, get(actual, "afterConfiguration"))
1494
+ add(location, "step.plan.done", expected.done, get(actualPlan, "done"))
1495
+ add(location, "step.plan.output", expected.output, get(actualPlan, "output"))
1496
+ }
1497
+
1498
+ const finalLocation: ModelVerificationLocation = { phase: "final" }
1499
+ compareState(finalLocation, "trace.final", reference.final, get(actualTrace, "final"))
1500
+ add(finalLocation, "trace.finalConfiguration", reference.final.activePaths, get(actualTrace, "finalConfiguration"))
1501
+
1502
+ return mismatches.length === 0
1503
+ ? Effect.void
1504
+ : Effect.fail(new ModelVerificationError({ mismatches }))
1505
+ }