Skip to main content
🧠0% 🤖100%

Open questions

What this revision settled, what is still open, what is left out of v1, and what could go wrong with the design as a whole.

Sources: the open-questions sections of the documents in best-practices (which now live here), choices that had to be made while writing this specification, and choices the implementation made. Section A includes the questions that the implementation and the translations have answered since this list was first written; each says what the code does. Many of the “open” items in section B are places where the code makes a guess; the guess is stated, as the code makes it, so that it can be confirmed or changed.

A. Settled in this revision

Question Decision Where
Capitalization Starsim’s rule, kept: modules, containers, and base classes are CamelCase; the pieces put inside a module, and loaders such as ss.people and ss.contacts, are lowercase 01, 17
How many kinds of transition? One class, ss.Transition. Its kind (contact-mediated or spontaneous) follows from whether its rate mentions a state 03
How is a transition written? Two equivalent spellings: operators (S >> I @ beta * I) and explicit (ss.Transition(S, I, rate=beta * I)), each with keyword alternatives to its positional arguments. Arrow strings (ss.transmission, ss.progression) and the dict tuple shorthand are gone 03, 01
A bare number after @ A rate: I >> R @ 0.1, because “at” reads as “at rate”. A duration is typed (@ ss.days(10)) or given as dur= 03
Declare states or infer them? Both. If any state is declared, all must be 03
Declaring states S, I, R = ss.states(3); S = ss.State() in a class body or one at a time; ss.states('S I R') when the names are wanted up front 03
Names from variables States, transitions, parameters, and modules take the name of the variable they are bound to, looked up once, where ss.Disease(...) or ss.Sim(...) is called. A transition falls back to S_to_I; a module bound to no one variable is named from its states (sir); a state that cannot be matched is an error (a ValueError) asking for ss.states('S I R'). With no Python frame to look in (from R, through reticulate), the names come from the arrow strings and name= 01
The source of an inflow and the sink of an outflow Three places, one class (ss.transitions.Outside): ss.unborn (births, ss.unborn >> S), ss.dead (deaths, I >> ss.dead @ 0.1), and ss.outside (arrivals and departures alive, ss.outside >> S, S >> ss.outside); the same three for counts (mosquitoes emerge from ss.unborn, bacteria are shed from ss.outside). ss.dead as a source and ss.unborn as a destination are errors. People who die go to ss.dead, not a state D. None cannot be used, because Python evaluates None @ 0.1 first. The string forms ('-> S', 'I ->') and ss.birth/ss.death are unchanged. This replaced ss.null and settles its name (B12; decided 2026-11-06) 03
What does a state in a rate mean? Contact with people in that state, through the model’s routes; the user never writes /N. I.n is the plain count, the same number as the result I. S >> I @ beta with no state in the rate is a constant hazard, not transmission 03, 06
Environmental and vector-borne transmission Counts in rates (W.n, Iv.n), with the reservoir or vector population as ordinary states in the same disease. There is no ss.Compartments and no special route kind. A state that no transition connects to the disease’s first state is not people: it holds a count, is never agents, and is left out of total_pop and of people dimensions. The guess is printed, and ss.State(people=False) says it outright 03, 06
Does infectiousness belong to the disease or the network? The disease, which may give one beta per network and per direction: beta=dict(mf=[0.008, 0.004], needles=0.01). Contact belongs to the network 06
Initial conditions Given only where not zero; the remainder goes to the first state, or, if that is given a value, to the one state that is not (else an error). init=dict(S=0.9, I=0.01, R=0.09) on the disease gives the whole distribution. init= and init_prev= are shares (a whole number 1 warns; init_prev= above 1 raises), and init_n= a number of people (decided 2026-10-22). With none given, nobody is seeded, except that ss.SIR, ss.SIS and ss.SEIR seed 1% (init_prev=0.01) 03
Are imperative transitions second-class? No. Any slot takes a function; custom per-step code is a plain function or step() 03
Formulas in strings Python by default. A string may name a state or draw an arrow; a string formula is not ruled out forever but needs a strong reason 00
How is it decided what can run under which solver? Try it and report: solver='auto' tries functions, function interventions and analyzers, and step() overrides on groups at sim.init(). No static analysis. A module written as code can say it works on groups with the class attribute compartmental = True (or False), which overrides the trial. The reasons are one record, read by describe() and the CapabilityError 12
The word for how a sim is run solver= on ss.Sim (sim.new(solver=) for another); the solvers are 'auto', 'ode', 'binomial', 'gillespie', 'tau', and 'abm', each with its own options as ss.solvers.ode(integrator=, substeps=, dwells=), ss.solvers.binomial(substeps=), ss.solvers.abm(step=), and after sim.init() sim.solver is one of these classes (isinstance(sim.solver, ss.solvers.abm)). describe() says what can run the model. The exact continuous-time solver is 'gillespie', which stays exact; tau-leaping is 'tau', asked for by name. ss.solvers.ode(integrator=) is the ODE scheme ('rk4' or 'euler'). sim.fit(method=) keeps method= for the fitting method 12, 01
Which solver by default? solver='auto': an ODE if every part has a compartmental form, otherwise agents; the choice and reason are printed. It never approximates the mixing or drops individual-level structure. It may represent a non-exponential dwell time by Erlang stages, and says so 12
What happens when a model does not fit the requested solver? The standard approximation is used and named in one line. approximations='allow' silences the line; approximations='error' makes any approximation an error. An error otherwise only when there is no approximation. No closure ladder 12
Distributions in a compartmental run A dwell-time distribution becomes Erlang stages. A distribution on beta or rel_sus runs as agents under 'auto'; under an explicit solver='ode' the mean is used, with a note, unless the user bins it into a dimension 12, 04
Must solvers agree? Simple models, within a stated tolerance, tested. Complex models: no; what was approximated is reported 12, 18
One run or many? A sim is one run. Many runs are a MultiSim, as in Starsim: ss.parallel(sim, n_runs=100) or ss.MultiSim(sim, n_runs=100).run(). Its results have a run dimension; msim.plot() draws the median and interval 01, 09
Execution order Starsim’s ordered loop, kept. No queued updates, phases, or derived ordering. Mutual dependence between modules is fine. describe() prints the loop as a schedule grouped by timestep 10
Interactions between modules ss.modify for scaling a named quantity; ordinary Python in step() for everything else. Any named numeric quantity can be a target. Base values are never overwritten 07
Must modules declare what they read and write? No. sim.what_modifies() lists the declared modifiers; direct writes are not tracked. 07
How are conditions written? Against the real attributes: a function of the sim, or a lazy expression on uninitialized arrays. No global predicate names (ss.age and the like) 07
How are interventions organized? What (a product or a change) plus delivery (keywords, or one who= function). coverage= is a fraction, capacity= a number 08
Does stratifying change the model? Yes: stratify works in place and returns the model, so it can be chained or passed straight to ss.Sim. inplace=False returns a stratified copy. Stratifying twice by one dimension is an error. Dimensions are agent properties; continuous ones are binned only for stratifying 05
Where is a model stratified? A property of people (age, sex, place) on the sim, sim.stratify(age=...) or ss.Sim(..., stratify=...), so every module agrees. A dimension that belongs to one disease (strain, resistance), and per-disease adjustments, on the disease. A contact matrix with other age groups is aggregated to the model’s, with a note 05
How are stratified results read? One result with a dimension per stratification: the total until a group is selected, .select(sex='female'), .by('age'). No generated column names 09, 05
Is strain a dimension or a second disease? Strains are part of the disease, ss.Disease(strains=ss.Strains(...)), with each state shared or each strain’s own (decided 2026-10-22, S7.4); unrelated pathogens are two diseases with ss.modify. One ss.immunity, with due_to= 05, 07
Is space without movement valid? Yes, with no warning 05
Commuting A share of time spent in other patches; no sub-step scheduling 06
Sexual networks One class, ss.SexualNet, a thin subclass of ss.DegreeNet (debut and participation): men and women (between='mf'), men (between='mm'), or two groups such as sex workers and clients (between=('fsw', 'client'), with n_contacts by group), paired as in Starsim v3, or with targets for the number of partners at a time (n_contacts, which may be a distribution; concurrency; max_contacts), whose net.check() compares the network made with the targets. Partnerships between anyone or among some people (who=), matching on one or several properties (match=), and a number of partners by level (n_contacts=ss.by(...)) are ss.DegreeNet’s, and so ss.SexualNet’s too; several partnership types with their own rules are STIsim’s. Both use the partner-change model under a compartmental solver 06
Is there a separate observation step? No. Results are automatic or declared with the model; data attaches by column name, with ss.SimData(aliases=, ignore_extra=) for headers that do not match. An unmatched column is otherwise an error 09
What is an outcome? A weighted sum of flows, with delay= and dur= (data by period is compared with the total over each period, so there is no every=). A number times a flow is that fraction of it, so there is no p=. It stands in for a reporting process that is not modeled, and marks no individuals: when testing is modeled, cases are the interventions’ own results, and when being counted changes what happens to a person, it is a state 09
Arithmetic on states Never makes a new state. In a rate a sum is a source or a denominator; passed to the module by keyword it is a result (one with a value for each person is an error, not dropped). Ratios aggregate by summing numerator and denominator separately 09
\| and + on states The same set of people, for whole states: as transition sources ((E \| I) >> R, which needs the brackets: without them it is an error that says so), as a result (infected = E \| I), in a rate (0.3*(Ia \| Is) is 0.3*(Ia + Is)), and in .n, .frac, .total. + adds numbers (weights, rates, N = S + E + I + R); \| joins sets of people, as conditions do (decided 2026-11-06) 03
Prevalence and the infected states Never guessed. States are marked, ss.State(infected=True) or ss.states('E I', infected=True), and give the results infected and prevalence unless the disease defines its own; with no marks there are neither. The marks are also the infected states for strains, products, and rescaling, which without marks are still worked out from the transitions. The inference for results (Graph.resolve_prevalence()) is removed (decided 2026-11-06) 03, 09
Several destinations A dict of each and its share, I >> {ss.dead: p_death, R: None} @ dur (None the rest), or ss.Transition(I, {ss.dead: p_death, R: None}, dur=dur); two lists ([ss.dead, R], p=[p_death, None]) still work. After @ the value is a Starsim object, not a bare number. ss.outside as one branch is not yet supported (deferred) 03
The arguments of a user’s function Any of self, sim, and uids by name, in every slot that takes a function (rates, parameters, conditions, coverage, who=, init_prev=, eligibility, waning, which may also take t), as distributions already did; other names by position, as before. One that takes uids is agents only. A method can be a rate (decided 2026-11-06) 03
A transmission written as code A declared disease defines transmit(self, transition), called once a step for each transmission: as agents the new cases’ UIDs, as compartments the number in each group; super() is the solver’s own. Under the ODE it is taken at the step’s start (first order in dt, with a note). A disease written entirely as code is ss.AgentDisease (Starsim 3’s ss.Infection, renamed since a declared ss.SIR is an infection too), agents only, with its transmission done by the solver through hooks overridable one at a time (decided 2026-11-06) 03, 01
The transmission log On request: analyzers='infection_log', Starsim’s analyzer 09
Does fitting need a likelihood? No. The default goodness of fit is the normalized absolute error (the mean of |model − data| over the mean of the data, per series), which needs no noise model; a likelihood is optional. The fitting method is chosen from the model and printed 13
Which fitting method? An ODE with gradients: L-BFGS-B, or NUTS (a posterior) if every data series has a likelihood; an ODE that JAX cannot run: Nelder-Mead; a stochastic compartmental sim whose series all have a likelihood: particle MCMC; any other stochastic sim, including agents: Optuna (a best fit, with the spread of its best trials). ABC-SMC (method='abc-smc') gives a posterior without a likelihood; method= a list fits in stages. A stochastic fit scores each point by the mean of 4 runs under common random numbers (n_runs=). The reason for the choice is printed 13
What sim.fit() returns An ss.fitting.Fit: the fitted sim (fit.sim), the values (fit.pars), the draws of a posterior (fit.samples), plot(), and summarize() 13
Search scale and held-out data Positive bounds spanning a factor of ten or more are searched on a log scale; ss.fitting.compare_models holds out the last 25% of each series by default (holdout=, or folds= for cross-validation) 13
Gradients In scope for fitting. The ODE runs as fixed-step RK4 in the kernels (plain Python or compiled by Numba); when gradients are wanted (sim.gradient(), a fit by L-BFGS-B or NUTS, sim.log_density()), JAX takes the same steps and differentiates the run. There is no line to draw between SciPy and JAX: the only integrators are 'rk4' and 'euler', both in the kernels. A sim that JAX cannot run (a rate that is a Python function of the sim, or interventions the kernels do not take) is fitted by Nelder-Mead, and the reason is printed. Symbolic Jacobians, fixed points, and eigenvalues stay out 13, 12
Model comparison In v1, by out-of-sample prediction: ss.fitting.compare_models 13
Parameter names and time types ss.par lowercase; Starsim’s time types and distribution names unchanged (ss.per is not renamed) 02, 04
Population size Starsim’s total_pop (people represented) and n_agents (agents simulated) 01
Named data ss.population('Kenya', year=2024) and ss.contacts('Kenya'). What is small and common is bundled, the rest is fetched with a cache (starsim_data). No dependence on other frameworks’ packages for data or generators 04
The serialized form A model is its code; an import gives code (sim.imported.code). A sim with its results is saved as a pickle (ss.save('run.sim', sim)). There is no model spec, hash, or comparison function (decided 2026-10-27). A declared model can be written as JSON, one more format, read by writing its code (decided 2026-11-03) 14
Reproducibility Starsim’s per-distribution streams, and common random numbers keyed on slots only (ss.options.crn turns them off). the commit or tag of the code is the record of what was run 11
Unit of a bare number The model’s unit, so how the run is written does not change what its numbers mean. It comes from unit=, then the typed values in the model, then calendar dates for start and stop (days), then a typed dt, start, or stop, then year-like numbers for start and stop (years); if none of these, time is in timesteps and no unit is assumed. Where it came from is printed. Typed values with different units give the smallest, unless the model also has bare numbers, whose unit is then not known: a TypeError asks for unit= 02
p= Always a branch probability: the probabilities of the branches out of a state sum to 1 (a ValueError if not). A probability per period is a rate, rate=ss.prob(0.1, 'day') 03
First positional argument of ss.Sim The model (a module, or a list of modules); Starsim’s pars dict is a keyword 01
Inflow rates The rate of a transition out of ss.unborn or ss.outside is a number per unit time; ss.birth(rate=r) is per person (for the births of everyone, per 1,000 people if it is above 1 per year, a bare number or typed, as ss.Births reads it), and multiplies by the population for the user; ss.immigration(n=) is a number of people per unit time. No special type is required on an inflow 03
Where a disease’s transitions run in the loop All of a declared disease’s transitions are taken in its step(), after the interventions, under every solver: those that are not contact-mediated, then those that are. So a delivery reaches the same people as agents as in compartments 10
Reading and writing a modified quantity A modifier acts where the engine uses the quantity, each time it is used; its condition is evaluated then. Reading sir.rel_sus from custom code gives the base value, sir.current_value('rel_sus') the value with the modifiers applied (agents only); there is no .base. sim.what_modifies() is kept, and lists the declared modifiers (code that assigns the array directly is not tracked) 07
A condition whose threshold cuts through a bin A lazy condition (ss.person.age > 70, with bins at 65) stops a compartmental run with a CapabilityError saying to add the edge or run as agents, and 'auto' runs such a model as agents. A function of the sim that compares ages is not checked, and sees each group’s midpoint 07
sim.people.age in a compartmental run The age group’s midpoint 12
Erlang stages for an approximated dwell time k = round((mean/std)²), from 1 to 50, with a note naming the mean and standard deviation kept 12
Contact rates when a network is run as groups From the network’s parameters, not measured: a random network’s mean number of contacts, a static network’s mean degree. A network of lasting partnerships (ss.SexualNet, ss.DegreeNet) uses the partner-change model instead 06, 12
Should 'auto' ever choose a stochastic compartmental solver? No. When a small number infected at the start makes the outcome a matter of chance, solver='ode' gives a note saying so and naming 'gillespie' with many runs 12
A dimension’s kind From its value: 'space' for an ss.geo.geography or with mobility=, 'age' for a dimension called age, and otherwise 'plain'; kind= overrides it (strains are ss.Strains, not a kind given to stratify()) 05
A campaign with coverage but no product ss.transfer() takes the delivery keywords, for moving people along a stratification; ss.vaccinate(move=S >> V) moves the people reached from one state to another 05, 08
How far lazy conditions extend Comparisons, &, \|, ~, arithmetic between arrays (+, -, *, /), .isin(), and a transition’s recent incidence (tr.incidence(over=, by=)). Not indexing. A function of the sim does the rest 07
A policy with memory until= on ss.modify (a duration) keeps it on for someone for a while once its condition holds for them, or (a condition) until another condition holds (a closure for at least two weeks), and tr.incidence(over=, by=) is a condition on recent flows, by place; min_duration= stays dropped. Anything more is a step() method or an intervention class 07
Is ss.outcome needed? Yes: the flepiMoP and measles metapop translations report cases as outcomes, and flepiMoP’s chains them (hospitalizations with a delay and a duration, and deaths from hospitalizations), which states could replace only by changing the model 09
A likelihood on a result that is not an outcome No wrapping is needed: sim.fit(likelihood=dict(prevalence=...)) gives one by the name of the data series 13
Agents in places with coupling between places (LASER) A stratification by place with ss.geo.Mobility(kind='force'), which moves the force of infection between places rather than people; checked on LASER’s England and Wales model 06
Tolerance for agreement between solvers Stated per test, with the population and number of runs (tests/devtests/test_contracts.py) 18
Arithmetic across the time types Written down once, as a table (02): number / dur is a rate and number / rate a duration, adding probabilities combines their rates, and meaningless arithmetic raises a TypeError 02
Vocabulary trimmed Gone: ss.Compartments, ss.export, crn_key, n_reps on a sim, min_duration=, p= on an outcome. Removed: ss.Needs (later ss.Requires) and sim.requires(). Deferred: movement models beyond ss.geo.gravity, msim.check_runs() 17, section C below

