# Deferred

Work that has been looked at and set aside for now: worth doing, but not yet. Each item says what it would take and why it waits. Items that are done move to `implementation.md`.

## Performance

- **The rest of the deliveries in a whole run.** Deliveries on a schedule of a product people hold, or a transfer, to people chosen by states and stratifications, are taken by the kernels (`ss.Delivery.tabulated`). Still stepped from Python, at 120 to 300 µs a step against about 10 µs: tests and their follow-ups (a care cascade, as tbsim's `care()`), treatments that cure (moving people along a transition, credited to its flow), capacity, `when=`, deliveries in a model of several diseases, modifiers with `when=` or a function as their amount, and transfers whose rate varies. A screen whose follow-ups are all compiled could be one set of moves from the compartments at the time of the screen (each person's path through the cascade is a product of static shares): shifts between groups as now, and moves along a transition to its destination's entry compartments (with branches, maps between dimensions, and the flow credited), drawn as a multinomial under the stochastic solvers; a `when=` that is a function of the sim cannot be compiled. Not done yet (2026-10-16), pending Cliff's decision: the cascade is the larger piece, and would let tbsim's care cascade be fitted by NUTS. A treatment alone is the common case (EpiChat, 2026-11-06): an SIR ODE of 10,000 people for 365 daily steps takes 0.0015 s, and 0.18 s with `ss.treat(ss.Treatment(cure=1.0), who=I, coverage=ss.perday(0.1), capacity=ss.perday(50))` (0.30 s before two fixes that day: the counts of each state as one cached matrix product, and a lookup of `take` on the product that built a "did you mean" message each step); a stepped run with a do-nothing intervention takes 0.003 s, so the rest is the delivery's own Python, about 0.5 ms a step (`cure_groups()`, `fire()`, 13 calls to `state_counts()` a step). To take it in the kernels: a cure as a move along its transition into the destination's entry compartments (`Shifts.land`, with the share cured as part of `cov`, and the failed and unneeded tallied as results), and capacity as a second pass in `apply_shifts()` that scales the share reached in each compartment and group so the total is at most the capacity of the step; both in Numba and Rust, and the JAX replay (`store_replayed()`). M.
- **Dropping the empty groups of a ragged stratification.** With `where=` on `stratify()`, a compartmental run keeps the full grid of groups: the people outside `where=` are counted at the first level, and the groups at the other levels stay empty but are still worked out (four age groups of women and one of men is eight groups, not five). Keeping only the groups that can hold people would need the group index to go through a lookup wherever it is now worked out by arithmetic (`group_mults`), in the solvers, kernels, movers, and fitting. Worth it only if a real model has many empty groups.
- **What is left of `benchmark-gaps.md` (2026-11-02).** Looked at and left, since each would add more than it gains. A sweep is only about 0.3 ms slower than a rerun of a small model (1.0 against 0.7 ms on `memilio/secir`), so working out the tables by which value changed would add dependency tracking for at most 1.4×. `choose_engine()` isn't a measurable part of a rerun. The rest of a small rerun is the ODE kernel and bookkeeping spread over many functions. SISe3's rest is `place_rates()` itself, an interpreter over the program; a herd-level fast path would serve one model. For England and Wales, what is left is whole-population passes (see below) and `add_people()`, whose per-place Python loop could be grouped by states for a few percent. A broader warning for plain stratifications would be a guess: the note covers names like `patch=`, and `populations_geography.qmd` covers the rest.

- **The rest of an agent-based step.** After this round, what is left in a large agent-based step is spread out: the infection pass, exposure by place, and finding the members of each state (one `flatnonzero` a state), each 1 to 3 ms a step for 668,000 agents. Finding the members of every state in one pass gains little, since each state is its own array.

- **The particle filter on outcomes** (review W11). With an outcome such as `cases` as the data, `engine.collect(..., expected=True)` works out the whole run again at every data point, about 2.5 times slower than with a flow. It would take keeping the outcome's pending amounts between data points. It waits for the particles-in-the-kernel work (N27, phase 8), which changes the same code.
- *Done (2026-11-07, in the API review: BLAS's threads are set by `ss.options.blas_threads`, and each worker of `ss.parallel()` and of a calibration takes one of Starsim's own, `ss.options.num_threads`).* **Threads in parallel workers.** NumPy's BLAS and Numba each start a pool of threads in every process. For one process this costs nothing (measured on the transition tests: the same CPU time, 1.5 to 1.7 times faster in wall time than with one thread), but many processes at once (pytest-xdist, `ss.parallel()`) oversubscribe the cores. It would take setting one thread in each worker (as `SCIRIS_NUM_THREADS=1` does for BLAS). It waits for a measurement of `ss.parallel()` with and without it.

- **Rust kernels that abort on bad input.** Found in review (2026-10-28), for input Starsim itself never makes: a failed allocation inside a pass (e.g. `census` with 10^12 groups) aborts, where Numba raises `MemoryError` (it would need `try_reserve` on sizes taken from arguments). Every other panic raises a `RuntimeError` through `ss.perflib.rust.fallback()`, and arrays the kernels can't take (read-only, object, 2-D where 1-D is meant) go to Numba.
- **Compiling the kernels by solver.** Each solver could compile only its own path through `ss.kernels.step()`, which would cut a first compiled run from about 45 s to 18 s (ODE) or 27 s (binomial) ([oct21/numba_vs_jax.md](oct21/numba_vs_jax.md)). Numba makes it awkward: a literal `solver` didn't drop the other paths and made calls 20× slower, and passing or closing over a step function defeats Numba's cache (its type, or its pickle, differs in every process). What works is a closure over the solver as a number (Numba drops the other branches and caches each), with each caller reaching the solver's `step()` through an `if` on that number, or a module-level tuple of functions (an experimental Numba feature that can't inline). Set aside on 2026-10-21 (S5.11 of [oct21/plan.md](oct21/plan.md)), since the kernels now run as plain Python until compiling pays, so the compile is opt-in and happens once; worth it if the one-time compile (about 100 s for everything) becomes a problem, e.g. in CI.

### Models whose rerun is still slower than the original (2026-11-02)

From the full benchmark (`lingua_franca benchmark`, in [timings.csv](../benchmark/timings.csv)), after the November 2 fixes that keep dated measures, `when=`, conditions on counts, typed coverage, and outcomes in the kernels. None of these models hit those cliffs, so the fixes left them within noise. LASER is left out here; its gap is whole-population passes over the agents (see "What is left of `benchmark-gaps.md`" above). Times are reruns with the Rust kernels.

| Model | Original | Starsim | Gap | What it is |
|---|---|---|---|---|
| `memilio/secir` | 0.02 ms | 0.37 ms | 18× | Starsim's fixed cost per run |
| `memilio/secir_ageres` | 0.06 ms | 0.49 ms | 8× | Starsim's fixed cost per run |
| `siminf/sise3` | 90 ms | 520 ms | 5.8× | Real model work, mostly `place_rates()` (see above) |
| `siminf/sir_herds` | 75 ms | 140 ms | 1.9× | Real model work, not yet looked at |
| `odin/seir_array` | 1.4 ms | 2.9 ms | 2.1× | Real model work, not yet looked at |
| `summer2/seir-age-clinical` | 0.29 ms | 0.48 ms | 1.7× | Starsim's fixed cost per run |
| `summer2/sir-age-location` | 0.22 ms | 0.34 ms | 1.5× | Starsim's fixed cost per run |

- **The fixed cost per run.** A restart and run of a small model costs about 0.2 ms, of which only 0.06 to 0.12 ms is the kernel; the rest is restarting, storing results, and finalizing, spread over many functions. MEmilio and summer2 run their whole model in less than that. Closing the gap would mean a leaner path from `sim.run()` to the kernel and back, not removing a cliff; it waits since all of these pass the benchmark's limit (2×, or 1 s) and a rerun under half a millisecond is rarely what limits a user.
- **Real model work.** SISe3, SIR in herds, and odin's SEIR on arrays spend their time in the run itself. SISe3's is the interpreter over the program; the other two haven't been profiled, and are the place to start if these gaps matter.
- **The rest.** About 20 models rerun faster than their originals, many by 10 to 100×. Every model passes on time except LASER's England and Wales.

## Accuracy

- **Continuous times of entering in the agent engine.** A declared disease run as agents and one written as code now round a time between steps the same way, at random in proportion by each agent's phase (`ss.People.phase()`: step floor(t + phase)). But the engine starts each stay at a whole step (the step of the move), and rounds each dwell from there, while code that chains its times (HIV's `ti_falling = ti_latent + dur_latent`) rounds the sum. So the same agent's successive stays are rounded the same way (with a fixed 1.5 steps, always 1 or always 2), and a chain of stays is rounded piece by piece rather than once. The exact version keeps each move's continuous time (the drawn exit time) as the next state's time of entering, schedules from it, and takes the exit at floor(t + phase): then both are one continuous-time process observed at the steps. It would take the scheduled exits as floats (`ti_exit_X` compared with the phase wherever it is now compared with `ti`, including the rescaling of a dwell whose rate changes and `backdate()`), and `schedule()` given the exit time on each scheduled move; it is not simpler or faster than the rounding by phase, and the difference is in how each agent's rounding errors are correlated, not in any mean, so it waits.
- **A nicer spelling than `self.ti + 1 + dur` in coded diseases.** An infection in a step happens at its end, and the seeds are dated at `ti=0` (the end of the step before the first, since `1878bad0`), so a course scheduled from an infection starts at the next step: `ss.Infection`'s docstring and example ask for `self.ti_recovered[uids] = self.ti + 1 + dur`, read with `TimeArr.due()`. HPVsim was ported to this on 2026-10-29 (its `set_prognoses()` now schedules from `ti = self.ti + 1`, keeping `ti_infected = self.ti`); scheduling from `self.ti + dur`, as Starsim 3 code does, gives every case but the seeds a course one step short (one year at `dt=1`), and gave HPVsim's seeds about 40% fewer infections when the seeds' date moved. The `+ 1` is ugly and easy to forget, and what it means depends on where in the step the code runs (`step_state()` at the start, `set_prognoses()` from transmission at the end). Ways around it: (a) a time of "now" that knows where in the step it is (e.g. `self.now`, or `self.t.now`), `ti` at the start of a step and `ti + 1` during and after transmission, so code writes `self.now + dur` everywhere and the seeds (at `now=0`) come out right too; it's the continuous-time view in the item above, and `ti_infected` could be set from it as well (though `ti_X.isclose(sim.ti)` as "this step" would then need care); (b) a method that schedules, e.g. `self.ti_recovered.after(uids, dur)` (or `.schedule()`), which adds the `+ 1` when called from transmission, so the step arithmetic never appears in user code; (c) `due()` taking a time set during transmission as exclusive (`<`, not `<=`) by recording which stamps came from the end of a step, which is invisible but magic, and wrong for code that compares the times by hand. (a) seems the most general and the simplest to explain, and (b) could be written on top of it, but `now` is taken: `sim.now` and `mod.now` are the current time as a date or duration (`sim.now == 2015`, `sim.now.dayofyear`), so (a) needs another name, a phase-aware `now` with `TimeArr` accepting dates and durations (which would change what time-varying inputs read during transmission), or renaming the date `now`. Cliff liked (a), then set it aside (2026-10-29) as too complicated for now; either way, the `ss.Infection` example, the coded-module docs, and the migration script (which would rewrite `self.ti + dur` in `set_prognoses()`) change with it.
- **A network ODE's saturation for a modifier on part of the people** (2026-11-09). Over a network whose edges saturate (`RandomNet(dur=0)`), the ODE now gives each transmission its own factor (1 − e^(−x))/x, from its own rate and each source state's coefficient, as agents have every factor in the exponent. A modifier of a transmission on a route that covers a fraction of people (`ss.modify(..., network=, frac=)`, `1 − frac·(1 − multiply)`) is still taken as one multiplier on x, but as agents it is a mixture: the exact factor is frac·sat(x·multiply) + (1 − frac)·sat(x). It matters only where x is large and the modifier strong; deferred until a model shows it. Edges that last several steps (households, `RandomNet(dur=...)`) would need a pair approximation, also not done.
- **Thinning for time-varying rates under Gillespie.** Gillespie and tau-leaping hold a value that varies smoothly in time for a quarter of a step at a time (with a note), rather than thinning as the spec asks for. Thinning needs a bound on every rate over the step, which a rate written as any expression of the parameters does not give; a bound could be worked out by interval arithmetic over the kernels' operations, if exactness is needed.

