camdl DSL cheatsheet
Orientation doc — what the DSL supports, in one place, with pointers to the normative sources. Not the spec. When this disagrees with docs/camdl-language-spec.md, the spec wins.
This file exists because the DSL surface is large enough that an agent or new contributor working from memory or from a single proposal often misses features the language already provides, then reinvents them poorly. Read this first when proposing DSL changes.
Status conventions. This cheatsheet reflects the language as of typed-time Phases 1 and 2 (the
2026-05-22-typed-time-and-dsl-ergonomics.mdproposal): the Exact/Calendar split,add_calendar_months/_years,date_range, the constant-day axis rule, and the anchored-mode diagnostics (E320–E323, E327–E329, W324–W328) have all shipped.
Time and units
Time unit declaration sets the model’s internal axis:
time_unit = 'days # also: 'weeks, 'months, 'yearsAll durations and rates normalise to this unit at compile time. The runtime only sees f64 values in this unit — time_unit itself isn’t read in the dynamics path (only at I/O boundaries for date conversion).
Numeric literals carry units — prefix-apostrophe syntax:
5 'days # duration, dimension [T]
14 'days
2 'weeks
0.5 'years
0.1 'per_day # rate, dimension [T⁻¹]
0.02 'per_year
1.0 'ratio # dimensionless multiplier (sinusoidal forcings, etc.)
100 'count # raw population countAvailable unit literals (defined in ocaml/lib/compiler/lexer.mll):
| Class | Literals | Dimension |
|---|---|---|
| Duration | 'days, 'weeks, 'months, 'years |
[T] |
| Rate | 'per_day, 'per_week, 'per_month, 'per_year |
[T⁻¹] |
| Count | 'count |
[P] |
| Multiplier | 'ratio |
[1] |
Conversion table (days_per_unit in rust/crates/ir/src/caltime.rs and the mirror in expander.ml, proleptic-Gregorian throughout):
1 'day = 1 day
1 'week = 7 days
1 'month = 365.2425 / 12 days ≈ 30.4369
1 'year = 365.2425 days
Mixed-unit arithmetic works through the dimensional checker:
5 'days + 3 'days # = 8 'days
1 / (14 'days) # = rate (1/time)
0.1 'per_day * 5 'days # = 0.5 (dimensionless)
5 'days + 0.1 'per_day # ERROR E302: cannot add time and rateDimensional information — three tiers
For full background see docs/camdl-language-spec.md §2.3.
| Tier | Syntax | Carries | Use when |
|---|---|---|---|
| 1. Kind keyword | rate, probability, count, positive, real, instant, duration |
dimension (inferred from kind) | parameter declarations — the 99% case |
| 2. Bracket annotation | [T], [T^-1], [P], [P/T], [1] |
dimension only | kind is under-determined (real/positive) |
| 3. Unit literal | 'days, 'per_day, 'count, 'ratio, … |
dimension and scale | concrete numeric values with a real-world scale |
Tiers are complementary, not redundant — tier 3 carries scale, the others don’t. A parameter from a prior or a --fixed-file / --params TOML lives at tier 1 or 2 (scale is implicit in the model’s time_unit); a literal like 5 'years lives at tier 3.
Parameter kinds
parameters {
beta : rate # [T⁻¹], log transform for inference
rho : probability # [1] bounded [0,1], logit transform
R0 : positive in [1.0, 20.0] # >0, log transform, with bounds
N0 : count # [P], integer ≥ 0
alpha : real # unconstrained
tau : instant in [date("2020-01-01"), date("2020-04-30")]
# [T] absolute time, renders as date
delta : duration in [1 'days, 60 'days]
# [T] span, renders as span
}instant and duration are the time-typed kinds; see docs/dates.md for full date semantics.
Dates and calendar arithmetic
date("YYYY-MM-DD") in DSL constant positions converts to internal time via origin:
origin = date("2020-02-24")
simulate {
from = date("2020-01-21") # = t − 34 days from origin (in time_unit)
to = date("2020-06-27")
}date(...) is accepted in every absolute-time position, not just simulate.from/to: the at = [...] schedule of an interventions {} or events {} entry takes dates too, each resolving to its internal offset via origin. Prefer it to a bare number anywhere the time is a calendar instant — a bare numeric there under a date origin warns W324 (simulate) / W325 (at-schedules); a date(...) is the legible form that silences it.
interventions {
vacc : transfer(from = S, to = V, fraction = 0.1)
at [date("2020-03-01"), date("2020-06-01")] # no W325 — dates are explicit
}Without a top-level origin, date(...) is E220.
Anchored vs unanchored models — see docs/dates.md for the full reference:
- Anchored: declares
origin. Internal axis maps to real calendar dates. Must usetime_unit = 'daysor'weeks(constant-day rule;time_unit = 'months/'yearswithoriginis E320). - Unanchored: no
origin. Internal axis is abstract; bare numbers. Anytime_unitis fine including'months/'years. SBC, synthetic, textbook SIR live here; so do the dacca SIRS models.
Anchor-only primitives — these require origin to be declared:
| Construct | Anchor-only? | If used unanchored |
|---|---|---|
date("YYYY-MM-DD") |
yes | E220 |
origin (identifier) |
yes | E327 |
add_calendar_months(d, n) |
yes | E327 |
add_calendar_years(d, n) |
yes | E327 |
date_range(..., calendar_months/_years = N) |
yes | E327 |
instant-kind param (rendering) |
yes | works as [T]; no date rendering |
5 'months, 5 'years |
no | legal — affine span |
0.087 'per_month |
no | legal — affine rate |
time_unit = 'months/'years |
no | legal (E320 only fires anchored) |
The bottom rows are calendar-named affine constructs, not anchor-only — the dacca SIRS configuration (unanchored, monthly axis, per-month rates, month-span durations) is all of those, and it remains fully legal.
Exact vs Calendar duration kinds. Duration unit literals carry a one-bit refinement on [T]: 'days/'weeks are Exact; 'months/'years are Calendar. The refinement propagates by LUB through arithmetic. In anchored mode, Instant ± Calendar-duration is E321 with a hint at add_calendar_* (calendar-exact) or a 'days literal (affine offset). Parameter references with [T] dimension are always Exact — a 'months-spelled bound on a duration-kind parameter is a length, not a step-from-a-date, and never contaminates uses.
Calendar arithmetic primitives (anchored mode, DSL constant positions only):
add_calendar_months(d, n) # Instant × Int → Instant
add_calendar_years(d, n) # Instant × Int → Instant
date_range(start, end, calendar_months = 3)
# calendar-aligned breakpoint list
date_range(start, end, every = 7 'days)
# affine cadenced is any compile-time-constant Instant (date(...), origin, or a nested add_calendar_* call). Month-end clamping is canonical and non-invertible — (d + 1 month) − 1 month ≠ d in general; a W327 warns on the literal nested round-trip shape.
Periodic forcings — already calendar-friendly
forcing {
school : periodic 'ratio {
period = 365.25 'days
step = 1 'days
on = [7:100, 115:199, 252:300, 308:356]
}
reporting_dow : periodic 'ratio {
period = 7 'days
values = [1.2, 1.1, 1.0, 1.0, 0.9, 0.8, 0.7]
}
}Every forcing declaration carries a tier-3 unit literal between the kind keyword and the block (sinusoidal 'ratio, interpolated 'count, etc.). This is required per GH #8.
Staged residences — via erlang / via hyper_erlang
Non-exponential dwell times without hand-writing the stage chain. A draining transition takes via <law> in place of @ rate (a transition is @ rate XOR via law, never both). The law stages the source compartment and supplies the per-stage rate, lowering to ordinary consecutive sub-stages — zero new IR.
onset : E --> I via erlang(stages = 3, rate = sigma) # E is Erlang-3, mean 1/sigma
recovery : I --> R via erlang(stages = 3, mean = 10 'days)
# mixture, branched at entry; shared endpoint on the arrow, last weight implicit
clearance : I --> R via hyper_erlang(
branch(label = typical, weight = p, stages = 2, mean = 4 'weeks),
branch(label = prolonged, stages = 1, mean = 2 'years))
# different destinations → each branch carries `to`, transition has no arrow target
outcome : I via hyper_erlang(
branch(label = fatal, weight = cfr, stages = 3, mean = 8 'days, to = D),
branch(label = recover, stages = 3, mean = 12 'days, to = R))erlang(stages = k, mean | rate): give exactly one of mean / rate; stages is a positive-integer literal (structure, not fittable — mean/rate/weight are). Each branch(label, stages, mean | rate, weight?, to?); label required, only the last branch omits weight (⇒ 1 − the others). A bare reference to the staged source sums its stages automatically. Block form: { via = erlang(...) }. Diagnostics: E243 unknown law, E244 stages, E245 mean-XOR-rate, E246 single-exit, E255 <2 branches, E256 last-branch weight, E258 duplicate labels, E248 stratified hyper_erlang (deferred). Full section: language spec §9.4.1.
Generated quantities — quantities {}
Report derived summaries of a run — the non-scored twin of an observation (no likelihood, never re-keys the fit). Each entry is name [idx]? = body:
quantities {
prevalence = I / N # series (one value per output time)
attack_rate = final((N0 - S) / N0) # scalar
peak_prevalence = max(I / N) # value reduction
time_to_peak = time_of_max(I) # → a time (a date in an anchored model)
takeoff = first_above(I_total, i_thr) # threshold crossing
fadeout = last_above(I_total, 0)
outbreak_dur = fadeout - takeoff # reduction arithmetic over scalars
person_days = integral(I) # ∫ over time (dim P·T)
peak_time[p in patch] = time_of_max(I[p]) # stratified
peak_reported = max(observations.cases) # reduce the simulated y_sim
}
Reductions (valid only inside quantities {}): final, max, min, mean, count_above|below(x, thr), time_of_max|min, first|last_above|below(x, thr), integral. A state quantity with no reduction is a series; one with a reduction is a scalar. max(a, b) / min(a, b) stay the binary operators everywhere — only a unary max(x) in a quantity is the peak reduction. Reduction arithmetic (a - b) combines earlier scalar quantities.
observations.<stream> reduces the simulated observation series (y_sim) a run already drew — the same draws --obs / the posterior predictive publish, never a fresh one — for a declared stream. It must be reduced (it lives on the stream’s own observation-time axis): a bare observations.<stream> series, or one mixed with state/arithmetic, is E289. (total/sum, a stratified obs source, and a disk-replay quantities command are not in v1.1.)
Output: one quantities/<name>.tsv per quantity (banded q05…q95 over draws) + a quantities.json manifest — from fit predict (in the fit segment) and simulate --quantities-out <dir> (a point value for a single run). A time reduction that never crosses is right-censored (reported via n_value/n_censored, not faked). Quantities are non-identity: adding a quantities {} block never changes a model’s run_id.
Common diagnostics
The compiler issues E-codes with source locations and fix-hints.
| Code | Class | Typical trigger |
|---|---|---|
| E100 | naming | parameter name shadows reserved (t, etc.) |
| E332 | indexing | a named index labels a dimension the thing indexed does not have |
| E347 | obs | a stream declares its temporal columns twice or by half (: time plus window columns, half a pair) |
| E348 | obs | covers or window columns on a state-reading stream |
| E350 | obs | an incidence(...) stream with neither covers = … nor window_start/window_stop |
| E351 | obs | a side of a ratio of flows names no flow after its where guard pruned every level |
| E352 | obs | projected divides transition rates — an instant reading; write the flows or prevalence(of =, among =) |
| E280 | obs | a bare name pools a declared stratum where a value meets data — write sum(I) / sum(a in age, I[a]), or index |
| E356 | obs | prevalence(X) / prevalence(X1, X2) removed — write X for the count, or prevalence(of = X, among = N) |
| E357 | obs | prevalence(of =, among =): the numerator reads a cell the denominator does not |
| E358 | obs | prevalence(of =, among =): the two sides collapse different dimensions |
| E359 | obs | prevalence(of =, among =) reads a flow (incidence(...)) on one side |
| E220 | date | date(...) without origin declared |
| E300 | dim | transition rate not P·T⁻¹ |
| E301 | dim | non-dimensionless argument to exp/log |
| E302 | dim | addition/subtraction of mismatched dimensions |
| E304 | dim | sqrt of odd-exponent dimension |
| E305 | dim | balance expression must have dimension P |
| E306 | dim | ODE derivative must have dimension P·T⁻¹ |
| E308 | dim | overdispersion σ² must have dimension T (positive dim T) |
| E309 | dim | forcing lag must be a duration (dimension T) |
| E314 | dim | intervention fraction not dimensionless, or count/assigned value not a count |
| E315 | dim | intervention or schedule time not T, or trigger threshold not its stream’s dimension |
| E316 | dim | a prior argument’s or a bound’s unit literal disagrees with its dimension |
| E317 | dim | quantity threshold not its series’ dimension, or value_at time not T |
| E320 | time | time_unit = 'months/'years with origin declared |
| E321 | time | Instant ± Calendar-duration (date(...) + 6 'months) |
| E322 | time | calendar cadence in anchored recurring schedule (every = 1 'months) |
| E323 | time | bare-numeric on=[...] in anchored periodic forcing |
| E327 | time | add_calendar_* / origin / calendar-cadence date_range in unanchored model |
| E328 | time | argument-shape error in add_calendar_* / date_range |
| E329 | time | zero/negative cadence or count < 1 in date_range |
| W301 | forcing | periodic range not aligned to step size |
| W324 | time | bare-numeric simulate.from/to/dt in anchored mode |
| W325 | time | bare-numeric at [k, ...] schedule in anchored mode |
| W326 | time | numeric --data time column under origin (use --time-format internal-days to silence) |
| W327 | time | literal nested add_calendar_* round-trip (non-invertible) |
| W328 | time | date_range end doesn’t land on a cadence boundary |
Where things live
- Lexer (tokens, unit literals):
ocaml/lib/compiler/lexer.mll - Parser (grammar):
ocaml/lib/compiler/parser.mly - AST:
ocaml/lib/compiler/ast.ml - Expander (stratification + IR emission):
ocaml/lib/compiler/expander.ml - Dimensional checker:
ocaml/lib/compiler/dimcheck.ml - IR types (Rust):
rust/crates/ir/src/ - Calendar conversion (Rust):
rust/crates/ir/src/caltime.rs - IR schema (the OCaml↔︎Rust contract):
ir/schema.json - Language spec (authoritative):
docs/camdl-language-spec.md - User-feature tour:
docs/user-features.md - Calendar reference:
docs/dates.md
Pitfalls that have actually bitten us
These are real failure modes that have produced incident reports. Read them before assuming the language doesn’t do something — it usually does.
- Reinventing existing surface. The DSL already has
5 'days,0.087 'per_month, range syntax[7:100], the dimensional checker, andinstant/durationparameter kinds. Before adding a new duration / rate / cadence / unit construct, grep this cheatsheet and the language spec. - Cross-language constants need a single source of truth. Anything that has to agree across OCaml and Rust either lives in one place and is read by both, or lives in two places with a test pinning them to match — never two hand-maintained copies. The shared proleptic-Gregorian
rata_diealgorithm (mirrored inrust/crates/ir/src/caltime.rsandocaml/lib/compiler/expander.ml, pinned bydays_per_unitreturning identical Gregorian-average constants on both sides) is the model to follow. - Calendar months aren’t durations. “+1 month” depends on its input and on the year:
date("2021-01-31") + 1 month = date("2021-02-28")butdate("2020-01-31") + 1 month = date("2020-02-29")(leap). It’s an instant operation, not a translation. The language enforces this through the Exact/Calendar refinement on[T](E321), withadd_calendar_months/add_calendar_yearsas the only correct calendar-stepping path. Seedocs/dates.md. - Hard errors over warnings. When a construct is silently ambiguous, the compiler hard-errors with a fix-hint rather than warning. This is CLAUDE.md policy and the typed-time proposal’s acceptance criterion (now shipped).
Recent and incoming changes
- A fraction of the window’s events (proposal 2026-09-09) —
projectedaccepts one division of one flow sum by another:incidence(die_comm) / (incidence(die_comm) + incidence(die_fac))is the share of the week’s deaths that were in the community, accumulated over the row’s window, declarescoverslike any incidence stream, and pairs withbinomial(n = n_deaths, p = projected),beta_binomial,betaorbernoulli. The ratio of the two transitions’ rates is a different quantity (the hazard share at one instant) and is now E352; write itprevalence(of = mu_c * I, among = mu_c * I + mu_f * H)if it is what you mean. See spec §12.1 andcamdl docs language-changes. covers(gh#833) — an incidence stream states the period each row covers, right after itscolumns { }:covers = day(time),starting_on(time, 7 'days),ending_on(time, 7 'days)(the label is the last included day) orclosing_at(time, 7 'days)(the label is the closing boundary, excluded — whatsimulate --obswrites); orwindow_start/window_stopcolumn roles in place of: time, one pair per row. Required on everyincidence(...)stream (E350); forbidden on a state-reading stream (E348). New reserved word:covers. See spec §12.1.1.betalikelihood (gh#440) — scores a directly-observed continuous proportion:frac ~ beta(mean = projected, concentration = phi), with shapesα = mean·φ,β = (1−mean)·φ. Mean-linked likeneg_binomial, so the model predicts the mean andφis the dispersion knob. Reach for it when the observation is a fraction with no reported denominator (assay fraction, coverage, positivity);beta_binomialremains thek-of-ncount family. Support is the open interval — an observed exact0or1scores-inf, so keep the predicted mean off the boundary. Differentiable (usable under PGAS’s NUTS move andnl-lbfgs). No new reserved word:betais a likelihood only in~position, distinct from thebetaprior.viadwell laws (gh#313) — non-exponential residence times on a draining transition:E --> I via erlang(stages = 3, rate = sigma)andvia hyper_erlang(branch(...), ...). Replaces the@ rateclause (a transition is@ rateXORvia law); lowers to ordinaryconsecutivesub-stages, so no new IR. New reserved word:via. See the section above.quantities {}(2026-06-25) — generated quantities: named, non-scored reductions of a run (peak, time-to-peak, attack rate, integral, …) reported asquantities/<name>.tsvfromfit predict/simulate --quantities-out. New reserved word:quantities; the reduction names (final,time_of_max,first_above,integral, …) are valid only inside the block (using one in a rate is E290). See the section above.reactive_interventions {}(gh#204) — state/observation-triggered policies:name : when sum_observed(stream, window = D) >= thr { action = transfer(..); after = ..; once = ..; cooldown = .. }. Thewhenpredicate reads observed data viaobserved(stream)/sum_observed(stream, window = ..)(never latent state — usingobserved()in a rate is E278). New reserved words:reactive_interventions,when,action. Forward chain-binomial runs the agenda (firings recorded inreactive_log.tsv); inference and Gillespie/ODE stop an active policy with aREACTIVE_INTERVENTIONScapability error. See spec §13.9.
For things this cheatsheet may lag on, check:
docs/dev/proposals/for in-flight design.docs/dev/incidents/for known bugs and their resolutions.git log -- ocaml/lib/compiler/lexer.mll ocaml/lib/compiler/parser.mlyfor actual grammar changes.