Skip to content
108 changes: 83 additions & 25 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ generic methods, with no dependency outside the standard library.
that way. This file is the working detail behind it; the two are edited
together.

Library: `fsm.go` (package doc, `Event`, `Machine`, `Fire`, `Send`, `Check`,
`Can`, `To`, the four error types), `rules.go` (`New`/`MustNew`, the `Rule`
Library: `fsm.go` (package doc, `Event`, `Machine`, `Fire`, `Send`, `TryFire`,
`TrySend`, `Check`, `Can`, `To`, the four error types), `rules.go` (`New`/`MustNew`, the `Rule`
interface and everything that produces one — the `From`/`FromEach`/`FromGroup`
chain through `On`/`To` with its `Guard`/`Action` methods, plus `Rules`,
`Group`, `OnEnter`, `OnExit`, `OnEnterVia`, `OnExitVia`, `OnEnterWith`,
`OnExitWith`, `OnTransition`, `Initial`), `introspect.go` (`States`, `Events`, `Edges`,
`OnExitWith`, `OnTransition`, `Initial`, `Stay`, `OnEnterGroup`/`OnExitGroup` and
their With variants, and `buildPlans`), `introspect.go` (`States`, `Events`, `Edges`,
`Groups`, `Initial`, `Terminals`, `Unreachable`, `DOT`). Tests are
`fsm_test.go` and the worked machines in `example_test.go`.

Expand Down Expand Up @@ -84,13 +85,36 @@ must keep that pairing, or the assertion becomes a runtime panic on a path that
is supposed to return errors. `TestFireDoesNotAllocate` is the guard on the
allocation half; it runs without `-race`, which changes the profile.

**`Fire` is one function, on purpose.** A call to a private generic method is
not inlined, and splitting `Fire` into a lookup and an apply step cost
`BenchmarkFire` six nanoseconds a round trip. So `Fire` keeps its whole body;
`TryFire` and `Check` share `lookup`, and `TryFire` carries a transition out
through `apply`, a copy of `Fire`'s tail — the action and the plan's two
loops. `TestTryFireRunsTheHooksFireDoes`
fires the same transitions both ways with every kind of hook and compares the
logs, and `FuzzMachineInvariants` checks that `TryFire` fires exactly when
`To` resolves: those are what keep the copy honest. A refusal from `TryFire`
builds no error value (`TestTryFireRefusalDoesNotAllocate`).

**Multiple guards and actions are combined at `On` time, not at `Fire` time,**
for the same reason: the combining closure is built where `A` is still known.
Guards run in registration order and the first rejection wins, reported under
the description it was declared with.

**Every hook a row runs is one plan, built by `New`.** `buildPlans` runs last
and gives each non-internal row a `plan`: `exit` — its source's plain, Via and
With exit hooks, then the exit hooks of the groups it leaves — and `enter` —
the `OnTransition` hooks, the entry hooks of the groups it enters, then its
target's plain, Via and With entry hooks. The declared hooks live on the
`builder`, not the `Machine`; `Fire` does one `plans` lookup, behind
`hasHooks`, and an internal row, or one no hook concerns, has no plan. A
`call` is a plain `Hook` or a typed func asserted to the row's `A`, which is
safe for the reason the guard assertion is. This replaced four lookups and
cut `BenchmarkFireWithHooks` by a third.

**The order inside `Fire` is fixed** — lookup, guards, action, the
state-changed check, `OnExit(from)`, assign, `OnTransition`, `OnEnter(to)`.
state-changed check, `OnExit(from)` and the groups left, assign,
`OnTransition`, the groups entered and `OnEnter(to)`.
`OnTransition` precedes the entry hooks so an entry hook that fires again is
logged after its cause (`TestOnTransitionKeepsCausalOrder`); the price is that
a transition hook is an observer and must not fire, or the new state's exit
Expand Down Expand Up @@ -145,12 +169,29 @@ which group owns each inherited row so the two cases can be told apart. With
no nesting there is no specificity rule to fall back on.

**Groups are not states and `Fire` knows nothing about them.** They expand to
ordinary rows before `New` returns. Do not add group entry/exit hooks: the
only reason to want them is superstate hook suppression, which a flat table
cannot express, and a group counter kept by them would dip on an intra-group
move. `docs/DESIGN.md` has the argument and the sum-the-members
answer. `TestGroupFireDoesNotAllocate` pins that the expansion produces rows
indistinguishable from hand-written ones.
ordinary rows before `New` returns. `TestGroupFireDoesNotAllocate` pins that
the expansion produces rows indistinguishable from hand-written ones.

**Group hooks are worked out per row, after everything else.** A flat table
can suppress a superstate's hooks on an intra-group move after all: each row
knows its source and target, so `attachGroupHooks` — run after expansion and
the deferred pass, so inherited rows count, and just before `buildPlans` — gives every
row the hooks of the groups it leaves (source in, target out) and enters, in
the order they run: leaving, smaller groups first; entering, larger first;
each group's in declaration order. Of groups the same size, the one whose
hooks were declared first goes first both ways
(`TestCrossingGroupHooksKeepDeclarationOrder`), except twins — same members
— which nest by group declaration as `DOT` draws them, so leaving runs the
later twin first (`TestTwinGroupHooksNestByDeclaration`). The sort key is
(size, twin set by first hook, group rank), so it is a total order. `buildPlans` puts them in each row's
plan after the state's exit hooks and before its entry hooks. An internal row
runs none, and an external self-transition
of a member crosses nothing. Pinned by `TestGroupHooksRunOnlyAcrossTheBoundary`,
`TestGroupHooksRunBetweenStateHooks`, `TestGroupHooksRunOutsideInAndInsideOut`
and `TestGroupHooksKeepAnExactCount`. A group entry hook must not fire on the
same state, for the reason a transition hook must not: the state entered has
not run its own entry hooks. Typed group hooks check the payload of every
crossing row, one error per hook and event, as `OnEnterWith` does.

**`ToStep.combine` builds the guard/action closures once and every expanded
row shares them.** This is what keeps the one-directional type erasure intact
Expand All @@ -163,17 +204,26 @@ states and three `any` values positionally.** That is the same argument as
`From`/`To`: transposable parameters of the same type do not belong in this
package.

**`DOT` draws only the groups it can draw truthfully.** `drawableGroups`
keeps, in declaration order, each group that is disjoint from, inside, or
around every one kept before it; clusters nest, so those are drawn exactly —
a group in the smallest that contains it (`innermost`), a state in the
smallest group that holds it, a later twin inside an earlier one. A group
that partly overlaps a kept one gets no cluster and a `//` comment instead
(`TestGroupDOTSkipsACrossingGroup`): a node sits in one cluster, so any
cluster for it would show other members than it has.

**`DOT` collapses a group edge to the cluster boundary only when all three
conditions in `collapsible` hold.** `ltail` plus `compound=true` draws one
arrow from the cluster and skips the sibling rows, so a wrong `yes` does not
just mislabel — Graphviz drops the `ltail` with a warning and the skipped rows
vanish from the diagram. The three: every member inherited it (otherwise the
arrow claims to cover the member that overrode it); the group holds all its
members in its own cluster (an overlapping group loses members to whichever
cluster is emitted first, and a cluster cannot be the tail of an edge whose
tail node is elsewhere); and the target is not itself a member (that is a
self-loop out of its own cluster, which Graphviz also refuses). Each has a
regression test, and the outputs were checked against real `dot`.
arrow claims to cover the member that overrode it); the group is drawn as a
cluster, which then holds all its members, nested clusters included; and the
target is not itself a member (that is a self-loop out of its own cluster,
which Graphviz also refuses). Each has a regression test, and the outputs,
nested clusters and the comment line included, were checked against real
`dot`.

