Skip to content

Commit f3e1e78

Browse files
Add causal runtime verification laws (#88)
1 parent c01c7d2 commit f3e1e78

13 files changed

Lines changed: 1283 additions & 19 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Add reusable runtime invariants, law-oriented causal command verification, and
6+
an explicit planner/runtime agreement check. Causal probe microsteps now retain
7+
stable public snapshots even when the optimized runtime reuses internal state.

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,56 @@ bursts or retains outstanding mailbox work. Its model steps continue to use
811811
`formatRuntimeTranscript` names are deprecated aliases for the enqueue-oriented
812812
runner and formatter because their delivery semantics were not visible.
813813

814+
### Runtime invariants and planner agreement
815+
816+
Planner invariants and runtime invariants are deliberately separate. Runtime
817+
laws inspect causal command evidence, explicit asynchronous observations, and
818+
runtime status without requiring a duplicate reference model:
819+
820+
```ts
821+
const invariant = MachineTest.runtimeInvariants(machine)
822+
const laws = [
823+
invariant.snapshot("count never becomes negative", ({ snapshot }) => snapshot.state.value.count >= 0),
824+
invariant.command(
825+
"every accepted add is processed",
826+
({ command, result }) =>
827+
command._tag !== "Send" || command.event._tag !== "Add" ||
828+
result._tag === "SendProcessed"
829+
)
830+
]
831+
832+
const transcript = yield * MachineTest.verifyCausalCommands(
833+
probe,
834+
commands,
835+
{ invariants: laws }
836+
)
837+
```
838+
839+
Use the existing `runCausalCommands` when a simplified application model
840+
provides exact expected results. Its returned transcript implements the same
841+
model-independent evidence interface, so reusable runtime laws compose with
842+
it directly:
843+
844+
```ts
845+
const transcript = yield * MachineTest.runCausalCommands(probe, commands, model)
846+
847+
yield * MachineTest.assertRuntimeInvariants(machine, transcript, laws)
848+
yield * MachineTest.assertPlannerRuntimeAgreement(machine, transcript)
849+
```
850+
851+
`checkRuntimeInvariants` returns an aggregate report; `assertRuntimeInvariants`
852+
fails with every predicate and non-vacuity violation. Snapshot laws observe the
853+
initial and post-command snapshots by default. Select `"awaited"`, `"all"`, or
854+
`"final"` explicitly when a law targets observations retained by
855+
`probe.await.until` or only the final runtime snapshot.
856+
857+
`assertPlannerRuntimeAgreement` is an explicit consistency check, not an
858+
application oracle. For each processed send it freshly plans from the receipt's
859+
`before` snapshot and compares handled/change flags, the public next snapshots,
860+
completion, command counts, emitted events, and public microstep evidence. It
861+
does not prove that the planner implements the intended business rules; use a
862+
reference model and runtime invariants for that.
863+
814864
## Current limits
815865

816866
Declarative first-class guards are not part of the current API. Ordinary

docs/agent-guide.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,37 @@ policies observe public snapshots but do not turn send acceptance into causal
840840
completion. Do not use the deprecated `runRuntimeCommands` name in new code;
841841
it is an alias for enqueue behavior and hides that important distinction.
842842

843+
For semantic laws over live execution, bind runtime invariant constructors to
844+
the machine and use the law-oriented causal verifier:
845+
846+
```ts
847+
const invariant = MachineTest.runtimeInvariants(machine)
848+
849+
const laws = [
850+
invariant.snapshot("balance never becomes negative", ({ snapshot }) =>
851+
snapshot.state.value.balance >= 0
852+
),
853+
invariant.command("stopped sends are rejected", ({ previous, result }) =>
854+
previous?.result._tag !== "Stopped" || result._tag === "SendRejected"
855+
)
856+
]
857+
858+
yield* MachineTest.verifyCausalCommands(probe, commands, { invariants: laws })
859+
```
860+
861+
Do not create a dummy model merely to run runtime laws. Continue to use
862+
`runCausalCommands` when an independent simplified model supplies exact
863+
expected results, then apply the same laws to its returned transcript with
864+
`assertRuntimeInvariants`. Conditional laws that must execute should declare
865+
`require.minObservations` so irrelevant generated commands cannot pass them
866+
vacuously.
867+
868+
Use `assertPlannerRuntimeAgreement(machine, transcript)` only to check the
869+
managed runtime boundary against a fresh pure plan. It is not an independent
870+
business oracle and is intentionally an explicit operation rather than a
871+
generic conformance mode. Combine it with application runtime laws or a
872+
reference model when correctness of the expected behavior matters.
873+
843874
## Common compiler errors
844875

845876
### `initial` is not callable

src/internal/machine/executionPlan.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,20 @@ interface OwnedIndexedState {
194194
readonly completedOrder: ReadonlyArray<number>
195195
}
196196

197+
const copyOwnedIndexedState = (state: OwnedIndexedState): OwnedIndexedState => ({
198+
active: state.active.slice(),
199+
activeLeaves: state.activeLeaves.slice(),
200+
values: state.values.slice(),
201+
completed: state.completed.slice(),
202+
outputs: state.outputs.slice(),
203+
completedOrder: state.completedOrder.slice()
204+
})
205+
206+
const retainIndexedMicrostep = (
207+
step: ExecutionMicrostep<OwnedIndexedState>,
208+
retain: boolean
209+
): ExecutionMicrostep<OwnedIndexedState> => retain ? { ...step, next: copyOwnedIndexedState(step.next) } : step
210+
197211
const updateOwnedIndexedValue = (
198212
state: OwnedIndexedState,
199213
index: number,
@@ -607,7 +621,8 @@ const planIndexedFlatState = (
607621
machine: Machine.Any,
608622
descriptor: IndexedExecutionDescriptor,
609623
configuration: OwnedIndexedState,
610-
decoded: { readonly _tag: PropertyKey }
624+
decoded: { readonly _tag: PropertyKey },
625+
retainMicrosteps: boolean
611626
): ExecutionMacrostep<OwnedIndexedState> => {
612627
let current = configuration
613628
let event: any = decoded
@@ -716,7 +731,7 @@ const planIndexedFlatState = (
716731
changed
717732
}
718733
current = next
719-
;(microsteps ??= []).push(step)
734+
;(microsteps ??= []).push(retainIndexedMicrostep(step, retainMicrosteps))
720735
if (transitionResult.commands.length > 0) {
721736
;(commands ??= []).push(...transitionResult.commands)
722737
}
@@ -751,11 +766,12 @@ const planIndexedState = (
751766
machine: Machine.Any,
752767
descriptor: IndexedExecutionDescriptor,
753768
configuration: OwnedIndexedState,
754-
input: unknown
769+
input: unknown,
770+
retainMicrosteps: boolean
755771
): ExecutionMacrostep<OwnedIndexedState> => {
756772
const decoded = decodeEventSync(machine, input)
757773
if (descriptor.flat) {
758-
return planIndexedFlatState(machine, descriptor, configuration, decoded)
774+
return planIndexedFlatState(machine, descriptor, configuration, decoded, retainMicrosteps)
759775
}
760776
if (descriptor.finalIndices.some((index) => configuration.active[index] === 1)) {
761777
const active = activeConfigurationFromIndexedState(descriptor, configuration)
@@ -794,7 +810,7 @@ const planIndexedState = (
794810
const commands = [...first.commands]
795811
const raisedEvents = [...first.raisedEvents]
796812
const emittedEvents = [...first.emittedEvents]
797-
const microsteps = [first]
813+
const microsteps = [retainIndexedMicrostep(first, retainMicrosteps)]
798814
let raisedIndex = 0
799815
let iterations = 0
800816

@@ -851,7 +867,7 @@ const planIndexedState = (
851867
commands.push(...step.commands)
852868
raisedEvents.push(...step.raisedEvents)
853869
emittedEvents.push(...step.emittedEvents)
854-
microsteps.push(step)
870+
microsteps.push(retainIndexedMicrostep(step, retainMicrosteps))
855871
}
856872
}
857873

@@ -861,7 +877,8 @@ export interface CompiledExecutionPlan {
861877
readonly snapshot: (state: unknown) => Machine.Snapshot<any>
862878
readonly plan: (
863879
state: unknown,
864-
event: unknown
880+
event: unknown,
881+
retainMicrosteps?: boolean
865882
) => ExecutionMacrostep
866883
readonly initial?: (
867884
args: ReadonlyArray<unknown>
@@ -891,7 +908,8 @@ const makeIndexedExecutionPlan = (
891908
fromConfiguration: (configuration) => ownedIndexedStateFromActive(indexed, configuration),
892909
toConfiguration: (state) => activeConfigurationFromIndexedState(indexed, state as OwnedIndexedState),
893910
snapshot: (state) => snapshotFromIndexedState(indexed, state as OwnedIndexedState),
894-
plan: (state, event) => planIndexedState(machine, indexed, state as OwnedIndexedState, event),
911+
plan: (state, event, retainMicrosteps = false) =>
912+
planIndexedState(machine, indexed, state as OwnedIndexedState, event, retainMicrosteps),
895913
initial: (args) => {
896914
const inputArgs = machine.input === undefined
897915
? args

src/internal/machine/process.ts

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,38 @@ const runSequentialDiscard = <E, R>(
4848
? effects[0]!
4949
: Effect.all(effects, { discard: true })
5050

51+
const acknowledgedPlan = (
52+
planned: {
53+
readonly next: unknown
54+
readonly commands: ReadonlyArray<unknown>
55+
readonly emittedEvents: ReadonlyArray<unknown>
56+
readonly microsteps: ReadonlyArray<{
57+
readonly next: unknown
58+
readonly event: unknown
59+
readonly transitions?: ReadonlyArray<unknown>
60+
readonly commands: ReadonlyArray<unknown>
61+
readonly raisedEvents: ReadonlyArray<unknown>
62+
readonly emittedEvents: ReadonlyArray<unknown>
63+
readonly exitPaths: ReadonlyArray<string>
64+
readonly entryPaths: ReadonlyArray<string>
65+
readonly changed: boolean
66+
}>
67+
readonly done: boolean
68+
readonly output: unknown
69+
},
70+
snapshot: (state: unknown) => unknown
71+
): unknown => ({
72+
next: snapshot(planned.next),
73+
commands: planned.commands,
74+
emittedEvents: planned.emittedEvents,
75+
microsteps: planned.microsteps.map((microstep) => {
76+
const { transitions: _, ...evidence } = microstep
77+
return { ...evidence, next: snapshot(microstep.next) }
78+
}),
79+
done: planned.done,
80+
output: planned.output
81+
})
82+
5183
const invokeCapabilityCache = new WeakMap<Machine.Any, boolean>()
5284

5385
const hasInvokeCapability = (machine: Machine.Any): boolean => {
@@ -98,7 +130,8 @@ const makeChildlessCompiledDrain = (
98130
try {
99131
planned = executionPlan.plan(
100132
configuration ?? executionPlan.fromConfiguration(Configuration.normalizeConfigurationSync(machine, current)),
101-
event
133+
event,
134+
acknowledged
102135
)
103136
} catch (error) {
104137
return error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError
@@ -108,7 +141,9 @@ const makeChildlessCompiledDrain = (
108141
configuration = planned.next
109142
context.executionState = configuration
110143
if (planned.microsteps.length === 0) {
111-
if (acknowledged) context.completeMessage({ before, plan: planned, after: current })
144+
if (acknowledged) {
145+
context.completeMessage({ before, plan: acknowledgedPlan(planned, executionPlan.snapshot), after: current })
146+
}
112147
return loop
113148
}
114149

@@ -129,7 +164,9 @@ const makeChildlessCompiledDrain = (
129164
}
130165
const continueAfterCommit = (): Effect.Effect<Option.Option<any>, any, any> => {
131166
const continued = Effect.suspend(() => {
132-
if (acknowledged) context.completeMessage({ before, plan: planned, after: next })
167+
if (acknowledged) {
168+
context.completeMessage({ before, plan: acknowledgedPlan(planned, executionPlan.snapshot), after: next })
169+
}
133170
return planned.done ? Effect.succeed(Option.some(planned.output)) : loop
134171
})
135172
return afterCommit === undefined ? continued : afterCommit.pipe(Effect.andThen(continued))
@@ -217,7 +254,8 @@ const makeInvokingCompiledDrain = (
217254
planned = executionPlan.plan(
218255
configuration ??
219256
executionPlan.fromConfiguration(Configuration.normalizeConfigurationSync(machine, current)),
220-
event
257+
event,
258+
acknowledged
221259
)
222260
} catch (error) {
223261
return error instanceof InfiniteTransitionError || error instanceof MachineSchemaDecodeError
@@ -226,7 +264,9 @@ const makeInvokingCompiledDrain = (
226264
}
227265
configuration = planned.next
228266
if (planned.microsteps.length === 0) {
229-
if (acknowledged) context.completeMessage({ before, plan: planned, after: current })
267+
if (acknowledged) {
268+
context.completeMessage({ before, plan: acknowledgedPlan(planned, executionPlan.snapshot), after: current })
269+
}
230270
return loop
231271
}
232272

@@ -285,7 +325,9 @@ const makeInvokingCompiledDrain = (
285325
}
286326
const continueAfterCommit = (): Effect.Effect<Option.Option<any>, any, any> => {
287327
const continued = Effect.suspend(() => {
288-
if (acknowledged) context.completeMessage({ before, plan: planned, after: next })
328+
if (acknowledged) {
329+
context.completeMessage({ before, plan: acknowledgedPlan(planned, executionPlan.snapshot), after: next })
330+
}
289331
return planned.done ? Effect.succeed(Option.some(planned.output)) : loop
290332
})
291333
return afterCommit.length === 0
@@ -522,7 +564,17 @@ const makeProcessLogic: <
522564
}
523565
}
524566

525-
if (acknowledged) completeMessage({ before, plan: planned, after: current })
567+
if (acknowledged) {
568+
completeMessage({
569+
before,
570+
plan: acknowledgedPlan(
571+
planned,
572+
(state) =>
573+
Configuration.snapshotFromConfiguration(machine, state as Configuration.ActiveConfiguration)
574+
),
575+
after: current
576+
})
577+
}
526578

527579
if (terminal === undefined) {
528580
pendingMessage = yield* pollMessage
@@ -638,7 +690,17 @@ const makeProcessLogic: <
638690
}
639691
}
640692

641-
if (acknowledged) completeMessage({ before, plan: planned, after: current })
693+
if (acknowledged) {
694+
completeMessage({
695+
before,
696+
plan: acknowledgedPlan(
697+
planned,
698+
(state) =>
699+
Configuration.snapshotFromConfiguration(machine, state as Configuration.ActiveConfiguration)
700+
),
701+
after: current
702+
})
703+
}
642704

643705
if (terminal === undefined) {
644706
pendingMessage = yield* pollMessage

0 commit comments

Comments
 (0)