Skip to content

Commit a4cd309

Browse files
Retain transition coverage in exploration (#131)
1 parent 64094df commit a4cd309

8 files changed

Lines changed: 347 additions & 79 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": minor
3+
---
4+
5+
Add exact `transitionCoverage` to `MachineTest.Exploration`. Coverage includes startup and every concretely planned event, including state-limit candidates, while unplanned depth- and transition-limit frontiers remain misses.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,8 @@ The testing entrypoint provides complementary layers:
549549
- `MachineTest.run` and `verify` inspect pure planner traces;
550550
- `coverage` reports exact transition-definition and conditional-branch hits;
551551
- invariants and generated scenarios check application laws;
552-
- `explore` performs bounded breadth-first state-space exploration;
552+
- `explore` performs bounded breadth-first state-space exploration and retains
553+
exact transition-definition and branch coverage for every plan it computes;
553554
- `probe` causally acknowledges live runtime commands;
554555
- runtime command models cover timers, invokes, bursts, and scheduling.
555556

src/internal/testing/machine/exploration.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type {
3030
import type { EnsureExecutable } from "../../machine/readiness.js"
3131
import { assertInvariants, type InvariantError } from "./invariant.js"
3232
import { appendTrace, run } from "./trace.js"
33+
import { makeTransitionCoverageCollector } from "./transitionCoverage.js"
3334

3435
type AnyMachine = Machine.Machine.Any
3536

@@ -119,6 +120,10 @@ export const explore = <M extends AnyMachine, Key extends ExplorationKey>(
119120

120121
const initialContext = stateContext(machine, initialTrace)
121122
const initialKey = validateKey(options.stateKey(initialContext))
123+
const transitionCoverage = makeTransitionCoverageCollector<M>(machine)
124+
for (const microstep of initialTrace.initial.plan.microsteps) {
125+
transitionCoverage.observeMicrostep(microstep)
126+
}
122127
const nodes: Array<ExplorationNode<M, Key>> = [{ ...initialContext, key: initialKey }]
123128
const nodeDraftsByKey = new Map<Key, number>([[initialKey, 0]])
124129
const edges: Array<ExplorationEdgeDraft<M>> = []
@@ -165,6 +170,8 @@ export const explore = <M extends AnyMachine, Key extends ExplorationKey>(
165170
const scenario = scenarioWithEvent(source.trace, event)
166171
const targetTrace = yield* appendTrace(machine, source.trace, event, scenario)
167172
plannedTransitions += 1
173+
const step = targetTrace.steps[targetTrace.steps.length - 1]!
174+
for (const microstep of step.plan.microsteps) transitionCoverage.observeMicrostep(microstep)
168175
if (invariants.length > 0) {
169176
yield* assertInvariants(machine, targetTrace, invariants)
170177
}
@@ -178,7 +185,7 @@ export const explore = <M extends AnyMachine, Key extends ExplorationKey>(
178185
target: existing,
179186
edge: {
180187
event,
181-
step: targetTrace.steps[targetTrace.steps.length - 1]!,
188+
step,
182189
discovered: false
183190
}
184191
})
@@ -207,7 +214,7 @@ export const explore = <M extends AnyMachine, Key extends ExplorationKey>(
207214
target: targetIndex,
208215
edge: {
209216
event,
210-
step: targetTrace.steps[targetTrace.steps.length - 1]!,
217+
step,
211218
discovered: true
212219
}
213220
})
@@ -243,6 +250,7 @@ export const explore = <M extends AnyMachine, Key extends ExplorationKey>(
243250
retainedEdges: edges.length,
244251
maxDepth: nodes.reduce((maximum, node) => Math.max(maximum, node.depth), 0)
245252
},
253+
transitionCoverage: transitionCoverage.summary(),
246254
completeness
247255
}
248256
})
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Exact transition-definition and branch coverage collected from retained
3+
* planner microsteps.
4+
*
5+
* @since 0.14.0
6+
*/
7+
8+
import * as Machine from "../../../Machine.js"
9+
import type {
10+
CoverageSummary,
11+
Microstep,
12+
TransitionBranchCoverageItem,
13+
TransitionCoverage,
14+
TransitionDefinitionCoverageItem
15+
} from "../../../testing/MachineTest.js"
16+
17+
type AnyMachine = Machine.Machine.Any
18+
19+
type StateNodePath<M extends AnyMachine> = Machine.Machine.StateNodeIdentifier<Machine.Machine.States<M>>
20+
21+
type EventTag<M extends AnyMachine> = Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>
22+
23+
type MachineTransitionCoverage<M extends AnyMachine> = TransitionCoverage<
24+
StateNodePath<M>,
25+
EventTag<M>,
26+
StateNodePath<M>
27+
>
28+
29+
const coverageSummary = <Item>(declared: ReadonlyArray<Item>, hit: ReadonlySet<number>): CoverageSummary<Item> => {
30+
const hits: Array<Item> = []
31+
const misses: Array<Item> = []
32+
declared.forEach((item, index) => (hit.has(index) ? hits : misses).push(item))
33+
return {
34+
total: declared.length,
35+
hit: hits.length,
36+
missing: misses.length,
37+
hits,
38+
misses
39+
}
40+
}
41+
42+
export const sameTransitionTrigger = (
43+
left: Machine.Machine.TransitionTrigger,
44+
right: Machine.Machine.TransitionTrigger
45+
): boolean => {
46+
if (left.type !== right.type) return false
47+
if (left.type === "event") return right.type === "event" && left.event === right.event
48+
if (left.type === "invoke") {
49+
return right.type === "invoke" && left.id === right.id && left.outcome === right.outcome
50+
}
51+
return true
52+
}
53+
54+
export interface TransitionCoverageCollector<M extends AnyMachine> {
55+
readonly observeMicrostep: (microstep: Microstep<M, unknown>) => void
56+
readonly summary: () => MachineTransitionCoverage<M>
57+
}
58+
59+
export const makeTransitionCoverageCollector = <M extends AnyMachine>(
60+
machine: M
61+
): TransitionCoverageCollector<M> => {
62+
const definitions = Machine.transitionDefinitions(machine).map(
63+
(definition, index): TransitionDefinitionCoverageItem<StateNodePath<M>, EventTag<M>, StateNodePath<M>> => ({
64+
id: `transition:${index}`,
65+
index,
66+
source: definition.source,
67+
trigger: definition.trigger,
68+
reenter: definition.reenter,
69+
branches: definition.branches
70+
})
71+
)
72+
const definitionHits = new Set<number>()
73+
const branchOffsets: Array<number> = []
74+
const branches: Array<TransitionBranchCoverageItem<StateNodePath<M>, EventTag<M>, StateNodePath<M>>> = []
75+
for (let definitionIndex = 0; definitionIndex < definitions.length; definitionIndex++) {
76+
branchOffsets.push(branches.length)
77+
const definition = definitions[definitionIndex]!
78+
definition.branches.forEach((branch, branchIndex) => {
79+
branches.push({
80+
id: `transition:${definitionIndex}:branch:${branchIndex}`,
81+
definitionIndex,
82+
branchIndex,
83+
source: definition.source,
84+
trigger: definition.trigger,
85+
reenter: definition.reenter,
86+
branch
87+
})
88+
})
89+
}
90+
const branchHits = new Set<number>()
91+
92+
return {
93+
observeMicrostep(microstep) {
94+
for (const retained of microstep.transitions) {
95+
const definitionIndex = definitions.findIndex((definition) =>
96+
definition.source === retained.source &&
97+
definition.reenter === retained.reenter &&
98+
sameTransitionTrigger(definition.trigger, retained.trigger)
99+
)
100+
if (definitionIndex === -1) continue
101+
definitionHits.add(definitionIndex)
102+
const definition = definitions[definitionIndex]!
103+
if (
104+
Number.isSafeInteger(retained.branchIndex) && retained.branchIndex >= 0 &&
105+
retained.branchIndex < definition.branches.length
106+
) {
107+
branchHits.add(branchOffsets[definitionIndex]! + retained.branchIndex)
108+
}
109+
}
110+
},
111+
summary: () => ({
112+
definitions: coverageSummary(definitions, definitionHits),
113+
branches: coverageSummary(branches, branchHits)
114+
})
115+
}
116+
}