B. Open

B1. Defaults that are guesses

Each of these is what the code does, and could reasonably go the other way. The rows that the implementation settled are in section A.

Question Current guess (what the code does) Alternative
Where init_prev puts people Through the infection transition, so into E in an SEIR Directly into the infectious state
A transition whose rate uses only counts (beta * W.n) Classed as spontaneous A third kind, or counted as an infection when it mentions a state
The denominator Written by the user (N = S + I + R), or the share in a state, I.frac A built-in total population
Counts across stratifications A count used in a rate is matched on the dimensions the two sides share and summed over the rest; people dimensions skip states that are not people Explicit .sum() and .select() always. Needs confirming on a real cholera or malaria model
Two set= modifiers on the same people The later in the loop wins, with a note An error
Routine delivery Each person is offered it once, at first eligibility (recorded as offered_<delivery>), or as they reach an age (who=ss.at_age(...)) A rate
When capacity runs out Recipients chosen at random A priority, ss.capacity(n, priority=...), a function or attribute (oldest first, highest risk first)
A state that is not people Its count is advanced deterministically, under every solver and inside an agent-based sim Drawn (chain-binomial) under the stochastic solvers
Reproducibility across machines Identical on the same machine and versions is required; bit-identical across machines is a goal Required

B2. States that are not people

