@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.
- package/README.md +4 -3
- package/dist/Machine.d.ts +24 -6
- package/dist/Machine.d.ts.map +1 -1
- package/dist/Machine.js.map +1 -1
- package/dist/internal/machine/atom.d.ts +5 -0
- package/dist/internal/machine/atom.d.ts.map +1 -1
- package/dist/internal/machine/atom.js +6 -3
- package/dist/internal/machine/atom.js.map +1 -1
- package/dist/internal/machine/machine.d.ts.map +1 -1
- package/dist/internal/machine/machine.js +1 -1
- package/dist/internal/machine/machine.js.map +1 -1
- package/dist/unstable/reactivity/AtomMachine.d.ts +26 -0
- package/dist/unstable/reactivity/AtomMachine.d.ts.map +1 -1
- package/dist/unstable/reactivity/AtomMachine.js +23 -0
- package/dist/unstable/reactivity/AtomMachine.js.map +1 -1
- package/docs/agent-guide.md +14 -0
- package/package.json +5 -5
- package/src/Machine.ts +6909 -0
- package/src/index.ts +1 -0
- package/src/internal/machine/activities.ts +108 -0
- package/src/internal/machine/atom.ts +684 -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 +1750 -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 +696 -0
- package/src/unstable/reactivity/index.ts +1 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded breadth-first exploration over concrete planner events.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.4.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Data from "effect/Data"
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import * as Graph from "effect/Graph"
|
|
10
|
+
import * as Schema from "effect/Schema"
|
|
11
|
+
import * as Machine from "../../../Machine.js"
|
|
12
|
+
import type {
|
|
13
|
+
Exploration,
|
|
14
|
+
ExplorationCompleteness,
|
|
15
|
+
ExplorationEdge,
|
|
16
|
+
ExplorationFrontier,
|
|
17
|
+
ExplorationKey,
|
|
18
|
+
ExplorationNode,
|
|
19
|
+
ExplorationPredicate,
|
|
20
|
+
ExplorationStateContext,
|
|
21
|
+
ExploreOptions,
|
|
22
|
+
ReachabilityFailure,
|
|
23
|
+
ResolvedExplorationLimits,
|
|
24
|
+
RunError,
|
|
25
|
+
RunFailure,
|
|
26
|
+
RunServices,
|
|
27
|
+
Scenario,
|
|
28
|
+
Trace
|
|
29
|
+
} from "../../../testing/MachineTest.js"
|
|
30
|
+
import type { EnsureExecutable } from "../../machine/readiness.js"
|
|
31
|
+
import { assertInvariants, type InvariantError } from "./invariant.js"
|
|
32
|
+
import { appendTrace, run } from "./trace.js"
|
|
33
|
+
|
|
34
|
+
type AnyMachine = Machine.Machine.Any
|
|
35
|
+
|
|
36
|
+
type InputValue<M extends AnyMachine> = Machine.Machine.Input<M>["Type"]
|
|
37
|
+
|
|
38
|
+
type ReadyMachine<M extends AnyMachine> =
|
|
39
|
+
& M
|
|
40
|
+
& EnsureExecutable<
|
|
41
|
+
Machine.Machine.States<M>,
|
|
42
|
+
Machine.Machine.UnhandledStates<M>,
|
|
43
|
+
Machine.Machine.OutputStates<M>
|
|
44
|
+
>
|
|
45
|
+
|
|
46
|
+
const defaultLimits: ResolvedExplorationLimits = {
|
|
47
|
+
maxDepth: 20,
|
|
48
|
+
maxStates: 1_000,
|
|
49
|
+
maxTransitions: 10_000
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const validateLimit = (name: keyof ResolvedExplorationLimits, value: number, minimum: number): void => {
|
|
53
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
54
|
+
throw new Error(`MachineTest.explore expected ${name} to be a safe integer greater than or equal to ${minimum}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resolveLimits = (limits: ExploreOptions<AnyMachine, ExplorationKey>["limits"]): ResolvedExplorationLimits => {
|
|
59
|
+
const resolved = {
|
|
60
|
+
maxDepth: limits?.maxDepth ?? defaultLimits.maxDepth,
|
|
61
|
+
maxStates: limits?.maxStates ?? defaultLimits.maxStates,
|
|
62
|
+
maxTransitions: limits?.maxTransitions ?? defaultLimits.maxTransitions
|
|
63
|
+
}
|
|
64
|
+
validateLimit("maxDepth", resolved.maxDepth, 0)
|
|
65
|
+
validateLimit("maxStates", resolved.maxStates, 1)
|
|
66
|
+
validateLimit("maxTransitions", resolved.maxTransitions, 0)
|
|
67
|
+
return resolved
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const validateKey = <Key extends ExplorationKey>(key: Key): Key => {
|
|
71
|
+
if (typeof key !== "string" && typeof key !== "number" && typeof key !== "symbol") {
|
|
72
|
+
throw new Error("MachineTest.explore expected stateKey to return a string, number, or symbol")
|
|
73
|
+
}
|
|
74
|
+
return key
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const stateContext = <M extends AnyMachine>(machine: M, trace: Trace<M>): ExplorationStateContext<M> => ({
|
|
78
|
+
machine,
|
|
79
|
+
snapshot: trace.final,
|
|
80
|
+
configuration: trace.finalConfiguration,
|
|
81
|
+
depth: trace.steps.length,
|
|
82
|
+
trace
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const scenarioWithEvent = <M extends AnyMachine>(
|
|
86
|
+
trace: Trace<M>,
|
|
87
|
+
event: Machine.Machine.InputEvent<M>
|
|
88
|
+
): Scenario<M> =>
|
|
89
|
+
({
|
|
90
|
+
...trace.scenario,
|
|
91
|
+
events: [...trace.scenario.events, event]
|
|
92
|
+
}) as Scenario<M>
|
|
93
|
+
|
|
94
|
+
interface ExplorationEdgeDraft<M extends AnyMachine> {
|
|
95
|
+
readonly source: number
|
|
96
|
+
readonly target: number
|
|
97
|
+
readonly edge: ExplorationEdge<M>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const explore = <M extends AnyMachine, Key extends ExplorationKey>(
|
|
101
|
+
machine: ReadyMachine<M>,
|
|
102
|
+
options: ExploreOptions<M, Key>
|
|
103
|
+
): Effect.Effect<
|
|
104
|
+
Exploration<M, Key>,
|
|
105
|
+
RunFailure<RunError<M>, M> | InvariantError<M>,
|
|
106
|
+
RunServices<M>
|
|
107
|
+
> => {
|
|
108
|
+
const limits = resolveLimits(options.limits)
|
|
109
|
+
const initialScenario = (machine.input === undefined || machine.input === Schema.Void
|
|
110
|
+
? { events: [] }
|
|
111
|
+
: { input: (options as { readonly input: InputValue<M> }).input, events: [] }) as unknown as Scenario<M>
|
|
112
|
+
|
|
113
|
+
return Effect.gen(function*() {
|
|
114
|
+
const initialTrace = yield* run(machine, initialScenario)
|
|
115
|
+
const invariants = options.invariants ?? []
|
|
116
|
+
if (invariants.length > 0) {
|
|
117
|
+
yield* assertInvariants(machine, initialTrace, invariants)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const initialContext = stateContext(machine, initialTrace)
|
|
121
|
+
const initialKey = validateKey(options.stateKey(initialContext))
|
|
122
|
+
const nodes: Array<ExplorationNode<M, Key>> = [{ ...initialContext, key: initialKey }]
|
|
123
|
+
const nodeDraftsByKey = new Map<Key, number>([[initialKey, 0]])
|
|
124
|
+
const edges: Array<ExplorationEdgeDraft<M>> = []
|
|
125
|
+
const frontier: Array<ExplorationFrontier<M, Key>> = []
|
|
126
|
+
const reasons = new Set<"depth" | "states" | "transitions">()
|
|
127
|
+
let plannedTransitions = 0
|
|
128
|
+
let cursor = 0
|
|
129
|
+
let halted = false
|
|
130
|
+
|
|
131
|
+
while (cursor < nodes.length && !halted) {
|
|
132
|
+
const sourceIndex = cursor
|
|
133
|
+
const source = nodes[cursor++]!
|
|
134
|
+
const candidates = options.events(source)
|
|
135
|
+
if (!Array.isArray(candidates)) {
|
|
136
|
+
throw new Error("MachineTest.explore expected events to return a readonly array")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (source.depth >= limits.maxDepth) {
|
|
140
|
+
if (candidates.length > 0) reasons.add("depth")
|
|
141
|
+
for (const event of candidates) {
|
|
142
|
+
frontier.push({
|
|
143
|
+
_tag: "DepthLimit",
|
|
144
|
+
source: source.key,
|
|
145
|
+
trace: source.trace,
|
|
146
|
+
event
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const event of candidates) {
|
|
153
|
+
if (plannedTransitions >= limits.maxTransitions) {
|
|
154
|
+
reasons.add("transitions")
|
|
155
|
+
frontier.push({
|
|
156
|
+
_tag: "TransitionLimit",
|
|
157
|
+
source: source.key,
|
|
158
|
+
trace: source.trace,
|
|
159
|
+
event
|
|
160
|
+
})
|
|
161
|
+
halted = true
|
|
162
|
+
break
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const scenario = scenarioWithEvent(source.trace, event)
|
|
166
|
+
const targetTrace = yield* appendTrace(machine, source.trace, event, scenario)
|
|
167
|
+
plannedTransitions += 1
|
|
168
|
+
if (invariants.length > 0) {
|
|
169
|
+
yield* assertInvariants(machine, targetTrace, invariants)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const targetContext = stateContext(machine, targetTrace)
|
|
173
|
+
const targetKey = validateKey(options.stateKey(targetContext))
|
|
174
|
+
const existing = nodeDraftsByKey.get(targetKey)
|
|
175
|
+
if (existing !== undefined) {
|
|
176
|
+
edges.push({
|
|
177
|
+
source: sourceIndex,
|
|
178
|
+
target: existing,
|
|
179
|
+
edge: {
|
|
180
|
+
event,
|
|
181
|
+
step: targetTrace.steps[targetTrace.steps.length - 1]!,
|
|
182
|
+
discovered: false
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (nodes.length >= limits.maxStates) {
|
|
189
|
+
reasons.add("states")
|
|
190
|
+
frontier.push({
|
|
191
|
+
_tag: "StateLimit",
|
|
192
|
+
source: source.key,
|
|
193
|
+
trace: source.trace,
|
|
194
|
+
event,
|
|
195
|
+
target: targetKey,
|
|
196
|
+
targetTrace
|
|
197
|
+
})
|
|
198
|
+
halted = true
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const targetIndex = nodes.length
|
|
203
|
+
nodeDraftsByKey.set(targetKey, targetIndex)
|
|
204
|
+
nodes.push({ ...targetContext, key: targetKey })
|
|
205
|
+
edges.push({
|
|
206
|
+
source: sourceIndex,
|
|
207
|
+
target: targetIndex,
|
|
208
|
+
edge: {
|
|
209
|
+
event,
|
|
210
|
+
step: targetTrace.steps[targetTrace.steps.length - 1]!,
|
|
211
|
+
discovered: true
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const nodeIndexes: Array<Graph.NodeIndex> = []
|
|
218
|
+
const graph = Graph.directed<ExplorationNode<M, Key>, ExplorationEdge<M>>((mutable) => {
|
|
219
|
+
for (const node of nodes) nodeIndexes.push(Graph.addNode(mutable, node))
|
|
220
|
+
for (const edge of edges) {
|
|
221
|
+
Graph.addEdge(mutable, nodeIndexes[edge.source]!, nodeIndexes[edge.target]!, edge.edge)
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
const nodesByKey = new Map<Key, Graph.NodeIndex>()
|
|
225
|
+
nodes.forEach((node, index) => nodesByKey.set(node.key, nodeIndexes[index]!))
|
|
226
|
+
const completeness: ExplorationCompleteness<M, Key> = reasons.size === 0
|
|
227
|
+
? { _tag: "Complete" }
|
|
228
|
+
: {
|
|
229
|
+
_tag: "Truncated",
|
|
230
|
+
reasons: (["depth", "states", "transitions"] as const).filter((reason) => reasons.has(reason)),
|
|
231
|
+
frontier
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
graph,
|
|
236
|
+
nodes,
|
|
237
|
+
nodesByKey,
|
|
238
|
+
start: nodeIndexes[0]!,
|
|
239
|
+
limits,
|
|
240
|
+
stats: {
|
|
241
|
+
states: nodes.length,
|
|
242
|
+
plannedTransitions,
|
|
243
|
+
retainedEdges: edges.length,
|
|
244
|
+
maxDepth: nodes.reduce((maximum, node) => Math.max(maximum, node.depth), 0)
|
|
245
|
+
},
|
|
246
|
+
completeness
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export class ReachabilityError<
|
|
252
|
+
M extends AnyMachine = AnyMachine,
|
|
253
|
+
Key extends ExplorationKey = ExplorationKey
|
|
254
|
+
> extends Data.TaggedError("MachineTestReachabilityError")<{
|
|
255
|
+
readonly name: string
|
|
256
|
+
readonly expectation: "reachable" | "unreachable"
|
|
257
|
+
readonly reason: ReachabilityFailure
|
|
258
|
+
readonly message: string
|
|
259
|
+
readonly completeness: ExplorationCompleteness<M, Key>
|
|
260
|
+
readonly witness?: ExplorationNode<M, Key>
|
|
261
|
+
}> {}
|
|
262
|
+
|
|
263
|
+
const validateAssertionName = (name: string): void => {
|
|
264
|
+
if (name.trim().length === 0) {
|
|
265
|
+
throw new Error("MachineTest reachability assertions expected name to be a non-empty string")
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const findShortest = <M extends AnyMachine, Key extends ExplorationKey>(
|
|
270
|
+
exploration: Exploration<M, Key>,
|
|
271
|
+
predicate: ExplorationPredicate<M, Key>
|
|
272
|
+
): ExplorationNode<M, Key> | undefined => exploration.nodes.find(predicate)
|
|
273
|
+
|
|
274
|
+
export const assertReachable = <M extends AnyMachine, Key extends ExplorationKey>(
|
|
275
|
+
exploration: Exploration<M, Key>,
|
|
276
|
+
name: string,
|
|
277
|
+
predicate: ExplorationPredicate<M, Key>
|
|
278
|
+
): Effect.Effect<ExplorationNode<M, Key>, ReachabilityError<M, Key>> => {
|
|
279
|
+
validateAssertionName(name)
|
|
280
|
+
return Effect.suspend(() => {
|
|
281
|
+
const witness = findShortest(exploration, predicate)
|
|
282
|
+
if (witness !== undefined) return Effect.succeed(witness)
|
|
283
|
+
const inconclusive = exploration.completeness._tag === "Truncated"
|
|
284
|
+
return Effect.fail(
|
|
285
|
+
new ReachabilityError({
|
|
286
|
+
name,
|
|
287
|
+
expectation: "reachable",
|
|
288
|
+
reason: inconclusive ? "Inconclusive" : "NotFound",
|
|
289
|
+
message: inconclusive
|
|
290
|
+
? `Reachability of ${name} is inconclusive because exploration was truncated`
|
|
291
|
+
: `Expected ${name} to be reachable`,
|
|
292
|
+
completeness: exploration.completeness
|
|
293
|
+
})
|
|
294
|
+
)
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export const assertUnreachable = <M extends AnyMachine, Key extends ExplorationKey>(
|
|
299
|
+
exploration: Exploration<M, Key>,
|
|
300
|
+
name: string,
|
|
301
|
+
predicate: ExplorationPredicate<M, Key>
|
|
302
|
+
): Effect.Effect<void, ReachabilityError<M, Key>> => {
|
|
303
|
+
validateAssertionName(name)
|
|
304
|
+
return Effect.suspend(() => {
|
|
305
|
+
const witness = findShortest(exploration, predicate)
|
|
306
|
+
if (witness !== undefined) {
|
|
307
|
+
return Effect.fail(
|
|
308
|
+
new ReachabilityError({
|
|
309
|
+
name,
|
|
310
|
+
expectation: "unreachable",
|
|
311
|
+
reason: "UnexpectedMatch",
|
|
312
|
+
message: `Expected ${name} to be unreachable but found a witness at depth ${witness.depth}`,
|
|
313
|
+
completeness: exploration.completeness,
|
|
314
|
+
witness
|
|
315
|
+
})
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
if (exploration.completeness._tag === "Truncated") {
|
|
319
|
+
return Effect.fail(
|
|
320
|
+
new ReachabilityError({
|
|
321
|
+
name,
|
|
322
|
+
expectation: "unreachable",
|
|
323
|
+
reason: "Inconclusive",
|
|
324
|
+
message: `Unreachability of ${name} is inconclusive because exploration was truncated`,
|
|
325
|
+
completeness: exploration.completeness
|
|
326
|
+
})
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
return Effect.void
|
|
330
|
+
})
|
|
331
|
+
}
|