From 07ff05808f361de1201552d3ad9f9f94967c37cf Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Mon, 10 Aug 2026 11:11:35 +0200 Subject: [PATCH] Strengthen machine strategy guardrails --- .changeset/strong-machines-compare.md | 5 + perf/runtime/counter.mjs | 113 +++++++- src/internal/machinePlanner.ts | 142 ++++++---- src/internal/machineProcess.ts | 24 ++ src/internal/machineRuntime.ts | 52 ++++ test/MachineStrategyDifferential.test.ts | 289 ++++++++++++++++++++ test/support/machineStrategyDifferential.ts | 118 ++++++++ 7 files changed, 687 insertions(+), 56 deletions(-) create mode 100644 .changeset/strong-machines-compare.md create mode 100644 test/MachineStrategyDifferential.test.ts create mode 100644 test/support/machineStrategyDifferential.ts diff --git a/.changeset/strong-machines-compare.md b/.changeset/strong-machines-compare.md new file mode 100644 index 0000000..acd40af --- /dev/null +++ b/.changeset/strong-machines-compare.md @@ -0,0 +1,5 @@ +--- +"@typeonce/effect-machine": patch +--- + +Add direct generic/indexed planner and generic/compiled runtime strategy guardrails, including startup, targetless, reentry, invoke, snapshot-stability, and generated-model coverage. Expand the runtime benchmark suite with hierarchical and parallel planning, observed hierarchical execution, and generic process lifecycle measurements. diff --git a/perf/runtime/counter.mjs b/perf/runtime/counter.mjs index 80ebfc0..9ccd278 100644 --- a/perf/runtime/counter.mjs +++ b/perf/runtime/counter.mjs @@ -1,4 +1,4 @@ -import { readFileSync } from "node:fs" +import { existsSync, readFileSync } from "node:fs" import { createRequire } from "node:module" import { dirname, join, resolve } from "node:path" import { fileURLToPath, pathToFileURL } from "node:url" @@ -11,9 +11,14 @@ const effectPackagePath = implementationRequire.resolve("effect/package.json") const effectPackage = JSON.parse(readFileSync(effectPackagePath, "utf8")) const effect = await import(pathToFileURL(resolve(dirname(effectPackagePath), effectPackage.exports["."])).href) const { Machine } = await import(pathToFileURL(join(implementationRoot, "dist/index.js")).href) -const machineRuntime = await import( - pathToFileURL(join(implementationRoot, "dist/internal/machineRuntime.js")).href -) +const machineRuntimePath = [ + join(implementationRoot, "dist/internal/machine/runtime.js"), + join(implementationRoot, "dist/internal/machineRuntime.js") +].find(existsSync) +if (machineRuntimePath === undefined) { + throw new Error("Effect Machine benchmark could not locate the internal runtime module") +} +const machineRuntime = await import(pathToFileURL(machineRuntimePath).href) const { Effect, Fiber, Option, Schema, Stream } = effect const CounterState = Schema.TaggedUnion({ @@ -221,6 +226,12 @@ const parallelFinishEvent = makeEvent(parallelCounterMachine, HierarchicalEvent. export const initialCounterSnapshot = Effect.runSync( Machine.planInitial(counterMachine).pipe(Effect.map((planned) => planned.state)) ) +const initialHierarchicalSnapshot = Effect.runSync( + Machine.planInitial(hierarchicalCounterMachine).pipe(Effect.map((planned) => planned.state)) +) +const initialParallelSnapshot = Effect.runSync( + Machine.planInitial(parallelCounterMachine).pipe(Effect.map((planned) => planned.state)) +) export const counterValue = (snapshot) => snapshot.value.value @@ -236,6 +247,32 @@ export const planCounterBatch = (size) => { ) } +const planHierarchicalCounterBatch = (size) => + Effect.runSync( + Effect.gen(function*() { + let snapshot = initialHierarchicalSnapshot + for (let index = 0; index < size; index += 1) { + snapshot = (yield* Machine.plan(hierarchicalCounterMachine, snapshot, hierarchicalIncrementEvent)).next + } + return snapshot.state.value.value + }) + ) + +const planParallelCounterBatch = (size) => + Effect.runSync( + Effect.gen(function*() { + let snapshot = initialParallelSnapshot + for (let index = 0; index < size; index += 1) { + snapshot = (yield* Machine.plan( + parallelCounterMachine, + snapshot, + index % 2 === 0 ? parallelIncrementLeftEvent : parallelIncrementRightEvent + )).next + } + return snapshot.states.Left.value.value + snapshot.states.Right.value.value + }) + ) + export const startCounter = () => Effect.runPromise(Machine.start(counterMachine)) export const stopCounter = (ref) => Effect.runPromise(ref.stop) @@ -324,6 +361,16 @@ export const runCounterBurst = (ref, size) => const startHierarchicalCounter = () => Effect.runPromise(Machine.start(hierarchicalCounterMachine)) const startParallelCounter = () => Effect.runPromise(Machine.start(parallelCounterMachine)) +const startObservedHierarchicalCounter = () => + Effect.runPromise( + Effect.gen(function*() { + const ref = yield* Machine.start(hierarchicalCounterMachine) + const observer = yield* ref.changes.pipe(Stream.runDrain, Effect.forkDetach) + yield* Effect.yieldNow + return { ref, observer } + }) + ) + const runHierarchicalCounterBurst = (ref, size) => Effect.runPromise( Effect.gen(function*() { @@ -346,6 +393,19 @@ const runParallelCounterBurst = (ref, size) => }) ) +const runObservedHierarchicalCounterBurst = ({ ref, observer }, size) => + Effect.runPromise( + Effect.gen(function*() { + for (let index = 0; index < size; index += 1) { + yield* ref.send(hierarchicalIncrementEvent) + } + yield* ref.send(hierarchicalFinishEvent) + const value = yield* ref.join + yield* Fiber.join(observer) + return value + }) + ) + export const startCounters = (count) => Effect.runPromise( Effect.forEach( @@ -502,6 +562,26 @@ export const effectMachineAdapter = { stopObservedCounter, stopCounters, additionalMachineBenchmarks: [ + { + id: "hierarchical-plan-counter", + label: "Plan transitions through a compound state", + unit: "transitions/s", + operations: ({ planningBatchSize }) => planningBatchSize, + expected: (operations) => operations, + start: () => undefined, + run: (_, operations) => planHierarchicalCounterBatch(operations), + stop: () => undefined + }, + { + id: "parallel-plan-counter", + label: "Plan transitions through parallel regions", + unit: "transitions/s", + operations: ({ planningBatchSize }) => planningBatchSize, + expected: (operations) => operations, + start: () => undefined, + run: (_, operations) => planParallelCounterBatch(operations), + stop: () => undefined + }, { id: "hierarchical-runtime-burst", label: "Drain burst through a compound state", @@ -521,9 +601,34 @@ export const effectMachineAdapter = { start: startParallelCounter, run: runParallelCounterBurst, stop: stopCounter + }, + { + id: "observed-hierarchical-runtime-burst", + label: "Drain a compound-state burst with a change observer", + unit: "events/s", + operations: ({ burstBatchSize }) => burstBatchSize, + expected: (operations) => operations, + start: startObservedHierarchicalCounter, + run: runObservedHierarchicalCounterBurst, + stop: stopObservedCounter } ], runtimeBenchmarks: [ + { + id: "generic-process-start-stop", + label: "Start and stop a raw generic process", + unit: "processes/s", + async: true, + operations: () => 1, + run: async () => { + const refs = await startRawProcesses(1) + try { + return refs.length + } finally { + await stopCounters(refs) + } + } + }, { id: "compiled-process-start-stop", label: "Start and stop a raw compiled process", diff --git a/src/internal/machinePlanner.ts b/src/internal/machinePlanner.ts index 8a66ae4..ac67136 100644 --- a/src/internal/machinePlanner.ts +++ b/src/internal/machinePlanner.ts @@ -2724,64 +2724,102 @@ export interface CompiledExecutionPlan { const executionPlanCache = new WeakMap() +const makeActiveExecutionPlan = (machine: Machine.Any): CompiledExecutionPlan => ({ + fromConfiguration: (configuration) => configuration, + toConfiguration: (state) => state as ActiveConfiguration, + snapshot: (state) => snapshotFromConfiguration(machine, state as ActiveConfiguration), + plan: (state, event) => planConfiguration(machine as any, state as ActiveConfiguration, event as any) +}) + +const makeIndexedExecutionPlan = ( + machine: Machine.Any, + indexed: IndexedExecutionDescriptor +): CompiledExecutionPlan => ({ + fromConfiguration: (configuration) => indexedConfigurationFromActive(indexed, configuration), + toConfiguration: (state) => activeConfigurationFromIndexed(indexed, state as IndexedConfiguration), + snapshot: (state) => snapshotFromIndexed(indexed, state as IndexedConfiguration), + plan: (state, event) => planIndexedConfiguration(machine, indexed, state as IndexedConfiguration, event), + initial: (args) => { + const inputArgs = machine.input === undefined + ? args + : args.length === 0 + ? (decodeInputSync(machine, machine.input, undefined), args) + : [decodeInputSync(machine, machine.input, args[0])] + const initial = machine.initial(...inputArgs as any) + const active = normalizeConfigurationSync(machine, initial as Machine.Snapshot) + validateInitialConfiguration(machine, active) + const completed = completeConfigurationSync(machine, active, InitialEvent).configuration + const configuration = indexedConfigurationFromActive(indexed, completed) + const state = snapshotFromIndexed(indexed, configuration) + const done = isActiveFinalConfiguration(machine, completed) + if (!done) { + return { + state, + configuration, + activeConfiguration: completed, + initialEntryPaths: getInitialEntryPaths(machine, completed), + done: false, + output: undefined + } + } + const root = getRootPath(machine, completed) + if (!completed.outputs.has(root)) { + throw new Error("Machine reached a terminal configuration without a completed root output") + } + return { + state, + configuration, + activeConfiguration: completed, + initialEntryPaths: getInitialEntryPaths(machine, completed), + done: true, + output: completed.outputs.get(root) + } + } +}) + +export type ExecutionPlanStrategy = "generic" | "indexed-flat" | "indexed-hierarchical" | "auto" + +export interface SelectedExecutionPlan { + readonly strategy: Exclude + readonly plan: CompiledExecutionPlan +} + +const selectExecutionPlan = ( + machine: Machine.Any, + strategy: ExecutionPlanStrategy +): SelectedExecutionPlan => { + if (strategy === "generic") { + return { strategy, plan: makeActiveExecutionPlan(machine) } + } + const indexed = compileIndexedExecutionDescriptor(machine) + if (indexed === undefined) { + if (strategy === "auto") { + return { strategy: "generic", plan: makeActiveExecutionPlan(machine) } + } + throw new Error(`Machine cannot compile the requested ${strategy} execution plan`) + } + const selected = indexed.flat ? "indexed-flat" : "indexed-hierarchical" + if (strategy !== "auto" && strategy !== selected) { + throw new Error(`Machine compiled ${selected}, not the requested ${strategy} execution plan`) + } + return { strategy: selected, plan: makeIndexedExecutionPlan(machine, indexed) } +} + +/** @internal Test-only uncached strategy selection. */ +export const selectExecutionPlanForTesting = ( + machine: Machine.Any, + strategy: ExecutionPlanStrategy +): SelectedExecutionPlan => selectExecutionPlan(machine, strategy) + export const compileExecutionPlan = (machine: Machine.Any): CompiledExecutionPlan => { const cached = executionPlanCache.get(machine) if (cached !== undefined) { return cached } const indexed = compileIndexedExecutionDescriptor(machine) - const activePlan = ( - plan: (configuration: ActiveConfiguration, event: unknown) => MacrostepPlan - ): CompiledExecutionPlan => ({ - fromConfiguration: (configuration) => configuration, - toConfiguration: (state) => state as ActiveConfiguration, - snapshot: (state) => snapshotFromConfiguration(machine, state as ActiveConfiguration), - plan: (state, event) => plan(state as ActiveConfiguration, event) - }) - const compiled: CompiledExecutionPlan = indexed !== undefined - ? { - fromConfiguration: (configuration) => indexedConfigurationFromActive(indexed, configuration), - toConfiguration: (state) => activeConfigurationFromIndexed(indexed, state as IndexedConfiguration), - snapshot: (state) => snapshotFromIndexed(indexed, state as IndexedConfiguration), - plan: (state, event) => planIndexedConfiguration(machine, indexed, state as IndexedConfiguration, event), - initial: (args) => { - const inputArgs = machine.input === undefined - ? args - : args.length === 0 - ? (decodeInputSync(machine, machine.input, undefined), args) - : [decodeInputSync(machine, machine.input, args[0])] - const initial = machine.initial(...inputArgs as any) - const active = normalizeConfigurationSync(machine, initial as Machine.Snapshot) - validateInitialConfiguration(machine, active) - const completed = completeConfigurationSync(machine, active, InitialEvent).configuration - const configuration = indexedConfigurationFromActive(indexed, completed) - const state = snapshotFromIndexed(indexed, configuration) - const done = isActiveFinalConfiguration(machine, completed) - if (!done) { - return { - state, - configuration, - activeConfiguration: completed, - initialEntryPaths: getInitialEntryPaths(machine, completed), - done: false, - output: undefined - } - } - const root = getRootPath(machine, completed) - if (!completed.outputs.has(root)) { - throw new Error("Machine reached a terminal configuration without a completed root output") - } - return { - state, - configuration, - activeConfiguration: completed, - initialEntryPaths: getInitialEntryPaths(machine, completed), - done: true, - output: completed.outputs.get(root) - } - } - } - : activePlan((configuration, event) => planConfiguration(machine as any, configuration, event as any)) + const compiled = indexed === undefined + ? makeActiveExecutionPlan(machine) + : makeIndexedExecutionPlan(machine, indexed) executionPlanCache.set(machine, compiled) return compiled } diff --git a/src/internal/machineProcess.ts b/src/internal/machineProcess.ts index c96031b..bfce1c4 100644 --- a/src/internal/machineProcess.ts +++ b/src/internal/machineProcess.ts @@ -978,6 +978,30 @@ const toResumedProcessLogic = ( ): internalRuntime.ProcessLogic => (makeProcessLogic as any)(machine, { _tag: "Resume", snapshot }) +/** @internal Test-only runtime strategy selection for a fresh machine. */ +export const startWithRuntimeStrategyForTesting = ( + machine: Machine.Any, + strategy: internalRuntime.ProcessRuntimeStrategy, + ...args: ReadonlyArray +): Effect.Effect, any, any> => + internalRuntime.startProcessWithStrategyForTesting( + (toProcessLogic as any)(machine, ...args), + strategy, + machine.id === undefined ? undefined : { id: machine.id } + ) + +/** @internal Test-only runtime strategy selection for a resumed machine. */ +export const resumeWithRuntimeStrategyForTesting = ( + machine: Machine.Any, + snapshot: Machine.Snapshot, + strategy: internalRuntime.ProcessRuntimeStrategy +): Effect.Effect, any, any> => + internalRuntime.startProcessWithStrategyForTesting( + toResumedProcessLogic(machine, snapshot), + strategy, + machine.id === undefined ? undefined : { id: machine.id } + ) + export const start: < const States extends Machine.StateSchemas, const Events extends ReadonlyArray, diff --git a/src/internal/machineRuntime.ts b/src/internal/machineRuntime.ts index d0d6cd4..55d7afd 100644 --- a/src/internal/machineRuntime.ts +++ b/src/internal/machineRuntime.ts @@ -2191,6 +2191,58 @@ const startLogicInternal: typeof startGenericInternal = (( ? startCompactCompiledInternal(logic, options) : startGenericInternal(logic, options)) as typeof startGenericInternal +export type ProcessRuntimeStrategy = "generic" | "compiled" | "auto" + +const startProcessWithStrategy = Effect.fnUntraced(function*( + logic: ProcessLogic, + strategy: ProcessRuntimeStrategy, + options?: { + readonly id?: string + } +) { + const runtime = yield* makeProcessRuntime + const internalOptions: StartInternalOptions = options === undefined + ? { + detached: true, + runtime + } + : { + ...options, + detached: true, + runtime + } + if (strategy === "generic") { + return yield* startGenericInternal(logic, internalOptions) + } + if (strategy === "compiled") { + if (logic[compiledProcess] !== true || (logic.drain === undefined && logic[compiledProcessDrain] === undefined)) { + return yield* Effect.die(new Error("Machine cannot force the compiled runtime for generic process logic")) + } + return yield* startCompactCompiledInternal(logic, internalOptions) + } + return yield* startLogicInternal(logic, internalOptions) +}) + +/** @internal Test-only startup strategy selection. */ +export const startProcessWithStrategyForTesting = < + State, + Event, + Error = never, + Requirements = never, + Output = never, + InitialError = never +>( + logic: ProcessLogic, + strategy: ProcessRuntimeStrategy, + options?: { + readonly id?: string + } +): Effect.Effect< + MachineRef, + InitialError, + Requirements +> => startProcessWithStrategy(logic, strategy, options) as any + export const startProcess: < State, Event, diff --git a/test/MachineStrategyDifferential.test.ts b/test/MachineStrategyDifferential.test.ts new file mode 100644 index 0000000..3bfc4a2 --- /dev/null +++ b/test/MachineStrategyDifferential.test.ts @@ -0,0 +1,289 @@ +import { assert, describe, it } from "@effect/vitest" +import { Effect, Fiber, Schema, Stream } from "effect" +import { FastCheck } from "effect/testing" +import { Machine } from "../src/index.js" +import * as Planner from "../src/internal/machinePlanner.js" +import { MachineTest } from "../src/testing.js" +import type { DifferentialStep } from "./support/machineRuntimeDifferential.js" +import { verifyManagedExecution } from "./support/machineRuntimeDifferential.js" +import { openWithRuntimeStrategy, verifyPlannerStrategies } from "./support/machineStrategyDifferential.js" + +class Count extends Schema.TaggedClass("StrategyCount")("Count", { + value: Schema.Number +}) {} +class Done extends Schema.TaggedClass("StrategyDone")("Done", { + value: Schema.Number +}) {} +class Noop extends Schema.TaggedClass("StrategyNoop")("Noop", {}) {} +class Increment extends Schema.TaggedClass("StrategyIncrement")("Increment", {}) {} +class Reenter extends Schema.TaggedClass("StrategyReenter")("Reenter", {}) {} +class Finish extends Schema.TaggedClass("StrategyFinish")("Finish", {}) {} + +const makeFlatMachine = () => { + const states = Machine.defineStates({ + Count, + Done: { schema: Done, type: "final", output: Schema.Number } + }) + return Machine.make({ + states: states.states, + events: [Noop, Increment, Reenter, Finish], + initial: () => states.initial.Count(new Count({ value: 0 })) + }).handle({ + Count: { + on: { + Noop: () => undefined, + Increment: ({ state, target }) => target.full.Count(new Count({ value: state.value + 1 })), + Reenter: { + reenter: true, + transition: ({ state, target }) => target.full.Count(new Count({ value: state.value })) + }, + Finish: ({ state, target }) => target.full.Done(new Done({ value: state.value })) + } + }, + Done: { output: ({ state }) => state.value } + }) +} + +describe("machine planner and runtime strategies", () => { + it.effect("matches generic and indexed-flat planning including targetless and reentering transitions", () => + verifyPlannerStrategies({ + machine: makeFlatMachine(), + events: [new Noop({}), new Increment({}), new Reenter({}), new Finish({})], + expected: "indexed-flat", + label: "flat strategy" + })) + + it.effect("matches generic and indexed-hierarchical planning across simultaneous parallel transitions", () => + Effect.gen(function*() { + class Root extends Schema.TaggedClass("StrategyRoot")("Root", {}) {} + class Left extends Schema.TaggedClass("StrategyLeft")("Left", { value: Schema.Number }) {} + class Right extends Schema.TaggedClass("StrategyRight")("Right", { value: Schema.Number }) {} + class Advance extends Schema.TaggedClass("StrategyAdvance")("Advance", {}) {} + const states = Machine.defineStates({ + Root: { + schema: Root, + type: "parallel", + states: { Left, Right } + } + }) + const machine = Machine.make({ + states: states.states, + events: [Advance], + initial: () => + states.initial.Root( + new Root({}), + (root) => root.Left(new Left({ value: 0 })).Right(new Right({ value: 0 })) + ) + }).handle({ + Root: { + states: { + Left: { + on: { + Advance: ({ state, target }) => target.branch.Root.Left(new Left({ value: state.value + 1 })) + } + }, + Right: { + on: { + Advance: ({ state, target }) => target.branch.Root.Right(new Right({ value: state.value + 10 })) + } + } + } + } + }) + + yield* verifyPlannerStrategies({ + machine, + events: [new Advance({}), new Advance({})], + expected: "indexed-hierarchical", + label: "hierarchical strategy" + }) + })) + + it.effect("falls back to the generic planner for unsupported automatic transitions", () => + Effect.gen(function*() { + class Idle extends Schema.TaggedClass("StrategyFallbackIdle")("Idle", {}) {} + class Ready extends Schema.TaggedClass("StrategyFallbackReady")("Ready", {}) {} + const states = Machine.defineStates({ Idle, Ready }) + const machine = Machine.make({ + states: states.states, + events: [], + initial: () => states.initial.Idle(new Idle({})) + }).handle({ + Idle: { always: ({ target }) => target.full.Ready(new Ready({})) }, + Ready: {} + }) + + assert.strictEqual(Planner.selectExecutionPlanForTesting(machine, "auto").strategy, "generic") + yield* verifyPlannerStrategies({ + machine, + events: [], + expected: "generic", + label: "automatic fallback" + }) + })) + + it.effect("matches indexed startup for decoded input and an initially final machine", () => + Effect.gen(function*() { + const Input = Schema.Struct({ value: Schema.Number }) + class Complete extends Schema.TaggedClass("StrategyComplete")("Complete", { + value: Schema.Number + }) {} + const states = Machine.defineStates({ + Complete: { schema: Complete, type: "final", output: Schema.Number } + }) + const machine = Machine.make({ + states: states.states, + events: [], + input: Input, + initial: (input) => states.initial.Complete(new Complete({ value: input.value })) + }).handle({ + Complete: { output: ({ state }) => state.value } + }) + + yield* verifyPlannerStrategies({ + machine, + initialArgs: [{ value: 42 }], + events: [], + expected: "indexed-flat", + label: "initially final startup" + }) + + const compiledInitial = Planner.selectExecutionPlanForTesting(machine, "indexed-flat").plan.initial! + assert.throws( + () => compiledInitial([{ value: "invalid" }]), + Machine.MachineSchemaDecodeError + ) + })) + + it.effect("matches generic and compiled managed runtimes for targetless, reentering, and terminal events", () => + Effect.gen(function*() { + const machine = makeFlatMachine() + const initial = yield* Machine.planInitial(machine) + const events = [new Noop({}), new Increment({}), new Reenter({}), new Finish({})] + const steps: Array = [] + let state = initial.state + for (const event of events) { + const plan = yield* Machine.plan(machine, state, event) + steps.push({ event, plan }) + state = plan.next + } + + for (const strategy of ["generic", "compiled"] as const) { + yield* verifyManagedExecution({ + machine, + open: openWithRuntimeStrategy(machine, strategy), + initial: { state: initial.state, done: initial.done, output: initial.output }, + steps, + label: `${strategy} runtime` + }) + } + }) as Effect.Effect) + + it.effect("does not mutate retained public snapshots in either runtime strategy", () => + Effect.gen(function*() { + const machine = makeFlatMachine() + for (const strategy of ["generic", "compiled"] as const) { + const ref = yield* openWithRuntimeStrategy(machine, strategy) + const retained = yield* ref.snapshot + const retainedEncoding = yield* Machine.encodeSnapshot(machine, retained.state) + const updated = yield* ref.changes.pipe( + Stream.drop(1), + Stream.filter((snapshot) => snapshot.status === "active" && snapshot.state.value.value === 1), + Stream.take(1), + Stream.runDrain, + Effect.forkChild({ startImmediately: true }) + ) + yield* ref.send(new Increment({})) + yield* Fiber.join(updated) + + assert.deepStrictEqual(yield* Machine.encodeSnapshot(machine, retained.state), retainedEncoding) + assert.strictEqual(retained.status, "active") + assert.strictEqual(retained.state.value.value, 0) + yield* ref.stop + } + })) + + it.effect("matches generic and compiled invoke completion traces", () => + Effect.gen(function*() { + class Idle extends Schema.TaggedClass("StrategyInvokeIdle")("Idle", {}) {} + class Loading extends Schema.TaggedClass("StrategyInvokeLoading")("Loading", {}) {} + class Success extends Schema.TaggedClass("StrategyInvokeSuccess")("Success", { + value: Schema.String + }) {} + class Load extends Schema.TaggedClass("StrategyInvokeLoad")("Load", {}) {} + class Loaded extends Schema.TaggedClass("StrategyInvokeLoaded")("Loaded", { + value: Schema.String + }) {} + const states = Machine.defineStates({ + Idle, + Loading, + Success: { schema: Success, type: "final", output: Schema.String } + }) + const machine = Machine.make({ + states: states.states, + events: [Load, Loaded], + initial: () => states.initial.Idle(new Idle({})) + }).handle({ + Idle: { + on: { Load: () => states.initial.Loading(new Loading({})) } + }, + Loading: { + invoke: Machine.invoke({ + id: "load", + src: () => Machine.effect(Effect.succeed(new Loaded({ value: "complete" }))) + }), + on: { + Loaded: ({ event }) => states.initial.Success(new Success({ value: event.value })) + } + }, + Success: { output: ({ state }) => state.value } + }) + + const results: Array = [] + for (const strategy of ["generic", "compiled"] as const) { + const ref = yield* openWithRuntimeStrategy(machine, strategy) + yield* ref.send(new Load({})) + const output = yield* ref.join + const snapshot = yield* ref.snapshot + results.push({ + output, + status: snapshot.status, + state: yield* Machine.encodeSnapshot(machine, snapshot.state) + }) + } + assert.deepStrictEqual(results[1], results[0]) + }) as Effect.Effect) + + it.effect("compares generated eligible models across canonical and indexed planners", () => + Effect.gen(function*() { + const generated = MachineTest.finiteModels({ + maxRoots: 2, + maxDepth: 3, + maxChildren: 3, + maxParallelRegions: 3, + maxEvents: 3, + maxTransitions: 10, + maxHistoryStates: 0, + maxChoiceStates: 0 + }) + const samples = FastCheck.sample(generated.arbitrary, { numRuns: 120, seed: 81_109 }) + let compared = 0 + for (let index = 0; index < samples.length && compared < 24; index++) { + const model = samples[index]! + const machine = MachineTest.compileModel(model) + const selected = Planner.selectExecutionPlanForTesting(machine, "auto").strategy + if (selected === "generic") continue + const events = Array.from({ length: 6 }, (_, eventIndex) => ({ + _tag: model.events[(index + eventIndex) % model.events.length]! + })) + yield* verifyPlannerStrategies({ + machine, + events, + expected: selected, + label: `generated strategy ${index}` + }) + compared += 1 + } + assert.ok(compared >= 12, `expected at least 12 indexed generated models, compared ${compared}`) + }), 30_000) +}) diff --git a/test/support/machineStrategyDifferential.ts b/test/support/machineStrategyDifferential.ts new file mode 100644 index 0000000..af837eb --- /dev/null +++ b/test/support/machineStrategyDifferential.ts @@ -0,0 +1,118 @@ +import { assert } from "@effect/vitest" +import { Effect } from "effect" +import { Machine } from "../../src/index.js" +import * as Model from "../../src/internal/machineModel.js" +import * as Planner from "../../src/internal/machinePlanner.js" +import * as Process from "../../src/internal/machineProcess.js" + +const eventTag = (event: unknown): PropertyKey | undefined => + typeof event === "object" && event !== null && "_tag" in event + ? (event as { readonly _tag: PropertyKey })._tag + : undefined + +const commandTags = (commands: ReadonlyArray<{ readonly _tag: string }>): ReadonlyArray => + commands.map((command) => command._tag) + +const encodeState = ( + machine: Machine.Machine.Any, + state: unknown +): Effect.Effect => Machine.encodeSnapshot(machine as any, state as any) + +const canonicalMacrostep = Effect.fn(function*( + machine: Machine.Machine.Any, + executionPlan: Planner.CompiledExecutionPlan, + planned: ReturnType +) { + return { + next: yield* encodeState(machine, executionPlan.snapshot(planned.next)), + commands: commandTags(planned.commands), + emittedEvents: planned.emittedEvents, + microsteps: yield* Effect.forEach( + planned.microsteps, + (step) => + Effect.map(encodeState(machine, executionPlan.snapshot(step.next)), (next) => ({ + next, + event: eventTag(step.event), + commands: commandTags(step.commands), + raisedEvents: step.raisedEvents.map(eventTag), + emittedEvents: step.emittedEvents, + exitPaths: step.exitPaths, + entryPaths: step.entryPaths, + changed: step.changed + })) + ), + done: planned.done, + output: planned.output + } +}) + +const verifyPlannerStrategiesEffect = Effect.fn(function*(options: { + readonly machine: Machine.Machine.Any + readonly events: ReadonlyArray<{ readonly _tag: PropertyKey }> + readonly expected?: "indexed-flat" | "indexed-hierarchical" | "generic" + readonly initialArgs?: ReadonlyArray + readonly label: string +}) { + const initialArgs = options.initialArgs ?? [] + const initial = yield* (Machine.planInitial as any)(options.machine, ...initialArgs) as Effect.Effect< + any, + unknown, + never + > + const generic = Planner.selectExecutionPlanForTesting(options.machine, "generic") + const selected = Planner.selectExecutionPlanForTesting(options.machine, "auto") + if (options.expected !== undefined) { + assert.strictEqual(selected.strategy, options.expected, `${options.label} selected strategy`) + } + + if (selected.plan.initial !== undefined) { + const compiledInitial = selected.plan.initial(initialArgs) + assert.deepStrictEqual( + yield* encodeState(options.machine, compiledInitial.state), + yield* encodeState(options.machine, initial.state), + `${options.label} compiled initial state` + ) + assert.deepStrictEqual(compiledInitial.initialEntryPaths, initial.initialEntryPaths) + assert.strictEqual(compiledInitial.done, initial.done) + assert.deepStrictEqual(compiledInitial.output, initial.output) + } + + const active = Model.normalizeConfigurationSync(options.machine, initial.state) + let genericState = generic.plan.fromConfiguration(active) + let selectedState = selected.plan.fromConfiguration(active) + for (let index = 0; index < options.events.length; index++) { + const event = options.events[index]! + const genericPlan = generic.plan.plan(genericState, event) + const selectedPlan = selected.plan.plan(selectedState, event) + assert.deepStrictEqual( + yield* canonicalMacrostep(options.machine, selected.plan, selectedPlan), + yield* canonicalMacrostep(options.machine, generic.plan, genericPlan), + `${options.label} event ${index}:${String(event._tag)}` + ) + genericState = genericPlan.next + selectedState = selectedPlan.next + if (genericPlan.done) break + } + return selected.strategy +}) + +export const verifyPlannerStrategies: (options: { + readonly machine: Machine.Machine.Any + readonly events: ReadonlyArray<{ readonly _tag: PropertyKey }> + readonly expected?: "indexed-flat" | "indexed-hierarchical" | "generic" + readonly initialArgs?: ReadonlyArray + readonly label: string +}) => Effect.Effect<"indexed-flat" | "indexed-hierarchical" | "generic", unknown> = verifyPlannerStrategiesEffect as any + +export const openWithRuntimeStrategy = ( + machine: Machine.Machine.Any, + strategy: "generic" | "compiled" +): Effect.Effect, unknown> => + Process.startWithRuntimeStrategyForTesting(machine, strategy) as any + +export const resumeWithRuntimeStrategy = ( + machine: Machine.Machine.Any, + snapshot: Machine.Machine.Snapshot, + strategy: "generic" | "compiled" +): Effect.Effect, unknown> => + Process.resumeWithRuntimeStrategyForTesting(machine, snapshot, strategy) as any