A reservoir or a vector population is an ordinary state that holds a count, in the same disease as the people it infects. That is enough for a water reservoir and a Ross-Macdonald mosquito population. Not settled:

  • Complex vector models. Mosquito species and larval stages by habitat are stratifications of the vector states (states=[Sv, Ev, Iv]), which are regular states, so nothing new should be needed. This needs confirming on a real malaria model with several species.
  • How a reservoir stratifies alongside the people. The rule is that people dimensions (age, sex, risk) skip states that are not people, patch applies to them, and a count used in a rate is matched on shared dimensions and summed over the rest. SimInf’s SISe3 translation (an environmental pressure per herd) is the worked example as compartments; a reservoir per patch alongside agents still needs one.
  • The inference can be wrong. A state is taken to be a count because no transition connects it to the disease’s first state. That is right for every example so far. A disease that deliberately keeps two unconnected sets of people states would be misread and has to say ss.State(people=True). Whether the printed guess is enough is to be seen.
  • Interventions on a vector population (bed nets, larviciding). They should be ss.modify on the vector states’ transitions; whether a vector population with its own age structure and interventions is still comfortable as states of the human disease, or wants to be a module of its own, is not known.
  • Small counts under a stochastic solver. Every state that is not people is deterministic under every solver, as SimInf does (B1). A vector population small enough for its own randomness to matter would want its counts drawn; if one turns up, that is an option on the state.