## Features

- **`ss.par` inside `ss.seasonal(mean=...)` in a class body** (October 30) raises a confusing "two members are both named" error; `ss.par(ss.seasonal(...))` works, but makes the amplitude and peak part of one parameter. S.
- **A count that carries a strain** (October 30): `Iv.n` in a rate of a disease with `ss.Strains` is refused, clearly, so a vector population per strain isn't one disease, and `ssl.Dengue`'s serotypes are members with mosquitoes of their own, as agents only. M.
- **`ssl.ERGMNet`** (October 30): a full exponential random graph model, simulating from given coefficients by edge toggles. Set aside: `ss.DegreeNet` already covers edges, concurrency, degree caps, and matching on an attribute, whose docs say so; what an ERGM adds is the dependent terms below.
- **Triangles as a target statistic.** `ss.PartnerNet` and `ss.MFNet` target the number of partners people have at a time and mixing by a property (`match=`). Triangles (two of a person's partners who are partners of each other), which an ERGM can also target, need the network fitted as a whole, not partner by partner.
- **WorldMove (synthetic movement within cities).** A collaborator suggested it ([Scientific Data, 2026](https://www.nature.com/articles/s41597-026-06555-2); https://github.com/tsinghua-fib-lab/WorldMove): one day of synthetic trajectories for each of about 1,600 cities in 179 countries, on a 1 km grid in half-hour slots, from a diffusion model, with commuting origin-destination matrices between the cells of each city. It is within cities, while `ss.geo` and `ss.gravity` are between administrative units, and a v4 run at a step of a day has no use for half-hourly paths. Its commuting matrices could give `ss.Mobility(matrix=...)` for a model of one city on a grid. It waits for a vignette that needs one; the repository has no license, and the paper is CC BY-NC-ND, so `starsim_data` could not redistribute it as things stand.
- **Gaps found while writing the importers and the import guides.** Each was worked around or listed as not translated. Fixed since (2026-10-18): `ss.PartnerNet(acts=)`, deaths from a life table (`ss.death(lifetable=)`), seeding a number of people on a schedule (a delivery with `move=` and `capacity=`), `ss.Mobility(scale='mean', cap=)`, the binomial solver's substeps for one Erlang stage (it now takes whole steps), and a one-off move between states (`ss.vaccinate(move='S -> V')`). Still open:
    - There is no declared condition on a network (a person's degree, or being the partner of someone infected).
    - A transition's current flow cannot be used in a rate (MEmilio's test-and-trace capacity): `I >> R @ (0.1 + 0.5*infection.incidence(over=ss.days(7)))` raises a `TypeError` ("condition has no single truth value").
    - `ss.PartnerNet` takes edges, concurrency, a cap on degree, matching on one attribute, and a number of partners by level (`n_contacts=ss.by(...)`, EpiModel's `nodefactor`); other ERGM terms (triangles, `nodematch(diff=TRUE)`) have no equivalent (EpiModel).
    - EpiModel lets people recover in the step they are infected, and counts after that, so its prevalence is 3 to 6% lower for the same epidemic; Starsim has no option for that order within a step.
    - An initial number below 1 is read as a fraction of the population, which a model with a fractional count (camdl's test models) cannot say otherwise.
    - In a compartmental run, a value that varies in time is taken at the start, middle, and end of each step, not at each substep, so a change within one step (MEmilio's one-day smoothing of a damping) is approximated, and `substeps=` does not help; a smaller `dt` does.
    - A count over the groups weighted by a parameter that differs between them (MEmilio's test-and-trace load, the sum over age groups of `(1 - mu)/T × INS`) cannot be written; `.n` is an unweighted total, so the importer uses the mean of the parameter.
    - A parameter of the infectious person's group (MEmilio's `RelativeTransmissionNoSymptoms` and `RiskOfInfectionFromSymptomatic` by age) can be given only as `rel_trans` for all infectious states together; a different one for each infectious state has no declared form.
- **`describe(verbose=True)`** (review P7, Decision docs 13, which recommends building it). It would show the per-step conversion of every converted quantity (`beta = 0.3 per day -> p = 1 - exp(-0.3 × 1) = 0.2592 per step`), the generated Erlang stages, and each schedule group's rows of the loop table, with a module moved by `sim.loop.insert()` at its new place (today the schedule ignores insertions). It would take a `verbose` argument on `Sim.describe()` and `Disease.describe()`, using the engine's dwell means and stage counts (`Engine.dwells`, `dwell_mean()`) and the loop's plan rather than the module list: about an hour with a test. It is simply not built yet; the spec no longer promises it.
- **The size of a stratified model, and its equations by group.** `describe()` lists each dimension's levels but not the number of compartments (groups times states), and `equations()` of a stratified disease prints the unstratified equations. The count is cheap (sum `sim.n_groups` times the states each dimension applies to, with a note above about 10,000 compartments, as the dropped `LargeStateSpace` warning was); an indexed form (`dS[a]/dt = -beta * S[a] * sum_b(C[a,b] * I[b] / N[b])`) needs the engine's groups and mixing written symbolically. Waits for a user who needs either: the sparse movers and factored mixing made large grids cheap.
- **The flow of each source of one transition.** A transition with several sources (`(S | I | R) >> D`) records only its total flow (deaths by the state they died from). The destination half is done (`res.cin.X`, 2026-10-19, `oct17/designs/destination_results.md`); a source would get a name of its own by the same mechanism, not a `source` axis, once sources can be named as targets. Waits for a user who needs it.
- **An outcome on the sim, across modules** (`ss.Sim([flu, rsv], ili=ss.outcome(0.3 * (flu.infection + rsv.infection)))`). The spec promised it; today `ili=` is an unknown parameter of the sim. It would take sim-level results built from several engines' flows by group, under every solver and in the kernels' whole runs and JAX, with data matching and likelihoods for a sim-level result. Waits because no vignette needs it: arithmetic on results after the run gives the series.
- **Fitting a product of parameters as one quantity** (`sim.fit(pars=[beta * contacts])`, which raises `ss.NothingToFit`). It would take `pars=` accepting an expression of `ss.par`s, with bounds from the factors, moving the first factor and holding the rest, and reporting the product. Waits because the `ss.Unidentifiable` note already says to fit one and fix the other.
- **A compartmental approximation of networks of lasting partnerships** (review X23). `ss.MFNet` and `ss.PartnerNet` under `ode`, `binomial`, or `gillespie` raise `ss.CapabilityError` ("no compartmental approximation yet"), although partner-change rates are the standard approximation for sexually transmitted infections. It would take partner-change rates between sex and risk groups worked out from the network's targets (`n_contacts`, `concurrency`, the duration, `acts`), with a note, a rule for networks with age mixing and several partnership types (19 B5), and `solvers()` reporting it. Waits because agents run these networks exactly, and the grouping for complex networks is open.
- **A report of the common-random-numbers contract** (review P7). The spec promised that `describe()` (or `sim.crn_report()`) would list which draws keep common random numbers (per-agent draws by slot) and which do not (sampling without replacement, joint draws). It would take marking the draws that do not in `ss.Dist` and the networks, and printing them. Waits because nobody has needed it, and pairing is weak once runs diverge anyway (review R30).
- **A declared program (`ss.program()`).** Programs exist only from an Atomica program book (`read_progbook()` in `starsim/converters/atomica.py`): each becomes a spending parameter `spend_<program>` whose coverage moves the parameters it affects, which `ss.fitting.optimize()` reallocates. It would take a Python object for a program's spending, unit cost, target population, and outcomes, built into the same parameters and modifiers the importer makes. Waits for a model outside Atomica that needs programs.

- **Exporting a stratification as separate species.** SBML and odin exports sum each stratification into one group (listed by `ss.LossyExport`). Writing each group as its own species (or an odin array) needs the mixing between groups, `adjust=` by group, and aging and other transfers written out from the engine's groups. It waits for someone who needs a stratified model in another tool.
- **Hosting the fetched contact matrices as release assets.** `starsim_data` fetches `prem2017.h5` and `mistry2021.h5` from GitHub at commit `56cb6a5` (`data/fetched.json`), each checked by sha256, so they stay reachable only while that commit is in the repository's history; a force-push or rewrite that drops it would break `fetch()` for both. Attaching the two files to a GitHub release and pointing the manifest at its URLs would take a two-line change. Deferred by Cliff (2026-10-16): the commit is on `main`, and nothing rewrites it.

- **The rest of the saved form** (review R29). Likelihoods and priors still go into the spec as text, and the `14-model-spec.md` grammar is not rewritten. Either changes hashes again, for models with likelihoods or priors only; priors in the hash is Decision results 13, which is not answered. It waits for phase 9 (the saved form).
- **The rest of the Atomica and camdl importers** (review N13). Atomica's junctions, interactions, and population types beyond the first, and camdl's interventions as deliveries, are listed by `ss.ImportIncomplete`. Atomica's programs are now read from a program book (`progbook=`), and a one-off move between states exists (`ss.vaccinate(move=...)`), which camdl's interventions can be written with.

- **Inline parameter names as `<transition>.<slot>`** (review L23, Decision lang 5). An inline value is named after its slot (`beta`, `dur`), qualified only on a clash (`dur_recovery`). Naming every one `recovery.dur`, with a unique short alias, would rename the parameters of most models (in `pars=`, `sim.fit()`'s tables, and the spec) for a modest gain in predictability. It waits for Cliff's view on Decision lang 5.
- **The rest of names without frames** (review L20, L21, L36, Decision lang 4). Done: one set of states and transitions can build several diseases (they are copied when another disease already has them), a string state name no longer picks up another disease's state from the caller, and a module that is not bound to one variable is named from its states (`sir`) rather than `disease`. Not done: dropping `ss.states(3)` for `ss.states('S I R')` (it would change most models and tests, and its failure, `ss.UnnamedState`, is already a clear error), and naming modules only structurally with no lookup at `ss.Sim` (it would name `hiv` and `syph` `si` and `si_1`). Both are for Cliff.

- **Cheaper rescaling of dwell times when people change level (KI6).** An agent's dwell is rescaled when they move to a level where its transition runs at a different speed (`adjust=` by a dimension people change along: age, a transfer, `move=`, migration). It costs about 4% on such models (an SIR with recovery adjusted by age, 200,000 agents, 200 steps: 0.53 s against 0.51 s), from looking up every member's group each step in `Agents.rescale_exits()`. It would take a per-step list of the agents whose group changed, kept by `Groups.group_raw()` (it already finds them for age) for each engine to read. Cliff (2026-10-19): "i wouldn't worry about 4%".
- **Local fits of a stochastic model over several seeds.** A fit by Nelder-Mead (or another local method) scores every point with the same seed, so it finds the optimum of one seed's realization; Optuna-style fits take a fresh seed per point (decision 13 of 2026-10-19). Ideally a local fit would run several times, each with its own seed, and report the spread. Cliff, 2026-10-19: "what should ideally happen is multiple nelder-meads run with different seeds. but for now i think single seed is ok." It would take a restart loop over seeds in `fit()` and a summary of where the runs land.
- **IF2 for observation and initial-value parameters.** `method='if2'` (built by N27) fits the model's own parameters. A parameter of how the data are observed (a reporting rate, a likelihood's dispersion) or one that sets the starting values is refused, pointing to `method='pmcmc'`: the first needs the likelihood evaluated with a value per particle, the second the starting compartments drawn per particle at the start of each pass.
- **Thanzi La Onse on v4.** Cliff would like to rebuild TLO (`/home/cliffk/idm/git/TLOmodel`; review in lingua_franca's `approaches/tlo.md`) on v4, expecting it to run 10 to 100 times faster. Most of TLO would stay as its own code and data, not move into Starsim, but if v4 can't represent TLO, that is a gap in v4. What it would take: first a port review of TLO against v4 (as for STIsim, HPVsim, and FPsim), then the v4 changes it calls for. The parts most likely to need v4 work are care that can be refused (requests that use several resources at once, such as staff time by cadre, consumables, and beds, with priorities, time windows, elastic and hard capacity modes, and a declared fallback when refused), symptoms owned by diseases with care-seeking handled centrally, per-person DALY accrual from declared causes of death and disability, `LinearModel`-style risk equations over person attributes, events on calendar dates at each module's own cadence, and about 30 modules sharing one population. Set aside on 2026-10-19 as work for later.

## Tests

- *Done (noted 2026-10-22): `tests/test_errors.py` triggers every catalog class by name (`test_every_class`).* **A test that triggers every named error, warning, and note** (review P6). The catalog in `design/15-errors.md` is generated and checked, but about twenty names are not triggered by name in any test, most of them notes and warnings: `AtBound`, `ClippedToRange`, `DataMisaligned`, `DuplicateDeclaration`, `ExtinctionPossible`, `LargeStep`, `MatrixAggregated`, `NotConverged`, `NothingToFit`, `OutcomeCycle`, `OverlappingSet`, `SmallCounts`, `SourceNeeded`, `StageStopped`, `StagesUnderAuto`, `StateNotPeople`, `TargetStatsNotRecovered`, `Unidentifiable`, `UnitInferred`, `UnknownDisease` (some are triggered by tests that match the message instead). It would take one parametrized test with a minimal trigger for each class, asserting the class and that the message names the user's identifier: about two hours. Waits for a quiet moment in the error register.
- **Golden `describe()` output for each corpus model** (review P10c). The expected text of each `tests/corpus.py` model's `describe()` in a YAML file, diffed and updated through the `baseline.yaml` and `update_baseline.py` pattern, so that a change in what a model means is reviewed. About an hour, plus reviewing 23 outputs. Waits because the text of `describe()` is still changing (guess marks, `describe(verbose=True)`).
- **A recorded table of solver differences for complex models** (`design/18-conformance.md`, suite 3). For the approximated corpus models (dwell times as stages, a network model as agents against its nearest ODE), the peak size, peak time, final size, and endemic prevalence under each solver, in a committed table regenerated on demand. About an hour. Waits for the solver menu and the kernels to settle, since every number would move.

## Carried over from the October 19 plan

Set aside on 2026-10-21 when the simplification plan ([oct21/plan.md](oct21/plan.md)) was closed off: none blocks it, and each was written before S3 made many compartmental forms agents only, so each is to be checked against the support matrix and narrowed (to agents) before it is started. The detail of each ID is in [oct19/implementation.md](oct19/implementation.md).

- **Vector models**: standard `prevalence`/`new_infections` from the acquisitions, not only from a transmission (about 8 sites in `compartmental.py`); MA11 (weighted counts), MA12 (per-node EIR from coded modules), MA14 (a product's coverage in a rate), MA33 (a neutral held product, and several wanings). Waits for the malaria port, which is what needs them.
- *Done (2026-11-05, from the tb_LAI_TPT port: a fading hold ends when no protection is left, and `skip_reached_within=` on deliveries is the re-dosing rule).* **MA15, re-dosing a product whose waning is a function**: such a product is held for good, so a second round reaches nobody who had the first (600 against 3,237 people in the review's example). A wrong answer, but a loud one to anyone who checks coverage, and it needs a spelling (e.g. `redose=`) from Cliff; agents only since S3.4.
- **HP19, a general per-person write on events** (a running maximum, a value frozen at entry), as agents: `ss.attr` with recorded values. Its compartmental form went with B6. Waits for a model that needs more than a module written as code gives.
- **Demography**: N11/SW3 (births from one stratum into another; `tests/switching/test_emulsion.py` xfail, in the lingua_franca repo), and whether `ss.birth(per=)` should cover SW2 (offspring by prototype) now that S7.1 removed `offspring=`. N11 waits for a model that needs it. *SW2 as agents works since 2026-10-22*: a triggered split on the birth event with `where=ss.mother(..., born=True)` (see `oct25/backlog/features_docs.md`; the quickstart's xfail now fails only on herd size).
- **Superseded by S3, unless Cliff says otherwise**: the care cascade's steps 11 and 12 (the delivery program in the Numba kernels, then JAX; see the second item under Performance) and its leftovers from steps 5 to 10 (a drawn delay's compartmental form, `delay=` breaking a dependency cycle, `on=` another disease's event under compartments); HP28 with D-A2 (hyperexponential dwells under the compartmental solvers; S3.1 keeps exponential, Erlang and fixed dwells). To check first: whether `delay=` breaking a dependency cycle still matters for agents.
- **Performance**: MA28, N29, N25/SW7, FP34, ST27, D-P5, MA26, SC6, W16, DF7, X27/PL12, PL11. Each is to be re-measured after the simplification (S8.1 measures the benchmark set) before any is fixed.
- **Inference and the saved form**: X26, DF15, DF26, R16, KI21, N20, DF22/R29, DF30, R23, DF38, PL10, C12c, X19, MA30, KI14, DF14/R17, DF12, FP38, X10.
- **Importers** (lowest priority, as before).
- **Small items from the October 19 decisions** (Cliff, 2026-10-21: "if something is simple + useful or simplifies things, implement; otherwise, defer"; none was both): NR13 (no progress bar for very short runs; naming a clashing product after its delivery, done 2026-11-05: it is named `<delivery>_product`, and result keys don't change, since products have none); X1 (hiding the `<transfer>_stage` dimension from `describe()` and results); DF20 (contact matrices as release assets); SW8 (a seed below one agent: it rounds to 0 with a note; stochastic rounding keeps the expected number but still gives no epidemic most of the time in `test_odin_agents`, and raising would refuse models that run, so neither is clearly better than the note). L20/L23 are under Features.
- **Two designs from October 17** (Cliff, 2026-10-21): [people_as_data.md](oct17/designs/people_as_data.md) and [burden_of_disease.md](oct17/designs/burden_of_disease.md).
- **Reviews**: the canon gallery, the naive review's xfails, real models at full complexity, D-T1's rest, D-T2, D-T3, P7/D45/X30, and the downstream CI gate.

## From the October 22 plan

Set aside while building [oct22/plan.md](oct22/plan.md), each with what it would take.

**Performance**
- FP58: declared rate exits under a changing modifier are redrawn (C17's correctness fix), so the declared method × pregnancy toy as agents is 2.1× slower (3.09 vs 1.44 s). Fix: redraw only the exits whose rate changed, or per-step hazards for rate exits under modifiers. M.
- Counts through the compartmental kernels under every solver (MA28): after D1, RK4 on counts in agent runs is still 4 Python derivative calls a step (about 0.15 ms each). Fix: a program for the count states with people counts as per-step inputs, run by `ss.kernels.one_step()`. M+.
- `sim.current_scale` lookups and `distributions.jump` show in agent profiles. S.

**Accuracy**
- A pair-formation ODE (Kretzschmar and Dietz: singles plus pairs by state, by sex) for `ss.SexualNet` under compartmental solvers, so a curable STI with fast transmission within partnerships runs right (question 17 of the oct22 plan, option b). M.
- CB-2: a fixed delay that is not a whole number of steps as a conveyor whose inflow is split between the floor and ceiling slots, keeping agents' mean (S–M: the targets of a state's entry become two compartments); a delay under one step still needs a step, since compartments move people once a step (plan_b C1: the split fixes 1.5 steps, not CB-2's case under one).
- An automatic hyperexponential fit for a dwell with a CV above 1 (FP53's alternative); users can write the mixture as states today (A3). M.
- ST29: exact rescaling of exits beyond 32-bit steps (adjust ≤ 1e-9) would need the drawn dwell stored as a float. Not worth it now.
- `ssl.NCD` prevalence divides by the unweighted `people.n_alive`: one line if weighted NCD runs matter.
- Fetuses as agents are put in each module's first state at conception without firing anything, so a `dur=0` split out of it would happen during gestation. Worth a look with `fetus=True` models.
- Optuna's convergence rule compares raw trial scores, so one lucky late draw makes a fit "not converged"; a smoothed criterion would be better. M, needs a small study.

**Features**
- FP52, implemented rather than refused: `ss.change`/`ss.modify` on a value over `to` in a `p=` table (FPsim's method switching). M.
- Sugar for a product whose holding is a user-declared state module (`ss.Vaccine(waning=prot)`), about 40–60 lines; wait until a downstream package asks (A3).
- A module given to a sim and never run raises on `.results`, pointing to `sim.analyzers.<name>` (TB §4.4): small, but touches every module's attribute access.
- `ss.SexualNet`/`ss.DegreeNet`: one-off contacts between two groups.
- ST49 under the compartmental solvers (one beta per route per source state). S.
- `0.275*ss.peract(...)` (a number times a per-act value directly) is a `TypeError`; it could scale the per-act hazard. S.
- ST42's `preg.redraw(uids)` and a modifier of the preterm chance, for BV's preterm link: needs a design for modifying `preterm_risk()`.
- Strains: waning cross-immunity and Covasim's antibody kinetics (need CV13); enhancement (protection above 1, dengue); within-host abundance and several lineages of one type; correlated transmission of several strains in one contact; `per='strain'` on a rate without `p=`; `ss.Strains` for diseases written as code.
- A helper for "the steps so far" (`res.x[:sim.ti]`) remains open (spec 19).
- ST44's closest-name hint includes non-result keys such as `restart`. S.

## From the October 22 plan_b

Set aside while building [oct22/plan_b.md](oct22/plan_b.md), each with what it would take.

**Performance**
- F1, the rest of an agent step's fixed cost: tbsim-shaped TB at 1k agents is about 3.5 ms a step, spread over `eval_for` of each competing rate (most are constants, so could be worked out once per step per state), `adjusted()`, `move()`, `Dist.jump()` reading `ss.options.crn` 112 times a step, and `arrays.__setitem__`'s `wrote()` bookkeeping; each 3–8%. S each; best together with a per-state cache of constant hazards.
- F3, Starsim's side of build time: each declared disease's `link_dists()` and `link_timepars()` walk its engine (1,750 nodes for one HPV genotype) and graph (830), which hold nothing they need; skipping them would roughly halve what is walked, keeping `Dists.init()`'s traces (the seeds) unchanged. S–M; best after the proposed Sciris change to `sc.search()` (in [oct25/backlog/ports.md](oct25/backlog/ports.md), under Sciris).
- F4, the expression tree: FPsim's declared contraception still interprets its regression per evaluation (37 nodes, about 70 evaluations a step); compiling a row once to a NumPy closure would remove most of it. M.
- F6, the rest: post-draw unit scaling (`self.unit(rvs)/self.module.dt`) could be one multiplication by a cached factor for durations, but changes the last bit of every drawn dwell. S.
- CV26, the JAX choice: `kernel_seconds()` is a fixed formula of the model's size (W8, 2026-10-25: never timed, so the engine, and a seeded run's results, no longer depend on load or on Numba's cache), within 0.93–1.6× for ODE runs over 0.05 s. It leaves out the interpolation of values that vary in time, and the substeps an ODE adds as an epidemic grows (an SIS with aging, 0.34×), so it errs low for such models; a long run of one may stay on the kernels when JAX would be faster. Add a term for the tables, and one for the substeps, if it matters. S.
- Tabulate `ss.strains.MemoryProtection` in compartmental runs (its value by group is constant), so a model with protection on the course by memory of strains runs as one compiled whole run rather than step by step. S.

**Accuracy**
- The chain binomial's bias near the epidemic threshold (TC1): its default substeps are now chosen by the bias (TB3, oct26: rate × substep ≤ 5%, and ≤ 0.1 × |R0 − 1| near threshold; the substep a power of 2 of the unit; TBsim's resistance example +11.7% → +5.8% at 6 substeps a month, 0.18 s either way; an SIS at R0 = 1.05, 1.67× the ODE → 1.03×; LASER's measles test 2 → 4 substeps, 3.2 → 5.8 s). Still open: letting arrivals leave within the substep in proportion (1 − e^(−x))/x, as `release_rates` does for conveyors, which would remove most of the bias at the old substeps; and a model whose R0 at the disease-free state says little about its threshold (TBsim's is 0.66, endemic by reinfection), which gets only the 5%. S–M.
- A scalar fertility rate of `ss.Pregnancy` (ST2, oct26): now read as data with one value (a rate per woman-year, rescaled to the women not pregnant), births +6.8%; still −4.4% against Starsim 3 (z ≈ −2.3) and a TFR of 3.34 for a rate implying 3.5; part may be that the rate is by age at conception and the ASFR by age at delivery. Check with FPsim's TFR (below). S.
- A chain-binomial run with more than one substep and a rate that varies in time (`ss.seasonal`) draws differently when stepped from Python (`run(profile=True)`) than as a whole run (up to 92 of 1,100 vaccinated over 60 days at 2 substeps; equal at 1). Present at `2dad9d88`; seen once TB3 gave such models 2 substeps (`tests/devtests/test_contracts.py::test_streams` now pins `substeps=1`). S.
- MG8 follow-ups (oct26, from the MIGHTI port): the cause of death is weighted by hazard only when every claim passes `hazard=`, but the agents engine's declared deaths (`solvers/agents.py` `request_death(uids, cause=mod)`) and `ss.Deaths` pass none, so a model mixing them can't use it; pass `hazard=tr.hazard(uids)` and the death rate there. And `Transition.hazard(uids)` is per unit of the sim's time, which can be inferred (months with STIsim's HIV) whatever `dt` is: add `hazard(uids, unit='year')`. S each.
- From the Typhoidsim port (oct26): In `ss.attr(init=...)` a bare state is the share of everyone in it, not whether this person is in it, so a starting value for one state only (Typhoid's prior infections for the susceptible, not the unexposed) can't be declared (TY3's hack stays); `as_cond(S)*dist` raises. S each.
- `ss.perday(1)*ss.by(patch=fn)` raises `IncompatibleTypes` (malariasim's port, MS2); a function returning a typed rate works. S.
- A self-loop's dwell under a step is at least one step as agents (FP86); compartmental solvers take the mean as given, so they move people several times faster (39% at the first monthly step, against 10%). Fix: floor such a dwell's mean at `dt` in the compartmental layout. S.
- A state that is a source both by contact and by a rate is dated −1 at the start, as for the rate, so its network part still loses the first step (plan_b HP62, question 2). Rare.
- Events recorded at a step's end (an `ss.Pregnancy` delivery cascading from a contact infection in the same step) are read by `since` from the step's start, a step early, now that stamps read as the step's index (plan_b A2); exact would need the phase per agent for `ti_<event>`. No test or benchmark has one. S.
- The chain binomial for raced dwell times (ST61): each compartment of the race moves one stage per substep, 4% long on the time in the state at a quarter-day step (0.7% at a twentieth); a pass through several stages of each clock in a substep, as `draw_chain()` does for one, would make it exact. S–M.
- A fixed delay (`ss.constant`) or a minimum stay racing another non-exponential dwell out of one state, under compartmental solvers: a conveyor for each stage of the other clock (plan_b ST61 refuses it clearly). M.
- Race dwell times whose `where=` overlap in some groups, rather than refusing them (`apart_fracs()` decides apart-or-not at run time). S.
- FPsim's port: infant deaths −4.4% (z −2.0) and TFR +5.6% (z 2.6) against v3, not diagnosed (FP91's cause, traits drawn before a coded caller sets ages, doesn't reach the declared pregnancy). S to bisect.
- Strain memory scaled by an attribute (`scale='nab'`, Covasim's waning antibodies), clocks as Erlang stages (Covasim's viral load), and `ss.attr` of an expression with distributions in it or of `ss.by` of distributions stay agents only under compartmental solvers (plan_b C7 takes only a plain distribution at its mean). M each.
- A module given to a sim is copied, so `mod.pars.x = ...` (or any change) on the original afterwards is silently lost; `_given` (FP80) marks the originals, so `Pars.__setitem__` could raise for a module so marked (the `Pars` would need to know its module). S.
- `ss.transfer()` on a dimension with `where=`: use `sim.groups.level()` and skip people outside it (found with CB-6; not reproduced). S.
- FP22: a function returning a bare time is read in the sim's unit, with a note shown only at `verbose>=1`, so it is silent by default. S.
- FP2: `finish_step()` results never reach `update_results()` (100 per step where 105 is expected); documented in the skill and listed by the script. S.

**Features**
- Maps between groups as sparse matrices (or Kronecker factors) in the program and the three kernels, so a crossing with tens of thousands of groups fits in memory (FP84: 25.7 GB dense). M; refused clearly above 2 GB meanwhile.
- TR5: `per='strain'` composed with a change of the strains left (acquiring resistance at the end of treatment) and with a split of the first destination (cured → treated or relapsing); tbsim writes 16 set-to-set transitions for two drugs. Would take a per-strain outcome table on the transition (e.g. `p=dict(cured='cure', RIF='q_rif')`) built into `prepare()`'s matrix. M.
- TR6: several strain moves on one transition, and a move with chances (`move={DS >> RIF: q, DS >> INH: q2}`), folded into one level-to-level matrix as `prepare()` does. S–M.
- TR7: `ss.par` and values above 1 in `ss.immunity` matrices (fitting `rr_superinfection`): immunity values as expressions evaluated each step in `level_protection()` and under the ODE. M. Meanwhile superinfection at a fitted rate is written `L >> L @ rr*foi`.
- TR8: values for carrying several strains (tbsim's `rr_prog_super`, `rr_clear_super`, `p_multi`), today `ss.by(strain=[f(s) for s in strains.sets])`: a function of the set in `ss.Strains` values. S.
- TR9: unordered sets when a full set never takes another strain (tbsim: 16 levels instead of 10 for two drugs); needs a rule for a full set other than D4's "replace the oldest" (plan_b B5).
- TR10 (cure by strain; [oct25/backlog/ports.md](oct25/backlog/ports.md)): values of `ss.Strains` that are expressions of parameters (`cure=[1, delta*phi]`), so a strain's chance of cure follows a fitted `delta` or `phi`; today they are fixed numbers, and tbsim computes them from the starting values. Would take the strains' values evaluated with the parameters, as the rates are, wherever `prepare()` builds its level-to-level matrices (every solver). M; with TR7, which needs the same for `ss.immunity`. (2026-10-24)
- Selecting groups of a result by strain (tbsim's "every level that carries a resistant strain"): `select(resistance=...)` takes labels only, so tbsim builds the list from `strains.sets`. A selector by strain (e.g. `select(resistance=strains.carrying('RIF'))`, returning the labels) would do. S; waits for a second model that needs it. (2026-10-24)
- HP52: `disease=[a, b]` over diseases written as code is refused clearly; supporting it needs the product keyed by coded diseases (`Product.key_by()` keys declared states), since one delivery per disease reaches different people. S–M.
- Values by network as an object that scales (`ss.by(network=dict(...))*0.9`), for per-genotype betas built outside the rate (`{net: beta}*0.9` is a plain dict; inside a rate, `0.9*{net: beta}*I` already works). S.
- CB-4: one-step partnerships on `ss.SexualNet` reach 40% of their target (`TargetStatsNotRecovered` warns), and there is no other spelling for one-off contacts (STIsim's sex work: FSWs have 0.72 sex-work partners against 5.06). S–M.
- CB-7: no age preference on `ss.SexualNet` (STIsim's age gap: 0.4 ± 21.7 years against 7.8 ± 3.0). S–M.
- CB-11: BV's ODE (192 groups, 1,152 compartments) takes 23 s compiled, 10× its 20,000 agents. Measure where the time goes. S.
- *Done (2026-10-26, HP74: with several scenarios, `quantiles()` puts the quantile first, so the unpacking gives a result for each quantile with a row per scenario; Starsim 3's pooled band is `np.quantile(res, q, axis=0)`).* FP12: `msim.reduce()`'s replacement, `low, mid, high = res.quantiles([...])`, raises `too many values to unpack` when the scenarios are labeled, since `quantiles()` is per scenario; Starsim 3's pooled band has no spelling. S.
- FP23: with no declared module, every `ss.change`/`ss.modify` crashes with `min() iterable argument is empty` (in `sc.suggest` with an empty list); a coded module's parameters can't be modified. S for the crash.
- FP28: no delivery to a mix (`Delivery(move=dict(to=[...], p=...))` gives a misleading `MissingDimension`), and no modifier over several transitions (`ss.modify([tr1, tr2])` is refused clearly). S–M.
- FP32: an `ss.par` in a modifier amount can be changed only when it is also given to a module by keyword; by object it says "does not belong to a module yet", by name "not found". S.
- FP39's rest: `ever='True -> False'` (`'"True" is not a level'`; arrow strings are gone, so recheck); `Delivery` without `disease=` beside a second declared module (`UnknownDisease`, about 3 lines); `diff()` and `msim.results` silently drop results the baseline lacks. S.
- FP64 (spec drift): a `p=` row over `to` must name every destination (`BadBranchSplit`), where spec 03 says an unnamed one gets 0, and a table naming a label that isn't a destination raises `UnknownState`, so one availability table can't be shared by rows with different destinations. Follow the spec. S.
- FP67: `to_df(resample=...)` no longer puts the times in the index, and `use_years=True` is ignored; the script doesn't flag it (the skill has the row), so FPsim's `Experiment` crashes far away with `IndexError`. S.
- FP70: `ss.Delivery(move=)` took only an arrow string of names; with arrow strings gone and FP90 fixed (plan_b D2), `move=m.none >> m.pill` should work: recheck. S.
- FP72: `ss.choice(a=ss.weeks(array)).pars.a` loses its unit after `init()`; a note on an empty age group is printed only when the sim, not the module, is stratified. S.

## From the October 22 constitution and skeptic reviews

Tradeoffs from the October 22 Claude assessment (T1–T13) and skeptic review (their status as of 2026-10-25 is in [oct25/backlog/oct22_reviews.md](oct25/backlog/oct22_reviews.md)), set aside for Cliff. The obvious items were built.

- *Done (2026-10-27, by deletion: oct27/plan.md phase 1).* **T2 (EX5)**: move `ss.jit`'s method/class rewriter (about 1,980 lines, 0–5% measured gain) out of core; keep the function compiler. Partly reopens jax_plan decision 3. S.
- **T3 (DL4 step 2)**: merge `Expr` and `Cond` into one tree, evaluator and printer. S6.4's precondition is met. L; hot-path risk.
- *Done (2026-10-22; Cliff: a small port edit is fine; the edits are in oct25/backlog/ports.md).* **T4 (DS1/DL5)**: Pregnancy's Starsim 3 callbacks as wrappers over `add_callback` (or drop them: one line in FPsim's port). Timing within the step to check. S.
- *Done (2026-10-27: oct27/plan.md phase 1).* **T5 (DL6)**: remove `ss.Strains(cross_immunity=)` and `covasim_matrix()` (Covasim's port uses `ss.immunity`). Reopens the "three layouts" decision. S.
- *Done (2026-10-22; Cliff: a small port edit is fine; the edits are in oct25/backlog/ports.md).* **T6 (DS5)**: move `parse_age_range`/`apply_age_range` (118 lines, STIsim only) into STIsim. S.
- **T7 (DL8)**: remove Pregnancy's burn-in (about 150 lines of solver machinery); start the sim one gestation earlier. Reopens S7.1. S.
- *Done (2026-10-22; Cliff: a small port edit is fine; the edits are in oct25/backlog/ports.md).* **T8 (ML2)**: replace `ss.PregnancyPars` with `define_pars()` in `pregnancy.py` and refuse the four unread keys (`slot_scale`, `min_slots`, `p_survive_maternal_death`, `trimesters`). Refusing them alone breaks STIsim (it passes every key of the container to `ss.Pregnancy`), and removing them from the container breaks FPsim (`FPPars` reads `trimesters`), so it needs one edit in each. S.
- **T9 (SV8, ML4)**: one meaning of a life table, then one implementation of births and deaths. Reopens the LASER choice. L.
- **T10 (ML5)**: Covasim's dynamic rescaling via per-agent weights. Perf risk. M–L.
- **T11 (DL9, ML10, PT5)**: Strains values as `adjust=`/`chances=`; a unit-free default `beta`; keep note categories (or drop the note classes). S each.
- **T12 (EX9)**: reword the constitution's startup "opt-in" to match `compile='auto'` (Cliff's decision, review_synthesis.md:192), or 12-backends.md:270 only. S.
- **T13**: "don't break downstream" is now run, in `test_downstream.yml` and `test_r.yml`, on each model's `v4` branch, and MIGHTI and Typhoidsim pass with local fixes not yet pushed (oct30). Rotasim still needs a `v4` branch and a matrix entry, and anything left only in `v4port/` folders needs moving to a `v4` branch. Work in other repos.
- **EX3's threshold**: counting the JAX handler's 20 s cold compile in `'auto'` would make a seeded run's draws depend on Numba's cache; a fixed threshold sends 0.5–20 s runs to the slower kernels. Left as is; `compile='never'` now sends them to the kernels.
- **SV1's remainder**: the dwell-modifier refusal stays predictive, since `sim.modifiers` is filled after the solver is chosen (reordering `sim.init()` would let the trial layout cover it); the trial layout also runs under `solver='abm'` (about 1 ms a module) so `describe()`'s "auto would choose" stays right.
- **`tr.since` against `X.time`**: a transition's `.since` reads one step longer than `preg.live_birth.since` or `X.time` (start-of-step stamp against step index; the A1 step-clock question). Changing either moves results by a step.
- **CRN for declared diseases as agents**: `crn=False` pairs scenarios about as well as CRN (paired variance 3,354 against 3,076, independent 17,726), so 18-conformance §10's "CRN beats crn=False for small changes" isn't shown for them (`worklogs_c/tests.md`).
- *Done (2026-11-09, #1575: when the density is built, every number taken as linear in the fitted parameters is checked against the sim at the corners and the middle of the bounds, and a model where it isn't linear is refused; `sim.log_density()` builds the same density).* **`sim.log_density()` self-check**: unlike NUTS, it never checks itself against a run of the sim, so a linearity bug like the seasonal one (fixed) can recur silently; a one-off check at a point away from the initial values costs two runs when the `LogDensity` is built. And a fitted parameter inside a time-varying `ss.by` is still expanded linearly (needs `ss.by.per_group()` in JAX). S each.
- **A shared object is one parameter** (skeptic fix 1): a reused `ss.days(7)` or `ss.peryear(0.3)` object in two transitions is now one parameter (`dur`, `beta`) rather than one per transition, so the names of parameters change for such models; no test relied on them, but a port that refers to an auto-named parameter of a shared object would need the new name.
- **IF2's fast SBC check** runs at `rtol=0.1` (no smaller size held 5% at every seed); the 5% check is marked `slow` (`pytest -m slow`).
- **From the skeptic review** (October 22): a reference RHS and integrator with a conformance page (M–L); complete exports to NumPy/deSolve/odin tested in CI (L; may reopen "export beyond Mermaid, SBML, and odin"); `'auto'` moving an ODE to agents as a warning, fixed `result.strata` shapes, and an explicit opt-in for the partner-change ODE (S each); the partner-change ODE's weights on the hazard, not the chance per partnership (S; a solver choice: the spec states the current form, `06-routes.md:202`); JAX and fitting as optional extras and `interop/` as a satellite or with its own owners (reopen two Won't do entries); `ARCHITECTURE.md`, the dated design history archived, `MAINTAINERS`/`CITATION.cff`, and a v4 README (the README conflicts with "no docs until final" only in that one file); the constitution's correctness wording.

## Extending a model with code (2026-10-25)

- *The narrower form is done (2026-11-09, #1588): `ss.Transition(..., noise=sigma)`, gamma white noise on a rate drawn each step by the chain binomial inside the kernels, so He et al. (2010) runs declared through the built-in particle filter (−5,817 ± 12 against pomp's −5,827). The general form below still waits.* **Compiled user code inside the kernels.** A rate (or a flow) given as an `@ss.jit` function of the counts, the time and a uniform, called by the kernels inside each run or particle. It is what a declared model with code of its own (He et al. 2010's noise on infection, tutorial G8) would need to run in one call to the kernels and through the built-in particle filter, IF2 and particle MCMC. It would take a new kind of entry in the kernels' program, a compile for each model, and a fallback from JAX's chain binomial (which couldn't call it), and the rate would be one that `describe()` can't show. It waits because the same model already runs and is filtered as a module written as code (the tutorial `docs/tutorials/g8_extending.qmd` matches pomp), and the gain (speed, the built-in fit) has not been measured against a need. Epirecipes (2026-11-05, `epirecipes/SUMMARY.md` item 3) adds two more such models, both noise on β written as a coded connector: `seasonalsirs` (pomp's gamma white noise) and `semiparametric` (a Brownian β); each runs, but step by step in Python, in seconds instead of milliseconds. A declared noise multiplier drawn per step in the kernels would be the narrower form of this item; it was judged not worth a new concept for two recipes.

## From the October 25 backlog

Set aside on 2026-10-25 from Cliff's review of [oct25/backlog.md](oct25/backlog.md), which has the evidence for each; the rest of that backlog is in [oct25/plan.md](oct25/plan.md).

- **A protocol for a distribution.** The moments and capabilities of a dwell time are found from its class name in several places (`transitions.dwell_stats()`, `solvers/choose.py`, `solvers/backends/jit.py`'s `dist_kinds`, `spec.py`), so a user's own distribution runs everywhere but with sampled moments (a noisy number of stages). A `moments()` method on `ss.Dist` would replace the name checks. M; waits for a second user distribution.
- *Done (2026-11-09, #1579; Cliff: "it's come up multiple times"): pMCMC keeps a run traced back through the filter with each draw and ABC-SMC the accepted run (`fit.trajectories`), and `fit.project()` continues them (`restart=True` for runs from the start). Still to do: trajectories for a best fit of a stochastic sim (a filter at the best values: `Fit` keeps neither the likelihood nor the problem), and `fit.plot()`'s band from the kept runs.* **Filtered trajectories, and projecting from the filter's state** (odin, SimInf). pMCMC keeps no trajectories, and `fit.project()` restarts at t = 0, so a stochastic forecast isn't conditioned on the realization the data pinned down. It would take keeping one particle per accepted draw (`ss.solvers.Particles` holds the stocks), an engine entry point to start a run at step k from given counts, and one argument on `project()`. M; waits for a forecasting user.
- **Movement bans by place status** (EMULSION, SimInf, MEmilio): `ss.modify` on an `ss.geo.Mobility`, with `where=` evaluated by origin place. This is B9 in [19-open-questions.md](../design/19-open-questions.md); the new reason is that standstills are the main veterinary control. M.
- **Commuting that depends on state** (`rel_away={ISy: 0.5}`, MEmilio): it touches mixing, the agents' place draw, the kernels, and JAX, and explains most of an 11% difference per county against MEmilio. M.
- **Several `Mobility`s on one dimension** (a seeding register plus commuting, as in flepiMoP). A list is refused clearly; accepting one is M.
- **Larger docs proposal**: plain wording for non-infectious models (`mechanics_nonstandard.qmd`). M.
- **Untraced differences from Starsim 3** that plan_b I2 meant to record: Covasim's `test_nabs` and `test_vaccine_target_eff` (vaccine efficacy 0.77 against 0.95); STIsim's HIV-6 (ART 17 below target) and #35 (CD4 < 50 on ART, 0.011 against 0.034); HPVsim's network age-gap SD (6.31 against 6.13, z ≈ 5); and HP58, a spurious date warning per HPVsim sim. S each to bisect, at the next `/v4.review` of each model.
- **A fit reported as converged at a local minimum.** In the summer2 reviewer's script, Nelder-Mead and L-BFGS-B both stopped at a mismatch of 0.054 (0 at the truth) with `converged=True`. Low confidence (the script is gone); recheck when fitting is next revisited. S.

## Modeling paradigms (2026-10-23)

Looked at whether v4 should support modeling paradigms beyond the ODE, chain-binomial, Gillespie, tau-leaping, and agent solvers. Following [paradigm-conversion.md](../best-practices/paradigm-conversion.md), each is treated as a point on an axis of the same declared model, not as a separate kind of model.

- *Done on 2026-10-28, as `ss.Sim(branching=True)` under every solver rather than a solver of its own ([oct28/branching.md](oct28/branching.md); spec 12, "Branching processes"); kept here as history.* **Branching processes (`solver='branching'`).** A branching process is the declared model with susceptibles never depleted (transmission linearized at the disease-free state): a new axis, depletion on or off, rather than new model text. What it would give that the current solvers don't: a cost that scales with the number infected rather than the population (people are created only when infected, so "the chance that one import into a city of 10 million takes off" can be run 100,000 times cheaply); chain-level outputs (chain size and length distributions, who infected whom by generation) for importation, elimination, and contact-tracing questions (Hellewell et al. 2020); and overdispersed offspring from heterogeneity agents already have (a gamma-distributed `rel_trans` gives negative binomial offspring). What it would take: agents created on infection with no population behind them; an expression that reads `S.n` held at its disease-free value, with a note, like the other standard approximations ([12-backends.md](../design/12-backends.md#the-standard-approximations)); `'auto'` never choosing it. It also covers much of "different solvers for different transitions in one run" ([19-open-questions.md](../design/19-open-questions.md)), whose usual reason is the stochastic tail of a disease near elimination. M. Proposed on 2026-10-23; not yet started. Left out of the first version: as agents, contact networks (whose branching form draws offspring from the excess degree distribution, a configuration-model branching process), strains, and several declared modules, each a `CapabilityError` until a model needs it.
- **The probability of a major outbreak (`dis.p_outbreak()` or `extinction_prob()`).** The fixed point of the multi-type offspring generating function, built on the next-generation matrix that `R0()` already computes (`starsim/diseases.py`, `engine.next_generation()`), with the offspring distribution each infected compartment implies (geometric for an exponential infectious period, more concentrated for Erlang stages). It would also give `ss.errors.ExtinctionPossible` a number to report. S; independent of the branching solver, and the cheaper of the two.
- **Seeds in a state with a minimum stay beside a timed exit, as compartments** (Epirecipes round 2, 2026-11-05). People put in a state at the start (`init=dict(E=1.0)`) whose exit has `after=` beside a fixed or whole-step dwell face that exit for one step more under the chain binomial (and 1.5 under the ODE) than agents do: with a 3-day delay and a death after 2 days, the binomial's death acts in steps 1–3, agents' in step 2–3 only. People who arrive by a flow agree under all three. Starting seeds one slot along (`Compartmental.arrive(before=True)`) does not change it; the stay's slots and the dwell's release would need a seed's own offset. S–M.
- **Analysis helpers beside `R0()`: the deterministic final size, equilibria and the Jacobian, the exact final-size distribution, and genealogies** (Epirecipes, 2026-11-05, `epirecipes/SUMMARY.md` item 7). Bjørnstad chapters 2 and 5, Black & Ross 2015 and the coalescent recipe each compute these in a few lines of NumPy from a declared model's run or parameters. They would fit next to `R0()` and `equations()`, and the deterministic final size (the root of the final-size equation from the next-generation matrix) is the cheapest and most used, but nothing in v4 needs them yet. S–M each.
- *Done on 2026-10-28: `solver='ide'` (each dwell time as a conveyor of whole steps at the distribution's own chance of leaving), which under `branching=True` is the renewal equation; Cliff took it out of deferred with the branching processes. Estimating Rt is a fit of `beta` as an `ss.timeseries`; a worked example of it is still to come with the docs.* **Renewal-equation models (EpiEstim, EpiNow2, epidemia), as the `'ide'` solver.** The mean-field counterpart of a branching process, and probably the most-used model type in outbreak analytics that v4 has no answer for. It is the same machinery as the deferred integro-differential solver ([12-backends.md](../design/12-backends.md); [19-open-questions.md](../design/19-open-questions.md)): an SEIR with arbitrary dwell times is a renewal equation with a particular generation-interval kernel. Framing `'ide'` as renewal would also give estimating Rt a natural home in fitting ([13-inference.md](../design/13-inference.md)). L, and it widens v4's scope, so it waits for Cliff's decision on whether it comes out of deferred.
- **Examples of paradigms v4 already covers.** A model with no transmission is a cohort Markov model under `'binomial'` or `'ode'` and a microsimulation under `'abm'` (the health-economics and TLO audience, who won't otherwise see that it is covered); TSIR is `'binomial'` with `dt` equal to the generation interval. One sentence each in the spec and an example each in the corpus; no new code. S; waits with the rest of the docs until the implementation is final.

## From the October 27 plans

- **Owned random streams** (oct27/architecture_simplicity_review.md §2; Cliff skeptical): a distribution reachable under two attribute names takes the name the search finds first, so adding an alias can change its seed. If it matters, name a distribution when it is first set as a module's attribute (later aliases then don't change it), rather than a registry. S.
- **A compartmental form for an escape** (oct27/plan.md): with `ss.Transition(before=)` removed, `rate*(X.time < d)` runs as agents only. S–M.
- **A whole JAX ODE run of births with a fixed delay** (oct27 review, `review/solvers.md`): it differs from the kernels (the a7 `births` model: `M0` by up to 38 in about 668, `NP` ends at 1,673 against 1,773), already so at `acef3c7b`. Find which step of the delay or the births the JAX run takes differently. S–M.

## From the Atomica speed work (October 27)

- **Parts shared between rates for the exact solver and for diseases tracked jointly.** The kernels' program works out the parts several rates share once, first (Optima TB India: 13,696 operations to 3,492, its run 0.49 s to 0.28 s), except for the exact solver, whose by-place paths work out a few rates at a time, and for diseases tracked jointly, whose program is joined rate by rate; both keep the sharing within each rate.
- **A transfer whose rate varies in time, in the JAX engines and in diseases tracked jointly.** A whole run in the kernels takes it from the program's values at each step (`ss.kernels.set_movers()`); the JAX ODE and chain binomial, and a crossing, hold a mover's rate for the run, so they refuse it (or, a crossing, step it).
- **Reading Atomica's workbooks.** Optima TB India takes 10.7 s to read and write as code, against about 6 s for Atomica to read it; done once with `ss.converters.convert()`, so it waits.
- **The states a transfer moves, written once.** Each of Optima TB's transfers lists the ~110 states it moves, so its `stratify()` is one line of 14,565 characters; a variable for the list would need `ss.converters.Source` to hold code of its own.

## From the October 31 constitution pass

- **`ss.on(d.I.n > 1000)`** raises `TypeError`, though `init_triggers()`'s comment says counts work.
- **Minimum stays (`after=`):** other exits skip the last slot under every solver; not checked for bias. Under the chain binomial, a cohort leaves half a step later than as agents.
- **`ss.Result(..., label=)`** given to a declared disease loses its label.
- **`describe()` of a coded sim with no unit** prints "every 0.00273973 steps".
- **The capability reason from `solvers/mixing.py:165`** reads badly in the header.
- **`test_twins`** compares one call of each kernel on one shape; `rust_twin.py` remains the fuller check.
- **`ss.compile()` dumped core at exit once,** while sources were being edited; not reproduced.
- **A wheel has no stale check,** since it has no source beside the library.
- **`ss.options.reticulate`** can go once R can index by `ss.uids`: it needs `convert=FALSE` in rstarsim, or `Arr` accepting plain arrays.
- **A declared disease on a network overriding `net_beta()`** ignores it silently; should it raise?
- **Unusable parameter values on a declared disease** (`dur_inf=dict(...)`) fail late, with a raw `TypeError`; one check when the graph is built.
- **Other Starsim 3 guards** remain: `infect(self)` in `__init_subclass__()`, `_infection_methods`, and the text in `check_betas()`.
- **`ss.Births` and `ss.Deaths` as thin constructors** (decision 8) aren't reachable yet: they need `parent` and mothers' ages on declared births, a declared rate by year, sex, and age, the `cbr` and `cmr` results, and a replacement for the `make_p_death()` and `get_births()` hooks that seven downstream classes override. A declared per-agent death rate from the UN table ended 3.5% below `ss.Deaths` in one run; rechecked on 2026-10-31 with `ss.Deaths`' own table and period lookup, it is within 0.3% (the original setup is lost).
- **A matrix of distributions in `ss.MixingPools`** still makes a pool for each pair of groups, each with a state, so a newborn takes a draw for each pair (#738: about 2× slower to run than a matrix of numbers at 16 groups with births, and 6× slower to build). A matrix of numbers no longer does. If someone needs Poisson contacts at 16 groups, draw one value per person per destination group, not per pair.
- **`ss.MixingPools`** stays: `rsvsim.RSVMixingPools` subclasses it, and typhoid, rotasim, tb_acf, and jhu-collab use it.
- **Stepped runs under 15 µs:** 24 µs now. A prepared kernel handle might save about 4, and compiled result expressions about 3. Spans would help only modules on coarser timelines.
- **`settled()`** doesn't notice a change inside a coded object held by identity, or to a modifier's `start=`, `stop=`, or `where=` during a run (its amount and new modifiers are noticed).
- **`loop.insert`** anchored on an empty `step()`, which is no longer in the loop, is untested.
- **Gillespie and tau-leaping** hold time-varying values for a whole step, which strengthens the thinning item: about 3% too many infections at `dt=91` days with an annual cycle.
- **Modifiers with a time-only `when=`** could be tabulated for whole runs, now that `when=` can't read the sim.
- **`ss.modify` in the class body of a coded disease** is an error for now.
- **Conditions on unnamed states in a class body** (`I & x` with `ss.states(3)`) get no name.
- **A coded `stratify=` with `ss.contacts(dims=)`** raises "nothing would use it" unless a result has `groups=True`.
- **`flatten_rates()`** is still about 190 lines of closures; temporary state in `choose_solver()` is held on the sim and deleted.
- **Threaded compartmental ensembles:** `run_many()` is serial, so parallelism comes only from `ss.parallel()` workers. `advance_threads()` shows the way. Forked workers gave 3.7× on 4 cores.
- **`frac()` by BLAS** applies only when every pair has an entry; JAX's rule was an eighth of the entries. Gravity with a cutoff still loops. Measure before widening.
- **Intel Macs are not supported** (Cliff, 2026-10-31): no wheel is built, and Numba's last wheels for them are 0.62 (Python 3.13 at most), so `pip` tries to compile LLVM's bindings. They could return with a `numba<0.63` marker on that platform.
- **People in a fixed delay at the start** leave half a step late under the stochastic solvers (see decision 8 of `oct31/plan.md`).
- **Peaks with a fixed delay as the only exit** (October 31): the chain binomial's default takes whole steps, so the peak is about 15% low (22% at `dt=2`); `substeps=3` gives 5 to 8%. Counting delays in the default fixed this but cost up to 8.7× on long runs, so it was removed.
- **Building a sim is most of a short run.** About 7 ms plus 2 µs a step: a Python object for each time point in `tvec` (`DateArray`, through `sc.toarray()`), the searches for distributions and time parameters (`sc.search` in `dists.init()` and `link_timepars()`), and copying parameters. A lazy `tvec` would fix the part that grows with steps; it touches every reader of `tvec`.
- **Declared agents at small sizes:** about 170 µs a step more than coded (SIR at 5,000 agents is 1.27×, at 50,000 about equal), from the bookkeeping of moves. Not ablated.
- **Numba at one group:** 33 to 41× hand-written, about 5× Rust's time, with no fix short of a second compile of `step()`.
- **`fading_late` in `ssl.Syphilis`** uses `Ll.time`, which restarts at 0, so people moving to `Ll` jump back to full infectiousness, coded and declared alike. The docstring and STIsim count from the start of latency.
- **Denormals:** a long SIR leaves `I` at the smallest denormal, about 10× slower per step, in hand-written code too; only runs far past the end of an epidemic.
- **Mixing by indirect indexing** is the largest single kernel cost left at 100 groups (about 20%), if the 2.8× it would give is ever worth a program field.
- **From the HPVsim port onto strains with courses of their own** (see `family_tradeoffs.md`): `internals/fitting.py:1343` names a shared beta `hpv16.beta`, and `graph.py:240` blocks an `infected` property.
- **`inspect.signature()` of a disease class** gives `(*args, **kwargs)`, from the `BuiltAfterInit` metaclass (`diseases.py:22`). STIsim reads `cls.__init__` instead.
- **`ss.diff_sims(die=True)`** raises on different values, not on keys that differ (`debugtools.py:457`), so a baseline test passes with stale keys.
- **A declared HPV genotype** costs about 2.5 ms a step against 0.75 ms coded, so the HPVsim port is about 1.5× a coded model with the same network: flat Python in `send()`, `fire()`, `enter()`, and `schedule()`, and `Arr.grow()` over about twice the arrays. M.
- **`BoolState.time`** is O(N) per read; give it a form for some agents. S.
- **`docs/design/12-backends.md`** still describes the chain binomial in JAX.

## From the Pandemic Simulator review (2026-11-02)

The reasons are in [the review](../approaches/pandemic_simulator.md).

- **`ss.modify(until=)` in the kernels.** A condition on a count now stays in the kernels as part of the rate, and dated measures are tabulated where they change (2 November), which took reruns from about 50 ms to 1.5 ms (Ebola) and 0.4 ms (`sir_reactive`), against the originals' 6 and 4.5 ms. `until=` and `hold=` still run a step at a time, at about 50 ms a year, since they need a memory of whether the measure is on; the kernels would need a status for each.
- **An exporter to the Pandemic Simulator's model directories.** Worth having only if its reviewers want generated models, so ask them first.
- **An importer for the Pandemic Simulator**, in three tiers, about 300 to 400 lines. Set aside for now: 21 of the 22 models run, but a traced `equation()` gives a flow's dependencies and not its formula, so most would come out incomplete. `references/pandemic_simulator.md` in the `convert-model` skill covers them by hand.
- **Inputs as JSON Schema** for forms, MCP tools, and validated configs: one function over `ss.par`. Waits for something that needs it.
- **A `metadata=` keyword for model cards**, if a form or registry needs one; the `convert-model` skill asks for the docstring section.
- **`wuhan_seir_model`**, if contact matrices need a test from a published paper; Ebola covered the harder structure.

## From the epidemics, epiworld, epipack, and epymorph models (2026-11-03)

Found while translating two models from each; the details are in each model's Findings. Each item waits for Cliff to say whether it should be fixed in v4.

- *Done (2026-11-04, in `ssv4`, uncommitted: `Compartmental.tables()` works out each span between the steps where modifiers switch at once, and keeps a whole run's tables with modifiers too, keyed on their values; 6 modifiers rerun in 1.3 ms, as with none. Also fixed: `ss.par(0.3*ss.seasonal(...))` was taken as constant in a whole run. The model's README and timings are stale until the benchmark is rerun).* **Any modifier makes a compartmental rerun about 30 times slower** (1.1 ms to about 31 ms on `epidemics/seasonal_scenarios`, even for a constant modifier): `Compartmental.tables()` rebuilds the per-step rate tables in Python on every run, though `kernel_reason()` is None. This is why that model misses its time target.
- **`ss.modify(mixing, multiply=ss.by(age=...))` crashes compartmental runs** with a broadcast error in `set_contacts()` (a scalar works), and as agents it acts only on the side of the people who would be infected. A pair of `rel_sus`/`rel_trans` modifiers is the exact workaround (`epidemics/seasonal_scenarios`).
- **Effects that add.** epidemics adds overlapping contact cuts; v4 modifiers multiply, and `add=` is refused on `rel_sus`/`rel_trans`.
- **`ss.Delivery(move=S >> V, capacity=ss.perday(n))` runs outside the kernels** (147 ms a run on `epidemics/seasonal_scenarios`), and `V` has to be passed to `ss.Disease` by hand when no transition reaches it.
- **Agents can't take a discrete-time step.** As agents, a step's other transitions run before transmission, so someone who becomes infectious has had half a step's chance of recovering first; no setting makes agents work out a step's chances at its start. On `epiworld/seirconn` agents die out in 46% of runs against 21%, so both epiworld models run as a chain binomial. The "Solvers and backends" page in ssv4 says agents do work them out at the start, which is wrong either way.
- **Strain values only as multipliers of rates**, so flu's daily recovery of 0.5 against COVID's 0.25 is `np.log(0.5)/np.log(0.75)`; and **a vaccine can't enhance recovery** as 1 − (1 − p)(1 − e) (`epiworld/covid_flu`, written as a second `I → R` transition).
- **`sim.plot()` raises `ValueError`** for a sim with a delivery at a fixed time plus an `ss.modify` with `start=` and `stop=`: `ss.results.intervention_marks()` compares an `np.float64` with a tuple (`mark not in out`). A bug; reproduces in a 6-line SIR.
- **A flow driven by its own source state is misread.** By the documented rule `I >> X @ c*I` is `c` times the share of contacts in `I`, but it is taken as the whole flow, `c` per person, with only a line in `describe()`. `ss.import_from()` has the same bug for epipack's `I + I -> I + X`, giving 9,787 in `S` against 2,942 (`epipack/siarx_tracing`).
- **Contact tracing has no compartmental form**: `trace=` raises `CapabilityError` under `solver='ode'`, even on a mixing pool, where the mean-field form `kappa*p*k0*I*I/N` would do. And a mixing pool has no contacts to trace, so the well-mixed run needs a `RandomNet` redrawn every step.
- *Done (2026-11-04, in `ssv4`, uncommitted: an index of each agent's edges, built once a network's edges stay the same between steps, so transmission and tracing visit only the edges of the smaller side; `ssl.StaticNet` makes its random graph without NetworkX; and `solver='abm-events'` (spec 12) takes agents event by event within each step. siarx_tracing reruns in 0.57 s by steps and 0.48 s by events, against epipack's 0.52 s. READMEs and timings are stale until the benchmark is rerun).* **Agents on a dense network are slow** (`epipack/siarx_tracing` reruns at 2.7 s against 0.53 s): one pass over all 250,000 edges per transmission per step, a `restart()` rebuilds the network, and fixed steps need `dt=0.025` to match continuous time; an event-by-event solver for agents on a network would avoid the last.
- **`init_n` can't be changed by `sim.new()` or `pars=`**, since it isn't a parameter.
- **`ss.parallel` uses all cores** (`workers=` by default one per CPU) despite `SCIRIS_NUM_THREADS=1`.
- **No fixed-step leap** of given lengths, so epymorph's own stepping (thirds of a day) can't be reproduced; and the docstring of `converters/epymorph.py` wrongly says `solver='tau'` "leaps in the same way".
- **The epymorph importer flattens parameters that vary by time and place** (Pei's humidity at each state's mean), so its runs peak 7 days late; `ss.by(patch=[ss.timeseries(...)])` would carry them. Smaller: commuting has no time of day, and `ss.geo` has no exponential distance kernel.
- **`beta` can't be given by R0**, though R0 is reported.
- *Done (2026-11-04: `install()` installs `local` with the environment's own `Rscript`, and the epidemics README no longer has the step by hand).* **`lingua_franca envs` ignores `local` for R packages**, so the epidemics package (not on CRAN) was installed by hand; the fix is to run `install.packages("src", lib="lib", repos=NULL, type="source")` in the R branch when `local` is set, and then drop the by-hand step from `docs/models/epidemics/README.md`.

## From the JSON form (2026-11-03)

- **The rest of a declared model in the JSON form** (`ss.export_to(sim, 'json')`, spec 14). Listed by `LossyExport` and not yet written: outcomes (`ss.outcome`, with their delays and likelihoods), modifiers (`ss.modify()`, which need conditions written out; Syphilis in the library has two), functions and attributes, strains (`ss.Strains`), transfers, `where=`, `move=`, and `increment=` on a transition, the sim's own stratification (`sim.stratify()`), named populations and geographies (`ss.people('Kenya')`, `ss.geo.geography()`; a stratification by places is written as their names, without their movement), and values by group given as an array or a table. Each is a few lines in `starsim/converters/starsim_json.py` (a field in the writer, and its check in the reader); add each when a model that uses it needs to be moved.
- **Whether `sim.to_json()` should give the JSON form.** It still writes Starsim 3's record of the results and parameters, which cannot be read back; the model's form is `ss.export_to(sim, 'json')`. Ask Cliff before changing either.

## From the tb_LAI_TPT port (2026-11-05)

The port's notes are `tb_LAI_TPT/v4-ideas.md`; the evaluation is `ssv4/working/tblai-plan.md` (item numbers below are its own).

- **Per-strain product moves, applied once at the dose** (item 11). A TPT that clears each susceptible strain with chance `efficacy` at the dose, keeping resistant ones, can't be written: `ss.Treatment(moves=...)` acts on whole agents, an undriven per-strain transition with `p=` is refused, and a per-strain transition triggered by the delivery can't take the strain adjustment. The port uses a clearance rate while the product is held instead, and fast rates over a few steps carry a discretization bias: 0.85 cleared over a 3-month hold at monthly steps where 0.70 was intended. Ideas: `ss.Treatment(moves={...}, per='strain')`, or an event-triggered per-strain branch.
- **The strain set each person moved from, on a transition's event** (item 32). Since agents can move more than once a step, an analyzer can't know someone's strains at the moment of a transition (the port misses about 1 resistant treatment start in 87). Idea: `tb.care.levels`, the level each mover came from.
- **Roles on generated transitions** (item 35). Names like `failure_DS_to_RIF/BDQ` aren't attributes, and finding every de novo move means filtering names by string. Idea: `tr.role == 'denovo'`, with source and destination strain sets. Revisit if a second strain model needs it.
- **Dwell draws keyed like the engine's in the compiled moves.** With the compiled moves, giving a state a nonzero competing rate (e.g. `mu=0` against `mu=0.01`) changes the dwell draws of that state's scheduled exits, since the engine then draws them instead of the kernel, from another stream. An undriven or zero-rate exit no longer does. Fixing it means the Numba and Rust kernels keying their draws by distribution and round as the engine does.
- **`ss.box(dur, decay=...)` held for good as agents.** Its exponential tail never reaches 0, so as agents it is held for good (fading), while compartments leave its decaying level at rate `1/decay`, so `where=vx.held` differs between solvers. Matching would make agents lose it at random instead of fading.

## From the transitions API refactor (2026-11-06)

- **`ss.outside` as one branch of a split.** `I >> {ss.outside: p, R: None} @ dur` (some leave alive, the rest go on, decided by one draw) raises `ss.CapabilityError`; leaving is a transition of its own (`emigration = I >> ss.outside @ rate`), which competes as a hazard instead. `ss.dead` as a branch works (`res.<transition>.dead`). Supporting it means a branch's flow out of the model counted as departures (in `emigrated`, as agents) rather than deaths, in every solver and in the kernels' split. Worth it if a translated model splits on leaving.
- **Finer transmission hooks for a declared disease** (e.g. `susceptibility(transition)`, `transmissibility(transition)`, a route of its own beside the solver's). A declared disease can now replace a whole transmission with `transmit(self, transition)` (`ss.Disease.transmit()`), and `ss.AgentDisease` has hooks for each piece (`transmissibility()`, `susceptibility()`, `route_betas()`, `transmit_route()`, `finalize_infections()`). Not done for declared diseases because the declared engine's transmission has strains, acts, mixing pools, and branching that the coded hooks don't map onto, and modifiers and products already reach `rel_sus` and `rel_trans`. Also not yet with compartments: a declared `transmit()` with strains, or for a transmission with several destinations (agents only).

## From the infected states (2026-11-07)

- **Flags on states other than `infected`.** `ss.State` takes only the flags Starsim reads (`infected`, `people`, `keep_time`); `ss.State(alien=True)` is a `TypeError`. A named group already covers most uses: `alien = Sa + Ia + Ra` given to the disease is a result, `Sa | Ia | Ra` a condition for `where=`, and a population of two kinds is better a stratification. Cliff isn't sure users will never need another flag; it would take `ss.State(**flags)` kept on the state and a way to read them (e.g. a group of the states with a flag), and waits until a model needs one.

## Decisions of 2026-11-07

Decided by Cliff; the spec (02, 03, 17, and, for the last bullet, the others it names) is updated to match.

- **`stop=` is a time, and `dur=` is back.** `start=` and `stop=` are read the same way: a date, a bare number (a calendar year in a sim with a calendar, else a time in the sim's unit from 0), or a duration (a time from 0; in a sim of calendar years, `ss.years(2030)` is the year 2030), so `start=ss.years(2000), stop=ss.years(2030)` runs 30 years. `dur=` on `ss.Sim` and `ss.Timeline` (removed earlier the same day, in Starsim's `f459d5eb6`) is the length of the run, the stop being the start plus it (`start=2000, dur=10`); giving both raises, and `sim.new()` with one replaces the other. In a sim with a calendar, `stop=ss.years(10)` raises, suggesting `dur=ss.years(10)`. A module's `dur=` stays a parameter of its own, not a time keyword.
- **Demographic rates are per 1,000 when above 1, read from their size.** `ss.Births`, `ss.Deaths`, `ss.Pregnancy`'s fertility, and `ss.birth()`/`ss.death()` of everyone read a rate (a bare number, a typed rate, or a table) as per 1,000 people if its typical value (the rate per year, or the median of a table or series) is above 1, else per person; `rate_per=` (1 or 1000) overrides, now with a typed rate too. So `ss.Births(birth_rate=20)`, `ss.peryear(20)`, and `ss.peryear(0.02)` are the same, as are the UN's `mx` tables and the same times 1,000; `ss.Births(birth_rate=0.02)` is now per person (it was 0.02 per 1,000). This replaces "a bare number or a table of births is per 1,000, a typed rate always per person" and its warning for a typed rate above 5 per person (`check_typed()`).
- **Accidental removals restored.** After an audit of what was removed by accident during v4's development (in Starsim's `f459d5eb6` and before it), Cliff decided to restore these, relative to Starsim 3 (the spec in 01, 02, 03, 04, 06, 07, 08, 17, and 19 is updated to match): a duration given as a time is that long after the sim's start in a sim with a calendar, as in Starsim 3 (from 0 without one; one of 1800 years or more is that year; the sim's own `stop=ss.years(10)` with a calendar still raises, suggesting `dur=`), amending the first bullet above; any two of `start=`, `stop=` and `dur=` give the third, and all three may be given if they agree (amending "giving both raises"); `start=` and `stop=` on `ss.modify()`, events and deliveries, as shorthand for `when=ss.schedule(start=, stop=)`, and with any `when=` (a window on an event's trigger); `ss.Sim(pars=)` holding the sim's settings and modules beside the model's parameters, told apart by name; `ss.Pregnancy(p_maternal_death=)` (the chance at delivery) beside the new `maternal_death_rate=` (the hazard while pregnant), with `maternal_death=` raising; `who=` (Starsim 3's `eligibility=`) and `check_eligibility()` on an intervention written as code; `route_betas()`, `set_outcomes()`, and `set_congenital()` (now defaulting to `set_prognoses()`) on a disease written as code; strains on `ss.AgentDisease`, as 05 already described; `ssl.HybridNet.rel_betas` and `ssl.HybridNet.betas()`; and `msim.show()`, now returning the text (for `output=True`). With them: Starsim's own threads are `ss.options.threads` (`STARSIM_THREADS`), and `num_threads` raises naming it and `blas_threads`; `ss.schedule()` with a lone duration by position raises, asking for `every=` or `at=`, and with no arguments is due at every step; and `ss.MultiSim(sim)` alone is one run (Starsim 3: four), now documented.

## From Dan Klein's review of fitting (2026-11-09)

Issues #1575–#1591; the plan and decisions are in `ssv4/working/nov09-dank.md`.

- **Exact JAX evaluation of numbers other than rates** (#1575, D1 (b)): initial values, shares and inflows worked out from the model's expressions in `Density.numbers()`, as rates already are, so a model with a product of fitted parameters (`init=dict(D=i0*cfr)`) keeps gradients instead of being refused. M. Waits for a real model that hits the check.
- **Prequential scoring in `compare_models()`** (#1580, D6 (b)): a stochastic model scored by the particle filter's one-step-ahead predictive log-likelihood through the held-out data, averaged over posterior draws, rather than by unconditioned runs at the best values. M–L, and only for sims the filter can run.
- **Simulation-based calibration** (#1586): truths drawn from the prior, a few hundred replicates, rank histograms for each posterior method, as a release-time devtest. A few core-hours.
- **A benchmark harness for the other frameworks** (#1590): fixed inputs, equivalence checked first, repeated timings; Cliff (on the issue): timings are indicative, and the question is whether a workflow is possible and convenient, so this waits.
- *Done (2026-11-09: it restarts the sim).* **`ss.fitting.ParticleFilter` on a sim already run**: without a restart it silently gave a wrong estimate.
- **Continuing kept runs past a delivery** (#1579): `fit.project()` runs each draw from the start, with a warning, when a delivery is due before the last data point, since the kept runs carry no delivery tallies (and a scenario's delivery there would be left out). It would take keeping the tallies (`tally[:, :ti]`) with each kept run, in the particle filter's path and ABC's, and checking that a scenario's deliveries before `ti` match the fit's. M.

## From the STIsim HIV and ART port (PR #1573, 2026-11-10)

The review and decisions are in `ssv4/working/nov09-rs.md`.

- **Those who die in another module stay under its modifiers for the step.** When a module's transition sends someone to `ss.dead`, its states end at once, so a module stepping later in the same step sees that module's `who=` modifiers as lifted (e.g. ART's `multiply=0` on HIV's death): a stopped exit falls due, the person has two claims to one death, and `People.resolve_causes()` picks a cause at random (26 of 107 deaths mislabelled in an all-on-ART cohort). Only the cause is wrong; the counts are right. The PR's fix (keep a modifier on people dying this step if it applied when last worked out) was dropped (decision 10 (b)): it patched step order inside every agent-run modifier and missed someone who starts being modified and dies in the same step. The general fix is to keep the states of the dying (or what `who=` sees of them) until deaths are resolved at the end of the step. Hazard-weighted causes (MG8 above) would not fix it. M.
- **Rescaled exits land on alternate steps.** After a modifier changes the speed of a scheduled exit, `rescale_exits()` rescales whole-step exit times and rounds them up, so neighbouring steps map to every other step: with `multiply=0.5` from day 5 (10-day dwell, 200k agents), the exits per step are `12158, 0, 11140, 0, 9895, 0, ...`. Totals are right, but daily flows show a sawtooth. Present before PR #1573 too, at the other parity. Needs the fractional exit time kept, or stochastic rounding. S–M.
- **A module that takes another module's timestep.** STIsim's ART sets `self.t.dt` from HIV's in `init_pre()`; there is no API for it yet. S.

## Won't do

Work that was looked at and decided against, with why, so that it is not proposed again without a new reason.

- **From the API review of November 7, 2026** (`ssv4/working/nov07-event.md`), decided against by Cliff: removing the frequency names (`ss.freqperyear` and the rest), which are distinct from rates, and the one-unit instances (`ss.day`, `ss.year`, ...); edges of `ss.RandomNet` lasting one step only (`dur=` stays); removing `ssl.GeoNet`, which is useful for extending; dropping `ss.Treatment(moves=)` for `cure=` (not every effect of a treatment is a cure); a new name for the options of rescaling (`rescale=` takes a dict); solvers at the top level (`ss.ode()`; they are `ss.solvers.ode()` and the rest); and removing keyword forms of positional arguments (`ss.Disease(transitions=, outcomes=, results=)`: each positional form has one).
- **`ss.strains.Family`, strains with courses of their own in one disease** (October 31): it was about 1.5× slower than coded genotypes in HPVsim, its main use case, and was removed; what would bring it back is in `family_tradeoffs.md`. Use one disease per strain with a connector.
- **Aligning `ss.SIR` with `ssl.AgentSIR`** (October 31, F20): they are meant as two different ways of doing the same thing.
- **Removing `RandomNet` methods, `library/mnch/`, `gillespie_places`, `to_edgelist()`, `pop_inds()`, or `set_diagnostics()`** (October 31): all are used or wanted.
- **Trimming importers, IF2, `compare_models()`, or `split_data()`** (October 31): importers and fitting are major value propositions, and future users count.
- **`'auto'` rebuilding as agents when code needs them** (October 31, decision 6): user code would run twice.
- **Compiling the kernels per solver** (October 28): a first ODE run compiled in 37 s instead of 136 s, but `ss.compile()` doubled to about 680 s, Gillespie gained nothing, and it needed a Numba compiler pass on Numba's internals; the ODE in NumPy needs no compiling at all. See `oct27/worklogs/atomica_speed_optB.md`.
- **Cutting Numba's compile time by not inlining `step()`, or by Python threads in `advance_threads`** (November 10, 2026; decided against by Cliff). Cold `ss.compile()` was 23% shorter without inlining (A), 37% with threads as well (B), and 17% with threads alone above a size cutoff (C), all bit-identical; but A and B made small runs and particle filters 50–110% slower (inlining saves 0.2–0.5 µs a step, half a small step), and C made small PMCMC fits 32% slower and added a thread pool, `nogil`, and a tuned cutoff that Rust's copy does not share. The cost falls mainly on CI and development, since users run Rust's kernels; CI instead runs only the tests marked `numba` with Numba's kernels, and keeps its cache across code changes.
- *`overdispersed` reopened and done as `noise=` (2026-11-09, #1588; Cliff: "if declared noise isn't too hard, then do"); the rest stands.* **camdl's `overdispersed`, `deterministic`, and `balance` as features, and more built-in likelihoods** (beta, beta-binomial, zero-inflated negative binomial, a normal whose spread depends on the model). Each is a keyword that one or two benchmark models need, and each is a few lines of code with what v4 already has: a connector that sets a value each step, a function of the sim, a module written as code, and a likelihood given as a function `fn(observed, model)`. Cliff (2026-10-25): no more complexity or built-in features, but a sensible, easy path for unanticipated extensions; that path is tutorial G8 and the tutorial `docs/tutorials/g8_extending.qmd`.

- **NumPy versions of the agent passes, to avoid their first-use compile.** An agent-based run compiles about 5 s of Numba passes (`census`, `count_alive`, `due_alive` and others) once per install; Cliff (2026-10-21): "5 s as a one-off compile is acceptable". So no second implementation of them (about 150–250 lines) for small populations.
- **Export to Petri nets.** A Petri net carries only the states and transitions (places and transitions), less than the Mermaid diagram does with its rates, and no importer reads one back to check the round trip. Decided by Cliff (2026-10-16).
- *Reversed: implemented in `66d865ef` ("Allow ss.null as one destination of a split") and tested (`tests/test_transitions.py`); kept here as history, not a reason to remove it (noted 2026-10-22).* **A branch into `ss.null` within one transition** (`ss.Transition(I, [R, ss.null], p=...)`). `ss.BadBranchSplit` says to write it as a transition of its own, each with `p=`, which works under every solver; allowing it in one would need each engine to take "outside" as one of a branch's destinations. Decided by Cliff (2026-10-16).
- **Export beyond Mermaid, SBML, and odin.** The code is the complete form of a model; exporting it to the other frameworks' formats would be lossy for each and needs a writer and a round-trip test per format, for little use. Decided by Cliff (2026-10-16); SBML and odin are kept only where they are little work, since their importers check the round trip.
- **Another step order for agents (item 18: options B, B′, and C; ST4).** Three conventions were built and measured so that agents would match the ODE at a coarse step (an exit chance r·dt/(1 + r·dt); that plus rates on counts after the start-of-step exits; every rate at the start of a step and every exit at its end); each fixed some models and broke others (spec 12 has the numbers). Cliff (2026-10-19): "just revert to the simplest, most natural interpretation in each case, and document how the different models give slightly different results ... if every method gave the exact same result for everything we wouldn't need to support different methods." ST4 (a dwell entered by transmission is counted one more step in results) is closed with it.
- **Aging from the edge density of a split (MA21).** In a compartmental run with a given age split, people age out of a group at one over its width; estimating the density at each edge from the neighboring groups holds a narrowing pyramid closer to agents (TVD 0.045 to 0.021 after 20 years) but departs from summer2 and textbook models, which age at one over the width (`test_summer2`: 135,866 against 106,893). Kept as the shared convention (decision 16: methods differ, and the others are for inspiration but their shared conventions win).
- **From the October 20 simplification reviews** ([oct20/simplify_plan.md](oct20/simplify_plan.md), "Won't do"): moving `ss.interop` or `ss.library` into separate packages (they stay, imported explicitly); JAX as an optional extra (it becomes a dependency); compatibility layers for removed names (v4 is unreleased; migration is by AI-assisted scripts; a removed name does now say what replaced it, from plan_b G2, which is a message, not a layer); dropping the stochastic compartmental solvers, or limiting declared agents to the core language; `Sim(modules=[...])` as the documented composition path; relative imports in place of `import starsim as ss`; plotting as a submodule or lazily imported; removing `ss.datedur`, merging `freq` into `per`, or moving `probper*`/`freqper*` and the time constants off the root; removing 2D results or `summarize_by`; moving `ss.Strains` or pregnancy out of core; an `ss.converters` submodule. Decided by Cliff (2026-10-20).
- **An SDE (diffusion-approximation) solver.** The missing middle of the stochasticity axis (deterministic, diffusion, jump; [paradigm-conversion.md](../best-practices/paradigm-conversion.md)). Its main use is POMP-style inference, which the particle filter on the chain-binomial solver already serves. Proposed for Won't do on 2026-10-23 in the paradigms review above.
- **Phylodynamic models (birth–death trees, the coalescent) and fitting to sequences.** A branching process with sampling is the birth–death phylodynamic model, so a branching solver with the infection log would give transmission trees (perhaps exported); fitting to sequence data belongs to BEAST and the like. Proposed for Won't do on 2026-10-23 in the paradigms review above.
- **Governance for the release** (from the October 22 skeptic review): a stability and deprecation policy for the declared language, and review of every pull request by someone other than its author. Decided by Cliff (2026-10-25). Likewise, the `/v4.review` reports need no baseline kept from earlier runs (no baggage).
- **A CRN-safe `ss.RandomNet`.** Keying each edge's random number on (source, stub) and finding its target among slots keeps 99.9–100% of edges when one agent dies (against 0–1% today), but every version was slower: 1.22–1.50× at 10k to 1M agents with no deaths, and 3.0–4.6× for the exact version with 30% of slots dead (2026-10-25, one thread). Cliff: only if it is no slower at all. `ssl.RandomSafeNet` is the CRN-safe network.
- **Settling the two-parameter NUTS coverage** (0.907 and 0.917 ± 0.011 against 0.95, after the trapped chains were fixed; exact grid posteriors under-cover by about 2.5 SE too). It would take about 2 core-hours (the grid posterior on all 400 replicates). Cliff (2026-10-19): low priority.

## Accuracy and features from the October 17 review

- **Conveyors for other dwell times.** A lognormal or Weibull dwell time with a short support could be a conveyor of whole steps releasing the distribution's discrete hazards (exact at the step) instead of Erlang stages (approximate in shape). It would change results for ordinary models, so it waits for Cliff.
- **A delay's history.** Starting a state with a fixed delay spread over its slots (as odin's `delay()` backfills its first days) would remove the extra inflow the odin vignette uses; it waits for a spelling.
- **Stays on transmissions, stays out of a state that splits on entry, and `ss.update` in diseases tracked jointly.** Each raises a `CapabilityError` today.
- **Exact exports of a delay.** SBML and odin exports write a conveyor as stages of one step each; odin's own `delay()` would be exact.
