@typeonce/effect-machine 0.6.0 → 0.7.0

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 (54) hide show
  1. package/README.md +4 -3
  2. package/dist/Machine.d.ts +24 -6
  3. package/dist/Machine.d.ts.map +1 -1
  4. package/dist/Machine.js.map +1 -1
  5. package/dist/internal/machine/atom.d.ts +5 -0
  6. package/dist/internal/machine/atom.d.ts.map +1 -1
  7. package/dist/internal/machine/atom.js +6 -3
  8. package/dist/internal/machine/atom.js.map +1 -1
  9. package/dist/internal/machine/machine.d.ts.map +1 -1
  10. package/dist/internal/machine/machine.js +1 -1
  11. package/dist/internal/machine/machine.js.map +1 -1
  12. package/dist/unstable/reactivity/AtomMachine.d.ts +26 -0
  13. package/dist/unstable/reactivity/AtomMachine.d.ts.map +1 -1
  14. package/dist/unstable/reactivity/AtomMachine.js +23 -0
  15. package/dist/unstable/reactivity/AtomMachine.js.map +1 -1
  16. package/docs/agent-guide.md +14 -0
  17. package/package.json +5 -5
  18. package/src/Machine.ts +6909 -0
  19. package/src/index.ts +1 -0
  20. package/src/internal/machine/activities.ts +108 -0
  21. package/src/internal/machine/atom.ts +684 -0
  22. package/src/internal/machine/cluster.ts +394 -0
  23. package/src/internal/machine/command.ts +58 -0
  24. package/src/internal/machine/commandRuntime.ts +43 -0
  25. package/src/internal/machine/configuration.ts +1331 -0
  26. package/src/internal/machine/errors.ts +87 -0
  27. package/src/internal/machine/executionPlan.ts +996 -0
  28. package/src/internal/machine/invocation.ts +119 -0
  29. package/src/internal/machine/machine.ts +1750 -0
  30. package/src/internal/machine/planner.ts +1933 -0
  31. package/src/internal/machine/process.ts +906 -0
  32. package/src/internal/machine/protocol.ts +322 -0
  33. package/src/internal/machine/readiness.ts +10 -0
  34. package/src/internal/machine/runtime.ts +2512 -0
  35. package/src/internal/machine/serialization.ts +498 -0
  36. package/src/internal/machine/stateDefinition.ts +270 -0
  37. package/src/internal/machine/symbols.ts +2 -0
  38. package/src/internal/machine/topology.ts +479 -0
  39. package/src/internal/testing/machine/arbitrary.ts +102 -0
  40. package/src/internal/testing/machine/exploration.ts +331 -0
  41. package/src/internal/testing/machine/finiteModel.ts +1498 -0
  42. package/src/internal/testing/machine/invariant.ts +372 -0
  43. package/src/internal/testing/machine/probe.ts +79 -0
  44. package/src/internal/testing/machine/referenceModel.ts +1505 -0
  45. package/src/internal/testing/machine/runtime.ts +1710 -0
  46. package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
  47. package/src/internal/testing/machine/trace.ts +150 -0
  48. package/src/internal/testing/machine/verification.ts +1890 -0
  49. package/src/testing/MachineTest.ts +2067 -0
  50. package/src/testing/index.ts +7 -0
  51. package/src/unstable/cluster/ClusterMachine.ts +390 -0
  52. package/src/unstable/cluster/index.ts +1 -0
  53. package/src/unstable/reactivity/AtomMachine.ts +696 -0
  54. package/src/unstable/reactivity/index.ts +1 -0