The way to find out how much this has to grow is to build a real dengue or malaria model with it.

B3. solver='auto' and near-compartmental models

  • 'auto' keeps a model with a non-exponential dwell time an ODE by fitting Erlang stages, and says so; with approximations='error' it runs such a model as agents instead. A dwell time that stages fit badly (a very heavy tail) is still fitted. Whether 'auto' should prefer agents past some mismatch, and what the threshold would be, is open; the printed line gives the mean and standard deviation kept.
  • A budget for approximations (principle 8). A stratification runs however many groups it makes; results by group are kept at a coarser interval past about 100 MB a module, but nothing limits the state space itself. Whether, past some number of groups, 'auto' should pick agents and approximations='error' refuse, and what the number would be, is open.

B4. Heterogeneity between people in a compartmental run

A dwell-time distribution becomes stages. A distribution on beta or rel_sus (ss.lognormal(mean=1, std=0.3)) runs as agents under 'auto'; under an explicit solver='ode' the mean is used, with a note, and binning it into a dimension (sir.stratify(rel_sus=[...])) is the way to keep the heterogeneity. Still open: whether the library should ever offer the binning itself, and with how many groups.

B5. Which approximation is standard for each network?

  • Partner-change rates between sex and risk groups is the standard approximation for a simple sexual network, and is built as the partner-change model for ss.SexualNet and ss.DegreeNet (see 06). For one with age mixing, concurrency, and several partnership types, the grouping and the rates have to be chosen, and the choice printed.
  • For a static network where some people have far more contacts than others, the mean contact rate is a poor summary. Grouping by number of contacts is the usual improvement. Whether to build it is open; the note should at least say when the mean is likely to be poor. An agent-based run is always available.

B6. Stratification details

  • Cross-immunity is one mechanism, ss.immunity(due_to=...), for strains in shared states and with courses of their own (ss.Strains(immunity=)). Between two unrelated diseases it is an ss.modify. (Resolved; S7.4, 2026-10-22.)
  • Strains with courses of their own under the compartmental solvers. As agents they are exact. Under the compartmental solvers, strains that do not affect each other run side by side; strains that affect each other (immunity=, a shared state) need agents (decided 2026-10-20: the coupling from the members’ shares, marked approximations, was built and removed), and joint states are the exact form for explicit ss.modify(..., who=) between members. Products over a family (disease= a family, ss.by(<family>=...) in efficacy=) come with it.
  • The exact compartmental form of a strain dimension with partial immunity: a history of the strains had (2^k levels). Until it is built, a compartmental solver raises for it.
  • Stratifying only some transitions, not just some states. It may be a sign the model wanted two transitions.

B7. Conditions and custom code

  • Is who= with an event enough for event-triggered logic in custom modules? Deliveries can be triggered by another module’s events (a transition, or a delivery or its outcome), and every event is remembered per person (.ever, .n_times, .since). Whether custom step() code needs a helper for “at this person’s next visit”, or a boolean flag is enough, should be decided against a few real models.
  • Stock that carries over. capacity= per round or per unit time does not express a stock that arrives in batches and is drawn down. A time series covers simple cases; otherwise who=fn.
  • Within-host state (viral load, CD4 count, waning immunity). Today a float agent property updated in step(), which can be a stratification once binned. More native support, such as declaring a trajectory as a function of time since infection, is worth pursuing and its shape is not settled.
  • Dose response. A rate can be a function, which covers it in principle. A worked example with viral load is needed to confirm.
  • Finer hooks for a declared disease’s transmission. A declared disease can replace a whole transmission with its own transmit(transition), but not one piece of it, as ss.AgentDisease can (susceptibility(), transmissibility(), route_betas()). Hooks such as susceptibility(transition) are deferred (deferred): the declared engine’s transmission has strains, acts, mixing pools, and branching that the coded hooks do not map onto, and modifiers and products already reach the per-person quantities.
  • ss.outside as one branch of a split (some people leave, the rest go on, decided by one draw) raises ss.CapabilityError for now; leaving is a transition of its own (deferred).

