Building a composable Julia ecosystem for infectious disease modelling

A roadmap, challenges, and questions

Sam Abbott

London School of Hygiene & Tropical Medicine

14 August 2026

Talk plan

  • What we are trying to build, and why it needs an ecosystem
  • What we wanted, and why the R route was hard
  • Automatic differentiation as the requirement, not a feature
  • What a good Julia package looks like, and what enforces it
  • No contributors yet, and building for whatever writes the next one
  • Four questions I cannot answer

samabbott.co.uk/JuliaCon2026/roadmap

JuliaCon 2026, Muschel — N3, Friday 14 August 2026, 14:30.

What we are trying to build

Chains of models lose the uncertainty

  • In a chain of models every arrow is a handover, and uncertainty is approximated at each one
  • The UK Community Infection Survey ran this way. Outside modellers saw summarised prevalence estimates
  • Fitting the streams together avoids the handovers. Each model still gets written from scratch
  • We want the components to be reusable, not the model

Note

The argument for joint models is the 16:45 talk in this room.

Directed graph of the DRC Bundibugyo outbreak model. One reproduction number drives infections, infections feed onsets through an incubation period, and onsets feed suspected cases, exports, deaths and isolation occupancy, all inside a single joint model.

The 2026 DRC Bundibugyo outbreak model, BVDOutbreakSize, one renewal process and many observation streams · cascade argument from the composable modelling paper, Abbott et al., in preparation, after Lison et al. 2024 · the survey account is from the EpiAware announcement post, 2026-07-10.

What we wanted

What we wanted exists, in four different places

  • The community of epinowcast. People who did not write the code answering each other
  • The technical coherence of SciML. Shared interfaces, and downstream CI run against packages it does not own
  • The ecosystem shape of Turing. Small packages other people build on
  • And the curation of rOpenSci. Software peer review and a written package guide. Julia has nothing like it
In R In Julia
Forum 144 members, 1,273 posts No category
Seminars 34 since May 2023 None
Good first issues Not counted None open

community.epinowcast.org and epinowcast.org/seminars, read 2026-08-11 · every EpiAware package carries the good first issue label, none of the open issues use it, searched 2026-08-11 · SciML governance, whose downstream CI runs ProbNumDiffEq.jl and PositiveIntegrators.jl · rOpenSci software review.

We had that in R, and it does not come apart

Package Since Reused as
EpiNow2 2020 All of it
epinowcast 2021 All of it
epidist 2023 All of it
primarycensored 2024 Copied source
CensoredDistributions.jl 2024 A distribution

Repository creation dates from the GitHub API, 2026-08-10 · the copy is inst/stan/functions/LICENSE.primarycensored in epinowcast, licence file and all, and both packages are mine.

  • I started epinowcast in 2021 to be the modular one. It still cannot be taken apart into pieces anyone else can use
  • The same structural request has been written down in EpiNow2 four times since 2020. Two closed as done and left the package no easier to take apart. Two are still open
  • We wrote down a workflow for building these models, then could not follow it with the tools we had built

Four EpiNow2 issues read 2026-08-10 · #122, #313, #1268, #1345 · A Workflow for Infectious Disease Modelling, Abbott et al. doi:10.5281/zenodo.19097427.

More than one way to compose, so we run several

  • A. Composed distributions with their own fitting layer. rand simulates a case, logpdf scores one
  • B. Turing submodels. Joint inference and the whole toolchain, and one PPL’s design decisions with it
  • C. SciML. A delay lowered into a generator, with solvers and sensitivities. Inference supplied separately
  • D. A model written by hand. What everyone does now, and the baseline the other three have to beat
  • We run all of them because I cannot tell you which is right

Note

A bet. Routes A, B and C take the same Distributions.jl objects and the same gradients.

using Distributions, CensoredDistributions

d = double_interval_censored(
    Gamma(2, 3); upper = 15, interval = 1
)

rand(d)         # simulate
logpdf(d, 4.0)  # score

Route A is ComposedDistributions.jl with DistributionsInference.jl, and the 14:45 talk in this room. Route B is ComposableTuringIDModels.jl, and the 16:45 talk. Route C is LoweredDistributions.jl, over Catalyst, SciMLBase, JumpProcesses and AlgebraicPetri. All at github.com/EpiAware.

Automatic differentiation as a first class citizen

What does it mean to say a package supports AD?

  • A real model needs a PPL, and a PPL needs gradients through every component. AD is a requirement, not a feature
  • Nobody has written down what supporting AD means, so we picked a definition and made CI defend it
  • DifferentiationInterfaceTest does the correctness testing. On top, a package declares which scenarios are broken on which backend, and CI re-runs them
  • In my view none of the six is reliably best, and each is kept going by a small team

