CAMDL language changes

Breaking and notable changes to the CAMDL language — grammar, dimensions, and semantics — newest first. This is the history an agent needs when a model that “should” compile is rejected: find the change, apply the migration.

Scope: the language surface (what you write in a .camdl file). CLI and fit.toml changes live in the full changelog (camdl docs changelog). For the current syntax, see camdl docs language (the spec).

How to read an entry: what changed, the migration (old → new), and the diagnostic you’ll hit if you use the old form.


2026-11-06 — an observation may not pool a population stratum by a bare name

What. Where a value is scored against data, pooling a stratum declared in dimensions {} must be stated. projected = I on an age-stratified I compiled and scored every row against I_child + I_adult — one reporting rate across age groups, asserted silently. It is now E280, the code incidence(infection) on a stratified family has had since 2026-06-10. The same holds for a bare name anywhere in the projection (I / N_tot, prevalence(of = I, among = …), sum(a in age, I[a] + R)) and for a let whose body pools by a bare name: let N = S + I + R read by a projection is E280 at the use. Model dynamics are unchanged — a bare I in a rate, or a let read only by rates, is the total as before. Residence structure created by via erlang may still be omitted (I on a staged, otherwise unstratified compartment pools its stages); hand-rolled staging declared in dimensions {} is a population stratum and may not.

Migration — IR-identical. State the pooling; the value is unchanged:

  • projected = I → projected = sum(I), or sum(a in age, I[a])
  • let N = S + I + R read by a projection → let N = sum(S + I + R), or sum(N) at the use
  • on an indexed stream prev[a in age], if each row is its own stratum, projected = I was scoring every row against the total — index it, projected = I[a] (a different, and usually the intended, model)

Diagnostic.

error[E280]: observation 'prev': the bare name `I` pools 'I' over age into the scored value without saying so
  = hint: pooling a population stratum where a value meets data is a modelling decision, so state it:
          write `sum(I)` for the total, or `sum(a in age, I[a])` to reduce each axis by name

2026-11-06 — prevalence(X) is removed; prevalence(of = …, among = …) is a checked proportion

What. prevalence(X) returned exactly X — the same projection, a count — under a name that promised a proportion, and prevalence(X1, X2) returned X1 + X2. Both forms are removed (E356). prevalence now takes two keyword arguments and means the proportion: prevalence(of = X, among = Y) is X / Y, the same value and IR, plus three checks plain division does not get — every cell of reads must be one among reads (E357), both sides must collapse the same dimensions (E358), and neither side may read a flow (E359). Plain division stays legal and unchecked. prevalence has no pooling argument: sum pools, on each side.

Migration — mechanical, IR-identical. Drop the call; the value and the IR are unchanged:

  • projected = prevalence(I) → projected = I (on a stratified I, projected = sum(I) — see the entry above)
  • projected = prevalence(I[child]) → projected = I[child]
  • projected = prevalence(Y1, Y2) → projected = Y1 + Y2

Migration — the proportion form changes the projection’s dimension, and is a modelling decision. Rewriting prevalence(I) as prevalence(of = I, among = N) is not the same stream: a count becomes a dimensionless fraction, so every likelihood argument that reads projected must be rechecked. A proportion into a count family (poisson(rate = …), neg_binomial(mean = …)) is E304; it belongs in a probability slot — binomial(n = tested, p = projected), beta_binomial, beta(mean = …) or bernoulli(p = …) — and the n the data supplies is the number tested, not the model’s population. Which likelihood the stream should have is a judgment about the surveillance system, so this cannot be applied mechanically: keep the count form when the data are counts, and move to the proportion only when the column is a proportion (or a positives-out-of-tested count).

Diagnostic.

error[E356]: observation 'prev': `prevalence(I)` was removed — it returned a count, not a proportion
  = hint: for the same value, drop the call:
              projected = I
          for an actual proportion, name the denominator:
              projected = prevalence(of = I, among = <denominator>)

2026-11-07 — Units in bounds, tables and times are checked