B8. Results and fitting

  • Outcomes that act on individuals. An outcome is a result, so anything can read it (a trigger on hospital occupancy, a rate), but it marks no individuals; if being hospitalized changes what happens to a person, hospitalization is a state. An outcome with a dur= is close to a state with a delay. If real models keep wanting outcomes to act on individuals, a state that takes no part in transmission may serve better.
  • Where do results that combine modules go? cases = symptomatic_testing.positive + screening.positive belongs to neither intervention. It could be declared on the sim, or on the disease that both test for.
  • Results so far. A trigger that reads a result during a run wants the values up to now (new[-7:] as the last seven steps). A result.so_far view or a slicing helper is a small thing worth having; it is not specified.
  • Overdispersion. Superspreading is a property of transmission; noisy reporting is a property of the data. They are different things and need different spellings; only the second has one (the likelihood).
  • Agent-level outputs (line lists, age at infection, partnership histories) are not time series. ss.Analyzer with custom code handles them, which may be the right answer.
  • Should bounds= and prior= be one argument? Bounds say what values are valid and where to search; a prior says what is believed. They overlap. Both are kept: the prior defaults to uniform over the bounds, and optimizers use the priors when every series has a likelihood. The question is revisited once calibration has been used in earnest.

B9. Space

  • Does space need conveniences other dimensions do not have? Maps as a default plot and per-patch data loading are the likely ones. A subnational planning audience asks spatial questions constantly.
  • Adaptive mobility (people travel less during an epidemic). The mobility is not a target of ss.modify (the targets are parameters, transitions, rel_sus, rel_trans, and a network’s n_contacts), and the mixing between places is worked out once at sim.init(), so it cannot be written yet; a fittable away= needs the same change (deferred). No reviewed framework does it declaratively, so there is nothing to compare against.