@@ -0,0 +1,1498 @@
1
+ /**
2
+ * Finite hierarchical, parallel, history, choice, and automatic-transition
3
+ * statechart models used by the public testing module.
4
+ *
5
+ * This module intentionally compiles through the public Machine API. It must
6
+ * not share planner helpers with the implementation that later reference
7
+ * models are expected to check.
8
+ *
9
+ * @internal
10
+ */
11
+
12
+ import * as Schema from "effect/Schema"
13
+ import { FastCheck } from "effect/testing"
14
+ import * as Machine from "../../../Machine.js"
15
+
16
+ /**
17
+ * An atomic state in a finite generated model.
18
+ *
19
+ * @category models
20
+ * @since 0.4.0
21
+ */
22
+ export interface FiniteAtomicState {
23
+ readonly _tag: "Atomic"
24
+ readonly key: string
25
+ /** Deterministic payload accepted by this state's generated schema. */
26
+ readonly value: number
27
+ }
28
+
29
+ /**
30
+ * A final state in a finite generated model.
31
+ *
32
+ * @category models
33
+ * @since 0.4.0
34
+ */
35
+ export interface FiniteFinalState {
36
+ readonly _tag: "Final"
37
+ readonly key: string
38
+ /** Deterministic payload accepted by this state's generated schema. */
39
+ readonly value: number
40
+ /** Deterministic value returned by this state's output handler. */
41
+ readonly output: string
42
+ }
43
+
44
+ /**
45
+ * A compound state in a finite generated model.
46
+ *
47
+ * @category models
48
+ * @since 0.4.0
49
+ */
50
+ export interface FiniteCompoundState {
51
+ readonly _tag: "Compound"
52
+ readonly key: string
53
+ /** Deterministic payload accepted by this state's generated schema. */
54
+ readonly value: number
55
+ /** Key of the direct child entered by default. */
56
+ readonly initial: string
57
+ readonly states: ReadonlyArray<FiniteState>
58
+ }
59
+
60
+ /**
61
+ * A parallel state in a finite generated model.
62
+ *
63
+ * Every direct child is an orthogonal region and is active whenever the
64
+ * parallel state is active. The output is deterministic so completion can be
65
+ * compared without sharing executable callbacks with the reference model.
66
+ *
67
+ * @category models
68
+ * @since 0.4.0
69
+ */
70
+ export interface FiniteParallelState {
71
+ readonly _tag: "Parallel"
72
+ readonly key: string
73
+ /** Deterministic payload accepted by this state's generated schema. */
74
+ readonly value: number
75
+ /** Deterministic value returned after every region completes. */
76
+ readonly output: string
77
+ /** Between two and three orthogonal region nodes. */
78
+ readonly states: ReadonlyArray<FiniteState>
79
+ }
80
+
81
+ /**
82
+ * A shallow or deep history pseudo-state in a finite generated model.
83
+ *
84
+ * History states never carry values, become active, or act as transition
85
+ * sources. `fallback` is a concrete descendant of the direct compound or
86
+ * parallel owner and is used only before that history register is captured.
87
+ *
88
+ * @category models
89
+ * @since 0.4.0
90
+ */
91
+ export interface FiniteHistoryState {
92
+ readonly _tag: "History"
93
+ readonly key: string
94
+ readonly history: "shallow" | "deep"
95
+ readonly fallback: string
96
+ }
97
+
98
+ /** A deterministic transient choice pseudo-state in a finite model. */
99
+ export interface FiniteChoiceState {
100
+ readonly _tag: "Choice"
101
+ readonly key: string
102
+ readonly targets: ReadonlyArray<string>
103
+ readonly selected: string
104
+ }
105
+
106
+ /**
107
+ * A finite model state.
108
+ *
109
+ * @category models
110
+ * @since 0.4.0
111
+ */
112
+ export type FiniteState =
113
+ | FiniteAtomicState
114
+ | FiniteFinalState
115
+ | FiniteCompoundState
116
+ | FiniteParallelState
117
+ | FiniteHistoryState
118
+ | FiniteChoiceState
119
+
120
+ /**
121
+ * The single trigger representation used by generated and hand-authored finite
122
+ * transitions. Event, always, and completion registrations therefore share the
123
+ * same validation, compilation, and reference interpretation path.
124
+ *
125
+ * @category models
126
+ * @since 0.4.0
127
+ */
128
+ export type FiniteTransitionTrigger =
129
+ | { readonly type: "event"; readonly event: string }
130
+ | { readonly type: "always" }
131
+ | { readonly type: "done" }
132
+
133
+ /**
134
+ * One deterministic transition in a finite generated model.
135
+ *
136
+ * @category models
137
+ * @since 0.4.0
138
+ */
139
+ interface FiniteTransitionBase {
140
+ readonly source: string
141
+ /** Omission represents a targetless transition. */
142
+ readonly target?: string
143
+ /** Optional schema-valid value supplied for the declared target state. */
144
+ readonly targetValue?: number
145
+ }
146
+
147
+ /**
148
+ * A public event transition may explicitly request source reentry.
149
+ *
150
+ * @category models
151
+ * @since 0.4.0
152
+ */
153
+ export type FiniteEventTransition = FiniteTransitionBase & {
154
+ readonly trigger: Extract<FiniteTransitionTrigger, { readonly type: "event" }>
155
+ readonly reenter: boolean
156
+ }
157
+
158
+ /**
159
+ * An always or completion transition. Automatic transitions deliberately omit
160
+ * the event-only reentry option.
161
+ *
162
+ * @category models
163
+ * @since 0.4.0
164
+ */
165
+ export type FiniteAutomaticTransition = FiniteTransitionBase & {
166
+ readonly trigger: Exclude<FiniteTransitionTrigger, { readonly type: "event" }>
167
+ }
168
+
169
+ /**
170
+ * One deterministic event or automatic transition in a finite model.
171
+ *
172
+ * @category models
173
+ * @since 0.4.0
174
+ */
175
+ export type FiniteTransition = FiniteEventTransition | FiniteAutomaticTransition
176
+
177
+ /**
178
+ * The exact generated transition that changes a value before history capture.
179
+ *
180
+ * The source and target are the same active atomic state. `value` is distinct
181
+ * from that state's generated default value.
182
+ *
183
+ * @category models
184
+ * @since 0.4.0
185
+ */
186
+ export interface FiniteHistoryMutation {
187
+ readonly source: string
188
+ readonly event: string
189
+ readonly target: string
190
+ readonly value: number
191
+ }
192
+
193
+ /**
194
+ * One exact transition in a generated history witness.
195
+ *
196
+ * @category models
197
+ * @since 0.4.0
198
+ */
199
+ export interface FiniteHistoryTransfer {
200
+ readonly source: string
201
+ readonly event: string
202
+ readonly target: string
203
+ }
204
+
205
+ /**
206
+ * A replayable value-mutation, capture, and restoration witness generated for
207
+ * one history pseudo-state.
208
+ *
209
+ * `events` is exactly `[mutation.event, leave.event, resume.event]`. Replaying
210
+ * it changes a schema-valid atomic value, exits the history owner through its
211
+ * root, and restores the remembered non-default value through `history`.
212
+ *
213
+ * @category models
214
+ * @since 0.4.0
215
+ */
216
+ export interface FiniteHistoryScenario {
217
+ readonly history: string
218
+ readonly owner: string
219
+ readonly historyType: "shallow" | "deep"
220
+ readonly mutation: FiniteHistoryMutation
221
+ readonly leave: FiniteHistoryTransfer
222
+ readonly resume: FiniteHistoryTransfer
223
+ readonly events: readonly [mutation: string, leave: string, resume: string]
224
+ }
225
+
226
+ /**
227
+ * A small immutable statechart model suitable for generation and shrinking.
228
+ *
229
+ * State paths use dot-separated state keys. Transitions are unique by
230
+ * source/event pair. A transition may target any state under its source root,
231
+ * or a different root as a complete configuration.
232
+ *
233
+ * @category models
234
+ * @since 0.4.0
235
+ */
236
+ export interface FiniteModel {
237
+ readonly roots: ReadonlyArray<FiniteState>
238
+ /** Key of the root entered during startup. */
239
+ readonly initial: string
240
+ readonly events: ReadonlyArray<string>
241
+ readonly transitions: ReadonlyArray<FiniteTransition>
242
+ /** Exact value-sensitive history witnesses attached by `finiteModels`. */
243
+ readonly historyScenarios?: ReadonlyArray<FiniteHistoryScenario>
244
+ }
245
+
246
+ /**
247
+ * Limits controlling finite model generation.
248
+ *
249
+ * @category models
250
+ * @since 0.4.0
251
+ */
252
+ export interface FiniteModelOptions {
253
+ /** Maximum number of root states. Always between one and three. */
254
+ readonly maxRoots?: 1 | 2 | 3
255
+ /** Maximum state-tree depth, counting a root as depth one. */
256
+ readonly maxDepth?: number
257
+ /** Maximum number of direct children in a compound state. */
258
+ readonly maxChildren?: number
259
+ /** Maximum number of regions in a parallel state. Always two or three. */
260
+ readonly maxParallelRegions?: 2 | 3
261
+ /** Maximum number of distinct public event tags. */
262
+ readonly maxEvents?: number
263
+ /** Maximum number of source/event transition registrations. */
264
+ readonly maxTransitions?: number
265
+ /** Maximum number of history pseudo-states added to one generated model. */
266
+ readonly maxHistoryStates?: number
267
+ /** Maximum number of deterministic choice witnesses added to a generated model. */
268
+ readonly maxChoiceStates?: number
269
+ }
270
+
271
+ /**
272
+ * Resolved limits and structural guarantees for a finite model arbitrary.
273
+ *
274
+ * @category models
275
+ * @since 0.4.0
276
+ */
277
+ export interface FiniteModelDiagnostics {
278
+ readonly limits: {
279
+ readonly maxRoots: 1 | 2 | 3
280
+ readonly maxDepth: number
281
+ readonly maxChildren: number
282
+ readonly maxParallelRegions: 2 | 3
283
+ readonly maxEvents: number
284
+ readonly maxTransitions: number
285
+ readonly maxHistoryStates: number
286
+ readonly maxChoiceStates: number
287
+ }
288
+ readonly guarantees: {
289
+ readonly compoundOnly: false
290
+ readonly parallelStates: true
291
+ readonly historyStates: true
292
+ readonly historyLeaveResumeSequences: true
293
+ readonly historyValueScenarios: true
294
+ readonly choiceStates: true
295
+ readonly choiceInitialWitnesses: true
296
+ readonly structurallyValid: true
297
+ readonly shrinkPreservesValidity: true
298
+ readonly eventlessTransitions: true
299
+ readonly acyclicAutomaticTransitions: true
300
+ }
301
+ }
302
+
303
+ /**
304
+ * A finite model arbitrary and the exact limits used to construct it.
305
+ *
306
+ * @category models
307
+ * @since 0.4.0
308
+ */
309
+ export interface FiniteModels {
310
+ readonly arbitrary: FastCheck.Arbitrary<FiniteModel>
311
+ readonly diagnostics: FiniteModelDiagnostics
312
+ }
313
+
314
+ interface RawAtomicState {
315
+ readonly _tag: "Atomic"
316
+ }
317
+
318
+ interface RawFinalState {
319
+ readonly _tag: "Final"
320
+ }
321
+
322
+ interface RawCompoundState {
323
+ readonly _tag: "Compound"
324
+ readonly initialIndex: number
325
+ readonly states: ReadonlyArray<RawState>
326
+ }
327
+
328
+ interface RawParallelState {
329
+ readonly _tag: "Parallel"
330
+ readonly states: ReadonlyArray<RawState>
331
+ }
332
+
333
+ type RawState = RawAtomicState | RawFinalState | RawCompoundState | RawParallelState
334
+
335
+ interface FlatFiniteState {
336
+ readonly node: FiniteState
337
+ readonly path: string
338
+ readonly parent: string | undefined
339
+ readonly root: string
340
+ }
341
+
342
+ interface TransitionCandidate {
343
+ readonly source: string
344
+ readonly trigger: FiniteTransitionTrigger
345
+ readonly targets: ReadonlyArray<string>
346
+ }
347
+
348
+ const triggerKey = (trigger: FiniteTransitionTrigger): string =>
349
+ trigger.type === "event" ? `event:${trigger.event}` : trigger.type
350
+
351
+ const defaults = {
352
+ maxRoots: 3 as const,
353
+ maxDepth: 3,
354
+ maxChildren: 3,
355
+ maxParallelRegions: 3 as const,
356
+ maxEvents: 3,
357
+ maxTransitions: 12,
358
+ maxHistoryStates: 2,
359
+ maxChoiceStates: 1
360
+ }
361
+
362
+ const validateLimit = (name: string, value: number, maximum: number): void => {
363
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
364
+ throw new Error(`MachineTest.finiteModels expected ${name} to be an integer between 1 and ${maximum}`)
365
+ }
366
+ }
367
+
368
+ const resolveOptions = (options: FiniteModelOptions): FiniteModelDiagnostics["limits"] => {
369
+ const limits = {
370
+ maxRoots: options.maxRoots ?? defaults.maxRoots,
371
+ maxDepth: options.maxDepth ?? defaults.maxDepth,
372
+ maxChildren: options.maxChildren ?? defaults.maxChildren,
373
+ maxParallelRegions: options.maxParallelRegions ?? defaults.maxParallelRegions,
374
+ maxEvents: options.maxEvents ?? defaults.maxEvents,
375
+ maxTransitions: options.maxTransitions ?? defaults.maxTransitions,
376
+ maxHistoryStates: options.maxHistoryStates ?? defaults.maxHistoryStates,
377
+ maxChoiceStates: options.maxChoiceStates ?? defaults.maxChoiceStates
378
+ }
379
+ validateLimit("maxRoots", limits.maxRoots, 3)
380
+ validateLimit("maxDepth", limits.maxDepth, 6)
381
+ validateLimit("maxChildren", limits.maxChildren, 4)
382
+ validateLimit("maxParallelRegions", limits.maxParallelRegions, 3)
383
+ if (limits.maxParallelRegions < 2) {
384
+ throw new Error("MachineTest.finiteModels expected maxParallelRegions to be two or three")
385
+ }
386
+ validateLimit("maxEvents", limits.maxEvents, 8)
387
+ validateLimit("maxTransitions", limits.maxTransitions, 256)
388
+ if (!Number.isSafeInteger(limits.maxHistoryStates) || limits.maxHistoryStates < 0 || limits.maxHistoryStates > 8) {
389
+ throw new Error("MachineTest.finiteModels expected maxHistoryStates to be an integer between 0 and 8")
390
+ }
391
+ if (!Number.isSafeInteger(limits.maxChoiceStates) || limits.maxChoiceStates < 0 || limits.maxChoiceStates > 8) {
392
+ throw new Error("MachineTest.finiteModels expected maxChoiceStates to be an integer between 0 and 8")
393
+ }
394
+ return limits
395
+ }
396
+
397
+ const rawStateArbitrary = (
398
+ depth: number,
399
+ limits: FiniteModelDiagnostics["limits"]
400
+ ): FastCheck.Arbitrary<RawState> => {
401
+ const leaf = FastCheck.boolean().map((final): RawState => final ? { _tag: "Final" } : { _tag: "Atomic" })
402
+ if (depth >= limits.maxDepth) return leaf
403
+
404
+ const nested = rawStateArbitrary(depth + 1, limits)
405
+ const compound = FastCheck.array(nested, {
406
+ minLength: 1,
407
+ maxLength: limits.maxChildren
408
+ }).chain((states) =>
409
+ FastCheck.integer({ min: 0, max: states.length - 1 }).map((initialIndex): RawCompoundState => ({
410
+ _tag: "Compound",
411
+ initialIndex,
412
+ states
413
+ }))
414
+ )
415
+ const parallel = FastCheck.array(nested, {
416
+ minLength: 2,
417
+ maxLength: limits.maxParallelRegions
418
+ }).map((states): RawParallelState => ({ _tag: "Parallel", states }))
419
+ return FastCheck.oneof(leaf, compound, parallel)
420
+ }
421
+
422
+ const normalizeStates = (raw: ReadonlyArray<RawState>): ReadonlyArray<FiniteState> => {
423
+ let value = 0
424
+ const visit = (states: ReadonlyArray<RawState>, parentPath: string): ReadonlyArray<FiniteState> =>
425
+ states.map((state, index) => {
426
+ const key = `state${index}`
427
+ const path = parentPath === "" ? key : `${parentPath}.${key}`
428
+ const currentValue = value++
429
+ if (state._tag === "Atomic") {
430
+ return Object.freeze({ _tag: "Atomic", key, value: currentValue })
431
+ }
432
+ if (state._tag === "Final") {
433
+ return Object.freeze({
434
+ _tag: "Final",
435
+ key,
436
+ value: currentValue,
437
+ output: `output:${path}`
438
+ })
439
+ }
440
+ const children = visit(state.states, path)
441
+ if (state._tag === "Parallel") {
442
+ return Object.freeze({
443
+ _tag: "Parallel",
444
+ key,
445
+ value: currentValue,
446
+ output: `output:${path}`,
447
+ states: Object.freeze(children.slice())
448
+ })
449
+ }
450
+ return Object.freeze({
451
+ _tag: "Compound",
452
+ key,
453
+ value: currentValue,
454
+ initial: children[state.initialIndex]!.key,
455
+ states: Object.freeze(children.slice())
456
+ })
457
+ })
458
+ return Object.freeze(visit(raw, "").slice())
459
+ }
460
+
461
+ const flattenStates = (roots: ReadonlyArray<FiniteState>): ReadonlyArray<FlatFiniteState> => {
462
+ const flattened: Array<FlatFiniteState> = []
463
+ const visit = (states: ReadonlyArray<FiniteState>, parent: string | undefined, root: string | undefined): void => {
464
+ for (const node of states) {
465
+ const path = parent === undefined ? node.key : `${parent}.${node.key}`
466
+ const nodeRoot = root ?? path
467
+ flattened.push({ node, path, parent, root: nodeRoot })
468
+ if (node._tag === "Compound" || node._tag === "Parallel") visit(node.states, path, nodeRoot)
469
+ }
470
+ }
471
+ visit(roots, undefined, undefined)
472
+ return flattened
473
+ }
474
+
475
+ const isPathInSubtree = (path: string, root: string): boolean => path === root || path.startsWith(`${root}.`)
476
+
477
+ const initialLeaves = (
478
+ states: ReadonlyMap<string, FlatFiniteState>,
479
+ path: string
480
+ ): ReadonlyArray<string> => {
481
+ const state = states.get(path)!
482
+ if (state.node._tag === "Choice") return initialLeaves(states, state.node.selected)
483
+ if (state.node._tag === "Compound") {
484
+ return initialLeaves(states, `${path}.${state.node.initial}`)
485
+ }
486
+ if (state.node._tag === "Parallel") {
487
+ return state.node.states
488
+ .filter((child) => child._tag !== "History" && child._tag !== "Choice")
489
+ .flatMap((child) => initialLeaves(states, `${path}.${child.key}`))
490
+ }
491
+ return state.node._tag === "Atomic" ? [path] : []
492
+ }
493
+
494
+ const addGeneratedHistory = (
495
+ roots: ReadonlyArray<FiniteState>,
496
+ initial: string,
497
+ maxHistoryStates: number,
498
+ maxTransitions: number,
499
+ events: ReadonlyArray<string>,
500
+ decisions: ReadonlyArray<"shallow" | "deep">
501
+ ): {
502
+ readonly roots: ReadonlyArray<FiniteState>
503
+ readonly scenarios: ReadonlyArray<FiniteHistoryScenario>
504
+ } => {
505
+ if (maxHistoryStates === 0 || maxTransitions < 2 || decisions.length === 0) {
506
+ return { roots, scenarios: [] }
507
+ }
508
+ const flattened = flattenStates(roots)
509
+ const byPath = new Map(flattened.map((state) => [state.path, state]))
510
+ const initialActiveLeaves = initialLeaves(byPath, initial)
511
+ const initialActive = new Set(initialActiveLeaves.flatMap((leaf) => {
512
+ const parts = leaf.split(".")
513
+ return parts.map((_, index) => parts.slice(0, index + 1).join("."))
514
+ }))
515
+ const outside = roots
516
+ .filter((root) => root.key !== initial)
517
+ .map((root) => ({ root, leaves: initialLeaves(byPath, root.key) }))
518
+ .find(({ leaves }) => leaves.length > 0)
519
+ if (outside === undefined) return { roots, scenarios: [] }
520
+
521
+ const eligible = flattened.filter((state) =>
522
+ initialActive.has(state.path) &&
523
+ (state.node._tag === "Compound" || state.node._tag === "Parallel") &&
524
+ initialLeaves(byPath, state.path).length > 0
525
+ )
526
+ const count = Math.min(
527
+ maxHistoryStates,
528
+ decisions.length,
529
+ Math.floor(events.length / 2),
530
+ Math.floor(maxTransitions / 3),
531
+ eligible.length
532
+ )
533
+ const selected = eligible.slice(0, count).map((owner, index) => {
534
+ const composite = owner.node as FiniteCompoundState | FiniteParallelState
535
+ const directAtomic = composite._tag === "Compound"
536
+ ? composite.states.find((child) => child.key === composite.initial && child._tag === "Atomic")
537
+ : composite.states.find((child) => child._tag === "Atomic")
538
+ const historyType = decisions[index] === "shallow" && directAtomic !== undefined ? "shallow" : "deep"
539
+ const mutationSource = historyType === "shallow"
540
+ ? `${owner.path}.${directAtomic!.key}`
541
+ : initialLeaves(byPath, owner.path)[0]!
542
+ const fallbackChild = composite._tag === "Compound"
543
+ ? composite.states.find((child) => child.key === composite.initial)!
544
+ : composite.states.find((child) => child._tag !== "History")!
545
+ const history: FiniteHistoryState = Object.freeze({
546
+ _tag: "History",
547
+ key: `history${index}`,
548
+ history: historyType,
549
+ fallback: `${owner.path}.${fallbackChild.key}`
550
+ })
551
+ return { owner, history, mutationSource }
552
+ })
553
+ const byOwner = new Map(selected.map(({ history, owner }) => [owner.path, history]))
554
+ const decorate = (states: ReadonlyArray<FiniteState>, parent: string | undefined): ReadonlyArray<FiniteState> =>
555
+ Object.freeze(states.map((state): FiniteState => {
556
+ const path = parent === undefined ? state.key : `${parent}.${state.key}`
557
+ if (state._tag !== "Compound" && state._tag !== "Parallel") return state
558
+ const children = decorate(state.states, path)
559
+ const history = byOwner.get(path)
560
+ return Object.freeze({
561
+ ...state,
562
+ states: Object.freeze(history === undefined ? children.slice() : [...children, history])
563
+ })
564
+ }))
565
+ const decorated = decorate(roots, undefined)
566
+ return {
567
+ roots: decorated,
568
+ scenarios: selected.map(({ history, mutationSource, owner }, index): FiniteHistoryScenario => {
569
+ const historyPath = `${owner.path}.${history.key}`
570
+ const leaveEvent = events[index]!
571
+ const mutationEvent = events[count + index]!
572
+ const mutationValue = (byPath.get(mutationSource)!.node as FiniteAtomicState).value + 10_000 + index
573
+ return Object.freeze({
574
+ history: historyPath,
575
+ owner: owner.path,
576
+ historyType: history.history,
577
+ mutation: Object.freeze({
578
+ source: mutationSource,
579
+ event: mutationEvent,
580
+ target: mutationSource,
581
+ value: mutationValue
582
+ }),
583
+ leave: Object.freeze({
584
+ source: initialLeaves(byPath, owner.path)[0]!,
585
+ event: leaveEvent,
586
+ target: outside.root.key
587
+ }),
588
+ resume: Object.freeze({
589
+ source: outside.leaves[0]!,
590
+ event: leaveEvent,
591
+ target: historyPath
592
+ }),
593
+ events: Object.freeze([mutationEvent, leaveEvent, leaveEvent]) as readonly [string, string, string]
594
+ })
595
+ })
596
+ }
597
+ }
598
+
599
+ const addGeneratedChoices = (
600
+ roots: ReadonlyArray<FiniteState>,
601
+ maxChoiceStates: number
602
+ ): ReadonlyArray<FiniteState> => {
603
+ if (maxChoiceStates === 0) return roots
604
+ let remaining = maxChoiceStates
605
+ const visit = (
606
+ states: ReadonlyArray<FiniteState>,
607
+ parent: string | undefined,
608
+ insideParallel: boolean
609
+ ): ReadonlyArray<FiniteState> =>
610
+ Object.freeze(states.map((state): FiniteState => {
611
+ const path = parent === undefined ? state.key : `${parent}.${state.key}`
612
+ if (state._tag === "Compound") {
613
+ const children = visit(state.states, path, insideParallel)
614
+ if (remaining > 0 && !insideParallel) {
615
+ const concrete = children.filter((child) =>
616
+ child._tag !== "History" && child._tag !== "Choice" && child._tag !== "Parallel"
617
+ )
618
+ if (concrete.some((child) => child.key === state.initial)) {
619
+ remaining -= 1
620
+ const choice: FiniteChoiceState = Object.freeze({
621
+ _tag: "Choice",
622
+ key: `choice${remaining}`,
623
+ targets: Object.freeze(concrete.map((child) => `${path}.${child.key}`)),
624
+ selected: `${path}.${state.initial}`
625
+ })
626
+ return Object.freeze({
627
+ ...state,
628
+ initial: choice.key,
629
+ states: Object.freeze([...children, choice])
630
+ })
631
+ }
632
+ }
633
+ return Object.freeze({ ...state, states: children })
634
+ }
635
+ if (state._tag === "Parallel") return Object.freeze({ ...state, states: visit(state.states, path, true) })
636
+ return state
637
+ }))
638
+ return visit(roots, undefined, false)
639
+ }
640
+
641
+ const freezeModel = (
642
+ roots: ReadonlyArray<FiniteState>,
643
+ initial: string,
644
+ events: ReadonlyArray<string>,
645
+ transitions: ReadonlyArray<FiniteTransition>,
646
+ historyScenarios: ReadonlyArray<FiniteHistoryScenario> = []
647
+ ): FiniteModel =>
648
+ Object.freeze({
649
+ roots,
650
+ initial,
651
+ events: Object.freeze(events.slice()),
652
+ transitions: Object.freeze(transitions.map((transition) => Object.freeze(transition))),
653
+ historyScenarios: Object.freeze(historyScenarios.slice())
654
+ })
655
+
656
+ const makeTransitionArbitrary = (
657
+ roots: ReadonlyArray<FiniteState>,
658
+ initial: string,
659
+ events: ReadonlyArray<string>,
660
+ maxTransitions: number,
661
+ historyScenarios: ReadonlyArray<FiniteHistoryScenario> = []
662
+ ): FastCheck.Arbitrary<ReadonlyArray<FiniteTransition>> => {
663
+ const states = flattenStates(roots)
664
+ const sourceOrder = new Map(states.map((state, index) => [state.path, index]))
665
+ const stateByPath = new Map(states.map((state) => [state.path, state]))
666
+ const initiallyActive = new Set(
667
+ initialLeaves(stateByPath, initial).flatMap((leaf) => {
668
+ const parts = leaf.split(".")
669
+ return parts.map((_, index) => parts.slice(0, index + 1).join("."))
670
+ })
671
+ )
672
+ const triggerOrder = (trigger: FiniteTransitionTrigger): number =>
673
+ trigger.type === "event"
674
+ ? events.indexOf(trigger.event)
675
+ : events.length + (trigger.type === "always" ? 0 : 1)
676
+ const orderTransitions = (transitions: ReadonlyArray<FiniteTransition>): ReadonlyArray<FiniteTransition> =>
677
+ transitions.slice().sort((left, right) =>
678
+ sourceOrder.get(left.source)! - sourceOrder.get(right.source)! ||
679
+ triggerOrder(left.trigger) - triggerOrder(right.trigger)
680
+ )
681
+ const active = states.filter(({ node }) => node._tag !== "Final" && node._tag !== "History" && node._tag !== "Choice")
682
+ const mandatory = historyScenarios.flatMap((scenario): ReadonlyArray<FiniteTransition> => [
683
+ {
684
+ source: scenario.mutation.source,
685
+ trigger: { type: "event", event: scenario.mutation.event },
686
+ target: scenario.mutation.target,
687
+ targetValue: scenario.mutation.value,
688
+ reenter: false
689
+ },
690
+ {
691
+ source: scenario.leave.source,
692
+ trigger: { type: "event", event: scenario.leave.event },
693
+ target: scenario.leave.target,
694
+ reenter: false
695
+ },
696
+ {
697
+ source: scenario.resume.source,
698
+ trigger: { type: "event", event: scenario.resume.event },
699
+ target: scenario.resume.target,
700
+ reenter: false
701
+ }
702
+ ])
703
+ const mandatoryRegistrations = new Set(
704
+ mandatory.map(({ source, trigger }) => `${source}\u0000${triggerKey(trigger)}`)
705
+ )
706
+ const reservedEvents = new Set(
707
+ historyScenarios.flatMap(({ leave, mutation }) => [leave.event, mutation.event])
708
+ )
709
+ const eventCandidates = active.flatMap(({ path: source, root }) =>
710
+ events.map((event): TransitionCandidate => ({
711
+ source,
712
+ trigger: { type: "event", event },
713
+ // `branch` addresses the source root while `full` replaces another root.
714
+ // Same-root targets may select any state, including a parallel state or
715
+ // a compound state whose initial descent enters a parallel state. The
716
+ // compiler expands those targets into every required orthogonal region.
717
+ targets: states.filter((target) =>
718
+ target.node._tag !== "History" && (target.root === root || target.parent === undefined)
719
+ )
720
+ .map(({ path }) => path)
721
+ }))
722
+ ).filter(({ source, trigger }) =>
723
+ !mandatoryRegistrations.has(`${source}\u0000${triggerKey(trigger)}`) &&
724
+ (trigger.type !== "event" || !reservedEvents.has(trigger.event))
725
+ )
726
+ const exitsSourceTargets = (source: FlatFiniteState): ReadonlyArray<string> =>
727
+ states.filter((target, targetIndex) => {
728
+ if (
729
+ targetIndex <= sourceOrder.get(source.path)! || target.node._tag === "History" ||
730
+ target.node._tag === "Choice"
731
+ ) return false
732
+ // A generated automatic transition must make its source inactive. This
733
+ // gives the independent oracle a structurally acyclic witness instead of
734
+ // relying on runtime iteration bounds. A later sibling under a compound
735
+ // parent exits the source branch; a later root replaces the whole root.
736
+ if (source.parent === undefined) {
737
+ return target.parent === undefined && target.root !== source.root
738
+ }
739
+ return stateByPath.get(source.parent)?.node._tag === "Compound" && target.parent === source.parent
740
+ }).map(({ path }) => path)
741
+ const automaticCandidates: ReadonlyArray<TransitionCandidate> = historyScenarios.length === 0
742
+ ? [
743
+ ...active.flatMap((source): ReadonlyArray<TransitionCandidate> =>
744
+ source.node._tag !== "Atomic" ? [] : [{
745
+ source: source.path,
746
+ trigger: { type: "always" },
747
+ targets: exitsSourceTargets(source)
748
+ }]
749
+ ),
750
+ ...states.flatMap((source): ReadonlyArray<TransitionCandidate> =>
751
+ source.node._tag !== "Compound" && source.node._tag !== "Parallel" ? [] : [{
752
+ source: source.path,
753
+ trigger: { type: "done" },
754
+ targets: exitsSourceTargets(source)
755
+ }]
756
+ )
757
+ ].filter(({ targets }) => targets.length > 0)
758
+ : []
759
+ const completesOnEntry = (path: string): boolean => {
760
+ const state = stateByPath.get(path)!
761
+ if (state.node._tag === "Final") return true
762
+ if (state.node._tag === "Compound") {
763
+ const node = state.node
764
+ return node.states.some(({ key, _tag }) => key === node.initial && _tag === "Final")
765
+ }
766
+ if (state.node._tag === "Parallel") {
767
+ return state.node.states
768
+ .filter(({ _tag }) => _tag !== "History" && _tag !== "Choice")
769
+ .every((child) => completesOnEntry(`${path}.${child.key}`))
770
+ }
771
+ return false
772
+ }
773
+ const materialize = (
774
+ selected: ReadonlyArray<TransitionCandidate>,
775
+ allowTargetlessEvents = true
776
+ ): FastCheck.Arbitrary<ReadonlyArray<FiniteTransition>> => {
777
+ if (selected.length === 0) return FastCheck.constant([])
778
+ const decisions = selected.map((candidate) =>
779
+ FastCheck.record({
780
+ // Targetless event transitions remain useful witnesses. Generated
781
+ // automatic transitions always exit their source so stabilization is
782
+ // acyclic by construction; targetless automatic semantics are covered
783
+ // by focused examples rather than mixed into the finite-model oracle.
784
+ targetIndex: FastCheck.integer({
785
+ min: candidate.trigger.type === "event" && allowTargetlessEvents ? 0 : 1,
786
+ max: candidate.targets.length
787
+ }),
788
+ targetValueOffset: FastCheck.integer({ min: 0, max: 2 }),
789
+ reenter: FastCheck.boolean()
790
+ })
791
+ )
792
+ return FastCheck.tuple(...decisions).map((values) =>
793
+ selected.map((candidate, index): FiniteTransition => {
794
+ const decision = values[index]!
795
+ const target = decision.targetIndex === 0 ? undefined : candidate.targets[decision.targetIndex - 1]
796
+ const targetState = target === undefined ? undefined : states.find(({ path }) => path === target)
797
+ const targetValue = targetState?.node._tag === "Atomic" &&
798
+ decision.targetValueOffset !== 0
799
+ ? targetState.node.value + decision.targetValueOffset
800
+ : undefined
801
+ const targetFields = target === undefined
802
+ ? {}
803
+ : { target, ...(targetValue === undefined ? {} : { targetValue }) }
804
+ const trigger = candidate.trigger
805
+ return trigger.type === "event"
806
+ ? { source: candidate.source, trigger, reenter: decision.reenter, ...targetFields }
807
+ : { source: candidate.source, trigger, ...targetFields }
808
+ })
809
+ )
810
+ }
811
+ const optionalBudget = maxTransitions - mandatory.length
812
+ const general = FastCheck.subarray([...eventCandidates, ...automaticCandidates], {
813
+ minLength: 0,
814
+ maxLength: Math.min(optionalBudget, eventCandidates.length + automaticCandidates.length)
815
+ }).chain((selected) => materialize(selected).map((transitions) => orderTransitions([...mandatory, ...transitions])))
816
+
817
+ if (mandatory.length > 0 || optionalBudget < 2 || automaticCandidates.length === 0) return general
818
+
819
+ // Bias one branch toward a reachable public-event -> automatic-transition
820
+ // chain. The automatic edge itself still moves forward in document order,
821
+ // so combining the two trigger kinds cannot introduce an automatic cycle.
822
+ const chainCandidates = eventCandidates.flatMap((eventCandidate) => {
823
+ const source = stateByPath.get(eventCandidate.source)!
824
+ if (source.node._tag !== "Atomic" || !initiallyActive.has(source.path)) return []
825
+ return automaticCandidates.flatMap((automaticCandidate) => {
826
+ if (
827
+ automaticCandidate.source === eventCandidate.source ||
828
+ !eventCandidate.targets.includes(automaticCandidate.source) ||
829
+ (automaticCandidate.trigger.type === "done" && !completesOnEntry(automaticCandidate.source))
830
+ ) return []
831
+ return [{
832
+ event: { ...eventCandidate, targets: [automaticCandidate.source] },
833
+ automatic: automaticCandidate
834
+ }]
835
+ })
836
+ })
837
+ if (chainCandidates.length === 0) return general
838
+
839
+ const chain = FastCheck.constantFrom(...chainCandidates).chain(({ automatic, event }) =>
840
+ materialize([event, automatic], false).map((transitions) => orderTransitions(transitions))
841
+ )
842
+ return FastCheck.oneof(general, chain)
843
+ }
844
+
845
+ /**
846
+ * Generates bounded, structurally valid hierarchical statechart
847
+ * models.
848
+ *
849
+ * Generation is composed from shrinkable topology, initial-state, event, and
850
+ * transition decisions. Paths and transition candidates are rebuilt after a
851
+ * topology shrink, so a shrink can never retain a dangling source or target.
852
+ * Generated automatic transitions are acyclic by construction: `always`
853
+ * transitions leave atomic sources and completion transitions leave compound
854
+ * or parallel sources. Separate focused witnesses cover cyclic stabilization.
855
+ *
856
+ * @category constructors
857
+ * @since 0.4.0
858
+ */
859
+ export const finiteModels = (options: FiniteModelOptions = {}): FiniteModels => {
860
+ const limits = resolveOptions(options)
861
+ const rawRoots = FastCheck.array(rawStateArbitrary(1, limits), {
862
+ minLength: 1,
863
+ maxLength: limits.maxRoots
864
+ })
865
+ const arbitrary = rawRoots.chain((raw) => {
866
+ const activeRoots = normalizeStates(raw)
867
+ return FastCheck.tuple(
868
+ FastCheck.integer({ min: 0, max: activeRoots.length - 1 }),
869
+ FastCheck.integer({ min: 1, max: limits.maxEvents }),
870
+ FastCheck.array(FastCheck.constantFrom("shallow" as const, "deep" as const), {
871
+ minLength: 0,
872
+ maxLength: limits.maxHistoryStates
873
+ })
874
+ ).chain(([initialIndex, eventCount, historyDecisions]) => {
875
+ const events = Array.from({ length: eventCount }, (_, index) => `Event${index}`)
876
+ const initial = activeRoots[initialIndex]!.key
877
+ const generated = addGeneratedHistory(
878
+ activeRoots,
879
+ initial,
880
+ limits.maxHistoryStates,
881
+ limits.maxTransitions,
882
+ events,
883
+ historyDecisions
884
+ )
885
+ const roots = generated.scenarios.length === 0
886
+ ? addGeneratedChoices(generated.roots, limits.maxChoiceStates)
887
+ : generated.roots
888
+ return makeTransitionArbitrary(roots, initial, events, limits.maxTransitions, generated.scenarios).map(
889
+ (transitions) => freezeModel(roots, initial, events, transitions, generated.scenarios)
890
+ )
891
+ })
892
+ })
893
+
894
+ return {
895
+ arbitrary,
896
+ diagnostics: {
897
+ limits,
898
+ guarantees: {
899
+ compoundOnly: false,
900
+ parallelStates: true,
901
+ historyStates: true,
902
+ historyLeaveResumeSequences: true,
903
+ historyValueScenarios: true,
904
+ choiceStates: true,
905
+ choiceInitialWitnesses: true,
906
+ structurallyValid: true,
907
+ shrinkPreservesValidity: true,
908
+ eventlessTransitions: true,
909
+ acyclicAutomaticTransitions: true
910
+ }
911
+ }
912
+ }
913
+ }
914
+
915
+ const isValidKey = (key: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
916
+
917
+ const validateModel = (model: FiniteModel): ReadonlyArray<FlatFiniteState> => {
918
+ if (!Array.isArray(model.roots) || model.roots.length < 1 || model.roots.length > 3) {
919
+ throw new Error("MachineTest.compileModel expected between one and three root states")
920
+ }
921
+ const visit = (states: ReadonlyArray<FiniteState>, parent: string | undefined): void => {
922
+ if (states.length === 0) {
923
+ throw new Error(`MachineTest.compileModel expected ${parent ?? "the model"} to contain a state`)
924
+ }
925
+ const siblingKeys = new Set<string>()
926
+ for (const node of states) {
927
+ if (!isValidKey(node.key)) {
928
+ throw new Error(`MachineTest.compileModel received invalid state key "${node.key}"`)
929
+ }
930
+ if (siblingKeys.has(node.key)) {
931
+ throw new Error(`MachineTest.compileModel received duplicate state key "${node.key}"`)
932
+ }
933
+ siblingKeys.add(node.key)
934
+ const path = parent === undefined ? node.key : `${parent}.${node.key}`
935
+ if (node._tag !== "History" && node._tag !== "Choice" && !Number.isSafeInteger(node.value)) {
936
+ throw new Error(`MachineTest.compileModel expected state "${path}" value to be a safe integer`)
937
+ }
938
+ if (node._tag === "History") {
939
+ if (parent === undefined) {
940
+ throw new Error(`MachineTest.compileModel received root history state "${path}"`)
941
+ }
942
+ if (node.history !== "shallow" && node.history !== "deep") {
943
+ throw new Error(`MachineTest.compileModel received invalid history mode at "${path}"`)
944
+ }
945
+ continue
946
+ }
947
+ if (node._tag === "Choice") {
948
+ if (parent === undefined || node.targets.length === 0 || !node.targets.includes(node.selected)) {
949
+ throw new Error(`MachineTest.compileModel received invalid choice state "${path}"`)
950
+ }
951
+ continue
952
+ }
953
+ if (node._tag === "Compound") {
954
+ if (!node.states.some((child) => child.key === node.initial && child._tag !== "History")) {
955
+ throw new Error(`MachineTest.compileModel received unknown initial child "${node.initial}" for "${path}"`)
956
+ }
957
+ visit(node.states, path)
958
+ } else if (node._tag === "Parallel") {
959
+ const regions = node.states.filter((child) => child._tag !== "History" && child._tag !== "Choice")
960
+ if (regions.length < 2 || regions.length > 3) {
961
+ throw new Error(`MachineTest.compileModel expected parallel state "${path}" to contain two or three regions`)
962
+ }
963
+ if (typeof node.output !== "string") {
964
+ throw new Error(`MachineTest.compileModel expected parallel state "${path}" output to be a string`)
965
+ }
966
+ visit(node.states, path)
967
+ } else if (node._tag === "Final" && typeof node.output !== "string") {
968
+ throw new Error(`MachineTest.compileModel expected final state "${path}" output to be a string`)
969
+ } else if (node._tag !== "Atomic" && node._tag !== "Final") {
970
+ throw new Error(`MachineTest.compileModel received unsupported state type at "${path}"`)
971
+ }
972
+ }
973
+ }
974
+ visit(model.roots, undefined)
975
+
976
+ if (!model.roots.some((root) => root.key === model.initial)) {
977
+ throw new Error(`MachineTest.compileModel received unknown initial root "${model.initial}"`)
978
+ }
979
+ const events = new Set<string>()
980
+ for (const event of model.events) {
981
+ if (typeof event !== "string" || event.length === 0 || events.has(event)) {
982
+ throw new Error(`MachineTest.compileModel received invalid or duplicate event tag "${String(event)}"`)
983
+ }
984
+ events.add(event)
985
+ }
986
+ if (events.size === 0) {
987
+ throw new Error("MachineTest.compileModel expected at least one event tag")
988
+ }
989
+
990
+ const flattened = flattenStates(model.roots)
991
+ const byPath = new Map(flattened.map((state) => [state.path, state]))
992
+ for (const state of flattened) {
993
+ if (state.node._tag !== "Choice") continue
994
+ const owner = state.parent === undefined ? undefined : byPath.get(state.parent)
995
+ if (owner?.node._tag !== "Compound") {
996
+ throw new Error(`MachineTest.compileModel expected choice state "${state.path}" to belong to a compound state`)
997
+ }
998
+ for (const targetPath of state.node.targets) {
999
+ const target = byPath.get(targetPath)
1000
+ if (
1001
+ target === undefined || target.node._tag === "History" || target.node._tag === "Choice" ||
1002
+ target.parent !== owner.path
1003
+ ) {
1004
+ throw new Error(`MachineTest.compileModel received invalid choice target "${targetPath}"`)
1005
+ }
1006
+ }
1007
+ }
1008
+ for (const state of flattened) {
1009
+ if (state.node._tag !== "History") continue
1010
+ const owner = state.parent === undefined ? undefined : byPath.get(state.parent)
1011
+ const fallback = byPath.get(state.node.fallback)
1012
+ if (
1013
+ owner === undefined ||
1014
+ (owner.node._tag !== "Compound" && owner.node._tag !== "Parallel") ||
1015
+ fallback === undefined ||
1016
+ fallback.node._tag === "History" ||
1017
+ fallback.path === owner.path ||
1018
+ !isPathInSubtree(fallback.path, owner.path)
1019
+ ) {
1020
+ throw new Error(
1021
+ `MachineTest.compileModel expected history state "${state.path}" fallback to be a concrete descendant of "${state.parent}"`
1022
+ )
1023
+ }
1024
+ }
1025
+ const registrations = new Set<string>()
1026
+ const transitionsByRegistration = new Map<string, FiniteTransition>()
1027
+ for (const transition of model.transitions) {
1028
+ const source = byPath.get(transition.source)
1029
+ if (
1030
+ source === undefined || source.node._tag === "Final" || source.node._tag === "History" ||
1031
+ source.node._tag === "Choice"
1032
+ ) {
1033
+ throw new Error(`MachineTest.compileModel received invalid transition source "${transition.source}"`)
1034
+ }
1035
+ if (transition.trigger.type === "event" && !events.has(transition.trigger.event)) {
1036
+ throw new Error(`MachineTest.compileModel received unknown transition event "${transition.trigger.event}"`)
1037
+ }
1038
+ if (
1039
+ transition.trigger.type === "done" && source.node._tag !== "Compound" && source.node._tag !== "Parallel"
1040
+ ) {
1041
+ throw new Error(
1042
+ `MachineTest.compileModel received completion transition for non-composite "${transition.source}"`
1043
+ )
1044
+ }
1045
+ if (transition.trigger.type !== "event" && "reenter" in transition) {
1046
+ throw new Error(`MachineTest.compileModel received event-only reenter option from "${transition.source}"`)
1047
+ }
1048
+ const registration = `${transition.source}\u0000${triggerKey(transition.trigger)}`
1049
+ if (registrations.has(registration)) {
1050
+ throw new Error(
1051
+ `MachineTest.compileModel received duplicate transition for "${transition.source}" on ` +
1052
+ `"${triggerKey(transition.trigger)}"`
1053
+ )
1054
+ }
1055
+ registrations.add(registration)
1056
+ transitionsByRegistration.set(registration, transition)
1057
+ if (transition.target !== undefined) {
1058
+ const target = byPath.get(transition.target)
1059
+ if (target === undefined) {
1060
+ throw new Error(`MachineTest.compileModel received unknown transition target "${transition.target}"`)
1061
+ }
1062
+ if (target.node._tag !== "History" && target.root !== source.root && target.parent !== undefined) {
1063
+ throw new Error(
1064
+ `MachineTest.compileModel expected cross-root target "${transition.target}" to select its root "${target.root}"`
1065
+ )
1066
+ }
1067
+ }
1068
+ if (
1069
+ transition.targetValue !== undefined &&
1070
+ (!Number.isSafeInteger(transition.targetValue) || transition.target === undefined ||
1071
+ byPath.get(transition.target)?.node._tag === "History" ||
1072
+ byPath.get(transition.target)?.node._tag === "Choice")
1073
+ ) {
1074
+ throw new Error(
1075
+ `MachineTest.compileModel received invalid target value for transition from "${transition.source}"`
1076
+ )
1077
+ }
1078
+ }
1079
+ const initiallyActiveLeaves = initialLeaves(byPath, model.initial)
1080
+ const initiallyActive = new Set(initiallyActiveLeaves.flatMap((leaf) => {
1081
+ const parts = leaf.split(".")
1082
+ return parts.map((_, index) => parts.slice(0, index + 1).join("."))
1083
+ }))
1084
+ const scenarioHistories = new Set<string>()
1085
+ const scenarioEvents = new Set<string>()
1086
+ const scenarioRegistrations = new Set<string>()
1087
+ for (const scenario of model.historyScenarios ?? []) {
1088
+ const history = byPath.get(scenario.history)
1089
+ const mutationState = byPath.get(scenario.mutation.source)
1090
+ if (
1091
+ history?.node._tag !== "History" || history.parent !== scenario.owner ||
1092
+ history.node.history !== scenario.historyType
1093
+ ) {
1094
+ throw new Error(`MachineTest.compileModel received invalid history scenario for "${scenario.history}"`)
1095
+ }
1096
+ if (scenarioHistories.has(scenario.history)) {
1097
+ throw new Error(`MachineTest.compileModel received duplicate history scenario for "${scenario.history}"`)
1098
+ }
1099
+ scenarioHistories.add(scenario.history)
1100
+ const owner = byPath.get(scenario.owner)!
1101
+ const ownerNode = owner.node
1102
+ let expectedMutationSource: string | undefined
1103
+ if (scenario.historyType === "deep") {
1104
+ expectedMutationSource = initialLeaves(byPath, owner.path)[0]
1105
+ } else if (ownerNode._tag === "Compound") {
1106
+ if (ownerNode.states.some((child) => child.key === ownerNode.initial && child._tag === "Atomic")) {
1107
+ expectedMutationSource = `${owner.path}.${ownerNode.initial}`
1108
+ }
1109
+ } else if (ownerNode._tag === "Parallel") {
1110
+ const directAtomic = ownerNode.states.find((child) => child._tag === "Atomic")
1111
+ if (directAtomic !== undefined) expectedMutationSource = `${owner.path}.${directAtomic.key}`
1112
+ }
1113
+ if (
1114
+ mutationState?.node._tag !== "Atomic" || scenario.mutation.target !== scenario.mutation.source ||
1115
+ scenario.mutation.value === mutationState.node.value || !initiallyActive.has(mutationState.path) ||
1116
+ scenario.mutation.source !== expectedMutationSource
1117
+ ) {
1118
+ throw new Error(`MachineTest.compileModel received invalid history mutation for "${scenario.history}"`)
1119
+ }
1120
+ if (
1121
+ !Array.isArray(scenario.events) || scenario.events.length !== 3 ||
1122
+ scenario.resume.target !== scenario.history || scenario.leave.event !== scenario.resume.event ||
1123
+ scenario.events[0] !== scenario.mutation.event || scenario.events[1] !== scenario.leave.event ||
1124
+ scenario.events[2] !== scenario.resume.event
1125
+ ) {
1126
+ throw new Error(`MachineTest.compileModel received inconsistent history events for "${scenario.history}"`)
1127
+ }
1128
+ const expectedOutside = model.roots
1129
+ .filter((root) => root.key !== model.initial)
1130
+ .find((root) => initialLeaves(byPath, root.key).length > 0)
1131
+ if (
1132
+ scenario.leave.source !== initialLeaves(byPath, owner.path)[0] ||
1133
+ scenario.leave.target !== expectedOutside?.key ||
1134
+ scenario.resume.source !==
1135
+ (expectedOutside === undefined ? undefined : initialLeaves(byPath, expectedOutside.key)[0])
1136
+ ) {
1137
+ throw new Error(`MachineTest.compileModel received invalid history transfer for "${scenario.history}"`)
1138
+ }
1139
+ const expectedTransitions: ReadonlyArray<readonly [FiniteHistoryTransfer | FiniteHistoryMutation, number?]> = [
1140
+ [scenario.mutation, scenario.mutation.value],
1141
+ [scenario.leave],
1142
+ [scenario.resume]
1143
+ ]
1144
+ if (scenarioEvents.has(scenario.mutation.event) || scenarioEvents.has(scenario.leave.event)) {
1145
+ throw new Error(`MachineTest.compileModel received duplicate history scenario event for "${scenario.history}"`)
1146
+ }
1147
+ scenarioEvents.add(scenario.mutation.event)
1148
+ scenarioEvents.add(scenario.leave.event)
1149
+ for (const [expected, targetValue] of expectedTransitions) {
1150
+ const registration = `${expected.source}\u0000${triggerKey({ type: "event", event: expected.event })}`
1151
+ if (scenarioRegistrations.has(registration)) {
1152
+ throw new Error(`MachineTest.compileModel received duplicate history witness for "${scenario.history}"`)
1153
+ }
1154
+ scenarioRegistrations.add(registration)
1155
+ const transition = transitionsByRegistration.get(registration)
1156
+ if (
1157
+ transition?.trigger.type !== "event" || transition.trigger.event !== expected.event ||
1158
+ transition.target !== expected.target || !("reenter" in transition) || transition.reenter ||
1159
+ transition.targetValue !== targetValue
1160
+ ) {
1161
+ throw new Error(`MachineTest.compileModel could not replay history scenario for "${scenario.history}"`)
1162
+ }
1163
+ }
1164
+ }
1165
+ return flattened
1166
+ }
1167
+
1168
+ const stateTag = (path: string): string => `State_${path.replaceAll(".", "_")}`
1169
+
1170
+ const stateValue = (
1171
+ state: FlatFiniteState,
1172
+ value?: number
1173
+ ): { readonly _tag: string; readonly value: number } => {
1174
+ if (state.node._tag === "History" || state.node._tag === "Choice") {
1175
+ throw new Error(`MachineTest.compileModel cannot construct a value for pseudo-state "${state.path}"`)
1176
+ }
1177
+ return { _tag: stateTag(state.path), value: value ?? state.node.value }
1178
+ }
1179
+
1180
+ const runtimeTargetPath = (
1181
+ byPath: ReadonlyMap<string, FlatFiniteState>,
1182
+ sourcePath: string,
1183
+ requestedPath: string
1184
+ ): string => {
1185
+ const source = byPath.get(sourcePath)!
1186
+ const requested = byPath.get(requestedPath)!
1187
+ if (requested.node._tag === "History") return requested.parent!
1188
+ if (requested.node._tag === "Choice") return runtimeTargetPath(byPath, sourcePath, requested.node.selected)
1189
+ if (source.root !== requested.root) return requested.path
1190
+
1191
+ const initial = (path: string): string => {
1192
+ const current = byPath.get(path)!
1193
+ if (current.node._tag === "Parallel") {
1194
+ if (sourcePath !== current.path && !sourcePath.startsWith(`${current.path}.`)) {
1195
+ return current.path
1196
+ }
1197
+ const child = sourcePath === current.path
1198
+ ? current.node.states[0]!.key
1199
+ : sourcePath.slice(current.path.length + 1).split(".")[0]!
1200
+ return initial(`${current.path}.${child}`)
1201
+ }
1202
+ return current.node._tag === "Compound" ? initial(`${current.path}.${current.node.initial}`) : current.path
1203
+ }
1204
+ const inspect = (path: string): string => {
1205
+ const current = byPath.get(path)!
1206
+ if (
1207
+ current.node._tag === "Parallel" && sourcePath !== current.path && !sourcePath.startsWith(`${current.path}.`)
1208
+ ) {
1209
+ return current.path
1210
+ }
1211
+ if (current.path === requestedPath) return initial(current.path)
1212
+ const next = requestedPath.slice(current.path.length + 1).split(".")[0]!
1213
+ return inspect(`${current.path}.${next}`)
1214
+ }
1215
+ return inspect(source.root)
1216
+ }
1217
+
1218
+ const makeStateTree = (
1219
+ states: ReadonlyArray<FiniteState>,
1220
+ parent: string | undefined
1221
+ ): Record<string, Machine.Machine.TaggedSchema | Machine.Machine.StateNodeConfig> => {
1222
+ const tree: Record<string, Machine.Machine.TaggedSchema | Machine.Machine.StateNodeConfig> = Object.create(null)
1223
+ for (const node of states) {
1224
+ const path = parent === undefined ? node.key : `${parent}.${node.key}`
1225
+ if (node._tag === "History") {
1226
+ tree[node.key] = {
1227
+ type: "history",
1228
+ ...(node.history === "deep" ? { history: "deep" } : {})
1229
+ }
1230
+ continue
1231
+ }
1232
+ if (node._tag === "Choice") {
1233
+ tree[node.key] = { type: "choice" }
1234
+ continue
1235
+ }
1236
+ const schema = Schema.TaggedStruct(stateTag(path), { value: Schema.Number })
1237
+ if (node._tag === "Atomic") {
1238
+ tree[node.key] = schema
1239
+ } else if (node._tag === "Final") {
1240
+ tree[node.key] = { schema, type: "final", output: Schema.Literal(node.output) }
1241
+ } else if (node._tag === "Compound") {
1242
+ tree[node.key] = {
1243
+ schema,
1244
+ initial: node.initial,
1245
+ states: makeStateTree(node.states, path)
1246
+ }
1247
+ } else {
1248
+ tree[node.key] = {
1249
+ schema,
1250
+ type: "parallel",
1251
+ output: Schema.Literal(node.output),
1252
+ states: makeStateTree(node.states, path)
1253
+ }
1254
+ }
1255
+ }
1256
+ return tree
1257
+ }
1258
+
1259
+ const selectSnapshot = (
1260
+ builder: Record<string, any>,
1261
+ path: string,
1262
+ byPath: ReadonlyMap<string, FlatFiniteState>,
1263
+ requestedParts: ReadonlyArray<string> | undefined,
1264
+ index: number,
1265
+ sourcePath?: string,
1266
+ requestedValue?: number
1267
+ ): unknown => {
1268
+ const state = byPath.get(path)!
1269
+ if (state.node._tag === "History") {
1270
+ throw new Error(`MachineTest.compileModel cannot construct active history state "${path}"`)
1271
+ }
1272
+ if (state.node._tag === "Choice") {
1273
+ return (builder[state.node.key] as () => unknown)()
1274
+ }
1275
+ const method = builder[state.node.key] as (value: unknown, selector?: (builder: any) => unknown) => unknown
1276
+ const value = stateValue(state, path === requestedParts?.join(".") ? requestedValue : undefined)
1277
+ if (state.node._tag === "Atomic" || state.node._tag === "Final") return method(value)
1278
+
1279
+ const requestedChild = requestedParts?.[index + 1]
1280
+ if (state.node._tag === "Parallel") {
1281
+ const parallel = state.node
1282
+ const sourceInside = sourcePath === path || sourcePath?.startsWith(`${path}.`) === true
1283
+ if (sourceInside) {
1284
+ const childKey = requestedChild ?? (sourcePath === path
1285
+ ? parallel.states.find((child) => child._tag !== "History" && child._tag !== "Choice")!.key
1286
+ : sourcePath!.slice(path.length + 1).split(".")[0]!)
1287
+ return method(
1288
+ value,
1289
+ (children: Record<string, any>) =>
1290
+ selectSnapshot(
1291
+ children,
1292
+ `${path}.${childKey}`,
1293
+ byPath,
1294
+ requestedChild === undefined ? undefined : requestedParts,
1295
+ index + 1,
1296
+ sourcePath,
1297
+ requestedValue
1298
+ )
1299
+ )
1300
+ }
1301
+ return method(value, (children: Record<string, any>) => {
1302
+ let selected: unknown = children
1303
+ for (const child of parallel.states.filter((child) => child._tag !== "History" && child._tag !== "Choice")) {
1304
+ const isRequestedRegion = requestedChild === child.key
1305
+ selected = selectSnapshot(
1306
+ selected as Record<string, any>,
1307
+ `${path}.${child.key}`,
1308
+ byPath,
1309
+ isRequestedRegion ? requestedParts : undefined,
1310
+ index + 1,
1311
+ sourcePath,
1312
+ requestedValue
1313
+ )
1314
+ }
1315
+ return selected
1316
+ })
1317
+ }
1318
+
1319
+ const childKey = requestedChild ?? state.node.initial
1320
+ const childPath = `${path}.${childKey}`
1321
+ return method(
1322
+ value,
1323
+ (children: Record<string, any>) =>
1324
+ selectSnapshot(
1325
+ children,
1326
+ childPath,
1327
+ byPath,
1328
+ requestedChild === undefined ? undefined : requestedParts,
1329
+ index + 1,
1330
+ sourcePath,
1331
+ requestedValue
1332
+ )
1333
+ )
1334
+ }
1335
+
1336
+ const findSnapshot = (snapshot: unknown, path: string): unknown => {
1337
+ if (typeof snapshot !== "object" || snapshot === null) return undefined
1338
+ const current = snapshot as Record<string, unknown>
1339
+ if (current.path === path) return snapshot
1340
+ if (current.state !== undefined) {
1341
+ const found = findSnapshot(current.state, path)
1342
+ if (found !== undefined) return found
1343
+ }
1344
+ if (typeof current.states === "object" && current.states !== null) {
1345
+ for (const child of Object.values(current.states)) {
1346
+ const found = findSnapshot(child, path)
1347
+ if (found !== undefined) return found
1348
+ }
1349
+ }
1350
+ return undefined
1351
+ }
1352
+
1353
+ const selectHistoryTarget = (builder: Record<string, any>, path: string): unknown => {
1354
+ const parts = path.split(".")
1355
+ let current: any = builder
1356
+ for (let index = 0; index < parts.length - 1; index++) current = current[parts[index]!]
1357
+ return current[parts[parts.length - 1]!]()
1358
+ }
1359
+
1360
+ type TargetBuilder = {
1361
+ readonly branch: Record<string, any>
1362
+ readonly full: Record<string, any>
1363
+ readonly history: Record<string, any>
1364
+ }
1365
+
1366
+ const makeHandlers = (
1367
+ states: ReadonlyArray<FiniteState>,
1368
+ parent: string | undefined,
1369
+ byPath: ReadonlyMap<string, FlatFiniteState>,
1370
+ transitions: ReadonlyMap<string, FiniteTransition>
1371
+ ): Record<string, unknown> => {
1372
+ const handlers: Record<string, unknown> = Object.create(null)
1373
+ for (const node of states) {
1374
+ const path = parent === undefined ? node.key : `${parent}.${node.key}`
1375
+ if (node._tag === "History") continue
1376
+ if (node._tag === "Choice") {
1377
+ handlers[node.key] = {
1378
+ choice: {
1379
+ targets: node.targets,
1380
+ transition: ({ target }: { readonly target: TargetBuilder }) => {
1381
+ const selected = byPath.get(node.selected)!
1382
+ const parts = selected.path.split(".")
1383
+ const builder = selected.root === byPath.get(path)!.root ? target.branch : target.full
1384
+ return selectSnapshot(builder, parts[0]!, byPath, parts, 0, path)
1385
+ }
1386
+ }
1387
+ }
1388
+ continue
1389
+ }
1390
+ if (node._tag === "Final") {
1391
+ handlers[node.key] = { output: () => node.output }
1392
+ continue
1393
+ }
1394
+ const on: Record<string, unknown> = Object.create(null)
1395
+ let always: unknown
1396
+ let onDone: unknown
1397
+ for (const transition of transitions.values()) {
1398
+ if (transition.source !== path) continue
1399
+ const config = {
1400
+ ...("reenter" in transition ? { reenter: transition.reenter } : {}),
1401
+ ...(transition.target === undefined
1402
+ ? {}
1403
+ : {
1404
+ targets: [
1405
+ byPath.get(transition.target)!.node._tag === "History" ||
1406
+ byPath.get(transition.target)!.node._tag === "Choice"
1407
+ ? transition.target
1408
+ : runtimeTargetPath(byPath, path, transition.target)
1409
+ ]
1410
+ }),
1411
+ transition: ({ target }: { readonly target: TargetBuilder }) => {
1412
+ if (transition.target === undefined) return undefined
1413
+ const targetState = byPath.get(transition.target)!
1414
+ if (targetState.node._tag === "History") return selectHistoryTarget(target.history, targetState.path)
1415
+ const parts = transition.target.split(".")
1416
+ const builder = targetState.root === byPath.get(path)!.root ? target.branch : target.full
1417
+ return selectSnapshot(builder, parts[0]!, byPath, parts, 0, path, transition.targetValue)
1418
+ }
1419
+ }
1420
+ if (transition.trigger.type === "event") on[transition.trigger.event] = config
1421
+ else if (transition.trigger.type === "always") always = config
1422
+ else onDone = config
1423
+ }
1424
+ const history = node._tag === "Compound" || node._tag === "Parallel"
1425
+ ? Object.fromEntries(node.states.flatMap((child) => {
1426
+ if (child._tag !== "History") return []
1427
+ return [[child.key, {
1428
+ default: ({ target }: { readonly target: Record<string, any> }) => {
1429
+ const fallback = byPath.get(child.fallback)!
1430
+ const parts = child.fallback.split(".")
1431
+ const completeRoot = selectSnapshot(target, fallback.root, byPath, parts, 0)
1432
+ if (findSnapshot(completeRoot, path) === undefined) {
1433
+ throw new Error(
1434
+ `MachineTest.compileModel could not construct history fallback for "${path}.${child.key}"`
1435
+ )
1436
+ }
1437
+ return completeRoot
1438
+ }
1439
+ }]]
1440
+ }))
1441
+ : {}
1442
+ handlers[node.key] = {
1443
+ ...(Object.keys(on).length === 0 ? {} : { on }),
1444
+ ...(always === undefined ? {} : { always }),
1445
+ ...(onDone === undefined ? {} : { onDone }),
1446
+ ...(node._tag === "Compound" || node._tag === "Parallel"
1447
+ ? {
1448
+ ...(node._tag === "Parallel" ? { output: () => node.output } : {}),
1449
+ ...(Object.keys(history).length === 0 ? {} : { history }),
1450
+ ...(node._tag === "Compound"
1451
+ ? {
1452
+ initial: () => stateValue(byPath.get(`${path}.${node.initial}`)!)
1453
+ }
1454
+ : {
1455
+ initial: () =>
1456
+ Object.fromEntries(
1457
+ node.states
1458
+ .filter((child) => child._tag !== "History" && child._tag !== "Choice")
1459
+ .map((child) => [child.key, stateValue(byPath.get(`${path}.${child.key}`)!)])
1460
+ )
1461
+ }),
1462
+ states: makeHandlers(node.states, path, byPath, transitions)
1463
+ }
1464
+ : {})
1465
+ }
1466
+ }
1467
+ return handlers
1468
+ }
1469
+
1470
+ /**
1471
+ * Compiles a finite model into a real machine using only public definition,
1472
+ * construction, and handler APIs.
1473
+ *
1474
+ * Hand-authored models are validated before compilation. This compiler is a
1475
+ * testing adapter, not the independent reference interpreter used by later
1476
+ * conformance stages.
1477
+ *
1478
+ * @category constructors
1479
+ * @since 0.4.0
1480
+ */
1481
+ export const compileModel = (model: FiniteModel): Machine.Machine.Any => {
1482
+ const flattened = validateModel(model)
1483
+ const byPath = new Map(flattened.map((state) => [state.path, state]))
1484
+ const stateTree = makeStateTree(model.roots, undefined)
1485
+ const defined = Machine.defineStates(stateTree as any)
1486
+ const eventSchemas = model.events.map((event) => Schema.TaggedStruct(event, {}))
1487
+ const initial = byPath.get(model.initial)!
1488
+ const machine = Machine.make({
1489
+ states: defined.states as any,
1490
+ events: eventSchemas as any,
1491
+ initial: () => selectSnapshot(defined.initial as any, initial.path, byPath, [initial.path], 0) as any
1492
+ })
1493
+ const transitions = new Map(model.transitions.map((transition) => [
1494
+ `${transition.source}\u0000${triggerKey(transition.trigger)}`,
1495
+ transition
1496
+ ]))
1497
+ return machine.handle(makeHandlers(model.roots, undefined, byPath, transitions) as any) as Machine.Machine.Any
1498
+ }