What. Three places held a unit literal or a unit annotation that the dimension check never saw, so a value in the wrong dimension was read as a bare number. A parameter bound (g : rate in [0.05 'days, 1] gave a lower bound of 0.05 per day). A table’s unit annotation (mu : age 'per_day = [cov, cov] let its cells take whatever dimension their use implied). And every time the compiler reads as a number: a schedule’s every, from, to and at_day, a trigger window, a reactive intervention’s after and cooldown, and the simulate and output times (after = 21 'per_day was 21 days). Each is now checked; a time or a bound in another unit of the right dimension is converted as before.

Migration. Write the value in its own dimension (after = 3 'weeks, in [0.05 'per_day, 1 'per_week]), or as a bare number in model units; give a unit-annotated table cells of the annotation’s dimension.

Diagnostics. E316 for a bound, E302 at the table value, E315 for a time.


2026-11-06 — A typed let is held to its annotation

What. The kind or dim clause on a let whose body is not a constant (let flow : rate = gamma * I) was accepted and then ignored: the body was inlined with whatever dimension it had. It is now enforced at every use, so a body whose dimension differs from the annotation is refused.

Migration. Fix whichever is wrong. If the body is right, correct the annotation (let flow : positive dim P/T = gamma * I) or drop it; if the annotation is right, fix the body.

Diagnostics. E318, located at the let.


2026-11-06 — One literal rule; typed constants take a dim clause

What. A numeric literal is dimensionless, with two exceptions: zero takes any dimension, and a literal, or arithmetic of literals, that is a whole additive term of a rate, a derivative, a balance, an initial value, a count-valued likelihood argument, an intervention count or a quantity threshold takes that position’s dimension (I = 10, S = N0 - 50, S = 0.95 * 10000, a constant importation beta * S * I / N + 0.5). A ratio of literals no longer takes whatever dimension its context needs: (1/730) * R in a rate was a rate, and is now refused, like 0.0001 * S * I always was. A literal coefficient that carries a dimension is a typed constant, which now accepts the parameter’s dim clause.

Migration.

Old New
@ (1/730) * R let waning : rate = 1 / (730 'days), then @ waning * R
@ (1/10000) * S * I let b : positive dim P^-1*T^-1 = 0.0001, then @ b * S * I
final((1000 - S) / 1000) let N0 : count = 1000, then final((N0 - S) / N0); or final(1 - S / (1000 'count))

A number’s dimension comes from where it stands, never from what it is combined with: guessing it from a neighbour would also accept I + 1e-6 in a rate, a constant added to a count by mistake. Prefer a typed constant for a named quantity, written once; a unit literal suits a one-off value.

Diagnostics. The rate, derivative or initial value reports its usual code (E300, E306, E313); a bare number combined with a dimensioned operand is E302, whose hint names both forms. A typed constant whose unit literals disagree with its dim clause is E282.


2026-11-06 — Unit literals keep their meaning in constants and products

What. Three fixes to how a unit literal is read. 'count now has dimension P wherever it appears, so gamma * 5 'count is a population rate (it was a per-capita rate, refused as E300). A typed constant whose unit literals give it a dimension other than its kind’s is refused: let tau : rate = 30 'days was accepted as the number 30.0 of kind rate, with the literal’s dimension dropped. And a parenthesized unit literal after / is no longer E107: I / (5 'days) is unambiguous and compiles; I / 5 'days is still E107.

Migration.

Old New
let tau : rate = 30 'days let tau : duration = 30 'days, or let r : rate = 1 / (30 'days)
let n : count = 5 'per_day the kind the value has

Diagnostics. A typed constant whose unit literals disagree with its kind is E282.


2026-11-05 — an indexed transition family whose members are identical is refused

What. Every member of an indexed transition family is its own flow. A family whose binder appears in neither its compartments nor its rate expanded to identical transitions and ran the flow once per member, with no diagnostic: inf[r in round] : S --> I @ beta * S on unstratified S, I ran at 2 beta S. A transition family that expands to two members with the same stoichiometry and the same rate is now E422, naming the binder that fails to distinguish them (gh#482). Families that index a compartment or a parameter by the binder, look up a table by it, or use a guard leaving one member are unaffected.

Migration. Use the binder, or drop it. inf[r in round] : S --> I @ beta * S → inf : S --> I @ beta * S if one flow was meant, or inf[r in round] : S[r] --> I[r] @ beta * S[r] with S, I stratified by round if each level has its own flow.

2026-11-05 — an indexed effect family whose members are identical is refused

What. Every member of an indexed intervention, event or reactive family fires. A family whose binder neither its action nor its schedule uses expanded to identical members and applied the action once per member, with no diagnostic: sia[r in round] : transfer(count = n, from = S, to = V) at [10] moved 2n. A family that expands to two members with the same actions and the same schedule is now E421, naming the binder that fails to distinguish them (gh#482). Models that used the binder in the action, the schedule, or a guard leaving one member are unaffected.

Migration. Use the binder, or drop it. sia[r in round] : transfer(count = n, from = S, to = V) at [10] → sia : transfer(count = n, from = S, to = V) at [10] if one transfer was meant, or sia[r in round] : transfer(…) at [t_start[r]] if each round has its own date.

2026-11-05 — Brackets after a kind mean size; dimensions are a dim clause

What. A parameter’s dimension is written with a dim clause after the kind: b : real dim T^-1, alpha : positive dim 1, mu : positive dim P/T. Brackets directly after a kind now mean a parameter family’s size (b : real[6], which lands with parameter families), so they no longer give a dimension. A unit literal is no longer accepted on a parameter declaration: it carries a scale, and a parameter’s value is always in the model’s time_unit, so the scale was silently dropped (gamma : positive 'per_year compiled to the same IR as 'per_day, gh#1006). dim is now a keyword.

Dimension inference also became complete for products of unknowns, and forcing arguments and initial conditions are now dimension-checked (E312, E313), so fewer parameters need a dim clause at all.

Migration.

Old New
x : real [1] x : real dim 1
x : real [1/T] x : real dim 1/T
x : positive [P*T^-1] x : positive dim P/T
x : positive 'ratio x : positive dim 1
x : real 'count x : real dim P
x : positive 'per_year x : positive dim 1/T
x : rate 'per_day x : rate

The dimension is unchanged by the rewrite, and so is the IR. A 'per_month or 'per_year parameter’s value was always read per time_unit; if you supplied values on the unit’s own scale, convert them.

Diagnostics. A dimension in brackets is E116; a unit literal on a parameter E117; kind[1] E417 (“a parameter family needs at least two cells”; if it was a dimension, write dim 1); kind[n] with n ≥ 2 E119 until parameter families land; a dim clause on a kind that fixes its dimension E281. Each refusal carries a machine-applicable fix-it under --json-errors. A model that uses dim as an identifier must rename it.


2026-10-29 — bspline forcing with estimable coefficients

What. A new forcing kind, bspline: a clamped B-spline over a window, β(t) = Σₖ cₖ Bₖ(t) (or exp of it with link = log), held at its end value past the window. Knots come from window = [a, b] with n_basis, or from explicit breakpoints; window ends may be dates or observation anchors. Coefficients may be parameters and carry emitted gradients, so PGAS with NUTS, IF2 and the particle filter can estimate them. periodic_spline coefficients are now live parameters too, and periodic_spline accepts link. A run whose horizon passes the window end prints the notice spline-held-past-window (language spec, “B-spline forcing”).

Guidance. Keep n_basis small, five or six. Explicit coefficients have independent priors, so nothing ties neighbouring coefficients together: with twelve, the posterior median of β(t) wandered up to 0.55 in log β from the truth on the benchmark outbreak (docs/dev/notes/2026-10-29-explicit-bspline-measurement.md).

Known limitation: stuck chains under the default starts. With starts = "from_prior" (the default when every coefficient has a prior), some chains settled in a region that fits the data far worse and stayed there; β(t)’s R̂ was 1.18–1.50 on the benchmark recovery datasets, and fit summary flags it. Workaround: start the chains from distinct good points, for example from IF2 fits (starts = { from_mle = "<IF2 fit>" }, or one from_params fit per chain), and give IF2 an explicit small rw_sd (0.05 on the coefficients; the automatic step can make every particle infeasible).

Not yet available. coefs = smooth(level, slope, scale, modes, per), a penalized form with a calibrated prior on curvature, compiles but every camdl command refuses it: “forcing beta_t: coefs = smooth(...) is not yet available in this release”. Use explicit coefficients.

Also. An estimated real declared without a range and given no start no longer fails fit run with “has no value”: its placeholder start is a draw from its prior.

Diagnostics. E408 (knot and coefficient shape), E409 (unknown argument), E411 (unknown link), E301 (a dimensioned coefficient under link = log), E600 (a parameter in a knot).


2026-10-27 — a typed constant let is a model constant, not a parameter

What. A typed let with a constant body (let omega : rate = 0.01) compiles to the IR’s constants section and is read through a constant reference; it is no longer an IR parameter (IR 0.41). It holds its declared value in every run. No [fixed], [estimate], --fixed, --fixed-file, --param, --params, --sweep, draws column, camdlc --set or scenario set can name it, even at its declared value, and fit run no longer demands a [fixed] entry for it (gh#965). The dimension checker still reads its declared kind. camdl fit summary --parameters lists the model’s constants, with their values and prose, under their own heading after the parameter legend.

Migration. Delete the constant’s line from every fit config’s [fixed], params file and scenario. A constant you do want to set from outside becomes a parameter: let omega : rate = 0.01 → omega : rate in parameters { }, with its value supplied by [fixed] or a scenario.

A fit’s mle_params.toml written before this change lists the constant beside the parameters. Used as --fixed-file or --params, its entry is ignored, with an info notice, when the file’s values still match its [provenance] content_hash and the entry equals the model’s declared value. A differing value, an edited file, or a hand-written file naming a constant is refused.

Diagnostic. The command’s ordinary unknown-parameter refusal, with this hint:

hint: `omega` is a constant of the model (`let omega : rate = 0.01`), not a parameter; declare it in `parameters {}` to set it from outside

In a model’s own scenarios { }, E268 carries the same advice.

2026-10-25 — every chain index a fit shows counts from 0

What. A fit’s chains are numbered 0 … N-1 on every surface that names one: the chain_N/ directories, the progress and refusal lines, chain_starts.tsv’s chain_id, fit_state.toml’s best_chain and chain_eval_ids, run.json’s inputs.best_chain, mle_params.toml’s [provenance] chain, every fit summary table (text, json, md, latex), --exclude-chains, the chain column fit predict --by-chain adds, progress.json’s per-chain rows and cross_chain_compat.json’s chains. They were numbered from 1, while the chain column of draws.tsv and trajectories.tsv — the join key — was numbered from 0, and the conversion between the two bases was written by hand at every site. The same off-by-one was fixed site by site four times (gh#781, gh#823, gh#910, gh#972) and was still open at three more (gh#978, gh#979, gh#981, gh#913). With one base there is nothing to convert. draws.tsv, trajectories.tsv and simulate’s draw, replicate and point_id columns already counted from 0 and do not change (docs/camdl-run-spec.md §2, “One index base”; gh#990).

Two labelling defects close with it: the scout-convergence refusal, the per-chain loglik-eval table of fit summary --format json|md|latex and the per-chain chains table now label every row by the chain’s own number, read from chain_eval_ids or from the chain_N/ directory, rather than by its position in a vector that IF2 has already dropped PF-degenerate chains from (gh#978, gh#981, gh#913).

Migration. Every consumer that joins on a chain number shifts by one: --exclude-chains 3,5 now names the chains that were 4,6; a script that opened chain_<k>/ for the chain fit summary called k opens the same directory, because both moved together; a script that opened chain_<c+1>/ for a draws.tsv row with chain = c now opens chain_<c>/. A stored fit written before this change — chain_1/ … chain_N/ with no chain_0/ — is refused with a message naming this change, rather than read with every chain shifted, by fit summary (every --format), fit predict, fit table --quantity, compare when it derives the fit’s prequential (an explicit prequential.json is read as given), simulate --draws posterior, and fit run --resume, which refuses the base leaf before any chain state is copied; re-run the fit. A plain fit table without --quantity reads no chain and lists the fit as before. progress.json’s numbering and cross_chain_compat.json’s chain_numbering now read counted from 0, matching the chain_N/ directories and draws.tsv's chain column.

Diagnostic. error: --exclude-chains: chain 4 not in this fit (chains 0..3) on a four-chain fit under the old habit of naming the last chain 4; and, on a leaf written before the change: this leaf was written by a camdl that counted chains from 1 (it holds chain_1/ and no chain_0/) … Re-run the fit.


2026-09-09 — a projection may divide two flow sums; a projection that divides transition rates is refused

What. A stream that measures “of this window’s events, what fraction were of kind A” — community deaths among classified deaths, positives among tests, deaths among cases — is the ratio of two flows accumulated over the same window. It now compiles: projected accepts exactly one division whose two sides are each a unit-weighted flow sum (a single incidence(tr), added terms, or sum(v in dim, incidence(tr[v]))), and lowers to the new flow_ratio IR projection. The stream is interval-valued — it declares covers like any incidence stream (E350 without it) — and dimensionless, so it pairs with binomial, beta_binomial, beta and bernoulli; a count family refuses it (E304). A window in which no denominator event occurred projects NaN: a row with n = 0 scores exactly 0, a row with n > 0 is refused and the refusal names the argument.

Two things tighten in the same change. A projection that divides transition rates — mu_c * I / (mu_c * I + mu_f * H), the only spelling that compiled before — is an instantaneous hazard ratio read at the row’s time label, not the share of the window’s events. It fitted without complaint and was 7–27 % below the window fraction on a simulated epidemic, worst at the peak. It is now E352, which names both spellings. And a where guard that prunes every level of one side of a ratio is E351 rather than a silent x / 0.

Migration. Old, an instant reading that fitted silently:

comm_frac {
  columns     { time : time, comm_deaths : count, n_deaths : count }
  projected   = mu_c * I / (mu_c * I + mu_f * H)
  comm_deaths ~ binomial(n = n_deaths, p = projected)
}

New, the fraction of the window’s events — the likelihood line does not move, and covers is the one new line (which day a weekly label names: spec §12.1.1):

comm_frac {
  columns     { time : time, comm_deaths : count, n_deaths : count }
  covers      = ending_on(time, 7 'days)
  projected   = incidence(die_comm) / (incidence(die_comm) + incidence(die_fac))
  comm_deaths ~ binomial(n = n_deaths, p = projected)
}

If the instantaneous hazard ratio really is the quantity meant, say so — projected = prevalence(mu_c * I / (mu_c * I + mu_f * H)) — and declare no covers.

A term in the rate ratio that is not a transition’s rate — an import rate iota added to a hazard, say — has no flow to accumulate. Give it a transition of its own (import_deaths : --> Dc @ iota accumulates as incidence(import_deaths) on both sides), or drop it if it was only there to keep the denominator away from zero: a zero denominator is scored as NaN under the rule above, not divided by zero.

Diagnostic. The old spelling is refused, with the two replacements in the hint:

error[E352]: observation 'comm_frac': `projected` divides transition rates — `mu_c * I` is
  the rate of `die_comm`, `mu_f * H` is the rate of `die_fac` — so it is read at the row's
  instant, not accumulated over its window
  = hint: if the column is the fraction of the window's events, write the flows and declare `covers`:
              projected = incidence(die_comm) / (incidence(die_comm) + incidence(die_fac))
          if you mean the instantaneous ratio of hazards, say so:
              projected = prevalence(mu_c * I / (mu_c * I + mu_f * H))

Everything else E341 refused stays refused, by name: a weight on either side (rho * incidence(a) / …), a state read on either side (incidence(a) / N), a constant numerator, subtraction inside a side, and a second division.


2026-09-06 — condition_from and W329 are removed

What. The fit.toml key condition_from — a string or a per-stream table, mirrored by --condition-from on fit run, pfilter and profile — opened an undeclared incidence stream’s first bin somewhere other than simulate.from, so that a warm-up before the data was simulated but not scored. W329 was the guard that refused a fit whose first bin was anomalously wide without it. Every incidence stream now declares what each row covers (the entry below), and a declared first period opens where it says, so neither has a job left: the warm-up before the first row is discarded without a separate setting. Both are removed.

Migration. Delete the key (and the flag). Give the stream its covers = … line or window_start/window_stop columns; the first period’s start is where scoring begins. A first period opening before simulate.from is an error naming both times — move simulate.from back, or drop the rows the run cannot cover. To score the warm-up deliberately, state it in the file: a first row whose window_start is simulate.from.

Diagnostic. A leftover key, at the top level or under [data], is a hard error rather than an unknown-field rejection:

condition_from = ... is no longer a fit.toml key: an incidence stream now states
what each row covers (`covers = ...` or window columns in the model), and a
declared first period opens where it says, so the warm-up before the first row
is discarded without a separate setting. Delete the key.

A leftover --condition-from flag is an unknown-argument error from the CLI. An incidence stream that declares neither covers nor window columns is E350 (the entry below).


2026-09-05 — an observation stream can state what each row covers

What. An incidence stream can now declare the period each of its rows covers, rather than having one inferred from the spacing between rows. Four forms, and covers becomes a reserved word:

cases {
  columns   { time : time, cases : count }
  covers    = day(time)                    # row D covers [D, D+1)
  projected = incidence(infection)
  cases     ~ poisson(rate = projected)
}
form the row covers D = 11 Jul
covers = day(time) [D, D+1 day) [11 Jul, 12 Jul)
covers = starting_on(time, 7 'days) [D, D+7 days) [11 Jul, 18 Jul)
covers = ending_on(time, 7 'days) [D−6 days, D+1 day) [5 Jul, 12 Jul) — 11th in
covers = closing_at(time, 7 'days) [D−7 days, D) [4 Jul, 11 Jul) — 11th out
window_start + window_stop columns exactly [start, stop) as written

ending_on and closing_at differ by a whole day. “Week ending 11 July” includes the 11th; “closing at 11 July” makes the 11th the boundary, excluded from this row and opening the next. closing_at is the reading of a file whose labels are closing boundaries — pomp’s accumulator convention, and what camdl’s own simulate --obs has always written.

The width in starting_on / ending_on is a fixed duration. A width that varies row to row is stated with the window columns instead — there is deliberately no form taking the width as a data column, since that would say the same thing while needing its own convention about whether the labelled day is counted.

window_start / window_stop are new columns { } roles. They are the declaration on their own — a stream carrying them does not also write covers = ... — and they are the only form that can state a gap between consecutive rows, or a width that changes row to row.

Migration. Declaring is required: an incidence stream with neither covers = … nor window columns is E350, whose hint names every form. There is no default, because no default is right — the same date column means three different spans depending on the source. A prevalence or state-reading stream declares nothing; a covers line or window columns there is E348.

Whether declaring changes your numbers depends on what your file meant. An undeclared stream was scored over (previous row, this row] — a window nobody wrote down. For a file whose labels are closing boundaries (anything simulate --obs wrote, anything derived from pomp) that window was right, and closing_at(time, <spacing>) states it with no change to the fitted numbers. For a file labelled by the day the count describes, that window read the count one day early; day(time) moves the scoring onto the day the label names, and the fitted numbers move by one bucket — the correction, not a regression. Say which file you have; do not shift a time column to make a form fit.

A declared first period opens where it says, not at simulate.from. A stream’s first bin opens at its first period’s start, and the warm-up before it is simulated but not scored. The fit.toml key condition_from, which used to hand-place that boundary, and the W329 guard that demanded it, are removed (the 2026-09-06 entry above). A first period opening before simulate.from is an error — that time is never simulated.

ic_free = true now omits the first observed row. IC-free inference leaves the first observation out of the log-likelihood — the term that pins the initial state. The omission is keyed on the first row that carries a value, not on the first boundary of the axis: a declared first period opening after simulate.from puts a boundary there where nothing is scored, and a file that begins with an NA row has a hole there. A fit whose data began with a hole used to skip that hole’s zero and count the real first row; it now skips the real first row. This is the one number in the migration that moves for a reason other than a window shift, and only for ic_free fits with a leading hole; the new value is the one the option describes.

A gap between rows means different things under different forms. Under day / starting_on / ending_on every row’s window follows from its label, so two consecutive rows whose windows do not touch can only mean a row is missing — and a missing row is exactly what used to widen the next bin silently. It is now refused, naming both windows and the uncovered span; write the unobserved row as NA (a hole: no likelihood term, but the window is kept). Under window_start/window_stop a gap is a statement: the flow over the uncovered span belongs to no row and is discarded, the same as an NA row closing that bin unscored.

Diagnostics. E347 — more than one set of temporal columns (a : time column and window columns together, half a window pair, covers alongside window columns), or covers naming a column that is not the stream’s time column. E348 — covers or window columns on a stream whose projected reads an instant; a state reading has no window. E349 — a malformed form: unknown name, day(...) given a width, starting_on/ending_on missing one, or a width naming a data column rather than a duration (use the window columns for that).


2026-09-04 — a timezone offset on a date is rejected, not discarded

What. A trailing zone designator on an ISO date — Z, z, +HH:MM, -HH:MM — used to be accepted and silently dropped, reducing the string to its civil date. It is now a hard error, in a date() literal, in origin, and in a --data time column alike:

origin = date("2026-07-01+01:00")   # was: origin = 2026-07-01.  Now: E223
time              cases
2026-07-02+01:00  22               # was: read as 2026-07-02.  Now: a load error

camdl models civil calendar dates and has no timezone semantics. A bare date is already zone-free and unambiguous, so an offset is information camdl has chosen not to represent — and accepting the cell while deleting the offset is the one response that cannot be right, because it changes what the data says without telling anyone.

Migration. Strip the offset and supply the civil date you mean: 2026-07-02+01:00 → 2026-07-02. If rows in one file carry different offsets, decide upstream which civil day each observation belongs to; camdl cannot make that call for you, which is precisely why it now refuses to guess. Nothing about multi-country pooling changes — civil dates from any set of zones still align on one axis, they just have to arrive as civil dates.

Diagnostic. E223 for a model-side date() / origin; a located loader error naming the line, the cell and the offset for a --data column. Both name the offset and the civil date to write instead.

Not +0.25d. The fractional-day suffix in camdl’s own --dates output is a different trailer with a different future (gh#839); it is not a zone designator and does not get this message.


2026-08-25 — a #' block at the top of a file documents the MODEL

What. A #' doc comment had to attach to a declaration; at file scope it was a syntax error (E001). A #' block in the file’s leading comment header — every line above it blank or a comment, so no declaration has started — now documents the model itself:

#' National SEIR with a facility-death delay: cases and deaths come from one
#' confirmation flow, deaths lagged through an isolation compartment.
#' @base bvd_national_twocfr.camdl
#' @adds nothing
#' @changes f_cfr_unret becomes free with a beta(2,2) prior

time_unit = 'days

It reaches camdlc inspect, both camdl render projections (doc in the JSON shape), the fit.meta.json sidecar, and camdl fit summary.

Inside this block, and only here, the tag vocabulary is open: @symbol and @ref behave as they do on a declaration, and any other @tag line is kept verbatim as free text. @base / @adds / @changes are a convention for recording where a variant came from; nothing validates them, and no check is made that the parent file exists. On a declaration the vocabulary stays closed (@default 0.3 is still E111).

Migration. None required — this only accepts what used to be rejected. A model header written as ordinary # comments keeps working; promoting it to #' is what makes it readable by every tool. Two things to know:

  • A #' block anywhere else at top level is still E001. The rule is positional, not “anywhere before the first declaration you like”.
  • In a file whose first declaration is a top-level let, the header block documents the model and that let is undocumented. Put the let after another declaration if you want a doc on it.

Not description. description = "…" is a string inside the model, so it is part of run identity and editing it re-keys every fit. The #' block rides the IR envelope, outside the hashed model, so correcting it costs no fits — it is where a model’s real description belongs.


2026-08-24 — init { } accepts ~: an initial condition may be DRAWN

What. init { } used to accept only compartment = expr. It now also accepts compartment ~ D(kw = ...), which says the compartment starts at a draw from D rather than at a computed value:

init {
  I ~ poisson(rate = I0)   # the number introduced is a draw around I0
  S = N0 - I               # reads the DRAWN I, so S + I = N0 on every draw
}

Nothing about = changed, and no existing model needs to move.

The admissible laws are chosen by the compartment’s kind: an integer compartment by poisson(rate = ..), binomial(n = .., p = ..) or neg_binomial(mean = .., r = ..); a : real compartment by normal(mean = .., sd = ..). The entries are evaluated in dependency order, so a deterministic entry reads what the laws above it drew.

Migration. None required. Where a model previously wrote a point estimate of an introduction size (init { I = I0 }) and let the filter absorb the uncertainty, I ~ poisson(rate = I0) states that uncertainty instead — and under pgas the law’s parameters are then estimated with log p(x₀ | θ) in the target, reported per sweep in the trace’s new initial_state_ll column.

Diagnostics.

  • E343 — the named distribution is an observation likelihood, not an initial-state law (bernoulli, beta, beta_binomial). Those describe a measurement of a compartment, not the compartment itself.
  • E344 — the law does not match the compartment’s kind (a normal on an integer compartment, a count law on a : real one), or binomial’s p names a parameter whose declared kind is not probability.
  • E345 — a law on the balance { } target. The balance stage overwrites its compartment after every substep, so the draw would be discarded.
  • E346 — a law on a compartment that via hyper_erlang(...) splits into weighted branches. Seed the branch first-stage cells directly instead.

Also. PGAS no longer INFERS an initial-state density. It used to attach a Binomial term to whichever compartment a finite-difference probe found moving with an estimated parameter — a decision that depended on the chain’s starting draw, so two chains of one fit could carry different targets (gh#719). That probe is gone: the term is declared or it does not exist. A fit run before this change on a model where the probe fired is not numerically comparable to one run after it.


2026-08-20 — incidence(...) takes exactly one transition; add flows with +

What. A projection head incidence(...) names one flow. Giving it several positional arguments is now E203, and giving it none is now E250. Both used to compile. incidence(a, b) read only a and discarded every later argument without a diagnostic, so a model observing two death flows fitted a likelihood against one of them, at plausible-looking counts (gh#669). incidence() kept an internal sentinel name and resurfaced downstream as an E507 about an unknown transition named '?', which is nothing you wrote.

The comma form is reachable because the sibling head does sum: prevalence(X1, X2) desugars to X1 + X2 and still does. incidence(...) does not, because a comma inside one head reads as “project these together” while saying nothing about whether they are disjoint.

Migration. Add the terms explicitly. incidence(a) + incidence(b) is a supported projection — it lowers to the same cumulative_flow_sum the family sum produces, and the two compose.

projected = incidence(die_community, die_facility)   # old: observed only die_community

projected = incidence(die_community) + incidence(die_facility)   # new
projected = sum(a in site, incidence(die[a]))                    # new, if one family

The union must be disjoint — naming one flow twice counts its events twice and is E342. Two terms that look different can be the same transition once the stream’s binder is substituted: inside cases[a in age], incidence(die[a]) + incidence(die[child]) collides on the child row.

A per-stratum weight inside the projection (sum(a in age, rho[a] * incidence(die[a]))) is a different object and is not supported yet (E341); a single reporting rate belongs in the likelihood.

Diagnostic.

error[E203]: observation 'deaths': `incidence(...)` projects exactly one flow,
             but 2 were given (die_community, die_facility) — only
             'die_community' would be observed, and 'die_facility' would go
             unobserved
error[E250]: observation 'cases': `incidence(...)` needs a transition to project

---

## 2026-08-20 — `neg_binomial(mean = …)` must be a count, not a rate

**What.** The `mean` of `neg_binomial(...)` — and of the same family reached
through `zero_inflated(base = neg_binomial(...), pi = ...)` — is now dimension-
checked as a **count** (`[P]`): the expected number of events over the reporting
interval, exactly like `poisson(rate = …)` and `binomial(n = …)`. It was
previously inferred and discarded, while the `r`/`dispersion` argument beside it
was checked, so a `projected` holding a transition **rate** (`P·T⁻¹`) reached
the likelihood with no diagnostic. `projected` accepts an arbitrary expression
whose dimension is inferred, so nothing upstream objected either.

Why it matters: at a one-day reporting step a per-day rate and a one-day
accumulated count coincide numerically, so the wrong likelihood is close enough
to look like a refactor; at a weekly step it is wrong by roughly the window
length.

**Migration.** Accumulate the flow over the reporting interval instead of
evaluating its instantaneous rate.

```camdl
observations {
  weekly_cases {
    columns       { time : time, weekly_cases : count }
    projected  = gamma * I                  # old: a rate, P·T⁻¹ — now E304
    projected  = incidence(recovery)        # new: the count over the interval
    emit_schedule = every 7 'days
    weekly_cases ~ neg_binomial(mean = rho * projected, r = k)
  }
}

Scaling the projection by dimensionless factors is unaffected — mean = rho * (1.0 + gamma * q / tau) * projected is still [P] and still compiles. A bare numeric literal (mean = 100) is a count by context and is exempt, as it already was for poisson’s rate and binomial’s n.

Diagnostic.

error[E304]: observation 'weekly_cases': NegBinomial `mean` must have the
             dimension of a count (expected events over the reporting interval)
  = note: expected dimension P (population count), got P*T^-1 (population-level rate)

2026-08-13 — a scenario’s simulate {} must set to, and only to

What. Inside scenarios { … }, a simulate {} block overrides the end time and nothing else. from now joins dt and integrator as E106, and a block that omits to — including an empty simulate { } — is E106 too. Previously from parsed and was silently discarded, and an absent to fell back to 0.0, so simulate { from = 10 'days } compiled to a scenario horizon of zero. That was inert only because the runtime discarded the field; it is being wired up (gh#561), which would have run those scenarios to t = 0.

The rule: a scenario overlays what leaves the trajectory prefix intact. to qualifies; from/dt/integrator re-tile the substep grid and would mix discretization error into every between-arm comparison.

Migration.

scenarios {
  a { simulate { from = 10 'days  to = 120 'days } }  # old: `from` dropped
  a { simulate { to = 120 'days } }                   # new: set `from` once, top level

  b { simulate { } }                                  # old: compiled to t_end = 0.0
  b { simulate { to = 120 'days } }                   # new: state the horizon
  b { }                                               # new: or drop the block
}

Diagnostic.

error[E106]: `from` is not a per-scenario override: set it once in the
             top-level `simulate {}` block
error[E106]: a scenario's `simulate {}` block must set `to`: it overrides the
             scenario's end time and nothing else

Also new. W106 warns when a scenario’s to is inert: under an explicit at = [...] output list, when it selects exactly the times the model horizon already would, so neither the trajectory nor any quantities {} reduction changes.


2026-08-10 — a reduction must use its binder

What. sum(v in d, …) where neither the body nor the where predicate mentions v is rejected with E334, located at the binder.

Migration.

sum(b in age, I[a])     # old: accepted, and silently doubled on a 2-level age
sum(b in age, I[b])     # new: index the body by the binder
I[a]                    # new: or drop the reduction

Diagnostic.

error[E334]: transition 'infection': reduction binder 'b' is never used, so
             `sum(b in age, …)` adds 2 identical copies of its body
  8│  … @ beta * S[a] * sum(b in age, I[a]) / N[a]
   │                        ~~~~~~~^
  = hint: index the body by 'b' (e.g. `… I[b] …`), or drop the reduction and
          write the body on its own — as written it multiplies by 2

Why. The reduction evaluates its body once per level and adds the results. If the body does not depend on the binder, that is |d| copies of one term — on a two-level dimension, exactly a silent doubling of whatever the term contributes. Nothing said so, and the factor is invisible in the model text.

What is still legal. A binder used only by the where predicate:

sum(q in patch where dist[p,q] < 50 and q != p, 1.0)   # neighbour count

The reduction depends on q through the predicate, which is what makes the result meaningful. Only a binder used by neither the predicate nor the body fires.

A binder that shadows an enclosing binding keeps its own diagnostic, E283; it is a different mistake and is reported first, so one mistake still gets one error.

Increment A5 of docs/dev/proposals/2026-07-31-aggregation-semantics.md.

2026-08-10 — an indexed let must be indexed where it is used

What. A let that declares index binders or a shape may no longer be referenced bare. E299, located at the use.

Migration.

let N[a in age] = S[a] + I[a] + R[a]

… / N          # old: broken two ways, see below
… / N[a]       # new

Diagnostic.

error[E299]: let binding 'N' expects 1 index but was given 0
  8│  infection[a in age] : S[a] --> I[a] @ beta * S[a] * I[a] / N
   │                                                             ^
  = hint: it is declared over [age] — index every axis, e.g. `N[child]`

Why. A bare reference inlined the body with nothing bound for the binder, and what happened next depended on whether the body used it.

If it did, you got one error per reference in the body, none naming the mistake, all naming identifiers you never wrote, with the caret on a let line that is correct:

error[E100]: undeclared name 'I_a'
error[E100]: undeclared name 'R_a'
error[E100]: undeclared name 'S_a'

I_a is the internal mangled name — a was unbound, so I[a] mangled the binder into the compartment name.

If it did not — a body that is a constant or depends only on outer names — there was no diagnostic at all:

let scale[a in age] = 1000.0
…  @ beta * S[a] * I[a] / (N[a] * scale)     # compiled clean

The binder was silently dropped and one shared value used everywhere, where the author asked for one per age band. That is the quiet case, and it is the reason this is an error rather than a lint.

What is unchanged. An unindexed let referenced bare (let N = S + I + R, then N) is untouched, as is a correctly indexed reference. This is about a declaration that promises a family and a use that does not pick a member.

Increment A6 of docs/dev/proposals/2026-07-31-aggregation-semantics.md. Distinct from the 2026-08-05 entry below ([a in aeg], an undeclared dimension in a binder): same mangled-name symptom, different trigger and different site, and that fix did not cover this.

2026-08-10 — I[] on a compartment read is rejected

What. An empty index list on a compartment read is now E204. It used to compile, producing IR byte-identical to the bare name.

Migration.

I[]         # old: accepted, meant the total across all strata
I           # new: the total across all strata
I[child]    # new: one stratum

Diagnostic.

error[E204]: 'I[]' has an empty index list
  = hint: for the total across all strata write `I`; to read one stratum
          write `I[child]`

Why. The brackets look like they select a stratum and select nothing, so a half-finished edit — deleting the index and leaving the brackets — reads as a stratum reference while silently meaning the total. The sibling constructs already rejected the same shape (beta[] → E299, C_age[] → E202, S[] in stoichiometry → E272); the compartment read path was the hole.

A genuine bare name is untouched: projected = I and prevalence(I) still mean the total across all strata. Only brackets you actually typed are rejected.

Corpus impact: zero — no committed .camdl uses the form.

2026-08-09 — #' doc comments attach to let bindings

What. A #' doc comment above a top-level let is now accepted; it was a bare E001 syntax error.

#' total population per patch — the FOI denominator
#' @symbol N
let N[p in patch] = S[p] + E[p] + I[p] + C[p]

This is a widening — nothing that compiled before stops compiling. The migration only runs the other way: a model using the new form is rejected by an older binary, with E001 pointing at the #' line.

Both let forms take one — the plain binding and the : rate-annotated form. The prose surfaces in camdlc inspect --let <name>; @symbol overrides the name camdlc render prints, on the definition line and at every use of it in the derived dynamics. A typed constant let lowers to an IR parameter, so its prose also reaches run.json and camdl fit summary’s legend, exactly as a parameters {} entry’s does.

Why here. A derived quantity is where a modelling assumption hides. let N = S + I + R shows its arithmetic but not whether it is the total population or the force-of-infection denominator, and those are different models. let was the last declaration site with nowhere structured to say so.

Not changed. #' still attaches to a declaration, never to a block keyword — #' above compartments { remains E001, while #' above a compartment member inside the block has always worked. See the spec’s “Doc comments” under §1.1 for the full list of sites.


2026-08-05 — a declaration’s index binder must name a declared dimension

What. infection[a in aeg] where aeg is not in dimensions { } is rejected with E263. This covers every construct that carries an index binder — transitions, lets, init entries, observations, interventions, events, forcings, reactive interventions, quantities.

This is a narrowing, and the reason it matters is the quiet case. An undeclared axis used to DROP OUT of the expansion product, so the declaration expanded as if it had no index at all. What you saw depended on whether the body mentioned the binder.

If it did, you got two errors naming identifiers you never wrote:

dimensions { age = [child, adult] }
infection[a in aeg] : S[a] --> I[a] @ beta * S[a] * I[a]
error[E100]: undeclared name 'I_a'
error[E100]: undeclared name 'S_a'

I_a is the internal mangled name — a was never bound, so I[a] mangled the binder itself into the compartment name. Neither error named the typo.

If the body did not mention the binder, there was no diagnostic at all:

infection[a in aeg] : S --> I @ beta * S      # compiles clean
$ camdlc a6.camdl | jq '.model.transitions[].name'
"infection"                        # not infection_child, infection_adult

One unstratified transition where the author asked for a family — a stratified model quietly running an unstratified one.

Migration. Correct the axis name, or declare it. The diagnostic names the declared dimensions:

error[E263]: 'aeg' is not a declared dimension
  = hint: the index binder `… in aeg` needs a dimension; declare it in
          `dimensions { aeg = [...] }`, or correct the name —
          declared dimensions: age

What is unchanged. A declared dimension that happens to have no levels is a different condition and still drops out of the product; the check keys on “not declared,” never on “no levels.”

Increment A6 of docs/dev/proposals/2026-07-31-aggregation-semantics.md.

2026-08-05 — a reduction binder may not reuse a declared name

What. sum(x in dim, …) where x is also a declared parameter, compartment, let binding, forcing, or table is rejected with E283, located at the binder.

This is a narrowing, though the old behaviour was already an error — just the wrong one, pointing somewhere else. E283 owned binder shadowing but its scope was the four binding sources (a nested binder, the transition binder, a stream index, an enclosing shaped-let index). A binder colliding with a declared global fell outside it, and what you got instead was a claim about a dimension level:

parameters { beta : rate  gamma : rate }
@ beta * S[a] * sum(gamma in age, gamma * I[gamma]) / N[a]
error[E100]: undeclared name 'adult'

adult is declared — it is a level of age, right there in dimensions {}. The binder substitutes each level in for gamma, so the body becomes adult * I_adult, and the substituted identifier resolved against nothing. The hint (“add a declaration in compartments/parameters/…”) pointed at a fix that is not one: declaring a compartment named adult does not help.

Migration. Rename the binder:

@ beta * S[a] * sum(b in age, gamma * I[b]) / N[a]
error[E283]: reduction binder 'gamma' shadows the declared parameter 'gamma'
  8│ … * sum(gamma in age, gamma * I[gamma]) / N[a]
   │       ~~~~~~~~~~~^
  = hint: rename the binder — it may not reuse the name of a parameter,
          compartment, `let` binding, forcing, or table.

What is unchanged. A binder named after a dimension level (sum(child in age, …)) is still legal — it does not produce this failure, and W103 already covers the let-versus-level case as a warning.

gh#495.

2026-08-05 — a table declared over an undeclared dimension is now an error

What. Every axis in a tables { } declaration must name a dimension declared in dimensions { }. An undeclared axis is rejected with E263, located on the axis list.

This is a narrowing, and a sibling of the sum(...) change below: the same “an unknown axis has no levels” collapse, one declaration site further up. It used to be caught only at a use — indexing the table with a level in the bad axis — so a table never indexed on that axis compiled clean:

dimensions { age = [child, adult] }
tables { C : age × aeg = [[1.0,2.0],[3.0,4.0]] }   # `aeg` is a typo for `age`

Two things rode on the silence. The inline cell-count check (E202) skips any table with a zero-size axis, so C above was never checked against its declared shape either. And a forcing whose time_dim is the phantom axis lowered to an interpolation with zero knots — which evaluates to 0 at every time:

tables  { temp_data : patch × week = [[1.0,2.0,3.0],[4.0,5.0,6.0]] }
forcing { temperature[p in patch] : interpolated 'ratio {
            table = temp_data  time_dim = week  method = linear } }

compiled to {"times": [], "values": []}. The Rust loader did reject that, but late and with no source location.

Migration. Correct the axis name, or declare it. The diagnostic names the declared dimensions:

error[E263]: 'aeg' is not a declared dimension
  6│ tables { C : age × aeg = [[1.0,2.0],[3.0,4.0]] }
   │              ~~~~~~~~~^
  = hint: declare it in `dimensions { aeg = [...] }`, or correct the name —
          declared dimensions: age

What is unchanged. A declared dimension with no levels is not this error, and an axis that is declared but does not match the table’s shape keeps its own diagnostics (E202 for the cell count, E229 for a forcing’s time_dim). One undeclared name now produces one E263 even when several sites can see it; the declaration is the one you get.

gh#490, gh#491.

2026-08-04 — sum(...) over an undeclared dimension is now an error

What. A reduction whose axis is not declared in dimensions { } is rejected with E263. It used to compile silently and evaluate to 0.0, because an unknown axis and a where guard that selected no levels both resolved to an empty domain — and an empty reduction is legitimately zero.

This is a narrowing: a model with a typoed axis compiled before and does not now. That is the point. In a force of infection the folded zero removed transmission entirely and the model still ran, producing a flat epidemic that looks like a result:

dimensions { age = [child, adult] }
@ beta * S[a] * sum(b in aeg, I[b]) / 100.0   # `aeg` is a typo for `age`

was beta * S_child * 0.0 / 100.0. In an observation projection it was worse — projected = sum(b in aeg, incidence(infection[b])) lowered to a literal const 0.0, so a fit scored every observation against an expected count of zero.

Migration. Correct the axis name, or declare it. The diagnostic names the declared dimensions:

error[E263]: 'aeg' is not a declared dimension
  7│ … @ beta * S[a] * sum(b in aeg, I[b]) / 100.0
   │                       ~~~~~~~^
  = hint: declare it in `dimensions { aeg = [...] }`, or correct the name —
          declared dimensions: age

What is unchanged. An empty domain that comes from a where guard is still legal and still zero — an isolated patch with no in-radius neighbour contributes nothing. That case now warns (W202) rather than erroring, and only when the guard is what emptied it. A declared dimension with no levels is likewise not this error.

gh#488.

2026-07-27 — bare stratified transfer(from = S, to = V) expands per stratum

What. A transfer endpoint may now name a bare stratified compartment. The action expands to one atomic transfer per cell, paired cell-for-cell, in every block that carries actions — interventions {}, events {}, and reactive_interventions {}. With age = [child, adult], vacc : transfer(fraction = cov, from = S, to = V) at [1] emits FractionTransfer(S_child, V_child, cov) and FractionTransfer(S_adult, V_adult, cov).

This is a widening — the bare form was previously rejected with E264, so no model that compiled before is rejected now. The explicitly indexed form (vacc[a in age] : transfer(from = S[a], to = V[a], …)) is unchanged, as are single-cell transfers across strata (from = S[child], to = S[adult]) and into an unstratified compartment (from = S[child], to = V).

Four forms that used to report E264 now report a specific code.

  • Endpoints with different shapes — one stratified and one not, different dimensions, or the same dimensions in a different order — are E237. The check compares declared dimension vectors, so two dimensions sharing level names (age = [low, high] vs risk = [low, high]) do not silently pair.
  • count = on a bare stratified transfer is E238: a count is absolute, so fanning it out would move count individuals out of every stratum. Migration. transfer(count = n, from = S, to = V) → transfer(fraction = f, from = S, to = V), or vacc[a in age] : transfer(count = n, from = S[a], to = V[a]).
  • A bare endpoint inside an indexed family is E239: it would fan out within each instance, transferring every cell once per instance. Migration. vacc[a in age] : transfer(from = S, to = V) → vacc[a in age] : transfer(from = S[a], to = V[a]), or drop the binder.
  • A bare staged compartment (via erlang(...), spec §9.4) as an endpoint is E237 rather than E264. Migration. transfer(to = E) → transfer(to = E_s1).

2026-07-12 — indexed parameters accept multiple dimensions

What. A parameter declaration may now index over more than one dimension, expanding to one scalar parameter per cell of the cartesian product:

parameters {
  mu[village, season]         : rate               # → mu_kwaru_wet, mu_kwaru_dry, …
  m[village, season, species] : positive 'ratio     # → m_kwaru_wet_gam, …
}
let C[v in village, s in season] = mu[v,s] * m[v,s,gam]

This is a widening — no migration needed. A single-dimension declaration (R0[patch]) is unchanged, and every existing model compiles identically. Cell names mangle <base>_<level1>_<level2>_… in declaration-dim order; a use (mu[v,s]), a scenario key (mu_kwaru_wet), and an init/--param-vec override all address a specific cell by that name. Each index axis must name a declared stratify dimension.

Diagnostics. A repeated axis (mu[village, village]) is E331; an unknown or empty index dimension is E330; under-indexing a use (mu[v] on a 2-D param) remains E299.

2026-07-10 — forcing selectors quoted, method a bare enum, until → to

Three surface changes land together (gh#423). All are signposted: the old form is rejected with a diagnostic that names the new form.

What (1) — forcing file columns are quoted strings. In a forcing { } block the file selectors — data, time_col, value_col, key_col — must now be quoted strings; a bare identifier is rejected. This carries the reader’s rule in the syntax: quoted = outside the model (a file or a file column), bare = inside the model (a parameter, table, dimension, or a closed enum). The model-name arguments table and time_dim stay bare (they name a tables {} entry / a dimension), and quoting them is now rejected. The compiled IR is unchanged — selectors are consumed at expand time and never cross the IR seam.

Migration. time_col = t → time_col = "t"; value_col = C → value_col = "C"; key_col = village → key_col = "village"; data = f.tsv → data = "f.tsv". Leave table = mymatrix and time_dim = week bare.

Diagnostic. A bare file selector → E410 (value_col must be a quoted column name — write value_col = "C"). A quoted table/time_dim → E412 (table names a model construct — remove the quotes).

What (2) — method is a bare, validated enum. The interpolation method is now written bare: method = linear (also constant, spline), matching every other closed enum in the language (integrator = rk45, format = tsv). The quoted form is rejected, and — new — the value is validated: only linear | constant | spline are accepted. Previously an unquoted-vs-quoted method compiled either way and an unknown value (method = "cubic_spline") was accepted by the compiler but failed to deserialize at the simulation boundary. The spec’s cubic_spline / pchip never existed in the runtime and are removed.

Migration. method = "linear" → method = linear. If you used "cubic_spline" or "pchip", switch to spline.

Diagnostic. A quoted method → E411 (method is now a bare enum — write method = linear); an unknown value → E411 (unknown interpolation method 'banana' — expected linear, constant, or spline).

What (3) — the recurring window end is to, not until. In a transfer(...) { … } / add(...) { … } recurring schedule body, the window end was spelled until, while a set block and simulate { from … to … } spelled the same slot to. to is now the only spelling.

Migration. { every = 30 'days from = 0 'days until = 90 'days } → { every = 30 'days from = 0 'days to = 90 'days }.

Diagnostic. until = … → E113 (`until` is now `to`).


2026-07-05 — ASCII * accepted as an alias for the × dimension separator

What. The dimension-product separator — in table shapes (C_age : age × age) and typed let shapes (: patch × patch) — now accepts the ASCII * as an exact alias for the Unicode ×. age * age compiles identically to age × age; the separator is purely syntactic (it names the axes), so the choice never affects the IR. This mirrors rate expressions, where × and * are already interchangeable as multiplication.

Migration. None — additive and backward-compatible. Every existing model compiles unchanged. × stays canonical and is recommended in committed models for readability; * is a hand-authoring escape hatch for keyboards without the glyph.

Diagnostic. None — the grammar is strictly more permissive. Previously age * age in dimension position produced E001 (syntax error); it is now accepted.


2026-06-29 — reserved no-overlay scenario sentinel renamed as_fitted → fitted

What. The reserved scenario name for the no-overlay row in camdl fit predict output — the fitted model with no scenario overlay applied, the value carried in the leading scenario column — is renamed from as_fitted to fitted. The name is reserved: a scenarios { } preset may not use it, because it would shadow the no-overlay value and make output rows ambiguous.

Migration. If you parse camdl fit predict output, the no-overlay scenario column value is now fitted (was as_fitted). The reservation moves with the name: a scenarios { } preset may no longer be named fitted (rename it); a preset named as_fitted is no longer reserved.

Diagnostic. A scenarios { } preset named fitted → E291 (“scenario name ‘fitted’ is reserved”), with a hint to rename the scenario.


2026-06-26 — partial dimension omission in a rate read is now an error

What. Indexing a compartment with some but not all of its dimensions in a rate expression is now a hard error. A compartment stratified over 2+ dimensions — e.g. E declared over [age, latent_stage] — referenced as E[a] (dropping latent_stage) has no defined cell: the compiler cannot tell whether you meant a specific stage or their sum. Previously this silently fell through to name-mangling and produced a confusing E100 against a synthetic compartment the user never wrote (undeclared name 'E_adult'), with no source location.

Unchanged: the bare name E (no brackets) still sums over all dimensions, and a full index E[a, e1] still resolves to one cell. Only the partial-index middle case is newly rejected.

Migration. Index every dimension (E[a, e1]), or — to fix one dimension and sum over another — marginalize explicitly:

# old (now E287):  gamma * E[a]
# new:             gamma * sum(s in latent_stage, E[a, s])

Diagnostic. A partial index in a rate read → E287 (“compartment ‘E’ has dimensions [age, latent_stage] but only 1 of 2 were indexed; a partial index has no defined cell”), located at the index node, with a hint giving both the full-index and explicit-sum(...) forms.


2026-06-18 — scope key removed from reactive_interventions {} (gh#204)

What. The scope = exogenous | particle reactive key is removed. A reactive trigger always reads reported surveillance — the realized observation draw, shared across particles. The particle (latent-state) scope was never wired: it parsed but the runtime ignored it, silently behaving as exogenous, so it is withdrawn until latent-scope triggers are actually implemented (at which point the key returns with the inference-safety seam that is its reason to exist). (IR schema 0.17 → 0.18.)

Migration. Delete the scope = ... line — exogenous is the only behavior and is now implicit.

Diagnostic. scope = ... in a reactive policy → E106 (“the scope reactive key was removed … remove it, exogenous is implicit”).


2026-06-18 — reactive_interventions {} block + new reserved words (gh#204)

What. A new top-level block, reactive_interventions {}, declares state/observation-triggered policies whose timing the model discovers at run time (e.g. “run an SIA after AFP detections cross a threshold”):

reactive_interventions {
  mop_up : when sum_observed(weekly_afp, window = 28 'days) >= threshold {
    after    = 21 'days
    action   = transfer(fraction = cov, from = S, to = V)
    once     = false
    cooldown = 180 'days
  }
}

The when predicate is a boolean over the trigger inputs observed(stream) and sum_observed(stream, window = D), combined with and / or / not. Reactive policies lower to the IR’s new fire = Reactive(..) source — internally the intervention schedule field became fire: Scheduled | Reactive, an orthogonal axis from kind (a reactive policy is kind = Scenario, fire = Reactive). Forward chain-binomial runs the agenda; Gillespie/ODE and inference reject an active reactive policy with a REACTIVE_INTERVENTIONS capability error. (IR schema 0.16 → 0.17.)

Migration. Three new reserved words — reactive_interventions, when, action — are added. A model that used one as a compartment / parameter / dimension name must rename it. observed(...) / sum_observed(...) are recognized only inside a when predicate. No other change to existing models (the fire IR reshape is internal; existing .camdl source is unaffected).

Diagnostic. when / action / reactive_interventions as an identifier → E001. observed() / sum_observed() in a rate or other model expression → E278 (“only valid inside a reactive trigger predicate”). Reactive validation: once+cooldown contradiction E276; negative after / cooldown E274; non-comparison when E273; threshold not a constant/parameter E272; stream-arg / window arity E270 / E271. Running a model with an active reactive policy → a REACTIVE_INTERVENTIONS capability error (parsed but not yet executable).

2026-06-16 — log_uniform and truncated_normal prior distributions (gh#155)

What. Two new priors for the ~ dist(...) syntax:

  • ~ log_uniform(lower, upper) — uniform on the log scale (every order of magnitude equally likely); the honest weakly-informative choice for a scale parameter known only to within orders of magnitude, where log_normal overstates knowledge. Requires the parameter’s Log transform.
  • ~ truncated_normal(mean, sd) — a normal truncated to the parameter’s declared in [lo, hi] bounds, which are the single source of truth for the support (exact inverse-CDF sampling, so no draw-time rejection unlike normal(...) + in [..]). The parameter MUST declare in [lo, hi].

Both reject hierarchical/pooled use. Purely additive — no existing model changes. (IR schema 0.15 → 0.16.)

Migration. None required (additive).

Diagnostic. truncated_normal without a declared in [lo, hi] → E285; either new prior used as a hierarchical/pooled prior → E286.

2026-06-16 — positive / real accept an optional unit literal (gh#60)

What. The dimension-under-determined parameter kinds positive and real now accept an optional tier-3 unit literal that supplies the parameter’s dimension, in the same grammar slot a [dim] bracket annotation would use:

parameters {
  tau    : positive 'ratio    in [0.001, 3.0]    # dimensionless (= positive [1])
  iota   : positive 'count                        # a count       (= positive [P])
  importn: positive 'per_year                     # per-time rate (= positive [T^-1])
}

Only the unit’s dimension is read — a parameter’s value is always in the model time_unit, so the scale half is inert ('per_year and 'per_day set the same T⁻¹ dimension). The literal is exact sugar for the matching bracket annotation. This resolves the I300 (“dimension of parameter could not be determined”) a bare positive/real emits in a dimension-determined slot, and makes a dimensional misuse of the parameter a hard error rather than a swallowed info. This is purely additive — every existing positive/real declaration is unchanged. No IR schema change (the literal lowers into the existing param_dim field).

Migration. None required. To type a previously-undetermined positive: replace tau : positive with tau : positive 'ratio (or the appropriate unit).

Diagnostic. A unit literal on a kind whose dimension is already fixed (rate, probability, count, instant, duration) is E281 (“a unit literal is only allowed on the ‘positive’ and ‘real’ kinds”). A unit literal combined with a [dim] bracket on the same declaration is E282.

2026-06-16 — simulate {} gains a tagged integrator (gh#166)

What. The simulate {} block gains an optional tagged integrator selecting the ODE method and (for rk45) its adaptive tolerances:

  • integrator = rk4 — fixed-step classic RK4 (the default; omit integrator entirely for it). Unchanged behaviour.
  • integrator = rk45 { atol = 1e-8 rtol = 1e-6 } — adaptive Dormand–Prince RK4(5) (opt-in; large steps in smooth stretches, small steps only where the trajectory changes fast). atol/rtol are dimensionless (tolerances, not times), optional (omitted → the runtime’s calibrated default), and are keys of the rk45 block — they cannot be written without rk45.

The tolerances live inside the rk45 tag by design: the IR type is Rk4 | Rk45 { atol, rtol }, so an orphan tolerance (atol without rk45, or rk4 with tolerances) is unrepresentable. This is purely additive — every existing model is unaffected (no integrator → rk4). The IR schema version bumped 0.14 → 0.15; old IR deserializes to rk4.

Migration. None required. To opt a model into adaptive stepping:

simulate {
  from = 0 'years
  to   = 40 'years
  integrator = rk45 { atol = 1e-8  rtol = 1e-6 }   # or just `integrator = rk45`
}

CLI. camdl simulate --integrator rk4|rk45 overrides the method for a forward run (method-only; it mutates the model before the run-id is computed, so the choice is recorded in the content hash, and it preserves any model-declared tolerances). There is intentionally no fit --integrator: the integrator is part of the model’s content identity, so on the inference path it is set in the model’s simulate {} block, not on the command line.

Diagnostics.

  • integrator = rk99 → E106 unknown integrator 'rk99': expected rk4 or rk45.
  • integrator = rk4 { atol = 1e-8 } → E106 `integrator = rk4` takes no tolerances (atol/rtol are rk45-only).
  • integrator = rk45 { foo = 1 } → E106 unknown integrator option 'foo'.
  • atol = 1e-8 at the top level (outside the rk45 block) → E106 unknown simulate key 'atol'.
  • atol = 1e-8 'days (any unit) → E106 `atol` must be dimensionless.
  • A dt / integrator key inside a scenario simulate {} block → E106 (whole-model knobs; set them once at the top level).
  • integrator = rk45 on a model that references dt in a rate (Expr::Dt) → rejected at simulation: adaptive stepping has no single fixed dt; use rk4.

2026-06-10 — observation block: ~ measurement, columns {}, from, emit_schedule (gh#171)

What. The observations {} surface was reshaped so it reads like the rest of the language and binds data by name, never positionally:

  • The measurement model is written with ~ (the operator already used for priors): cases ~ neg_binomial(...), replacing likelihood = neg_binomial(...). The left side is a declared value column; the right side is keyword-only.
  • A columns { name : role } block (always required) declares every file column and its role — time, dim, or a value type (count/real/ probability/…). The data file binds to these names; the : time column is the fit time source (no more “column 0 is time”).
  • The stream-header colon is dropped: cases : { … } → cases { … }, with an optional from <source> clause naming the data source a file binds to (--data <source>=file; defaults to the stream name), so several streams can read one wide file.
  • The emission cadence is renamed emit_schedule and is simulate-only (it tells simulate --obs when to emit synthetic rows). It is optional — a fit-only model omits it; fitting reads the data file’s time column.

Migration (old → new):

# OLD
observations {
  cases : {
    projected  = incidence(infection)
    every      = 7 'days
    likelihood = neg_binomial(mean = rho * projected, r = k)
  }
}

# NEW
observations {
  cases {
    columns       { time : time, cases : count }
    projected     = incidence(infection)
    emit_schedule = every 7 'days   # simulate-only; omit for a fit-only model
    cases         ~ neg_binomial(mean = rho * projected, r = k)
  }
}

The projected = … field is unchanged. The ~ RHS does not take the prior’s | dim pooling suffix (stratify the stream header instead).

Diagnostic. error[E273] likelihood = D(...) → <col> ~ D(...); error[E270] stream-header colon removed → name { … }; error[E272] every/schedule → emit_schedule; error[E271] the | dim suffix is a prior construct, not a likelihood one. New-surface coherence: E274 unknown column role, E275 missing/duplicate : time, E276 undeclared scored column, E277 dead value column, E278 [p in dim] ↔︎ : dim mismatch.

2026-06-09 — forcing/table coefficients are live parameters (gh#119)

What. A parameter used inside a forcing coefficient (amplitude = alpha) or an inline-table value (tbl = [k, ...]) is now evaluated live during inference, so it is genuinely estimable — previously it was frozen at its construction-time value (a silent flat likelihood; see the incident 2026-06-09-forcing-coefficient-param-frozen-at-construction.md). Sinusoidal and Fourier coefficients, and constant-indexed parameter tables, also get an analytic gradient, so they are estimable under NUTS as well as IF2/PF.

Two cases are newly constrained:

  • Structural data cannot be a parameter (compile error). Interpolation knots, piecewise step grids, and the periodic-spline basis are precomputed at construction and cannot vary per step, so a parameter driving one of those — or a parameter used as a non-constant table lookup index — is now a compile error (it was a silently-broken zero gradient). Use a constant, or a forcing whose coefficients are live (sinusoidal, fourier, periodic).
  • NUTS-only limitation (no error; the model compiles and runs). A parameter that is a periodic step value or an inline-table value reached by a non-constant index evaluates live — estimable with IF2 or the bootstrap particle filter — but its gradient is not yet emitted, so a NUTS fit that depends on it is refused at fit time (not compile time) with a clear message. Full derivatives are tracked in gh#215.

Migration. No change for the common (now-working) cases. For the structural compile error, make the coefficient constant or switch to a sinusoidal/ fourier/periodic forcing. For the NUTS limitation, estimate with IF2/PF, or express the seasonality as a sinusoidal/fourier forcing (analytic gradient).

Diagnostic. Compile-time error[E600] “parameter ‘…’ drives a … forcing coefficient, which is structural data … cannot be an estimated parameter”, naming the parameter and forcing. The NUTS limitation surfaces at fit time: “NUTS cannot estimate parameter(s) […]: each drives a forcing or inline-table coefficient whose gradient is not yet emitted (gh#215) …”.

2026-06-04 — phantom output {} sub-blocks removed

What. The summary {}, flows {}, synthetic {}, and experiment/compare sub-blocks inside output {} never did anything and were removed; using them is now an error.

Migration. Delete them. Trajectory cadence and format are configured on output {} directly (see camdl docs language); there is no per-quantity sub-block surface.

Diagnostic. error[E106] on the removed sub-block.

2026-05-26 — strict dimensions on likelihood arguments (gh#116)

What. Observation-likelihood arguments with a fixed dimensional contract — Binomial.p, Bernoulli.p, BetaBinomial.alpha/beta, NegBinomial.dispersion — are now strictly checked. A count where a probability/dimensionless value is required (the textbook missing-/N bug) is rejected instead of silently accepted.

Migration. Make the argument dimensionless: binomial(n = N, p = projected) where projected is a count → p = projected / N (a proportion). A projection that is already a proportion (projected = I / N) is fine.

Diagnostic. error[E304] “must be dimensionless (probability); a count here is almost certainly a missing /N.”

2026-04-22 — every forcing requires a unit-kind tag (GH #8)

What. A forcing declaration must carry a unit-kind literal after its type, so the compiler knows whether the forcing is a count, a rate, a ratio, etc. The un-annotated form no longer parses.

Migration.

forcing {
  pop    : interpolated { ... }      →   pop    : interpolated 'count { ... }
  birthrate : interpolated { ... }   →   birthrate : interpolated 'per_year { ... }
  school : periodic { ... }          →   school : periodic 'ratio { ... }
}

Same for sinusoidal/piecewise. Pick the kind from what the forcing is (a population is 'count, a multiplier is 'ratio); see the forcing-kinds taxonomy in camdl docs language.

Diagnostic. error[E001]: syntax error at the forcing type (no migration hint yet — see the policy in CLAUDE.md; this log is the bridge until the diagnostic points here directly).

2026-03-28 — functions {} renamed to forcing {}

What. The block declaring time-varying covariates (population, birth rate, seasonal terms) was renamed from functions {} to forcing {}.

Migration. Rename the block keyword: functions { → forcing {. The contents are unchanged (modulo the unit-kind tag added 2026-04-22, above).

Diagnostic. error[E001]: syntax error on the functions keyword.


This log is seeded with the breaking changes surfaced so far; older or smaller changes may not yet be backfilled. Add an entry (on top) whenever a breaking language change lands — see CLAUDE.md, “Breaking language changes must signpost the migration.”