B10. Reproducibility

  • What does common random numbers mean across solvers? As agents, the pairing is per agent, by slot; under the stochastic compartmental solvers, it is per run, by seed (each module draws from its own generator, seeded from the sim’s seed and its name), so it holds only while trajectories stay close; under the ODE there is nothing to pair. An ODE baseline against an agent-based intervention run is not a paired comparison, and nothing says so yet.
  • What is the CRN guarantee when the population differs between scenarios? Births, deaths, and migration make the agent sets diverge. There is probably a statable guarantee (“agents alive at the divergence point keep their draws”) worth writing down.
  • How much contact history should a network keep by default for contact tracing? (trace= on a delivery uses the network’s current contacts.)

B11. The model as code

  • Should a people property stratified on the only disease in a sim and the same stratification on the sim be one model? They give the same results, written in two places.

B12. Names and spellings that may change

  • ss.null. It is a source for births and a sink for deaths, so neither ss.source nor ss.sink fits both. ss.null is short and reads acceptably in both positions; a better word would be welcome. (Resolved 2026-11-06: not one word but three, ss.unborn, ss.dead, and ss.outside, so that a flow out of the model says whether people died or left; section A.)
  • How robust is picking up names from variables? S, I, R = ss.states(3) and ss.Disease(infection, recovery) find names by looking at the caller’s variables, once, where ss.Disease(...) or ss.Sim(...) is called. That is convenient and slightly magical. A transition has a fallback (S_to_I), and a module one from its states (sir); a state does not, and raises a ValueError. With no frame at all (from R), the arrow strings and name= work. Whether to drop ss.states(3) for ss.states('S I R'), and to name modules only from their structure, is for Cliff (deferred).
  • Inline parameter names. An inline value is named after its slot. A duration is always named by the state it is the time in (dur_I; by its transition, dur_recovery, if the state has several timed exits), and a rate by its transition (rate_death), so their names say which they are; beta and p are qualified only on a clash (beta_infection). Decided 2026-10-29 (“let’s be explicit”). Naming every one <transition>.<slot> would be more predictable still, and would rename the parameters of most models (deferred).
  • The many-run views. msim.plot(lines=True) draws every run, and .values is the full runs-by-time array; the names are placeholders for “every run” and “the full array”.
  • Which distributions, under which names? Starsim’s list and names are the starting point and can be revisited. Each is given by its moments where it has them, with SciPy’s loc= as a shift. ss.erlang(k=, mean=) has been added as a friendlier spelling of ss.gamma with an integer shape, and ss.truncnorm for a bounded normal.
  • I and I.n. A longer spelling for the bare state (I.contact, say) would be clearer, and would stop S >> I @ beta * I reading like the textbook equation.

B13. Time

  • Can the Timeline be simpler? Three views seem irreducible: the step index, time in the sim’s own unit, and the calendar date. The proposal is to keep those three and make the rest (yearvec, relvec, tvec) conversions on request, sim.t.to('year'). Whether that is a real simplification or only moves the names needs trying in Starsim.
  • A unitless model that later gets a unit. Giving the sim dates gives it a unit, and the bare numbers that were per step become per day. The note says so; whether a note is enough is open.

Open questions from best-practices

The open questions of the best-practices documents that are still open and not already above, collected here so that there is one list. Each names the document it came from.

  • Deterministic flows inside a stochastic model (camdl’s deterministic() and overdispersed(), Catalyst.jl’s per-reaction scale): per-transition methods are deferred (section C), and their random-number implications (which streams exist, what common random numbers mean) need settling with them. (stochasticity-and-reproducibility.md)
  • Ragged stratifications in results and export. ss.levels(who=) in stratify() is built (people outside have no level, so selecting a level leaves them out), but how such results plot and export (is a man’s age missing, or “all ages”?) is untried on a real model. (stratification.md)

C. Deferred: not in v1

