Statecharts · Lesson 02

Depth

An arrow on a parent is an arrow on every descendant, including the ones that do not exist yet. Getting the benefit means knowing exactly which boxes a transition exits — and in what order.


A box that is also a machine

Depth is the cheap one. A state contains a whole state machine; entering the outer state means entering the outer state and one of its children, the one marked initial. Being in b below means being in outer, inner, and b — all three are in the configuration.

stateDiagram-v2
  state outer {
    state inner {
      [*] --> a
      a --> b : go
    }
    inner --> c : hop
  }
  outer --> other : leave
  other --> outer : back
  
class M(StateChart):
    class outer(State.Compound):
        class inner(State.Compound):
            a = State(initial=True)
            b = State()
            go = a.to(b)
        c = State()
        hop = inner.to(c)

    class other(State.Compound):
        d = State(initial=True)

    leave = outer.to(other)
    back  = other.to(outer)

The payoff is in hop and leave. hop is drawn once, on inner, and applies from a and from b and from every state you add to inner next year. leave is drawn once and applies from everywhere. That is the whole argument for depth: an arrow on a parent is an arrow on every descendant, including the ones that do not exist yet.

Which boxes get exited

Not all of them. A transition only disturbs the states below its transition domain: the smallest compound state that contains both the source and the target. (SCXML computes this as the LCCA, the least common compound ancestor; Harel drew it as the box you have to leave in order to get from one end of the arrow to the other.)

Running the chart above and logging every entry and exit:

init    +outer  +inner  +a
go      -a  +b                          # domain: inner. outer untouched.
hop     -b  -inner  +c                  # domain: outer.
leave   -c  -outer  +other  +d          # domain: the root.
back    -d  -other  +outer  +inner  +a
The two orderings, and they are opposite. Exits run innermost first (-c then -outer); entries run outermost first (+outer then +inner then +a). This is not arbitrary: it is the same discipline as a constructor and a destructor, and it means a parent's entry action can set up something its children's entry actions rely on, and will still be torn down after them.

Why this makes safety structural

Put the magnetron's on in the entry action of cooking, and its off in the exit action. Now put cooking inside door_shut. Opening the door targets a state outside door_shut, so the domain is the root, so the engine must exit door_shut, and to exit a compound it first exits whichever child is active. The magnetron goes off, and no author had to remember to turn it off.

Compare the flat version, where open_door needs an arrow from cooking carrying magnetron_off, and another from paused, and another from whatever state gets added next quarter. The difference is not lines of code. It is that in one version the invariant can be broken by forgetting, and in the other it cannot be expressed wrongly.

When parent and child both want the event

Depth creates a conflict that flat machines cannot have: an event matching a transition on a child and one on an ancestor. SCXML's rule, which this library follows:

The deepest match wins. A transition from a descendant takes priority over one from an ancestor. Between two at the same depth that would exit overlapping states, the one declared first wins.

Read that as a feature, not a tie-break: it is how you write “cancel does this everywhere, except in uploading, where it does that” without a conditional. The general rule goes on the parent; the exception goes on the child, and the child wins by being deeper.

Three transitions that all look like a loop

One more distinction, because the difference is invisible in a diagram and very visible in a log:

External self-transition — s.to.itself(). Exits s, re-enters s. Exit and entry actions both run. Use when re-entry is the point: restarting a timeout, resetting a retry counter.
Internal transition — s.to.itself(internal=True). Runs its action, exits nothing, enters nothing. Use for pure data updates: bumping a counter, appending to a queue.
Transition to a child — from a compound s to one of its own children. Exits and re-enters s as well, unless it is marked internal. The usual accident: a “stay here but go back to step one” arrow that quietly reruns the parent's entry action.
Library note. In python-statemachine 3.2.x, internal=True does not suppress entry actions unless you also set enable_self_transition_entries = False on the chart. And inside a parallel state, an internal self-transition corrupts the configuration outright — it re-enters the sibling regions' defaults. Use an external self-transition there. Both behaviours were found by experiment; the reproductions are in exercises/README.md.

Exercise 1 — trace the entries and exits

Using the chart at the top of this lesson.

Exercise 2 — which kind of arrow

Exercise 3 — recall

Do the problem

You now have enough for P1, the microwave oven: one compound state, one entry/exit pair carrying a safety invariant, one guard, and a counter that is deliberately not a state. Ten acceptance tests. Do it before lesson 03 — the next construct is easier to judge once you have felt what depth alone can and cannot do.

← Lesson 01Status mapP1 →Lesson 03 →

Sources: SCXML Appendix D · Harel 1987 §2 · python-statemachine: transitions · RESOURCES.md