Note

Is there a guide for any of this anywhere in the Julia ecosystem? I went looking and did not find one.

const _AD_BACKENDS = [
  (header = "ForwardDiff", ...),
  (header = "ReverseDiff (tape)", ...),
  (header = "Enzyme forward", ...),
  (header = "Enzyme reverse", ...),
  (header = "Mooncake reverse", ...),
  (header = "Mooncake forward", ...),
]

Abridged from _AD_BACKENDS, src/scaffold.jl:1464 in EpiAwarePackageTools.jl, read 2026-08-11. Each entry also carries a Codecov flag, a test item tag and the package it loads, and the list generates the CI matrix, the flags, the README badges and the support table in the docs · check_broken and the registry contract are in src/ad_harness.jl · DifferentiationInterfaceTest.jl.

A wrong number, not an error

f(x) = xlogy(x[1], 0.8)   # d/dx = log(0.8)

DI.gradient(f, DI.AutoForwardDiff(), [0.0])
DI.gradient(f, DI.AutoMooncake(), [0.0])
Backend Gradient at x = 0
Finite differences -0.22314
ForwardDiff -0.22314
ReverseDiff -0.22314
Enzyme -0.22314
Mooncake 0.0
  • logpdf(Gamma(k, θ), x) computes xlogy(k - 1, x / θ). Mooncake has no rule for xlogy, so it differentiates the implementation. At shape 1.0 the first argument is zero, the iszero branch returns a constant, and the gradient comes back as zero
  • Nothing throws. The sampler runs. The number is wrong
  • EpiAwareADTools.jl carries the rule, and deletes it the day Mooncake ships one

The three lines and the backend comparison are from ComposedDistributions.jl#99, 2026-07-07. xlogy comes from LogExpFunctions, DI is DifferentiationInterface. Mooncake 0.5.37, Julia 1.12 · the withdrawn upstream report is chalk-lab/Mooncake.jl#1241, closed the day it was opened, and nothing has replaced it as of 2026-08-11 · the local rule is ext/EpiAwareADToolsLogExpFunctionsMooncakeExt.jl.

What a good Julia package looks like

R has a stack of checks. Julia has Aqua

  • In R a package arrives with a definition of good already attached. R CMD check, CRAN policy, and rOpenSci review if you want it
  • In Julia you get Aqua, and after that you decide. We decided on nine checks that run in every package
  • JET is one of the nine. Benchmarking and the per-backend AD tests run alongside them
  • All of it is CI, and the CI is now the thing to maintain. 8,600 lines of Julia hold ten of the eleven packages rigid
@testitem "Quality: Aqua"
@testitem "Quality: ExplicitImports"
@testitem "Quality: import centralisation"
@testitem "Quality: docstring format"
@testitem "Quality: README sections"
@testitem "Quality: doctest"
@testitem "Quality: formatting"
@testitem "Quality: linting (JET)"
@testitem "Quality: extension ambiguities"

test/package/quality.jl, written into each adopting package by EpiAwarePackageTools.jl and overwritten on every sync, read 2026-08-11. JET and the formatter each run in their own environment, so the checked version cannot drift with whatever the test environment resolves · 8,600 lines is src/ in EpiAwarePackageTools.jl, of which scaffold.jl is 3,367 · ten of the eleven Julia packages carry template-sync.yaml on main, CensoredDistributions.jl is the one still migrating, checked 2026-08-10.

Documentation has to be a failing test

  • Julia’s default is a docstring if you feel like it. Asking for them did not work, so the template tests for them
  • Public symbols need a docstring in the standard format. The README’s Julia blocks run at docs build, so a broken example fails it
  • Fourteen standards are written down, each naming the check that catches a breach or saying plainly that a human has to
  • The banned word list is one of those checks. A README that calls itself comprehensive does not ship
const BANNED_README_WORDS = [
    "comprehensive", "cornerstone",
    "facilitate", "foster",
    "framework", "harness",
    "landscape", "leverage",
    "novel", "pivotal",
    "practitioner", "robust",
    "streamline", "synergy",
    # ... 20 in total
]

BANNED_README_WORDS in src/quality.jl, and the standards page in the EpiAwarePackageTools.jl documentation, read 2026-08-11.

Contributors, and building for AI

No contributors, and most of the writing is not mine

All of my organisations, seabbs-bot only, January to July 2026 · EpiAware counts from gh api search/issues, read 2026-08-10 · more in “How I use coding agents”.

  • We have been applying for funding to do this properly, so far without success. We started anyway, with coding agents
  • 966 merged pull requests in the EpiAware org from the bot account, and 119 closed unmerged
  • No outside contributors so far, and no open good first issue on any package. Whoever writes the next one is likely to be a model
  • On the DRC outbreak model the agents wrote the tests as well as the code. I read fewer of them than I should, and I do not have a defence for that

Why we rolled our own template

  • PkgTemplates.jl composes the plugins you pick. BestieTemplate.jl is a Copier template, applied to an existing package a feature at a time
  • Both are built to be chosen from. We wanted every package identical, so that whatever writes the next one has one shape to copy
  • Neither covers the opinionated part, per-backend AD testing and benchmarking
  • CI comes from one central .github, as SciML does, and EpiAwarePackageTools.jl overwrites the callers weekly. Its standards page is written for a model to read

Important

We should probably have built this on their Copier template rather than writing our own scaffolder.

# MANAGED by EpiAwarePackageTools.scaffold
# — do not edit by hand.
name: Test

jobs:
  test:
    uses: EpiAware/.github/.github/workflows/tests.yml@6fcdcde

.github/workflows/test.yaml, written into every adopting package and overwritten on every sync, SHA abridged · 23 reusable workflows in EpiAware/.github, counted 2026-08-11, template-sync.yaml runs Monday 04:00 UTC and a sync keeps whatever SHA dependabot has pinned · the standards page is in the EpiAwarePackageTools.jl documentation, read 2026-08-11 · BestieTemplate.jl and PkgTemplates.jl read 2026-08-11.

What I want to ask you

Four questions I cannot answer

1. What is the smallest governance you can start with? JuMP and SciML have written theirs. Turing has not. None of them says how an org starts, or how a package joins one.

2. Julia has no rOpenSci. Should it? No maintenance status labels, no disputes procedure. How do I decide whether to depend on a one-maintainer package?

3. Who tells a user which backend works for a model built from five packages? DifferentiationInterface answers for a function. Nothing composes.

4. How do you release eleven packages that have to work together? SciML runs downstream CI outside its own org. That is workflow files rather than guidance, and nothing publishes a tested version set. Twenty when we get there.

Important

And the one behind all of them. Is there a Julia ecosystem whose users are mostly not developers? I went looking and could not find one.

EpiAwareR, Sebastian Funk’s R interface prototype, is our attempt at that question. It is the piece I am least sure how to sustain.

Thank you

Important

Tell me which of the four questions you already have an answer to.

Backup

Eleven packages, three ways to compose them

flowchart LR
  DJ["Distributions.jl"]
  EXT["Censored<br>Convolved<br>Modified<br>Reparameterised"]
  CMP["Composed<br>Distributions.jl"]
  LOW["Lowered<br>Distributions.jl"]
  A["A<br>Distributions<br>Inference.jl"]
  B["B<br>ComposableTuring<br>IDModels.jl"]
  C["C<br>SciML<br>solvers"]
  SCR["Scoring<br>Rules.jl"]
  NXT["Generation<br>Time.jl<br>Reproduction<br>Number.jl"]

  DJ --> EXT --> CMP
  DJ --> LOW --> C
  CMP --> A --> SCR
  CMP --> B --> SCR
  C --> SCR
  NXT -.-> CMP
  NXT -.-> B

  classDef empty stroke-dasharray:6 4,color:#777,stroke:#777
  class NXT empty

  • Dashed is an empty repository
  • Ten of the eleven packages are registered in General. ScoringRules.jl is not
  • EpiAwarePackageTools.jl and EpiAwareADTools.jl are the two that hold the rest rigid

22 repositories, 19 of them public. 11 hold a Julia package, 10 of those are registered in General. Counted on 2026-08-10.

What ships next

flowchart LR
  S["<b>Sep 2026</b><br>Nothing red"]
  D["<b>Dec 2026</b><br>The dynamics components"]
  M["<b>Mar 2027</b><br>A written way in"]

  S --> D --> M

  • Sep. ComposedDistributions.jl green on all six backends, ScoringRules.jl registered, and the documentation site level with main rather than trailing it
  • Dec. GenerationTime.jl and ReproductionNumber.jl, the two empty repositories, are the disease dynamics pieces the abstract promised
  • Mar. A governance document saying how a package joins and who decides, and a contributing guide that names a first task

Important

Come and find me at JuliaCon 2027 if none of them happened.

  • SpeedyWeather.jl is the size and shape we are aiming at. One domain, a handful of packages, and users who are modellers rather than developers
  • The abstract for this talk says we have one package and an R prototype, EpiAwareR. It is ten packages out of date

What the language buys us

  • Distributions.jl is a shared vocabulary that already exists. A censored delay can be a distribution, not a function in my package
  • Multiple dispatch means someone else’s distribution works in my model without either of us having heard of the other
  • One automatic differentiation stack, so gradients are an ecosystem property rather than a per-package one
  • Package extensions let two packages work together without either taking a dependency on the other

On top of DifferentiationInterfaceTest

  • DifferentiationInterfaceTest works per function and per package. We added a registry where a package declares which scenarios are broken on which backend, and check_broken re-runs them
  • A scenario declared broken that starts passing is flagged, so the declarations cannot go stale
  • gamma_inc and beta_inc carry no AD rules, so we hand-wrote those derivatives. ScoringRules.jl hit the same two from scratch

src/ad_harness.jl in EpiAwarePackageTools.jl and src/gamma_ad.jl, src/beta_ad.jl in EpiAwareADTools.jl, read 2026-08-11.

Nobody can tell you which backend a model needs

Backend Censored Composed
ForwardDiff green red
ReverseDiff, tape green red
Enzyme forward green, 1 broken red
Enzyme reverse green, 1 broken red
Mooncake reverse green red
Mooncake forward green red

Censored is CensoredDistributions.jl, Composed is ComposedDistributions.jl · declared broken scenarios in CensoredDistributions.jl#889, read from test/ADFixtures on main · last AD run on main green 2026-06-21 there and red on all six 2026-08-07 in ComposedDistributions.jl · TuringLang/ADTests, archived 2026-06-15.

  • DifferentiationInterface tells you about a function. Turing tells you what Turing tests. Nothing tells you about a model assembled from five packages that each tested on their own
  • The one public per-backend table for a probabilistic programming stack, TuringLang/ADTests, was archived in June

Agents on the outbreak model

  • On the live DRC outbreak model the tests were written by the same agents that wrote the code. A green suite says the code does what it was told, not that what it was told was right
  • Its chart digitiser undercounted one situation report by 54% after a change in image compression. A self-check caught it, not a person
  • 1,144 pull requests in the EpiAware org from the bot account, all time. 966 merged, 119 closed unmerged, the same ratio as my other organisations

EpiAware counts from gh api search/issues, read 2026-08-10 · BVDOutbreakSize.

Six years of packages, none of which come apart

Created Package What it fixed What can be reused
Jun 2020 EpiNow2 Real-time \(R_t\) The whole package
Oct 2021 epinowcast Reporting delays The whole package
Jul 2023 epidist Delay estimation The whole package
Aug 2024 primarycensored Censoring bias Stan source, copied in
Sep 2024 CensoredDistributions.jl Censoring bias The distribution
  • The same structural request has been written down in EpiNow2 four times across five and a half years. #122 (2020) and #313 (2022) were closed as done and left the package no easier to take apart. #1268 and #1345 are still open
  • Sharing a component between two Stan packages means copying its source in. #1268 asks us to do exactly that

Repository creation dates from the GitHub API, 2026-08-10 · epiforecasts/EpiNow2#122, #313, #1268, #1345

Eleven packages, and four questions

What we said we would build

flowchart LR
  DJ["Distributions.jl"]
  EXT["Censored<br>Convolved<br>Modified<br>Reparameterised"]
  CMP["Composed<br>Distributions.jl"]
  LOW["Lowered<br>Distributions.jl"]
  A["A<br>Distributions<br>Inference.jl"]
  B["B<br>ComposableTuring<br>IDModels.jl"]
  C["C<br>SciML<br>solvers"]
  SCR["Scoring<br>Rules.jl"]
  NXT["Generation<br>Time.jl<br>Reproduction<br>Number.jl"]

  DJ --> EXT --> CMP
  DJ --> LOW --> C
  CMP --> A --> SCR
  CMP --> B --> SCR
  C --> SCR
  NXT -.-> CMP
  NXT -.-> B

  classDef empty stroke-dasharray:6 4,color:#777,stroke:#777
  class NXT empty

Dashed is empty. A, B and C share the same objects.

What holds it together

  • EpiAwarePackageTools.jl, up to 69 files per package, nine checks
  • Six autodiff configurations in CI, from one list
  • 8,600 lines of Julia to hold ten packages rigid

What we cannot answer

  1. What is the smallest governance an ecosystem can start with?
  2. Julia has no rOpenSci. Should it?
  3. Who tells a user which backend works for a model built from five packages?
  4. How do you release eleven packages that have to work together?

Important

And the one behind all of them. Is there a Julia ecosystem whose users are mostly not developers? I went looking and could not find one.

22 repositories, 19 public. 11 hold a Julia package, 10 registered in General. Counted on 2026-08-10 · epiaware.org · github.com/EpiAware