Item Note
Different solvers for different transitions in one run Sometimes most of a model is well described by smooth average flows, but one part is a rare chance event. A country that has nearly eliminated polio is the standard example: transmission in a large population can be an ODE, but whether an imported case arrives this month is a matter of chance, and an ODE would instead add a steady trickle of a fraction of a case per day. A mixed run would treat that one transition as whole, random events and everything else as an ODE. If added, it would be a solver= keyword on the transition (ss.transmission('S -> I', beta=1e-6, solver='gillespie')). Until then, run the whole model with a stochastic solver or as agents
Different solvers at different levels Individual animals inside herds that are tracked only as counts, or the reverse. The existing way to combine the two is a state that is not people, which stays a count inside an agent-based sim
Finer network-to-ODE approximations (pair approximations and the like) One standard approximation per kind of network. Someone who needs the network’s exact behavior runs agents
Symbolic analysis beyond equations() Symbolic Jacobians, fixed points, eigenvalues. Gradients for fitting are a different thing and are in (automatic differentiation of the ODE run, in JAX). R0() stays as an optional numeric helper for simple models
Travelers in transit People in neither their home patch nor their destination. A transit patch can be added by hand
Optimal control Interventions that vary in time to optimize an outcome. cost= on products gives total cost as a result, which is what cost-effectiveness needs, and sim.optimize() reallocates a fixed budget between programs (Atomica’s program books), which is the budget optimization that was asked for
Joint likelihoods with correlation between data series Series are treated as independent
Edge-level eligibility Networks already say who is in contact with whom, and trace= on a delivery reaches the contacts of the people it selects (agents only)
Preallocated agent arrays An optimization to benchmark before building; it would not change what the user writes
Result caching An identical re-run could be a cache hit. It surprises people who expect a re-run to run, and nobody has asked for it
msim.check_runs() Estimating how many runs a comparison needs. check_dt() stays
Movement models beyond gravity and radiation (ss.stouffer, ss.competing_destinations) A matrix or a movement register covers them until a translation needs one by name. Radiation (ss.geo.radiation) is built, since it has no parameters to fit: it gives movement between places with no data to calibrate it to

D. Risks

  1. The north star is not met for some frameworks. “No more lines than the original, and about the same time” is a hard test against a compiled C++ ODE solver or a framework built for one kind of model. The per-translation table (tests/north_star.py, 18) is how this is known: on its first run, 20 of 23 translations met the lines target and 16 of 23 the time on repeated runs. The failing rows are published on the “Coming from” pages, and say where the design or implementation needs work.
  2. Guessed defaults are only safe if people read describe(). It is short and marks only what was guessed, and the most consequential guesses (unit, solver, approximations) are printed at run time without being asked. If testing with users shows the output is not read, the answer is to make it harder to miss, not to turn guesses into errors.
  3. The bare-state rule carries a lot. beta * I meaning “exposure through the routes” is what lets one disease run on homogeneous mixing, a contact matrix, or a network without change. It is also a rule a newcomer has to be told once, and the difference between I and I.n in a rate will be a common question. describe() and equations() showing the result are the mitigation.
  4. Operator spellings may be too clever. S >> I @ beta * I depends on Python’s precedence working out, and has edges (brackets around a set of sources joined by |, (E | I) >> R, whose absence is an error that says to add them). The explicit form always works, and error messages for the edges need to be good.
  5. ss.modify does a lot of work. Interventions, co-infection, product efficacy, waning, seasonality, and parameter changes all go through it. If the base and effective value mechanism is slow or surprising, much is affected. It should stay small: a target, an operation, a condition.
  6. Try-it-and-report finds problems late. A function that cannot run on groups is discovered when it is first called, not when the model is written. 'auto' tries functions at sim.init(), which catches most cases before any time is spent running.
  7. Approximating instead of refusing can mislead. A user who types solver='ode' on a network model gets a result. The one-line note, describe(), and approximations='error' are the safeguards; the recorded differences in 18 say how large the effect typically is.
  8. The ordered loop makes module order part of the model. That is how Starsim works and it allows within-step chains, but two people can build “the same” model with different orders and get slightly different results. sim.loop and sim.check_dt() make this visible. It matters less than it did: a declared disease takes all of its transitions after the interventions under every solver, and a compartmental run that the kernels take whole (with the deliveries they tabulate) has no module order at run time.
  9. Two languages in one library. Modules written with step() (coded subclasses of ss.Disease or ss.AgentDisease (Starsim 3’s ss.Infection), and Starsim 3’s SIR, SIS, and SEIR in the library, ssl.AgentSIR and the others) stay valid next to declared diseases; ss.SIR, ss.SIS, and ss.SEIR are now declared, and the v4 verbs (ss.modify, conditions, sim.fit()) work on modules written as code too. If the declared form does not cover enough real models, most models will be mostly custom code and the benefits (equations, a choice of solver, stratification) will apply to little of them. The share of each translation that needs custom code is the thing to watch.
  10. More is inferred than before. Which states are people, the sim’s unit, and the names of states all come from what was written and not from a declaration. Each is printed, and each has an explicit spelling (ss.State(people=False), unit=, ss.states('S I R')). They are safe to the extent that the printed line is read, which is risk 2 again.
  11. Stratifying in place loses the unstratified model unless a copy was asked for. In place is the default because it is the usual case, and a line that silently did nothing to sir would be the more common mistake; the error on stratifying twice is what a re-run notebook cell hits.