**The collapse is keyed on `(group, *eventDef)`, never on the event name.**
Two events can share a name, so counting rows by name merges two group
Expand Down Expand Up @@ -229,16 +279,21 @@ once already, in `DOT`'s group-edge collapse, and produced an arrow that
described a machine nobody had declared.

**A machine with no hooks must not pay for hooks.** `Machine.hasHooks` is set
at construction and short-circuits the whole block in `Fire`, including the
map lookups the plain hooks would do — it is why the hookless path is faster
than it was before payload hooks existed. `BenchmarkFire` and
at construction and skips the plan lookup in `Fire`. `BenchmarkFire` and
`BenchmarkFireWithHooks` are the pair that keeps this honest; measure both
before and after touching the fire path.

**Self-transitions are UML's external kind.** `From(a).On(ev).To(a)` runs
exit then entry, so a counter kept by those hooks dips and returns. Pinned by
`TestSelfTransitionRunsExitAndEntry`; do not turn it into an internal
transition that skips the hooks.
transition that skips the hooks. The internal kind is its own declaration,
`From(a).On(ev).Stay()`: guards and actions run, then `Fire` returns before
any hook, `OnTransition` included (`TestStayRunsGuardsAndActionsButNoHook`).
`builder.internal` holds those rows, and `buildPlans` gives them no plan, so
`Fire` needs no check of its own. An internal row is registered with `to` set to its source, which
keeps `To`, `Check` and the fuzzer's Fire-agrees-with-To invariant uniform,
but it is not a way out (`Terminals` skips it) and it enters and leaves
nothing (`decl.internal` keeps it out of every Via and With hook).

**Declaration order is the output order.** `Machine.states` and `Machine.edges`
are slices kept alongside the maps purely so `States`, `Edges`, `Terminals` and
Expand Down Expand Up @@ -274,11 +329,12 @@ thing there is to test against, and that is the right target anyway.
state closes over the shared `st`.
- **`FuzzMachineInvariants` covers what nobody wrote down.** It builds a
machine from fuzzed `(source, event, target)` rows — sources are the four
states plus two groups overlapping on `running`, and two of the three events
share the name `a` — then asserts what must hold for any definition `New`
accepted: `DOT` is deterministic, every edge endpoint is in `States()`, an
states plus two groups overlapping on `running`, targets the four states or
`Stay`, and two of the three events share the name `a` — then asserts what
must hold for any definition `New` accepted: `DOT` is deterministic, every
edge endpoint is in `States()`, an internal edge loops on its source, an
inherited edge names a group that `Has` its source, `Events()` repeats no
name, `Terminals()` is exactly the states with no outgoing edge,
name, `Terminals()` is exactly the states with no outgoing external edge,
`Unreachable` is closed under the edge relation, a declared `Initial` leaves
nothing unreachable and is not terminal, and `Fire` agrees with `To` on every
pair — failing without moving the state.
Expand All @@ -288,7 +344,9 @@ thing there is to test against, and that is the right target anyway.
systematically, and each one makes `New` return an error, which skips the
whole invariant block: unfiltered, acceptance at the ~200-byte inputs the
engine actually generates is **0%**, so the fuzzer only ever exercised
`New`'s error paths. Filtered it is ~87%. Measure acceptance before changing
`New`'s error paths. Filtered it is ~79% (it was ~87% before internal
targets, which add no reachability, so more definitions leave a state
unreachable from their start). Measure acceptance before changing
the generator, and keep the three named tests that cover those errors.
- **A fuzz failure is a reproducer worth keeping.** Go writes it to
`testdata/fuzz` and prints only the path, so CI uploads that directory as an
Expand Down
Loading
Loading