@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.
- package/README.md +1 -1
- package/package.json +8 -8
- package/src/Machine.ts +6873 -0
- package/src/index.ts +1 -0
- package/src/internal/machine/activities.ts +108 -0
- package/src/internal/machine/atom.ts +636 -0
- package/src/internal/machine/cluster.ts +394 -0
- package/src/internal/machine/command.ts +58 -0
- package/src/internal/machine/commandRuntime.ts +43 -0
- package/src/internal/machine/configuration.ts +1331 -0
- package/src/internal/machine/errors.ts +87 -0
- package/src/internal/machine/executionPlan.ts +996 -0
- package/src/internal/machine/invocation.ts +119 -0
- package/src/internal/machine/machine.ts +1747 -0
- package/src/internal/machine/planner.ts +1933 -0
- package/src/internal/machine/process.ts +906 -0
- package/src/internal/machine/protocol.ts +322 -0
- package/src/internal/machine/readiness.ts +10 -0
- package/src/internal/machine/runtime.ts +2512 -0
- package/src/internal/machine/serialization.ts +498 -0
- package/src/internal/machine/stateDefinition.ts +270 -0
- package/src/internal/machine/symbols.ts +2 -0
- package/src/internal/machine/topology.ts +479 -0
- package/src/internal/testing/machine/arbitrary.ts +102 -0
- package/src/internal/testing/machine/exploration.ts +331 -0
- package/src/internal/testing/machine/finiteModel.ts +1498 -0
- package/src/internal/testing/machine/invariant.ts +372 -0
- package/src/internal/testing/machine/probe.ts +79 -0
- package/src/internal/testing/machine/referenceModel.ts +1505 -0
- package/src/internal/testing/machine/runtime.ts +1710 -0
- package/src/internal/testing/machine/runtimeInvariant.ts +486 -0
- package/src/internal/testing/machine/trace.ts +150 -0
- package/src/internal/testing/machine/verification.ts +1890 -0
- package/src/testing/MachineTest.ts +2067 -0
- package/src/testing/index.ts +7 -0
- package/src/unstable/cluster/ClusterMachine.ts +390 -0
- package/src/unstable/cluster/index.ts +1 -0
- package/src/unstable/reactivity/AtomMachine.ts +649 -0
- 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
|
+
}
|