Release 1.2: The Multipathogen Update
Welcome to the 1.2 release! This release turns GEMS from a single-disease simulator into a multipathogen framework: an arbitrary number of pathogens can now circulate, interact, and co-infect the same population within a single run. On top of that core change it introduces a composable transmission system, a wider set of shipped transmission functions and modifiers (cross-immunity, viral interference, and seasonal forcing), customizable infectiousness and immunity profiles, per-pathogen seeding and recurring case importation, a full separation of host-level care and mortality from per-pathogen disease progression, and a contact-tracing index that makes TraceInfectiousContacts three to four orders of magnitude faster.
Backwards compatibility was a design goal throughout. Old-format configs and single-pathogen scripts are auto-detected and rerouted through a self-contained legacy layer, so existing work should keep running unchanged. The breaking surface that remains is narrow and listed immediately below.
Upgrading
Most code needs no changes. These are the exceptions:
Symptomaticwas renamed toMild. The old name still works, sinceSymptomaticis retained as a delegating compatibility type, but preferMildin new code.Hospitalizedis no longer a disease progression tier. Hospitalization is now a host-level decision made by aHealthProgression. TheHospitalizedcategory survives in the legacy layer for old configs. If you are porting a config to the new format rather than leaning on that layer, care parameters have two destinations: the top-level[HealthProgression]section, or, for a single-pathogen simulation, embedded directly on theSevereandCriticalcategories, from where they are collected into the default policy automatically.- Per-individual care accessors were removed from the public API:
hospital_admission,hospital_discharge,icu_admission,icu_discharge,ventilation_admission, andventilation_discharge. Care is no longer a property of one infection; usehealth_episodes(rd)for reconstructed stays. - The
infections()DataFrame changed columns. Seven columns were dropped: the six per-infection care ticks (hospital_admission,hospital_discharge,icu_admission,icu_discharge,ventilation_admission,ventilation_discharge) anddeath, since care and mortality are host-level now. Usehealth_episodes(rd)for stays. Five columns were added:pathogen_id,progression_category,critical_onset,critical_offset, andremoved, the tick at which the infection ended, whether by recovery or by the host death that cut it short. Post-processing that read the dropped columns must be updated. compartment_periodschanged columns. Thehospitalized,icu, andventilateddurations were replaced by a singlecriticalduration, because those are host-level periods rather than per-infection ones. The same applies toaggregated_compartment_periods. Care durations now come fromhealth_episodes(rd).TestTypeandSeroprevalenceTestTypetake apathogen_id::Int8instead of aPathogenobject, and the accessor ispathogen_id(tt)rather thanpathogen(tt). Note thatpathogenis still exported as aSimulationaccessor, so calling it on a test type fails at the call site rather than at import.- Start conditions must name their pathogen when a simulation has more than one. Pass
pathogen = "Covid19", orpathogen = "all"to seed all of them. - Source files moved.
structs/andmethods/were dissolved into domain folders; see Repository Restructure.
Multipathogen Core
The simulation no longer tracks a single infection per agent. Each individual can carry multiple concurrent infections, one per distinct pathogen, and the entire disease timeline moved off the Individual struct into dedicated infection records.
- Bits-type
InfectionState: An immutable,isbitsrecord holding one infection's complete timeline (exposure,infectiousness_onset,symptom_onset, …,recovery), itspathogen_id, a precomputed per-tickinfectiousness::Int8, and anactiveflag.ImmunityStatemirrors this for immunity records, tagged withIMMUNITY_SOURCE_NATURALorIMMUNITY_SOURCE_VACCINE. - Pathogen bitmasks, bounded at 32: The scalar
infected::Boolsemantics were replaced withactive_pathogens_mask::UInt32anddetected_mask::UInt32onIndividual, where bitid - 1flags active infection and detection for a pathogen. This gives O(1) "is this agent infected with pathogen X?" queries without touching a registry, and the width of those masks is what setsMAX_PATHOGENS = 32(shared with the test-registry key packing). Pathogen ids are validated and auto-assigned as a contiguous1..Nrange by_finalize_pathogen_ids!, whether pathogens arrive from a config file or as a user-supplied tuple. - Single-active-infection invariant: All mask mutation is routed through
infected!(ind, pid, val)anddetected!(ind, pid, val), giving one place to enforce the rule thatinfect!upholds: at most one active infection per(individual, pathogen)pair, with a concurrent re-infection by the same pathogen warned and skipped rather than overwriting the existing one.
Record Storage & Lock-Free Concurrency
Those records live in two places: a small inline cache on the agent for the common case, and three new registry types holding what does not fit. InfectionRegistry and ImmunityRegistry share one design; TestRegistry is deliberately different.
- Cache + overflow storage: Each
Individualcarries a small inlineinfection_cache::NTuple{INFECTIONS_CACHE_SIZE, InfectionState}(and a parallelimmunity_cache) plus aninfection_head::Int32/immunity_head::Int32pointer into a registry-backed linked list holding any additional concurrent infections. The common single-infection case stays allocation-free and cache-local, while co-infection is unbounded. Both cache sizes are tunable constants (default1). - Flat storage, index-linked chains: An
InfectionRegistryis astates::Vector{InfectionState}plus afree_slots::Vector{Int32}free list, andImmunityRegistrymirrors it forImmunityState. The overflow chain hanging offinfection_headis a linked list of indices into that vector, not heap-allocated nodes, so walking a co-infected agent's records stays inside one contiguous array. Registries can be pre-sized at construction withoverflow_fraction, which reserves capacity for the expected share of co-infected agents, divided across shards. - Recycled slots: When an infection ends, its index is returned to
free_slotsand reused by the next insertion instead of appending tostates. Registry size therefore settles at the peak number of concurrent overflow records, not the cumulative number of infections over the run. - Cache promotion: When an infection living in an individual's inline cache ends, the head of that individual's overflow chain is moved into the freed cache slot and its node returned to the free list. The hot inline slot stays occupied while the agent has any active infection, so the fast path keeps working for agents who have been co-infected and partly recovered.
- Thread-sharded ownership: Each registry type is stored on the
Simulationas aVectorsharded by_owner_shard(id) = mod(id - 1, maxthreadid()) + 1, so every agent deterministically belongs to exactly one shard. Element-wise reads and writes during the threaded update and spread phases are therefore contention-free without locking. - Deferred structural mutation: Linked-list insertion and removal mutate registry structure, so they are staged as
_PendingInfection/_EndedInfectionrecords and applied in a per-shard threaded flush (flush_pending_infections!,flush_ended_infections!) between phases. The parallel hot loops stay purely element-wise. Overflow unlinks are ordered before cache promotions, so an ended overflow record can never be resurrected into a freed cache slot. - Lazy iterators:
each_infectionandeach_immunityreturn type-constrainedInfectionIterator/ImmunityIteratorvalues that resolve shards lazily and walk an individual's inline cache and overflow chain, presenting all active records through one uniform interface regardless of where they live. TestRegistryis a dictionary, not a sharded vector: Test state is sparse, since only tested agents have any, so it is held as aDict{UInt64, TestState}keyed by a packed composite of individual id and pathogen id. This is the packing thatMAX_PATHOGENSbounds alongside the bitmasks.get_test_state(ind, reg, pid)returns a defaultTestState()for a pair that was never tested rather than inserting one, so reads never grow the dictionary.- Immunity accessors:
natural_immunity_recorded/vaccine_immunity_recorded,natural_immunity_active/vaccine_immunity_active,natural_immunity_pending/vaccine_immunity_pending, andimmunity_activedistinguish an immunity that exists, one that is currently protective, and one still building up.
Type-Stable Pathogen Dispatch
Supporting many pathogens naively would put a dynamic dispatch on every contact. Instead, the pathogen set is encoded in the type system.
- Fully parameterized
Pathogen{PRG, PA, TF, IP, IM}: A pathogen carries the concrete types of its progressions tuple, progression-assignment function, transmission function, and infectiousness and immunity profiles, so every dispatch on it resolves to a concrete method. Simulation{P<:Tuple, HP<:HealthProgression}: Pathogens are stored as a statically-typed heterogeneous tuple and the health policy as a second type parameter, making the whole pathogen and care configuration part of the simulation's type.@generatedlookup:get_pathogen(sim, pid)unrolls the tuple search at compile time, recovering the concretePathogen{…}type even when indexed by a runtimepid::Int8. The spread and progression loops specialize on each pathogen's concrete transmission, infectiousness, and immunity types instead of paying dynamic dispatch per contact.- Net effect: Adding pathogens does not add per-tick dispatch overhead. Because the same rework also removed dynamic dispatch that existed in the single-pathogen code, a default one-pathogen run is slightly faster than on 1.1.
Pathogens: API & Configuration
Simulation(pathogens = ...)accepts a singlePathogen, aVector, or aTuple. AVectorrequires all elements to share one concrete type; for heterogeneous pathogens pass aTuple, and the error message says so explicitly.- Old calls still work:
pathogen = ...(singular) is upgraded topathogensautomatically, so existing single-pathogen scripts are unchanged. - Accessors:
pathogens(sim),get_pathogen(sim, id),first_pathogen(sim), andpathogen(sim)for the single-pathogen case. - TOML schema: The config defines named
[Pathogens.<Name>]tables, each with its owntransmission_function,infectiousness_profile,immunity_profile,progressions, andprogression_assignmentblocks. - Indexed calibration paths:
assign_values_to_parameters!now understandsname[i]segments in a parameter path, so calibration can target one element of a collection (pathogens[2].transmission_function.transmission_rate) rather than only named fields.pathogenalso resolves against a multi-pathogen simulation for single-pathogen paths.
Composable Transmission
Transmission was decomposed into a base rate × stackable modifiers architecture, separating TransmissionFunction (produces a base probability) from TransmissionModifier (adjusts it through a transmission_factor contract). TransmissionFunction now returns only the base rate; infectiousness and immunity are applied exactly once, centrally, rather than being folded in by each function.
CompositeTransmissionRate{B, M}: Wraps a baseTransmissionFunctiontogether with a statically-typed tuple of modifiers, multiplying in each modifier'stransmission_factorwithout re-multiplying base rates. The tuple is part of the type, so the composition stays allocation- and dispatch-free.CrossImmunityModifier/CrossImmunityTransmissionRate: Prior exposure or immunity to one pathogen partially protects against another. Configured as aDict{String, Float64}ofcross_immunitiesplus adefault_cross_factor. Pathogens are referenced by name and resolved to ids at construction, so the configuration cannot silently depend on id ordering.ViralInterferenceModifier/ViralInterferenceTransmissionRate: An active infection with one pathogen suppresses transmission of another. Takesinterferencesandpersistencedictionaries (with defaults) so interference can outlast the interfering infection itself. It reads interfering state from the registry rather than same-tick staged state, making it independent of intra-tick spread order.SinusoidalSeasonalModifier/SinusoidalSeasonalTransmissionRate: Sinusoidal seasonal forcing with anamplitudeand apeak_day, for flu- and RSV-style seasonality.ConstantTransmissionRateandAgeDependentTransmissionRateare retained, adapted to the new interface, and concretely typed.
Each modifier also ships as a standalone …TransmissionRate for the common case of one base rate plus one modifier, so simple setups do not need to build a composite by hand.
Infectiousness & Immunity Profiles
Pathogen is now a composition root: progression, transmission, infectiousness, and immunity are each independently swappable per pathogen.
InfectiousnessProfile (calculate_infectiousness) computes an individual's shedding level over the course of an infection from its InfectionState. The result is cached per tick on the agent, so the spread phase reads it directly.
ConstantInfectiousness: One fixedlevelacross the whole infectious window.StagedInfectiousness: A separate level per disease stage (asymptomatic,presymptomatic,symptomatic,severe,critical), which is what makes presymptomatic transmission modelling possible.BetaInfectiousness: A continuous shedding curve with atime_to_peakand aconcentrationshape parameter, scaled byleveland optionally adjusted per stage viaasymptomatic_factor…critical_factor. The defaultconcentrationis drawn from the SARS-CoV-2 viral load and shedding kinetics literature (Eckerle et al.).
ImmunityProfile (calculate_immunity) defines how immunity is acquired and decays.
FullImmunity(the previous behaviour) andNoImmunity.ExponentialWaningandSigmoidalWaning: Configurablehalflife(default 180), a protectionfloor, avaccine_buildup_durationramp from vaccination to peak immunity, and, for the sigmoidal form, ahillshape parameter.- Independent barriers: Natural and vaccine-derived immunity are combined as independent protective barriers rather than one overwriting the other, and
susceptibility_factorexposes the resulting protection to the transmission path. immunity_is_stable: A hook that lets the engine clearneeds_immunity_updateonce an individual's immunity has settled, skipping per-tick recomputation while it is constant.- Immunity can shape progression, not only susceptibility:
infect!calls two immunity-aware entry points, each falling through to its immunity-blind form so they are opt-in.assign(individual, pa_func, immunities, pathogen_id, rng)lets an assignment function choose the progression category from the infectee's pre-exposure immunity, andcalculate_progression(individual, tick, dp, immunities, pathogen_id, rng)lets a category shape the timings within it. Both can read same-pathogen protection throughimmunity_level(individual, immunities, pathogen_id)or prior exposure to other pathogens througheach_immunity(individual, immunities), which is what makes a milder course for previously immune hosts expressible.
Host Health Progression: Decoupling Disease from Care
This is the conceptually largest change in the release. Hospitalization, ICU, ventilation, and death used to be baked directly into the disease-progression categories. That is coherent for one pathogen and incoherent for two: if pathogen A schedules a hospital admission and pathogen B independently schedules another, neither stay is authoritative, and two independently scheduled deaths for one host is not an outcome at all.
Disease progression now describes severity only: Asymptomatic → Mild → Severe → Critical. Everything host-level is decided separately.
HealthProgressionandHealthProfile: AHealthProgressionis a host-level policy that looks at all of an individual's active infections at once.select_health_profile(hp, infection)picks the profile for one infection's peak tier, andcalculate_health_profile(profile, individual, infection, rng)turns it into a(CareContribution, HealthOutcome)pair.calculate_health_progression!folds those contributions into a single forward plan for the host.CareContributionandCareLevel: Each infection contributes care demand at one or more ofCARE_HOSPITAL,CARE_ICU,CARE_VENTILATION, as an admission/discharge tick pair.HealthOutcomecarries a proposed death and thepathogen_idto attribute it to;combine_outcomeresolves competing proposals to the earliest death, so mortality has one unambiguous cause.- Per-level demand counters:
Individualcarrieshospital_demands,icu_demands, andventilation_demandscounters rather than a single care episode. A host is at a care level while at least one infection demands it, which makes overlapping stays from different pathogens compose correctly instead of one overwriting the other. HealthSchedule: A tick-keyed schedule ofCareTransitionrecords (host, level, admission-or-discharge) with awake_ticksset for scheduled deaths. Transitions are emitted per level and applied when due. This replaces an earlier single-episode host timeline that merged two disjoint care episodes into one incorrectly widened stay; separate episodes now stay separate. Schedules are sharded per thread on theSimulationalongside the registries.- Forward plans are validated, not clamped:
calculate_health_progression!produces a plan that_validate_health_planchecks against the current tick, instead of silently clamping inconsistent ticks into range. Corrupted care and mortality output under co-infection was a real bug this fixed. DefaultHealthProgression{S, C}: The shipped policy, splitting care by peak tier intoSevereHealthProfile(ward admission only) andCriticalHealthProfile(hospital → ICU → ventilation, plus a death probability that is ungated by hospital or ICU admission, so a host can die without ever being admitted).HealthLoggerandhealth_episodes(): Care is logged as transitions and reconstructed into clean, joinable hospital, ICU, and ventilator episodes, withpp_health_episodes.jlandpp_hospital_df.jlbacking the post-processing.- Config: A top-level
[HealthProgression]section configures the policy, withsevereandcriticalparameter blocks (hospital_probability,hospital_to_icu_probability,icu_to_ventilation_probability,death_probability, and the corresponding delay distributions). - Embedded care for simple cases: Rather than writing a policy, care parameters can be attached directly to the
SevereandCriticalcategories, either as flat keyword arguments or as a prebuiltHealthProfile, and are harvested into aDefaultHealthProgressionwhen the simulation is assembled. Harvesting is global across the pathogen set and allows only one embedded profile per tier, so it raises anArgumentErrorif two pathogens both embed care forSevere. It is a single-pathogen convenience; multi-pathogen setups configure care through[HealthProgression]or their own policy.
Breaking changes
Symptomatic→Mild, andHospitalizedis no longer a disease tier. Both remain available through the legacy layer.- The
Criticalprogression's parameters changed shape:severeness_onset_to_hospital_admission,hospital_admission_to_icu_admission,icu_admission_to_icu_discharge,icu_discharge_to_hospital_discharge,icu_admission_to_death, anddeath_probabilitymoved out of the category and into the health progression, replaced bysevereness_onset_to_critical_onset,critical_onset_to_critical_offset, andcritical_offset_to_severeness_offset. - The per-individual care accessors (
hospital_admission,icu_admission,ventilation_discharge, …) are no longer exported. Usehealth_episodes(rd). - The
HOSPITAL_STATUS_*constants were removed.
The legacy compatibility layer
src/pathogen/legacy_progressions/ reproduces the pre-decoupling behaviour for old configs and code, and is deliberately isolated rather than scattered through the codebase: the folder and its single include can be deleted outright to remove it.
Symptomaticis a delegating wrapper aroundMildthat accepts the same parameters and produces an identical progression.Hospitalizedresolves directly to its legacy type.Criticalis the hard case, because the name is reused rather than renamed._is_legacy_criticaldetects an old-formatCriticalblock by its parameters and_normalize_legacy_pathogen!reroutes just that block toLegacyCritical. A new-formatCriticalis left untouched even when the same config uses legacy names for other tiers.LegacyHealthProgression/LegacyCriticalHealthProfilereproduce the old coupled care course: deterministic hospital → ICU admission with death anchored to the drawn ICU-admission tick, preserving the previous ICU/death coupling.
See Custom Health Progression for writing your own policy.
Seeding, Start Conditions & Imported Cases
- Pathogen-aware start conditions:
InfectedFraction,PatientZero,PatientZeros, andRegionalSeedsall take apathogenname and seed that pathogen. Previously apathogenargument was accepted and silently ignored with a warning that GEMS only supported single-pathogen simulations. - Explicit selection, resolved once: A condition names a pathogen, uses
ALL_PATHOGENS("all"), or leaves the name empty for a single-pathogen simulation._expand_pathogensresolves the selection against the simulation's pathogens at construction:ALL_PATHOGENSexpands into one condition per pathogen, a name is validated against the pathogen set, and an empty name is rejected with an actionable error when more than one pathogen exists. Because this happens at construction, the config-file and programmatic paths agree. MultiStartCondition: A composite condition wrapping one sub-condition per pathogen, initialized in order, for example a different infected fraction per disease in one run. It is constructed automatically when multiple pathogens are supplied.ImportedCases: A new start condition for recurring case importation, which previously had no equivalent.ticksaccepts an explicit vector, a schedule, or a window with anintervalandoffsetthat may each be a distribution;countaccepts a fixed number, a vector, or a distribution; andagsoptionally restricts imports to a region or set of regions. An import only lands on a host that is alive, not hospitalized, not self-isolating, and not already carrying that pathogen. It makes multi-season and endemic scenarios expressible without driving importation from outside the simulation.
Contact Tracing: InfecterIndex
get_infections_between, which backs the contact-tracing measures, linearly scanned every thread's infection log shard on every call. It was the one hot path deliberately left out of the 1.1 intervention-optimization pass because it needed the most implementation work of that batch.
InfecterIndex: An inverted index mapping each infecter to a tick-ordered list of its infectees.get_infections_betweenwalks the relevant slice of that list instead of scanning the log.- Built lazily, maintained incrementally: The index is constructed on first use, so runs without contact tracing pay nothing. New links are staged per thread as
_PendingLinkrecords and merged in with a k-way merge that preserves tick ordering, keeping the parallel logging path lock-free.
Measured impact
Average process_measure runtime for TraceInfectiousContacts, measured on a 16-thread desktop CPU using the default configuration and the shipped population files:
| Population | Speedup |
|---|---|
| Saxony | 490× |
| North Rhine-Westphalia | 1,750× |
| Germany | ~4 orders of magnitude (extrapolated) |
The scaling is expected: the old implementation's cost grew with the size of the infection log, while an indexed lookup grows with the number of infectees actually traced. The Germany figure is extrapolated from the Saxony and NRW measurements, not measured directly.
Pathogen-Aware Analysis, Reporting & Plotting
- Pathogen-tagged loggers: Infection, test, vaccination, and health events carry a
pathogen_id. The logging layer was reorganized intologging/{logger, event_loggers, infection_logger, tick_loggers, infecter_index}.jl. - Multipathogen post-processing: The full
pp_*suite (incidence, attack rates, effective R, R0, serial and generation intervals, tick cases, deaths, tests, and detection) is pathogen-aware, splitting and grouping results per pathogen. - Per-pathogen aggregation:
aggregate_by_pathogenwas added, andResultDataandBatchaggregation were updated to render and store per-pathogen results across runs (also required becauseSimulationis now parameterized). - Plot filters: Every built-in plot gained a
pathogenkeyword accepting an id or a name, defaulting to showing all pathogens with an automatic per-pathogen subplot layout.
Repository Restructure
A large structural cleanup with no behavioural intent, reorganizing the source tree around its actual domains. Worth knowing about if you are looking for a file at its old path.
structs/andmethods/were dissolved intopathogen/(pathogens, progressions, health progression, transmission functions, profiles, vaccines),population/,settings/,registries/,simulation/(simulation, infections, calibration, batch, initialization, termination),contacts/,analysis/(formerlymodel_analysis/), andlogging/(formerlylogger/).agents.jlwas split intopopulation/individuals.jl(the struct) andpopulation/individual_methods.jl(behaviour), with setting membership and lookup moved onto the individual.- Start conditions and stop criteria each moved into their own file under
simulation/initialization/start_conditions/andsimulation/termination/stop_criteria/. - The public API surface was tightened further: internal helpers lost their exports and gained
_prefixes (e.g._basefolder).
Reproducibility Notes
Seeded runs from 1.1.x will not reproduce bit-identically on 1.2.0. The infection model, the order in which random draws are made during progression and care assignment, and the separation of care from disease progression all change the draw sequence. Results remain equally valid; they are simply a different realization. Configs that relied on the coupled Critical behaviour and are rerouted through the legacy layer reproduce the old behaviour, not the old random stream.
Testing
test/registriestest.jl(new) covers cache and overflow promotion, sharded ownership, deferred flush ordering, and iterator traversal.test/healthprogressiontest.jl(new) covers the contribution and schedule API, including a regression test that two disjoint care contributions produce two episodes rather than one widened stay.test/pathogentest.jlwas substantially expanded for the new transmission functions and modifiers, all three infectiousness profiles, the waning immunity profiles, and the single-active-infection guard.test/testutils.jlis new, andagentstest,infectionstest,interventionstest,simulationtest,settingstest,postprocessortest,loggertest, andutilstestwere updated for the per-(individual, pathogen)model, the bitmask accessors, and the new start conditions.