Model structure: states and transitions
The central document. Everything else in this folder depends on the position taken here.
Recommendation
- Transitions are declared data wherever they can be, and plain Python wherever they cannot. This was the largest gap between Starsim 3 and this project’s goals. Declaring a transition does not mean giving up functions: any part of a transition may be a function when the logic is complex.
- There is one kind of transition, and its rate says which mathematics applies. A rate that mentions a state (
beta * I) is contact-mediated; a rate that does not is spontaneous. The distinction is structural, read from what was written, and the user never has to pick a category. - States are declared like ingredients, then transitions are written like the method. A transition takes the states themselves, so nothing is inferred from a name.
- Two spellings, one object. Operators (
S >> I @ beta * I) for the form that reads like the equations, andss.Transition(...)for the fully explicit form. - Dwell time is a declared distribution per transition, defaulting to exponential. The solver decides how to realize it, and the user can say how explicitly.
- A state is one concept. “Compartment” is what a state is called when the model runs as an ODE; “boolean per-agent array” is what it is called when it runs with agents. The declaration is identical.
- The disease model is a labeled directed graph over states. Say so, and draw it.
Why: what “transitions are code” costs
The frameworks in the review that can check, convert, or draw a model can do so because the model is data.
From the Starsim review, a limitation of Starsim 3:
Transitions are still code.
define_statesdeclares the state space, but there is no object representing “S → I at rate β·I/N”. Progression is scheduled imperatively by writingti_recoveredinset_prognoses(). The model is therefore not introspectable as structure, cannot be diagrammed or dimension-checked as a transition system, and cannot be automatically converted to ODEs.
The same finding in EpiModel (“There is no object representing ‘S → I at rate λ’. A model cannot be introspected, diagrammed, dimension-checked, or converted to another paradigm without parsing R”), individual (“The model is entirely code. Processes are R closures. Nothing is introspectable, serializable, diagrammable, or convertible”), MetaCast (“y_deltas[states_index['S']] += -infections is not a specification; it is code”), and LASER.
Code-based models have their own strength: they can say anything. Here is what becomes possible when the model is data:
| Capability | Framework | Enabled by |
|---|---|---|
| Build-time dimension checking | camdl | Named transitions with typed rate expressions |
| ODEs derived from the model | epipack | Reaction tuples as SymPy |
| Automatic stratification of a finished model | summer2 | Named flow kinds the framework understands |
incidence(transition) as an observable |
camdl | Transitions have names |
| Analytic gradients for fitting | camdl, summer2 | Symbolic or traceable rate expressions |
| Automatic model diagrams | epiworld, EMULSION, EoN | The model is a graph |
| Translation between six model formats | MIRA | A closed Template taxonomy |
| One model text run several ways | camdl, epipack, EMULSION | The dynamics are chosen after the structure is declared |
Starsim 3 had already shown part of this: define_states gave automatic n_<state> results, saving, and validation. Declaring the transitions the same way gives the rest of the table.
That table leans heavily on camdl, the framework that took “the model is data” furthest, and this project owes it much of that. Its strictness is a different choice: camdl refuses anything it cannot prove, which gives strong guarantees, while this project’s users need to be able to write the complicated transition anyway.
One kind of transition is enough
Four frameworks independently split transitions into two kinds: EoN uses two directed graphs (H spontaneous, J neighbor-induced), epipack 3-tuples and 5-tuples, EpiHiper separate transitions and transmissions arrays, and MIRA NaturalConversion versus ControlledConversion. The split is real, because the mathematics differs, but the rate already says which kind a transition is (I >> R @ gamma has no state in it; S >> I @ beta * I does). That is how the equations are written, and it is why camdl and SimInf can use one transition syntax.
Dwell time is an axis most frameworks fix silently
Finding 7 of the landscape review: “Dwell-time distribution is a first-class axis that most frameworks silently fix.” ODE means exponential, and nearly every framework assumes it without saying so. MEmilio needed four model families (ODE, LCT, GLCT, IDE) to span the axis; EpiHiper declares a distribution per transition; camdl has via erlang and via hyper_erlang; individual and Starsim schedule sampled times. Declaring the distribution on the transition and letting each solver realize it (Erlang stages in a compartmental run, a sampled time for agents) spans the axis with one model. A scalar duration means an exponential dwell with that mean, which is the guess that most needs printing; odin gets the same visibility by making the modeler write 1 - exp(-gamma*dt) by hand.
States carry properties
EpiHiper puts infectivity and susceptibility on the state ({"id": "V", "susceptibility": 0.1}), and that two-number declaration is the vaccine model: no multipliers threaded through transmission code, and a new state composes without editing anything else. Starsim’s per-agent rel_sus and rel_trans answer a different question (heterogeneity that is not a state), so both are kept.
The model as a table
Because the model is data, a grid of transitions (Atomica’s transition matrix, which a domain expert fills in by hand), a form, or a web interface is one more way to show it. That is how to reach the accessibility of Atomica without adopting spreadsheets as a language.
The specification
03-states-and-transitions.md specifies states, the one transition class and its two spellings (S >> I @ beta * I and ss.Transition), what may follow @, rates that mention states, the source of infection, dwell times, branching, functions in any slot, the places outside the model (ss.unborn, ss.dead, ss.outside), states that are not people, and what is guessed. Names from variables, describe(), diagram(), equations(), and transitions are in 01-objects.md; what a state in a rate means under each kind of mixing is in 06-routes.md. The docs show how to write a model in Writing models and Diseases.
Trade-offs
- Two ways to write a transition is one more than the minimum. The operator form reads like the equations; the explicit form names every part. Both build the same object, and
describe()prints one standard form. - Operators have precedence rules the user does not see.
S >> I @ beta * Iworks without parentheses, butS >> p * A + (1 - p) * B @ ratedoes not, and needs them around the destinations. The error for the second should say so. - Declaring states takes one more line. Earlier arrow strings (
ss.transmission('S -> I', beta=0.3)) inferred the states and guessed the source of infection, which went wrong for an SEIR. They have been removed; the source is now always in the rate. - The library cannot see inside a function in a rate. It cannot print it as an equation or differentiate it symbolically. It can still run it wherever it works.
- A state that is not people is declared as such. A reservoir or a vector population says
ss.State(people=False). Every other state is people; working this out from the transitions was tried, and it could misread a model.describe()marks which states are counts.
Rejected
- Separate classes for spontaneous and contact-mediated transitions as the user-facing choice. The split is real and the library tracks it; the user’s rate expression already says which is which.
- A general reaction primitive
A + B → C + D(epipack).S >> I @ beta * Icovers the same ground while keeping “who moves” separate from “who causes it”, which is what stratification and networks need. - Requiring
/Nin the rate. camdl is right thatbeta * S * (I/N)should type-check, but a modeler who writes/Nby hand can write it wrongly, and the denominator depends on the mixing. It is allowed (via counts,I.n / N) and never needed in the common case. - A dict shorthand with positional tuples,
dict(infection=('S -> I', 0.05)). The first draft proposed it; deciding whether0.05is a transmission rate or a recovery rate from context turned out to need rules that misfire (SIRS, for one). - A separate module class for quantities that are not people (an earlier draft’s
ss.Compartments). The reservoir’s states use the same syntax as the disease’s, so a second class added a name (and a confusing one, sinceS,I, andRare compartments too) without adding anything to say. - A bare number after
@as a duration.I >> R @ 10meaning “after 10 days” is how some people think, but@ 0.1and@ 10cannot both be guessed safely from magnitude. A bare number is a rate; a duration is typed or given asdur=. - Integer state indices (epiworld’s
target_states = 2L). Named, checked references only. - A separate
Junctionstate kind (Atomica). The weighted arrow says the same thing. - Mandatory ontology identifiers (MIRA). Never required; an optional
ontology=onss.Statewas considered for model merging and dropped as a small thing that did not earn a name (17-vocabulary.md).
Open questions
The open questions are collected in 19-open-questions.md.