ADR-004: Static transition tables for pet state
Date: 2026-07-19
Status: Accepted
Context
PBI-018 derived the pet activity from total elapsed world time with a single comparison. That worked
while time was the only trigger. PBI-021 and PBI-022 add a semantic action, which gives the same
state a second trigger, and the two cannot both own the activity. An action that makes a pet attentive
would be overwritten by the next update, because the update recomputes the activity from a total the
action did not change.
PBI-021 also requires that activity, expression, and later pet state share one trigger, guard, and
effect vocabulary, and that behaviour lives in a private Core table rather than in a public ABI.
PBI-023 requires that the maximum number of inspected rows is derivable from a compile-time size.
The general architecture section 8.9 already states that Core behaviour is expressed as explicit state transitions. This decision chooses the representation.
Decision
Two static tables in src/transition.c hold every pet state change. src/transition.h declares them
and is internal, so no frontend, adapter, or host can depend on the shape.
A row is a trigger, an action, a source state, a dwell guard, and a destination state. The engine scans a table in order and applies the first matching row. A row must change its region, so a scan either finds one change or leaves the region alone.
Activity and expression are separate regions with separate tables and separate timers. There is no combined enum for activity, expression, mood, relationship, and future state.
Accepted behaviour:
- an activity row is evaluated before an expression row
- one trigger applies at most one row per table, so a large delta replays no chain of missed transitions
- a valid action with no matching row is an accepted no-op
- an unknown action is rejected before any row is inspected
- each region records the clock reading at which it last changed, and a time row measures the dwell since then
PET_ACTION_NONE is the zero action and marks the absence of an intention. Time rows carry it, and a
dispatched PET_ACTION_NONE matches nothing because the triggers differ.
Consequences
Activity is no longer a function of total elapsed time. A greeting restarts the activity timer, so a
pet greeted one second before it would have become curious stays attentive instead. The PBI-018
promise that the same total always yields the same activity now holds only for a world that received
no action, which is the case its tests cover and the case the architecture describes.
Behaviour became reviewable as data. The four activity rows and two expression rows are the complete answer to what the pet can do, and a reviewer reads them without reading the engine. Adding a behaviour means adding a row.
Work stays bounded by declared sizes. A dispatch inspects at most
PET_ACTIVITY_TRANSITION_COUNT + PET_EXPRESSION_TRANSITION_COUNT rows, which is six, independent of
the world, the delta, and the action.
Rows are declared with an explicit count rather than a bare initialiser list, so a forgotten row is zero filled rather than a compile error. A zero row has an equal source and destination, which the row invariant test rejects. That test is what makes the count safe.
The price is storage. Two timestamps and one expression grew PetWorld from 72 to 88 bytes, inside
the 96 byte bound that the type tests assert, leaving 8 bytes of headroom. The next field to be added
is likely to move that bound, which section 8.3 of the version architecture requires to be a recorded
decision.
The tables are internal symbols with external linkage in a static library, because the table tests read them directly. No public header exposes them and the headless host cannot reach them.
Alternatives considered
A switch statement per action. Smallest change for one action, and PBI-022 explicitly rejects
it. Every new behaviour would add a branch, the branches would drift apart, and no reviewer could see
the whole behaviour in one place.
Function pointer guards and effects. More expressive than a dwell value, and the natural answer if a guard ever needs to read several fields. Rejected for now because every guard this release needs is a dwell comparison, a table of data reviews better than a table of function names, and indirect calls are the thing an embedded target is least happy about. A guard that outgrows a dwell supersedes this choice rather than patching it.
One combined state enum. A single value such as IDLE_HAPPY removes both timers and both tables.
Rejected because the product needs a pet that settles its activity while still looking happy, and
because the combined set grows multiplicatively once mood, relationship, and traits arrive. PBI-022
names this directly.
One table with a region field. Fewer types and one scan. Rejected because a row would need a union or an integer state value, which loses the compiler’s help in checking that a row’s source and destination belong to the same region.
Cascading transitions until no row matches. Would let one large delta walk attentive to idle to
curious. Rejected because the loop bound stops being a table size and starts depending on the delta,
which PBI-023 forbids, and because architecture section 8.1 already promises that an accepted delta
is applied in one step with no missed time replayed.
A dwell narrower than PetTimeMs. A uint32_t dwell removes the padding in a row without
reordering fields. Rejected because ADR-002 defines
one time unit for the project, and a second duration type inside the Core is too high a price for 32
bytes. Leading with the dwell removes the padding at no cost.
References
- Architecture for v0.1.0
- General architecture
- Product specification
- ADR-002
PBI-021,PBI-022,PBI-023