Skip to main content
🧠0% 🤖100%

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-/N bug 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 an AGENTS.md briefing.

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 * Pop factors 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

  1. 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.
  2. 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, and Catalyst.jl are the evidence, and the lingua franca has to make its own contribution.
  3. Take the observation block as a whole. columns / projected / ~ distribution, with multiple named streams and incidence(transition), is the best treatment of observation in the review, and more general than flepiMoP’s delay-based form.
  4. 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.
  5. 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.
  6. 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)^P bug.
  7. 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.
  8. 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.