Implementation plan
Status of the Starsim v4 implementation of the design spec. The code is on the v4 branch of the Starsim repository; section numbers refer to the files in design/. Checked items run and have a test in tests/test_transitions.py, tests/test_deliveries.py, or tests/test_dimensions.py.
Build set
The nine build steps from models/README.md.
Held-out set
By section of the spec
01 Objects
02 Types
03 States and transitions
04 Parameters
05 Dimensions
06 Routes
07 Effects
08 Interventions
Pregnancy and births (proposal 04)
09 Results
10 to 12 Execution, random numbers, and solvers
- Not planned: a whole agent-based run in one kernel, as for compartments. A prototype of the best case (one compiled pass over 175,000 agents a step) took 1.1 ms a step on one thread, against 4.4 ms now, so 4 times at most; it reaches the 1 ns for each agent of LASER only on 20 threads, which Starsim usually spends on running sims side by side. And it would run only models with nothing in Python between steps, which are the ones the compartmental solvers already run
13 Inference
14 to 16 Model spec, errors, and interop
Departures from the spec
Sims with no declared states keep Starsim 3’s time defaults.
The unit of a model’s bare numbers comes from the model (
unit=, else its typed values, else dates, else a typeddt,stop, ordur), as the review’s Decision lang 1 recommends, with two departures from that recommendation: a year-like number forstartorstopis still a calendar year (stop=2020), since Starsim 3 sims are written that way; and the typed defaults of modules written as code do not set the unit, since they convert themselves and would otherwise disagree with most declared models.Rates are written as strings in the model spec, and the spec is
sim.to_spec();sim.to_json()is unchanged from Starsim 3. The spec is for reading by people and other tools; it is not loaded back (ss.load()of a spec andss.from_spec()were removed).The default ODE integrator is fixed-step RK4 with automatic substeps, in the Numba kernels, with or without gradients being asked for; JAX is used only for
integrator='jax',sim.gradient(), and gradient-based fitting.integrator='jax'takes the same RK4 steps (not an adaptive JAX integrator), so its results are the kernels’.What JAX differentiates is the run, from the numbers in the program and the compartments at the start to the compartments and flows at every step. The steps either side are Python (the parameters to those numbers, and the run to the results, outcomes, and goodness of fit), and are differentiated by central differences along the direction of each parameter. So a gradient is exact to about 1e-10, not to the last bit, and costs two restarts and two runs’ worth of results for each parameter.
Superseded by v4: an age stratification with no
split=takes the named world population’s ages (or those of the people given), notss.People’s defaultsEvery agent’s dwell time is sampled, including exponential ones.
A clock of a set of states (
(A | B).time) has to be made before the sim is built (the time each person entered the set is kept only for the sets known then); one made later raises an error saying so.X.enter.uidsin custom code is read from the states declared on the module in the sim (sim.diseases.d.graph.states.X), sincesim.diseases.d.Xis the state’s array as agents.The proposal’s initial placement firing events (seeds counted as having gone through the transitions to their state) is not done: people placed at the start have entered their state, and their clocks start then. Moves at the start from there (the cascade) are events and are counted at the first time point (agents).
A clock in a compartmental run is a conveyor of one-step levels by default (exact on the step grid), not 10 levels as products have (proposal 01 §2, Q3), and not Erlang aging (Q1); it does not reuse a dwell time’s Erlang stages (Q2), except that a dwell time kept as a conveyor is its own clock. Only
ss.constantdwell times are conveyors by default, not every dwell time with a CV below 0.1 (03 Q4).The property of people that says they hold a product is named
has_<product>(e.g.has_bcg), and the record that a routine delivery has been offered isoffered_<delivery>. The product’s own name is taken by the product module.A product whose
waning=is a duration is lost at an exponentially distributed time, under every solver, after which the person can receive it again. Only a function of time since delivery fades the efficacy while the person still holds the product.A delivery never gives a product to someone who already holds it.
A bare number for a time (
ss.change(at=...), a delivery’sstart=) is a time in the sim’s unit, counted from the start, even in a sim with dates; it is a calendar year only in a sim whose ownstart/stopare year-like numbers. A time outside the sim raisesE1101.A modifier acts where the engine uses the quantity; reading
sir.rel_susfrom custom code gives the base value, andsir.rel_sus.effectivethe value with the modifiers applied (agents only). So.baseis not needed.In a compartmental run, a product whose protection fades by a function of time is held at levels of time since delivery (
stage 1, …), sohas_<product>has more than two levels.A network’s parameter is the target of a modifier as a path (
'school.pars.n_contacts'), sinceschool.pars.n_contactsis a plain value.W1203andW1204are not used: tau leaps adapt to the rates, andW102covers large steps.ss.modifyof an undriven transition multiplies the efficacy of the treatments that move people along it, for the people the modifier applies to (proposal 02 §2.1), rather than raisingE504(proposal 03 §2.4): 02 owns treatment. A modifier of a transition with a rate does not change a treatment’s efficacy.A transition with no rate, duration, or
p=out of a state that hasp=branches is undriven, not their remainder (03 §2.1). An immediate remainder is written as a split ('E -> P | A',p=[0.3, None]) or withp=Noneand a driver.To the engines, an undriven transition is a rate of 0, as
@ 0was, so as agents a state whose only exits are undriven still draws for them each step.As agents, each disease of a treatment draws its own number per person, so cures of two diseases by one product are independent.
Under
gillespie(andtau), a value that varies smoothly in time is held for a quarter of a step at a time, at its middle, with a note, rather than by thinning, which needs a bound on every rate over the step; a rate can be any expression of the parameters.A module written as code that works on groups as well as on agents says so with the class attribute
compartmental = True; otherwise it makes the model agent-based.The peak of
ss.seasonalgiven as a month is the middle of that month. In a sim with no dates, time starts at the beginning of a year.ss.gravitygivesk × pop[i]^(a−1) × pop[j]^b / distance^c: for commuting, the share of time a person fromispends inj(capped so that it sums to at most 1, or rescaled toaway=), and for migration the rate of moving.An inflow whose rate uses a count (
xi * I.n, orss.birth) arrives in each place according to the count there; an inflow at a fixed rate is shared between places by their split.A new infection is recorded under the strain acquired, and a state that a dimension does not apply to is counted at that dimension’s first level.
As agents, the number who start in each state is exact (rounded to whole agents), not a chance for each agent.
A level of a stratification named like an attribute of a result (
low,high) is a column called<dimension>_<level>inresult.by().A test with several results draws once per person from the distribution of the worst result (or of the highest,
combine='max'), not once per disease; the same distribution, and a binary test keeps the draws it had.outcomes=cannot be given withsensitivity=orspecificity=; false positives are itsNonekey.An imported rate with another state and no division by the total (
beta * S * I, rule 3 of 16) is kept as counts (beta * I.n), which is exact, rather than givendependence='density'and ann_ref: there is no density-dependent mixing.Results follow the convention of other frameworks under every solver:
n_<state>[ti]is the number at time pointti(aftertisteps, son_<state>[0]is the number at the start), and a flow atti(recovery[ti]) is the number during the step that ends there (0 at the start), son_R[ti] - n_R[ti-1] == recovery[ti]; a sim withstop=Tends atT.E215is already PeopleAndCounts, so the pregnancy-structure code of proposal 04 isE216(reserved, not raised yet).Maternal death at delivery (
p_maternal_death) is code inss.Pregnancy(agents), not a declared branch: a split toss.nullis not a destination.
Known issues
- The kernels are compiled the first time they are used after Starsim is installed or changed, which takes one or two minutes (a line says so); after that they are loaded from a cache.
- With
sim.run(n_runs=)in one call to the kernel, only the states and transitions are kept by group (not outcomes or results given as expressions), and reported outcomes are drawn for all the runs at once, so differ (in the draw, not the distribution) from those of a single run with the same seed. - A typed rate cannot be used as a plain number in an expression (
1 - ss.peryear(0.008)); the error says to convert it first (rate.to_prob(dt)). - A bare number given for a parameter of a module written as code (
ss.MFNet(duration=5),ss.SIS(dur_inf=10)) keeps the unit of its default, as in Starsim 3, while one in a declared model is in the sim’s unit. It warns when the two units differ (durationread in years in a sim in days). - As agents, a dwell time is sampled when someone enters a state, so an
adjuston a duration does not follow a person who later moves to another level (through anss.transfer, or by aging). A rate does. - A strain’s initial share is the split of the dimension (equal by default), which applies to everyone who starts in an infected state.
- As agents, someone tested on the step they were infected may already have recovered (about
dt/2of a dwell time), so event-triggered tests find slightly fewer cases than in a compartmental run. - An ODE and an agent-based run of the same model differ by an amount of order
dt(about 6% in the prevalence of an SIS model with a daily step). - As agents, people added by a movement register in a step are made at its end, so the other events of the same step cannot choose them; and people who moved into a place in a step are chosen after those who were already there.
- A movement register cannot add people to a state that splits on entry into branches.
- A modifier’s cached value is worked out once per row; people who enter a state later in the row (by the cascade) get the value from before they entered for the rest of it.
- [B] In a compartmental run, a trigger on states and strata is exact for arrivals by transitions, but people who come to meet it by aging, a transfer, or another disease’s move wait until the next loop time (the proposal routes those flows too); a trigger on an event is agents only.
- An
ss.byof distributions of several families, or over several dimensions, is not supported; nor is arithmetic betweenss.byof different dimensions, or a branch modifier throughtr.to(B). - Arithmetic on stratified results keeps the values by group only for sums, differences, and scaling by a number; for anything else (a ratio),
select()says the result is not stratified. (A ratio declared on the disease,prev=I/N, is stratified: it keeps its numerator and denominator.) - With
immunity=between strains, a compartmental run keeps only each person’s last strain (noted), so with partial protection people can go from A to B and back to A; as agents, every strain is kept, but not yet over networks. - Code that changes a property of people that a dimension is made from (
people.patch) has to callsim.groups_changed(uids), or the agents’ groups are not worked out again (dimensions binned from a property that changes on its own, such as age, are checked every step). - A state that is not people is deterministic under the stochastic solvers, so a vector population whose own randomness matters cannot be drawn.
- Under the exact solver, places are run one at a time between steps only when people mix within their place and nothing moves them between groups within a step; otherwise every rate is worked out again after each event.
- A plain
duration=on an outcome is an exponential stay, whiledelay=is an exact shift. - As agents, TB (LSHTM) comes out 5–10% higher than the ODE with weekly steps, and 20–30% higher with monthly ones; this is the difference of order
dtabove, made larger by an epidemic that is near R = 1. rel_susandrel_transon a state are numbers, notss.pars, so they cannot be fitted; the TB models write them into the rates instead.- Once JAX has run in a process (
sim.gradient(), a gradient fit, orintegrator='jax'), running sims in parallel by forking processes (ss.parallel()of agent-based sims, Optuna with several workers) can deadlock, as JAX warns. Spawning instead would be safe but takes about 1.5 s longer to start. - Gradients need a sim that the kernels run from start to finish (no interventions other than deliveries the kernels take, no modifiers with
when=, transfers whose rate varies, or movement register), and a rate that is a Python function of the sim has none (one written with NumPy functions of counts does). - As agents, a stock target is met to within an agent or two: a dwell in the end state drawn shorter than a step ends at once (the same-step cascade).
- A treatment’s transitions given by name across diseases (
moves='ct.cure') do not make the diseases tracked jointly in a compartmental run; given as objects (ct.cure), they do. - In a compartmental run,
on_fail=given as a delivery receives the failures as a share of each state and group of the delivery’s disease, so it can act only on what that disease’s engine can select. - Under
solver='binomial', people who arrive in a state during a step face none of its hazards until the next step (the chain-binomial’s whole steps); with a hazard that reads the clock it is the largest they miss, so BV recurrence (decaying over 3 months) is 6% below the ODE at weekly steps and 24% at monthly ones. Agents had the same miss for people entering in the state-change row, now fixed (see 03); substeps (substeps=) shrink it - As agents, back-dated seeds who die before the start are removed, so the population is that much smaller; with no
over=, how far back they go is ten times the mean time to leave the state (from its dwell times and constant rates), or 20 years - A product keyed by diseases does not make them tracked jointly as compartments, so a delivery of it whose selection depends on one disease’s state is
E804for the others (as for any delivery of a multi-disease sim). ss.probperyear(ss.timeseries(...))(proposal 02 §4.4’s scale-up of a routine rate) is not supported; a time series of bare coverage is.take=(an all-or-nothing vaccine) is not built.- [B] A recorded attribute’s levels are read at the middle of each bin (the open last bin at the unweighted mean of what can be recorded into it), so coarse bins bias what is read from them: the HIV test gives
cd4_stopfiner bins at the top than the CD4 stratification. Levels weighted by what is actually recorded into them would need the flows of each step - [B] With
ss.spreadin a compartmental run, everyone in a clock or conveyor moves up a level at the start of the first step (the back-dated people have been there a step), including anyone placed there at the start byinit= - [B] A compartmental model with attributes works out each compartment’s rates in Python: the HIV CD4 model (6,720 compartments) takes about 7 s for 10 years of monthly steps
- The JAX run is compiled the first time it is used for a model (about 1 s), and again if a step needs more substeps than it has allowed for.
Review of 2026-10-17
The work from the review’s plan, by phase. Finding numbers are those of the review.
Phase 1: unblock
Phase 2: contract tests
Phase 3: the silent wrong answers
- X3: each engine draws from its own
np.random.Generator(stream 0 for Python, 1 for the kernels, 2 for a register’s events at the start), seeded from[rand_seed, crc32(name), stream]and passed into every kernel that draws (a typed list forrun_many()), so a stepped run gives the same draws as a whole one. Numba’s generators have no hypergeometric draw, so the kernels have their own (one draw per person moved, which is cheap for a register’s events). Reseeding Numba’s global generator each step was measured at 4.8 µs a call, too slow. - X19: a distribution’s seed is a hash of its name and the sim’s seed together, which moved every agent-based baseline once (
tests/baseline.yamlregenerated). - N1: the particle filter took the rows of the tables as one per step when a smooth value makes three. N2: the JAX density wrote one value into every row of the table for a flow whose rate is a fitted value times a time series.
- Fixed along the way: copying a sim left its parameters and states referring to the original’s modules;
next_generation()left the compartments at the disease-free state.
Phase 4: the guessing rules
Phase 7, started early: inference
Phase 5: one system (first round)
Phase 6: docs
Phase 7: features for the seats
Phase 8: performance
Phase 9: surface and structure
- [~] The saved form (R21): the spec is not read back (Decision 10);
ss.save('model.py', sim). R29’s cleaning of the spec waits. - [~] Dependencies (P14):
seaborndropped,[data]and[interop]extras;starsim_dataas a dependency needs it on PyPI.
Extending a model with code (2026-10-25)
From spec_gaps_response.md (of October 24, no longer kept), on He et al. (2010):