Skip to content

Type planner target resolution and runtime child operations - #261

Merged
SandroMaglione merged 1 commit into
mainfrom
planner-typed-transitions
Nov 1, 2026
Merged

SandroMaglione merged 1 commit into
mainfrom
planner-typed-transitions

Conversation

@SandroMaglione

Copy link
Copy Markdown
Member

Summary

This is part 2 of the planner/execution-layer cast cleanup; part 1 (#258) covers executionPlan.ts. The two PRs are independent: each branches from main, they touch disjoint hunks, and I checked locally that they merge cleanly and that the combined tree type-checks and passes all 901 tests. No public API change. All emitted .d.ts changes are under internal/, which the package does not export.

Casts (grep -c "as any" / grep -c "as unknown as"):

File Before After
internal/machine/planner.ts 28 / 4 1 / 0
internal/machine/machine.ts 4 / 2 0 / 1
internal/machine/invocation.ts 2 / 3 0 / 0
internal/testing/machine/finiteModel.ts 4 / 0 3 / 0
packages/*/src total 99 / 23 65 / 15 (46 / 14 combined with #258)

How:

  • Resolved targets (12 casts). resolveInitialTarget and resolveHistoryTarget rebuilt targets with the typed public makeTarget(path as any, value as any, { snapshot: snapshot as any, values: values as any }). They now use Topology.makeResolvedTarget, an erased constructor for already-decoded targets. It has one documented assertion, needed because it keeps makeTarget's exact own-property shape (values present when undefined), which the optional public values field cannot express.
  • Choice routes. resolveChoiceTarget now returns ChoiceRoute (Target | Snapshot | HistoryTarget | InitialTarget), narrowed by its existing final check, instead of unknown. Additional choice targets are ResolvedTarget.
  • Validating helpers take what they validate. getTargetNodePath and normalizeTargetConfigurationSync already check their input at runtime and throw for anything else, so they now accept unknown instead of fake-generic States unions. normalizeConfigurationSync takes AtomicSnapshot<string, unknown>, which is what it forwards. Three now-redundant as Machine.Snapshot<any> | Machine.Target<any, any> assertions go away with them. Two white-box tests drop the type argument they passed.
  • Transition event. collectEvaluatedTransition constrains Context extends { readonly event?: unknown }, replacing five (selection.context as any).event.
  • Unnecessary casts. Four as unknown as MicrostepTransition<…> and the machine as any, decodedEvent as any, and output as any sites in canSync, settle, and macrostepConfiguration compile without any cast.
  • Runtime child operations (machine.ts). Internal spawn, sendTo, and stopChild duplicated the public overload signatures and were assigned with as any. Following the pattern from Check public Machine operations against their implementations #223, they are now erased implementations, and Machine.ts checks them structurally against its public overloads. The duplicate SpawnResult/SpawnError helper types were removed.
  • Invocation sources (invocation.ts). oneShot and streamLogic are typed as ProcessLogic that ignores events (Event = unknown), so they fit AnyConfig.src without as unknown as. Timer durations narrow raw.after directly. ChildOwner.spawn forwards through an explicitly generic wrapper (scope.spawn<Child>(child, ...options)) instead of (scope.spawn as any).

Casts deliberately kept, each with a one-line comment:

  • planner.ts: completeConfigurationSync(…, decodedEvent as any). Completion contexts receive the triggering event, which can be a private invocation event that the public LifecycleEvent type does not model. Widening it would mean importing invocation events into configuration.ts.
  • machine.ts: stateNodes keeps as unknown as. Compiled nodes are stored with string paths, and only the generic machine type carries their literal identifiers. (getSnapshot dropped its unknown hop.)
  • finiteModel.ts: three casts remain where a runtime-built model enters the literal-typed public constructors. The redundant inner eventsFromSchemas(...) as any was removed.

Behavior. I compiled src for base and PR and diffed the output. Apart from comments and shorthand properties, there are three changes, all equivalent for every call the public types allow:

  1. Machine.spawn(logic, options) now forwards { id: options.id } and calls runtime.spawn(logic) when no id is given. The runtime already treated an absent id and no options identically. SpawnOptions declares only id, so internal spawn fields (onOutcome, descriptor) that an untyped caller could previously smuggle through are no longer forwarded.
  2. ChildOwner.spawn spreads its optional options argument instead of always passing a second argument. The runtime reads options?.input either way.
  3. Timer invocations inline the same typeof after === "function" check that resolveValue performed.

Changeset

Validation

  • pnpm check (92 files / 901 tests, all tstyche suites, pack and consumer checks), including the forced generic-versus-compiled differential suites
  • Automated type-performance measurement passed or was not required. Locally, pnpm perf:types showed 0 instantiation change in every scenario. Type-checking packages/effect-machine itself went from 419,337 to 413,361 instantiations (−5,976, −1.4%) and from 91,980 to 91,544 types. Check time showed no measurable difference in interleaved runs (≈2.5 s for both).
  • Automated runtime- and memory-performance measurement passed or was not required. I ran pnpm perf:runtime base and PR twice each, alternating. Rows with low variability moved by ±3% (plan counter +3.2%, compound −3.1%, parallel −2.0%), and memory was unchanged. Rows with 34–71% MAD swung both ways because of host load. The CI base-versus-PR comparison is authoritative.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Type performance

Measured with TypeScript 6.0.3 and skipLibCheck=true.

Scenario Base PR Difference
Effect only 55 55 0 (0.0%)
Import effect-machine 55 55 0 (0.0%)
Machine.state (3 children) 2,664 2,664 0 (0.0%)
Machine.make (3 states, 2 events) 8,932 8,932 0 (0.0%)
machine.handle (3 states, 2 transitions) 39,449 39,449 0 (0.0%)
declared transition (10 named branches) 60,402 60,402 0 (0.0%)
atomic construction, guards, and reentry 85,710 85,710 0 (0.0%)
registered invocation (input-taking Effect) 39,819 39,819 0 (0.0%)
machine.handle (depth 24) 194,531 194,531 0 (0.0%)
machine.handle (wide depth 16) 218,238 218,238 0 (0.0%)
machine.handle (parallel/history/choice) 91,642 91,642 0 (0.0%)
machine definition (3 independent implementations) 99,173 99,173 0 (0.0%)
machine exact input/output/error/services 82,941 82,941 0 (0.0%)
execution adapter readiness 71,428 71,428 0 (0.0%)

Marginal instantiations are measured against the matching setup without that API call:

Scenario Base PR Difference
Import effect-machine 0 0 0
Machine.state (3 children) 2,609 2,609 0 (0.0%)
Machine.make (3 states, 2 events) 6,260 6,260 0 (0.0%)
machine.handle (3 states, 2 transitions) 30,517 30,517 0 (0.0%)
declared transition (10 named branches) 51,849 51,849 0 (0.0%)
atomic construction, guards, and reentry 70,370 70,370 0 (0.0%)
registered invocation (input-taking Effect) 32,682 32,682 0 (0.0%)
machine.handle (depth 24) 177,222 177,222 0 (0.0%)
machine.handle (wide depth 16) 201,707 201,707 0 (0.0%)
machine.handle (parallel/history/choice) 79,144 79,144 0 (0.0%)
machine definition (3 independent implementations) 88,492 88,492 0 (0.0%)
machine exact input/output/error/services 70,732 70,732 0 (0.0%)
execution adapter readiness 31,922 31,922 0 (0.0%)
Check times (informational)
Scenario Base PR
Effect only 0.03s 0.04s
Import effect-machine 0.03s 0.03s
Machine.state (3 children) 0.09s 0.09s
Machine.make (3 states, 2 events) 0.17s 0.17s
machine.handle (3 states, 2 transitions) 0.32s 0.32s
declared transition (10 named branches) 0.44s 0.40s
atomic construction, guards, and reentry 0.53s 0.52s
registered invocation (input-taking Effect) 0.32s 0.32s
machine.handle (depth 24) 0.68s 0.67s
machine.handle (wide depth 16) 0.74s 0.71s
machine.handle (parallel/history/choice) 0.52s 0.54s
machine definition (3 independent implementations) 0.55s 0.56s
machine exact input/output/error/services 0.52s 0.50s
execution adapter readiness 0.45s 0.45s

Type instantiations are the comparison metric. Check time varies with runner load and is informational only.

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

Runtime performance

Median of 5 independent benchmark processes on AMD EPYC 9V45 96-Core Processor with Node v24.22.1.

Pull request baseline

Scenario Effect Machine
Plan counter transitions 197,843 transitions/s
Drain burst with terminal fence 695,716 increments/s
Drain burst with a change observer 654,440 increments/s
Lookup and send to one child 566,393 increments/s
Start and stop a machine 209,336 machines/s
Start and stop a parent with one child 65,863 families/s
Complete an Effect invocation with tracing enabled 24,974 invocations/s
Complete an Effect invocation with tracing disabled 26,741 invocations/s
Plan transitions through a compound state 180,828 transitions/s
Plan transitions through parallel regions 145,153 transitions/s
Drain burst through a compound state 573,463 events/s
Drain burst through two parallel regions 537,947 events/s
Drain a compound-state burst with a change observer 547,793 events/s

Process runtime reference points

Scenario Effect Machine
Start and stop a raw generic process 58,119 processes/s
Start and stop a raw compiled process 128,008 processes/s
Memory profile Effect Machine
Idle machine 1.9 KiB
Raw generic managed process 11.4 KiB
Raw compiled process 3.1 KiB
Two independent idle machines 3.5 KiB
Idle parent with one child 6.0 KiB
Parent with observed child registry 9.6 KiB
Parent with observed invoked child snapshots 6.9 KiB

Effect Machine change from base

Metric Base Base variability PR PR variability Difference
Plan counter transitions 201,912 transitions/s 1.6% MAD 197,843 transitions/s 0.7% MAD -2.0%
Drain burst with terminal fence 685,706 increments/s 0.9% MAD 695,716 increments/s 0.8% MAD +1.5%
Drain burst with a change observer 648,896 increments/s 0.7% MAD 654,440 increments/s 1.9% MAD +0.9%
Lookup and send to one child 557,696 increments/s 0.4% MAD 566,393 increments/s 1.1% MAD +1.6%
Start and stop a machine 208,030 machines/s 1.0% MAD 209,336 machines/s 0.4% MAD +0.6%
Start and stop a parent with one child 65,604 families/s 0.8% MAD 65,863 families/s 0.1% MAD +0.4%
Complete an Effect invocation with tracing enabled 24,642 invocations/s 0.4% MAD 24,974 invocations/s 1.3% MAD +1.3%
Complete an Effect invocation with tracing disabled 26,892 invocations/s 0.4% MAD 26,741 invocations/s 0.5% MAD -0.6%
Plan transitions through a compound state 180,302 transitions/s 0.5% MAD 180,828 transitions/s 0.5% MAD +0.3%
Plan transitions through parallel regions 145,940 transitions/s 1.3% MAD 145,153 transitions/s 1.9% MAD -0.5%
Drain burst through a compound state 567,263 events/s 2.3% MAD 573,463 events/s 0.4% MAD +1.1%
Drain burst through two parallel regions 523,528 events/s 0.9% MAD 537,947 events/s 0.9% MAD +2.8%
Drain a compound-state burst with a change observer 535,271 events/s 1.6% MAD 547,793 events/s 0.4% MAD +2.3%
Idle machine heap per unit 1.9 KiB 1.1% MAD 1.9 KiB 0.3% MAD -0.7%
Raw generic managed process heap per unit 11.3 KiB 0.0% MAD 11.4 KiB 0.0% MAD +0.9%
Raw compiled process heap per unit 3.1 KiB 0.1% MAD 3.1 KiB 0.1% MAD -0.0%
Two independent idle machines heap per unit 3.6 KiB 0.1% MAD 3.5 KiB 0.6% MAD -0.1%
Idle parent with one child heap per unit 6.0 KiB 0.1% MAD 6.0 KiB 0.0% MAD -0.0%
Parent with observed child registry heap per unit 9.6 KiB 0.0% MAD 9.6 KiB 0.0% MAD 0.0%
Parent with observed invoked child snapshots heap per unit 6.9 KiB 0.2% MAD 6.9 KiB 0.1% MAD +0.0%

Process runtime reference change from base

Metric Base Base variability PR PR variability Difference
Start and stop a raw generic process 58,221 processes/s 1.7% MAD 58,119 processes/s 1.1% MAD -0.2%
Start and stop a raw compiled process 128,502 processes/s 1.7% MAD 128,008 processes/s 1.0% MAD -0.4%

Regression guard

No large, noise-adjusted throughput or heap regressions detected.

Versions and interpretation
  • Effect Machine: 0.46.0

Higher throughput is better; lower heap is better. Variability is the median absolute deviation across independent processes, relative to their median. Small differences on shared GitHub-hosted hardware remain informational; the required guard rejects only large changes beyond the measured noise allowance.

Build resolved history and initial targets through one erased constructor,
type choice routes, let validating normalizers accept unknown input, and
expose the transition event through the evaluated context's type. Replace the
overloaded internal spawn, sendTo, and stopChild with erased implementations
checked by the public Machine signatures, and type invocation sources as
event-agnostic process logic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@SandroMaglione
SandroMaglione force-pushed the planner-typed-transitions branch from 79c96d4 to ac855f3 Compare November 1, 2026 09:38
@SandroMaglione
SandroMaglione merged commit 7ffcab7 into main Nov 1, 2026
9 checks passed
@SandroMaglione
SandroMaglione deleted the planner-typed-transitions branch November 1, 2026 09:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant