camdl
| Language | camdl DSL; compiler in OCaml, runtime in Rust |
| Paradigms | Compartmental: ODE (RK4, adaptive RK4(5)), chain-binomial (Euler-multinomial), Gillespie SSA |
| Specification style | Standalone typed DSL with a compiler, flat JSON IR, layered TOML configuration |
| Version reviewed | 0.1.0-alpha plus unreleased changes (pre-0.2.0) |
| License | MIT |
| Code | https://github.com/vsbuffalo/camdl |
| Docs | Ships offline: camdl docs <topic>; docs/camdl-language-spec.md |
| Paper | — (alpha) |
| Moving to Starsim | Conversion guide |
What it’s for
camdl is a modeling language, simulator, and inference toolkit for compartmental infectious disease models, developed at IDM. Its premise is that a model has types: compartments are counts, rates are time⁻¹, and probabilities have no units. A rate with the wrong dimensions is a modeling error, which camdl rejects before running, and not a wrong answer.
It is the most relevant framework in this review, because it already attempts most of what the lingua franca wants to be (a rigorous, checkable, AI-friendly text form that runs on several solvers), and because it comes from IDM, so its decisions could be discussed directly.
How a model is specified
A .camdl file, in named blocks:
time_unit = 'days
compartments { S, I, R }
let N = S + I + R
parameters {
#' transmission rate (contacts × per-contact transmission probability)
#' @symbol β
beta : rate in [0.001, 2.0]
#' recovery rate — the mean infectious period is 1/γ
#' @symbol γ
gamma : rate in [0.001, 1.0]
}
transitions {
infection : S --> I @ beta * S * (I / N)
recovery : I --> R @ gamma * I
}
init {
S = 999
I = 1
}
simulate {
from = 0 'days
to = 120 'days
}
The notation is consistent: : defines structure, = gives a value, @ gives a rate, --> is a flow, and #' is a comment that attaches to the declaration below it and appears later in camdl fit summary.
camdl check sir.camdl compiles the model and checks its dimensions, as every other command does automatically. Dropping the / N gives:
error[E300]: transition 'infection' rate has wrong dimension
= note: rate = ((beta * S) * I)
Values live outside the model
A notable decision: parameter values are never set in the parameters block. That block holds names, kinds, dimensions, and optional priors. Values come from a params.toml, a command-line --param, or (the one route in the file) a named scenario’s set = { ... }, in a fixed, documented order of precedence.
So a model’s structure is separate from its values. camdl enforces this by giving each run a separate fingerprint (a hash) for the model, config, parameters, and scenario, plus the seed, and fits have their own. A reviewer asking “did the structure change, or only the parameters?” compares the fingerprints. The lingua franca should copy this way of supporting reproducibility.
Core abstractions
| Concept | camdl name | Notes |
|---|---|---|
| State | compartments { S, I, R } |
Counts, dimension P |
| Transition | name : SRC --> DST @ rate |
Named, built-in, data in the IR. Also inflow (no source) and outflow (no destination) |
| Rate | @ expr |
Always a total propensity, never per-capita: the compiler never silently multiplies by a population |
| Parameter | name : kind in [lo, hi] ~ prior |
Kinds: rate, probability, count, real, positive, instant, duration |
| Derived quantity | let N = S + I + R |
Named bindings, expanded at compile time |
| Stratification | dimensions { age = [child, adult] } + stratify(by = age) |
Applies to all compartments by default; only = [R] for partial |
| Residence structure | via erlang, via hyper_erlang |
Dwell-time stages, generated by the compiler and explicitly distinguished from population strata |
| External data | read("data/lga_pop.tsv", column = "patch") |
Dimension levels, contact matrices, and time series read from files |
| Forcing | forcing { ... } |
Time-varying inputs |
| Observation | observations { name { columns, projected, ~ dist } } |
The observation process is part of the model |
| Intervention | transfer(...), add(...), { COMP = EXPR } with a schedule |
Three actions only; inactive by default |
| Scenario | named set / scale deltas |
Paired against baseline with common random numbers |
| Fitting | fit.toml, one method per file |
Declarative, with convergence gates; a pipeline is two files chained |
Time
time_unit = 'days sets the base unit; literals carry units (120 'days, 2 'years); origin enables calendar dates. Dimensions are real types: rate is time⁻¹, compartments are counts, and every rate expression is checked to have dimension P·T⁻¹.
Starsim reached the same conclusion from the other direction, with dur/per/prob/freq classes in Python. Two separate IDM designs arriving at dimensional typing is strong evidence for it.
Types also do more than catch errors: the inference tools transform parameters automatically (probabilities fit on a logit scale, rates on a log scale).
Stochasticity and reproducibility
Three solvers: Gillespie (exact continuous time), chain binomial (Euler-multinomial discrete time), and ODE. The same model runs on all three. overdispersed() adds extra-demographic noise, and deterministic() gives deterministic flows within a stochastic model.
Runs are stored by their exact inputs, so an identical re-run comes from the cache, and results cannot be silently overwritten. Paired scenarios share random numbers, as in Starsim, but at the level of the run and not the agent.
A Richardson dt-convergence check tells “it ran” from “it converged”, and fits have convergence gates. Almost nothing else in the review offers these diagnostics.
Observations and inference
The observation model is in the model file, declaratively:
observations {
weekly_cases {
columns { time : time, weekly_cases : count }
covers = closing_at(time, 7 'days)
projected = incidence(infection)
emit_schedule = every 7 'days
weekly_cases ~ neg_binomial(mean = rho * projected, r = k)
}
detection {
columns { time : time, detection : count }
projected = prevalence(I)
emit_schedule = every 14 'days
detection ~ bernoulli(p = p_detect * projected / N)
}
}
Two things stand out. Multiple observation streams (clinical incidence and environmental surveillance) are normal, not a special case. And incidence(transition) names a transition, not a compartment, so the flow through a named edge can be observed directly. That works only because transitions are named objects, a recurring argument in this review.
Indexing a projection selects one cell and never sums over the others; a partial index is an error.
Inference: IF2 (maximum likelihood via iterated filtering), PGAS + NUTS (Bayesian posterior), PMMH, bootstrap particle filter, and profile likelihood. Gradients are derived symbolically from the rate expressions, so NUTS gets exact ∇log ℒ. Every solver × method combination either works and is tested, or fails with a check that names the limitation.
A fit.toml file declares one fitting method, so “IF2 to find the mode, then PGAS for the posterior around it” is two files, the second starting from the first’s result. (Earlier versions had named stages within one file.)
Interventions
Three actions, deliberately:
interventions {
sia_round_1 : transfer(fraction = 0.80, from = S, to = V) at [180, 545]
routine_vacc : transfer(fraction = vacc_rate, from = S, to = V) {
every = 30 'days
from = 0 'days
to = 2 'years
}
importation_pulse : { I_child_p1 = I_child_p1 + 10 at = [90] }
}
transfer happens all at once: delta = floor(source * fraction) is worked out from the state before the intervention, then applied as one move. Interventions are off by default and switched on by scenario or command line, which makes the baseline clear.
Stratification is the hard part, and the error messages record what was learned. A plain stratified transfer is applied cell by cell, but only when both ends have the same dimensions in the same order (E237). count on such a transfer is rejected, as it would multiply the intended total by the number of cells (E238). A plain endpoint inside an indexed family is rejected, because the coverage reached would be 1 − (1 − f)^P and not f (E239). Each is a real modeling error that is easy to miss elsewhere.
Strengths
- Dimensions checked before running. The missing-
/Nbug is an error with a line number. For a tool informing public health decisions, that matters most. - Named, built-in transitions as data.
incidence(transition), symbolic differentiation, and diagrams all follow from this choice. - One model, three solvers: ODE, chain binomial, and Gillespie, with a named error where something is not supported.
- The observation model is part of the model, with multiple streams.
- Structure separated from values, enforced by separate run fingerprints.
- Runs stored by their inputs, cached, with a full record; paired scenarios with common random numbers.
- Exact gradients, derived symbolically.
- Convergence is checked, not assumed: dt-convergence checks, particle-filter health (ESS, τ²), prequential scoring, and gates on each fit.
- Error messages as documentation. E237/E238/E239 hold hard-won modeling knowledge, and the user meets it exactly when it matters. The lingua franca should teach this way.
- Designed for AI agents: tested documentation inside the program (
camdl docs), matched to the version so that it cannot go stale, plus anAGENTS.mdbriefing.
Limitations
- Compartmental only. No agents, no networks, and no individual heterogeneity beyond stratification. This project needs a wider range.
- No population structure beyond strata and patches. Contact matrices and metapopulation coupling are supported; individual contact networks and lasting partnerships are not.
- Alpha, and two languages. An OCaml front end and Rust back end mean a heavier install than
pip install, and the interface is still changing. - Small user base. The decisions are good, but a handful of projects have tested them, not a whole field.
- Being explicit makes models longer. With no hidden multiplication, the user writes
* Popfactors and full indices by hand. That is the right trade for correctness, at some cost in approachability. - Bare names change meaning. After stratification, bare names mean global totals, and stratification rewrites global formulas into per-stratum ones. The user has to hold two readings of one name.
- Indexing by position is unsafe when dimensions share level names. camdl checks that a level belongs to the dimension in that slot, but not which dimension an index variable came from, so
I[r, a]with the two crossed passes silently. The spec documents this and recommends named indexing.
Implications for the lingua franca
- Adopt camdl’s core and do not reinvent it. Dimensional types, named transitions as data, rates as total flows, values outside the model, separate run fingerprints, and declared observations. The question for this project is how to extend these beyond compartmental models.
- That extension is the hard part. camdl shows that a typed compartmental language on several solvers works. It does not show how the same text runs as agents on a contact network. There
epipack,Emulsion, andCatalyst.jlare the evidence, and the lingua franca has to make its own contribution. - Take the observation block as a whole.
columns/projected/~ distribution, with multiple named streams andincidence(transition), is the best treatment of observation in the review, and more general than flepiMoP’s delay-based form. - Take the separate run fingerprints. Model / config / params / scenario / seed, hashed separately (and fits too), tell structural changes from value changes across a large set of runs. No other framework here addresses this.
- Adopt “a named error, never a silent gap”. Where a paradigm and a feature do not work together, the language should say so by name before running. A language spanning paradigms will have many such cases.
- Treat error messages as part of the language. E237/E238/E239 deliver what was learned about stratified coverage at the point of the mistake. This matters even more when the author may be an AI that would otherwise confidently write the
1 − (1 − f)^Pbug. - Fix indexing by position. With more kinds of dimension than camdl (agents, networks, strata, time), tracking which dimension an index variable belongs to is worth the work.
- Put the docs in the program. Tested, version-matched documentation that an agent can read offline is a small feature with a large effect on whether generated models are correct.