src/internal/testing/machine/verification.ts

Lines changed: 4 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ import type {
2929
StateCoverageItem,
3030
Trace,
3131
TraceStep,
32-
TransitionBranchCoverageItem,
33-
TransitionDefinitionCoverageItem,
3432
VerificationLaw,
3533
VerificationLawGroup,
3634
VerificationViolation,
@@ -41,6 +39,7 @@ import { toArbitraryWithReport } from "./arbitrary.js"
4139
import type { FiniteModel } from "./finiteModel.js"
4240
import * as ReferenceModel from "./referenceModel.js"
4341
import { rawConfigurationPaths, run } from "./trace.js"
42+
import { makeTransitionCoverageCollector, sameTransitionTrigger } from "./transitionCoverage.js"
4443

4544
export {
4645
advanceCommand,
@@ -470,18 +469,6 @@ const normalizeTraces = <M extends AnyMachine>(
470469
): ReadonlyArray<Trace<M>> =>
471470
Array.isArray(traceOrTraces) ? traceOrTraces as ReadonlyArray<Trace<M>> : [traceOrTraces as Trace<M>]
472471

473-
const sameTransitionTrigger = (
474-
left: Machine.Machine.TransitionTrigger,
475-
right: Machine.Machine.TransitionTrigger
476-
): boolean => {
477-
if (left.type !== right.type) return false
478-
if (left.type === "event") return right.type === "event" && left.event === right.event
479-
if (left.type === "invoke") {
480-
return right.type === "invoke" && left.id === right.id && left.outcome === right.outcome
481-
}
482-
return true
483-
}
484-
485472
const targetWithinSelection = (
486473
target: string | undefined,
487474
branch: Machine.Machine.TransitionBranch,
@@ -576,48 +563,7 @@ export const coverage = <M extends AnyMachine>(
576563
const entryHits = new Set<number>()
577564
const exitHits = new Set<number>()
578565

579-
const definitions = Machine.transitionDefinitions(machine).map(
580-
(
581-
definition,
582-
index
583-
): TransitionDefinitionCoverageItem<
584-
StateNodePath<M>,
585-
Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
586-
StateNodePath<M>
587-
> => ({
588-
id: `transition:${index}`,
589-
index,
590-
source: definition.source,
591-
trigger: definition.trigger,
592-
reenter: definition.reenter,
593-
branches: definition.branches
594-
})
595-
)
596-
const transitionHits = new Set<number>()
597-
const branchOffsets: Array<number> = []
598-
const branches: Array<
599-
TransitionBranchCoverageItem<
600-
StateNodePath<M>,
601-
Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
602-
StateNodePath<M>
603-
>
604-
> = []
605-
for (let definitionIndex = 0; definitionIndex < definitions.length; definitionIndex++) {
606-
branchOffsets.push(branches.length)
607-
const definition = definitions[definitionIndex]!
608-
definition.branches.forEach((branch, branchIndex) => {
609-
branches.push({
610-
id: `transition:${definitionIndex}:branch:${branchIndex}`,
611-
definitionIndex,
612-
branchIndex,
613-
source: definition.source,
614-
trigger: definition.trigger,
615-
reenter: definition.reenter,
616-
branch
617-
})
618-
})
619-
}
620-
const branchHits = new Set<number>()
566+
const transitionCoverage = makeTransitionCoverageCollector(machine)
621567

622568
const declaredEvents = publicEventTags(machine)
623569
const declaredEventTags = declaredEvents.tags
@@ -675,6 +621,7 @@ export const coverage = <M extends AnyMachine>(
675621
}
676622

677623
const observeMicrostep = (microstep: Microstep<M, any>): void => {
624+
transitionCoverage.observeMicrostep(microstep)
678625
microsteps += 1
679626
if (microstep.changed) changedMicrosteps += 1
680627
raisedEvents += microstep.raisedEvents.length
@@ -692,20 +639,6 @@ export const coverage = <M extends AnyMachine>(
692639
historyTargets += 1
693640
if (retained.resolvedTarget !== undefined) resolvedHistoryTargets += 1
694641
}
695-
const definitionIndex = definitions.findIndex((definition) =>
696-
definition.source === retained.source &&
697-
definition.reenter === retained.reenter &&
698-
sameTransitionTrigger(definition.trigger, retained.trigger)
699-
)
700-
if (definitionIndex === -1) continue
701-
transitionHits.add(definitionIndex)
702-
const definition = definitions[definitionIndex]!
703-
if (
704-
Number.isSafeInteger(retained.branchIndex) && retained.branchIndex >= 0 &&
705-
retained.branchIndex < definition.branches.length
706-
) {
707-
branchHits.add(branchOffsets[definitionIndex]! + retained.branchIndex)
708-
}
709642
}
710643
}
711644

@@ -745,10 +678,7 @@ export const coverage = <M extends AnyMachine>(
745678
entry: coverageSummary(activeNodes, entryHits),
746679
exit: coverageSummary(activeNodes, exitHits)
747680
},
748-
transitions: {
749-
definitions: coverageSummary(definitions, transitionHits),
750-
branches: coverageSummary(branches, branchHits)
751-
},
681+
transitions: transitionCoverage.summary(),
752682
events: declaredEvents.diagnostics.length === 0
753683
? {
754684
available: true,

src/testing/MachineTest.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,17 @@ export interface Exploration<M extends AnyMachine, Key extends ExplorationKey =
13751375
readonly start: Graph.NodeIndex
13761376
readonly limits: ResolvedExplorationLimits
13771377
readonly stats: ExplorationStats
1378+
/**
1379+
* Exact declared transition branches witnessed by concretely planned work.
1380+
* This is observed evidence, not a claim that every reachable branch was
1381+
* explored. Startup and state-limit plans count; unplanned depth- and
1382+
* transition-limit frontiers do not.
1383+
*/
1384+
readonly transitionCoverage: TransitionCoverage<
1385+
StateNodePath<M>,
1386+
Machine.Machine.TagOf<Machine.Machine.Events<M>[number]>,
1387+
StateNodePath<M>
1388+
>
13781389
readonly completeness: ExplorationCompleteness<M, Key>
13791390
}
13801391

@@ -1409,7 +1420,10 @@ export type ExploreOptions<M extends AnyMachine, Key extends ExplorationKey = Ex
14091420
*
14101421
* Invariants are checked against startup and every concretely planned edge,
14111422
* so a failure retains a shortest discovered counterexample. Staged actions
1412-
* and runtime activities are not executed.
1423+
* and runtime activities are not executed. `transitionCoverage` includes
1424+
* startup and every event plan that was actually computed, including a plan
1425+
* retained at a state-limit frontier. Depth- and transition-limit frontiers
1426+
* have no plan and therefore contribute no transition hits.
14131427
*
14141428
* **Example**
14151429
*

0 commit comments

Comments
 (0)