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